Article

Edge AIデバイスへのモデルデプロイ最適化

高田晃太郎
Edge AIデバイスへのモデルデプロイ最適化

統計によると、Gartnerは2025年までに企業データの約75%がエッジで生成・処理されると予測している。¹クラウドの学習基盤が成熟する一方、現場のカメラ、センサー、組み込みGPUやNPU(Neural Processing Unit)上での推論は、電力10W未満と100ms以下のレイテンシSLO(サービス目標)といった制約が同時に求められることが多い。公開ベンチマークや実機検証の報告を横断すると、同一モデルでも最適化前後でスループットが2〜4倍、モデルサイズが約75%削減(INT8化)といった差が繰り返し観測される。²³つまり、モデルの精度だけで勝負する時代は終わり、デプロイ最適化そのものがプロダクト価値とTCO(総保有コスト)を左右する設計要素になっている。

エッジの現実は、学習コードをそのまま持ち込むだけでは動かないということだ。演算ユニットの違い、量子化誤差、メモリ帯域、スケジューラ、電源の瞬断、無線の変動まで含めた複合最適化が必要になる。そこで本稿では、モデル圧縮と表現変換、推論ランタイムの選定とチューニング、そしてMLOpsとしての継続運用を、できるだけ平易に用語を補いつつ、コードと数値で具体化していく。

エッジ制約を前提にしたアーキテクチャ思考

最初に押さえたいのは、エッジにおける性能指標とボトルネックの見立てだ。レイテンシの中央値(p50)を縮めても、長い裾のp95やp99(95/99パーセンタイル)が現場の体験を壊すことは珍しくない。スループットを引き上げても、メモリ確保のジッターや温度スロットリングで実効性能が崩れることがある。したがって、レイテンシp95と消費電力当たりのスループット(inf/sec/W)を主要KPIに据え、追加でメモリピーク、コールドスタート、モデルサイズ、起動回数当たりの失敗率をモニタリングする設計が有効だ。

クラウドで学習したPyTorchモデルは、そのままではエッジのアクセラレータを活かせないことが多い。表現としてはONNX(Open Neural Network Exchange)に正規化し、そこからTensorRT、TFLite、Core ML、OpenVINOなどに落とし込むと、ベンダーの最適化とカーネルを享受できる。重い前処理はモデルに吸収し、入出力は固定解像度・固定レイアウトに寄せると、余計なコピーや変換を避けられる。さらに、INT8(8bit整数)量子化を前提に蒸留やQAT(Quantization Aware Training)で精度劣化を抑える戦略が、モバイルNPUやJetsonクラスのGPUではコスト対効果が高い。⁶²

ベースラインの確立と評価プロトコル

評価の作法は単純だが強力だ。まずFP32の純粋なベースラインを取り、続いてFP16、INT8の候補を作り、同一入力・同一バッチ・同一電力上限でp50/p95レイテンシ、スループット、消費電力、精度差分(mAPやF1など)を測る。ここで重要なのは、計測コードを本番と同じI/O経路に置き、ウォームアップを十分に取り、サーマルが落ち着いた状態と熱飽和時の両方を観測することだ。下に示す簡易ハーネスは、こうした実務的な計測を素早く回すための最小セットである。

# benchmark_harness.py
import time, statistics, numpy as np

class Bench:
    def __init__(self, runner, warmup=20, iters=200):
        self.runner = runner
        self.warmup = warmup
        self.iters = iters

    def run(self, sample):
        for _ in range(self.warmup):
            self.runner(sample)
        ts = []
        for _ in range(self.iters):
            t0 = time.perf_counter()
            self.runner(sample)
            ts.append((time.perf_counter() - t0) * 1000)
        p50 = np.percentile(ts, 50)
        p95 = np.percentile(ts, 95)
        return {
            "latency_ms_p50": round(float(p50), 3),
            "latency_ms_p95": round(float(p95), 3),
            "throughput_inf_per_s": round(1000.0 / p50, 2)
        }

# 使い方: runnerは1サンプル推論を行う関数を渡す

モデル圧縮と表現変換:精度を落とさず軽くする

量子化はエッジ最適化の主役だ。ベンダーの公開データや実地の計測では、INT8により2〜4倍のスループット向上とモデルサイズの約75%削減が一般的に観測される。²一方で闇雲にPTQ(Post-Training Quantization: 事後量子化)をかけると精度が落ちることがある。蒸留とQAT(学習時に量子化誤差を模擬)を併用し、代表分布に近いデータでキャリブレーションするのが王道である。PyTorchでのQATの最小例を以下に示す。

# pytorch_qat_minimal.py
import torch
import torch.nn as nn
from torch.ao.quantization import get_default_qat_qconfig
from torch.ao.quantization.quantize_fx import prepare_qat_fx, convert_fx

class SmallCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 16, 3, stride=2, padding=1), nn.ReLU(),
            nn.Conv2d(16, 32, 3, stride=2, padding=1), nn.ReLU()
        )
        self.classifier = nn.Linear(32 * 56 * 56, 10)

    def forward(self, x):
        x = self.features(x)
        x = torch.flatten(x, 1)
        return self.classifier(x)

model = SmallCNN().train()
model.qconfig = get_default_qat_qconfig("qnnpack")
prepared = prepare_qat_fx(model)

opt = torch.optim.Adam(prepared.parameters(), lr=1e-3)
for step in range(200):  # デモ用に短縮
    x = torch.randn(8, 3, 224, 224)
    y = torch.randint(0, 10, (8,))
    loss = nn.CrossEntropyLoss()(prepared(x), y)
    opt.zero_grad(); loss.backward(); opt.step()

prepared.eval()
quantized_model = convert_fx(prepared)

# セーブ
try:
    scripted = torch.jit.script(quantized_model)
    scripted.save("model_qat_int8.pt")
except Exception as e:
    print("Export failed:", e)

表現変換の軸足としてONNXを使うのは、複数のランタイムに橋渡しできるからだ。動的軸を正しく指定し、ランタイムが苦手な演算はあらかじめ置き換えておくと、後段の最適化が安定する。⁶次のスニペットはPyTorchからONNXへのエクスポート例である。

# export_to_onnx.py
import torch
from torchvision.models import resnet18

model = resnet18(weights=None).eval()
dummy = torch.randn(1, 3, 224, 224)

try:
    torch.onnx.export(
        model, dummy, "resnet18.onnx",
        input_names=["input"], output_names=["logits"],
        opset_version=17,
        dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}
    )
    print("Exported: resnet18.onnx")
except Exception as e:
    raise RuntimeError(f"ONNX export failed: {e}")

キャリブレーションの質とデータの現実

INT8の成否はキャリブレーションの代表性に強く依存する。昼夜や温湿度、カメラ露出などの現実の揺らぎを含んだデータを数百〜数千サンプル用意し、統計の安定化を図ると、精度低下はしばしば1pt未満に収まる。モデル内部の活性化レンジが広い層にはクリップや正規化を入れ、学習時から量子化を意識した分布に整えると、後段のTensorRTやTFLiteの最適化も通りやすい。²

ランタイム最適化:TensorRT、TFLite、Core ML、OpenVINO

Jetsonやデータセンター寄りのGPUを使う場合、TensorRTはコンパイル時最適化とカーネル選択で高い性能を引き出す。ONNXからエンジンを生成し、FP16やINT8を有効にし、ワークスペースとバッチを丁寧に合わせるだけで、FP32比で2〜4倍程度のスループット向上が得られるといったケースが報告されている。³⁴以下はPythonからのビルドと実行の最小例である。

# tensorrt_build_and_infer.py
import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit  # noqa
import numpy as np

TRT_LOGGER = trt.Logger(trt.Logger.INFO)

def build_engine(onnx_path: str, int8: bool = False, fp16: bool = True):
    builder = trt.Builder(TRT_LOGGER)
    network_flags = 1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
    network = builder.create_network(flags=network_flags)
    parser = trt.OnnxParser(network, TRT_LOGGER)
    config = builder.create_builder_config()
    config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30)
    if fp16 and builder.platform_has_fast_fp16:
        config.set_flag(trt.BuilderFlag.FP16)
    if int8 and builder.platform_has_fast_int8:
        config.set_flag(trt.BuilderFlag.INT8)
        # 実運用ではEntropyCalibratorを設定する
    with open(onnx_path, "rb") as f:
        if not parser.parse(f.read()):
            for i in range(parser.num_errors):
                print(parser.get_error(i))
            raise RuntimeError("ONNX parse failed")
    return builder.build_engine(network, config)

engine = build_engine("resnet18.onnx", int8=False, fp16=True)
context = engine.create_execution_context()

input_idx = 0
output_idx = 1
inp_shape = tuple(context.get_binding_shape(input_idx))
host_in = np.random.rand(*inp_shape).astype(np.float32)
host_out = np.empty(tuple(context.get_binding_shape(output_idx)), dtype=np.float32)
d_in = cuda.mem_alloc(host_in.nbytes)
d_out = cuda.mem_alloc(host_out.nbytes)

cuda.memcpy_htod(d_in, host_in)
context.execute_v2([int(d_in), int(d_out)])
cuda.memcpy_dtoh(host_out, d_out)
print(host_out.shape)

モバイルやRaspberry PiのようなARMデバイスではTFLiteが扱いやすい。代表データでのフルインテージャ量子化を有効にしつつ、失敗時は自動でFP16へフォールバックするように実装しておくと、現場でのデバイスばらつきに強くなる。²

# tflite_convert_int8.py
import tensorflow as tf
import numpy as np

# 代表データセット
def rep_ds():
    for _ in range(200):
        yield [np.random.rand(1, 224, 224, 3).astype(np.float32)]

try:
    converter = tf.lite.TFLiteConverter.from_saved_model("./saved_model")
    converter.optimizations = [tf.lite.Optimize.DEFAULT]
    converter.representative_dataset = rep_ds
    converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
    converter.inference_input_type = tf.int8
    converter.inference_output_type = tf.int8
    tflite_model = converter.convert()
    open("model_int8.tflite", "wb").write(tflite_model)
    print("INT8 conversion OK")
except Exception as e:
    print("INT8 failed, fallback to FP16:", e)
    converter = tf.lite.TFLiteConverter.from_saved_model("./saved_model")
    converter.optimizations = [tf.lite.Optimize.DEFAULT]
    converter.target_spec.supported_types = [tf.float16]
    tflite_model = converter.convert()
    open("model_fp16.tflite", "wb").write(tflite_model)

Appleデバイスに向けてはCore MLが第一候補だ。mlprogramバックエンドとANE(Apple Neural Engine)優先の設定を行うと、消費電力を抑えつつレイテンシが安定しやすい。ONNXからの変換例を示す。

# coreml_convert.py
import coremltools as ct

mlmodel = ct.converters.onnx.convert(
    model="resnet18.onnx",
    minimum_deployment_target=ct.target.iOS15,
    compute_units=ct.ComputeUnit.ALL  # ANE/GPU/CPU
)
mlmodel.save("ResNet18.mlmodel")

x86や多様なVPU(Vision Processing Unit)をターゲットにするならOpenVINOが選択肢に入る。モデル最適化により、畳み込みの再配置や定数折り畳みが適用される。⁵

# openvino_infer.py
from openvino.runtime import Core

core = Core()
compiled = core.compile_model("resnet18.onnx", device_name="CPU")
input_layer = compiled.input(0)
infer_req = compiled.create_infer_request()

import numpy as np
inp = np.random.rand(1, 3, 224, 224).astype(np.float32)
res = infer_req.infer({input_layer.any_name: inp})
print([a.shape for a in res.values()])

前処理・後処理の吸収とI/O最適化

実務では前処理のコストが支配的になることがある。リサイズ、標準化、色空間変換をモデルの最初の層に埋め込むことで、メモリコピー回数とCPU負荷を削減できる。ONNX Graph SurgeonやTorch FXで前処理ノードを統合し、入出力テンソルのレイアウトをNCHWかNHWCに固定すると、ランタイムごとの自動変換が減って安定する。後処理も同様で、NMS(Non-Maximum Suppression)やデコードは可能な範囲でグラフに含め、バウンディングボックスのスケーリングだけをアプリ側に残すと良い。⁵

Edge MLOps:継続運用とSLOを壊さない更新

デプロイ最適化は一度きりでは終わらない。カメラの設置環境が変わり、日照や反射でデータ分布は容易にズレる。モデル更新のたびに現場全台へ配り直すのではなく、デバイス属性やロケーションで段階的にロールアウトし、SLOを監視しながら切り戻せる運用が必要だ。MLflowやモデルレジストリでバージョン管理し、メタデータに対象デバイス、ランタイム、量子化方式、期待レイテンシや消費電力を記録しておくと、現場のトリアージが格段に楽になる。MLflowの最小ログ例を示す。

# mlflow_register.py
import mlflow

mlflow.set_tracking_uri("http://mlflow.local:5000")
mlflow.set_experiment("edge-infer")
with mlflow.start_run() as run:
    mlflow.log_param("runtime", "tensorrt")
    mlflow.log_param("precision", "fp16")
    mlflow.log_metric("p95_ms", 18.7)
    mlflow.log_metric("throughput", 50.2)
    mlflow.log_artifact("engine.plan")
    mlflow.register_model(
        model_uri=f"runs:/{run.info.run_id}/engine.plan",
        name="resnet18-edge"
    )

現場の監視はアプリ側のログだけでなく、ドライバや温度のテレメトリも重要だ。周期的にp50/p95の移動平均、エラー率、リトライ回数、温度と周波数の関係を記録し、閾値を超えたら自動で以前のモデルに切り戻す。SLOの例としては、レイテンシp95 < 100ms、失敗率 < 0.1%、稼働温度 < 75°Cなどの分かりやすい指標をデバイス群で可視化し、ダッシュボードで台数加重平均を確認すると、異常の顕在化を早期に捉えられる。

ROIとビジネスインパクトの言語化

最適化の効果を経営言語に落とすことが、継続投資の鍵になる。例えば、INT8化とTensorRT導入でスループットが約3倍になれば、同じ台数で処理できるカメラ本数が増え、サーバーやバッテリー容量を追加せずにCPI(Cost per Inference)を30〜60%削減できるケースがある。電力当たりの処理量が増えると、バッテリー駆動の無人店舗やドローンでは運用時間が延び、売上損失のリスクが下がる。導入期間の目安としては、既存モデルがある前提で、ONNX正規化と二つのランタイム検証までが1〜2週間、量子化の精度追い込みとA/B配信でさらに1〜2週間が実務的だ。現場稼働後は、データドリフト監視と再学習の自動化を整えるまでを四半期スパンで計画すると、開発・運用のリズムが安定する。

リスク低減のためのロールバック戦略

本番では常に退路を用意する。モデルとランタイムを組にしたバンドルをバージョン固定で配り、ヘルスチェックに失敗した場合は前バージョンへ自動復帰させる。入力のスキーマ変更やカメラファームウェア更新が潜むこともあるため、推論前後のスキーマ検証を軽量に入れておくと、安全に実験の速度を上げられる。デバイス側のストレージに直近二つのエンジンを残すだけでも、現場の復旧時間は短くなる。

具体例で見る一連のパイプライン

最後に、実装の流れをコンパクトにまとめる。学習済みモデルをONNXに変換し、TensorRTとTFLiteの二系統をビルドしてA/Bで比較し、最終的にSLOと消費電力を満たすものを段階的に展開する。以下は、同一入力で二系統を計測し、結果を比較するための小さなスクリプトである。

# compare_runtimes.py
import numpy as np, time

# ダミーのランナー: 実務では前出のTensorRT/TFLite実装を呼び出す
class Runner:
    def __init__(self, name, f):
        self.name = name; self.f = f
    def __call__(self, x):
        return self.f(x)

def fake_trt(x):
    time.sleep(0.010)  # 10ms想定
    return x.mean(axis=(1,2,3))

def fake_tflite(x):
    time.sleep(0.018)  # 18ms想定
    return x.mean(axis=(1,2,3))

from benchmark_harness import Bench
sample = np.random.rand(1, 3, 224, 224).astype(np.float32)

for r in [Runner("trt_fp16", fake_trt), Runner("tflite_int8", fake_tflite)]:
    m = Bench(r).run(sample)
    print(r.name, m)

CTO視点では、技術選定だけでなく、組織のワークフローと品質保証も同時に設計することが重要だ。スプリント内に最適化・計測・運用テストのスロットを組み込み、合格基準をSLOとROIで定義しておくと、議論が感覚ではなく数字で回る。

まとめ:速く、安く、壊さないための指針

エッジAIの価値は、モデルの精度だけでなく、実機での一貫したレイテンシと電力効率に現れる。量子化と表現変換で土台を整え、ターゲットごとの最適化ランタイムを選び、SLOとROIで意思決定する運用を回せば、同じハードでも体感は別物になるはずだ。次の一歩として、まずは現行モデルのFP32ベースラインを測り、ONNX正規化と二つのランタイムでの試作を始めてみてほしい。p95がどこまで縮むか、消費電力当たりのスループットがどれだけ伸びるかを数字で確かめれば、投資すべき箇所は自ずと見えてくる。あなたの現場では、どの制約がいちばん強いだろうか。レイテンシ、電力、それともメモリか。問いを具体化し、今週中に最初の計測結果をダッシュボードに載せることから始めてみよう。

参考文献

  1. 75% of data to be processed at the edge by 2025: Michael Dell. Economic Times (2021)
  2. Electronics (MDPI) article on low-bit quantization and performance impacts
  3. NVIDIA Developer Blog: Speed Up Inference with TensorRT
  4. NVIDIA Developer Blog: JetPack doubles Jetson inference performance
  5. OpenVINO Docs: Additional Optimization Use Cases
  6. ONNX Runtime Blog: NimbleEdge x ONNX Runtime