In the recent video you mentioned CUDA graphs as one of the directions worth
pursuing after the GB10 decode work, since the CUDA backend currently launches
every kernel individually. I'd like to work on this and, given the impact
radius, I'm opening a discussion before writing code, with a staged plan and a
complete audit of the decode path. Line numbers below refer to main @ 80ebbc3.
Why the seam already exists
The architecture is already designed for batched submission:
ds4_gpu_begin_commands / ds4_gpu_end_commands. On Metal, begin creates a
command buffer and end commits it — one submission per token. On CUDA, begin
is a no-op stub and end is cudaDeviceSynchronize() (ds4_cuda.cu:2479). This
asymmetry is a large part of the Metal-vs-CUDA overhead gap: a decode token is
~350 individual kernel launches at ~5-10µs each, plus the idle gaps between
them.
Because the Metal programming model forbids host readbacks inside a command
buffer, the token body (metal_graph_encode_token_raw_swa) is by construction
free of mid-token host decisions, and MoE expert selection already flows
through a device buffer (selected tensor in ds4_gpu_routed_moe_one_tensor).
Both are exactly the invariants CUDA graph capture needs — the design work is
mostly already done.
Prior art: the Entrpi fork (decode-perf-tuning branch) implemented per-layer
graph capture and measured +5-10% decode on GB10, growing with context, with
bit-exact output vs eager. I've studied that implementation in depth and the
plan below builds on its validated patterns (and its documented failure modes).
Staged plan
Every stage is env-gated, falls back to eager, and must be bit-exact
(md5 of output, graphs ON vs OFF) before moving on. Flag off = byte-identical
behavior to today.
- Stage 0 — infrastructure, zero behavior change. A thread-local stream
override (ds4_current_stream() / ds4_capture_set_stream()) so that all
~100 ds4_gpu_* shims can be redirected to a capture stream without
touching their ABI. Small, self-contained; I'd propose this as the first PR.
- Stage 1 — dense-FFN layer cluster. First layers have no router: minimal
capture, validates the mechanics (warm-up before capture since cudaMalloc is
forbidden inside one, sync brackets, replay, cache keyed on stable bits).
- Stage 2 — MoE cluster. Adds nothing conceptually: expert selection is
already device-side.
- Stage 3 — whole layer, then whole token. begin/end_commands become
capture/replay with a graph cache — the CUDA implementation of what Metal
already does, with one structural difference: Metal cheaply re-encodes every
token, a graph replays a frozen recording, so all per-token scalars must
move from kernel arguments to a small device-side struct (~40B, memcpy'd
once per token) that kernels dereference at execution time.
Audit of the decode path (complete)
All shims reachable from metal_graph_eval_token_raw_swa (19461), including
static helpers (metal_graph_decode_kv_store, 13463):
- 23 green — no per-token arguments, capturable as-is: all matmuls, norms,
swiglu, residual adds, MoE dispatch, attention output.
- 11 red (scalar) — carry per-token scalars (
pos0, raw_row, n_raw,
raw_start, n_comp, emit counters, token id in embed and hash-mode
routing) that must migrate to the device-side struct.
- 1 red (pointer) —
dsv4_indexer_qat at 15230 receives a host-computed
view at offset index_row * row_bytes: the pointer changes every emit.
Needs a row-view variant (stable base + row index read device-side). The
same 8 lines also contain a host if (emit) branch and a host counter
increment — a compact example of everything replay must handle.
- 4 structural — the expert-streaming readback family
(signal/commit_and_wait_selected_readback,
stream_expert_cache_begin_selected_load, guarded by
overlap_selected_shared, 15780) performs a mid-token host readback and
cannot live inside a graph. Proposed scope cut: v1 graphs only when weights
are resident; streaming keeps the eager path. Note this doesn't affect the
primary target (GB10 + Flash q2 = resident).
Questions
- Is this a direction you want in the tree at all, and at which granularity
would you want it to stop (per-layer is where the measured gain lives;
whole-token is architecturally cleaner but marginal, ~1%)?
- Would you take Stage 0 as a standalone PR? It's behavior-invariant and
reviewable in isolation.
- Preference on reusing/adapting the Entrpi implementation (with credit)
vs. a minimal reimplementation shaped on ds4's simplicity philosophy?
Happy to adjust scope. Development starts on an RTX 4070 (8GB); a GB10 arrives
this week for the remaining stages and the final numbers.
In the recent video you mentioned CUDA graphs as one of the directions worth
pursuing after the GB10 decode work, since the CUDA backend currently launches
every kernel individually. I'd like to work on this and, given the impact
radius, I'm opening a discussion before writing code, with a staged plan and a
complete audit of the decode path. Line numbers below refer to main @ 80ebbc3.
Why the seam already exists
The architecture is already designed for batched submission:
ds4_gpu_begin_commands/ds4_gpu_end_commands. On Metal, begin creates acommand buffer and end commits it — one submission per token. On CUDA, begin
is a no-op stub and end is
cudaDeviceSynchronize()(ds4_cuda.cu:2479). Thisasymmetry is a large part of the Metal-vs-CUDA overhead gap: a decode token is
~350 individual kernel launches at ~5-10µs each, plus the idle gaps between
them.
Because the Metal programming model forbids host readbacks inside a command
buffer, the token body (
metal_graph_encode_token_raw_swa) is by constructionfree of mid-token host decisions, and MoE expert selection already flows
through a device buffer (
selectedtensor inds4_gpu_routed_moe_one_tensor).Both are exactly the invariants CUDA graph capture needs — the design work is
mostly already done.
Prior art: the Entrpi fork (
decode-perf-tuningbranch) implemented per-layergraph capture and measured +5-10% decode on GB10, growing with context, with
bit-exact output vs eager. I've studied that implementation in depth and the
plan below builds on its validated patterns (and its documented failure modes).
Staged plan
Every stage is env-gated, falls back to eager, and must be bit-exact
(md5 of output, graphs ON vs OFF) before moving on. Flag off = byte-identical
behavior to today.
override (
ds4_current_stream()/ds4_capture_set_stream()) so that all~100
ds4_gpu_*shims can be redirected to a capture stream withouttouching their ABI. Small, self-contained; I'd propose this as the first PR.
capture, validates the mechanics (warm-up before capture since cudaMalloc is
forbidden inside one, sync brackets, replay, cache keyed on stable bits).
already device-side.
capture/replay with a graph cache — the CUDA implementation of what Metal
already does, with one structural difference: Metal cheaply re-encodes every
token, a graph replays a frozen recording, so all per-token scalars must
move from kernel arguments to a small device-side struct (~40B, memcpy'd
once per token) that kernels dereference at execution time.
Audit of the decode path (complete)
All shims reachable from
metal_graph_eval_token_raw_swa(19461), includingstatic helpers (
metal_graph_decode_kv_store, 13463):swiglu, residual adds, MoE dispatch, attention output.
pos0,raw_row,n_raw,raw_start,n_comp, emit counters, token id in embed and hash-moderouting) that must migrate to the device-side struct.
dsv4_indexer_qatat 15230 receives a host-computedview at offset
index_row * row_bytes: the pointer changes every emit.Needs a row-view variant (stable base + row index read device-side). The
same 8 lines also contain a host
if (emit)branch and a host counterincrement — a compact example of everything replay must handle.
(
signal/commit_and_wait_selected_readback,stream_expert_cache_begin_selected_load, guarded byoverlap_selected_shared, 15780) performs a mid-token host readback andcannot live inside a graph. Proposed scope cut: v1 graphs only when weights
are resident; streaming keeps the eager path. Note this doesn't affect the
primary target (GB10 + Flash q2 = resident).
Questions
would you want it to stop (per-layer is where the measured gain lives;
whole-token is architecturally cleaner but marginal, ~1%)?
reviewable in isolation.
vs. a minimal reimplementation shaped on ds4's simplicity philosophy?
Happy to adjust scope. Development starts on an RTX 4070 (8GB); a GB10 arrives
this week for the remaining stages and the final numbers.