Skip to content

Bins-sharded multi-device fits (--nDevices) - #154

Open
davidwalter2 wants to merge 12 commits into
WMass:mainfrom
davidwalter2:260828_multiDeviceFitter
Open

Bins-sharded multi-device fits (--nDevices)#154
davidwalter2 wants to merge 12 commits into
WMass:mainfrom
davidwalter2:260828_multiDeviceFitter

Conversation

@davidwalter2

@davidwalter2 davidwalter2 commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Depends on #153 (native TensorFlow trust-region minimizers). This branch is
based on 260826_tfMinimizer, so until #153 merges the diff below also shows
its commits; it reduces to the seven listed at the end once #153 lands. Review
from "Add multi-device fits" onward.

What this adds

--nDevices N splits the likelihood's bins across N devices. Each shard is a
duck-typed evaluator exposing the attributes the existing Fitter methods
read, and the Fitter's own unbound methods are bound onto it, so both
interpolation forms, both systematic types and the bin-by-bin-stat machinery
are shared with the single-device path by construction rather than duplicated.

Shard graphs are jit-compiled individually (XLA clusters are single-device) and
the thin combiner stays a plain graph. --devices picks the GPUs explicitly;
otherwise the least-occupied ones are chosen, so a fit started on a shared node
lands next to nobody.

On a 92144-bin, 6538-parameter W+Z fit across 4 V100s this preconditions all 71
parameter blocks in under 7 minutes and then iterates in seconds.

The dense Hessian

Both the preconditioner's reference matrix and the postfit covariance need a
dense [npar, npar] Hessian. It is assembled a batch of columns at a time from
Hessian-vector products, which are the same HVPs the minimiser already
evaluates each iteration, so the work stays distributed across the shards.

tf.vectorized_map is a pfor of width k, so XLA compiles it without the
unrolling that a while_loop of the same shape produces, and peak memory is
one [k, npar] batch rather than the [npar, nbins, 9] that vectorising
tape.jacobian over every parameter at once would need (43 GB for this model,
~70 GB per device once sharded). --hvpBatch (default 256) sets k, and the
batch halves itself on ResourceExhaustedError, so it is an upper bound rather
than a number that has to be tuned.

Regularizers

Regularizer.needs_observables declares whether a penalty reads the predicted
yields. Penalties that depend only on the parameters are supported here: every
device holds the whole parameter vector, so the penalty is evaluated once in
the global term gnll_local, which gvg and gvgp differentiate — gradient
and HVP come for free. Penalties that read the yields are rejected in
arm_regularizers, which runs when regularizers are present and before any
loss is evaluated; the graphs are rebuilt there, since __init__ traces them
before --regularizer has been parsed.

The flag also pays off single-device: _compute_nll_components only builds
full=True yields when some regularizer actually needs them, and each
regularizer is handed None rather than a truncated vector when it has
declared it does not.

Not supported in sharded mode

Checked at construction: sparse tensors, --covarianceFit.

Rejected at the point of use, before the fit starts: regularizers with
needs_observables=True, --fullNll, global impacts (--doImpacts), and toys
(-t > 0). These are global over all bins on one device, which is what the
sharding exists to avoid; each raises NotImplementedError naming the flag and
the single-device remedy. The fwdrev HVP falls back to revrev.

Testing

tests/test_sharded_fit.py checks a 2-device fit against the single-device one
on parameters, uncertainties, NLL and EDM; that a parameter-only penalty shifts
the sharded loss by exactly the amount it shifts the single-device loss, and
reaches the gradient; and that each unsupported postfit step raises rather than
running. 114 tests pass on this branch.

Commits

  • Add multi-device fits: bins-sharded likelihood via --nDevices
  • Only occupy the GPUs a fit actually uses, preferring unoccupied ones
  • Move multi-device support into a MultiDeviceFitter subclass
  • Forward all Fitter kwargs through make_fitter
  • Let a regularizer declare whether its penalty reads the yields
  • Assemble the dense Hessian from batched Hessian-vector products
  • Stop multi-device fits silently computing the wrong thing

🤖 Generated with Claude Code

https://claude.ai/code/session_01HrsrmERvJF2Vafn7wE1B3v

davidwalter2 and others added 12 commits August 26, 2026 11:07
…vice

New --minimizerMethod tf-trust-exact: a TensorFlow port of scipy's
trust-exact (More-Sorensen) where the Hessian, the H + lambda*I builds,
the Cholesky factorizations and the triangular solves stay on the TF
device, so the n x n matrix never round-trips to LAPACK per lambda
trial. The python outer loop mirrors scipy's _minimize_trust_region and
plugs into the existing callback / early-stopping / restart / result
plumbing unchanged, including the preconditioner (applied at the same
internal-coordinate boundary as for the scipy methods).

Differences from a line-for-line port, all deliberate:
- No custom op for the potrf failure index: tf.linalg.cholesky signals
  a non-PD input by filling the factor with NaNs on every backend, and
  a failed factorization already proves lambda_current is a valid lower
  bound, so the safeguarded bracket update converges without the
  index-accelerated bound (a few extra cheap factorizations at worst).
- The Hessian closure runs only on accepted steps; scipy's subproblem
  constructor consumes Hessian norms and therefore pays a Hessian per
  proposal.
- lambda_new is clamped to >= 0 (the interior-case Newton correction
  can otherwise push the bracket negative) and the matrix norms are
  computed as matrix norms -- tf.norm's default axis=None flattens the
  tensor and its max|H_ij| can sit below |lambda_min|, silently
  invalidating the lambda_ub bracket.
- The rare hard-case refinement downloads the factor once and reuses
  scipy's host-side smallest-singular-value estimate.

Validated against scipy on random PD/indefinite subproblems (worst-case
model reduction 96.7% of the exact optimum vs scipy's 96.0%, ~1.6
factorizations per solve), on Rosenbrock and an ill-conditioned
quadratic (identical minima to ~1e-8), and through a full Fitter fit on
the test tensor against the scipy path. Without a visible GPU a warning
points users back to scipy trust-exact: TF's CPU Cholesky kernel is
single-threaded Eigen and measured ~7x slower at n=2000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
The matrix-free counterpart of tf-trust-exact: the same truncated-CG
trust-region subproblem as scipy's trust-ncg (the practical stand-in
for trust-krylov, which solves the identical subproblem via GLTR), but
the entire CG iteration runs inside a single tf.function while_loop.
One solve costs one graph dispatch instead of one python round trip --
x assignment, numpy conversion both ways, forced device sync -- per
Hessian-vector product, which is what the scipy callback path pays and
what dominates when individual HVPs are fast.

Exact improvements over scipy's loop, with identical iterates:
- Hz is carried through the CG recurrence, so the boundary and
  negative-curvature exits price their steps from dot products where
  scipy spends two extra HVPs;
- the model value of the returned step falls out of the same
  bookkeeping and is handed to the outer loop, which otherwise needs
  one more HVP per proposal.

The preconditioner gains tf_transforms(): graph versions of the block
T/T^T applications (gather -> dense matvec -> scatter), because for an
HVP subproblem the reparameterisation runs inside the compiled loop,
once per CG iteration, where the numpy path cannot. The subproblem also
re-pins the fitter's parameter state before each solve, since the outer
loop's objective evaluations at proposed points move it in between.

Validated: steps agree with scipy's CGSteihaugSubproblem to float
precision across PD/indefinite models and radii (same algorithm, so
bitwise-deterministic, unlike the nearly-exact solver); Rosenbrock
matches scipy trust-ncg; full Fitter fits match the trust-krylov
reference with and without preconditioning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
The actual trust-krylov algorithm (Gould-Lucidi-Roma-Toint, the method
behind trlib/scipy trust-krylov), completing the native set. Where
Steihaug-CG stops at the first boundary crossing, GLTR keeps expanding
the Krylov subspace and returns the step that is optimal within it: the
Lanczos basis tridiagonalizes H, the projected subproblem
min gamma0*e1.h + h.T_k h/2, ||h|| <= Delta is solved per iteration by
eigendecomposition plus a safeguarded secular solve (hard case
included), and the full-space residual comes free as gamma_k |h_k|.

Division of labour: HVPs, the Lanczos recurrence and full CGS2
reorthogonalization run on the TF device through one compiled step
function against a fixed-shape [kmax, n] basis buffer (zero rows make
masking unnecessary, and fixed shapes mean no retracing); the k x k
tridiagonal solves run in numpy where they cost microseconds against
HVPs costing milliseconds. Boundary-phase residuals use a 10x tighter
tolerance than the interior forcing sequence, as in trlib.

The Krylov data is radius-independent, so the outer loop's
rejected-step path -- re-solving at a shrunken radius -- reuses it and
usually costs zero new HVPs, verified by a test counting them.

Validated: subproblem reaches >= 99.9% of the exact-optimum model
reduction on problems the subspace exhausts (PD and indefinite, all
radii) where truncated CG has no such guarantee; explicit hard-case
KKT check; Rosenbrock matches scipy; full Fitter fits match the
trust-krylov reference with and without preconditioning (32 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
Fixes the two issues the first GPU run surfaced:

- The interior branch estimated the smallest singular value with the
  host-side Cline et al. recurrence, downloading the full factor per
  call -- ~1 s per outer iteration at n=4000 over PCIe, erasing the
  cheap on-device factorizations. Replaced by inverse iteration on
  L L^T: a handful of O(n^2) triangular solves on device, with only two
  scalars and one n-vector crossing to the host. Inverse iteration
  converges fastest exactly in the near-singular regime the hard case
  lives in; far from singularity it only gives an upper bound on
  sigma_min, which every use is safe against (lambda_lb just gets
  looser, and acceptance is guarded, below).

- The hard-case acceptance trusted the s_min estimate outright, so an
  inaccurate estimate could accept a corrected step that raises the
  model; the outer loop then rejects it, which showed up as a doubled
  outer iteration count under GPU rounding at n=2000. Both hard-case
  exits now verify the candidate against the model value (one matvec)
  and fall through to the normal lambda updates when it fails.

Subproblem quality scan is unchanged (worst case 96.6% of the exact
optimum over 60 PD/indefinite cases vs scipy's 96.0%, ~1.5
factorizations per solve); new unit test pins the device estimator to
the true sigma_min in the near-singular regime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
A proposal whose objective overflows to NaN produced a NaN rho, and
IEEE comparisons on NaN are all False: the radius was neither shrunk
nor the step accepted, so the outer loop spun at a fixed radius until
early stopping gave up far from the minimum. First observed on the
first preconditioned run at scale, where an internal-coordinate step of
norm 1 is an enormous physical step whose exponentials overflow. A
non-finite rho now counts as a hard rejection (radius shrinks), with a
regression test whose objective is NaN outside a box around the
minimum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
The NLL is a sum over bins plus parameter-level terms, and every large
tensor in the fit (logk, norm, nobs, sumw/sumw2, beta) is
bins-proportional, so sharding the bins axis over N devices splits both
memory and compute while only the parameter vector out and one
[nparams] partial per shard back ever cross devices. The primary
motivation is memory -- models whose logk exceeds a single GPU now
load host-side (FitInputData(host_memory=True)) and only per-device
slices are materialized on GPUs -- with compute scaling as a bonus in
exactly that regime (each shard stays large enough to saturate its
device).

Two structural rules, established on a 4-GPU proof of concept
(worst-case numbers in rabbit/sharding.py): one GradientTape per shard
differentiating w.r.t. a device-local copy of x, because a single tape
over the combined loss misplaces the backward ops and drags every
shard's logk across the bus per call (135 ms vs 4.5 ms at 4 shards);
and per-shard jit compilation with a plain-graph combiner, since XLA
clusters are single-device.

No duplication of the likelihood mathematics: each shard is a
duck-typed evaluator (rabbit/sharding.py) onto which the Fitter binds
its own unbound methods, so both interpolation forms, both systematic
types and the BinByBinStat machinery (instantiated per shard from a
sliced indata view; kstat/beta are per-bin) are shared with the
single-device path by construction. The minimizers, the preconditioner
and the outer fit loop are untouched -- they consume the same
loss_val/loss_val_grad/loss_val_grad_hessp/loss_val_grad_hess
interface, now assembled from per-shard partials. The dense Hessian
shards too, which also lets the preconditioner's reference matrix
build fit in memory where a single device could not.

Not supported (rejected at construction or call time): sparse mode,
--covarianceFit, regularizers' full=True path, fwdrev HVP (falls back
to revrev), profile=False Hessians.

Tests run the sharded machinery on CPU replicas (select_devices falls
back when no GPU is visible) and hold it to near-bitwise agreement
with the single-device path: loss/grad/HVP/Hessian at a randomized
point, profiled beta, full fits via scipy and native minimizers, and
deepcopy (toys) rebuilding the shard machinery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
(cherry picked from commit 295b8c1)
By default TF creates a CUDA context on -- and reserves the memory of --
every visible GPU, so a single-device fit on a shared 4-GPU machine
blocked all four while computing on one, and always landed on GPU:0
even when another process was already using it.

rabbit_fit now restricts the visible devices to --nDevices before the
TF runtime initializes, choosing the least-occupied GPUs by current
memory use (nvidia-smi query, first-N fallback); --devices gives
explicit indices for full control. Verified on a 4-GPU node: a
single-device fit reserves exactly one GPU leaving the others untouched
(4 MiB, no CUDA context), and with a process occupying GPU:0 the fit
lands on GPU:1. Under slurm with --gres this is all transparent, since
CUDA_VISIBLE_DEVICES already hides other jobs' GPUs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
(cherry picked from commit aa85a4b)
The device layout is fixed at initialization and never changes during a
fit, so the single-device / multi-device split is a static choice best
expressed as a class: rabbit.sharding.MultiDeviceFitter subclasses
Fitter and owns all sharding -- shard construction, the per-device
tf.function assembly, the set_nobs propagation -- while the base Fitter
carries none of it. make_fitter() picks the class from --nDevices, so
call sites stay generic.

The base-class diff reduces to making the deepcopy strip-list an
extensible class attribute: since __deepcopy__ already re-instantiates
via type(self) and ends in _make_tf_functions(), and init_fit_parms
already ends in _make_tf_functions(), the subclass rebuilds its shards
by overriding that one method -- no hooks, no if-shards branches, and
toys preserve the subclass automatically. The likelihood mathematics
remains shared by construction: shard evaluators borrow the base
class's own unbound methods, so this class is orchestration only.

Behavior is unchanged: the full sharded and single-device test suites
pass identically (101 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
(cherry picked from commit 30bbfa3)
The factory introduced by the MultiDeviceFitter refactor accepted only
do_blinding and dropped globalImpactsFromJVP, which rabbit_fit passes --
a TypeError on every rabbit_fit invocation. make_fitter and
MultiDeviceFitter.__init__ now forward **kwargs verbatim so the wrapper
cannot drift from Fitter.__init__'s signature, the tests construct
through the factory with rabbit_fit's exact kwargs, and rabbit_fit runs
end-to-end on both fitter classes.

Reported from a parallel session reviewing the branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCVDNM26GpDMggr8GDFiwY
(cherry picked from commit a0076df)
A penalty on the parameters alone -- a bound on polynomial coefficients, a
smoothness prior -- does not need the predicted yields; one that shapes the
prediction does. Nothing expressed that, so the fitter had to assume the
worst for every regularizer.

The distinction is not cosmetic. It decides whether the likelihood has to
build full=True yields on every call, and it is what makes a penalty usable
at all in a bins-sharded multi-device fit, where no single device holds the
full yield vector.

The default is True because it is the safe answer: a subclass that wrongly
inherited False would be handed None and fail loudly rather than silently
penalise the wrong thing.
tape.jacobian vectorises over every parameter at once, holding one
[nbins, 9] intermediate each: [nparams, nbins, 9] doubles, 43 GB for a
6538-parameter 92144-bin model and ~70 GB per device once sharded. No GPU
has that, so the reference Hessian could not be formed and the fit fell
back to running unpreconditioned -- easy to miss, because the fit then
proceeds normally and only the wall clock gives it away.

Assemble it a batch of columns at a time from HVPs instead. tf.vectorized_map
is a pfor of width k rather than the while_loop tape.jacobian's non-pfor
path builds, so XLA compiles it without the unrolling that made chunking
unusable, and memory scales with k instead of nparams: k = 256 costs a few
GB where vectorising over all parameters costs 43. The batch halves itself
on ResourceExhaustedError, so --hvpBatch is an upper bound rather than a
number that has to be right. These are the same HVPs the minimiser already
uses, so on a sharded fit they stay distributed.

Also let a regularizer declare that its penalty does not read the predicted
yields. _compute_nll_components forced full=True yields on every likelihood
call whenever any regularizer was attached; now that cost is only paid for
penalties that actually use them, and each regularizer is handed None rather
than a truncated vector when it declared it does not.

Drop three experiments that did not work out:

- preconditioner refresh on curvature drift (--preconditionRefresh and
  friends). 1.5-2.5x faster but landed 3-48 above the minimum: a rebuild
  rescales the gradient scipy's gtol test is applied to, so an unconverged
  point can look converged. It needs a convergence test in a fixed metric
  before it is worth having, and it was off by default meanwhile.
- --hessianParallelIterations. Fixed the memory but replaced the vectorised
  pfor with a while_loop XLA unrolls, so compilation never finished.
- --preconditionOnHost. Kept the fast pfor path but reached 161 GB of RSS
  on a 187 GB node before producing anything.

(cherry picked from commit ea172ba)
Regularizers, reduced_nll, global impacts, toys and the full-NLL Hessian
were all inherited from the base class and computed over all bins on one
device, which is what the sharding exists to avoid.

A penalty on the parameters alone has no reason to be unsupported here --
every device holds the whole parameter vector -- so it is evaluated in
gnll_local, the only global term, which gvg and gvgp differentiate, giving
the gradient and HVP for free. gnll_local previously summed the constraint
and external-likelihood pieces only, so a penalty was accepted and then
contributed nothing: the fit minimised the unregularized likelihood while
the command line said otherwise, and its loss compared favourably with a
single-device fit's for lack of a positive term. Penalties that read the
yields are now refused, in arm_regularizers, which runs when regularizers
exist and before any loss is evaluated. The graphs are rebuilt there, since
__init__ traces them before --regularizer has been parsed.

reduced_nll is the same quantity the sharded loss_val computes, so it uses
that. The remaining four all run after the minimiser, where an allocation
failure would take the completed result down with it; they now refuse at the
point of use, before the fit starts, and --fullNll says why it cannot work
rather than running out of memory discovering it.

Drop the sharded jacobian Hessian and the shard/global graph entries that
fed it. It needed ~70 GB per device, so it could not run at all, and it is
what the postfit covariance called -- the failure would have landed at the
end of the fit. The batched-HVP Hessian replaces it unconditionally.

(cherry picked from commit 76b5f3d)
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