上下文并行与 Ring Attention

把 Llama-3 70B 的训练序列拉到 128K,即便开了 TP=8 加序列并行,每张卡每层仍要存约 4.2 GiB 激活,80 层合计约 335 GiB。张量并行加序列并行把一层里的激活要么沿 hidden 维切、要么沿序列维切,最大激活降到了 \(bsh/t\)。但它有一个边界:TP 区域内部,每张卡上的张量仍然是完整的序列长度,比如 \([b, s, F/t]\);而 TP 度又被 NVLink 域卡在 8 左右。序列一长(128K、1M),这点切分就不够了。

上下文并行(Context Parallelism,CP)的做法是:把序列切开,并且在整个模型里都保持切开,包括注意力。MLP、LayerNorm 这些逐 token 的操作不受影响;唯一的麻烦是注意力,因为每个 query 要看到(因果情况下:之前的)所有 key 和 value。

激活随序列长度增长

训练时,反向传播要用到前向的中间结果,所以每一层都要存下一批张量。对 GPT 结构的一层逐项记账(bf16,不做重计算,\(a\) 为头数),每层要存的字节数是:

$$m_\text{act}^{\text{层}} = sbh\left(34 + 5\frac{as}{h}\right)$$

第一项正比于 \(s\),来自线性层输入、LayerNorm 输入、MLP 中间激活等;第二项正比于 \(s^2\),来自注意力分数矩阵 \(QK^\top\)、softmax 输出和它的 dropout mask,每个头都是一个 \(s\times s\) 的矩阵。拿 Llama 系列的尺寸代进去算一下:序列只有一两千 token 时,激活和模型状态比起来可以忽略;到 2K–4K 以后激活开始占大头,而且因为有 \(s^2\) 项,增长得越来越快。

FlashAttention 不存 \(s\times s\) 的矩阵,只存 \(Q,K,V,O\) 和每行的 log-sum-exp,反向时分块重算分数矩阵。这样第二项就没了,激活变成和 \(s\) 线性增长。线性听起来还好,但数字仍然很大。按 Llama 结构(RMSNorm、GQA、SwiGLU、FlashAttention)记账,每层每条序列存 \(12sbh + 4sb\,n_{kv}d_h + 6sbF\) 字节(推导见张量并行的显存账)。拿 Llama-3 70B(\(h=8192\),\(F=28672\),8 个 KV 头)在 \(s=128\text{K}\)、TP=8 + SP 下算一下:

而模型状态本身(706 亿参数,每卡 1/8 加上 ZeRO 切分)也要占几十 GB。这说明:长序列下,重计算最多只能把激活压到「和 \(s\) 线性、系数是 \(2h\cdot L\)」的水平,而且全层重计算要把每层的前向再跑一遍,前向约占一步训练计算量的 1/3,所以总计算多出约 1/3。剩下唯一的办法是把 \(s\) 本身分到更多卡上。

上下文并行的基本想法

CP 把输入序列沿 \(s\) 维切成 \(N\) 段(\(N\) 是 CP 度),第 \(i\) 张卡只处理第 \(i\) 段 token 的所有层。和 SP 的区别是:SP 只在 LayerNorm/Dropout 这些区域切序列,进入 TP 区域时要 All-Gather 回完整序列;CP 则从 embedding 到 loss 一直切着。

所以 CP 的全部难点就是:怎么在 query、key、value 都分散在不同卡上的情况下,精确地算出注意力。

分块注意力:online softmax 与 log-sum-exp 合并

对一个 query 向量 \(q\)(省略头的下标),注意力输出是

$$o = \sum_{j} \frac{e^{x_j}}{\sum_{k} e^{x_k}}\, v_j,\qquad x_j = \frac{q\cdot k_j}{\sqrt{d_h}}$$

softmax 的分母要对所有 key 求和,看起来必须先拿到全部 key 才能开始。但其实可以先对一部分 key 算一个「局部结果」,再把局部结果合并。设 key 被分成两块 \(\mathcal{B}_1, \mathcal{B}_2\)。对每一块定义:

$$m_b = \max_{j\in\mathcal{B}_b} x_j,\qquad \ell_b = \sum_{j\in\mathcal{B}_b} e^{x_j - m_b},\qquad o_b = \frac{1}{\ell_b}\sum_{j\in\mathcal{B}_b} e^{x_j-m_b}\,v_j$$

\(o_b\) 就是「假如只有这一块 key」时的注意力输出,\(m_b\) 是为了数值稳定减掉的最大值。把 \(m_b\) 和 \(\ell_b\) 合成一个数更方便:

$$\mathrm{lse}_b = \log\sum_{j\in\mathcal{B}_b} e^{x_j} = m_b + \log \ell_b$$

这就是这一块的 log-sum-exp。现在推导完整结果。完整的分母是

$$\sum_{j} e^{x_j} = \sum_{j\in\mathcal{B}_1} e^{x_j} + \sum_{j\in\mathcal{B}_2} e^{x_j} = e^{\mathrm{lse}_1} + e^{\mathrm{lse}_2}$$

所以全局 \(\mathrm{lse} = \log\left(e^{\mathrm{lse}_1}+e^{\mathrm{lse}_2}\right)\)。完整的分子是

$$\sum_j e^{x_j} v_j = \sum_{j\in\mathcal{B}_1} e^{x_j} v_j + \sum_{j\in\mathcal{B}_2} e^{x_j} v_j = e^{\mathrm{lse}_1} o_1 + e^{\mathrm{lse}_2} o_2$$

(因为 \(o_b = \sum_{j\in\mathcal{B}_b} e^{x_j}v_j / e^{\mathrm{lse}_b}\)。)两式相除:

$$\boxed{\;o = e^{\mathrm{lse}_1-\mathrm{lse}}\,o_1 + e^{\mathrm{lse}_2-\mathrm{lse}}\,o_2,\qquad \mathrm{lse}=\log\left(e^{\mathrm{lse}_1}+e^{\mathrm{lse}_2}\right)\;}$$
一个 query、8 个 key 分成两块先后到达(分数与 2 维的 value 为示意)。每块算完只留下 \((\mathrm{lse}_b, o_b)\),原始分数就可以丢掉;合并时按 \(e^{\mathrm{lse}_b-\mathrm{lse}}\) 加权,结果和一次性对 8 个 key 做 softmax 完全相同。

两个权重 \(e^{\mathrm{lse}_b - \mathrm{lse}}\) 之和恰好是 1,可以理解成「这一块 key 在总 softmax 质量中所占的比例」。实际计算时用稳定的写法:\(\mathrm{lse} = M + \log(e^{\mathrm{lse}_1-M}+e^{\mathrm{lse}_2-M})\),\(M=\max(\mathrm{lse}_1,\mathrm{lse}_2)\),所有指数都不超过 0。几个性质让它适合分布式:

FlashAttention 在单卡内部用的是同一个恒等式(在 SRAM 里按块扫过 K/V,边扫边更新 \(m\)、\(\ell\) 和未归一化的输出),只是把合并写成了增量形式。CP 把它搬到了卡与卡之间:每张卡用 FlashAttention 算出本地 Q 对某个 KV 块的 \((o_b, \mathrm{lse}_b)\),再和之前的累积结果合并。

反向同样只需要 lse。对一个 key \(j\),softmax 概率是 \(p_j = e^{x_j - \mathrm{lse}}\),用全局的 lse 就能在任何一块上单独重算出正确的 \(p_j\),然后算 \(dv_j = \sum_q p_j\, do\)、\(dx_j = p_j(do\cdot v_j - D)\),其中 \(D = do\cdot o\) 是每个 query 一个标量(也是全局的)。所以前向结束时每张卡把本地 query 的全局 \(o\) 和 lse 存下来,反向时再把 KV 块转一圈,每一步都能独立算出这一块对 \(dQ\)(累加在本地)以及对 \(dK, dV\) 的贡献。

Ring Attention

有了合并规则,算法就很自然了。\(N\) 张卡排成一个环,卡 \(i\) 一开始持有第 \(i\) 段的 \(Q_i, K_i, V_i\)。一共 \(N\) 步,每一步每张卡做三件事:

  1. 把手里当前的 K/V 块异步发给下一张卡(最后一步不用发);
  2. 用本地的 \(Q_i\) 和手里当前的 K/V 块算局部注意力 \((o, \mathrm{lse})\),合并进累积结果;
  3. 等上一张卡发来的 K/V 块到达,作为下一步的「当前块」。

第 \(r\) 步(从 0 开始),卡 \(i\) 手里的是卡 \((i-r)\bmod N\) 原来的 K/V。\(N\) 步之后,每个 \(Q_i\) 都见过了全部 K/V,Q 和输出 O 自始至终没离开过本卡。

def ring_attention_fwd(q, k, v, rank, N):           # q,k,v: 本卡的 [s/N, heads, d]
    out, lse = None, None
    for r in range(N):
        if r < N - 1:
            nxt = async_sendrecv(k, v, to=(rank+1) % N, frm=(rank-1) % N)
        src = (rank - r) % N                             # 当前 k,v 原属于哪张卡
        o_b, lse_b = flash_attn(q, k, v, mask=causal_mask(rank, src))
        out, lse = merge(out, lse, o_b, lse_b)           # 上一节的合并公式
        if r < N - 1:
            k, v = nxt.wait()
    return out, lse
4 卡的 Ring Attention。Q 和累积输出 O 始终留在本卡,KV 块每一步沿环走一跳(与本步的计算重叠,每张卡同时收发,没有中心节点)。中间是块级的因果注意力矩阵:橙色是本步在算的块,紫色是算完的,打叉的是被因果掩码整块跳过的。每张卡的 O 条记录已经用 log-sum-exp 合并进来的块数。

显存

每张卡在注意力里只需要:本地 Q(和累积的 O、lse)、当前计算用的 K/V 块、正在接收的下一个 K/V 块(双缓冲)。数一下一共是 6 个块:Q、当前的 K 和 V、正在接收的 K 和 V、输出 O。显存只和块大小线性相关,与总序列长度无关;序列再长,只要加卡让块大小不变,每卡的注意力显存就不变。

能否把通信藏住

每一步的计算是一个 \(c\times c\) 的注意力块(\(c = s/N\) 是每卡的 token 数),通信是一个 K/V 块。设单卡算力 \(C\),单向带宽 \(W\),按一个 micro-batch 算:

$$T_\text{math} = \frac{4\,b\,c^2 h}{C}\quad(QK^\top \text{ 和 } PV \text{ 各 } 2bc^2h),\qquad T_\text{comm} = \frac{2\cdot 2\,b\,c\,n_{kv}d_h}{W}\quad(K,V\text{,bf16})$$

要 \(T_\text{math}\ge T_\text{comm}\):

$$c \;\ge\; \frac{C}{W}\cdot\frac{n_{kv}d_h}{h} = \frac{C}{W}\cdot\frac{n_{kv}}{n_h}$$

多头注意力时(\(n_{kv}=n_h\))条件就是:每卡的块大小 \(c\) 要大于「算力 / 带宽」\(C/W\)。几个数:H100 节点内 \(C/W\approx 990\times10^{12}/450\times10^9\approx 2200\),MHA 需要每卡至少约 2200 个 token;GQA 8/64(Llama-3 70B)只要约 275。跨节点每卡约 50 GB/s,\(C/W\approx 19800\),MHA 需要约 2 万 token,GQA 8/64 约 2500。因果掩码会让每步有用计算大约减半,要求再翻倍。这也解释了为什么 GQA 让 CP 变得容易:K/V 比 Q 小得多,要搬的东西少了 \(n_h/n_{kv}\) 倍。反向时每步要计算的量约为前向的 2 倍多,而要传的除了 K/V 还有累积的 \(dK, dV\)(也绕环走),通信也大约翻倍,比例差不多。

因果掩码带来的负载不均

语言模型用因果注意力:token \(p\) 只能看 \(\le p\) 的 key。把 \(s\times s\) 的分数矩阵画出来,只有下三角(含对角线)需要算。如果按顺序切(卡 \(i\) 拿第 \(i\) 段连续的 token),在 Ring Attention 的第 \(r\) 步,卡 \(i\) 处理的是 \(Q_i\) 和 \(K_{(i-r)\bmod N}\):

每一步的耗时取决于最慢的那张卡,所以第 1 到 \(N-1\) 步每步都要 \(c^2\) 的时间。总耗时约 \(c^2/2 + (N-1)c^2\),而总的有用工作量约 \(N^2c^2/2\),平摊到 \(N\) 张卡是 \(Nc^2/2\)。效率

$$\eta_\text{naive} \approx \frac{Nc^2/2}{(N-\tfrac12)c^2} = \frac{N}{2N-1}\;\to\;\frac12$$

\(N=4\) 时约 57%,\(N=8\) 时约 53%:差不多一半的时间在空等。把注意力矩阵按卡上色就能直观看到:卡 0 持有最前面的 token,第 0 步就算完了自己的全部行,之后一直闲着;编号越大的卡,要算的块越多,最后一张卡每一步都满载。问题不在于谁先算完,而在于每一步都要等最忙的那张卡。

Zig-zag 与 Striped:重新分配 token

解决办法是不按连续段分配 token,让每张卡都拿到一些「早」的 token 和一些「晚」的 token。

Zig-zag

把序列切成 \(2N\) 个块 \(C_0, \dots, C_{2N-1}\)(每块 \(c/2\) 个 token),卡 \(i\) 拿 \(C_i\) 和 \(C_{2N-1-i}\):卡 0 拿第一块和最后一块,卡 \(N-1\) 拿中间两块。Llama 3 的 CP 正是这样切的:「把输入序列切成 \(2\times\text{CP}\) 块,第 \(i\) 个 CP rank 拿第 \(i\) 块和第 \(2\times\text{CP}-1-i\) 块」。

\(N=4\),序列切成 8 块。块 \(C_k\) 里的 query 要看 \(k+\tfrac12\) 个块的 key(块内小条的长度),右侧是每张卡的总工作量(单位:一个 \((c/2)^2\) 的块)。连续切分时是 2、6、10、14;zig-zag 让卡 \(i\) 拿 \(C_i\) 和 \(C_{7-i}\),每张卡都是 8。

验证一下为什么平衡。卡 \(i\) 的 query 块是 \(\{i,\;2N-1-i\}\),它在某一步拿到的 KV 来自卡 \(j\),KV 块是 \(\{j,\;2N-1-j\}\)。四个(query 块,key 块)组合:

key 块 \(j\)(早)key 块 \(2N-1-j\)(晚)
query 块 \(i\)(早)\(j\lt i\):全算;\(j=i\):对角;\(j\gt i\):跳过\(2N-1-j\ge N\gt i\):总是跳过
query 块 \(2N-1-i\)(晚)\(j\le N-1\lt 2N-1-i\):总是全算\(j\gt i\):全算;\(j=i\):对角;\(j\lt i\):跳过

当 \(j\ne i\)(第 1 到 \(N-1\) 步)时,恰好有两个组合要全算、两个跳过,工作量是 \(2\times(c/2)^2 = c^2/2\),每张卡、每一步都一样。当 \(j=i\)(第 0 步)时,一个全块加两个对角半块,也约是 \(c^2/2\)。总耗时约 \(Nc^2/2\),正好等于理想值。相比朴素切法快了约 \(2-1/N\) 倍。

表格还揭示了一个实现上的巧合:\(j\lt i\) 时要算的两个组合都用 key 块 \(j\),即「全部 Q × KV 的前一半」;\(j\gt i\) 时都用 query 块 \(2N-1-i\),即「Q 的后一半 × 全部 KV」。两种情况都是没有掩码的矩形,直接调一次不带 causal 的 FlashAttention 即可,只有第 0 步需要带掩码。开源实现 ring-flash-attention 的 zigzag 版本就是这样做的。

Striped

Striped Attention(Brandon 等人)走得更彻底:token \(p\) 分给卡 \(p \bmod N\),每张卡拿到均匀散布在整条序列上的一组 token。这样每一步每张卡面对的掩码都近似是一个「上三角掉一半」的块,各卡工作量只差对角线上的那几个元素。实测中,Striped 在 8 张 A100 上把 256K 序列的端到端训练吞吐提高到 Ring Attention 的最多 1.45 倍,在 16 块 TPUv4、786K 序列上是 1.65 倍;当块大小和设备数趋于无穷时,理论加速趋近 2 倍。和 zig-zag 相比,Striped 每一步的掩码都不是全满或全空的矩形,实现上需要一个带偏移的因果掩码。

重排 token 的代价

注意力对 token 顺序是「置换等变」的:只要掩码按 token 在原序列里的位置来判断,重排不影响结果。所以 zig-zag 或 striped 只需要在模型入口重排一次 token,整个前向和反向都保持这个布局。但要同时重排三样东西:输入 token id、位置编码用的 position id(RoPE 必须用原始位置),以及计算 loss 用的目标 token。逐 token 的模块本来就不关心顺序。

演示:Ring Attention 逐步执行

左边是 \(T\) 个 token 的因果注意力矩阵(行是 query,列是 key,只画下三角),上方和左侧色带的深浅表示每个 token 分给了哪张卡(越深编号越大)。当前这一步正在计算的格子用实心橙色,之前步骤算过的用浅紫色。右边是每张卡这一步的工作量(按格子数)、空等的部分,以及累计的效率。这里假设掩码可以按元素精确跳过;真实的 kernel 按 tile 跳过,对角块的收益会打折扣。

Ring Attention:朴素 / Zig-zag / Striped
切分方式 卡数 N
注意力矩阵(原始 token 顺序)
环上的 KV 位置
本步每卡工作量(格子数)
本步计算 已完成 尚未计算 空等(本步比最忙的卡少做的部分)
本步耗时(最忙的卡)
累计耗时 / 理想耗时
全部 N 步的效率

切到「朴素连续」,按几下「下一步」:从第 1 步开始,编号小的卡整块空闲,编号大的卡整块满载。换成 Zig-zag,每一步每张卡的工作量都相同。Striped 下各卡只差几个对角元素。把 \(N\) 调到 8,朴素切法的效率接近 53%。

All-Gather 式 CP

Ring 不是唯一的实现。另一种做法是:每层注意力之前,在 CP 组内对 K 和 V 做一次 All-Gather,每张卡拿到完整序列的 K/V,然后用本地的 Q 算注意力;反向时对 \(dK,dV\) 做 Reduce-Scatter。两种实现各有取舍:

Llama 3 选的是 All-Gather 式(同样配合 \(2\times\text{CP}\) 块的负载均衡切法),并给了两个理由:一是更容易支持各种注意力掩码,比如文档掩码(一条序列里拼了多篇文档,跨文档不可见),环形实现要在每一步为不同的 KV 块计算正确的掩码;二是 GQA 让 K/V 比 Q 小得多,而注意力的计算是 \(O(S^2)\)、All-Gather 是 \(O(S)\),暴露在关键路径上的通信可以忽略。

完整 K/V 的大小:每层 \(2\cdot 2\,b\,s\,n_{kv}d_h\) 字节(TP 下再除以 \(t\)),对 Llama-3 70B、\(s=128\text{K}\) 是 512 MiB,TP=8 后 64 MiB。这是一层的临时缓冲,算完就可以释放(反向重算时再 gather),所以完全可以接受。对 MHA 模型或者 KV 头很多的模型,这个账就不那么好看了。

DeepSpeed-Ulysses:按头的 All-to-All

换一个角度:注意力在序列维上纠缠,但在头维上完全独立。DeepSpeed-Ulysses 利用这一点在注意力前后各转置一次切分维度:

  1. 注意力之外,和 CP 一样,每张卡持有 \(s/P\) 个 token 的完整隐藏向量(\(P\) 是序列并行度),逐 token 的模块本地计算。
  2. QKV 投影后,每张卡有 \([s/P,\; n_h,\; d_h]\) 的 Q(K/V 类似)。做一次 All-to-All:把头维切成 \(P\) 份发给不同的卡,同时收集别的卡的序列段。结束后每张卡有 \([s,\; n_h/P,\; d_h]\):完整序列、部分头。
  3. 每张卡对自己的 \(n_h/P\) 个头算普通的(单卡)注意力,可以直接用 FlashAttention,掩码怎么复杂都行。
  4. 输出 \([s, n_h/P, d_h]\) 再做一次 All-to-All,换回 \([s/P, n_h, d_h]\),接输出投影。
4 卡、4 个头的 Ulysses。块 (sᵢ, hⱼ) 是第 i 段 token 的第 j 个头。All-to-All 本质上是一次分布式转置:块 (sᵢ, hⱼ) 从卡 i 搬到卡 j,切分维从序列换成头;各卡对自己的头做完整序列的注意力(块填实),再用一次 All-to-All 换回来。

通信量。All-to-All 里每张卡把本地张量的 \((P-1)/P\) 发出去。在节点内 NVSwitch、节点间 fat-tree 这样的网络上,总大小为 \(M\) 的 All-to-All 在 \(P\) 张卡上每条链路只传 \(M/P\)。QKV 的总大小是 \(3Nh\)、输出是 \(Nh\)(\(N\) 为序列长度),每条链路共 \(4Nh/P\),所以序列长度和卡数同比例增长时,每条链路的通信量不变。对比 Megatron 的 SP:它的 All-Gather / Reduce-Scatter 每卡通信量是 \(O(N)\),不随 \(P\) 下降。

上面的 \(3Nh\) 是 MHA。对 GQA,K/V 只有 \(n_{kv}d_h\) 宽,把每卡发送的字节数(bf16,单层前向)写准确一点:

$$\text{Ulysses:}\;\frac{P-1}{P}\cdot 2b\frac{s}{P}\,(2h + 2n_{kv}d_h)\qquad \text{Ring / All-Gather:}\;\frac{P-1}{P}\cdot 2b\,s\cdot 2n_{kv}d_h$$

两者之比是 \(\frac{h+n_{kv}d_h}{P\cdot n_{kv}d_h}\)。MHA 下是 \(2/P\),Ulysses 在 \(P\ge 3\) 时就更省;但 GQA 8/64 下是 \(9/P\),要 \(P\gt 9\) Ulysses 才更省。也就是说 GQA 越激进,环形或 All-Gather 式 CP 越占便宜:它们只搬 K/V,而 Ulysses 还要搬 Q 和 O。

限制。

两者也可以叠加:USP(Unified Sequence Parallelism,Fang 与 Zhao)把序列并行组拆成两维,一维做 Ulysses、另一维做 Ring,比如节点内用 Ulysses 的 All-to-All,节点间用 Ring 的点对点传递。

通信量对比与选择

Ring AttentionAll-Gather CPUlysses
注意力时每卡持有本地 Q + 1–2 个 KV 块本地 Q + 完整 K/V完整序列的 \(n_h/P\) 个头
每卡前向发送(单层)\(\frac{P-1}{P}\cdot 4bs\,n_{kv}d_h\)同左\(\frac{P-1}{P}\cdot\frac{4bs}{P}(h+n_{kv}d_h)\)
通信方式相邻点对点,\(P-1\) 步一次集合通信两次 All-to-All
与计算重叠天然重叠(块够大时)暴露在关键路径上暴露在关键路径上
并行度上限无硬上限(受 \(s/P\) 够不够大约束)同左\(\le n_h\)(GQA 时实际 \(\le n_{kv}\))
复杂掩码每步要算对应掩码容易容易
因果负载均衡需要 zig-zag / striped需要 \(2\times\)CP 切法不需要(每卡都是完整序列)

大致的选择:KV 头少(GQA/MQA)且需要文档掩码,All-Gather 式最省事;序列极长、跨节点、想把通信完全藏住,用 Ring + zig-zag;头多、节点内带宽高、想复用现成的注意力 kernel,Ulysses 或 Ulysses + Ring 的混合。

演示:激活显存 vs 序列长度

看每卡显存随序列长度怎么增长,以及 CP 能把能训练的最长序列推到多远。激活按 Llama 结构记账(FlashAttention,TP + SP)。CP 下所有激活(包括 logits)都按 \(s/N\) 计;模型状态假设优化器状态用 ZeRO-1 在 DP×CP 上切(DP 取 8)。全重计算时,每层只存层输入,再加上反向时临时重算一层的激活。真实框架里还有通信缓冲、显存碎片、CUDA context 等开销,实际能放下的长度会再短一些。

每卡显存 vs 序列长度
TP(+SP)
CP 度 N 重计算
CP = 1 CP = N(选中) HBM 容量
CP 度模型状态 / 卡能放下的最长序列(2 的幂)该长度下 GPU 总数(TP×CP)

试试:70B、TP=8、80 GB、无重计算时,CP=1 只能放下 8K,每翻倍一次 CP 能放下的长度也翻倍,CP=8 到 64K。打开全重计算,CP=1 就能到 64K、CP=8 到 1M,但要付出约 1/3 的额外前向计算。405B 的模型状态本身就很大,留给激活的空间少,必须靠 CP 才能做长上下文。

和其他并行组合

TP+SP 解决「单层太大」,CP 解决「序列太长」,二者在 NVLink 域内外的位置不同;「模型太深、一个节点放不下」则要靠流水线并行。

参考与延伸阅读