MLSys 2024 · 論文導讀

Punica

一顆 GPU、一份 backbone 權重、幾十個不同的 LoRA 模型,塞進同一個 batch 一起跑。關鍵只有一個新 CUDA kernel:SGMV。

Multi-Tenant LoRA Serving SGMV CUDA Kernel LLM Inference Llama-2 7B / 13B / 70B A100 12× throughput

Lequn Chen*, Zihao Ye*, Yongji Wu, Danyang Zhuo, Luis Ceze, Arvind Krishnamurthy · University of Washington & Duke University(*共同第一作者) · arXiv:2310.18547

SECTION 01

問題定義 — k×n 個 GPU 的荒謬

LoRA 讓每個租戶都能用很低的成本微調出自己的模型。於是 ML 服務商面對的不再是「服務一個大模型」,而是「同時服務幾百個彼此不同、但共享同一個 backbone 的小模型」。用現有的 serving 系統做這件事,浪費得非常離譜。

如果把每個 LoRA 模型當成獨立訓練出來的模型來服務,就是在浪費 GPU。假設服務一個 LoRA 模型需要 k 個 GPU,服務 n 個不同的 LoRA 模型看起來就需要 k × n 個 GPU。這個直觀做法忽略了一件事:這些 LoRA 模型都源自同一個 pre-trained model,權重之間存在巨大的相關性。

三條設計準則

作者認為一個高效的多 LoRA 服務系統必須遵循三條準則,整篇論文的設計都是從這三條推出來的:

G1 · 整併到少量 GPU

GPU 昂貴且稀缺,因此必須把多租戶 LoRA 服務工作負載整併到盡可能少的 GPU 上,提高整體 GPU 使用率。

G2 · 讓不同模型也能 batch

如同 Orca 已經指出的,batching 是提升效能與 GPU 使用率最有效的手段之一。但 batching 只在請求指向完全相同的模型時才有效。因此必須設法讓不同 LoRA 模型的請求也能一起 batch。

G3 · 只需盯著 decode 階段

decode 階段是模型服務成本的主導因素,所以只需要專注在 decode 階段的效能。其他面向相對次要,用直觀的技巧處理即可(例如 LoRA 權重的 on-demand 載入)。

核心洞察:有了 SGMV 之後,作者觀察到一件出乎意料的事——batch 同一個 LoRA 模型與 batch 不同 LoRA 模型,效能差異可以忽略不計。同時 LoRA 模型的 on-demand 載入只有毫秒級延遲。這兩件事加起來給了 Punica 極大的排程自由度:可以把使用者請求整併到一小組 GPU 上,而不受「哪些 LoRA 模型已經在哪張卡上跑」的限制。

Punica 的三個貢獻

指出批次處理「多個不同 LoRA 模型」請求的機會

這是問題定義層面的貢獻:把 multi-tenant LoRA serving 當成一個獨立的系統問題來看待。

設計並實作了能並行執行多個 LoRA 模型的高效 CUDA kernel

即 SGMV(Segmented Gather Matrix-Vector Multiplication),論文的技術核心。

開發了整併多租戶 LoRA 工作負載的新排程機制

包含 GPU 選擇邏輯、on-demand 模型載入、請求遷移,以及可分離的 KvCache layout。

結果先講:在固定大小的 GPU 叢集上,相較於 state-of-the-art LLM serving 系統,Punica 在服務多個 LoRA 模型時達到 12× 的 throughput,而每個 token 只增加 2ms 延遲。

SECTION 02

背景 — 為什麼是 decode 階段

要理解 Punica 為什麼長成這樣,得先理解 prefill 與 decode 這兩個階段在算術特性上的根本差異。這個差異是整篇論文的地基。

2.1 Prefill vs. Decode

Transformer LLM 的運作分兩階段。Prefill 接受使用者 prompt,生成後續的第一個 token 與一份 Key-Value cache(KvCache)。Decode 接受一個 token 與 KvCache,生成下一個 token 並在 KvCache 上追加一個 column。decode 是迭代過程,生成的 token 成為下一步的輸入,直到產生 end-of-sequence token。

假設 prompt 長度為 s、attention head 維度為 d

Prefill:self-attention = (s, d) × (d, s) × (s, d)  MLP = (s, h) × (h, h)
Decode :self-attention = (1, d) × (d, s+1) × (s+1, d)  MLP = (1, h) × (h, h)
s = past sequence length,h = hidden dimension

差別一目了然:decode 階段的輸入是單一向量(batch 維度以外只有 1 個 token),GPU 使用率極低。

0 10 ms 20 ms 30 ms 40 ms batch 1 batch 32 17 ms 34 ms 長序列(約 2× 延遲,換來 32× batch) 11 ms 13 ms 短序列(幾乎不變) Decode Latency
圖 1 · Decode 階段的 batching 效益(依論文 Figure 1 與正文敘述重繪)。
論文正文只給出 batch 1 與 batch 32 的端點值,中間的曲線形狀未提供 — 此處以虛線連接,不代表實際趨勢。

這張圖就是 G3 的證據。Prefill 的延遲與 batch size 成正比(GPU 算力已被充分利用,batch 沒有額外好處)。但 decode 完全不是這樣:batch size 從 1 增加到 32,短序列的 decode step 延遲只從 11ms 增加到 13ms,長序列從 17ms 增加到 34ms換句話說,batching 能顯著提升 decode 階段的 GPU 使用率——而長輸出的服務延遲主要由 decode 階段決定,這正是 Orca 這類系統的立足點。

2.2 LoRA 的數學形式

LoRA 的關鍵觀察是:pre-trained model 與 fine-tune 後模型之間的權重差異具有低秩性。因此這個差異可以表示為兩個小而密的矩陣的乘積。

W ∈ ℝh₁×h₂  A ∈ ℝh₁×r  B ∈ ℝr×h₂
W + AB 是 fine-tune 後模型的新權重
r 是 LoRA rank,通常遠小於原始維度(例如 16 相對於 4096)

額外儲存成本

0.1%–1%

每個 fine-tuned 模型只增加原模型權重的 0.1% 到 1%。

套用位置

通常套用到 transformer layer 的所有 dense projection,包含 attention 的 Query-Key-Value-Output projection 與 MLP。

注意

self-attention 運算本身不含任何權重,所以 LoRA 不會作用在 attention 的計算核心上,只作用在它前後的線性投影。

那麼,怎麼在共享 GPU 叢集上高效服務多租戶 LoRA?

一種做法是把每個 LoRA 模型當成獨立模型,用傳統 LLM serving 系統(例如 vLLM)服務。但這忽略了不同 LoRA 模型之間的權重共享——而這個共享正是大幅提升 GPU 效率的關鍵。更進一步,如果把每個 LoRA 模型視為獨立模型,模型載入時間會成為 bootstrap 階段的顯著效能瓶頸

即使我們在各個 LoRA 模型之間共享 backbone,仍然留下一個問題:要怎麼高效地「批次計算」那些 LoRA add-on?這就是 SGMV 要解決的問題。

SECTION 03

SGMV — 論文的技術核心

Segmented Gather Matrix-Vector Multiplication。名字很長,語意其實一句話就講完:把 batch 切成連續的 segment,每個 segment 乘上屬於它自己的那個權重矩陣。

3.1 問題的形式化

當一個 LoRA 模型在 batch 中有多個輸入時,可以把它們進一步 batch 在一起。作者把指向同一個 LoRA 模型的輸入連續地擺在一起。令 n 為 batch 中 LoRA 模型的數量,s_i 為第 i 個模型在 batch 中的最後一個元素索引,其中 s₀ = 0s_n 為 batch size。輸入 {x_i | i ∈ [1, s_n]} 被切分為 {{x_j | j ∈ (s_{i-1}, s_i]} | i ∈ [1, n]}

那麼 dense projection 的輸出可以寫成:

⎡ y₁ ⎤  ⎡ x₁ ⎤    ⎡ x₁ ⎤
⎢ ⋮ ⎥ := ⎢ ⋮ ⎥ · W + ⎢ ⋮ ⎥ · A₁B₁  (第 1 段)
⎢ y_{s₁} ⎥ ⎣ x_{s₁} ⎦  ⎣ x_{s₁} ⎦
⎢ ⋮ ⎥            ⋮
⎣ y_{s_n} ⎦ ⎣ x_{s_n} ⎦  ⎣ x_{s_n} ⎦ · A_nB_n (第 n 段)
左半邊是 backbone 計算,可用一般的 GEMM 批次處理;右半邊才是 SGMV 要解決的。

這就是整個設計的分水嶺。左邊那項 x · W 對所有請求都是同一個 W,所以是標準的 batched GEMM,完全沒問題。右邊那項每個 segment 要乘上不同的 A_i B_i——這才是既有系統做不到 batch 的地方。

3.2 SGMV 的語意

注意 v += x·AB 這個 operator 可以拆成同一個 kernel 的兩次啟動:先初始化 v := 0,然後執行 v += x·A,接著 y += v·B。這個 operator 就叫 SGMV:

Y[ s[i] : s[i+1] ] += X[ s[i] : s[i+1] ] @ W[i]
X(batch,已依 LoRA 分段) W[i](每段各自的權重) Y(就地累加) segment 0 s[0] .. s[1] segment 1 s[1] .. s[2] segment 2 s[2] .. s[3] W[0] W[1] W[2] += 結果 0 += 結果 1 += 結果 2 段長度可以不同 — s[] 這個 indptr 就是唯一需要傳進 kernel 的排程資訊
圖 2 · SGMV 語意(依論文 Figure 3 重繪)。同一個 LoRA 模型的請求被排在連續的 segment 內,segment 邊界由 indptr s[] 描述。

SGMV 把 batch 中不同請求的 feature-weight 乘法平行化,並把對應到同一個 LoRA 模型的請求 group 起來,藉此提高 kernel 的 operational intensity(算術強度),使其能夠使用 GPU Tensor Cores 加速

3.3 Shrink 與 Expand:兩種截然不同的 kernel schedule

作者依據輸入與輸出的 feature 維度,把 SGMV operator 分成兩類:

SGMV-shrink · v = x·A

LoRA 模組的第一個 operator。它把高維輸入 shrink 成低秩輸出(例如 4096 → 16)。

問題:輸出維度太細(too thin),平行度不足。

解法:Split-K。把 B 沿著 input feature 維度切成 B = [B⁽¹⁾; …; B⁽ᵏ⁾],把不同的 y⁽ⁱ⁾ = v·B⁽ⁱ⁾ 分派到不同 threadblock。所有 threadblock 的 partial sum 算完之後,做一次 grid synchronization,再跨 threadblock reduction 聚合:y = Σᵢ y⁽ⁱ⁾

SGMV-expand · y = v·B

第二個 operator。它把低秩輸入 expand 回高維輸出(例如 16 → 4096)。

輸出維度夠寬,平行度天然充足。

解法:沿輸出維度切分。把 A 沿 output feature 維度切成 A = [A⁽¹⁾ … A⁽ⁿ⁾],把不同的 v⁽ⁱ⁾ = x·A⁽ⁱ⁾ 分派到 GPU 中不同的 threadblock,最後把 v⁽ⁱ⁾ 串接起來就是最終結果。

兩個 kernel 都把 LoRA 索引綁定到 CUDA 的 blockIdx.y,於是每個 blockIdx.y 上的計算就是「features 與某個特定 LoRA 權重」之間的矩陣乘法。兩者都使用 GPU Tensor Cores 加速矩陣乘法。

一個重要的例外情況。當 batch 中每個請求都有不同的 LoRA 索引時,每個 LoRA 索引對應的計算就退化成 matrix-vector multiplication——這是完全 IO-bound 的。作者為這種情況設計了專門的 schedule:最大化記憶體頻寬使用率,並且「不使用 Tensor Cores」,因為此時 operational intensity 太低。這個細節在後面看原始碼時會再出現一次。

3.4 Roofline 分析

作者用 roofline model 分析 SGMV kernel。FLOP 與記憶體 I/O bytes 的計算方式為:

FLOP = sn × hi × ho × 2
I/O  = [ sn × (hi + ho) + n × h₁ × h₂ ] × 2
n = LoRA 模型數,s = segment indices,s_n = 輸入總數,h_i / h_o = SGMV 權重矩陣的輸入/輸出維度。
FLOP 的因子 2 來自矩陣乘法的 multiply-add;I/O 的因子 2 來自 16-bit 浮點的 byte 大小。
本 case study 使用 h_i = 16、h_o = 4096。
算術強度 FLOP : I/O → Achieved FLOP/s → 312 TFLOP/s(A100 peak FP16) 1.935 TB/s(記憶體頻寬上限) Distinct 算術強度不變, 加大 batch 只往上走 Identical 沿頻寬斜線爬升 → 受記憶體頻寬限制 Uniform / Skewed 兩種效果的組合
圖 3 · SGMV kernel 的 roofline 定性行為(依論文 Figure 7 與正文敘述重繪)。
座標軸與曲線位置為示意——論文的 Figure 7 未提供可讀取的原始數值,僅兩條 ceiling(1.935 TB/s、312 TFLOP/s)為論文明確給出的數字。
SECTION 04

系統設計 — kernel 之外的四件事

有了 SGMV 只解決了「一張卡上怎麼算」。Punica 還需要回答「叢集上怎麼排」。這一節的四個機制——排程、on-demand 載入、請求遷移、KvCache layout——共同構成 Punica 的系統層。

Users Users Frontend Frontend Scheduler 全域 GPU 狀態視圖 Runner GPU LLM backbone KvCache LoRA … LoRA GPU LLM backbone KvCache LoRA … LoRA Runner GPU LLM backbone KvCache LoRA … LoRA GPU LLM backbone KvCache LoRA … LoRA 每張 GPU 都載入完整的 backbone;只有 LoRA 元件按需從遠端儲存 swap in 大部分 GPU 記憶體保留給 KvCache
圖 4 · Punica 系統架構(依論文 Figure 2 重繪)。Frontend 對外提供 RESTful API,Scheduler 派發請求,每台 GPU server 跑一個 Runner。

4.1 排程新請求

Punica 的 scheduler 擁有所有 GPU 的全域狀態視圖。對每張 GPU,Punica 維護一個 working set(即 LLM invocation 的 batch 輸入)。隨著新請求加入 working set、decode step 展開,KvCache 消耗越來越多 GPU 記憶體,所以 Punica 也持續追蹤每張 GPU 上可用於 KvCache 的記憶體空間。

排程規則:把新請求排給目前 working set 最大(也就是 LLM invocation batch size 最大)的那張 GPU,同時滿足兩個約束:(1) 尚未達到 max batch size 上限;(2) 有足夠記憶體容納新請求的 KvCache。有多個候選時,GPU UUID 最大的那張拿到新請求。當所有 GPU 都滿載(達到 max batch size 或記憶體不足),請求進入佇列。之後有 GPU 空出來時,佇列中的請求以 FCFS 方式排程。

max batch size 上限在「叢集 throughput」與「per-token 延遲」之間取平衡。過大的 batch 會大幅拖慢延遲,卻只帶來邊際的 throughput 收益。作者 profile A100 之後,把 max batch size 定為 32。

這個 GPU 選擇邏輯刻意強調「在延遲約束內的叢集 throughput」,它的性質是:

因此 scheduler 能維持峰值 throughput,並依當前系統總負載整併 GPU 使用。這讓叢集的 scale up/down 更容易。在雲端環境中,Punica 執行兩種叢集配置操作:(1) 若叢集中沒有輕載 GPU,就請求更多 GPU;(2) 對於完全沒有負載的 GPU server,把資源歸還。

4.2 On-demand 模型載入

LoRA 模型與底層 pre-trained model 之間的權重共享讓模型載入非常快。LoRA 模型(矩陣 A 與 B)的大小只有底層 pre-trained model 的約 1%

把 LoRA 模型從主記憶體載入 GPU 記憶體只是一次非同步的 host-to-device memory copy,延遲受 PCIe 頻寬限制:

載入單層

~50 µs

PCIe Gen4 x16

載入整個模型

2 ms

PCIe Gen4 x16

單個 decode step

~30 ms

遠大於載入時間 → 天然重疊

這裡有一個「不做過度優化」的務實決定。因為 memory copy 與 GPU 計算可以重疊,理論上可以實作精細的 layer-by-layer 甚至 matrix-by-matrix 載入來把延遲藏到最小。但作者注意到:每個 decode step 約需 30ms,而每個請求可能需要數千個 decode steps。所以他們選擇了更簡單但等效的做法——當新請求加入 GPU 時,若其 LoRA 模型尚未載入,就發出一個非同步的 memory copy 來載入 LoRA 權重,然後讓 GPU 繼續跑 batch 中的其他輸入。等到這次模型執行結束時,權重早就載完了,新請求自然地加入 batch。

4.3 請求遷移

隨著請求生成更多 token,它們的 KvCache 佔用越來越多 GPU 記憶體。當一張 GPU 的 KvCache 空間耗盡時,就把一些請求遷移到其他 GPU。請求遷移分兩步——evict 與 add。Scheduler 驅逐 GPU 上最新的請求,這保留了 FCFS 語意。被驅逐請求的排程方式與新增請求相同。

Cancel R₃

Scheduler 先送出請求的取消指令給 GPU 1。

Release KvCache

GPU 1 跑完前一個 batch 後接收取消並釋放 KvCache。GPU 1 也丟棄 R₃ 在前一個 batch 中生成的新 token。

Stop streaming R₃

停止把 R₃ 的 token 串流給 scheduler。

Add R₃

送出取消指令之後立刻,scheduler 就把 R₃ 加到 GPU 2。

Build KvCache

GPU 2 對 R₃ 的原始 prompt 加上所有先前生成的 token 執行一次 prefill,藉此在 GPU 2 上重建 R₃ 的 KvCache。

Start streaming R₃

GPU 2 開始把 R₃ 的新 token 串流給 scheduler。

為什麼用重算而不是搬 KvCache?作者選擇重算是為了簡單。PagedAttention 已經證明在大多數情況下,重算的延遲與搬移 KvCache 相當或更好——這與作者的觀察一致。

另外,Punica scheduler 支援取消請求(典型場景是使用者斷線),實作很直接:把請求從 GPU 與 scheduler 狀態中移除。更重要的是,把請求取消當成一個排程原語(scheduling primitive),才使得請求遷移成為可能。

4.4 KvCache 的記憶體佈局

Punica 使用可分離(separable)的 KvCache layout,這對文字生成的 batching throughput 至關重要。HuggingFace Transformers 的 KvCache layout 由複雜的巢狀 tensor list 組成,概念上可視為:

[ L , 2 , B , N , S , D ]
L = 層數,2 = Key/Value projection,B = batch size,N = head 數,S = 序列長度,D = head 維度

這個佈局有兩個問題:

decode steps Request Index(同一個 batch 內的 4 個請求) Waste Actual
圖 5 · 不可分離的 KvCache 造成浪費的 decode step(依論文 Figure 6 重繪,為示意圖)。
連續 4 個請求被 batch 在一起,較短的請求必須陪著最長的請求空跑,浪費的計算就是斜線部分。

論文指出 FasterTransformer 與 DeepSpeed 也有類似問題。Punica 的 KvCache layout 則是:

[ Σi ⌈ Si / P ⌉ , L , 2 , N , P , D ]
S_i = 第 i 個序列的長度,P = page size

作者使用 paged KvCache(PagedAttention)來最小化記憶體碎片化,並把 batching 維度放到最外層以支援 continuous batching

SECTION 05

實作與原始碼 — 論文沒說的那部分

Punica 的實作分兩部分:跑在單張 GPU 上的 PyTorch Python library,以及支援跨叢集服務的系統元件。這一節除了論文寫的內容,也直接引用 punica-ai/punica repo 裡的實際程式碼——有幾個地方跟論文的描述不完全一致,值得知道。

5.1 論文描述的實作

Python Library

CUDA kernel 透過 PyBind11 以 PyTorch Extension 形式暴露。Llama 模型實作改編自 HuggingFace Transformers。

FlashInfer

使用 FlashInfer 做快速且記憶體高效的 self-attention。除了像 FlashAttention 一樣融合 softmax(QKᵀ)V 之外,FlashInfer 還支援不需要 padding 的 batch decoding,並支援 paged KvCache。

LayerNorm 融合

110 µs → 4 µs

作者也融合了 LayerNorm,延遲降低約 27 倍。

系統元件

Scheduler、frontend、runner 全部用 Rust 寫。Unary RPC 與串流文字 chunk 都透過 web socket 實作,I/O 非同步處理。Runner 為每張 GPU spawn 一個 subprocess(PyTorch library 的薄封裝),主行程用 pipe 與 subprocess 溝通。

5.2 混合 Prefill 與 Decode 的批次策略

Punica 把處於 Prefill 階段的新請求與處於 Decode 階段的既有請求混在同一個 batch。這樣一來,dense projection 與 LoRA 都能受惠於更大的 batch size。

為了做 batching,所有輸入沿序列長度維度串接。Prefill 請求一律放在開頭,Decode 請求放在後半部。接著傳入一個 BatchLen struct 來區分不同請求:它包含每個 Prefill 請求的起始索引清單,以及 Decode 請求的數量。

在 self-attention 層,把索引與前導輸入狀態傳給 BatchPrefill kernel,把尾端的輸入狀態傳給 BatchDecode kernel。

一個很細的排程巧思:在 batch 內部,作者進一步組織輸入順序,讓共享同一個 LoRA 模型的請求彼此相鄰。而且——Prefill 請求的尾巴與 Decode 請求的頭,如果可能的話會共享同一個 LoRA 模型。這是為了讓 SGMV 的 segment 數量最小化。

接著在每次 batched model invocation 之前,先串接 batch 輸入並建構 BatchLen 與 SGMV segment indices。這兩者在整個 model invocation 期間保持不變。這個設計避免了重複計算——否則 BatchLen 要算 L 次,SGMV segment indices 要算 7L 次(L 為層數)。

為什麼是 7L?因為每一層有 7 個 LoRA add-on 需要計算。這一點在原始碼裡看得非常清楚。

5.3 原始碼:每一層的 7 次 LoRA 加法

下面是 src/punica/models/llama_lora.py 中 attention 層的 forward。注意 dense projection 照常呼叫,然後 add_lora() 就地(in-place)把 LoRA delta 加上去:

# src/punica/models/llama_lora.py
from punica.ops import add_lora_sgmv_custom_cutlass as add_lora

def forward(self, hidden_states, blen, prefill_kv, decode_kv,
            lora: BatchedLlamaLoraWeight):
    # 1) backbone 的 dense projection —— 一般的 batched GEMM
    q_proj = self.q_proj(hidden_states)
    k_proj = self.k_proj(hidden_states)
    v_proj = self.v_proj(hidden_states)

    # 2) LoRA add-on —— SGMV,就地累加到 q/k/v_proj 上
    add_lora(q_proj, hidden_states,
             lora.q.wa_ptr, lora.q.wb_ptr,
             lora.segment,          # ← 就是 SGMV 的 indptr s[]
             self.layer_idx, lora.rank)
    add_lora(k_proj, hidden_states,
             lora.k.wa_ptr, lora.k.wb_ptr, lora.segment,
             self.layer_idx, lora.rank)
    add_lora(v_proj, hidden_states,
             lora.v.wa_ptr, lora.v.wb_ptr, lora.segment,
             self.layer_idx, lora.rank)
    ...

Attention 有 q, k, v, o 四次,MLP 有 gate, up, down 三次 —— 合計每層 7 次,正好對應論文說的 7Llora.segment 這一個 tensor 就是圖 2 裡的 s[],整個 batch 的 LoRA 排程資訊全部濃縮在裡面。

5.4 原始碼:SGMV 的實際 API 與一個意外發現

repo 的 src/punica/ops/__init__.py 定義了對外的 kernel API。SGMV 的語意跟論文完全一致:

def sgmv_cutlass(y, x, w_ptr, s, layer_idx):
    """
    Semantics:
      y[s[i]:s[i+1]] += x[s[i]:s[i+1]] @ deref(w_ptr[i])

    Args:
      y: Shape: `[B, H2]`. Output vectors. Will be changed in-place.
      x: Shape: `[B, H1]`. Input vectors.
      w_ptr: Shape: `[S]`. DType: torch.int64. Pointer to the weight matrices.
      s: Shape: `[S+1]`, DType: torch.int32. Indptr of the weight matrices.
         `s[0] == 0`, `s[-1] == B`.
    """

但真正被模型使用的是這個 混合實作

def add_lora_sgmv_custom_cutlass(y, x, wa_ptr, wb_ptr, s,
                                 layer_idx, lora_rank):
    """
    Semantics:
      y[s[i]:s[i+1]] += x[s[i]:s[i+1]] @ deref(wa_ptr[i]).T @ deref(wb_ptr[i])
    """
    tmp1 = torch.empty((8 * 1024 * 1024,), dtype=torch.uint8, device=x.device)
    tmp2_size = _kernels.sgmv_cutlass_tmp_size(wa_ptr.size(0))
    tmp2 = torch.empty((tmp2_size,), dtype=torch.uint8, device=x.device)
    v = torch.zeros((x.size(0), lora_rank), dtype=x.dtype, device=x.device)

    _kernels.sgmv_shrink(v, x, wa_ptr, s, tmp1, layer_idx)   # ← 自製 CUDA kernel
    _kernels.sgmv_cutlass(y, v, wb_ptr, s, tmp2, layer_idx)  # ← CUTLASS 實作

這裡有一個論文沒有明講的實作細節。shrink 那一半(v = x·A,含 Split-K 那套設計)是作者自己寫的 CUDA kernel;但 expand 那一半(y = v·B在實際出貨的路徑上是走 CUTLASS 的通用實作,而不是論文 §4 描述的那個自製 expand schedule。

證據就在同一個檔案裡——純自製版本的 sgmv()add_lora_sgmv() 都還停在:

def sgmv(y, x, w_ptr, s, layer_idx):
    if x.size(1) < y.size(1):
        raise NotImplementedError("TODO: sgmv_expand")
    ...

也就是說:論文描述的 expand kernel schedule 在這份開源程式碼裡沒有完成,出貨路徑用 CUTLASS 補上。這不影響論文的效能數字(benchmark 跑的就是這個混合實作),但如果你想照著論文 §4 重現那個 expand kernel,會發現 repo 裡沒有。

權重的記憶體佈局也直接反映了這個混合設計 —— src/punica/utils/lora.py 的註解說得很白:

class LoraWeight:
    def __init__(self, num_layers, in_features, out_features,
                 lora_rank, dtype, device):
        # SGMV-Shrink custom CUDA kernel uses column-major.
        self.wa = torch.zeros(
            (num_layers, lora_rank, in_features), dtype=dtype, device=device)
        # SGMV-Expand cutlass kernel uses row-major.
        self.wb = torch.zeros(
            (num_layers, lora_rank, out_features), dtype=dtype, device=device)

5.5 原始碼:另一個 kernel — BGMV

repo 裡還有一個論文正文沒有命名、但確實對應到 §4 那段「每個請求都有不同 LoRA 索引」討論的 kernel:BGMV(Batched Gather Matrix-Vector),放在 csrc/bgmv/

def add_lora_bgmv(y, x, wa_T_all, wb_T_all, indicies, layer_idx, scale):
    """
    Semantics:
      y[i] += (
          x[i].unsqueeze(0)
          @ wa_T_all[indices[i], layer_idx, :, :].transpose(-1, -2)
          @ wb_T_all[indices[i], layer_idx, :, :].transpose(-1, -2)
          * scale
        ).squeeze(0)

      indicies: Shape: `[B]`. Indices of the LoRA weights.
    """

注意 SGMV 與 BGMV 的關鍵差異。SGMV 吃的是 s(indptr,描述連續 segment),BGMV 吃的是 indicies(每個 batch 元素各自一個索引)。前者假設同一個 LoRA 的請求被排在一起、可以用 Tensor Cores 做矩陣乘法;後者對應論文說的「退化成 matrix-vector、完全 IO-bound、不使用 Tensor Cores」的情況——每個請求都是不同 LoRA,根本沒有 segment 可言。

這個 BGMV 後來的影響力其實不小:它是最早被下游推論框架採用的多 LoRA batching kernel 形式之一,因為 indicies 這個介面比 SGMV 的「請求必須依 LoRA 排序」假設更容易接進既有的 continuous batching 排程器。

SECTION 06

實驗結果

先講一個讀這篇論文必須知道的事:這篇論文沒有任何表格。所有實驗結果都以圖(Figure 7–13)呈現,論文沒有附上原始數值表。下面所有數字都是論文正文明確寫出來的;正文沒寫、只能從圖上目測的數值,我一律標示為未提供,不做估算。

6.1 實驗設定

實驗設定(整理自論文 §7)
項目設定
Testbed #1單台 server,1× NVIDIA A100 80GB GPU。大記憶體,用來研究 LoRA batching 效果。
Testbed #22× NVIDIA HGX A100 40GB server,每台 8 GPU。具備 NvSwitch,用來研究 tensor parallelism 與叢集部署。
模型Llama-2 7B / 13B / 70B
LoRA rank所有實驗使用 16;LoRA 套用到所有 dense projection
LoRA 權重隨機權重(作者說明:權重不影響延遲效能)
工作負載Prompt 與 response 長度取自 ShareGPT 的真實 user-bot 對話分佈
Max batch size32(所有系統一致)

四種請求分佈(決定 batch 中 LoRA 模型的多樣性):

Distinct

每個請求都指向不同的 LoRA 模型。最嚴苛的情況。

Uniform

所有 LoRA 模型同樣熱門。給定 n 個請求,使用 ⌈√n⌉ 個模型。

Skewed

模型熱門度遵循 Zipf-α 分佈,第 i 熱門的模型請求數是第 i+1 名的 α 倍。實驗取 α = 1.5

Identical

所有請求都指向同一個 LoRA 模型。等於傳統單模型服務。

Baseline 的設定對 Punica 不利,這點作者很誠實。由於當時沒有知名的 multi-LoRA 服務系統,作者拿 Punica 跟一系列主流 LLM backbone 服務系統比較,並給了 baseline 各種對它們有利的放寬

  • HuggingFace PEFT + HuggingFace Transformers(真的有 LoRA)
  • DeepSpeed(真的有 LoRA)
  • FasterTransformervLLM只跑 backbone,因為這兩個系統當時不支援 LoRA 模型
  • 並且省略了 baseline 系統的模型切換成本

換句話說,vLLM 那組數字是「假裝完全沒有 LoRA 開銷」的理想上界。這讓 12× 的結論反而更保守、更可信。

6.2 LoRA operator microbenchmark

作者把 batched LoRA operator 實作成兩次 SGMV kernel 啟動,並與兩個 PyTorch baseline 比較:

作者的分析指出 Gather-BMM 的 I/O 比 SGMV 多得多:Gather 讀入 n × h_i × h_o 個元素、寫出 s_n × h_i × h_o 個;接著 BMM 又必須讀回 Gather 剛寫出的 s_n × h_i × h_o 個權重元素。合計 Gather-BMM 比 SGMV 多出 s_n × h_i × h_o × 2 個元素的記憶體 I/O。

0 40 µs 80 µs 120 µs batch 1 batch 64 116 µs · Distinct 46 µs · Uniform / Skewed 40 µs · Identical 37 µs(起點,四種分佈相同) SGMV Latency
圖 6 · SGMV 在四種工作負載下的延遲(依論文 Figure 8 正文敘述重繪)。
只有 batch 1 與 batch 64 的端點值為論文明確給出,中間曲線形狀未提供,此處以虛線連接。
Loop 與 Gather-BMM 的具體數值論文正文未給出,故未繪製。

Distinct

Loop 表現極差,因為它跑的是多輪 batch size 1。Gather-BMM 延遲因 Gather 的拖累而快速上升。SGMV 也逐步上升,從 37µs 到 116µs,因為 batching 不改變算術強度。

Uniform / Skewed

與 Distinct 情況類似,但 Gather-BMM 表現略好(要載入的矩陣較少)。SGMV 延遲只從 37µs 微幅上升到 46µs——這是「平行度增加」與「算術強度增加」兩種效果的組合。

Identical

所有實作的語意都相同(就是 BMM)。因此可以推論 SGMV 在 LoRA 的情境下比 torch.bmm() 更高效地實作了 BMM。SGMV 延遲幾乎不變,從 37µs 到 40µs

6.3 不同 LoRA rank 的影響

這是論文中唯一一組數值最完整的實驗(Testbed #1,LoRA rank 8/16/32/64):

0 40 µs 80 µs 120 µs batch 1 batch 64 42 72 r = 8 42 75 r = 16 42 89 r = 32 42 118 r = 64 Distinct 工作負載(最嚴苛情況)· 論文正文給出的實際數值 Latency
圖 7 · 不同 LoRA rank 下的 SGMV 延遲(依論文 Figure 9 與正文數值重繪,數值為論文明確給出)。

關鍵發現:在 Distinct 情況下,batch size 1 的延遲在四種 rank 下全都是 42µs;batch size 64 才拉開差距(72 / 75 / 89 / 118 µs)。而當工作負載存在權重共享時(Uniform、Skewed、Identical),延遲在 batch size 1 到 64 之間幾乎不變,維持在約 42µs 到 45µs

6.4 Transformer layer benchmark

把 LoRA operator 併入 transformer layer 之後測整層效能(Testbed #1,7B 與 13B 配置,序列長度 512 與 2048)。

序列較短時 batching 效果更強:序列長度 512 時,batch size 從 1 增加到 32,延遲只增加 72%。序列較長時 self-attention 花的時間更多,削弱了 layer 層級的 batching 效果。

這是整篇論文最重要的一個「非結果」。與 kernel 層級的 microbenchmark 形成對比——layer 層級的延遲在不同工作負載(Distinct / Uniform / Skewed / Identical)之間幾乎相同。原因是 LoRA add-on 的計算時間相對於 backbone dense projection 與 self-attention 來說很小。

作者用斜體強調了這個性質:這種「LoRA-model-agnostic」的效能特性,讓我們可以把不同的 LoRA 模型當成同一個模型來排程。於是排程演算法可以只關注整體 throughput,而不必操心個別 LoRA 模型放在哪張卡上——這正是 Punica 排程器設計的立論基礎

6.5 單 GPU 文字生成

Testbed #1 上生成 1000 個請求(約 101k tokens),所有系統以 FCFS 排程、max batch size 32。

Figure 11 · 單 GPU 文字生成 throughput(僅列出論文正文明確給出的數值)
系統工作負載7B (tok/s)13B (tok/s)
PunicaDistinct / Uniform / Skewed(一致高 throughput)1044693
vLLM(backbone-only)Identical1140789
HuggingFace Transformers全部未提供未提供
DeepSpeed全部未提供未提供
FasterTransformer(backbone-only)全部未提供未提供

論文的定性描述是:Punica 無論在哪種工作負載下都能穩定交出高 throughput。多數 baseline 在 Identical 情況下也能有相對高的 throughput,但一旦出現多個 LoRA 模型,效能就崩壞:

Identical 情況下 vLLM 略勝 Punica(1140 vs 1044、789 vs 693)——但這是預期內的。原因作者寫得很清楚:vLLM 跑的是 backbone-only,完全沒有 LoRA 開銷。而在 Identical 情況下 vLLM 與 Punica 都優於其他系統,因為兩者的 KvCache layout 都支援 continuous batching;其他系統則必須等 batch 中最長的序列跑完。HuggingFace Transformers 效能低落則是因為缺乏關鍵的 CUDA kernel 優化(例如 FlashAttention)。

6.6 70B 模型 + Tensor Parallelism

在 Testbed #2 的 8 張 GPU 上,用 Megatron 的 tensor parallel scheme 跑 70B 模型。Punica 與 vLLM 比較,vLLM 也使用相同的 Megatron TP scheme。這組數字是論文中對比最鮮明的:

0 100 200 300 400 500 21–25 vLLM 多個 LoRA 模型 441–446 Punica 多個 LoRA 模型 457 vLLM backbone-only(無 LoRA) Throughput (tok/s)
圖 8 · 70B 模型的 throughput(Testbed #2,8 GPU tensor parallel;數值為論文正文明確給出的範圍)。

這是全篇最有說服力的一組數字。在存在多個 LoRA 模型的情況下,vLLM 的 throughput 只有 21 到 25 tok/s;而它服務單純 backbone 時可以達到 457 tok/s(因為 batch size 大)。Punica 則不論 LoRA 熱門度分佈為何,都能穩定交出 441 到 446 tok/s——幾乎追平 vLLM 的無 LoRA 上界,同時真的在服務多個不同的 LoRA 模型。

在 Identical 情況下,Punica 與 vLLM 效能相同,因為兩者的平行化方案一樣。

6.7 叢集部署

Testbed #2 的 16 張 GPU,7B 模型,實驗持續一小時。負載變化方式:宏觀來看請求率逐漸上升再逐漸下降;微觀來看,請求到達時間的間隔服從指數分佈,到達過程服從 Poisson 分佈。LoRA 模型熱門度服從 Zipf-1.5(與 Skewed 工作負載相同)。

論文的觀察是:

論文的 Figure 13 呈現三個面板(請求率、throughput、每張 GPU 的 batch size 隨時間變化),但未提供可讀取的原始數值,此處不重繪。

SECTION 07

討論、限制與後續影響

先說清楚:這篇論文沒有 Limitations 章節。作者在 §8 寫的是 Related Work,§9 是 Conclusion。所以下面我把「作者明說的」與「從實驗設定可以推出的」分開列,不把後者偽裝成作者的說法。

7.1 作者明說的定位

與推論優化工作正交

作者明確指出 Punica 與 speculative decoding 這類優化是正交的(orthogonal)。Orca 的 continuous batching、vLLM 的 PagedAttention、FlashAttention 這些 Punica 已經整合進來了。

與量化工作互補

模型與 KvCache 量化能為 KvCache 省下更多空間,讓 Punica 能服務更長的序列而不需遷移。作者還特別指出一個有趣的互動:量化降低了 self-attention 延遲,這反而讓 Punica 的高效 LoRA kernel 變得更重要(因為 LoRA 在總延遲中的佔比上升了)。

與 PetS 的差異

PetS 在單張 GPU 上批次處理不同 adapter(Adapters、MaskBert、Diff-Pruning、Bitfit)的請求,允許在不同下游任務間共享 GPU 記憶體,但它不支援多個不同模型並行執行

與 CNN serving 系統的差異

Clipper 這一系列系統主要為較小的 CNN 模型設計。關鍵差異是:CNN 服務是無狀態的,而 LLM 服務需要持久化 KvCache。這個狀態引入了對「不同系統設計」的親和性需求。

7.2 從實驗設定看得出來的限制

限制一:完全沒有品質/準確度評估。論文明確寫出 LoRA 模型使用隨機權重,理由是「權重不影響延遲效能」。就系統論文的目標而言這是完全合理的,但這代表:本文沒有任何一個實驗驗證 Punica 產生的輸出與逐一服務各 LoRA 模型的輸出在數值上等價。如果你要把它用在生產環境,數值正確性需要自己驗證(repo 裡有 tests/test_sgmv.py 等單元測試可作為起點)。

限制二:所有 LoRA 模型必須共用同一個 rank 與同一個 backbone。實驗中所有 LoRA 都是 rank 16。從 SGMV 的介面設計就看得出來——add_lora_sgmv_custom_cutlass(...) 只接受單一個 lora_rank 純量,而權重指標陣列 wa_ptr/wb_ptr 假設所有矩陣形狀一致。混合 rank 的多租戶場景不在本文範圍內。

限制三:SGMV 要求同 LoRA 的請求必須連續排列。這是 segment 語意的前提。它把一部分複雜度推給了排程器(§5.2 那段「讓共享 LoRA 的請求相鄰、讓 prefill 尾巴與 decode 開頭共享 LoRA」的安排)。repo 裡的 BGMV 正是為了繞開這個限制而存在的替代介面。

限制四:論文沒有表格,多數 baseline 數值無法查證。Figure 8、11、13 的具體數值論文正文都沒有給出。想做精確比較的話,只能自己跑 repo 裡的 benchmarks/bench_textgen_lora.py

7.3 這篇論文為什麼重要

Punica 是最早把「multi-tenant LoRA serving」當成獨立系統問題來處理的工作之一,而它的技術貢獻可以濃縮成一句話:把「不同模型不能一起 batch」這個看似根本的限制,用一個 kernel 拆掉了。

更關鍵的是 §6.4 那個實驗結論——LoRA add-on 的成本相對於 backbone 小到讓整層延遲對「batch 裡有幾種 LoRA」不敏感。這個性質一旦成立,多租戶 LoRA 服務的排程問題就從「模型放置問題」退化成「單純的 throughput 排程問題」,難度差了一個等級。Punica 系統層的所有設計,都是站在這個結論上的。

如果你要用這篇論文:

  • 做推論框架的:直接看 csrc/bgmv/csrc/sgmv/。BGMV 的 indicies 介面比 SGMV 的 segment 介面更容易接進既有的 continuous batching 排程器。
  • 做排程的:§5.1 的 GPU 選擇邏輯(挑 working set 最大的、UUID 最大的 tie-break)與「忙碌的更忙、閒置的保持閒置」哲學,是很乾淨的整併式排程範例。
  • 做 kernel 的:shrink 用 Split-K + grid sync、expand 沿輸出維度切、distinct 情況退回 IO-bound 非 Tensor-Core schedule —— 這三種 schedule 的取捨邏輯值得細看。
  • 要重現的:需要 A100(80GB 用於單卡實驗,或 2×HGX A100 40GB 用於叢集實驗)、CUDA 11.8 或 12.1、Python 3.10/3.11。

結論

Punica 的設計核心是一個新的 CUDA kernel(SGMV),它讓不同 LoRA 模型的 GPU 運算得以批次化。對每張 GPU,Punica 只需要底層 pre-trained model 的單一份副本,就能服務多個不同的 LoRA 模型,在記憶體與計算兩方面都顯著提升 GPU 效率。此外,Punica 的排程器在共享 GPU 叢集上整併多租戶 LoRA 服務工作負載。在固定大小的 GPU 叢集上,相較於 state-of-the-art LLM serving 系統,Punica 達到 12× 的 LoRA 模型服務 throughput,而每個 token 只增加 2ms 延遲