Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions Benchmarks/EventDetection/Test-DetectionBenchmarkBudget.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<#
.SYNOPSIS
Validates full BenchmarkDotNet detection results against checked-in budgets.

.DESCRIPTION
Normalizes the permanent candidate-index and streaming-throughput BenchmarkDotNet
JSON reports into PowerForge summary rows. The gate verifies the complete 4-case
candidate matrix and 12-case throughput matrix before comparing timing and
allocation metrics. Allocation is expressed per event for throughput so scale
changes cannot hide a regression.

.EXAMPLE
.\Test-DetectionBenchmarkBudget.ps1 `
-CandidateResultPath .\BenchmarkDotNet.Artifacts\results\EventViewerX.DetectionBenchmarks.DetectionCandidateIndexBenchmarks-report-full-compressed.json `
-ThroughputResultPath .\BenchmarkDotNet.Artifacts\results\EventViewerX.DetectionBenchmarks.DetectionThroughputBenchmarks-report-full-compressed.json
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $CandidateResultPath,

[Parameter(Mandatory)]
[string] $ThroughputResultPath,

[string] $BaselineRoot = $PSScriptRoot,

[string] $OutputRoot,

[switch] $UpdateBaseline
)

$ErrorActionPreference = 'Stop'
$repositoryRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path
if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
$OutputRoot = Join-Path $repositoryRoot 'Ignore\Benchmarks\EventDetection\Gate'
}
$outputRoot = [IO.Path]::GetFullPath($OutputRoot)
$baselineRoot = [IO.Path]::GetFullPath($BaselineRoot)
$candidateResultPath = (Resolve-Path -LiteralPath $CandidateResultPath).Path
$throughputResultPath = (Resolve-Path -LiteralPath $ThroughputResultPath).Path

function ConvertFrom-BenchmarkParameters {
param([string] $Value)

$result = [ordered] @{}
foreach ($part in @($Value -split '&')) {
if ([string]::IsNullOrWhiteSpace($part)) {
continue
}
$pair = $part.Split('=', 2)
if ($pair.Count -ne 2) {
throw "Benchmark parameter '$part' is not a name=value pair."
}
$result[[Uri]::UnescapeDataString($pair[0])] = [Uri]::UnescapeDataString($pair[1])
}
$result
}

function ConvertTo-DetectionSummaryRow {
param(
[Parameter(Mandatory)] $Benchmark,
[Parameter(Mandatory)] [string] $Scenario,
[Parameter(Mandatory)] [string[]] $RequiredParameters
)

$variables = ConvertFrom-BenchmarkParameters -Value ([string] $Benchmark.Parameters)
foreach ($name in $RequiredParameters) {
if (-not $variables.Contains($name)) {
throw "Benchmark '$($Benchmark.FullName)' is missing required parameter '$name'."
}
}
if ($null -eq $Benchmark.Statistics -or $null -eq $Benchmark.Memory) {
throw "Benchmark '$($Benchmark.FullName)' is missing statistics or memory diagnostics."
}
$eventCount = if ($variables.Contains('EventCount')) {
[long] $variables.EventCount
} else {
1L
}
if ($eventCount -le 0) {
throw "Benchmark '$($Benchmark.FullName)' has an invalid EventCount."
}
$sampleCountProperty = $Benchmark.Statistics.PSObject.Properties['N']
$medianProperty = $Benchmark.Statistics.PSObject.Properties['Median']
$allocationProperty = $Benchmark.Memory.PSObject.Properties['BytesAllocatedPerOperation']
if ($null -eq $sampleCountProperty -or $null -eq $sampleCountProperty.Value -or
$null -eq $medianProperty -or $null -eq $medianProperty.Value) {
throw "Benchmark '$($Benchmark.FullName)' is missing required timing statistics."
}
$sampleCount = [int] $sampleCountProperty.Value
$medianNanoseconds = [double] $medianProperty.Value
if ($sampleCount -le 0 -or
[double]::IsNaN($medianNanoseconds) -or
[double]::IsInfinity($medianNanoseconds) -or
$medianNanoseconds -lt 0) {
throw "Benchmark '$($Benchmark.FullName)' has invalid timing statistics."
}
if ($null -eq $allocationProperty -or $null -eq $allocationProperty.Value) {
throw "Benchmark '$($Benchmark.FullName)' is missing BytesAllocatedPerOperation."
}
$allocated = [double] $allocationProperty.Value
if ([double]::IsNaN($allocated) -or [double]::IsInfinity($allocated) -or $allocated -lt 0) {
throw "Benchmark '$($Benchmark.FullName)' has invalid BytesAllocatedPerOperation."
}
[ordered] @{
suite = 'event-detection'
scenario = $Scenario
operation = [string] $Benchmark.Method
engine = 'EventViewerXDetection'
variables = $variables
sampleCount = $sampleCount
failureCount = 0
status = 'Succeeded'
medianMs = [double] $Benchmark.Statistics.Median / 1000000.0
metrics = [ordered] @{
AllocatedBytes = $allocated
AllocatedBytesPerEvent = $allocated / $eventCount
}
}
}

function Assert-ExactMatrix {
param(
[Parameter(Mandatory)] [object[]] $Rows,
[Parameter(Mandatory)] [string[]] $ExpectedKeys,
[Parameter(Mandatory)] [scriptblock] $Key
)

[string[]] $allKeys = @($Rows | ForEach-Object $Key)
[array] $duplicates = @($allKeys | Group-Object | Where-Object Count -gt 1)
if ($duplicates.Count -gt 0) {
throw "Benchmark matrix contains duplicate parameter tuples: $($duplicates.Name -join ', ')."
}
[string[]] $actual = @($allKeys | Sort-Object)
[string[]] $expected = @($ExpectedKeys | Sort-Object -Unique)
if (($actual -join "`n") -ne ($expected -join "`n")) {
throw "Benchmark matrix mismatch. Expected [$($expected -join ', ')]; actual [$($actual -join ', ')]."
}
}

$candidateDocument = Get-Content -LiteralPath $candidateResultPath -Raw | ConvertFrom-Json
$throughputDocument = Get-Content -LiteralPath $throughputResultPath -Raw | ConvertFrom-Json
[object[]] $candidateRows = @(
$candidateDocument.Benchmarks |
ForEach-Object {
ConvertTo-DetectionSummaryRow -Benchmark $_ -Scenario 'CandidateIndex' -RequiredParameters RuleCount
}
)
[object[]] $throughputRows = @(
$throughputDocument.Benchmarks |
ForEach-Object {
ConvertTo-DetectionSummaryRow -Benchmark $_ -Scenario 'Throughput' -RequiredParameters EventCount,Lane
}
)

Assert-ExactMatrix -Rows $candidateRows -ExpectedKeys @('1', '10', '100', '1000') -Key {
[string] $_.variables.RuleCount
}
$expectedThroughput = foreach ($eventCount in 1000,10000,100000,1000000) {
foreach ($lane in 'StatelessPredicate','ThresholdWindow','OrderedTemporal') {
"$eventCount|$lane"
}
}
Assert-ExactMatrix -Rows $throughputRows -ExpectedKeys $expectedThroughput -Key {
"$($_.variables.EventCount)|$($_.variables.Lane)"
}
if ($UpdateBaseline.IsPresent -and
@($candidateRows + $throughputRows | Where-Object sampleCount -lt 3).Count -gt 0) {
throw 'Updating detection baselines requires at least three measured samples for every benchmark tuple.'
}

[IO.Directory]::CreateDirectory($outputRoot) | Out-Null
$candidateSummaryPath = Join-Path $outputRoot 'candidate-summary.json'
$throughputSummaryPath = Join-Path $outputRoot 'throughput-summary.json'
$candidateRows | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $candidateSummaryPath -Encoding utf8
$throughputRows | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $throughputSummaryPath -Encoding utf8

Import-Module PSPublishModule -MinimumVersion 3.0.134 -Force -ErrorAction Stop
$groupBy = @('Suite', 'Scenario', 'Operation', 'Engine', 'Variables')
$gates = @(
@{
SummaryPath = $candidateSummaryPath
BaselinePath = Join-Path $baselineRoot 'detection-candidate-timing-baseline.json'
Metric = 'MedianMs'
RelativeTolerance = 0.5
AbsoluteToleranceMs = 0.25
}
@{
SummaryPath = $candidateSummaryPath
BaselinePath = Join-Path $baselineRoot 'detection-candidate-allocation-baseline.json'
Metric = 'AllocatedBytes'
RelativeTolerance = 0.1
AbsoluteToleranceMs = 65536
}
@{
SummaryPath = $throughputSummaryPath
BaselinePath = Join-Path $baselineRoot 'detection-throughput-timing-baseline.json'
Metric = 'MedianMs'
RelativeTolerance = 0.5
AbsoluteToleranceMs = 0.25
}
@{
SummaryPath = $throughputSummaryPath
BaselinePath = Join-Path $baselineRoot 'detection-throughput-allocation-baseline.json'
Metric = 'AllocatedBytesPerEvent'
RelativeTolerance = 0.1
AbsoluteToleranceMs = 16
}
)

$results = foreach ($gate in $gates) {
$gate.GroupBy = $groupBy
$gate.Confirm = $false
if ($UpdateBaseline.IsPresent) {
$gate.Update = $true
}
Test-BenchmarkGate @gate
}
$results
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"generatedUtc": "2026-09-20T15:00:19.4630477+00:00",
"metrics": {
"event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=1000|AllocatedBytes": 2030360,
"event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=100|AllocatedBytes": 2030360,
"event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=10|AllocatedBytes": 2030368,
"event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=1|AllocatedBytes": 2030360
}
}
10 changes: 10 additions & 0 deletions Benchmarks/EventDetection/detection-candidate-timing-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"generatedUtc": "2026-09-20T15:00:19.4557095+00:00",
"metrics": {
"event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=1000|MedianMs": 0.670431640625,
"event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=100|MedianMs": 0.64148505859375,
"event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=10|MedianMs": 0.757770751953125,
"event-detection|CandidateIndex|EvaluateIndexedCandidates|EventViewerXDetection|RuleCount=1|MedianMs": 0.5335905029296875
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"generatedUtc": "2026-09-20T15:00:19.4647027+00:00",
"metrics": {
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=OrderedTemporal|AllocatedBytesPerEvent": 160.003656,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=StatelessPredicate|AllocatedBytesPerEvent": 104.0032,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=ThresholdWindow|AllocatedBytesPerEvent": 176.78412,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=OrderedTemporal|AllocatedBytesPerEvent": 160.03656,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=StatelessPredicate|AllocatedBytesPerEvent": 104.032,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=ThresholdWindow|AllocatedBytesPerEvent": 181.01386,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=OrderedTemporal|AllocatedBytesPerEvent": 160.3656,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=StatelessPredicate|AllocatedBytesPerEvent": 104.32,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=ThresholdWindow|AllocatedBytesPerEvent": 186.5887,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=OrderedTemporal|AllocatedBytesPerEvent": 163.656,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=StatelessPredicate|AllocatedBytesPerEvent": 107.2,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=ThresholdWindow|AllocatedBytesPerEvent": 179.992
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"generatedUtc": "2026-09-20T15:00:19.4639571+00:00",
"metrics": {
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=OrderedTemporal|MedianMs": 107.52748333333332,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=StatelessPredicate|MedianMs": 91.15102222222222,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000000;Lane=ThresholdWindow|MedianMs": 482.3738,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=OrderedTemporal|MedianMs": 10.629079296875,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=StatelessPredicate|MedianMs": 8.4470671875,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=100000;Lane=ThresholdWindow|MedianMs": 47.57053636363636,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=OrderedTemporal|MedianMs": 1.0334109375,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=StatelessPredicate|MedianMs": 0.8798130859375,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=10000;Lane=ThresholdWindow|MedianMs": 4.65337578125,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=OrderedTemporal|MedianMs": 0.11401185913085937,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=StatelessPredicate|MedianMs": 0.0965123291015625,
"event-detection|Throughput|StreamDetection|EventViewerXDetection|EventCount=1000;Lane=ThresholdWindow|MedianMs": 0.459223828125
}
}
46 changes: 45 additions & 1 deletion Benchmarks/EventLogParsing/Invoke-EventLogParsingBenchmark.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ param(

[string] $OutputRoot,

[string] $BaselinePath,

[switch] $UpdateBaseline,

[ValidateRange(0, 10)]
[double] $RelativeTolerance = 0.5,

[ValidateRange(0, [double]::MaxValue)]
[double] $AbsoluteToleranceMs = 500,

[ValidateRange(0, [int]::MaxValue)]
[int] $WarmupCount = 0,

Expand All @@ -84,6 +94,10 @@ $specPath = Join-Path $PSScriptRoot 'event-log-parsing.benchmark.ps1'

Import-Module PSPublishModule -MinimumVersion 3.0.134 -ErrorAction Stop

if ($UpdateBaseline.IsPresent -and $IterationCount -lt 3) {
throw 'Updating a performance baseline requires at least three measured iterations.'
}

if ([bool] $EvtxECmdPath -ne [bool] $EvtxMapsPath) {
throw 'EvtxECmdPath and EvtxMapsPath must be supplied together.'
}
Expand Down Expand Up @@ -149,6 +163,15 @@ if ($ReadmeTable -eq 'Reporting') {
$Case = 'Typed-Report-Html', 'Typed-Report-Excel', 'Typed-Report-Email', 'Typed-Report-All'
$Engine = 'EventViewerXReport'
}
$typedFixtureFullPath = $null
$typedFixtureSha256 = $null
if ($TypedFixturePath) {
$typedFixtureFullPath = [IO.Path]::GetFullPath($TypedFixturePath)
if (-not (Test-Path -LiteralPath $typedFixtureFullPath -PathType Leaf)) {
throw "The typed fixture '$typedFixtureFullPath' does not exist."
}
$typedFixtureSha256 = (Get-FileHash -LiteralPath $typedFixtureFullPath -Algorithm SHA256).Hash
}
if ($ReadmeTable -eq 'ColdStart') {
if ($Case -or $Engine) {
throw 'ReadmeTable ColdStart owns its curated Case and Engine matrix. Do not combine it with Case or Engine.'
Expand Down Expand Up @@ -192,7 +215,8 @@ if ($TypedFixturePath) {
if ($ExpectedTypedCount -le 0) {
throw 'TypedFixturePath requires a positive ExpectedTypedCount.'
}
$variables.TypedFixturePath = [IO.Path]::GetFullPath($TypedFixturePath)
$variables.TypedFixturePath = $typedFixtureFullPath
$variables.TypedFixtureSha256 = $typedFixtureSha256
$variables.ExpectedTypedCount = $ExpectedTypedCount
$variables.TypedEventTypes = $TypedEventTypes
}
Expand Down Expand Up @@ -261,6 +285,24 @@ if (-not $Plan) {
throw "The benchmark completed with $($failedSamples.Count) failed sample(s):`n$($failureSummary -join "`n")"
}

if ($BaselinePath) {
$gate = @{
SummaryPath = [string] $benchmarkResult.Artifacts['summary.json']
BaselinePath = [IO.Path]::GetFullPath($BaselinePath)
Metric = 'MedianMs'
GroupBy = @('Suite', 'Scenario', 'Operation', 'Engine', 'Variables')
Comment thread
PrzemyslawKlys marked this conversation as resolved.
RelativeTolerance = $RelativeTolerance
AbsoluteToleranceMs = $AbsoluteToleranceMs
Confirm = $false
}
if ($UpdateBaseline.IsPresent) {
$gate.Update = $true
Comment thread
PrzemyslawKlys marked this conversation as resolved.
}
Test-BenchmarkGate @gate | Out-Null
} elseif ($UpdateBaseline.IsPresent) {
throw 'UpdateBaseline requires BaselinePath.'
}

$readmePath = Join-Path $PSScriptRoot 'README.md'
if ($ReadmeTable -in 'Scale', 'ColdStart', 'Reporting') {
$readmePath = Join-Path $repositoryRoot 'README.md'
Expand Down Expand Up @@ -308,6 +350,8 @@ if (-not $Plan) {
-Renderer ComparisonTable `
-Confirm:$false | Out-Null
}
} elseif ($UpdateBaseline.IsPresent) {
throw 'A benchmark plan cannot update a performance baseline.'
}

$benchmarkResult
5 changes: 5 additions & 0 deletions Benchmarks/EventLogParsing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ operation in a retained `output-validation.json` sidecar. After validation succe
deleted so repeated large-log samples do not consume unbounded disk space. A failed lane keeps its output for
diagnosis.

Typed benchmark baseline keys include the wrapper-calculated fixture SHA-256,
the expected typed count, and the effective report sample count for each case.
This prevents a valid budget from being reused for different EVTX bytes or a
smaller reporting window.

PowerForge records:

- end-to-end duration and engine-reported duration;
Expand Down
Loading
Loading