流水线设计原理:从概念到实战

大家好,我是你们的老朋友。今天我们来聊聊流水线设计。

说实话,流水线这个概念在端侧推理里太重要了。我刚开始做端侧优化时,总觉得单次推理跑得快就行。后来发现,吞吐量才是王道。流水线,就是提升吞吐量的利器。

流水线的概念

什么是流水线?说白了,就是把一个大任务拆成多个小阶段。每个阶段独立执行,同时进行。

你想想看,洗衣服的过程:

  • 一个人洗衣服:先洗、再漂、再甩干。一次只能处理一桶。
  • 流水线作业:第一桶在洗,第二桶在漂,第三桶在甩干。三桶同时进行。

端侧推理也是一样。一个推理请求,可以拆成:

  • 数据预处理
  • 模型推理
  • 后处理

这三个阶段,如果串行执行,耗时就是三者之和。如果流水线执行,耗时就是最慢的那个阶段。

核心思想:流水线不减少单个任务的延迟,但能大幅提升系统吞吐量。

我在项目中遇到过一个问题:单次推理延迟已经优化到极致了,但服务器还是扛不住高并发。后来加了流水线,吞吐量直接翻倍。嗯,这就是流水线的魅力。

流水线的阶段划分

阶段划分,是流水线设计的第一步。也是最重要的一步。

我个人习惯,按以下原则划分:

  1. 功能独立:每个阶段做一件事,不依赖其他阶段内部状态。
  2. 粒度适中:阶段太粗,流水线深度不够;阶段太细,调度开销过大。
  3. 资源均衡:各阶段耗时尽量接近,避免瓶颈。

举个例子,端侧推理常见的三阶段划分:

阶段 任务 典型耗时占比
Stage 1 数据预处理(解码、缩放、归一化) 10% - 20%
Stage 2 模型推理(前向传播) 60% - 80%
Stage 3 后处理(NMS、阈值过滤) 10% - 20%

为什么这样分?因为每个阶段可以跑在不同的硬件上。比如:

  • Stage 1 用 CPU 做,因为解码需要灵活操作。
  • Stage 2 用 NPU/GPU 做,因为矩阵运算快。
  • Stage 3 用 CPU 做,因为逻辑判断多。

我曾经犯过一个错误:把预处理和推理放在同一个阶段。结果 CPU 和 NPU 互相等待,吞吐量反而下降了。后来拆开,各自独立跑,效果立竿见影。

小技巧:阶段划分时,可以用性能分析工具(如 perf、nsys)先跑一遍,看看各部分的耗时占比。数据说话,别靠猜。

流水线的深度与平衡

流水线深度,就是阶段的数量。深度越大,理论上吞吐量越高。但有个前提:各阶段必须平衡。

为什么?

假设流水线有 4 个阶段,耗时分别是:1ms、1ms、1ms、5ms。那整体吞吐量就被第 4 阶段卡死了。其他阶段再快也没用。

这就是木桶效应。流水线的吞吐量,由最慢的阶段决定。

我建议,设计流水线时,先做两件事:

  1. 测量各阶段耗时:用真实数据跑一遍,记录 P50、P99 延迟。
  2. 调整阶段粒度:把慢的阶段拆细,或者把快的阶段合并。

举个例子,我做过一个 OCR 推理流水线:

  • Stage 1:图像预处理(2ms)
  • Stage 2:文本检测模型推理(15ms)
  • Stage 3:文本识别模型推理(20ms)
  • Stage 4:结果后处理(1ms)

很明显,Stage 3 是瓶颈。怎么优化?

我当时的做法是:把 Stage 3 拆成两个子阶段,每个子阶段处理一半的文本行。这样,两个子阶段可以并行执行。虽然总计算量没变,但流水线吞吐量从 20ms 降到了 10ms。

平衡公式:理想流水线中,各阶段耗时相等。实际中,允许 10% - 20% 的偏差。超过这个范围,就需要重新划分。

还有一个坑,我得提醒你:流水线深度不是越大越好。

深度过大,会带来两个问题:

  • 调度开销增加:每个阶段切换、数据传递都需要时间。
  • 内存占用增加:每个阶段都需要缓存中间结果。

我记得有一次,为了追求极致吞吐量,我把流水线拆成了 10 个阶段。结果呢?调度开销占了总时间的 30%,内存也爆了。最后回退到 5 个阶段,效果反而更好。

注意:端侧设备资源有限。流水线深度建议控制在 3 - 6 个阶段。超过 6 个,收益递减,风险递增。

实战中的流水线设计

说了这么多理论,来点实际的。

假设我们要设计一个端侧人脸检测流水线。目标设备是手机,算力有限。

我的设计思路:

  1. 阶段划分:预处理(CPU)→ 推理(NPU)→ 后处理(CPU)
  2. 深度选择:3 个阶段,不多不少。
  3. 平衡优化:如果推理阶段耗时 30ms,预处理 5ms,后处理 5ms。那我会把预处理和后处理合并,或者给推理阶段分配更多资源。

代码示例(伪代码):

// 流水线调度器
void pipeline_scheduler() {
    while (true) {
        // Stage 1: 预处理
        if (preprocess_queue.has_data()) {
            auto data = preprocess_queue.pop();
            npu_queue.push(data);
        }
        
        // Stage 2: 推理
        if (npu_queue.has_data() && npu_idle) {
            auto data = npu_queue.pop();
            npu_infer(data);
            postprocess_queue.push(data);
        }
        
        // Stage 3: 后处理
        if (postprocess_queue.has_data()) {
            auto data = postprocess_queue.pop();
            postprocess(data);
            output_queue.push(data);
        }
    }
}

这段代码看着简单,但实际跑起来,你会发现很多细节:

  • 队列长度怎么设?太短会阻塞,太长会浪费内存。
  • NPU 空闲怎么判断?轮询还是中断?
  • 数据传递怎么避免拷贝?用共享内存还是指针?

嗯,这些就是流水线设计的进阶话题了。今天先聊到这里。

最后一句:流水线设计,没有银弹。多试、多测、多调。你的设备、你的模型、你的数据,都会告诉你答案。

好,今天就到这里。下次我们聊聊流水线中的同步与异步机制。敬请期待。