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