diff --git a/.github/workflows/nuget-benchmark.yml b/.github/workflows/nuget-benchmark.yml new file mode 100644 index 0000000..2abf49d --- /dev/null +++ b/.github/workflows/nuget-benchmark.yml @@ -0,0 +1,78 @@ +name: NuGet Benchmark + +on: + workflow_dispatch: + inputs: + rows: + description: Workbook row count + required: true + default: '100000' + type: string + columns: + description: Workbook column count + required: true + default: '10' + type: string + iterations: + description: Fresh processes per runtime and scenario + required: true + default: '5' + type: string + schedule: + - cron: '17 4 * * 1' + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + BENCHMARK_ROWS: ${{ inputs.rows || '100000' }} + BENCHMARK_COLUMNS: ${{ inputs.columns || '10' }} + BENCHMARK_ITERATIONS: ${{ inputs.iterations || '5' }} + PACKAGE_VERSION: 0.1.0-benchmark.${{ github.run_number }} + +jobs: + benchmark: + name: Benchmark ${{ matrix.rid }} + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + rid: win-x64 + - runner: windows-11-arm + rid: win-arm64 + - runner: ubuntu-latest + rid: linux-x64 + - runner: ubuntu-24.04-arm + rid: linux-arm64 + - runner: macos-15-intel + rid: osx-x64 + - runner: macos-latest + rid: osx-arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.85.0 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - uses: Swatinem/rust-cache@v2 + with: + key: nuget-benchmark-${{ matrix.rid }} + - name: Run NuGet stress benchmark + shell: pwsh + run: >- + ./scripts/compare-nuget-v1-rust.ps1 + -Rid '${{ matrix.rid }}' + -Rows $env:BENCHMARK_ROWS + -Columns $env:BENCHMARK_COLUMNS + -Iterations $env:BENCHMARK_ITERATIONS + -MiniExcelRustVersion $env:PACKAGE_VERSION + - uses: actions/upload-artifact@v4 + with: + name: nuget-benchmark-${{ matrix.rid }} + path: | + target/benchmarks/nuget-v1/benchmark-${{ matrix.rid }}.json + target/benchmarks/nuget-v1/benchmark-${{ matrix.rid }}.md + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 36de338..cc3129a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /target/ /benchmarks/dotnet-v1-query/bin/ /benchmarks/dotnet-v1-query/obj/ +/benchmarks/nuget-v1-query/bin/ +/benchmarks/nuget-v1-query/obj/ /dotnet/**/bin/ /dotnet/**/obj/ /web-demo/dist/ diff --git a/benchmarks/nuget-v1-query/NuGetV1Query.csproj b/benchmarks/nuget-v1-query/NuGetV1Query.csproj new file mode 100644 index 0000000..f5f5bbd --- /dev/null +++ b/benchmarks/nuget-v1-query/NuGetV1Query.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + enable + 0.1.0-benchmark + 1.46.0 + + + + + + + + \ No newline at end of file diff --git a/benchmarks/nuget-v1-query/Program.cs b/benchmarks/nuget-v1-query/Program.cs new file mode 100644 index 0000000..21402bf --- /dev/null +++ b/benchmarks/nuget-v1-query/Program.cs @@ -0,0 +1,238 @@ +using System.Diagnostics; +using System.Globalization; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MiniExcelLibs; +using ManagedMiniExcel = MiniExcelLibs.MiniExcel; + +if (args.Length == 0) + return Usage(); + +return args[0].ToLowerInvariant() switch +{ + "generate" => Generate(args), + "verify" => Verify(args), + "managed" => Benchmark(args, useRust: false), + "rust" => Benchmark(args, useRust: true), + _ => Usage() +}; + +static int Generate(string[] arguments) +{ + if (arguments.Length != 4 || + !int.TryParse(arguments[2], out var rowCount) || rowCount < 1 || + !int.TryParse(arguments[3], out var columnCount) || columnCount is < 1 or > 26) + return Usage(); + + CreateWorkbook(Path.GetFullPath(arguments[1]), rowCount, columnCount); + return 0; +} + +static int Verify(string[] arguments) +{ + if (arguments.Length != 2) + return Usage(); + + var path = Path.GetFullPath(arguments[1]); + using var managed = Query(path, useRust: false).GetEnumerator(); + using var rust = Query(path, useRust: true).GetEnumerator(); + long rowIndex = 0; + while (true) + { + var hasManaged = managed.MoveNext(); + var hasRust = rust.MoveNext(); + Require(hasManaged == hasRust, $"Row count differs after row {rowIndex}."); + if (!hasManaged) + break; + CompareRows(managed.Current, rust.Current, rowIndex); + rowIndex++; + } + + Console.WriteLine($"Verified {rowIndex} rows against MiniExcel 1.46.0."); + return 0; +} + +static int Benchmark(string[] arguments, bool useRust) +{ + if (arguments.Length is < 2 or > 4 || + arguments.Length >= 3 && (!int.TryParse(arguments[2], out var passes) || passes < 1) || + arguments.Length >= 4 && (!int.TryParse(arguments[3], out var warmups) || warmups < 0)) + return Usage(); + + var path = Path.GetFullPath(arguments[1]); + var measuredPasses = arguments.Length >= 3 ? int.Parse(arguments[2], CultureInfo.InvariantCulture) : 1; + var warmupPasses = arguments.Length >= 4 ? int.Parse(arguments[3], CultureInfo.InvariantCulture) : 0; + for (var pass = 0; pass < warmupPasses; pass++) + Consume(path, useRust); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + var allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + var stopwatch = Stopwatch.StartNew(); + var firstRowMilliseconds = 0d; + long rows = 0; + long cells = 0; + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + for (var pass = 0; pass < measuredPasses; pass++) + { + foreach (var row in Query(path, useRust)) + { + if (rows == 0) + firstRowMilliseconds = stopwatch.Elapsed.TotalMilliseconds; + rows++; + cells += row.Count; + AppendRow(hash, row); + } + } + stopwatch.Stop(); + + Console.WriteLine(JsonSerializer.Serialize(new BenchmarkResult( + useRust ? "MiniExcel.Rust" : "MiniExcel", + Environment.Version.ToString(), + measuredPasses, + rows, + cells, + Convert.ToHexString(hash.GetHashAndReset()), + stopwatch.Elapsed.TotalMilliseconds, + firstRowMilliseconds, + GC.GetTotalAllocatedBytes(precise: true) - allocatedBefore))); + return 0; +} + +static IEnumerable> Query(string path, bool useRust) +{ + if (useRust) + return MiniExcelRust.Query(path, useHeaderRow: false); + return ManagedMiniExcel.Query(path, useHeaderRow: false) + .Cast>(); +} + +static void Consume(string path, bool useRust) +{ + foreach (var row in Query(path, useRust)) + _ = row.Count; +} + +static void CompareRows( + IDictionary managed, + IDictionary rust, + long rowIndex) +{ + Require(managed.Keys.SequenceEqual(rust.Keys, StringComparer.Ordinal), + $"Column order differs at row {rowIndex}."); + foreach (var key in managed.Keys) + { + Require(Normalize(managed[key]) == Normalize(rust[key]), + $"Value differs at row {rowIndex}, column {key}: managed={managed[key]}, rust={rust[key]}."); + } +} + +static void AppendRow(IncrementalHash hash, IDictionary row) +{ + foreach (var cell in row) + { + AppendText(hash, cell.Key); + AppendText(hash, Normalize(cell.Value)); + } +} + +static void AppendText(IncrementalHash hash, string value) +{ + var bytes = Encoding.UTF8.GetBytes(value); + hash.AppendData(BitConverter.GetBytes(bytes.Length)); + hash.AppendData(bytes); +} + +static string Normalize(object? value) => value switch +{ + null or DBNull => "null", + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty, + _ => value.ToString() ?? string.Empty +}; + +static void CreateWorkbook(string path, int rows, int columns) +{ + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + if (File.Exists(path)) + File.Delete(path); + using var archive = ZipFile.Open(path, ZipArchiveMode.Create); + AddEntry(archive, "[Content_Types].xml", """ + + + + + + + + """); + AddEntry(archive, "_rels/.rels", """ + + + + + """); + AddEntry(archive, "xl/workbook.xml", """ + + + + + """); + AddEntry(archive, "xl/_rels/workbook.xml.rels", """ + + + + + """); + + var entry = archive.CreateEntry("xl/worksheets/sheet1.xml", CompressionLevel.Fastest); + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)); + writer.Write(""); + for (var row = 1; row <= rows; row++) + { + writer.Write($""); + for (var column = 1; column <= columns; column++) + { + var reference = $"{(char)('A' + column - 1)}{row}"; + var value = (long)(row - 1) * columns + column; + writer.Write($"{value}"); + } + writer.Write(""); + } + writer.Write(""); +} + +static void AddEntry(ZipArchive archive, string name, string contents) +{ + var entry = archive.CreateEntry(name, CompressionLevel.Fastest); + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)); + writer.Write(contents); +} + +static void Require(bool condition, string message) +{ + if (!condition) + throw new InvalidOperationException(message); +} + +static int Usage() +{ + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" NuGetV1Query generate "); + Console.Error.WriteLine(" NuGetV1Query verify "); + Console.Error.WriteLine(" NuGetV1Query [passes] [warmup-passes]"); + return 2; +} + +internal sealed record BenchmarkResult( + string Runtime, + string DotNetRuntime, + int Passes, + long Rows, + long Cells, + string ContentHash, + double ElapsedMilliseconds, + double FirstRowMilliseconds, + long AllocatedBytes); \ No newline at end of file diff --git a/docs/dotnet-v1-query-benchmark.md b/docs/dotnet-v1-query-benchmark.md index 5891576..1df332f 100644 --- a/docs/dotnet-v1-query-benchmark.md +++ b/docs/dotnet-v1-query-benchmark.md @@ -11,6 +11,25 @@ This benchmark compares dynamic, headerless XLSX streaming over the same workboo Both runners enumerate every returned row without retaining the complete worksheet. Save performance, typed mapping, formulas, and other APIs are outside this comparison. +### NuGet-To-NuGet Stress Harness + +To benchmark the distributable package rather than the Rust CLI, run: + +```powershell +pwsh ./scripts/compare-nuget-v1-rust.ps1 +``` + +This harness builds a local `MiniExcel.Rust` package, resolves the latest stable public MiniExcel v1 +package, restores both into an isolated `net8.0` consumer, and compares `MiniExcel.Query` with +`MiniExcelRust.Query`. Before timing, it verifies every row, column, and normalized value. Cold and +steady scenarios run in alternating fresh processes and report query time, first-row latency, +managed allocation, peak working set, and peak private memory to +`target/benchmarks/nuget-v1/benchmark-.{json,md}`. + +Use `-Rows`, `-Columns`, `-Iterations`, `-Passes`, and `-WarmupPasses` to change the load. The +`NuGet Benchmark` GitHub workflow runs the same harness on Windows, Linux, and macOS for x64 and +Arm64. Pass `-MiniExcelVersion 1.46.0` to pin a historical baseline for reproducible comparisons. + ## Fairness Controls - Both runners use Release builds, the same workbook, and equivalent public dynamic Query APIs. diff --git a/docs/dotnet-v1-query-benchmark.zh-CN.md b/docs/dotnet-v1-query-benchmark.zh-CN.md index e5b59d5..a2b1dc0 100644 --- a/docs/dotnet-v1-query-benchmark.zh-CN.md +++ b/docs/dotnet-v1-query-benchmark.zh-CN.md @@ -11,6 +11,23 @@ 两个 runner 都会遍历所有返回行,但不会把完整 worksheet 保留在内存中。本测试不包含 Save、类型映射、公式及其他 API。 +### NuGet 对 NuGet 压力测试 + +如需比较实际发布包而不是 Rust CLI,请运行: + +```powershell +pwsh ./scripts/compare-nuget-v1-rust.ps1 +``` + +该脚本先构建本地 `MiniExcel.Rust` 包,自动解析 NuGet 上最新的稳定 MiniExcel v1,再创建隔离的 +`net8.0` 消费者并对比 `MiniExcel.Query` 与 `MiniExcelRust.Query`。计时前会逐行、逐列、 +逐值验证结果;Cold 与 Steady 场景使用交替的新进程执行,并将 Query 耗时、首行延迟、托管分配、 +峰值工作集与峰值私有内存写入 `target/benchmarks/nuget-v1/benchmark-.{json,md}`。 + +可通过 `-Rows`、`-Columns`、`-Iterations`、`-Passes` 和 `-WarmupPasses` 调整压力。 +GitHub 的 `NuGet Benchmark` workflow 会在 Windows、Linux、macOS 的 x64 与 Arm64 环境运行同一套测试。 +如需复现历史结果,可传入 `-MiniExcelVersion 1.46.0` 固定基线版本。 + ## 公平性控制 - 两个 runner 都使用 Release 构建、同一份工作簿和语义等价的公开动态 Query API。 diff --git a/scripts/compare-nuget-v1-rust.ps1 b/scripts/compare-nuget-v1-rust.ps1 new file mode 100644 index 0000000..4bccbd3 --- /dev/null +++ b/scripts/compare-nuget-v1-rust.ps1 @@ -0,0 +1,273 @@ +[CmdletBinding()] +param( + [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64')] + [string] $Rid = 'win-x64', + + [ValidateRange(1, 20)] + [int] $Iterations = 5, + + [ValidateRange(1, 1000000)] + [int] $Rows = 100000, + + [ValidateRange(1, 26)] + [int] $Columns = 10, + + [ValidateRange(1, 100)] + [int] $Passes = 3, + + [ValidateRange(0, 100)] + [int] $WarmupPasses = 1, + + [ValidateSet('Cold', 'Steady', 'Both')] + [string] $Scenario = 'Both', + + [string] $MiniExcelVersion, + + [string] $MiniExcelRustVersion = '0.1.0-benchmark', + + [switch] $SkipPackageBuild, + + [string] $OutputDirectory +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$packageDirectory = Join-Path $repositoryRoot 'target/nuget/packages' +$packageCache = Join-Path $repositoryRoot 'target/nuget/benchmark-packages' +$restoreDirectory = Join-Path $repositoryRoot 'target/nuget/benchmark-restore' +$restoreConfig = Join-Path $restoreDirectory 'nuget.config' +$project = Join-Path $repositoryRoot 'benchmarks/nuget-v1-query/NuGetV1Query.csproj' +$runner = Join-Path $repositoryRoot 'benchmarks/nuget-v1-query/bin/Release/net8.0/NuGetV1Query.dll' +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $repositoryRoot 'target/benchmarks/nuget-v1' +} +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +$workbook = Join-Path $OutputDirectory "benchmark-$Rows`x$Columns.xlsx" + +if ([string]::IsNullOrWhiteSpace($MiniExcelVersion)) { + $versionIndex = Invoke-RestMethod 'https://api.nuget.org/v3-flatcontainer/miniexcel/index.json' + $MiniExcelVersion = @( + $versionIndex.versions | + Where-Object { $_ -match '^1\.\d+\.\d+$' } | + Sort-Object { [version]$_ } -Descending + )[0] +} +if ($MiniExcelVersion -notmatch '^1\.') { + throw "MiniExcelVersion must be a stable v1 release, received '$MiniExcelVersion'." +} + +if (-not $SkipPackageBuild) { + & (Join-Path $repositoryRoot 'scripts/dotnet/Test-Package.ps1') ` + -Rid $Rid ` + -Version $MiniExcelRustVersion + if ($LASTEXITCODE -ne 0) { + throw 'MiniExcel.Rust package build and smoke test failed.' + } +} + +$package = Join-Path $packageDirectory "MiniExcel.Rust.$MiniExcelRustVersion.nupkg" +if (-not (Test-Path $package)) { + throw "Candidate package not found: $package" +} + +New-Item -ItemType Directory -Path $OutputDirectory, $restoreDirectory -Force | Out-Null +$cachedCandidate = Join-Path $packageCache "miniexcel.rust/$($MiniExcelRustVersion.ToLowerInvariant())" +if (Test-Path $cachedCandidate) { + Remove-Item $cachedCandidate -Recurse -Force +} +& dotnet new nugetconfig --output $restoreDirectory --force | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'NuGet configuration creation failed.' } +& dotnet nuget add source $packageDirectory --name MiniExcelRustLocal --configfile $restoreConfig | Out-Null +if ($LASTEXITCODE -ne 0) { throw 'Local NuGet source configuration failed.' } +& dotnet restore $project ` + --force ` + --no-cache ` + --packages $packageCache ` + --configfile $restoreConfig ` + -p:MiniExcelVersion=$MiniExcelVersion ` + -p:MiniExcelRustPackageVersion=$MiniExcelRustVersion +if ($LASTEXITCODE -ne 0) { throw 'Benchmark restore failed.' } +& dotnet build $project ` + -c Release ` + --no-restore ` + -p:MiniExcelVersion=$MiniExcelVersion ` + -p:MiniExcelRustPackageVersion=$MiniExcelRustVersion +if ($LASTEXITCODE -ne 0) { throw 'Benchmark build failed.' } + +& dotnet $runner generate $workbook $Rows $Columns +if ($LASTEXITCODE -ne 0) { throw 'Benchmark workbook generation failed.' } +& dotnet $runner verify $workbook +if ($LASTEXITCODE -ne 0) { throw 'MiniExcel and MiniExcel.Rust returned different data.' } + +function Invoke-MeasuredProcess { + param( + [string] $Runtime, + [pscustomobject] $BenchmarkScenario, + [int] $Iteration + ) + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = 'dotnet' + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @( + $runner, + $Runtime, + $workbook, + "$($BenchmarkScenario.Passes)", + "$($BenchmarkScenario.WarmupPasses)" + )) { + $startInfo.ArgumentList.Add($argument) + } + + $processStopwatch = [Diagnostics.Stopwatch]::StartNew() + $process = [Diagnostics.Process]::Start($startInfo) + $peakWorkingSet = 0L + $peakPrivateBytes = 0L + while (-not $process.WaitForExit(10)) { + $process.Refresh() + $peakWorkingSet = [Math]::Max($peakWorkingSet, $process.WorkingSet64) + $peakPrivateBytes = [Math]::Max($peakPrivateBytes, $process.PrivateMemorySize64) + } + $processStopwatch.Stop() + $output = $process.StandardOutput.ReadToEnd().Trim() + $errors = $process.StandardError.ReadToEnd().Trim() + if ($process.ExitCode -ne 0) { + throw "$Runtime $($BenchmarkScenario.Name) failed: $errors" + } + $measurement = $output | ConvertFrom-Json + [pscustomobject]@{ + Scenario = $BenchmarkScenario.Name + Runtime = $measurement.Runtime + Iteration = $Iteration + Passes = $measurement.Passes + Rows = $measurement.Rows + Cells = $measurement.Cells + ContentHash = $measurement.ContentHash + ElapsedMs = [Math]::Round($measurement.ElapsedMilliseconds, 2) + FirstRowMs = [Math]::Round($measurement.FirstRowMilliseconds, 2) + AllocatedMB = [Math]::Round($measurement.AllocatedBytes / 1MB, 2) + ProcessElapsedMs = [Math]::Round($processStopwatch.Elapsed.TotalMilliseconds, 2) + PeakWorkingSetMB = [Math]::Round($peakWorkingSet / 1MB, 2) + PeakPrivateMB = [Math]::Round($peakPrivateBytes / 1MB, 2) + } +} + +function Get-Median { + param([double[]] $Values) + + $sorted = @($Values | Sort-Object) + $middle = [Math]::Floor($sorted.Count / 2) + if ($sorted.Count % 2 -eq 1) { + return $sorted[$middle] + } + return ($sorted[$middle - 1] + $sorted[$middle]) / 2 +} + +$scenarios = @() +if ($Scenario -in @('Cold', 'Both')) { + $scenarios += [pscustomobject]@{ Name = 'Cold'; Passes = 1; WarmupPasses = 0 } +} +if ($Scenario -in @('Steady', 'Both')) { + $scenarios += [pscustomobject]@{ Name = 'Steady'; Passes = $Passes; WarmupPasses = $WarmupPasses } +} + +foreach ($runtime in @('managed', 'rust')) { + $null = Invoke-MeasuredProcess -Runtime $runtime ` + -BenchmarkScenario ([pscustomobject]@{ Name = 'Preflight'; Passes = 1; WarmupPasses = 0 }) ` + -Iteration 0 +} + +$results = [Collections.Generic.List[object]]::new() +for ($scenarioIndex = 0; $scenarioIndex -lt $scenarios.Count; $scenarioIndex++) { + $benchmarkScenario = $scenarios[$scenarioIndex] + foreach ($iteration in 1..$Iterations) { + $order = if (($iteration + $scenarioIndex) % 2 -eq 1) { @('managed', 'rust') } else { @('rust', 'managed') } + foreach ($runtime in $order) { + $results.Add((Invoke-MeasuredProcess -Runtime $runtime -BenchmarkScenario $benchmarkScenario -Iteration $iteration)) + } + } +} + +foreach ($benchmarkScenario in $scenarios) { + $scenarioResults = @($results | Where-Object Scenario -eq $benchmarkScenario.Name) + if (($scenarioResults.Rows | Select-Object -Unique).Count -ne 1 -or + ($scenarioResults.Cells | Select-Object -Unique).Count -ne 1 -or + ($scenarioResults.ContentHash | Select-Object -Unique).Count -ne 1) { + throw "$($benchmarkScenario.Name): MiniExcel and MiniExcel.Rust returned different results." + } +} + +$summary = foreach ($benchmarkScenario in $scenarios) { + foreach ($runtime in @('MiniExcel', 'MiniExcel.Rust')) { + $group = @($results | Where-Object { $_.Scenario -eq $benchmarkScenario.Name -and $_.Runtime -eq $runtime }) + $medianElapsed = Get-Median ([double[]]$group.ElapsedMs) + [pscustomobject]@{ + Scenario = $benchmarkScenario.Name + Runtime = $runtime + MedianElapsedMs = [Math]::Round($medianElapsed, 2) + RowsPerSecond = [Math]::Round($group[0].Rows / ($medianElapsed / 1000), 0) + MedianFirstRowMs = [Math]::Round((Get-Median ([double[]]$group.FirstRowMs)), 2) + MedianAllocatedMB = [Math]::Round((Get-Median ([double[]]$group.AllocatedMB)), 2) + MedianPeakWorkingSetMB = [Math]::Round((Get-Median ([double[]]$group.PeakWorkingSetMB)), 2) + MedianPeakPrivateMB = [Math]::Round((Get-Median ([double[]]$group.PeakPrivateMB)), 2) + } + } +} + +$comparison = foreach ($benchmarkScenario in $scenarios) { + $managed = $summary | Where-Object { $_.Scenario -eq $benchmarkScenario.Name -and $_.Runtime -eq 'MiniExcel' } + $rust = $summary | Where-Object { $_.Scenario -eq $benchmarkScenario.Name -and $_.Runtime -eq 'MiniExcel.Rust' } + [pscustomobject]@{ + Scenario = $benchmarkScenario.Name + RustSpeedup = [Math]::Round($managed.MedianElapsedMs / $rust.MedianElapsedMs, 2) + AllocationReductionPercent = [Math]::Round((1 - $rust.MedianAllocatedMB / $managed.MedianAllocatedMB) * 100, 1) + WorkingSetReductionPercent = [Math]::Round((1 - $rust.MedianPeakWorkingSetMB / $managed.MedianPeakWorkingSetMB) * 100, 1) + } +} + +$report = [ordered]@{ + TimestampUtc = [DateTime]::UtcNow.ToString('O') + OperatingSystem = [Runtime.InteropServices.RuntimeInformation]::OSDescription + Architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + Rid = $Rid + DotNetSdk = (& dotnet --version).Trim() + MiniExcelVersion = $MiniExcelVersion + MiniExcelRustVersion = $MiniExcelRustVersion + Rows = $Rows + Columns = $Columns + WorkbookSha256 = (Get-FileHash $workbook -Algorithm SHA256).Hash.ToLowerInvariant() + PackageSha256 = (Get-FileHash $package -Algorithm SHA256).Hash.ToLowerInvariant() + Iterations = $Iterations + Results = $results + Summary = $summary + Comparison = $comparison +} +$jsonPath = Join-Path $OutputDirectory "benchmark-$Rid.json" +$markdownPath = Join-Path $OutputDirectory "benchmark-$Rid.md" +$report | ConvertTo-Json -Depth 6 | Set-Content $jsonPath +$results | Format-Table Scenario,Runtime,Iteration,ElapsedMs,FirstRowMs,AllocatedMB,PeakWorkingSetMB,PeakPrivateMB -AutoSize +$summary | Format-Table Scenario,Runtime,MedianElapsedMs,RowsPerSecond,MedianFirstRowMs,MedianAllocatedMB,MedianPeakWorkingSetMB -AutoSize + +$markdown = [Collections.Generic.List[string]]::new() +$markdown.Add("# MiniExcel v1 vs MiniExcel.Rust ($Rid)") +$markdown.Add('') +$markdown.Add("- Date (UTC): $($report.TimestampUtc)") +$markdown.Add("- MiniExcel: $MiniExcelVersion") +$markdown.Add("- MiniExcel.Rust: $MiniExcelRustVersion") +$markdown.Add("- Workbook: $Rows rows x $Columns columns") +$markdown.Add("- Iterations: $Iterations fresh processes per runtime and scenario") +$markdown.Add('') +$markdown.Add('| Scenario | Runtime | Median elapsed (ms) | Rows/s | First row (ms) | Allocated (MB) | Peak working set (MB) |') +$markdown.Add('| --- | --- | ---: | ---: | ---: | ---: | ---: |') +foreach ($item in $summary) { + $markdown.Add("| $($item.Scenario) | $($item.Runtime) | $($item.MedianElapsedMs) | $($item.RowsPerSecond) | $($item.MedianFirstRowMs) | $($item.MedianAllocatedMB) | $($item.MedianPeakWorkingSetMB) |") +} +$markdown.Add('') +foreach ($item in $comparison) { + $markdown.Add("- $($item.Scenario): Rust speedup $($item.RustSpeedup)x; allocation reduction $($item.AllocationReductionPercent)%; working-set reduction $($item.WorkingSetReductionPercent)%.") +} +$markdown | Set-Content $markdownPath +Write-Host "Report: $jsonPath" +Write-Host "Markdown: $markdownPath" \ No newline at end of file