제11회 EVO-X2 (Ryzen AI Max+ 395 / 128GB) + Qwen3.8-Flash-Next용 llama.cpp 개조로 베이스를 교체하여 MTP를 조사한 이야기에 이은 내용입니다.
제11회에서는 MTP를 적용했을 때 TG(Token Generation)가 느려지는 본말전도 같은 현상을 수정했습니다.
이번에는 llama.cpp 개조를 시작하기 전부터 궁금했던 'MTP를 넣으면 PP(Prompt Processing)가 느려진다'는 현상에 대해 조사했습니다.
제11회의 초반에도 베이스의 llama.cpp를 공식(upstream) 최신 버전(당시)으로 교체했지만, 이번에도 처음에 베이스 교체 → 제11회 및 그 이전부터 적용한 속도 개선 패치를 이식한 후 본론을 시작했습니다.
본격적인 조사에 들어간 후에도 외전 6에서 작성했던 P-MODE 버튼의 함정에 빠지거나 해서 여러 가지로 헤맸습니다.
슬슬 '베이스 교체 → 과거 속도 개선 패치 이식 → 동작 확인 → 속도 체크' 정도까지는 자동화하고 싶다는 생각도 합니다. 아직은 수동으로 하나하나 누르는 원시인입니다(웃음)
이후부터 Prompt Processing (입력을 읽어들이는 처리)를 PP, Token Generation (출력 생성)을 TG로 표기합니다. 둘 다 단위는 tok/s이며, 높을수록 빠릅니다.
MTP의 PP 저하는 Draft 측에서 출력이 필요하지 않은데도 일반적인 Attention 등이 실행되고 있었던 것과 관련이 있었습니다. 그래서 Draft의 K/V cache 업데이트에 필요한 계산만 수행하는 전용 경로를 만들었더니, PP가 개선되었습니다.
Stage B (Draft의 KV-only 경로): r5 P-MODE 고정 후 비교 결과, 64k에서 +3.59%, 128k에서 +6.71% 증가했습니다.
: Stage B를 활성화한 상태에서 추가로 64k mtp_eh_proj flatten에서 +1.39%, 128k에서 +1.40% 증가했습니다.
이후, upstream을 교체한 통합 버전(r6)에서도 Vulkan에서의 PP 개선을 재확인했습니다. 통합 버전의 MTP ON/OFF 비교에서는 64k와 128k 모두 MTP ON의 PP가 MTP OFF를 상회하는 수치도 얻었습니다.
| 항목 | 내용 |
|---|
| PC | GMKtec NucBox EVO-X2 |
| ... | |
백엔드에 대하여: Stage A, Stage B, flatten은 Vulkan 전용 구현이 아니며, ROCm에서도 가볍게 동작 확인을 진행했습니다. 다만, 단계별 상세 속도 비교나 RGP를 사용한 GPU 처리에 대한 조사는 Vulkan을 중심으로 실시했기 때문에, 본 기사에 게재하는 성능 측정 결과는 Vulkan에 한정합니다. ROCm에서도 동일하게 고속화된다고 확인한 것은 아닙니다.
소스 코드, Windows 빌드, 검증 스크립트 등은 이곳에서 공개하고 있습니다.
이번 주요 구현 및 조사는 investigation/r5-mtp-* 계열 브랜치에서 진행했으며, 이후 통합 버전(r6)에도 이식했습니다. 통합 버전은 main 브랜치에 공개되어 있지만, 최적화 플래그는 원칙적으로 기본값 OFF인 실험 기능입니다.
MTP (Multi-Token Prediction)란 다음 토큰을 하나씩 생성하는 대신, Draft 모델에게 여러 토큰을 예측하게 하고 Target 모델에서 한 번에 검증함으로써 TG를 가속화하는 메커니즘입니다.
다만, 출력 생성이 되기 전의 긴 입력 또한 Target 모델만 읽어들이면 되는 것은 아닙니다. 이번 Qwen3.8-Flash-Next 구성에서는 Target으로부터 전달된 hidden state에 대응하도록 Draft 측에서도 입력 토큰열에 대한 상태를 업데이트합니다(catch-up 처리).
Draft 모델 분량의 처리가 늘어나기 때문에 PP가 느려진다는 것은 직관적으로 이해할 수 있습니다.
하지만, 이해하는 것과 허용할 수 있는 것은 별개이며, MTP가 켜져 있을 때도 PP가 조금 더 빨라졌으면 좋겠다고 이전부터 생각했습니다. 특히 장문 입력의 경우, TG를 몇 초 단축하기 위해 PP가 수십 초 증가하면 전체적으로는 느려질 수도 있습니다.
이번 출발점의 속도는 이렇습니다. upstream을 교체한 llama.cpp(r5 b11560)를 빌드하여 평소 사용하던 일본어 논문 데이터를 llama-cli에 입력하고, MTP ON/OFF의 차이를 측정했습니다.
PP의 t/s로는 큰 차이가 없어 보일 수도 있지만, 시간 차이로 보면 꽤 다릅니다.
| context | MTP OFF PP | MTP ON PP | ONによるPP低下 | 増えたPP時間 |
|---|
| 64k | 361.52 | 334.00 | −7.61% | +14.08초 |
| 128k | 335.46 | 303.97 | −9.39% | +38.99초 |
우선 llama.cpp를 개조하여 처리 경계마다 CPU 측의 경과 시간을 출력하는 phase trace를 추가했다.
구체적으로는 Target의 prompt 처리, speculative wrapper, Draft의 process() 및 llama_get_embeddings_nextn()에 의한 hidden 가져오기, Draft의 llama_process() 등을 나누어 기록한다.
개조된 llama-cli를 사용하여 64k와 128k에서 MTP OFF/ON을 비교한 결과는 다음과 같다.
※ 로그 출력 오버헤드가 있으므로 PP 속도 및 시간의 절대값은 기본 측정값과 다릅니다.
| trace 대상 | 64k | 128k |
|---|
| MTP OFF의 PP 시간 | 171.12초 | 382.97초 |
| MTP ON의 PP 시간 | 181.31초 | 420.91초 |
| MTP ON 증가분 | +10.19초 | +37.94초 |
| ON 시 speculative wrapper | 87.12초 | 200.24초 |
└ hidden getter(llama_get_embeddings_nextn() ) | 78.44초 | 178.30초 |
└ Draft llama_process() | | |
| 6.76초 | 20.02초 | |
이 표 아래의 3개 행은 중첩된 CPU wall time입니다.
GPU 처리는 비동기적으로 큐에 투입됩니다. hidden을 가져올 때 발생하는 동기화(synchronization) 때문에, 그 이전에 Target 측에서 던진 GPU 처리 완료 대기가 이 getter llama_get_embeddings_nextn()에 모여 나타나는 경우가 있습니다. 즉, CPU 측에서 기다린 시간과 GPU 측에서 실제로 시간을 사용한 연산은 별개의 문제입니다.
한편, Draft의 llama_process()는 KV 길이가 길어질수록 시간이 증가하는 경향을 보였으며, 128k full batch 그룹에서는 KV 깊이와의 상관관계가 약 r=0.998이었습니다. Draft 측의 긴 문장 Flash Attention 처리가 의심되지만, PP 속도 저하의 원인이라고 단정할 수는 없습니다.
Draft의 catch-up 과정 중에는 기본적으로 Target으로부터 hidden이 전달됩니다. 하지만 이 구간에서는 Draft로부터 최종 토큰 출력을 얻고 싶은 것이 아닙니다. 그렇다면 일반적인 Draft 계산을 중간에 끊고 K/V 캐시 업데이트만 남기면 충분하지 않을까요?
첫 번째 Stage A에서는 일반 Attention 처리를 구성한 후, 출력(output), FFN, NextN 등이 불필요한 지점에서 조기 반환(early return)하는 방법을 시도했습니다. 그래프 상의 계산량이 상당히 줄어들 것이라 생각했지만, 기대했던 만큼 효과가 나타나지 않았습니다.
원인을 조사하기 위해 active graph의 연산 노드를 세어보니 다음과 같았습니다.
| 그래프 | 노드 수 | Flash Attention | MoE matmul | FFN계 | 일반matmul |
|---|
| 일반 Draft catch-up | 133 | 1 | 3 | 11 | 22 |
| Stage A | 44 | 1 | 0 | 0 | 6 |
노드 수는 133에서 44로 줄었습니다. MoE나 FFN도 사라졌습니다. 그런데 가장 없애고 싶었던 Flash Attention이 남아있습니다.
llama.cpp의 공통 build_attn_mha() 안에서 ggml_build_forward_expand()가 호출되면서, Attention의 출력이 이미 그래프의 root로 등록되어 있었습니다. 나중에 다른 곳에서 early return을 하더라도, 한 번 root화된 FA는 사라지지 않는 것 같습니다.
단순히 '불필요한 결과를 사용하지 않는다'만으로는 충분하지 않았습니다.
Stage A에서 FA가 남아있는 이유를 알게 되었으므로, 이번에는 일반 Attention 경로를 중간까지 재사용하는 것을 포기했습니다.
Qwen4Exp의 MTP Draft 중, 출력이 불필요한 catch-up에 한정하여 작동하는 전용 KV-only 경로를 추가합니다.
K/V 투영(projection), K 정규화(normalization), RoPE와 같은 모델 고유 변환, K/V 캐시로의 쓰기, 필요한 재귀 복사본은 유지한다. 반면, Q 투영, Flash Attention, attention output, WO, FFN, NextN output, logits는 생성하지 않는다.
이 기능은 환경 변수 LLAMA_MTP_DRAFT_PREFILL_KV_CROP으로 ON/OFF할 수 있도록 했다.
여기까지 구현하고 다시 연산 노드(operation node)를 세어보니, 드디어 목표했던 그래프가 나왔다.
| 그래프 | 노드 수 | Flash Attention | MoE matmul | FFN 계열 | matmul | K/V 쓰기 |
|---|
| 일반 (通常) | 133 | 1 | 3 | 11 | 22 | 있음 |
| Stage A | 44 | 1 | 0 | 0 | 6 | 있음 |
| Stage B | 31 | 0 | 0 | 0 | 5 | K와 V를 각각 1개 |
계산 그래프 확인이 끝났으므로, 속도를 측정해 보았다.
측정하는 동안 알 수 없는 Windows 재부팅과 Evo-X2의 P-MODE 버튼 문제에 직면했지만, 그 이야기는 외전 6편에 적었으니, 여기서는 여러 문제를 해결한 후의 측정 결과만 게재한다.
| context | Stage B OFF PP | Stage B ON PP | PP 개선율 | PP 단축 시간 | 기본 MTP OFF PP | 기본 MTP ON PP |
|---|
| 64k | 333.495 | 345.460 | +3.588% | 약 6.42초 | (361.52) | (334.00) |
| 128k | 300.930 | 321.130 | +6.713% | 약 26.39초 | (335.46) | (303.97) |
Stage B의 OFF와 ON을 비교해 보면, Draft에서 불필요한 처리를 제거한 결과로 속도가 빨라진 것을 알 수 있다.
llama-cli에서 출력되는 로그를 확인하면, 입력 후반부일수록 개선 폭이 크다는 경향도 보였다. 예를 들어 64k의 4–8k 구간에서는 약 1.7%, 49–61k 구간에서는 약 4.6%의 시간 단축이었다.
하지만 측정 날짜와 llama-cli 빌드가 다르기 때문에 참고 값일 뿐이며, 처음에 측정한 기본 측정값과 비교하면 MTP OFF 속도에는 도달하지 못했다.
(Stage B OFF와 기본 MTP ON이 거의 동일한 조건)
Stage B로 인해 PP는 상당히 빨라졌지만, 여전히 MTP OFF보다는 느리다. 다른 어느 부분에서 시간이 걸리고 있는지 알아보기 위해 Radeon Developer Panel / Radeon GPU Profiler(RGP)를 사용했다. 사용 방법의 자세한 내용은 제10회에 적었으므로 여기서는 생략한다.
Vulkan의 64k 입력에서, 프롬프트가 약 51~53k까지 진행된 지점의 1,024 토큰 분량의 Draft catch-up 그래프를 두 번 캡처하여 GPU의 Event timing과 Pipeline state를 확인했다. Stage B에서는 FA(Flash Attention)는 사라졌지만, 대신 눈에 띄었던 것이 mtp_eh_proj라는 투영(projection)의 Q8 matmul이었다.
flatten 전 대표적인 그래프에서 Draft 전체가 약 62ms인데, 이 matmul만으로 약 56~59ms를 차지했다. 하나의 연산이 그래프의 대부분을 사용하고 있다.
RGP 상의 Vulkan kernel은 matmul_q8_0_q8_1_0 (80,1,1024)이며, 이론적 occupancy는 3/16 wavefronts/SIMD, VGPR은 256/256이다. 즉, 연산 자체보다는 GPU에 전달하는 계산의 형태가 비효율적인 형태가 아닐까 생각했다.
여기서 궁금했던 점은, mtp_eh_proj가 각 토큰이나 하이퍼-커넥션(hyper-connection)에 독립적으로 적용되는 선형 투영이라는 점이다. 행렬의 수나 값을 변경하지 않고도 입력 텐서의 보여주는 방식만 바꿀 수 있을 것 같았다.
원래의 mtp_eh_proj 입력은 개념적으로 [5120, 4, 1024] (특징 차원 × 하이퍼-커넥션 × 토큰)이었다. Vulkan에서는 폭이 좁은 행렬을 1,024 배치로 처리하는 형태로 보였다.
그래서 ggml_reshape_2d()를 사용해 하이퍼-커넥션과 토큰 축을 합치고, 투영 후에 ggml_reshape_3d()
원래 형태로 되돌리도록 변경했습니다.
// e_norm / h_norm은 각각 [n_embd, hc, n_tokens]
ggml_tensor * eh_in = ggml_concat(ctx0, e_norm, h_norm, 0);
if (mtp_eh_proj_flatten) {
...
1,024 토큰일 때의 GEMM 형태는 다음과 같습니다.
| M | N | K | batch |
|---|
| flatten OFF | 2560 | 4 | 5120 |
| flatten ON | 2560 | 4096 | 5120 |
곱셈의 대상이나 논리적인 출력 순서는 동일하며, GPU에 제공되는 행렬 형태만 변경한 것입니다.
이 기능은 환경 변수 LLAMA_MTP_EH_PROJ_FLATTEN으로 ON/OFF할 수 있도록 했습니다.
Stage B가 ON이고 해당 zero-output Draft 경로일 때만 유효합니다.
Stage B를 ON 상태로 유지한 채, flatten 기능만을 ON/OFF하며 측정했습니다.
| context | flatten OFF PP | flatten ON PP | PP 개선율 | PP 단축 시간 |
|---|
| 64k | 344.615 | 349.390 | +1.386% | 약 2.45초 |
| 128k | 319.605 | 324.070 | +1.397% | 약 5.44초 |
1.4% 정도라고 하면 작게 보일 수 있지만, 장문(long-context) PP에서는 2~5초의 차이가 납니다.
아직 MTP OFF보다는 느리지만, 많이 개선되었습니다.
RGP를 사용하여 flatten ON/OFF 모두에 대한 약 51~53k의 해당 Draft 그래프를 캡처해 본 결과는 다음과 같았습니다.
| 1024-token Draft graph GPU 이벤트 | flatten OFF | flatten ON |
|---|
| Draft 그래프 전체(inclusive) | 약 61.0~63.9ms | 약 9.24~9.37ms |
mtp_eh_proj 주변 | | |
| 약 56.5~59.1ms | 약 4.68~4.75ms | |
| 주요 Q8 matmul kernel | 약 55.9~58.5ms | 약 4.04~4.05ms |
matmul 단독으로는 약 13.8~14.5배, Draft 그래프 전체로도 약 6.6~6.8배의 단축 효과를 보였습니다.
flatten ON 시 실제 kernel은 matmul_q8_0_q8_1_1 (40,64,1)입니다. 이론적 occupancy는 6/16으로 증가했습니다. 해당 그래프의 dispatch 수는 양쪽 모두 20이었으며, 단순히 dispatch가 줄어든 것이 아니라 주로 matmul의 형태와 실행 시간이 변경된 것으로 생각됩니다. occupancy만 개선 원인이라고 단정할 수는 없습니다.
반면, llama-cli의 전체 PP는 약 **+1.4%**였습니다. 당연하게도, 하나의 연산이 14배 빨라진다고 해서 처리 전체가 14배 빨라지는 것은 아닙니다.
※RGP 오버헤드가 있으므로, PP 속도나 시간의 절대값은 참고치로 주로 비율을 보고 있습니다.
추후에 기본 공식(upstream) llama.cpp를 교체하고, 지금까지 효과가 있었던 모든 고속화 패치를 이식하여 통합 버전(r6)을 만들었습니다. 그 과정에서 Stage B와 flatten의 효과를 재확인했습니다.
아래는 기본 llama.cpp가 변경되었을 뿐만 아니라 다른 고속화 패치도 적용된 상태로 측정된 결과입니다.
| context | Stage B OFF PP | Stage B ON PP | PP 개선율 | TG 개선율 |
|---|
| 64k | 336.990 | 352.665 | +4.65% | +1.97% |
| 128k | 297.845 | 318.320 | +6.87% | +2.94% |
※TG에도 약간의 개선 효과가 있었으므로 참고용으로 개선율만 기재합니다.
| context | flatten OFF PP | flatten ON PP | PP 개선율 |
|---|---|---|
| 64k | 352.803 | 358.365 | +1.58% |
| 128k | 318.900 | 323.070 | +1.31% |
Stage B와 flatten을 포함한 자체 제작 고속화 플래그를 사용 가능한 모든 조건에서 ON으로 설정하고, MTP가 켜진(ON) 상태와 꺼진(OFF) 상태의 차이를 비교해 보았습니다. 256k 컨텍스트 길이만 MTP OFF 상태로 측정하지 않았기 때문에, 여기서는 MTP ON 값만 기재합니다.
| context | MTP OFF PP | MTP ON PP | ON과 OFF의 차이 | MTP OFF TG | MTP ON TG |
|---|---|---|---|---|
| 64k | 350.59 | 358.29 | +2.20% | 26.07 | 33.90 |
| 128k | 311.75 | 322.98 | +3.60% | 24.03 | 30.49 |
| 256k | — | 301.30 | — | — | 25.08 |
베이스를 교체한 효과인지, 통합 버전에서는 MTP ON 상태일 때 PP와 TG 모두 더 빨라졌습니다. 이 정도면 MTP ON을 일상적인 기본 설정으로 사용해도 될 것 같습니다.
참고로, 자체 제작 고속화 플래그를 비활성화(OFF)했을 때는 다음과 같은 결과가 나왔습니다.
고속화를 적용하지 않으면, MTP ON 상태에서 약 8% 느려지는 경향은 여전히 남아있습니다.
| context | MTP OFF PP | MTP ON PP |
|---|
| 64k | 346.05 | 319.31 |
| 128k | 270.79 | 248.01 |
- Stage A에서는 그래프의 노드 수를 크게 줄였음에도 불구하고, FA(Flash Attention)를 root에서 분리할 수 없어 PP 개선이 거의 이루어지지 않았습니다.
- Stage B에서는 필요한 K/V 업데이트를 직접 구성하여 FA를 제거했고, 128k 컨텍스트 길이에서 MTP의 PP 추가 비용을 약 70% 절감할 수 있었습니다.
- RGP(Retrieval-Augmented Generation Process)에서 남아있는 무거운
mtp_eh_proj 부분을 찾아내어, flatten 덕분에 GPU 내부적으로는 대폭 단축되었고, 전체 PP에서도 약 1~2%의 단축 효과를 얻었습니다.
제가 외출을 하거나 다른 것에 몰두하는 바람에 이번 내용은 기사화되기까지 약 2주 정도가 걸렸습니다.
최근에는 고속화를 잠시 쉬면서, 특별편 4와 특별편 5에서 조사했던 입력 길이별 차이점이나 모델 양자화(Quantization)에 따른 차이를 연구하고 있습니다. 다음번에는 그 내용에 대한 기사가 될 예정입니다.
upstream의 llama.cpp (commit: de7fa0a3c6a2e1b4cd9f22eb8d6bf5b12dbdb63b)을 빌드하여 Unsloth IQ3(Ple16 테이블 변환 없음)으로 측정했습니다.
| context | r5 clean PP | r5 clean TG |
|---|
| 64k | 269.24 | 25.31 |
| ... | | |
| upstream에 지금까지의 패치(Ple16 변환 대응, QSA union 등)를 적용한 후, 제11회에서 도입했던 고속화(DENSE와 NOOP)의 이식 효과를 측정했습니다. | | |
| context | DENSE OFF→ON TG | 개선율 | NOOP OFF→ON TG | 개선율 |
|---|---|
| 64k | 29.275→31.270 | +6.81% | 31.385→32.045 | +2.10% |
| 128k | 23.420→27.055 | +15.52% | 26.960→28.775 | +6.73% |
| 256k | 15.490→21.100 | +36.22% | 21.075→24.110 | +14.40% |
아래는 평소의 장문 요약 작업을 수행했을 때의 예시입니다. 본문의 ABBA는 temp=0, seed=1234, 512 토큰 출력 등 조건을 고정한 별도의 측정이므로, 이대로 실행한 결과와 표의 수치는 직접 비교할 수 없습니다. MTP 유무와 전용 환경 변수 역시 비교 시 별도로 전환했습니다.
.\llama-cli.exe \
-m "(모델 경로)" \
-c (컨텍스트 길이) \
...
이번에도 지난번과 동일한 장문 입력을 사용했습니다. 실제 입력 토큰 수는 다음과 같습니다.
| context | 실제 입력 |
|---|
| 64k | 61,789 tokens |
| ... | |
| 256k의 경우 요구 컨텍스트(context)인 262,144의 약 97.3%까지 입력을 했습니다. 이전 기사와 동일한 내용이므로 자세한 내용은 생략합니다. | |
하드웨어 (Hardware)
- 장치 (Device): NucBox_EVO-X2
- CPU / APU: AMD Ryzen AI MAX+ 395 (Radeon 8060S 탑재)
- Windows가 표시하는 CPU 클럭: 3.00 GHz
- 설치 메모리: 128 GB / UMA 프레임 버퍼: 96 GB
- GPU: AMD Radeon 8060S Graphics
OS
- Windows 11 Pro 26H2 / OS 빌드 26300.9550
- 업데이트: 2026-10-08
llama.cpp 기반
- r5 클린 업스트림 (clean upstream):
b11517, 고정 소스(pinned source) de7fa0a3c6a2e1b4cd9f22eb8d6bf5b12dbdb63b
- r5 Stage B 측정: Vulkan
b11618 - r5 플랫(flatten) 측정: Vulkan
b11659 - r6 업스트림 기반 (upstream base):
b11870 (고정 소스(pinned source) 74bca3950054eed1795f525cb9cf06bfe796492d)
) - r6 Stage B:
b11903, r6 플랫: b11906, r6 통합 버전: b11908
Vulkan
- 컴파일러 (Compiler): LLVM / Clang 20.1.8
- Vulkan SDK: 1.4.357.0
사용된 모델
-
저장소 (Repository):
unsloth/Qwen3.8-Flash-Next-GGUF
-
URL: https://huggingface.co/unsloth/Qwen3.8-Flash-Next-GGUF
-
양자화 (Quantization):
UD-IQ3_XXS
-
llama.cpp가 보고한 파일 크기: 76.32 GiB
-
BPW: 3.71
-
모델 파라미터 (Model params): 176.94B
-
상기 모델의 오리지널 버전과 LaurentZuijdwijk의 Strix Halo 전용 fork 내에 있는 gguf-py/gguf/scripts/gguf_split_ple_heads.py를 사용하여 변환한 PLE16 모델을 용도에 따라 사용합니다. MTP가 켜져 있을 때는 대응하는 Unsloth Q8_0 Draft 버전을 함께 사용합니다.