On-Device LLM Inference at 50M Mobile Sessions: llama.cpp vs MLX vs MediaPipe vs ExecuTorch
A production-grade benchmark and architecture guide for running 2B-class LLMs on phones at 50M sessions: prefill latency, thermal-throttled tokens/sec, RSS, and true cost per million tokens versus cloud. Learn when llama.cpp, MLX Swift, MediaPipe, or ExecuTorch wins—and where cloud fallback is still mandatory.
AI Generated with Qwen 3.0 via CometAPIWhy 50M mobile sessions changes the inference equation
At 50M sessions, the runtime decision is not a GitHub benchmark. It is an SLO: p95 time-to-first-token (TTFT), sustained tokens/sec after 10 minutes, peak RSS, crash-free rate, and battery delta per session. Prefill is compute-bound; decode is memory-bandwidth-bound. Thermal throttling hurts both, but GPU-heavy prefill often degrades first and hardest.
The winning architecture is rarely one runtime everywhere. It is a tiered policy: quantized 1B-class models on mid-tier devices, 2B-3B int4 models on flagships, runtime-specific acceleration, and cloud fallback when thermal state or memory pressure crosses a threshold.
Runtime architecture: where each stack wins and breaks
llama.cpp: control and portability
llama.cpp is the lowest-friction path for iOS and Android when you need GGUF, quantized KV cache, custom samplers, and CPU fallback. Use it when model portability and direct control matter more than vendor NPU integration. The trade-off: you own the threading, memory arena, and tokenizer lifecycle.
#include "llama.h"
#include <chrono>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
struct ModelDeleter { void operator()(llama_model* m) const { llama_model_free(m); } };
struct CtxDeleter { void operator()(llama_context* c) const { llama_free(c); } };
using ModelPtr = std::unique_ptr<llama_model, ModelDeleter>;
using CtxPtr = std::unique_ptr<llama_context, CtxDeleter>;
struct BackendGuard {
BackendGuard() { llama_backend_init(); }
~BackendGuard() { llama_backend_free(); }
};
struct DecodeStats { double prefill_ms; double decode_tok_s; int generated; };
DecodeStats run_prefill_decode(const std::string& model_path, const std::string& prompt,
int n_predict, int n_threads) {
BackendGuard backend;
llama_model_params mp = llama_model_default_params();
mp.n_gpu_layers = 99; // Metal/Vulkan where available; fallback to CPU if unsupported
ModelPtr model(llama_model_load_from_file(model_path.c_str(), mp));
if (!model) throw std::runtime_error("model load failed");
llama_context_params cp = llama_context_default_params();
cp.n_ctx = 2048;
cp.n_batch = 512;
cp.n_threads = n_threads;
CtxPtr ctx(llama_init_from_model(model.get(), cp));
if (!ctx) throw std::runtime_error("context init failed");
const llama_vocab* vocab = llama_model_get_vocab(model.get());
int n = -llama_tokenize(vocab, prompt.c_str(), (int32_t)prompt.size(), nullptr, 0, true, true);
if (n <= 0) throw std::runtime_error("tokenize sizing failed");
std::vector<llama_token> tokens(n);
if (llama_tokenize(vocab, prompt.c_str(), (int32_t)prompt.size(), tokens.data(), n, true, true) < 0)
throw std::runtime_error("tokenize failed");
auto t0 = std::chrono::steady_clock::now();
llama_batch batch = llama_batch_get_one(tokens.data(), n);
if (llama_decode(ctx.get(), batch) != 0) throw std::runtime_error("prefill failed");
auto t1 = std::chrono::steady_clock::now();
int generated = 0;
for (; generated < n_predict; ++generated) {
const float* logits = llama_get_logits_ith(ctx.get(), -1);
llama_token next = 0;
for (int32_t t = 1; t < llama_vocab_n_tokens(vocab); ++t)
if (logits[t] > logits[next]) next = t;
if (llama_vocab_is_eog(vocab, next)) break;
llama_batch one = llama_batch_get_one(&next, 1);
if (llama_decode(ctx.get(), one) != 0) break;
}
auto t2 = std::chrono::steady_clock::now();
double prefill_ms = std::chrono::duration<double, std::milli>(t1 - t0).count();
double decode_s = std::chrono::duration<double>(t2 - t1).count();
return {prefill_ms, decode_s > 0 ? generated / decode_s : 0.0, generated};
}MLX Swift, MediaPipe, and ExecuTorch: choose the integration surface
MLX Swift is Apple-silicon-only and excellent when you control the iOS stack: unified memory, GPU execution, and tight Swift concurrency fit. It is not an Android answer. MediaPipe LLM Inference gives the fastest path to a cross-platform .task bundle, but abstracts KV-cache policy and sampler control. ExecuTorch is the right enterprise bet when your model pipeline is already PyTorch and you need a portable .pte with delegates: Core ML and Metal on iOS, XNNPACK/Vulkan and vendor NPU delegates on Android. Treat NNAPI as legacy in new designs; recent Android releases deprecate it, so select QNN or MediaTek delegates explicitly when you need NPU acceleration.
A minimal MediaPipe session factory looks like this:
import com.google.mediapipe.tasks.genai.llminference.LlmInference
import java.nio.file.Files
import java.nio.file.Paths
fun createLlm(context: android.content.Context, modelPath: String): LlmInference {
require(Files.exists(Paths.get(modelPath))) { "Missing model: $modelPath" }
val options = LlmInference.LlmInferenceOptions.builder()
.setModelPath(modelPath)
.setMaxTokens(512)
.setTopK(40)
.setTemperature(0.8f)
.build()
return LlmInference.createFromOptions(context, options)
}ExecuTorch export should be pinned in CI because delegate partitioning changes model numerics and memory shape:
import torch
from executorch.exir import to_edge_transform_and_lower
from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner
class CausalLM(torch.nn.Module):
def __init__(self, model: torch.nn.Module):
super().__init__()
self.model = model
def forward(self, input_ids: torch.Tensor) -> torch.Tensor:
return self.model(input_ids).logits
try:
ep = torch.export.export(CausalLM(model).eval(), (torch.ones(1, 128, dtype=torch.long),))
edge = to_edge_transform_and_lower(ep, partitioner=[XnnpackPartitioner()])
edge.to_executorch().write_to_file("gemma-2b.pte")
except Exception as exc:
raise RuntimeError("ExecuTorch export failed") from excBenchmark prefill, decode, RSS, and throttling correctly
Report four numbers, not one: cold prefill tok/s for a 512-token prompt, cold decode tok/s, 10-minute sustained decode tok/s, and peak RSS. Also log time-to-first-token and mAh per 100 generated tokens. Representative 2B-class int4 planning ranges below are useful for architecture, not procurement; validate on your device matrix.
| Runtime / target | Cold prefill 512 tok | Cold decode tok/s | 10-min sustained | Peak RSS |
|---|---|---|---|---|
| llama.cpp Q4_K_M, Metal/Vulkan | 60-180 tok/s | 12-28 | 6-14 | 1.8-3.2 GB |
| MLX Swift 4-bit, iPhone | 80-220 tok/s | 18-35 | 10-18 | 1.5-2.8 GB |
| MediaPipe int4, Android/iOS | 40-120 tok/s | 10-22 | 6-12 | 1.6-2.6 GB |
| ExecuTorch + XNNPACK/Core ML | 50-150 tok/s | 8-24 | 5-12 | 1.4-2.5 GB |
KV cache sizing is non-negotiable. For FP16 KV: bytes = 2 * layers * kv_heads * head_dim * ctx_len * 2. A 32-layer, 8-KV-head, head-dim-128 model at 2048 context consumes about 256 MiB before logits and activations. Int8 KV halves it; that is often the difference between a stable 2B session and an OOM kill on 6 GB devices.
Thermal-aware scheduling is a product feature
Do not wait for the OS to kill your GPU context. Subscribe to thermal state, apply hysteresis, and shrink generation budgets before throttling becomes visible. On Android, PowerManager.getCurrentThermalStatus() gives a coarse but actionable signal:
fun installThermalGovernor(context: android.content.Context, session: SessionController) {
val pm = context.getSystemService(android.os.PowerManager::class.java)
pm.addThermalStatusListener(context.mainExecutor) { status ->
session.maxNewTokens = when (status) {
android.os.PowerManager.THERMAL_STATUS_NONE,
android.os.PowerManager.THERMAL_STATUS_LIGHT -> 512
android.os.PowerManager.THERMAL_STATUS_MODERATE -> 256
android.os.PowerManager.THERMAL_STATUS_SEVERE -> 128
else -> 0 // route to cloud or stop generation cleanly
}
session.telemetry.record("thermal_status", status)
}
}On iOS, ProcessInfo.thermalState is the equivalent control loop:
final class ThermalGovernor {
private var observer: NSObjectProtocol?
private(set) var state: ProcessInfo.ThermalState = .nominal
func start(onChange: @escaping (ProcessInfo.ThermalState) -> Void) {
observer = NotificationCenter.default.addObserver(
forName: ProcessInfo.thermalStateDidChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
self.state = ProcessInfo.processInfo.thermalState
onChange(self.state)
}
}
deinit {
if let observer { NotificationCenter.default.removeObserver(observer) }
}
}True cost per million tokens: cloud vs edge at 50M sessions
On-device inference has near-zero marginal compute cost, but model distribution, QA, telemetry, support, and app-size regressions are real. Model it as (CDN + QA + telemetry + support) / monthly_tokens_millions. At 50M monthly sessions with 700 tokens each, 35B tokens can cost less in cloud than in a poorly optimized app-distribution model. At 50M daily sessions, on-device economics flip decisively because cloud spend scales linearly with tokens while CDN is mostly install-bound.
| Scenario | Monthly tokens | Cloud blended at $0.80-$1.10/M | On-device monthly | On-device $/M tokens |
|---|---|---|---|---|
| 50M sessions/month | 35B | $28k-$38.5k | $120k-$200k | $3.4-$5.7 |
| 50M sessions/day | 1.05T | $840k-$1.15M | $120k-$200k | $0.11-$0.19 |
Use hybrid routing: local first for short prompts and low thermal state; cloud fallback for severe thermal, low battery, or long-context tasks. That preserves UX without turning every token into a cloud invoice.
Operational checklist and pitfalls
- Pin runtime, model hash, tokenizer, and quantization recipe per app release; benchmark after every upgrade.
- Measure p50 and p95 TTFT on a 512-token prompt, not only decode tok/s on short prompts.
- Cap
n_batchand chunk prefill at 256-512 tokens to avoid memory spikes on 6 GB devices. - Use int8 KV cache where supported; validate task accuracy, not just perplexity.
- Disable background sync, cellular radio churn, and screen auto-brightness during battery benchmarks.
- Treat thermal state as a first-class routing input; add 2-3 minutes of hysteresis to avoid oscillation.
- Never ship a model larger than 60% of the minimum supported device RAM without a cloud fallback.
Sources & Technical References
- llama.cpp official repository: https://github.com/ggml-org/llama.cpp
- MLX Swift official repository: https://github.com/ml-explore/mlx-swift
- MediaPipe LLM Inference Android guide: https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android
- ExecuTorch documentation: https://docs.pytorch.org/executorch/stable/index.html
- Core ML documentation: https://developer.apple.com/documentation/coreml
- Android NNAPI guide: https://developer.android.com/ndk/guides/neuralnetworks
- Android PowerManager thermal status: https://developer.android.com/reference/android/os/PowerManager#getCurrentThermalStatus()
- Apple ProcessInfo thermalState: https://developer.apple.com/documentation/foundation/processinfo/thermalstate
- Gemma documentation: https://ai.google.dev/gemma
- Microsoft Phi-3 model card: https://huggingface.co/microsoft/Phi-3-mini-4k-instruct
- GPTQ quantization paper: https://arxiv.org/abs/2210.17323
- AWQ quantization paper: https://arxiv.org/abs/2306.00978
- MLPerf Mobile benchmark: https://mlcommons.org/benchmarks/inference-mobile/
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides

Landing Pages at 100M Views: Next.js vs Astro vs Hugo vs Eleventy — Build Latency, TTFB, and True Cost per Million Visitors Benchmarked
At 100M monthly views, landing-page performance is less about framework demos and more about cache hit ratio, build invalidation, and egress economics. This benchmark-driven guide compares Next.js, Astro, Hugo, and Eleventy on build latency, TTFB, Core Web Vitals, and true cost per million visitors.

Go Microservices at 1M RPS: go-zero vs Kratos vs go-micro vs go-kit on p99 Latency, Memory, and True Cost per Billion Requests
Most Go framework benchmarks measure a closed loop against an idle server, then publish a number that collapses under real traffic. Here is an open-loop, HdrHistogram-based comparison of go-zero, Kratos, go-micro, and go-kit at 1M requests per second — including the kernel tuning, the memory math, and the engineering amortization that decides which framework actually costs less to own.

Service Mesh at 200k RPS: Istio Sidecar vs Ambient vs Cilium eBPF vs Linkerd — p99 Tail Latency, CPU per Million Requests, and True Cost per Billion
A reproducible 200k RPS benchmark and cost model comparing Istio sidecar, Istio ambient, Cilium eBPF, and Linkerd on p99 tail latency, vCPU-seconds per million requests, and dollars per billion. Includes the PromQL, load-gen manifests, and a Go TCO calculator you can run against your own fleet.