news 2026/9/11 16:10:24

PyTorch Operator Upgrader 完整指南:为 BC-breaking 算子变更编写版本升级器(operator_upgraders 源码解析)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch Operator Upgrader 完整指南:为 BC-breaking 算子变更编写版本升级器(operator_upgraders 源码解析)

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) -> intfoo(Tensor self, int a) -> Scalar返回值类型放宽
参数类型更具体foo(Tensor self, Scalar a) -> intfoo(Tensor self, int a) -> int入参类型收窄
新增参数无默认值foo(Tensor self, int a) -> intfoo(Tensor self, int a, int b) -> int旧程序无法提供新参数
内部实现变更schema 不变schema 不变语义发生变化也属于破坏
弃用(deprecate)算子直接移除视为破坏

需要特别区分的是:给算子新增带默认值的参数并不构成 BC-breaking,因此不需要 upgrader。例如def foo(x, y)变成def foo(x, y, z=100)是向后兼容的。这一条也在官方指南末尾的 NOTE 中单独强调。

整体工作流概览

官方指南把整个流程拆成两大步,每步再细分若干子步骤:

  1. 准备阶段:在改动算子前,先构建 PyTorch 源码版本、编写测试模块、生成"历史版本"测试模型并提交 PR;
  2. 实施阶段:修改算子、编写 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 中已按此规范积累了多个版本的历史模块,例如TestVersionedDivTensorExampleV7TestVersionedLinspaceV7TestVersionedLinspaceOutV7TestVersionedLogspaceV8TestVersionedGeluV9TestVersionedRandomV10等,可以作为命名与写法的直接参照。

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. 做出算子变更

这是你的实际业务改动,例如把linspacesteps从可选参数改为必填参数。

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>。其中startend表示:当 全局算子版本号 落在区间[start, end]内时,该 upgrader 会被应用到对应时期导出的算子。例如linspace_0_7表示linspace算子在第 0~7 版导出的模型需要使用这个 upgrader。

linspaceoutoverload 为例,先检查 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 天策略的目的,是保证"旧 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_3div.Tensordiv.Scalardiv.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_7logspace_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 中的kMaxSupportedFileFormatVersionkProducedFileFormatVersion各加 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会尽力保留算子语义。

注意:本文引用的版本号(0xALkProducedFileFormatVersion = 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_entrytest_only_remove_entrytest_only_reset_flag等测试专用接口,以及calculate_package_version_based_on_upgraders/get_version_calculator_flag用于按 upgrader 计算 package 版本的开关。

当前仓库中已注册的算子版本映射包括:

算子bump 版本upgrader 名旧 schema 摘要
aten::linspace8linspace_0_7steps=None可选
aten::linspace.out8linspace_out_0_7steps=None可选
aten::logspace/.out9logspace_0_8/logspace_out_0_8steps=None可选
aten::div.*(8 个变体)4div_*_0_3旧整数除法语义
aten::full/.out5full_0_4/full_out_0_4旧 dtype 推断
aten::gelu/.out10gelu_0_9/gelu_out_0_9approximate参数

5. 自动生成移动端 upgrader 代码

重新从源码构建 PyTorch 后,运行:

python pytorch/torchgen/operator_versions/gen_mobile_upgraders.py

该脚本会自动更新 torch/csrc/jit/mobile/upgrader_mobile.cpp,把upgraders_entry.cppversion_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_scalartest_versioned_div_scalar_reciprocaltest_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)

测试的核心验证逻辑分为四步:

  1. 描述旧行为:写出历史版本下算子的等价实现(如torch.true_divide);
  2. 描述当前模块形态:定义当前新语义下的nn.Module
  3. 加载旧模型:用_load_for_lite_interpretertorch.jit.load分别加载旧.ptl模型,此时会自动应用 upgrader;
  4. 交叉比对结果:旧模型(经 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 算子变更的完整动作清单:

  1. 改动前:在 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;
  2. 改动后:修改算子;在 torch/csrc/jit/operator_upgraders/upgraders_entry.cpp 以 TorchScript 编写命名规范为<op>_<overload>_<start>_<end>的 upgrader;在 caffe2/serialize/versions.h 提升kMaxSupportedFileFormatVersionkProducedFileFormatVersion并写明原因;在 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 16:09:23

SerenityOS json 命令实战指南:语法高亮、缩进美化与点分查询

SerenityOS json 命令实战指南&#xff1a;语法高亮、缩进美化与点分查询 【免费下载链接】serenity The Serenity Operating System &#x1f41e; 项目地址: https://gitcode.com/GitHub_Trending/se/serenity json 是 SerenityOS 自带的 JSON 命令行工具&#xff0c;…

作者头像 李华
网站建设 2026/9/11 16:09:18

YOLOv5火灾识别与检测系统实战:环境配置、训练与实时推理

简介&#xff1a;这是一份基于YoloV5的火灾识别与检测系统完整项目&#xff0c;面向计算机视觉、深度学习方向的在校学生、算法工程师及毕设/课设开发者&#xff0c;覆盖从数据集配置、模型训练到推理部署的完整流程&#xff0c;可快速上手目标检测与火灾区域定位。资源共130个…

作者头像 李华
网站建设 2026/9/11 16:08:11

Typst 快速安装与配置指南:5 步跑通 PDF 编译

Typst 快速安装与配置指南&#xff1a;5 步跑通 PDF 编译 【免费下载链接】typst A markup-based typesetting system that is powerful and easy to learn. 项目地址: https://gitcode.com/GitHub_Trending/ty/typst Typst 是一套把 .typ 源文件直接编译成 PDF 的标记语…

作者头像 李华
网站建设 2026/9/11 16:04:38

SSM金融终端管理系统:JSP层重梳与国密加密实践

简介&#xff1a;本资源是一套基于SSM框架的金融支付终端管理系统毕设项目&#xff0c;面向计算机专业本科生及Java全栈初学者&#xff0c;解决金融场景下支付终端统一管理、交易处理与账务审计等核心需求。压缩包含1246个文件&#xff0c;总大小16.72MB&#xff0c;其中Java源…

作者头像 李华
网站建设 2026/9/11 16:04:10

猫抓:浏览器端一站式媒体资源嗅探与下载工具

猫抓&#xff1a;浏览器端一站式媒体资源嗅探与下载工具 【免费下载链接】cat-catch 猫抓 浏览器资源嗅探扩展 / cat-catch Browser Resource Sniffing Extension 项目地址: https://gitcode.com/GitHub_Trending/ca/cat-catch 猫抓&#xff08;cat-catch&#xff09;是…

作者头像 李华
网站建设 2026/9/11 16:03:55

freeCodeCamp 如何对 Docker 本地环境运行 Playwright E2E 测试?

freeCodeCamp 如何对 Docker 本地环境运行 Playwright E2E 测试&#xff1f; 【免费下载链接】freeCodeCamp freeCodeCamp.orgs open-source codebase and curriculum. Learn math, programming, and computer science for free. 项目地址: https://gitcode.com/GitHub_Trend…

作者头像 李华