F# Compiler Server: results
Compiler repos/server @ 2df01fc34c, built solution: dotnet/fsharp
main @ 99406265c8. Recorded 2026-10-18T09:48:01+0100. Plan: plan.html.
Code: compiler_server (diff). Later, more complete results: report.
Whole build of FSharp.Compiler.Service.slnx, mean of the measured runs: compiler server 48.8 s vs stock 66.1 s (1.35×) and vs stock + JIT flags 45.9 s (0.94×).
Findings
Correctness
- Identical to stock. Across all 9 server builds (cold and warm), every compilation output matches a hash that a stock build also produced. That covers 17 assemblies with embedded PDBs, 17 XML docs and the reference assemblies. The diagnostics match too.
- No fallbacks. In every server build, workers served all 17 compilations and none fell back to a stock process. No worker retired, since this solution doesn't load type providers during compilation.
- Stock
mainis still non-deterministic for reference assemblies under load.- Three stock builds produced three different
refint/FSharp.Compiler.Service.dll(net11.0), and one stock build produced a differentrefint/FSharp.Core.dll(net10.0). The main assemblies were identical every time. - Replaying the FCS compilation with the stock compiler: three replays one after another gave identical output. Four replays running at the same time gave two variants.
- The variants differ in 36 bytes, which look like the PE timestamp, the MVID and one other 16-byte field. In a deterministic build the timestamp and MVID are derived from the signature hash (
SignatureHash.calculateSignatureHashOfFiles,fsc.fs:869). So that hash still varies with thread timing, even with #19751 (85e2e2189, included here) fixed. - The server outputs are always one of the stock variants.
- Three stock builds produced three different
Performance (whole build, 11-core M-series)
- As requested (1 warm-up + 2 runs): stock 66.1 s, stock + JIT flags 45.9 s, server 48.8 s (runs of 55.2 s and 42.5 s). The server is 1.35× faster than stock and 6% slower than the flags.
- Longer server sequence (1 warm-up + 5 runs, extra): 43.4, 46.3, 53.0, 45.3 and 37.9 s, mean 45.2 s. That is 1.46× faster than stock and level with the flags (45.9 s).
- CPU drops sharply. The server uses about 265 s of CPU per build, against 491 s for stock (−46%) and 342 s for the flags (−23%). The work is no longer re-JIT'd in every process.
- The server is noisy. Warm server builds range from 37.9 to 55.2 s, while stock and flags repeat within about 1 s. The likely cause is which warm worker picks up the large compilations, which are on the critical path: the client takes the lowest free slot, so the assignment changes from build to build. The 5-heap GC configuration is another candidate. Neither has been measured yet.
- The cold server build costs about the same as stock (68.7 and 78.5 s, against 73.0 s for the stock warm-up). Up to 9 workers start and JIT the compiler from scratch.
- Memory. Up to 9 workers stay alive. Their combined RSS right after a build is 3.5–4.6 GB, measured before the idle GC compaction runs.
Next experiments
- Worker affinity. Send a project to the worker that compiled it last time, and big compilations to the warmest worker. That should cut the variance and the warm build time.
- Workers + JIT flags. The plan keeps custom flags out of the workers. They still pay tiering cost on code they haven't run yet, so measuring the flags on workers would show how much that costs.
- GC heap count: compare 5 heaps, DATAS and 11 heaps.
- Opt-in cache reuse (IL readers for framework and NuGet references).
Wall time per build
Speedup (measured runs)
| Config | wall s | CPU s | speedup vs stock | Δ wall vs stock | speedup vs stock + flags | Δ CPU vs stock |
|---|---|---|---|---|---|---|
| baseline (stock fsc) | 66.1 | 491.0 | 1.00× | 0.0% | 0.69× | 0.0% |
| baseline + JIT flags | 45.9 | 341.9 | 1.44× | -30.6% | 1.00× | -30.4% |
| compiler server | 48.8 | 263.7 | 1.35× | -26.1% | 0.94× | -46.3% |
| compiler server, longer sequence | 45.2 | 267.5 | 1.46× | -31.7% | 1.02× | -45.5% |
Every build
Outputs: the assembly (with its embedded PDB), the reference assembly and the XML doc of all 17 compilations, 48 files,
compared by SHA-256. Stock builds are not deterministic for 2 of them (net11.0/refint/FSharp.Compiler.Service.dll, net10.0/refint/FSharp.Core.dll),
so a build passes when every file matches a hash some stock build produced. Diagnostics: the distinct
warning/error FS lines of the build log.
| Config | Build | wall s | CPU s | worker CPU s | workers alive | workers started | worker RSS MB | identical to 1st stock build | outputs no stock build produced | diagnostics |
|---|---|---|---|---|---|---|---|---|---|---|
| baseline (stock fsc) | warmup | 73.0 | 479.5 | 0.0 | 0 | 0 | 0 | 48/48 | none | same |
| baseline (stock fsc) | run1 | 66.9 | 493.9 | 0.0 | 0 | 0 | 0 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| baseline (stock fsc) | run2 | 65.3 | 488.1 | 0.0 | 0 | 0 | 0 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| baseline + JIT flags | warmup | 46.0 | 342.2 | 0.0 | 0 | 0 | 0 | 46/48 | none other stock variant: FSharp.Compiler.Service.dll, FSharp.Core.dll | same |
| baseline + JIT flags | run1 | 45.7 | 341.4 | 0.0 | 0 | 0 | 0 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| baseline + JIT flags | run2 | 46.1 | 342.4 | 0.0 | 0 | 0 | 0 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| compiler server | warmup | 68.7 | 478.3 | 415.6 | 9 | 9 | 4015 | 48/48 | none | same |
| compiler server | run1 | 55.2 | 289.2 | 230.1 | 9 | 0 | 4259 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| compiler server | run2 | 42.5 | 238.2 | 186.4 | 9 | 0 | 4030 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| compiler server, longer sequence | warmup | 78.5 | 383.1 | 326.8 | 9 | 9 | 5825 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| compiler server, longer sequence | run1 | 43.4 | 260.1 | 201.2 | 9 | 0 | 3991 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| compiler server, longer sequence | run2 | 46.3 | 305.9 | 240.9 | 9 | 0 | 4579 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| compiler server, longer sequence | run3 | 53.0 | 272.1 | 211.2 | 9 | 0 | 3458 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| compiler server, longer sequence | run4 | 45.3 | 258.9 | 197.8 | 9 | 0 | 4135 | 47/48 | none other stock variant: FSharp.Compiler.Service.dll | same |
| compiler server, longer sequence | run5 | 37.9 | 240.6 | 178.3 | 9 | 0 | 3576 | 48/48 | none | same |
Who compiled what
From the build logs (binlog replay): compilations served by a worker, compilations that fell back to a stock process, and how the 17 compilations spread over workers.
| Build | by a worker | stock fallback | per worker |
|---|---|---|---|
| server warmup | 17 | 0 | 5 · 4 · 2 · 1 · 1 · 1 · 1 · 1 · 1 |
| server run1 | 17 | 0 | 6 · 5 · 2 · 2 · 1 · 1 |
| server run2 | 17 | 0 | 6 · 5 · 3 · 1 · 1 · 1 |
| server_long warmup | 17 | 0 | 7 · 3 · 1 · 1 · 1 · 1 · 1 · 1 · 1 |
| server_long run1 | 17 | 0 | 7 · 5 · 3 · 1 · 1 |
| server_long run2 | 17 | 0 | 5 · 5 · 2 · 1 · 1 · 1 · 1 · 1 |
| server_long run3 | 17 | 0 | 6 · 4 · 2 · 2 · 1 · 1 · 1 |
| server_long run4 | 17 | 0 | 6 · 5 · 2 · 2 · 1 · 1 |
| server_long run5 | 17 | 0 | 7 · 5 · 2 · 1 · 1 · 1 |
Method
- Command:
dotnet build FSharp.Compiler.Service.slnx -c Release --no-restore --no-incremental -nodeReuse:false, compiler, FSharp.Build and targets from a layout viaFSharpTargetsPath,FSharpPropsShim,FSharpTargetsShim,FSharpOverridesTargetsShim. - Stock and flags use
layouts/baseline(main, ReadyToRun, SDK runtimeconfig). Flags are applied to the fsc processes only, through anFscToolExewrapper. The server useslayouts/server(the same R2R build of the server branch) withFSharpUseSharedCompilation=true; workers run default JIT settings, Server GC and GCHeapCount = max(1, cores/2). - CPU: rusage of the build process tree plus the CPU that live workers used during the build (ps before and after).
- Hardware: