Skip to content

[Discussion] CUDA graphs for the decode path: staged plan + full shim audit #534

Description

@riccardo-galbani

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

  1. 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%)?
  2. Would you take Stage 0 as a standalone PR? It's behavior-invariant and
    reviewable in isolation.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions