找回密码
立即注册
搜索
发回帖 发新帖

5159

积分

0

好友

655

主题
发表于 昨天 23:20 | 查看: 3| 回复: 0

在单机 8 卡 H100 运行 70B 模型的线上集群中,当一个 2000 tokens 的长文档分析请求与 63 个 20 tokens 的短问答被打包进同一个静态批次时,前 20 步迭代之后,整个批次有 63 个槽位在全速生成无意义的填充标记。GPU Tensor Core 利用率从前 20 步的 80% 附近跌落至不足 12%,昂贵的 HBM 带宽被反复搬运无意义的全零 KV Cache 吞噬。长短序列混合引发的算力空转与显存浪费,是自回归模型在服务化部署时必须直面的结构性矛盾。

连续批处理(Continuous Batching / In-Flight Batching)的核心,在于将调度周期从宏观的请求生命周期彻底下沉至单步 Token 前向迭代(Iteration-level)。本文以 TensorRT-LLM 与 vLLM 的工程实践为基准,拆解请求有限状态机、零锁竞争的批装配流水线,剖析 Prefill 与 Decode 混跑引发的硬件争抢,并给出分块预填充(Chunked Prefill)到物理解耦调度的完整工程演进。

静态批处理的时序绑架与算力塌陷

传统深度学习推理框架普遍假定张量具备规则的矩形几何形状。在静态批处理(Static Batching)模式下,批处理调度器(Batch Scheduler)将多个请求组合成固定维度的三维张量 [BatchSize, NumHeads, MaxSeqLen]。这种设计在 CNN 或标准 BERT 编码器中运行良好,但在大语言模型(LLM)自回归解码场景下暴露出严重的物理失配。

自回归生成的输出序列长度由模型在运行时动态输出的终止符(End-of-Sequence, EOS)决定。序列间的生命周期存在巨大的不确定性:短请求可能在第 5 步就已经生成完毕,而同批次的长请求可能需要迭代 2048 步。

由于静态批处理的执行契约绑定在整个批次的最长序列上,已完成的请求槽位不能提前退出,必须使用零标记进行双向填充(Padding)。随着迭代步深入,批次内真实有效的 Token 比例急剧收缩:

静态批处理执行时序(Batch Size = 4):
Req 1 (30 tokens):   [====]-------------------------------- (Padding)
Req 2 (15 tokens):   [==]---------------------------------- (Padding)
Req 3 (100 tokens):  [==============================]------- (Padding)
Req 4 (150 tokens):  [=============================================]
                      0    30                     100           150 (Step)

依据 Orca 论文(OSDI 2022 §4.1)在真实工业负载下的基准测试,当生产环境并发请求的序列长度服从重尾分布时,静态批处理的有效算力利用率通常不足 25%。

这种算力塌陷在现代 GPU 微架构上呈现双重放大效应:

  1. 计算维度:在 Prefill 阶段之后,自回归 Decode 属于典型的访存受限(Memory-bound)任务,算子运算强度(FLOPs/Byte)通常小于 2。对 Padding 槽位执行全零矩阵乘法(GEMM/GEMV),纯粹在消耗 GPU SM 调度器资源与时钟周期。
  2. 显存维度:为了维持静态批次张量对齐,显存管理器必须预先按照最大可能长度为每个槽位静态保留显存。退出的请求不仅无法将显存交还给等待队列中的新请求,其无效的 KV 块还阻碍了后续请求的进入,导致集群整体吞吐量发生严重断崖。

步进级生命周期与请求有限状态机

连续批处理打破了“批次内所有请求生命周期必须齐步走”的前提假设。其底层逻辑是将调度的粒度拆解到最微观的前向步进(Iteration-level):每一个前向步进结束,都是一次全新的调度决策点。

在单步迭代边界处,调度器执行三项确定性操作:

  • 识别并剔除已生成 EOS 或命中长度上限的请求,就地释放其占用的物理显存块;
  • 检查当前全局空闲显存块与算力预算,若有余量,从等待队列中装填新的就绪请求;
  • 为仍在运行的请求分配下一步所需的 KV Cache 物理块,组装紧凑的打平张量并提交下一次 GPU 发射。

为了支撑纳秒级的决策流转,必须在 Host 侧维护一个轻量、严谨的请求有限状态机(Finite State Machine, FSM)。参考 TensorRT-LLM 的 GptManager 与 vLLM 的 Scheduler 架构,请求在生命周期内遵循五态流转模型:

               +---------------+
  新请求到达 -->|    WAITING    |
               +-------+-------+
                       | 显存块与 Token 预算就绪 (Allocate & Prefill)
               +-------v-------+
               | RUNNING_CTX   | (计算密集型 Prefill 阶段)
               +-------+-------+
                       | 首字生成完成,转入自回归
               +-------v-------+  显存耗尽被抢占 (Evict)   +-----------+
               | RUNNING_GEN   |-------------------------->| PREEMPTED |
               +-------+-------+<--------------------------+-----------+
                       |          重新调度 (Recompute/Swap)
                       | 生成 EOS / 达到最大长度
               +-------v-------+
               |   FINISHED    | ---> 物理块回收、回传 Client
               +---------------+

各状态迁移条件与资源绑定规则定义如下:

  • WAITING:请求已解析并进入调度器缓冲池,尚未分配任何 GPU KV Cache 物理块。
  • RUNNING_CTX:请求已被装配入调度批次,正在执行 Context/Prefill 阶段。此阶段为计算密集型,根据调度策略既可一次性计算完整个 Prompt,也可分块计算。
  • RUNNING_GEN:请求处于自回归 Decode 阶段,每步迭代固定输入 1 个 Token,并向显存管理器申请 1 个 Token 的 KV 写入空间。
  • PREEMPTED:运行时显存达到高水位硬阈值,调度器触发紧急保护,剥夺低优先级请求的显存并将其挂起。根据配置,该请求的 KV Cache 可被换出至 Host 内存(Swap 策略),或直接丢弃物理块等候从头重算(Recompute 策略)。
  • FINISHED:请求生成结束,显存管理器同步将其引用的所有 Paged KV Cache 物理块标记为空闲,请求指针移出活跃轮询池。

在 C++20 体系下,面向高并发调度器的请求核心结构定义需要兼顾内存紧凑性与缓存局部性;这一点在现代 C++ 的并发与内存模型课程中也有系统性的拆解,尤其是原子状态标记、移动语义与锁粒度的结合方式,恰好和这里的调度器诉求一致:

#pragma once

#include <cstdint>
#include <vector>
#include <string>
#include <chrono>
#include <atomic>
#include <memory>

enum class RequestState : uint8_t {
    WAITING,       // 等待初始显存分配
    RUNNING_CTX,   // Prefill 阶段
    RUNNING_GEN,   // Decode 逐步自回归
    PREEMPTED,     // 显存枯竭被驱逐挂起
    FINISHED       // 生成终止,待回收资源
};

struct LlmRequest {
    uint64_t req_id{0};
    uint64_t arrival_time_us{0};

    // 状态流转标识
    std::atomic<RequestState> state{RequestState::WAITING};

    // Token 序列容器
    std::vector<int32_t> prompt_tokens;
    std::vector<int32_t> generated_tokens;
    size_t max_new_tokens{0};

    // 分页显存映射:记录该请求持有的物理 Block ID 列表
    std::vector<int32_t> block_table;

    // Prefill 进度指示(用于分块预填充 Chunked Prefill)
    size_t context_processed_tokens{0};

    // 构造函数
    LlmRequest(uint64_t id, std::vector<int32_t> prompt, size_t max_tokens)
        : req_id(id)
        , prompt_tokens(std::move(prompt))
        , max_new_tokens(max_tokens) {
        arrival_time_us = std::chrono::duration_cast<std::chrono::microseconds>(
            std::chrono::steady_clock::now().time_since_epoch()
        ).count();
    }

    [[nodiscard]] bool is_finished() const noexcept {
        return state.load(std::memory_order_relaxed) == RequestState::FINISHED;
    }

    [[nodiscard]] size_t total_tokens() const noexcept {
        return prompt_tokens.size() + generated_tokens.size();
    }
};

读写分离缓冲池与非对齐张量批装配

调度器的吞吐上限受到 CPU 控制流开销的严格制约。一方面,外部网络 I/O 线程(如 gRPC 工作线程)以不可预测的随机间隔高频提交新请求;另一方面,主调度循环必须与 CUDA Stream 保持齐步发射,任何微秒级的互斥锁竞争都会导致 GPU 算力出现微小停顿(Micro-stall)。

细粒度临界区与双队列解耦

为了彻底消除网络接收端与 GPU 发射端之间的锁竞争,生产环境通常采用读写分离的双缓冲或待定队列架构:

  1. 网络线程仅持有轻量互斥锁,将新收到的请求句柄写入 incoming_queue_,随后立即返回;
  2. 调度循环主线程在每个 Step 开始时,在极短临界区(仅涉及指针移动,耗时通常在 200 纳秒以内)内将 incoming_queue_ 整体转移(swap 或 splice)至调度线程私有的 waiting_pool_;
  3. 后续复杂的配额检查、Block 分配、状态变更完全由主调度线程单线程执行,无须依赖任何全局大锁。
网络 I/O 线程                调度器私有环境(零锁竞争)
 [Req A] ──┐
 [Req B] ──┼─> [incoming_queue_]
 [Req C] ──┘         │
                     │  std::swap (临界区 < 1μs)
                     ▼
               [waiting_pool_] ──> 显存与 Token 预算规划
                     │
                     ▼
               [active_pool_]  ──> 组装 Ragged Tensor ──> 发射至 GPU Stream

这正是高性能网络服务里典型的“单线程执行 + 短临界区交换”思路:与其让多类工作线程反复争抢同一把大锁,不如把锁的持有时间压缩到指针交换这一瞬。对从 Nginx 源码到 epoll、线程池、连接数控制都覆盖完整的Linux C++ 通讯架构实战课程来说,这套以“避免锁竞争拖慢主循环”为核心的工程范式同样是重点内容。

生产级调度循环与批装配实现

以下 C++ 代码展示了具备完备状态回收、预算控制与分页映射的调度核心逻辑:

#include <deque>
#include <mutex>
#include <span>
#include <algorithm>

// 模拟显存分页块管理器接口
class BlockManager {
public:
    virtual bool can_allocate_blocks(size_t num_blocks) const = 0;
    virtual std::vector<int32_t> allocate_blocks(size_t num_blocks) = 0;
    virtual void free_blocks(std::span<const int32_t> blocks) = 0;
    virtual bool append_slot(int32_t req_id, std::vector<int32_t>& block_table) = 0;
    virtual ~BlockManager() = default;
};

// 单步装配生成的元数据包,直接传递给底层算子
struct BatchExecutionMeta {
    std::vector<int32_t> flat_input_tokens;  // 打平的一维输入 Token
    std::vector<int32_t> cu_seqlens;         // 累积序列长度前缀和 [0, s1, s1+s2, ...]
    std::vector<int32_t> block_tables_flat;  // 打平的物理 Block 表
    size_t max_seqlen{0};                    // 本批次内单序列最大长度
    size_t num_requests{0};                  // 实际参与计算的请求数
};

class ContinuousBatchScheduler {
private:
    std::mutex ingress_mtx_;
    std::vector<std::shared_ptr<LlmRequest>> incoming_queue_;

    // 调度器线程独占容器,无需互斥锁保护
    std::deque<std::shared_ptr<LlmRequest>> waiting_pool_;
    std::vector<std::shared_ptr<LlmRequest>> active_pool_;

    std::shared_ptr<BlockManager> block_mgr_;
    const size_t max_batched_tokens_;  // 单批次最大 Token 预算(防计算超载)
    const size_t block_size_{16};      // 每个物理 Block 容纳的 Token 数量

public:
    ContinuousBatchScheduler(std::shared_ptr<BlockManager> bm, size_t max_tokens)
        : block_mgr_(std::move(bm)), max_batched_tokens_(max_tokens) {}

    // 网络 I/O 线程调用:轻量入队
    void submit_request(std::shared_ptr<LlmRequest> req) {
        std::lock_guard<std::mutex> lock(ingress_mtx_);
        incoming_queue_.push_back(std::move(req));
    }

    // 调度主线程核心循环:单步批装配
    BatchExecutionMeta schedule_step() {
        // 1. 交换入队队列至私有等待池
        {
            std::vector<std::shared_ptr<LlmRequest>> tmp;
            {
                std::lock_guard<std::mutex> lock(ingress_mtx_);
                tmp.swap(incoming_queue_);
            }
            waiting_pool_.insert(waiting_pool_.end(), tmp.begin(), tmp.end());
        }

        BatchExecutionMeta meta;
        meta.cu_seqlens.push_back(0);

        // 2. 遍历现有 active_pool_,处理正常完成或推进 Decode
        std::vector<std::shared_ptr<LlmRequest>> next_active;
        size_t current_token_budget = 0;

        for (auto& req : active_pool_) {
            if (req->is_finished()) {
                // 回收物理显存块
                block_mgr_->free_blocks(req->block_table);
                continue;
            }

            // 为当前步 Decode 尝试追加 1 个 slot
            if (!block_mgr_->append_slot(req->req_id, req->block_table)) {
                // 显存触顶,降级抢占该请求
                req->state.store(RequestState::PREEMPTED, std::memory_order_relaxed);
                block_mgr_->free_blocks(req->block_table);
                req->block_table.clear();
                // 放回等待队列头部以便后续重算
                waiting_pool_.push_front(req);
                continue;
            }

            // Decode 步每请求仅发射最新的 1 个 Token
            int32_t next_token = req->generated_tokens.empty() ?
                                 req->prompt_tokens.back() :
                                 req->generated_tokens.back();
            meta.flat_input_tokens.push_back(next_token);

            current_token_budget += 1;
            meta.cu_seqlens.push_back(static_cast<int32_t>(meta.flat_input_tokens.size()));
            meta.max_seqlen = std::max(meta.max_seqlen, req->total_tokens());
            next_active.push_back(req);
        }

        // 3. 检查剩余预算,从 waiting_pool_ 提取新请求执行 Prefill
        while (!waiting_pool_.empty() && current_token_budget < max_batched_tokens_) {
            auto req = waiting_pool_.front();
            size_t prompt_len = req->prompt_tokens.size();

            // 检查显存与 Token 算力预算是否接纳该请求
            size_t needed_blocks = (prompt_len + block_size_ - 1) / block_size_;
            if (current_token_budget + prompt_len > max_batched_tokens_ ||
                !block_mgr_->can_allocate_blocks(needed_blocks)) {
                break; // 预算不足,本步停止接纳
            }

            waiting_pool_.pop_front();
            req->block_table = block_mgr_->allocate_blocks(needed_blocks);
            req->state.store(RequestState::RUNNING_CTX, std::memory_order_relaxed);

            // 装配完整的 Prompt Token
            meta.flat_input_tokens.insert(
                meta.flat_input_tokens.end(),
                req->prompt_tokens.begin(),
                req->prompt_tokens.end()
            );

            current_token_budget += prompt_len;
            meta.cu_seqlens.push_back(static_cast<int32_t>(meta.flat_input_tokens.size()));
            meta.max_seqlen = std::max(meta.max_seqlen, prompt_len);
            next_active.push_back(req);
        }

        active_pool_ = std::move(next_active);
        meta.num_requests = active_pool_.size();
        return meta;
    }
};

非对齐紧凑拼接(Ragged Tensor Packing)

连续批处理在算子层面的落地,依赖于将各个序列从传统的规整张量转换为扁平的非对齐张量(Ragged Tensor)。

在向底层 FlashAttention-2 或 FlashInfer 算子提交执行时,框架完全放弃了补零的 [BatchSize, MaxSeqLen] 结构,将批次内的所有有效输入直接展平为一维向量 [TotalTokens]。

传统对齐填充张量:
[TokA, TokB,   0,   0] -> 大量空乘浪费
[TokC, TokD, TokE,  0]
[TokF,   0,    0,   0]

非对齐紧凑拼接 (Ragged Tensor):
Flat Tokens: [TokA, TokB, TokC, TokD, TokE, TokF]
cu_seqlens:  [0, 2, 5, 6]  (由前缀和严格定位各请求边界)

通过传入前缀和累积数组 cu_seqlens,GPU Kernel 内部的每个 Warp 线程组可以直接通过索引偏移定位当前请求的有效数据范围。这样既彻底消除了无效显存传输与零乘算力消耗,又使批装配的 CPU 开销在 8 卡 H100 架构下被压缩在 30 微秒以内。

NVIDIA CUDA 架构标识

计算密集与访存密集的硬件级混跑冲突

当连续批处理成功将不同生命周期的请求塞进同一个前向迭代时,推理系统立即撞上了另一堵物理墙:计算密集型负载与访存密集型负载的底层资源竞争。

在自回归大模型推理链路中,Prefill 与 Decode 呈现截然相反的计算模式与硬件特征:

维度 Prefill 阶段(Prompt 处理) Decode 阶段(Token 生成)
计算模式 矩阵-矩阵乘法(GEMM) 矩阵-向量乘法(GEMV)
序列处理 一次性处理多个已存在的 Context Token 每次步进仅处理 1 个新生 Token
算力强度 高(通常在 100~250 FLOPs/Byte 之间) 极低(通常仅 0.5~2 FLOPs/Byte)
瓶颈属性 计算受限(Compute-bound),受 Tensor Core 吞吐限制 访存受限(Memory-bound),受 HBM 带宽限制
单步耗时 几十毫秒至数百毫秒不等(随长度非线性上升) 稳定在 1.5~3 毫秒之间(仅受权重读取耗时主导)

当调度器贪婪地执行就绪请求装填时,一个包含 4096 tokens 的突发长文本 Prefill 请求可能与 60 个平稳运行的 Decode 请求塞入同一个 Iteration。

此时 GPU Stream 触发大算子发射。为了完成 4096 tokens 的巨幅 GEMM 计算,底层 CUDA Kernel 占满了 GPU 全部的 Streaming Multiprocessor(SM)计算单元。原本执行极快的 Decode 请求不得不等待整个大 GEMM 算子执行完毕:

预期理想状态(纯 Decode 步进):
Step N   : [60 Decodes] ──────> 耗时 1.8ms (流畅响应)
Step N+1 : [60 Decodes] ──────> 耗时 1.8ms (流畅响应)

无预算混批造成的时序阻塞现场:
Step N   : [60 Decodes] ──────────────────────────────────────────> 耗时 1.8ms
Step N+1 : [1 个 4096 Prefill + 60 Decodes] ─────────────────────> 耗时 46.5ms (严重流水线阻塞)
Step N+2 : [60 Decodes] ──────────────────────────────────────────> 耗时 1.8ms

在这 46.5 毫秒的单步阻塞期间,同批次全部交互式短请求的输出流被全额挂起。在终端客户端,用户直观感知为输出打字机突然发生剧烈顿挫。在监控指标上,Token 间生成延迟(Time-Per-Output-Token, TPOT)的 P99 曲线出现数十倍的异常脉冲。

分块预填充与 Token 预算规划

为了彻底消除长文本 Prefill 对常规 Decode 的性能破坏,现代推理引擎引入了分块预填充(Chunked Prefill)机制。

其核心指导原则是:消灭任意单一请求在单步迭代中的资源独占权,将整个批次单步前向的总计算量平抑为一条可预测的水平线。

分块调度算法

调度器不再将超长 Prompt 视为不可分割的整体,而是将其切分为若干个不超过预算上限的 Chunk。

假定系统划定的单步全局 Token 预算上限为 max_num_batched_tokens = 512:

  1. 调度器优先满足现有 RUNNING_GEN 状态的所有 Decode 请求(假设共 48 个,消耗 48 个 Token 预算);
  2. 调度器计算剩余可用算力预算:available_budget = 512 - 48 = 464;
  3. 从等待池中取出长 Prompt 请求(长度 2048),但在当前 Step 仅装填前 464 个 Token 进行部分计算(Partial Prefill),并将其中间 KV Cache 写入分配好的 Block 表中;
  4. 该长请求保持 RUNNING_CTX 状态留在批次中,其剩余的 1584 个 Token 将在后续步进中逐步切片消费,直至所有 Prompt Token 算完,方才转入 RUNNING_GEN。
Chunked Prefill 切片前向示意:
Prompt (2048 tokens): [Chunk 1: 464] -> [Chunk 2: 464] -> [Chunk 3: 464] -> [Chunk 4: 464] -> [Chunk 5: 208]
                        Step 1           Step 2           Step 3           Step 4           Step 5
Decode 负载 (48 个):    48 tokens        48 tokens        48 tokens        48 tokens        48 tokens
批次总计算 Token:       512 (恒定)       512 (恒定)       512 (恒定)       512 (恒定)       256

通过将大计算量平摊到多个微步迭代,每个 Step 的 GPU 执行时间被稳定约束在 4~6 毫秒以内。Decode 请求的 TPOT P99 抖动被压制在毫秒级微小区间内。

工程代价与边界权衡

Chunked Prefill 并非零成本优化,它本质上是一场以长请求首字延迟(Time-To-First-Token, TTFT)的轻微劣化,换取全集群交互短请求 TPOT 极端稳定性的工程权衡:

  • TTFT 增加:由于长文本被切成多个 Chunk 跨多步完成,首个 Token 的发射时间相比整块一次性前向略微拉长约 10%~18%;
  • 中间状态开销:调度器需要严密追踪每一个切片请求的 context_processed_tokens 进度偏移,底层 Attention 算子必须支持针对历史 Partial KV 块执行增量 Cross-Attention 计算;
  • 显存占用拉长:在整个分块计算期间,长请求已经分配的 KV Cache 物理块必须持续驻留显存,拉长了显存占用的生命周期窗口。

终局演进:Prefill 与 Decode 物理分离架构

在千万级日请求量的高并发超大规模生产集群中,即使引入了 Chunked Prefill,单一 GPU 卡上计算密集与访存密集的内在冲突依然通过缓存污染(L2 Cache 击穿)和算子调度开销对性能产生侵蚀。

由此,业界前沿架构(如 Splitwise、DistServe 以及 Mooncake 等开源系统)正加速迈向 Prefill-Decode 物理分离(PD Disaggregation) 架构。

                            RDMA 高速互联网络 (RoCE v2 / InfiniBand)
                                KV Cache 块张量直传通道
                                           │
   +------------------------+               │               +------------------------+
   |   Prefill 专用实例池   |               │               |    Decode 专用实例池   |
   | (8卡 H100, 高计算密度) |───────────────┴──────────────>| (8卡 H100, 高带宽利用) |
   |                        |                               |                        |
   | 纯 GEMM 吞吐跑满       |                               | 纯 GEMV 纳秒级极速响应 |
   | TPOT 抖动彻底归零      |                               | TPOT 稳定在 1.5ms 基线 |
   +------------------------+                               +------------------------+

在这种物理隔离架构下:

  1. 集群拓扑分工:前端 Prefill 节点池专门执行大批量 Prompt 计算,配置高计算密度配置,算子直接使用纯 GEMM 满载发射;Decode 节点池则专门接收已经完成 Prefill 的请求,全力运转逐步自回归生成。
  2. 跨节点 KV 直传:Prefill 节点在完成前向后,绕过 CPU 内存,利用 GPUDirect RDMA(Remote Direct Memory Access)技术将显存中的物理 KV Cache 块以微秒级延迟打入 Decode 节点的 HBM 显存对应物理槽位。
  3. 架构收益:从物理层面彻底切断了 GEMM 与 GEMV 的资源抢占,Decode 节点的 TPOT 抖动彻底归零;两套集群可以针对不同硬件特性进行独立弹性扩缩容,按 Prompt 密集还是 Output 密集独立调度,实现了整机吞吐与端到端 SLA 达标率的全局帕累托最优。

从早期的单机静态批处理,到步进级的连续批处理与非阻塞 C++ 状态机,再到时间分片维度的 Chunked Prefill,最终演进至物理隔离的 PD 分离网络——大模型推理架构的演变轨迹,本质上是现代计算系统在异构芯片上对内存分页、时分多路复用与分布式微流水线技术的深度重演。深入理解这些机制在微秒级控制流与底层硬件算子间的协作机理,是构建高吞吐、工业级可用大模型推理服务系统的立足之本。对这类底层系统问题感兴趣的话,也欢迎到云栈社区和同样关注 LLM 推理与 C++ 工程实践的人一起交流。




上一篇:多Agent协作下半场:为什么上下文比模型更关键
下一篇:谷歌更新UGC垃圾内容防护指南:AI生成页面与共享平台的索引风险应对
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-10 00:12 , Processed in 0.074862 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表