Skip to content

[2.x] Intern analysis values while deserializing - #1754

Merged
eed3si9n merged 1 commit into
sbt:developfrom
hoangmaihuy:perf/analysis-intern
Jul 26, 2026
Merged

[2.x] Intern analysis values while deserializing#1754
eed3si9n merged 1 commit into
sbt:developfrom
hoangmaihuy:perf/analysis-intern

Conversation

@hoangmaihuy

@hoangmaihuy hoangmaihuy commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

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

Fixes #1753

Changes

  • Global weak string pool — interned inline in BinaryDeserializer.string(), so the per-read string table and all of its back-references hold the cross-analysis-shared instance.
  • UsedName pools — 8 weak-valued name → UsedName pools, one per UseScope combination. A UsedName is fully determined by its (already canonical) name and 3 scope bits, so probing is keyed by the name string and a pool hit allocates nothing; a candidate is constructed only on a miss. UsedName also gains a cached hashCode (it is re-hashed into a per-class Set on every read) which fits in existing object padding.
  • Both pool types are hand-rolled in zinc-core/WeakPools.scala on ConcurrentHashMap + ReferenceQueue, so this adds no dependency. WeakInterner keys its map by a weak reference that hashes and compares by its referent's value; WeakValuePool keeps strong equals-keyed keys with weakly-held values, which is what makes probe-before-construct possible.
  • Per-read node cache — a plain HashMap on Deserializer, used through ConsistentAnalysisFormat.internNode, dedups value-equality xsbti.api nodes within a single read. ~95% of Type/TypeParameter/Annotation duplication is intra-analysis, so this captures nearly all of it with no weak-reference bookkeeping, and it is leak-free by construction: the cache dies with the read. Structure and EmptyType are excluded (identity equality / singleton).
  • Shared scope EnumSets — the 8 possible UseScope sets are allocated once and shared instead of copied per UsedName. Worth 251 MB by itself, independent of interning.
  • Cached enum.values()values() clones its backing array on every call and sits on per-node read paths (UseScope, Severity, CompileOrder, DefinitionType, Variance, ParameterModifier).
  • readUsedNameSet interns per name instead of materializing a Seq[String] per scope group and mapping it into fresh UsedNames; writeUsedNameSet shares the same scope-bit encoding instead of repeating it.
  • The fresh-compilation path interns too (AnalysisCallback.usedName), so compiler-produced names are canonical, not only deserialized ones.

Leak-freedom

A build server runs for days, so pooling must not retain. Both pools hold their canonical values weakly: once the last analysis referencing a value is dropped the value becomes collectable, and every pool operation drains the reference queue so the entry goes with it.

WeakInterner (strings) keys the map by a weak reference that hashes and compares by the value of its referent, so nothing in the pool strongly references an interned string. WeakValuePool (UsedName) is keyed strongly by the name and holds the value weakly; the entry remembers its key so that expunging a dead value releases the key too. (Keying that pool weakly instead would leak — the value references its key, pinning it.) WeakPoolSpec asserts, for both pools, that values and keys are released once unreachable; AnalysisInternerSpec asserts the same end to end, plus retention while a reference is held.

Thread safety: the pools are ConcurrentHashMaps, and the fresh-compile path interns from many threads at once; a 16-thread test asserts every thread converges on one canonical instance without deadlock.

Transparency: a test asserts api hashes are identical before and after a round trip, so nothing incremental compilation observes changes.

Why NameHash is deliberately not interned

It is the highest-volume type (15.7 M instances, 424 MB incl. arrays) and the obvious candidate, but the shape is wrong: its internal side has zero within-analysis duplication (5,999,323 occurrences = 5,999,323 distinct values) and the corpus-wide distinct population is 6.7 M, so a weak pool would need ~6.7 M entries at ~40 B each — more than the duplicates it removes. Measured directly, interning it increased retained heap (+12 MB at 100 analyses) and cost 15.6% of read CPU. Unlike UsedName there is also no probe-before-construct trick, because identity includes the api hash int, so all 15.7 M reads would have to allocate a candidate first.

It remains the largest analysis-side consumer and is the next thing worth attacking, but it needs a representation change (parallel String[]/int[] instead of 15.7 M wrappers, or sharing whole nameHashes arrays per (className, apiHash)) — both breaking changes to a public xsbti type, so out of scope here.

Benchmark results

All numbers below are from one session on one machine, each measurement paired against a stock develop worktree running the identical harness back to back.

Heap: all 582 analyses co-resident

4 GB fixed heap, loading every analysis in the corpus and holding strong references to all of them. Both heap figures come from the same run: first sampled with no explicit GC (what the process actually occupies right after loading, floating garbage included), then after four forced GCs with every analysis still held (the live set — the steady-state floor a long-lived build server sits at).

metric stock develop this PR Δ
used heap after load, before GC 2335 MB 1738 MB −597 MB (−26%)
retained heap after GC 1906 MB 1237 MB −669 MB (−35%)
garbage in the pre-GC sample 429 MB 501 MB
histogram total bytes (incl. garbage) 2343 MiB 1296 MiB −1047 MiB (−45%)
histogram total objects (incl. garbage) 82.28 M 43.61 M −38.7 M (−47%)

Judge this by the retained row. It is stable: stock measured 1906 MB in two sessions a day apart, and a second harness reports 1904 MB (1 thread) / 1901 MB (8 threads) for stock against 1244 / 1238 MB for this PR — agreement to within 0.6%. The pre-GC row is that same live set plus whatever floating garbage the loader happened to leave behind, which depends entirely on when G1 last collected: across repeat runs stock landed between 2233 and 2536 MB and this PR between 1460 and 1738 MB, i.e. a delta anywhere from −26% to −39%. Of the 669 MB retained saving, roughly 256 MB comes from the format-level fixes alone (measured with interning disabled while it was still switchable) and the rest from the pools.

Where the heap goes, class by class (pre-GC histogram, same pair of runs):

class stock this PR Δ cause
java.util.RegularEnumSet 8,224,311 / 251.0 MiB absent (< 1.7 MiB) −251 MiB shared scope sets
sbt.internal.inc.UsedName 8,224,298 / 188.2 MiB 396,197 / 9.1 MiB −179 MiB UsedName pools
byte[] (string bodies) 3.33 M / 208.5 MiB 1.05 M / 80.7 MiB −128 MiB string pool
xsbti.api.Projection 4,120,137 / 94.3 MiB 509,459 / 11.7 MiB −83 MiB per-read node cache
xsbti.api.Singleton 4,092,961 / 62.5 MiB absent −62 MiB per-read node cache
readUsedNameSet wrappers (JSetWrapper, SetHasAsScala, lambda) 3.22 M / 57.3 MiB absent −57 MiB no EnumSet.asScala per name
[Lxsbti.UseScope; 1,543,366 / 52.6 MiB absent −53 MiB cached values()
java.lang.String 3,119,293 / 71.4 MiB 1,042,455 / 23.9 MiB −48 MiB string pool
xsbti.api.Annotation + args + arrays 960,212 / 22.0 MiB 210,975 / 4.8 MiB −17 MiB per-read node cache
xsbti.api.NameHash 15,727,923 / 360.0 MiB 15,727,923 / 360.0 MiB 0 not interned, by design
[Lxsbti.api.NameHash; 241,470 / 64.1 MiB 241,470 / 64.1 MiB 0 not interned, by design
pool bookkeeping (WeakValue, KeyedWeakValue, their map nodes) 0 2.81 M / ~85.6 MiB +86 MiB cost side

NameHash and its arrays are byte-identical between the two columns (15,727,923 instances, 377,470,152 bytes), which is the clearest confirmation that this PR leaves it untouched.

The interned UsedName count equals the corpus-global distinct population exactly (396,197) — the pools are perfect. Bookkeeping is what interning costs: one weak reference plus one map node per canonical value — 1,007,113 strings (30.7 MiB of WeakValue) and 396,197 UsedNames (12.1 MiB of KeyedWeakValue), plus ~42.8 MiB of ConcurrentHashMap nodes.

CPU: zinc's own AnalysisFormatBenchmark

Read path, the only one that interns, with enough iterations to settle (-f1 -wi 5 -i 10 …readConsistentBinary):

build readConsistentBinary vs stock
stock develop 95.546 ± 0.627 ms
this PR 99.038 ± 2.274 ms +3.7%
earlier revision of this PR, Guava pools 102.919 ± 3.540 ms +7.7%

The whole benchmark class as a control (-f1 -wi 3 -i 5 xsbt.AnalysisFormatBenchmark, same session):

benchmark stock develop this PR Δ
readConsistentBinary 94.332 ± 3.947 ms 95.571 ± 5.328 ms +1.3%
writeConsistentBinary 136.822 ± 3.699 ms 134.833 ± 1.539 ms −1.5%
writeConsistentBinaryNoSort 63.941 ± 3.090 ms 61.687 ± 0.371 ms −3.5%
writeNull 122.548 ± 9.534 ms 111.511 ± 0.151 ms −9.0%
writeNullNoSort 51.364 ± 0.589 ms 46.341 ± 0.564 ms −9.8%

This PR barely touches serialization, so the four write benchmarks are a control — and they come out faster here, which is the point: at five iterations the run-to-run drift on this machine is several percent in either direction, so read cost should be read off the tighter run above (+3.7%), not from this table.

CPU: loading the whole corpus

582 analyses in a 4 GB heap, median of three runs each:

workload stock develop this PR Δ
sequential 4110 ms 4857 ms +18%
8 threads 2032 ms 2383 ms +17%

Both builds scale identically from 1 to 8 threads — stock 2.02×, this PR 2.04× — so the pools add per-call work, not lock contention: ReferenceQueue.poll() returns on a plain volatile read while the queue is empty, and the maps are ConcurrentHashMaps. (The Guava-based revision measured 2387 ms at 8 threads, indistinguishable from the hand-rolled 2383 ms.)

End to end in a real build

Published this branch as zinc 2.0.0-intern-SNAPSHOT, built sbt against it, and ran Test / compileIncremental over a 303-module monorepo (603 analyses). This run predates the switch from Guava to the hand-rolled pools; the pooled populations it reports are a property of the pooling scheme, which is unchanged.

  • [success], 710 s, no errors.
  • Peak heap 16914 MB, retained after forced GC 4761 MB (project's own -Xmx20G -Xms8G, G1).
  • In the live server, UsedName held 400,158 instances / 9.2 MiB — within 1% of the corpus-global distinct population — while compiling new modules and holding 600+ analyses. So the pools stay canonical under a real parallel compile, not only when reading files.
  • RegularEnumSet was absent from the entire histogram.
  • NameHash was 704 MiB / 30.8 M instances (+125 MiB of arrays), again the dominant analysis-side cost and untouched here.
  • Pool bookkeeping totalled 54.5 MiB (Guava entries at the time; the hand-rolled pools cost about a third more per entry).

Compatibility

  • Analysis format version unchanged (1100029) — existing analysis files stay readable, and files written by this build are readable by stock zinc. Verified against released zinc 2.0.0.
  • No public API change; AnalysisInterner is sbt.internal.inc.
  • Api hashes unchanged (asserted by test), so incremental invalidation behaves identically.
  • No new dependency: the weak pools are hand-rolled on the JDK's ConcurrentHashMap and ReferenceQueue.

Testing

  • WeakPoolSpec — canonicalization and separation of unequal values; 16-thread convergence; release of values once unreachable; for the weak-valued pool, get/putIfAbsent semantics and release of the key along with its dead value.
  • AnalysisInternerSpec — string canonicalization; one UsedName per (name, scope) with probe-before-construct; scope-combination distinction; control-character names pooled under their escaped form; 16-thread convergence; release once unreachable; retention while referenced.
  • ConsistentAnalysisFormatInternerSuite — strings shared across independent reads; node cache within one read; UsedNames shared across independent reads; api hashes preserved.
  • Full build green: zincRoot/testFull — 221 scalatest tests + 78 property checks, 0 failures, including the e2e IncrementalCompilerSpec/MultiProjectIncrementalSpec suites that exercise fresh compilation. scalafmtCheckAll, scalafmtSbtCheck, headerCheck and Test/headerCheck clean.

@hoangmaihuy hoangmaihuy changed the title [2.x] Deduplicate analysis values while deserializing [2.x] Intern analysis values while deserializing Jul 25, 2026
@hoangmaihuy
hoangmaihuy force-pushed the perf/analysis-intern branch from 3daf7dc to 203d331 Compare July 25, 2026 06:55
@eed3si9n

Copy link
Copy Markdown
Member

Thanks for the contribution and the analysis.

Comment thread build.sbt Outdated
Comment thread internal/zinc-core/src/main/scala/sbt/internal/inc/AnalysisInterner.scala Outdated
@hoangmaihuy
hoangmaihuy force-pushed the perf/analysis-intern branch from 203d331 to edca486 Compare July 26, 2026 05:56
sbt keeps one Analysis resident per subproject for the whole session, and
structurally-equal values are shared neither across those analyses nor,
for api tree nodes, within a single one. On a 582-analysis monorepo
corpus, holding every analysis co-resident retains 1906 MB after GC;
canonicalizing values as they are read brings that to 1237 MB (-35%).

- global weak string pool, interned in BinaryDeserializer.string()
- 8 weak-valued name -> UsedName pools, one per UseScope combination,
  probed by the already-canonical name so a pool hit allocates nothing
- both pool types are hand-rolled on ConcurrentHashMap + ReferenceQueue
  in WeakPools.scala, so this adds no dependency
- per-read HashMap dedups value-equality xsbti.api nodes (~95% of Type
  duplication is intra-analysis); it dies with the read, so it cannot leak
- share the 8 possible scope EnumSets instead of copying one per UsedName
- cache enum values(), which clones its array on every call, on per-node
  read paths
- intern on the fresh-compilation path too (AnalysisCallback.usedName)

Canonical values are held weakly, so they are released once no analysis
references them, and draining the reference queue releases the pool entry
with them. NameHash is deliberately not interned: it has zero
within-analysis duplication and a 6.7M distinct population, so a weak pool
costs more than it saves (+12 MB retained and 15.6% of read CPU when
measured directly).

The analysis format version is unchanged and api hashes are preserved, so
existing analysis files stay readable and incremental invalidation is
unaffected. Read cost is +3.7% on AnalysisFormatBenchmark and +17%
loading the corpus, unchanged from 1 to 8 threads: the pools add per-call
work, not contention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hoangmaihuy
hoangmaihuy force-pushed the perf/analysis-intern branch from edca486 to be5354d Compare July 26, 2026 05:59

@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 20c07f8 into sbt:develop Jul 26, 2026
14 of 15 checks passed
@hoangmaihuy
hoangmaihuy deleted the perf/analysis-intern branch July 26, 2026 07:34
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.

Analysis values are duplicated across co-resident analyses

2 participants