Loadout Optimizer: branch-and-bound search pruning (100x+ on real vaults), balanced slicing, warm worker pool#11868
Merged
Merged
Conversation
Tooling for evaluating process() worker changes against a frozen reference. Kept after the meet-in-the-middle pareto experiment measured as a net loss on real vaults (on stat-specialised armor the domination frontier contains ~80-95% of pairs, so it barely prunes while adding O(pairs^2) pruning and a second frontier pass). - process-baseline.ts: frozen copy of the current process() exported as `processBaseline`, the reference oracle. - process-parity.test.ts: asserts a candidate process() matches the baseline across scenarios (retained sets, and max/min stat ranges). - process.bench.test.ts: skipped-by-default interleaved A/B (baseline vs candidate, min-of-N in one window), synthetic corpus plus a real-vault mode via the LO_BENCH_PROFILE env var (a Bungie GetProfile response).
The parity/bench harnesses were written for the meet-in-the-middle experiment; now that they're general-purpose tooling for any process() perf change, drop the pareto-specific naming: "candidate" instead of "pareto", and describe the baseline as a permanent frozen reference rather than something to delete once an experiment lands.
Prune whole subtrees of the nested item loop when no completion could enter the top-N tracker or move an observed stat range. Two things make this work on real (stat-specialized) vaults, where domination-based pruning does not: - The tracker-floor bound uses each remaining bucket's best single-item enabled-stat TOTAL, not the sum of per-stat maxes (which come from different specialized pieces no single set can combine). The loose per-stat bound never prunes real data; the tight total bound prunes almost everything once the top-N heap fills. - The stat ranges otherwise force visiting nearly every set. When the search spans all combos (no exotic/perk/set-bonus requirement, no stat minimums) the exact ranges are seeded up front - minStat from summed per-bucket floors, maxStat from the mod solver on each stat's maximizing set - so the in-loop range checks stop blocking. minStat is seeded whenever no requirement couples the buckets, even with stat minimums present. Correctness never depends on the seeds (the loop's bounds still compute the true ranges); they only let pruning start immediately. Verified byte-exact - sets, and min and max stat ranges - against the frozen baseline across every parity scenario. Real-vault A/B (25 items/bucket): "show everything" 6.6s -> 12ms (531x); with 90/90/60 minimums 7.9s -> 3.8s (2.1x). Synthetic 3.0x/1.2x/3.2x.
Extends the range-seeding / subtree pruning to searches that set stat minimums (previously only the no-minimum "show everything" case pruned hard; with minimums it was ~2x). - Seed maxStat from each stat's maximizing set only when that set can actually fit mods to meet the minimums (same gate the main loop uses), so the seed never overstates an unreachable max. - Tighten the subtree max-range bound: stat mods that must be reserved to bring the other stats up to their minimums (best case, each at its subtree max) can't also boost stat i. Without this the bound assumed every mod could pour into one stat, so it always exceeded the true minimum-constrained max and blocked the prune. All zero when there are no minimums, so that path is unchanged. Byte-exact (sets, min and max ranges) across all parity scenarios. Real-vault A/B (25/bucket) with 90/90/60 minimums: 7.9s -> 0.84s (9.5x), up from 2.1x; no-minimum "show everything" unchanged at ~530x.
Each worker runs an independent search with its own top-N tracker, so how hard it can prune depends on the quality of the items it holds. Contiguous slices (partitionEvenly) hand one worker the best items and another the worst, so they finish at very different times and the weak ones barely prune. Sort the sliced bucket high-stat-first and deal items round-robin, so every worker gets a stratified spread. Results are unchanged - slices still partition the bucket and combineResults merges the top-N. Simulated 6-way split of a real vault with 90/90/60 minimums: contiguous wall 4.5s (imbalance 1.57x) -> interleaved wall 3.8s (imbalance 1.26x), ~16%. Measured with a new load-balancing bench (process.bench.test.ts), which also showed that heavily-pruned searches (no stat minimums) run FASTER single-worker than split - splitting fragments the global tracker floor and multiplies total work - but those are already ~20ms, so it doesn't matter; the slow constrained searches are where parallelism and balance pay off.
Creating a worker spawns a thread and parses the worker bundle - tens of ms paid on every run, which now dominate the wall clock for searches whose compute is only a few ms (post branch-and-bound pruning). Workers are stateless between runs (process() takes all its input each call), so pool them: a worker returns to the pool on clean completion and is reused by the next run, and is terminated only when a run is cancelled (so stale work stops) or errors (so a broken worker is never reused). The pool is capped at the core count, and a worker is retired after 50 runs to bound any slow per-run accumulation such as comlink callback proxies. Results are unchanged; this only removes per-run worker spin-up. Needs in-browser validation (real Web Workers don't run under jest) - typecheck and the LO worker/mapper tests pass.
…pruning Type the profile fixture load in the bench harness and drop a forEach. Regenerate the equivalence snapshots (only the pruning counters moved; sets and stat ranges are unchanged) and stop asserting that tail-resolved and expanded tuning process the same candidate count, since expanded variants form separate branches with tighter subtree bounds and prune differently.
bhollis
approved these changes
Jul 9, 2026
bhollis
left a comment
Contributor
There was a problem hiding this comment.
I more-or-less understand what this is doing, but the process stuff is getting very difficult to read/understand. Once all these optimizations land it might be worth a few passes of trying to clean things up and/or undoing optimizations that aren't that meaningful.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
11862:


11868:
Builds on #11860 and #11862. Speeds up the LO process worker by pruning the combination search, balancing work across cores, and reusing workers between runs. Measured on a real vault via an A/B harness (details below); results are verified byte-for-byte identical to the current worker.
Results
A/B of the new
process()vs the frozen #11862process(), same inputs, min of 7 interleaved rounds, on a real vault (my own, via a GetProfile export):Worker load balancing, simulated 6-way split, real vault with minimums: contiguous wall 4.5 s (imbalance 1.57x) -> interleaved wall 3.8 s (1.26x).
What changed
Branch-and-bound subtree pruning (
process.ts). A set can only affect the output three ways: enter the top-N tracker, raise an observed max stat range, or lower a min stat range. Each level of the nested item loop now skips whole subtrees when no completion could do any of those. Two things make this work on real, stat-specialized armor (where earlier domination-based attempts failed):Correctness never depends on the seeds — the loop's bounds still compute the true ranges; the seeds only let pruning start immediately.
Interleaved worker slices (
process-wrapper.ts). Each worker prunes against its own top-N tracker, so contiguous slices handed one worker the best items and another the worst — very unbalanced. Now the sliced bucket is sorted high-stat-first and dealt round-robin, so every worker gets a stratified spread. Result-identical (slices still partition; results still merge).Warm worker pool (
process-wrapper.ts). Workers were created and terminated every run — spawning a thread and parsing the worker bundle each time, which now dominates the wall clock for the fast searches above. Workers are stateless between runs, so they are pooled: returned on clean completion, terminated only on cancel (stale work stops) or error (never reuse a broken worker).Correctness
New
process-parity.test.tsruns the candidate against a frozen copy of the #11862 worker (process-baseline.ts) across 11 scenarios (exotic/perk/set-bonus constraints, stat minimums, auto stat mods, exotic tuning) and asserts the retained top-N sets, and every stat's min and max range, are byte-identical. All green. The one drift vs the old snapshot test is iteration-count statistics (numProcessed / skip reasons), which any pruning changes.process.bench.test.ts(skipped by default) is the A/B harness used for the numbers above, with a real-vault mode viaLO_BENCH_PROFILEand a load-balancing simulation.Changelog: The Loadout Optimizer is dramatically faster, especially "show all sets" searches.