三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

【Bug已解决】[Web] DB-style detection model returns all-zero output on Apple M-series above ~2048px inpu…

【Bug已解决】[Web] DB-style detection model returns all-zero output on Apple M-series above ~2048px inpu…

【Bug已解决】[Web] DB-style detection model returns all-zero output on Apple M-series above ~2048px input - likely fp16 overflow in ReduceMean 解决方案

一、现象长什么样

一个 DB 风格(Differentiable Binarization,文字检测)的 ORT 模型,在 Apple M 系列芯片上通过 WebGPU / CoreML EP 跑推理。输入图片小的时候(比如 1024x1024)输出正常,但一旦边长超过约 2048px,输出整体变成全零(概率图、阈值图全 0),检测彻底失效:

// Web 端(ONNX Runtime Web)加载 const session = await ort.InferenceSession.create("db_detect.onnx", { executionProviders: ["webgpu"], // Apple M 系列走 Metal }); const out = await session.run(feed); // input 2048x2048 -> out 全 0;input 1024x1024 -> 正常

最小复现信号:

输入边长 <= 2048px : 输出正常(有非零概率) 输入边长 > 2048px : 输出全 0 阈值恰好在 2048 附近,像“数值溢出后归零”

注意:模型能跑、不报错,只是大图时数值溢出导致结果归零。这不是逻辑错,是数值精度问题。

二、背景

DB 检测模型的头部有一个概率图生成步骤:对特征图做ReduceMean(或ReduceSum/Softmax前的求和)把通道维聚合成单通道概率。整个过程在 Apple M 系列上走的是 fp16(半精度)路径 - WebGPU/Metal 的f16是原生类型,ORT 在 Apple 上为了性能默认用 fp16 执行。

fp16 的表示范围大约是 +/-65504,相对精度约 3 位十进制。问题在于ReduceMean的实现:

  • 如果它在 fp16 下逐个元素累加再除以个数,大图(2048x2048 的特征图,像素数约 4M)累加时,部分和会远超 65504 然后上溢成 inf;
  • inf 参与后续Sigmoid/exp运算变成 nan / 0;
  • 最终概率图被钳到 0,表现为全零输出。

而 1024x1024(约 1M 像素)时累加和还没超 65504,所以正常。阈值约 2048px 恰好对应累加和突破 fp16 上限的临界点。这就是 “likely fp16 overflow in ReduceMean”。

三、根因

根因是 Apple M 系列 fp16 路径下的ReduceMean用 fp16 累加大图特征,部分和溢出 fp16 上限(+/-65504),变成 inf/nan 后结果归零:

  1. fp16 累加溢出ReduceMean在 fp16 精度下逐元素求和,特征图元素多(2048 的平方量级)时部分和轻松破 65504,直接 inf。
  2. 溢出传染:inf 进Sigmoid/exp变成 nan 或 0;再经Clip/Cast被钳成 0,输出全零。
  3. 只在 Apple(fp16 原生)明显:x86 上 ORT 可能用 fp32 跑、或 Metal 的 fp16 累加更易触发;CPU fp32 路径完全不受影响,所以小图正常、大图归零、只在 M 系列。
  4. 不是模型结构错:同样的 ONNX 在 CPU fp32 上大图也正常,说明是 EP 的数值实现问题。

所以这不是逻辑错,而是 fp16 归约缺乏数值稳定性(没用 fp32 累加器 / Kahan 补偿),大图溢出归零。

四、最小可运行复现

下面用 NumPy 模拟 “fp16 ReduceMean 大图溢出归零”(用float16累加复现):

import numpy as np def reduce_mean_fp16_naive(x): """有 bug 的实现:在 fp16 下逐元素累加(Apple M 的 fp16 路径)。""" acc = np.float16(0.0) for v in x.flatten(): acc = acc + np.float16(v) # fp16 累加,大数组溢出 return float(acc) / x.size def reduce_mean_fp32(x): """正确实现:用 fp32 累加,最后再转回。""" return float(np.mean(x.astype(np.float32))) if __name__ == "__main__": # 模拟 2048x2048 特征图,元素均值约 1.0(如激活值) big = np.ones(2048 * 2048, dtype=np.float32) naive = reduce_mean_fp16_naive(big) correct = reduce_mean_fp32(big) print("fp16 朴素累加:", naive, "(inf/nan 即为溢出)") print("fp32 累加 :", correct) # 朴素 fp16 累加 4M 个 1.0 -> 远超 65504 -> inf assert (not np.isfinite(naive)) or naive != correct print("复现:大图 fp16 累加溢出 -> 归约结果 inf -> 输出归零")

跑出来:朴素 fp16 累加得到inf(或不稳定值),fp32 得到正确的1.0。这正好复现大图 fp16 ReduceMean 溢出归零的机制。

五、解决方案(第一层:最小直接修复)

最小修复:让ReduceMean(及相关归约)在 fp16 路径下用 fp32 累加器,或干脆对这个模型用 fp32 执行。Web 侧可以这样:

// 方案 A:对归约相关节点强制 fp32(若 ORT Web 暴露该选项) const session = await ort.InferenceSession.create("db_detect.onnx", { executionProviders: [{ name: "webgpu" }], graphOptimizationLevel: "all", }); // 或在导出时把 ReduceMean 之后的头部数据类型固定为 fp32 // 方案 B:导出时把特征图头部的 ReduceMean 输入限制在 fp32 精度 // (用 onnx 修改工具把对应节点的 dtype 标成 fp32)

对 ORT 仓库侧,修复是改 Apple/Metal 的ReduceMean内核:归约累加用 fp32(或float2双缓冲)做部分和,最后再转 fp16 输出。这一层立刻让大图输出正常。

六、解决方案(第二层:结构性改进)

把 “哪些算子在 fp16 路径下必须用 fp32 累加” 收口成唯一的配置对象OrtWebReduceMeanFp16Policy,Web 加载与内核选择读它:

from dataclasses import dataclass, field from typing import Tuple @dataclass(frozen=True) class OrtWebReduceMeanFp16Policy: """Apple M 系列 fp16 归约数值稳定的单一事实来源。""" # 必须在 fp16 路径下用 fp32 累加的算子 fp32_accum_ops: Tuple[str, ...] = ("ReduceMean", "ReduceSum", "ReduceL2", "Softmax") # 触发 fp32 累加的“大图阈值”:特征图元素数超过此值必用 fp32 累加 overflow_pixel_threshold: int = 2048 * 2048 # 是否全局强制这些算子 fp32 执行(最稳,略慢) force_fp32_for_these_ops: bool = False # 平台限定(Apple M 系列 Metal fp16 原生易溢出) affected_platforms: Tuple[str, ...] = ("apple", "m-series", "metal", "webgpu") def needs_fp32_accum(self, op_type: str, num_pixels: int) -> bool: if op_type not in self.fp32_accum_ops: return False if self.force_fp32_for_these_ops: return True return num_pixels > self.overflow_pixel_threshold def describe(self) -> str: return "大图 ReduceMean 等在 fp16 路径用 fp32 累加,防溢出归零" POLICY = OrtWebReduceMeanFp16Policy() def plan_reduce(op_type: str, num_pixels: int, policy: OrtWebReduceMeanFp16Policy = POLICY) -> str: return "fp32_accum" if policy.needs_fp32_accum(op_type, num_pixels) else "fp16"

所有 Web 加载与内核选择读同一份POLICY,大图归约自动用 fp32 累加,避免溢出。

七、解决方案(第三层:断言 / CI 守护)

把 “大图 ReduceMean 不溢出、数值稳定” 做成断言。下面用 pytest 风格守护(复用第四节逻辑):

import numpy as np def test_large_image_mean_finite(): big = np.ones(2048 * 2048, dtype=np.float32) assert np.isfinite(reduce_mean_fp32(big)) assert abs(reduce_mean_fp32(big) - 1.0) < 1e-3 def test_fp32_accum_for_large_reduce(policy): assert policy.needs_fp32_accum("ReduceMean", 2048 * 2048 + 1) is True assert policy.needs_fp32_accum("ReduceMean", 1024 * 1024) is False def test_affected_platform_covers_apple(policy): assert "apple" in policy.affected_platforms def test_reduce_ops_listed(policy): assert "ReduceMean" in policy.fp32_accum_ops

这四组断言锁住:(1) 大图均值有限且正确;(2) 大图 ReduceMean 触发 fp32 累加、小图不触发;(3) 平台覆盖 Apple;(4) 归约算子已列入名单。CI 跑通即代表大图不会溢出归零。

八、排查清单

遇到 Apple M 系列上大图 DB 检测输出全零:

  1. 先换 EP / 精度验证:用 CPU fp32 跑大图,若正常 -> 锁定 fp16 数值问题。
  2. 看阈值是否在 2048px 附近:是的话高度怀疑 fp16 累加溢出。
  3. 查 ReduceMean 内核:Apple/Metal 的 fp16 归约是不是用 fp16 累加(应改 fp32 累加)。
  4. 临时规避:对该模型强制 fp32 执行,或导出时把归约头部标 fp32。
  5. 根本修复:改 Metal ReduceMean 内核用 fp32 部分和,最后转 fp16。
  6. 统一策略对象:用OrtWebReduceMeanFp16Policy固化大图阈值与算子名单。
  7. CI 守护:断言大图 ReduceMean 数值有限、触发 fp32 累加。

九、小结

[Web] DB-style detection model returns all-zero output on Apple M-series above ~2048px input - likely fp16 overflow in ReduceMean的根因是:Apple M 系列 fp16 路径下的ReduceMean在 fp16 精度下逐元素累加大图特征,部分和超过 fp16 上限 +/-65504 后变成 inf/nan,经 Sigmoid/exp 传染后概率图被钳成 0,表现为大图(>2048px)输出全零;小图累加和未超上限所以正常,CPU fp32 路径不受影响。

最小修复是让 ReduceMean 在 fp16 路径用 fp32 累加器(或对该模型强制 fp32);结构性改进是用唯一的OrtWebReduceMeanFp16Policy固化“大图阈值 + 必须用 fp32 累加的算子名单”;CI 用四组断言守护“大图均值有限、触发 fp32 累加、平台覆盖 Apple、算子已列入”。记住:fp16 归约一定要用 fp32 累加器,大图累加必溢出,否则结果悄悄归零。

← 返回列表