已开启
多流内存校测 #2
hbhu_bin创建于  3月13日
hbhu_bin成员
3月13日 创建

NPU Sanitizer 功能说明书

1. 概述

NPU Sanitizer 是一个用于检测 NPU 多流程序中同步错误的运行时分析工具。它基于 PyTorch 上游的 CUDA Sanitizer(torch.cuda._sanitizer)架构,针对昇腾 NPU 进行了适配和扩展。

NPU Sanitizer 提供两类独立的检测能力:

检测类型 检测目标 检测时机 报告方式
数据竞争检测 不同流上对同一 tensor 的未同步读写 kernel launch 时 抛出异常,程序中断
record_stream 检测 跨流使用 tensor 但未保证内存生命周期安全 tensor 释放时 打印到 stderr

2. 启用方式

# 启用基本的数据竞争检测
export TORCH_NPU_SANITIZER=1

# 同时启用 record_stream 检测
export TORCH_NPU_SANITIZER=1
export TORCH_NPU_SANITIZER_CHECK_RECORD_STREAM=1

代码方式:

import torch_npu.npu._sanitizer as sanitizer
sanitizer.npu_sanitizer.check_record_stream = True
sanitizer.npu_sanitizer.enable()

3. 整体架构

┌─────────────────────────────────────────────────────────────────────┐
│                          用户 Python 代码                           │
│  x = torch.randn(100, device='npu')                                 │
│  with torch_npu.npu.stream(s1): y = x + 1                           │
└──────────────┬─────────────────────────────────────┬────────────────┘
               │                                     │
               ▼                                     ▼
┌────────────────────────────┐     ┌──────────────────────────────────┐
│ NPUSanitizerDispatchMode   │     │ C++ PyCallbackTrigger            │
│ (TorchDispatchMode)        │     │ (PyCallbackTrigger.h)            │
│                            │     │                                  │
│ 拦截所有 aten 算子调度     │     │ 拦截底层 C++ 事件:               │
│ · 解析读写参数             │     │ · memory alloc/dealloc           │
│ · 调用 EventHandler        │     │ · event create/record/wait       │
│ · 拦截 record_stream       │     │ · stream create/sync             │
│                            │     │ · device synchronize             │
│                            │     │ · record_stream                  │
└──────────────┬─────────────┘     └─────────────────┬────────────────┘
               │                                     │
               ▼                                     ▼
┌─────────────────────────────────────────────────────────────────────┐
│                           NPUEventHandler                           │
│                     (继承自 csan.EventHandler)                      │
│                                                                     │
│  ┌──────────────────────────────┐  ┌──────────────────────────────┐ │
│  │ 数据竞争检测 (父类逻辑)      │  │ record_stream 检测 (新增)    │ │
│  │                              │  │                              │ │
│  │ · TensorInfo: 读写访问记录   │  │ · NPUTensorInfo: 分配流 +    │ │
│  │ · StreamSynchronizations:    │  │   跨流使用记录 + record 记录 │ │
│  │   流间同步状态矩阵           │  │ · 延迟: 释放时/手动 flush    │ │
│  │ · kernel launch 时即时检查   │  │                              │ │
│  └──────────────────────────────┘  └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘

3.1 关键组件

组件 文件 职责
NPUSanitizer _sanitizer.py 总入口,管理生命周期,注册回调
NPUSanitizerDispatchMode _stream_check.py Python 层拦截所有 aten 算子调度
NPUEventHandler _stream_check.py 核心分析引擎,继承自上游 EventHandler
CallbackRegistry _npu_trace.py Python 回调注册表
PyCallbackTrigger PyCallbackTrigger.h C++ 层事件触发器,通过 pybind 调用 Python 回调
StreamAndEventKernelNpu.cpp C++ record_stream 原生函数实现,含 sanitizer 埋点

3.2 数据流

C++ 分配器分配内存
    → traceNpuMemoryAllocation(ptr)
    → NPUMemoryAllocationCallbacks.fire_callbacks(ptr)
    → EventHandler._handle_memory_allocation(ptr)
    → 记录 tensor 分配信息

Python 算子调度 (x + 1)
    → NPUSanitizerDispatchMode.__torch_dispatch__()
    → 解析输入/输出 tensor 的 data_ptr 和读写属性
    → NPUEventHandler._handle_kernel_launch(stream, read_set, write_set, ...)
    → 父类: 数据竞争检查 (即时)
    → 子类: 记录跨流使用信息 (延迟)

C++ stream.wait_event(event)
    → traceNpuEventWait(event, stream)
    → EventHandler._handle_event_wait(event, stream)
    → 更新 StreamSynchronizations 同步状态矩阵

C++ 分配器释放内存
    → traceNpuMemoryDeallocation(ptr)
    → NPUEventHandler._handle_memory_deallocation(ptr)
    → 检查 record_stream 是否缺失,打印错误到 stderr

4. 功能一:数据竞争检测

4.1 原理

数据竞争发生在不同流上对同一 tensor 进行未同步的并发访问时。Sanitizer 通过维护一个 同步状态矩阵 来判断两个流之间是否已经同步。

同步状态矩阵 (StreamSynchronizations)

核心数据结构:

current_sync_states: dict[StreamId, dict[StreamId, SeqNum]]

current_sync_states[A][B] = N 表示:流 A 已经"看到"了流 B 上序号 ≤ N 的所有操作。

状态更新规则

事件 状态变更
kernel launch on stream S current_sync_states[S][S] = seq_num++
event.record(stream S) recorded_sync_states[event] = current_sync_states[S].copy()
stream A.wait_event(event) current_sync_states[A] 合并 recorded_sync_states[event]
stream_synchronize(S) 所有流合并 current_sync_states[S]
device_synchronize() 所有流互相合并(全局屏障)

数据竞争判定

当流 S 上的 kernel 访问 tensor T 时:

  1. 获取 T 的上一次访问记录 prev_access(包含访问流 prev_stream 和序号 prev_seq
  2. 检查 current_sync_states[S].get(prev_stream, -1) >= prev_seq
  3. 如果不满足 → 数据竞争
流 0 (default):  [创建 x, seq=1] ─────────────────────────────
流 1 (stream1):            [读取 x, seq=2] ← 此时检查:
                              current_sync_states[1].get(0) >= 1 ?
                              如果 stream1 没有 wait_stream(default) → False → 数据竞争!

4.2 检测时机与报告

  • 时机:每次 kernel launch 时即时检查
  • 报告:抛出 CUDASanitizerErrors 异常,程序中断
  • 输出:包含两次冲突访问的算子名、流 ID、完整 stack trace

4.3 解决数据竞争的方法

# 方法 1: usage_stream.wait_stream(creation_stream)
stream1.wait_stream(default_stream)
with torch_npu.npu.stream(stream1):
    y = x + 1  # 安全:stream1 已同步 default stream

# 方法 2: usage_stream.wait_event(event)
event = torch_npu.npu.Event()
event.record()  # 在 default stream 上记录
with torch_npu.npu.stream(stream1):
    stream1.wait_event(event)
    y = x + 1  # 安全

# 方法 3: device synchronize(重量级)
torch_npu.npu.synchronize()

注意record_stream 不能解决数据竞争。它只解决内存生命周期问题。

5. 功能二:record_stream 缺失检测

5.1 背景:为什么需要 record_stream

参考 PyTorch 官方文档

NPU 的 caching allocator 只知道 tensor 在哪个流上分配。当 tensor 在其他流上使用后被释放时,allocator 可能在分配流上立即复用这块内存,但此时使用流上的操作可能还没执行完,造成 use-after-free

时间 →

分配流 (default):  [alloc x] ──────── [dealloc x] ── [alloc y, 复用 x 的内存] ──
                                                         ↑ 危险!使用流还在用这块内存
使用流 (stream1):         [wait] ── [使用 x ──────────────────────] ← 还没完成!

record_stream 告诉 allocator:"这个 tensor 还在 stream1 上被使用,在 stream1 完成前不要复用它的内存"。

5.2 什么时候需要 record_stream

根据 PyTorch 文档,有两种方式保证内存安全:

方式 A:调用 record_stream(让 allocator 管理)

x.record_stream(stream1)  # allocator 会等 stream1 完成后才复用内存

方式 B:手动同步(用户自己管理)

# 确保 creation_stream 等待 usage_stream 完成后,才可能释放/复用内存
default_stream.wait_stream(stream1)  # creation waits usage
del x  # 此时安全,因为 default stream 已经等过 stream1 了

关键区分

同步方向 作用 能否替代 record_stream
usage.wait(creation) 解决数据竞争 不能 — 只保证使用流在创建流之后执行
creation.wait(usage) 保证内存安全 — 创建流等待使用流完成后才继续
device_synchronize() 全局屏障 — 所有流互相等待

5.3 检测原理

record_stream 检测分两阶段:

阶段一:记录(kernel launch 时)

每次 kernel launch 时,对于访问的每个 tensor,检查当前执行流是否与 tensor 的分配流不同。如果不同,记录一条 CrossStreamUsage,并记下当前全局操作序号 seq_num

@dataclass
class CrossStreamUsage:
    usage_stream: int          # 使用流 ID
    seq_num: int               # 使用时的全局操作序号
    operator: str              # 算子名 (如 "aten::add.Tensor")
    stack_trace: StackSummary  # 调用栈

如果同一 tensor 在同一流上被多次使用,seq_num 会更新为最后一次使用的序号,确保后续同步检查能覆盖所有使用。

阶段二:检查(tensor 释放时或手动 flush)

当 tensor 的内存被释放时(_handle_memory_deallocation),对每条跨流使用记录检查:

对于每个 cross_stream_usage(usage_stream):
    if usage_stream in recorded_streams:
        → 已调用 record_stream,安全 ✓
    elif creation_stream 已同步等待 usage_stream:
        → 用户手动管理,安全 ✓
    else:
        → 报告 MissingRecordStreamError ✗

其中"creation_stream 已同步等待 usage_stream"的判定复用同步状态矩阵,并比较具体的 seq_num

def _is_creation_stream_synced_to_usage(self, creation_stream, usage_stream, usage_seq_num):
    creation_state = self.syncs.current_sync_states.get(creation_stream, {})
    return creation_state.get(usage_stream, -1) >= usage_seq_num

这里比较 >= usage_seq_num 而非 >= 0,是为了解决两个边界问题:

  1. 流创建继承的误判:当 creation_stream 晚于 usage_stream 创建时,create_stream 会将 host_sync_state(包含 {usage_stream: 0})复制给新流。如果只检查 >= 0,这个继承的初始值 0 会被误判为"已同步"。而实际 kernel 的 seq_num 从 1 开始(self.seq_num += 1 后再调用 update_seq_num),所以 0 >= 1 为 False,正确识别为未同步。

  2. 同一流多次使用,wait 在中间:如果 tensor 在 usage_stream 上先后执行了 seq=5 和 seq=8 的操作,但 creation.wait_stream(usage) 只在 seq=5 之后调用(此时 synced_value = 5),则 5 >= 8 为 False,正确检测到后续使用未被同步覆盖。

5.4 为什么延迟到释放时检查

如果在 kernel launch 时就检查,会产生大量误报。因为典型的用户代码是:

with torch_npu.npu.stream(stream1):
    y = x + 1                          # ← 如果在这里检查,sync 还没发生

default_stream.wait_stream(stream1)     # ← 用户在后面补上同步
del x                                   # ← 释放时 creation stream 已经 sync 了

延迟到释放时检查,此时同步状态矩阵已经反映了所有已发生的同步操作,能准确判断是否需要 record_stream。

5.5 record_stream 追踪路径

record_stream 调用通过两条路径被追踪:

路径 1:Python Dispatch Mode 拦截

# NPUSanitizerDispatchMode.__torch_dispatch__
if "record_stream" in func_name:
    # 提取 tensor.data_ptr() 和 stream_id
    # 调用 event_handler._handle_record_stream(data_ptr, stream_id)
    return func(*args, **kwargs)

路径 2:C++ 原生函数埋点

// StreamAndEventKernelNpu.cpp
void NPUNativeFunctions::record_stream(Tensor& self, Stream stream) {
    // sanitizer trace
    trigger->traceNpuRecordStream(data_ptr, npu_stream);
    // 实际的 allocator 注册
    NPUCachingAllocator::recordStream(self.storage().data_ptr(), npu_stream);
}

C++ 层通过 PyCallbackTriggerNPURecordStreamCallbacks_handle_record_stream 回调到 Python。

两条路径都将 stream 添加到 NPUTensorInfo.recorded_streams 集合中(set 去重,不会冲突)。

5.6 检测时机与报告

  • 自动检测:tensor 内存被 caching allocator 释放时,自动打印错误到 stderr
  • 手动检测:调用 event_handler.flush_record_stream_warnings() 检查所有存活的 tensor
  • 报告方式:打印详细错误到 stderr(包含算子名、流 ID、调用栈),不抛异常、不中断程序
  • 错误记录:所有检测到的错误存入 event_handler.record_stream_errors 列表

5.7 错误输出示例

============================
NPUSanitizer: missing record_stream detected!
Tensor (data ptr: 140234567890) allocated on stream 0
was used on stream 93824992370688 without record_stream or
creation_stream.wait_stream(usage_stream).

This may cause use-after-free if the caching allocator reuses memory
on the allocation stream before the usage stream finishes.

Fix with ONE of:
  A) tensor.record_stream(stream) — tell allocator about the usage
  B) creation_stream.wait_stream(usage_stream) before deallocation

Cross-stream usage during kernel:
  aten::add.Tensor(Tensor self, Tensor other, *, Scalar alpha=1) -> Tensor
With stack trace:
  File "train.py", line 42, in forward
    y = x + 1
...

6. 两类检测的关系

┌────────────────────────────────────────────────────────────────────┐
│                        多流 tensor 使用场景                        │
│                                                                    │
│  问题 1: 数据竞争                  问题 2: 内存提前复用            │
│  "使用流是否等待了创建流?"         "创建流是否等待了使用流?"       │
│                                                                    │
│  解决: usage.wait(creation)        解决: record_stream             │
│        或 event sync               或 creation.wait(usage)         │
│        或 device sync              或 device sync                  │
│                                                                    │
│  检测时机: kernel launch           检测时机: tensor 释放           │
│  报告方式: 抛异常                  报告方式: stderr 打印           │
└────────────────────────────────────────────────────────────────────┘

完全正确的多流代码需要同时解决两个问题:

x = torch.randn(100, device='npu')       # default stream 创建
stream1 = torch_npu.npu.Stream()

# 解决问题 1: 数据竞争
stream1.wait_stream(default_stream)       # usage waits creation

# 解决问题 2: 内存安全 (二选一)
x.record_stream(stream1)                  # 方式 A: 告诉 allocator
# 或: 在后面调用 default_stream.wait_stream(stream1)  # 方式 B: 手动管理

with torch_npu.npu.stream(stream1):
    y = x + 1

7. 测试矩阵

测试场景 数据竞争 record_stream 警告 测试类
无同步,跨流读写 检测到 N/A (异常先抛出) TestDataRaceDetection
usage.wait(creation),无 record_stream 检测到 TestMissingRecordStream
usage.wait(creation) + device sync TestRecordStreamNotNeeded
usage.wait(creation) + creation.wait(usage) TestRecordStreamNotNeeded
usage.wait(creation) + record_stream TestRecordStreamNotNeeded
record_stream 但无数据竞争同步 检测到 TestDataRaceDetection
同一流操作 TestRecordStreamNotNeeded
完整正确用法 TestCorrectUsage

8. 核心数据结构

8.1 数据竞争检测(父类 EventHandler

class TensorInfo:
    allocation_stack_trace: StackSummary | None  # 分配时调用栈
    reads: list[Access]                          # 上次写入后的所有读访问
    write: Access | None                         # 最后一次写访问

class Access:
    type: AccessType        # READ 或 WRITE
    seq_num: int            # 操作序号(全局递增)
    stream: int             # 执行流 ID
    operator: str           # 算子 schema
    stack_trace: StackSummary

class StreamSynchronizations:
    current_sync_states: dict[StreamId, dict[StreamId, SeqNum]]  # 流间同步矩阵
    recorded_sync_states: dict[EventId, dict[StreamId, SeqNum]]  # event 快照
    host_sync_state: dict[StreamId, SeqNum]                      # host 端同步状态

8.2 record_stream 检测(NPUEventHandler 扩展)

class NPUTensorInfo:
    allocation_stream: int | None               # 分配时的流 ID
    allocation_stack_trace: StackSummary | None  # 分配时调用栈
    recorded_streams: set[int]                   # record_stream 注册过的流
    cross_stream_usages: dict[int, CrossStreamUsage]  # 跨流使用记录 (key=流ID)

class CrossStreamUsage:
    usage_stream: int           # 使用流 ID
    seq_num: int                # 最后一次使用时的全局操作序号
    operator: str               # 算子名
    stack_trace: StackSummary   # 使用时调用栈

9. seq_num 同步判定详解

9.1 为什么不能用 >= 0

StreamSynchronizations 在创建新流时,会将 host_sync_state 复制给新流作为初始状态:

def create_stream(self, stream):
    self.host_sync_state[stream] = 0
    self.current_sync_states[stream] = self.host_sync_state.copy()

如果 creation_stream 晚于 usage_stream 创建,其初始状态会包含 {usage_stream: 0}。这个 0 只代表"创建时知道该流存在",不代表发生过任何同步。而第一个 kernel 的 seq_num 为 1(self.seq_num += 1 后赋值),因此:

creation_state[usage_stream] 来源 >= 0 >= usage_seq_num (≥1)
-1(不存在) 从未见过 False False
0(继承) create_stream 复制 True (误判) False
≥ 1(真实同步) wait_stream / device_sync True True

9.2 同一流多次使用的覆盖

_record_cross_stream_usage 在遇到已记录的流时,不再跳过,而是更新 seq_num

existing = info.cross_stream_usages.get(stream)
if existing is not None:
    existing.seq_num = current_seq  # 更新为最新使用的序号
    continue

这确保检查时比较的是最后一次使用的序号,避免 wait 在多次使用中间时的漏检。

10. 限制与注意事项

  1. 性能开销:Sanitizer 会拦截每个算子调度并收集调用栈,仅建议在调试阶段使用。

  2. record_stream 检测为非阻断式:检测到缺少 record_stream 时只打印警告到 stderr,不中断程序。这是因为检测发生在 C++ 分配器回调中,该回调内部捕获了所有异常。

  3. deallocation 时机依赖 Python GC:tensor 的 Python 对象被销毁后,caching allocator 会标记内存块可复用,但实际的 deallocation 回调触发时机取决于引用计数和垃圾回收。

  4. 回调累积:每次 reset_sanitizer + setup_sanitizer 会在 CallbackRegistry 中追加新回调,旧回调仍然存在(但引用已失效的 handler,会静默失败)。在大量测试场景下可能有轻微性能影响。

  5. 默认流特殊性:默认流 ID 为 0,在 StreamSynchronizations 初始化时即创建。NPU 默认流的语义可能与 CUDA 默认流有差异。

likedislike
Hhbhu_bin成员
3月13日 修改了issue 的描述
Hhbhu_bin成员
3月13日 修改了issue 的描述
Hhbhu_bin成员
3月13日 修改了issue 的描述
Hhbhu_bin成员
3月13日 修改了issue 的描述
hbhu_bin成员
3月14日 评论:

current_sync_states[A][B] = N 的含义:流 A 已经"观察到"了流 B 上序号 ≤ N 的所有操作

关键规则:

  1. 流创建时(create_stream,第 264-274 行):
    self.host_sync_state[stream] = 0 self.current_sync_states[stream] = self.host_sync_state.copy()
  2. 新流的自身序号初始化为 0,并且只继承 host 当时已知的流状态。已存在的流不会自动获得新流的条目。
    默认值为 -1:creation_state.get(usage_stream, -1)。如果字典中没有 usage_stream 这个 key,返回 -1。
  3. 同步操作传播状态(_state_wait_for_other,第 293-297 行):
    state[stream] = max(state.get(stream, -1), seq_num)
    将另一个流的当前 seq_num(至少为 0)写入,取 max 确保只前进不后退。

步骤 1: 创建 default stream (id=0)
host_sync_state = {0: 0}
current_sync_states[0] = {0: 0}

步骤 2: 在 default stream 上分配 tensor x
(tensor 的 allocation_stream = 0)

步骤 3: 在 default stream 上执行 kernel (seq_num=1)
current_sync_states[0] = {0: 1} ← 自增

步骤 4: 创建 stream1
host_sync_state = {0: 0, stream1: 0}
current_sync_states[stream1] = {0: 0, stream1: 0} ← copy from host

此时 current_sync_states[0] 仍是 {0: 1}
注意:stream1 不在 current_sync_states[0] 中!

步骤 5: stream1.wait_stream(default) → stream1 上使用 x
current_sync_states[stream1] = {0: 1, stream1: 0} ← 合并了 stream 0 的状态
(这解决了数据竞争,但 stream 0 仍然不知道 stream1 的存在)

步骤 6: 检查 creation_stream(0) 是否同步了 usage_stream(stream1)
creation_state = current_sync_states[0] = {0: 1}
creation_state.get(stream1, -1) → -1 ← key 不存在,返回默认值
-1 >= 0 → False ← 未同步!需要 record_stream

likedislike
Hhbhu_bin成员
3月14日 修改了issue 的描述