perf(kotlin): intern dirChildren keys per directory and compact the buckets

Two optimizations to `getKotlinFileIndex`, both output-identical, kept
because they were measured and a third was dropped because it was not.

1. PER-DIRECTORY KEY MEMO. The component walk over `dir` cut one `slice`
   per component per FILE, and every slice after the first file of a
   directory is a freshly allocated string that hashes to a key the map
   already holds and is then dropped. The key list is a pure function of
   `dir`, so it is interned once per DIRECTORY. Measured -18.4% to -21.7%
   of the build at 32 000 files; zero retained cost, the memo dies with
   the frame.

2. BUCKET COMPACTION at the freeze loop. `addChild` mints `[raw]` and
   pushes, and V8 grows a backing store by `old + old/2 + 16`, so the
   SECOND child takes a 1-slot store to 17 and every bucket then retains
   its overshoot. 61 144 buckets at 32 000 files, 52.9% of their slots
   empty, 88 B each. `slice()` on freeze: -5 397 768 B, -11.20%, and the
   predicted 5 382 507 B lands within 0.03% of it. Same fix and the same
   accounting as the python `byBasename` note this repo already carries.

   `length === 1` is skipped deliberately. A bucket that never grew is
   already exact, so slicing it allocates a second array to save nothing
   — on a corpus of single-file packages the unguarded form costs 31% of
   the build for zero bytes.

DROPPED: merging the `dirChildren` walk into the `suffixByStem` walk.
It measures -0.10% at 32k, +0.23% at 100k and +0.40% at one file per
directory, all inside a base-vs-identical-copy noise floor of -2.3% to
+3.1%, and it does not compose usefully with the memo — the second scan
it deletes is exactly the scan the memo makes rare. Only its provably
free half is kept: `stem.lastIndexOf('/')` in place of `norm.lastIndexOf`,
one backwards scan instead of two, exact because an extension carries no
'/'.

Neither optimization is visible to the correctness fingerprint, which is
the point and also the risk: it observes the index only through the four
resolver tiers, so a key-order move no corpus query reaches would survive
it. Correctness therefore rests on a structural comparison of all three
maps — key insertion order, values, bucket contents in order, frozen-ness
— over 1234 corpora in both iteration orders, 14 808 comparisons, zero
failures. The fingerprint, `cases` and `non_null` are unchanged and MUST
NOT be re-baselined by this commit.

Gates that did move, both because a reading and its budget move with the
code rather than when CI goes red:

  - `heap_reading_bytes.kotlin` 48 200 224 -> 42 802 456 with its ceiling
    at 1.5x. A memory WIN passes every arm, so nothing forced this.
  - `depth_budget` 2.2 -> 2.0. The memo turns a per-file component walk
    into a per-directory one, which is precisely the per-depth work this
    arm exists to see: the band went 1.27-1.40 -> 1.20-1.26, and 2.2 held
    over it would have drifted from ~1.6x headroom to ~1.9x.

The gate controls were re-run against the optimized builder, including
one this change makes newly plausible: keying the memo on the directory's
LAST SEGMENT instead of its full path drifts the fingerprint
(36a4e9dad313, non_null 13310 -> 13305). That is the memo's whole
safety argument stated as a test — its key decides which key set a
directory contributes — and it is the one way this optimization could
move an answer. The bucket-cap control was re-run too, since compaction
now rewrites the same buckets.

Also recorded, from measuring a reuse this repo had been invited to make:
replacing `dirChildren` with the shared `package-dir-index` is
output-identical (0 divergences over 107 948 answers) and passes every
arm of the kotlin bench at 1.37x-1.50x — while costing 409x per fan-out
and 8114x on `import data.*` at 200 matching directories on a corpus this
bench does not carry. `_blind_spot` in the kotlin baselines now says so,
with the memory the trade would have bought (26.2%, 12.18 MiB) and the
corpus arm that would have to exist first.

Refs #2881.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8QYxYvd5ntikRUvpLZD5e
This commit is contained in:
Gergo Magyar 2026-08-11 18:30:56 +00:00
parent 233f7118d3
commit bebdda7850
5 changed files with 91 additions and 27 deletions

File diff suppressed because one or more lines are too long

View file

@ -270,9 +270,12 @@
* of eight, so go, dart and kotlin were excluded silently. All three
* retain a real per-pass structure: go's `PackageDirIndex` reads
* 2 998 464 B, dart's basename buckets 7 834 200 B, and kotlin's
* `suffixByStem` cascade 48 073 096 B (45.85 MiB) — the second-largest
* reading in this file, above ruby's 39.12 and java's 33.34, both of which
* carry a full budget.
* `suffixByStem` cascade 42 802 456 B (40.82 MiB) — above ruby's 39.12 and
* java's 33.34, both of which carry a full budget. (Read 48 073 096 B when
* this paragraph was written and described as "the second-largest reading
* in this file", which it was not even then: csharp_csproj and php both
* read higher. #2881 then compacted kotlin's `dirChildren` buckets and
* took 11% off it.)
* 2. TWO OF THE STATED REASONS NO LONGER HOLD. swift was excluded as "below
* its own noise floor" on 0.98 MB at 8000 files against 0.29 MB at 32 000;
* it now reads 969 120 B and 3 449 216 B, growing the right way. COBOL was
@ -560,9 +563,8 @@ const HEAP_BUDGETED = [
// per-pass structure and each grows LINEARLY with the file count (ratio
// 0.996-1.004 against a 1.25 budget over 8000 -> 32000 files), so each can
// carry the full ceiling + floor + ratio set rather than a bound alone.
// kotlin's 45.85 MiB is the second-largest reading in this file — larger than
// ruby's and java's, both of which were budgeted from the start — and it had
// no stated exclusion reason at all.
// kotlin's 40.82 MiB is larger than ruby's and java's, both of which were
// budgeted from the start, and it had no stated exclusion reason at all.
'kotlin',
'dart',
'go',
@ -2608,7 +2610,7 @@ for (const lang of HEAP_BUDGETED) {
* `1.5 x 0 B` is 0 — its bound is ABSOLUTE (1 MiB) for the same reason: a
* multiplier on 16 B fails on the first byte of anything. The other eight are
* stable enough today to floor (0.24% peak-to-peak at worst over five runs) and
* two of them — kotlin at 45.85 MiB and dart at 7.47 — are larger than budgeted
* two of them — kotlin at 40.82 MiB and dart at 7.47 — are larger than budgeted
* arms, so a floor there would be worth having. That is a promotion to tier one,
* with a ceiling and a recorded reading, and it is not this change: a floor
* without them would assert "still measuring" against a number nothing else

View file

@ -1,14 +1,15 @@
{
"_comment": "Baselines for bench/kotlin-import-target/measure.mjs --check. `fingerprint` is a sha256 over every `fileSet | fromFile | targetRaw -> result` record the correctness corpus resolves, in BOTH file-set iteration orders; it is a CORRECTNESS gate, so drift means Kotlin import resolution started returning a different file set and IMPORTS/CALLS edges moved in every Kotlin repository. Explain it, never re-baseline to make CI green. `cases` and `non_null` are asserted beside it because a shrunken or hollowed corpus produces a perfectly valid fingerprint over a smaller surface — all three are one re-baseline, never separate ones. `scaling_budget`, `depth_budget` and `small_ms_ceiling` are timing gates and carry deliberate headroom for shared CI runners.",
"_provenance": "RE-BASELINED ONCE, DELIBERATELY, IN #2881. The previous value ebf1790bf1d42dad483a51f2cbdeb2351e493b9e8236e4eedeef592dd81e2c5c (13256 non-null) was the PRE-INDEX implementation's, and the index that replaced it in #2872 reproduced it byte for byte — that is what made #2872 a performance change. #2881 changes resolution on purpose: `getKotlinFileIndex` no longer requires the parent directory to be the FIRST occurrence of that name in the path, so a file whose package directory name repeats higher in its own path is now a child of that package (`data/src/main/kotlin/com/example/data/Repo.kt` IS a child of `data`, and `import data.helper` resolves instead of returning null). The drift was not read off the new code and accepted; the corpus was dumped from both implementations and diffed record by record. Of 19968 distinct records, 235 moved: 54 NULL -> resolved (the fix itself, and exactly the +54 in non_null, 13256 -> 13310) and 181 answers that changed within a now-larger `dirChildren` bucket — 149 where the first-child tier picks a different member and 32 where a fan-out array gained entries. ZERO buckets lost a member, zero results were dropped, and in all 149 reselections the new answer's parent directory is the queried package segment. The corpus itself is untouched, which is why `cases` is unchanged at 20106 — the fingerprint is over the same surface as the value it replaces.",
"_gate_controls": "The gate is only worth its baseline if a plausible regression moves it, so each arm was checked against the mutation it exists to catch, with the resolver otherwise untouched. Caught against the CURRENT baseline (#2881): skipping the dirChildren suffix loop above depth 8 (fingerprint 41bb550b76d4…, non_null 13310 -> 12800); capping a dirChildren bucket at 17 entries (a7681945b752…, non_null unchanged — the fingerprint is the only arm that sees it); and, the mutation #2881 makes newly plausible, a HALF fix that drops only the `startsWith` guard and keeps the `indexOf` first-occurrence check (836977b83bf0…, non_null 13310 -> 13282), which leaves every mid-path repeat such as `top/data/mid/data/Repo.kt` still broken. Caught against the previous baseline and unaffected by the re-baseline: capping suffixByStem key depth at 7 (a0e6eb98f9…). Also caught, with the RESOLVER untouched and only the corpus edited: dropping the competing file from the exact-beats-earlier-suffix case and emptying the repeated-directory case (44df5093ee…). All of these passed silently before this corpus carried deep paths, packages above 16 files, queries against suffix keys deeper than 7, and the file set inside the hashed record. Re-check them after any corpus edit — a corpus that stops spanning an axis takes the gate with it.",
"_comment": "Baselines for bench/kotlin-import-target/measure.mjs --check. `fingerprint` is a sha256 over every `fileSet | fromFile | targetRaw -> result` record the correctness corpus resolves, in BOTH file-set iteration orders; it is a CORRECTNESS gate, so drift means Kotlin import resolution started returning a different file set and IMPORTS/CALLS edges moved in every Kotlin repository. Explain it, never re-baseline to make CI green. `cases` and `non_null` are asserted beside it because a shrunken or hollowed corpus produces a perfectly valid fingerprint over a smaller surface \u2014 all three are one re-baseline, never separate ones. `scaling_budget`, `depth_budget` and `small_ms_ceiling` are timing gates and carry deliberate headroom for shared CI runners.",
"_provenance": "RE-BASELINED ONCE, DELIBERATELY, IN #2881. The previous value ebf1790bf1d42dad483a51f2cbdeb2351e493b9e8236e4eedeef592dd81e2c5c (13256 non-null) was the PRE-INDEX implementation's, and the index that replaced it in #2872 reproduced it byte for byte \u2014 that is what made #2872 a performance change. #2881 changes resolution on purpose: `getKotlinFileIndex` no longer requires the parent directory to be the FIRST occurrence of that name in the path, so a file whose package directory name repeats higher in its own path is now a child of that package (`data/src/main/kotlin/com/example/data/Repo.kt` IS a child of `data`, and `import data.helper` resolves instead of returning null). The drift was not read off the new code and accepted; the corpus was dumped from both implementations and diffed record by record. Of 19968 distinct records, 235 moved: 54 NULL -> resolved (the fix itself, and exactly the +54 in non_null, 13256 -> 13310) and 181 answers that changed within a now-larger `dirChildren` bucket \u2014 149 where the first-child tier picks a different member and 32 where a fan-out array gained entries. ZERO buckets lost a member, zero results were dropped, and in all 149 reselections the new answer's parent directory is the queried package segment. The corpus itself is untouched, which is why `cases` is unchanged at 20106 \u2014 the fingerprint is over the same surface as the value it replaces.",
"_gate_controls": "The gate is only worth its baseline if a plausible regression moves it, so each arm was checked against the mutation it exists to catch, with the resolver otherwise untouched. All values below are against the CURRENT baseline (#2881, guards removed + per-directory key memo + bucket compaction). Caught: skipping the dirChildren component walk above depth 8 (fingerprint 41bb550b76d4\u2026, non_null 13310 -> 12800); capping a dirChildren bucket at 17 entries (a7681945b752\u2026, non_null UNCHANGED \u2014 the fingerprint is the only arm that sees it, and note the compaction pass now rewrites those same buckets, so this control was re-run after it); capping suffixByStem key depth at 7 (d24b8a2bd822\u2026, non_null unchanged); and a HALF fix that drops only the `startsWith` guard while keeping the `indexOf` first-occurrence check (836977b83bf0\u2026, non_null 13310 -> 13282), which leaves every mid-path repeat such as `top/data/mid/data/Repo.kt` broken and is the mutation #2881 itself makes plausible. Added with the memo: keying `dirKeys` on the directory's LAST SEGMENT instead of its full path (36a4e9dad313\u2026, non_null 13310 -> 13305) \u2014 the memo's whole safety argument is that its key determines the key SET a directory contributes, so a coarser key silently hands one directory another's bucket list, and that is the one way this optimization can move an answer. Also caught, with the RESOLVER untouched and only the corpus edited: dropping the competing file from the exact-beats-earlier-suffix case and emptying the repeated-directory case (44df5093ee\u2026). All of these passed silently before this corpus carried deep paths, packages above 16 files, queries against suffix keys deeper than 7, and the file set inside the hashed record. Re-check them after any corpus edit \u2014 a corpus that stops spanning an axis takes the gate with it. NOTE what no fingerprint control here can catch: the memo and the compaction are both invisible to this bench by design (identical output), so their correctness rests on a structural comparison of the three maps \u2014 key insertion order, bucket order, frozen-ness \u2014 over 1234 corpora in both iteration orders, not on this gate.",
"fingerprint": "d91110bee389891c313811c5b4bae61d909561156e1458d38d487be969f0059c",
"cases": 20106,
"non_null": 13310,
"scaling_budget": 1.6,
"depth_budget": 2.2,
"depth_budget": 2.0,
"small_ms_ceiling": 40,
"_scaling_note": "(t_large/t_small)/(1600/400). ~1.0 is linear. OBSERVED BAND: 0.99-1.04 on a 12-core dev box, small arm ~6 ms. Read that band as a floor, not a spec — independent runs on other hardware during review came out 0.954-1.014, 0.965-1.036 and ~0.95-1.08, so a 1.2 reading is noise and should be re-run, not investigated. IMPORTS_PER_FILE is sized so the small arm lands in the ms rather than the ~2 ms a first revision measured, where timer granularity and JIT warm-up, not scaling, set the number; bench/cpp-qualified-ns documents the same artifact. TRIAGE: every timing arm here is a TIMING signal — RE-RUN IT on an idle machine before investigating; runner contention dominates. The fingerprint arm is the opposite: deterministic, a re-run never changes it, and it must never be wished away. FLOOR CHECK: the pre-index implementation — i.e. exactly the regression this gate exists to catch — measures ratio 3.737 on this corpus (2207.8 ms small, 33003.5 ms large, one cold run) against ~1.0 for the index. Independent review runs measured its floor at 3.905-4.297. Treat the absolute times as an order of magnitude only: the floor arm is one cold run because best-of-seven against a quadratic implementation costs minutes, while the index arm is best-of-seven after two warmups.",
"_depth_note": "deep_ms/shallow_ms at a FIXED file count, paths 24 components against 8. scaling_ratio divides the file count out, so it is scale-invariant and structurally cannot see a cost that grows with path depth instead — and both loops this change added are depth loops (one suffixByStem entry per '/' in a stem, one dirChildren pass per component of dir). OBSERVED BAND: 1.44-1.51 over four unloaded runs before #2881; 1.27-1.40 over five after. That change deleted two string comparisons per component of every dir, which is per-depth work, so the depth arm got cheaper while the file-count arm did not. The BUDGET moved with the band — 2.4 -> 2.2, holding the ~1.6x headroom over the band's top that 2.4 expressed against the old one; left at 2.4 it would have quietly become 1.8x, which is how a gate goes slack without anyone deciding to loosen it. Note what this budget is NOT for: a revert of #2881 scores ~1.5 and passes at either number, and that is correct — reverting it restores a resolution bug, which is the FINGERPRINT's job to catch, not a timing arm's. It sits above 1.0 legitimately: 3x the depth is 3x the suffix keys per file, so the build genuinely does more work; what the budget of 2.4 forbids is that growing faster than the depth ratio itself.",
"_ceiling_note": "small_ms_ceiling is an ABSOLUTE bound, because scaling_ratio is a ratio and a constant-factor regression that grows both arms equally passes it. Measured during review: a full workspace scan reintroduced on 1-in-16 imports is caught by the ratio (1.814), but at 1-in-32 it passes at 1.490 while running 2.8x slower in absolute terms. 40 ms against an observed 5.9-6.1 ms leaves ~6x of headroom for a loaded shared runner while still catching that shape."
"_scaling_note": "(t_large/t_small)/(1600/400). ~1.0 is linear. OBSERVED BAND: 0.99-1.04 on a 12-core dev box, small arm ~6 ms. Read that band as a floor, not a spec \u2014 independent runs on other hardware during review came out 0.954-1.014, 0.965-1.036 and ~0.95-1.08, so a 1.2 reading is noise and should be re-run, not investigated. IMPORTS_PER_FILE is sized so the small arm lands in the ms rather than the ~2 ms a first revision measured, where timer granularity and JIT warm-up, not scaling, set the number; bench/cpp-qualified-ns documents the same artifact. TRIAGE: every timing arm here is a TIMING signal \u2014 RE-RUN IT on an idle machine before investigating; runner contention dominates. The fingerprint arm is the opposite: deterministic, a re-run never changes it, and it must never be wished away. FLOOR CHECK: the pre-index implementation \u2014 i.e. exactly the regression this gate exists to catch \u2014 measures ratio 3.737 on this corpus (2207.8 ms small, 33003.5 ms large, one cold run) against ~1.0 for the index. Independent review runs measured its floor at 3.905-4.297. Treat the absolute times as an order of magnitude only: the floor arm is one cold run because best-of-seven against a quadratic implementation costs minutes, while the index arm is best-of-seven after two warmups.",
"_depth_note": "deep_ms/shallow_ms at a FIXED file count, paths 24 components against 8. scaling_ratio divides the file count out, so it is scale-invariant and structurally cannot see a cost that grows with path depth instead \u2014 and the two loops the index is built from are depth loops (one suffixByStem entry per '/' in a stem, one dirChildren pass per component of dir). OBSERVED BAND, five runs each on one box: 1.44-1.51 before #2881; 1.27-1.40 after its guard removal, which deleted two string comparisons per component of every dir; 1.20-1.26 after the same issue's per-directory key memo, which turns that whole component walk from once-per-FILE into once-per-DIRECTORY. Both movements are per-depth work, which is why this arm sees them and the file-count arm does not. The BUDGET moved with the band both times \u2014 2.4 -> 2.2 -> 2.0 \u2014 holding the ~1.6x headroom over the band's top that 2.4 expressed against the original; left at 2.4 it would quietly have become 1.9x, which is how a gate goes slack without anyone deciding to loosen it. Note what this budget is NOT for: a revert of #2881 scores ~1.5 and passes at any of those numbers, and that is correct \u2014 reverting it restores a resolution bug, which is the FINGERPRINT's job to catch, not a timing arm's. It sits above 1.0 legitimately: 3x the depth is 3x the suffix keys per file, so the build genuinely does more work; what the budget forbids is that growing faster than the depth ratio itself.",
"_ceiling_note": "small_ms_ceiling is an ABSOLUTE bound, because scaling_ratio is a ratio and a constant-factor regression that grows both arms equally passes it. Measured during review: a full workspace scan reintroduced on 1-in-16 imports is caught by the ratio (1.814), but at 1-in-32 it passes at 1.490 while running 2.8x slower in absolute terms. 40 ms against an observed 5.9-6.1 ms leaves ~6x of headroom for a loaded shared runner while still catching that shape.",
"_blind_spot": "WHAT THIS BENCH CANNOT SEE, measured rather than guessed. Its scaling corpus gives every module a UNIQUE package leaf (`com/example/mod{N}`), so a `dirChildren` query matches exactly one directory. That makes it blind to any cost that grows with the number of DIRECTORIES sharing a queried segment \u2014 the shape a real Kotlin monorepo has, where 200 modules each hold `data`, `ui` and `domain`. Established by building the reuse this file's memo argues against: swapping `dirChildren` for the shared `import-resolvers/package-dir-index.ts` (with its first-occurrence rule off) is OUTPUT-IDENTICAL \u2014 same fingerprint, same cases, same non_null, 0 divergences over 107948 answers \u2014 and on THIS corpus it costs only 1.37x-1.50x and passes every arm here. On a repeated-leaf corpus the same swap measures 13.5x per first-child query, 409x per fan-out, and 8114x on `import data.*` at 200 matching directories (it merges and SORTS every candidate, per import), for 3.1x-5.7x end to end and a bench-style scaling_ratio of 3.465 against this file's 1.6 budget \u2014 i.e. back to the pre-index quadratic floor of 3.737. A change that regresses this resolver to the very shape the bench exists to catch would go GREEN here. The trade it buys is real and also measured: 26.2% less retained memory, 12.18 MiB at 32000 files. If that memory is ever wanted, the shape to build is per-suffix keys -> DIRECTORY lists plus files-per-directory (8.29 MiB against 15.87 measured, single-directory query still one hash lookup) \u2014 and the corpus needs a repeated-leaf arm FIRST, or the gate will not be watching."
}

View file

@ -188,6 +188,26 @@ const getKotlinFileIndex = perFileSet((allFilePaths: ReadonlySet<string>): Kotli
const exactByStem = new Map<string, string>();
const suffixByStem = new Map<string, string>();
const dirChildren: MutableDirChildren = new Map();
/**
* BUILD-LOCAL: `dir` -> every `dirChildren` key a file in that directory
* contributes to. That list is a pure function of `dir`, and a package
* directory holds many files, so without this the walk below cuts one `slice`
* per component of the SAME directory once per FILE — and every slice after
* the first file's is a freshly allocated string that hashes to a key the map
* already holds and is then dropped. Interning them once per DIRECTORY
* instead of once per FILE is ~21% of the build at 32 000 files.
*
* It cannot move an answer. The array is filled on the first file of a
* directory, in the order the per-file walk produced, and every later file in
* that directory finds those keys already present — so the key set, the Map's
* key insertion order and every bucket's order are what the per-file form
* produced. Asserted structurally, not just through the fingerprint: the
* fingerprint only observes the index through the four resolver tiers, so a
* key-order move no corpus query reaches would survive it.
*
* Dropped with this frame, so it costs nothing retained.
*/
const dirKeys = new Map<string, string[]>();
for (const raw of allFilePaths) {
const norm = raw.replace(/\\/g, '/');
@ -206,13 +226,18 @@ const getKotlinFileIndex = perFileSet((allFilePaths: ReadonlySet<string>): Kotli
if (!suffixByStem.has(suffix)) suffixByStem.set(suffix, raw);
}
const lastSlash = norm.lastIndexOf('/');
if (lastSlash < 0) continue; // repo-root file has no package directory
const dir = norm.slice(0, lastSlash);
// From `stem`, not `norm`: an extension carries no '/', so the last '/' of
// the two is the same character at the same index, and `stem.slice(0,
// dirEnd)` IS the string `norm.slice(0, norm.lastIndexOf('/'))` was. One
// backwards scan instead of two, over the string this loop already walked.
const dirEnd = stem.lastIndexOf('/');
if (dirEnd < 0) continue; // repo-root file has no package directory
const dir = stem.slice(0, dirEnd);
const memo = dirKeys.get(dir);
// The file's own directory always qualifies: the old scan's `atRoot` branch
// matched `norm.startsWith(dir + '/')` and found no '/' after it.
addChild(dirChildren, dir, raw);
const keys = memo ?? [dir];
// Every component-suffix of the directory also qualifies: `s` is a suffix
// of `dir` starting after a '/', so `dir` ends with `/s` by construction
@ -245,10 +270,14 @@ const getKotlinFileIndex = perFileSet((allFilePaths: ReadonlySet<string>): Kotli
// resolving to null on the very shape this fixes. The bucket is either
// right or it is not; there is no version of it that is right for one
// consumer and wrong for the other.
for (let i = 0; i < dir.length; i++) {
if (dir[i] !== '/') continue;
addChild(dirChildren, dir.slice(i + 1), raw);
if (memo === undefined) {
for (let i = 0; i < dirEnd; i++) {
if (dir[i] !== '/') continue;
keys.push(dir.slice(i + 1));
}
dirKeys.set(dir, keys);
}
for (const key of keys) addChild(dirChildren, key, raw);
}
// `findKotlinPackageFiles` hands a bucket straight out of the index — the
@ -260,7 +289,33 @@ const getKotlinFileIndex = perFileSet((allFilePaths: ReadonlySet<string>): Kotli
// reorder the cached bucket and flip the FIRST-child tier's answer for every
// later import in the run. Freezing makes the contract true at runtime, so a
// future mutation is a loud TypeError instead of a silent edge move.
for (const bucket of dirChildren.values()) Object.freeze(bucket);
//
// COMPACTED as they are frozen. `addChild` mints a bucket as `[raw]` and
// `push`es the rest, and V8 grows an array's backing store by
// `old + old/2 + 16`, so the SECOND child takes a 1-slot store to 17 and the
// eighteenth takes it to 41 — and every bucket then retains its overshoot for
// the life of the index. `slice()` allocates exactly `length`. Same fix and
// same accounting as the python `byBasename` sentence in
// `bench/import-target/baselines.json`: contents byte-identical, 5.13 MiB
// smaller at 32 000 paths, 11.2% of what that bench's kotlin heap arm reads
// (61 144 buckets x 88 B; 52.9% of those buckets' slots were empty).
//
// `length === 1` is skipped because a bucket that never grew is ALREADY exact
// — `[raw]` allocates one slot — so slicing it allocates a second array to
// save nothing. Not a micro-optimization: on a corpus of single-file packages
// the unguarded form costs 31% of the build and saves zero bytes.
//
// Re-`set` of a key the Map already holds keeps that key's position, so
// iteration order is unaffected.
for (const [key, bucket] of dirChildren) {
if (bucket.length === 1) {
Object.freeze(bucket);
continue;
}
const tight = bucket.slice();
Object.freeze(tight);
dirChildren.set(key, tight);
}
return { exactByStem, suffixByStem, dirChildren };
});

View file

@ -135,7 +135,13 @@ describe('resolveKotlinImportTarget — index parity', () => {
expect(resolve(['top/data/mid/data/Repo.kt'], 'data.something')).toEqual([
'top/data/mid/data/Repo.kt',
]);
expect(resolve(['a/c/b/c/File.kt'], 'c.X')).toEqual(['a/c/b/c/File.kt']);
// `['a/c/b/c/File.kt'], 'c.X'` used to sit here too. It is the same shape
// with the segments renamed — four components, second and fourth equal,
// query the repeated name — so it could not fail while the case above
// passed. The bench corpus still carries it, where a second spelling of a
// shape costs nothing; a unit case that cannot distinguish two
// implementations is just a slower way to assert the first one.
//
// Unrepeated control: the parent is the only occurrence.
expect(resolve(['top/data/Repo.kt'], 'data.something')).toEqual(['top/data/Repo.kt']);
});