ggml-cuda: add per-op GPU roofline profiling for the HIP backend#33
Merged
Conversation
Author
|
Roofline counter part PR: https://gitenterprise.xilinx.com/FaaSApps/rocm-scripts/pull/597 |
6605ef4 to
24ad1e9
Compare
mgehre-amd
reviewed
Jul 3, 2026
Optional profiler (GGML_HIP_ROOFLINE, off by default) that attributes each GPU kernel's device-measured time to the ggml op that launched it and writes a JSON report, one row per op invocation listing every kernel that op dispatched. Aggregation (grouping identical ops, averaging, counting) is left to the consumer. Activated at runtime by GGML_ROOFLINE_OUT=<path>; otherwise every entry point is a no-op. Kernel durations are read from device dispatch timestamps (the same source as rocprofv3) and match rocprofv3's measurements. An earlier approach based on host CUDA/HIP events (cudaEventElapsedTime around each op) was giving wrong results: it included kernel-launch bubbles and overstated GPU time by roughly 1.7-3x. Uses rocprofiler-sdk, loaded with dlopen and configured at runtime with symbols resolved via dlsym (the library is not linked, since its constructors abort when linked into an early-loaded shared object). Disables GPU graphs while active so each op's kernels are dispatched individually, which is required for per-op attribution. llama-bench calls ggml_cuda_roofline_reset() after its warmup run so the report covers only the measured run, not warmup. The declaration and call are guarded by #ifdef GGML_HIP_ROOFLINE (the macro is added to the llama-bench target only when the option is on), so they compile out entirely in non-roofline builds. Each row carries the op's destination/source shapes and types, HBM byte traffic, matmul dimensions, and the name and device time of every GPU kernel it dispatched. When the backend fuses several ops into one kernel (matmul+bias, ffn up+gate+GLU, rms_norm+mul, rope+view+set_rows, ...), the row is labelled by the head op and adds a fused_ops array with each fused node's geometry; bytes/dst_bytes are corrected to the fused group's real traffic (intermediates excluded), and MoE expert weights are scaled to the routed experts (top_k taken from the ids tensor). Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
24ad1e9 to
1525b34
Compare
Author
|
@mgehre-amd this PR is hidden behind a cmake flag, it is safe to merge. It would simplify my workflow having it merge so I don't have to cherry-pick the commits on every branch |
mgehre-amd
approved these changes
Jul 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds an optional, off-by-default profiler for the HIP/ROCm backend that attributes each GPU kernel's device-measured time to the ggml op that launched it and writes a per-op JSON report on exit. It is intended to feed roofline analysis (arithmetic intensity, % of peak) with quant-aware, per-op numbers.
It is enabled at build time with
GGML_HIP_ROOFLINEand activated at runtime with theGGML_ROOFLINE_OUT=<path.json>environment variable. When the variable is not set, every entry point is a no-op and the build behaves exactly like a normal one.Accuracy
Kernel durations are read from device dispatch timestamps, the same source
rocprofv3uses, and matchrocprofv3's measurements. Verified apples-to-apples with graphs disabled on both a small dense model and a large MoE model: token-generation throughput was within run-to-run noise ofrocprofv3and prefill was equal or slightly faster.An earlier approach based on host CUDA/HIP events (
cudaEventElapsedTimearound each op) was giving wrong results: it captured kernel-launch bubbles and overstated GPU time by roughly 1.7-3x. That approach was discarded in favor of device timestamps, which are bubble-free.How it works
The profiler uses rocprofiler-sdk, loaded with
dlopenand configured at runtime viarocprofiler_force_configure, with its entry points resolved throughdlsym. It is deliberately not linked: rocprofiler-sdk's global constructors abort when the library is linked into an early-loaded shared object, so linking it intolibggml-hipis not viable.Each op invocation is tagged with a unique external correlation id, so its kernels stay separate from every other invocation; the shared op geometry (destination and source shapes, types, and op params) is stored once per distinct shape. The kernel-dispatch records produced by rocprofiler carry the invocation id back, and each invocation's kernels are recorded with their individual device time. Kernel symbol names are resolved through the code-object callback and demangled. Grouping identical ops, counting, and averaging are left to the consumer.
While the profiler is active it disables GPU graphs (it sets
GGML_CUDA_DISABLE_GRAPHSif the user has not set it). Per-op attribution requires the eager node loop, because a replayed graph dispatches all of its kernels in a single host call and the correlation id cannot be updated per kernel. This has negligible cost: disabling graphs was within noise on the dense model and slightly faster on the MoE model in testing.To keep the report to the measured run only, the profiler exposes
ggml_cuda_roofline_reset(), which drains any pending records and discards everything captured so far (kernel-name cache and the monotonic invocation counter are kept).llama-benchcalls it after its warmup block and before the timed reps, so the artifact covers exactly one profiled run rather than warmup plus reps. Both the declaration and the call are guarded by#ifdef GGML_HIP_ROOFLINE(the same macro is added to thellama-benchtarget only when the option is enabled), so the code is compiled out entirely in non-roofline builds.Report contents
The JSON report has one row per op invocation. Each row records the ggml op name, destination and source shapes and types, HBM byte traffic (total and per tensor), matmul dimensions, and the list of GPU kernels the op dispatched, each with its demangled symbol name and device time.
When the CUDA backend fuses several ops into one kernel (e.g.
MUL_MAT+bias, ffn up+gate+SwiGLU,RMS_NORM+MUL,ROPE+VIEW+SET_ROWS), the row is labelled by the head op and adds afused_opsarray holding each fused node's geometry (op name, types, shapes, params, matmul dims). The row'sbytes/dst_bytesare corrected to the fused group's real HBM traffic — intermediates produced and consumed inside the fused span never reach global memory and are excluded — while the per-node geometry lets a consumer sum exact FLOPs across the group (fusion changes memory traffic, not arithmetic).Example
A single output-projection
MUL_MATinvocation (the lm_head,q8_0weight), showing the two kernels it dispatched (activation quantization + the matmul):{ "device": "gfx1151", "total_gpu_time_us": 63981.4, "rows": [ { "ggml_op": "MUL_MAT", "dtype": "f32", "quant": "q8_0", "ne": [151936, 1, 1, 1], "src_ne": [[896, 151936, 1, 1], [896, 1, 1, 1]], "src_types": ["q8_0", "f32"], "op_params": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "bytes": 145254400, "dst_bytes": 607744, "src_bytes": [144643072, 3584], "M": 1, "N": 151936, "K": 896, "n_experts": 0, "top_k": 0, "kernels": [ { "name": "quantize_q8_1(float const*, void*, ...) [clone .kd]", "gpu_time_us": 1.04 }, { "name": "void mul_mat_vec_q<(ggml_type)8, 1, false, false>(void const*, ...) [clone .kd]", "gpu_time_us": 608.7 } ] } ] }neis the destination shape andsrc_ne/src_typesthe sources (heresrc0is theq8_0weight[896, 151936],src1thef32activation).bytesis the total HBM traffic (dst_bytes+src_bytes).M/N/Kare the matmul dims (n_experts/top_kare non-zero only forMUL_MAT_ID).op_paramscarries op geometry for conv/pool/rope/etc. Kernel symbol names are truncated above for readability; the artifact stores the full demangled signatures.A fused row adds the
fused_opsarray. Here a down-projectionMUL_MATis fused with its residualADDinto one kernel;bytes/dst_bytesare the fused group's external traffic (the matmul output is an intermediate consumed by theADD, so it is not counted), and each fused node keeps its own geometry so a consumer can sum exact FLOPs (2·M·N·K+numelhere):{ "ggml_op": "MUL_MAT", "fused_ops": [ {"ggml_op": "MUL_MAT", "dtype": "f32", "quant": "q4_K", "ne": [896, 1, 1, 1], "src_ne": [[4864, 896, 1, 1], [4864, 1, 1, 1]], "src_types": ["q4_K", "f32"], "op_params": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "M": 1, "N": 896, "K": 4864, "top_k": 0}, {"ggml_op": "ADD", "dtype": "f32", "quant": "f32", "ne": [896, 1, 1, 1], "src_ne": [[896, 1, 1, 1], [896, 1, 1, 1]], "src_types": ["f32", "f32"], "op_params": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "M": 0, "N": 0, "K": 0, "top_k": 0} ], "dtype": "f32", "quant": "q4_K", "ne": [896, 1, 1, 1], "src_ne": [[4864, 896, 1, 1], [4864, 1, 1, 1]], "src_types": ["q4_K", "f32"], "op_params": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], "bytes": 2478080, "dst_bytes": 3584, "src_bytes": [2451456, 19456], "M": 1, "N": 896, "K": 4864, "n_experts": 0, "top_k": 0, "kernels": [ { "name": "quantize_q8_1(float const*, void*, ...) [clone .kd]", "gpu_time_us": 1.16 }, { "name": "void mul_mat_vec_q<(ggml_type)12, 1, true, false>(void const*, ...) [clone .kd]", "gpu_time_us": 15.36 } ] }For a fused
MUL_MAT_IDgroup (MoE), the expert-weight bytes in the group total are scaled to the experts actually routed (min(M·top_k, n_experts)), andtop_kis taken from the ids tensor (the number of experts selected per token), so decode traffic is not over-counted.Fusion is genuine, not just adjacent nodes
The
fused_opslabels reflect real kernel-level fusion, verified two ways. First, structurally: every fused row dispatches exactly one compute kernel (matmul rows additionally carry a separate activation-quantizeprep kernel, which is reported but not counted as the fused work) — no fused claim ever maps to multiple kernels. Second, by reading each kernel's implementation to confirm it performs the whole group in a single pass. The template/boolean parameters baked into the demangled kernel names even line up with the fusion flags:RMS_NORM+MUL→rms_norm_f32<block, do_multiply, do_add>; the recorded<…, true, false>setsdo_multiply=true, so the kernel multiplies by the second operand in-kernel after normalizing (the<…, true, true>variant additionally fuses anADD).SILU+MUL,SIGMOID+MUL→unary_gated_op_kernel<op, T>, which computesdst = op(x) * gatein one pass — the activation and the multiply are the same kernel.ROPE+VIEW+SET_ROWS→rope_neox<…, __half>, which takesrow_indices/set_rows_strideand scatters the roped result straight into the KV cache row (the__halfoutput type is the cache), so the rope compute and theset_rowswrite are fused.MUL_MAT+ADDandMUL_MAT+MUL_MAT+GLU→mul_mat_vec_q<type, ncols, has_fusion, …>; the recordedhas_fusion=truepath readsfusion.gate(the second projection),fusion.x_bias/fusion.gate_bias(the biasADD), andactive_glu, so the matmul(s) + bias + GLU run together.ADD+ADD+…→k_bin_bcast<op_add, n_fuse>, a kernel variadic over its source pointers and launched withmake_index_sequence<n_fuse>, so an n-way residual add is one kernel.SOFT_MAX+RESHAPE+ARGSORT+VIEW+GET_ROWS+RESHAPE+SUM_ROWS+CLAMP+DIV+RESHAPE) →topk_moe_cuda, documented as the "fusion of softmax → top-k → get_rows pipeline", doing softmax + top-k + normalize/clamp in-kernel.Usage
Build with the flag enabled:
cmake -S . -B build -DGGML_HIP=ON -DGGML_HIP_ROOFLINE=ON && cmake --build build -j. The prebuilt ROCm multiarch release binaries (llama-b*-ubuntu-rocm-multiarch-x64.tar.gz) are built withGGML_HIP_ROOFLINE=ON, so they are profiling-capable out of the box — no rebuild needed, just setGGML_ROOFLINE_OUTat runtime.Run any binary with the output path set:
GGML_ROOFLINE_OUT=/tmp/roofline.json ./build/bin/llama-bench -m model.gguf -ngl 999 -p 256 -n 128. The report is written to that path on exit. Underllama-benchthe artifact covers a single profiled run (warmup is reset out), so-r 1gives a clean one-pass report.The profiler loads
librocprofiler-sdk.soat runtime; it ships in the same ROCm library directory as hipBLAS/rocBLAS, so no setup beyond what already lets the HIP binary find those libraries is needed. If the ROCm runtime is not on the default loader path (e.g. a pip/venv install), put that directory onLD_LIBRARY_PATH— the same as for any normal run of the binary.Scope and impact
The change is contained to the HIP backend and a single new source file plus a one-line hook in the CUDA graph-compute loop, guarded by
GGML_HIP_ROOFLINE. Builds without the flag are unaffected, and builds with the flag are unaffected unlessGGML_ROOFLINE_OUTis set at runtime.By design it touches as little existing code surface as possible so that pulling in upstream changes stays easy: all of the logic lives in the new
ggml-cuda-roofline.{cpp,h}, and the edits to existing files are only the guarded one-line hook and init call inggml-cuda.cu, theGGML_HIP_ROOFLINEoption inggml/CMakeLists.txt, the roofline build wiring inggml-hip/CMakeLists.txt, and, forllama-bench, a guarded post-warmup reset call (behind#ifdef GGML_HIP_ROOFLINE) plus a two-linetarget_compile_definitionsin itsCMakeLists.txtthat defines that macro for thellama-benchtranslation unit only when the option is on. Everything is compiled out unless the option is enabled, which keeps merge conflicts against upstream minimal and confined to a few well-isolated points.The only non-source change is a one-line
-DGGML_HIP_ROOFLINE=ONadded to the ROCm release build in.github/workflows/build-gfx11-rocm.yml, so the published multiarch binaries ship profiling-capable. The rocprofiler-sdk headers this requires are already present in TheRock's multiarch ROCm tarball the workflow extracts to/opt/rocm, so no extra dependency install is needed.