Inside PocketTune

Every component,
explained from zero.

No background assumed. Read this and every claim on the overview page will make complete sense.

Chapter one

Ten ideas that unlock
the whole project.

On-device LLM

ChatGPT runs in a data centre. A small language model — 1 to 3 billion parameters instead of trillions — can run on the phone itself. No internet, no account, completely private. Slower and less capable, but yours.

Quantization and GGUF

A model is billions of numbers. At full precision each takes 4 bytes, so a 1B model would be ~4 GB. Quantization compresses each number to about 4 bits, shrinking the model to ~700 MB with minor quality loss. Q4_0, Q4_K_M and Q8_0 are different compression schemes; GGUF is the file format that stores them.

Prefill versus decode

Answering happens in two phases. Prefill — the model reads your prompt; parallel work, can be very fast. Decode — it writes the answer one token at a time; sequential, always slower. Both are measured in tokens per second. A token is roughly ¾ of a word.

Matrix multiplication

Nearly all of an LLM's compute is multiplying huge grids of numbers together, billions of times per answer. Whoever does matmul faster, wins. Everything else here is about making matmul faster.

dotprod and i8mm

Arm chips include special instructions that crunch many numbers in a single step. dotprod is a good shortcut and is nearly everywhere. i8mm is a far better one for 8-bit integer matmul — exactly what quantized models need. Crucially, it isn't everywhere: two flagship phones of the same generation can differ, so the fast path can never be assumed — only detected.

The -march flag

Compilers default to safe, generic code that runs on any arm64 chip — and uses none of those fast instructions. -march=… is permission to emit them. Across the three phones benchmarked it was worth 3.88×, 4.94× and 5.59× on prefill — same flag, three different ceilings. And the ranking is not the feature ranking: the biggest gain belongs to a chip with no i8mm at all. The right flags are the ones matching that phone — which is why they're chosen after detection, and why the size of the win has to be measured rather than predicted.

big.LITTLE cores

Phone CPUs mix fast, power-hungry cores with slow, efficient ones — commonly two big and six little, though some chips add a third middle tier. The counter-intuition: using every core makes generation slower than using just the big ones — the little cores become the bottleneck everyone waits for. How many big cores, and at which indices, differs per phone.

KleidiAI

Arm's official library of hand-tuned matmul routines for dotprod, i8mm and SME2. The obvious assumption — turn it on, get speed — is exactly what we tested. On all three phones measured, for Q4_0: no measurable gain once the compiler flags are right, because plain compiled code already saturates that hardware — true on the i8mm chip and on both dotprod-only ones. On SME2 silicon or a different quant the verdict could invert — so it stays a lever under test, per device.

llama.cpp and llama-bench

llama.cpp is the open-source C++ engine that runs GGUF models on CPUs — the industry standard for local inference. llama-bench is its built-in stopwatch: load a model, run fixed workloads, report tokens per second as JSON.

adb and cross-compilation

adb is a USB command line to the phone: push files, run programs, read sensors. Cross-compilation is building arm64 phone binaries on an x86 laptop — the Android NDK provides the compiler that makes it possible. No Mac required.

Chapter two

The background knowledge.

None of these are required to use the app. Each one turns a mysterious number on the overview page into an obvious one.

SIMD — one instruction, many numbers

Single Instruction, Multiple Data: instead of adding numbers one at a time, the CPU processes a whole row of them per instruction. dotprod and i8mm are SIMD instructions specialised for the multiply-and-add work at the heart of matmul — that's the entire mechanism behind the 4.94×.

Memory bandwidth — the real speed limit

Writing each token means reading essentially every weight in the model from RAM. A ~700 MB model at 17 tok/s streams ~12 GB every second — close to what a phone's memory can move at all. That's why decode barely improves from faster math (1.34×) while prefill leaps (4.94×), and why smaller quantizations speed up generation: less data to haul.

The KV cache

While writing an answer, the model must "remember" everything said so far. The KV cache stores that work so each new token only pays for itself — and it grows with the conversation, living in RAM. One of the app's levers quantizes it to 8-bit (q8_0), roughly halving its size. Whether that's free or costly is measured, per phone.

Flash attention

A smarter ordering of the same attention math: work in tiles small enough to stay in the CPU's fast cache, instead of building one huge intermediate matrix in slow RAM. Identical answers, less memory traffic. It helps some chips and hurts others — which is exactly why the app sweeps it instead of assuming it.

Threads and cores

A thread is one worker; a core is the hardware that runs it. Matmul splits nicely across threads — but each step ends with everyone waiting at a barrier for the slowest worker. Add workers on slow cores and the whole team slows down. That's the mechanism behind "2 threads beats 6".

Thermal throttling

A phone has no fan. Under sustained load the chip heats up and the OS quietly slows the cores to protect it — which silently corrupts any benchmark that runs configs back-to-back. It's why the harness enforces cooldowns between variants and logs temperature before and after each one.

Runtime dispatch — one APK, every chip

The app can't ship a separate build per phone. So the engine binding packs six arm64 kernel builds into one APK and picks at startup by reading the CPU's feature list — an i8mm chip loads the i8mm build, a dotprod-only chip loads the dotprod one. Detection replaces assumption at the binary level too.

Tokens per joule

Android exposes the battery's voltage and current. Sample them during a benchmark, multiply → watts; divide token rate by watts → tokens per joule. Speed per unit of energy — the number that decides whether a config is actually worth it on a battery. A config 5% faster but 30% hungrier is a bad phone config.

Median of five

A phone is a noisy lab: background apps, thermal state, and battery level all nudge the numbers. Every measurement is repeated five times and the median reported, so a single hiccup — or a single lucky run — can never become a headline.

The attribution ladder

If you change the compiler flags and a library and the thread count and things get faster, you've learned nothing. The ladder changes exactly one lever per rung, so every gain has an owner. It's how we can say the 4.94× belongs to the flags — and that the library everyone credits added ≈0%.

Chapter three

From TypeScript
down to the silicon.

Each layer talks only to the one below it.

Screens · TS
Five screens — Device · Models · Tune · Chat · Lab
React Native, every line TypeScript — by design, no C++ or Kotlin is written in this project. Device reads the silicon, Models manages GGUF downloads, Tune sweeps and applies, Chat runs the winner offline, Lab holds the evidence.
Core · TS
Tuning core
Detection, sweep planning, scoring, power sampling, persistence — the loop that turns a phone's core map into an applied configuration. Mirrors the harness methodology so in-app numbers and published numbers are directly comparable.
Bindings
llama.rn
A prebuilt bridge letting TypeScript call llama.cpp. We audited its binaries: they pack six arm64 kernel builds and dispatch at runtime by CPU feature — an i8mm chip loads v8_2_dotprod_i8mm, a dotprod-only chip loads v8_2_dotprod. The fast path ships; detection decides which one runs.
Engine · C++
llama.cpp
Loads the GGUF model and runs the matmuls. In the app it arrives prebuilt inside llama.rn; for the published evidence we also compile it ourselves with the NDK — seven variants, one lever changed at a time.
Silicon
Arm CPU — whichever one is in the phone
A feature list (dotprod? i8mm? SVE2? SME2?) and a core layout (how many big cores, at which indices) that differ on every device. The fixed reality every layer above must adapt to — read at runtime from /proc/cpuinfo and cpufreq, never assumed.

Inside the app: one loop, six stages

This is the product in one diagram — the pipeline the Tune tab runs, with the TypeScript module responsible for each stage.

01
Detect
Read /proc/cpuinfo + cpufreq: features, clusters, where the big cores sit.
lib/cpu.ts
02
Plan
Thread candidates derived from the core map × flash attention × KV-cache quant.
lib/tuner.ts
03
Bench
llama-bench-style run per config, battery power sampled throughout.
lib/llama.ts · battery.ts
04
Score
65% decode + 35% prefill — chat feel first; energy shown alongside.
lib/tuner.ts
05
Apply
The winner becomes the live engine settings, persisted across restarts.
store.ts
06
Chat
Offline assistant on the tuned config — measured tok/s on every reply.
ChatScreen.tsx

The app ships alone. Everything else in the repository exists to produce and audit the evidence:

armcreate/
├── app/ ← the React Native app — five screens + the tuning core (src/lib/)
├── harness/ ← bench.py — pushes builds + model to the phone, times them, saves JSON
├── tools/ ← Python utilities — distill results/ into the app's bundled Lab evidence
├── models/ ← the .gguf compressed models (Q4_0 · Q4_K_M · Q8_0)
├── results/ ← raw measurement JSON — the evidence behind every number
├── vendor/llama.cpp/ ← engine source + 7 compiled build variants
├── docs/ ← benchmark schema · how to add a new phone
└── site/ ← this website
Chapter four

What's used,
and why.

ComponentWhat it isWhy this one
llama.cppC++ inference engine for GGUF modelsThe industry standard for CPU inference. Builds anywhere, and exposes every knob we need to test — arch flags, KleidiAI, repack, threads.
React NativeCross-platform app frameworkThe builder writes only TypeScript and Python. RN keeps 100% of authored code in TS while llama.rn handles the native inference layer.
llama.rnReact Native bindings for llama.cppLets the TypeScript app call the C++ engine without writing native code. Audited: its prebuilts pack six arm64 kernel builds and dispatch by CPU feature at runtime — the same detection-over-assumption story the harness proves with explicit per-arch builds.
Android NDKCross-compiler toolchainBuilds arm64 phone binaries on an x86 Windows laptop. No Mac, no emulator.
adbUSB command line to the phonePush binaries, run benchmarks, read CPU features and battery state — scriptable and reproducible, with no app install needed.
harness/bench.pyThe measurement scientistEnforces rigour automatically: five repetitions, cooldowns between variants, battery and thermal logging, raw JSON out. Anyone reruns every number with one command.
GGUF modelsPre-quantized Llama 3.2 1BSmall enough to iterate quickly, permissively licensed, and available in the exact quant formats the sweep compares.
KleidiAIArm's optimized kernel libraryIncluded as a lever under test, never an assumption. Verdict on all three phones measured: neutral for Q4_0 — kept in the sweep because it may matter on other quants and on SME2 chips.
Chapter five

Seven builds.
One variable at a time.

You can't attribute a speedup unless exactly one thing changes.

Variant-marchKleidiAIRepackQuestion it answers
genericnoneoffonWhat do you get if you don't think about any of this? The default.
arch+i8mm+dotprodoffonWhat are the compiler flags alone worth?
kleidiai+i8mm+dotprodononDoes Arm's library add anything on top?
arch-norepack+i8mm+dotprodoffoffBaseline with llama.cpp's own weight-repacking disabled.
kleidiai-norepack+i8mm+dotprodonoffKleidiAI's true standalone value, unmasked by repack.
dp-arch+dotprod onlyoffonTarget for phones without i8mm.
dp-kleidiai+dotprod onlyononThe same, with KleidiAI.
Measurement rules — non-negotiable

Physical phone only, never an emulator. Five repetitions per configuration. Fixed workloads: 128-token prefill, 64-token decode. Cooldowns of 90–120 seconds between variants, so heat from one run can't slow the next. Battery level and temperature logged before and after every variant. Raw JSON committed to results/, so every published number is auditable.

Chapter six

The optimizations —
what was done, what it achieved.

Three families. Make the machine code match the silicon. Make the memory traffic smaller. Put the work on the right cores. Everything below is one of the three — plus the energy meter that judges them all.

OptimizationFamily · whenWhat it actually doesAchieved (Nothing 2a)
Arch flagsmachine code · build-time-march=…+dotprod+i8mm lets the compiler emit the chip's matrix instructions instead of lowest-common-denominator arm64.4.94× prefill, 1.34× decode
Weight repackingmemory traffic · load-timeRearranges quantized weight blocks at model load into the tile order the SIMD units consume — streaming reads instead of scattered ones.Folded into the 4.94×; the norepack builds isolate it
KleidiAImachine code · build-timeArm's hand-written matmul microkernels, swapped in for llama.cpp's own compiled paths.≈0% for Q4_0 — the honest negative
Quantizationmemory traffic · model formatWeights stored at ~4 bits instead of 16. Decode is memory-bound, so less data ≈ more tokens per second — and a 1B model fits in ~700 MB.Enables everything; Q4_0 vs Q4_K_M vs Q8_0 sweep is next
Thread countscheduling · runtime, in-appDerives candidates from the phone's real core map instead of "all cores". Little cores stall the team; fewer, faster workers win.+25% decode (2 threads vs 6)
Flash attentionmemory traffic · runtime, in-appReorders attention into cache-sized tiles — same math, fewer trips to RAM.Swept per device by the tuner
KV cache q8_0memory traffic · runtime, in-appQuantizes the conversation's memory to 8-bit, roughly halving its RAM footprint as the chat grows.Swept per device by the tuner
Tokens per joulemeasurement · runtime, in-appSamples the battery's power rails during every benchmark point, so efficiency ranks configs alongside speed.Reported per config where the kernel exposes rails
The division of labour

Build-time levers (arch flags, repacking, KleidiAI) are proved by the harness and arrive in the app pre-baked inside llama.rn's feature-dispatched kernels. Runtime levers (threads, flash attention, KV quant) are the ones no binary can decide in advance — they depend on the individual phone, so the app measures them there. The headline numbers came from the first family; the product is the second.

Chapter seven

What the silicon
actually said.

Results are per device — so here are all three. One has i8mm; two do not. We said a chip without i8mm would tell a different story and would get its own table only once it was actually measured. Two now have, and the story they tell is not the one we expected.

Flip between the three and the thesis is right there. The same compiler flag is worth 3.88×, 4.94× and 5.59×. We expected that spread to be explained by i8mm — the matrix-multiply instructions the 2a has and the others lack. It isn't. The Pixel 7a has no i8mm whatsoever and still posts the largest gain of the three, comfortably ahead of the chip that has it. Its Cortex-X1 cores are simply wider and hungrier, and they extract more from ordinary dot-product code than the 2a's A715s extract from the fancy instructions. The feature checklist told us the wrong story; only the measurement told us the right one.

This is the most uncomfortable — and most useful — result in the project. A reasonable engineer reads the spec sheets, sees i8mm on one phone and not the others, and confidently predicts the ranking. That prediction is wrong. Silicon does not reduce to a feature list, and a config chosen from one is a config chosen by guesswork.

The thread count says the same thing even louder, because every phone wants a different answer. On the A34, the generic build decodes fastest at 6 threads, yet once the arch flags are on 6 threads becomes the worst choice and 2 threads — just the two big A78 cores — wins. On the Pixel the winner is 4 threads: it is the only tri-cluster phone here, with exactly four fast cores (2× Cortex-X1 + 2× Cortex-A78), so the fifth and sixth threads spill onto little A55s and prefill collapses from 138.0 to 97.5 tok/s — a 29% penalty for the crime of using more of your CPU. Two phones want 2 threads, one wants 4, and one of them changes its mind depending on the build. No static default survives that.

Honest caveats

Three phones across two vendors (MediaTek and Google) now, which retires the earlier "these are all MediaTek chips" caveat — but it is still three chips, one model, one quant family. Two honest wrinkles. One: the Pixel's generic baseline is genuinely noisy — it swung ±9% across runs, and an early run on a cold, freshly-idle phone came in ~25% high before settling. Its arch builds, by contrast, repeat to within 0.3%. We publish the conservative run, so the 5.59× is if anything an under-statement; the raw JSON for every run is in results/, including the ones that disagree with each other. Two: "KleidiAI adds nothing" means on this hardware, for this format; on SME2 silicon or with other quants the answer may flip, and we would publish that too. Nothing here is presented as a law of Arm phones. Which is precisely why the product ships a measurement loop instead of a lookup table.

Next: more devices through the same harness — each one's ladder published beside these, whatever it says — plus the quantization sweep (Q4_0 vs Q4_K_M vs Q8_0), energy per token via the battery sensor, and big.LITTLE speculative decoding. The llama.rn binary audit is done — its prebuilts carry the fast kernels and dispatch by feature, which is why the app can tune runtime levers instead of recompiling.

See the numbers.

The evidence, the findings, and the app.

Back to overview