Skip to content

perf(cohere2): fuse the parallel-block residual adds with the next norm - #1948

Open
inureyes wants to merge 2 commits into
mainfrom
perf/cohere2-fused-residual-norm
Open

inureyes wants to merge 2 commits into
mainfrom
perf/cohere2-fused-residual-norm

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

A Cohere2 block ends in (attn + mlp) + x and the next block starts with a LayerNorm of that sum, so on the decode critical path every layer boundary is three dependent kernels: two adds and MLX's layer_norm_single_row. Two commits remove two of them, both byte-identical to the current output:

  • compiled_add3 runs the two adds as one compiled kernel with the same association order.
  • fused_add3_layer_norm is one Metal kernel that writes the residual and its LayerNorm. It copies the pinned MLX single-row kernel (threadgroup size, 8 reads per thread, two-stage simd and threadgroup reductions, precise::rsqrt, the affine step with the bias read from memory as fast::layer_norm passes it), so both outputs match the unfused pair bit for bit. layers::residual_add3_layer_norm falls back to the pair off Metal, above a 6656-wide row, or on mixed dtypes, and MLXCEL_FUSED_ADD_NORM=0 forces the fallback. The Cohere2 layer loop fuses block i's add with block i+1's input norm, and the last block's add with the final norm.

Measurements

command-r7b 4-bit on M1 Ultra, 500-token prompt, 128 tokens, eight interleaved ABBA pairs, measured with #1947 applied:

Change Off On
compiled_add3 (budget pinned on both arms) 109.35 tok/s 110.54 tok/s (+1.1%)
fused add + LayerNorm (same binary, MLXCEL_FUSED_ADD_NORM=0 vs unset) 112.45 113.72 (+1.1%)

Prefill is unchanged.

Validation

  • A unit test asserts exact equality with compiled_add3 followed by LayerNorm::forward across f16 and bf16, with and without a bias, several rows, and a width that is not a multiple of 8. Multiplying the kernel's rsqrt by 1.001 makes it fail, which shows the fused path runs and the test discriminates.
  • Greedy generations for three prompts at 200 tokens are identical to main. The logit-trace gate was not run because the change is bitwise identical by construction and by that test.
  • On the branch this was developed on, the full workspace gate (make verify-test, 11,412 passed), clippy and fmt pass; on this branch, clippy, fmt and the new test.

Two mx.fast.metal_kernel details worth knowing: a 0-d input is passed to the kernel as a scalar rather than a pointer, and a small input can land in the constant address space, so the kernel takes the missing bias as a one-element array and uses auto pointers. Upstream's kernel starts one tail loop at a negative index for threads past the end of a narrow row; the copy clamps the start to 0, which leaves the result unchanged.

Cohere2 blocks end with `(attn + ff) + x`, which MLX runs as two dependent elementwise kernels, each one more barrier level on the decode critical path. `compiled_add3` compiles the same expression, with the same association order, into one kernel, so the result is byte-identical and every layer drops one dispatch and one barrier.

Validated on c4ai-command-r7b-12-2024 4-bit on M1 Ultra: greedy generations for three prompts at 200 tokens match main exactly, and eight interleaved ABBA pairs (500-token prompt, 128 tokens, command-buffer input budget pinned on both arms) read decode median 110.54 vs 109.35 tok/s, +1.1%.
A Cohere2 block ends in `(attn + mlp) + x` and the next block starts with a LayerNorm of that sum, so every layer boundary on the decode critical path is two dependent dispatches, a compiled add and MLX's `layer_norm_single_row`. `fused_add3_layer_norm` is one Metal kernel that writes the residual and its normalization. It copies the pinned MLX kernel's threadgroup size, read pattern, two-stage reductions, `precise::rsqrt` and affine step (bias read from memory, as `fast::layer_norm` passes it), so both outputs are byte-identical to the unfused pair rather than close to it. `layers::residual_add3_layer_norm` falls back to the pair off Metal, above a 6656-wide row, or on mixed dtypes, and `MLXCEL_FUSED_ADD_NORM=0` forces it. The Cohere2 layer loop now fuses block i's add with block i+1's input norm, and the last block's add with the final norm.

A unit test asserts exact equality across f16 and bf16, with and without bias, several rows and a width off the 8-read boundary; perturbing the kernel's rsqrt by 0.1% makes it fail. Greedy generations for three prompts at 200 tokens match main. command-r7b 4-bit on M1 Ultra, eight ABBA pairs in one binary: decode 112.45 to 113.72 tok/s (+1.1%), prefill unchanged.
@inureyes inureyes added status:review Under review type:performance Performance improvements priority:medium Medium priority area:models Model architectures, weights, loading, metadata platform:macos macOS (Apple Silicon) specific labels Sep 21, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:models Model architectures, weights, loading, metadata platform:macos macOS (Apple Silicon) specific priority:medium Medium priority status:review Under review type:performance Performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant