Skip to content

Speed up posterior diagnostics (summary 1.7x, effective_sample_size 2.1x) - #2251

Merged
Qazalbash merged 1 commit into
pyro-ppl:masterfrom
kyo219:diagnostics-speedup
Aug 23, 2026
Merged

Speed up posterior diagnostics (summary 1.7x, effective_sample_size 2.1x)#2251
Qazalbash merged 1 commit into
pyro-ppl:masterfrom
kyo219:diagnostics-speedup

Conversation

@kyo219

@kyo219 kyo219 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Changes made

All in numpyro/diagnostics.py:

  • autocovariance is now the FFT primitive, autocorrelation derives from it. Previously autocorrelation normalized the raw FFT autocovariance by its lag-0 value, and autocovariance then multiplied the variance back in (autocorrelation(x) * x.var()) — two extra full-array passes plus a separate variance pass, and a redundant .astype(np.float64) copy (the FFT output is already float64). effective_sample_size consumes autocovariance, so its hot path drops all of these. autocorrelation still returns the same values, now computed as autocov / autocov[lag 0] (the lag-0 autocovariance is identical for the biased and unbiased estimators, which is what the old normalization relied on implicitly).
  • FFTs go through scipy.fft with workers=-1, with an np.fft fallback. scipy is not a direct numpyro dependency but is guaranteed transitively via jax, and numpyro already uses it in several lazy imports (e.g. distributions/continuous.py, infer/calibration.py); the test suite also imports scipy.fftpack in test_diagnostics.py. np.fft is single-threaded; scipy.fft parallelizes across the batch dimensions, which is exactly the shape of the ESS workload (one FFT per chain per parameter column). Since scipy.fft computes in the input precision (unlike np.fft, which always promotes), the centered signal is explicitly cast to float64 to keep the double-precision behavior.
  • summary() sorts each parameter's draws once and reuses the sorted array for both the median and the hpdi bounds. Previously np.median partitioned the very array that hpdi then fully sorted. The sliding-window part of hpdi is factored into a private _hpdi_of_sorted helper; the public hpdi is unchanged. The median-from-sorted matches np.median semantics including NaN propagation (sorting places NaNs last, so checking the last draw suffices).

Behavioral notes: float64 results agree with master to rtol 1e-9. For float32 inputs, results differ at the float32-round-off level (relative ~1e-7) because the old code normalized with a float32 x.var() while the new code stays in float64 throughout — the new values are the more precise ones. autocovariance of a constant series now returns 0 rather than NaN (the old NaN came from the 0/0 lag-0 normalization that the variance multiplication couldn't undo); effective_sample_size and autocorrelation still return NaN for that case as before.

Benchmarks

Apple M2 (24 GB, 8 cores), CPU, numpy 2.x/scipy via jax 0.10.2, Python 3.11. Best of 3 after warmup.

workload master this PR speedup
summary() on {a: (4, 2000, 100), b: (4, 2000, 50, 10)} 0.317 s 0.199 s 1.59×
summary() on (4, 5000, 2000) 3.50 s 2.08 s 1.69×
effective_sample_size on (4, 2000, 50, 10) 0.107 s 0.052 s 2.06×
hpdi standalone (8000, 500) 0.100 s 0.100 s 1.0× (unchanged by design)

This is the mcmc.print_summary() path, so every MCMC run with the default summary printout benefits.

Links to related issues/PRs

None.

Tests

  • New test_summary_median_matches_numpy (odd and even draw counts) and test_summary_median_propagates_nan in test/test_diagnostics.py — lock in that the shared-sort median is exactly np.median, including NaN propagation.
  • Existing coverage: pytest test/test_diagnostics.py — 28 passed (autocorrelation/autocovariance/ESS/hpdi/gelman-rubin values are asserted against fixed references there).
  • Cross-checked patched vs master outputs on random, AR(1), float32, integer, and NaN-containing inputs for autocorrelation/autocovariance (bias=True/False), effective_sample_size, hpdi, and every summary statistic: float64 agrees to rtol 1e-9; float32 differs only at round-off as described above.
  • ruff check / ruff format --check / ty check clean.

Dependencies

None (scipy is used only behind a try/except fallback).

Three changes to numpyro/diagnostics.py:

- Make autocovariance the FFT primitive and derive autocorrelation from
  it by lag-0 normalization, instead of computing autocorrelation first
  and multiplying the variance back in. This removes two full-array
  passes, a separate variance pass, and a redundant float64 astype copy
  from the effective_sample_size path.
- Use scipy.fft with workers=-1 (scipy is always present via jax; fall
  back to np.fft if unavailable) so batched FFTs use all cores.
- In summary(), sort the draws once and reuse the sorted array for both
  the median and the hpdi bounds; previously np.median partitioned the
  same array that hpdi then fully sorted.

summary() is 1.6-1.7x faster and effective_sample_size 2.1x faster on
realistic posterior sizes; float64 results agree with master to rtol
1e-9 (float32 inputs now normalize in double precision, removing
float32-level round-off from n_eff).
@github-actions github-actions Bot added the awaiting review Awaiting review from maintainers label Aug 23, 2026
@Qazalbash

Copy link
Copy Markdown
Collaborator

Thanks @kyo219!

Can you share the code you used for benchmarks.

@github-actions

Copy link
Copy Markdown

Benchmark report

this PR diagnostics-speedup at 3304780d vs baseline master at 999d8d1f

- run time:     1 slower, 0 faster
  compile time: unchanged across 32 benchmarks

Significant changes (1)

                  ─────── run time ───────     ────── compile time ─────
  benchmark       baseline  this PR      Δ     baseline   this PR      Δ
────────────────────────────────────────────────────────────────────────
- normal_sample    24.9 ms  26.3 ms  +5.8%     212.7 ms  216.0 ms  +1.6%

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

distributions

                                 ──────── run time ────────     ────── compile time ─────
  benchmark                      baseline  this PR        Δ     baseline   this PR      Δ
─────────────────────────────────────────────────────────────────────────────────────────
  biject_to_constraints            4.1 ms   4.0 ms    -0.9%     359.0 ms  371.4 ms  +3.5%
  categorical_log_prob             2.2 ms   2.1 ms    -3.5%      72.5 ms   73.1 ms  +0.9%
  dirichlet_log_prob               643 µs   669 µs    +4.0%     471.1 ms  460.8 ms  -2.2%
  dirichlet_sample                45.3 ms  45.7 ms    +0.9%     881.3 ms  866.1 ms  -1.7%
  gamma_log_prob                   2.1 ms   2.0 ms    -1.0%       2.32 s    2.27 s  -1.9%
  gamma_sample                    19.3 ms  19.2 ms    -0.4%     826.3 ms  838.1 ms  +1.4%
  lkj_cholesky_sample              5.3 ms   5.4 ms    +1.4%       1.23 s    1.23 s  +0.0%
  mixture_same_family_log_prob     2.1 ms   2.1 ms    +1.1%     105.5 ms  109.9 ms  +4.1%
  multivariate_normal_log_prob     280 µs   288 µs    +2.9%     161.4 ms  167.0 ms  +3.4%
  normal_log_prob                  581 µs   616 µs  (+5.9%)      61.1 ms   60.4 ms  -1.1%
- normal_sample                   24.9 ms  26.3 ms    +5.8%     212.7 ms  216.0 ms  +1.6%
  stick_breaking_transform         6.3 ms   6.2 ms    -1.7%     226.9 ms  212.1 ms  -6.5%
  student_t_log_prob               3.0 ms   3.0 ms    -0.5%      82.6 ms   81.3 ms  -1.5%
  truncated_normal_log_prob        709 µs   741 µs    +4.6%      56.3 ms   56.5 ms  +0.3%

handlers

                                  ──────── run time ───────     ────── compile time ──────
  benchmark                       baseline   this PR      Δ     baseline   this PR       Δ
──────────────────────────────────────────────────────────────────────────────────────────
  initialize_model_hierarchical    45.6 ms   46.0 ms  +1.0%       3.82 s    3.85 s   +0.7%
  log_density_hierarchical          3.9 ms    3.8 ms  -2.7%       1.24 s    1.24 s   -0.3%
  nested_handler_stack              1.4 ms    1.4 ms  -0.8%       760 µs    640 µs  -15.8%
  potential_energy_and_grad          23 µs     23 µs  -2.3%     104.1 ms  103.4 ms   -0.6%
  predictive_forward_sampling     760.1 ms  741.9 ms  -2.4%     175.2 ms  194.0 ms  +10.8%
  trace_seeded_model                859 µs    834 µs  -2.8%     567.2 ms  566.1 ms   -0.2%

mcmc

                             ──────── run time ───────     ───── compile time ─────
  benchmark                  baseline   this PR      Δ     baseline  this PR      Δ
───────────────────────────────────────────────────────────────────────────────────
  hmc_logistic_regression    759.3 ms  761.4 ms  +0.3%       3.38 s   3.36 s  -0.6%
  nuts_dense_mass_funnel       1.21 s    1.21 s  -0.1%       2.65 s   2.70 s  +1.8%
  nuts_eight_schools           1.19 s    1.18 s  -0.9%       2.51 s   2.56 s  +1.9%
  nuts_hierarchical_glm        4.93 s    4.95 s  +0.3%       4.87 s   4.82 s  -1.0%
  nuts_logistic_regression     1.13 s    1.14 s  +1.3%       3.37 s   3.41 s  +1.1%
  nuts_vectorized_chains       2.65 s    2.65 s  +0.0%       2.99 s   3.07 s  +2.7%

svi

                                             ──────── run time ───────     ───── compile time ─────
  benchmark                                  baseline   this PR      Δ     baseline  this PR      Δ
───────────────────────────────────────────────────────────────────────────────────────────────────
  svi_autodelta_map_logistic                 326.2 ms  333.2 ms  +2.1%       3.25 s   3.31 s  +2.0%
  svi_autodiagonalnormal_hierarchical          1.07 s    1.07 s  -0.4%       5.08 s   5.12 s  +1.0%
  svi_automultivariatenormal_eight_schools   788.0 ms  794.2 ms  +0.8%       4.06 s   4.08 s  +0.6%
  svi_autonormal_logistic                    803.1 ms  803.6 ms  +0.1%       3.47 s   3.44 s  -0.8%
  svi_multi_particle_elbo                      1.55 s    1.54 s  -1.0%       3.58 s   3.65 s  +1.9%
  svi_trace_mean_field_elbo                    1.40 s    1.37 s  -2.5%       5.29 s   5.43 s  +2.7%
Methodology and environment

Each benchmark is set up untimed, then called once with the JAX caches cleared and several more times warm. Run is the fastest warm call; compile is the first call minus that, i.e. the tracing, lowering and XLA compilation the warm calls did not have to pay for.

Both refs were measured on the same runner over 2 interleaved round(s), taking the best observation per benchmark. A result is called neutral when it moves less than ±5% (run) or ±25% (compile), or when the measurement itself is under 1 ms (run) / 50 ms (compile) — a shared CI runner cannot resolve changes below that. Compile time gets the looser band because it is measured once per round rather than best-of-N, and swings by roughly 20% even between two runs of identical code. A delta shown in parentheses did clear its threshold, but on a measurement below the resolution floor, so it is reported without being called a change.

baseline this PR
ref master diagnostics-speedup
commit 999d8d1f 3304780d
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.

@kyo219

kyo219 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Sure! Script below (Apple M2, 8 cores, CPU; best of 3 after warmup). Agreement was checked separately by dumping every statistic (autocorrelation/autocovariance with bias=True/False, effective_sample_size, hpdi, all summary stats) on random / AR(1) / float32 / integer / NaN-containing inputs from both branches and comparing with np.allclose(rtol=1e-9).

import time
import numpy as np
from numpyro.diagnostics import summary, effective_sample_size

def timeit(fn, n=3):
    fn()
    ts = []
    for _ in range(n):
        t0 = time.perf_counter(); fn(); ts.append(time.perf_counter() - t0)
    return min(ts)

rng = np.random.default_rng(0)
small = {"a": rng.standard_normal((4, 2000, 100)), "b": rng.standard_normal((4, 2000, 50, 10))}
big = {"c": rng.standard_normal((4, 5000, 2000))}
print(f"summary small: {timeit(lambda: summary(small)):.3f}s")
print(f"summary big:   {timeit(lambda: summary(big)):.3f}s")
print(f"ess:           {timeit(lambda: effective_sample_size(small['b'])):.3f}s")

@Qazalbash Qazalbash left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gains are noticable. These numbers are on 13th Gen i5-13420H.

master #2251
summary small: 0.509s 0.279s
summary big: 5.281s 2.539s
ess: 0.246s 0.158s

Thanks @kyo219

@Qazalbash Qazalbash self-assigned this Aug 23, 2026
@Qazalbash
Qazalbash merged commit 7a99e0f into pyro-ppl:master Aug 23, 2026
11 checks passed
@Qazalbash Qazalbash removed the awaiting review Awaiting review from maintainers label Aug 23, 2026
@kyo219
kyo219 deleted the diagnostics-speedup branch August 24, 2026 01:03
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.

2 participants