F# Compiler Server: plan

This is a first-party design for dotnet/fsharp. It is based on main @ 99406265, SDK 11.0.100-rc.1, and Apple M-series hardware (11 cores, 18 GB). Written 2026-10-18. Implementation: branch compiler_server (diff); measured results: report.

Summary

Two components, no brokerAn in-process client inside the existing Fsc MSBuild task talks to a pool of long-lived worker processes. There is no thin-client process and no broker process.
Workers are fsc.dlldotnet fsc.dll --compiler-server-worker … is a hidden mode of the shipped compiler. Workers use the same assembly, runtimeconfig and ReadyToRun code as stock fsc, with no new binary and no SDK layout change.
Same entry point as stockThe worker runs the same function as fscmain.fs: CompileFromCommandLineArguments with ReduceMemoryFlag.No, the same argv and response file, and its own exiter.
A fresh process, simulatedBefore each compilation the worker resets counters, the file-index table, caches and option globals. It runs each compilation on a fresh thread with captured console output. There is no cache reuse by default.
Opt-inEnabled with FSharpUseSharedCompilation=true. UseSharedCompilation=false vetoes it. If anything goes wrong, the task falls back to the stock process path.
Type providers retire workersThe worker sets an explicit flag where a type-provider or compiler-tool assembly is loaded, with an assembly-load watch as a backstop. A worker that loaded one answers, then exits.

How fsc runs today

On main, Fsc is a ToolTask marked [MSBuildMultiThreadableTask] (#20506). That attribute only lets the task run on a thread inside a multi-threaded MSBuild node, using TaskEnvironment for the working directory and environment. Fsc.ExecuteTool still calls ToolTask.ExecuteTool, which starts dotnet fsc.dll @rsp once per project (Fsc.fs:758). Every compilation therefore pays runtime startup, assembly loading and JIT. Phase 1 measured that JIT is the main reason the JIT flags help.

MSBuild node(s) Fsc task (ToolTask) writes rsp, spawns process,parses stdout/stderr lines spawn ×N projects dotnet fsc.dll @proj1.rspstartup · load · JIT · compile · exit dotnet fsc.dll @proj2.rspstartup · load · JIT · compile · exit dotnet fsc.dll @projN.rspstartup · load · JIT · compile · exit Cost repeated for every project • runtime startup + assembly load • Tier-0 JIT, then re-JIT of hot code • cold caches (R2R helps but doesn't cover generics or FSharp.Core instantiations)
Today's model: one short-lived compiler process per project and target framework.

Architecture

Third-party solution (for comparison): MSBuild thin client broker workers the two dashed hops (two extra processes) are removed MSBuild node (per -m / -mt thread) Fsc task: ExecuteTool override 1 GetProcessStartInfo → cwd, env (TaskEnvironment) 2 write rsp exactly like ToolTask 3 pool key → lock a free slot 4 connect (spawn the worker if absent) 5 JSONL request → JSONL response 6 LogEventsFromTextOutput per line 7 return the exit code (or fall back to stock) Per-user server dir $TMP/fsharp-server-<user>/<key>/ slot-0.lock ⇄ pipe fsc-<key>-0 slot-1.lock ⇄ pipe fsc-<key>-1 … up to ProcessorCount exclusive file lock = this client owns this worker right now Worker pool (fsc.dll, worker mode) worker 0 (Server GC, GCHeapCount = 5) one compilation at a time; fresh thread, reset process state, captured console worker 1 … spawned on demand by the client that owns the slot exits: idle 10 min · type provider loaded · shutdown CLI GC after each response (off the critical path) pool key = fsc.dll identity + host + user + culture env
Proposed topology. The client lives in the Fsc task already loaded in every MSBuild node. Slot lock files replace the broker: the OS arbitrates which client talks to which worker, and the kernel releases the lock if a client dies.

Why this shape

Request lifecycle

Fsc task (client)slot-i.lock / pipeworker i (fsc.dll) GetProcessStartInfo(…)cwd + env as the child would get write @rsp (UTF-8 BOM, as ToolTask) open slot-i.lock FileShare.None (try 0..N-1) connect pipe fsc-<key>-i (refused → spawn worker i, then retry) {"v":1,"type":"compile","cwd":"…","args":["@/tmp/x.rsp"]} per compilation RestoreProcessState(snapshot) SetCurrentDirectory(cwd) Console.SetOut/SetError → capture new Thread(16 MB) "F# Main Thread" CompileFromCommandLineArguments exiter = throw WorkerExit n --times → STATS line at end TP / external load? → retire restore Console {"exitCode":0,"lines":[[0,"…"],[1,"…"]],"retire":false,"pid":…} LogEventsFromTextOutput(line,stdout/stderr importance) → ExitCode GC.Collect (compacting)then accept the next request slot lock released when the task returns (or the client dies)
One connection per compilation: a single request line, then a single response line. The client buffers output and logs it only after a complete response arrives. If the connection fails, nothing has been logged yet, so the stock fallback can't double-log diagnostics.

Fidelity: the worker must look like a fresh dotnet fsc.dll @rsp

Input / effectStock process gets it fromServer path
argv / response fileToolTask writes a temp .rsp (ResponseFileEncoding = UTF-8 with BOM, ResponseFileEscape), and argv = @"path"Same code path in the override, using the protected virtuals. The worker gets argv [| "fsc.exe"; "@path" |], the same as fscmain.
Working directoryProcessStartInfo.WorkingDirectory (from TaskEnvironment) or else the node's cwdTaken from GetProcessStartInfo. The worker calls Directory.SetCurrentDirectory.
CultureOn Unix the runtime derives it at startup from LANG/LC_ALL/LC_MESSAGES; on Windows from the user profile. The compiler then applies --preferreduilang and the code-page rule (fsc.fs:451) on its main thread.Identical by construction. The culture env vars are part of the pool key, and the worker is started with the env the child would have. So its process default culture equals the child's. Each compilation runs on a new thread, so the compiler's own culture logic runs unchanged and nothing leaks between compilations.
Console encodingRedirected std handlesWorkers also run with redirected std handles. Text goes over the pipe as strings. On Unix, stock is UTF-8 end to end, so the text is the same. Windows without --utf8output is a known item, listed under risks.
stdout / stderrToolTask reads lines and calls LogEventsFromTextOutput with stdout/stderr importanceConsole.SetOut/SetError capture, ordered at line granularity. The client calls the same method with the same importances. Line splitting matches Process: \r, \n and \r\n.
Exit codeQuitProcessExiter → Environment.Exit n; the catch-all in main → 1fscmain's body moves into a shared function that both paths call. The worker passes an exiter that throws, and the catch-all stays the same.
--times STATS lineA ProcessExit handler prints STATS: … to stdoutThe ILBinaryReader stats are reset per compilation, and the same line is printed into the captured stdout at the end. This repo's build passes --times to every compilation, so it matters here.
ThreadMain thread ("F# Main Thread", 8 MB stack on macOS/Linux)A new thread with the same name and a 16 MB stack, so it is never smaller than stock. StackGuard works on depth counts, so the output doesn't depend on stack size.
Runtime knobs (DOTNET_*)The process env, including FscEnvironmentVariables on the jit_flags branchIgnored, as agreed. Workers always run Server GC with GCHeapCount = max(1, cpus/2), which is 5 here, and drop the JIT flags.
Env vars the compiler reads onceThe process envFSharp_*, FSHARP_* and FCS_* values are part of the pool key, so a worker never uses stale values. Other variables are ignored for now, as agreed.

Process-state audit: what a second compilation would otherwise inherit

I searched src/Compiler for module-level mutable state and caches. The worker captures a snapshot after startup and restores it before every compilation (CaptureProcessState / RestoreProcessState in fsc.fs). Each module gets a small internal reset helper.

StateWhereWhy it mattersAction
uniqueCount (newUnique)CompilerGlobalState.fs:137output bytes ReflectedDefinitions resource name = …-<newUnique()>-… (CreateILModule.fs:371). It also feeds local names (activePatternResult<n>, PatternMatchCompilation.fs:1455) that reach PDBs.restore
stampCount (newStamp)CompilerGlobalState.fs:142diagnostics Unnamed type variables display as ?<stamp> (TypedTree.fs:2551). Ordering is by relative stamp.restore
bindIdCheckRecordSyntaxHelpers.fs:177output names Synthesized identifiers …-<n>restore
File index tablerange.fs:251output order IlxGen sorts type definitions by (FileIndex, line, col) (IlxGen.fs:2227). Files seen in an earlier compilation keep their old, smaller indexes. FSharp.Core's three TFMs share sources, and so do FSharp.Build and FCS, so the order would change. A long-lived process would also eventually wrap past maxFileIndex.truncate to snapshot
IL module reader caches + interned attrs/typerefsilread.fs:5088-5167staleness Keyed by path + mtime. Files replaced with an old mtime (NuGet zip timestamps, File.Copy) would give stale metadata.clear (opt-in reuse later)
Namespace memo tablesil.fs:67-102Pure string splitting, so harmless, but it is still a cacheclear
ILBinaryReader statsilread.fsstdout STATS line countsreset
Option globals: enableConsoleColoring, showParserStackOnParseError, showTermFileCount, progress, tracking, DebugPrint.layout*, Parsing.Flags.debugCompilerOptions.fs etc.behaviour Set by switches such as --consolecolors- and debug flags. They would stick for the following compilations.restore defaults
global_gTcGlobals.fs:2002Debug aid; holds the previous TcGlobals alivereset
Console.OutputEncoding, culturefsc.fs:524, fsc.fs:451The compiler restores the encoding itself; culture is thread-localfresh thread
AssemblyResolve / Resolving handlers, design-time assembliesDependencyManager, CompilerLocation.fs:183Process-wide, can't be undoneretire worker
Snapshot rather than zero. Counters and the file table go back to the value captured just before the first compilation, which is the same point a fresh fsc process starts from. Values consumed by lazy statics during compilation 1 are not consumed again in compilation N. That can shift later stamps, but it can't create collisions with long-lived objects, because those were created before the snapshot. The byte-for-byte tests below are the check that nothing else leaks.

Type providers and compiler extensions

Enabling and fallback

<!-- Microsoft.FSharp.Targets -->
<_FSharpUseSharedCompilation Condition="'$(FSharpUseSharedCompilation)' == 'true' and '$(UseSharedCompilation)' != 'false'">true</_FSharpUseSharedCompilation>
<Fsc … UseSharedCompilation="$(_FSharpUseSharedCompilation)" />

The SDK sets UseSharedCompilation=true by default (Microsoft.NET.Sdk.targets:1172 and Microsoft.Managed.Core.targets:57). Reusing that property would turn the F# server on for everyone. So the opt-in is a new property, and the existing one can only veto.

The server path is skipped (stock behaviour) when any of these hold:

The task falls back to stock when:

Cancellation (Ctrl+C) kills the worker the task is talking to, so a cancelled compilation can't keep writing outputs that race with the next build. A new worker is started on demand.

Pool, slots and lifetime

AspectDecision
Pool keySHA-256 (first 16 hex chars) of: fsc.dll full path, size and mtime; host path; user name; LANG/LC_ALL/LC_MESSAGES/DOTNET_SYSTEM_GLOBALIZATION_*; FSharp_*/FSHARP_*/FCS_*; protocol version; heap count. A rebuilt compiler or a different locale gets a separate pool, so stale workers are never reused.
Slotsslot-0..N-1.lock with N = ProcessorCount. The client tries them in order, so low slots, which are warm, get reused first. In -mt mode concurrency isn't capped by the node count, so N is set by CPUs, not nodes.
Pipesfsc-<key>-<slot>, with PipeOptions.CurrentUserOnly on both ends. The short name stays under the 104-byte Unix socket path limit on macOS.
SpawnThe slot owner starts dotnet fsc.dll --compiler-server-worker <key> <slot> <dir> with the child env from GetProcessStartInfo, minus the JIT flags, plus DOTNET_gcServer=1 and DOTNET_GCHeapCount. Std handles are redirected and closed, so the worker never holds MSBuild's output pipes open.
Idle exit10 min. Before exiting, the worker takes its own slot lock, so it can't race a client that's connecting.
MemoryA compacting GC runs after each response, off the critical path. With a fixed heap count, DATAS is off.

Protocol

JSONL over a named pipe (a Unix domain socket on macOS and Linux): one request line, then one response line per connection, written with System.Text.Json's Utf8JsonWriter and Utf8JsonReader. The STJ source generator only works with C#, and both ends are F#. Hand-written reader and writer code for two small message types is a few dozen lines with no reflection, so startup cost is low. FSharp.Build (netstandard2.0) references STJ compile-only (ExcludeAssets="runtime"). The server path only runs on .NET Core MSBuild, where STJ is part of the shared framework, so no new DLL ships.

→ {"v":1,"type":"compile","cwd":"/src/proj","args":["@/tmp/MSBuildTemp/tmp1a2b.rsp"]}
← {"v":1,"exitCode":0,"retire":false,"pid":48211,"lines":[[1,"warning FS3370: …"],[0,"STATS: #ByteArrayFile = …"]]}
→ {"v":1,"type":"shutdown"}

Stop CLI

Code changes (dotnet/fsharp branch compiler_server)

FileChange
src/FSharp.Build/CompilerServerProtocol.fs (new, also linked into fsc)Pool key, paths, pipe names, JSONL request/response, and a LangVersion 9-safe subset
src/FSharp.Build/Fsc.fsUseSharedCompilation parameter; the server branch of ExecuteTool; fallback; Cancel
src/FSharp.Build/Microsoft.FSharp.Targets, FSharp.Build.fsprojOpt-in property; compile-only STJ reference
src/fsc/fscmain.fs, src/fsc/CompilerServer.fs (new)The shared runCompilation; the worker loop; the shutdown CLI
src/Compiler/Driver/fsc.fs(i)CaptureProcessState / RestoreProcessState, and the extension-load flag
Small internal reset helpersCompilerGlobalState.fs, range.fs, CheckRecordSyntaxHelpers.fs, ilread.fs (stats), il.fs, CompilerLocation.fs / DependencyProvider.fs (load flag)

The SDK side needs nothing: a new FSharp.Build plus fsc.dll ship in sdk/<v>/FSharp/ as usual.

Test and benchmark plan

Correctness: byte-identical to stock

  1. Build FSharp.Compiler.Service.slnx (17 compilations: FSharp.Core ×3 TFMs, FCS ×2, …) with the stock R2R layout of main. Snapshot every obj/**/*.dll|pdb|xml and refint assembly, and keep the MSBuild warnings and messages.
  2. Rebuild with the server layout and FSharpUseSharedCompilation=true, cold and then warm with workers reused. Compare every output byte for byte, plus the diagnostics text in order.
  3. Stress reuse ordering: force a single worker (FSharpCompilerServerMaxWorkers=1, a test knob) so each worker compiles many projects in sequence. Run with -m:1 and a reversed project order.
  4. Spot checks: a compilation with errors (exit code and messages), --consolecolors-, a type-provider project (worker retires), a non-English LANG, and killing a worker mid-compilation (fallback).

Performance: whole dotnet build of the slnx

ConfigCompilerNotes
baselinestock main R2R layoutone process per project
baseline + JIT flagssameDOTNET_TieredPGO=0 and DOTNET_TC_CallCountingDelayMs=0 on fsc processes only
servercompiler_server R2R layoutworkers: default JIT settings, Server GC, GCHeapCount = 5

For each config: one warm-up build, then two measured builds, as requested. Each run is dotnet build -c Release --no-restore --no-incremental with MSBuild node reuse off, so the only state that survives between builds is the server's workers. F# workers are stopped between configs. I'll report wall and CPU time per build, plus the server's cold first build separately, since it includes worker startup and JIT. Two runs on a desktop machine with roughly 3–5% noise can only show large effects.

Risks and open items

Reliability later (designed for, not built now)