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