Detailed implementation plan
Work Classification
Library (PyAutoFit) + profiling artifact (autolens_profiling) — library-led; both repos share one worktree and one branch. Two PRs; the profiling PR merges second, since its numbers describe the merged code.
Affected Repositories
PyAutoFit (primary) — the feature
autolens_profiling — the standalone runtime demonstration
Branch Survey
| Repository |
Current Branch |
Dirty? |
| ./PyAutoFit |
main |
clean |
| ./autolens_profiling |
main |
clean (untracked results/ artifacts only) |
Suggested branch: feature/multistart-nan-step-diagnostics
Worktree root: ~/Code/PyAutoLabs-wt/multistart-nan-step-diagnostics/
worktree_check_conflict multistart-nan-step-diagnostics PyAutoFit autolens_profiling returns clean (exit 0).
⚠️ Two unregistered worktrees on autolens_profiling, neither in active.md:
feature/numerical-hazard-profiling — 0 commits ahead of main (fully merged); stale leftover, safe to ignore, worth a separate cleanup.
feature/point-source-defaults-campaign — 8 commits ahead; genuinely unmerged work, but scoped to point-source defaults and touching nothing here.
Neither is a conflict; recorded so a later session does not rediscover them.
Implementation Steps
1. autofit/non_linear/search/mle/multi_start_gradient/search.py — the counters
- Add a pure-NumPy static helper
_nan_lane_counts(foms, grad_finite) -> (n_value_nan, n_grad_nan):
alive = np.isfinite(np.asarray(foms))
n_value_nan = int((~alive).sum())
n_grad_nan = int((alive & ~grad_finite).sum()) — disjoint by construction
- Returns
alive too, so the existing resurrection path reuses it rather than recomputing.
- Extend
batched_value_and_grad (~line 495-530) to return a third output, jnp.all(jnp.isfinite(grads), axis=1), an (n_starts,) bool:
- unbatched path: fold into the
jax.jit(jax.vmap(...)) wrapper;
- chunked path: concatenate the third output per chunk alongside
foms/grads, discarding padded rows the same way.
- In the fit loop (~line 625), consume the third output and accumulate
n_value_nan_lane_steps / n_grad_nan_lane_steps. Counted unconditionally, outside the if self.resurrect guard.
- Initialise both to
0 on the fresh path (~line 574); restore via int(search_internal.get(..., 0)) on the resume path (~line 549), matching the n_resurrections precedent.
- Add both keys to the
search_internal dict (~line 700).
2. search.py — samples_via_internal_from (~line 935)
Add both counters to samples_info via .get(..., 0) so pre-existing search_internal files load without a KeyError.
3. autofit/text/text_util.py — search_summary_from_samples (line 115)
Add a guarded block following the hasattr(samples, "total_accepted_samples") idiom three lines above, keyed off getattr(samples, "samples_info", {}) with .get():
Resurrections = 797
Value-NaN Lane-Steps = 797
Gradient-NaN Lane-Steps = 12
Value-NaN Lane-Step Rate = 0.0166
Gradient-NaN Lane-Step Rate = 0.00025
Rates divide by n_starts * total_steps, guarded against a zero denominator. Raw counts are not comparable across runs (797 on a 16x3000 run vs 10 on an 8x300 run differ 80x raw but ~2x by rate).
Naming constraint: neutral factual counts only. Do not label these a smoothness metric in user-facing output — the resurrection-rate to HMC-divergence-rate correlation is unvalidated (separate item, wsdev#117).
4. Tests
test_autofit/non_linear/search/mle/test_multi_start_gradient.py is deliberately JAX-free (see its own comment: _fit needs jax + optax). The helper extraction in step 1 is what keeps it that way.
_nan_lane_counts tested directly with NumPy arrays: all-finite; value-NaN only; grad-NaN only; both non-finite on the same lane (asserts disjointness).
samples_info plumbing tested by hand-building a search_internal dict — the pattern already used by test__samples_via_internal_from and test__samples_info__stop_reason_max_steps_and_legacy_search_internal — including a legacy dict with neither key, asserting 0.
test_autofit/text/test_text_util.py: summary block with a stub samples object, plus a negative test that a samples object without the keys emits no block.
5. autolens_profiling — standalone overhead demonstration
scripts/misc/searches/multi_start_nan_accounting_overhead.py
The feature is unconditional, so there is no flag to A/B inside one process. Instead the script measures the two halves separately and divides:
- Denominator — time
jax.jit(jax.vmap(jax.value_and_grad(...))) on a real PyAutoLens likelihood at realistic (n_starts, n_params) shapes. That is the loop's per-step cost, same code path, same shapes.
- Numerator — the accounting variants (host pull / eager device reduce / fused-in-jit) applied to that same real gradient output.
- Control — a duplicate baseline run, so the noise floor prints alongside the overhead.
The control is required, not decorative: without it the script can only report "we measured no impact", which is unfalsifiable. With it, it reports "overhead is below a noise floor of X%", a claim that could have failed.
Conventions: run from repo root (ruff.toml sentinel walk-up); honour AUTOLENS_PROFILING_SMOKE=1 at module top; write a version-stamped JSON + PNG pair under results/searches/multi_start_nan_accounting/ per results/README.md; ruff check + ruff format --check clean (the PR gate); short pointer added to scripts/misc/searches/README.md. Add to the hand-maintained 5-script smoke list in .github/workflows/lint.yml.
Cross-reference scripts/misc/hazards/checks/nonfinite_gradient.py — an existing detector for non-finite gradients on likelihood surfaces. Related motivation, different artifact; not a duplicate.
Prior measurement
A scratch microbenchmark (CPU, n_starts=16, n_params=30, best-of-7, duplicate-baseline control) already established the shape of the answer and drove the design:
| variant |
cheap obj (180 us/step) |
expensive obj (1.9 ms/step) |
| noise floor |
~1% |
~1.5% |
host pull of grads |
+6.4% |
+0.9% (below noise) |
eager jnp.all(...) outside the jit |
+22.6% |
+3.3% |
| fused into the jitted call |
+5.2% |
+0.05% (below noise) |
The eager-outside-the-jit variant is the worst of the three — it pays an un-jitted kernel dispatch plus a second host round-trip — which is why the plan fuses into the jitted call instead. Note the CPU benchmark structurally understates the host-pull variant, since on CPU "device to host" is a memcpy in the same address space; on GPU it is a real round-trip. The fused variant sidesteps the question on either backend.
The autolens_profiling script supersedes this scratch run with real likelihood shapes, and should also be run under the GPU profile.
Key Files
autofit/non_linear/search/mle/multi_start_gradient/search.py — fit loop, batched_value_and_grad, search_internal, samples_via_internal_from
autofit/text/text_util.py — search_summary_from_samples (line 115)
test_autofit/non_linear/search/mle/test_multi_start_gradient.py — JAX-free unit tests
test_autofit/text/test_text_util.py — summary block tests
autolens_profiling/scripts/misc/searches/multi_start_nan_accounting_overhead.py — the demonstration
Deliberately out of scope
Making resurrect trigger on non-finite gradients. That would change search behaviour and shift every existing benchmark number, so the wsdev #117/#125 pixelized results would stop being comparable without re-running. The resurrection policy is decided after the counters show how often frozen lanes actually occur.
Overview
MultiStartGradientdetects a dead lane only via the value (alive = np.isfinite(np.asarray(foms))). The gradient is never checked inside the fit loop, so a lane whose likelihood is finite but whose gradient is non-finite is counted alive, has its update zeroed byoptax.apply_if_finite, and silently freezes in place. That failure mode — a differentiability failure rather than an evaluation failure — is currently invisible.This adds per-step, disjoint accounting for both: value-NaN lane-steps (likelihood undefined) and gradient-NaN lane-steps (likelihood defined but not differentiable), persisted into
search_internalandsamples_infoand surfaced insearch.summaryas raw counts plus rates normalised byn_starts × total_steps.Scope is measurement only —
resurrectbehaviour is unchanged, so existing benchmark numbers stay comparable. A standalone demonstration inautolens_profilingshows the accounting does not cost runtime.Plan
MultiStartGradientfit loop: value-NaN and gradient-NaN lane-steps.if self.resurrect— withresurrect=Falsethe value-NaN counter is the only record that lanes died.search_internal(resume-safe) andsamples_info, alongsiden_resurrections.search.summarywith raw counts and normalised rates, following the existing MCMC duck-typed precedent.autolens_profiling, with a duplicate-baseline control so the measurement noise floor is reported alongside the overhead.Detailed implementation plan
Work Classification
Library (PyAutoFit) + profiling artifact (autolens_profiling) — library-led; both repos share one worktree and one branch. Two PRs; the profiling PR merges second, since its numbers describe the merged code.
Affected Repositories
PyAutoFit(primary) — the featureautolens_profiling— the standalone runtime demonstrationBranch Survey
results/artifacts only)Suggested branch:
feature/multistart-nan-step-diagnosticsWorktree root:
~/Code/PyAutoLabs-wt/multistart-nan-step-diagnostics/worktree_check_conflict multistart-nan-step-diagnostics PyAutoFit autolens_profilingreturns clean (exit 0).autolens_profiling, neither inactive.md:feature/numerical-hazard-profiling— 0 commits ahead of main (fully merged); stale leftover, safe to ignore, worth a separate cleanup.feature/point-source-defaults-campaign— 8 commits ahead; genuinely unmerged work, but scoped to point-source defaults and touching nothing here.Neither is a conflict; recorded so a later session does not rediscover them.
Implementation Steps
1.
autofit/non_linear/search/mle/multi_start_gradient/search.py— the counters_nan_lane_counts(foms, grad_finite) -> (n_value_nan, n_grad_nan):alive = np.isfinite(np.asarray(foms))n_value_nan = int((~alive).sum())n_grad_nan = int((alive & ~grad_finite).sum())— disjoint by constructionalivetoo, so the existing resurrection path reuses it rather than recomputing.batched_value_and_grad(~line 495-530) to return a third output,jnp.all(jnp.isfinite(grads), axis=1), an(n_starts,)bool:jax.jit(jax.vmap(...))wrapper;foms/grads, discarding padded rows the same way.n_value_nan_lane_steps/n_grad_nan_lane_steps. Counted unconditionally, outside theif self.resurrectguard.0on the fresh path (~line 574); restore viaint(search_internal.get(..., 0))on the resume path (~line 549), matching then_resurrectionsprecedent.search_internaldict (~line 700).2.
search.py—samples_via_internal_from(~line 935)Add both counters to
samples_infovia.get(..., 0)so pre-existingsearch_internalfiles load without a KeyError.3.
autofit/text/text_util.py—search_summary_from_samples(line 115)Add a guarded block following the
hasattr(samples, "total_accepted_samples")idiom three lines above, keyed offgetattr(samples, "samples_info", {})with.get():Rates divide by
n_starts * total_steps, guarded against a zero denominator. Raw counts are not comparable across runs (797 on a 16x3000 run vs 10 on an 8x300 run differ 80x raw but ~2x by rate).Naming constraint: neutral factual counts only. Do not label these a smoothness metric in user-facing output — the resurrection-rate to HMC-divergence-rate correlation is unvalidated (separate item, wsdev#117).
4. Tests
test_autofit/non_linear/search/mle/test_multi_start_gradient.pyis deliberately JAX-free (see its own comment:_fitneeds jax + optax). The helper extraction in step 1 is what keeps it that way._nan_lane_countstested directly with NumPy arrays: all-finite; value-NaN only; grad-NaN only; both non-finite on the same lane (asserts disjointness).samples_infoplumbing tested by hand-building asearch_internaldict — the pattern already used bytest__samples_via_internal_fromandtest__samples_info__stop_reason_max_steps_and_legacy_search_internal— including a legacy dict with neither key, asserting0.test_autofit/text/test_text_util.py: summary block with a stub samples object, plus a negative test that a samples object without the keys emits no block.5.
autolens_profiling— standalone overhead demonstrationscripts/misc/searches/multi_start_nan_accounting_overhead.pyThe feature is unconditional, so there is no flag to A/B inside one process. Instead the script measures the two halves separately and divides:
jax.jit(jax.vmap(jax.value_and_grad(...)))on a real PyAutoLens likelihood at realistic(n_starts, n_params)shapes. That is the loop's per-step cost, same code path, same shapes.The control is required, not decorative: without it the script can only report "we measured no impact", which is unfalsifiable. With it, it reports "overhead is below a noise floor of X%", a claim that could have failed.
Conventions: run from repo root (
ruff.tomlsentinel walk-up); honourAUTOLENS_PROFILING_SMOKE=1at module top; write a version-stamped JSON + PNG pair underresults/searches/multi_start_nan_accounting/perresults/README.md;ruff check+ruff format --checkclean (the PR gate); short pointer added toscripts/misc/searches/README.md. Add to the hand-maintained 5-script smoke list in.github/workflows/lint.yml.Cross-reference
scripts/misc/hazards/checks/nonfinite_gradient.py— an existing detector for non-finite gradients on likelihood surfaces. Related motivation, different artifact; not a duplicate.Prior measurement
A scratch microbenchmark (CPU,
n_starts=16,n_params=30, best-of-7, duplicate-baseline control) already established the shape of the answer and drove the design:gradsjnp.all(...)outside the jitThe eager-outside-the-jit variant is the worst of the three — it pays an un-jitted kernel dispatch plus a second host round-trip — which is why the plan fuses into the jitted call instead. Note the CPU benchmark structurally understates the host-pull variant, since on CPU "device to host" is a memcpy in the same address space; on GPU it is a real round-trip. The fused variant sidesteps the question on either backend.
The
autolens_profilingscript supersedes this scratch run with real likelihood shapes, and should also be run under the GPU profile.Key Files
autofit/non_linear/search/mle/multi_start_gradient/search.py— fit loop,batched_value_and_grad,search_internal,samples_via_internal_fromautofit/text/text_util.py—search_summary_from_samples(line 115)test_autofit/non_linear/search/mle/test_multi_start_gradient.py— JAX-free unit teststest_autofit/text/test_text_util.py— summary block testsautolens_profiling/scripts/misc/searches/multi_start_nan_accounting_overhead.py— the demonstrationDeliberately out of scope
Making
resurrecttrigger on non-finite gradients. That would change search behaviour and shift every existing benchmark number, so the wsdev #117/#125 pixelized results would stop being comparable without re-running. The resurrection policy is decided after the counters show how often frozen lanes actually occur.Original Prompt
Click to expand starting prompt