|
| 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×|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