AI Use
Audience
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 matrix.
- All vectors are represented as column vectors, like .
- A matrix of vectors is represented as concatenated column vectors, like .
- represents a matrix multiplication, while 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 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 should affect a token (where can be equal to ).
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 , and , which is calculated by 3 matrices , , and .
Now the input sequence is transformed into 3 other sequence matrices:
And each key query pair is resolved as:
Size of is noting the attention each token is paying to every other token.
Finally the output of attention is attention score weighted sum of values:
QWhat's up with Softmax?
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 ?
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 image sequentially. So, looking into the future has its advantages.
But for language that’s not the case. So a causal mask makes sure a token does not attend to future tokens.
where is the row index and is the column index.
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 the intermediate activations. 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.
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 , while the output is of size . So either the rest of the layers need to take input of size or is set equal to and generally the latter is chosen but not for just this reason.
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 model to learn or makes learning unstable 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 and at the final output location . Mathematically:
This does require
NNote on residual impact
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 high values. 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 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:
and compare this with the output from pre-norm model:
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 gets smaller and smaller for deeper layers.
Multi Head Attention (MHA)
Increasing efficiency
Attention mechanism represents each token as three 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 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 attention heads, each with internal representation of size . The output of each head is concatenated in the end to recover sized output vector of the attention layer.
In equations (where represents a specific attention head):
Output matrix
Notice that now the attention output is now poorly represented in the residual connection:
Each attention head can only affect a subset of the residual. And hence the need for an output matrix (denoted by ) which mixes up information from all attention heads.
QWhy not keep the still in
QWhy not merge with each head's as two transformations: ?
Finishing up transformer
Transformer output
The output from the stacked layers 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.
Summed up
So the overall equation for one transformer layer looks like:
Where and . After stacking layers, the output of layer () is normalized to produce the final transformer vector:
MHA matmaul
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 , 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 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).
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 where 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 . Now choosing a fixed rotation frequency and and representing the absolute position of those vectors, that term becomes .
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 real numbers into a high dimensional space of complex numbers.
For a 2D pair at position , the rotation by angle is:
Extending this to the full -dimensional embedding vector , the block-diagonal rotation matrix is defined as:
What should be the value of , i.e. what is the optimal rotation frequency?
- If we choose a very fast moving frequency , then the dot product will start repeating very soon. Like for , , basically treating the token at 8 positions away as same as attending to self token.
- If we choose a very slow moving frequency , then the dot product will look almost the same. Like for , , basically failing to distinguish between nearby tokens.
A single choice fails to capture everything. So, RoPE uses pairwise encoded based on a decaying procedure:
We use a separate for each complex number and is the base which controls how large our context window can be before the rotations start to repeat. The original transformer paper used but modern LLMs, with their million long context window, use much larger base.
Mathematically this looks like:
where the frequencies are .
Generalizing over the input sequence :
Decoding
But where is the output of the model?
We expect that the final token of the last transformer layer encodes what comes next, let’s call it . A reverse embedding (called “LM Head”) inverts that to a logits over the dictionary. So the first token is:
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 for all the tokens that we have already processed. In fact, their is not even required, just the to calculate how the new token is impacted by those past tokens. So, we just store that and 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 . 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 does not change the math, 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.
Plugging in the specific parameters
These are the specifics of our Qwen model:
- Number of layers (): .
- RoPE base (): .
- Hidden size (): .
- Attention heads (): .
- KV heads: .
- Head dimension (): .
- FFN size (): .
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
# /// script# requires-python = ">=3.14"# dependencies = [# "huggingface-hub>=1.27.0",# "jinja2>=3.1.6",# "numpy>=2.5.2",# "psutil>=7.2.2",# "transformers>=5.15.0",# ]# ///import argparsefrom dataclasses import dataclassimport jsonimport osimport structimport timeimport numpy as npimport numpy.typing as nptimport psutilfrom huggingface_hub import snapshot_downloadfrom transformers import AutoTokenizerfrom typing import AnnotatedEmbedxCtx = Annotated[npt.NDArray[np.float32], "shape=(d_embed, max_context)"]EmbedxDict = Annotated[npt.NDArray[np.float32], "shape=(d_embed, dict_size)"]ModelxEmbed = Annotated[npt.NDArray[np.float32], "shape=(d_model, d_embed)"]EmbedxTokens = Annotated[npt.NDArray[np.float32], "shape=(d_embed, T)"]ModelxTokens = Annotated[npt.NDArray[np.float32], "shape=(d_model, T)"]ModelxModel = Annotated[npt.NDArray[np.float32], "shape=(d_model, d_model)"]FFNxModel = Annotated[npt.NDArray[np.float32], "shape=(d_ff, d_model)"]ModelxFFN = Annotated[npt.NDArray[np.float32], "shape=(d_model, d_ff)"]KVxModel = Annotated[npt.NDArray[np.float32], "shape=(num_kv_heads * d_k, d_model)"]def get_ram_mb() -> float: process = psutil.Process(os.getpid()) return process.memory_info().rss / (1024 * 1024)class WeightContext: def __init__(self, loader: "MemoryEfficientSafetensorsLoader"): self.loader = loader self.loaded_tensors: list[np.ndarray] = [] def load(self, name: str) -> np.ndarray: arr = self.loader.load_tensor(name) self.loaded_tensors.append(arr) return arr def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.loaded_tensors.clear()class MemoryEfficientSafetensorsLoader: def __init__(self, repo_id: str = "Qwen/Qwen2.5-Coder-7B-Instruct"): print("Checking / downloading model weight shards from HuggingFace Hub...") self.model_dir = snapshot_download( repo_id=repo_id, allow_patterns=["*.safetensors", "*.json"] ) index_path = os.path.join(self.model_dir, "model.safetensors.index.json") with open(index_path, "r") as f: index = json.load(f) self.weight_map = index["weight_map"] self.file_headers = {} shard_files = set(self.weight_map.values()) for shard in shard_files: shard_path = os.path.join(self.model_dir, shard) with open(shard_path, "rb") as f: header_len = struct.unpack("<Q", f.read(8))[0] header_json = f.read(header_len).decode("utf-8") header = json.loads(header_json) self.file_headers[shard] = {"header_len": header_len, "header": header} def load_tensor(self, name: str) -> np.ndarray: shard = self.weight_map[name] shard_path = os.path.join(self.model_dir, shard) info = self.file_headers[shard] meta = info["header"][name] header_len = info["header_len"] start, end = meta["data_offsets"] shape = meta["shape"] dtype_str = meta["dtype"] with open(shard_path, "rb") as f: f.seek(8 + header_len + start) raw_bytes = f.read(end - start) if dtype_str == "BF16": u16 = np.frombuffer(raw_bytes, dtype=np.uint16) arr = (u16.astype(np.uint32) << 16).view(np.float32) elif dtype_str == "F32": arr = np.frombuffer(raw_bytes, dtype=np.float32) elif dtype_str == "F16": arr = np.frombuffer(raw_bytes, dtype=np.float16).astype(np.float32) else: raise ValueError(f"Unsupported tensor dtype: {dtype_str}") return arr.reshape(shape) def load_scope(self) -> WeightContext: return WeightContext(self)@dataclassclass Qwen2_5_Coder_7B_Config: num_layers: int = 28 hidden_size: int = 3584 num_heads: int = 28 num_kv_heads: int = 4 head_dim: int = 128 intermediate_size: int = 18944 vocab_size: int = 152064 rope_theta: float = 1000000.0 rms_norm_eps: float = 1e-6class Embedding: def __init__(self, w_embed: EmbedxDict): self.w_embed = w_embed def embed(self, token_ids: list[int]) -> EmbedxTokens: return self.w_embed[token_ids].Tclass QwenRotaryEmbedding: def __init__(self, head_dim: int, base: float = 1000000.0): self.head_dim = head_dim self.base = base inv_freq = 1.0 / (base ** (np.arange(0, head_dim, 2, dtype=np.float32) / head_dim)) self.inv_freq = inv_freq def apply(self, x: np.ndarray, positions: np.ndarray) -> np.ndarray: freqs = np.outer(self.inv_freq, positions) emb = np.concatenate([freqs, freqs], axis=0) cos = np.cos(emb)[None, :, :] sin = np.sin(emb)[None, :, :] half = self.head_dim // 2 x1 = x[:, :half, :] x2 = x[:, half:, :] rotate_half = np.concatenate([-x2, x1], axis=1) return (x * cos) + (rotate_half * sin)def softmax(x: np.ndarray, axis: int = 0) -> np.ndarray: x_max = np.max(x, axis=axis, keepdims=True) exp_x = np.exp(x - x_max) return exp_x / np.sum(exp_x, axis=axis, keepdims=True)class KVCache: def __init__(self): self.k_cache: dict[int, np.ndarray] = {} self.v_cache: dict[int, np.ndarray] = {} def update( self, layer_id: int, new_k: np.ndarray, new_v: np.ndarray ) -> tuple[np.ndarray, np.ndarray]: if layer_id not in self.k_cache: self.k_cache[layer_id] = new_k self.v_cache[layer_id] = new_v else: self.k_cache[layer_id] = np.concatenate([self.k_cache[layer_id], new_k], axis=-1) self.v_cache[layer_id] = np.concatenate([self.v_cache[layer_id], new_v], axis=-1) return self.k_cache[layer_id], self.v_cache[layer_id]class GroupedQueryAttention: def __init__( self, num_heads: int, num_kv_heads: int, d_model: int, w_k: KVxModel, k_b: np.ndarray, w_q: ModelxModel, q_b: np.ndarray, w_v: KVxModel, v_b: np.ndarray, w_o: ModelxModel, rope: QwenRotaryEmbedding, ): self.num_heads = num_heads self.num_kv_heads = num_kv_heads self.d_model = d_model self.d_k = d_model // num_heads self.queries_per_kv = num_heads // num_kv_heads self.w_k, self.k_b = w_k, k_b[:, None] self.w_q, self.q_b = w_q, q_b[:, None] self.w_v, self.v_b = w_v, v_b[:, None] self.w_o = w_o self.rope = rope def forward( self, x: np.ndarray, positions: np.ndarray, kv_cache: KVCache, layer_id: int = 0 ) -> np.ndarray: T = x.shape[1] q = self.w_q @ x + self.q_b k = self.w_k @ x + self.k_b v = self.w_v @ x + self.v_b q = q.reshape(self.num_heads, self.d_k, T) k = k.reshape(self.num_kv_heads, self.d_k, T) v = v.reshape(self.num_kv_heads, self.d_k, T) q = self.rope.apply(q, positions) k = self.rope.apply(k, positions) k, v = kv_cache.update(layer_id, k, v) T_total = k.shape[-1] k = np.repeat(k, self.queries_per_kv, axis=0) v = np.repeat(v, self.queries_per_kv, axis=0) # Note: Modifying the matrix multiplication here to take advantage of vector maths for speed. We'll talk more about it in the inference post. scores = (q.transpose(0, 2, 1) @ k) / np.sqrt(self.d_k) causal_mask = np.triu(np.ones((T, T_total), dtype=bool), k=T_total - T + 1) scores[:, causal_mask] = -1e9 weights = softmax(scores, axis=-1) head_outputs = (v @ weights.transpose(0, 2, 1)).reshape(self.d_model, T) return self.w_o @ head_outputsclass SwiGLUFFN: def __init__(self, w_gate: FFNxModel, w_up: FFNxModel, w_down: ModelxFFN): self.w_gate = w_gate self.w_up = w_up self.w_down = w_down @staticmethod def swish(x: np.ndarray) -> np.ndarray: return x / (1.0 + np.exp(-x)) def forward(self, x: ModelxTokens) -> ModelxTokens: gate = self.w_gate @ x up = self.w_up @ x gated_act = up * self.swish(gate) output = self.w_down @ gated_act return outputclass RMSNorm: def __init__(self, weight: npt.NDArray[np.float32], eps: float = 1e-6): self.weight = weight[:, None] self.eps = eps def forward(self, x: np.ndarray) -> np.ndarray: variance = np.mean(x**2, axis=0, keepdims=True) return (x / np.sqrt(variance + self.eps)) * self.weightclass TransformerBlock: def __init__( self, attention: GroupedQueryAttention, ffn: SwiGLUFFN, norm1: RMSNorm, norm2: RMSNorm, ): self.attention = attention self.ffn = ffn self.norm1 = norm1 self.norm2 = norm2 def forward( self, x: ModelxTokens, positions: np.ndarray, kv_cache: KVCache, layer_id: int = 0, ) -> ModelxTokens: norm_x1 = self.norm1.forward(x) attn_out = self.attention.forward(norm_x1, positions, kv_cache, layer_id) intermediate = x + attn_out norm_x2 = self.norm2.forward(intermediate) ffn_out = self.ffn.forward(norm_x2) output = intermediate + ffn_out return outputclass StackedTransformer: def __init__( self, loader: MemoryEfficientSafetensorsLoader, cfg: Qwen2_5_Coder_7B_Config, rope: QwenRotaryEmbedding, ): self.loader = loader self.cfg = cfg self.rope = rope def forward( self, x: ModelxTokens, positions: np.ndarray, kv_cache: KVCache ) -> ModelxTokens: for layer_idx in range(self.cfg.num_layers): prefix = f"model.layers.{layer_idx}." with self.loader.load_scope() as ctx: in_norm_w = ctx.load(prefix + "input_layernorm.weight") norm1 = RMSNorm(in_norm_w, eps=self.cfg.rms_norm_eps) q_w = ctx.load(prefix + "self_attn.q_proj.weight") q_b = ctx.load(prefix + "self_attn.q_proj.bias") k_w = ctx.load(prefix + "self_attn.k_proj.weight") k_b = ctx.load(prefix + "self_attn.k_proj.bias") v_w = ctx.load(prefix + "self_attn.v_proj.weight") v_b = ctx.load(prefix + "self_attn.v_proj.bias") o_w = ctx.load(prefix + "self_attn.o_proj.weight") attn = GroupedQueryAttention( self.cfg.num_heads, self.cfg.num_kv_heads, self.cfg.hidden_size, k_w, k_b, q_w, q_b, v_w, v_b, o_w, self.rope, ) post_norm_w = ctx.load(prefix + "post_attention_layernorm.weight") norm2 = RMSNorm(post_norm_w, eps=self.cfg.rms_norm_eps) gate_w = ctx.load(prefix + "mlp.gate_proj.weight") up_w = ctx.load(prefix + "mlp.up_proj.weight") down_w = ctx.load(prefix + "mlp.down_proj.weight") ffn = SwiGLUFFN(gate_w, up_w, down_w) block = TransformerBlock(attn, ffn, norm1, norm2) x = block.forward(x, positions, kv_cache, layer_idx) with self.loader.load_scope() as ctx: final_norm_w = ctx.load("model.norm.weight") final_norm = RMSNorm(final_norm_w, eps=self.cfg.rms_norm_eps) output = final_norm.forward(x)[:, -1:] return outputclass LMHead: def __init__(self, w_head: npt.NDArray[np.float32]): self.w_head = w_head def forward( self, last_hidden_state: npt.NDArray[np.float32] ) -> npt.NDArray[np.float32]: return (self.w_head @ last_hidden_state).squeeze(-1)class Decoder: def __init__( self, transformers: StackedTransformer, loader: MemoryEfficientSafetensorsLoader ): self.transformers = transformers self.loader = loader def step( self, x: np.ndarray, positions: np.ndarray, kv_cache: KVCache ) -> tuple[int, np.ndarray, np.ndarray]: last_hidden_state = self.transformers.forward(x, positions, kv_cache) with self.loader.load_scope() as ctx: lm_head_w = ctx.load("lm_head.weight") lm_head = LMHead(lm_head_w) logits = lm_head.forward(last_hidden_state) next_token_id = int(np.argmax(logits)) return next_token_id, logits, last_hidden_statedef generate_tokens( max_new_tokens: int = 3, prompt: str = "Give me the quicksort algorithm in python"): print("=== Qwen2.5-Coder-7B-Instruct pure NumPy Inference ===") print(f"Prompt: {prompt!r}") print(f"Max New Tokens: {max_new_tokens}") print(f"Initial Process memory consumption: {get_ram_mb():.1f} MB\n") t_start = time.time() tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-Coder-7B-Instruct") eos_token_ids = { t_id for t_id in [ tokenizer.eos_token_id, tokenizer.convert_tokens_to_ids("<|im_end|>"), tokenizer.convert_tokens_to_ids("<|endoftext|>"), ] if t_id is not None } messages = [ { "role": "system", "content": "You are a helpful assistant specializing in coding.", }, {"role": "user", "content": prompt}, ] text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) tokens = tokenizer.encode(text) seq_len = len(tokens) print(f"Formatted Chat Prompt:\n{text}") print(f"Tokenized Sequence Length: {seq_len} tokens.\n") loader = MemoryEfficientSafetensorsLoader("Qwen/Qwen2.5-Coder-7B-Instruct") cfg = Qwen2_5_Coder_7B_Config() rope = QwenRotaryEmbedding(head_dim=cfg.head_dim, base=cfg.rope_theta) transformers = StackedTransformer(loader, cfg, rope) decoder = Decoder(transformers, loader) generated_tokens = [] cache = KVCache() for step in range(max_new_tokens): step_start = time.time() curr_seq_len = len(tokens) if max_new_tokens > 1: print( f"\n--- Generating Token {step + 1}/{max_new_tokens} (seq_len={curr_seq_len}) ---" ) with loader.load_scope() as ctx: embed_weight = ctx.load("model.embed_tokens.weight") embedding = Embedding(embed_weight) if step == 0: x = embedding.embed(tokens) positions = np.arange(curr_seq_len, dtype=np.float32) else: x = embedding.embed([tokens[-1]]) positions = np.array([curr_seq_len - 1], dtype=np.float32) next_token_id, logits, output = decoder.step(x, positions, cache) next_token_str = tokenizer.decode([next_token_id]) top5_indices = np.argsort(logits)[-5:][::-1] top5_logits = logits[top5_indices] top5_tokens = [tokenizer.decode([idx]) for idx in top5_indices] step_end = time.time() tokens.append(next_token_id) generated_tokens.append(next_token_id) print(f"Generated Token {step + 1} ID : {next_token_id}") print(f"Generated Token {step + 1} Text: {next_token_str!r}") print(f"Top 5 Logits : {top5_logits.tolist()}") print(f"Top 5 Tokens : {top5_tokens}") print(f"Step Time : {step_end - step_start:.2f} s") print(f"Process memory : {get_ram_mb():.1f} MB") if next_token_id in eos_token_ids: print(f"Reached EOS token ({next_token_str!r}, ID: {next_token_id}).") break t_end = time.time() full_generated_text = tokenizer.decode(generated_tokens) print("\n================ FINAL GENERATION RESULT ================") print(f"Total Tokens Generated : {len(generated_tokens)}") print(f"Generated Tokens List : {generated_tokens}") print(f"Generated Text : {full_generated_text!r}") print(f"Total Computation Time : {t_end - t_start:.2f} s") print(f"Peak Process memory: {get_ram_mb():.1f} MB") print("=========================================================\n")if __name__ == "__main__": parser = argparse.ArgumentParser( description="Qwen2.5-Coder-7B-Instruct pure NumPy inference" ) parser.add_argument( "num_tokens", type=int, nargs="?", default=None, help="Number of tokens to generate (positional)", ) parser.add_argument( "--max-new-tokens", "-n", type=int, default=None, help="Number of tokens to generate", ) parser.add_argument( "--prompt", "-p", type=str, default="Give me the quicksort algorithm in python", help="Input prompt", ) args = parser.parse_args() max_new_tokens = ( args.max_new_tokens if args.max_new_tokens is not None else (args.num_tokens if args.num_tokens is not None else 3) ) generate_tokens(max_new_tokens=max_new_tokens, prompt=args.prompt)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 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:
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.
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 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 model can perform just as well.
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 to project to some low-rank dimension which serves as a base for KV, generally called . This matrix is of size signifying the transformation from embedding size to size.
- A to project back to for key per head. This matrix is of size .
- A to project back to for value per head. This matrix is of size .
For each token, its low-rank representation of size is stored in the cache. Whereas previously, two vectors per token per KV head of size (and generally like v/s ) was stored in the cache.
QBut what about the compute?
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 w.r.t. past tokens (say we have seen tokens and is the new low-rank kv cache of size ) for one head (omitting for clarity):
using matrix associativity
Notice the brilliance here, each does not have to be computed separately, rather the attention head constant matrix can be absorbed in to the newly computed query vector for each head, denoted by . 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 directly with .
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 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 only for the right logit and 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 is that the mean stays at and variance at 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 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 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 ( in Gemma 2 --- which BTW also used logit softcapping in attention layers as well rather than QK normalization with the value ). So, a hard cap is employed for softmax like .
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 is used, which has linear behavior within the limits and asymptotically approaches the limits. The formula used for LM head in Gemma 2:
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 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...
Top Comments from Hacker News