Gemma 4 をモバイルアプリに組み込んだ直後、最初にぶつかる壁はモデルサイズではなくピークメモリでした。「1.8GB の量子化モデルなら載るだろう」と思って組み込んだら、実機で推論を回した瞬間に OS に殺される — この経験をした人は多いのではないでしょうか。
モバイルでの推論は、ファイルサイズとピーク使用量が別物です。モデル本体・KVキャッシュ・アクティベーション・OS予約を合算した瞬間メモリが、その端末の許容範囲に収まって初めて安定稼働します。ここでは私が Gemma 4 をモバイルに載せるときに使っているメモリ予算設計を、実測値とあわせて共有します。
モバイル推論で見るべき4つのメモリ領域
推論中のアプリがメモリを占有する内訳を分解すると、おおよそ次の4つになります。
- モデル重み: 量子化された重みデータ本体(静的)
- KV キャッシュ: 過去トークンの Key/Value を保持する領域(コンテキスト長に比例して増加)
- アクティベーション: 推論中の中間テンソル(層数・隠れ次元で決まる)
- OS・ランタイム予約: GPU ドライバ・ML フレームワーク・OS 自体
モデル重みだけを見て「載る」と判断すると、残りの3領域で必ず足が出ます。私のデバイスでの実測値を紹介します(Gemma 4 の 4bit 量子化版、コンテキスト 4096 トークン、iOS の MLX 経由)。
- モデル重み: 1.85 GB
- KV キャッシュ(4096 tokens): 約 350 MB
- アクティベーション: 約 220 MB
- OS/ランタイム予約: 約 180 MB
- 合計ピーク: 約 2.6 GB
iPhone 14(6GB 物理メモリ)ではアプリに割り当てられるのがおよそ 3 GB 弱なので、ギリギリ動く水準です。iPhone 12(4GB)では厳しく、コンテキストを 2048 に絞るか、さらに小さい量子化版に切り替える必要があります。
端末ティア別の推奨プロファイル
私は内部で端末を3ティアに分類し、それぞれで異なるモデル・設定を提供しています。
Tier 1: ハイエンド(RAM 8GB 以上)— iPhone 15 Pro, Pixel 9 Pro 等
- モデル: Gemma 4 Instruct 9B 4bit
- コンテキスト: 8192
- KV キャッシュ戦略: フル
- 期待ピーク: 4.5 GB 程度
Tier 2: ミドル(RAM 6GB)— iPhone 14/15, Pixel 8/9 等
- モデル: Gemma 4 Instruct 2B 4bit
- コンテキスト: 4096
- KV キャッシュ戦略: スライディングウィンドウ
- 期待ピーク: 2.6 GB
Tier 3: エントリー(RAM 4GB)— iPhone 12/13, Pixel 6a 等
- モデル: Gemma 4 Instruct 2B 4bit
- コンテキスト: 2048
- KV キャッシュ戦略: 部分保持 + 再計算
- 期待ピーク: 1.4 GB
Tier 3 では、パフォーマンスは落ちますが「端末が落ちるより、少し遅くても動く」という判断です。ローンチ時点で端末ティアを見せるのは失礼なので、インストール直後に自動判定して裏で切り替えています。
端末ティアを自動判定するコード
端末 RAM とチップ情報から、どのモデルを落とすかを決めるロジックです。
import Foundation
enum DeviceTier {
case entry, mid, high
static func detect() -> DeviceTier {
let ramBytes = ProcessInfo.processInfo.physicalMemory
let ramGB = Double(ramBytes) / 1_073_741_824
// 論理コア数で補助判定
let cores = ProcessInfo.processInfo.activeProcessorCount
if ramGB >= 8.0 && cores >= 6 {
return .high
} else if ramGB >= 5.5 {
return .mid
} else {
return .entry
}
}
var modelName: String {
switch self {
case .high: return "gemma-4-instruct-9b-q4"
case .mid: return "gemma-4-instruct-2b-q4"
case .entry: return "gemma-4-instruct-2b-q4"
}
}
var contextLength: Int {
switch self {
case .high: return 8192
case .mid: return 4096
case .entry: return 2048
}
}
}physicalMemory は iOS では「物理メモリ総量」を返すので、アプリに割り当て可能なメモリより少し大きい値です。Android では ActivityManager.MemoryInfo.totalMem で同等の情報が取れます。補助的に CPU コア数を見ているのは、iPad mini 6 のような「RAM はあるがチップが古い」端末を Tier を1つ下げて扱うためです。
KV キャッシュのスライディングウィンドウ設計
コンテキスト長を 8192 にしたいけれど、ピークメモリを抑えたい場合、KV キャッシュを「全保持」から「スライディングウィンドウ」に切り替えるのが効きます。
原理はシンプルで、ウィンドウ外のトークンは推論計算に含めない(= KV を捨てる)代わりに、重要情報を先頭に残す方式です。チャットアプリでは「システムプロンプト + 直近2000トークン」のようにします。
class SlidingKVCache:
"""前方 N トークンを必ず残し、残りをウィンドウで動かす。"""
def __init__(self, max_tokens: int, keep_prefix: int):
self.max_tokens = max_tokens
self.keep_prefix = keep_prefix
self.tokens: list[int] = []
# KV キャッシュ本体(モデル実装依存)
self.kv_cache = None
def append(self, token_ids: list[int]):
self.tokens.extend(token_ids)
if len(self.tokens) <= self.max_tokens:
return
# 上限を超えたら、prefix を除いた中間部分を切り詰める
overflow = len(self.tokens) - self.max_tokens
head = self.tokens[:self.keep_prefix]
tail = self.tokens[self.keep_prefix + overflow:]
self.tokens = head + tail
self._rebuild_kv_for_trimmed_context()この実装のトレードオフは「先頭 prefix 以降の過去情報が忘れられる」ことです。FAQ チャットや短期対話ではほとんど問題になりませんが、長い物語生成やコード編集には向きません。アプリの用途で切り替えられるようにしておくのがよいです。
ロード時とピーク時を分けて監視する
メモリはロード時(モデルをディスクから RAM に乗せる瞬間)と推論時(実行中)で波形が違います。ロード時に一時的に 2 倍使う実装もあるため、ピークを捉えるには両方の監視が必要です。
import os.signpost
func trackMemorySignposts() {
let log = OSLog(subsystem: "app.model", category: "memory")
let before = MemoryMonitor.currentUsage()
os_signpost(.begin, log: log, name: "model_load")
let model = loadModel()
let afterLoad = MemoryMonitor.currentUsage()
os_signpost(.end, log: log, name: "model_load",
"delta=%{public}llu", afterLoad - before)
os_signpost(.begin, log: log, name: "inference")
let output = model.generate("Hello")
let peak = MemoryMonitor.peakUsage()
os_signpost(.end, log: log, name: "inference",
"peak=%{public}llu", peak)
}Instruments で signpost を見ると、ロード時のスパイクと推論時のピークが時系列で可視化できます。「本番で端末が落ちる」という報告が来たとき、ロードで落ちているのか推論で落ちているのかが一瞬で分かります。
メモリ低下イベントに対応する
iOS・Android ともに、OS がメモリ警告を出します。これを無視していると、OS はアプリを強制終了します。Gemma 4 のようにメモリフットプリントが大きい場合、警告受信時にはモデルを一時的にアンロードする実装を入れておくと、アプリ自体が強制終了する最悪のケースを避けられます。
NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil, queue: .main
) { _ in
ModelManager.shared.unloadIfIdleForSeconds(5)
}ユーザーが5秒以上何もしていない状態であれば、モデルをアンロードしてメモリを返します。次に使うときは再ロードが必要なので数秒の待ちが発生しますが、アプリが落ちるよりはるかにマシです。
よくある勘違い
- 「量子化を強くすれば全部解決」ではありません。2bit 量子化はファイルサイズは小さいですが、推論中のアクティベーションは大きく変わらず、精度だけが大幅に落ちます
- 「デバイスの物理 RAM = アプリが使える RAM」ではありません。iOS では実際に使えるのは 50〜60% 程度、Android もバックグラウンドプロセス次第で変動します
- 「ベンチマークの数字がそのまま出る」ではありません。MLPerf 等の数字はクリーンな状態での値で、実アプリではカメラ・マイク・バックグラウンドタスクと競合します
全体を振り返って
モバイルでの Gemma 4 運用は、ファイルサイズではなく「ピークメモリ」を設計の起点に置くと安定します。今日試せる一歩として、まずあなたのアプリで physicalMemory を取得して端末ティアを分類してみてください。そこに合わせてモデルとコンテキスト長を切り替えるだけで、落ちるアプリから動くアプリに変わります。「全端末で同じ品質」を目指すのではなく「端末ごとに賢く切り替える」のが、モバイル AI の現実解ではないでしょうか。