CSharpMind

Server GC, Workstation GC, and the Numbers Between Them

Server GC finished 2.7 times faster and collected 17 times less often — and made the slowest request in the run 5 to 13 times slower. Both numbers are real.

5 min read
Three parallel feed rails carrying queued hex nuts, with a pivoting steel gate across the middle track holding the line back. Loose nuts wait behind it; the workbench beyond is out of focus.

The same workload, the same machine, one switch changed. Server GC finished in 977 ms; Workstation GC took 2612 ms. Server GC ran 313 generation-zero collections; Workstation ran 5228.

And Server GC produced the slowest single request in the entire run, by a factor of between five and thirteen.

Both of those come out of the same three-repeat batch, and the second one is the reason “use Server GC on a server” is advice with a missing clause.

The measurement

Eight threads, each performing two million small operations: a 2 KiB buffer, eight short strings, and every sixty-fourth iteration a 512-byte object written into a long-lived array so there is something to promote. 38 GiB allocated in total on a twelve-core machine. Each operation is timed individually with Stopwatch.GetTimestamp, so the reported distribution is per-operation latency rather than an average smeared over the run.

Three repeats of each mode, interleaved so machine drift affects both:

Workstation Server
wall clock 2579 / 2612 / 3068 ms 872 / 977 / 1001 ms
gen0 collections 5228 / 5229 / 5229 313 / 313 / 314
total GC pause 440 / 442 / 471 ms 240 / 271 / 393 ms
p50 0.04 µs 0.04 µs
p99 8.67 / 8.83 / 9.71 µs 1.08 / 1.08 / 2.96 µs
p99.99 190 / 196 / 214 µs 858 / 873 / 1019 µs
max 6.38 / 6.47 / 6.58 ms 35.6 / 44.6 / 87.1 ms

The collection counts are the most stable numbers in the table — 5228 against 313, reproducible to within one collection across every run — and everything else in the table follows from them. Allocation is identical at 38 GiB in both modes, so seventeen times fewer collections means each Server GC collection is clearing, on average, seventeen times as much. That ratio is the finding; the exact budget arithmetic behind it is not something this measurement can see, and the documentation does not state a Workstation-to-Server gen0 budget ratio to quote. What it does state is the architecture: Server GC creates a separate heap and a dedicated collection thread per core, each heap with its own gen0, collected when that heap’s allocation budget is consumed.

Total GC pause is the number to trust least here. It moved from 240 ms to 393 ms across three otherwise identical Server GC runs, and in an earlier batch on the same machine it came out higher for Server GC than for Workstation. It is a wall-clock sum of suspension windows, not a measure of what those suspensions cost the caller, and the distribution says more than the sum does.

What the distribution actually says

Read the table down to p99 and Server GC is better at latency, decisively: 1.08 µs against 8.83, because the overwhelming majority of operations never meet a collection at all and there are seventeen times fewer collections to meet.

Read one more row and it reverses. At p99.99 Workstation is at 190–214 µs and Server is at 858–1019 µs. At the maximum, Workstation’s worst operation in two million took 6.5 ms and Server GC’s took 35.6 ms in the best repeat and 87.1 ms in the worst.

That is not noise, and it is not a defect. It is the same budget difference read from the other end: rarer collections are larger collections, and an operation unlucky enough to trigger one absorbs the whole of it. A service reporting p99 will describe Server GC as an improvement. The same service reporting p99.9 or a timeout count will describe it as a regression. Both reports are accurate, which is why the number a service is actually judged on has to be decided before the GC mode is.

The related failure is a report built on averages. The median is 0.04 µs in both modes — identical to two decimal places — so any summary anchored on the mean or the median will conclude the two modes are indistinguishable, on a workload where the worst case differs by a factor of thirteen.

Which mode a process is already using

The default is Workstation GC. The documentation says so plainly, and GCSettings.IsServerGC confirms it in a console application.

But almost nothing that matters is a console application, and the templates disagree with the default. Building a project created by dotnet new web on .NET 10 and reading the generated app.runtimeconfig.json:

{
  "runtimeOptions": {
    "configProperties": {
      "System.GC.Server": true
    }
  }
}

Server GC, switched on by the web SDK, before anyone chose it. That is a defensible default for a request-serving process, but it means the answer to “which collector is this service using” is a property of the project template rather than of any decision recorded in the codebase — and it is the reason a background worker split out of a web project can silently change collectors on the way out.

Two more facts belong with it. Since .NET 9, Server GC also runs with DATAS enabled by default, which starts the process with a single heap and adds heaps as throughput cost demands; on this workload switching it off changed the wall clock from 930 ms to 936 ms and left the collection counts alone, so nothing here is attributable to it. And GC.GetConfigurationVariables() reports what is actually in effect at runtime, which is worth printing at startup in any service where the answer matters — the same reflex as watching the two thread pool counters described in where thread pool starvation actually comes from, and for the same reason: the configuration a process is running under is checkable, and guessing at it is how these investigations go wrong.


Measured on .NET 10.0.11, macOS 26.6.2 arm64, 12 cores, Release configuration, DOTNET_gcServer set per run. Each figure is from a fresh process after a warm-up phase and a forced blocking gen2 collection. Wall clock varied by roughly 20% between batches on this machine; collection counts and the shape of the latency distribution did not. The runtime configuration defaults quoted are from the .NET garbage collector configuration reference.

Frequently asked

Is Server GC simply the right choice for a server?
For throughput on a multi-core machine, generally yes, and that is what it is named for. For a service with a tail-latency objective it is a trade rather than an upgrade, because the same run that finished 2.7 times sooner also contained the slowest single request. The decision belongs to whichever of those two the service is measured on.
Does DATAS change this result?
Not on this workload. DATAS is enabled by default for Server GC from .NET 9 onward, and disabling it with the runtime configuration switch moved the wall clock from 930 ms to 936 ms and left the collection counts unchanged. On a workload whose live set grows and shrinks over time it is designed to matter considerably more.
Why does Workstation GC collect seventeen times more often?
Both modes allocated the same 38 GiB, so the difference is entirely in how much allocation each mode permits before it collects. That is also why its pauses are individually short and Server GC's are individually long — one mechanism seen from either end, rather than two separate behaviours.
Share

Related articles

Arrow keys to move, Enter to open.