59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
606 lines
23 KiB
Python
606 lines
23 KiB
Python
"""Unit tests for the breakable CUDA graph core (Phase 1).
|
|
|
|
Captures a tiny ``Linear -> eager break -> Linear`` forward with
|
|
:class:`BreakableCapture`, mutates the static input buffer, replays, and asserts
|
|
the replayed output matches an eager recompute. This exercises the load-bearing
|
|
invariants in isolation -- segment splitting, the eager break handoff, shared
|
|
mempool address stability -- without touching the model or the hot path.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(
|
|
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
)
|
|
from ci_system.ci_register import register_cuda_ci # noqa: E402
|
|
|
|
register_cuda_ci(est_time=15, suite="runtime-1gpu")
|
|
|
|
from types import SimpleNamespace # noqa: E402
|
|
|
|
import torch # noqa: E402
|
|
|
|
from tokenspeed.runtime.execution.breakable_cuda_graph import ( # noqa: E402
|
|
BreakableCapture,
|
|
active_forward,
|
|
break_here,
|
|
break_point,
|
|
current_forward_ctx,
|
|
is_breakable_capture_active,
|
|
scrub_padding_tail,
|
|
slice_to_real_tokens,
|
|
)
|
|
|
|
|
|
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
|
|
class TestBreakableCudaGraph(unittest.TestCase):
|
|
def setUp(self):
|
|
torch.manual_seed(0)
|
|
self.dev = "cuda"
|
|
self.dtype = torch.float32
|
|
self.n, self.d = 8, 16
|
|
self.w1 = torch.randn(self.d, self.d, device=self.dev, dtype=self.dtype)
|
|
self.w2 = torch.randn(self.d, self.d, device=self.dev, dtype=self.dtype)
|
|
# Static input buffer: graphs read this address; live inputs are copied in.
|
|
self.x_static = torch.zeros(self.n, self.d, device=self.dev, dtype=self.dtype)
|
|
|
|
def _eager(self, x):
|
|
"""Reference forward: Linear -> relu (the "break" op) -> Linear."""
|
|
h = x @ self.w1
|
|
h = torch.relu(h)
|
|
return h @ self.w2
|
|
|
|
def _build_capture(self):
|
|
"""Capture the same forward with relu as an eager break."""
|
|
cap = BreakableCapture()
|
|
|
|
def forward():
|
|
h = self.x_static @ self.w1
|
|
# Break-output buffer, allocated in-segment so it is pool-pinned.
|
|
dst = torch.empty_like(h)
|
|
h = break_here(torch.relu, dst, h)
|
|
return h @ self.w2
|
|
|
|
# Warm up (cublas workspace / lazy init) before capture.
|
|
for _ in range(3):
|
|
forward()
|
|
torch.cuda.synchronize()
|
|
|
|
with cap:
|
|
captured_out = forward()
|
|
return cap, captured_out
|
|
|
|
def test_break_here_passthrough_when_inactive(self):
|
|
self.assertFalse(is_breakable_capture_active())
|
|
h = torch.randn(self.n, self.d, device=self.dev, dtype=self.dtype)
|
|
dst = torch.empty_like(h)
|
|
out = break_here(torch.relu, dst, h)
|
|
self.assertIs(out, dst)
|
|
torch.testing.assert_close(out, torch.relu(h))
|
|
|
|
def test_segments_split_at_break(self):
|
|
cap, _ = self._build_capture()
|
|
# Two graph segments (before/after relu) + one eager break = 3.
|
|
self.assertEqual(cap.num_segments, 3)
|
|
|
|
def test_replay_matches_eager(self):
|
|
cap, captured_out = self._build_capture()
|
|
for trial in range(5):
|
|
new_x = torch.randn(self.n, self.d, device=self.dev, dtype=self.dtype)
|
|
self.x_static.copy_(new_x)
|
|
cap.replay()
|
|
torch.cuda.synchronize()
|
|
torch.testing.assert_close(
|
|
captured_out, self._eager(new_x), msg=f"trial {trial}"
|
|
)
|
|
|
|
def test_multiple_breaks_chain(self):
|
|
"""Many breaks (like a deep transformer) must chain correctly."""
|
|
depth = 6
|
|
ws = [
|
|
torch.randn(self.d, self.d, device=self.dev, dtype=self.dtype)
|
|
for _ in range(depth + 1)
|
|
]
|
|
|
|
def eager(x):
|
|
h = x @ ws[0]
|
|
for i in range(depth):
|
|
h = torch.relu(h) # the "break" op
|
|
h = h @ ws[i + 1]
|
|
return h
|
|
|
|
cap = BreakableCapture()
|
|
|
|
def forward():
|
|
h = self.x_static @ ws[0]
|
|
for i in range(depth):
|
|
dst = torch.empty_like(h)
|
|
h = break_here(torch.relu, dst, h)
|
|
h = h @ ws[i + 1]
|
|
return h
|
|
|
|
for _ in range(3):
|
|
forward()
|
|
torch.cuda.synchronize()
|
|
with cap:
|
|
captured_out = forward()
|
|
|
|
# depth breaks => depth+1 graph segments + depth eager breaks.
|
|
self.assertEqual(cap.num_segments, 2 * depth + 1)
|
|
for trial in range(4):
|
|
new_x = torch.randn(self.n, self.d, device=self.dev, dtype=self.dtype)
|
|
self.x_static.copy_(new_x)
|
|
cap.replay()
|
|
torch.cuda.synchronize()
|
|
torch.testing.assert_close(captured_out, eager(new_x), msg=f"trial {trial}")
|
|
|
|
def test_nested_capture_rejected(self):
|
|
with BreakableCapture():
|
|
with self.assertRaises(RuntimeError):
|
|
with BreakableCapture():
|
|
pass
|
|
|
|
def test_scrub_padding_tail(self):
|
|
# Zeros [num_real:] in place across tensors; skips None; no-op when unpadded.
|
|
t1 = torch.ones(6, 3, device=self.dev, dtype=self.dtype)
|
|
t2 = torch.ones(6, device=self.dev, dtype=self.dtype)
|
|
scrub_padding_tail(4, t1, None, t2)
|
|
self.assertTrue(bool((t1[:4] == 1).all()) and bool((t1[4:] == 0).all()))
|
|
self.assertTrue(bool((t2[:4] == 1).all()) and bool((t2[4:] == 0).all()))
|
|
# Unpadded (count == rows): untouched.
|
|
t3 = torch.ones(4, 3, device=self.dev, dtype=self.dtype)
|
|
scrub_padding_tail(4, t3)
|
|
self.assertTrue(bool((t3 == 1).all()))
|
|
|
|
def test_slice_to_real_tokens(self):
|
|
"""Leading [:num_real] per tensor in order; None and unpadded pass through."""
|
|
a = torch.arange(6, device=self.dev)
|
|
b = torch.arange(6, device=self.dev).view(6, 1)
|
|
c = torch.arange(4, device=self.dev) # already real length
|
|
ra, rn, rb, rc = slice_to_real_tokens(4, a, None, b, c)
|
|
self.assertEqual(ra.shape[0], 4)
|
|
self.assertEqual(rb.shape[0], 4)
|
|
self.assertIsNone(rn)
|
|
self.assertIs(rc, c) # no-op: not padded
|
|
torch.testing.assert_close(ra, torch.arange(4, device=self.dev))
|
|
|
|
|
|
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
|
|
class TestBucketedCapture(unittest.TestCase):
|
|
"""Per-token-bucket lazy capture with input padding (what PrefillGraph does).
|
|
|
|
A token-shaped inner forward whose per-layer "attention" runs as an eager
|
|
break is captured once per padded token bucket and replayed for any real token
|
|
count <= bucket. Mirrors the runner's mechanics directly on BreakableCapture so
|
|
the bucketing + padding + replay-parity invariant is unit-tested in isolation.
|
|
"""
|
|
|
|
def setUp(self):
|
|
torch.manual_seed(1)
|
|
self.dev, self.dtype = "cuda", torch.float32
|
|
self.d, self.depth = 16, 3
|
|
self.buckets = [4, 8, 16]
|
|
self.ws = [
|
|
torch.randn(self.d, self.d, device=self.dev, dtype=self.dtype)
|
|
for _ in range(self.depth + 1)
|
|
]
|
|
# Persistent static input buffer (max bucket); graph reads this address.
|
|
self.x_static = torch.zeros(
|
|
max(self.buckets), self.d, device=self.dev, dtype=self.dtype
|
|
)
|
|
self._captures: dict = {}
|
|
self._outputs: dict = {}
|
|
self._pool = None
|
|
|
|
def _inner(self, n):
|
|
"""Inner forward over the leading ``n`` rows of the static buffer."""
|
|
h = self.x_static[:n] @ self.ws[0]
|
|
for i in range(self.depth):
|
|
dst = torch.empty_like(h)
|
|
h = break_here(torch.relu, dst, h) # per-token "attention" break
|
|
h = h @ self.ws[i + 1]
|
|
return h
|
|
|
|
def _eager(self, x):
|
|
h = x @ self.ws[0]
|
|
for i in range(self.depth):
|
|
h = torch.relu(h)
|
|
h = h @ self.ws[i + 1]
|
|
return h
|
|
|
|
def _run_bucketed(self, n):
|
|
"""Pad ``n`` up to a bucket, lazily capture/replay, return output[:n]."""
|
|
idx = next(i for i, b in enumerate(self.buckets) if b >= n)
|
|
bucket = self.buckets[idx]
|
|
cap = self._captures.get(bucket)
|
|
if cap is None:
|
|
for _ in range(2): # warmup
|
|
self._inner(bucket)
|
|
torch.cuda.synchronize()
|
|
cap = BreakableCapture(pool=self._pool)
|
|
with cap:
|
|
out = self._inner(bucket)
|
|
self._pool = self._pool or cap.pool
|
|
cap.replay() # capture doesn't execute; populate `out`
|
|
self._captures[bucket], self._outputs[bucket] = cap, out
|
|
else:
|
|
cap.replay()
|
|
return self._outputs[bucket][:n], bucket
|
|
|
|
def test_replay_matches_eager_across_buckets(self):
|
|
captured = set()
|
|
for n in [3, 4, 5, 8, 11, 16, 1]:
|
|
new_x = torch.randn(n, self.d, device=self.dev, dtype=self.dtype)
|
|
self.x_static.zero_() # scrub the padded tail
|
|
self.x_static[:n].copy_(new_x)
|
|
out, bucket = self._run_bucketed(n)
|
|
captured.add(bucket)
|
|
torch.cuda.synchronize()
|
|
torch.testing.assert_close(
|
|
out, self._eager(new_x), msg=f"n={n}", rtol=1e-4, atol=1e-4
|
|
)
|
|
# First sighting of each distinct bucket captured once; later n replay.
|
|
self.assertEqual(set(self._captures), {4, 8, 16})
|
|
|
|
|
|
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
|
|
class TestBreakPointAndAmbientCtx(unittest.TestCase):
|
|
"""The ``@break_point`` decorator + ambient live-context rebind.
|
|
|
|
Exercises the two properties the model refactor relies on: (1) a decorated
|
|
method runs as an eager break under capture / a direct call otherwise, with
|
|
its output buffer sized from the named ``out`` arg; (2) the ForwardContext a
|
|
break reads is rebound to the LIVE ambient context at replay, so a graph
|
|
captured with a dummy ctx replays correctly against a different live ctx.
|
|
"""
|
|
|
|
def setUp(self):
|
|
torch.manual_seed(2)
|
|
self.dev, self.dtype = "cuda", torch.float32
|
|
self.d = 16
|
|
self.w0 = torch.randn(self.d, self.d, device=self.dev, dtype=self.dtype)
|
|
self.w1 = torch.randn(self.d, self.d, device=self.dev, dtype=self.dtype)
|
|
self.x_static = torch.zeros(8, self.d, device=self.dev, dtype=self.dtype)
|
|
|
|
def test_passthrough_runs_method_when_not_capturing(self):
|
|
"""Off the capture path @break_point always runs the method.
|
|
|
|
Including side effects and 0-row inputs -- the decorator never silently
|
|
skips a method; 0-row / idle handling is each model's own explicit
|
|
``if hidden_states.shape[0] == 0`` guard.
|
|
"""
|
|
calls = []
|
|
|
|
class M:
|
|
@break_point
|
|
def forward(self, x, ctx):
|
|
calls.append(x.shape[0])
|
|
return x * ctx.scale
|
|
|
|
m = M()
|
|
self.assertFalse(is_breakable_capture_active())
|
|
x = torch.randn(4, self.d, device=self.dev, dtype=self.dtype)
|
|
out = m.forward(x, SimpleNamespace(scale=3.0)) # direct call
|
|
torch.testing.assert_close(out, x * 3.0)
|
|
# 0 rows: the method still runs and its output (not the input) is returned.
|
|
empty = torch.zeros(0, self.d, device=self.dev, dtype=self.dtype)
|
|
out0 = m.forward(empty, SimpleNamespace(scale=9.0))
|
|
torch.testing.assert_close(out0, empty * 9.0)
|
|
self.assertEqual(calls, [4, 0]) # method ran for both, including 0 rows
|
|
|
|
def test_ambient_ctx_rebinds_at_replay(self):
|
|
class M:
|
|
def __init__(self, w):
|
|
self.w = w
|
|
|
|
@break_point
|
|
def forward(self, x, ctx):
|
|
# Reads the (live) ctx; output [tokens, d] matches arg ``x``.
|
|
return torch.relu(x @ self.w) * ctx.scale
|
|
|
|
m = M(self.w1)
|
|
dummy = SimpleNamespace(scale=2.0) # capture-time ctx
|
|
live = SimpleNamespace(scale=5.0) # replay-time ctx
|
|
|
|
def outer():
|
|
h = self.x_static @ self.w0 # captured graph segment
|
|
return m.forward(h, dummy) # eager break reading the ambient ctx
|
|
|
|
for _ in range(3): # warmup
|
|
with active_forward(dummy):
|
|
outer()
|
|
torch.cuda.synchronize()
|
|
cap = BreakableCapture()
|
|
with active_forward(dummy):
|
|
with cap:
|
|
captured = outer()
|
|
# 1 break => 2 graph segments + 1 eager break.
|
|
self.assertEqual(cap.num_segments, 3)
|
|
|
|
new_x = torch.randn(8, self.d, device=self.dev, dtype=self.dtype)
|
|
self.x_static.copy_(new_x)
|
|
with active_forward(live): # replay against a DIFFERENT live ctx
|
|
cap.replay()
|
|
torch.cuda.synchronize()
|
|
# The break must have used live.scale (5.0), not the captured dummy (2.0).
|
|
expected = torch.relu((new_x @ self.w0) @ self.w1) * 5.0
|
|
torch.testing.assert_close(captured, expected, rtol=1e-4, atol=1e-4)
|
|
|
|
def test_break_reads_live_scalar_off_ambient_not_frozen_arg(self):
|
|
"""Scalar args freeze at capture; live values must come off the ambient ctx.
|
|
|
|
Mirrors the hybrid attention-backend pattern: only the ambient ctx is
|
|
rebound at replay, so a break needing a live scalar (forward_mode, bs)
|
|
reads current_forward_ctx(), never its own frozen arg.
|
|
"""
|
|
seen = {}
|
|
|
|
class M:
|
|
@break_point
|
|
def forward(self, x, frozen_mode):
|
|
amb = current_forward_ctx()
|
|
seen["frozen"] = frozen_mode
|
|
seen["live"] = amb.mode
|
|
return x * amb.mult
|
|
|
|
m = M()
|
|
dummy = SimpleNamespace(mode="EXTEND", mult=1.0) # capture-time ctx
|
|
live = SimpleNamespace(mode="MIXED", mult=4.0) # replay-time ctx
|
|
|
|
def outer():
|
|
h = self.x_static @ self.w0
|
|
return m.forward(h, frozen_mode="EXTEND") # frozen scalar arg
|
|
|
|
for _ in range(3):
|
|
with active_forward(dummy):
|
|
outer()
|
|
torch.cuda.synchronize()
|
|
cap = BreakableCapture()
|
|
with active_forward(dummy):
|
|
with cap:
|
|
captured = outer()
|
|
|
|
new_x = torch.randn(8, self.d, device=self.dev, dtype=self.dtype)
|
|
self.x_static.copy_(new_x)
|
|
with active_forward(live): # replay against a DIFFERENT live ctx
|
|
cap.replay()
|
|
torch.cuda.synchronize()
|
|
# The positional/kw scalar arg stayed frozen at capture time...
|
|
self.assertEqual(seen["frozen"], "EXTEND")
|
|
# ...but the ambient read tracked the live ctx (mode + multiplier).
|
|
self.assertEqual(seen["live"], "MIXED")
|
|
torch.testing.assert_close(
|
|
captured, new_x @ self.w0 * 4.0, rtol=1e-4, atol=1e-4
|
|
)
|
|
|
|
def test_break_point_computed_out_shape(self):
|
|
"""A NARROW break whose output shape matches no input (deepseek_v3 MLA-like)."""
|
|
d2 = self.d // 2
|
|
wv = torch.randn(self.d, d2, device=self.dev, dtype=self.dtype)
|
|
|
|
class M:
|
|
@break_point # out-shape (d2 != input d) inferred from the actual output
|
|
def attn(self, x): # output last-dim d2 != input last-dim d
|
|
return torch.relu(x) @ wv
|
|
|
|
m = M()
|
|
|
|
def outer():
|
|
h = self.x_static @ self.w0 # captured segment
|
|
return m.attn(h) # narrow break, computed output shape
|
|
|
|
for _ in range(3):
|
|
outer()
|
|
torch.cuda.synchronize()
|
|
cap = BreakableCapture()
|
|
with cap:
|
|
captured = outer()
|
|
self.assertEqual(tuple(captured.shape), (self.x_static.size(0), d2))
|
|
|
|
new_x = torch.randn(8, self.d, device=self.dev, dtype=self.dtype)
|
|
self.x_static.copy_(new_x)
|
|
cap.replay()
|
|
torch.cuda.synchronize()
|
|
expected = torch.relu(new_x @ self.w0) @ wv
|
|
torch.testing.assert_close(captured, expected, rtol=1e-4, atol=1e-4)
|
|
|
|
def test_nested_break_inner_passes_through(self):
|
|
"""A broader @break_point overrides a nested one.
|
|
|
|
Capture is inactive while the outer break runs eagerly, so an inner
|
|
break called inside it passes straight through -- exactly one break.
|
|
"""
|
|
seen = {"inner_active": None}
|
|
|
|
class M:
|
|
@break_point
|
|
def inner(self, x): # would-be default backend break
|
|
seen["inner_active"] = is_breakable_capture_active()
|
|
return torch.relu(x)
|
|
|
|
@break_point
|
|
def outer(self, x): # broader override break
|
|
return self.inner(x) @ self_w1 # noqa: F821
|
|
|
|
self_w1 = self.w1
|
|
m = M()
|
|
|
|
def fwd():
|
|
h = self.x_static @ self.w0 # captured segment
|
|
return m.outer(h) # broad break; inner passes through
|
|
|
|
for _ in range(3):
|
|
fwd()
|
|
torch.cuda.synchronize()
|
|
cap = BreakableCapture()
|
|
with cap:
|
|
captured = fwd()
|
|
# Exactly one break (outer) => 2 graph segments + 1 break = 3.
|
|
self.assertEqual(cap.num_segments, 3)
|
|
self.assertIs(seen["inner_active"], False) # inner saw inactive capture
|
|
|
|
new_x = torch.randn(8, self.d, device=self.dev, dtype=self.dtype)
|
|
self.x_static.copy_(new_x)
|
|
cap.replay()
|
|
torch.cuda.synchronize()
|
|
expected = torch.relu(new_x @ self.w0) @ self.w1
|
|
torch.testing.assert_close(captured, expected, rtol=1e-4, atol=1e-4)
|
|
|
|
|
|
class TestPrefillTokenBuckets(unittest.TestCase):
|
|
"""Pure-function tests for the prefill-graph token-bucket schedule (no GPU)."""
|
|
|
|
@staticmethod
|
|
def _cfg(**overrides):
|
|
base = dict(
|
|
prefill_graph_max_tokens=2048,
|
|
disable_prefill_graph=False,
|
|
chunked_prefill_size=2048,
|
|
prefill_graph_capture_sizes=None,
|
|
)
|
|
base.update(overrides)
|
|
return SimpleNamespace(**base)
|
|
|
|
def test_disabled(self):
|
|
from tokenspeed.runtime.execution.prefill_graph import (
|
|
get_prefill_token_buckets,
|
|
)
|
|
|
|
self.assertEqual(
|
|
get_prefill_token_buckets(self._cfg(prefill_graph_max_tokens=0)), []
|
|
)
|
|
self.assertEqual(
|
|
get_prefill_token_buckets(self._cfg(disable_prefill_graph=True)), []
|
|
)
|
|
|
|
def test_clamped_to_chunk(self):
|
|
from tokenspeed.runtime.execution.prefill_graph import (
|
|
get_prefill_token_buckets,
|
|
)
|
|
|
|
# No bucket above the chunk (see get_prefill_token_buckets for why).
|
|
buckets = get_prefill_token_buckets(
|
|
self._cfg(prefill_graph_max_tokens=8192, chunked_prefill_size=2048)
|
|
)
|
|
self.assertEqual(buckets[-1], 2048)
|
|
|
|
def test_relative_ladder_properties(self):
|
|
from tokenspeed.runtime.execution.prefill_graph import (
|
|
get_prefill_token_buckets,
|
|
)
|
|
|
|
# Gaps bounded relatively (size/8) and absolutely (512); exact top; increasing.
|
|
for max_tokens in (8192, 2048, 1500):
|
|
buckets = get_prefill_token_buckets(
|
|
self._cfg(
|
|
prefill_graph_max_tokens=max_tokens,
|
|
chunked_prefill_size=max_tokens,
|
|
)
|
|
)
|
|
self.assertEqual(buckets[-1], max_tokens)
|
|
self.assertEqual(buckets, sorted(set(buckets)))
|
|
gaps = [b2 - b1 for b1, b2 in zip(buckets, buckets[1:])]
|
|
for b1, g in zip(buckets, gaps):
|
|
self.assertLessEqual(g, 512, f"cap violated at {b1}")
|
|
if b1 >= 256:
|
|
self.assertLessEqual(g, max(b1 // 8, 16), f"relative bound at {b1}")
|
|
|
|
def test_explicit_capture_sizes(self):
|
|
from tokenspeed.runtime.execution.prefill_graph import (
|
|
get_prefill_token_buckets,
|
|
)
|
|
|
|
# Explicit list overrides the ladder; clamped to max_tokens (always included).
|
|
buckets = get_prefill_token_buckets(
|
|
self._cfg(prefill_graph_capture_sizes=[256, 1024, 4096])
|
|
)
|
|
self.assertEqual(buckets, [256, 1024, 2048])
|
|
|
|
|
|
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
|
|
class TestWeakRefTensor(unittest.TestCase):
|
|
"""The non-owning-view op behind break-closure weak refs."""
|
|
|
|
def test_alias_without_ownership(self):
|
|
from tokenspeed.runtime.execution.breakable_cuda_graph import weak_ref_tensor
|
|
|
|
x = torch.arange(12, device="cuda", dtype=torch.float32).view(3, 4)[:, 1:]
|
|
w = weak_ref_tensor(x)
|
|
if w is x: # identity fallback (no C++ toolchain) -- still correct
|
|
self.skipTest("weak_ref extension unavailable; identity fallback")
|
|
# Aliases the same memory (incl. strides), sees writes, owns nothing.
|
|
self.assertEqual(w.data_ptr(), x.data_ptr())
|
|
self.assertEqual(w.stride(), x.stride())
|
|
x.fill_(3.0)
|
|
torch.cuda.synchronize()
|
|
self.assertTrue(bool((w == 3.0).all()))
|
|
# Non-tensor / CPU passthrough.
|
|
self.assertIsNone(weak_ref_tensor(None))
|
|
cpu = torch.ones(2)
|
|
self.assertIs(weak_ref_tensor(cpu), cpu)
|
|
|
|
|
|
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
|
|
class TestPoolReuseAcrossCaptures(unittest.TestCase):
|
|
"""Graph-pool memory must NOT grow per capture (the dense-ladder enabler).
|
|
|
|
Allocator pool blocks are stream-keyed, so all BreakableCaptures must share
|
|
one capture stream (class-level default) -- a fresh stream per capture makes
|
|
pool memory grow with the SUM of bucket sizes instead of the max.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _pool_mb():
|
|
return (
|
|
sum(
|
|
s["total_size"]
|
|
for s in torch.cuda.memory_snapshot()
|
|
if s.get("segment_pool_id", (0, 0)) != (0, 0)
|
|
)
|
|
/ 2**20
|
|
)
|
|
|
|
def test_same_and_smaller_captures_reuse_pool(self):
|
|
d, d2, depth = 512, 2048, 4
|
|
w1 = torch.randn(d, d2, device="cuda")
|
|
w2 = torch.randn(d2, d, device="cuda")
|
|
xbuf = torch.zeros(4096, d, device="cuda")
|
|
|
|
def fwd(n):
|
|
x = xbuf[:n]
|
|
for _ in range(depth):
|
|
h = x @ w1
|
|
dst = torch.empty_like(h)
|
|
h = break_here(torch.relu, dst, h)
|
|
x = h @ w2
|
|
return x
|
|
|
|
pool = torch.cuda.graph_pool_handle()
|
|
caps = []
|
|
deltas = []
|
|
for n in (4096, 2048, 4096):
|
|
for _ in range(2):
|
|
fwd(n)
|
|
torch.cuda.synchronize()
|
|
before = self._pool_mb()
|
|
cap = BreakableCapture(pool=pool) # shared default capture stream
|
|
with cap:
|
|
out = fwd(n)
|
|
cap.replay()
|
|
torch.cuda.synchronize()
|
|
caps.append((cap, out))
|
|
deltas.append(self._pool_mb() - before)
|
|
# First capture claims ~peak-live; later ones must reuse it (small allowance).
|
|
self.assertLess(deltas[1], max(2.0, deltas[0] * 0.1), f"deltas={deltas}")
|
|
self.assertLess(deltas[2], max(2.0, deltas[0] * 0.1), f"deltas={deltas}")
|
|
# Replays still valid after cross-capture reuse.
|
|
for cap, _ in caps:
|
|
cap.replay()
|
|
torch.cuda.synchronize()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|