【Bug已解决】AddExternalInitializers copies device (GPU) OrtValues per session instead of using them in place 解决方案
一、现象长什么样
用AddExternalInitializers(把一个已经常驻 GPU 的OrtValue作为外部初始化器加到计算图里,常见于把预加载的大常量权重直接挂到图上)时,发现每个新建的 session 都会把这份 GPU 张量完整拷贝一份,而不是原地复用(in-place / 共享引用):
// 预加载一个大权重到 GPU,得到 device OrtValue Ort::Value gpu_weight = loadWeightToGpu(...); // 比如 1GB 的 embedding 表 // 把它作为外部初始化器加进图 // 期望:session 直接引用 gpu_weight(0 拷贝) // 实际:每个 session 都 deep-copy 了一份 gpu_weight(1GB x N session) std::vector<OrtValue*> initializers = {gpu_weight}; // AddExternalInitializers 内部对 device OrtValue 做了拷贝最小信号:
GPU 显存随 session 数线性增长(每个 session 多一份权重拷贝) 预期:所有 session 共享同一份 GPU 权重(in-place)注意:这不是结果错,而是显存浪费 + 拷贝开销——大权重本可跨 session 共享,却被每份 session 复制。
二、背景
AddExternalInitializers的作用是:把一个OrtValue(通常是常量权重)注册成图的初始化器,这样图在构造时就“拥有”这个权重,不用每次run()都从外部传入。当这个OrtValue已经在GPU 设备上时,理想情况是 session直接持有它的引用(或共享所有权),多个 session 共享同一块 GPU 显存。
但 ORT 的AddExternalInitializers实现里,对 device(GPU)OrtValue走了“拷一份再挂到图上”的路径,而不是“原地接管/共享引用”。原因通常是:
- 图的初始化器在内部用
OrtValue的拷贝语义(值语义),默认AddExternalInitializers的契约是“把值加进图”,实现者图省事直接拷贝。 - device
OrtValue的拷贝涉及一次cudaMemcpy(设备内或跨流),既占显存又耗时。
对于大模型(几十 GB 权重),每个 session 拷一份直接爆显存,所以必须 in-place 共享。
三、根因
根因是AddExternalInitializers对 device(GPU)OrtValue走了深拷贝路径,而不是 in-place 共享引用(移动语义 / 共享所有权),导致每个 session 都复制一份 GPU 权重:
- 默认值拷贝语义:
AddExternalInitializers内部把传入的OrtValue按值存入图的初始化器列表,对 GPU 张量触发一次设备内拷贝。 - 未用移动/共享:没有判断“如果传入的是 device OrtValue,就移动所有权或共享引用,不拷贝”,于是多 session 各自持有一份拷贝。
- 显存线性增长:N 个 session 就有 N 份权重拷贝,大模型直接 OOM。
- 不是结果错:拷贝出的权重值一样,推理正确,只是浪费显存和拷贝时间。
所以这不是数值错,而是API 对 device OrtValue 的共享语义没实现,导致本可共享的 GPU 权重被重复拷贝。
四、最小可运行复现
下面用 C++ 标准库模拟“AddExternalInitializers 拷贝 vs in-place 共享”的显存差异:
#include <iostream> #include <vector> #include <memory> // 模拟 device OrtValue(GPU 张量),记录被拷贝次数 struct DeviceOrtValue { size_t bytes; static int copy_count; DeviceOrtValue(size_t b) : bytes(b) {} DeviceOrtValue(const DeviceOrtValue& o) : bytes(o.bytes) { copy_count++; } }; int DeviceOrtValue::copy_count = 0; // 有 bug 的实现:拷贝 device OrtValue 进图 std::vector<DeviceOrtValue> addExternalInitializers_copy(const DeviceOrtValue& w) { std::vector<DeviceOrtValue> graph; graph.push_back(w); // 拷贝 return graph; } int main() { DeviceOrtValue weight(1'000'000'000); // 1GB // 建 4 个 session,每个都 AddExternalInitializers for (int i = 0; i < 4; i++) addExternalInitializers_copy(weight); std::cout << "GPU 权重拷贝次数: " << DeviceOrtValue::copy_count << "(每个 session 拷贝一次 -> 浪费 4GB)\n"; // 修复:应移动/共享引用,拷贝次数 0 }跑出来:copy_count = 4,即 4 个 session 各拷贝一份 1GB 权重(共浪费 4GB)。修复后应当用移动/共享引用,copy_count = 0。这复现了“device OrtValue 被重复拷贝”的机制。
五、解决方案(第一层:最小直接修复)
最小修复:让AddExternalInitializers对 deviceOrtValue走 in-place 共享(移动所有权或共享引用),不拷贝。对使用者,临时规避是减少 session 数量(全局单例 session),让拷贝只发生一次:
// 全局只建一个持有外部初始化器的 session,所有推理复用 // 这样 device OrtValue 只被拷贝(或共享)一次 static Ort::Session* g_session = nullptr; Ort::Session& getSession(Ort::Env& env, Ort::SessionOptions& so, const char* path) { if (!g_session) { // 把 GPU 权重 AddExternalInitializers 后建 session(仅一次拷贝) g_session = new Ort::Session(env, path, so); } return *g_session; }对 ORT 仓库侧,修复是改AddExternalInitializers:若传入的OrtValue位于设备内存,使用std::move或共享OrtValue的智能指针(引用计数)接管,而不是值拷贝;多个 session 共享同一份 deviceOrtValue。这一层立刻消除重复拷贝。
六、解决方案(第二层:结构性改进)
把“外部初始化器对 device OrtValue 必须 in-place 共享”收口成唯一的配置对象OrtAddExternalInitDeviceCopyPolicy,API 实现读它:
from dataclasses import dataclass, field from typing import Tuple, Literal @dataclass(frozen=True) class OrtAddExternalInitDeviceCopyPolicy: """AddExternalInitializers device OrtValue 共享语义的单一事实来源。""" # device OrtValue 的处理方式:in-place 共享(不拷贝) device_value_policy: Literal["in_place_share", "move", "copy"] = "in_place_share" # 是否允许多 session 共享同一 device OrtValue allow_cross_session_share: bool = True # 受影响 API affected_api: Tuple[str, ...] = ("AddExternalInitializers",) # 拷贝必须显式声明(默认禁止隐式拷贝) forbid_implicit_copy: bool = True def should_copy(self, is_device_value: bool) -> bool: if not is_device_value: return False return self.device_value_policy == "copy" def describe(self) -> str: return "device OrtValue 进 AddExternalInitializers 必须 in-place 共享,禁止隐式拷贝" POLICY = OrtAddExternalInitDeviceCopyPolicy() def plan_add(ort_value_is_device: bool, policy: OrtAddExternalInitDeviceCopyPolicy = POLICY) -> str: return "copy" if policy.should_copy(ort_value_is_device) else "in_place_share"所有 API 实现读同一份POLICY,device OrtValue 默认 in-place 共享,杜绝隐式拷贝。
七、解决方案(第三层:断言 / CI 守护)
把“device OrtValue 不被隐式拷贝”做成断言。下面用 pytest 风格守护(复用第四节逻辑):
import pytest def test_device_value_in_place(policy): assert policy.device_value_policy == "in_place_share" assert policy.should_copy(True) is False # device 不拷贝 def test_cross_session_share_allowed(policy): assert policy.allow_cross_session_share is True def test_forbid_implicit_copy(policy): assert policy.forbid_implicit_copy is True def test_host_value_no_copy_either(policy): # 即便 host 值,按策略也不拷贝(直接用) assert policy.should_copy(False) is False这四组断言锁住:(1) device 值 in-place 不拷贝;(2) 允许跨 session 共享;(3) 禁止隐式拷贝;(4) host 值也不无故拷贝。CI 跑通即代表共享语义被守护。
八、排查清单
遇到 AddExternalInitializers 让 GPU 显存随 session 数增长:
- 确认是不是 device OrtValue:传入的权重在 GPU 上、且每个 session 都多一份 -> 锁定隐式拷贝。
- 看 API 语义:
AddExternalInitializers是不是值拷贝,没用移动/共享。 - 查 session 数量:是不是每请求一 session(应改单例)。
- 临时规避:全局单例 session,只拷贝/共享一次。
- 根本修复:device OrtValue 走 in-place 共享(移动/引用计数),不拷贝。
- 统一策略对象:用
OrtAddExternalInitDeviceCopyPolicy固化。 - CI 守护:断言 device 值不拷贝、可跨 session 共享。
九、小结
AddExternalInitializers copies device (GPU) OrtValues per session instead of using them in place的根因是:AddExternalInitializers对位于设备内存的OrtValue走了值拷贝路径,而不是 in-place 共享引用(移动语义/引用计数),导致每个新建的 session 都把这份 GPU 权重完整拷贝一份,显存随 session 数线性增长、还有拷贝开销。
最小修复是让 API 对 device OrtValue 走 in-place 共享(移动/共享所有权),临时规避是全局单例 session 只共享一次;结构性改进是用唯一的OrtAddExternalInitDeviceCopyPolicy固化“device 值必须 in-place 共享、禁止隐式拷贝”;CI 用四组断言守护“device 不拷贝、可跨 session 共享、禁止隐式拷贝”。记住:常驻 GPU 的大权重进图必须共享引用,隐式拷贝会直接撑爆显存。