欢迎光临
我们一直在努力

海光 DCU 性能分析实战:用 hipprof 从 API、Kernel 到 PMC 定位 PyTorch 瓶颈

请添加图片描述

本文围绕海光 DCU 的真实性能分析流程展开。我们先用一个可控的 vector-add 程序区分 HIP API、设备 kernel、调用栈、PMC 和资源泄漏检测的统计口径,再把同一套方法用到一个保持 69 通道完整输出的气象 Transformer。所有命令均在BW / gfx936、DTK 25.04.2 环境实测;文章中的优化收益来自严格 A/B,不使用profiler 插桩后的时间充当正常运行性能。

模型在 DCU 上“能跑”以后,下一步最容易犯的错误是只盯着设备利用率。利用率不满,并不能直接说明矩阵乘法没有吃满;它也可能是大量短 kernel、数据搬运、内存分配、同步、CPU 发射或不连续张量物化共同造成的。

我在气象 Transformer 上真正遇到过的情况正是如此。直觉上,Transformer 的主角应该是 GEMM;实际 trace 中,单类 direct-copy kernel 就占了约三分之一。直到把kernel 统计、调用栈和真实 shape 放到一起,优化方向才从“继续砍矩阵计算”转向“减少布局转换、中间张量和碎片化 launch”。

这篇文章重点回答五个问题:

  • hy-smi、HIP API 时间和设备 kernel 时间分别能说明什么;
  • –hip-trace –stats、–kernel-stack 和 PMC 分别应该在什么时候使用;
  • 为什么 profiler 下的运行时间不能当作发布性能;
  • 如何把几百个长 kernel 名称归类成可决策的热点;
  • 怎样从热点证据走到微基准、整模 A/B 和数值止损。
  • 一、先建立正确的性能分析分层

    在海光 DCU 上分析 PyTorch 程序,可以把证据分成五层:

    层次工具或数据能回答的问题
    设备状态 hy-smi 当前设备、进程和粗粒度利用率是否正常
    HIP API hipprof –hip-trace –stats 分配、拷贝、同步和 kernel 发射在主机侧花了多少时间
    设备 kernel result_*.kernel.csv 哪些 kernel 真正在 DCU 上占用设备执行时间
    调用来源 –kernel-stack、–hiptx-trace / ROCTX range 底层 kernel 是由哪条上层路径触发的
    硬件行为 PMC wave、寄存器、LDS、scratch、缓存和停顿等资源行为

    请添加图片描述

    这五层不能互相替代。

    例如,hipLaunchKernel 的 API 时间主要是主机提交开销;kernel CSV 中的时间是设备执行时间。两张表的百分比分母不同,不能把 hipLaunchKernel 0.3% 和vector_add kernel 100% 放在一起相加。

    同样,PMC 是带硬件计数器插桩的诊断模式。它能解释一个 kernel 为什么慢,却可能大幅改变 kernel 自己的执行时间。最终性能必须退出 profiler 后重新测量。

    二、环境与本文实测边界

    本文使用:

    OS Ubuntu 22.04.5 LTS
    DTK path /opt/dtk-25.04.2
    hipprof DTK 25.04.2 bundled tool
    PyTorch 2.5.1
    HIP runtime 6.3.25405
    device BW
    architecture gfx936

    进入容器后先检查工具:

    set +u
    source /opt/dtk-25.04.2/env.sh
    set -u

    command -v hy-smi
    command -v hipprof
    hipprof –help | head -n 40

    DTK 手册中几个容易混淆的参数是:

    • –stats:只打印并导出统计数据,不生成完整可视化 JSON;
    • –kernel-stack:额外导出 kernel 的主机端调用栈 CSV;
    • –pmc:采集硬件计数器;
    • –pmc-type 3:把 PMC 输出为适合批量解析的 CSV;
    • –kernel-name:只对指定 kernel 做 PMC;
    • –memory-usage:启动资源泄漏检查,但相比 –leak-check 减少函数栈提取;
    • –mask-trace:只收集配置文件中指定的 HIP API,降低大 trace 的数据量。

    本文不会使用浏览器可视化页面。hipprof 生成的 CSV 和数据库已经足够完成主要统计、归类和 A/B 决策,而且更适合归档和脚本复现。

    三、先准备一个可控的最小程序

    最小实验使用第一篇文章中的 vector_add.hip。它包含:

    • 三次 hipMalloc;
    • 两次 H2D 和一次 D2H hipMemcpy;
    • 20 次 warmup;
    • 100 次正式 kernel;
    • HIP event 同步计时;
    • 最大绝对误差检查;
    • 资源销毁与显存释放。

    编译时先确认当前 DCU 架构:

    rocminfo | grep -E 'Name:.*gfx'

    本文实机是 gfx936:

    hipcc -O3 -std=c++17 \\
    –offload-arch=gfx936 \\
    vector_add.hip -o vector_add

    正常运行:

    device=BW arch=gfx936:sramecc+:xnack-
    elements=1048576 mean_kernel_ms=0.0185488 max_abs_error=0

    这里的 0.0185488 ms 是退出 profiler 后,由程序自己的 HIP event 得到的基线。后面所有插桩模式都要和它分开记录。

    四、把不同 hipprof 模式拆成独立进程

    不要一次叠上 –hip-trace –kernel-stack –pmc –memory-usage。不同模式的目标和扰动程度不同,叠加后既难解释,也可能制造新的瓶颈。

    下面脚本把正常运行、stats、kernel-stack 和 PMC 放到四个目录。完整文件名为run_vector_add_profile.sh:

    #!/usr/bin/env bash

    # Run separate hipprof passes so trace, stack and PMC instrumentation do not
    # contaminate one another. Each pass writes into its own output directory.
    set -euo pipefail

    APP="$(realpath "${1:-./vector_add}")"
    OUT="${2:-hipprof_vector_add_$(date +%Y%m%d_%H%M%S)}"
    mkdir -p "$OUT"/{normal,stats,stack,pmc}

    set +u
    if [[ -f /opt/dtk-25.04.2/env.sh ]]; then
    source /opt/dtk-25.04.2/env.sh
    elif [[ -f /opt/dtk/env.sh ]]; then
    source /opt/dtk/env.sh
    fi
    set -u

    "$APP" | tee "$OUT/normal/program.txt"

    (
    cd "$OUT/stats"
    hipprof –hip-trace –stats "$APP" | tee hipprof.txt
    )

    (
    cd "$OUT/stack"
    hipprof –kernel-stack "$APP" | tee hipprof.txt
    )

    (
    cd "$OUT/pmc"
    hipprof –pmc –pmc-type 3 –kernel-name vector_add "$APP" | tee hipprof.txt
    )

    printf 'profile output: %s\\n' "$(realpath "$OUT")"

    运行:

    chmod +x run_vector_add_profile.sh
    ./run_vector_add_profile.sh ./vector_add ./profile_run

    输出目录大致为:

    profile_run/
    ├── normal/program.txt
    ├── stats/result_<pid>.hiptrace.csv
    ├── stats/result_<pid>.kernel.csv
    ├── stack/result_<pid>.stack.csv
    └── pmc/pmc_results_<pid>.csv

    这种目录隔离有三个好处:

  • 不会把不同进程生成的 PID 文件混在一起;
  • 可以明确知道一个 CSV 对应哪一种插桩模式;
  • 后续复查时不需要猜“这个 kernel 时间究竟来自正常、trace 还是 PMC”。
  • 五、第一步:用 –hip-trace –stats 分开看 API 和 kernel

    命令:

    hipprof –hip-trace –stats ./vector_add

    本次独立冷进程得到的 HIP API 前几项:

    name calls total_ms average_us percent
    hipMalloc 3 240.481148 80160.382 97.846
    hipMemcpy 3 2.432756 810.918 0.990
    hipEventSynchronize 1 1.496596 1496.596 0.609
    hipLaunchKernel 120 0.830707 6.922 0.338
    hipDeviceSynchronize 1 0.377584 377.584 0.154

    设备 kernel 表则只有:

    kernel calls total_ms average_us percent
    vector_add 120 2.255999 18.799 100.000

    请添加图片描述

    这两张表揭示了三个事实。

    1. 冷进程的 API 百分比不等于稳态热点

    hipMalloc 占 API 记录时间的 97.846%,但程序只在启动时分配三次。如果业务进程启动一次、随后推理几千次,直接为这三次分配重写 allocator,未必能改善稳定期吞吐。

    相反,如果业务每个样本都在循环中分配和释放大张量,分配才可能是稳定期问题。因此要同时看调用次数、代码位置和业务生命周期,不能只按百分比排序。

    2. API launch 时间和设备执行时间不是同一个指标

    本次 hipLaunchKernel 主机 API 平均约 6.922 us,设备 kernel 平均约18.799 us。前者是提交,后者是设备执行。异步流水中两者还可能重叠,不能简单相加成“每个 kernel 25.721 us”。

    3. –stats 很适合第一轮筛选

    它不生成完整 JSON,数据量小,能够先回答:

    • API 是否被分配、拷贝或同步主导;
    • kernel 名称、调用次数和设备时间排名;
    • 是否值得继续做 stack 或 PMC。

    如果第一轮就发现某个 kernel 总占比不到 0.2%,通常不值得立即写专用 kernel。

    六、第二步:用 –kernel-stack 找到上层调用者

    命令:

    hipprof –kernel-stack ./vector_add

    vector-add 的 120 次 launch 都能回到:

    kernel launches user frame
    vector_add(float const*, float const*, float*, int) 120 vector_add()

    真实 PyTorch 模型会更复杂。调用栈常常只能回到:

    python()
    libtorch_hip.so
    librocblas.so

    这是正常现象,不代表 –kernel-stack 没用。它至少能区分自定义扩展、rocBLAS、PyTorch elementwise 和 Runtime 路径。但如果想精确定位到模型中的某个 block,建议:

  • 用 kernel CSV 的 launch index 和 stack CSV 的 index 做关联;
  • 缩小输入,只保留一个或几个目标 block;
  • 在上层加入 ROCTX range,并用 –hiptx-trace 采集;
  • 对融合前后分别跑 stack,而不是把两个候选放在同一个进程里交替。
  • 长 kernel 名称本身往往只能告诉你“它是什么”,调用栈和 range 才能告诉你“是谁触发了它”。

    七、第三步:只对少数热点运行 PMC

    PMC 使用有限的硬件计数器资源,不可能一次拿到所有指标。DTK 手册把指标分组,–pmc-type 3 可以导出 CSV,适合脚本解析。

    本文只过滤 vector_add:

    hipprof –pmc –pmc-type 3 \\
    –kernel-name vector_add \\
    ./vector_add

    解析得到的稳定资源字段:

    records 120
    grid_size 1048576
    workgroup_size 256
    wave_size 64
    arch_vgpr 8
    accum_vgpr 0
    sgpr 16
    lds_bytes 0
    scratch_bytes 0
    lds_bank_conflict 0

    这些字段可以这样理解:

    • workgroup_size=256:每个 workgroup 256 个线程;
    • wave_size=64:该 kernel 的本次记录显示以 wave64 执行;
    • arch_vgpr=8、sgpr=16:这个简单 kernel 的寄存器压力较低;
    • lds=0:没有使用片上共享存储;
    • scratch=0:没有观察到寄存器溢出到 scratch;
    • LDS bank conflict=0:没有 LDS,自然也没有 LDS bank 冲突。

    不能仅凭这些字段宣称 kernel 已经“100% 吃满硬件”。要判断计算、L1/L2、访存停顿或 LDS 行为,还需要选择相应 PMC 分组,并结合业务目标分析。

    请添加图片描述

    八、最重要的警告:PMC 会改变被测程序

    同一个可执行程序在四种模式下打印:

    mode program mean kernel
    normal 0.0185488 ms
    hip-trace –stats 0.0187840 ms
    kernel-stack 0.0189324 ms
    PMC type 3 1.4807800 ms

    相对正常运行:

    • stats 约慢 1.27%;
    • kernel-stack 约慢 2.07%;
    • PMC 约慢 79.83x。

    请添加图片描述

    PMC 采集可能通过重复或重放 kernel 读取不同计数器,程序自己的 HIP event 也会感受到这类扰动。因此:

    trace、stack 和 PMC 用于归因;退出 profiler 后的同步墙钟或 HIP event 才用于最终性能结论。

    如果把 1.48078 ms 写成 vector-add 的“真实性能”,误差会接近两个数量级。

    九、–memory-usage 不是峰值显存监控

    命令:

    hipprof –memory-usage ./vector_add

    本次进程结束时打印:

    host leakcheck memory size 170.06 MB
    device0 leakcheck memory size 92.00 KB

    vector-add 已显式释放三个设备指针,但 Runtime、profiler 和进程级资源仍可能出现在记录中。–memory-usage 的定位是资源泄漏检查,而不是业务稳定期的显存平均值,也不是进程生命周期峰值。

    如果业务关注模型显存,应先明确口径:

    • PyTorch 活动张量:memory_allocated();
    • caching allocator 保留:memory_reserved();
    • 进程峰值:外部监控或 profiler 峰值;
    • 稳定期平均占用:固定周期外部采样;
    • 资源未释放:–leak-check 或 –memory-usage。

    不要看到 92 KB 就直接宣布用户代码存在 92 KB 显存泄漏。需要结合分配/释放记录、调用栈以及新进程复测再判断。

    十、用脚本解析 CSV,而不是手工数几百个 kernel

    下面的 summarize_hipprof.py 可以同时解析 API、kernel、stack 和 PMC CSV,并按名称模式聚合 PyTorch kernel。它不会依赖浏览器,也不会把私有绝对路径打印到摘要。

    """Summarize hipprof CSV exports without loading the browser UI."""

    from __future__ import annotations

    import argparse
    import csv
    from collections import defaultdict
    from pathlib import Path
    from typing import Iterable

    CATEGORY_RULES = (
    ("vector_add", ("vector_add",)),
    ("direct_copy", ("direct_copy_kernel",)),
    ("softmax", ("softmax",)),
    ("add", ("cudafunctor_add", "addfunctor", "binary_internal::add")),
    ("layernorm", ("layer_norm", "layernorm")),
    ("gelu", ("gelu",)),
    ("roll", ("roll_cuda",)),
    ("gemm", ("cijk_", "gemm", "addmm")),
    ("convolution", ("conv3d", "convolution", "col_2_image")),
    ("cat_or_index", ("catarray", "index_elementwise", "index_kernel")),
    )

    def read_csv(path: Path) > list[dict[str, str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as handle:
    return list(csv.DictReader(handle))

    def as_int(row: dict[str, str], key: str) > int:
    value = row.get(key, "").strip()
    return int(float(value)) if value else 0

    def as_float(row: dict[str, str], key: str) > float:
    value = row.get(key, "").strip()
    return float(value) if value else 0.0

    def classify_kernel(name: str) > str:
    lowered = name.lower()
    for category, patterns in CATEGORY_RULES:
    if any(pattern in lowered for pattern in patterns):
    return category
    return "other"

    def print_api_summary(path: Path, top: int) > None:
    rows = [row for row in read_csv(path) if row.get("Name") != "Total"]
    rows.sort(key=lambda row: as_int(row, "TotalDurationNs"), reverse=True)
    print("\\n[HIP API summary]")
    print("name\\tcalls\\ttotal_ms\\taverage_us\\tpercent")
    for row in rows[:top]:
    print(
    f"{row['Name']}\\t{as_int(row, 'Calls')}\\t"
    f"{as_int(row, 'TotalDurationNs') / 1e6:.6f}\\t"
    f"{as_int(row, 'AverageNs') / 1e3:.3f}\\t"
    f"{as_float(row, 'Percentage'):.3f}"
    )

    def print_kernel_summary(path: Path, top: int) > None:
    rows = [row for row in read_csv(path) if row.get("Name") != "Total"]
    rows.sort(key=lambda row: as_int(row, "TotalDurationNs"), reverse=True)
    total_ns = sum(as_int(row, "TotalDurationNs") for row in rows)

    print("\\n[Kernel summary]")
    if total_ns <= 0:
    print("no non-zero duration rows")
    return
    print("name\\tcalls\\ttotal_ms\\taverage_us\\tpercent")
    for row in rows[:top]:
    display_name = row["Name"]
    if len(display_name) > 100:
    display_name = display_name[:97] + "…"
    print(
    f"{display_name}\\t{as_int(row, 'Calls')}\\t"
    f"{as_int(row, 'TotalDurationNs') / 1e6:.6f}\\t"
    f"{as_int(row, 'AverageNs') / 1e3:.3f}\\t"
    f"{as_int(row, 'TotalDurationNs') / total_ns * 100:.3f}"
    )

    category_ns: dict[str, int] = defaultdict(int)
    category_calls: dict[str, int] = defaultdict(int)
    for row in rows:
    category = classify_kernel(row["Name"])
    category_ns[category] += as_int(row, "TotalDurationNs")
    category_calls[category] += as_int(row, "Calls")

    print("\\n[Pattern-based kernel categories]")
    print("category\\tcalls\\ttotal_ms\\tpercent")
    for category, duration_ns in sorted(
    category_ns.items(), key=lambda item: item[1], reverse=True
    ):
    print(
    f"{category}\\t{category_calls[category]}\\t"
    f"{duration_ns / 1e6:.6f}\\t{duration_ns / total_ns * 100:.3f}"
    )

    def sanitize_frame(frame: str) > str:
    frame = frame.strip()
    if not frame:
    return "unknown"
    if " [" in frame:
    frame = frame.split(" [", 1)[0]
    if "/" in frame:
    prefix, separator, suffix = frame.rpartition("/")
    if separator:
    frame = suffix
    return frame

    def is_runtime_frame(frame: str) > bool:
    lowered = frame.lower()
    return lowered.startswith("python(") or any(
    marker in lowered
    for marker in (
    "libgalaxy",
    "libamdhip",
    "libc.so",
    "libtorch",
    "libpython",
    "python3",
    )
    )

    def print_stack_summary(path: Path, top: int) > None:
    rows = read_csv(path)
    grouped: dict[tuple[str, str], list[tuple[int, str]]] = defaultdict(list)
    for row in rows:
    key = (row.get("str_name", "unknown"), row.get("index", "unknown"))
    grouped[key].append((as_int(row, "stack_sq"), row.get("addrname", "")))

    kernel_launches: dict[str, int] = defaultdict(int)
    kernel_origins: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
    for (kernel_name, _), frames in grouped.items():
    kernel_launches[kernel_name] += 1
    origin = "runtime-only"
    for _, frame in sorted(frames):
    cleaned = sanitize_frame(frame)
    if cleaned and not is_runtime_frame(cleaned):
    origin = cleaned
    break
    kernel_origins[kernel_name][origin] += 1

    print("\\n[Kernel stack summary]")
    print("kernel\\tlaunches\\tmost_common_user_frame")
    for kernel_name, launches in sorted(
    kernel_launches.items(), key=lambda item: item[1], reverse=True
    )[:top]:
    origins = kernel_origins[kernel_name]
    origin = max(origins.items(), key=lambda item: item[1])[0]
    display_name = kernel_name if len(kernel_name) <= 90 else kernel_name[:87] + "…"
    print(f"{display_name}\\t{launches}\\t{origin}")

    def unique_values(rows: Iterable[dict[str, str]], key: str) > str:
    values = sorted({row.get(key, "").strip() for row in rows if row.get(key, "").strip()})
    return ",".join(values) if values else "n/a"

    def print_pmc_summary(path: Path) > None:
    rows = read_csv(path)
    if not rows:
    print("\\n[PMC summary]\\nno rows")
    return

    print("\\n[PMC resource summary]")
    print(f"records\\t{len(rows)}")
    print(f"kernel\\t{unique_values(rows, 'KernelName')}")
    for label, key in (
    ("grid_size", "grd"),
    ("workgroup_size", "wgr"),
    ("wave_size", "wave_size"),
    ("arch_vgpr", "arch_vgpr"),
    ("accum_vgpr", "accum_vgpr"),
    ("sgpr", "sgpr"),
    ("lds_bytes", "lds"),
    ("scratch_bytes", "scr"),
    ("lds_bank_conflict", "SQ_LDS_BANK_CONFLICT"),
    ):
    print(f"{label}\\t{unique_values(rows, key)}")

    def main() > None:
    parser = argparse.ArgumentParser()
    parser.add_argument("–hiptrace", type=Path)
    parser.add_argument("–kernel", type=Path)
    parser.add_argument("–stack", type=Path)
    parser.add_argument("–pmc", type=Path)
    parser.add_argument("–top", type=int, default=10)
    args = parser.parse_args()

    if not any((args.hiptrace, args.kernel, args.stack, args.pmc)):
    parser.error("provide at least one hipprof CSV path")
    if args.hiptrace:
    print_api_summary(args.hiptrace, args.top)
    if args.kernel:
    print_kernel_summary(args.kernel, args.top)
    if args.stack:
    print_stack_summary(args.stack, args.top)
    if args.pmc:
    print_pmc_summary(args.pmc)

    if __name__ == "__main__":
    main()

    vector-add 的解析命令:

    python summarize_hipprof.py \\
    –hiptrace profile_run/stats/result_<pid>.hiptrace.csv \\
    –kernel profile_run/stats/result_<pid>.kernel.csv \\
    –stack profile_run/stack/result_<pid>.stack.csv \\
    –pmc profile_run/pmc/pmc_results_<pid>.csv

    注意 <pid> 是占位符,实际运行时应替换成当前目录中的文件名;不要把带尖括号的文本直接交给 shell。

    模式归类是启发式的。PyTorch 或 DTK 版本变化后,kernel 名称可能改变,发布报告前应抽查每个分类的代表行,避免把名称相似但语义不同的 kernel 合并。

    十一、真实案例:69 通道气象 Transformer 的热点是什么

    我们对一个保持 69 通道、原空间分辨率和完整预测路径的气象 Transformer 分别运行了 stats 和 kernel-stack。设备 kernel CSV 共记录:

    kernel calls 1869
    kernel time 825.152715 ms

    按名称模式聚合后:

    category calls total_ms percent
    direct_copy 843 266.933896 32.350
    gemm 316 181.074114 21.944
    add 192 103.964575 12.599
    softmax 56 90.451776 10.962
    layernorm 96 77.525224 9.395
    gelu 44 35.815013 4.340
    roll 120 31.863178 3.861
    other 134 19.596949 2.375
    cat_or_index 60 10.896634 1.321
    convolution 8 7.031356 0.852

    请添加图片描述

    这里的 825.152715 ms 是捕获窗口内所有设备 kernel duration 的累计值,用于比较同一CSV 内各类别的相对占比;它不是无插桩环境下的单样本端到端墙钟时间,也不应和后文的整模稳态时间直接比较。

    这里有两个看似矛盾、其实都成立的结论:

  • GEMM 仍然是重要类别,占约 21.94%;
  • direct-copy、add、softmax、LayerNorm、GELU、roll 合计约 73.51%,非 GEMM 路径 和碎片化数据流比我们最初预期的更重要。
  • 如果只优化 GEMM,即使把这部分无限加速,Amdahl 上限也受剩余约 78% 时间约束。因此后续重点转向:

    • QKV 布局转换和切片物化;
    • bias 与激活中间张量;
    • LayerNorm 前后的非连续 rearrange;
    • roll、copy、cat 和 index 等碎片化路径。

    为什么不能只看 HIP API 表

    同一全模型进程的 HIP API 表中,hipModuleLoadData、hipHostMalloc、hipMalloc和 hipModuleGetFunction 占比较高,因为 trace 包含模型加载和 Runtime 初始化。这些数字适合分析冷启动,不应直接替代稳态 kernel 分类。

    十二、从热点归因到真正有效的融合

    最终通过数值门和整模 A/B 的方案包含两部分。

    1. QKV 后处理融合

    原路径把 QKV projection 组织为连续的:

    [B, P, T, 3, H, D]

    其中 B 是 batch,P 是窗口组,T 是窗口内 token 数,H 是 head 数,D 是单个 head 的维度。

    随后 permute 到:

    [3, B, H, P, T, D]

    Q、K、V 切片变成非连续视图;在这条真实路径中,后续计算触发了额外的布局物化与复制。融合 kernel 读取一次交错 QKV,直接写出 packed contiguous 布局,并融合Q scaling。

    2. fc1 bias + exact GELU 融合

    原路径:

    no-bias GEMM -> BF16 bias add -> exact GELU -> output

    融合路径把 bias 物化和 exact erf GELU 合并到一个随源码编译的 HIP kernel 中,避免一个完整中间张量。

    最终外部 2 ms 采样的 A-B-B-A:

    metric reference fused delta
    sampled mean VRAM 2789.217304 MiB 2770.666960 MiB -18.550344 MiB
    steady mean time 0.188963599 s 0.177391734 s -6.1239%
    完整输出代理指标 33.900791087 33.894619270 -0.006171817

    这组结果说明:

    • 时间和采样显存同时下降,不是使用持久大 workspace 换速度;
    • 完整输出代理指标只变化 -0.00617;
    • 微基准收益能够落到整模,但整模提升远小于某个单 kernel 的加速倍数。

    这里的完整输出代理指标是项目内部的同权重 A/B 数值门,只用于判断候选是否改变完整模型输出,不是跨模型或跨硬件的通用 benchmark。

    并不是所有相似融合都安全。另一个 fc2 residual+bias kernel 只节省约 0.47 ms,却让完整输出代理指标从约 33.90 降到 33.44,因此直接停止。profiler 只能告诉你“哪里值得看”,不能替代数值与业务正确性门槛。

    请添加图片描述

    十三、一套可以复用的 DCU 性能优化流程

    步骤 1:正常环境建立基线

    记录:

    • warmup;
    • 重复次数;
    • 同步位置;
    • 输入 shape 和 dtype;
    • 端到端时间;
    • 业务数值指标;
    • 显存统计口径。

    步骤 2:stats 找 API 和 kernel 热点

    先用:

    hipprof –hip-trace –stats <command>

    只选总占比足够高、调用次数和业务位置合理的候选。

    步骤 3:stack 或 range 找到调用路径

    hipprof –kernel-stack <command>

    调用栈太浅时,缩小模型,或加入 ROCTX range 并使用 –hiptx-trace。

    步骤 4:真实 shape 微基准

    对 reference 和 candidate 同时做:

    • 数值差异;
    • 20–30 次 warmup;
    • 100 次左右重复;
    • 显式同步;
    • 多轮交错 A/B。

    步骤 5:只对少数 kernel 跑 PMC

    hipprof –pmc –pmc-type 3 \\
    –kernel-name <hot-kernel> \\
    <microbenchmark>

    PMC 用于解释资源行为,不用于最终时间。

    步骤 6:回到完整模型做 A-B-B-A

    保持相同权重、输入、进程条件和采样方式。时间、显存和完整输出必须一起通过。

    步骤 7:冷启动和发布检查

    source-only 扩展还要验证:

    • 新容器第一次编译;
    • 构建产物是否只写入临时目录;
    • 缓存是否按源码、Runtime 和架构失效;
    • 退出 profiler 后的发布路径是否仍然使用相同代码。

    十四、常见误区对照表

    现象容易得出的错误结论更可靠的处理
    hy-smi 利用率不满 GEMM 一定没调好 先区分 launch、copy、同步和设备 kernel
    hipMalloc 占 API 90% 以上 allocator 是稳态第一瓶颈 看调用次数及其是否只发生在冷启动
    PMC 下 kernel 慢几十倍 优化前 kernel 真实就这么慢 退出 PMC 后重新同步计时
    memory-usage 显示若干 KB 用户代码必然泄漏 结合调用栈、释放记录和新进程复测
    单 kernel 快 2x 整模也会快 2x 用热点占比估算 Amdahl 上限并跑整模 A/B
    stack 只看到 python() stack 完全没价值 关联 index、缩小模型、加入 range
    kernel 名包含 CUDA 当前不是 DCU/HIP 路径 PyTorch 兼容命名不能代表真实后端
    输出差异很小 一定可以上线 用完整业务指标和边界输入决定

    十五、发布前验收清单

    • 正常、stats、stack、PMC 分进程运行;
    • API 与设备 kernel 的时间口径没有混用;
    • 记录 kernel 调用次数,而不只看百分比;
    • 对长 kernel 名称做抽样核对后再归类;
    • stack 或 range 已经定位到上层路径;
    • PMC 只过滤少数热点 kernel;
    • PMC 插桩时间没有写成正式性能;
    • 微基准使用真实 shape、dtype 和同步位置;
    • candidate 先通过数值门,再测性能;
    • 完整模型使用交错 A/B,时间、显存和输出一起验证;
    • 显存报告明确峰值、活动、保留或采样平均口径;
    • 报告中的绝对时间标注具体 DCU、DTK 和软件版本;
    • CSV、脚本和原始结论都有可审计归档。

    结语

    hipprof 最有价值的地方不是生成一张很复杂的时间线,而是把“感觉模型慢”拆成可以验证的问题:主机在分配还是发射,设备在 GEMM 还是 copy,某个 kernel 是计算、缓存、LDS 还是寄存器受限,以及一个微优化最终能不能落到整模。

    在本文的气象 Transformer 中,真正改变方向的是 direct-copy 占比,而不是某个漂亮的峰值算力数字。最终有效的方案也不是继续删除模型结构,而是沿着 profiler 证据,融合 QKV 布局和 fc1 bias/GELU 数据流,并用完整输出和采样显存守住边界。

    可以复用的核心顺序只有一句话:

    正常基线 -> stats 找热点 -> stack 找来源 -> 真实 shape 微基准
    -> PMC 解释行为 -> 整模 A-B-B-A -> 冷启动发布验证

    工具负责提供证据,数值和端到端结果负责做最终决定。

    参考资料

    • 光合开发者社区工具下载:https://developer.hpccube.com/tool/
    • DTK 25.04.2《hipprof 使用手册》
    • DTK 25.04.2《Rocprofiler API 开发手册》
    • PyTorch HIP semantics:https://docs.pytorch.org/docs/stable/notes/hip.html
    • PyTorch Profiler:https://docs.pytorch.org/docs/stable/profiler.html
    赞(0)
    未经允许不得转载:171主机测评 » 海光 DCU 性能分析实战:用 hipprof 从 API、Kernel 到 PMC 定位 PyTorch 瓶颈
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址