◉ANTIGRAVITY LABJP
Articles/AI Tools
⚙ AI Tools/2026-04-22Intermediate

Memory Budget Design for Embedding Gemma 4 in Mobile Apps

A quantized model that fits on paper can still get your app killed on a real phone. Starting from Google's published memory figures (about 1.1 GB for E2B, 2.5 GB for E4B on mobile), this walks through the four memory regions, picking a profile from available memory, estimating the KV cache, and surviving memory warnings.

gemma-419mobileon-devicememory4optimization3

The night I tried adding a small on-device model to a wallpaper app, I looked at the file size, decided it would fit, and moved on. It ran in the simulator. On a real phone, a few seconds into inference, the app simply vanished.

The cause wasn't the model's size. It was peak memory. File size and runtime footprint are different things: weights, KV cache, activations, and the OS and runtime reserve all have to fit inside what the device will actually grant you, at the same moment.

Whether it fits is decided by the peak, not the file. I've built every budget since around that one sentence.

Start with the published numbers

The on-device Gemma 4 models are E2B and E4B. Google's documentation lists approximate memory for the static weights.

ModelQ4_0 (GPU/TPU)Mobile (LiteRT-LM)Mobile (text-only)
Gemma 4 E2B2.9 GB1.1 GB0.84 GB
Gemma 4 E4B4.5 GB2.5 GB2.2 GB

Two caveats are easy to miss. These figures cover static weights only, not the KV cache or supporting software. And the same page says they vary by inference tool and environment. E2B and E4B also use Per-Layer Embeddings, so what gets loaded is larger than the "effective" parameter count suggests.

So the table is only the first row of your budget. The remaining rows you fill in by measuring on your own devices.

The four memory regions

An app that is running inference occupies roughly four regions.

  • Model weights: the quantized tensors themselves (static).
  • KV cache: Key/Value buffers for past tokens (grows with context length).
  • Activations: intermediate tensors during inference.
  • OS and runtime reserve: GPU driver, ML framework, and your app's own UI and caches.

Judge by weights alone and the other three regions will overrun you.

Estimate the KV cache with a formula

The KV cache can be estimated from layer count, KV head count, head dimension, context length, and element size.

KV bytes ≈ 2 × layers × kv_heads × head_dim × context_length × bytes_per_element

The leading 2 is for Key and Value. Layer and head counts differ per model, so I won't quote numbers here; read them from the model card or the runtime log. I chose context lengths by gut feeling at first and kept lowering the limit afterwards.

The takeaway is the linear relationship: double the context and this term doubles. You can't move the weights, but you can move this.

Filling in a budget table once, with placeholder numbers

The official page mentions a 128K context window. That is a ceiling, not a budget. Plug placeholder values into the formula above (24 layers, 4 KV heads, head dimension 128, 2 bytes for fp16) and the KV cache per context length falls out. These are not any real model's values; they are there to give you the order of magnitude.

Context lengthKV cache (placeholder)How to read it
4,096about 192 MiBA bit under a fifth of the weights; fits on most devices
8,192about 384 MiBNo longer negligible next to E2B's ~1.1 GB mobile weights
32,768about 1.5 GiBComparable to the weights; hard on mid-range phones even with E2B

The arithmetic: 2 × 24 × 4 × 128 × 4,096 × 2 bytes = 201,326,592 bytes, which is 192 MiB. Every other row is just that value doubled or quadrupled.

I skipped this table at first and spent a while thinking "it supports 128K, so I can go long." Being able to go long and surviving at that length are different questions. What belongs in your budget is not the model's ceiling but the largest value you measured on your own device without a crash.

Usable memory is smaller than physical memory

Classifying devices by total RAM alone is risky, because what your app may actually use is much smaller.

On iOS there is an API that returns what is left for your app right now.

import os
 
func availableBytesForApp() -> UInt64 {
    // iOS 13+. Roughly how much more the app can allocate.
    return UInt64(os_proc_available_memory())
}
 
func chooseProfile() -> ModelProfile {
    let gb = Double(availableBytesForApp()) / 1_073_741_824
 
    if gb >= 4.0 {
        return .e4bFullContext
    } else if gb >= 2.0 {
        return .e4bShortContext
    } else {
        return .e2bShortContext
    }
}

The 4.0 and 2.0 thresholds are placeholders. Derive yours from your model's measured peak plus a margin.

On Android, availMem and lowMemory from ActivityManager.getMemoryInfo() play the same role. If isLowRamDevice() returns true, I'd rather start from the smaller model.

import android.app.ActivityManager
import android.content.Context
 
fun chooseProfile(context: Context): ModelProfile {
    val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
    val info = ActivityManager.MemoryInfo().also { am.getMemoryInfo(it) }
 
    // While lowMemory is true, or on low-RAM devices, pin to the smaller model
    if (info.lowMemory || am.isLowRamDevice) return ModelProfile.E2B_SHORT_CONTEXT
 
    val availGb = info.availMem / 1_073_741_824.0
    return when {
        availGb >= 4.0 -> ModelProfile.E4B_FULL_CONTEXT
        availGb >= 2.0 -> ModelProfile.E4B_SHORT_CONTEXT
        else -> ModelProfile.E2B_SHORT_CONTEXT
    }
}

availMem reports free memory for the whole device, not a per-app ceiling like iOS's os_proc_available_memory(). Set the Android thresholds more conservatively than the iOS ones.

I split by remaining memory rather than device name because the same phone has different headroom depending on what is running in the background.

Sliding-window KV cache

When you want a long context but a lower peak, switch the KV cache from "keep everything" to "keep the prefix and slide a window." The system prompt stays at the front; the middle of old conversation is dropped.

class SlidingKVCache:
    """Keep the first keep_prefix tokens; slide the rest as a window."""
 
    def __init__(self, max_tokens: int, keep_prefix: int):
        self.max_tokens = max_tokens
        self.keep_prefix = keep_prefix
        self.tokens: list[int] = []
 
    def append(self, token_ids: list[int]) -> bool:
        """Returns True if trimming happened; the caller rebuilds the KV."""
        self.tokens.extend(token_ids)
        if len(self.tokens) <= self.max_tokens:
            return False
        overflow = len(self.tokens) - self.max_tokens
        head = self.tokens[: self.keep_prefix]
        tail = self.tokens[self.keep_prefix + overflow :]
        self.tokens = head + tail
        return True

The trade-off is forgetting whatever leaves the window. That rarely matters for FAQ-style chat, and it's a poor fit for long story generation or code editing. I'd make it switchable per use case.

Measure load and inference separately

Memory behaves differently while the model loads and while it runs. Some implementations briefly use nearly double during load, so you need both measurements to catch the peak.

import os.signpost
 
func measureLoadAndInference() {
    let log = OSLog(subsystem: "app.model", category: "memory")
 
    let before = os_proc_available_memory()
    os_signpost(.begin, log: log, name: "model_load")
    let model = loadModel()
    os_signpost(.end, log: log, name: "model_load")
    let afterLoad = os_proc_available_memory()
 
    os_signpost(.begin, log: log, name: "inference")
    _ = model.generate("Hello")
    os_signpost(.end, log: log, name: "inference")
    let afterRun = os_proc_available_memory()
 
    print("consumed by load:", before - afterLoad)
    print("remaining after inference:", afterRun)
}

Lining up signposts in Instruments tells you whether a crash happened during load or during inference. When a "it crashes in production" report arrives, that split saves a lot of time.

Be ready for memory warnings

When memory gets tight, the OS sends a warning and, if things don't improve, terminates the app. With a large model, design so you can unload the moment a warning arrives.

NotificationCenter.default.addObserver(
    forName: UIApplication.didReceiveMemoryWarningNotification,
    object: nil, queue: .main
) { _ in
    ModelManager.shared.unloadIfIdle(seconds: 5)
}

If the model is idle, release it and reload on next use. A few seconds of waiting beats losing the whole app.

On Android, onTrimMemory plays the same role.

// In Application or an Activity: the higher the level, the more the OS wants memory back
override fun onTrimMemory(level: Int) {
    super.onTrimMemory(level)
    if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
        ModelManager.unloadIfIdle(seconds = 5)
    }
}

Three assumptions that bit me

  • Stronger quantization doesn't solve everything. Activations and the KV cache shrink less than weights, and accuracy drops first.
  • Physical RAM isn't what you get. As above, the app's share is smaller and shifts with conditions.
  • Published or benchmark numbers won't appear as-is. Camera, networking, and background work compete with you, so re-measure in your real app.

What to try next

The smallest useful step: log os_proc_available_memory() at three points on a real device, before load, after load, and after inference. Writing those differences into rows two onward of your budget turns "it should fit" into numbers.

I no longer classify devices without those three readings.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⚙ AI Tools2026-06-12
Cutting Down 'Plausible but Wrong' RAG Answers — A Retrieval Evaluation Harness for Gemma 4 and Antigravity
Replace gut feeling with recall@5, MRR and faithfulness scores — a 30-question golden dataset and a small Python harness for evaluating a local Gemma 4 RAG stack.
⚙ AI Tools2026-04-22
Running Multiple Gemma 4 LoRAs in Production — A Practical Guide to Merging and Dynamic Adapter Switching
You've trained several LoRAs on Gemma 4 — summarization, translation, code review. How do you serve them without tripling your GPU bill? A working notebook on pre-merge rank checks, Weighted and TIES merges, dynamic switching, and measuring switch churn, written with Antigravity alongside.
⚙ AI Tools2026-04-21
Tuning Gemma 4 for Yourself — A Realistic LoRA / QLoRA Workflow on a Solo Developer's Budget
Full fine-tuning of Gemma 4 is out of reach for most individuals, but LoRA / QLoRA makes personalization realistic on a solo budget. This guide walks through data prep, training settings, evaluation, and wiring the result into an Antigravity workflow — from hard-earned practical experience.