왜 CPU/GPU만으로는 부족한가
모바일과 엣지 기기에서 LLM을 돌릴 때 가장 먼저 부딪히는 벽은 발열과 배터리다. CPU는 범용성이 높지만 행렬 곱 위주의 추론에서 전력당 처리량(TOPS/W)이 낮고, GPU는 처리량은 높아도 상시 켜두면 소비 전력이 크다. 반면 NPU(Neural Processing Unit)는 INT8/INT4 정수 연산과 고정 데이터 흐름에 특화돼 있어 같은 연산을 훨씬 적은 전력으로 처리한다. 핵심은 "무엇을 NPU로 보내고 무엇을 남길지"를 정하는 오프로딩 전략이다.
NPU가 잘하는 것과 못하는 것
NPU는 정적 shape의 대규모 GEMM/컨볼루션에 강하지만, 동적 시퀀스 길이·희소 연산·커스텀 커널에는 약하다. LLM 추론은 두 단계로 나뉜다. 프롬프트를 한 번에 처리하는 prefill은 연산량이 크고 병렬성이 높아 NPU가 유리하다. 토큰을 하나씩 생성하는 decode는 메모리 대역폭에 묶이고 KV 캐시 접근이 잦아 NPU 이점이 줄어든다. 그래서 실무에서는 prefill은 NPU, decode의 일부 비정형 연산은 CPU로 나누는 하이브리드 구성이 흔하다.
| 단계 | 특성 | 권장 백엔드 |
|---|---|---|
| prefill | 연산 집약, 병렬성 높음 | NPU |
| decode | 메모리 대역폭 제약 | NPU + CPU 폴백 |
| 샘플링/후처리 | 동적·분기 많음 | CPU |
양자화 없이는 시작할 수 없다
대부분의 NPU는 부동소수점 대신 정수 연산을 요구한다. FP16 모델을 그대로 올리면 NPU 경로를 못 타고 CPU로 폴백되어 오히려 느려진다. 가중치는 INT4, 활성값은 INT8로 두는 W4A8 구성이 전력·정확도 균형이 좋다. 아래는 대표적인 변환 예시다.
python -m mlc_llm convert_weight ./model \
--quantization q4f16_1 \
--device auto \
-o ./dist/model-q4
python -m mlc_llm gen_config ./model \
--quantization q4f16_1 \
--conv-template llama-3 \
--context-window-size 4096 \
-o ./dist/model-q4
변환 후 반드시 소규모 평가셋으로 perplexity나 태스크 정확도를 측정해 회귀를 확인한다. INT4에서 특정 레이어(예: 첫/마지막 레이어)는 INT8로 유지하는 혼합 정밀도가 정확도 손실을 크게 줄인다.
런타임에서 오프로딩 지정하기
Android의 경우 TFLite Delegate로 NPU 경로를 명시한다. Delegate가 지원하지 않는 연산은 자동으로 CPU로 떨어지므로, 로그로 실제 위임 비율을 확인하는 것이 중요하다.
from tflite_runtime.interpreter import Interpreter, load_delegate
# QNN(HTP) 델리게이트로 NPU 위임
npu = load_delegate("libQnnTFLiteDelegate.so",
options={"backend_type": "htp"})
interp = Interpreter(
model_path="model-q4.tflite",
experimental_delegates=[npu],
)
interp.allocate_tensors()
# 위임되지 못하고 CPU로 남은 노드 수 확인
# adb logcat 에서 "Delegated N nodes / M total" 확인
여기서 "Delegated 12 nodes out of 340" 같은 로그가 보이면 대부분 CPU로 폴백된 것이다. 원인은 보통 지원하지 않는 연산자, 동적 shape, 또는 미양자화 텐서다.
전력 효율을 실제로 측정하기
추론 속도(tok/s)만 보면 안 된다. 전력 효율은 토큰당 에너지(mJ/token)로 봐야 한다. 발열로 인한 스로틀링이 걸리면 초기 벤치마크와 지속 성능이 크게 달라지므로, 최소 수 분간 지속 부하를 준 뒤 측정한다.
# 전류·전압 샘플링으로 순간 전력(mW) 계산
adb shell "cat /sys/class/power_supply/battery/current_now \
/sys/class/power_supply/battery/voltage_now"
# 클럭·온도로 스로틀링 여부 확인
adb shell "cat /sys/class/thermal/thermal_zone*/temp"
토큰당 에너지 = (평균 전력 × 생성 시간) / 생성 토큰 수. 같은 모델이라도 NPU 위임률이 높을수록 이 값이 눈에 띄게 낮아진다.
실무에서 자주 밟는 지점
- 메모리 복사 비용: CPU↔NPU 간 텐서 전송이 잦으면 오프로딩 이득이 사라진다. 연산 그래프를 최대한 연속된 블록으로 NPU에 위임해 왕복을 줄인다.
- KV 캐시 위치: decode 단계 KV 캐시를 NPU 접근 가능한 메모리에 두지 않으면 매 토큰마다 복사가 발생한다.
- 콜드 스타트: NPU 컨텍스트 초기화와 그래프 컴파일에 수백 ms가 걸린다. 앱 시작 시 워밍업 추론을 한 번 돌려 첫 응답 지연을 감춘다.
- 드라이버 파편화: 같은 칩셋이라도 벤더 드라이버 버전에 따라 지원 연산자가 다르다. 기기별 위임률을 CI에서 회귀 추적하는 것이 안전하다.
정리
NPU 오프로딩의 핵심은 세 가지다. 첫째, NPU가 요구하는 정수 양자화를 정확도 검증과 함께 통과시킨다. 둘째, prefill은 NPU, 동적 연산은 CPU로 나누고 실제 위임률을 로그로 확인한다. 셋째, tok/s가 아니라 mJ/token과 지속 성능으로 효율을 판단한다. 이 세 축을 데이터로 확인하면서 조정하면, 발열과 배터리 제약 안에서도 실용적인 온디바이스 추론을 구성할 수 있다.