"""Phase 1 spec test for ``tl.scratch_scope`` (ADR-0063, P3a). ADR-0063 D1: ``tl.scratch_scope()`` is a context manager whose ``__enter__`` snapshots ``_scratch_cursor`` and whose ``__exit__`` restores it, freeing every handle allocated inside the ``with``-block. This pins the bump allocator behaviour without changing any command emission. Phase 1 (this commit): tests only — production code lands in Phase 2. """ from __future__ import annotations from kernbench.triton_emu.tl_context import TLContext # Configured TLContext with a real (non-zero) scratch base — required for # _make_compute_out to allocate, see tl_context.py:_scratch_alloc. def _ctx() -> TLContext: return TLContext( pe_id=0, num_programs=1, dispatch_cycles=0, scratch_base=1 << 24, # 16 MiB base scratch_size=1 << 20, # 1 MiB pool ) # ── 1. Method exists and returns a usable ctx-mgr ───────────────────── def test_scratch_scope_method_exists(): ctx = _ctx() assert hasattr(ctx, "scratch_scope"), ( "TLContext must expose scratch_scope() per ADR-0063 D1" ) with ctx.scratch_scope() as scope: assert scope is not None # ── 2. Recycling within a loop bounds peak cursor ──────────────────── def test_scratch_scope_recycles_within_loop(): """ADR-0063 D1 / Test Req 1: a loop inside scratch_scope keeps peak _scratch_cursor bounded by one iteration's footprint.""" ctx = _ctx() # One iteration footprint: allocate three small handles via # _make_compute_out. one_iter_peaks = [] for _ in range(10): with ctx.scratch_scope(): ctx._make_compute_out(shape=(64,), dtype="f16") ctx._make_compute_out(shape=(64,), dtype="f16") ctx._make_compute_out(shape=(64,), dtype="f16") one_iter_peaks.append(ctx._scratch_cursor) # After the loop the cursor must be at its post-loop start (0 here), # not 10× one iteration. assert ctx._scratch_cursor == 0, ( f"cursor must reset on each scope exit; final cursor = " f"{ctx._scratch_cursor}" ) # Every iteration sees the same peak — proves recycling. assert len(set(one_iter_peaks)) == 1, ( f"every iteration must reach the same peak (recycled); " f"got distinct peaks {one_iter_peaks}" ) # ── 3. Allocations outside the scope are not recycled ──────────────── def test_scratch_scope_preserves_outside_allocations(): """ADR-0063 D3: handles allocated outside the scope (persistent arena) keep their addresses; only inside-scope allocations are rewound.""" ctx = _ctx() # Persistent allocation BEFORE the scope. persistent = ctx._make_compute_out(shape=(64,), dtype="f16") cursor_after_persistent = ctx._scratch_cursor with ctx.scratch_scope(): ctx._make_compute_out(shape=(64,), dtype="f16") ctx._make_compute_out(shape=(64,), dtype="f16") # On exit the cursor must rewind to the save-point (which equals # cursor_after_persistent), NOT to 0 — the persistent allocation is # preserved. assert ctx._scratch_cursor == cursor_after_persistent, ( f"cursor must rewind to scope-enter point ({cursor_after_persistent}), " f"not 0; got {ctx._scratch_cursor}" ) # A new allocation after the scope must not collide with the # persistent one. fresh = ctx._make_compute_out(shape=(64,), dtype="f16") assert fresh.addr != persistent.addr, ( "post-scope allocation must not reuse the persistent address" ) # ── 4. Nested scopes rewind to their own save-points ───────────────── def test_scratch_scope_nesting(): """ADR-0063 D4: nested scopes rewind to the matching enter-point.""" ctx = _ctx() with ctx.scratch_scope(): ctx._make_compute_out(shape=(64,), dtype="f16") outer_save = ctx._scratch_cursor with ctx.scratch_scope(): ctx._make_compute_out(shape=(64,), dtype="f16") ctx._make_compute_out(shape=(64,), dtype="f16") inner_peak = ctx._scratch_cursor # Inner exit: rewind to outer_save. assert ctx._scratch_cursor == outer_save, ( f"inner exit must rewind to {outer_save}; " f"got {ctx._scratch_cursor}" ) assert inner_peak > outer_save, ( "sanity: inner scope must have allocated something" ) # Outer exit: rewind all the way to 0. assert ctx._scratch_cursor == 0, ( f"outer exit must rewind to 0; got {ctx._scratch_cursor}" ) # ── 5. Control: without scope, allocations pile up ─────────────────── def test_without_scope_grows_unbounded(): """Sanity check that the bump allocator without scratch_scope grows linearly — the failure mode ADR-0063 §A.2 cites for the S=16 cap.""" ctx = _ctx() for _ in range(10): ctx._make_compute_out(shape=(64,), dtype="f16") ctx._make_compute_out(shape=(64,), dtype="f16") ctx._make_compute_out(shape=(64,), dtype="f16") # 30 allocations × aligned(64 * 2 = 128 → 128) B = 3840 B assert ctx._scratch_cursor >= 30 * 128, ( f"without scope, cursor must grow with allocation count; got " f"{ctx._scratch_cursor}" ) # ── 6. Overflow avoided by recycling ───────────────────────────────── def test_overflow_avoided_with_scope(): """ADR-0063 Test Req 3: a loop that would overflow 1 MiB without scope completes when wrapped in scratch_scope.""" # 1 MiB pool / one alloc of 64 KiB = 16 max without scope. # 100 iterations of 64 KiB without recycling = 6.4 MiB → overflow. # With scratch_scope: peak ≈ 64 KiB per iter, well under 1 MiB. ctx = _ctx() big_shape = (32 * 1024,) # 32 K elements × 2 B = 64 KiB per handle for _ in range(100): with ctx.scratch_scope(): ctx._make_compute_out(shape=big_shape, dtype="f16") # Completes without raising — the recycle keeps us under the cap. assert ctx._scratch_cursor == 0 # ── 7. scratch_scope + copy_to integration (ADR-0063 §D3.1) ────────── def test_scoped_loop_with_copy_to_keeps_cursor_bounded(): """ADR-0063 §D3.1: the two-arena flash pattern. Persistent ``running`` allocated OUTSIDE the scope; per-iteration work allocates inside; the last act before scope exit is ``tl.copy_to(running, new_running)`` to persist state. After the loop the cursor must equal the post-persistent-allocation value (running survived) and never have grown beyond that + one iteration's footprint. """ ctx = _ctx() # Persistent arena: one running-state handle outside any scope. running = ctx._make_compute_out(shape=(8, 64), dtype="f16") cursor_after_persist = ctx._scratch_cursor assert running.addr != 0 n_iters = 50 for _ in range(n_iters): with ctx.scratch_scope(): # Simulate per-tile work: a few scoped allocations. ctx._make_compute_out(shape=(8, 64), dtype="f16") ctx._make_compute_out(shape=(8, 64), dtype="f16") new_running = ctx._make_compute_out(shape=(8, 64), dtype="f16") # Persist back to the outside-scope handle so its bytes # survive __exit__. ctx.copy_to(running, new_running) # After scope exit cursor returns to the persistent footprint — # scoped allocations are recycled, running survives. assert ctx._scratch_cursor == cursor_after_persist, ( f"after scope exit cursor must rewind to " f"{cursor_after_persist}; got {ctx._scratch_cursor}" ) assert ctx._scratch_cursor == cursor_after_persist def test_persistent_handle_survives_after_copy_to_in_scope(): """ADR-0063 §D2 / §D3: a handle allocated outside a scratch_scope must keep its address even after a copy_to(...) targeting it from inside the scope and after that scope exits.""" ctx = _ctx() persistent = ctx._make_compute_out(shape=(64,), dtype="f16") persistent_addr_before = persistent.addr with ctx.scratch_scope(): scoped = ctx._make_compute_out(shape=(64,), dtype="f16") ctx.copy_to(persistent, scoped) # Out of scope; persistent's address is unchanged. assert persistent.addr == persistent_addr_before # A fresh allocation after the scope must not collide with persistent. fresh = ctx._make_compute_out(shape=(64,), dtype="f16") assert fresh.addr != persistent.addr