Skip to content

Counting: params, FLOPs, KV bytes, memory plans

ModuleM05.1 · build · Python · Pass 5 · 2 to 3 h
You buildpython/tinyllm/accounting.py: ModelConfig, param_count, flops_per_token, kv_bytes_per_token, memory_plan
Contractcourse/contracts/py/tinyllm/accounting.pyi
Testscourse/tests/M05.1/test_accounting.py (what they check: section 4), golden counts from Hugging Face transformers 5.19.0 in course/fixtures/M05.1/hf_param_counts.json (course/oracle/M05.1/hf_param_counts.py)
Needsno code from earlier modules · reading: M00.1 (powers and logs for orders of magnitude), S-M05 (the product and sum rules)
Used byL7.5 checks its cache hook against kv_bytes_per_token · L7.6 latent cache bytes · L7.9 {tinyllm} info · later: L10.2 admission by KV bytes, L11.1 and C1 memory and compute budgets
MilestoneMS-L7 (step 4: info reports the parameter count Hugging Face reports)
Optional depthKaplan et al., “Scaling Laws for Neural Language Models” (2020), section 2.1; Chowdhery et al., “PaLM” (2022), appendix B; Rajbhandari et al., “ZeRO” (2020), section 3.1; Korthikanti et al., “Reducing Activation Recomputation in Large Transformer Models” (2022), section 4
  • A decoder’s parameters are a sum of products of a few config integers; counted per component, they match Hugging Face exactly for dense, GQA, MoE, and MLA models (test_golden_hf_param_counts).
  • The KV cache grows with the number of kv heads, not query heads: GQA and MQA shrink it by H/HkvH / H_{kv}, MLA replaces it with one latent per layer (test_kv_bytes_scale_with_kv_heads_not_query_heads, test_mla_caches_the_latent).
  • A forward pass costs 2 FLOPs per parameter a token touches plus an attention term linear in the context; a training step costs three forwards (test_smollm2_flops, test_flops_attention_term_is_linear_in_context).
  • AdamW training costs 16 bytes per parameter before activations, in fp32 or bf16 alike (test_memory_plan_16_bytes_per_param).
  • Counts are exact Python integers: 671,026,404,352 is not 6.71×10116.71 \times 10^{11} (test_counts_are_python_ints).
Terminal window
ol start M05.1 # stubs accounting.py into your repo
ol tests M05.1 # read the test catalog first
ol check M05.1 # exit code is the verdict
ol diff M05.1 # after passing: your code against the reference

Pass 5 swaps your 2017 transformer for a Llama-family model and, at its milestone, loads SmolLM2-135M from Hugging Face. Before you load 270 MB of weights, three questions need numbers, not guesses. Does your model have the same parameters as theirs (MS-L7 step 4 compares {tinyllm} info with Hugging Face’s num_parameters(), and a missing norm or a double-counted tied head is off by thousands or by 28 million)? How many bytes of KV cache does one generated token cost (the Rust engine in Pass 7 admits requests by exactly this number, L10.2)? Will a training run fit in your laptop’s memory (C1 picks its model size from it)? This module answers all three by counting.

SymbolMeaningType / shape
VVvocabulary sizeint
ddmodel width, d_modelint
LLnumber of blocks, n_layersint
HH, HkvH_{kv}query heads, key/value headsint
dhd_hwidth of one head, d_headint
ffMLP hidden width, d_ffint
EE, kk, SS, fef_eexperts, experts per token (top_k), shared experts, expert widthint
rr, drd_r, dvd_v, rqr_qMLA latent width, rope width, value width, query latent widthint
TTcontext length, seq_lenint
PPtotal parametersint
NNmatmul parameters one token touchesint
bbbytes per number (dtype_bytes: 2 for bf16, 4 for fp32)int

Everything here uses two rules from S-M05. Product rule: a matrix with mm rows and nn columns holds m⋅nm \cdot n numbers. Sum rule: parts that do not overlap add. A Linear(in, out) layer stores a weight of out⋅in\text{out} \cdot \text{in} numbers, plus out\text{out} more with a bias.

The token embedding is a V×dV \times d table. The lm_head maps the final hidden state back to VV logits, another V×dV \times d matrix, unless the model ties them: then the head reuses the embedding and stores nothing (SmolLM2 does, saving 49152⋅576=28,311,55249152 \cdot 576 = 28{,}311{,}552 numbers, a fifth of the model). Tying saves parameters but not work: the head is still a matrix product per token.

A Llama block is RMSNorm, attention, RMSNorm, MLP (L7.1 to L7.5).

  • Attention (L7.5): qq is Hdh×dH d_h \times d, kk and vv are Hkvdh×dH_{kv} d_h \times d each, the output projection is d×Hdhd \times H d_h: 2dHdh+2dHkvdh2 d H d_h + 2 d H_{kv} d_h. Qwen-style biases add Hdh+2HkvdhH d_h + 2 H_{kv} d_h. MHA is Hkv=HH_{kv} = H; GQA shares each kv head among H/HkvH / H_{kv} query heads.
  • MLA (DeepSeek-V2/V3) compresses keys and values into a latent of width rr: a down projection (r+dr)×d(r + d_r) \times d (the extra drd_r is one shared rope key), a norm of rr, an up projection H(dh+dv)×rH (d_h + d_v) \times r, the queries H(dh+dr)×dH (d_h + d_r) \times d (or through a latent of width rqr_q with its own norm), and the output d×Hdvd \times H d_v.
  • Gated MLP (L7.2): gate and up are f×df \times d, down is d×fd \times f: 3df3 d f.
  • Mixture of experts: a router E×dE \times d, then EE experts and SS shared experts of 3dfe3 d f_e each. A token is routed to kk experts, so it touches only k+Sk + S of them: its active parameters are the total minus (E−k)⋅3dfe(E - k) \cdot 3 d f_e per MoE layer. DeepSeek-V3 keeps its first 3 layers dense.
  • Norms: two RMSNorm gains of dd per block (plus rr and rqr_q inside MLA), and one final gain of dd.

Generating token t+1t+1 needs the keys and values of tokens 1..t1..t in every layer. Recomputing them is quadratic work, so the engine caches them. One token adds a key and a value of dhd_h numbers for each kv head in each layer:

kv bytes per token=L⋅2⋅Hkv⋅dh⋅b.\text{kv bytes per token} = L \cdot 2 \cdot H_{kv} \cdot d_h \cdot b .

Query heads do not appear. That is the whole argument for GQA: SmolLM2’s 9 query heads read 3 kv heads, so its cache is a third of the MHA cache. MLA caches r+drr + d_r numbers per layer instead: DeepSeek-V3 stores 61⋅576⋅2=70,27261 \cdot 576 \cdot 2 = 70{,}272 bytes per token where an MHA cache of its 128 heads would take about 6 MB.

A multiply-add is 2 floating-point operations. In y=Wxy = W x each entry of WW is used in exactly one multiply-add per token, so a matrix product costs 2 FLOPs per parameter. Let NN be the matmul parameters one token touches: the attention projections, the active MLP, and the VdV d head (tied or not; the embedding lookup is a copy and the norm gains are elementwise, so they are left out). Attention itself adds work that has no parameters: the token’s query is dotted with TT keys (T⋅dqkT \cdot d_{qk} multiply-adds per head) and the TT weights average TT values (T⋅dvT \cdot d_v). Following PaLM, every token attends to all TT positions:

forward=2N+2LHT(dqk+dv),training step=3⋅forward,\text{forward} = 2N + 2 L H T (d_{qk} + d_v), \qquad \text{training step} = 3 \cdot \text{forward},

because the backward pass computes two products per forward product (the gradient with respect to the input and with respect to the weight, M08.3). The familiar "6N6N" is the training cost with the attention term dropped; for a small model at a long context that term is not small.

With PP parameters at bb bytes: the weights (bPbP), their gradients (bPbP), the optimizer states in fp32 (4P4P per state: SGD momentum keeps 1, AdamW keeps 2), and, when training in 16-bit, an fp32 master copy of the weights (4P4P) so small updates are not rounded away. AdamW in bf16 is 2+2+4+8=162 + 2 + 4 + 8 = 16 bytes per parameter, and in fp32 4+4+0+8=164 + 4 + 0 + 8 = 16 too. Then the activations: backward needs values saved during the forward. Per token per layer this contract counts the norm inputs and outputs (2d2d twice), qq, kk, vv, one softmax row of TT probabilities per head, the attention output, and the four MLP intermediates (4f4f); plus fp32 logits of VV per token. The softmax rows make activations grow with T2T^2 per sequence, which is why L11.1 recomputes them.

V=10V = 10, d=4d = 4, L=2L = 2, H=2H = 2, Hkv=1H_{kv} = 1, dh=2d_h = 2, f=6f = 6, untied.

  1. Embedding: 10⋅4=4010 \cdot 4 = 40. Head (untied): 40.
  2. Attention per layer: qq 4⋅4=164 \cdot 4 = 16, kk 2⋅4=82 \cdot 4 = 8, vv 8, output 4⋅4=164 \cdot 4 = 16: 48. Two layers: 96.
  3. MLP per layer 3⋅4⋅6=723 \cdot 4 \cdot 6 = 72, two layers 144.
  4. Norms: 2⋅42 \cdot 4 per layer, two layers, plus the final 4: 20.
  5. Total 40+96+144+20+40=34040 + 96 + 144 + 20 + 40 = 340.
  6. FLOPs at T=3T = 3: N=96+144+40=280N = 96 + 144 + 40 = 280, so 2N=5602N = 560; attention 2⋅2⋅2⋅3⋅(2+2)=962 \cdot 2 \cdot 2 \cdot 3 \cdot (2 + 2) = 96. Forward 656, training 3⋅656=19683 \cdot 656 = 1968.
  7. KV bytes in fp16: 22 layers ⋅ 2\cdot\ 2 (key and value) ⋅ 1\cdot\ 1 kv head ⋅ 2\cdot\ 2 wide ⋅ 2\cdot\ 2 bytes =16= 16.
  8. fp32 AdamW, batch 1, T=3T = 3: weights 1360, grads 1360, master 0, optimizer 2⋅4⋅340=27202 \cdot 4 \cdot 340 = 2720. Saved values per token per layer: 8+4+2+2+2⋅3+4=268 + 4 + 2 + 2 + 2 \cdot 3 + 4 = 26 (attention) plus 8+4⋅6=328 + 4 \cdot 6 = 32 (MLP) =58= 58; per token 2⋅58⋅4+4⋅10=5042 \cdot 58 \cdot 4 + 4 \cdot 10 = 504 bytes; three tokens 1512. Total 6952.

These are test_hand_example_params, test_hand_example_flops_and_kv_bytes, and test_hand_example_memory_plan.

@dataclass
class ModelConfig:
vocab: int; d_model: int; n_layers: int; n_heads: int; n_kv_heads: int; d_head: int; d_ff: int
tie_embeddings: bool; attn: Literal["mha", "gqa", "mla"] = "gqa"; kv_lora_rank: int = 0; qk_rope_dim: int = 0
n_experts: int = 0; top_k: int = 0; n_shared: int = 0
q_lora_rank: int = 0; v_head_dim: int = 0; d_ff_expert: int = 0; n_dense_layers: int = 0; qkv_bias: bool = False
def param_count(cfg) -> dict[str, int]: ... # embed, attn, mlp, norm, lm_head, total, active
def flops_per_token(cfg, seq_len: int, training: bool) -> int: ...
def kv_bytes_per_token(cfg, dtype_bytes: int) -> int: ...
def memory_plan(cfg, batch, seq, dtype_bytes, optimizer) -> dict[str, int]: ... # weights, grads, master, optimizer, activations, total
TestKINDChecksWhy it matters downstream
test_hand_example_paramsunitsection 3, steps 1 to 5you and the test agree on every component
test_hand_example_flops_and_kv_bytesunitsection 3, steps 6 and 7the FLOP and cache formulas
test_hand_example_memory_planunitsection 3, step 8the memory formula, term by term
test_golden_hf_param_countsgoldennine architectures against num_parameters(), per component{tinyllm} info in MS-L7
test_counts_are_python_intsboundaryevery count is an int; DeepSeek-V3’s 671,026,404,352no float rounding in large counts
test_tying_saves_exactly_one_matrix_of_params_but_no_flopspropertyuntying adds VdVd params, no FLOPsthe head is a product either way
test_kv_bytes_scale_with_kv_heads_not_query_headspropertySmolLM2’s 23,040 bytes; MHA, GQA, MQA ratiosL10.2 admission
test_mla_caches_the_latentunitDeepSeek-V3’s 70,272 bytes, independent of headsL7.6
test_moe_active_parameterspropertyMixtral and DeepSeek-V3 active countsMoE compute budgets in C1
test_smollm2_flopsunit410,517,504 FLOPs per token at 2048throughput estimates in L9.1
test_flops_attention_term_is_linear_in_contextpropertythe per-position step for GQA and MLAlong-context costs
test_flops_count_only_routed_expertspropertymore experts, same FLOPsMoE ablations
test_memory_plan_16_bytes_per_parampropertyAdamW 16 bytes in fp32 and bf16, SGD 12fitting C1 in memory
test_activations_linear_in_batch_quadratic_in_contextpropertybatch scaling and the softmax rowsrecomputation in L11.1
test_validationboundaryinconsistent configs raisewrong counts never pass silently
PitfallSymptomCaught by
1. counting a tied head twice28,311,552 too many for SmolLM2test_golden_hf_param_counts, test_tying_saves_exactly_one_matrix_of_params_but_no_flops (mutant s01)
2. sizing kk and vv by HH (GQA counted as MHA)attention too large by 2d(H−Hkv)dh2 d (H - H_{kv}) d_h per layertest_hand_example_params (mutant s02)
3. caching keys only (no factor 2)half the KV bytes; the engine over-admits and runs out of cachetest_hand_example_flops_and_kv_bytes (mutant s03)
4. training as two forwardsFLOPs, and every MFU estimate, a third lowtest_smollm2_flops (mutant s04)
5. counting every expert as activeMixtral “uses” 46.7B per tokentest_moe_active_parameters (mutant s05)
counts as floatsthe last digits of a 671B count rounded awaytest_counts_are_python_ints (mutant s14)
a tied head left out of the FLOPstying appears to save computetest_tying_saves_exactly_one_matrix_of_params_but_no_flops (mutant s06)
the attention term halved or droppedlong contexts look cheaptest_flops_attention_term_is_linear_in_context (mutant s07)
a master copy in fp32 training20 bytes per parameter instead of 16test_memory_plan_16_bytes_per_param (mutant s08)
the MLA cache counted per headDeepSeek-V3 looks 85 times more expensive to servetest_mla_caches_the_latent (mutant s09)
forgetting the final normoff by dd against Hugging Facetest_hand_example_params (mutant s10)
optimizer states at the training dtypebf16 AdamW 12 bytes, not 16test_memory_plan_16_bytes_per_param (mutant s11)
forgetting the softmax rowsactivation memory linear in contexttest_activations_linear_in_batch_quadratic_in_context (mutant s12)
forgetting MLA’s query-latent normoff by rqr_q per layertest_golden_hf_param_counts (mutant s13)
FLOPs over every expertMoE as expensive as densetest_flops_count_only_routed_experts (mutant s15)
DirectionModuleHow it uses this
BackM00.1powers of ten and logs for reading counts like 6.7×1096.7 \times 10^9
BackS-M05the product and sum rules, as pen-and-paper problems
ForwardL7.5its course tests check the bytes the attention layer hands its cache against kv_bytes_per_token
ForwardL7.6the MLA latent cache must cost exactly kv_bytes_per_token(attn="mla")
ForwardL7.9{tinyllm} info reports param_count
ForwardL10.2the Rust engine admits a request when its KV bytes fit
ForwardL11.1mixed precision, accumulation, and recomputation trade the terms of memory_plan
ForwardC1the capstone model size is chosen from flops_per_token and memory_plan
Your pieceProduction equivalentWhat it addsWhere to look
param_countPreTrainedModel.num_parameters()counts the live module, with exclude_embeddingstransformers/modeling_utils.py
flops_per_tokenMFU accounting in training frameworksmeasured throughput over the theoretical peak, per hardwarePaLM appendix B; nanoGPT estimate_mfu
kv_bytes_per_tokenvLLM’s cache sizingprofiles free memory, then divides by block bytesvLLM CacheEngine.get_cache_block_size
memory_planDeepSpeed and Megatron memory estimatorssharding (ZeRO stages 1 to 3), tensor and pipeline parallelism, selective recomputationZeRO section 3; Korthikanti et al. section 4