PyTorch Operator Upgrader 完整指南:为 BC-breaking 算子变更编写版本升级器(operator_upgraders 源码解析)
【免费下载链接】pytorchTensors and Dynamic neural networks in Python with strong GPU acceleration项目地址: https://gitcode.com/GitHub_Trending/py/pytorch
PyTorch 的算子(operator)在演进中可能因修复 bug、改善易用性等原因发生破坏性变更,导致旧程序在新运行时(BC breaking)或新程序在旧运行时(FC breaking)行为不一致。本文基于torch/csrc/jit/operator_upgraders/模块的官方开发者指南(README.md),系统讲解Upgrader(升级器)的完整设计:何时需要编写、如何命名、如何注册到版本映射表、如何生成测试模型并验证,并穿插仓库源码级佐证。读完本文,你将具备独立完成一次"BC-breaking 算子变更 + 配套 upgrader + 测试 + 版本号提升"全流程的能力。
背景:为什么算子变更需要版本化
PyTorch 算子会因各种原因被修改,例如提升可用性或修复 bug。这些修改可能带来两类破坏:
- 向后兼容(BC)破坏:旧程序在新版 PyTorch 运行时不再按预期工作(old program / new runtime problem);
- 向前兼容(FC)破坏:新程序在旧版 PyTorch 上无法运行(new program / old runtime problem)。
官方指南聚焦于维护向后兼容性的要求,并为此引入upgrader概念:一种用于将"新算子"适配为"旧算子行为"的方法。当新运行时读取一个包含旧算子定义的旧程序时,upgrader 会把旧算子定义适配成符合新算子实现的形式。显然,upgrader 只会在遇到旧算子定义时被应用——如果程序里没有"旧"算子,就完全不会触发 upgrader。
更完整的动机说明见 PyTorch 官方 RFC-0017(PyTorch Operator Versioning),本仓库的模型生成脚本 test/jit/fixtures_srcs/generate_models.py 也在文件头注释中引用了该 RFC。
判断变更是否 BC-breaking 的标准很简单:运行
python test/forward_backward_compatibility/check_forward_backward_compatibility.py如果失败,就说明你的改动是 BC-breaking 的,必须编写 upgrader。
典型 BC-breaking 变更示例
| 变更类型 | 旧 schema | 新 schema | 说明 |
|---|---|---|---|
| 返回类型更泛化 | foo(Tensor self, int a) -> int | foo(Tensor self, int a) -> Scalar | 返回值类型放宽 |
| 参数类型更具体 | foo(Tensor self, Scalar a) -> int | foo(Tensor self, int a) -> int | 入参类型收窄 |
| 新增参数无默认值 | foo(Tensor self, int a) -> int | foo(Tensor self, int a, int b) -> int | 旧程序无法提供新参数 |
| 内部实现变更 | schema 不变 | schema 不变 | 语义发生变化也属于破坏 |
| 弃用(deprecate)算子 | — | — | 直接移除视为破坏 |
需要特别区分的是:给算子新增带默认值的参数并不构成 BC-breaking,因此不需要 upgrader。例如def foo(x, y)变成def foo(x, y, z=100)是向后兼容的。这一条也在官方指南末尾的 NOTE 中单独强调。
整体工作流概览
官方指南把整个流程拆成两大步,每步再细分若干子步骤:
- 准备阶段:在改动算子前,先构建 PyTorch 源码版本、编写测试模块、生成"历史版本"测试模型并提交 PR;
- 实施阶段:修改算子、编写 upgrader(TorchScript 形式)、提升版本号、更新版本映射表、自动生成移动端 upgrader 代码、编写测试并提交单个 PR。
为什么要"先准备模型、再改算子"?因为一旦算子被修改,新运行时将再也无法导出包含历史算子定义的模型,也就无法再测试 upgrader。所以必须在改动之前把旧模型固化下来。
准备阶段:生成历史版本测试模型
1. 在 fixtures_src.py 中添加测试模块
在 test/jit/fixtures_srcs/fixtures_src.py 中添加一个使用被变更算子的torch.nn.Module。官方指南给出的示例:
class TestVersionedLinspaceV7(torch.nn.Module): def __init__(self) -> None: super().__init__() def forward(self, a: Union[int, float, complex], b: Union[int, float, complex]): c = torch.linspace(a, b, steps=5) d = torch.linspace(a, b) return c, d需要注意的约束:
- 模块必须实际使用被变更的算子(例如
torch.linspace); - 命名遵循
TestVersioned{${OpnameOverloadedname}}V${kProducedFileFormatVersion}规范,其中kProducedFileFormatVersion定义在 caffe2/serialize/versions.h; - 算子用法参考官方 PyTorch Docs(如
torch.linspace)。
本仓库的 fixtures_src.py 中已按此规范积累了多个版本的历史模块,例如TestVersionedDivTensorExampleV7、TestVersionedLinspaceV7、TestVersionedLinspaceOutV7、TestVersionedLogspaceV8、TestVersionedGeluV9、TestVersionedRandomV10等,可以作为命名与写法的直接参照。
2. 在 ALL_MODULES 中注册模块与算子
在 generate_models.py 的ALL_MODULES字典中,用模块实例作为 key、被变更算子名作为 value 注册:
# key: test module instance, value: changed operator name ALL_MODULES = { TestVersionedLinspaceV7(): "aten::linspace", }这样能保证导出的测试模型覆盖所有需要的内容。如果模型未覆盖被变更的算子,导出过程会失败。
3. 导出模型到 fixtures 目录
运行:
python test/jit/fixtures_src/generate_models.py将模型导出到test/jit/fixtures。注意仓库实际路径为test/jit/fixtures_srcs/(复数srcs),以仓库现有目录为准。
4. 提交变更并创建 PR
在改动运行时之前,先把"旧模型 + 注册逻辑"合入主干。这一步非常关键:等改动合并后再想切回旧版本源码重新生成模型会非常困难,所以一定要在改动前提交一个有效的测试模型。
实施阶段:修改算子并编写 upgrader
1. 做出算子变更
这是你的实际业务改动,例如把linspace的steps从可选参数改为必填参数。
2. 在 upgraders_entry.cpp 中编写 upgrader
upgrader 本体写在 torch/csrc/jit/operator_upgraders/upgraders_entry.cpp 的kUpgradersEntryMap映射中,key 为 upgrader 名称,value 为一段TorchScript 源码字符串。
命名规范(软性强制):<operator_name>_<operator_overload>_<start>_<end>。其中start和end表示:当 全局算子版本号 落在区间[start, end]内时,该 upgrader 会被应用到对应时期导出的算子。例如linspace_0_7表示linspace算子在第 0~7 版导出的模型需要使用这个 upgrader。
以linspace的outoverload 为例,先检查 upgrader 是否已存在于upgraders_entry.cpp:
- 若不存在:upgrader 名可直接取
linspace_out_0_{kProducedFileFormatVersion}; - 若已存在(例如已有
linspace_out_0_7,表示算子版本从 7 升到 8 时linspace.out发生了变化):- 如果能在版本升到 8 之前写出对所有
linspace版本都有效的 upgrader,就写linspace_out_0_{kProducedFileFormatVersion}; - 如果无法写出跨版本的 upgrader,则查看 versions.h 中版本升到 8 的日期:
- 若已过去180 天,可以写
linspace_out_8_{kProducedFileFormatVersion}并弃用旧的 upgrader; - 若未满 180 天,则等待满 180 天后再执行同样的操作。
- 若已过去180 天,可以写
- 如果能在版本升到 8 之前写出对所有
这个 180 天策略的目的,是保证"旧 upgrader 的覆盖区间"与"新 upgrader 的起始区间"之间始终有足够长的重叠保护期,避免出现既不被旧 upgrader 也不被新 upgrader 覆盖的模型版本。
以 linspace 为完整示例
当linspace版本升到 8 时,变更内容是把step(实为steps)从可选参数改为必填参数。旧 schema 为:
linspace(start: Union[int, float, complex], end: Union[int, float, complex], steps: Optional[int], dtype: Optional[int], layout: Optional[int], device: Optional[Device], pin_memory: Optional[bool]):新 schema 为:
linspace(start: Union[int, float, complex], end: Union[int, float, complex], steps: int, dtype: Optional[int], layout: Optional[int], device: Optional[Device], pin_memory: Optional[bool]):upgrader 只作用于旧模型(新模型不会触发)。先用伪 Python 描述修复逻辑:当旧模型里steps缺省(None)时,按新语义补默认值100再调用新算子:
def linspace_0_7(start: Union[int, float, complex], end: Union[int, float, complex], steps: Optional[int], *, dtype: Optional[int], layout: Optional[int], device: Optional[Device], pin_memory: Optional[bool]): if (steps is None): return torch.linspace(start=start, end=end, steps=100, dtype=dtype, layout=layout, device=device, pin_memory=pin_memory) return torch.linspace(start=start, end=end, steps=steps, dtype=dtype, layout=layout, device=device, pin_memory=pin_memory)实际的 upgrader 必须以TorchScript编写,下面就是仓库中linspace(0~7 版本导出)的真实注册代码(见 upgraders_entry.cpp):
static std::unordered_map<std::string, std::string> kUpgradersEntryMap( { {"linspace_0_7", R"SCRIPT( def linspace_0_7(start: Union[int, float, complex], end: Union[int, float, complex], steps: Optional[int], *, dtype: Optional[int], layout: Optional[int], device: Optional[Device], pin_memory: Optional[bool]): if (steps is None): return torch.linspace(start=start, end=end, steps=100, dtype=dtype, layout=layout, device=device, pin_memory=pin_memory) return torch.linspace(start=start, end=end, steps=steps, dtype=dtype, layout=layout, device=device, pin_memory=pin_memory) )SCRIPT"}, }linspace.out的 upgrader 也以同样的方式注册:
{"linspace_out_0_7", R"SCRIPT( def linspace_out_0_7(start: Union[int, float, complex], end: Union[int, float, complex], steps: Optional[int], *, out: Tensor): if (steps is None): return torch.linspace(start=start, end=end, steps=100, out=out) return torch.linspace(start=start, end=end, steps=steps, out=out) )SCRIPT"},应用时机:当新运行时加载旧模型时,会先检查旧模型的算子版本。若旧模型版本低于当前运行时版本,就把旧模型中的算子替换为上述 upgrader。
仓库中已有的其他 upgrader 一览
upgraders_entry.cpp 中目前注册了以下 upgrader,可作为不同变更类型的参考:
div_*_0_3(div.Tensor、div.Scalar、div.out及对应 inplace 变体):语义变更——整数除法行为改变。当任一操作数为浮点时走true_divide,否则用rounding_mode='trunc'的divide复现旧行为;full_0_4/full_out_0_4:语义变更——不再从 bool/int 填充值推断浮点 dtype,upgrader 在dtype is None时先把fill_value转成 float;linspace_0_7/linspace_out_0_7、logspace_0_8/logspace_out_0_8:参数语义变更——steps变为必填,缺省时补 100;gelu_0_9/gelu_out_0_9:新增approximate参数,upgrader 显式传approximate='none'复现旧行为。
upgrader 如何变成可执行的 Graph
kUpgradersEntryMap只是字符串源。真正执行时,仓库通过create_upgrader_graph把 TorchScript 字符串编译成Graph:
std::shared_ptr<Graph> create_upgrader_graph( const std::string& upgrader_name, const std::string& upgrader_body) { auto cu = std::make_shared<CompilationUnit>(); cu->define(std::nullopt, upgrader_body, nativeResolver(), nullptr); Function& jitFunc = cu->get_function(upgrader_name); GraphFunction& graphFunction = toGraphFunction(jitFunc); return graphFunction.graph(); }generate_upgraders_graph()遍历整个 map 逐个编译,populate_upgraders_graph_map()则在首次使用时一次性填充全局 upgrader graph 表。这意味着upgrader 的运行时形态是 JIT 编译后的 Graph,与 TorchScript 模型加载链路完全打通。
3. 提升文件格式版本号
同时把 caffe2/serialize/versions.h 中的kMaxSupportedFileFormatVersion和kProducedFileFormatVersion各加 1,并在该文件的历史注释区补充原因。当前仓库中该文件已经历多次提升,注释完整记录了历次变更:
constexpr uint64_t kMaxSupportedFileFormatVersion = 0xAL; // We describe new operator version bump reasons here: // 1) [01/24/2022] // We bump the version number to 8 to update aten::linspace // and aten::linspace.out to error out when steps is not // provided. (see: https://github.com/pytorch/pytorch/issues/55951) // 2) [01/30/2022] // Bump the version number to 9 to update aten::logspace and // and aten::logspace.out to error out when steps is not // provided. (see: https://github.com/pytorch/pytorch/issues/55951) // 3) [02/11/2022] // Bump the version number to 10 to update aten::gelu and // and aten::gelu.out to support the new approximate kwarg. // (see: https://github.com/pytorch/pytorch/pull/61439) constexpr uint64_t kProducedFileFormatVersion = 0xAL;versions.h还解释了版本化机制的关键设计(见文件中的 "Dynamic Versions and torch.jit.save vs. torch.save" 注释):
- 采用"生产文件格式版本号"描述归档的读取方式;归档中写入的版本至少等于当前生产版本,但如果包含某些符号则可能更高,这些条件版本称为"动态版本";
- 动态版本的价值在于:
torch.div语义改变时被赋予动态版本 4,保存使用torch.div的模块时归档也至少带上版本 4,从而阻止旧版 PyTorch 误用错误的除法语义;不使用这些算子的程序可以只写生产版本号,从而在旧版本上照常运行; - 对比之下,
torch.save类似 Python pickle,不保留算子语义、忽略动态版本——torch.save/torch.load跨版本加载时行为可能不同,而torch.jit.save会尽力保留算子语义。
注意:本文引用的版本号(
0xAL、kProducedFileFormatVersion = 0xAL)以当前仓库为准,较 README 中的示例(0x9L)更新,实际开发时一律读取 versions.h 中的现值。
4. 更新 version_map.cpp 版本映射表
在 torch/csrc/jit/operator_upgraders/version_map.cpp 中为算子注册版本映射条目,格式如下,且必须按 bump 到的版本号排序:
{{${operator_name.overloaded_name}, {{${bump_to_version}, "${upgrader_name}", "${old operator schema}"}}},对于linspace若存在两次版本提升(一次升到 8、一次升到 12),排序后的结果是:
{{"aten::linspace", {{12, "linspace_0_11", "aten::linspace(Scalar start, Scalar end, int? steps=None, *, ScalarType? dtype=None, Layout? layout=None, Device? device=None, bool? pin_memory=None) -> Tensor"}}}, {{8, "linspace_0_7", "aten::linspace(Scalar start, Scalar end, int? steps=None, *, ScalarType? dtype=None, Layout? layout=None, Device? device=None, bool? pin_memory=None) -> Tensor"}}},version_map.cpp中实际存储的是std::unordered_map<std::string, std::vector<UpgraderEntry>> operatorVersionMap,并在首次访问时通过get_operator_version_map()对每个算子的条目按bumped_at_version降序排序(见该文件中的std::sort逻辑),保证查找时优先命中最新版本的 upgrader。文件中还提供了test_only_add_entry、test_only_remove_entry、test_only_reset_flag等测试专用接口,以及calculate_package_version_based_on_upgraders/get_version_calculator_flag用于按 upgrader 计算 package 版本的开关。
当前仓库中已注册的算子版本映射包括:
| 算子 | bump 版本 | upgrader 名 | 旧 schema 摘要 |
|---|---|---|---|
aten::linspace | 8 | linspace_0_7 | steps=None可选 |
aten::linspace.out | 8 | linspace_out_0_7 | steps=None可选 |
aten::logspace/.out | 9 | logspace_0_8/logspace_out_0_8 | steps=None可选 |
aten::div.*(8 个变体) | 4 | div_*_0_3 | 旧整数除法语义 |
aten::full/.out | 5 | full_0_4/full_out_0_4 | 旧 dtype 推断 |
aten::gelu/.out | 10 | gelu_0_9/gelu_out_0_9 | 无approximate参数 |
5. 自动生成移动端 upgrader 代码
重新从源码构建 PyTorch 后,运行:
python pytorch/torchgen/operator_versions/gen_mobile_upgraders.py该脚本会自动更新 torch/csrc/jit/mobile/upgrader_mobile.cpp,把upgraders_entry.cpp与version_map.cpp的内容同步到移动端(lite interpreter)使用的代码中。官方建议的构建方式是pip install -e . --no-build-isolation。
6. 编写测试
利用步骤 1 生成的旧模型,在test/test_save_load_for_op_versions.py中添加测试。仓库中对应文件为 test/jit/test_save_load_for_op_version.py(测试类TestSaveLoadForOpVersion(JitTestCase)),其中已包含test_versioned_div_scalar、test_versioned_div_scalar_reciprocal、test_versioned_div_scalar_inplace等大量用例。官方指南给出的测试模板:
@settings(max_examples=10, deadline=200000) # A total of 10 examples will be generated @given( sample_input=st.tuples(st.integers(min_value=5, max_value=199), st.floats(min_value=5.0, max_value=199.0)) ) # Generate a pair (integer, float) @example((2, 3, 2.0, 3.0)) # Ensure this example will be covered def test_versioned_div_scalar(self, sample_input): # Step 1. Write down the old behavior of this operator, if possible def historic_div_scalar_float(self, other: float): return torch.true_divide(self, other) # Step 2. Write down how current module should look like class MyModuleFloat(torch.nn.Module): def __init__(self) -> None: super().__init__() def forward(self, a, b: float): return a / b try: # Step 3. Load the old model and it will apply upgrader v3_mobile_module_float = _load_for_lite_interpreter( pytorch_test_dir + "/jit/fixtures/test_versioned_div_scalar_float_v2.ptl") v3_server_module_float = torch.jit.load( pytorch_test_dir + "/jit/fixtures/test_versioned_div_scalar_float_v2.ptl") except Exception as e: self.skipTest("Failed to load fixture!") # Step4. Load the new model and it won't apply the upgrader current_mobile_module_float = self._save_load_mobile_module(MyModuleFloat) current_server_module_float = self._save_load_module(MyModuleFloat) for val_a, val_b in product(sample_input, sample_input): a = torch.tensor((val_a,)) b = val_b def _helper(m, fn): m_result = self._try_fn(m, a, b) fn_result = self._try_fn(fn, a, b) if isinstance(m_result, Exception): self.assertTrue(fn_result, Exception) else: self.assertEqual(m_result, fn_result) # Ensure the module loaded from the old model with upgrader # has the same result as the module loaded from the new model _helper(v3_mobile_module_float, current_mobile_module_float) _helper(v3_mobile_module_float, current_server_module_float) # Ensure the module loaded from the new model with upgrader # has the same result as the module loaded from the new model _helper(current_mobile_module_float, torch.div) _helper(current_server_module_float, torch.div)测试的核心验证逻辑分为四步:
- 描述旧行为:写出历史版本下算子的等价实现(如
torch.true_divide); - 描述当前模块形态:定义当前新语义下的
nn.Module; - 加载旧模型:用
_load_for_lite_interpreter和torch.jit.load分别加载旧.ptl模型,此时会自动应用 upgrader; - 交叉比对结果:旧模型(经 upgrader)的输出必须与当前模型、以及直接调用新算子的输出保持一致;同时验证新模型加载时不会应用 upgrader。
仓库中 test/jit/test_save_load_for_op_version.py 的既有用例还覆盖了 int/float 两种标量、inplace 变体、reciprocal 变体等更多场景,可继续参照。
7. 提交单个 PR
把第 2 步的所有改动放在一个PR 中提交。官方指南还给出两个参考 PR(新增logspace测试模块的 PR、更新logspace算子的 PR),用于整体感受改动的完整形态。
关于 FC-breaking 的说明
官方指南明确指出:FC-breaking 变更的解决方案目前还不存在。如果你遇到如下 FC 破坏场景,且希望得到支持,请到 PyTorch Forum 或 GitHub 上报,官方会据此排定优先级:
- 新增默认参数;
- 在非末尾位置(非 out 参数区之前)插入新的默认参数,例如
foo(Tensor self, int a, int b=1, Tensor(a!) out)变为foo(Tensor self, int a, int c=1, int b=1, Tensor(a!) out); - 在 schema 非末尾位置新增 out 参数;
- 新增容器类型(
ListType/DictType)的默认参数,如int[2] c=1; - 修改默认参数名(仅当该参数总是使用默认值、序列化时会忽略它时才可行,其他情况都会失败);
- 修改默认参数的默认值(新运行时若以默认值保存该参数,旧运行时会用旧默认值导致错误输出);
- 新增算子。
总结与自查清单
完成一次 BC-breaking 算子变更的完整动作清单:
- 改动前:在 test/jit/fixtures_srcs/fixtures_src.py 添加
TestVersioned{Op}{Overload}V{version}模块;在 test/jit/fixtures_srcs/generate_models.py 的ALL_MODULES注册;运行python test/jit/fixtures_src/generate_models.py导出旧模型;先提交模型相关 PR; - 改动后:修改算子;在 torch/csrc/jit/operator_upgraders/upgraders_entry.cpp 以 TorchScript 编写命名规范为
<op>_<overload>_<start>_<end>的 upgrader;在 caffe2/serialize/versions.h 提升kMaxSupportedFileFormatVersion与kProducedFileFormatVersion并写明原因;在 torch/csrc/jit/operator_upgraders/version_map.cpp 添加按版本排序的映射条目;重建后运行python pytorch/torchgen/operator_versions/gen_mobile_upgraders.py同步移动端代码;在 test/jit/test_save_load_for_op_version.py 添加测试;最后把所有改动合成一个 PR提交。
核心原则一句话:upgrader 只服务于"旧模型 + 新运行时"这一种组合——新模型永远不会触发 upgrader,旧模型在旧运行时也不需要 upgrader。把握好这一点,再配合版本映射表与 180 天策略,就能在 PyTorch 生态中安全、可追溯地演进算子语义。
【免费下载链接】pytorchTensors and Dynamic neural networks in Python with strong GPU acceleration项目地址: https://gitcode.com/GitHub_Trending/py/pytorch
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考