未来何时到来,取决于我们能以多快的速度生成 token。
随着GenAI的发展,我们迎来了一个崭新的技术时代。然而,由于LLM庞大的参数规模,在现代的AI系统中,LLM的推理 (inference) 性能就成为一个格外重要的技术问题。提升LLM推理的性能,更快地生成token,同时也意味着运营成本的降低。
在本文中,围绕LLM推理服务的性能问题,我们将从以下几个方面展开讨论:
我们应该关心哪些性能指标?这是系统设计中一个非常重要的问题。而且,这不是一个新问题。想想跑在互联网上的那些应用系统,诸如搜索引擎、Feeds流、电商交易系统,我们当时是怎么描述系统的性能的?
很多人会想到QPS (queries per second) 或 TPS (transactions per second)。没错,它们表达了系统的一个重要的性能指标,称为吞吐量 (Throughput)。QPS或TPS都是系统吞吐量的度量单位,表达了单位时间内系统所能处理的请求数。也可以用requests per second(每秒请求数)来表示吞吐量。
吞吐量为什么重要?因为它表达了系统整体的处理能力。吞吐量越高,系统就可以用更少的资源来处理同样的请求,也就意味着更低的单位成本。
然而,我们只考虑吞吐量够用吗?答案是否定的。现代的系统很多都是在线系统 (online serving),不仅要求单位时间内尽量服务尽可能多的请求(用户),也要求单个请求的响应时间 (Response Time) 越短越好。于是,我们得到另外一个性能指标——响应时间。
通常来说,一个系统在满负载的情况下,它的吞吐量越高,请求的平均响应时间也越短。你可能会问:两者是不是倒数的关系?比如,1秒钟处理了10个请求,也就是说吞吐量是10 requests/s,那么平均每个请求的响应时间是不是1/10 = 0.1s呢?
不完全是。如果系统完全是串行执行的,前一个请求处理完才能处理下一个请求,那么响应时间确实是0.1s。但是,现代系统都有一定的并行执行能力,这就让情况不同了。假设一个系统内部有10个并行的、独立的、同构的 (parallel, independent, homogeneous) 的服务通道 (service channel),那么10个请求可以并行执行,每个请求都执行1s,也可以在1s内将10个请求都执行完。这样算下来的话,系统的吞吐量是10 requests/s,而平均响应时间则是1s。这也是Cary Millsap在十几年前的一篇经典blog[1]中举的一个例子。因此,从吞吐量不能推导出响应时间。
在接下来的讨论中,我们会看到,服务通道是其中一个重要的内部变量。不过现在,我们暂时先把关注点放在系统外部。通常来说,鉴于系统的复杂性,我们需要同时使用吞吐量和响应时间来度量一个系统。大体上可以这样理解:
现在,我们就来看看现代的LLM推理系统,情况有没有变化。当然,有些东西没有变。我们仍然应该关注吞吐量和响应时间,它们对于系统性能的描述能力,跟什么类型的系统无关,跟技术的新旧也无关。
但是,毕竟LLM推理系统也有一些不一样的地方。最大的一个不同在于,LLM生成的是一个长的sequence,一个token一个token的流式输出。这是由Decoder-Only的语言模型架构所决定的,它自回归式的 (auto-regressive) 生成方式正是如此。这也意味着,LLM推理系统对于请求的响应,存在一个显著的持续时间(若干秒、十几秒,甚至几十秒)。
在我们前面的分析中,那些互联网时代的「旧系统」,请求的响应时间通常是非常短的,以毫秒计。因此我们以request作为吞吐量和响应时间的基本计量单位。切换到LLM推理系统,一个请求本身包含很多token,同时也会生成很多token。我们仍然可以以 requests/s 来表示吞吐量,但业界通常换算到更细的粒度,也就是token的粒度,就得到了大家常说的 tokens/s。
那么,响应时间怎么表示呢?仍然是换算成token粒度,且业界常用的词汇是延迟 (Latency)。比如,在PagedAttention的论文中[2],作者使用了 Normalized Latency这个度量,它定义为:每个请求的端到端的延迟(也就是系统从收到一个请求直到最后一个token生成完毕的持续时间)除以生成的token数,再对于所有请求计算平均值。它的度量单位是s/token。
前面我们说过,响应时间跟用户体验有关。因此,判断响应时间的度量单位是否合理,也应该从用户体验的角度来考虑。对于一个典型的LLM应用来说,通常第一个token的生成延迟(系统从收到一个请求直到第一个token生成完毕的持续时间)会比较高,远大于相邻token之间的生成延迟。而第一个token何时生成,是一个比较重要的用户体验。所以呢,我建议把首token的生成延迟也作为系统响应时间的另外一个度量。
总结一下,对于LLM的推理系统来说,我们需要使用至少三个性能指标来对它进行度量:
前面我们讨论了性能指标。这相当于是说,我们从外部观察系统,可以得到一些度量的数值。然后我们用这些数值来描述系统的性能表现。
这非常有用。这些性能指标可以揭示系统的现状和问题。但是,如果我们想进一步分析问题根源,找出优化的方向,则需要对系统运行的内部机制进行建模 (modeling) 。「建模」是一个由具体到抽象、由现实到理论的过程,需要从逻辑层面对事物的运行机制进行描述。这里可以参见我之前的一篇文章《谈谈业务开发中的抽象思维》,其中谈到的抽象思维的第二个阶段,就是一个「建模」的过程。
理想情况下,我们可以借助排队论 (Queueing Theory) 中的「M/M/m」队列[3]来表示一个在线系统。如下图:
显然,这是一个「理论模型」,对实际的系统进行了抽象和简化。它有严格的计算公式,大写的M表示马尔科夫过程 (Markov) ,小写的m表示m个服务通道。但我不打算在这里讨论数学上的细节,而是举个生活中的例子来做类比说明。
假设我们去银行营业厅办理业务,银行开设了多个服务窗口来服务客户。我们可以把这些银行窗口的整个服务过程近似看作一个「M/M/m」系统:
现在重新回到计算机系统,我们来做一个对比:
在以上的描述中,我们反复提到了一些概念,比如吞吐量、响应时间、工作负载、算力、服务通道、系统容量、服务时间、排队延迟,等等。这些概念之间是什么关系呢?它们是否是同一个逻辑层面的概念?我们把这些概念画在一个图中:
我们来解释一下上图:
再着重补充说明一下工作负载的概念。对于固定的某个系统来说,它的吞吐量和响应时间,随着工作负载的高低变化而变化。由于系统有一个固有的系统容量,所以根据工作负载的高低,我们在对系统性能进行度量时,经常需要区分两种情况:
上图出自blog[1],表达了一个「M/M/m」系统的响应时间随工作负载的变化曲线。在这个图中,x轴的资源利用率 (utilization) 是工作负载的一种度量方式。可以看出,这个曲线也明显呈现出了两个阶段:
两个阶段的交界点,就是系统的拐点 (knee),也就是图中ρ*的位置。
对于任何并发在线系统来说,这个曲线画出来,形状也都是类似的。比如,在PagedAttention的论文中[2],推理系统的响应时间随工作负载的变化曲线如下:
注意在这个图中,x轴用req/s (每秒请求数) 来表示工作负载;y轴的Normalized Latency是响应时间的一个度量方式。
当然,我们也可以画出系统的吞吐量随工作负载的变化曲线。容易想象出曲线的形状:
到现在为止,从性能表征的角度对系统的运行机制进行建模,这个任务就基本完成了。哪些是系统的固有属性,哪些是外在因素,外在因素和固有属性之间的相互作用关系如何,也都基本清楚了。不过,如果我们把关注点放在系统内部的细节上,会发现,还有一个关键的因素被遗漏了。这个关键因素被称为相关性 (Coherency) [1]。
在理想的「M/M/m」系统中,多个服务通道之间是完全独立的。但在真实的系统中,服务通道之间不可能独立,它们肯定是有相关性的。相关性通常表达了不同请求之间对于共享资源的竞争关系。比如,在传统的互联网应用系统中,不同的请求经常会访问同样的一份数据,对于共享资源的访问会导致额外的相关性延迟 (coherency delay)。这是分布式系统设计的核心挑战之一。特别是当工作负载越过系统拐点之后,相关性延迟通常会非常显著地表现出来。
考虑到相关性和拐点这两个重要概念之后,前面的概念图在修改之后就变成了:
我们基本上已经得到了对于系统性能进行分析的逻辑框架(建模的结果)。我们重新概括总结一下:
以上逻辑框架是抽象的。之所以总结这样一个逻辑框架,其实有三个目标:
本节我们以vLLM[4](一个高性能的大模型推理引擎)为例来具体分析这三个目标。
首先来看度量的问题。
我们已经在第一小节得到了针对LLM推理系统的三个性能指标:吞吐量(每秒生成的token数)、首token的生成延迟、Normalized Latency。考虑到工作负载(每秒请求数)对于系统性能的影响,为了全面刻画系统的性能表现,我们可以画出三类性能曲线:
再来看使用的问题。
我们希望系统承载尽可能多的工作负载,这样才能达到最低的单位成本。但是,工作负载增加到一定程度,延迟就会大幅增加。那么,给系统施加多少工作负载才是最优的呢?答案是,让工作负载处于接近拐点的位置。这时候,系统的吞吐量接近最高值,而延迟也还没有大幅增加。
当流量增加,导致工作负载越过了拐点,就应该进行系统扩容了。通过增加更多的计算节点,来让单个节点的工作负载降下来,直到降低到拐点以下。
最后,以vLLM为例,来说说优化的问题。这个问题稍微复杂一些。
vLLM采用PagedAttention算法[2],对推理性能做了很多优化。根据上一小节的逻辑框架,我们应该从算力、服务通道、相关性这三个维度去理解。
概括起来,vLLM对于推理性能的优化,主要可以归结在两个方面:
具体来看,提升服务通道的数目,是如何做到的呢?
上图出自PagedAttention论文[2],y轴的「# Batched requests」就表示在一个batch中放入的平均请求数,相当于服务通道的数目。
这里我们着重分析一下提高显存利用率的具体做法。为什么vLLM很大程度上是在做显存管理?据PagedAttention的论文[2]所述,GPU计算能力的增长速度,快于显存容量的增长速度。这导致计算能力和显存容量之间的gap越来越大,显存容量逐渐成为了系统瓶颈。因此,vLLM借鉴了操作系统的虚拟内存分页机制,设计了非常精细的显存管理方案,使得整个sequence不必存储于连续的显存空间内。这种方案,加上合适的block大小设置,完全杜绝了外部碎片 (external fragmentation) ,并极大降低了内部碎片 (internal fragmentation) 。最终带来的结果就是,降低了显存浪费,提高了显存利用率。
再看一下另外一个方面,vLLM是如何降低相关性的呢?
依据LLM推理场景工作负载的流量特征,本来是存在一些不利因素,它们是倾向于使相关性增加的:
基于iteration level的调度,将一个sequence的生成,切分成多次iteration来完成。iteration又分为两种计算类型:prefill和decode。prefill的计算粒度可能仍然较大,它一般要求整个prompt的所有token都要在一个iteration中计算完毕。为了缓解这个问题,vLLM还提供了Chunked Prefill模式,允许将大的prefill操作分解成小的chunk。vLLM启动时,可以传入--enable-chunked-prefill参数来打开这种更细粒度的调度模式。
continuous batching技术,是一种高度动态的batch操作。它允许一个sequence在生成完毕后立即可以退出当前batch,从而释放资源,并能够调度新的sequence进入当前batch。这一操作,本质上是让同一个batch内的不同请求不再需要互相等待,从而消除了batch操作带来的相关性延迟。
至此,我们基本上把影响vLLM推理性能的关键因素都分析清楚了。最后,还有两个跟性能高度相关的参数,我们简单看一下:
熟悉我的读者朋友们应该知道,本公众号的目标不仅仅是简单地讨论具体的技术,而是更关注认知层面的总结。因此在文章最后啰嗦几句,算是个小结。
在本文中,我们在抽象层面总结了一个逻辑框架,并结合vLLM的实例进行了具体的分析。通常来讲,具体的知识或具体的技术,在短期内是重要的,在长期看则没有那么重要。技术更新换代的速度正在加快,但总有些fundamental的东西,那些涉及到逻辑层面的本质的东西,是不随着技术变迁而变化的。因此,把相关概念的逻辑关系理清楚,是本文最重要的贡献;具体到vLLM/PagedAttention的分析以及对于三类性能曲线的定义,则是次要的结论。
在技术快速变化的环境中,面对新的框架,新的算法,新的技术变革,只要我们保持工程师的系统化思维,就总是能够从容面对。系统化思维,需要我们在更大范围内做整合,把原来看似不相关的表层概念进行抽象,才能在更高的抽象层面发现相似之处,达到融会贯通。人类知识的联想、迁移,可能性便来源于此。
我之前写过的一些跟认知有关的文章,列出几篇,供感兴趣的读者阅读:
在文本的分析中,我们提到了银行营业厅的例子。它是一个分析并发在线系统的绝佳类比。有时候,世界的本质,就隐藏在看似普通的日常现象之中。在前面的文章《卓越的人和普通的人到底区别在哪?》一文中,我还提到了一个例子,图灵奖得主Lamport当年就是从观察面包店如何服务顾客的现象中,获得了他的顿悟,从而得窥分布式系统的本质,发明了划时代意义的「面包店算法」。
你是不是已经发现了:不同的事物之间,总有那么几分相似呢?
(正文完)
其它精选文章: