~/blog/fastllm-dual-2080ti-concurrency-scheduler

改裝 2080 Ti 22G · part 21

雙 2080 Ti FastLLM 升級:寫程式快 16%、兩人同時用,長請求不再擋人

❯ cat --toc

TL;DR

兩張改裝 2080 Ti 22G 的 FastLLM + DFlash2 換成自編的上游最新版:寫程式 116.1 tok/s(同一組題目原本 100.3)、數學 135.5、散文 74.2。兩個請求可以同時跑,一條 54K token 的長請求在讀 prompt 時,短請求 1.1 秒就開始回(原本等 39.5 秒)。做法是撤掉一個讓草稿猜中率下降的上游 commit、把草稿轉 NVFP4、改排程器;排程器那兩項已送上游 PR #755、#756。

改裝 2080 Ti 22G 系列 #21 封面:兩張並排的雙風扇顯示卡,一條切成幾段的長發光資料帶,段與段之間有幾個小方塊插進來先通過

前言

超市只開一個結帳櫃台的時候,前面那台推車堆滿月底大採購,後面只拿一瓶水的人也得乾等。

上一篇把 FastLLM + DFlash2 換上這兩張卡之後,它同時要接好幾個 agent 的請求,但一次只處理一個。只要有人丟進一段幾萬 token 的長文,其他人的短問題就排在後面 40 秒。

這一週把它改成這樣:三種題目都快了 14% 到 16%,兩個請求可以同時跑,長請求在讀 prompt 的時候,短請求一秒多就開始回。

這版跟上一篇差在哪:速度、並行、插隊三件事

先看結果,重點在最後一欄的第一和第三列。兩組都是同一台機器、同一支量測腳本,前後各量一次:

兩張 2080 Ti,TP=2,Qwen3.8-27B FP8上一篇的設定這一版
寫程式 / 數學 / 散文(tok/s)100.3 / 118.6 / 64.8116.1 / 135.5 / 74.2
同時處理的請求數12(兩條合計 175.8)
54K token 的請求讀 prompt 時,短請求等第一個字(p95)39.5 秒1.13 秒
取消一個長請求後,下一個請求等第一個字(最大值)9.8 秒0.54 秒
每個請求的 context 上限262,144222,080

140 題能力測試(不開思考 / 開思考)是 128 / 125 對 132 / 127,細節在後面。

量法:真實的 chat 請求,temperature 0,每次 512 token,三種題目各打三發取中位數。插隊和取消那兩列是 33 分鐘壓力測試(同一個亂數種子,8 輪、112 個請求)的結果。這組寫程式題要求附大量註解和說明,數字會比純程式碼的題目低。用上一篇那三題量,這一版是寫程式 175.3、數學 154.1、散文 64.7。

三件事是分開做的:

  1. 速度:換上游最新的 master,撤掉其中一個 commit,再把 DFlash2 草稿轉成 NVFP4。
  2. 兩個請求同時跑:--max_batch 2,同時拿掉 --tokens。
  3. 長請求不擋人:改排程器,讓它在長 prompt 的每一塊之間讓出來。

速度:撤掉 d4b04876,再把草稿轉 NVFP4

上一篇用的 FastLLM 停在 9 月 21 日的 14f849af。上游到 9 月 25 日的 a2bf07fd 多了 87 個 commit,直接換上去,數學和散文變快,寫程式反而掉了 15%。

我用同一支腳本對這 87 個 commit 做 git bisect,找到是 d4b04876「修复小批量 RMSNorm 归约舍入差异」。它改的是寬度 5120、batch 1 到 8 的 RMSNorm kernel,主模型和 DFlash2 草稿的 hidden size 剛好都是 5120。換上它之後,草稿第 3 個位置的接受率從 60.9% 掉到 43.5%,被主模型退回的猜測變多,吐字就慢了。

撤掉這一個 commit,再打開草稿的 NVFP4 量化(FASTLLM_DRAFT_QUANT=nvfp4,上游 7e0d6705 把它改成預設開啟)。看寫程式那一欄,第二列掉下去、後兩列拿回來:

版本寫程式數學散文
上一篇的設定(14f849af)100.3118.664.8
上游最新 a2bf07fd,草稿不量化84.8126.968.1
+ 撤掉 d4b04876102.6122.170.9
+ 草稿轉 NVFP4116.6135.874.7
長條圖:四個版本的寫程式、數學、散文吐字速度。上一篇的設定 100.3/118.6/64.8,上游最新 84.8/126.9/68.1,撤掉 d4b04876 後 102.6/122.1/70.9,再把草稿轉 NVFP4 後 116.6/135.8/74.7
上游最新版單獨換上去,寫程式掉到 84.8。撤掉 d4b04876 拿回來,草稿轉 NVFP4 再往上加。

草稿是一顆 5 層、3.6GB 的小模型,每一步都要跑。它的權重變小,猜的那段時間就變短,主模型驗的次數不變,整體吐字就快了。這一步只改草稿,主模型的權重完全沒動,所以不影響回答的內容。

要先講清楚:d4b04876 是上游為了讓不同 kernel 算出一致結果做的修正。我拿 FP32 當參考值比對新舊兩個 kernel,誤差一樣大,沒找到它算錯的證據。撤掉它是因為在這組硬體加 DFlash2 上它讓猜中率變低,換一張卡可能完全不受影響。

兩個請求同時跑:拿掉 --tokens,max_batch 才會生效

把 --max_batch 1 改成 2,其他不動,重啟後兩個請求還是一條一條輪流跑。log 裡有一行:

explicit-token safety max_batch limited 2 -> 1

Qwen3.8 是混合架構,大部分層用的是 GDN(一種線性注意力),每個請求除了 KV cache 還要帶一份不小的遞迴狀態。FastLLM 在你用 --tokens 明確指定 context 長度時,會把這些狀態的預算限制在 KV 空間的 25% 以內;我這組只放得下一個請求,它就默默把並行數壓回 1。這個 25% 寫死在程式裡,沒有開關可以調。

拿掉 --tokens,讓它自己算 KV 頁數,並行才會生效:

設定實際並行單條寫程式兩條同時,合計每個請求的 context 上限
--max_batch 2 --tokens 2621441(被壓回)116.9約 117(輪流)262,144
--max_batch 2,不帶 --tokens2116.7186222,080

單條速度不受影響,兩條同時跑的合計多了六成。代價是每個請求的 context 上限從 262,144 降到 222,080,兩個請求共用同一個 KV 池。

長請求不再擋人:排程器在每一塊之間讓出來

並行打開之後還剩一個問題:只要有一條長請求在讀 prompt,其他請求還是要等它讀完。一條 54K token 的請求讀 prompt 要 40 秒,後面的短請求就等 39 秒。

原因在排程器。Qwen3.8 配 DFlash 時,FastLLM 走的是一個專用的排程迴圈,它挑到長請求之後,會在同一個內層迴圈裡把 prompt 的每一塊(chunk)依序做完,中間不回頭看有沒有別的請求在排隊。所以 --chunked_prefill_size 只決定每一塊多大,不會讓別人插進來;我試了三種 chunk 大小和三個相關的環境變數,短請求一律等 37 到 41 秒。

我改的地方是:長 prompt 每做完一塊,就暫時放開模型的鎖,讓另一條排程執行緒去處理排隊中的短請求,然後再繼續下一塊。開關是 FASTLLM_COOPERATIVE_LONG_PREFILL=1,不設就跟原版完全一樣。

時間軸示意圖:原本長請求的 prompt 切成好幾塊依序做完,短請求第 3 秒送達,要等全部做完才開始,送達後 37.4 秒才有第一個字;改過之後每一塊之間讓出來,短請求在下一個空檔開始,送達後 1.2 秒就有第一個字、3.8 秒答完
長請求本身的讀取時間幾乎不變(40.5 秒),短請求不用排在它後面。

另一個一起修的:取消一個長請求時,上游只把它標記成已中止,prompt 的讀取迴圈沒檢查這個標記,GPU 會把整段硬算完,下一個請求要等 9.8 秒。現在每一塊之間都會檢查,取消後 0.5 秒就能接下一個請求,已經讀完的那幾塊還能留在 prefix cache 裡,重送同一段 prompt 時直接命中。

改完的實測,看第一列就好:

54K token 的長請求讀 prompt 期間原本改過之後
插進 1 條短請求:第一個字 / 答完(單次)37.4 秒 / 37.8 秒1.18 秒 / 3.8 秒
同時插進 2 條短請求:第一個字38.1 / 38.6 秒1.3 / 2.7 秒
插進 1 條短請求:第一個字(33 分鐘壓測,p95)39.5 秒1.13 秒
取消長請求後,下一個請求第一個字(壓測最大值)9.8 秒0.54 秒
長請求自己讀 prompt(壓測 p95)42.2 秒42.7 秒(+1%)

一條短請求插進來最划算,3.8 秒就答完。同時插進兩條時,兩條都會在 3 秒內開始,但它們要跟長請求搶同一組 GPU,生成得很慢(每秒 1 到 2 個字),要等長請求讀完才會加速。第三條會排在前面某一條結束之後,因為並行上限是 2。

這兩個改動都送到上游了:#755 是取消時中止,改動小、對所有人都有用;#756 是排程器,疊在 #755 上面。兩個都還沒合併,所以下面要自己編。

怎麼編:上游 a2bf07fd、撤掉一個 commit、合併兩個 PR

你需要跟上一篇一樣的環境:兩張改裝 2080 Ti 22G、一份有 sm_75 的 NCCL、CUDA 12.4(nvcc)、GCC 13、Python 3.14 的 venv。模型和草稿也是同一份:orcarouter/Qwen3.8-27B-Uncensored-FP8 和 z-lab/Qwen3.8-27B-DFlash2。

git clone https://github.com/ztxz16/fastllm && cd fastllm
git checkout -b dual-2080ti a2bf07fd
git revert --no-edit d4b04876                  # 讓 DFlash2 猜中率下降的那個 commit
git fetch origin pull/749/head:pr749 pull/756/head:pr756
git merge --no-edit pr749                      # 自訂 AllReduce 註冊快取無上限(長對話會吃滿 GPU0)
git merge --no-edit pr756                      # 已經包含 #755

#749 是 9 月送的:DFlash2 在雙卡上每一步都會多配一份指標表,永遠不釋放,長對話跑久了會把 GPU0 吃滿。這三個合併在 a2bf07fd 上都不會衝突。

編譯。後面那串 -D_AMX... 是 CUDA 12.4 配 GCC 13 的 workaround,沒加會在 AMX 的 header 編不過:

mkdir build-fastllm && cd build-fastllm
cmake .. -DUSE_CUDA=ON -DUSE_NUMAS=OFF -DCUDA_ARCH=75 -DCMAKE_CUDA_ARCHITECTURES=75 \
  -DCMAKE_CUDA_COMPILER=/usr/bin/nvcc \
  -DCMAKE_CUDA_FLAGS="-D_AMXTILEINTRIN_H_INCLUDED -D_AMXINT8INTRIN_H_INCLUDED -D_AMXBF16INTRIN_H_INCLUDED -D_AMXCOMPLEXINTRIN_H_INCLUDED"
make -j24 fastllm_tools
cd tools && ~/venvs/ftllm-dual/bin/pip install --no-deps --force-reinstall .

裝完一樣是 ftllm 這個套件,指令也一樣是 ftllm server。

啟動指令

export LD_PRELOAD=/home/coolthor/nccl-install/lib/libnccl.so.2   # 有 sm_75 的 NCCL
export CUDA_VISIBLE_DEVICES=0,1
export FASTLLM_CUDA_GRAPH=0                   # DFlash2 在 sm_75 還是要關
export FASTLLM_DRAFT_QUANT=nvfp4              # 預設就是 nvfp4,寫出來比較清楚
export FASTLLM_COOPERATIVE_LONG_PREFILL=1     # PR #756 的開關

~/venvs/ftllm-dual/bin/ftllm server \
  -p /mnt/nvme1t/models/qwen38-27b-uncensored-fp8 --model_name qwen38-27b-ud \
  --tp 2 --max_batch 2 --chunked_prefill_size 4096 \
  --gpu_mem_ratio 0.95 --kv_cache_dtype fp4 \
  --speculative_algorithm dflash \
  --speculative_draft_model_path /mnt/nvme1t/models/dflash2-qwen38-zlab \
  --speculative_num_draft_tokens 8 \
  --prefix_cache true \
  --chat_template /mnt/nvme1t/models/qwen-fastllm-low/chat_template.jinja \
  --host 0.0.0.0 --port 8082

跟上一篇的差別只有四個地方:拿掉 --tokens 262144、--max_batch 改 2、加 --chunked_prefill_size 4096、多兩個環境變數。chat template 還是上一篇那份 effort 改成 low 的版本。

啟動後看 log 確認兩件事:沒有 max_batch limited 那一行,以及 Model context window: 222080 tokens per session 這類的數字。

會卡住你的四件事

  1. 帶著 --tokens 開 --max_batch 2:並行數會被默默壓回 1,只有 log 裡一行提示。
  2. 直接換上游最新版:在這組硬體上寫程式會從 102 掉到 85,要撤掉 d4b04876。
  3. 想打開 CUDA Graph:新版開了不會像以前那樣第一個請求就崩,但反而慢 11%。維持 FASTLLM_CUDA_GRAPH=0。
  4. 以為 --chunked_prefill_size 能讓短請求插隊:不能,要 #756 那個開關。

能力:140 題 132 / 127

主模型的權重沒動,能力應該不變,還是照上一篇的 140 題重跑了一次(GSM8K 50、MATH-500 30、MuSR 20、HumanEval+ 40)。看合計那欄:

GSM8KMATH-500MuSRHumanEval+合計
上一篇,不開思考49291535128
這一版,不開思考50301636132
上一篇,開思考 + low5030936125
這一版,開思考 + low49301236127

多出來的幾題在誤差範圍內,說成「沒有變差」比較準。這兩輪是在加排程器之前的版本上跑的;加了排程器之後另外跑了 20 題 smoke test,20 題都對。

那,還能更快嗎?

這一篇動的都是程式和排程,主模型的權重一直是 FP8,每吐一個字還是要讀 29GB。2080 Ti 沒有 FP4 硬體,把主模型也壓到 4 bit 有沒有用?下一篇。


進階:除錯與實驗紀錄

不讀這節不影響使用。這裡是每一步的原始數字和走過的路。

git bisect 87 個 commit:一步 10 分鐘

每一步都是:編一版純上游(不疊任何 patch)、在另一個 port 開起來、用同一支腳本量寫程式三發取中位數,≥96 算好、≤89 算壞。兩端先量:14f849af 100.2、a2bf07fd 84.7,跟疊了我們 patch 的版本(100.3 / 84.8)一樣,所以退步跟 patch 無關。

commit寫程式 tok/s判定
14f849af100.2好
809d34b6(SM75 解碼調校)101.9好
00276210(DFlash 草稿緩衝、CUDA Graph)101.9好
b2283cc7102.2好
2dd79d9d102.2好
90fc11e4(移除 DFlash 實驗開關)102.2好
d4b04876(RMSNorm reduction)84.7壞
6023b07084.7壞
a2bf07fd84.7壞

d4b04876 前後,DFlash 接受率前三位從 87.2 / 74.4 / 60.9% 變成 83.9 / 61.2 / 43.5%。在 a2bf07fd 上只撤掉它:寫程式 102.3、數學 121.4、散文 70.3,數學和散文的進步都留著。

我懷疑新 kernel 在某些 shape 上算錯,寫了一支小程式比對:隨機產生 8×5120 的 FP16 測試資料,量級 0.01、0.5、1、10、100 五組,拿 FP32 算的結果當標準,新舊兩個 kernel 的最大絕對誤差一樣(約 0.00195),40,960 個值裡最多只有 2 個不同,最大差 0.000488。所以不能說它算錯。為什麼接受率會掉那麼多,我還解釋不了。

logprobs patch:零影響

我自己的版本另外疊了 #752(OpenAI API 回傳 logprobs)。擔心它拖慢速度,拿掉前後各量一次:舊版 100.3 / 100.3,新版 84.8 / 84.7,完全一樣。請求沒有要 logprobs 時,它走的就是原本的路徑。

CUDA Graph:「Resource deadlock avoided」是假線索

新版開 FASTLLM_CUDA_GRAPH=1,第一個請求就崩,錯誤訊息是 Resource deadlock avoided,看起來像是鎖出問題。往前找,真正的錯誤是 graph replay 時第一個 decode 回報 cudaErrorIllegalAddress (700)。程式因此要退出,解構時 MTP 執行緒去 pthread_join 自己,才冒出 deadlock 那一行。

看到這個訊息別去查鎖,往 log 前面找第一個 CUDA 錯誤。草稿轉 NVFP4 之後開 graph 不會崩,但寫程式 105.9、數學 120.2、散文 61.1,比不開慢 11%,所以一樣關著。

草稿 token 數與其他開關

在草稿 NVFP4 的版本上:--speculative_num_draft_tokens 4 和 6 都比 8 慢,大於 8 這份草稿 checkpoint 不接受(它的 block size 就是 8)。README 裡三個跟 SM75 有關的開關(FASTLLM_DFLASH_ATTENTION、FASTLLM_TP_NATIVE_GREEDY、SM75 解碼調校)分別關掉,差距都在 2% 以內,維持預設。

並行:三次才試對

第幾次設定單條寫程式兩條同時長請求中插短請求
1--max_batch 2 --tokens 262144116.9各約 58,輪流等長請求做完
2--max_batch 2,不帶 --tokens116.7各 93,合計 186還是要等
3第 2 次 + --chunked_prefill_size 4096116.2合計 186.6還是要等(38.6 秒)

第 1 次的 log 就是那行 explicit-token safety max_batch limited 2 -> 1。第 3 次測下來短請求還是要等,光調 chunk 大小沒用,我才去改排程器。

排程器:卡在哪一行

Qwen3.5 系列配 DFlash 走的是 Qwen35MTPLoop。它挑到長請求後,在 src/models/qwen3_5.cpp 約 24327 到 24489 行的內層迴圈把所有 chunk 做完,forward 鎖到 24715 行附近才放開。短請求在這段時間其實已經送達、也登記成待處理,只是排程器困在迴圈裡沒回頭看。(行號是 a2bf07fd 的。)

先試了不改程式的做法,全部沒用:

條件短請求第一個字長請求讀 prompt
基準,chunk 409637.4 秒40.2 秒
FASTLLM_ACTIVE_PREFILL_TOKEN_LIMIT=102437.740.5
FASTLLM_IDLE_PREFILL_BATCH_WAIT_US / QUIET_US 調整37.8 到 39.640.7 到 42.4
chunk 1024 / 2048 / 819240.7 / 38.0 / 38.243.6 / 40.9 / 41.1

讀原始碼才知道:ACTIVE_PREFILL_TOKEN_LIMIT 只在通用的排程迴圈生效,IDLE_PREFILL_* 只在另一個沒開的分支才讀。

壓力測試抓到的兩個退步

第一版原型把短請求第一個字壓到 1.26 秒,但 75 分鐘的 A/B 壓力測試(同一個亂數種子,各 18 輪、252 個請求)抓到:兩條請求同時生成時,合計從 175.5 掉到 73.3,每輪都有一條慢到每秒 41 個字。

原因是測試序列裡「兩條同時生成」剛好排在「取消一個長請求」之後,而取消不會中止 prompt 讀取。原版取消後的短請求要等 9.8 秒,等輪到兩條同時生成時,被取消的讀取剛好做完;原型把取消後的短請求 0.5 秒就處理掉了,主執行緒卻還在幫已取消的請求讀 prompt,兩條生成請求一條跟它搶 GPU,另一條要等它讀完。修掉取消這件事之後,同一套測試:

18 輪、252 個請求原版原型修正版
長請求中插短請求,第一個字 p9539.7 秒1.19 秒1.18 秒
取消後的短請求,第一個字中位數9.8 秒0.54 秒0.54 秒
兩條同時生成,合計175.573.3175.7
長請求讀 prompt,p9542.4 秒43.1 秒42.9 秒
錯誤 / 答錯0 / 00 / 00 / 0

第二個退步是修正版只能讓一條短請求插隊:輔助執行緒把被暫停的長請求也算進自己的名額(同一個檔案約 23570 到 23579 行),--max_batch 2 就只剩一個位置。跳過它之後,同時兩條短請求的第一個字從 1.3 / 36.7 秒變成 1.3 / 2.7 秒,log 裡在長請求讀取的每一塊之間都印出 Decode alive = 2。重啟三次重測,第二條都在 2.58 到 2.66 秒。

最終版跑 18 輪回歸測試:插短請求 p95 1.20 秒、取消後 0.65 秒、兩條同時合計 170.5、長請求 p95 +2.0%、0 錯誤。VRAM 峰值比修正版高約 200 MiB,但同一個執行檔把開關關掉跑 9 輪,也是同樣的斜率往上,所以是新版上游本來就有的現象,跟新排程器無關。

送上游前的三輪 code review

送 PR 前讓 Codex 做了三輪 review。第一輪抓到一個會卡死服務的問題:輔助執行緒空閒時無限期等待,而主執行緒通知它停止時沒拿 lock,通知可能落在它「檢查完、還沒開始等」的空檔,join() 就永遠等下去。另外三個是:取消刪掉 context 後,取 token 的函式重新上鎖時還用舊指標;輔助執行緒判斷長短請求用錯了門檻(4096 對 2048);建立執行緒失敗時沒清理。第二輪抓到修法把主迴圈的閒置等待也改成每 10 ms 醒一次、以及建執行緒失敗會讓排程器跟著退出。第三輪通過。

修完之後針對這幾點測:輔助執行緒停止 50 次都沒卡住;取消長請求的同時另一條在串流,30 輪 90 個請求都正常;2049 到 4096 token 的請求插進來,輔助執行緒不會接手去讀它的 prompt。

版本鎖定

  • 上游基底:a2bf07fd(2026-09-25)
  • 撤掉:d4b04876
  • 合併:#749、#755、#756(我自己的版本另外有 #751 多模態 prefix cache 修正和 #752 logprobs;#751 在 a2bf07fd 上跟 basellm.cpp 有衝突,要自己解,它只影響「長對話尾端附圖」那種情境)
  • CUDA 12.4、GCC 13.4、sm_75,NCCL 2.31.2(自編)

還沒做的

  • #755、#756 都還沒被合併,上游改動後可能要重新對齊。
  • 所有數字只在兩張 2080 Ti、TP=2、DFlash2 這一組上量過。d4b04876 的影響、草稿 NVFP4 的提升幅度,換一張卡可能都不一樣。
  • 同時插進兩條短請求時,它們在長請求讀完之前生成得很慢(每秒 1 到 2 個字)。要改善得讓排程器把短請求的生成跟長 prompt 的讀取放進同一步一起算,那是另一個改法。

同系列:

常見問題

FastLLM 開 --max_batch 2 為什麼還是一次只處理一個請求?
因為同時帶了 `--tokens`。Qwen3.8 這類混合模型每個請求的狀態很大,FastLLM 在你明確指定 `--tokens` 時會把並行數壓到 KV 預算的 25% 放得下的量,log 裡會出現 `explicit-token safety max_batch limited 2 -> 1`。拿掉 `--tokens` 讓它自己算 KV 頁數,並行才會生效,代價是每個請求的 context 上限變成自動算出的值(我這組是 222,080)。
FastLLM 的 --chunked_prefill_size 能讓短請求在長 prompt 讀取中插隊嗎?
不能。Qwen3.5/3.8 配 DFlash 時,排程器會在同一個迴圈裡把長 prompt 的所有 chunk 做完才去看別的請求,chunk 大小只影響每一塊多大。我送了上游 PR #756,加一個預設關閉的 `FASTLLM_COOPERATIVE_LONG_PREFILL=1`,在 chunk 之間讓出來服務短請求:54K token 的長請求讀 prompt 時,短請求從等 39.5 秒變成 1.1 秒開始回。
FastLLM 在 2080 Ti 上開 DFlash2,升級到最新版反而變慢?
我遇到的是上游 commit `d4b04876`(小 batch RMSNorm 的 reduction 改寫)。在兩張 2080 Ti、TP=2、DFlash2 這組上,它讓草稿第 3 個位置的接受率從 60.9% 掉到 43.5%,寫程式從 102 掉到 85 tok/s。撤掉它就回來了。這是只在這組硬體上量到的,別的卡不一定受影響。
在 FastLLM 裡取消一個長請求,GPU 會馬上停下來嗎?
上游目前不會。取消只是把請求標記成已中止,長 prompt 的讀取迴圈沒有檢查這個標記,會繼續把整段算完,我這邊取消後下一個請求要等 9.8 秒。PR #755 改成在每個 chunk 之間檢查這個標記,取消後 0.5 秒就能接下一個請求,已經讀完的部分仍能當 prefix cache 重用。

接著讀

不想錯過新文章?

訂閱我確保不漏接!

隨時一鍵退訂。