본문 바로가기
Inference

ONNX(Open Neural Network Exchange)

by AteN 2024. 11. 4.

ONNX와 ONNX Runtime 완전 정리, 포맷부터 배포 튜닝까지

요약

  • 가장 먼저 깨야 할 오해. "ONNX로 바꾸면 빨라진다"는 틀렸습니다. ONNX는 교환 포맷일 뿐이고, 속도는 런타임과 실행 백엔드가 결정합니다.
  • ONNX 파일은 연산 그래프와 가중치를 함께 담은 protobuf입니다. 가중치만 담는 safetensors와 달리 모델 구조까지 들어 있어 원 프레임워크 코드 없이 단독 실행됩니다.
  • ONNX Runtime(ORT)은 하나의 엔진이 아니라 EP라는 하드웨어 백엔드를 갈아 끼우는 구조입니다. 같은 파일이 CPU에서도 GPU에서도 NPU에서도 돕니다.
  • 성능을 실제로 바꾸는 손잡이는 셋입니다. 그래프 최적화, 스레딩, 입출력 바인딩. 세 번째를 빠뜨려서 "ORT가 느리다"고 오해하는 경우가 특히 많습니다.
  • 변환이 막히거나 결과가 이상하면 Netron으로 그래프를 직접 봅니다. 입출력 이름, 동적 축, 융합 결과, 양자화 형식이 눈으로 확인됩니다.
  • 변환 성공이 끝이 아닙니다. 가장 흔한 사고는 "변환은 됐는데 정확도가 떨어지는" 것이고, 원인은 대개 동적 축 미지정, 전처리 불일치, opset 다운그레이드입니다.

1장. ONNX 포맷

1.1 ONNX가 정확히 무엇인가

1.1.1 그래프 구조

ONNX(Open Neural Network Exchange)는 확장 가능한 연산 그래프 모델의 정의와, 내장 연산자 및 표준 자료형의 정의를 제공합니다.

공식 설명을 뜯어보면 구조가 명확합니다.

각 연산 데이터플로 그래프는 노드들의 리스트로 구성되고 비순환 그래프를 이룬다
   노드는 하나 이상의 입력과 하나 이상의 출력을 갖는다
   각 노드는 하나의 연산자 호출이다
   그래프에는 목적과 작성자 등을 기록하는 메타데이터도 있다

연산자는 그래프 외부에 구현된다
   단 내장 연산자 집합은 프레임워크 간에 이식 가능하다
   ONNX 를 지원하는 모든 프레임워크는
   해당 자료형에 대한 이 연산자들의 구현을 제공한다

마지막 문단이 핵심입니다. ONNX는 "무엇을 계산할지"만 정하고 "어떻게 계산할지"는 각 런타임에 맡깁니다. 이 분리가 ONNX의 힘이자 함정입니다.

1.1.2 파일 안의 계층 구조

ModelProto            최상위. 파일 하나가 이것
   ir_version         ONNX 중간표현 버전
   opset_import       사용하는 연산자 집합과 그 버전
   producer_name      어떤 도구가 만들었나
   graph              ▼

GraphProto
   input              그래프 입력 목록. 이름과 타입과 형태
   output             그래프 출력 목록
   initializer        가중치가 여기 들어간다
   node               연산 노드들. 위상 정렬된 리스트
   value_info         중간 텐서의 타입 정보 (선택)

NodeProto
   op_type            연산자 이름. Conv, MatMul, Relu 등
   name               노드 이름 (선택)
   input / output     이 노드가 읽고 쓰는 텐서 이름 목록
   attribute          연산자 파라미터. kernel_shape, strides, pads 등

1.1.3 노드는 어떻게 연결되나

여기가 ONNX 그래프의 핵심 개념입니다.

노드끼리 포인터로 이어져 있는 게 아니다
텐서 "이름"으로 연결된다

   노드 A 의 output 에 적힌 문자열이
   노드 B 의 input 에 그대로 적혀 있으면
   A 의 결과가 B 로 흘러간다

   이름이 하나만 어긋나도 그래프가 끊긴다

입력으로 올 수 있는 이름은 세 종류입니다.

① 그래프 입력 (graph.input)      바깥에서 넣어 주는 값
② initializer                    파일에 저장된 가중치
③ 다른 노드의 output             중간 계산 결과

가중치가 "노드의 입력"으로 들어간다는 게 처음엔 낯설 수 있습니다. Conv 노드는 입력 텐서와 가중치와 bias를 모두 input 목록으로 받습니다. 학습 프레임워크에서 층이 가중치를 속성으로 갖는 것과 다른 모델입니다.

1.1.4 실제 그래프를 열어 보기

말로만 보면 감이 안 오니 아주 작은 모델을 직접 내보내 보겠습니다.

import torch, onnx

class M(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.conv = torch.nn.Conv2d(3, 8, 3, padding=1, bias=True)
        self.bn   = torch.nn.BatchNorm2d(8)
    def forward(self, x):
        return torch.relu(self.bn(self.conv(x)))

m = M().eval()
torch.onnx.export(
    m, torch.rand(1, 3, 8, 8), "tiny.onnx",
    input_names=["input"], output_names=["output"],
    dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}},
)

내보낸 파일의 그래프를 그대로 찍으면 이렇게 나옵니다.

=== inputs ===
  input  ['batch', 3, 8, 8]

=== initializers ===
  onnx::Conv_12  [8, 3, 3, 3]
  onnx::Conv_13  [8]

=== nodes ===
  op=Conv  in=['input', 'onnx::Conv_12', 'onnx::Conv_13']
           out=['/conv/Conv_output_0']
           attr={'dilations': [1,1], 'group': 1, 'kernel_shape': [3,3],
                 'pads': [1,1,1,1], 'strides': [1,1]}

  op=Relu  in=['/conv/Conv_output_0']
           out=['output']
           attr={}

=== outputs ===
  output  ['batch', 8, 8, 8]

이 짧은 덤프에서 읽어야 할 게 다섯 가지입니다.

① 이름으로 연결된다. Conv의 출력 /conv/Conv_output_0Relu의 입력에 그대로 적혀 있습니다. 이 문자열이 곧 간선입니다.

② 가중치가 input 목록에 있다. Conv의 입력 셋 중 뒤 둘(onnx::Conv_12, onnx::Conv_13)이 initializer, 즉 가중치와 bias입니다. 형태가 각각 [8,3,3,3][8]로 찍혀 있습니다.

③ BatchNorm이 사라졌다. 코드에는 분명 self.bn이 있는데 그래프에는 없습니다. eval 모드에서 BN은 고정된 스케일과 시프트라, 내보내기 단계의 상수 폴딩이 Conv 가중치에 접어 넣은 것입니다. 4.3.1에서 볼 Conv + BatchNorm 융합과 같은 원리인데, 여기서는 런타임이 아니라 내보내기 시점에 이미 일어났습니다.

④ 동적 축은 문자열로 표시된다. 입출력 형태의 첫 자리가 숫자가 아니라 'batch'입니다. 숫자면 고정, 문자열이면 동적입니다. 2.1.3에서 다룰 "조용히 고정되는" 문제를 이 한 줄로 확인할 수 있습니다.

⑤ 속성은 노드에 붙는다. padsstrides 같은 값은 텐서가 아니라 노드의 attribute입니다. 학습되지 않는 설정값이 여기 들어갑니다.

1.1.5 그래서 무엇이 편해지나

이 구조를 알고 나면 실무에서 세 가지가 달라집니다.

추론 코드를 쓸 때
   feed dict 의 키는 graph.input 의 이름이다
   내가 정한 이름이 아니라 파일에 적힌 이름이다

변환이 이상할 때
   있어야 할 op_type 이 없으면 어딘가에서 접히거나 사라진 것
   Cast 나 Constant 가 잔뜩 있으면 dtype 이나 형태 처리에 문제가 있는 것

최적화 효과를 확인할 때
   노드 개수와 op_type 분포를 최적화 전후로 비교하면
   무엇이 접혔는지 숫자로 보인다

3장에서 다룰 Netron이 바로 이 구조를 그림으로 보여 주는 도구입니다.

1.1.6 저장, 교환, 실행을 구분하기

모델 포맷이 헷갈리는 이유는 세 층이 섞여 있기 때문입니다.

저장 (storage)     safetensors, pickle
                   가중치만 담는다. 모델 구조는 별도 코드가 필요

교환 (interchange) ONNX
                   그래프 + 가중치. 프레임워크와 하드웨어 간 이식

실행 (runtime)     ONNX Runtime, TensorRT, GGUF + llama.cpp, LiteRT
                   실제로 돌리는 엔진

저장은 실행이 아닙니다. safetensors는 transformers 같은 라이브러리가 읽어 실행하고, ONNX는 ONNX Runtime 같은 엔진이 실행합니다.

"가중치만 담는지 그래프까지 담는지"가 이식성을 가릅니다. safetensors만 받고 모델 구조를 잊어버리는 게 흔한 실수인데 ONNX에서는 그럴 일이 없습니다. 대신 그 대가로 그래프가 굳습니다.

1.2 왜 "ONNX = 빠름"이 틀렸나

ONNX 파일 자체는 그냥 그래프와 가중치의 직렬화다
   실행 코드가 아니다

속도를 결정하는 건
   ① 어떤 런타임이 그걸 읽는가
   ② 그 런타임이 어떤 실행 백엔드로 돌리는가
   ③ 그래프 최적화가 얼마나 걸렸는가
   ④ 입출력 복사가 얼마나 일어나는가
   ⑤ 양자화 여부

   같은 .onnx 파일이라도 백엔드에 따라 성능이 자릿수로 갈린다

빨라졌다면 ONNX 덕이 아니라 런타임과 백엔드 덕입니다. 반대로 PyTorch보다 느려지는 경우도 흔합니다. 동적 형태가 많아 최적화가 안 걸리거나, 4장에서 볼 입출력 복사가 시간을 다 먹는 경우입니다.

1.3 opset

1.3.1 세 가지 버전 체계를 구분하기

ONNX 라이브러리 버전   pip 로 설치하는 onnx 패키지의 버전
opset 버전            연산자 집합의 버전. 모델 파일 안에 기록된다
ORT 버전              onnxruntime 패키지의 버전

   셋은 서로 다른 체계다
   "ONNX 몇 버전 쓰세요"라는 말은 대개 opset 이야기이고
   ORT 버전과는 별개다

1.3.2 양방향 함정

opset은 스펙이 올라갈 때마다 연산자가 추가되거나 시맨틱이 바뀝니다.

내보낼 때 opset  >  런타임이 지원하는 opset
   │
   └──► 로드가 실패하거나 지원 안 되는 연산자에서 에러

그래서 opset 을 낮춰 내보내려 하면
   │
   └──► 그 연산자가 낮은 opset 에 없어 내보내기가 실패하거나
         우회 구현으로 대체돼 정확도나 속도가 달라진다

PyTorch 문서도 "실행할 런타임 백엔드나 컴파일러가 지원하는 opset 버전에 맞춰 설정하라"고 명시합니다. 지정하지 않으면 권장 버전이 쓰입니다.

배포 타깃을 먼저 정하고 opset을 거기 맞추는 게 순서입니다. 반대로 하면 나중에 전부 다시 내보내게 됩니다.

1.4 파일 크기와 external data

protobuf에는 2GB 제한이 있습니다. 이를 넘는 모델은 가중치를 별도 파일로 분리해야 합니다.

model.onnx            그래프 구조만
model.onnx.data       가중치 (또는 여러 파일)

   두 파일이 같은 디렉터리에 있어야 로드된다
   배포할 때 하나만 복사하는 실수가 잦다

2장. 모델 변환

2.1 PyTorch에서 내보내기

2.1.1 기본 형태

import torch

class MyModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = torch.nn.Conv2d(1, 128, 5)

    def forward(self, x):
        return torch.relu(self.conv1(x))

model = MyModel().eval()                # eval() 을 빼먹으면 안 된다
x = torch.rand((1, 1, 128, 128), dtype=torch.float32)

torch.onnx.export(
    model,
    (x,),
    "my_model.onnx",
    input_names=["input"],
    output_names=["output"],
    dynamo=True,                        # 어느 exporter 를 쓸지 고르는 스위치
)

input_namesoutput_names를 반드시 지정하세요. 안 주면 자동 생성된 이름이 붙는데, 추론 코드에서 이 이름으로 텐서를 넘겨야 해서 나중에 헷갈립니다.

2.1.2 두 개의 exporter

레거시 경로 (dynamo=False)
   TorchScript 추적 기반
   오래된 예제 대부분이 이쪽이다

torch.export 기반 경로 (dynamo=True)
   AOT 방식으로 텐서 연산만 담은 추적 그래프를 만든다

   ① 정규화된 functional ATen 연산자 집합을 만든다
   ② Python 제어흐름과 자료구조를 전부 제거한다 (일부 예외 있음)
   ③ 이 정규화와 제어흐름 제거가 미래 입력에도 타당함을 보이는
      형태 제약 집합을 기록한다
   그런 다음 ONNX 그래프로 번역한다

현재 문서 기준 dynamo의 기본값은 True이고 이것이 권장 방식입니다.

내부적으로는 세 전략을 순서대로 시도합니다.

① 모델이 이미 ExportedProgram 이면 그대로 사용
② torch.export.export() 를 strict=False 로 시도
③ torch.export.export() 를 strict=True 로 시도

이 폴백 순서를 알면 에러 메시지가 읽힙니다. 어느 단계에서 깨졌는지에 따라 원인이 다릅니다.

2.1.3 동적 축, 여기서 가장 많이 틀린다

dynamo=True  ──►  dynamic_shapes
dynamo=False ──►  dynamic_axes (레거시)

   문서가 dynamic_axes 를 deprecated 로 표시하고
   dynamo=True 일 때는 dynamic_shapes 를 선호하라고 명시한다

PyTorch 문서 FAQ의 첫 질문이 이겁니다.

LLM 모델을 내보냈는데 입력 크기가 고정된 것 같습니다.

추적기는 예제 입력의 형태를 그대로 기록한다
   동적 형태 입력을 받아야 한다면
   내보낼 때 반드시 지정해야 한다

지정을 빼먹으면 조용히 고정 크기 모델이 나옵니다. 에러가 안 나고 예제와 같은 크기에서는 잘 돌기 때문에 배포 후에야 발견됩니다. 배치 1로 내보내 놓고 프로덕션에서 배치를 키우려다 막히는 게 전형적입니다.

2.1.4 나머지 옵션

옵션 무엇이 달라지나
external_data 가중치를 별도 파일로 분리. 2GB를 넘는 모델에는 필수
optimize 내보낸 그래프를 정리. dynamo=True에서만 유효하고 기본이 켜짐
verify 내보낸 직후 ONNX Runtime으로 검증까지 수행
report 내보내기 과정의 마크다운 리포트 생성. 실패 원인 추적에 유용
custom_translation_table 특정 연산자의 변환 방식을 직접 지정
keep_initializers_as_inputs 가중치를 그래프 입력으로도 노출. 런타임에 가중치를 갈아 끼울 때 True

custom_translation_table이 실무 탈출구입니다. 특정 연산자가 안 나갈 때 그 연산을 직접 구현해 매핑을 주입할 수 있습니다.

2.2 transformers 계열은 Optimum으로

optimum-cli export onnx --model <model_id> <output_dir>

태스크별 헤드와 전처리 설정까지 함께 처리해 줘서, 직접 부르는 것보다 사고가 적습니다.

2.3 변환이 막히는 경우

2.3.1 연산자 커버리지

ONNX 표준 연산자 집합에 없는 연산
ONNX 는 지원하는데 타깃 백엔드가 구현 안 한 연산
커스텀 CUDA 커널

   어느 쪽이든 나가지 않거나, 나가도 안 돈다

앞서 다룬 인페인팅 모델 LaMa가 좋은 예입니다. FFT와 IFFT 층 때문에 ONNX로 그대로 내보낼 수 없습니다. 공식 배포 카드가 이를 명시하고, 커뮤니티 포트는 커스텀 구현으로 우회하되 입력 해상도가 고정됩니다.

아키텍처 장점이 배포에서 상쇄되는 전형입니다. 논문에서 좋아 보이는 구조와 배포 가능한 구조가 다를 수 있다는 걸 미리 알아 두면 모델 선택이 달라집니다.

2.3.2 제어흐름

Python if 문이 텐서 값에 의존하면
   추적 시점의 분기만 그래프에 굳는다
   다른 입력에서 틀린 결과가 나온다

반복문도 마찬가지
   PyTorch 문서는 torch.cond 를 안내한다

에러가 안 나고 조용히 틀린다는 게 이 부류의 무서운 점입니다.

2.3.3 데이터 의존 형태

출력 크기가 입력 값에 따라 달라지는 연산(NMS 결과 개수, 마스크 픽셀 수 등)은 정적 그래프와 상성이 나쁩니다. 형태 추론이 실패하거나 최악의 경우로 잡혀 메모리를 낭비합니다.

2.4 변환 후 검증

"변환은 됐는데 정확도가 떨어진다"가 가장 흔한 사고입니다.

import numpy as np, onnxruntime as ort, torch

x = torch.rand(1, 3, 224, 224)

with torch.no_grad():
    ref = model(x).numpy()

sess = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
out = sess.run(None, {"input": x.numpy()})[0]

print(np.abs(ref - out).max())
np.testing.assert_allclose(ref, out, rtol=1e-3, atol=1e-5)

여러 입력 크기로 돌려 봐야 동적 축이 제대로 잡혔는지 확인됩니다. 예제 입력 하나로만 검증하면 고정 크기 모델을 통과시킵니다.

원인별로 좁히는 순서입니다.

오차가 크다
   ① 전처리가 다른가     정규화 mean/std, 채널 순서(RGB vs BGR), 리사이즈 보간
   ② 학습 모드로 내보냈나  eval() 을 빼먹으면 dropout 과 BN 이 다르게 돈다
   ③ opset 을 낮췄나      연산자가 우회 구현으로 대체됐을 수 있다
   ④ 동적 축을 안 잡았나   다른 크기에서만 틀린다면 이것
   ⑤ EP 옵션인가         TF32 같은 저정밀 연산이 켜져 있을 수 있다

크기가 달라진다
   출력 형태 추론이 어긋난 것. 데이터 의존 연산을 의심

전처리 불일치가 압도적으로 흔합니다. 정규화 값을 원 모델 카드와 안 맞추면 정확도가 급락합니다. ONNX 변환 자체는 멀쩡한데 파이프라인이 틀린 경우입니다.


3장. 모델 들여다보기, Netron

3.1 무엇인가

Netron은 신경망과 딥러닝과 머신러닝 모델을 위한 뷰어입니다. 그래프 구조와 각 노드의 속성을 시각적으로 보여 줍니다.

지원 포맷이 넓습니다.

정식 지원   ONNX, TensorFlow Lite, PyTorch, torch.export, ExecuTorch,
            TorchScript, TensorFlow, Core ML, OpenVINO, Keras, Caffe,
            Darknet, Safetensors, NumPy

실험 지원   MLIR, JAX, GGUF, RKNN, ncnn, MNN, PaddlePaddle, scikit-learn

ONNX 작업에서 사실상 필수 도구입니다. 변환이 실패하거나 결과가 이상할 때, 코드로 추측하는 것보다 그래프를 직접 보는 게 훨씬 빠릅니다.

3.2 쓰는 방법 세 가지

3.2.1 브라우저

netron.app에 파일을 끌어다 놓으면 끝입니다. 설치가 필요 없습니다.

주의할 점. 사내 모델을 외부 웹에 올리는 게 되는지 확인이 필요합니다. 정책상 안 되면 아래 두 방법을 씁니다.

3.2.2 데스크톱 앱

macOS     brew install --cask netron
Windows   winget install -s winget netron
Linux     .deb 또는 .rpm 설치

3.2.3 파이썬 패키지

pip install netron
netron model.onnx              # 로컬 서버가 뜨고 브라우저가 열린다
import netron
netron.start("model.onnx")

원격 서버에서 작업할 때 유용합니다. 서버에서 띄우고 포트 포워딩으로 로컬 브라우저에서 봅니다.

3.3 Netron으로 무엇을 확인하나

3.3.1 입출력 이름과 형태

추론 코드를 쓰기 전에 가장 먼저 볼 것입니다.

그래프 맨 위 입력 노드를 클릭하면

   name    "input" 인지 "input.1" 인지 "pixel_values" 인지
   type    float32
   shape   [1, 3, 224, 224]

sess.run(None, {"input": ...})의 키가 여기 적힌 이름과 정확히 같아야 합니다. 자동 생성된 이름은 예측이 안 돼서 반드시 확인해야 합니다.

3.3.2 동적 축이 제대로 잡혔나

이게 Netron을 쓰는 가장 실용적인 이유입니다.

고정 축   shape 에 숫자가 박혀 있다        [1, 3, 224, 224]
동적 축   심볼 이름이 보인다               [batch, 3, height, width]

   내보낼 때 dynamic_shapes 를 지정했는데
   여기에 숫자가 박혀 있으면 지정이 안 먹은 것이다

2.1.3에서 본 "조용히 고정 크기가 되는" 문제를 배포 전에 눈으로 잡을 수 있습니다.

3.3.3 융합이 걸렸는지 비교

4.3에서 다룰 그래프 최적화의 효과를 확인하는 방법입니다.

최적화 전 모델과
optimized_model_filepath 로 저장한 모델을
Netron 으로 나란히 열어 본다

   Conv 와 BatchNorm 이 각각 있던 자리가
   Conv 하나로 접혔는지
   LayerNorm 이 수십 개 노드에서 단일 노드로 바뀌었는지

"최적화가 실제로 걸렸나"를 확인하는 가장 확실한 방법입니다.

3.3.4 양자화 형식 확인

QDQ 형식이면
   원래 연산자 사이에 QuantizeLinear 와 DequantizeLinear 가 보인다

QOperator 형식이면
   QLinearConv 나 MatMulInteger 같은 전용 노드가 보인다

5장에서 다룰 두 형식의 차이가 그래프에 그대로 드러납니다.

3.3.5 어디서 끊겼나

변환이 이상하게 됐을 때 그래프가 예상과 다른 지점을 찾습니다.

있어야 할 층이 없다          제어흐름이 추적 시점에 굳은 것
Constant 노드가 잔뜩 있다     상수로 굳어야 할 게 남아 있거나 반대
Cast 가 여기저기 끼어 있다    dtype 이 오가며 오버헤드가 붙은 것

3.4 Netron 없이 파이썬으로 보기

빠르게 확인만 하려면 코드로도 됩니다.

import onnx

m = onnx.load("model.onnx")

# 입출력 이름과 형태
for i in m.graph.input:
    dims = [d.dim_param or d.dim_value for d in i.type.tensor_type.shape.dim]
    print("input :", i.name, dims)       # dim_param 이 있으면 동적 축
for o in m.graph.output:
    dims = [d.dim_param or d.dim_value for d in o.type.tensor_type.shape.dim]
    print("output:", o.name, dims)

# opset
print("opset :", [(op.domain or "ai.onnx", op.version) for op in m.opset_import])

# 어떤 연산자가 몇 개 쓰였나
from collections import Counter
print(Counter(n.op_type for n in m.graph.node).most_common(15))

# 그래프 유효성 검사
onnx.checker.check_model(m)

마지막 두 줄이 유용합니다. 연산자 분포를 보면 "이 모델에 무엇이 들어 있나"가 한눈에 잡히고, check_model은 구조적 오류를 잡아 줍니다.

ORT 쪽에서도 확인할 수 있습니다.

sess = ort.InferenceSession("model.onnx", providers=["CPUExecutionProvider"])
for i in sess.get_inputs():
    print(i.name, i.shape, i.type)      # shape 에 문자열이 있으면 동적 축
for o in sess.get_outputs():
    print(o.name, o.shape, o.type)

4장. ONNX Runtime

여기서부터가 성능이 결정되는 영역입니다.

4.1 세션 수명주기

.onnx 파일
   │
   ▼
InferenceSession 생성 시점에 일어나는 일
   │
   ├─ ① 그래프 로드와 검증
   │
   ├─ ② 기본(Basic) 그래프 최적화      모든 백엔드에 공통 적용
   │
   ├─ ③ 그래프 분할 (partitioning)     어느 노드를 어느 EP 가 맡을지 배정
   │
   ├─ ④ 확장(Extended) 최적화          배정된 노드에만 적용
   │
   ├─ ⑤ 레이아웃 최적화
   │
   └─ ⑥ 실행 계획 수립과 메모리 할당
         │
         ▼
      Run() 호출마다 실제 추론

②와 ④ 사이에 분할이 끼어 있다는 게 중요합니다. 기본 최적화는 분할 전이라 모든 백엔드에 적용되고, 확장 최적화는 분할 후라 특정 백엔드에 배정된 노드에만 적용됩니다. 그래서 어떤 EP를 쓰느냐에 따라 걸리는 최적화가 달라집니다.

세션 생성이 비싸다는 것도 여기서 나옵니다. 매 요청마다 세션을 만들면 안 되고, 한 번 만들어 재사용해야 합니다.

4.2 execution provider

4.2.1 종류

공식 문서 기준으로 제공되는 EP를 묶으면 이렇습니다.

NVIDIA        CUDA, TensorRT, TensorRT RTX
Intel         OpenVINO, oneDNN
Windows       DirectML
모바일과 엣지   QNN (Qualcomm), NNAPI (Android), CoreML (Apple), XNNPACK
AMD           ROCm, MIGraphX, Vitis AI
웹            WebGPU
커뮤니티 유지   ACL, Arm NN, TVM, RKNPU, CANN

같은 모델을 다른 하드웨어에 이식할 때 코드를 안 고쳐도 되는 게 여기서 나옵니다.

4.2.2 우선순위와 조용한 폴백

EP 목록은 우선순위입니다. 앞에 쓴 것부터 노드를 가져가고, 못 맡는 노드가 뒤 EP로 넘어갑니다. 그래서 CPU EP를 마지막에 넣어 두는 게 관례입니다.

여기서 실무의 함정이 생깁니다.

목록에 CUDA 를 넣어도
라이브러리 버전이 안 맞으면 조용히 CPU 로 떨어진다

   에러가 안 난다
   그래서 "GPU 붙였는데 왜 안 빨라지지"가 된다

   반드시 실제로 무엇이 잡혔는지 확인해야 한다

GPU EP는 버전 정합성이 까다롭습니다. 문서에 따르면 특정 CUDA 메이저 버전으로 빌드된 패키지는 같은 메이저 안에서는 호환되지만, cuDNN은 메이저 버전이 다르면 호환되지 않습니다. PyTorch와 같은 환경에 올릴 때 이 조합이 자주 충돌합니다.

4.2.3 Python

import onnxruntime as ort
import numpy as np

sess = ort.InferenceSession(
    "model.onnx",
    providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)

print(sess.get_providers())     # 실제로 무엇이 잡혔는지 반드시 확인

x = np.random.rand(1, 3, 224, 224).astype(np.float32)
out = sess.run(None, {"input": x})[0]

sess.run의 첫 인자를 None으로 주면 모든 출력을 받고, 이름 리스트를 주면 그것만 받습니다. 출력이 여러 개인데 하나만 필요하면 이름을 지정하는 게 낫습니다.

4.2.4 C++

#include <onnxruntime_cxx_api.h>
#include <vector>
#include <iostream>

int main() {
    Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "app");

    Ort::SessionOptions so;
    so.SetIntraOpNumThreads(4);
    so.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);

    // CUDA EP 추가. 실패하면 예외가 나므로 감싸는 게 안전하다
    OrtCUDAProviderOptions cuda_opts;
    cuda_opts.device_id = 0;
    so.AppendExecutionProvider_CUDA(cuda_opts);

#ifdef _WIN32
    Ort::Session session(env, L"model.onnx", so);
#else
    Ort::Session session(env, "model.onnx", so);
#endif

    // 입출력 이름 조회
    Ort::AllocatorWithDefaultOptions alloc;
    auto in_name  = session.GetInputNameAllocated(0, alloc);
    auto out_name = session.GetOutputNameAllocated(0, alloc);
    const char* in_names[]  = { in_name.get() };
    const char* out_names[] = { out_name.get() };

    // 입력 텐서 구성
    std::vector<int64_t> shape = {1, 3, 224, 224};
    std::vector<float> data(1 * 3 * 224 * 224, 0.0f);

    auto mem = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
    Ort::Value input = Ort::Value::CreateTensor<float>(
        mem, data.data(), data.size(), shape.data(), shape.size());

    // 실행
    auto outputs = session.Run(
        Ort::RunOptions{nullptr}, in_names, &input, 1, out_names, 1);

    float* p = outputs[0].GetTensorMutableData<float>();
    auto info = outputs[0].GetTensorTypeAndShapeInfo();
    std::cout << "elements: " << info.GetElementCount() << "\n";
    return 0;
}

C++ API에서 알아둘 점입니다.

Ort::Env        프로세스당 하나. 로깅과 스레드풀의 소유자
                여러 세션이 공유한다

Ort::Session    생성이 비싸다. 재사용해야 한다
                스레드 안전이라 여러 스레드가 Run() 을 부를 수 있다

Ort::Value      텐서 래퍼. CreateTensor 는 버퍼를 복사하지 않고 가리킨다
                따라서 Run() 이 끝날 때까지 원본 버퍼가 살아 있어야 한다

이름 소유권      GetInputNameAllocated 는 스마트 포인터를 돌려준다
                .get() 으로 꺼낸 포인터를 쓰는 동안
                원본 객체가 살아 있어야 한다

세 번째와 네 번째가 C++에서 실제로 크래시를 내는 지점입니다. 임시 객체에서 .get()을 뽑아 쓰면 이미 해제된 메모리를 가리킵니다.

4.3 그래프 최적화

세 레벨이 있고 이전 레벨이 적용된 뒤에 다음 레벨이 적용됩니다. 기본적으로 전부 켜져 있습니다.

4.3.1 Basic

의미를 보존하는 그래프 재작성입니다. 분할 전에 실행되므로 모든 EP에 적용됩니다.

상수 폴딩 (Constant Folding)
   상수 초기값에만 의존하는 부분을 미리 계산해 둔다
   런타임에 계산할 필요가 사라진다

중복 노드 제거
   Identity, Slice, Unsqueeze, Dropout 제거

의미 보존 노드 융합
   Conv + Add        Add 를 Conv 의 bias 로 접는다
   Conv + Mul
   Conv + BatchNorm  추론 시 BN 은 스케일과 시프트라 Conv 에 흡수된다
   Relu + Clip
   Reshape 융합

Conv + BatchNorm 융합이 체감이 큽니다. 학습 때는 별개 층이지만 추론에서는 BN이 고정된 선형 변환이라 Conv 가중치에 접어 넣을 수 있습니다. 층 하나가 통째로 사라집니다.

4.3.2 Extended

복잡한 노드 융합입니다. 분할 후에 실행되고, CPU나 CUDA나 ROCm EP에 배정된 노드에만 적용됩니다.

GEMM Activation Fusion      CPU
Matmul Add Fusion           CPU
Conv Activation Fusion      CPU
GELU Fusion                 CPU, CUDA, ROCm
Layer Normalization Fusion  CPU, CUDA, ROCm
BERT Embedding Layer Fusion CPU, CUDA, ROCm
Attention Fusion            CPU, CUDA 등

트랜스포머 계열에서 차이가 큽니다. LayerNorm이나 Attention은 원래 수십 개 노드로 펼쳐지는데, 융합되면 전용 커널 하나로 접힙니다.

뒤집어 말하면 다른 EP를 쓰면 이 최적화가 안 걸립니다. 특정 NPU EP로 돌렸는데 기대만큼 안 빠르다면 확장 최적화 대상이 아니어서일 수 있습니다.

4.3.3 레벨 조절

so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
레벨 무엇이 달라지나
ORT_DISABLE_ALL 최적화를 전부 끈다. 최적화가 정확도를 바꾸는지 검증할 때 기준선
ORT_ENABLE_BASIC 기본만. 확장 융합이 문제를 일으킬 때 여기까지 내린다
ORT_ENABLE_EXTENDED 확장 융합까지
ORT_ENABLE_ALL 레이아웃 최적화까지. 기본값

레벨을 낮추는 건 성능이 아니라 디버깅 목적입니다. 최적화 후 결과가 이상하면 ORT_DISABLE_ALL로 내려 보고 어느 레벨에서 갈리는지 좁힙니다.

4.3.4 온라인 모드와 오프라인 모드

온라인 모드   세션 생성 때마다 최적화를 수행한다. 기본
오프라인 모드  최적화된 그래프를 디스크에 저장해 둔다
so.optimized_model_filepath = "model_opt.onnx"
so.SetOptimizedModelFilePath("model_opt.onnx");

무엇이 달라지나. 최적화 자체가 시간이 걸려서 큰 모델은 세션 생성이 수 초씩 걸리기도 합니다. 오프라인으로 한 번 저장해 두면 이후 세션 생성이 그만큼 빨라집니다.

주의할 게 있습니다. 저장된 그래프는 그때 쓴 EP와 하드웨어에 맞춰 최적화된 것입니다. 확장 최적화가 분할 후에 걸리기 때문입니다. 다른 환경에 그대로 옮기면 최적이 아니거나 안 돌 수 있습니다.

3.3.3에서 본 대로 Netron으로 전후를 비교하면 무엇이 접혔는지 눈으로 확인할 수 있습니다.

4.4 스레딩

두 종류의 병렬성이 있고 이걸 헷갈리면 튜닝이 산으로 갑니다.

intra-op   연산자 하나 "안에서"의 병렬화
           예: 하나의 큰 MatMul 을 여러 스레드가 나눠 계산

inter-op   그래프의 여러 노드를 "가로질러" 병렬 실행
           예: 서로 독립인 두 브랜치를 동시에

4.4.1 intra_op_num_threads

so.intra_op_num_threads = 0     # 기본. 물리 CPU 코어 수
so.SetIntraOpNumThreads(0);

기본값(0 또는 미지정)이면 물리 코어 수만큼 잡습니다. 6코어 12스레드 머신이면 6개입니다. 하이퍼스레딩 논리 프로세서가 아니라 물리 코어 기준이라는 게 포인트입니다.

기본값으로 두면 어피니티 설정도 함께 켜집니다. 스레드를 코어에 고정해 캐시 지역성을 챙깁니다.

언제 바꾸나.

한 프로세스에서 여러 세션을 동시에 돌린다
   기본값이면 세션마다 코어 수만큼 잡아 과다 구독이 난다

컨테이너에 CPU 제한이 걸려 있다
   호스트 코어 수를 보고 잡으면 스로틀링이 난다
   제한에 맞춰 명시적으로 지정한다

지연보다 처리량이 중요하다
   세션당 1스레드로 두고 프로세스를 여러 개 띄우는 편이 나을 수 있다

4.4.2 execution_mode와 inter_op_num_threads

so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL    # 기본
# so.execution_mode = ort.ExecutionMode.ORT_PARALLEL
# so.inter_op_num_threads = 4                           # PARALLEL 일 때만 의미
so.SetExecutionMode(ExecutionMode::ORT_SEQUENTIAL);
// so.SetExecutionMode(ExecutionMode::ORT_PARALLEL);
// so.SetInterOpNumThreads(4);

기본은 순차 실행입니다. 문서 설명이 명확합니다.

모델에 브랜치가 많으면
   ORT_PARALLEL 이 더 나은 성능을 준다

브랜치가 많지 않은 모델에서는
   오히려 성능을 해칠 수 있다

"브랜치가 많은가"가 판단 기준입니다. 일자로 쭉 이어지는 CNN은 병렬로 돌릴 게 없어서 스케줄링 오버헤드만 늘어납니다. 멀티브랜치 구조나 여러 출력 헤드가 있는 모델이 후보입니다.

inter_op_num_threadsORT_PARALLEL일 때만 의미가 있습니다. 순차 모드에서 만지면 아무 효과가 없습니다.

4.4.3 스핀 동작

so.add_session_config_entry("session.intra_op.allow_spinning", "1")   # 기본 1

스레드가 일감을 기다리며 도는(spin) 동작을 제어합니다.

켜면 (기본)   추론이 빠르다
              대신 CPU 사이클과 전력을 더 쓴다

끄면          유휴 시 CPU 점유가 내려간다
              대신 일감이 왔을 때 깨어나는 지연이 붙는다

요청이 계속 들어오는 서버에서는 켜 두는 게 맞습니다. 반대로 요청이 드문드문 오는 온디바이스나 배터리 환경에서는 유휴 상태에서 CPU를 태우는 게 손해입니다.

세부 손잡이도 있습니다. spin_duration_us로 스핀 시간을 마이크로초 단위로 제한하고, spin_backoff_max로 지수 백오프를 걸어 스핀 중 CPU와 전력 사용을 줄입니다. 백오프는 기본이 1이라 꺼져 있고 2의 거듭제곱 값을 주면 켜집니다.

4.5 입출력 바인딩

"ORT가 느리다"는 오해의 가장 큰 원인입니다. 공식 문서가 이 문제를 직접 지목합니다.

비 CPU EP 를 쓸 때

   입력이 대상 디바이스에 복사돼 있지 않으면
   ORT 가 Run() 호출의 일부로 CPU 에서 복사해 온다

   출력이 디바이스에 미리 할당돼 있지 않으면
   ORT 는 출력을 CPU 로 요청한 것으로 보고
   Run() 마지막 단계에서 디바이스로부터 복사해 간다

   이 복사가 실행 시간을 잡아먹는다
   사용자는 ORT 가 느리다고 오해하지만
   실제로는 시간 대부분이 이 복사에 쓰인다

해법이 IOBinding입니다. 입력을 미리 디바이스로 올려 두고 출력도 디바이스에 미리 할당한 뒤 Run()을 부릅니다.

4.5.1 Python

io = sess.io_binding()

io.bind_input(
    name="input",
    device_type="cuda", device_id=0,
    element_type=np.float32,
    shape=x_gpu.shape,
    buffer_ptr=x_gpu.data_ptr(),        # 이미 GPU 에 있는 버퍼
)
io.bind_output(name="output", device_type="cuda", device_id=0)

sess.run_with_iobinding(io)

4.5.2 C++

Ort::IoBinding io_binding{session};

auto input_tensor = Ort::Value::CreateTensor<float>(
    memory_info, data.data(), data.size(), shape.data(), shape.size());
io_binding.BindInput("input", input_tensor);

// 출력 형태를 미리 모를 때: 텐서 대신 메모리 정보만 바인딩
Ort::MemoryInfo out_mem{"Cuda", OrtDeviceAllocator, 0, OrtMemTypeDefault};
io_binding.BindOutput("output", out_mem);

session.Run(run_options, io_binding);

출력 형태를 미리 모를 때도 쓸 수 있다는 게 위 예제의 요점입니다. 텐서 대신 메모리 정보만 바인딩하면 세션이 필요한 형태에 맞춰 알아서 할당해 줍니다. 데이터 의존 형태나 동적 형태에서 유용한 방법입니다.

반대로 출력 형태를 알고 텐서를 재사용하고 싶으면 세션 할당자로 미리 할당해 값을 바인딩합니다.

4.5.3 언제 이득이 큰가

GPU 나 NPU EP 를 쓴다
전처리와 후처리도 같은 디바이스에서 돈다
같은 크기 입력으로 반복 추론한다
입출력 텐서가 크다   이미지나 비디오 프레임

   조건이 겹칠수록 이득이 커진다
   반대로 CPU EP 만 쓴다면 의미가 없다

4.6 EP별 설정

EP마다 자기 옵션을 받습니다. CUDA EP를 예로 들면 이렇습니다.

옵션 무엇이 달라지나
device_id 어느 GPU를 쓸지
gpu_mem_limit 이 EP가 쓸 GPU 메모리 상한. 한 GPU에 여러 프로세스를 올릴 때 필요
arena_extend_strategy 메모리 아레나를 어떻게 늘릴지. 메모리 파편화와 관련
cudnn_conv_algo_search 합성곱 알고리즘 탐색 방식. 탐색을 넓게 하면 첫 실행이 느려지고 이후가 빨라진다
cudnn_conv_use_max_workspace 합성곱에 큰 워크스페이스를 허용. 속도와 메모리의 교환
do_copy_in_default_stream 복사를 기본 스트림에서 할지
user_compute_stream 외부에서 만든 CUDA 스트림을 넘겨 준다. PyTorch와 스트림을 공유할 때
enable_cuda_graph CUDA Graph로 커널 실행 오버헤드를 줄인다. 형태가 고정이어야 한다
prefer_nhwc NHWC 레이아웃 선호
use_tf32 TF32 연산 허용. 속도와 수치 정밀도의 교환
sess = ort.InferenceSession(
    "model.onnx",
    providers=[
        ("CUDAExecutionProvider", {
            "device_id": 0,
            "gpu_mem_limit": 4 * 1024 * 1024 * 1024,
            "cudnn_conv_algo_search": "EXHAUSTIVE",
        }),
        "CPUExecutionProvider",
    ],
)

cudnn_conv_algo_search가 대표적으로 오해를 부르는 옵션입니다. 탐색을 넓게 하면 첫 몇 번의 추론이 눈에 띄게 느립니다. 워밍업 없이 벤치마크를 재면 이 탐색 시간이 결과에 섞입니다.

use_tf32 같은 옵션은 수치가 미세하게 달라집니다. 2.4의 검증에서 오차가 안 맞을 때 이런 옵션이 원인일 수 있습니다.

4.7 나머지 세션 옵션

옵션 무엇이 달라지나
enable_profiling 연산자별 지연을 담은 JSON 트레이스를 남긴다
enable_mem_pattern 메모리 패턴을 예측해 재사용. 형태가 고정일 때 이득, 동적이면 끄는 게 나을 수 있다
enable_cpu_mem_arena CPU 메모리 아레나 사용. 끄면 메모리를 아끼고 속도를 잃는다
log_severity_level 로그 상세도. EP 배정 문제를 볼 때 낮춘다
add_free_dimension_override_by_name 동적 축을 특정 값으로 고정

enable_mem_pattern의 조건이 실무에서 중요합니다. 입력 형태가 매번 달라지는 모델에서는 패턴 예측이 빗나가 오히려 손해가 날 수 있습니다.

free dimension override는 재미있는 절충입니다.

동적으로 내보낸 모델을 특정 배치 크기로 고정해 로드한다
   │
   ├──► 모델은 하나만 관리하면서
   └──► 실행 시에는 정적 형태의 최적화 이득을 얻는다

배치 크기별로 모델을 여러 벌 내보낼 필요가 없어집니다.

4.8 프로파일링

so = ort.SessionOptions()
so.enable_profiling = True
sess = ort.InferenceSession("model.onnx", so, providers=[...])

# ... 추론 여러 번 ...

path = sess.end_profiling()      # JSON 경로 반환
so.EnableProfiling("ort_profile");
// ... 실행 ...
std::string path = session.EndProfilingAllocated(alloc).get();

나오는 JSON은 표준 성능 트레이스 형식이라 크롬 계열 브라우저의 트레이싱 뷰어나 Perfetto UI로 바로 열립니다. 스레딩과 연산자별 지연이 담겨 있습니다.

여기서 보는 것은 셋입니다.

어느 연산자가 시간을 먹나        융합이 안 걸린 구간을 찾는다
CPU 로 폴백한 노드가 있나        EP 배정 실패 구간이 보인다
복사에 시간이 얼마나 쓰이나      IOBinding 이 필요한지 판단

두 번째가 특히 유용합니다. GPU EP를 지정했는데 일부 노드만 GPU로 가고 나머지가 CPU로 떨어지면, 그 경계마다 디바이스 간 복사가 일어나 전체가 느려집니다. 프로파일 없이는 안 보입니다.

배포 빌드에는 onnxruntime_perf_test 같은 도구도 함께 들어 있어 여러 옵션 조합을 빠르게 비교할 수 있습니다.

4.8.1 벤치마크할 때 틀리기 쉬운 것

워밍업을 안 한다
   첫 실행에는 메모리 할당과 알고리즘 탐색과 커널 컴파일이 섞인다
   최소 몇 번 돌린 뒤부터 재야 한다

한 번만 재고 끝낸다
   중앙값이나 p95 로 봐야 한다. 평균은 이상치에 흔들린다

비교 조건을 안 맞춘다
   스레드 수, 배치 크기, 정밀도, 전처리 포함 여부

GPU 에서 동기화를 안 한다
   비동기 실행이라 시간을 잘못 잰다

5장. 양자화

5.1 기본 수식

ORT의 양자화는 8비트 선형 양자화를 가리킵니다.

val_fp32 = scale x (val_quantized - zero_point)

비대칭   scale = (데이터 범위 폭) / (양자화 범위 폭)
대칭     scale = max(|최대|, |최소|) x 2 / (양자화 범위 폭)

zero_point 는 양자화 공간에서의 0 을 나타낸다
   부동소수점 0 이 양자화 공간에서 정확히 표현되는 게 중요하다
   많은 CNN 이 제로 패딩을 쓰기 때문이다
   0 을 유일하게 표현할 수 없으면 정확도 오차가 난다

마지막 문단이 잘 안 알려진 디테일입니다. 제로 패딩 영역에 미세한 값이 생겨 오차가 누적됩니다.

5.2 두 가지 표현 방식

QOperator (연산자 지향)
   양자화된 연산자가 각자 ONNX 정의를 갖는다
   QLinearConv, MatMulInteger 등

QDQ (텐서 지향, Quantize-DeQuantize)
   원래 연산자 사이에 DequantizeLinear(QuantizeLinear(tensor)) 를 끼워
   양자화와 역양자화 과정을 시뮬레이션한다
   정적 양자화에서는 이 두 연산자가 양자화 파라미터도 함께 담는다

둘 중 무엇인지는 Netron으로 바로 확인됩니다 (3.3.4 참고).

QDQ로 나오는 경우가 정해져 있습니다.

quantize_static 에 QDQ 형식을 지정한 경우
TensorFlow 에서 변환하거나 PyTorch 에서 내보낸 QAT 모델
TFLite 등 다른 프레임워크에서 변환된 양자화 모델

   뒤 두 경우는 별도 양자화 도구를 거칠 필요가 없다
   ORT 가 양자화 모델로 바로 실행한다

QAT로 학습한 모델을 다시 양자화 도구에 넣는 실수가 종종 나오는데 그럴 필요가 없습니다.

5.3 동적과 정적

동적 양자화   가중치만 미리 양자화, 활성값은 런타임에 계산
              캘리브레이션 데이터가 불필요해 간편
              활성값 통계를 매번 계산하니 오버헤드가 있다

정적 양자화   대표 입력으로 활성값 범위를 미리 계산
              캘리브레이션 데이터셋이 필요
              대개 더 빠르고, 데이터 대표성이 정확도를 좌우한다

앞서 효율 백본 편에서 다룬 얘기가 그대로 적용됩니다. depthwise convolution은 채널별 분포가 제각각이라 per-channel 양자화가 사실상 필수입니다. 텐서 하나로 스케일을 잡으면 채널 하나의 outlier가 전체를 망칩니다.


6장. 언제 쓰고 언제 안 쓰나

6.1 ONNX가 제값을 하는 쪽

크로스 프레임워크 이식      PyTorch 로 학습하고 C++ 이나 C# 이나 Rust 로 서빙
엣지와 모바일 배포          CoreML, NNAPI, QNN 으로 하드웨어 가속
웹 배포                    WebGPU 로 브라우저 실행
Python 런타임 제거          추론 서버에서 파이썬 의존성을 걷어낸다
비전 모델 전반             연산자 커버리지가 좋고 그래프가 정적이다

6.2 다른 선택지가 나은 쪽

대형 LLM 서빙
   GGUF 와 llama.cpp, TensorRT-LLM, vLLM 쪽 생태계가 훨씬 성숙하다
   KV 캐시와 연속 배칭 같은 LLM 특화 최적화가
   정적 그래프로는 표현이 까다롭다

연구와 실험 단계
   그래프가 굳으면 모델을 고칠 때마다 다시 내보내야 한다

동적 제어흐름이 많은 모델
   2.3.2 에서 본 대로 잘 안 나가거나 조용히 틀린다

"전부 ONNX로 통일한다"가 답이 아닙니다. 비전 모델은 ONNX, LLM은 전용 런타임으로 가는 이원 구성이 실무에서 흔합니다.

6.3 성능 튜닝 순서

손잡이가 많아서 아무거나 만지면 시간만 씁니다.

1. 의도한 EP 가 실제로 잡혔는지 확인      get_providers()
   여기가 틀리면 나머지가 다 무의미하다

2. 프로파일을 떠서 병목을 본다            4.8
   연산자 병목인가, EP 폴백인가, 복사인가

3. 복사가 병목이면 IOBinding              4.5
   비 CPU EP 에서 가장 큰 이득이 여기서 나오는 경우가 많다

4. 형태를 고정할 수 있으면 고정            4.7
   최적화 여지가 크게 늘어난다

5. 스레딩을 환경에 맞춘다                 4.4
   컨테이너 CPU 제한, 동시 세션 수, 브랜치 유무

6. 세션 생성이 느리면 오프라인 최적화       4.3.4
   단 저장 환경과 실행 환경이 같아야 한다

7. 그래도 부족하면 양자화                 5장
   정확도 검증을 반드시 함께

3번을 2번보다 먼저 하지 않는 게 요령입니다. IOBinding은 코드가 꽤 늘어나는데, 복사가 병목이 아니면 헛수고입니다.


남는 질문

  • torch.export 기반 exporter가 레거시 경로의 커버리지를 언제 완전히 따라잡을까. 전환기라 모델에 따라 옛 경로가 더 잘 되는 경우가 아직 있습니다.
  • LLM을 ONNX로 서빙하는 게 실용적이 될 수 있나. 연산자 자체는 늘고 있지만, KV 캐시 관리와 연속 배칭 같은 런타임 수준 최적화가 그래프 밖에 있다는 구조적 문제가 남습니다.
  • 동적 형태와 성능의 교환. 동적 축을 열어 둘수록 이식성은 좋아지지만 그래프 최적화 여지는 줄어듭니다. 이 균형점을 자동으로 잡아 주는 도구는 아직 없습니다.

용어 정리

용어 한 줄 뜻
ONNX 연산 그래프와 가중치를 함께 담는 개방형 모델 교환 포맷
ModelProto / GraphProto / NodeProto ONNX 파일의 최상위 / 그래프 / 노드 구조체
initializer 그래프에 담긴 가중치 텐서
opset ONNX 연산자 집합의 버전. 런타임 지원 범위와 맞춰야 한다
external data 2GB를 넘는 모델의 가중치를 별도 파일로 저장하는 방식
ONNX Runtime (ORT) ONNX 모델을 실행하는 엔진. EP를 갈아 끼우는 구조
execution provider (EP) ORT의 하드웨어 백엔드. CUDA, TensorRT, CoreML, QNN 등
그래프 분할 (partitioning) 어느 노드를 어느 EP가 맡을지 배정하는 단계
Basic / Extended 최적화 분할 전 공통 최적화 / 분할 후 특정 EP 노드에만 걸리는 융합
온라인 / 오프라인 모드 세션 생성 시 최적화 / 최적화된 그래프를 디스크에 저장
intra-op / inter-op 연산자 하나 안에서의 병렬화 / 여러 노드를 가로지른 병렬 실행
스핀 (spinning) 스레드가 일감을 기다리며 도는 동작. 지연과 전력의 교환
IOBinding 입출력을 미리 디바이스에 배치해 복사 오버헤드를 없애는 기능
free dimension override 동적 축을 로드 시점에 특정 값으로 고정하는 옵션
Netron ONNX를 포함한 여러 모델 포맷의 그래프를 보여 주는 뷰어
torch.export 기반 exporter TorchScript 추적을 대체한 PyTorch의 새 내보내기 경로
dynamic_shapes / dynamic_axes 동적 축 지정. 전자가 새 경로용, 후자는 레거시
QOperator / QDQ 양자화 표현 방식. 전용 연산자를 쓰는 쪽과 Q/DQ 쌍을 끼우는 쪽
per-channel 양자화 채널별로 스케일을 두는 양자화. depthwise에 사실상 필수

참고자료

댓글