dotnet-performance
Tunes Puck .NET 11 performance: allocation, LINQ, Vector<T>, SIMD, tiering, AllocationWindow, puck bench, AOT analysis (IsAotCompatible, IL2026). Simulation-value numerics: maths-usage.
SKILL.md.claude/skills/dotnet-performance/SKILL.md
GitHub.NET 11 performance
Judge a change by code, disassembly and load-independent counts (allocations,
operation counts). A wall-clock benchmark (puck bench) is evidence beside
those counts, never alone, and runs on a quiet machine. Keep runtime
improvements apart from workload-specific evidence.
Route to the relevant evidence
| Area | Read |
|---|---|
| Cross-domain mental model, pattern changes, API shortlist, folklore | references/core-guidance.md |
What the data costs: steady-state allocation, gen0 pauses and old-to-young references, LOH, layout and hot/cold splitting, struct copies, zero-fill and stackalloc, scans, bandwidth, false sharing, branches; measuring them here |
references/data-and-memory.md |
| JIT, codegen, allocation, inlining, bounds checks, ISA | references/jit-and-codegen.md |
Collections, LINQ, Frozen/Immutable, CollectionsMarshal |
references/collections-and-linq.md |
| Strings, spans, search, regex, UTF-8, encoding | references/strings-text-search.md |
| Numerics, SIMD, tensor APIs, randomness, threading | references/numerics-simd-threading.md |
| I/O, compression, networking, JSON, crypto | references/io-network-json-crypto.md |
| AOT, reflection, GC handles, diagnostics, DI, runtime | references/runtime-aot-reflection-diagnostics.md |
Read only the files relevant to the code under review. The references describe
.NET 11 in general, with magnitudes measured on 10.0 that 11 keeps unless a
section says otherwise; resolve their advice through
Directory.Build.props and the affected
project file before changing code.
Apply repository constraints
- Bare-metal Puck intentionally has no GC. Its own runtime and platform host are the target. A port gives engine allocations explicit owners and lifetimes and supplies reclamation or reuse; do not propose adding GC or hosting stock .NET to preserve implicit managed lifetimes. The GC and JIT advice below applies to the existing stock-runtime builds (owner ruling).
net11.0is the repository default.Puck.Analyzersdeliberately targetsnetstandard2.0; inspect the affected project rather than assuming every project inherits the default.InvariantGlobalizationis on. Culture-sensitive comparison and formatting guidance collapses to the invariant case.PlatformTargetis x64 andOptimizationPreferenceis Speed. Arm-specific and size-tuning material is background rather than a work item.Puck.WorldsetsTieredPGO=false. Advice that rests on dynamic PGO (guarded devirtualization, stack-allocatedforeachenumerators overIEnumerable<T>, hot and cold splitting) describes other hosts; for World code, read the disassembly of the actual build before relying on it.- Test hosts shape allocation evidence. Test projects run blocking server GC
(no background GC) so
GC.GetAllocatedBytesForCurrentThreadstays exact, and several suites setSystem.Runtime.TieredCompilationtofalseso allocation laws measure optimized code. Read the test project's ownRuntimeHostConfigurationOptionentries before comparing numbers across hosts. Counts are not comparable across different GC modes or tiering configurations. System.Numerics.Tensorsis a NuGet package outside the shared framework, and no Puck project references it directly (it arrives transitively throughMicrosoft.Extensions.AI, whichPuck.World.Embeddingsuses). Adding a direct reference is a dependency decision.- AOT and trim compatibility analysis is the default; Native AOT publication
is not.
Directory.Build.propssetsIsAotCompatible=true, enabling the AOT and trimming analyzers under warnings-as-errors, and executables opt intoPublishAotdeliberately when they ship that artifact. A project that cannot satisfy the analyzers setsIsAotCompatible=falselocally with a comment naming the blocker (src/Puck.Launcher/Puck.Launcher.csprojis one). Discover the current set with a search for that property and read each blocker comment before adding one; do not copy a count into a skill or document. - Fix a reflection-JSON analysis failure with source generation, not an
opt-out. Declare a
[JsonSerializable]partial class deriving fromJsonSerializerContext(BindingProfileJsonContextinsrc/Puck.Commandsis the in-tree example) and call theJsonTypeInfooverloads (JsonSerializer.Serialize(value, Context.Default.Type)) so the analyzer sees the supported graph. SetIsAotCompatible=falseonly when the project file can document a structural blocker, such as reflection-based document serialization that a source-generated context cannot yet replace. - World publication uses ReadyToRun.
Puck.Worldrestores thewin-x64andlinux-x64graphs and precompiles published assemblies; ordinary builds still produce IL. Compare the exact published artifact withpuck bench startup --world-artifact <path>and account for its larger assemblies. The World README owns the publish recipe; CI retains its compile-once contract.
Determinism outranks throughput
AGENTS.md rule 4 binds every value a simulation advances, compares, hashes,
snapshots or replays: the same document and input produce bit-identical state
on every run, machine and backend. Some of the references' strongest
recommendations are unsafe on that path, and they do not say so because they
are not written about this repository.
- Decide whether a value is simulation-significant before measuring. Route
simulation numerics to maths-usage: use the appropriate
fixed-point or integer operation. A stable float reduction is still forbidden
on a simulation or replayed-value path. Throughput alone does not justify a
changed simulation value; justify a deliberate correction and show repeatability
with
puck test --reproducebefore measuring it. - Presentation-only float SIMD.
Vector<T>is machine-width; a float reduction can change its order and bits across machines. Fixed-widthVector128/256/512with explicit lane-combine order and scalar tail gives a controlled reduction order when the presentation needs one. Reordering is a deliberate presentation choice, not a route for floats into simulation.TensorPrimitivesmakes no reduction-order guarantee over float/double. - Randomness and parallel completion order are not optimization knobs.
Random.Shared,RandomNumberGeneratorandParallel.*never touch a value path;maths-usagenames the reproducible primitives that replace them.
Presentation-only float (shaders, renderer and UI math, capture output) sits outside this contract and takes the references as written.
Review discipline
- A capturing local function can allocate its display class at method entry, before a cache check returns. Keep a hot cache-check method separate from its closure-bearing slow rebuild. A test of it measures warmed calls on an active path; an inactive-tick test cannot see the allocation.
- Start with a profile, benchmark, allocation trace or demonstrated hot path. A
suspected hot path is not one; ask for the evidence, and keep the clearer
form (LINQ,
foreach, an interface call) when it does not show a win. - Route measurements through the existing harnesses:
puck bench kernelsfor BenchmarkDotNet timing, allocation and disassembly;puck bench worldfor server construction and tick workloads;puck bench startupfor fresh-process readiness. The CLI reference owns options, report fields and measurement hygiene. - Profile a running World's frame thread with
dotnet-trace collect -p <pid> --profile dotnet-sampled-thread-time(frame cost) or--profile gc-verbose(allocation ticks with stacks, GC heap stats), thendotnet-trace convert --format speedscopeand aggregate the frame thread's stacks yourself; readhost.framesbeside it for produce and simulate per iteration. The sampler suspends threads at safe points, so samples pile ontoThread.PollGCunderBuffer.BulkMoveWithWriteBarrierand other poll sites: charge that time to the caller's loop, not to the copy.SpanHelpers.ZeroMemoryNativeunder a method returning a large struct with references (WorldBody.CaptureIntegrationResidue) is the same skid: confirm such a share with an untraced reading before cutting it. The debug world's scoped replay (the chess table's idle demo) starts 60 s after boot, from tick 1800 at 30 Hz: a lobby trace taken earlier holds no scope hash. Per-frame costs that matter here come in 0.1-0.5 ms pieces; a scan of thousands of objects per call (a cache miss each) and a closure a method allocates on entry because one of its lambdas captures a parameter are the usual shapes. - Attribute a trace to the workload's outer frame or thread, including its tiered and inlined stacks. Filtering
only for an inner
WorldTick.Advanceframe drops steady samples after that method inlines; a short trace can then make first-use workspace growth look like the dominant steady allocation. Separate warmup from the measured window. - Check the folklore section of the core guidance before preserving an old hand-optimization, and confirm specialization and call boundaries in optimized assembly. Prefer idiomatic code the .NET 11 JIT and libraries recognize.
- Preserve semantics, exception behavior, traversal order and sinks; performance evidence does not silently authorize changing them. A hand-written loop replaces a LINQ pipeline only with the same deferred-versus-immediate execution, the same empty-input and null exceptions and the same ordering.
- Record first-use work separately from warm tables, and distinguish allocated bytes from retained pools or peak workspace. Use idle, serial runs; sampled timings alone never establish an algorithmic or full-domain claim.
- Keep identities structural on hot read paths: formatting a principal or a parsed channel just to check a public policy or compare a channel name creates garbage even when the check has no work to do. Format only when the policy or output needs text. A placement response-mask change preserves placement ids and order, so resolved addresses carry across it; live permission checks refresh on every use.
- No law gates a timing: cost laws state deterministic counts. The managed
allocation meter is
Puck.Abstractions.Counting.AllocationWindow:Leasttakes the least of up to 16 windows ofGC.GetAllocatedBytesForCurrentThreadand, when every window allocated, throws naming the window, the GC mode and the types the runtime'sAllocationSampledevent sampled on that thread;Measurereturns the same least as a count for a ceiling, andTotalcounts one run of a body that cannot repeat. Write allocation laws, stages and diagnostics with it rather than reading the counter by hand. Warm the body first: it runs more than once and must leave its subject able to run again. Outside the existing harnesses, say which harness you built and why it measures the claim. - Record the runtime, build configuration, workload, and the counts or
disassembly behind a performance claim, plus the
puck benchresult when the owner asked for a timing.
First-call latency and tiering
Judge a hot kernel under default tiering as well as in FullOpts disassembly. A method runs its tier-0 code until call counting promotes it, and promotion waits for a spell with no new tier-0 compiles, about a second when the process sees one processor; generic-math helpers stay calls at tier 0. So a kernel's first calls in a fresh process can run several times slower than its steady state.
- A compute kernel a request or count enters directly takes
[MethodImpl(MethodImplOptions.AggressiveOptimization)]; the cheap dispatcher above several kernels stays tiered, so first use compiles only the path taken. - A published World is ReadyToRun-compiled, which moves first-use JIT work out
of startup; an ordinary build still produces IL that tiers. A rendering World
of either build prepares the engine's non-generic methods on a lowest-priority
background thread after boot (
WorldCodeWarmer,[world.warm]on stderr), so methods prepared before their first use avoid first-call JIT compilation on the frame thread and can still tier up. Calls made before preparation finishes, methods left unprepared, generic instantiations and static constructors can still pay first-use costs. Addon mounting pre-initializesWorldAddonMutationDecoder's vocabulary and JSON contracts before its first submitted write; this runs no guest and submits no mutation. - Check each hot method's tier with
DOTNET_JitDisasmSummaryin a scratch process pinned to one CPU, and record first-call cost beside the steady state. Pinning a harness to one CPU before it warms up starves the background tiering thread, so warm up first.
Route adjacent work
| Skill | Route to it when |
|---|---|
maths-usage |
The code sits on a simulation value path. It owns the determinism contract this file defers to, the primitive that is correct, and which tier the change owes. |
maths-laws |
The optimization changes a public Puck.Maths member, or moves a value the law suite pins. |
rendering |
Shader, HLSL and GPU cost questions, which this skill's host-side C# guidance does not cover. |
gaming-bricks |
The optimization changes emulator timing, snapshots, replay, allocation or Post-stage behavior. |
content-search |
You need to locate a performance pattern, project property, analyzer opt-out or repeated workaround textually. |
symbol-analysis |
You are about to delete a hand-optimization the folklore sections retire and need to know what still references it. |