onnxruntime performance issue
onnxruntime, onnx, cudaExecutionProvider
Mar 03, 2024
이번 글에서는 onnxruntime을 사용하는 과정에서 겪었던 에러를 시작으로 onnxruntime에 대해서 소개해보는 시간을 가져볼까합니다.
일단 onnxruntime에 대해서 간단하게 알아보자.
onnxruntime이란 간단히 표현하자면 cross-platform machine-learning model 가속기이다. hardware-specific 라이브러리를 추가할 수 있다는 점이 특징이다.
가령 Rock-NPU, TensorRT, Vitis 와 같이 hardware specific한 라이브러리를 개발하여 하드웨어를 가속기로 사용할 수 있다. 실제로 training, inference 에서 사용이 가능하고 onnx 모델을 활용하고자 할 때 쓴다고 이해하고 있다.
onnx graph가 pytorch, tensorflow와 같은 프레임워크와의 호환성을 챙긴 것과 같이, onnxruntime은 onnx 모델이 다양한 플랫폼과 하드웨어에서 가속화할 수 있도록 도와준다.
내가 이슈를 공유받았을 때 아래와 같은 상황이었다.
→ pytorch를 사용하는 것보다 10배 이상의 time latency가 있음을 확인했다.
처음에는 onnx랑 onnxruntime을 사용하는데 pytorch보다 느릴 수 있나?
cudaExecutionProvider만 사용해서 생기는 문제인가? 그럼 어떤 executionProvider를 추가해야 될까?
위와 같은 질문이 떠올랐다.
작년에 onnxruntime 관련 개발을 했을 때, 새로운 execution provider를 만든다는 것은 새로운 하드웨어의 특징을 살리기 위해서라고 생각했다.
하지만 현재 A100 서버를 사용하고 있기 때문에, cudaExecutionProvider말고 다른 옵션이 없다는 것을 깨달았다. TensorrtExecutionProvider를 사용한다면 사용하겠지만, 이미 cudaexecutionprovider로의 추론이 너무 느리기에 크게 차이가 없다고 생각했다.
이번 이슈는 아래 글에서 정리된 내용 중 5가지를 진행했다.
enable_profiling 으로 latency 측정
먼저, onnx graph에서 bottleneck을 찾고자 profiling을 진행했다. 해당 기능은 node들의 duration을 측정해준다. 공식 문서에서 가져온 예시로 설명해보겠다.
{"cat":"Node", "name":"Add_1234", "dur":17, ...} {"cat":"Kernel", "name":"ort_add_cuda_kernel", dur:33, ...}
Add 연산자 부분을 확인해보자. 이 연산자는 ort_add_cuda_kernel이라는 이름으로 선언이 되어 있었고, 33 microsecond(ms) 만큼 시간이 소요된다.
블로그에서 소개된 json 일부 정보로 살펴보겠다.
"cat": "Node", "pid": 1027615, "tid": 1027615, "dur": 313964, "ts": 4425685, "ph": "X", "name": "/w_1/Conv_kernel_time",
→ 위 정보 중 dur(time consumption) 값을 체크해야 한다.
어떤 node에서 시간이 제일 걸렸고, onnx graph단에서 node를 변경해야 하는지 체크해보고자 진행했다.
버전 체크
onnxruntime과 onnx 그리고 cuda버전의 compatibility를 체크해야 한다.
→ 가령 export할 수 있는 onnx 버전과 onnxruntime-gpu 버전이 다르면 Operator를 인식 못하는 경우도 많다. 그리고 특정 버전에서는 node끼리의 충돌이 있을 수 있기 때문에 이 과정을 진행했다.
A100에서 설치된 환경을 살펴보니 cuda 11.7을 쓰고 있었다. 그런데 onnxruntime과 onnx가 cuda 11.8버전에 맞는 환경으로 세팅되어 있어서 onnxruntime과 onnx버전을 각각 1.14.0, 1.13.0으로 downgrade로 변경해줬다.
IO binding
pytorch와 onnx의 성능 차이를 살펴볼 때, CPU GPU Data transfer가 눈에 띄는 bottleneck이다.
- Target device로 input이 복사가 안된다면, onnxruntime은
Run()
메소드를 실행하면서 CPU에서부터 copy 를 진행한다.
- 마찬가지로 output이 device에 할당이 안되어 있다면 onnxruntime은 device에서 cpu로 다시 복사를 진행한다.
이 과정들이 execution time에서 시간을 소모하기 때문에 성능이 느려지게 된다.
이 문제를 해결하기 위해
IO binding
을 진행한다.
Key Idea
: Run()
이 실행되기 전에 input이 device에 복사되고 output에 대한 메모리가 확보하여 execution time에서 시간을 줄일 수 있다.def run(text): print(text) # Tokenize text to phoneme token ids inputs = tokenizer(text) io_binding = model.io_binding() io_binding.bind_cpu_input("text", inputs) io_binding.bind_output("wav", "cuda") start = time.time() # Run model model.run_with_iobinding(io_binding) outputs = io_binding.copy_outputs_to_cpu() print("Time:", time.time() - start)
cudnn_backend setting
cudaexecutionprovider를 사용할 때, cudnn_conv_algo_search의 설정을 바꾸는 것이 성능에 영향을 주는 것을 블로그에서 소개되었다.
이 configuration은 cuDNN convolution algorithm을 설정한다. profiling json 로그를 살펴보았을 때, conv 연산이 제일 duration이 높았던 것을 확인하고 해당 세팅을 DEFAULT로 변경했다.
그렇다면 CUDNN_CONVOLUTION_FWD_ALGO_IMPLICIT_PRECOMP_GEMM 이 어떤 설정인지 확인해보자.
- Convolution 연산을 GEMM 연산으로 변환하여 실행하지만, 실제 input을 matrix로 변환하지는 않음
- Matrix indices precompute를 진행하기 위해 메모리 사용
- implicit matrix를 사용
실제로 cuDNN algorithm의 forward 방법은 8가지가 존재하는데, PRECOMP_GEMM이 제일 빠른 algorithm임을 확인했다.
해당 config를 추가한 결과, pytorch보다 4x 빨라짐을 확인할 수 있었다.
Parallel & number of threads
아래 표는 parallel 처리와 num_threads의 관계가 있을 것이라고 생각하고 진행해본 실험이다.
profiling json에서 싱글 thread로 모델이 실행되는 것을 확인했었고, thread 개수를 늘리면 성능에 어떤 영향을 주는지 궁금해서 진행했다. 결론적으로 현재 다루고 있는 onnx graph의 특성상 sequential과 intra_op_num_thread는 default로 두는 것이 제일 성능이 좋았다. 이 섹션에서는 parallel의 기능과 intra thread에 대해서 알아보겠다.
ㅤ | 세팅 | 실행 시간 |
1 | sess_options.intra_op_num_threads ≥2
sess_options.execution_mode ort.ExecutionMode.ORT_Parallel로 적용 | 8.4초 |
2 | sess_options의 intra_op_num_threads = default
sess_options.execution_mode ort.ExecutionMode.ORT_Parallel | 8.1초 |
3 | sess_options의 intra_op_num_threads = default
sess_options.execution_mode ort.ExecutionMode.ORT_Sequential | 6.7초 |
- Sequential과 parallel
- graph의 여러 개의 operator들은 sequential 혹은 parallel하게 동작시킬 수 있다. Default는 sequential이다.
- 보통 graph에 여러 분기들이 존재한다면 parallel option을 쓰면 더 성능이 좋아질 수 있다. 하지만, 충분한 분기가 없는 경우에는 성능을 저하시킬 수 있기에 experimental하게 찾아야 한다.
- 그리고 parallel을 사용한다면 inter_op_num_threads를 변경하여 parallel한 모델 추론에서 사용할 수 있는 thread의 개수를 조절할 수 있다.
- intra_op_num_thread
onnxruntime에서 session은 multi-thread를 사용하여 각 operator랑 연산을 병렬화할 수 있다.
default 값은 0이고, 사용하는 thread의 개수 만큼 physical core를 사용한다.
아래 코드 예시를 통해 살펴보자.
sess_opt = SessionOptions() sess_opt.intra_op_num_threads = 3 sess = ort.InferenceSession('model.onnx', sess_opt)
위처럼 총 3개의 thread로 설정한다면, main thread 1개와 2개의 extra intra thread가 사용될 것이다.
그런데 위와 같이 설정하면 affinity 설정이 없는 상태로 쓰인다.
- setting Affinity
- 여러 session을 병렬적으로 사용해야하는 상황에서 독립된 코어들로 thread pool을 사용하고 싶을 때
- numba node의 cache overhead를 없애기 위해 thread pool을 제한시키고자 할 때
process Affinity은 특정 케이스를 제외하고 custom 설정을 안해주고 os 에서 처리시키는 것을 권장한다.
특정 케이스로
→ 이외에도 thread들을 조절하는 방법들이 더 있는데, 공식 문서에서 자세히 다룬다.
마무리
onnxruntime를 알게 된지 1년 정도 되어가는데, performance tuning과 관련해서 작업하는 와중에 몰랐던 내용들을 더 알게 되어가는 중이다. 특히 TensorrtExecutionProvider와 CudaExecutionProvider들은 production에서도 자주 쓰 일 수 있는 옵션이기에 더 좋은 best practices들을 찾으면 추후에 한 번 더 정리할 예정이다.
참고 자료
- onnxruntime performance tuning
- GPU performance debugging
Share article