Skip to content

Commit 0234696

Browse files
authored
Merge pull request #68 from stacknil/stacknil/lgl-07-benchmark-harness
chore(performance): add reproducible benchmark harness
2 parents 6ca9096 + 1af280e commit 0234696

2 files changed

Lines changed: 309 additions & 5 deletions

File tree

docs/performance-envelope.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ CSV export was not enabled.
2424
| Shell | PowerShell 7.5.5 |
2525
| Build | CMake Release build |
2626
| Executable | `build\Release\loglens.exe` |
27+
| Result source | `scripts/benchmark-performance-envelope.ps1` default run |
2728

2829
## Workload Shape
2930

30-
The input corpus was generated locally under `build/performance-envelope/`.
31-
Generated files are not committed.
31+
The input corpus is generated by
32+
[`scripts/benchmark-performance-envelope.ps1`](../scripts/benchmark-performance-envelope.ps1)
33+
under `build/performance-envelope/`. Generated files are not committed.
3234

3335
The synthetic input uses sanitized syslog-style records only:
3436

@@ -52,6 +54,18 @@ real authentication data.
5254

5355
## Method
5456

57+
Reproduction command:
58+
59+
```powershell
60+
pwsh -File scripts/benchmark-performance-envelope.ps1
61+
```
62+
63+
For a quick smoke run:
64+
65+
```powershell
66+
pwsh -File scripts/benchmark-performance-envelope.ps1 -LineCounts 1000 -Runs 1 -WarmupRuns 0 -SkipBuild
67+
```
68+
5569
Command shape:
5670

5771
```powershell
@@ -66,14 +80,16 @@ For each line count:
6680
- peak memory is the maximum observed process working set sampled by the
6781
benchmark harness
6882
- input generation time is excluded
83+
- the harness writes `results.json` and `summary.json` under
84+
`build/performance-envelope/`
6985

7086
## Results
7187

7288
| Input lines | Parsed lines | Parser warnings | Findings | Median elapsed | Elapsed range | Peak working set |
7389
| ---: | ---: | ---: | ---: | ---: | ---: | ---: |
74-
| 1,000 | 750 | 250 | 50 | 44.66 ms | 44.47-64.96 ms | 3.10 MB |
75-
| 10,000 | 7,500 | 2,500 | 50 | 104.01 ms | 91.36-107.15 ms | 13.82 MB |
76-
| 100,000 | 75,000 | 25,000 | 50 | 635.69 ms | 588.39-796.45 ms | 99.77 MB |
90+
| 1,000 | 750 | 250 | 50 | 67.95 ms | 65.45-68.32 ms | 6.00 MB |
91+
| 10,000 | 7,500 | 2,500 | 50 | 98.14 ms | 94.80-231.86 ms | 12.77 MB |
92+
| 100,000 | 75,000 | 25,000 | 50 | 697.10 ms | 686.41-761.98 ms | 99.81 MB |
7793

7894
## Interpretation
7995

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
param(
2+
[int[]]$LineCounts = @(1000, 10000, 100000),
3+
[int]$Runs = 5,
4+
[int]$WarmupRuns = 1,
5+
[string]$BuildDir = "build",
6+
[string]$Configuration = "Release",
7+
[string]$OutputRoot = "build/performance-envelope",
8+
[switch]$SkipBuild
9+
)
10+
11+
$ErrorActionPreference = "Stop"
12+
13+
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
14+
$repoRoot = Split-Path -Parent $scriptRoot
15+
$buildRoot = Join-Path $repoRoot $BuildDir
16+
$benchmarkRoot = Join-Path $repoRoot $OutputRoot
17+
$inputRoot = Join-Path $benchmarkRoot "inputs"
18+
$runRoot = Join-Path $benchmarkRoot "runs"
19+
20+
function Resolve-LogLensExecutable {
21+
$candidateNames = if ($IsWindows -or $env:OS -eq "Windows_NT") {
22+
@("loglens.exe", "loglens")
23+
} else {
24+
@("loglens", "loglens.exe")
25+
}
26+
27+
$candidateDirs = @(
28+
(Join-Path $buildRoot $Configuration),
29+
$buildRoot
30+
)
31+
32+
foreach ($directory in $candidateDirs) {
33+
foreach ($name in $candidateNames) {
34+
$candidate = Join-Path $directory $name
35+
if (Test-Path -LiteralPath $candidate) {
36+
return (Resolve-Path -LiteralPath $candidate).Path
37+
}
38+
}
39+
}
40+
41+
throw "Unable to find a LogLens executable under '$buildRoot'. Build first or pass -BuildDir/-Configuration."
42+
}
43+
44+
function New-BenchmarkInput {
45+
param(
46+
[int]$LineCount,
47+
[string]$Path
48+
)
49+
50+
$start = [DateTime]::new(2026, 3, 10, 0, 0, 0, [DateTimeKind]::Unspecified)
51+
$writer = [System.IO.StreamWriter]::new($Path, $false, [System.Text.UTF8Encoding]::new($false))
52+
53+
try {
54+
for ($index = 0; $index -lt $LineCount; ++$index) {
55+
$timestamp = $start.AddSeconds($index)
56+
$month = $timestamp.ToString("MMM", [Globalization.CultureInfo]::InvariantCulture)
57+
$day = $timestamp.Day
58+
$time = $timestamp.ToString("HH:mm:ss", [Globalization.CultureInfo]::InvariantCulture)
59+
$hostname = "bench-host-{0:D2}" -f (($index % 4) + 1)
60+
$processIdValue = 5000 + ($index % 1000)
61+
$octet = 1 + ($index % 200)
62+
$port = 40000 + ($index % 20000)
63+
$user = "user{0:D3}" -f ($index % 250)
64+
65+
switch ($index % 8) {
66+
0 {
67+
$line = "{0} {1,2} {2} {3} sshd[{4}]: Failed password for {5} from 203.0.113.{6} port {7} ssh2" -f $month, $day, $time, $hostname, $processIdValue, $user, $octet, $port
68+
}
69+
1 {
70+
$line = "{0} {1,2} {2} {3} sshd[{4}]: Accepted publickey for {5} from 203.0.113.{6} port {7} ssh2: ED25519 SHA256:SANITIZEDKEY" -f $month, $day, $time, $hostname, $processIdValue, $user, $octet, $port
71+
}
72+
2 {
73+
$line = "{0} {1,2} {2} {3} sudo[{4}]: {5} : TTY=pts/0 ; PWD=/home/{5} ; USER=root ; COMMAND=/usr/bin/id" -f $month, $day, $time, $hostname, $processIdValue, $user
74+
}
75+
3 {
76+
$line = "{0} {1,2} {2} {3} pam_unix(sshd:auth): authentication failure; user={4} euid=0 tty=ssh rhost=203.0.113.{5}" -f $month, $day, $time, $hostname, $user, $octet
77+
}
78+
4 {
79+
$line = "{0} {1,2} {2} {3} sshd[{4}]: Connection closed by authenticating user {5} 203.0.113.{6} port {7} [preauth]" -f $month, $day, $time, $hostname, $processIdValue, $user, $octet, $port
80+
}
81+
5 {
82+
$line = "{0} {1,2} {2} {3} sshd[{4}]: Timeout, client not responding from 203.0.113.{5} port {6}" -f $month, $day, $time, $hostname, $processIdValue, $octet, $port
83+
}
84+
6 {
85+
$line = "{0} {1,2} {2} {3} pam_unix(sudo:session): session opened for user root by {4}(uid=1000)" -f $month, $day, $time, $hostname, $user
86+
}
87+
default {
88+
$line = "{0} {1,2} {2} {3} su[{4}]: FAILED SU (to root) {5} on pts/1" -f $month, $day, $time, $hostname, $processIdValue, $user
89+
}
90+
}
91+
92+
$writer.WriteLine($line)
93+
}
94+
} finally {
95+
$writer.Dispose()
96+
}
97+
}
98+
99+
function Get-PlatformInfo {
100+
$runtime = [System.Runtime.InteropServices.RuntimeInformation]
101+
$platform = [ordered]@{
102+
os = $runtime::OSDescription
103+
architecture = $runtime::OSArchitecture.ToString()
104+
shell = "PowerShell $($PSVersionTable.PSVersion)"
105+
}
106+
107+
if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) {
108+
try {
109+
$os = Get-CimInstance Win32_OperatingSystem
110+
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
111+
$system = Get-CimInstance Win32_ComputerSystem
112+
$platform.os = "$($os.Caption), version $($os.Version), build $($os.BuildNumber)"
113+
$platform.cpu = $cpu.Name.Trim()
114+
$platform.logical_processors = $cpu.NumberOfLogicalProcessors
115+
$platform.ram_gb = [Math]::Round($system.TotalPhysicalMemory / 1GB, 1)
116+
} catch {
117+
$platform.cim_warning = $_.Exception.Message
118+
}
119+
}
120+
121+
[pscustomobject]$platform
122+
}
123+
124+
function Invoke-LogLensBenchmarkRun {
125+
param(
126+
[string]$Executable,
127+
[int]$LineCount,
128+
[int]$RunNumber,
129+
[bool]$Warmup
130+
)
131+
132+
$inputPath = Join-Path $inputRoot "auth_$LineCount.log"
133+
$runName = if ($Warmup) { "${LineCount}_warmup_${RunNumber}" } else { "${LineCount}_run_${RunNumber}" }
134+
$outputDirectory = Join-Path $runRoot $runName
135+
$stdoutPath = Join-Path $runRoot "$runName.stdout.txt"
136+
$stderrPath = Join-Path $runRoot "$runName.stderr.txt"
137+
138+
Remove-Item -LiteralPath $outputDirectory -Recurse -Force -ErrorAction SilentlyContinue
139+
140+
$processInfo = [System.Diagnostics.ProcessStartInfo]::new()
141+
$processInfo.FileName = $Executable
142+
foreach ($argument in @("--mode", "syslog", "--year", "2026", $inputPath, $outputDirectory)) {
143+
[void]$processInfo.ArgumentList.Add($argument)
144+
}
145+
$processInfo.WorkingDirectory = $repoRoot
146+
$processInfo.UseShellExecute = $false
147+
$processInfo.RedirectStandardOutput = $true
148+
$processInfo.RedirectStandardError = $true
149+
$processInfo.CreateNoWindow = $true
150+
151+
$process = [System.Diagnostics.Process]::new()
152+
$process.StartInfo = $processInfo
153+
$timer = [System.Diagnostics.Stopwatch]::StartNew()
154+
155+
[void]$process.Start()
156+
$maxWorkingSet = 0L
157+
while (-not $process.HasExited) {
158+
try {
159+
$process.Refresh()
160+
if ($process.WorkingSet64 -gt $maxWorkingSet) {
161+
$maxWorkingSet = $process.WorkingSet64
162+
}
163+
} catch {
164+
# The process may exit between HasExited and Refresh.
165+
}
166+
Start-Sleep -Milliseconds 1
167+
}
168+
169+
$timer.Stop()
170+
$stdout = $process.StandardOutput.ReadToEnd()
171+
$stderr = $process.StandardError.ReadToEnd()
172+
Set-Content -LiteralPath $stdoutPath -Value $stdout -Encoding utf8
173+
Set-Content -LiteralPath $stderrPath -Value $stderr -Encoding utf8
174+
175+
try {
176+
$process.Refresh()
177+
if ($process.PeakWorkingSet64 -gt $maxWorkingSet) {
178+
$maxWorkingSet = $process.PeakWorkingSet64
179+
}
180+
} catch {
181+
# PeakWorkingSet64 may not be available on every platform.
182+
}
183+
184+
if ($process.ExitCode -ne 0) {
185+
throw "LogLens benchmark failed for $runName with exit code $($process.ExitCode): $stderr"
186+
}
187+
188+
$report = Get-Content -LiteralPath (Join-Path $outputDirectory "report.json") -Raw | ConvertFrom-Json
189+
190+
[pscustomobject]@{
191+
lines = $LineCount
192+
run = $RunNumber
193+
warmup = $Warmup
194+
elapsed_ms = [Math]::Round($timer.Elapsed.TotalMilliseconds, 3)
195+
peak_working_set_mb = [Math]::Round($maxWorkingSet / 1MB, 3)
196+
parsed_lines = [int]$report.parser_quality.parsed_lines
197+
parser_warnings = [int]$report.warning_count
198+
findings = [int]$report.finding_count
199+
}
200+
}
201+
202+
function Get-Median {
203+
param([double[]]$Values)
204+
205+
$ordered = @($Values | Sort-Object)
206+
if ($ordered.Count -eq 0) {
207+
return 0
208+
}
209+
210+
$middle = [int]($ordered.Count / 2)
211+
if ($ordered.Count % 2 -eq 1) {
212+
return $ordered[$middle]
213+
}
214+
215+
($ordered[$middle - 1] + $ordered[$middle]) / 2
216+
}
217+
218+
function New-Summary {
219+
param([object[]]$MeasuredResults)
220+
221+
$summary = @()
222+
foreach ($group in ($MeasuredResults | Group-Object lines | Sort-Object { [int]$_.Name })) {
223+
$rows = @($group.Group)
224+
$elapsed = [double[]]@($rows.elapsed_ms)
225+
$peaks = [double[]]@($rows.peak_working_set_mb)
226+
$summary += [pscustomobject]@{
227+
lines = [int]$group.Name
228+
runs = $rows.Count
229+
parsed_lines = [int]$rows[0].parsed_lines
230+
parser_warnings = [int]$rows[0].parser_warnings
231+
findings = [int]$rows[0].findings
232+
median_elapsed_ms = [Math]::Round((Get-Median -Values $elapsed), 2)
233+
min_elapsed_ms = [Math]::Round((($elapsed | Measure-Object -Minimum).Minimum), 2)
234+
max_elapsed_ms = [Math]::Round((($elapsed | Measure-Object -Maximum).Maximum), 2)
235+
peak_working_set_mb = [Math]::Round((($peaks | Measure-Object -Maximum).Maximum), 2)
236+
}
237+
}
238+
239+
$summary
240+
}
241+
242+
if (-not $SkipBuild) {
243+
& cmake --build $buildRoot --config $Configuration
244+
if ($LASTEXITCODE -ne 0) {
245+
throw "CMake build failed with exit code $LASTEXITCODE"
246+
}
247+
}
248+
249+
Remove-Item -LiteralPath $benchmarkRoot -Recurse -Force -ErrorAction SilentlyContinue
250+
New-Item -ItemType Directory -Path $inputRoot, $runRoot | Out-Null
251+
252+
foreach ($lineCount in $LineCounts) {
253+
New-BenchmarkInput -LineCount $lineCount -Path (Join-Path $inputRoot "auth_$lineCount.log")
254+
}
255+
256+
$executable = Resolve-LogLensExecutable
257+
$allResults = @()
258+
259+
foreach ($lineCount in $LineCounts) {
260+
for ($warmup = 1; $warmup -le $WarmupRuns; ++$warmup) {
261+
$allResults += Invoke-LogLensBenchmarkRun -Executable $executable -LineCount $lineCount -RunNumber $warmup -Warmup $true
262+
}
263+
for ($run = 1; $run -le $Runs; ++$run) {
264+
$allResults += Invoke-LogLensBenchmarkRun -Executable $executable -LineCount $lineCount -RunNumber $run -Warmup $false
265+
}
266+
}
267+
268+
$measuredResults = @($allResults | Where-Object { -not $_.warmup })
269+
$summary = New-Summary -MeasuredResults $measuredResults
270+
$platform = Get-PlatformInfo
271+
272+
$resultDocument = [pscustomobject]@{
273+
generated_at_utc = (Get-Date).ToUniversalTime().ToString("o")
274+
executable = (Resolve-Path -LiteralPath $executable).Path
275+
line_counts = $LineCounts
276+
measured_runs_per_size = $Runs
277+
warmup_runs_per_size = $WarmupRuns
278+
platform = $platform
279+
summary = $summary
280+
runs = $allResults
281+
}
282+
283+
$resultDocument | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $benchmarkRoot "results.json") -Encoding utf8
284+
$summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $benchmarkRoot "summary.json") -Encoding utf8
285+
286+
$summary | Format-Table -AutoSize
287+
Write-Host ""
288+
Write-Host "Wrote benchmark artifacts to $benchmarkRoot"

0 commit comments

Comments
 (0)