Skip to content

[CuTeDSL] Add prepared-launch API for low-overhead kernel replay - #3419

Open
zkyue wants to merge 2 commits into
NVIDIA:mainfrom
zkyue:feat/prepared-launch
Open

[CuTeDSL] Add prepared-launch API for low-overhead kernel replay#3419
zkyue wants to merge 2 commits into
NVIDIA:mainfrom
zkyue:feat/prepared-launch

Conversation

@zkyue

@zkyue zkyue commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #3418 ([FEA] CuTe DSL: prepared-launch API to eliminate
per-call host marshalling overhead).

What

A first-class "bind once, replay cheaply" handle on compiled CuTe DSL
functions:

compiled = cute.compile(fn, out_ptr, in_ptr, alpha, n, stream)
prepared = compiled.prepare(out_ptr, in_ptr, alpha, n, stream)   # validate + marshal once
for step in range(steps):
    prepared.launch(out.data_ptr(), x.data_ptr(), alpha, n, handle)  # in-place + direct call

Steady-state launches of an already-compiled function currently re-marshal
every argument on every call (ExecutionArgs.generate_execution_args plus
packed void** regeneration; wrappers typically also rebuild the argument
objects themselves each step) before a ~2.6 us C call. On a B200 with a
trivial 5-argument kernel that is 13.5-20.8 us/launch of host time;
prepared.launch is 3.8 us/launch (table below).

Design

  • JitCompiledFunction.prepare(*args, **kwargs) -> PreparedLaunch validates
    eligibility once, runs the normal marshalling exactly once, and snapshots
    the marshalled bytes into private per-slot ctypes storage owned by the
    returned handle. It delegates to JitExecutor.prepare through the extracted
    _ensure_default_executor() (same lock/weakref-proxy pattern as before), so
    compiled.to(device).prepare(...) is the multi-device variant — the API
    mirrors the existing to() / __call__ split exactly.
    CudaDialectJitCompiledFunction inherits prepare(); cuda_jit_executor.py
    needs zero changes.
  • PreparedLaunch.launch(*args, **kwargs) -> int | None has the same
    signature and return contract as calling the compiled function: keyword
    arguments and omitted defaulted parameters rectify through the existing
    get_rectified_args (raising the shared binding diagnostics); full
    positional calls are the fast path. It rewrites per-argument slots in place
    (no allocation, no repacking, no ExecutionArgs regeneration) and invokes
    the compiled entry point directly, checking a private CUDA-result slot.
    Pointer slots accept runtime Pointer objects or raw integer addresses;
    scalar slots accept Python scalars or Numeric instances (written with
    plain ctypes assignment semantics — a value the storage rejects, such as a
    float for an integer slot, raises a typed error instead of applying the
    implicit DSL cast a direct call would); stream slots accept CUDA stream
    objects, raw integer handles, or framework streams exposing an integer
    cuda_stream attribute (torch.cuda.Stream).
  • Eligibility (validated at prepare, not at launch) is annotation-driven
    where the declaration is informative — a declared pointer, stream, or
    numeric slot must be prepared with a value of that kind — and value-driven
    for unannotated slots: runtime pointers (make_ptr / pointer-address
    slots; the marshalling contract is verified against the pointer's _desc
    storage, so contract lookalikes are rejected), numeric scalars with plain
    ctypes storage (Boolean, Int8-64, Uint8-64, Float32/64, TFloat32, plain
    Python bool/int/float), CUDA streams. Anything else (dlpack tensors,
    structs, sequences, custom JIT arguments, bit-cast scalar storage such as
    Float16, and TVM-FFI compiled functions, which dispatch through their own
    entry point) raises PreparedLaunchError — a DSLUserCodeError carried by
    six new LAUNCH_* entries in the DiagId catalog, so errors render like
    every other DSL diagnostic. The normal call path remains for rejected
    mixes.
  • Bitwise identity: the initial slot bytes are the slow path's bytes
    (memmoved from the storages generate_execution_args just produced), and
    launch-time writes produce the same bytes the slow path would marshal for
    the same values of the prepared kinds. Verified per-slot (packed payload
    byte comparison) and end-to-end (bitwise output comparison in the tests).
  • Ownership and lifecycle: each instance owns its packed void** array and
    CUDA-result slot (the executor's are shared/thread-local), so instances
    never race each other or the normal call path. The slow path's scalar
    ctypes storage is ephemeral, so the handle snapshots into cells it owns.
    Launching after jit_module.unload() raises cleanly; a recompiled or
    autotuned function needs a new handle. One instance per thread is the
    documented model (instances share no mutable state; sequential cross-thread
    handoff works). CUDA-graph capture through a capturing stream works with
    standard frozen-at-capture semantics.

What this PR does NOT change

  • No changes to __call__'s hot path; the only change to existing code is
    extracting _ensure_default_executor() from run_compiled_program
    (verbatim, same locking).
  • No changes to cuda_jit_executor.py.
  • No behavior change for non-users; the feature is purely additive.

Performance

B200 (sm_100), trivial 4096-element f32 scale kernel (~2 us device time,
host-bound), 300 warmup + 5000 timed calls x 3 repeats, median; host wall =
perf_counter around the loop, cuda-event = events on the stream around the
loop (agreement to 0.01 us confirms the host is the bottleneck). Numbers vary
~±0.3 us run to run on a shared host:

path host wall (us/call) cuda-event (us/call)
direct call, reused arg objects 13.48 13.49
direct call, rebuilt arg objects (wrapper steady state) 20.76 20.76
prepared.launch, raw int addresses/handle 3.77 3.78
prepared.launch, rebuilt pointer objects 6.40 6.40

Slow-path host breakdown (isolated medians): ~5.9 us argument-object
construction (wrapper-side, applies to the rebuilt-args row), ~8.2 us
generate_execution_args, ~1.2 us packed-array population, ~2.6 us bare
entry-point call. In a production Megatron-Core integration the same
mechanism cut 30-220 us/launch wrapper steady state to ~3.6 us (externally
measured corroboration; see the issue).

Tests

test/python/CuTeDSL/test_prepared_launch.py (unittest style, matches the
existing suite; device behavior is exercised on a real sm_100 kernel, and
eligibility rejections also use host-only signatures):

  • bitwise output parity with the direct call (incl. prepare before any call);
  • pointer/scalar/stream mutation between launches, raw ints, kwargs,
    defaulted parameters omitted at prepare and launch, framework streams at
    launch; Float64/Int64/Boolean width coverage with bitwise checks;
  • fast-path contract: launches never re-enter generate_execution_args
    (monkeypatch guard) and reuse the packed array in place;
  • storage stability: 1000-launch read-modify-write accumulate under periodic
    gc + allocator churn;
  • eligibility rejections (dlpack, sequence, Float16, multi-slot custom
    __c_pointers__, pointer-contract lookalikes, wrong-kind values for
    declared pointer/stream slots, framework stream at prepare, extra tail
    args, missing args, TVM-FFI-style non-JitExecutor executors) and that the
    direct call keeps working for rejected mixes;
  • misuse: launch arg-count mismatches (shared binding diagnostics), bad
    pointer/scalar/stream/boolean values (naming the argument), launch after
    jit_module.unload();
  • threading: concurrent per-thread instances + sequential cross-thread
    handoff;
  • to(device).prepare(...);
  • CUDA-graph capture + replay and frozen-at-capture semantics both directions;
  • an in-test micro-benchmark asserting prepared is not slower than direct
    (direction-only, load-tolerant).

28 tests; full test/python/CuTeDSL/ suite: 34 passed (includes the
pre-existing test_struct_in_if.py), zero regressions.

A tutorial example (examples/python/CuTeDSL/dsl_tutorials/prepared_launch.py)
follows the by-passing-dlpack argument style of call_bypass_dlpack.py.

Notes for reviewers

(To run the tests in-tree via the editable flow on current wheels you'll also want #3417.)

zkyue added 2 commits July 29, 2026 09:18
Calling a compiled CuTe DSL function re-marshals every argument on every
call: ExecutionArgs.generate_execution_args re-runs signature
rectification, adapter lookups, and casts into freshly allocated ctypes
storage, and the packed void** array is regenerated — even when only
pointer addresses and scalar values change between calls (wrappers
typically also rebuild the argument objects themselves each step). For
small kernels launched from a Python loop (the steady state of most
training and inference wrappers) this host work dominates the launch
cost.

Add a first-class "bind once, replay cheaply" handle:

    compiled = cute.compile(fn, out_ptr, in_ptr, alpha, n, stream)
    prepared = compiled.prepare(out_ptr, in_ptr, alpha, n, stream)
    for step in range(steps):
        prepared.launch(out.data_ptr(), x.data_ptr(), alpha, n, handle)

prepare() validates eligibility up front, runs the normal marshalling
exactly once, and snapshots the marshalled bytes into private per-slot
ctypes storage owned by the returned PreparedLaunch (the slow path's
scalar storage is ephemeral, so the handle owns stable cells). launch()
takes the same runtime arguments as a normal call — keyword arguments
and omitted defaulted parameters rectify through the signature; full
positional calls are the fast path — rewrites the slot bytes in place,
and invokes the compiled entry point directly: no per-call object
construction, marshalling, or repacking. Launches are bitwise-identical
to the direct call for values of the prepared kinds.

Eligibility is annotation-driven where the declaration is informative
(numeric, pointer, and stream slots must be prepared with values of the
declared kind) and value-driven otherwise: runtime pointers (make_ptr /
pointer-address slots; the marshalling contract is verified against the
pointer's _desc storage), numeric scalars with plain ctypes storage
(Boolean, Int8-64, Uint8-64, Float32/64, TFloat32, plain Python
bool/int/float), and CUDA streams. Launch-time values may also be raw
integer addresses/handles or framework streams exposing an integer
cuda_stream attribute. Anything else — dlpack tensors, structs,
sequences, custom JIT arguments, scalars with bit-cast storage such as
Float16, and TVM-FFI compiled functions (which dispatch through their
own entry point) — raises PreparedLaunchError at prepare time with a
catalog diagnostic (six new LAUNCH_* DiagIds) and keeps using the
normal call path.

Each instance owns its packed argument array and CUDA-result slot, so
instances never race the executor's shared thread-local scratch or each
other's argument storage; the model is one instance per thread
(sequential cross-thread handoff works). The handle pins its module:
launching after jit_module.unload() raises cleanly, and a
recompiled/autotuned function needs a new handle. CUDA-graph capture
through a capturing stream works with standard frozen-at-capture
semantics.

The only change to existing code is extracting the default-executor
bootstrap from run_compiled_program into _ensure_default_executor()
(same lock/weakref-proxy pattern) so prepare() shares it; the __call__
hot path is untouched and cuda_jit_executor.py needs no changes
(CudaDialectJitCompiledFunction inherits prepare()).

On a B200, steady-state host cost for a trivial 5-argument kernel drops
from 13-21 us/call (direct call, depending on argument-object reuse) to
~3.8 us/call (prepared, raw addresses), with host-wall and CUDA-event
timings in agreement.

Signed-off-by: zky <kaiyue.zhou@z.ai>
Unit tests (test/python/CuTeDSL/test_prepared_launch.py, unittest style
matching the existing suite) covering the prepared-launch contract on a
real sm_100 device kernel (eligibility rejections also use host-only
signatures):

- bitwise output parity with the direct call, including prepare()
  before any direct call;
- argument mutation between launches: new pointers/scalars/stream,
  raw integer addresses and stream handles, Numeric instances,
  keyword-argument rectification, defaulted parameters omitted at both
  prepare and launch, and framework stream objects (torch.cuda.Stream)
  at launch;
- scalar width coverage (Float64/Int64/Boolean, flag flip between
  launches) checked bitwise against the direct call;
- fast-path contract: launches never re-enter ExecutionArgs
  marshalling (monkeypatch guard) and reuse the packed argument array
  in place;
- storage stability: a 1000-launch read-modify-write accumulate under
  periodic gc + allocator churn (guards against ephemeral-storage
  regressions);
- prepare-time eligibility rejections with catalog diagnostics: dlpack
  tensors, sequences, Float16 scalars, multi-slot custom __c_pointers__
  arguments, runtime-pointer-contract lookalikes that marshal storage
  other than their _desc, wrong-kind values for declared pointer/stream
  slots, framework streams at prepare, extra tail arguments, the shared
  missing-argument diagnostic, and non-JitExecutor executors (the
  TVM-FFI to()-returns-self shape); the direct call keeps working for
  rejected argument mixes;
- launch-time misuse: argument-count mismatches (shared binding
  diagnostics), bad pointer/scalar values (with the failing argument's
  name), non-stream values for stream slots, non-bool values for
  Boolean slots, launch after jit_module.unload();
- threading model: concurrent launches through per-thread instances,
  and sequential cross-thread handoff of a single instance;
- explicitly device-bound executors: to(device).prepare(...);
- CUDA graph capture through a capturing stream: capture + replay
  correctness and frozen-at-capture semantics in both directions;
- an in-test micro-benchmark asserting the prepared path is not slower
  than the direct call (prints host-wall and CUDA-event per-call
  times; direction-only, load-tolerant).

The tutorial example (examples/python/CuTeDSL/dsl_tutorials/
prepared_launch.py) follows the by-passing-dlpack argument style of
call_bypass_dlpack.py: compile once, prepare once, then replay with raw
addresses/values only, and compares steady-state host launch cost
against the direct call path.

Signed-off-by: zky <kaiyue.zhou@z.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEA] CuTe DSL: prepared-launch API to eliminate per-call host marshalling overhead

1 participant