Skip to content

Commit 939cd1d

Browse files
authored
Merge pull request #63 from DeepWave-KAUST/feat/dd-and-boundary-memory
Domain decomposition (sweep.parallel) + boundary-memory modes
2 parents af90867 + 065302b commit 939cd1d

195 files changed

Lines changed: 26038 additions & 2043 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,39 @@ and this project adheres to
1010
## [Unreleased]
1111

1212
### Added
13+
- **Domain decomposition (`sweep.parallel`).** `ModelParallel` splits one
14+
model into tiles — one GPU per tile — and exchanges a halo every time step,
15+
so a single shot is solved cooperatively instead of replicated.
16+
`MeshTopology(py, px, shot_groups=...)` describes the rank grid and composes
17+
with shot parallelism; `pad_to_mesh` / `unpad_from_mesh` size a model to the
18+
tile multiple. Forward and backward are plain autograd, and the gradient is
19+
**bit-identical** to the single-domain gradient on fp32 GPU boundaries.
20+
Supported equations: `Acoustic` (2-D), `Acoustic3D`, `AcousticVRZ3D`,
21+
`Elastic` (2-D), `Elastic3D`; anything without stepped kernels is refused at
22+
construction. See the [Domain decomposition](docs/user-guide/parallel.md)
23+
guide and notebooks 25 / 26.
24+
- **CPU-staged boundary storage under domain decomposition.**
25+
`BoundaryOptions(storage="cpu")` now works for the Acoustic 2-D/3-D DD
26+
backward, so a tile whose boundary ring does not fit in GPU memory has a
27+
fallback instead of a hard stop. The gradient is **bit-identical** to
28+
gpu-direct on fp32 and bf16, and within each dtype's own run-to-run floor on
29+
fp16/int8; it composes with `tail_steps`. `storage="disk"` under DD, and
30+
cpu staging on a single-tile mesh, are still refused — by name, at the first
31+
backward. Elastic DD remains gpu-direct only. See
32+
[Domain decomposition](docs/user-guide/parallel.md#boundary-storage-under-dd).
33+
- **`BoundaryOptions.tail_steps`** (dict spelling:
34+
`boundary_saving_config={'tail_steps': K}`): keep only the last `K` steps of
35+
the boundary ring and stop the reverse loop there. For steady-state
36+
objectives (frequency-selection / encoded FWI) whose adjoint source is zero
37+
outside a probe window, the truncated gradient is the same gradient; the
38+
reverse pass and the ring both shrink proportionally. Acoustic 2-D/3-D,
39+
boundary-saving backward only, and it composes with domain decomposition.
40+
- **CPML aux strip allocation.** `psi`/`zeta` (acoustic) and the elastic
41+
memory variables now live in per-axis slabs — the PML band plus stencil
42+
reach — instead of full grids, for `Acoustic`, `Acoustic3D`, `Elastic` and
43+
`Elastic3D` on `impl='c'`. Gradients are bit-for-bit unchanged; only the
44+
allocation shrinks. Equation authors opt in via the new `CUDALayoutSpec`
45+
fields `pml_slot_axes`, `checkpoint_slot_axes` and `adjoint_pml_slab`.
1346
- Per-edge free surface (deepwave-style). `Propagator(free_surface=...)` now
1447
accepts a per-edge spec — an edge-name list (`['top', 'left']`), a
1548
length-`2*ndim` bool mask (`[z0, z1, x0, x1]`), or a dict — in addition to the
@@ -33,12 +66,60 @@ and this project adheres to
3366
- `CHANGELOG.md` and `CONTRIBUTING.md` scaffolding.
3467

3568
### Changed
69+
- **The gradient-memory mode is now one three-way choice**`'full'`,
70+
`'boundary'` or `'ckpt'` — resolved identically for the eager and CUDA
71+
backends by `resolve_memory_strategy`, and selected in one place with
72+
`memory=MemoryOptions(strategy=...)`. The legacy `use_ckpt` /
73+
`boundary_saving_config` knobs still work and resolve into the same choice.
74+
Three behaviour changes come with it:
75+
- `boundary_saving_config={'enabled': True}` now actually runs the boundary
76+
backward on both backends. It used to lose silently to the `use_ckpt=True`
77+
default, so scripts that believed they were using boundary saving were
78+
checkpointing (`impl='c'`) or ignoring the dict entirely (`impl='eager'`).
79+
- Contradictory requests raise `ValueError` instead of one path winning
80+
silently — `use_ckpt=True` together with an enabled `boundary_saving_config`,
81+
or `memory=` contradicting a legacy knob. Knobs that *agree*
82+
(`memory=MemoryOptions(strategy='boundary')` with `use_ckpt=False`) are
83+
accepted.
84+
- A dict passed without `enabled=True` (e.g. `{'storage': 'cpu'}`) selects
85+
`'full'`, not checkpointing.
86+
Unchanged on purpose: no knobs at all still means boundary saving for
87+
`impl='c'` and checkpointing for the eager backend, and an explicit
88+
off-switch (`use_ckpt=False`) still means full-wavefield storage.
3689
- `docs/user-guide/equations.md`: summary table expanded from 3 rows to cover
3790
all 20+ exported equation classes, grouped by physics family. Template
3891
reminder at the bottom replaced with a "See Also" cross-reference block.
3992
- `mkdocs.yml`: enabled `attr_list` and `md_in_html` Markdown extensions to
4093
support Material grid-card layouts.
4194

95+
### Fixed
96+
- **CPML aux writes on a domain-decomposition cut tile.** With the strip
97+
allocation, the per-axis PML compute band reaches columns on a cut face that
98+
carry no slab storage; the unclamped index produced a negative offset and the
99+
ungated store aliased `±0` into another row's slab cell, racing its owner.
100+
Writes are now gated on `stored()` and read through the clamped accessor
101+
(`aux_rd_*`). Only reachable with `impl='c'` acoustic + multi-GPU DD, and
102+
never on a released build; single-domain runs are bit-for-bit unchanged.
103+
- **The boundary spec can no longer be changed after construction.**
104+
`prop.free_surface = ...` (and `fs_faces`, `abcn`, `pad`, `pml_type`,
105+
`topography`) used to land on the `PropTorch` wrapper, where it shadowed the
106+
backend's value: the read-back reported the new setting while every kernel
107+
kept the old one — a script could believe it had switched a free surface on
108+
and quietly model without one. The write now raises `AttributeError` and
109+
points at the constructor.
110+
- **DAS Mu 2-D/3-D were non-deterministic on `impl='c'`.** `das_mu*/kernels.cuh`
111+
includes the elastic kernels, which address the CPML memory variables through
112+
the solver's aux slabs, but the DAS drivers never installed them: the row
113+
stride collapsed to zero and every row aliased the first, so the same input
114+
gave a different answer each run (plain single-GPU forward, with or without a
115+
free surface). `AcousticLSRTM` and `AcousticVRZ3D` borrow the acoustic
116+
kernels the same way but never launch the slab-addressed ones and were
117+
unaffected. `test/test_c_aux_slab_repeatability.py` now pins the class.
118+
- **`AcousticVRZ3D` boundary staging with `storage_dtype='fp16'`/`'int8'`.**
119+
The 2-D and 3-D VRZ paths now pass `boundary_tangent_pad = M` into the
120+
effective-boundary saver, fixing an out-of-bounds staging copy on the
121+
low-precision ring.
122+
42123
## Earlier history
43124

44125
Earlier release notes will be backfilled from the commit history. For now,

_dd_cuda/PERF_REPORT.md

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# DD performance — cut-aware pad + comm/compute overlap (8×V100, ibex)
2+
3+
Two committed optimizations on `perf/dd-cut-aware-pad`:
4+
5+
* `1e0c9cc` **cut-aware compact pad** — cut faces allocate only the M stencil
6+
halo (not abcn+M). Per-card memory cut **19–59 %** (scales with tile
7+
thinness + #cut-axes). Bonus: cut tiles also *compute* faster (no cut-side
8+
PML work) — the px8 "no-comm" floor (1.174 ms) is BELOW the px1 full-pad
9+
step (1.356 ms).
10+
* `a7b7bb4` **comm/compute overlap** (acoustic fwd) — phase-1 cut strips →
11+
async halo exchange on a comm stream (start = copy-send+P2P, no wait;
12+
finish = wait+copy-recv) running while phase-2 interior computes. Bit-exact
13+
vs serial; **source-safe** (serial fallback when a source sits in a cut
14+
strip, since the source is injected in phase 2).
15+
16+
## Scaling (acoustic 3D, so4, abcn20, V100, per_step ms)
17+
18+
WEAK (fixed tile/GPU, problem grows):
19+
20+
| tile/GPU | px1 | px2 | px4 | px8 | 8-GPU eff |
21+
|----------|-----|-----|-----|-----|-----------|
22+
| 256³ |1.356|1.396|1.397|1.419| 7.65× (95.6%) |
23+
| 320³ |2.578|||2.584| **7.98× (99.8%)** |
24+
25+
STRONG (fixed global 256×256×1024, split):
26+
27+
| GPUs | 1 | 2 | 4 | 8 |
28+
|------|---|---|---|---|
29+
| ms |4.964|2.712|1.396|0.845|
30+
| speedup||1.83×|3.56×|**5.87×**|
31+
32+
## Conclusions
33+
34+
* **Weak scaling reaches 8× on 8 GPUs** (7.98× / 99.8 % at 320³; 7.65 % at
35+
256³). The user's "8卡8倍" target is met for realistic tile sizes — bigger
36+
tiles → closer to ideal as the halo becomes a smaller fraction.
37+
* **Strong scaling tops out at ~5.9× (8 GPUs).** This is structural, not a
38+
missing optimization:
39+
* the per-step halo comm (~0.2 ms) is **copy + req.wait bound**, not
40+
P2P-bound (the NCCL transfer itself is ~40 µs). It is 4 strided staging
41+
copies + a CPU-blocking `work.wait()`; overlap hides only part of it.
42+
* the **compute-only** strong floor at px8 is already just 7.36× (thin
43+
128-wide tiles drop FD-kernel efficiency), so even perfect comm hiding
44+
could not reach 8×.
45+
* NCCL env tuning (`NCCL_P2P_LEVEL=NVL`, `NCCL_ALGO=Ring`) did not help
46+
(default best). Larger tiles are the lever for weak-scaling efficiency.
47+
48+
Remaining levers are deep/fragile (custom fused strided-halo copy kernels, a
49+
GPU-event P2P sync to drop the CPU `work.wait`, or splitting z) — not worth the
50+
robustness cost for the residual strong-scaling gap.
51+
52+
## Comm profile + why K-step doesn't help (loop round 2)
53+
54+
Per-step halo exchange breakdown (256³ x-halo, 2 ranks, ms):
55+
56+
| copy-send | copy-recv | P2P+wait | Python/API | full |
57+
|-----------|-----------|----------|------------|------|
58+
| 0.025 | 0.051 | 0.068 | 0.054 | 0.198 |
59+
60+
No single dominant component — copies, P2P+wait, and Python overhead are each
61+
~1/3. Only the P2P (~0.04 ms, NVLink) can truly overlap compute; the strided
62+
staging copies use SMs (compete with phase-2) and `work.wait`/Python are CPU,
63+
so the SPECFEM overlap recovers only a fraction.
64+
65+
**K-step exchange (wider halo, exchange every K steps) is net-negative here.**
66+
It amortizes the per-exchange overhead (~0.064 ms/step saved at K=4) but forces
67+
each step to redundantly compute the K·M halo region: 2(K−1)M extra x-cells =
68+
12/nxp_eff of the tile. On the thin strong-scaling tiles (nxp=128) that is
69+
~9.4 % extra compute > the 7.2 % comm saved → slower; on fat weak tiles it is
70+
roughly neutral. So the textbook latency-hiding lever does not apply.
71+
72+
**Conclusion (round 2): the *x-cut* clean optimizations are maximized.** Strong
73+
scaling ~5.9× is the practical limit *for x-cut* (comm is irreducibly ~0.2 ms
74+
split 3-ways and only partly hideable; the x-cut compute-only floor is 7.36×).
75+
Weak scaling already meets the 8× goal. — But round 3 found the x-cut *axis
76+
choice itself* was the limit; see below.
77+
78+
## Round 3: copy-engine (rejected) + decomposition-axis (the real win)
79+
80+
**Copy-engine halo staging — TRIED, REJECTED (net-negative).** Hypothesis: move
81+
the strided halo staging copies off the SMs onto the GPU copy engine
82+
(`cudaMemcpy2DAsync`) so they overlap the stencil. Implemented + validated
83+
**bit-exact** (dd_api_check px8 acoustic/elastic 2D/3D all PASS with the engine
84+
genuinely on). But it is **slower** everywhere: the isolated strided copy went
85+
0.074→0.094 ms (+27 %, DMA launch latency dominates these tiny D2D strided
86+
copies), and every end-to-end config regressed (strong px8 overlap 0.841→0.908,
87+
+compute-stream 0.829→0.885). Reverted. (Reusable finding: cudaMemcpy2DAsync D2D
88+
is the wrong tool for small strided halo strips on V100.)
89+
90+
**Decomposition axis — the actual lever.** The 7.36× compute floor was blamed on
91+
launch-amortization; it is really the **x-cut tile SHAPE**. A 1-D x-cut shrinks
92+
the *contiguous* x-dimension (px8: Nx/8) — worst for the FD kernel and the halo
93+
copy. Cutting more axes (a) saves more cut-aware PML (more cut faces → less PML
94+
work) and (b) gives a squarer tile. Compute-floor sweep, equal 8.39 M cells/tile
95+
(global 256²×1024 / 8), none-mode on 8× V100:
96+
97+
| decomposition | tile (Nz,Ny,Nx) | x_contig | per_step | peak_mem |
98+
|---------------|-----------------|----------|----------|----------|
99+
| x-cut px8 py1 | (256,256,128) | 128 | 0.666 ms | 0.80 GB |
100+
| **bal px4 py2** | (256,128,256) | 256 | **0.608 ms** | **0.74 GB** |
101+
| bal px2 py4 | (256, 64,512) | 512 | 0.683 ms | 0.82 GB |
102+
| y-cut px1 py8 | (256, 32,1024) | 1024 | 1.031 ms | 1.03 GB |
103+
104+
Not monotonic — a **balanced** tile wins; y-cut (fat x, thin y) is *worst*
105+
(refutes "fat contiguous x is better"). End-to-end via the production
106+
`ModelParallel` (correct corner halo; forward, 8× V100):
107+
108+
| global (Nz,Ny,Nx) | x-cut px8 | balanced px4 py2 | speedup | mem |
109+
|-------------------|-----------|------------------|---------|-----|
110+
| 256 × 256 × 1024 | 0.893 ms (6.55×) | **0.814 ms (7.18×)** | +9 % | 2.69→2.56 GB |
111+
| 384 × 384 × 384 | 1.026 ms (4.67×) | **0.716 ms (6.69×)** | +43 % | 2.84→2.29 GB |
112+
| 512 × 512 × 512 | 1.890 ms | **1.439 ms** | +31 % | 5.66→4.71 GB |
113+
| 256 × 512 × 1024 | 1.673 ms | **1.417 ms** | +15 % | 4.94→4.54 GB |
114+
115+
**The balanced 2-D decomposition is up to ~1.5× faster and ~18 % lighter for
116+
strong scaling, generalising across shapes — biggest for cubic globals where the
117+
x-cut tile is thinnest.** Shipped as `sweep.parallel.balanced_grid(world, shape)`
118+
(returns the recommended `(py, px)`; pure arithmetic, additive — does not change
119+
any default). Default caps `py<=2` (a conservative load-balance choice that
120+
already captures +9–43 %). `py>=3` (the cubic optimum, e.g. 384³ px2py4 =
121+
0.688 ms / 6.96×) is opt-in by raising `max_py` (e.g. `max_py=world`) and is
122+
**validated** (8× V100 bit-exact).
123+
124+
**py>=3 boundary-save crash — FIXED (`83a70df`, ibex bit-exact confirmed).** An
125+
earlier `CUDA error: invalid configuration argument` for `py>=3` was **not** a
126+
missing y-kernel guard: `ModelParallel._capture` ran a public fwd/bwd with
127+
`cut_face_mask=0` on a thin cut-aware tile, so the kernel sized a cut face as
128+
full PML → negative boundary count → invalid launch. Fix is pure-Python
129+
(`_capture` fwrap/bwrap set the mask; **no CUDA rebuild**); `Boundary3D::front_back`
130+
is dead code. See lesson_dd_capture_cut_mask.
131+
132+
**Updated bottom line:** weak 8× met; **strong scaling improves from ~5.9× to
133+
7.0–7.2× simply by choosing a balanced grid** (≈8× compute is reachable because
134+
cutting more axes is super-linear via cut-aware PML savings). Use
135+
`balanced_grid()` instead of a 1-D x-cut. Bench tools: `dd_ddp_timing.py`
136+
(production-path per-decomposition timing), `dd_axis_strong.sbatch`,
137+
`dd_axis_generalize.sbatch`.
138+
139+
## Round 4: per-step / per-forward driver optimizations (2026-06-15)
140+
141+
A pass over the DD driver targeting the per-step Python overhead (×nt), the
142+
per-forward redundancy, and readability/extensibility. All bit-exact — ibex
143+
`dd_api_check` acoustic/elastic 2D/3D (+ free surface) PASS, the 3D 2×2 corner
144+
stays PASS_TOL (≤1e-5) as before; per-change gate sbatches in `_dd_cuda/`.
145+
146+
* **per-step caching** (`89154ca`) — the stepped runners rebuilt
147+
`list(wavefields)` every step and `ModelParallel._halo_view` rebuilt the halo
148+
crop slice every exchange. The bound order depends only on `(k%3, k%2)` (≤6
149+
distinct lists) and the crop slice is loop-invariant, so both are cached.
150+
Bit-exact (same persistent tensors; only roles rotate).
151+
* **`forward(models=None)`** (`622100a`) — an FWI epoch fires many shots through
152+
one model, yet forward re-padded the runtime model and ran the NCCL model-halo
153+
collective every shot. `models=None` reuses the buffers a prior forward set →
154+
one model-halo per epoch, not per shot. Explicit on purpose (no version-
155+
guessing that could silently run on a stale model).
156+
* **elastic halo aggregation** (`9db162a`) — the elastic loop fired a separate
157+
`batch_isend_irecv`+wait per field (nphys = 5 in 2-D, 9 in 3-D) each step.
158+
`FastHaloGroup` concatenates each field-group (velocity / stress fwd;
159+
adjoint+recon bwd) into ONE batched P2P → 2 waits/step. **Measured
160+
1.28–1.30× elastic-2D forward** (2× V100, nphys=5, tiles 64²/128², nt300;
161+
`dd_agg_bench.py`); larger for 3-D (nphys=9 → 2). Bit-exact.
162+
*True comm/compute overlap stays acoustic-only:* the elastic kernel's
163+
`step_phase` is a velocity/stress field-group split, not the strip/interior
164+
spatial split overlap needs (phase 2 reads all velocity halos), so hiding the
165+
exchange behind interior compute would require a C++ kernel sub-split.
166+
* **shot-parallel gradient** (`789fe7d`) — `gradient()` all_reduces the per-tile
167+
gradient across the shot process group when `shot_groups>1` (the FWI gradient
168+
is a sum over shots), enabling combined shot+model parallelism without a B>1
169+
rewrite. No-op for `shot_groups==1`. (`dd_shotpar_check.py`, world=4 = 2×2.)
170+
* **autograd-transparent forward** — DD now behaves like single-domain
171+
`PropTorch`: if a model tensor `requires_grad`, the record carries a grad_fn
172+
and a plain `loss.backward()` populates each model's `.grad` (per-tile leaf →
173+
tile grad; replicated global leaf → global grad, `all_reduce` to assemble) via
174+
a `_DDForward(autograd.Function)` whose backward runs the DD adjoint. The
175+
explicit `gradient()` method is **removed** (dev-stage, no back-compat) — an
176+
arbitrary adjoint goes through `record.backward(gradient=adjoint)`.
177+
* **API / readability**`balanced_grid(max_py=…)` replaces the misleading
178+
`allow_y_thin` bool (**removed**, no deprecated alias); `FastHaloSet.exchange`
179+
deduped via `_get` (`dcfb3a9`); `forward()` split into `_prepare_call` + per-
180+
family loop helpers — 33 lines, was ~120 (`4ace00d`).
181+
* **stale-test fix** (`f8711cd`) — `test_dd_tiles_3d` / `test_dd_elastic_tiles_3d`
182+
predated the cut-aware pad (symmetric `PAD` offsets, missing/mis-offset
183+
model-halo fill) and asserted against the wrong tile region → spurious gross
184+
failures; migrated to per-tile `prop.padding` offsets. Production path was
185+
always correct.

0 commit comments

Comments
 (0)