All ArticlesMobile•November 2, 2026•9 min read

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.

On-Device LLM Inference at 50M Mobile Sessions: llama.cpp vs MLX vs MediaPipe vs ExecuTorchAI Generated with Qwen 3.0 via CometAPI

Why 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.

cpp
#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:

kotlin
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:

python
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 exc

Benchmark 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 / targetCold prefill 512 tokCold decode tok/s10-min sustainedPeak RSS
llama.cpp Q4_K_M, Metal/Vulkan60-180 tok/s12-286-141.8-3.2 GB
MLX Swift 4-bit, iPhone80-220 tok/s18-3510-181.5-2.8 GB
MediaPipe int4, Android/iOS40-120 tok/s10-226-121.6-2.6 GB
ExecuTorch + XNNPACK/Core ML50-150 tok/s8-245-121.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:

kotlin
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:

swift
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.

ScenarioMonthly tokensCloud blended at $0.80-$1.10/MOn-device monthlyOn-device $/M tokens
50M sessions/month35B$28k-$38.5k$120k-$200k$3.4-$5.7
50M sessions/day1.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_batch and 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

2 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles