저비용 NPU 보드를 점점 쉽게 구할 수 있다. 세 대를 사서 네트워크로 묶으면 18 TOPS 를 쓸 수 있는 걸까.
명목 TOPS는 장치 한 대가 가장 좋을 때의 숫자다. 세 대를 하나의 스케줄러 뒤에 묶었을 때 무엇이 남는지는 아무도 말해 주지 않는다. 그래서 직접 만들어서 재보기로 했다.
RK3576 보드 세 대(NanoPi R76S), 2.5GbE, 표준 gRPC. 특수 전송도 RDMA도 커널 우회도 쓰지 않았다. 워크로드는 YOLOv8n INT8. Rust로 스케줄러와 노드 에이전트를 짜고, 두 주 동안 측정했다. 유효 측정 421건, 유효 run 의 추론 오류율은 0.
결론부터 — 확장은 된다. 3노드에서 3.00배, 112.9 → 229.0 → 338.4 inf/s. 30회 반복에서 편차도 작았다.
거기까지가 쉬운 부분이었다. 이 글은 그 다음에 벌어진 일에 관한 것이다.
왜 안 늘어나지?
전송, tail 지연, 스케줄링, 열 이질성에 대해 421번의 측정이 알려준 것.
1. 운영점 밖에서 최적화하면 결론의 부호가 뒤집힌다
노드당 커넥션 수를 몇 개로 할지 정해야 했다. 부하를 고정하고(concurrency 32) 커넥션 1개와 4개를 비교했다. 결과는 명확했다 — 커넥션을 늘리면 tail latency가 46% 나빠진다. 그래서 1개로 가기로 했다.
그런데 뭔가 걸렸다. c32가 정말 적절한 부하인가?
포화 곡선을 다시 그려 봤다. c24부터 c64까지 전 구간이 과부하였다. 처리량은 이미 평평하고 지연만 올라가는 구간. 세 구성 모두에게 과부하였다.
운영점(peak의 98%를 내는 가장 낮은 concurrency, c12)에서 다시 쟀다.
c32 고정 (과부하) 커넥션 4개 → tail 46% 악화
c12 운영점 커넥션 2개 → tail 18.8% 개선
부호가 뒤집혔다. 앞선 측정이 틀린 게 아니다. 정확히 맞았고, 다만 configuration effect가 아니라 overload behaviour를 재고 있었다.
과부하 데이터는 지우지 않았다. "과부하 거동"이라는 별도 결과로 남기고, 운영 판단에는 쓰지 않기로 했다.
최종 운영점은 노드당 커넥션 2개 @ concurrency 12/node. 3노드 387.2 inf/s, 기존 대비 +13.3%.
그런데 여기서 하나가 더 나왔다. scaling efficiency는 98.9%에서 95.3%로 내려갔다.
절대 처리량은 오르고 확장 효율은 내려간다. 둘이 반대로 움직인다. 하나만 인용하면 트레이드오프가 숨는다. 그래서 이 저장소는 항상 둘 다 적는다. 측정 조건을 붙여서.
2. 남은 손실은 tail에서 나타났다
3노드 효율 95.3%. 손실 4.7%는 어디로 갔나.
서버 쪽부터 의심했다. CPU, 메모리, 10G 링크, 스케줄러 큐 — 전부 배제됐다. 스케줄러 큐 대기는 0.000ms, 라우팅은 0.004ms였다.
노드별 지연을 노드 수에 따라 그렸더니 답이 나왔다.
1노드 → 3노드
p50 85.95 → 85.65 ms +0% 평평하다
p95 118.79 → 153.64 +23%
p99 136.83 → 197.75 +36%
중앙값은 평평했다. p99만 올랐다. Little's law로 확인하니 효율 손실이 평균 지연 증가와 정확히 일치했다. 손실은 처리 능력이 아니라 꼬리에 있다.
3. 같은 온도, 다른 결과
엣지 디바이스는 보통 팬이 없다. 그래서 지속 부하를 31분씩 두 조건으로 쟀다.
능동 냉각 peak 387.7 → steady 380.3 −1.9%
팬리스 peak 389.4 → steady 345.4 −11.3%
예상대로였다. 팬리스에서 열화가 크다. 그런데 그다음이 예상 밖이었다.
팬리스 조건에서 노드별 지연을 봤더니 king 한 대만 다른 둘보다 2.4배 느렸다. 열 편차 때문이라고 생각했다. 그러면 부하 인지 스케줄링이 이걸 흡수할 수 있어야 한다. 정책 A/B를 돌렸다.
1차 결과는 1.33배 편차. 2.4배가 재현되지 않았다. "덜 뜨거워서겠지" 싶어 예열을 25분으로 늘리고 연속 가열을 강화했다.
1.10배가 나왔다. 더 낮아졌다.
여기서 계기를 의심했다. 하네스가 run이 끝난 뒤 온도를 읽고 있었는데, RK3576은 부하가 끊기면 수 초 만에 식는다. 1초 간격 열 로거로 다시 집계했다.
| 실험 | 하네스가 적은 온도 | 실제 부하 중 최대 | CPU 클럭 p50 | 지연 편차 |
|---|---|---|---|---|
| S0-A | 85.9~86.8 ✓ | 86.8 / 85.9 / 86.8 | 1008 / 1800 / 1800 | 2.40× |
| 2차 | 78~79 ✗ | 86.8 / 85.9 / 86.8 | 1200 / 1800 / 1800 | 1.33× |
| 4차 | 81 / 80 / 80 ✗ | 86.8 / 85.9 / 86.8 | 1416 / 1608 / 1608 | 1.10× |
세 실험의 SoC 최고 온도가 거의 같았다(~86°C). 그런데 편차는 1.10× 에서 2.40× 까지 갈렸다.
온도만으로는 이질이 정해지지 않는다. 함께 움직인 것은 CPU 강등이 보드마다 갈리는 정도였고, 열 제어는 온도를 목표로 하지 편차를 목표로 하지 않는다. 세 보드가 같은 온도에서 같이 내려가면 이질은 생기지 않는다.
열 조건은 이질의 필요조건이지 충분조건이 아니다. 그리고 열 제어는 그 갈림을 재현하는 결정론적 수단이 아니다.
"연속 가열하면 재현된다"는 처방은 애초에 들을 수 없는 처방이었다.
그래서 열 제어가 쓰는 손잡이를 직접 잡기로 했다. scaling_max_freq. king의 CPU를 사다리로 캡하며 편차를 쟀다.
캡(MHz) 2208 1608 1200 1008 816 600
편차 1.12 1.18 1.33 1.79 2.26 3.93
캡 816 MHz가 S0-A를 세 노드 지연 6ms 이내로 재현한다. 30분 예열도 실리콘 운도 필요 없다. 이질이 다이얼이 됐다.
4. 스케줄러가 낡은 상태를 보고 몰려가고 있었다
부하 인지 정책(least-queue, ECT)을 켜자 처리량이 55~58% 붕괴했다. round-robin보다 한참 나빴다.
"부하 인지 정책은 이 워크로드에 안 맞는다"고 쓸 뻔했다.
그런데 55%는 품질 차이의 크기가 아니다. 그건 고장의 크기다. 구현을 봤다.
정책이 보는 상태가 하트비트로만 갱신되고 있었다. 하트비트 간격은 1초, 추론은 90ms. 결정 순간의 상태는 이미 낡아 있었고, 그래서 모든 결정이 같은 "한가한" 노드로 쏠렸다 — herding.
로컬 in-flight 카운터로 바꿨다.
p99 232.0 → 146.9 ms −37%
노드 지연 편차 1.33× → 1.00×
CPU 사용률 편차 10.3%p → 3.1%p
기본 설정에 있던 버그였다. 정책 A/B를 돌리지 않았으면 못 찾았다.
성능이 이상하면 "이 접근이 나쁘다"보다 "구현이 의도대로 도는가"를 먼저 본다.
5. io_uring을 만들려다가, 재보고 안 만들었다
계획은 이랬다. CPU 프로파일 → syscall·복사 비용 측정 → 버퍼 풀 → io_uring. 스펙에 그렇게 적혀 있었다.
앞의 둘을 했다.
운영점에서 요청당 노드 CPU를 유저/커널로 갈랐다. 커널 시간에 syscall 진입·TCP 스택·copy_to_user가 들어가고, 유저 시간에 protobuf 직렬화· 유저공간 copy·HTTP/2 프레이밍이 들어간다. io_uring이 줄이는 것은 커널 쪽 일부다.
transport 비용 16.35 CPU-ms/req
유저 9.37 (57%) 직렬화 · 유저공간 copy · HTTP/2
커널 6.99 (43%) syscall 진입 · TCP 스택 · copy_to_user
네트워크 syscall 요청당 약 165회
syscall 진입 비용 0.165 ms = transport 비용의 1.0%
부하 중 보드 CPU 48.9% idle, 포화 코어 없음
등록 버퍼로 1.2MB copy를 양방향 모두 없앤다고 가정해도 합계는 약 8%다.
그리고 그 8%를 다 회수해도 처리량은 안 오른다. 보드가 절반 놀고 있기 때문이다.
CPU-ms/req는 비용이지 제약이 아니다. 포화되지 않은 자원의 사용량을 줄이는 것은 처리량을 올리지 않는다.
만들지 않았다. 그리고 그 판단을 커밋 로그가 아니라 스펙에 적었다. 스펙의 §15.3에는 원래 이런 목록이 있었다 — "다음 조건에서는 io_uring을 적용하지 않을 수 있다: gRPC 직렬화가 더 큰 병목 / 개선이 5% 미만…"
둘 다 걸렸다. 측정 전에 적어 둔 탈출 조건이 실제로 발동한 것이다.
계측기가 우리를 오도한 여섯 번
이게 이 프로젝트에서 제일 오래 남을 부분 같다. 여섯 건 모두 저장소 실험 대장 §4.13 에 이름과 발견 경위가 있다.
- run 종료 후 온도 샘플링 — 실제보다 5°C 낮게 읽었다.
max_soc_c라는 이름이었는데 최대값이 아니었다. 이 값 위에 "덜 뜨거워서"라는 설명을 세웠고, 그게 틀렸다. - strace 컬럼 오독 — 파서가
usecs/call과calls를 뒤바꿔 읽어 호출 수가 100배 작게 나왔다. "strace가 한 스레드에만 붙었으니 검정 무효"라고 판단할 뻔했다. 측정이 아니라 파서가 틀렸다. - 13.2%가 과부하 구간 값이었다 — 잔여 손실을 13.2%로 여러 문서에 적었는데, 그 백분율은 c32(과부하)에서 나온 값이었고 운영점 숫자와 짝지어져 있었다. 실제는 16.1%다.
- 하네스 충돌 — 중단한 줄 알았던 하네스가 살아남아 새 하네스와 같은 클러스터를 각각 c36으로 때렸다. 기준선이 197 inf/s(정상 391), 다음 run은 오류율 82%. 클러스터 고장으로 오진하기 직전이었다.
- 결과 경로 덮어쓰기 — 같은 날짜 경로를 재사용해 15 run짜리 데이터를 4줄로 덮어썼다. git이 추적 중이라 복원했다.
- 비밀 검사 도구의 맹점 — 평문 비밀번호를 찾는 스크립트가 정작 문서에 산문으로 적힌 비밀번호를 못 잡았다. "비밀번호는 코드로 샌다"는 전제로 만들어졌는데, 이 저장소는 문서가 훨씬 많다.
여섯 개의 공통점이 있다. 전부 "성공처럼 보였다." 숫자가 나왔고, 그럴듯했고, 아무도 멈추지 않았다.
계측기의 출력이 예상과 다르면 계측기부터 의심한다. 그리고 판정 임계를 결과에 맞춰 옮기는 것과, 임계를 재는 계기가 다른 물리량을 재고 있었음을 고치는 것은 다른 일이다. 후자를 할 때는 "기준은 그대로 두고 출처만 바꿨다"고 문서에 적는다.
주장하지 않는 것
먼저 적는 편이 낫다.
- 노드가 세 대뿐이다. 4노드 이상에서 유지되는지 모른다.
- 반복 수가 작다. 구성당 3~4 run이 많다. 그래서 처리량 1% 미만 차이는 우열 근거로 쓰지 않았다.
- percentile은 run-level 평균이다. pooled가 아니다 — 각 run의 최악 구간이 희석돼 tail이 낮게 보인다. 조건 비교에는 유효하지만 절대값을 "이 시스템의 p99"로 인용하면 안 된다.
- 잔여 16.1%의 정체는 미확정이다. 로컬 direct 161.5 대비 운영점 135.5. CPU 비용이 아니라 경로 지연으로 보이지만(1.2MB 왕복 전송만 8.2ms) 특정하지 못했다.
- 보드에
perf가 없다. 벤더 커널이라 심볼 단위 프로파일이 불가능 했고,/proc/PID/stat의 utime/stime 분리로 대신했다.
그래서
6 + 6 + 6은 18이 되는가. 처리량 기준으로는 거의 그렇다 — 3.00배.
하지만 그 숫자에 도달하기까지 막은 것은 대역폭도 명목 연산량도 아니었다. 전송 운영점, tail latency, 열이 아닌 클럭 편차, 그리고 우리가 직접 심어 놓은 스케줄러 버그였다.
그리고 정작 제일 자신 있게 만들려던 것(io_uring)은 재보니 만들 이유가 없었다.
유효 측정 421건, 오류율 0. 전부 공개한다 — 원본 데이터, 하네스, 실패 목록, 뒤집힌 결론까지.
- 저장소: https://github.com/seongkyounyoo/npudure
- 실험 대장:
docs/experiments/README.md— 무엇을 묻고 무엇이 배제됐는지 - 방법론: 같은 문서 §4
NPUDure는 Apache-2.0으로 공개된다. RKNN Runtime과 SDK는 포함되지 않으며 벤더 경로에서 별도 설치가 필요하다.