サマリー
- エッジAIは2026年最大の成長市場:世界のエッジAIチップ出荷量は50億個超と予測、モデル最適化が実用化のボトルネック
- 量子化3段階:FP16→INT8→INT4、INT8量子化の精度劣化は1%未満、INT4はGPTQ/AWQアルゴリズムとの組み合わせが必要
- 構造化プルーニング2つのアプローチ:マグニチュードプルーニングはシンプルかつ高効率、蒸留プルーニングは精度面で優位、組み合わせでパラメータ70%以上を圧縮可能
- デプロイ3大フレームワーク:ONNX Runtimeは汎用性最強、TensorRTはパフォーマンス最適、TFLiteはモバイル向け推奨
- 本記事ではモデル最適化からエッジデプロイまでのフルパイプライン実践コードを提供
目次
エッジAI:AI実用化のラストマイル
エッジAI市場の現状
| デバイスタイプ |
2026年出荷量 |
典型的な演算力 |
典型的な用途 |
| スマートフォン |
15億 |
30-60 TOPS |
音声アシスタント、撮影強化 |
| スマートカメラ |
5億 |
2-8 TOPS |
顔認識、行動検知 |
| 自動車(ADAS) |
1億 |
50-200 TOPS |
自動運転、DMS |
| IoTゲートウェイ |
3億 |
1-4 TOPS |
予知保全、異常検知 |
| 産業用コントローラ |
5000万 |
5-20 TOPS |
品質検査、ロボット制御 |
エッジAIの主要課題
| 課題 |
クラウド |
エッジ |
差 |
| 演算力 |
1000+ TOPS |
2-60 TOPS |
50-500× |
| メモリ |
80-640GB |
2-16GB |
5-80× |
| 消費電力 |
300W+ |
5-30W |
10-60× |
| レイテンシ |
50-200ms(ネットワーク) |
1-10ms(ローカル) |
エッジ優位 |
| プライバシー |
データをクラウドへ |
ローカル処理 |
エッジ優位 |
エッジAIチップの状況
| チップ |
メーカー |
演算力 |
消費電力 |
エコシステム |
| Jetson Orin NX |
NVIDIA |
100 TOPS |
25W |
CUDA+TensorRT |
| Hailo-8 |
Hailo |
26 TOPS |
2.5W |
専用コンパイラ |
| Google Edge TPU |
Google |
4 TOPS |
2W |
TFLite |
| Rockchip RK3588 |
Rockchip |
6 TOPS |
10W |
RKNN |
| Huawei昇騰310P |
Huawei |
22 TOPS |
8W |
MindSpore |
| Horizon Robotics J5 |
Horizon Robotics |
128 TOPS |
15W |
天工開物 |
モデル量子化3段階
量子化の原理と精度への影響
┌──────────────────────────────────────────────────────────────┐
│ 量子化3段階 │
│ │
│ FP32 → FP16 (半精度) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 精度劣化: <0.1% │ │
│ │ 容量削減: 50% │ │
│ │ 速度向上: 1.5-2× │ │
│ │ 手法: 直接変換、キャリブレーション不要 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ │
│ FP16 → INT8 (8bit整数量子化) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 精度劣化: 0.5-2% │ │
│ │ 容量削減: 75% (vs FP32) │ │
│ │ 速度向上: 2-4× │ │
│ │ 手法: PTQ(訓練後量子化) / QAT(量子化対応訓練) │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ │
│ INT8 → INT4 (4bit整数量子化) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 精度劣化: 2-5% (GPTQ/AWQによる補償が必要) │ │
│ │ 容量削減: 87.5% (vs FP32) │ │
│ │ 速度向上: 3-6× │ │
│ │ 手法: GPTQ / AWQ / AQLM / HQQ │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
INT8 PTQ量子化実践
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from neural_compressor import QuantizationAwareTraining, PostTrainingQuantConfig
def int8_ptq_quantize(model_path, output_path, calibration_data):
model = AutoModelForCausalLM.from_pretrained(
model_path,
torch_dtype=torch.float16,
device_map="auto",
)
tokenizer = AutoTokenizer.from_pretrained(model_path)
config = PostTrainingQuantConfig(
approach="static",
tuning_criterion={
"max_trials": 100,
"objective": "accuracy",
},
quant_format="QDQ",
calibration_sampling_size=512,
)
def calibration_dataloader():
for text in calibration_data:
inputs = tokenizer(text, return_tensors="pt", max_length=512)
yield {"input_ids": inputs["input_ids"]}
from neural_compressor import quantize
q_model = quantize(
model,
config,
calibration_dataloader=calibration_dataloader,
)
q_model.save(output_path)
return q_model
def int8_gptq_quantize(model_path, output_path, calibration_data):
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoGPTQForCausalLM.from_pretrained(
model_path,
torch_dtype=torch.float16,
device_map="auto",
)
quantize_config = BaseQuantizeConfig(
bits=8,
group_size=128,
desc_act=True,
damp_percent=0.01,
sym=True,
)
examples = []
for text in calibration_data:
tokenized = tokenizer(text, return_tensors="pt", max_length=2048)
examples.append(tokenized["input_ids"])
model.quantize(examples, quantize_config=quantize_config)
model.save_quantized(output_path)
return model
INT4量子化実践(GPTQ/AWQ)
def int4_gptq_quantize(model_path, output_path, calibration_data):
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoGPTQForCausalLM.from_pretrained(
model_path,
torch_dtype=torch.float16,
device_map="auto",
)
quantize_config = BaseQuantizeConfig(
bits=4,
group_size=128,
desc_act=True,
damp_percent=0.01,
sym=False,
)
examples = []
for text in calibration_data[:256]:
tokenized = tokenizer(text, return_tensors="pt", max_length=2048)
examples.append(tokenized["input_ids"])
model.quantize(examples, quantize_config=quantize_config)
model.save_quantized(output_path)
return model
def int4_awq_quantize(model_path, output_path, calibration_data):
from awq import AutoAWQForCausalLM
model = AutoAWQForCausalLM.from_pretrained(model_path)
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
quant_config = {
"zero_point": True,
"q_group_size": 128,
"w_bit": 4,
"version": "GEMM",
}
model.quantize(
tokenizer,
quant_config=quant_config,
calib_data=calibration_data[:256],
)
model.save_quantized(output_path)
return model
量子化効果の比較
| 量子化方式 |
7Bモデル容量 |
推論速度 |
精度劣化 |
VRAM要件 |
| FP32 |
28GB |
1× |
ベースライン |
32GB |
| FP16 |
14GB |
1.8× |
<0.1% |
16GB |
| INT8 PTQ |
7GB |
3.2× |
0.5-2% |
8GB |
| INT8 GPTQ |
7GB |
3.5× |
0.3-1% |
8GB |
| INT4 GPTQ |
3.5GB |
5.0× |
2-4% |
4GB |
| INT4 AWQ |
3.5GB |
5.5× |
1-3% |
4GB |
構造化プルーニング実践
プルーニング手法の比較
| 手法 |
粒度 |
精度劣化 |
ハードウェア適合性 |
複雑度 |
| 非構造化プルーニング |
個別重み |
小 |
低い(疎計算) |
低 |
| 構造化プルーニング |
チャネル/レイヤ |
中 |
高い(密計算) |
中 |
| 蒸留プルーニング |
チャネル+蒸留 |
小 |
高い |
高 |
| 自動プルーニング(NAS) |
探索空間 |
小 |
高い |
極めて高い |
マグニチュードプルーニング実践
import torch
import torch.nn as nn
from transformers import AutoModelForCausalLM
class MagnitudePruner:
def __init__(self, model, sparsity_ratio=0.5):
self.model = model
self.sparsity_ratio = sparsity_ratio
self.masks = {}
def compute_masks(self):
for name, module in self.model.named_modules():
if isinstance(module, nn.Linear):
weight = module.weight.data.abs()
threshold = torch.quantile(
weight.flatten(), self.sparsity_ratio
)
self.masks[name] = (weight > threshold).float()
def apply_masks(self):
for name, module in self.model.named_modules():
if name in self.masks and isinstance(module, nn.Linear):
module.weight.data *= self.masks[name]
def structured_prune(self):
for name, module in self.model.named_modules():
if isinstance(module, nn.Linear):
weight = module.weight.data
row_norms = weight.norm(dim=1)
threshold = torch.quantile(
row_norms, self.sparsity_ratio
)
keep_mask = (row_norms > threshold).float()
module.weight.data *= keep_mask.unsqueeze(1)
if module.bias is not None:
module.bias.data *= keep_mask
def fine_tune(self, dataloader, epochs=3, lr=1e-5):
optimizer = torch.optim.AdamW(self.model.parameters(), lr=lr)
for epoch in range(epochs):
for batch in dataloader:
outputs = self.model(**batch)
loss = outputs.loss
loss.backward()
optimizer.step()
optimizer.zero_grad()
self.apply_masks()
return self.model
def prune_model(model_path, output_path, sparsity=0.5):
model = AutoModelForCausalLM.from_pretrained(
model_path, torch_dtype=torch.float16, device_map="auto"
)
pruner = MagnitudePruner(model, sparsity_ratio=sparsity)
pruner.structured_prune()
model.save_pretrained(output_path)
return model
プルーニング+蒸留の組み合わせ
class PruneDistillPipeline:
def __init__(self, teacher_path, student_path, sparsity=0.5):
self.teacher = AutoModelForCausalLM.from_pretrained(
teacher_path, torch_dtype=torch.float16, device_map="auto"
)
self.student = AutoModelForCausalLM.from_pretrained(
student_path, torch_dtype=torch.float16, device_map="auto"
)
self.pruner = MagnitudePruner(self.student, sparsity)
def train(self, dataloader, epochs=5):
optimizer = torch.optim.AdamW(self.student.parameters(), lr=2e-5)
kl_loss = nn.KLDivLoss(reduction="batchmean")
for epoch in range(epochs):
for batch in dataloader:
with torch.no_grad():
teacher_out = self.teacher(**batch)
student_out = self.student(**batch)
hard_loss = student_out.loss
soft_loss = kl_loss(
F.log_softmax(student_out.logits / 2.0, dim=-1),
F.softmax(teacher_out.logits / 2.0, dim=-1),
) * 4.0
total_loss = 0.3 * hard_loss + 0.7 * soft_loss
total_loss.backward()
optimizer.step()
optimizer.zero_grad()
self.pruner.apply_masks()
return self.student
プルーニング効果の比較
| プルーニング方式 |
パラメータ保持率 |
精度劣化 |
推論高速化 |
容量削減 |
| マグニチュードプルーニング50% |
50% |
3-5% |
1.8× |
50% |
| マグニチュードプルーニング70% |
30% |
8-12% |
2.5× |
70% |
| 蒸留プルーニング50% |
50% |
1-3% |
1.8× |
50% |
| 蒸留プルーニング70% |
30% |
4-6% |
2.5× |
70% |
| プルーニング+量子化 |
30%+INT8 |
5-8% |
5× |
85% |
ONNX Runtimeデプロイ実践
PyTorch→ONNXエクスポート
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
def export_to_onnx(model_path, output_path, opset=17):
model = AutoModelForCausalLM.from_pretrained(
model_path, torch_dtype=torch.float16
)
tokenizer = AutoTokenizer.from_pretrained(model_path)
model.eval()
dummy_input = tokenizer("Hello", return_tensors="pt")
input_ids = dummy_input["input_ids"]
attention_mask = dummy_input["attention_mask"]
torch.onnx.export(
model,
(input_ids, attention_mask),
output_path,
opset_version=opset,
input_names=["input_ids", "attention_mask"],
output_names=["logits"],
dynamic_axes={
"input_ids": {0: "batch", 1: "seq_len"},
"attention_mask": {0: "batch", 1: "seq_len"},
"logits": {0: "batch", 1: "seq_len"},
},
do_constant_folding=True,
)
print(f"Exported to {output_path}")
ONNX Runtime推論
import onnxruntime as ort
import numpy as np
class ONNXInferenceEngine:
def __init__(self, model_path, provider="CUDAExecutionProvider"):
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess_options.intra_op_num_threads = 4
sess_options.inter_op_num_threads = 4
self.session = ort.InferenceSession(
model_path,
sess_options=sess_options,
providers=[provider],
)
self.io_binding = self.session.io_binding()
def infer(self, input_ids, attention_mask):
input_ids_np = np.array(input_ids, dtype=np.int64)
attention_mask_np = np.array(attention_mask, dtype=np.int64)
outputs = self.session.run(
["logits"],
{
"input_ids": input_ids_np,
"attention_mask": attention_mask_np,
},
)
return outputs[0]
def infer_with_io_binding(self, input_ids, attention_mask):
input_ids_tensor = ort.OrtValue.ortvalue_from_numpy(
np.array(input_ids, dtype=np.int64), "cuda", 0
)
attention_mask_tensor = ort.OrtValue.ortvalue_from_numpy(
np.array(attention_mask, dtype=np.int64), "cuda", 0
)
self.io_binding.bind_ortvalue_input("input_ids", input_ids_tensor)
self.io_binding.bind_ortvalue_input("attention_mask", attention_mask_tensor)
self.io_binding.bind_output("logits", "cuda", 0)
self.session.run_with_iobinding(self.io_binding)
return self.io_binding.get_outputs()[0].numpy()
ONNX Runtimeパフォーマンス比較
| 設定 |
レイテンシ(ms) |
スループット(req/s) |
VRAM |
| PyTorch FP16 |
45 |
22 |
8GB |
| ORT FP16 |
32 |
31 |
6GB |
| ORT INT8 |
18 |
55 |
4GB |
| ORT INT8+IO Binding |
14 |
71 |
4GB |
| ORT INT8+Batch=8 |
8/batch |
125 |
6GB |
TensorRT最適化実践
ONNX→TensorRT変換
import tensorrt as trt
def build_tensorrt_engine(
onnx_path,
engine_path,
fp16=True,
int8=False,
max_batch_size=8,
max_workspace=4 << 30,
):
logger = trt.Logger(trt.Logger.WARNING)
builder = trt.Builder(logger)
network = builder.create_network(
1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)
)
parser = trt.OnnxParser(network, logger)
with open(onnx_path, "rb") as f:
if not parser.parse(f.read()):
for i in range(parser.num_errors):
print(f"Parse error: {parser.get_error(i)}")
return None
config = builder.create_builder_config()
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, max_workspace)
if fp16:
config.set_flag(trt.BuilderFlag.FP16)
if int8:
config.set_flag(trt.BuilderFlag.INT8)
config.int8_calibrator = None
profile = builder.create_optimization_profile()
profile.set_shape(
"input_ids",
min=(1, 1),
opt=(1, 128),
max=(max_batch_size, 2048),
)
profile.set_shape(
"attention_mask",
min=(1, 1),
opt=(1, 128),
max=(max_batch_size, 2048),
)
config.add_optimization_profile(profile)
engine = builder.build_serialized_network(network, config)
with open(engine_path, "wb") as f:
f.write(engine)
print(f"TensorRT engine saved to {engine_path}")
return engine
class TensorRTInference:
def __init__(self, engine_path):
self.logger = trt.Logger(trt.Logger.WARNING)
self.runtime = trt.Runtime(self.logger)
with open(engine_path, "rb") as f:
self.engine = self.runtime.deserialize_cuda_engine(f.read())
self.context = self.engine.create_execution_context()
def infer(self, input_ids, attention_mask):
import pycuda.driver as cuda
import pycuda.autoinit
input_ids_np = np.array(input_ids, dtype=np.int32)
attention_mask_np = np.array(attention_mask, dtype=np.int32)
d_input_ids = cuda.mem_alloc(input_ids_np.nbytes)
d_attention_mask = cuda.mem_alloc(attention_mask_np.nbytes)
cuda.memcpy_htod(d_input_ids, input_ids_np)
cuda.memcpy_htod(d_attention_mask, attention_mask_np)
output_shape = self.engine.get_binding_shape(2)
d_output = cuda.mem_alloc(
trt.volume(output_shape) * np.dtype(np.float32).itemsize
)
bindings = [int(d_input_ids), int(d_attention_mask), int(d_output)]
self.context.execute_v2(bindings)
output = np.empty(output_shape, dtype=np.float32)
cuda.memcpy_dtoh(output, d_output)
return output
TensorRTパフォーマンス比較
| 設定 |
レイテンシ(ms) |
スループット(req/s) |
高速化比 |
| PyTorch FP16 |
45 |
22 |
1× |
| ORT INT8 |
18 |
55 |
2.5× |
| TRT FP16 |
15 |
66 |
3× |
| TRT INT8 |
8 |
125 |
5.6× |
| TRT INT8+Batch=8 |
3/batch |
333 |
15× |
エッジデバイス推論戦略
階層型推論アーキテクチャ
┌──────────────────────────────────────────────────────────────┐
│ エッジ・クラウド協調推論アーキテクチャ │
│ │
│ クラウド(大規模モデル) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 70Bモデル → 複雑な推論、長文生成、マルチターン対話 │ │
│ │ レイテンシ: 200-500ms | コスト: 高 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↕ ネットワーク │
│ エッジゲートウェイ(中規模モデル) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 7B量子化モデル → 日常的な推論、簡単な対話、意図認識 │ │
│ │ レイテンシ: 20-50ms | コスト: 中 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↕ ローカルバス │
│ エンドデバイス(小規模モデル) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ プルーニング+量子化小規模モデル → キーワード検出、ウェイクワード、簡単な分類 │ │
│ │ レイテンシ: 1-5ms | コスト: なし │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
エッジデプロイコード(Jetson)
import jetson.inference
import jetson.utils
class EdgeInferencePipeline:
def __init__(self, model_type="classification", threshold=0.7):
self.model = jetson.inference.initialize(model_type)
self.threshold = threshold
self.fallback_enabled = True
def infer_local(self, image):
try:
results = self.model.Detect(image)
confident = [r for r in results if r.Confidence >= self.threshold]
if not confident and self.fallback_enabled:
return self.infer_cloud(image)
return confident
except Exception as e:
if self.fallback_enabled:
return self.infer_cloud(image)
raise
def infer_cloud(self, image):
import requests
_, compressed = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, 85])
response = requests.post(
"https://api.example.com/infer",
files={"image": compressed.tobytes()},
timeout=5.0,
)
return response.json()
def benchmark(self, image, iterations=100):
import time
latencies = []
for _ in range(iterations):
start = time.perf_counter()
self.infer_local(image)
latencies.append((time.perf_counter() - start) * 1000)
return {
"mean_ms": sum(latencies) / len(latencies),
"p50_ms": sorted(latencies)[len(latencies) // 2],
"p99_ms": sorted(latencies)[int(len(latencies) * 0.99)],
}
エッジデバイスパフォーマンス実測
| デバイス |
モデル |
量子化 |
レイテンシ(ms) |
精度 |
| Jetson Orin NX |
BERT-base |
FP16 |
12 |
99.2% |
| Jetson Orin NX |
BERT-base |
INT8 |
6 |
98.8% |
| Jetson Orin NX |
ResNet50 |
FP16 |
8 |
99.5% |
| Jetson Orin NX |
ResNet50 |
INT8 |
4 |
99.1% |
| RK3588 |
MobileBERT |
INT8 |
25 |
97.5% |
| RK3588 |
MobileNetV3 |
INT8 |
8 |
98.2% |
| Raspberry Pi 5 |
TinyBERT |
INT8 |
120 |
95.8% |
| Raspberry Pi 5 |
MobileNetV2 |
INT8 |
45 |
97.0% |
まとめ
重要ポイントの振り返り
- 量子化:INT8 PTQがコストパフォーマンス最適、INT4はGPTQ/AWQによる精度補償が必要
- プルーニング:構造化プルーニング+蒸留の組み合わせでパラメータ70%以上を圧縮、精度劣化は制御可能
- デプロイ:ONNX Runtimeは汎用的、TensorRTは極限のパフォーマンス、TFLiteはモバイル向け
- エッジ戦略:階層型推論+クラウドフォールバックでレイテンシと精度を両立
エッジAI最適化ロードマップ
| フェーズ |
最適化手法 |
期待効果 |
| ステップ1 |
FP16エクスポート+ONNX Runtime |
2×高速化 |
| ステップ2 |
INT8量子化 |
4×高速化 |
| ステップ3 |
構造化プルーニング |
容量-50% |
| ステップ4 |
TensorRT最適化 |
6×高速化 |
| ステップ5 |
エッジ・クラウド協調 |
柔軟なデプロイ |
AI生成画像を圧縮する必要がありますか?画像圧縮ツールと動画圧縮をお試しください。エッジデプロイの素材を素早く最適化できます。
関連記事