境排查vLLM EngineCore調(diào)度器崩潰實(shí)戰(zhàn))
1. 沒有 GPU 也能修引擎崩潰問題背景與整體思路1.1 一個(gè)反直覺的排查場(chǎng)景很多人一聽到“給推理引擎修 bug”第一反應(yīng)就是沒卡怎么復(fù)現(xiàn)沒卡怎么調(diào)試我一開始也是這么想的。手上這臺(tái)開發(fā)機(jī)沒有獨(dú)立顯卡只有一塊集成顯卡連 CUDA 都跑不起來更別說加載一個(gè)幾十 GB 的大模型權(quán)重了。但問題偏偏就出在一個(gè)部署了 vLLM 的線上環(huán)境里——服務(wù)跑著跑著EngineCore 進(jìn)程直接掛掉日志里只剩下一堆看不出所以然的堆棧前端請(qǐng)求全部超時(shí)。這種場(chǎng)景其實(shí)很典型真正出問題的機(jī)器在遠(yuǎn)端有卡、有負(fù)載、有真實(shí)流量而你手邊只有一臺(tái)沒有 GPU 的筆記本。你不可能把線上流量原樣搬過來也不可能靠“再跑一遍看看”來復(fù)現(xiàn)。這時(shí)候唯一能依靠的就是把崩潰這件事從“運(yùn)行時(shí)現(xiàn)象”降維成“代碼邏輯問題”用純 CPU 的方式去逼近它。我這次要處理的就是這樣一個(gè)問題vLLM 的 EngineCore 在特定調(diào)度條件下會(huì)崩潰。關(guān)鍵詞里提到的 EngineCore、調(diào)度器正是這次排查的兩個(gè)核心抓手。整篇文章我會(huì)把從拿到日志到定位根因、再到寫出修復(fù)補(bǔ)丁的完整過程拆開講重點(diǎn)不是“我修了個(gè)什么 bug”而是“在沒有 GPU 的情況下我是怎么把這個(gè)問題一步步逼到墻角的”。1.2 為什么調(diào)度器是這類崩潰的高發(fā)區(qū)先給不熟悉 vLLM 架構(gòu)的朋友補(bǔ)一下背景。vLLM 的推理服務(wù)大致分成幾層最上面是 API Server負(fù)責(zé)接收請(qǐng)求中間是 EngineCore負(fù)責(zé)調(diào)度和批處理最底下是 Worker真正在 GPU 上跑模型。EngineCore 里最核心的組件就是調(diào)度器Scheduler它決定每一輪迭代哪些請(qǐng)求進(jìn) batch、哪些請(qǐng)求被搶占、哪些請(qǐng)求被換出。調(diào)度器之所以容易出問題是因?yàn)樗瑫r(shí)要處理好幾件互相牽制的事顯存預(yù)算、序列長度、搶占策略、請(qǐng)求優(yōu)先級(jí)。任何一個(gè)狀態(tài)沒對(duì)齊就可能出現(xiàn)“調(diào)度器認(rèn)為某個(gè)請(qǐng)求還在但實(shí)際它的狀態(tài)已經(jīng)被釋放”這種不一致。而這類不一致在 GPU 上往往表現(xiàn)為崩潰在 CPU 上卻可能只是邏輯錯(cuò)誤——這恰恰給了我們無卡排查的機(jī)會(huì)。我這次的思路很明確不去復(fù)現(xiàn) GPU 上的崩潰現(xiàn)場(chǎng)而是把調(diào)度器的狀態(tài)機(jī)單獨(dú)拎出來用 CPU 跑單元測(cè)試構(gòu)造出會(huì)導(dǎo)致狀態(tài)不一致的輸入序列。只要能在純 CPU 環(huán)境下讓調(diào)度器邏輯暴露出問題就等于間接定位了 GPU 上的崩潰根因。1.3 整體排查路線圖我把整個(gè)過程分成四步后面每個(gè)章節(jié)會(huì)展開講讀日志、縮小范圍從崩潰堆棧里找到可疑的模塊和函數(shù)確認(rèn)問題出在調(diào)度階段而不是模型計(jì)算階段。搭 CPU 復(fù)現(xiàn)環(huán)境不裝 GPU 依賴只裝 vLLM 的純 Python 部分用假的模型和假的 KV Cache 跑調(diào)度邏輯。構(gòu)造觸發(fā)用例根據(jù)日志里的請(qǐng)求模式反推出可能觸發(fā)崩潰的調(diào)度序列寫成可重復(fù)執(zhí)行的測(cè)試。定位根因、寫補(bǔ)丁、驗(yàn)證找到狀態(tài)不一致的具體位置改代碼再用同一套 CPU 測(cè)試驗(yàn)證修復(fù)有效。這條路線的核心價(jià)值在于它不依賴任何特定硬件任何一臺(tái)能跑 Python 的機(jī)器都能復(fù)現(xiàn)。對(duì)于經(jīng)常要處理遠(yuǎn)端推理服務(wù)問題的同學(xué)來說這套方法可以反復(fù)套用。2. 從日志到代碼核心細(xì)節(jié)解析與實(shí)操要點(diǎn)2.1 崩潰日志里到底該看什么拿到一份 EngineCore 崩潰日志很多人第一眼會(huì)被幾十行堆棧嚇到然后開始從頭往下讀。我的習(xí)慣是反過來先看最后拋出的異常類型再看異常發(fā)生前最后幾個(gè)被調(diào)用的業(yè)務(wù)函數(shù)最后才回頭看完整堆棧。這次日志里最關(guān)鍵的信息有三條第一異常類型是AssertionError不是段錯(cuò)誤也不是 OOM說明這是代碼里主動(dòng)斷言的邏輯被違反了第二斷言發(fā)生在調(diào)度相關(guān)的代碼路徑上第三崩潰前最后一次成功的調(diào)度迭代里出現(xiàn)了“請(qǐng)求被搶占后又立刻被重新調(diào)度”的模式。這三條信息合起來基本可以判斷問題不在模型計(jì)算而在調(diào)度器的狀態(tài)管理。因?yàn)槿绻秋@存或計(jì)算問題異常類型通常是 CUDA 相關(guān)的錯(cuò)誤而AssertionError加上調(diào)度路徑幾乎可以鎖定是邏輯 bug。提示讀日志時(shí)優(yōu)先關(guān)注異常類型和最后一個(gè)業(yè)務(wù)函數(shù)調(diào)用而不是堆棧的行數(shù)。堆棧越長越說明問題可能被層層包裝真正的根因往往在中間某一層。2.2 為什么可以脫離 GPU 復(fù)現(xiàn)調(diào)度問題這里要解釋一個(gè)關(guān)鍵判斷調(diào)度器本質(zhì)上是一段純邏輯代碼它不直接操作 GPU。調(diào)度器做的事情是根據(jù)當(dāng)前顯存預(yù)算和請(qǐng)求狀態(tài)決定這一輪哪些請(qǐng)求能進(jìn) batch。它調(diào)用的是抽象的 KV Cache 管理器接口而不是直接調(diào) CUDA。這意味著只要我們能提供一個(gè)“假的” KV Cache 管理器讓它按照我們?cè)O(shè)定的規(guī)則返回顯存占用和釋放結(jié)果就能在 CPU 上完整跑通調(diào)度邏輯。模型本身不需要真的加載因?yàn)檎{(diào)度階段根本不涉及前向計(jì)算。我實(shí)際的做法是找到 vLLM 里調(diào)度器依賴的 KV Cache 抽象接口寫一個(gè) mock 實(shí)現(xiàn)把 block 的分配和釋放都記錄成日志。這樣每次調(diào)度迭代我都能看到“調(diào)度器認(rèn)為的 block 狀態(tài)”和“mock 實(shí)際記錄的 block 狀態(tài)”是否一致。不一致的地方就是 bug 的藏身之處。2.3 搭建最小 CPU 復(fù)現(xiàn)環(huán)境的關(guān)鍵步驟搭環(huán)境這一步坑其實(shí)不少。我踩過的幾個(gè)典型問題不要裝完整 vLLM完整安裝會(huì)拉一堆 CUDA 相關(guān)依賴在沒有 GPU 的機(jī)器上要么裝不上要么裝上了 import 就報(bào)錯(cuò)。正確做法是只裝純 Python 依賴或者直接從源碼里把調(diào)度相關(guān)模塊單獨(dú)拎出來。注意 torch 版本即使不跑 GPU調(diào)度代碼里也可能 import torch。裝 CPU 版 torch 即可不要裝帶 CUDA 的版本否則 import 時(shí)會(huì)嘗試加載 CUDA 庫。隔離虛擬環(huán)境這一點(diǎn)非常重要。因?yàn)榘惭b vLLM 相關(guān)依賴時(shí)很容易把已經(jīng)裝好的 torch 版本覆蓋掉。用獨(dú)立的 venv 或 conda 環(huán)境避免污染主環(huán)境。具體步驟大致是這樣# 創(chuàng)建獨(dú)立環(huán)境 python -m venv vllm_cpu_debug source vllm_cpu_debug/bin/activate # 安裝 CPU 版 torch pip install torch --index-url https://download.pytorch.org/whl/cpu # 只安裝調(diào)度模塊需要的輕量依賴 pip install numpy pytest然后把 vLLM 源碼里調(diào)度器相關(guān)的幾個(gè)文件復(fù)制到工作目錄手動(dòng)處理一下 import 路徑。這一步不需要裝整個(gè)包只要保證調(diào)度器能 import 成功、能實(shí)例化就行。注意不同版本的 vLLM 調(diào)度器接口差異較大復(fù)制源碼前先確認(rèn)你線上環(huán)境用的是哪個(gè)版本避免用錯(cuò)接口導(dǎo)致復(fù)現(xiàn)結(jié)果對(duì)不上。2.4 構(gòu)造觸發(fā)用例的核心思路有了環(huán)境下一步是構(gòu)造能觸發(fā)崩潰的輸入。這里不能瞎試要根據(jù)日志里的請(qǐng)求模式反推。我總結(jié)了一個(gè)“三要素法”請(qǐng)求到達(dá)順序日志里崩潰前請(qǐng)求是批量到達(dá)還是逐個(gè)到達(dá)這決定了調(diào)度器每輪看到的隊(duì)列長度。序列長度分布有沒有特別長的請(qǐng)求和特別短的請(qǐng)求混在一起長請(qǐng)求容易觸發(fā)搶占。搶占發(fā)生時(shí)機(jī)搶占是在顯存快滿時(shí)發(fā)生還是在顯存充足時(shí)也發(fā)生這能區(qū)分是預(yù)算計(jì)算錯(cuò)誤還是狀態(tài)管理錯(cuò)誤。我這次的用例就是圍繞“長請(qǐng)求觸發(fā)搶占、搶占后短請(qǐng)求立刻補(bǔ)位”這個(gè)模式構(gòu)造的。寫成一個(gè) pytest 測(cè)試每次運(yùn)行都能穩(wěn)定復(fù)現(xiàn)斷言失敗。到這一步問題已經(jīng)從“線上偶發(fā)崩潰”變成了“本地必現(xiàn)的邏輯錯(cuò)誤”排查難度直接降了一個(gè)數(shù)量級(jí)。3. 實(shí)操過程從復(fù)現(xiàn)到定位根因的完整實(shí)現(xiàn)3.1 用 mock KV Cache 記錄狀態(tài)變化復(fù)現(xiàn)環(huán)境搭好后第一件事是寫 mock。我的 mock 設(shè)計(jì)很簡單維護(hù)一個(gè) block 集合分配時(shí)加入釋放時(shí)移除每次操作都打印當(dāng)前集合狀態(tài)。關(guān)鍵是要讓 mock 的行為和真實(shí) KV Cache 的語義一致——比如釋放一個(gè)已經(jīng)被釋放的 block真實(shí)實(shí)現(xiàn)可能會(huì)報(bào)錯(cuò)mock 也要報(bào)錯(cuò)。class MockKVCache: def __init__(self, num_blocks): self.free_blocks set(range(num_blocks)) self.allocated {} def allocate(self, seq_id, num_blocks): if len(self.free_blocks) num_blocks: return False blocks [self.free_blocks.pop() for _ in range(num_blocks)] self.allocated[seq_id] blocks return True def free(self, seq_id): if seq_id not in self.allocated: raise RuntimeError(fdouble free: {seq_id}) for b in self.allocated.pop(seq_id): self.free_blocks.add(b)這個(gè) mock 里最關(guān)鍵的是free方法里的雙重釋放檢查。真實(shí)環(huán)境里雙重釋放往往不會(huì)立刻報(bào)錯(cuò)而是導(dǎo)致后續(xù)分配拿到臟 block最終在 GPU 上表現(xiàn)為崩潰。mock 把它變成顯式異常問題就暴露出來了。3.2 復(fù)現(xiàn)腳本的編寫與運(yùn)行有了 mock接下來寫復(fù)現(xiàn)腳本。腳本的核心是模擬調(diào)度器的多輪迭代每輪傳入不同的請(qǐng)求狀態(tài)觀察 mock 是否拋出異常。def test_scheduler_preemption(): cache MockKVCache(num_blocks10) scheduler Scheduler(cache) # 第一輪一個(gè)長請(qǐng)求占滿大部分 block scheduler.add_request(seq_id1, num_blocks8) scheduler.step() # 第二輪新請(qǐng)求進(jìn)來觸發(fā)搶占 scheduler.add_request(seq_id2, num_blocks5) scheduler.step() # 第三輪被搶占的請(qǐng)求重新調(diào)度 scheduler.step()運(yùn)行這個(gè)測(cè)試果然在第三輪step時(shí)拋出了double free異常。這說明調(diào)度器在搶占和重新調(diào)度的過程中對(duì)同一個(gè)請(qǐng)求的 block 釋放了兩次。問題定位到了。3.3 根因分析狀態(tài)機(jī)里的一個(gè)遺漏分支順著異常往上追我找到了調(diào)度器里處理搶占的那段代碼。邏輯大致是當(dāng)顯存不足時(shí)選擇一個(gè) victim 請(qǐng)求釋放它的 block把它標(biāo)記為preempted。然后在下一輪preempted的請(qǐng)求會(huì)被重新加入等待隊(duì)列。問題出在當(dāng)一個(gè)請(qǐng)求被搶占后如果它同時(shí)還在另一個(gè)數(shù)據(jù)結(jié)構(gòu)里被引用重新調(diào)度時(shí)就會(huì)走兩條釋放路徑。一條是搶占時(shí)的釋放另一條是重新調(diào)度失敗時(shí)的清理釋放。兩條路徑都調(diào)用了free導(dǎo)致雙重釋放。這個(gè) bug 在 GPU 上之所以表現(xiàn)為崩潰而不是顯式異常是因?yàn)檎鎸?shí)的 KV Cache 釋放是異步的雙重釋放不會(huì)立刻報(bào)錯(cuò)而是破壞了 block 的引用計(jì)數(shù)最終在某個(gè)后續(xù)分配時(shí)觸發(fā) CUDA 錯(cuò)誤。這也解釋了為什么線上日志里看不到直接的“double free”只有一堆間接的崩潰堆棧。3.4 補(bǔ)丁編寫與 CPU 驗(yàn)證定位到根因后補(bǔ)丁其實(shí)很簡單在釋放 block 前加一個(gè)狀態(tài)檢查確保同一個(gè)請(qǐng)求不會(huì)被釋放兩次。def free_request(self, seq_id): if seq_id not in self.allocated: return # 已經(jīng)釋放過直接返回 for b in self.allocated.pop(seq_id): self.free_blocks.add(b)改完后重新跑復(fù)現(xiàn)腳本測(cè)試通過。為了確認(rèn)沒有引入新問題我又補(bǔ)了幾個(gè)邊界用例連續(xù)搶占、搶占后立即取消、多個(gè)請(qǐng)求同時(shí)搶占。全部通過后才把補(bǔ)丁提交到遠(yuǎn)端環(huán)境做真實(shí)驗(yàn)證。提示CPU 復(fù)現(xiàn)通過不代表 GPU 上一定沒問題但它是必要條件。如果 CPU 上都跑不過GPU 上必然有問題。所以先用 CPU 測(cè)試做快速迭代再用 GPU 做最終確認(rèn)效率最高。4. 常見問題與排查技巧實(shí)錄4.1 無卡排查常見問題速查表問題現(xiàn)象可能原因排查方向import vLLM 就報(bào) CUDA 錯(cuò)誤裝了帶 CUDA 的 torch換 CPU 版 torch隔離環(huán)境調(diào)度器實(shí)例化失敗依賴的配置對(duì)象缺失手動(dòng)構(gòu)造最小配置或從源碼里找默認(rèn)值復(fù)現(xiàn)腳本跑不出異常用例構(gòu)造不對(duì)對(duì)照日志里的請(qǐng)求模式調(diào)整到達(dá)順序和長度分布修復(fù)后 CPU 通過但線上仍崩還有其他觸發(fā)路徑擴(kuò)大用例覆蓋補(bǔ)充邊界場(chǎng)景安裝依賴覆蓋了已有 torch環(huán)境未隔離用 venv 或 conda 獨(dú)立環(huán)境4.2 幾個(gè)容易踩的坑第一個(gè)坑是盲目相信日志里的行號(hào)。線上環(huán)境的代碼可能和本地版本不一致行號(hào)對(duì)不上是常事。正確做法是根據(jù)函數(shù)名和調(diào)用關(guān)系去定位而不是死磕行號(hào)。第二個(gè)坑是試圖在 CPU 上跑完整模型。完全沒必要調(diào)度問題不涉及前向計(jì)算跑模型只會(huì)浪費(fèi)時(shí)間還可能引入新變量。把范圍縮小到調(diào)度器本身效率高得多。第三個(gè)坑是忽略 mock 的語義準(zhǔn)確性。mock 太寬松問題復(fù)現(xiàn)不出來mock 太嚴(yán)格又會(huì)引入假陽性。我的經(jīng)驗(yàn)是mock 的行為要盡量貼近真實(shí)實(shí)現(xiàn)的契約尤其是錯(cuò)誤處理部分。4.3 一個(gè)提升效率的小技巧如果你經(jīng)常要處理這類遠(yuǎn)端推理服務(wù)的問題建議在本地維護(hù)一套“調(diào)度器單元測(cè)試集”。每次遇到新的調(diào)度相關(guān) bug就把觸發(fā)用例加進(jìn)去。時(shí)間長了這套測(cè)試集就成了你的回歸防線很多問題在提交前就能被攔住。我自己這套測(cè)試集現(xiàn)在有二十多個(gè)用例覆蓋了搶占、取消、超長序列、并發(fā)到達(dá)等場(chǎng)景。這次修的這個(gè) bug其實(shí)就是被其中一個(gè)用例的變體觸發(fā)的。沒有這套積累光靠臨時(shí)構(gòu)造用例排查時(shí)間至少要翻倍。5. 從這次排查延伸出的幾點(diǎn)經(jīng)驗(yàn)5.1 硬件不是排查邏輯問題的門檻這次經(jīng)歷最大的收獲是讓我重新認(rèn)識(shí)了“復(fù)現(xiàn)”這件事。很多人把復(fù)現(xiàn)等同于“原樣重跑”但實(shí)際上復(fù)現(xiàn)的本質(zhì)是讓問題在可控環(huán)境下穩(wěn)定出現(xiàn)。只要能把問題從硬件依賴中剝離出來沒有 GPU 一樣能修引擎 bug。調(diào)度器、狀態(tài)機(jī)、資源管理器這類組件本質(zhì)上都是純邏輯代碼。它們的 bug 大多源于狀態(tài)不一致而狀態(tài)不一致完全可以用 mock 和單元測(cè)試來捕捉。GPU 只是讓這些 bug 的表現(xiàn)形式變得更“嚇人”而已。5.2 日志分析要抓主要矛盾一份崩潰日志里90% 的信息是噪音。真正有用的往往就那兩三條異常類型、最后一個(gè)業(yè)務(wù)函數(shù)、崩潰前的關(guān)鍵狀態(tài)變化。抓住這三條就能把排查范圍縮小到一兩個(gè)模塊。剩下的時(shí)間應(yīng)該花在構(gòu)造復(fù)現(xiàn)用例上而不是反復(fù)讀堆棧。5.3 補(bǔ)丁要小驗(yàn)證要狠定位到根因后補(bǔ)丁能小則小。這次我只加了一個(gè)狀態(tài)檢查沒有重構(gòu)任何邏輯。小補(bǔ)丁的好處是引入新風(fēng)險(xiǎn)的概率低review 起來也快。但驗(yàn)證要狠CPU 用例要覆蓋各種邊界GPU 上還要跑一輪真實(shí)流量確認(rèn)。兩者都過了才算真正修好。最后分享一個(gè)我自己的習(xí)慣每次修完一個(gè) bug我都會(huì)在代碼注釋里寫清楚“這個(gè)檢查是為了防止什么場(chǎng)景”。這樣下次有人改到這段代碼時(shí)能立刻明白為什么不能刪。很多 bug 的復(fù)發(fā)就是因?yàn)楹髞淼娜瞬恢喇?dāng)初為什么這么寫。