一顆 GPU、一份 backbone 權重、幾十個不同的 LoRA 模型,塞進同一個 batch 一起跑。關鍵只有一個新 CUDA kernel:SGMV。
LoRA 讓每個租戶都能用很低的成本微調出自己的模型。於是 ML 服務商面對的不再是「服務一個大模型」,而是「同時服務幾百個彼此不同、但共享同一個 backbone 的小模型」。用現有的 serving 系統做這件事,浪費得非常離譜。
如果把每個 LoRA 模型當成獨立訓練出來的模型來服務,就是在浪費 GPU。假設服務一個 LoRA 模型需要 k 個 GPU,服務 n 個不同的 LoRA 模型看起來就需要 k × n 個 GPU。這個直觀做法忽略了一件事:這些 LoRA 模型都源自同一個 pre-trained model,權重之間存在巨大的相關性。
作者認為一個高效的多 LoRA 服務系統必須遵循三條準則,整篇論文的設計都是從這三條推出來的:
GPU 昂貴且稀缺,因此必須把多租戶 LoRA 服務工作負載整併到盡可能少的 GPU 上,提高整體 GPU 使用率。
如同 Orca 已經指出的,batching 是提升效能與 GPU 使用率最有效的手段之一。但 batching 只在請求指向完全相同的模型時才有效。因此必須設法讓不同 LoRA 模型的請求也能一起 batch。
decode 階段是模型服務成本的主導因素,所以只需要專注在 decode 階段的效能。其他面向相對次要,用直觀的技巧處理即可(例如 LoRA 權重的 on-demand 載入)。
核心洞察:有了 SGMV 之後,作者觀察到一件出乎意料的事——batch 同一個 LoRA 模型與 batch 不同 LoRA 模型,效能差異可以忽略不計。同時 LoRA 模型的 on-demand 載入只有毫秒級延遲。這兩件事加起來給了 Punica 極大的排程自由度:可以把使用者請求整併到一小組 GPU 上,而不受「哪些 LoRA 模型已經在哪張卡上跑」的限制。
這是問題定義層面的貢獻:把 multi-tenant LoRA serving 當成一個獨立的系統問題來看待。
即 SGMV(Segmented Gather Matrix-Vector Multiplication),論文的技術核心。
包含 GPU 選擇邏輯、on-demand 模型載入、請求遷移,以及可分離的 KvCache layout。
結果先講:在固定大小的 GPU 叢集上,相較於 state-of-the-art LLM serving 系統,Punica 在服務多個 LoRA 模型時達到 12× 的 throughput,而每個 token 只增加 2ms 延遲。
要理解 Punica 為什麼長成這樣,得先理解 prefill 與 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:
差別一目了然:decode 階段的輸入是單一向量(batch 維度以外只有 1 個 token),GPU 使用率極低。
這張圖就是 G3 的證據。Prefill 的延遲與 batch size 成正比(GPU 算力已被充分利用,batch 沒有額外好處)。但 decode 完全不是這樣:batch size 從 1 增加到 32,短序列的 decode step 延遲只從 11ms 增加到 13ms,長序列從 17ms 增加到 34ms。換句話說,batching 能顯著提升 decode 階段的 GPU 使用率——而長輸出的服務延遲主要由 decode 階段決定,這正是 Orca 這類系統的立足點。
LoRA 的關鍵觀察是:pre-trained model 與 fine-tune 後模型之間的權重差異具有低秩性。因此這個差異可以表示為兩個小而密的矩陣的乘積。
0.1%–1%
每個 fine-tuned 模型只增加原模型權重的 0.1% 到 1%。
通常套用到 transformer layer 的所有 dense projection,包含 attention 的 Query-Key-Value-Output projection 與 MLP。
self-attention 運算本身不含任何權重,所以 LoRA 不會作用在 attention 的計算核心上,只作用在它前後的線性投影。
一種做法是把每個 LoRA 模型當成獨立模型,用傳統 LLM serving 系統(例如 vLLM)服務。但這忽略了不同 LoRA 模型之間的權重共享——而這個共享正是大幅提升 GPU 效率的關鍵。更進一步,如果把每個 LoRA 模型視為獨立模型,模型載入時間會成為 bootstrap 階段的顯著效能瓶頸。
即使我們在各個 LoRA 模型之間共享 backbone,仍然留下一個問題:要怎麼高效地「批次計算」那些 LoRA add-on?這就是 SGMV 要解決的問題。
Segmented Gather Matrix-Vector Multiplication。名字很長,語意其實一句話就講完:把 batch 切成連續的 segment,每個 segment 乘上屬於它自己的那個權重矩陣。
當一個 LoRA 模型在 batch 中有多個輸入時,可以把它們進一步 batch 在一起。作者把指向同一個 LoRA 模型的輸入連續地擺在一起。令 n 為 batch 中 LoRA 模型的數量,s_i 為第 i 個模型在 batch 中的最後一個元素索引,其中 s₀ = 0、s_n 為 batch size。輸入 {x_i | i ∈ [1, s_n]} 被切分為 {{x_j | j ∈ (s_{i-1}, s_i]} | i ∈ [1, n]}。
那麼 dense projection 的輸出可以寫成:
這就是整個設計的分水嶺。左邊那項 x · W 對所有請求都是同一個 W,所以是標準的 batched GEMM,完全沒問題。右邊那項每個 segment 要乘上不同的 A_i B_i——這才是既有系統做不到 batch 的地方。
注意 v += x·AB 這個 operator 可以拆成同一個 kernel 的兩次啟動:先初始化 v := 0,然後執行 v += x·A,接著 y += v·B。這個 operator 就叫 SGMV:
s[] 描述。SGMV 把 batch 中不同請求的 feature-weight 乘法平行化,並把對應到同一個 LoRA 模型的請求 group 起來,藉此提高 kernel 的 operational intensity(算術強度),使其能夠使用 GPU Tensor Cores 加速。
作者依據輸入與輸出的 feature 維度,把 SGMV operator 分成兩類:
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⁽ⁱ⁾。
第二個 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 太低。這個細節在後面看原始碼時會再出現一次。
作者用 roofline model 分析 SGMV kernel。FLOP 與記憶體 I/O bytes 的計算方式為:
有了 SGMV 只解決了「一張卡上怎麼算」。Punica 還需要回答「叢集上怎麼排」。這一節的四個機制——排程、on-demand 載入、請求遷移、KvCache layout——共同構成 Punica 的系統層。
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,把資源歸還。
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
~30 ms
遠大於載入時間 → 天然重疊
這裡有一個「不做過度優化」的務實決定。因為 memory copy 與 GPU 計算可以重疊,理論上可以實作精細的 layer-by-layer 甚至 matrix-by-matrix 載入來把延遲藏到最小。但作者注意到:每個 decode step 約需 30ms,而每個請求可能需要數千個 decode steps。所以他們選擇了更簡單但等效的做法——當新請求加入 GPU 時,若其 LoRA 模型尚未載入,就發出一個非同步的 memory copy 來載入 LoRA 權重,然後讓 GPU 繼續跑 batch 中的其他輸入。等到這次模型執行結束時,權重早就載完了,新請求自然地加入 batch。
隨著請求生成更多 token,它們的 KvCache 佔用越來越多 GPU 記憶體。當一張 GPU 的 KvCache 空間耗盡時,就把一些請求遷移到其他 GPU。請求遷移分兩步——evict 與 add。Scheduler 驅逐 GPU 上最新的請求,這保留了 FCFS 語意。被驅逐請求的排程方式與新增請求相同。
Scheduler 先送出請求的取消指令給 GPU 1。
GPU 1 跑完前一個 batch 後接收取消並釋放 KvCache。GPU 1 也丟棄 R₃ 在前一個 batch 中生成的新 token。
停止把 R₃ 的 token 串流給 scheduler。
送出取消指令之後立刻,scheduler 就把 R₃ 加到 GPU 2。
GPU 2 對 R₃ 的原始 prompt 加上所有先前生成的 token 執行一次 prefill,藉此在 GPU 2 上重建 R₃ 的 KvCache。
GPU 2 開始把 R₃ 的新 token 串流給 scheduler。
為什麼用重算而不是搬 KvCache?作者選擇重算是為了簡單。PagedAttention 已經證明在大多數情況下,重算的延遲與搬移 KvCache 相當或更好——這與作者的觀察一致。
另外,Punica scheduler 支援取消請求(典型場景是使用者斷線),實作很直接:把請求從 GPU 與 scheduler 狀態中移除。更重要的是,把請求取消當成一個排程原語(scheduling primitive),才使得請求遷移成為可能。
Punica 使用可分離(separable)的 KvCache layout,這對文字生成的 batching throughput 至關重要。HuggingFace Transformers 的 KvCache layout 由複雜的巢狀 tensor list 組成,概念上可視為:
這個佈局有兩個問題:
1/S。論文指出 FasterTransformer 與 DeepSpeed 也有類似問題。Punica 的 KvCache layout 則是:
作者使用 paged KvCache(PagedAttention)來最小化記憶體碎片化,並把 batching 維度放到最外層以支援 continuous batching。
Punica 的實作分兩部分:跑在單張 GPU 上的 PyTorch Python library,以及支援跨叢集服務的系統元件。這一節除了論文寫的內容,也直接引用 punica-ai/punica repo 裡的實際程式碼——有幾個地方跟論文的描述不完全一致,值得知道。
CUDA kernel 透過 PyBind11 以 PyTorch Extension 形式暴露。Llama 模型實作改編自 HuggingFace Transformers。
使用 FlashInfer 做快速且記憶體高效的 self-attention。除了像 FlashAttention 一樣融合 softmax(QKᵀ)V 之外,FlashInfer 還支援不需要 padding 的 batch decoding,並支援 paged KvCache。
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 溝通。
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 需要計算。這一點在原始碼裡看得非常清楚。
下面是 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 次,正好對應論文說的 7L。lora.segment 這一個 tensor 就是圖 2 裡的 s[],整個 batch 的 LoRA 排程資訊全部濃縮在裡面。
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)
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 排程器。
先講一個讀這篇論文必須知道的事:這篇論文沒有任何表格。所有實驗結果都以圖(Figure 7–13)呈現,論文沒有附上原始數值表。下面所有數字都是論文正文明確寫出來的;正文沒寫、只能從圖上目測的數值,我一律標示為未提供,不做估算。
| 項目 | 設定 |
|---|---|
| Testbed #1 | 單台 server,1× NVIDIA A100 80GB GPU。大記憶體,用來研究 LoRA batching 效果。 |
| Testbed #2 | 2× 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 size | 32(所有系統一致) |
四種請求分佈(決定 batch 中 LoRA 模型的多樣性):
每個請求都指向不同的 LoRA 模型。最嚴苛的情況。
所有 LoRA 模型同樣熱門。給定 n 個請求,使用 ⌈√n⌉ 個模型。
模型熱門度遵循 Zipf-α 分佈,第 i 熱門的模型請求數是第 i+1 名的 α 倍。實驗取 α = 1.5。
所有請求都指向同一個 LoRA 模型。等於傳統單模型服務。
Baseline 的設定對 Punica 不利,這點作者很誠實。由於當時沒有知名的 multi-LoRA 服務系統,作者拿 Punica 跟一系列主流 LLM backbone 服務系統比較,並給了 baseline 各種對它們有利的放寬:
換句話說,vLLM 那組數字是「假裝完全沒有 LoRA 開銷」的理想上界。這讓 12× 的結論反而更保守、更可信。
作者把 batched LoRA operator 實作成兩次 SGMV kernel 啟動,並與兩個 PyTorch baseline 比較:
torch.bmm() 做 batched 矩陣乘法。與 SGMV 一樣啟動 Gather 兩次、BMM 兩次。作者的分析指出 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。
Loop 表現極差,因為它跑的是多輪 batch size 1。Gather-BMM 延遲因 Gather 的拖累而快速上升。SGMV 也逐步上升,從 37µs 到 116µs,因為 batching 不改變算術強度。
與 Distinct 情況類似,但 Gather-BMM 表現略好(要載入的矩陣較少)。SGMV 延遲只從 37µs 微幅上升到 46µs——這是「平行度增加」與「算術強度增加」兩種效果的組合。
所有實作的語意都相同(就是 BMM)。因此可以推論 SGMV 在 LoRA 的情境下比 torch.bmm() 更高效地實作了 BMM。SGMV 延遲幾乎不變,從 37µs 到 40µs。
這是論文中唯一一組數值最完整的實驗(Testbed #1,LoRA rank 8/16/32/64):
關鍵發現:在 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。
把 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 排程器設計的立論基礎。
Testbed #1 上生成 1000 個請求(約 101k tokens),所有系統以 FCFS 排程、max batch size 32。
| 系統 | 工作負載 | 7B (tok/s) | 13B (tok/s) |
|---|---|---|---|
| Punica | Distinct / Uniform / Skewed(一致高 throughput) | 1044 | 693 |
| vLLM(backbone-only) | Identical | 1140 | 789 |
| 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)。
在 Testbed #2 的 8 張 GPU 上,用 Megatron 的 tensor parallel scheme 跑 70B 模型。Punica 與 vLLM 比較,vLLM 也使用相同的 Megatron TP scheme。這組數字是論文中對比最鮮明的:
這是全篇最有說服力的一組數字。在存在多個 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 效能相同,因為兩者的平行化方案一樣。
Testbed #2 的 16 張 GPU,7B 模型,實驗持續一小時。負載變化方式:宏觀來看請求率逐漸上升再逐漸下降;微觀來看,請求到達時間的間隔服從指數分佈,到達過程服從 Poisson 分佈。LoRA 模型熱門度服從 Zipf-1.5(與 Skewed 工作負載相同)。
論文的觀察是:
論文的 Figure 13 呈現三個面板(請求率、throughput、每張 GPU 的 batch size 隨時間變化),但未提供可讀取的原始數值,此處不重繪。
先說清楚:這篇論文沒有 Limitations 章節。作者在 §8 寫的是 Related Work,§9 是 Conclusion。所以下面我把「作者明說的」與「從實驗設定可以推出的」分開列,不把後者偽裝成作者的說法。
作者明確指出 Punica 與 speculative decoding 這類優化是正交的(orthogonal)。Orca 的 continuous batching、vLLM 的 PagedAttention、FlashAttention 這些 Punica 已經整合進來了。
模型與 KvCache 量化能為 KvCache 省下更多空間,讓 Punica 能服務更長的序列而不需遷移。作者還特別指出一個有趣的互動:量化降低了 self-attention 延遲,這反而讓 Punica 的高效 LoRA kernel 變得更重要(因為 LoRA 在總延遲中的佔比上升了)。
PetS 在單張 GPU 上批次處理不同 adapter(Adapters、MaskBert、Diff-Pruning、Bitfit)的請求,允許在不同下游任務間共享 GPU 記憶體,但它不支援多個不同模型並行執行。
Clipper 這一系列系統主要為較小的 CNN 模型設計。關鍵差異是:CNN 服務是無狀態的,而 LLM 服務需要持久化 KvCache。這個狀態引入了對「不同系統設計」的親和性需求。
限制一:完全沒有品質/準確度評估。論文明確寫出 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。
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 排程器。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 延遲。