Skip to content

Commit f36f619

Browse files
objectstack-fleet[bot]claudegithub-actions[bot]
authored
ci(test-shards): place Test Core bins by predicted wall, and cut the CLI into 4 slices on the refreshed dataset (#25968)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DGuXpcS2tMB1v58Jdn3L7NW)_ Part of #25870. This PR carries two steps: (1) wall-based bin placement, and (2) the refreshed dataset with the CLI cut into 4 slices. #25870 stays open, because its acceptance is measured on 3 consecutive spec-touching PRs after landing. The prediction below is not that measurement. **Supersedes #25960.** It carries the shard-timings-refresh lane's commit 92343f5 (`chore(ci): refresh the Test Core shard-timings dataset`) unchanged. That refresh cannot land alone, because pin 3c refuses it at 3 slices (step 2). ## Diagnosis — which of the three, with run ids The long shards are always the three that carry a `@objectstack/cli` file-level slice. Read from `gh api …/runs/N/jobs` step timings on 56 `pull_request` runs (2026-10-25 18:54Z – 2026-10-26 05:44Z), and from the slice leg's own `Duration` / turbo `Time:` lines on the 18 slice-shard jobs of six `packages/spec`-touching runs. The slowest shard on a spec-touching PR breaks down like this (minutes): | run (spec PR) | slowest | setup | closure | whole-package leg | cli slice leg (turbo `Time:`) | |---|---|--:|--:|--:|--:| | 40274911131 (#25903) | 5/8 28.8 | ~1.5 | 6.0 | 5.7 | 15.4 | | 40271593540 (#25944) | 4/8 28.1 | ~1.5 | 5.5 | 3.4 | 17.3 | | 40269339195 (#25813) | 4/8 28.5 | ~1.5 | 5.6 | 3.4 | 17.5 | | 40266487909 (#25903) | 5/8 28.5 | ~1.5 | 5.9 | 5.6 | 15.4 | | 40263851511 (#25813) | 3/8 26.8 | ~1.5 | 6.3 | 4.5 | 14.3 | | 40261969569 (#25830) | 3/8 25.7 | ~1.5 | 6.0 | 4.3 | 13.6 | (whole-package leg = `Run this shard's tests` minus the slice leg's turbo `Time:`.) Three causes, all real, in this order of size: 1. **The cli slice leg itself, 13.6–17.5 min.** vitest cuts the CLI by file COUNT (`BaseSequencer.shard`: sha1 of the path, then equal-count chunks), so the three slices are not equal: across the six runs they measured 518–1047 s, the heaviest ~1.27× the mean (~820 s). Total cli 2406–2542 s against a pinned 2899 s (0.83–0.88×). 2. **The dependency closure, 5.5–6.3 min**, against 0.4–1.0 min on shards that skip declarations. `@objectstack/cli` is in ci.yml's `DTS_READERS`, so every slice shard builds the whole cli closure WITH `.d.ts`, and a PR run reads no remote cache, so that build is cold (ci.yml: local cold closure 5m59s with declarations, 1m24s without). 3. **A whole-package leg of 2.2–5.7 min run before the slice — the partitioner's doing.** `partition()` balanced bins by their SUM of predicted seconds. A slice runs serially after the four-wide whole leg, so the step is ~ wholeSum / 2.9 + slice (the #25376 wall model). Balancing sums filled every slice shard up to the mean with ~900 s of whole packages: predicted step 1277 s on the three slice shards against 643 s on the other five (committed dataset, full list). Stale weights are not the cause: on the refreshed dataset (#25960, the shard-timings-refresh lane's 2026-10-26 output) the sum-placed split still predicts 1107 s on the slice shards against 518 s elsewhere — the same 2.1× gap. ## Step 1: wall-based placement (cause 3) In `scripts/partition-test-shards.mjs`, with no workflow or package edit: - `partition()` places bins by **load** — the wall model in whole-package seconds, a slice counting `WHOLE_LEG_PARALLELISM` (2.9, the #25376 constant, reused rather than re-derived) times its weight. A list with no slice is placed by exactly the same additions and comparisons as before, so every non-sliced run (and the nightly, which slices every package on every shard) gets a byte-identical split. - `total` stays the plain sum, so the density cap and the drift report mean what they meant. `refineToCap()` keeps `load` in step with `total`. - `balanceOf()` also reports the walls; pin 2 grades the wall ratio (with a slice in the split the sums are unequal by design); new pin 2b is a fixture where sum-placement and wall-placement differ. - The shard log prints the predicted walls under the bins line. Predicted test steps, full list (`turbo ls`, dogfood excluded), via `planRun()`: | dataset | before (slice shards / others) | after (slice shards / others) | |---|---|---| | committed (2026-10-23) | 1277 s / 643 s | 966 s / 830 s | | #25960 refresh (2026-10-26) | 1107 s / 518 s | 899 s / 642 s | Measured-based expectation for the six spec runs above: removing the whole-package leg takes the slowest shard from 25.7–28.8 min to **~22.3–25.1 min**. The other five shards absorb the moved packages (their slowest was 17.5 min; +~2.5 min of four-wide work each). ## Step 2: the refreshed dataset, and the CLI at 4 slices The refresh (92343f5, 14 runs, newest 40272142430) measures `@objectstack/cli` at 2697.45s. Pin 3c (`sliceCountProblems`, the serial-floor derivation of #22075) then refuses 3 slices: | n | one slice | vs the heaviest other serial task (`@objectstack/metadata-protocol` 670.12s; spec and runtime run as two tasks) | limit 1.3x = 871.16s | |--:|--:|--:|---| | 3 | 899.15s | 1.34x | refused: "Raise it to 4" (this is #25960's red, job 121437007857) | | 4 | 674.36s | 1.01x | meets, and 3 does not, so 4 is the smallest | So the change is `FILE_SHARDED_PACKAGES` cli 3 → 4, with `PREVIOUS_FILE_SHARDED_PACKAGES` set to the outgoing `{cli: 3}` per the map's own rule, so the refresh lane still decodes the 3-slice summaries retained from before the change. The docblocks carry the new derivation table. The bin-sum floor and the wall model agree on this dataset, and no ratio or bound moved. Predicted test-step walls on the full list are 738s on all 8 shards (max/mean 1.00x), and every bin is within the density cap. **Predicted from measurement, not from the model.** vitest's `BaseSequencer.shard` (sha1 of the path relative to the package root, sorted, equal-count chunks) reproduced locally over `vitest list --filesOnly` (506 files) matches run 40274911131's three slice jobs file for file: 169/169/168 files on jobs 121415631670/…679/…708. From those jobs' per-file times, with the wall taken as 0.39 × the per-file sum (vitest's `Duration` over its `tests` sum on the same jobs: 0.403/0.381/0.390): | split | predicted slice walls | measured (vitest `Duration`) | heaviest / mean | |---|---|---|--:| | 3 ways | 789 / 691 / 926 s | 812.65 / 673.01 / 921.52 s | 1.15 | | 4 ways | 487 / 616 / 673 / 630 s | — | 1.12 | The heaviest slice falls 27%. Applied to the six spec runs (slowest = setup ~1.5 + that run's cold DTS closure + ~1.1 min of whole leg, the wall model's 738 − 674 s, + 0.727 × that run's heaviest slice): | run (spec PR) | slowest before | predicted after both steps | |---|--:|--:| | 40274911131 (#25903) | 28.8 | 19.8 | | 40271593540 (#25944) | 28.1 | 20.7 | | 40269339195 (#25813) | 28.5 | 20.9 | | 40266487909 (#25903) | 28.5 | 19.7 | | 40263851511 (#25813) | 26.8 | 19.3 | | 40261969569 (#25830) | 25.7 | 18.5 | That gives 18.5–20.9 min, with three of six at or under 20. The cost: a fourth shard now builds the CLI's DTS closure (~5.8 min cold). The 738s walls above count test time only, not that build. The per-file read of 5/8 lacks 11 files that print before the 5,000-line log tail; their 172.2s (vitest `tests` minus the captured sum) is spread evenly over them. ## If the readings stay above ~20 min What is left on a slice shard is the cold DTS closure (5.5–6.3 min) plus the heaviest slice. The next steps, neither taken here: - **B. Declarations only where a slice reads them.** Three CLI test files read built `.d.ts`. With the split now reproducible, a slice holding none of them could build DTS-off (~1 min instead of ~5.8). This edits `ci.yml`, so it is Tier H. - **C. Weight-balanced CLI slices** (a sequencer splitting by measured file time): heaviest/mean 1.12 → ~1.0. It needs a per-file weight source that the dataset does not carry today. ## Acceptance notes - **Deliberate leftovers in `.github/workflows/ci.yml` (Tier H, untouched):** the Test Core header still says each slice is "placed at its PLAIN weight so every bin keeps the whole-package split's density", and its "3 slices" wording (the drift-step comment, "a shard carrying one of its 3 slices is predicted a third of that"; the timeout history) is now stale. Correcting either makes a PR Tier H, so both are left for whichever step edits ci.yml. The partitioner's own headers carry the corrected statements. - Slice shards now come out as shards 1–4 on the full list (LPT places the heaviest loads first). Shard numbers carry no meaning beyond the per-shard Turbo seed key. ## Tests - `node scripts/partition-test-shards.mjs --self-test` at b323410 → `self-test OK (75 measured packages -> 78 shard items, 8 shards, predicted test-step walls 738/738/738/738/738/738/738/739s, max/mean 1.00x <= 1.3x, …, file-level slices: @objectstack/cli x4)`. - Step 1 revert proof (`scripts/ablation-replace.mjs`, bin choice back to `total`, on the committed dataset): the self-test reds with `balance: at 8 shards the slowest predicted test step is 1.45x the mean (1277s vs 881s)`. Restored blob == HEAD, and `git diff HEAD` is empty. - Step 2 revert proof: the refreshed dataset with cli at 3 reds pin 3c with `@objectstack/cli: sliced 3 ways at 2697.45s, one slice is 899s -- past 1.3x the heaviest other serial task (670s, so 871s) … Raise it to 4.` (the same red as #25960's job 121437007857). - `scripts/test-shard-timings.json` is byte-identical to 92343f5 (`git diff 92343f5 b323410 -- scripts/test-shard-timings.json` is empty). - Sibling self-tests are green at b323410: `measure-test-shard-timings` (decodes through both maps), `check-test-completeness`, `ci/select-shard-timings-run`, `nightly-tiers`, `check-test-suite-ceilings`, `ci/select-shard-packages.selftest.sh`. ## Gates - Local derived set at b323410 (`node scripts/pm/dispatch-gates.mjs --commands`, merge-base origin/main cdd4eea): all 35 families exit 0. The `--ran` reconciliation reads `35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED`. Step 1 alone (8ca31e0) ran its 33 families the same way. - Everything else (Lint & Repo Gates, Test Core, …) belongs to CI and is read from this PR's check-runs. Clause-②: no Metadata-protocol: none 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01NPH84ZP1jWcst3tpRwfrJL --- _Generated by [Claude Code](https://claude.ai/code/session_01NPH84ZP1jWcst3tpRwfrJL)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 62ba5ef commit f36f619

2 files changed

Lines changed: 550 additions & 276 deletions

File tree

‎scripts/partition-test-shards.mjs‎

Lines changed: 156 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ const TIMINGS_PATH = path.join(REPO_ROOT, 'scripts', 'test-shard-timings.json');
157157
// beside them. That last figure is the floor this count cannot move: a CLI
158158
// slice (~960-1010s measured) is serial, so past eight the slowest shard is
159159
// the slice and more shards only add runners.
160+
//
161+
// ⚠ The bins above were placed by their SUM. Since #25870 partition() places
162+
// them by that same wall model (WHOLE_LEG_PARALLELISM), so a slice shard no
163+
// longer carries a whole-package leg ahead of its slice; on the committed
164+
// dataset's full list the predicted steps move from 1277s on the three slice
165+
// shards (643s elsewhere) to 966s on the slice shards and 830s elsewhere. On
166+
// the 2026-10-26 refresh, with the CLI cut into 4 slices (pin 3c, below), all
167+
// eight read ~738s.
160168
export const SHARD_COUNT = 8;
161169

162170
// The acceptance bound #10472 set: the slowest shard within ~1.3x the mean.
@@ -354,10 +362,23 @@ export function timingModeNote(event) {
354362
// n = 2 869.44s 1.52x still the floor
355363
// n = 3 579.63s 1.01x meets -- spec's task is the floor
356364
//
357-
// So n = 3, and pin 3c holds it there. Slices are placed at their PLAIN weight
358-
// (partition() is unchanged), so every bin keeps the density the whole-package
365+
// So n = 3, and pin 3c held it there. Slices were placed at their PLAIN weight
366+
// (partition() unchanged), so every bin kept the density the whole-package
359367
// split had -- see densityCap() for the hard limit on that and its pin.
360368
//
369+
// RE-DERIVED AT 4 (#25870), on the 14-run refresh of 2026-10-26 (newest run
370+
// 40272142430): the CLI at 2697.45s. `@objectstack/spec` (1168.98s) and
371+
// `@objectstack/runtime` (793.70s) each run as two tasks, so the heaviest
372+
// other serial task is `@objectstack/metadata-protocol`'s 670.12s, and a slice
373+
// must sit within 1.3 x 670.12 = 871.16s.
374+
//
375+
// n = 3 899.15s 1.34x still the floor -- pin 3c refused 3
376+
// n = 4 674.36s 1.01x meets -- metadata-protocol is the floor
377+
//
378+
// Since #25870 partition() places a slice at its wall share rather than its
379+
// plain weight (WHOLE_LEG_PARALLELISM); `total`, and so the density cap, still
380+
// reads plain sums.
381+
//
361382
// ⛔ THE COUNT IS CONFIGURED; WHETHER A RUN SLICES IS DECIDED BY THAT RUN. A
362383
// pull_request or merge_group run splits its own affected set, so planShards()
363384
// asks, of the run's own list, whether a configured package is that run's
@@ -378,7 +399,7 @@ export function timingModeNote(event) {
378399
// a change to what that generator can DECODE, which is why the map it replaced
379400
// is kept below.
380401
export const FILE_SHARDED_PACKAGES = Object.freeze({
381-
'@objectstack/cli': 3,
402+
'@objectstack/cli': 4,
382403
});
383404

384405
// THE MAP AS IT STOOD BEFORE ITS LAST CHANGE, read only by the generator's
@@ -406,8 +427,10 @@ export const FILE_SHARDED_PACKAGES = Object.freeze({
406427
// `test-core-run-summary-*`, #25771), so four days after that PR lands no
407428
// retained summary predates the change and this map decodes nothing.
408429
// The outgoing map of #22075's third round was empty -- the runs before it
409-
// carried no slice -- so this one is empty too.
410-
export const PREVIOUS_FILE_SHARDED_PACKAGES = Object.freeze({});
430+
// carried no slice. #25870 raised the CLI from 3 slices to 4, so this holds 3.
431+
export const PREVIOUS_FILE_SHARDED_PACKAGES = Object.freeze({
432+
'@objectstack/cli': 3,
433+
});
411434

412435
// The item grammar. A shard item is a package (`@objectstack/cli`) or a SLICE
413436
// of one (`@objectstack/cli 1/2`), and this pair of functions is the only place
@@ -882,21 +905,58 @@ function readManifest(dir) {
882905
}
883906
}
884907

885-
// LPT greedy: heaviest package into the currently lightest bin. Deterministic:
886-
// input order never matters because both the package sort and the bin choice
887-
// break ties explicitly (by name / by lowest bin index).
908+
// ── A SLICE IS A SERIAL LEG: BINS ARE PLACED BY WALL, NOT BY SUM (#25870) ────
909+
//
910+
// A shard's "Run this shard's tests" step runs its whole packages four at a
911+
// time (`--concurrency=4`) and THEN each file-level slice as a turbo run of its
912+
// own, alone on the runner (ci.yml). So a slice's seconds reach the wall one for
913+
// one while a whole package's are shared about 2.9 ways -- the wall model the
914+
// SHARD_COUNT header calibrated (#25376): step ~ wholeSum / 2.9 + slice. This
915+
// constant is that 2.9, not a new measurement.
916+
//
917+
// Placing bins by their SUM of seconds treated a slice second like a whole
918+
// second, so every slice shard was filled up to the mean with whole packages and
919+
// then ran its slice after them. On the committed dataset's full list that put
920+
// ~900s of whole packages in front of each 966s CLI slice: predicted step 1277s
921+
// on the three slice shards against 643s on the other five, and measured the
922+
// same way on the pull_request runs #25870 tabulates -- 3/8-5/8 at 25-29 min,
923+
// the rest at 7-17, the gap being each slice shard's whole-package leg
924+
// (`Run this shard's tests` minus the slice leg's own turbo `Time:`).
925+
//
926+
// So partition() places by LOAD -- a bin's predicted wall, kept in whole-package
927+
// seconds (a slice counts WHOLE_LEG_PARALLELISM times its weight) so that a list
928+
// with no slice is placed by exactly the same additions and comparisons as the
929+
// plain-sum LPT it replaced, byte for byte. `total` stays the plain sum of
930+
// predicted test windows: the density cap and the drift report are statements
931+
// about that quantity, and neither changes meaning here.
932+
export const WHOLE_LEG_PARALLELISM = 2.9;
933+
934+
// One item's share of a bin's load, in whole-package seconds.
935+
export function loadOf(item) {
936+
return item.slice ? item.weight * WHOLE_LEG_PARALLELISM : item.weight;
937+
}
938+
939+
// A bin's predicted `Run this shard's tests` step under the wall model.
940+
export function wallOf(bin) {
941+
return bin.load / WHOLE_LEG_PARALLELISM;
942+
}
943+
944+
// LPT greedy on load (above): heaviest item into the currently lightest bin.
945+
// Deterministic: input order never matters because both the item sort and the
946+
// bin choice break ties explicitly (by name / by lowest bin index).
888947
export function partition(items, shardCount) {
889948
const sorted = [...items].sort(
890-
(a, b) => b.weight - a.weight || a.name.localeCompare(b.name, 'en')
949+
(a, b) => loadOf(b) - loadOf(a) || a.name.localeCompare(b.name, 'en')
891950
);
892-
const bins = Array.from({ length: shardCount }, () => ({ total: 0, names: [] }));
951+
const bins = Array.from({ length: shardCount }, () => ({ total: 0, load: 0, names: [] }));
893952
for (const it of sorted) {
894953
let best = 0;
895954
for (let i = 1; i < bins.length; i++) {
896-
if (bins[i].total < bins[best].total) best = i;
955+
if (bins[i].load < bins[best].load) best = i;
897956
}
898957
bins[best].names.push(it.name);
899958
bins[best].total += it.weight;
959+
bins[best].load += loadOf(it);
900960
}
901961
return bins;
902962
}
@@ -905,13 +965,34 @@ export function partition(items, shardCount) {
905965
// `floor` is the heaviest single package: sharding is BY PACKAGE, so no split
906966
// at any shard count can put max below it, and comparing it to the mean says
907967
// whether the bound is even reachable before anyone tries to reach it.
968+
//
969+
// `walls` / `wallRatio` are the same statistic on what the bins were placed by
970+
// (wallOf, #25870). With no slice in the split they are the totals scaled by
971+
// one constant, so the two ratios agree; with a slice, only the wall ratio says
972+
// whether the shards finish together. A bin built by hand (no `load`) is read
973+
// as all whole packages.
908974
export function balanceOf(bins, items = null) {
909975
const totals = bins.map((b) => b.total);
910976
const sum = totals.reduce((a, b) => a + b, 0);
911977
const mean = sum / bins.length;
912978
const max = Math.max(...totals);
913979
const floor = items ? Math.max(0, ...items.map((i) => i.weight)) : null;
914-
return { totals, sum, mean, max, min: Math.min(...totals), ratio: mean === 0 ? 1 : max / mean, floor };
980+
const walls = bins.map((b) => wallOf({ load: b.load ?? b.total }));
981+
const wallMean = walls.reduce((a, b) => a + b, 0) / bins.length;
982+
const wallMax = Math.max(...walls);
983+
return {
984+
totals,
985+
sum,
986+
mean,
987+
max,
988+
min: Math.min(...totals),
989+
ratio: mean === 0 ? 1 : max / mean,
990+
floor,
991+
walls,
992+
wallMean,
993+
wallMax,
994+
wallRatio: wallMean === 0 ? 1 : wallMax / wallMean,
995+
};
915996
}
916997

917998
// ── THE DENSITY CAP: NO SHARD CARRIES MORE THAN THE SPLIT PROVEN GREEN (#22075) ─
@@ -971,7 +1052,7 @@ export function refineToCap(bins, items, cap) {
9711052
const weightOf = new Map(items.map((i) => [i.name, i.weight]));
9721053
const slices = new Set(items.filter((i) => i.slice).map((i) => i.name));
9731054
const movable = (name) => !slices.has(name);
974-
const out = bins.map((b) => ({ total: b.total, names: [...b.names] }));
1055+
const out = bins.map((b) => ({ total: b.total, load: b.load, names: [...b.names] }));
9751056
const EPSILON = 1e-9;
9761057
for (let step = 0; step < 10_000; step++) {
9771058
let h = 0;
@@ -998,15 +1079,21 @@ export function refineToCap(bins, items, cap) {
9981079
}
9991080
if (best === null) break;
10001081
const { to, give, take } = best;
1082+
// Only whole packages move, and a whole package's load is its weight, so
1083+
// `load` follows `total` move for move.
10011084
out[h].names.splice(out[h].names.indexOf(give), 1);
10021085
out[h].total -= weightOf.get(give);
1086+
out[h].load -= weightOf.get(give);
10031087
out[to].names.push(give);
10041088
out[to].total += weightOf.get(give);
1089+
out[to].load += weightOf.get(give);
10051090
if (take !== null) {
10061091
out[to].names.splice(out[to].names.indexOf(take), 1);
10071092
out[to].total -= weightOf.get(take);
1093+
out[to].load -= weightOf.get(take);
10081094
out[h].names.push(take);
10091095
out[h].total += weightOf.get(take);
1096+
out[h].load += weightOf.get(take);
10101097
}
10111098
}
10121099
return out;
@@ -1484,7 +1571,9 @@ const SELF_TEST_BATTERIES = Object.freeze({
14841571
// 8 of these 25 are pins 3b and 3c, the file-level slicing's (#16173), and
14851572
// every one of them runs unconditionally: 3c's fixtures and 3b's cut of the
14861573
// heaviest package do not depend on what the live map slices.
1487-
'the balancing pins (#10472)': 25,
1574+
// 2 of these 27 are pin 2b, the wall placement's (#25870): a fixture, so it
1575+
// runs whatever the dataset holds.
1576+
'the balancing pins (#10472)': 27,
14881577
'predicted-vs-measured drift (#16173)': 9,
14891578
'drift warning tier under the red (#16465)': 12,
14901579
'file-level slice items (#16173)': 20,
@@ -1731,16 +1820,55 @@ function selfTest() {
17311820
});
17321821
const real = datasetPlan.bins;
17331822
const balance = balanceOf(real, datasetItems);
1823+
// Graded on predicted WALL, the quantity the bins are placed by (#25870): with
1824+
// a slice in the split, the bin SUMS are deliberately unequal -- a slice shard
1825+
// carries its serial slice and little else -- and only the walls say whether
1826+
// the shards finish together. With no slice the two ratios are the same number.
17341827
check(() => {
1735-
if (balance.ratio > MAX_SHARD_OVER_MEAN) {
1828+
if (balance.wallRatio > MAX_SHARD_OVER_MEAN) {
17361829
throw new Error(
1737-
`balance: at ${SHARD_COUNT} shards the heaviest bin is ${balance.ratio.toFixed(2)}x the mean ` +
1738-
`(${balance.max.toFixed(0)}s vs ${balance.mean.toFixed(0)}s), past the ${MAX_SHARD_OVER_MEAN}x bound. ` +
1739-
'Bins: ' + balance.totals.map((t) => t.toFixed(0)).join('/') + 's.'
1830+
`balance: at ${SHARD_COUNT} shards the slowest predicted test step is ${balance.wallRatio.toFixed(2)}x the mean ` +
1831+
`(${balance.wallMax.toFixed(0)}s vs ${balance.wallMean.toFixed(0)}s), past the ${MAX_SHARD_OVER_MEAN}x bound. ` +
1832+
'Walls: ' + balance.walls.map((t) => t.toFixed(0)).join('/') + 's; bins: ' +
1833+
balance.totals.map((t) => t.toFixed(0)).join('/') + 's.'
17401834
);
17411835
}
17421836
});
17431837

1838+
// 2b. A SLICE IS PLACED AS THE SERIAL LEG IT IS (#25870). Three shards, a
1839+
// package cut in two (100s a slice) and three whole packages (100s, 50s,
1840+
// 50s). Placed by SUM -- the LPT this replaced -- each slice shard also
1841+
// takes a 50s whole package, which runs four-wide BEFORE the slice and
1842+
// lengthens that shard's step to ~117s. Placed by wall, the slices run
1843+
// alone and the three whole packages share the third shard (~69s). The
1844+
// two placements differ, so a revert to the sum reds here.
1845+
const serial = partition(
1846+
[
1847+
{ name: 's 1/2', weight: 100, slice: { index: 1, count: 2 } },
1848+
{ name: 's 2/2', weight: 100, slice: { index: 2, count: 2 } },
1849+
mk('x', 100),
1850+
mk('y', 50),
1851+
mk('z', 50),
1852+
],
1853+
3
1854+
);
1855+
check(() => {
1856+
const crowded = serial.filter((bin) => bin.names.some((n) => n.startsWith('s ')) && bin.names.length > 1);
1857+
if (crowded.length > 0) {
1858+
throw new Error(
1859+
`slice placement: a slice shard also carries whole packages (${crowded.map((b) => b.names.join('+')).join('; ')}) ` +
1860+
'that run before its serial slice -- are the bins placed by their plain SUM again?'
1861+
);
1862+
}
1863+
});
1864+
check(() => {
1865+
const walls = serial.map(wallOf);
1866+
const expected = [100, 100, 200 / WHOLE_LEG_PARALLELISM];
1867+
if (walls.some((w, i) => Math.abs(w - expected[i]) > 1e-9)) {
1868+
throw new Error(`slice placement: predicted walls ${walls.map((w) => w.toFixed(2)).join('/')}s, expected ${expected.map((w) => w.toFixed(2)).join('/')}s`);
1869+
}
1870+
});
1871+
17441872
// 3. The bound is REACHABLE at this shard count -- the arithmetic #10472
17451873
// asked about when it floated 6 -> 8. No split can put the heaviest bin
17461874
// below the heaviest single package, so once that package exceeds
@@ -3014,7 +3142,8 @@ function selfTest() {
30143142
console.log(
30153143
`partition-test-shards: self-test OK (${datasetPackages.length} measured packages ` +
30163144
`-> ${datasetItems.length} shard items, ${SHARD_COUNT} shards, ` +
3017-
`max/mean ${balance.ratio.toFixed(2)}x <= ${MAX_SHARD_OVER_MEAN}x, floor ${balance.floor.toFixed(0)}s, ` +
3145+
`predicted test-step walls ${balance.walls.map((t) => t.toFixed(0)).join('/')}s, ` +
3146+
`max/mean ${balance.wallRatio.toFixed(2)}x <= ${MAX_SHARD_OVER_MEAN}x, floor ${balance.floor.toFixed(0)}s, ` +
30183147
`bins ${balance.totals.map((t) => t.toFixed(2)).join('/')}s within the ${datasetCap.toFixed(2)}s density cap, ` +
30193148
`file-level slices: ` +
30203149
`${datasetPlan.decisions.filter((d) => d.count > 1).map((d) => `${d.name} x${d.count}`).join(', ') || 'none'})`
@@ -3137,7 +3266,7 @@ function main() {
31373266
{ excluded }
31383267
);
31393268
const mine = bins[shardIndex - 1];
3140-
const { max, mean, ratio } = balanceOf(bins);
3269+
const { max, mean, ratio, walls, wallRatio } = balanceOf(bins);
31413270
// Printed on every shard, not just the imbalanced one, and printed as the
31423271
// RATIO the acceptance bound is written in: the per-shard numbers alone never
31433272
// said whether the split was balanced, which is why #10472's imbalance had to
@@ -3151,6 +3280,13 @@ function main() {
31513280
`density cap ${cap.toFixed(2)}s; ` +
31523281
`${packages - estimated} measured, ${estimated} estimated from test-file count)`
31533282
);
3283+
// What the bins were placed by (wallOf, #25870): the test step each shard is
3284+
// predicted to take, a slice counted as the serial leg it runs as.
3285+
console.error(
3286+
` predicted test-step walls (whole / ${WHOLE_LEG_PARALLELISM} + slice): ` +
3287+
`${walls.map((w) => w.toFixed(0)).join('/')}s, this shard ${wallOf(mine).toFixed(0)}s; ` +
3288+
`max/mean ${wallRatio.toFixed(2)}x`
3289+
);
31543290
// Only a document that states its own slicing prints this, so a Test Core
31553291
// shard's log is unchanged; the nightly's says where its slice count came from.
31563292
if (acrossShards !== null) {

0 commit comments

Comments
 (0)