Skip to content

TypeScript 7.1 regression: quadratic root scanning in settled no-op builds #64848

Open

Description

🔎 Search Terms

TypeScript 7.1 no-op build regression, incremental up-to-date checks, isContentMapperSupplementalBuildInfoPath, content mapper supplemental files, quadratic root scanning, tsc --build performance

🕗 Version & Regression Information

  • This changed between TypeScript 7.0.2 and 7.1.0-dev.20261009.1.

It also reproduces with 7.1.0-dev.20261016.1. It remains reproducible when building main at abcbb2e, which reports Version 7.1.0-dev.

The version comparison used Linux x86_64, Node.js 24.21.0, and Python 3.12.1.

A separate same-revision comparison of unpatched and patched main used macOS 26.7.1 arm64, 18 logical CPUs, Go 1.27.2, and Python 3.12.13. Both binaries used identical build settings and embedded standard libraries. These measurements are reported separately below.

⏯ Playground Link

No response

💻 Code

This dependency-free synthetic project contains 4,000 ordinary TypeScript roots and 4,000 imported declaration dependencies outside the root include. No content mappers are configured.

Save the script below as reproduce.py, then run:

npm init -y
npm install --save-dev \
  stable@npm:typescript@7.0.2 \
  nightly@npm:typescript@7.1.0-dev.20261016.1

python3 reproduce.py \
  --compiler stable="$PWD/node_modules/stable/bin/tsc" \
  --compiler nightly="$PWD/node_modules/nightly/bin/tsc" \
  --directory "$PWD/fixture"

The script runs three blocks with alternating compiler order. Each compiler/block starts with a clean full build and two settling builds, followed by 20 measured no-op builds. It also saves verbose/extended diagnostics after each block.

import argparse
import json
from pathlib import Path
import statistics
import subprocess
import tempfile
import time

parser = argparse.ArgumentParser()
parser.add_argument('--compiler', action='append', required=True, help='label=/absolute/path/to/tsc')
parser.add_argument('--projects', type=int, default=1)
parser.add_argument('--files', type=int, default=4000)
parser.add_argument('--dependencies', type=int, default=4000)
parser.add_argument('--samples', type=int, default=20)
parser.add_argument('--rounds', type=int, default=3)
parser.add_argument('--directory', type=Path)
args = parser.parse_args()
compilers = dict(item.split('=', 1) for item in args.compiler)
compilers = {name: str(Path(path).resolve()) for name, path in compilers.items()}
root = args.directory or Path(tempfile.mkdtemp(prefix='typescript-noop-'))
root.mkdir(parents=True, exist_ok=True)
references = []
for project in range(args.projects):
    directory = root / f'p{project:02}'
    (directory / 'src').mkdir(parents=True, exist_ok=True)
    (directory / 'deps').mkdir(parents=True, exist_ok=True)
    config = {
        'compilerOptions': {
            'target': 'es2024', 'module': 'esnext', 'moduleResolution': 'bundler',
            'lib': ['es2024'], 'types': [], 'strict': True, 'skipLibCheck': True,
            'composite': True, 'declaration': True, 'emitDeclarationOnly': True,
            'outDir': 'out', 'rootDir': 'src',
        },
        'include': ['src'],
    }
    (directory / 'tsconfig.json').write_text(json.dumps(config, indent=2))
    for dependency in range(args.dependencies):
        (directory / 'deps' / f'dep-{dependency}.d.ts').write_text(
            f'export interface Dependency{dependency} {{ value: number }}\n'
        )
    (directory / 'deps' / 'index.d.ts').write_text(''.join(
        f'export type {{ Dependency{dependency} }} from "./dep-{dependency}";\n'
        for dependency in range(args.dependencies)
    ))
    for file in range(args.files):
        (directory / 'src' / f'item-{file}.ts').write_text(
            'import type { Dependency0 } from "../deps/index";\n'
            f'export interface Model{file} extends Dependency0 {{ id: string }}\n'
            f'export const value{file}: Model{file} = {{ id: "{project}-{file}", value: {file} }}\n'
        )
    references.append({'path': f'./p{project:02}'})
(root / 'tsconfig.json').write_text(json.dumps({'files': [], 'references': references}, indent=2))
results = []
for round_index in range(args.rounds):
    order = list(compilers)
    if round_index % 2:
        order.reverse()
    for name in order:
        tsc = compilers[name]
        subprocess.run([tsc, '-b', '--clean'], cwd=root, check=True)
        subprocess.run([tsc, '-b', '--builders', '3', '--checkers', '5'], cwd=root, check=True)
        for _ in range(2):
            subprocess.run([tsc, '-b'], cwd=root, check=True, stdout=subprocess.DEVNULL)
        for sample in range(args.samples):
            start = time.monotonic()
            proc = subprocess.run([tsc, '-b'], cwd=root, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
            results.append({'compiler': name, 'round': round_index + 1, 'sample': sample + 1, 'seconds': time.monotonic() - start, 'exit_code': proc.returncode})
        with (root / f'{name}-round-{round_index + 1}-verbose.log').open('w') as log:
            subprocess.run([tsc, '-b', '--verbose', '--extendedDiagnostics'], cwd=root, check=True, stdout=log, stderr=subprocess.STDOUT)
        (root / 'results.json').write_text(json.dumps(results, indent=2) + '\n')
for name in compilers:
    values = [row['seconds'] for row in results if row['compiler'] == name]
    print(name, 'n=', len(values), 'median=', statistics.median(values), 'range=', [min(values), max(values)])
print('Results and generated fixture:', root)

🙁 Actual behavior

Settled no-op tsc -b builds are substantially slower in 7.1, even without content mappers.

Synthetic version comparison on Linux x86_64:

Compiler Samples Median wall time Range
7.0.2 60 0.167s 0.138–0.235s
7.1.0-dev.20261009.1 60 0.855s 0.788–0.916s
7.1.0-dev.20261016.1 60 0.890s 0.813–0.971s

The October 16 nightly is approximately 5.3× slower than 7.0.2 on this workload.

Every measured build succeeds. Post-block verbose/extended diagnostics confirm that the project is up to date, with no build starts or checking/emission work. Each compiler's build state is cleaned, rebuilt, and settled before measurement.

🙂 Expected behavior

No-op build freshness checks should avoid work proportional to the product of project roots and existing imported dependencies.

A project with no content mappers should not incur a full content-mapper root scan for every ordinary dependency. Missing generated supplemental files should still be handled correctly, while missing ordinary dependencies and content-mapper configuration changes should continue to invalidate the build.

Additional information about the issue

Identified code path

At main revision abcbb2e, the non-root input loop in tsc/internal/execute/build/buildtask.go calls isContentMapperSupplementalBuildInfoPath before obtaining the input's modification time.

That helper iterates over all build-info roots and constructs root + "." while checking each dependency. This adds O(non-root dependencies × project roots) work even when every input exists and no content mappers are configured.

Source:

if isContentMapperSupplementalBuildInfoPath(inputPath, getBuildInfoRootInfoReader().Roots()) &&
!orchestrator.host.FS().FileExists(inputFileName) {
continue
}
inputTime := orchestrator.host.GetMTime(inputFileName)
if inputTime.IsZero() {

Helper:

func isContentMapperSupplementalBuildInfoPath(inputPath tspath.PathKey, roots iter.Seq[tspath.PathKey]) bool {
for root := range roots {
suffix, ok := strings.CutPrefix(string(inputPath), string(root)+".")
if !ok {
continue
}
index, extension, ok := strings.Cut(suffix, ".")
if !ok || extension == "" {
continue
}
if _, err := strconv.Atoi(index); err == nil && contentmapper.IsSupportedVirtualExtension("."+extension) {
return true
}
}
return false
}

Same-revision patch validation

I tested moving the supplemental-path check inside the existing inputTime.IsZero() branch. This reuses the existing modification-time lookup instead of adding an existence check for ordinary dependencies.

Both binaries were built from abcbb2e, with only this freshness-check patch different, using go -C tsc build -o /absolute/path/to/binary ./cmd/tsc.

The same synthetic reproducer was run on macOS 26.7.1 arm64 with Go 1.27.2. Three alternating blocks, each with a clean build, two settling builds, and 20 measured no-op builds per binary:

Binary Samples Median wall time Range
Unpatched 60 0.4386s 0.3830–0.6092s
Patched 60 0.0815s 0.0589–0.3014s

This is approximately an 81% reduction in median wall time. Per-block medians, in unpatched/patched order:

  • Block 1: 0.3991s / 0.0785s
  • Block 2: 0.5269s / 0.1590s
  • Block 3: 0.4186s / 0.0637s

Commands ran serially without concurrent test or benchmark workloads. Filesystem caches stayed warm. Absolute times vary by environment; these numbers should not be compared directly with the Linux version-comparison results.

All six post-block validations reported the project up to date, zero build starts, and zero checked files. In representative final-block extended diagnostics, both variants reported zero files, lines, identifiers, symbols, types, and instantiations; reported total time was 0.364s unpatched and 0.039s patched. These diagnostic invocations were separate from the measured samples.

Candidate fix and correctness coverage

Candidate commit: https://github.com/turadg/TypeScript/commit/1841aea46280e7b438000d7fc72298fa7c48ede4

The change includes focused coverage for:

  • A settled build whose generated supplemental files do not exist on disk.
  • A content-mapper configuration change forcing a rebuild, followed by another settled no-op build.
  • Deletion of an ordinary imported declaration dependency forcing a rebuild and reporting the missing module.

Existing content-mapper tests also cover a physical file appearing at a supplemental path and mapper package identity changes. There are no timing assertions.

The contribution-guide generation, build, test, test:all, lint, formatting, package, and module checks passed. Node-driven checks were also validated with Node 24.20.0/npm 11.19.1.

I have a fix in 1841aea which I'm happy to PR but followed guidelines to have a Backlog issue first.

AI assistance: I used Codex to investigate this specific regression and prepare the candidate patch, tests, and report.

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