Skip to content

Elementwise kernels in C: RMSNorm, RoPE, SiLU-mul, embedding, add, argmax

ModuleL9.6 · side · C · Pass 6 · 3 to 4 h
You buildc/src/kernels/elementwise.c: tl_rmsnorm_f32, tl_rope_f32 (half and interleaved layouts, partial rotary, attention scaling), tl_silu_mul_f32, tl_embedding_f32, tl_add_f32, tl_argmax_f32
Contractcourse/contracts/c/include/tinyllm/elementwise.h · rules: c/ABI.md
Testscourse/tests/L9.6/: test_elementwise.c (C, under ASan and UBSan) and shared file fixtures (what they check: section 4)
Needsrt.02 the loader · M09.5 your tl_rsqrtf · M09.6 your tl_expf · L7.1 your RMSNorm · L7.3 your rope_cos_sin and apply_rope (or --ref-deps). Reading: L7.2 (SwiGLU)
Used byThese standalone C routines are useful as independent examples; the Python and Rust engine paths retain their own implementations.
MilestoneMS-L9 (the C backend generates the same tokens as numpy)
Optional depthZhang and Sennrich, “Root Mean Square Layer Normalization” (2019); Su et al., “RoFormer” (2021), section 3.4; Shazeer, “GLU Variants Improve Transformer” (2020)
  • Each kernel touches every element once, so it is bound by memory bandwidth, not arithmetic: one pass, no temporaries, and outputs that may alias their first input (rmsnorm_rows_are_independent_and_in_place, add_in_place).
  • RMSNorm puts ε\varepsilon inside the root, x/x2‾+ε⋅wx / \sqrt{\overline{x^2} + \varepsilon} \cdot w, and its sum of squares runs in a fixed order per row, so a row’s bits never depend on its batch (rmsnorm_eps_is_inside_the_root).
  • RoPE is a rotation of pairs, and which entries form a pair is the layout: (xi,xi+r/2)(x_i, x_{i + r/2}) for HF Llama, (x2i,x2i+1)(x_{2i}, x_{2i+1}) for Meta’s code; both members are read before either is written (rope_interleaved_layout_and_position_zero, rope_is_a_rotation).
  • SiLU saturates without NaN when written g/(1+e−g)g / (1 + e^{-g}): at g=−104g = -10^4 the exponential overflows to +∞+\infty and the quotient is −0-0 (silu_mul_saturates_without_nan).
  • Greedy argmax breaks ties to the lowest index and skips NaN, the rule the Python sampler and the Rust engine share (argmax_ties_nan_and_empty).
Terminal window
ol start L9.6 # stubs c/src/kernels/elementwise.c into your repo
ol tests L9.6 # read the test catalog first
ol check L9.6 # exit code is the verdict
ol check L9.6 --ref-deps # only if rt.02, M09.5, M09.6, L7.1, or L7.3 is not passing yet
ol diff L9.6 # after passing: your code against the reference

Elementwise and row-wise operations include embedding lookup, normalization, rotary position encoding, gated activation, residual addition, and greedy selection. Each operation has details that silently change model output when implemented incorrectly: where ε\varepsilon goes, which entries RoPE pairs, how SiLU behaves at large gates, and how argmax breaks ties. This optional module implements the C versions as standalone routines, with the Python definitions serving as the behavioral reference.

SymbolMeaningType / shape
x∈Rdx \in \mathbb{R}^{d}one row (a token’s hidden state)float[d]
w∈Rdw \in \mathbb{R}^{d}the RMSNorm gainfloat[d]
ε\varepsilona small constant that keeps the root away from 0float
x2‾\overline{x^2}1d∑ixi2\frac{1}{d}\sum_i x_i^2, the mean of squaresscalar
T,H,DT, H, Dtokens, heads, head width of a RoPE input [T, H, D]int64_t
rrd_rot, the rotated width (even, ≤D\le D)int64_t
ptp_tthe absolute position of token ttint32_t
ωi\omega_iinv_freq[i], the frequency of pair ii, 0≤i<r/20 \le i < r/2float[r/2]
θt,i=pt ωi\theta_{t,i} = p_t\,\omega_ithe rotation angle of pair ii at token ttfloat
μ\muattn_scaling, YaRN’s factor on cos and sin (1 otherwise)float
σ(g)\sigma(g)the logistic sigmoid 1/(1+e−g)1/(1 + e^{-g})

RMSNorm (L7.1) rescales a row to unit root mean square, then applies a learned gain:

yi=xix2‾+ε wi,x2‾=1d∑i=0d−1xi2.y_i = \frac{x_i}{\sqrt{\overline{x^2} + \varepsilon}}\, w_i, \qquad \overline{x^2} = \frac{1}{d}\sum_{i=0}^{d-1} x_i^2 .

In C that is one pass to accumulate ∑ixi2\sum_i x_i^2 in float32 in increasing ii, one call to your tl_rsqrtf (M09.5, Newton’s method) for 1/x2‾+ε1/\sqrt{\overline{x^2} + \varepsilon}, and one pass to write xi⋅inv⋅wix_i \cdot \text{inv} \cdot w_i. Because the order of the sum is fixed per row, a row’s result is the same whether the call normalizes 1 row or 64 (batch invariance, c/ABI.md rule 10). Because the sum is complete before the first write, y == x works. ε\varepsilon goes inside the root: a zero row then gives 0⋅(1/ε)=00 \cdot (1/\sqrt{\varepsilon}) = 0, never 0/00/0, and Llama’s checkpoints were trained that way.

RoPE (L7.3) rotates pairs of a query or key vector by an angle proportional to its position. Pair ii of token tt is rotated by θt,i=ptωi\theta_{t,i} = p_t \omega_i:

(a′b′)=μ(cos⁡θ−sin⁡θsin⁡θcos⁡θ)(ab)=(μ(acos⁡θ−bsin⁡θ)μ(asin⁡θ+bcos⁡θ)).\begin{pmatrix} a' \\ b' \end{pmatrix} = \mu \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix} \begin{pmatrix} a \\ b \end{pmatrix} = \begin{pmatrix} \mu(a\cos\theta - b\sin\theta) \\ \mu(a\sin\theta + b\cos\theta) \end{pmatrix} .

A rotation keeps the pair’s length (times μ\mu), and the dot product of a query rotated by θq\theta_q and a key rotated by θk\theta_k depends only on θq−θk\theta_q - \theta_k: attention sees relative position. Three details of the contract:

  • The layout says which entries form pair ii: layout 0 (“half”, HF Llama, SmolLM2) pairs (xi,xi+r/2)(x_i, x_{i + r/2}); layout 1 (“interleaved”, Meta’s code, the RoPE paper) pairs (x2i,x2i+1)(x_{2i}, x_{2i+1}). Both are the same rotation on a permuted vector, which is why HF’s conversion script permutes the rows of q_proj and k_proj.
  • Partial rotary: only the first rr entries of each head rotate; entries rr to D−1D - 1 pass through.
  • The angle is formed once per (token, pair) in double precision and rounded to float, which equals float32(pt)⋅(p_t) \cdot float32(ωi)(\omega_i) for every position below 2242^{24}: the same angle your Python computes. One angle serves all HH heads, so the loop order is token, pair, head.

Both members of a pair must be read before either is written; computing b′b' from the new a′a' is a different (wrong) map.

Llama’s MLP (L7.2) computes down(silu(gate x)⊙up x)\mathrm{down}(\mathrm{silu}(\mathrm{gate}\,x) \odot \mathrm{up}\,x). The middle step is elementwise:

yi=silu(gi) ui,silu(g)=g σ(g)=g1+e−g.y_i = \mathrm{silu}(g_i)\, u_i, \qquad \mathrm{silu}(g) = g\,\sigma(g) = \frac{g}{1 + e^{-g}} .

Written this way it is safe at both ends. For g→+∞g \to +\infty, e−g→0e^{-g} \to 0 and y→guy \to g u. For g→−∞g \to -\infty, e−ge^{-g} overflows to +∞+\infty (your tl_expf saturates above 88.7), and g/∞=−0g / \infty = -0, the right limit. The algebraically equal geg/(1+eg)g e^{g} / (1 + e^{g}) computes ∞/∞=NaN\infty / \infty = \mathrm{NaN} at g=100g = 100.

Embedding copies row ids[t] of a [V, d] table: the source starts at element ids[t] * d. The contract makes the caller check ids against VV (the kernel cannot know VV). Add is the residual connection, y=a+by = a + b, usually in place. Argmax picks the greedy token (spec/sampling.md, temperature 0): the largest value, the lowest index among equal values (a strict > while scanning forward), NaN entries skipped, and −1-1 when there is no number at all, so the caller reports an error instead of emitting token 0.

RMSNorm of x=[3,4]x = [3, 4], w=[1,0.5]w = [1, 0.5], ε=0\varepsilon = 0: x2‾=(9+16)/2=12.5\overline{x^2} = (9 + 16)/2 = 12.5, 12.5=3.5355339\sqrt{12.5} = 3.5355339, inv=0.2828427\text{inv} = 0.2828427, so y=[3⋅0.2828427⋅1, 4⋅0.2828427⋅0.5]=[0.8485281,0.5656854]y = [3 \cdot 0.2828427 \cdot 1,\ 4 \cdot 0.2828427 \cdot 0.5] = [0.8485281, 0.5656854].

RoPE (half layout) of x=[1,2,3,4]x = [1, 2, 3, 4], D=r=4D = r = 4, ω=[1,0.01]\omega = [1, 0.01], position 1, μ=1\mu = 1. Pairs are (x0,x2)=(1,3)(x_0, x_2) = (1, 3) at θ=1\theta = 1 and (x1,x3)=(2,4)(x_1, x_3) = (2, 4) at θ=0.01\theta = 0.01:

Paircos⁡θ\cos\thetasin⁡θ\sin\thetaa′=acos⁡−bsin⁡a' = a\cos - b\sinb′=asin⁡+bcos⁡b' = a\sin + b\cos
(1,3)(1, 3), θ=1\theta = 10.54030230.84147100.5403023−2.5244130=−1.98411070.5403023 - 2.5244130 = -1.98411070.8414710+1.6209069=2.46237790.8414710 + 1.6209069 = 2.4623779
(2,4)(2, 4), θ=0.01\theta = 0.010.99995000.00999981.9999000−0.0399993=1.95990071.9999000 - 0.0399993 = 1.95990070.0199997+3.9998000=4.01979970.0199997 + 3.9998000 = 4.0197997

Written back to positions 0, 2 and 1, 3: [−1.9841107,1.9599007,2.4623779,4.0197997][-1.9841107, 1.9599007, 2.4623779, 4.0197997]. With the interleaved layout the pairs are (1,2)(1, 2) and (3,4)(3, 4) and the result is [−1.1426397,1.9220756,2.9598507,4.0297995][-1.1426397, 1.9220756, 2.9598507, 4.0297995] (rope_interleaved_layout_and_position_zero).

SiLU-mul of gate [0,1,−1][0, 1, -1] and up [5,2,3][5, 2, 3]: silu(0)=0\mathrm{silu}(0) = 0, silu(1)=1/(1+e−1)=0.7310586\mathrm{silu}(1) = 1/(1 + e^{-1}) = 0.7310586, silu(−1)=−1/(1+e)=−0.2689414\mathrm{silu}(-1) = -1/(1 + e) = -0.2689414; times up: [0,1.4621172,−0.8068243][0, 1.4621172, -0.8068243].

Argmax of [2,7,7,−1][2, 7, 7, -1]: 7 first appears at index 1, and index 2 is not strictly greater, so the answer is 1.

All four are the first test, hand_example; RMSNorm and argmax repeat through the shared fixture in test_hand_example.

/* tinyllm/elementwise.h: void, the caller guarantees valid pointers and sizes */
void tl_rmsnorm_f32(const float *x, const float *w, float *y, int64_t rows, int64_t d, float eps);
void tl_rope_f32(float *x, const int32_t *pos, int64_t T, int64_t H, int64_t D, int64_t d_rot,
const float *inv_freq, float attn_scaling, int layout /* 0 half, 1 interleaved */);
void tl_silu_mul_f32(const float *gate, const float *up, float *y, int64_t n);
void tl_embedding_f32(const float *table, const int32_t *ids, float *out, int64_t n, int64_t d);
void tl_add_f32(const float *a, const float *b, float *y, int64_t n);
int32_t tl_argmax_f32(const float *x, int64_t n); /* lowest index on ties; NaN skipped; -1 if none */

From Python, declare each with restype None (or c_int32 for argmax) on your loader.

TestKINDChecksWhy it matters downstream
hand_exampleunit, smokesection 3 for RMSNorm, RoPE, SiLU-mul, argmaxyou and the tests agree on the definitions
rmsnorm_eps_is_inside_the_rootboundary[10−3,10−3][10^{-3}, 10^{-3}] with ε=10−6\varepsilon = 10^{-6} gives 0.7071; a zero row gives zerostiny-norm rows, Llama’s trained form
rmsnorm_rows_are_independent_and_in_placepropertyevery row alone equals the row in a batch of up to 12, bit for bit; y == xbatched decode in L10.2
rope_interleaved_layout_and_position_zerounitthe interleaved worked example; position 0 is the identityloading Meta-layout weights
rope_partial_rotary_and_scalingunitthe tail past d_rot untouched; μ=2\mu = 2 doubles the pair; [T, H, D] indexingGPT-NeoX style partial rotary, YaRN
rope_is_a_rotationpropertyevery pair keeps its length, random shapes and positions, both layoutsno half-updated pairs, no wrong partners
silu_mul_saturates_without_nanboundarygates ±100\pm 100, −104-10^4 give the right limitslarge activations in trained MLPs
embedding_gathers_rowsunitrepeated and out-of-order idsthe first op of the forward
add_in_placeunity=a+by = a + b with y == athe residual stream
argmax_ties_nan_and_emptyboundaryties to the lowest index, NaN skipped, −1-1 for all NaN or empty, all −∞-\infty gives 0greedy decoding parity
hand_exampleunit, smokeRMSNorm and argmax of section 3 in the C test harnesschecks standalone routines
PitfallSymptomCaught by
ε\varepsilon outside the root, x/(x2‾+ε)x / (\sqrt{\overline{x^2}} + \varepsilon)correct on ordinary rows, wrong on tiny ones; drifts from the trained modelrmsnorm_eps_is_inside_the_root (mutant s01)
the sum of squares not divided by ddevery output shrinks by d\sqrt{d}rmsnorm_eps_is_inside_the_root (mutant s02)
forgetting the gain wwright shape, wrong scalehand_example (mutant s03)
the sum of squares declared once per call, not per rowrow 2 is normalized by rows 1 and 2 together; results depend on the batchrmsnorm_rows_are_independent_and_in_place (mutant s15)
the layouts swappedplausible numbers, wrong attention pattern on HF weightsrope_interleaved_layout_and_position_zero (mutant s04)
rotating by −θ-\theta (a sign flipped)relative positions mirrored; the model’s outputs degraderope_interleaved_layout_and_position_zero (mutant s05)
computing b′b' from the new a′a'lengths change; not a rotationrope_is_a_rotation (mutant s06)
rotating all DD entries when d_rot < Dthe pass-through tail is scrambled (and inv_freq is read past its end)rope_partial_rotary_and_scaling (mutant s07)
ignoring attn_scalingYaRN-extended models lose their temperature correctionrope_partial_rotary_and_scaling (mutant s08)
SiLU as a sigmoid, or with ege^{g} instead of e−ge^{-g}wrong MLP; or 00 instead of gug u at large gatessilu_mul_saturates_without_nan (mutants s09, s10)
embedding row at ids[t] instead of ids[t] * devery token reads a slice of row 0 or 1embedding_gathers_rows (mutant s11)
argmax with >=ties go to the last index; greedy output differs from Pythonargmax_ties_nan_and_empty (mutant s12)
argmax without the NaN checka NaN in the first place wins foreverargmax_ties_nan_and_empty (mutant s13)
returning 0 for an empty or all-NaN rowthe engine emits token 0 instead of reporting the errorargmax_ties_nan_and_empty (mutant s14)
DirectionModuleHow it uses this
Backrt.02the shared status, error, and allocator support
BackM09.5tl_rsqrtf: the 1/⋅1/\sqrt{\cdot} of RMSNorm
BackM09.6tl_expf: the e−ge^{-g} of SiLU
BackL7.1RMSNorm, the specification of tl_rmsnorm_f32
BackL7.3rope_cos_sin and apply_rope, the specification of tl_rope_f32
Forwardthe standalone Rust enginethe Rust forward calls the same six kernels in its independent Rust implementation
Your pieceProduction equivalentWhat it addsWhere to look
separate RMSNorm and addfused add + RMSNorm (vLLM fused_add_rms_norm)one pass that adds the residual and normalizes, halving memory trafficvLLM csrc/layernorm_kernels.cu
RoPE with cos per callcached cos/sin tables and fused QK rotationprecomputed tables per position, rotation fused into the QKV projection epiloguellama.cpp ggml_rope_ext; FlashInfer rope.cuh
scalar SiLUvectorized SwiGLU with a polynomial expSIMD over 8 or 16 lanesggml ggml_vec_swiglu_f32
argmaxfused sampling kernelsargmax and top-k inside the final matmul’s epilogueFlashInfer sampling.cuh