From b312955afa1d2b23556c424bfc1c849f17a544fb Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sat, 5 Sep 2026 22:31:23 +0800 Subject: [PATCH 1/2] Add cross-platform quality benchmarks Validate packaged queries and lifecycle resource growth across supported RIDs, benchmark equivalent queries against MiniExcel on .NET 10, publish reproducible reports, and automate benchmark result updates through pull requests. --- .github/workflows/benchmarks.yml | 115 ++++++ .github/workflows/ci.yml | 8 +- .github/workflows/release.yml | 8 +- README.md | 64 ++- build/Benchmark-Package.ps1 | 240 +++++++++++ build/Publish-Benchmark-Results.ps1 | 79 ++++ build/Test-Package.ps1 | 19 +- docs/benchmarks/README.md | 12 + docs/benchmarks/benchmark-win-x64.json | 351 ++++++++++++++++ docs/benchmarks/benchmark-win-x64.md | 21 + src/MiniExcelRust/MiniExcelRust.csproj | 2 +- .../MiniExcelRust.PackageTests.csproj | 5 +- tests/MiniExcelRust.PackageTests/Program.cs | 391 +++++++++++++++++- 13 files changed, 1276 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/benchmarks.yml create mode 100644 build/Benchmark-Package.ps1 create mode 100644 build/Publish-Benchmark-Results.ps1 create mode 100644 docs/benchmarks/README.md create mode 100644 docs/benchmarks/benchmark-win-x64.json create mode 100644 docs/benchmarks/benchmark-win-x64.md diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..ab886e0 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,115 @@ +name: Cross-platform benchmarks + +on: + workflow_dispatch: + inputs: + rows: + description: Workbook row count + required: true + default: 100000 + type: number + columns: + description: Workbook column count + required: true + default: 10 + type: number + iterations: + description: Fresh processes per runtime and scenario + required: true + default: 5 + type: number + mini_excel_version: + description: MiniExcel NuGet baseline version + required: true + default: 2.0.0-preview.4 + type: string + schedule: + - cron: '17 3 * * 1' + +permissions: + contents: read + +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 }} + env: + BENCHMARK_ROWS: ${{ inputs.rows || '100000' }} + BENCHMARK_COLUMNS: ${{ inputs.columns || '10' }} + BENCHMARK_ITERATIONS: ${{ inputs.iterations || '5' }} + MINIEXCEL_VERSION: ${{ inputs.mini_excel_version || '2.0.0-preview.4' }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.85.0 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + - uses: Swatinem/rust-cache@v2 + with: + key: benchmark-${{ matrix.rid }} + - name: Run equivalent-query benchmark + shell: pwsh + run: | + ./build/Benchmark-Package.ps1 ` + -Rid '${{ matrix.rid }}' ` + -Rows $env:BENCHMARK_ROWS ` + -Columns $env:BENCHMARK_COLUMNS ` + -Iterations $env:BENCHMARK_ITERATIONS ` + -MiniExcelVersion $env:MINIEXCEL_VERSION + - uses: actions/upload-artifact@v4 + with: + name: benchmark-${{ matrix.rid }} + path: | + artifacts/benchmarks/benchmark-${{ matrix.rid }}.json + artifacts/benchmarks/benchmark-${{ matrix.rid }}.md + if-no-files-found: error + + publish-results: + name: Open benchmark results PR + needs: benchmark + if: github.ref_name == github.event.repository.default_branch + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: benchmark-* + path: artifacts/benchmarks + merge-multiple: true + - name: Publish benchmark reports + shell: pwsh + run: ./build/Publish-Benchmark-Results.ps1 + - name: Create or update results pull request + uses: peter-evans/create-pull-request@v7 + with: + branch: automation/benchmark-results + base: ${{ github.event.repository.default_branch }} + delete-branch: true + commit-message: 'docs: update cross-platform benchmark results' + title: 'docs: update cross-platform benchmark results' + body: | + Automated benchmark refresh from `${{ github.workflow }}`. + + Source run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + add-paths: docs/benchmarks \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 996b9fe..6aa9cf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: key: ${{ matrix.rid }} - name: Build, pack, and test shell: pwsh - run: ./build/Test-Package.ps1 -Rid '${{ matrix.rid }}' + run: ./build/Test-Package.ps1 -Rid '${{ matrix.rid }}' -LifecycleIterations 5000 - uses: actions/upload-artifact@v4 with: name: ${{ matrix.rid }} @@ -106,7 +106,7 @@ jobs: -v "$GITHUB_WORKSPACE:/work" \ -w /work \ mcr.microsoft.com/dotnet/sdk:8.0-alpine \ - sh -lc 'dotnet restore tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj --source artifacts/packages && dotnet run --project tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj -c Release --no-restore' + sh -lc 'dotnet restore tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj --source artifacts/packages && dotnet run --project tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj -c Release --no-restore -- suite 5000 32' - uses: actions/upload-artifact@v4 with: name: ${{ matrix.rid }} @@ -130,9 +130,9 @@ jobs: run: dotnet pack src/MiniExcelRust/MiniExcelRust.csproj -c Release -o artifacts/packages - name: Verify package contents shell: pwsh - run: ./build/Verify-Package.ps1 -PackagePath artifacts/packages/MiniExcelRust.0.1.0-preview.1.nupkg + run: ./build/Verify-Package.ps1 -PackagePath artifacts/packages/MiniExcelRust.0.1.0-preview.2.nupkg - uses: actions/upload-artifact@v4 with: name: nuget-preview - path: artifacts/packages/MiniExcelRust.0.1.0-preview.1.*nupkg + path: artifacts/packages/MiniExcelRust.0.1.0-preview.2.*nupkg if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b37265..c4e102a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: inputs: version: - description: NuGet version, for example 0.1.0-preview.1 + description: NuGet version, for example 0.1.0-preview.2 required: true - default: 0.1.0-preview.1 + default: 0.1.0-preview.2 type: string publish: description: Publish to NuGet.org after all tests pass @@ -188,7 +188,7 @@ jobs: shell: pwsh run: | dotnet restore tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj --source artifacts/packages -p:MiniExcelRustPackageVersion=${{ needs.prepare.outputs.version }} - dotnet run --project tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj -c Release --no-restore -p:MiniExcelRustPackageVersion=${{ needs.prepare.outputs.version }} + dotnet run --project tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj -c Release --no-restore -p:MiniExcelRustPackageVersion=${{ needs.prepare.outputs.version }} -- suite 5000 32 - name: Consume package in Alpine if: matrix.musl shell: bash @@ -197,7 +197,7 @@ jobs: -v "$GITHUB_WORKSPACE:/work" \ -w /work \ mcr.microsoft.com/dotnet/sdk:8.0-alpine \ - sh -lc 'dotnet restore tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj --source artifacts/packages -p:MiniExcelRustPackageVersion=${{ needs.prepare.outputs.version }} && dotnet run --project tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj -c Release --no-restore -p:MiniExcelRustPackageVersion=${{ needs.prepare.outputs.version }}' + sh -lc 'dotnet restore tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj --source artifacts/packages -p:MiniExcelRustPackageVersion=${{ needs.prepare.outputs.version }} && dotnet run --project tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj -c Release --no-restore -p:MiniExcelRustPackageVersion=${{ needs.prepare.outputs.version }} -- suite 5000 32' publish: needs: [prepare, test-package] diff --git a/README.md b/README.md index 00cb2ac..eba8933 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,19 @@ versioned C ABI. dotnet add package MiniExcelRust --prerelease ``` +The package is currently prerelease. Pin an exact version in production builds: + +```shell +dotnet add package MiniExcelRust --version 0.1.0-preview.2 +``` + +To check for and install a newer preview: + +```shell +dotnet list package --outdated --include-prerelease +dotnet add package MiniExcelRust --prerelease +``` + ## Usage ```csharp @@ -24,8 +37,21 @@ foreach (var row in MiniExcelRust.Query("input.xlsx", useHeaderRow: true)) } ``` +`Query` accepts `path`, `useHeaderRow`, `sheetName`, and `startCell`. Each streamed row is +an `IDictionary`; cells are returned as strings, doubles, booleans, or nulls. +For example, a query against another sheet starting at `C2` is: + +```csharp +var rows = MiniExcelRust.Query( + "input.xlsx", + useHeaderRow: true, + sheetName: "Data", + startCell: "C2"); +``` + Rows are streamed in bounded batches across the native boundary. Disposing the enumerator -early closes the native query handle. +early closes the native query handle. Normal `foreach` enumeration disposes it automatically; +code that manually obtains an enumerator should wrap it in `using`. ## Supported Platforms @@ -58,11 +84,43 @@ dotnet build ./src/MiniExcelRust/MiniExcelRust.csproj -c Release ``` `Test-Package.ps1` builds the native library, packs `MiniExcelRust`, restores a separate -consumer from the local package feed, and executes an XLSX query smoke test. +consumer from the local package feed, and verifies equivalent queries against MiniExcel. + +GitHub CI runs those header, headerless, sheet, start-cell, Unicode, boolean, null, numeric, +full-enumeration, and early-disposal queries on all eight supported RIDs. Each platform also +runs 5,000 lifecycle iterations and fails when private memory grows by more than 32 MB, when +the native handle/file-descriptor count grows by more than four, or when the workbook cannot +be reopened exclusively. This is a bounded resource-growth regression test rather than a +mathematical proof that no leak can exist. + +## Benchmark + +The benchmark first compares every returned row and cell with the configured MiniExcel NuGet +baseline. It then measures both implementations in alternating fresh processes against the +same generated XLSX file and query options. The scheduled and manually dispatched GitHub +workflow runs on Windows, Linux, and macOS for x64 and Arm64; musl remains covered by the +Alpine correctness and lifecycle job because GitHub does not provide native musl runners. + +The latest checked-in cross-platform summary and per-RID reports are in the +[benchmark results](https://github.com/mini-software/MiniExcelRust/blob/main/docs/benchmarks/README.md). +Each report includes elapsed time, first-row latency, managed allocation, peak process memory, +environment metadata, and a JSON file containing all raw iterations and hashes. + +After all scheduled benchmarks pass on the default branch, the workflow updates +`docs/benchmarks/` through an `automation/benchmark-results` pull request. Repeated runs refresh +the same PR instead of committing directly to the protected branch. Repository settings must +allow GitHub Actions to create pull requests. + +Run the same reproducible comparison locally, or override `-MiniExcelVersion` to test a newer +NuGet release: + +```powershell +./build/Benchmark-Package.ps1 -Rid win-x64 -MiniExcelVersion 2.0.0-preview.4 +``` ## Release -Version tags use the form `v0.1.0-preview.1`. The release workflow builds all eight native +Version tags use the form `v0.1.0-preview.2`. The release workflow builds all eight native assets, verifies the assembled package, tests it on native GitHub-hosted runners, and publishes to NuGet.org through the protected `release` environment. diff --git a/build/Benchmark-Package.ps1 b/build/Benchmark-Package.ps1 new file mode 100644 index 0000000..d412e85 --- /dev/null +++ b/build/Benchmark-Package.ps1 @@ -0,0 +1,240 @@ +[CmdletBinding()] +param( + [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64')] + [string] $Rid = 'win-x64', + + [ValidateRange(1, 10)] + [int] $Iterations = 5, + + [ValidateRange(1, 1000000)] + [int] $Rows = 100000, + + [ValidateRange(1, 26)] + [int] $Columns = 10, + + [string] $Version = '0.1.0-preview.2', + + [string] $MiniExcelVersion = '2.0.0-preview.4', + + [ValidateSet('net8.0', 'net9.0', 'net10.0')] + [string] $TargetFramework = 'net10.0', + + [switch] $SkipPackageTest, + + [string] $OutputDirectory +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $repositoryRoot 'artifacts/benchmarks' +} +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) + +$consumerProject = Join-Path $repositoryRoot 'tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj' +$consumerDll = Join-Path $repositoryRoot "tests/MiniExcelRust.PackageTests/bin/Release/$TargetFramework/MiniExcelRust.PackageTests.dll" +$workbook = Join-Path $OutputDirectory "benchmark-$Rows`x$Columns.xlsx" +$package = Join-Path $repositoryRoot "artifacts/packages/MiniExcelRust.$Version.nupkg" +$scenarios = @( + [pscustomobject]@{ Name = 'Cold'; Passes = 1; WarmupPasses = 0 } + [pscustomobject]@{ Name = 'Warm'; Passes = 3; WarmupPasses = 1 } +) + +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +if (-not $SkipPackageTest) { + & (Join-Path $PSScriptRoot 'Test-Package.ps1') ` + -Rid $Rid ` + -Version $Version ` + -MiniExcelVersion $MiniExcelVersion + if ($LASTEXITCODE -ne 0) { + throw 'Package build and lifecycle suite failed.' + } +} + +if (-not (Test-Path $package)) { + throw "Package not found: $package" +} + +& dotnet restore $consumerProject ` + --force ` + --source (Split-Path $package -Parent) ` + -p:MiniExcelRustPackageVersion=$Version ` + -p:MiniExcelVersion=$MiniExcelVersion ` + -p:TargetFramework=$TargetFramework +if ($LASTEXITCODE -ne 0) { + throw 'Benchmark consumer restore failed.' +} + +& dotnet build $consumerProject ` + -c Release ` + --no-restore ` + -p:MiniExcelRustPackageVersion=$Version ` + -p:MiniExcelVersion=$MiniExcelVersion ` + -p:TargetFramework=$TargetFramework +if ($LASTEXITCODE -ne 0) { + throw 'Benchmark consumer build failed.' +} + +& dotnet $consumerDll generate $workbook $Rows $Columns +if ($LASTEXITCODE -ne 0) { + throw 'Benchmark workbook generation failed.' +} +& dotnet $consumerDll verify $workbook false +if ($LASTEXITCODE -ne 0) { + throw 'Managed and Rust benchmark parity failed.' +} + +function Invoke-BenchmarkProcess { + param( + [string] $Runtime, + [pscustomobject] $Scenario, + [int] $Iteration + ) + + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = 'dotnet' + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @($consumerDll, $Runtime, $workbook, "$($Scenario.Passes)", "$($Scenario.WarmupPasses)")) { + $startInfo.ArgumentList.Add($argument) + } + + $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) + } + + $output = $process.StandardOutput.ReadToEnd().Trim() + $errorOutput = $process.StandardError.ReadToEnd().Trim() + if ($process.ExitCode -ne 0) { + throw "$Runtime $($Scenario.Name) benchmark failed: $errorOutput" + } + + $measurement = $output | ConvertFrom-Json + [pscustomobject]@{ + Scenario = $Scenario.Name + Runtime = $measurement.Runtime + DotNetRuntime = $measurement.DotNetRuntime + Iteration = $Iteration + Passes = $measurement.Passes + Rows = $measurement.Rows + Cells = $measurement.Cells + ElapsedMs = [Math]::Round($measurement.ElapsedMilliseconds, 2) + FirstRowMs = [Math]::Round($measurement.FirstRowMilliseconds, 2) + AllocatedMB = [Math]::Round($measurement.AllocatedBytes / 1MB, 2) + PeakWorkingSetMB = [Math]::Round($peakWorkingSet / 1MB, 2) + PeakPrivateMB = [Math]::Round($peakPrivateBytes / 1MB, 2) + } +} + +$results = [Collections.Generic.List[object]]::new() +foreach ($scenario in $scenarios) { + foreach ($iteration in 1..$Iterations) { + $order = if ($iteration % 2 -eq 1) { @('managed', 'rust') } else { @('rust', 'managed') } + foreach ($runtime in $order) { + $results.Add((Invoke-BenchmarkProcess -Runtime $runtime -Scenario $scenario -Iteration $iteration)) + } + } +} + +foreach ($scenario in $scenarios) { + $scenarioResults = @($results | Where-Object Scenario -eq $scenario.Name) + if (($scenarioResults.Rows | Select-Object -Unique).Count -ne 1 -or + ($scenarioResults.Cells | Select-Object -Unique).Count -ne 1) { + throw "$($scenario.Name): managed and Rust runners returned different row or cell counts." + } +} + +$summaries = foreach ($scenario in $scenarios) { + $groups = @($results | Where-Object Scenario -eq $scenario.Name | Group-Object Runtime) + $managed = $groups | Where-Object Name -eq 'MiniExcelV2' + $rust = $groups | Where-Object Name -eq 'MiniExcelRust' + $managedElapsed = ($managed.Group.ElapsedMs | Measure-Object -Average).Average + $rustElapsed = ($rust.Group.ElapsedMs | Measure-Object -Average).Average + $managedAllocated = ($managed.Group.AllocatedMB | Measure-Object -Average).Average + $rustAllocated = ($rust.Group.AllocatedMB | Measure-Object -Average).Average + $managedWorkingSet = ($managed.Group.PeakWorkingSetMB | Measure-Object -Average).Average + $rustWorkingSet = ($rust.Group.PeakWorkingSetMB | Measure-Object -Average).Average + + [pscustomobject]@{ + Scenario = $scenario.Name + Runtime = 'MiniExcelV2' + ElapsedMs = [Math]::Round($managedElapsed, 2) + FirstRowMs = [Math]::Round(($managed.Group.FirstRowMs | Measure-Object -Average).Average, 2) + AllocatedMB = [Math]::Round($managedAllocated, 2) + PeakWorkingSetMB = [Math]::Round($managedWorkingSet, 2) + PeakPrivateMB = [Math]::Round(($managed.Group.PeakPrivateMB | Measure-Object -Average).Average, 2) + Speedup = 1 + AllocationReductionPercent = 0 + WorkingSetReductionPercent = 0 + } + [pscustomobject]@{ + Scenario = $scenario.Name + Runtime = 'MiniExcelRust' + ElapsedMs = [Math]::Round($rustElapsed, 2) + FirstRowMs = [Math]::Round(($rust.Group.FirstRowMs | Measure-Object -Average).Average, 2) + AllocatedMB = [Math]::Round($rustAllocated, 2) + PeakWorkingSetMB = [Math]::Round($rustWorkingSet, 2) + PeakPrivateMB = [Math]::Round(($rust.Group.PeakPrivateMB | Measure-Object -Average).Average, 2) + Speedup = [Math]::Round($managedElapsed / $rustElapsed, 2) + AllocationReductionPercent = [Math]::Round((1 - $rustAllocated / $managedAllocated) * 100, 1) + WorkingSetReductionPercent = [Math]::Round((1 - $rustWorkingSet / $managedWorkingSet) * 100, 1) + } +} + +$metadata = [ordered]@{ + TimestampUtc = [DateTime]::UtcNow.ToString('O') + OperatingSystem = [Runtime.InteropServices.RuntimeInformation]::OSDescription + Architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + Rid = $Rid + DotNetSdk = (& dotnet --version).Trim() + TargetFramework = $TargetFramework + DotNetRuntime = (@($results.DotNetRuntime | Select-Object -Unique) -join ', ') + MiniExcelVersion = $MiniExcelVersion + MiniExcelRustVersion = $Version + RustVersion = '1.85.0' + Rows = $Rows + Columns = $Columns + WorkbookSha256 = (Get-FileHash $workbook -Algorithm SHA256).Hash.ToLowerInvariant() + PackageSha256 = (Get-FileHash $package -Algorithm SHA256).Hash.ToLowerInvariant() + Iterations = $Iterations +} + +$jsonPath = Join-Path $OutputDirectory "benchmark-$Rid.json" +$markdownPath = Join-Path $OutputDirectory "benchmark-$Rid.md" +[ordered]@{ Metadata = $metadata; Results = $results; Summary = $summaries } | + ConvertTo-Json -Depth 6 | + Set-Content $jsonPath + +$markdown = [Collections.Generic.List[string]]::new() +$markdown.Add("# MiniExcelRust benchmark ($Rid)") +$markdown.Add('') +$markdown.Add("- Date (UTC): $($metadata.TimestampUtc)") +$markdown.Add("- OS: $($metadata.OperatingSystem)") +$markdown.Add("- Architecture: $($metadata.Architecture)") +$markdown.Add("- .NET SDK: $($metadata.DotNetSdk)") +$markdown.Add("- Target framework: $($metadata.TargetFramework)") +$markdown.Add("- .NET runtime: $($metadata.DotNetRuntime)") +$markdown.Add("- Baseline: MiniExcel $($metadata.MiniExcelVersion)") +$markdown.Add("- Candidate: MiniExcelRust $($metadata.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 | Elapsed (ms) | First row (ms) | Managed allocation (MB) | Peak working set (MB) | Peak private (MB) | Speedup |') +$markdown.Add('| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |') +foreach ($summary in $summaries) { + $markdown.Add("| $($summary.Scenario) | $($summary.Runtime) | $($summary.ElapsedMs) | $($summary.FirstRowMs) | $($summary.AllocatedMB) | $($summary.PeakWorkingSetMB) | $($summary.PeakPrivateMB) | $($summary.Speedup)x |") +} +$markdown.Add('') +$markdown.Add('Managed allocation excludes allocations made inside Rust. Peak working set and peak private bytes include the complete process.') +$markdown | Set-Content $markdownPath + +$results | Format-Table Scenario, Runtime, Iteration, ElapsedMs, FirstRowMs, AllocatedMB, PeakWorkingSetMB, PeakPrivateMB -AutoSize +$summaries | Format-Table Scenario, Runtime, ElapsedMs, FirstRowMs, AllocatedMB, PeakWorkingSetMB, Speedup, AllocationReductionPercent, WorkingSetReductionPercent -AutoSize +Write-Host "JSON: $jsonPath" +Write-Host "Markdown: $markdownPath" \ No newline at end of file diff --git a/build/Publish-Benchmark-Results.ps1 b/build/Publish-Benchmark-Results.ps1 new file mode 100644 index 0000000..63198af --- /dev/null +++ b/build/Publish-Benchmark-Results.ps1 @@ -0,0 +1,79 @@ +[CmdletBinding()] +param( + [string] $InputDirectory, + + [string] $OutputDirectory +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +if ([string]::IsNullOrWhiteSpace($InputDirectory)) { + $InputDirectory = Join-Path $repositoryRoot 'artifacts/benchmarks' +} +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $repositoryRoot 'docs/benchmarks' +} + +$InputDirectory = [IO.Path]::GetFullPath($InputDirectory) +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +$resultFiles = @(Get-ChildItem $InputDirectory -Filter 'benchmark-*.json' -File | Sort-Object Name) +if ($resultFiles.Count -eq 0) { + throw "No benchmark JSON files found in $InputDirectory." +} + +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +$published = foreach ($resultFile in $resultFiles) { + $result = Get-Content $resultFile.FullName -Raw | ConvertFrom-Json + $rid = $result.Metadata.Rid + if ([string]::IsNullOrWhiteSpace($rid)) { + throw "$($resultFile.Name) does not contain Metadata.Rid." + } + + $markdownSource = Join-Path $InputDirectory "benchmark-$rid.md" + if (-not (Test-Path $markdownSource)) { + throw "Missing Markdown report for ${rid}: $markdownSource" + } + + Copy-Item $resultFile.FullName (Join-Path $OutputDirectory $resultFile.Name) -Force + Copy-Item $markdownSource (Join-Path $OutputDirectory "benchmark-$rid.md") -Force + + foreach ($scenario in @('Cold', 'Warm')) { + $baseline = $result.Summary | Where-Object { $_.Scenario -eq $scenario -and $_.Runtime -eq 'MiniExcelV2' } + $candidate = $result.Summary | Where-Object { $_.Scenario -eq $scenario -and $_.Runtime -eq 'MiniExcelRust' } + if ($null -eq $baseline -or $null -eq $candidate) { + throw "$($resultFile.Name) is missing the $scenario summary." + } + + [pscustomobject]@{ + Rid = $rid + Scenario = $scenario + DotNetRuntime = $result.Metadata.DotNetRuntime + MiniExcelVersion = $result.Metadata.MiniExcelVersion + TimestampUtc = ([DateTimeOffset]$result.Metadata.TimestampUtc).ToUniversalTime() + BaselineElapsedMs = $baseline.ElapsedMs + CandidateElapsedMs = $candidate.ElapsedMs + Speedup = $candidate.Speedup + AllocationReductionPercent = $candidate.AllocationReductionPercent + WorkingSetReductionPercent = $candidate.WorkingSetReductionPercent + } + } +} + +$latestTimestamp = ($published.TimestampUtc | Sort-Object -Descending | Select-Object -First 1) +$index = [Collections.Generic.List[string]]::new() +$index.Add('# Cross-platform benchmark results') +$index.Add('') +$index.Add("Last updated (UTC): $($latestTimestamp.ToString('yyyy-MM-dd HH:mm:ss'))") +$index.Add('') +$index.Add('Each platform validates every returned row and cell before timing equivalent queries in alternating fresh processes.') +$index.Add('') +$index.Add('| RID | Scenario | .NET runtime | MiniExcel | MiniExcel (ms) | MiniExcelRust (ms) | Speedup | Allocation reduction | Working-set reduction |') +$index.Add('| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |') +foreach ($row in $published | Sort-Object Rid, Scenario) { + $index.Add("| [$($row.Rid)](benchmark-$($row.Rid).md) | $($row.Scenario) | $($row.DotNetRuntime) | $($row.MiniExcelVersion) | $($row.BaselineElapsedMs) | $($row.CandidateElapsedMs) | $($row.Speedup)x | $($row.AllocationReductionPercent)% | $($row.WorkingSetReductionPercent)% |") +} +$index.Add('') +$index.Add('Managed allocation excludes allocations made inside Rust. Each linked report includes peak process memory and environment metadata; the adjacent JSON contains every raw iteration and input hash.') +$index | Set-Content (Join-Path $OutputDirectory 'README.md') + +Write-Host "Published $($resultFiles.Count) platform result set(s) to $OutputDirectory." \ No newline at end of file diff --git a/build/Test-Package.ps1 b/build/Test-Package.ps1 index 70ee724..ab5bb35 100644 --- a/build/Test-Package.ps1 +++ b/build/Test-Package.ps1 @@ -3,9 +3,17 @@ param( [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'osx-x64', 'osx-arm64')] [string] $Rid = 'win-x64', - [string] $Version = '0.1.0-preview.1', + [string] $Version = '0.1.0-preview.2', - [switch] $SkipNativeBuild + [string] $MiniExcelVersion = '2.0.0-preview.4', + + [switch] $SkipNativeBuild, + + [ValidateRange(100, 1000000)] + [int] $LifecycleIterations = 1000, + + [ValidateRange(1, 1024)] + [int] $MaxPrivateGrowthMb = 32 ) $ErrorActionPreference = 'Stop' @@ -43,13 +51,16 @@ finally { & dotnet restore $consumerProject ` --force ` --source $packageDirectory ` - -p:MiniExcelRustPackageVersion=$Version + -p:MiniExcelRustPackageVersion=$Version ` + -p:MiniExcelVersion=$MiniExcelVersion if ($LASTEXITCODE -ne 0) { throw 'Package consumer restore failed.' } & dotnet run --project $consumerProject -c Release --no-restore ` - -p:MiniExcelRustPackageVersion=$Version + -p:MiniExcelRustPackageVersion=$Version ` + -p:MiniExcelVersion=$MiniExcelVersion ` + -- suite $LifecycleIterations $MaxPrivateGrowthMb if ($LASTEXITCODE -ne 0) { throw 'Package consumer smoke test failed.' } diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md new file mode 100644 index 0000000..1a287b0 --- /dev/null +++ b/docs/benchmarks/README.md @@ -0,0 +1,12 @@ +# Cross-platform benchmark results + +Last updated (UTC): 2026-09-05 14:05:30 + +Each platform validates every returned row and cell before timing equivalent queries in alternating fresh processes. + +| RID | Scenario | .NET runtime | MiniExcel | MiniExcel (ms) | MiniExcelRust (ms) | Speedup | Allocation reduction | Working-set reduction | +| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | +| [win-x64](benchmark-win-x64.md) | Cold | .NET 10.0.3 | 2.0.0-preview.4 | 2891.79 | 1041.49 | 2.78x | 92.5% | 20.3% | +| [win-x64](benchmark-win-x64.md) | Warm | .NET 10.0.3 | 2.0.0-preview.4 | 6136.27 | 2770.39 | 2.21x | 93.5% | 19.3% | + +Managed allocation excludes allocations made inside Rust. Each linked report includes peak process memory and environment metadata; the adjacent JSON contains every raw iteration and input hash. diff --git a/docs/benchmarks/benchmark-win-x64.json b/docs/benchmarks/benchmark-win-x64.json new file mode 100644 index 0000000..79a8d29 --- /dev/null +++ b/docs/benchmarks/benchmark-win-x64.json @@ -0,0 +1,351 @@ +{ + "Metadata": { + "TimestampUtc": "2026-09-05T14:05:30.0420829Z", + "OperatingSystem": "Microsoft Windows 10.0.19045", + "Architecture": "X64", + "Rid": "win-x64", + "DotNetSdk": "10.0.103", + "TargetFramework": "net10.0", + "DotNetRuntime": ".NET 10.0.3", + "MiniExcelVersion": "2.0.0-preview.4", + "MiniExcelRustVersion": "0.1.0-preview.2", + "RustVersion": "1.85.0", + "Rows": 100000, + "Columns": 10, + "WorkbookSha256": "b2af7da37611c99a60b34a6fd90d8a3e24c3e0519591dd7afb65276d0f05d6c8", + "PackageSha256": "58f3b31b205825be5b1ddc01357d74f47c49be0888ecee48b2dc18c54744c2a3", + "Iterations": 5 + }, + "Results": [ + { + "Scenario": "Cold", + "Runtime": "MiniExcelV2", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 1, + "Passes": 1, + "Rows": 100000, + "Cells": 1000000, + "ElapsedMs": 3224.45, + "FirstRowMs": 774.92, + "AllocatedMB": 1554.95, + "PeakWorkingSetMB": 54.43, + "PeakPrivateMB": 28.71 + }, + { + "Scenario": "Cold", + "Runtime": "MiniExcelRust", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 1, + "Passes": 1, + "Rows": 100000, + "Cells": 1000000, + "ElapsedMs": 1150.92, + "FirstRowMs": 399.67, + "AllocatedMB": 115.36, + "PeakWorkingSetMB": 42.66, + "PeakPrivateMB": 25.12 + }, + { + "Scenario": "Cold", + "Runtime": "MiniExcelRust", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 2, + "Passes": 1, + "Rows": 100000, + "Cells": 1000000, + "ElapsedMs": 1106.52, + "FirstRowMs": 413.31, + "AllocatedMB": 117.03, + "PeakWorkingSetMB": 41.52, + "PeakPrivateMB": 25.15 + }, + { + "Scenario": "Cold", + "Runtime": "MiniExcelV2", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 2, + "Passes": 1, + "Rows": 100000, + "Cells": 1000000, + "ElapsedMs": 2820.27, + "FirstRowMs": 778.24, + "AllocatedMB": 1554.5, + "PeakWorkingSetMB": 53.87, + "PeakPrivateMB": 29.0 + }, + { + "Scenario": "Cold", + "Runtime": "MiniExcelV2", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 3, + "Passes": 1, + "Rows": 100000, + "Cells": 1000000, + "ElapsedMs": 2724.11, + "FirstRowMs": 726.09, + "AllocatedMB": 1555.65, + "PeakWorkingSetMB": 52.74, + "PeakPrivateMB": 28.63 + }, + { + "Scenario": "Cold", + "Runtime": "MiniExcelRust", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 3, + "Passes": 1, + "Rows": 100000, + "Cells": 1000000, + "ElapsedMs": 1024.3, + "FirstRowMs": 332.8, + "AllocatedMB": 115.84, + "PeakWorkingSetMB": 41.88, + "PeakPrivateMB": 25.15 + }, + { + "Scenario": "Cold", + "Runtime": "MiniExcelRust", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 4, + "Passes": 1, + "Rows": 100000, + "Cells": 1000000, + "ElapsedMs": 958.28, + "FirstRowMs": 322.11, + "AllocatedMB": 117.0, + "PeakWorkingSetMB": 44.1, + "PeakPrivateMB": 25.18 + }, + { + "Scenario": "Cold", + "Runtime": "MiniExcelV2", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 4, + "Passes": 1, + "Rows": 100000, + "Cells": 1000000, + "ElapsedMs": 2849.46, + "FirstRowMs": 729.01, + "AllocatedMB": 1554.91, + "PeakWorkingSetMB": 52.52, + "PeakPrivateMB": 28.86 + }, + { + "Scenario": "Cold", + "Runtime": "MiniExcelV2", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 5, + "Passes": 1, + "Rows": 100000, + "Cells": 1000000, + "ElapsedMs": 2840.64, + "FirstRowMs": 776.06, + "AllocatedMB": 1554.98, + "PeakWorkingSetMB": 54.25, + "PeakPrivateMB": 28.72 + }, + { + "Scenario": "Cold", + "Runtime": "MiniExcelRust", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 5, + "Passes": 1, + "Rows": 100000, + "Cells": 1000000, + "ElapsedMs": 967.43, + "FirstRowMs": 320.21, + "AllocatedMB": 117.12, + "PeakWorkingSetMB": 43.4, + "PeakPrivateMB": 25.19 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelV2", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 1, + "Passes": 3, + "Rows": 300000, + "Cells": 3000000, + "ElapsedMs": 6114.83, + "FirstRowMs": 395.59, + "AllocatedMB": 4674.55, + "PeakWorkingSetMB": 57.1, + "PeakPrivateMB": 33.18 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelRust", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 1, + "Passes": 3, + "Rows": 300000, + "Cells": 3000000, + "ElapsedMs": 2663.1, + "FirstRowMs": 321.71, + "AllocatedMB": 304.99, + "PeakWorkingSetMB": 46.24, + "PeakPrivateMB": 28.93 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelRust", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 2, + "Passes": 3, + "Rows": 300000, + "Cells": 3000000, + "ElapsedMs": 2807.06, + "FirstRowMs": 323.25, + "AllocatedMB": 304.99, + "PeakWorkingSetMB": 45.7, + "PeakPrivateMB": 29.07 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelV2", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 2, + "Passes": 3, + "Rows": 300000, + "Cells": 3000000, + "ElapsedMs": 5938.35, + "FirstRowMs": 389.26, + "AllocatedMB": 4674.55, + "PeakWorkingSetMB": 57.41, + "PeakPrivateMB": 33.7 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelV2", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 3, + "Passes": 3, + "Rows": 300000, + "Cells": 3000000, + "ElapsedMs": 5963.81, + "FirstRowMs": 375.66, + "AllocatedMB": 4674.53, + "PeakWorkingSetMB": 56.39, + "PeakPrivateMB": 33.18 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelRust", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 3, + "Passes": 3, + "Rows": 300000, + "Cells": 3000000, + "ElapsedMs": 2759.87, + "FirstRowMs": 317.09, + "AllocatedMB": 304.99, + "PeakWorkingSetMB": 46.86, + "PeakPrivateMB": 28.75 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelRust", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 4, + "Passes": 3, + "Rows": 300000, + "Cells": 3000000, + "ElapsedMs": 2717.37, + "FirstRowMs": 340.56, + "AllocatedMB": 304.99, + "PeakWorkingSetMB": 45.2, + "PeakPrivateMB": 28.88 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelV2", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 4, + "Passes": 3, + "Rows": 300000, + "Cells": 3000000, + "ElapsedMs": 6246.7, + "FirstRowMs": 420.71, + "AllocatedMB": 4674.55, + "PeakWorkingSetMB": 56.43, + "PeakPrivateMB": 33.21 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelV2", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 5, + "Passes": 3, + "Rows": 300000, + "Cells": 3000000, + "ElapsedMs": 6417.68, + "FirstRowMs": 388.48, + "AllocatedMB": 4674.55, + "PeakWorkingSetMB": 58.0, + "PeakPrivateMB": 33.53 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelRust", + "DotNetRuntime": ".NET 10.0.3", + "Iteration": 5, + "Passes": 3, + "Rows": 300000, + "Cells": 3000000, + "ElapsedMs": 2904.56, + "FirstRowMs": 331.73, + "AllocatedMB": 304.99, + "PeakWorkingSetMB": 46.38, + "PeakPrivateMB": 29.02 + } + ], + "Summary": [ + { + "Scenario": "Cold", + "Runtime": "MiniExcelV2", + "ElapsedMs": 2891.79, + "FirstRowMs": 756.86, + "AllocatedMB": 1555.0, + "PeakWorkingSetMB": 53.56, + "PeakPrivateMB": 28.78, + "Speedup": 1, + "AllocationReductionPercent": 0, + "WorkingSetReductionPercent": 0 + }, + { + "Scenario": "Cold", + "Runtime": "MiniExcelRust", + "ElapsedMs": 1041.49, + "FirstRowMs": 357.62, + "AllocatedMB": 116.47, + "PeakWorkingSetMB": 42.71, + "PeakPrivateMB": 25.16, + "Speedup": 2.78, + "AllocationReductionPercent": 92.5, + "WorkingSetReductionPercent": 20.3 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelV2", + "ElapsedMs": 6136.27, + "FirstRowMs": 393.94, + "AllocatedMB": 4674.55, + "PeakWorkingSetMB": 57.07, + "PeakPrivateMB": 33.36, + "Speedup": 1, + "AllocationReductionPercent": 0, + "WorkingSetReductionPercent": 0 + }, + { + "Scenario": "Warm", + "Runtime": "MiniExcelRust", + "ElapsedMs": 2770.39, + "FirstRowMs": 326.87, + "AllocatedMB": 304.99, + "PeakWorkingSetMB": 46.08, + "PeakPrivateMB": 28.93, + "Speedup": 2.21, + "AllocationReductionPercent": 93.5, + "WorkingSetReductionPercent": 19.3 + } + ] +} diff --git a/docs/benchmarks/benchmark-win-x64.md b/docs/benchmarks/benchmark-win-x64.md new file mode 100644 index 0000000..0ff2692 --- /dev/null +++ b/docs/benchmarks/benchmark-win-x64.md @@ -0,0 +1,21 @@ +# MiniExcelRust benchmark (win-x64) + +- Date (UTC): 2026-09-05T14:05:30.0420829Z +- OS: Microsoft Windows 10.0.19045 +- Architecture: X64 +- .NET SDK: 10.0.103 +- Target framework: net10.0 +- .NET runtime: .NET 10.0.3 +- Baseline: MiniExcel 2.0.0-preview.4 +- Candidate: MiniExcelRust 0.1.0-preview.2 +- Workbook: 100000 rows x 10 columns +- Iterations: 5 fresh processes per runtime and scenario + +| Scenario | Runtime | Elapsed (ms) | First row (ms) | Managed allocation (MB) | Peak working set (MB) | Peak private (MB) | Speedup | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Cold | MiniExcelV2 | 2891.79 | 756.86 | 1555 | 53.56 | 28.78 | 1x | +| Cold | MiniExcelRust | 1041.49 | 357.62 | 116.47 | 42.71 | 25.16 | 2.78x | +| Warm | MiniExcelV2 | 6136.27 | 393.94 | 4674.55 | 57.07 | 33.36 | 1x | +| Warm | MiniExcelRust | 2770.39 | 326.87 | 304.99 | 46.08 | 28.93 | 2.21x | + +Managed allocation excludes allocations made inside Rust. Peak working set and peak private bytes include the complete process. diff --git a/src/MiniExcelRust/MiniExcelRust.csproj b/src/MiniExcelRust/MiniExcelRust.csproj index 23b6f7f..7071358 100644 --- a/src/MiniExcelRust/MiniExcelRust.csproj +++ b/src/MiniExcelRust/MiniExcelRust.csproj @@ -8,7 +8,7 @@ MiniExcelLibs MiniExcelRust MiniExcelRust - 0.1.0-preview.1 + 0.1.0-preview.2 MiniExcelRust Low-memory, high-performance XLSX queries for .NET powered by MiniExcel for Rust. Mini-Software diff --git a/tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj b/tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj index 244880d..1680d84 100644 --- a/tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj +++ b/tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj @@ -5,10 +5,13 @@ net8.0 enable enable - 0.1.0-preview.1 + 0.1.0-preview.2 + 2.0.0-preview.4 + https://api.nuget.org/v3/index.json + diff --git a/tests/MiniExcelRust.PackageTests/Program.cs b/tests/MiniExcelRust.PackageTests/Program.cs index 4c760c7..5d51b7e 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -1,30 +1,70 @@ +using System.Diagnostics; +using System.Globalization; using System.IO.Compression; using System.Text; +using System.Text.Json; +using MiniExcelLib; +using MiniExcelLib.OpenXml; using MiniExcelLibs; +using ManagedMiniExcel = MiniExcelLib.MiniExcel; -var workbookPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); +if (args.Length == 0) + return RunSuite(lifecycleIterations: 1_000, maxPrivateGrowthMb: 32); -try +return args[0].ToLowerInvariant() switch { + "suite" => RunSuite( + args.Length >= 2 ? int.Parse(args[1], CultureInfo.InvariantCulture) : 1_000, + args.Length >= 3 ? int.Parse(args[2], CultureInfo.InvariantCulture) : 32), + "verify" => VerifyFileParity(args), + "generate" => GenerateBenchmarkWorkbook(args), + "managed" => Benchmark(args, useRust: false), + "rust" => Benchmark(args, useRust: true), + _ => Usage() +}; + +static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) +{ + var workbookPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { CreateWorkbook(workbookPath); + VerifyParity(workbookPath); + VerifyLifecycle(workbookPath, lifecycleIterations, maxPrivateGrowthMb); + Console.WriteLine("MiniExcelRust parity and lifecycle suite passed."); + return 0; + } + finally + { + if (File.Exists(workbookPath)) + File.Delete(workbookPath); + } +} - var rows = MiniExcelRust.Query(workbookPath, useHeaderRow: true).ToList(); - Require(rows.Count == 2, $"Expected 2 rows, received {rows.Count}."); - Require(Equals(rows[0]["Name"], "alpha"), "The first string value did not match."); - Require(Equals(rows[0]["Value"], 42d), "The first numeric value did not match."); - Require(Equals(rows[1]["Name"], "beta"), "The second string value did not match."); - Require(Equals(rows[1]["Value"], true), "The boolean value did not match."); +static void VerifyParity(string path) +{ + var scenarios = new[] + { + new QueryScenario("header", true, "Sheet1", "A1"), + new QueryScenario("headerless", false, "Sheet1", "A1"), + new QueryScenario("sheet-and-start-cell", true, "Data", "C2") + }; - using (var enumerator = MiniExcelRust.Query(workbookPath).GetEnumerator()) - Require(enumerator.MoveNext(), "The early-disposal query returned no rows."); + foreach (var scenario in scenarios) + { + var managedRows = QueryManaged(path, scenario.UseHeaderRow, scenario.SheetName, scenario.StartCell).ToList(); + var rustRows = MiniExcelRust.Query(path, scenario.UseHeaderRow, scenario.SheetName, scenario.StartCell).ToList(); + CompareRows(managedRows, rustRows, scenario.Name); + } - File.Delete(workbookPath); - Console.WriteLine("MiniExcelRust package smoke test passed."); -} -finally -{ - if (File.Exists(workbookPath)) - File.Delete(workbookPath); + var rows = MiniExcelRust.Query(path, useHeaderRow: true, sheetName: "Sheet1").ToList(); + Require(rows.Count == 3, $"Expected 3 rows, received {rows.Count}."); + Require(Equals(rows[0]["Name"], "alpha"), "The first string value did not match."); + Require(Equals(rows[0]["Value"], 42d), "The first numeric value did not match."); + Require(Equals(rows[0]["Note"], "Taiwan 台灣"), "The Unicode value did not match."); + Require(Equals(rows[1]["Name"], "beta"), "The second string value did not match."); + Require(Equals(rows[1]["Value"], true), "The boolean value did not match."); + Require(rows[1]["Note"] is null, "The empty value should be null."); } static void Require(bool condition, string message) @@ -33,6 +73,222 @@ static void Require(bool condition, string message) throw new InvalidOperationException(message); } +static IEnumerable> QueryManaged( + string path, + bool useHeaderRow, + string? sheetName = null, + string startCell = "A1") +{ + var importer = ManagedMiniExcel.Importers.GetOpenXmlImporter(); + foreach (IDictionary row in importer.Query( + path, + hasHeaderRow: useHeaderRow, + sheetName: sheetName, + startCell: startCell)) + yield return row; +} + +static void CompareRows( + IReadOnlyList> expected, + IReadOnlyList> actual, + string scenario) +{ + Require(expected.Count == actual.Count, $"{scenario}: row count differs: managed={expected.Count}, rust={actual.Count}."); + for (var rowIndex = 0; rowIndex < expected.Count; rowIndex++) + CompareRow(expected[rowIndex], actual[rowIndex], scenario, rowIndex); +} + +static void CompareRow( + IDictionary expected, + IDictionary actual, + string scenario, + long rowIndex) +{ + Require( + expected.Keys.SequenceEqual(actual.Keys, StringComparer.Ordinal), + $"{scenario}: column order differs at row {rowIndex}."); + foreach (var key in expected.Keys) + { + Require( + Equals(expected[key], actual[key]), + $"{scenario}: value differs at row {rowIndex}, column {key}: managed={expected[key] ?? ""}, rust={actual[key] ?? ""}."); + } +} + +static int VerifyFileParity(string[] arguments) +{ + if (arguments.Length is < 2 or > 3) + return Usage(); + + var path = Path.GetFullPath(arguments[1]); + var useHeaderRow = arguments.Length == 3 && bool.Parse(arguments[2]); + using var managed = QueryManaged(path, useHeaderRow).GetEnumerator(); + using var rust = MiniExcelRust.Query(path, useHeaderRow).GetEnumerator(); + long rowIndex = 0; + + while (true) + { + var hasManaged = managed.MoveNext(); + var hasRust = rust.MoveNext(); + Require(hasManaged == hasRust, $"benchmark-parity: row count differs after row {rowIndex}."); + if (!hasManaged) + break; + CompareRow(managed.Current, rust.Current, "benchmark-parity", rowIndex); + rowIndex++; + } + + Console.WriteLine($"Verified {rowIndex} rows against the configured MiniExcel baseline."); + return 0; +} + +static void VerifyLifecycle(string path, int iterations, int maxPrivateGrowthMb) +{ + if (iterations < 100) + throw new ArgumentOutOfRangeException(nameof(iterations), "Lifecycle iterations must be at least 100."); + + for (var iteration = 0; iteration < 50; iteration++) + QueryAndDisposeEarly(path); + + ForceCollection(); + var baseline = CaptureResources(); + var samples = new List { baseline }; + var sampleInterval = Math.Max(100, iterations / 10); + + for (var iteration = 1; iteration <= iterations; iteration++) + { + QueryAndDisposeEarly(path); + if (iteration % 25 == 0) + _ = MiniExcelRust.Query(path, useHeaderRow: true).Count(); + if (iteration % sampleInterval == 0) + { + ForceCollection(); + samples.Add(CaptureResources()); + } + } + + using (File.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { } + + var final = samples[^1]; + var privateGrowth = final.PrivateBytes - baseline.PrivateBytes; + var workingSetGrowth = final.WorkingSetBytes - baseline.WorkingSetBytes; + var maxPrivateGrowth = maxPrivateGrowthMb * 1024L * 1024L; + Require( + privateGrowth <= maxPrivateGrowth, + $"Private memory grew by {ToMb(privateGrowth):F2} MB after {iterations} early-disposal cycles; limit is {maxPrivateGrowthMb} MB."); + + if (baseline.ResourceCount is not null && final.ResourceCount is not null) + { + Require( + final.ResourceCount - baseline.ResourceCount <= 4, + $"Native handle/file-descriptor count grew from {baseline.ResourceCount} to {final.ResourceCount}."); + } + + Console.WriteLine( + $"Lifecycle: iterations={iterations}, private-growth={ToMb(privateGrowth):F2} MB, " + + $"working-set-growth={ToMb(workingSetGrowth):F2} MB, resources={baseline.ResourceCount?.ToString() ?? "n/a"}->{final.ResourceCount?.ToString() ?? "n/a"}."); +} + +static void QueryAndDisposeEarly(string path) +{ + using var rows = MiniExcelRust.Query(path).GetEnumerator(); + Require(rows.MoveNext(), "The lifecycle fixture did not contain a row."); +} + +static ResourceSample CaptureResources() +{ + using var process = Process.GetCurrentProcess(); + process.Refresh(); + return new ResourceSample(process.PrivateMemorySize64, process.WorkingSet64, GetResourceCount(process)); +} + +static int? GetResourceCount(Process process) +{ + if (OperatingSystem.IsLinux() && Directory.Exists("/proc/self/fd")) + return Directory.EnumerateFileSystemEntries("/proc/self/fd").Count(); + + try + { + return process.HandleCount; + } + catch (PlatformNotSupportedException) + { + return null; + } +} + +static void ForceCollection() +{ + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); +} + +static double ToMb(long bytes) => bytes / 1024d / 1024d; + +static int GenerateBenchmarkWorkbook(string[] arguments) +{ + if (arguments.Length is < 2 or > 4) + return Usage(); + + var path = Path.GetFullPath(arguments[1]); + var rows = arguments.Length >= 3 ? int.Parse(arguments[2], CultureInfo.InvariantCulture) : 100_000; + var columns = arguments.Length >= 4 ? int.Parse(arguments[3], CultureInfo.InvariantCulture) : 10; + CreateBenchmarkWorkbook(path, rows, columns); + Console.WriteLine($"Generated {rows}x{columns} benchmark workbook at {path}."); + return 0; +} + +static int Benchmark(string[] arguments, bool useRust) +{ + if (arguments.Length is < 2 or > 4) + return Usage(); + + var path = Path.GetFullPath(arguments[1]); + var passes = 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); + + ForceCollection(); + var allocatedBefore = GC.GetTotalAllocatedBytes(precise: true); + var stopwatch = Stopwatch.StartNew(); + var firstRowMilliseconds = 0d; + long rowCount = 0; + long cellCount = 0; + + for (var pass = 0; pass < passes; pass++) + { + var rows = useRust ? MiniExcelRust.Query(path) : QueryManaged(path, useHeaderRow: false); + foreach (var row in rows) + { + if (rowCount == 0) + firstRowMilliseconds = stopwatch.Elapsed.TotalMilliseconds; + rowCount++; + cellCount += row.Count; + } + } + + stopwatch.Stop(); + Console.WriteLine(JsonSerializer.Serialize(new BenchmarkResult( + useRust ? "MiniExcelRust" : "MiniExcelV2", + System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, + passes, + rowCount, + cellCount, + stopwatch.Elapsed.TotalMilliseconds, + firstRowMilliseconds, + GC.GetTotalAllocatedBytes(precise: true) - allocatedBefore))); + return 0; +} + +static void Consume(string path, bool useRust) +{ + var rows = useRust ? MiniExcelRust.Query(path) : QueryManaged(path, useHeaderRow: false); + foreach (var row in rows) + _ = row.Count; +} + static void CreateWorkbook(string path) { using var archive = ZipFile.Open(path, ZipArchiveMode.Create); @@ -43,6 +299,7 @@ static void CreateWorkbook(string path) + """); AddEntry(archive, "_rels/.rels", """ @@ -54,25 +311,91 @@ static void CreateWorkbook(string path) AddEntry(archive, "xl/workbook.xml", """ - + """); AddEntry(archive, "xl/_rels/workbook.xml.rels", """ + """); AddEntry(archive, "xl/worksheets/sheet1.xml", """ - NameValue - alpha42 - beta1 + NameValueNote + alpha42Taiwan 台灣 + beta1 + gamma-7.5last """); + AddEntry(archive, "xl/worksheets/sheet2.xml", """ + + + + CodeAmount + x3.5 + y9 + + + """); +} + +static void CreateBenchmarkWorkbook(string path, int rows, int columns) +{ + if (rows < 1 || columns is < 1 or > 26) + throw new ArgumentOutOfRangeException(nameof(rows), "Rows must be positive and columns must be between 1 and 26."); + + 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) @@ -80,4 +403,28 @@ 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); -} \ No newline at end of file +} + + static int Usage() + { + Console.Error.WriteLine("Usage:"); + Console.Error.WriteLine(" PublicNuGetSmoke suite [lifecycle-iterations] [max-private-growth-mb]"); + Console.Error.WriteLine(" PublicNuGetSmoke verify [use-header-row]"); + Console.Error.WriteLine(" PublicNuGetSmoke generate [rows] [columns]"); + Console.Error.WriteLine(" PublicNuGetSmoke [passes] [warmup-passes]"); + return 2; + } + + internal sealed record QueryScenario(string Name, bool UseHeaderRow, string SheetName, string StartCell); + + internal sealed record ResourceSample(long PrivateBytes, long WorkingSetBytes, int? ResourceCount); + + internal sealed record BenchmarkResult( + string Runtime, + string DotNetRuntime, + int Passes, + long Rows, + long Cells, + double ElapsedMilliseconds, + double FirstRowMilliseconds, + long AllocatedBytes); \ No newline at end of file From 1f5bae309076095f7cb8088892e87880abda2b61 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sat, 5 Sep 2026 22:35:56 +0800 Subject: [PATCH 2/2] Update repository URLs after rename --- Cargo.toml | 4 ++-- README.md | 2 +- src/MiniExcelRust/MiniExcelRust.csproj | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 858ba33..3c267d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,5 +7,5 @@ version = "0.1.0" edition = "2024" rust-version = "1.85" license = "Apache-2.0" -repository = "https://github.com/mini-software/MiniExcelRust" -homepage = "https://github.com/mini-software/MiniExcelRust" +repository = "https://github.com/mini-software/MiniExcelRustNuGetTest" +homepage = "https://github.com/mini-software/MiniExcelRustNuGetTest" diff --git a/README.md b/README.md index eba8933..5fe242c 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ workflow runs on Windows, Linux, and macOS for x64 and Arm64; musl remains cover Alpine correctness and lifecycle job because GitHub does not provide native musl runners. The latest checked-in cross-platform summary and per-RID reports are in the -[benchmark results](https://github.com/mini-software/MiniExcelRust/blob/main/docs/benchmarks/README.md). +[benchmark results](https://github.com/mini-software/MiniExcelRustNuGetTest/blob/main/docs/benchmarks/README.md). Each report includes elapsed time, first-row latency, managed allocation, peak process memory, environment metadata, and a JSON file containing all raw iterations and hashes. diff --git a/src/MiniExcelRust/MiniExcelRust.csproj b/src/MiniExcelRust/MiniExcelRust.csproj index 7071358..d0fdd0f 100644 --- a/src/MiniExcelRust/MiniExcelRust.csproj +++ b/src/MiniExcelRust/MiniExcelRust.csproj @@ -15,8 +15,8 @@ Mini-Software excel;xlsx;rust;native;streaming Apache-2.0 - https://github.com/mini-software/MiniExcelRust - https://github.com/mini-software/MiniExcelRust + https://github.com/mini-software/MiniExcelRustNuGetTest + https://github.com/mini-software/MiniExcelRustNuGetTest git README.md false