Skip to content

[2.x] Intern directory item conversions in MappedFileConverter#1751

Merged
eed3si9n merged 1 commit into
sbt:developfrom
hoangmaihuy:perf/file-interning
Jul 24, 2026
Merged

[2.x] Intern directory item conversions in MappedFileConverter#1751
eed3si9n merged 1 commit into
sbt:developfrom
hoangmaihuy:perf/file-interning

Conversation

@hoangmaihuy

@hoangmaihuy hoangmaihuy commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Disclaimer: Developed with Claude Code and human review in the loop

Fixes #1750

Problem

MappedFileConverter.toVirtualFile expands a directory classpath entry into one
MappedVirtualFile per contained file. Because every consumer converts its classpath
independently and the FileConverter is shared build-wide, a directory that appears on many
consumers' classpaths is re-materialized once per consumer — producing large numbers of
value-identical MappedVirtualFile instances (encoded-path strings included) retained
simultaneously. See #1750 for the full write-up.

Change

  • Cache per-item conversions in MappedFileConverter, keyed by the file's
    (lastModified, size), mirroring the metadata-keyed caching already used by
    ClasspathCache. Consumers sharing a classpath now share one MappedVirtualFile per
    contained file instead of each re-materializing a copy.
  • Directory conversions are still rebuilt from a fresh listing on every call — only the
    items are shared. Directory membership is therefore always current (added/removed files
    are reflected), and each item is revalidated against its (lastModified, size) before reuse.
  • Items are held through a SoftReference: while any live conversion references an item the
    reference cannot clear (simultaneously-held conversions keep sharing one instance); once no
    conversion holds it, the GC may reclaim it under memory pressure, bounding the retention of a
    long-lived shared converter. A reclaimed item is simply rebuilt on the next conversion.
  • Top-level conversions are intentionally left uncached. Consumers hold a source
    conversion across edits and rely on its lazy contentHash reading the content of first
    access; interning those would fuse instances whose laziness must stay independent and break
    incremental source change detection.

Why it's safe

  • Interned item hashes are never consumed by change detection. Source stamps read
    contentHash only on top-level source files (fresh instances). Classpath/library change
    detection stamps by path (forHashInRootPathsFarmHash.ofPath), re-resolving class
    files rather than reading MappedDirectory.items — even for directory classpath entries.
    So sharing item instances does not affect incremental correctness.
  • A cached item is served only while its (lastModified, size) is unchanged, so a memoized
    contentHash/contentHashStr is never served across a detected content change (the same
    heuristic ClasspathCache already relies on).
  • The cache is an instance field of the converter (not a global singleton), so its retention
    is bounded by the converter's lifetime, and further bounded under pressure by the soft
    references.

Results

Micro-benchmark (FileConverterBenchmark, JMH; 2000-file directory, 100 consumers holding
conversions simultaneously): ~12% faster average conversion and ~4.5× less retained heap
(26.8 MB → 5.9 MB) for a shared converter vs a fresh converter per consumer.

Macro (real clean compile — fresh empty action cache, wiped target + boot, ~300-subproject
build, 132,362 class files):

Build wall time in-JVM peak jstat peak post-GC vs stock (peak)
Stock (no interning) 968 s 17,984 MB 16.41 GB 3,795 MB
Directory-item interning 927 s (−4.2%) 16,944 MB 15.48 GB 3,884 MB −1,040 MB (−5.8%)

jstat sample distribution corroborates the ordering (the interning build never exceeded
16 GB; stock touched 16.4 GB).

Caveat: one run per build; wall time and momentary heap peaks are both timing-noisy (treat
exact figures as ±a few percent), but the direction — lower peak heap and lower wall time — is
consistent across in-JVM peak, jstat peak, the distribution, and wall clock.

Tests

MappedFileConverterInterningSpec covers:

  • items shared across repeated directory conversions;
  • an item re-converted fresh once its file is rewritten (same size, bumped mtime);
  • directory content changes (add) reflected in a fresh conversion;
  • a missing directory that later exists;
  • top-level conversions keeping fresh-instance semantics.

🤖 Generated with Claude Code

@hoangmaihuy hoangmaihuy changed the title Intern directory item conversions in MappedFileConverter [2.x] Intern directory item conversions in MappedFileConverter Jul 23, 2026
@hoangmaihuy
hoangmaihuy force-pushed the perf/file-interning branch from fc28c0d to f1bb914 Compare July 23, 2026 09:58
@eed3si9n

Copy link
Copy Markdown
Member

Thanks for the contribution!

Comment thread internal/zinc-core/src/main/scala/sbt/internal/inc/MappedVirtualFile.scala Outdated
Every consumer of a shared classpath converts the same entries
independently; for a directory entry the conversion wraps every contained
file, so a shared classes directory is re-materialized - value-identical
MappedVirtualFiles, encoded-path strings included - once per consumer. On
large multi-project builds these duplicates multiply into a significant
heap cost held across the consumers' CompileOptions.

Cache the per-item conversions in MappedFileConverter, keyed by the file's
(lastModified, size) and held through a SoftReference, following the
metadata-keyed caching of ClasspathCache:

- Consumers sharing a classpath now share one MappedVirtualFile per
  contained file. A cached item is served only while its (lastModified,
  size) is unchanged, so its memoized content hashes are never served
  across a detected content change.
- Directory conversions are still rebuilt from a fresh listing on every
  call, so directory membership stays current; only the items are shared.
- The SoftReference bounds retention: while a live conversion holds an item
  the reference cannot clear, so simultaneously-held conversions keep
  sharing one instance, but once no conversion holds it the GC may reclaim
  it under memory pressure (it is rebuilt on the next conversion).
- Top-level conversions are intentionally left uncached: consumers hold a
  source conversion across edits and rely on its lazy content hash reading
  the content of first access, so those keep fresh-instance semantics.

Adds MappedFileConverterInterningSpec and a FileConverterBenchmark
measuring conversion time and retained heap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hoangmaihuy
hoangmaihuy force-pushed the perf/file-interning branch from de0a1fb to 7fd60a3 Compare July 24, 2026 02:39

@eed3si9n eed3si9n left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@eed3si9n
eed3si9n merged commit 57b581e into sbt:develop Jul 24, 2026
8 checks passed
@hoangmaihuy
hoangmaihuy deleted the perf/file-interning branch July 24, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[2.x] MappedFileConverter duplicates directory-entry conversions across consumers, inflating heap on large builds

2 participants