「EVO-X2(Ryzen AI Max+ 395 / 128GB)+ Qwen3.8-Flash-Next」 시리즈의 속도 측정을 할 때는 항상 llama-cli와 일본어 논문을 사용한 자체 제작 벤치마크의 속도를 사용하고 있습니다.
이전에는 llama-bench를 사용했었는데, 제2회에서 llama-bench로 가속화한 후, llama-cli로 실문서로 확인했을 때 출력이 깨져 있었습니다.
그 이후로는 속도와 출력 내용을 한 번에 확인할 수 있는 llama-cli를 사용하고 있지만, 기사나 GitHub의 README에 공개하려면 누구나 재현할 수 있도록 llama-bench 결과도 포함하는 것이 좋겠다고 생각했습니다.
측정 조건을 맞추자 llama-cli와 llama-bench에서 비슷한 값이 나올 거라 예상하고 확인해 봤더니, Vulkan의 PP(Prompt Processing)에서 완전히 다른 결과가 나왔습니다. 여기서 벗어나 다시 늪에 빠졌습니다.
이후부터는 Prompt Processing (입력을 읽어들이는 처리)를 PP, Token Generation (출력 생성)으로 표기합니다. 둘 다 단위는 tok/s이며, 높을수록 빠릅니다.
이번 EVO-X2 + Qwen3.8-Flash-Next 환경에서는 동일한 모델과 토큰 수를 지정했음에도 불구하고, llama-cli와 llama-bench에서 Vulkan의 PP 속도가 크게 달랐습니다.
- 첫 번째 비교에서는 256k의 Vulkan PP가 llama-cli의 268.10 tok/s에 비해 llama-bench의 216.09 tok/s로 약 19.4%나 차이가 났습니다. - 이 차이는 입력이 실문서인지 무작위 데이터인지의 차이 때문이 아닐까 추측하여, llama-bench를 개조해 실문서를 입력할 수 있도록 했습니다. 그 결과, 256k에서는 270.42 tok/s가 되었고, 과거 llama-cli와의 차이는 약 0.86%까지 줄었습니다. 64k에서도 마찬가지였습니다. -
제11회에서 도입한 가속화와 관련된 QSA grouped-union의 내부 통계를 측정해 보니, 256k에서는 random 입력의 unique union 수가 실문서보다 약 67% 더 많은 것이 밝혀졌습니다. 이는 입력에 따라 Flash Attention으로 전달되는 KV의 수가 달라진다는 초기 가설을 뒷받침하는 결과였습니다. - QSA union을 OFF로 하고 64k를 측정했을 때는, 이번에는 random 입력 쪽이 약 14% 더 빨라졌습니다. QSA union 외의 경로에도 입력 의존성이 있어, 원인을 단순하게 하나로 좁힐 수는 없었습니다.
소스 코드, Windows 빌드, 검증 스크립트 등은 여기서 공개하고 있습니다.
이번 작업은 r4/upstream-refresh-20261002에서 분기된 테스트용 전용 브랜치에서 진행한 후, r5/upstream-refresh-20261009 브랜치에 통합하는 중입니다.
속도와 관련된 부분만 먼저 말씀드립니다.
| 항목 | 내용 |
|---|
| PC | GMKtec NucBox EVO-X2 |
| ... | |
실험마다 llama-bench의 빌드, 웜업(warmup) 및 반복 횟수 등이 다르기 때문에, 비교에 영향을 주는 것은 본문에서도 구분하여 작성합니다. 자세한 내용은 기사 말미에 정리하겠습니다. | |
'나무만 보고 숲을 보지 못하는' 타입인 저와, 흔히 '오버 엔지니어링 경향이 있다'는 평판을 받는 Chappy님은 궁합이 너무 좋아서 자주 늪에 빠집니다.
무언가 궁금한 것이 생기면 사소한 부분에서 점점 깊이 파고들다가, 원래 무엇을 하고 있었는지 잊어버립니다.
가끔 다른 모델에게 리뷰를 받기도 하지만, 리뷰를 부탁할 때는 보통 늪에 빠져 있지 않을 때라, 늪 방지에는 별 의미가 없습니다.
이번 발단의 데이터는 이 llama-cli와 llama-bench의 비교 데이터입니다. llama-bench에는 MTP 모델을 로드하는 기능이 없었기 때문에, 모두 MTP OFF, 제10회에서 추가된 가속화 2가지(QSA union과 Moe Tile 선택 구 방식) ON으로 비교하고 있습니다.
llama-cli에 입력되는 데이터는 항상 사용하던 일본어 논문 데이터입니다.
Vulkan 256k의 PP만 유독 차이가 크게 난다. 그다음으로 큰 것은 Vulkan의 64k이다. 128k는 측정하지 않았지만, 이것은 Vulkan의 PP에 무언가 있는 것 같다.
llama-cli와 llama-bench의 속도 차이 원인으로는 몇 가지 가설을 세울 수 있다.
그중 가장 명확한 것은, llama-cli에는 한국어 실문서를 입력으로 사용하고, llama-bench는 랜덤 토큰을 입력으로 사용한다는 점이다.
게다가 Vulkan의 PP에서만 그렇다는 점이気になるのは, 8번째 회차에 처음 도입하여 11번째 회차에서도 이식된 QSA union이다. 이것은 Vulkan의 장문 PP를 가속화하기 위한 메커니즘이었다.
QSA(Query-aware Sparse Attention)에서는 각 query에 대해 attention 대상이 되는 KV 위치 후보를 선택한다. grouped-union은 64개의 query가 선택한 KV 위치를 모아서 중복을 제거하고, 필요한 KV만 모아 Flash Attention으로 전달하는 방식이다.
개념적으로는 다음과 같다.
예를 들어, 각 query가 비슷한 위치를 선택한다면, 중복 제거 후 남는 KV는 적다. 반대로 64개의 query가 여기저기 떨어진 위치를 선택하면, union 후의 KV가 커지고, gather나 Flash Attention으로 처리해야 할 데이터가 늘어난다.
실문서에서는 선택하는 KV 위치가 겹치기 쉽고, 랜덤 토큰에서는 분산되기 쉬우므로, 토큰 수는 같더라도 random 입력의 PP가 느려질 가능성이 있다.
물론, 여기까지는 가설이다. 입력을 바꾸면 MoE의 expert 선택이나 다른 연산도 달라질 수 있고, llama-cli와 llama-bench에서는 prompt 처리 경로, batch 구성 방식, logits/output 지정, context allocation이 완전히 같다고 할 수는 없다.
하지만
| 추가 옵션 | 내용 |
|---|
-f , --prompt-file | |
| UTF-8 텍스트 파일을 읽어 모델의 vocabulary로 tokenize하여 PP 입력에 사용 | |
--prompt-slice head-tail | |
앞부분 ceil(N/2) 토큰과 뒷부분 floor(N/2) 토큰을 연결 (기본값) | |
--prompt-slice head | |
| 앞부분부터 N 토큰을 사용 | |
-c , --ctx-size | |
context를 명시 지정. 0 또는 미지정 시 기존처럼 자동 설정 | |
-p N
으로 지정한 토큰 수를 정확히 N개 사용하며, 원 파일이 짧을 경우 오류가 발생한다. 임의로 반복하여 늘리지는 않는다. tokenize와 자르기는 측정 구간 밖에서 진행하며, warm-up과 측정을 반복할 때도 동일한 토큰 시퀀스를 사용한다. -f를 지정하지 않을 때는 기존의 random 입력 상태를 유지한다.
head-tail은 문서의 시작 부분에 있는 지시문과 끝 부분에 있는 질문을 남기고 싶을 때, 앞에서부터 지정된 토큰 수만큼이 아니라 앞뒤를 남긴 채 가운데를 삭제하여 지정된 토큰 수에 맞추는 모드이다.
개조판 llama-bench는 입력을 raw text로 tokenize하기 때문에, llama-cli의 Jinja chat template까지 재현한 것은 아니다. 배치(batch) 끝단의 logits/output 요청 등도 기존 llama-bench와 동일하다. 목적은 우선 입력 내용에 따른 차이를 분리하는 것이지, llama-cli와 내부 실행 경로를 완전히 일치시키는 것이 아니다.
우선 Vulkan의 64k와 256k를 QSA union ON으로 비교했다. 개조판 llama-bench는 b11427 버전이며, 각 조건당 2회 측정했다. 모델은 PLE16 변환판이고, MTP는 OFF였다. 직접 만든 속도 향상용 환경 변수 옵션으로 Vulkan 전용의 GGML_VK_MOE_LEGACY_TILE_SELECTION=1,
GGML_VK_QSA_UNION=1,
GGML_VK_GET_ROWS_128X4=0
을 고정했다.
| Context | 과거의 llama-cli (실제 문서) | 개조판 llama-bench (실제 문서) | 개조판 llama-bench (random) |
|---|---|
| 64k | 337.45 | 337.68 | 316.10 |
| 256k | 268.10 | 270.42 | 213.42 |
단위는 모두 PP tok/s이다. 과거의 llama-cli는 별도 세션의 참고값이다. 개조판 llama-bench의 실제 문서와 random은 동일한 빌드 및 동일 context에서의 비교이다.
실제 문서를 입력한 llama-bench는, 과거의 llama-cli 대비 64k에서 +0.07%, 256k에서도 +0.86% 차이를 보였다. 처음에 있었던 큰 속도 차이는 거의 사라졌다.
같은 개조판 llama-bench로 random 입력도 측정해 보니, 64k와 256k에서 각각 316.10, 213.42가 나왔다. 처음에 측정했던 개조 전 llama-bench에서는 322.19, 216.09였기 때문에 미묘하게 다르지만, 처음에 측정했던 llama-bench는 context 길이를 정확히 맞추지 않았을 수도 있으므로 단순 비교는 어렵다. 그럼에도 불구하고, random과 실제 문서 사이에 차이가 있다는 것은 명확하게 알 수 있다.
이로써, 처음에 관측했던 llama-bench와 llama-cli의 큰 PP 차이는 적어도 툴 자체의 고정적인 속도 차이라기보다는 입력 내용이 주요한 차이였음을 생각할 수 있을 것 같다.
일련의 검증은 끝났으니 여기서 돌아가 본론으로 돌아가면 되지만, 처음에 세웠던 가설은 입력 내용이 다르다 → QSA union의 크기가 바뀐다는 2단계였기 때문에, 후반부도 검증해 보고 싶은 마음에 더 깊이 들어갔다. 차피님은 이런 경우에 멈춰주지 않을 뿐만 아니라, 더욱 철저하게 세부까지 파고든다. 나 역시 그런 반응이 기분 좋아서 다른 AI에게 바람을 피울 수 없게 된다.
참고로, 처음 64k 실제 문서 측정에서는 243.34 tok/s라는 숫자가 나왔었다. llama-cli에 가까워지기는커녕, 원래의 llama-bench 측정 결과보다 느려서 무슨 일이 일어난 건가 싶었다.
조사해 보니, 10회차에 넣었던 자체 제작 속도 향상용 환경 변수를 전부 빠뜨리고 있어서, 속도 향상 기능 두 가지(QSA union과 Moe Tile 선택 구 방식) 모두 OFF 상태였다. 이번 첫 측정은 모든 기능을 ON 상태에서 진행했기 때문에, 그것과 비교해서 극단적으로 느린 값이 나온 것이었다.
순서가 좀 뒤죽박죽되긴 하지만, 나중에 QSA union을 OFF, MOE Tile 선택 구 방식, llama-bench에 실문서를 입력하여 측정한 Vulkan 64k의 PP는 268.81이었다.
도중에 진단 기능을 넣어서 다시 빌드했기 때문에 완전히 동일한 exe/dll에서의 비교는 아니지만, 둘 다 OFF → MOE Tile 선택 구 방식 ON, QSA union OFF로 설정했을 때, 243.34→268.81 tok/s가 되었다.
엄밀한 비교는 아니지만, 묘하게 Moe Tile 선택을 구 방식으로 되돌린 효과를 다시 한번 확인할 수 있었을지도 모른다.
실문서 입력에서 속도 차이가 줄어든 것은 확인했지만, 이것만으로는 QSA union이 정말 원인과 관련되어 있는지 알 수 없다. 그렇다면 union 후에 남은 KV 위치의 개수를 직접 측정해 보는 것이 좋다.
조사해보니, qsa_union.comp라는 Vulkan 셰이더에는 필요한 카운트가 이미 있었다.
| 카운터 | 의미 |
|---|
data_count[0] | 패딩된 union count: Flash Attention용으로 256 단위로 반올림한 union 후 KV 개수 (최소 256) |
data_count[1] | 고유 union count: 중복 제거 후에 남은 KV 위치의 정확한 개수 |
data_count[2] | 선택된 count: union 전 후보 슬롯 수 (n_batch × n_top) |
data_count[3] | 원래 KV 길이: 원본 KV 길이 |
다만, 이것은 GPU 내부 카운터이기 때문에 llama-bench 측에서 printf를 추가하는 것만으로는 읽을 수 없다. 그래서 Vulkan 백엔드 쪽에 진단 출력 기능을 추가했다.
GGML_VK_QSA_UNION_STATS=1일 때만, 각 64-query 그룹의 카운터(4개에 16바이트)를 GPU상의 진단용 버퍼로 복사하고, 백엔드 동기화 시점에 모아서 CPU로 회수한다. KV 길이는 GGML_VK_QSA_UNION_STATS_KV_BIN=16384로 16k 간격으로 나누고 CSV에 집계하여 기록한다. 각 그룹별로 GPU→CPU 동기화하면 그 자체가 부하가 될 것 같아서, 모아서 readback하도록 했다.
기록 상한은 1동기화 구간당 65,536 그룹이다. 수집하지 못한 개수도 dropped_groups로 남기도록 했다.
참고로, 이 진단 기능을 ON으로 하면 처리량이 늘어나서 느려질 가능성이 있으므로, 진단 ON 상태에서 표시된 PP 속도를 일반적인 속도 비교에 그대로 사용하지 않는다. PP 성능 비교는 진단 OFF, union 크기 측정은 진단 ON으로 분리한다.
진단 ON에서의 측정 결과. 실문서와 random 모두 QSA union ON, STATS ON 상태에서 모델・context・입력 토큰 수를 맞추고 Vulkan 환경 변수 설정을 통일했다.
| Context | 입력 | PP tok/s (참고) | Unique union 평균 | Padded union 평균 | Unique / selected |
|---|---|---|
| 64k | 실문서 | 336.58 | 17,512 | 17,638 | 13.36% |
| 64k | random | 315.62 | 24,859 | 24,985 | 18.96% |
| 256k | 실문서 | 269.57 | 19,704 | 19,829 | 15.01% |
| 256k | random | 215.46 | 32,876 | 33,002 | 25.05% |
Unique / selected는 후보 슬롯 수 대비 중복 제거 후에 남는 비율이다. (selected count에는 무효 인덱스가 포함될 수 있으므로, 이를 그대로
| KV 길이 구간 시작 위치 | 실물 문서의 unique union 평균 | random의 unique union 평균 |
|---|
| 32,768 | 16,908 | 23,033 |
| ... | | |
| ※ 표는 16k 간격의 전체 14 구간 중 일부를 발췌. 그래프는 전체 구간을 표시하고 있다. | | |
실물 문서의 unique union 수는 KV가 늘어나도 2만 전후로 유지되고 있다. 한편, random은 KV 길이가 길어질수록 3.5만~3.7만 가까이 증가하고 있다.
union 후의 KV 수가 늘어나면, 그만큼 gather할 K/V의 수와 Flash Attention으로 처리하는 대상이 늘어난다. random의 256k PP가 실물 문서보다 느려지기 쉽다는 이번 결과는 방향과 크기의 경향성 모두 일치한다.
이를 통해 처음 가졌던 '랜덤 입력에서는 union 후 KV 수가 커지지 않을까'라는 가설은, 적어도 현재 환경에서는 측정으로 확인할 수 있었다.
하지만 PP 차이 전체가 union 사이즈만으로 설명되었다고 증명된 것은 아니다. 카운터가 보여주는 것은 처리되는 KV 수의 차이일 뿐, 각 GPU kernel 시간 내역을 직접 분리한 것은 아니다.
입력의 차이와 QSA union의 조합이 속도 차이로 이어지는지 궁금하여, QSA union OFF로 설정하고 측정해 보았다.
여기서는 llama-cli는 사용하지 않고, 수정된 llama-bench만으로 실물 문서와 random을 비교한다. 64k, b11433, GGML_VK_QSA_UNION=0로 설정하고, 진단(diagnose)은 OFF 상태를 유지했다. MoE tile 선택 구 방식은 ON으로 유지했다.
| 64k QSA union OFF | 실물 문서 PP | random PP | random의 실물 문서 대비 비율 |
|---|---|---|
| 첫 측정 (실물 문서→random, warmup 없음・각 1회) | 268.81 | 305.59 | +13.68% |
| 재측정 (random→실물 문서, warmup 있음・각 2회) | 269.12 | 306.51 | +13.90% |
단위는 tok/s이다. 재측정의 개별 값도 random은 306.680 / 306.349, 실물 문서는 269.087 / 269.153 tok/s로 일치했다.
QSA union OFF라면 비슷할 것이라 생각했는데, PP가 역전되어 random 쪽이 더 빠르다. 첫 측정에서 '어?' 싶어서 순서를 바꿔 warmup을 주고 재측정해도 같은 경향이었으므로, 처음 1회만의 우연은 아닌 것 같다.
주의할 점으로, GGML_VK_QSA_UNION=0는 모델의 QSA 메커니즘 자체를 OFF하는 설정이 아니다. 제11회에 도입된 QSA의 grouped-union 경로를 OFF하는 설정이며, 다른 Flash Attention 경로나 조건에 따라 기존의 sparse 처리로 전환된다. 따라서 같은 kernel에서 단순히 union 연산만 제거한 비교는 아니다.
이 결과로부터, QSA union 외의 부분에도 입력 의존성이 있는 것 같다. MoE의 expert 선택이나, Flash Attention의 실행 경로 차이 등 궁금한 후보가 몇 군데 있다.
이는 또 다른 깊은 연구로 이어졌다.
'llama-cli와 llama-bench에서 PP가 완전히 다르다'는 의문에서 예상치 못한 깊은 논의에 빠지게 되었다.
이번에 확인된 것은, 벤치마크에서는 모델・양자화・토큰 수・context 길이뿐만 아니라 입력 토큰의 내용도 조건의 일부가 된다는 점이다.
표준 llama-bench는 random token ID를 사용한다. 제11회에 추가된 고속화 항목인 Vulkan QSA grouped-union에서는, 실물 문서와 random으로 union 후 남는 KV 수가 크게 달라졌고, llama-bench에 실물 문서를 입력했을 때 과거의 llama-cli와 비슷한 PP가 나왔다. 다만, QSA union OFF에서 PP 순위가 역전되었기 때문에, QSA union만이 유일한 원인이라고 할 수는 없다.
이번에 사용된 실물 문서는 제2회부터 계속 테스트용으로 사용하던 것으로, 자연어 처리 분야의 일본어 논문 일부를 잘라내어 context 길이에 맞춰 여러 번 반복하고, 앞뒤로 지시문을 추가한 것이다. 반복 횟수는 64k에서 7번, 256k에서 31번으로, 실무에 사용하는 문서는 아니다.
이번 결과로 미루어 볼 때, 반복되는 문서의 경우 선택된 KV 위치가 중복되기 쉬워 QSA union 효과가 특히 두드러질 수도 있을까요? 의도한 것은 아니지만, 저 스스로 고속화에 특별히 유리한 벤치마크를 골랐다는 걱정도 들었습니다.
반복되지 않는 실무와 유사한 문서나 소스 코드를 입력했을 때 PP 또는 QSA union의 효과가 달라지는지 매우 궁금하므로, 다음번에는 입력 문서의 종류를 늘려보고 싶습니다.
그러고 보니, 가장 처음 목적이었던 '누구나 재현할 수 있도록 llama-bench 결과도 포함하는 것'은 어떻게 된 건가요? llama-bench를 개조하면 '누구나 쉽게 재현 가능한 지표'는 아닐 텐데요? (소스 코드를 공개했으니 빌드해서 시도는 할 수 있지만)
따라서, 일반적인 속도 측정 결과에 llama-bench 결과를 포함하는 것은 연기합니다.
.\llama-cli.exe \
-m "(메인 모델의 경로)" \
-c (컨텍스트 길이) \
...
Vulkan에서 MoE Tile 선택 구 방식과 QSA union을 활성화하려면, 시작 전에 다음 환경 변수도 설정해야 합니다.
$env:GGML_VK_MOE_LEGACY_TILE_SELECTION = '1'
$env:GGML_VK_QSA_UNION = '1'
$env:GGML_VK_GET_ROWS_128X4 = '0'
64k 상당, PP만 측정하는 예시입니다. -f는 입력 파일 지정, -p가 처리할 토큰 수입니다. 다음 예시는 64k 입력을 하는 데 필요한 길이만큼의 소스 파일을 사용합니다.
.\llama-bench.exe \
-m "(메인 모델의 경로)" \
-f "(충분히 긴 실문서 파일의 경로)" \
...
random 입력을 측정할 때는 -f와 --prompt-slice를 제거합니다. 256k라면 -c 262144 -p 255181로 변경합니다. 진단용 CSV를 출력하는 경우에만 GGML_VK_QSA_UNION_STATS=1 등을 설정합니다.
이 개조는 제가 독자적으로 진행한 것이므로, 일반적인 공식 llama-bench에 같은 옵션이 있다는 의미는 아닙니다.
실문서 테스트 데이터는 지난번까지 사용했던 것과 동일합니다.
건효사(乾孝司)・오쿠무라 마나부(奥村学) 「텍스트를 대상으로 한 평가 정보 분석에 관한 연구 동향」 (『자연어 처리』 Vol.13, No.3, 2006년, pp.201–241)
이 논문의 언어처리학회 논문지 LaTeX 코퍼스에 수록된 LaTeX 소스에서 제3장 일부를 각각 9,406자 및 14,874자로 잘라내고, 두 종류의 블록을 반복하여 필요한 길이의 일본어 문서를 작성한 후, 시작과 끝에 지시문을 추가했습니다.
llama-cli에서는 실제 입력이 64k가 61,789 토큰, 256k가 255,181 토큰입니다. 개조된 bench는 지정한 파일을 raw tokenize한 후, head-tail로 같은 길이의 토큰열을 가져오기 때문에, CLI와 완전히 동일한 토큰열을 제공하는 것은 아닙니다.
-
Device: NucBox EVO-X2
-
CPU / APU: AMD Ryzen AI MAX+ 395 with Radeon 8060S
-
Windows가 표시하는 CPU 클럭: 3.00 GHz
-
설치된 메모리: 128GB
-
UMA 프레임 버퍼: 96GB
-
Windows CPU 측에서 보이는 RAM: 약 32GB
-
GPU: AMD Radeon 8060S Graphics
-
GPU 아키텍처: gfx1151
-
OS: Windows 11 Pro
-
버전: 25H2
-
OS 빌드: 26200.9168
-
설치일: 2025-10-24
-
Windows 기능 경험 팩: 1000.26100.344.0
이번 검증에 사용된 llama.cpp의 기반은 2026년 10월 2일로 고정된 upstream bed0a856606ee4a24a164066f73d2379447033f5입니다.
(CUDA: fuse shared experts into MMVQ (#29184)))
여기에 r4로 포팅한 PLE16 지원이나 Vulkan QSA grouped-union, MoE tile 전환 등을 포함하는 빌드를 사용했습니다. 이번 실문서 대응 및 union 통계 취득은 그 r4에서 파생된 조사 브랜치에서 추가되었습니다.
메인 모델:
이번 주요 벤치마크에서는 위 모델을 LaurentZuijdwijk의 Strix Halo 전용 fork 내의 gguf-py\gguf\scripts exttt{gguf_split_ple_heads.py}로 변환한 PLE16 모델을 사용했다.