Skip to content

Loadout Optimizer: branch-and-bound search pruning (100x+ on real vaults), balanced slicing, warm worker pool#11868

Merged
delphiactual merged 7 commits into
masterfrom
lo-perf-bnb
Jul 9, 2026
Merged

Loadout Optimizer: branch-and-bound search pruning (100x+ on real vaults), balanced slicing, warm worker pool#11868
delphiactual merged 7 commits into
masterfrom
lo-perf-bnb

Conversation

@delphiactual

@delphiactual delphiactual commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

11862:
image
11868:
image

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 #11862 process(), same inputs, min of 7 interleaved rounds, on a real vault (my own, via a GetProfile export):

search before after
"show everything" (no stat minimums) 6.6 s 12 ms (~530x)
with 90/90/60 minimums 7.9 s 0.84 s (9.5x)
synthetic (uniform-random smoke test) 3.0x / 1.2x / 3.2x

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):

  • The tracker-floor bound uses each remaining bucket's best single-item enabled-stat total, not the sum of per-stat maxes — the latter is wildly loose on real rolls (each stat's max comes from a different piece no set combines) and never prunes.
  • The stat ranges otherwise force visiting nearly every set, so the exact ranges are seeded up front (min from summed per-bucket floors; max from the mod solver on each stat's maximizing set) so the in-loop range checks stop blocking. When stat minimums are set, the max-range bound also reserves the mods needed to hit the other minimums, so it stays tight.

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.ts runs 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 via LO_BENCH_PROFILE and a load-balancing simulation.

Changelog: The Loadout Optimizer is dramatically faster, especially "show all sets" searches.

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 bhollis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Base automatically changed from lo-exotic-tuning-tail to master July 9, 2026 11:46
@delphiactual delphiactual merged commit 2cc6056 into master Jul 9, 2026
4 checks passed
@delphiactual delphiactual deleted the lo-perf-bnb branch July 9, 2026 11:46
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 participants