windows driver npu convert_scores 버그가 아니었다

wangki·2026년 5월 18일

windows_driver_npu

목록 보기
8/15

이전 포스팅에서 추론 버그라고 했던 convert_scores의 충격적인 사실을 발견했다.
아무리 Byte-identical로 바이트 단위로 정말 많은 CSR을 비교했다. input data의 CRC32를 비교했더니 틀렸다. 동작하는 libedgetpu의 input data를 binary file로 뽑아내서 npu driver에서 로드하는 방식을 사용하기도 했다.

그렇다면 어떻게 convert_scores 값이 쓰레기 값이 아닌지 알 수 있었던 내용을 작성해 보겠다.


libedgetpu의 output buffer

당연히 추론 이후의 output buffer의 Byte-identical을 진행해서 다른 내용을 확인했기에 개발 중인 npu driver의 output buffer에 쓰레기 값이 들어왔을 것이라고 판단한 것이다.

테스트를 하기 위해 가설을 세웠다.

혹시 exe0의 param data의 값이 libedgetpu에서 wrong 값이 들어간다면?

테스트를 위해서 exe0의 param 값에 0xcc값을 채워서 빌드 후 테스트했다. 결과가 너무 충격적이라서 소리를 질렀다.

우리의 convert_scores와 비슷한 패턴으로 나오는 것이었다. 뭐지 싶었다.
그러면 우리가 넣어주는 exe0의 param 값이 정상적으로 안 들어가는 건가?라는 생각을 했다.

좀 이상해서 Squeeze1 값도 비교해 보기 위해서 dump를 찍어서 확인했더니 동일한 것이다!! 그러면 우리가 비교했던 값은 무엇인가?라는 생각이 들었다.


convert scores는 잘못이 없었다.

libedgetpu로 로그만 추가한 상태로 추론을 해보았다. 정상적으로 동작이 되었고 로그를 확인하니 여전히 우리와 같은 값이 나오는 것이었다.

그러면 npu driver가 정상적으로 추론 값을 받고 있는 건가? 라는 생각이 들었다.
동시에 Squeeze1과 convert_scores 값을 해석하는 방식이 잘못된 건가? 라는 생각을 했다.

그러면 convert_scores 값은 유의미한 값이고 full dump를 찍어보자

  [uniform] convert_scores: 2025 / 2034 anchors match 'ff 00 80 80'
  [diff-anchor] idx=143  : ff 01 80 80
  [diff-anchor] idx=146  : ff 01 80 80
  [diff-anchor] idx=200  : fb 05 80 80
  [diff-anchor] idx=203  : 01 ff 80 80   ← ★
  [diff-anchor] idx=206  : 02 fe 80 80   ← ★
  [diff-anchor] idx=263  : 80 80 80 80
  [diff-anchor] idx=266  : 80 80 80 80
  [diff-anchor] idx=1278 : f6 0a 80 80
  [diff-anchor] idx=1284 : f6 0a 80 80

full dump를 찍었더니 4byte 씩 패턴을 보였고 다른 것들이 발견되었다.
0xff / 256 = 0.996은 pycoral의 detection score 0.9961과 byte-identical 하다는 것을 발견했다. 즉 head 256 byte만 보고 다 균일이다 = 망가졌다라고 잘못 읽었던 것이다. 2034 anchor 중 9 개만 의미 있는 값을 갖고 있었고, 그 9개가 head 256 byte 안에 없었던 것뿐이다.

chip은 처음부터 정확한 답을 보내고 있었다.


그러면 box 좌표는 어디서 오는가

score는 byte 자체에서 풀렸다. 그런데 detection 결과의 box 같은 좌표는 chip output 어디에도 명시되어 있지 않다. Squeeze1의 byte를 dequant 해도 그냥 4개의 float일뿐 픽셀이 안 나온다.

anchor 0 의 Squeeze1: 94 88 73 4b
dequant: dy=+0.43, dx=-0.87, dh=-3.14, dw=-7.47 이게 픽셀이 아니면 어떤 보정값 같은데, 출발점이 되는 좌표가 chip output에 없다.

그러면 box 좌표 만들기에 chip output 외에 더 필요한 정보가 있는 거 아닌가?


tflite graph 까보기

chip 다음에 어떤 op가 실행되는지 알아야 했다. tflite schema.fbs 를 받아서 flatc로 모델을 JSON으로 변환한 후, op chain만 추출하는 extract_op_chain.py 를 작성해서 돌렸다.

  op[0] 'edgetpu-custom-op'
     OUT: 2(Squeeze1), 4(convert_scores)
  op[1] 'DEQUANTIZE'
     IN : 2(Squeeze1)         OUT: 3(Squeeze2)
  op[2] 'DEQUANTIZE'
     IN : 4(convert_scores)   OUT: 5(convert_scores1)
  op[3] 'TFLite_Detection_PostProcess'
     IN : 3(Squeeze2), 5(convert_scores1), 1(anchors)   ← ★
     OUT: 6, 7, 8, 9

tflite 모델 파일(.tflite)은 일종의 연산 설계도(Graph)인데, 이걸 JSON으로 변환해서 열어보면 모델 내부에서 데이터가 어떤 순서로 흐르고, 어떤 Op들이 체인처럼 엮여서 실행되는지 그 지도를 100% 명확하게 까볼 수 있다.

  • 0단계: PCIe 가속기 칩이 담당한 연산
  • 1단계 & 2단계: 칩이 내뱉는 무식한 바이트 데이터들을 cpu가 알아들을 수 있게 부동소수점으로 변환하는 과정
  • 3단계: 대망의 최종 후처리 단계

사실 tflite model에 대해서 깊이 있게 아는 것이 아니라 정확히는 모르겠다. 하지만 찾아본 결과 2,034개의 anchor 박스마다 각각 4개(x,yu,w,h)의 기본 규격 좌표를 미리 기억하고 있는 고정된 데이터 테이블이 있다고 한다. buf=1의 의미는 모델 안에 const data가 들어있다는 의미라고 보면 된다.

즉, 최종 얼굴 사각형 박스는 칩이 주는 게 아니라, 모델 파일에 적혀 있던 2,034개의 고정 앵커 좌표에다가 칩이 실시간으로 뱉어낸 미세 조정값(Squeeze1)을 수학적으로 합성해서 cpu가 만들어내고 있었던 것이다!!!!


anchors와 quant params 추출

standalone으로 우리가 만든 c++ 모듈에서 동작시키기 위해서는 3개의 const 값이 필요하다.
1. anchors의 실제 byte (2034 * 4 float32 = 32544 byte)
2. Squeeze1의 quant params (scale, zero_point)
3. convert_scores의 quant params

extract_postproc_deps.py를 ai 도움을 받아 작성하여 한 번에 추출했다.

# 결과 
 anchors.bin               32544 bytes float32
 Squeeze1       scale=0.10822763  zp=144
 convert_scores scale=0.00390625  zp=0
 anchor[203]    = [yc=0.175, xc=0.375, h=0.2828, w=0.1414]

anchor[203] 좌표를 픽셀로 풀면 (yc=56, xc=120, h=90, w=45) 의 세로 직사각형 — karina 얼굴 근처에 미리 정의된 prior box.


anchor + Squeeze1의 합성

표준 SSD MobileNet 의 box decode 공식이 있다. 이걸 그대로 사용했다.

  ycenter = anchor.yc + (dy / 10.0f) * anchor.h;
  xcenter = anchor.xc + (dx / 10.0f) * anchor.w;
  h       = exp(dh / 5.0f) * anchor.h;
  w       = exp(dw / 5.0f) * anchor.w;

  out[0] = ycenter - h / 2;   // ymin
  out[1] = xcenter - w / 2;   // xmin
  out[2] = ycenter + h / 2;   // ymax
  out[3] = xcenter + w / 2;   // xmax

이 공식의 수학적 의미를 깊게 알지는 못한다. 그냥 anchor의 좌표에서 chip이 보낸 보정값만큼 조정해서 진짜 box를 만든다 정도로 이해하고 사용했다. 아직 공부할게 너무 많다..

계산 결과로는 다음과 같다.

  ycenter = 0.175 + 0.022 * 0.2828 = 0.181
  xcenter = 0.375 + 0.130 * 0.1414 = 0.393
  h       = exp(-0.108) * 0.2828   = 0.254
  w       = exp(0.433)  * 0.1414   = 0.218

  box (normalized) : (0.054, 0.284) - (0.308, 0.502)
  box (320x320 px) : (90, 17) - (160, 98)

opencv로 시각화

driver가 만든 box는 320*320 model space의 normalized 좌표이다. 원본 이미지에 그리려면 추론 시 letterbox 전처리를 역으로 풀어야 한다.

scale = min(320 / srcW, 320 / srcH)
newW = int(srcW scale)
newH = int(srcH
scale)
x0 = int(round(max(0, min(d.xmin 320, newW)) / scale))
y0 = int(round(max(0, min(d.ymin
320, newH)) / scale))
...
cv2.rectangle(img, (x0, y0), (x1, y1), (0, 0, 255), 2)


결과

눈물이 났다.


결론

4월 말부터 이 프로젝트를 했으니 약 3주가 넘게 흘렀다. 처음 해보는 영역이라서 너무 어려웠고 방향성을 몰랐지만 그래도 첫 번째 목표인 추론에 성공하였다.

지금은 특정 model에 대해서 의존성이 강하게 만들었는데 범용으로 사용할 수 있도록 runtime을 만들 예정이다. 또한 ai agent의 도움을 많이 받았는데 모르는 부분을 공부로 채워나가야겠다.

https://github.com/wangki-kyu/npu_driver
https://github.com/wangki-kyu/libedgetpu

0개의 댓글