Skip to content

Commit 86d027a

Browse files
committed
cute/profiler: compact 1xi64 record format with 32-bit timer + anchor rebase
Adds a cheap recording path inspired by IKET's SASS shape: bits 63..56 55..32 31..0 tag(8) dur_ns(24) ts_lo32(32) Each event becomes one packed int64 store instead of four. The per-CTA anchor in slot 0 (full 64-bit globaltimer) lets the host decoder reconstruct full 64-bit timestamps from the low-32 timer readings while handling wraparound. Device side: - read_globaltimer_lo32(): mov.u32 $0, %globaltimer_lo; - compact_anchor_init(buf, unit_id, max_events): writes the 64-bit anchor to slot 0 (lane 0 / warp 0 only). Call once at top of kernel. - compact_event_stop(buf, unit_id, event_idx, start_ts32, tag, max_events): one packed STG.E.EF.64 per event, lane-0-only inner guard so it's safe in deeply nested control flow. Host side: - ProfileBuf gains compact: bool field; slice_size dispatches to 1 + max_events (vs the legacy 1 + 4*max_events). - allocate_profile_buffer/profile_session take compact=True. - decode_events dispatches; _decode_events_compact bit-unpacks the whole grid with torch ops and only iterates Python over surviving events. Hot-path SASS on Blackwell (sm_100): ~10 instructions and 8 B of global-memory traffic per event (vs ~12 instructions and 32 B for the legacy 4xi64 path).
1 parent 13679ae commit 86d027a

3 files changed

Lines changed: 185 additions & 19 deletions

File tree

transformer_nuggets/cute/profiler/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def my_kernel(output, prof_buf, max_events):
4141

4242
from transformer_nuggets.cute.profiler.ops import (
4343
read_globaltimer,
44+
read_globaltimer_lo32,
4445
static_start,
4546
static_stop,
4647
warp_atomic_alloc,
@@ -51,6 +52,8 @@ def my_kernel(output, prof_buf, max_events):
5152
region_end,
5253
RegionToken,
5354
raw_event_stop,
55+
compact_event_stop,
56+
compact_anchor_init,
5457
)
5558

5659
from transformer_nuggets.cute.profiler.postprocessors import (
@@ -82,10 +85,13 @@ def my_kernel(output, prof_buf, max_events):
8285
"warp_start",
8386
"warp_stop",
8487
"profile_region",
88+
"read_globaltimer_lo32",
8589
"region_start",
8690
"region_end",
8791
"RegionToken",
8892
"raw_event_stop",
93+
"compact_event_stop",
94+
"compact_anchor_init",
8995
# Post-processors
9096
"group_by_unit",
9197
"group_by_tag",

transformer_nuggets/cute/profiler/host.py

Lines changed: 85 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -101,16 +101,22 @@ class ProfileBuf:
101101
max_events_per_unit: Maximum number of events per profiling unit.
102102
num_units: Number of profiling units (e.g., blocks, warps).
103103
unit_name: Name for units in trace output (e.g., "Block", "Warp").
104+
compact: ``True`` if events use the 1xi64 packed format
105+
``[tag(8) | dur_ns(24) | ts_lo32(32)]`` with slot 0 holding a
106+
64-bit anchor; ``False`` for the legacy 4xi64 record format.
104107
"""
105108

106109
tensor: torch.Tensor
107110
max_events_per_unit: int
108111
num_units: int
109112
unit_name: str = "Unit"
113+
compact: bool = False
110114

111115
@property
112116
def slice_size(self) -> int:
113117
"""Size of each unit's buffer slice in int64 elements."""
118+
if self.compact:
119+
return 1 + self.max_events_per_unit
114120
return 1 + 4 * self.max_events_per_unit
115121

116122
def to_cute(self) -> cute.Tensor:
@@ -203,24 +209,26 @@ def allocate_profile_buffer(
203209
num_units: int | tuple[int, str],
204210
device: torch.device | str | None = None,
205211
stream: torch.cuda.Stream | None = None,
212+
compact: bool = False,
206213
) -> ProfileBuf:
207214
"""Allocate a profile buffer for intra-kernel profiling.
208215
209-
Uses static allocation: each profiling unit (block, warp, etc.) gets its own
210-
pre-allocated buffer slice. This eliminates atomics from the profiling path.
216+
Each profiling unit (block, warp, ...) owns a pre-allocated slice of the
217+
buffer; no atomics are needed on the recording path.
211218
212219
Args:
213220
max_events_per_unit: Maximum events each unit can record.
214-
num_units: Number of profiling units. Can be:
215-
- int: Just the count (uses "Unit" as name in traces)
216-
- tuple[int, str]: (count, name) for nicer trace labels (e.g., (4, "Block"))
221+
num_units: ``int`` (count, uses ``"Unit"`` as label) or
222+
``(count, name)`` for nicer trace labels (e.g. ``(4, "Block")``).
217223
device: Device to allocate on. Defaults to current CUDA device.
218-
stream: CUDA stream for allocation (optional).
224+
stream: Optional CUDA stream for allocation.
225+
compact: ``True`` selects the 1xi64 packed record format used by
226+
:func:`compact_event_stop` (slice size = ``1 + max_events``).
227+
``False`` keeps the legacy 4xi64 records (slice size =
228+
``1 + 4 * max_events``).
219229
220230
Returns:
221231
ProfileBuf with the allocated tensor.
222-
223-
Buffer size: num_units * (1 + 4 * max_events_per_unit) int64s.
224232
"""
225233
if isinstance(num_units, tuple):
226234
num_units_count, unit_name = num_units
@@ -233,7 +241,7 @@ def allocate_profile_buffer(
233241
elif isinstance(device, str):
234242
device = torch.device(device)
235243

236-
slice_size = 1 + 4 * max_events_per_unit
244+
slice_size = (1 + max_events_per_unit) if compact else (1 + 4 * max_events_per_unit)
237245
total_size = num_units_count * slice_size
238246

239247
if stream is not None:
@@ -247,6 +255,7 @@ def allocate_profile_buffer(
247255
max_events_per_unit=max_events_per_unit,
248256
num_units=num_units_count,
249257
unit_name=unit_name,
258+
compact=compact,
250259
)
251260

252261

@@ -257,20 +266,24 @@ def decode_events(
257266
) -> list[Event]:
258267
"""Decode profiling events from the buffer.
259268
260-
Scans all event slots in each unit's slice, skipping empty slots.
261-
This works with both atomic mode (counter auto-incremented) and
262-
static mode (explicit event indices).
269+
Dispatches on ``buf.compact``: legacy 4xi64 records or the 1xi64 packed
270+
format produced by :func:`compact_event_stop`. Empty slots are skipped.
263271
264272
Args:
265273
buf: Profile buffer (ProfileBuf).
266274
tag_table: TagTable for mapping tag IDs to names.
267-
tid_base: Base offset for thread IDs.
275+
tid_base: Base offset added to ``Event.tid``.
268276
269277
Returns:
270278
List of Event objects from all units.
271279
"""
272-
cpu_buf = buf.tensor.cpu().numpy()
280+
if buf.compact:
281+
return _decode_events_compact(buf, tag_table, tid_base)
282+
return _decode_events_legacy(buf, tag_table, tid_base)
283+
273284

285+
def _decode_events_legacy(buf: ProfileBuf, tag_table: TagTable, tid_base: int) -> list[Event]:
286+
cpu_buf = buf.tensor.cpu().numpy()
274287
slice_size = buf.slice_size
275288
events = []
276289

@@ -287,11 +300,9 @@ def decode_events(
287300
if start_ns == 0 and dur_ns == 0:
288301
continue
289302

290-
if 0 <= tag_id < len(tag_table):
291-
tag_name = tag_table.name(tag_id)
292-
else:
293-
tag_name = f"unknown_{tag_id}"
294-
303+
tag_name = (
304+
tag_table.name(tag_id) if 0 <= tag_id < len(tag_table) else f"unknown_{tag_id}"
305+
)
295306
events.append(
296307
Event(
297308
start_ns=start_ns,
@@ -306,6 +317,54 @@ def decode_events(
306317
return events
307318

308319

320+
def _decode_events_compact(buf: ProfileBuf, tag_table: TagTable, tid_base: int) -> list[Event]:
321+
"""Decode the 1xi64 packed records, reconstructing 64-bit ts from each unit's anchor.
322+
323+
Bit-unpacks every slot in one shot with torch, masks out empty / unitless
324+
rows, and only iterates Python over the surviving valid events.
325+
"""
326+
mask32 = (1 << 32) - 1
327+
mask24 = (1 << 24) - 1
328+
329+
grid = buf.tensor.cpu().to(torch.int64).view(buf.num_units, buf.slice_size)
330+
anchors = grid[:, 0]
331+
records = grid[:, 1:]
332+
333+
valid = (records != 0) & (anchors != 0).unsqueeze(1)
334+
if not valid.any():
335+
return []
336+
337+
ts_lo = records & mask32
338+
dur_ns = (records >> 32) & mask24
339+
tag_ids = (records >> 56) & 0xFF
340+
anchor_lo = anchors & mask32
341+
anchor_hi = (anchors >> 32) << 32
342+
wrap = (ts_lo < anchor_lo.unsqueeze(1)).to(torch.int64) << 32
343+
start_ns = anchor_hi.unsqueeze(1) + wrap + ts_lo
344+
345+
unit_idx, _ = torch.nonzero(valid, as_tuple=True)
346+
starts = start_ns[valid].tolist()
347+
durs = dur_ns[valid].tolist()
348+
tags = tag_ids[valid].tolist()
349+
units = unit_idx.tolist()
350+
351+
n_tags = len(tag_table)
352+
events = []
353+
for s, d, t, u in zip(starts, durs, tags, units):
354+
tag_name = tag_table.name(t) if 0 <= t < n_tags else f"unknown_{t}"
355+
events.append(
356+
Event(
357+
start_ns=s,
358+
dur_ns=d,
359+
tag_id=t,
360+
tag_name=tag_name,
361+
tid=u + tid_base,
362+
unit_id=u,
363+
)
364+
)
365+
return events
366+
367+
309368
def events_to_perfetto(
310369
events: list[Event],
311370
trace_path: str | None = None,
@@ -450,6 +509,7 @@ def profile_session(
450509
post_process_trace: Callable[[dict, PostProcessContext], dict] | None = None,
451510
split_overlaps: bool = True,
452511
trace_format: Literal["chrome_json", "track_event"] = "track_event",
512+
compact: bool = False,
453513
) -> Iterator[tuple[ProfileBuf, TagTable]]:
454514
"""Context manager for profiling a kernel session.
455515
@@ -499,6 +559,11 @@ def group_by_block_warp(events, ctx):
499559
Perfetto track into sibling lanes/tracks before writing the trace.
500560
trace_format: ``"track_event"`` writes native Perfetto ``.pftrace`` output;
501561
``"chrome_json"`` writes Chrome JSON/JSON.GZ output.
562+
compact: Use the 1xi64 packed record format (paired with
563+
``compact_event_stop`` / ``compact_anchor_init`` on the device
564+
side). Slot 0 of each unit's slice is the 64-bit anchor; the
565+
decoder reconstructs full timestamps from the anchor + each
566+
record's low-32-bit timer reading.
502567
503568
Yields:
504569
Tuple of (ProfileBuf, TagTable).
@@ -508,6 +573,7 @@ def group_by_block_warp(events, ctx):
508573
num_units=num_units,
509574
device=device,
510575
stream=stream,
576+
compact=compact,
511577
)
512578
tag_table = TagTable(tag_names)
513579

transformer_nuggets/cute/profiler/ops.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141

4242
__all__ = [
4343
"read_globaltimer",
44+
"read_globaltimer_lo32",
4445
"static_start",
4546
"static_stop",
4647
"warp_atomic_alloc",
@@ -51,6 +52,8 @@
5152
"region_start",
5253
"region_end",
5354
"raw_event_stop",
55+
"compact_event_stop",
56+
"compact_anchor_init",
5457
"ENABLE_PROFILING",
5558
"ENABLE_PROFILING_CONST",
5659
]
@@ -88,6 +91,71 @@ def raw_event_stop(
8891
_store_i64(base_ptr + byte_offset + Int64(24), Int64(tid))
8992

9093

94+
@cute.jit
95+
def compact_anchor_init(
96+
buf: cute.Tensor,
97+
unit_id: Int32,
98+
max_events_per_unit: Int32,
99+
) -> None:
100+
"""Write a full 64-bit globaltimer anchor to slot 0 of this unit's buffer.
101+
102+
Compact mode stores each event as a single packed int64 with only the low
103+
32 bits of the timer. The decoder reconstructs full 64-bit timestamps by
104+
combining each record's ts_lo32 with the upper 32 bits of this anchor (and
105+
handling wraparound). Call once per CTA at the top of ``@cute.kernel``,
106+
BEFORE any nested warp guards.
107+
108+
Only lane 0 of warp 0 performs the store.
109+
"""
110+
warp_idx = cute.arch.warp_idx()
111+
lane_idx = cute.arch.lane_idx()
112+
if warp_idx == 0 and lane_idx == 0:
113+
ts = read_globaltimer()
114+
base_ptr = buf.iterator.toint()
115+
slice_size = Int64(1 + max_events_per_unit)
116+
byte_offset = Int64(unit_id) * slice_size * Int64(8)
117+
_store_i64(base_ptr + byte_offset, ts)
118+
119+
120+
@cute.jit
121+
def compact_event_stop(
122+
buf: cute.Tensor,
123+
unit_id: Int32,
124+
event_idx: Int32,
125+
start_ts32: Int32,
126+
tag: Int32,
127+
max_events_per_unit: Int32,
128+
) -> None:
129+
"""Record one event in compact format: single packed int64 store, no warp guard.
130+
131+
Pair with :func:`read_globaltimer_lo32` for the start timestamp. The
132+
packed record layout is::
133+
134+
bits 63..56 55..32 31..0
135+
tag(8) dur_ns(24) ts_lo32(32)
136+
137+
``dur_ns`` saturates at ~16.7 ms (2^24 ns); regions longer than that
138+
overflow silently — use the legacy 4xi64 path (:func:`raw_event_stop`) for
139+
coarse outer regions if needed. ``tag`` is masked to 8 bits.
140+
141+
Only lane 0 of the calling warp performs the store; the caller is
142+
responsible for any outer warp guard. The inner ``if lane_idx == 0:``
143+
yields no value, so this primitive is safe in deeply nested control flow.
144+
"""
145+
lane_idx = cute.arch.lane_idx()
146+
end_ts32 = read_globaltimer_lo32()
147+
if lane_idx == 0:
148+
dur32 = end_ts32 - start_ts32
149+
ts_u = Int64(start_ts32) & Int64(0xFFFFFFFF)
150+
dur_u = Int64(dur32) & Int64(0xFFFFFF)
151+
tag_u = Int64(tag) & Int64(0xFF)
152+
packed = ts_u | (dur_u << 32) | (tag_u << 56)
153+
base_ptr = buf.iterator.toint()
154+
slice_size = Int64(1 + max_events_per_unit)
155+
byte_offset = (Int64(unit_id) * slice_size + Int64(1 + event_idx)) * Int64(8)
156+
_store_i64(base_ptr + byte_offset, packed)
157+
158+
91159
class RegionToken(NamedTuple):
92160
"""Pairing token returned by :func:`region_start` and consumed by :func:`region_end`.
93161
@@ -148,6 +216,32 @@ def read_globaltimer(*, loc=None, ip=None) -> Int64:
148216
return Int64(result)
149217

150218

219+
@dsl_user_op
220+
def read_globaltimer_lo32(*, loc=None, ip=None) -> Int32:
221+
"""Read the low 32 bits of ``%globaltimer`` (PTX ``mov.u32 ... %globaltimer_lo;``).
222+
223+
Lowers to a single ``CS2R.32 Rn, SR_GLOBALTIMERLO`` on Blackwell. One-third
224+
the register pressure of :func:`read_globaltimer` and avoids the 64-bit
225+
packing overhead, at the cost of ~4 s wraparound. The host decoder rebases
226+
against a per-unit 64-bit anchor (see :func:`compact_anchor_init`).
227+
228+
Returns:
229+
Int32: Low 32 bits of the current global timer in nanoseconds.
230+
"""
231+
result = llvm.inline_asm(
232+
T.i32(),
233+
[],
234+
"mov.u32 $0, %globaltimer_lo;",
235+
"=r",
236+
has_side_effects=True,
237+
is_align_stack=False,
238+
asm_dialect=llvm.AsmDialect.AD_ATT,
239+
loc=loc,
240+
ip=ip,
241+
)
242+
return Int32(result)
243+
244+
151245
@dsl_user_op
152246
def _store_i64(ptr: Int64, val: Int64, *, loc=None, ip=None) -> None:
153247
"""Store an int64 value to a global memory address using cache-streaming store."""

0 commit comments

Comments
 (0)