본문으로 건너뛰기

[onnxruntime] ONNX Runtime CUDA Graph: 진정한 비동기 추론을 위한 동기화 지점 제거

PR 링크: microsoft/onnxruntime#28686 상태: Merged | 변경: +574 / -26

들어가며

지연 시간(Latency)에 민감한 고성능 딥러닝 추론 파이프라인을 구축할 때, 엔지니어들은 보통 다음의 네 가지 기법을 조합하여 최적의 성능을 끌어냅니다.

  1. IO Binding: 호스트-디바이스 간 데이터 복사 비용을 줄이기 위해 미리 할당된 GPU 버퍼를 사용합니다.
  2. Custom Compute Stream: 별도의 CUDA 스트림을 할당하여 연산을 격리합니다.
  3. CUDA Graph: 커널 런칭 오버헤드를 줄이기 위해 연산 그래프를 캡처하여 재사용합니다.
  4. Fully Async Execution: Session::Run() 호출 시 호스트 측의 동기화(Synchronization)를 완전히 제거하여 CPU가 다른 작업을 병렬로 수행하게 합니다.

하지만 지금까지 ONNX Runtime(ORT)에서는 disable_synchronize_execution_providers=1 옵션을 설정하더라도, CUDA Graph를 사용할 경우 내부적으로 cudaStreamSynchronize가 강제로 호출되는 문제가 있었습니다. 이로 인해 진정한 의미의 비동기 실행이 불가능했습니다. 이번 글에서는 이 문제를 해결한 microsoft/onnxruntime의 최근 변경 사항을 분석해 보겠습니다.

코드 분석: 동기화 플래그의 전파

이번 PR의 핵심은 ReplayGraph 가상 함수에 sync 파라미터를 추가하고, 이를 실행 옵션(RunOptions)에 따라 제어할 수 있도록 전체 호출 체인을 수정한 것입니다.

1. IExecutionProvider 인터페이스 변경

가장 먼저 모든 실행 제공자(EP)의 기반이 되는 인터페이스가 변경되었습니다.

Before:

virtual common::Status ReplayGraph(int /*graph_annotation_id*/) {
  return Status::OK();
}

After:

/**
   Run the instantiated graph.
   @param sync If true, synchronize the device/stream after replay to ensure completion before returning.
               If false, the caller is responsible for synchronization.
 */
virtual common::Status ReplayGraph(int /*graph_annotation_id*/, bool /*sync*/ = true) {
  return Status::OK();
}

기존에는 Replay 시 동기화 여부를 선택할 수 없었으나, 이제 sync 플래그를 통해 호출자가 동기화 시점을 결정할 수 있게 되었습니다.

2. CUDAExecutionProvider의 구현

CUDA EP에서는 이 플래그를 실제 CUDA Graph Replay 로직까지 전달합니다.

Before:

Status CUDAExecutionProvider::ReplayGraph(int graph_annotation_id) {
  return GetPerThreadContext().ReplayGraph(graph_annotation_id);
}

After:

Status CUDAExecutionProvider::ReplayGraph(int graph_annotation_id, bool sync) {
  return GetPerThreadContext().ReplayGraph(graph_annotation_id, sync);
}

이 변경 사항은 PerThreadContext를 거쳐 최종적으로 CUDAGraphManager::Replay까지 전달됩니다. sync=false일 경우, 내부적으로 호출되던 cudaStreamSynchronize를 건너뛰게 됩니다.

3. 타 EP(TensorRT, DML 등)의 대응

재미있는 점은 CUDA 외의 다른 EP들의 처리 방식입니다. 리뷰어 hariharans29는 "동기화를 지원하지 않는 다른 EP들이 이 플래그를 무시하는 것이 위험하지 않느냐"는 질문을 던졌습니다.

이에 대해 메인테이너 tianleiwu는 다음과 같이 답변하며 코드를 보완했습니다.

  • TensorRT/DML/JS/WebGPU: 이 EP들은 구조적으로 항상 동기적으로 Replay를 수행합니다.
  • 따라서 sync=false 요청이 들어오더라도 동기적으로 동작하는 것은 보수적(Conservative)인 선택이며, 안전합니다. (반대로 동기화가 필요한데 건너뛰는 것이 위험한 상황입니다.)
// onnxruntime/core/providers/tensorrt/tensorrt_execution_provider.cc
Status TensorrtExecutionProvider::ReplayGraph(int, bool /*sync*/) {
  // The sync parameter is ignored: TRT EP always replays synchronously under a lock_guard in compute_func().
  ORT_ENFORCE(IsGraphCaptured(0));
  // ...
}

왜 이게 좋은 최적화인가?

1. 호스트-디바이스 병렬성 극대화

기존에는 Run() 함수가 반환되기 전 반드시 GPU 연산이 끝나기를 기다려야 했습니다. 이 최적화를 통해 CPU는 GPU가 커널을 실행하는 동안 즉시 제어권을 돌려받아 다음 배치를 준비하거나 다른 비즈니스 로직을 수행할 수 있습니다.

2. 성능 수치로 증명된 결과

PR 작성자가 H100 GPU에서 nsys 프로파일링을 통해 검증한 결과는 놀랍습니다.

Config Host-sync APIs inside Run() Result
sync=on (기존) 100회 (cudaStreamSynchronize) 동기화 발생
sync=off (개선) 0회 완전 비동기 달성

평균 2.3µs 정도 소요되던 동기화 오버헤드가 완전히 사라졌으며, 이는 초당 수천 번의 추론이 발생하는 고성능 서버에서 유의미한 처리량(Throughput) 향상으로 이어집니다.

일반적인 교훈: "Async by Default"의 어려움

프레임워크 설계에서 비동기 옵션을 제공하더라도, 내부 깊숙한 곳에 하드코딩된 동기화 지점이 하나라도 남아있다면 그 옵션은 무용지물이 됩니다. 이번 개선은 다음과 같은 교훈을 줍니다.

  1. 추상화 계층의 일관성: 인터페이스(IExecutionProvider) 수준에서부터 옵션이 고려되어야 최하단 구현체까지 올바르게 전달될 수 있습니다.
  2. 프로파일링의 중요성: 단순히 코드를 고치는 것에 그치지 않고, nsys와 같은 도구로 실제 cudaStreamSynchronize 호출 횟수가 0이 되었는지 확인하는 과정이 최적화의 완성도를 결정합니다.

마치며

이제 ONNX Runtime 사용자들은 CUDA Graph와 IO Binding을 조합할 때, RunOptions에 단 한 줄의 설정을 추가함으로써 진정한 비동기 파이프라인을 구축할 수 있게 되었습니다.

run_options = ort.RunOptions()
run_options.add_run_config_entry("disable_synchronize_execution_providers", "1")
session.run_with_iobinding(io_binding, run_options)
# 이제 여기서 CPU는 멈추지 않고 즉시 다음 작업을 수행합니다!

저지연 추론 시스템을 설계하고 있다면, 이번에 업데이트된 ORT의 비동기 기능을 적극적으로 활용해 보시기 바랍니다.

참고 자료

⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글