후속 보고: 4개의 DGX Spark에서 GLM-5.2 NVFP4 실행 — MTP 미스터리가 해결되었으며, 이제 128K 컨텍스트에서 약 24
요약
4개의 DGX Spark 환경에서 GLM-5.2 NVFP4 모델을 실행할 때 발생하던 MTP(Multi-Token Prediction) 수락률 저하 버그를 해결한 보고서입니다. vLLM 설정 파이프라인의 오류를 수정하여 128K 컨텍스트에서도 성능 저하 없이 높은 토큰 생성 속도를 확보했습니다.
핵심 포인트
- DCP4 환경에서 MTP 수락률이 급격히 떨어지던 버그 원인 규명
- vLLM의 SpeculativeConfig 설정 누락으로 인한 병렬 설정 오류 확인
- MTP4 설정 시 128K 컨텍스트에서 약 22-23 tok/s 성능 달성
- MAX_CUDAGRAPH_CAPTURE_SIZE 설정 시 주의사항 공유
이 글은 4개의 DGX Spark에서 128K 컨텍스트로 GLM-5.2 NVFP4를 실행하는 것에 관한 이전 게시물의 후속 보고입니다. 이전 게시물의 요약은 다음과 같습니다: MTP1을 사용했을 때 128K 컨텍스트에서 약 15 tok/s로 작동했으며, 128K 컨텍스트를 사용하거나 또는 약 23 tok/s(32K에서의 DCP1)를 사용하거나 둘 중 하나를 선택해야 하는 고통스러운 트레이드오프(tradeoff)가 있었습니다. 또한 저는 DCP4에서 MTP2/MTP3 수락률(acceptance)이 붕괴되는 현상이 "정말 버그처럼 보인다"고 지적했지만, 30시간 동안의 조사로도 원인을 파악하지 못했습니다.
그것은 버그였습니다. 이제 해결되었습니다. 트레이드오프도 사라졌습니다. 결과는 다음과 같습니다:
TL;DR
이전 게시물 (DCP4/128K/MTP1) | 현재 (DCP4/128K/MTP3) | 현재 (DCP4/128K/MTP4)
디코딩, 짧은 코드 생성 (hot) | 14.5-15.2 tok/s | 22-23 tok/s
위치당 MTP 수락률 (MTP acceptance per position) | 0.74 (MTP1 전용) | 0.90 / 0.79 / 0.67
컨텍스트 (context) | 131,072 | 131,072
하드웨어 (hardware) | 4x GB10 Spark + MikroTik RoCE (변동 없음)
네, MTP4 — 재귀적으로 재사용되는 단일 MTP 레이어는 여전히 위치 4에서 약 0.84로 조건부 수락되고 있으며, 이는 MTP4가 정점인 제 RTX 6000 Pro 박스에서 관찰되는 것과 일치합니다. 한 가지 설정 주의사항: MAX_CUDAGRAPH_CAPTURE_SIZE는 num_speculative_tokens + 1보다 더 큰 여유 공간이 필요합니다 (초안 모델이 타겟 모델보다 더 작은 캡을 도출하므로, 정확히 N+1로 설정하면 "No valid cudagraph sizes" 오류와 함께 시작에 실패합니다). 저는 MTP4를 위해 10을 사용합니다. 호스트 페이징(host paging) 소동이 있을 때 가끔 성능이 저하되는 것을 보았습니다 — MTP3는 저의 보수적인 기본값이며, MTP4는 최고 성능 설정입니다.
동일한 머신, 동일한 스위치, 동일한 체크포인트, 동일한 1.81 GB/rank KV 예산을 사용했습니다. 모든 이득은 vLLM의 누락된 설정 파이프라인 한 줄과 더 새로운 업스트림 브랜치로의 리베이스(rebasing) 덕분입니다. 이제 DCP1/32K 타협 설정은 무의미합니다: 전체 컨텍스트를 사용하는 DCP4가 이를 압도합니다.
실제 버그의 정체
이전 게시물에서 저는 수락률이 0.9, 0.75^4, 0.6^4 처럼 보이는 것 같다고 썼으며, 어떤 랭크 교차(rank-intersection) 효과 때문일 것이라고 추측했습니다.
지수(exponent)에 대한 직관은 무언가 실재하는 것을 가리키고 있었습니다 (손실이 DCP 월드 크기에 따라 스케일링된다는 점). 하지만 그 메커니즘은 생각보다 더 교묘하게 숨겨져 있었습니다. 그리고 이 문제가 30시간 이상의 절제 실험 (ablations) 동안 살아남은 이유는 진정으로 악랄했습니다:
SpeculativeConfig.create_draft_parallel_config()는 타겟 설정 (target config)에서 필드들을 복사하여 드래프트 모델의 병렬 설정 (parallel config)을 구축하는데, decode_context_parallel_size는 복사되는 필드 중 하나가 아니었습니다. 이 값은 조용히 기본값인 1로 설정됩니다. 제 스택이 사용하는 코드 경로에서 이 값은 있는 그대로 사용됩니다.
따라서 TP4/DCP4 환경에서 MTP 드래프트 레이어의 KV 캐시 (KV cache), 메타데이터 (metadata), 그리고 희소 인덱서 (sparse-indexer) 상태는 모두 DCP로 샤딩(sharded)되어 있었지만 (쓰기 측은 타겟 설정을 따름), 드래프트의 어텐션 (attention)은 자신이 DCP 하에 있지 않다고 생각했습니다. 즉, 쿼리 올-개더 (query all-gather)도 없고, LSE 병합 (LSE merge)도 없으며, 글로벌 top-k 인덱스들이 마치 로컬 쿼터-캐시 (local quarter-cache)가 전체 캐시인 것처럼 소비되었습니다. 텐서 덤프 (Tensor dumps) 결과, 드래프트 순전파 (forwards) 과정에서 4개의 랭크 중 3개가 아무것도 선택하지 못했고, 64개의 헤드 중 48개에 대해 문자 그대로 모두 0인 어텐션 (all-zero attention)을 출력하는 것이 확인되었습니다.
여기서부터가 악랄한 부분입니다. 어텐션 바로 다음에 오는 연산은 행 병렬 (row-parallel) 방식인 o_proj인데, 이 연산의 TP 올-리듀스 (all-reduce)가 랭크별로 일치하지 않는 4개의 결과를 하나의 은닉 상태 (hidden state)로 합쳐버리며, 이 상태는 모든 랭크에서 비트 단위로 동일하게(bit-identical) 만들어집니다. 제가 원래 조사 과정에서 실행했던 모든 랭크 간 발산 체크 (cross-rank divergence check)는 모두 정상으로 나왔습니다. 왜냐하면 오염이 발생한 직후 단 한 번의 연산 만에 합의(consensus)를 통해 세탁(laundered)되어 버리기 때문입니다. 그리고 드래프트가 타겟의 은닉 상태를 입력으로 받기 때문에, 단일 단계 MTP1은 해당 신호 덕분에 대부분 생존하지만 (0.75 수락률), 재귀적인 23단계에서는 쓰레기 값이 누적되어 붕괴합니다. 이것이 제 첫 번째 게시물에서 보여드린 붕괴 곡선 (collapse curve)의 원인입니다.
이는 또한 왜 이 버그가 어떤 설정값(knob) 조정에도 반응하지 않았는지를 설명해 줍니다. KV 인터리브 크기 (KV interleave size), ag_rs 대 a2a DCP 통신 백엔드 (comm backend), 글로벌 대 랭크-로컬 top-k, CUDA 그래프 (CUDA graphs) 대 이거 (eager) 실행 방식 등 그 어떤 것도 드래프트의 병렬 설정이 구축되는 방식에는 영향을 주지 않았습니다. 저는 설정 공간 (config space)을 탐색하는 것을 포기하고 대신 텐서 탭 (tensor tap)을 구축하기 전까지 이 모든 것들을 테스트해 보았으나, 소수점 둘째 자리까지 동일한 수락률 곡선을 보였습니다.
발견 과정
일부 분들이 아주 상세한 세부 사항을 선호한다는 것을 알고 있기에, 방법론에 대한 노트를 남깁니다:
- 스택을 훨씬 더 최신 업스트림 (upstream) 브랜치로 리베이스(Rebase)했습니다 (아래 참조). 용량(Capacity)은 정확히 재현되었으나, MTP3는 여전히 붕괴되었습니다. 이로써 "업스트림에서 수정되었다"라거나 "내 오래된 포크(fork) 문제다"라는 가설은 모두 배제되었습니다.
- 남은 설정 가설들(interleave/comm-backend/top-k-mode/eager)을 검증하기 위해 네 번의 추가적인 시도(burn boots)를 거쳤으나 모두 동일한 결과가 나왔습니다. 이 시점에서 버그는 설정 표면(config surface)이 아닌 연산(compute) 자체에 있음이 분명해졌습니다.
- MLA 디코드(decode) 경로에 작은 환경 제어형 탭(env-gated tap)을 작성하여, 초안 레이어(draft-layer) 순전파(forward) 시마다 다음 항목들을 덤프(dump)하도록 했습니다: post-allgather 쿼리, 실제로 소비된 top-k 인덱스, 랭크(rank)별 부분 출력 + LSE, 병합된 출력, 메타데이터, 그리고 원시 fp8 KV 페이지(raw fp8 KV pages).
- DCP1에서 탭을 교정(Calibrate)했습니다: 역양자화된 fp8_ds_mla 캐시 상에서 fp64 참조 어텐션(reference attention)을 수행한 결과, 모든 순전파에서 코사인 유사도(cosine) 0.9999 이상으로 커널의 출력을 재현했습니다. 따라서 이 측정 도구는 신뢰할 수 있었습니다.
- DCP4에서 동일한 프로브(probe)를 실행하고 덤프를 읽었습니다: 모든 랭크에서 impl.dcp_world_size == 1이었고, 병합된 출력은 병합 전의 부분 출력과 바이트 단위로 일치했습니다(즉, 병합이 전혀 실행되지 않았음). DCP 로컬 시퀀스 길이(전체 22에 대해 6/6/5/5)는 비-DCP 어텐션(non-DCP attention)으로 입력되고 있었으며, 출력이 0인 랭크들도 있었습니다. 그로부터 create_draft_parallel_config까지의 설정 추적(config trace back)에는 약 20분이 소요되었습니다.
해결책은 업스트림의 더 최신 러너 경로(runner path)에 이미 존재하는 로직을 미러링하는 약 10줄의 코드입니다(이것이 대형 SM120 장비에서 이 문제가 발견되지 않았던 이유입니다. 그들은 이미 수정 사항이 포함된 코드 경로를 실행하지만, 제 스택은 그렇지 않은 경로를 실행합니다).
수정 사항이 포함된 PR(Pull Request), GLM 라우팅 체크포인트를 위한 세 개의 동반 패치, 그리고 모든 증거가 초안(draft)으로 올라와 있습니다:
https://github.com/local-inference-lab/vllm/pull/72
업데이트된 레시피 (The updated recipe)
모든 것은 이전과 동일한 저장소(repo)의 동일한 레시피 디렉토리에 있습니다:
github.com/m9e/blackwell-llm-docker → recipes/4x-spark-cluster/glm52-b12x-spark/
새로운 프로덕션 엔트리 포인트(entry point): start-glm52-production.sh (DCP4 / MTP3 / 128K, 진단 기능 꺼짐)
이미지는 이제 훨씬 더 최신 업스트림(upstream) 베이스(local-inference-lab/vllm eldritch 라인, 6월 29일 + b12x)로부터 빌드되며, 그 위에 5개의 파일 오버레이(overlay)가 추가되었습니다: Spark Ray-startup 수정 사항과 이전 게시물의 post-load malloc_trim, 그리고 DCP 초안 수정 사항입니다. 빌드 스크립트는 레시피 디렉토리에 있습니다.
레시피 디렉토리에 있는 ELDRITCH_REBASE_NOTES.md에는 전체 조사 과정이 기록되어 있습니다 — 수치와 함께 기록된 모든 잘못된 가설, 덤프(dump) 증거, 그리고 메모리 장부(memory ledger)까지 포함되어 있습니다.
이전 게시물을 팔로우하셨던 분들이라면 주목할 만한 한 가지 당혹스러운 발견이 있습니다: 제가 메모리 이득의 일부로 설명했던 NCCL 채널 축소(NCCL_MAX/MIN_NCHANNELS=4, 고정된 NCCL_IB_HCA)가 실제 커밋된 실행 스크립트에는 포함되어 있지 않았습니다 — 이는 원래 작업 중에 수동으로 적용되었던 것입니다. 이제는 커밋되었습니다. 만약 이전에 레시피를 클론(clone)했다면, 기본 채널 수를 사용하고 있었으며 메모리 이득을 제대로 챙기지 못하고 있었을 것입니다.
이전 게시물의 다른 모든 사항은 여전히 적용됩니다: 공격적인 OS/Ray 가지치기(pruning), 호스트 네트워킹(host networking), fp8_ds_mla KV, 하이브리드 체크포인트 조립 스크립트(여전히 실제 model.layers.78.* MTP 레이어가 필요함), 그리고 Spark 패브릭(fabric) 상의 IB/RDMA. 하드웨어 섹션은 스위치 부분까지 변경 사항이 없습니다.
이전 게시물에서 수정하고 싶은 내용:
"MTP2/MTP3는 연구 영역이다" → 틀렸습니다, 그저 작동하지 않았을 뿐입니다. MTP3는 이제 프로덕션 기본값입니다.
"이 설정은 정확히 하나의 MTP 레이어를 가지므로, MTP1이 깨끗한 프로덕션 지점이다" → 초안(draft)이 실제로 자신이 초안을 작성 중인 컨텍스트를 볼 수 있게 되면, 단일 레이어 재귀(recursion)는 문제없이 작동합니다.
Position-3 수락률(acceptance)은 0.67이며, 이는 재귀적으로 재사용되는 단일 단계 헤드(single-step head)로서 솔직히 기대했던 것보다 더 나은 결과입니다.
원문 게시물에서 언급되었던 409/512 프리필(prefill) 진동 현상은 여전히 존재하며, 여전히 설명되지 않았고, 여전히 그리 중요하지는 않습니다.
진행 중인 논의 사항 (Open threads)
- 수정된 스택(fixed stack)에서의 깔끔한 롱 컨텍스트 디코드(long-context decode) 측정: (저의 첫 번째 깊이 프로브(depth probe)는 호스트 페이징 혼란(host paging churn) 중에 실행되어 보고하기에 공정하지 않았습니다. 이전 MTP1 베이스라인은 32K-112K 컨텍스트에서 TTFT 이후 약 13 tok/s였으며, 수락률은 깊이에 따라 크게 감소하지 않으므로 10대 후반의 속도를 예상합니다 — 댓글을 통해 정확한 수치로 후속 보고하겠습니다).
- 초안(draft)을 위한 b12x-MoE A/B 테스트 및 수정된 스택에서의 DCP2 재테스트: 주로 설정 매트릭스(config matrix)를 위한 목적입니다.
- 원문 게시물의 fp8_ds_mla 품질 문제는 여전히 별도의 기술 문서(writeup)를 작성할 가치가 있습니다.
참고할 점을 하나 더 추가하자면, 최근 전문가 프루닝(expert-pruned)된 GLM-5.2 체크포인트들이 눈에 띄는 Spark 성능 수치를 보여주고 있는데, 이는 전문가(experts)를 제거하거나(예: 별도의 복구 튜닝 없이 단순한 교정 편향 순위(correction-bias ranking)를 통해 256개에서 218개로 감소) 또는 축소된 컨텍스트를 실행함으로써 여유 성능(headroom)을 확보한 결과입니다. 이 게시물의 모든 수치는 131,072 컨텍스트에서 256개 전문가 전체를 사용하는 체크포인트를 기준으로 합니다. 이제 네 대의 Spark를 빠르게 만들기 위해 모델을 프루닝할 필요가 없습니다.
만약 Spark를 보유하고 있고 15 tok/s 설정에 머물러 있었다면, 레시피(recipe)를 기반으로 다시 빌드하거나, 업스트림(upstream)에 PR이 반영되기를 기다렸다가 그들의 것을 기반으로 다시 빌드하십시오. 이제 네 대의 Spark는 128K 컨텍스트에서 744B급 모델을 약 24 tok/s로 실행하며, 지난주와 비교해 유일하게 바뀐 점은 투기적 디코더(speculative decoder)에 더 이상 자신의 캐시를 파편화된 뷰(shredded view)로 제공하지 않는다는 것입니다.
물론, 이것이 정확히 '엄청나게 빠른(blazing)' 수준은 아닙니다. 8개의 RTX6000 pro에서는 100 TPS를 약간 상회할 수 있고, Together와 같은 최대 하드웨어 설정을 사용하는 사람들은 300 TPS 이상을 기록하고 있습니다.
하지만 제 노드들을 확인해 보았습니다 — 그리고 우리가 거의 확실하게 메모리 대역폭 제한(memory b/w bound) 상태라는 점을 기억하십시오;
이것은 120와트에서 구동되는 프런티어 인텔리전스(frontier intelligence)입니다. 정말 멋지네요.
오!
그리고 한 가지 아주 작은 사항을 덧붙이자면 - https://www.reddit.com/user/Front_Eagle739/ 님께 감사드립니다(h/t) - 이분이 저에게 omlx를 상기시켜 주셨습니다. 제가 직접 테스트해 보았는데, m3ultra에서 c=112k의 wall time(실행 시간)이 6000초 이상에서 약 1000초로 단축되었습니다. 컨텍스트가 길어짐에 따라 성능이 완전히 무너지는 대신, 전체 시간 동안 기본적으로 100+ tps의 prefill (프리필) 속도를 유지했습니다. 여전히 느리긴 합니다 - Spark가 500의 prefill (5배 차이) 및 약 24의 decode (+66% 차이)를 기록하는 것에 비하면 말이죠. 하지만 제 생각에, 이는 해당 모델을 Mac에서 "사용 가능한" 수준으로 끌어올리기에 충분했습니다. (omlx 또한 KV 캐시 (KV cache)를 강력하게 처리하는데, 제가 사용한 harness (하네스) 역시 마찬가지였습니다.)
제출자: /u/llamaCTO
[link] [comments]
AI 자동 생성 콘텐츠
본 콘텐츠는 r/LocalLLaMA의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기