适用于并发LLM服务的高效的GPU池化

Aegaeon

Posted by Sirin on October 21, 2025

Introduction

Background

GPU集群上运行的大模型对于资源的利用率十分不平衡,来自用户的请求模式难以预测,绝大部分的模型只会接收极少数的推理请求,资源浪费严重,17.7%的GPU资源只被用于服务1.35%的请求。而热门的模型则会承受超过其预留资源容量的突发流量的请求。

Existing Methods & Limitations

为了让每个GPU能够更高效率地处理多个模型的请求,通常是采取GPU池化的策略。将GPU的资源集中在一起,再根据请求去动态地分配给不同的工作负载。通常的方法包括多路复用(Multiplexing)自动扩缩容(Auto-Scaling)

多路复用Multiplexing是比较常见的GPU划分方法,将一块物理GPU划分为多个隔离分区,然后每个分区上挂载一个模型负载。具体来分,可以分为空间多路复用时间多路复用。空间多路复用就是像MIG、MPS这些技术,将显存划分成多个分区然后分配给用户提交的工作负;时间多路复用则是采用轮询时间片的方式,通过快速切换负载来实现并发。

这种技术的优势就是低延迟切换(模型已在显存中,不需要加载开销),能够实现较好的资源隔离且易实现。但相应的,本身硬件资源具有硬性限制(一张80GB的GPU上最多能同时安置2-3个模型),容易产生碎片化问题,缺乏弹性(无法处理请求处理过程中的动态资源需求变化)。

自动扩缩容Auto-Scaling采用的是动态装卸载的方式,GPU在同一时间只运行一个模型,当收到某个模型的请求时,将该模型挂载到GPU上运行,请求完成后,将其卸载到主机内存上再将其他模型挂载到显存。但这样就存在一个粒度问题,这种切换是在请求级别的粒度进行的,会出现队头阻塞。

时间轴: 0s--------10s--------20s--------30s--------40s
GPU:    [模型A请求1=============] [模型B请求2==========]
         ↑                     ↑ ↑                  ↑
        请求1开始         请求1结束 请求2开始        请求2结束

为了弥补Auto-Scaling技术在处理粒度上的不足,本文提出了Token级别的Auto-Scaling技术——Aegaeon,在token粒度上进行评估,抢占性将正在运行的模型卸载并将新请求到达的模型挂载,满足SLO(Service-Level Object)指标的同时,使得每张GPU上可挂载的模型数量大大增加

关于SLO……

SLO 的全称是 Service-Level Objective(服务等级目标)。

可以把它理解为一个量化的、可测量的服务质量承诺。在云计算和在线服务领域,SLO 是定义用户期望服务达到何种性能水平的正式指标。与传统的一次性请求-响应服务不同,LLM 推理是一个流式生成的过程,用户体验由多个关键节点上的延迟共同决定。因此,不能简单地用整个请求的“总完成时间”来衡量好坏。在 Aegaeon 和现代 LLM 服务系统中,通常关注两个核心的、按 Token 划分的 SLO:

  • TTFT - Time To First Token

    • 定义:从用户发送请求到收到第一个输出 Token 所经过的时间。

    • 影响:这直接决定了用户的上手体验,一个过长的 TTFT 会让用户感觉系统“卡住了”或没有响应。

    • 典型值:在交互式应用中(如聊天机器人),通常要求 TTFT < 1-2 秒。

  • TBT - Time Between Tokens

    • 定义:在流式输出中,后续每个 Token 之间的生成时间间隔。

    • 影响:这决定了输出内容的流畅度。一个稳定且较低的 TBT 会让输出像“打字”一样流畅出现。如果 TBT 过高且波动大,输出会变得一卡一卡的,体验很差。

    • 典型值:对于流畅的对话,TBT 通常需要 < 100-200 毫秒。

文中第2.1节的最后给出了SLO attainment的定义,即“满足其截止时间的Token的生成次数的百分比”。

Overview of Aegaeon

Aegaeon-scheduling.png

本文的一个关键问题是如何实现Token-Level Scheduling。首先是Unified Scheduling Policies,包括Prefill-prioritized和Decode-prioritized,这两种调度方案都是将对应请求的Prefill和Decode阶段都放在同一GPU上进行,但其缺点就如图上所示,前者会导致TBT不满足SLO需求(请求生成内容不流畅)而后者则是TTFT不满足SLO需求(请求回应的等待时间过长)。为此,需要采用(c)中所示的将Prefill和Decode阶段分离到两张卡上进行的Disaggregated Scheduling Policy。

Prefill-Phase Scheduling

在Prefill阶段采取的策略是分组FCFS,核心是在保证整体上FCFS的前提下,将同一模型的请求批量化处理以减少模型切换的成本。

数据结构实现:

  • 每个 Prefill 实例维护一个作业队列
  • 队列中的元素不是单个请求,而是。每个组包含属于同一模型的多个请求。
  • 组的大小有上限 MAX_GROUPSIZE(论文中设为8)。

新请求到达时:

  • 步骤1(寻找现有组):调度器遍历所有 Prefill 实例的作业队列,寻找是否存在一个属于目标模型 r.model 且尚未满员的组。
  • 步骤2(加入或创建)
    • 如果找到:将该请求 r 加入这个现有的组。这是首选方案,因为它避免了创建新组可能引发的模型切换。
    • 如果未找到:为请求 r 创建一个新的组。然后将这个新组添加到负载最轻的 Prefill 实例的作业队列末尾。负载的计算依据是队列中所有组的预计执行总时间(包括预填充计算时间和潜在的模型切换时间)。

执行时:

  • 实例从其作业队列的头部取出第一个组。
  • 如果当前GPU上加载的模型不是这个组所需的模型,则执行一次模型切换。
  • 然后,该组内的请求被逐个执行(批大小为1),生成它们的第一个 Token。之后,这些请求被移交到 Decode 实例。

通过将同模型请求分组,一次模型切换可以服务最多8个请求,极大地降低了每个请求平摊的切换开销。此外,虽然进行了分组,但整体仍是FCFS。新创建的组被放在队列尾部,防止后到的请求插队。设置批大小为1并限制组大小,是为了防止单个大组长时间占用实例,能让请求尽快进入解码阶段,有助于降低 TBT。

Decode-Phase Scheduling

在Decode阶段采取的策略是加权轮转调度,通过将时间切片,每个轮次中,为Active Models分配相应的配额,以轮转的方式去执行,保证所有请求都能获得进展。

数据结构实现

  • 每个 Decode 实例维护一个工作列表。
  • 列表中的每个元素是一个Decode Batch,包含属于同一模型的一组请求和相应的时间配额

请求到达时:从 Prefill 阶段完成的请求会被分发到 Decode 实例,基于KV 缓存容量来确定batch size。

执行时(核心):

  • a. 轮次开始
    • 重排序:将工作列表中属于同一模型的批次调整到相邻位置。这是为了在后续轮转中,连续处理同模型批次,减少不必要的模型切换。
    • 计算配额:为工作列表中的每一个批次 $i$计算一个时间配额 $q_i$。
  • b. 配额计算原理
    • 定义 $\large n_i = \frac{d}{t_i}$。其中 $d$ 是目标 TBT(如 100ms),$t_i$ 是批次 $i$解码一个 Token 的实际时间。$n_i$可以理解为该批次的速度,$n_i$ 越大,说明该批次解码越快,理应获得更多机会。
    • 公式 $\large q_i = \frac{c}{ n_i \times (\alpha - \sum_k(\frac{1}{n_k})) }$ ,其中$c$是工作列表中所有模型的auto scaling开销之和,该公式的目标是,让分配给每个批次的时间配额,能使得本轮次的总 SLO attainment 达到 $\frac{1}{\alpha}$。
    • $\alpha$ 是一个控制参数,确保配额不会无限大($\alpha \ge 0.5$),它代表了系统在满足 SLO 和实现多路复用之间的权衡。
  • c. 轮转执行
    • 实例按照工作列表的顺序,执行每个批次。
    • 对于批次 $i$,它连续解码其请求,持续时间精确为 $q_i$
    • 时间配额用完后,实例立即切换到下一个批次。
    • 一个轮次结束后,开启新一轮次,重新计算配额并继续。

通过这一设计,每个请求所在的批次在每个轮次都能获得执行时间,确保所有请求都有进展,不会饥饿。通过短暂的、受控的停顿去服务其他请求,系统巧妙地利用了用户阅读时间作为缓冲。只要整个轮次的总时间不至于导致输出中断,用户体验就是流畅的。同时,配额公式直接与 TBT 目标 $d$ 挂钩,使得调度决策是 SLO-aware 的。系统会自动为解码速度快的请求分配更多资源,从而在整体上最大化 SLO 达成率。

Efficient Preemptive Auto-Scaling

在确定了调度策略之后,一个关键的问题就是如何实现高效的抢占调度操作。对于正常情况下的模型切换主要包含下图的阶段

Scale-Down-Up.png

  1. 旧实例保存KV Cache卸载到主机内存
  2. 传输Garbage Collection
  3. 重新初始化新的实例
  4. 加载新实例的KV Cache
  5. 开始推理

Aegaeon的优化主要集中在三方面

  1. 初始化分解与成分重用
  2. 显式内存管理
  3. 细粒度KV Cache同步
一、初始化分解与成分重用

当从一个模型(Model A)切换到另一个模型(Model B)时,传统的推理引擎(如 vLLM)需要执行一个上述完整的切换流程,时间开销十分巨大。Aegaeon发现,推理引擎的许多组件是模型无关的,其初始化开销可以在多次模型切换中被摊销,因此设计提出了组件重用。

具体做法:

  • 一次性初始化:在每个 GPU 实例启动时,Aegaeon 就完成所有模型无关组件的初始化,包括分布式执行器、性能剖析结果、调度器、tokenizer 等,并将它们缓存起来。
  • 模型特定数据动态切换:在自动扩展时,只动态替换模型特定的部分,即模型权重和KV Cache
  • 预分配内存池:预先分配好用于存储 KV Cache 的主机内存池,避免了每次缩放时临时锁定内存页的开销。
二、显式内存管理

在频繁的模型加载/卸载过程中,传统的张量库(如 PyTorch)内存分配器会产生严重的内存碎片化,无论是在 GPU 显存还是主机内存中。

  • GPU 显存碎片:导致即使总空闲内存足够,也无法成功分配一块连续的较大内存给新模型,从而触发耗时的垃圾回收和整理操作。
  • 主机内存碎片:影响模型权重的缓存效率(因为需要连续空间来存放大模型文件)。不同模型的 KV Cache 形状不同,如果为每种形状预分配固定块,会造成巨大浪费;如果动态分配,则会产生碎片。

Aegaeon采用统一管理的方式来处理VRAM和DRAM的碎片化问题。

  • 自管理的 VRAM 缓冲区

    • 启动时,一次性向系统申请绝大部分所需的 GPU 显存,形成一个大的、连续的内存池。
    • 在这个池内使用 指针递增分配。分配时简单地移动指针;释放时(模型切换后)可以直接将指针重置到起始位置,实现瞬时释放
    • 通过“Monkey-Patch”技术,让模型权重的分配指向这个自管理缓冲区,绕过了 PyTorch 默认的分配器,从而避免出发Garbage Collection。
  • 快速模型加载

    • Model Cache:在主机内存中维护一个共享区域,缓存所有模型的原始权重张量块。
    • Stage Buffer:为每个 GPU 分配一个页锁定的主机内存缓冲区,专门用于在主机和 GPU 之间采用多线程+分块+流水线的方式传输模型权重数据,最大化 PCIe 带宽利用率。
    • 预取:在VRAM容量充足时,根据调度器的决策,提前在后台将下一个可能需要用到的模型权重加载到 GPU 显存中空闲的区域。当实际需要切换时,只需在 GPU 内部进行一次快速的数据拷贝即可,实现了近瞬时的模型加载
  • 统一的 KV Cache(Slab 分配)

    为了解决不同形状 KV Cache 的存储问题,Aegaeon 在主机和 GPU 内存中都采用了 Slab 分配器。通过将内存分块为 Slab,每个 Slab 被分配给一种特定的 KV Cache 形状。当需要为某个形状的 KV Cache 分配空间时,就从对应的 Slab 中取一个空闲块。释放时,将块归还给对应的 Slab。

三、细粒度KV Cache同步

在抢占式扩展中,KV Cache 需要在 GPU 和主机内存之间 swap in/out。为了降低延迟,一般使用多个 CUDA 流来重叠执行这些传输操作与其他计算。然而,这里存在复杂的依赖关系:

  1. Decode操作必须等待其对应的 KV Cache 完全换入后才能开始。
  2. 只有当Prefill intance完成换出,换入操作才能开始
  3. 选择换出的CPU缓存时,不能选择那些正在进行传输的部分。

如果简单地异步执行,会引发数据竞争,导致推理结果错误。传统的粗粒度同步(如阻塞整个流)又会丧失并发性。

Aegaeon采用 CUDA events 来单个单个地追踪传输的状态。当针对请求构建KV Cache的传输时,Aegaeon通过cudaEventRecord来将其记录为一个事件,后续可以使用cudaEventQuery来查询这一传输的状态或者等待此传输的完成cudaStreamWaitEvent,事件也可以通过IPC在实例之间进行传递以进行同步。

重叠KV Cache换入

  1. Decode实例在换入 KV Cache 前,通过 cudaStreamWaitEvent 命令,让它的“换入流”等待prefill实例的“换出 Event”。
  2. 在批量解码时,Aegaeon 会查询每个请求的 KV Cache 换入状态,只要有一个请求准备就绪,就可以立即开始解码,而不必等待整个批次。

重叠KV cache换出

换出操作被完全解耦到后台异步执行。引入 Move List 来追踪那些正在被换出到主机的内存块。当分配新的主机内存块时,会避开移动列表中的块,从而自动遵守依赖关系。一个后台daemon线程会定期查询这些换出操作的 Event,换出完成后将对应的块从移动列表中移除,使其可用。