엔진을 바꿀 때마다 코드를 다시 짜지 않으려면
같은 모델을 ONNX Runtime으로도 돌려보고 TensorRT로도 돌려보게 되는 일이 반복된다. 매번 새 실행 파일을 만들면 두 결과를 비교할 수 없다. 전처리가 미묘하게 다르고, 반복 횟수가 다르고, 워밍업을 뺀 쪽과 안 뺀 쪽이 섞인다.
그래서 엔진을 런타임 인자로 받고 나머지는 전부 고정하는 실행 파일 하나로 정리했다. 구조는 단순하다.
1
2
3
4
main.cpp
├ config.ini 파싱 배치·해상도·반복·가속기·최적화 수준·모델 경로·엔진
├ #ifdef ONNX → onnx_frvf::frvf_onnx
└ #ifdef TENSORRT → trt_frvf::frvf_trt
두 래퍼가 같은 시그니처를 갖는다.
1
2
// 생성자: (모델경로, 가속기, 최적화수준, B, C, W, H)
// do_inference(cv::Mat) -> float (밀리초)
인터페이스를 이렇게 맞추면 main은 엔진을 모른다. 새 엔진을 붙일 때 do_inference 하나만 구현하면 되고, 기존 결과와 곧바로 비교 가능한 숫자가 나온다.
설정을 인자가 아니라 파일로 받는 이유
처음에는 전부 커맨드라인 인자였다.
1
./build/main 1 3 112 112 1000 1 0 model.onnx onnx img.png
숫자 아홉 개가 순서대로 놓여 있으면 며칠만 지나도 여섯 번째가 무엇인지 알 수 없다. 그리고 실험 결과와 함께 남길 수도 없다. 설정 파일로 옮기고 나서 실행할 때 쓴 config.ini를 결과 옆에 그대로 복사해 두는 것으로 바꿨다.
1
2
3
4
5
6
7
8
9
B=1
W=112
H=112
C=3
ITERATION=1000
ACCELERATOR=1
OPTIMIZER=0
MODEL=model.onnx
ENGINE=onnx
이게 실제로 가장 값어치가 컸다. 한 달 뒤 “이 수치가 fp16이었나 fp32였나”를 묻는 일이 없어진다.
반복 횟수와 워밍업
ITERATION=1000으로 두고 앞쪽 결과를 버린다. 버리는 개수는 엔진마다 다르다.
- ONNX Runtime CPU: 5~10회면 충분하다
- ONNX Runtime CUDA: cuDNN이 알고리즘을 고르는 데 20~30회
- TensorRT: 엔진이 이미 빌드 시점에 튜닝돼 있어 5회 정도
워밍업을 안 빼면 평균이 부풀 뿐 아니라 분산이 커져 두 엔진의 차이가 노이즈에 묻힌다. 그래서 평균만 찍지 말고 p50/p95를 같이 남긴다. p95가 평균의 두 배를 넘으면 그 측정은 뭔가 다른 것에 방해받은 것이다 (대개 다른 프로세스가 GPU를 같이 쓰고 있다).
1
2
3
4
5
6
7
8
9
std::vector<float> lat;
lat.reserve(iter);
for (int i = 0; i < iter; ++i) {
float ms = engine->do_inference(frame);
if (i >= warmup) lat.push_back(ms);
}
std::sort(lat.begin(), lat.end());
float p50 = lat[lat.size() * 50 / 100];
float p95 = lat[lat.size() * 95 / 100];
배치 크기는 처리량과 지연을 갈라놓는다
B를 바꿔가며 재면 두 지표가 반대로 움직인다.
| 배치 | 프레임당 지연 | 초당 처리량 |
|---|---|---|
| 1 | 가장 낮음 | 낮음 |
| 8 | 중간 | 높음 |
| 16+ | 높음 | 포화 후 정체 |
실시간 카메라 스트림처럼 프레임이 도착하는 대로 처리해야 하는 구성에서는 배치를 키워도 실제로는 못 채운다. 도착을 기다리는 시간이 그대로 지연이 되기 때문이다. 반대로 저장된 영상을 일괄 처리하는 구성에서는 배치가 클수록 이득이다.
어느 쪽인지 정하지 않고 벤치마크를 하면 어떤 숫자를 봐야 하는지도 정해지지 않는다. 이 하네스에서 배치를 설정 파일 최상단에 둔 이유다.
도커로 환경을 고정한다
CUDA·cuDNN·TensorRT·ONNX Runtime의 버전 조합이 조금만 달라도 수치가 바뀐다. 호스트에 직접 깔고 비교하면 몇 주 뒤 재현이 안 된다.
1
docker build -f docker/Dockerfile --no-cache --tag=onnxruntime-cuda:1.8.2 .
1
2
3
docker run -it --gpus device=0 -v $(pwd):/mnt --net host --privileged \
-e DISPLAY=unix$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix \
onnxruntime-cuda:1.8.2
--gpus device=0으로 장치를 명시하는 것이 중요하다. --gpus all로 두면 컨테이너가 호스트의 모든 GPU를 보고, 프레임워크가 기본 장치를 임의로 고른다. 같은 서버를 여러 사람이 쓰면 남의 작업이 올라간 GPU에서 측정되는 일이 생긴다. 그런 측정치는 조용히 20~30% 나쁘게 나오고, 그 원인은 결과만 봐서는 안 보인다.
ARM 보드용 이미지를 x86 호스트에서 빌드해야 하면 플랫폼을 지정한다.
1
docker build --platform linux/amd64 -f docker/Dockerfile --no-cache --tag=onnxruntime-cuda:1.8.2 .
빌드와 실행을 한 스크립트로
측정 절차가 여러 명령으로 쪼개져 있으면 누군가 중간 단계를 건너뛴다. 소스가 바뀐 채로 옛 바이너리를 돌리는 사고가 실제로 몇 번 있었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/sh
set -e
MODE=$1 # build | run
main() {
./build/main
}
if [ "$MODE" = "build" ]; then
cd build && cmake .. && make all && cd ..
sleep 1
main
elif [ "$MODE" = "run" ]; then
main
fi
set -e가 여기서는 안전장치다. cmake가 실패했는데 스크립트가 계속 진행하면 이전 빌드 산출물로 실행돼 버린다. 그 결과는 “고쳤는데 아무 변화가 없다”로 나타나서, 코드를 계속 의심하게 만든다.
결론적으로 남긴 규칙
- 엔진은 바꾸되 전처리·반복·워밍업은 절대 같이 바꾸지 않는다
- 설정은 파일로 받고, 결과와 함께 보관한다
- 평균이 아니라 p50/p95를 남긴다
- 측정 환경(컨테이너 태그, GPU 인덱스, 클럭 상태)을 결과에 적는다
이 네 줄을 지키기 전과 후로, 벤치마크 결과를 두고 벌어지는 논쟁의 성격이 완전히 달라졌다. 전에는 “그 수치가 맞느냐”를 다퉜고, 지금은 “그래서 무엇을 고칠 것이냐”를 다툰다.