diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4b4587f..f7c6df0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -43,7 +43,9 @@ src/ EncDotNet.ChartViewer/ # Legacy Avalonia + Mapsui viewer (maintenance only) tests/ EncDotNet.UnitTests/ # xUnit tests for the libraries and viewer geometry builders - EncDotNet.Benchmarks/ # BenchmarkDotNet parsing benchmarks + EncDotNet.Benchmarks/ # BenchmarkDotNet benchmarks (informational; compared on PRs) + EncDotNet.PerformanceTests/ # Allocation and retained-memory budgets (gate) + Shared/ # Deterministic synthetic S-57 cells for both scripts/ # File-based C# scripts (`dotnet run scripts/X.cs`) standards/ # IHO and ISO reference documents (md + pdf) docs/ # DocFX conceptual docs diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..5bfc8a5 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,189 @@ +name: Benchmarks + +# Compares the pull request's benchmark timings with its base branch's and posts the +# result to the job summary. Timings on shared runners are too noisy to gate on, so this +# job never fails a pull request over them; the exact allocation and retained-memory +# budgets in tests/EncDotNet.PerformanceTests, which run in CI, are the gate. +# +# Noise control: base and head run on the same runner, against the same input bytes, +# interleaved base/head then head/base so slow drift during the job affects both. The +# report flags only changes that exceed the threshold in every round. + +on: + pull_request: + branches: [ main ] + paths: + - 'src/EncDotNet.Iso8211/**' + - 'src/EncDotNet.S57/**' + - 'tests/EncDotNet.Benchmarks/**' + - 'tests/Shared/**' + - 'scripts/CompareBenchmarks.cs' + - 'scripts/DownloadBenchmarkCells.cs' + - '.github/workflows/benchmarks.yml' + workflow_dispatch: + inputs: + scope: + description: 'Benchmarks to run: core (the pull request set) or all' + type: choice + options: [ core, all ] + default: core + rounds: + description: 'Interleaved rounds of base and head' + type: number + default: 2 + +concurrency: + group: benchmarks-${{ github.ref }} + cancel-in-progress: true + +jobs: + compare: + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Determine base commit + id: base + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + else + BASE="$(git merge-base origin/main HEAD)" + fi + echo "sha=$BASE" >> "$GITHUB_OUTPUT" + # NOAA re-issues cells as updates land; refresh the cached copies weekly. + echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" + + - name: Restore NOAA cells + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/enc + key: noaa-benchmark-cells-${{ hashFiles('scripts/DownloadBenchmarkCells.cs') }}-${{ steps.base.outputs.week }} + + - name: Download NOAA cells + # Real cells are optional: without them the run falls back to synthetic cells. + continue-on-error: true + shell: bash + run: | + if [ ! -s "$RUNNER_TEMP/enc/cells.txt" ]; then + dotnet run scripts/DownloadBenchmarkCells.cs -- "$RUNNER_TEMP/enc" + fi + + - name: Choose inputs + id: inputs + shell: bash + run: | + if [ -s "$RUNNER_TEMP/enc/cells.txt" ]; then + # Real cells cover representative sizes; add the synthetic Large cell for scale. + echo "synthetic=Large" >> "$GITHUB_OUTPUT" + echo "enc_root=$RUNNER_TEMP/enc" >> "$GITHUB_OUTPUT" + else + echo "::warning::NOAA cells unavailable; benchmarking synthetic cells only." + echo "synthetic=Harbour,Approach,Large" >> "$GITHUB_OUTPUT" + echo "enc_root=" >> "$GITHUB_OUTPUT" + fi + + - name: Build head + run: dotnet build tests/EncDotNet.Benchmarks --configuration Release + + - name: Build base + id: build_base + # The base tree gets the head's benchmark harness, so both builds run identical + # benchmarks and only the library code differs. If the head's benchmarks use an API + # the base lacks, the base can't build and the report shows head results only. + continue-on-error: true + shell: bash + run: | + git worktree add "$RUNNER_TEMP/base" "${{ steps.base.outputs.sha }}" + rm -rf "$RUNNER_TEMP/base/tests/EncDotNet.Benchmarks" "$RUNNER_TEMP/base/tests/Shared" + cp -r tests/EncDotNet.Benchmarks tests/Shared tests/Directory.Build.props "$RUNNER_TEMP/base/tests/" + rm -rf "$RUNNER_TEMP/base/tests/EncDotNet.Benchmarks/bin" "$RUNNER_TEMP/base/tests/EncDotNet.Benchmarks/obj" + dotnet build "$RUNNER_TEMP/base/tests/EncDotNet.Benchmarks" --configuration Release + + - name: Run benchmarks + shell: bash + env: + ENCDOTNET_BENCHMARK_SYNTHETIC: ${{ steps.inputs.outputs.synthetic }} + ENCDOTNET_ENC_ROOT: ${{ steps.inputs.outputs.enc_root }} + SCOPE: ${{ inputs.scope || 'core' }} + ROUNDS: ${{ inputs.rounds || 2 }} + BASE_BUILT: ${{ steps.build_base.outcome == 'success' }} + run: | + DLL=tests/EncDotNet.Benchmarks/bin/Release/net10.0/EncDotNet.Benchmarks.dll + ARGS=(--filter '*' --exporters json --warmupCount 3 --iterationCount 10 --launchCount 1) + if [ "$SCOPE" = "core" ]; then + ARGS+=(--anyCategories Core) + fi + + run() { # run + echo "::group::$2" + dotnet "$1/$DLL" "${ARGS[@]}" --artifacts "$2" + echo "::endgroup::" + } + + OUT="$RUNNER_TEMP/bdn" + for ((r = 1; r <= ROUNDS; r++)); do + # Alternate the order (base, head, then head, base) so drift cancels out. + if [ "$BASE_BUILT" != "true" ]; then + run . "$OUT/head-$r" + elif ((r % 2)); then + run "$RUNNER_TEMP/base" "$OUT/base-$r" + run . "$OUT/head-$r" + else + run . "$OUT/head-$r" + run "$RUNNER_TEMP/base" "$OUT/base-$r" + fi + done + + - name: Report + if: always() + shell: bash + env: + ROUNDS: ${{ inputs.rounds || 2 }} + run: | + OUT="$RUNNER_TEMP/bdn" + BASES=(); HEADS=() + for ((r = 1; r <= ROUNDS; r++)); do + [ -d "$OUT/base-$r" ] && BASES+=("$OUT/base-$r") + [ -d "$OUT/head-$r" ] && HEADS+=("$OUT/head-$r") + done + if [ ${#HEADS[@]} -eq 0 ]; then + echo "No benchmark results to report." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + { + if [ ${#BASES[@]} -gt 0 ]; then + dotnet run scripts/CompareBenchmarks.cs -- --base "${BASES[@]}" --head "${HEADS[@]}" + else + echo "> The base commit couldn't build this pull request's benchmarks, so only head results are shown." + echo + dotnet run scripts/CompareBenchmarks.cs -- --head "${HEADS[@]}" + fi + if [ -s "$RUNNER_TEMP/enc/cells.txt" ]; then + echo + echo "NOAA cells measured:" + echo + sed 's/^/- /' "$RUNNER_TEMP/enc/cells.txt" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: ${{ runner.temp }}/bdn/**/results/* + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index dcdf106..93a18ca 100644 --- a/.gitignore +++ b/.gitignore @@ -484,6 +484,7 @@ $RECYCLE.BIN/ # Catalog files .catalog/ .expanded/ +.benchmark-cells/ # DocFX output /_site/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 560be75..2af12cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,8 +33,41 @@ they're gated to a runtime identifier. the build otherwise. - **Never commit real ENC cells or NOAA downloads.** The `scripts/` that download them write to `.catalog/` and `.expanded/`, which Git ignores. -- Parsing benchmarks live in `tests/EncDotNet.Benchmarks` - (`dotnet run -c Release --project tests/EncDotNet.Benchmarks`). + +## Performance + +Performance is checked in two ways, because elapsed time on shared CI runners +is too noisy to fail a build on, while memory figures are exact. + +- **Memory budgets (gate).** `tests/EncDotNet.PerformanceTests` measures the + bytes each reader, writer and model builder allocates, and the bytes the + parsed models retain, for a synthetic harbour cell. They run with + `dotnet test` like any other test and fail when a figure moves outside its + budget in `PerformanceBudgets.cs`: above it is a regression; well below it + means the budget must be lowered to lock in the improvement. When a change + moves a figure on purpose, update the budget to the figure the test prints + and explain why in the pull request. The project compiles every method fully + optimized up front (`TieredCompilation` off), because the tiered JIT's + optimizations change allocation counts while a process runs. +- **Timing benchmarks (informational).** `tests/EncDotNet.Benchmarks` holds + BenchmarkDotNet benchmarks. On pull requests that touch the libraries, the + `Benchmarks` workflow runs the `Core` benchmarks for the base and the head + on the same runner, interleaved, and posts a comparison to the job summary. + It flags only changes that hold in every round, and never fails the build. + +Both use synthetic cells from `tests/Shared/SyntheticS57Cell.cs`, generated +deterministically and shaped like real NOAA cells. The benchmarks also read +real cells when `ENCDOTNET_ENC_ROOT` points at a directory of them; CI +downloads a few with `scripts/DownloadBenchmarkCells.cs`. To run locally: + +```bash +dotnet test tests/EncDotNet.PerformanceTests --configuration Release +dotnet run scripts/DownloadBenchmarkCells.cs -- .benchmark-cells +ENCDOTNET_ENC_ROOT=.benchmark-cells dotnet run -c Release --project tests/EncDotNet.Benchmarks -- --anyCategories Core +``` + +`ENCDOTNET_BENCHMARK_SYNTHETIC` limits the synthetic cells, for example to +`Large`, the cell with a record longer than 99,999 bytes. ## Package management diff --git a/EncDotNet.slnx b/EncDotNet.slnx index 4a9a470..e77edd8 100644 --- a/EncDotNet.slnx +++ b/EncDotNet.slnx @@ -12,6 +12,7 @@ + diff --git a/scripts/CompareBenchmarks.cs b/scripts/CompareBenchmarks.cs new file mode 100644 index 0000000..2ef53e2 --- /dev/null +++ b/scripts/CompareBenchmarks.cs @@ -0,0 +1,206 @@ +#!/usr/bin/env dotnet run + +using System.Globalization; +using System.Text; +using System.Text.Json; + +// ============================================================================ +// CompareBenchmarks.cs — Compares BenchmarkDotNet results for a base and a head +// build and writes a Markdown report. +// +// Usage: +// dotnet run scripts/CompareBenchmarks.cs -- --base ... --head ... [--threshold 10] +// +// Each is one round's BenchmarkDotNet artifacts folder; the script reads +// every *-report-full-compressed.json file under it. Pass the rounds in the +// order they ran: base and head round N are paired to judge whether a change +// is consistent or noise. +// +// Timings from shared CI runners are noisy, so this report is informational. +// A change is flagged only when it exceeds the threshold in every round. +// ============================================================================ + +var baseDirs = new List(); +var headDirs = new List(); +var threshold = 10.0; + +List? target = null; +for (var i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--base": + target = baseDirs; + break; + case "--head": + target = headDirs; + break; + case "--threshold": + threshold = double.Parse(args[++i], CultureInfo.InvariantCulture); + target = null; + break; + default: + if (target is null) + { + Console.Error.WriteLine($"Unexpected argument: {args[i]}"); + return 1; + } + + target.Add(args[i]); + break; + } +} + +if (headDirs.Count == 0) +{ + Console.Error.WriteLine("Usage: CompareBenchmarks.cs --base ... --head ... [--threshold 10]"); + return 1; +} + +var baseRounds = baseDirs.Select(LoadRound).ToList(); +var headRounds = headDirs.Select(LoadRound).ToList(); +var host = headRounds.SelectMany(r => r.Values).Select(r => r.Host).FirstOrDefault(h => h is not null); + +var report = new StringBuilder(); +report.AppendLine("## Benchmark comparison"); +report.AppendLine(); +report.AppendLine( + $"Timings are from a shared runner and are **informational**; the allocation and retained-memory " + + $"budgets in `tests/EncDotNet.PerformanceTests` are the gate. Base and head ran in {headRounds.Count} " + + $"interleaved round(s) on the same machine. A change is flagged when it exceeds {threshold:F0}% in every round."); +if (host is not null) +{ + report.AppendLine(); + report.AppendLine($"Runner: {host}"); +} + +report.AppendLine(); +report.AppendLine("| Benchmark | Input | Base | Head | Time | Allocated (base → head) | Allocated |"); +report.AppendLine("|---|---|---:|---:|---:|---:|---:|"); + +var flagged = 0; +var keys = headRounds.SelectMany(r => r.Keys).Distinct().Order(StringComparer.Ordinal); +foreach (var key in keys) +{ + var head = headRounds.Where(r => r.ContainsKey(key)).Select(r => r[key]).ToList(); + var basis = baseRounds.Where(r => r.ContainsKey(key)).Select(r => r[key]).ToList(); + var first = head[0]; + + if (basis.Count == 0) + { + report.AppendLine( + $"| {first.Title} | {first.Input} | — | {FormatTime(Median(head.Select(h => h.MedianNs)))} | new | " + + $"{FormatBytes(first.AllocatedBytes)} | new |"); + continue; + } + + var baseTime = Median(basis.Select(b => b.MedianNs)); + var headTime = Median(head.Select(h => h.MedianNs)); + var change = (headTime / baseTime - 1) * 100; + + // Pair rounds in the order they ran; a real change shows in each pair. + var roundChanges = basis.Zip(head, (b, h) => (h.MedianNs / b.MedianNs - 1) * 100).ToList(); + var consistent = roundChanges.All(c => c >= threshold) || roundChanges.All(c => c <= -threshold); + var marker = consistent ? (change > 0 ? " ⚠️" : " ✅") : string.Empty; + if (consistent) + { + flagged++; + } + + var baseAllocated = basis[^1].AllocatedBytes; + var headAllocated = head[^1].AllocatedBytes; + var allocatedChange = baseAllocated == 0 + ? (headAllocated == 0 ? "0%" : "new") + : $"{(headAllocated / (double)baseAllocated - 1) * 100:+0.0;-0.0;0.0}%"; + + report.AppendLine( + $"| {first.Title} | {first.Input} | {FormatTime(baseTime)} | {FormatTime(headTime)} | " + + $"{change:+0.0;-0.0;0.0}%{marker} | {FormatBytes(baseAllocated)} → {FormatBytes(headAllocated)} | {allocatedChange} |"); +} + +foreach (var key in baseRounds.SelectMany(r => r.Keys).Distinct().Except(headRounds.SelectMany(r => r.Keys)).Order(StringComparer.Ordinal)) +{ + var removed = baseRounds.First(r => r.ContainsKey(key))[key]; + report.AppendLine($"| {removed.Title} | {removed.Input} | {FormatTime(removed.MedianNs)} | — | removed | | |"); +} + +report.AppendLine(); +report.AppendLine(flagged == 0 + ? "No timing change was consistent across rounds." + : $"{flagged} timing change(s) were consistent across rounds: ⚠️ slower, ✅ faster. Confirm on a quiet machine before acting on them."); + +Console.Write(report.ToString()); +return 0; + +static Dictionary LoadRound(string directory) +{ + var results = new Dictionary(StringComparer.Ordinal); + if (!Directory.Exists(directory)) + { + return results; + } + + foreach (var file in Directory.GetFiles(directory, "*-report-full-compressed.json", SearchOption.AllDirectories)) + { + using var json = JsonDocument.Parse(File.ReadAllText(file)); + var host = DescribeHost(json.RootElement); + foreach (var benchmark in json.RootElement.GetProperty("Benchmarks").EnumerateArray()) + { + if (!benchmark.TryGetProperty("Statistics", out var statistics) || statistics.ValueKind != JsonValueKind.Object) + { + continue; // The benchmark failed in this round. + } + + var type = benchmark.GetProperty("Type").GetString()!; + var method = benchmark.GetProperty("Method").GetString()!; + var title = benchmark.GetProperty("MethodTitle").GetString()!.Trim('\''); + var parameters = benchmark.GetProperty("Parameters").GetString() ?? string.Empty; + var input = parameters.StartsWith("Input=", StringComparison.Ordinal) ? parameters["Input=".Length..] : parameters; + var allocated = benchmark.TryGetProperty("Memory", out var memory) + ? memory.GetProperty("BytesAllocatedPerOperation").GetInt64() + : 0; + + results[$"{type}.{method}|{input}"] = new Result( + title, input, statistics.GetProperty("Median").GetDouble(), allocated, host); + } + } + + return results; +} + +static string? DescribeHost(JsonElement root) +{ + if (!root.TryGetProperty("HostEnvironmentInfo", out var info)) + { + return null; + } + + var processor = info.TryGetProperty("ProcessorName", out var p) ? p.GetString() : null; + var runtime = info.TryGetProperty("RuntimeVersion", out var r) ? r.GetString() : null; + var os = info.TryGetProperty("OsVersion", out var o) ? o.GetString() : null; + return string.Join(", ", new[] { processor, os, runtime }.Where(s => !string.IsNullOrEmpty(s))); +} + +static double Median(IEnumerable values) +{ + var sorted = values.Order().ToArray(); + var middle = sorted.Length / 2; + return sorted.Length % 2 == 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +} + +static string FormatTime(double nanoseconds) => nanoseconds switch +{ + >= 1e9 => $"{nanoseconds / 1e9:F2} s", + >= 1e6 => $"{nanoseconds / 1e6:F2} ms", + >= 1e3 => $"{nanoseconds / 1e3:F2} μs", + _ => $"{nanoseconds:F0} ns" +}; + +static string FormatBytes(long bytes) => bytes switch +{ + >= 1L << 20 => $"{bytes / (double)(1L << 20):F2} MB", + >= 1L << 10 => $"{bytes / (double)(1L << 10):F1} KB", + _ => $"{bytes} B" +}; + +internal sealed record Result(string Title, string Input, double MedianNs, long AllocatedBytes, string? Host); diff --git a/scripts/DownloadBenchmarkCells.cs b/scripts/DownloadBenchmarkCells.cs new file mode 100644 index 0000000..1b2442a --- /dev/null +++ b/scripts/DownloadBenchmarkCells.cs @@ -0,0 +1,99 @@ +#!/usr/bin/env dotnet run + +#:project ../src/EncDotNet.Noaa/EncDotNet.Noaa.csproj + +using System.IO.Compression; +using System.Security.Cryptography; +using EncDotNet.Noaa; + +// ============================================================================ +// DownloadBenchmarkCells.cs — Downloads a fixed set of real NOAA ENC cells for +// the benchmarks in tests/EncDotNet.Benchmarks. +// +// Usage: dotnet run scripts/DownloadBenchmarkCells.cs -- [cell...] +// +// Each cell is extracted to // with its update files. Point +// ENCDOTNET_ENC_ROOT at to include them in a benchmark run. The +// script writes /cells.txt recording each cell's edition, update +// number and SHA-256, so a benchmark report can say exactly what it measured. +// +// NOAA publishes only the current edition of each cell, so the bytes change as +// NOAA issues updates. That's fine for comparing two builds in the same run, +// which read the same bytes. The exact budget tests use synthetic cells instead. +// +// Never commit the downloaded cells (see CONTRIBUTING.md). +// ============================================================================ + +// A harbour, an approach and a coastal cell, all with update files. Together they +// cover the range of record counts and edge lengths in typical NOAA cells. +string[] defaultCells = ["US5WA50M", "US4MD81M", "US3WA01M"]; + +if (args.Length == 0) +{ + Console.Error.WriteLine("Usage: dotnet run scripts/DownloadBenchmarkCells.cs -- [cell...]"); + return 1; +} + +var outputDirectory = Path.GetFullPath(args[0]); +var cellNames = args.Length > 1 ? args[1..] : defaultCells; +Directory.CreateDirectory(outputDirectory); + +using var catalogClient = new EncProductCatalogClient(); +var catalog = await catalogClient.GetNoaaCatalogAsync(); +var cellsByName = catalog.Cells.ToDictionary(c => c.Name, StringComparer.OrdinalIgnoreCase); + +using var httpClient = new HttpClient(); +var manifest = new List(); +var missing = 0; + +foreach (var name in cellNames) +{ + if (!cellsByName.TryGetValue(name, out var cell)) + { + Console.Error.WriteLine($"warning: {name} is not in the NOAA catalog; skipping it."); + missing++; + continue; + } + + var zipBytes = await httpClient.GetByteArrayAsync(cell.ZipfileLocation); + var cellDirectory = Path.Combine(outputDirectory, cell.Name); + if (Directory.Exists(cellDirectory)) + { + Directory.Delete(cellDirectory, recursive: true); + } + + Directory.CreateDirectory(cellDirectory); + + // NOAA zips hold ENC_ROOT//.000, .001 and so on. Flatten them so each + // cell's files sit together in one folder. + using (var archive = new ZipArchive(new MemoryStream(zipBytes))) + { + foreach (var entry in archive.Entries) + { + if (Path.GetFileNameWithoutExtension(entry.Name).Equals(cell.Name, StringComparison.OrdinalIgnoreCase) + && Path.GetExtension(entry.Name).Length == 4 + && Path.GetExtension(entry.Name)[1..].All(char.IsAsciiDigit)) + { + entry.ExtractToFile(Path.Combine(cellDirectory, entry.Name.ToUpperInvariant()), overwrite: true); + } + } + } + + var baseFile = Path.Combine(cellDirectory, $"{cell.Name}.000"); + if (!File.Exists(baseFile)) + { + Console.Error.WriteLine($"warning: {cell.Name}'s zip has no .000 file; skipping it."); + Directory.Delete(cellDirectory, recursive: true); + missing++; + continue; + } + + var hash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(baseFile))); + var line = $"{cell.Name} edition {cell.Edition} update {cell.UpdateNumber} " + + $"({new FileInfo(baseFile).Length:N0} bytes, sha256 {hash[..16]})"; + manifest.Add(line); + Console.WriteLine(line); +} + +File.WriteAllLines(Path.Combine(outputDirectory, "cells.txt"), manifest); +return missing == cellNames.Length ? 1 : 0; diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 1d1034a..a96288e 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -7,4 +7,9 @@ false + + + + + diff --git a/tests/EncDotNet.Benchmarks/BenchmarkCategories.cs b/tests/EncDotNet.Benchmarks/BenchmarkCategories.cs new file mode 100644 index 0000000..bcaf680 --- /dev/null +++ b/tests/EncDotNet.Benchmarks/BenchmarkCategories.cs @@ -0,0 +1,13 @@ +namespace EncDotNet.Benchmarks; + +/// +/// Benchmark categories, for selecting a subset with --anyCategories. +/// +public static class BenchmarkCategories +{ + /// + /// The benchmarks the pull request comparison runs: one per public reader, writer and + /// model builder, covering the work an application does to load a chart. + /// + public const string Core = "Core"; +} diff --git a/tests/EncDotNet.Benchmarks/BenchmarkInput.cs b/tests/EncDotNet.Benchmarks/BenchmarkInput.cs new file mode 100644 index 0000000..4fb15d2 --- /dev/null +++ b/tests/EncDotNet.Benchmarks/BenchmarkInput.cs @@ -0,0 +1,120 @@ +using EncDotNet.Testing; + +namespace EncDotNet.Benchmarks; + +/// +/// A cell to benchmark: a base cell (.000) and its updates, if any. +/// +/// +/// Inputs load lazily, because BenchmarkDotNet runs each case in its own process and only +/// that case's input needs to be built. +/// +public sealed class BenchmarkInput +{ + /// + /// The environment variable naming a directory of real ENC cells to benchmark + /// alongside the synthetic ones. Every *.000 file under it, at any depth, + /// becomes an input, with the numbered update files next to it. + /// + public const string EncRootVariable = "ENCDOTNET_ENC_ROOT"; + + /// + /// The environment variable listing which synthetic profiles to benchmark, comma-separated + /// (for example Large). Unset means all of them; none means none. + /// + public const string SyntheticVariable = "ENCDOTNET_BENCHMARK_SYNTHETIC"; + + private const int SyntheticUpdateCount = 3; + + private readonly Func _loadBase; + private readonly Func> _loadUpdates; + private byte[]? _bytes; + private IReadOnlyList? _updates; + + private BenchmarkInput(string name, bool hasUpdates, Func loadBase, Func> loadUpdates) + { + Name = name; + HasUpdates = hasUpdates; + _loadBase = loadBase; + _loadUpdates = loadUpdates; + } + + /// + /// Gets the name shown in benchmark results: the profile name for a synthetic cell + /// (prefixed Synthetic), or the cell name for a real one. + /// + public string Name { get; } + + /// + /// Gets a value indicating whether the cell has update files. + /// + public bool HasUpdates { get; } + + /// + /// Gets the base cell's bytes. + /// + public byte[] Bytes => _bytes ??= _loadBase(); + + /// + /// Gets the bytes of the update files, in order. + /// + public IReadOnlyList Updates => _updates ??= _loadUpdates(); + + /// + /// Gets the synthetic cells selected by , followed by any + /// real cells under . + /// + public static IEnumerable All() + { + var selected = Environment.GetEnvironmentVariable(SyntheticVariable)? + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var profiles = new[] { SyntheticCellProfile.Harbour, SyntheticCellProfile.Approach, SyntheticCellProfile.Large } + .Where(p => selected is null || selected.Contains(p.Name, StringComparer.OrdinalIgnoreCase)); + + foreach (var profile in profiles) + { + yield return new BenchmarkInput( + $"Synthetic{profile.Name}", + hasUpdates: true, + () => SyntheticS57Cell.Create(profile), + () => [.. Enumerable.Range(1, SyntheticUpdateCount).Select(n => SyntheticS57Cell.CreateUpdate(profile, n))]); + } + + var root = Environment.GetEnvironmentVariable(EncRootVariable); + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + yield break; + } + + foreach (var path in Directory.GetFiles(root, "*.000", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) + { + yield return new BenchmarkInput( + Path.GetFileNameWithoutExtension(path), + UpdatePaths(path).Any(), + () => File.ReadAllBytes(path), + () => [.. UpdatePaths(path).Select(File.ReadAllBytes)]); + } + } + + /// + /// Gets the inputs that have update files. + /// + public static IEnumerable WithUpdates() => All().Where(input => input.HasUpdates); + + /// + public override string ToString() => Name; + + private static IEnumerable UpdatePaths(string basePath) + { + var directory = Path.GetDirectoryName(basePath); + if (string.IsNullOrEmpty(directory)) + { + return []; + } + + var stem = Path.GetFileNameWithoutExtension(basePath); + return Enumerable.Range(1, 999) + .Select(n => Path.Combine(directory, $"{stem}.{n:D3}")) + .TakeWhile(File.Exists); + } +} diff --git a/tests/EncDotNet.Benchmarks/Iso8211Benchmarks.cs b/tests/EncDotNet.Benchmarks/Iso8211Benchmarks.cs new file mode 100644 index 0000000..755abd6 --- /dev/null +++ b/tests/EncDotNet.Benchmarks/Iso8211Benchmarks.cs @@ -0,0 +1,98 @@ +using BenchmarkDotNet.Attributes; +using EncDotNet.Iso8211; + +namespace EncDotNet.Benchmarks; + +/// +/// Benchmarks for the ISO 8211 layer: the token reader, the document reader, the DDR reader, +/// subfield decoding and the writer. +/// +[MemoryDiagnoser] +public class Iso8211Benchmarks +{ + private byte[] _bytes = null!; + private Iso8211Document _document = null!; + private Iso8211Record _ddrRecord = null!; + private Iso8211FieldDefinition _sg2dDefinition = null!; + private Iso8211Field[] _sg2dFields = null!; + + /// + /// Gets or sets the cell to read. + /// + [ParamsSource(nameof(Inputs))] + public BenchmarkInput Input { get; set; } = null!; + + /// + /// Gets the cells to benchmark. + /// + public static IEnumerable Inputs => BenchmarkInput.All(); + + [GlobalSetup] + public void Setup() + { + _bytes = Input.Bytes; + _document = Iso8211DocumentReader.Read(_bytes); + _ddrRecord = _document.DataDescriptiveRecord!; + _sg2dDefinition = Iso8211DataDescriptiveRecordReader.Read(_ddrRecord).GetFieldDefinition("SG2D")!; + _sg2dFields = [.. _document.DataRecords.SelectMany(r => r.Fields).Where(f => f.Tag == "SG2D")]; + } + + [BenchmarkCategory(BenchmarkCategories.Core)] + [Benchmark(Description = "Iso8211Reader: read all tokens")] + public int Iso8211Reader_ReadAllTokens() + { + var reader = new Iso8211Reader(_bytes); + var count = 0; + while (reader.Read()) + { + count++; + } + + return count; + } + + [Benchmark(Description = "Iso8211Reader: skip all records")] + public int Iso8211Reader_SkipAllRecords() + { + var reader = new Iso8211Reader(_bytes); + var count = 0; + while (reader.Read()) + { + if (reader.TokenType == Iso8211TokenType.StartRecord) + { + reader.SkipRecord(); + count++; + } + } + + return count; + } + + [BenchmarkCategory(BenchmarkCategories.Core)] + [Benchmark(Description = "Iso8211DocumentReader.Read")] + public Iso8211Document Iso8211DocumentReader_Read() => Iso8211DocumentReader.Read(_bytes); + + [Benchmark(Description = "Iso8211DataDescriptiveRecordReader.Read")] + public Iso8211DataDescriptiveRecord DataDescriptiveRecordReader_Read() => Iso8211DataDescriptiveRecordReader.Read(_ddrRecord); + + [BenchmarkCategory(BenchmarkCategories.Core)] + [Benchmark(Description = "Iso8211FieldReader: decode SG2D")] + public long Iso8211FieldReader_DecodeCoordinates() + { + long sum = 0; + foreach (var field in _sg2dFields) + { + var reader = new Iso8211FieldReader(_sg2dDefinition, field.Data); + foreach (var group in reader.GetSubfieldGroups()) + { + sum += group.GetSubfield("YCOO") + group.GetSubfield("XCOO"); + } + } + + return sum; + } + + [BenchmarkCategory(BenchmarkCategories.Core)] + [Benchmark(Description = "Iso8211DocumentWriter.Write")] + public byte[] Iso8211DocumentWriter_Write() => Iso8211DocumentWriter.Write(_document); +} diff --git a/tests/EncDotNet.Benchmarks/Iso8211ReaderBenchmarks.cs b/tests/EncDotNet.Benchmarks/Iso8211ReaderBenchmarks.cs deleted file mode 100644 index 8b93dfa..0000000 --- a/tests/EncDotNet.Benchmarks/Iso8211ReaderBenchmarks.cs +++ /dev/null @@ -1,146 +0,0 @@ -using BenchmarkDotNet.Attributes; -using EncDotNet.Iso8211; - -namespace EncDotNet.Benchmarks; - -/// -/// Benchmarks for measuring raw token-reading throughput -/// against real NOAA ENC chart files. -/// -/// -/// Set the ENCDOTNET_ENC_ROOT environment variable to the root directory -/// containing NOAA ENC files (*.000) before running. -/// -[MemoryDiagnoser] -public class Iso8211ReaderBenchmarks -{ - private byte[][] _chartFiles = null!; - private byte[] _singleChart = null!; - - [GlobalSetup] - public void Setup() - { - var encRoot = Environment.GetEnvironmentVariable("ENCDOTNET_ENC_ROOT") - ?? throw new InvalidOperationException( - "Set the ENCDOTNET_ENC_ROOT environment variable to the NOAA ENC root directory."); - - var files = Directory.GetFiles(encRoot, "*.000", SearchOption.AllDirectories); - - if (files.Length == 0) - { - throw new InvalidOperationException( - $"No .000 files found under '{encRoot}'."); - } - - _chartFiles = files.Select(File.ReadAllBytes).ToArray(); - _singleChart = _chartFiles.MaxBy(f => f.Length)!; - } - - [Benchmark(Description = "ReadAllTokens (single largest chart)")] - public int ReadAllTokens_SingleChart() - { - var reader = new Iso8211Reader(_singleChart); - int count = 0; - while (reader.Read()) count++; - return count; - } - - [Benchmark(Baseline = true, Description = "ReadAllTokens (all charts)")] - public int ReadAllTokens_AllCharts() - { - int count = 0; - foreach (var file in _chartFiles) - { - var reader = new Iso8211Reader(file); - while (reader.Read()) count++; - } - return count; - } - - [Benchmark(Description = "SkipAllRecords (all charts)")] - public int SkipAllRecords_AllCharts() - { - int count = 0; - foreach (var file in _chartFiles) - { - var reader = new Iso8211Reader(file); - while (reader.Read()) - { - if (reader.TokenType == Iso8211TokenType.StartRecord) - { - reader.SkipRecord(); - count++; - } - } - } - return count; - } - - [Benchmark(Description = "ReadFields, skip directory (all charts)")] - public int ReadFields_SkipDirectory_AllCharts() - { - int count = 0; - foreach (var file in _chartFiles) - { - var reader = new Iso8211Reader(file); - while (reader.Read()) - { - if (reader.TokenType == Iso8211TokenType.StartRecord) - { - reader.SkipDirectory(); - } - else if (reader.TokenType == Iso8211TokenType.Field) - { - count++; - } - } - } - return count; - } - - [Benchmark(Description = "ReadFields with tag string (all charts)")] - public int ReadFields_WithTagString_AllCharts() - { - int count = 0; - foreach (var file in _chartFiles) - { - var reader = new Iso8211Reader(file); - while (reader.Read()) - { - if (reader.TokenType == Iso8211TokenType.StartRecord) - { - reader.SkipDirectory(); - } - else if (reader.TokenType == Iso8211TokenType.Field) - { - _ = reader.GetTagString(); - count++; - } - } - } - return count; - } - - [Benchmark(Description = "ReadFields with span tag compare (all charts)")] - public int ReadFields_WithSpanTagCompare_AllCharts() - { - int count = 0; - foreach (var file in _chartFiles) - { - var reader = new Iso8211Reader(file); - while (reader.Read()) - { - if (reader.TokenType == Iso8211TokenType.StartRecord) - { - reader.SkipDirectory(); - } - else if (reader.TokenType == Iso8211TokenType.Field) - { - _ = reader.CurrentTag.SequenceEqual("VRID"u8); - count++; - } - } - } - return count; - } -} diff --git a/tests/EncDotNet.Benchmarks/ParsingLayerBenchmarks.cs b/tests/EncDotNet.Benchmarks/ParsingLayerBenchmarks.cs deleted file mode 100644 index 1560d0c..0000000 --- a/tests/EncDotNet.Benchmarks/ParsingLayerBenchmarks.cs +++ /dev/null @@ -1,56 +0,0 @@ -using BenchmarkDotNet.Attributes; -using EncDotNet.Iso8211; -using EncDotNet.S57; - -namespace EncDotNet.Benchmarks; - -/// -/// Benchmarks comparing the cost of each parsing layer: -/// raw vs vs . -/// -[MemoryDiagnoser] -public class ParsingLayerBenchmarks -{ - private byte[][] _chartFiles = null!; - private byte[] _singleChart = null!; - - [GlobalSetup] - public void Setup() - { - var encRoot = Environment.GetEnvironmentVariable("ENCDOTNET_ENC_ROOT") - ?? throw new InvalidOperationException( - "Set the ENCDOTNET_ENC_ROOT environment variable to the NOAA ENC root directory."); - - var files = Directory.GetFiles(encRoot, "*.000", SearchOption.AllDirectories); - - if (files.Length == 0) - { - throw new InvalidOperationException( - $"No .000 files found under '{encRoot}'."); - } - - _chartFiles = files.Select(File.ReadAllBytes).ToArray(); - _singleChart = _chartFiles.MaxBy(f => f.Length)!; - } - - [Benchmark(Baseline = true, Description = "Iso8211Reader tokens (single chart)")] - public int Iso8211Reader_SingleChart() - { - var reader = new Iso8211Reader(_singleChart); - int count = 0; - while (reader.Read()) count++; - return count; - } - - [Benchmark(Description = "Iso8211DocumentReader (single chart)")] - public Iso8211Document Iso8211DocumentReader_SingleChart() - { - return Iso8211DocumentReader.Read(_singleChart); - } - - [Benchmark(Description = "S57DocumentReader (single chart)")] - public S57Document S57DocumentReader_SingleChart() - { - return S57DocumentReader.Read(_singleChart); - } -} diff --git a/tests/EncDotNet.Benchmarks/S57Benchmarks.cs b/tests/EncDotNet.Benchmarks/S57Benchmarks.cs new file mode 100644 index 0000000..6d6606b --- /dev/null +++ b/tests/EncDotNet.Benchmarks/S57Benchmarks.cs @@ -0,0 +1,44 @@ +using BenchmarkDotNet.Attributes; +using EncDotNet.S57; +using EncDotNet.S57.Charts; + +namespace EncDotNet.Benchmarks; + +/// +/// Benchmarks for the S-57 layer: reading a document and building a chart from it. +/// +[MemoryDiagnoser] +public class S57Benchmarks +{ + private byte[] _bytes = null!; + private S57Document _document = null!; + + /// + /// Gets or sets the cell to read. + /// + [ParamsSource(nameof(Inputs))] + public BenchmarkInput Input { get; set; } = null!; + + /// + /// Gets the cells to benchmark. + /// + public static IEnumerable Inputs => BenchmarkInput.All(); + + [GlobalSetup] + public void Setup() + { + _bytes = Input.Bytes; + _document = S57DocumentReader.Read(_bytes); + } + + [BenchmarkCategory(BenchmarkCategories.Core)] + [Benchmark(Description = "S57DocumentReader.Read")] + public S57Document S57DocumentReader_Read() => S57DocumentReader.Read(_bytes); + + [BenchmarkCategory(BenchmarkCategories.Core)] + [Benchmark(Description = "S57Chart.FromDocument")] + public S57Chart S57Chart_FromDocument() => S57Chart.FromDocument(_document); + + [Benchmark(Description = "Bytes to S57Chart")] + public S57Chart BytesToChart() => S57Chart.FromDocument(S57DocumentReader.Read(_bytes)); +} diff --git a/tests/EncDotNet.Benchmarks/S57UpdateBenchmarks.cs b/tests/EncDotNet.Benchmarks/S57UpdateBenchmarks.cs new file mode 100644 index 0000000..f95b8c8 --- /dev/null +++ b/tests/EncDotNet.Benchmarks/S57UpdateBenchmarks.cs @@ -0,0 +1,45 @@ +using BenchmarkDotNet.Attributes; +using EncDotNet.S57; + +namespace EncDotNet.Benchmarks; + +/// +/// Benchmarks for applying update files (.001, .002, …) to a base cell. +/// +[MemoryDiagnoser] +public class S57UpdateBenchmarks +{ + private S57Document _base = null!; + private S57Document[] _updates = null!; + + /// + /// Gets or sets the cell to update. + /// + [ParamsSource(nameof(Inputs))] + public BenchmarkInput Input { get; set; } = null!; + + /// + /// Gets the cells that have update files. + /// + public static IEnumerable Inputs => BenchmarkInput.WithUpdates(); + + [GlobalSetup] + public void Setup() + { + _base = S57DocumentReader.Read(Input.Bytes); + _updates = [.. Input.Updates.Select(u => S57DocumentReader.Read(u))]; + } + + [BenchmarkCategory(BenchmarkCategories.Core)] + [Benchmark(Description = "S57Document.ApplyChanges (all updates)")] + public S57Document ApplyChanges() + { + var document = _base; + foreach (var update in _updates) + { + document = document.ApplyChanges(update); + } + + return document; + } +} diff --git a/tests/EncDotNet.PerformanceTests/AllocationBudgetTests.cs b/tests/EncDotNet.PerformanceTests/AllocationBudgetTests.cs new file mode 100644 index 0000000..b32172f --- /dev/null +++ b/tests/EncDotNet.PerformanceTests/AllocationBudgetTests.cs @@ -0,0 +1,147 @@ +using EncDotNet.Iso8211; +using EncDotNet.S57; +using EncDotNet.S57.Charts; + +namespace EncDotNet.PerformanceTests; + +/// +/// Gates allocation regressions in the readers and writers against . +/// +/// +/// Allocated bytes are exact and identical between runs and machines, so unlike elapsed time +/// they can fail a build reliably on shared CI runners. Allocation also drives much of the CPU +/// cost of these APIs through garbage collection. +/// +public class AllocationBudgetTests +{ + private static PerformanceCells.Inputs Cell => PerformanceCells.Harbour; + + [Fact] + public void Iso8211Reader_ReadingAllTokens_DoesNotAllocate() + { + var bytes = Cell.Bytes; + + var allocated = MemoryMeter.AllocatedBytes(() => + { + var reader = new Iso8211Reader(bytes); + while (reader.Read()) + { + } + }); + + PerformanceBudget.AssertAllocated("Iso8211Reader all tokens", allocated, PerformanceBudgets.Iso8211ReaderAllTokens); + } + + [Fact] + public void Iso8211DocumentReader_Read_StaysWithinBudget() + { + var bytes = Cell.Bytes; + + var allocated = MemoryMeter.AllocatedBytes(() => Iso8211DocumentReader.Read(bytes)); + + PerformanceBudget.AssertAllocated("Iso8211DocumentReader.Read", allocated, PerformanceBudgets.Iso8211DocumentReaderRead); + } + + [Fact] + public void Iso8211DataDescriptiveRecordReader_Read_StaysWithinBudget() + { + var ddr = Cell.Iso8211Document.DataDescriptiveRecord!; + + var allocated = MemoryMeter.AllocatedBytes(() => Iso8211DataDescriptiveRecordReader.Read(ddr)); + + PerformanceBudget.AssertAllocated( + "Iso8211DataDescriptiveRecordReader.Read", allocated, PerformanceBudgets.Iso8211DataDescriptiveRecordReaderRead); + } + + [Fact] + public void Iso8211FieldReader_DecodingCoordinates_StaysWithinBudget() + { + // SG2D decoding is the single hottest path when reading a cell. + var ddr = Iso8211DataDescriptiveRecordReader.Read(Cell.Iso8211Document.DataDescriptiveRecord!); + var definition = ddr.GetFieldDefinition("SG2D")!; + var fields = Cell.Iso8211Document.DataRecords + .SelectMany(r => r.Fields) + .Where(f => f.Tag == "SG2D") + .ToArray(); + + var allocated = MemoryMeter.AllocatedBytes(() => + { + foreach (var field in fields) + { + var reader = new Iso8211FieldReader(definition, field.Data); + foreach (var group in reader.GetSubfieldGroups()) + { + _ = group.GetSubfield("YCOO"); + _ = group.GetSubfield("XCOO"); + } + } + }); + + PerformanceBudget.AssertAllocated("Iso8211FieldReader SG2D", allocated, PerformanceBudgets.Iso8211FieldReaderCoordinates); + } + + [Fact] + public void Iso8211DocumentWriter_Write_StaysWithinBudget() + { + var document = Cell.Iso8211Document; + + var allocated = MemoryMeter.AllocatedBytes(() => Iso8211DocumentWriter.Write(document)); + + PerformanceBudget.AssertAllocated("Iso8211DocumentWriter.Write", allocated, PerformanceBudgets.Iso8211DocumentWriterWrite); + } + + [Fact] + public void S57DocumentReader_Read_StaysWithinBudget() + { + var bytes = Cell.Bytes; + + var allocated = MemoryMeter.AllocatedBytes(() => S57DocumentReader.Read(bytes)); + + PerformanceBudget.AssertAllocated("S57DocumentReader.Read", allocated, PerformanceBudgets.S57DocumentReaderRead); + } + + [Fact] + public void S57Document_ApplyChanges_StaysWithinBudget() + { + var baseDocument = Cell.S57Document; + var updates = Cell.Updates; + + var allocated = MemoryMeter.AllocatedBytes(() => + { + var document = baseDocument; + foreach (var update in updates) + { + document = document.ApplyChanges(update); + } + }); + + PerformanceBudget.AssertAllocated("S57Document.ApplyChanges x3", allocated, PerformanceBudgets.S57DocumentApplyChanges); + } + + [Fact] + public void S57Chart_FromDocument_StaysWithinBudget() + { + var document = Cell.S57Document; + + var allocated = MemoryMeter.AllocatedBytes(() => S57Chart.FromDocument(document)); + + PerformanceBudget.AssertAllocated("S57Chart.FromDocument", allocated, PerformanceBudgets.S57ChartFromDocument); + } + + [Fact] + public void S57DocumentReader_Read_AllocatesInProportionToInputSize() + { + // A cell with four times the records should cost about four times as much per byte + // read. A super-linear structure (a list scanned per record, say) breaks this. + var small = PerformanceCells.Harbour; + var large = PerformanceCells.HarbourX4; + + var smallPerByte = (double)MemoryMeter.AllocatedBytes(() => S57DocumentReader.Read(small.Bytes)) / small.Bytes.Length; + var largePerByte = (double)MemoryMeter.AllocatedBytes(() => S57DocumentReader.Read(large.Bytes)) / large.Bytes.Length; + + var ratio = largePerByte / smallPerByte; + TestContext.Current.TestOutputHelper?.WriteLine( + $"Allocated per input byte: {smallPerByte:F1} (1x), {largePerByte:F1} (4x), ratio {ratio:F3}"); + Assert.InRange(ratio, 0.85, 1.15); + } +} diff --git a/tests/EncDotNet.PerformanceTests/EncDotNet.PerformanceTests.csproj b/tests/EncDotNet.PerformanceTests/EncDotNet.PerformanceTests.csproj new file mode 100644 index 0000000..e7ecc03 --- /dev/null +++ b/tests/EncDotNet.PerformanceTests/EncDotNet.PerformanceTests.csproj @@ -0,0 +1,30 @@ + + + + Exe + + false + + false + false + + + + + + + + + + + + + + + + + + diff --git a/tests/EncDotNet.PerformanceTests/MemoryMeter.cs b/tests/EncDotNet.PerformanceTests/MemoryMeter.cs new file mode 100644 index 0000000..f78b3fb --- /dev/null +++ b/tests/EncDotNet.PerformanceTests/MemoryMeter.cs @@ -0,0 +1,44 @@ +namespace EncDotNet.PerformanceTests; + +/// +/// Measures managed memory in ways that are exact and repeatable, unlike elapsed time. +/// +internal static class MemoryMeter +{ + /// + /// Returns the bytes allocated on the current thread by one call to . + /// + /// + /// The action runs twice first, so JIT compilation, type loading and lazily built static + /// caches are not counted. is exact and + /// per-thread, so tests on other threads don't disturb the count. The action returns + /// nothing so that measuring a value-returning call doesn't box its result. + /// + public static long AllocatedBytes(Action action) + { + action(); + action(); + + var before = GC.GetAllocatedBytesForCurrentThread(); + action(); + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + /// + /// Returns the bytes of the managed heap still held by the object returns. + /// + /// + /// This counts the whole process heap, so callers must not run alongside other tests; see + /// . + /// + public static long RetainedBytes(Func factory) + { + GC.KeepAlive(factory()); + + var before = GC.GetTotalMemory(forceFullCollection: true); + var result = factory(); + var after = GC.GetTotalMemory(forceFullCollection: true); + GC.KeepAlive(result); + return after - before; + } +} diff --git a/tests/EncDotNet.PerformanceTests/PerformanceBudget.cs b/tests/EncDotNet.PerformanceTests/PerformanceBudget.cs new file mode 100644 index 0000000..c996746 --- /dev/null +++ b/tests/EncDotNet.PerformanceTests/PerformanceBudget.cs @@ -0,0 +1,73 @@ +namespace EncDotNet.PerformanceTests; + +/// +/// Checks a measured memory figure against its recorded budget. +/// +/// +/// +/// A budget fails in both directions. Going over it is a regression. Coming in well under +/// it means the code improved and the budget must be lowered to the new figure, so that a +/// later regression can't hide in the headroom the improvement left behind. +/// +/// +/// The tolerances are small because the figures are exact: allocation counts don't vary +/// between runs or machines, and retained sizes vary only by heap bookkeeping. +/// +/// +internal static class PerformanceBudget +{ + /// + /// The fraction above an allocation budget that still passes, which absorbs + /// small differences between .NET patch releases. + /// + private const double AllocatedOverTolerance = 0.01; + + /// + /// The fraction below a budget at which the budget must be lowered. + /// + private const double UnderTolerance = 0.05; + + /// + /// The fraction either side of a retained-memory budget that still passes. + /// + private const double RetainedTolerance = 0.03; + + /// + /// Asserts that an allocation figure is within its budget. + /// + public static void AssertAllocated(string scenario, long actual, long budget) => + AssertWithin(scenario, "allocated", actual, budget, AllocatedOverTolerance, UnderTolerance); + + /// + /// Asserts that a retained-memory figure is within its budget. + /// + public static void AssertRetained(string scenario, long actual, long budget) => + AssertWithin(scenario, "retained", actual, budget, RetainedTolerance, Math.Max(RetainedTolerance, UnderTolerance)); + + private static void AssertWithin(string scenario, string kind, long actual, long budget, double over, double under) + { + TestContext.Current.TestOutputHelper?.WriteLine($"{scenario}: {actual:N0} bytes {kind} (budget {budget:N0})"); + + // Budgets under 1 KB are checked exactly; a percentage of zero is no tolerance at all. + var upper = budget < 1024 ? budget : (long)(budget * (1 + over)); + var lower = budget < 1024 ? budget : (long)(budget * (1 - under)); + + if (actual > upper) + { + Assert.Fail( + $"{scenario} {kind} {actual:N0} bytes, over its budget of {budget:N0} by {Percent(actual, budget)}. " + + "Find the regression, or if the increase is intended, raise the budget in PerformanceBudgets.cs " + + "and explain why in the pull request."); + } + + if (actual < lower) + { + Assert.Fail( + $"{scenario} {kind} {actual:N0} bytes, under its budget of {budget:N0} by {Percent(budget, actual)}. " + + $"Lower the budget in PerformanceBudgets.cs to {actual:N0} to lock in the improvement."); + } + } + + private static string Percent(long larger, long smaller) => + smaller == 0 ? "∞" : $"{(larger - smaller) * 100.0 / smaller:F1}%"; +} diff --git a/tests/EncDotNet.PerformanceTests/PerformanceBudgets.cs b/tests/EncDotNet.PerformanceTests/PerformanceBudgets.cs new file mode 100644 index 0000000..168a67d --- /dev/null +++ b/tests/EncDotNet.PerformanceTests/PerformanceBudgets.cs @@ -0,0 +1,33 @@ +namespace EncDotNet.PerformanceTests; + +/// +/// The recorded memory budgets, in bytes, for the synthetic harbour cell +/// (, about 400 KB). +/// +/// +/// +/// These figures are measurements, not targets. When a change moves one, the failing test +/// prints the new figure; update the constant here and say why in the pull request. +/// +/// +/// Changing or its profiles changes the +/// input, so every budget has to be re-recorded in the same change. +/// +/// +internal static class PerformanceBudgets +{ + // Allocated bytes for one operation. + public const long Iso8211ReaderAllTokens = 0; + public const long Iso8211DocumentReaderRead = 2_631_240; + public const long Iso8211DataDescriptiveRecordReaderRead = 50_136; + public const long Iso8211FieldReaderCoordinates = 6_194_360; + public const long Iso8211DocumentWriterWrite = 2_318_456; + public const long S57DocumentReaderRead = 29_024_040; + public const long S57DocumentApplyChanges = 685_440; + public const long S57ChartFromDocument = 1_030_312; + + // Retained bytes held by the resulting model. + public const long Iso8211DocumentRetained = 2_475_768; + public const long S57DocumentRetained = 1_194_928; + public const long S57ChartRetained = 1_145_696; +} diff --git a/tests/EncDotNet.PerformanceTests/PerformanceCells.cs b/tests/EncDotNet.PerformanceTests/PerformanceCells.cs new file mode 100644 index 0000000..37cab38 --- /dev/null +++ b/tests/EncDotNet.PerformanceTests/PerformanceCells.cs @@ -0,0 +1,49 @@ +using EncDotNet.Iso8211; +using EncDotNet.S57; +using EncDotNet.Testing; + +namespace EncDotNet.PerformanceTests; + +/// +/// The synthetic cells the performance budget tests measure, generated once per test run. +/// +internal static class PerformanceCells +{ + private static readonly Lazy LazyHarbour = new(() => new Inputs(SyntheticCellProfile.Harbour)); + private static readonly Lazy LazyHarbourX4 = new(() => new Inputs(SyntheticCellProfile.Harbour.Scale(4))); + + /// + /// Gets the harbour-scale cell, shaped like NOAA US5WA50M. + /// + public static Inputs Harbour => LazyHarbour.Value; + + /// + /// Gets a cell with four times the harbour cell's records, for growth-rate checks. + /// + public static Inputs HarbourX4 => LazyHarbourX4.Value; + + /// + /// A base cell, three updates and the parsed models built from them. + /// + internal sealed class Inputs + { + public Inputs(SyntheticCellProfile profile) + { + Profile = profile; + Bytes = SyntheticS57Cell.Create(profile); + Iso8211Document = Iso8211DocumentReader.Read(Bytes); + S57Document = S57DocumentReader.Read(Bytes); + Updates = [.. Enumerable.Range(1, 3).Select(n => S57DocumentReader.Read(SyntheticS57Cell.CreateUpdate(profile, n)))]; + } + + public SyntheticCellProfile Profile { get; } + + public byte[] Bytes { get; } + + public Iso8211Document Iso8211Document { get; } + + public S57Document S57Document { get; } + + public IReadOnlyList Updates { get; } + } +} diff --git a/tests/EncDotNet.PerformanceTests/RetainedMemoryBudgetTests.cs b/tests/EncDotNet.PerformanceTests/RetainedMemoryBudgetTests.cs new file mode 100644 index 0000000..7092282 --- /dev/null +++ b/tests/EncDotNet.PerformanceTests/RetainedMemoryBudgetTests.cs @@ -0,0 +1,47 @@ +using EncDotNet.Iso8211; +using EncDotNet.S57; +using EncDotNet.S57.Charts; + +namespace EncDotNet.PerformanceTests; + +/// +/// Gates growth in the memory the parsed models hold, which bounds how many charts an +/// application such as a chart viewer can keep loaded. +/// +[Collection(nameof(RetainedMemoryCollection))] +public class RetainedMemoryBudgetTests +{ + private static PerformanceCells.Inputs Cell => PerformanceCells.Harbour; + + [Fact] + public void Iso8211Document_StaysWithinBudget() + { + var bytes = Cell.Bytes; + + var retained = MemoryMeter.RetainedBytes(() => Iso8211DocumentReader.Read(bytes)); + + PerformanceBudget.AssertRetained("Iso8211Document", retained, PerformanceBudgets.Iso8211DocumentRetained); + } + + [Fact] + public void S57Document_StaysWithinBudget() + { + var bytes = Cell.Bytes; + + var retained = MemoryMeter.RetainedBytes(() => S57DocumentReader.Read(bytes)); + + PerformanceBudget.AssertRetained("S57Document", retained, PerformanceBudgets.S57DocumentRetained); + } + + [Fact] + public void S57Chart_StaysWithinBudget() + { + // Measure the whole load path. The chart keeps the records' attributes, pointers and + // coordinates but not the document itself, so it can retain less than the document. + var bytes = Cell.Bytes; + + var retained = MemoryMeter.RetainedBytes(() => S57Chart.FromDocument(S57DocumentReader.Read(bytes))); + + PerformanceBudget.AssertRetained("S57Chart", retained, PerformanceBudgets.S57ChartRetained); + } +} diff --git a/tests/EncDotNet.PerformanceTests/RetainedMemoryCollection.cs b/tests/EncDotNet.PerformanceTests/RetainedMemoryCollection.cs new file mode 100644 index 0000000..1bb612b --- /dev/null +++ b/tests/EncDotNet.PerformanceTests/RetainedMemoryCollection.cs @@ -0,0 +1,8 @@ +namespace EncDotNet.PerformanceTests; + +/// +/// Runs the retained-memory tests on their own, after the parallel tests finish, because +/// sees every thread's allocations. +/// +[CollectionDefinition(nameof(RetainedMemoryCollection), DisableParallelization = true)] +public sealed class RetainedMemoryCollection; diff --git a/tests/Shared/SyntheticCellProfile.cs b/tests/Shared/SyntheticCellProfile.cs new file mode 100644 index 0000000..f3f435e --- /dev/null +++ b/tests/Shared/SyntheticCellProfile.cs @@ -0,0 +1,112 @@ +namespace EncDotNet.Testing; + +/// +/// Describes the shape of a synthetic S-57 cell generated by . +/// +/// +/// The built-in profiles are calibrated against real NOAA cells: record counts, the +/// distribution of edge lengths, attributes per feature and spatial pointers per feature +/// follow the measured averages of the cell named in each profile's remarks. +/// +/// The profile name, used in the generated data set name and in benchmark output. +/// The seed for the generator; the same profile always yields the same bytes. +/// The number of isolated nodes (RCNM 110) holding a single SG2D position. +/// The number of isolated nodes holding an SG3D sounding array. +/// The number of soundings in each sounding node. +/// The number of connected nodes (RCNM 120). +/// The number of edges (RCNM 130). +/// The mean number of intermediate SG2D points per edge. +/// The number of points in the single longest edge. +/// The number of point features. +/// The number of line features. +/// The number of area features. +public sealed record SyntheticCellProfile( + string Name, + int Seed, + int IsolatedNodes, + int SoundingNodes, + int SoundingsPerNode, + int ConnectedNodes, + int Edges, + double MeanEdgePoints, + int MaxEdgePoints, + int PointFeatures, + int LineFeatures, + int AreaFeatures) +{ + /// + /// A harbour-scale cell shaped like NOAA US5WA50M (about 400 KB). + /// + public static SyntheticCellProfile Harbour { get; } = new( + Name: "Harbour", + Seed: 5050, + IsolatedNodes: 148, + SoundingNodes: 4, + SoundingsPerNode: 66, + ConnectedNodes: 861, + Edges: 1024, + MeanEdgePoints: 12.8, + MaxEdgePoints: 1084, + PointFeatures: 163, + LineFeatures: 389, + AreaFeatures: 211); + + /// + /// An approach-scale cell shaped like NOAA US4MD81M (about 3.5 MB). + /// + public static SyntheticCellProfile Approach { get; } = new( + Name: "Approach", + Seed: 4081, + IsolatedNodes: 1372, + SoundingNodes: 52, + SoundingsPerNode: 64, + ConnectedNodes: 5747, + Edges: 7490, + MeanEdgePoints: 18.0, + MaxEdgePoints: 2771, + PointFeatures: 1813, + LineFeatures: 3376, + AreaFeatures: 2143); + + /// + /// A cell four times the size of whose longest edge holds + /// 15 000 points, so its record exceeds the 99 999-byte leader limit (S-57 Part 3, + /// Annex A.3) and is written with a record length of 00000. + /// + /// + /// Real cells this large are rare, which is why this scenario is synthetic. + /// + public static SyntheticCellProfile Large { get; } = new( + Name: "Large", + Seed: 9999, + IsolatedNodes: 4 * 1372, + SoundingNodes: 4 * 52, + SoundingsPerNode: 64, + ConnectedNodes: 4 * 5747, + Edges: 4 * 7490, + MeanEdgePoints: 18.0, + MaxEdgePoints: 15_000, + PointFeatures: 4 * 1813, + LineFeatures: 4 * 3376, + AreaFeatures: 4 * 2143); + + /// + /// Returns a copy of this profile with every record count multiplied by . + /// + /// The multiplier for record counts. + /// The scaled profile. + public SyntheticCellProfile Scale(int factor) => this with + { + Name = $"{Name}x{factor}", + IsolatedNodes = IsolatedNodes * factor, + SoundingNodes = SoundingNodes * factor, + ConnectedNodes = ConnectedNodes * factor, + Edges = Edges * factor, + PointFeatures = PointFeatures * factor, + LineFeatures = LineFeatures * factor, + AreaFeatures = AreaFeatures * factor + }; + + /// + public override string ToString() => Name; +} diff --git a/tests/Shared/SyntheticS57Cell.cs b/tests/Shared/SyntheticS57Cell.cs new file mode 100644 index 0000000..3ff8d84 --- /dev/null +++ b/tests/Shared/SyntheticS57Cell.cs @@ -0,0 +1,475 @@ +using EncDotNet.Iso8211; + +namespace EncDotNet.Testing; + +/// +/// Generates deterministic, S-57-shaped ISO 8211 cells for tests and benchmarks. +/// +/// +/// +/// The repository never commits real ENC cells, so performance tests that need exact, +/// repeatable inputs use these instead. The output depends only on the +/// : the generator uses its own pseudo-random sequence +/// rather than , so the bytes are identical on every runtime and OS. +/// +/// +/// Records follow the S-57 Part 3 structure (DSID/DSSI, DSPM, vector records with +/// VRID/ATTV/VRPT/SG2D/SG3D, feature records with FRID/FOID/ATTF/FSPT) and are encoded with +/// the library's own and . +/// The content is plausible but not a valid chart: geometry is a random walk and attribute +/// values are random text. +/// +/// +public static class SyntheticS57Cell +{ + private const int FeatureRcnm = 100; + private const int IsolatedNodeRcnm = 110; + private const int ConnectedNodeRcnm = 120; + private const int EdgeRcnm = 130; + private const int ProducingAgency = 550; + + private const byte Insert = 1; + private const byte Delete = 2; + private const byte Modify = 3; + + // Common S-57 object classes per primitive (S-57 Appendix A, Chapter 1). + private static readonly int[] PointObjectClasses = [17, 14, 75, 159, 153, 86, 90]; + private static readonly int[] LineObjectClasses = [30, 43, 122, 69, 109]; + private static readonly int[] AreaObjectClasses = [42, 71, 46, 4, 112, 27]; + + // Common attribute codes (S-57 Appendix A, Chapter 2): OBJNAM, DRVAL1, DRVAL2, COLOUR, + // CATLAM, VALSOU, SCAMIN, SORDAT, SORIND, NOBJNM, INFORM, QUASOU. + private static readonly int[] AttributeCodes = [116, 87, 88, 75, 36, 179, 133, 147, 148, 301, 102, 125]; + + private static readonly Lazy LazyDdr = new(BuildDdr); + private static readonly Lazy LazyDefinitions = + new(() => Iso8211DataDescriptiveRecordReader.Read(LazyDdr.Value)); + + /// + /// Generates the base cell (.000) for a profile. + /// + /// The cell shape to generate. + /// The encoded ISO 8211 bytes. + public static byte[] Create(SyntheticCellProfile profile) => + Iso8211DocumentWriter.Write(CreateDocument(profile)); + + /// + /// Generates the base cell (.000) for a profile as an ISO 8211 document model. + /// + /// The cell shape to generate. + /// The document, ready for . + public static Iso8211Document CreateDocument(SyntheticCellProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + + var defs = LazyDefinitions.Value; + var rng = new SplitMix64(profile.Seed); + var builder = new Iso8211DocumentBuilder().AddRecord(LazyDdr.Value); + var recordNumber = 1; + + builder.AddRecord(DataSetIdentification(defs, profile, recordNumber++, updateNumber: 0)); + builder.AddRecord(DataSetParameters(defs, recordNumber++)); + + var walk = new CoordinateWalk(new SplitMix64((long)rng.NextUInt64())); + + for (int i = 1; i <= profile.IsolatedNodes; i++) + { + builder.AddRecord(Node(defs, recordNumber++, IsolatedNodeRcnm, i, walk.Next())); + } + + for (int i = 1; i <= profile.SoundingNodes; i++) + { + builder.AddRecord(SoundingNode(defs, ref rng, recordNumber++, profile.IsolatedNodes + i, profile.SoundingsPerNode, walk)); + } + + for (int i = 1; i <= profile.ConnectedNodes; i++) + { + builder.AddRecord(Node(defs, recordNumber++, ConnectedNodeRcnm, i, walk.Next())); + } + + for (int i = 1; i <= profile.Edges; i++) + { + builder.AddRecord(Edge(defs, ref rng, recordNumber++, i, EdgePointCount(profile, i), profile.ConnectedNodes, walk, Insert, ruinModifyRange: null)); + } + + var featureId = 1; + for (int i = 0; i < profile.PointFeatures; i++) + { + builder.AddRecord(Feature(defs, ref rng, recordNumber++, featureId++, profile, primitive: 1, Insert)); + } + + for (int i = 0; i < profile.LineFeatures; i++) + { + builder.AddRecord(Feature(defs, ref rng, recordNumber++, featureId++, profile, primitive: 2, Insert)); + } + + for (int i = 0; i < profile.AreaFeatures; i++) + { + builder.AddRecord(Feature(defs, ref rng, recordNumber++, featureId++, profile, primitive: 3, Insert)); + } + + return builder.Build(); + } + + /// + /// Generates an update cell (.001, .002, …) for a profile's base cell. + /// + /// + /// Each update modifies the attributes of about 5% of features, deletes about 1%, + /// inserts about 1% new features, and replaces a run of coordinates in about 2% of + /// edges using an SGCC update control field (S-57 Part 3, 8.4.3). + /// + /// The profile of the base cell being updated. + /// The update number, starting at 1. + /// The encoded ISO 8211 bytes. + public static byte[] CreateUpdate(SyntheticCellProfile profile, int updateNumber) + { + ArgumentNullException.ThrowIfNull(profile); + ArgumentOutOfRangeException.ThrowIfLessThan(updateNumber, 1); + + var defs = LazyDefinitions.Value; + var rng = new SplitMix64(profile.Seed * 1000L + updateNumber); + var builder = new Iso8211DocumentBuilder().AddRecord(LazyDdr.Value); + var recordNumber = 1; + builder.AddRecord(DataSetIdentification(defs, profile, recordNumber++, updateNumber)); + + var walk = new CoordinateWalk(new SplitMix64((long)rng.NextUInt64())); + var edgeModifications = Math.Max(1, profile.Edges / 50); + for (int i = 0; i < edgeModifications; i++) + { + // Replace a run of existing points, so the SGCC range always lies within the edge. + var rcid = 1 + (int)rng.NextUInt32((uint)profile.Edges); + var basePoints = EdgePointCount(profile, rcid); + if (basePoints == 0) + { + continue; + } + + var count = 1 + (int)rng.NextUInt32((uint)Math.Min(basePoints, 8)); + var index = 1 + (int)rng.NextUInt32((uint)(basePoints - count + 1)); + builder.AddRecord(Edge(defs, ref rng, recordNumber++, rcid, count, profile.ConnectedNodes, walk, Modify, ruinModifyRange: (index, count))); + } + + var featureCount = profile.PointFeatures + profile.LineFeatures + profile.AreaFeatures; + var modifications = Math.Max(1, featureCount / 20); + for (int i = 0; i < modifications; i++) + { + var rcid = 1 + (int)rng.NextUInt32((uint)featureCount); + builder.AddRecord(FeatureAttributeUpdate(defs, ref rng, recordNumber++, rcid)); + } + + var deletions = Math.Max(1, featureCount / 100); + for (int i = 0; i < deletions; i++) + { + var rcid = 1 + (int)rng.NextUInt32((uint)featureCount); + builder.AddRecord(FeatureDeletion(defs, recordNumber++, rcid)); + } + + var insertions = Math.Max(1, featureCount / 100); + for (int i = 0; i < insertions; i++) + { + var rcid = featureCount + (updateNumber * insertions) + i + 1; + builder.AddRecord(Feature(defs, ref rng, recordNumber++, rcid, profile, primitive: 1 + (int)rng.NextUInt32(3), Insert)); + } + + return Iso8211DocumentWriter.Write(builder.Build()); + } + + private static Iso8211Record DataSetIdentification(Iso8211DataDescriptiveRecord defs, SyntheticCellProfile profile, int recordNumber, int updateNumber) + { + var name = $"SY{profile.Name.ToUpperInvariant()[..Math.Min(6, profile.Name.Length)]}.{updateNumber:D3}"; + return Record(recordNumber) + .AddField(Field(defs, "DSID").AddSubfields( + 10, 1, updateNumber == 0 ? 1 : 2, 5, name, "1", updateNumber.ToString(System.Globalization.CultureInfo.InvariantCulture), + "20260101", "20260101", "03.1", 1, "2.0", "2.0", 1, ProducingAgency, string.Empty)) + .AddField(Field(defs, "DSSI").AddSubfields( + 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0)) + .Build(); + } + + private static Iso8211Record DataSetParameters(Iso8211DataDescriptiveRecord defs, int recordNumber) => + Record(recordNumber) + .AddField(Field(defs, "DSPM").AddSubfields( + 20, 1, 2, 17, 23, 20_000, 1, 1, 1, 1, 10_000_000, 10, string.Empty)) + .Build(); + + private static Iso8211Record Node(Iso8211DataDescriptiveRecord defs, int recordNumber, int rcnm, int rcid, (int Y, int X) position) => + Record(recordNumber) + .AddField(Field(defs, "VRID").AddSubfields(rcnm, rcid, 1, Insert)) + .AddField(Field(defs, "SG2D").AddSubfields(position.Y, position.X)) + .Build(); + + private static Iso8211Record SoundingNode(Iso8211DataDescriptiveRecord defs, ref SplitMix64 rng, int recordNumber, int rcid, int soundings, CoordinateWalk walk) + { + var sg3d = Field(defs, "SG3D"); + for (int i = 0; i < soundings; i++) + { + var (y, x) = walk.Next(); + sg3d.AddSubfields(y, x, (int)rng.NextUInt32(400)); + } + + return Record(recordNumber) + .AddField(Field(defs, "VRID").AddSubfields(IsolatedNodeRcnm, rcid, 1, Insert)) + .AddField(sg3d) + .Build(); + } + + private static Iso8211Record Edge( + Iso8211DataDescriptiveRecord defs, + ref SplitMix64 rng, + int recordNumber, + int rcid, + int points, + int connectedNodes, + CoordinateWalk walk, + byte ruin, + (int Index, int Count)? ruinModifyRange) + { + var record = Record(recordNumber) + .AddField(Field(defs, "VRID").AddSubfields(EdgeRcnm, rcid, ruin == Insert ? 1 : 2, ruin)); + + if (ruin == Insert) + { + if (rng.NextUInt32(10) == 0) + { + // QUAPOS (402) on about 10% of edges, as in real cells. + record.AddField(Field(defs, "ATTV").AddSubfields(402, "4")); + } + + var begin = 1 + (int)rng.NextUInt32((uint)connectedNodes); + var end = 1 + (int)rng.NextUInt32((uint)connectedNodes); + record.AddField(Field(defs, "VRPT").AddSubfields( + Name(ConnectedNodeRcnm, begin), 255, 255, 1, 255, + Name(ConnectedNodeRcnm, end), 255, 255, 2, 255)); + } + + if (ruinModifyRange is { } range) + { + record.AddField(Field(defs, "SGCC").AddSubfields(Modify, range.Index, range.Count)); + } + + if (points > 0) + { + var sg2d = Field(defs, "SG2D"); + for (int i = 0; i < points; i++) + { + var (y, x) = walk.Next(); + sg2d.AddSubfields(y, x); + } + + record.AddField(sg2d); + } + + return record.Build(); + } + + private static Iso8211Record Feature(Iso8211DataDescriptiveRecord defs, ref SplitMix64 rng, int recordNumber, int rcid, SyntheticCellProfile profile, int primitive, byte ruin) + { + var objectClasses = primitive switch + { + 1 => PointObjectClasses, + 2 => LineObjectClasses, + _ => AreaObjectClasses + }; + var objl = objectClasses[rng.NextUInt32((uint)objectClasses.Length)]; + var group = primitive == 3 && objl is 42 or 71 ? 1 : 2; + + var record = Record(recordNumber) + .AddField(Field(defs, "FRID").AddSubfields(FeatureRcnm, rcid, primitive, group, objl, 1, ruin)) + .AddField(Field(defs, "FOID").AddSubfields(ProducingAgency, rcid, 1)); + + // 1-7 attributes, mean about 3.7, values averaging about 9 characters. + var attributeCount = 1 + (int)rng.NextUInt32(4) + (int)rng.NextUInt32(4); + record.AddField(Attributes(defs, ref rng, attributeCount)); + + var fspt = Field(defs, "FSPT"); + if (primitive == 1) + { + var node = 1 + (int)rng.NextUInt32((uint)(profile.IsolatedNodes + profile.SoundingNodes)); + fspt.AddSubfields(Name(IsolatedNodeRcnm, node), 255, 255, 255); + } + else + { + var mean = primitive == 2 ? 3.0 : 15.0; + var edges = Math.Clamp(1 + (int)(-mean * Math.Log(1 - rng.NextDouble())), 1, profile.Edges); + for (int i = 0; i < edges; i++) + { + var edge = 1 + (int)rng.NextUInt32((uint)profile.Edges); + fspt.AddSubfields(Name(EdgeRcnm, edge), 1 + (int)rng.NextUInt32(2), 1, 2); + } + } + + record.AddField(fspt); + return record.Build(); + } + + private static Iso8211Record FeatureAttributeUpdate(Iso8211DataDescriptiveRecord defs, ref SplitMix64 rng, int recordNumber, int rcid) => + Record(recordNumber) + .AddField(Field(defs, "FRID").AddSubfields(FeatureRcnm, rcid, 255, 255, 0, 2, Modify)) + .AddField(Field(defs, "FOID").AddSubfields(ProducingAgency, rcid, 1)) + .AddField(Attributes(defs, ref rng, 1 + (int)rng.NextUInt32(2))) + .Build(); + + private static Iso8211Record FeatureDeletion(Iso8211DataDescriptiveRecord defs, int recordNumber, int rcid) => + Record(recordNumber) + .AddField(Field(defs, "FRID").AddSubfields(FeatureRcnm, rcid, 255, 255, 0, 2, Delete)) + .AddField(Field(defs, "FOID").AddSubfields(ProducingAgency, rcid, 1)) + .Build(); + + private static Iso8211FieldBuilder Attributes(Iso8211DataDescriptiveRecord defs, ref SplitMix64 rng, int count) + { + var attf = Field(defs, "ATTF"); + for (int i = 0; i < count; i++) + { + var code = AttributeCodes[rng.NextUInt32((uint)AttributeCodes.Length)]; + attf.AddSubfields(code, RandomText(ref rng, 1 + (int)rng.NextUInt32(16))); + } + + return attf; + } + + private static string RandomText(ref SplitMix64 rng, int length) + { + const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ."; + return string.Create(length, rng.NextUInt64(), static (span, seed) => + { + var local = new SplitMix64((long)seed); + for (int i = 0; i < span.Length; i++) + { + span[i] = alphabet[(int)local.NextUInt32((uint)alphabet.Length)]; + } + }); + } + + /// + /// Gets the number of intermediate points in an edge. Edge 1 is the profile's longest; the + /// rest follow an exponential distribution, which matches the long tail of real coastline + /// edges. The count depends only on the profile and record ID, so updates can target + /// coordinate ranges that exist in the base cell. + /// + private static int EdgePointCount(SyntheticCellProfile profile, int rcid) + { + if (rcid == 1) + { + return profile.MaxEdgePoints; + } + + var rng = new SplitMix64(((long)profile.Seed << 32) ^ rcid); + return Math.Min(profile.MaxEdgePoints, (int)(-profile.MeanEdgePoints * Math.Log(1 - rng.NextDouble()))); + } + + // NAME (b15) packs RCNM (b11) and RCID (b14) little-endian (S-57 Part 3, 2.2). + private static long Name(int rcnm, int rcid) => (long)(((ulong)(uint)rcid << 8) | (byte)rcnm); + + private static Iso8211RecordBuilder Record(int recordNumber) => + new Iso8211RecordBuilder().AddField("0001", BitConverter.GetBytes((ushort)recordNumber)); + + private static Iso8211FieldBuilder Field(Iso8211DataDescriptiveRecord defs, string tag) => + new(defs.GetFieldDefinition(tag) ?? throw new InvalidOperationException($"No DDR definition for {tag}.")); + + private static Iso8211Record BuildDdr() + { + // S-57 Part 3, Annex A: field definitions for the data structures used above. + var definitions = new[] + { + Definition("0001", "ISO/IEC 8211 Record Identifier", Iso8211DataStructureCode.Elementary, Iso8211DataTypeCode.Binary, [], -1, "(b12)"), + Definition("DSID", "Data set identification field", Iso8211DataStructureCode.Vector, Iso8211DataTypeCode.MixedDataTypes, + ["RCNM", "RCID", "EXPP", "INTU", "DSNM", "EDTN", "UPDN", "UADT", "ISDT", "STED", "PRSP", "PSDN", "PRED", "PROF", "AGEN", "COMT"], -1, + "(b11,b14,2b11,3A,2A(8),R(4),b11,2A,b11,b12,A)"), + Definition("DSSI", "Data set structure information field", Iso8211DataStructureCode.Vector, Iso8211DataTypeCode.MixedDataTypes, + ["DSTR", "AALL", "NALL", "NOMR", "NOCR", "NOGR", "NOLR", "NOIN", "NOCN", "NOED", "NOFA"], -1, + "(3b11,8b14)"), + Definition("DSPM", "Data set parameter field", Iso8211DataStructureCode.Vector, Iso8211DataTypeCode.MixedDataTypes, + ["RCNM", "RCID", "HDAT", "VDAT", "SDAT", "CSCL", "DUNI", "HUNI", "PUNI", "COUN", "COMF", "SOMF", "COMT"], -1, + "(b11,b14,3b11,b14,4b11,2b14,A)"), + Definition("VRID", "Vector record identifier field", Iso8211DataStructureCode.Vector, Iso8211DataTypeCode.MixedDataTypes, + ["RCNM", "RCID", "RVER", "RUIN"], -1, "(b11,b14,b12,b11)"), + Definition("ATTV", "Vector record attribute field", Iso8211DataStructureCode.Array, Iso8211DataTypeCode.MixedDataTypes, + ["ATTL", "ATVL"], 0, "(b12,A)"), + Definition("VRPC", "Vector record pointer control field", Iso8211DataStructureCode.Vector, Iso8211DataTypeCode.MixedDataTypes, + ["VPUI", "VPIX", "NVPT"], -1, "(b11,2b12)"), + Definition("VRPT", "Vector record pointer field", Iso8211DataStructureCode.Array, Iso8211DataTypeCode.MixedDataTypes, + ["NAME", "ORNT", "USAG", "TOPI", "MASK"], 0, "(B(40),4b11)"), + Definition("SGCC", "Coordinate control field", Iso8211DataStructureCode.Vector, Iso8211DataTypeCode.MixedDataTypes, + ["CCUI", "CCIX", "CCNC"], -1, "(b11,2b12)"), + Definition("SG2D", "2-D coordinate field", Iso8211DataStructureCode.Array, Iso8211DataTypeCode.MixedDataTypes, + ["YCOO", "XCOO"], 0, "(2b24)"), + Definition("SG3D", "3-D coordinate (sounding array) field", Iso8211DataStructureCode.Array, Iso8211DataTypeCode.MixedDataTypes, + ["YCOO", "XCOO", "VE3D"], 0, "(3b24)"), + Definition("FRID", "Feature record identifier field", Iso8211DataStructureCode.Vector, Iso8211DataTypeCode.MixedDataTypes, + ["RCNM", "RCID", "PRIM", "GRUP", "OBJL", "RVER", "RUIN"], -1, "(b11,b14,2b11,2b12,b11)"), + Definition("FOID", "Feature object identifier field", Iso8211DataStructureCode.Vector, Iso8211DataTypeCode.MixedDataTypes, + ["AGEN", "FIDN", "FIDS"], -1, "(b12,b14,b12)"), + Definition("ATTF", "Feature record attribute field", Iso8211DataStructureCode.Array, Iso8211DataTypeCode.MixedDataTypes, + ["ATTL", "ATVL"], 0, "(b12,A)"), + Definition("NATF", "Feature record national attribute field", Iso8211DataStructureCode.Array, Iso8211DataTypeCode.MixedDataTypes, + ["ATTL", "ATVL"], 0, "(b12,A)"), + Definition("FFPC", "Feature record to feature object pointer control field", Iso8211DataStructureCode.Vector, Iso8211DataTypeCode.MixedDataTypes, + ["FFUI", "FFIX", "NFPT"], -1, "(b11,2b12)"), + Definition("FFPT", "Feature record to feature object pointer field", Iso8211DataStructureCode.Array, Iso8211DataTypeCode.MixedDataTypes, + ["LNAM", "RIND", "COMT"], 0, "(B(64),b11,A)"), + Definition("FSPC", "Feature record to spatial record pointer control field", Iso8211DataStructureCode.Vector, Iso8211DataTypeCode.MixedDataTypes, + ["FSUI", "FSIX", "NSPT"], -1, "(b11,2b12)"), + Definition("FSPT", "Feature record to spatial record pointer field", Iso8211DataStructureCode.Array, Iso8211DataTypeCode.MixedDataTypes, + ["NAME", "ORNT", "USAG", "MASK"], 0, "(B(40),3b11)"), + }; + + return Iso8211DataDescriptiveRecordWriter.BuildDdr(definitions, fieldControlLength: 9); + } + + private static Iso8211FieldDefinition Definition( + string tag, + string name, + Iso8211DataStructureCode structure, + Iso8211DataTypeCode type, + string[] labels, + int repeatingStart, + string formats) => new() + { + Tag = tag, + FieldName = name, + DataStructureCode = structure, + DataTypeCode = type, + FormatControls = formats, + RepeatingSubfieldStartIndex = repeatingStart, + SubfieldDefinitions = [.. labels.Select((label, index) => new Iso8211SubfieldDefinition { Name = label, Index = index })] + }; + + /// + /// A random walk of positions near 47.6°N 122.3°W in COMF 107 units. + /// + private sealed class CoordinateWalk(SplitMix64 rng) + { + private SplitMix64 _rng = rng; + private int _y = 476_000_000; + private int _x = -1_223_000_000; + + public (int Y, int X) Next() + { + _y += (int)_rng.NextUInt32(4001) - 2000; + _x += (int)_rng.NextUInt32(4001) - 2000; + return (_y, _x); + } + } + + /// + /// SplitMix64: a small, fully specified generator whose sequence never changes across + /// .NET versions, unlike . + /// + private struct SplitMix64(long seed) + { + private ulong _state = unchecked((ulong)seed); + + public ulong NextUInt64() + { + var z = _state += 0x9E3779B97F4A7C15UL; + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL; + return z ^ (z >> 31); + } + + public uint NextUInt32(uint exclusiveMax) => (uint)(NextUInt64() % exclusiveMax); + + public double NextDouble() => (NextUInt64() >> 11) * (1.0 / (1UL << 53)); + } +}