F# Compiler Server: making it faster

Per-compilation and whole-build experiments on the compiler server branch (repos/server), 2026-10-18, 11-core Apple M-series. Plan: plan.html · first results: results.html · code: compiler_server (diff).

Summary

Findings, ranked by benefit for the effort

#ChangeMeasured effectComplexityVerdict
1Keep the compiler warm: compile in long-lived workers (the server)Per compilation (5 projects summed): wall -25%, CPU -36% vs stock + JIT flags (vs stock: -58%); FSharp.Build 0.21×, FCS 0.89× of flags. Whole builds with the tuned workers (rows 2–3): Fantomas -22% wall / -44% CPU vs flags (-35% vs stock); FSharp.Compiler.Service.slnx -12% wall / -29% CPU vs flags.built (this branch)Done.
2Workers: DOTNET_TC_CallCountingDelayMs=0, Tiered PGO keptCold first FCS compilation 27.6 s → 17.5 s (-37%); steady state unchanged (18.8 vs 18.7 s). Build with fresh workers (FCS slnx warm-up): 58.2 s → 47.3 s, on par with stock + flags.1 line (client)Done: now the default.
3Workers: non-concurrent GC (DOTNET_gcConcurrent=0; GCSettings.LatencyMode=Batch per compilation is equivalent)Per compilation: CPU -13%, wall -5%. FCS slnx build: 44.4 s → 39.3 s (-12%).1 line (client)Done: now the default.
4Workers: GC heap count (cores/2 vs all cores vs DATAS)Alone, 11 heaps make FCS -12% faster, but in a parallel build they are a disaster: 58.6 s vs 40.0 s (+47%). DATAS: FCS slnx 37.7 s (more memory: up to 7.0 GB vs 4.5 GB), Fantomas 20.1 vs 19.7 s with more CPU.1 lineKeep max(1, cores/2); DATAS is a reasonable alternative, not clearly better.
5Worker affinity (a project goes back to the worker that compiled it)Per compilation a dedicated worker is no faster than a shared warm one (19.4 vs 18.7 s): most compiler code paths are shared between projects. In builds it avoids sending a big compilation to a worker that has never run it.~30 lines (client)Kept; cheap. Fast tier-up (row 2) matters more.
6Keep assembly caches between compilations (IL readers, shared imported CCUs)No gain (18.5 vs 18.7 s): a warm worker spends ~0.03–0.05 s importing references.small (opt-in switch exists)Leave off: not stock-exact, and nothing to gain on these projects.
7JIT flags in workers (TieredPGO=0)Steady state +11% (both flags), +11% (TieredPGO=0 alone).1 lineRejected: Tiered PGO pays off in long-lived workers.
8No compacting GC when idleNo effect (18.8 vs 18.7 s).1 lineKeep the idle GC: it returns memory.
9Reduce allocations in the compiler (large projects)FCS allocates ~33 GB per compilation; GC is 31–39% of its busy CPU samples, JIT 2–3%: for FCS the server can only remove JIT/startup.large (compiler work)Biggest remaining lever for big projects, with or without the server.

Per-compilation results

Each compilation is replayed from the response file captured in a real build (outputs redirected). Process configs start dotnet fsc.dll per compilation; server configs send it to a worker over its socket, as the Fsc task does. 1 warm-up round, then 5 measured rounds of the 5 projects; cells are medians in seconds, with the ratio to stock + JIT flags. "Cold" columns are the warm-up round: the first time a fresh worker (or process) compiles that project.

configFCSFSharp.CoreComponentTestsTest.UtilitiesFSharp.Buildsum wall ssum CPU s cold FCS scold round sFCS GC pause s
stock
stock fsc process
23.95 1.76×10.19 2.26×6.55 1.67×3.07 1.54×1.02 1.15×44.8 1.79×170 1.53×24.545.5–
flags
stock fsc process + JIT flags (TieredPGO=0, CallCountingDelayMs=0)
13.64 1.00×4.51 1.00×3.93 1.00×2.00 1.00×0.89 1.00×25.0 1.00×111 1.00×13.725.0–
srv
worker: Server GC, 5 heaps (default)
12.17 0.89×3.02 0.67×2.50 0.64×0.87 0.43×0.19 0.21×18.7 0.75×72 0.64×27.637.14.1
srv_dedicated
worker per project (perfect affinity)
12.30 0.90×3.20 0.71×2.56 0.65×0.99 0.50×0.34 0.39×19.4 0.78×69 0.62×27.149.93.9
srv_reuse
worker: keep assembly caches between compilations
12.12 0.89×3.01 0.67×2.36 0.60×0.81 0.40×0.19 0.22×18.5 0.74×71 0.64×26.434.53.8
srv_noidlegc
worker: no idle compacting GC
12.33 0.90×3.00 0.67×2.39 0.61×0.84 0.42×0.20 0.22×18.8 0.75×72 0.64×27.135.94.0
srv_flags
worker + both JIT flags
13.31 0.98×3.59 0.80×2.75 0.70×0.95 0.48×0.21 0.24×20.8 0.83×77 0.69×14.621.94.0
srv_pgo0
worker: TieredPGO=0
13.36 0.98×3.46 0.77×2.80 0.71×0.92 0.46×0.21 0.24×20.8 0.83×77 0.69×21.229.33.9
srv_ccd0
worker: CallCountingDelayMs=0 (PGO kept)
12.32 0.90×2.90 0.64×2.61 0.66×0.81 0.41×0.20 0.22×18.8 0.75×72 0.65×17.524.44.1
srv_datas
worker: DATAS (no fixed heap count)
11.45 0.84×3.16 0.70×2.42 0.62×0.91 0.46×0.21 0.24×18.2 0.73×83 0.74×26.835.63.1
srv_heaps_all
worker: 11 heaps
11.27 0.83×2.90 0.64×2.38 0.61×0.84 0.42×0.20 0.22×17.6 0.70×78 0.70×26.234.82.9
srv_nonconc
worker: non-concurrent GC
11.63 0.85×2.91 0.65×2.19 0.56×0.82 0.41×0.23 0.25×17.8 0.71×62 0.56×26.735.44.0
srv_batch
worker: GC latency Batch while compiling
11.58 0.85×2.87 0.64×2.18 0.56×0.86 0.43×0.19 0.22×17.7 0.71×62 0.56×27.235.44.0
srv_heaps_all_nonconc
worker: 11 heaps + non-concurrent GC
10.28 0.75×2.84 0.63×1.90 0.48×0.81 0.40×0.19 0.21×16.0 0.64×69 0.62×25.734.02.7
srv_heaps_all_batch
worker: 11 heaps + Batch latency
10.30 0.75×2.92 0.65×1.95 0.50×0.77 0.39×0.20 0.22×16.1 0.65×69 0.62×26.434.12.7
srv_ccd0_nonconc
worker: CallCountingDelayMs=0 + non-concurrent GC
11.58 0.85×2.91 0.65×2.14 0.54×0.78 0.39×0.19 0.21×17.6 0.70×61 0.55×17.423.94.0

Outputs: every server configuration produced assemblies identical to stock for every compilation (hashes compared per run).

Where the time goes in a large compilation (FCS)

Native sampling with macOS sample (1 ms, every thread), self samples of busy threads grouped by what runs: managed compiler code (JIT'd or ReadyToRun, unsymbolized), GC (gc_heap functions in libcoreclr), JIT (libclrjit), other runtime code, other native code. Sampling slows the compilation, so read shares, not times.

profilemanagedGCJITruntimeother
flags46.6%38.8%3.0%6.0%5.5%
srv49.8%30.6%1.6%7.4%10.6%
srvh11nc48.1%36.2%1.8%9.6%4.3%

flags = stock process with both JIT flags; srv = warm worker (default); srvh11nc = warm worker, 11 heaps, non-concurrent GC. For FCS the JIT is already small with the flags, so a warm worker can only win a little there; GC is the dominant cost.

Whole builds

dotnet build -c Release --no-restore --no-incremental, MSBuild node reuse off, 1 warm-up and 3 measured builds per config, configs run one after another. Server configs include worker affinity; their worker settings go to the workers only (FSHARP_COMPILER_SERVER_WORKER_*). CPU = build process tree + live workers.

FSharp.Compiler.Service.slnx (17 compilations, up to 9 at once)

configwarm-up (cold workers) smeasured builds smean svs flagsCPU sCPU vs flagsworkersworker RSS MB
flags46.646.1 · 45.3 · 45.645.71.00×3451.00×00
server67.145.3 · 45.4 · 42.644.40.97×2730.79×83931
server_nonconc58.238.4 · 39.1 · 40.539.30.86×2500.72×84250
server_ccd0_nonconc47.341.8 · 39.6 · 38.540.00.88×2460.71×94111
server_ccd0_nonconc_datas50.041.1 · 36.4 · 35.737.70.83×2540.73×95926
server_ccd0_nonconc_heapsall59.262.3 · 60.1 · 53.458.61.28×3070.89×94166

Fantomas (fantomas.slnx, many projects, mostly dependency chains)

Built with this repo's main compiler and targets, so -p:LangVersion=11 is passed to every config: main's targets no longer default preview SDKs to F# 11 (the SDK's do), and Fantomas.Tests does not compile with main's newer default language version.

configwarm-up (cold workers) smeasured builds smean svs flagsCPU sCPU vs flagsworkersworker RSS MB
baseline29.229.2 · 31.7 · 29.730.21.20×951.01×00
flags25.225.3 · 25.5 · 25.025.31.00×941.00×00
server_ccd0_nonconc22.519.7 · 19.7 · 19.719.70.78×530.56×34381
server_ccd0_nonconc_datas22.220.2 · 19.9 · 20.320.10.80×610.65×32367

Final: the branch's new defaults, no overrides

configwarm-up (cold workers) smeasured builds smean svs flagsCPU sCPU vs flagsworkersworker RSS MB
server FCS slnx48.344.7 · 55.3 · 41.947.3–239–94290
server FCS slnx, repeat46.245.6 · 38.0 · 40.8 · 37.5 · 37.539.9–239–94591
server FCS slnx, repeat 243.940.8 · 41.4 · 37.7 · 36.3 · 38.1 · 39.8 · 38.3 · 39.939.0–228–84707
server Fantomas21.719.8 · 19.4 · 19.019.4–52–33702

Stock + JIT flags for reference: 45.7 s (FCS slnx, table above) and 25.3 s (Fantomas). The first FCS run contains one of the unexplained slow builds (55.3 s); the repeat's first build started with 2.5 cores of outside load.

Not done yet, worth trying next