Pixel 10 Pro FoldのTPUでGemma 4を動かす ― Tensor G5向けモデルを自作Androidアプリから使う
そして最後に決めたのが、Java版とは別にKotlinの新しいプロジェクトを作り、まずはGemmaを自作アプリから直接動かしてみることだった。
今回はその続きである。
Gemma 4を動かすだけでなく、Pixel 10 Pro Foldに搭載されているGoogle Tensor G5のTPUを使って、Tensor G5専用モデルから実際に日本語を生成するところまで確認できた。
前回の記事より少し技術寄りになるが、今回は「動いた」という結果だけではなく、実際にAndroid Studioでどのような構成を作り、どのファイルを変更し、どのコードを書いたのかまでまとめておく。
前回の最後に決めたKotlin版を実際に作った
前回の記事では、GemmaをLiteRT-LMから直接利用するため、新しいKotlinプロジェクトを作るところで終わっていた。
そこでまずは、本来作りたい読書ブログ画面には手を付けず、モデルを選択して文章を生成するだけの小さな動作確認アプリを作った。
↓
アプリ内部へコピー
↓
LiteRT-LMでモデルを初期化
↓
「ローカルAIで生成」
↓
日本語の生成結果を表示
モデル選んで、「ローカルAIで生成」を押すだけ。
まずAI部分だけ動くことを確認した方が原因を切り分けやすい。
でも本命はTensor G5のTPU。
今回実際にやった作業を先にまとめる
細かい説明へ入る前に、実際に行った作業を順番に並べると次のようになる。
- Kotlin + XML ViewsのAndroidプロジェクトを作る
- LiteRT-LMを呼び出すテスト画面を作る
- モデルファイルを選択してアプリ内部へ保存できるようにする
- Google Tensor G5用のDynamic Featureモジュールを作る
- Tensor用の
libLiteRtDispatch_GoogleTensor.soをDynamic Featureへ配置する - AndroidManifest.xmlへNPUとTensor用native libraryの宣言を追加する
- LiteRT-LMのKotlin APIとnative runtimeの世代を揃える
- Tensor G5モデルでは
Backend.NPUを選択する - AABを作り、bundletoolでDynamic Feature込みのAPKセットを作る
- Pixel 10 Pro Foldへインストールする
- 旧Tensor G5モデルを選択して初期化する
- プロンプトを送り、日本語文章の生成を確認する
むしろAndroid側のnative runtime構成を作る作業の方が大きい。
まずは普通のKotlinアプリとしてモデル選択画面を作る
最初のテスト画面は非常に単純なものにした。
- モデル選択ボタン
- 現在読み込んでいるモデル名
- 実行バックエンド表示
- プロンプト入力欄
- 生成ボタン
- 生成結果表示欄
モデルファイルはAndroidのファイル選択画面から選べるようにした。
例えばMainActivity側では、次のような形でファイルピッカーを呼び出せる。
private val modelPicker =
registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) {
importModel(uri)
}
}
private fun selectModel() {
modelPicker.launch(arrayOf("*/*"))
}
実際のアプリでは.litertlmモデルを選択し、それをそのまま外部ストレージから利用するのではなく、アプリ内部のmodelsディレクトリへコピーした。
簡略化したサンプルなら次のようになる。
private fun copyModelToAppStorage(
uri: Uri,
fileName: String
): File {
val modelDir = File(filesDir, "models")
modelDir.mkdirs()
val destination = File(modelDir, fileName)
contentResolver.openInputStream(uri).use { input ->
requireNotNull(input)
destination.outputStream().use { output ->
input.copyTo(output)
}
}
return destination
}
一度選んだモデルをアプリ専用領域へコピーして、そのパスを保存しておけばいい。
GPU用モデルとTensor G5用モデルは実行方法が違う
GPUモデルであれば、LiteRT-LMのバックエンドとしてGPUを指定する。
val backend = Backend.GPU()
一方、Google Tensor G5向けモデルの場合はNPUバックエンドを指定する。
val backend = Backend.NPU(
nativeLibraryDir = applicationInfo.nativeLibraryDir
)
Google Tensorでは一般的にTPUという呼び方をするが、LiteRT-LMのAPI上ではBackend.NPUという名前になっている。
そのためテストアプリでは、
と表示するようにした。
モデル名からTensor G5向けかを判定する簡単な例なら、次のように書ける。
private fun selectBackend(modelFile: File): Backend {
val name = modelFile.name.lowercase()
return if (name.contains("google_tensor_g5")) {
Backend.NPU(
nativeLibraryDir = applicationInfo.nativeLibraryDir
)
} else {
Backend.GPU()
}
}
その先でGoogle Tensor用runtimeへ接続する。
ただしBackend.NPUを指定するだけでは動かない
ここがGPU版と大きく違うところだった。
Tensor G5では、
Backend.NPU(...)
と書くだけではなく、Google Tensor向けのnative runtimeをアプリ側に用意する必要がある。
↓
LiteRT-LM
↓
Google Tensor Dispatch Runtime
↓
Google Tensor G5 TPU / NPU
中心になるのが次のnative libraryである。
libLiteRtDispatch_GoogleTensor.so
実際の作業1 ― Tensor Runtime用のモジュールを追加する
今回の構成では、Tensor用runtimeを通常のappモジュールへ直接入れず、Dynamic Featureとして分離した。
サンプルとして、次のようなプロジェクト構成を考える。
TensorLocalAiSample
│
├─ app
│
└─ tensor_runtime
├─ runtime_strings
└─ google_tensor_runtime
settings.gradle.ktsへモジュールを追加する。
include(":app")
include(":tensor_runtime:runtime_strings")
include(":tensor_runtime:google_tensor_runtime")
次にapp側のbuild.gradle.ktsへDynamic Featureを登録する。
android {
namespace = "com.example.tensorlocalai"
defaultConfig {
applicationId = "com.example.tensorlocalai"
minSdk = 32
}
dynamicFeatures += setOf(
":tensor_runtime:google_tensor_runtime"
)
}
ここで使っているcom.example.tensorlocalaiは記事用のサンプルパッケージ名である。
実際の作業2 ― Dynamic Feature側を作る
google_tensor_runtime側は、コードを持つ画面ではなくTensor用native libraryを運ぶためのモジュールとして作った。
google_tensor_runtime/build.gradle.ktsの基本形は次のようになる。
plugins {
id("com.android.dynamic-feature")
}
android {
namespace = "com.example.tensorlocalai.tensorruntime"
compileSdk = 36
defaultConfig {
minSdk = 32
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("src/main/jni")
}
}
}
dependencies {
implementation(project(":app"))
}
そしてnative libraryを次の場所へ配置する。
tensor_runtime/
└─ google_tensor_runtime/
└─ src/
└─ main/
└─ jni/
└─ arm64-v8a/
└─ libLiteRtDispatch_GoogleTensor.so
app/src/main/jniLibsじゃないの?
だから専用モジュール側へ置いている。
実際の作業3 ― Dynamic FeatureのManifestを書く
ローカル動作確認では、Tensor runtimeをinstall-time Featureとしてインストールする構成にした。
サンプルのAndroidManifest.xmlは次のようになる。
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution">
<dist:module
dist:instant="false"
dist:title="@string/tensor_runtime_title">
<dist:delivery>
<dist:install-time>
<dist:removable
dist:value="true" />
</dist:install-time>
</dist:delivery>
<dist:fusing
dist:include="true" />
</dist:module>
</manifest>
今回removable="true"としているのは、bundletoolでTensor runtimeを独立したFeature splitとして確認するための検証用構成である。
最終的にGoogle Playで配布する場合の構成とは別に考える必要がある。
実際の作業4 ― appのAndroidManifest.xmlを修正する
Tensor用Dispatch Runtimeを用意しても、それだけではPixel側のTensor用vendor libraryへアクセスできない。
そこでapp側のAndroidManifest.xmlへNPUの宣言を追加する。
<uses-feature
android:name="android.hardware.npu"
android:required="false" />
さらに<application>内へTensor関連のnative libraryを追加する。
<application
android:extractNativeLibs="true"
... >
<uses-native-library
android:name="libOpenCL.so"
android:required="false" />
<uses-native-library
android:name="libcdsprpc.so"
android:required="false" />
<uses-native-library
android:name="libedgetpu_litert.so"
android:required="false" />
<uses-native-library
android:name="libedgetpu_util.so"
android:required="false" />
<uses-native-library
android:name="libedgetpu_client.google.so"
android:required="false" />
<uses-native-library
android:name="libedgetpu_tachyon.google.so"
android:required="false" />
</application>
これらのライブラリを自分のAPKへコピーするという意味ではない。
Pixel側に存在するTensor関連ライブラリを、自分のアプリから利用できるようにするための宣言である。
uses-native-libraryって、ここに書いた.soを自分で全部用意するわけじゃないの?
Android端末側にあるライブラリを、アプリから参照できるようにする宣言だ。
その先のTensor用ライブラリはPixel側、ということね。
実際の作業5 ― LiteRT-LMのKotlin側とnative側を揃える
LiteRT-LMはKotlinだけで動いているわけではない。
↓
liblitertlm_jni.so
↓
libLiteRt.so
↓
Google Tensor Dispatch
↓
Tensor G5
Kotlin側のAPIとliblitertlm_jni.so側のJNIインターフェースは、同じ世代に揃える必要がある。
今回、実際にTensor G5で文章生成まで成功した構成では、Google AI Edge Galleryで動いているnative runtime側に合わせて、LiteRT-LM v0.15.0相当のKotlin APIを使用した。
作業としては、LiteRT-LM公式ソースの、
kotlin/java/com/google/ai/edge/litertlm/
以下にあるKotlin APIソースを、サンプルプロジェクトなら次の位置へ配置する。
app/
└─ src/
└─ main/
└─ java/
└─ com/
└─ google/
└─ ai/
└─ edge/
└─ litertlm/
├─ Engine.kt
├─ Config.kt
├─ Conversation.kt
├─ Message.kt
├─ LiteRtLmJni.kt
└─ ...
関連する依存関係も追加する。
dependencies {
implementation(
"com.google.code.gson:gson:2.12.1"
)
implementation(
kotlin("reflect")
)
implementation(
"org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0"
)
}
でも今回のTensor G5検証では、Gallery側nativeとJNIの世代を揃える必要があった。
native側なので、合わない場合は普通のKotlin例外ではなくアプリごと落ちることもある。
Google AI Edge Galleryのnative runtimeを動作確認用として利用した
今回の目的は、まず「Pixel 10 Pro FoldのTensor G5で本当にGemma 4を生成まで動かせるのか」を確認することだった。
そこで、すでに同じ端末でNPU動作しているGoogle AI Edge Galleryのnative runtimeを比較対象として利用した。
Android Debug Bridgeから、インストールされているGalleryのAPK位置を確認できる。
adb shell pm path com.google.ai.edge.gallery
すると、base APKだけではなく複数のsplit APKが表示される。
package:/data/app/.../base.apk
package:/data/app/.../split_config.arm64_v8a.apk
package:/data/app/.../split_config.ja.apk
package:/data/app/.../split_google_tensor_runtime.apk
package:/data/app/.../split_google_tensor_runtime.config.arm64_v8a.apk
必要なsplitをPCへ取得する場合は、
adb pull <APK_PATH> gallery-arm64.apk
のように取得し、ZIPとして展開してnative libraryを確認できる。
今回確認した主なファイルは、
libLiteRt.so
liblitertlm_jni.so
libLiteRtDispatch_GoogleTensor.so
である。
動作確認したバイナリはSHA-256も記録した。
libLiteRt.so
31672bad4be4dcaa7c7efbecbb0346e36ad30209807529a8366ce291acb9726e
liblitertlm_jni.so
83f96e035bd6b49a691113743a8b7f26912f0a84a0df07351dafd5c5dbe33b5d
libLiteRtDispatch_GoogleTensor.so
e3776e8bd21cc721cf873e8e802a74365adc60f789fb925c3be46de7729bc593
Google AI Edge Galleryから取得したバイナリを、そのまま一般配布用アプリへ同梱してよいという意味ではない。
一般公開するアプリでは、公式に再配布可能なruntime構成へ整理する必要がある。
実際の作業6 ― EngineConfigを作ってNPUで初期化する
必要なruntimeが揃ったら、Kotlin側からTensor G5用のEngineを作る。
サンプルコードは次のようになる。
import com.google.ai.edge.litertlm.Backend
import com.google.ai.edge.litertlm.Engine
import com.google.ai.edge.litertlm.EngineConfig
private fun createTensorEngine(
modelFile: File
): Engine {
val config = EngineConfig(
modelPath = modelFile.absolutePath,
backend = Backend.NPU(
nativeLibraryDir =
applicationInfo.nativeLibraryDir
),
cacheDir = cacheDir.absolutePath
)
return Engine(config)
}
Engineを作っただけではまだ推論できないので、バックグラウンドスレッド側でinitialize()する。
val engine = createTensorEngine(modelFile)
withContext(Dispatchers.Default) {
engine.initialize()
}
数GBのモデルを扱うため、UIスレッド上で初期化すると画面が固まる。
そのためCoroutinesを使ってバックグラウンド処理にした。
難しいのは、その
Backend.NPUの先に必要なnative runtimeを正しく用意するところだ。
実際の作業7 ― Conversationを作って文章を生成する
Engineが初期化できたらConversationを作る。
今回使ったGemma 4 E2BではSamplerを次のように設定した。
val conversationConfig =
ConversationConfig(
samplerConfig = SamplerConfig(
topK = 1,
topP = 1.0,
temperature = 1.0
)
)
そして文章を生成する。
val result =
engine.createConversation(
conversationConfig
).use { conversation ->
conversation
.sendMessage(
"日本語で、読書ブログの冒頭に使える文章を作ってください。"
)
.toString()
}
実際のアプリでは、この処理もUIスレッドから分離する。
val generatedText =
withContext(Dispatchers.Default) {
engine.createConversation(
conversationConfig
).use { conversation ->
conversation
.sendMessage(prompt)
.toString()
}
}
resultTextView.text = generatedText
なぜtopKを1にしているのか
今回使用したGemma 4 E2Bモデルでは、SamplerのtopKに制約があった。
そのため、
topK = 1
としている。
この設定でGPU版でもTensor G5版でも文章生成まで確認できた。
Tensor G5用モデルは「旧モデル」を使った
今回の記事で特に注意しておきたいのがモデルのバージョンである。
今回Tensor G5 NPUで生成まで成功したのは、現在公開されている新しいモデルではなく、一つ前の旧Tensor G5モデルだった。
動作確認できた旧モデルのサイズは、
3,953,110,901 bytes
約3.95GBである。
Android画面上ではGiB換算のため、およそ3.68GBと表示された。
SHA-256は次の通り。
62faebcfd101acb841c33249530430397e031eb17d4dd3d2a71193d135705f27
一方、現在の約3.11GB版Tensor G5モデルでは、
Input tensor not found
となり、この環境では正常に利用できなかった。
ここで重要なのは、自作アプリだけで発生した現象ではないことである。
新しい約3.11GB版をGoogle AI Edge Galleryへ読み込ませても正常に動作しなかった。
一方、旧約3.95GB版をAI Edge Galleryへ読み込ませた場合は、
となり、文章生成まで成功した。
そのため今回の検証では、アプリ側の問題とモデル側の問題を混同しないよう、GalleryでもNPU動作が確認できている旧モデルを基準にした。
実際の作業8 ― AABを作る
今回Tensor runtimeをDynamic Featureとしているため、動作確認ではAABを作り、bundletoolを使ってFeature split込みでインストールした。
サンプルプロジェクトの場所を、
C:\Dev\TensorLocalAi
とした場合、まずプロジェクトディレクトリへ移動する。
cd C:\Dev\TensorLocalAi
Android Studio付属のJavaを使う場合は、例えば次のように設定する。
set "JAVA_HOME=C:\Program Files\Android\Android Studio\jbr"
set "PATH=%JAVA_HOME%\bin;%PATH%"
そしてAABを作成する。
gradlew.bat :app:bundleDebug
成功すれば、例えば、
app\build\outputs\bundle\debug\app-debug.aab
が作成される。
実際の作業9 ― bundletoolでAPKセットを作る
次にbundletoolでAABからAPKSを作成する。
java -jar tools\bundletool-all-1.18.3.jar build-apks ^
--bundle=app\build\outputs\bundle\debug\app-debug.aab ^
--output=tensor-local-ai-debug.apks ^
--overwrite
ここで作成される.apksは一つの普通のAPKではなく、端末に必要な複数のsplit APKをまとめたファイルである。
実際の作業10 ― Pixelへインストールする
ADBでPixelが見えていることを確認する。
adb devices
例えば、
List of devices attached
YOUR_DEVICE_ID device
と表示されたとする。
bundletoolからインストールする。
java -jar tools\bundletool-all-1.18.3.jar install-apks ^
--apks=tensor-local-ai-debug.apks ^
--device-id=YOUR_DEVICE_ID ^
--adb="%LOCALAPPDATA%\Android\Sdk\platform-tools\adb.exe"
ここでYOUR_DEVICE_IDは自分の端末IDへ置き換える。
インストール後は、Tensor runtime用splitも端末に入っている状態になる。
base.apk
split_config.arm64_v8a.apk
split_config.ja.apk
split_config.xxhdpi.apk
split_google_tensor_runtime.apk
split_google_tensor_runtime.config.arm64_v8a.apk
だからAABとbundletoolで明示的にインストールした。
実際の作業11 ― 旧Tensor G5モデルを読み込む
アプリを起動したら、「モデル選択」から旧Tensor G5モデルを選択する。
アプリ内部へコピーしたあと、モデル名を判定してNPUバックエンドを選択する。
val isTensorModel =
modelFile.name
.lowercase()
.contains("google_tensor_g5")
val backend =
if (isTensorModel) {
Backend.NPU(
nativeLibraryDir =
applicationInfo.nativeLibraryDir
)
} else {
Backend.GPU()
}
そしてEngineを初期化する。
val engineConfig =
EngineConfig(
modelPath = modelFile.absolutePath,
backend = backend,
cacheDir = cacheDir.absolutePath
)
val engine = Engine(engineConfig)
engine.initialize()
そしてTensor G5 TPU / NPUで生成に成功した
ここまでの構成で旧Tensor G5モデルを読み込むと、モデル初期化が完了した。
アプリ画面では、
実行バックエンド: TPU / NPU (Google Tensor G5)
モデル: Tensor G5用Gemma 4 E2B (約3.68 GB)
と表示された。
さらに日本語プロンプトを入力して生成ボタンを押すと、実際に日本語文章が返ってきた。
↓
Tensor G5用Gemma 4 E2B旧モデル
↓
LiteRT-LM
↓
Google Tensor Dispatch Runtime
↓
Tensor G5 TPU / NPU
↓
日本語文章生成
画面も「生成完了」。
GPUへのフォールバックで動いたわけではない
ここは念のため明確にしておきたい。
今回の結果は、NPUの初期化に失敗したあとGPUへ切り替わって文章が生成できた、というものではない。
Tensor G5向けモデルに対して明示的に、
Backend.NPU(
nativeLibraryDir = applicationInfo.nativeLibraryDir
)
を指定している。
その状態でEngineを初期化し、Conversationを作成し、日本語生成まで完了した。
Tensor G5専用モデルをNPUバックエンドで動かして、そのまま生成まで進んでいる。
作業内容をファイル単位で整理すると
今回の作業を「どのファイルを触ったか」で整理すると、だいたい次のようになる。
最終的なサンプル構成
TensorLocalAiSample
│
├─ settings.gradle.kts
│
├─ app
│ ├─ build.gradle.kts
│ │
│ └─ src
│ └─ main
│ ├─ AndroidManifest.xml
│ │
│ ├─ java
│ │ ├─ com/example/tensorlocalai/
│ │ │ ├─ MainActivity.kt
│ │ │ └─ LocalAiManager.kt
│ │ │
│ │ └─ com/google/ai/edge/litertlm/
│ │ ├─ Engine.kt
│ │ ├─ Config.kt
│ │ ├─ Conversation.kt
│ │ └─ ...
│ │
│ └─ jniLibs
│ └─ arm64-v8a
│ ├─ libLiteRt.so
│ └─ liblitertlm_jni.so
│
└─ tensor_runtime
├─ runtime_strings
│
└─ google_tensor_runtime
├─ build.gradle.kts
│
└─ src
└─ main
├─ AndroidManifest.xml
│
└─ jni
└─ arm64-v8a
└─ libLiteRtDispatch_GoogleTensor.so
今回動いた構成をまとめる
- Google Pixel 10 Pro Foldを使用
- Google Tensor G5のTPU / NPUを使用
- AndroidアプリはKotlin + XML Views
- LiteRT-LMから
Backend.NPUを指定 - Google Tensor Dispatch RuntimeをDynamic Featureとして配置
- AndroidManifest.xmlでTensor用vendor native libraryを公開
- Kotlin APIとJNI/native runtimeの世代を揃える
- Gemma 4 E2Bの旧Tensor G5モデル約3.95GB版を使用
SamplerConfigはtopK = 1- AABを作り、bundletoolでFeature split込みでインストール
- NPUバックエンドのまま日本語生成まで成功
↓
Google Tensor G5
↓
TPU / NPU
↓
Google Tensor Dispatch Runtime
↓
LiteRT-LM
↓
Gemma 4 E2B
↓
自作Androidアプリで日本語生成
前回は「TPUを使おう」で終わった。そして今回は本当に動いた
前回の記事を書いた時点では、Google AI Edge GalleryでGemma 4を動かし、Pixel 10 Pro FoldにはTensor G5とTPUがあるのだから、それを自作アプリでも使ってみたいというところまでだった。
その時点では、実際に自作アプリからTensor G5向けモデルを動かせるかどうかは分かっていなかった。
今回、Kotlin版のテストアプリを作り、モデルの保存、Dynamic Feature、Tensor用Dispatch Runtime、AndroidManifest.xml、LiteRT-LM、NPU Backendという部品を一つずつ組み合わせることで、ようやく全体の形が分かった。
そして最後に、Tensor G5のTPU / NPU上でGemma 4 E2Bを動かし、日本語文章を返すところまで確認できた。
次はテスト画面から本来の読書ブログアプリへ
ここまでは、あくまでLiteRT-LMとTensor G5を動かすためのテスト画面だった。
しかし、一番大きな技術的確認だった「自作Androidアプリから端末内AIを呼び出し、日本語文章を生成する」という部分は動いた。
しかも今回は、Android側に管理されたGemini Nanoを利用する方式ではなく、自分で選んだGemma 4モデルをLiteRT-LMから直接読み込み、Pixel 10 Pro FoldのTensor G5を使って動かしている。
次はいよいよ、本来作りたかった読書ブログ専用アプリへこのAI機能を組み込んでいく。
↓
書名と感想メモを入力
↓
「AIで記事を作成」
↓
Tensor G5上のGemma 4が文章生成
↓
Amazonアソシエイトリンクを追加
↓
Bloggerへ投稿
最初に考えていた「ChatGPTアプリへ共有して、生成された文章をコピーして戻る」という構成から見ると、かなり違うものになった。
ただし利用する側から見れば、最終的な操作はむしろ単純になる。
本の写真と感想を入れてボタンを押せば、端末内AIが記事を作る。
それが最初から作りたかったアプリの形である。
この記事の構成、文章、会話部分の生成にはOpenAIのChatGPTを使用しています。
Androidアプリの設計やLiteRT-LM、Tensor G5の動作確認についてもChatGPTと相談しながら進めています。
記事内のパッケージ名、Windowsのプロジェクトパス、Android端末ID、クラス名の一部などは、実際の開発環境固有の値ではなく、説明用のサンプル値へ置き換えています。
一方、今回開発しているAndroidアプリのAI生成機能そのものについては、クラウド上のChatGPT APIを利用しているわけではありません。
Gemma 4をAndroid端末内で実行し、Google Pixel 10 Pro FoldのTensor G5に搭載されたTPU / NPUを利用してローカルで文章を生成しています。
なお、今回紹介したGoogle AI Edge Gallery由来のnative runtime構成は、Tensor G5での動作確認を目的とした検証構成です。一般配布するアプリとして利用する場合は、公式に配布・再配布可能なruntime構成へ整理する必要があります。

0 件のコメント:
コメントを投稿