DeepSeek V3 in 190 lines of pure JAX

Writing this one took forever. From trying to understand the architecture on my whiteboard to asking a language model to create a 3D end-to-end working model for me to capture the nuances.

Got this idea from a couple of places: first it was the Welch Labs video on MLA, then saurabh's blog on writing Llama3 in 100 lines of pure JAX as well as Alessio's blog: Vision Transformer in pure JAX

I assume if you're reading this one then you are familiar with python and understand the basics of transformer architectures. If not then no worries i'll try my best to be as lucid as i can be. Also, and of course, this won't be a complete 671B parameter implementation of the V3 architecture but merely a 36.2M params implementation owing to the fact that i am GPU poor.

Couple of things before we dive in: I have trained the model on a MacBook Air M5 (24GB, 10-core GPU) through Apple's jax-metal plugin, which plugs Apple's GPU into JAX as a PJRT, more on this later. And secondly, i saw that at a lot of places the shakespeare dataset is used so I wanted it to be different just for the sake of it and used various books by Hammett, and numerous books from the Project Gutenberg comprising of Sherlock Holmes, Edgar Wallace, Father Brown, Anna Katharine, etc. You can build the dataset with prepare_data.py in the repo. Initially I thought of using a dataset of film scripts, but getting those legally was a bummer so i went ahead with these. Also, i used Claude Opus 5.5 to draw some of the images that are explaining the architecture.

The 207 books by size; Hammett (orange) is repeated 3× in the training split
The 207 books by size; Hammett (orange) is repeated 3× in the training split

High level overview of the Architecture

The basic skeleton of the architecture at its root is a decoder only model. You send in tokens -> they get vectorized and each token is looked up in the embedding table -> this vector will be the residual stream, every layer will read from it and add back to it. This would happen through N blocks and each block will do the same 2 steps -> Attention + FFN -> both steps would read a normalized copy of the residual stream and add their output back. And then finally the o/p head would turn the vector into a score for every word in the vocabulary.

The whole model: 36.2M parameters, 13.6M used per token
The whole model: 36.2M parameters, 13.6M used per token

Now comes the idea that makes you finally think, the places where V3 showed novelty.

1) Multi-head Latent Attention - I would highly encourage y'all to go through the Welch Labs video for this cause that is GOATED. So while generating a model caches every past token's keys and values (KV cache) and by basic logic, at long contexts that cache eats a lot of memory (yes the one which sam altman said should belong in the datacenters and not with individuals lol). What MLA does is that it compresses each token into one small latent vector and caches only that. The KV for every head are rebuilt from it whenever needed.

2) DeepSeekMOE - That is kind of self explanatory, that one big FFN becomes many small FFNs. A router just scores every expert for each token and sends tokens to the top k. That's why its mentioned that original V3 has 671B parameters but only uses 37B parameters per token.

3) Aux-loss-free balancing - See there's a nuance here as well. If we would keep the router idle for sometime, then it gets lazy in properly routing and gives few experts more tokens and then they get better and rest go by unused. So V3 just becomes a damn Robin Hood and overused experts gets their bias nudged down after every step and underused ones gets an up.

4) Multi Token Prediction - So generally we just see the next token but here we predict the token after the next one as well to give the model a push to plan a little ahead.

The other differences such that the original model is 671B/37B and my implementation is 36.2M/13.6M and og model had 7168 dims whereas we have 384 dims and etc. will be covered along with the implementation itself. You can go through the Deepseek V3 technical report if you want to go in-depth.

Now let's get started

Now why i liked (felt cool) using JAX? it looks like numpy but doesn't run line by line. When you wrap a function in jax.jit, it compiles it in place. jit: just in time. Also JAX doesn't really care if the program runs on GPU or TPU or CPU, it only needs a backend that can compile and run XLA programs for that chip.

An “XLA program” is your Python function rewritten as a plain list of array operations, with all the Python stripped out.

The model runs on a mac GPU as i mentioned earlier, all thanks to Apple's jax-metal plugin, a PJRT plugin. It translates XLA programs into Metal which is Apple's GPU API and runs them on Mac's GPU. The catch is that it only works with jax==0.4.34 cause Apple hasn't updated jax-metal in a while so newer JAX breaks it.

uv venv -p 3.12 .venv
uv pip install --python .venv/bin/python -r requirements.txt
.venv/bin/python smoke_metal.py
Python → XLA → jax-metal → the M5's GPU
Python → XLA → jax-metal → the M5's GPU

Also bf16 matmuls run ~4x faster than f32 here which i will discuss more in training

smoke_metal.py running on the M5
smoke_metal.py running on the M5

The config

I mentioned earlier that the real V3 has 671B/37B parameters whereas this one has 36.2M/13.6M. Other contrasts include that; OG had 61 layers (3+58) and ours has 6 layers (1+5); OG had 128 heads and mine has 6; you know what? i should add a comparative table instead.

DeepSeek V3 Ours
params (total / active per token) 671B / 37B 36.2M / 13.6M
layers (dense + MoE) 61 (3 + 58) 6 (1 + 5)
d_model 7168 384
attention heads 128 6
MLA query / KV latent rank 1536 / 512 192 / 128
per-head dims (nope / rope / v) 128 / 64 / 128 32 / 16 / 32
experts 1 shared + 256 routed, top-8 1 shared + 16 routed, top-4
expert hidden size 2048 256
vocab ~129K 8192
context 4K (extended to 128K) 256

overall my model is 1/18500th of V3 if you wanna look it from that perspective

from dataclasses import dataclass
import jax, jax.numpy as jnp
import optax


@dataclass(frozen=True)
class Config:
    vocab: int = 8192
    d_model: int = 384
    n_layers: int = 6
    n_dense: int = 1           # first layers use a dense FFN instead of MoE
    n_heads: int = 6
    q_rank: int = 192          # MLA: low-rank query compression
    kv_rank: int = 128         # MLA: low-rank joint key/value compression (this is what gets cached)
    d_nope: int = 32           # per-head query/key dims without RoPE
    d_rope: int = 16           # per-head query/key dims with RoPE (key part shared across heads)
    d_v: int = 32              # per-head value dim
    d_ff: int = 1024           # dense FFN hidden size
    n_shared: int = 1          # MoE: always-on shared experts
    n_routed: int = 16         # MoE: routed experts
    top_k: int = 4             # MoE: routed experts per token
    d_expert: int = 256        # MoE: hidden size of each expert
    route_scale: float = 1.0   # MoE: scale on the normalized gate weights
    gamma: float = 1e-3        # bias update speed for aux-loss-free balancing
    alpha_seq: float = 1e-4    # weight of the complementary sequence-wise balance loss
    n_mtp: int = 1             # multi-token prediction depth
    mtp_lambda: float = 0.3    # weight of the MTP loss
    seq_len: int = 256
    batch: int = 32
    rope_theta: float = 10000.0
    init_std: float = 0.02
    dtype: str = "bfloat16"    # compute dtype; weights and optimizer state stay float32


SMALL = Config(n_layers=4, n_routed=8, top_k=2, seq_len=128, batch=16)
TINY = Config(vocab=64, d_model=32, n_layers=2, n_heads=2, q_rank=16, kv_rank=8, d_nope=8, d_rope=4, d_v=8,
              d_ff=64, n_routed=4, top_k=2, d_expert=16, seq_len=16, batch=2, dtype="float32")   # tests

Initializing the weights

so pure JAX has no nn.Module or layer classes and all the model's weights are one nested dict of arrays. This is already pointed out in Saurabh's and Alessio's blog.

Couple of things that i would like to point out: there's no global seed in JAX, instead you pass a key and jax.random.split for every weight. Secondly we stack each expert weight across all 16 experts into a single 3D block - [16, 384, 256] - so the GPU can work through all of them in a single call. Also the routed experts hold about 23.6M of the 36.2M parameters which is about 2/3 of the model and most of it sits idle for any given token.

def normal(key, shape, std):
    return jax.random.normal(key, shape) * std


def init_swiglu(cfg, key, hidden):
    k1, k2, k3 = jax.random.split(key, 3)
    d, s = cfg.d_model, cfg.init_std
    return {"w1": normal(k1, (d, hidden), s), "w3": normal(k2, (d, hidden), s), "w2": normal(k3, (hidden, d), s)}


def init_mla(cfg, key):
    k = jax.random.split(key, 5)
    d, H, s = cfg.d_model, cfg.n_heads, cfg.init_std
    return {"wq_a": normal(k[0], (d, cfg.q_rank), s), "q_norm": jnp.ones(cfg.q_rank),
            "wq_b": normal(k[1], (cfg.q_rank, H * (cfg.d_nope + cfg.d_rope)), s),
            "wkv_a": normal(k[2], (d, cfg.kv_rank + cfg.d_rope), s), "kv_norm": jnp.ones(cfg.kv_rank),
            "wkv_b": normal(k[3], (cfg.kv_rank, H * (cfg.d_nope + cfg.d_v)), s),
            "wo": normal(k[4], (H * cfg.d_v, d), s)}


def init_moe(cfg, key):
    k = jax.random.split(key, 5)
    d, E, h, s = cfg.d_model, cfg.n_routed, cfg.d_expert, cfg.init_std
    return {"router": normal(k[0], (d, E), s), "shared": init_swiglu(cfg, k[1], cfg.n_shared * h),
            "experts": {"w1": normal(k[2], (E, d, h), s), "w3": normal(k[3], (E, d, h), s),
                        "w2": normal(k[4], (E, h, d), s)}}


def init_block(cfg, key, moe):
    k1, k2 = jax.random.split(key)
    ffn = {"moe": init_moe(cfg, k2)} if moe else {"ffn": init_swiglu(cfg, k2, cfg.d_ff)}
    return {"norm_attn": jnp.ones(cfg.d_model), "attn": init_mla(cfg, k1), "norm_ffn": jnp.ones(cfg.d_model), **ffn}


def init_params(cfg, key):
    ke, kh, kl, km = jax.random.split(key, 4)
    d = cfg.d_model
    layers = [init_block(cfg, k, moe=i >= cfg.n_dense) for i, k in enumerate(jax.random.split(kl, cfg.n_layers))]
    mtp = [{"norm_h": jnp.ones(d), "norm_e": jnp.ones(d), "proj": normal(k1, (2 * d, d), cfg.init_std),
            "block": init_block(cfg, k2, moe=False)}
           for k1, k2 in (jax.random.split(k) for k in jax.random.split(km, cfg.n_mtp))]
    return {"embed": normal(ke, (cfg.vocab, d), cfg.init_std), "head": normal(kh, (d, cfg.vocab), cfg.init_std),
            "norm_f": jnp.ones(d), "layers": layers, "mtp": mtp}


def n_params(params):
    return sum(x.size for x in jax.tree.leaves(params))

Tokenization + BPE

I hope we already know the need for tokenization if you've made it this far in the blog. The model only sees numbers so text needs to become ids first, but we can't give 1 id per character cause then the sequences will be very long. We also can't give 1 id per word, even though that would make the sequences shorter, cause the vocabulary has way too many words to have an id of their own + on words which will be unseen, the model would likely give <unknown>. That's why we use byte pair encoding, where common words get a single id + the rare words get broken down into smaller chunks. For e.g, according to the dataset i am using, fog, Holmes, cigarette (along with the space before them), get a single id whereas unbelievably gets broken down into un | b | elie | v | ably.

tok = Tokenizer(models.BPE())
tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
tok.decoder = decoders.ByteLevel()
tok.train_from_iterator(train, trainers.BpeTrainer(vocab_size=VOCAB, special_tokens=[DOC],
                                                   initial_alphabet=pre_tokenizers.ByteLevel.alphabet()))
tok.save(str(DATA / "tokenizer.json"))

for name, docs in [("train", train), ("val", val)]:
    ids = [[0] + e.ids for e in tok.encode_batch(docs)]
    arr = np.concatenate(ids).astype(np.uint16)
    assert arr.max() < 65536 and tok.token_to_id(DOC) == 0
    arr.tofile(DATA / f"{name}.bin")
From 207 books to training batches
From 207 books to training batches

Embedding and residual stream

We now know that the model has a number assigned for the words, but just a number can't tell you anything about the actual word so we keep a table of 8192 rows, one per token and each row has a list of 384 numbers. What we're doing here is creating a lookup table. All those numbers are the word's description and they start out randomly but the training slowly tunes them so that similar words would end up with similar numbers.

Now these 384 numbers are carried by the token through the model, and as i have told above we have 6 blocks performing Attention and FFN.

One block: read a normalized copy, add the result back
One block: read a normalized copy, add the result back

In the Attention layer - x is RMS normalized then each token looks back at the tokens before it -> then decides which ones matter the most and pulls the info from them. the resultant vector is a vector of the same size as x and that gets added to x

whereas in the FFN - the updated x is normalized again and passed through a SwiGLU FFN (in layers 1–5 this FFN is swapped for MoE, more on that later) -> the FFN works on each position seperately so that there's no mixing across tokens and the output f is added to x again: x = x + f

def block(p, bias, x, cos, sin, cfg, cache=None, pos=0):
    a, cache = mla(p["attn"], rms_norm(x, p["norm_attn"]), cos, sin, cfg, cache, pos)
    x = x + a
    h = rms_norm(x, p["norm_ffn"])
    f, aux = moe(p["moe"], bias, h, cfg) if "moe" in p else (swiglu(p["ffn"], h), None)
    return x + f, aux, cache


def forward(params, biases, tokens, cfg, caches=None, pos=0):
    cos, sin = rope(cfg, pos + jnp.arange(tokens.shape[1]))
    x, auxes, new_caches = params["embed"][tokens], [], []                # [B,T,d]
    for i, p in enumerate(params["layers"]):
        bias = biases[i - cfg.n_dense] if i >= cfg.n_dense else None     # one bias vector per MoE layer
        x, aux, c = block(p, bias, x, cos, sin, cfg, None if caches is None else caches[i], pos)
        new_caches.append(c)
        if aux is not None: auxes.append(aux)
    logits = rms_norm(x, params["norm_f"]) @ params["head"]               # [B,T,V]
    aux = {"load": jnp.stack([a["load"] for a in auxes]), "seq_loss": sum(a["seq_loss"] for a in auxes)}
    return logits, x, aux, new_caches

RMS Norm

The numbers moving through the token can grow huge or can go really tiny, and ofcourse that would affect output. So right before the token passes through the attention and FFN layer it passes through root mean square normalization where it rescales it to a standard size.

\mathrm{RMSNorm}(x) = \frac{x}{\sqrt{\frac{1}{d}\sum_{i=1}^{d} x_i^2 + \epsilon}} \odot w
def rms_norm(x, w, eps=1e-6):
    return x * jax.lax.rsqrt(jnp.mean(x * x, -1, keepdims=True) + eps) * w

RoPE

We know that attention compares tokens by multiplying their vectors but that dot product doesn't know where each token really is, in the sentence. Rotary Positional Embeddings fixes this issue by rotating the vector by an angle that depends on its position. So for e.g. there's fog and came are 1 apart in terms of position, then they would look the same whether they are at 4 and 5 or 101 and 102 i.e. when you would compare two vectors the result depends only on how far apart they are. The difference in V3 is that it rotates only 16 of each head's Q/K dims and the other 32 gets no rotation and the rotated key part is one vector shared by all 6 heads.

16 of each head's 48 query/key numbers get rotated by position
16 of each head's 48 query/key numbers get rotated by position
\theta_i = 10000^{-2i/d}, \qquad \begin{pmatrix}x_1'\\x_2'\end{pmatrix} = \begin{pmatrix}\cos m\theta_i & -\sin m\theta_i\\ \sin m\theta_i & \cos m\theta_i\end{pmatrix}\begin{pmatrix}x_1\\x_2\end{pmatrix}
def rope(cfg, positions):
    freqs = cfg.rope_theta ** -(jnp.arange(0, cfg.d_rope, 2) / cfg.d_rope)    # [d_rope/2]
    angles = positions[:, None] * freqs[None, :]                               # [T, d_rope/2]
    return jnp.cos(angles), jnp.sin(angles)


def apply_rope(x, cos, sin):
    cos, sin = (a.reshape(a.shape[0], *[1] * (x.ndim - 3), a.shape[1]) for a in (cos, sin))
    x1, x2 = jnp.split(x, 2, axis=-1)
    return jnp.concatenate([x1 * cos - x2 * sin, x1 * sin + x2 * cos], -1)

DeepSeek's novelty

Multi-head Latent Attention

This is where we get into V3's waters. You must have seen how the usual Attention architecture goes by. Every token makes a query, key and value for every head, and that is goated and was working perfectly fine. The issue was that while generating them they cache every past token's K and V for every head and every layer -> and this will be really painful when you will increase the context window.

What V3 does differently is that it caches just a small summary c of each token instead of complete K and V and then rebuild KV from that whenever needed. And that is the reason RoPE can't rotate all the keys cause it can't rotate a summary.

Cache per token per layer: 480 → 144 numbers here, 40,960 → 576 in the real V3. At a 128K context over 61 layers in bf16, that's ~655 GB vs ~9 GB.

One token through MLA: only c and the shared RoPE key are cached
One token through MLA: only c and the shared RoPE key are cached
def init_cache(cfg, B, n_layers):
    # MLA caches only the compressed latent c and the shared RoPE key, not per-head K and V
    return [{"c": jnp.zeros((B, cfg.seq_len, cfg.kv_rank)), "kr": jnp.zeros((B, cfg.seq_len, cfg.d_rope))}
            for _ in range(n_layers)]


def mla(p, x, cos, sin, cfg, cache=None, pos=0):
    B, T, _ = x.shape
    H, dn, dr, dv = cfg.n_heads, cfg.d_nope, cfg.d_rope, cfg.d_v
    # queries: d -> q_rank latent -> H heads of (nope | rope)
    q = (rms_norm(x @ p["wq_a"], p["q_norm"]) @ p["wq_b"]).reshape(B, T, H, dn + dr)   # [B,T,H,dn+dr]
    q_nope, q_rope = q[..., :dn], apply_rope(q[..., dn:], cos, sin)
    # keys/values: d -> kv_rank latent c (cached) + one RoPE key shared by all heads (cached)
    kv_a = x @ p["wkv_a"]                                                               # [B,T,kv_rank+dr]
    c, kr = rms_norm(kv_a[..., :cfg.kv_rank], p["kv_norm"]), apply_rope(kv_a[..., cfg.kv_rank:], cos, sin)
    if cache is not None:                                    # write this chunk at pos, attend over the cache
        cache = {"c": jax.lax.dynamic_update_slice(cache["c"], c, (0, pos, 0)),
                 "kr": jax.lax.dynamic_update_slice(cache["kr"], kr, (0, pos, 0))}
        c, kr = cache["c"], cache["kr"]                                                 # [B,S,kv_rank], [B,S,dr]
    S = c.shape[1]
    kv = (c @ p["wkv_b"]).reshape(B, S, H, dn + dv)          # decompress latent into per-head K_nope and V
    k_nope, v = kv[..., :dn], kv[..., dn:]                                              # [B,S,H,dn], [B,S,H,dv]
    # every head sees the same RoPE key; score = content (nope) part + position (rope) part
    q = jnp.concatenate([q_nope, q_rope], -1)                                           # [B,T,H,dn+dr]
    k = jnp.concatenate([k_nope, jnp.broadcast_to(kr[:, :, None], (B, S, H, dr))], -1)  # [B,S,H,dn+dr]
    scores = jnp.einsum("bthd,bshd->bhts", q, k) / jnp.sqrt(dn + dr)                    # [B,H,T,S]
    causal = jnp.arange(S)[None, :] <= (pos + jnp.arange(T))[:, None]                   # [T,S]
    attn = jax.nn.softmax(jnp.where(causal, scores, -jnp.inf).astype(jnp.float32), -1).astype(x.dtype)
    out = jnp.einsum("bhts,bshd->bthd", attn, v).reshape(B, T, H * dv)                  # [B,T,H*dv]
    return out @ p["wo"], cache

Mixture of Experts

Usual FFN is a single big network that every token goes through whereas MoE replaces it with many small expert networks + a router. The router looks at each token as well as scores 16 experts and routes it to the top k, 4 in this case and the token's o/p is a weighted mix of those 4. On top of that 1 shared expert sees every token and handles the common stuff so the routed experts can work on specialization.

The router's 16 scores for one token; the top 4 are picked
The router's 16 scores for one token; the top 4 are picked
s_i = \sigma(x \cdot e_i), \qquad g_i = \frac{s_i}{\sum_{j \in \text{top-}k} s_j}, \qquad y = \mathrm{FFN}_{\text{shared}}(x) + \sum_{i \in \text{top-}k} g_i \,\mathrm{FFN}_i(x)
def swiglu(p, x):
    return (jax.nn.silu(x @ p["w1"]) * (x @ p["w3"])) @ p["w2"]            # [.., d] -> [.., h] -> [.., d]


def route(w_router, bias, x, cfg):
    scores = jax.nn.sigmoid(x.astype(jnp.float32) @ w_router.astype(jnp.float32))   # [N,E] affinity, in f32
    _, idx = jax.lax.top_k(scores + jax.lax.stop_gradient(bias), cfg.top_k)   # bias decides WHO is picked...
    picked = jax.nn.one_hot(idx, cfg.n_routed).sum(-2) > 0              # [N,E]
    gated = jnp.where(picked, scores, 0.0)                              # ...but gate values use raw scores
    return (gated / gated.sum(-1, keepdims=True) * cfg.route_scale).astype(x.dtype), scores


def moe(p, bias, x, cfg):
    B, T, d = x.shape
    xf = x.reshape(B * T, d)
    gates, scores = route(p["router"], bias, xf, cfg)                   # [N,E], [N,E]
    w = p["experts"]   # every expert runs on every token; unpicked experts get gate 0 (see post)
    h = jax.nn.silu(jnp.einsum("nd,edh->neh", xf, w["w1"])) * jnp.einsum("nd,edh->neh", xf, w["w3"])
    routed = jnp.einsum("ne,ned->nd", gates, jnp.einsum("neh,ehd->ned", h, w["w2"]))   # [N,d]
    out = routed + swiglu(p["shared"], xf)
    load = (gates > 0).mean(0) / cfg.top_k                              # [E] share of routed slots
    seq = seq_balance_loss(scores.reshape(B, T, -1), (gates > 0).reshape(B, T, -1), cfg)
    return out.reshape(B, T, d), {"load": load, "seq_loss": seq}

Balancing

As mentioned previously, a router gets rather lazy when left alone for sometime. It might happen that a few experts must've been scoring well early so naturally more tokens will get routed to them and they would score even higher and then more tokens to them and so on which leads the others to starve. Usual fix is to add an auxiliary loss that would punish uneven routing but that can hurt the next word prediction quality.

V3 has no loss term instead adds a bias number to each expert where after every training step overloaded experts get their bias tuned down by 0.001, the underloaded ones gets an up + a backup loss that stops a single sequence from piling onto some of the experts.

Share of tokens per expert, with and without the bias
Share of tokens per expert, with and without the bias
b_i \leftarrow b_i + \gamma \cdot \mathrm{sign}(\bar{c} - c_i), \qquad \text{pick} = \text{top-}k(s_i + b_i), \quad \text{gate uses } s_i \text{ only}
def seq_balance_loss(scores, picked, cfg):
    # DeepSeek's tiny complementary loss: discourage any single sequence from piling onto few experts
    E, K, T = cfg.n_routed, cfg.top_k, scores.shape[1]
    f = picked.sum(1) * E / (K * T)                                     # [B,E] load, 1.0 = perfectly even
    P = (scores / scores.sum(-1, keepdims=True)).mean(1)                # [B,E] mean normalized affinity
    return cfg.alpha_seq * (f * P).sum(-1).mean()


def update_bias(biases, loads, gamma):
    # aux-loss-free balancing: after each step, nudge overloaded experts down and underloaded ones up
    return biases + gamma * jnp.sign(loads.mean(-1, keepdims=True) - loads)

Loss + MTP

Understanding loss is pretty simple -> if the unseen word gets high probability then the loss is less and vice versa. We train the model in manner that it adjusts the weights to make the loss smaller.

The usual model only learns to predict the next token whereas V3 adds a small module that enables the model to even guess the token after that which helps the model to plan a little ahead. It works like:

The MTP module predicts the token after next
The MTP module predicts the token after next

Hence the total loss becomes: loss = main + .3 * mtp + tiny sequence balance loss (we discussed earlier)

\mathcal{L} = \mathcal{L}_{\text{main}} + \lambda\,\mathcal{L}_{\text{MTP}} + \alpha\,\mathcal{L}_{\text{seq}}, \qquad \mathcal{L}_{\text{CE}} = -\frac{1}{T}\sum_t \log p(x_{t+1})
def cross_entropy(logits, targets):
    logp = jax.nn.log_softmax(logits.astype(jnp.float32), -1)
    return -jnp.take_along_axis(logp, targets[..., None], -1).mean()


def loss_fn(params, biases, tokens, cfg):
    # tokens: [B, T + 1 + n_mtp]. Main model predicts t+1; MTP module k predicts t+1+k.
    T = tokens.shape[1] - 1 - cfg.n_mtp
    logits, h, aux, _ = forward(params, biases, tokens[:, :T], cfg)
    main = cross_entropy(logits, tokens[:, 1:T + 1])
    cos, sin = rope(cfg, jnp.arange(T))
    mtp = 0.0
    for k, m in enumerate(params["mtp"], start=1):
        # combine depth k-1's hidden state at i with the embedding of token i+k, then one more block
        e = params["embed"][tokens[:, k:T + k]]                           # shared embedding
        h = jnp.concatenate([rms_norm(h, m["norm_h"]), rms_norm(e, m["norm_e"])], -1) @ m["proj"]   # [B,T,d]
        h, _, _ = block(m["block"], None, h, cos, sin, cfg)
        mtp += cross_entropy(rms_norm(h, params["norm_f"]) @ params["head"], tokens[:, k + 1:T + k + 1])  # shared head
    mtp = mtp / cfg.n_mtp
    loss = main + cfg.mtp_lambda * mtp + aux["seq_loss"]
    return loss, {"loss": loss, "main": main, "mtp": mtp, "seq": aux["seq_loss"], "load": aux["load"]}

Training

Finally we are training!!! I'll just go through a single training step and you can imagine that for 12,000 times

  1. A single batch contains 32 chunks of 256 tokens
  2. The model runs and we get the loss
  3. Use jax.value_and_grad to see through how each weight should change so that loss can be minimized
  4. update the weights with AdamW optimizer
  5. and finally update the expert biases for load balancing
One training step: 8,192 tokens, ~0.7 s
One training step: 8,192 tokens, ~0.7 s

And as explained nicely in the other blogs (Saurabh's and Alessio's), @jax.jit decorator makes the whole step compiled once into a single XLA program and then that's reused for every step. Plus there's no .backward() in JAX, the gradient is just a function you call.

Coming to using bf16 which i talked about much earlier: the weights are stored as float32 but cast to bf16 for forward and backward passes so the matmuls run at bf16 speed instead and the small updates are still added in float32

bf16 matmuls: 11.10 vs 2.76 TFLOP/s, about 4x
bf16 matmuls: 11.10 vs 2.76 TFLOP/s, about 4x
def make_train_step(cfg, optimizer):
    cast = lambda p: jax.tree.map(lambda a: a.astype(cfg.dtype), p)      # mixed precision: f32 master weights

    @jax.jit
    def train_step(params, opt_state, biases, batch):
        loss = lambda p: loss_fn(cast(p), biases, batch, cfg)
        (_, metrics), grads = jax.value_and_grad(loss, has_aux=True)(params)
        updates, opt_state = optimizer.update(grads, opt_state, params)
        params = optax.apply_updates(params, updates)
        return params, opt_state, update_bias(biases, metrics["load"], cfg.gamma), metrics
    return train_step
# train.py: AdamW with gradient clipping, 200 warmup steps, cosine decay to 6e-5
sched = optax.warmup_cosine_decay_schedule(0.0, 6e-4, 200, 12000, 6e-5)
opt = optax.chain(optax.clip_by_global_norm(1.0), optax.adamw(sched, b1=0.9, b2=0.95, weight_decay=0.1))
The learning rate over the 12,000 steps
The learning rate over the 12,000 steps
Tokens per second over the run; median ~10.7k
Tokens per second over the run; median ~10.7k

Generation and sampling

So now the model only does one thing: you give it some tokens and it scores every possible next token. Within that step is Prefill: which runs the complete prompt at once which fills the MLA cache with c and kr(the shared RoPE key) for every prompt token and Decode: which feeds in only the newest token and reuses the cache for everything before it and repeats. This covers how the generation happens.

Prefill once, then decode one token at a time from the cache
Prefill once, then decode one token at a time from the cache

Coming to sampling: we are taking temperature=0.8with top-k 50 best tokens and picking randomly among them.

The model's top guesses after "The fog came in off the"
The model's top guesses after "The fog came in off the"
_forward = jax.jit(forward, static_argnames="cfg")

def sample_token(logits, key, temperature, top_k):
    if temperature == 0:
        return logits.argmax(-1)
    kth = jax.lax.top_k(logits, top_k)[0][:, -1:]                      # keep only the top_k logits
    return jax.random.categorical(key, jnp.where(logits < kth, -jnp.inf, logits) / temperature)

def generate(params, biases, prompt, n_new, cfg, key, temperature=0.8, top_k=50):
    B, P = prompt.shape
    if P + n_new > cfg.seq_len:
        raise ValueError(f"prompt ({P}) + new tokens ({n_new}) exceeds seq_len ({cfg.seq_len})")
    logits, _, _, caches = _forward(params, biases, prompt, cfg, init_cache(cfg, B, cfg.n_layers), 0)   # prefill
    out = [prompt]
    for i in range(n_new):                                              # then one token at a time
        key, sub = jax.random.split(key)
        out.append(sample_token(logits[:, -1], sub, temperature, top_k)[:, None])
        if i < n_new - 1:
            logits, _, _, caches = _forward(params, biases, out[-1], cfg, caches, P + i)
    return jnp.concatenate(out, 1)

Results

So finally we made it to results!!!

Data 207 books, 21.7M training tokens (Hammett repeated 3×); val is held-out crime fiction
Training 12,000 steps, ~98M tokens (~4.5 epochs), ~2.6 h
Speed ~10.7k tokens/s (median)
Val loss 5.02 (step 250) → 3.51
Train loss 2.81
MTP val loss 3.68
Expert load, with bias 5.7–6.9% per expert, even is 6.25% (step 2000)
Expert load, without bias 0.09–18.6% per expert (step 2000)
MLA cache 144 numbers per token per layer, vs 480 for normal attention
Train and val loss over the 12,000 steps
Train and val loss over the 12,000 steps
Three samples for the prompt "The fog came in off the" (temperature 0.8, top-k 50)
Three samples for the prompt "The fog came in off the" (temperature 0.8, top-k 50)

I believe i need more data than more steps to make it even better as its starting to memorize also it writes like a detective fiction sorta manner but ofcourse the story wouldn't hold together + the experts went from 0.09–18.6% to 5.7–6.9% and loss barely moved which really was DeepSeek's point.

Would love to have more feedback on what i could do better here as well as if i did anything wrong (X / email)! Most prolly i'll be moving to inference or mech interp now and exploring those!

Thanks for making it this far, hope you liked what you read :)