
Qualcomm NPU 컴파일러 역공학 (Reverse Engineering)
요약
Qualcomm NPU의 성능을 극대화하기 위해 SDK를 역공학하여 VTCM 메모리 배치 및 최적화 알고리즘을 분석한 기술 보고서입니다. HTP의 MILP 기반 배치 방식과 숨겨진 시뮬레이터 Hextimate의 존재를 밝혀냈습니다.
핵심 포인트
- Qualcomm HTP는 MILP를 사용하여 VTCM 배치를 최적화함
- 재귀적 백트래킹 할당기를 통한 3D 좌표 공간 배치 방식 사용
- 메모리 압박 완화를 위해 컴파일러가 가중치 정밀도를 자동 변경함
- Hextimate 시뮬레이터를 통해 루프라인 및 경합 모델 복구 가능
저의 업무는 우리가 실행하고자 하는 어떤 모델이든 엣지 배포 (edge deployment)를 더 빠르게 만들기 위해 NPU의 사용을 극대화하는 것입니다. 하지만 웹상에 있는 NPU 문서는 기본적으로 존재하지 않으며, 그나마 나와 있는 적은 정보들도 너무 실망스러워서 한때는 포기할까 생각하기도 했습니다. 그래서 저는 대신 컴파일러를 역공학 (reverse engineered)했습니다. 이전에 NPU가 무엇인지, 그리고 어디서 문제가 발생하는지에 대한 짧은 입문서를 작성한 적이 있는데, 다음에 이어질 내용을 이해하기에는 충분할 것입니다.
Qualcomm은 어떤 SoC에 대해서도 VTCM의 메모리 용량을 공개하지 않았습니다. 제 텐서 (tensors)가 사방으로 넘쳐흐르고 있는지(spilling), 혹은 양자화 (quantisation)가 정말 필요한지를 제가 어떻게 알 수 있겠습니까? 여기에 실제 하드웨어에서 실행되기 전 모델의 동작을 어떻게 시뮬레이션하는지, 그리고 어떤 최적화 알고리즘 (optimization algorithms)이 관여하는지에 대한 저의 호기심까지 더해졌습니다.
저는 (Claude Code를 활용하여) QNPU SDK (v2.46.0.260424)의 *.so 파일들을 집중적으로 분석했으며, 스트리핑 (stripping) 과정에서도 살아남은 언맹글드 네임 (unmangled names), Ghidra로 디컴파일 (decompiled)된 로우 머신 코드 (raw machine code), 그리고 제 Linux 환경에서의 몇 가지 경험적 파라미터 스위핑 (empirical parameter sweeping)에 의존했습니다.
몇 가지 새로운 발견 사항은 다음과 같습니다 (어차피 전체 글을 다 읽을 수 있는 집중력을 가진 사람은 없겠지만요):
- HTP는 VTCM 배치를 MILP (혼합 정수 선형 계획법)로 해결하며, 공개적으로 전혀 알려지지 않았던 HiGHS (휴리스틱이 아닌 최적화 솔버)를 사용하여 이를 해결합니다.
- VTCM 배치는 3D 좌표 공간에서 작동하는 재귀적 백트래킹 할당기 (recursive backtracking allocator)를 사용합니다.
- 컴파일러는 메모리 압박을 완화하기 위해 배치 과정에서 (사용자가 모르는 사이에) 가중치 정밀도 (weight precision)를 자동으로 변경할 수 있습니다.
- 유효한 fit/spill 경계는 서로 다른 SoC가 동일한
vtcmSize를 보고하더라도 타겟 아키텍처 (target architecture)에 따라 달라집니다. - HTP에는 Hextimate라고 불리는 숨겨진 분석 시뮬레이터 (analytical simulator)가 포함되어 있으며, 이를 통해 우리는 루프라인 방정식 (roofline equations)과 경합 모델 (contention models)을 복구했습니다.
부록에는 본문에 다 담지 못한 더 많은 정보가 포함되어 있습니다.
이것이 핵심이며, 이 글의 나머지 부분에서는 제가 발견한 세 가지 사항을 다룰 것입니다. 이 사항들은 인터넷 어디에도 공개적으로 문서화된 적이 없다고 생각하며, Qualcomm NPU에서 엣지 배포 (edge deployment)를 수행하려는 모든 이들에게 도움이 될 것입니다.
메모리 벽 (The memory wall)
Hexagon 칩에는 VTCM (vector tightly coupled memory)이라고 불리는 작은 온칩 스크래치 메모리 (on chip scratch memory) 풀이 있습니다. 반대편에는 메인 메모리인 DDR이 있지만, 이는 느립니다. ML 추론 (ML inference)에서 발생하는 중대한 병목 현상 (bottleneck)은 데이터를 이동시키는 데 드는 비용에 의해 발생합니다. 이 경우 컴파일러 (compiler)의 전체 작업은 매 순간 무엇을 VTCM에 머물게 할지 결정하는 것입니다. 왜냐하면 VTCM에 들어가지 않는 모든 것은 DDR로 내보내졌다가 나중에 다시 가져와야 하는데, 이는 매우 비용이 많이 들고 에너지 효율이 낮기 때문입니다.
모델의 모든 텐서 (tensor)에는 수명 (lifetime)이 있습니다. 실행 중 어느 순간에도 특정 텐서 세트가 살아 있습니다 (working set이라고 부를 수 있습니다). 만약 이것이 VTCM에 들어간다면 모든 것이 빨라질 것입니다. 그렇지 않다면, 컴파일러는 스필 작업 (spill operations, 텐서를 DDR로 밀어내기)과 필 작업 (fill operations, 다시 가져오기)을 삽입하기 시작할 것입니다. 이것이 제가 알아내고자 했던 임계점 (cliff)입니다.
동일한 모델 (Qwen 0.8B)을 사용하여 SM8350에서 테스트했을 때, 컴파일러는 5.46 MB의 스필 (spilling)과 33.9 MB의 필 (filling)을 보고했으며, 총 DDR 사용량은 37.9 MB였습니다. 반면 SM8650 (V75)에서는 아무것도 스필되지 않았고 DDR 읽기(read)는 1.15 MB였습니다. 타겟 칩에서 아무것도 발생하지 않다가 33배의 DDR 읽기 트래픽 점프가 발생한 것입니다 (이는 예상된 결과입니다). 하지만 특이하게도, 컴파일된 출력에서 두 칩은 동일한 VTCM 크기를 보고했습니다. 단순히 '4'라고 적힌 필드입니다. 저는 이 '4'가 '무엇'인지, 혹은 어떤 코드인지 알지 못합니다. 어쨌든 동작 방식은 완전히 다릅니다. 저는 실제 용량 (capacities)을 복구하지는 못했으며, 그것은 제가 다음에 하고 싶은 작업입니다.
컴파일된 바이너리에는 spillFillBufferSize라고 불리는 메타데이터 필드가 포함되어 있으며, 이 값이 0이면 모델 가중치 (weights)가 칩에 완전히 들어간다는 것을 나타냅니다. 이는 자신의 추론이 왜 느린지에 대한 인과 관계를 빠르게 파악하려는 모든 이들에게 도움이 될 수 있습니다.
이제 타겟 칩이 SM8350이라면, 추측하며 시간을 허비하는 대신 확신을 가지고 모델 양자화 (Quantising)에 시간을 투자할 수 있습니다.
적합 여부를 결정짓는 한 가지 요소가 더 있으며, 그 부분은 조금 더 흥미롭습니다.
스케줄러가 시간으로 테트리스를 하는 방식
칩이 연산을 실행하는 순서는 각 텐서 (Tensor)가 얼마나 오래 유지될지를 결정하며, 이는 워킹 셋 (Working set)의 크기를 결정하고, 결과적으로 성능 절벽 (Cliff)에 부딪힐지 여부를 결정합니다. 따라서 컴파일러 스케줄러 (Compiler's scheduler)의 과제는 워킹 셋을 최소화하여 VTCM의 제한 범위 내에 유지할 수 있는 올바른 순서를 찾는 것입니다.
스케줄러는 "우선순위 BFS (Priority BFS)"라고 불리는 방식을 사용하여 이를 수행합니다. 그래프를 탐색하며 해당 순서가 요구할 피크 (Peak) VTCM을 측정하고, peak_tcm <= tcm_capacity 인지 확인합니다.
이후 결과로 SMALL 또는 LARGE를 반환하며, 이는 각각 스필 (Spill)이 없음 또는 발생함을 나타냅니다. 따라서 채우기/스필 (Fill/spill) 결정은 스케줄러가 찾은 최적의 순서가 피크 워킹 셋을 용량 이내로 유지할 수 있는지 여부에 따른 결과입니다. 이때 내부적인 정렬 지표 (Ordering metric)는 그래프의 깊이 우선 위상 정렬 (Depth first topological sort) 내에서의 연산 위치이며, 이는 값의 생성자 (Producer)와 소비자 (Consumer)를 시간상으로 최대한 가깝게 유지한다는 좋은 특성을 가집니다. 두 연산의 순위가 같을 경우, 연산의 입력과 출력 사이의 그래프 거리, 브랜치 (Branch) 내의 깊이, 가장 무거운 브랜치를 먼저 스케줄링하는 것 등 일련의 휴리스틱 (Heuristics) 스택을 사용하여 순위를 결정합니다.
순서가 고정되면, 컴파일러는 각 텐서를 VTCM에 물리적으로 배치해야 합니다. 단편화 (Fragmentation)를 줄이기 위해 수명이 가장 긴 텐서부터 먼저 정렬하고, 그 다음 수명이 짧은 텐서들을 배치합니다. 참고로 배치는 단순한 1차원 주소가 아니며, 코드는 각 블록에 타일 공간 (Tile space) 내의 3차원 좌표인
(d0, d1, d2)
를 할당하며, 패커 (Packer, 재귀적 방식)가 백트래킹 (Backtracks)을 수행합니다. 정확한 재시도 조건 (Retry condition)은 몇 킬로바이트의 인라인 SIMD (Inlined SIMD) 안에 들어있었으나, 아직 디코딩을 시도하지는 않았습니다.
해당 백트래킹 패커 (backtracking packer)는 폴백 경로 (fallback path)입니다. 대신 컴파일러가 전체 최적화 도구 (full optimizer)를 실행할 때, 배치 (placement)는 더욱 인상적인 형태를 띠게 되는데, 이는 제가 이전에 다른 곳에서 본 적이 있는 정식 최적화 문제 (formal optimization problem)입니다.
VTCM에 텐서 (tensors)를 배치하는 것이 작은 트렁크에 여행 가방을 싸는 것과 같은 종류의 문제라는 점은 이제 꽤 명확해졌습니다. 일부 가방은 집에 남겨둘 수 있지만, 나중에 그것들을 가져오는 데 비용이 발생합니다. 컴파일러는 가능할 경우 이를 휴리스틱 (heuristics)으로 대충 처리하지 않고, 대신 전체 과정을 정식 혼합 정수 선형 계획법 (Mixed Integer Linear Program, MILP)으로 작성하여 잘 알려진 오픈 소스 최적화 패키지인 HiGHS에 전달합니다.
컴파일러는 디버깅을 위해 자신이 처리하고 있는 정확한 문제를 표준 솔버 파일 형식인 .mps로 덤프 (dump)합니다. 그 후 디컴파일된 바이너리 (decompiled binary)에서 몇 가지 사소한 것들을 찾아냈습니다:
- 최소화하려는 목표는 총 이동량 (DDR로 스필 (spilled)된 바이트, 거기서 다시 채워진 바이트, 그리고 멀티 코어 칩에서 코어 간에 송수신된 바이트의 합)입니다.
- 동시에 살아있는(alive) 두 텐서는 동일한 온칩 (on-chip) 바이트를 점유할 수 없습니다.
- 텐서는 유효한 주소의 워크벤치 (workbench)에 머물거나, 아니면 DDR로 추방됩니다. 이는 반연속적 (semi continuous)입니다.
사용자의 모델 수치 정밀도 (numeric precision)는 사용자에게 알리지 않고 재작성될 수 있습니다. 메모리 압박 (memory pressure)을 완화하기 위해 배치 과정 중에 텐서를 float, FP16, BF16 사이에서 변환하는 relaxed_precision_cast라고 불리는 연산들이 존재합니다. 따라서 이제 우리는 컴파일러가 스스로 다운그레이드 (downgrade)를 삽입할 수 있다는 것을 알게 되었습니다. 바이트가 맞게 들어간다면 우리의 float32 텐서는 FP16이 될 수 있습니다. (저는 이러한 캐스트 (casts)가 배치 중에 삽입된다는 것을 확인했습니다. 이것이 솔버 (solver) 내부의 변수인지 아니면 별도의 패스 (pass)인지는 제가 추출한 정보만으로는 말할 수 없습니다.)
최적화 도구에는 여기에 다 담을 수 없을 정도로 훨씬 더 많은 것들이 들어 있습니다. 그중 일부는 다음과 같습니다;
- 그래프는 스필링 (spilling)을 위해 모델을 어디서 자를지 결정하기 위해 최소 컷 (min-cut) 방식으로 분할됩니다.
- 별도의 깔끔하고 작은 알고리즘은 프로듀서 (producer)가 복사하는 대신 최종 레이아웃 (layout)에 직접 쓰도록 하여, 일반적인 경우 연결 (concatenation) 비용을 무료로 만듭니다.
- 컨볼루션 (Convolutions)은 im2col을 통해 행렬 곱셈 (matrix multiplies)으로 변환됩니다.
- 중복 연산은 콘텐츠의 체크섬 (checksumming)을 통해 찾아내어 병합됩니다.
Hexagon + estimate = Hextimate (??)
이 모든 것 — 즉, 가장 작은 워킹 셋 (working set)을 찾아내는 스케줄러(scheduler)와 이동되는 바이트 수를 최소화하는 옵티마이저 (optimizer) — 은 컴파일러가 실제로 가질 수 없는 한 가지 요소에 의존합니다. 두 스케줄 중 하나를 선택하거나, 스필링 (spill)을 피할 가치가 있는지 결정하려면, 각 옵션이 시간 측면에서 얼마나 '비싼지'를 알아야 합니다. 하지만 칩은 거기 없습니다. 컴파일러는 제 Linux PC에서 실행되고 있으며, 모델이 휴대폰으로 배포될 때까지 존재하지 않을 하드웨어를 상대로 수천 번의 베팅을 하고 있는 셈입니다. 그렇다면 컴파일러는 어떻게 비용을 산정할까요?
컴파일러는 추측하며, 오직 추측만을 위해 구축된 전체 시뮬레이터를 가지고 있습니다. 그것은 Hextimate (Hexagon estimate, 맞죠?)라고 불리며, 컴파일러에는 이와 관련된 참조가 가득합니다. 이는 전체 칩에 대한 작은 분석 모델로, 사용자의 모델을 실행하는 척하며 비용을 계산(compute)과 메모리 이동(memory movement)이라는 두 가지 범주로 나누어 조각조각 합산합니다.
또한 이는 경합 (contention, 칩의 두 부분이 동일한 리소스를 원할 때 한쪽이 기다려야 하는 상황)도 모델링하며, 단일 숫자가 아닌 '범위'를 반환합니다. 모든 것이 완벽하게 겹친다고 가정하여 워크로드 (workload)를 한 번 실행하고, 아무것도 겹치지 않는다고 가정하여 한 번 더 실행한 뒤 양 끝값을 보고합니다. 이 정도면 단순한 룩업 테이블 (lookup table) 인터페이스라기보다 시뮬레이터라고 부를 만합니다.
Hextimate의 형태 대부분은 내부 구성 요소들의 이름을 통해 파악했습니다. 하지만 메모리 측면에 대해서는 머신 코드 (machine code)에서 직접 실제 공식을 얻어냈으며 (Sonnet 4.6 덕분에), 이는 모든 성능 엔지니어가 이미 알고 있는 교과서적인 루프라인 모델 (roofline model)입니다:
bandwidth = channels * width * efficiency * frequency
time = bytes/bandwidth
시뮬레이터는 특정 요소들을 하나로 뭉뚱그리는 대신, 매우 사소해 보이는 것들에 대해 별도의 비용 (cost)을 유지합니다. 비록 이러한 별도의 비용 요소들의 이름은 읽을 수 있었지만, 그 안에 저장된 실제 값들을 추출할 수는 없었습니다.
- 정수 (Integer) 및 부동 소수점 (floating point) 연산은 별도로 가격이 책정됩니다 (사소함).
- 하나의 데이터를 여러 곳에 동시에 쓰는 것 (멀티캐스트, multicast)은 한 번에 하나씩 쓰는 것과 별도로 가격이 책정됩니다 (이 또한 사소함).
- KV 캐시 (KV cache) 텐서와 가중치 (weights)는 고유의 "fast DDR" 계수를 가집니다.
또한 FlashAttention, MoEs, KVCache, 회전 임베딩 (rotary embeddings) 등을 인식하는 전용 탐지기 (detectors)도 포함되어 있습니다.
결론
그래서 무엇이 중요한가요?
이번 역공학 (RE) 시도에서 얻은 핵심 결과물은 엣지 ML (edge ML)에 관심 있는 모든 이들에게 유용할 것이라 생각합니다:
- 이는 커스텀 스케줄러 (custom scheduler) 개발자들에게 배치 품질 (placement quality)이 휴리스틱 (heuristics)에 의해 제한되는 것이 아니라 솔버 (solver)에 의해 제한되며, 목적 함수 (objective)가 순수하게 바이트 트래픽 (byte traffic)이라는 점을 알려줍니다.
- 커스텀 스케줄러는 Hextimate의 신념을 미러링하여 컴파일러의 결정을 예측할 수 있습니다.
- 비용 모델 (cost model)은 계수 수준에서 LLM 워크로드에 편향되어 있습니다 (따라서 KV/가중치 텐서와 같이 인식 가능한 요소들이 큰 장점이 됩니다).
- 정밀도 (Precision)는 사용자가 요청했는지 여부와 관계없이 솔버가 조절하는 노브 (knob)이며, 따라서 실제 하드웨어에서의 품질이 달라질 수 있습니다.
spillFillBufferSize는 정적 적합/스필 (static fit/spill) 오라클 (oracle)로 사용될 수 있습니다.
이것이 누군가에게 직접적인 도움이 될지, 아니면 단순히 누군가의 호기심을 충족시켜 줄지는 별개의 문제이지만, 저는 어디에서도 찾을 수 없었던 답들을 얻었습니다. 만약 Hexagon에서 엣지 작업을 해보셨고 의견을 나누고 싶거나, 제가 틀린 부분을 알려주고 싶다면 언제든 환영합니다.
참고 사항
문제는 이것을 얼마나 신뢰할 수 있느냐입니다. 대상 파일은 (libHtpPrepare.so, x86_64 호스트 빌드, QAIRT 2.46.0.260424, BuildID 63e60947ee8df89fe11592a8af12a30ddedb91cd 및 sha1 8048df5fe605d743a20337a6968aebc6f1930f4b)가 유일한 타겟이었습니다.
). 저의 이전 역공학 (RE) 시도들은 난이도가 낮거나 중간 정도인 crackme들이었으며, 독점적인 컴파일러(proprietary compilers)가 아니었습니다. 그리고 이번 작업은 Claude Code의 도움 없이는 불가능했을 것입니다. 따라서 모든 주장을 비판적으로 검토하며 읽어주시기 바랍니다. 통찰을 얻기 위해 수행한 단계들을 공개하기 전에, 모든 법적 사항을 파악할 때까지 기다릴 예정입니다.
- NPU 관련 문헌 어디에서도 찾을 수 없었던 유일한 발견 사항들은 이 글의 맨 처음에 언급된 것들입니다. "이 SDK 버전에서는 공개적으로 문서화되지 않음"이라는 주장은 제가 확신할 수 있는 부분입니다.
- 이것은 하나의 바이너리에 대한 하나의 SDK 버전입니다. 수치들(33배의 DDR 점프, 5.46/33.9 MB의 spill/fill,
spillFillBufferSize판정)은 Qualcomm의 자체 도구를 오라클 (oracle)로 사용하여 제 환경에서 재현 가능하지만, 다른 QAIRT 릴리스에서는 이 중 어떤 것이라도 재배치될 수 있습니다. - 바이너리에는
dp_tcm_threshold_selector,TCM_THRESH_75_predictor,special_mlp_features와 같은 이름들이 도처에 널려 있으며, 마치 그 안에 spill 시점을 결정하는 훈련된 모델 (trained model)이 들어 있는 것처럼 보입니다. 하지만 실제로는 그렇지 않습니다. 적어도 이 바이너리 안에는 없습니다. 저는 가중치 행렬 (weight matrix), 순전파 (forward pass), 또는 추론 (inference)의 냄새가 나는 그 어떤 것도 찾을 수 없었습니다.special_mlp_features
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기