公司动态

【Bug已解决】WebGPU plugin EP: ReleaseEpFactory throws “Unknown exception“ on macOS during teardown (Web…

📅 2026/8/13 11:52:27
【Bug已解决】WebGPU plugin EP: ReleaseEpFactory throws “Unknown exception“ on macOS during teardown (Web…
【Bug已解决】WebGPU plugin EP: ReleaseEpFactory throws Unknown exception on macOS during teardown (WebGpuContextFactory::Cleanup is not exception-safe) 解决方案一、现象长什么样在 macOS 上用 ONNX Runtime 的 WebGPUplugin EP独立插件执行提供方不是内置 WebGPU EP程序退出、卸载 EP 工厂时崩在一个看起来很神秘的错误上terminate called after throwing an instance of Ort::Exception what(): [ONNXRuntimeError] : 1 : FAIL : Unknown exception或者在没有用 C 异常而是走 C 接口时表现为ReleaseEpFactory返回了一个非空、但内容不可读的失败码整个进程在std::terminate处直接挂掉连atexit里注册的清理都没跑完。最小触发路径#include onnxruntime/core/session/onnxruntime_cxx_api.h #include onnxruntime/core/providers/webgpu/webgpu_plugin_api.h int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, demo); Ort::SessionOptions so; // 注册 WebGPU plugin EP Ort::ThrowOnError(Ort::GetApiBase()-...-RegisterExecutionProviderFactory( so, WebGpuPlugin)); { Ort::Session session(env, model.onnx, so); // 正常推理 ... } // -- session 析构在这里一切正常 // -- 进程退出、EP 工厂 teardown 时抛 Unknown exception return 0; }关键点是推理本身没问题session 析构也没问题问题出在“插件 EP 工厂在进程退出阶段被释放”这一步。把同样的程序放到 Linux/Windows 上同款 WebGPU plugin EP往往不崩——这说明 teardown 路径对平台差异macOS 上 Metal/Vulkan 适配器的释放顺序敏感。二、背景ONNX Runtime 的执行提供方EP有两种存在形态内置 EP编译进onnxruntime主库GetEpRegistration直接拿到工厂。plugin EP独立.dylib/.so/.dll通过OrtEpFactory注册ReleaseEpFactory负责在不再需要时释放工厂句柄。WebGPU plugin EP 走的是第二种。它的生命周期大致是加载.dylib→ 拿到OrtEpFactory创建 session 时factory 的CreateExecutionProvider会建一个WebGpuContextFactory里面持有 wgpu 的Adapter、Device、Instance等 Metal/Vulkan 资源session 销毁时这些资源进入WebGpuContextFactory::Cleanup()做释放进程退出、EP 卸载时ReleaseEpFactory调用 factory 的析构析构里又会再调一次Cleanup()。macOS 上 Metal 的 device/command-queue 释放对调用线程和执行顺序非常敏感如果在非渲染线程、或在上一个Cleanup已经部分释放后再次进入清理Metal 会返回一个错误状态。问题就出在Cleanup()没有把这一步包进异常安全的结构里。三、根因根因是WebGpuContextFactory::Cleanup()不是异常安全的not exception-safe而ReleaseEpFactory的 teardown 路径又是“裸调用”这个清理函数没有 try/catch 兜底。具体来说Cleanup()在 macOS 上会调用 wgpu 的device.destroy()/queue.destroy()/instance.drop()。这些调用在 Metal 后端实现里对“重复释放”或“在已失活的 adapter 上释放”会走abort()或抛 C 异常取决于 wgpu 编译选项。ReleaseEpFactory在释放工厂时会触发 factory 析构析构里直接调Cleanup()。这一次调用发生在进程退出阶段此时全局 Metal 状态、NSAutoreleasePool环境、甚至 GPU 进程连接都已经在拆于是Cleanup()内部抛异常。这个异常从Cleanup()一路冒泡到ReleaseEpFactory而ReleaseEpFactory是 C 风格接口声明为不抛异常没有捕获。C 标准规定从noexcept/extern C 边界抛出未捕获异常 → 调std::terminate→ 进程直接终止ORT 上层只能拿到一句“Unknown exception”。所以表面是“Unknown exception”实际是teardown 路径把平台相关的释放异常暴露成了进程级崩溃。Linux/Windows 不崩只是因为那里的 wgpu 后端对同样的条件选择静默而非抛异常属于“运气好”不是真的修好了。四、最小可运行复现我们可以用一段纯 C 标准库的等价代码复现“析构里抛异常冒泡到 noexcept 边界导致 terminate”的核心机制。它不依赖真实的 wgpu/Metal但精准复现了 teardown 崩溃的成因#include iostream #include stdexcept // 模拟 WebGpuContextFactory::Cleanup struct WebGpuContextFactory { bool cleaned false; void Cleanup() { if (cleaned) { // macOS 上 Metal 对已释放 adapter 再次释放会抛异常 throw std::runtime_error(Metal device already released); } cleaned true; } ~WebGpuContextFactory() noexcept { // 析构声明为不抛异常 Cleanup(); // -- 异常从这里冒出 noexcept 边界 } }; // 模拟 ReleaseEpFactory 的裸调用 extern C void ReleaseEpFactory(WebGpuContextFactory* f) { delete f; // 析构里抛异常且没有 try/catch } int main() { auto* f new WebGpuContextFactory(); f-Cleanup(); // 第一次清理成功session 析构时 ReleaseEpFactory(f); // 第二次清理抛异常 - std::terminate return 0; }用clang -stdc17 demo.cpp -o demo ./demo跑你会看到terminate called after throwing an instance of std::runtime_error what(): Metal device already released Abort trap: 6这正是 ORT 里ReleaseEpFactory抛 “Unknown exception” 的精简版本析构里抛异常越过noexcept/extern C边界进程终止。五、解决方案第一层最小直接修复最小修复让Cleanup()本身异常安全并且让ReleaseEpFactory在调用 factory 析构时也捕获异常、转成错误码而不是让它冒泡。Cleanup()用noexcept内部 try/catch对“重复清理”和“Metal 释放失败”都降级为日志不再抛出void WebGpuContextFactory::Cleanup() noexcept { try { if (device_) { device_.Destroy(); // wgpu/Metal 后端 device_.reset(); } if (queue_) { queue_.Destroy(); queue_.reset(); } if (instance_) { instance_.Drop(); instance_.reset(); } device_ nullptr; queue_ nullptr; instance_ nullptr; } catch (const std::exception e) { // 平台相关释放失败记录不抛。teardown 阶段任何异常都不应终止进程 LOG(ERROR) WebGpuContextFactory::Cleanup partial failure: e.what(); } }注意这里Cleanup()整个标成noexcept内部兜底所有异常保证它永远不向外抛。这样ReleaseEpFactory即便直接delete也不会触发std::terminate。六、解决方案第二层结构性改进单点 catch 能止血但更稳的是把“EP 工厂 teardown 必须异常安全”做成结构性约束避免每个平台后端都自己重复写 try/catch。下面用一个唯一配置对象WebGpuEpFactoryTeardownPolicy描述清理顺序与兜底策略所有平台后端都遵守同一套顺序。from dataclasses import dataclass, field from typing import Tuple, Literal from enum import Enum class ReleaseOrder(Enum): QUEUE_FIRST queue_first # 先 queue再 device最后 instancemacOS Metal 安全序 DEVICE_FIRST device_first dataclass(frozenTrue) class WebGpuEpFactoryTeardownPolicy: WebGPU plugin EP 工厂 teardown 的单一事实来源。 # 释放顺序macOS 必须 queue - device - instance release_order: ReleaseOrder ReleaseOrder.QUEUE_FIRST # 是否允许重复 Cleanup幂等 idempotent_cleanup: bool True # teardown 阶段异常一律降级为日志绝不冒泡到 ReleaseEpFactory swallow_teardown_exceptions: bool True # 是否在 Cleanup 前检查 GPU 进程是否仍存活 guard_gpu_process_alive: bool True # 各资源释放超时毫秒超时即跳过并记日志 per_resource_timeout_ms: int 2000 # 平台黑名单这些平台强制走异常安全清理 force_safe_platforms: Tuple[str, ...] (darwin, macos, ios) def ordered_resources(self) - Tuple[str, ...]: if self.release_order ReleaseOrder.QUEUE_FIRST: return (queue, device, instance) return (device, queue, instance) def should_swallow(self, platform: str) - bool: if self.swallow_teardown_exceptions: return True return platform in self.force_safe_platforms POLICY WebGpuEpFactoryTeardownPolicy() def teardown_factory(factory_state: dict, platform: str darwin, policy: WebGpuEpFactoryTeardownPolicy POLICY) - list: 按策略顺序释放资源任何异常都降级为日志条目不抛出。 log [] for res in policy.ordered_resources(): try: if policy.guard_gpu_process_alive and not factory_state.get(gpu_alive, True): log.append(fskip {res}: gpu process gone) continue _release(res, factory_state) except Exception as e: # noqa: BLE001 if policy.should_swallow(platform): log.append(fswallowed {res} release error: {e}) else: raise return log def _release(res: str, state: dict): if state.get(res) is None and policy_idempotent(state): return # 真实实现里调用 wgpu 的 Destroy/Drop state[res] None def policy_idempotent(state: dict) - bool: return True这样无论哪个平台、哪个 wgpu 后端teardown 都走统一、幂等、异常安全的顺序。七、解决方案第三层断言 / CI 守护把“teardown 不抛异常、重复 Cleanup 幂等”做成断言。下面用 pytest 风格守护 Python 绑定层的等价行为用模拟对象确保工厂析构和重复清理都不抛。import pytest class FakeWebGpuFactory: def __init__(self, policy): self.policy policy self.state {queue: object(), device: object(), instance: object()} self.cleaned False def cleanup(self): # 对应 noexcept 的 Cleanup永远不抛 for res in self.policy.ordered_resources(): try: self.state[res] None except Exception: # noqa: BLE001 pass self.cleaned True def __del__(self): # ReleaseEpFactory 调析构 - 调 cleanup必须安全 self.cleanup() def test_release_factory_does_not_terminate(policy): f FakeWebGpuFactory(policy) f.cleanup() # 第一次session 析构 del f # 第二次ReleaseEpFactory不应抛/终止 def test_duplicate_cleanup_is_idempotent(policy): f FakeWebGpuFactory(policy) f.cleanup() f.cleanup() # 重复调用必须安全 assert all(v is None for v in f.state.values()) def test_teardown_order_on_macos(policy): assert policy.ordered_resources() (queue, device, instance) assert policy.swallow_teardown_exceptions is True这三组断言分别锁住(1)ReleaseEpFactory释放不导致进程终止(2) 重复Cleanup幂等(3) macOS 上释放顺序与兜底策略正确。CI 跑通即代表 teardown 路径异常安全。八、排查清单遇到 WebGPU plugin EP “Unknown exception” 在退出/卸载时崩确认崩溃点是推理时崩、session 析构时崩还是进程退出/EP 卸载时崩本题是最后一种。看平台macOS 上 Metal 后端最容易暴露Linux/Windows 可能只是“恰好不抛”不代表修好。搜Cleanup/~Factory/ReleaseEpFactory看清理函数是否标noexcept内部是否可能抛。查重复释放session 析构和 factory 析构是否都调了Cleanup第二次是否抛。检查释放顺序macOS 上必须queue → device → instance顺序反了 Metal 会报错。兜底给Cleanup包noexcept try/catchteardown 阶段任何异常降级为日志绝不冒泡到extern C边界。统一策略用单一配置对象规定 teardown 顺序与幂等性避免各后端各自为政。九、小结ReleaseEpFactory throws Unknown exception on macOS during teardown的根因是WebGpuContextFactory::Cleanup()不是异常安全的ReleaseEpFactory在进程退出阶段裸调 factory 析构析构里再次Cleanup()macOS 上 Metal 对已部分释放的 adapter 再次释放会抛异常这个异常冒泡过noexcept/extern C边界触发std::terminate上层只能看到一句“Unknown exception”。最小修复是给Cleanup()加noexcept 内部 try/catch把平台相关释放失败降级为日志结构性改进是用唯一的WebGpuEpFactoryTeardownPolicy规定queue → device → instance的释放顺序与幂等性CI 用三组断言守护“teardown 不终止、重复清理幂等、macOS 顺序正确”。记住teardown 阶段任何异常都不该杀进程兜底必须在最底层做完。