Files
wehub-resource-sync 26446540fa
Lint / lint (push) Waiting to run
CI / MacOS (push) Waiting to run
CI / Windows (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:36:25 +08:00

311 lines
13 KiB
ReStructuredText

.. Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
.. http://www.apache.org/licenses/LICENSE-2.0
.. Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
In-kernel profiling with CudaProfiler
=====================================
Once a kernel is correct and you have seen how it compiles (see
:doc:`compiling`), the next question is usually *where the cycles go*. Host-side
timers and ``nsys`` tell you how long a launch took, but not how that time splits
across the regions *inside* one kernel — the TMA loads, the mainloop MMAs, the
softmax, the epilogue.
``tvm.tirx.bench.CudaProfiler`` is a lightweight, in-kernel event tracer for
exactly this. You bracket regions of device code with ``start`` / ``end``
markers; at runtime one leader thread per block stamps the GPU global timer into
a buffer you pass in as an ordinary kernel argument. After the launch you read
the buffer back and decode it into per-region durations or a Perfetto timeline.
It is *not* zero cost — every event is a ``%globaltimer`` read plus a global
store, and every thread in the region pays a block fence — so it is a
profiling/debugging tool, not something you leave on in production.
The kernel
----------
The kernel below brackets a ``load`` / ``compute`` / ``store`` sequence. The
``compute`` region runs a 4000-iteration FMA loop so it clearly dominates. Events
are a plain ``enum.Enum`` whose integer values start at 0 and index a names list.
.. code-block:: python
from enum import Enum
import numpy as np
import tvm
from tvm.script import tirx as T
from tvm.tirx.bench import CudaProfiler, export_to_perfetto_trace
NUM_BLOCKS, BLOCK, NUM_GROUPS = 4, 128, 1
WRITE_STRIDE = NUM_BLOCKS * NUM_GROUPS # >= number of (block, group) lanes
PROF_SIZE = 4096 # uint64 slots in the profiler buffer
N = NUM_BLOCKS * BLOCK
class Ev(Enum):
Load = 0
Compute = 1
Store = 2
EV_NAMES = ["load", "compute", "store"]
@T.prim_func
def profiled_kernel(out_ptr: T.handle, inp_ptr: T.handle, prof_ptr: T.handle):
out = T.match_buffer(out_ptr, (N,), "float32")
inp = T.match_buffer(inp_ptr, (N,), "float32")
prof = T.match_buffer(prof_ptr, (PROF_SIZE,), "uint64")
T.device_entry()
bid = T.cta_id([NUM_BLOCKS])
tid = T.thread_id([BLOCK])
idx = bid * BLOCK + tid
# Construct the profiler inside the kernel; only the leader thread writes.
p = CudaProfiler(prof, write_stride=WRITE_STRIDE, num_groups=NUM_GROUPS,
default_leader=(tid == 0))
p.init(0) # group_id = 0; also stamps the buffer header at slot 0
p.start(Ev.Load)
x: T.f32 = inp[idx]
p.end(Ev.Load)
p.start(Ev.Compute)
acc: T.f32 = T.float32(0)
for _ in range(4000):
acc = acc * T.float32(1.0001) + x
p.end(Ev.Compute)
p.start(Ev.Store)
out[idx] = acc
p.end(Ev.Store)
p.finalize() # mark this (block, group) lane done
Run it and read the trace
-------------------------
Allocate a zeroed ``uint64`` buffer, pass it as the last argument, then read it
back. Each record is one ``uint64``: the high 32 bits are the timestamp, the low
32 bits a packed tag, so decoding is plain bit-twiddling on the host.
.. code-block:: python
dev = tvm.cuda(0)
exe = tvm.compile(tvm.IRModule({"main": profiled_kernel}),
target=tvm.target.Target("cuda"), tir_pipeline="tirx")
inp = tvm.runtime.tensor(np.ones(N, "float32"), device=dev)
out = tvm.runtime.tensor(np.zeros(N, "float32"), device=dev)
prof = tvm.runtime.tensor(np.zeros(PROF_SIZE, "uint64"), device=dev)
exe(out, inp, prof)
dev.sync()
prof_np = prof.numpy()
opens, spans = {}, {}
for i in range(1, len(prof_np)):
word = int(prof_np[i])
if word == 0:
continue
ts, tag = word >> 32, word & 0xFFFFFFFF
block = (tag >> 12) // NUM_GROUPS
event_idx, event_type = (tag >> 2) & 0x3FF, tag & 0x3 # 0=start 1=end 2=instant 3=finalize
if event_type == 0:
opens[(block, event_idx)] = ts
elif event_type == 1:
spans.setdefault(block, []).append((EV_NAMES[event_idx], ts - opens[(block, event_idx)]))
for block in sorted(spans):
print(f"block {block}:", ", ".join(f"{n}={d}ns" for n, d in spans[block]))
export_to_perfetto_trace(prof_np, "cudaprofiler.perfetto-trace", EV_NAMES)
Durations are stable to within a few percent (they shift with GPU clocks)::
block 0: load=32ns, compute=8704ns, store=64ns
block 1: load=96ns, compute=8704ns, store=64ns
block 2: load=96ns, compute=8704ns, store=64ns
block 3: load=96ns, compute=8704ns, store=64ns
``export_to_perfetto_trace`` writes ``cudaprofiler.perfetto-trace`` from the same
records; drop it onto https://ui.perfetto.dev for an interactive timeline. Because
the timestamps come from the global ``%globaltimer`` (not a per-SM cycle counter),
events from different blocks share one time axis and are directly comparable.
On a real kernel
----------------
The same markers, sprinkled through a warp-specialized FlashAttention-4 kernel
(one ``group`` per warp-group via ``num_groups``), produce a per-warp-group
timeline of the whole pipeline:
.. figure:: https://raw.githubusercontent.com/tlc-pack/web-data/main/images/tirx/tirx_cudaprofiler_fa4.png
:align: center
:alt: FlashAttention-4 in-kernel timeline in Perfetto
One CTA of an FA4 forward kernel. ``group_0`` issues the TMA loads
(``issue-tma-*``), ``group_3`` / ``group_4`` run the softmax pipeline
(``softmax-max`` / ``-exp2`` / ``-sum``), and ``group_5`` runs the
``correction`` — the overlap between the producer and consumer warp-groups is
exactly what intra-kernel profiling is for.
The API
-------
Construct the profiler **inside** the kernel body and call four methods:
* ``init(group_id)`` — once per thread; ``group_id`` selects the sub-track and
stamps the buffer header at slot 0.
* ``start(event_type, leader=None)`` / ``end(event_type, leader=None)`` — open and
close a region. Every thread executes them, but only the leader stores a record.
* ``finalize(leader=None)`` — write a terminal record for this lane.
Constructor arguments:
* ``profiler_buffer`` — the ``uint64`` buffer you pass into the kernel.
* ``write_stride`` — how far each leader advances between writes. Must be ``>=``
the number of ``(block, group)`` lanes so per-lane streams never collide;
``NUM_BLOCKS * NUM_GROUPS`` is the tight value, a persistent-grid kernel uses
``num_sms * num_groups``.
* ``num_groups`` — independent sub-tracks per block. Use ``1`` for a plain kernel;
in a warp-specialized kernel give each warp-group its own ``group_id`` and
leader so their timelines don't mix.
* ``default_leader`` — the predicate for the one writing thread (override per call
with ``leader=``).
* ``profiler_enabled`` — pass ``False`` (or a false-y ``PrimExpr``) to turn every
method into a no-op, so you can leave the markers in and compile them out.
``CudaProfiler`` emits ``start`` / ``end`` / ``finalize``; ``instant`` (event type
2) is reserved in the wire format and understood by the decoder, but there is no
method that produces one.
Groups and granularity
----------------------
A block's threads are partitioned into ``num_groups`` logical *groups*, and the
trace's unit is one ``(block, group)`` lane — each becomes its own track. The
partition is yours: a group can be a warp-group, a single warp, or any set of
threads, and it does **not** have to align to a warp (the recording path has no
warp-collective op — just a predicated per-thread store and a block fence). Two
rules:
* a thread joins a group by calling ``init(group_id)``, which points *its* write
cursor at that group's lane;
* exactly one thread per group is the leader and actually writes — pick it with a
predicate that is true for one thread in the group, and it must be a thread that
called ``init`` for that group.
Because each leader has its own cursor, one ``start`` / ``end`` statement records
into *every* group at once: each leader stamps its own lane.
**Groups as warp-groups.** A 256-thread block is two warp-groups; give each its
own ``group_id`` and make its first thread the leader. Here the two warp-groups do
different amounts of compute, so their tracks have different durations:
.. code-block:: python
NUM_GROUPS = 2
p = CudaProfiler(prof, write_stride=NUM_BLOCKS * NUM_GROUPS, num_groups=NUM_GROUPS,
default_leader=(tid % 128 == 0)) # first thread of each warp-group
if tid < 128:
p.init(0)
else:
p.init(1)
# ... load ...
p.start(Ev.Compute)
if tid < 128:
for _ in range(1000): # warp-group 0: light
acc = acc * T.float32(1.0001) + x
else:
for _ in range(5000): # warp-group 1: heavy
acc = acc * T.float32(1.0001) + x
p.end(Ev.Compute)
::
block 0 group 0: load=96ns, compute=3040ns, store=64ns
block 0 group 1: load=96ns, compute=10816ns, store=64ns
block 1 group 0: load=96ns, compute=3072ns, store=64ns
block 1 group 1: load=128ns, compute=10784ns, store=64ns
**Groups that are not warp multiples.** A 128-thread block split 48 / 48 / 32
works the same way — the leaders are the base thread of each group, and the
48-thread groups (1.5 warps, crossing warp boundaries) each record a correct
track:
.. code-block:: python
NUM_GROUPS = 3 # groups [0, 48) [48, 96) [96, 128)
p = CudaProfiler(prof, write_stride=NUM_BLOCKS * NUM_GROUPS, num_groups=NUM_GROUPS,
default_leader=((tid == 0) | (tid == 48) | (tid == 96)))
if tid < 48:
p.init(0)
elif tid < 96:
p.init(1)
else:
p.init(2)
::
block 0 group 0: load=96ns, compute=4544ns, store=64ns # 48 threads (1.5 warps)
block 0 group 1: load=64ns, compute=4512ns, store=96ns # 48 threads, crosses warp lines
block 0 group 2: load=64ns, compute=4576ns, store=64ns # 32 threads
What each call wraps
--------------------
The methods are thin wrappers around the ``T.cuda.timer_*`` intrinsics, which
lower to small ``__device__`` helpers emitted into the generated CUDA. The
profiler keeps two per-thread ``"local"`` scratch slots — the running tag and
write cursor — and every record is written by:
.. code-block:: c++
// tvm_builtin_get_timestamp() == asm("mov.u32 %0, %globaltimer_lo;")
profiler_buffer[profiler_write_offset[0]] =
((uint64_t)tvm_builtin_get_timestamp() << 32) | (profiler_tag[0] | event_bits);
profiler_write_offset[0] += profiler_write_stride; // global store; only the leader runs this
``init`` computes ``BLOCK_GROUP_IDX = block_idx * num_groups + group_id``, writes
the header ``profiler_buffer[0] = ((uint64_t)num_groups << 32) | num_blocks`` from
block 0 / ``threadIdx.x == 0``, and seeds this lane's cursor to ``1 +
BLOCK_GROUP_IDX`` and tag to ``BLOCK_GROUP_IDX << 12``. ``start`` writes the record
(``event_bits = (event << 2) | 0``) then ``__threadfence_block()``; ``end`` fences
then writes (``| 1``); ``finalize`` fences then writes ``0x3``. The fence runs on
*every* thread in the region, only the store is leader-only — that fence is what
brackets the region's memory traffic, and why the markers perturb the kernel.
Usage notes and caveats
-----------------------
* **Zero the buffer before the launch.** The decoder treats ``0`` as "empty" and
reads the grid shape from slot 0, which only block 0 / thread 0 writes.
* **Exactly one leader per (block, group).** Each thread keeps its own cursor,
initialized to ``1 + block_group``; two leaders in the same lane write the same
offsets and clobber each other. Use ``tid == 0`` or lane 0 of the group's leader
warp.
* **Call ``init`` once, before any ``start``.** It seeds each thread's tag and
cursor; without it both are garbage.
* **Size ``write_stride`` and the buffer together.** The largest slot a lane
touches is ``1 + block_group + (records_per_lane - 1) * write_stride``;
over-allocate, unused slots stay ``0`` and are skipped.
* **``%globaltimer_lo`` is only the low 32 bits of the nanosecond timer.** It wraps
about every 4.29 s (``2**32`` ns), so a region straddling a wrap decodes to a
bogus duration. Resolution is coarse (tens of ns), so very short regions read 0
or a single tick.
* **No payload.** ``start`` / ``end`` record only a timestamp and the event id;
encode anything extra in the event id (a distinct ``Ev`` member) or in
``num_groups``.
* **It is not free.** Two stores plus two block fences per region. Profile, read
the numbers, then build with ``profiler_enabled=False``.