支持私有化部署
AI知识库

53AI知识库

学习大模型的前沿技术与行业应用场景


vllm近期更新的一些trick总结

发布日期:2025-04-06 19:15:58 浏览次数: 1573 作者:许同学说
推荐语

vLLM V1性能优化与用户体验的全面解析。

核心内容:
1. vLLM V1重大版本的核心架构升级与性能对比
2. 用户实际体验与vLLM性能表现的差异分析
3. vLLM在集群式推理架构上的新扩展方向

杨芳贤
53A创始人/腾讯云(TVP)最具价值专家

引言

本文是想总结近期vllm更新的一些trick,文章主要从三个方面来介绍该内容,首先是vllm发布重大版本v1的一些性能优化点和对比数据,看起来效果是卓越的,然后是买家秀,从用户的实际体验上看,效果似乎一言难尽,最后是vllm的扩展,不再局限于单机能力,vllm对集群式的推理架构又有了新的方向。

vllm v1介绍

在今年的1月27日,vLLM团队宣布了vLLM V1的alpha版本发布,这是对其核心架构的一次重大升级。基于过去一年半的开发经验,团队重新审视了关键设计决策,整合了多项功能,并简化了代码库,以提升灵活性和可扩展性,同时也是因为这段时间内在适配各种模型而形成的技术债务积累,到不得不新开一个版本,为了提供一个简单、模块化且易于修改的代码库,对原有架构做了非常多的重构设计。

unsetunsetCPU overhead 优化unsetunset

在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下来后的asyncllminput processing,到process 1后的engineCoreForward执行流程为:

  1. 进程0主要负责前后处理和流式传输,通过input_socket将请求发送给进程1。
  2. 进程1在接收到请求后将其放入输入队列中。
  3. engine_core的主循环会不断从输入队列中取出请求,处理完成后将其放入输出队列中。
  4. 进程1再从输出队列中取结果通过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 10???...???: the block isnot written yet, cannot read, can write
        (case 21000...000: the block is just written, can read, cannot write
        (case 31???...???: the block is written and read by some readers, can read ifnot read, cannot write
        (case 41111...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 0and 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

该方案基于的策略可以总结成:

  1. 数据大小判断 :

  • 小数据(< max_chunk_bytes):使用共享内存传输
  • 大数据(>= max_chunk_bytes):使用ZMQ套接字传输
  • 溢出标记 :

    • 共享内存的第一个字节作为溢出标记(overflow flag)
    • 当数据太大时,设置溢出标记为1,告知读取器从ZMQ读取数据
  • 读写同步 :

    • 共享内存使用元数据区域跟踪每个块的读写状态
    • 写入器等待所有读取器完成读取后才能重用内存块
    • 读取器等待写入器完成写入后才能读取内存块
  • 本地/远程区分 :

    • 本地进程(同一节点):优先使用共享内存,必要时回退到ZMQ
    • 远程进程(不同节点):只能使用ZMQ通信

    看起来是充分利用到了zmq和共享内存的优势,进一步来说,vllm提供了它的测试用例,在tests目录下,具体OK不OK,能跑跑看看效果。

    unsetunset调度器重构unsetunset

    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+,整体的流程优化为:

    1. 前缀缓存:重用相同前缀的 KV cache,提高吞吐量
    2. 推测解码:预测可能的输出 token,减少推理次数
    3. 资源抢占:在资源不足时抢占低优先级请求
    4. 分块预填充:对长文本进行分块处理,避免阻塞其他请求
    5. 编码器缓存:缓存多模态输入的编码结果,避免重复计算

    unsetunset其它与性能unsetunset

    除了上面两个性能重大更新以外,还有分段 CUDA graphs ,以及TP架构更新(Tensor-Parallel),整体架构调整等等,因为我理解得不深,具体可以看vllm官方的博文,以及我在最后引出的知乎贴,这里不再概述。根据官方的说法,vLLM V1 相比于 V0,吞吐量提高了 1.7 倍。用H100的测试结果为:

    • 文本模型:Llama 3.1 8B 和 Llama 3.3 70B
    • 视觉语言模型:Qwen2-VL

    unsetunsetv1 使用unsetunset

    对于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 v1 真的效果好嘛?

    我虽然对于vllm 8.0以上暂时还没用过,不过据最新的《AI Mathematical Olympiad - Progress Prize 2》中,已经有非常多的测试结果了。首先来看一组数据,nvidia L4显卡,是2023年作为对标消费级4090推出的显卡,具体参数对比如下:

    模块NVIDIA L4GeForce RTX 4090
    硬件架构
    Ada Lovelace 架构,7680 CUDA 核心,240 Tensor 核心
    Ada Lovelace 架构,16384 CUDA 核心,512 Tensor 核心
    显存配置
    24GB GDDR6,768GB/s 带宽
    24GB GDDR6X,1008GB/s 带宽
    互联能力
    ✔️ NVLink 4.0(400GB/s)
    ❌ 仅 PCIe 5.0(128GB/s)
    功耗效率
    150W TDP,72W 典型功耗
    450W TDP,450W 典型功耗?
    核心场景
    云推理、视频处理、虚拟桌面(VDI)
    游戏、内容创作、本地 AI 训练

    本节后续大部分未标明的数据,都出自L4卡上。

    unsetunsetvllm 加速包unsetunset

    正如上节的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'
    • VLLM_ATTENTION_BACKEND:该环境变量用于指定vLLM使用的注意力计算后端。如果设置为“FLASHINFER”,则vLLM将使用FlashInfer作为其注意力计算的后端。这通常可以提高性能,尤其是在处理长文本生成任务时。
    • VLLM_USE_FLASHINFER_SAMPLER:当设置为“1”时,vLLM将使用FlashInfer的采样器。这可以提高采样性能,尤其是在需要进行top-p和top-k采样的情况下
    • VLLM_FLASHINFER_FORCE_TENSOR_CORES:当设置为“1”时,强制FlashInfer使用Tensor Cores进行计算。这通常可以提高计算效率,但需要确保你的GPU支持Tensor Cores。

    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。

    unsetunset推理提前停止unsetunset

    标题的意思是想做一种带有动态提前停止机制批量文本生成系统,即在有限时间内,根据自定义的规则,来将大模型推理效率最大化,而在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),为开发者提供了实现复杂动态提前停止策略的基础能力

    unsetunsetvllm benchmarkunsetunset

    从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的对比:

    FrameworkTime Taken (s)
    vLLM V0 (0.7.3)456
    vLLM V1 (0.7.3)438
    SGLang390

    上述是评估 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 production stack

    本节是vllm在集群上的扩展,在此之前,vllm主要方案为AIBrix,它主要由golang进行编写,整个架构看起来非常复杂,而production stack的推出,主要解决三个方面:

    • ? 从单个 vLLM 实例扩展到分布式 vLLM 部署,无需更改任何应用程序代码
    • ? 通过网络仪表板进行监控
    • ? 享受请求路由和 KV 缓存卸载带来的性能优势

    它目前是完全以python语言为主,核心代码占据了70%,架构非常清晰明了:

    其中Grafana的接入提供非常多的指标:

    1. 可用的 vLLM 实例:显示健康实例的数量。
    2. 请求延迟分布:可视化端到端请求延迟。
    3. 首次令牌时间 (TTFT) 分布:监控令牌生成的响应时间。
    4. 正在运行的请求数:跟踪每个实例的活动请求数。
    5. 待处理请求数:跟踪等待处理的请求。
    6. GPU KV 使用百分比:监控 GPU KV 缓存使用情况。
    7. GPU KV 缓存命中率:显示 GPU KV 缓存的命中率。

    而对比AIBrix 项目,开发者认为主要有4点不同,分别是:

    • 界面设计:Production Stack 是一个开放、可扩展的框架,有意为社区贡献和创新留出空间。我们的目标是保持界面的灵活性,以便将来支持更多的存储和计算设备。
    • 开发人员友好性:在 Production Stack 中,操作员可以直接用 Python 编写 LLM 服务逻辑,从长远来看可以进行更多优化,而且在 5 分钟内即可轻松设置。AIBrix 更重,需要从 Go 进行修改。此外,AIBrix 针对 Kubernetes 原生实现,但 Production stack 保留了两种选项,并开放社区讨论在 Kubernetes 或 Router/Proxy/vllm 中实现哪些逻辑。
    • vLLM 支持: Production Stack 支持最新的 vLLM 版本,并将通过利用 vLLM 上游连接器继续贡献和支持最新的 vLLM。AIBrix 修改了 vLLM 0.6.1 以用于 KV 缓存操作。
    • KV 缓存性能: Production Stack 具有先进的 KV 缓存优化——高效的 KV 传输和混合——利用我们的 LMCache 项目,特别适用于长上下文推理。AIBrix 使用 PyTorch 复制并依靠 Vineyard 来管理 CPU 上的 KV 缓存。

    从我个人来看,毫无疑问,我会赞同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+中大型企业

联系我们

售前咨询
186 6662 7370
预约演示
185 8882 0121

微信扫码

添加专属顾问

回到顶部

加载中...

扫码咨询