Fused and custom Metal kernels
nuvec's fast module: fused RMSNorm, LayerNorm, RoPE, attention, cross entropy and Gated DeltaNet kernels, plus custom Metal kernels written from source.
Fused kernels (nuvec::fast)🔗
The fast module exposes MLX’s fused operations and a few of nuvec’s own. Each one is a single Metal kernel on the GPU and an equivalent composition of ordinary operations elsewhere; the composition also defines its derivatives and batching, so they work under every transform.
| Function | What it computes |
|---|---|
rms_norm(x, weight, eps) | RMS normalization over the last axis |
add_rms_norm(x, residual, weight, eps) | x + residual and its RMS normalization (a transformer block’s residual connection) |
residual_rms_norm(h, x, norm, scale, next) | (h + rms_norm(x)) · scale and, optionally, its RMS normalization (Gemma 4’s sandwich norms, layer scalar and the next layer’s norm) |
layer_norm(x, weight, bias, eps) | Layer normalization |
rope(x, dims, traditional, base, scale, offset, freqs) | Rotary position embeddings, optionally on the first dims features only |
norm_rope(x, norm, rotation, offset, cache) | Per-head RMS normalization and RoPE, optionally written straight into a KV cache |
norm_rope_at(x, norm, rotation, positions, pool) | The same with each sequence at its own position, optionally written straight into a paged KV cache’s slots |
scaled_dot_product_attention(q, k, v, scale, &options) | Attention with grouped-query heads; AttentionOptions holds the mask (causal, boolean or additive), attention sinks, a sliding window and a cap on score memory |
cross_entropy(logits, targets) | Fused log-softmax and negative log-likelihood |
gated_delta(q, k, v, g, beta, state) | The Gated DeltaNet recurrence (Qwen 3.5’s linear attention), returning the outputs and the new state |
gated_delta_steps(q, k, v, g, beta, state) | The same, returning the state after every step (to roll the recurrence back) |
gated_delta_slots(q, k, v, g, beta, pool, slots) | The same for a batch whose states live at slots of a pool, updated in place |
causal_conv1d_slots(x, w, pool, slots, silu) | A causal depthwise convolution (optionally silu) for a batch whose last inputs live at slots of a pool, updated in place |
paged_attention(q, k_cache, v_cache, block_tables, lengths, scale, &options) | Attention for a batch of sequences of different lengths over a KV cache stored in fixed-size blocks, with the same window and sinks |
moe_route(probs, k, normalize) | A mixture of experts’ routing: each row’s top-k experts (in argpartition’s order) and their probabilities, optionally renormalised |
weighted_sum(x, w) | The experts’ outputs weighted and summed over the k experts |
paged_write(cache, new, slots) | Writes new keys or values (of one or several sequences, read through their strides) into their blocks, in place when the cache is owned |
The Gated DeltaNet kernels pick mlx-lm’s packed kernel where it applies: key head size 128, value head size a multiple of 8, float32 gates and state. MLX_GDN_PACKED=0 keeps every shape on the generic kernel, as in mlx-lm, so the two paths can be compared in one build.
Attention picks MLX’s kernel for the shape: vector kernels for short queries (decode), a two-pass split-K kernel for long contexts, and the tiled flash-style kernel or the composition for prefill; at head size 512, long causal prefill in half precision runs MLX’s blocked path (256 queries at a time over only the keys they see). A sliding window skips the keys outside it in every kernel. plan_attention reports the choice, and the scratch memory it holds, from the shapes alone; max_score_bytes bounds every score-sized intermediate (the tiled kernel, or the composition in blocks of queries). With 2 to 8 causal queries (speculative verification), each row gives the bits of the same row decoded alone. softmax and logsumexp over the last axis use fused kernels too.
use nuvec::fast::{AttentionOptions, plan_attention};
use nuvec::DType;
# fn main() -> nuvec::Result<()> {
let opts = AttentionOptions::causal().max_score_bytes(512 << 20);
let plan = plan_attention(&[1, 16, 1024, 256], &[1, 2, 33_792, 256], &[1, 2, 33_792, 256], DType::BF16, &opts)?;
assert!(plan.max_intermediate_bytes <= 512 << 20);
# Ok(())
# }Custom kernels🔗
MetalKernel runs your own kernel body, written in Metal Shading Language, the way MLX’s mx.fast.metal_kernel does. You give a name, the input and output names and the body; nuvec generates the function signature:
use nuvec::prelude::*;
use nuvec::fast::{KernelConfig, MetalKernel, TemplateArg};
# fn main() -> Result<()> {
# if !nuvec::metal::is_available() { return Ok(()); } // needs a Metal GPU
# let n = 1024;
# let x = nuvec::ops::ones([n], DType::F16);
let scale = MetalKernel::new(
"scale",
&["inp"],
&["out"],
"uint i = thread_position_in_grid.x;\nout[i] = static_cast<T>(inp[i] * N);",
);
let config = KernelConfig {
grid: [n as usize, 1, 1],
threadgroup: [256, 1, 1],
template: vec![("T".into(), TemplateArg::DType(DType::F16)), ("N".into(), TemplateArg::Int(3))],
..Default::default()
};
let y = scale.call(&[x], &[(&[n], DType::F16)], &config).remove(0);
# assert_eq!(y.astype(DType::F32).sum(..).item::<f32>()?, 3072.0);
# Ok(())
# }Signature rules, as in MLX:
- an input is
const device T* name; a 0-d input isconst constant T& name; name_shape,name_stridesandname_ndimare added when the body uses them;- an output is
device T* name, ordevice atomic<T>* namewith atomic outputs (optionally initialized); - thread attributes the body mentions (
thread_position_in_grid,thread_index_in_simdgroup, …) become parameters; - template arguments become
constexprvalues and type aliases; MLX’sbfloat16_t,float16_tandelem_to_locare defined.
Kernels compile once per generated source and run with one thread per grid position. They have no derivative or batching rule of their own: wrap them in custom_function to give them one. mlx-lm’s own Gated DeltaNet kernel source runs unchanged through MetalKernel.