llama.cpp / JNI / CMake 深掘り

このページは現行 Android 実装を題材にしつつ、より一般的な llama.cpp を Android アプリへ導入する設計に焦点を置いて、JNI 境界、ネイティブ状態管理、CMake 構成、llama.cpp/ggml の取り込み方を整理した深掘り資料です。

関連文書: 技術仕様 | 操作マニュアル | プライバシーポリシー

Android NDK JNI 境界設計 llama.cpp 導入パターン ggml CPU backend mtmd 拡張 CMake 単一ターゲット構成

目次

1. このページの位置づけ

既存の「技術仕様」はアプリ全体の責務分担を俯瞰するページです。それに対してこのページは、「llama.cpp を Android にどう埋め込むか」という観点で、特に LlamaNativejni_llama.cppCMakeLists.txt の設計意図を分解して説明します。

対象実装では、Java 側 UI と API サーバーの両方が最終的にひとつの共有ライブラリ llama_jni を呼びます。この「アプリは Java、推論エンジンは C/C++」という分離は、Android で llama.cpp を扱うときの最も一般的な構図です。

観点本ページで主に扱うものアプリ固有寄りのもの
共通設計JNI 境界、共有ライブラリ化、モデル初期化、生成ループ、CMake ターゲット設計特になし
この実装の特徴arm64-v8a 限定、16KB page size 対応、setLoadParameters() / setParameters() 分離、2 段階 initAndroid 用 UI / Foreground Service / 独自 API サーバー
拡張要素mtmd による画像・音声 projector、libcurl + mbedTLS ダウンロードWebUI 同梱、Ollama/OpenAI 互換レイヤ

2. なぜ JNI + CMake で包むのか

Android で llama.cpp を使う方法は大きく 3 つあります。1) 既製のネイティブライブラリを APK に同梱する、2) upstream の CMake をほぼそのまま呼ぶ、3) アプリ専用の共有ライブラリへ必要 source を直接まとめる、です。この実装は 3) に寄せています。

方式利点注意点
prebuilt binary を同梱導入が早いABI、ビルドフラグ、依存ライブラリ、更新タイミングの制御が弱い
upstream を add_subdirectory で丸ごと使う上流に追従しやすいAndroid アプリに不要な CLI/ツール構成が混ざりやすい
必要 source を app 用ターゲットへ直接集約APK に必要な構成だけを固定しやすい。JNI/API 境界も明確にできるupstream 更新時に source list と compile definition の差分確認が必要

一般的なアプリ組み込みでは、「Java/Kotlin は UI と運用制御」「C/C++ は推論ランタイム」と割り切るほど設計が安定します。特に llama.cpp はコンテキスト、サンプラー、バックエンド初期化、量子化形式の扱いがネイティブ寄りなので、CMake でひとつの共有ライブラリにまとめたほうが責務を整理しやすいです。

3. エンドツーエンドの流れ

Gradle (externalNativeBuild)
  ↓
CMakeLists.txt
  ↓
llama_jni.so
  ↓
System.loadLibrary("llama_jni")
  ↓
LlamaNative native methods
  ↓ JNI
jni_llama.cpp
  ↓
llama.cpp / ggml / optional mtmd / curl / TLS

ここで重要なのは、llama.cpp を Java から直接触るのではなく、Java から見える API はごく薄い JNI façade に限定することです。この実装では、モデルロード前のパラメータ設定、モデル初期化、生成、停止、ストリーミング callback、解放、能力問い合わせだけを JNI 表面に出しています。

役割この実装の具体例
GradleABI、NDK、CMake 引数の宣言arm64-v8andkVersion 27.2.12479018-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON
CMakesource 束ね、include path、link option、静的依存の結線llama_jni に llama.cpp / ggml / mtmd / curl / mbedTLS を集約
JNI wrapperJava 呼び出しを C/C++ 側の状態遷移へ変換LlamaNativejni_llama.cpp
llama.cpp runtimeモデルロード、tokenize、decode、sampler、EOG 判定llama_model_load_from_file()llama_init_from_model()llama_sampler_*

4. JNI 設計の要点

4-1. Java 側 API は薄く保つ

LlamaNative が公開するメソッドは、実質的に「ロード系」「生成系」「補助系」の 3 群です。一般的な導入でも、まずは次の粒度で切るのが扱いやすいです。

load 系:
  setLoadParameters(...)
  initWithMmproj(modelPath, mmprojPath)
  free()

generate 系:
  setParameters(...)
  generate(prompt)
  generateWithMedia(prompt, media)
  cancelGeneration()

補助系:
  setTokenListener(listener)
  getChatTemplate()
  supportsVision()
  supportsAudio()

この実装では temp / top_p / top_ksetLoadParameters() に含まれていますが、考え方としては「モデル初期化の前後どちらで保持してもよい基礎ランタイム設定」と「より拡張的な sampler chain 設定」を 2 段に分けています。

4-2. 状態所有者をひとつに寄せる

jni_llama.cppg_modelg_ctxg_mtmdg_cancel_generationg_jvm をグローバルに持ち、さらに g_mutex で保護しています。これは Android アプリではかなり現実的な構成で、同じモデル状態に複数の Java 経路が同時に触れないことが最優先です。

一方、Java 側では ModelManager が busy 状態と再初期化を制御します。つまり「UI / HTTP / Service の競合は Java 側で抑え、ネイティブ内部でも mutex で最後の砦を置く」という二重防御です。

4-3. callback は JavaVM を保持して戻す

ストリーミング出力は TokenListener を global ref 化し、必要なときに JavaVM から AttachCurrentThread() してコールバックしています。一般的な JNI 実装でも、ネイティブ生成ループのスレッドが Java スレッドとは限らないため、JNIEnv* を使い回さず、都度 attach / detach する設計が安全です。

4-4. 2 段階 init は実運用向けの工夫

この実装では ModelManager が最初に n_ctx=64 で preload し、その後に本来の n_ctx で再初期化します。これは一般的な llama.cpp 組み込みでも有効なパターンで、巨大コンテキストや不完全なモデルファイルによる失敗を早い段階で切り分けやすくなります。

5. llama.cpp ランタイムの見方

5-1. 初期化フェーズ

  1. モデルファイルと任意の mmproj ファイルを検証する。
  2. split GGUF の欠落がないか確認する。
  3. llama_backend_init() を呼び、利用可能 backend を確認する。
  4. llama_model_load_from_file() でモデルを読み込む。
  5. llama_init_from_model() で推論コンテキストを作る。
  6. 必要なら mtmd を初期化し、vision/audio 対応を確定する。

この流れ自体はかなり一般的です。アプリ固有なのは、クラッシュマーカー、ログファイル、split GGUF 補完、HTTPS trust store 連携といった運用補助です。

5-2. 生成フェーズ

  1. メモリをクリアし、prompt prefill を行う。
  2. テキストのみなら llama_tokenize()llama_decode() をバッチで回す。
  3. マルチモーダルなら mtmd 側で chunk 化して同じ g_ctx に prefill する。
  4. sampler chain を組み、llama_sampler_sample()llama_sampler_accept()llama_decode() を反復する。
  5. EOG、stop sequence、context safety limit、cancel flag のいずれかで止める。

つまり JNI 層の本質は「llama.cpp API を 1 回呼ぶこと」ではなく、モデル状態・サンプラー状態・コールバック状態を Android から扱いやすい API へ再構成することです。

5-3. sampler chain は UI パラメータの変換層

この実装では penalty、DRY、top-n-sigma、top-k、typical、top-p、min-p、XTC、temperature、Mirostat を順に chain 化しています。一般的なアプリ組み込みでも、Java 側の設定画面をそのままネイティブ sampler 設定へ写像する層が必要です。

ここを generate() の中で毎回構築するか、構成変更時にキャッシュするかは設計判断ですが、llama.cpp の更新追従性を考えると、まずは 都度構築して明快さを優先するほうが保守しやすいケースが多いです。

5-4. text-only と multimodal を分けて考える

一般的な llama.cpp 導入の最小構成は text-only です。現在の実装が mtmd を同じターゲットに入れているのは、画像 / 音声 projector を含むモデルまで 1 本の llama_jni で扱いたいからです。逆に言えば、最初の導入では mtmd を切り離して text-only で完成させるほうが検証しやすいです。

6. CMake 設計の読み解き方

6-1. この CMakeLists.txt がやっていること

add_library(llama_jni SHARED
  jni/jni_llama.cpp
  ${LLAMA_SOURCES}
  ${GGML_SOURCES}
)

target_compile_definitions(llama_jni PRIVATE
  GGML_USE_CPU
  GGML_USE_K_QUANTS
)

target_link_libraries(llama_jni PRIVATE
  curl mbedtls mbedcrypto mbedx509
  log android jnigraphics m dl atomic
)

読み方のコツは、「アプリ用の最終生成物は llama_jni.so ひとつ」と捉えることです。llama.cpp、ggml、mtmd、ダウンロード/TLS 依存は、CMake の中でこのひとつの共有ライブラリに収束しています。

6-2. source grouping の意味

ここで面白いのは、llama.cpp 側は比較的広く GLOB を使いながら、ggml の backend-sensitive な source は明示列挙していることです。一般的な導入でも、変化の激しい上流コードと、確実に固定したい backend 実装を分けて管理すると更新時の差分確認がしやすくなります。

6-3. build-info と compile definition

configure_file()build-info.cpp を生成しているのは、llama.cpp 側が期待する build number / commit / compiler 情報を CMake 時点で注入するためです。同様に GGML_USE_CPUGGML_USE_K_QUANTS は、どの backend / 量子化パスを有効化するかを compile definition で固定しています。

llama.cpp は upstream 更新で source 構成や macro の前提が変わりやすいため、Android へ持ち込むときは 「どの macro を有効にしたら、どの source 群が必要になるか」を CMake 側で明示しておくのが重要です。

6-4. Android 固有の knobs

項目この実装意味
ABIarm64-v8a のみ対象端末を絞り、ネイティブサイズと検証面積を抑える
Flexible page sizes-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ONAndroid 15 世代の 16KB page size 環境を意識した設定
Link option-Wl,-z,max-page-size=16384 / -Wl,-z,common-page-size=1638416KB page size でのリンク互換性を確保する
NDK27.2.12479018ページサイズや最新 Android 互換性を踏まえた固定

6-5. libcurl / TLS の扱い

CMake は libcurl.a と mbedTLS 系静的ライブラリを IMPORTED として結線する設計です。これは「モデルダウンロードも同じ llama_jni ランタイム内で完結したい」ためです。一般的な導入では、ここを Java 側 HTTP クライアントに寄せる手もありますが、split GGUF やネイティブログと一体化したいならネイティブ側に寄せる価値があります。

7. 一般的な導入チェックリスト

  1. 対象 ABI を決める: 最初は arm64-v8a だけに絞ると楽です。
  2. text-only から始める: まず generate(prompt) が安定してから multimodal を足します。
  3. JNI API を薄く保つ: load / generate / cancel / free / callback 程度に留めます。
  4. 状態管理を一元化する: Java 側の manager と C++ 側 mutex の両方を置きます。
  5. load-time と sampling 設定を整理する: n_ctx、スレッド数、GPU 層数と、penalty / Mirostat / DRY を混線させないようにします。
  6. 停止・キャンセルを最初から入れる: UI と API の両方で必要になります。
  7. ログとクラッシュ痕跡を残す: モデルロード失敗とネイティブクラッシュは再現しづらいため必須です。
  8. upstream 更新時の差分点を決めておく: source list、compile definition、build-info、mtmd 追加分を毎回確認します。

8. 実装時の落とし穴

落とし穴なぜ起きるか対処の方向性
upstream 更新で急にビルドが壊れるllama.cpp / ggml の source 構成や macro 依存が変わるためCMake の source list と definition を更新差分とセットで見直す
Java callback で落ちるネイティブスレッドから古い JNIEnv* を使ってしまうためJavaVM を保持し、必要時に attach / detach する
長い prompt で即失敗するn_ctx を超える token 数が prefill で発生するためtokenize 後に上限チェックし、UI/API に明確なエラーを返す
split GGUF の一部欠落に気づきにくいprimary shard だけ存在していてもロード時に失敗するためロード前とダウンロード後の両方で欠落 shard を検査する
HTTPS ダウンロードだけ証明書エラーになるJava とネイティブで trust store の前提が違うためAndroid CA Store を PEM 化して libcurl 側へ渡す
generate() が肥大化するprefill、sampler、callback、stop sequence、UTF-8 整形が 1 箇所に集まりやすいためprefill、callback、stop 判定を補助関数へ切り出して責務を分ける

要するに、一般的な llama.cpp 導入で一番大事なのは「モデルを動かすこと」そのものよりも、Android 側のアプリ寿命・スレッド・ABI・更新運用に合わせて、ネイティブ推論をどう包むかです。このページで扱った JNI / CMake 設計は、その包み方の実践例として読むと理解しやすくなります。