Skip to content

feat(agent): memory-analysis skill - attribution read from real NPU snapshots - #1103

Open
weich97 wants to merge 1 commit into
mindspore-ai:masterfrom
weich97:feat/memory-analysis-real
Open

weich97 wants to merge 1 commit into
mindspore-ai:masterfrom
weich97:feat/memory-analysis-real

Conversation

@weich97

@weich97 weich97 commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

说明:本 PR 接替 #1086 与 #1098,内容完全一致,仅换分支与 commit 重开。
门禁 bot 对前两者所在的分支 feat/skill-memory-analysis (@ b81866c7) 不再响应任何指令:
/check-pr 与 /retest 各多次、amend 换 SHA、close/reopen、变基到当前 master(树内容已变)均无回应,
#1098 自建立起 5.5 小时零 bot 回应,而同期其它 PR 的指令与回报均正常。
故改用新分支 feat/memory-analysis-real 重开以便过门禁。

这次改了什么

本 PR 原先提交的是 memory-analysis skill 的骨架。在真机上实采快照后发现骨架的
归属逻辑是坏的:它取 frames[0] 作为分配来源,而记录栈最内层优先、最内几帧
恰是快照机制自身(torch_npu::unwind::unwind / CapturedTraceback::gather / ??),
于是每一个块都会被归到同一个 unwind(),归属完全无效。骨架未合入,故原地重做
并强推覆盖(原骨架提交 3f002da 备份在本地)。

现在字段、帧形态、恒等式全部对着实采数据写,分类规则对着一次已知构成的运行
逐项对账。

实采验证

torch_npu 2.9.0.post2 / CANN 9.0.0 / Ascend 910。全链路
collect_snapshot → mem_hook → breakdown 真跑贯通,一次运行产出 3 份 dump
(step2 / step3 / backward 中途)。

分类逐项对账(地面真值已知)

模型:3 × Linear(2048, 2048, bias=False),bf16;AdamW;输入 512×2048 bf16。

类别 工具给出 应有值
参数 24.00 MiB 3 × 2048² × 2B = 24.00 MiB ✅
梯度 24.00 MiB 同参数(bf16)= 24.00 MiB ✅
优化器状态 48.00 MiB 2 状态 × 3 × 8 MiB = 48.00 MiB ✅
其他用户张量 2.00 MiB 输入 512×2048×2B = 2.00 MiB ✅
激活(backward 中途) 12.00 MiB — 步后降至 2.00 MiB
合计 112.00 MiB = 段 allocated_size 112.01 MiB ✅

AdamW 的 exp_avg/exp_avg_sq 走 zeros_like(param),参数 bf16 时状态也是
bf16
(8 MiB/个),不是 fp32 的 16 MiB/个。按 fp32 估会得出 96 MiB 的错误预期。

一致性校验(五项,前四项实测零偏差)

校验 实测
逐段 Σ块 = total_size、Σ活跃块 = allocated_size 9 段全部符合
Σ活跃块 = Σ段 allocated_size 112.01 vs 112.01 MiB
Σ段 total_size = memory_reserved() 156.00 vs 156.00 MiB
Σ段 allocated_size = memory_allocated() 112.01 vs 112.01 MiB
快照是否为峰值(容差 2%) 不通过,见下

快照不是峰值(同一次运行实测)

取样时刻 活跃量 激活
optimizer.step() 之后 100.01 MiB 0
backward 中途(autograd hook 内) 112.01 MiB 12.00 MiB
max_memory_allocated() 130.01 MiB —

步后快照里没有任何激活,比真实峰值低 30 MiB;即便在 backward 中途取样仍低
18 MiB。所以:mem_hook.dump(tag) 支持任意时刻调用(含从 autograd hook 内),
每份 dump 旁写 max_memory_allocated,报告把"快照是否为峰值"列为校验项、差距
超 2% 即标红并退出码 2。不标出来就会被当成峰值构成读。

跨快照比对(区分泄露与一次性分配)

比对 allocated Δ 归因
step3 vs step2(稳态) +0.00 MiB 各类别增量全为 0.00 —— 无泄露
midbwd vs step3 +12.00 MiB 激活 +10.00 MiB,其余 0.00

单份快照分不清"泄露"与"一次性大分配",--compare 正是为此。两个方向都验过。

归属规则(实测得出,容易写错的地方)

  • frames[0] 不是归属。 记录栈最内层优先,最内几帧是快照机制自身;
    第一个 .py 帧实测在第 26/62 位,位置不固定,必须扫不能索引。
    归属取最内层不在 site-packages 内的 .py 帧 → 调用方自己的代码行。
  • 梯度没有任何 Python 帧(autograd 引擎在 C++ 侧分配),唯一信号是 native 帧里的
    torch::autograd::AccumulateGrad。按 Python 帧分类会把梯度整类漏掉。
  • native 退路要先跳过 at:: / c10:: 分配器管道,否则每个梯度块都报
    at::_ops::empty_strided::redispatch。跳过后拿到
    torch::autograd::AccumulateGrad::apply,才有意义。
  • 优化器状态判据用路径含 /optim/:AdamW 走 torch/optim/adam.py,不是 adamw.py。
  • 总量取段字段(total_size/allocated_size/active_size),重新累加的值只用于校验。

附带修掉的三个问题

  • _dump_snapshot 写出的文件权限是 --w-------(0200,只写),连属主自己都读不回
    (真机上直接 PermissionError)。mem_hook 落盘后补 chmod 0600,解析器遇到
    非 mem_hook 产出的 dump 会给出可操作提示而非裸 traceback。
  • collect_snapshot.py 导出的 MEM_SNAPSHOT_STEPS/MEM_SNAPSHOT_PATH
    仓库里没有任何消费者,是死接口;且仅凭退出码判成功。现改为逐份校验 dump
    与计数器旁车,新增的 mem_hook 就是读这些变量的那一端。
  • 记录期间 torch_npu 会打 AclrtGetMemUsageInfo failed, ret:207000 的 ERROR 日志,
    但快照正常产出 —— 已记入 references,避免据此误判采集失败。

SKILL.md 据实收敛

骨架的 SKILL.md 宣称了 Module/Layer 粒度汇总、重计算收益估算、峰值时刻构成,
这些当前并未实现。已在 SKILL.md 里单列「尚未实现」一节如实标注,不当成已有
能力;device_traces 回放峰值构成的口径标 [待对齐]。

测试

tests/ut/tools/test_memory_breakdown.py,21 条 + 1 条按平台跳过,CPU only,
fixture 复刻真实帧形态(含噪声帧、分配器管道帧、无 Python 帧的梯度块)。覆盖归属
优先级、管道跳过、四条类别规则、段恒等式、峰值校验三种状态、比对两个方向、
不可读 dump 的提示、三种退出码。

按 #1072 的教训,测试向上搜索定位 .agent/skills/memory-analysis/scripts,找不到则
unittest.SkipTest——CI 会把 tests/ 单独拷出,固定深度路径假设会导致 collection
error 中断整个 UT 会话。

pylint 干净(仅剩 C9004 sys-path-mutation,与已过门禁的 #1072 同形),
lizard 最大 CCN 10 / NLOC 30,check_agents_catalog 通过。

…apshots

Add `.agent/skills/memory-analysis`: record NPU allocation history, dump
snapshots at chosen moments, and break device memory down into what holds it
and which line allocated it. The field names, frame shapes and identity
relations below were read off snapshots taken on torch_npu 2.9.0.post2 /
CANN 9.0.0 / Ascend 910, and the category rules were checked against a run
whose composition was known in advance.

`breakdown.py` reads a `_snapshot()` dump:

* Totals come from the segment fields (`total_size` / `allocated_size` /
  `active_size`) rather than re-summed blocks; the re-summed values serve the
  consistency checks instead.
* Attribution scans the recorded stack rather than reading `frames[0]`. The
  stack is innermost-first and its innermost entries are the snapshot
  machinery itself (`torch_npu::unwind::unwind`, `CapturedTraceback::gather`,
  `??`), so `frames[0]` is the same for every block and says nothing about the
  allocation. The call site is the innermost Python frame outside
  `site-packages`; the first Python frame sat at index 26 of 62 on the
  collected snapshots, so the position cannot be assumed.
* Gradients carry no Python frame at all, because the autograd engine
  allocates them from C++. They are recognised by
  `torch::autograd::AccumulateGrad` among the native frames, which is the only
  available signal. The native fallback also skips `at::` and `c10::`
  allocator plumbing, or it would report
  `at::_ops::empty_strided::redispatch` for every such block.
* Categories: optimizer state from a `torch/optim` frame (AdamW allocates
  through `adam.py`, not `adamw.py`), parameters from the module `.to()`
  conversion, gradients as above, activations when the innermost own-code
  frame is a `forward`.

On a run of three bf16 `Linear(2048, 2048)` layers with AdamW, the split came
out as parameters 24.0 MiB, gradients 24.0 MiB, optimizer state 48.0 MiB,
input 2.0 MiB and activations 12.0 MiB, summing to the reported
`allocated_size` and matching the sizes those tensors must have.

A snapshot is one instant and is not the peak, so the report states the
snapshot total beside `max_memory_allocated` and flags the gap: on that run a
post-step snapshot held 100.01 MiB with no activations at all, a mid-backward
snapshot held 112.01 MiB, and the peak was 130.01 MiB. Five invariants are
emitted as consistency checks with exit code 2 on failure - per-segment block
tiling, the global active total, the two allocator counters, and whether the
snapshot is within 2% of the peak.

`--compare` diffs two snapshots by category, which is what separates a leak
from a one-off allocation; a single snapshot cannot. Across two steady steps
every category came out at +0.00 MiB, and against the mid-backward snapshot
the growth was attributed to activations at +10.00 MiB.

`collect_snapshot.py` exported `MEM_SNAPSHOT_STEPS` and `MEM_SNAPSHOT_PATH`
that nothing read, and judged collection by the run's exit code. It now
verifies that each dump arrived with its counter sidecar, and the new
`mem_hook` is what reads those variables, so the launch side and the training
script share one window. `mem_hook.dump(tag)` can be called at any moment,
including from an autograd hook while the graph is alive, which is the only
way to see activations and the only way to inspect a run that is going badly
without waiting for it to end. Each dump also writes the allocator counters,
which live only in the process, so the offline report can cross-check its
totals and name the peak.

`_dump_snapshot` creates the file mode 0200, write-only, so not even its owner
can read it back; `mem_hook` restores owner read/write and the parser explains
the fix when it meets a dump taken another way.

Unit tests cover the attribution order, the native-plumbing skip, each category
rule, the segment identity, the peak check in all three states, the comparison
in both directions, the unreadable-dump message and the exit codes, against a
fixture carrying the real frame shapes (CPU only).
@weich97

weich97 commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

/check-pr

@weich97

weich97 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

/retest

@mindspore-ai-bot

Copy link
Copy Markdown

🔵 The pipeline #603 is running. Please wait a moment... (Link)

@mindspore-ai-bot

Copy link
Copy Markdown
Project Name Build_Stage Build Result Details
Hyper-parallel_Github_Gate - ❌ FAILURE 603
coverage FAILURE 603
Smoke_CPU_X86_CentOS_Level0 FAILURE 603

@weich97

weich97 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

/retest

1 similar comment
@weich97

weich97 commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author

/retest

@mindspore-ai-bot

Copy link
Copy Markdown

🔵 The pipeline #604 is running. Please wait a moment... (Link)

@mindspore-ai-bot

Copy link
Copy Markdown
Project Name Build_Stage Build Result Details(Full/Incremental) Detail
Coverage Line Coverage ✅ SUCCESS 65%/0% Link

@mindspore-ai-bot

Copy link
Copy Markdown
Platform Type Module Excution Time Time Limit
_torch - - -
- activation_memory 118.229s 120.0s
- shard 166.304s 300.0s
- optimizer 247.794s 300.0s
- pipeline_parallel 558.141s 600.0s
- context_parallel 76.818s 120.0s
- dtensor 73.857s 120.0s
- clip_grad 78.969s 120.0s
- swap_optimizer 77.155s 120.0s
- tensor_parallel 230.769s 300.0s
- accuracy 75.366s 120.0s
- expert_parallel 66.49s 120.0s
- process_group 69.17s 90.0s
- llama3_examples 63.133s 120.0s
- init_weights 74.543s 118.75s
- fully_shard 311.643s 600.0s
- distributed_checkpoint 77.011s 120.0s
- multicore 73.64s 120.0s

@mindspore-ai-bot

Copy link
Copy Markdown
Project Name Build_Stage Build Result Details
Hyper-parallel_Github_Gate - ✅ SUCCESS 604

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants