Skip to content

推理服务和批处理

为数千个并发用户提供LLM服务需要的不仅仅是加载模型并运行推理。本文件涵盖预填充-解码分离、连续批处理、PagedAttention和vLLM、调度策略、分离式推理服务、多模型和LoRA推理服务,以及重要的指标

  • 单个LLM推理请求很简单:输入token,生成输出token。但是以低延迟和高吞吐量为10,000个并发用户提供LLM服务是一个系统问题。朴素方法(一次处理一个请求)浪费90%+的GPU容量。智能批处理和调度可以在不增加硬件的情况下将吞吐量提高10-50倍。

预填充 vs 解码:两个截然不同的阶段

  • LLM推理有两个不同的阶段,具有根本不同的计算特征:

  • 预填充(Prefill)(提示词处理):同时处理所有输入token。这是一个单一大型矩阵乘法:\(O(\text{prompt\_length} \times d_{\text{model}}^2)\)。提示词可以并行处理(所有token已知)。预填充是计算受限的:GPU的ALU是瓶颈。

  • 解码(Decode)(token生成):每次生成一个token,自回归地。每个新token需要通过KV缓存关注所有先前的token。解码是内存带宽受限的:GPU花费大部分时间从内存加载模型权重和KV缓存,而非计算。每个解码步只产生一个token,但必须加载整个模型(70B模型在FP16下约140 GB)。

  • 含义:

预填充 解码
处理的token 一次性全部(并行) 一次一个(顺序)
瓶颈 计算(FLOPS) 内存带宽
算术强度 极低
GPU利用率 高(50-80%) 无批处理时低(1-10%)
延迟指标 首token时间(TTFT) 每输出token时间(TPOT)
  • TTFT影响用户体验(响应多久开始流式输出)。TPOT决定感知的生成速度。用户容忍较高的TTFT(1-5秒)但期望快速的TPOT(对话应用每token 30-100 ms)。

静态批处理(朴素方法)

  • 最简单的批处理:收集\(B\)个请求,将它们填充到相同长度,作为单个批次处理。

  • 问题1:请求有不同的提示词长度并生成不同数量的输出token。短请求先完成但必须等待批次中最长的请求,然后才能开始下一批。GPU在为剩余的一个长请求生成时处于空闲状态。

  • 问题2:填充浪费计算。如果最长提示词是2000个token,最短是50,,这批被填充到2000。GPU为短请求处理1950个填充token——纯粹浪费。

静态批处理在等待最长请求时浪费GPU槽位;连续批处理立即填充释放的槽位

连续批处理

  • 连续批处理(也称迭代级批处理)通过在单独解码步的粒度上操作,而非整个请求,解决上述两个问题。

  • 在每个解码步:

    1. 所有进行中的请求并行生成一个token(作为一个批次)。
    2. 完成的请求(生成EOS token)被立即从批次中移除
    3. 队列中的新请求被立即插入释放的槽位。
  • 批次大小每一步都动态变化。GPU从不空闲等待拖后腿的请求,且没有浪费的填充(每个请求只使用它需要的槽位)。

  • 影响:连续批处理通常比静态批处理提高2-10倍吞吐量,不改变模型质量或显著增加延迟。

PagedAttention和vLLM

  • KV缓存创造了内存管理的噩梦。每个请求都有一个随每个生成token增长的KV缓存。不同请求处于不同阶段(不同缓存大小)。为每个请求分配连续内存浪费空间(你必须为最大可能长度分配,即使请求只生成几个token)。

PagedAttention将虚拟KV缓存页映射到非连续物理GPU内存,消除碎片并支持按需分配

  • PagedAttention(Kwon et al., 2023)将OS的虚拟内存概念(第13章)应用于KV缓存。缓存被划分为固定大小的(token位置的块)。页面按需分配,在物理GPU内存中可以是非连续的。

  • 好处:

    • 无碎片:页面大小统一,因此请求之间没有浪费内存的"空洞"。
    • 惰性分配:内存仅在token实际生成时分配,不为最大长度预分配。
    • 写时复制:共享通用前缀的请求(如系统提示词)共享相同的KV缓存页。仅当请求分叉时才复制页面。
  • vLLM是围绕PagedAttention构建的推理引擎。通过几乎消除KV缓存内存浪费,它比静态分配推理服务(如没有paged attention的HuggingFace text-generation-inference)实现了2-4倍更高的吞吐量。

调度策略

  • 当多个请求在等待且GPU只能处理有限的批次时,调度决定为哪些请求提供服务:

  • 先到先服务(FCFS):按到达顺序处理请求。简单但不公平:提交10K token生成的用户阻塞所有后面用户。

  • 最短作业优先(SJF):处理最快完成的请求。最小化平均延迟但惩罚长时间运行的请求(可能饿死)。实践中,估计输出长度未知,所以SJF使用启发式(提示词长度、用户历史)。

  • 抢占:如果高优先级请求到达,暂停较低优先级的进行中请求(将其KV缓存交换到CPU内存或SSD)、服务高优先级请求,然后恢复暂停的请求。vLLM支持此功能。

  • 基于优先级:为用户或请求类型分配优先级。实时交互查询获得比批处理作业更高的优先级。与抢占结合,确保高优先级流量的延迟SLO。

  • Token预算:限制活跃批次中的token总数。这防止少数长请求垄断GPU内存并饿死新请求。

分离式推理服务

  • 预填充和解码具有相反的计算特征。在同一GPU上同时运行两者意味着GPU在计算受限(预填充)和内存带宽受限(解码)之间交替,永远无法充分利用任一资源。

  • 分离式推理服务将它们分开:

    • 预填充节点:为计算优化的GPU(高FLOPS,可能内存较少)。处理所有传入提示词。
    • 解码节点:为内存带宽优化的GPU(大KV缓存容量、高内存带宽)。处理所有token生成。
  • 预填充节点计算初始KV缓存并将其发送到解码节点(通过NVLink或网络)。解码节点使用接收到的缓存生成token。

  • 这是Mooncake(Moonshot AI)的架构,正被多个LLM推理服务团队探索。好处:每种GPU类型匹配其工作负载特征,改善整体利用率。

多模型和LoRA推理服务

  • 在生产中,你经常需要服务多个模型(不同大小用于不同层级、不同微调变体用于不同任务)。

  • 模型复用:在同一GPU上加载多个模型并将请求路由到相应的模型。GPU内存共享:一张40 GB GPU可能同时容纳一个13B模型(26 GB)和一个7B模型(14 GB)。

  • LoRA推理服务:不是部署单独的微调模型,而是部署一个基础模型配合多个LoRA适配器(第6章)。每个适配器增加<1%参数。请求在推理时被路由到相应的适配器。

  • S-LoRA(Sheng et al., 2023):从单一基础模型服务数千个LoRA适配器。适配器存储在CPU上,按需分页到GPU内存。基础模型的KV缓存和权重共享;只有小型LoRA矩阵因请求而异。

  • Punica(Chen et al., 2023):通过使用自定义CUDA核在同一批次内对不同请求应用不同的LoRA矩阵,批处理跨不同LoRA适配器的请求。这避免了按请求切换适配器的开销。

约束和引导生成

  • 许多应用需要LLM以特定格式产生输出:有效的JSON、SQL查询、特定语言代码,或遵循schema的响应。约束生成保证输出符合语法或schema。

  • 语法约束解码:在每个解码步,屏蔽会违反语法的token。如果到目前为止的输出是{"name": "Alice", "age":且语法要求下一个是整数,则屏蔽除数字外的所有token。LLM的概率分布在有效token上重新归一化。

  • Outlines(Willard & Louf, 2023):将JSON schema或正则表达式编译为有限状态机(FSM)。在每个解码步,FSM确定哪些token是有效延续。无效token概率为0。这保证100% schema合规且零重试。

  • SGLang原生集成约束生成:你在Python中指定输出结构,引擎高效处理token屏蔽和缓存。这结合了RadixAttention(前缀缓存),使结构化输出重用缓存前缀。

  • 为什么重要:没有约束生成时,你自由生成并解析输出,失败时重试。对于复杂JSON schema,重试率10-30%很常见,浪费计算。约束生成完全消除重试。

请求路由

  • 并非每个查询都需要最大的模型。请求路由根据估计的难度将查询导向不同模型:

  • 级联:先尝试小模型。如果小模型的置信度低于阈值(例如top token的softmax概率<0.8),升级到大模型。简单查询(80%+流量)由小模型廉价服务;只有困难查询使用昂贵模型。

  • 学习路由:训练一个轻量分类器(或使用小模型的困惑度)预测查询需要哪个模型层级。将"What is 2+2?"路由到3B模型,将"Explain the mathematical foundations of quantum entanglement"路由到70B模型。

  • 影响:如果80%的查询可由成本低10倍的模型处理,平均每查询成本下降约70%。这是多模型部署中影响最大的成本优化之一。

  • 设备端+云混合路由Cactusgithub.com/cactus-compute/cactus)在设备层实现请求路由。它通过自定义ARM SIMD核在设备端(手机、笔记本、可穿戴设备)运行小模型,当本地模型置信度低或查询超出设备能力时自动路由到云模型。应用对两条路径使用OpenAI兼容API——路由是透明的。这是基础设施层级的级联:第一层是免费的(设备端),第二层花费金钱(云API)。对于大多数查询是简单的应用(助手问答、自动补全、转录),设备端处理以零边际成本覆盖70-90%流量。

推理指标

  • 正确的指标取决于使用场景:
指标 衡量什么 目标(对话) 目标(批量)
TTFT 首token时间 <1秒 不太重要
TPOT 每输出token时间 <100 ms 不太重要
吞吐量 总token/秒 不太重要 最大化
p99延迟 最差1%的请求 <5秒 <30秒
每token成本 $/100万token 最小化 最小化
SLO合规 满足延迟目标的请求百分比 >99% >95%
  • TTFT vs TPOT权衡:激进批处理增加吞吐量(更大的总token/s)但增加TPOT(每个token需要更长时间,因为GPU处理更多请求)。调度策略必须平衡吞吐量(收入)和延迟(用户体验)。

  • 每token成本是生产的终极指标。它结合了硬件成本(GPU租赁)、吞吐量(tokens/s)和利用率。在50% GPU利用率运行的系统每token成本是在100%运行时的2倍。这就是为什么批处理、调度和PagedAttention如此重要——它们提高了利用率。

编程任务(使用CoLab或notebook)

  1. 模拟连续批处理 vs 静态批处理并测量吞吐量差异。

    import random
    import time
    
    def simulate_static_batching(requests, batch_size=8):
        """在固定批次中处理请求。等待所有完成。"""
        total_tokens = 0
        total_time = 0
    
        for i in range(0, len(requests), batch_size):
            batch = requests[i:i + batch_size]
            max_len = max(r['output_len'] for r in batch)
            # 批次中所有请求的时间与最长的一样
            batch_time = max_len * 0.01  # 每token 10ms
            total_time += batch_time
            total_tokens += sum(r['output_len'] for r in batch)
    
        return total_tokens / total_time  # tokens/秒
    
    def simulate_continuous_batching(requests, max_batch=8):
        """使用连续批处理。移除完成的,添加新的。"""
        total_tokens = 0
        total_time = 0
        active = []
        queue = list(requests)
    
        while active or queue:
            # 填充批次
            while len(active) < max_batch and queue:
                active.append({'remaining': queue.pop(0)['output_len']})
    
            if not active:
                break
    
            # 一个解码步:所有活跃请求生成1个token
            for req in active:
                req['remaining'] -= 1
            total_tokens += len(active)
            total_time += 0.01  # 每步10ms
    
            # 移除完成的请求
            active = [r for r in active if r['remaining'] > 0]
    
        return total_tokens / total_time
    
    # 生成具有不同输出长度的请求
    random.seed(42)
    requests = [{'output_len': random.randint(10, 500)} for _ in range(100)]
    
    static_tps = simulate_static_batching(requests)
    continuous_tps = simulate_continuous_batching(requests)
    
    print(f"静态批处理:     {static_tps:.0f} tokens/s")
    print(f"连续批处理: {continuous_tps:.0f} tokens/s")
    print(f"加速比: {continuous_tps / static_tps:.1f}x")
    

  2. 计算PagedAttention节省的KV缓存内存。比较预分配(最坏情况)vs分页(实际使用)。

    def paged_vs_preallocated(n_requests, max_seq_len, avg_seq_len, page_size, kv_per_token_bytes):
        """比较内存使用:预分配 vs 分页KV缓存。"""
        # 预分配:每个请求获得max_seq_len个槽位
        preallocated_gb = n_requests * max_seq_len * kv_per_token_bytes / 1e9
    
        # 分页:只分配实际使用的(以页面粒度)
        import math
        avg_pages = math.ceil(avg_seq_len / page_size)
        paged_gb = n_requests * avg_pages * page_size * kv_per_token_bytes / 1e9
    
        waste_preallocated = (max_seq_len - avg_seq_len) / max_seq_len
        waste_paged = (avg_pages * page_size - avg_seq_len) / (avg_pages * page_size)
    
        print(f"请求: {n_requests}, 最大序列: {max_seq_len}, 平均序列: {avg_seq_len}")
        print(f"  预分配: {preallocated_gb:.1f} GB (浪费: {waste_preallocated:.0%})")
        print(f"  分页:   {paged_gb:.1f} GB (浪费: {waste_paged:.0%})")
        print(f"  节省:   {preallocated_gb - paged_gb:.1f} GB ({preallocated_gb/paged_gb:.1f}x)")
        print()
    
    # Llama-70B: 每层每token约1.3 KB,80层 = 每token总计约100 KB
    kv_bytes = 100_000
    
    # 场景1: 短请求,大最大长度
    paged_vs_preallocated(256, max_seq_len=4096, avg_seq_len=256, page_size=16, kv_per_token_bytes=kv_bytes)
    
    # 场景2: 不同长度
    paged_vs_preallocated(256, max_seq_len=8192, avg_seq_len=1024, page_size=16, kv_per_token_bytes=kv_bytes)
    
    # 场景3: 长上下文
    paged_vs_preallocated(64, max_seq_len=131072, avg_seq_len=16000, page_size=16, kv_per_token_bytes=kv_bytes)