Posts NPU 배포를 위한 ONNX 그래프 린팅 규칙
Post
Cancel

NPU 배포를 위한 ONNX 그래프 린팅 규칙

컴파일이 몇 시간 뒤에 실패하는 것을 막기

NPU용 컴파일러는 대체로 느리고, 실패 메시지가 불친절하다. 그래프 어딘가에 지원되지 않는 연산이 하나 있으면 빌드가 한참 돌다가 “unsupported node”만 뱉고 끝난다.

그래서 export 직후에 ONNX 그래프를 정적으로 훑는 린터를 앞에 붙였다. 컴파일 전에 그 자리에서 잡는 것이 목적이다.

1
2
3
4
5
6
7
8
9
10
11
12
PyTorch nn.Module
   │ export_onnx.py      정적 shape, 후처리를 그래프 밖으로
   ▼
ONNX (FP32) ─────────────►  정답 기준: onnxruntime (PC)
   │ rules.py            NPU-safe 린터 — 금지 op 스캔, 컴파일 전 차단
   │ calibration.py      층화 추출 캘리브레이션셋
   │ compile.py          INT8 PTQ
   ▼
NPU 실행 파일
   │ parity_test.py      정확도 게이트 — 실패 시 배포 차단
   ▼
모델 레지스트리

규칙 목록

ERROR는 배포를 차단하고 WARN은 기록만 한다.

규칙무엇을 막는가심각도
R001a그래프 입출력의 동적 축(symbolic dimension)ERROR
R001b배포 그래프의 batch ≠ 1ERROR
R002NonMaxSuppression, TopK, ArgMax, ArgMin, GridSample, DeformConv, RoiAlign, NonZero, Loop, If, ScanERROR
R003Conv/ConvTranspose의 공간 축이 3차원 이상ERROR
R004conv 출력 채널이 8의 배수가 아님WARN
R005activation이 허용 목록(Relu, Clip, HardSigmoid, HardSwish) 밖ERROR
R006LayerNormalization, GroupNormalization, InstanceNormalization, LpNormalizationERROR
R007Resize/Upsample 보간이 nearest가 아님ERROR

각 규칙에는 이유가 하나씩 붙는다.

  • R002는 출력 크기가 입력에 따라 달라지는 연산들이다. NPU는 정적 메모리 배치를 전제하므로 통째로 그래프 밖 CPU 후처리로 뺀다.
  • R003은 3D conv를 막는다. 시간축 모델링이 필요하면 3D conv 대신 Temporal Shift Module 같은 방식을 쓴다.
  • R006은 정규화 계층 중 conv에 fold되지 않는 것들이다. BatchNorm은 추론 시 conv 가중치에 접히지만 LayerNorm은 안 접힌다.
  • R007은 이식성 때문이다. bilinear 리사이즈는 가속기마다 구현이 미묘하게 달라 같은 그래프가 하드웨어를 바꾸면 다른 값을 낸다.
  • R004만 WARN인 이유는 타일링 효율 문제일 뿐 동작은 하기 때문이다.

알려진 구멍 하나

_check_conv_rankConv, ConvTranspose만 검사한다. 그래서 kernel_shape가 3차원인 MaxPool/AveragePool은 모든 규칙을 통과한다. R003이 잡으려던 것과 정확히 같은 부류의 위험이 빠져나간다.

린터를 붙일 때 이런 구멍은 반드시 생긴다. 중요한 것은 구멍을 발견했을 때 문서에 남기는 것이다. “3D 풀링을 쓰는 모델을 설계할 계획이 있으면 이 구멍을 먼저 메워야 한다”고 적어두면, 다음 사람이 린터가 통과했다는 이유로 안심하지 않는다.

규칙 하나를 둘로 쪼갠 이유

원래 R001은 하나였다. “배치는 반드시 1.” 그런데 연산자 프로브 24개 중 Temporal Shift Module 하나가 여기 걸렸다. TSM의 입력은 (N*T, C, H, W)라 시간축 T개 프레임이 배치 축에 들어온다.

여기서 핵심 통찰이 하나 나왔다. 고정된 배치 T도 여전히 정적 shape이다. 실제로 필요한 것은 “동적 축이 없을 것”이지 “배치가 반드시 1일 것”이 아니었다.

원래 규칙은 스펙 문언 그대로 구현됐을 뿐이므로 린터 버그가 아니라 스펙 결함이었다. 그리고 그 결과, 스펙이 자기가 채택한 구조(TSM)를 스스로 금지하는 모순이 생겼다.

여기서 구분 하나가 더 필요했다. 연산자 프로브 그래프와 실제 배포 그래프는 다른 것이다. 프로브는 하드웨어 지원 여부를 측정하려고 만드는 진단용 그래프이지 제품에 실려 나가는 그래프가 아니다. 배포 규칙으로 진단 그래프를 검사한 것 자체가 범주 오류였다.

그래서 이렇게 나눴다.

  • R001a — 동적 축 금지, 면제 없음
  • R001b — 배포 그래프의 batch=1, 진단 그래프에 한해 면제
1
lint(model, allow_batched_input=True)   # 진단 그래프에서만 명시적으로 요청

기본값은 False다. 기본이 면제면 배포 그래프가 조용히 통과해버린다.

Sigmoid를 그래프 밖으로 빼기

허용 activation 목록에 Sigmoid가 없는데, 검출기 헤드는 objectness와 클래스 점수에 Sigmoid를 쓴다. 그대로면 R005에 걸린다.

두 가지 안이 있었다.

  1. 프로브에 Sigmoid 케이스를 추가하고 측정 결과로 허용 목록을 확정한다
  2. 검출기 헤드의 Sigmoid를 NMS와 함께 CPU 후처리로 분리한다

2안을 택하고 1안도 함께 했다. 2안의 근거는 이론이 아니라 이미 동작하는 구조와의 일관성이었다. 랜드마크 쪽 스테이지가 이미 같은 패턴을 구현하고 검증해둔 상태였다. ONNX 그래프는 로짓을 그대로 내보내고 런타임이 sigmoid를 적용한다. ArgMax도 마찬가지로 그래프 밖에서 디코딩한다.

1안을 같이 한 이유는, 어느 안을 택하든 “activation 허용은 측정으로 확정한다”는 규정과 “Sigmoid가 측정 없이 금지되어 있다”는 자기모순은 남기 때문이다.

이 결정에 붙는 비용

그래프 밖으로 뺀 만큼 런타임이 그 계산을 떠맡는다. sigmoid 적용, 랜드마크 디코딩, bbox 디코딩(dist2bbox 상당), letterbox 역변환이 전부 C++ 쪽 코드로 존재해야 한다.

그래서 모델을 바꿀 때마다 “그래프에 무엇이 남고 무엇이 CPU로 가는지”“그 CPU 쪽 대응 코드가 있는지”를 함께 확인해야 한다. ONNX 파일만 봐서는 이 비용이 전혀 드러나지 않는다. 새 모델을 붙였는데 출력이 이상하면 대개 여기다.

이 규칙들이 서 있는 기반을 정확히 말하기

이 부분이 가장 중요하다.

위 규칙과 결정은 전부 ONNX 그래프의 정적 구조onnxruntime(CPU)에서 그래프가 받아들여지는지를 근거로 세워졌다. 실제 NPU 하드웨어 실측이 아니다.

그래서 지원 여부 표 상단에 이렇게 적어뒀다.

이것은 onnxruntime 기준선이지 NPU 지원 여부가 아니다. 여기서 컴파일이 성공했다는 것은 onnxruntime이 CPU에서 그래프를 받아들였다는 뜻이고, 대상 NPU가 그 연산자를 지원하는지에 대해서는 아무것도 말하지 않는다.

이 문장을 안 적어두면, 몇 달 뒤 누군가 이 표를 인용하며 “NPU가 지원한다”고 말한다. 그리고 그 전제 위에 설계가 쌓인다.

린터 규칙은 “이렇게 만들면 안전할 것”이라는 설계 원칙이지 “이렇게 만들었더니 실제로 컴파일됐다”는 확인이 아니다. 둘의 차이를 문서에 명시하는 것이 규칙을 만드는 것만큼 중요했다.

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

INT8 양자화는 정확히 어디서 정확도를 잃는가

멀티태스크 학습에서 불확실성 가중이 무너진 과정