微信扫码
添加专属顾问
我要投稿
vLLM V1性能优化与用户体验的全面解析。 核心内容: 1. vLLM V1重大版本的核心架构升级与性能对比 2. 用户实际体验与vLLM性能表现的差异分析 3. vLLM在集群式推理架构上的新扩展方向
本文是想总结近期vllm更新的一些trick,文章主要从三个方面来介绍该内容,首先是vllm发布重大版本v1的一些性能优化点和对比数据,看起来效果是卓越的,然后是买家秀,从用户的实际体验上看,效果似乎一言难尽,最后是vllm的扩展,不再局限于单机能力,vllm对集群式的推理架构又有了新的方向。
在今年的1月27日,vLLM团队宣布了vLLM V1的alpha版本发布,这是对其核心架构的一次重大升级。基于过去一年半的开发经验,团队重新审视了关键设计决策,整合了多项功能,并简化了代码库,以提升灵活性和可扩展性,同时也是因为这段时间内在适配各种模型而形成的技术债务积累,到不得不新开一个版本,为了提供一个简单、模块化且易于修改的代码库,对原有架构做了非常多的重构设计。
在vllm官方《vLLM v0.6.0: 2.7x Throughput Improvement and 5x Latency Reduction》的blog中,就介绍了vllm 6.0对于性能增强的核心内容是将 API 服务器和推理引擎分离到不同的进程:
通过将 http 服务组件与 vLLM 引擎分离,并使用 ZMQ socket将它们连接起来。这种架构确保两个 CPU 密集型组件彼此隔离,同时通过一次批处理多个调度步骤,我们让 GPU 比以前更忙碌,从而减少延迟并提高吞吐量:
而在《vLLM V1: A Major Upgrade to vLLM's Core Architecture》一文中,针对之前拆解两个进程后的引擎中处理请求的方式以及与 http 请求交互的方式方面调度与细化上又做了全面的更新,如下图所示:
vLLM V1 通过将多处理架构更深入地集成到 AsyncLLM 的核心中来扩展此功能,从而创建一个EngineCore
专注于调度程序和模型执行器的隔离执行循环。这种设计允许 CPU 密集型任务(例如tokenization, multimodal input processing, de-tokenization, and request streaming)与核心执行循环有更大的重叠,从而最大限度地提高模型吞吐量。
上图中,从API server
下来后的asyncllm
为input processing,到process 1
后的engineCore
为Forward,执行流程为:
input_socket
将请求发送给进程1。engine_core
的主循环会不断从输入队列中取出请求,处理完成后将其放入输出队列中。output_socket
将其发送回进程0,完成后处理后即可返回用户。这使得该过程变为:
这里我比较感兴趣的点在于如何使用的进程通信,我之前有整理过关于 python进程通信方式总结(三):共享内存 的方案,而vllm的trick更加让我眼前一亮,它将zmq与shared memory融合,基于以下规则,创造了一种新的机制,如下所示:
Buffer memory layout:
data metadata
| |
| (current_idx) | (current_idx)
v v
+-------------------------------+----------------------------------------+
| chunk0 | chunk1 | ... | chunk | metadata0 | metadata1 | ... | metadata |
+-------------------------------+----------------------------------------+
| max_chunks x max_chunk_bytes | max_chunks x (1 + n_reader) bytes |
metadata memory layout: each byte is a flag, the first byte is the written
flag, and the rest are reader flags. The flags are set to 0 by default.
+--------------+--------------+--------------+-----+--------------+
| written_flag | reader0_flag | reader1_flag | ... | readerN_flag |
+--------------+--------------+--------------+-----+--------------+
The state of metadata isas follows:
(case 1) 0???...???: the block isnot written yet, cannot read, can write
(case 2) 1000...000: the block is just written, can read, cannot write
(case 3) 1???...???: the block is written and read by some readers, can read ifnot read, cannot write
(case 4) 1111...111: the block is written and read by all readers, cannot read, can write
State transition for readers:
When a reader finds a block that it can read (case 2or3), it can yield the block for caller to read.
Only after the caller finishes reading the block, the reader can mark the block as read.
Readers only mark the block as read (from0 to 1), the writer marks the block as ready to read (from1 to 0).
State transition for writer:
When the writer writes to a block (case 1or4), it first resets the written flag to 0, converting either case
to case 1. Then it can yield the block for caller to write. After the caller finishes writing the block, the writer
can reset the reader flags to 0, and mark the block as written (from0 to 1).
在这种机制下,数据发送(enqueue)为:
def enqueue(self, obj, timeout: Optional[float] = None):
serialized_obj = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
# 本地读取器处理
if self.n_local_reader > 0:
# 判断数据大小决定使用哪种通信方式
if len(serialized_obj) >= self.buffer.max_chunk_bytes:
# 大数据:标记溢出并使用ZMQ发送
with self.acquire_write(timeout) as buf:
buf[0] = 1# 溢出标记
self.local_socket.send(serialized_obj)
else:
# 小数据:直接使用共享内存
with self.acquire_write(timeout) as buf:
buf[0] = 0# 非溢出
buf[1:len(serialized_obj) + 1] = serialized_obj
# 远程读取器只能使用ZMQ
if self.n_remote_reader > 0:
self.remote_socket.send(serialized_obj)
数据接收(dequeue)为:
def dequeue(self, timeout: Optional[float] = None):
if self._is_local_reader:
# 本地读取器先从共享内存读取
with self.acquire_read(timeout) as buf:
overflow = buf[0] == 1
ifnot overflow:
# 如果数据在共享内存中,直接读取
obj = pickle.loads(buf[1:])
# 如果数据溢出,则从ZMQ读取
if overflow:
recv = self.local_socket.recv()
obj = pickle.loads(recv)
elif self._is_remote_reader:
# 远程读取器只能从ZMQ读取
recv = self.remote_socket.recv()
obj = pickle.loads(recv)
return obj
该方案基于的策略可以总结成:
数据大小判断 :
溢出标记 :
读写同步 :
本地/远程区分 :
看起来是充分利用到了zmq和共享内存的优势,进一步来说,vllm提供了它的测试用例,在tests目录下,具体OK不OK,能跑跑看看效果。
vLLM V1 引入了一个简单而灵活的调度程序。它通过统一处理用户提供的提示标记和模型生成的输出标记,消除了prefill和decode阶段之间的传统区别,使其能表示为一个简单的字典,例如,{request_id: num_tokens}
它指定每个步骤中每个请求要处理的标记数。我们发现这种表示足够通用,可以支持chunked prefills, prefix caching, and speculative decoding等功能。例如,分块预填充调度是无缝实现的:在固定的标记预算下,调度程序动态决定为每个请求分配多少个标记(如下图所示)。
step0把R1、R2的完整prefill token和R3的部分prefill token组batch计算,step1和step2把R1、R2的decode和R3的部分prefill token 组batch计算,step3把R1、R2和R3的detoken部分组batch计算,这样每次计算的token可能是不同request的prefill阶段和decode阶段组合。
而由于不需要考虑prefill和decode,整个调度器的代码比v0少了几个数量级,从2k+行代码变为了700+,整体的流程优化为:
除了上面两个性能重大更新以外,还有分段 CUDA graphs
,以及TP架构更新(Tensor-Parallel
),整体架构调整等等,因为我理解得不深,具体可以看vllm官方的博文,以及我在最后引出的知乎贴,这里不再概述。根据官方的说法,vLLM V1 相比于 V0,吞吐量提高了 1.7 倍。用H100的测试结果为:
对于vllm 0.7.0到vllm 0.8.0的版本,如果想要开启v1架构,需要在开启vllm之前在环境变量中进行引入VLLM_USE_V1
:
os.environ["VLLM_USE_V1"] = 1
os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["TRITON_PTXAS_PATH"] = "/usr/local/cuda/bin/ptxas"
然后其余的都正常服务启动:
python3 -m vllm.entrypoints.openai.api_server \
--model=/workspace/dev/hf_models/DeepSeek-R1 \
--dtype=auto \
--block-size 32 \
--tokenizer-mode=slow \
--max-model-len 32768 \
--max-num-batched-tokens 2048 \
--tensor-parallel-size 8 \
--pipeline-parallel-size 3 \
--gpu-memory-utilization 0.90 \
--max-num-seqs 48 \
--trust-remote-code \
--no-enable-prefix-caching \
--enable-chunked-prefill=True \
--disable-custom-all-reduce \
--port 8862
而在vllm 8.0以上版本中,v1架构被默认开启,如果想要关闭,同样,提前在启动前对环境变量进行导入:
export VLLM_USE_V1=0
看起来非常简单方便,以用户角度视角看,基本没有改动,但我看了一圈issue和评测后,不禁想问,它真的好嘛?
我虽然对于vllm 8.0以上暂时还没用过,不过据最新的《AI Mathematical Olympiad - Progress Prize 2》中,已经有非常多的测试结果了。首先来看一组数据,nvidia L4显卡,是2023年作为对标消费级4090推出的显卡,具体参数对比如下:
模块 | NVIDIA L4 | GeForce RTX 4090 |
---|---|---|
硬件架构 | ||
显存配置 | ||
互联能力 | ||
功耗效率 | ||
核心场景 |
本节后续大部分未标明的数据,都出自L4卡上。
正如上节的v1使用中,所使用的环境变量,可通过简单设置进行开启:
os.environ["OMP_NUM_THREADS"] = str(str(int(psutil.cpu_count(logical=False) / 2))) #"12"
os.environ["CUDA_VISIBLE_DEVICES"] = "0, 1, 2, 3"
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["TRITON_PTXAS_PATH"] = "/usr/local/cuda/bin/ptxas"
os.environ["TOKENIZERS_PARALLELISM"] = "false"
最后三行分别对tokenizers
库的并行处理、vLLM库的多进程启动方法以及Triton
库的ptxas
编译器路径进行了设置,其中spawn
应该都很熟悉,即vllm在启动工作进程时会采用"spawn"
执行。
另外还有一个很重要的加速库:
# Force FlashInfer
os.environ["VLLM_ATTENTION_BACKEND"]='FLASHINFER'
os.environ["VLLM_USE_FLASHINFER_SAMPLER"]='1'
os.environ["VLLM_FLASHINFER_FORCE_TENSOR_CORES"]='1'
FLASHINFER库的重要性不用多说,vllm能有几倍到十几倍的加速效果跟其有很大关系,但现在出现了一个问题,就是当vllm转v1架构后,似乎它们无法兼容,在8.0版本左右,vllm直接报错为:
NotImplementedError: VLLM_USE_V1=1 is not supported with VLLM_ATTENTION_BACKEND=FLASHINFER.
如果遇到,解决方案是退FLASHINFER的版本,能安装成功的方式为:
pip install vllm bitsandbytes flashinfer-python==0.2.2.post1
那么到此,看起来v1架构已经和v0不存在其它的显著差异了。以下将进入数据对比,但对比之前,还插入一个8.2版本的我认为很有意思的trick。
标题的意思是想做一种带有动态提前停止机制的批量文本生成系统,即在有限时间内,根据自定义的规则,来将大模型推理效率最大化,而在vllm 8.2版本上,该策略是可行的:
# Add each prompt as a request to the engine.
request_ids = []
num_input_tokens = {}
current_request_ids = set()
for i, text in enumerate(list_of_texts):
random_request_id = str(uuid.uuid4())
req_id = f"req_{i}_{random_request_id}"
_ = engine.add_request(request_id=req_id, prompt=text, params=sampling_params)
current_request_ids.add(req_id)
request_ids.append(req_id)
finished_requests = set()
outputs_dict = {}
definitive_answers = {}
# If no threshold provided, wait for all responses.
if threshold isNone:
threshold = 1.0#len(list_of_messages)
start_gen_time = time.time()
# Step through token generation until threshold finished responses are collected.
whileTrue:
# print(engine.is_sleeping())
request_outputs = engine.step()
for output in request_outputs:
if output.finished and (output.request_id notin finished_requests) and (output.request_id in current_request_ids):
finished_requests.add(output.request_id)
output_text = output.outputs[0].text
# print(output_text[-150:])
# print("###############")
# print(output_text)
q_answer, points = extract_answer(output_text)
# lets recalibrate points:
if q_answer in num_in_q:
# Let's reduce number of points
print("reducing points count")
points *= 0.7
if q_answer isnotNone:
if definitive_answers.get(q_answer) isnotNone:
definitive_answers[q_answer] += points - 1 / len(output.outputs[0].token_ids)
else:
definitive_answers[q_answer] = points - 1 / len(output.outputs[0].token_ids)
print(output.request_id)
print(definitive_answers)
outputs_dict[output.request_id] = len(output.outputs[0].token_ids)
# if len(finished_requests) >= int(threshold*len(list_of_texts)):
if len(definitive_answers) > 0:
if round(np.max(list(definitive_answers.values()))) >= min_points: # we have a good chance of having already found the solution
print(f"Threshold reached: {len(finished_requests)} of {len(list_of_messages)} completed responses.")
print("all answers", definitive_answers)
print("num_tokens", outputs_dict)
break
should_early_stop = early_stopping_criterion(finished_requests, definitive_answers, len(current_request_ids))
if should_early_stop:
break
# if out of time -> stop
if time.time() - start_gen_time > 60*MAX_TIME_PER_QUESTION:
print("Out of time! Let's take a decision!")
break
print("finished_requests", finished_requests)
# Abort remaining unfinished requests.
for req_id in request_ids:
if req_id notin finished_requests:
engine.abort_request(req_id)
上述代码实现了一套奖励规则,通过强制模型生成<think>...</think>
结构,并使用extract_answer
函数从中提取答案和置信度分数,系统可以在收集到足够证据表明某个答案是最佳答案时,提前终止所有生成任务,从而节省计算资源和时间,而vLLM 通过其异步处理模型、逐步生成 (engine.step
)、完成状态检测 (output.finished
) 以及强制中止接口 (engine.abort_request
),为开发者提供了实现复杂动态提前停止策略的基础能力。
从v1 发布截至目前为止才两个多月,时间周期太短,在0.7.3到0.8.2的几个版本中,v1 的内存计算和张量并行似乎被认为存在问题,它启动所需要的显存变得更多,而不得不将gpu_memory_utilization
改为0.9来避免OOM,官方也基于此在0.8.2修复了该内存泄漏的bug,但启动时长看起来明显要比0.7.2多出一节,初始化时间如下:
v0.7.3
INFO 03-24 05:57:17 core.py:116] init engine (profile, create kv cache, warmup model) took 169.77 seconds
v0.8.1
INFO 03-25 12:40:30 [core.py:138] init engine (profile, create kv cache, warmup model) took 200.46 seconds
v0.8.2
INFO 03-25 23:27:22 [core.py:151] init engine (profile, create kv cache, warmup model) took 239.56 seconds
不过这都不是问题,都知道vllm会有kv cache,主要关注的是后续的inference是否变快,但从目前来看,效果不是说没有,在以L4类型的卡上面,可能还是负提升。
相比于官方发布的性能报告,在以H100型号以上的显卡提升明显,而H100以下用户实际体验的反馈是,从v0.7.3升级到0.8.2,性能/速度似乎没有显著差异,这里看一组针对v0.7.3的对比:
Framework | Time Taken (s) |
---|---|
vLLM V0 (0.7.3) | 456 |
vLLM V1 (0.7.3) | 438 |
SGLang | 390 |
上述是评估 vLLM 在 4 块 NVIDIA L4 GPU 上运行一个经过 AWQ 量化的 14B 参数大语言模型时的表现。测试模拟了 8 个并发请求,每个请求的最大长度可达 14,000 token,可以看到,多卡推理上,vllm v1有所提升,但依然没有超过sglang,再考虑还有很多量化格式要重新适配,我只能说,未来可期。
当然,issue上还有很多测试结果,比如说在A100上使用 Qwen2.5-7B-Instruct比较 vllm v1 v0.7.3 和 v0.8.2并做了可视化:
上述是我从很多测试图中挑选的相对正常的结果,其输入是1024,输出是512,其它看起来不太正常的,官方已经定位到问题,并抬高了优先级,之后应该会有更详细的说明了。
本节是vllm在集群上的扩展,在此之前,vllm主要方案为AIBrix
,它主要由golang进行编写,整个架构看起来非常复杂,而production stack
的推出,主要解决三个方面:
它目前是完全以python语言为主,核心代码占据了70%,架构非常清晰明了:
其中Grafana的接入提供非常多的指标:
而对比AIBrix
项目,开发者认为主要有4点不同,分别是:
从我个人来看,毫无疑问,我会赞同vllm Production stack
的理念,并尝试使用,而该项目部署也挺简单,使用 helm chart 通过运行单个命令就能将 vLLM Production stack部署到k8s 集群:
sudo helm repo add llmstack-repo https://lmcache.github.io/helm/ &&\
sudo helm install llmstack llmstack-repo/vllm-stack
官方blog中,测试结果如下:
但并不知晓它是在什么环境上做的benchmark,感兴趣或者有条件的朋友可以进一步测试。
53AI,企业落地大模型首选服务商
产品:场景落地咨询+大模型应用平台+行业解决方案
承诺:免费场景POC验证,效果验证后签署服务协议。零风险落地应用大模型,已交付160+中大型企业
2025-04-07
GitHub开源最强MCP客户端指南!手把手教你玩转AI交互!
2025-04-07
斯坦福团队开源!OpenVLA:小白也能搞机器人,100条数据就能微调!
2025-04-07
9000 字详细解读阿里万象 2.1(Wan2.1)最新技术报告
2025-04-07
实测Llama 4,究竟是王者归来,还是廉颇老矣?
2025-04-07
【AI启示录】2025 w14:文档集 + 规则库 + 循环迭代 = 好的氛围编程
2025-04-06
Meta Llama 4 全面解析:全新的原生多模态 AI
2025-04-06
字节跳动开源神器Agent TARS,AI自动化时代真来了
2025-04-06
一文读懂开源 Llama 4 模型
2025-01-01
2024-07-25
2025-01-21
2024-05-06
2024-09-20
2024-07-20
2024-06-12
2024-07-11
2024-08-13
2024-12-26
2025-04-07
2025-04-03
2025-04-03
2025-04-03
2025-04-01
2025-03-31
2025-03-25
2025-03-25