Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .agent/skills/memory-analysis/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
name: memory-analysis
description: >
NPU memory analysis for training: record allocation history, dump snapshots
at chosen moments (including mid-backward), and break device memory down
into parameters / gradients / optimizer state / activations with each large
block attributed to the call site that allocated it, plus fragmentation,
cross-step growth for leak triage, and a check on whether the snapshot is
the peak at all. Use for 显存分析 / 显存拆解 / 显存峰值 / 显存碎片 /
显存泄露 / OOM 定位 / memory breakdown. Rule-based, cross-checked against
the allocator's own invariants, and reproducible; emits a visual HTML
report.
---

# Memory Analysis

Record allocation history, dump a snapshot at the moment that matters, and
break device memory down into what is holding it and which line allocated it.
**Rule-based and reproducible** — the same snapshot gives the same breakdown.

**This file is the index.** The snapshot guide and breakdown dimensions load
on demand.

> 对齐状态:字段、帧形态与恒等式均在 **torch_npu 2.9.0.post2 + CANN 9.0.0 /
> Ascend 910** 上实采核对,分类规则对着一次已知构成的运行逐项对账(见
> `references/breakdown-dimensions.md` § 实测对账)。规则版本 v2。

## Subcommands

```bash
python .agent/skills/memory-analysis/scripts/<tool>.py <options>
```

| Intent | Tool | Purpose |
|---|---|---|
| 采集 | `collect_snapshot.py --run-dir <d> --launch "<cmd>" [--steps 1-3]` | 驱动被测命令并校验每份 dump 都带计数器旁车 |
| 训练脚本内挂钩 | `mem_hook.start()` / `step()` / `dump(tag)` | 进程内记录与落盘,**任意时刻可 dump** |
| 拆解 + HTML | `breakdown.py --snapshot <f> [--compare <f2>] [--json r.json]` | 解析快照、分类、归属、校验(退出码 0=全通过 / 2=有不通过 / 1=文件缺失)|

训练脚本接入:

```python
import sys
sys.path.insert(0, ".agent/skills/memory-analysis/scripts")
import mem_hook

mem_hook.start() # 开始记录;读 collect_snapshot 导出的 MEM_* 变量
for step, batch in enumerate(loader):
out = model(batch)
out.register_hook(lambda g: mem_hook.dump("midbwd") and None) # 想看激活就在此处
loss(out).backward(); opt.step(); opt.zero_grad()
mem_hook.step() # 落在配置窗口内的 step 自动 dump
mem_hook.stop()
```

## References

| When | Read |
|---|---|
| 快照字段、帧形态、落盘坑、实测恒等式 | [references/snapshot-format.md](references/snapshot-format.md) |
| 总量三层、类别判定规则与实测对账、归属规则、校验项 | [references/breakdown-dimensions.md](references/breakdown-dimensions.md) |

## Report

`templates/memory-breakdown.html` — 六节:总量与碎片(含与峰值的差距)、
按类别、最大活跃块及其调用点、跨快照增长、一致性校验、结论。

## What it covers

- **总量与碎片**:Reserved / Allocated / Active 三层与碎片量。
- **按类别**:参数 / 梯度 / 优化器状态 / 激活 / 其他用户张量 / unclassified,
判据见 references,已对着已知构成的运行逐项对账。
- **块级归属**:最大活跃块各自归到**分配它的那行自有代码**(梯度无 Python 帧,
归到 `AccumulateGrad`)。
- **跨 step 增长**:`--compare` 比对两份快照,区分泄露与一次性大分配。
- **是否峰值**:把快照活跃量与 `max_memory_allocated` 对比并报差距——
步后快照里激活为 0,实测比峰值低 30 MiB,不标出来就会被误读。

### 尚未实现(不要当成已有能力)

- **Module / Layer 粒度汇总**:当前归属到调用点(文件:行),未按 module 名聚合。
- **重计算收益估算**:需要 module 粒度激活量,依赖上一项。
- **峰值时刻构成**:当前只标注快照与峰值的差距;回放 `device_traces` 还原峰值
构成的口径 `[待对齐]`。

## Hard rules

- **快照不是峰值。** 报告必须同时给出 `max_memory_allocated`;差距超 2% 即校验
不通过。要看激活必须在 backward 期间 dump。
- **`frames[0]` 不是归属。** 记录栈最内层是快照机制自身,取第 0 帧会让每个块都
归到同一处;按 references 的规则扫帧。
- **梯度按 native 帧的 `AccumulateGrad` 判定**——它没有任何 Python 帧,按 Python
帧分类会整类漏掉。
- **Default steps 1-3** 且至少两份以便比对;单份快照分不清泄露与一次性峰值。
- **校验不通过的数字不采信。** 四条恒等式(逐段与全局、与进程计数器)是解析
正确的正面证据。
- **Reproducible evidence.** 拆解带被采集运行的命令、commit 与环境
(与 `autoresearch` / `profiling` / `perf-playbook` 共享该纪律)。
- **换省显存手段必须同时量化性能。** 本 skill 只定位占用来源;手段选择是
`perf-playbook` 的事,且显存降了但性能掉了不算收益。
- A classification needing an internal 口径 stays `[待对齐]`; an unparsed field
stays `[待解析]`. Never fill a number that was not computed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# 显存拆解维度与分类规则

规则版本 **v2**(按 torch_npu 2.9.0.post2 实采字段与帧形态重写)。字段出处与
实测证据见 [snapshot-format.md](snapshot-format.md)。

## 总量三层

| 层 | 来源 | 含义 |
|---|---|---|
| Reserved | `Σ segment.total_size` | 分配器向设备保留的总量 |
| Allocated | `Σ segment.allocated_size` | 取样瞬间在用 |
| 碎片 | `Reserved − Allocated` | 保留但未在用;占 Reserved 超 10% 先查分配器配置 |

段总量**直接取段字段**,不重新累加 block——累加值另算,专用于校验(见下)。

峰值 `max_memory_allocated` 不在快照里,由 `mem_hook` 写进
`<snapshot>.stats.json` 旁车。**快照 ≠ 峰值**,差距作为校验项输出。

## 类别判定规则(按记录栈,顺序判定)

| 顺序 | 类别 | 判据 | 实测验证 |
|---|---|---|---|
| 1 | 优化器状态 | Python 帧路径含 `/optim/` | AdamW 走 `torch/optim/adam.py:177/181 _init_group`(AdamW 复用 adam.py,不要按 `adamw` 匹配) |
| 2 | 参数 | Python 帧含 `_module.py` 且函数名为 `convert`/`to` | `torch_npu/utils/_module.py:76 convert`,由 `module.to(device)` 触发 |
| 3 | 梯度 | **native** 帧含 `AccumulateGrad` | 梯度无任何 Python 帧,这是唯一信号 |
| 4 | 激活 | 最内层自有代码帧的函数名含 `forward` | 仅在 backward 期间的快照里出现 |
| 5 | 其他用户张量 | 有自有代码帧但不匹配上述 | 如输入 batch |
| 6 | unclassified | 无可用帧 | 如实标注,不猜 |

### 实测对账(一次已知构成的运行)

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

| 类别 | 工具给出 | 地面真值 | 核对 |
|---|---|---|---|
| 参数 | 24.0 MiB | 3 × 2048² × 2B = 24.0 MiB | ✅ |
| 梯度 | 24.0 MiB | 同参数(bf16)= 24.0 MiB | ✅ |
| 优化器状态 | 48.0 MiB | 2 状态 × 3 × 8 MiB = 48.0 MiB | ✅ |
| 激活(backward 中途) | 12.0 MiB | — | 步后快照降至 2.0 MiB |
| 其他用户张量 | 2.0 MiB | 512 × 2048 × 2B = 2.0 MiB(输入) | ✅ |
| 合计 | 112.0 MiB | = `allocated_size` 112.01 MiB | ✅ |

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

## 归属(调用点)规则

见 [snapshot-format.md](snapshot-format.md) § frames 的真实形态。要点:

1. 记录栈最内层优先,最内几帧是快照机制自身(`unwind` / `CapturedTraceback` /
`??`)——**取 `frames[0]` 会让所有块归到同一处**。
2. 取最内层「不在 site-packages 内」的 `.py` 帧 → 调用方自己的代码位置。
3. 退到最内层框架 `.py` 帧。
4. 再退到 native 符号,但**先跳过 `at::` / `c10::` 分配器管道**,否则拿到
`at::_ops::empty_strided::redispatch` 这类无用帧。

## 一致性校验(正面生效标记)

| 校验 | 依据 | 离线可查 |
|---|---|---|
| 逐段 Σ块 = `total_size`,Σ活跃块 = `allocated_size` | 分配器自洽 | 是 |
| Σ活跃块 = Σ段 `allocated_size` | 同上,全局 | 是 |
| Σ段 `total_size` = `memory_reserved()` | 旁车计数器 | 需旁车 |
| Σ段 `allocated_size` = `memory_allocated()` | 旁车计数器 | 需旁车 |
| 快照是否为峰值(容差 2%) | 旁车 `max_memory_allocated` | 需旁车 |

前四条是解析正确的正面证据;第五条防止把取样瞬间当峰值读。任一不通过
→ 退出码 2,报告里标红。

## 采集纪律

- **默认采 step 1–3**(`--steps`);跑长不代表构成会变,但峰值可能在后面出现。
- **要看激活就必须在 backward 期间取样**:`mem_hook.dump(tag)` 可从 autograd
hook 内调用。步后快照里激活为 0。
- **长跑要能中途取样**:性能/显存异常时当场 dump 当场定位,不要等跑完
(见仓库纪律:性能是贯穿整个实验的判据)。
- 省显存的手段(重计算、offload、分配器配置)见 `perf-playbook`;本 skill 只
定位占用来源,**换手段必须同时量化性能影响**,不能只报显存降了。

## 待对齐

- `device_traces` 回放峰值构成的口径 `[待对齐]`(当前只用快照瞬间 + 旁车峰值
标注差距)。
- Module/Layer 粒度归并:需要按模块名正则把调用点聚合,规则随实践补充。
124 changes: 124 additions & 0 deletions .agent/skills/memory-analysis/references/snapshot-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# 显存快照格式与实测要点

基于 `torch_npu.npu.memory`,在 **torch_npu 2.9.0.post2 + CANN 9.0.0 /
Ascend 910** 上实采核对。字段名与行为均为实测,不是推测。

## API

| 调用 | 作用 |
|---|---|
| `_record_memory_history(enabled="all", context="all", stacks="all", max_entries=N)` | 开始记录分配历史。`stacks="all"` 才有 Python 帧,归属全靠它 |
| `_snapshot(device=None)` | 返回快照 dict(进程内) |
| `_dump_snapshot(filename=...)` | 落盘为 pickle |
| `_record_memory_history(enabled=None)` | 停止记录 |

实测签名:`_record_memory_history_impl(enabled='all', context='all',
stacks='all', max_entries=sys.maxsize, device=None)`。

### 两个落盘坑

1. **`_dump_snapshot` 写出的文件权限是 `--w-------`(0200,只写)**,连属主自己都
读不回来。`mem_hook.dump()` 落盘后补 `chmod 0600`;手工 dump 的要自己
`chmod 600 <file>`,否则解析会报 PermissionError。
2. `_dump_snapshot` 还会在当前工作目录生成 `PROF_00000N_*` 与 `FRAMEWORK/`
目录,属正常副产物。
3. 记录期间 torch_npu 会打 `AclrtGetMemUsageInfo failed, ret:207000` 的
**ERROR 日志**,但快照正常产出,属已知噪音,不要据此判定采集失败。

## 快照结构

```python
{"segments": [...], "device_traces": [...]}
```

### segment 字段(实测)

`active_size` · `address` · `allocated_size` · `blocks` · `device` · `frames`
· `is_expandable` · `requested_size` · `segment_pool_id` · `segment_type`
· `stream` · `total_size`

| 字段 | 含义 |
|---|---|
| `total_size` | 该段向设备保留的字节数 |
| `allocated_size` | 段内在用字节数 |
| `active_size` | 含 pending-free 的在用字节数;与 `allocated_size` 相等表示无待释放块 |
| `segment_type` | `large` / `small` |
| `frames` | 实测为空 `[]`,归属只能取 block 的 |

### block 字段(实测)

`address` · `frames` · `requested_size` · `size` · `state`

`state` 实测取值:`active_allocated` · `inactive`(`active_pending_free` 亦属
活跃,按 `active` 前缀判定)。

### 实测恒等式

**离线可查(只用快照本身):**

```
逐段:Σ(所有 block.size) == segment.total_size
逐段:Σ(活跃 block.size) == segment.allocated_size
```

**需要进程内计数器(`mem_hook` 写进 `<snapshot>.stats.json` 旁车文件):**

```
Σ(segment.total_size) == memory_reserved()
Σ(segment.allocated_size) == memory_allocated()
```

实测四条全部精确成立(零偏差)。`breakdown.py` 把它们作为校验项输出,不通过
则退出码 2。

## frames 的真实形态(归属的关键)

记录栈是**最内层优先**,且最内几帧是**快照机制自身**:

```
[0] torch_npu::unwind::unwind() ← 取 frames[0] 等于每块都归到这里
[1] torch_npu::CapturedTraceback::gather(...)
[2..] ?? ← 无符号
...
[26] 第一个 .py 帧(共 62 帧时) ← 位置不固定,必须扫不能索引
...
[61] python3 / __libc_start_main
```

- **第一个 `.py` 帧的下标不固定**(实测 62 帧里在第 26 位),必须遍历查找。
- Python 帧内部同样最内层优先:`torch_npu/utils/_module.py:76 convert` →
`torch/nn/modules/module.py:957 _apply` → `train.py:31 main`。
- **归属取最内层「不在 site-packages/dist-packages 内」的 `.py` 帧**,得到调用方
自己的代码位置;没有则退到最内层框架 `.py` 帧;再没有则退到 native 符号。
- native 退路要先跳过**分配器管道**(`at::` / `c10::` 前缀),否则拿到的是
`at::_ops::empty_strided::redispatch` 这类对归属无用的帧。跳过后得到
`torch::autograd::AccumulateGrad::apply`,才是有意义的。

### 梯度没有 Python 帧

实测:梯度块的 `frames` 里**一个 `.py` 帧都没有**——autograd 引擎在 C++ 侧分配。
唯一可用信号是 native 帧里的 `torch::autograd::AccumulateGrad::apply`。
按 Python 帧做分类的实现会把梯度整类漏掉。

## device_traces

每设备一条列表,元素形如
`{"action": "segment_alloc"|"alloc"|..., "addr":, "size":, "stream":, "frames": [...]}`,
`frames` 同样带 Python 帧。可用于回放峰值时刻的构成(当前 `breakdown.py` 未用,
标 `[待对齐]`:回放口径)。

## 快照不等于峰值(最重要的一条)

实测同一次运行:

| 取样时刻 | 活跃量 |
|---|---|
| `optimizer.step()` 之后 | 100.01 MiB(**激活为 0**) |
| backward 中途(autograd hook 内) | 112.01 MiB(激活在场) |
| `max_memory_allocated()` | **130.01 MiB** |

- 步后快照里**没有任何激活**,拿它当峰值构成会严重低估。
- 即便 backward 中途取样,仍低于真实峰值 18 MiB。
- 所以:`mem_hook.dump(tag)` 支持**任意时刻**调用(含从 autograd hook 内),
并把 `max_memory_allocated` 写入旁车;`breakdown.py` 把「快照是否为峰值」
作为一项校验,差距超 2% 即报不通过并在报告里写明。
Loading