Files

1042 lines
40 KiB
PowerShell

[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 '^(?<number>\d+)(?<unit>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"