Softmax-Free Attention in JAX/Flax NNX

· 8 min read

#attention#kernels#yat#jax#flax#nnx#implementation

Part 5 of 5Attention Is a Kernel
  1. 1Attention is Explainable Because it is a Kernel
  2. 2What an MLP Knows, When It's a Kernel
  3. 3Cheap Attention: Linear-Time Kernel Approximation
  4. 4Why Attention Needs Q and K Projections
  5. 5The Kernel Between the Rolesthis post's explainer
Explainer companionThe Kernel Between the RolesWant the full intuition first? This is the runnable companion to the explainer.Read the explainer

The explainer swaps attention’s exp(q·k) for the Yat kernel and reports what a parameter-matched contest measures. This is the implementation: one attention module with two score branches, the training harness that holds everything else fixed, and the telemetry. Everything below is scripts/yat_attention.py, the script both Kaggle runs executed (quality bundle kgl_blog-yatattn-v1, telemetry bundle kgl_blog-yatattn-telem); code blocks are quoted from it.

One module, two compatibility functions

The entire experimental contrast lives inside Attention.__call__. Same projections, same heads, same causal mask, same value aggregation; the branch chooses only how a query and key become a score, and how scores become weights:

if s.variant == "softmax":
    logits = (q @ k.transpose(0, 1, 3, 2)) / math.sqrt(s.dh)
    logits = jnp.where(mask, logits, -jnp.inf)
    w = jax.nn.softmax(logits, axis=-1)
else:
    dots = q @ k.transpose(0, 1, 3, 2)                       # (B,h,T,T)
    d2 = (jnp.sum(q * q, -1, keepdims=True)
          + jnp.sum(k * k, -1, keepdims=True).swapaxes(-1, -2)
          - 2 * dots)
    b = jax.nn.softplus(s.b_raw.value)[None, :, None, None]    # per head
    eps = jax.nn.softplus(s.eps_raw.value)[None, :, None, None]
    kappa = ((dots + b) ** 2) / (jnp.maximum(d2, 0.0) + eps)   # >= 0, Mercer
    kappa = jnp.where(mask, kappa, 0.0)
    mass = kappa.sum(-1, keepdims=True)                        # (B,h,T,1)
    w = kappa / (mass + 1e-9)

The kernel scalars are stored raw and passed through a softplus, so admissibility (b,ε>0b, \varepsilon > 0) is structural rather than clipped, one pair per head, initialized at zero so the effective values start at log2\log 2:

if variant == "yat_b":
    s.b_raw = nnx.Param(jnp.zeros((heads,)))
    s.eps_raw = nnx.Param(jnp.zeros((heads,)))

Read the kernel branch against the softmax branch and notice what is missing. There is no exponential, so there is no max-subtraction trick and no shift gauge. The causal mask is 0.0, not -inf, because zero kernel mass is exclusion. And mass, the row sum that softmax divides away inside its own definition, is a first-class tensor here, kept for telemetry. The pairwise distance is assembled from the same dots the score already needed, so the kernel branch costs one extra elementwise pass over the attention matrix, not an extra matmul.

Everything around this branch is deliberately boring: the same projections, the same pre-norm GPT skeleton, the same AdamW, the same batches in the same order. Softmax counts 2,719,169 parameters and the kernel 2,719,217; the difference is exactly the forty-eight learned scalars.

The contest

Three seeds per variant, 12,000 steps, each at its swept learning rate (3e-4 softmax, 3e-3 kernel), best validation loss on held-out Shakespeare:

best-val (nats/char)bits/char
softmax1.4923 ± 0.00602.153
yat kernel (learned per-head b, eps)1.5089 ± 0.00202.177
ablation: b = 0, eps = 1 frozen1.5131 ± 0.00712.183
Validation loss curves for all six runs drawing through 12000 training steps, softmax in red and yat in blue, braiding together and settling 1.4 percent apart
All six runs’ validation losses drawing through training, from the run’s eval checkpoints. The families braid; the gap at the end is 1.4 percent. The learning rate was subsequently audited with a full per-variant sweep (see the table below): softmax’s optimum is exactly this schedule, and the kernel’s quality barely moves with the rate, so the gap survives the fairness check.

The telemetry that killed a claim

The run logs the maximum attention score each model produces on held-out batches. We expected to caption this “bounded kernel, roaming logits”. The data said otherwise, and the companion is where the numbers live:

trained score, meantrained score, max
softmax logits15 to 1834.5
yat kernel17,000 to 68,000443,508

The kernel’s scores are five orders of magnitude larger, because κ(q,q)=q4/ε\kappa(q,q) = \lVert q \rVert^4 / \varepsilon and training grows the projection norms. The lesson the explainer draws is the honest one: boundedness-for-bounded-inputs is not boundedness-under-training, and what actually matters is that a ratio of polynomials digests 4×1054 \times 10^5 without a max trick, while e34.5e^{34.5} does not exist in float32 at all.

The mass channel’s naive test is measured with a rank statistic, mass against next-token correctness:

order = np.argsort(m)
ranks = np.empty_like(order, dtype=float); ranks[order] = np.arange(len(m))
pos = ranks[c == 1]
auroc = (pos.sum() - len(pos) * (len(pos) - 1) / 2) / (len(pos) * (c == 0).sum() + 1e-9)

AUROC 0.509, 0.501, 0.508 across seeds. Null, reported as such.

The learning-rate audit

The Yat training recipe says kernel attention wants roughly three times the softmax learning rate, and the original contest ran both variants at softmax’s 3e-4. The audit sweep (bundle kgl_blog-yatattn-lrsweep, one seed per cell, full 12,000 steps each):

LRsoftmaxyat
3e-41.48381.5166
1e-31.50661.5098
3e-31.50401.5007
1e-22.58491.5092

Three seeds re-run at the kernel’s best rate (bundle kgl_blog-yatattn-fair) give 1.5163 ± 0.0023, within run-to-run noise of the published 1.5131 ± 0.0071, so the quality table stands.

The b = 0 ablation row is not just a smaller kernel, it is a different mathematical object: the explainer walks through why the universality theorem needs the bias. Empirically the ablation costs a run-noise-sized amount of quality and, more decisively, un-tames the score scale: 443,508 maximum trained score against the full kernel’s 61,221.

The learned scalars themselves are exported by the telemetry run (bundle kgl_blog-yatattn-b-telem), and they tell a per-head specialization story: b wanders from its log 2 init across [0.22, 3.40], and eps splits, most heads driving it toward zero (sharp, local kernels) while two late-layer heads grow it to 6.4 and 12.7 (smooth, near-global ones). The entropy statistics on the trained maps barely move against the ablation (mean 0.73 vs softmax’s 0.48), so the diffuseness mechanism the explainer describes is a property of the polynomial ratio, not of the frozen scalars. The column shapes are the finding: softmax needs its rate found (and dies at 1e-2), while the kernel’s entire column sits in a 1 percent band. Per-variant LR overrides and the sweep mode are in scripts/yat_attention.py (SWEEP_LRS, LR_YAT, LR_SOFTMAX env knobs).

The routing, watched

The telemetry run snapshots both models’ full attention tensors on one fixed validation window at four checkpoints. Two honest ways to look at them: through depth, and through training.

Trained attention maps for all six layers, both models side by side, causal structure changing character with depth
The trained routing through depth, layer 1 to 6, both models on the same Shakespeare window, all heads averaged. Both develop the standard anatomy, local bands early, sparser long-range columns late; six layers are six facts, so this is a grid. The kernel model’s maps are the ones computed without an exponential anywhere, and its bands are visibly fatter, the diffuseness the explainer measures as an entropy gap.

Watching a single head read is where the diffuseness difference stops being a statistic. One query position advancing through the window, its full attention row at every position:

Two attention-row traces advancing across the sequence as the query position moves, the softmax row spiking hard onto single keys while the yat row spreads its mass over several
Layer 4, one head, the attention row of each query position as the models read the window left to right, from the trained checkpoints. The softmax row (top) repeatedly spikes onto a single key; the kernel row (bottom) spreads over several. An exponential is a soft argmax; a ratio of polynomials is a genuine weighted average.
A grid of attention maps, four training checkpoints by two models, one layer and head, showing near-uniform haze at step 0 sharpening to structured routing at step 12000
One layer and head at the four telemetry checkpoints, both models: from causal haze to structure. Four snapshots are four facts, so this is a grid, not an animation.
The two score landscapes over the key plane while the query orbits: the bilinear ramp rotates rigidly while the yat kernel's bright peak travels with the query
The two compatibility functions computed live from their formulas while the query orbits the plane: the bilinear ramp only rotates, scoring direction; the kernel’s peak travels with the query, scoring direction and place at once. This is the geometry the explainer’s draggable panel lets you feel; here it is as one continuous sweep.

Every number above is from scripts/yat_attention.py on Kaggle (bundles kgl_blog-yatattn-v1 and kgl_blog-yatattn-telem); figures are rendered from the bundles by scripts/render_yatattn_gifs.py. The linear-time road, positive random features for exactly this kernel, is SLAY (arXiv 2602.04915).

Cite as

Bouhsine, T. (). Softmax-Free Attention in JAX/Flax NNX. Records of the !mmortal Data Scientist. https://tahabouhsine.com/blog/attention-is-a-compatibility-kernel-jax-flax-nnx/

BibTeX
@misc{bouhsine2026attentionisacompatibilitykerneljaxflaxnnx,
  author       = {Bouhsine, Taha},
  title        = {Softmax-Free Attention in JAX/Flax NNX},
  year         = {2026},
  month        = {jul},
  howpublished = {\url{https://tahabouhsine.com/blog/attention-is-a-compatibility-kernel-jax-flax-nnx/}},
  note         = {Blog post, Records of the !mmortal Data Scientist}
}

References

  1. Bouhsine, T., Choromanski, K., et al. (2026). SLAY: Scalable Linear Attention with Yat Kernels. arXiv:2602.04915