Appleの新しいAIフレームワーク、CoreAIが公開されました。
好きなAI/LLMモデルをCoreAI形式に変換して、iOSやMacOSで使用することができます。
Apple公式のCoreAI-ModelsはGemma3やQwen3などの変換方法をサポートしています。
僕はCoreAIの兄貴分であるCoreMLフレームワークの頃から、
CoreML-ModelsというAIモデル集(iOS/MacOS対応)をOSSで作っていて、
CoreML-LLMというLLMをCoreMLで使用するリポジトリも作っていたので、
今回のCoreAIのアナウンスを聞いて
「ようし、ここはひとつ」と腕まくりをして、
まだ公式がサポートしていないモデル(Gemma4やQwen3など)をCoreAI形式に変換した
を公開しました。
変換済みモデル、変換コード、CoreAI特有の知見ドキュメントがまとまっています。
GitHub に置いてあるので、ご自由にご活用ください。
iPhoneですぐにビルドして使えるサンプルアプリもついています。
性能
単位:tok/s
| macOS GPU (M4 Max) | iOS GPU (iPhone 17 Pro) | iOS ANE | |
|---|---|---|---|
| Gemma 4 E2B | 57.0 | 22 | 6 |
| Qwen3.5-0.8B | 58.5 | 28 | 15 |
出力はオリジナルと同等。
Core AI の基本的な仕組み
Core ML との実務上の大きな違いは2つ。**変換の入口が torch.export**であること(coremltools のようなトレーサ独自実装ではなく、PyTorch 標準のエクスポート機構)と、カスタム Metal カーネルが正式機能であることです。
全体像
PyTorch モデル
│ torch.export(トレース)
▼
ExportedProgram
│ coreai_torch.TorchConverter
▼
.aimodel(グラフ + 重み、デバイス非依存)
│ 初回ロード時に specialization(デバイス向けコンパイル)
▼
OS の Core AI ランタイムが実行 — GPU(MPSGraph 経由)/ Neural Engine / CPU
specialization(初回コンパイル)と AOT
.aimodel はデバイス非依存です。初回ロード時に OS がそのデバイス向けに specialize(重いコンパイル)し、結果をコンテンツハッシュでキャッシュします。初回だけ待たされ、2回目以降は速い。LLM サイズだと初回は数秒〜数十秒かかります。
これを先回りするのが AOT コンパイルです:
xcrun coreai-build compile model.aimodel --platform iOS --preferred-compute neural-engine
# → アーキテクチャ別の .aimodelc(コンパイル済みアーティファクト)
実測では gemma 4 の 1.9 GB モノリスで初回ロード 19.2 秒 → 4.9 秒(~4倍)。ただし tok/s は変わりません。なお Mac は元々 ~1 秒で specialize するので AOT 不要、実益はデバイス側です。アーキテクチャ指定には罠があり、iPhone 17 Pro は h17p ではなく h18p(マーケティング名でなくデバイス識別子 iPhone18,1 由来)です。
簡単な使い方
① まず動かす(変換済みモデル)
一番簡単な経路はリポジトリのチャットアプリです:
git clone https://github.com/john-rocky/coreai-model-zoo-
apps/CoreAIChat/を Xcode 27 beta でビルドして iPhone(iOS 27 beta)へ - アプリ内ダウンロードで Hugging Face からモデルを取得 → チャット
CLI 派は swift/CoreAIRunner(Swift パッケージ)で .aimodel を直接使えます。
モデルごとの構成と実行手順は各 HF モデルカードと zoo のカードに書いてあります。
② 自分のモデルを変換する(Python)
検証済みの最小フローです(罠コメント付き):
import torch, shutil
from pathlib import Path
from coreai_torch import TorchConverter, get_decomp_table
import coreai.runtime as rt
ep = (torch.export.export(model.eval(), args=(), kwargs=inputs)
.run_decompositions(get_decomp_table()))
prog = (TorchConverter()
.add_exported_program(exported_program=ep,
input_names=[...], output_names=[...])
.to_coreai())
prog.optimize()
shutil.rmtree(out_dir, ignore_errors=True) # save_asset は上書きしない
prog.save_asset(Path(out_dir), rt.AIModelAssetMetadata())
# 動作確認(数値検証は cpu_only で — GPU/ANE の fp16 計算はノイズが乗る)
model = await rt.AIModel.load(Path(out_dir), rt.SpecializationOptions.cpu_only())
fn = model.load_function("main") # これは同期
res = await fn({"x": rt.NDArray(x)}) # 呼び出しは非同期
ハマりどころ:AIModel.load は async、load_function は sync、関数呼び出しはまた async。非対応の ATen オペは実行時ではなく add_exported_program の検証時に出ます。
そして検証は「cosine ≈ 1.0 + top-1 argmax 一致」で判定してください — 隠れ状態の maxdiff が大きく見えるのはたいてい fp16 計算ノイズで、argmax が合っていれば実用上一致です。
詳細は knowledge/conversion-guide.md。
③ Swift で動かす(最小コード)
低レベル API の骨格はこれだけです:
import CoreAI
let prepared = try await PreparedModel.prepare(at: url) // 初回はここで specialize が走る
let fn = try prepared.model.loadFunction(named: "main")!
var states = InferenceFunction.MutableViews()
states.insert(&keyCache, for: "keyCache") // state は in-place 更新で持続
var outputs = InferenceFunction.MutableViews()
outputs.insert(&logits, for: "logits")
_ = try await fn.run(inputs: ["input_ids": inputIds, "position_ids": positionIds],
states: consume states, outputViews: consume outputs)
state バッファは呼び出し間で同じものを使い回す(in-place 更新で KV/SSM 状態が持続する)のがポイントです。
Apple の高レベルパイプライン(CoreAILM)は「input_ids → logits + KV キャッシュ1組」の標準構成しか扱えないので、Qwen3.5(状態4本)や Gemma 4(デュアル KV + per-layer embedding)のような構成はこの低レベル API の上に薄いランナーを書きます — その汎用版が swift/CoreAIRunner です。
詳細は knowledge/swift-runtime.md(実機へのモデル転送の罠 — 途中までコピーされたファイルをロードすると specialization キャッシュが汚染される — もここに)。
コンピュートユニットの規則
-
GPU:
グラフは MPSGraph にロワーされます。
TorchMetalKernelで書いた MSL は本物のグラフオペとして.aimodelに埋め込まれ、モデルと一緒に配布されて OS ランタイム内で動きます(AOT も生き延びます)。
カスタムカーネルは GPU 専用です。 -
ANE:
固定のハードウェアオペのみが動く fp16 専用ユニットです。
fp32 精度が必要な計算は、内部 fp32 で累積するハードウェアオペ(LayerNorm、Conv2d)を選ぶことでしか得られません。これが上の [x,−x] トリックと 1×1 Conv2d の理由です。 -
動的形状は遅い:
形状が変わるたびに再 specialize が走ります。
LLM デコードは固定形状バケット(パディングして同じ形状を使い回す)が基本です。
知見
・HaggingFace の model コードはそのままでは torch.export を通らないので、トレース可能・固定形状の decode-step 関数に書き直し、HF の重みを移して、cosine + top-1 で層ごとに突き合わせます。
・Qwen3.5(Mamba 系 gated-delta + 周期フルアテンション、ハイブリッド SSM)の while_loop スキャンは実機の Swift ランタイムで lower 不能。decode ではスキャン長が常に1なので、ループフリーな単ステップ更新(bit 一致)に差し替えると GPU/ANE 両方で通ります。おまけ:ハイブリッドはコンテキストを 8倍(256→2048)にしても −9% しか遅くなりません。
・ベータの KV 書き込みクラッシュは回避できる。 グラフ内 KV キャッシュ書き込み(インデックス形)は再現性 100% でクラッシュします(報告済み: FB23024751 / apple/coreai-models#5)。回避は host 管理 KV(Gemma 4 を 13→27 tok/s に運んだ構成)。さらに one-hot マスクを入力として渡す「エスケープ」で stateful なグラフ内 KV も復活します。
・カスタム Metal カーネルで 13→57 tok/s。fused int8 dequant-LUT matvec(FFN)+ 262,144 語彙ヘッドの in-kernel argmax。iPhone では int4 k-means 版が 1.43× を勝ち取り 22 tok/s の出荷構成に — 一方 Mac の int4 は速度・精度ともミス。**どの壁(帯域 / ALU / ディスパッチ)に当たっているかが最適化の正解を決めます。 なお同じ M4 Max で MLX は ~160 tok/s — このギャップはカーネル磨きでは埋まらない構造的なものです。
・ANE は fp16 のまま完全一致にできる。 ANE で出力がゴミになる根本原因は2つ:RMSNorm の mean(x²) の fp16 オーバーフロー(→ LayerNorm(concat([x, −x])) の前半分が RMSNorm という恒等式で、fp32 累積のハードウェア LayerNorm に計算を移す)と、fp16 matmul 累積の複利(→ 全 nn.Linear を 1×1 Conv2d に。ANE の conv エンジンは fp32 で MAC する)。両方で GPU と ~2e-8 一致。
状態(state)と KV キャッシュ
グラフは state テンソル(呼び出し間で持続し、in-place 更新される)を持てます。
LLM の KV キャッシュにぴったりの機構ですが、このベータではインデックス形のグラフ内書き込みがクラッシュするため(上記 FB23024751)、現実解は host-cache(K/V をふつうの入出力として回し、ホスト側でバッファに書く)です。
詳細と回避策は knowledge/stateful-kv-cache.md と kvwrite-bug ページにまとめてあります。
🐣
フリーランスエンジニアです。
AIについて色々記事を書いていますのでよかったらプロフィールを見てみてください。
もし以下のようなご要望をお持ちでしたらお気軽にご相談ください。
AIサービスを開発したい、ビジネスにAIを組み込んで効率化したい、AIを使ったスマホアプリを開発したい、
ARを使ったアプリケーションを作りたい、スマホアプリを作りたいけどどこに相談したらいいかわからない…
いずれも中間コストを省いたリーズナブルな価格でお請けできます。
お仕事のご相談はこちらまで
rockyshikoku@gmail.com
機械学習やAR技術を使ったアプリケーションを作っています。
機械学習/AR関連の情報を発信しています。