docs/OPTIMIZATIONS.md · the app's ABOUT screen
Every lever, with sources
What each runtime decision does, why it works, what it measured, and where it is in the source. Negative results are kept at equal weight - two of these levers were regressions and were removed or demoted, and that is stated here, not buried.
100 tok 16.4 t/s TTFT 299 ms 33.0°C 4.34 W CPU 47% 1.2 GB live stats bar - ENTITY v3.6.2
Runtime-selected CPU backend
The build compiles seven Arm CPU backend variants, armv8.0 through armv9.2, each with
Arm KleidiAI micro-kernels, via stock llama.cpp GGML_CPU_ALL_VARIANTS=ON -
a build decision, not hand-written kernels. At startup
ggml_backend_load_all_from_path loads every variant in the APK and ggml
scores them against the physical CPU; the best supported one runs. Old phones without
dotprod get the armv8.0 fallback instead of SIGILL.
Source: lib/build.gradle.kts, ai_chat.cpp: init(). Cost:
release APK ~9.8 MB against ~7 MB single-variant.
A3 - Backend selection at startup
Seven Arm CPU backend variants ship in every APK. At startup the CPU's feature flags are read, variants whose requirements the silicon cannot meet drop out, and the best survivor loads.
CMF Phone 1 · Dimensity 7300
/proc/cpuinfo features
- dotprod present
- fp16 present
- i8mm absent
- sve absent
- sve2 absent
- sme absent
Shipped variants (best supported loads)
- android_armv8.0_1 baseline arm64 supported
- android_armv8.2_1 dotprod supported
- android_armv8.2_2 dotprod + fp16 loaded
- android_armv8.6_1 dotprod + fp16 + i8mm not supported
- android_armv9.0_1 dotprod + i8mm + fp16 + sve2 not supported
- android_armv9.2_1 dotprod + i8mm + fp16 + sve + sme not supported
- android_armv9.2_2 dotprod + i8mm + fp16 + sve + sve2 + sme not supported
Old phones without dotprod get the armv8.0 fallback instead of a crash - the Tecno Spark 10 row in the dataset is exactly that case. No device in the dataset reports SME yet, so the two armv9.2 variants have never been the loaded one. Each variant also carries Arm KleidiAI micro-kernels; whether they run is gated by the model's quantization (see A4).
Big-core affinity
build_fast_cpu_set() ranks every online core by
cpuinfo_max_freq - live hardware detection, not a hardcoded mask - and
pin_to_fast_cores() applies it with sched_setaffinity on
every decode entry point, because ggml's worker pool spawns lazily and inherits the
calling thread's mask. Each benchmark arm logs the mask the kernel actually applied,
so a failed pin cannot pass as "pinning earns nothing".
Source: ai_chat.cpp: build_fast_cpu_set / pin_to_fast_cores; the
ablation switch is g_pin_cores.
Measured: on the five-run 2026-07-18 exports, pinning over the same thread count earns +21% decode on the Dimensity 7300 (distributions non-overlapping) and +1% decode but median power 2.52 → 1.78 W on the Snapdragon 6 Gen 4. July's three-run sets read ~0%. Across the contributed dataset the median is +0.7% decode and +2.0% tok/W, over a range of -8.5% to +29.3% - so device-dependent that v3.5.0 made placement a user setting rather than ship any default as correct.
The mechanism figure is A2 on the overview; the full pinning story is A8 beside the leaderboard.
Adaptive thread count - two widths, two signals
Decode derives its width from the top frequency cluster: the count of cores within 10% of the fastest clock, clamped to 2-6. On the reference 4+4 that yields 4. Decode does not want width - an SM8550 in the dataset decodes 23.8 tok/s on 2 threads and 6.72 on 8 - because it is bandwidth-bound and two threads already saturate the bus.
Prefill (since v3.5.0) derives a separate, usually
wider set from the kernel's own per-core cpu_capacity (1024 = strongest):
every core strictly above the slowest tier, never narrower than decode. Frequency
cannot rank cores - an A55 at 2.0 GHz and an A78 at 2.5 GHz are 25% apart in clock
and roughly 3x apart in throughput.
Why two rules? The single clock rule was validated on the devices actually measured - and the contributed dataset then proved it wrong for prefill on prime-core flagships, where the 10% window admits only the prime core and the count collapsed to 2. The decode rule was deliberately left alone; the prefill rule moved to capacity. The full falsification is on the leaderboard page and in the journey.
Source: ai_chat.cpp: top_cluster_core_count / prompt_thread_count,
mirrored by BenchmarkActivity.autoGenThreads() - one definition, called
from both places, after a drift bug proved why (Journey section 4).
Adaptive context
Auto mode buckets on GGUF file size and current free RAM to choose a 2048, 4096 or 8192-token window before the model loads. Because weights are memory-mapped and paged on demand, the KV cache is the real RAM-pressure lever - which is what lets a 3B-class model run inside about 2 GB free instead of OOMing.
Source: MainActivity.adaptiveContext(),
ai_chat.cpp: init_context() (reads back what llama.cpp actually
allocated).
A5 - Adaptive context sizing
Drag model size and free RAM: the running context snaps between 2048 / 4096 / 8192 by the shipped rule, keeping the KV cache under the memory ceiling - and a fixed 8192 window shows the failure it prevents.
Auto: KV cache at the derived window fits under the ceiling
Fixed 8192 window (what a hardcoded default would do) -
red line = free-RAM ceiling. Weights are memory-mapped and paged on demand, so the KV cache - sized by this rule - is the actual RAM-pressure lever.
Anchor: Llama-3.2-1B-Instruct-Q4_0, 773 MB, trained context 131,072 → running context 4096 (auto-fit to free RAM) at 1.2 GB free - the shipped model-info card's exact state.
In Auto mode only; manual mode uses the user's chosen context verbatim. The smaller window is an admission decision: it cuts KV-cache demand before a 3B-class model exhausts the remaining memory, which is what lets one run inside about 2 GB free.
Thermal-aware guard
Every eighth generated token, Auto mode asks Android's thermal status (cached for one second) and inserts a cooperative delay: 0 ms at NONE or LIGHT, 6 ms at MODERATE, 12 ms at SEVERE and above. Efficiency mode doubles the delay and caps inference at two threads. It is a soft back-off - no affinity change, no realtime request, no root-only thermal zones.
Source: ChatViewModel.ThermalGuard.delayMs().
A6 - The thermal guard
A small cooperative delay between tokens holds a flat sustained rate instead of running hot and hitting a hard throttle mid-answer; past the crossover, the guarded run has produced more total tokens.
y: decode rate relative to the cool-start rate · legend: solid bright = guarded, solid dim = unguarded · both lines schematic
The guard is cooperative, not a scheduler control: it inserts the delay itself rather than changing affinity or clocks, giving the OS a recovery opportunity before a hard system throttle. Whether the crossover lands at this point on a given phone is exactly what the bench app's sustained test measures.
KV session cache
Each conversation's decoded state is rebuilt on demand: switching to or restoring a persisted chat primes the native KV through the same chat-template path as live turns ("Preparing conversation..."), so the next reply continues seamlessly instead of starting from a cold cache. Long conversations never hard-stop: on overflow the oldest half of the turns is trimmed and the system prompt always survives.
Source: ai_chat.cpp: primeHistoryNative / shift_context.
mmap weights
GGUF weights are memory-mapped, paged in on demand rather than fully resident. RAM pressure therefore comes from the KV cache, which the adaptive-context rule sizes - the pairing that lets a 773 MB model report 1.2 GB free while generating.
Source: llama.cpp mmap loading, used as shipped.
Energy telemetry
watts = |battery current| x voltage / 1e9, sampled every 150 ms during benchmark passes and once per render tick in chat. Both OEM current units are disambiguated by a plausibility window, and since v3.6.0 the voltage unit is normalised first - a contributed device reported whole volts where millivolts are documented and broke watts by a factor of a million. Power and tok/W hide entirely while charging.
Source: MainActivity.snapMetrics(), PowerMath,
BenchmarkActivity.
Predicting a model before downloading it
The catalog used to rank models on RAM and ISA flags, which answers "will it fit" and not "will it be usable". A 4 GB phone could be told a 3B model fits and still find it decodes at 3 tok/s - after a 2 GB download. That is the worst possible way to learn it.
DeviceProbe answers it in about 60 ms with no model present, because the
two phases of inference are bound by things that can be measured directly.
Decode reads essentially every weight once per token, so
tok/s ~= bandwidth / model_bytes - a physical relationship, not a fitted
curve. It is measured with a 32 MB arraycopy, large enough that no phone's
last-level cache can hold it, so the number is DRAM rather than SRAM.
Prefill is a GEMM over the whole prompt and tracks
integer throughput on the performance cores, measured with a dependency-chained
multiply-accumulate loop - integer rather than float on purpose, since a float
benchmark would rank a phone with strong FP and weak integer units far too highly.
Both readings are scaled against one calibration device measured properly with
llama-bench, which is also what lets the estimate carry the Q4_0/Q8_0
trade instead of treating "reaches KleidiAI" as a single good thing.
Source: DeviceProbe.kt, ModelCatalog.assess. Anchor: CMF
Phone 1, Dimensity 7300, Llama-3.2-1B-Instruct, 4 threads pinned.
Sized against free memory, not installed RAM. A 6 GB phone with a browser and a few background apps resident has well under 2 GB to give, and a rule written against 6 GB recommends models into memory that is already spoken for. The thresholds were re-derived rather than reused - fractions calibrated against total would reject models that genuinely run. A model is flagged tight above 70% of free memory rather than rejected, because weights are mmap'd page cache the kernel can evict; the KV cache is the anonymous memory that actually has to fit, and it is the smaller of the two. The cost is stated plainly on screen: the verdict moves with whatever else is running, because the phone really does have less room when it is busy.
This is an estimate and says so. It cannot see thermal headroom, memory pressure from other apps, or the vendor's scheduler. It exists to keep a user off a model their phone cannot serve, not to replace a benchmark - the real three-arm ablation still ships in both apps.
Two of its anchor constants were originally derived rather than measured, and were wrong by 4.1x and 2.5x. That is entry 10 on the journey page.
100 tok 16.4 t/s TTFT 299 ms 33.0°C 4.34 W CPU 47% 1.2 GB live stats bar - ENTITY v3.6.2
The quantization gate, and why decode does not care
A4 - The KleidiAI quantization gate
A GGUF's header names its quantization; that one field decides whether Arm's KleidiAI kernels run at all. Q4_0 and Q8_0 route to them - everything else drops to ggml's other CPU paths, and until PR #25701 nothing told you.
model-Q4_0.gguf
general.file_type
KleidiAI kernels Q4_0 · Q8_0 only
Arm-written matmul micro-kernels for the compute-bound prompt GEMM.
ggml CPU path - no KleidiAI
Not bare scalar code: K-quants still reach ggml's Arm dotprod repack kernels. What they miss is KleidiAI - and nothing said so until the upstream warning landed.
Q4_0 reaches Arm's fast path. The app's model card reports this state as "KLEIDIAI ACTIVE".
| Metric | Q3_K_L (733 MB, no fast path) | Q4_0 (773 MB, fast path) | change |
|---|---|---|---|
| Prompt throughput | 42.7 tok/s | 121 tok/s | +183% |
| Derived TTFT (512-token prompt) | 12,050 ms | 4,299 ms | -64% |
| Decode throughput | 16.9 tok/s | 14.7 tok/s | -13% |
The fallback used to be silent - no log line, nothing in logcat. This project contributed the
one-time warning upstream:
llama.cpp PR #25701, merged
2026-07-21 as commit fb0e6b6.
A7 - Prefill versus decode
Prompt processing is one big compute-bound GEMM - kernels matter. Decode streams the whole weight matrix from memory for every single token - bandwidth matters, kernels mostly cannot.
PREFILL - 512 tokens, one large GEMM
bottleneck: arithmetic - a better kernel (KleidiAI, i8mm/SMMLA) directly helps
DECODE - one token at a time
bottleneck: memory bandwidth - tracks bytes per weight; kernel quality cannot help a workload waiting on DRAM
This split is why the Q4_0 move wins prompt throughput by +183% and TTFT by -64% while decode gives up 13% (Q4_0 carries ~6% more bytes than Q3_K_L) - and why decode gains come from thread placement and count, not from kernels.
A file named Q4_0 is only 76% Q4_0
The gate above is a claim about types. Checking it properly meant reading each
file's tensor table instead of its general.file_type label — and the label is
wrong about its own contents. In the exact Llama-3.2-1B Q4_0 the catalog ships,
token_embd.weight is Q6_K and two ffn_down tensors are Q4_1.
Llama 3.2 ties its embeddings, so that Q6_K tensor is also the output projection: the
largest matmul in the model sits off the fast path, in a quantization chosen because it
reaches the fast path.
| Quant | Perplexity | vs F16 | Size | On KleidiAI | Prefill | Decode |
|---|---|---|---|---|---|---|
| F16 reference | 14.2580 | — | 2480 MB | n/a | — | — |
| Q8_0 | 14.2705 | +0.09% | 1321 MB | 100% | 208.1 | 11.5 |
| Q4_K_M | 14.7346 | +3.34% | 808 MB | 0% | 111.7 | 16.0 |
| Q4_0 imatrix (shipped) | 15.6159 | +9.52% | 773 MB | 76% | 128.2 | 18.2 |
| Q3_K_L | 16.0927 | +12.87% | 733 MB | 0% | 43.4 | 17.4 |
| Q4_0 no imatrix | 16.5272 | +15.92% | 771 MB | 79% | 138.8 | 19.1 |
Perplexity: wikitext-2 test, 200 chunks, every quant built from one F16 source. Speed: CMF Phone 1, four threads pinned to the A78 cluster, three repetitions, 90 s cooldown between models. Coverage read from each file's tensor table, not its label.
Negative results, at equal weight
Two of the ideas above shipped, measured slower, and were removed or demoted. They stay on this page at the same size as the wins.