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/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 00cb2ac..5fe242c 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/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. + +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..d0fdd0f 100644 --- a/src/MiniExcelRust/MiniExcelRust.csproj +++ b/src/MiniExcelRust/MiniExcelRust.csproj @@ -8,15 +8,15 @@ 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 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 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