Pantheon/docs/architecture/llama-forward.md

14 KiB

Llama Forward Pass

The Llama forward pass (src/models/llama.c) is shared by Llama, Qwen2, Mistral, and Mixtral architectures. Architecture-specific differences (attention biases, sliding window, MoE routing, metadata keys) are handled by config flags, not separate code paths.

Phi-3 has its own forward pass in src/models/phi3.c due to fused QKV/gate+up weights and NeoX-style RoPE. See the architecture differences table below.

Forward Pass Flow

token (uint32)
  │
  ├── embed: tok_embd[token] → hidden[n_embd]
  │
  ├── for each layer (0..n_layer-1):
  │   │
  │   ├── RMSNorm(hidden, attn_norm)
  │   │
  │   ├── Attention:
  │   │   ├── Q = Wq @ normed  (+bias for Qwen2)
  │   │   ├── K = Wk @ normed  (+bias for Qwen2)
  │   │   ├── V = Wv @ normed  (+bias for Qwen2)
  │   │   ├── RoPE(Q, K, pos, rope_theta)
  │   │   ├── Write K,V to KV cache at pos
  │   │   ├── GQA: for each query head
  │   │   │   ├── scores = Q_head @ K_cache^T / sqrt(head_dim)
  │   │   │   ├── causal mask (future tokens = -inf)
  │   │   │   ├── softmax(scores)
  │   │   │   └── output = scores @ V_cache
  │   │   └── O = Wo @ concat(heads)  (+bias for Qwen2)
  │   │
  │   ├── residual: hidden += O
  │   │
  │   ├── RMSNorm(hidden, ffn_norm)
  │   │
  │   ├── FFN (standard) or MoE FFN (Mixtral):
  │   │   │
  │   │   ├── Standard SwiGLU (n_experts == 0):
  │   │   │   ├── gate = W_gate @ normed
  │   │   │   ├── up   = W_up   @ normed
  │   │   │   ├── act  = silu(gate) * up
  │   │   │   └── down = W_down @ act
  │   │   │
  │   │   └── MoE routing (n_experts > 0, Mixtral):
  │   │       ├── gate_logits = W_router @ normed    → [n_experts]
  │   │       ├── probs = softmax(gate_logits)
  │   │       ├── top_k = select top n_experts_used
  │   │       ├── weights = renormalize(selected probs)
  │   │       ├── for each selected expert e:
  │   │       │   ├── gate_e = expert[e].W_gate @ normed
  │   │       │   ├── up_e   = expert[e].W_up   @ normed
  │   │       │   ├── act_e  = silu(gate_e) * up_e
  │   │       │   └── out_e  = expert[e].W_down @ act_e
  │   │       └── down = Σ (weight_e * out_e)
  │   │
  │   └── residual: hidden += down
  │
  ├── RMSNorm(hidden, output_norm)
  │
  └── logits = W_output @ normed  →  float[n_vocab]

Key Operations

RMSNorm

rms = sqrt(mean(x^2) + eps)
out = (x / rms) * weight

Implemented as a single SIMD kernel (pn_vrms_norm_f32). Default eps = 1e-5, configurable per model via GGUF metadata.

RoPE (Rotary Position Embeddings)

Applies learned rotation to Q and K vectors based on position:

for each pair (i, i+1) in head:
    freq = 1.0 / (theta ^ (2i / head_dim))
    angle = pos * freq
    q[i], q[i+1] = rotate(q[i], q[i+1], angle)

rope_theta defaults to 10000 (Llama). Qwen2.5 uses 1,000,000 for extended context.

Grouped Query Attention (GQA)

GQA reduces KV heads relative to query heads. Each KV head is shared across n_head / n_kv_head query heads:

Model Q Heads KV Heads Ratio
SmolLM2-135M 9 3 3:1
Qwen2.5-0.5B 14 2 7:1
Mistral-7B 32 8 4:1
Phi-3-mini 32 32 1:1 (MHA)

The forward pass maps query head h to KV head h * n_kv_head / n_head.

SwiGLU FFN

gate = W_gate @ x
up   = W_up   @ x
out  = W_down @ (silu(gate) * up)

The silu(gate) * up step is fused into a single SIMD kernel (pn_vsilu_mul_f32).

Sliding Window (Mistral)

When sliding_window > 0, the KV cache operates as a circular buffer:

  • Write position: pos % max_seq (wraps around)
  • Readable length: min(pos + 1, max_seq) (prevents reading uninitialized slots)
  • max_seq = min(sliding_window, n_ctx)

This limits memory to a fixed window size regardless of sequence length. Standard Llama/Qwen2 use sliding_window = 0 (full context).

Mixture of Experts (Mixtral)

When n_experts > 0, the FFN sublayer is replaced with expert routing:

normed = RMSNorm(hidden)
gate_logits = W_router @ normed                   → [n_experts]
probs = softmax(gate_logits)
top_idx, top_wt = top_k(probs, n_experts_used)    → select best experts
top_wt = renormalize(top_wt)                       → weights sum to 1

ffn_out = 0
for each selected expert e:
    gate = expert[e].W_gate @ normed
    up   = expert[e].W_up   @ normed
    hid  = silu(gate) * up
    out  = expert[e].W_down @ hid
    ffn_out += top_wt[e] * out

hidden += ffn_out

Expert weight formats in GGUF:

Merged (most common): 3D tensors sliced into per-expert views (zero-copy mmap)

  • blk.N.ffn_gate_exps.weight [n_experts, n_ff, n_embd]
  • blk.N.ffn_up_exps.weight [n_experts, n_ff, n_embd]
  • blk.N.ffn_down_exps.weight [n_experts, n_embd, n_ff] (transposed)

Split (older): individual tensors per expert

  • blk.N.ffn_gate.E.weight [n_ff, n_embd] for E=0..n_experts-1

The loader probes for merged format first, falls back to split.

Architecture Differences

Shared Llama Forward Pass (llama.c)

Feature Llama Qwen2 Mistral Mixtral
Attention biases No Yes (Q,K,V,O) No No
Sliding window No No Yes (4096) No
MoE experts No No No Yes (n_experts, top-k)
GGUF key prefix llama.* qwen2.* llama.* llama.*
GGUF arch string llama qwen2 mistral llama (detected via expert_count)
RoPE theta 10000 1000000 10000 10000
RoPE style Interleaved Interleaved Interleaved Interleaved
Q/K/V weights Separate Separate Separate Separate
FFN weights Separate Separate Separate Per-expert (merged or split)
Tied embeddings model-dependent model-dependent No No

Note: Mistral GGUFs use llama.* metadata keys (not mistral.*), but general.architecture = "mistral". Mixtral GGUFs use general.architecture = "llama" and are detected via llama.expert_count > 0.

Phi-3 (phi3.c — separate forward pass)

Feature Phi-3
GGUF key prefix phi3.*
Q/K/V weights Fused attn_qkv.weight [3*n_embd, n_embd]
Gate/Up weights Fused ffn_up.weight [2*n_ff, n_embd]
RoPE style NeoX (split-half pairs: head[i], head[i+half])
RoPE dim Configurable via rope.dimension_count (96 for mini)
Attention MHA (n_head == n_kv_head = 32)
Tied embeddings No (separate output.weight)

Phi-3 uses its own forward pass because the fused weight layout requires different matvec + split logic, and NeoX RoPE pairs elements differently from the interleaved style used by Llama/Qwen2/Mistral.

Weight Storage

Two modes, selected automatically based on quant type support:

Quantized (default)

All projection weights (Wq, Wk, Wv, Wo, W_gate, W_up, W_down, W_output) stay in mmap'd quantized form as pn_qweight structs. Matrix-vector multiply happens via pn_qmv() which dequantizes one block at a time.

Embeddings and norms are always F32 (owned memory), since they're accessed by index (embeddings) or element-wise (norms).

F32 legacy

All weights dequantized to tm_tensor on load. Fallback for unsupported quant types. Uses standard tm_matmul for projections.

Per-Token Memory

All intermediate buffers are allocated from a bump arena that resets after each token:

Buffer Size Used For
normed n_embd RMSNorm output
q, k, v n_embd Q/K/V projections
attn_out n_embd Attention output
ffn_gate, ffn_up n_ff FFN intermediates
ffn_act n_ff SiLU activation
ffn_out n_embd FFN output
logits_raw n_vocab LM head output
scores max_seq per head Attention scores

For MoE layers (Mixtral), additional per-layer buffers:

Buffer Size Used For
gate_logits n_experts Router output
ffn_out n_embd Weighted expert accumulator
exp_gate n_ff Per-expert gate projection
exp_up n_ff Per-expert up projection
exp_hid n_ff Per-expert SiLU activation
exp_out n_embd Per-expert down projection

Arena is sized at state creation: sum of all buffers * sizeof(float) * 1.25 (25% safety margin). Typical sizes: 1.5 KB (SmolLM2-135M), 11 KB (Mistral-7B), 19 KB (Mixtral_7Bx2).

Batched Multi-Sequence Decode

pn_llama_forward_batch() decodes one token for each of B independent sequences in a single pass, for bulk throughput (e.g. transcribing many pages at once). Each states[b] carries its own KV cache and position, so the sequences can have different lengths and each attends only to its own cache; row b's input is tokens[b] or, for image-token positions, an embd_rows[b] override. It writes [B, n_vocab] row-major logits and advances every state's pos by one.

The batched path is restricted to the quantized, non-MoE Llama/Qwen weights. For MoE, F32 legacy, or ternary weights it returns without mutating any state so the caller can fall back to per-sequence pn_llama_forward. Results are bit-identical to running B separate sequential decodes (verified by test_batch_decode_correct: a B=2 batch reproduces each sequence's standalone greedy stream with no cross-contamination).

Fused AMX-panel M=B matmul (pn_qmm)

The projection and FFN matmuls in the batched path go through pn_qmm (src/core/qweight.c), a fused quantized M=B kernel: out[b, r] = sum_c dequant(W[r, c]) * acts[b, c]. Each weight row is dequantized once into a small L2-resident panel and multiplied against all B activation vectors, so the quantized weight is read and dequantized once per step while the matmul runs at AMX throughput (the panel GEMM lowers to AMX under BLAS). This avoids both the NEON-dot ceiling of the single-vector pn_qmv and the full-weight F32 copy to DRAM that a plain BLAS GEMM would need.

Because the per-token matmul FLOPs are irreducible, throughput is compute-bound and plateaus rather than scaling linearly with B. Measured on LightOnOCR 1B Q4_K, M4, against 38.8 tok/s single-stream pn_qmv:

path B=16 B=32
full-BLAS F32 copy 49.7 46.5 (DRAM-bound, regresses)
NEON fused dot 58.4 -
AMX panel (pn_qmm) 72.5 84.3

The AMX panel gives roughly 2.2x aggregate CPU throughput and settles near ~85 tok/s. B=1 is intentionally suboptimal (the panel adds a scratch pass over the fused pn_qmv), so single sequences should use pn_llama_forward. pn_qmm falls back to a per-b pn_qmv loop above PN_QMM_MAX_B (64) or for ternary or GPU-resident weights.

Flash (Blocked) Prefill Attention

Prefill over a long image-token sequence was the bulk-OCR bottleneck. The original per-query attention recomputed over the whole K/V cache once per query position, giving O(P^2) DRAM traffic across the ~1369 image-token queries of a page.

prefill_flash_range processes a block of queries (PF_BQ=32) together and streams K and V in blocks (PF_BK=256) that stay cache-resident and are reused across the whole query block. This cuts K/V reads from about P times to about P/BQ. The math is unchanged from the per-query path (full-precision softmax, with the scores tile kept L2-resident), and a per-query fallback is retained for the sliding-window wraparound case. It is parallelized over query heads.

Measured: prefill ~4.3 to ~3.2 s/page (~1.33x), bulk OCR ~6.3 to ~5.2 s/page steady state. It is pure CPU, so it helps regardless of the vision backend and without contending for the GPU (unlike GPU prefill below). Output stays correct and batched decode stays bit-identical.

Optional GPU Paths (off by default)

Two Metal offload paths exist behind build flags and environment switches. Both are off by default because, on the current hardware, they lose to the CPU AMX path or contend with the vision GPU. The shipped app builds Pantheon without USE_METAL, so both are inert there.

  • GPU batched decode (PN_BATCH_GPU, gated at runtime by pn_qmm_set_gpu_decode): uploads the Q4_K decoder weights once (pn_qmv_gpu_upload) and runs the M=B GEMM on the GPU (fm_sgemm_q4_k). Bit-identical to the CPU path, but not a win: pn_qmm stages, dispatches, syncs, and copies back per weight, so a step does ~168 CPU/GPU round-trips and the per-call sync latency dominates (~68 tok/s at B=16 vs ~86 tok/s for the CPU AMX panel). Auto-batching cannot hide it because each GEMM's result is immediately consumed by a CPU op (bias, RoPE, attention). Beating AMX would need a GPU-resident decode (the whole layer chain on GPU, one sync per step); the flag is kept as the seam for that.

  • GPU F16 prefill (PN_GPU_PREFILL): uploads the Q4_K decoder weights once and runs each prefill projection as an F16 MPS GEMM (fm_gemm_q4_k_f16) instead of the CPU dequant+AMX path. Also a net loss for bulk OCR, for two reasons: prefill is attention-bound, not GEMM-bound (the O(P^2) decoder attention over the image tokens dominates, so GPU-accelerating only the GEMMs is ~1.3x, not the ~5x a GEMM microbench suggests), and it contends with Core ML vision for the single GPU, breaking the CPU-compute / GPU-vision overlap that makes bulk fast (~6.3 s/page went to ~8-10 s/page). It is kept as an opt-in for the CPU-vision case, where the GPU is otherwise idle. Setting it also forces decode back onto the CPU AMX path (via the pn_qmm_set_gpu_decode gate) so the prefill upload does not drag decode onto the slower GPU route.

ABI note: pn_qweight.gpu is an unconditional struct field, present in every build (just NULL and unused without USE_METAL). It must not be wrapped in #ifdef USE_METAL: a conditional field makes the struct layout depend on the build flag, so a translation unit compiled the other way reads later fields at the wrong offset.