Propagators¶
A propagator wraps an equation object, grid configuration, acquisition geometry, and model tensors into a callable solver. The two main entry points are:
sweep.propagator.torch.PropTorch— PyTorch-family runtimesweep.propagator.jax.PropJax— JAX-family runtime
backend vs impl¶
PropTorch takes two related but distinct knobs:
backend— the array / autograd framework carrying tensors and gradients ("torch"for PyTorch eager /torch.compile;"jax"for JAX onPropJax).impl— the implementation path under that framework ("eager"= pure-Python operators with PyTorch autograd;"c"= the prebuilt CUDA core (libsweep_core.so) driven through the pure-Pythonsweep._Cctypes layer).
The two axes are orthogonal:
| Backend | Impl | Device | What runs |
|---|---|---|---|
torch |
eager |
CPU or CUDA | Pure-Python operators + PyTorch autograd |
torch |
c |
CUDA | Prebuilt CUDA core via the sweep._C ctypes layer |
jax |
— | CPU or CUDA | JAX implementation via PropJax |
"cuda" is not a backend or impl value — it is a device choice driven by
the tensors you pass in. The compiled path runs on CUDA tensors only; a host
model tensor is refused with a clear error, and a CPU propagator resolves to
impl="eager".
A minimal invocation for each path:
from sweep.propagator.torch import PropTorch
solver_eager = PropTorch(..., backend="torch", impl="eager")
solver_c = PropTorch(..., backend="torch", impl="c")
Geometry conventions¶
sources— shape(nshots, ndim)for a single point source per shot, or(1, nsources, ndim)for one source-encoded supershot (all sources fire together;receiversis then(1, nreceivers, ndim)and the wavelet(nt,)or(nsources, nt))receivers— shape(nshots, nreceivers, ndim)- 2D coordinates are
(x, z)in grid indices — horizontal first, depth last - 3D coordinates are
(x, y, z)in grid indices — horizontal axes first, depth last
Model and wavefield arrays themselves are stored as (nz, nx) / (nz, ny, nx)
(depth first), which is the same axis order matplotlib.imshow expects when
plotting with depth growing downward. The propagator reverses the user-supplied
(x, z) / (x, y, z) coordinate tuples internally before indexing into the
wavefield tensor.
Boundaries: free surface and PML¶
Every propagator takes free_surface= and abcn= (PML thickness).
free_surface accepts several equivalent forms, all normalised to one
canonical per-face boolean tuple (axis-major order — 2-D
(z_lo, z_hi, x_lo, x_hi), 3-D (z_lo, z_hi, y_lo, y_hi, x_lo, x_hi)):
PropTorch(eq, ..., free_surface=False) # absorbing everywhere (default)
PropTorch(eq, ..., free_surface=True) # historical top-only free surface
PropTorch(eq, ..., free_surface="top") # single face name — same as True
PropTorch(eq, ..., free_surface=["top", "left"]) # list/set of face names
PropTorch(eq, ..., free_surface={"bottom": True,
"right": True}) # dict: face name -> on/off
PropTorch(eq, ..., free_surface=(True, False,
True, False)) # canonical per-face tuple: top + left
PropTorch(eq, ..., free_surface=(1, 1, 1, 1)) # same form with ints = closed box
Face names (also the order of the canonical tuple): 2-D
top, bottom, left, right; 3-D top, bottom, front, back, left, right
— top is the z-min face, matching the (nz, nx) depth-first array
layout. An unknown name raises ValueError listing the valid names.
Each free face replaces its PML pad with the image-method boundary
condition; the remaining faces stay absorbing. abcn takes a scalar or a
canonical tuple of per-face PML widths (no face names or dict). Per-face
widths are eager-only -- impl='c' raises NotImplementedError for them
-- and, like per-edge free surfaces, 2-D Acoustic / Elastic /
ViscoAcoustic only.
boundary_buffer= (default None) puts that many sigma=0 cells between
the physical box and the PML ramp on every absorbing face. Boundary saving
stores a shell M + 1 cells wide, which is all the reverse time step needs;
an equation whose gradient stencil reaches further -- AcousticVRZ and
AcousticVRZ3D take a divergence of a gradient, reach 2M -- would image
cells the shell never restored. Such an equation declares
BOUNDARY_BUFFER_REACH and None gives it REACH * M + 1 buffer cells
under every memory strategy and backend, so full, checkpointing and boundary
saving solve the same grid; the buffer is cropped with the pad, so shapes and
coordinates do not change. Equations with pointwise imaging get no buffer.
An explicit value is honoured, but boundary saving refuses one below the
equation's need. A non-zero buffer cannot be combined with topography=
yet (NotImplementedError).
Support matrix:
- 2-D
Acoustic/Elastic— full per-edge free-surface support on the eager and compiled CUDA backends, with adjoint gradients across every backward memory mode (full / boundary-saving / checkpointing). - 2-D
ViscoAcoustic— full per-edge free-surface support on the eager and compiled CUDA backends (full / checkpointing gradients; boundary saving is refused — the dissipative spectral damping term cannot be reverse-time reconstructed, and theimpl='c'default falls back to'full'). - 3-D — top face only (
free_surface=True); per-edge lists raise. - Topography (irregular surface) — a separate top-only feature; it cannot be combined with per-edge faces.
- Anisotropic equations (
AcousticVTI1st,AcousticVTI,AcousticTTI,ElasticTTI(SG), …) raiseNotImplementedErrorfor any free surface: the anisotropic stress-free condition couples through the stiffness tensor and is not the isotropic image method these solvers implement. Run withfree_surface=Falseor use an isotropic equation.
See the per-edge free surface notebook for snapshots of each configuration and a closed-box energy check.
Implementation-specific options¶
Every option block lives in sweep.propagator.options and is documented in
detail on the Propagator Options page. A
quick reference:
| Option block | Used with | Configures |
|---|---|---|
EagerOptions |
impl="eager" |
torch.compile flags, debug knobs |
CUDAOptions |
impl="c" |
Container for impl="c"-only options; its memory= is equivalent to PropTorch(memory=...) |
Full |
as memory= |
Keep the whole forward wavefield. No parameters |
BoundarySaving |
as memory= |
Boundary saving with GPU / CPU / disk storage |
Ckpt |
as memory= |
Chunk- or recursive-mode checkpointing |
MemoryOptions(strategy=..., boundary=..., ckpt=...) is the older way to say
the same three things and is deprecated; BoundarySaving and Ckpt subclass
the BoundaryOptions / CkptOptions it wrapped, so every field, default and
validation rule is the one you already knew.
Pass each block through the matching kwarg. The memory strategy (Full,
BoundarySaving, Ckpt) always goes through PropTorch's own memory=,
whichever impl runs:
PropTorch(..., eager_options=EagerOptions(...)) # impl="eager"
PropTorch(..., cuda_options=CUDAOptions(...)) # impl="c"
PropTorch(..., memory=BoundarySaving(...)) # either impl
A full Option composition example¶
To FWI on a large model with the compiled CUDA path, boundary saving on pinned host memory, and asynchronous disk fallback at the largest scale:
import torch
from sweep.equations import Acoustic
from sweep.propagator.torch import PropTorch
from sweep.propagator.options import Full, BoundarySaving, Ckpt
dev = torch.device("cuda")
shape = (512, 2048)
dh, dt = 10.0, 0.002
solver = PropTorch(
Acoustic(spatial_order=8, device=dev),
shape=shape, dh=dh, dt=dt, device=dev,
impl="c",
memory=BoundarySaving(storage="cpu", pinned_memory=True),
)
Swap in memory=BoundarySaving(storage="disk", disk_async_read=True) for the
disk fallback, memory=Ckpt(mode="chunk", chunks=100) for chunk-mode
checkpointing, or memory=Full() to keep the whole forward wavefield. The
strategy IS the object, so there is no separate strategy= string to keep in
step with it.
The validation rules are enforced in the dataclass __post_init__ methods, so
incompatible combinations (e.g. disk_async_read=True with storage="cpu")
fail loudly at construction time rather than during a long FWI run.
Memory-saving features¶
The gradient-memory mode is a three-way choice, identical for the eager and CUDA backends, and the choice is a type:
memory=Full() # keep the forward wavefield
memory=BoundarySaving(storage="gpu") # reconstruct from the boundary
memory=Ckpt(mode="chunk", chunks=100) # rematerialise from checkpoints
Left unset, impl="c" defaults to BoundarySaving(storage="gpu") (fp32 ring;
Full() for ViscoAcoustic and DASZhao3D, which have no compiled boundary
saving) and the eager backend to Ckpt(). The modes are mutually exclusive,
and conflicting requests raise a ValueError instead of one path silently
winning.
The older spellings still work, and now warn
memory=MemoryOptions(strategy=..., boundary=..., ckpt=...) and the flat
boundary_saving_config={...} dict are read exactly as before and emit a
DeprecationWarning; the removal criterion is written next to the shim.
use_ckpt= is not deprecated — it stays a plain supported keyword.
Three rules make that resolution predictable:
- An off-switch means
'full', not "the other trick".use_ckpt=False(orboundary_saving_config={'enabled': False}) with nothing else selects full-wavefield storage on both backends — the long-standing meaning ofimpl='c', use_ckpt=False. The implicit backend default applies only when no gradient-memory knob is passed at all. - A request is honoured, not out-voted.
boundary_saving_config={'enabled': True}now really runs the boundary backward; it used to lose silently to theuse_ckpt=Truedefault, so scripts that thought they were measuring boundary saving were checkpointing. memory=may sit next to a legacy knob when they agree.memory=BoundarySaving(), use_ckpt=Falsestates one intent twice and is accepted;memory=BoundarySaving(), use_ckpt=Truecontradicts it and raises. Where both carry detail,memory=wins.
Tail truncation has a legacy dict spelling too — boundary_saving_config=
{'enabled': True, 'tail_steps': K} is equivalent to
BoundarySaving(tail_steps=K).
| Feature | Path | Configured by |
|---|---|---|
| Full storage (no reconstruction) | both | Full() |
Boundary saving (GPU or CPU ring; + disk on impl="c") |
both | BoundarySaving(storage=..., storage_dtype=..., ...) |
| Asynchronous disk prefetch | impl="c" |
BoundarySaving(storage="disk", disk_async_read=True, ...) |
| Boundary tail truncation (steady-state / freqsel objectives) | impl="c" acoustic |
BoundarySaving(tail_steps=...) |
| Chunked checkpointing | both | Ckpt(mode="chunk", chunks=...) |
| Recursive (fixed-budget) checkpointing | impl="c" |
Ckpt(mode="recursive", count=...) |
torch.compile on the eager step |
impl="eager" |
EagerOptions(use_compile=True, ...) |
A runnable comparison of these options lives in the Memory · strategies notebook, which exercises full-wavefield, boundary saving, and checkpointing on the same Marmousi shot and prints the per-mode peak GPU / host memory.
Boundary tail truncation (BoundarySaving.tail_steps)¶
For steady-state objectives — frequency-selection / DFT-comb FWI, where the
loss reads only the last n_probe samples of the record and the adjoint
source is therefore zero everywhere earlier — the reverse sweep does not need
to walk the whole record. tail_steps=K makes the forward save only the last
K steps' boundary strips and stops the backward after them:
- The forward physics is unchanged — the wavefield still runs the full
record so the steady state can ring up. Only the saved/reconstructed step
range shrinks, so both the backward wall time and the one-shot boundary
buffer drop by roughly
1 - K / nt(measured: 74 % backward time and 75 % buffer atK/nt = 25 %). - The restore at reverse step
itconsumes the strip saved at forward stepit - 1, so one saved step is spent on alignment: the effective reverse depth isK - 1— budget it insidemargin. marginis physical: the dropped gradient term is exactly the adjoint × ring-up-transient correlation that steady-state methods discard, and it decays as the adjoint field drains through the absorbing boundary. Sweep the margin once per setup: on a ramped-sine test the truncated gradient converges monotonically to the full one (cos 0.992 → 1.000000 for margin 0 → 800 steps on a 140×160 grid).- Do not use it with impulsive-source objectives: there the early adjoint–forward correlations are real gradient content and the truncated gradient is genuinely different (cos ≈ 0.1 in the same test).
- Scope:
impl="c"Acoustic 2-D/3-D with the boundary-saving backward, anystorage/storage_dtype. Checkpointing, the eager backend and the other equations raiseNotImplementedError/ValueErrorrather than silently ignoring the option. Unset (None, the default) is bit-exact legacy behaviour, andtail_steps >= ntdegenerates to it bitwise. - Domain decomposition composes:
ModelParallelinheritstail_stepsfrom the wrapped propagator's memory config exactly likestorage/storage_dtype, shrinks every tile's boundary ring toKsteps, and stops the lockstep reverse halo loop at the same global step on every rank (the stop index is derived from(nt, tail_steps), which are identical across ranks by construction, so no rank can be left waiting in an exchange). The truncated DD gradient is bit-exact against the truncated single-domain gradient on fp32 boundaries (test/test_dd_tail_two_tile.py,test/dd_tail_nccl_check.py).
Environment toggles¶
A few knobs stay out of the API because the right value depends on the
machine, not on the problem. All are read once per run. None changes the
physics, but two change the numbers: a lossy SWEEP_BOUNDARY_DTYPE
(fp16/bf16/int8) and SWEEP_VRZ_GRAD_SPLIT (a different summation, ~1 ULP).
| Variable | Effect |
|---|---|
SWEEP_VRZ_GRAD_SPLIT=1 |
AcousticVRZ3D backward: force the O(M) split gradient (materialise c_d/e_d, then one divergence) instead of the fused nested-stencil kernel that order<=4 picks by default. The crossover is GPU-dependent — fused wins on RTX 6000 Ada, split is ~12 s/iter faster on V100 at production scale. |
SWEEP_DD_DISABLE_OVERLAP=1 |
Domain decomposition: serial step-then-exchange instead of the overlapped forward (see Domain decomposition). |
SWEEP_BOUNDARY_DTYPE |
impl="c": storage_dtype of the implicit default ring only (no memory knob passed). Any BoundarySaving(...) carries storage_dtype="fp32" unless set, so it always wins. |
SWEEP_DATASETS_CACHE |
Where sweep.datasets caches downloads (see Datasets). |
SWEEP_JIT_FULL=1 |
Developer path: compile the pybind shim against your torch instead of using the ctypes layer over the prebuilt core. |
SWEEP_CORE=<path>/libsweep_core.so |
Use a custom CUDA core; its core.json sidecar gets the same fit check as the shipped one. |
SWEEP_JIT_ALLOW_OLD_CUDA=1 |
Try an nvcc 12.0–12.3 for a local core build. |
TORCH_CUDA_ARCH_LIST |
Target arch(s) of a local core build; when set it overrides the visible GPU's arch, and it is required when no GPU is visible. |
TORCH_EXTENSIONS_DIR |
Where a locally built core is cached. |
CUDA_HOME / CUDA_PATH |
Toolkit for a local core build, version-checked against torch's CUDA major. |
Consistency testing¶
The C memory modes are exercised by test/solver_gradient_mode_suite.py. The
suite compares eager gradients against compiled full-wavefield, boundary
saving, and checkpoint modes across interior, finite-difference edge, and
free-surface source placements, and writes per-mode gradient figures to
test/test_outputs/solver_gradient_mode_suite/.