JAX / GPU acceleration#
GalSpec’s original spectral models are astropy Fittable1DModel classes fitted
with numpy. That is convenient but slow: every likelihood evaluation writes the
free parameters onto the model through setattr and then re-evaluates a Python
expression tree, and every evaluation happens on a single CPU core.
galspec.jax_models re-implements the same models as pure JAX functions, so
that
the model evaluation is a single
jax.jit-compiled computation,it can be
vmap-ed over batches of parameter vectors (JNesty feeds the GPU this way, which is where the largest gain comes from),and it runs on a GPU when one is available.
The models are numerically identical to the CPU versions: with float64
enabled, fluxes agree to ~1e-16 relative, i.e. to floating-point rounding
(tests/test_jax_parity.py asserts this for every model and for the
example compound models).
This page is the backend reference. For a fit read end to end see Example: fitting a strong AGN; for what a prior can say – including one parameter’s prior given another’s – see Priors.
What is available#
CPU (astropy) model |
JAX equivalent |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The galspec.tie_* classes are reused unchanged: they only read
model[name].parameter and do arithmetic, which works the same way on a JAX
model.
API#
The JAX models deliberately mirror the astropy ones: the same keyword arguments,
the same fixed/tied/bounds/name semantics, the same parameter
names (amplitude_0, amp_c0_1, … where the suffix is the submodel
index), and the same +/* composition. Only the evaluation is different:
a JAX model offers flux(x, theta) and flux_batch(x, thetas) for the
jit/vmap path, while model(x) evaluates with the current parameter
values just like astropy.
import numpy as np
from galspec import jax_models as jxm
from galspec.jnesty_fit import JNesty_Fit
continuum = jxm.PowerLaw1D(amplitude=0.5, x_0=5000, alpha=1.0,
fixed={"x_0": True}, name="Continuum")
line = jxm.Line_MultiGauss_doublet(n_components=2, amp_c0=1.7, amp_c1=0.57,
dv_c=-43, sigma_c=250,
wavec0=5006.84, wavec1=4958.91,
name="OIII", amp_w0=0.35, dv_w0=-280,
sigma_w0=340)
line.amp_c1.tied = galspec.tie_MultiGauss_doublet_ratio("OIII", 2.98)
model = continuum + line
fit = JNesty_Fit(model, wave, flux, error, nlive=200, device="auto")
fit.fit(dlogz=0.5)
best_model, names, theta = fit.get_best_fit()
print(fit.get_evidence())
print(fit.prior_dominated()) # parameters pressed against a prior edge
fit.plot_corner()
Priors are set on the parameters themselves, and a parameter whose prior is never mentioned is uniform over its bounds:
from galspec import priors
model.sigma_w0.prior = priors.Uniform(300.0, 8000.0)
model.amp_c.prior = priors.LogUniform(1e-3, 3.0) # flat per decade
# H-beta's shift near H-alpha's, within 5% of the width, at most 5 widths out:
model.dv_w0.prior = priors.Normal(mean="b_halpha.dv_w0", sigma=0.05,
scale_from="b_halpha.sigma_w0", nsigma=5)
The fitter reads the priors off the model – it is not told about them – and works
out the order in which dependent parameters have to be drawn. nsigma is a hard
limit, not a suggestion: a value further from the centre than nsigma widths has
zero prior probability. A model with no priors at all behaves exactly as before
(bounds_dict, then the parameter’s own finite bounds, then a per-type default
with log-uniform amplitudes), so old code keeps its old prior – and therefore a
comparable evidence.
Fitting a sample#
The methods above fit one spectrum. When the same model is fitted to many
spectra, two things dominate the wall time of a script that loops: compiling the
likelihood and prior for every source, and copying the model code into each
iteration. JNesty_Fit(..., share_compilation=True) handles the first: the
spectrum is passed to the compiled likelihood as an argument, so the program –
and the compilation – is shared by every fit whose model structure is the
same. The second is what python -m galspec.pipeline does, driving a whole
sample from a TOML file; see Fitting From a Configuration File.
from galspec.jnesty_fit import clear_shared_compilation_cache
for wave, flux, error in spectra: # one model, many spectra
fit = JNesty_Fit(model.copy(), wave, flux, error,
nlive=500, share_compilation=True)
fit.fit(dlogz=0.5) # only the first compiles
clear_shared_compilation_cache() # free the compiled kernels
The saving is the compilation, not the sampling: with the full IRAS model (39 free
parameters) the first source compiles in 0.5 s and the next ones in 0.04 s
(develop/dev_jax/bench_shared_compilation.py), while the per-iteration cost
is unchanged within run-to-run noise. The numbers themselves do not change
either: the same arithmetic is traced, and tests/test_shared_compilation.py
compares the likelihood, the prior transform and a short run against the
per-source path. An on-disk cache
(enable_persistent_compilation_cache(directory)) helps across processes,
which is what the pipeline’s workers option needs.
Precision#
galspec.jax_models enables JAX’s 64-bit mode on import, because that is what
makes the fluxes agree with the CPU models to rounding. float32 is available and
considerably faster on consumer GPUs, at ~4e-5 relative accuracy (measured for
the [O III] model):
GALSPEC_JAX_X64=0 python my_fit.py # disable float64 (float32 everywhere)
Adding a new model#
Subclass galspec.jax_models.JaxModel, declare the parameters and write
evaluate as a pure jnp function of (x, params):
import jax.numpy as jnp
from galspec.jax_models.core import JaxModel, ParamSpec
class TwoSlope(JaxModel):
PARAMS = (
ParamSpec("amplitude", 1.0),
ParamSpec("x_break", 5000.0),
ParamSpec("alpha_1", 0.0),
ParamSpec("alpha_2", 1.0),
)
def evaluate(self, x, params):
ratio = x / params["x_break"]
slope = jnp.where(x < params["x_break"], params["alpha_1"],
params["alpha_2"])
return params["amplitude"] * ratio ** (-slope)
Everything else – fixed, tied, bounds, name, compound + and
*, JNesty_Fit – is inherited. Note that anything computed in
evaluate must have a shape that is independent of the parameter values;
if a static size is needed (e.g. a convolution kernel radius), take it from a
constructor argument and mask with jnp.where. prepare(x) is the hook for
x-dependent static data, and sigma_pix_max is the usual pattern for widths
(see IronTemplate or Line_Exponential).
Benchmarks#
Everything below was measured on one machine (RTX 4090, 19 GB; 192-core host)
with the scripts in develop/dev_jax. (develop/ is a separate, local-only
repository – it is not part of this one, so the paths name scripts rather than files
you can fetch.) Raw numbers are stored in develop/dev_jax/results/.
Cost of one likelihood evaluation#
develop/dev_jax/bench_likelihood.py. This is what nested sampling spends its
time on. “batched” means vmap over a block of parameter vectors, which is how
the GPU is kept busy.
model |
astropy on CPU |
JAX |
JAX |
|---|---|---|---|
[O III] window, 35 056 pixels, 8 free parameters |
0.697 ms |
0.030 ms (23x) |
0.013 ms (53x) |
full spectrum, 51 918 pixels, 39 free parameters |
7.663 ms |
0.192 ms (40x) |
0.073 ms (105x) |
The log-likelihoods agree with the CPU fitter to |dlogL| = 0 at the model’s
own parameters and to 2e-16 relative over random prior draws, so the speed is
not bought with accuracy.
Complete fits#
develop/dev_jax/bench_jnesty.py. Same data, priors, nlive and dlogz
for every sampler; each configuration runs in its own process, and the JNesty
entries exclude the one-off compilation.
problem |
dynesty, 1 CPU |
dynesty, 8 CPUs |
JNesty, CPU |
JNesty, GPU |
|---|---|---|---|---|
[O III], 35 056 px, 8 par, converged (nlive 80, dlogz 1) |
57.7 s |
36.3 s (1.6x) |
13.9 s (4.1x) |
14.0 s (4.1x) |
AGN: power law + Fe II + [O III], 35 056 px, 10 par, converged |
111.7 s |
– |
18.2 s (6.1x) |
17.7 s (6.3x) |
full spectrum, 51 918 px, 39 par, 4000 iterations |
1006 s |
– |
45.2 s (22x) |
45.4 s (22x) |
(speedups are relative to the single-process dynesty run. The full-spectrum row used an equal iteration budget instead of convergence: neither sampler had converged after 4000 iterations of a 39-dimensional problem, so only the timings of that row are meaningful – the evidences are not.)
For the converged problems all samplers agree: the maximum-likelihood fits have
the same chi-square, the best-fit parameters agree to within one posterior sigma,
and the evidences span ~3 with a median reported error of ~1. In other words the
JAX path reproduces the CPU result, as tests/test_jax_parity.py does at the
model level.
What the speedup depends on#
The three converged rows above differ only in how expensive the astropy model is per evaluation, and that is what sets the speedup. Measured, on the same machine:
CPU model cost per call |
JNesty wall time to convergence |
speedup vs dynesty |
|---|---|---|
0.70 ms ([O III], 8 parameters) |
14.0 s |
4.1x |
0.95 ms (AGN, 10 parameters) |
17.7 s |
6.3x |
4.6 ms (full spectrum, 39 parameters) |
45.4 s |
22x |
The JAX model costs the same either way – roughly 0.013 ms per evaluation when batched – and what JNesty adds on top is close to a constant, ~0.15 ms of per-call overhead. Hence
speedup = (astropy cost per call) / 0.15 ms,
so a ten-fold gain needs the astropy model to cost more than ~1.5 ms per evaluation. A two- or three-component fit to a few thousand pixels is cheaper than that and lands at 4-6x; a full optical spectrum with a dozen components is far above it and lands at 20x or more.
There is also a hard ceiling on small problems: JNesty needs ~2 ms per iteration even with a trivial likelihood (measured), so a problem that converges in 3 000 iterations cannot finish faster than ~6 s no matter how fast the model becomes – 8.7x for the [O III] fit above. Pushing past that means giving JNesty’s loop more work per kernel launch, which is where the remaining headroom sits (see below).
Where the GPU does – and does not – help#
develop/dev_jax/bench_device_scaling.py separates the two effects (device and
precision) for the [O III] model:
spectrum |
JAX-CPU float64 |
JAX-GPU float64 |
JAX-CPU float32 |
JAX-GPU float32 |
|---|---|---|---|---|
4 382 pixels |
16.9 ms / iteration |
17.2 ms |
16.2 ms |
16.4 ms |
35 056 pixels |
11.1 ms / iteration |
11.2 ms |
7.5 ms |
7.6 ms |
350 560 pixels |
51.8 ms / iteration |
44.2 ms |
22.8 ms |
13.2 ms |
At the spectrum sizes this package is used for, the GPU and the JAX-CPU backend take the same time: a nested-sampling iteration is bound by the sampler’s own bookkeeping and by per-call dispatch, not by the arithmetic. The GPU only pulls ahead when the model is expensive enough to dominate that floor (350 k pixels: 1.2x in float64, 1.7x in float32).
The arithmetic itself is much faster than the sampler currently uses: the batched evaluation above runs at 15.6 us/point (float64) and 1.5 us/point (float32) for the 35 k-pixel model, i.e. 45-460x faster than the astropy model’s 0.70 ms. The headroom is in the sampler: JNesty’s random walk evaluates the likelihood for one candidate at a time, so the batched throughput shown above is only partly realised end-to-end. Feeding larger batches per kernel launch is the lever with the largest remaining payoff for GPU runs.
Float32 is worth knowing about: on this GeForce the 64-bit pipeline runs at a small fraction of the 32-bit rate, so float32 is ~3-10x faster per evaluation and ~2-4x faster end-to-end, at ~4e-5 relative accuracy instead of ~1e-16.
The iron template, and letting the lines broaden it#
galspec.jax_models.IronTemplate speaks the same language as the CPU
galspec.IronTemplate_new: amplitude, sigma (velocity dispersion in
km/s), dv (velocity shift) and a z that is fixed by default. This is
the parameterisation to fit with, because sigma and dv are the numbers a
paper quotes and the redshift is known:
iron = jxm.IronTemplate_new(amplitude=0.2, sigma=2500.0, dv=0.0, name="FeII")
iron.z.fixed = True # already the default
# sigma and dv are free: the Fe II optical multiplet is a measurement
IronTemplate_tied is the stricter model: the template is broadened not by one
Gaussian but by a multi-Gaussian profile that is fitted at the same time –
normally the profile of the broad line, tied component by component. Its kernel
parameters carry the same names as the Line_MultiGauss they come from, and
n_components counts the kernel’s components, so 2 is a core plus one wind:
# the broad component of H-alpha, on its own: component 0 is the broad core
b_ha = jxm.Line_MultiGauss(n_components=2, amp_c=0.65, dv_c=0.0, sigma_c=1500.0,
amp_w0=0.10, dv_w0=0.0, sigma_w0=6000.0,
wavec=6563.0, name="b_halpha")
iron = jxm.IronTemplate_tied(n_components=2, amplitude=0.2, name="FeII")
iron.dv_c.tied = tie.tie_MultiGauss_dv_c("b_halpha") # the broad core
iron.sigma_c.tied = tie.tie_MultiGauss_sigma_c("b_halpha")
iron.amp_w0.tied = tie.tie_MultiGauss_amp_w0("b_halpha") # the wind
iron.dv_w0.tied = tie.tie_MultiGauss_dv_w0("b_halpha")
iron.sigma_w0.tied = tie.tie_MultiGauss_sigma_w0("b_halpha")
# free iron parameters left: amplitude, and z (fixed by default)
There are no weight parameters, and no amp_c: a Line_MultiGauss already
is a weighted sum of Gaussians, whose weights are its own amplitudes
(amp_c for component 0, amp_c * amp_w0 for the wind). The iron only needs
the shape of that sum, and the convolution renormalises it, so the core
amplitude cancels identically and the wind ratio – the line’s own amp_w0,
tied – is the only weight left. Adding a separate weight would introduce a flat
direction that the data cannot close.
With one component it is identical to IronTemplate (verified to machine
precision in tests/test_model_features.py), so the generalisation cannot
quietly change the simpler model’s meaning. Shifts are relative to the core
(dv_c + dv_w0), exactly as in the line model, so the iron inherits the line’s
centroid.
Bound the wind ratio, and the core-wind ambiguity disappears. The two
components of a Line_MultiGauss are exchangeable: the map
leaves the spectrum unchanged to 3e-16 (a unit check in
develop/dev_jax/iron_tied_io_test.py), and the tied iron follows it. So a fit
with A_w allowed above 1 has two equally good answers and the words “core” and
“wind” mean nothing. Since a wind is by definition no brighter than its own core,
put A_w in [0, 1]: the swap needs A_w' = 1/A_w > 1 and is excluded,
while the widths keep their full data-driven range. Measured on the worked
example’s posterior (67 620 samples): zero samples on the mirror branch, and the
widths come out the same as with artificially disjoint width priors – 1409 and
7247 km/s here, 1409 and 7255 km/s there – with the same error bars, so the bound
costs no information. develop/dev_jax/io_test/cpu_vs_jax.html is the separate
question of whether this model agrees with the CPU one.
The implementation subtracts the template’s own width in quadrature, component by
component, instead of deconvolving it: exact for Gaussian kernels, and it needs no
host-side kernel array, so the model stays differentiable and jit-able
through every kernel parameter.
The CPU galspec.IronTemplate_tied now takes the same route, component for
component: every kernel component is smoothed once with
sqrt(sigma**2 - sigma_intr**2) on the template grid, projected onto the data grid
at its own shift, and added with the line’s own relative weight. The two backends
therefore agree to 1e-15 of the iron peak – the same function, not two
approximations to it – and both are within 0.01-0.02% of an independent
velocity-space convolution (measured in develop/dev_jax/io_test/cpu_vs_jax.html;
the residue is the 126 km/s sampling of the template, not the algorithm). Before
this revision the CPU class FFT-deconvolved the template by a 200 km/s intrinsic
Gaussian, filtered the result with a Wiener filter and convolved with a kernel
sampled on a fixed +/-10 000 km/s window; it was 8.5% (mock) and 10.0% (fit) of the
iron peak away from the exact answer, and 8-10% from this model. That algorithm is
kept, unmodified and clearly labelled, as legacy_iron() in the comparison script
so the old numbers stay reproducible.
Cost. The convolution runs on the template grid (2 389 channels for
park2022, 801 of them the tabulated range and the rest the zero padding), not on
the 57 000-pixel spectrum, and only the sum is interpolated onto the data. Measured
on this machine, a two-component kernel costs +0.40 ms per evaluation – 56% of a
full-spectrum model, which is 0.32 ms without it. A fit needs 1e5-1e6 evaluations,
so call it 40 s to 7 minutes on top of the sampling. It is not free – the kernel costs one smoothing and one
interpolation per component – but it does not change the scale of the problem.
Two things keep it there: the smoothing happens on the template rather than on the
spectrum, and the kernel radius is static jnp data sized from the widest width
the model can be asked for (the prior bound inside a fit, the widest constructor
value outside one), so nothing re-traces when a sample moves. The zero padding
triples the template, which triples the smoothing; it is the price of not reflecting
the template’s edge flux into the fit, and it is paid once per evaluation.
The template grid is even in the logarithm of wavelength. As tabulated, both
templates are even in wavelength (park2022: exactly 2 A over 4000-5600 A), so
one channel would be 150 km/s at the blue end and 107 km/s at the red end and a
Gaussian of a given sigma would be a different number of channels at each end –
29% narrower where the template is red. Both backends therefore resample at load
time, through galspec.iron_template.load_iron_template() (which calls
galspec.stellar_continuum.log_rebin()), onto a grid with constant
dln(lambda): 126 km/s per channel over 4000-5600 A. Every model built on the
template – the CPU and JAX IronTemplate, IronTemplate_new and
IronTemplate_tied – broadens it in velocity space by construction, and the CPU
and JAX versions still agree to 1e-16 because they share the grid.
The tabulated range is then extended with zeros by 100 000 km/s on each side, on the
same grid, for a total of 2 389 channels. Without that the smoothing pads by
mirroring, and the template’s red end (still carrying about 4% of its peak at
5600 A) is mirrored back into the spectrum as a constant floor: the model stayed at
5% of the iron peak all the way to 6800 A even though there is no iron there, and it
mis-set the iron wing at 5450-5600 A. With the zeros the model fades out where the
template runs out. Against an independent convolution on a uniform velocity grid the
JAX tied iron is then 0.02% of the iron peak over the whole range, at every kernel
width tested (0.01-0.47%, the 0.47% at the narrowest kernel, where the template’s own
sampling is what limits it). tests/test_model_features.py pins the grid, the
padding and the velocity-space broadening.
LaTeX labels for parameters#
Fitters report amp_c0_7 and Continuum.alpha; plots should print
\(A_{c0}\) and \(\alpha\). galspec.param_labels splits every
parameter name into a base and a subscript, so one table covers the whole
library, and prefixes the owning model:
model["Halpha"].label = r"BH$\alpha$" # display name, may contain maths
fit = JNesty_Fit(model, wave, flux, error)
print(fit.param_labels[:3])
# ['Continuum:$A$', 'Continuum:$\alpha$', 'BH$\alpha$:$A_{c}$']
Both fitters expose param_labels (aligned with full_param_names), so
fit.plot_corner() and galspec.plot.plot_posterior_hist use them without
further work. The model’s name is untouched: it is still the key you index
with (model["Halpha"]).
A model class that needs a different symbol declares it in a PARAM_SYMBOLS
class attribute, which overrides the shared table for that model only:
class Line_Exponential(Line_Gaussian):
PARAM_SYMBOLS = {"sigma": r"\varsigma", "amplitude": r"\mathcal{A}"}
>>> parameter_labels(Line_Exponential(amplitude=1.0, sigma=300.0))
['$\mathcal{A}$', '$\Delta v$', '$\varsigma$', '$\lambda$']
One-dimensional posteriors#
A corner plot of thirty parameters is unreadable. plot_posterior_hist draws
one histogram per requested parameter, with the median, the 16-84 percentile band
and the value printed on top:
galspec.plot.plot_posterior_hist(
fit, params=["Halpha.sigma_w0", "OIII.sigma_c", "HeI.amp_w0"],
ncols=3, filename="marginals.png")
It accepts a fit object or a plain (nsamples, ndim) array, and labels can be
overridden with labels=.
The x range of each panel comes from the posterior (view_quantiles, 0.1% to
99.9% by default, plus pad of that span), not from the prior. That is not
cosmetic: the prior on a broad width spans 300-8000 km/s while the posterior spans
a few km/s, so an axis stretched to the prior would draw a well-measured parameter
as a spike one pixel wide – the shape, which is the reason for the panel, would be
invisible. When a prior bound falls outside the plotted range, its value is
written in the panel corner, so the zoom hides nothing. A parameter that really
has filled its prior is still obvious, because its histogram is flat rather than
peaked.
Drawing twenty components#
galspec.plot.plot_fit_jax draws every component, the total model and the residual
panel from one call – for a JAX model. (plot_fit is the astropy counterpart;
the two are the same figure, and the reason they are two functions is at the end of
this section.) Two conventions in it exist because a twenty-component fit broke the
defaults:
Colours. matplotlib’s ten-colour cycle is not enough for twenty components: two unrelated lines end up in the same colour, and the worst case is a line taking the same red as the total model.
plot_fittherefore cycles a 24-colour palette (galspec.plot._COMPONENT_COLORS) with the red reserved for the total model.Residuals.
residual_in_sigma=True(witherror=) divides the residual by the flux uncertainty and labels the panelRes./sigma, with dotted guides at +/-1. In raw flux units a single bright pixel sets the scale of the whole panel, so a well-fitted zoom looks flat and the stated rms cannot be checked by eye. Passylim1per panel for the same reason.
The legend is placed by the caller: with twenty entries, bbox_to_anchor outside
the axes is the only way to keep it from covering the data.
Multi-Gaussian lines are broken into their Gaussians#
Line_MultiGauss and Line_MultiGauss_doublet are sums: a core plus one per
wind, and (for a doublet) one of each under each line. plot_fit_jax draws every
one of them as a dashed curve in the colour of the component it belongs to, so the
broad H-alpha core and its wind can be told apart in the figure instead of only in
the parameter table:
model["b_halpha"].n_components # 2 in the worked example: core + one wind
model["b_halpha"].subcomponents # [Line_Gaussian(core), Line_Gaussian(wind0)]
subcomponents is a property on the JAX models, mirroring the astropy property of
the same name: it returns galspec.Line_Gaussian objects carrying the absolute
amplitude (the stored wind amplitudes are relative to the core) and the absolute
shift dv_c + dv_w{i}, so sum(part(wave) for part in m.subcomponents) is the
component itself, to 1e-16. It reads the current parameter values, which is why
plot_fit_jax first asks the compound model to write its tied parameters back –
otherwise a tied amplitude would be drawn at its construction default.
Why not one function#
plot_fit cannot do this for a JAX model, and the failure is silent. Its test is
isinstance(m, galspec.Line_MultiGauss), against the astropy class;
galspec.jax_models.Line_MultiGauss is a different class that happens to have the
same name, so the branch is never taken and the dashed curves simply do not appear.
Everything else about a JAX model duck-types – iteration yields the leaves, they
have name and label, calling one evaluates it – which is exactly why the
omission is easy to miss. Hence two functions with the same signature: pass an
astropy model to plot_fit, a JAX model (or a JNesty_Fit) to
plot_fit_jax.
Saving a fit, and what lives on the GPU#
galspec.jnesty_fit_io writes a completed fit to FITS and reads it back:
from galspec.jnesty_fit_io import save_jnesty_fit_to_fits, load_jnesty_fit_from_fits
save_jnesty_fit_to_fits(fit, "iras07546_jnsty.fits") # thin=2 to shrink it
loaded = load_jnesty_fit_from_fits("iras07546_jnsty.fits")
loaded.get_quantiles((0.16, 0.5, 0.84))
The layout is the same one galspec.dynesty_fit_io uses – WAVE_USE,
FLUX_USE/FERR, SAMPLES, LOGWT, LOGL, LOGVOL,
SAMPLES_EQ, PARAM_NAMES, PARAM_BOUNDS, MODEL_PARAM and
MODEL_STR – so a JNesty file can be read with the same habits and the same
scripts. Three extensions exist only here: SAMPLES_U (the unit-cube
coordinates), DLZ_TRAJ/SCALE_TR (the remaining-evidence estimate and the
random-walk step scale against iteration), and the X64/JAXVER/JNESTYV/
DEVICE/NITER/RUNTIME keywords needed to describe a run. The model is
serialised by galspec.jax_models.serialization, which knows about the
constructor arguments the astropy walker never sees (n_components,
degree, template_name, a stellar type) and about convolution wrappers.
A loaded fit evaluates the model, the best fit, the quantiles and the evidence
exactly as the original did – the reloaded model reproduces the flux to 0.0
difference in double precision, ties included. The priors come back too: each one
is stored as its type and its arguments ({"kind": "uniform", "args": {"lower":
300.0, "upper": 8000.0}}), together with the name of any parameter it is centred
on, so a file written from now on can be reloaded and the sampler resumed. The
one exception is a prior supplied as a bare Python function (the older
custom_priors spelling): that cannot be written to a file, so the loader warns
and keeps only the resolved bounds. Files written before priors became objects
fall in the same case: they read normally, and are for results rather than for
resuming.
Memory. JNesty’s GPU footprint for a fit like this is ~1 GB and does not
grow with the number of iterations: the dead-point history is one buffer allocated
once at max_iterations size (ndim * max_iterations * 8 bytes – 61 MB for
38 parameters and 200 000 iterations, 2.4 GB for 8 million), and the sampler runs
inside a single lax.while_loop with a fixed-shape state. memory_frac only
caps the proposal batch, so raising it does not reserve memory, and with
XLA_PYTHON_CLIENT_PREALLOCATE=false (the default in the example environment)
the driver keeps the high-water mark for the life of the process without ever
shrinking it – nvidia-smi therefore shows the peak, which can look like slow
growth if another job is sharing the card. Two things are worth knowing when the
card is shared:
set
max_iterationsto a realistic budget (a value ten times larger than needed reserves a buffer ten times larger);call
galspec.jnesty_fit.JNesty_Fit.release_device_buffers()when a run is over. It drops the sampler (and JAX’s compiled kernels) while keeping the results, so plots and summaries still work. Saving to FITS first makes that lossless.
Streaming the dead points to disk during a run is possible – run_nested
accepts init_live_x/init_live_logL/init_logZ_val/init_logX/
init_iter_offset, which is how JNesty continues a run – but it does not lower
the peak footprint, because the peak is the fixed history buffer. Its real value
is checkpoint/resume after an interruption, and it needs a joint-evidence
recombination on the host (jnesty.dynamic.compute_integrals) because each
segment’s logZ is computed from that segment alone.
Caveats#
JAX’s first call in a process pays for compilation (about 1-3 s for these models, 5-20 s including CUDA initialisation for a 39-parameter model). Amortise it over a fit;
galspec.jnesty_fit.JNesty_Fit.warmup()does that explicitly.The CUDA backend cannot be selected after JAX has initialised; set
JAX_PLATFORMS=cpu(or passdevice="cpu") if you want to force the CPU.float64on consumer GPUs (GeForce) runs at a small fraction of thefloat32rate. The numbers above already include that penalty; on data-centre GPUs (A100/H100)float64is much less penalised.JNesty has no
seedargument (it uses a fixed internal key), so repeated runs are reproducible but cannot be re-seeded.JNesty does not report the number of likelihood calls;
JNesty_Fit.ncallfollows JNesty’s own convention (iterations * rwalk_K + nlive) and is an estimate.
A complete AGN fit: CPU and GPU, same answer#
The benchmarks above use windows of a spectrum. The case that actually matters is
a full, realistic fit, so develop/dev_jax/run_sdss_fit.py runs one and writes
everything needed to compare: the SDSS spectrum of IRAS 07546+3928 (z = 0.0953,
dereddened, rest frame, 4200-6900 A, 59 146 pixels at 0.046 A sampling), a model
that covers H-gamma, H-beta and H-alpha together with all the strong forbidden
lines, and 23 free parameters.
The model#
develop/dev_jax/sdss_agn.py builds the same model twice, once from the
astropy classes and once from the JAX classes, from a single shared list of
components:
a power-law continuum;
the Park et al. (2022) Fe II template (the iron pseudo-continuum);
H-alpha, H-beta, H-gamma: a narrow and a broad Gaussian each;
[O III] 4959/5007 with a narrow core and a broad wing;
[N II] 6548/6583, [S II] 6716/6731, [O I] 6300/6364, He II 4686, [O III] 4363.
The iron template’s broadening and the line wavelengths are fixed (the former is degenerate with the continuum: freed, it runs to whatever upper limit it is given while chi2/dof is unchanged).
Twenty-one free parameters is what remains after the standard ties (all narrow
lines share the kinematics of [O III]; all broad Balmer lines share the kinematics
of H-alpha) and the fixed doublet ratios. For the timing comparison the
[S II] 6716/6731 ratio is tied too; physically it should not be (it is an
electron-density diagnostic, not a fixed number), and the worked example in
example/fit_iras07546_jax.py leaves it free, models each Balmer line as two
separate models (a narrow Line_template line tied to [O III] and a broad
Line_MultiGauss with a core and a wind), fits He I 5876 the same way, and ties
the iron template’s kernel to the broad H-alpha component by component, which is
why that fit covers 4200-6800 A with 27 free parameters over seventeen models – the
narrow lines are one measured profile rather than a Gaussian each, so they cost a
peak and a shift instead of a peak, a width and a shift. The
benchmark model is also convolved with
the SDSS line-spread function (Gaussian, R = 2000 at 5500 A): at 0.046 A sampling
the instrument, not the gas, sets the width of the narrow cores, so convolving
makes the comparison the harder, more realistic one. (The worked example does
not convolve: for that spectrum the instrument contributes ~150 km/s next to
~700 km/s of gas, and its own reference fit in
example/wrk_sagan_iras07546.ipynb likewise ignores the LSF.)
The two implementations agree to 1.5e-15 of the peak flux at the best fit, and the log-likelihoods agree to 3e-5 out of ~1e5 (relative 3e-10) over 25 random prior draws. This is what makes the timing comparison meaningful: the GPU is not buying speed with accuracy.
Priors without a preliminary fit#
The priors are derived from the data’s own flux range, not from a least-squares fit. Every emission-line amplitude is log-uniform between 1e-3 and twice the brightest pixel; narrow-line widths span 30-1000 km/s (FWHM 70-2400 km/s); broad Balmer widths span 500-20 000 km/s; the continuum slope may range from -5 to +5. Nothing is tuned to a known answer, which is what makes the exercise meaningful: a nested sampler with wide priors has to find the solution itself.
The fit#
quantity |
value |
|---|---|
pixels / free parameters |
59 146 / 21 |
reduced chi2 |
1.98 |
residual rms |
1.40 sigma |
narrow-line FWHM (shared by H-alpha .. [O I]) |
824 km/s |
broad H-alpha FWHM |
4 690 km/s |
log evidence |
114 264 +/- 0.6 |
The residual scatter is 1.4 sigma, so the model describes a real spectrum at the level expected from a 1% photon noise budget plus a 3% systematic allowance for flux calibration, the iron template and the imperfection of a single power law. The strongest lines ([O III], H-beta) retain a few-sigma misfit at their very cores: two Gaussian components cannot reproduce the longer wings of a real line profile. That limitation is identical for the two code paths and does not affect the comparison.
Timing#
Same model, same data, same nlive (400) and same stopping rule
(dlogz = 0.01, i.e. less than 1% of the evidence may remain unaccounted for).
Each configuration runs in its own process; compilation is excluded from the
JNesty numbers.
configuration |
wall clock |
likelihood calls |
log Z |
|---|---|---|---|
dynesty, 1 CPU core |
8.9 h (32104 s) |
2.76 x 10^6 |
114271.0 +/- 0.6 |
dynesty, 16 CPU cores |
83.7 min (5 025 s) |
2.58 x 10^6 |
114 284.7 +/- 0.6 |
JNesty, GPU |
6.3 min (379 s) |
2.91 x 10^6 |
114 264.5 +/- 0.6 |
All three rows are measured wall-clock times for the same fit with the same stable stopping rule. The GPU is 84.7x faster than one core and 13.3x faster than sixteen; sixteen cores are only 6.4x faster than one, because dynesty’s parallel mode funnels proposals through a parent process. One model evaluation costs 11.6 ms on this machine (32 104 s / 2.76 x 10^6 calls, and cross-checked by a counter wrapped around the likelihood), which is the quantity that sets every one of these numbers.
Three things are worth taking from the table.
The parameters are identical. Over all 21 fitted quantities the two samplers’ posterior medians differ by at most 0.05 of a combined standard error (median 0.02), and reduced chi2 is 1.978 for both.
Parallel CPU scaling is poor here. Sixteen cores buy only 6.4x over one core (5 025 s against 32 104 s, both measured), because dynesty’s parallel mode funnels every proposal through a single parent process: on this problem the workers sit at ~36% CPU. The GPU does not have that bottleneck, which is why the gain is larger than the per-call ratio.
The evidence agrees less well than the parameters. log Z differs by 20.2 between the two codes while each reports +/-0.6. Doubling
nlive(400 to 800) moves JNesty’s answer by 0.3, so its integration has converged. The decisive control is the single-core run: the same dynesty code, on the same problem, differing only in core count, returns 114 271.0 against 114 284.7 – 13.6 nats apart, 22x its quoted error. Sampler-to-sampler scatter of this size is therefore normal and is not GPU precision (the models agree to 3e-5 in log L). Report evidence ratios across runs made with the same sampler, or quote both.
A readable HTML version of this comparison, written for readers who are new to
spectral fitting, is produced by develop/dev_jax/make_report.py. It writes
develop/dev_jax/results/report/report.html: a single self-contained file
(every figure inlined, navigation by in-page anchors) that renders anywhere,
including in a sandboxed preview pane. The same script also writes the four
sections as separate pages (index.html, model.html, results.html,
glossary.html) for reading straight from a checkout.
Prior questions: how many components, and how similar must two lines be?#
Two constructions that used to be documented here now have their own pages, because they are about priors rather than about the GPU:
priors.SpikeAndSlab– a prior that can switch a broad component off, so the number of components becomes a posterior rather than a choice made before the fit;priors.Normal(mean="other.parameter", ...)– a Gaussian centred on another parameter, with a hardnsigmalimit, which turns “these two lines share one kinematics” from an assumption into a measurement.
Both are written up, with the mock calibration and the measured answers on IRAS 07546+3928, in Priors.