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
Fsc MSBuild task talks to a pool of long-lived worker processes. There is no thin-client process and no broker process.dotnet 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.fscmain.fs: CompileFromCommandLineArguments with ReduceMemoryFlag.No, the same argv and response file, and its own exiter.FSharpUseSharedCompilation=true. UseSharedCompilation=false vetoes it. If anything goes wrong, the task falls back to the stock process path.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.
Architecture
Why this shape
- No extra client process. The
Fsctask is already loaded and JIT-warm in MSBuild nodes, and FSharp.Build.dll is the library call. An AOT thin client would get the child-process environment for free, but it would mean shipping a native binary per platform from dotnet/fsharp. We can get the exact same inputs in-process by callingToolTask.GetProcessStartInfo, which is protected virtual. That call returns the exact working directory and environment the stock child would receive, including in-mtmode. - No broker. A broker exists to pick an idle worker and to start and stop workers. Per-slot exclusive file locks do the first. The slot owner starting its own worker does the second. Locks are released by the kernel when a process dies, so a crashed client can't wedge the pool.
- Worker = fsc.dll. This keeps everything that already ships with the compiler: Server GC runtimeconfig, R2R images, deps.json, FSharp.Core binding, and the reference resolver. The SDK's
FSharp/folder gains no files. The Fsc task already knows the host (DOTNET_HOST_PATH) andDotnetFscCompilerPath. - Processes, not threads. The working directory,
Console, environment variables and many compiler statics are process-wide. One compilation per process keeps fidelity simple. Compiling inside MSBuild nodes was rejected for the same reasons, and also because a compiler crash would take down the node and type providers would load into MSBuild.
Request lifecycle
Fidelity: the worker must look like a fresh dotnet fsc.dll @rsp
| Input / effect | Stock process gets it from | Server path |
|---|---|---|
| argv / response file | ToolTask 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 directory | ProcessStartInfo.WorkingDirectory (from TaskEnvironment) or else the node's cwd | Taken from GetProcessStartInfo. The worker calls Directory.SetCurrentDirectory. |
| Culture | On 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 encoding | Redirected std handles | Workers 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 / stderr | ToolTask reads lines and calls LogEventsFromTextOutput with stdout/stderr importance | Console.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 code | QuitProcessExiter → Environment.Exit n; the catch-all in main → 1 | fscmain'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 line | A ProcessExit handler prints STATS: … to stdout | The 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. |
| Thread | Main 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 branch | Ignored, 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 once | The process env | FSharp_*, 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.
| State | Where | Why it matters | Action |
|---|---|---|---|
uniqueCount (newUnique) | CompilerGlobalState.fs:137 | output 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:142 | diagnostics Unnamed type variables display as ?<stamp> (TypedTree.fs:2551). Ordering is by relative stamp. | restore |
bindId | CheckRecordSyntaxHelpers.fs:177 | output names Synthesized identifiers …-<n> | restore |
| File index table | range.fs:251 | output 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/typerefs | ilread.fs:5088-5167 | staleness 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 tables | il.fs:67-102 | Pure string splitting, so harmless, but it is still a cache | clear |
| ILBinaryReader stats | ilread.fs | stdout STATS line counts | reset |
Option globals: enableConsoleColoring, showParserStackOnParseError, showTermFileCount, progress, tracking, DebugPrint.layout*, Parsing.Flags.debug | CompilerOptions.fs etc. | behaviour Set by switches such as --consolecolors- and debug flags. They would stick for the following compilations. | restore defaults |
global_g | TcGlobals.fs:2002 | Debug aid; holds the previous TcGlobals alive | reset |
Console.OutputEncoding, culture | fsc.fs:524, fsc.fs:451 | The compiler restores the encoding itself; culture is thread-local | fresh thread |
| AssemblyResolve / Resolving handlers, design-time assemblies | DependencyManager, CompilerLocation.fs:183 | Process-wide, can't be undone | retire worker |
Type providers and compiler extensions
- Explicit hooks. A process-wide flag is set wherever the compiler loads third-party code into its own process:
getTypeProviderAssembly→Assembly.UnsafeLoadFrom(CompilerLocation.fs:183), for type-provider design-time assembliesDependencyProvider→Assembly.LoadFrom(DependencyProvider.fs:518), for--compilertooland dependency managers- the AssemblyResolve / native resolve handler installation
- Backstop. The worker subscribes to
AppDomain.AssemblyLoad. Any load from outside the compiler directory or the shared framework directory marks the worker for retirement, which catches future extension points nobody has hooked yet. This is the third-party heuristic, kept only as a safety net. - Retirement. The worker stops listening before it replies, sends
"retire":truewith its pid, and exits. The client waits for that pid to exit before it releases the slot. That ensures nothing holds the provider DLL open on Windows when the next build step copies it. - The cost is one worker restart per project that uses type providers. Every other compilation in the build still uses a warm worker.
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:
- a
HostObjectis set (VS in-process compile) DotnetFscCompilerPathis empty (.NET Frameworkfsc.exe)- MSBuild runs on .NET Framework
--pauseis usedSkipCompilerExecutionis set
The task falls back to stock when:
- no slot is free
- connecting or spawning fails or times out
- the connection drops before a response arrives (for example, a worker crash or stack overflow)
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
| Aspect | Decision |
|---|---|
| Pool key | SHA-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. |
| Slots | slot-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. |
| Pipes | fsc-<key>-<slot>, with PipeOptions.CurrentUserOnly on both ends. The short name stays under the 104-byte Unix socket path limit on macOS. |
| Spawn | The 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 exit | 10 min. Before exiting, the worker takes its own slot lock, so it can't race a client that's connecting. |
| Memory | A 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
dotnet <sdk>/FSharp/fsc.dll --compiler-server-shutdownvisits every pool dir of the current user. For each slot it takes the lock, waiting for any running compilation to finish, then sendsshutdown. Workers that don't respond are killed using the pid file the worker writes.- It uses the same DLL as the worker and the client protocol code, so there's nothing extra to install. This works now by full path.
- The natural home later is
dotnet build-server shutdown, which already stops MSBuild nodes, VBCSCompiler and Razor. That needs a small dotnet/sdk change to call the command above.
Code changes (dotnet/fsharp branch compiler_server)
| File | Change |
|---|---|
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.fs | UseSharedCompilation parameter; the server branch of ExecuteTool; fallback; Cancel |
src/FSharp.Build/Microsoft.FSharp.Targets, FSharp.Build.fsproj | Opt-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 helpers | CompilerGlobalState.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
- Build
FSharp.Compiler.Service.slnx(17 compilations: FSharp.Core ×3 TFMs, FCS ×2, …) with the stock R2R layout ofmain. Snapshot everyobj/**/*.dll|pdb|xmlandrefintassembly, and keep the MSBuild warnings and messages. - 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. - Stress reuse ordering: force a single worker (
FSharpCompilerServerMaxWorkers=1, a test knob) so each worker compiles many projects in sequence. Run with-m:1and a reversed project order. - Spot checks: a compilation with errors (exit code and messages),
--consolecolors-, a type-provider project (worker retires), a non-EnglishLANG, and killing a worker mid-compilation (fallback).
Performance: whole dotnet build of the slnx
| Config | Compiler | Notes |
|---|---|---|
| baseline | stock main R2R layout | one process per project |
| baseline + JIT flags | same | DOTNET_TieredPGO=0 and DOTNET_TC_CallCountingDelayMs=0 on fsc processes only |
| server | compiler_server R2R layout | workers: 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
- Hidden statics. The audit is a grep, so something it missed could still leak between compilations. The byte-identical tests over 17 compilations in several orders are the check; anything they find goes into
RestoreProcessState. - Windows console encoding. Without
--utf8output, stock output passes through the OEM code page, which can turn characters into?. The server sends exact strings. To be identical, the worker would have to round-trip text throughConsole.OutputEncoding. That's planned, but untested on this Mac. - Memory. Up to 11 idle workers each hold a Server-GC heap. Mitigations: the compacting GC, an idle timeout, and possibly a lower N later.
- Static state that depends on the working directory or env (for example
BinFolderOfDefaultFSharpCompiler) is computed from the compiler location, which is the same for every request. Other environment variables are ignored, as agreed.
Reliability later (designed for, not built now)
- Health and version handshake; a worker log file; structured telemetry of fallbacks.
- Memory caps and recycling after K compilations or M GB.
- Opt-in cache reuse (IL readers for immutable framework and NuGet references) for extra speed, as a separate measured experiment.
dotnet build-server shutdownintegration; multi-user/tmphardening on Linux.