First-class cudnn.Handle — design#
The backend cudnnHandle_t carries a device binding (compute capability, SM
count, …) and the current stream. On the FE side the handle is a bare int
(create_handle() returns reinterpret_cast<intptr_t>(handle)), so it has no
place to hang per-handle state. That state has instead accreted as side tables
and per-engine queries:
stream: the module-global
_handle_to_streamdict (PR #611);device: three parallel stacks — the backend handle; pygraph’s separate
sm_count/sm_version/device_propertyargs (deviceless AOT); and frost’s owncurrent_device()+ driver introspection (frost/device.py), which every python engine re-queries because nothing hands it the device.
This makes create_handle() return a first-class Handle object that owns
{backend_handle, device, stream}, collapsing those into one concept. The
naming anticipates the front end BEING “cudnn” and today’s cuDNN becoming “cudnn
backend”: this object is the handle; the wrapped cudnnHandle_t is the
backend_handle. It is optional by design — python engines (frost, cutedsl,
linear-attention) need device+stream, not a cudnnHandle_t, so the backend
becomes one consumer rather than the anchor.
Hard constraints (from a full call-site inventory)#
The C++ boundary needs no change; the handoff is EXPLICIT in Python. Every handle-consuming binding takes
std::intptr_t/std::optional. The backend handle is extracted explicitly in our Python code —to_backend_handle(h)at each handoff (_execute*,backend_graph, and the workspace / cuda-graph methods, whose signatures namehandlerather than a*argspassthrough).deserializeis the one genuinely ambiguous classic overload ((data)vs(handle, data, ...)), so it stays a passthrough and unwraps just its first positional. A reader can grepbackend_handleand trace the plumbing top-to-bottom without an IDE. A full inventory confirmed every handle→C++ handoff is in_pygraph/__init__(the__getattr__delegation carries no handle), so the set is closed.Handledeliberately has no__index__: the only path to the backend is those explicit calls, and a Handle that reaches a binding unconverted fails loudly instead of being silently coerced. C++ never reads device off the handle. The C++ handle ABI is unchanged; the only.cppchange is renaming theset_stream/destroy_handlebindings to_raw_*(python/properties.cpp) so the Python wrappers own those names.The
create_handlewrapper must be defined AFTER the__init__.pysymbol- copy loop, or the raw pybind symbol shadows it.Dunder minimalism.
Handledefines no int-coercing dunders and leaves__eq__/__hash__/__bool__at the object defaults (identity eq, identity hash, always-truthy). This satisfies all three Python pressure points at once:_handle_to_streamuses the handle as a dict key -> needs it hashable (identity hash is fine);wrapper.pycompares the stored handle against the string sentinel'auto'andNone-> a value-based__eq__that callsint(other)would raise on a str; identity__eq__returnsFalsecleanly;if handle:/handle or 0-> a live Handle must stay truthy. Giving Handle a value__eq__without__hash__would make it unhashable (TypeError on the stream dict) — so we give it neither.
The Python handle APIs take a
cudnn.Handleonly; a raw backend int is rejected.cudnn.create_handle()is the only way to make a handle in the Python API, so every real caller already holds a Handle (verified across flashinfer / sglang / the FE’s own code; torch uses the C++ frontend, not this module). A bare int would silently opt out of the Handle’s device/stream tracking and device-scoped build, soto_backend_handle/set_stream/get_stream/destroy_handle/execute(handle=)raise on a non-Handle. A framework holding a foreigncudnnHandle_twraps it once —cudnn.Handle(backend_handle, ordinal, stream)— so it becomes first-class (gaining the same device/stream/scoping) rather than a second-class bare int.Deviceless AOT must not eager-query the driver.
test_deviceless_aot_ compilation.pybuilds withdevice_property=and no live handle, targeting an SM that differs from any local GPU.Handle.devicepopulated from a deserializedDevicePropertiesJSON (deviceVer,multiProcessorCount) must stay authoritative when set; the live-driver path is used only when no override/descriptor is present.Device caches key by ordinal, not by Handle. The existing per-device
lru_caches (frost.device.*, occupancy map, tile budgets) assume a stable ordinal; a process driving two GPUs must not cross-contaminate.
Handle#
class Handle:
backend_handle: int | None # the wrapped cudnnHandle_t; None = pure-python (future)
_ordinal: int | None # CUDA device ordinal this handle is bound to
stream: int | None # authoritative; absorbs _handle_to_stream
device -> DeviceInfo # lazy, cached by ordinal
# no __index__/__int__; __eq__/__hash__/__bool__ at object defaults (identity, truthy)
# the explicit handoff (grep `backend_handle` to trace it):
to_backend_handle(h) # a Handle's .backend_handle, a foreign int, or None
create_handle() (Python, after the copy loop): Handle(backend_handle= _pybind_module.create_handle(), ordinal=<current device>).
DeviceInfo (the union Handle.device exposes)#
Lives in cudnn/_device.py — the FE’s single owner of a GPU’s facts. Each
fact is a @cached_property that queries the driver once and caches on the
instance, and there is one instance per ordinal (device_info(ordinal),
lru-cached), so a GPU’s facts are asked for once and shared. Handle.device is
that object.
This inverts the previous direction: the driver queries used to live in
frost/device.py and DeviceInfo delegated down to them. Now the common layer
owns the queries, and frost/device.py’s fact functions (compute_capability,
multiprocessor_count, …) are thin shims onto device_info(ordinal) — so
frost consumes the same object rather than running a parallel introspection
stack, and its ~24-file / 65-site call surface is unchanged. (A later step can
repoint those sites at handle.device.* directly where a handle is in scope; the
ownership move here is the enabling half.) Fields (superset the inventory proved
is consumed):
field |
form |
consumers |
|---|---|---|
|
int |
frost build/guard, operand views |
|
(major, minor) |
frost arch gate, api_base sm107, tensor_adapter |
|
packed |
Context, kernel_registry ranges, DSA gates |
|
int (user-overridable) |
tile scorer, heuristics |
|
bytes |
tile SMEM budget |
|
bytes (0 if unsupported — load-bearing) |
tile SMEM budget |
|
bytes |
L2 swizzle budget |
|
str |
diagnostics |
sm_version is a derived property of compute_capability so the two forms
cannot drift. Handle.device also owns the serializable backend
DeviceProperties (deviceless AOT); when built from a descriptor/override the
fields come from its JSON, not the driver.
Environment facts (versions) — cudnn/_env.py#
Device facts are per-ordinal; version facts (CUDA driver, CUDA runtime) are
process-global — one per process regardless of which GPU a handle is bound to.
Putting them on DeviceInfo would duplicate them per ordinal, and on Handle
per handle, so they get their own owner cudnn/_env.py (driver_version(),
runtime_version()). This mirrors the backend, which exposes its own versions as
argument-less globals (cudnnGetVersion, cudnnGetCudartVersion), never off a
handle or the DEVICEPROP descriptor — cuDNN’s own version stays there,
cudnn.backend_version(). The CUDA queries had accreted as re-reads in each
engine (the DeviceInfo oversized-SMEM gate, the cutile GDN/KDA check_support);
_env collects them. ~100 ns and off the execute hot path — the cache is a single
owner returning a constant, not a speed play.
Stream model#
Handle.stream is the single source of truth. Today the stream lives in two
disconnected places: set_stream writes _handle_to_stream (PR #611) but
_resolve_stream reads live via cudnn.get_stream -> cudnnGetStream on
every python-engine execute. Handle unifies them:
set_stream(h, s): Handle -> compare/writeh.stream, call_raw_set_streamonly on change (and only ifh.backend_handle is not None); foreign int -> today’s_handle_to_streampath.get_stream(h): Handle -> returnh.stream(no round-trip); foreign int -> raw binding._resolve_stream/_build_context: readh.streamfor a Handle. This kills the per-executecudnnGetStreamthat #611 did not remove.Preserve the raise-not-fallback contract: a failed query on a supplied handle raises (asserted by
test_dispatch.py:585), never silently falls back to the torch current stream.
Device-consumer migration (frost / linear-attention / sdpa)#
to_backend_handle does not help here — these want a device ordinal, not
the backend handle int. Every device-derived frost build constant (_current_arch,
_plan_device, _grid_num_clusters, _sm_count, the SMEM/L2 budgets) already
funnels through frost.device.current_device()/resolve_device(None), so rather
than thread an ordinal through ~20 signatures, a scoped build-device override
covers them all at once:
frost/device.py—build_device(ordinal), a context manager that scopes an override intocurrent_device()(liketorch.cuda.device()).None= no override (classic current-device). Grepbuild_device/_build_deviceto trace it: the context manager +current_device()’s read + the one hinge.Frost GEMM (done):
FrostGemmEngine.build_plan(graph, plan, ctx)wrapsbuild_gemm_plan(graph)inwith build_device(ctx.handle.device.ordinal), so the whole build bakes for the handle’s GPU.tile_config._sm_count()is re-routed offtorch.cuda.current_deviceontofrost.deviceso it honours the scope too (it was the one query that bypassedcurrent_device()).Frost GEMM compile target (done): the scope also had to reach the cute compile arch, which the earlier constants did not. cutedsl derives the compile target from the ambient CUDA device (
torch.cuda.get_device_capability), so a build for handle-GPU-A while GPU-B is current baked A’s constants into a B-targeted kernel._frost_compile_options()(ingemm/frost/compiler.py) now pins--gpu-arch sm_<scope>into thecute.compile()options string, so the compile target follows the scope. The arch is part of the baked, content-hashed source, so a cross-arch kernel can no longer collide in the JIT cache with a same-source same-machine one. Limitation: the pin is honoured on the publicnvidia-cutlass-dsl >= 4.7(frost’sCUTEDSL_MIN_VERSION) and on internal RCs; only a public wheel below the floor never threads--gpu-arch, and frost already declines those as too-old (buffers.cutedsl_too_old, which the support check reuses so an internal RC’s own0.xnumbering is judged new, not old). On such a wheel a handle-scoped build fails loud — it cannot pin the target and cutedsl resolves it from an arch captured at import time, which we can neither set nor reliably read (a live-device comparison would miss an import-on-B / build-on-A process), so_frost_compile_optionsrefuses anybuild_device-scoped build rather than bake scope constants into a possibly-mis-targeted kernel. An unscoped build makes no cross-device promise and is unchanged.Not yet scope-following (documented holes):
check_support/kernel-selection gates read the ambient arch (buffers.current_sm()), and the linear-attention kernels lazy-compile at first execute — after the build scope has closed — so their compile target is the execute-time device. Same-GPU (scope == ambient, the normal case) all of these agree; a handle-scoped build on a sub-floor wheel is the only case that diverges, and it is the fail-loud path above._check_plan_devicestays unchanged and correct: it is the EXECUTE-time launch guard and must check the live current device (where the launch is going) against the baked device — the override is a build-scope only, unset at execute, so the guard keeps reading the live device.current_device()is otherwise unchanged — it is the fallback whenever no build scope is active (no-handle, render-only, execute).Linear-attention (done):
gdn/gdn2/kda_engine.build_planwrap their build inwith build_device(ctx.handle.device.ordinal)too, and their one device-baked constant —num_sm = multiprocessor_count(current_device_id())— is re-routed ontofrost.device.current_device()so it follows the scope (it read thebuffersprobe, which bypassed it).test_la.py359 passed / 0 failed on SM100.sdpa frost engines: nothing to scope at the engine level — their build bakes no device constant from
current_device; arch gating is incheck_support, and the onetorch.cuda.current_device()(sdpa/bwd/engines.py) tags a TensorDesc’s operand device, which is correctly the live device (asVariantPack.device).
workspace stays a per-execute argument — it is not handle state and does not
belong on the Handle (today it is conflated into ExecutionContext).
VariantPack.device (operand DLPack views) stays on current_device(): it is
read at EXECUTE, where the operands live on the current device, so the live device
is correct (and the build scope is not active then).
Validation#
Single-GPU L0 (matmul/conv/rope/norm) — regression. Handle core: 41 passed.
Frost GEMM no-regression with the build-device adoption:
test_public_execute_ flavors+test_stream_respect32 passed (SM100).Cross-device redirect (
test/python/gemm/frost/test_build_device.py): scope a build to a different-arch real GPU (L40S sm89 / H100 sm90 / A100 sm80) and assert every frost build constant (_current_arch,_plan_device, the re-routed_sm_count) reports THAT device — the multi-GPU behaviour a single-GPU run cannot otherwise exercise, proven on parley’s SM80..SM100 range without needing two Blackwells (device queries only, no kernel launch)._check_plan_deviceremains the execute-time launch guard against baking on one GPU and launching on another.Compile-target follows scope (SM100, cutedsl 4.7): the
--gpu-archpin is non-regressive on the matching-arch path —test_matmul.pybf16 sweep 677 passed / 337 skipped end-to-end with--gpu-arch sm_100abaked in. That the pin actually moves the target is shown by compiling one graph three ways:sm_100a(machine) andsm_103a(a different Blackwell sibling) both compile, whilesm_90afails in the arch-specific NVVM backend — impossible if the option were ignored (all three would target sm_100 and pass), so on 4.7 the option reaches the compiler. The sub-floor-wheel fail-loud is unit-checked by forcing the support probe false and asserting abuild_device-scoped build raises while an unscoped one passes through.Forced through flashinfer’s GEMM fuzzer (SM100): this build dropped into flashinfer’s
.venv(via aPYTHONPATHshim) and forced onto thecudnnbackend across the full unified GEMM fuzz cross-product (bf16 / fp8 / nvfp4 / mxfp4 / mxfp8, mm + bmm) — 731 passed / 0 failed / 151 xfailed (the xfails are flashinfer’s pre-tracked, backend-agnostic findings). The first-classHandle,set_streamidempotency anddestroy_handleclear are exercised on every one.
Relationship to PR #611#
This PR is the full first-class-Handle superset of #611 (it reimplements the
set_stream idempotency as Handle.stream and keeps the discarded-context fix).
#611 stays open as the minimal, low-risk fallback. Exactly one merges.