跳转至

系统级优化

📟 PagedAttention 如何通过分页管理减少显存碎片?

💡 PagedAttention 借鉴操作系统的虚拟内存分页思想,将 KV Cache 分割为固定大小的 block(页),允许这些 block 在物理显存中离散分布,通过页表(block table)维护逻辑序列到物理块的映射。这彻底消除了外部碎片,极大降低了内部碎片,使显存利用率从传统方案的 20-30% 跃升至接近 90%。

🔍 背景:传统 KV Cache 的内存痛点

  • 在自回归生成中,每个请求都需要存储不断增长的 KV Cache。传统推理系统(如 FasterTransformer)会为每个请求预分配一块连续的最大长度显存(例如最大 2048 tokens)。

  • 外部碎片:如果请求实际生成长度远小于最大长度,预留空间大部分被浪费,而且频繁分配和释放不同大小的连续块会导致显存出现大量小空隙,这些空隙无法合并成大块供其他请求使用,形成外部碎片。

  • 内部碎片:即使按实际长度动态分配,不同请求的 KV Cache 大小不同,分配器也可能因对齐等产生内部碎片。更严重的是,无法精准共享相同前缀的 KV Cache,导致大量重复存储。

🧱 PagedAttention 的分页机制

  1. 固定大小的 Block:将 KV Cache 切分为固定大小的物理块(例如每个 block 包含 16 个 token 的 K 和 V)。块大小是配置参数,需权衡碎片率和管理开销。

  2. 逻辑块映射:每个请求的 KV Cache 由一系列逻辑块组成,逻辑块通过页表映射到物理块。物理块从全局内存池中按需分配,不要求连续。

  3. 内存池管理:系统初始化时预分配一个巨大的物理块池,后续请求从池中获取空闲块,用完归还。这避免了频繁的 cudaMalloc/cudaFree,同时物理块的离散分配天然无外部碎片。

✨ 减少碎片的体现

  • 消除外部碎片:因为所有分配单位是大小相同的 block,所以不会出现“有足够总空闲但无连续大块”的问题。任何空闲 block 都可直接分配给任意请求。

  • 控制内部碎片:每个序列的最后一个 block 可能未被完全占满,平均内部碎片约为半个 block 大小。通过选择合适的 block size(例如 16 或 32),可以将此控制在可接受范围。

  • 前缀共享:多个请求共享相同的 system prompt 时,它们的逻辑页表可以指向相同的物理块(只读),从而避免为每个请求重复存储前缀 KV Cache,大幅节省显存。这也是分页带来的额外优势。

📊 实际效果(vLLM 论文数据)

  • 在相同显存下,PagedAttention 使系统能够同时处理的请求数量提高 2-4 倍,显存利用率从不足 30% 提升到 80-90%。

  • 允许动态调整 batch size,减少了排队延迟,同时支持更长的生成。

🔧 工程细节

  • 块大小选择:小则碎片少,但页表更大,管理开销增加;大则管理简单,但内部碎片多。常用 16 或 32。

  • 回收与重映射:当请求完成,所有物理块被标记为空闲,归还池中。由于使用逻辑-物理映射,无需移动数据。

  • Copy-on-Write 等扩展:某些框架支持对共享块进行写时复制,进一步优化多轮对话等场景。

✅ 总结:PagedAttention 通过分页离散分配打破了显存连续性的桎梏,将碎片降至极低,是 LLM 推理服务的核心内存管理创新。


🎛️ 推理服务中,如何实现多个 LoRA 适配器的显存共享?

💡 实现多 LoRA 适配器显存共享的核心思路是:基础模型权重常驻 GPU 且全局共享,多个 LoRA 适配器则以“热插拔”方式动态换入换出 GPU 内存池,仅同时保留活跃适配器于 GPU,其余放 CPU 或 NVMe,通过高效的 I/O 与分页管理使数千个适配器服务于海量用户而不爆显存。

🔌 基础模型自然共享

所有 LoRA 适配器都基于同一个基础模型,因此基础模型的权重(通常占显存 95% 以上)只需在 GPU 中保存一份,天然的共享。真正需要管理的是 LoRA 的 A 和 B 矩阵。

🗂️ 适配器权重的显存管理策略

  1. 集中存储 + 动态加载 系统维护一个中央适配器仓库(CPU 内存或 SSD)。当请求到达时,调度器根据请求所需的 LoRA ID,将对应适配器权重异步加载到 GPU 的一个预留内存池中。一旦该请求完成,且没有其他请求在使用该适配器,就可以将其卸载,释放 GPU 空间给其他适配器。

  2. 分页管理 LoRA 权重(类似 PagedAttention) S-LoRA 等先进系统将每个 LoRA 适配器的权重矩阵也划分为固定大小的块(tiles),存储在 GPU 内存池中。不同适配器的块可以在物理内存中交织存放,由统一的页表管理。每个 LoRA 矩阵的加载只需拷贝所需的块,而非整个矩阵。这种方式实现了:

  3. 细粒度共享:如果两个适配器部分相同(很少见),可共享块。
  4. 外部碎片消除:所有 LoRA 块大小相同,无外部碎片。
  5. 高利用率:GPU 内存池被众多适配器分时复用,整体显存占用接近活跃适配器总大小。

  6. 统一内存池 将基础模型的权重、KV Cache 和 LoRA 权重统一管理在一个分页内存池中(如 Punica、vLLM 的 LoRA 集成)。GPU 显存被划分为不同类型的内存块(KV block, LoRA block),按需分配,全局调度,最大化整体利用率。

  7. 批量计算与适配器分组 若多个请求使用不同 LoRA 适配器,需要批量执行 y = Wx + B_i A_i x。一些系统通过将同一批中相同适配器的请求分组,减少适配器切换开销。显存共享策略要确保热门的适配器常驻 GPU,冷门的则被换出。

📊 显存节省示例

  • 假设一个 LoRA 适配器占 50 MB,1000 个适配器总大小 50 GB,远超单卡显存。通过动态加载,GPU 仅保留当前活跃请求所需的 10 个适配器(500 MB),其余暂存 CPU 内存(占用 50 GB 系统内存但 GPU 显存无压力)。这样一张 24 GB 显卡就能服务上千个定制模型。

⚠️ 挑战

  • 切换延迟:从 CPU 拷贝适配器到 GPU 需要时间,必须预取和流水线化以避免增加请求延迟。

  • 内存碎片:动态分配固定大小块可缓解,但需精心设计块大小和池容量。

✅ 因此,多 LoRA 适配器显存共享依赖统一的动态内存管理,通过分页与换入换出实现高密度部署,是“模型即服务”的关键支撑。


💾 模型卸载(offload)到 CPU/NVMe 的显存-速度权衡是怎样的?

💡 模型卸载将部分模型状态(权重、优化器、KV Cache)从 GPU 显存转移至 CPU 内存或 NVMe SSD,以大幅降低 GPU 显存占用,但代价是计算时将数据通过低带宽总线搬运,导致显著的速度下降。这是典型的“空间换时间”策略,带宽和延迟的鸿沟决定了下降程度。

🔄 卸载层次与带宽对比

存储层 典型带宽 与 GPU HBM 的差距
GPU HBM (A100) 2 TB/s 基准
CPU 内存 (DDR5) 50-100 GB/s (通过 PCIe 4.0 x16) ~20-40x
NVMe SSD (PCIe 4.0) 7 GB/s ~300x
NVMe SSD (PCIe 3.0) 3.5 GB/s ~600x

当计算所需数据不在 GPU 时,必须等待数据搬运,GPU 空闲等待,吞吐大打折扣。

🔍 显存节省 vs 速度损失的具体表现

  • 卸载优化器状态(训练场景) ZeRO-Offload 将 Adam 优化器状态(动量、方差)放在 CPU,GPU 仅保留参数和梯度。更新参数时,梯度传到 CPU,CPU 执行优化器步骤,再将更新后的参数传回 GPU。由于优化器状态大小是权重的 4 倍,显存节省巨大。但每步增加两次完整的参数传输(梯度→CPU,参数→GPU),加上 CPU 计算。通常导致 20-50% 的吞吐下降,但使单卡能训练数十 B 模型。

  • 卸载模型参数(推理场景) 推理时可将不常用的层或整个模型权重放在 CPU,计算时逐层预取。例如 llama.cpp 支持部分层卸载到 CPU。每层需要传输权重矩阵,会严重降低生成速度,尤其对于小 batch。典型地,卸载一半层到 CPU 可能导致速度下降 3-5 倍。

  • 卸载 KV Cache(推理场景) 长序列推理中,可将冷 KV Cache 块卸载到 CPU 内存,需要时换入。vLLM 等支持 swap 到 CPU。由于 KV Cache 访问频繁,即使部分卸载,延迟也会上升,但可处理更长上下文。

⚖️ 权衡决策

  • 何时值得:当 GPU 显存绝对不足,无法运行目标模型时,卸载是唯一解法。例如在 24GB 卡上推理 70B 量化模型,必须部分卸载。

  • 如何优化:通过异步预取(prefetch)和计算重叠隐藏传输延迟。DeepSpeed ZeRO-Infinity 可以在计算当前层时提前从 NVMe 预取下一层,实现部分隐藏。但硬件带宽底层限制仍在。

  • 效果实例:使用 ZeRO-Infinity 在单张 V100 32GB 上训练 30B 模型,比纯 GPU 训练慢 3-5 倍;推理 175B 模型,生成速度降至数秒一个 token,但可行。

📊 速度-显存曲线(定性)

  • 完全 GPU:速度 100%,显存 100%。

  • 卸载优化器到 CPU:速度 70-80%,显存可降至 40%。

  • 卸载参数到 CPU:速度 20-40%,显存可降至 10%。

  • 卸载到 NVMe:速度 5-15%,显存可降至几乎无限(仅 GPU 保留极少工作集)。

✅ 总结:卸载是解决显存不足的终极手段,以大幅牺牲速度为代价换取模型运行能力。实际应用中,总是优先用量化、并行等其他方法,卸载作为保底方案。


🏊 内存池(memory pool)技术在大模型推理中的应用

💡 内存池是一种预分配一大块显存并自行管理分配与回收的技术,大模型推理中主要用于管理 KV Cache 和临时张量,以消除频繁的 cudaMalloc/cudaFree 开销,避免碎片,并实现动态批量处理。

🔧 内存池的实现方式

  1. 固定大小块池(Block Pool) 这是 PagedAttention 的核心。系统预先分配成千上万个固定大小的物理块(如每块容纳 16 个 token 的 K、V),这些块构成一个空闲池。请求到来时,从池中获取空闲块分配给该请求的 KV Cache;请求完成,块回收至池中。因为块大小统一,分配/回收仅为 O(1) 的链表操作,极快且无碎片。

  2. 分层内存池 一些系统(如 TensorRT-LLM)将不同生命周期的对象放入不同池:如权重池(静态,生命周期与应用同)、激活池(临时,每层复用)、KV Cache 池等。通过显式内存复用,可精确控制峰值显存。

  3. PyTorch CUDA Caching Allocator PyTorch 自带的内存池机制:它拦截 cudaMalloc,维护一个缓存区。释放的张量空间不会立即归还给驱动,而是保留在缓存中,下次分配同样大小时直接复用,减少分配开销。但这只是通用池,缺乏语义层优化。

🚀 在大模型推理中的典型应用

  • vLLM 的 KV Cache 池: 初始化时根据可用显存创建尽可能多的 KV blocks,存入池中。调度器根据请求长度和当前占用,动态分配块。当总需求超过池容量时,可抢占低优先级请求,回收其块(swap 到 CPU 或丢弃)。这种池化设计保证了显存的有效利用和弹性调度。

  • 多 LoRA 的权重池: S-LoRA 将 LoRA 权重也切块放入池,与 KV Cache 池统一管理。池的大小决定了可同时驻留的 LoRA 适配器数量和可支持的并发请求数。

  • 激活内存池: 部分推理引擎(如 ONNX Runtime)会在模型初始化时分析计算图,预分配最大激活所需的一片内存,运行时通过 offset 重复使用,杜绝动态分配。

📊 内存池的优势

  • 低碎片化:固定块或区域复用杜绝了外部碎片。

  • 高吞吐:快速分配/回收使得能够高频调度大量请求,提升 GPU 利用率。

  • 可预测性:预分配和池化使显存占用上限可预测,方便进行容量规划和限制。

  • 支持弹性抢占:可基于池的占用情况做出换入换出决策,实现平滑的过载管理。

🔍 实际配置:vLLM 中,通过 --gpu-memory-utilization 设定池可使用的显存比例,剩下的留给权重和激活。例如设为 0.9,则 90% 的显存用于 KV 块池,10% 为其他。

✅ 因此,内存池是大模型推理系统中不可或缺的显存管理基石,它通过预分配和复用机制将显存利用率推至极致,支撑高并发服务。


☸️ 在 Kubernetes 集群中,如何设置 GPU 显存限制?

💡 Kubernetes 原生仅支持以整数卡数(nvidia.com/gpu)为粒度的 GPU 分配,不提供细粒度的显存限制。要限制单容器的 GPU 显存,必须借助第三方 GPU 共享方案、MIG(多实例 GPU)或硬件虚拟化技术,将物理 GPU 切分为更小的显存单元。

📊 常见方案对比

方案 原理 是否硬件隔离 显存限制方式
NVIDIA MIG 硬件分区(A100/A30/H100) 每个 MIG 实例有固定显存和计算资源
NVIDIA Time-Slicing GPU Operator 时分复用 否(进程间无显存隔离) 不限制显存,仅限制并发
vGPU / GPU 虚拟化 软件虚拟化(如 NVIDIA vGPU, 腾讯云 cGPU) 半隔离 可限制显存,显存和算力配额
开源 GPU 共享调度器 基于 CUDA API 劫持或自定义设备插件(如 Hami, Volcano) 软隔离 在容器内设置显存使用上限

🔧 具体实施方式

  1. 使用 MIG(最硬隔离,推荐生产)
  2. 配置节点 MIG 模式,将一张 A100-80GB 划分为多个 GPU 实例(如 7 个 10GB 的 1g.10gb 实例)。
  3. K8s 中通过 nvidia.com/mig-1g.10gb 这样的资源声明请求 MIG 实例。Pod 会绑定到特定的 MIG 实例,显存上限由该实例大小决定,绝对隔离。
  4. 优点:性能稳定,无相互干扰;缺点:仅部分 GPU 支持,切分不够灵活。

  5. 基于 Nvidia GPU Operator 的时分复用

  6. 配置 time-slicing 可让多个 Pod 共享同一 GPU,但不会限制显存。每个容器仍可尝试使用全部显存,可能引发 OOM。
  7. 需要结合应用层的显存限制(如设置 PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb 或框架内置的 gpu-memory-utilization),但非强隔离。

  8. GPU 共享调度器(软件限制)

  9. 部署如 Hami(原 kube-batch)、Volcano 等,它们提供自定义设备插件,暴露 nvidia.com/gpumem 资源。
  10. 在 Pod 中设置 resources.limits.nvidia.com/gpumem:`` 8Gi,调度器会确保节点上所有 Pod 的显存声明之和不超过物理总量,并为容器注入环境变量或内核拦截来限制实际显存使用。
  11. 例如 Hami 通过修改 CUDA 库的显存分配函数,限制进程内可见显存大小,实现软件层面的显存限制。
  12. 优点:灵活,支持超售;缺点:软件限制可能被绕过,且存在性能干扰。

  13. 云厂商的 GPU 共享方案

  14. 阿里云 ACK 支持 aliyun.com/gpu-mem,腾讯云 TKE 支持 tencent.com/vcuda-coretencent.com/vcuda-memory,它们都是通过虚拟化驱动实现显存和算力的隔离。直接在 Pod 中设置此类扩展资源即可。

📝 示例 YAML(使用 Hami)

apiVersion: v1
kind: Pod
metadata:
  name: gpu-test
spec:
  containers:
  - name: app
    image: my-llm:latest
    resources:
      limits:
        nvidia.com/gpu: 1         # 请求1卡
        nvidia.com/gpumem: 8192   # 限制显存 8GiB

⚠️ 注意事项

  • 软件显存限制通常依赖 LD_PRELOAD 劫持 CUDA 库,要求容器内 CUDA 版本与宿主兼容。

  • 显存超售(总和超过物理容量)可能导致 OOM 杀 Pod,需设置合适的调度策略。

  • 监控和日志对排查显存瓶颈至关重要,可配合 Prometheus GPU Exporter。

✅ 总结:在 K8s 中实现 GPU 显存限制需借助 MIG(最优)或第三方软件方案(灵活)。选择方案需权衡隔离性、灵活性和硬件兼容性。