我正在让 LLM 在小型和老旧设备上运行
I'm making LLMs work on small and old devices

原始链接: https://github.com/hyperionaidev/smolmaxxing/

smolmaxxing 探索在性能不足、空闲的设备上运行大语言模型。在小米电视盒子 S(第三代)——Amlogic S7D、约 1 GB 可用内存、未 root——上,它运行从 260K 到 16B 参数的模型,Qwen3.5-0.8B 达到 16.8 令牌/秒,2B 为 7.7,8B MoE 为 7.2,dense 1-bit 8B 为 5.2,16B MoE 为 5.1。2013 年的 iPad Air 运行 1.7B 模型可达 24–26 令牌/秒,4B 约 10,最高 16B 的 MoE 模型为 2.6–4.5 令牌/秒。性能提升来自自定义 ARM 内核(Q2_K 重打包、1-bit 查表、融合 DeltaNet)、内存技巧(裁剪词汇表头部、无头模式、flash 重打包缓存),以及基于 flash 的权重流式加载(专家分页、缓存感知路由、预取)。关键发现:对于 2B 以上模型,瓶颈是 flash 带宽而非 CPU,因此 MoE 有效;且按字节计,更大的模型并不总是更好。所有声明均在真实硬件上测量。MIT 许可。

Hacker News 用户 mrashad74 宣布了一个在小型旧设备上运行 LLM 的开源项目。他们报告称,在第一代 iPad Air 和小米电视盒子上——两者可用内存均不到 1GB——以不错速度运行 16B 模型。该工作,包括开发的内核、可复现结果和修改后的模型(托管在 Hugging Face),正在开源。他们向社区征求更有挑战性设备的目标建议。评论者称赞了用 AI 复活旧技术的趋势;作者提到自己有备用手机和平板可测试,还有一位评论者开玩笑建议带屏幕的电子烟。
相关文章

原文

🤏 smolmaxxing: run the biggest model you can on the smallest hardware you own

Every device around you is more capable than it looks. TV boxes, old phones, single-board computers and routers all ship with CPUs, RAM and flash that sit idle most of the day. smolmaxxing is about unlocking that capability: finding the biggest language model each device can really run, then pushing it further with custom kernels, smarter memory use and weight streaming. Every claim is measured on the hardware itself.

Device #1 is a Xiaomi TV Box S (3rd Gen): Amlogic S7D, 4× Cortex-A55 @ 2.5 GHz, 1.9 GB of RAM (about 1 GB usable), eMMC flash, Android 14, no root. On it, this repo runs models from 260 K to 16 B parameters:

  • Qwen3.5-0.8B: 16.8 tok/s (2.2× the stock 7.5; measured in the evening of 11-05, see §17).
  • Qwen3.5-2B: 7.7 tok/s in real chat. The box's speed drifts by ~10 % over a day, so every step is measured back to back: §16 gave +11 %, §17 another +7 %.
  • An 8B mixture-of-experts model (Moon-Beam-8B-A1B): 7.2 tok/s in real chat, its experts paged from flash on demand and prefetched one layer ahead (§19).
  • A dense 1-bit 8B model (Bonsai-8B): 5.2 tok/s in real chat, up from 1.2: fully resident in a "headless" mode plus a lookup-table kernel (§19).
  • A 16B mixture-of-experts model (Ling-mini-2.0): 5.1 tok/s in real chat, coherent over 600-word answers, the same expert pager applied unchanged (§20). Twice Moon-Beam's size, but not better per byte: bigger only helps on this box when the model is also better.
  • A dense 9B model: 0.27 tok/s, streaming most of its weights from flash.

Device #2 is a first-generation iPad Air (2013): Apple A7, 2 cores, NEON without dotprod, 1 GB of RAM, iOS 12.5.7, jailbroken, CPU only. A 13-year-old tablet runs a 1.7B-parameter model at 24-26 tok/s (18-22 in long chats), a 4B one at about 10 tok/s, an 8B mixture of experts at about 3.5 tok/s (5 with half its experts, at a quality cost), and a 16B one at 2.6-4.5 tok/s, with their experts paged from flash, from a chat page in its own Safari. See Results on device #2.

Every step is written up experiment by experiment in EXPERIMENT.md (E1-E160).

The live token-stream page served by llama-server on the TV box

Device Status
Xiaomi TV Box S (3rd Gen): Amlogic S7D, Cortex-A55, 2 GB ✅ done: 260K → 16B parameters, 8B at 5-7 tok/s, 16B at 5 tok/s
iPad Air (2013): Apple A7, 2 cores, 1 GB, iOS 12.5.7, jailbroken (ipad-air/) ✅ running: Bonsai-1.7B (1-bit) at 24-26 tok/s, 18-22 in long chats; Bonsai-4B at about 10 tok/s; Moon-Beam-8B-A1B (MoE) at about 3.5 tok/s; Ling-mini-2.0 (16B MoE) at 2.6-4.5 tok/s; CPU only
More TV boxes, phones, single-board computers, routers… 🔜 coming soon

The techniques aren't tied to one box. The 1-bit lookup-table kernel needs only base NEON, as the iPad shows; any ARM chip with dotprod gets the other kernels; and any Linux or Android device with more model than RAM can use the streaming engine.

Best generation speed per size class on the two 1 GB devices: 2B class TV 7.7 (Qwen3.5-2B) vs iPad 23.7 (Bonsai-1.7B, 1-bit); 4B 4.3 vs 9.4; 8B dense 5.2 vs 0.08; 8B MoE 7.2 vs 3.5; 16B MoE 5.1 vs 4.5 with the same cache bias

Best model per size class, first run on the TV box to now: 0.8B 7.5 to 16.8 tok/s, 2B 0.1 to 7.7, 4B 2.2 to 4.3, 8B dense 1.21 to 5.2, 8B MoE 1.87 to 7.2, 9B 0.16 to 0.27, 16B MoE 4.1 to 5.1; and where one Moon-Beam token's 144 ms go

Class Best model First run → now (tok/s) Gain Bits/byte What got it there Download
0.8B Qwen3.5-0.8B 7.5 → 16.8 bench ×2.2 ≈1.00 all-Q4_K, padded 65K head, fp16 DeltaNet state HF
2B Qwen3.5-2B 0.1 → 7.7 chat ×77 0.909 all-Q4_K, padded 65K head, fused DeltaNet HF
4B Ternary-Bonsai-4B 2.2 → 4.3 chat ×2.0 ≈0.965 ternary weights in 2-bit fields, merged scales HF
8B dense Bonsai-8B (1-bit) 1.21 → 5.2 chat ×4.3 0.974 1-bit LUT kernel, fully resident in headless mode HF
8B MoE Moon-Beam-8B-A1B 1.87 → 7.2 chat ×3.9 0.996 Q2_K experts paged from flash, cache-aware routing, prefetch HF
9B Qwen3.5-9B 0.16 → 0.27 chat ×1.7 0.885 weight streaming plus its own MTP head rebuild (q9)
16B MoE Ling-mini-2.0 4.1 → 5.1 chat ×1.2 1.054 the Moon-Beam recipe, unchanged HF

"First run" is the first time each model ran on the box:

  • 0.8B: the stock Q4_K_M file.
  • 2B: the stock file, streamed from flash because it didn't fit in RAM. Its first build that fit ran at 4.8, so from there the gain is ×1.6.
  • 4B: the stock file, which thrashed.
  • 8B dense: the last 0.1 GB streamed from flash.
  • 8B MoE: the first expert-pager run, without routing bias.
  • 9B: streamed.
  • 16B: started on the Moon-Beam recipe, so its gain is small.

On this box, the 2B is the best speed-quality balance. Ling-mini-2.0 shows that bigger isn't automatically better: it is twice Moon-Beam's size, but scores worse per byte and runs slower.

What we built, grouped by the bottleneck it attacks (details and numbers in What's in here):

Bottleneck Innovations Biggest wins
Compute (in-order A55, 10 items) Q2_K and ternary repacks for vdotq_laneq, 1-bit bit planes, a 1-bit lookup-table kernel, ternary in 2-bit fields, Q8_0 attention with a skip threshold, fused Gated-DeltaNet, DeltaNet state read by id / fp16, the LM head on the fast kernel, guided matmul scheduling Q2_K 7.8 → 13.9 G weights/s; Bonsai-8B 3.77 → 5.1-5.4; 4B 3.2 → 4.3; long answers no longer halve the speed
RAM (~1 GB, 5 items) pruned LM head, lossless 1.875-bit ternary, headless mode, a fork-free memory guard, a repack cache on flash −0.2 to −0.5 GB per model; free RAM ~1,000 → ~1,300 MB; Bonsai-8B 1.2 → 3.6 before any kernel work
Flash (278 MB/s, 5 items) weight streaming with O_DIRECT, MTP speculation when flash-bound, expert pager, cache-aware routing, pre-gated prefetch 9B +69 %; expert misses 13 % → ~2 %; Moon-Beam 4.4 → 7.2
Output quality (2 items) a long-answer degeneration test, a repeat penalty of 1.15 over 256 tokens degenerate long answers 12/13 → 1/13

Quality is English perplexity on a fixed 22 KB text, converted to bits per byte so models with different tokenizers compare fairly (lower is better). Speed is generation on the TV, measured either through llama-server with real chat requests ("chat") or with llama-bench ("bench").

Model Size on the box Quality (bits/byte) Generation Prompt Notes
stories260K (llama2.c) 1 MB n/a ~650 tok/s n/a the first thing that ran (§1-8)
Gemma 3 270M Q4_K_M 253 MB n/a 22 tok/s 73 tok/s
Qwen3.5-0.8B Q4_K_M (stock) 497 MB n/a 7.5 bench 28 tok/s
Qwen3.5-0.8B, all-Q4_K + 65K head 442 MiB (566 MB RSS) ≈1.00* 16.8 bench 33 tok/s best speed for its quality so far (§15-17)
Qwen3.5-2B, all-Q4_K + 65K head 0.99 GB 0.909 7.7 chat 17.8 the default: best overall (§11, §16, §17)
Ternary-Bonsai-4B, merged scales + 2-bit fields 1.03 GB ≈0.965 4.3 chat (4.8 bench) 9.6 best 4B: was 3.2 chat / 7.0 prompt on the same build (§18)
Moon-Beam-8B-A1B (MoE), Q2_K experts + 60K head 0.48 GB GGUF + 2.6 GB repack cache on flash, ~1.1 GB RSS 0.996 7.2 chat n/a fastest 8B: 128 experts, 6 used; expert pager, cache-aware routing, pre-gated prefetch, repeat penalty against long-answer loops (§19, headless mode)
Ling-mini-2.0 (MoE, 16.3 B), Q2_K experts + 60K head 0.47 GB GGUF + 5.3 GB repack cache on flash, ~1.13 GB RSS 1.054 5.1 chat n/a biggest model at a usable speed: 256 experts of 1 MB, 8 used, pool 43 per layer, bias 0.3 (§20)
Bonsai-8B, 1-bit, LUT kernel 0.96 GB, all resident 0.991 (0.974 on the head) 5.2 chat (5.1-5.4 bench), 4.0 at ~900 tokens of context 5.8-6.1 best dense 8B: headless mode, Q8_0 attention (§19)
Bonsai-8B, 1-bit + compact scales 0.84 GB + 0.12 GB streamed 0.991 1.21 chat (2.2 bench) 4.9 §14, before headless mode
Ternary-Bonsai-8B 0.89 GB + 0.80 GB streamed 0.919 0.31 bench 3.45 §14
Qwen3.5-9B UD-IQ2_XXS + MTP 0.63 GB + 1.6 GB streamed 0.885 0.16 chat, 0.27 with MTP 0.8 best quality; flash-bound (§14)

* measured on the pruned head's positions (1.3 % of tokens fall outside it), so not exactly comparable; the stock full-vocabulary Q4_0 file scores 1.024.

Other small models, stock files (screening, §15 E54):

Model Generation (bench) Prompt Bits/byte
LFM2.5-350M (QAD Q4_0) 23.7 tok/s 104 tok/s 1.805
LFM2-700M (Q4_0) 11.5 tok/s 52.8 tok/s 1.169
Qwen3-0.6B (Q8_0) 9.35 tok/s 50.6 tok/s 1.059
LFM2.5-1.2B (QAD Q4_0) 8.3 tok/s 31.6 tok/s 1.133
Llama 3.2 1B (Q4_0) 7.0 tok/s 29.5 tok/s 0.998
Ternary-Bonsai-2-2B (Q2_0) 5.9 tok/s 15.8 tok/s 1.124
Gemma 3 1B (Q4_K_M) 5.7 tok/s 11.1 tok/s 1.260
Granite 4.0 H 1B (Q4_0, ~1.5 B) too big as shipped 0.916
↳ rebuilt: all-Q4_K, Q8_0 embedding, 48K head (E62) 5.29 tok/s (fits only after freeing RAM, 950 MB RSS) 0.919
LFM3-1.7B (QAD Q4_0) doesn't fit

Granite 4.0 H 1B was the one model close to the 2B's quality. Rebuilt our way (E62), it runs at 5.29 tok/s, below the 2B's 6.55 at slightly lower quality: it carries about twice the 0.8B's weight bytes per token.

Physics of the box: one process can hold ~0.96 GB before Android starts killing things (~1.15 GB in headless mode, with the launchers and Google services force-stopped), and flash reads top out at 278 MB/s, with a lone ~1 MB random read costing ~6 ms. A model that has to stream X MB per token can never beat 278 / X tokens per second. That, not the CPU, is what limits everything above 2B, and why a mixture-of-experts model, which touches ~1 B of its 8 B weights per token, is the way past it.

10 tok/s at 8 B? Measured, not guessed (E98): Moon-Beam reaches 10.4 tok/s with 2 experts per token, but the text turns to gibberish; the best coherent setting is ~7-8 tok/s. Dense Qwen3-8B is Bonsai-8B at 1 bit, and the smallest Qwen MoE (35B-A3B) touches 3× Moon-Beam's weights per token.

A 2013 iPad Air: Apple A7, 2 cores, NEON without dotprod, 1 GB of RAM, iOS 12.5.7, jailbroken with Amethyst. Everything runs on the CPU with 2 threads and a 1,024-token context, cross-compiled from a Mac. The write-up is §21 of EXPERIMENT.md, and build steps are in ipad-air/README.md.

Model File Generation (tok/s) Peak RSS Fixed three-question test
Bonsai-1.7B Q1_0 (1-bit) 237 MiB 23.7-25.8, 18-22 through long chats 605 MB all three right, and writes correct code: the one to use
Bonsai-4B Q1_0 (1-bit), -c 512 -b 64 546 MiB 9.4-11.4, 9.2-10.3 through long chats; as a server, iOS resets it within minutes 615-682 MB right on France, tides mostly right, fails recall every run; better per byte (1.073 vs 1.158 bits/byte)
Moon-Beam-8B-A1B (MoE, Q2_K experts), expert pager, -c 512 -b 64 2.6 GiB (474 MB mapped) 3.5-3.6 on longer answers; 4.9-5.1 with --experts 4 (+27 % perplexity) ~600-625 MB all three right; a session model, too big for the always-on server
SmolLM2-360M-Instruct Q4_K_M 258 MiB 10.9-12.5 269-316 MB all three right; the lightest good option
Gemma 3 270M-it Q4_K_M 241 MiB 15.4-19.0 248-294 MB wrong on tides, empty reply on recall
SmolLM2-135M-Instruct Q4_K_M 101 MB 18.6-29.3 141 MB right on France and recall, weak on tides
Qwen3.5-0.8B Q4_K_M 508 MiB about 0.2 506 MB thrashes the page cache, stopped by hand

Only the Bonsai and Moon-Beam rows were measured on the latest build. The other rows predate the attention and sampler work below.

Best model per size class on the iPad Air, first run to now: 1.7B 2.75 to 23.7-25.8 tok/s; 4B 9.4-11.4; 8B dense 0.08; 8B MoE can't load with stock llama.cpp, then 1.6 to 3.5 (5.0 with 4 experts); 16B MoE can't load, now 2.6 (4.5 with bias 0.3); and where one Moon-Beam token's ~0.3 s go

Class Best model First run → now (tok/s) Gain What got it there
1.7B Bonsai-1.7B (1-bit) 2.75 → 23.7-25.8 ×8.6 multiplication-free and lookup-table 1-bit kernels without dotprod, the LM head on the fast kernel, row-order attention with pruning, a one-pass sampler
4B Bonsai-4B (1-bit) 9.4-11.4 the 1.7B's kernels, attention for 4 q heads per K head, -c 512 -b 64 to stay under the ~700 MB ceiling
8B dense Bonsai-8B (1-bit) 0.08 doesn't fit in RAM; most of the file is re-read from flash every token
8B MoE Moon-Beam-8B-A1B can't load → 1.6 → 3.5 (5.0 with 4 experts, +27 % perplexity) ×2.2 expert pager, Q2_K lookup-table kernel, prefetch, cache bias 0.03, frequency-based eviction
16B MoE Ling-mini-2.0 can't load → 2.6 (3.3 at +2.6 %, 4.5 with bias 0.3) new the Moon-Beam recipe, and a model file assembled over HTTP and streamed straight to the iPad

"Can't load": iOS 12 lets a process map at most about 1.7 GB, so stock llama.cpp fails on the 2.6 and 5.5 GB files before running a token. "First run" for Moon-Beam is the first expert-pager run (pool 8, no bias).

What we built on the iPad, grouped by the bottleneck it attacks:

Bottleneck Innovations Biggest wins
Compute (2-core A7, no dotprod, 6 items) a multiplication-free 1-bit kernel, the TV's 1-bit lookup-table kernel ported to base NEON, a lookup-table copy of the tied LM head, row-order attention with pruning (2 or 4 q heads per K head), a one-pass sampler with a 40-entry heap, a Q2_K lookup-table kernel for experts Bonsai-1.7B 2.75 → 3.45 → ~11 → 23.7-25.8 tok/s; sampling 3 → 0.23 ms per token; Moon-Beam 2.94 → 3.06 from the Q2_K kernel alone
Address space and RAM (~1.7 GB mappable, ~700 MB usable, 4 items) an expert pager that maps only the non-expert part (experts-last files), pruned LM heads for bailingmoe2 and bailingmoe3, small batches (-c 512 -b 64), std::filesystem compiled out for iOS 12 8B and 16B MoE models load at all, in 556-625 MB; Bonsai-4B fits
Flash (~260 MB/s, 5 items) uncached pread with 4 readers, reads queued by when they're needed, pre-gated prefetch, cache-aware routing bias, decayed-frequency eviction Moon-Beam 1.6 → 3.5 tok/s at no quality cost; misses 42 % → 38 % from eviction alone
Tooling (5 items) an op profiler with page-fault counts, an expert trace and cache simulator, on-device perplexity (tinyppl), model assembly over HTTP range requests, a bench that kills orphaned runs found the LM head that never reached the fast kernel (45 of 72 ms); ruled out the soft-fault theory; built a 5.5 GB model with 1.4 GB of Mac disk

How Bonsai-1.7B got from 2.75 to about 25 tok/s (same model file throughout):

Step tok/s What changed
Stock llama.cpp, 2 threads 2.75 emulates dotprod with widening multiplies
Multiplication-free NEON kernel 3.45 weights are ±1, so no multiply is needed; output identical
Lookup-table kernel from device #1, ported to base NEON about 11 its dotprod requirement turned out to be only an assumption
The output head on that kernel 18-22 151,669 rows isn't a multiple of 32, so the tied head had never reached it: 45 of 72 ms per token
Row-order attention with pruning, and a sampler that doesn't sort the vocabulary 23.7-25.8 the KV cache stores all heads of a row side by side, so one head at a time missed the cache constantly

In long chats (400-token answers), the three turns went from 15.4 / 7.6 / 8.2 to 21.8 / 17.9 / 18.4 tok/s through the attention work alone. The pruning threshold that holds long chats above 17 costs +0.42 % perplexity at 1,024 tokens of context, near the ±0.3 % noise floor; GGML_FA_EPS=0.0001 trades that back for about 15 tok/s.

An 8B model, through an expert pager. iOS 12 caps a process at about 1.7 GB of mapped address space (1.3 GB for the web server's user), so Moon-Beam-8B's 2.6 GB file can't even be mapped, and keeping fewer experts to make it fit more than doubles its perplexity (§21.7). Dense Bonsai-8B runs at 0.08 tok/s from flash. So the model file is rewritten with its experts last, only the 474 MB before them is mapped, and each layer keeps 14 experts in RAM, reading the others from flash with pread when the router picks them. Results match the unpaged model exactly. A small routing bias toward experts already in RAM (+0.3 % perplexity, inside the noise) brought it to 2.4-2.8 tok/s in about 600 MB (§21.8). Round 2 (§21.9) queues flash reads by when they're needed, prefetches the next layer's likely experts, and runs the experts through a lookup-table kernel for Q2_K: 3.4 tok/s, 40× the dense 8B, at +0.7 % perplexity on the full eval text. The flash is still the limit: each token reads about 53 MB at 266 MB/s. Wiring weights in RAM kernel-panicked the iPad, like a pool of 16 did before. Round 3 (§21.10) evicts by decayed use frequency instead of recency, for 3.5 tok/s at no quality cost. 5 tok/s takes 4 experts per token instead of 8 (+27 % perplexity); choosing the count per token adaptively turned out no better than a fixed count. A 16B model (§21.11): Ling-mini-2.0, 16.3B parameters with 256 small experts per layer, runs through the same pager at 2.6 tok/s with unchanged routing, 3.3 at +2.6 % perplexity and 4.5 with a strong cache bias, in under 580 MB. Its 5.5 GB file was assembled from two published quantizations over HTTP range requests and streamed straight to the iPad, since the Mac had no room for it. It is twice Moon-Beam's size at the same speed, but not a better model (+3 % perplexity).

Speed against perplexity cost for Moon-Beam-8B-A1B and Ling-mini-2.0 on the iPad: Moon-Beam 3.5 tok/s at no cost up to 5.0 at +27 %; Ling 2.6 at no cost, 3.3 at +2.6 %, 4.5 at +8.5 %, 5.2 at +17 %

Limits worth knowing. Bonsai is a 1-bit model and still gets facts wrong (in one test it called Mercury the closest planet to Earth). About 700 MB is the practical memory ceiling: 1-bit weights don't compress, so iOS starts killing processes past it, which is why the 4B needs a 512-token context. The jailbreak doesn't survive a reboot. The fast output head adds 2-3 s to the first prompt after each launch.

Kernels for Cortex-A55 (ARMv8.2 dotprod, no i8mm), as a patch on llama.cpp v0.8.0, each with an exactness test against a scalar reference:

Kernel Before → after Where
Q2_K, repacked 8 rows × 4 bytes for vdotq_laneq (upstream repacks Q2_K only on x86 / RISC-V) 7.8 → 13.9 G weights/s; generation +41 %, prompt 2.4× E34
TQ1_0 / TQ2_0 ternary, repacked 9.5 → 14.4 / 11.9 → 19.1 G weights/s E38
Ternary Q2_0 → lossless 1.875 bits/weight in RAM (base-3 packing that keeps the per-64 scales) Ternary-Bonsai-4B from "thrashes" to 3.2 tok/s E39, E42
Q1_0 bit planes: vtst turns a bit plane straight into a mask for vdot, no table lookups; 8-bit row-affine scales (−5.6 %, 0.07 % error) Bonsai-4B 3.02 → 4.22 tok/s; Bonsai-8B fits in 0.96 GB E44, E52
Fused Gated-DeltaNet step with direct state write (Qwen3.5) 2B 6.0 → 6.3-6.7 tok/s E28
DeltaNet state read in place by id instead of a 1 MB/layer gather every token; fp16 state (LLAMA_STATE_F16=1, on by default in the UI) 0.8B 11.9 → 12.9 tok/s; 2B +2.5 % for +0.15 % perplexity E56, E61, E65
The LM head on the fast kernel: the 65,668-row head was never repacked (ggml repacks only row counts divisible by 8), so 4 token rows were added; embedding rows are then rebuilt from the repacked head (inverse 8-row interleave) instead of read from flash; the one-token DeltaNet conv step fused from 4 nodes into one NEON op 2B +7 % (bench and chat); 0.8B +9.5 % E69-E72
Ternary weights in 2-bit fields, with scales merged per 128: on the in-order A55, one base-3 digit costs ~5 NEON ops per 16 weights and a 2-bit field costs 2, so spending 13 % more RAM buys speed. Merging adjacent scales costs +0.18 % perplexity Ternary-Bonsai-4B 3.2 → 4.3 tok/s in chat, prompt 7.0 → 9.6 E76, E77
1-bit lookup-table kernel (Four-Russians / T-MAC style): 32 rows per group, one 16-entry table per 4 activations built straight from the float activations, ~8 NEON ops + 2 loads per 128 multiply-adds vs 16 + 4 Bonsai-8B 3.77 → 5.1-5.4 tok/s (with E82, E88) E81
Q8_0 attention with a threshold: all K dots first with sdot, so the softmax maximum is known up front, then expf and V work skipped for rows below max + ln(1e-4) a 1,000-token answer no longer halves the speed (2.50 → ~4 tok/s at the end) E86
Guided matmul scheduling: each thread grabs remaining / (2 × threads) rows, so a slow or preempted thread leaves a short tail instead of ~28 ms per token of idle cores; single-row element-wise ops split by columns (ggml left them all on thread 0) 2B 6.26 → 6.54 tok/s; 0.8B +1.4 % E63, E64

Memory tricks:

  • Pruned LM head: keep only the ~49-66 K most useful vocabulary rows, chosen by token frequency on English, code and 7 other languages. The other logits are scattered to −inf. Saves 0.2-0.5 GB and misses ~1 % of English tokens.
  • Repack cache on flash (GGML_REPACK_CACHE): repacked weights are written to a file once and mapped afterwards. Models load in seconds.

Headless mode (tools/tv-llm-mode.sh on|off): a small foreground activity, then the launchers, Google services and other heavy apps are force-stopped, and svc power stayon keeps the animated screensaver (17 % of a core) away. Nothing persistent except stay-awake, which off restores. MemAvailable ~1000 → ~1300 MB. A fork-free guard (tools/tv-watch.c) replaced a shell loop that started ~25 processes a second and cost 10 % of the speed.

Expert pager for mixture-of-experts models (ggml-cpu/moe-pager-tv.inc):

  • Experts live on flash: each expert tensor is overlaid with empty anonymous memory, and right after a layer's routing the missing experts are read with parallel O_DIRECT straight into their own addresses; a per-layer LRU pool (43 of 128 experts) keeps the rest. Kernels see an ordinary tensor. The GGUF's own expert bytes are hole-punched once the repack cache holds them, so the model costs one copy of its weights on flash.
  • Cache-aware routing (GGML_MOE_BIAS): a small bonus for experts already in RAM cuts misses from 13 % to ~2 % for a measured quality cost (arXiv 2412.00099 has the idea on other models). Fewer experts per token (LLAMA_MOE_USED=6) is the other dial.
  • Warm start from a list of each layer's most used experts, and pre-gated prefetch: layer L+2's router applied to layer L's hidden state, predicting what the router will actually pick (sigmoid, the model's own selection bias and the cache bonus), loads ~60 % of misses before they are needed.
  • Why answers looped, and the fix: 2-bit experts break long-form coherence after ~250-300 words, and routing bias amplifies it. DRY sampling only hides it (the model paraphrases its loop); a repeat penalty of 1.15 over 256 tokens brings degenerate long answers from 12/13 to 1/13 on the TV (E99, E100).

Weight streaming for models bigger than RAM (ggml-cpu/stream-tv.inc):

  • What stays in RAM: a chosen part of the weights stays resident. The rest lives in "stream zones".
  • Prefetch: a hook on every matmul records the order the weights are used. Reader threads then fetch zone weights ahead of time with parallel 8 MB O_DIRECT reads straight into the weights' own addresses, at the flash's full 278 MB/s.
  • Correctness: every compute thread waits until its weight is completely loaded, so a wrong prediction costs time, never correctness.
  • Drop-behind: each zone weight is freed right after use.
  • Verify mode: GGML_STREAM_VERIFY=1 compares every streamed tensor with the file. It found two silent-corruption bugs, which are fixed and documented in E48 and E50.
  • Speculative decoding: with the model's own MTP head, verifying 2 tokens costs about one pass over the weights when the pass is flash-bound, so it pays off (+69 %). On the in-RAM 2B it lost (E31).

Profiling (env switches in the patch): GGML_OPPROF=1 per-op table, GGML_OPPROF=2 a node-by-node, per-thread trace of one graph (tools/optrace.py), LLAMA_UBATCH_PROF / GGML_CPU_PROF phase timings. They showed where a small model's time really goes (E57): ~71 % Q4_K matmuls, and only ~2 ms per token of barriers and scheduling.

Tooling:

  • Streaming GGUF rewriter: adds a pruned head, extra tensors and metadata in one sequential pass, so Hugging Face → Mac → TV pipelines never store the model on the Mac.
  • MTP fetcher: range-reads only the MTP tensors from safetensors.
  • Memory guard: wraps every heavy run on the TV.
  • Benchmark and perplexity harnesses, a model screener that streams any Hugging Face GGUF to the TV and scores it (tools/screen-small.sh), and a live token-stream web UI.

You need: the TV box (or another ARMv8.2 Android device) with network ADB, adb, zig (for static aarch64 musl builds), cmake + ninja, python3, and uv (for numpy).

export ADB_SERIAL=<tv-ip>:5555          # every script talks to the box through this
adb connect $ADB_SERIAL

# 1. llama.cpp v0.8.0 + the patch, cross-compiled for the TV
git clone https://github.com/ggml-org/llama.cpp third_party/llama.cpp
cd third_party/llama.cpp && git checkout 8642541 && git apply ../../patches/llama.cpp-v0.8.0-tvbox.patch
cmake -S . -B build-tv -G Ninja -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=aarch64 \
  -DCMAKE_C_COMPILER=$PWD/../../zigcc/cc -DCMAKE_CXX_COMPILER=$PWD/../../zigcc/c++ -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_EXE_LINKER_FLAGS=-static -DBUILD_SHARED_LIBS=OFF -DGGML_NATIVE=OFF \
  -DGGML_CPU_ARM_ARCH=armv8.2-a+dotprod+fp16 -DGGML_OPENMP=OFF -DLLAMA_OPENSSL=OFF -DLLAMA_BUILD_TESTS=OFF
ninja -C build-tv llama-server llama-cli llama-bench llama-perplexity llama-quantize
cd ../..
adb -s $ADB_SERIAL shell mkdir -p /data/local/tmp/llm/patched25 /data/local/tmp/llm/patched
adb -s $ADB_SERIAL push third_party/llama.cpp/build-tv/bin/llama-{server,cli,bench,perplexity} /data/local/tmp/llm/patched25/
adb -s $ADB_SERIAL push third_party/llama.cpp/build-tv/bin/llama-quantize /data/local/tmp/llm/patched/   # used by tools/tv-quant.sh
zig cc -target aarch64-linux-musl -O2 -static -o gguf_recut tools/gguf-recut/gguf_recut.c && adb -s $ADB_SERIAL push gguf_recut /data/local/tmp/llm/

# 2. models: rebuilt from their Hugging Face sources straight onto the TV (see below)
tools/rebuild-models.sh 2b          # also: 08 4b b8 tb8 q9 mb lm caches

# 3. the live chat page (default: the 2B on patched25 with fp16 state), then open http://<tv-ip>:8090 from any device on the network
tv-llm-ui/start.sh
tv-llm-ui/start.sh stop

Other models:

# Moon-Beam-8B-A1B MoE, ~7.2 tok/s (first: `tools/rebuild-models.sh mb`, needs a native Mac build and ~9 GB on the Mac while it runs).
# start.sh turns on headless mode, the expert pager, routing bias, prefetch and the repeat penalty; POOL / BIAS / EXPERTS override them.
MODEL=mb-q2-h60.gguf tv-llm-ui/start.sh
# Ling-mini-2.0 16B MoE, ~5.1 tok/s (first: `tools/rebuild-models.sh lm`)
MODEL=lm2-q2-h60.gguf tv-llm-ui/start.sh
# Bonsai-8B 1-bit fully resident, ~5.2 tok/s (first: `tools/rebuild-models.sh b8`; the repack cache is written on first start)
MODEL=b8-q1-hml.gguf tv-llm-ui/start.sh
sh /data/local/tmp/llm/tv-llm-mode.sh off   # on the TV, afterwards: launcher and screensaver back
# Qwen3.5-0.8B, ~16 tok/s (first: `tools/rebuild-models.sh 08`, needs a native Mac build of llama-quantize)
MODEL=q08-q4k-h65p8.gguf tv-llm-ui/start.sh
# The 8B / 9B numbers below were measured with the build of that time (patched16); the current build has the same
# code plus §15-17. GGML_CHUNK_GUIDED=0 in SENV restores the old matmul scheduling. No fp16 state with MTP.
# Bonsai-8B 1-bit with streaming and no headless mode, ~1.2 tok/s (first run: `tools/rebuild-models.sh b8 caches`)
MODEL=b8-q1-hml.gguf BIN=patched25 EXTRA="-ctk q8_0 -ctv q8_0 -fa on -cram 0" \
  SENV="GGML_REPACK_CACHE=/data/local/tmp/llm/cache/b8c GGML_REPACK_RESIDENT_MB=800 GGML_STREAM_MODE=cache GGML_STREAM_WINDOW_MB=64" tv-llm-ui/start.sh
# Qwen3.5-9B with MTP, ~0.27 tok/s
MODEL=q9-iq2-h65-mtp.gguf BIN=patched25 EXTRA="-nr -ctxcp 0 -cram 0 -ub 16 -b 64 --spec-type draft-mtp --spec-draft-n-max 1" \
  SENV="LLAMA_STREAM_RESIDENT_MB=600 LLAMA_STREAM_PIN=output.,blk.32. GGML_STREAM_WINDOW_MB=64" tv-llm-ui/start.sh

Everything on the box lives in /data/local/tmp/llm and runs as the adb shell user. The only system setting touched is stay-awake in headless mode (svc power stayon), which tv-llm-mode.sh off turns back off. adb shell rm -rf /data/local/tmp/llm removes it all.

Model files: not in git, rebuildable

The model files (0.5-4 GB each) are not in this repo. tools/rebuild-models.sh recreates every one from public Hugging Face files, streaming them through tools/gguf_build.py onto the TV. The SHA-256s below are of the files used in the experiments. Rebuilds of the 4B, Bonsai-8B and 9B files were checked byte-identical.

Ready-made downloads. The best file of each size class is on Hugging Face exactly as the TV runs it. They need the patched llama.cpp above; stock llama.cpp can't load the pruned head. Each card has the run command and the measured numbers:

Download with hf download hyperionaidev/<repo> --local-dir models/, then adb push the files to /data/local/tmp/llm/, with moe-warm-*.txt next to them. That replaces the 08 / 2b / 4b / mb / b8 / lm targets. For the 8B and 16B models, the first start writes the repack cache.

iPad Air downloads. Every model run on the iPad, where to get it, and its speed there. The three MoE files are our own builds, laid out for the iPad's expert pager and lookup-table kernel: they need the iOS 12 patch (ipad-air/), and any other llama.cpp loads them but gets scrambled experts. The rest are used exactly as published; the Bonsai models' fast layouts are built in RAM at load time. 53 experiments in all (E108-E160, §21):

Model Download iPad speed (tok/s) Peak RSS Experiments
Ling-mini-2.0 (16B MoE) hyperionaidev/Ling-mini-2.0-ipad-GGUF: lm2-q2-h60-lut.gguf (SHA-256 3e464310…4e50bd) 2.6 unbiased; 3.3 with bias 0.1 (+2.6 % perplexity); 4.5 with bias 0.3; 5.2 adding --experts 6 556-576 MB 4 (E157-E160)
Moon-Beam-8B-A1B (MoE) hyperionaidev/Moon-Beam-8B-A1B-ipad-GGUF: mb-q2-h60-lut.gguf (SHA-256 acf1cb56…94b976); the experts-last file without the repack, about 4 % slower, is hyperionaidev/Moon-Beam-8B-A1B-ipad-xl-GGUF: mb-q2-h60-xl.gguf (aa919400…871d7e) 3.5-3.6; 3.7-4.0 with 7 experts (+0.4-1.9 %); 5.0 with 4 experts (+27 %) 600-625 MB 19 (E138-E156)
Bonsai-8B (1-bit, dense) prism-ml/Bonsai-8B-gguf 0.08: doesn't fit in RAM, read from flash 629 MB 4 (E134-E137, with the first Moon-Beam attempts)
Bonsai-4B (1-bit), -c 512 -b 64 prism-ml/Bonsai-4B-gguf 9.4-11.4; 9.2-10.3 through long chats 615-682 MB 5 (E129-E133)
Bonsai-1.7B (1-bit), the default prism-ml/Bonsai-1.7B-gguf 23.7-25.8; 18-22 through long chats 605 MB 11 (E118-E128)
SmolLM2-360M-Instruct Q4_K_M bartowski/SmolLM2-360M-Instruct-GGUF 10.9-12.5 269-316 MB 10 (E108-E117: first runs, the small models and the web chat)
Gemma 3 270M-it Q4_K_M unsloth/gemma-3-270m-it-GGUF 15.4-19.0 248-294 MB
SmolLM2-135M-Instruct Q4_K_M stock Q4_K_M 18.6-29.3 141 MB
Qwen3.5-0.8B Q4_K_M unsloth/Qwen3.5-0.8B-GGUF about 0.2: thrashes 506 MB
stories15M q4_0 stock about 290 in llama-simple
File on the TV Target Built from SHA-256
w-q4k-h65.gguf (Qwen3.5-2B) 2b unsloth/Qwen3.5-2B-GGUF BF16 + imatrix_unsloth, MTP block from Qwen/Qwen3.5-2B, quantized on the TV, head data/head-ids-ml60k.txt 16057be3…3285b
w-q4k-h65p8.gguf (Qwen3.5-2B, default) 2b the file above re-cut on the TV with data/head-ids-ml60k-p8.txt (65,672 rows, so the head is repacked, §17) 631a27ae…54fd11
w-q4k-h45.gguf (option) 2b45 the same, 45,654-row head data/head-ids-ml40k.txt (byte-identical to E29's H45 file) a50eadc5…664d5b
q08-q4k-h65.gguf (Qwen3.5-0.8B) 08 unsloth/Qwen3.5-0.8B-GGUF BF16 + its imatrix_unsloth, pure Q4_K, head data/head-ids-ml60k.txt, quantized on the Mac 901f3838…8523a6b
q08-q4k-h65p8.gguf 08 the file above re-cut on the Mac with data/head-ids-ml60k-p8.txt (§17) 7dd2aac5…52fc232
tb4-q20-hml.gguf 4b prism-ml/Ternary-Bonsai-4B-gguf Q2_0_g64, head data/head-ids-qwen3-ml64k.txt a02d805f…3fe24
b8-q1-hml.gguf b8 prism-ml/Bonsai-8B-gguf Q1_0, same head fc297cb3…0d42a
tb8-q20-hml.gguf tb8 prism-ml/Ternary-Bonsai-8B-gguf Q2_0_g64, same head 01b1e0ef…93e06
mb-q2-h60.gguf mb tablinum/Moon-Beam-8B-A1B-GGUF-Preview Q8_0, requantized on the Mac: experts Q2_K, the rest Q4_K, own imatrix from data/corpus, head data/head-ids-ling-60k.txt; expert bytes hole-punched on the TV after the repack cache (cache/mbq2.*) is written 2d78fcdd…1c34ee
lm2-q2-h60.gguf lm bartowski/inclusionAI_Ling-mini-2.0-GGUF Q4_K_M + its imatrix: experts requantized to Q2_K on the Mac, the rest Q4_K, head data/head-ids-ling-60k.txt; punched on the TV after the repack cache (cache/lmq2.*) is written c089aeb0…e8d7
q9-iq2-h65-mtp.gguf q9 unsloth/Qwen3.5-9B-GGUF UD-IQ2_XXS, MTP block from Qwen/Qwen3.5-9B (Q4_0), 65K head 7766478e…8dcbc
cache/b8c.*, cache/tb8.* caches written by the first run with GGML_REPACK_CACHE

The head-id lists in data/ were made by tools/vocab_select.py (Qwen3.5 tokenizer) and tools/head_select_tok.py (any tokenizer, via llama-tokenize), from the corpus tools/fetch_corpus.py downloads (Wikipedia + Project Gutenberg).

Path What
EXPERIMENT.md the full write-up: §1-8 the first tiny model, §9-10 llama.cpp and the first flash streaming, §11 the 2B push, §13 the 4B push, §14 the 9B push, §15 the 0.8B speed push, §16-17 the 2B rounds 3-4, §18 the 4B round 2 (arXiv / GitHub scan, 2-bit ternary layouts), §19 the 8B push (headless mode, LUT kernel, Moon-Beam MoE with an expert pager, why long answers loop), §20 bigger than 9B (Ling-mini-2.0 16B, and why LFM2.5-24B and Qwen3.6-35B don't fit)
patches/llama.cpp-v0.8.0-tvbox.patch every change to llama.cpp: pruned head (output_ids) for Qwen3 / Qwen3.5 / Granite 4.0 H, the kernels above (ggml-cpu/repack-*-tv.inc), fused DeltaNet (state read by id, optional fp16 state, one-token conv step), the 1-bit LUT kernel, Q8_0 attention, the MoE expert pager with routing bias and prefetch (ggml-cpu/moe-pager-tv.inc), a pruned head for bailingmoe3, embedding rows from the resident head, repack cache and streaming engine (ggml-cpu/stream-tv.inc, loader hooks), padded small batches, op profiler, pruned-head-aware perplexity, Vulkan tuning for Mali
tools/ GGUF rewriter / head pruner / re-cutter, vocabulary selection, corpus and MTP fetchers, model rebuild script, TV memory guard and RAM helper, benchmark sweeps, kernel benches and exactness checks (kbench/), flash benchmark
tv-llm-ui/ the live token-stream page for llama-server (confidence colours, top-3 alternatives, speed, box stats), its launcher and the benchmark harness
tiny-llm-web/ the llama2.c story server + TV page from the first experiment
bench/ results.jsonl (every chat benchmark: prompts, outputs, speeds) and raw/ (logs of every experiment)
data/ head-id lists and the two evaluation texts
zigcc/ zig wrappers for cross-compiling static aarch64 binaries
ipad-air/ device #2: cross-compile for iOS 12, smolchat (terminal chat and Safari web page), deploy and benchmark scripts (README, §21)
screenshots/ the web pages running on the TV
  • On this box the 2B at 4 bits beats every bigger model at 1-2 bits. Post-training quantization below ~2.5 bits breaks normal models (E22, E23, E35). Models trained for 1-bit or ternary hold up, but at 4-8 B they don't fit without streaming.
  • Past RAM, flash bandwidth sets the speed. The kernel's LRU is the worst possible policy for a cyclic scan bigger than the cache: it re-reads everything every token. Keep a fixed resident set and stream the rest yourself.
  • Batching is free when you are flash-bound. Prompts and speculative verification cost about one weight pass, whatever their size.
  • Measure, then verify. Two streaming bugs gave plausible speeds and wrong numbers; only comparing tensors with the file, and perplexity with a Mac reference, caught them.
  • Small models are mostly vocabulary. Qwen3.5-0.8B's 248K-row head is a third of its weights; pruning it to 65K rows plus all-Q4_K took it from 7.5 to 12.1 tok/s (E55). Small models also lose more to 4 bits (+7 % perplexity vs +2.3 % for the 2B).
  • Simulate before you write a kernel. Activation sparsity (E58) and a low-rank head (E60) both looked promising on paper; a quality simulation on the Mac killed each in under an hour.
  • On an in-order core, count NEON ops as well as bytes. Tighter packing saved 20 % of the bytes and cost 2.5× the ops; undoing it made the 4B 33 % faster in chat (E76, E77). The 4-bit kernel is the opposite case, limited by RAM (E27), so the trick does not help the 2B or 0.8B.
  • Check that every big matrix reaches the fast kernel. The 2B's head (65,668 rows, ~10 % of each token's weights) silently ran on the generic kernel for weeks because ggml only repacks row counts divisible by 8; four extra rows gave +5.8 % (E71, E72). The load log's repack buffer size gives it away.
  • Most of the RAM was Android's, not the model's. Force-stopping the launchers and Google services for the duration (nothing persistent) let Bonsai-8B sit fully in RAM: 1.2 → 3.6 tok/s before any kernel work (E80). An animated screensaver and a guard that forked 25 times a second each cost another 10-15 % (E82, E88).
  • For a mixture-of-experts model the flash is a cache miss, not a stream. A ~6 ms miss per expert means the pool hit rate decides the speed; routing toward cached experts and prefetching what the next router will pick turned 4.4 into 7.2 tok/s.
  • Perplexity can't see loops. It scores one token at a time on clean text; the 2-bit model scored fine and still fell into loops in 12 of 13 long answers. Score long free-running generations too (tools/loop_stats.py), and compare models with different tokenizers in bits per byte, never in perplexity.
  • Past 9B, count resident bytes, not parameters. The ceiling for a mixture-of-experts model here is the non-expert weights that must stay in RAM plus a useful expert pool. Ling-mini-2.0 (16B) keeps 279 MB resident and runs at 5 tok/s; Qwen3.6-35B-A3B would need ~870 MB before caching a single expert (§20).
  • Guard the box. After two early kernel panics, every heavy run goes through a wrapper that kills it below 180 MB of free memory.

MIT. The llama.cpp patch is a derivative of llama.cpp (MIT). Model weights keep their own licenses, and the evaluation texts are Wikipedia excerpts (CC BY-SA 4.0).

Built on llama.cpp and llama2.c. Models from Qwen, unsloth (GGUFs and importance matrices), PrismML (Bonsai), Google (Gemma) and stories260K. The evaluation texts are excerpts from Wikipedia (CC BY-SA 4.0). The 🤏 logo is the pinching-hand emoji from Google's Noto Emoji (Apache 2.0).

联系我们 contact @ memedata.com