Skip to content

Parallel per-file type checking with concurrent project check#861

Open
michaelglass wants to merge 3 commits into
fsprojects:masterfrom
michaelglass:perf/skip-type-check-and-fix-async-naming-overhead
Open

Parallel per-file type checking with concurrent project check#861
michaelglass wants to merge 3 commits into
fsprojects:masterfrom
michaelglass:perf/skip-type-check-and-fix-async-naming-overhead

Conversation

@michaelglass

@michaelglass michaelglass commented Jun 29, 2026

Copy link
Copy Markdown

sorry, force pushed so need to open a new PR for #846

this does help for projects with lots of local dependencies. E.g. fsharp monorepos with multiple dependent projects, for instance https://github.com/michaelglass/fshotwatch

here's my [claude's] benchmark script

results:

pathological target -- linting FSHotWatch

stage solution lint vs master
master (91fd00b0, incl. #845) ~908 s
+ #846 parallel per-file (variant C, ee218763) ~91 s ~10×
+ cross-project sharing (78f134b3) ~27 s ~33×
0.27.0 (released) ≈ 1000 s for reference. Sharing adds 2.28× on top of #846.

Non-pathological target — linting FSharpLint's own solution (flat dependency graph):

linter FSharpLint.slnx lint vs master
0.27 (released) ~27 s ~1.0×
master (91fd00b0) ~27 s
+ #846 parallel per-file (ee218763) ~27 s ~1.0× (no regression)
+ cross-project sharing (78f134b3) ~28 s ~0.96× (≈ +1 s / ~2–4%)

What makes this pathological / what the speedup is conditional on

The perf improvement scales with a solution of many local subprojects that reference each other. FsHotWatch is a good example: 13 projects, with the test/CLI projects each referencing 8–9 of the others.

On master, every project is linted in isolation — a fresh MSBuild design-time load + FCS re-type-checking that project's whole project-to-project (P2P) dependency closure. So each shared library project gets re-loaded and re-type-checked once per dependent: duplicated work that grows with the density of the dependency graph.

The two changes remove different parts of that cost:

  • Performance: parallel per-file type checking #846 parallel per-file (~10×): type-checks a project's files in parallel instead of serially — conditional on projects having many files.
  • Cross-project sharing (→~33×): one WorkspaceLoader (MSBuild design-time build runs once for the whole solution, not per project) + one shared FSharpChecker (each project and its dependencies type-checked once, cached, reused by every dependent) — conditional on many cross-referencing local subprojects.

the ~33× is conditional on lots of inter-dependent local subprojects.

Caveat

On a single project, or a flat solution whose projects share no dependencies, the sharing win largely disappears and you're left with roughly the parallel-per-file effect; on a single small project the parallelization overhead can even make it marginally slower.

@michaelglass michaelglass force-pushed the perf/skip-type-check-and-fix-async-naming-overhead branch from e9e2506 to c7d4473 Compare June 29, 2026 20:27
@knocte

knocte commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

@michaelglass hey thanks, do you mind fixing CI? But please fix it so that each commit has a green CI status.

@michaelglass michaelglass force-pushed the perf/skip-type-check-and-fix-async-naming-overhead branch 2 times, most recently from c7d4473 to 9677c09 Compare June 30, 2026 07:46
@michaelglass michaelglass force-pushed the perf/skip-type-check-and-fix-async-naming-overhead branch from fbaefef to ee21876 Compare June 30, 2026 10:06
@michaelglass michaelglass marked this pull request as draft June 30, 2026 10:07
@michaelglass michaelglass force-pushed the perf/skip-type-check-and-fix-async-naming-overhead branch 3 times, most recently from e4b874f to c30df05 Compare June 30, 2026 11:03
michaelglass and others added 2 commits June 30, 2026 13:16
Split asyncLintProject into separate MSBuild loading and lint phases so
callers can load project options sequentially (avoiding Ionide.ProjInfo
deadlocks) while running FCS type-check + lint rules in parallel.

- Add getProjectOptions: loads MSBuild project info, returns FSharpProjectOptions
- Add asyncLintProjectOptions: lints with pre-loaded options, skips MSBuild
- Add Checker option to OptionalLintParameters for sharing FSharpChecker
- Refactor asyncLintProject to compose the two new functions
- Run project-level and per-file type checking concurrently
- Compute enabledRules once per project instead of per file (lintWithRules)
- Update global.json to .NET 10 SDK
- Suppress NETSDK1188 (FSharp.Core locale resource warning)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit bafeb43)
Combines the WorkspaceLoader and FSharpChecker sharing from the two
sibling branches. asyncLintSolution now:

  - Creates one WorkspaceLoader and calls LoadProjects once with every
    project in the solution (one warm MSBuild engine, one project-
    graph evaluation).
  - Creates one FSharpChecker and injects it via
    OptionalLintParameters.Checker so every per-project call reuses
    its parse/typecheck caches.
  - Maps each project's FSharpProjectOptions with a singleton
    known-set (mapToFSharpProjectOptions proj [proj]) so FCS resolves
    P2P references against compiled DLLs.

The singleton known-set matters when the checker is shared: passing the
full known-set makes FCS treat referenced projects as source projects,
and a shared checker will re-type-check referenced project sources per
dependent. In a nested solution this flips the combined change from a
win to a regression. Isolated mapping preserves the DLL-based
resolution that single-project loading happens to produce.

Measured on FsHotWatch solution (11 fsproj, ~80 files), M-series Mac,
3 hyperfine runs + 1 warmup:

  branch baseline                      42.3s +/- 6.2s
  + share WorkspaceLoader only         39.9s +/- 7.7s  (-5.7%)
  + share FSharpChecker only           35.1s +/- 1.6s  (-17.0%)
  + both, full-known-set mapping       57.0s +/- 7.4s  (regression)
  + both, singleton-known-set mapping  19.9s +/- 0.2s  (-52.9%, 2.13x)

Against the published dotnet-fsharplint 0.26.10 on the same solution
(515s +/- 121s), the combined patch is ~25.9x faster.

An isolation probe (12-project loop) confirms where the savings come
from:
  - Per-project WorkspaceLoader pays ~14.7s of redundant MSBuild
    engine cold-starts over 11 projects; single LoadProjects(all) saves
    another ~8.5s through MSBuild's internal project-graph evaluation
    cache.
  - Per-project FSharpChecker pays ~9s of redundant reference
    resolution and symbol-table loading.

The two effects partially overlap: a per-project FSharpChecker does
MSBuild-adjacent reference work that masks some of the
WorkspaceLoader-sharing savings on the wall clock. Sharing only the
loader gains 5.7% because the checker reabsorbs the freed budget;
sharing both lets the savings materialize fully.

Also bumps FSharp.Core from 10.1.201 to 10.1.202 to match FCS 43.12.202.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3928a19)
@michaelglass michaelglass force-pushed the perf/skip-type-check-and-fix-async-naming-overhead branch from c30df05 to 78f134b Compare June 30, 2026 11:17
@michaelglass

Copy link
Copy Markdown
Author

well michaelglass@7fc3684 is green, but not in this PR? 🤷

@michaelglass michaelglass marked this pull request as ready for review June 30, 2026 12:23
@michaelglass

Copy link
Copy Markdown
Author

I'm going to also benchmark this against non-pathological case to see how it performs as well. I know when I did this initially there was a minor regression (single-digit percentage)

@knocte

knocte commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

It's not that it's not green in the PR, it's that in the PR it's missing CI status because you didn't push 1 by 1 :) I made a script for that: https://github.com/tarsgate/conventions/blob/master/scripts/gitPush1by1.fsx . But don't worry, I know it's a hassle, I can take care of it this weekend.

@michaelglass

Copy link
Copy Markdown
Author

ok can go back and push one by one again. no stress

@michaelglass michaelglass force-pushed the perf/skip-type-check-and-fix-async-naming-overhead branch from 78f134b to ee21876 Compare June 30, 2026 13:51
@knocte

knocte commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

I stand corrected, what must be actually happening is that, despite you having pushed 1 by 1, as you're a first time contributor, I need to click "approval to run CI" everytime you push. Hopefully this doesn't happen anymore after your first PR lands.

@michaelglass

Copy link
Copy Markdown
Author

ok done. no serious regression!

@michaelglass

Copy link
Copy Markdown
Author

added the benchmark scrip to the gist

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants