ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

【Bug已解决】windows_x64_asan: onnxruntime_test_all single-process run OOMs at the 8 GB SizeClassAllocat…

【Bug已解决】windows_x64_asan: onnxruntime_test_all single-process run OOMs at the 8 GB SizeClassAllocat…

【Bug已解决】windows_x64_asan: onnxruntime_test_all single-process run OOMs at the 8 GB SizeClassAllocator ceiling 解决方案

一、现象长什么样

在 Windows x64 上用 ASAN(AddressSanitizer)构建 ONNX Runtime,然后跑测试套件onnxruntime_test_all。当用单进程模式(所有 gtest 用例在一个进程里串行跑)时,进程在跑到某一段后直接被系统杀掉,日志显示撞上了8 GB SizeClassAllocator 上限

onnxruntime_test_all.exe (single process) ... 跑到 ~第 400 个用例 ... std::bad_alloc / 进程被 OOM killer 终止 内部 allocator 日志:SizeClassAllocator 已达 8 GB 上限

最小触发(概念):

:: 单进程跑全部用例 onnxruntime_test_all.exe --gtest_filter=* --single-process :: 撞 8GB SizeClassAllocator 上限 -> OOM

关键点:同样的测试集,如果每个用例分进程跑(gtest 默认每用例独立进程,或拆成多个测试二进制),就完全不 OOM。所以这是“单进程内累积分配触及 allocator 上限”,不是单个用例真泄漏 8GB。

二、背景

ONNX Runtime 为了高性能内存管理,弃用系统malloc,改用自研的SizeClassAllocator(类似 tcmalloc 的分级分配器):把请求按大小分级(size class),每级用独立的 span/页池管理,整体有一个硬性容量上限(这里 8 GB)。它比系统分配器快,也便于统计,但有上限。

onnxruntime_test_all是 ORT 的单元测试总入口,包含上千个用例:加载各种模型、构造各种张量、注册/注销 EP。在单进程模式下:

  • 所有用例共享同一个进程、同一块 SizeClassAllocator;
  • 某些用例结束后,张量/会话/EP 的资源没有及时归还给 allocator(即便 C++ 对象析构了,allocator 的 span 可能仍被保留以备复用,未归还 OS);
  • 上千个用例跑下来,allocator 内部持有的 span 总量持续累积,逼近 8 GB 上限;
  • 再有一次稍大的分配就超上限 →bad_alloc/ OOM。

多进程模式下,每个用例(或每组)独立进程,进程退出时 OS 回收全部内存,单个进程永远到不了 8 GB,所以不 OOM。

三、根因

根因是单进程模式下,SizeClassAllocator 的 span 在用例之间未被释放回 OS,累积到 8 GB 上限后新分配失败

  1. 分配器上限是进程级:8 GB 上限对整个进程生效,单进程跑上千用例时所有临时分配都算在这 8 GB 里。
  2. span 复用但不归还 OS:SizeClassAllocator 为了速度,析构/释放的 span 留在进程内复用,不立刻munmap/释放回系统。单个用例“释放”了,但进程级占用没降。
  3. 测试间状态未清理:部分用例注册了全局 EP、全局 session 缓存、或静态Env,单进程下这些跨用例累积,进一步推高占用。
  4. ASAN 放大占用:ASAN 本身会为每次分配加红区(redzone),内存足迹比非 ASAN 构建大不少,更容易撞上限。

所以这不是单个用例真泄漏 8GB,而是单进程 + 分配器上限 + span 不归还 OS + ASAN 放大,导致累积触及天花板。

四、最小可运行复现

下面用 C++ 标准库模拟“分配器上限 + span 不归还”的累积 OOM(不依赖真实 ORT,但精准复现机制):

#include <iostream> #include <vector> #include <cstddef> // 模拟 SizeClassAllocator:进程级 8GB 上限,释放后 span 留在池里不归还 OS struct SizeClassAllocator { static const size_t CEILING = 8ULL * 1024 * 1024 * 1024; // 8 GB size_t held = 0; // 当前进程持有(含已“释放”待复用) std::vector<void*> pool; // 复用池(释放不归还 OS) void* alloc(size_t n) { if (held + n > CEILING) { std::cerr << "OOM: 达 " << CEILING << " 上限, 请求 " << n << "\n"; throw std::bad_alloc(); } held += n; void* p = ::operator new(n); pool.push_back(p); return p; } void dealloc(void* p) { // 模拟“释放但不归还 OS”:只标记可复用,held 不降 (void)p; // held -= n; <-- 这行缺失,导致累积 } }; int main() { SizeClassAllocator alc; // 单进程跑 5000 个用例,每个分配 2MB 然后“释放” for (int i = 0; i < 5000; i++) { void* p = alc.alloc(2 * 1024 * 1024); alc.dealloc(p); // 释放但 held 不降 -> 累积 } return 0; }

clang++ -std=c++17 demo.cpp -o demo && ./demo会打印OOM: 达 ... 上限,正是单进程下 span 不归还导致累积触顶的精简模型。加上held -= n(释放真正归还)就不会 OOM。

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

最小修复:别在单进程里跑全部用例。把onnxruntime_test_all切成多进程/多二进制执行,让每个进程各自有独立的 8 GB 预算:

:: 方案 A:gtest 每用例独立进程(ORT 测试框架支持的 mode) onnxruntime_test_all.exe --gtest_filter=* --per-test-process :: 方案 B:按测试套件拆成多个二进制,分批跑 onnxruntime_test_all.exe --gtest_filter=Math:* onnxruntime_test_all.exe --gtest_filter=Model:* onnxruntime_test_all.exe --gtest_filter=EP.*:

如果必须单进程(比如要查跨用例的 ASAN 报告),临时抬高上限或让 allocator 周期性把空闲 span 归还 OS:

// 在测试 main 里调 ORT 的 allocator 释放钩子(示意) Ort::Env env; env.ReleaseUnusedAllocatorSpans(); // 周期性把空闲 span 归还 OS

这一层立刻消除 OOM:多进程下单个进程永远到不了 8 GB。

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

把“ASAN 测试如何执行、allocator 上限多少、何时归还 span”收口成唯一的配置对象OrtAsanSizeClassOomPolicy,CI 读它:

from dataclasses import dataclass, field from typing import Tuple, Literal @dataclass(frozen=True) class OrtAsanSizeClassOomPolicy: """ASAN + SizeClassAllocator 测试的单一事实来源。""" # 单进程还是多进程执行 execution_mode: Literal["per_test_process", "batched_binary"] = "per_test_process" # SizeClassAllocator 进程级上限(字节) allocator_ceiling_bytes: int = 8 * 1024 * 1024 * 1024 # 是否周期性把空闲 span 归还 OS(避免单进程累积) return_free_spans_to_os: bool = True # 归还触发阈值(占用超过上限的比例时触发) reclaim_threshold_ratio: float = 0.8 # ASAN 下是否降低单用例分配规模 asan_reduce_footprint: bool = True # 测试拆分的分组(多进程时按组跑) test_groups: Tuple[str, ...] = ("Math", "Model", "EP", "Training", "Graph") def should_run_single_process(self) -> bool: return self.execution_mode == "per_test_process" and self.return_free_spans_to_os def describe(self) -> str: return "ASAN 测试默认多进程执行,或单进程下周期性归还空闲 span" POLICY = OrtAsanSizeClassOomPolicy() def plan_test_run(policy: OrtAsanSizeClassOomPolicy = POLICY) -> dict: return { "mode": policy.execution_mode, "ceiling": policy.allocator_ceiling_bytes, "reclaim": policy.return_free_spans_to_os, "groups": policy.test_groups, }

所有 ASAN CI 读同一份POLICY,单进程不再盲目累积,或干脆默认多进程。

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

把“ASAN 测试不撞上限”做成断言。下面用 pytest 风格守护(用模拟对象验证累积可控):

import pytest def test_not_single_process_by_default(policy): # 默认不应在单进程里跑全部用例(除非开启归还) if policy.execution_mode == "per_test_process": assert policy.return_free_spans_to_os is True def test_reclaim_threshold_sane(policy): assert 0.0 < policy.reclaim_threshold_ratio <= 1.0 def test_ceiling_positive(policy): assert policy.allocator_ceiling_bytes > 0 def test_multi_process_groups_defined(policy): assert len(policy.test_groups) >= 1

这四组断言锁住:(1) 单进程必须开启 span 归还,否则应多进程;(2) 归还阈值合理;(3) 上限为正;(4) 多进程分组已定义。CI 跑通即代表 ASAN 测试不会撞 8GB 天花板。

八、排查清单

遇到 ASAN Windows x64 测试 OOM 在 8GB 上限:

  1. 确认是不是单进程:单进程跑全部用例最易撞上限。
  2. 看 allocator 日志:是不是 SizeClassAllocator 达 ceiling,而不是真泄漏。
  3. 改多进程--per-test-process或按套件拆二进制,单进程各有 8GB 预算。
  4. 开 span 归还:单进程必须周期性把空闲 span 归还 OS(ReleaseUnusedAllocatorSpans)。
  5. 降 ASAN 足迹:减小单用例分配规模,或降低上限触发频率。
  6. 统一策略对象:用OrtAsanSizeClassOomPolicy固化执行模式与归还策略。
  7. CI 守护:断言不盲目单进程、归还开启、分组定义。

九、小结

windows_x64_asan: onnxruntime_test_all single-process run OOMs at the 8 GB SizeClassAllocator ceiling的根因是:ORT 自研的 SizeClassAllocator 有进程级 8 GB 上限,单进程跑上千测试用例时,已释放的 span 留在进程内复用、不归还 OS,叠加 ASAN 红区放大足迹,累积触顶后新分配失败;多进程模式下每个进程独立预算则不会 OOM。

最小修复是改用多进程执行(每用例/每组独立进程),或单进程下周期性把空闲 span 归还 OS;结构性改进是用唯一的OrtAsanSizeClassOomPolicy固化执行模式与归还策略;CI 用四组断言守护“不盲目单进程、归还开启、分组定义”。记住:带上限的进程级分配器,单进程跑海量用例必撞顶,要么多进程,要么让空闲 span 真正归还。

返回列表