Skip to content

boot-sp PR-4: K>1 batch executor (groups of K ranks; wheel per group) - #18

Draft
DLWoodruff wants to merge 23 commits into
bootsp-pr-cfrom
bootsp-pr-d
Draft

boot-sp PR-4: K>1 batch executor (groups of K ranks; wheel per group)#18
DLWoodruff wants to merge 23 commits into
bootsp-pr-cfrom
bootsp-pr-d

Conversation

@DLWoodruff

@DLWoodruff DLWoodruff commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Stacked PR — fork-only for now. Base is bootsp-pr-c (PR-3), so this diff is
PR-4 alone. The branch itself carries the whole boot-sp stack (PR-1→4), so
git checkout bootsp-pr-d is self-contained and runnable. Opens upstream only
after PR-2 (now upstream Pyomo/mpi-sppy#818)
and PR-3 land.

⚠️ Migration note (for anyone already tracking this branch)

This branch was force-pushed again (2026-07-26). A review against the
Chen & Woodruff papers found seven issues; the fixes were placed on the lowest
branch whose code they correct, so bootsp-pr-b (PR-2) and bootsp-pr-c (PR-3)
were both rewritten and this branch was rebased onto the new pr-c.

Re-sync, don't merge. Every commit SHA is new (previous published tip
15bedb80c3946212; the tip before that was 5b4e48b6). Reset your
checkout onto the new tip:

git fetch origin
git checkout bootsp-pr-d && git reset --hard origin/bootsp-pr-d
# for work branched off it (substitute the tip you branched from):
git rebase --onto origin/bootsp-pr-d 15bedb80 <your-branch>

A plain git pull will try to merge the old and new histories.

Smoothed bootstrap results change substantially. Two deviations from
CW2024 Algorithm 3 were corrected in PR-2, which this branch inherits: the
batches were overlapping sliding windows rather than independent resamples, and
the center was the empirical gap rather than the smoothed one. On the cvar
example at N=20, nB=10 the interval widens about fivefold — the previous
smoothed intervals were far too narrow. Smoothed_bagging is unaffected except
for a ddof=1 correction to its variance. The empirical methods are unchanged.

Bootstrap CI bounds change; point estimates do not. No CLI/flag changes —
the same commands run. But the interval endpoints move, from two fixes the
branch now inherits from main (they postdated this branch's old base):

  • Sample splitting — the CI resampling pool now excludes the records used
    to compute xhat. The interval is estimated out-of-sample (on data that did
    not choose xhat) rather than optimistically in-sample, so it is honest and
    generally a touch wider.
  • Independent, pair-seeded RNG streams — every resample draws differently,
    so the bounds shift. (The K > 1 batch executor's per-group seeding changed
    too, so those runs move as well.)

The gap point estimates (the centers) are unchanged — they are deterministic.
Only recorded/expected CI bounds need updating.

New requirement. Sample splitting needs
sample_size + candidate_sample_size ≤ max_count. A config that previously
over-subscribed the dataset now fails fast with a clear error instead of
silently overlapping the candidate and CI records.

The generic_cylinders bootstrap now honors sample splitting end to end
do_boot holds the M candidate records out of the N-record pool
positionally, so the integrated --boot-* path is consistent with the
standalone estimators.

You also inherit current upstream main (CVaR η-bound, CI ruff pin, etc.),
since the branch was rebased onto it.

What this adds

The K > 1 batch executor for the data-based bootstrap in generic_cylinders
(design doc/designs/bootsp_merge_design.md §9.4). A resampled batch is no
longer only a per-rank extensive form: it can be solved by a group of K
ranks running a wheel
. The R ranks partition into G = R // K groups that
run concurrently, each solving its share of the batches in sequence, with the
results gathered to rank 0.

The two endpoints are the same mechanism, so this is one executor with
--boot-ranks-per-batch as its only new rank knob:

  • K = 1G = R: each rank its own group, a direct EF per batch (what PR-3
    shipped). Reproduced bit-for-bit as the degenerate case, so the standalone
    user_boot / simulate_boot are unaffected.
  • K = RG = 1: one group of all ranks, a wheel per batch in sequence (the
    development checkpoint).

The pieces

  • BatchExecutor (confidence_intervals/bootsp/batch_executor.py) — owns
    the rank arithmetic and the collectives: each group's communicator (wheel /
    xhat-evaluation), a leaders-only communicator (the cross-group Gatherv),
    per-group batch share and rng seed, and gather().
  • Estimator refactor (boot_sp.py) — the empirical estimators route their
    parallelism through the executor; the five repeated Gatherv blocks collapse
    into executor.gather, and a single _batch_optimal_value is the one entry
    for the batch optimal L_b (EF best bound at K = 1, wheel outer bound at
    K > 1). The smoothed methods and the simulation prep keep the K = 1-only
    path via an optional mpicomm.
  • Batch wheel builder (generic/boot_batch.py) — --boot-batch-config-file
    is parsed as a file of generic_cylinders flags and turned into a per-batch
    wheel (reusing the hub/spoke/decomp builders with injected sample-scenario
    names); its outer decomposition bound is read back as L_b.
  • Wiringdo_boot/config drop the K != 1 restriction, require the
    batch config file, and retire the interim --boot-solver-name /
    --boot-solver-options
    (the batch solver now comes from the file).
  • register_generic_args factored out of parse_args (pure refactor) so the
    batch config file and the main CLI share one registration point.

Why the outer bound (design §9.4.1)

A batch's reported gap is its value at xhat (an inner bound, solver-exact) minus
its optimal. For the optimal the executor uses the wheel's outer
(decomposition) bound, not an incumbent — a MIP batch carries a bound slack even
at K = 1, so inner−outer keeps the reported point estimate conservative — it
never understates the gap an exact solve would report.

The interval endpoints carry no such guarantee, and an earlier version of
this description claimed they did. Each endpoint combines the point estimate
with a width built from the spread of the batch gaps, and a bound slack
perturbs that spread without a sign; the pivotal methods (Classical_quantile,
Subsampling, Extended) reflect the bootstrap quantiles about the point
estimate, so a batch slack can move both of their endpoints down. All the
methods do converge to the exact-solve CI as the batch gap tightens, so
--rel-gap in the batch config file is the lever when the interval matters.

Validation

End to end on the schultz_data MIP, both endpoints reproduce the design's math
exactly: the G = 1 checkpoint (np=2, K=2) and G > 1 (np=4, K=2) both give the
same value at xhat as the K = 1 EF path (the xhat-evaluation is
K-invariant), while their Lagrangian/subgradient outer bound sits at or below the
EF optimum — so the reported gap is conservative.

  • mpisppy/tests/test_boot_generic.py::test_do_boot_g1_wheel_matches_ef encodes
    this self-validatingly (no magic numbers), gated on n_proc >= 2 so it runs
    under the existing mpiexec -np 2 test_boot_generic invocation — no coverage
    harness change
    .
  • All boot tests green: serial 48 passed / 3 skipped; mpiexec -np 2 33 passed
    / 2 skipped across the three MPI-run suites (incl. the G=1 wheel test).
    ruff clean, headers OK, docs build with no warnings.

Review fixes carried by this branch

A review of the whole stack against Chen & Woodruff (2023, 2024) and the
certified-bounds notes produced seven findings. Each fix sits on the lowest
branch whose code it corrects, so most arrive here by inheritance:

  • PR-2 (Bootstrap/bagging confidence intervals (boot-sp merge PR-2: statdist + smoothed methods) Pyomo/mpi-sppy#818) — the two CW2024 Algorithm 3 deviations in the smoothed
    bootstrap, sample variances (ddof=1) in smoothed bagging, and a
    maximization guard in solve_routine.
  • PR-3 — the conservatism wording above, the reference z* kept as the
    incumbent (with a convergence check), and a consistent fallback when the
    solver proves no outer bound.
  • PR-4 (this branch) — the pieces that only exist once cylinders solve the
    batches: do_boot probes one scenario for its objective sense (a K > 1
    batch is a wheel, so solve_routine's check never sees it), _best_bound's
    docstring names its boot_batch sibling and why that one raises where this
    one falls back, and the relocated rst section regains the corrected wording.

Runnable demo

examples/bootsp/schultz_data/schultz_data_boot_cylinders.bash — 6 ranks find
xhat together, then re-form into two groups of three that solve the bootstrap
batches concurrently, each batch by a subgradient wheel (config in the committed
schultz_wheel_batch.txt). Runs in ~4 s with any MIP/MIQP solver; the log shows
the two concurrent 3-rank groups.

🤖 Generated with Claude Code

DLWoodruff and others added 17 commits July 26, 2026 16:30
Pure refactor: pull the full generic_cylinders option-registration sequence
out of parse_args into a reusable register_generic_args(cfg, module) so a
second consumer -- the bootstrap batch config file, which is literally a batch
generic_cylinders configuration -- can build an identical Config and then read
its options from a file rather than the command line. parse_args now just calls
it and parses argv. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New mpisppy/confidence_intervals/bootsp/batch_executor.py. A batch is solved by
a group of K ranks (--boot-ranks-per-batch); the bootstrap partitions the R
ranks into G = R // K groups that run concurrently, each solving its share of
the batches in sequence, with the results gathered to rank 0 (design 9.4).

The executor owns only the rank arithmetic and the collectives: it exposes each
group's communicator (for the wheel / xhat evaluation), a leaders-only
communicator (for the cross-group Gatherv), the per-group batch share and rng
seed, and a gather() that assembles the per-group results on global rank 0. The
two endpoints are the same mechanism -- K=1 is G=R (each rank its own group, a
direct EF per batch) and K=R is G=1 (one group, a wheel per batch in sequence).
K=1 reproduces the original per-rank behavior bit-for-bit, so the standalone
drivers are unaffected. K must divide the rank count (leftover-rank handling is
a scheduled refinement).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The empirical estimators (classical/subsampling/extended/bagging and their
resample helpers) now take a BatchExecutor (default K=1) instead of the module
COMM_WORLD/rankcomm globals: group seed and batch share from the executor, the
group communicator for the solves and xhat evaluations, and executor.gather in
place of the five repeated Gatherv blocks. A single _batch_optimal_value is the
one entry for the batch optimal L_b -- a direct-EF best bound at K=1, the wheel
outer bound (via executor.batch_optimal_solver) at K>1 -- so the estimator math
is identical across K. extended's center block gates on group 0 (its solves are
collective on the group) rather than on rank 0.

evaluate_routine/evaluate_scenarios gain an optional mpicomm (default rankcomm)
so the smoothed methods and the simulation prep, which are K=1 only, keep their
old behavior. solve_routine, slice_lens and the module globals are retained for
them. The standalone user_boot/simulate_boot paths are unchanged (their tests
pass with the default K=1 executor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New mpisppy/generic/boot_batch.py. For K>1 a resampled batch is solved by a
full hub/spoke wheel rather than a direct EF. How that wheel is configured is a
different problem from the xhat solve (a batch has N scenarios, usually far more
than the M candidate records), so it is supplied separately as a file of
generic_cylinders flags:

- parse_batch_config_file reads the file as a generic_cylinders command line
  (via register_generic_args + create_parser; # starts a comment), so it is
  literally a batch generic_cylinders configuration.
- make_batch_optimal_solver returns the callable the executor invokes per
  batch: it builds a wheel over the batch's sample scenarios (reusing the
  generic hub/spoke/decomp builders with injected SampleScenario names and the
  positional sample->record mapping), runs it on the group communicator, and
  returns the wheel's outer (decomposition) bound as L_b. The bound is shared
  across the group with allgather (WheelSpinner reports it only on hub ranks);
  a config that yields no outer bound (a bare PH hub) is rejected with a clear
  message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
config.boot_args gains --boot-batch-config-file (required for a bootstrap run)
and clarifies --boot-ranks-per-batch (K); it retires the interim
--boot-solver-name / --boot-solver-options, whose role the batch config file
now fills.

do_boot no longer rejects K>1: it parses the batch config file, builds the
executor first (so K is validated against the rank count before the K>1 wheel
solver, which validates the batch rho), attaches the wheel solver only when
K>1, and passes the executor to compute_ci. The estimator's solver name and
options now come from the batch config file (they govern the K=1 EF and the
xhat-evaluation solves, so both agree with the K>1 wheel). boot_requested now
errors when no batch config file is given.

test_boot_generic: batch config fixtures (per-rank, with teardown), a
config-file solver-role test replacing the retired boot-solver test, a
batch-config-required test, a bad-ranks-per-batch test, and
test_do_boot_g1_wheel_matches_ef -- the G=1 checkpoint (K=n_proc). It is
self-validating: the wheel path reproduces the K=1 EF value at xhat exactly
(K-invariant), its outer bound sits at or below the EF optimum, and the
reported gap is conservative. Gated on n_proc>=2, so it runs under the existing
mpiexec -np 2 test_boot_generic invocation with no harness change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
boot_sp.rst: document --boot-batch-config-file and its role split from the xhat
solve, add the "MPI ranks: groups of K" section, drop the retired
--boot-solver-* rows, and update the example to pass a batch config file.
schultz_data_boot.bash writes a one-line (K=1) batch config to a temp file.
The design doc status and section 9.4 are marked implemented (the G=1 checkpoint
and G>1 validated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
examples/bootsp/schultz_data/schultz_data_boot_cylinders.bash runs the
wheel-per-batch path end to end: 6 ranks find xhat together (PH hub +
xhatshuffle + lagrangian), then re-form into G = 2 groups of 3 that solve the
bootstrap batches concurrently, each batch by a subgradient wheel configured in
the committed schultz_wheel_batch.txt. The solver flows through so it runs with
any MIP/MIQP solver. boot_sp.rst points at the demo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oot_sp.rst

The "In generic_cylinders" section's first mention of the driver now links to
its own page via :ref:`generic_cylinders`, matching how the other docs point at
it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A batch was described as having "N scenarios, usually far more than the M
candidate records." That is wrong: M (candidate_sample_size) and N (sample_size)
are independent user choices with no inherent ordering (only M + N <= dataset
size is enforced), and the batch scenario count is N only for the classical and
extended methods -- it is the subsample size for subsampling and bagging. The
real reason the batch is configured separately is simply that it is a different
instance: a resample of the data, sized independently of the candidate records.
Reworded across boot_sp.rst, the design doc, boot_batch.py, and the
--boot-batch-config-file help.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The K=1 and K>1 batch config examples shared a single literal block, so they
read as the contents of one file (which would be nonsense -- two --solver-name
lines, and the two cases never coexist). Split them into two labelled blocks
with a sentence making clear they are alternatives (one file per run), and trim
the now-redundant "K=1 needs only a solver / K>1 needs a wheel" prose to just
the outer-bound requirement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… cap

Address three gaps in the "MPI ranks: groups of K" subsection:
- K must not only divide the rank count but, for K>1, be a multiple of the
  number of cylinders in the batch config (WheelSpinner splits the group's K
  ranks evenly among the hub and spokes). Documented in the flag table and the
  subsection; a two-cylinder batch needs an even K.
- The K>1 example was a bare subgradient hub with no spokes; make it a real
  hub-and-spoke wheel (PH hub + Lagrangian bounding spoke).
- Because the G groups run concurrently, add --max-solver-threads to the K>1
  example and explain that it keeps the concurrent solves from oversubscribing
  the cores.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"wheel" was overused and is not the house term: outside boot_sp.rst the docs
describe this system as hub-and-spoke / cylinders (WheelSpinner is only ever
named as the API object). Replace the seven prose uses with "cylinders" / "a
cylinder solve" / "cylinder configuration". Not "multi-cylinder", since a K>1
batch can be a single cylinder spanning K ranks (the subgradient-hub demo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The page was ~12 flat top-level sections. Group them into five: an intro that
now includes a roadmap paragraph, then Background (Methods, The model module,
The optimality gap), Standalone drivers (Modes, Arguments, Batch parallelism,
boot_general_prep, Examples), In generic_cylinders (Options, The batch config
file, MPI ranks: groups of K, Example), Smoothed methods, and References.

- The outer-bound paragraph moves out of "Batch parallelism" into a Background
  subsection "The optimality gap" (it is interface-agnostic).
- The two example sections merge under Standalone drivers > Examples
  (On-the-fly data / From a dataset file).
- The generic_cylinders material gains subsections.
- "Batch parallelism" now hyperlinks to the "MPI ranks: groups of K" section.

Content (tables, code blocks, citations, examples) is unchanged; only structure
and the new roadmap paragraph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reorder the Examples subsections so the data-based example (schultz_data) comes
first, matching the page's framing; the on-the-fly schultz example follows,
reworded to say plainly what it is (data computed from the scenario number, no
dataset file) and flagged as an unusual, testing-oriented example. Drops the
non-sequitur "reproducible across solvers" claim. Also fixes an intro typo
("boostrap") and a missing comma.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the generic_cylinders hold-out split the candidate block (M) and the
resampling pool (N) are disjoint, so M + N cannot exceed the dataset size;
normally they add up to it so no record goes unused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DLWoodruff and others added 6 commits July 26, 2026 16:30
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rd pool)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The generic_cylinders runs (the xhat solve in the docs' end-to-end example and
in both schultz_data bash demos) and the K>1 batch config run cylinders, so
several solvers are in flight at once; cap threads per solver to avoid
oversubscribing the cores.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The maximization guard, the conservatism wording and the outer-bound fallback
all landed in PR-2/PR-3, where the code they correct lives. Three pieces only
make sense once cylinders solve the batches, so they land here:

- do_boot probes one scenario for its objective sense. solve_routine makes the
  same check, but only K=1 builds an extensive form for it to look at; a K>1
  batch is solved by a wheel, so nothing else would catch a maximization model
  on that path. Section 9.4.1 updated to say so.
- _best_bound's docstring can now name its K>1 sibling,
  boot_batch._outer_bound_over_group, and why that one raises where this one
  falls back: an extensive form has an incumbent to offer in place of an
  unproved bound, and a wheel run for its outer bound does not.
- the rst's optimality-gap section, which the nested-structure reorganization
  relocated, gets the corrected wording back, and can point at --rel-gap in the
  batch config file (a PR-4 flag) as the lever for tightening the batch solves.

Also design 4.11, which named boot_sp._batch_rng: this PR removed it in favor
of BatchExecutor.group_seed, so the reference is updated to the two streams
that now exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant