☰
MindSpore图模式中常量Tensor触发重编译的警告解析与修复
2026/10/5 4:32:45 网站建设 项目流程

如果MindSpore训练刚开始,日志里突然冒出一行英文提示:Constant value tensor are detected in tuple or list, which might cause recompiling,我的建议是你别慌,但也别完全无视。我第一次遇到这个报警,是在一个图像分类模型上。训练没有中断,Loss还在正常下降,但仔细看每个step的耗时,比预期慢了差不多三分之一——那会儿我还不知道,这就是编译器在后台反复重画计算图造成的隐性开销。

这个提示几乎是所有从PyTorch迁移到MindSpore的玩家都会碰到的老熟人,因为它和静态图编译机制深度绑定。今天就把这个报警从原理到排查再到修复,掰开揉碎讲清楚。文章里所有修复方案我都实际跑过,结论可以直接拿来用。

1. 报警现场还原:它出现在哪一步,又意味着什么

1.1 一段能稳定触发报警的Demo代码

先给一段可以稳定复现报警的代码。你不需要理解它的业务逻辑,只需要看它犯的“结构性错误”。

import mindspore as ms from mindspore import nn, ops, Tensor import numpy as np class DemoNet(nn.Cell): def __init__(self): super(DemoNet, self).__init__() self.fc = nn.Dense(16, 4) # 注意:这是Python的list,里面装的是直接创建的常量Tensor self.gamma_list = [ Tensor(np.ones(4, np.float32)), Tensor(np.full(4, 0.5, np.float32)) ] def construct(self, x): out = self.fc(x) results = [] for gamma in self.gamma_list: results.append(out * gamma) return ops.stack(results) if __name__ == "__main__": ms.set_context(mode=ms.GRAPH_MODE) net = DemoNet() input_data = Tensor(np.random.randn(8, 16).astype(np.float32)) print(net(input_data))

在MindSpore 2.x、Graph模式下跑这段代码,日志里大概率会出现类似这样的一整段警告:

[WARNING] CORE(..., pid, tid): Constant value tensor are detected in tuple or list, which might cause recompiling. You could set const_arg to False by mental.mutable ...

它提示的关键词就是Constant value tensor、tuple or list、recompiling。这段代码里有两个错误模式:

  • self.gamma_list是一个Python list,里面装着用Tensor()直接创建出来的常量张量;
  • construct里又在动态构造results这个list,并往里append中间结果。

这两件事单独出现都问题不大,但叠加在一起,就精准踩中了图编译器对“常量容器”的识别逻辑。

1.2 Warning还是Error?严重程度其实有分级

这个提示在绝大多数场景下是一条Warning,不会中断训练。但这不代表可以放置不管,它的严重程度是分级的:

场景表现实际影响
只在首次构图时出现一条,之后不再出现Warning一次,训练正常低,基本无害
每次训练迭代都出现每个step都慢一截,日志刷屏中,典型性能陷阱
list里装的常量值在训练中频繁变化反复触发重编译,CPU占用飙升高,有可能让训练整体崩溃
叠加在@ms.jit、动态shape、分布式并行等场景重编译变成递归行为,甚至出现Recursive compilation之类的错误高,直接阻断训练

我在实际项目中见过最离谱的情况:一个显存占用很小的模型,因为这个问题,单step耗时从80ms上涨到接近200ms。查了半天没有内存泄漏,最后定位到就是recompiling在作怪。所以我的态度很明确:只要看到这条Warning,就当Bug去修,不要心存侥幸。

2. 为什么list里的常量Tensor会触发recompiling:图编译机制拆解

2.1 静态图编译的三个阶段

MindSpore的Graph模式(静态图模式)不是Python边解释边执行,而是先把Python代码“翻译”成一张计算图,再让后端执行。这个过程大体可以分成三步:

  1. Parse:扫描construct函数以及它调用的子函数,把Python语法转成MindSpore的中间表示;
  2. Optimize:对中间表示做算子融合、常量折叠、内存规划等优化;
  3. Codegen/Execute:生成可执行图并运行。

在第二步里,有一个非常关键的优化动作叫常量折叠。如果一个值在编译期就能确定下来(比如一个整型数字、一个字符串、一个直接拿np.array创建的Tensor),编译器会把它直接“烙”进图结构里,不再当成运行时输入。这是合理的、也是静态图性能好的原因之一——省掉了很多运行期的动态判断。

但问题来了:如果某个常量被烙进图里之后,下一次运行时它的值变了,那编译器手里那张旧图就失效了。它必须从头再解析一遍、再优化一遍、再生成一遍。这个“后补”的过程,就是报错里的recompiling。

2.2 list和tuple在编译器眼中是“结构节点”

你可能觉得,list就是Python的list,里面存几个Tensor能有多大事?

在Python世界确实没什么大事。但在图编译器里,list和tuple不是简单的数据容器,它们会变成图中的结构节点。随便搜一下IR文件就能看到MakeTuple、ListAppend这样的节点——每一个都代表“程序里出现了一个元组/列表结构”。

当一个list里的元素是常量Tensor,编译器为了支持“这个list在运行期可以继续使用”,最省事的做法就是把这些常量的值直接内联到结构节点里。比如:

  • [Tensor(1.0), Tensor(2.0)]会被编译成“包含两个值节点的tuple结构”;
  • 如果Tensor(1.0)是一个Constant value tensor,它的具体数值就成了这个结构的一部分。

一旦你下一次迭代时,list里的Tensor数值变了、引用变了、或者list本身多了一个元素少了一个元素,这个结构就不再匹配旧图,编译器只能选择重新编译。这就解释了为什么报警原文说的是“detected in tuple or list”——它检测到的不是普通计算Tensor,而是嵌在容器结构里的常量Tensor。

2.3 缓存键失效:重编译的直接导火索

再往深一层讲,MindSpore的图编译器为了方便做编译缓存,会给每个方法/模块生成一个缓存键。我个人的理解是:这个键可以粗略看作“源码位置 + 当前参与构图的对象特征”的组合。list里的常量Tensor因为参与了结构生成,它的值会被纳入缓存键的计算范围。

一旦常量Tensor的值变成另一副面孔,缓存键就对不上了。编译器查缓存查不到,只能老老实实重新编译一遍。如果你每个step都在变,那就每个step都重编一次。

提示:如果你观察到的报警频率是“每个step都报”,基本可以认定这个list里的Tensor值在每次运行都不一样——比如你在__init__里写死了一个Tensor,但construct里又去改它,或者这个list本身就是动态算出来的。这种情况单靠“改list为tuple”往往解决不了,必须让编译器从源头上不再把它当常量。

2.4 从PyTorch迁过来的用户为什么最容易中招

我从PyTorch转向MindSpore的前几周,几乎天天踩这种坑。原因很简单:PyTorch的Dynamic Graph是“边跑边建图”,list就是普通list,你随便append什么它都ok,完全没有“编译期/运行期”的界限。

可静态图不同,它需要提前知道图结构。习惯PyTorch写法的人,很自然就会在forward里这么写:

lst = [] for i in range(n): lst.append(some_tensor) return torch.stack(lst)

这段代码搬到MindSpore的construct里,就成了最标准的报警模板。所以如果你也刚从PyTorch过来,看到这条Warning不要太紧张——它不是你写错了,只是你的编程习惯还没适配静态图模式。

3. 从报警到定位:一次完整的排查链路

3.1 先分清报警频率,别一上来就改代码

看到Warning后,第一件事不是改代码,而是观察它出现的规律。

  • 只出现一次:说明只是首次构图时检测到某个常量为图结构的一部分,后续没再变,影响很小;
  • 每个step稳定出现:说明list内容在迭代中被动态改变,这是重编译的主犯;
  • 与数据加载过程同步出现:很可能是Dataset里返回了Tensor,被塞进了construct的list结构,这个要注意,后面会讲。

判断方法就是开一个训练脚本,盯着日志跑10个step,数一数Warning出现的次数。次数不上涨,可以晚点处理;次数跟着step走,立刻进入定位流程。

3.2 开启save_graphs,让IR文件说话

排查这类问题,最硬核的工具是保存IR图。在训练脚本入口加一行:

ms.set_context(save_graphs=True)

或者旧版本用环境变量:

export MS_DEV_SAVE_GRAPHS=1

跑几个step之后,当前工作目录下会生成类似rank_0/的目录,里面是各种.ir文件和.dot文件。.ir是文本格式,直接打开就能看图结构。

接着重点搜索这几个节点名:

  • MakeTuple:代表把多个值打包成tuple;
  • ListAppend:代表对list执行append;
  • TensorConstant、ScalarConstant、Constant:代表常量节点。

排查逻辑是:找到与报警行号对应的子图,看MakeTuple或ListAppend的输入里,有没有挂着TensorConstant节点。有,就是它们触发了报警。

不同版本的IR文件命名有差异,常见的有xx_validate.ir、xx_after_optimize.ir等,我一般直接看以validate或数字编号开头的.ir文件,信息更靠近原始源码结构。

3.3 用二分法把嫌疑代码缩到一行

如果IR文件太大、看起来费劲,更笨但更有效的方法是二分注释法。

回到能稳定复现报警的最小脚本,把construct里不影响整体结构的业务逻辑逐块注释掉,只保留list相关操作。比如先把results.append(...)删掉、把for gamma in self.gamma_list改成只取第一个元素……每改一次跑一次脚本,看Warning是否还出现。

我一般会把网络结构调整为一个“哑网络”,比如一个nn.Dense加一个list操作,最小化到10行以内。只要还能复现,范围就锁定了。之后再按原结构逐步加回来,定位真正的触发点。

3.4 快速判断list里的Tensor到底是不是“常量”

这是个经验活儿。以下特征只要中一个,基本可以判定它是常量Tensor,会被编译器拿去折叠:

特征说明
用Tensor(np.array(...))直接创建没有经过任何计算图抵消
构造来自超参数、配置文件训练过程中一般不更新
内部元素数量固定与输入shape无关,纯Python对象
与Parameter无关没有参与优化器更新
在__init__里创建后未被修改值保持初始状态

反过来,如果某个Tensor来自Parameter、或者经过了算子输出(比如self.fc(x)、x + 1),它就是一个计算节点,不会轻易被当常量折叠。排查的目的就是把那些“看着像数据、实际被当成常量”的对象揪出来。

4. 从应急到根治:四类修复写法

4.1 方案A:用 ops.mutable() 把容器标记为可变

这是MindSpore官方给出的应对方法,也是我认为最省事的应急方案。ops.mutable()可以给list、tuple、Tensor打上“可变”标签,告诉编译器:这个容器里的值运行期可能会变,不要把它固化成图结构。

拿前面DemoNet为例,改成这样:

from mindspore.ops import mutable class DemoNet(nn.Cell): def __init__(self): super(DemoNet, self).__init__() self.fc = nn.Dense(16, 4) self.gamma_list = [ Tensor(np.ones(4, np.float32)), Tensor(np.full(4, 0.5, np.float32)) ] def construct(self, x): out = self.fc(x) results = mutable([]) # 标记为可变list for gamma in mutable(self.gamma_list): # 标记整个list为可变 results.append(out * gamma) return ops.stack(results)

我实测下来,mutable list里的append操作在图模式下是可以跑的,报警也会消失。但有两个注意点:

  • ops.mutable()标记的是对象本身,不是里面的每个元素。如果list里某个元素本身就是常量Tensor,你可以单独把那个Tensor也mutable掉;
  • mutable机制会引入额外的运行时判断,性能上有一定代价。所以它是“应急”方案,不是“最优”方案。

如果版本较低(比如1.x),可能没有ops.mutable这个API,当时用的是set_const_arg之类的接口,思路完全一样,就是让编译器别把对象当常量,建议优先升级到2.x再试。

4.2 方案B:把参与训练的常量升级为Parameter

这一条放在第一位容易被人忽略:很多“常量Tensor”其实是模型的训练参数,只是碰巧写完就没动过。

比如常用来存EMA影子参数、手动更新的BatchNorm参数、或者某些中间统计量,用Python list装Tensor,结果被编译器当成常量。正确的做法是让这些东西变成一个真正的Parameter:

from mindspore import Parameter, ParameterTuple class DemoNet(nn.Cell): def __init__(self): super(DemoNet, self).__init__() self.fc = nn.Dense(16, 4) self.gamma_params = ParameterTuple([ Parameter(Tensor(np.ones(4, np.float32)), name="gamma1"), Parameter(Tensor(np.full(4, 0.5, np.float32)), name="gamma2") ]) def construct(self, x): out = self.fc(x) results = [] for gamma in self.gamma_params: results.append(out * gamma) return ops.stack(results)

ParameterTuple是MindSpore官方提供的容器,专门用来存放多个Parameter。它天然不会被当成常量折叠,而且里面的Parameter还能正常参与梯度更新,语义上更准确。

唯一的门槛是:如果你这些Tensor真的只是常量、不参与训练,把它们设成Parameter反而有点“滥用训练设施”的味道。所以这个方案更适合“本意是参数但被误写成常量”的场景。

4.3 方案C:用 ops.stack / ops.concat 替代动态list

这个方案最稳定,也是我说“根治问题”时最推荐的方式。不要真的去绕开list,而是干脆不构造动态list。

很多人的真实目的是“把几个张量拼起来再统一处理”,而不是“拥有一个Python list”。这种情况直接用ops.stack就够了:

class DemoNet(nn.Cell): def __init__(self): super(DemoNet, self).__init__() self.fc1 = nn.Dense(16, 8) self.fc2 = nn.Dense(16, 8) def construct(self, x): out1 = self.fc1(x) out2 = self.fc2(x) return ops.stack((out1, out2))

注意这里传给ops.stack的是一个tuple,不是list。tuple本身是固定的、不可变的,编译器对tuple的识别比list要轻松很多。这也是一个小技巧:只要数据个数固定,就用tuple,别用list。

如果数据个数不固定,比如某个维度是动态的,那就用ops.concat或者先补成固定shape再stack。核心原则是:把“容器结构”的负担转嫁给算子,让算子去处理张量拼接,而不是让Python容器进来掺和。

4.4 方案D:把容器和分支控制移出construct

第三种情况是:list里装的确实是常量,而且跑训练时根本不会变,那它就不该出现在编译期“视线”里。写死在__init__里,construct里只用固定下标访问:

class DemoNet(nn.Cell): def __init__(self): super(DemoNet, self).__init__() self.fc = nn.Dense(16, 4) # 常量列表放这里 self.fixed_gamma = (Tensor(np.ones(4, np.float32)), Tensor(np.full(4, 0.5, np.float32))) def construct(self, x): out = self.fc(x) # 用固定的下标访问,编译器能正确折叠 return out * self.fixed_gamma[0]

但要提醒一点:如果下标是动态的,比如self.fixed_gamma[idx]中的idx是一个Tensor变量,那这种写法依然是雷区——动态索引list是Python层面的操作,静态图不支持直接把这个操作烙进图里。动态取数请优先用Tensor API,比如ops.gather、ops.select、ops.stack之后统一处理。

同样,如果list里面装的是Cell(层对象),优先用nn.CellList而不是Python list。CellList的身份是“模块容器”,图编译器对它有一套专门的处理逻辑,比裸list安全得多。

4.5 方案对比一览

方案适用场景改动量注意点
A: ops.mutable()临时绕过,list内容运行期会变小有性能损耗,建议后续优化
B: Parameter / ParameterTuple常量实际是带梯度参数中语义更准确,但别滥用
C: ops.stack / ops.concat固定数量的张量聚合小尽量传tuple,不要传list
D: 移出construct / CellList常量列表与模型结构无关中动态下标还是会炸,用Tensor API

5. 踩过几次坑之后,我总结的避坑习惯

5.1 写construct时的三条容器纪律

第一次遇到这个Warning,花了我一个晚上才定位到问题。但吃一堑长一智,之后我给自己立了三条纪律,基本从源头上杜绝了这个报警。

第一,construct里不临时append list来收集Tensor。如果数量固定,直接定义多个变量名;如果数量不固定,优先交给Tensor算子去构造,实在不行用ops.mutable包裹。第二,常量数据要么放__init__里,要么用tuple,不要用list。学习Rate、固定权重、超参表这几个是最常规的雷区。第三,涉及动态分支或动态索引,绝不直接操作Python容器。从控制流到数据访问,全部切到Tensor API的语义上。

这三条不只是为了消掉一个Warning,更是为了让静态图的编译缓存能最大化起作用,把图结构的稳定性当成性能优化的一部分。

5.2 自检清单:每次写完训练代码先过一遍

写代码的时候人容易上头,等写完再回头检查成本就高了。我这里有一个非常简单的自查清单,几条而已,但命中率很高:

  • 搜索代码里所有的[Tensor(、List(...)、list(...);
  • 搜索construct方法里所有的.append(;
  • 搜索for ... in循环内部是否在使用list容器;
  • 搜索if条件里是否有Tensor参与判断;
  • 搜索range()的长度是否来自Tensor属性。

如果这五项全是“否”,那这个报警基本和你无缘。任何一项命中,动手改之前先想清楚这个容器到底该不该留在计算图里。

5.3 用训练速度回归验证重编译是否根除

修复完代码之后,我建议不要只看日志里有没有Warning就万事大吉。更有效的方法是做一个训练速度回归:在修复前后各跑相同数量的step,分别记录平均单step耗时。

我自己的实测经验是:修复前如果每个step都触发recompiling,修复后单step耗时能下降20%到50%。如果改动完速度完全没变化,说明报警分支可能没有被真正清除,或者刚才报警的重编译路径与主训练路径没有重合。

还有一个辅助验证手段:监听日志中“Warning”出现的次数。如果修复后连续跑几万个step都一次不出现,这个Bug才算真正闭环。别小看这个过程——有一次我正在修另一个问题,结果发现某个Warning已经连续几千行在刷屏,训练却一直都是“正常”的,那种无声的磨损最可怕。

回头看我那次分类模型训练,排查这个报警的收获不只是把单step时间拉回了正常值,还顺藤摸瓜发现了一个藏在权重初始化阶段的多余list拷贝——那个地方虽然没报错,但白占了每次构图的内存分配。所以遇到这类编译期提示,别总觉得是框架在“小题大做”,它往往是在帮你指出那些平时看不见的高成本代码路径。如果你也在跑MindSpore训练时看到这行英文,照着上面的链路过一遍,基本都能落地解决。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询