Files
aare/python/tests/perf/ladder.py
kferjaoui 4c0a093e9f
Build on RHEL8 / build (push) Successful in 3m18s
Build on RHEL9 / build (push) Successful in 4m3s
Run tests using data on local RHEL8 / build (push) Successful in 4m10s
docs: Performance study
2026-08-21 10:04:08 +02:00

320 lines
14 KiB
Python

"""The opt1 → opt8 ladder as a configuration matrix.
The point of this file is that the ladder is *data*, not code. Every step runs
through the same measure() function, so nothing can differ between two steps
except the fields in their STEP entry. Previous campaigns drifted precisely
because each step lived in its own notebook cell with its own warm-up policy.
Three classes cover eight steps:
ClusterFinderCUDAOpt2 opt1, opt2 frozen pre-refactor pipeline (88e0e8d)
ClusterFinderCUDAGraph opt5 graph-based
ClusterFinderCUDA opt3, opt4, opt7, opt8
opt3/opt4 are reachable on the *current* ClusterFinderCUDA because batch_chunk
disables the internal chunking that opt7 added: set it to the batch size and
find_clusters_batched degenerates to submit-everything-then-collect-everything.
This is why the old pipeline does not need to be kept alive in a second class.
opt6 is NOT a row here — it is the DEVICE_PED_TYPE build axis, so the whole
matrix is run once per build and the two result sets are compared.
"""
from __future__ import annotations
import gc
import sys
import time
from dataclasses import dataclass, field
from typing import Callable
import numpy as np
import common
from common import Row, faults, load_frames, image_size, train_pedestal
@dataclass(frozen=True)
class Step:
step: str
label: str
cls: str # "opt2cls" | "graph" | "cuda"
n_streams: int = 4
pinned: bool = True
batch_chunk: str = "auto" # "auto" | "off" | int
collection: str = "collect"
per_frame: bool = False # opt1: one find_clusters() call per frame
act: str = ""
status: str = "adopted"
# ClusterFinderCUDAOpt2 is registered for 3x3 only (cuda_bindings.cu), so
# the 9x9 ladder necessarily starts at opt3. Not a limitation of the
# measurement — the deck's arc is the 3x3 one.
cluster_dims: tuple[int, ...] = (3, 5, 7, 9)
# ---- the ladder ----------------------------------------------------------
STEPS: list[Step] = [
# ClusterFinderMT cannot restart after stop(), so this is ALWAYS a
# first-pass number carrying its own ~1.8 s of allocator faults (docs §3.4).
# measure() forces reps=1 for it. Every speedup quoted against it must say
# whether it uses the raw or the fault-corrected baseline.
Step("cpu", "ClusterFinderMT", "cpu", # thread count appended per size
n_streams=0, pinned=False, batch_chunk="n/a", collection="n/a",
act="baseline", status="reference"),
Step("opt1", "1 stream, one launch per frame", "opt2cls",
n_streams=1, pinned=False, collection="per-frame", per_frame=True,
act="I - getting frames to the GPU", cluster_dims=(3,)),
Step("opt2", "4 streams + host-side batching", "opt2cls",
n_streams=4, pinned=False, act="I - getting frames to the GPU",
cluster_dims=(3,)),
Step("opt3", "pipeline rework, no pinning", "cuda",
pinned=False, batch_chunk="off", act="I - getting frames to the GPU"),
Step("opt4", "+ pinned input (DMA H2D)", "cuda",
pinned=True, batch_chunk="off", act="I - getting frames to the GPU"),
Step("opt5", "CUDA Graphs", "graph",
pinned=True, act="I - getting frames to the GPU", status="rejected"),
Step("opt7", "host<->GPU overlap, chunked internally", "cuda",
pinned=True, batch_chunk="auto", act="III - getting results back"),
Step("opt8", "zero-copy collection (collect_view)", "cuda",
pinned=True, batch_chunk="auto", collection="collect_view",
act="III - getting results back"),
]
STEPS_BY_NAME = {s.step: s for s in STEPS}
# The CPU baseline's ClusterCollector must outlive the finder's worker threads
# but cannot be attached to it — pybind11 classes have no __dict__, and the
# AttributeError from trying leaves ClusterFinderMT half-built with 48 threads
# running, which aborts the process at destruction. Only one CPU step runs at a
# time, so a module-level holder is sufficient and explicit.
_cpu_sink = None
# Frames whose returned cluster count landed exactly ON the cap. The kernel bumps
# its counter for every detection but guards only the write
# (clusterfinder_kernel.cuh: `if (write_idx >= max_clusters) return;`) and the host
# then clamps n_found to the cap, so a truncated frame is indistinguishable from a
# frame that happened to contain exactly `cap` clusters -- and silently short.
# With the cap set well above the observed maximum, landing on it is effectively
# impossible by chance, so this counter is a sound truncation detector. It exists
# because a cap that was believed non-truncating silently discarded 0.0095 % of
# 9x9 clusters through an entire campaign.
_at_cap = 0
# Best ClusterFinderMT thread count per cluster size, swept 2026-08-19
# (results/2026-08-19_cpu_threads/). This is a 16-core / 32-thread 7950X: the
# campaign's original n_threads=48 oversubscribed it by 1.5x and understated the
# CPU by 24 % at 3x3 and 15 % at 9x9, inflating every speedup. The optima differ
# by size because ClusterCollector's drain is inside the timed region and 9x9
# clusters are 9x larger.
CPU_THREADS = {3: 24, 9: 32}
# ---- finder construction -------------------------------------------------
def build_finder(step: Step, cluster_dim: int, cap: int, batch_size: int):
"""A FRESH finder, trained on the same 1000 pedestal frames.
Never reuse one across steps: the device pedestal keeps evolving, so two
steps sharing a finder are not measuring the same thing.
"""
from aare import ClusterFinderCUDA, ClusterFinderCUDAGraph, ClusterFinderCUDAOpt2
dim = (cluster_dim, cluster_dim)
common_kw = dict(image_size=image_size(), cluster_size=dim, n_sigma=common.N_SIGMA,
max_clusters_per_frame=cap, n_streams=step.n_streams)
if step.cls == "cpu":
from aare import ClusterFinderMT, ClusterCollector
global _cpu_sink
cf = ClusterFinderMT(image_size(), dim, n_sigma=common.N_SIGMA,
capacity=cap,
n_threads=CPU_THREADS[cluster_dim])
_cpu_sink = ClusterCollector(cf)
train_pedestal(cf)
return cf
if step.cls == "opt2cls":
# time_kernels kept OFF for comparability with the ClusterFinderCUDA
# steps, which default to off. Without this, opt1/opt2 pay an event tax
# that opt3+ do not, inflating the opt2 -> opt3 step (docs §11).
cf = ClusterFinderCUDAOpt2(**common_kw, time_kernels=False)
elif step.cls == "graph":
cf = ClusterFinderCUDAGraph(**common_kw)
elif step.cls == "cuda":
cf = ClusterFinderCUDA(**common_kw, time_kernels=False)
else:
raise ValueError(step.cls)
train_pedestal(cf)
if step.cls == "cuda":
cf.batch_chunk = batch_size if step.batch_chunk == "off" else (
0 if step.batch_chunk == "auto" else int(step.batch_chunk))
return cf
# ---- the measured region -------------------------------------------------
def steps_for(cluster_dim: int) -> list[Step]:
"""The steps that can actually run at this cluster size."""
return [s for s in STEPS if cluster_dim in s.cluster_dims]
def measure(step: Step, cluster_dim: int, cap: int, n_frames: int,
batch_size: int, reps: int, retain: bool = False) -> list[Row]:
"""Run one ladder step `reps` times, returning one Row per rep.
Policy applied identically to every step:
- fresh finder, same pedestal
- input pinned only if the step says so (that IS opt4)
- output slots pre-pinned OUTSIDE the timer, without processing frames
- faults bracketed around the timed region only
Reps deliberately SHARE one finder, because the whole point of repeating is
to reach the heap plateau, and a new finder would reset it. Two consequences
to keep in mind when reading the CSV:
* quote the last rep, not the mean — earlier reps carry first-touch faults;
* n_clusters drifts by ~0.002 % between reps, because the device pedestal
advances by n_frames each pass. This is expected and is why steps are
compared against each other at the same rep index, never across reps.
"""
data = load_frames(n_frames)
rows: list[Row] = []
if step.cls == "cpu":
reps = 1 # stop() is terminal; a second pass is impossible
cf = build_finder(step, cluster_dim, cap, batch_size)
if step.pinned:
cf.register_input_buffer(data)
# Pre-pin the output slots. Allocation only: no transfer, no launch, and
# crucially no pedestal advance, so this cannot perturb the result. Sized to
# the largest batch this step will submit (docs §12.1).
if hasattr(cf, "reserve_output_slots"):
slot_frames = batch_size if step.batch_chunk == "off" else (
cf.chunk_size_for(min(n_frames, batch_size))
if hasattr(cf, "chunk_size_for") else batch_size)
cf.reserve_output_slots(slot_frames)
try:
rows = _reps(cf, step, cluster_dim, cap, n_frames, batch_size, reps,
retain, data)
finally:
# A finder that escapes here still owns worker threads (CPU) or CUDA
# streams, and destroying it implicitly aborts the process. Release it
# explicitly whatever happened.
if step.pinned:
try:
cf.unregister_input_buffer()
except Exception:
pass
del cf
gc.collect()
return rows
def _reps(cf, step: Step, cluster_dim: int, cap: int, n_frames: int,
batch_size: int, reps: int, retain: bool, data) -> list[Row]:
global _at_cap
rows: list[Row] = []
for rep in range(reps):
gc.collect()
_at_cap = 0
mf0, Mf0 = faults()
t0 = time.perf_counter()
n_clusters = _drive(cf, step, data, n_frames, batch_size, cap, retain)
wall = time.perf_counter() - t0
mf1, Mf1 = faults()
if _at_cap:
print(f" !! {step.step} {cluster_dim}x{cluster_dim} rep {rep}: "
f"{_at_cap} frame(s) returned exactly cap={cap} clusters — "
f"the cap is truncating and this row undercounts. Raise it.",
file=sys.stderr, flush=True)
# The CPU baseline's thread count varies by cluster size, so it goes in
# the label rather than being hardcoded: a row must say what it ran.
label = (f"{step.label}, {CPU_THREADS[cluster_dim]} threads"
if step.cls == "cpu" else step.label)
rows.append(Row(
step=step.step, label=label, cluster_dim=cluster_dim, cap=cap,
n_frames=n_frames, n_streams=step.n_streams, pinned=step.pinned,
batch_chunk=step.batch_chunk, collection=step.collection,
device_ped_type=common.device_ped_type(), rep=rep,
wall_s=wall, us_per_frame=wall * 1e6 / n_frames,
fps=n_frames / wall, minor_faults=mf1 - mf0, major_faults=Mf1 - Mf0,
n_clusters=n_clusters, clusters_per_frame=n_clusters / n_frames,
notes=" ".join(filter(None, [
step.status if step.status != "adopted" else "",
"retain" if retain else "",
f"TRUNCATED at_cap={_at_cap}" if _at_cap else "",
])),
))
return rows
def _drive(cf, step: Step, data, n_frames: int, batch_size: int, cap: int,
retain: bool = False) -> int:
"""The timed work. One branch per collection strategy, nothing else.
`retain=False` (default) counts each batch and lets it die, so peak result
memory is one batch. This is what makes N=100 000 possible at 9x9, where
retaining every ClusterVector would need 46.6 GB, and it is also the only
mode in which opt8 is comparable — a BatchView cannot be retained.
`retain=True` keeps everything, which is what the notebook does. It measures
the finder PLUS a growing result heap; at 9x9 that heap is re-faulted every
pass because it sits above glibc's mmap threshold (docs §12.2).
"""
global _at_cap
total = 0
kept: list = [] if retain else None
# The CPU finder's `cap` is a ClusterVector capacity, which grows on demand,
# so it cannot truncate and is not checked.
if step.cls == "cpu":
for i in range(n_frames):
cf.find_clusters(data[i])
cf.stop() # terminal — this is why the CPU step cannot be repped
_cpu_sink.stop()
for cv in _cpu_sink.steal_clusters():
total += cv.size
return total
if step.per_frame:
# opt1: no batching at all — one find_clusters() call per frame, and the
# result stolen out of the finder's internal vector each time. This is
# the fully synchronous path: H2D, kernel and D2H cannot overlap because
# there is only one frame in flight.
for i in range(n_frames):
cf.find_clusters(data[i], i)
cv = cf.steal_clusters()
total += cv.size
_at_cap += cv.size == cap
if retain:
kept.append(cv)
return total
if step.collection == "collect_view":
from aare import find_cluster_views_batched_iter
for start in range(0, n_frames, batch_size):
stop = min(start + batch_size, n_frames)
for v in find_cluster_views_batched_iter(
cf, data[start:stop], first_frame=start):
# No retain branch: a view is borrowed and released at the end of
# this iteration. That constraint IS opt8.
total += v.total_clusters
_at_cap += int(np.count_nonzero(np.asarray(v.counts) == cap))
return total
for start in range(0, n_frames, batch_size):
stop = min(start + batch_size, n_frames)
batch = cf.find_clusters_batched(data[start:stop], first_frame=start)
for cv in batch:
total += cv.size
_at_cap += cv.size == cap
if retain:
kept.extend(batch)
return total