你请求的执行提供程序,未必就是你拿到的
一句话总结
执行提供程序(EP)是用来请求的,而不是有保障的。即使放进一个不存在的 EP,也 不会报错,而是静默回退,必须去问会话,才能知道它实际拿到了什么。
为什么这是个问题
明明在列表里写了要用加速器,实际上却是在用 CPU 运行,这种事经常发生。问题在于,那时什么事都不会发生。不抛异常,退出码也是 0。只有一行警告从标准错误里一闪而过,在收集日志的地方,这一行就被淹没了。
在这个实验环境里亲自测一下就是这样。把根本不存在的 CUDAExecutionProvider 放在列表最前面,会话照样能创建,推理也能进行。去问 session.get_providers(),返回的只有一个 CPUExecutionProvider。如果编造一个完全不存在的名字放进去,会出现“Unknown Provider Type”的提示,同样落回到 CPU 上运行。
所以规则只有一条:不要相信请求列表,要去问会话。部署日志里该留下的,不是请求的列表,而是会话返回的列表。
EP 决定什么
EP 不是用来选择“这个模型在哪里运行”的装置,而是决定 每个节点由谁来承担 的装置。执行器按顺序遍历请求列表,逐个询问各个 EP“这个节点你能承担吗”,只把说愿意承担的节点交给它。剩下的节点交给下一个 EP,最终没人承担的,就由 CPU 承担。
所以一个模型会被切成多个片段,在不同的地方运行。片段与片段之间要传递值。如果所支持的算子列表在计算图的顺序上不断被截断,片段数就会增加,传递的次数也随之增加。
지원: MatMul 만 조각 5개 · 넘김 4번
[MatMul] -> Add -> Relu -> [MatMul] -> Add
EP CPU CPU EP CPU
列表的 顺序就是优先级。写在前面的提供程序先被询问,并拿走它说愿意承担的节点。所以同样的两个提供程序,只换一下书写顺序,分配也可能不同。而且 CPU 即使不写,也会跟在最后——在这个环境里只写一个 AzureExecutionProvider 来创建会话,实际的列表就是它加上 CPUExecutionProvider 两个。因为不能留下没人承担的节点。请求的列表与实际的列表不同,是 正常工作而不是错误,原因也在这里。
由此引出一个重要的结论:同一个模型放入同样的输入,不同的机器也可能得出不同的数。片段切分方式不同,计算顺序就不同,而浮点数加法一旦顺序改变,结果就会有细微的差别。所以“在我们的笔记本上是对的”很难作为依据。
层层叠加之后,误差会怎样
量化误差在每一层都会新产生,前一层产生的误差会成为下一层的输入。所以 误差会累积。不过实际测一下就会发现,它并不是每一层均匀增长的。在这个实验里测五层模型时,各层边界的相对误差依次是 0.099、0.074、0.063、0.214、0.167、0.369。时降时升,整体变成了 3.7 倍。
有下降的区间,是因为每一层的值的大小不同,激活函数会截掉一部分,缩放因子也是重新确定的。所以不能只说“每一层都会稍微变大”就算了,必须在每个层边界上测量并做成表,才能看出问题在哪里。如果在某一层比值跳到三倍,那一层就是收窄范围的候选。
误差增长,也不等于准确率就会同样崩溃。即使最后一个边界的相对误差是 0.37,决策发生改变的样本也可能寥寥无几。因为输出向量整体稍微偏移,和排名被颠倒,是两回事。所以分层误差表要 用来挑选该动哪里,是否部署则另外用任务指标来判断。如果想把这两件事合并成一个数字,两者都会变得模糊。
要测层边界,就得能看到中间张量。在 ONNX 计算图中,把想看的张量追加到计算图的输出列表里就行。不过在整数版本里,整数张量无法以 float 取出,所以把线性算子 接收输入的那一侧 的张量定为边界更安全。那个位置在两个计算图里都是 float。
现场必须做的事
- 在部署日志里留下
get_providers()的结果。请求列表不是依据。 - 决定当期望的 EP 没有拿到时,要不要把它当作失败。有些服务悄悄用 CPU 运行更好,也有些服务不能这样。默认是静默回退,所以不想这样就得自己去拦住。
- 数值比较要 同时写下机器和设置。同样的模型、同样的输入,条件不同也不保证得到同样的数。
- 做一张层边界误差表。准确率崩了的时候,该动哪一层,表里就写着。
下一项实验要做什么
把存在的 EP 和不存在的 EP 混在一起请求,亲自测出实际拿到了什么,并做一个模拟,在给定支持的算子列表时,数出计算图会被切成几段。然后把层边界张量取成计算图的输出,用同样的输入分别运行 FP32 版本和整数版本,测出每一层的误差,做成增长比值的表。最后,把同一个模型、同一个输入,只改变优化级别和线程数来运行,看结果是否会变。评分器每次都会用不同的模型、随机种子和支持列表,真正运行你的工具,并把同样的计算重做一遍来核对。