Kornia 修复 pinned-torch CI 中 triton 孤儿依赖导致的 Dynamo/ONNX 导出崩溃
【免费下载链接】kornia🐍 Geometric Computer Vision Library for Spatial AI项目地址: https://gitcode.com/gh_mirrors/ko/kornia
导读
本文剖析 Kornia 仓库中一次典型的"构建环境漂移"问题修复(对应 changelog.d/+migration-096.fixed.md):在 pinned-torch CI 矩阵中,uv sync依据 lockfile 安装的是 PyPI 版 torch,而矩阵各腿随后将 torch 单独替换为+cpu构建——该构建的 wheel 完全不声明triton,导致 lockfile 里的 triton 变成"孤儿",最终 torch 2.6.0 腿在 32 个 Dynamo / ONNX 导出测试的导入阶段集体崩溃。文章将完整还原问题机理、安装步骤的修复方案、环境校验脚本check_torch_env.py的实现细节,以及测试侧external_data=False的兼容性处理,读者读完可以掌握这类"按依赖元数据精确换装 torch"的 CI 加固套路,并可直接复用其中的环境一致性校验思路。
一、问题现场:2.6.0 腿在导入阶段集体阵亡
1.1 两个安装阶段的叠加效应
Kornia 的 tests.yml 构建 CI 环境分两步:
uv sync安装 lockfile:此时安装的 torch 来自 PyPI,其Requires-Dist中在 Linux x86_64 + Python < 3.13 条件下声明了triton (==3.2.0)这类 sidecar 依赖(参见 test_check_torch_env.py 中的PYPI_WHEEL_REQUIRES);- 矩阵腿单独换装 torch:
pinned通道执行uv pip install --reinstall-package torch --index-url https://download.pytorch.org/whl/cpu "torch==$PYTORCH_VERSION"(tests.yml),仅替换 torch 本身。
问题在于:CPU wheel 索引的+cpu构建声明了比 PyPI wheel 更窄的依赖集——完全不声明triton。changelog 明确注明这一点在 2.5.1、2.6.0、2.9.1 和 2.14.0 上均已核实(见 check_torch_env.py 的模块 docstring)。测试代码中CPU_WHEEL_REQUIRES变量给出了 torch-2.6.0+cpu wheel 的完整Requires-Dist列表,确实只有 filelock、typing-extensions、networkx、jinja2、fsspec、setuptools、sympy、opt-einsum、optree 等,没有 triton(test_check_torch_env.py)。
1.2 孤儿 triton 为什么会被 torch 捡起来
uv sync为 lockfile 的 torch(例如 2.14.0)装的 triton(3.8.0)在换装后仍然残留在虚拟环境中。更关键的是 torch 的分发机制:torch 对 inductor triton 路径的判断依据是"导入是否可用",而非版本是否匹配(_is_triton_available()只测试import triton是否成功)。于是:
- torch 2.6.0 换上后,发现环境中存在可导入的 triton,便走 inductor 的 triton 路径;
- 但该 triton 是 3.8.0,早已移除了 torch 2.6.0 需要的
from triton.backends.compiler import AttrsDescriptor这个名称; - 结果 32 个 dynamo 和 export 测试在导入阶段直接抛 ImportError——且这个腿从未绿过。
这正是一个典型的"孤儿依赖被静默拾取"问题:安装元数据(wheel 声明)与运行时分发逻辑(按 import 可用性)产生了错位。
二、修复方案:安装步骤同步"瘦身",验证步骤整体断言
2.1 安装步骤:丢弃未声明的 sidecar 包
修复后,torch 换装步骤在完成--reinstall-package torch之后立即追加一次"对账"(tests.yml):
report=$(pixi run -e ... uv run --no-sync \ python .github/scripts/check_torch_env.py --list-undeclared-sidecars | tr -d '\r') # 结果行必须存在,否则视为解析失败而非环境干净 if ! printf '%s\n' "$report" | grep -q '^undeclared-sidecars:'; then echo "the sidecar check reported no result; refusing to assume the venv is clean" >&2 exit 1 fi stale=$(printf '%s\n' "$report" | sed -n 's/^undeclared-sidecars:[[:space:]]*//p' | tail -n 1) if [[ -n "$stale" ]]; then echo "Removing packages the installed torch does not declare: $stale" pixi run -e ... uv run --no-sync uv pip uninstall $stale fi这段逻辑的关键点有三:
- 对账发生在换装步骤内部,而不是单独一个步骤。工作流注释明确解释:如果把对账放在独立的步骤里,它可能被重排序到换装之前——那时环境里还是 lockfile 的 torch(它声明triton),检查结果为空,问题就会在"全绿"的运行中幸存下来(tests.yml)。把对账放进换装自己的步骤里,顺序就"不可重排"了。
- 空捕获等于失败。脚本总是会打印
undeclared-sidecars:前缀;若 grep 不到该前缀,说明解析坏了,工作流会拒绝假设环境是干净的。这个约定同时被 test_check_torch_env.py 的TestWorkflowWiring测试用grep -q '^undeclared-sidecars:'断言钉死。 - 允许名单是精心收缩的:
SIDECAR_PACKAGES = ("triton", "pytorch-triton")(check_torch_env.py)。脚本刻意不做"已安装但不在已装 torch 闭包内"的通用 diff——因为虚拟环境里绝大部分包(kornia 自身的依赖、测试工具链)本来就合法地不在 torch 的闭包内,且安装元数据不记录来源,diff 无法区分孤儿与根依赖。换装后幸存的其他包(lockfile 的nvidia-*、cuda-*wheel)只占磁盘、在+cputorch 上不会被加载,因此无害。未来若出现新的"按可用性分发"的 sidecar,再往名单里加。
2.2 验证步骤:对"整个环境"断言,而非只查torch.__version__
原先只读torch.__version__的验证方式,无法发现"其余包仍属于另一个 torch"的 venv。修复后的 "Verify PyTorch version" 步骤调用:
resolved=$(pixi run -e ... uv run --no-sync \ python .github/scripts/check_torch_env.py \ --channel "$TORCH_CHANNEL" --expected "$EXPECTED_PYTORCH_VERSION" \ | tr -d '\r' | sed -n 's/^torch-version: //p' | tail -n 1) if [[ -z "$resolved" ]]; then echo "the environment check did not report a torch version" >&2 exit 1 fi(tests.yml)。其中有两个被测试钉死的契约:
- 只有通过的检查才打印
torch-version:行。若在失败路径也打印,误配的 venv 仍会给工作流一个"看起来有效"的答案,shell 的-z守卫就会变成死代码(test_check_torch_env.py); - 通道语义:
pinned要求版本精确等于矩阵值(忽略+cpu本地段);stable把矩阵值当作地板,因为上游探测装的是 stable 索引当天解析出的版本(check_torch_env.py)。
验证步骤同时把解析出的实际版本写入GITHUB_OUTPUT,供后续按版本命名的产物工件使用。
三、check_torch_env.py:一个可复用的环境一致性校验器
check_torch_env.py 是本修复的"唯一事实来源",工作流的注释都指向它。其核心函数如下。
3.1runtime_requirements:按 marker 求值当前依赖
读取已装 torch 的Requires-Dist,用packaging.requirements.Requirement解析,并把extramarker 置空后求值——只保留"无 extra、纯运行时"条件下成立的依赖(check_torch_env.py)。测试test_drops_extras_and_false_markers验证了 setuptools(python_version >= "3.12")和 opt-einsum / optree(仅 extra)都会被正确过滤(test_check_torch_env.py)。
3.2scan_installed:按首个 sys.path 条目优先 + 同位置冲突检测
distributions()按sys.path顺序遍历、首个匹配胜出——这与importlib.metadata.version和普通import的解析结果一致,所以"first-wins"是诚实答案。但同一分发位置内出现两个name-version.dist-info目录则没有先后可言,那正是"半成品 venv"(例如被中断的uv pip install --reinstall-package torch的残留),会被单独报告出来,而不是由文件系统顺序"随机裁决"(check_torch_env.py)。测试用临时目录构造冲突 dist-info 验证该行为(test_check_torch_env.py)。
3.3undeclared_sidecars+check_environment:四类问题的完整诊断
for name in undeclared_sidecars(requires, installed, environment): problems.append( f"{name} {installed[name]} is installed, but torch {torch_version} declares no dependency on it: " "torch dispatches onto an importable sidecar without checking its version (#4199)" )check_environment汇总四类问题(check_torch_env.py):
- 版本不符:不满足通道约束;
- 缺失依赖:torch 声明了但环境里没有;
- 版本滞留:依赖还停在 lockfile 版本,未随换装移动——测试
test_flags_a_requirement_left_at_the_locked_version用sympy 1.14.0复现了这一点; - 孤儿 sidecar:装了但当前 torch 未声明(#4199 主故障);
- 同包多版本:一个名字出现多个冲突版本,即半成品 venv。
失败时,诊断信息会附带一张name version需求表(缩进两空格打印到 stderr),且只在确实有错时才打印——健康腿不会被约 15 行的噪声刷屏;成功时打印torch-version: x.y.z并返回 0(check_torch_env.py)。
四、测试侧修复:ONNX 导出改为external_data=False
4.1 被 import 错误掩盖的五个用例
triton 崩溃发生在 32 个 dynamo / export 测试的导入阶段,把tests/core/test_small_linalg.py里 5 个 ONNX 用例的真实问题也一并掩盖了。环境修好后,这些用例暴露出 torch 2.6 dynamo 导出器的一个兼容性差异。
4.2 BytesIO 目标下的导出器差异
test_small_linalg.py 的_export_kwargs展示了完整的分层兼容逻辑:
kwargs = {"opset_version": 18} if torch_version_ge(2, 5, 0): kwargs["dynamo"] = use_dynamo_exporter if use_dynamo_exporter and torch_version_ge(2, 6, 0): kwargs["external_data"] = False return kwargs原因链非常清晰(源码注释逐条给出):
- 这些用例把图导出到
BytesIO; - dynamo 导出器默认
external_data=True,会把手柄直接交给 onnxscript 的save_model_with_external_data——它需要的是文件系统路径,于是在 torch 2.6 上翻译成功后才抛出TypeError: expected str, bytes or os.PathLike object, not BytesIO; - 2.9.1 和 2.14.0 通过更新的 onnxscript framework API 能接受 buffer;
- 这些图只有少量节点、没有 initializer,任何版本下都没有外部数据要写,所以
external_data=False无副作用; - 该参数必须用
torch_version_ge(2, 6, 0)门控,因为external_data=在 2.6 之前不存在——而_export_kwargs承诺对 kornia 支持的所有 torch 版本都返回合法 kwargs。
4.3 "导出成功本身就是门槛"的测试哲学
该文件的另一个设计要点(test_small_linalg.py):缺失 lowering 会直接抛异常(在 2.9.1 上实测:legacy 导出器抛UnsupportedOperatorError,dynamo 导出器抛ConversionError),所以"导出调用成功"本身就是最硬的门槛。_assert_basic_arithmetic_only正则(_LINALG_OP_RE)则补上另一种情况:防止未来某个分解把 linalg 运算"成功导出"成重操作——它特意用子串匹配aten_linalg_inv这种 dynamo 导出的 fallback 节点命名,并用锚定规则避免qr误吞Sqrt、lu误吞Relu/Selu/Elu/Celu/PRelu/LeakyRelu/ThresholdedRelu全家(test_small_linalg.py)。
五、测试对脚本与工作流契约的双向锁定
本修复的可维护性很大程度上来自 test_check_torch_env.py 对"脚本与工作流两端契约"的联合断言:
TestWorkflowWiring直接读取 tests.yml 文本,断言换装步骤内先--reinstall-package torch后--list-undeclared-sidecars(顺序是修复的全部意义)、uv pip uninstall $stale存在、验证步骤含--channel "$TORCH_CHANNEL" --expected "$EXPECTED_PYTORCH_VERSION"与-z "$resolved"守卫、以及工作流解析的前缀与脚本打印的前缀一致(test_check_torch_env.py)。注释特别说明:任一侧改名前缀,另一侧会静默退化为 no-op。TestMain强调main是工作流 grep 并据此门控的表面:stdout 前缀是tests.yml解析的对象,且只有通过的检查才打印版本——破坏任一约定,工作流步骤会退化成"保持绿色但什么都不做"的 no-op(test_check_torch_env.py)。- 测试代码里还保留着
MIS_PROVISIONED这个"罪证":2.6.0+cpu torch + 3.8.0 triton + 2.14.0 闭包——正是 #4199 描述的误配环境的字面快照(test_check_torch_env.py)。
六、经验总结:可迁移的三条 CI 加固原则
- 依赖换装必须连带对账:当你把某个包从 lockfile 的版本换成另一个通道/构建(如 PyPI →
+cpuwheel)时,不要只换包本身,还要按已装包声明的依赖闭包清理幸存者——尤其警惕按 import 可用性分发的 sidecar。 - 验证要断言整个环境,而不是单点版本:只查
torch.__version__发现不了"其余包仍属于另一个 torch"的 venv。把"所有运行时依赖齐备、无未声明 sidecar、无重复版本"作为验证契约,让误配在 setup 阶段就失败,而不是变成一堵"看起来毫不相关"的测试失败墙。 - 用测试锁定工作流与脚本的双向契约:把"脚本打印什么前缀、工作流 grep 什么前缀、步骤顺序如何"写成读取工作流文件本身的测试,任何一侧被破坏都会在 PR 阶段暴露,而不是在 CI 上静默退化。
【免费下载链接】kornia🐍 Geometric Computer Vision Library for Spatial AI项目地址: https://gitcode.com/gh_mirrors/ko/kornia
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考