Published on

ONNX Runtime IOBinding으로 추론 지연 2배 줄이기

Authors

서빙 환경에서 ONNX Runtime(ORT)로 GPU 추론을 돌리다 보면, 모델 자체 연산보다 입력·출력 텐서 이동과 동기화 비용이 병목이 되는 경우가 많습니다. 특히 Python에서 numpy 입력을 만들고 ORT로 넘긴 뒤, 다시 numpy로 결과를 받아 후처리하는 흐름은 눈에 보이지 않는 복사(copy)와 디바이스 간 전송(H2D, D2H)을 반복합니다.

이 글에서는 ORT의 IOBinding을 사용해 입력/출력을 원하는 디바이스 메모리에 직접 바인딩하고, 불필요한 복사 및 암묵적 동기화를 줄여 추론 지연(latency)을 2배 수준으로 개선하는 접근을 정리합니다. (정확한 수치는 모델/배치/하드웨어에 따라 달라지지만, “복사 제거”가 잘 먹히는 워크로드에서는 꽤 자주 체감됩니다.)

또한 IOBinding을 적용할 때 흔히 겪는 함정(동기화 타이밍, 출력 메모리 재사용, 스트림/프로바이더 설정 등)과, 실제 서빙 코드에 녹여내는 패턴을 코드로 보여드립니다.

관련해서 GPU 메모리 압박이나 OOM 이슈가 함께 있다면, 운영 관점에서 리눅스 OOMKilled 원인 추적 - cgroup·dmesg·ulimit도 같이 보면 원인 분리가 빨라집니다.

왜 IOBinding이 지연을 줄이나

일반적인 ORT Python 추론 코드는 대략 다음 흐름입니다.

  1. CPU 메모리에 numpy 입력 생성
  2. ORT가 내부적으로 입력을 디바이스로 복사(예: CUDA)
  3. GPU 커널 실행
  4. ORT가 출력을 CPU로 복사(또는 Python에서 numpy로 materialize)
  5. 후처리

문제는 2, 4 단계가 항상 필요한 것이 아닌데도 기본 API를 쓰면 쉽게 발생한다는 점입니다.

  • 출력이 다음 GPU 연산으로 바로 이어질 경우, D2H 복사는 낭비입니다.
  • 입력이 이미 GPU에 있다면, H2D 복사는 낭비입니다.
  • 프레임워크 간 연결(예: PyTorch torch.Tensor로 전처리 후 ORT 추론)에서 CPU로 되돌아오는 순간이 전체 파이프라인을 느리게 만듭니다.

IOBinding은 ORT에게 이렇게 말할 수 있게 해줍니다.

  • “입력은 이미 CUDA 메모리에 있으니 그대로 써라”
  • “출력은 CUDA 메모리에 이 버퍼에 써라(또는 CUDA에 두어라)”

즉, 메모리 위치를 명시적으로 제어해 불필요한 복사와 동기화를 줄입니다.

IOBinding이 특히 효과적인 시나리오

다음 조건일수록 효과가 큽니다.

  • 전처리/후처리가 GPU에서 이뤄짐(예: 토큰화 제외, 이미지 전처리, 후처리 NMS 등)
  • 작은 배치(batch size 1~8)로 짧은 추론을 매우 자주 호출함(복사/동기화 오버헤드 비중이 커짐)
  • 모델 연산량이 크지 않거나(경량 모델), TensorRT/FP16 등으로 연산이 매우 빨라졌는데도 end-to-end 지연이 줄지 않음
  • 멀티모델 파이프라인에서 중간 텐서를 CPU로 내렸다가 다시 올리는 구조

반대로, 배치가 크고 모델 연산이 압도적으로 크면 IOBinding으로 얻는 이득이 상대적으로 작을 수 있습니다.

준비: ORT GPU 세션 설정 체크리스트

IOBinding 자체보다 먼저, 세션 옵션과 프로바이더가 제대로 잡혀야 합니다.

  • CUDAExecutionProvider가 활성화되어야 함
  • intra_op_num_threads 등 CPU 스레드 옵션은 GPU 추론에선 큰 의미가 없을 수 있으나, 전/후처리가 CPU면 영향
  • graph_optimization_level은 보통 ORT_ENABLE_ALL 권장

아래는 기본적인 세션 생성 예시입니다.

import onnxruntime as ort

so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL

providers = [
    ("CUDAExecutionProvider", {
        "device_id": 0,
        "arena_extend_strategy": "kNextPowerOfTwo",
        "cudnn_conv_algo_search": "EXHAUSTIVE",
        "do_copy_in_default_stream": 1,
    }),
    "CPUExecutionProvider",
]

sess = ort.InferenceSession("model.onnx", sess_options=so, providers=providers)
print(sess.get_providers())

여기서 do_copy_in_default_stream 같은 값은 워크로드에 따라 영향을 줄 수 있습니다. 다만 이 글의 핵심은 “복사를 없애거나 줄이는 것”이므로, IOBinding 적용 후에 스트림 관련 옵션을 미세 튜닝하는 순서를 권장합니다.

기본 API vs IOBinding: 차이를 코드로 보기

기본 추론(복사 발생 가능)

import numpy as np

input_name = sess.get_inputs()[0].name
x = np.random.randn(1, 3, 224, 224).astype(np.float32)

# 일반 run: 출력이 numpy로 돌아오며, 내부적으로 H2D/D2H가 일어날 수 있음
y = sess.run(None, {input_name: x})

이 코드는 간단하지만, 파이프라인에서 GPU 텐서를 유지하고 싶을 때는 불리합니다.

IOBinding 기반 추론(출력 GPU 유지)

IOBinding은 크게 2가지 방식으로 씁니다.

  • “입력은 일반 feed로 주되, 출력만 CUDA에 바인딩”
  • “입력도 CUDA OrtValue로 만들고, 출력도 CUDA에 바인딩”

여기서는 출력을 GPU에 유지하는 것부터 시작합니다.

import onnxruntime as ort
import numpy as np

input_name = sess.get_inputs()[0].name
output_name = sess.get_outputs()[0].name

x = np.random.randn(1, 3, 224, 224).astype(np.float32)

io = sess.io_binding()

# 입력은 일반적으로 제공(ORT가 필요 시 복사)
io.bind_cpu_input(input_name, x)

# 출력은 CUDA로 바인딩: ORT가 결과를 GPU 메모리에 둠
io.bind_output(output_name, device_type="cuda", device_id=0)

sess.run_with_iobinding(io)

# 출력 OrtValue를 가져옴(이 시점에 CPU로 복사하지 않음)
ort_outputs = io.get_outputs()
assert len(ort_outputs) == 1
out_ortvalue = ort_outputs[0]

# 필요할 때만 numpy로 변환(이때 D2H)
out_np = out_ortvalue.numpy()

핵심은 out_ortvalue.numpy()를 호출하기 전까지는 D2H가 강제되지 않는다는 점입니다. 즉, 후처리를 GPU에서 이어갈 수 있다면(또는 다음 모델 입력으로 바로 연결한다면) 이 복사를 통째로 없앨 수 있습니다.

입력까지 GPU로: OrtValue로 직접 바인딩

진짜 효과는 입력도 GPU에 두는 순간부터 커집니다. 예를 들어 전처리가 PyTorch라면 입력은 이미 cuda 텐서일 가능성이 큽니다. 이때 CPU numpy로 내려갔다가 다시 올리면 왕복 비용이 생깁니다.

ORT는 OrtValue로 CUDA 메모리를 표현할 수 있습니다. 다만 “PyTorch CUDA 텐서 포인터를 ORT로 무복사 공유”는 환경/버전에 따라 제약이 있고, 가장 안전한 접근은 ORT가 제공하는 방식으로 CUDA OrtValue를 만들고, 파이프라인을 그에 맞춰 구성하는 것입니다.

아래 예시는 ORT에서 CUDA OrtValue를 생성해 입력/출력을 모두 CUDA에 바인딩하는 형태입니다(개념 예시).

import onnxruntime as ort
import numpy as np

input_meta = sess.get_inputs()[0]
input_name = input_meta.name
input_shape = [1, 3, 224, 224]

output_name = sess.get_outputs()[0].name

# CPU에서 만든 데이터를 "CUDA OrtValue"로 올림(여기서 H2D 1회)
x_cpu = np.random.randn(*input_shape).astype(np.float32)

x_cuda = ort.OrtValue.ortvalue_from_numpy(x_cpu, device_type="cuda", device_id=0)

io = sess.io_binding()
io.bind_input(
    name=input_name,
    device_type="cuda",
    device_id=0,
    element_type=np.float32,
    shape=input_shape,
    buffer_ptr=x_cuda.data_ptr(),
)

io.bind_output(output_name, device_type="cuda", device_id=0)

sess.run_with_iobinding(io)

y_cuda = io.get_outputs()[0]
# y_cuda는 CUDA에 존재

중요 포인트:

  • buffer_ptr를 직접 넘기는 패턴은 강력하지만, **버퍼 생명주기(lifetime)**를 잘못 관리하면 크래시/오염이 날 수 있습니다.
  • Python에서는 특히 “참조가 끊겨 GC가 메모리를 회수”하는 순간이 위험합니다. 입력 OrtValue(x_cuda)를 추론 호출이 끝날 때까지 유지하세요.

출력 버퍼를 재사용해 지연과 할당을 동시에 줄이기

서빙에서 지연을 흔드는 또 다른 요소는 메모리 할당/해제입니다. IOBinding은 출력 버퍼를 “매번 새로 만들지 말고” 재사용하는 전략과 궁합이 좋습니다.

  • 출력 shape이 고정이거나 상한이 명확할 때
  • 배치가 작고 호출 빈도가 높을 때

개념적으로는 다음과 같습니다.

  1. 초기화 단계에서 출력용 CUDA OrtValue(또는 적절한 버퍼)를 확보
  2. bind_output 시 그 버퍼에 쓰도록 지정
  3. 매 요청마다 같은 버퍼를 재사용

주의: 동시 요청(concurrency)이 있으면 요청별 버퍼를 분리하거나, 락/풀링이 필요합니다.

아래는 “요청 단위 버퍼 풀”을 아주 단순화한 예시입니다.

import queue
import onnxruntime as ort
import numpy as np

class OutputBufferPool:
    def __init__(self, n, shape, dtype=np.float32, device_id=0):
        self.q = queue.Queue()
        self.shape = shape
        self.dtype = dtype
        self.device_id = device_id
        for _ in range(n):
            # 0으로 채운 CPU 배열을 CUDA로 올려 출력 버퍼로 사용(초기 1회 비용)
            cpu = np.zeros(shape, dtype=dtype)
            cuda_val = ort.OrtValue.ortvalue_from_numpy(cpu, "cuda", device_id)
            self.q.put(cuda_val)

    def acquire(self):
        return self.q.get()

    def release(self, buf):
        self.q.put(buf)

pool = OutputBufferPool(n=8, shape=(1, 1000), dtype=np.float32, device_id=0)

input_name = sess.get_inputs()[0].name
output_name = sess.get_outputs()[0].name


def infer(x_cpu: np.ndarray):
    out_buf = pool.acquire()
    try:
        io = sess.io_binding()
        io.bind_cpu_input(input_name, x_cpu)

        # 재사용 버퍼 포인터로 출력 바인딩
        io.bind_output(
            name=output_name,
            device_type="cuda",
            device_id=0,
            element_type=np.float32,
            shape=list(out_buf.shape()),
            buffer_ptr=out_buf.data_ptr(),
        )

        sess.run_with_iobinding(io)
        y = io.get_outputs()[0]  # out_buf와 같은 메모리를 가리키는 OrtValue
        return y
    finally:
        pool.release(out_buf)

이 방식은 “출력용 GPU 메모리 할당”을 요청 경로에서 제거해 tail latency를 줄이는 데 도움됩니다.

동기화(synchronization) 함정: 빨라졌는데 결과가 늦게 나오는 이유

IOBinding을 적용했는데도 지연이 줄지 않거나, 측정이 이상하게 나오는 이유는 대부분 동기화 타이밍 때문입니다.

  • GPU 연산은 비동기로 큐에 쌓이고, 특정 API 호출에서 암묵적으로 동기화가 걸립니다.
  • numpy()로 변환하는 순간 D2H 복사와 동기화가 한 번에 발생해 “갑자기 느려 보일” 수 있습니다.

따라서 벤치마크는 다음을 분리해서 측정하는 것이 좋습니다.

  • ORT 실행 자체 시간(가능하면 GPU 이벤트 기반)
  • D2H 복사 시간(OrtValue.numpy())
  • 후처리 시간

Python에서 정확한 GPU 이벤트 측정은 CUDA/PyTorch 이벤트를 쓰는 편이 낫지만, 최소한 아래처럼 “D2H를 호출하는 지점”을 명확히 분리하세요.

import time

t0 = time.perf_counter()
sess.run_with_iobinding(io)
t1 = time.perf_counter()

# 여기서 D2H가 발생할 수 있음
out = io.get_outputs()[0].numpy()
t2 = time.perf_counter()

print("run_with_iobinding:", (t1 - t0) * 1000, "ms")
print("to_numpy(D2H):", (t2 - t1) * 1000, "ms")

실전 적용 패턴: 전처리 GPU, ORT, 후처리 GPU

IOBinding의 이상적인 목표는 “CPU 왕복 제거”입니다. 예를 들어 이미지 모델이라면 다음 파이프라인이 흔합니다.

  • (GPU) 리사이즈/정규화
  • (GPU) ORT 추론
  • (GPU) 후처리
  • (CPU) 최종 결과만 최소한으로 내려서 응답

이 구조로 가면 D2H는 “최종 결과”에만 발생하고, 중간 텐서 이동이 크게 줄어듭니다.

다만 운영 중에는 GPU 메모리가 빠듯해질 수 있습니다. 메모리 압박이 생기면 지연이 튀거나 OOM이 날 수 있으니, 원인 추적은 리눅스 OOMKilled 원인 추적 - cgroup·dmesg·ulimit 같은 관점(컨테이너 제한, 커널 로그, 실제 RSS/VRAM 추정)을 병행하는 것이 좋습니다.

자주 발생하는 문제와 해결 포인트

1) 출력 shape이 가변인데 버퍼 재사용을 고정으로 해버림

모델 출력이 입력 길이에 따라 달라지는 경우(예: NLP 시퀀스)에는 고정 버퍼 재사용이 어렵습니다.

대안:

  • 최대 길이로 패딩해서 shape을 고정
  • shape별 버퍼 풀을 따로 두기
  • 출력은 ORT에 맡기되(bind_output만) D2H를 최소화하는 방향으로 타협

2) bind_output만 했는데도 CPU로 내려오는 것처럼 보임

io.get_outputs()[0]는 OrtValue이고, 이 자체는 CPU numpy가 아닙니다. 하지만 이후 코드가 무심코 numpy()를 호출하거나, np.array(ortvalue) 같은 변환을 하면서 D2H를 유발할 수 있습니다.

대안:

  • 후처리를 GPU에서 수행하도록 라이브러리를 맞추기
  • 정말 필요한 최소 결과만 numpy()로 변환

3) 멀티스레드/멀티프로세스에서 버퍼 포인터 재사용으로 데이터 경합

동시에 같은 출력 버퍼에 쓰면 결과가 섞입니다.

대안:

  • 요청별 독립 버퍼
  • 버퍼 풀을 두고 요청마다 acquire/release
  • 프로세스 모델(예: gunicorn worker)이라면 worker별 세션/버퍼로 격리

4) 컨테이너에서 성능이 들쭉날쭉

EKS 같은 환경에서는 노드/드라이버/디바이스 플러그인, 이미지 풀, 네트워크 정책 등 인프라 요인이 섞여 “추론 자체 최적화”가 묻히는 경우가 있습니다. 특히 초기 cold start에서 이미지 pull 문제가 있으면 지연이 크게 튈 수 있으니 EKS Pod ImagePullBackOff 401 해결 가이드 같은 체크도 함께 권장합니다.

간단 벤치마크 가이드: 2배 개선을 확인하는 방법

IOBinding의 효과를 확인하려면 최소한 아래 2가지를 비교하세요.

  • sess.run 기반(기본)
  • run_with_iobinding 기반(출력 GPU 유지, 가능하면 입력도 GPU)

그리고 측정 시나리오를 분리합니다.

  • “모델만” 속도: 출력 numpy() 변환을 하지 않거나, 변환을 별도로 측정
  • “end-to-end” 속도: 실제 서비스와 동일하게 최종 결과를 CPU로 내려 응답

주의할 점:

  • 워밍업을 충분히(예: 50~200회)
  • 측정은 p50뿐 아니라 p95/p99도 보기(할당/동기화는 tail에 민감)
  • 배치/입력 크기별로 따로 보기

마무리: IOBinding은 ‘복사 제거’ 도구다

정리하면 IOBinding은 ORT에서 흔히 발생하는 병목인

  • 입력/출력 텐서의 불필요한 복사
  • 암묵적 동기화
  • 요청 경로에서의 메모리 할당

을 줄이는 데 특화된 기능입니다.

적용 우선순위는 다음이 실전에서 가장 안전합니다.

  1. 출력만 CUDA에 바인딩해서 D2H를 늦추거나 제거
  2. 파이프라인 후처리를 GPU로 옮겨 numpy() 호출 자체를 최소화
  3. 입력도 CUDA 바인딩으로 H2D를 줄이기
  4. 출력 버퍼 재사용(풀링)으로 tail latency 안정화

이 순서대로 적용하면, “모델은 빠른데 서비스가 느린” 상황에서 가장 흔한 원인인 메모리 이동 비용을 깔끔하게 줄일 수 있습니다.