[CmdletBinding()] param( [string]$DataRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'target\mhash-benchmark\data'), [string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'target\mhash-benchmark\results'), [string[]]$Sizes = @('1MiB', '64MiB', '256MiB'), [ValidateSet( 'crc32', 'crc-32', 'crc64-xz', 'crc-64-xz', 'crc64', 'xxh32', 'xxhash32', 'xxhash-32', 'xxh64', 'xxhash64', 'xxhash-64', 'xxh3', 'xxh3-64', 'xxhash3-64', 'xxh3-128', 'xxhash3-128', 'md4', 'md5', 'ripemd160', 'ripemd-160', 'blake2sp', 'blake2-sp', 'sha1', 'sha-1', 'sha224', 'sha-224', 'sha256', 'sha-256', 'sha384', 'sha-384', 'sha512', 'sha-512', 'sha3-224', 'sha3_224', 'sha3-256', 'sha3_256', 'sha3-384', 'sha3_384', 'sha3-512', 'sha3_512', 'blake3', 'blake3-256', 'blake3-512', 'kangarootwelve-264', 'k12-264', 'kangarootwelve-256', 'k12-256', 'kangarootwelve', 'kangarootwelve-512', 'k12-512', 'parallelhash128-264', 'parallelhash-128-264', 'parallelhash256-528', 'parallelhash-256-528', 'streebog-256', 'streebog256', 'gost-256', 'streebog-512', 'streebog512', 'gost-512' )] [string[]]$Algorithms = @('md5', 'sha1', 'sha256', 'sha512'), [ValidateSet('text', 'sum', 'json', 'jsonl')] [string[]]$DigestFormats = @('sum'), [int]$Repeat = 3, [int]$Warmup = 1, [string]$DigestPath, [switch]$SkipBuild, [switch]$NoExternal, [switch]$Extended, [switch]$SizeOnly, [switch]$Hyperfine, [int]$HyperfineRuns = 0, [int]$HyperfineWarmup = -1 ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest if ($Extended) { if (-not $PSBoundParameters.ContainsKey('Sizes')) { $Sizes = @('1MiB', '64MiB', '256MiB', '1GiB') } if (-not $PSBoundParameters.ContainsKey('Repeat')) { $Repeat = 5 } if (-not $PSBoundParameters.ContainsKey('Warmup')) { $Warmup = 2 } if (-not $PSBoundParameters.ContainsKey('DigestFormats')) { $DigestFormats = @('sum', 'jsonl', 'json') } } if ($Repeat -lt 0) { throw 'Repeat must be zero or greater.' } if (($Repeat -eq 0) -and (-not $SizeOnly)) { throw 'Repeat must be at least 1 unless -SizeOnly is set.' } if ($Warmup -lt 0) { throw 'Warmup must be zero or greater.' } if ($HyperfineRuns -lt 0) { throw 'HyperfineRuns must be zero or greater. Use 0 to mirror -Repeat.' } if ($HyperfineWarmup -lt -1) { throw 'HyperfineWarmup must be -1 or greater. Use -1 to mirror -Warmup.' } if ($HyperfineRuns -eq 0) { $HyperfineRuns = [Math]::Max(1, $Repeat) } if ($HyperfineWarmup -eq -1) { $HyperfineWarmup = $Warmup } function Invoke-StrictNative { param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string[]]$ArgumentList ) & $FilePath @ArgumentList if ($LASTEXITCODE -ne 0) { throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($ArgumentList -join ' ')" } } function ConvertTo-ByteCount { param( [Parameter(Mandatory = $true)] [string]$Value ) if ($Value -notmatch '^(?\d+)(?B|KiB|MiB|GiB|K|M|G)?$') { throw "Invalid size '$Value'. Use values like 1MiB, 64MiB, or 1GiB." } $number = [int64]$Matches.number $unit = if ($Matches.unit) { $Matches.unit } else { 'B' } switch ($unit) { 'B' { return $number } { $_ -in @('K', 'KiB') } { return $number * 1024L } { $_ -in @('M', 'MiB') } { return $number * 1024L * 1024L } { $_ -in @('G', 'GiB') } { return $number * 1024L * 1024L * 1024L } default { throw "Unsupported size unit '$unit'." } } } function Format-ByteSize { param( [Parameter(Mandatory = $true)] [int64]$Bytes ) if ($Bytes -ge 1GB) { return ('{0:N0}GiB' -f ($Bytes / 1GB)) } if ($Bytes -ge 1MB) { return ('{0:N0}MiB' -f ($Bytes / 1MB)) } if ($Bytes -ge 1KB) { return ('{0:N0}KiB' -f ($Bytes / 1KB)) } return "${Bytes}B" } function ConvertTo-FileSha256 { param( [Parameter(Mandatory = $true)] [string]$Path ) try { return (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() } catch { return $null } } function ConvertTo-JsonLines { param( [object[]]$InputObject = @(), [int]$Depth = 8 ) foreach ($item in $InputObject) { $item | ConvertTo-Json -Depth $Depth -Compress } } function Write-JsonLines { param( [Parameter(Mandatory = $true)] [string]$Path, [object[]]$InputObject = @(), [int]$Depth = 8 ) $lines = @(ConvertTo-JsonLines -InputObject $InputObject -Depth $Depth) if ($lines.Count -eq 0) { [System.IO.File]::WriteAllText($Path, '', [System.Text.UTF8Encoding]::new($false)) return } Set-Content -LiteralPath $Path -Value $lines -Encoding utf8NoBOM } function ConvertTo-QuotedArgument { param( [Parameter(Mandatory = $true)] [string]$Argument ) if ($Argument -match '^[A-Za-z0-9_./:\\-]+$') { return $Argument } return '"' + ($Argument -replace '"', '\"') + '"' } function ConvertTo-NativeCommandLine { param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string[]]$ArgumentList ) $parts = @($FilePath) + $ArgumentList return (($parts | ForEach-Object { ConvertTo-QuotedArgument -Argument $_ }) -join ' ') } function New-DeterministicFile { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [int64]$Bytes ) if ((Test-Path -LiteralPath $Path -PathType Leaf) -and ((Get-Item -LiteralPath $Path).Length -eq $Bytes)) { return } $directory = Split-Path -Parent $Path if (-not [string]::IsNullOrWhiteSpace($directory)) { New-Item -ItemType Directory -Force -Path $directory | Out-Null } $buffer = [byte[]]::new(1MB) for ($index = 0; $index -lt $buffer.Length; $index++) { $buffer[$index] = [byte](($index * 31 + 17) -band 0xff) } $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read) try { $remaining = $Bytes while ($remaining -gt 0) { $write = [int][Math]::Min($buffer.Length, $remaining) $stream.Write($buffer, 0, $write) $remaining -= $write } } finally { $stream.Dispose() } } function Resolve-CommandPath { param( [Parameter(Mandatory = $true)] [string]$Name ) $command = Get-Command -Name $Name -ErrorAction SilentlyContinue if ($null -eq $command) { return $null } return $command.Source } function Resolve-CoreutilsCommandPath { param( [Parameter(Mandatory = $true)] [string]$Name ) $source = Resolve-CommandPath -Name $Name if ([string]::IsNullOrWhiteSpace($source)) { return $null } $candidate = Join-Path $env:USERPROFILE "scoop\apps\uutils-coreutils\current\$Name.exe" if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } return $source } function Get-ToolVersion { param( [Parameter(Mandatory = $true)] [string]$Name, [Parameter(Mandatory = $true)] [string]$Path, [string[]]$VersionArguments = @('--version') ) try { $output = @(& $Path @VersionArguments 2>&1) $firstLine = @($output | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Select-Object -First 1) if ($firstLine.Count -gt 0) { $candidate = [string]$firstLine[0] if ($candidate.IndexOf([char]0xfffd) -lt 0) { return $candidate } } } catch { } try { $item = Get-Item -LiteralPath $Path -ErrorAction Stop if (-not [string]::IsNullOrWhiteSpace($item.VersionInfo.ProductVersion)) { return "$Name $($item.VersionInfo.ProductVersion)" } if (-not [string]::IsNullOrWhiteSpace($item.VersionInfo.FileVersion)) { return "$Name $($item.VersionInfo.FileVersion)" } } catch { } return $Name } function Get-BinaryArtifactInfo { param( [Parameter(Mandatory = $true)] [string]$Tool, [Parameter(Mandatory = $true)] [string]$Path, [string]$Version = '' ) $fullPath = [System.IO.Path]::GetFullPath($Path) if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { return [pscustomobject]@{ tool = $Tool path = $fullPath exists = $false size_bytes = $null size_label = '' sha256 = '' last_write_utc = '' product_version = '' file_version = '' version = $Version } } $item = Get-Item -LiteralPath $fullPath [pscustomobject]@{ tool = $Tool path = $fullPath exists = $true size_bytes = [int64]$item.Length size_label = Format-ByteSize -Bytes ([int64]$item.Length) sha256 = ConvertTo-FileSha256 -Path $fullPath last_write_utc = $item.LastWriteTimeUtc.ToString('o') product_version = if ($item.VersionInfo) { [string]$item.VersionInfo.ProductVersion } else { '' } file_version = if ($item.VersionInfo) { [string]$item.VersionInfo.FileVersion } else { '' } version = $Version } } function Get-GitMetadata { param( [Parameter(Mandatory = $true)] [string]$Root ) $git = Resolve-CommandPath -Name 'git' if ([string]::IsNullOrWhiteSpace($git)) { return [pscustomobject]@{ available = $false commit = '' branch = '' status_short = @() } } Push-Location -LiteralPath $Root try { $commit = (& $git rev-parse --short HEAD 2>$null) $branch = (& $git branch --show-current 2>$null) $status = @(& $git status --short 2>$null) return [pscustomobject]@{ available = $true commit = if ($LASTEXITCODE -eq 0) { [string]$commit } else { '' } branch = [string]$branch status_short = $status } } catch { return [pscustomobject]@{ available = $true commit = '' branch = '' status_short = @("git metadata failed: $($_.Exception.Message)") } } finally { Pop-Location } } function New-Candidate { param( [Parameter(Mandatory = $true)] [string]$Tool, [Parameter(Mandatory = $true)] [string]$Algorithm, [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string[]]$Arguments, [string]$OutputFormat = 'native', [string]$Version = '' ) [pscustomobject]@{ Tool = $Tool Algorithm = $Algorithm FilePath = $FilePath Arguments = $Arguments OutputFormat = $OutputFormat Version = $Version } } function New-CandidatesForAlgorithm { param( [Parameter(Mandatory = $true)] [string]$Algorithm, [Parameter(Mandatory = $true)] [string]$InputPath, [Parameter(Mandatory = $true)] [string]$DigestExe, [Parameter(Mandatory = $true)] [hashtable]$Tools, [Parameter(Mandatory = $true)] [string[]]$DigestFormats, [switch]$NoExternal ) $candidates = [System.Collections.Generic.List[object]]::new() foreach ($format in $DigestFormats) { $candidates.Add((New-Candidate -Tool 'mercury-mhash:auto' -Algorithm $Algorithm -FilePath $DigestExe -Arguments @('--algorithm', $Algorithm, '--format', $format, $InputPath) -OutputFormat $format -Version $Tools.mhash_version)) $candidates.Add((New-Candidate -Tool 'mercury-mhash:read' -Algorithm $Algorithm -FilePath $DigestExe -Arguments @('--algorithm', $Algorithm, '--format', $format, '--io', 'read', $InputPath) -OutputFormat $format -Version $Tools.mhash_version)) } if (-not $NoExternal) { if ($Tools.pwsh) { $psAlgorithm = @{ md5 = 'MD5' sha1 = 'SHA1' sha256 = 'SHA256' sha512 = 'SHA512' }[$Algorithm] if ($psAlgorithm) { $escapedInputPath = $InputPath.Replace("'", "''") $script = "Get-FileHash -LiteralPath '$escapedInputPath' -Algorithm $psAlgorithm | Select-Object -ExpandProperty Hash" $candidates.Add((New-Candidate -Tool 'powershell:Get-FileHash' -Algorithm $Algorithm -FilePath $Tools.pwsh -Arguments @('-NoProfile', '-Command', $script) -Version $Tools.pwsh_version)) } } if ($Tools.certutil) { $certAlgorithm = @{ md5 = 'MD5' sha1 = 'SHA1' sha256 = 'SHA256' sha512 = 'SHA512' }[$Algorithm] if ($certAlgorithm) { $candidates.Add((New-Candidate -Tool 'windows:certutil' -Algorithm $Algorithm -FilePath $Tools.certutil -Arguments @('-hashfile', $InputPath, $certAlgorithm) -Version $Tools.certutil_version)) } } $sumPath = $Tools["${Algorithm}sum"] if ($sumPath) { $candidates.Add((New-Candidate -Tool "coreutils:${Algorithm}sum" -Algorithm $Algorithm -FilePath $sumPath -Arguments @($InputPath) -Version $Tools["${Algorithm}sum_version"])) } if ($Tools.openssl) { $opensslAlgorithm = @{ md5 = 'md5' sha1 = 'sha1' sha256 = 'sha256' sha512 = 'sha512' }[$Algorithm] if ($opensslAlgorithm) { $candidates.Add((New-Candidate -Tool 'openssl:dgst' -Algorithm $Algorithm -FilePath $Tools.openssl -Arguments @('dgst', "-$opensslAlgorithm", $InputPath) -Version $Tools.openssl_version)) } } } return $candidates } function Invoke-MeasuredProcess { param( [Parameter(Mandatory = $true)] [object]$Candidate ) $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $Candidate.FilePath foreach ($argument in $Candidate.Arguments) { [void]$startInfo.ArgumentList.Add($argument) } $startInfo.UseShellExecute = $false $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.CreateNoWindow = $true $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo $timer = [System.Diagnostics.Stopwatch]::StartNew() $started = $process.Start() if (-not $started) { throw "Failed to start $($Candidate.Tool)." } $peakWorkingSet = 0L try { $process.Refresh() $peakWorkingSet = [Math]::Max($peakWorkingSet, [int64]$process.WorkingSet64) } catch { } while (-not $process.WaitForExit(5)) { try { $process.Refresh() $peakWorkingSet = [Math]::Max($peakWorkingSet, [int64]$process.WorkingSet64) } catch { } } $process.WaitForExit() $timer.Stop() $stdout = $process.StandardOutput.ReadToEnd() $stderr = $process.StandardError.ReadToEnd() try { $process.Refresh() $peakWorkingSet = [Math]::Max($peakWorkingSet, [int64]$process.PeakWorkingSet64) } catch { } $elapsedMs = $timer.Elapsed.TotalMilliseconds $cpuMs = $process.TotalProcessorTime.TotalMilliseconds $exitCode = $process.ExitCode $process.Dispose() $cpuOneCorePercent = if ($elapsedMs -gt 0) { ($cpuMs / $elapsedMs) * 100.0 } else { 0.0 } $cpuMachinePercent = if ([Environment]::ProcessorCount -gt 0) { $cpuOneCorePercent / [Environment]::ProcessorCount } else { 0.0 } [pscustomobject]@{ elapsed_ms = $elapsedMs cpu_ms = $cpuMs cpu_one_core_percent = $cpuOneCorePercent cpu_machine_percent = $cpuMachinePercent peak_working_set_bytes = $peakWorkingSet exit_code = $exitCode stdout = $stdout.Trim() stderr = $stderr.Trim() } } function Invoke-HyperfineSuite { param( [Parameter(Mandatory = $true)] [string]$HyperfinePath, [Parameter(Mandatory = $true)] [object]$File, [Parameter(Mandatory = $true)] [string]$Algorithm, [Parameter(Mandatory = $true)] [object[]]$Candidates, [Parameter(Mandatory = $true)] [string]$OutputPath, [Parameter(Mandatory = $true)] [int]$Runs, [Parameter(Mandatory = $true)] [int]$Warmup ) $arguments = @( '--style', 'basic', '--shell', 'none', '--output', 'pipe', '--warmup', [string]$Warmup, '--runs', [string]$Runs, '--export-json', $OutputPath ) foreach ($candidate in $Candidates) { $name = '{0}/{1}/{2}/{3}' -f $candidate.Tool, $candidate.OutputFormat, $Algorithm, $File.label $commandLine = ConvertTo-NativeCommandLine -FilePath $candidate.FilePath -ArgumentList $candidate.Arguments $arguments += @('--command-name', $name, $commandLine) } $started = (Get-Date).ToUniversalTime().ToString('o') $output = @(& $HyperfinePath @arguments 2>&1) $exitCode = $LASTEXITCODE $finished = (Get-Date).ToUniversalTime().ToString('o') $parsed = $null if (Test-Path -LiteralPath $OutputPath -PathType Leaf) { try { $parsed = Get-Content -LiteralPath $OutputPath -Raw | ConvertFrom-Json } catch { Write-Warning "Failed to parse hyperfine JSON ${OutputPath}: $($_.Exception.Message)" } } [pscustomobject]@{ timestamp_utc = $started finished_utc = $finished size_label = $File.label bytes = $File.bytes algorithm = $Algorithm runs = $Runs warmup = $Warmup export_path = $OutputPath exit_code = $exitCode stdout_sample = (($output | Select-Object -First 20) -join [Environment]::NewLine) stderr_sample = '' results = if ($null -ne $parsed) { $parsed.results } else { @() } } } function ConvertTo-Summary { param( [object[]]$Records = @() ) $successful = @($Records | Where-Object exit_code -eq 0) $successful | Group-Object size_label, algorithm, tool, output_format | ForEach-Object { $rows = @($_.Group) $first = $rows[0] $elapsed = @($rows | ForEach-Object { [double]$_.elapsed_ms }) $cpuMs = @($rows | ForEach-Object { [double]$_.cpu_ms }) $cpu = @($rows | ForEach-Object { [double]$_.cpu_one_core_percent }) $memory = @($rows | ForEach-Object { [double]$_.peak_working_set_bytes }) $bytes = [double]$first.bytes $meanElapsed = ($elapsed | Measure-Object -Average).Average $minElapsed = ($elapsed | Measure-Object -Minimum).Minimum $meanCpuMs = ($cpuMs | Measure-Object -Average).Average $maxMemory = ($memory | Measure-Object -Maximum).Maximum $meanCpu = ($cpu | Measure-Object -Average).Average $throughput = if ($meanElapsed -gt 0) { ($bytes / 1MB) / ($meanElapsed / 1000.0) } else { 0.0 } $binarySizeBytes = if ($null -ne $first.binary_size_bytes) { [int64]$first.binary_size_bytes } else { 0L } [pscustomobject]@{ size_label = $first.size_label bytes = [int64]$first.bytes algorithm = $first.algorithm tool = $first.tool output_format = $first.output_format runs = $rows.Count mean_ms = [Math]::Round($meanElapsed, 3) min_ms = [Math]::Round($minElapsed, 3) mean_cpu_ms = [Math]::Round($meanCpuMs, 3) throughput_mib_s = [Math]::Round($throughput, 2) peak_working_set_mib = [Math]::Round($maxMemory / 1MB, 2) mean_cpu_one_core_percent = [Math]::Round($meanCpu, 1) binary_size_bytes = $binarySizeBytes binary_size_label = if ($binarySizeBytes -gt 0) { Format-ByteSize -Bytes $binarySizeBytes } else { '' } version = $first.version } } | Sort-Object algorithm, bytes, output_format, mean_ms, tool } function Write-MarkdownReport { param( [Parameter(Mandatory = $true)] [string]$Path, [object[]]$Summary = @(), [Parameter(Mandatory = $true)] [object]$Environment, [object[]]$Artifacts = @(), [object[]]$HyperfineRows = @() ) $builder = [System.Text.StringBuilder]::new() [void]$builder.AppendLine('# Digest Benchmark Report') [void]$builder.AppendLine() [void]$builder.AppendLine('This report is produced by `scripts/benchmark-mhash.ps1`. It measures warm-cache CLI hashing throughput and process telemetry. Windows does not provide a safe non-admin cache-drop primitive, so disk-cold numbers are intentionally out of scope.') [void]$builder.AppendLine() [void]$builder.AppendLine('## Environment') [void]$builder.AppendLine() [void]$builder.AppendLine(('- Mode: `{0}`' -f $Environment.mode)) [void]$builder.AppendLine(('- Timestamp: `{0}`' -f $Environment.timestamp_utc)) [void]$builder.AppendLine(('- Machine: `{0}`' -f $Environment.machine_name)) [void]$builder.AppendLine(('- OS: `{0}`' -f $Environment.os)) [void]$builder.AppendLine(('- Process architecture: `{0}`' -f $Environment.process_architecture)) [void]$builder.AppendLine(('- Processor count: `{0}`' -f $Environment.processor_count)) [void]$builder.AppendLine(('- PowerShell: `{0}`' -f $Environment.powershell_version)) [void]$builder.AppendLine(('- Git: `{0}` `{1}`' -f $Environment.git.branch, $Environment.git.commit)) [void]$builder.AppendLine(('- Git status entries: `{0}`' -f @($Environment.git.status_short).Count)) [void]$builder.AppendLine(('- Sizes: `{0}`' -f ($Environment.sizes -join ', '))) [void]$builder.AppendLine(('- Algorithms: `{0}`' -f ($Environment.algorithms -join ', '))) [void]$builder.AppendLine(('- Digest formats: `{0}`' -f ($Environment.mhash_formats -join ', '))) [void]$builder.AppendLine(('- Repeat: `{0}`' -f $Environment.repeat)) [void]$builder.AppendLine(('- Warmup: `{0}`' -f $Environment.warmup)) [void]$builder.AppendLine(('- External tools: `{0}`' -f (-not $Environment.no_external))) [void]$builder.AppendLine(('- Hyperfine requested: `{0}`' -f $Environment.hyperfine_requested)) [void]$builder.AppendLine(('- Data root: `{0}`' -f $Environment.data_root)) [void]$builder.AppendLine(('- Output root: `{0}`' -f $Environment.output_root)) [void]$builder.AppendLine() [void]$builder.AppendLine('## Tool Artifacts') [void]$builder.AppendLine() if ($Artifacts.Count -eq 0) { [void]$builder.AppendLine('No tool artifacts were discovered.') } else { [void]$builder.AppendLine('| Tool | Size | SHA-256 | Path | Version |') [void]$builder.AppendLine('|---|---:|---|---|---|') foreach ($artifact in ($Artifacts | Sort-Object tool, path)) { $sha = if (-not [string]::IsNullOrWhiteSpace($artifact.sha256)) { $artifact.sha256.Substring(0, [Math]::Min(16, $artifact.sha256.Length)) } else { '' } [void]$builder.AppendLine(('| {0} | {1} | `{2}` | `{3}` | {4} |' -f $artifact.tool, $artifact.size_label, $sha, $artifact.path, $artifact.version)) } } [void]$builder.AppendLine() [void]$builder.AppendLine('## Summary') [void]$builder.AppendLine() if ($Summary.Count -eq 0) { [void]$builder.AppendLine('No process benchmark records were emitted. This is expected for `-SizeOnly` runs.') } else { [void]$builder.AppendLine('| Algorithm | Size | Tool | Format | Mean ms | Min ms | CPU ms | MiB/s | Peak WS MiB | CPU % of one core | Binary |') [void]$builder.AppendLine('|---|---:|---|---|---:|---:|---:|---:|---:|---:|---:|') foreach ($row in $Summary) { [void]$builder.AppendLine(('| {0} | {1} | {2} | {3} | {4:N3} | {5:N3} | {6:N3} | {7:N2} | {8:N2} | {9:N1} | {10} |' -f $row.algorithm, $row.size_label, $row.tool, $row.output_format, $row.mean_ms, $row.min_ms, $row.mean_cpu_ms, $row.throughput_mib_s, $row.peak_working_set_mib, $row.mean_cpu_one_core_percent, $row.binary_size_label)) } } [void]$builder.AppendLine() [void]$builder.AppendLine('## Hyperfine') [void]$builder.AppendLine() if ($HyperfineRows.Count -eq 0) { if ($Environment.hyperfine_requested) { [void]$builder.AppendLine('Hyperfine was requested, but no hyperfine summary rows were produced. Check the raw hyperfine output for command failures.') } else { [void]$builder.AppendLine('Hyperfine was not requested. Pass `-Hyperfine` to add hyperfine timing exports when `hyperfine` is installed.') } } else { [void]$builder.AppendLine('| Algorithm | Size | Command | Mean ms | Stddev ms | Min ms | Max ms | Runs |') [void]$builder.AppendLine('|---|---:|---|---:|---:|---:|---:|---:|') foreach ($row in $HyperfineRows) { [void]$builder.AppendLine(('| {0} | {1} | {2} | {3:N3} | {4:N3} | {5:N3} | {6:N3} | {7} |' -f $row.algorithm, $row.size_label, $row.command, $row.mean_ms, $row.stddev_ms, $row.min_ms, $row.max_ms, $row.runs)) } } [void]$builder.AppendLine() [void]$builder.AppendLine('Raw JSON, JSONL, CSV, artifact, and optional hyperfine files are emitted beside this report for deeper analysis.') Set-Content -LiteralPath $Path -Value $builder.ToString() -Encoding utf8NoBOM } $workspaceRoot = Split-Path -Parent $PSScriptRoot $outputRootFull = [System.IO.Path]::GetFullPath($OutputRoot) $dataRootFull = [System.IO.Path]::GetFullPath($DataRoot) New-Item -ItemType Directory -Force -Path $outputRootFull, $dataRootFull | Out-Null $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $mode = if ($SizeOnly) { 'size-only' } elseif ($Extended) { 'extended' } else { 'default' } if ([string]::IsNullOrWhiteSpace($DigestPath)) { $DigestPath = Join-Path $workspaceRoot 'target\release-fast\mhash.exe' } $DigestPath = [System.IO.Path]::GetFullPath($DigestPath) if (-not $SkipBuild) { Invoke-StrictNative -FilePath 'cargo' -ArgumentList @('build', '--profile', 'release-fast', '-p', 'mercury-mhash', '--bin', 'mhash') } if (-not (Test-Path -LiteralPath $DigestPath -PathType Leaf)) { throw "Digest binary not found: $DigestPath" } $tools = @{ mhash = $DigestPath mhash_version = Get-ToolVersion -Name 'mhash' -Path $DigestPath pwsh = Resolve-CommandPath -Name 'pwsh' certutil = Resolve-CommandPath -Name 'certutil' openssl = Resolve-CommandPath -Name 'openssl' hyperfine = Resolve-CommandPath -Name 'hyperfine' cargo = Resolve-CommandPath -Name 'cargo' rustc = Resolve-CommandPath -Name 'rustc' } if ($tools.pwsh) { $tools.pwsh_version = Get-ToolVersion -Name 'pwsh' -Path $tools.pwsh -VersionArguments @('-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()') } if ($tools.certutil) { $tools.certutil_version = Get-ToolVersion -Name 'certutil' -Path $tools.certutil -VersionArguments @('-?') } if ($tools.openssl) { $tools.openssl_version = Get-ToolVersion -Name 'openssl' -Path $tools.openssl -VersionArguments @('version') } if ($tools.hyperfine) { $tools.hyperfine_version = Get-ToolVersion -Name 'hyperfine' -Path $tools.hyperfine } if ($tools.cargo) { $tools.cargo_version = Get-ToolVersion -Name 'cargo' -Path $tools.cargo } if ($tools.rustc) { $tools.rustc_version = Get-ToolVersion -Name 'rustc' -Path $tools.rustc } foreach ($algorithm in @('md5', 'sha1', 'sha256', 'sha512')) { $name = "${algorithm}sum" $path = Resolve-CoreutilsCommandPath -Name $name $tools[$name] = $path if ($path) { $tools["${name}_version"] = Get-ToolVersion -Name $name -Path $path } } $artifactSpecs = [System.Collections.Generic.List[object]]::new() $artifactSpecs.Add([pscustomobject]@{ tool = 'mercury-mhash'; path = $tools.mhash; version = $tools.mhash_version }) foreach ($spec in @( [pscustomobject]@{ tool = 'powershell:pwsh'; path = $tools.pwsh; version = if ($tools.ContainsKey('pwsh_version')) { $tools.pwsh_version } else { '' } }, [pscustomobject]@{ tool = 'windows:certutil'; path = $tools.certutil; version = if ($tools.ContainsKey('certutil_version')) { $tools.certutil_version } else { '' } }, [pscustomobject]@{ tool = 'openssl:dgst'; path = $tools.openssl; version = if ($tools.ContainsKey('openssl_version')) { $tools.openssl_version } else { '' } }, [pscustomobject]@{ tool = 'hyperfine'; path = $tools.hyperfine; version = if ($tools.ContainsKey('hyperfine_version')) { $tools.hyperfine_version } else { '' } } )) { if (-not [string]::IsNullOrWhiteSpace($spec.path)) { $artifactSpecs.Add($spec) } } foreach ($algorithm in @('md5', 'sha1', 'sha256', 'sha512')) { $name = "${algorithm}sum" if (-not [string]::IsNullOrWhiteSpace($tools[$name])) { $artifactSpecs.Add([pscustomobject]@{ tool = "coreutils:$name" path = $tools[$name] version = if ($tools.ContainsKey("${name}_version")) { $tools["${name}_version"] } else { '' } }) } } $toolArtifacts = @($artifactSpecs | ForEach-Object { Get-BinaryArtifactInfo -Tool $_.tool -Path $_.path -Version $_.version }) $artifactByPath = @{} foreach ($artifact in $toolArtifacts) { if ($artifact.exists) { $artifactByPath[$artifact.path] = $artifact } } $files = @() if (-not $SizeOnly) { $files = @(foreach ($size in $Sizes) { $bytes = ConvertTo-ByteCount -Value $size $label = Format-ByteSize -Bytes $bytes $path = Join-Path $dataRootFull "mhash-$label.bin" Write-Host "Preparing $label fixture: $path" New-DeterministicFile -Path $path -Bytes $bytes [pscustomobject]@{ label = $label bytes = $bytes path = $path sha256 = ConvertTo-FileSha256 -Path $path } }) } $records = [System.Collections.Generic.List[object]]::new() $matrix = [System.Collections.Generic.List[object]]::new() if (-not $SizeOnly) { foreach ($file in $files) { foreach ($algorithm in $Algorithms) { $candidates = @(New-CandidatesForAlgorithm -Algorithm $algorithm -InputPath $file.path -DigestExe $DigestPath -Tools $tools -DigestFormats $DigestFormats -NoExternal:$NoExternal) foreach ($candidate in $candidates) { $matrix.Add([pscustomobject]@{ file = $file algorithm = $algorithm candidate = $candidate }) } } } } if ($SizeOnly) { Write-Host 'Size-only mode: skipping fixture generation and process benchmarks.' } else { $totalRuns = ($Repeat + $Warmup) * $matrix.Count Write-Host "Benchmark matrix: sizes=$($files.Count), algorithms=$($Algorithms.Count), mhash_formats=$($DigestFormats.Count), cases=$($matrix.Count), process_runs=$totalRuns, repeat=$Repeat, warmup=$Warmup" foreach ($case in $matrix) { $file = $case.file $algorithm = $case.algorithm $candidate = $case.candidate $candidatePath = [System.IO.Path]::GetFullPath($candidate.FilePath) $artifact = if ($artifactByPath.ContainsKey($candidatePath)) { $artifactByPath[$candidatePath] } else { $null } $binarySizeBytes = if (($null -ne $artifact) -and ($artifact.exists)) { [int64]$artifact.size_bytes } else { 0L } for ($iteration = -$Warmup; $iteration -lt $Repeat; $iteration++) { $isWarmup = $iteration -lt 0 $label = if ($isWarmup) { 'warmup' } else { "run $($iteration + 1)" } Write-Host ("[{0} {1} {2}/{3}] {4}" -f $file.label, $algorithm, $candidate.Tool, $candidate.OutputFormat, $label) $measurement = Invoke-MeasuredProcess -Candidate $candidate if ($measurement.exit_code -ne 0) { Write-Warning "$($candidate.Tool) $algorithm failed with exit code $($measurement.exit_code): $($measurement.stderr)" } if (-not $isWarmup) { $records.Add([pscustomobject]@{ timestamp_utc = (Get-Date).ToUniversalTime().ToString('o') size_label = $file.label bytes = $file.bytes fixture_sha256 = $file.sha256 algorithm = $algorithm tool = $candidate.Tool output_format = $candidate.OutputFormat version = $candidate.Version binary_path = $candidatePath binary_size_bytes = $binarySizeBytes binary_size_label = if ($binarySizeBytes -gt 0) { Format-ByteSize -Bytes $binarySizeBytes } else { '' } repeat = $iteration + 1 elapsed_ms = [Math]::Round($measurement.elapsed_ms, 3) cpu_ms = [Math]::Round($measurement.cpu_ms, 3) cpu_one_core_percent = [Math]::Round($measurement.cpu_one_core_percent, 3) cpu_machine_percent = [Math]::Round($measurement.cpu_machine_percent, 3) peak_working_set_bytes = $measurement.peak_working_set_bytes exit_code = $measurement.exit_code stdout_sample = if ($measurement.stdout.Length -gt 160) { $measurement.stdout.Substring(0, 160) } else { $measurement.stdout } stderr_sample = if ($measurement.stderr.Length -gt 160) { $measurement.stderr.Substring(0, 160) } else { $measurement.stderr } }) } } } } $hyperfineSuites = [System.Collections.Generic.List[object]]::new() if ($Hyperfine -and (-not $SizeOnly)) { if ([string]::IsNullOrWhiteSpace($tools.hyperfine)) { Write-Warning 'Hyperfine was requested but hyperfine was not found on PATH. Skipping hyperfine integration.' } else { foreach ($file in $files) { foreach ($algorithm in $Algorithms) { $suiteCandidates = @($matrix | Where-Object { ($_.file.path -eq $file.path) -and ($_.algorithm -eq $algorithm) } | ForEach-Object { $_.candidate }) if ($suiteCandidates.Count -eq 0) { continue } $hyperfineSuitePath = Join-Path $outputRootFull ("mhash-benchmark-{0}.hyperfine.{1}.{2}.json" -f $timestamp, $algorithm, $file.label) Write-Host ("[hyperfine {0} {1}] commands={2}, runs={3}, warmup={4}" -f $file.label, $algorithm, $suiteCandidates.Count, $HyperfineRuns, $HyperfineWarmup) $suite = Invoke-HyperfineSuite -HyperfinePath $tools.hyperfine -File $file -Algorithm $algorithm -Candidates $suiteCandidates -OutputPath $hyperfineSuitePath -Runs $HyperfineRuns -Warmup $HyperfineWarmup if ($suite.exit_code -ne 0) { Write-Warning "hyperfine $algorithm $($file.label) failed with exit code $($suite.exit_code)." } $hyperfineSuites.Add($suite) } } } } $hyperfineRows = @(foreach ($suite in $hyperfineSuites) { foreach ($result in @($suite.results)) { $times = @($result.times | ForEach-Object { [double]$_ }) $min = if ($null -ne $result.min) { [double]$result.min } elseif ($times.Count -gt 0) { ($times | Measure-Object -Minimum).Minimum } else { 0.0 } $max = if ($null -ne $result.max) { [double]$result.max } elseif ($times.Count -gt 0) { ($times | Measure-Object -Maximum).Maximum } else { 0.0 } [pscustomobject]@{ size_label = $suite.size_label bytes = $suite.bytes algorithm = $suite.algorithm command = $result.command runs = $times.Count mean_ms = [Math]::Round(([double]$result.mean) * 1000.0, 3) stddev_ms = [Math]::Round(([double]$result.stddev) * 1000.0, 3) min_ms = [Math]::Round($min * 1000.0, 3) max_ms = [Math]::Round($max * 1000.0, 3) } } }) $rawPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.raw.json" $recordsJsonlPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.records.jsonl" $summaryPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.summary.json" $summaryJsonlPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.summary.jsonl" $csvPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.summary.csv" $artifactPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.artifacts.json" $artifactCsvPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.artifacts.csv" $hyperfinePath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.hyperfine.json" $hyperfineCsvPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.hyperfine.csv" $markdownPath = Join-Path $outputRootFull "mhash-benchmark-$timestamp.md" $environment = [pscustomobject]@{ mode = $mode timestamp_utc = (Get-Date).ToUniversalTime().ToString('o') invocation = $MyInvocation.Line script_path = $PSCommandPath workspace_root = [System.IO.Path]::GetFullPath($workspaceRoot) machine_name = $env:COMPUTERNAME os = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription os_architecture = [string][System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture process_architecture = [string][System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture processor_count = [Environment]::ProcessorCount powershell_version = $PSVersionTable.PSVersion.ToString() git = Get-GitMetadata -Root $workspaceRoot repeat = $Repeat warmup = $Warmup sizes = $Sizes algorithms = $Algorithms mhash_formats = $DigestFormats no_external = [bool]$NoExternal extended = [bool]$Extended size_only = [bool]$SizeOnly hyperfine_requested = [bool]$Hyperfine hyperfine_available = -not [string]::IsNullOrWhiteSpace($tools.hyperfine) hyperfine_runs = $HyperfineRuns hyperfine_warmup = $HyperfineWarmup data_root = $dataRootFull output_root = $outputRootFull tools = $tools tool_artifacts = $toolArtifacts fixtures = $files } $summary = @(ConvertTo-Summary -Records @($records)) [pscustomobject]@{ environment = $environment records = $records summary = $summary tool_artifacts = $toolArtifacts hyperfine_suites = $hyperfineSuites hyperfine_summary = $hyperfineRows } | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $rawPath -Encoding utf8NoBOM Write-JsonLines -Path $recordsJsonlPath -InputObject @($records) -Depth 8 ConvertTo-Json -InputObject $summary -Depth 7 | Set-Content -LiteralPath $summaryPath -Encoding utf8NoBOM Write-JsonLines -Path $summaryJsonlPath -InputObject $summary -Depth 7 if ($summary.Count -gt 0) { $summary | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding utf8NoBOM } else { [System.IO.File]::WriteAllText($csvPath, '', [System.Text.UTF8Encoding]::new($false)) } ConvertTo-Json -InputObject $toolArtifacts -Depth 7 | Set-Content -LiteralPath $artifactPath -Encoding utf8NoBOM $toolArtifacts | Export-Csv -LiteralPath $artifactCsvPath -NoTypeInformation -Encoding utf8NoBOM ConvertTo-Json -InputObject @($hyperfineSuites) -Depth 12 | Set-Content -LiteralPath $hyperfinePath -Encoding utf8NoBOM if ($hyperfineRows.Count -gt 0) { $hyperfineRows | Export-Csv -LiteralPath $hyperfineCsvPath -NoTypeInformation -Encoding utf8NoBOM } else { [System.IO.File]::WriteAllText($hyperfineCsvPath, '', [System.Text.UTF8Encoding]::new($false)) } Write-MarkdownReport -Path $markdownPath -Summary $summary -Environment $environment -Artifacts $toolArtifacts -HyperfineRows $hyperfineRows Write-Host "Raw results: $rawPath" Write-Host "Records JSONL: $recordsJsonlPath" Write-Host "Summary JSON: $summaryPath" Write-Host "Summary JSONL: $summaryJsonlPath" Write-Host "Summary CSV: $csvPath" Write-Host "Artifact JSON: $artifactPath" Write-Host "Artifact CSV: $artifactCsvPath" if ($Hyperfine) { Write-Host "Hyperfine JSON: $hyperfinePath" Write-Host "Hyperfine CSV: $hyperfineCsvPath" } Write-Host "Markdown report: $markdownPath"