Skip to content

Latest commit

 

History

History
 
 

ZeroGC

Folders and files

README.md

ZeroGC

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-experimental feed (built and signed via the official Arcade/Azure Pipelines integration) - see docs/zerogc/using-prebuilt-binaries.md for exact consumption instructions, or "Building ZeroGC.dll / libZeroGC.so" below to build from source instead.

What works

  • Implements the full IGCHeap + IGCHandleManager/IGCHandleStore ABI 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.

Known limitations

  • 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 (ICorProfilerCallback GC 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 InterlockedExchangeAdd64 on a shared watermark, not a critical section — see ClaimArenaSlice/AllocateFromArena in native/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 single CRITICAL_SECTION, which became a real bottleneck under heavy multi-threaded allocation (see gcperfsim-mt-throughput below) — the per-thread-slice design removes that bottleneck entirely.

Repository layout

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)

Supported runtime versions & versioning policy

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.

Building ZeroGC.dll / libZeroGC.so

Windows (ZeroGC.dll)

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.ps1

This 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).

Linux (libZeroGC.so)

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/runtime

This 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.

Running an app with ZeroGC

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.dll
DOTNET_GCName=libZeroGC.so dotnet YourApp.dll

Unset (Remove-Item Env:DOTNET_GCName / unset DOTNET_GCName) or set to nothing to fall back to the regular (Workstation/Server) GC.

Sample apps and workloads

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 via Thread.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-process HttpClient worker loops that self-drive load against /api/work for 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's SimpleItem allocation 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 simple is a deliberate, permanent choice: GCPerfSim's default ReferenceItem allocation type has a pre-existing bug in its survivor-list bookkeeping (OldArr.NonEmptyLength can underflow in MemoryAlloc.DoSurvive, later causing an IndexOutOfRangeException) that reproduces intermittently at the multi-hundred-second scale used here; SimpleItem avoids 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 a BaseTagbFor600s constant in run-benchmarks.ps1) rather than a wall-clock timeout, since GCPerfSim's own -totalMins is unreliable as a hard stop and -tagb is also the memory-safety mechanism that keeps a ZeroGC run's worst-case memory bounded regardless of allocation speed.

  • 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 with Array.Clear, not reallocated). Reports the same ##RESULT## JSON shape as ConsoleApp, plus BytesAllocatedThisThreadDuringRun (a GC.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-serve scenario) — 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 uses NativeMemory.AlignedAlloc for tensors). The harness starts dotllm serve <model> --no-browser --no-ui (an OpenAI-compatible HTTP API on localhost:8099, tested here with the tiny QuantFactory/SmolLM-135M-GGUF Q4_K_M model for CI-friendly run times), waits for it to become ready, then drives sequential POST /v1/completions requests for the run's duration from a background job while dotnet-counters captures the usual counters.

    • Setup: dotnet tool install -g DotLLM.Cli --prerelease, then dotllm 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.ps1 uses a separate Invoke-MonitoredServerRun helper that invokes dotnet-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 after dotnet-counters has already exited cleanly. (Killing the target first causes dotnet-counters to throw ServerNotAvailableException and write no CSV at all — a real failure mode hit and worked around during development of this scenario.) A synthetic per-second operations series is built from the background HTTP load-driver's own completed- request timestamps and merged in the same shape dotnet-counters produces, so it plugs into the existing stats/percentile pipeline unmodified.
  • dotLLM serve, 1.5B model (dotllm-serve-1_5b scenario) — 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 to dotllm-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.html for the exact measured throughput and memory numbers across all 3 GC modes.
  • GrowingCacheApp (samples/GrowingCacheApp/, growing-cache scenario) — 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-lived Dictionary-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 calls GC.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?) and GC.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 three gcperfsim-* 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 -tagb large 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.html for 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.html for 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.
  • gcperfsim-mt-throughput-moderate (GCPerfSim, same as gcperfsim-mt-throughput above but -c 1000 instead 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 -c values (-tagb 45, all else identical): -c 0 gives Workstation ~3.5 GB/s, Server ~10.6 GB/s, ZeroGC ~23.3 GB/s (a ~6.6x spread); -c 1000 narrows that to Workstation ~3.0 GB/s, Server ~4.7 GB/s, ZeroGC ~5.2 GB/s (~1.7x); by -c 5000 the three modes converge to within ~13% of each other (Server and ZeroGC both edge out Workstation only slightly), and by -c 20000 the gap is down to ~6% — pure measurement noise — because compute now dominates wall time almost completely. -c 1000 was 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 0 extreme. See results/report.html for the exact measured numbers from the harness run (which can differ modestly, run to run, from the exploratory numbers above). Takeaway for interpreting gcperfsim-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() in native/ZeroGCHeap.cpp is 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, plus FinalCacheEntryCount) and shown as extra comparison-table rows specific to this scenario, alongside the usual independently-sampled dotnet-counters pause-time series used for the percentile table and charts.

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/) — isolates GCHandle.Alloc/Free throughput under concurrent load from independent threads, plus a correctness check that every handle's Target round- 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\

Running the benchmark suite

dotnet tool install --global dotnet-counters   # one-time
$env:PATH += ";$env:USERPROFILE\.dotnet\tools"
cd src\ZeroGC
.\run-benchmarks.ps1 -DurationSeconds 600

This 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.

Results (10-minute runs, this machine)

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 300000 compute 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-cache scenario 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 (via GC.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 its GarbageCollect() 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).

Debugging notes / lessons learned

Performance deep-dive: write-barrier and handle-store fixes

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.