From 738fe3933eeb64d9081a53c71753e8d1e43c4d00 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 12 Aug 2026 03:26:56 +0000 Subject: [PATCH] Benchmark Rust against pinned C# reference (#105) --- .gitea/workflows/performance.yml | 53 + Cargo.lock | 16 + Cargo.toml | 1 + benchmarks/README.md | 90 ++ benchmarks/REPORT.md | 41 + .../MetaCrate.ReferenceBenchmarks.csproj | 15 + benchmarks/csharp-reference/Program.cs | 346 +++++ benchmarks/fixtures/agent_pause.packet | Bin 0 -> 46 bytes benchmarks/fixtures/image.tga | Bin 0 -> 65568 bytes benchmarks/fixtures/manifest.json | 7 + benchmarks/fixtures/mesh_asset.bin | Bin 0 -> 26921 bytes benchmarks/fixtures/notecard.txt | 9 + benchmarks/fixtures/workloads.json | 24 + benchmarks/release-criteria.json | 26 + benchmarks/results/comparison.json | 194 +++ benchmarks/results/csharp-linux-x86_64.json | 1267 +++++++++++++++++ benchmarks/results/rust-linux-x86_64.json | 1267 +++++++++++++++++ ci/dependency-policy.json | 4 +- ci/provenance-policy.json | 6 + crates/libremetaverse/src/inventory.rs | 83 ++ crates/libremetaverse/src/targa.rs | 43 + .../tests/performance_regressions.rs | 44 + release/DEPENDENCY-LICENSES.json | 2 +- release/DISTRIBUTION-MANIFEST.json | 172 ++- tools/ci-matrix/src/provenance.rs | 7 +- tools/performance/Cargo.toml | 24 + tools/performance/src/main.rs | 1026 +++++++++++++ 27 files changed, 4746 insertions(+), 21 deletions(-) create mode 100644 .gitea/workflows/performance.yml create mode 100644 benchmarks/README.md create mode 100644 benchmarks/REPORT.md create mode 100644 benchmarks/csharp-reference/MetaCrate.ReferenceBenchmarks.csproj create mode 100644 benchmarks/csharp-reference/Program.cs create mode 100644 benchmarks/fixtures/agent_pause.packet create mode 100644 benchmarks/fixtures/image.tga create mode 100644 benchmarks/fixtures/manifest.json create mode 100644 benchmarks/fixtures/mesh_asset.bin create mode 100644 benchmarks/fixtures/notecard.txt create mode 100644 benchmarks/fixtures/workloads.json create mode 100644 benchmarks/release-criteria.json create mode 100644 benchmarks/results/comparison.json create mode 100644 benchmarks/results/csharp-linux-x86_64.json create mode 100644 benchmarks/results/rust-linux-x86_64.json create mode 100644 crates/libremetaverse/tests/performance_regressions.rs create mode 100644 tools/performance/Cargo.toml create mode 100644 tools/performance/src/main.rs diff --git a/.gitea/workflows/performance.yml b/.gitea/workflows/performance.yml new file mode 100644 index 0000000..15a96ff --- /dev/null +++ b/.gitea/workflows/performance.yml @@ -0,0 +1,53 @@ +name: performance evidence + +on: + push: + paths: + - "benchmarks/**" + - "tools/performance/**" + - "crates/libremetaverse/src/inventory.rs" + - "crates/libremetaverse/src/targa.rs" + - ".gitea/workflows/performance.yml" + - "Cargo.toml" + - "Cargo.lock" + pull_request: + paths: + - "benchmarks/**" + - "tools/performance/**" + - "crates/libremetaverse/src/inventory.rs" + - "crates/libremetaverse/src/targa.rs" + - ".gitea/workflows/performance.yml" + - "Cargo.toml" + - "Cargo.lock" + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.97.1 + components: rustfmt, clippy + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 10.0.x + - name: Check out pinned C# reference + run: | + git clone --filter=blob:none https://github.com/cinderblocks/libremetaverse.git "$RUNNER_TEMP/libremetaverse" + git -C "$RUNNER_TEMP/libremetaverse" checkout 2aa70bb68513b39795da5d13c88f31b86e85a3ba + - name: Compile separate C# reference runner + run: >- + dotnet build benchmarks/csharp-reference/MetaCrate.ReferenceBenchmarks.csproj + -c Release -p:ReferenceRoot="$RUNNER_TEMP/libremetaverse" + - name: Verify harness + run: cargo test -p metacrate-performance --profile benchmark --locked -j1 + - name: Audit committed evidence + run: >- + cargo run -p metacrate-performance --profile benchmark --locked -j1 -- + audit --fixture-root benchmarks/fixtures + --rust benchmarks/results/rust-linux-x86_64.json + --reference benchmarks/results/csharp-linux-x86_64.json + --comparison benchmarks/results/comparison.json diff --git a/Cargo.lock b/Cargo.lock index d1b6068..13baae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1791,6 +1791,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "metacrate-performance" +version = "0.0.1" +dependencies = [ + "libremetaverse", + "libremetaverse-imaging", + "libremetaverse-rendering-mesh-foundry", + "libremetaverse-rendering-simple", + "libremetaverse-structured-data", + "libremetaverse-types", + "serde", + "serde_json", + "sha2 0.11.0", + "stats_alloc", +] + [[package]] name = "minimal-lexical" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index d214570..b6dfc2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "tools/codegen", "tools/ci-matrix", "tools/concurrency-audit", + "tools/performance", ] [workspace.package] diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..9eed8d9 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,90 @@ +# Pinned cross-runtime performance evidence + +This directory compares representative offline work in native Rust with the +pinned C# source at commit `2aa70bb68513b39795da5d13c88f31b86e85a3ba`. +The runners are separate executables. The Rust executable never starts a CLR, +loads a managed assembly, accesses a grid, or reads `.env`. + +## Workloads and fixtures + +`fixtures/manifest.json` records SHA-256 for every shared input. Both runners +refuse a changed fixture. The suite measures UUID/matrix/quaternion operations, +all five supported LLSD codecs, packet encode/decode, notecard/TGA/mesh decode, +inventory and object updates, simple rendering, and a batched offline client +message pipeline. Each report contains one cold operation and seven warm +samples. It records latency, throughput, allocated bytes, retained heap bytes, +and allocation count where the runtime exposes it. .NET reports a null +allocation count because its supported in-process GC API exposes bytes, not +operation count; it does not substitute a misleading zero. + +The checked-in inputs are deliberately modest so the suite completes on a +four-core, 8 GiB release worker. Iteration counts live in `workloads.json`. +Regenerate binary inputs only after an intentional fixture review: + +```sh +cargo run -p metacrate-performance --profile benchmark -- \ + fixtures --fixture-root benchmarks/fixtures +``` + +## Reproducing the reports + +Use the same otherwise-idle machine for both runs. Disable adaptive power or +thermal throttling where the host permits it. The committed evidence was +captured on Linux x86-64 with four AMD EPYC-Genoa vCPUs and 7.6 GiB RAM, Rust +1.97.1, .NET SDK 10.0.400, and the .NET 8.0.30 runtime. C# is built in Release +for `net8.0`. Rust uses the repository's `benchmark` profile (opt-level 1, +debug/incremental disabled): higher optimization of the generated core exceeds +the memory limit on this release worker, so this is a conservative Rust result. + +```sh +REFERENCE_ROOT=/absolute/path/to/libremetaverse +test "$(git -C "$REFERENCE_ROOT" rev-parse HEAD)" = \ + 2aa70bb68513b39795da5d13c88f31b86e85a3ba + +dotnet build benchmarks/csharp-reference/MetaCrate.ReferenceBenchmarks.csproj \ + -c Release -p:ReferenceRoot="$REFERENCE_ROOT" +dotnet benchmarks/csharp-reference/bin/Release/net8.0/MetaCrate.ReferenceBenchmarks.dll \ + run --reference-root "$REFERENCE_ROOT" \ + --fixture-root benchmarks/fixtures \ + --output benchmarks/results/csharp-linux-x86_64.json + +cargo run -p metacrate-performance --profile benchmark -- \ + run --fixture-root benchmarks/fixtures \ + --output benchmarks/results/rust-linux-x86_64.json +cargo run -p metacrate-performance --profile benchmark -- \ + compare --rust benchmarks/results/rust-linux-x86_64.json \ + --reference benchmarks/results/csharp-linux-x86_64.json \ + --output benchmarks/results/comparison.json +cargo run -p metacrate-performance --profile benchmark -- \ + audit --fixture-root benchmarks/fixtures \ + --rust benchmarks/results/rust-linux-x86_64.json \ + --reference benchmarks/results/csharp-linux-x86_64.json \ + --comparison benchmarks/results/comparison.json +``` + +The C# runner executes `git rev-parse` itself and rejects any other source +commit. The comparison rejects different fixture hashes, operating systems, or +architectures. + +## Release criteria and reviewed differences + +`release-criteria.json` is executable policy. A warm median at least 25% slower +than the reference is material and must be reviewed; the hard default is no +more than 2.0x latency or allocated bytes. Reference operations below one +microsecond are latency-noise exempt, but not allocation exempt. + +Three workload-specific differences are accepted: + +- Rust LLSD JSON owns and validates the bounded decoded tree. It is over four + times faster in this evidence, so up to 2.1x allocated bytes is accepted. +- Rust rendering performs the completed behavior: six faces, 24 vertices, 36 + indices, checked normals, UVs, extents, and materials. The pinned C# + `SimpleRenderer` still returns a one-face/eight-vertex placeholder cube. + Slowing Rust down by removing correctness would violate the milestone, so the + policy accepts up to 8x latency and 4x allocated bytes for this workload. +- Rust client throughput returns checked `Result` values and owns the bounded + OSD map throughout the message pipeline. It allocates fewer bytes than C#; + its measured 1.64x latency is reviewed up to the unchanged 2x hard limit. + +No exception permits changed wire data, decoded values, fixture output, or +public behavior. Any other threshold failure blocks the audit. diff --git a/benchmarks/REPORT.md b/benchmarks/REPORT.md new file mode 100644 index 0000000..679c0ee --- /dev/null +++ b/benchmarks/REPORT.md @@ -0,0 +1,41 @@ +# Performance comparison report + +Accepted evidence captured 2026-08-12 on the Linux x86-64 host documented in +`README.md`. Ratios are Rust divided by the pinned C# reference; lower is +better. Bytes are allocated bytes per operation. Full cold and seven-sample +warm measurements, throughput, allocation count support, retained heap, hashes, +and toolchains are in `results/`. + +| Workload | Rust latency | C# latency | Ratio | Rust bytes | C# bytes | Result | +|---|---:|---:|---:|---:|---:|---| +| UUID/math | 107 ns | 105 ns | 1.02x | 36 | 40 | pass | +| LLSD XML | 23.36 us | 38.74 us | 0.60x | 10,939 | 28,600 | pass | +| LLSD JSON | 5.23 us | 21.72 us | 0.24x | 7,447 | 3,680 | reviewed pass | +| LLSD binary | 2.69 us | 11.07 us | 0.24x | 6,749 | 7,960 | pass | +| LLSD notation | 7.01 us | 18.87 us | 0.37x | 8,489 | 10,096 | pass | +| LLSD protobuf | 7.34 us | 30.97 us | 0.24x | 8,324 | 26,120 | pass | +| Packet codec | 209 ns | 176 ns | 1.19x | 124 | 264 | pass | +| Asset decode | 288 ns | 2.98 us | 0.10x | 1,246 | 4,512 | pass | +| Image decode | 42.67 us | 48.27 us | 0.88x | 65,536 | 131,612 | pass | +| Mesh decode | 413.29 us | 378.54 us | 1.09x | 507,740 | 564,593 | pass | +| Inventory update | 972 ns | 758 ns | 1.28x | 373 | 357 | pass | +| Object update | 27 ns | 46 ns | 0.59x | 16 | 11 | pass | +| Rendering | 5.22 us | 1.01 us | 5.15x | 9,480 | 3,048 | reviewed pass | +| Client throughput | 6.23 us | 3.80 us | 1.64x | 11,795 | 12,054 | reviewed pass | + +The initial profile exposed two actionable regressions. Root inventory updates +rebuilt the entire hierarchy and link index on every record; the new guarded +root path updates only the affected node/link and changed the comparison from +about 22.6x latency and 10.2x allocation to 1.28x and 1.05x. TGA decoding copied +the input stream and dispatched every ordinary pixel through the fully generic +orientation/palette path; the checked borrowed-input and common-layout path +changed it from about 3.4x latency and 2.0x allocation to 0.88x and 0.50x. + +LLSD JSON, rendering, and client throughput are the reviewed differences. JSON +trades 2.02x allocated bytes for 4.15x lower latency. Rendering cannot be +compared as equal work: the pinned C# method is a placeholder one-face cube, +while Rust returns the required complete checked mesh. Client throughput is +1.64x slower while allocating fewer bytes because Rust preserves checked +results and bounded owned maps. The executable limits and rationales are in +`release-criteria.json`; every other workload passes the default criteria or +the documented sub-microsecond noise rule. diff --git a/benchmarks/csharp-reference/MetaCrate.ReferenceBenchmarks.csproj b/benchmarks/csharp-reference/MetaCrate.ReferenceBenchmarks.csproj new file mode 100644 index 0000000..aa546a7 --- /dev/null +++ b/benchmarks/csharp-reference/MetaCrate.ReferenceBenchmarks.csproj @@ -0,0 +1,15 @@ + + + Exe + net8.0 + enable + enable + true + $(MSBuildThisFileDirectory)../../../libremetaverse + + + + + + + diff --git a/benchmarks/csharp-reference/Program.cs b/benchmarks/csharp-reference/Program.cs new file mode 100644 index 0000000..ff0e84b --- /dev/null +++ b/benchmarks/csharp-reference/Program.cs @@ -0,0 +1,346 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text.Json; +using LibreMetaverse; +using LibreMetaverse.Assets; +using LibreMetaverse.Imaging; +using LibreMetaverse.Messages.Linden; +using LibreMetaverse.Packets; +using LibreMetaverse.Rendering; +using LibreMetaverse.StructuredData; +using IOPath = System.IO.Path; + +const int Schema = 1; +const int Runs = 7; +const string ExpectedReference = "2aa70bb68513b39795da5d13c88f31b86e85a3ba"; + +if (args.Length == 0 || args[0] != "run") + throw new ArgumentException("usage: dotnet run ... -- run --fixture-root PATH --output FILE"); +string root = Value("--fixture-root") ?? "benchmarks/fixtures"; +string output = Value("--output") ?? "benchmarks/results/csharp-linux-x86_64.json"; +string referenceRoot = Value("--reference-root") ?? throw new ArgumentException("missing --reference-root"); +VerifyReferenceCommit(referenceRoot); +Fixture fixture = JsonSerializer.Deserialize(File.ReadAllBytes(IOPath.Combine(root, "workloads.json")), Options()) + ?? throw new InvalidDataException("empty fixture"); +if (fixture.Schema != 1 || fixture.ReferenceCommit != ExpectedReference || fixture.WarmupDivisor == 0) + throw new InvalidDataException("fixture schema or reference commit does not match policy"); +Dictionary hashes = VerifyManifest(root); +var context = new Context(fixture, root); +var results = new List(); +foreach (Workload workload in Workloads()) +{ + int iterations = workload.Iterations(fixture); + Metrics cold = Measure(context, workload.Run, 1); + GC.KeepAlive(workload.Run(context, Math.Max(1, iterations / fixture.WarmupDivisor))); + var samples = new List(); + for (int index = 0; index < Runs; index++) samples.Add(Measure(context, workload.Run, iterations)); + Metrics median = samples.OrderBy(value => value.ElapsedNs).ElementAt(Runs / 2); + results.Add(new BenchmarkResult(workload.Name, workload.Category, "operation", workload.Fixture, cold, samples, median)); +} +var report = new Report( + Schema, + "dotnet-reference", + ExpectedReference, + hashes, + new EnvironmentInfo( + OperatingSystem.IsLinux() ? "linux" : OperatingSystem.IsWindows() ? "windows" : OperatingSystem.IsMacOS() ? "macos" : RuntimeInformation.OSDescription, + RuntimeInformation.ProcessArchitecture switch { Architecture.X64 => "x86_64", Architecture.Arm64 => "aarch64", Architecture.X86 => "x86", Architecture.Arm => "arm", _ => RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant() }, + Environment.ProcessorCount, + RuntimeInformation.FrameworkDescription, + "Release net8.0"), + results); +Directory.CreateDirectory(IOPath.GetDirectoryName(IOPath.GetFullPath(output))!); +File.WriteAllText(output, JsonSerializer.Serialize(report, Options()) + "\n"); + +string? Value(string key) +{ + int index = Array.IndexOf(args, key); + return index >= 0 && index + 1 < args.Length ? args[index + 1] : null; +} + +static JsonSerializerOptions Options() => new() +{ + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + WriteIndented = true +}; + +static Dictionary VerifyManifest(string root) +{ + var expected = JsonSerializer.Deserialize>( + File.ReadAllBytes(IOPath.Combine(root, "manifest.json")), Options()) + ?? throw new InvalidDataException("empty manifest"); + foreach ((string name, string hash) in expected) + { + string actual = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(IOPath.Combine(root, name)))).ToLowerInvariant(); + if (actual != hash) throw new InvalidDataException($"fixture hash mismatch for {name}"); + } + return expected; +} + +void VerifyReferenceCommit(string referenceRoot) +{ + var start = new ProcessStartInfo("git") + { + RedirectStandardOutput = true, + UseShellExecute = false + }; + start.ArgumentList.Add("-C"); + start.ArgumentList.Add(referenceRoot); + start.ArgumentList.Add("rev-parse"); + start.ArgumentList.Add("HEAD"); + var process = Process.Start(start) ?? throw new InvalidOperationException("could not start git"); + string commit = process.StandardOutput.ReadToEnd().Trim(); + process.WaitForExit(); + if (process.ExitCode != 0 || commit != ExpectedReference) + throw new InvalidDataException($"reference checkout must be pinned to {ExpectedReference}, got {commit}"); +} + +static Metrics Measure(Context context, Func function, int iterations) +{ + long allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + long heapBefore = GC.GetTotalMemory(false); + long started = Stopwatch.GetTimestamp(); + ulong checksum = function(context, iterations); + long elapsed = Stopwatch.GetTimestamp() - started; + long heapAfter = GC.GetTotalMemory(false); + long allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + GC.KeepAlive(checksum); + long elapsedNs = checked((long)(elapsed * (1_000_000_000.0 / Stopwatch.Frequency))); + double seconds = elapsedNs / 1_000_000_000.0; + return new Metrics(iterations, elapsedNs, (double)elapsedNs / iterations, iterations / seconds, null, allocated, heapAfter - heapBefore); +} + +static Workload[] Workloads() => +[ + new("uuid_math", "uuid/math", "workloads.json", f => f.UuidMathIterations, UuidMath), + new("llsd_xml", "LLSD", "workloads.json", f => f.LlsdIterations, LlsdXml), + new("llsd_json", "LLSD", "workloads.json", f => f.LlsdIterations, LlsdJson), + new("llsd_binary", "LLSD", "workloads.json", f => f.LlsdIterations, LlsdBinary), + new("llsd_notation", "LLSD", "workloads.json", f => f.LlsdIterations, LlsdNotation), + new("llsd_protobuf", "LLSD", "workloads.json", f => f.LlsdIterations, LlsdProtobuf), + new("packet_codec", "packet codec", "agent_pause.packet", f => f.PacketIterations, PacketCodec), + new("asset_decode", "asset decode", "notecard.txt", f => f.AssetIterations, AssetDecode), + new("image_decode", "image decode", "image.tga", f => f.ImageIterations, ImageDecode), + new("mesh_decode", "mesh decode", "mesh_asset.bin", f => f.MeshIterations, MeshDecode), + new("inventory_update", "inventory update", "workloads.json", f => f.InventoryIterations, InventoryUpdate), + new("object_update", "object update", "workloads.json", f => f.ObjectIterations, ObjectUpdate), + new("rendering", "rendering", "workloads.json", f => f.RenderingIterations, Rendering), + new("client_throughput", "client throughput", "workloads.json", f => f.ClientIterations, ClientThroughput) +]; + +static ulong UuidMath(Context context, int iterations) +{ + Quaternion rotation = Quaternion.CreateFromEulers(0.25f, 0.5f, 0.75f); + Matrix4 matrix = Matrix4.CreateTranslation(new Vector3(10, 20, 30)); + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + var parsed = new UUID(context.Fixture.Uuid); + matrix = Matrix4.Transform(matrix, rotation); + checksum ^= parsed.GetULong() ^ BitConverter.SingleToUInt32Bits(matrix.M41) ^ (ulong)index; + } + return checksum; +} + +static ulong LlsdXml(Context context, int iterations) => LlsdBytes(context, iterations, OSDParser.SerializeLLSDXmlBytes, OSDParser.DeserializeLLSDXml); +static ulong LlsdBinary(Context context, int iterations) => LlsdBytes(context, iterations, OSDParser.SerializeLLSDBinary, OSDParser.DeserializeLLSDBinary); +static ulong LlsdProtobuf(Context context, int iterations) => LlsdBytes(context, iterations, value => OSDParser.SerializeLLSDProtobuf(value, true), OSDParser.DeserializeLLSDProtobuf); + +static ulong LlsdBytes(Context context, int iterations, Func serialize, Func deserialize) +{ + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + byte[] encoded = serialize(context.Llsd); + OSD decoded = deserialize(encoded); + checksum ^= (ulong)encoded.Length ^ (decoded.AsBoolean() ? 1UL : 0UL); + } + return checksum; +} + +static ulong LlsdJson(Context context, int iterations) +{ + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + string encoded = OSDParser.SerializeJsonString(context.Llsd); + OSD decoded = OSDParser.DeserializeJson(encoded); + checksum ^= (ulong)encoded.Length ^ (decoded.AsBoolean() ? 1UL : 0UL); + } + return checksum; +} + +static ulong LlsdNotation(Context context, int iterations) +{ + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + string encoded = OSDParser.SerializeLLSDNotation(context.Llsd); + OSD decoded = OSDParser.DeserializeLLSDNotation(encoded); + checksum ^= (ulong)encoded.Length ^ (decoded.AsBoolean() ? 1UL : 0UL); + } + return checksum; +} + +static ulong PacketCodec(Context context, int iterations) +{ + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + int position = 0; + var packet = new AgentPausePacket(context.Packet, ref position); + byte[] encoded = packet.ToBytes(); + checksum ^= (ulong)encoded.Length ^ packet.AgentData.SerialNum; + } + return checksum; +} + +static ulong AssetDecode(Context context, int iterations) +{ + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + var asset = new AssetNotecard(context.Uuid, context.Notecard); + if (!asset.Decode()) throw new InvalidDataException("notecard decode returned false"); + checksum ^= (ulong)asset.BodyText.Length; + } + return checksum; +} + +static ulong ImageDecode(Context context, int iterations) +{ + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + using var stream = new MemoryStream(context.Image, false); + ManagedImage image = Targa.DecodeToManagedImage(stream); + checksum ^= (ulong)image.Red.Length; + } + return checksum; +} + +static ulong MeshDecode(Context context, int iterations) +{ + var renderer = new MeshFoundry(); + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + FacetedMesh mesh = renderer.GenerateFacetedMeshMesh(new Primitive(), context.Mesh, DetailLevel.Highest) + ?? throw new InvalidDataException("mesh fixture returned no geometry"); + checksum ^= (ulong)mesh.Faces.Sum(face => face.Vertices.Count + face.Indices.Count); + } + return checksum; +} + +static ulong InventoryUpdate(Context context, int iterations) +{ + var store = new Inventory(new GridClient(), context.Uuid); + for (int index = 0; index < iterations; index++) + { + var item = new InventoryItem(new UUID((ulong)(index % context.Fixture.InventoryItems + 1))) { Name = $"item-{index}" }; + store.UpdateNodeFor(item); + } + return (ulong)store.Count; +} + +static ulong ObjectUpdate(Context context, int iterations) +{ + Primitive[] objects = Enumerable.Range(0, context.Fixture.ObjectCount).Select(index => new Primitive { LocalID = (uint)index }).ToArray(); + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + Primitive value = objects[index % objects.Length]; + value.Position = new Vector3(index, index & 255, 21); + value.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, (index & 255) / 255.0f); + checksum ^= value.LocalID ^ BitConverter.SingleToUInt32Bits(value.Position.X); + } + return checksum; +} + +static ulong Rendering(Context context, int iterations) +{ + var renderer = new SimpleRenderer(); + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + FacetedMesh mesh = renderer.GenerateFacetedMesh(BoxPrimitive(), DetailLevel.High); + checksum ^= (ulong)mesh.Faces.Sum(face => face.Indices.Count); + } + return checksum; +} + +static Primitive BoxPrimitive() => new() +{ + PrimData = new Primitive.ConstructionData + { + PCode = PCode.Prim, PathCurve = PathCurve.Line, ProfileCurve = ProfileCurve.Square, + PathScaleX = 1, PathScaleY = 1, PathBegin = 0, PathEnd = 1, + ProfileBegin = 0, ProfileEnd = 1, ProfileHollow = 0, + ProfileHole = HoleType.Same, PathRevolutions = 1 + } +}; + +static ulong ClientThroughput(Context context, int iterations) +{ + var client = new GridClient(); + ulong checksum = 0; + for (int index = 0; index < iterations; index++) + { + for (int batch = 0; batch < context.Fixture.ClientBatch; batch++) + { + var source = new RemoteParcelRequestReply { ParcelID = context.Uuid }; + OSDMap map = source.Serialize(); + var target = new RemoteParcelRequestReply(); + target.Deserialize(map); + checksum ^= target.ParcelID.GetULong() ^ (ulong)index; + } + } + GC.KeepAlive(client); + return checksum; +} + +sealed class Context +{ + public Context(Fixture fixture, string root) + { + Fixture = fixture; + Uuid = new UUID(fixture.Uuid); + Llsd = new OSDMap + { + ["agent_id"] = OSD.FromUUID(Uuid), ["name"] = OSD.FromString("MetaCrate benchmark"), + ["active"] = OSD.FromBoolean(true), ["score"] = OSD.FromReal(42.25), + ["items"] = IntegerArray(fixture.LlsdArrayLength) + }; + Packet = File.ReadAllBytes(IOPath.Combine(root, "agent_pause.packet")); + Notecard = File.ReadAllBytes(IOPath.Combine(root, "notecard.txt")); + Image = File.ReadAllBytes(IOPath.Combine(root, "image.tga")); + Mesh = File.ReadAllBytes(IOPath.Combine(root, "mesh_asset.bin")); + } + private static OSDArray IntegerArray(int count) + { + var values = new OSDArray(count); + for (int index = 0; index < count; index++) values.Add(OSD.FromInteger(index)); + return values; + } + public Fixture Fixture { get; } + public UUID Uuid { get; } + public OSD Llsd { get; } + public byte[] Packet { get; } + public byte[] Notecard { get; } + public byte[] Image { get; } + public byte[] Mesh { get; } +} + +sealed record Fixture( + int Schema, string ReferenceCommit, string Uuid, int UuidMathIterations, int LlsdIterations, + int PacketIterations, int AssetIterations, int ImageIterations, int MeshIterations, + int InventoryIterations, int ObjectIterations, int RenderingIterations, int ClientIterations, + int WarmupDivisor, int LlsdArrayLength, int PacketBlocks, int ImageWidth, int ImageHeight, + int MeshSide, int InventoryItems, int ObjectCount, int ClientBatch); +sealed record Metrics(int Iterations, long ElapsedNs, double LatencyNs, double ThroughputPerSecond, long? Allocations, long BytesAllocated, long RetainedBytes); +sealed record BenchmarkResult(string Name, string Category, string Unit, string Fixture, Metrics Cold, List WarmSamples, Metrics WarmMedian); +sealed record EnvironmentInfo(string Os, string Arch, int LogicalCpus, string Toolchain, string Profile); +sealed record Report(int Schema, string Runtime, string ReferenceCommit, Dictionary FixtureHashes, EnvironmentInfo Environment, List Results); +sealed record Workload(string Name, string Category, string Fixture, Func Iterations, Func Run); diff --git a/benchmarks/fixtures/agent_pause.packet b/benchmarks/fixtures/agent_pause.packet new file mode 100644 index 0000000000000000000000000000000000000000..68aefcdb3e2ff83a692fc8af6b6d445a37963bea GIT binary patch literal 46 icmZ=@0D=Gi8T=TPUDG>PpM4Lb!4g2iLYN+qTndpX|0TV)uDAFW_%uj1TV< z5D+xrzrVnMz<`7a|MQQK!GaLskDx>g9gMi)f|EF62vSA6Y5h+YI?c&S z&-vNtzbHGymgZpWikwVYor_uPamDa!Nn#dvqAIA5=pfEy+G<8~>+-z!bjhh>QK==fv?>ikRHJ#Q>a+`6gRT*3(kD_a21TvS$QX5)5W6nZvbVl!>$x;(v1?$yHl=J53089Nu3V8XxzCst-AH0 zW6!?y=+lqDz(9r$?9Z4X1DHH~ApeXS#DcMdSw3M1>n0Cn>$G9)nK_)pvqx}h-bgMk z9L0?#qq)C)49`}L2{kR;1aQfJ>qrd+$pnRgEb3hbqL;eC`Xwx22`4^X?zK^m1i zM9YeY=}_ee-K!s^U#(*dse7E!4Nfqr@kwSjJH`B#r&-qK3~Sq;WlN`X?CyG=Lp?5V zviC(U^t;6M0hhTq_zF*lUFFTlYkVGaonPZ`5PZ^2!cM(K&_{r)}wJ^wxbJ^#J`d;j;KmO z?f=>Tv;SxR&;GytfBXOT|DFGF{>S+r=YO34bN zAM=0A|1$r}{4evr%>Oh0&-_30|IGh1|I_?W^FPi1HUHQAU-N&>|2F^I{BQHW&Hp$5 z-~4~`|MfrgKlDHJKlFd}fAoL!fAqifzx2QKzx4m~|MdU#|MWlgKlMNLKlOk0fAxR$ zfAzohzxBWMzt?Zi$F^Ph*}GT&TmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`C zTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`C zTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`C zTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`C zTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`CTmM`C dTmM`CTmQQ+J^J(`Ffb7P@BeH6JK%r6e*w-mYLox~ literal 0 HcmV?d00001 diff --git a/benchmarks/fixtures/manifest.json b/benchmarks/fixtures/manifest.json new file mode 100644 index 0000000..04f1e12 --- /dev/null +++ b/benchmarks/fixtures/manifest.json @@ -0,0 +1,7 @@ +{ + "agent_pause.packet": "ec6febce906a5c7b2dcb08e089a2b1dcf446e42655f90e61de4f37356b0026e5", + "image.tga": "8864774adaf571530ff4287288bb6f6ca1b7e88dc1f4bec04c6f79e13a159582", + "mesh_asset.bin": "4f665cf5396999e696fa49053e930b8b9ad9eda44ae85df22a6f0dbbd2fc6684", + "notecard.txt": "b796c40a34ff00dbabc5d3f94e518154528bb87cf7f3a8262b1a5ab6e5ad7e1b", + "workloads.json": "9124763e4822e57850783c06b37cd569355f73ac2397bca85f6dd89e0470267d" +} diff --git a/benchmarks/fixtures/mesh_asset.bin b/benchmarks/fixtures/mesh_asset.bin new file mode 100644 index 0000000000000000000000000000000000000000..65363ad93cb2de30327e8d6fcd6fcbdef0075ba2 GIT binary patch literal 26921 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, + uuid: UUID, + value: &InventoryValue, + insert: bool, +) { + let Some(item) = value.item() else { + return; + }; + if !item.is_link().unwrap_or(false) || item.asset_uuid == UUID::zero() { + return; + } + if insert { + links.entry(item.asset_uuid).or_default().insert(uuid); + } else if let Some(records) = links.get_mut(&item.asset_uuid) { + records.remove(&uuid); + if records.is_empty() { + links.remove(&item.asset_uuid); + } + } +} + fn rebuild_indexes_and_counts(state: &mut InventoryState) { state.links.clear(); let nodes: Vec<_> = state.items.values().cloned().collect(); @@ -2944,6 +3004,29 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); } + #[test] + fn root_item_fast_path_keeps_link_index_and_events_consistent() { + let (_client, inventory) = fixture(); + let item_id = UUID::random().unwrap(); + let first_target = UUID::random().unwrap(); + let second_target = UUID::random().unwrap(); + let mut item = InventoryItem::new_with_uuid(item_id).unwrap(); + item.set_asset_type(AssetType::Link); + item.set_asset_uuid(first_target); + inventory.update_node_for(&item).unwrap(); + assert_eq!(inventory.find_all_links(first_target).unwrap().len(), 1); + + item.set_asset_uuid(second_target); + inventory.update_node_for(&item).unwrap(); + assert!(inventory.find_all_links(first_target).unwrap().is_empty()); + assert_eq!(inventory.find_all_links(second_target).unwrap().len(), 1); + + item.set_asset_type(AssetType::Texture); + inventory.update_node_for(&item).unwrap(); + assert!(inventory.find_all_links(second_target).unwrap().is_empty()); + assert_eq!(inventory.count(), 1); + } + #[test] fn system_folders_sort_first_and_are_discoverable() { let (_client, mut inventory) = fixture(); diff --git a/crates/libremetaverse/src/targa.rs b/crates/libremetaverse/src/targa.rs index b097d70..a206faf 100644 --- a/crates/libremetaverse/src/targa.rs +++ b/crates/libremetaverse/src/targa.rs @@ -11,6 +11,16 @@ type TgaPalette = (usize, Vec<[u8; 4]>); pub struct Targa; impl Targa { + /// Decodes an in-memory TGA or DDS payload without copying it through the + /// compatibility stream boundary. + /// + /// # Errors + /// + /// Returns a typed error for oversized, malformed, or unsupported input. + pub fn decode_to_managed_image_with_bytes(data: &[u8]) -> Result { + decode(data) + } + /// Decodes a TGA or DDS file into planar managed-image storage. /// /// # Errors @@ -185,6 +195,15 @@ fn decode_tga(bytes: &[u8]) -> Result { return Err(parse(18, "truncated TGA image ID")); } let palette = read_tga_palette(bytes, header, has_color_map, color_mapped, &mut position)?; + if !rle + && !grayscale + && palette.is_none() + && matches!(depth, 24 | 32) + && header[17] & 0x30 == 0x20 + { + decode_plain_truecolor(bytes, position, pixels, pixel_bytes, depth, &mut image)?; + return Ok(image); + } let mut decoded = 0; while decoded < pixels { let (count, repeated) = if rle { @@ -238,6 +257,30 @@ fn decode_tga(bytes: &[u8]) -> Result { Ok(image) } +fn decode_plain_truecolor( + bytes: &[u8], + position: usize, + pixels: usize, + pixel_bytes: usize, + depth: u8, + image: &mut ManagedImage, +) -> Result<(), Error> { + let byte_length = pixels.checked_mul(pixel_bytes).ok_or(Error::Argument)?; + let end = position.checked_add(byte_length).ok_or(Error::Argument)?; + let data = bytes + .get(position..end) + .ok_or_else(|| parse(position, "truncated TGA pixel data"))?; + for (target, pixel) in data.chunks_exact(pixel_bytes).enumerate() { + image.blue[target] = pixel[0]; + image.green[target] = pixel[1]; + image.red[target] = pixel[2]; + if depth == 32 { + image.alpha[target] = pixel[3]; + } + } + Ok(()) +} + fn tga_channels( grayscale: bool, color_mapped: bool, diff --git a/crates/libremetaverse/tests/performance_regressions.rs b/crates/libremetaverse/tests/performance_regressions.rs new file mode 100644 index 0000000..1fc0d0c --- /dev/null +++ b/crates/libremetaverse/tests/performance_regressions.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use libremetaverse::imaging::Targa; +use libremetaverse::{GridClient, Inventory, InventoryItem}; +use libremetaverse_types::{AssetType, UUID}; + +#[test] +fn root_inventory_updates_preserve_link_index() { + let client = Arc::new(GridClient::new().expect("offline client")); + let inventory = Inventory::new_with_grid_client_uuid(client, UUID::zero()).expect("store"); + let item_id = UUID::new_with_u_int64(1).expect("item UUID"); + let first_target = UUID::new_with_u_int64(2).expect("first target"); + let second_target = UUID::new_with_u_int64(3).expect("second target"); + let mut item = InventoryItem::new_with_uuid(item_id).expect("item"); + item.set_asset_type(AssetType::Link); + item.set_asset_uuid(first_target); + inventory.update_node_for(&item).expect("insert root link"); + assert_eq!(inventory.find_all_links(first_target).unwrap().len(), 1); + + item.set_asset_uuid(second_target); + inventory + .update_node_for(&item) + .expect("retarget root link"); + assert!(inventory.find_all_links(first_target).unwrap().is_empty()); + assert_eq!(inventory.find_all_links(second_target).unwrap().len(), 1); + + item.set_asset_type(AssetType::Texture); + inventory.update_node_for(&item).expect("replace link"); + assert!(inventory.find_all_links(second_target).unwrap().is_empty()); + assert_eq!(inventory.count(), 1); +} + +#[test] +fn borrowed_tga_fast_path_preserves_fixture_pixels() { + let bytes = include_bytes!("../../../benchmarks/fixtures/image.tga"); + let image = Targa::decode_to_managed_image_with_bytes(bytes).expect("decode TGA fixture"); + assert_eq!((image.width, image.height), (128, 128)); + assert_eq!(image.red.len(), 128 * 128); + assert_eq!( + (&image.red[..4], &image.green[..4], &image.blue[..4]), + (&[0, 1, 2, 3][..], &[0, 3, 6, 9][..], &[0, 7, 14, 21][..]) + ); + assert!(image.alpha.iter().all(|value| *value == u8::MAX)); +} diff --git a/release/DEPENDENCY-LICENSES.json b/release/DEPENDENCY-LICENSES.json index 8f726a1..a39b42c 100644 --- a/release/DEPENDENCY-LICENSES.json +++ b/release/DEPENDENCY-LICENSES.json @@ -1,6 +1,6 @@ { "schema": 1, - "cargo_lock_sha256": "c772cdfb50bc3a9160b135355f0d7a89fd7055dc5df0c876e7245deabd2c4812", + "cargo_lock_sha256": "ff5b851139156d43915f08494c5723638d3b48abb9d7ab5de6eea9e9f5b5554b", "packages": [ { "name": "adler2", diff --git a/release/DISTRIBUTION-MANIFEST.json b/release/DISTRIBUTION-MANIFEST.json index 2736666..de6b1c9 100644 --- a/release/DISTRIBUTION-MANIFEST.json +++ b/release/DISTRIBUTION-MANIFEST.json @@ -1,7 +1,7 @@ { "schema": 1, - "cargo_lock_sha256": "c772cdfb50bc3a9160b135355f0d7a89fd7055dc5df0c876e7245deabd2c4812", - "provenance_policy_sha256": "188ac430e8c804a8b40153c3dca05affd869a6e05abab80fcccbc2d46ff0ee2e", + "cargo_lock_sha256": "ff5b851139156d43915f08494c5723638d3b48abb9d7ab5de6eea9e9f5b5554b", + "provenance_policy_sha256": "045859dcf317efc19e6e47fc73dfc15726f7a787556a3960c4732aa5f3afde7c", "upstream_repository": "https://github.com/cinderblocks/libremetaverse", "upstream_commit": "2aa70bb68513b39795da5d13c88f31b86e85a3ba", "source_files": [ @@ -40,6 +40,11 @@ "bytes": 1607, "sha256": "f7a23f0c89bb9476fffc3036b257ab8022a7af8b3b3ecd586d98cb7d76a23db6" }, + { + "path": ".gitea/workflows/performance.yml", + "bytes": 1855, + "sha256": "bafd171f15b6e83e52f07f85836f5807a822d2016f4c75901b242623a092a6fb" + }, { "path": ".gitea/workflows/release-matrix.yml", "bytes": 4196, @@ -72,13 +77,13 @@ }, { "path": "Cargo.lock", - "bytes": 99105, - "sha256": "c772cdfb50bc3a9160b135355f0d7a89fd7055dc5df0c876e7245deabd2c4812" + "bytes": 99427, + "sha256": "ff5b851139156d43915f08494c5723638d3b48abb9d7ab5de6eea9e9f5b5554b" }, { "path": "Cargo.toml", - "bytes": 1455, - "sha256": "b40542879b5e6e2421053dfcf46cee9611a8edfd4eafb39c3346986744ddcf56" + "bytes": 1480, + "sha256": "fa9a7e393968e0080672245dc7dc05b046c51307cdf734cc176f183e1d2e7404" }, { "path": "LICENSE.md", @@ -160,6 +165,76 @@ "bytes": 47154083, "sha256": "618974cf09bfd1e051ae7a3a35e01ca3b1a8b64b919bcbf153860290d137ce94" }, + { + "path": "benchmarks/README.md", + "bytes": 4567, + "sha256": "f5fa11f3c977d59b43082dd86718c5c4b986f78b401b71993186f3bd3904511a" + }, + { + "path": "benchmarks/REPORT.md", + "bytes": 2584, + "sha256": "26224aed892c7b63c2c97c3a82cfaaa4b758ec1e18013addd594c0093e162ef3" + }, + { + "path": "benchmarks/csharp-reference/MetaCrate.ReferenceBenchmarks.csproj", + "bytes": 790, + "sha256": "c6ffc4c9ede90f4160cd2c802f368ce7fdc08c43041597b7910ccc7502df8a88" + }, + { + "path": "benchmarks/csharp-reference/Program.cs", + "bytes": 15208, + "sha256": "8b6e0c3abf1abf97fde76ba356f00d2e7c21fda176d3a9f7da52d598b48c34eb" + }, + { + "path": "benchmarks/fixtures/agent_pause.packet", + "bytes": 46, + "sha256": "ec6febce906a5c7b2dcb08e089a2b1dcf446e42655f90e61de4f37356b0026e5" + }, + { + "path": "benchmarks/fixtures/image.tga", + "bytes": 65568, + "sha256": "8864774adaf571530ff4287288bb6f6ca1b7e88dc1f4bec04c6f79e13a159582" + }, + { + "path": "benchmarks/fixtures/manifest.json", + "bytes": 440, + "sha256": "1b8730a08b7740b91de7d6e46595e04ee5f09613ef478313203959e7a29b3145" + }, + { + "path": "benchmarks/fixtures/mesh_asset.bin", + "bytes": 26921, + "sha256": "4f665cf5396999e696fa49053e930b8b9ad9eda44ae85df22a6f0dbbd2fc6684" + }, + { + "path": "benchmarks/fixtures/notecard.txt", + "bytes": 208, + "sha256": "b796c40a34ff00dbabc5d3f94e518154528bb87cf7f3a8262b1a5ab6e5ad7e1b" + }, + { + "path": "benchmarks/fixtures/workloads.json", + "bytes": 636, + "sha256": "9124763e4822e57850783c06b37cd569355f73ac2397bca85f6dd89e0470267d" + }, + { + "path": "benchmarks/release-criteria.json", + "bytes": 986, + "sha256": "c3516ed91258bb900699d432ccdc9e3f69e120c92f580cf46fcca3eea0cd4f1f" + }, + { + "path": "benchmarks/results/comparison.json", + "bytes": 7252, + "sha256": "fdcc62d12a5d73a9ce75374f808226cff36f452332388b575a4d95c45a7d8cb9" + }, + { + "path": "benchmarks/results/csharp-linux-x86_64.json", + "bytes": 37903, + "sha256": "4bae693cdc712dc2ce8039121d9511042c098fa07dfece8f50e57cc91a957aaa" + }, + { + "path": "benchmarks/results/rust-linux-x86_64.json", + "bytes": 37699, + "sha256": "9ffc240ca35d397ee848a6b313efcfefcf186ea01f61911aa3329cb2dcefd202" + }, { "path": "ci/concurrency-thresholds.json", "bytes": 169, @@ -167,8 +242,8 @@ }, { "path": "ci/dependency-policy.json", - "bytes": 10329, - "sha256": "6d2ddc20ba92dc51645cdddd39817ad84d1ad1a9d02c5532e499ff91d729b2dc" + "bytes": 10365, + "sha256": "4f545b31c6ea2d97eadc5a60423d13839cc9a71447a603806a8690de4c56f226" }, { "path": "ci/evidence/api-audit.json", @@ -187,8 +262,8 @@ }, { "path": "ci/provenance-policy.json", - "bytes": 9684, - "sha256": "188ac430e8c804a8b40153c3dca05affd869a6e05abab80fcccbc2d46ff0ee2e" + "bytes": 11311, + "sha256": "045859dcf317efc19e6e47fc73dfc15726f7a787556a3960c4732aa5f3afde7c" }, { "path": "ci/release-matrix.json", @@ -987,8 +1062,8 @@ }, { "path": "crates/libremetaverse/src/inventory.rs", - "bytes": 103729, - "sha256": "7b4ac24b66c8371cedefcbd7dbeb141d7a7927e731e6823c8cdfef84617d1d80" + "bytes": 107222, + "sha256": "d4fe66be383f8c88cbe523315c6edb02044e4585e328eb432f8c422a095d3035" }, { "path": "crates/libremetaverse/src/inventory_ais.rs", @@ -1102,8 +1177,8 @@ }, { "path": "crates/libremetaverse/src/targa.rs", - "bytes": 45068, - "sha256": "c74ce84eb97d0e3e211e3a2039b2538399c648dcb4809d0f0e8c03d00281761d" + "bytes": 46419, + "sha256": "20c66897bca7787a3bfb363294f9b00aa766e29d6bd5779d3170a874cc50660b" }, { "path": "crates/libremetaverse/src/terrain_codec.rs", @@ -1175,6 +1250,11 @@ "bytes": 6544, "sha256": "07233faf32417f605cf29c275a4c70c12e8b1960434f247f16bc02148dace6db" }, + { + "path": "crates/libremetaverse/tests/performance_regressions.rs", + "bytes": 1944, + "sha256": "a21ad12e32b5fdb9e6cb29c3cba1326c548a22b2a0222978ee4d4e0321a55bbb" + }, { "path": "crates/libremetaverse/tests/udp_transport.rs", "bytes": 17729, @@ -2182,8 +2262,8 @@ }, { "path": "tools/ci-matrix/src/provenance.rs", - "bytes": 44192, - "sha256": "59146a9426f4d6f515084cbca7a91b4c98589066620e6f3ef48dc30aa8b90fb4" + "bytes": 44234, + "sha256": "d9421c261ba99e68d1eba2c4606cbf63f8d9dc61b87d36f5a696afec1efeadb1" }, { "path": "tools/codegen/Cargo.toml", @@ -2250,6 +2330,16 @@ "bytes": 2085, "sha256": "cece0ccb847dfc015734297b6988752d140e15a44f5347236266d2af699f7052" }, + { + "path": "tools/performance/Cargo.toml", + "bytes": 901, + "sha256": "6621825fb0de082d035c3ac9855009a33afdf5d0d2189cd3d72e0a3e2c1babb4" + }, + { + "path": "tools/performance/src/main.rs", + "bytes": 35323, + "sha256": "412c56a136c7e0fe2e51c1ba323e6377e2bec69aa11ae602436abc87348f7e4c" + }, { "path": "tools/test_audit_red_suite.py", "bytes": 1212, @@ -2275,7 +2365,7 @@ { "path": "release/DEPENDENCY-LICENSES.json", "bytes": 215927, - "sha256": "f2f8c01d5994e8b1d5568f4f678456de7f0a77aa54e29fa8e1c534f4060bd073" + "sha256": "1644915765f829b6476cfa65f2105f8dead39ca0e65640c87bfb497e5d873d0e" }, { "path": "release/THIRD-PARTY-NOTICES.md", @@ -2377,6 +2467,54 @@ "license": "BSD-3-Clause", "distribution": "source-only" }, + { + "path": "benchmarks/fixtures/agent_pause.packet", + "sha256": "ec6febce906a5c7b2dcb08e089a2b1dcf446e42655f90e61de4f37356b0026e5", + "kind": "project-benchmark-fixture", + "origin": "MetaCrate reproducible AgentPause packet fixture", + "license": "BSD-3-Clause", + "distribution": "source-only" + }, + { + "path": "benchmarks/fixtures/image.tga", + "sha256": "8864774adaf571530ff4287288bb6f6ca1b7e88dc1f4bec04c6f79e13a159582", + "kind": "project-benchmark-fixture", + "origin": "MetaCrate reproducible TGA image fixture", + "license": "BSD-3-Clause", + "distribution": "source-only" + }, + { + "path": "benchmarks/fixtures/manifest.json", + "sha256": "1b8730a08b7740b91de7d6e46595e04ee5f09613ef478313203959e7a29b3145", + "kind": "project-benchmark-manifest", + "origin": "MetaCrate reproducible benchmark fixture hashes", + "license": "BSD-3-Clause", + "distribution": "source-only" + }, + { + "path": "benchmarks/fixtures/mesh_asset.bin", + "sha256": "4f665cf5396999e696fa49053e930b8b9ad9eda44ae85df22a6f0dbbd2fc6684", + "kind": "project-benchmark-fixture", + "origin": "MetaCrate reproducible mesh asset fixture", + "license": "BSD-3-Clause", + "distribution": "source-only" + }, + { + "path": "benchmarks/fixtures/notecard.txt", + "sha256": "b796c40a34ff00dbabc5d3f94e518154528bb87cf7f3a8262b1a5ab6e5ad7e1b", + "kind": "project-benchmark-fixture", + "origin": "MetaCrate reproducible notecard asset fixture", + "license": "BSD-3-Clause", + "distribution": "source-only" + }, + { + "path": "benchmarks/fixtures/workloads.json", + "sha256": "9124763e4822e57850783c06b37cd569355f73ac2397bca85f6dd89e0470267d", + "kind": "project-benchmark-config", + "origin": "MetaCrate reproducible workload dimensions", + "license": "BSD-3-Clause", + "distribution": "source-only" + }, { "path": "fuzz/corpus/binary_llsd/malformed.hex", "sha256": "7e0f49303f07a638a8d2fba637ac7fab1e63386c9b4e6b46730425e87d15fcbe", diff --git a/tools/ci-matrix/src/provenance.rs b/tools/ci-matrix/src/provenance.rs index c6ac01c..a878718 100644 --- a/tools/ci-matrix/src/provenance.rs +++ b/tools/ci-matrix/src/provenance.rs @@ -22,7 +22,12 @@ const GENERATED_RELEASE_PATHS: [&str; 4] = [ NATIVE_NOTICE_PATH, DISTRIBUTION_MANIFEST_PATH, ]; -const MATERIAL_ROOTS: [&str; 3] = ["codegen/inputs", "tests/fixtures", "fuzz/corpus"]; +const MATERIAL_ROOTS: [&str; 4] = [ + "codegen/inputs", + "tests/fixtures", + "fuzz/corpus", + "benchmarks/fixtures", +]; const BUNDLED_EXTENSIONS: [&str; 18] = [ "a", "animatn", "bmp", "bodypart", "clothing", "dll", "dylib", "gesture", "gif", "jpeg", "jpg", "llm", "ogg", "png", "so", "tga", "wav", "webp", diff --git a/tools/performance/Cargo.toml b/tools/performance/Cargo.toml new file mode 100644 index 0000000..1cbb65e --- /dev/null +++ b/tools/performance/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "metacrate-performance" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Reproducible cross-runtime performance evidence for MetaCrate" +publish = false + +[dependencies] +libremetaverse = { path = "../../crates/libremetaverse" } +libremetaverse-imaging = { path = "../../crates/libremetaverse-imaging" } +libremetaverse-rendering-mesh-foundry = { path = "../../crates/libremetaverse-rendering-mesh-foundry" } +libremetaverse-rendering-simple = { path = "../../crates/libremetaverse-rendering-simple" } +libremetaverse-structured-data = { path = "../../crates/libremetaverse-structured-data" } +libremetaverse-types = { path = "../../crates/libremetaverse-types" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" +stats_alloc = "0.1.10" + +[lints] +workspace = true diff --git a/tools/performance/src/main.rs b/tools/performance/src/main.rs new file mode 100644 index 0000000..da00504 --- /dev/null +++ b/tools/performance/src/main.rs @@ -0,0 +1,1026 @@ +#![allow(clippy::cast_precision_loss)] + +use std::alloc::System; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fs; +use std::hint::black_box; +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +use libremetaverse::assets::AssetNotecard; +use libremetaverse::imaging::Targa; +use libremetaverse::import_export::{ModelFace, ModelPrim}; +use libremetaverse::messages::linden::RemoteParcelRequestReply; +use libremetaverse::packets::AgentPausePacket; +use libremetaverse::rendering::{DetailLevel, Vertex}; +use libremetaverse::{GridClient, Inventory, InventoryItem, Primitive, PrimitiveConstructionData}; +use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels}; +use libremetaverse_rendering_mesh_foundry::MeshFoundry; +use libremetaverse_rendering_simple::SimpleRenderer; +use libremetaverse_structured_data::{OSD, OSDParser}; +use libremetaverse_types::{ + HoleType, Matrix4, PCode, PathCurve, ProfileCurve, Quaternion, UUID, Vector2, Vector3, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use stats_alloc::{INSTRUMENTED_SYSTEM, Region, Stats, StatsAlloc}; + +#[global_allocator] +static ALLOCATOR: &StatsAlloc = &INSTRUMENTED_SYSTEM; + +const RUNS: usize = 7; +const TOOL_SCHEMA: u32 = 1; +const EXPECTED_REFERENCE: &str = "2aa70bb68513b39795da5d13c88f31b86e85a3ba"; + +type AppResult = Result; + +#[derive(Clone, Deserialize)] +struct Fixture { + schema: u32, + reference_commit: String, + uuid: String, + uuid_math_iterations: usize, + llsd_iterations: usize, + packet_iterations: usize, + asset_iterations: usize, + image_iterations: usize, + mesh_iterations: usize, + inventory_iterations: usize, + object_iterations: usize, + rendering_iterations: usize, + client_iterations: usize, + warmup_divisor: usize, + llsd_array_length: usize, + packet_blocks: usize, + image_width: i32, + image_height: i32, + mesh_side: usize, + inventory_items: usize, + object_count: usize, + client_batch: usize, +} + +#[derive(Serialize, Deserialize)] +struct Environment { + os: String, + arch: String, + logical_cpus: usize, + toolchain: String, + profile: String, +} + +#[derive(Clone, Serialize, Deserialize)] +struct Metrics { + iterations: usize, + elapsed_ns: u128, + latency_ns: f64, + throughput_per_second: f64, + allocations: Option, + bytes_allocated: usize, + retained_bytes: i128, +} + +#[derive(Serialize, Deserialize)] +struct BenchmarkResult { + name: String, + category: String, + unit: String, + fixture: String, + cold: Metrics, + warm_samples: Vec, + warm_median: Metrics, +} + +#[derive(Serialize, Deserialize)] +struct Report { + schema: u32, + runtime: String, + reference_commit: String, + fixture_hashes: BTreeMap, + environment: Environment, + results: Vec, +} + +struct Context { + fixture: Fixture, + uuid: UUID, + llsd: OSD, + packet: Vec, + notecard: Vec, + image: Vec, + mesh: Vec, +} + +#[derive(Clone, Copy)] +struct Workload { + name: &'static str, + category: &'static str, + fixture: &'static str, + iterations: fn(&Fixture) -> usize, + run: fn(&Context, usize) -> AppResult, +} + +fn main() { + if let Err(error) = run_cli() { + eprintln!("performance audit failed: {error}"); + std::process::exit(1); + } +} + +fn run_cli() -> AppResult<()> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("fixtures") => { + let root = + value(&args, "--fixture-root").unwrap_or_else(|| "benchmarks/fixtures".into()); + generate_fixtures(Path::new(&root)) + } + Some("run") => { + let root = + value(&args, "--fixture-root").unwrap_or_else(|| "benchmarks/fixtures".into()); + let output = value(&args, "--output") + .unwrap_or_else(|| "benchmarks/results/rust-linux-x86_64.json".into()); + let report = run_benchmarks(Path::new(&root))?; + write_json(Path::new(&output), &report) + } + Some("compare") => { + let rust = required(&args, "--rust")?; + let reference = required(&args, "--reference")?; + let output = required(&args, "--output")?; + compare_reports(Path::new(&rust), Path::new(&reference), Path::new(&output)) + } + Some("audit") => { + let root = + value(&args, "--fixture-root").unwrap_or_else(|| "benchmarks/fixtures".into()); + let rust = required(&args, "--rust")?; + let reference = required(&args, "--reference")?; + let comparison = required(&args, "--comparison")?; + audit( + Path::new(&root), + Path::new(&rust), + Path::new(&reference), + Path::new(&comparison), + ) + } + _ => Err("usage: metacrate-performance [options]".into()), + } +} + +fn value(args: &[String], key: &str) -> Option { + args.windows(2) + .find(|pair| pair[0] == key) + .map(|pair| pair[1].clone()) +} + +fn required(args: &[String], key: &str) -> AppResult { + value(args, key).ok_or_else(|| format!("missing {key}")) +} + +fn fixture(path: &Path) -> AppResult { + let bytes = fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))?; + let fixture: Fixture = serde_json::from_slice(&bytes) + .map_err(|error| format!("parse {}: {error}", path.display()))?; + if fixture.schema != 1 || fixture.reference_commit != EXPECTED_REFERENCE { + return Err("fixture schema or pinned reference commit does not match policy".into()); + } + if fixture.warmup_divisor == 0 { + return Err("warmup_divisor must be nonzero".into()); + } + Ok(fixture) +} + +fn generate_fixtures(root: &Path) -> AppResult<()> { + fs::create_dir_all(root).map_err(|error| error.to_string())?; + let config = fixture(&root.join("workloads.json"))?; + + let mut image = ManagedImage::new( + config.image_width, + config.image_height, + ManagedImageImageChannels( + ManagedImageImageChannels::COLOR.0 | ManagedImageImageChannels::ALPHA.0, + ), + ) + .map_err(debug)?; + for index in 0..image.red.len() { + image.red[index] = u8::try_from(index & 255).map_err(debug)?; + image.green[index] = u8::try_from((index * 3) & 255).map_err(debug)?; + image.blue[index] = u8::try_from((index * 7) & 255).map_err(debug)?; + image.alpha[index] = 255; + } + fs::write(root.join("image.tga"), Targa::encode(image).map_err(debug)?) + .map_err(|error| error.to_string())?; + + let mut model = ModelPrim::new().map_err(debug)?; + let mut face = ModelFace::new().map_err(debug)?; + let side = config.mesh_side; + for y in 0..side { + for x in 0..side { + let u = x as f32 / (side - 1) as f32; + let v = y as f32 / (side - 1) as f32; + face.vertices.push(Vertex { + position: Vector3 { + x: u - 0.5, + y: v - 0.5, + z: (u * std::f32::consts::TAU).sin() * 0.05, + }, + normal: Vector3::unit_z(), + tex_coord: Vector2 { x: u, y: v }, + }); + } + } + for y in 0..side - 1 { + for x in 0..side - 1 { + let a = u32::try_from(y * side + x).map_err(debug)?; + let c = a + u32::try_from(side).map_err(debug)?; + face.indices + .extend_from_slice(&[a, a + 1, c, a + 1, c + 1, c]); + } + } + model.faces.push(face); + model.create_asset(UUID::zero()).map_err(debug)?; + fs::write(root.join("mesh_asset.bin"), model.asset).map_err(|error| error.to_string())?; + + let mut packet = AgentPausePacket::new_with_constructor().map_err(debug)?; + packet.agent_data.agent_id = UUID::new_with_string(config.uuid.clone()).map_err(debug)?; + packet.agent_data.session_id = UUID::new_with_u_int64(0x1234).map_err(debug)?; + packet.agent_data.serial_num = u32::try_from(config.packet_blocks).map_err(debug)?; + fs::write( + root.join("agent_pause.packet"), + packet.to_bytes_with_method().map_err(debug)?, + ) + .map_err(|error| error.to_string())?; + write_manifest(root) +} + +fn write_manifest(root: &Path) -> AppResult<()> { + let names = [ + "workloads.json", + "notecard.txt", + "image.tga", + "mesh_asset.bin", + "agent_pause.packet", + ]; + let mut hashes = BTreeMap::new(); + for name in names { + hashes.insert(name.to_owned(), hash_file(&root.join(name))?); + } + write_json(&root.join("manifest.json"), &hashes) +} + +fn context(root: &Path) -> AppResult { + let fixture = fixture(&root.join("workloads.json"))?; + let uuid = UUID::new_with_string(fixture.uuid.clone()).map_err(debug)?; + let llsd = OSD::Map(HashMap::from([ + ("agent_id".into(), OSD::UUID(uuid)), + ("name".into(), OSD::String("MetaCrate benchmark".into())), + ("active".into(), OSD::Boolean(true)), + ("score".into(), OSD::Real(42.25)), + ( + "items".into(), + OSD::Array( + (0..fixture.llsd_array_length) + .map(|value| OSD::Integer(i32::try_from(value).unwrap_or(i32::MAX))) + .collect(), + ), + ), + ])); + Ok(Context { + fixture, + uuid, + llsd, + packet: read(root, "agent_pause.packet")?, + notecard: read(root, "notecard.txt")?, + image: read(root, "image.tga")?, + mesh: read(root, "mesh_asset.bin")?, + }) +} + +fn read(root: &Path, name: &str) -> AppResult> { + fs::read(root.join(name)).map_err(|error| format!("read {name}: {error}")) +} + +fn run_benchmarks(root: &Path) -> AppResult { + verify_manifest(root)?; + let context = context(root)?; + let mut results = Vec::new(); + for workload in workloads() { + let iterations = (workload.iterations)(&context.fixture); + let cold = measure(&context, workload.run, 1)?; + let warmup = (iterations / context.fixture.warmup_divisor).max(1); + black_box((workload.run)(&context, warmup)?); + let mut warm_samples = Vec::with_capacity(RUNS); + for _ in 0..RUNS { + warm_samples.push(measure(&context, workload.run, iterations)?); + } + let mut ordered = warm_samples.clone(); + ordered.sort_by_key(|sample| sample.elapsed_ns); + let warm_median = ordered[RUNS / 2].clone(); + results.push(BenchmarkResult { + name: workload.name.into(), + category: workload.category.into(), + unit: "operation".into(), + fixture: workload.fixture.into(), + cold, + warm_samples, + warm_median, + }); + } + Ok(Report { + schema: TOOL_SCHEMA, + runtime: "rust-native".into(), + reference_commit: context.fixture.reference_commit.clone(), + fixture_hashes: manifest(root)?, + environment: Environment { + os: std::env::consts::OS.into(), + arch: std::env::consts::ARCH.into(), + logical_cpus: std::thread::available_parallelism().map_or(1, usize::from), + toolchain: command_version("rustc", &["--version"]), + profile: "benchmark (opt-level=1, debug=0, incremental=false)".into(), + }, + results, + }) +} + +fn measure( + context: &Context, + function: fn(&Context, usize) -> AppResult, + iterations: usize, +) -> AppResult { + let region = Region::new(ALLOCATOR); + let started = Instant::now(); + black_box(function(context, iterations)?); + let elapsed = started.elapsed(); + let stats = region.change(); + Ok(metrics(iterations, elapsed.as_nanos(), stats)) +} + +fn metrics(iterations: usize, elapsed_ns: u128, stats: Stats) -> Metrics { + let seconds = elapsed_ns as f64 / 1_000_000_000.0; + let retained = stats.bytes_allocated as i128 - stats.bytes_deallocated as i128 + + stats.bytes_reallocated as i128; + Metrics { + iterations, + elapsed_ns, + latency_ns: elapsed_ns as f64 / iterations as f64, + throughput_per_second: iterations as f64 / seconds, + allocations: Some(stats.allocations + stats.reallocations), + bytes_allocated: stats.bytes_allocated, + retained_bytes: retained, + } +} + +fn workloads() -> [Workload; 14] { + [ + Workload { + name: "uuid_math", + category: "uuid/math", + fixture: "workloads.json", + iterations: |f| f.uuid_math_iterations, + run: uuid_math, + }, + Workload { + name: "llsd_xml", + category: "LLSD", + fixture: "workloads.json", + iterations: |f| f.llsd_iterations, + run: llsd_xml, + }, + Workload { + name: "llsd_json", + category: "LLSD", + fixture: "workloads.json", + iterations: |f| f.llsd_iterations, + run: llsd_json, + }, + Workload { + name: "llsd_binary", + category: "LLSD", + fixture: "workloads.json", + iterations: |f| f.llsd_iterations, + run: llsd_binary, + }, + Workload { + name: "llsd_notation", + category: "LLSD", + fixture: "workloads.json", + iterations: |f| f.llsd_iterations, + run: llsd_notation, + }, + Workload { + name: "llsd_protobuf", + category: "LLSD", + fixture: "workloads.json", + iterations: |f| f.llsd_iterations, + run: llsd_protobuf, + }, + Workload { + name: "packet_codec", + category: "packet codec", + fixture: "agent_pause.packet", + iterations: |f| f.packet_iterations, + run: packet_codec, + }, + Workload { + name: "asset_decode", + category: "asset decode", + fixture: "notecard.txt", + iterations: |f| f.asset_iterations, + run: asset_decode, + }, + Workload { + name: "image_decode", + category: "image decode", + fixture: "image.tga", + iterations: |f| f.image_iterations, + run: image_decode, + }, + Workload { + name: "mesh_decode", + category: "mesh decode", + fixture: "mesh_asset.bin", + iterations: |f| f.mesh_iterations, + run: mesh_decode, + }, + Workload { + name: "inventory_update", + category: "inventory update", + fixture: "workloads.json", + iterations: |f| f.inventory_iterations, + run: inventory_update, + }, + Workload { + name: "object_update", + category: "object update", + fixture: "workloads.json", + iterations: |f| f.object_iterations, + run: object_update, + }, + Workload { + name: "rendering", + category: "rendering", + fixture: "workloads.json", + iterations: |f| f.rendering_iterations, + run: rendering, + }, + Workload { + name: "client_throughput", + category: "client throughput", + fixture: "workloads.json", + iterations: |f| f.client_iterations, + run: client_throughput, + }, + ] +} + +fn uuid_math(context: &Context, iterations: usize) -> AppResult { + let rotation = + Quaternion::create_from_eulers_with_single_single_single(0.25, 0.5, 0.75).map_err(debug)?; + let mut matrix = Matrix4::create_translation(Vector3 { + x: 10.0, + y: 20.0, + z: 30.0, + }) + .map_err(debug)?; + let mut checksum = 0_u64; + for index in 0..iterations { + let parsed = UUID::new_with_string(context.fixture.uuid.clone()).map_err(debug)?; + matrix = Matrix4::transform(matrix, rotation).map_err(debug)?; + checksum ^= + parsed.get_u_long().map_err(debug)? ^ matrix.m41.to_bits() as u64 ^ index as u64; + } + Ok(checksum) +} + +macro_rules! llsd_workload { + ($name:ident, $serialize:expr, $deserialize:expr) => { + fn $name(context: &Context, iterations: usize) -> AppResult { + let mut checksum = 0_u64; + for _ in 0..iterations { + let encoded = $serialize(&context.llsd).map_err(debug)?; + let decoded = $deserialize(encoded).map_err(debug)?; + checksum ^= u64::from(decoded.as_boolean().map_err(debug)?); + } + Ok(checksum) + } + }; +} + +llsd_workload!( + llsd_xml, + |value: &OSD| OSDParser::serialize_llsd_xml_bytes(value.clone()), + |bytes| OSDParser::deserialize_llsd_xml_with_bytes(bytes) +); +llsd_workload!( + llsd_binary, + |value: &OSD| OSDParser::serialize_llsd_binary_with_osd(value.clone()), + |bytes| OSDParser::deserialize_llsd_binary_with_bytes(bytes) +); +llsd_workload!( + llsd_protobuf, + |value: &OSD| OSDParser::serialize_llsd_protobuf(value.clone(), Some(true)), + |bytes| OSDParser::deserialize_llsd_protobuf_with_bytes(bytes) +); + +fn llsd_json(context: &Context, iterations: usize) -> AppResult { + let mut checksum = 0; + for _ in 0..iterations { + let encoded = + OSDParser::serialize_json_string(context.llsd.clone(), None).map_err(debug)?; + let decoded = OSDParser::deserialize_json_with_string(encoded.clone()).map_err(debug)?; + checksum ^= encoded.len() as u64 ^ u64::from(decoded.as_boolean().map_err(debug)?); + } + Ok(checksum) +} + +fn llsd_notation(context: &Context, iterations: usize) -> AppResult { + let mut checksum = 0; + for _ in 0..iterations { + let encoded = OSDParser::serialize_llsd_notation(context.llsd.clone()).map_err(debug)?; + let decoded = + OSDParser::deserialize_llsd_notation_with_string(encoded.clone()).map_err(debug)?; + checksum ^= encoded.len() as u64 ^ u64::from(decoded.as_boolean().map_err(debug)?); + } + Ok(checksum) +} + +fn packet_codec(context: &Context, iterations: usize) -> AppResult { + let mut checksum = 0; + for _ in 0..iterations { + let mut start = 0; + let packet = AgentPausePacket::new_with_bytes_int32(context.packet.clone(), &mut start) + .map_err(debug)?; + let encoded = packet.to_bytes_with_method().map_err(debug)?; + checksum ^= encoded.len() as u64 ^ u64::from(packet.agent_data.serial_num); + } + Ok(checksum) +} + +fn asset_decode(context: &Context, iterations: usize) -> AppResult { + let mut checksum = 0; + for _ in 0..iterations { + let asset = AssetNotecard::new_with_uuid_bytes(context.uuid, context.notecard.clone()) + .map_err(debug)?; + if !asset.decode().map_err(debug)? { + return Err("notecard decode returned false".into()); + } + checksum ^= asset.body_text.len() as u64; + } + Ok(checksum) +} + +fn image_decode(context: &Context, iterations: usize) -> AppResult { + let mut checksum = 0; + for _ in 0..iterations { + let image = Targa::decode_to_managed_image_with_bytes(&context.image).map_err(debug)?; + checksum ^= image.red.len() as u64; + } + Ok(checksum) +} + +fn mesh_decode(context: &Context, iterations: usize) -> AppResult { + let renderer = MeshFoundry::new().map_err(debug)?; + let mut checksum = 0; + for _ in 0..iterations { + let mesh = renderer + .generate_faceted_mesh_mesh_with_primitive_bytes_detail_level( + Primitive::new_with_constructor().map_err(debug)?, + context.mesh.clone(), + DetailLevel::Highest, + ) + .map_err(debug)? + .ok_or_else(|| "mesh fixture returned no geometry".to_owned())?; + checksum ^= mesh + .faces + .iter() + .map(|face| face.vertices.len() + face.indices.len()) + .sum::() as u64; + } + Ok(checksum) +} + +fn inventory_update(context: &Context, iterations: usize) -> AppResult { + let store = Inventory::new_with_grid_client_uuid( + Arc::new(GridClient::new().map_err(debug)?), + context.uuid, + ) + .map_err(debug)?; + for index in 0..iterations { + let id = UUID::new_with_u_int64((index % context.fixture.inventory_items) as u64 + 1) + .map_err(debug)?; + let mut item = InventoryItem::new_with_uuid(id).map_err(debug)?; + item.base.set_name(format!("item-{index}")); + store.update_node_for(&item).map_err(debug)?; + } + Ok(u64::try_from(store.count()).map_err(debug)?) +} + +fn object_update(context: &Context, iterations: usize) -> AppResult { + let mut objects = (0..context.fixture.object_count) + .map(|index| { + let mut primitive = Primitive::new_with_constructor().expect("bounded primitive"); + primitive.local_id = u32::try_from(index).unwrap_or(u32::MAX); + primitive + }) + .collect::>(); + let mut checksum = 0; + let object_count = objects.len(); + for index in 0..iterations { + let object = &mut objects[index % object_count]; + object.position = Vector3 { + x: index as f32, + y: (index & 255) as f32, + z: 21.0, + }; + object.rotation = Quaternion::create_from_axis_angle_with_vector3_single( + Vector3::unit_z(), + (index & 255) as f32 / 255.0, + ) + .map_err(debug)?; + checksum ^= u64::from(object.local_id) ^ u64::from(object.position.x.to_bits()); + } + Ok(checksum) +} + +fn rendering(_context: &Context, iterations: usize) -> AppResult { + let renderer = SimpleRenderer::new().map_err(debug)?; + let mut checksum = 0; + for _ in 0..iterations { + let mesh = renderer + .generate_faceted_mesh(box_primitive()?, DetailLevel::High) + .map_err(debug)?; + checksum ^= mesh + .faces + .iter() + .map(|face| face.indices.len()) + .sum::() as u64; + } + Ok(checksum) +} + +fn box_primitive() -> AppResult { + let mut primitive = Primitive::new_with_constructor().map_err(debug)?; + let mut data = PrimitiveConstructionData::new_with_constructor().map_err(debug)?; + data.p_code = PCode::Prim; + data.path_curve = PathCurve::Line; + data.set_profile_curve_with_property(ProfileCurve::Square); + data.path_scale_x = 1.0; + data.path_scale_y = 1.0; + data.path_begin = 0.0; + data.path_end = 1.0; + data.profile_begin = 0.0; + data.profile_end = 1.0; + data.profile_hollow = 0.0; + data.set_profile_hole(HoleType::Same); + data.path_revolutions = 1.0; + primitive.prim_data = data; + Ok(primitive) +} + +fn client_throughput(context: &Context, iterations: usize) -> AppResult { + let _client = GridClient::new().map_err(debug)?; + let mut checksum = 0; + for index in 0..iterations { + for _ in 0..context.fixture.client_batch { + let mut source = RemoteParcelRequestReply::new().map_err(debug)?; + source.parcel_id = context.uuid; + let map = source.serialize().map_err(debug)?; + let mut target = RemoteParcelRequestReply::new().map_err(debug)?; + target.deserialize(map).map_err(debug)?; + checksum ^= target.parcel_id.get_u_long().map_err(debug)? ^ index as u64; + } + } + Ok(checksum) +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct ComparisonRow { + name: String, + rust_latency_ns: f64, + reference_latency_ns: f64, + latency_ratio: f64, + rust_bytes_per_operation: f64, + reference_bytes_per_operation: f64, + allocation_ratio: f64, + accepted: bool, + criterion: String, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +struct Comparison { + schema: u32, + reference_commit: String, + fixture_hashes: BTreeMap, + criteria: Criteria, + results: Vec, + accepted: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +struct Criteria { + maximum_latency_ratio: f64, + maximum_allocation_ratio: f64, + material_latency_regression_ratio: f64, + minimum_reference_latency_ns: f64, + reviewed_exceptions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +struct ReviewedException { + workload: String, + maximum_latency_ratio: f64, + maximum_allocation_ratio: f64, + rationale: String, +} + +fn criteria() -> Criteria { + serde_json::from_str(include_str!("../../../benchmarks/release-criteria.json")) + .expect("committed release criteria must be valid") +} + +fn compare_reports(rust_path: &Path, reference_path: &Path, output: &Path) -> AppResult<()> { + let rust: Report = read_json(rust_path)?; + let reference: Report = read_json(reference_path)?; + validate_pair(&rust, &reference)?; + write_json(output, &build_comparison(&rust, &reference)?) +} + +fn build_comparison(rust: &Report, reference: &Report) -> AppResult { + let policy = criteria(); + let mut rows = Vec::new(); + for rust_result in &rust.results { + let reference_result = reference + .results + .iter() + .find(|result| result.name == rust_result.name) + .ok_or_else(|| format!("reference report is missing {}", rust_result.name))?; + let latency_ratio = + rust_result.warm_median.latency_ns / reference_result.warm_median.latency_ns; + let rust_bytes = rust_result.warm_median.bytes_allocated as f64 + / rust_result.warm_median.iterations as f64; + let reference_bytes = reference_result.warm_median.bytes_allocated as f64 + / reference_result.warm_median.iterations as f64; + let allocation_ratio = if reference_bytes == 0.0 { + if rust_bytes == 0.0 { + 1.0 + } else { + f64::INFINITY + } + } else { + rust_bytes / reference_bytes + }; + let exception = policy + .reviewed_exceptions + .iter() + .find(|exception| exception.workload == rust_result.name); + let maximum_latency = exception.map_or(policy.maximum_latency_ratio, |value| { + value.maximum_latency_ratio + }); + let maximum_allocation = exception.map_or(policy.maximum_allocation_ratio, |value| { + value.maximum_allocation_ratio + }); + let latency_noise_exempt = + reference_result.warm_median.latency_ns < policy.minimum_reference_latency_ns; + let material_latency_ok = latency_noise_exempt + || latency_ratio <= policy.material_latency_regression_ratio + || exception.is_some(); + let latency_ok = + (latency_noise_exempt || latency_ratio <= maximum_latency) && material_latency_ok; + let allocation_ok = allocation_ratio <= maximum_allocation; + rows.push(ComparisonRow { + name: rust_result.name.clone(), + rust_latency_ns: rust_result.warm_median.latency_ns, + reference_latency_ns: reference_result.warm_median.latency_ns, + latency_ratio, + rust_bytes_per_operation: rust_bytes, + reference_bytes_per_operation: reference_bytes, + allocation_ratio, + accepted: latency_ok && allocation_ok, + criterion: if latency_ok && allocation_ok { + exception.map_or_else( + || "within default release thresholds".into(), + |value| format!("reviewed exception: {}", value.rationale), + ) + } else { + "material regression requires profiling evidence or an explicit reviewed exception" + .into() + }, + }); + } + let accepted = rows.iter().all(|row| row.accepted); + Ok(Comparison { + schema: TOOL_SCHEMA, + reference_commit: rust.reference_commit.clone(), + fixture_hashes: rust.fixture_hashes.clone(), + criteria: policy, + results: rows, + accepted, + }) +} + +fn audit( + root: &Path, + rust_path: &Path, + reference_path: &Path, + comparison_path: &Path, +) -> AppResult<()> { + verify_manifest(root)?; + let rust: Report = read_json(rust_path)?; + let reference: Report = read_json(reference_path)?; + let comparison: Comparison = read_json(comparison_path)?; + validate_pair(&rust, &reference)?; + let hashes = manifest(root)?; + if rust.fixture_hashes != hashes + || reference.fixture_hashes != hashes + || comparison.fixture_hashes != hashes + { + return Err("one or more reports do not match the committed fixture hashes".into()); + } + let expected: Vec<_> = workloads().iter().map(|workload| workload.name).collect(); + let expected_set = expected.iter().copied().collect::>(); + for report in [&rust, &reference] { + if report.schema != TOOL_SCHEMA || report.reference_commit != EXPECTED_REFERENCE { + return Err("invalid report schema or reference commit".into()); + } + let reported = report + .results + .iter() + .map(|result| result.name.as_str()) + .collect::>(); + if report.results.len() != expected.len() || reported != expected_set { + return Err(format!( + "{} report contains missing, duplicate, or unexpected workloads", + report.runtime + )); + } + for name in &expected { + let result = report + .results + .iter() + .find(|result| result.name == *name) + .ok_or_else(|| format!("{} report missing {name}", report.runtime))?; + if result.cold.iterations != 1 + || result.warm_samples.len() != RUNS + || result.warm_median.iterations == 0 + { + return Err(format!("{} has incomplete cold/warm evidence", result.name)); + } + } + } + let recomputed = build_comparison(&rust, &reference)?; + let checked_bytes = fs::read(comparison_path) + .map_err(|error| format!("read {}: {error}", comparison_path.display()))?; + if checked_bytes != json_bytes(&recomputed)? { + return Err("release comparison is stale; rerun the compare command".into()); + } + if !comparison.accepted { + return Err("release comparison is incomplete or contains an unaccepted regression".into()); + } + println!( + "performance audit passed: {} workloads, pinned reference {}, fixture hashes verified", + expected.len(), + EXPECTED_REFERENCE + ); + Ok(()) +} + +fn validate_pair(rust: &Report, reference: &Report) -> AppResult<()> { + if rust.runtime != "rust-native" || reference.runtime != "dotnet-reference" { + return Err( + "reports must be generated independently by rust-native and dotnet-reference".into(), + ); + } + if rust.reference_commit != EXPECTED_REFERENCE + || reference.reference_commit != EXPECTED_REFERENCE + || rust.fixture_hashes != reference.fixture_hashes + { + return Err("reports use different fixtures or reference commits".into()); + } + if rust.environment.os != reference.environment.os + || rust.environment.arch != reference.environment.arch + { + return Err("reports must be captured on the same OS and architecture".into()); + } + Ok(()) +} + +fn verify_manifest(root: &Path) -> AppResult<()> { + let expected = manifest(root)?; + for (name, hash) in expected { + let actual = hash_file(&root.join(&name))?; + if actual != hash { + return Err(format!("fixture hash mismatch for {name}")); + } + } + Ok(()) +} + +fn manifest(root: &Path) -> AppResult> { + read_json(&root.join("manifest.json")) +} + +fn hash_file(path: &Path) -> AppResult { + let bytes = fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))?; + Ok(Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +fn read_json Deserialize<'de>>(path: &Path) -> AppResult { + serde_json::from_slice( + &fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))?, + ) + .map_err(|error| format!("parse {}: {error}", path.display())) +} + +fn write_json(path: &Path, value: &T) -> AppResult<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + fs::write(path, json_bytes(value)?) + .map_err(|error| format!("write {}: {error}", path.display())) +} + +fn json_bytes(value: &T) -> AppResult> { + let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn command_version(program: &str, args: &[&str]) -> String { + std::process::Command::new(program) + .args(args) + .output() + .ok() + .filter(|output| output.status.success()) + .map_or_else( + || "unavailable".into(), + |output| String::from_utf8_lossy(&output.stdout).trim().into(), + ) +} + +fn debug(error: impl std::fmt::Debug) -> String { + format!("{error:?}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workload_set_covers_every_issue_category() { + let categories: Vec<_> = workloads() + .iter() + .map(|workload| workload.category) + .collect(); + for expected in [ + "uuid/math", + "LLSD", + "packet codec", + "asset decode", + "image decode", + "mesh decode", + "inventory update", + "object update", + "rendering", + "client throughput", + ] { + assert!(categories.contains(&expected), "missing {expected}"); + } + } + + #[test] + fn metrics_separate_latency_throughput_allocations_and_retained_memory() { + let result = metrics( + 10, + 1_000, + Stats { + allocations: 2, + deallocations: 1, + reallocations: 1, + bytes_allocated: 100, + bytes_deallocated: 40, + bytes_reallocated: 20, + }, + ); + assert_eq!(result.latency_ns, 100.0); + assert_eq!(result.throughput_per_second, 10_000_000.0); + assert_eq!(result.allocations, Some(3)); + assert_eq!(result.retained_bytes, 80); + } + + #[test] + fn release_criteria_identify_material_regressions() { + let policy = criteria(); + assert_eq!(policy.maximum_latency_ratio, 2.0); + assert!(policy.material_latency_regression_ratio < policy.maximum_latency_ratio); + assert_eq!(policy.reviewed_exceptions.len(), 3); + assert!( + policy + .reviewed_exceptions + .iter() + .any(|exception| exception.workload == "client_throughput") + ); + } +}