Skip to content

perf(typescript-language): reuse one program per project instead of per file - #3257

Draft
JoshuaKGoldberg wants to merge 2 commits into
mainfrom
perf/hoist-program-per-project
Draft

perf(typescript-language): reuse one program per project instead of per file#3257
JoshuaKGoldberg wants to merge 2 commits into
mainfrom
perf/hoist-program-per-project

Conversation

@JoshuaKGoldberg

Copy link
Copy Markdown
Collaborator

SASSAFRAS

PR Checklist

Overview

createFile asked the ProjectService for a project and a program once per file: an openClientFile, a getDefaultProjectForFile, a forced getLanguageService(true).getProgram(), and a matching closeClientFile on dispose, 1024 times over for 1024 files that all belong to the same tsconfig.json.

The file factory now remembers, per tsconfig.json, the project that owns it. A file is served out of an already-open project — with no openClientFile at all — when both hold:

  1. The closest tsconfig.json above the file is the one that project was created for, so tsserver would resolve the file to that project anyway (findConfigPath walks up from the file's directory, memoized per directory, the same rule as getConfigFileNameForFile).
  2. That project's program already contains a source file for the path, which is Project#containsScriptInfo's test for the file belonging to the project.

Anything else — a file under a different tsconfig.json, a file no tsconfig.json covers (the allowDefaultProject/inferred-project path), a file the project's program doesn't have, or a path opened earlier whose text may since have changed — falls through to the original open-and-resolve path. The program is always re-fetched through getLanguageService(true), so a reused project still hands out a synchronized program. Symbol.dispose closes only the files it opened.

For the 1024-file fixture this is 1 openClientFile and 1 closeClientFile instead of 1024 of each.

What the profile actually says

Worth recording, since it differs from the issue's analysis. With ts.server instrumented on main at 1024 files × 1 rule:

  • ProjectService#openClientFile: 1024 calls, 876 ms — of which tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptInfo is 669 ms, printProjects 196 ms, cleanupProjectsAndScriptInfos 79 ms — plus 129 ms of closeClientFile.
  • Project#getLanguageService(true) and LanguageService#getProgram(): 1024 calls each, ~0 ms total.
  • ConfiguredProject#updateGraph: 2 calls total, not 1024.

So the per-file forced synchronization the issue targets was already free — updateProjectIfDirty and synchronizeHostData both short-circuit on an unchanged project version. All of the waste was the per-file open/close bookkeeping around it, which is what this removes.

That also answers the issue's open question about superlinearity. The marginal cost per added file at 1 rule climbs on main (1.18 ms/file going 2 → 256, 1.29 ms/file going 256 → 1024) and falls on this branch (0.96 ms/file, then 0.81 ms/file). The growth was not repeated graph updates; it was ScriptInfo bookkeeping, realpath canonicalization, and the per-open project walks getting more expensive as more files were open.

Overlap with #3256: ~196 ms of the ~420 ms saved here is printProjects logging inside openClientFile/closeClientFile, which #3256 removes by another route. The two fixes overlap and are not additive.

Measurements

Cold cache (--cache-ignore --skip-formatting --skip-language-reports), hyperfine -r 8, back to back on the same machine:

files rules main @ 116a836 this branch delta
2 1 783 ms ± 27 ms 779 ms ± 31 ms −4 ms (−0.5%)
256 1 1.082 s ± 32 ms 1.022 s ± 36 ms −60 ms (−5.5%)
1024 1 2.072 s ± 74 ms 1.648 s ± 34 ms −424 ms (−20.5%)
1024 many (272) 3.156 s ± 67 ms 2.739 s ± 71 ms −417 ms (−13.2%)

Warm cache, same runs:

files rules main @ 116a836 this branch delta
2 1 808 ms ± 30 ms 792 ms ± 30 ms −16 ms (−2.0%)
256 1 1.070 s ± 68 ms 1.007 s ± 36 ms −63 ms (−5.9%)
1024 1 2.253 s ± 217 ms 1.648 s ± 40 ms −605 ms (−26.9%)
1024 many (272) 3.194 s ± 68 ms 2.729 s ± 88 ms −465 ms (−14.6%)

Findings are byte-identical to main on the 2-file, 256-file, and 1024-file fixtures and on Flint's own source (28 reports, plus the same language reports in the same order).

Not covered

  • Reuse is per closest tsconfig.json, so a project reached through project references or a solution-style config — where tsserver's chosen config isn't the closest one — keeps opening every file. That direction fails safe: it only loses the speedup.
  • jsconfig.json isn't part of the lookup, so a jsconfig.json-only project keeps the old per-file path.
  • The remaining ~600 ms at 1024 files × 1 rule is the initial program build (readdir and visitDirectory for the config's file glob, readFileUtf8, parse), which is untouched here.

JoshuaKGoldberg and others added 2 commits August 19, 2026 14:21
…er file

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 19, 2026

Copy link
Copy Markdown

Deploy Preview for flint-fyi ready!

Name Link
🔨 Latest commit cfc9826
🔍 Latest deploy log https://app.netlify.com/projects/flint-fyi/deploys/6a85fd0b143ebf0008e8a253
😎 Deploy Preview https://deploy-preview-3257--flint-fyi.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: cfc9826

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.11765% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/typescript-language/src/language.ts 94.11% 0 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

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.

⚡️ Performance: Hoist the TypeScript program per project instead of per file

1 participant