Stiletto
Stiletto traces plain Julia array code — *, mul!, broadcasting, reductions, transpose, reshape, view, y .= ... — into fused cuDNN graphs. Values are symbolic while the traced function runs; cuDNN tensors are declared only once the whole graph is known, so decisions that span the entire computation are made from all use sites at once:
- Rank unification — cuDNN wants matmul operands at rank ≥ 3 and pointwise broadcasts at uniform rank; every tensor is declared at the maximum rank any operation requires, lifted by trailing singletons (free for column-major storage).
- Composite values — an argument may back several graph tensors (a block-scaled array declares elements, swizzled scales, and a dequantize node) through the
declare/bind!/argkeyextension seams. - In-place and presentation semantics —
mul!andy .= ...become graph outputs bound to the caller's buffers;transpose,permutedims,reshape, andvieware free stride re-presentations, not operations.
Engine support is never faked: when cuDNN has no engine for a graph, compilation throws cuDNN.UnsupportedGraphError rather than falling back to a different computation.
using Stiletto, CUDA
M, N, K = 640, 320, 480
A = CUDA.randn(Float32, K, M)
B = CUDA.randn(Float32, K, N)
function matmul_epilogue(a::AbstractMatrix, b::AbstractMatrix)
sum(tanh.(transpose(a) * b / √K), dims=1)
end
C = @jit matmul_epilogue(A, B) # 1×320 CuArray, 1 allocation
function matmul_epilogue!(c::AbstractMatrix, a::AbstractMatrix, b::AbstractMatrix)
c .= matmul_epilogue(a, b)
end
@jit matmul_epilogue!(C, A, B) # 0 allocationsOne graph, one kernel launch: the gemm engine takes tanh and /√K as its pointwise epilogue and the sum fuses as a terminal reduction, so the only allocation is the output — and the in-place form writes the caller's buffer directly. Traced functions compose like the ordinary Julia functions they are.
Installation
using Pkg
Registry.add(url="https://registry.jool.space")
Pkg.add("Microscaling")Compiling and executing
compile traces once and returns a callable; jit compiles per argument signature on first use and executes the cached plan, keyed on shapes, strides, dtypes — and on the native code of the traced call, so redefining a function (or anything it calls) retraces instead of replaying a stale graph.
Stiletto.compile — Function
compile(f, args...; io_dtype=nothing, intermediate_dtype=Float32, compute_dtype=Float32,
allocator=(T, dims) -> CuArray{T}(undef, dims),
max_workspace=nothing, deterministic=false, heuristics=nothing)Trace f applied to symbolic stand-ins for args, build the resulting cuDNN graph, and return a callable. Calling the result with arrays of the same shapes as args executes the graph and returns outputs obtained from allocator; in-place assignments (mul!, y .= ...) write into the caller's buffers and allocate nothing.
Output element types follow Julia's promotion of the traced computation, so compiled code returns what the plain code would (explicit casts like Float16.(x) included). Passing io_dtype overrides that as a precision policy: every non-cast output is declared and allocated at io_dtype, regardless of what scalar constants promote the trace to.
Engine selection is steerable: max_workspace (bytes) caps the workspace a plan may demand, deterministic=true rejects engines flagged numerically nondeterministic, and heuristics overrides the heuristic modes consulted (a tuple of cuDNN.cudnnBackendHeurMode_t values, tried in order). When no engine satisfies the constraints, compilation throws cuDNN.UnsupportedGraphError rather than silently relaxing them.
Stiletto.@compile — Macro
g = @compile f(args...; kwargs...)Expand to compile(f, args...), returning the compiled callable without executing it. Call keywords are closed over as trace-time constants.
Stiletto.jit — Function
jit(f, args...; kwargs...)Compile f for this argument signature on first use and execute the cached graph, so jit can be called inline in hot code (a forward pass). Entries live in cuDNN's per-handle plan cache. Under CUDA graph capture the call replays as recorded stream work; the in-place forms (mul!, y .= ...) are the natural fit there, binding stable caller buffers and allocating nothing.
Arguments are runtime inputs — what the graph sees: arrays are keyed by shape/dtype/strides and rebind their pointers per call, scalars become by-value tensors rebound per call. Captured values are trace-time constants — what the tracer sees: isbits captures (flags, dims) specialize the graph per value and may be branched on; captured arrays are baked by identity. Pass data as arguments; capture configuration.
Keyword arguments forward to compile — including the engine-selection knobs (max_workspace, deterministic, heuristics) — and participate in the plan key: the same call with a different knob is a different cached plan.
Stiletto.@jit — Macro
d = @jit f(args...; kwargs...)Expand to jit(f, args...): compile on first use, execute the cached graph. Call keywords are closed over as trace-time constants.
Stiletto.TracedArray — Type
TracedArray{T,N}The symbolic stand-in compile and jit run the traced function on: shape and element type are real, values are not. Subtyping AbstractArray admits it into generic array code; interface promises a symbolic value cannot keep (element access, allocation) throw informative errors instead of tracing incorrectly.
The compiled callable can be split into its two halves for callers that schedule execution themselves:
Stiletto.graph — Function
Stiletto.graph(c) -> cuDNN.GraphThe built cuDNN graph behind a compiled callable, for callers that schedule execution themselves: execute!(Stiletto.graph(c), b) with b from Stiletto.bindings is exactly what calling c does.
Stiletto.bindings — Function
Stiletto.bindings(c, args...) -> (bindings, outputs)Prepare the tensor bindings a call of c with args would execute with: every graph tensor mapped to its backing (argument storage — reshaped, viewed, or split into components as the trace declared it — captured arrays, by-value scalars, and freshly allocated output buffers). outputs are the arrays the call would return, already present in bindings.
Together with Stiletto.graph this splits c(args...) into its two halves, so execution can be scheduled by the caller — under CUDA graph capture, with rebinding between launches, or inside a larger execution scheme.
Stiletto.workspace — Function
Stiletto.workspace(c) -> IntBytes of scratch workspace the compiled graph's selected execution plan demands per call. compile's max_workspace caps this at plan selection; querying it tells you what the chosen engine actually asked for.
Traced semantics
Traced code keeps Base's meaning. Output element types follow Julia's promotion of the traced computation (explicit io_dtype overrides that as a precision policy — see compile). Base's mutating idioms trace as written: a trailing mul!(c, a, b), the returned-destination form (mul!(c, a, b); c), and y .= ...; y all hand back the caller's buffer.
Arguments are runtime inputs — arrays rebind pointers per call, scalars become by-value tensors. Captured values are trace-time constants: isbits captures (flags, dimensions, size(x, 1)) specialize the graph per value and may be branched on; captured arrays are baked by identity. Pass data as arguments; capture configuration.
Operator library
Named operations built on the tracer, each in two tiers: TracedArray methods become nodes of the surrounding trace, and plain-array methods jit-compile a standalone graph — which is also how operands no BLAS method claims (block-scaled composites, narrow dtypes) get eager execution.
Matrix multiplication
Stiletto.mul! — Function
Stiletto.mul!(C, A, B[, α, β])
Stiletto.mul!(y, A, x[, α, β])LinearAlgebra.mul! executed as a cuDNN graph: C = α·A·B + β·C with the scaling and accumulation fused as a matmul epilogue; β = 0 leaves the destination unread. Matrix-matrix and matrix-vector, matching Base; batching is spelled batched_mul!.
On traced values this is what LinearAlgebra.mul! dispatches to, so both spellings trace identically. On plain arrays it jit-compiles the graph — the eager mul! for operands no BLAS method claims, such as block-scaled composites. α and β are trace-time constants — each coefficient pair compiles its own plan.
Stiletto.batched_mul — Function
Stiletto.batched_mul(a, b)
Stiletto.batched_mul!(c, a, b[, α, β])
a ⊠ bBatched matrix multiplication (M, K, batch...) × (K, N, batch...) → (M, N, batch...). Trailing batch extents broadcast pairwise: each pair must match or be 1 — missing dimensions count as 1 — and the result takes the larger. Unlike NNlib.batched_mul, any number of batch dimensions is accepted. ⊠ (\boxtimes) is the infix spelling.
The 5-arg form computes α·A·B + β·C with the scaling and accumulation fused as a matmul epilogue; β = 0 leaves the destination unread. α and β are trace-time constants — each pair compiles its own plan.
Attention
Stiletto.attention — Function
Stiletto.attention(q, k, v; stats=false, scale=1/√size(q, 1), causal=false,
seq_len_q=nothing, seq_len_kv=nothing)
Stiletto.attention!(o, q, k, v; ...)Scaled dot-product attention softmax(scale ⋅ qᵀk) ⋅ v over rank-4 operands laid out (head_dim, heads, seq_len, batch). Grouped-query attention gives k/v fewer heads (a divisor of q's). scale binds by value at execution; causal bakes a causal mask into the graph.
stats=true additionally returns the softmax log-sum-exp as a (1, seq_len, heads, batch) Float32 array — the saved state attention_backward consumes.
seq_len_q/seq_len_kv (passed together) are Int32 vectors of per-batch valid lengths — runtime inputs masking each sequence's tail, so one graph built at maximum length serves ragged batches by rebinding lengths per call. Output rows past seq_len_q are undefined.
Stiletto.attention_backward — Function
Stiletto.attention_backward(dO, q, k, v, o, stats; scale=1/√size(q, 1),
causal=false, seq_len_q=nothing,
seq_len_kv=nothing) -> (dQ, dK, dV)
Stiletto.attention_backward!(dQ, dK, dV, dO, q, k, v, o, stats; ...)Gradients of attention with respect to q, k, and v, from the forward pass's output o and saved softmax stats (attention(...; stats=true)). scale/causal/sequence lengths must match the forward call. The operands cannot feed other operations in the same trace — the composite pattern owns its graph.
Normalization
Stiletto.rmsnorm — Function
Stiletto.rmsnorm(x, scale; bias=nothing, epsilon=1f-5)
Stiletto.layernorm(x, scale, bias; epsilon=1f-5)
Stiletto.rmsnorm!(y, x, scale; ...) / layernorm!(y, x, scale, bias; ...)RMS and layer normalization over the dimensions scale spans: a rank-1 scale of length size(x, 1) normalizes over the first dimension. scale and bias must be inputs of the trace (arguments or captures), not computed values — precompute them before compiling. epsilon is a trace-time constant.
Stiletto.batchnorm — Function
Stiletto.batchnorm(x, scale, bias, mean, inv_variance)
Stiletto.batchnorm!(y, x, scale, bias, mean, inv_variance)Inference-phase batch normalization over the channel dimension — the second-to-last of x, which must be at least 3-dimensional ((spatial..., channels, batch)). Parameters and statistics are per-channel vectors of length size(x, ndims(x) - 1). inv_variance is the folded inverse standard deviation 1 ./ sqrt.(running_var .+ epsilon); fold it once when loading parameters.
Convolution and pooling
Stiletto.conv — Function
Stiletto.conv(x, w; stride=1, dilation=1, pre_padding=0, post_padding=pre_padding)
Stiletto.conv!(y, x, w; ...)Cross-correlation of x :: (spatial..., channels, batch) with filters w :: (spatial..., channels ÷ groups, out_channels), producing (out_spatial..., out_channels, batch). The group count is inferred from the channel ratio (depthwise: filter channel extent 1). Spatial keywords take a scalar or one value per spatial dimension; asymmetric padding expresses causal windows (pre_padding=K-1, post_padding=0).
Stiletto.maxpool — Function
Stiletto.maxpool(x, window; stride=window, pre_padding=0, post_padding=pre_padding)
Stiletto.meanpool(x, window; ...; include_padding=false)
Stiletto.maxpool!(y, x, window; ...) / meanpool!(y, x, window; ...)Pool x :: (spatial..., channels, batch) over window, producing (out_spatial..., channels, batch). stride defaults to the window (non-overlapping). meanpool divides by the number of contributing elements; include_padding=true divides by the full window size instead.
Quantization
Stiletto.quantize! — Function
Stiletto.quantize!(dest, x)Quantize x into dest::BlockscaledArray, writing quantized elements and block scales into dest's storage components. Block size, element type, and scale type are read from dest; the blocked dimension is the one dest's block spans. Inside a trace, dest must be an argument and x is fused into the graph — cuDNN's engines support quantize fused after a matmul (quantize!(dest, w' * x)), the full narrow-precision pipeline in one kernel.
Assignment into a block-scaled destination is the same store — quantization is how the destination stores values — so traced dest .= x and mul!(dest, a, b) route here. Partial writes (view(dest, ...) .= x) are refused: block scales couple elements, so only whole-array stores have a meaning.
Requires the Microscaling extension (using Microscaling).
Extensions
- NNlib — activation functions map to cuDNN pointwise modes;
NNlib.batched_mul/batched_mul!andbatched_transpose/batched_adjointon traced values route onto the traced matmul, whose semantics are a strict superset. - SpecialFunctions —
erftraces as a pointwise mode. - Microscaling —
BlockscaledArrayarguments trace like any array, declaring element and swizzled-scale tensors joined by a dequantize node and binding their storage components at execution. Assignment into a composite destination (c .= w' * x,mul!(c, a, b), or the explicitStiletto.quantize!) fuses block-scale quantization onto the computed value — quantization is how the destination stores values.