.agents/skills/performance-fixer/references/measuring.md
Every SkiaSharp perf fix needs two proofs, and this file is how to produce both against this repo's real harness. Neither alone is sufficient: a benchmark with no parity test can ship a rendering regression; a parity test with no benchmark can ship a "fix" that isn't faster.
Bootstrap first (managed-C# work uses pre-built natives — see repo-helpers.md):
dotnet cake --target=externals-download
The harness already exists: benchmarks/SkiaSharp.Benchmarks (BenchmarkDotNet, references the
local binding, single $(TFMCurrent) TFM, InternalsVisibleTo so benchmarks can call
SkiaApi.sk_*). Read benchmarks/README.md first — it is
the on-ramp (conventions + exact run commands). Copy
benchmarks/SkiaSharp.Benchmarks/Benchmarks/TemplateBenchmark.cs
(a working New vs Old + [MemoryDiagnoser] skeleton), rename it, and replace the two method
bodies; or model on SKColorParseBenchmark/SurfaceCanvasBenchmark/PaintFastBoundsBenchmark. The
switcher auto-discovers every [Benchmark] class — no registration needed.
# Discover (fast):
dotnet run -c Release --project benchmarks/SkiaSharp.Benchmarks -- --list flat
# Iterate (fast directional signal — wide CIs, one class, ShortRun):
dotnet run -c Release --project benchmarks/SkiaSharp.Benchmarks -- --filter '*YourBenchmark*' --job short
# Final numbers (default job, run ≥2×):
dotnet run -c Release --project benchmarks/SkiaSharp.Benchmarks -- --filter '*YourBenchmark*'
The dominant cost is BenchmarkDotNet building a host per benchmark × [Params] combo, so narrow
the --filter and trim [Params] while iterating; widen for the final run. Don't add
cross-runtime [SimpleJob(RuntimeMoniker.NetXX)] attributes — they silently skip off-Windows.
House style, from the README and existing benchmarks:
[MemoryDiagnoser] on the class — allocations matter as much as time; a big part of many
wins is going zero-alloc (Allocated column). A time win that adds allocations is usually a
net loss under GC.New vs Old in the same process. Put the current shipped behaviour as the
[Benchmark(Baseline = true)] Old method — copy the current implementation verbatim inline
if the fix will replace it (see SKColorParseBenchmark.OldTryParse), or call the current native
path via SkiaApi.sk_*. Put the proposed fast path as New. Same job/TFM ⇒ honest ratio.SurfaceCanvasBenchmark renders a
varied 1,000-item frame; PaintFastBoundsBenchmark scatters items so ~75% are cullable. Shape
the workload like the real caller (per-frame, per-point batch, startup parse) and use [Params]
to cover sizes/shapes that take different branches.[Params]/non-const fields, and sanity-check the absolute times are plausible for the work.Read the result with statistical rigor — BenchmarkDotNet noise is the default failure mode:
Old's is within noise — not a finding.Allocated/Gen0 must not rise).Vector256 ran 5.7–6.5× slower on ARM64 NEON in
#4241 — gate per-runtime or drop it).[DisassemblyDiagnoser] when the change is codegen-sensitive (inlining, sealed, bounds
checks) to confirm the JIT did what you expect.Record the exact numbers (Mean/Error/StdDev/ratio/allocations), hardware, and TFM — they go verbatim into the issue/PR.
The moment you optimize, the risk is a different answer, so the primary safety test asserts
parity with the original / native path — not just "the new code returns something sensible".
Add it to the console test project (source under tests/Tests/, run via
tests/SkiaSharp.Tests.Console). The model is PR #4241's SKMatrixManagedTests, which call the
native Skia C API directly and assert bit-equality — that class ships with the SKMatrix PR, so if it
is not yet in the tree, write a new test that calls SkiaApi.sk_* directly as the oracle. Minimal
shape:
[SkippableFact]
public void ManagedResultMatchesNative()
{
foreach (var input in EdgeAndRandomInputs()) // incl. NaN/±0/Inf/degenerate/odd counts
{
var managed = Subject.FastPath(input); // the new managed code
NativeOracle(ref input, out var native); // SkiaApi.sk_* — internal, via InternalsVisibleTo
// bit-exact for floats: compare raw bits, not ==, so -0/NaN/Inf are caught
Assert.Equal(BitConverter.SingleToInt32Bits(native.X), BitConverter.SingleToInt32Bits(managed.X));
Assert.Equal(BitConverter.SingleToInt32Bits(native.Y), BitConverter.SingleToInt32Bits(managed.Y));
}
}
Parity means more than "same return value" — a binding has observable behaviour beyond the result:
int/uint bits of each
float, not ==, so -0/NaN/Inf are caught).-0, Inf, NaN, MaxValue, Epsilon, degenerate/
non-invertible matrices, empty/#/named-color strings, odd/small/large batch counts to hit SIMD
lane loops and the scalar tail, in-place (dst == src) and overlapping spans. The bugs live in
the edges; right on the happy path but wrong on NaN is still wrong.owns: / OwnedBy effects, no changed
disposal semantics, and GC.KeepAlive on any argument whose managed wrapper must outlive the
native call (a cache/in/ref refactor can drop a keep-alive and introduce a GC race).SKCanvas, a shaping/
path change), add a rendered-bitmap parity check (draw before/after, compare pixel bytes/
hash). Bit-exact numbers are necessary but a full render is the real proof.Call the native oracle directly (SkiaApi.sk_*, reachable via InternalsVisibleTo) and assert
the managed result equals it. Confirm the equivalence test passes against the current tree
before you change the implementation (proves the oracle/harness is right).
The equivalence test is a guardrail, not a red→green — its job is to fail the instant the new code diverges. To prove it genuinely catches divergence, temporarily introduce a deliberate wrong result and confirm it goes red, then remove that.
A faster path that changes behaviour is a regression. Rebuild and run the type's existing test
class plus neighbouring consumers (e.g. for an SKMatrix change, also SKPath/SKCanvas/
SKShader tests that map through it), and keep the new benchmark in the repo so the win can be
re-measured later.
dotnet build binding/SkiaSharp/SkiaSharp.csproj
dotnet test tests/SkiaSharp.Tests.Console/SkiaSharp.Tests.Console.csproj --filter "FullyQualifiedName~<YourType>"
Families geometry-math and handles-and-collections need you to verify an algorithm or a
pointer-stability invariant in the pinned Skia C++. The runner does not check out the submodule, so
read the SHA (git submodule status externals/skia or .gitmodules) and view the file in
mono/skia at that commit, and cite it. This is read-only verification — never edit or build
native code.