01 The Mobile CPU Inference Ceiling

Running large language models locally on consumer smartphones has typically relied on CPU inference engines (such as ARM NEON-accelerated llama.cpp). While modern ARM Cortex-X3 and Cortex-A715 cores are remarkably efficient, transformer autoregressive decoding is strictly memory-bandwidth bound. On an 8-core CPU cluster, memory bus contention, cache misses, and thermal throttling rapidly degrade token generation speeds from 8 tok/s down to an unusable 2.8 tok/s after just 90 seconds of continuous generation.

However, modern mobile system-on-chips (SoCs) pack massive compute parallelism in their embedded graphics processors:

  • Qualcomm Adreno 740 / 750: Up to 3.4 TFLOPS FP32 compute and unified high-bandwidth access to system LPDDR5X RAM (up to 68 GB/s).
  • ARM Immortalis-G720: Up to 12 shader cores featuring native FP16 dot-product acceleration.

By dispatching quantized matrix multiplication kernels directly into Vulkan compute shaders, we offload matrix dequantization and attention heads to GPU shader units, freeing the ARM cores and unlocking sustained 14.8 tokens/sec on 7B parameter models within a strict 4.2W power envelope.

02 Unified LPDDR5X Memory & Zero-Copy Buffers

Unlike desktop systems where model weights must be copied over a PCIe bus from host RAM to VRAM, mobile SoCs feature a unified physical memory space shared between the CPU, GPU, and NPU.

Physical LPDDR5X RAM (16GB, 68.2 GB/s Bandwidth) Zero-Copy Mapped Model Weights (mmap Q4_K_M) Kryo CPU Cluster 1x Cortex-X3 + 4x A715 Tokenizer & KV-Cache Pointers Adreno 740 Vulkan 1.3 SPIR-V MatMul Shaders 14.8 tok/s sustained Thermal Governor Dynamic Freq Scaling Ceiling: 68°C / 4.2W

By creating Vulkan storage buffers using the flag VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, the GGUF model tensors mapped by the host process are immediately accessible to GPU compute shaders without redundant serialization or memory duplication.

03 Compiling Vulkan SPIR-V Kernels on NDK

To build llama.cpp with the optimized Vulkan backend for Android ARM64 inside Termux or an Android NDK toolchain, we link directly against the Android system Vulkan loader (libvulkan.so):

#!/usr/bin/env bash
set -euo pipefail

export NDK_PATH="/opt/android-ndk-r27b"
export TOOLCHAIN="$NDK_PATH/toolchains/llvm/prebuilt/linux-x86_64"
export TARGET="aarch64-linux-android"
export API_LEVEL="34"

cmake -B build-vulkan \
  -DCMAKE_TOOLCHAIN_FILE="$NDK_PATH/build/cmake/android.toolchain.cmake" \
  -DANDROID_ABI="arm64-v8a" \
  -DANDROID_PLATFORM="android-$API_LEVEL" \
  -DCMAKE_BUILD_TYPE=Release \
  -DGGML_VULKAN=ON \
  -DGGML_VULKAN_CHECK_RESULTS=OFF \
  -DGGML_VULKAN_DEBUG=OFF \
  -DGGML_VULKAN_PERF=ON

cmake --build build-vulkan --target llama-cli -j$(nproc)

04 Dequantization in GLSL Compute Shaders

The bottleneck of LLM execution on mobile GPUs is integer unpacking. Weights are stored in Q4_K_M or Q8_0 block quantization. The compute shader executes in parallel workgroups of 64 or 128 threads, loading quantized blocks into local workgroup memory, dequantizing them into 16-bit floats (float16_t), and computing fused multiply-accumulate operations:

#version 450
#extension GL_EXT_shader_explicit_arithmetic_types_float16 : require
#extension GL_EXT_shader_16bit_storage : require

layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;

layout(binding = 0) readonly buffer MatrixA {
    uint8_t q_weight_data[]; // Quantized 4-bit nibbles
};

layout(binding = 1) readonly buffer VectorX {
    float16_t input_activations[];
};

layout(binding = 2) writeonly buffer VectorY {
    float16_t output_activations[];
};

layout(push_constant) uniform BlockParams {
    uint rows;
    uint cols;
    float scale;
} params;

void main() {
    uint row = gl_GlobalInvocationID.x;
    if (row >= params.rows) return;

    float16_t sum = float16_t(0.0);
    uint block_offset = row * (params.cols / 2);

    for (uint c = 0; c < params.cols; c += 2) {
        uint8_t packed_byte = q_weight_data[block_offset + (c / 2)];
        
        // Unpack low and high 4-bit signed integers
        int w0 = int(packed_byte & 0x0F) - 8;
        int w1 = int((packed_byte >> 4) & 0x0F) - 8;

        sum += float16_t(w0) * float16_t(params.scale) * input_activations[c];
        sum += float16_t(w1) * float16_t(params.scale) * input_activations[c + 1];
    }

    output_activations[row] = sum;
}

05 Thermal Throttling & Power Capping (4.2W)

Smartphone chassis lack active fan dissipation. If an inference task runs the Adreno GPU at its maximum 850MHz clock rate unconstrained, SoC junction temperatures hit 82°C within 120 seconds, triggering aggressive thermal governor frequency drops.

By configuring a batch size governor that clamps GPU dispatch queue depth and limits execution frequency to 585 MHz, the SoC stabilizes at 68°C, sustaining 14.8 tok/s indefinitely without thermal throttling.

06 Comparative Token Throughput & Latency

We benchmarked inference on a OnePlus 11 (Qualcomm Snapdragon 8 Gen 2, 16GB RAM) running Android 14 across three representative open-weights models:

Model Quantization CPU-Only (8-Core NEON) Vulkan GPU (Adreno 740) Speedup
Qwen 2.5 1.5B Instruct Q4_K_M 16.4 tok/s 42.1 tok/s 2.56x
Llama 3.2 3B Instruct Q4_K_M 7.8 tok/s 26.4 tok/s 3.38x
Qwen 2.5 7B Instruct Q4_K_M 3.1 tok/s (drops to 2.2) 14.8 tok/s (sustained) 4.77x
DeepSeek-R1-Distill-Qwen-7B Q4_K_M 2.9 tok/s 14.2 tok/s 4.89x
Peak Package Power - 8.4 W (spikes) 4.2 W (regulated) -50% energy

07 Reproducible NDK Toolchain & Scripts

To reproduce these benchmarks on any ARM64 Android device with Termux and Vulkan support:

# Install dependencies in Termux
pkg update && pkg install -y git cmake clang vulkan-tools vulkan-headers

# Clone and build llama.cpp with Vulkan compute
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
cmake -B build -DGGML_VULKAN=1
cmake --build build --config Release -j$(nproc)

# Download Qwen 2.5 7B quantized model
curl -L -o qwen2.5-7b-instruct-q4_k_m.gguf \
  "https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_k_m.gguf"

# Execute GPU accelerated inference
./build/bin/llama-cli \
  -m qwen2.5-7b-instruct-q4_k_m.gguf \
  -p "Explain the difference between L1 cache and register file in modern CPUs." \
  -n 512 \
  -ngl 99 \
  --threads 4
K
Krish / axe01010
Systems engineer and security researcher. Eight years building and shipping production software directly from mobile Linux environments.
RELATED RESEARCH & BUILDS