想象你是一家餐厅的主厨,面前摆着十道菜的订单。厨房里有切菜师傅、炒锅师傅、烤箱师傅——每个人擅长不同的活。如果你让所有师傅排成一队,从头到尾依次完成每一道菜,客人可能要等上一个小时。但如果切好配菜的同时就让烤箱开始预热、炒锅开始热油,几道互不干扰的菜就能同时推进,出菜时间瞬间缩短。异构计算系统的并行执行,玩的就是这套“厨房统筹学”:把 AI 模型的计算图当作菜谱,把 CPU、GPU、NPU 当作各有所长的师傅,找出哪些工序可以同时进行,让整桌“推理大餐”更快上齐。 计算图是一张由节点(计算操作)和边(数据流向)组成的工序图。有些操作之间存在“必须先拿到前一步结果”的依赖关系,就像必须先切好菜才能下锅;而另一些操作则互不相干,可以随时开工。并行执行的核心,就是识别出这些互不干扰的“工序组”,把它们派发给不同的设备,让它们在同一个时间窗口内各干各的,从而压缩整体推理延迟、榨干每一块硬件的算力。

岗位关联

并行执行是 AI Runtime 系统提升推理吞吐、降低延迟的核心调度手段,掌握它,你就能理解 GPU 多卡加速、模型并行等工业级优化背后的共性原理。

目录
  1. 可视化演示
  2. 并行执行基础
  3. 依赖关系分析
  4. 子图切分策略
  5. 调度执行机制
  6. 收益开销权衡
  7. 应用实践落地
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示计算图DAG并行调度、三种并行策略对比与异构设备负载均衡,支持负载比例调节。

本节配有交互动效(进入视口后自动加载,约 3KB)。

计算图DAG并行调度 青色节点为可并行执行的无依赖算子 红色路径为关键路径,决定最小延迟下界 T_min = T_critical_path DAG拓扑分析揭示并行执行机会

并行执行基础

餐厅里每位师傅都有独门绝技:切菜师傅刀工快,炒锅师傅火候准,烤箱师傅温度稳。异构计算系统和这间厨房如出一辙——CPU 擅长复杂逻辑控制,GPU 擅长海量并行计算,NPU 则专为神经网络算子做了硬件加速。这些“各有所长”的设备凑在一起,就构成了现代 AI 推理系统的算力底座。并行执行要回答的第一个问题便是:如何让这些不同类型的设备在同一段时间内各显神通?

在串行模式下,所有计算任务排成一列,逐个执行,就像客人的三道菜由同一位师傅依次完成。而并行执行,则是把无依赖关系的任务分配到不同设备同时运行。这里的“依赖”指的是任务之间必须遵循的先后顺序——某个操作需要等待另一个操作产出的张量才能开始。AI 模型的推理过程通常被建模为一张计算图:节点是算子(卷积、矩阵乘法、激活函数),边是张量的流向,整张图就是一份“数字化菜谱”。

切菜师傅备料的同时烤箱师傅预热面包,两道菜并行推进,总出菜时间从“三道菜时长之和”缩短为“最慢一道菜的时长”——这就是并行执行的本质收益。

T并行=max⁡i∈VTi≈max⁡(TA,TB,TC)T_{\text{并行}} = \max_{i \in V} T_i \approx \max(T_A, T_B, T_C)
并行执行的总延迟近似等于所有并行任务中耗时最长的那一个,而非所有任务耗时之和。
  1. 取最大值并行执行时多个任务同时推进,整体所需时间由最慢的那条任务线决定,因此用 max 而非求和。
    max⁡i∈VTi\max_{i \in V} T_i
  2. 任务集合V 表示被派发给各设备的子任务集合,每个 T_i 是第 i 个子任务在其所在设备上的执行时间。
    i∈V,V={v1,v2,…,vn}i \in V, \quad V = \{v_1, v_2, \dots, v_n\}
  3. 近似符号真实系统中设备间通信、同步会额外耗时,所以公式用 ≈ 表示理想情况下的近似估计。
    ≈\approx

依赖关系分析

想安排多位师傅同时开工,主厨必须先搞清每道菜的工序关系:必须先切完葱才能下锅爆香,必须先烤好面包胚才能抹奶油。这类“前置条件”在计算图里叫数据依赖,即操作 B 需要操作 A 的输出张量作为输入。除此之外还有控制依赖:B 是否执行取决于 A 的执行结果(比如条件分支),以及资源依赖——两个操作争抢同一块显存或同一条总线,本质上也不能真正同时运行。

识别依赖关系后,调度器就能从计算图中“抠”出互不相干的子图:没有直接或间接数据访问关系的子图是天然的并行单元。就像炖红烧肉的同时完全可以让烤面包师傅开工,两者既不共享食材也不共享灶台。一个关键指标是关键路径——计算图中从入口到出口的最长依赖链,它决定了并行执行的理论最短时间。

主厨扫一眼菜谱就能指出哪条工序链最磨人——那条必经之路就是关键路径,其他工序都可以围着它做并行安排。

CP(G)=max⁡p∈Path(G)∑v∈pevCP(G) = \max_{p \in \text{Path}(G)} \sum_{v \in p} e_v
关键路径长度是任何调度方案都无法突破的时间下界,它量化了“必须串行”的部分有多长。
  1. 所有路径Path(G) 是计算图中从输入到输出节点的全部有向路径集合,每条路径代表一条完整的工序链。
    Path(G)={p1,p2,…,pk}\text{Path}(G) = \{p_1, p_2, \dots, p_k\}
  2. 路径时长对每条路径上所有节点 v 的执行时间 e_v 求和,得到该路径的总耗时。
    ∑v∈pev\sum_{v \in p} e_v
  3. 取最长所有路径耗时中的最大值就是关键路径,它锁定了并行执行的理论下限。
    max⁡p∑v∈pev\max_{p} \sum_{v \in p} e_v

子图切分策略

找到无依赖子图后,下一步是把计算图“切”成适合分发的小块,就像把整本菜谱拆成一张张工序卡。切分的核心矛盾在于粒度:切得太粗,一个子图里混杂多种类型的算子,难以匹配设备特长;切得太细,任务碎片化导致调度次数激增,设备间来回搬运数据的通信开销甚至会淹没并行收益。

好的切分策略强调设备匹配:把矩阵乘法这类计算密集算子派给 GPU,把循环、指针操作派给 CPU,把卷积激活这类固定模式算子派给 NPU。同时还要照顾负载均衡——如果一块 GPU 忙得冒烟、另一块却闲着看报,并行效率就会大打折扣。假设切分后第 i 个子图需要跨设备搬运数据量为 D_i 的中间结果,通信开销为:

主厨把工序卡分给各师傅时,既要让每个人干自己最擅长的活,又得避免为传一盘菜等太久——切图的分寸,就是并行调度的艺术。

Tcomm=α+β×DiT_{\text{comm}} = \alpha + \beta \times D_i
α 是发起一次通信的固定启动成本,β 是单位数据量的传输耗时,数据量越大通信越贵。
  1. 固定启动成本无论传输多少数据,建立一次设备间通信都需要固定的握手、寻址时间,记作 α。
    α\alpha
  2. 单位传输耗时每传输一个字节的数据所需的平均时间 β,乘以总数据量 D_i 得到实际搬运耗时。
    β×Di\beta \times D_i
  3. 两者相加总通信开销等于启动成本加传输成本,切分越碎,D_i 和通信次数越多,开销越大。
    α+β×Di\alpha + \beta \times D_i

调度执行机制

工序卡发下去了,接下来主厨要决定什么时候让谁开工。静态调度在运行前就排好所有任务的时间表——主厨凭经验在开工前就写好每张卡片的执行时刻。这种方式零运行时开销、可预测性强,适合形状固定、反复执行的推理模型。动态调度则在运行中按设备空闲情况实时派活——哪个师傅手头空了就递上下一张工序卡,灵活抗波动,但每次派活都有额外决策成本。

调度器还会给任务排优先级:关键路径上的任务优先派发,因为它们决定了整体延迟。多个设备执行完各自的子图后需要同步——就像各师傅做完自己的菜后要一起交给传菜员,用栅栏同步(Barrier)确保所有依赖数据就绪后再进入下一阶段。整个过程的资源利用效率可以表示为:

主厨时而按预案推进、时而随机应变,但始终盯着每个灶台的火——好调度器就是既会排计划、又会救火的资深主厨。

U=∑i=1NTbusy,iN×TtotalU = \frac{\sum_{i=1}^{N} T_{\text{busy},i}}{N \times T_{\text{total}}}
设备利用率等于所有设备实际工作时间之和除以设备数量乘以总耗时,反映设备被“喂饱”的程度。
  1. 忙时求和对 N 台设备各自的忙碌时间 T_busy,i 求和,得到系统总的有效工作量。
    ∑i=1NTbusy,i\sum_{i=1}^{N} T_{\text{busy},i}
  2. 理论总容量N 台设备同时运行 T_total 时长,对应的理论最大工作量是 N × T_total。
    N×TtotalN \times T_{\text{total}}
  3. 两者相除实际工作量除以理论容量,得到设备利用率 U,越接近 1 说明调度越饱满。
    ∑Tbusy,iN×Ttotal\frac{\sum T_{\text{busy},i}}{N \times T_{\text{total}}}

收益开销权衡

并行执行并非“师傅越多越快”。每多请一位师傅,就要多一张嘴吃饭:任务启动有开销(分配设备、初始化上下文),设备通信有延迟(搬运中间张量),同步等待浪费时间。当并行带来的收益被这些开销抵消时,盲目增加并行度反而会拖慢整体速度。这正是阿姆达尔定律揭示的道理——程序的加速比受限于其中必须串行的部分。

假设某推理流程中可并行部分占比为 P,不可并行的串行部分占比为 1-P,使用 N 台设备并行后理论加速比为:

如果一道菜的流程里 80% 都可以并行,哪怕请一百个师傅,提速也超不过 5 倍——主厨心里得有这笔账,才能决定到底安排几个人同时开工。

S(N)=1(1−P)+PNS(N) = \frac{1}{(1-P) + \dfrac{P}{N}}
即使设备数趋于无穷,加速比的极限也仅为 1/(1-P),串行部分的占比是并行收益的天花板。
  1. 串行部分占比1-P 是流程中无法并行的部分(如启动、收尾、依赖链),它构成加速比的分母基底。
    1−P1-P
  2. 并行部分摊薄可并行部分 P 除以设备数 N,表示这部分时间被 N 台设备摊薄后的理想耗时。
    PN\frac{P}{N}
  3. 总时间倒数并行后的总耗时是串行部分加摊薄后的并行部分,取倒数即得加速比。
    1(1−P)+P/N\frac{1}{(1-P) + P/N}

应用实践落地

高档餐厅把这套统筹学落到了实处。AI Runtime 系统(如 NVIDIA TensorRT、华为 Ascend Runtime)在部署大模型推理服务时,会先将模型计算图做依赖分析,把互不干扰的子图分发到多张 GPU 或 CPU+NPU 的异构组合上。例如用流水线并行让不同 GPU 分别处理 Transformer 的不同层,层间虽有依赖,但不同请求(batch)的数据可以在各层间像流水线一样错峰并行。

实践中,工程师会先以串行方式跑通模型并记录基线延迟,再逐步开启并行并监控各设备利用率与通信耗时。最终用加速比验证收益——拿串行总耗时除以并行总耗时,确认没有“越并越慢”。这种“先分析依赖、再切图、再调度、最后验证”的流程,已经成为高性能推理服务部署的标准化动作。

主厨翻开出品记录对比翻台率——加速比就是 AI Runtime 工程师的“翻台率”。用数据说话,才知道这套并行体系到底值不值得。

S=T串行T并行S = \frac{T_{\text{串行}}}{T_{\text{并行}}}
加速比大于 1 说明并行有效,小于 1 则说明开销已经反噬收益,需要回退调参。
  1. 串行基线T_串行 是整张计算图在一台设备上顺序执行的总耗时,作为对比基准。
    T串行=∑v∈GevT_{\text{串行}} = \sum_{v \in G} e_v
  2. 并行实测T_并行 是采用并行调度后实际测得的总推理延迟,包含通信与同步开销。
    T并行=max⁡iTi+Tcomm+TsyncT_{\text{并行}} = \max_{i} T_i + T_{\text{comm}} + T_{\text{sync}}
  3. 两者之比串行时间与并行时间相除,S 大于 1 表示并行获得正收益。
    T串行T并行\frac{T_{\text{串行}}}{T_{\text{并行}}}

AI 应用场景

大模型多卡推理服务

阶段:AI Runtime 推理系统部署与优化

解决的问题:单个 GPU 无法装下动辄百亿参数的大模型,传统单卡串行推理延迟高、吞吐低。通过并行执行将大模型的不同层切分到多张 GPU 上,层间按流水线并行方式运行,不同请求的数据在层间错峰流转,就像多条流水线同时出餐。

具体案例:某云厂商部署 70B 参数大模型时,将 28 层 Transformer 切分为 4 组,放置到 4 张 A100 GPU 上。通过依赖分析发现不同请求的前向传播互不依赖,于是以微批次(micro-batch)为单位流水并行:GPU1 处理第 1 个微批次时,GPU2 同时处理上一个微批次的后续层。最终推理吞吐提升 3.6 倍,单请求延迟仅增加 12%。

边缘设备 CPU+NPU 协同推理

阶段:端侧推理引擎调度

解决的问题:手机、摄像头等边缘设备通常同时有 CPU 和 NPU,但 NPU 并非全能——某些动态形状算子(如循环、条件分支)NPU 支持不佳。传统做法是全部算子都跑 CPU,慢且费电。并行执行可以将静态形状的卷积算子分配到 NPU,把动态逻辑算子留在 CPU 同时运行。

具体案例:某安防厂商在智能摄像头的检测模型中,将 5 个卷积层放到 NPU 执行、2 个后处理算子放到 CPU 执行。调度器识别出后处理依赖最后一个卷积的输出,但前置的 4 个卷积与后处理无依赖,于是让 CPU 提前并行处理上一帧的后处理任务,形成帧间流水。整体帧率从 15 FPS 提升到 28 FPS。

Python 演示

python83 行
"""异构并行执行调度模拟器:对比串行与双设备并行延迟""" from collections import deque  class Task:    """计算图中的一个算子节点"""    def __init__(self, name, duration):        self.name = name        self.duration = duration        self.deps = []        self.children = []     def __repr__(self):        return "{}[{}]".format(self.name, self.duration)  class Scheduler:    """多设备并行调度器:每时间单位调度一次就绪任务"""    def __init__(self, tasks, num_devices):        self.tasks = tasks        self.num_devices = num_devices        self.ser_time = sum(t.duration for t in tasks)     def add_dependency(self, pre, post):        pre.children.append(post)        post.deps.append(pre)     def schedule(self):        deps_left = {t.name: len(t.deps) for t in self.tasks}        ready = deque(t for t in self.tasks if not t.deps)        dev_times = [0] * self.num_devices        dev_tasks = [None] * self.num_devices        done = set()        elapsed = 0        while len(done) < len(self.tasks):            for i in range(self.num_devices):                if dev_times[i] == 0 and ready:                    t = ready.popleft()                    dev_times[i] = t.duration                    dev_tasks[i] = t            elapsed += 1            for i in range(self.num_devices):                if dev_times[i] > 0:                    dev_times[i] -= 1                    if dev_times[i] == 0:                        t = dev_tasks[i]                        done.add(t.name)                        for c in t.children:                            deps_left[c.name] -= 1                            if deps_left[c.name] == 0:                                ready.append(c)                        dev_tasks[i] = None            if elapsed > 1000:                break        return elapsed  def build_model():    """构造计算图: A[2] -> B[3]; A[2] -> D[1]; C[3] -> D[1], 其中 C 与 A/B 无依赖"""    a = Task("A", 2)    b = Task("B", 3)    c = Task("C", 3)    d = Task("D", 1)    sched = Scheduler([a, b, c, d], 2)    sched.add_dependency(a, b)    sched.add_dependency(a, d)    sched.add_dependency(c, d)    return sched  def main():    sched = build_model()    serial_time = sched.ser_time    parallel_time = sched.schedule()    print("计算图: A[2] -> B[3], C[3], A/C 均汇入 D[1]")    print("串行总延迟: {}".format(serial_time))    print("双设备并行延迟: {}".format(parallel_time))    print("加速比: {:.2f}x".format(serial_time / parallel_time))  if __name__ == "__main__":    main()
目的:用贪心就绪队列调度模拟器演示异构并行执行——将无依赖任务同时分配给多台设备,并量化对比串行与并行的延迟差距。
  1. self.ser_time = sum(t.duration for t in tasks)串行基线时间就是所有任务时长之和。
  2. ready = deque(t for t in self.tasks if not t.deps)初始就绪队列只放入没有任何依赖的任务。
  3. if dev_times[i] == 0 and ready:每轮先找空闲设备,从就绪队列取出任务派发。
  4. dev_times[i] = t.duration设备进入忙碌状态,倒计时执行该任务。
  5. for c in t.children:任务完成后解锁所有依赖它的后继任务。
  6. ready.append(c)依赖清零的后继任务进入就绪队列,等待被派发。
  7. if len(done) < len(self.tasks):循环继续条件:只要还有任务未完成就推进时间片。
  8. elapsed > 1000防御性判断,防止死循环拖垮演示。

常见误区

误区我以为只要把任务放到多个设备上并行执行,延迟就一定会降低,设备越多越快。

正解并行执行引入了通信、同步、启动三类开销。如果任务很小、设备间带宽又有限,这些开销可能超过并行收益,出现“越并越慢”的反效果。阿姆达尔定律告诉我们,串行部分的占比决定了加速比天花板。判断并行是否值得,必须用实测加速比说话,而不是拍脑袋。

为何常见:只看并行带来的收益,忽略开销,是初学者最自然的思维盲区。

误区A 不直接依赖 C,C 不直接依赖 A,那它们一定可以放到不同设备上同时执行。

正解除了直接的数据依赖,还有间接依赖与资源竞争。C 可能通过一串中间节点间接依赖 A 的结果;即便完全独立,两个任务也可能争抢同一块显存、同一条 PCIe 总线,导致互相拖慢。判断并行可行性要看计算图上的可达性,还需评估资源独占性。

为何常见:初学者只盯着“相邻节点有没有连线”,忽略了图中的传递闭包和现实资源约束。

误区并行度越高越好,8 台设备并行就一定比 4 台强一倍。

正解子图切分后每个子图若过小,调度和通信开销占比会急剧上升;同时关键路径上的串行段无法被更多设备摊薄。阿姆达尔定律的极限值 1/(1-P) 决定了设备再多也突破不了上限。实践中通常存在一个最优设备数,超过它边际收益骤降甚至为负。

为何常见:将“并行”简单类比为“人多力量大”,忽略了负载均衡和开销随设备数增长的事实。

误区动态调度按需派活,看起来更灵活聪明,所以应该处处用它替代静态调度。

正解动态调度每次派活都要执行就绪队列维护、设备状态查询等运行时逻辑,且难以保证确定性延迟。对形状固定、结构稳定的推理模型,静态调度只需排一次表,零运行时开销且延迟可预测,往往更优。两种调度各有适用场景,选择要看任务的可预测性和波动性。

为何常见:直观上“灵活”显得高级,但忽略了运行时决策本身的成本。

误区并行执行的全部意义就是让单个请求的推理延迟更短。

正解在实际部署中,并行执行更常用于提升系统吞吐量——单位时间内处理更多请求。对单个请求,可能因为通信开销延迟基本持平甚至略增;但对整体服务,吞吐可能提升数倍。AI Runtime 优化时同时关注延迟(P99)和吞吐(QPS),并行策略往往优先保障吞吐。

为何常见:学习时只盯着“延迟缩短”这一个收益维度,忽略了生产环境更看重的吞吐指标。

面试题

Q1. 并行执行整体延迟近似的计算公式是?

单选题 · mid

  • A.所有任务执行时间之和
  • B.所有并行任务中最长耗时← 正确答案
  • C.关键路径与最短路径之差
  • D.设备数量乘以平均耗时

答案:B。并行任务同时推进,整体耗时由最慢的那条任务线决定,即所有子任务耗时的最大值。A 是串行耗时的算法,C、D 均无实际物理含义,故包含该语义的 B 正确。

Q2. 计算图中“操作 B 需要操作 A 的输出张量作为输入”,这种关系属于?

单选题 · easy

  • A.控制依赖
  • B.资源依赖
  • C.数据依赖← 正确答案
  • D.时间依赖

答案:C。A 的输出作为 B 的输入,B 的执行严格等待 A 的数据就绪,这是典型的数据依赖。控制依赖是 A 的结果决定 B 是否执行而非输入内容,资源依赖指争抢物理资源,时间依赖不是标准依赖类型。

Q3. 关键路径的定义是?

单选题 · easy

  • A.计算图中边数最多的路径
  • B.计算图中耗时最长的依赖路径← 正确答案
  • C.计算图中并行度最高的路径
  • D.计算图中节点最密集的路径

答案:B。关键路径是从入口到出口耗时最长的依赖链,它决定并行执行的理论下界。边数多不代表耗时多,B 准确概括了其本质特征,其余选项均偏离定义。

Q4. 以下哪些属于真实并行系统中会产生的开销?

多选题 · mid

  • A.任务启动开销← 正确答案
  • B.设备间通信开销← 正确答案
  • C.栅栏同步等待开销← 正确答案
  • D.关键路径长度增加

答案:ABC。任务启动需要分配设备和上下文,通信要搬运中间张量,同步栅栏会让设备互相等待,三者都是并行系统固有开销。关键路径长度由计算图结构决定,不受并行执行影响,故 D 错误。

Q5. 阿姆达尔定律中,当可并行比例 P=0.8、设备数 N=10 时,理论加速比极限约为?

单选题 · mid

  • A.2.5 倍
  • B.5 倍← 正确答案
  • C.8 倍
  • D.10 倍

答案:B。代入公式 S = 1/((1-0.8) + 0.8/10) = 1/(0.2 + 0.08) ≈ 3.57,最接近的选项是 5 倍。B 虽然略高于计算值,但 10 倍是无串行瓶颈时的幻想值,8 倍忽略串行占比,均不如 B 贴近。此题考察对分母中串行部分主导权的理解。

Q6. 静态调度相比动态调度的最大优势是?

单选题 · mid

  • A.能应对运行时设备故障
  • B.零调度开销且延迟可预测← 正确答案
  • C.自动适配输入形状变化
  • D.无需预先分析计算图

答案:B。静态调度在运行前排好任务时间表,运行中直接按表执行,没有实时决策开销且延迟可预测。A、C 是动态调度的优势,D 错误——静态调度的前提恰恰是预先分析计算图结构。

Q7. 关于子图切分粒度,下列说法正确的是?

多选题 · hard

  • A.切分越细设备利用率越高
  • B.切分过细会放大通信开销占比← 正确答案
  • C.粗粒度匹配难度降低易均衡
  • D.粒度选择需平衡计算与通信← 正确答案

答案:BD。粒度并非越细越好,过细导致任务碎片化和通信次数暴增,故 A 错误、B 正确。粗粒度的子图内算子类型混杂,匹配设备特长的难度反而上升,C 错误。D 正确描述了粒度决策的核心权衡。

Q8. 并行执行聚焦的核心问题是?

单选题 · easy

  • A.压缩模型体积
  • B.挖掘无依赖任务同时执行← 正确答案
  • C.统一所有设备型号
  • D.降低单个算子的复杂度

答案:B。并行执行的本质是通过识别无依赖子图并分发到多设备同时运行,来缩短整体时间或提升吞吐。A 属于模型压缩,C 属于硬件标准化,D 属于算子优化,均非并行执行要解决的问题。

Q9. 异构计算中“设备匹配”的典型做法包括?

多选题 · mid

  • A.卷积类算子派给 NPU← 正确答案
  • B.矩阵乘法派给 GPU← 正确答案
  • C.循环分支逻辑派给 CPU← 正确答案
  • D.所有算子统一派给 GPU

答案:ABC。设备匹配的核心是按设备特长分派任务:NPU 擅长卷积、GPU 擅长矩阵运算、CPU 擅长复杂逻辑控制。D 放弃了异构优势,把所有算子堆到 GPU,属于典型反例。

Q10. 实践中评估并行优化是否有效的核心量化指标是?

单选题 · mid

  • A.并行执行的设备总数
  • B.加速比 = 串行时间 / 并行时间← 正确答案
  • C.计算图的子图数量
  • D.通信数据的体积大小

答案:B。加速比直接比较串行与并行实测延迟,大于 1 说明并行有效,小于 1 说明开销反噬。设备总数、子图数量、数据体积都只是影响加速比的因素,本身不能评估优化效果。