[CuTeDSL] Add prepared-launch API for low-overhead kernel replay - #3419
Open
zkyue wants to merge 2 commits into
Open
[CuTeDSL] Add prepared-launch API for low-overhead kernel replay#3419zkyue wants to merge 2 commits into
zkyue wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Steady-state launches of an already-compiled function currently re-marshal
every argument on every call (
ExecutionArgs.generate_execution_argspluspacked
void**regeneration; wrappers typically also rebuild the argumentobjects 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.launchis 3.8 us/launch (table below).Design
JitCompiledFunction.prepare(*args, **kwargs) -> PreparedLaunchvalidateseligibility 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.preparethrough the extracted_ensure_default_executor()(same lock/weakref-proxy pattern as before), socompiled.to(device).prepare(...)is the multi-device variant — the APImirrors the existing
to()/__call__split exactly.CudaDialectJitCompiledFunctioninheritsprepare();cuda_jit_executor.pyneeds zero changes.
PreparedLaunch.launch(*args, **kwargs) -> int | Nonehas the samesignature 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); fullpositional 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
Pointerobjects or raw integer addresses;scalar slots accept Python scalars or
Numericinstances (written withplain 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_streamattribute (torch.cuda.Stream).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-addressslots; the marshalling contract is verified against the pointer's
_descstorage, 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— aDSLUserCodeErrorcarried bysix new
LAUNCH_*entries in the DiagId catalog, so errors render likeevery other DSL diagnostic. The normal call path remains for rejected
mixes.
(memmoved from the storages
generate_execution_argsjust produced), andlaunch-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).
void**array andCUDA-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 orautotuned 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
__call__'s hot path; the only change to existing code isextracting
_ensure_default_executor()fromrun_compiled_program(verbatim, same locking).
cuda_jit_executor.py.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_counteraround the loop, cuda-event = events on the stream around theloop (agreement to 0.01 us confirms the host is the bottleneck). Numbers vary
~±0.3 us run to run on a shared host:
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 bareentry-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 theexisting suite; device behavior is exercised on a real sm_100 kernel, and
eligibility rejections also use host-only signatures):
defaulted parameters omitted at prepare and launch, framework streams at
launch; Float64/Int64/Boolean width coverage with bitwise checks;
generate_execution_args(monkeypatch guard) and reuse the packed array in place;
gc + allocator churn;
__c_pointers__, pointer-contract lookalikes, wrong-kind values fordeclared 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;
pointer/scalar/stream/boolean values (naming the argument), launch after
jit_module.unload();handoff;
to(device).prepare(...);(direction-only, load-tolerant).
28 tests; full
test/python/CuTeDSL/suite: 34 passed (includes thepre-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
mechanism extends naturally (Float16/BFloat16 bit-pattern writers,
multi-slot dlpack descriptors) if there is interest.
semantics with typed errors on rejected values (out-of-range integers
truncate to the declared width, like the existing fast path; wrong-kind
scalars, non-stream stream values, and non-bool Boolean values raise
PreparedLaunchError). Full_full_arg_checkvalidation still runs atprepare via the normal marshalling; kind-level validation is done once at
prepare, annotation-driven.
__c_pointers__allocations — this PR's replay path performs none), [QST] Best practice of managing various functions with CuTeDSL AOT + TVM-FFI in c++ #2852 (AOT/TVM-FFI
export — complementary deployment model).
(To run the tests in-tree via the editable flow on current wheels you'll also want #3417.)