단일 RTX 5090에서 DeepSeek-V4-Flash-0731 구동 업데이트: 이중 CUDA 그래프를 활용한 phase-adaptive
요약
단일 RTX 5090 환경에서 DeepSeek-V4-Flash-0731 모델을 1M 컨텍스트로 구동하기 위한 최적화 기술을 소개합니다. 이중 CUDA 그래프와 phase-adaptive DSpark 패치를 통해 추론 단계와 생성 단계의 수락률 차이를 극복하고 처리량을 개선했습니다.
핵심 포인트
- RTX 5090 32GB에서 1M 컨텍스트 구동을 위한 MoE 레이어 GPU 배치 최적화
- 추론과 생성 단계의 특성에 맞춘 phase-adaptive DSpark 패치 적용
- K=1(추론 단계 유리)과 K=2(코드 생성 단계 유리)의 동적 활용 가능성 제시
- vLLM 포크를 활용한 메모리 효율적 KV 캐시 관리
이 게시물은 라우팅된 MoE 전문가(experts) 대부분을 시스템 RAM에 배치하여, 단일 RTX 5090에서 DeepSeek-V4-Flash-0731을 네이티브 1M 컨텍스트로 실행하는 것에 관한 이전 게시물의 후속 글입니다. 첫 번째 게시물 링크: https://old.reddit.com/r/LocalLLaMA/comments/1vfbcgx/deepseekv4flash0731_full_1m_context_on_a_single/ 저는 이전 게시물에서 DSpark가 어려운 추론(reasoning) 과정과 최종/코드 생성(final/code generation) 과정에서 매우 다르게 동작하기 때문에, 투기적 디코딩(speculative decoding) 경로를 패치하고 싶다고 말하며 글을 마쳤습니다. 그리고 실제로 해냈습니다. AI 슬롭(slop, 무의미한 콘텐츠)을 읽는 것을 좋아하지 않으시나요? 실제 패치에 대한 리포지토리(Repo) 링크는 여기 있습니다: https://github.com/blackbeardlabs/ds4x_adaptive_dspark_production_bundle. 이것이 무엇에 관한 것인지 더 잘 이해하고 싶다면 아래 내용을 읽어주세요:
---영리한 AI 슬롭 시작---
먼저 버전 명확화
제가 사용 중인 소프트웨어는 vLLM의 포크(fork)인 guqiong96/Lvllmds4-x 입니다. 설치된 패키지와 로그에는 vLLM 2.3.9로 식별됩니다. 저는 업스트림(upstream) vLLM에 공식적인 2.3.9 릴리스가 있다고 주장하는 것이 아닙니다. 이 점이 이전 스레드에서 혼란을 야기했기에, 다른 무엇보다도 이를 명확히 하고자 합니다.
제가 테스트한 스택은 여전히 다음과 같습니다:
RTX 5090 32GB
Ryzen 9 9950X3D
256GB DDR5-5600
Linux Mint
NVIDIA driver 595.71.05
CUDA 13.2
guqiong96/Lvllmds4-x 패키지/런타임 보고 vLLM 2.3.9
lk_moe 2.3.2
PyTorch 2.11.0+cu130
네이티브 DeepSeek-V4-Flash-0731 safetensors 체크포인트, ~155.4 GiB
현재 배치는 여전히 두 개의 완전한 라우팅된 MoE 레이어를 GPU에 배치하는 방식입니다: export LVLLM_GPU_RESIDENT_MOE_LAYERS=0,1
모델 할당량은 약 15.92 GiB입니다. --gpu-memory-utilization 0.92 설정을 통해, 남은 GPU KV 캐시(KV cache)는 약 9.02 GiB / 1,332,343 토큰이며, 이는 모델의 네이티브 1,048,576 토큰 컨텍스트의 1.27배입니다. 따라서 이 최적화로 인해 1M 컨텍스트 전체를 포기하지는 않았습니다.
DSpark를 패치한 이유
DSpark의 깊이(depth)를 K=2로 고정했을 때, 실제 OpenCode/에이전트 워크로드(agentic workloads)에서 매우 명확한 분리 현상을 목격했습니다. 길고 어려운 추론 과정 동안, 두 번째 투기적 위치(speculative position)가 수락되는 비율이 매우 낮았습니다.
확장된 윈도우(Extended windows)의 양상은 대략 다음과 같았습니다:
초안 수락률(Draft acceptance): ~30-50%
생성 속도(Generation): ~11-13 tok/s
그 후 동일한 완성 과정(completion)이 추론을 마치고 예측 가능한 코드/텍스트를 출력하기 시작하면, 수락률은 80-90% 범위로 급증하며 처리량(throughput) 또한 대략 다음과 같이 상승했습니다:
생성 속도(Generation): ~17-18 tok/s
저는 K=1 고정 방식과 K=2 고정 방식을 별도로 테스트했습니다. 결과는 수락 동작(acceptance behavior)이 시사하는 바와 정확히 일치했습니다:
K=1은 어려운 추론(reasoning) 단계에서 더 나았습니다.
K=2는 수락률이 높은 최종/코드 생성(final/code generation) 단계에서 더 나았습니다.
따라서 명확한 목표는 다음과 같아졌습니다:
추론(reasoning) -> K1 </think> 콘텐츠(content) -> K2
중요한 점은 K를 변경하면 CUDA 그래프 형상(CUDA graph shapes)도 함께 변경된다는 것입니다. 만약 한 단계가 즉시 실행(eager execution) 방식으로 전환된다면, 단순히 DSpark 루프에 if/else 문을 넣는 것만으로는 충분하지 않습니다. 저는 이를 고통스러운 시행착오를 통해 배웠습니다.
MVP(최소 기능 제품): 단계 전환은 작동했지만, K1이 고통스러울 정도로 느려졌습니다.
저의 첫 번째 패치는 단계를 성공적으로 감지하고 런타임 투기적 깊이(runtime speculative depth)를 변경했습니다:
K2 -> K1 (phase=reasoning, reasoning->content marker=</think>)
K1 -> K2 (phase=content)
하지만 서버는 K=2로 설정되어 시작되었기 때문에, K2 CUDA 그래프 형상만 존재했습니다. 런타임에 K가 1로 변경되었을 때, K1은 즉시 실행(eager) 경로로 빠지게 되었습니다. 결과적으로 추론 속도가 약 5-6 tok/s로 떨어졌습니다. 즉, 단계 로직은 올바랐으나 구현 측면에서는 성능에 아무런 도움이 되지 않았습니다.
진정한 해결책은 K1과 K2 그래프 형상을 모두 캡처하는 것이었습니다.
최종 설계: 단계 적응형 K(phase-adaptive K) + 이중 CUDA 그래프(dual CUDA graphs)
K에 따라 변경되어야 하는 두 가지 그래프 제품군(graph families)이 있습니다.
대상 검증기(target verifier)의 경우:
K1 -> 대상 쿼리 길이(target query length) 2
K2 -> 대상 쿼리 길이(target query length) 3
DSpark 초안 작성기(DSpark drafter)의 경우:
K1 -> DSpark 쿼리 길이(DSpark query length) 1
K2 -> DSpark 쿼리 길이(DSpark query length) 2
저는 CUDA 그래프 후보 관리자(CUDA graph candidate manager)를 패치하여, 기존의 uniform_token_count 디스크립터(descriptor)를 키(key)로 사용하여 두 가지 쿼리 길이가 동일한 관리자 내에 공존할 수 있도록 했습니다.
--max-num-seqs 2 설정을 사용하면, 이제 다음과 같은 스타트업(startup) 과정을 보여줍니다:
Phase-adaptive DSpark CUDA graph candidates: decode_query_len=3 query_lens=(2, 3) max_num_reqs=2
Phase-adaptive DSpark CUDA graph candidates: decode_query_len=2 query_lens=(1, 2) max_num_reqs=2
Capturing CUDA graphs (FULL): 4/4
Capturing dspark CUDA graphs (FULL): 4/4
이 패치 이전에는 둘 다 2/2였습니다. 이제 두 단계(phase) 모두 CUDA graph가 적용된 상태를 유지합니다.
단계 탐지(phase detection) 작동 방식
새로 생성되는 각 요청은 추론 모드(reasoning mode)에서 시작됩니다. 스케줄러는 현재 요청에 의해 생성된 토큰만을 확인합니다. 에이전트 프롬프트(agentic prompt)에는 이전 어시스턴트 턴의 </think>가 포함될 수 있기 때문에, 프롬프트나 전체 대화 기록을 의도적으로 스캔하지 않습니다. 이 모델의 토크나이저(tokenizer)는 다음과 같이 출력합니다:
<think> -> 128821
</think> -> 128822
<|DSML|tool_calls> -> [30, 128825, 72461, 4941, 12548, 32]
확정된 </think>는 해당 완료(completion)의 나머지 부분 동안 해당 요청을 sticky-content로 만듭니다. 또한 암시적인 추론 종료 폴백(fallback)으로서 전체 DSML 도구 호출(tool-call) 마커를 추가했습니다. 아래의 긴 검증 실행(validation run)은 명시적인 </think> 마커를 통해 전환되었습니다. DSML 폴백은 패치에 포함되어 있지만, 이번 특정 실행에서 사용된 경로는 아니었습니다.
현재 배치 정책(batch policy)은 의도적으로 보수적입니다: prefill -> 활성화된 모든 추론 요청에 대해 K2 -> 모든 활성화된 요청 콘텐츠에 대해 K1 -> K2
따라서 K는 현재 요청별로 독립적으로 선택할 수 있는 것이 아니라, 배치 전체에 전역적(batch-global)으로 적용됩니다. 저의 --max-num-seqs 2 사용 사례에서는 정확성 측면에서 이 방식이 괜찮습니다. 요청별로 진정한 혼합 K를 구현하려면 단계별로 분리된 마이크로배칭(microbatching)/패딩(padding)/마스킹(masking)이 필요하며, 이는 더 큰 스케줄러 변경을 요구합니다.
코드에서 실제로 변경되는 부분
이 패치는 4개의 vLLM-fork 파일과 이전 게시물에서 다룬 FlashInfer 호환성 수정 사항을 다룹니다.
개념적으로:
scheduler.py
- 요청별 추론/콘텐츠 상태(reasoning/content state)를 유지합니다.
- 생성된 출력에서 확정된 </think> / DSML 마커를 감지합니다.
- 유효한 K(effective K)를 선택합니다.
- 기존의 num_spec_tokens_to_schedule 채널을 사용합니다.
model_runner.py
- 런타임 K(runtime K)를 소비하여 DSpark speculator에 전달합니다.
- 활성 초안 너비(active draft width)만을 scheduler/target verifier로 다시 전달합니다.
dspark/speculator.py
- 쿼리 패킹(query packing), 어텐션 메타데이터(attention metadata), 입력 준비(input preparation) 및 순차적 마르코프 샘플링(sequential Markov sampling)에 런타임 K를 사용합니다.
- 저장 텐서(storage tensor)는 설정된 최대 K로 유지하되, 사용되지 않는 위치는 무효화(invalidate)합니다.
- 런타임 쿼리 너비(runtime query width)를 사용하여 그래프를 디스패치(dispatch)합니다.
- 각 DSpark 그래프가 캡처되는 동안 올바른 유효 K(effective K)를 바인딩합니다.
cudagraph_utils.py
- 타겟 쿼리 길이(target query lengths)와 두 종류의 DSpark 쿼리 길이(DSpark query lengths)를 모두 캡처합니다.
- 런타임 디스패치는 uniform_token_count를 통해 이미 이들을 구분하는 방법을 알고 있습니다.
flashinfer/comm/cuda_ipc.py
- 이전 게시물과 동일한 호환성 수정 사항: 실제 로드된 파일 이름을 일치시켜 libcudart_stub.so가 실제 CUDA 런타임에 대한 부분 문자열 검색(substring search)에서 승리하지 못하도록 합니다.
나는 백업, 토크나이저 검증(tokenizer validation), 앵커 체크(anchor checks), 구문 컴파일(syntax compilation), 임포트 테스트(import tests) 및 쓰기 후 테스트 실패 시 자동 롤백(automatic rollback) 기능이 포함된 보호된 원샷 패처(guarded one-shot patcher)로 정확히 작동하는 수정 사항을 래핑했습니다. 완전한 보호된 패처, 런처, 재현 노트 및 롤백 지침은 상단에 링크된 리포지토리에 있습니다. Reddit 게시물 자체에 ~35 KB의 방어적 패칭 코드를 쏟아붓지는 않겠습니다.
20분 이상의 실제 에이전틱(agentic) 실행
이것은 합성된 한 줄 디코드 벤치마크가 아니라, OpenCode가 수행한 실제 에이전틱 작업이었습니다. 아래의 단계별 통계(phase statistics)를 위해 다음과 같은 조건의 깨끗한 10초 구간만을 유지했습니다:
- 프롬프트 처리량(Prompt throughput) = 0
- 실행 중인 요청(Running) = 1개
K2의 경우, 즉각적인 단계 전환(phase-transition) 구간도 제외했습니다.
90개의 깨끗한 10초 구간(windows)에 걸친 K1 추론 (K1 reasoning):
평균 생성 속도 (Mean generation): 13.78 tok/s
중앙값 (Median): 13.6 tok/s
범위 (Range): 12.0 - 16.3 tok/s
가중 초안 수락률 (Weighted draft acceptance): 54.16%
안정적인 K1 구간 동안 두 번째 투기적 위치(speculative position)는 0.000을 유지했습니다. 이는 시스템이 K2를 조용히 재현(replaying)하는 것이 아니라 실제로 하나의 초안 위치(one draft position)만 실행하고 있었음을 보여주는 유용한 무결성 검사 (sanity check)입니다.
12개의 깨끗한 10초 구간에 걸친 K2 콘텐츠/코드 (K2 content/code):
평균 생성 속도 (Mean generation): 17.02 tok/s
중앙값 (Median): 17.1 tok/s
범위 (Range): 15.1 - 17.7 tok/s
가중 초안 수락률 (Weighted draft acceptance): 80.73%
두 번째 투기적 수락 위치(speculative acceptance position)는 K2에서 즉시 다시 활성화됩니다. 이 전환은 로그에서 확인할 수 있습니다.
하나의 긴 완료(completion) 사례가 매우 명확한 예시를 보여줍니다:
23:53:28 K1 추론 (K1 reasoning) 13.8 tok/s
23:53:33 </think>
23:53:33 K1 -> K2 콘텐츠 (K2 content)
23:53:38 16.4 tok/s # 혼합 전환 구간 (mixed transition window)
23:53:48 17.4 tok/s
23:53:58 17.1 tok/s
23:54:08 17.5 tok/s
23:54:18 17.1 tok/s
23:54:28 16.6 tok/s
23:54:38 16.7 tok/s
23:54:48 17.1 tok/s
23:54:58 16.9 tok/s
23:55:08 17.4 tok/s
이것이 바로 제가 이전 포스트를 작성할 때 구현하고자 했던 정확한 동작입니다. 실제로 얻은 이득은 무엇일까요? 이것은 50%의 엔드 투 엔드 (end-to-end) 기적 같은 패치는 아닙니다. 큰 이득은 제가 실패했던 첫 번째 적응형 MVP (adaptive MVP)와 비교했을 때 나타났습니다. 성급한 K1 (eager K1)은 약 5-6 tok/s였던 반면, 이중 그래프 (dual-graph) K1은 다시 약 13-15+ tok/s로 돌아왔습니다. 유용한 정적 구성 (static configurations)과 비교하면 개선 폭은 더 작지만 실제로 유용합니다. 어려운 추론 중에 K2를 켜두는 것과 비교했을 때, K1을 사용하는 것은 제 A/B 테스트 결과에서 대략 한 자릿수 중간에서 높은 한 자릿수 퍼센트 정도의 승리입니다. 최종/코드 생성 중에 K1을 켜두는 것과 비교하여 K2로 전환하는 것은, 제가 설명하는 이 추론 집약적인 에이전틱 워크로드 (agentic workload)에서 대략 또 다른 높은 한 자릿수 정도의 승리입니다. 전체 작업의 예상 이득은 정밀하게 제어된 벤치마크 수치가 아니라 대략 한 자릿수 중간 퍼센트 정도로 설명하겠습니다. 더 흥미로운 결과는 이제 전체 완료 과정 동안 하나의 타협적인 K를 선택할 필요가 없다는 점입니다.
저는 대략 다음과 같은 수치를 얻었습니다: 두 경로 모두 CUDA 그래프 (CUDA-graphed)를 적용했을 때, 추론 (reasoning) K1은 이 긴 과정 동안 평균 ~13.8 tok/s, 콘텐츠 (content) K2는 이 긴 과정 동안 평균 ~17.0 tok/s를 의미합니다. 이것이 목표였습니다.
안정성 / 컨텍스트 (Stability / context) 제공된 20분 이상의 검증 로그 동안: Traceback: 0 RuntimeError: 0 AssertionError: 0 CUDA OOM: 0 남은 컨텍스트 용량 (Context capacity remained): GPU KV 캐시 (KV cache): 9.02 GiB, KV 토큰 (KV tokens): 1,332,343, 네이티브 모델 컨텍스트 (Native model ctx): 1,048,576, 용량 (Capacity): 네이티브 대비 1.27x (1M). 로그에 기록된 에이전트 실행 (agentic run) 중 초기 콜드 프롬프트 처리 (Initial cold prompt processing) 속도는 여전히 약 844 tok/s였습니다.
실행 구성 (Launch configuration) 로컬 패치를 적용한 후, 중요한 새로운 스위치는 다음과 같습니다: export VLLM_DSPARK_PHASE_ADAPTIVE=1
설정된 최대 K는 2로 유지됩니다: --speculative-config '{"method":"dspark","num_speculative_tokens":2,"draft_sample_method":"greedy"}'
저의 전체 실행 구성은 다음과 같습니다:
MODEL="/path/to/DeepSeek-V4-Flash-0731"
export CUDA_DEVICE_ORDER=PCI_BUS_ID
export CUDA_VISIBLE_DEVICES=0
export LVLLM_MOE_NUMA_ENABLED=1
export LK_THREADS=12
export OMP_NUM_THREADS=12
export LK_THREAD_BINDING=CPU_CORE
export LVLLM_GPU_RESIDENT_MOE_LAYERS=0,1
export LVLLM_GPU_PREFILL_MIN_BATCH_SIZE=0
로컬 단계 적응형 (phase-adaptive) DSpark 패치:
추론 (reasoning) -> K1 CUDA 그래프 (CUDA graph)
콘텐츠/코드/도구 (content/code/tool) -> K2 CUDA 그래프 (CUDA graph)
export VLLM_DSPARK_PHASE_ADAPTIVE=1
export FLASHINFER_DISABLE_VERSION_CHECK=1
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
vllm serve "$MODEL"
--host 0.0.0.0
--port 8070
--tensor-parallel-size 1
--max-model-len 1048576
--gpu-memory-utilization 0.92
--trust-remote-code
--served-model-name DeepSeek-V4-Flash-0731
--compilation_config.cudagraph_mode FULL_DECODE_ONLY
--enable-prefix-caching
--enable-chunked-prefill
--max-num-batched-tokens 8192
--dtype bfloat16
--max-num-seqs 2
--enable-auto-tool-choice
--tool-call-parser deepseek_v4
--kv-cache-dtype fp8_ds_mla
--tokenizer-mode deepseek_v4
--reasoning-parser deepseek_v4
--default-chat-template-kwargs '{"enable_thinking": true, "reasoning_effort": "max"}'
--speculative-config
'{"method":"dspark","num_speculative_tokens":2,"draft_sample_method":"greedy"}' \ --disable-custom-all-reduce 한 가지 미적인 참고 사항: VLLM_DSPARK_PHASE_ADAPTIVE는 제가 직접 설정한 로컬 환경 변수이며 포크(fork)된 공식 환경 변수 목록에 등록되어 있지 않기 때문에, 서버에서 Unknown vLLM environment variable 경고를 출력합니다. 패치된 코드는 os.getenv()를 통해 이를 직접 읽어오므로, 이 설정에서 해당 경고가 발생하는 것은 예상된 것이며 해롭지 않습니다. 이를 재현한다면, 먼저 정상적인 고정-K (fixed-K) DSpark 추론이 작동하는 것을 확인한 후에 동일한 포크/빌드 제품군에 이 패치를 적용할 것입니다. 원샷 패처(one-shot patcher)는 제가 실제로 테스트한 구성에 맞춰 의도적으로 범위를 제한했습니다: DeepSeek-V4-Flash-0731 Lvllmds4-x 패키지, vLLM 2.3.9 보고, DSpark 구성 max K = 2, V2 모델 러너, FULL_DECODE_ONLY, max_num_seqs = 2. 이 패처는 토크나이저 ID를 검증하고, 수정하는 모든 파일을 백업하며, 누락되거나 모호한 소스 앵커(source anchors)를 거부하고, 파일을 교체하기 전에 전체 패치된 파일을 컴파일하며, 임포트 테스트(import tests)를 수행하고, 롤백 스크립트(rollback script)를 생성합니다. 패치 후, 이를 신뢰하기 전에 필수적이라고 간주하는 두 가지 시작 라인은 다음과 같습니다: CUDA 그래프 캡처 (FULL): 4/4, dspark CUDA 그래프 캡처 (FULL): 4/4. 그 후 런타임 시: DSpark adaptive K changed: 2 -> 1 phase=reasoning ... DSpark adaptive phase transition: ... marker=</think> DSpark adaptive K changed: 1 -> 2 phase=content. 만약 K1에서 다시 ~5-6 tok/s가 나온다면, 이는 거의 확실하게 캡처된 경로(captured path)를 벗어난 것이며, 저는 이를 성공적인 재현이라고 부르지 않을 것입니다. 아직 개선할 점들이 남아 있습니다. 가장 명백하고 큰 문제는 요청당(per-reque)
AI 자동 생성 콘텐츠
본 콘텐츠는 r/LocalLLaMA의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기