[Memory] Borrow CUDA graph pool storage for EAGLE sampling (#35375)

Co-authored-by: cctry <cctry@fb.com>
This commit is contained in:
cctry
2026-08-18 16:19:41 -07:00
committed by GitHub
co-authored by cctry
parent 7ebaa98f81
commit 37c09ff3d8
11 changed files with 783 additions and 184 deletions
@@ -0,0 +1,306 @@
"""CUDA graph-pool borrowing allocator and lifetime regression tests."""
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import torch
from sglang.srt.environ import envs
from sglang.srt.model_executor.runner_backend.full_cuda_graph_backend import (
FullCudaGraphBackend,
)
from sglang.srt.model_executor.runner_utils import pool
from sglang.srt.speculative import eagle_utils
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.test_utils import CustomTestCase
register_cuda_ci(est_time=60, stage="base-b", runner_config="1-gpu-small")
class TestGraphPoolBorrow(CustomTestCase):
def setUp(self):
super().setUp()
self._reset_borrow_state()
def tearDown(self):
try:
if torch.cuda.is_available():
pool._teardown_borrow_pool()
torch.cuda.synchronize()
torch.cuda.empty_cache()
finally:
self._reset_borrow_state()
@staticmethod
def _reset_borrow_state():
pool._active_graph_pool_user = None
pool._borrow_stub = None
pool._borrow_mem_pool = None
pool._borrow_disabled_reason = None
pool._borrow_static_runs = None
pool._borrow_extents_total = 0
pool._largest_logged_graph_pool_borrow = 0
def test_graph_replay_fails_during_active_pool_borrow(self):
graph = Mock()
backend = object.__new__(FullCudaGraphBackend)
backend._graphs = {"shape": graph}
backend._outputs = {"shape": None}
snapshot = [
{
"allocated_size": 0,
"blocks": [{"state": "inactive", "address": 4096, "size": 4096}],
}
]
with (
envs.SGLANG_ENABLE_GRAPH_POOL_BORROW.override(True),
patch.object(pool, "get_global_graph_memory_pool", return_value=(1, 2)),
patch.object(
pool, "_borrow_stub", MagicMock(cursor_bytes=0, freed_bytes=0)
),
patch.object(pool, "_borrow_mem_pool", None),
patch.object(pool.torch.cuda, "MemPool"),
patch.object(pool.torch.cuda, "use_mem_pool"),
patch.object(pool.torch.cuda, "memory_snapshot", return_value=snapshot),
pool.borrow_graph_pool(user="test"),
):
with self.assertRaisesRegex(
RuntimeError, "graph pool already has live user"
):
backend.replay("shape", None)
graph.replay.assert_not_called()
def test_external_graph_storage_can_disable_borrowing(self):
with (
envs.SGLANG_ENABLE_GRAPH_POOL_BORROW.override(True),
patch.object(pool, "is_cuda", return_value=True),
patch.object(pool, "get_global_graph_memory_pool", return_value=(1, 2)),
):
self.assertTrue(pool.graph_pool_borrow_enabled())
pool.disable_graph_pool_borrow("graph storage is externally managed")
self.assertFalse(pool.graph_pool_borrow_enabled())
def test_eagle_non_greedy_probabilities_use_borrow_scope(self):
state = {"active": False, "users": []}
@contextmanager
def tracking_borrow(*, user):
self.assertFalse(state["active"])
state["active"] = True
state["users"].append(user)
try:
yield
finally:
state["active"] = False
import torch.nn.functional as F
real_softmax = F.softmax
def checked_softmax(*args, **kwargs):
self.assertTrue(state["active"])
return real_softmax(*args, **kwargs)
def fake_sampling(**kwargs):
self.assertTrue(state["active"])
kwargs["predicts"].fill_(3)
kwargs["accept_index"].fill_(0)
kwargs["accept_token_num"].fill_(1)
verify_input = SimpleNamespace(
draft_token_num=2,
draft_token=torch.tensor([1, 2], dtype=torch.int32),
max_tree_depth=2,
tree_topk=1,
retrieve_index=torch.zeros((1, 2), dtype=torch.int32),
retrieve_next_token=torch.zeros((1, 2), dtype=torch.int32),
retrieve_next_sibling=torch.zeros((1, 2), dtype=torch.int32),
draft_probs=None,
)
sampling_info = SimpleNamespace(
acc_additive_penalties=None,
acc_scaling_penalties=None,
logit_bias=None,
is_all_greedy=False,
temperatures=torch.ones((1, 1)),
need_top_k_sampling=False,
need_top_p_sampling=False,
sampling_seed=None,
)
batch = SimpleNamespace(
device="cpu",
seq_lens=torch.tensor([4], dtype=torch.int32),
sampling_info=sampling_info,
forward_mode=SimpleNamespace(is_idle=lambda: False),
)
logits_output = SimpleNamespace(next_token_logits=torch.randn((2, 8)))
spec_config = SimpleNamespace(
speculative_use_rejection_sampling=False,
speculative_accept_threshold_single=1.0,
speculative_accept_threshold_acc=1.0,
)
tp_group = SimpleNamespace(world_size=1)
with (
patch.object(eagle_utils, "borrow_graph_pool", tracking_borrow),
patch.object(eagle_utils, "get_spec", return_value=spec_config),
patch("torch.nn.functional.softmax", side_effect=checked_softmax),
patch(
"sglang.srt.layers.dp_attention.is_dp_attention_enabled",
return_value=False,
),
patch("sglang.srt.distributed.get_tp_group", return_value=tp_group),
patch(
"sgl_kernel.tree_speculative_sampling_target_only",
side_effect=fake_sampling,
),
):
predict, accept_lens, accept_index = eagle_utils.eagle_sample(
verify_input, batch, logits_output
)
self.assertEqual(state["users"], ["EAGLE probability borrow"])
self.assertFalse(state["active"])
self.assertTrue(torch.equal(predict, torch.full_like(predict, 3)))
self.assertTrue(torch.equal(accept_lens, torch.full_like(accept_lens, 2)))
self.assertTrue(torch.equal(accept_index, torch.zeros_like(accept_index)))
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
def test_borrowed_allocations_land_on_free_graph_pool_runs(self):
"""Borrowed blocks span free runs, recycle, and add no reservation."""
handle = torch.cuda.graph_pool_handle()
graph = torch.cuda.CUDAGraph()
x = torch.zeros(8, device="cuda")
stream = torch.cuda.Stream()
with torch.cuda.stream(stream), torch.cuda.graph(
graph, pool=handle, stream=stream
):
# Two capture-only transients become disjoint free graph-pool runs.
transient_a = torch.empty(48 << 20, dtype=torch.uint8, device="cuda")
transient_b = torch.empty(24 << 20, dtype=torch.uint8, device="cuda")
y = x + 1
del transient_a, transient_b
torch.cuda.synchronize()
device_id = torch.cuda.current_device()
reserved_before = torch.cuda.memory_reserved(device_id)
with (
envs.SGLANG_ENABLE_GRAPH_POOL_BORROW.override(True),
patch.object(pool, "get_global_graph_memory_pool", return_value=handle),
patch.object(pool, "_borrow_mem_pool", None),
):
runs = pool.find_free_graph_pool_runs(handle)
self.assertGreaterEqual(len(runs), 2)
largest_run_bytes = runs[0][1]
def on_a_run(tensor):
start = tensor.data_ptr()
end = start + tensor.nbytes
return any(
address <= start and end <= address + nbytes
for address, nbytes in runs
)
with pool.borrow_graph_pool(user="test"):
# Together these exceed the largest run, forcing first-fit to
# use more than one captured extent.
a = torch.empty(40 << 20, dtype=torch.uint8, device="cuda")
b = torch.empty(20 << 20, dtype=torch.uint8, device="cuda")
self.assertGreater(a.nbytes + b.nbytes, largest_run_bytes)
self.assertTrue(on_a_run(a) and on_a_run(b))
self.assertTrue(
a.data_ptr() + a.nbytes <= b.data_ptr()
or b.data_ptr() + b.nbytes <= a.data_ptr()
)
recycled_address = a.data_ptr()
del a
c = torch.empty(40 << 20, dtype=torch.uint8, device="cuda")
self.assertEqual(c.data_ptr(), recycled_address)
del b, c
# Captures retire the persistent borrow pool. Its storage aliases
# existing graph-pool runs, so the reserved footprint is unchanged.
pool._teardown_borrow_pool()
self.assertEqual(torch.cuda.memory_reserved(device_id), reserved_before)
del graph, y
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
def test_static_borrow_runs_serve_without_a_pool_snapshot(self):
"""Fixed extents serve borrows without consulting the shared pool."""
handle = torch.cuda.graph_pool_handle()
graph = torch.cuda.CUDAGraph()
x = torch.zeros(8, device="cuda")
stream = torch.cuda.Stream()
with torch.cuda.stream(stream), torch.cuda.graph(
graph, pool=handle, stream=stream
):
transient = torch.empty(64 << 20, dtype=torch.uint8, device="cuda")
y = x + 1
del transient
torch.cuda.synchronize()
runs = pool.find_free_graph_pool_runs(handle)
self.assertTrue(runs)
with (
envs.SGLANG_ENABLE_GRAPH_POOL_BORROW.override(True),
patch.object(pool, "get_global_graph_memory_pool", return_value=None),
patch.object(pool, "_borrow_static_runs", None),
patch.object(pool, "_borrow_mem_pool", None),
):
pool.set_graph_pool_borrow_runs(runs)
self.assertTrue(pool.graph_pool_borrow_enabled())
with pool.borrow_graph_pool(user="test"):
borrowed = torch.empty(16 << 20, dtype=torch.uint8, device="cuda")
self.assertTrue(
any(
address <= borrowed.data_ptr() < address + nbytes
for address, nbytes in runs
)
)
del borrowed
del graph, y
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
def test_cross_stream_borrow_frees_resolve_before_pointer_reuse(self):
"""Deferred record_stream frees must not collide on the next borrow."""
handle = torch.cuda.graph_pool_handle()
graph = torch.cuda.CUDAGraph()
x = torch.zeros(8, device="cuda")
stream = torch.cuda.Stream()
with torch.cuda.stream(stream), torch.cuda.graph(
graph, pool=handle, stream=stream
):
transient = torch.empty(128 << 20, dtype=torch.uint8, device="cuda")
y = x + 1
del transient
torch.cuda.synchronize()
side = torch.cuda.Stream()
with (
envs.SGLANG_ENABLE_GRAPH_POOL_BORROW.override(True),
patch.object(pool, "get_global_graph_memory_pool", return_value=handle),
patch.object(pool, "_borrow_mem_pool", None),
):
for _ in range(3):
with pool.borrow_graph_pool(user="test"):
borrowed = torch.empty(16 << 20, dtype=torch.uint8, device="cuda")
with torch.cuda.stream(side):
widened = borrowed.to(torch.int32)
borrowed.record_stream(side)
del borrowed, widened
# Regression: this used to fail with "Trying to free a pointer not
# allocated here" after a deferred free was re-issued too early.
torch.cuda.empty_cache()
del graph, y
if __name__ == "__main__":
unittest.main()