第 7 课:Profiler、Timeline 与 CUDA Allocator

参考答案版:包含完整代码实现与问答题深度参考解析。建议先独立完成练习题后再展开核对。

本课目标与完成标准

本课产出:实现区间并集计算暴露时间,正确区分 allocated/reserved、碎片和真实泄漏。

通过要求:唯一代码填空题通过给定检查;Q1~Q3 都给出因果链;能指出一个正确性边界和一个性能/运营取舍;总分至少 8/10。

与既有六条主线的边界

train/lesson12 给出 MFU;本课训练读取 trace 的因果关系,并用 allocator snapshot 解释 OOM。

前置:Python、PyTorch、train 第 1~5 课、CUDA 基础。本课只补 AI Infra 缺口,不重复已经完成的 kernel 或并行算法推导。

核心概念与运行机制

1. 它是什么,解决什么问题

在深度学习系统开发中,工程师最常遭遇的两大“幽灵现象”莫过于:“明明 GPU 显存还显示剩余很多,却突发抛出 CUDA out of memory(OOM)”,以及“代码里写的运算逻辑很紧凑,但 GPU 吞吐极低且利用率大幅波动”。

解决这两大系统难题依赖于底层的显存管理机制与全栈性能剖析工具链:

  1. PyTorch Caching Allocator(显存缓存分配器):
    • 解决的核心问题是消除昂贵的 cudaMalloc 系统调用开销并控制显存外部碎片。
    • 直接调用 NVIDIA 驱动层的 cudaMalloc / cudaFree 极其昂贵,不仅涉及操作系统用户态到内核态的上下文切换,还会强制同步全 GPU 的所有计算流(耗时达数十至数百微秒)。对于每秒创建数万个临时张量的深度学习网络,直接调用驱动分配器将使性能崩溃;
    • 如图 7-1 所示,PyTorch 在用户空间实现了高效的双池

flowchart TD
    subgraph CachingAllocator["PyTorch Caching Allocator 双池架构"]
        Large["大块内存池 (Large Pool: >= 1MB) ➔ 容纳激活与大权重"]
        Small["小块内存池 (Small Pool: < 1MB) ➔ 容纳标量与小临时变量"]
    end
    subgraph Segments["物理显存段 (Segments)"]
        S1["Segment 1 (20MB) ➔ 细分为多个 Allocated/Free Blocks"]
        S2["Segment 2 (50MB) ➔ 细分为多个 Allocated/Free Blocks"]
    end
    CachingAllocator --> Segments

图 7-1:PyTorch 缓存分配器双池结构与显存切分碎片控制模型

(Large/Small Pool)内存池化管理,向操作系统批发大块显存段(Segments),并在应用层按需细分管理。

  1. PyTorch Profiler 与 CUPTI 跨设备全景时间线(Timeline):
    • 解决的核心问题是穿透异步执行假象,精确关联 CPU 调用与 GPU 真实物理执行时序。
    • 如图 7-2 所示,通过集成底层 CUPTI

flowchart LR
    StartTrace["启动 torch.profiler 采集"] --> CPUHook["CPU 端拦截 Python Op 分发并打标 Correlation ID"]
    CPUHook --> CUPTI["CUPTI 驱动层捕获真实 GPU Kernel Launch 与执行时延"]
    CUPTI --> MatchEvents["依据 Correlation ID 关联 CPU 算子与 GPU 内核时序"]
    MatchEvents --> ExportJSON["导出 Chrome Trace / Perfetto 格式瀑布流 Timeline"]

图 7-2:端到端 Profiler 追踪捕获、跨设备事件关联与时间线呈现流程

(CUDA Profiling Tools Interface)事件,建立全局关联标识符(Correlation ID),精准测量算子在 GPU 硬件上的真实执行跨度与流间气泡。

2. 它如何工作

显存分配与性能剖析具有高度严密的底层运作流水线(如图 7-1 与 图 7-2 所示):

  1. Caching Allocator 物理段与双池切分(图 7-1):
    • 大段批发(Segments):分配器首次遇到显存请求时,调用驱动 API 向 GPU 显存批发大尺寸连续物理段(例如 20MB 或更高),由 Block 链表进行维护;
    • 双池隔离策略:
      • Small Pool(小池,<1MB< 1\ \text{MB}):专门容纳标量、小偏置项与元数据张量;
      • Large Pool(大池,≥1MB\ge 1\ \text{MB}):容纳模型权重、KV Cache 与大型前向激活张量。
      • 大小池物理隔离,防止高频产生的小张量将大连续空间切碎,有效遏制外部内存碎片(External Fragmentation);
    • 关键显存指标的数学物理含义:
      • memory_allocated()(已分配内存):当前 Python 进程中真正存活的 torch.Tensor 对象所占用的有效显存净值;
      • memory_reserved()(已预留内存):分配器当前已从 GPU 驱动端批发并持有的所有物理 Segments 总量(包含当前未被使用的空闲 Blocks);
      • 显存碎片量:Fragmentation=memory_reserved−memory_allocated\text{Fragmentation} = \text{memory\_reserved} - \text{memory\_allocated}。
  2. Profiler 事件捕获与跨设备关联对齐(图 7-2):
    • CPU 端插桩:Python 算子进入 Dispatcher 时,Profiler 拦截算子并记录 CPU 时间戳,同时生成一个全局单调递增的 correlation_id;
    • GPU 端捕获:底层的 CUPTI 回调接口捕获驱动发射的 CUDA Kernel Launch 事件及其在 GPU Compute Stream 上的物理开始与结束硬件时间戳(以纳秒级 GPU 硬件时钟为准);
    • 事件合并与瀑布流导出:Profiler 后处理引擎依据 correlation_id 将 CPU 逻辑算子与 GPU 硬件内核准确连线对齐,输出 Perfetto / Chrome Trace 格式的时间线。
  3. 真实 GPU 占空比的区间并集计算(Union of Intervals):
    • 在多流并发(Multi-Stream Execution)或前向反向重叠场景下,多个计算流与通信流上的 Kernel 在时间轴上存在大量的重叠区间(如区间 [0,4][0, 4] 与 [3,7][3, 7]);
    • 严禁将各个 Kernel 的物理执行耗时机械相加,否则会导致算出的“总时间”超过真实时间,产生算力利用率突破 100% 的荒谬计算;
    • 必须通过区间贪心合并算法(Interval Union)合并重叠时间段,严格推导出 GPU Compute Stream 处于纯忙碌状态的真实有效时间([0,4]∪[3,7]=[0,7][0, 4] \cup [3, 7] = [0, 7],有效忙时为 7)。

3. 正确性条件与常见误区

  • “reserved 远大于 allocated 等于显存泄漏”的伪常识:
    • 常见误区:看到 memory_reserved() 占满了 80GB 显存,而 memory_allocated() 只有 30GB,就慌忙断定代码发生内存泄漏;
    • 实际情况:Caching Allocator 本身的设计哲学就是“能不还给系统就不还”。先前峰值用过的临时显存会留在 Free Blocks 中供下一个 Step 零成本复用。真正的内存泄漏标准是 memory_allocated() 随着训练 Step 呈单调线性递增(例如某个列表在循环中不断 append 中间张量);
  • torch.cuda.empty_cache() 的滥用危害:
    • 致命误区:在训练主循环每个 Step 结尾盲目调用 torch.cuda.empty_cache();
    • 系统后果:empty_cache() 会强制清空当前所有未分配的空闲 Segments 并将其归还给操作系统驱动。该操作不仅完全无法释放任何存活张量,而且会在每一个 Step 强制触发全局 GPU 硬件同步与慢速驱动重新分配,使吞吐暴跌 30%~50%;
  • CPU Wall-Clock 测量 GPU 异步算子的荒谬性:
    • 严重错误:使用 Python 原生 t0 = time.time(); op(); t1 = time.time() 来测量单算子耗时;
    • 底层物理实况:CUDA Kernel 发射是纯粹非阻塞异步的,t1 - t0 测到的仅仅是 CPU 将指令压入驱动环形队列的发射时延(通常仅需 5μs5\ \mu\text{s}),完全脱离硬件运算实际;若紧随其后调用了 torch.cuda.synchronize(),则测到的时间又包含了先前计算流中所有排队任务的累积耗时。必须使用 torch.cuda.Event(enable_timing=True) 测量流内硬件事件。

4. 性能与工程取舍

  • Profiler 观测开销与探针效应(Observer Effect):
    • 开启 record_shapes=True、with_stack=True 或内存生命周期追踪会显著增加 CPU 开销并膨胀 Trace JSON 文件(可达数 GB)。在万卡集群上,过重的 Profiling 开销甚至会人为制造出在正常运行时不存在的 CPU 瓶颈;
    • 最佳实践:采用 Warmup 机制,仅在训练平稳的第 10~15 个 Step 开启局部采样窗口(torch.profiler.schedule)。
  • 可复用碎片整理 vs. 虚拟内存扩展(expandable_segments):
    • 当模型遭遇严重外部显存碎片导致的 OOM 时,可在环境变量中启用 PyTorch 2.1+ 的 PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True;
    • 该机制借助现代 Linux 与 CUDA 虚拟内存分配 API(cuMemAddressRange_Reserve 与 cuMemMap),允许将离散的物理显存块动态拼装映射为一个连续的虚拟显存地址空间,彻底消除传统 Caching Allocator 的外部碎片 OOM。

具体演示

设 Profiler 捕获到某 GPU 核心计算流上的三个 Kernel 执行区间: Kernel 1:[0.0,4.0]ms,Kernel 2:[3.0,7.0]ms,Kernel 3:[9.0,10.0]ms\text{Kernel 1}: [0.0, 4.0]\ \text{ms}, \quad \text{Kernel 2}: [3.0, 7.0]\ \text{ms}, \quad \text{Kernel 3}: [9.0, 10.0]\ \text{ms} 当前调度批次的总观测窗口为 Twindow=10.0msT_{\text{window}} = 10.0\ \text{ms}。

  1. 错误计算方法(简单求和): Terr=(4.0−0.0)+(7.0−3.0)+(10.0−9.0)=4.0+4.0+1.0=9.0msT_{\text{err}} = (4.0 - 0.0) + (7.0 - 3.0) + (10.0 - 9.0) = 4.0 + 4.0 + 1.0 = 9.0\ \text{ms} 错误计算不仅忽略了重叠,在跨流并发时甚至可能得出大于窗口总时长的荒谬结论;

  2. 精确数学区间并集计算(Interval Union):

    • 排序后首先处理 [0.0,4.0][0.0, 4.0];
    • 下一个区间 [3.0,7.0][3.0, 7.0] 的起点 3.0≤4.03.0 \le 4.0,发生重叠,合并并扩展边界为 [0.0,max⁡(4.0,7.0)]=[0.0,7.0][0.0, \max(4.0, 7.0)] = [0.0, 7.0],覆盖时长 7.0ms;
    • 下一个区间 [9.0,10.0][9.0, 10.0] 的起点 9.0>7.09.0 > 7.0,无重叠,独立累加其时长 1.0ms1.0\ \text{ms};
    • 真实总有效忙时为: Tbusy=7.0+1.0=8.0msT_{\text{busy}} = 7.0 + 1.0 = 8.0\ \text{ms}
  3. 性能暴露分析:

    • GPU 实际物理空闲时间(Bubble):Tidle=10.0−8.0=2.0msT_{\text{idle}} = 10.0 - 8.0 = 2.0\ \text{ms}(即 [7.0,9.0][7.0, 9.0] 区间);
    • GPU 真实算力占空比为 80%80\%,精准暴露出 2ms 的待优化调度空洞。

实践任务:唯一代码填空题

补齐半开区间并集时长;相交或相邻区间应合并。

只能修改 TODO/______ 位置,不得删除断言或放宽通过条件。

Code
def union_duration(intervals):
    if any(end < start for start, end in intervals):
        raise ValueError("negative interval")
    if not intervals:
        return 0
    ordered = sorted(intervals)
    total = 0
    start, end = ordered[0]
    for next_start, next_end in ordered[1:]:
        if next_start <= end:
            end = max(end, next_end)
        else:
            total += end - start
            start, end = next_start, next_end
    # TODO:循环结束后最后一段尚未计入。
    return ______

assert union_duration([(0, 4), (3, 7), (9, 10)]) == 8
assert union_duration([]) == 0

检查方法

运行本单元格,所有断言必须通过;再补一个边界输入并解释预期。

Q1

memory_reserved 高于 memory_allocated 是否等于内存泄漏?

你的答案:

Q2

为什么 CPU wall-clock 包围 CUDA op 会低估或高估单 kernel 时间?

你的答案:

Q3

trace 中 GPU 空闲但 CPU 也不忙,下一步查什么?

你的答案:

评分规则

  • 代码 4 分:正常输入 2 分、边界输入 1 分、解释 1 分;
  • Q1~Q3 各 2 分;
  • 一票否决:混淆测量与推测、忽略租户/请求隔离、用平均值掩盖尾延迟、删除失败路径。

参考答案(仅 answer 分支)

先独立完成。核对后请改变一个规模或故障条件重新推演。

Code
def union_duration(intervals):
    if any(end < start for start, end in intervals):
        raise ValueError("negative interval")
    if not intervals:
        return 0
    ordered = sorted(intervals)
    total = 0
    start, end = ordered[0]
    for next_start, next_end in ordered[1:]:
        if next_start <= end:
            end = max(end, next_end)
        else:
            total += end - start
            start, end = next_start, next_end
    return total + end - start

assert union_duration([(0, 4), (3, 7), (9, 10)]) == 8
assert union_duration([]) == 0

Q1 参考答案

memory_reserved 显著高于 memory_allocated 绝不自动等价于发生显存泄漏,必须通过显存池化机制与生命周期基线进行科学鉴别:

  1. Caching Allocator 池化保留机制(Caching Pool Retention):
    • PyTorch 的显存分配器在某个张量被 Python GC 回收析构后,为了杜绝昂贵的系统级驱动调用(cudaFree 与随后的 cudaMalloc 会引发昂贵的 GPU 流全局同步与内核态切换),默认不会将物理显存段(Segments)归还给驱动;
    • 这些空闲块作为 Free Blocks 驻留在池中,等待后续 Step 产生新张量时以纳秒级速度极速复用。因此在训练进入稳态后,memory_reserved 维持在峰值水平是标准的性能保护行为;
  2. 显存碎片化与真实不可用(Fragmentation vs. Available Free):
    • memory_reserved - memory_allocated 的差值中,一部分是可随时复用的连续空闲块,另一部分是因张量频繁分配释放导致的交错外部碎片(Inactive Split Blocks)。若模型申请大张量时由于碎片过多找不到足够大的单一连续物理空间,仍会突发抛出 OOM;
  3. 真实显存泄漏的判定金标准:
    • 必须观察连续数十个 Step 中,每个 Step 结束时的基线 memory_allocated()(存活有效张量显存)是否呈单调线性递增;
    • 若 memory_allocated() 每次更新后都回落到完全相同的恒定基线,则系统绝无泄漏;若其基线持续攀升,则说明代码中存在持续持有张量引用的对象(如未 detach() 的 Loss 列表),需调用 torch.cuda.memory._record_memory_history() 导出显存快照(Snapshot)进行调用栈溯源。

Q2 参考答案

使用 CPU Wall-Clock(如 Python time.time())直接测量 CUDA 算子耗时,会由于 GPU 异步架构导致成百上千倍的严重低估或高估:

  1. 严重低估的物理根因(Asynchronous Queueing / Underestimation):
    • PyTorch 底层通过 CUDA Compute Stream 异步下发指令。在 CPU 线程执行 t0 = time.time(); y = torch.matmul(a, b); t1 = time.time() 时,CPU 仅仅是将 Kernel Launch 命令压入 CUDA Driver 的环形命令队列(Ring Buffer),耗时仅需 3∼10μs3 \sim 10\ \mu\text{s} 便瞬间返回;
    • 此时 GPU 硬件甚至尚未完成调度或刚刚开始读取数据。测量到的 t1 - t0 完全是 CPU 发射入队时延,直接低估了实际矩阵乘耗时数个数量级;
  2. 严重高估的物理根因(Implicit Pipeline Flushes / Overestimation):
    • 若为了等待计算完成而在代码段末尾加入同步:t0 = time.time(); y = torch.matmul(a, b); torch.cuda.synchronize(); t1 = time.time();
    • cudaStreamSynchronize() 会强行阻塞 CPU,直至 GPU 计算流上在此之前积压的所有历史异步任务全部执行完毕。若在此算子之前存在未完成的耗时通信、慢速数据搬运或先前的复杂 Kernel,这些历史任务的等待时间将被全部错误计入当前算子的测量区间,造成极度的假象高估;
  3. 精准测量规范:必须使用硬件级流内事件 torch.cuda.Event(enable_timing=True) 测量流内起止,或者通过 CUPTI / Nsight Systems 捕获纳秒级的底层硬件时间戳。

Q3 参考答案

当 Profiler Trace 呈现出“GPU 大面积闲置(Bubble),同时 CPU 线程利用率也极低(不忙碌)”时,说明系统遭遇了操作系统级别的阻塞式等待(Blocking System Calls),应遵循以下排查树定位根因:

  1. 跨节点分布式通信等待与掉队者排查(NCCL Straggler Wait):
    • 检查 CPU 线程是否阻塞在跨卡集合通信的同步点上。若当前 Rank 算力较快,提前进入了下一个 dist.all_reduce() 或梯度同步屏障,CPU 线程将阻塞在底层网络 Socket / RDMA 轮询等待慢速节点(Straggler)赶到;
  2. 存储端阻塞式数据 I/O 等待(Blocking Storage I/O):
    • 检查数据加载流水线。若 DataLoader 预取队列已被排空,主进程或 Worker 线程阻塞在系统调用(如 read(), recv(), open())上等待慢速网络文件系统(NFS/Ceph/S3)响应磁盘数据,导致 CPU 与 GPU 双重陷入休眠等待;
  3. 多进程共享内存 IPC 与死锁争用(IPC & GIL Lock Contention):
    • 检查 Python 多进程间的数据传输。若 Worker 进程因内存溢出崩溃、或进程间共享内存队列(shm)锁争用导致死锁,主进程将无限期挂起在等待子进程推送 Batch 句柄上;
  4. 操作系统内存管理锁与页错误等待(Page Allocation & Memory Locks):
    • 检查操作系统是否正在执行昂贵的物理内存清零、透明大页合并(THP Compaction)或换页等待,导致 CPU 线程在申请内存时被内核调度器置于不可中断休眠态(D 状态);
  5. 定位手段:在代码中插入 torch.cuda.nvtx.range_push/pop 命名区间,在系统侧使用 Linux strace -p <pid> 或 perf top 查看 CPU 线程陷入睡眠时的系统调用名称,快速定位空洞的真正发起点。

参考资料

API 与平台能力会演进;部署前应按目标版本重新核对。