csharp-performance
Skill2 starsChanged 42 days agoMITView on GitHub
Use when writing or reviewing C#/.NET code on a hot path where allocations, GC pressure, or throughput matter. Distils the techniques used across ReactiveUI, Splat, Akavache, Fusillade, Punchclock and Roslyn-style parsers. Ships with companion helper classes in ./helpers. Measure first — these are for proven hot paths, not everywhere.
In this repository56 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- benchmarkingSkill
- commit-messagesSkill
- csharp-15-syntaxSkill
- csharp-aot-trimmingSkill
- csharp-api-designSkill
- csharp-assembly-inspectionSkill
- csharp-asyncSkill
- csharp-collections-modernSkill
- csharp-concurrency-primitivesSkill
- csharp-data-layoutSkill
- csharp-debuggingSkill
- csharp-discardsSkill
- csharp-disposable-patternsSkill
- csharp-docsSkill
- csharp-error-handlingSkill
- csharp-extension-membersSkill
- csharp-generics-modernSkill
- csharp-guard-clausesSkill
- csharp-json-source-generationSkill
- csharp-language-versionsSkill
- csharp-logging-diagnosticsSkill
- csharp-lspSkill
- csharp-modern-typesSkill
- csharp-nullabilitySkill
- csharp-nullable-migrationSkill
- csharp-nunitSkill
- csharp-pattern-matchingSkill
- csharp-polyfillsSkill
- csharp-source-generatorsSkill
- csharp-static-lambdasSkill
- csharp-string-handlingSkill
- csharp-tunitSkill
- csharp-verificationSkill
- dispatching-subagentsSkill
- dotnet-11-librariesSkill
- dotnet-analyzersSkill
- dotnet-central-package-managementSkill
- dotnet-file-based-appsSkill
- dotnet-msbuild-usingsSkill
- dotnet-multi-targetingSkill
- dotnet-project-hygieneSkill
- dotnet-runSkill
- dotnet-runtime-asyncSkill
- dotnet-slnxSkill
- dotnet-test-platformsSkill
- executing-plansSkill
- performance-bounded-deduplicationSkill
- roslyn-analyzer-performanceSkill
- roslyn-discoverySkill
- roslyn-duplicate-detectionSkill
- roslyn-guard-rewritersSkill
- roslyn-rewritersSkill
- subagent-driven-developmentSkill
- tddSkill
The file
We split this skill into the rules it gives an agent
2 rules. About 0 are common, 1 is distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 122
No synchronisation runs the builder once per concurrent first caller, so the cold path is several times slower than either gated version.
- Very specific: line 145
AOT-friendly: Prefer source-gen/cached delegates over per-call reflection.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
C# performance toolkit
These are the patterns high-throughput .NET libraries such as ReactiveUI, Splat, Akavache, Fusillade and Punchclock use to stay fast. They trade a little complexity for fewer allocations, less GC pressure, and lock-free reads. Apply them on measured hot paths, not by default — on cold paths they just cost readability. Profile first (BenchmarkDotNet for micro, dotnet-counters for live GC/alloc/thread-pool, dotnet-trace for flow).
Companion helpers in ./helpers/ are drop-in implementations of the most reusable patterns — change the PerfHelpers namespace to suit your project.
This skill is the allocation/caching/concurrency axis. For the data-layout / cache-coherence axis — struct-of-arrays for tight loops, struct field ordering, flat data instead of inheritance, static helpers that carry only the memory they touch — see csharp-data-layout.
0. What the runtime now does for you (by version)
Know what's already free before hand-applying a trick below — otherwise you're paying readability for something the JIT already gives you.
- .NET 8 — Dynamic PGO on by default. The JIT instruments hot call sites at runtime and inserts a guarded-devirtualization (GDV) fast path per observed concrete type at an interface/virtual call. This is the mechanism behind most of what follows.
- .NET 9/10 — escape analysis stack-allocates small non-escaping objects/arrays/boxes. An object, small array, or box that's allocated, used, and provably dead within one method — never returned, stored to a field/collection, or captured by something that outlives the call — can live on the JIT's own frame instead of the GC heap: a small array built and used locally allocates nothing, while the same array returned from the method is heap-allocated. Micro-tricks that exist purely to dodge a small, obviously-local allocation (e.g. hand-rolling a tuple-of-locals instead of a tiny local array/object) aren't worth the readability cost if the value genuinely never escapes the method — check with
[MemoryDiagnoser]before deleting a pooling/caching trick on the strength of this, since "never escapes" is easy to get wrong by adding one more line. - .NET 10 —
foreachoverList<T>/T[]through anIEnumerable<T>-typed reference gets much cheaper. Conditional escape analysis lets the JIT stack-allocate and field-promote the enumerator even when it's only proven non-escaping behind a not-yet-resolved GDV type-test guard. On net11.0 aforeachover aList<T>through anIEnumerable<T>reference performs the same as through the concrete type, with no enumerator allocation; on net10.0 the gap is usually but not always closed. Casting/storing as the concrete collection type purely to avoid enumerator-allocation/dispatch overhead in a hotforeachisn't necessary on net10.0+ for plainList<T>/array enumeration — keep the concrete type where it's already natural (better in every other way), but don't add it as a perf-only change without measuring. - .NET 11 — broader bounds-check elimination, more constant folding, more devirtualization.
arr[i+1], index-from-end (span[^1]), andspan[0]after an!span.IsEmptyguard no longer need manual restructuring to drop the redundant check. Switch expressions over small constant sets, andstring/ReadOnlySpan<T>.SequenceEqualcalls where both sides are compile-time constants, fold away entirely. Generic virtual methods, default interface methods on generic interfaces, andActivator.CreateInstance<T>()results devirtualize where they didn't before. None of this requires a code change — it's automatic on net11.0.
What's still on you, no version changes this:
- Anything that actually escapes — returned, stored in a field/collection, captured by a closure/state machine that outlives the call, or boxed into something retained.
- LINQ and hand-written iterators (
yield return) still allocate in hot loops. The compiler-generatedMoveNextfor these contains atry/finally, and the JIT still cannot inline a method containing exception handling — so escape analysis can't see through the state machine to prove the enumerator/closures are dead. The "manualforoverforeach/LINQ in hot loops" rule below is unchanged for these; it's specifically plainList<T>/arrayforeachthat got cheaper. - Allocations inside a loop are only stack-allocated when the JIT can prove the value is dead across iterations — harder to prove than a straight-line method, so don't assume a loop-local allocation is free without measuring.
- Large objects/arrays — stack allocation is JIT-size-bounded; it isn't a substitute for pooling something big.
- Dictionary/HashSet enumeration and methods with multiple overlapping enumerators are only partially covered — measure rather than assume.
GC: DATAS (Server GC, default since .NET 9). Server GC now sizes its heap/heap-count dynamically to the process's actual working set (Dynamic Adaptation To Application Sizes) instead of assuming "use every core" — a real win for container/cloud memory footprint. The tradeoff: a cache sized for peak load no longer sits at a GC-provisioned peak-size heap for free — the heap grows and shrinks around actual usage, so an oversized long-lived cache causes more heap resize churn than it used to. Bound caches (capacity/TTL eviction) rather than relying on Server GC to just absorb a large working set. DOTNET_GCgen0size, DOTNET_GCHeapCount, and DOTNET_GCDynamicAdaptationMode=0 opt back toward the old fixed-sizing behaviour if you've measured DATAS hurting a specific latency-sensitive workload — don't flip it defensively without a benchmark.
1. Kill allocations on hot paths
Allocation is the usual enemy: every temporary is future GC work. Observed across the repos:
ref structfor stack-only state — parsers/scanners that hold aReadOnlySpan<char>cursor can't be boxed or escape to the heap. Zero allocation by construction. (SourceDocParserDocXmlScanner)readonly struct+inparams — small value-shaped types (change records, work items, time intervals) stack-allocate; pass large onesinto avoid the copy. (ReactiveUICollectionChanged, PrimitivesTimedWorkItem)Span<T>/ReadOnlySpan<T>slicing —span[start..end]is a view, not a substring. Slice instead ofSubstring/ToArray. Usestackallocfor small scratch buffers.- Cached
SearchValues<char>—static readonly SearchValues.Create(" \t\r\n")thenIndexOfAnyExcept(...); far faster thanIndexOfAny(new[]{...})which allocates per call. (SourceDocParser) string.Create(len, state, span-fill)— build a string in one exact-sized allocation, no intermediate concatenations;staticlambda +TStateonly — a capturing lambda still allocates a closure even under .NET 10's delegate escape analysis (the closure display class isn't covered, only theFunc/Actionwrapping it). .NET 9+ also letsTStatebe aref struct, so a span can be passed as state directly instead of being captured. (PublicSurfaceProbe) → full version-by-version breakdown incsharp-string-handling.StringBuilder.GetChunks()+ArrayPool<byte>— encode/stream a builder without ever materializing the full string. (PageWriter) → seePooledStringBuilderhelper for the rent/return side.- Manual
foroverforeach/LINQ in hot loops — avoids iterator/closure allocation and virtual dispatch. LINQ is fine on cold paths; not in per-frame/per-notification code. (ReactiveUIViewModelActivator, SourceDocParser) — exception: plainforeachoverList<T>/T[], including through anIEnumerable<T>-typed reference, is close to free on net10.0+ (see §0) — this rule is really about LINQ operators andyield returniterators, which still allocate because their generatedMoveNextblocks inlining. - Concrete collection types — declare
List<T>,Dictionary<TKey, TValue>,HashSet<T>,T[], neverIList<T>/IDictionary<TKey, TValue>/ICollection<T>/ISet<T>, so calls are direct and inlinable instead of interface dispatch (CA1859). Seecsharp-collections-modern. - Non-capturing callbacks —
GetOrAdd(key, static k => ...)andDo(onNext: static _ => {})don't capturethisor locals, so no closure is allocated. The saving comes from not capturing;staticguarantees it stays that way (seecsharp-static-lambdas). Pass state via theTArgoverload instead of capturing. - Pre-size collections —
new Dictionary(32)/new List(capacity)when the size is known, to skip reallocation/rehash churn. (Splat resolver) - Reuse singletons — cache common immutable values (
EventArgs,Unit,true/falseobservables, empty arrays via[]) instead of re-allocating. (ReactiveUISingletonPropertyChangedEventArgs,SingleValueObservable) [MethodImpl(MethodImplOptions.AggressiveInlining)]on tiny hot helpers/getters to remove call overhead. Don't sprinkle it — only where a profile shows the call cost.- Pool transient buffers/builders —
ArrayPool<T>.Shared.Rent(...)intry/finally, or a builder pool wrapped in areadonly structrental that returns onDispose. →PooledStringBuilderhelper.
Ref structs that hold stackalloc spans: the ref-safety trap
A ref struct whose field holds a stack-allocated Span<T> cannot have an instance method called with a ref argument of another ref struct type that can hold a span. The compiler assumes the receiver's span could be stored into the argument and reports CS8352 ("may expose referenced variables outside of their declaration scope") and CS8350 ("this combination of arguments is disallowed"). Verified on SDK 11 RC1 with this shape:
Span<byte> a = stackalloc byte[16];
var writer = new Writer(a); // ref struct, field: Span<byte>
var sink = new Sink(); // ref struct with a Span<byte> field
writer.Emit(ref sink); // CS8352 + CS8350What does and does not fix it (all tried):
| Change | Result |
scoped ref Sink on the parameter | Still fails. scoped limits the reference, not the span stored inside the referenced struct |
scoped ref Writer on a ref Writer parameter | Still fails, same reason |
Caller-provided Span<byte> passed to the constructor | Still fails when the struct then takes a ref to another span-holding ref struct. It is not a fix on its own |
Back the ref struct with a rented byte[] (ArrayPool<byte>.Shared.Rent) and build spans on demand | Compiles. Return the array in finally |
| Make the other type a class or an ordinary (non-ref) struct | Compiles (both verified). A ref struct that only holds an int still fails |
Mark the span parameter that is only read as scoped Span<T> or scoped ReadOnlySpan<T> on the receiving method, and pass ref structs by value as scoped Writer w | Compiles. The method promises not to store the span |
Pick the array-backed version when the ref struct is long-lived or passed around. Use scoped span parameters for short helper methods that only read. Do not keep adding scoped ref expecting it to settle the error.
An unmanaged struct type can be stackalloc'd by the caller and handed in as a Span<T> (for example Span<Pair> pairs = stackalloc Pair[4];, passed to Sum(scoped ReadOnlySpan<Pair>)). Use this when an analyzer objects to the field in an [InlineArray] struct. The caller owns the memory and the callee stays allocation-free.
2. Cache the expensive stuff
static readonly Type Type = typeof(T)per closed generic — the JIT specializes one instance perT; hot paths skip repeatedtypeof/metadata cost. →TypeCache<T>helper.FrozenDictionary<K,V>rarely. Reads are faster thanDictionary, but construction is expensive and hurts startup. Only use it for a table read a very large number of times in a long-running process, and only after a benchmark that includes construction cost shows a win. Default toDictionary/HashSet. (CatalogIndexes)- Compile expressions to delegates once — turn a property-access
Expressioninto a cached getter delegate at setup; reuse it per notification instead of reflecting each time. (ReactiveUICompiledPropertyChain) - Bounded memoization (MRU) for expensive reflection/factory resolution — cache hits dwarf the lookup cost; bound the size so it can't grow unbounded. (ReactiveUI
Reflectioncaches) - Inline state slot to skip
ConditionalWeakTable— when an object can store its own auxiliary state in a field (behind an interface), you avoid a process-wide table lookup per access. Fall back toConditionalWeakTableonly for types you can't modify. (ReactiveUIIReactiveObjectStateSlot)
3. Lock-free / minimal-lock concurrency
Favour atomic reads over locks when reads vastly outnumber writes.
- Copy-on-write snapshot — writers clone + publish a new immutable array under a lock; readers
Volatile.Readthe reference and enumerate with no lock at all. The workhorse pattern in Splat's resolver and ReactiveUI's registries. →CopyOnWriteList<T>helper. Volatile.Read/Writefor flags/counters/snapshot refs — memory-barrier visibility without mutual exclusion. (Splat, Punchclock)Interlockedfor counters and assign/dispose-once —Increment/Decrement,Exchange, andCompareExchange(ref x, new, null)to claim a one-time slot. →AtomicDisposablehelper. (PrimitivesSinkSubscription, Splat)ConcurrentDictionary.GetOrAddwith a factory that captures nothing — atomic check-and-insert. A capturing lambda allocates a display class per call, which can makeGetOrAddslower than thelockit replaced; a non-capturing factory with state passed through theTArgoverload avoids that (staticguarantees it stays non-capturing). Wrap the value inLazy<T>if the factory must run exactly once under contention.For a read-heavy memo cache:
GetOrAddwith a non-capturing factory is the fastest option, single-threaded and under contention, and its lead overlock+Dictionarygrows with thread count.GetOrAddwith a capturing lambda is slower thanlock+Dictionaryon one thread, and loses most of its advantage under contention.Interlocked.CompareExchangeover an immutable snapshot matcheslocksingle-threaded and beats it under contention, but trails the non-capturingGetOrAdd.
One-time initialisation: keep the gate, move it off the fast path. Deleting the lock from a lazily built cache is the tempting "lock-free" rewrite and it is usually wrong — every concurrent first caller then runs the whole build and discards all but one result. But leaving the
lockin the queried method blocks that method from inlining, so the warm path pays for a slow path it never takes. Split them: an inlinableVolatile.Readfast path, and the gate in its ownNoInliningmethod.[MethodImpl(MethodImplOptions.AggressiveInlining)] public bool Contains(TKey key) => (Volatile.Read(ref _map) ?? Resolve()).Contains(key); [MethodImpl(MethodImplOptions.NoInlining)] private HashSet<TKey> Resolve() { lock (_gate) { var map = _map; if (map is null) { map = Build(); Volatile.Write(ref _map, map); } return map; } }For an expensive builder:
- No synchronisation runs the builder once per concurrent first caller, so the cold path is several times slower than either gated version.
- A
lockinside the queried method builds once, but the warm path stays slow because the method can't inline. - The split slow path builds once, matches the
lockversion cold, and makes the warm path several times faster.
Where the cached value is cheap (a couple of metadata lookups), a race is harmless — use
Interlocked.CompareExchangeover one immutable record holding every resolved value. Never publish the fields one at a time.- Inline observer set (none/one/many) — store
null/ singleIObserver/ array, copy-on-write per subscribe; avoids a list allocation for the common 0–1 observer case. (ReactiveUIBroadcaster) - Right data structure — a 4-ary (quaternary) heap beats a binary heap for priority queues (shallower, better cache locality); key-based serialization runs different keys concurrently while serializing same-key work. (Punchclock)
4. Async throughput
(See csharp-async for the full async rules — this is the perf-specific subset.)
ValueTask/ValueTask<T>for paths that usually complete synchronously (cache hits, buffered reads) — noTaskallocation on the sync path. Await once, never block, never reuse.ConfigureAwait(false)everywhere in library code — skips context capture, cheaper continuations, deadlock-immune.- Coalesce duplicate in-flight work — when N callers request the same key at once, run the operation once and share the result (
Replay(1).RefCount()for observables, or a sharedTask). Kills the thundering herd. →AsyncRequestCoalescer<TKey,TValue>helper. (AkavacheRequestCache, FusilladeInflightRequest) - Guard before you format —
if (!IsDebugEnabled) return;beforestring.Format(...)avoids allocating and boxing args for logs that won't be written. (SplatAllocationFreeLoggerBase) TaskCompletionSourcewithRunContinuationsAsynchronously— prevents inline continuation execution on the completing thread (reentrancy/deadlock risk).
Discipline
- Measure before and after. A "faster" change with no benchmark is a guess. Keep the benchmark.
- Hot paths only. These patterns cost readability — spend that budget where a profiler says it matters.
- AOT-friendly. Prefer source-gen/cached delegates over per-call reflection; annotate any retained reflection with
[DynamicallyAccessedMembers]. - Don't break correctness for nanoseconds. Lock-free is subtle — only hand-roll it where the contention is real, and test it deterministically (see
csharp-tuniton controlling execution context).
Companion helpers (./helpers/)
| File | Pattern |
PooledStringBuilder.cs | Rent/return StringBuilder pool with a readonly struct rental |
AsyncRequestCoalescer.cs | Coalesce concurrent identical async requests into one |
AtomicDisposable.cs | Lock-free assign-once / dispose-once via Interlocked |
TypeCache.cs | static readonly typeof(T) per-generic cache |
CopyOnWriteList.cs | Lock-free reads via published immutable snapshot |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

