ZeroGC is an experimental, standalone CoreCLR GC implementation for .NET that only allocates memory and never collects, compacts, or reclaims it. It is a .NET analog to the JVM's "Epsilon" no-op GC, and is directly inspired by the author's earlier prototype, UpsilonGC.
Why would you want a GC that never frees memory? It's useful for measuring the pure cost of garbage collection in a workload (by comparing against a GC that does none of it), for short-lived batch/CLI tools where "leak everything, then exit" is an acceptable trade for eliminating GC pause latency entirely, and as a minimal, from-scratch reference implementation of the CoreCLR standalone-GC ABI.
ZeroGC is loaded exactly like any other standalone/out-of-process GC:
via DOTNET_GCName=ZeroGC.dll (or COMPlus_GCName), sitting next to the app's
managed executable. No changes to the CoreCLR runtime are required or made
by this project — C:\github\runtime was used only as a read-only reference
for the unmodified GC interface headers (gcinterface.h, gcenv.*.h, etc.)
and to build a local, unmodified coreclr.dll/SDK for testing.
Prebuilt binaries are published as NuGet packages on Microsoft's
dotnet-experimentalfeed (built and signed via the official Arcade/Azure Pipelines integration) - seedocs/zerogc/using-prebuilt-binaries.mdfor exact consumption instructions, or "Building ZeroGC.dll / libZeroGC.so" below to build from source instead.
- Implements the full
IGCHeap+IGCHandleManager/IGCHandleStoreABI surface required by the EE (allocation, write barriers, handle creation/destruction, GC.Collect() as a no-op,GC.GetGCMemoryInfo(),GC.CollectionCount(),GC.GetTotalAllocatedBytes(), etc.) - A simple bump-pointer arena allocator: each allocation request reserves a chunk from a growing set of large (64 MB) committed segments; individual object allocations are carved out with a lock-protected pointer bump. Nothing is ever freed, compacted, or generation-tracked.
- Handles (
GCHandle, weak/strong/pinned/etc.) are implemented as a simple fixed-capacity segmented table. Weak handles are never cleared (since nothing is ever collected, this is consistent with ZeroGC's semantics — the referent is always still alive). - Standard counters (
GC.CollectionCount(n)always 0,GC.GetTotalAllocatedBytes(),GC.GetGCMemoryInfo()heap/committed sizes,GCSettings.IsServerGC, etc.) all report real, meaningful values so that existing diagnostics/monitoring code keeps working and can be compared apples-to-apples against the regular GC.
- Memory is never reclaimed. Long-running or allocation-heavy processes will grow without bound until the process is killed or the machine runs out of memory. This is the entire point of the GC, not a bug.
- No compaction, no generations, no finalization triggering based on memory pressure (finalizers still run at process exit via the normal EE shutdown path).
- No heap walking / profiling API support (
ICorProfilerCallbackGC callbacks,dotnet-gcdump, etc. are not implemented) — the arena is not a walkable managed heap structure. - Card table and card bundle table are both implemented (allocated, biased, and wired up) purely so the EE's write barrier doesn't fault — ZeroGC itself never reads them, since it never needs to know which objects were mutated (there's no compaction or generational promotion to drive from that information).
- Allocation is per-thread and lock-free. Each OS thread claims its own
private, contiguous slice of the arena (a single lock-free
InterlockedExchangeAdd64on a shared watermark, not a critical section — seeClaimArenaSlice/AllocateFromArenainnative/ZeroGCHeap.cpp); every allocation-context refill and page commit after that touches only that thread's own memory, so concurrent threads never contend with each other. An earlier version of this allocator funneled every thread's refill through one shared bump pointer guarded by a singleCRITICAL_SECTION, which became a real bottleneck under heavy multi-threaded allocation (seegcperfsim-mt-throughputbelow) — the per-thread-slice design removes that bottleneck entirely.
src/ZeroGC/
native/ ZeroGC native GC plugin sources
ZeroGC.h Shared declarations
dllmain.cpp GC_VersionInfo / GC_Initialize / DllMain entry points
ZeroGCHeap.cpp IGCHeap implementation (allocator, write barriers, counters)
ZeroGCHandles.cpp IGCHandleManager / IGCHandleStore implementation
build.ps1 Builds ZeroGC.dll (Release, x64) using VS Build Tools
build-linux.sh Builds libZeroGC.so (Release, x64) using clang++
ZeroGCPal.h Win32-compatibility shim used only on non-Windows hosts
tools/symresolve.cpp Debug-only dbghelp-based crash symbol resolver (Windows-only, see below)
samples/
ConsoleApp/ Allocation-heavy console benchmark app
WebApi/ Self-driving ASP.NET Core (Kestrel) benchmark app (8 concurrent workers)
GCPerfSim/ Vendored dotnet/performance allocation-pattern simulator (unmodified)
ZeroAllocApp/ Near-zero-allocation numeric compute (matrix multiply) benchmark app
GrowingCacheApp/ Growing in-memory cache benchmark app that naturally escalates gen2 GC pauses
WriteBarrierBench/ Isolated write-barrier microbenchmark (see "Performance deep-dive")
HandleStressBench/ Isolated GCHandle Alloc/Free microbenchmark (see "Performance deep-dive")
run-benchmarks.ps1 Orchestrates all 9 scenarios x 3 GC modes via dotnet-counters
generate-report.ps1 Renders results/results-full.json -> results/report.html
results/
results-full.json Raw benchmark output (summary + percentiles + time series) from the last run
report.html Self-contained HTML comparison report (the deliverable artifact)
raw/ Per-run dotnet-counters CSV captures (supplementary/debug data)
ZeroGC's own scanning/allocation code hardcodes assumptions about object
layout (MethodTable header flags, card-table encoding, sync-block layout,
etc.) that CoreCLR's standalone-GC ABI check does not fully validate:
that check only rejects a GC whose major ABI version is older than the
runtime expects (see GC_INTERFACE_MAJOR_VERSION in gcinterface.h and
the check in src/coreclr/vm/gcheaputilities.cpp) - it says nothing about
whether those object-layout assumptions still hold, and they have
silently drifted before (g_oldMethodTableFlags in
src/ZeroGC/native/dllmain.cpp is a real, already-encountered example; the
ConfigurationValueFunc callback's parameter types are another, found
while adding net10.0 GA support - see git history for both).
Because of this, ZeroGC is built and released once per targeted runtime major version, rather than relying on the ABI version check alone:
| Target runtime | dotnet/runtime tag built against |
Status |
|---|---|---|
| .NET 10.0 (GA) | v10.0.0 |
Supported |
| .NET 11.0 (preview) | v11.0.0-preview.6.26359.118 (updated periodically as preview builds progress) |
Supported |
A binary built for one target runtime version must only be used with an
app running that same major version - see
docs/zerogc/using-prebuilt-binaries.md
for the full explanation and consequences of ignoring this. If you need a
version not listed above, build it yourself (see "Building" below) against
the matching dotnet/runtime tag/branch - the build scripts are fully
parameterized by -RuntimeRepo/--runtime-repo for exactly this purpose.
Requires the VC++ build tools (the same toolset used to build CoreCLR) and a
local .NET SDK. From a Developer Command Prompt / PowerShell with cl.exe
on PATH (or let build.ps1 locate vcvars64.bat itself):
cd src\ZeroGC\native
.\build.ps1This produces native\ZeroGC.dll (Release, x64). The script defines
FEATURE_MANUALLY_MANAGED_CARD_BUNDLES to match the EE's own build
configuration on AMD64/ARM64 — this define is required; without it the
write barrier's card-bundle table handling silently mismatches the EE side
and the process fails fast on the very first write barrier (see "Debugging
notes" below for the full story).
Requires clang++ (or g++) with C++17 support and a local .NET SDK. Same
dotnet/runtime checkout, used only as a read-only header dependency:
cd src/ZeroGC/native
./build-linux.sh --runtime-repo /path/to/runtimeThis produces native/obj-linux/Release/libZeroGC.so (x64). The Windows
Win32 APIs ZeroGC's own code calls directly (VirtualAlloc/VirtualFree,
CRITICAL_SECTION, QueryPerformanceCounter, the raw Interlocked*
functions, GlobalMemoryStatusEx) are provided on Linux by a small
portability shim, native/ZeroGCPal.h, built on mmap/mprotect,
pthread_mutex_t, clock_gettime, and GCC/Clang atomic builtins — the
vendored gc/env/gcenv.* headers already define the portable pieces
(HRESULT, BOOL, S_OK, etc.) since the real GC ships on Linux/macOS too.
ZeroGCHeap.cpp/ZeroGCHandles.cpp are unmodified: the shim reproduces the
same Win32 function names so no call sites needed to change. This has been
validated (correctness, not yet performance-benchmarked) under WSL2 Ubuntu
by running ConsoleApp, HandleStressBench, and WriteBarrierBench
published as linux-x64 self-contained.
See docs/zerogc/using-prebuilt-binaries.md
for the full, verified instructions (NuGet package feed, both the
lightweight "copy one file" option and the dotnet publish -r option).
Short version:
Copy ZeroGC.dll (Windows) or libZeroGC.so (Linux) next to the app's
built/published output, then:
$env:DOTNET_GCName = "ZeroGC.dll"
dotnet YourApp.dllDOTNET_GCName=libZeroGC.so dotnet YourApp.dllUnset (Remove-Item Env:DOTNET_GCName / unset DOTNET_GCName) or set to
nothing to fall back to the regular (Workstation/Server) GC.
Eleven workloads are benchmarked, each once per GC configuration (Workstation, Server, ZeroGC):
-
ConsoleApp — a tight allocation loop (allocates short strings/objects into a
List<T>sized batch, throttled viaThread.Sleep(1)per outer iteration) that runs for a configurable duration and prints throughput and GC counters as a##RESULT##{json}line. -
WebApi — a minimal ASP.NET Core Kestrel app exposing
/api/work(an allocation + JSON-serialization endpoint) and/api/ping; on startup, it spawns 8 concurrent in-processHttpClientworker loops that self-drive load against/api/workfor a configured duration (a single throttled loop tops out around ~60 req/sec due to Windows timer granularity, too little to exercise the GC meaningfully — 8 concurrent workers scale throughput roughly 8x while keeping each worker's own memory growth rate, and therefore the total run's memory growth, bounded and predictable). Reports the same##RESULT##JSON shape as ConsoleApp and exits. -
GCPerfSim (
samples/GCPerfSim/) — vendored unmodified from dotnet/performance (MIT licensed), used for 3 more realistic allocation-pattern scenarios that the two hand-written apps above don't exercise well on their own:- gcperfsim-webserver: moderate-survival, high-throughput SOH churn
modeling per-request allocation (
-tc 4 -tlgb 0.5 -sohsr 200-3000 -sohsi 15). - gcperfsim-cache: LOH-heavy, higher-survival workload modeling a
cache/large-buffer-heavy service (
-tc 2 -tlgb 0.3 -lohar 100 -lohsr 100000-300000). - gcperfsim-churn: many threads, small objects, low survival, modeling
high-throughput transient object churn (
-tc 8 -tlgb 0.1 -sohsr 200-600 -sohsi 200).
All three scenarios pass
-at simple(GCPerfSim'sSimpleItemallocation type) and add-c 300000(extra CPU compute between allocations, which dominates wall-clock time far more than GC pause overhead does — this keeps run duration roughly comparable across all 3 GC modes even though ZeroGC has zero pause time).-at simpleis a deliberate, permanent choice: GCPerfSim's defaultReferenceItemallocation type has a pre-existing bug in its survivor-list bookkeeping (OldArr.NonEmptyLengthcan underflow inMemoryAlloc.DoSurvive, later causing anIndexOutOfRangeException) that reproduces intermittently at the multi-hundred-second scale used here;SimpleItemavoids that code path entirely and was verified crash-free across all 3 scenarios at full scale. Each scenario is bounded by-tagb(total allocation across all threads, scaled from aBaseTagbFor600sconstant inrun-benchmarks.ps1) rather than a wall-clock timeout, since GCPerfSim's own-totalMinsis unreliable as a hard stop and-tagbis also the memory-safety mechanism that keeps a ZeroGC run's worst-case memory bounded regardless of allocation speed. - gcperfsim-webserver: moderate-survival, high-throughput SOH churn
modeling per-request allocation (
-
ZeroAllocApp (
samples/ZeroAllocApp/) — a hand-rolled, genuinely near-zero-allocation numeric workload: dense 96x96 float matrix multiplication on buffers allocated once at startup and reused every iteration (the output buffer is cleared withArray.Clear, not reallocated). Reports the same##RESULT##JSON shape as ConsoleApp, plusBytesAllocatedThisThreadDuringRun(aGC.GetAllocatedBytesForCurrentThread()delta) to make the "near-zero-alloc" claim empirically verifiable. The point of this scenario is the opposite of the others: it demonstrates that when an app doesn't allocate, the GC's collection strategy (including never collecting at all, as ZeroGC does) can't meaningfully affect it — all 3 GC modes should show near-identical throughput and ~0 collections. -
dotLLM serve (
dotllm-servescenario) — a real-world, near-zero-alloc .NET application: dotLLM, a from-scratch C#/.NET LLM inference engine that avoids managed-heap allocations on its hot inference path (it usesNativeMemory.AlignedAllocfor tensors). The harness startsdotllm serve <model> --no-browser --no-ui(an OpenAI-compatible HTTP API onlocalhost:8099, tested here with the tinyQuantFactory/SmolLM-135M-GGUFQ4_K_Mmodel for CI-friendly run times), waits for it to become ready, then drives sequentialPOST /v1/completionsrequests for the run's duration from a background job whiledotnet-counterscaptures the usual counters.- Setup:
dotnet tool install -g DotLLM.Cli --prerelease, thendotllm model pull QuantFactory/SmolLM-135M-GGUF --file SmolLM-135M.Q4_K_M.gguf. - Unlike the other scenarios, the target process here never exits on its
own —
run-benchmarks.ps1uses a separateInvoke-MonitoredServerRunhelper that invokesdotnet-counters collect --duration <span>so it auto-stops and flushes a valid CSV after a fixed time span regardless of the server's lifetime, then force-kills the server only afterdotnet-countershas already exited cleanly. (Killing the target first causesdotnet-countersto throwServerNotAvailableExceptionand write no CSV at all — a real failure mode hit and worked around during development of this scenario.) A synthetic per-secondoperationsseries is built from the background HTTP load-driver's own completed- request timestamps and merged in the same shapedotnet-countersproduces, so it plugs into the existing stats/percentile pipeline unmodified.
- Setup:
-
dotLLM serve, 1.5B model (
dotllm-serve-1_5bscenario) — the same real-world dotLLM engine, but with a meaningfully heavier configuration: a real ~1 GB quantized 1.5-billion-parameter model (Qwen/Qwen2.5-1.5B-Instruct-GGUF,Q4_K_M) instead of the ~100 MB 135M model, fed a few-hundred-token prompt (a technical passage about GC generations, ~295 tokens as tokenized by the model) instead of a handful of words. This exercises a substantially larger resident model + a much longer prefill/attention pass than the tiny short-prompt scenario, while remaining the same near-zero-managed-alloc inference workload (localhost:8100, otherwise identical harness plumbing todotllm-serve).- Setup:
dotllm model pull Qwen/Qwen2.5-1.5B-Instruct-GGUF --file qwen2.5-1.5b-instruct-q4_k_m.gguf. - On this machine, each request (prefill of ~295 prompt tokens + 64
generated tokens, CPU inference) takes on the order of tens of
milliseconds to ~20 seconds depending on whether dotLLM's prompt cache
can reuse KV-cache state from a previous identical prompt (enabled by
default) — see
results/report.htmlfor the exact measured throughput and memory numbers across all 3 GC modes.
- Setup:
-
GrowingCacheApp (
samples/GrowingCacheApp/,growing-cachescenario) — the opposite of the two zero-alloc scenarios above, but unlike an earlier version of this scenario, it never forces a collection: it models a realistic, very common production pattern — a long-running service (a session store, product-catalog cache, feature store, etc.) whose in-memory cache keeps accumulating long-lived entries for its entire lifetime, while concurrently handling ordinary "requests" that allocate short-lived garbage. The long-livedDictionary-backed cache grows roughly linearly with elapsed time (not allocated up front), so gen2's live-object count — and therefore mark-phase cost — grows with it. Every gen2 collection observed in this scenario is triggered entirely by the CLR's own allocation-budget/promotion heuristics; the program never callsGC.Collect(). Gen2 activity is only observed, never induced, via two purely-informational APIs sampled every ~50 ms:GC.CollectionCount(2)(did a gen2 collection just happen?) andGC.GetTotalPauseDuration()(.NET 5+'s cumulative GC pause-time counter) — the delta of the latter whenever the former increments approximates that collection's duration, with no side effects on the GC itself. -
gcperfsim-mt-throughput (GCPerfSim,
-tc 16 -tlgb 0.3 -sohsr 100-2000 -sohsi 50 -lohar 0 -at simple -c 0) — the one scenario in this suite deliberately designed to be GC-bound rather than compute-bound, to answer the natural question the other nine leave open: if Workstation and Server GC show no measurable throughput difference anywhere else, is that because Server GC has no advantage, or because none of these workloads stress the GC enough to reveal one? All threegcperfsim-*scenarios above pass-c 300000(heavy compute between allocations), which makes wall-clock time dominated by compute rather than allocation/collection — this is why they show no Workstation-vs-Server gap. This scenario uses-c 0(no compute delay at all) and 16 concurrently-allocating threads (matched to this machine's 16 logical cores), so allocation/collection cost dominates wall time instead. Because it runs at multi-GB/s allocation rates, it is intentionally a short burst (bounded by-tagb, same mechanism as the other GCPerfSim scenarios) rather than a full ~600 s run — a-tagblarge enough to sustain multiple minutes at these rates would force ZeroGC (which retains 100% of everything ever allocated) to hold hundreds of GB, well past what this machine's RAM can safely absorb. Two findings fall out of this scenario that don't appear anywhere else in the suite:- Server GC's multi-heap design shows a real, large throughput/pause
advantage under genuine GC pressure: with enough concurrent
allocating threads and no compute to hide behind, Server GC's per-core
heaps and parallel GC threads clear the same allocation volume multiple
times faster than Workstation GC's single heap — see
results/report.htmlfor the measured gap. - "Never collecting" is fastest, but only once the allocator itself is
lock-free. ZeroGC's arena allocator now hands each OS thread its own
private, contiguous slice via a single lock-free interlocked add (see
"Known limitations" above) — with that change, ZeroGC is the fastest
of all three GC modes in this scenario, faster than Server GC's
parallel heaps and far faster than Workstation GC's single heap (see
results/report.htmlfor the measured numbers). An earlier version of ZeroGC's allocator funneled every thread's allocation-context refill through one shared bump pointer guarded by a single global lock; under this scenario's 16-thread concurrent allocation pressure that single lock was the bottleneck, and ZeroGC ended up no faster than Workstation GC despite doing zero GC work — a useful reminder that a production-quality "never collect" GC still needs a scalable multi-context allocator to actually realize its "no pause, ever" advantage under high thread counts.
- Server GC's multi-heap design shows a real, large throughput/pause
advantage under genuine GC pressure: with enough concurrent
allocating threads and no compute to hide behind, Server GC's per-core
heaps and parallel GC threads clear the same allocation volume multiple
times faster than Workstation GC's single heap — see
-
gcperfsim-mt-throughput-moderate (GCPerfSim, same as
gcperfsim-mt-throughputabove but-c 1000instead of-c 0) — a less artificial variant answering a follow-up question: does the GC-mode gap seen at-c 0(zero compute between allocations — a fairly extreme, synthetic condition) survive once some real compute is mixed in? Measured directly against this ZeroGC build at a few-cvalues (-tagb 45, all else identical):-c 0gives Workstation ~3.5 GB/s, Server ~10.6 GB/s, ZeroGC ~23.3 GB/s (a ~6.6x spread);-c 1000narrows that to Workstation ~3.0 GB/s, Server ~4.7 GB/s, ZeroGC ~5.2 GB/s (~1.7x); by-c 5000the three modes converge to within ~13% of each other (Server and ZeroGC both edge out Workstation only slightly), and by-c 20000the gap is down to ~6% — pure measurement noise — because compute now dominates wall time almost completely.-c 1000was chosen as the scenario's fixed parameter because it's the largest tested value that still shows a clear, non-noise GC-mode difference: the allocator/GC really is doing meaningfully different amounts of work per GC mode here, it just isn't the only thing happening, unlike the-c 0extreme. Seeresults/report.htmlfor the exact measured numbers from the harness run (which can differ modestly, run to run, from the exploratory numbers above). Takeaway for interpretinggcperfsim-mt-throughput's MB/s numbers more broadly: they are a ceiling/headroom measurement, not a business-code throughput predictor — real request-handling code is dominated by far more than raw allocation cost (I/O, serialization, business logic, etc.), so the size of the GC-mode gap you'll actually see depends entirely on how allocation-bound your specific workload is; the nine other scenarios in this suite (all realistic, non-synthetic workloads) show ~1-2% variance across GC modes precisely because they aren't allocation-bound in this way.- On this machine, over a 10-minute run growing the cache to ~30.8M
entries (~4 GB): Workstation GC shows 11 naturally-triggered gen2
events averaging ~568 ms (max ~2,349 ms); Server GC shows 13
events averaging ~606 ms (max ~2,176 ms) — both starting in the
low milliseconds early in the run and climbing steadily as the cache
grows, entirely as a side effect of the workload's own allocation/
retention pattern (no forced collections anywhere). ZeroGC shows
exactly 0 gen2 events (its
GarbageCollect()innative/ZeroGCHeap.cppis a documented no-op), so its pause time stays at 0 ms for the entire run regardless of how large the cache gets. - The observed pause samples are reported directly in the
##RESULT##JSON (ObservedGen2Events,ObservedGen2PauseAvgMs/MaxMs/MinMs/SamplesMs, plusFinalCacheEntryCount) and shown as extra comparison-table rows specific to this scenario, alongside the usual independently-sampleddotnet-counterspause-time series used for the percentile table and charts.
- On this machine, over a 10-minute run growing the cache to ~30.8M
entries (~4 GB): Workstation GC shows 11 naturally-triggered gen2
events averaging ~568 ms (max ~2,349 ms); Server GC shows 13
events averaging ~606 ms (max ~2,176 ms) — both starting in the
low milliseconds early in the run and climbing steadily as the cache
grows, entirely as a side effect of the workload's own allocation/
retention pattern (no forced collections anywhere). ZeroGC shows
exactly 0 gen2 events (its
Two additional microbenchmark apps (not part of the 11-scenario suite
above, and not driven by run-benchmarks.ps1/dotnet-counters) are used
to validate specific ZeroGC-internal fixes in isolation — see
"Performance deep-dive" below for what they measure and why:
- WriteBarrierBench (
samples/WriteBarrierBench/) — isolates the write barrier's own per-store cost by repeatedly overwriting reference fields on a small, cache-resident, per-thread-private object ring (no cross-thread contention on the objects themselves), plus a deterministic correctness check. - HandleStressBench (
samples/HandleStressBench/) — isolatesGCHandle.Alloc/Freethroughput under concurrent load from independent threads, plus a correctness check that every handle'sTargetround- trips exactly and recycled slots behave correctly after being freed and reallocated.
Both hand-written sample apps have their own global.json + empty
Directory.Build.props/.targets overrides (GCPerfSim and ZeroAllocApp
too) so they build independently of this repo's root Arcade-SDK-based
build files.
To publish a sample manually:
cd src\ZeroGC\samples\ConsoleApp # or WebApi, GCPerfSim, ZeroAllocApp, GrowingCacheApp
dotnet publish -c Release -r win-x64 --self-contained -o publish
copy ..\..\native\ZeroGC.dll publish\dotnet tool install --global dotnet-counters # one-time
$env:PATH += ";$env:USERPROFILE\.dotnet\tools"
cd src\ZeroGC
.\run-benchmarks.ps1 -DurationSeconds 600This runs all 9 workloads x 3 GC modes (Workstation, Server, ZeroGC) = 27
runs total, each for -DurationSeconds (default 600s/10 min for GCPerfSim
scenarios; ConsoleApp/WebApi/ZeroAllocApp/dotllm-serve/dotllm-serve-1_5b/
growing-cache honor it as a wall-clock duration; GCPerfSim scenarios honor
it by linearly scaling their calibrated -tagb allocation budget, so actual
wall time is only approximately -DurationSeconds). The two dotllm-serve*
scenarios additionally require the dotllm global tool and their
respective pulled models — see above — and can be excluded via -Scenarios
if unavailable.
For every run, dotnet-counters collect attaches to the target process for
the whole duration and captures a genuine second-by-second time series (GC
pause time, working set, committed bytes, heap size, collection counts,
allocation rate, and — for ConsoleApp/WebApi/ZeroAllocApp/dotllm-serve — a
custom ZeroGC.Bench operations throughput counter) via the standard
System.Runtime EventCounters/Meters every .NET process exposes. This works
identically for ZeroGC because its IGCHeap counters
(GC.CollectionCount/GetTotalAllocatedBytes/GetGCMemoryInfo) feed the
same counters as the built-in GC — no ZeroGC-specific tooling is required.
GC-mode environment variables (DOTNET_gcServer, DOTNET_GCName) are
scoped only to each child process via ProcessStartInfo.EnvironmentVariables
— never set in the parent shell, since dotnet-counters is itself a .NET
process and would otherwise try to load the same GC config for itself.
Results are written incrementally to results\results-full.json after each
run (crash-safe: a failure partway through the suite doesn't lose already-
completed runs), then generate-report.ps1 is invoked automatically to
produce results\report.html.
Both hand-written sample workloads are throttled so a multi-minute run under
ZeroGC doesn't exhaust machine memory; each GCPerfSim scenario is bounded by
its calibrated -tagb (see above) for the same reason. ZeroAllocApp and
dotllm-serve barely allocate at all, so their memory growth is a non-issue
regardless of GC mode. Scale -DurationSeconds down on memory-constrained
machines.
See results/report.html for the full self-contained comparison report:
per-scenario comparison tables (throughput, allocation, collection counts,
heap/committed/working-set memory) for all 3 GC modes, a percentile table
(avg/p50/p90/p99 for GC pause time in ms, plus working set and throughput),
and four inline SVG charts per scenario laid out in a 2x2 grid: GC pause
time in ms over time, throughput over time, working set over time, and a
grouped avg/p50/p90/p99/max bar chart for pause time in ms —
one line/bar per GC mode.
GC pause time in ms is derived from dotnet-counters' dotnet.gc.pause.time
histogram, sampled once per second (--refresh-interval 1): each raw sample
already equals the seconds of GC pause observed within that ~1s window, so
ms = seconds-in-that-window * 1000 directly — no extra instrumentation was
needed to get an absolute-time view.
Highlights across all 9 workloads:
- Throughput/allocation-rate parity: Workstation, Server, and ZeroGC are
all within a few percent of each other on ops/sec (ConsoleApp/WebApi/
ZeroAllocApp/dotllm-serve/dotllm-serve-1_5b/growing-cache) or MB/s allocation throughput
(GCPerfSim) for every scenario — none of these workloads are GC-bound
enough at these allocation rates for GC pause time to dominate wall-clock
time, and the GCPerfSim scenarios'
-c 300000compute cost dominates further. - GC pause time: ZeroGC is always exactly 0 ms (there is nothing to pause for). Workstation/Server GC pause time stays under ~1% of wall time (a few ms per second sampled) in the naturally-occurring scenarios, even in the most GC-active one (gcperfsim-cache), consistent with modern background/concurrent GC design.
- ...but naturally-triggered gen2 GCs can and do take hundreds of ms —
and get worse over time: the
growing-cachescenario shows what the other scenarios' background/concurrent GC hides. It never forces a collection; it simply lets an ever-growing in-memory cache accumulate long-lived entries (reaching ~30.8M entries / ~4 GB by the end of the 10-minute run) while handling ordinary request-like allocation churn, and observes (viaGC.CollectionCount(2)/GC.GetTotalPauseDuration()) the gen2 collections the CLR's own heuristics decide to run — 11 of them under Workstation GC, 13 under Server GC. On this machine, Workstation GC's naturally-triggered gen2 pauses average ~568 ms (max ~2,349 ms) and Server GC averages ~606 ms (max ~2,176 ms), starting in the low milliseconds early in the run and climbing steadily as the cache grows. ZeroGC shows exactly 0 gen2 events and 0 ms pause time for the entire run, since itsGarbageCollect()is a no-op — the cache just keeps growing unchecked instead. - Memory footprint diverges sharply and predictably: ZeroGC's working set, committed bytes, and GC heap size all grow monotonically and track total bytes allocated (e.g. in gcperfsim-cache, ZeroGC's working set grows from ~1.5 GB to ~6 GB over the 10-minute run), while Workstation/Server GC keep their footprint roughly flat via periodic gen0/1/2 collections (hundreds of gen0 collections, tens of gen1, single-digit-to-low-teens gen2 over the same run).
- Zero-alloc workloads erase the difference entirely: ZeroAllocApp and dotllm-serve barely allocate anything on their hot paths, and across all 3 GC modes their throughput, working set, and collection counts are all within noise of each other — when there's (almost) nothing to collect, it genuinely doesn't matter whether the GC collects at all. This is the intended counterpoint to the GC-heavy scenarios (including growing-cache).
- "Total allocated" needs a caveat: ZeroGC reports a noticeably higher
"Total allocated" figure than Workstation/Server for the same workload.
This is expected, not a bug — see the report's inline callout: the real
GC's
GetTotalAllocatedBytes(precise: false)is a fast approximation that can undercount with many allocating threads, while ZeroGC returns its arena's exact bump-pointer position (a true, exact count).
A later pass specifically hunted for remaining bottlenecks in the
allocate-only design (beyond the per-thread-arena fix already covered
above), by reading CoreCLR's actual generated write-barrier assembly
(JitHelpers_FastWriteBarriers.asm) and the handle-table code, rather than
guessing. Two concrete issues were found and fixed, each validated with a
dedicated, isolated microbenchmark (samples/WriteBarrierBench,
samples/HandleStressBench) that measures only the mechanism in
question, decoupled from GC-pause/allocation noise.
1. Write barrier was configured backwards for a GC that never reads the
card table. ZeroGCHeap::Initialize() used to set ephemeral_low=1, ephemeral_high=MAX ("everything is ephemeral"). CoreCLR's
JIT_WriteBarrier_PreGrow64 — the barrier ZeroGC actually installs — does,
for every single reference-type field/array-element store in the entire
process:
mov [rcx], rdx ; the store itself
cmp rdx, <ephemeral_low>
jb Exit ; skip card marking entirely
; ...otherwise touch a card-table byte, and (on x64/arm64, since
; FEATURE_MANUALLY_MANAGED_CARD_BUNDLES is defined) a second,
; coarser card-bundle byte...With ephemeral_low=1, virtually every non-null reference is >= 1, so
that jb Exit almost never fires — every store pays for a card-table
read/write plus a card-bundle read/write that ZeroGC never reads back
(it never collects or scans generationally). The fix: set
ephemeral_low = ephemeral_high = m_arenaReservedEnd (an empty range
sitting above every address the arena can ever hand out). Every real
object pointer is then always < ephemeral_low, so jb Exit fires
unconditionally right after the store — the card table and card-bundle
table are never touched again after Initialize(). The equivalent C++
slow-path helpers (gchelpers.cpp's ErectWriteBarrier and friends, used
by e.g. Buffer.Memmove of reference arrays) use the same
ref >= ephemeral_low && ref < ephemeral_high check, which is also always
false for an empty range — consistent across both the JIT-generated and
VM-helper write barrier paths. (OpenJDK's Epsilon GC — the JVM's equivalent
no-op collector — reaches the same conclusion by shipping a completely
empty barrier set from the start.)
A/B result (WriteBarrierBench, single-threaded, cache-resident working
set so the barrier's own instruction cost dominates over cache-miss
latency): ~343M reference-stores/sec before → ~385M/sec after, a ~12%
throughput improvement, reproducible across repeated runs. With 8
threads (each on its own private working set): ~2.35B/sec → ~2.51B/sec
(~7%). Both configurations pass the bundled correctness check (a
deterministic ring of stores whose final state is verified exactly).
2. Handle table had reintroduced the same single-global-lock bottleneck
the arena allocator had before its own fix. ZeroGCHandleStore::AllocSlot/
FreeSlot serialized every GCHandle.Alloc/Free — and anything built on
handles under the hood (WeakReference, interop pinning,
ConditionalWeakTable, etc.) — on a single global CRITICAL_SECTION
guarding the free list. An initial fix replaced it with a lock-free
Treiber-stack free list (CAS push/pop via
InterlockedCompareExchangePointer) — but that turned out to have a
genuine ABA correctness bug, not just a benign "occasionally leak an
orphaned slot" edge case as first assumed: thread T1 can read
(head=X, next=Y), stall, and have other threads pop-then-repush X in
the meantime such that T1's eventual CAS(&head, X, Y) still succeeds —
but by then Y may itself already have been legitimately popped and be in
active use by a different thread. T1's stale CAS makes Y the new head
anyway, so a later AllocSlot on yet another thread can pop Y and hand
out the same slot a different thread is still actively using — real,
silent memory corruption. This was caught empirically (not just reasoned
about): HandleStressBench at 3+ concurrent threads reliably corrupted an
unrelated ConditionalWeakTable-backed dependent handle used internally by
System.Text.Json's reflection metadata cache, producing a subtly wrong
(empty) serialized result instead of a crash — a good reminder that a
"probably fine" lock-free data structure needs an actual concurrent stress
test, not just a paper analysis, before being trusted.
The final fix sidesteps the ABA hazard entirely instead of patching it with
a 128-bit tagged/versioned CAS: each OS thread only ever pushes to and pops
from its own (thread_local) free list, so no two threads ever contend
on the same list and no cross-thread pointer race is possible. A slot freed
on a different thread than it was allocated on is simply added to the
freeing thread's own list — still safe, still recycled, just not
necessarily by the original owner. Brand-new slots (thread-local free list
empty) still fall back to plain new, relying on the CRT heap's own
synchronization instead of a lock of our own.
A/B result (HandleStressBench, GCHandle.Alloc+Free pairs/sec):
| Threads | Before (global lock) | After (thread-local free lists) | Speedup |
|---|---|---|---|
| 1 | ~30.2M ops/sec | ~79.0M ops/sec | 2.6x |
| 8 | ~1.16M ops/sec | ~13.9M ops/sec | 11.9x |
Both configurations pass the bundled correctness check (every handle's
Target round-trips exactly, IsAllocated/Free behave as documented,
and recycled slots are re-verified after being freed and reallocated).
Both fixes are pure ZeroGC-internal changes with no effect on the runtime
itself (C:\github\runtime remains untouched, as always) and no observable
behavior change for correct managed code — they only remove work ZeroGC
was doing for a card table and a lock it never actually needed.
While bringing ZeroGC up, allocations worked but the process crashed with a
CoreCLR fail-fast (EEPOLICY_HANDLE_FATAL_ERROR) on the very first object
allocated after the first arena refill. Root cause: FEATURE_MANUALLY_MANAGED_CARD_BUNDLES
is unconditionally defined for AMD64/ARM64 in the EE's own build
(clrdefinitions.cmake), which makes ErectWriteBarrier in gchelpers.cpp
unconditionally dereference a second "card bundle" byte table (coarser
granularity than the card table, shift=21) on every write barrier that dirties
a new card. ZeroGC was passing card_bundle_table = nullptr in
WriteBarrierParameters, causing an immediate invalid write. The fix:
allocate and bias a card-bundle table exactly like the card table (same
technique, different shift), and make sure ZeroGC's own build also defines
FEATURE_MANUALLY_MANAGED_CARD_BUNDLES so its logic matches the EE's
expectations for this architecture.
Lesson: a standalone GC's own preprocessor defines must match the EE's build configuration for architecture-gated ABI features like this — it's not enough to reason locally about what a bump-pointer allocator "needs"; the EE-side code paths are compiled assuming specific GC-side behavior for these define-gated features regardless of what the GC actually needs internally.
native/tools/symresolve.cpp is a small, standalone dbghelp-based tool kept
here as a debugging aid for anyone hitting similar unexplained CoreCLR
fail-fast crashes in a standalone GC. It resolves a raw instruction pointer
(from CoreCLR's createdump-based fail-fast crash dump, obtained via
DOTNET_DbgEnableMiniDump=1 + DOTNET_DbgMiniDumpName=... environment
variables) to a symbol + source line/offset using the Microsoft public
symbol server. It requires a modern dbghelp.dll/symsrv.dll pair (e.g.
from a WinDbg install) alongside its own executable — the OS-shipped
dbghelp.dll in System32 lacks symbol-server support. These DLLs and the
compiled tool binary are not checked into this repository (see
.gitignore); rebuild with cl.exe if needed.