Configuration
Show Removed Lines

LLM speedrun: Architecture

Speedrun LLM architecture by reconstructing Qwen2.5-Coder-7B-Instruct model from scratch - first of three in the "LLM speedrun" series.

AI Use
This entire article is written by me without any kind of text generated from AI. None of the python code is designed by AI either. I used AI to proofread the article to highlight gaps, and to translate the python code to Rust.
Audience
This blog series is me publishing my personal notes on the state of LLMs and scalably serving them. I’ll discuss the motivation for ideas specific to LLMs / transformers, but not the building blocks behind machine learning and language modeling. To that end, I expect the reader to understand these topics: normalization, standard feed forward networks, LSTMs and basics of linear algebra. To follow along in code, I expect you to either be comfortable with Python’s numpy or with Rust’s rayon crate (I actually familiarized myself with rayon while writing this blog).
Running code

Most sections have code attached with them that you can copy in your local and run directly. The full Qwen implementation is available in harness section.

For python, use:

uv run <file.py> <args>

For rust, use:

cargo +nightly -Zscript <file.rs> -- <args>

This is a first of three part series, deep diving into individual parts of LLMs, starting with architecture. The other 2 planned posts are: inference, and training.

Table of Contents

A note on notation:

  • All scalers are represented as 1×11 \times 1 matrix.
  • All vectors are represented as column vectors, like dembed×1d_{embed} \times 1.
  • A matrix of XX vectors is represented as XX concatenated column vectors, like dembed×Xd_{embed} \times X.
  • ×\times represents a matrix multiplication, while \odot represents an element wise multiplication.

Overview

For a while, one popular way to model language has been to predict the next token.

That is the premise behind RNNs or their most successful derivative, LSTMs. But these models require all previous tokens to be processed before processing the current one. This makes the training process inherently serial.

This was a huge bottleneck as we couldn’t train over a large set of corpus (and apparently we needed to train over sum of all human knowledge for models to be able to speak fluently).

The transformers architecture solved that problem, with the core mechanism of “attention,” which was introduced in a 2014 paper. Folks at Google looked at it and said “maybe… maybe that’s all you need?” leading to the “Attention is all you need” paper in 2017 --- unironically for machine translation --- paving the way to parallelize the computation of language models.

Transformer architecture

Setting up the inputs

Similar to RNNs, the input text is converted into learned embeddings. Concretely, given a sequence of length TT tokens, the embedding for the sequence is represented by a matrix of . But what about the position? In RNNs the position is automatic, as the tokens are processed one at a time but that is not the case with transformers where each token is processed in a position-independent way. So, position is encoded as an embedding as well which is added to the embedding of a token (modern LLMs do not add positions, rather use “Rotary Position Embedding”).

Attention

Attention mechanism entails that each token asks a query “how relevant are you to me” to every token (including itself) that answers using its key. Then the weighted some of values of all tokens is the final value of the querying token. The inputs pass through the transformer block which is made up of many attention layers (as anything in deep learning).

In RNNs, the single state vector carries the information of every text that has been seen by the model up to a point (and it can be arbitrarily large). Attention allows each token to look back at all the past tokens, hence allowing it to change its context encoding dynamically based on its needs. So, being able to train in parallel and being able to look back allowed attention mechanism to be extremely powerful in representing language.

Concretely, each token (of size ) has a meaning in some high dimensional space called a “value” space (of size ). Now each token wants to know how much other tokens matter to it (and other tokens will also ask the same of it). Or stated otherwise, how much of “value” of token xx should affect a token yy ().

How to figure that out? What if each token asks a “query” to every other token, and all those other tokens provide a “key” which tells how relevant it is to the query token. This means that each token has 3 high dimensional representation , which is calculated by 3 matrices .

Now the is transformed into 3 other sequence matrices:

K=WK×IK = W^K \times I Q=WQ×IQ = W^Q \times I V=WV×IV = W^V \times I

And each key query pair is resolved as:

Attentionweights=Softmax(KT×Qdmodel)Attention^{weights} = Softmax(\frac{K^T \times Q}{\sqrt{d_{model}}})

Size of AttentionweightsAttention^{weights} is T×TT \times T noting the attention each token is paying to every other token.

Finally the output of attention is attention score weighted sum of values:

Attentionoutput=V×AttentionweightsAttention^{output} = V \times Attention^{weights}
QWhat's up with Softmax?
A

What’s the technique to output probabilistic contribution of a bunch of things based on some unbounded weights? You got it - Softmax.

QThen what's up with dmodel\sqrt{d_{model}}?
A

That’s just normalization to make sure that softmax does not misbehave and its variance is 1.0.

Every token with every other token, even the future ones?

I am no alien from Arrival, I read sequentially

We do look at things in parallel too. We do not read each pixel of an sequentially. So, looking into the future has its advantages.

But for language that’s not the case. So a makes sure a token does not attend to future tokens.

Mij={0if ijif i>jM_{ij} = \begin{cases} 0 & \text{if } i \le j \\ -\infty & \text{if } i \gt j \end{cases} Attentionweights=Softmax(KT×Qdmodel+M)Attention^{weights} = Softmax(\frac{K^T \times Q}{\sqrt{d_{model}}} + M)

where ii is the row index and .

N
I am going to omit this causal mask in future equations to keep math representations terse.

But how does it remember things?

Attention is not the end of things for a transformer, which also has a classic fully connected layer, a feed-forward network. This layer is huge, typically having 4dmodel4 \cdot d_{model} the . This is also the bulk of the weights in an LLM (generally accounting for 70% of it). Modern LLMs use gated activations rather than ReLU, that too typically SwiGLU.

Output=Wdown×((Wup×Attentionoutput)Swish(Wgate×Attentionoutput))Output = W_{down} \times ((W_{up} \times Attention^{output}) \odot Swish(W_{gate} \times Attention^{output}))

This FFN layer is considered to be the “brain” of an LLM, however, interpretability of ML models is an active area of research. So Google around (ironic in an LLM blog).

Stacking

The output from one layer of transfomer is passed to the next one. One can stack as many of these layers as one wants (and have the capacity to train, something called scaling laws that I’ll write about in the training part of the series).

You might notice one discrepancy though. The input to the first layer is of size dembed×Td_{embed} \times T, while the output is of size dmodel×Td_{model} \times T. So either the rest of the layers need to take input of dmodeld_{model} size or dembedd_{embed} is set equal to dmodeld_{model} and generally the latter is chosen but not for just this reason.

N
Notice how setting dembed=dmodeld_{embed} = d_{model} allows arbitrary stacking of the same layer. This is very powerful as this makes making deep networks trivial, all the while retaining the parallel training benefits of each individual layer.

Gradients be vanishing

or activations exploding

Notice that softmax is a part of the attention layer and FFN will also include an activation function. This makes it very hard for the or makes as multiple transformer layers are stacked. So transformer borrows the same idea from “ResNet” and uses residual connections.

In transformer, residual connection is added at two places: After calculating AttentionoutputAttention^{output} and at the final output location OutputOutput. Mathematically:

Intermediate=I+AttentionoutputIntermediate = I + Attention^{output} Output=I+FFN(Intermediate)Output = I + FFN(Intermediate)

This does require dmodel=dembedd_{model} = d_{embed}

NNote on residual impact
With residual flow, the intent of the entire network has changed. Each layer of transformer now acts as a perturbation on the initial input. Conceptually, initial layers will act as a larger perturbation (the network is trying to figure out the language specifics) and the later layers as smaller perturbations (the network is finetuning the representation of the larger image). Later, I’ll discuss how this is supported mathematically.

Normalization

Like with any model created after 2015, networks need to normalize, with the basic idea being the same: network learns better when the distribution of inputs to each layer is constrained and well defined. Otherwise in a deep network, just after a few matrix multipliers we can reach really . Transformer contains:

  • Residual connections, famous for leading to explosion in activations.
  • Activation output depending on softmax where large inputs can lead to spiky outputs and hence poor cross token attention outputs.

Normalization is applied to AttentionoutputAttention^{output} and to either input or output of a transformer layer. For the latter, where normalization is applied matters and is known as Pre-Norm v/s Post-Norm.

Why pre-norm over post-norm?

Take a look at the output from a post-norm model:

Outputl=RMSNorm(Il+F(Il))Output_l = RMSNorm(I_l + F(I_l))

and compare this with the output from pre-norm model:

Outputl=Il+F(RMSNorm(Il))Output_l = I_l + F(RMSNorm(I_l))

Pre norm has two benefits:

  • The input residual is unobstructed and the gradients flow back nicely, without subjected to normalization pass.
  • This naturally leads to small perturbations from the latter layers as compared to initial layers. The input to a layer is normalized so F(RMSNorm(Il))Il\frac{F(RMSNorm(I_l))}{I_l} gets smaller and smaller for deeper layers.

Multi Head Attention (MHA)

Increasing efficiency

Attention mechanism represents each token as three dmodeld_{model} sized vectors. The tokens then figure out the relationship between each other. But what kind of relationship? Are those tokens going to tell what “it” refers to from the rest of the sentence? Or are they going to talk about the grammar of the sentence? Or maybe they are trying to understand a single word which was not in the dictionary and hence got split into multiple single character tokens.

Having a single large attention matrix makes it difficult for the model to reason about multiple things in parallel. Empirical evidence also shows that each attention is trying to reason about one specific stuff, so having such a large dmodeld_{model} representation is a waste, where a low-rank representation will do.

So, rather than computing a single large attention, in MHA, attention is split into multiple smaller representations. Concretely, there are hh attention heads, each with internal representation of size dk=dmodelhd_k = \frac{d_{model}}{h}. The output of each head is concatenated in the end to recover dmodeld_{model} sized output vector of the attention layer.

N
Note how this further aids parallel execution of a transformer block, none of the attention heads are dependent upon each other.

In equations (where ii represents a specific attention head):

WiK,WiQ,WiVRdk×dmodelW_i^K, W_i^Q, W_i^V \in \mathbb{R}^{d_k \times d_{model}} Ki=WiK×I,Qi=WiQ×I,Vi=WiV×IK_i = W^K_i \times I, Q_i = W^Q_i \times I, V_i = W^V_i \times I Attentionioutput=Vi×Softmax(KiT×Qidk)Attention^{output}_i = V_i \times Softmax(\frac{K_i^T \times Q_i}{\sqrt{d_k}}) Attentionoutput=[Attention0outputAttention1outputAttentionh1output]Attention^{output} = \begin{bmatrix} Attention^{output}_0 \\ Attention^{output}_1 \\ \vdots \\ Attention^{output}_{h-1} \end{bmatrix}

Output matrix

Notice that now the attention output is now poorly represented in the residual connection:

Intermediate=I+AttentionoutputIntermediate = I + Attention^{output} Intermediate=I+[Attention0outputAttention1outputAttentionh1output]Intermediate = I + \begin{bmatrix} Attention^{output}_0 \\ Attention^{output}_1 \\ \vdots \\ Attention^{output}_{h-1} \end{bmatrix}

Each attention head can only affect a subset of the residual. And hence the need for an (denoted by WOW^O) which mixes up information from all attention heads.

Intermediate=I+WO×AttentionoutputIntermediate = I + W^O \times Attention^{output}
QWhy not keep the VV still in dmodel×Td_{model} \times T
A
That’s a valid representation, but that’s unnecessary heavy computation per head now and the whole point of MHA is to increase efficiency of each attention layer.
QWhy not merge WOW^O with each head's WVW^V as two transformations: WiO×WiV×IW^O_i \times W^V_i \times I?
A
That’s also a valid representation, but now the number of matrix multiplication has increased from 11 to hh and the storage requirement per attention head for VV also increases from dk×Td_k \times T to dmodel×Td_{model} \times T. This low-rank representation and post output matrix multiplication helps with both storage and compute.

Finishing up transformer

Transformer output

The output from the is just the last token’s final representation. The expectation is that this token now includes all the context the next stage needs. Using pre-norm requires that the output of the stacked transformer be normalized before it is fed further.

Otransformer=RMSNorm(Ostacked)×[001]O_{transformer} = RMSNorm(O_{stacked}) \times \begin{bmatrix} 0 \\ \vdots \\ 0 \\ 1 \end{bmatrix}

Summed up

So the overall equation for one transformer layer looks like:

Intermediatel=Il+WO×Attentionoutput(RMSNorm(Il))Intermediate_l = I_l + W^O \times Attention^{output}(RMSNorm(I_l)) Outputl=Intermediatel+FFN(RMSNorm(Intermediatel))Output_l = Intermediate_l + FFN(RMSNorm(Intermediate_l))

Where I1=II_1 = I and Il+1=OutputlI_{l+1} = Output_l. After stacking LL layers, the output of layer LL (OutputLOutput_L) is normalized to produce the final transformer vector:

Otransformer=RMSNorm(OutputL)×[001]O_{transformer} = RMSNorm(Output_L) \times \begin{bmatrix} 0 \\ \vdots \\ 0 \\ 1 \end{bmatrix}
MHA matmaul
In code, we are looping over all the attention heads and calculating one at a time, but we can also represent all the heads as a single matrix of size h×dmodel×dmodelh \times d_{model} \times d_{model}.

Contemporay modifications

Grouped Query Attention (GQA)

Storing and retrieving KV cache is costly and we want to reduce that and make it tenable to serve the model on a single GPU. In GQA, multiple attention heads share the same KV matrices (called “KV heads”). The precursor to this was “Multi Query Attention” which forced all heads to share a single KV, but that decreased accuracy considerably. Whereas GQA maintained the accuracy while decreasing the memory requirements (savings depends on the ratio KV_headsQuery_heads\frac{KV\_heads}{Query\_heads}, but anywhere from 50% to 90%).

But why does this work? Each head is still asking a different query, but the high dimensional space of KV is equipped to answer those multiple queries. During training, the model learns to superimpose multiple concepts to same KV.

Rotary Positional Embeddings (RoPE)

Previously, I mentioned that position embeddings can be learned and simply added to the token embeddings. But this is suboptimal for multiple reasons:

  • That position additive term does not cancel itself in the KT×QK^T \times Q product. In fact, it produces an additional term which is dependent on the absolute location. Whereas attention’s requirement is to know the relative distance between tokens.
  • It has a fixed length, so positional embeddings are constrained by the training set size and does not generalize to longer sequences natively.
  • Storing positional embeddings for longer sequences waste quite a lot of memory (hence storage, bandwidth etc etc).
N
The original transformer paper uses sinusoidal position embeddings to solve some of these problems. That actually boils down to just a rotation in a constrained way (check "You could have designed state of the art positional encoding" for details).

The question boils down to “how to encode the absolute position for each token which when seen by attention layer acts as a relative distance between attending tokens”. One elegant way is the rotation in complex plane. Constraining in two-dimension, dot product of two complex numbers is represented by v1v2cos(θ)\lVert \mathbf{v_1} \rVert \lVert \mathbf{v_2} \rVert cos(\theta) where θ\theta represents the angular distance between the vectors. If each vector encodes a token’s absolute position, this naturally encodes the relative position of tokens in dot product as cos(θ1θ2)cos({\theta}_1 - {\theta}_2). Now choosing a fixed rotation frequency and mm and nn representing the absolute position of those vectors, that term becomes cos((mn)θ)cos((m - n) \theta).

It is much easier to represent rotation in two dimensional space, than in higher dimensions where the freedom of rotation explodes. So to represent this rotation in embedding space, each pair of the embedding is thought to represent a complex number. Mathematically, embedding is transformed from a high dimensional space of dkd_k real numbers into a .

For a 2D pair at position mm, the rotation by angle mθm\theta is:

Rθ,m[xy]=[cos(mθ)sin(mθ)sin(mθ)cos(mθ)][xy]R_{\theta, m} \begin{bmatrix} x \\ y \end{bmatrix} = \begin{bmatrix} \cos(m\theta) & -\sin(m\theta) \\ \sin(m\theta) & \cos(m\theta) \end{bmatrix} \begin{bmatrix} x \\ y \end{bmatrix}

Extending this to the full dkd_k-dimensional embedding vector x\mathbf{x}, the block-diagonal rotation matrix Rθ,mR_{\theta, m} is defined as:

Rθ,mx=[cos(mθ)sin(mθ)00sin(mθ)cos(mθ)0000cos(mθ)sin(mθ)00sin(mθ)cos(mθ)][x1x2xdk1xdk]R_{\theta, m} \mathbf{x} = \begin{bmatrix} \cos(m\theta) & -\sin(m\theta) & \dots & 0 & 0 \\ \sin(m\theta) & \cos(m\theta) & \dots & 0 & 0 \\ \vdots & \vdots & \ddots & \vdots & \vdots \\ 0 & 0 & \dots & \cos(m\theta) & -\sin(m\theta) \\ 0 & 0 & \dots & \sin(m\theta) & \cos(m\theta) \end{bmatrix} \begin{bmatrix} x_1 \\ x_2 \\ \vdots \\ x_{d_k-1} \\ x_{d_k} \end{bmatrix}

What should be the value of θ\theta, i.e. what is the optimal rotation frequency?

  • If we choose a very fast moving frequency θfast\theta_{fast}, then the dot product will start . Like for θ=45\theta = 45^\circ, cos((102)θ)=cos((22)θ)cos((10 - 2) \theta) = cos((2-2) \theta), basically treating the token at 8 positions away as same as attending to self token.
  • If we choose a very slow moving frequency θslow\theta_{slow}, then the dot product will look . Like for θ=0.0001\theta = 0.0001^\circ, cos((32)θ)cos((22)θ)cos((3 - 2) \theta) \approx cos((2 - 2) \theta), basically failing to distinguish between nearby tokens.

A single θ\theta choice fails to capture everything. So, RoPE uses pairwise θ\theta encoded based on a decaying procedure:

θi=b(i1)dk/2\theta_i = b^{\frac{-(i - 1)}{d_k / 2}}

We use a separate θ\theta for each complex number and bb is the base which controls how large our context window can be before the rotations start to repeat. The original transformer paper used b=10000b = 10000 but modern LLMs, with their million long context window, use much larger base.

Mathematically this looks like:

RΘ,mx=[cos(mθ1)sin(mθ1)00sin(mθ1)cos(mθ1)000000000000cos(mθdk/2)sin(mθdk/2)00sin(mθdk/2)cos(mθdk/2)][x1x2x3x4xdk1xdk]R_{\Theta, m} \mathbf{x} = \begin{bmatrix} \cos(m\theta_1) & -\sin(m\theta_1) & \dots & 0 & 0 \\ \sin(m\theta_1) & \cos(m\theta_1) & \dots & 0 & 0 \\ 0 & 0 & \dots & 0 & 0 \\ 0 & 0 & \dots & 0 & 0 \\ \vdots & \vdots & \ddots & \vdots & \vdots \\ 0 & 0 & \dots & \cos(m\theta_{d_k/2}) & -\sin(m\theta_{d_k/2}) \\ 0 & 0 & \dots & \sin(m\theta_{d_k/2}) & \cos(m\theta_{d_k/2}) \end{bmatrix} \begin{bmatrix} x_1 \\ x_2 \\ x_3 \\ x_4 \\ \vdots \\ x_{d_k-1} \\ x_{d_k} \end{bmatrix}

where the frequencies are θi=100002(i1)/dk\theta_i = 10000^{-2(i-1)/d_k}.

Generalizing over the input sequence II:

positional_embedding=RΘ,m×Ipositional\_embedding = R_{\Theta,m} \times I
N
I also view RoPE acting as a forcing function to allow attention to learn important concepts of human language. If you look at a pronoun, its context is in nearby words only. Similarly this rotation acts as forcing function telling the attention layer if it is looking for such language concepts, it should force them to be in the earlier part of the embeddings.
N
For a more mathematically rigorous handling of RoPE, check “Rotary Embeddings: A Relative Revolution”.

Decoding

But where is the output of the model?

We expect that the of the last transformer layer encodes , let’s call it OtransformerO_{transformer}. A (called “LM Head”) inverts that to a logits over the dictionary. So the first token is:

T1=Sample(Softmax(Otransformer×LM_Head))T_1 = Sample(Softmax(O_{transformer} \times LM\_Head))
N
LM head can just be the inverse of the embedding as well. That will tie the encoding and decoding weights and saves parameters of the model in one of the heavier parts.

Now this procedure can be repeated as many times as needed. Append the latest generated token to the input, and go through the entire process again to generate the next token and so on and so on (this is what is happening when you talk to your AI bot, it is inherently a linear process of generating tokens.) Obviously, we are not going to calculate the KQVKQV for all the tokens that we have already processed. In fact, their QQ is not even required, just the KVKV to calculate how the new token is impacted by those past tokens. So, we just store that KK and VV and that is the “KV cache”.

Implementing Qwen

Now that I have introduced all the building blocks of transformer and LLMs, let’s modify our implementation to specifics of “Qwen2.5-Coder-7B-Instruct” model and generate some text.

Introducing bias

Modern LLMs are actually bias free, because the inputs are normalized at all the stages and empirical evidence has shown that bias does not improve the speed of convergence of the model. But Qwen uses bias so we should add that to be able to run this model.

Split-half RoPE implementation

As I described, RoPE was implemented pairwise over dkd_k. But Qwen uses split-half implementation, where the hidden state is not considered pairwise, rather split in the middle. The real part of the complex number belongs to the first half and the imaginary part to the second half. This , just how the matrix is setup. The four components of the original rotation are split in four quadrants now when setting up the RoPE matrix.

RΘ,mx=[cos(mθ1)00sin(mθ1)000cos(mθ2)00sin(mθ2)000cos(mθdk/2)00sin(mθdk/2)sin(mθ1)00cos(mθ1)000sin(mθ2)00cos(mθ2)000sin(mθdk/2)00cos(mθdk/2)][x1x2xdk/2xdk/2+1xdk/2+2xdk]R_{\Theta, m} \mathbf{x} = \begin{bmatrix} \cos(m\theta_1) & 0 & \dots & 0 & -\sin(m\theta_1) & 0 & \dots & 0 \\ 0 & \cos(m\theta_2) & \dots & 0 & 0 & -\sin(m\theta_2) & \dots & 0 \\ \vdots & \vdots & \ddots & \vdots & \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \dots & \cos(m\theta_{d_k/2}) & 0 & 0 & \dots & -\sin(m\theta_{d_k/2}) \\ \sin(m\theta_1) & 0 & \dots & 0 & \cos(m\theta_1) & 0 & \dots & 0 \\ 0 & \sin(m\theta_2) & \dots & 0 & 0 & \cos(m\theta_2) & \dots & 0 \\ \vdots & \vdots & \ddots & \vdots & \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \dots & \sin(m\theta_{d_k/2}) & 0 & 0 & \dots & \cos(m\theta_{d_k/2}) \end{bmatrix} \begin{bmatrix} x_1 \\ x_2 \\ \vdots \\ x_{d_k/2} \\ x_{d_k/2 + 1} \\ x_{d_k/2 + 2} \\ \vdots \\ x_{d_k} \end{bmatrix}

Plugging in the specific parameters

These are the specifics of our Qwen model:

  • Number of layers (LL): 2828.
  • RoPE base (bb): 1,000,0001,000,000.
  • Hidden size (dembed=dmodeld_{embed} = d_{model}): 35843584.
  • Attention heads (hh): 2828.
  • KV heads: 44.
  • Head dimension (dkd_k): .
  • FFN size (dffd_{ff}): .

Harness

A few more things to get Qwen to generate tokens:

  • Tokenization
  • Loading the right weights
  • Setting up prompt
  • Setting up end of stream token
Copy it and run it on your local - Qwen implementation in numpy
1# /// script
2# requires-python = ">=3.14"
3# dependencies = [
4# "huggingface-hub>=1.27.0",
5# "jinja2>=3.1.6",
6# "numpy>=2.5.2",
7# "psutil>=7.2.2",
8# "transformers>=5.15.0",
9# ]
10# ///
11
12import argparse
13from dataclasses import dataclass
14import json
15import os
16import struct
17import time
18import numpy as np
19import numpy.typing as npt
20import psutil
21from huggingface_hub import snapshot_download
22from transformers import AutoTokenizer
23from typing import Annotated
24
25EmbedxCtx = Annotated[npt.NDArray[np.float32], "shape=(d_embed, max_context)"]
26EmbedxDict = Annotated[npt.NDArray[np.float32], "shape=(d_embed, dict_size)"]
27ModelxEmbed = Annotated[npt.NDArray[np.float32], "shape=(d_model, d_embed)"]
28EmbedxTokens = Annotated[npt.NDArray[np.float32], "shape=(d_embed, T)"]
29ModelxTokens = Annotated[npt.NDArray[np.float32], "shape=(d_model, T)"]
30ModelxModel = Annotated[npt.NDArray[np.float32], "shape=(d_model, d_model)"]
31FFNxModel = Annotated[npt.NDArray[np.float32], "shape=(d_ff, d_model)"]
32ModelxFFN = Annotated[npt.NDArray[np.float32], "shape=(d_model, d_ff)"]
33KVxModel = Annotated[npt.NDArray[np.float32], "shape=(num_kv_heads * d_k, d_model)"]
34
35
36def get_ram_mb() -> float:
37 process = psutil.Process(os.getpid())
38 return process.memory_info().rss / (1024 * 1024)
39
40
41class WeightContext:
42 def __init__(self, loader: "MemoryEfficientSafetensorsLoader"):
43 self.loader = loader
44 self.loaded_tensors: list[np.ndarray] = []
45
46 def load(self, name: str) -> np.ndarray:
47 arr = self.loader.load_tensor(name)
48 self.loaded_tensors.append(arr)
49 return arr
50
51 def __enter__(self):
52 return self
53
54 def __exit__(self, exc_type, exc_val, exc_tb):
55 self.loaded_tensors.clear()
56
57
58class MemoryEfficientSafetensorsLoader:
59 def __init__(self, repo_id: str = "Qwen/Qwen2.5-Coder-7B-Instruct"):
60 print("Checking / downloading model weight shards from HuggingFace Hub...")
61 self.model_dir = snapshot_download(
62 repo_id=repo_id, allow_patterns=["*.safetensors", "*.json"]
63 )
64
65 index_path = os.path.join(self.model_dir, "model.safetensors.index.json")
66 with open(index_path, "r") as f:
67 index = json.load(f)
68 self.weight_map = index["weight_map"]
69
70 self.file_headers = {}
71 shard_files = set(self.weight_map.values())
72 for shard in shard_files:
73 shard_path = os.path.join(self.model_dir, shard)
74 with open(shard_path, "rb") as f:
75 header_len = struct.unpack("<Q", f.read(8))[0]
76 header_json = f.read(header_len).decode("utf-8")
77 header = json.loads(header_json)
78 self.file_headers[shard] = {"header_len": header_len, "header": header}
79
80 def load_tensor(self, name: str) -> np.ndarray:
81 shard = self.weight_map[name]
82 shard_path = os.path.join(self.model_dir, shard)
83 info = self.file_headers[shard]
84 meta = info["header"][name]
85
86 header_len = info["header_len"]
87 start, end = meta["data_offsets"]
88 shape = meta["shape"]
89 dtype_str = meta["dtype"]
90
91 with open(shard_path, "rb") as f:
92 f.seek(8 + header_len + start)
93 raw_bytes = f.read(end - start)
94
95 if dtype_str == "BF16":
96 u16 = np.frombuffer(raw_bytes, dtype=np.uint16)
97 arr = (u16.astype(np.uint32) << 16).view(np.float32)
98 elif dtype_str == "F32":
99 arr = np.frombuffer(raw_bytes, dtype=np.float32)
100 elif dtype_str == "F16":
101 arr = np.frombuffer(raw_bytes, dtype=np.float16).astype(np.float32)
102 else:
103 raise ValueError(f"Unsupported tensor dtype: {dtype_str}")
104
105 return arr.reshape(shape)
106
107 def load_scope(self) -> WeightContext:
108 return WeightContext(self)
109
110
111@dataclass
112class Qwen2_5_Coder_7B_Config:
113 num_layers: int = 28
114 hidden_size: int = 3584
115 num_heads: int = 28
116 num_kv_heads: int = 4
117 head_dim: int = 128
118 intermediate_size: int = 18944
119 vocab_size: int = 152064
120 rope_theta: float = 1000000.0
121 rms_norm_eps: float = 1e-6
122
123
124class Embedding:
125 def __init__(self, w_embed: EmbedxDict):
126 self.w_embed = w_embed
127
128 def embed(self, token_ids: list[int]) -> EmbedxTokens:
129 return self.w_embed[token_ids].T
130
131
132class QwenRotaryEmbedding:
133 def __init__(self, head_dim: int, base: float = 1000000.0):
134 self.head_dim = head_dim
135 self.base = base
136 inv_freq = 1.0 / (base ** (np.arange(0, head_dim, 2, dtype=np.float32) / head_dim))
137 self.inv_freq = inv_freq
138
139 def apply(self, x: np.ndarray, positions: np.ndarray) -> np.ndarray:
140 freqs = np.outer(self.inv_freq, positions)
141 emb = np.concatenate([freqs, freqs], axis=0)
142 cos = np.cos(emb)[None, :, :]
143 sin = np.sin(emb)[None, :, :]
144
145 half = self.head_dim // 2
146 x1 = x[:, :half, :]
147 x2 = x[:, half:, :]
148 rotate_half = np.concatenate([-x2, x1], axis=1)
149 return (x * cos) + (rotate_half * sin)
150
151
152def softmax(x: np.ndarray, axis: int = 0) -> np.ndarray:
153 x_max = np.max(x, axis=axis, keepdims=True)
154 exp_x = np.exp(x - x_max)
155 return exp_x / np.sum(exp_x, axis=axis, keepdims=True)
156
157
158class KVCache:
159 def __init__(self):
160 self.k_cache: dict[int, np.ndarray] = {}
161 self.v_cache: dict[int, np.ndarray] = {}
162
163 def update(
164 self, layer_id: int, new_k: np.ndarray, new_v: np.ndarray
165 ) -> tuple[np.ndarray, np.ndarray]:
166 if layer_id not in self.k_cache:
167 self.k_cache[layer_id] = new_k
168 self.v_cache[layer_id] = new_v
169 else:
170 self.k_cache[layer_id] = np.concatenate([self.k_cache[layer_id], new_k], axis=-1)
171 self.v_cache[layer_id] = np.concatenate([self.v_cache[layer_id], new_v], axis=-1)
172 return self.k_cache[layer_id], self.v_cache[layer_id]
173
174
175class GroupedQueryAttention:
176 def __init__(
177 self,
178 num_heads: int,
179 num_kv_heads: int,
180 d_model: int,
181 w_k: KVxModel,
182 k_b: np.ndarray,
183 w_q: ModelxModel,
184 q_b: np.ndarray,
185 w_v: KVxModel,
186 v_b: np.ndarray,
187 w_o: ModelxModel,
188 rope: QwenRotaryEmbedding,
189 ):
190 self.num_heads = num_heads
191 self.num_kv_heads = num_kv_heads
192 self.d_model = d_model
193 self.d_k = d_model // num_heads
194 self.queries_per_kv = num_heads // num_kv_heads
195 self.w_k, self.k_b = w_k, k_b[:, None]
196 self.w_q, self.q_b = w_q, q_b[:, None]
197 self.w_v, self.v_b = w_v, v_b[:, None]
198 self.w_o = w_o
199 self.rope = rope
200
201 def forward(
202 self, x: np.ndarray, positions: np.ndarray, kv_cache: KVCache, layer_id: int = 0
203 ) -> np.ndarray:
204 T = x.shape[1]
205 q = self.w_q @ x + self.q_b
206 k = self.w_k @ x + self.k_b
207 v = self.w_v @ x + self.v_b
208
209 q = q.reshape(self.num_heads, self.d_k, T)
210 k = k.reshape(self.num_kv_heads, self.d_k, T)
211 v = v.reshape(self.num_kv_heads, self.d_k, T)
212
213 q = self.rope.apply(q, positions)
214 k = self.rope.apply(k, positions)
215
216 k, v = kv_cache.update(layer_id, k, v)
217 T_total = k.shape[-1]
218
219 k = np.repeat(k, self.queries_per_kv, axis=0)
220 v = np.repeat(v, self.queries_per_kv, axis=0)
221
222 # Note: Modifying the matrix multiplication here to take advantage of vector maths for speed. We'll talk more about it in the inference post.
223 scores = (q.transpose(0, 2, 1) @ k) / np.sqrt(self.d_k)
224 causal_mask = np.triu(np.ones((T, T_total), dtype=bool), k=T_total - T + 1)
225 scores[:, causal_mask] = -1e9
226
227 weights = softmax(scores, axis=-1)
228 head_outputs = (v @ weights.transpose(0, 2, 1)).reshape(self.d_model, T)
229
230 return self.w_o @ head_outputs
231
232
233class SwiGLUFFN:
234 def __init__(self, w_gate: FFNxModel, w_up: FFNxModel, w_down: ModelxFFN):
235 self.w_gate = w_gate
236 self.w_up = w_up
237 self.w_down = w_down
238
239 @staticmethod
240 def swish(x: np.ndarray) -> np.ndarray:
241 return x / (1.0 + np.exp(-x))
242
243 def forward(self, x: ModelxTokens) -> ModelxTokens:
244 gate = self.w_gate @ x
245 up = self.w_up @ x
246 gated_act = up * self.swish(gate)
247 output = self.w_down @ gated_act
248 return output
249
250
251class RMSNorm:
252 def __init__(self, weight: npt.NDArray[np.float32], eps: float = 1e-6):
253 self.weight = weight[:, None]
254 self.eps = eps
255
256 def forward(self, x: np.ndarray) -> np.ndarray:
257 variance = np.mean(x**2, axis=0, keepdims=True)
258 return (x / np.sqrt(variance + self.eps)) * self.weight
259
260
261class TransformerBlock:
262 def __init__(
263 self,
264 attention: GroupedQueryAttention,
265 ffn: SwiGLUFFN,
266 norm1: RMSNorm,
267 norm2: RMSNorm,
268 ):
269 self.attention = attention
270 self.ffn = ffn
271 self.norm1 = norm1
272 self.norm2 = norm2
273
274 def forward(
275 self,
276 x: ModelxTokens,
277 positions: np.ndarray,
278 kv_cache: KVCache,
279 layer_id: int = 0,
280 ) -> ModelxTokens:
281 norm_x1 = self.norm1.forward(x)
282 attn_out = self.attention.forward(norm_x1, positions, kv_cache, layer_id)
283 intermediate = x + attn_out
284
285 norm_x2 = self.norm2.forward(intermediate)
286 ffn_out = self.ffn.forward(norm_x2)
287 output = intermediate + ffn_out
288 return output
289
290
291class StackedTransformer:
292 def __init__(
293 self,
294 loader: MemoryEfficientSafetensorsLoader,
295 cfg: Qwen2_5_Coder_7B_Config,
296 rope: QwenRotaryEmbedding,
297 ):
298 self.loader = loader
299 self.cfg = cfg
300 self.rope = rope
301
302 def forward(
303 self, x: ModelxTokens, positions: np.ndarray, kv_cache: KVCache
304 ) -> ModelxTokens:
305 for layer_idx in range(self.cfg.num_layers):
306 prefix = f"model.layers.{layer_idx}."
307 with self.loader.load_scope() as ctx:
308 in_norm_w = ctx.load(prefix + "input_layernorm.weight")
309 norm1 = RMSNorm(in_norm_w, eps=self.cfg.rms_norm_eps)
310
311 q_w = ctx.load(prefix + "self_attn.q_proj.weight")
312 q_b = ctx.load(prefix + "self_attn.q_proj.bias")
313 k_w = ctx.load(prefix + "self_attn.k_proj.weight")
314 k_b = ctx.load(prefix + "self_attn.k_proj.bias")
315 v_w = ctx.load(prefix + "self_attn.v_proj.weight")
316 v_b = ctx.load(prefix + "self_attn.v_proj.bias")
317 o_w = ctx.load(prefix + "self_attn.o_proj.weight")
318
319 attn = GroupedQueryAttention(
320 self.cfg.num_heads,
321 self.cfg.num_kv_heads,
322 self.cfg.hidden_size,
323 k_w,
324 k_b,
325 q_w,
326 q_b,
327 v_w,
328 v_b,
329 o_w,
330 self.rope,
331 )
332
333 post_norm_w = ctx.load(prefix + "post_attention_layernorm.weight")
334 norm2 = RMSNorm(post_norm_w, eps=self.cfg.rms_norm_eps)
335
336 gate_w = ctx.load(prefix + "mlp.gate_proj.weight")
337 up_w = ctx.load(prefix + "mlp.up_proj.weight")
338 down_w = ctx.load(prefix + "mlp.down_proj.weight")
339 ffn = SwiGLUFFN(gate_w, up_w, down_w)
340
341 block = TransformerBlock(attn, ffn, norm1, norm2)
342 x = block.forward(x, positions, kv_cache, layer_idx)
343
344 with self.loader.load_scope() as ctx:
345 final_norm_w = ctx.load("model.norm.weight")
346 final_norm = RMSNorm(final_norm_w, eps=self.cfg.rms_norm_eps)
347 output = final_norm.forward(x)[:, -1:]
348
349 return output
350
351
352class LMHead:
353 def __init__(self, w_head: npt.NDArray[np.float32]):
354 self.w_head = w_head
355
356 def forward(
357 self, last_hidden_state: npt.NDArray[np.float32]
358 ) -> npt.NDArray[np.float32]:
359 return (self.w_head @ last_hidden_state).squeeze(-1)
360
361
362class Decoder:
363 def __init__(
364 self, transformers: StackedTransformer, loader: MemoryEfficientSafetensorsLoader
365 ):
366 self.transformers = transformers
367 self.loader = loader
368
369 def step(
370 self, x: np.ndarray, positions: np.ndarray, kv_cache: KVCache
371 ) -> tuple[int, np.ndarray, np.ndarray]:
372 last_hidden_state = self.transformers.forward(x, positions, kv_cache)
373 with self.loader.load_scope() as ctx:
374 lm_head_w = ctx.load("lm_head.weight")
375 lm_head = LMHead(lm_head_w)
376 logits = lm_head.forward(last_hidden_state)
377 next_token_id = int(np.argmax(logits))
378 return next_token_id, logits, last_hidden_state
379
380
381def generate_tokens(
382 max_new_tokens: int = 3, prompt: str = "Give me the quicksort algorithm in python"
383):
384 print("=== Qwen2.5-Coder-7B-Instruct pure NumPy Inference ===")
385 print(f"Prompt: {prompt!r}")
386 print(f"Max New Tokens: {max_new_tokens}")
387 print(f"Initial Process memory consumption: {get_ram_mb():.1f} MB\n")
388
389 t_start = time.time()
390
391 tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-Coder-7B-Instruct")
392 eos_token_ids = {
393 t_id
394 for t_id in [
395 tokenizer.eos_token_id,
396 tokenizer.convert_tokens_to_ids("<|im_end|>"),
397 tokenizer.convert_tokens_to_ids("<|endoftext|>"),
398 ]
399 if t_id is not None
400 }
401 messages = [
402 {
403 "role": "system",
404 "content": "You are a helpful assistant specializing in coding.",
405 },
406 {"role": "user", "content": prompt},
407 ]
408 text = tokenizer.apply_chat_template(
409 messages, tokenize=False, add_generation_prompt=True
410 )
411 tokens = tokenizer.encode(text)
412 seq_len = len(tokens)
413 print(f"Formatted Chat Prompt:\n{text}")
414 print(f"Tokenized Sequence Length: {seq_len} tokens.\n")
415
416 loader = MemoryEfficientSafetensorsLoader("Qwen/Qwen2.5-Coder-7B-Instruct")
417 cfg = Qwen2_5_Coder_7B_Config()
418
419 rope = QwenRotaryEmbedding(head_dim=cfg.head_dim, base=cfg.rope_theta)
420 transformers = StackedTransformer(loader, cfg, rope)
421 decoder = Decoder(transformers, loader)
422
423 generated_tokens = []
424 cache = KVCache()
425
426 for step in range(max_new_tokens):
427 step_start = time.time()
428 curr_seq_len = len(tokens)
429
430 if max_new_tokens > 1:
431 print(
432 f"\n--- Generating Token {step + 1}/{max_new_tokens} (seq_len={curr_seq_len}) ---"
433 )
434
435 with loader.load_scope() as ctx:
436 embed_weight = ctx.load("model.embed_tokens.weight")
437 embedding = Embedding(embed_weight)
438 if step == 0:
439 x = embedding.embed(tokens)
440 positions = np.arange(curr_seq_len, dtype=np.float32)
441 else:
442 x = embedding.embed([tokens[-1]])
443 positions = np.array([curr_seq_len - 1], dtype=np.float32)
444
445 next_token_id, logits, output = decoder.step(x, positions, cache)
446 next_token_str = tokenizer.decode([next_token_id])
447
448 top5_indices = np.argsort(logits)[-5:][::-1]
449 top5_logits = logits[top5_indices]
450 top5_tokens = [tokenizer.decode([idx]) for idx in top5_indices]
451
452 step_end = time.time()
453
454 tokens.append(next_token_id)
455 generated_tokens.append(next_token_id)
456
457 print(f"Generated Token {step + 1} ID : {next_token_id}")
458 print(f"Generated Token {step + 1} Text: {next_token_str!r}")
459 print(f"Top 5 Logits : {top5_logits.tolist()}")
460 print(f"Top 5 Tokens : {top5_tokens}")
461 print(f"Step Time : {step_end - step_start:.2f} s")
462 print(f"Process memory : {get_ram_mb():.1f} MB")
463
464 if next_token_id in eos_token_ids:
465 print(f"Reached EOS token ({next_token_str!r}, ID: {next_token_id}).")
466 break
467
468 t_end = time.time()
469
470 full_generated_text = tokenizer.decode(generated_tokens)
471 print("\n================ FINAL GENERATION RESULT ================")
472 print(f"Total Tokens Generated : {len(generated_tokens)}")
473 print(f"Generated Tokens List : {generated_tokens}")
474 print(f"Generated Text : {full_generated_text!r}")
475 print(f"Total Computation Time : {t_end - t_start:.2f} s")
476 print(f"Peak Process memory: {get_ram_mb():.1f} MB")
477 print("=========================================================\n")
478
479
480if __name__ == "__main__":
481 parser = argparse.ArgumentParser(
482 description="Qwen2.5-Coder-7B-Instruct pure NumPy inference"
483 )
484 parser.add_argument(
485 "num_tokens",
486 type=int,
487 nargs="?",
488 default=None,
489 help="Number of tokens to generate (positional)",
490 )
491 parser.add_argument(
492 "--max-new-tokens",
493 "-n",
494 type=int,
495 default=None,
496 help="Number of tokens to generate",
497 )
498 parser.add_argument(
499 "--prompt",
500 "-p",
501 type=str,
502 default="Give me the quicksort algorithm in python",
503 help="Input prompt",
504 )
505 args = parser.parse_args()
506
507 max_new_tokens = (
508 args.max_new_tokens
509 if args.max_new_tokens is not None
510 else (args.num_tokens if args.num_tokens is not None else 3)
511 )
512 generate_tokens(max_new_tokens=max_new_tokens, prompt=args.prompt)
513

This is the output when I prompt with “Quicksort in python, just the code no preamble please”:

```python
def quicksort(arr):
    if len(arr) <= 1:
        return arr
    pivot = arr[len(arr) // 2]
    left = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
 right = [x for x in arr if x > pivot]
    return quicksort(left) + middle + quicksort(right)
```<|im_end|>

Miscellaneous

Mixture of Experts (MoE)

How do we scale even more?

In all the model architectures, the FFN is used for every single token in every single layer. By increasing the size of FFN more information can be stored but that will also incur higher compute costs during decoding (as if decoding isn’t costly enough already).

The idea behind MOE is to split the FFN into many many small FFN networks (called experts) and only a few of those experts are activated per layer. The experts to activate is a fixed number kk which is controlled by another small “gating network” (or gating function) and then we take the weighted sum of the output of those activated FFN experts. In equation that looks like:

Intermediate=I+Attentionoutput×WO\text{Intermediate} = I + \text{Attention}_{\text{output}} \times W^O Output=Intermediate+MoE(RMSNorm(Intermediate))\text{Output} = \text{Intermediate} + \text{MoE}(\text{RMSNorm}(\text{Intermediate})) MoE(X)=iTopKGi(X)×FFNi(X)\text{MoE}(X) = \sum_{i \in \text{TopK}} G_i(X) \times \text{FFN}_i(X) Gi(X)=Softmax(TopK(X×Wgating,k))iG_i(X) = \text{Softmax}\left(\text{TopK}\left(X \times W_{\text{gating}}, k\right)\right)_i

Shared MoE

Some experts are needed for all cases (like to understand name, grammar etc) which will now get duplicated over the experts. Another variation is having shared experts which are always active for all and use MoE for the rest of the FFNs. The output of shared experts is summed with the output of the gated MoE. Generally in shared MoE we decrease the parameters provided to individual expert which allows us to scale the number of experts and activate multiple of them.

N
One problem with MoE is breakdown of experts and network relying on only a few of them (as early on grammar might be learned by a few experts and the network routing everything to them). To account for this, an additional auxiliary loss is used during training, which I’ll talk about in the training blog.

RoPE and runtime context scaling (YARN)

One of the most important benefits of RoPE technique is that it allows a model to scale context window beyond the limits of its training (either zero-shot or very small fine-tuning step). If the base bb of RoPE is kept the same, but the context window is increased, then the model ends up in unknown territory where the new values are never seen by the model during training. But if base is modified so that even larger attentions are represented by the same distribution as in the training, then the .

But scaling just the base leads to model scaling up frequency for the earlier pair in the embeddings as well, where the model is supposed to attend to nearby tokens. This leads to “blurriness” where the model fails to figure out the relationship between nearby tokens (as the model was trained for specific frequencies). To fix this, either the model is fine-tunned over a small corpus, or NTK-aware scaling is used where the faster frequencies are largely left alone and just the slower frequencies are modified to accommodate for larger contexts. The current SoTA for NTK-aware scaling is YARN (again Google around, its pretty straightforward).

Multi-head Latent Attention (MLA)

Another technique to save memory introduced by DeepSeek

The basic idea is: If each attention head is already learning some low-rank representation (as shown by empirical evidence), why not use a single KV per layer, rather than per attention head, which during decoding can be transformed to represent each head?

Hold your questions about compute costs.

So, what we have are 3 matrices:

  • A WDKVW^{DKV} to project to some low-rank dimension dcd_c which serves as a base for KV, generally called cKVc^{KV}. This matrix is of size [dc,dmodel][d_c, d_{model}] signifying the transformation from embedding size to dcd_c size.
  • A WhUKW^{UK}_h to project cKVc^{KV} back to dheadd_{head} for key per head. This matrix is of size [dhead,dc][d_{head}, d_c].
  • A WhUVW^{UV}_h to project cKVc^{KV} back to dheadd_{head} for value per head. This matrix is of size [dhead,dc][d_{head}, d_c].
N
WDKVW^{DKV} is per layer, whereas WhUKW^{UK}_h and WhUVW^{UV}_h are per attention head (hence the hh in subscript). We are just adding one additional matrix for down projection, up projection ones replace the older WKW^K and WVW^V.

For each token, its low-rank representation of size cKVc^{KV} is stored in the cache. Whereas previously, two vectors per token per KV head of size dmodeld_{model} (and generally dmodeldcd_{model} \gg d_c like 40964096 v/s 128128) was stored in the cache.

QBut what about the compute?
A

You might think now we have to reconstruct KV for each token when decoding. Ah no. And that’s where the beauty of this technique comes. Let’s see the equation we’ll have to apply during decoding of token tt w.r.t. past tokens (say we have seen NN tokens and CKVC^{KV} is the new low-rank kv cache of size [dc,N][d_c, N]) for one head (omitting hh for clarity):

Attentionscoret=KT×Qt=(WUK×CKV)T×Qt=(CKV)T×((WUK)T×Qt)Attention_{score}^t = K^T \times Q_t = (W^{UK} \times C^{KV})^T \times Q_t = (C^{KV})^T \times ((W^{UK})^T \times Q_t)

using matrix associativity

Notice the brilliance here, each KK does not have to be computed separately, rather the attention head constant matrix WUKW^{UK} can be absorbed in to the newly computed query vector QtQ_t for each head, denoted by Qt~\tilde{Q_t}. Or thought from query’s perspective, it is projecting itself down to the low-rank space based on that attention head’s specific keys, asking the question in the key space. Similar behavior with absorbing the AttentionscoreAttention_{score} directly with WUVW^{UV}.

Empirical evidence shows that this model outperforms both GQA in both accuracy and memory savings, while maintaining the same speed (just one additional matmul, but reduces the dimension of other matmuls).

Encoder architecture

What I have described is what is commonly known as a “decoder only” architecture (as opposed to encoder-decoder or encoder only architecture). The only difference is that in an encoder all tokens attend to all tokens past and future. Decoder then attends to its own generated tokens’s KV, called self-attention, but also to the encoder’s KV, called cross-attention.

Each architecture has its own use based on the problem statement:

  • Encoder-only: Used generally for understanding tasks like sentiment analysis, image categorization etc.
  • Decoder-only: Natural language generation.
  • Encoder-decoder: Sequence to sequence tasks where target output sequence depends on a structured input like describe an image, or translation etc.

QK normalization

Remember the dk\sqrt{d_k} when computing the attention score? Turns out that is not enough.

  • The goal of a softmax loss to make sure that the output of softmax is 11 only for the right logit and 00 every where else (the cross-entropy loss). For that to happen the gradient flowing back into the weights are continuously pushing them to be higher and higher positive values which can eventually break down the network. The assumption behind dk\sqrt{d_k} is that the mean stays at 00 and variance at 11 but that isn’t true.
  • The residual network even with pre-norm keeps increasing the variance a bit which for deep layers add up to push QKQK high enough to saturate softmax.
  • Deeper layers are also developing complex “thoughts” that they have to distinguish to figure out what token to attend to when all the tokens are saying they are the most important one. This will naturally push the weights to be higher, so that the softmax output is spread enough and it can isolate the important tokens.

The math is pretty simple actually, we just apply RMSNorm to the QKQK vectors of each token.

Logit softcapping

QK normalization solves the problem for attention, but who solves the similar problem for LM Head’s softmax? That’s logit softcapping. The core idea is that we want to make sure that the raw value of the softmax output does not go beyond certain limits (3030 in Gemma 2 --- which BTW also used logit softcapping in attention layers as well rather than QK normalization with the value 5050). So, a hard cap is employed for softmax like max(50,min(50,value))max(-50, min(50, value)).

But what about the gradients? Once a value goes beyond the limit its game over for training. To solve this, the same LSTM solution of tanhtanh is used, which has linear behavior within the limits and asymptotically approaches the limits. The formula used for LM head in Gemma 2:

Attentionweights=Softmax(50.0tanh(QKT50.0dk))Attention_{weights} = Softmax ( 50.0 \cdot tanh(\frac{QK^T}{50.0\cdot\sqrt{d_k}})) Outputlogits=30.0tanh(Otransformer×WLM_Head30.0)Output_{logits} = 30.0 \cdot tanh(\frac{O_{transformer} \times W_{LM\_Head}}{30.0})

Sliding window attention

Do not attend to all the past tokens, attend to a fixed window and forget about the past before that. That’s it!

Multi Token Prediction (MTP)

Another one from DeepSeek

The idea is to have a smaller MTP model at the end of the main model which predicts the next token immediately rather than going through the entire autoregressive process of the main model. Take the hidden state, and then embedding of just predicted token, concat and pass through this MTP model. You can configure how many times this MTP model runs to generate multiple tokens (it was configured to 11 for DeepSeek 3). Note that you still share the LM Head. It forces the model to learn a much richer representation because now it has to predict multiple future tokens from a single hidden state and it provides native speculative decoding.

Conclusion

The space of LLMs is crowded with many other ideas which are not based on transformer architecture (like SSMs). But transformers and the many concepts I have discussed here form the backbone of all major LLMs today.

In the next iteration in this series, I’ll build a highly efficient inference engine, targeting one processor (likely my M4 pro) and one single GPU node. I am hoping to be able to reach parity with some SoTA engine for one specific model.

Hacker News Discussion

Checking Hacker News for discussions...

Embeddings
Copy runnable code
Setting up inputs
Attention
Causal Attention
Feed-Forward Network
Stacking
Residual Connections
Normalization
Multi-Head Attention
Output Matrix
Transformer output
Grouped-Query Attention
RoPE
Decoding & LM Head
Introducing Bias
Split-Half RoPE
Qwen Parameters
Complete Qwen code
35class EmbeddingWithPosition:
36 def __init__(self, w_embed: EmbedxDict, w_pos: EmbedxCtx):
37 self.w_embed = w_embed
38 self.w_pos = w_pos
39
40 def embed(self, token_ids: list[int]) -> EmbedxTokens:
41 tokens = np.array(token_ids)
42 tokens_one_hot = np.zeros((dict_size, tokens.size))
43 tokens_one_hot[tokens, np.arange(tokens.size)] = 1
44
45 positions = np.arange(tokens.size)
46 positions_one_hot = np.zeros((max_context, positions.size))
47 positions_one_hot[positions, np.arange(positions.size)] = 1
48
49 return (self.w_embed @ tokens_one_hot) + (self.w_pos @ positions_one_hot)