Skip to content

Commit 4d0116e

Browse files
authored
Merge pull request #80 from DeepWave-KAUST/fix/bs-backward-last-two
fix(bs): backward binds p.u_last_two instead of self-allocating
2 parents fe6153e + 4290248 commit 4d0116e

2 files changed

Lines changed: 105 additions & 2 deletions

File tree

src/sweep/csrc/cuda/equations/acoustic3d/backward.cu

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -850,18 +850,30 @@ void run_bs_imaging(
850850
auto cpml = cpml_tensor.view();
851851

852852
int save_width = p.abcn > 0 ? p.M + 1 : p.M;
853+
// bs.last_two is never read anywhere in the backward -- the adjoint seeds
854+
// are taken straight from p.u_last_two a few lines below. This used to pass
855+
// {}, so the saver allocated its OWN {1,2,B,1,nz,ny,nx} FP32 two-wavefield
856+
// buffer on every call; and on the staged (storage=cpu) path its options are
857+
// HOST memory (saver.cuh:558, store_on_gpu ? gpu : pinned), so torch::zeros
858+
// memset ~380 MB on the host every step. DD calls in once per time step, so
859+
// this one line was ~20 ms/step of pure host stall -- the GPU idle with no
860+
// kernel and no communication. forward.cu:189/194 has always passed
861+
// p.last_two (bound, not allocated); the backward now does the same.
862+
torch::Tensor last_two_scratch = (p.u_last_two.defined() && p.u_last_two.numel() > 0)
863+
? p.u_last_two
864+
: torch::zeros({1, 2, B, 1, nz, ny, nx}, vp.options().dtype(torch::kFloat32));
853865
EffectiveBoundarySaver boundary_saver;
854866
bool staged_boundary = p.boundary_on_cpu || p.boundary_on_disk;
855867
if (staged_boundary) {
856868
boundary_saver.allocate(
857869
true, 3, 1, ctx, vp, save_width, 2,
858870
true, false, p.transfer_interval, p.boundary_cpu, p.boundary_gpu,
859-
{}, p.use_pinned_memory
871+
last_two_scratch, p.use_pinned_memory
860872
);
861873
} else {
862874
boundary_saver.allocate(
863875
true, 3, 1, ctx, vp, save_width, 2,
864-
true, true, 1, {}, p.boundary_gpu, {}, p.use_pinned_memory
876+
true, true, 1, {}, p.boundary_gpu, last_two_scratch, p.use_pinned_memory
865877
);
866878
if (p.boundary_gpu.empty())
867879
boundary_saver.load_from_vector(p.u_boundary, vp);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Regression: the boundary-saving backward must not allocate a wavefield per call.
2+
3+
``<eq>/backward.cu`` hands ``last_two`` to ``EffectiveBoundarySaver::allocate``.
4+
It used to pass ``{}``, so ``allocate_last_two`` took the self-allocating branch
5+
and built a fresh ``{nvar, 2, B, 1, nz, ny, nx}`` FP32 buffer on EVERY call --
6+
a buffer the backward never reads (its reverse seed comes from ``p.u_last_two``
7+
directly). Harmless for a monolithic backward (one call); ruinous under
8+
DD/stepped, where the extension is entered once per time step. On the Gorgon
9+
615-tooth cascade that was ~382 MB per step and ~30k steps per iteration, and
10+
on the staged path the buffer lands in HOST memory: 1760 s/iteration against
11+
166 s once it binds ``p.u_last_two`` instead.
12+
13+
A value test cannot see this: the buffer is never read, so gradients were
14+
always bit-exact. What the defect changes is bytes allocated per call, so that
15+
is what this pins. ``allocated_bytes.all.allocated`` is a CUMULATIVE counter,
16+
so the caching allocator reusing blocks does not hide it.
17+
"""
18+
from __future__ import annotations
19+
20+
import sys
21+
from pathlib import Path
22+
23+
import pytest
24+
import torch
25+
26+
sys.path.insert(0, str(Path(__file__).resolve().parent))
27+
from test_stepped_backward import ( # noqa: E402
28+
NT2D, NT3D, Harness, build, capture_backward, partitions_for,
29+
run_public_once,
30+
)
31+
32+
if not torch.cuda.is_available():
33+
pytest.skip("boundary saving is impl='c' CUDA only", allow_module_level=True)
34+
35+
36+
def _cum_alloc():
37+
torch.cuda.synchronize()
38+
return torch.cuda.memory_stats()["allocated_bytes.all.allocated"]
39+
40+
41+
def _alloc_of(h, cuts):
42+
h.zero_state()
43+
a = _cum_alloc()
44+
h.replay_stepped(cuts)
45+
return _cum_alloc() - a
46+
47+
48+
# ``bound`` is the measured fixed-tree baseline plus 1 x model_bytes -- the
49+
# midpoint of the 2 x model_bytes the defect adds, so the pass and fail sides
50+
# each keep a full model of margin. Baselines measured on H100:
51+
# 2D 3.02 x model (bound 4.0, defect would be 5.02)
52+
# 3D 2.00 x model (bound 3.0, defect would be 4.00)
53+
@pytest.mark.parametrize("ndim,nt,bound", [(2, NT2D, 4.0), (3, NT3D, 3.0)])
54+
def test_stepped_backward_allocation_is_flat_in_segments(ndim, nt, bound):
55+
"""Bytes allocated per backward call must not carry a whole wavefield.
56+
57+
Replaying one reverse sweep as 2 segments and as ``nt`` segments is the
58+
same work; only the number of extension entries differs. Extra bytes
59+
divided by extra calls is the per-call allocation. The defect adds the
60+
``last_two`` buffer -- 2 x model_bytes -- to every call, so it cannot fit
61+
under the bound below; the fixed path only allocates the small model-sized
62+
scratch the kernel genuinely needs.
63+
"""
64+
# Order copied from run_case: capture has to happen before the forward
65+
# (the wrapper is read at forward time), and bs is the string "gpu", not a
66+
# dict.
67+
prop, wav, src, rec, models = build(ndim, bs="gpu", nt=nt)
68+
cap = capture_backward(prop)
69+
run_public_once(prop, wav, src, rec, models)
70+
h = Harness(cap, ndim, "bs")
71+
72+
m = h.p.models[0]
73+
model_bytes = m.numel() * m.element_size()
74+
parts = partitions_for(nt)
75+
76+
_alloc_of(h, parts["halves"]) # warm every lazy buffer
77+
a = _alloc_of(h, parts["halves"])
78+
b = _alloc_of(h, parts["per_step"])
79+
n_extra = (len(parts["per_step"]) - 1) - (len(parts["halves"]) - 1)
80+
per_call = (b - a) / n_extra
81+
82+
print(f"\n[{ndim}D nt={nt}] model {model_bytes/1e6:.2f} MB | per-call alloc "
83+
f"{per_call/1e6:.3f} MB = {per_call/model_bytes:.2f} x model "
84+
f"({n_extra} extra calls)")
85+
86+
# The backward genuinely allocates per-call scratch (f_this, the CPML view,
87+
# the aux slabs). The defect adds the last_two buffer on top -- EXACTLY
88+
# 2 x model_bytes more -- so the two paths sit either side of ``bound``.
89+
assert per_call < bound * model_bytes, (
90+
f"backward allocates {per_call/model_bytes:.2f} x model per call -- the "
91+
"last_two self-allocation is back (saver.cuh allocate_last_two)")

0 commit comments

Comments
 (0)