aorta

amd-roctracer — kernel-level attribution on the portable AMD backend

Three distinct Triton kernels — an elementwise scale, a fused bias+GELU, and a row-sum reduction — launched in sequence, captured by Proton’s roctracer backend. This is the example to reach for when you want to know which kernel in a launch sequence owns the GPU time, on the whole-kernel AMD backend that exists in every released Triton — including the releases that predate rocprofiler, which arrived in 3.8.0. (instrumentation ships in every release too, but it measures inside one kernel — see ../amd-instrumentation.)

It differs from triton-vecadd and triton-softmax in two ways that matter: the payload is a real multi-kernel sequence rather than a single launch, and the recipe pins the backend explicitly through mode: env instead of leaving backend: "auto" on mode: cli. The second is not a style choice — see Why mode: env.

Requirements

   
Runtime Triton + PyTorch built for ROCm, one AMD GPU
Profiler Proton, which ships inside Triton — no separate install
Triton version Any released Triton. roctracer has been in Proton’s backend list since it was introduced, so this example does not care which release you have. rocprofiler needs 3.8.0 or newer
Python deps torch, triton

Run it in a container

docker run --rm -it \
  --device=/dev/kfd --device=/dev/dri \
  --group-add video --ipc=host \
  --security-opt seccomp=unconfined \
  -v "$PWD:/work" -w /work \
  rocm/pytorch:latest \
  bash -lc '
    pip install -e . &&
    aorta sweep run \
      --recipe examples/profiling/proton/amd-roctracer/recipe.yaml \
      --output ./profiling_results \
      -- python examples/profiling/proton/amd-roctracer/pipeline.py \
           --rows 4096 --cols 1024 --iters 20
  '

Pin the image by digest for anything you intend to compare over time. A ROCm venv with torch + triton on the host works the same way.

Run standalone

python examples/profiling/proton/amd-roctracer/pipeline.py --rows 4096 --cols 1024 --iters 20

Options: --rows, --cols, --iters, --backend. Output:

pipeline: device=...
pipeline: rows=4096 cols=1024 iters=20
pipeline: max_rel_err=...
pipeline: PASS

With no --backend and no AORTA_PROTON_* in the environment the payload takes no capture at all — it just runs the three kernels and checks them. That is the fast way to separate “my payload is broken” from “my profiler is broken”.

The check is a bounded relative error (ceiling 1e-5) against torch.nn.functional.gelu(x * scale + bias).sum(dim=-1), not exact equality: the reduction kernel sums --cols float32 values in a different order than Tensor.sum, so the low bits differ legitimately. Measured error on the defaults is around 1.9e-07, four orders of magnitude inside the ceiling, so a failure here means a real bug rather than drift. The comparison is written as not (err <= tol) so a NaN fails instead of passing.

The activation and the reduction each use one program per row, with the block extent rounded up to the next power of two, so a very large --cols asks Triton for a block it may not find registers for. Keep --cols at or below a few thousand.

Run standalone under Proton

This payload drives Proton itself, so there is no python -m triton.profiler.proton wrapper to add — name the backend and it profiles:

python examples/profiling/proton/amd-roctracer/pipeline.py --backend roctracer
proton-viewer -m time/s pipeline.hatchet

To rehearse exactly what aorta does, export the bundle by hand instead:

mkdir -p ./proton_out
env AORTA_PROTON_DIR=./proton_out \
    AORTA_PROTON_NAME=./proton_out/proton \
    AORTA_PROTON_CONTEXT=shadow \
    AORTA_PROTON_DATA=tree \
    AORTA_PROTON_BACKEND=roctracer \
  python examples/profiling/proton/amd-roctracer/pipeline.py --iters 20

AORTA_PROTON_NAME present is what the payload treats as “aorta asked for a capture”; the rest of the variables fall back to the collector’s own defaults when absent, so a partial bundle still behaves.

What you get

A .hatchet JSON tree in the trial’s proton/ directory, reported as proton_artifact_dir, plus the collector’s numeric metrics — proton_kernel_count, proton_gpu_time_ms and proton_top_kernel_ms — in perf.md and matrix.json::cells[*].metrics_summary.

The point of this example is what the tree’s shape tells you. With context: "shadow" each Triton kernel gets its own leaf under ROOT, so the capture names all three:

Expect the fused activation to be the most expensive of the three and the reduction the cheapest — it reads the same bytes but writes one value per row instead of a full matrix. Read the raw tree with `proton-viewer -m time/s

.hatchet`, in the same environment as the capture. ## Why `mode: env` On Triton 3.7.x and earlier, pinning `roctracer` through `mode: cli` **silently captures nothing**, and the mechanism is worth knowing because it is not a bug you would guess from the output. Triton's Proton CLI front-end resolves the backend like this: ```python backend = args.backend if args.backend else _select_backend() ``` `_select_backend()` initialises the Triton HIP driver as a side effect — and it is only called when `-b` is *absent*. roctracer records nothing unless it starts after the HIP runtime is up, so with `-b roctracer` the profiler attaches to a runtime that does not exist yet. The run still exits 0 and still writes a hatchet: a ~160-byte one, holding a bare `ROOT` frame with empty metrics. aorta's parser finds no `time ()` leaves, degrades to `proton_artifact_dir`, and the trial carries no Proton metrics while looking like a success. Upgrading Triton *does* help, and not for the reason the line above would suggest. That line is line 73 of `third_party/proton/proton/proton.py` at the `v3.8.0` tag, unchanged, so 3.8.0 still skips the driver-initialising call on the `-b` path exactly as 3.7.1 does — yet the same pin captures normally there: measured on gfx950 / ROCm 10 at 3090 bytes, byte-for-byte what `backend: auto` produces. Something else on that stack brings the runtime up before the session starts. So the empty capture is a 3.7.x-and-earlier defect, not a permanent property of pinning. The collector nonetheless refuses the pairing on *every* version, because 3.7.x is still what several images in use ship (3.7.1 in `rocm/pytorch:rocm7.14_ubuntu26.04_py3.14_pytorch_release_2.12.0`, 3.6.0 in `rocm/pytorch:latest`) and the failure it prevents is silent. Dropping the refusal once the supported floor reaches 3.8 is tracked in [ROCm/aorta#439](https://github.com/ROCm/aorta/issues/439). `mode: env`, which this example uses, is correct on every version either way. `mode: env` avoids this entirely. aorta exports `AORTA_PROTON_*` and leaves argv alone; the payload imports `torch` at module scope — which brings the HIP runtime up — and only then calls `proton.start(backend=...)` itself. The collector enforces this: `mode: cli` with an explicit `roctracer` backend raises `ProtonWrapError` and names `mode: env` as the route, so a `mode: cli` version of this recipe would not run at all. The guard stops at `roctracer`. `rocprofiler` looks like the same case and is the opposite one: it is configured by an `__attribute__((constructor))` when `libproton.so` loads, so it wants to be set up *before* HSA rather than after, and its CLI pin is allowed for that reason. **The two backends constrain different events, so one ordering serves both.** `roctracer` constrains when the *session starts* — it installs its interceptor then, and needs the runtime already up. `rocprofiler` constrains when it is *configured*, which the `libproton.so` constructor does at import. Those are not the same moment, so there is nothing to trade off: this payload imports `triton.profiler` before `torch`, exactly as [`../amd-rocprofiler/gelu.py`](/aorta/examples/profiling/proton/amd-rocprofiler/gelu.py) does, and calls `proton.start()` from `main()` after torch, also as gelu.py does. The capture is unaffected by the import move: measured on gfx950, the hatchet is byte-identical (1648 bytes, 15 launches across the three kernels) on Triton 3.7.1 and 3.8.0. Importing Proton *after* torch is not merely suboptimal on 3.8.0 — it hangs the process forever at exit, after the capture has already been written. The constructor registers Proton as a rocprofiler-sdk client; when that lands after HSA is up, the atexit `rocprofiler::registration::finalize()` re-enters its own non-recursive registration mutex through Proton's `protonToolFini` and deadlocks. `import torch` followed by `import triton.profiler` is the whole reproducer. See [ROCm/aorta#434](https://github.com/ROCm/aorta/issues/434). See [Pinning an explicit AMD backend](../../../../docs/profiling-collectors.md#pinning-an-explicit-amd-backend) for both contracts side by side, including which one is measured and which is taken from upstream's source. ## Notes - **Is this the backend you want?** It gives whole-kernel spans. If you need attribution *inside* a kernel, or you are weighing the three AMD backends against each other, see [Choosing a Proton backend](/aorta/docs/proton-backends.html). - **Why `roctracer` and not `rocprofiler`.** `roctracer` is deprecated upstream in favour of the rocprofiler-sdk backend, and as of Triton 3.8.0 it is no longer the only whole-kernel AMD backend you can name on a release. It is still the one present in *every* release: 3.6.0, 3.7.0 and 3.7.1 offer `cupti` / `roctracer` / `instrumentation` and nothing else — of those `cupti` is NVIDIA's and `instrumentation` measures inside a kernel rather than timing it — while 3.8.0 adds `rocprofiler` alongside them. So this example is the one that runs on whatever image you have, and [`../amd-rocprofiler`](/aorta/examples/profiling/proton/amd-rocprofiler/) is the same idea on the newer backend; that one still needs a post-3.8 `main` build, for its `pcsampling` mode rather than for the backend. - **Device selection.** Proton on AMD reads `ROCR_VISIBLE_DEVICES` and *rejects* `HIP_VISIBLE_DEVICES` and `CUDA_VISIBLE_DEVICES` outright for `roctracer` — `proton.start()` raises `ValueError` before any kernel runs. aorta's collector translates the rejected spellings automatically and logs a warning; a standalone run has to unset them yourself. - **Do not stack this with `rocprof`.** `roctracer` installs an HSA queue interceptor and so does `rocprofv3`; the pairing is rejected at recipe load. Only [`../amd-instrumentation`](/aorta/examples/profiling/proton/amd-instrumentation/) coexists with `rocprof`. - **`Could not load libroctracer64.so`.** Some container images get ROCm from Python wheels, which ship only `libroctracer64.so.4` while Proton `dlopen`s the unversioned name. Add a directory of unversioned symlinks to `$LD_LIBRARY_PATH`, or use an image with a system ROCm install. This example pins `roctracer`, so that is the name you will see here; the sibling `rocprofiler` example (and `backend: auto` from Triton 3.8.0) fails on `librocprofiler-sdk.so` instead, with the same remedy. - Triton compiles on first launch. The unprofiled correctness pass absorbs that compile, which is why the capture's launch count is exact. ## Provenance The three kernels are original to this repository — not adapted from a Triton tutorial. They are deliberately ordinary shapes (elementwise, per-row activation, per-row reduction) so the tree reads like the front of a transformer block without needing one.