Avoid per-step host sync in the SVI progress-bar loop - #2247
Conversation
The progress-bar loop in SVI.run called jax.device_get(loss) on every step, blocking on the device each iteration and defeating async dispatch. Collect losses as device arrays and transfer each display batch with a single device_get instead; also hoist jit(body_fn) out of the loop. 1.9x faster on a dispatch-bound model (CPU); results are bit-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Benchmark reportthis PR run time: unchanged across 32 benchmarks
+ compile time: 1 fasterSignificant changes (1) ──────── run time ─────── ────── compile time ──────
benchmark baseline this PR Δ baseline this PR Δ
────────────────────────────────────────────────────────────────────────────────────────
+ predictive_forward_sampling 702.5 ms 703.7 ms +0.2% 208.6 ms 155.7 ms -25.4%Red is slower, green is faster; a row is coloured by the worse of its two columns. A delta in parentheses cleared the threshold on a measurement below the resolution floor, so it is shown without being called a change. † marks a benchmark that could not be compared — see below. Full results
|
| baseline | this PR | |
|---|---|---|
| ref | master |
svi-progress-bar-host-sync |
| commit | 999d8d1f |
305f8c3f |
| numpyro | 0.21.0 | 0.21.0 |
| jax | 0.11.1 | 0.11.1 |
| backend | cpu | cpu |
| python | 3.14.7 | 3.14.7 |
Runner: Linux-6.17.0-1022-azure-x86_64-with-glibc2.39, 4 CPUs.
Produced by this benchmark run.
fehiepsi
left a comment
There was a problem hiding this comment.
Interesting! Thanks for the enhancement!
Changes made
numpyro/infer/svi.py: the progress-bar loop inSVI.runcalledjax.device_get(loss)on every step, forcing a blocking device→host transfer per iteration — the host cannot dispatch stepi+1until stepihas finished computing, so JAX's async dispatch is defeated for the whole run. Losses are now appended as device arrays and each display batch (num_steps // 20steps, the existing postfix-update granularity) is fetched with a singlejax.device_get, which also replaces the device scalars with host values as before so memory behavior is unchanged.jit(body_fn)is also hoisted out of the loop instead of being re-wrapped on every iteration.Results are unchanged: the losses returned by the progress-bar path are bit-identical to master (verified locally on a 1234-step run), and the postfix string is computed from the same values as before.
Benchmarks
Apple M2 (24 GB), CPU, jax 0.10.2, Python 3.11. Each variant runs in a fresh process; time is the best of 3 repeats after a warmup run of
svi.run(..., progress_bar=True).progress_bar=False(reference)The win comes from removing the per-step synchronization, so it scales with the ratio of dispatch overhead to per-step compute; on accelerators, where a device round-trip is more expensive than on CPU, the effect should be larger.
Links to related issues/PRs
None.
Tests
test_run_progress_bar_matches_scan_pathintest/infer/test_svi.py— locks in that the progress-bar path returns the same losses and params as thelax.scanpath, using a step count (123) that is not a multiple of the display batch so the tail losses collected outside a batch boundary are exercised. Tolerance is rtol=1e-5 because the two paths run differently compiled programs and already differ by a few ulps on master.pytest test/infer/test_svi.py— 66 passed, 4 skippedruff check/ruff format --checkclean;ty checkreports only the diagnostic already present on master.Dependencies
None.
🤖 Generated with Claude Code