Posts 추론 엔진 비교를 위한 C++ 벤치마크 하네스 설계
Post
Cancel

추론 엔진 비교를 위한 C++ 벤치마크 하네스 설계

엔진을 바꿀 때마다 코드를 다시 짜지 않으려면

같은 모델을 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 인덱스, 클럭 상태)을 결과에 적는다

이 네 줄을 지키기 전과 후로, 벤치마크 결과를 두고 벌어지는 논쟁의 성격이 완전히 달라졌다. 전에는 “그 수치가 맞느냐”를 다퉜고, 지금은 “그래서 무엇을 고칠 것이냐”를 다툰다.

This post is licensed under CC BY 4.0 by the author.