![【Bug已解決】[Bug]: MTP speculative decoding crash with illegal memory access on long sequences (Qwen3.6-](http://pic.xiahunao.cn/yaotu/【Bug已解決】[Bug]: MTP speculative decoding crash with illegal memory access on long sequences (Qwen3.6-)
【Bug已解決】[Bug] MTP speculative decoding crash with illegal memory access on long sequences (Qwen3.6-27B-FP8, v0.19.1) 解決方案一、現(xiàn)象長什么樣在Qwen3.6-27B-FP8 vLLM 0.19.1 上開MTPmulti-token prediction多 token 預(yù)測投機解碼短序列一切正常但一旦序列接近 KV 緩存上限長文檔、長對話forward 中途進程崩潰報非法內(nèi)存訪問torch.OutOfMemoryError: CUDA out of memory (有時) RuntimeError: CUDA error: an illegal memory access was encountered或者更明確指向投機解碼層illegal memory access at kernel mtp_draft_forward: slot index 16384 num_kv_slots 16384幾個特征短序列比如 2k token 以內(nèi)完全正常長序列接近--max-model-len必崩。只在開 MTP 時崩關(guān)掉 MTPspeculative_configNone長序列也能跑。崩的位置是 MTP 的 draft 前向不是主模型前向。報錯有時是illegal memory access有時是out of memory本質(zhì)是同一件事MTP 往「超出已分配 KV 槽位」的地方寫了數(shù)據(jù)。本質(zhì)MTP 一次草擬k個 token會把當(dāng)前位置往后推k格去寫 KV 緩存當(dāng)序列已經(jīng)很長、剩余 KV 槽位不足k個時草擬位置越界kernel 寫到了未分配顯存 → 非法內(nèi)存訪問。二、背景MTP 的做法是主模型算出下一個 token 后MTP 頭基于「已生成的序列 剛算出的 token」再一次性草擬出接下來 k 個 token比如 k3然后主模型并行驗證這 k 個。為了草擬第k個 tokenMTP 前向需要把位置pos, pos1, ..., posk-1的 KV 都寫進緩存、并讀出來算下一層。問題在于「寫 KV 緩存」這一步和「KV 緩存容量」的耦合KV 緩存是按max_model_len預(yù)分配好固定槽位的比如 16384 個 slot。普通解碼每次只推進 1 個位置永遠不會越界因為調(diào)度器保證序列長度 ≤ max_model_len。但 MTP 一次要推進k個位置。調(diào)度器在計算「這個序列還能不能接著生成」時往往只按「主模型 1」來算剩余槽位沒把 MTP 要額外占的k-1個槽位算進去。于是當(dāng)序列長度 max_model_len - 2時調(diào)度器認為「還能生成」MTP 卻要寫pos到pos2共 3 個槽位最后一個槽位pos2 max_model_len已經(jīng)越界 → kernel 寫未分配顯存 → 非法內(nèi)存訪問。這和普通「序列超長」不同普通情況調(diào)度器會攔下但 MTP 把「一次占用的槽位數(shù)」從 1 變成了 k調(diào)度器的邊界判斷沒跟著改漏洞就出現(xiàn)了。三、根因根因是MTP 草擬長度k沒有被納入 KV 槽位的邊界核算導(dǎo)致長序列末尾草擬位置越界三層第一層主因調(diào)度器的「剩余槽位」判斷沒加 MTP 的k余量。調(diào)度器決定「這個序列還能不能生成下一個 token」時檢查的是seq_len 1 max_model_len。但 MTP 實際上需要seq_len k max_model_len。差了k-1個槽位序列在max_model_len - k seq_len max_model_len - 1這段區(qū)間里調(diào)度器放行、MTP 越界。第二層MTP draft 前向沒有對 slot 做邊界斷言。draft kernel 拿到pos和k后直接kv_cache[pos i] ...沒有任何if pos i num_slots: 截斷/報錯的防護。它假設(shè)「調(diào)用方保證槽位夠」但調(diào)用方調(diào)度器的保證是錯的于是越界寫直接發(fā)生。第三層錯誤表現(xiàn)不穩(wěn)定IMA vs OOM。越界寫的后果取決于「越界到哪」若越界到同一塊已分配顯存的鄰近區(qū)域可能只是靜默污染偶爾還能跑完但結(jié)果錯若越界到未映射顯存就是illegal memory access若越界觸發(fā)了一次額外的顯存分配就是out of memory。同一個根因三種表象增加排查難度。一句話MTP 的草擬長度沒被調(diào)度器算進 KV 邊界長序列末尾草擬越界寫未分配顯存表現(xiàn)為非法內(nèi)存訪問或偶發(fā) OOM。四、最小可運行復(fù)現(xiàn)下面用純 Python 模擬「MTP 草擬 k 個 token但調(diào)度器只按 1 判斷邊界長序列末尾越界」的控制流不需要 GPUclass KVCache: def __init__(self, num_slots): self.slots [None] * num_slots self.num num_slots def write(self, pos, k, value): # MTP draft 前向?qū)?pos .. posk-1 for i in range(k): idx pos i if idx self.num: # 原版沒有這個檢查直接越界 raise IndexError(fslot {idx} num_slots {self.num}) self.slots[idx] value def can_generate(seq_len, max_len, k, speculative): # 調(diào)度器的邊界判斷 needed seq_len (k if speculative else 1) return needed max_len def main(): max_len, k 16, 3 cache KVCache(max_len) # 序列化到 seq_len 14max_len - 2 seq_len max_len - 2 speculative True # 調(diào)度器認為14 1 15 16放行 print(調(diào)度器放行:, can_generate(seq_len, max_len, k, speculative)) # 但 MTP 要寫 14,15,16 - 16 越界 try: cache.write(seq_len, k, draft_token) print(寫成功實際會越界) except IndexError as e: print(復(fù)現(xiàn)成功:, e) if __name__ __main__: main()跑出來會打印調(diào)度器放行: True然后復(fù)現(xiàn)成功: slot 16 num_slots 16——調(diào)度器以為能生成、MTP 卻越界和線上「長序列末尾崩潰」完全一致。五、解決方案第一層最小直接修復(fù)最省事的救火關(guān)掉 MTP退回普通解碼長序列立刻能跑。代價是吞吐下降失去投機加速llm LLM( modelQwen3.6-27B-FP8, # speculative_configNone # 不啟用 MTP )或者把--max-model-len調(diào)大一點給 MTP 的k余量留出空間代價是 KV 緩存顯存變大vllm serve Qwen3.6-27B-FP8 \ --speculative-config {method:mtp,num_speculative_tokens:3} \ --max-model-len 16384 \ --gpu-memory-utilization 0.8 # 留出 KV 余量更精準(zhǔn)的臨時規(guī)避限制 MTP 只在「剩余槽位充足」時啟用剩余不足k就退回單 token 解碼。這是第一層的「帶保護」版本def safe_num_draft(seq_len, max_len, k): # 剩余槽位不足以支撐 k 個草擬時自動縮減到 1普通解碼 remaining max_len - seq_len return min(k, max(1, remaining))六、解決方案第二層結(jié)構(gòu)性改進第一層是「避開/手動留余量」第二層是「讓調(diào)度器和 MTP 用同一套邊界規(guī)則」——核心是把 MTP 的k納入「可生成判定」和「KV 槽位核算」的單一事實來源from dataclasses import dataclass dataclass class SeqBounds: max_len: int num_speculative: int 1 def can_generate(self, seq_len: int) - bool: # 單一邊界規(guī)則主模型 全部草擬 token 都必須落在 max_len 內(nèi) needed seq_len self.num_speculative return needed self.max_len def draft_slots_ok(self, pos: int, k: int) - bool: # MTP draft 前向的邊界檢查pos .. posk-1 必須全部合法 return pos k self.max_len def check_position(self, pos: int, k: int) - None: assert self.draft_slots_ok(pos, k), ( fMTP draft 越界: pos{pos} k{k} 需要槽位 {posk} f但 max_len{self.max_len} ) def check_expert(self, pos: int, k: int) - None: # 專家路由側(cè)的同樣檢查MoE 下 token 也要落到合法 slot self.check_position(pos, k)調(diào)度器在決定是否繼續(xù)生成時統(tǒng)一調(diào)用can_generate把num_speculative算進去MTP draft kernel 入口先check_position(pos, k)再寫 KVdef mtp_draft_forward(kv_cache, pos, k, bounds: SeqBounds): bounds.check_position(pos, k) # 越界立刻報錯絕不寫未分配顯存 for i in range(k): kv_cache.write(pos i, compute_token(pos i))這樣「邊界規(guī)則」只有一份調(diào)度器和 MTP 不可能再各算各的。七、解決方案第三層斷言 / CI 守護把「MTP 不越界」「調(diào)度器按 k 判斷」「長序列末尾安全降級」固化成測試import pytest def test_draft_within_bounds_ok(): b SeqBounds(max_len16, num_speculative3) b.check_position(10, 3) # 10..12 16應(yīng)通過 def test_draft_at_boundary_raises(): b SeqBounds(max_len16, num_speculative3) with pytest.raises(AssertionError): b.check_position(14, 3) # 14..16 越界 def test_scheduler_accounts_for_k(): b SeqBounds(max_len16, num_speculative3) # seq_len14 時1431716調(diào)度器應(yīng)拒絕繼續(xù)生成 assert b.can_generate(14) is False assert b.can_generate(13) is True # 13316 16 def test_long_seq_tail_safe_degrade(): # 長序列末尾MTP 自動退化成單 token不越界 b SeqBounds(max_len16, num_speculative3) seq_len 15 k min(b.num_speculative, 16 - seq_len) # k1 b.check_position(seq_len, k) # 15..15 合法 def test_no_ima_on_max_len(): # 端到端在 max_len 處停止草擬不應(yīng)觸發(fā)越界 b SeqBounds(max_len16, num_speculative3) for seq_len in range(0, 16): k 3 if b.can_generate(seq_len) else 0 if k: b.check_position(seq_len, k) assert True再加一個端到端回歸長序列 MTP 跑到max_model_len不崩def test_mtp_long_sequence_no_ima(): engine make_engine(modelQwen3.6-27B-FP8, speculative{method: mtp, num_speculative_tokens: 3}, max_model_len16384) out engine.generate(超長文檔... * 500, max_tokens16384) assert out is not None # 不應(yīng) illegal memory access八、排查清單看報錯是否illegal memory access/out of memory且棧指向 MTP draft 前向 → 坐實本問題。短序列能跑、長序列崩且只在開 MTP 時崩 → 基本是 MTP 越界。臨時救火關(guān) MTP或調(diào)大--max-model-len或在長序列末尾手動降num_speculative。檢查調(diào)度器「剩余槽位」判斷是否包含 MTP 的k余量最常見疏漏。長期修復(fù)邊界規(guī)則單一化調(diào)度器與 MTP 共用SeqBoundsdraft 前向前做check_position。升級 vLLM 到合了 MTP 邊界修復(fù)的版本并跑上面的長序列回歸。若用CUDA_LAUNCH_BLOCKING1TORCH_USE_CUDA_DSA1復(fù)現(xiàn)能讓越界錯誤定位到精確 kernel 行。九、小結(jié)MTP 長序列非法內(nèi)存訪問不是 FP8 或 Qwen 的鍋而是MTP 一次草擬 k 個 token但調(diào)度器的 KV 邊界判斷只按 1 算長序列末尾草擬位置越界寫未分配顯存。最小修復(fù)是關(guān) MTP / 調(diào)大 max_model_len / 末尾降 k結(jié)構(gòu)性修復(fù)是把邊界規(guī)則收斂成單一SeqBounds、draft 前向前做check_position最后用 pytest 把「不越界」「調(diào)度器按 k 判斷」「長序列安全降級」鎖死。配合CUDA_LAUNCH_BLOCKING1能快速定位越界 kernel。抓住「投機解碼一次性占用的槽位數(shù) ≠ 1」這條所有 spec decode 的邊界坑都能照此排查。