C# zero-GC engineering plans. Use when the user asks to make a system allocation-free, or mentions zero-GC, zero allocation, GC spikes, per-frame allocation budgets, object pooling systems, or preventing GC regressions. Produces a plan with commitment levels, acceptance criteria and regression guards, not a one-off code fix.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add tianzhiying/Unity-perf --skill csharp-zero-gc --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Csharp Zero Gc?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/tianzhiying-csharp-zero-gc)More formats (shields.io, HTML) on the badges page.
---
name: csharp-zero-gc
description: C# zero-GC engineering plans. Use when the user asks to make a system allocation-free, or mentions zero-GC, zero allocation, GC spikes, per-frame allocation budgets, object pooling systems, or preventing GC regressions. Produces a plan with commitment levels, acceptance criteria and regression guards, not a one-off code fix.
---
# C# zero-GC engineering plans
Zero GC is not a pile of tricks; it is an engineering commitment: which paths are promised allocation-free, how that is accepted, and how it is still zero six months from now. This skill pins down three things — the target, the acceptance definition, and the guardrail requirement. How you get there is yours to design: the allocation-free techniques you know, the newer language features (Span, ref struct, params collections, interceptors, source generators, and whatever is newer), and the library ecosystem are all more complete and more current than any list here. Zero GC without guardrails gets eaten away by everyday commits, so preventing regressions matters as much as getting to zero.
## 1. Target tiers
Declare the commitment before you start. The following are non-negotiable acceptance criteria.
| Tier | Commitment | Applies to | Acceptance definition |
|------|------|------|---------|
| L1 Peak controlled | Allocation is concentrated in the loading phase; runtime peak stays within the device's memory budget | The floor for every project | PerfDog PSS peak passes; steps in the Mono Reserved curve appear only on loading frames |
| L2 Zero B/frame on hot paths | Combat, main loop, UI refresh, and network send/receive allocate 0 GC bytes within a frame | The target for the vast majority of games; standard | Profiler GC Alloc column stays at 0 B; CI allocation assertions all green |
| L3 Zero B throughout | No code path allocates after the loading screen; GC can be turned off | Lockstep, competitive, VR — genres with zero tolerance for spikes | Over an N-minute soak, the delta in GC.CollectionCount is 0; memory stays flat during the GC-off window |
Declare the tier before starting work: L2 and L3 differ by an order of magnitude in cost (L3 requires third-party libraries and SDKs to be allocation-free too, which constrains your options). For most projects the right answer is L2 plus localized L3 (for example, the lockstep simulation frame).
## 2. The five-step rollout
This is a fallback skeleton, not a process straitjacket. If you judge that a better path exists (upgrade libraries before stopping the bleeding, drive vertically through one subsystem at a time, or a route you designed yourself), you may reorder, merge, add, or drop steps — just state why. Only two things are non-negotiable: the acceptance definition for the declared tier must be met, and the guardrails must land.
1. **Measure the baseline.** With the Profiler (on-device Development Build), sort by GC Alloc to capture the top 20 allocation sites and the per-frame allocation curve, then define the hot-path list — which systems commit to L2, which to L3.
2. **Stop the bleeding at the top.** The top 20 is usually saturated by five categories: strings, LINQ, closures, Instantiate, and missing NonAlloc overloads. Zero them out one by one, prioritizing functions that are both time-expensive and allocation-heavy. This step alone often removes most of the allocation volume.
3. **Structural replacement.** Point fixes rebound. Promote high-frequency allocation shapes into architectural patterns (a unified pool-family entry point, a tiered buffer strategy, allocation-free events and messages, a string system, frame data structures). Which patterns you use is your call; for the accumulated standard implementations see [references/zero-alloc-patterns.md](references/zero-alloc-patterns.md) — use them directly or propose something better.
4. **Library upgrades.** Swap heavy-allocating subsystems wholesale for allocation-free implementations. For validated choices (UniTask and Awaitable, ZString, ZLinq, MemoryPack, LitMotion) see [references/zero-alloc-libraries.md](references/zero-alloc-libraries.md). If you know a better or newer library, propose it proactively; just give the selection rationale (magnitude of the problem solved, AOT and IL2CPP compatibility, maintenance status).
5. **Solidify the guardrails.** A Roslyn Analyzer with per-directory severity tiers + CI allocation assertions + a PR review checklist; see [references/guardrails.md](references/guardrails.md). **Without step 5, the zero-GC plan is not complete.**
When the target project won't run (read-only repo, no runtime, no Profiler), step 1 is not a gate — switch criteria and keep going, and don't report less because of it. Rank allocation sites using a statically verifiable triple: frequency (follow the call chain to confirm whether it's per frame, per refresh, per tap, or one-time init — dirty-flag coalescing is not per frame), multiplier (is it inside a loop, multiplied by N — and get evidence bounding N), and per-occurrence allocation shape (how many `object[]`, how many boxes, how many new strings, how many new collections). With the triple in hand the item goes on the list as usual, marked "pending on-device Profiler confirmation." Lacking baseline data is not grounds for omitting anything; the acceptance trio in section 4 simply moves to post-implementation measurement instead of being a precondition for producing the list.
## 3. Design reminders
This is a thinking checklist for when you meet an allocation, not an answer table. For each class of allocation source, first answer from your own knowledge: "what is the current best solution in the language, the runtime, and the ecosystem?" Use the reference files only when you want a safety-net cross-check.
The five routine allocation sources (string concatenation, temporary collections and buffers, closures and method-group delegates, coroutines and Tasks, hot-path JSON) need only a passing mention. What genuinely requires a dedicated inventory is the following.
**The display endpoints of strings.** Anything that can be pre-generated or cached must never be concatenated at runtime. Take a dedicated inventory of the "number to string" display endpoints — the `text =` or SetText assignment sites for values like currency, score, and countdowns. They hide in the display components at the boundary of your remediation scope, they run often, and they are the easiest to miss. For values with a small domain use a lookup table; for a large domain use a dictionary plus an LRU cache; pair either with a value dirty check so an unchanged value neither rebuilds the string nor triggers a UGUI/TMP rebuild. Every such site must go either on the remediation list or explicitly into the exemption list. An allocation site covered by neither is a gap that will fail the plan's own acceptance.
**Critical premises must be quoted, and unproven ones assumed worst-case.** Any configuration claim that determines list ordering or exemption (whether the logging macro is defined, whether the tween library has recycling enabled, the value of a build switch) must cite the evidence line verbatim (file, line number, content excerpt). Writing only the conclusion does not count as verification. When you can't produce the citation, or you have doubts, always assume the worst case (treat the macro as defined, treat recycling as disabled) and keep the corresponding allocation source on the list. Missing the largest allocation source because you "assumed it would be stripped" is a plan-level accident.
**Compute less before you allocate less.** For expensive recomputation invoked from multiple entry points within the same event or round (recommendation and evaluation functions), first add a cache keyed on an input version number to cut the call count; add a state early-out for routine refreshes while hidden or inactive. Without addressing call count, zeroing allocations item by item is twice the work for half the result.
**When reviewing logging, look at what the argument expressions call** — not just "will it be stripped" and "how many interpolation holes are there." Once an expensive call hides inside a log argument — stack-trace extraction, whole-object serialization, collection Join, LINQ projection, bulk child lookups — the cost of a single log line can exceed the rest of its enclosing function combined, and it won't match the pattern you grep for "string concatenation." Rate a log statement by the most expensive call in its arguments, not by the number of log lines. These sites are also usually the highest-value items: delete a few lines and they go to zero.
**A self-check claiming "this pattern is fully covered" must cover the full set of objects being checked, not just the few classes that occurred to you.** The classic case is the boxing axis: having verified your own generic collections (`List<T>` and `Dictionary<K,V>` really don't box thanks to their struct enumerators), you declare "no boxing in foreach" — and miss the non-generic enumerator types from the host framework and the engine (iterating Transform children, legacy `ArrayList` and `Hashtable`, third-party types implementing only non-generic `IEnumerable`), each of which allocates an enumerator on every foreach. The test is whether the static type of the iterated expression can supply a struct enumerator, not whether "it looks like one of my collections." The same applies on the closure axis (don't look only at lambdas; method-group-to-delegate conversion allocates too) and the string axis (don't look only at interpolation; ToString, Join, and Replace are the same family).
**The granularity of an audit-bucket decision must reach the specific hit, never a pattern category.** Writing a category name like "inherent coroutine allocation" or "engine API allocation" as the reason lumps together the genuinely unfixable and the fixable-but-missed within that category: the coroutine shell really can't be removed, but the wait object newed on every iteration inside it disappears if you cache it; bulk child lookups really do allocate, but caching the references at startup sidesteps them entirely. Every point placed in the bucket must individually answer "why can this not be cleaned up." If you can't answer, it's a list item. And anyone declaring "three-state closure" or "all hits categorized" should know that such a declaration is falsifiable by a single counterexample — make a strong claim and you own it.
**The audit bucket is not an exemption bucket.** First-party and framework segments on the committed path (message dispatch, base-class refresh chains) may not be suspended wholesale as "not audited, out of scope." The L2 and L3 acceptance definitions won't exempt them, and the per-message log concatenation and temporary collections hiding in the bucket will fail the plan's own acceptance. Either bring them into the inventory (at minimum, grep them once per allocation pattern) or explicitly narrow the commitment scope and write the excluded segment into the acceptance definition. The audit bucket accepts only points that were "inventoried but can't or aren't worth cleaning" — never points that weren't looked at.
**"This module has no Update" is not "this module has no per-frame code."** Zero grep hits for frame callbacks in the target directory proves only that these files don't declare one. Frame-driving is usually inherited: framework components the module mounts (lists, scrolling, layout, animation widgets) come with their own frame and scroll callbacks, and base-class refresh chains and host-container rebuild chains also run every frame. The grep boundary must follow component composition and the inheritance chain, not file boundaries — follow every framework component the target mounts and every base class it inherits, check whether it has a frame entry point, and check which of the target's methods hangs beneath that entry point. Get this inference wrong and the entire report's tier assignment and priority ordering rest on a false foundation — far worse than missing one allocation site. Likewise, a widget's behavior (virtualized or not, pooled or not, per-frame callbacks or not) is determined by its actual runtime configuration (prefab, serialized fields, scene instance); code comments and naming don't count.
**When the boundary follows components, the physical search scope must follow too.** A substantial share of the components the target mounts live outside the gameplay code directory: engine-supplied UI components, libraries pulled in by the package manager, third-party SDKs — with sources in the package cache or read-only package directories. Grepping for frame callbacks only inside the gameplay source directory systematically misses this entire class, and they happen to be the most common source of per-frame cost (layout rebuilds, scroll boundary computation, mesh regeneration) — and they are completely unrelated to the gameplay-side configuration field you just verified. You may have just proven "this field makes the gameplay callback early-out," while missing that the same object also mounts an engine component running unconditionally every frame. The method is: resolve each mounted component's GUID back to the directory its `.meta` lives in, read the implementation there, and search any directory the lookup lands in that you haven't searched yet.
**Once you find a hot implementation, grep the whole repo by the stable identifier it consumes** rather than only following the chain down from the current module. Asset path constants, protocol numbers, key API names, and event IDs are all cross-module searchable anchors. It is very common for the same expensive logic (cold load plus instantiate plus destroy, serialization, bulk rebuild) to be copy-pasted into two or three copies hanging off different gameplay chains; following the current module you see only one, so your remediation's scope shrinks proportionally without the report showing it. Before making a recommendation, answer: how many call sites does this anchor have repo-wide, and how many does my plan cover?
**Claiming "there is an allocation here" requires reading the implementation just as much as claiming "there is none."** Judging boxing because a parameter is `object` or a non-generic interface, or assuming a virtual method does work because of its signature, is accounting by signature instead of by the actual execution path — the virtual method may be an empty implementation, that overload may not be on the real dispatch path, generic dispatch may have pinned the type further up. Walk the full path from call site to the line that actually does the work before assigning a tier. The costs are asymmetric: a miss costs you one optimization item, whereas a false positive makes you propose a change with zero benefit — and if that change is framework-level (modifying a shared base class or the dispatch mechanism), you are trading project-wide regression risk for zero gain. So any item recommending changes to the framework or a shared layer carries an evidence bar one notch above module-local items.
**Frequency claims need call-surface evidence.** The trigger frequency annotated on each allocation site (per frame, per packet, per action) must be backed by a call-site grep. Discovering that the target component has zero callers repo-wide, or isn't wired up at all, is itself an important finding — the allocation is real but not yet triggered; annotate it "must be fixed before wiring up" and never account for it at an imagined frequency.
**Two filters before an item makes the list: reachability and multiplier.** Reachability means whether the code actually executes in a production build — test switches, mock data construction, editor macros, and unwired paths must all be traced back to the switch's default value or its definition and verified. Unreachable items go to exemptions, not the list; otherwise the list gets diluted by dead code and the priorities distort. Multiplier means byte estimates must distinguish "once" from "inside a loop, times N." Per-element or per-byte string concatenation (hex dumps, per-record logging, per-item concatenation) is an order-of-magnitude amplifier; measuring it as a single occurrence sorts the biggest single point to the bottom.
When a third-party SDK's allocations can't be removed, fall back on engineering measures — isolation, frequency reduction, a deliberate GC.Collect on the loading screen — not resignation.
Beware over-pooling: pools must have caps. Trading GC pressure for a resident-memory explosion is a real risk, and acceptance must include the memory peak curve.
Order discipline: inventory freely first — read the module on instinct for allocations and produce real findings; then work the pattern × file matrix, the three-state closure, and the targeted enumerations to catch omissions; write the closure declaration last. Doing it in the other order spends all your attention filling in cells, and **filling in the cells is not the same as cleaning it up.**
## 3.1 The four pseudo-optimization checks
Zero-GC work is especially prone to producing items that are "factually correct, benefit zero": the allocation site is identified precisely, but that line never runs, or the layer below stopped allocating long ago. Run every item through these four before reporting it.
1. **Reachability** — does this allocation actually happen? Are there call sites (watch for reflection, hot-update, and serialized calls)? Are the serialized fields bound (an unassigned reference in a prefab means the dependent branch never runs, so the loading and allocation behind it never execute once)? Is the branch condition always true or always false (a static switch field assigned in exactly one place across the repo makes the other branch entirely dead, along with every allocation inside it)? Is there a gate upstream (an early `return`, an undefined macro, development-build-only code, the framework having already disabled the node)? **If you can't answer "under what conditions does it allocate," you may not report it.**
2. **Already handled below** — did a lower layer already eliminate the allocation? For example: TMP's `text` setter already early-outs on an equal value (adding your own dirty check on top actually adds a getter round trip); `SetActive` with the same value early-outs; the path loader already short-circuits identical paths; concurrent load requests are already coalesced; the target runtime's enum comparer is already non-boxing; the texture importer already downsampled at import time. Confirm the layer below hasn't already done it before adding a dirty check or a cache.
3. **Self-consistent prescription** — does the fix solve the very fact you stated? The classic failure: you cited evidence that the target platform defines the logging macro and that `[Conditional]` therefore does nothing, then still prescribed "add `[Conditional]`" to eliminate boxing in log arguments — following which not a single byte gets stripped. Self-check the fix itself too: can that delegate field bind an instance method, does the API exist, will it compile?
4. **Side effects of the fix** — four classes of risk that are easy to under-report. Pooling and reuse can break an upstream "reference-equal, so skip the refresh" check, leaving the consumer permanently showing stale content (this is the most dangerous class in this skill, precisely because pooling is the core technique). Statically caching config data breaks when a hot update replaces the data instance wholesale, leaving the cache pointed at the old one forever. Reused buffers are not reentrant — nested calls trample each other. Eliminating a closure by moving the comparison token into an instance field breaks async callbacks that relied on the captured epoch/path/id to decide "is this response stale" — once it's an instance field the staleness test becomes a tautology, the callback always believes it is the newest, and an old response overwrites newer content. The correct fix is to compare against the callback's own parameters, which naturally carry the values from when the request was issued, requiring no capture and therefore creating no closure. The test: whenever a fix moves "the storage location of the variable a staleness or race check depends on," first ask "after this change, can two concurrent requests still be told apart?" **Stating a risk honestly costs you nothing; failing to report one does.**
Column discipline: only items that "genuinely allocate less after the change" go on the zero-GC list; things that "should be fixed but don't reduce allocation" get their own section. Allocation volume needs an order of magnitude — a few hundred bytes on a one-time init path is not the same as a few hundred bytes on a per-frame path, and if you can't state the frequency you may not mark it high-benefit. But doubt about magnitude **only lowers the benefit tier; it does not delete the entry.** Deleting requires a concrete order-of-magnitude counter-proof.
## 3.1.5 Independent adversarial final review
Once the inventory is complete, dispatch an independent subagent that took no part in it to re-review the whole list — and you must reserve a subagent slot for it (if the concurrency cap is saturated, this stage never runs at all). This step cannot be done by yourself. It is mandatory when delivering a *plan* — a plan is meant to be executed, and no-op items mixed into the list waste a proportional amount of work; worse, errors like "inflated remediation scope" make people think they're done when they aren't. It may be skipped when you're only handing over a rough lead list.
The final review does three things:
1. Run every item through the four pseudo-optimization checks above; on a hit, delete or downgrade it and record the counter-evidence line number.
2. For every item marked "high allocation" or "per frame," argue the opposing case: deliberately construct an argument that the item does *not* hold — look for a gate, for unreachability, for the layer below already handling it, for a framework fallback. Only when that construction fails does the item pass. Killing items on evidence-free suspicion ("theoretically there might be another guard") is forbidden — the cost of killing a real allocation site exceeds the cost of letting a false one through.
3. Cross-reconcile, looking for the three things a per-item pass can't see: whether a gate or early-out reported by this very list makes the allocation paths described by several other items structurally unreachable; grouping by "which single change resolves it," where multiple items in a group are one root cause split apart and should be merged; and whether the same point is double-counted across the three states (list, exemptions, audit bucket).
The final review's conclusions must be enforced — killed items may not appear in the final plan. Append one line at the end of the report: `four checks deleted a; opposing-case deleted or downgraded b; cross-reconciliation merged c`.
## 3.2 Execution strategy
The target may be a folder or a subsystem: first rank the heavy-allocating files by "per-frame or per-event path × size." For multi-file remediation you can fan out parallel subagents by file or sub-chain to inventory allocation sources (subagents must carry this skill's disciplines), with the main report consolidating the three-state closure. The orchestration discipline is: dispatch in one wave, wait for all of them, synthesize personally. The deliverable can only be the final report itself — ending a turn in an intermediate state like "waiting for subagents to return" is a non-delivery, and the cost of restarting the synthesis is burned entirely.
**When evidence hasn't come back, closing the loop yourself yields inferences, not terminal judgments.** If the evidence subagent for one axis times out or fails, whatever you backfill from firsthand evidence must be downgraded and labeled "unverified inference," listed separately from verified items. Above all, you may not use it to rule an item in the audit bucket "verified, confirmed clean, released from the bucket." Getting a list item wrong is only an ordering error (the benchmark will correct it later), whereas releasing from the bucket and exempting are terminal judgments — get one wrong and a real problem leaves your field of view permanently, so the evidence bar there must be the highest of all. **Ruling a point "clean" takes more work than ruling it "dirty"**: dirty needs only one allocation pointed out, while clean requires reading the implementations it depends on to the bottom — for a pooling utility, read into the pool implementation for its residency policy and hit criteria; for a cache, read the invalidation path; for "an unchanged value produces no new object," read how "unchanged" is actually determined. Anything released from the bucket on a first-order conclusion like "it has a pool / a cache / interning inside, therefore steady-state zero allocation" counts as unclosed.
**Pattern × file matrix closure:** every first-party file inside the committed chain must be run through the complete allocation-pattern list (closures, method-group delegates, tweens, temporary collections, strings, logging, boxing). A file run through only some patterns means the inventory isn't closed. The easiest miss is the *second* pattern beyond the primary symptom — you caught the text assignment in a file and missed the method-group delegate and the tween right next to it. When the inventory is done, ask yourself: "which file did I run through only one pattern?"
**Token economy:** use grep to locate allocation patterns and then read the hits closely; don't read line by line. Plan code shows only the remediated implementation, never a restatement of the original. Multiple hits of the same pattern get a "pattern × hit list" rather than being expanded one by one. Guardrails and CI assertions get one template plus a list of where it applies. Dispatch evidence subagents in a single wave and don't leave the main thread spinning; the main report consumes subagent conclusions directly rather than re-gathering evidence. Saving tokens may never leave an allocation site outside the three states.
## 4. Output specification
1. **Lead with the tier and the scope**: is this remediation targeting L1, L2, or L3, and which systems does it cover? Legacy projects default to the five-step route — state which step you're on.
2. **The remediation list has three columns**: allocation site (file:line + bytes per frame) → the approach adopted (yours or cited from a reference file) → expected result after the change (0 B, or reduced to what), sorted by bytes per frame descending.
3. **Every remediation must land a guardrail at the same time**: a newly zeroed path either gets a CI allocation assertion or moves into the Analyzer's error directory. A change that zeroes without guarding counts as incomplete.
4. **The acceptance trio** (measured after implementation — not a precondition for producing the list): evidence of 0 B in the Profiler's GC Alloc column, the delta in GC.CollectionCount over an N-minute soak, and the memory peak curve. Lists delivered at the static-analysis stage must annotate every item "pending on-device confirmation."
5. **Give the selection rationale before introducing a third-party library**, and follow the library-adoption discipline in zero-alloc-libraries.md. Libraries outside that list may still be recommended, held to the same standard.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!