rml / Models and memory

Models and memory

Which open-weight models rml targets, the arithmetic that decides whether each one fits a 16 GB Mac, and how far each model has actually got: config parsing, golden tests on real weights, or still planned.

On this page
  1. The budget and the formula
  2. The parameter count in code
  3. Milestone: Llama 3.1 8B Instruct
  4. Worked example: the KV cache
  5. Why the memory number is also the speed number
  6. Second architecture: Qwen3 8B
  7. Mixture of experts
  8. Status per model
  9. Test models
  10. Getting the weights
  11. Tests

In short: rml's budget is a 16 GB Apple Silicon Mac with the inference process under about 11 GB peak RSS, and docs/models.md works the numbers for each target from its config.json: weights at a given bits per weight, plus a KV cache, prefill scratch and runtime overhead. Llama 3.1 8B fits at Q8_0 (about 10.2 GB, tight) and Q4_K_M (about 6.6 GB, the performance target) but not bf16; Qwen3 8B is the second dense architecture; Qwen3-30B-A3B does not fit, so OLMoE-1B-7B proves the MoE path. In the code, ParameterCount reproduces the parameter counts and its tests pin them. Five real checkpoints, from SmolLM2-135M to OLMoE-1B-7B, pass golden tests; the 8B milestone models and every measurement of peak RSS are still planned.

The models page sits beside the rest of rml's planning documents: the milestone in progress.md L5–8 is that rml-chat loads Llama 3.1 8B Instruct at 4-bit by mmap, stays under about 11 GB peak RSS, matches the reference top-1 tokens and decodes within 1.5× of llama.cpp, and the README's goal says the same (README.md L8–10). How weights stay mapped is on Model files; the block formats behind "bits per weight" are on Quantization; the concepts are quantization and the KV cache.

The budget and the formula

The process gets about 11 GB so macOS, the window server and a browser keep about 5 GB. Weights are mmapped and only count toward RSS as pages are touched, but decode touches every weight on every token, so in steady state the whole file is resident: the document plans as if it all counts (models.md L3–6). Every table on the page is built from the same parts (L8–22):

ItemFormula or figureMeaningSource
Pattention + MLP + norms + embeddings + LM headParameter count computed from each config.json, not taken from the model's marketing name.L10–11
Bits per weightbf16 16, Q8_0 8.5, Q4_0 4.5, Q4_K ≈ 4.5; a llama.cpp Q4_K_M file ≈ 4.9Q8_0 is 32 int8 values plus one f16 scale per block; Q4_K_M keeps part of ffn_down, attn_v and the output at Q6_K.L12–14
KV cache2 × layers × kv_heads × head_dim × context × bytesKeys and values for every layer and position.L15
Activations< 10 MB for decode; 512 × max(ffn, q_dim) floats per buffer for a 512-token prefill chunkA few hidden-size vectors per layer when decoding; at most a few hundred MB for prefill. Logits only for the last prompt token.L16–18
Runtime300 MBCode, tokenizer tables, thread stacks, allocator slack.L19

Figures are decimal GB, and the document asks that they be checked against the downloaded file size before anyone relies on them (L21–22). Two of its assumptions run ahead of the code. The prefill row assumes 512-token chunks, but Session::Generate runs the whole unmatched prompt in one Forward at the pin, so chunked prefill is planned (see Generation). And no code at the pin calls madvise, so dropping each layer's pages after a full-precision golden run (L41–43) is planned too.

The parameter count in code

ParameterCount(const ModelConfig&) is the same sum, counting a tied head once (config.cc L495–506): per layer, the Q, K, V and output projections, any q/k/v bias and QK-norm weights, three hidden × intermediate matrices per expert (one expert for a dense model) plus the router, and two norm vectors (four with Gemma 3's sandwich norms); then the final norm and the embedding, doubled when the head is untied. The config tests pin it for each family, so the numbers docs/models.md quotes are checked in CI:

ConfigParameterCountmodels.md saysTest
SmolLM2-135M (committed smollm2_135m.config.json)134,515,0080.135 Bconfig_test.cc L27–50
Llama 3.1 8B (committed llama31_8b.config.json)8,030,261,2488.03 BL52–65
Qwen2 0.5B-shaped (inline)494,032,768—L67–80
Qwen3 0.6B-shaped (inline)596,049,920—L82–95
Mixtral 8×7B-shaped (inline)46,702,792,704—L107–120
OLMoE-1B-7B (inline)6,919,161,8566.9 BL136–161
Gemma 3 1B (inline)999,885,952—L163–186

Qwen3 8B and Qwen3-30B-A3B have no config in the tests; their counts (8.19 B and 30.5 B) come only from the document.

Milestone: Llama 3.1 8B Instruct

32 layers, hidden 4096, 32 query heads and 8 KV heads (GQA 4:1), head_dim 128, SwiGLU FFN 14336, vocab 128256, an untied head, RoPE θ = 500000 with llama3 frequency scaling (factor 8, low/high factors 1 and 4, original context 8192). P = 8.03 B, of which the embedding and the untied head are 0.53 B each (models.md L24–31); the same scaling fields are what ParsesLlama31RopeScaling checks.

Llama 3.1 8B memory at three precisions Three horizontal stacked bars on a gigabyte scale, each made of weights, an 8K-token f16 KV cache of 1.07 GB, and 0.6 GB of prefill scratch and runtime. bf16 weights are 16.06 GB, total about 17.7 GB, past both the 11 GB budget line and the 16 GB physical RAM line. Q8_0 weights are 8.53 GB, total about 10.2 GB, just under the 11 GB line. Q4_K_M weights are about 4.9 GB, total about 6.6 GB, well under it. 11 GB budget 16 GB RAM bf16 weights 16.06 ≈ 17.7 GB: does not fit Q8_0 weights 8.53 ≈ 10.2 GB: fits, about 0.8 GB to spare Q4_K_M ≈ 4.9 ≈ 6.6 GB: fits; the performance target 0 4 8 12 16 GB weights KV, 8K ctx, f16 (1.07) scratch + runtime (0.6)
The table at models.md L33–39, drawn to scale (27 px per GB). These are the document's estimates; rml has not measured peak RSS for any model at the pin.
Componentbf16Q8_0Q4_K_M
Weights16.06 GB8.53 GB~4.9 GB
KV cache, 8K ctx, f161.07 GB1.07 GB1.07 GB
Prefill scratch (512-token chunk)0.3 GB0.3 GB0.3 GB
Runtime0.3 GB0.3 GB0.3 GB
Total~17.7 GB, does not fit~10.2 GB, fits, tight~6.6 GB, fits

The consequences (L41–45): bf16 cannot be the runtime format, so full-precision checks on the 8B model either page through the mmap layer by layer or run Q8_0 against bf16 reference logits; Q8_0 fits with about 0.8 GB to spare and an int8 KV cache adds headroom; Q4_K_M is the main performance target. rml can run both quantized formats: DotActivationType pairs Q8_0 and Q4_0 weights with Q8_0 activations, and Q4_K, Q5_K and Q6_K (the mix inside a Q4_K_M file) with Q8_K (dot.cc L285–297).

Worked example: the KV cache

With the Llama 3.1 8B shape, one position in one layer stores kv_heads × head_dim = 8 × 128 = 1024 keys and as many values. At f16 and an 8192-token context:

2 (K, V) × 32 layers × 8 kv_heads × 128 head_dim × 8192 tokens × 2 bytes
  = 1,073,741,824 bytes ≈ 1.07 GB

rml's KvCache holds exactly that per position and layer as a row of row_bytes() (kv_cache.h L55–56): 4096 bytes at f32, 2048 at f16. Its int8 mode is not quite half of f16, because each group of quant_group() channels (at most 32) carries one f32 scale, 1.125 bytes per entry (L13–18): a 1152-byte row here, so about 0.60 GB for 8K tokens where models.md L44 rounds the int8 cache to 0.54 GB by leaving the scales out. The cache grows a 16-token page at a time out of an arena reserved up front, and pages it never touches are never made resident. KvCache::Create defaults to f32 (L40–43), but rml-chat asks for f16 and a 4096-token context unless told otherwise (main.cc L48–49), which is 0.54 GB for this model. The layout is drawn on The decoder.

Why the memory number is also the speed number

Decode is memory bound: each token reads every weight once. At Q4_K_M (about 4.9 GB a token) the ceiling is about 14 tok/s on an M1 (68 GB/s), 20 on an M2 or M3 (100 GB/s) and 24 on an M4 (120 GB/s), and achieved GB/s as a fraction of that is meant to be the headline benchmark (L47–49). That benchmark, beside llama.cpp on the same file, is planned (progress.md L71); see Kernels.

Second architecture: Qwen3 8B

36 layers, hidden 4096, 32 query and 8 KV heads, head_dim 128, FFN 12288, vocab 151936, an untied head, per-head RMSNorm on Q and K before RoPE, θ = 1000000; P = 8.19 B (models.md L51–56).

ComponentQ8_0Q4_K_M
Weights8.70 GB~5.0 GB
KV cache, 8K, f161.21 GB1.21 GB
Scratch + runtime0.6 GB0.6 GB
Total~10.5 GB, fits, very tight~6.8 GB, fits

The table is L58–63. Qwen3 differs from Llama only in config-driven ways, which is the point of choosing it: the config-driven DecoderLM has to absorb QK-norm, a different θ and no RoPE scaling with no new model class (L65–66); how it does is on The decoder. The document also flags that newer releases (Qwen3.5, newer Gemma and Llama generations) could not be checked from a sandbox without Hugging Face access, and asks that the table be redone before Phase 6, preferring a newer dense ~8B model if one fits (L68–71).

Mixture of experts

A sparse model touches only its active experts per token, but the whole file still has to be stored somewhere. Mixture of experts explains the routing; here is what it does to the budget (models.md L73–96):

ModelShapeP (total / active)FootprintVerdict
Qwen3-30B-A3B48 layers, hidden 2048, 128 experts (8 active, FFN 768), 4 KV heads30.5 B / 3.0 BQ4_K_M ~19 GB with KV; Q3_K_M ~15 GBDoes not fit: over physical RAM, or over the 11 GB budget
OLMoE-1B-7B16 layers, hidden 2048, 64 experts (8 active, FFN 1024), 16 KV heads, vocab 503046.9 B / ~1.3 BQ8_0 ~8.5 GB, Q4_K_M ~5.2 GB with a 4K f16 cacheThe MoE path proof
Qwen1.5-MoE-A2.7B—14.3 B~8.8 GB at Q4Fallback if OLMoE's router needed special handling

Streaming experts from SSD through the page cache is rejected: which experts a token touches changes every token, so the resident set still grows toward the whole file and the OS would thrash (L83–85). OLMoE won out without special handling; its real-weight golden passes, and a Qwen1.5-MoE golden is listed only as a later step (progress.md L293).

Status per model

Three sources describe where each model stands, and they are not equally current. The README's table (README.md L249–256) lists only the targets and test models; progress.md L58–59 lists the real-weight goldens that have run; and the code says what is actually checked: real_model_test's case list (real_model_test.cc L130–134) and the golden files under rml/llm/testdata/goldens.

ModelRoleFormats (README)Real-weight goldenStatus at the pin
Llama 3.1 8B InstructMilestoneGGUF Q4_K_M, Q8_0; safetensors bf16noneplanned: needs the Mac (progress.md L291–292); config and parameter count tested
Qwen3 8BSecond architectureGGUF Q4_K_M, Q8_0noneplanned; the family is covered by Qwen3-0.6B's golden
OLMoE-1B-7B-0924-InstructMoE proofGGUF Q4_K_M, Q8_0; safetensors bf16OLMoE-1B-7B-0924-Instruct.jsonGolden-tested, worst logit error 1.3e-4
SmolLM2-135M-InstructCI test modelsafetensors, GGUFSmolLM2-135M-Instruct.jsonGolden-tested (1.4e-4), and the only checkpoint CI downloads
TinyLlama-1.1B-Chat-v1.0Local test model, Llama-shapedsafetensors, GGUFTinyLlama-1.1B-Chat-v1.0.jsonGolden-tested (1.1e-4)
Qwen3-0.6BQwen3 family checknot in the READMEQwen3-0.6B.jsonGolden-tested (1.1e-4)
gemma-3-1b-itGemma 3 family checknot in the READMEgemma-3-1b-it.jsonGolden-tested (1.7e-4); progress.md L79 still calls real Gemma 3 weights untested

The worst-error figures are the ones progress.md L58–59 reports, all in f32 and all run in the development container; the test itself allows 2e-3 per logit (real_model_test.cc L33). The README's status line says phases 1–7 are done apart from the macOS-only work: real-weight golden tests, NEON and Metal kernels, and the llama.cpp comparison (README.md L15–16).

Test models

ModelShapePbf16Q8_0Use
Random toy config2 layers, hidden 64, 4 heads, 2 KV heads, vocab 256~0.1 M——Unit tests, no download
SmolLM2-135M30 layers, hidden 576, 9 heads, 3 KV heads, tied head0.135 B0.27 GB0.14 GBCI golden tests
TinyLlama 1.1B22 layers, hidden 2048, 32 heads, 4 KV heads1.10 B2.20 GB1.17 GBLocal golden tests

SmolLM2-135M uses the Llama block with a tied head and GQA 3:1, so it runs the same code path as the 8B target at a size CI can download and run in seconds (models.md L98–107). The toy configs are generated by tools/make_llm_fixtures.py and live in the repository; how they and the real goldens are made is on Build and testing.

Getting the weights

Weights live in a gitignored models/ directory and are never committed (AGENTS.md L40). tools/download_model.py fetches a Hugging Face checkpoint into models/<name> using only the standard library:

ItemBehaviourSource
Argumentsrepo (owner/name), --revision (default main), --dest (default models/<name>)L49–55
FilesThe six configs rml reads (config.json, generation_config.json, model.safetensors.index.json, special_tokens_map.json, tokenizer.json, tokenizer_config.json) and top-level *.safetensors, sharded or notL34–45
PinningResolves the revision to a commit through the hub API and downloads every file from that commitL57–68
ResumeSkips a file already present at the expected size; writes to .partial and retries a short transfer up to 3 timesL64–78
Gated modelsLlama and Gemma need a token with access in $HF_TOKEN, sent as a bearer headerL27–31

Only safetensors are downloaded; GGUF files such as the README's llama-3.1-8b-q4_k_m.gguf are fetched by hand. CI uses the script for one model, SmolLM2-135M-Instruct at a pinned revision, cached between runs (ci.yml L48, L56–63).

Tests

FileTestsWhat it pins
config_test.cc19Each family's parsed config, and ParameterCount for seven of them, including the committed Llama 3.1 8B and SmolLM2-135M configs.
real_model_test.cc3 per model, 5 modelsTokenizer ids, the chat prompt, and top-8 logits plus an 8-token greedy continuation against each golden; skipped when $RML_MODELS_DIR has no copy (L1–6).