想象你是一家国际物流调度中心的调度员。大屏幕上,今天的货物列表密密麻麻——有的需要冷藏(卷积算子),有的怕摔(归一化算子),有的是加急件(矩阵乘法算子)。你手中握着几份承运合同:公路卡车(CPU 车队)哪儿都能去,但速度平平;航空货运(CUDA 车队)快得多,但需要起降条件;特快专运(TensorRT 车队)速度惊人,却只收特定包裹。你的工作不是自己开车,而是为每一件包裹选对车队,并保证整个运输网络有序运转。在 AI 推理世界里,这个调度中心叫 ONNX Runtime,而这些承运车队,就是执行提供者(Execution Provider,EP)。本文带你从“调度台”视角,看懂 EP 架构如何落地、如何协同、如何调优。

岗位关联

理解 EP 架构,是 AI 从业者从“能跑模型”走向“跑得快且稳”的必经之路——它决定了你的模型最终在哪块硬件上、以什么效率被执行。

目录
  1. 可视化演示
  2. EP 基本概念
  3. EP 分类体系
  4. EP 执行流程
  5. 图优化与分区
  6. 多 EP 协同
  7. 性能优化实践
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示ONNX Runtime算子通过能力查询路由到不同EP、CPU/CUDA/TensorRT三种EP类型性能对比及不支持算子回退机制三种核心模式,直观呈现AI Runtime执行架构。

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

算子EP路由分配 能力查询Capability Query检测EP对算子支持情况 TensorRT优先尝试CUDA次之CPU最后兜底 Assign(op) = first EP in [TRT, CUDA, CPU] where Capability(op) = true 按优先级分配算子到最优执行后端

EP 基本概念

你手上第一份合同,是和“通用公路运输”签的框架协议。协议不规定卡车用什么发动机,只约定:调度中心把包裹送到 1 号门,车队负责送达并签收回执。在 AI 里,这份框架协议就是执行提供者接口。它不关心引擎内部是 CUDA 汇编还是 NPU 指令,只管“能不能接单、怎么接单、怎么执行”。

ONNX Runtime 本身不直接访问 GPU 寄存器,也不算矩阵乘法,它只提供一个可插拔的“运力接口”。任何一个执行提供者,只要实现这套接口,就能进入调度中心的候选名单。接口约定三件事:你能处理哪些算子(支持表)、你如何分配显存(设备分配器)、你如何执行内核(计算调度)。这三件事立好,其余细节全由 EP 自己决定。

在 Python 侧,你甚至不会直接碰到接口,只需在创建会话时给出承运商名单:providers=['CUDAExecutionProvider','CPUExecutionProvider']。ONNX Runtime 会按名单顺序创建 EP 对象,并挂到会话的候选链上。每个 EP 向分区器提交自己的支持算子集;初始化时,分区器拿计算图逐个核对,把节点分派给合适的 EP。整个过程中,Runtime 只关心“哪个 EP 能执行”这个布尔答案。

这份“接口优先、实现靠后”的合同精神,正是 EP 架构的灵魂:作为 AI 工程师,你只需在正确的地方签一个字(配置 EP 列表),剩下的车队调度、内核匹配全部交给调度中心,这就是工程解耦的威力。

F:(Graph, EPs)⟼ExecutableSession\mathcal{F}: (\text{Graph},\ \mathrm{EPs}) \longmapsto \text{ExecutableSession}
EPs 候选序列与计算图一起,被“编译”成一个可执行的会话对象——这正是会话创建时发生的事。
  1. Graph 计算图所有待执行算子的集合,每个算子就是一件待配送的包裹。
    Graph={n1,n2,…,nm}\text{Graph} = \{n_1, n_2, \ldots, n_m\}
  2. EPs 候选序列用户传入的执行提供者列表,顺序即派单优先级。
    EPs=[EP1,EP2,…,EPk]\mathrm{EPs} = [\mathrm{EP}_{1}, \mathrm{EP}_{2}, \ldots, \mathrm{EP}_{k}]
  3. 会话映射ONNX Runtime 在图上完成优化、分区、内核选择后,产出可运行会话。
    ExecutableSession=ORT(Graph,EPs)\text{ExecutableSession} = \mathrm{ORT}(\text{Graph}, \mathrm{EPs})

EP 分类体系

承运商远不止一家。公路卡车队(CPU EP)什么货都接,是万能备胎;航空货运队(CUDA EP)快但需要专门机场;特快专运队(TensorRT EP)更快,但只收固定的几类包裹;还有特种车队(NPU 专用 EP),只服务特定客户的冷门货物。

CPU EP 是 ORT 打包自带的“通用车队”,覆盖全部 ONNX 算子,任何设备上都能运行,是最后的保底。CUDA EP 为 NVIDIA GPU 提供大量手写内核,覆盖常见算子,是加速主力。TensorRT EP 把子图交给 TensorRT 引擎做层融合、低精度量化,适合形状固定的重模型。此外还有 OpenVINO、DirectML、QNN、CoreML 等专用 EP,分别瞄准不同厂商硬件。想自建车队时,你可以实现自定义 EP 并注册进 ORT,相当于自己创办一支小车队。

每支车队都有自己的“路单”,即支持算子表。分区器在派单前一定会翻路单:路单上没有的算子,车队再强也不能接。这个“支持度”判断是 EP 分类体系中的核心规则,也是后续所有分区决策的基础。

车队好不好,先看路单;路单里没有的包裹,说破天也不能接单。理解这一点,你就能解释为什么同一个模型在不同 EP 上性能天差地别——不是车队偷懒,而是它的“营业范围”本身不同。

support(op,EP)={1,EP 能执行该算子0,EP 不能执行该算子\mathrm{support}(\mathrm{op}, \mathrm{EP}) = \begin{cases} 1, & \mathrm{EP}\ \text{能执行该算子} \\ 0, & \mathrm{EP}\ \text{不能执行该算子} \end{cases}
支持度函数是一张二元判决表:EP 能执行返回 1,不能执行返回 0。它是分区器的“航班时刻表”。
  1. 算子 op计算图中的一个待执行节点,如卷积、激活、矩阵乘法。
    op∈{Conv,Relu,Gemm,… }\mathrm{op} \in \{\text{Conv}, \text{Relu}, \text{Gemm}, \dots\}
  2. 提供者 EP每支车队维护自己的算子支持表,相当于运输路单。
    EP∈{CPUEP,CUDAEP,TRTEP}\mathrm{EP} \in \{\text{CPUEP}, \text{CUDAEP}, \text{TRTEP}\}
  3. 取值判定路单上有的算子返回 1,分区器才可能把节点派给它。
    support(Conv,TRTEP)=1\mathrm{support}(\text{Conv}, \text{TRTEP}) = 1

EP 执行流程

包裹从收货到送达,要经历五道工序:收货登记、路线规划、分拣贴标、装车发运、签收回执。ONNX Runtime 的一次推理也遵循同样的流水线:加载模型、图优化、图分区、内核匹配、执行产出。

加载模型把 ONNX 文件解析成内存中的计算图;图优化器对图做等价改写,让结构更紧凑;图分区器把每个节点分配给第一个支持它的 EP,形成若干子图;内核匹配阶段,每个子图在自己的 EP 内核注册表里查找执行函数;执行时,各 EP 使用自己的线程池和内存分配器完成计算。内核注册表是每支车队内部的“装备清单”,以算子类型、算子版本、设备类型为索引,快速定位执行内核。

张量布局也在这里发挥作用:CPU EP 通常用 NCHW 连续内存,GPU EP 可能偏好 NHWC 或半精度排列。跨设备传递数据时,必须在边界插入拷贝节点,让数据从一种布局“翻译”成另一种布局。这也是为什么图分区越碎,性能越差——频繁翻译比运输本身更费时。

每次推理,就是调度中心完整运转一遍流水线:接单、规划、分拣、发运、签收。理解这条流水线的每个工位,你才能在性能出现问题时,一眼看出是哪个环节掉了链子。

Session→加载Graph→优化子图集→内核执行\text{Session} \xrightarrow{\text{加载}} \text{Graph} \xrightarrow{\text{优化}} \text{子图集} \xrightarrow{\text{内核}} \text{执行}
一次会话的生命周期是五段式流水线:加载、优化、分区、内核匹配、执行。每次 run() 调用都在流水线末端重复执行。
  1. 加载模型从文件或内存读取 ONNX 格式计算图,得到待规划的包裹清单。
    Graph←ONNX 模型\text{Graph} \leftarrow \text{ONNX 模型}
  2. 优化与分区优化器先做等价改写,分区器再把节点归入各 EP 子图。
    子图集={G1,G2,…,Gp}\text{子图集} = \{G_{1}, G_{2}, \dots, G_{p}\}
  3. 内核调度与执行每个子图找到第一个支持它的 EP 内核,执行并产出张量。
    Run(Session)→输出张量\text{Run}(\text{Session}) \to \text{输出张量}

图优化与分区

调度台不是把包裹原样转运,而是先做合包——把两件套好的货物捆成一件,减少搬运次数。图优化器做的就是这件事:常量折叠把训练完就不变的权重提前算好;算子融合把相邻的计算合并成一个;冗余消除则直接删掉不影响结果的节点。

常用 Pass 包括常量折叠(Constant Folding)、Conv+BN 融合(BatchNorm Fusion)、Relu 融合等。这些优化在分区之前完成,让计算图变得更紧凑。真正分区时,图分割器按支持度函数给每个节点打上 EP 颜色标签:同色节点连成一段子图,最终整张图被切成若干“颜色区域”。不同颜色区域之间的边,需要插入 Copy 节点,把张量从一种设备的地址空间搬到另一种设备。

分区策略直接决定性能:如果颜色区域切得太碎,拷贝节点就会成倍增加;如果切得整,每个 EP 都能在自己设备上连续执行大片计算。因此 ORT 的分区器会尽量把连续且支持度相同的节点聚在一起,仅在不得不跨设备的边界才允许“断裂”。

合并小包裹、规划颜色区域、在边界安排转运——这就是调度台的两把刷子。作为工程师,你可以通过调整 graph_optimization_level 控制优化强度,也可以看 profiling 报告评估分区质量,这些都是实打实的调优手段。

Conv(BN(x))≈ConvBN~(x)\text{Conv}(\text{BN}(x)) \approx \widetilde{\text{ConvBN}}(x)
算子是计算图上的等价变换:把卷积和批归一化两个节点合并成一个融合算子,不仅减少一次中间读写,还少一次内存往返。
  1. 卷积运算提取特征的卷积算子,是包裹中的“重货”。
    y=Conv(x)y = \text{Conv}(x)
  2. 批归一化对卷积结果做归一化,让数值分布稳定,像给货物加防震包装。
    z=BN(y)z = \text{BN}(y)
  3. 等价融合合并成一次计算后,中间结果不再落回内存,减少一次搬运。
    z≈ConvBN~(x)z \approx \widetilde{\text{ConvBN}}(x)

多 EP 协同

多支车队联合作战时,调度中心采用“谁合适谁先上”的派单逻辑:把订单先给最快的专线,专线接不了就转给次快线路,最后还有一家永不拒单的公路车队兜底。这套机制在 ORT 里就是 EP 优先级链。

providers 列表的顺序就是优先级顺序:['TensorrtExecutionProvider','CUDAExecutionProvider','CPUExecutionProvider'] 表示先让 TensorRT 挑活,挑剩下的给 CUDA,最后 CPU 全收。分区器对每个节点从高到低询问 support 函数,找到第一个能执行的 EP 后停止。这个回退机制保证了任何图至少能在 CPU 上跑完——模型永远不会因为“没有合适的承运商”而无法推理。

内存协同同样关键。同一设备内,EP 会复用内存池(arena)降低分配开销;跨设备边界则必须真实拷贝。IO Binding 允许外部直接绑定输入输出的设备内存,让数据不经过 CPU 中转。此外,多个 EP 可以并发执行不同子图,前提是子图之间没有数据依赖——这就像多支车队同时发往不同城市,互不干扰。

联合运输的精髓是“优先派单 + 层层回退”:既能吃到专线的高速度,又永远不担心没车可用。实际部署时,保留 CPU EP 作为兜底是黄金法则,它会救你于各种莫名的加载失败和算子缺失中。

j∗(ni)=min⁡{j:support(ni,EPj)=1}j^{*}(n_i) = \min\{ j : \mathrm{support}(n_i, \mathrm{EP}_j) = 1 \}
分区器给节点 n_i 选 EP 时,从优先级最高的 j=1 开始逐个检查,取第一个支持该节点的 EP 下标。
  1. 节点 n_i分区器当前正在处理的算子节点,也就是待派发的包裹。
    ni∈Graphn_i \in \text{Graph}
  2. 可执行条件第 j 个 EP 的支持表必须包含该算子。
    support(ni,EPj)=1\mathrm{support}(n_i, \mathrm{EP}_j) = 1
  3. 取最小值从高优先级到低优先级找到第一个可用 EP,编号最小者胜出。
    j∗=min⁡{j:… }j^{*} = \min\{j : \dots\}

性能优化实践

考核一支车队,不能听广告,要拉出去跑三趟:记录装载时间、路途耗时、到货稳定性。AI 工程中的 EP 选型也一样,必须用 profiling 和基准测试说话。

第一步是支持度评估。开启 profiling 后,生成的 trace 文件会显示每个算子实际落到哪个 EP;如果发现有节点回退到 CPU,就说明加速 EP 的覆盖不全。第二步是延迟剖析。enable_profiling=True 会产生 Chrome tracing 文件,逐算子观察耗时,定位瓶颈是某个算子、某次拷贝还是内存分配。第三步是对比实验。同一模型分别在 CPU EP 与目标加速 EP 上跑多轮,取 P50/P95 延迟计算加速比,同时检查数值差异是否在容差范围内。

版本适配也是常见的坑:ONNX Runtime、CUDA、TensorRT 三者版本必须互相兼容,否则 EP 加载直接失败。自定义 EP 则需要通过动态库方式注册,并验证各版本的 ABI 兼容性。工程实践建议:永远保留 CPU EP 兜底,永远用真实请求分布做压测,永远把 profiling 报告存档备查。

EP 选择不是玄学,而是工程度量:支持度看覆盖、剖析看瓶颈、对比看数据。把这套方法论沉淀成团队的部署流程,你就能在每次新模型上线时,快速找到最合适的“运输专线”。

speedup=TCPU EPT目标 EP\text{speedup} = \frac{T_{\mathrm{CPU\,EP}}}{T_{\mathrm{目标\,EP}}}
加速比是目标 EP 决策的核心指标:大于 1 说明加速 EP 更快,还需结合 P95 稳定性一起判断是否值得切换。
  1. CPU 基线耗时纯 CPU EP 下同一模型的中位延迟,是性能考核的基准线。
    TCPU EPT_{\mathrm{CPU\,EP}}
  2. 目标 EP 耗时切换后目标加速 EP 的中位延迟。
    T目标 EPT_{\mathrm{目标\,EP}}
  3. 加速比计算比值大于 1 才有切换价值,同时还要看延迟抖动是否可控。
    speedup>1\text{speedup} > 1

AI 应用场景

BERT 在线推理的 EP 选型

阶段:模型部署与推理优化

解决的问题:传统只配 CPU EP 时 QPS 上不去、延迟抖动明显;GPU 裸跑又可能浪费显存。基于 EP 架构,在同一 ORT 会话中配置 CUDA EP 与 CPU 兜底,用 profiling 定位不支持算子,再用 IO Binding 管理设备内存,在加速与回退开销之间取得平衡。

具体案例:推荐系统团队上线 BERT 向量化服务。先用 CPU EP 测得 P99 延迟 45ms;加入 CUDA EP 后降到 22ms。开启 profiling 发现仍有 7% 节点回退 CPU,跨设备拷贝频繁。随后把输入固定成长序列并切换 TensorRT EP 候选,P99 稳定在 18ms,QPS 提升 2.4 倍。整个过程就是“先测基线—再看回退—再换候选”的 EP 调优循环。

边缘摄像头上的 NPU/CPU 混合执行

阶段:端侧推理部署

解决的问题:边缘设备功耗受限,纯 CPU 跑不满实时帧率;NPU 省电但算子支持有限。利用多 EP 协同机制,让 QNN 或 OpenVINO EP 承接卷积类重算子,CPU EP 兜底后处理,实现“能省则省、不能省兜底”的混合执行。

具体案例:智能摄像头人脸检测模型,QNN EP 承接主干卷积算子,RoiAlign 和非极大值抑制留在 CPU。跨 EP 边界只出现两处拷贝,通过 IO Binding 让边界张量零拷贝复用。实测帧率从 12fps 提升到 30fps,整机功耗降低 40%。这正是 EP 架构在资源受限场景下的典型胜利。

Python 演示

python81 行
import ioimport time import numpy as npimport onnxfrom onnx import TensorProto, helperimport onnxruntime as ort  def build_model_bytes():    """构造一个小型卷积网络: Conv -> BatchNorm -> Relu -> Pool -> Gemm"""    w1 = helper.make_tensor("w1", TensorProto.FLOAT, [4, 1, 3, 3],                            np.random.randn(4, 1, 3, 3).astype(np.float32).reshape(-1).tolist())    b1 = helper.make_tensor("b1", TensorProto.FLOAT, [4], np.zeros(4, dtype=np.float32).tolist())    scale = helper.make_tensor("scale", TensorProto.FLOAT, [4], np.ones(4, dtype=np.float32).tolist())    bias = helper.make_tensor("bias", TensorProto.FLOAT, [4], np.zeros(4, dtype=np.float32).tolist())    mean = helper.make_tensor("mean", TensorProto.FLOAT, [4], np.zeros(4, dtype=np.float32).tolist())    var = helper.make_tensor("var", TensorProto.FLOAT, [4], np.ones(4, dtype=np.float32).tolist())    w2 = helper.make_tensor("w2", TensorProto.FLOAT, [4, 4],                            np.random.randn(4, 4).astype(np.float32).reshape(-1).tolist())    b2 = helper.make_tensor("b2", TensorProto.FLOAT, [4], np.zeros(4, dtype=np.float32).tolist())     nodes = [        helper.make_node("Conv", ["X", "w1", "b1"], ["c1"], kernel_shape=[3, 3], pads=[1, 1, 1, 1]),        helper.make_node("BatchNormalization", ["c1", "scale", "bias", "mean", "var"], ["bn1"]),        helper.make_node("Relu", ["bn1"], ["r1"]),        helper.make_node("GlobalAveragePool", ["r1"], ["g1"]),        helper.make_node("Flatten", ["g1"], ["f1"], axis=1),        helper.make_node("Gemm", ["f1", "w2", "b2"], ["Y"]),    ]    graph = helper.make_graph(        nodes, "tiny_net",        [helper.make_tensor_value_info("X", TensorProto.FLOAT, [1, 1, 8, 8])],        [helper.make_tensor_value_info("Y", TensorProto.FLOAT, [1, 4])],        [w1, b1, scale, bias, mean, var, w2, b2],    )    model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])    buf = io.BytesIO()    onnx.save(model, buf)    return buf.getvalue()  def bench(model_bytes, providers):    opts = ort.SessionOptions()    opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL    sess = ort.InferenceSession(model_bytes, opts, providers=providers)    data = np.random.randn(1, 1, 8, 8).astype(np.float32)    for _ in range(3):        sess.run(None, {"X": data})    times = []    for _ in range(20):        t0 = time.perf_counter()        sess.run(None, {"X": data})        times.append(time.perf_counter() - t0)    return np.median(times), " + ".join(sess.get_providers())  def main():    model_bytes = build_model_bytes()    available = set(ort.get_available_providers())    print("机器上可用的 EP 列表:", available)     cpu_t, cpu_info = bench(model_bytes, ["CPUExecutionProvider"])    print(f"CPU EP 实际激活: {cpu_info}, 中位耗时 {cpu_t * 1000:.2f} ms")     candidates = ["TensorrtExecutionProvider", "CUDAExecutionProvider",                  "OpenVINOExecutionProvider", "DmlExecutionProvider"]    accel = [p for p in candidates if p in available]    if not accel:        print("没有检测到加速 EP, 只有 CPU 兜底线路可用。")        return     providers = accel[:1] + ["CPUExecutionProvider"]    opt_t, opt_info = bench(model_bytes, providers)    speedup = cpu_t / opt_t    print(f"加速 EP 实际激活: {opt_info}, 中位耗时 {opt_t * 1000:.2f} ms")    print(f"相对 CPU 基线的加速比: {speedup:.2f}x")  if __name__ == "__main__":    main()
目的:演示如何在 ONNX Runtime 中指定 EP 候选链、查看实际激活的 EP,并对比 CPU 基线与加速 EP 的延迟和加速比。
  1. ort.get_available_providers()查询当前机器上有哪些 EP 可用,相当于查看有哪些车队在营业。
  2. ort.InferenceSession(model_bytes, opts, providers=providers)带着候选 EP 列表创建会话,providers 顺序就是派单优先级。
  3. opts.graph_optimization_level = ORT_ENABLE_ALL开启全部图优化 Pass,让常量折叠和算子融合先起作用。
  4. sess.get_providers()返回实际激活的 EP 列表,确认候选名单里真正上岗的是谁。
  5. accel[:1] + ["CPUExecutionProvider"]只选第一个合适的加速 EP 配 CPU 兜底,避免多 EP 叠加开销。
  6. np.median(times)用中位耗时而非平均值,防止偶发尖峰扭曲真实性能判断。
  7. io.BytesIO()把 ONNX 模型序列化到内存缓冲区,绕开磁盘文件,适合快速实验。

常见误区

误区“我把能装的 EP 全部加进 providers 列表,模型肯定跑得飞快。” 初学者常把 EP 当成性能开关,认为加得越多算力越强。

正解EP 不是叠加加速,而是按优先级排队。分区器把每个节点交给第一个能执行的 EP,其他 EP 只是后备。真正执行时,绝大多数节点只落在一支 EP 上;多个 EP 还意味着多套内存分配器和线程池,边界拷贝反而拖慢速度。正确做法是精选一个加速 EP 加 CPU 兜底。

为何常见:把 EP 类比成“多张显卡叠加”,忽视了 ORT 采用串行候选机制,而不是并行叠加。

误区“TensorRT 是 NVIDIA 官方优化引擎,任何模型上它都比 CUDA EP 快,直接无脑选它。”

正解TensorRT EP 支持的算子集合更小。模型一旦有算子不被支持,图中就会插入多个回退到 CPU 的子图,跨设备拷贝开销可能完全抵消融合优化。对于小模型、动态形状模型,TensorRT EP 的优势往往发挥不出来,甚至更慢。

为何常见:拿公开 benchmark 中精心优化模型的结论当普适规律,忽略了支持度覆盖这个前提。

误区“providers 里写了 CUDAExecutionProvider,所有算子都应该在 GPU 上执行,CPU 完全不用参与。”

正解providers 只是候选名单,不是强制指派。分区器要求每个算子必须在 EP 支持表中有记录,不支持的算子会自动顺延到下一个 EP,最终落到 CPU。要确认实际分布,必须开启 profiling 查看算子级分配记录,或检查推理日志。

为何常见:把 EP 当成全局开关,忽略了图分区器的自治逻辑和“按节点决策”的工作方式。

误区“同一个模型,换 EP 只是换了计算位置,输出应该完全一样,直接切换就行。”

正解不同 EP 使用不同内核实现,卷积算法、浮点累加顺序、算子融合方式都可能不同,输出存在微小数值差异。在验收测试中应设定容差范围,例如逐元素相对误差 1e-4,不能把旧输出当金标准直接对比。

为何常见:认为数学等价就是数值等价,低估了浮点实现的多样性,忽视了 GPU 并行归约顺序带来的求和误差。

面试题

Q1. ONNX Runtime 中“执行提供者 EP”主要解决什么问题?

单选题 · easy

  • A.让模型图能在多种计算后端上执行← 正确答案
  • B.把 ONNX 模型直接编译成汇编语言
  • C.只负责训练阶段的模型加速优化
  • D.替代 Python 解释器独立执行代码

答案:A。EP 的本质是后端执行抽象,让同一个计算图可以跑在 CPU、GPU、NPU 上。B 混淆了编译器与执行提供者,C 错在“训练”,D 与 Python 解释器无关。

Q2. 以下哪个 EP 在 ONNX Runtime 中通常作为默认兜底存在?

单选题 · easy

  • A.使用 TensorRT 专用加速引擎
  • B.使用 CUDA 内核做通用加速
  • C.使用 CPU 实现全算子覆盖← 正确答案
  • D.使用 OpenVINO 做端侧加速

答案:C。CPU EP 由 ORT 内置,支持全部 ONNX 算子,任何设备上都能运行,因此是回退机制的最底层兜底。TensorRT、CUDA、OpenVINO 都是可选加速 EP。

Q3. 创建会话时 providers=["CUDAExecutionProvider","CPUExecutionProvider"] 是什么含义?

单选题 · mid

  • A.CUDA 优先执行,不支持的算子回退 CPU← 正确答案
  • B.CUDA 仅在 CPU 完全故障时才启用
  • C.两个 EP 并行执行计算图中的全部节点
  • D.两个 EP 按随机顺序轮流执行节点

答案:A。providers 列表是优先级链,分区器先问 CUDA 能不能执行,不能就问 CPU。并行执行是错误的——每个节点只会交给一个 EP;随机轮流也不成立。

Q4. 跨 EP 图边界通常会插入什么节点?

单选题 · easy

  • A.负责跨设备搬运数据的拷贝节点← 正确答案
  • B.向设备分配器申请内存的节点
  • C.把模型权重持久化到磁盘的节点
  • D.对模型输出做校准对齐的节点

答案:A。当图被切成分属不同 EP 的子图时,边界张量需要从一种设备搬到另一种设备,这个搬运动作由 Copy(拷贝)节点完成。其他选项描述的不是分区边界行为。

Q5. TensorRT EP 相对 CUDA EP 的核心特点是什么?

单选题 · mid

  • A.支持的算子子集更小但融合优化更强← 正确答案
  • B.支持全部算子且无跨设备拷贝开销
  • C.运行过程完全不依赖 NVIDIA 显卡
  • D.只能用于训练阶段的模型加速

答案:A。TensorRT EP 牺牲算子覆盖广度换取深度优化,通过层融合、低精度计算提升性能。它不支持全部算子,依赖 NVIDIA 硬件,且用于推理而非训练。

Q6. 下列关于 EP 的说法,哪些是正确的?

多选题 · mid

  • A.EP 的顺序会影响计算图的节点归属← 正确答案
  • B.不同 EP 的数值结果可能存在微小差异← 正确答案
  • C.EP 只存在于 NVIDIA 的 GPU 设备之上
  • D.自定义 EP 需要实现统一运行时接口← 正确答案

答案:ABD。EP 优先级链决定分区归属;不同 EP 内核实现不同,浮点结果有微小差异;自定义 EP 必须实现接口并注册。EP 并不只属于 NVIDIA,OpenVINO、QNN 等都属于 EP。

Q7. 对一个极小模型,CUDA EP 反而可能比 CPU EP 更慢,最常见原因是?

单选题 · mid

  • A.图优化器强制关闭了所有算子融合
  • B.内核启动与内存拷贝开销占比过大← 正确答案
  • C.CPU 与 GPU 的时钟频率差距不够明显
  • D.模型数值精度要求过高导致无法加速

答案:B。GPU 擅长大规模并行,但内核启动有固定开销,小模型计算量小,启动和数据传输反而成为主成本。这与优化器开关、频率差、精度要求关系不大。

Q8. 工程中验证某个加速 EP 是否值得使用的合理步骤有哪些?

多选题 · mid

  • A.配置该 EP 加 CPU 兜底后做基准对比← 正确答案
  • B.开启 Profiling 观察算子级耗时分布← 正确答案
  • C.检查图中有多少节点回退到 CPU 执行← 正确答案
  • D.把机器上所有 EP 一次性全配置上

答案:ABC。正确流程是:配 EP 链、开 profiling、查回退节点、做基准对比。把全部 EP 都配置上会引入多套内存池和线程池开销,是常见反模式。

Q9. 图优化器在 ONNX Runtime 中作用在哪个阶段?

单选题 · easy

  • A.模型加载之后、EP 分区之前← 正确答案
  • B.EP 内核执行结束后的运行阶段
  • C.模型训练数据的预处理阶段
  • D.设备内存释放之后的清理阶段

答案:A。图优化器在会话初始化时对计算图做常量折叠、算子融合等 Pass,发生在分区之前。它不参与训练、运行和内存清理阶段。

Q10. 关于多 EP 协同与图分区,下列理解正确的有哪些?

多选题 · hard

  • A.同一个算子节点只会被分配给唯一的 EP← 正确答案
  • B.跨 EP 边界可能插入设备间拷贝节点← 正确答案
  • C.不支持的算子会继续留在这个 EP 执行
  • D.CPU EP 通常作为最终兜底的执行线路← 正确答案

答案:ABD。分区器为每个节点选第一个支持的 EP,节点唯一归属;跨设备边界要拷贝;CPU 是兜底。C 错误——不支持的算子会顺延到下一个 EP,而不是留在原地。