Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

453 lines
20 KiB
Python

# Copyright (c) 2026 LightSeek Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Breakable CUDA graph capture for variable-shape (prefill / extend) forwards.
A *breakable* CUDA graph captures a forward as an ordered list of zero-arg
callables -- each is either a captured ``CUDAGraph.replay`` (a "graph segment")
or an eager Python function (a "break"). At designated break points (attention /
KV-cache ops, whose metadata is data-dependent and cannot be captured) the
current stream capture is ended, the op runs eagerly, and a fresh segment begins
capturing the remainder. Replay simply calls each segment in order.
This is the ``torch.compile``-free alternative to piecewise CUDA graphs. The
design is gratefully adapted from vLLM and SGLang, who pioneered the breakable
prefill graph: vLLM's ``BreakableCUDAGraphWrapper`` (the homogeneous segment-list
structure + the ``set_forward_context``/``get_forward_context`` ambient pattern we
mirror in :func:`active_forward`/:func:`current_forward_ctx`) and SGLang's
breakable prefill graph (the eager-copy output handoff at each break). Unlike a
full prefill graph, attention -- the only batch/length-aware op and the source of
the host-side ``max_seq_len_q`` scalar -- stays eager, so it never enters a graph.
Keeping all KV-cache reads/writes in the eager breaks also makes them honor the
per-layer transfer consumer index naturally.
Address-stability contract (the load-bearing invariant):
* All segments share one CUDA mempool, so graph-allocated intermediates keep
stable device addresses across replays.
* The runner must copy live inputs into the *same* static input buffers used at
capture before calling :meth:`BreakableCapture.replay`.
* Break-point outputs must land at the *same* address each replay. We achieve
this by allocating a destination buffer in the captured segment (pool-pinned)
and copying the eager op's result into it; the next segment reads that address.
"""
from __future__ import annotations
import functools
import gc
from collections import deque
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any
import torch
from tokenspeed_kernel.ops.transform.weak_ref import weak_ref_tensor as _kernel_weak_ref
__all__ = [
"BreakableCapture",
"active_forward",
"break_here",
"break_point",
"current_forward_ctx",
"is_breakable_capture_active",
"scrub_padding_tail",
"slice_to_real_tokens",
"weak_ref_tensor",
]
# Ambient per-forward ctx; plain module state (one launch thread per rank).
_ambient_ctx: Any = None
@contextmanager
def active_forward(ctx: Any) -> Iterator[None]:
"""Publish ``ctx`` as the ambient forward context for the enclosed block.
An eager break runs once at capture and again on every replay, and the args
it closed over at capture are the *dummy* batch's, hence stale. Rather than
thread the live context through ``replay()`` (which would conflate graph
mechanics with forward semantics), the runner wraps capture and each replay
in this, and breaks rebind their captured context arg to the ambient one by
identity (see :func:`break_here`) -- so break bodies read live ``ctx``
fields exactly like the eager path. Re-entrant (saves/restores the
previous value).
"""
global _ambient_ctx
prev = _ambient_ctx
_ambient_ctx = ctx
try:
yield
finally:
_ambient_ctx = prev
def current_forward_ctx() -> Any:
"""The ambient forward context, or ``None`` outside an :func:`active_forward`."""
return _ambient_ctx
def weak_ref_tensor(t: Any) -> Any:
"""Reference a break-point tensor without pinning its cudagraph mempool slot.
CUDA tensors are wrapped in a non-owning view (``tokenspeed_kernel``
``ops.transform.weak_ref``, an ``at::from_blob`` alias -- the vLLM/sglang
approach), so break closures do not pin pool blocks and graph capture
memory stays ~peak-live instead of scaling with the bucket sum. Safe
because replay is stream-ordered: the captured segment rewrites the
aliased address before the break reads it. Non-tensors and CPU tensors
pass through; if the kernel extension is unavailable this degrades to
the identity (strong ref -- correct, more memory).
"""
if isinstance(t, torch.Tensor) and t.is_cuda:
return _kernel_weak_ref(t)
return t
class BreakableCapture:
"""Context manager that captures a breakable graph.
Usage::
cap = BreakableCapture(pool=shared_pool)
with cap:
model_forward(...) # attention calls hit break_here()
# later, after copying live inputs into the static buffers:
cap.replay()
Args:
pool: An optional CUDA mempool id (as returned by
``torch.cuda.graph_pool_handle()`` or ``CUDAGraph.pool()``) shared by
all segments. If ``None``, the first segment allocates a fresh pool
and the rest reuse it.
stream: An optional dedicated capture stream. CUDA forbids stream capture
on the default stream; if ``None``, a single class-level side stream is
(lazily) created and SHARED by all captures. Sharing one capture stream
is load-bearing for memory: the caching allocator's pool blocks are
stream-keyed, so captures on different streams can never reuse each
other's freed blocks -- a fresh stream per capture makes graph-pool
memory grow with the SUM of bucket sizes instead of the max (measured:
2478MB -> 564MB for buckets [8192,4096,2048,1024] on the repro). This
mirrors ``torch.cuda.graph``'s shared ``default_capture_stream`` and
its documented "pass the same stream for effective memory sharing".
"""
_active: BreakableCapture | None = None
_default_capture_stream: torch.cuda.Stream | None = None
def __init__(
self, pool: Any | None = None, stream: torch.cuda.Stream | None = None
) -> None:
self.pool = pool
self.segments: list[Callable[[], Any]] = []
self._current_graph: torch.cuda.CUDAGraph | None = None
self._capturing = False
if stream is None:
if BreakableCapture._default_capture_stream is None:
BreakableCapture._default_capture_stream = torch.cuda.Stream()
stream = BreakableCapture._default_capture_stream
self._stream = stream
self._stream_ctx: Any | None = None
# Break-output handoff buffers keyed by (shape, dtype, device); see break_point.
self._handoff: dict[Any, torch.Tensor] = {}
@classmethod
def current(cls) -> BreakableCapture | None:
return cls._active
# -- capture lifecycle -------------------------------------------------
def __enter__(self) -> BreakableCapture:
if BreakableCapture.current() is not None:
raise RuntimeError("Nested BreakableCapture is not supported.")
# A GC run during capture invalidates it: destructors of collected
# CUDA graphs call reset, which is illegal while a stream is capturing.
# Clear pending garbage, then keep automatic GC off for the whole
# capture window (restored in __exit__).
gc.collect()
self._gc_was_enabled = gc.isenabled()
gc.disable()
# The capture stream must observe prior entry-stream work (warmup, buffers).
self._stream.wait_stream(torch.cuda.current_stream())
self._stream_ctx = torch.cuda.stream(self._stream)
self._stream_ctx.__enter__()
BreakableCapture._active = self
self._begin_segment()
return self
def __exit__(self, *exc: object) -> bool:
try:
self._end_segment()
finally:
BreakableCapture._active = None
if self._stream_ctx is not None:
self._stream_ctx.__exit__(*exc)
self._stream_ctx = None
# Eager breaks ran on the side stream; entry stream must observe them.
torch.cuda.current_stream().wait_stream(self._stream)
if self._gc_was_enabled:
gc.enable()
return False
def _begin_segment(self) -> None:
assert not self._capturing
graph = torch.cuda.CUDAGraph()
if self.pool is not None:
graph.capture_begin(pool=self.pool)
else:
graph.capture_begin()
self._current_graph = graph
self._capturing = True
def _end_segment(self) -> None:
if not self._capturing:
return
assert self._current_graph is not None
self._current_graph.capture_end()
self.segments.append(self._current_graph.replay)
# All segments share one pool so intermediate addresses stay stable.
if self.pool is None:
self.pool = self._current_graph.pool()
self._current_graph = None
self._capturing = False
def add_eager(self, fn: Callable[[], Any]) -> Any:
"""End the current segment, run ``fn`` eagerly, record it, start a new one.
``fn`` is a zero-arg callable that performs the break-point op and writes
its result into a stable (pool-pinned) address. It is stored verbatim and
re-invoked on every :meth:`replay`.
"""
assert self._capturing, "add_eager called outside an active capture"
self._end_segment()
result = fn()
self.segments.append(fn)
self._begin_segment()
return result
# -- replay ------------------------------------------------------------
def replay(self) -> None:
"""Replay all segments in order.
Breaks read the live forward context from the ambient :func:`active_forward`
scope (the runner wraps replay in it), so this stays a pure graph primitive.
"""
deque((run() for run in self.segments), maxlen=0)
@property
def num_segments(self) -> int:
return len(self.segments)
def is_breakable_capture_active() -> bool:
"""True while a :class:`BreakableCapture` is open AND currently capturing."""
cap = BreakableCapture.current()
return cap is not None and cap._capturing
def _record_break(
cap: BreakableCapture,
fn: Callable[..., torch.Tensor],
resolve_dst: Callable[[torch.Tensor], torch.Tensor],
args: tuple,
kwargs: dict,
) -> torch.Tensor:
"""Record ``fn(*args, **kwargs)`` as an eager break on ``cap`` (the one closure
builder shared by :func:`break_here` and :func:`break_point`).
Args/kwargs are bound once at capture time, with two live exceptions: (1) tensor
args alias persistent storage (the static input buffers / pool-pinned segment
intermediates), so they carry live values at replay -- ``weak_ref_tensor`` is
the (currently identity) hook to avoid pinning their pool slots; (2) the
per-forward ``ForwardContext`` is rebound by identity to the live ambient
context each replay (see :func:`active_forward`), so ``fn`` may read live
``ctx`` fields exactly like the eager path. **Other (loose) non-tensor scalars
are frozen** to their capture-time value -- route per-request quantities
through ``ctx`` / ``forward_*_metadata`` rather than a bare scalar arg.
``resolve_dst(result)`` maps the break's first output to its stable handoff
buffer; it is called once (on the capture-time invocation) and the buffer is
reused verbatim on every replay, where the (possibly shorter, see
:func:`_land_in`) live result is copied into it.
"""
weak_args = tuple(weak_ref_tensor(a) for a in args)
weak_kwargs = {k: weak_ref_tensor(v) for k, v in kwargs.items()}
# Capture-time ambient ctx (the dummy batch's), rebound live at replay.
captured_ctx = current_forward_ctx()
state: dict[str, torch.Tensor] = {}
def replay_fn() -> torch.Tensor:
live_ctx = current_forward_ctx()
def sub(a: Any) -> Any:
return live_ctx if a is captured_ctx else a
result = fn(
*(sub(a) for a in weak_args),
**{k: sub(v) for k, v in weak_kwargs.items()},
)
dst = state.get("dst")
if dst is None:
dst = state["dst"] = resolve_dst(result)
_land_in(dst, result)
return dst
return cap.add_eager(replay_fn)
def break_here(
fn: Callable[..., torch.Tensor],
dst: torch.Tensor,
*args: Any,
**kwargs: Any,
) -> torch.Tensor:
"""Run ``fn(*args, **kwargs)`` as an eager break, landing its result in ``dst``.
The low-level explicit-destination primitive underneath :func:`break_point`
(which is the decorator every model actually uses -- prefer it; this exists
for callers that must control the handoff buffer's placement themselves, e.g.
a pool-pinned ``dst`` allocated in the current captured segment, and for
exercising the break mechanics directly in unit tests).
``dst`` must have a replay-stable address (pool-pinned or persistently owned).
At capture and on every replay, ``fn`` runs eagerly and its result is copied
into ``dst`` (unless ``fn`` already wrote ``dst`` in place and returned it);
the following graph segment reads ``dst``. Outside an active capture this is
a transparent pass-through. Argument binding/freezing semantics are those of
:func:`_record_break`.
Returns:
``dst`` (the stable handoff buffer).
"""
cap = BreakableCapture.current()
if cap is None or not cap._capturing:
_land_in(dst, fn(*args, **kwargs))
return dst
weak_dst = weak_ref_tensor(dst)
return _record_break(cap, fn, lambda _result: weak_dst, args, kwargs)
def break_point(method: Callable | None = None) -> Callable:
"""Mark a sequence-mixing method as an eager breakable-graph break point.
Decorate a sequence-mixing method (attention / MLA / linear-mixer / sparse
indexer ``forward``) and it runs as an eager break under a breakable capture --
the surrounding token-shaped compute (norms, MoE, projections, collectives) is
captured around it automatically, while everything inside the method stays
eager -- or a zero-overhead direct call when not capturing. This is the one
decorator every model uses to mark a break. Use it bare: ``@break_point``.
The handoff buffer's shape/dtype/device are **inferred from the method's actual
output** at capture time (the break runs during capture regardless), so no
output spec is needed -- it works uniformly for breaks whose output matches no
input (MLA: ``[tokens, heads*v_head_dim]`` vs ``q``'s ``[tokens, heads*qk_head_dim]``)
and for one wrapper that returns different shapes per call (e.g. hybrid full-attn
q-shaped vs GDN z-shaped). Buffers live in a per-capture shape-keyed cache
(:attr:`BreakableCapture._handoff`), shared across same-shape breaks. That
sharing relies on break outputs having strictly sequential lifetimes -- break
K's output is consumed by K's following segment before break K+1 runs (true
for transformer topology, where attention output feeds the adjacent
o-proj/residual). A model whose break output is read by a LATER segment must
not share its shape with an intervening break, or replay silently corrupts.
Inside the method ``ctx`` is live (rebound by identity at replay), so write the
body exactly like the eager path. Loose non-tensor scalar args are frozen to
their capture-time value -- route per-request quantities through ``ctx`` / metadata.
The decorator never skips the method: 0-row / idle batches remain each decorated
model method's own explicit guard, on the eager path and under capture alike.
"""
def decorator(method: Callable) -> Callable:
@functools.wraps(method)
def wrapper(*args: Any, **kwargs: Any) -> Any:
# Zero-overhead passthrough off the capture path (no 0-row skipping here).
if not is_breakable_capture_active():
return method(*args, **kwargs)
cap = BreakableCapture.current()
def resolve_dst(result: torch.Tensor) -> torch.Tensor:
# Handoff buffer inferred from the capture-time output, shape-keyed.
key = (tuple(result.shape), result.dtype, result.device)
dst = cap._handoff.get(key)
if dst is None:
dst = cap._handoff[key] = torch.empty(
result.shape, dtype=result.dtype, device=result.device
)
return dst
return _record_break(cap, method, resolve_dst, args, kwargs)
return wrapper
return decorator(method) if method is not None else decorator
# -- padded-input helpers (the two strategies of the prefill padding contract) --
def scrub_padding_tail(num_real_tokens: int, *tensors: torch.Tensor | None) -> None:
"""Zero the padded tail rows ``[num_real_tokens:]`` of token-shaped tensors in place.
Under a padded-bucket replay, an eager break receives ``bucket`` rows whose
tail holds garbage (it grows across layers and can overflow to NaN through
projections / FP8 quantize). Zeroing suits breaks whose kernel honors the
live cu-seqlens but whose surrounding ops (varlen attention read, recurrent
scan writeback, FP8 quantize) would otherwise touch the garbage rows. Pass
the real token count from the live metadata's CPU mirror (sync-free on the
launch thread); a no-op on unpadded forwards, and ``None`` tensors are
skipped.
"""
for t in tensors:
if t is not None and num_real_tokens < t.shape[0]:
t[num_real_tokens:].zero_()
def slice_to_real_tokens(num_real_tokens: int, *tensors: torch.Tensor | None):
"""Return ``tensors`` (in order) each sliced to the real leading rows ``[:num_real_tokens]``.
The slice-strategy counterpart to :func:`scrub_padding_tail`, for coarse breaks whose
kernel asserts the input row count equals the live metadata token count (e.g. DSA
sparse attention). A tensor already the right length (or ``None``) is returned
unchanged.
"""
return tuple(
t[:num_real_tokens] if (t is not None and num_real_tokens < t.shape[0]) else t
for t in tensors
)
def _land_in(dst: torch.Tensor, result: torch.Tensor) -> None:
"""Copy ``result`` into ``dst`` at a stable address.
``dst`` is the (possibly token-padded) handoff buffer the next graph segment
reads. ``result`` may cover only the real (unpadded) leading rows -- e.g. a
varlen attention kernel writes only ``sum(cu_seqlens_q)`` rows -- so we copy
into the matching leading slice. Padded rows are left as-is (discarded by the
final output slice). No-op when the op already wrote ``dst`` in place.
"""
if result is dst:
return
if result.shape == dst.shape:
dst.copy_(result)
else:
dst.narrow(0, 0, result.shape[0]).copy_(result)