Skip to content

First incremental rebuild after a clean build is up to 75× slower than a full check #64464

Description

🔎 Search Terms

incremental noEmit slow first edit, tsbuildinfo signature, declaration emit getAlternativeContainingModules getAliasForSymbolInContainer, isSymbolAccessible slow, affected files signature, typescript 7 incremental slower than full check

🕗 Version & Regression Information

⏯ Playground Link

No response

💻 Code

The problem needs thousands of files and build state across runs, so here is a generator instead of a snippet. hub.ts stands for a widely imported constants module. Each leaf exports a value whose inferred type names an interface that the leaf never imports.

// generate.js — node generate.js <dir> [leaves=6000] [types=3000]
const fs = require('fs');
const path = require('path');

const dir = path.resolve(process.argv[2] || 'repro');
const LEAVES = Number(process.argv[3] || 6000);
const TYPES = Number(process.argv[4] || 3000);

fs.rmSync(path.join(dir, 'src'), {recursive: true, force: true});
fs.mkdirSync(path.join(dir, 'src/types'), {recursive: true});
fs.mkdirSync(path.join(dir, 'src/leaves'), {recursive: true});
const write = (file, text) => fs.writeFileSync(path.join(dir, 'src', file), text);

write('hub.ts', 'export const HUB_PREFIX = "hub";\nexport const HUB_LIMIT = 10;\n');
for (let i = 0; i < TYPES; i++) {
    write(`types/t${i}.ts`,
        `export interface Model${i} { id: string; value${i}: number; }\n` +
        `export function make${i}(id: string): Model${i} { return {id, value${i}: ${i}}; }\n`);
}
write('types/index.ts', Array.from({length: TYPES}, (_, i) => `export {make${i}} from './t${i}';\n`).join(''));
for (let i = 0; i < LEAVES; i++) {
    const a = i % TYPES;
    const b = (i * 7 + 3) % TYPES;
    write(`leaves/l${i}.ts`,
        `import {HUB_PREFIX} from '../hub';\n` +
        `import {make${a}, make${b}} from '../types';\n` +
        `export const leaf${i} = {first: make${a}(HUB_PREFIX), second: make${b}('${i}')};\n`);
}
write('index.ts', Array.from({length: LEAVES}, (_, i) => `export * from './leaves/l${i}';\n`).join(''));
fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify({
    compilerOptions: {
        target: 'es2022', module: 'esnext', moduleResolution: 'bundler', strict: true,
        noEmit: true, incremental: true, skipLibCheck: true,
    },
    include: ['src'],
}, null, 2));
node generate.js repro && cd repro
rm -f tsconfig.tsbuildinfo
tsc -p . --extendedDiagnostics --incremental false   # full check
tsc -p . --extendedDiagnostics                        # clean incremental build
echo '// comment-only edit' >> src/hub.ts
tsc -p . --extendedDiagnostics                        # first edit: slow
echo '// another comment-only edit' >> src/hub.ts
tsc -p . --extendedDiagnostics                        # second edit: fast again

Wall times on a 32-core Linux machine (6000 leaves, 3000 types):

5.4.5 6.0.3 7.0.2 7.1.0-dev.20260925.1
full check (--incremental false) 3.0 s 3.2 s 0.57 s 0.56 s
clean incremental build 3.6 s 3.3 s 0.83 s 0.82 s
no-op rebuild 2.2 s 2.0 s 0.59 s 0.61 s
first comment-only edit to hub.ts 31.7 s 24.5 s 34.3 s 41.8 s
second comment-only edit 2.5 s 2.3 s 0.73 s 0.74 s
for comparison: --incremental false --noEmit false --declaration --emitDeclarationOnly --outDir out 23.9 s 8.2 s 11.4 s

The cost grows faster than the program: with 7.0.2, the first edit takes 4.1 s at 1500 leaves, 8.5 s at 3000 and 34.3 s at 6000.

🙁 Actual behavior

After a clean incremental build, the first edit to a module that many files import takes much longer than checking the whole program from scratch, even when the edit is a comment. In 7.0.2 the time is reported as Check time, and in the nightly as Emit time.

A CPU profile of that run (--pprofDir) shows where it goes. The builder emits declarations for the edited file's importers to compute their shape signatures, even with --noEmit. Serializing the inferred types asks whether each named symbol is accessible, and getAlternativeContainingModules then scans every source file for a module that re-exports the symbol:

      flat  flat%        cum   cum%
     2.54s  6.66%     30.28s 79.45%  checker.(*Checker).getAlternativeContainingModules
     1.93s  5.06%     24.93s 65.42%  checker.(*Checker).getAliasForSymbolInContainer
     2.71s  7.11%      2.71s  7.11%  ast.IsNonLocalAlias
         0     0%     32.78s 86.01%  declarations.(*DeclarationTransformer).ensureType
Duration: 34.20s, Total samples = 38.11s (111.42%)

The run uses about one core: 111% CPU, against about 700% for a full check. The same declaration emit run on its own (--emitDeclarationOnly) takes 8.2 s, so running it for signatures costs about 4× as much.

After that first edit the .tsbuildinfo holds real declaration signatures, and later edits are fast again. That includes edits that change the hub's exports, and edits to other widely imported modules.

🙂 Expected behavior

The first incremental rebuild after a comment-only edit should cost about as much as a full check, not 8–75× more. At least, computing signatures should not scan the whole program once per symbol, and it should run in parallel the way checking does.

Additional information about the issue

This is how it shows up in a real application with 22,154 files (a React + Express monorepo, TypeScript 7.0.2, tsc -p src/ui --noEmit --incremental). After a clean build, the first comment-only edit to a 112-line constants module takes 291 s. That module is re-exported through a shared barrel that about 900 files import. A full check of the same program takes 21 s and the no-op rebuild 3.5 s. The next edit takes 4.7 s. On the warm .tsbuildinfo, an edit that adds an export to that module takes 24 s.

The profile of that run matches the synthetic one: 58% in getAlternativeContainingModules, 136% CPU over 303 s.

      flat  flat%        cum   cum%
    57.08s 13.80%     57.08s 13.80%  ast.IsNonLocalAlias
    15.43s  3.73%    242.09s 58.54%  checker.(*Checker).getAlternativeContainingModules
    14.86s  3.59%    217.74s 52.65%  checker.(*Checker).getAliasForSymbolInContainer
         0     0%    228.96s 55.37%  declarations.(*DeclarationTransformer).ensureType
Duration: 302.67s, Total samples = 413.53s (136.63%)

Separately, tsc --watch --noEmit on that project took 437 s for its first check and peaked at 13 GB; I haven't profiled that run. For now we run our dev-server type checks with incremental off. A five-minute wait after every clean build costs more than faster rebuilds save afterwards.

#63830 reports the same hotspot in declaration emit for tsc -b on a private codebase and is waiting on a shareable repro. The generator above reproduces it without private code. I can attach the raw pprof files.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions