ZeroGC: an allocate-only garbage collector for .NET

A word on runtimelab first. ZeroGC lives on a feature branch of dotnet/runtimelab, the .NET team’s playground “for experimentation and exploring new ideas that may or may not make it into the main dotnet/runtime repo”. Everything there - including this project - should be treated as unsupported by Microsoft. Nothing in this post implies any kind of production-grade support, and it is not a direction of .NET development. Even if I am part of .NET team, it’s a personal experiment. Treat it as one.

With that said: if you ever want to write a new garbage collector, you have to start from something. And the smallest possible something is a GC that only allocates and never reclaims memory. Meet ZeroGC.

The simplest GC that can exist

ZeroGC is a standalone CoreCLR GC, loaded via DOTNET_GCName, that implements the full IGCHeap / IGCHandleManager ABI - but whose allocator is just a bump pointer over a large reserved arena. It never collects, never compacts, never promotes, never frees. Every object you allocate stays resident for the entire life of the process. It’s the .NET cousin of Java’s Epsilon GC - a “no-op” collector.

Because it does nothing, it’s the perfect baseline: it introduces (almost) zero overhead. There are no pauses to measure, no background threads, no write-barrier bookkeeping to pay for.

The “almost” is a fun sidenote. Even a do-nothing GC has to be fast at doing nothing. For example, ZeroGC configures the ephemeral range as empty (ephemeral_low == ephemeral_high) so the JIT-emitted write barriers become near-free, and it hands out per-thread arena slices with a single interlocked add so allocation stays lock-free under many threads. Skipping work correctly still takes engineering.

Of course, there’s this “little” caveat:

Memory is never reclaimed. Long-running or allocation-heavy processes will grow endlessly until the process is killed or the machine runs out of memory. This is a feature of this GC, not a bug.

Whether that’s a dealbreaker or a non-issue depends entirely on your app - and that’s precisely what makes it interesting to measure.

A return of Zero/Upsilon GC

This isn’t a new idea for me. Years ago I built UpsilonGC, exploring the loosened GC ↔ Execution Engine coupling that .NET Core 2.0 introduced - a “Zero GC” that only allocates, and an “Upsilon GC” that actually reclaims. That work produced a couple of blog posts and a talk:

All of that is now comfortably outdated - the GC-EE interface has moved on - so ZeroGC is the modern rewrite against today’s CoreCLR.

Why do this at all?

Three reasons:

  1. A code baseline - if you want to write a serious collector, you need somewhere to start, and “empty project” is a miserable place to start from. ZeroGC is a working codebase to grow from: the full IGCHeap / IGCHandleManager surface is already implemented and already loads into a real runtime, so the very first thing you do is modify a GC that works rather than debug one that doesn’t yet. It doubles as living documentation of the GC-EE interface - every method you have to implement, with the minimum viable body already written.
  2. A performance baseline - this is what a workload looks like with GC overhead at (nearly) zero: no pauses, no background threads, no bookkeeping. That gives you a number to compare a real collector against. But read that number carefully. “GC overhead removed” is not the same as “runtime made as fast as possible”, and it would be wrong to conclude that any app with a GC plugged in will be always slower. A real collector does work that earns its keep:
    • compaction and moving objects around opens possibility to get better cache and TLB behaviour
    • reusing memory keeps the hot working set small enough to actually stay in cache
    • generational collection means fresh allocations land in memory you just touched
  3. For the curious (and slightly crazy) - you can drop it into your own app and watch what happens. It’s the cleanest way to answer “how much of my app’s profile is actually GC-bound?

What running it actually looks like

The repo ships a benchmark report comparing ZeroGC against Workstation and Server GC across eleven workloads, using dotnet-counters to capture GC pause time, memory and throughput second by second over ten-minute runs. The harness and its sample apps live in src/ZeroGC/.

Two charts tell you almost everything about a workload: how long the collector stopped you, and how much memory you were holding while it did. Here are three workloads that land in three completely different places.

The GC was never in your way

An ASP.NET Core minimal API, driven by eight concurrent workers for ten minutes.

Workstation GCServer GCZeroGC
ASP.NET Core (Kestrel) minimal API GC pause time ms per second 0 2.5 5 Working set MB 0 1,000 2,000 3,000 0 120 s 240 s 360 s 480 s 600 s Workstation GC Server GC ZeroGC Workstation GC Server GC ZeroGC worst pause of the whole run: 4.6 ms 2.7 GB and still climbing
ASP.NET Core (Kestrel) minimal API, 8 concurrent workers, 10 minutes. Drawn from the repo's full 1 Hz counter capture (596 samples per run, results/raw/); every peak is at its true height.

Look at the top panel: no pause, under any collector, ever reaches 5 ms - the worst of the entire ten-minute run is 4.6 ms. Add every single pause together and Workstation GC stopped this app for 0.14 seconds in total, Server GC for 0.57. Out of six hundred. There is no big latency problem here, so there is not much for ZeroGC to win back - unless you really want sub-millisecond (zero!) pauses. Throughput is identical to three significant figures (507.3 → 507.5 ops/s).

Now look at the bottom panel, and notice that the two real collectors are the flat line along the bottom - they hold steady around 85 MB for the whole run, because that is what a collector does. ZeroGC’s straight line is every request the API ever served, still resident. It ends the run at 2.7 GB and rising, and it would keep rising until the process died. That is 31× the memory in exchange for a latency win of exactly zero.

Real collections, but nobody notices

GCPerfSim’s cache / large-object-heavy workload - a growing pool of long-lived objects, many of them on the LOH.

Workstation GCServer GCZeroGC
GCPerfSim: cache / large-object-heavy workload GC pause time ms per second 0 10 20 Working set MB 0 2,000 4,000 6,000 0 120 s 240 s 360 s 480 s 600 s Workstation GC Server GC ZeroGC Workstation GC Server GC ZeroGC the worst pause of the entire run: 21 ms ZeroGC never gives the cache back
GCPerfSim cache / large-object-heavy workload, 10 minutes. Drawn from the repo's full 1 Hz counter capture (596 samples per run, results/raw/); every peak is at its true height.

This one really does collect, and the two collectors disagree about how. Workstation GC produces the dense blue sawtooth - a collection every second or so, none of them costing more than 4.7 ms all run. Server GC is the opposite shape: long quiet stretches punctuated by gen2 spikes, the worst reaching 21 ms.

But check the axis - the whole panel is 25 ms tall. Totalled over ten minutes, Workstation GC spent 0.71 s collecting and Server GC 0.18 s. Collections everywhere, latency nowhere. Note also that “fewer pauses” and “less time paused” are not the same property: Server GC pauses four times less in total, yet owns every one of the tall spikes.

The bottom panel is where the two real collectors earn their keep. Both hold the working set near 1 GB, roughly flat, while ZeroGC climbs steadily to 5.5 GB - about 6× more. The genuinely-live data is much the same in all three runs; the entire difference is garbage that ZeroGC is contractually unable to release.

The one where it actually pays off

A console app with a growing in-memory cache under a real request workload - the case that naturally escalates into expensive gen2 collections.

Workstation GCServer GCZeroGC
Console: growing in-memory cache with real request workload (naturally escalating gen2 GCs) GC pause time ms per second 0 250 500 Working set MB 0 2,000 4,000 6,000 8,000 0 120 s 240 s 360 s 480 s 600 s Workstation GC Server GC ZeroGC Workstation GC Server GC ZeroGC 493 ms - a request that visibly hangs all three grow together - the app keeps what it allocates
Console app, growing in-memory cache with a real request workload, 10 minutes. Drawn from the repo's full 1 Hz counter capture (596 samples per run, results/raw/); every peak is at its true height.

Now we see something. Server GC’s gen2 collections arrive on a visible frequency, and they get worse as the cache grows: about 270 ms early in the run, 493 ms by the end. Workstation GC trades those for constant small pauses plus its own 117 ms spike near the end. Totalled up, both real collectors stop the app for roughly 8.2-8.6 seconds across the run. ZeroGC is the flat green line along the bottom: not “fast”, just absent.

And the bottom panel is why this is the one workload where the bargain is worth taking. Because the app retains nearly everything it allocates, all three lines climb together. Never freeing costs about 16% more memory (6.6 GB → 7.7 GB) - and buys away every pause in the panel above. Compare that against 31× for the API that had nothing to gain in the first place.

Try it - for fun

If your app is time-bounded rather than long-running - a CLI tool, a batch job, a benchmark, a short-lived worker, an inference run that fits in RAM - ZeroGC might genuinely finish faster and quieter. You don’t have to build anything: prebuilt, signed binaries are published as NuGet packages, built by Microsoft’s official Arcade/Azure Pipelines infrastructure and pushed to the public dotnet-experimental feed.

1. Get the binary. There is one package per targeted runtime major version - Microsoft.DotNet.RuntimeLab.ZeroGC.Net10 for net10.0 apps (built against v10.0.0 GA), Microsoft.DotNet.RuntimeLab.ZeroGC.Net11 for net11.0 (tracking the previews), x64 Windows and Linux. Add the feed to a nuget.config next to your project:

<configuration>
  <packageSources>
    <add key="dotnet-experimental"
         value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json" />
  </packageSources>
</configuration>
dotnet add package Microsoft.DotNet.RuntimeLab.ZeroGC.Net10 --version 1.0.0-zerogc.26381.4

(Use whatever prerelease version is currently published - --version and --prerelease can’t be combined in one command.)

2. Put it next to your app. The package carries ZeroGC.dll / libZeroGC.so as a RID-specific native asset, so a plain dotnet build won’t flatten it next to your executable. Either copy the one file out of the NuGet cache into your build output by hand (fine for a quick experiment, easy to automate with a post-build <Copy> target)…

cp ~/.nuget/packages/microsoft.dotnet.runtimelab.zerogc.net10/1.0.0-zerogc.26381.4/runtimes/linux-x64/native/libZeroGC.so \
   bin/Release/net10.0/libZeroGC.so

…or do a RID-specific publish, which picks it up automatically:

dotnet publish -c Release -r win-x64 --self-contained false

3. Turn it on. Same knob as any other standalone GC - just the file name:

DOTNET_GCName=libZeroGC.so dotnet YourApp.dll   # Linux
$env:DOTNET_GCName = "ZeroGC.dll"; dotnet YourApp.dll   # Windows

Unset it to go back to the normal GC. If you’d rather bake it into the app than set an environment variable, the equivalent runtimeconfig.json property (configProperties."System.GC.Name") and AppContext switch work too.

4. Check it’s actually live. ZeroGC reports itself through GC.GetConfigurationValues(), but the cheapest sanity check is the behaviour itself: GC.CollectionCount(0/1/2) should stay pinned at 0 forever, and the working set should climb monotonically.

Which package should I take? Match your app’s target framework - .Net10 for net10.0, .Net11 for net11.0. That is the sanity default and what the docs recommend, though it is probably not load-bearing; what a standalone GC is actually tied to is covered below.

Run it, watch the memory graph climb, and see whether your workload is one of the “GC was invisible anyway” cases or one of the “pauses gone, memory paid” cases. Then share what you find - I’d love to see the graphs.

Building from sources

If you’re on a restricted network, need a runtime version that isn’t published, or want to hack on ZeroGC itself, building from source takes a couple of minutes. Both scripts want a local dotnet/runtime checkout, used read-only as a header and reference dependency - nothing under it is built or modified - and both default that path to my machine, so pass your own:

.\build.ps1 -RuntimeRepo C:\src\runtime          # needs the VC++ build tools
./build-linux.sh --runtime-repo ~/src/runtime    # needs clang++ with C++17

Checking out the dotnet/runtime tag matching your app’s target runtime is the tidy thing to do; any reasonably recent tag will build a working binary, for reasons covered under versioning below. Full details: docs/zerogc/using-prebuilt-binaries.md and src/ZeroGC/README.md.

Versioning: what a standalone GC is tied to

A standalone GC is loaded across a versioned ABI, described by two numbers in gcinterface.h:

#define GC_INTERFACE_MAJOR_VERSION 5
#define GC_INTERFACE_MINOR_VERSION 8

Those numbers cover more than the C++ methods. They version the whole GC/EE contract: which methods exist on IGCHeap and IGCToCLR and what they mean, and also the behaviours and data contracts that never appear as a method signature at all - including the object-model layout the GC reads directly.

There is a second number for the other direction, EE_INTERFACE_MAJOR_VERSION (currently 4), describing what the runtime side offers. The two are exchanged at load time, which is what lets each side adapt to the other.

The loader in gcheaputilities.cpp applies a single rule:

if (g_gc_version_info.MajorVersion < GC_INTERFACE_MAJOR_VERSION)
{
    LOG((LF_GC, LL_FATALERROR, "Loaded GC has incompatible major version number ..."));
    return E_FAIL;
}

The comparison is one-directional. A GC reporting an older major version is rejected; one reporting the same or a newer version is accepted. That is what permits a GC built against a newer runtime to load into an older one. A lower minor version is not rejected at all - it logs at LL_INFO100 and continues.

The major number moves rarely: it has been version 5 since January 2023 (dotnet/runtime#81188, “Fix GC interfaces versioning”), spanning .NET 8, 9, 10 and 11-preview. Keeping a newer GC loadable on older runtimes is an explicit goal on the runtime side - dotnet/runtime#88457, “Ensure GCHeap related debugging still works even when we use newer CLRGC to target older runtimes”, is maintenance for exactly that scenario. When the major version does change, the outcome is a refusal to load with E_FAIL and a logged reason.

The object layout is part of that contract

A standalone GC does not only call the interface. It also compiles against the gcenv.*.h shim headers, which mirror the VM’s private MethodTable and ObjHeader structures field for field. A GC reads object headers directly - it has to, and it cannot avoid the dependency in any case: Object, MethodTable and gc_alloc_context appear throughout the IGCHeap and IGCHandleManager signatures every standalone GC must implement.

That layout is versioned by the same numbers. The MethodTable data a collector needs to walk the object graph is a binary contract kept that way for performance, and changing it incompatibly requires a major version bump even though no C++ signature changes. dotnet/runtime#91821 (“Converge Representations between NativeAOT and CoreCLR”, September 2023) is the worked example: it moved MTFlag_Collectible from 0x10000000 to 0x00200000 and bumped EE_INTERFACE_MAJOR_VERSION from 1 to 2 in the same change, touching no interface method.

The compatibility code handles that signal, and compiles only for standalone GCs:

bool Collectible()
{
#ifdef BUILD_AS_STANDALONE
    if (g_oldMethodTableFlags)
    {
        // This flag is used for .NET 8 or below
        const int Old_MTFlag_Collectible = 0x10000000;
        return (m_flags & Old_MTFlag_Collectible) != 0;
    }
#endif
    return (m_flags & MTFlag_Collectible) != 0;
}

A GC opts into it during the version handshake. The runtime passes its own version in, the GC records it and writes its own version back - ZeroGC’s GC_VersionInfo is the whole mechanism in eight lines:

ZEROGC_EXPORT void GC_VersionInfo(VersionInfo* info)
{
    // On entry, `info` carries the interface version the runtime supports;
    // remember it so we know which optional IGCToCLR members are safe to call.
    g_runtimeSupportedVersion = *info;
    g_oldMethodTableFlags = g_runtimeSupportedVersion.MajorVersion < 2;

    info->MajorVersion = GC_INTERFACE_MAJOR_VERSION;
    info->MinorVersion = GC_INTERFACE_MINOR_VERSION;
    info->BuildVersion = 0;
    info->Name = "ZeroGC";
}

What all this means in practice

  • The net10.0 build would run on .NET 11-preview: the GC interface major version has not moved
  • Older runtimes (.NET 9, .NET 8) are plausible for the same reason, and where the object model did change, the version handshake is what lets a single binary cope.
  • If the major version is bumped, the failure is a refusal at startup with a logged reason, not corruption

For anyone writing their own collector, the practical shape of this is: the version numbers cover more than the vtable, so read them as the contract for the object model too, and use the handshake to branch when you must. Depending on MethodTable is not something to design around - every standalone GC does it, including the one in the box.




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • Introducing dotLLM - Building an LLM Inference Engine in C#
  • Visualizing logprobs from OpenAI responses
  • Logits, logprobs, and temperature
  • Simple CLI REPL for Model Context Protocol (MCP)