실시간 비전 — 엣지 배포
엣지 추론(Edge inference)은 정확도 90짜리 모델을 RAM 2GB짜리 기기에서 30 fps로 돌리는 기술입니다. 정확도 1%p는 지연 시간(latency) 몇 ms와 계속 맞교환됩니다.
유형: Learn + Build
언어: Python
선수 학습: Phase 4 Lesson 04 (Image Classification), Phase 10 Lesson 11 (Quantization)
소요 시간: 약 75분
학습 목표
- 임의의 PyTorch 모델에 대해 추론 지연 시간(inference latency), 최대 메모리(peak memory), 처리량(throughput)을 측정하고 FLOPs / 파라미터 / 지연 시간의 절충 관계를 읽어냅니다.
- PyTorch의 학습 후 양자화(post-training quantization)로 비전 모델을 INT8로 양자화하고 정확도 손실이 1% 미만인지 확인합니다.
- ONNX로 내보내고(export) ONNX Runtime 또는 TensorRT로 컴파일합니다. 가장 흔한 내보내기 실패 세 가지와 해결책을 말합니다.
- 엣지 환경의 제약 조건에 맞춰 MobileNetV3, EfficientNet-Lite, ConvNeXt-Tiny, MobileViT 중 무엇을 선택할지 설명합니다.
문제
학습 시점의 비전 모델은 부동소수점(floating-point) 괴물입니다. 1억 개의 파라미터, 순전파(forward pass) 한 번당 10 GFLOPs, VRAM 2GB 같은 숫자가 나옵니다. 이 상태로는 휴대폰, 자동차 인포테인먼트 장치, 산업용 카메라, 드론에 들어가지 않습니다. 비전 시스템을 출시(ship)한다는 것은 같은 예측을 100배 작은 예산(budget)에 맞춘다는 뜻입니다.
대부분의 일은 세 가지 조절 손잡이(knob)가 합니다. 모델 선택, 즉 같은 설계 방식(recipe)을 쓰는 더 작은 아키텍처를 고르는 것, 양자화(quantisation), 즉 FP32 대신 INT8을 쓰는 것, 그리고 추론 런타임(inference runtime), 즉 ONNX Runtime, TensorRT, Core ML, TFLite 중에서 고르는 것입니다. 이 셋을 잘 맞추면 워크스테이션(workstation) 데모에 그치지 않고 30달러짜리 카메라 모듈 위에서 도는 제품이 됩니다.
이 레슨은 먼저 측정 원칙(measurement discipline)을 세웁니다. 측정할 수 없는 것은 최적화할 수 없습니다. 그다음 세 가지 손잡이를 하나씩 다룹니다. 모든 엣지 런타임을 외우는 것이 목표가 아니라, 어떤 지렛대(lever)가 있고 각각이 생각대로 동작했는지 어떻게 검증하는지를 아는 것이 목표입니다.
개념
세 가지 예산(budget)
flowchart LR
M["Model"] --> LAT["Latency<br/>ms per image"]
M --> MEM["Memory<br/>peak MB"]
M --> PWR["Power<br/>mJ per inference"]
LAT --> SHIP["Ship / no-ship<br/>decision"]
MEM --> SHIP
PWR --> SHIP
style LAT fill:#fecaca,stroke:#dc2626
style MEM fill:#fef3c7,stroke:#d97706
style PWR fill:#dbeafe,stroke:#2563eb
- 지연 시간(Latency): p50, p95, p99를 봅니다. p50 평균만 보면 실시간 시스템에서 중요한 꼬리 분포(tail behaviour)가 가려집니다.
- 최대 메모리(Peak memory): 정상 상태(steady-state)의 평균이 아니라 기기가 한 번이라도 마주하는 최대 메모리입니다. 임베디드(embedded) 대상에서 메모리 부족(OOM)은 치명적입니다.
- 전력/에너지(Power / energy): 배터리로 동작하는 기기에서는 추론 한 번당 밀리줄(millijoule)이 중요합니다. 보통 CPU/GPU 사용률 × 시간으로 근사합니다.
엣지 환경에서의 결정은 (model, latency, memory, accuracy) 표로 내립니다. 모든 칸은 워크스테이션이 아니라 목표 기기(target device)에서 측정해야 합니다.
측정 원칙(Measurement discipline)
엣지 프로파일링은 항상 세 가지 규칙을 따라야 합니다.
- 측정 전 모델을 5~10번의 더미 순전파(dummy forward)로 예열(warm up) 합니다. 차가운 캐시(cold cache)와 JIT 컴파일은 대표성 없는 첫 숫자를 만듭니다.
- GPU 작업은 시간 측정 구간 전후에
torch.cuda.synchronize()로 동기화(synchronise) 합니다. 그렇지 않으면 커널 실행 시간이 아니라 커널 디스패치(kernel dispatch) 시간을 재게 됩니다.
- 입력 크기를 운영 환경의 해상도로 고정(fix) 합니다. 224x224에서의 지연 시간은 512x512에서의 지연 시간이 아닙니다.
지연 시간 근사값으로서의 FLOPs
FLOPs(추론당 부동소수점 연산 수, floating-point operations per inference)는 지연 시간을 가늠하는 값싸고 기기 독립적인 근사값(proxy)입니다. 아키텍처 비교에는 유용하지만 절대적인 실제 측정 시간(wall-clock time)으로는 오해를 부를 수 있습니다. FLOPs가 10% 더 많은 모델이 실제로는 2배 빠를 수도 있습니다. 하드웨어 친화적인 연산(op)을 쓰느냐가 결정적이기 때문입니다. 예를 들어 깊이별 합성곱(depthwise conv)은 잘 컴파일될 수도 있지만 메모리 대역폭에 묶일 수도 있고, 7x7처럼 큰 합성곱(conv)은 불리할 수 있습니다.
규칙은 이렇습니다. 아키텍처 탐색에는 FLOPs를 쓰고, 배포 결정에는 실제 기기에서의 지연 시간을 씁니다.
양자화(Quantisation) 한 문단 설명
FP32 가중치(weight)와 활성값(activation)을 INT8로 바꿉니다. 모델 크기는 4배 줄고, 메모리 대역폭(memory bandwidth)도 4배 줄며, INT8 커널이 있는 하드웨어에서는 연산량(compute)이 24배 줄어듭니다. 최신 모바일 SoC와 NVIDIA Tensor Core GPU 대부분이 여기에 해당합니다. 비전 작업에서 학습 후 정적 양자화(post-training static quantisation)의 정확도 손실은 보통 0.11 퍼센트 포인트입니다.
유형은 다음과 같습니다.
- 동적 양자화(Dynamic) — 가중치를 INT8로 양자화하고 활성값은 부동소수점(FP)으로 계산합니다. 쉽지만 속도 향상(speedup)이 작습니다.
- 정적(학습 후) 양자화(Static, post-training) — 가중치를 양자화하고 작은 보정 데이터셋(calibration set)으로 활성값의 범위를 보정(calibrate)합니다. 동적 양자화보다 훨씬 빠릅니다.
- 양자화 인식 학습(Quantisation-aware training; QAT) — 학습 중에 양자화를 시뮬레이션해 모델이 적응하도록 합니다. 정확도는 가장 좋지만 라벨링된 데이터가 필요합니다.
비전에서는 학습 후 정적 양자화가 노력(effort) 5%로 효과(benefit) 95%를 줍니다. PTQ의 정확도 손실을 받아들일 수 없을 때만 QAT를 씁니다.
가지치기(Pruning)와 지식 증류(Distillation)
- 가지치기(Pruning) — 중요하지 않은 가중치(크기 기반, magnitude-based)나 채널(구조적, structured)을 제거합니다. 과대 매개변수(overparameterised) 모델에는 잘 작동하지만 이미 작은(compact) 아키텍처에는 덜 유용합니다.
- 지식 증류(Distillation) — 큰 교사(teacher) 모델의 로짓(logits)을 작은 학생(student) 모델이 흉내 내도록 학습합니다. 모델을 줄이면서 잃은 정확도 대부분을 회복할 수 있어 운영(production) 엣지 모델의 표준 방식입니다.
추론 런타임(Inference runtimes)
- PyTorch eager — 느립니다. 배포용이 아니라 개발용입니다.
- TorchScript — 구식(legacy)입니다.
torch.compile과 ONNX 내보내기가 대부분 대체했습니다.
- ONNX Runtime — 중립적인(neutral) 런타임입니다. CPU, CUDA, CoreML, TensorRT, OpenVINO 공급자(provider)를 지원합니다. 여기서 시작합니다.
- TensorRT — NVIDIA의 컴파일러입니다. NVIDIA GPU와 Jetson에서 지연 시간이 가장 좋습니다. ONNX Runtime과 통합하거나 단독(standalone)으로 씁니다.
- Core ML — iOS/macOS용 Apple 런타임입니다.
.mlmodel 또는 .mlpackage가 필요합니다.
- TFLite — Android/ARM용 Google 런타임입니다.
.tflite가 필요합니다.
- OpenVINO — Intel의 CPU/VPU 런타임입니다.
.xml + .bin이 필요합니다.
실무 흐름은 PyTorch → ONNX → 목표 런타임입니다. ONNX는 공용어(lingua franca) 역할을 합니다.
엣지 아키텍처 선택표
| 예산 | 모델 | 이유 |
|---|
| 파라미터 300만 개 미만 | MobileNetV3-Small | 어디서나 컴파일되고 좋은 기준선(baseline)이 됩니다. |
| 300만~1,000만 개 | EfficientNet-Lite-B0 | TFLite에서 파라미터당 정확도가 좋습니다. |
| 1,000만~2,000만 개 | ConvNeXt-Tiny | 파라미터당 정확도가 좋고 CPU 친화적입니다. |
| 2,000만~3,000만 개 | MobileViT-S 또는 EfficientViT | ImageNet 수준의 정확도를 가진 트랜스포머(transformer) 계열입니다. |
| 3,000만~8,000만 개 | Swin-V2-Tiny | 스택이 윈도 어텐션(window attention)을 지원할 때 사용합니다. |
특별한 이유가 없다면 위 모델들은 모두 INT8로 양자화합니다.
만들어보기
Step 1: 지연 시간(latency)을 올바르게 측정하기
import time
import torch
def measure_latency(model, input_shape, device="cpu", warmup=10, iters=50):
model = model.to(device).eval()
x = torch.randn(input_shape, device=device)
with torch.no_grad():
for _ in range(warmup):
model(x)
if device == "cuda":
torch.cuda.synchronize()
times = []
for _ in range(iters):
if device == "cuda":
torch.cuda.synchronize()
t0 = time.perf_counter()
model(x)
if device == "cuda":
torch.cuda.synchronize()
times.append((time.perf_counter() - t0) * 1000)
times.sort()
return {
"p50_ms": times[len(times) // 2],
"p95_ms": times[int(len(times) * 0.95)],
"p99_ms": times[int(len(times) * 0.99)],
"mean_ms": sum(times) / len(times),
}
예열하고, 동기화하고, time.perf_counter()를 사용합니다. 평균(mean)만이 아니라 백분위수(percentile)를 보고합니다.
Step 2: 파라미터와 FLOP 집계
def parameter_count(model):
return sum(p.numel() for p in model.parameters())
def flops_estimate(model, input_shape):
"""
합성곱/선형 계층만 다루는 모델을 위한 간이 FLOP 집계입니다. 실제 운영에서는 `fvcore`나 `ptflops`를 사용합니다.
"""
total = 0
def conv_hook(m, inp, out):
nonlocal total
c_out, c_in, kh, kw = m.weight.shape
h, w = out.shape[-2:]
total += 2 * c_in * c_out * kh * kw * h * w
def linear_hook(m, inp, out):
nonlocal total
total += 2 * m.in_features * m.out_features
hooks = []
for m in model.modules():
if isinstance(m, torch.nn.Conv2d):
hooks.append(m.register_forward_hook(conv_hook))
elif isinstance(m, torch.nn.Linear):
hooks.append(m.register_forward_hook(linear_hook))
model.eval()
with torch.no_grad():
model(torch.randn(input_shape))
for h in hooks:
h.remove()
return total
실제 프로젝트에서는 fvcore.nn.FlopCountAnalysis 또는 ptflops를 씁니다. 모든 모듈 유형을 더 정확하게 처리해 줍니다.
Step 3: 학습 후 정적 양자화(post-training static quantisation)
def quantise_ptq(model, calibration_loader, backend="x86"):
import torch.ao.quantization as tq
model = model.eval().cpu()
model.qconfig = tq.get_default_qconfig(backend)
tq.prepare(model, inplace=True)
with torch.no_grad():
for x, _ in calibration_loader:
model(x)
tq.convert(model, inplace=True)
return model
세 단계로 진행합니다. 설정(configure), 준비(prepare; 관측기(observer) 삽입), 실제 데이터로 보정(calibrate), 변환(convert; 결합 + 양자화)을 차례로 수행합니다. 모델은 Conv -> BN -> ReLU를 ConvBnReLU로 결합(fuse)해야 하며, torch.ao.quantization.fuse_modules가 이를 처리합니다.
Step 4: ONNX로 내보내기(export)
def export_onnx(model, sample_input, path="model.onnx"):
model = model.eval()
torch.onnx.export(
model,
sample_input,
path,
input_names=["input"],
output_names=["output"],
dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}},
opset_version=17,
)
return path
2026년 기준 opset_version=17은 안전한 기본값입니다. dynamic_axes는 ONNX 모델이 임의의 배치 크기(batch size)로 실행되도록 해 줍니다.
Step 5: 벤치마크와 모드(regime) 비교
import torch.nn as nn
from torchvision.models import mobilenet_v3_small
def compare_regimes():
model = mobilenet_v3_small(weights=None, num_classes=10)
params = parameter_count(model)
flops = flops_estimate(model, (1, 3, 224, 224))
lat_fp32 = measure_latency(model, (1, 3, 224, 224), device="cpu")
print(f"FP32 MobileNetV3-Small: 파라미터 {params:,}개 {flops/1e9:.2f} GFLOPs "
f"p50={lat_fp32['p50_ms']:.2f}ms p95={lat_fp32['p95_ms']:.2f}ms")
같은 함수를 resnet50, efficientnet_v2_s, convnext_tiny에 적용하면 배포 결정에 필요한 비교 표를 얻을 수 있습니다.
활용하기
운영 환경의 스택은 보통 세 가지 경로 중 하나로 수렴합니다.
- 웹/서버리스(Web / serverless): PyTorch → ONNX → ONNX Runtime(CPU 또는 CUDA 공급자). 가장 쉽고 대부분의 경우에 충분합니다.
- NVIDIA 엣지(Jetson, GPU 서버): PyTorch → ONNX → TensorRT. 지연 시간은 가장 좋지만 엔지니어링 비용도 가장 큽니다.
- 모바일(Mobile): PyTorch → ONNX → Core ML(iOS) 또는 TFLite(Android). 내보내기 전에 양자화합니다.
측정에는 torch-tb-profiler, nvprof / nsys, macOS의 Instruments를 사용해 계층별(layer-by-layer) 세부 분석을 봅니다. OpenVINO의 benchmark_app과 TensorRT의 trtexec은 단독 CLI 형태의 숫자를 제공합니다.
산출물 만들기
이 레슨의 최종 산출물은 다음과 같습니다.
outputs/prompt-edge-deployment-planner.md — 목표 기기와 지연 시간 SLA를 바탕으로 백본(backbone), 양자화 전략, 런타임을 고르는 프롬프트입니다.
outputs/skill-latency-profiler.md — 예열, 동기화, 백분위수, 메모리 추적까지 포함하는 완전한 지연 시간 벤치마킹 스크립트를 작성하는 스킬입니다.
연습문제
- (쉬움) CPU에서 224x224 입력으로
resnet18, mobilenet_v3_small, efficientnet_v2_s, convnext_tiny의 p50 지연 시간을 측정합니다. 표를 만들고 ms당 정확도(accuracy-per-ms)가 가장 좋은 아키텍처를 찾아냅니다.
- (보통)
mobilenet_v3_small에 학습 후 정적 양자화를 적용합니다. CIFAR-10이나 비슷한 보류 데이터셋(held-out subset)에서 FP32와 INT8의 지연 시간과 정확도 손실을 비교해 보고합니다.
- (어려움)
convnext_tiny를 ONNX로 내보내고, onnxruntime의 CPUExecutionProvider로 실행해 PyTorch eager 기준선과 지연 시간을 비교합니다. ONNX Runtime이 더 빨라지는 첫 계층(layer)을 찾아 그 이유를 설명합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 지연 시간(Latency) | "얼마나 빠른가" | 입력에서 출력까지 걸리는 시간입니다. 평균(mean)이 아니라 p50/p95/p99 백분위수를 봅니다. |
| FLOPs | "모델 크기(Model size)" | 순전파 한 번당 부동소수점 연산 수입니다. 연산 비용의 대략적인 근사값입니다. |
| INT8 양자화(quantisation) | "8비트(8-bit)" | FP32 가중치/활성값을 8비트 정수로 바꿉니다. 크기는 약 4배 작아지고 2~4배 빠릅니다. |
| PTQ | "학습 후 양자화(Post-training quantisation)" | 재학습 없이 학습된 모델을 양자화합니다. 쉽고 보통은 충분합니다. |
| QAT | "양자화 인식 학습(Quantisation-aware training)" | 학습 중 양자화를 시뮬레이션합니다. 정확도가 가장 좋지만 라벨링된 데이터가 필요합니다. |
| ONNX | "중립적 포맷(Neutral format)" | 주류 추론 런타임이 모두 지원하는 모델 교환 포맷입니다. |
| TensorRT | "NVIDIA 컴파일러" | ONNX를 NVIDIA GPU용으로 최적화된 엔진으로 컴파일합니다. |
| 지식 증류(Distillation) | "교사 → 학생(Teacher → student)" | 작은 모델이 큰 모델의 로짓을 흉내 내도록 학습합니다. 줄어든 모델의 정확도를 회복합니다. |
더 읽을거리