Batching by Length Instead of Looping Item by Item for SLM Optimization
https://www.kdnuggets.com/batching-by-length-instead-of-looping-item-by-item-for-slm-optimization📌 別再逐筆跑 SLM 推論:依長度分批能省下多少浪費的運算?
TL;DR:KDnuggets 系列文章實測顯示,小模型逐筆推論在 CPU 上嚴重浪費記憶體頻寬,而 padding 到全域最長長度更會白算好幾倍 token。
同一顆 0.5B 模型跑 600 張長短不一的工單分類,如果每一批都把所有句子補齊到資料集裡最長的 449 tokens,光是 padding 就會讓你多算 3.7 倍不必要的 token——這是這個 SLM 最佳化系列第三篇、也是最後一篇要處理的問題。
🤔 逐筆推論為何是整條管線裡最大的浪費
前兩篇文章分別談了限制輸出空間、以及用 key-value cache 重用 prompt 前綴,這篇則聚焦「依長度分批」而非逐筆跑迴圈。文章指出,在 batch size 為 1 的情況下,小模型是受記憶體頻寬限制而非算力限制:硬體必須把全部權重從記憶體讀出來只為服務一個序列,接著馬上重來一次服務下一個,運算單元大部分時間都在閒置。這件事在 GPU 上成立,在 CPU 上更明顯——而 0.5B 這種規模的模型,實務上最常就是跑在 CPU 上。批次處理能把「讀一次權重」的成本攤提到多筆序列上,但最直接的實作方式又會引入新的浪費:同一批裡的序列必須補齊(padding)到相同長度。真實世界的文字長度分布通常有長尾,如果資料集裡最長的項目有幾百個 token、中位數卻遠低於一百,把每個批次都補到全域最大值,代表大部分算力都花在 padding 上。
🧩 解法:先依 token 長度排序,再分批
文章延續系列第一篇的支援單分類情境(用 Qwen2.5-0.5B-Instruct,透過 Hugging Face Transformers 在配備 16 核心 Neural Engine、24GB RAM 的 M2 MacBook Air 上執行),並沿用第一篇的「限制式評分」做法,讓每筆項目只需一次前向傳播即可完成分類。基準版本(逐筆迴圈)的做法很直接:每次只把一個 prompt 丟進 tokenizer,取最後一個位置的 logits,在三個標籤(billing、technical、account)對應的第一個 token 之間比較大小。文章刻意模擬了一個長尾分布的工單長度,搭配固定的填充句(filler)延伸文字,讓長度變化但分類訊號仍保留在句首。
針對批次化版本,文章的做法是把同一批資料跑兩次:一次維持任意順序,用來獨立衡量「單純分批」帶來的效益;另一次先依 token 長度排序再分批,藉此看出排序本身額外帶來的效益。兩次執行都會追蹤處理的 token 預算裡有多少比例是 padding,而排序的核心邏輯很單純:把長度相近的項目分到同一批,讓每一批只需要補齊到「自己這批」的最大長度,而不是整個資料集的最大長度。
📊 基準數字:144 秒、4.2 筆/秒,padding 白算 3.7 倍
以 600 筆模擬工單為例,prompt 長度最短 48 tokens、中位數 94 tokens、最長 449 tokens;如果每批都補齊到全域最大值,將處理 3.7 倍於實際所需的 token 量。逐筆推論的基準結果是 144.35 秒處理完 600 筆,平均每筆約 0.23–0.24 秒,換算約 4.2 筆/秒。文章釋出的內容目前尚未附上依長度排序分批後的具體秒數與加速倍率,因此本文僅呈現已公開的基準數據與 padding 浪費比例,不臆測分批後的實際效能提升。
⚠️ 本文未附完整分批結果
素材中分批版本的程式碼已展示,但對應的實測輸出(耗時、items/sec、padding 比例)尚未在提供的內容中出現,因此無法據此估算依長度排序分批到底能帶來多少倍加速,只能確認 padding 浪費的量化依據(3.7 倍)與逐筆基準的成本。
🎯 實務啟示
如果你的應用場景是離線或近即時的批次分類、標記等窄域自動化任務,且輸入長度分布本身有長尾,先量測「padding 到全域最大值會浪費多少 token」是判斷是否值得改用依長度排序分批的第一步;搭配系列前兩篇提到的限制輸出空間與 KV cache 前綴重用,三者疊加起來才是把 SLM 部署成本壓低的完整思路。
🔗 來源
- 標題:Batching by Length Instead of Looping Item by Item for SLM Optimization
- 作者/機構:Matthew Mayo,KDnuggets
- 連結:https://www.kdnuggets.com/batching-by-length-instead-of-looping-item-by-item-for-slm-optimization
#SLM #LLMInference #Batching #HuggingFace #Transformers #EdgeAI #ModelOptimization #Qwen #PythonML #InferenceOptimization
原始資料 KDnuggets · 收集於 2026-09-25
摘要原文
Batching by Length Instead of Looping Item by Item for SLM Optimization We finish off our short series on SLM optimization with the third entry, focused on batching by length instead of looping item by item. By Matthew Mayo , KDnuggets Managing Editor on September 25, 2026 in Language Models Previous articles in this series discussed constraining output space as well as reusing the prompt prefix with a key-value cache , both framed as approaches to small language model (SLM) narrow automation optimization. Let's finish this series off with the third entry, focused on batching by length instead of looping item by item. As in our previous articles, all benchmarks below use Qwen2.5-0.5B-Instruct in float16 through Hugging Face Transformers , running on an M2 Macbook Air with 24GB RAM and a 16-core Neural Engine. Don't forget to set up a Python environment and install your requirements: pip install torch transformers accelerate We will continue to use the support ticket framing from our first article . Why Batch by Length Instead of Looping Item by Item Processing one ticket per forward pass is the single largest source of waste in the whole pipeline. At batch size 1, a small model is memory-bandwidth bound rather than compute-bound: the hardware streams every weight out of memory in order to serve one sequence, then does it again for the next one, and the arithmetic units sit mostly idle in between. This is true on a GPU and it is true on the CPU we have been using throughout this series, which is where a 0.5B model most often actually runs. Batching amortizes that weight read across many sequences. But the obvious implementation introduces its own waste, since sequences in a batch must be padded to a common length. Real-world text has a long tail: if the longest item in your dataset is a few hundred tokens and the median is well under a hundred, padding every batch to the global maximum means most of what you compute is padding. The answer is to sort by token length before forming batches, so each batch contains similarly sized items and pads to its own local maximum. Looping Item by Item Here is the per-item baseline on a realistic length distribution, with the constrained scoring from the first article in this series carried forward so that each item costs exactly one forward pass: import os import time import inspect import torch import numpy as np from transformers import AutoTokenizer, AutoModelForCausalLM MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" torch.set_num_threads(os.cpu_count() or 1) tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token tokenizer.padding_side = "left" # keeps the last real token at index -1 model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32) model.eval() LABELS = ["billing", "technical", "account"] # constrained scoring, carried over from the first article in this series label_first_ids = [tokenizer.encode(label, add_special_tokens=False)[0] for label in LABELS] assert len(set(label_first_ids)) == len(LABELS), ( "Labels share a first token; score full label sequences instead." ) label_first_ids = torch.tensor(label_first_ids, device=model.device) # a causal LM returns a logit vector for every position by default; at batch 32 by # 400 tokens that is a multi-gigabyte tensor we would immediately throw away, so # ask for the last position only where the installed version supports it _forward_params = inspect.signature(model.forward).parameters if "logits_to_keep" in _forward_params: LAST_LOGIT_ONLY = {"logits_to_keep": 1} elif "num_logits_to_keep" in _forward_params: LAST_LOGIT_ONLY = {"num_logits_to_keep": 1} else: LAST_LOGIT_ONLY = {} def build_prompt(ticket): messages = [ { "role": "system", "content": "You classify support tickets. Answer with exactly one of: billing, technical, account.", }, {"role": "user", "content": f"Ticket: {ticket}\nCategory:"}, ] return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) # simulate a long-tailed ticket length distribution: most items are short, a few # are very long. Each ticket keeps a real, classifiable sentence at the front and # is extended with filler, so length varies without the label signal disappearing. BASE_TICKETS = [ "My card was charged twice for the same invoice.", "The mobile app crashes whenever I open the settings page.", "I need to change the email address on my profile.", ] FILLER = ( "I have been waiting for a response for several days now and would really " "appreciate an update on this whenever someone gets a chance to look at it." ).split() rng = np.random.default_rng(0) target_words = np.clip(rng.lognormal(np.log(60), 0.9, size=600), 12, 400).astype(int) def make_ticket(base, n_words): words = base.split() while len(words) < n_words: words += FILLER return " ".join(words[:n_words]) tickets_var = [make_ticket(BASE_TICKETS[i % 3], int(n)) for i, n in enumerate(target_words)] prompts = [build_prompt(t) for t in tickets_var] token_lengths = [len(tokenizer(p, add_special_tokens=False)["input_ids"]) for p in prompts] print( f"Prompt lengths: min {min(token_lengths)}, " f"median {int(np.median(token_lengths))}, " f"max {max(token_lengths)} tokens" ) print(f"Padding every item to the global maximum would process " f"{max(token_lengths) * len(prompts) / sum(token_lengths):.1f}x the necessary tokens") # time inference baseline_predictions = [] start = time.time() for n, prompt in enumerate(prompts, start=1): # this loop runs for minutes on CPU, so report progress rather than sitting silent if n % 100 == 0: print(f" {n}/{len(prompts)} tickets ({(time.time() - start) / n:.2f}s each)", flush=True) inputs = tokenizer(prompt, add_special_tokens=False, return_tensors="pt").to(model.device) with torch.no_grad(): logits = model(**inputs, **LAST_LOGIT_ONLY).logits[0, -1, :] baseline_predictions.append(LABELS[int(logits[label_first_ids].argmax())]) duration_loop = time.time() - start # output task metrics print(f"One at a time: {duration_loop:.2f} seconds ({len(prompts) / duration_loop:.1f} items/sec)") Output: Prompt lengths: min 48, median 94, max 449 tokens Padding every item to the global maximum would process 3.7x the necessary tokens 100/600 tickets (0.24s each) 200/600 tickets (0.23s each) 300/600 tickets (0.23s each) 400/600 tickets (0.23s each) 500/600 tickets (0.24s each) 600/600 tickets (0.24s each) One at a time: 144.35 seconds (4.2 items/sec) Batching by Length Now the batched version. It runs the same data twice: once in arbitrary order, to isolate what batching alone is worth, and once sorted by length, to show what the sorting adds on top. Both runs track how much of the processed token budget went to padding. import os import time import inspect import torch import numpy as np from transformers import AutoTokenizer, AutoModelForCausalLM MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" BATCH_SIZE = 32 torch.set_num_threads(os.cpu_count() or 1) tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token # keep the last real token at index -1 tokenizer.padding_side = "left" model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32) model.eval() LABELS = ["billing", "technical", "account"] label_first_ids = [tokenizer.encode(label, add_special_tokens=False)[0] for label in LABELS] assert len(set(label_first_ids)) == len(LABELS), ( "Labels share a first token; score full label sequences instead." ) label_first_ids = torch.tensor(label_first_ids, device=model.device) _forward_params = inspect.signature(model.forward).parameters if "logits_to_keep" in _forward_params: LAST_LOGIT_ONLY = {"logits_to_keep": 1} elif "num_logits_to_keep" in _forward_params: LAST_LOGIT_ONLY = {"num_logits_to_keep": 1} else: LAST_LOGIT_ONLY = {} def build_prompt(ticket): messages = [ { "role": "system", "content": "You classify support tickets.
由 claude-code/sonnet 自動生成