Overview
Multi-band af.FactorGraphModel fits driven by af.MultiStartProdigy (the autolens_assistant COSMOS-Web Ring case: F277W+F444W JWST fitted simultaneously) pay a cold JAX compile exceeding one hour on CPU even with every shipped mitigation live (persistent cache, autotune off, constant-folding disabled). The compile census (autolens_profiling#93) localised the cause: the in-XLA jax.lax.map scan used by the batch_size path is compile-intractable (and memory-explosive to compile — OOM-killed at ~6 GB RSS even at batch 1) precisely when its body is a multi-band FactorGraphModel fusion, while the same width via a Python loop over jit(vmap(value_and_grad)) chunks ("pyloop") compiled in 166 s. Single-band compile is benign either way. This task productizes the validated pyloop lever in AbstractMultiStartGradient, speeding up cold compile on CPU and GPU alike.
Plan
- Replace the in-XLA
lax.map batched path in AbstractMultiStartGradient._fit with Python-loop batching: compile jax.jit(jax.vmap(value_and_grad)) over one batch_size-shaped chunk and sweep chunks in Python.
- Pad the ragged final chunk (broad-start collection may return fewer than
n_starts rows) so the whole search pays exactly one chunk compile; discard padded outputs.
- Keep
batch_size semantics: still the memory bound, now also the compile/throughput tunable (small on CPU, wider on GPU). batch_size=None (single full vmap) unchanged.
- Numpy-only unit tests for the chunk/pad helper; JAX parity + benchmarks via workspace-test scripts and the autolens_profiling jax_compile probe.
- Benchmark cold compile + steady eval, old vs new path, multi-band datacube cells on CPU and laptop GPU; record in autolens_profiling notes + README.
Detailed implementation plan
Affected Repositories
- PyAutoFit (primary)
- autolens_profiling (benchmark validation + durable findings notes)
Branch Survey
| Repository |
Current Branch |
Dirty? |
| ./PyAutoFit |
main |
clean |
| ./autolens_profiling |
main |
clean (untracked benign dataset/imaging/jwst_lw/) |
Suggested branch: feature/multiband-pyloop-batching
Note: worktree_check_conflict flagged PyAutoFit as claimed by task python-312-floor, but that Codex worktree's branch is 0 commits ahead of origin/main (PR #1429 merged) — stale claim, not a real conflict.
Sizing note (Brain override)
The Brain FeatureDecision scored this too-large (score 13) off repo count and proposed a generic 4-phase split. Overridden to a single implement+benchmark task: the lever is already validated (autolens_profiling#93 + the deeper-dig prompt's sub-investigation B), the library change is one method plus tests, and the benchmark closes the loop. Precedent: the #93 measure→fix override.
Implementation Steps
autofit/non_linear/search/mle/multi_start_gradient/search.py — in _fit, delete the @jax.jit + jax.lax.map(_value_and_grad, params, batch_size=...) construction (currently lines 252–259). When batch_size is set, keep the single _vmapped = jax.jit(jax.vmap(_value_and_grad)) and define batched_value_and_grad(params) as a Python sweep: iterate params in batch_size chunks; pad the ragged final chunk to batch_size (repeat last row) and trim the padded outputs; concatenate per-chunk (foms, grads). The jit cache keys on the (batch_size, ndim) shape → exactly one chunk compile per search.
- Factor the chunk/pad bookkeeping into a pure helper
_chunk_slices(n_rows, batch_size) -> [(lo, hi, pad)] so it is numpy-only unit-testable (library unit tests never import JAX).
- Update the
batch_size docstring: drop the lax.map description; state the Python-loop tiling, unchanged memory-bound semantics, and the compile rationale (in-XLA scan over a multi-band FactorGraphModel fusion is compile-intractable; the Python loop compiles one chunk).
step_update (optax, elementwise) stays as-is — not the compile driver.
test_autofit/non_linear/search/mle/test_multi_start_gradient.py — add numpy-only tests for _chunk_slices (exact division, ragged, n < batch, batch=1); update test__batch_size_is_carried_to_every_rule's docstring, which cites the jax.lax.map property.
Validation & benchmarks
python -m pytest test_autofit/ stays green (numpy-only).
- Parity:
batch_size=4 vs batch_size=None foms/grads identical on a small JAX problem via autofit_workspace_test/scripts/searches/MultiStartProdigy.py.
- Headline A/B: multi-band FactorGraphModel + MultiStartProdigy cold compile, old
lax.map vs new pyloop, CPU and laptop GPU (fresh JAX_COMPILATION_CACHE_DIR per arm; XLA_PYTHON_CLIENT_MEM_FRACTION=0.5 on laptop GPU; idle machine — compile is host-CPU-bound). Instruments: autolens_profiling/scripts/misc/jax_compile/probe.py datacube cells (datacube_img / datacube_img_hetero, laxmap_vag vs pyloop_vag) plus one end-to-end multi-band fit timing (cold + warm).
- Record results in
autolens_profiling/results/notes/ + jax_compile/README.md multi-band section; keep ruff clean.
Fallback (decision on evidence, not implemented up front)
If the GPU steady-eval benchmark shows a real throughput regression from host dispatch (~12 dispatches/step at defaults; expected negligible vs likelihood cost), reintroduce the scan behind an explicit opt-in rather than as the default.
Out of scope (follow-ups)
Key Files
autofit/non_linear/search/mle/multi_start_gradient/search.py — the batched value_and_grad construction in _fit.
test_autofit/non_linear/search/mle/test_multi_start_gradient.py — numpy-only knob/helper tests.
autolens_profiling/scripts/misc/jax_compile/probe.py — validated pyloop_vag pattern + benchmark instrument.
autolens_profiling/jax_compile/README.md (via scripts/misc/jax_compile/README.md) — multi-band findings section to update.
Original Prompt
Click to expand starting prompt
Multi-band FactorGraphModel value_and_grad compile — deeper dig + productize the fix
Type: research
Target: PyAutoFit
Repos:
- PyAutoFit
- autolens_profiling
Difficulty: large
Autonomy: supervised
Priority: normal
Status: formalised
Follow-up to the multi-band compile investigation. Experiment A is already done
(autolens_profiling branch research/multiband-compile-ab; census in
autolens_profiling/jax_compile/README.md "Multi-band … heterogeneous-shape cliff"
section). Established, on local CPU MGE vag over a 4-band af.FactorGraphModel:
- Homogeneous 4-band (1 distinct shape) == single-band: cold compile 120s
(≈ the 117s single-band figure). XLA fuses identical-shape factors into one
shared kernel; the factor graph adds no compile cost when band shapes match.
- Heterogeneous (2 distinct shapes) = 704s cold = 5.9× the control, superlinear
in the number of distinct shapes (2 shapes → ~6×, not 2×). Trace and steady
eval unchanged → pure XLA fusion-compilation effect. This reproduces the real
1h cold compile of an N-distinct-shape multi-wavelength fit.
- Persistent cache rescues both arms (multi-band caching now certified): warm
compile 2–7s. The cliff is a one-time first-compile (cache-miss) cost per graph
structure.
Reproduce via the probe cells added in Experiment A: python jax_compile/probe.py --dataset-class datacube_img[_hetero] --model-type mge --transforms vag --cache-dir <fresh> (run twice cold/warm). Two distinct shapes come from
jwst (0.03") + jwst_lw (0.06") channels.
Reproduction-context follow-up (2026-07-21) — what actually causes the >1h.
Heterogeneity ALONE does not reach the reported >1h: hetero vag topped out at
~12 min. Profiling the MultiStartProdigy transform (lax.map/vmap over
starts) on the SAME (single-core, nproc=1) host, homogeneous 4-band graph,
located the dominant driver:
vag (start-width 1): 120 s compile
vmap_vag (width 2): 209 s compile
laxmap_vag (MultiStartProdigy default: batch_size 4 / n_batch 16):
did not finish compiling in 55 min (killed).
So compile scales steeply with multi-start width, and the full production
transform is intractable to compile cold on one core even before heterogeneity —
which then multiplies it by 5.9×. Driver order: transform × core count >>
heterogeneity >> the factor graph itself. The nproc=1 dev host is worst-case
for XLA compile (host-CPU-bound); HPC/A100 numbers will be far lower. Biggest
shipped lever is the persistent cache (amortizes the whole cold cost). This is
recorded in the jax_compile/README.md multi-band section (findings 2 + 4).
This task is the deeper dig + the fix, as one task:
- Sub-investigation B — the source lever (now concrete + validated). The
dominant compile cost is the in-XLA lax.map scan in MultiStartProdigy, NOT
the batching itself: at vmap width 1, pyloop_vag (Python loop over vmap
chunks, batching hoisted out of XLA) compiled in 166 s vs laxmap_vag (in-XLA
lax.map scan) intractable (>30 min). Implement Python-loop multi-start
batching in MultiStartProdigy (the pyloop pattern in
autolens_profiling/jax_compile/probe.py): iterate starts in Python over small
vmap chunks so no scan is compiled; batch_size = vmap width becomes a
compile/throughput tunable (small CPU, wider GPU). Benchmark cold compile +
per-eval runtime, especially on GPU/A100 vs the current lax.map path —
pyloop trades on-device loop fusion for host dispatch overhead and loses
composability (can't jit/scan over the whole search), so the win is clear on
CPU/limited-RAM but is a compile-vs-throughput trade on A100 (pick chunk width
per device). Clean re-confirm DONE (2026-07-21): laxmap bs=1 compile was
OOM-killed (~6 GB RSS) even from a 10 GB-free start → the lax.map scan path is
memory-explosive to compile here, confound resolved in favour of the lever.
Secondary: whether a per-factor jit boundary inside
af.FactorGraphModel.log_likelihood_function additionally bounds the
heterogeneity multiplier (N×single-band + linear combine) without padding.
- Sub-investigation C — multi-core / A100 rows + verdict, including the
transform. Add A100 (and/or multi-core CPU) multi-band rows for BOTH vag
AND the production laxmap_vag transform (datacube_img /
datacube_img_hetero) to the jax_compile census — the transform is the
dominant driver (see reproduction context) and the single-core figure is
worst-case, so the central open question is whether the full MultiStartProdigy
compile is tractable on an HPC host (single-band A100 vag cold was ~28s vs
229s CPU). Quantify the transform-width scaling and heterogeneity multiplier
under real core counts, then ship the final N-band compile verdict.
- Productize the immediate user workaround — pad short-wavelength bands to a
common grid so all factors share one fused kernel — as a documented recipe
and/or helper for N-band gradient fits.
Deliverable: multi-band A100 census rows + either a productizable compile-reduction
lever (per-factor jit or the padding helper) or a documented "GPU-only /
same-shape-bands" verdict. Out of scope: re-opening the #71 single-band
settings verdict.
Overview
Multi-band
af.FactorGraphModelfits driven byaf.MultiStartProdigy(the autolens_assistant COSMOS-Web Ring case: F277W+F444W JWST fitted simultaneously) pay a cold JAX compile exceeding one hour on CPU even with every shipped mitigation live (persistent cache, autotune off, constant-folding disabled). The compile census (autolens_profiling#93) localised the cause: the in-XLAjax.lax.mapscan used by thebatch_sizepath is compile-intractable (and memory-explosive to compile — OOM-killed at ~6 GB RSS even at batch 1) precisely when its body is a multi-band FactorGraphModel fusion, while the same width via a Python loop overjit(vmap(value_and_grad))chunks ("pyloop") compiled in 166 s. Single-band compile is benign either way. This task productizes the validated pyloop lever inAbstractMultiStartGradient, speeding up cold compile on CPU and GPU alike.Plan
lax.mapbatched path inAbstractMultiStartGradient._fitwith Python-loop batching: compilejax.jit(jax.vmap(value_and_grad))over onebatch_size-shaped chunk and sweep chunks in Python.n_startsrows) so the whole search pays exactly one chunk compile; discard padded outputs.batch_sizesemantics: still the memory bound, now also the compile/throughput tunable (small on CPU, wider on GPU).batch_size=None(single full vmap) unchanged.Detailed implementation plan
Affected Repositories
Branch Survey
Suggested branch:
feature/multiband-pyloop-batchingNote:
worktree_check_conflictflagged PyAutoFit as claimed by taskpython-312-floor, but that Codex worktree's branch is 0 commits ahead of origin/main (PR #1429 merged) — stale claim, not a real conflict.Sizing note (Brain override)
The Brain FeatureDecision scored this too-large (score 13) off repo count and proposed a generic 4-phase split. Overridden to a single implement+benchmark task: the lever is already validated (autolens_profiling#93 + the deeper-dig prompt's sub-investigation B), the library change is one method plus tests, and the benchmark closes the loop. Precedent: the #93 measure→fix override.
Implementation Steps
autofit/non_linear/search/mle/multi_start_gradient/search.py— in_fit, delete the@jax.jit+jax.lax.map(_value_and_grad, params, batch_size=...)construction (currently lines 252–259). Whenbatch_sizeis set, keep the single_vmapped = jax.jit(jax.vmap(_value_and_grad))and definebatched_value_and_grad(params)as a Python sweep: iterateparamsinbatch_sizechunks; pad the ragged final chunk tobatch_size(repeat last row) and trim the padded outputs; concatenate per-chunk(foms, grads). The jit cache keys on the(batch_size, ndim)shape → exactly one chunk compile per search._chunk_slices(n_rows, batch_size) -> [(lo, hi, pad)]so it is numpy-only unit-testable (library unit tests never import JAX).batch_sizedocstring: drop thelax.mapdescription; state the Python-loop tiling, unchanged memory-bound semantics, and the compile rationale (in-XLA scan over a multi-band FactorGraphModel fusion is compile-intractable; the Python loop compiles one chunk).step_update(optax, elementwise) stays as-is — not the compile driver.test_autofit/non_linear/search/mle/test_multi_start_gradient.py— add numpy-only tests for_chunk_slices(exact division, ragged, n < batch, batch=1); updatetest__batch_size_is_carried_to_every_rule's docstring, which cites thejax.lax.mapproperty.Validation & benchmarks
python -m pytest test_autofit/stays green (numpy-only).batch_size=4vsbatch_size=Nonefoms/grads identical on a small JAX problem viaautofit_workspace_test/scripts/searches/MultiStartProdigy.py.lax.mapvs new pyloop, CPU and laptop GPU (freshJAX_COMPILATION_CACHE_DIRper arm;XLA_PYTHON_CLIENT_MEM_FRACTION=0.5on laptop GPU; idle machine — compile is host-CPU-bound). Instruments:autolens_profiling/scripts/misc/jax_compile/probe.pydatacube cells (datacube_img/datacube_img_hetero,laxmap_vagvspyloop_vag) plus one end-to-end multi-band fit timing (cold + warm).autolens_profiling/results/notes/+jax_compile/README.mdmulti-band section; keep ruff clean.Fallback (decision on evidence, not implemented up front)
If the GPU steady-eval benchmark shows a real throughput regression from host dispatch (~12 dispatches/step at defaults; expected negligible vs likelihood cost), reintroduce the scan behind an explicit opt-in rather than as the default.
Out of scope (follow-ups)
census_gpu.sbatchlater).Key Files
autofit/non_linear/search/mle/multi_start_gradient/search.py— the batchedvalue_and_gradconstruction in_fit.test_autofit/non_linear/search/mle/test_multi_start_gradient.py— numpy-only knob/helper tests.autolens_profiling/scripts/misc/jax_compile/probe.py— validatedpyloop_vagpattern + benchmark instrument.autolens_profiling/jax_compile/README.md(viascripts/misc/jax_compile/README.md) — multi-band findings section to update.Original Prompt
Click to expand starting prompt
Multi-band FactorGraphModel value_and_grad compile — deeper dig + productize the fix
Type: research
Target: PyAutoFit
Repos:
Difficulty: large
Autonomy: supervised
Priority: normal
Status: formalised
Follow-up to the multi-band compile investigation. Experiment A is already done
(autolens_profiling branch
research/multiband-compile-ab; census inautolens_profiling/jax_compile/README.md"Multi-band … heterogeneous-shape cliff"section). Established, on local CPU MGE
vagover a 4-bandaf.FactorGraphModel:(≈ the 117s single-band figure). XLA fuses identical-shape factors into one
shared kernel; the factor graph adds no compile cost when band shapes match.
in the number of distinct shapes (2 shapes → ~6×, not 2×). Trace and steady
eval unchanged → pure XLA fusion-compilation effect. This reproduces the real
compile 2–7s. The cliff is a one-time first-compile (cache-miss) cost per graph
structure.
Reproduce via the probe cells added in Experiment A:
python jax_compile/probe.py --dataset-class datacube_img[_hetero] --model-type mge --transforms vag --cache-dir <fresh>(run twice cold/warm). Two distinct shapes come fromjwst(0.03") +jwst_lw(0.06") channels.Reproduction-context follow-up (2026-07-21) — what actually causes the >1h.
Heterogeneity ALONE does not reach the reported >1h: hetero
vagtopped out at~12 min. Profiling the
MultiStartProdigytransform (lax.map/vmapoverstarts) on the SAME (single-core,
nproc=1) host, homogeneous 4-band graph,located the dominant driver:
vag(start-width 1): 120 s compilevmap_vag(width 2): 209 s compilelaxmap_vag(MultiStartProdigy default:batch_size4 /n_batch16):did not finish compiling in 55 min (killed).
So compile scales steeply with multi-start width, and the full production
transform is intractable to compile cold on one core even before heterogeneity —
which then multiplies it by 5.9×. Driver order: transform × core count >>
heterogeneity >> the factor graph itself. The
nproc=1dev host is worst-casefor XLA compile (host-CPU-bound); HPC/A100 numbers will be far lower. Biggest
shipped lever is the persistent cache (amortizes the whole cold cost). This is
recorded in the
jax_compile/README.mdmulti-band section (findings 2 + 4).This task is the deeper dig + the fix, as one task:
dominant compile cost is the in-XLA
lax.mapscan inMultiStartProdigy, NOTthe batching itself: at vmap width 1,
pyloop_vag(Python loop overvmapchunks, batching hoisted out of XLA) compiled in 166 s vs
laxmap_vag(in-XLAlax.mapscan) intractable (>30 min). Implement Python-loop multi-startbatching in
MultiStartProdigy(thepylooppattern inautolens_profiling/jax_compile/probe.py): iterate starts in Python over smallvmapchunks so no scan is compiled;batch_size= vmap width becomes acompile/throughput tunable (small CPU, wider GPU). Benchmark cold compile +
per-eval runtime, especially on GPU/A100 vs the current
lax.mappath —pyloop trades on-device loop fusion for host dispatch overhead and loses
composability (can't jit/scan over the whole search), so the win is clear on
CPU/limited-RAM but is a compile-vs-throughput trade on A100 (pick chunk width
per device). Clean re-confirm DONE (2026-07-21):
laxmap bs=1compile wasOOM-killed (~6 GB RSS) even from a 10 GB-free start → the
lax.mapscan path ismemory-explosive to compile here, confound resolved in favour of the lever.
Secondary: whether a per-factor jit boundary inside
af.FactorGraphModel.log_likelihood_functionadditionally bounds theheterogeneity multiplier (N×single-band + linear combine) without padding.
transform. Add A100 (and/or multi-core CPU) multi-band rows for BOTH
vagAND the production
laxmap_vagtransform (datacube_img/datacube_img_hetero) to thejax_compilecensus — the transform is thedominant driver (see reproduction context) and the single-core figure is
worst-case, so the central open question is whether the full MultiStartProdigy
compile is tractable on an HPC host (single-band A100
vagcold was ~28s vs229s CPU). Quantify the transform-width scaling and heterogeneity multiplier
under real core counts, then ship the final N-band compile verdict.
common grid so all factors share one fused kernel — as a documented recipe
and/or helper for N-band gradient fits.
Deliverable: multi-band A100 census rows + either a productizable compile-reduction
lever (per-factor jit or the padding helper) or a documented "GPU-only /
same-shape-bands" verdict. Out of scope: re-opening the #71 single-band
settings verdict.