Raspberry Pi 5 上で UltraFace-slim 顔検出モデルの GPU (Vulkan V3D) と CPU (ARM NEON) の性能を比較し、なぜ CPU が 14 倍速いのかを調査した記録。
| 構成 | 推論時間 | 備考 |
|---|---|---|
| GPU default | 164.4 ms | V3D 7.1 Vulkan Compute |
| GPU + FP16 all | 159.6 ms | fp16 packed/storage/arithmetic 有効 |
| GPU + BF16 storage | 161.7 ms | bf16 storage 有効 |
| GPU + FP16 + pack + light | 160.5 ms | 全オプション有効 |
| CPU NEON (baseline) | 6.1 ms | ARM Cortex-A76 NEON |
| CPU + FP16 | 5.9 ms | NEON + fp16 |
GPU の全最適化オプションを有効にしても 160ms → 159ms(1% 未満の改善)。
オーバーヘッドではなく V3D の演算速度そのものがボトルネック。
| モード | GPU (Vulkan V3D) | CPU (ARM NEON) | 改善倍率 |
|---|---|---|---|
| fast (全体 1 パス) | 165 ms / 6 FPS | 12 ms / 82 FPS | 14× |
| count (全体 + 5 タイル) | 971 ms / 1 FPS | 49 ms / 20 FPS | 20× |
100 層のニューラルネットワークで毎層ごとに CPU↔GPU の submit-wait が走り、
その往復オーバーヘッドが支配的になっている。
NCNN の command.cpp を調査したところ、複数レイヤーの dispatch は 1 本の command buffer に記録されている。
実際の動作:
[record layer1][record layer2]...[record layer100] → [submit 1回] → [wait 1回]
初期仮説(誤り):
[record+submit+wait layer1] → [record+submit+wait layer2] → ...
NCNN は pending_dispatch_total が閾値を超えると command buffer を途中で submit する。
閾値はデバイスの r-score で決定される。
| デバイススコア | Threshold | 用途 |
|---|---|---|
| > 75 | 8M | 高性能 Discrete GPU |
| 50-75 | 4M | 中性能 GPU |
| 15-50 | 1M | 統合 GPU |
| 10-15 | 256K | 低性能デバイス |
| < 10 | 32K | 超低性能デバイス |
V3D の r-score は 7(最低カテゴリ)。閾値 32K は小さいが、UltraFace-slim の各層の dispatch サイズも小さいため、実際にはほぼ 1 回の submit でまとまっている可能性が高い。
転送量を半減する FP16 storage、メモリ再利用の lightmode、パッキング最適化をすべて有効にしても 1% 未満の改善。
これは往復オーバーヘッドが主因ではないことの証拠。
| 項目 | V3D (VideoCore VII) | Cortex-A76 (NEON) |
|---|---|---|
| コア数 | 12 QPU | 4 CPU コア |
| クロック | ~800 MHz | 2.4 GHz (3 倍) |
| SIMD 幅 | 16-way float | 128bit = 4×float ×4 コア |
| キャッシュ | なし(VRAM 経由) | L1 64KB + L2 512KB |
| 3×3 Conv 最適化 | 汎用 compute shader | Winograd 変換 + NEON 手書きアセンブリ |
| メモリ帯域 | 共有メモリ(バス競合) | CPU に直結 |
| 設計目的 | グラフィックス描画 | 汎用計算 + モバイル ML |
UltraFace-slim はモデルサイズ 1 MB。
Cortex-A76 の L2 キャッシュは 512 KB × 4 コア = 2 MB。
モデル全体がキャッシュに載り、メモリアクセスが極めて高速。
GPU にはこのレベルのキャッシュがなく、毎回 VRAM(共有メモリ)へアクセスする。
CPU: [コア] ← 1ns → [L1 64KB] ← 5ns → [L2 512KB] ← モデル全体がここに入る
GPU: [QPU] ← 50ns → [共有メモリ] ← 毎回ここまでアクセス
NCNN は ARM NEON 向けに手書きアセンブリレベルの最適化を持っている。
- 3×3 Convolution に Winograd 変換を適用(演算量 2.25 倍削減)
- NEON の
vfma(Fused Multiply-Add) 命令を直接使用 - メモリアクセスパターンを NEON のレジスタ幅に合わせて最適化
対して GPU パスは汎用の Vulkan Compute Shader で、V3D 固有の最適化はない。
UltraFace-slim の最初の Convolution 層:
入力: 320 × 240 × 3 = 230,400 要素
出力: 160 × 120 × 16 = 307,200 要素
V3D の 12 QPU で分割すると 1 QPU あたり約 25,600 要素。
QPU の起動・同期コストに対してデータが少なく、並列効率が低い。
比較: GPU.add で 100 万要素の場合は 1 QPU あたり約 83,000 要素で、十分な並列効率が出る。
| 処理 | dispatch 回数 | データ量/回 | GPU vs CPU |
|---|---|---|---|
| GPU.add (100 万要素) | 1 回 | 4 MB | GPU 5.8ms ≪ CPU ~20ms |
| YUYV→RGB 変換 (640×480) | 1 回 | 600 KB | GPU 向き |
| 行列積 (大きな行列) | 1 回 | 数 MB | GPU 向き |
| UltraFace 推論 (100 層) | 100 回 | 数 KB/回 | CPU 6ms ≪ GPU 160ms |
GPU が速い条件:
✅ 1 回の dispatch で大量データを並列処理する
✅ データがキャッシュに収まらないほど大きい
✅ 単純な演算の繰り返し(加算、行列積、画像変換)
GPU が遅い条件:
❌ 多段の依存処理(100 層のニューラルネットワーク)
❌ 各段のデータが小さい(数 KB〜数十 KB)
❌ CPU 側に NEON 等の特化最適化がある
「mruby から GPU も CPU も使い分けられる。それが mruby-gpu の強み」
- GPU.add で 100 万要素の加算 → GPU が圧勝(大量データ × 単純演算)
- 顔認識は CPU NEON が最速 → 適材適所(小モデル × L2 キャッシュ × NEON 最適化)
- YUYV→RGB 変換を GPU Compute Shader で → 画像処理は GPU 向き(1 dispatch × 大データ)
- mruby スクリプト側で
use_gpu: true/falseを切り替えるだけ → 1 行の変更で最適な実行パスを選択
# GPU が得意: 画像前処理 (1 dispatch × 大データ)
GPU.init("shader")
rgb = GPU.yuv_to_rgb(frame) # ← GPU Compute Shader
# CPU が得意: ML 推論 (100 層 × 小データ)
detector = FaceDetector.new("models/ultraface-slim", use_gpu: false) # ← CPU NEON
faces = detector.detect(frame, 640, 480)NCNN の ncnn::Option で設定可能な Vulkan 関連オプション。
V3D では効果が限定的だったが、他の GPU(Mali, Adreno, Discrete GPU)では有効な場合がある。
| オプション | デフォルト | 効果 |
|---|---|---|
use_vulkan_compute |
false | Vulkan Compute 推論を有効化 |
use_packing_layout |
true | SIMD-friendly な packed layout |
use_fp16_packed |
- | FP16 packed format |
use_fp16_storage |
- | FP16 storage buffer(転送量 50% 削減) |
use_fp16_arithmetic |
- | FP16 で演算実行 |
use_bf16_storage |
- | BF16 storage buffer |
use_int8_storage |
- | INT8 storage(メモリ 1/4) |
use_int8_arithmetic |
- | INT8 演算(量子化モデル向け) |
lightmode |
false | 中間ブロブの即時解放 |
use_shader_local_memory |
- | Shared memory 活用 |
use_cooperative_matrix |
- | Tensor Core 活用(NVIDIA/ARM) |
use_subgroup_ops |
- | Subgroup 最適化 |
NCNN は SPIR-V コードと specialization constant から hash を生成し、pipeline をキャッシュする。
初回実行時のみ Vulkan Shader コンパイルが発生(100-500ms)。2 回目以降はキャッシュヒットで < 1ms。
初回フレーム: 200ms(Vulkan shader compile + 推論)
2回目以降: 160ms(推論のみ)
- Board: Raspberry Pi 5 (4GB)
- GPU: VideoCore VII (V3D 7.1.10.2) — r-score 7
- CPU: ARM Cortex-A76 × 4 @ 2.4 GHz
- OS: Ubuntu 24.04 arm64
- NCNN: v1.0.20260331 (Vulkan enabled)
- Model: UltraFace-slim (slim_320) — 1 MB, 100 layers, 4420 anchors
- Camera: Logitech HD Pro Webcam C920 (640×480 YUYV @ 30fps)
| 機能 | ファイル | 行番号 |
|---|---|---|
| Command buffer 統合 | ncnn/src/command.cpp | 1555-1577 |
| Submit threshold 判定 | ncnn/src/net.cpp | 252-273 |
| Pipeline Cache | ncnn/src/pipelinecache.cpp | 130-160 |
| V3D デバイススコア | ncnn/src/gpu.cpp | スコア算出ロジック |
| NEON Conv 最適化 | ncnn/src/layer/arm/convolution_arm.cpp | Winograd 実装 |