llama.cpp / JNI / CMake 深掘り
このページは現行 Android 実装を題材にしつつ、より一般的な llama.cpp を Android アプリへ導入する設計に焦点を置いて、JNI 境界、ネイティブ状態管理、CMake 構成、llama.cpp/ggml の取り込み方を整理した深掘り資料です。
関連文書: 技術仕様 | 操作マニュアル | プライバシーポリシー
目次
1. このページの位置づけ
既存の「技術仕様」はアプリ全体の責務分担を俯瞰するページです。それに対してこのページは、「llama.cpp を Android にどう埋め込むか」という観点で、特に LlamaNative、jni_llama.cpp、CMakeLists.txt の設計意図を分解して説明します。
対象実装では、Java 側 UI と API サーバーの両方が最終的にひとつの共有ライブラリ llama_jni を呼びます。この「アプリは Java、推論エンジンは C/C++」という分離は、Android で llama.cpp を扱うときの最も一般的な構図です。
| 観点 | 本ページで主に扱うもの | アプリ固有寄りのもの |
|---|---|---|
| 共通設計 | JNI 境界、共有ライブラリ化、モデル初期化、生成ループ、CMake ターゲット設計 | 特になし |
| この実装の特徴 | arm64-v8a 限定、16KB page size 対応、setLoadParameters() / setParameters() 分離、2 段階 init | Android 用 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 表面に出しています。
| 層 | 役割 | この実装の具体例 |
|---|---|---|
| Gradle | ABI、NDK、CMake 引数の宣言 | arm64-v8a、ndkVersion 27.2.12479018、-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON |
| CMake | source 束ね、include path、link option、静的依存の結線 | llama_jni に llama.cpp / ggml / mtmd / curl / mbedTLS を集約 |
| JNI wrapper | Java 呼び出しを C/C++ 側の状態遷移へ変換 | LlamaNative と jni_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_k が setLoadParameters() に含まれていますが、考え方としては「モデル初期化の前後どちらで保持してもよい基礎ランタイム設定」と「より拡張的な sampler chain 設定」を 2 段に分けています。
4-2. 状態所有者をひとつに寄せる
jni_llama.cpp は g_model、g_ctx、g_mtmd、g_cancel_generation、g_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. 初期化フェーズ
- モデルファイルと任意の mmproj ファイルを検証する。
- split GGUF の欠落がないか確認する。
llama_backend_init()を呼び、利用可能 backend を確認する。llama_model_load_from_file()でモデルを読み込む。llama_init_from_model()で推論コンテキストを作る。- 必要なら mtmd を初期化し、vision/audio 対応を確定する。
この流れ自体はかなり一般的です。アプリ固有なのは、クラッシュマーカー、ログファイル、split GGUF 補完、HTTPS trust store 連携といった運用補助です。
5-2. 生成フェーズ
- メモリをクリアし、prompt prefill を行う。
- テキストのみなら
llama_tokenize()とllama_decode()をバッチで回す。 - マルチモーダルなら mtmd 側で chunk 化して同じ
g_ctxに prefill する。 - sampler chain を組み、
llama_sampler_sample()→llama_sampler_accept()→llama_decode()を反復する。 - 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_SOURCES: llama.cpp 本体、common、models、生成されたbuild-info.cpp。GGML_SOURCES: ggml 本体、backend、CPU 実装、ARM 固有実装。MTMD_SOURCES: 画像 / 音声 projector を扱う追加実装。
ここで面白いのは、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_CPU や GGML_USE_K_QUANTS は、どの backend / 量子化パスを有効化するかを compile definition で固定しています。
llama.cpp は upstream 更新で source 構成や macro の前提が変わりやすいため、Android へ持ち込むときは 「どの macro を有効にしたら、どの source 群が必要になるか」を CMake 側で明示しておくのが重要です。
6-4. Android 固有の knobs
| 項目 | この実装 | 意味 |
|---|---|---|
| ABI | arm64-v8a のみ | 対象端末を絞り、ネイティブサイズと検証面積を抑える |
| Flexible page sizes | -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON | Android 15 世代の 16KB page size 環境を意識した設定 |
| Link option | -Wl,-z,max-page-size=16384 / -Wl,-z,common-page-size=16384 | 16KB page size でのリンク互換性を確保する |
| NDK | 27.2.12479018 | ページサイズや最新 Android 互換性を踏まえた固定 |
6-5. libcurl / TLS の扱い
CMake は libcurl.a と mbedTLS 系静的ライブラリを IMPORTED として結線する設計です。これは「モデルダウンロードも同じ llama_jni ランタイム内で完結したい」ためです。一般的な導入では、ここを Java 側 HTTP クライアントに寄せる手もありますが、split GGUF やネイティブログと一体化したいならネイティブ側に寄せる価値があります。
7. 一般的な導入チェックリスト
- 対象 ABI を決める: 最初は
arm64-v8aだけに絞ると楽です。 - text-only から始める: まず
generate(prompt)が安定してから multimodal を足します。 - JNI API を薄く保つ: load / generate / cancel / free / callback 程度に留めます。
- 状態管理を一元化する: Java 側の manager と C++ 側 mutex の両方を置きます。
- load-time と sampling 設定を整理する:
n_ctx、スレッド数、GPU 層数と、penalty / Mirostat / DRY を混線させないようにします。 - 停止・キャンセルを最初から入れる: UI と API の両方で必要になります。
- ログとクラッシュ痕跡を残す: モデルロード失敗とネイティブクラッシュは再現しづらいため必須です。
- 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 設計は、その包み方の実践例として読むと理解しやすくなります。