mirror of
https://github.com/slsdetectorgroup/aare.git
synced 2026-09-04 00:00:41 +02:00
docs: Performance study
This commit is contained in:
@@ -69,7 +69,8 @@ def _cuda_available():
|
||||
|
||||
|
||||
def ClusterFinderCUDA(image_size, cluster_size=(3,3), n_sigma=5, dtype=np.int32,
|
||||
max_clusters_per_frame=2048, n_streams=4):
|
||||
max_clusters_per_frame=2048, n_streams=4,
|
||||
time_kernels=False):
|
||||
"""
|
||||
Factory function to create a ClusterFinderCUDA object. Provides a cleaner
|
||||
syntax for the templated ClusterFinderCUDA in C++. API mirrors
|
||||
@@ -91,6 +92,14 @@ def ClusterFinderCUDA(image_size, cluster_size=(3,3), n_sigma=5, dtype=np.int32,
|
||||
but as tight as possible to minimize PCIe traffic. Default 2048.
|
||||
n_streams : int, optional
|
||||
Number of CUDA streams for H2D/kernel/D2H pipelining. Default 4.
|
||||
time_kernels : bool, optional
|
||||
Enable per-frame CUDA-event kernel timing, exposed via
|
||||
avg_kernel_time_ms(). Off by default because it adds two event records
|
||||
per frame to the streams plus a host-side query per frame, and the
|
||||
number it yields is only meaningful at n_streams=1 — under multi-stream
|
||||
contention the events measure queue wait, not execution, and over-read
|
||||
by up to ~3.5x. Use Nsight Systems for exclusive kernel times. When
|
||||
disabled, avg_kernel_time_ms() returns NaN.
|
||||
|
||||
Example
|
||||
-------
|
||||
@@ -123,7 +132,71 @@ def ClusterFinderCUDA(image_size, cluster_size=(3,3), n_sigma=5, dtype=np.int32,
|
||||
return cls(image_size,
|
||||
n_sigma=n_sigma,
|
||||
max_clusters_per_frame=max_clusters_per_frame,
|
||||
n_streams=n_streams)
|
||||
n_streams=n_streams,
|
||||
time_kernels=time_kernels)
|
||||
|
||||
def find_cluster_views_batched_iter(cf, frames, first_frame=0, chunk=None):
|
||||
"""
|
||||
Drive a ClusterFinderCUDA over `frames`, yielding zero-copy BatchViews.
|
||||
|
||||
Same pipelining as cf.find_clusters_batched() — chunk i+1 is submitted
|
||||
before chunk i is collected — but nothing is copied out of the pinned D2H
|
||||
buffer, so the host cost per frame collapses to reading one counter. At 9x9
|
||||
that removes ~467 kB of copying per frame.
|
||||
|
||||
Each view is released as soon as the loop body finishes, which is what makes
|
||||
the next submit legal (the finder has only two slots). Consequently:
|
||||
|
||||
**Anything you need after the loop body must be copied out.** Reductions
|
||||
(`v.sums()`) return owned numpy arrays and are safe; `v.frame_data(i)` and
|
||||
`v.frame_xy(i)` are views and are not.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cf : ClusterFinderCUDA
|
||||
frames : ndarray (n_frames, nrows, ncols), uint16
|
||||
Pin it first with cf.register_input_buffer(frames) for DMA-speed H2D.
|
||||
first_frame : int
|
||||
Frame number of frames[0].
|
||||
chunk : int, optional
|
||||
Frames per chunk; defaults to cf.chunk_size_for(len(frames)).
|
||||
|
||||
Yields
|
||||
------
|
||||
BatchView
|
||||
Valid only until the next iteration.
|
||||
|
||||
Example
|
||||
-------
|
||||
.. code-block:: python
|
||||
|
||||
cf.register_input_buffer(data)
|
||||
for v in find_cluster_views_batched_iter(cf, data):
|
||||
hist.fill(v.sums()) # reduced in C++, nothing materialised
|
||||
cf.unregister_input_buffer()
|
||||
"""
|
||||
n = frames.shape[0]
|
||||
if n == 0:
|
||||
return
|
||||
c = chunk or cf.chunk_size_for(n)
|
||||
bounds = [(s, min(s + c, n)) for s in range(0, n, c)]
|
||||
|
||||
tok = cf.submit_batch(frames[bounds[0][0]:bounds[0][1]],
|
||||
first_frame=first_frame + bounds[0][0])
|
||||
for a, b in bounds[1:]:
|
||||
nxt = cf.submit_batch(frames[a:b], first_frame=first_frame + a)
|
||||
view = cf.collect_view(tok)
|
||||
try:
|
||||
yield view
|
||||
finally:
|
||||
view.release() # frees the slot for the submit after next
|
||||
tok = nxt
|
||||
view = cf.collect_view(tok)
|
||||
try:
|
||||
yield view
|
||||
finally:
|
||||
view.release()
|
||||
|
||||
|
||||
def ClusterFinderCUDAGraph(image_size, cluster_size=(3,3), n_sigma=5, dtype=np.int32,
|
||||
max_clusters_per_frame=2048, n_streams=4):
|
||||
@@ -167,7 +240,8 @@ def ClusterFinderCUDAGraph(image_size, cluster_size=(3,3), n_sigma=5, dtype=np.i
|
||||
|
||||
|
||||
def ClusterFinderCUDAOpt2(image_size, cluster_size=(3, 3), n_sigma=5, dtype=np.int32,
|
||||
max_clusters_per_frame=3000, n_streams=4):
|
||||
max_clusters_per_frame=3000, n_streams=4,
|
||||
time_kernels=False):
|
||||
"""
|
||||
Factory for the OPT2 snapshot finder — the pre-refactor pipeline (per-frame
|
||||
pinned staging, round-robin streams with sync barriers, variable-length D2H),
|
||||
@@ -179,6 +253,11 @@ def ClusterFinderCUDAOpt2(image_size, cluster_size=(3, 3), n_sigma=5, dtype=np.i
|
||||
the opt arc; only the pipeline differs).
|
||||
|
||||
Only the 3x3 cluster size is registered.
|
||||
|
||||
time_kernels defaults to False, matching ClusterFinderCUDA. Leave it off for
|
||||
any throughput comparison: with events on here and off there, opt1/opt2 pay
|
||||
a per-frame tax that opt3+ do not, which inflates the opt2 -> opt3 step by
|
||||
exactly that tax. Kernel times come from nsys.
|
||||
"""
|
||||
if not _cuda_available():
|
||||
raise RuntimeError(
|
||||
@@ -190,7 +269,8 @@ def ClusterFinderCUDAOpt2(image_size, cluster_size=(3, 3), n_sigma=5, dtype=np.i
|
||||
return cls(image_size,
|
||||
n_sigma=n_sigma,
|
||||
max_clusters_per_frame=max_clusters_per_frame,
|
||||
n_streams=n_streams)
|
||||
n_streams=n_streams,
|
||||
time_kernels=time_kernels)
|
||||
|
||||
|
||||
def ClusterCollector(clusterfindermt, dtype=np.int32):
|
||||
|
||||
@@ -32,7 +32,7 @@ from ._aare import corner
|
||||
|
||||
from ._version import __version__
|
||||
from .ClusterFinder import ClusterFinder, ClusterFinderFrozen, ClusterCollector, ClusterFinderMT, ClusterFileSink, ClusterFile
|
||||
from .ClusterFinder import ClusterFinderCUDA, ClusterFinderCUDAGraph, ClusterFinderCUDAOpt2, _cuda_available
|
||||
from .ClusterFinder import ClusterFinderCUDA, ClusterFinderCUDAGraph, ClusterFinderCUDAOpt2, _cuda_available, find_cluster_views_batched_iter
|
||||
from .ClusterVector import ClusterVector
|
||||
from .Cluster import Cluster
|
||||
|
||||
|
||||
@@ -35,10 +35,84 @@ void define_ClusterFinderCUDA(py::module &m, const std::string &typestr) {
|
||||
py::class_<typename CF::BatchToken>(m,
|
||||
(class_name + "_BatchToken").c_str());
|
||||
|
||||
using VT = typename ClusterType::value_type;
|
||||
constexpr size_t NPIX =
|
||||
static_cast<size_t>(ClusterSizeX) * static_cast<size_t>(ClusterSizeY);
|
||||
|
||||
// Zero-copy view into the finder's pinned D2H buffer.
|
||||
py::class_<typename CF::BatchView>(m, (class_name + "_BatchView").c_str())
|
||||
.def_property_readonly("n_frames", &CF::BatchView::n_frames)
|
||||
.def_property_readonly("first_frame", &CF::BatchView::first_frame)
|
||||
.def_property_readonly("valid", &CF::BatchView::valid)
|
||||
.def_property_readonly("total_clusters", &CF::BatchView::total_clusters)
|
||||
.def("count", &CF::BatchView::count, py::arg("frame_index"))
|
||||
.def("release", &CF::BatchView::release,
|
||||
R"(Give the slot back to the finder. The view is unusable
|
||||
afterwards and submit_batch() may reuse the buffer. Called
|
||||
automatically on destruction and on __exit__.)")
|
||||
.def("__enter__", [](py::object self) { return self; })
|
||||
.def("__exit__", [](typename CF::BatchView &v, py::object, py::object,
|
||||
py::object) { v.release(); })
|
||||
.def_property_readonly(
|
||||
"counts",
|
||||
[](const typename CF::BatchView &v) {
|
||||
py::array_t<uint32_t> out(v.n_frames());
|
||||
auto *o = out.mutable_data();
|
||||
for (size_t i = 0; i < v.n_frames(); ++i)
|
||||
o[i] = v.count(i);
|
||||
return out;
|
||||
},
|
||||
R"(Clusters found per frame, as a numpy array.)")
|
||||
.def(
|
||||
"sums",
|
||||
[](const typename CF::BatchView &v) {
|
||||
std::vector<VT> s;
|
||||
{
|
||||
// Reduce without the GIL, but take it back before building
|
||||
// the array — constructing a Python object without it is a
|
||||
// segfault, so this cannot be a call_guard.
|
||||
py::gil_scoped_release nogil;
|
||||
s = v.sums();
|
||||
}
|
||||
return py::array_t<VT>(s.size(), s.data());
|
||||
},
|
||||
R"(Per-cluster sums for the whole batch, reduced in C++ straight out
|
||||
of the pinned buffer — the clusters are never materialised on the
|
||||
host. This is the fast path for spectra/histograms.)")
|
||||
.def(
|
||||
"frame_data",
|
||||
[](py::object self, size_t i) {
|
||||
auto &v = self.cast<typename CF::BatchView &>();
|
||||
const uint32_t n = v.count(i);
|
||||
// Zero-copy (n, NPIX) view; stride skips each cluster's x/y.
|
||||
return py::array_t<VT>(
|
||||
{static_cast<size_t>(n), NPIX},
|
||||
{sizeof(ClusterType), sizeof(VT)},
|
||||
n == 0 ? nullptr : v.clusters(i)->data.data(), self);
|
||||
},
|
||||
py::arg("frame_index"),
|
||||
R"(Zero-copy (n_clusters, ClusterSizeX*ClusterSizeY) view of one
|
||||
frame's pixel data, straight out of the pinned buffer. Valid until
|
||||
this view is released — copy it if you need to keep it.)")
|
||||
.def(
|
||||
"frame_xy",
|
||||
[](py::object self, size_t i) {
|
||||
auto &v = self.cast<typename CF::BatchView &>();
|
||||
const uint32_t n = v.count(i);
|
||||
return py::array_t<CoordType>(
|
||||
{static_cast<size_t>(n), size_t{2}},
|
||||
{sizeof(ClusterType), sizeof(CoordType)},
|
||||
n == 0 ? nullptr : &v.clusters(i)->x, self);
|
||||
},
|
||||
py::arg("frame_index"),
|
||||
R"(Zero-copy (n_clusters, 2) view of one frame's cluster centre
|
||||
coordinates as (x, y).)");
|
||||
|
||||
py::class_<CF>(m, class_name.c_str())
|
||||
.def(py::init<Shape<2>, float, size_t, int>(), py::arg("image_size"),
|
||||
py::arg("n_sigma") = 5.0f,
|
||||
py::arg("max_clusters_per_frame") = 2048, py::arg("n_streams") = 4)
|
||||
.def(py::init<Shape<2>, float, size_t, int, bool>(),
|
||||
py::arg("image_size"), py::arg("n_sigma") = 5.0f,
|
||||
py::arg("max_clusters_per_frame") = 2048, py::arg("n_streams") = 4,
|
||||
py::arg("time_kernels") = false)
|
||||
|
||||
.def_property(
|
||||
"nSigma", &CF::get_nSigma, &CF::set_nSigma,
|
||||
@@ -154,11 +228,77 @@ sqrt(max(sum2/n - mean^2, 0)). Counterpart to `noise` for the device pedestal.)"
|
||||
py::arg("token"), py::call_guard<py::gil_scoped_release>(),
|
||||
R"(Wait for a previously submitted batch and return its results as
|
||||
a list of ClusterVector, one per input frame. Releases the batch
|
||||
slot so it can be reused by the next submit_batch() call.)")
|
||||
slot so it can be reused by the next submit_batch() call.
|
||||
|
||||
One allocation and one copy per frame; see collect_view() for
|
||||
neither.)")
|
||||
|
||||
.def(
|
||||
"collect_view",
|
||||
[](CF &self, typename CF::BatchToken token) {
|
||||
return self.collect_view(token);
|
||||
},
|
||||
py::arg("token"), py::call_guard<py::gil_scoped_release>(),
|
||||
R"(Like collect(), but copies nothing: returns a BatchView onto the
|
||||
finder's pinned D2H buffer.
|
||||
|
||||
The slot stays reserved until the view is released, so with 2 slots
|
||||
you must finish with it before submitting two more batches. Use it
|
||||
as a context manager, or call release():
|
||||
|
||||
tok = cf.submit_batch(frames[a:b], first_frame=a)
|
||||
with cf.collect_view(tok) as v:
|
||||
hist.fill(v.sums()) # reduced in C++, nothing copied
|
||||
|
||||
Anything you want to keep past release() must be copied out.)")
|
||||
|
||||
.def("avg_kernel_time_ms", &CF::avg_kernel_time_ms,
|
||||
R"(Average kernel execution time per frame in milliseconds,
|
||||
excluding PCIe transfers.)")
|
||||
R"(Average per-frame kernel time in ms, or NaN if the finder was
|
||||
constructed with time_kernels=False (the default).
|
||||
|
||||
WARNING: only meaningful with n_streams=1. The CUDA events bracket
|
||||
the kernel on its own stream, so under multi-stream contention the
|
||||
interval includes time spent queued behind other streams' kernels
|
||||
and over-reads by up to ~3.5x. Use Nsight Systems for exclusive
|
||||
kernel times.)")
|
||||
|
||||
.def("kernel_timing_enabled", &CF::kernel_timing_enabled,
|
||||
R"(True if per-frame kernel timing was enabled at construction.)")
|
||||
|
||||
.def("chunk_size_for", &CF::chunk_size_for, py::arg("n_frames"),
|
||||
R"(The chunk size find_clusters_batched() would use for n_frames.
|
||||
Use it to match its pipelining when driving submit_batch/
|
||||
collect_view by hand.)")
|
||||
|
||||
.def("reserve_output_slots", &CF::reserve_output_slots,
|
||||
py::arg("n_frames"), py::call_guard<py::gil_scoped_release>(),
|
||||
R"(Pre-allocate both pinned output slots for batches of n_frames.
|
||||
|
||||
Processes nothing: no transfer, no kernel, and the pedestal is NOT
|
||||
advanced. Only the two cudaMallocHost calls that the first
|
||||
submit_batch() would make happen here.
|
||||
|
||||
Page-locking runs about 1.0 us per 4 kB page (~66 ms for two
|
||||
128 MiB slots), and that cost is charged to whoever triggers it.
|
||||
Call this before starting a timer so it does not land inside the
|
||||
measurement. Slots only grow, so pass the largest batch you intend
|
||||
to use:
|
||||
|
||||
cf.reserve_output_slots(cf.chunk_size_for(len(data)))
|
||||
)")
|
||||
|
||||
.def_property(
|
||||
"batch_chunk", &CF::get_batch_chunk, &CF::set_batch_chunk,
|
||||
R"(Frames per internally pipelined chunk in find_clusters_batched().
|
||||
|
||||
0 (default) = auto: the batch is split into ~8 chunks so the host
|
||||
marshals one chunk while the GPU runs the next. Rounded up to a
|
||||
multiple of n_streams, which keeps the frame->stream assignment —
|
||||
and therefore the per-stream device pedestal each frame sees —
|
||||
identical to processing the batch in one go.
|
||||
|
||||
Set equal to the batch size to disable chunking and recover the old
|
||||
submit-everything-then-copy-everything behaviour.)")
|
||||
|
||||
.def("reset_timers", &CF::reset_timers,
|
||||
R"(Reset the internal kernel timing counters.)")
|
||||
|
||||
@@ -91,8 +91,12 @@ void define_ClusterFinderCUDAGraph(py::module &m, const std::string &typestr) {
|
||||
|
||||
.def(
|
||||
"avg_kernel_time_ms", &CF::avg_kernel_time_ms,
|
||||
R"(Always returns 0.0 — graph version does not instrument individual kernel time.
|
||||
Use wall-clock timing around find_clusters_batched instead.)")
|
||||
R"(Always NaN — the graph version replays the whole H2D->kernel->D2H
|
||||
DAG as one unit and does not instrument individual kernels. Use
|
||||
Nsight Systems, or wall-clock timing around find_clusters_batched.)")
|
||||
|
||||
.def("kernel_timing_enabled", &CF::kernel_timing_enabled,
|
||||
R"(Always False for this variant.)")
|
||||
|
||||
.def("reset_timers", &CF::reset_timers)
|
||||
|
||||
|
||||
@@ -36,9 +36,10 @@ void define_ClusterFinderCUDAOpt2(py::module &m, const std::string &typestr) {
|
||||
py::class_<CF>(m, class_name.c_str())
|
||||
// ctor: (image_size, n_sigma, capacity, n_streams) — capacity is the
|
||||
// per-stream device cluster buffer (upper bound on clusters/frame).
|
||||
.def(py::init<Shape<2>, float, size_t, int>(), py::arg("image_size"),
|
||||
py::arg("n_sigma") = 5.0f,
|
||||
py::arg("max_clusters_per_frame") = 3000, py::arg("n_streams") = 4)
|
||||
.def(py::init<Shape<2>, float, size_t, int, bool>(),
|
||||
py::arg("image_size"), py::arg("n_sigma") = 5.0f,
|
||||
py::arg("max_clusters_per_frame") = 3000, py::arg("n_streams") = 4,
|
||||
py::arg("time_kernels") = false)
|
||||
|
||||
.def_property(
|
||||
"nSigma", &CF::get_nSigma, &CF::set_nSigma,
|
||||
@@ -93,7 +94,12 @@ void define_ClusterFinderCUDAOpt2(py::module &m, const std::string &typestr) {
|
||||
R"(Process a 3D array (n_frames, nrows, ncols) round-robin across
|
||||
n_streams. Returns a list of ClusterVector, one per input frame.)")
|
||||
|
||||
.def("avg_kernel_time_ms", &CF::avg_kernel_time_ms)
|
||||
.def("avg_kernel_time_ms", &CF::avg_kernel_time_ms,
|
||||
R"(Mean per-frame kernel time in ms, or NaN if time_kernels was
|
||||
not enabled at construction. Inflated by queue-wait under
|
||||
multi-stream load — use nsys for the true value.)")
|
||||
.def("kernel_timing_enabled", &CF::kernel_timing_enabled,
|
||||
R"(True if per-frame kernel timing was enabled at construction.)")
|
||||
.def("reset_timers", &CF::reset_timers);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,32 +0,0 @@
|
||||
# Minimal probe for nsys: train pedestal, run one batched pass, print summary.
|
||||
# Usage: nsys_kernel_probe.py [n_streams] [n_frames]
|
||||
import sys
|
||||
sys.path.append('/home/ferjao_k/aare/build')
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
from aare import File, ClusterFinderCUDA
|
||||
|
||||
n_streams = int(sys.argv[1]) if len(sys.argv) > 1 else 8
|
||||
N = int(sys.argv[2]) if len(sys.argv) > 2 else 2000
|
||||
|
||||
base = Path('/mnt/sls_det_storage/moench_data/2603_MaxIVBeamtime/2026032408/process/xrf/')
|
||||
f = File(base / 'Cu_factor_10_data_master_0.json')
|
||||
pd = File(base / 'Cu_factor_10_pedestal_master_0.json')
|
||||
|
||||
cf = ClusterFinderCUDA((f.rows, f.cols), (3, 3), n_sigma=5,
|
||||
max_clusters_per_frame=3000, n_streams=n_streams)
|
||||
for _ in range(1000):
|
||||
cf.push_pedestal_frame(pd.read_frame().copy())
|
||||
|
||||
data = f.read_n(N)
|
||||
cf.register_input_buffer(data)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
res = cf.find_clusters_batched(data, first_frame=0)
|
||||
t = time.perf_counter() - t0
|
||||
|
||||
cf.unregister_input_buffer()
|
||||
n = sum(cv.size for cv in res)
|
||||
print(f'n_streams={n_streams} N={N} wall={t:.3f}s ({N/t:.0f} FPS) '
|
||||
f'clusters/frame={n/N:.2f} event kernel_ms={cf.avg_kernel_time_ms():.3f}')
|
||||
@@ -0,0 +1,173 @@
|
||||
# ClusterFinderCUDA measurement campaign
|
||||
|
||||
Everything that produces a number in
|
||||
[`docs/ClusterFinderCUDA_benchmark_results.md`](../../../docs/ClusterFinderCUDA_benchmark_results.md)
|
||||
lives here, and everything it produces lands in `results/<date>_<build>/` with a
|
||||
manifest, so any figure in the report or the deck can be traced back to the run
|
||||
and the build that made it.
|
||||
|
||||
## Scripts
|
||||
|
||||
| script | produces | answers |
|
||||
|---|---|---|
|
||||
| `run_campaign.sh` | both arms | the whole thing: f32 ladder+probes → rebuild → f64 ladder+probes → rebuild back |
|
||||
| `run_ladder.py` | `ladder_<dim>x<dim>.csv` | end-to-end wall / FPS / faults / counts for every step |
|
||||
| `run_probes.py` | `probes.csv` + `.nsys-rep`/`.sqlite` | per-engine GPU times, duty cycles, **the rooflines** |
|
||||
| `ladder.py` | — | the ladder *as a config matrix*; imported, not run |
|
||||
| `common.py` | — | dataset, pedestal, fault bracketing, env capture, CSV format |
|
||||
| `nsys_kernel_probe.py` | — | the workload `run_probes.py` traces; runnable alone under nsys |
|
||||
| `gpu_span.py` | a duty-cycle table | *engine busy* vs *engine idle*; `analyze()` is importable |
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
./run_campaign.sh # both arms, ~1.5 h, unattended
|
||||
./run_campaign.sh f32 # one arm
|
||||
|
||||
python run_ladder.py --dry-run # 2000 frames, 1 rep — proves it executes
|
||||
python run_ladder.py # 3x3 @ 100k, 9x9 @ 20k, 5 reps
|
||||
python run_ladder.py --dims 9 --steps opt7 opt8 # harness labels — see the map below
|
||||
python run_ladder.py --retain # keep every ClusterVector (notebook behaviour)
|
||||
|
||||
python run_probes.py # 4 configs at 20k frames
|
||||
python run_probes.py --only 9x9_s4
|
||||
python gpu_span.py <rep>.sqlite 20000 # re-read duty cycles from an existing profile
|
||||
|
||||
# per-operation durations from a committed profile — no GPU, no rerun.
|
||||
# Pass the .sqlite: the reps are newer than their exports, so the .nsys-rep path
|
||||
# demands --force-export=true and rewrites the export gpu_span.py reads.
|
||||
nsys stats --report cuda_gpu_kern_sum --report cuda_gpu_mem_time_sum \
|
||||
--format table results/2026-08-18_f64/probe_9x9_s1_uncontended.sqlite
|
||||
```
|
||||
|
||||
`nsys stats` gives durations, not duty cycles — it cannot tell "engine busy" from
|
||||
"engine idle waiting". Use it for *how long is one kernel* (`s1`); use `gpu_span.py`
|
||||
for overlap, duty and the roofline.
|
||||
|
||||
**The GPU must be idle.** Both drivers abort above 5 % utilisation: a competing
|
||||
process leaves per-operation averages intact while destroying the duty cycle and
|
||||
the wall clock, so the failure is silent if you don't check. Close any notebook
|
||||
first. `--allow-busy-gpu` exists but the numbers are not quotable.
|
||||
|
||||
## Step labels
|
||||
|
||||
The `--steps` flag and the `step` column of every CSV use the harness's internal labels,
|
||||
which predate the report's numbering. The report carries the same map in its §15.
|
||||
|
||||
| harness label | step in the report | act |
|
||||
|---|---|---|
|
||||
| `cpu` | baseline | — |
|
||||
| `opt1` `opt2` `opt3` `opt4` | opt1 opt2 opt3 opt4 | **I** — feeding the GPU |
|
||||
| `opt5` | **route A** — CUDA Graphs, *rejected* | (fork after opt4) |
|
||||
| `opt7` | **opt5** — chunked host↔GPU overlap | **II** — getting results back |
|
||||
| `opt8` | **opt6** — zero-copy `collect_view()` | **II** |
|
||||
| *(build axis, not a row)* | **opt7** — f32 device pedestal | **III** — the kernel |
|
||||
|
||||
Renaming the labels would rewrite recorded data, so they stay as they are; translate on
|
||||
the way out.
|
||||
|
||||
## Fixed parameters
|
||||
|
||||
| | 3×3 | 9×9 |
|
||||
|---|--:|--:|
|
||||
| `N` | 100 000 | 20 000 |
|
||||
| `max_clusters_per_frame` | 3 000 | 1 500 |
|
||||
| `n_streams` | 4 | 4 |
|
||||
| `BATCH_SIZE` | 2 000 | 2 000 |
|
||||
| pedestal frames / `n_sigma` | 1 000 / 5 | 1 000 / 5 |
|
||||
| reps | 5 | 5 |
|
||||
| nsys probe frames | 20 000 | 20 000 |
|
||||
|
||||
Optimising over these is a separate exercise; what matters here is that every
|
||||
step sees the same ones. Two are deliberate departures from earlier campaigns:
|
||||
`n_streams` was 8 at 9×9 (8 streams buy no kernel concurrency there — instance
|
||||
time +1 % — while inflating the event timer 3.5×), and probes were 2 000 frames
|
||||
(too short for the clocks to ramp, which is how a 26.7 µs roofline was published
|
||||
for a pipeline that sustains 24.25).
|
||||
|
||||
9×9 is held at N=20 000 because its result heap is ~5× larger per frame
|
||||
(1422 × 328 B = 466 kB vs 2330 × 40 B = 93 kB); 100 k would need 46.6 GB to
|
||||
retain against 98 GB free with no swap.
|
||||
|
||||
## Reading the output
|
||||
|
||||
**`cold` = rep 0 in a fresh process. `warm` = best of the remaining reps.**
|
||||
Not the last rep — `collect()` does not converge, it oscillates between allocator
|
||||
states. Measured at 9×9, opt4, one run: 85.8 / 73.7 / 86.6 µs with faults
|
||||
520 k / 127 k / 519 k. Quoting the last rep there reports 86.6 when 73.7 was
|
||||
achieved in the same run; the choice of rep would be doing the work, not the code.
|
||||
|
||||
**The `spread` column is a result, not noise.** Paths that allocate per frame
|
||||
vary 3–28 % run to run; `collect_view()`, which allocates nothing, is
|
||||
reproducible to 0.0–0.2 %. That contrast is the strongest argument for opt6
|
||||
(harness `opt8`) — it is the only path whose throughput is *reproducible*.
|
||||
|
||||
**Each step runs in its own process.** The heap is process-wide, so in a shared
|
||||
process every step inherits what the previous ones grew: `opt3` reports **2**
|
||||
faults after opt1/opt2 have run and **92 251** on its own. `--no-isolate` restores
|
||||
the fast path and makes the fault columns meaningless; throughput is unaffected
|
||||
either way.
|
||||
|
||||
**Reps share a finder within a process** — a new one would reset the heap. The
|
||||
device pedestal advances by `n_frames` each pass, so `n_clusters` drifts ~0.002 %
|
||||
between reps. Compare steps at the same rep index, never across reps.
|
||||
|
||||
**opt7 is not a row.** It is `DEVICE_PED_TYPE` in
|
||||
`include/aare/clusterfinder_kernel.cuh`. Run the matrix once per build and compare
|
||||
directories; `env.json` records which arm you are in. The f64 arm is the report's
|
||||
Acts I–II, the f32 arm is Act III.
|
||||
|
||||
**opt1/opt2 are 3×3 only** — `ClusterFinderCUDAOpt2` is registered for 3×3 in
|
||||
`cuda_bindings.cu`, so the 9×9 ladder starts at opt3. They are also unaffected by
|
||||
the opt7 flip by construction: their binding pins `PEDESTAL_TYPE` to `double`.
|
||||
If they move between arms, something is wrong.
|
||||
|
||||
**The CPU baseline is first-pass only** and forced to 1 rep: `ClusterFinderMT`
|
||||
cannot restart after `stop()`. Both speedup columns therefore divide by a *cold*
|
||||
CPU number, which reads ~9 % generous.
|
||||
|
||||
**At 4 streams the probe's `kernel_us_per_frame` is engine occupancy**, the union
|
||||
of kernel intervals over frames — not per-kernel duration. That is why f64 9×9
|
||||
reads 32.08 µs at `s4` while each kernel is ~39.9 µs. Use `s1` for exclusive
|
||||
kernel times (the opt7 claim), `s4` for "% of roofline".
|
||||
|
||||
**Expect zero-copy to land 2–3 % *under* its roofline.** The roofline is measured
|
||||
under CUPTI, which dilates GPU op durations slightly, so it is a mild
|
||||
over-estimate. "≥100 % of roofline" means *at the floor, within the profiler's
|
||||
own systematic error* — not a measurement error. Take percentages from the **f32**
|
||||
arm: the f64 9×9 `s4` kernel column is an interval union at `overlap = 1.36`, which
|
||||
CUPTI inflates further, and zero-copy reads 6.4 % under it there.
|
||||
|
||||
## Two policies enforced in code
|
||||
|
||||
1. **Never warm up by processing frames.** The kernel pushes a pedestal update
|
||||
per pixel per frame, so a finder that has seen extra frames is no longer
|
||||
comparable with one that has not. Slots are pre-pinned with
|
||||
`reserve_output_slots()`, which allocates without transferring or launching —
|
||||
verified to leave cluster counts bit-identical.
|
||||
2. **`time_kernels=False` everywhere**, including `ClusterFinderCUDAOpt2`, which
|
||||
gained the flag for this reason. With events on for one finder and off for
|
||||
another, the instrumented one pays a per-frame tax the other does not and the
|
||||
step between them absorbs it. Kernel times come from nsys, which is the only
|
||||
source correct under multi-stream load anyway.
|
||||
|
||||
## Results directories
|
||||
|
||||
```
|
||||
results/<date>_<f32|f64>[_tag]/
|
||||
env.json build, git rev, driver, GPU, DEVICE_PED_TYPE, timestamp
|
||||
manifest.csv artifact -> config -> build -> which report section cites it
|
||||
ladder_3x3.csv one row per (step, rep) — every rep kept, nothing averaged
|
||||
ladder_9x9.csv
|
||||
probes.csv per-engine us/frame, duty %, overlap, bottleneck, roofline
|
||||
probe_*.nsys-rep openable in nsys-ui
|
||||
probe_*.sqlite input to gpu_span.py
|
||||
```
|
||||
|
||||
Current campaign: **`2026-08-18_f32/`** and **`2026-08-18_f64/`**.
|
||||
`2026-08-12_f32_legacy/` is retired — see its `SUPERSEDED.md`.
|
||||
|
||||
> **Known wart:** `results_dir()` stamps *today's* date, so a campaign that spans
|
||||
> midnight splits across two directories. That happened once already (the f32
|
||||
> ladder and its probes landed a day apart) and had to be merged by hand, with a
|
||||
> note added to `env.json`. Prefer a campaign tag over a date if this recurs.
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Shared plumbing for the ClusterFinderCUDA measurement campaign.
|
||||
|
||||
Everything that could differ between ladder steps and silently change a number
|
||||
lives here exactly once: dataset loading, pedestal training, fault bracketing,
|
||||
slot pre-pinning and the CSV/manifest format. Steps differ only by the config
|
||||
rows in ladder.py.
|
||||
|
||||
Two policies are enforced here rather than left to each caller, because both
|
||||
have produced wrong numbers in this campaign before:
|
||||
|
||||
1. **Never warm up by processing frames.** The kernel pushes a pedestal update
|
||||
per pixel per frame, so a finder that has seen extra frames is no longer
|
||||
comparable with one that has not. Slots are pre-pinned with
|
||||
reserve_output_slots(), which allocates without transferring or launching.
|
||||
|
||||
2. **Every finder is fresh.** The device pedestal keeps evolving, so two steps
|
||||
sharing a finder see different pedestal states and cannot be compared.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import resource
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(REPO / "build"))
|
||||
|
||||
DATA_DIR = Path(
|
||||
"/mnt/sls_det_storage/moench_data/2603_MaxIVBeamtime/2026032408/process/xrf/"
|
||||
)
|
||||
DATA_FILE = DATA_DIR / "Cu_factor_10_data_master_0.json"
|
||||
PEDESTAL_FILE = DATA_DIR / "Cu_factor_10_pedestal_master_0.json"
|
||||
|
||||
N_PEDESTAL_FRAMES = 1000
|
||||
N_SIGMA = 5
|
||||
|
||||
# Cost of one first-touch page, measured on this machine (docs §3.1, §12).
|
||||
# Two different values because pinned pages additionally cost driver work.
|
||||
US_PER_HEAP_FAULT = 0.7
|
||||
US_PER_PINNED_FAULT = 1.0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# process-level instrumentation
|
||||
# --------------------------------------------------------------------------
|
||||
def faults():
|
||||
"""(minor, major) fault counters. Bracket every timed region with these."""
|
||||
r = resource.getrusage(resource.RUSAGE_SELF)
|
||||
return r.ru_minflt, r.ru_majflt
|
||||
|
||||
|
||||
def _sh(cmd, default="unknown"):
|
||||
try:
|
||||
return subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True, timeout=30
|
||||
).stdout.strip() or default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def capture_env() -> dict:
|
||||
"""Everything needed to know whether a later run is comparable to this one."""
|
||||
import aare
|
||||
|
||||
return {
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"host": _sh("hostname"),
|
||||
"git_rev": _sh(f"git -C {REPO} rev-parse --short HEAD"),
|
||||
"git_branch": _sh(f"git -C {REPO} rev-parse --abbrev-ref HEAD"),
|
||||
"git_dirty": bool(_sh(f"git -C {REPO} status --porcelain")),
|
||||
"aare_version": getattr(aare, "__version__", "unknown"),
|
||||
# The opt6 axis. Read from the header rather than trusted from memory:
|
||||
# a stale build is the single easiest way to mislabel a whole campaign.
|
||||
"device_ped_type": device_ped_type(),
|
||||
"gpu": _sh("nvidia-smi --query-gpu=name --format=csv,noheader"),
|
||||
"driver": _sh("nvidia-smi --query-gpu=driver_version --format=csv,noheader"),
|
||||
"gpu_busy_pct": _sh(
|
||||
"nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader"
|
||||
),
|
||||
"nvcc": _sh("nvcc --version | tail -1"),
|
||||
"python": sys.version.split()[0],
|
||||
}
|
||||
|
||||
|
||||
def assert_build_fresh() -> None:
|
||||
"""Abort if the installed extension predates the headers it was compiled from.
|
||||
|
||||
device_ped_type() below parses the SOURCE header, so on an un-rebuilt tree it
|
||||
reports the type the source *claims* while the loaded module is still the
|
||||
previous build — and env.json is stamped with the wrong build identity. That
|
||||
is not hypothetical: on 2026-08-20 it produced a full-f64 9x9 probe labelled
|
||||
"float" (header edited 10:54, binary built 09:57). See
|
||||
results/2026-08-20_INVALID_stale_build/INVALID.md.
|
||||
|
||||
mtime only. Cheap, and it catches the case that actually occurred.
|
||||
"""
|
||||
so = list((REPO / "build" / "aare").glob("_aare_cuda*.so"))
|
||||
if not so:
|
||||
raise RuntimeError("assert_build_fresh: no _aare_cuda*.so under build/aare")
|
||||
built = max(f.stat().st_mtime for f in so)
|
||||
|
||||
headers = [REPO / "include/aare/clusterfinder_kernel.cuh",
|
||||
REPO / "include/aare/ClusterFinderCUDA.hpp"]
|
||||
stale = [h for h in headers if h.exists() and h.stat().st_mtime > built]
|
||||
if stale:
|
||||
names = ", ".join(h.name for h in stale)
|
||||
raise RuntimeError(
|
||||
f"assert_build_fresh: {names} newer than the installed extension.\n"
|
||||
f" header(s) edited after the build -> env.json would record the "
|
||||
f"SOURCE type, not the compiled one.\n"
|
||||
f" Rebuild and reinstall before measuring.")
|
||||
|
||||
|
||||
def device_ped_type() -> str:
|
||||
"""Parse DEVICE_PED_TYPE out of the kernel header — the opt6 build axis.
|
||||
|
||||
Reads SOURCE, not the binary; there is no binding that exposes the compiled
|
||||
type. Only meaningful once assert_build_fresh() has passed.
|
||||
"""
|
||||
hdr = REPO / "include/aare/clusterfinder_kernel.cuh"
|
||||
try:
|
||||
for line in hdr.read_text().splitlines()[:40]:
|
||||
if "using DEVICE_PED_TYPE" in line and not line.strip().startswith("//"):
|
||||
return line.split("=")[1].split(";")[0].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def assert_idle_gpu(max_pct: int = 5):
|
||||
"""A competing process leaves per-op averages intact while destroying the
|
||||
duty cycle and the wall clock (docs §14). Fail loudly rather than record it."""
|
||||
pct = _sh("nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader")
|
||||
try:
|
||||
val = int(pct.split()[0])
|
||||
except Exception:
|
||||
return
|
||||
if val > max_pct:
|
||||
raise SystemExit(
|
||||
f"GPU is {val}% busy — another process is running. "
|
||||
f"Numbers taken now are not quotable (docs §14). Aborting."
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# dataset + finders
|
||||
# --------------------------------------------------------------------------
|
||||
_data_cache: dict[int, np.ndarray] = {}
|
||||
|
||||
|
||||
def load_frames(n_frames: int) -> np.ndarray:
|
||||
"""Data frames, held in RAM so file I/O is outside every timing loop."""
|
||||
from aare import File
|
||||
|
||||
if n_frames not in _data_cache:
|
||||
f = File(DATA_FILE)
|
||||
_data_cache[n_frames] = f.read_n(n_frames)
|
||||
return _data_cache[n_frames]
|
||||
|
||||
|
||||
def image_size() -> tuple[int, int]:
|
||||
from aare import File
|
||||
|
||||
f = File(DATA_FILE)
|
||||
return (f.rows, f.cols)
|
||||
|
||||
|
||||
def train_pedestal(finder) -> None:
|
||||
"""The same 1000 pedestal frames for every finder in every step."""
|
||||
from aare import File
|
||||
|
||||
pd = File(PEDESTAL_FILE)
|
||||
for _ in range(N_PEDESTAL_FRAMES):
|
||||
finder.push_pedestal_frame(pd.read_frame().copy())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# results
|
||||
# --------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class Row:
|
||||
"""One measurement. Written verbatim to CSV; every report number cites one."""
|
||||
|
||||
step: str # opt1 … opt8, cpu
|
||||
label: str # human-readable description
|
||||
cluster_dim: int
|
||||
cap: int
|
||||
n_frames: int
|
||||
n_streams: int
|
||||
pinned: bool
|
||||
batch_chunk: str # "auto" | "off" | explicit int
|
||||
collection: str # collect | collect_view | per-frame | n/a
|
||||
device_ped_type: str
|
||||
rep: int
|
||||
wall_s: float
|
||||
us_per_frame: float
|
||||
fps: float
|
||||
minor_faults: int
|
||||
major_faults: int
|
||||
n_clusters: int
|
||||
clusters_per_frame: float
|
||||
notes: str = ""
|
||||
|
||||
|
||||
def write_rows(path: Path, rows: list[Row]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", newline="") as fh:
|
||||
w = csv.DictWriter(fh, fieldnames=list(Row.__dataclass_fields__))
|
||||
w.writeheader()
|
||||
for r in rows:
|
||||
w.writerow(asdict(r))
|
||||
|
||||
|
||||
def write_env(path: Path, env: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(env, indent=2) + "\n")
|
||||
|
||||
|
||||
def append_manifest(path: Path, entry: dict) -> None:
|
||||
"""artifact -> config -> build -> which report section cites it."""
|
||||
cols = ["artifact", "kind", "config", "build", "cites", "produced_by", "timestamp"]
|
||||
new = not path.exists()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", newline="") as fh:
|
||||
w = csv.DictWriter(fh, fieldnames=cols)
|
||||
if new:
|
||||
w.writeheader()
|
||||
w.writerow({c: entry.get(c, "") for c in cols})
|
||||
|
||||
|
||||
def results_dir(tag: str = "") -> Path:
|
||||
"""perf/results/<date>_<f32|f64>[_tag]/ — one directory per campaign."""
|
||||
ped = device_ped_type()
|
||||
short = {"float": "f32", "double": "f64"}.get(ped, ped)
|
||||
name = f"{time.strftime('%Y-%m-%d')}_{short}" + (f"_{tag}" if tag else "")
|
||||
d = Path(__file__).resolve().parent / "results" / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def print_table(rows: list[Row], floor_us: float | None = None) -> None:
|
||||
"""Cold (rep 0) vs warm (BEST of the rest), with the spread.
|
||||
|
||||
"Best of the rest", not "last", because collect() does not converge: it
|
||||
oscillates between allocator states. Measured at 9x9, opt4, one run:
|
||||
85.8 / 73.7 / 86.6 us per frame with faults 520k / 127k / 519k. Quoting the
|
||||
last rep there reports 86.6 when 73.7 was achieved in the same run — the
|
||||
choice of rep would be doing the work, not the code.
|
||||
|
||||
The spread column is therefore part of the result, not noise to hide: the
|
||||
paths that allocate per frame vary by 3-17 %, and collect_view(), which
|
||||
allocates nothing, is reproducible to a few tenths of a percent. That
|
||||
contrast is itself a finding.
|
||||
"""
|
||||
by_step: dict[str, list[Row]] = {}
|
||||
for r in rows:
|
||||
by_step.setdefault(r.step, []).append(r)
|
||||
for v in by_step.values():
|
||||
v.sort(key=lambda r: r.rep)
|
||||
|
||||
def warm_of(v: list[Row]) -> Row:
|
||||
return min(v[1:] or v, key=lambda r: r.us_per_frame)
|
||||
|
||||
cpu = by_step.get("cpu")
|
||||
cpu_us = warm_of(cpu).us_per_frame if cpu else None
|
||||
|
||||
hdr = (f"{'step':<6} {'label':<38} {'cold FPS':>9} {'warm FPS':>9} "
|
||||
f"{'warm us/f':>10} {'spread':>7} {'faults c->w':>18}")
|
||||
if cpu_us:
|
||||
hdr += f" {'vs CPU':>7}"
|
||||
if floor_us:
|
||||
hdr += f" {'%floor':>7}"
|
||||
print(hdr)
|
||||
print("-" * len(hdr))
|
||||
for step, v in by_step.items():
|
||||
cold, warm = v[0], warm_of(v)
|
||||
us = [r.us_per_frame for r in v[1:]] or [v[0].us_per_frame]
|
||||
spread = 100 * (max(us) - min(us)) / min(us)
|
||||
line = (f"{step:<6} {warm.label[:38]:<38} {cold.fps:9,.0f} {warm.fps:9,.0f} "
|
||||
f"{warm.us_per_frame:10.2f} {spread:6.1f}% "
|
||||
f"{cold.minor_faults:>8,} ->{warm.minor_faults:>8,}")
|
||||
if cpu_us:
|
||||
line += f" {cpu_us / warm.us_per_frame:6.2f}x"
|
||||
if floor_us:
|
||||
line += f" {100 * floor_us / warm.us_per_frame:6.0f}%"
|
||||
print(line)
|
||||
print(" cold = rep 0 (fresh process). warm = best of reps 1..n; spread = "
|
||||
"(max-min)/min over those reps.")
|
||||
if cpu and len(cpu) == 1:
|
||||
print(" cpu is first-pass only (stop() is terminal): cold == warm.")
|
||||
@@ -0,0 +1,143 @@
|
||||
"""How many threads should the CPU baseline actually use?
|
||||
|
||||
The campaign's CPU reference was ClusterFinderMT with n_threads=48. This machine
|
||||
is a Ryzen 9 7950X: 16 physical cores, 32 logical. 48 threads oversubscribes it
|
||||
by 1.5x, so the baseline was slower than the CPU can go and every GPU speedup
|
||||
quoted against it was correspondingly flattered.
|
||||
|
||||
This sweeps the thread count at both cluster sizes and reports the best. The
|
||||
result is the number the deck and the report should divide by.
|
||||
|
||||
Two timings are recorded per point, because the campaign and the notebook do not
|
||||
measure the same thing:
|
||||
|
||||
loop_s -- the find_clusters() loop alone. This is what
|
||||
ClusterFinderCUDA_perf.ipynb prints as `CPU clustering`.
|
||||
wall_s -- loop + stop() + draining the ClusterCollector, which is what
|
||||
ladder.py's `cpu` step records, because _drive() does the drain
|
||||
inside the timed region.
|
||||
|
||||
wall_s is the one to compare against the GPU rows in ladder_*.csv: those include
|
||||
their own result collection. loop_s is here so the notebook's number can be
|
||||
reconciled with this one rather than looking like a contradiction.
|
||||
|
||||
Matches the ladder's CPU step in every other respect: same 1000 pedestal frames,
|
||||
same caps, same frame counts, a fresh finder per point, clusters retained.
|
||||
|
||||
python python/tests/perf/cpu_threads.py [--tag 2026-08-19_cpu]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gc
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import common
|
||||
from common import Row, faults
|
||||
|
||||
# 8 = half the physical cores
|
||||
# 16 = one per physical core
|
||||
# 24 = 1.5x cores (the count that beat 48 in the notebook)
|
||||
# 32 = one per logical thread
|
||||
# 48 = the campaign's original, 1.5x oversubscribed
|
||||
THREADS = [8, 16, 24, 32, 48]
|
||||
|
||||
# (cluster_dim, cap, n_frames) -- exactly the ladder's `cpu` row for each size.
|
||||
# 9x9 ran at 20 000 frames there, so it stays at 20 000 here.
|
||||
CONFIGS = [(3, 3000, 100_000), (9, 1500, 20_000)]
|
||||
|
||||
|
||||
def measure(dim: int, cap: int, n_frames: int, n_threads: int, data) -> Row:
|
||||
"""One point. The finder is built, trained, driven and destroyed here.
|
||||
|
||||
ClusterFinderMT.stop() is terminal, so a point cannot be repeated on the
|
||||
same finder -- which is also why the ladder runs the CPU step with reps=1.
|
||||
"""
|
||||
from aare import ClusterFinderMT, ClusterCollector
|
||||
|
||||
cf = ClusterFinderMT(common.image_size(), (dim, dim), n_sigma=common.N_SIGMA,
|
||||
capacity=cap, n_threads=n_threads)
|
||||
sink = ClusterCollector(cf)
|
||||
common.train_pedestal(cf)
|
||||
|
||||
gc.collect()
|
||||
mf0, Mf0 = faults()
|
||||
t0 = time.perf_counter()
|
||||
for i in range(n_frames):
|
||||
cf.find_clusters(data[i])
|
||||
loop_s = time.perf_counter() - t0
|
||||
|
||||
# The drain is inside ladder.py's timed region, so it is inside ours too.
|
||||
cf.stop()
|
||||
sink.stop()
|
||||
n_clusters = 0
|
||||
for cv in sink.steal_clusters():
|
||||
n_clusters += cv.size
|
||||
wall_s = time.perf_counter() - t0
|
||||
mf1, Mf1 = faults()
|
||||
|
||||
del cf, sink
|
||||
gc.collect()
|
||||
|
||||
return Row(
|
||||
step="cpu", label=f"ClusterFinderMT, {n_threads} threads",
|
||||
cluster_dim=dim, cap=cap, n_frames=n_frames, n_streams=0, pinned=False,
|
||||
batch_chunk="n/a", collection="n/a",
|
||||
device_ped_type=common.device_ped_type(), rep=0,
|
||||
wall_s=wall_s, us_per_frame=wall_s * 1e6 / n_frames,
|
||||
fps=n_frames / wall_s, minor_faults=mf1 - mf0, major_faults=Mf1 - Mf0,
|
||||
n_clusters=n_clusters, clusters_per_frame=n_clusters / n_frames,
|
||||
notes=f"retain threads={n_threads} loop_s={loop_s:.3f} "
|
||||
f"loop_fps={n_frames / loop_s:.1f}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tag", default=time.strftime("%Y-%m-%d") + "_cpu")
|
||||
ap.add_argument("--threads", type=int, nargs="*", default=THREADS)
|
||||
args = ap.parse_args()
|
||||
|
||||
out = common.results_dir(args.tag)
|
||||
rows: list[Row] = []
|
||||
|
||||
for dim, cap, n_frames in CONFIGS:
|
||||
data = common.load_frames(n_frames)
|
||||
print(f"\n=== {dim}x{dim}, cap {cap}, {n_frames:,} frames "
|
||||
f"{'=' * 30}", flush=True)
|
||||
print(f"{'threads':>8} {'wall_s':>9} {'FPS':>10} {'us/fr':>9} "
|
||||
f"{'loop FPS':>10} {'faults':>12}", flush=True)
|
||||
for n_threads in args.threads:
|
||||
r = measure(dim, cap, n_frames, n_threads, data)
|
||||
rows.append(r)
|
||||
loop_fps = float(r.notes.split("loop_fps=")[1])
|
||||
print(f"{n_threads:>8} {r.wall_s:>9.3f} {r.fps:>10,.1f} "
|
||||
f"{r.us_per_frame:>9.1f} {loop_fps:>10,.1f} "
|
||||
f"{r.minor_faults:>12,}", flush=True)
|
||||
|
||||
common.write_rows(out / "cpu_threads.csv", rows)
|
||||
common.write_env(out / "env.json", common.capture_env())
|
||||
common.append_manifest(out / "manifest.csv", dict(
|
||||
artifact="cpu_threads.csv", kind="ladder",
|
||||
config="ClusterFinderMT thread sweep, 3x3 + 9x9",
|
||||
build=common.device_ped_type(),
|
||||
cites="CPU baseline for every speedup in deck + report",
|
||||
produced_by="cpu_threads.py",
|
||||
timestamp=time.strftime("%Y-%m-%d %H:%M:%S")))
|
||||
|
||||
print(f"\nwrote {out / 'cpu_threads.csv'}")
|
||||
print("\nbest per size (wall_s convention, comparable to ladder GPU rows):")
|
||||
for dim, _, _ in CONFIGS:
|
||||
best = max((r for r in rows if r.cluster_dim == dim), key=lambda r: r.fps)
|
||||
old = {3: 5228.6, 9: 1304.0}[dim]
|
||||
print(f" {dim}x{dim}: {best.label:<32} {best.fps:>9,.1f} FPS "
|
||||
f"({best.us_per_frame:6.1f} us/fr) "
|
||||
f"vs 48-thread {old:,.1f} = {best.fps / old:.2f}x")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Engine duty cycle over the processing window, from an nsys SQLite export.
|
||||
|
||||
nsys stats --force-export=true --report cuda_gpu_sum <rep>.nsys-rep # makes the .sqlite
|
||||
python gpu_span.py <rep>.sqlite <n_frames>
|
||||
|
||||
Reports, per engine (kernel / H2D / D2H):
|
||||
sum = total of individual op durations (what `nsys stats` gives you)
|
||||
busy = length of the UNION of those intervals (no double-counting overlaps)
|
||||
duty = busy / processing-window
|
||||
|
||||
Processing window = first kernel start -> last D2H end. Scoping matters: the trace
|
||||
also contains the pedestal-upload H2Ds that precede any kernel, and including them
|
||||
deflates every duty cycle.
|
||||
"""
|
||||
import sqlite3, sys
|
||||
|
||||
Q = {'kernel': "SELECT start,end FROM CUPTI_ACTIVITY_KIND_KERNEL ORDER BY start",
|
||||
'H2D': "SELECT start,end FROM CUPTI_ACTIVITY_KIND_MEMCPY WHERE copyKind=1 ORDER BY start",
|
||||
'D2H': "SELECT start,end FROM CUPTI_ACTIVITY_KIND_MEMCPY WHERE copyKind=2 ORDER BY start"}
|
||||
|
||||
|
||||
def union(rows):
|
||||
"""Total wall time covered by at least one interval."""
|
||||
busy, cs, ce = 0, *rows[0]
|
||||
for s, e in rows[1:]:
|
||||
if s > ce:
|
||||
busy += ce - cs
|
||||
cs, ce = s, e
|
||||
else:
|
||||
ce = max(ce, e)
|
||||
return busy + ce - cs
|
||||
|
||||
|
||||
def analyze(sqlite_path, n_frames):
|
||||
"""Per-engine sum / busy / overlap / duty / per-frame, plus the window.
|
||||
|
||||
Returns a plain dict so a driver can write it to CSV. The roofline is
|
||||
max(per_frame_us) over the three engines: PCIe is full-duplex, so the floor
|
||||
is the tallest bar, never the sum.
|
||||
"""
|
||||
db = sqlite3.connect(str(sqlite_path))
|
||||
ops = {k: db.execute(q).fetchall() for k, q in Q.items()}
|
||||
lo = ops['kernel'][0][0] # first kernel start
|
||||
hi = max(ops['kernel'][-1][1], ops['D2H'][-1][1])
|
||||
span = hi - lo
|
||||
|
||||
out = {'n_frames': n_frames, 'window_ms': span / 1e6,
|
||||
'window_us_per_frame': span / 1e3 / n_frames,
|
||||
'window_fps': n_frames / (span / 1e9)}
|
||||
for k, rows in ops.items():
|
||||
rows = [r for r in rows if r[1] > lo] # drop the pedestal-phase copies
|
||||
tot, busy = sum(e - s for s, e in rows), union(rows)
|
||||
out[f'{k}_n'] = len(rows)
|
||||
out[f'{k}_sum_ms'] = tot / 1e6
|
||||
out[f'{k}_busy_ms'] = busy / 1e6
|
||||
out[f'{k}_overlap'] = tot / busy
|
||||
out[f'{k}_duty_pct'] = 100 * busy / span
|
||||
out[f'{k}_us_per_frame'] = busy / 1e3 / n_frames
|
||||
engines = ('kernel', 'H2D', 'D2H')
|
||||
tallest = max(engines, key=lambda k: out[f'{k}_us_per_frame'])
|
||||
out['bottleneck'] = tallest
|
||||
out['roofline_us_per_frame'] = out[f'{tallest}_us_per_frame']
|
||||
out['roofline_fps'] = 1e6 / out['roofline_us_per_frame']
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
N = int(sys.argv[2]) if len(sys.argv) > 2 else 2000
|
||||
r = analyze(sys.argv[1], N)
|
||||
print(f"processing window: {r['window_ms']:.1f} ms / {N} frames = "
|
||||
f"{r['window_us_per_frame']:.1f} us/frame -> {r['window_fps']:,.0f} FPS")
|
||||
for k in ('kernel', 'H2D', 'D2H'):
|
||||
print(f" {k:6s} sum={r[k+'_sum_ms']:7.2f} ms busy={r[k+'_busy_ms']:7.2f} ms "
|
||||
f"overlap={r[k+'_overlap']:4.2f}x duty={r[k+'_duty_pct']:5.1f}% "
|
||||
f"per-frame={r[k+'_us_per_frame']:5.1f} us")
|
||||
print(f" roofline = {r['bottleneck']} at {r['roofline_us_per_frame']:.1f} us/frame "
|
||||
f"= {r['roofline_fps']:,.0f} FPS")
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Register pressure, spills and occupancy for every ClusterFinder kernel.
|
||||
|
||||
Reads the *compiled* extension — no rebuild and no source parsing:
|
||||
|
||||
cuobjdump -res-usage <lib.so>
|
||||
|
||||
then applies the sm_89 occupancy arithmetic. Reproduces the figures on slides 7
|
||||
and 8 of docs/cf_cuda_fused.pptx, and cross-checks against
|
||||
cudaOccupancyMaxActiveBlocksPerMultiprocessor (same blocks/SM).
|
||||
|
||||
python kernel_resources.py # shipping build, 16x16 blocks
|
||||
python kernel_resources.py --blocks 64 256 1024 # the block-size sweep
|
||||
python kernel_resources.py --lib path/to/other.so
|
||||
|
||||
Two things to know when reading the output:
|
||||
|
||||
1. **Register count depends on the cluster payload type.** At 9x9 it is 96
|
||||
(float) / 120 (double) / 128 (int). The Python bindings register the *int*
|
||||
variants, so those are the rows to quote.
|
||||
|
||||
2. **Register count also depends on DEVICE_PED_TYPE**, at 3x3. The f64 pedestal
|
||||
costs 9 extra registers there — 47 vs 38 — which is enough to lose a block per
|
||||
SM and drop occupancy from 100 % to 83 %. At 9x9 nothing moves: the limiter is
|
||||
the clusterData[CSX][CSY] staging array, not the pedestal accumulators. Always
|
||||
record which build a number came from; `common.device_ped_type()` reports it.
|
||||
|
||||
SHARED reads 0 in the ELF because the finder passes shared memory dynamically at
|
||||
launch, so it is recomputed here with the launcher's own formula
|
||||
(ClusterFinderCUDA.hpp): (BLOCK_X + 2*col_radius) * (BLOCK_Y + 2*row_radius) *
|
||||
sizeof(COMPUTE_TYPE).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
DEFAULT_LIB = REPO / "build/aare/_aare_cuda.cpython-311-x86_64-linux-gnu.so"
|
||||
|
||||
# --- RTX 4090 / sm_89, from cudaDeviceProp ---------------------------------
|
||||
REGS_PER_SM = 65536
|
||||
MAX_THREADS_PER_SM = 1536
|
||||
MAX_BLOCKS_PER_SM = 24
|
||||
SMEM_PER_SM = 102400 # opt-in maximum; 48 KB is the default carveout
|
||||
WARP = 32
|
||||
REG_ALLOC_GRANULARITY = 256 # registers are allocated per warp, rounded up
|
||||
|
||||
KERNEL_RE = re.compile(r"\s*Function (\S+):")
|
||||
USAGE_RE = re.compile(r"REG:(\d+) STACK:(\d+) SHARED:(\d+) LOCAL:(\d+)")
|
||||
TEMPLATE_RE = re.compile(
|
||||
r"Cluster<(\w+), \(unsigned char\)(\d+), \(unsigned char\)(\d+)")
|
||||
|
||||
|
||||
def demangle(name: str) -> str:
|
||||
try:
|
||||
import cxxfilt
|
||||
return cxxfilt.demangle(name)
|
||||
except Exception:
|
||||
out = subprocess.run(["c++filt", name], capture_output=True, text=True)
|
||||
return out.stdout.strip() or name
|
||||
|
||||
|
||||
def read_res_usage(lib: Path) -> list[tuple[str, int, int, int, int]]:
|
||||
"""[(mangled_name, regs, stack, shared, local), ...] straight from the ELF."""
|
||||
proc = subprocess.run(["cuobjdump", "-res-usage", str(lib)],
|
||||
capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
sys.exit(f"cuobjdump failed:\n{proc.stderr}")
|
||||
rows, cur = [], None
|
||||
for line in proc.stdout.splitlines():
|
||||
m = KERNEL_RE.match(line)
|
||||
if m:
|
||||
cur = m.group(1)
|
||||
continue
|
||||
m = USAGE_RE.search(line)
|
||||
if m and cur:
|
||||
rows.append((cur, *map(int, m.groups())))
|
||||
cur = None
|
||||
return rows
|
||||
|
||||
|
||||
def occupancy(regs: int, smem_per_block: int, block: int):
|
||||
"""Blocks/SM, warps/SM, occupancy %, and which resource binds."""
|
||||
warps_per_block = math.ceil(block / WARP)
|
||||
per_warp = math.ceil(regs * WARP / REG_ALLOC_GRANULARITY) * REG_ALLOC_GRANULARITY
|
||||
limits = {
|
||||
"registers": (REGS_PER_SM // per_warp) // warps_per_block,
|
||||
"threads/SM": MAX_THREADS_PER_SM // block,
|
||||
"shared mem": SMEM_PER_SM // smem_per_block if smem_per_block else 1 << 20,
|
||||
"blocks/SM": MAX_BLOCKS_PER_SM,
|
||||
}
|
||||
blocks = min(limits.values())
|
||||
binder = min(limits, key=lambda k: (limits[k], k))
|
||||
warps = blocks * warps_per_block
|
||||
return blocks, warps, 100.0 * warps / (MAX_THREADS_PER_SM // WARP), binder
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--lib", type=Path, default=DEFAULT_LIB)
|
||||
ap.add_argument("--blocks", type=int, nargs="+", default=[256],
|
||||
help="threads per block (default 256 = the 16x16 the finder launches)")
|
||||
ap.add_argument("--all-types", action="store_true",
|
||||
help="also show the float/double cluster payloads (bindings use int)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.lib.exists():
|
||||
sys.exit(f"no such library: {args.lib}\nBuild it first: cmake --build build -j16")
|
||||
|
||||
try:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import common
|
||||
build = common.device_ped_type()
|
||||
except Exception:
|
||||
build = "unknown"
|
||||
|
||||
kernels = []
|
||||
for name, regs, stack, shared, local in read_res_usage(args.lib):
|
||||
d = demangle(name)
|
||||
if "find_clusters_in_single_frame" not in d:
|
||||
continue
|
||||
m = TEMPLATE_RE.search(d)
|
||||
if not m:
|
||||
continue
|
||||
ctype, sx, sy = m.group(1), int(m.group(2)), int(m.group(3))
|
||||
if not args.all_types and ctype != "int":
|
||||
continue
|
||||
pipeline = "opt2" if "device_opt2" in d else "current"
|
||||
kernels.append((pipeline, sx, sy, ctype, regs, stack + local))
|
||||
|
||||
kernels.sort(key=lambda k: (k[0] != "current", k[1]))
|
||||
|
||||
print(f"library {args.lib}")
|
||||
print(f"build DEVICE_PED_TYPE = {build}")
|
||||
print(f"payload {'all cluster types' if args.all_types else 'int (what the bindings register)'}")
|
||||
print()
|
||||
|
||||
for block in args.blocks:
|
||||
side = int(math.isqrt(block))
|
||||
print(f"--- {side}x{side} blocks · {block} threads ---")
|
||||
print(f"{'kernel':28} {'regs':>5} {'spill':>6} {'smem/blk':>9} "
|
||||
f"{'blk/SM':>7} {'warps':>6} {'occupancy':>10} limited by")
|
||||
print("-" * 92)
|
||||
for pipeline, sx, sy, ctype, regs, spill in kernels:
|
||||
# the launcher's own formula; COMPUTE_TYPE is float in every build
|
||||
smem = (side + sy - 1) * (side + sx - 1) * 4
|
||||
blocks, warps, occ, binder = occupancy(regs, smem, block)
|
||||
label = f"[{pipeline}] {sx}x{sy} Cluster<{ctype}>"
|
||||
note = " <- will not launch" if blocks == 0 else ""
|
||||
print(f"{label:28} {regs:5} {spill:6} {smem:8} B {blocks:7} {warps:6} "
|
||||
f"{occ:9.1f}% {binder}{note}")
|
||||
print()
|
||||
|
||||
print(f"sm_89: {REGS_PER_SM:,} regs/SM · {MAX_THREADS_PER_SM:,} threads/SM · "
|
||||
f"{MAX_BLOCKS_PER_SM} blocks/SM · {REG_ALLOC_GRANULARITY}-reg granularity")
|
||||
print("Spills must be 0. A non-zero STACK/LOCAL means ptxas gave up and went "
|
||||
"to local memory,\nwhich costs far more than the occupancy it buys back.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,319 @@
|
||||
"""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
|
||||
@@ -0,0 +1,66 @@
|
||||
# Minimal probe for nsys: train pedestal, run one batched pass, print summary.
|
||||
# Usage: nsys_kernel_probe.py [n_streams] [n_frames] [cluster_dim] [cap] [batch]
|
||||
#
|
||||
# nsys profile --trace=cuda --sample=none --cpuctxsw=none -o rep \
|
||||
# python nsys_kernel_probe.py 4 2000 9 1700
|
||||
# nsys stats --report cuda_gpu_sum rep.nsys-rep # per-op totals
|
||||
# python gpu_span.py rep.sqlite 2000 # engine duty cycles
|
||||
#
|
||||
# The wall time printed here is NOT a throughput number: a fresh process pays the
|
||||
# full first-touch page-fault tax inside the timed call, and nsys inflates it
|
||||
# further. Take per-operation GPU times from the reports, wall times from the
|
||||
# notebook. See docs/ClusterFinderCUDA_benchmark_results.md sections 3.3 and 14.
|
||||
import sys
|
||||
sys.path.append('/home/ferjao_k/aare/build')
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
from aare import File, ClusterFinderCUDA
|
||||
|
||||
n_streams = int(sys.argv[1]) if len(sys.argv) > 1 else 8
|
||||
N = int(sys.argv[2]) if len(sys.argv) > 2 else 2000
|
||||
cdim = int(sys.argv[3]) if len(sys.argv) > 3 else 9
|
||||
cap = int(sys.argv[4]) if len(sys.argv) > 4 else 1700
|
||||
# Loop in BATCH_SIZE slices exactly as run_ladder.py does, so the duty cycles
|
||||
# describe the configuration the throughput numbers were taken on. One giant
|
||||
# call would chunk differently and give a different overlap picture.
|
||||
batch = int(sys.argv[5]) if len(sys.argv) > 5 else 2000
|
||||
|
||||
base = Path('/mnt/sls_det_storage/moench_data/2603_MaxIVBeamtime/2026032408/process/xrf/')
|
||||
f = File(base / 'Cu_factor_10_data_master_0.json')
|
||||
pd = File(base / 'Cu_factor_10_pedestal_master_0.json')
|
||||
|
||||
cf = ClusterFinderCUDA((f.rows, f.cols), (cdim, cdim), n_sigma=5,
|
||||
max_clusters_per_frame=cap, n_streams=n_streams)
|
||||
for _ in range(1000):
|
||||
cf.push_pedestal_frame(pd.read_frame().copy())
|
||||
|
||||
data = f.read_n(N)
|
||||
cf.register_input_buffer(data)
|
||||
|
||||
cf.reserve_output_slots(cf.chunk_size_for(min(N, batch)))
|
||||
|
||||
t0 = time.perf_counter()
|
||||
n = 0
|
||||
for start in range(0, N, batch):
|
||||
stop = min(start + batch, N)
|
||||
# Counted and discarded per batch, matching run_ladder.py's default
|
||||
# consumer: peak result memory is one batch, not the whole run.
|
||||
for cv in cf.find_clusters_batched(data[start:stop], first_frame=start):
|
||||
n += cv.size
|
||||
t = time.perf_counter() - t0
|
||||
|
||||
cf.unregister_input_buffer()
|
||||
|
||||
# Transfer sizes per frame, so the nsys memcpy rows can be checked against the
|
||||
# payload they carry: the D2H is cap-sized regardless of how many clusters were
|
||||
# found, which is what makes `cap` a throughput knob and not just a safety bound.
|
||||
h2d = f.rows * f.cols * 2
|
||||
slot = 2 + 2 + cdim * cdim * 4 # x, y (uint16) + data (int32)
|
||||
print(f'n_streams={n_streams} N={N} cluster={cdim}x{cdim} cap={cap} batch={batch}')
|
||||
print(f' H2D/frame={h2d:,} B D2H/frame={cap * slot:,} B '
|
||||
f'({slot} B/slot, {100 * n / N / cap:.0f}% filled)')
|
||||
print(f' wall={t:.3f}s ({N/t:.0f} FPS, profiler-inflated) clusters/frame={n/N:.2f}')
|
||||
if cf.kernel_timing_enabled():
|
||||
print(f' event kernel_ms={cf.avg_kernel_time_ms():.3f} '
|
||||
f'(only meaningful at n_streams=1)')
|
||||
@@ -0,0 +1,5 @@
|
||||
# Binary profiler artifacts: large (0.5-2 MB each, ~40 MB per campaign) and
|
||||
# regenerable from the scripts. They stay on disk for nsys-ui; the CSV/JSON
|
||||
# summaries next to them are what the report cites and what gets tracked.
|
||||
*.nsys-rep
|
||||
*.sqlite
|
||||
@@ -0,0 +1,32 @@
|
||||
# Superseded — do not cite
|
||||
|
||||
These profiles predate the `perf/` harness. They are kept only because they are
|
||||
the provenance for numbers still quoted in older revisions of
|
||||
`docs/ClusterFinderCUDA_benchmark_results.md`. **Every one of them has been replaced** by
|
||||
`../2026-08-18_f32/` and `../2026-08-18_f64/`.
|
||||
|
||||
Three reasons they were retired rather than reused:
|
||||
|
||||
1. **2 000 frames.** Too short for the GPU clocks to ramp (210 MHz idle ->
|
||||
3.1 GHz boost), which under-reports the GPU by ~7-10 %. This is how a
|
||||
26.7 us/frame roofline was published for a pipeline that sustains 24.25.
|
||||
2. **No build record.** Nothing in these files says which `DEVICE_PED_TYPE` they
|
||||
were taken on. `probe_s1.nsys-rep` was described in the report as the f64
|
||||
reference; `nsys stats` shows a 25.2 us kernel, i.e. it is **f32**. The
|
||||
entire f64 arm was therefore unsupported until the 2026-08-18 campaign.
|
||||
3. **One giant call, not the ladder's batching.** They drove
|
||||
`find_clusters_batched(all_frames)` rather than looping in BATCH_SIZE
|
||||
slices, so their overlap and duty cycles describe a configuration the
|
||||
throughput numbers were never taken on.
|
||||
|
||||
| file | what it actually is |
|
||||
|---|---|
|
||||
| `probe_s1.nsys-rep` | 9x9, 1 stream, **f32** (25.2 us kernel) — mislabelled as f64 in the report |
|
||||
| `probe3x3_s4.*` | 3x3, 4 streams, events ON |
|
||||
| `probe3x3_s4_no_timing.*` | 3x3, 4 streams, events OFF |
|
||||
| `probe3x3_s1_no_timing.*` | 3x3, 1 stream |
|
||||
| `probe9x9_s1_no_timing_cap_1500.*` | 9x9, 1 stream, cap 1500 |
|
||||
| `probe9x9_s1_no_timing.*` | 9x9, 1 stream, cap 3000 |
|
||||
| `probe9x9_s4_no_timing.*` | 9x9, 4 streams, cap 3000 |
|
||||
|
||||
Safe to delete once the report cites only the 2026-08-18 campaign.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"timestamp": "2026-08-18 11:10:35",
|
||||
"host": "pc-moench-04.psi.ch",
|
||||
"git_rev": "7177f00",
|
||||
"git_branch": "bench/opt2-pipeline",
|
||||
"git_dirty": true,
|
||||
"aare_version": "2026.7.2",
|
||||
"device_ped_type": "float",
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"driver": "595.71.05",
|
||||
"gpu_busy_pct": "0 %",
|
||||
"nvcc": "Build cuda_12.4.r12.4/compiler.34097967_0",
|
||||
"python": "3.11.15",
|
||||
"note":
|
||||
"ladder_*.csv were taken 2026-08-13 and probes.csv 2026-08-18, same f32 build (git 7177f00, DEVICE_PED_TYPE=float), no rebuild between them. Merged here so the arm is one directory."
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
step,label,cluster_dim,cap,n_frames,n_streams,pinned,batch_chunk,collection,device_ped_type,rep,wall_s,us_per_frame,fps,minor_faults,major_faults,n_clusters,clusters_per_frame,notes
|
||||
cpu,"ClusterFinderMT, 48 threads",3,3000,100000,0,False,n/a,n/a,float,0,19.125628537032753,191.25628537032753,5228.58633411033,2664805,0,233085343,2330.85343,reference
|
||||
opt1,"1 stream, one launch per frame",3,3000,100000,1,False,auto,per-frame,float,0,6.339732066029683,63.39732066029683,15773.537266004043,797,0,233094770,2330.9477,
|
||||
opt1,"1 stream, one launch per frame",3,3000,100000,1,False,auto,per-frame,float,1,6.308290184941143,63.08290184941143,15852.155983362236,0,0,233094984,2330.94984,
|
||||
opt1,"1 stream, one launch per frame",3,3000,100000,1,False,auto,per-frame,float,2,6.3270947767887264,63.270947767887264,15805.04220781632,0,0,233094984,2330.94984,
|
||||
opt1,"1 stream, one launch per frame",3,3000,100000,1,False,auto,per-frame,float,3,6.326483318815008,63.26483318815008,15806.569773542162,0,0,233094984,2330.94984,
|
||||
opt1,"1 stream, one launch per frame",3,3000,100000,1,False,auto,per-frame,float,4,6.329101589974016,63.29101589974016,15800.030790848872,0,0,233094984,2330.94984,
|
||||
opt2,4 streams + host-side batching,3,3000,100000,4,False,auto,collect,float,0,4.077369901817292,40.77369901817292,24525.613914849815,196111,0,233093553,2330.93553,
|
||||
opt2,4 streams + host-side batching,3,3000,100000,4,False,auto,collect,float,1,4.201830665813759,42.01830665813759,23799.150406893714,308497,0,233094462,2330.94462,
|
||||
opt2,4 streams + host-side batching,3,3000,100000,4,False,auto,collect,float,2,3.986788455862552,39.86788455862552,25082.84578103223,60192,0,233094462,2330.94462,
|
||||
opt2,4 streams + host-side batching,3,3000,100000,4,False,auto,collect,float,3,3.9811147190630436,39.811147190630436,25118.5929210136,60024,0,233094462,2330.94462,
|
||||
opt2,4 streams + host-side batching,3,3000,100000,4,False,auto,collect,float,4,4.107359660090879,41.073596600908786,24346.54091085547,150292,0,233094462,2330.94462,
|
||||
opt3,"pipeline rework, no pinning",3,3000,100000,4,False,off,collect,float,0,3.4708277301397175,34.708277301397175,28811.571122250578,92259,0,233093554,2330.93554,
|
||||
opt3,"pipeline rework, no pinning",3,3000,100000,4,False,off,collect,float,1,3.4449734720401466,34.449734720401466,29027.79972374621,51811,0,233094465,2330.94465,
|
||||
opt3,"pipeline rework, no pinning",3,3000,100000,4,False,off,collect,float,2,3.448159068124369,34.48159068124369,29000.982270343793,51808,0,233094465,2330.94465,
|
||||
opt3,"pipeline rework, no pinning",3,3000,100000,4,False,off,collect,float,3,3.440851571969688,34.40851571969688,29062.573002169866,51803,0,233094465,2330.94465,
|
||||
opt3,"pipeline rework, no pinning",3,3000,100000,4,False,off,collect,float,4,3.413981437915936,34.13981437915936,29291.31332976578,3461,0,233094465,2330.94465,
|
||||
opt4,+ pinned input (DMA H2D),3,3000,100000,4,True,off,collect,float,0,2.558677193010226,25.58677193010226,39082.69486794942,92309,0,233093554,2330.93554,
|
||||
opt4,+ pinned input (DMA H2D),3,3000,100000,4,True,off,collect,float,1,2.524539733072743,25.245397330727428,39611.180877824816,51427,0,233094465,2330.94465,
|
||||
opt4,+ pinned input (DMA H2D),3,3000,100000,4,True,off,collect,float,2,2.490160754881799,24.901607548817992,40158.04995880546,951,0,233094465,2330.94465,
|
||||
opt4,+ pinned input (DMA H2D),3,3000,100000,4,True,off,collect,float,3,2.5080041729379445,25.080041729379445,39872.34195183068,26340,0,233094465,2330.94465,
|
||||
opt4,+ pinned input (DMA H2D),3,3000,100000,4,True,off,collect,float,4,2.5344345020130277,25.344345020130277,39456.5335661951,51751,0,233094465,2330.94465,
|
||||
opt5,CUDA Graphs,3,3000,100000,4,True,auto,collect,float,0,2.5488168040756136,25.488168040756136,39233.89073710508,151070,0,233093554,2330.93554,rejected
|
||||
opt5,CUDA Graphs,3,3000,100000,4,True,auto,collect,float,1,2.419733207905665,24.19733207905665,41326.87011662427,953,0,233094465,2330.94465,rejected
|
||||
opt5,CUDA Graphs,3,3000,100000,4,True,auto,collect,float,2,2.4206148399971426,24.206148399971426,41311.818116474096,46,0,233094465,2330.94465,rejected
|
||||
opt5,CUDA Graphs,3,3000,100000,4,True,auto,collect,float,3,2.4250832318793982,24.250832318793982,41235.698092927596,1094,0,233094465,2330.94465,rejected
|
||||
opt5,CUDA Graphs,3,3000,100000,4,True,auto,collect,float,4,2.4093486091587692,24.093486091587692,41504.994179698755,1026,0,233094465,2330.94465,rejected
|
||||
opt7,"host<->GPU overlap, chunked internally",3,3000,100000,4,True,auto,collect,float,0,1.994967577047646,19.94967577047646,50126.127938374855,95899,0,233093554,2330.93554,
|
||||
opt7,"host<->GPU overlap, chunked internally",3,3000,100000,4,True,auto,collect,float,1,1.9801588000264019,19.80158800026402,50501.000222137074,30450,0,233094465,2330.94465,
|
||||
opt7,"host<->GPU overlap, chunked internally",3,3000,100000,4,True,auto,collect,float,2,1.9894863478839397,19.894863478839397,50264.230315710454,67370,0,233094465,2330.94465,
|
||||
opt7,"host<->GPU overlap, chunked internally",3,3000,100000,4,True,auto,collect,float,3,1.980688118841499,19.80688118841499,50487.504341920234,57917,0,233094465,2330.94465,
|
||||
opt7,"host<->GPU overlap, chunked internally",3,3000,100000,4,True,auto,collect,float,4,2.0119610340334475,20.119610340334475,49702.7518468022,95398,0,233094465,2330.94465,
|
||||
opt8,zero-copy collection (collect_view),3,3000,100000,4,True,auto,collect_view,float,0,1.6354960668832064,16.354960668832064,61143.52826942089,1438,0,233093554,2330.93554,
|
||||
opt8,zero-copy collection (collect_view),3,3000,100000,4,True,auto,collect_view,float,1,1.6309949120040983,16.309949120040983,61312.26974652189,0,0,233094465,2330.94465,
|
||||
opt8,zero-copy collection (collect_view),3,3000,100000,4,True,auto,collect_view,float,2,1.631136188050732,16.31136188050732,61306.9593652408,0,0,233094465,2330.94465,
|
||||
opt8,zero-copy collection (collect_view),3,3000,100000,4,True,auto,collect_view,float,3,1.6322539390530437,16.322539390530437,61264.97697902034,0,0,233094465,2330.94465,
|
||||
opt8,zero-copy collection (collect_view),3,3000,100000,4,True,auto,collect_view,float,4,1.6319843179080635,16.319843179080635,61275.098604001054,0,0,233094465,2330.94465,
|
||||
|
@@ -0,0 +1,27 @@
|
||||
step,label,cluster_dim,cap,n_frames,n_streams,pinned,batch_chunk,collection,device_ped_type,rep,wall_s,us_per_frame,fps,minor_faults,major_faults,n_clusters,clusters_per_frame,notes
|
||||
cpu,"ClusterFinderMT, 48 threads",9,1500,20000,0,False,n/a,n/a,float,0,15.337030207971111,766.8515103985555,1304.0334229507748,2967117,0,28438072,1421.9036,reference
|
||||
opt3,"pipeline rework, no pinning",9,1500,20000,4,False,off,collect,float,0,1.8587155409622937,92.93577704811469,10760.11878054541,520359,0,28439289,1421.96445,
|
||||
opt3,"pipeline rework, no pinning",9,1500,20000,4,False,off,collect,float,1,1.6278529588598758,81.39264794299379,12286.12196890787,126986,0,28447972,1422.3986,
|
||||
opt3,"pipeline rework, no pinning",9,1500,20000,4,False,off,collect,float,2,1.8940563639625907,94.70281819812953,10559.347852857782,519312,0,28452043,1422.60215,
|
||||
opt3,"pipeline rework, no pinning",9,1500,20000,4,False,off,collect,float,3,1.7711672731675208,88.55836365837604,11291.988228888395,356562,0,28453565,1422.67825,
|
||||
opt3,"pipeline rework, no pinning",9,1500,20000,4,False,off,collect,float,4,1.7310039640869945,86.55019820434973,11553.988560938307,355909,0,28454173,1422.70865,
|
||||
opt4,+ pinned input (DMA H2D),9,1500,20000,4,True,off,collect,float,0,1.7293723039329052,86.46861519664526,11564.889731676854,520357,0,28439288,1421.9644,
|
||||
opt4,+ pinned input (DMA H2D),9,1500,20000,4,True,off,collect,float,1,1.4968323530629277,74.84161765314639,13361.549781493257,126986,0,28447972,1422.3986,
|
||||
opt4,+ pinned input (DMA H2D),9,1500,20000,4,True,off,collect,float,2,1.7610446740873158,88.05223370436579,11356.895310089314,519296,0,28452045,1422.60225,
|
||||
opt4,+ pinned input (DMA H2D),9,1500,20000,4,True,off,collect,float,3,1.6390001631807536,81.95000815903768,12202.561323231756,355738,0,28453565,1422.67825,
|
||||
opt4,+ pinned input (DMA H2D),9,1500,20000,4,True,off,collect,float,4,1.731389197986573,86.56945989932865,11551.417799797951,482775,0,28454174,1422.7087,
|
||||
opt5,CUDA Graphs,9,1500,20000,4,True,auto,collect,float,0,2.0218078319448978,101.09039159724489,9892.136969694497,760653,0,28439289,1421.96445,rejected
|
||||
opt5,CUDA Graphs,9,1500,20000,4,True,auto,collect,float,1,1.9103619859088212,95.51809929544106,10469.220047050585,683396,0,28447973,1422.39865,rejected
|
||||
opt5,CUDA Graphs,9,1500,20000,4,True,auto,collect,float,2,1.7221080309245735,86.10540154622868,11613.673266050739,484224,0,28452044,1422.6022,rejected
|
||||
opt5,CUDA Graphs,9,1500,20000,4,True,auto,collect,float,3,1.630410891957581,81.52054459787905,12266.846411941382,420603,0,28453565,1422.67825,rejected
|
||||
opt5,CUDA Graphs,9,1500,20000,4,True,auto,collect,float,4,1.4879669798538089,74.39834899269044,13441.158487243432,191861,0,28454174,1422.7087,rejected
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1500,20000,4,True,auto,collect,float,0,1.5269549458753318,76.34774729376659,13097.963403586173,465095,0,28439289,1421.96445,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1500,20000,4,True,auto,collect,float,1,1.2762275428976864,63.81137714488432,15671.186624439883,130398,0,28447972,1422.3986,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1500,20000,4,True,auto,collect,float,2,1.2400420890189707,62.002104450948536,16128.484812819956,53083,0,28452043,1422.60215,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1500,20000,4,True,auto,collect,float,3,1.2918187589384615,64.59093794692308,15482.047974310877,130484,0,28453565,1422.67825,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1500,20000,4,True,auto,collect,float,4,1.3914556668605655,69.57278334302828,14373.436737028398,244033,0,28454174,1422.7087,
|
||||
opt8,zero-copy collection (collect_view),9,1500,20000,4,True,auto,collect_view,float,0,0.47659120289608836,23.829560144804418,41964.68562253471,1445,0,28439289,1421.96445,
|
||||
opt8,zero-copy collection (collect_view),9,1500,20000,4,True,auto,collect_view,float,1,0.4731572079472244,23.65786039736122,42269.24934055065,0,0,28447971,1422.39855,
|
||||
opt8,zero-copy collection (collect_view),9,1500,20000,4,True,auto,collect_view,float,2,0.4732611121144146,23.66305560572073,42259.96915454326,0,0,28452042,1422.6021,
|
||||
opt8,zero-copy collection (collect_view),9,1500,20000,4,True,auto,collect_view,float,3,0.473247610963881,23.66238054819405,42261.174777544584,0,0,28453565,1422.67825,
|
||||
opt8,zero-copy collection (collect_view),9,1500,20000,4,True,auto,collect_view,float,4,0.4731025758665055,23.655128793325275,42274.13043222018,0,0,28454174,1422.7087,
|
||||
|
@@ -0,0 +1,7 @@
|
||||
artifact,kind,config,build,cites,produced_by,timestamp
|
||||
probe_3x3_s4.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,3x3 cap=3000 N=20000 streams=4 batch=2000,float,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-18 11:10:35
|
||||
probe_3x3_s1_uncontended.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,3x3 cap=3000 N=20000 streams=1 batch=2000,float,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-18 11:10:35
|
||||
probe_9x9_s4.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1500 N=20000 streams=4 batch=2000,float,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-18 11:10:35
|
||||
probe_9x9_s1_uncontended.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1500 N=20000 streams=1 batch=2000,float,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-18 11:10:35
|
||||
ladder_3x3.csv,end-to-end wall/FPS,3x3 cap=3000 N=100000 batch=2000 streams=4 consumer=streaming,float,"docs/benchmark_opt1_opt6_results.md §4, §6, §10, §10.3",perf/run_ladder.py,2026-08-13 17:57:20
|
||||
ladder_9x9.csv,end-to-end wall/FPS,9x9 cap=1500 N=20000 batch=2000 streams=4 consumer=streaming,float,"docs/benchmark_opt1_opt6_results.md §4, §6, §10, §10.3",perf/run_ladder.py,2026-08-13 17:57:20
|
||||
|
@@ -0,0 +1,5 @@
|
||||
n_frames,window_ms,window_us_per_frame,window_fps,kernel_n,kernel_sum_ms,kernel_busy_ms,kernel_overlap,kernel_duty_pct,kernel_us_per_frame,H2D_n,H2D_sum_ms,H2D_busy_ms,H2D_overlap,H2D_duty_pct,H2D_us_per_frame,D2H_n,D2H_sum_ms,D2H_busy_ms,D2H_overlap,D2H_duty_pct,D2H_us_per_frame,bottleneck,roofline_us_per_frame,roofline_fps,label,cluster_dim,cap,n_streams,batch,device_ped_type
|
||||
20000,457.218964,22.8609482,43742.71754834736,20000,110.724674,110.663005,1.0005572684385355,24.203502853831758,5.53315025,19999,332.555825,332.555825,1.0,72.73447761016317,16.62779125,20000,151.466742,151.466742,1.0,33.12783456637201,7.5733371,H2D,16.62779125,60140.27870358307,3x3_s4,3,3000,4,2000,float
|
||||
20000,860.686761,43.03433805,23237.257625251194,20000,86.459044,86.459044,1.0,10.045355397304641,4.3229522,19999,263.014103,263.014103,1.0,30.558632352426763,13.15070515,20000,105.444691,105.444691,1.0,12.251227249910029,5.27223455,H2D,13.15070515,76041.54975674441,3x3_s1_uncontended,3,3000,1,2000,float
|
||||
20000,1395.476764,69.7738382,14332.019361377197,20000,502.711249,485.060864,1.0363879799628608,34.75950847147176,24.2530432,19999,394.701491,394.701491,1.0,28.28434705488224,19.73507455,20000,456.813167,456.813167,1.0,32.735275769880175,22.84065835,kernel,24.2530432,41231.939091255976,9x9_s4,9,1500,4,2000,float
|
||||
20000,1791.736919,89.58684595,11162.353015063367,20000,473.307274,473.307274,1.0,26.41611438492662,23.6653637,19999,264.749131,264.749131,1.0,14.776116303266283,13.23745655,20000,388.783565,388.783565,1.0,21.698696994924173,19.43917825,kernel,23.6653637,42255.847519469986,9x9_s1_uncontended,9,1500,1,2000,float
|
||||
|
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"timestamp": "2026-08-18 11:44:48",
|
||||
"host": "pc-moench-04.psi.ch",
|
||||
"git_rev": "7177f00",
|
||||
"git_branch": "bench/opt2-pipeline",
|
||||
"git_dirty": true,
|
||||
"aare_version": "2026.7.2",
|
||||
"device_ped_type": "double",
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"driver": "595.71.05",
|
||||
"gpu_busy_pct": "0 %",
|
||||
"nvcc": "Build cuda_12.4.r12.4/compiler.34097967_0",
|
||||
"python": "3.11.15"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
step,label,cluster_dim,cap,n_frames,n_streams,pinned,batch_chunk,collection,device_ped_type,rep,wall_s,us_per_frame,fps,minor_faults,major_faults,n_clusters,clusters_per_frame,notes
|
||||
cpu,"ClusterFinderMT, 48 threads",3,3000,100000,0,False,n/a,n/a,double,0,20.115794302197173,201.15794302197173,4971.218063662412,2680160,1,233085343,2330.85343,reference
|
||||
opt1,"1 stream, one launch per frame",3,3000,100000,1,False,auto,per-frame,double,0,6.398641797946766,63.98641797946766,15628.316626832993,795,0,233094770,2330.9477,
|
||||
opt1,"1 stream, one launch per frame",3,3000,100000,1,False,auto,per-frame,double,1,6.403091112151742,64.03091112151742,15617.45698264713,0,0,233094984,2330.94984,
|
||||
opt1,"1 stream, one launch per frame",3,3000,100000,1,False,auto,per-frame,double,2,6.361006764927879,63.61006764927879,15720.781897507351,0,0,233094984,2330.94984,
|
||||
opt1,"1 stream, one launch per frame",3,3000,100000,1,False,auto,per-frame,double,3,6.333786997012794,63.33786997012794,15788.342747737339,0,0,233094984,2330.94984,
|
||||
opt1,"1 stream, one launch per frame",3,3000,100000,1,False,auto,per-frame,double,4,6.3262022321578115,63.262022321578115,15807.272093148196,0,0,233094984,2330.94984,
|
||||
opt2,4 streams + host-side batching,3,3000,100000,4,False,auto,collect,double,0,4.187054253881797,41.87054253881797,23883.139299494505,196127,0,233093553,2330.93553,
|
||||
opt2,4 streams + host-side batching,3,3000,100000,4,False,auto,collect,double,1,4.259959110058844,42.59959110058844,23474.403724644828,308518,0,233094462,2330.94462,
|
||||
opt2,4 streams + host-side batching,3,3000,100000,4,False,auto,collect,double,2,4.044346384936944,40.44346384936944,24725.874216028387,60192,0,233094462,2330.94462,
|
||||
opt2,4 streams + host-side batching,3,3000,100000,4,False,auto,collect,double,3,4.052164989057928,40.52164989057928,24678.165935994773,60024,0,233094462,2330.94462,
|
||||
opt2,4 streams + host-side batching,3,3000,100000,4,False,auto,collect,double,4,4.14804248791188,41.4804248791188,24107.756921829383,150310,0,233094462,2330.94462,
|
||||
opt3,"pipeline rework, no pinning",3,3000,100000,4,False,off,collect,double,0,3.460056332172826,34.60056332172826,28901.26356330233,92408,0,233093484,2330.93484,
|
||||
opt3,"pipeline rework, no pinning",3,3000,100000,4,False,off,collect,double,1,3.4260347769595683,34.26034776959568,29188.26179830694,1274,0,233094390,2330.9439,
|
||||
opt3,"pipeline rework, no pinning",3,3000,100000,4,False,off,collect,double,2,3.498826839029789,34.98826839029789,28581.00860679622,91720,0,233094390,2330.9439,
|
||||
opt3,"pipeline rework, no pinning",3,3000,100000,4,False,off,collect,double,3,3.483933220151812,34.83933220151812,28703.190813640944,91721,0,233094390,2330.9439,
|
||||
opt3,"pipeline rework, no pinning",3,3000,100000,4,False,off,collect,double,4,3.438609245000407,34.38609245000407,29081.524789533963,60308,0,233094390,2330.9439,
|
||||
opt4,+ pinned input (DMA H2D),3,3000,100000,4,True,off,collect,double,0,2.694697377970442,26.94697377970442,37109.918470814235,142473,0,233093484,2330.93484,
|
||||
opt4,+ pinned input (DMA H2D),3,3000,100000,4,True,off,collect,double,1,2.6183871990069747,26.183871990069747,38191.44855196553,46349,0,233094390,2330.9439,
|
||||
opt4,+ pinned input (DMA H2D),3,3000,100000,4,True,off,collect,double,2,2.5983168808743358,25.983168808743358,38486.45280184221,1010,0,233094390,2330.9439,
|
||||
opt4,+ pinned input (DMA H2D),3,3000,100000,4,True,off,collect,double,3,2.680369977839291,26.80369977839291,37308.28237399239,91787,0,233094390,2330.9439,
|
||||
opt4,+ pinned input (DMA H2D),3,3000,100000,4,True,off,collect,double,4,2.6633800519630313,26.633800519630313,37546.2750523702,91845,0,233094390,2330.9439,
|
||||
opt5,CUDA Graphs,3,3000,100000,4,True,auto,collect,double,0,2.608076024800539,26.08076024800539,38342.440576534886,151236,0,233093484,2330.93484,rejected
|
||||
opt5,CUDA Graphs,3,3000,100000,4,True,auto,collect,double,1,2.5156038589775562,25.156038589775562,39751.886865304805,26430,0,233094390,2330.9439,rejected
|
||||
opt5,CUDA Graphs,3,3000,100000,4,True,auto,collect,double,2,2.524822043022141,25.24822043022141,39606.751801130034,51870,0,233094390,2330.9439,rejected
|
||||
opt5,CUDA Graphs,3,3000,100000,4,True,auto,collect,double,3,2.525553680025041,25.25553680025041,39595.27797445528,51852,0,233094390,2330.9439,rejected
|
||||
opt5,CUDA Graphs,3,3000,100000,4,True,auto,collect,double,4,2.5333995749242604,25.333995749242604,39472.6520797611,51964,0,233094390,2330.9439,rejected
|
||||
opt7,"host<->GPU overlap, chunked internally",3,3000,100000,4,True,auto,collect,double,0,2.012347515905276,20.12347515905276,49693.20617319615,95888,0,233093484,2330.93484,
|
||||
opt7,"host<->GPU overlap, chunked internally",3,3000,100000,4,True,auto,collect,double,1,2.007254082011059,20.07254082011059,49819.30334390474,40011,0,233094390,2330.9439,
|
||||
opt7,"host<->GPU overlap, chunked internally",3,3000,100000,4,True,auto,collect,double,2,1.9837507978081703,19.837507978081703,50409.557546486765,30795,0,233094390,2330.9439,
|
||||
opt7,"host<->GPU overlap, chunked internally",3,3000,100000,4,True,auto,collect,double,3,1.9911192271392792,19.911192271392792,50223.00957018732,33910,0,233094390,2330.9439,
|
||||
opt7,"host<->GPU overlap, chunked internally",3,3000,100000,4,True,auto,collect,double,4,1.9873879398219287,19.873879398219287,50317.30242307904,39200,0,233094390,2330.9439,
|
||||
opt8,zero-copy collection (collect_view),3,3000,100000,4,True,auto,collect_view,double,0,1.715774823911488,17.15774823911488,58282.706219005995,2062,0,233093484,2330.93484,
|
||||
opt8,zero-copy collection (collect_view),3,3000,100000,4,True,auto,collect_view,double,1,1.712581568164751,17.12581568164751,58391.37934151815,0,0,233094390,2330.9439,
|
||||
opt8,zero-copy collection (collect_view),3,3000,100000,4,True,auto,collect_view,double,2,1.7117952490225434,17.117952490225434,58418.201626100585,0,0,233094390,2330.9439,
|
||||
opt8,zero-copy collection (collect_view),3,3000,100000,4,True,auto,collect_view,double,3,1.7099325810559094,17.099325810559094,58481.83788523901,0,0,233094390,2330.9439,
|
||||
opt8,zero-copy collection (collect_view),3,3000,100000,4,True,auto,collect_view,double,4,1.7095515080727637,17.095515080727637,58494.873964185754,0,0,233094390,2330.9439,
|
||||
|
@@ -0,0 +1,27 @@
|
||||
step,label,cluster_dim,cap,n_frames,n_streams,pinned,batch_chunk,collection,device_ped_type,rep,wall_s,us_per_frame,fps,minor_faults,major_faults,n_clusters,clusters_per_frame,notes
|
||||
cpu,"ClusterFinderMT, 48 threads",9,1500,20000,0,False,n/a,n/a,double,0,15.480465562082827,774.0232781041414,1291.9508085717475,2968664,0,28438072,1421.9036,reference
|
||||
opt3,"pipeline rework, no pinning",9,1500,20000,4,False,off,collect,double,0,1.9789065518416464,98.94532759208232,10106.591431205901,520482,0,28439276,1421.9638,
|
||||
opt3,"pipeline rework, no pinning",9,1500,20000,4,False,off,collect,double,1,2.038018618011847,101.90093090059236,9813.453038770884,683444,0,28447962,1422.3981,
|
||||
opt3,"pipeline rework, no pinning",9,1500,20000,4,False,off,collect,double,2,1.6433628669474274,82.16814334737137,12170.166676061215,191845,0,28452026,1422.6013,
|
||||
opt3,"pipeline rework, no pinning",9,1500,20000,4,False,off,collect,double,3,1.6958885919302702,84.79442959651351,11793.227512212865,191546,0,28453556,1422.6778,
|
||||
opt3,"pipeline rework, no pinning",9,1500,20000,4,False,off,collect,double,4,1.901584326988086,95.0792163494043,10517.545667658052,519380,0,28454157,1422.70785,
|
||||
opt4,+ pinned input (DMA H2D),9,1500,20000,4,True,off,collect,double,0,1.9237553810235113,96.18776905117556,10396.332193420161,520481,0,28439276,1421.9638,
|
||||
opt4,+ pinned input (DMA H2D),9,1500,20000,4,True,off,collect,double,1,2.005056690890342,100.2528345445171,9974.780309637546,683443,0,28447962,1422.3981,
|
||||
opt4,+ pinned input (DMA H2D),9,1500,20000,4,True,off,collect,double,2,1.6088325418531895,80.44162709265947,12431.374602208323,191845,0,28452027,1422.60135,
|
||||
opt4,+ pinned input (DMA H2D),9,1500,20000,4,True,off,collect,double,3,1.6616083378903568,83.08041689451784,12036.530838183435,191546,0,28453556,1422.6778,
|
||||
opt4,+ pinned input (DMA H2D),9,1500,20000,4,True,off,collect,double,4,1.8773560170084238,93.86780085042119,10653.280368137153,519380,0,28454157,1422.70785,
|
||||
opt5,CUDA Graphs,9,1500,20000,4,True,auto,collect,double,0,2.122308956924826,106.1154478462413,9423.698625378047,760811,0,28439276,1421.9638,rejected
|
||||
opt5,CUDA Graphs,9,1500,20000,4,True,auto,collect,double,1,1.9905071242246777,99.52535621123388,10047.690740011894,519681,0,28447962,1422.3981,rejected
|
||||
opt5,CUDA Graphs,9,1500,20000,4,True,auto,collect,double,2,1.896492162020877,94.82460810104385,10545.785740916674,519307,0,28452027,1422.60135,rejected
|
||||
opt5,CUDA Graphs,9,1500,20000,4,True,auto,collect,double,3,1.8956730319187045,94.78365159593523,10550.342629370536,519388,0,28453556,1422.6778,rejected
|
||||
opt5,CUDA Graphs,9,1500,20000,4,True,auto,collect,double,4,1.9486719449050725,97.43359724525362,10263.400185080553,582642,0,28454157,1422.70785,rejected
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1500,20000,4,True,auto,collect,double,0,1.535099176922813,76.75495884614065,13028.474186333062,464904,0,28439276,1421.9638,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1500,20000,4,True,auto,collect,double,1,1.2591699638869613,62.95849819434807,15883.4792550654,48608,0,28447962,1422.3981,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1500,20000,4,True,auto,collect,double,2,1.309328624047339,65.46643120236695,15275.00402318929,129226,0,28452027,1422.60135,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1500,20000,4,True,auto,collect,double,3,1.3087106021121144,65.43553010560572,15282.217449543243,130365,0,28453556,1422.6778,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1500,20000,4,True,auto,collect,double,4,1.4137493839953095,70.68746919976547,14146.77893155238,244576,0,28454157,1422.70785,
|
||||
opt8,zero-copy collection (collect_view),9,1500,20000,4,True,auto,collect_view,double,0,0.6129775650333613,30.648878251668066,32627.62153279052,2066,0,28439276,1421.9638,
|
||||
opt8,zero-copy collection (collect_view),9,1500,20000,4,True,auto,collect_view,double,1,0.6005836641415954,30.029183207079768,33300.93905998206,0,0,28447962,1422.3981,
|
||||
opt8,zero-copy collection (collect_view),9,1500,20000,4,True,auto,collect_view,double,2,0.6006644780281931,30.033223901409656,33296.45872460144,0,0,28452027,1422.60135,
|
||||
opt8,zero-copy collection (collect_view),9,1500,20000,4,True,auto,collect_view,double,3,0.6017205759417266,30.08602879708633,33238.01910662416,0,0,28453556,1422.6778,
|
||||
opt8,zero-copy collection (collect_view),9,1500,20000,4,True,auto,collect_view,double,4,0.601737436838448,30.086871841922402,33237.08776552242,0,0,28454157,1422.70785,
|
||||
|
@@ -0,0 +1,7 @@
|
||||
artifact,kind,config,build,cites,produced_by,timestamp
|
||||
ladder_3x3.csv,end-to-end wall/FPS,3x3 cap=3000 N=100000 batch=2000 streams=4 consumer=streaming,double,"docs/benchmark_opt1_opt6_results.md §4, §6, §10, §10.3",perf/run_ladder.py,2026-08-18 11:36:53
|
||||
ladder_9x9.csv,end-to-end wall/FPS,9x9 cap=1500 N=20000 batch=2000 streams=4 consumer=streaming,double,"docs/benchmark_opt1_opt6_results.md §4, §6, §10, §10.3",perf/run_ladder.py,2026-08-18 11:36:53
|
||||
probe_3x3_s4.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,3x3 cap=3000 N=20000 streams=4 batch=2000,double,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-18 11:44:48
|
||||
probe_3x3_s1_uncontended.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,3x3 cap=3000 N=20000 streams=1 batch=2000,double,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-18 11:44:48
|
||||
probe_9x9_s4.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1500 N=20000 streams=4 batch=2000,double,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-18 11:44:48
|
||||
probe_9x9_s1_uncontended.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1500 N=20000 streams=1 batch=2000,double,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-18 11:44:48
|
||||
|
@@ -0,0 +1,5 @@
|
||||
n_frames,window_ms,window_us_per_frame,window_fps,kernel_n,kernel_sum_ms,kernel_busy_ms,kernel_overlap,kernel_duty_pct,kernel_us_per_frame,H2D_n,H2D_sum_ms,H2D_busy_ms,H2D_overlap,H2D_duty_pct,H2D_us_per_frame,D2H_n,D2H_sum_ms,D2H_busy_ms,D2H_overlap,D2H_duty_pct,D2H_us_per_frame,bottleneck,roofline_us_per_frame,roofline_fps,label,cluster_dim,cap,n_streams,batch,device_ped_type
|
||||
20000,465.941344,23.2970672,42923.85781503004,20000,320.923507,303.431619,1.0576468861671269,65.12227835270184,15.171580950000001,19999,323.314466,323.314466,1.0,69.38952084063182,16.1657233,20000,153.775667,153.775667,1.0,33.003224328597035,7.68878335,H2D,16.1657233,61859.279751497415,3x3_s4,3,3000,4,2000,double
|
||||
20000,1081.033554,54.0516777,18500.81334293163,20000,294.470605,294.470605,1.0,27.239728490425748,14.72353025,19999,262.833965,262.833965,1.0,24.313210633238125,13.141698250000001,20000,106.121461,106.121461,1.0,9.816666708200993,5.30607305,kernel,14.72353025,67918.49393592274,3x3_s1_uncontended,3,3000,1,2000,double
|
||||
20000,1380.430047,69.02150235,14488.238678565942,20000,873.141638,641.583455,1.3609166994494895,46.47707114129486,32.07917275,19999,401.770313,401.770313,1.0,29.104720943530722,20.08851565,20000,450.35086,450.35086,1.0,32.62395374388718,22.517543,kernel,32.07917275,31172.87368328412,9x9_s4,9,1500,4,2000,double
|
||||
20000,2090.896126,104.54480629999999,9565.276701842242,20000,798.56177,798.56177,1.0,38.192321467814516,39.9280885,19999,264.869784,264.869784,1.0,12.667763869585935,13.243489199999999,20000,388.851,388.851,1.0,18.597337053940286,19.44255,kernel,39.9280885,25045.025634022022,9x9_s1_uncontended,9,1500,1,2000,double
|
||||
|
@@ -0,0 +1,11 @@
|
||||
step,label,cluster_dim,cap,n_frames,n_streams,pinned,batch_chunk,collection,device_ped_type,rep,wall_s,us_per_frame,fps,minor_faults,major_faults,n_clusters,clusters_per_frame,notes
|
||||
cpu,"ClusterFinderMT, 8 threads",3,3000,100000,0,False,n/a,n/a,float,0,26.28347669984214,262.8347669984214,3804.6717008560972,2476464,0,233091568,2330.91568,retain threads=8 loop_s=25.833 loop_fps=3871.0
|
||||
cpu,"ClusterFinderMT, 16 threads",3,3000,100000,0,False,n/a,n/a,float,0,15.164910248946398,151.64910248946398,6594.170249503958,2597376,0,233089481,2330.89481,retain threads=16 loop_s=14.680 loop_fps=6811.8
|
||||
cpu,"ClusterFinderMT, 24 threads",3,3000,100000,0,False,n/a,n/a,float,0,14.78826567903161,147.8826567903161,6762.118166553549,2476158,0,233087992,2330.87992,retain threads=24 loop_s=14.494 loop_fps=6899.3
|
||||
cpu,"ClusterFinderMT, 32 threads",3,3000,100000,0,False,n/a,n/a,float,0,16.830063453875482,168.30063453875482,5941.748245576155,2393978,0,233086801,2330.86801,retain threads=32 loop_s=16.783 loop_fps=5958.3
|
||||
cpu,"ClusterFinderMT, 48 threads",3,3000,100000,0,False,n/a,n/a,float,0,19.52668195287697,195.2668195287697,5121.197766283404,2444826,0,233085343,2330.85343,retain threads=48 loop_s=19.479 loop_fps=5133.8
|
||||
cpu,"ClusterFinderMT, 8 threads",9,1500,20000,0,False,n/a,n/a,float,0,27.1391425980255,1356.957129901275,736.9429571240431,2373525,1,28440010,1422.0005,retain threads=8 loop_s=24.983 loop_fps=800.5
|
||||
cpu,"ClusterFinderMT, 16 threads",9,1500,20000,0,False,n/a,n/a,float,0,16.167955602984875,808.3977801492438,1237.0147773233411,2521827,0,28438925,1421.94625,retain threads=16 loop_s=14.145 loop_fps=1413.9
|
||||
cpu,"ClusterFinderMT, 24 threads",9,1500,20000,0,False,n/a,n/a,float,0,14.831497456878424,741.5748728439212,1348.4815041871968,2662966,0,28438530,1421.9265,retain threads=24 loop_s=12.445 loop_fps=1607.0
|
||||
cpu,"ClusterFinderMT, 32 threads",9,1500,20000,0,False,n/a,n/a,float,0,13.304477212950587,665.2238606475294,1503.253354482203,2784433,0,28438219,1421.91095,retain threads=32 loop_s=9.328 loop_fps=2144.0
|
||||
cpu,"ClusterFinderMT, 48 threads",9,1500,20000,0,False,n/a,n/a,float,0,14.94528856384568,747.264428192284,1338.21437535721,3021388,0,28438072,1421.9036,retain threads=48 loop_s=8.166 loop_fps=2449.2
|
||||
|
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"timestamp": "2026-08-19 17:43:09",
|
||||
"host": "pc-moench-04.psi.ch",
|
||||
"git_rev": "7177f00",
|
||||
"git_branch": "bench/opt2-pipeline",
|
||||
"git_dirty": true,
|
||||
"aare_version": "2026.7.2",
|
||||
"device_ped_type": "float",
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"driver": "595.71.05",
|
||||
"gpu_busy_pct": "0 %",
|
||||
"nvcc": "Build cuda_12.4.r12.4/compiler.34097967_0",
|
||||
"python": "3.11.15"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
artifact,kind,config,build,cites,produced_by,timestamp
|
||||
cpu_threads.csv,ladder,"ClusterFinderMT thread sweep, 3x3 + 9x9",float,CPU baseline for every speedup in deck + report,cpu_threads.py,2026-08-19 17:43:09
|
||||
|
@@ -0,0 +1,32 @@
|
||||
# INVALID — do not cite these numbers
|
||||
|
||||
Probes run 2026-08-20 for 9x9 at cap 1700, discarded.
|
||||
|
||||
`env.json` in this directory says `"device_ped_type": "float"`. **It is wrong.**
|
||||
`common.device_ped_type()` parses `include/aare/clusterfinder_kernel.cuh` — the
|
||||
*source* — and the tree had not been rebuilt after that header was edited:
|
||||
|
||||
header edited : 2026-08-20 10:54:29
|
||||
binary built : 2026-08-20 09:57:53 <- 57 minutes EARLIER
|
||||
|
||||
So the header read `float/float` while the loaded module was still the
|
||||
`COMPUTE_TYPE = double; DEVICE_PED_TYPE = double` build used for the 9x9
|
||||
validation study. The measurement is a full-f64 kernel labelled f32.
|
||||
|
||||
The giveaway is the kernel time, which the cap cannot affect:
|
||||
|
||||
9x9 s4 kernel 80.73 us (the genuine f32 build reads 24.25)
|
||||
9x9 s1 kernel 87.92 us (the genuine f32 build reads 23.67)
|
||||
|
||||
Re-run after `make install`, and only once the guard in common.py confirms the
|
||||
binary is newer than the header.
|
||||
|
||||
One number here is still worth reading, because the cluster payload type is
|
||||
`int32` regardless of COMPUTE_TYPE, so the D2H byte count is build-independent:
|
||||
|
||||
cap 1500 -> 492 004 B/frame -> D2H 22.84 us (2026-08-18_f32)
|
||||
cap 1700 -> 557 600 B/frame -> D2H 22.70 us (here)
|
||||
|
||||
13.3 % more bytes, no more time. D2H is not bandwidth-bound at this size, which
|
||||
contradicts the linear extrapolation used to predict "cap 1700 makes D2H
|
||||
overtake the kernel". To be confirmed on a clean build.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"timestamp": "2026-08-20 11:12:32",
|
||||
"host": "pc-moench-04.psi.ch",
|
||||
"git_rev": "7177f00",
|
||||
"git_branch": "bench/opt2-pipeline",
|
||||
"git_dirty": true,
|
||||
"aare_version": "2026.7.2",
|
||||
"device_ped_type": "float",
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"driver": "595.71.05",
|
||||
"gpu_busy_pct": "0 %",
|
||||
"nvcc": "Build cuda_12.4.r12.4/compiler.34097967_0",
|
||||
"python": "3.11.15"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
artifact,kind,config,build,cites,produced_by,timestamp
|
||||
probe_9x9_s4_cap1700.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1700 N=20000 streams=4 batch=2000,float,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-20 11:12:32
|
||||
probe_9x9_s1_uncontended_cap1700.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1700 N=20000 streams=1 batch=2000,float,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-20 11:12:32
|
||||
|
@@ -0,0 +1,3 @@
|
||||
n_frames,window_ms,window_us_per_frame,window_fps,kernel_n,kernel_sum_ms,kernel_busy_ms,kernel_overlap,kernel_duty_pct,kernel_us_per_frame,H2D_n,H2D_sum_ms,H2D_busy_ms,H2D_overlap,H2D_duty_pct,H2D_us_per_frame,D2H_n,D2H_sum_ms,D2H_busy_ms,D2H_overlap,D2H_duty_pct,D2H_us_per_frame,bottleneck,roofline_us_per_frame,roofline_fps,label,cluster_dim,cap,n_streams,batch,device_ped_type
|
||||
20000,1819.694496,90.9847248,10990.855906836792,20000,1961.014812,1614.614064,1.214540896009438,88.7299526128808,80.7307032,19999,295.218095,295.218095,1.0,16.223497716179278,14.760904749999998,20000,453.962236,453.962236,1.0,24.94716761510719,22.6981118,kernel,80.7307032,12386.861012750353,9x9_s4,9,1700,4,2000,float
|
||||
20000,2957.254181,147.86270905,6763.030424810142,20000,1758.386908,1758.386908,1.0,59.460120786959166,87.9193454,19999,263.332636,263.332636,1.0,8.904633145567272,13.1666318,20000,438.000176,438.000176,1.0,14.811042581800987,21.9000088,kernel,87.9193454,11374.061026619063,9x9_s1_uncontended,9,1700,1,2000,float
|
||||
|
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"timestamp": "2026-08-20 11:26:21",
|
||||
"host": "pc-moench-04.psi.ch",
|
||||
"git_rev": "7177f00",
|
||||
"git_branch": "bench/opt2-pipeline",
|
||||
"git_dirty": true,
|
||||
"aare_version": "2026.7.2",
|
||||
"device_ped_type": "float",
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"driver": "595.71.05",
|
||||
"gpu_busy_pct": "0 %",
|
||||
"nvcc": "Build cuda_12.4.r12.4/compiler.34097967_0",
|
||||
"python": "3.11.15"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"timestamp": "2026-08-20 11:25:16",
|
||||
"host": "pc-moench-04.psi.ch",
|
||||
"git_rev": "7177f00",
|
||||
"git_branch": "bench/opt2-pipeline",
|
||||
"git_dirty": true,
|
||||
"aare_version": "2026.7.2",
|
||||
"device_ped_type": "float",
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"driver": "595.71.05",
|
||||
"gpu_busy_pct": "0 %",
|
||||
"nvcc": "Build cuda_12.4.r12.4/compiler.34097967_0",
|
||||
"python": "3.11.15"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
step,label,cluster_dim,cap,n_frames,n_streams,pinned,batch_chunk,collection,device_ped_type,rep,wall_s,us_per_frame,fps,minor_faults,major_faults,n_clusters,clusters_per_frame,notes
|
||||
cpu,"ClusterFinderMT, 32 threads",9,1700,20000,0,False,n/a,n/a,float,0,13.512256105896086,675.6128052948043,1480.1377240972352,2777514,0,28438219,1421.91095,reference
|
||||
opt3,"pipeline rework, no pinning",9,1700,20000,4,False,off,collect,float,0,2.034773570019752,101.73867850098759,9829.103490766227,684506,0,28442005,1422.10025,
|
||||
opt3,"pipeline rework, no pinning",9,1700,20000,4,False,off,collect,float,1,1.9132814179174602,95.66407089587301,10453.245305528184,520617,0,28452049,1422.60245,
|
||||
opt3,"pipeline rework, no pinning",9,1700,20000,4,False,off,collect,float,2,1.9234019841533154,96.17009920766577,10398.242366794704,520181,0,28456746,1422.8373,
|
||||
opt3,"pipeline rework, no pinning",9,1700,20000,4,False,off,collect,float,3,1.9693005678709596,98.46502839354798,10155.89002831716,585114,0,28458528,1422.9264,
|
||||
opt3,"pipeline rework, no pinning",9,1700,20000,4,False,off,collect,float,4,1.9842164178844541,99.2108208942227,10079.545668372073,584154,0,28459216,1422.9608,
|
||||
opt4,+ pinned input (DMA H2D),9,1700,20000,4,True,off,collect,float,0,1.9024269040673971,95.12134520336986,10512.887489784713,684506,0,28442005,1422.10025,
|
||||
opt4,+ pinned input (DMA H2D),9,1700,20000,4,True,off,collect,float,1,1.7872322199400514,89.36161099700257,11190.487602484502,520617,0,28452049,1422.60245,
|
||||
opt4,+ pinned input (DMA H2D),9,1700,20000,4,True,off,collect,float,2,1.632499601924792,81.6249800962396,12251.15153254499,355879,0,28456746,1422.8373,
|
||||
opt4,+ pinned input (DMA H2D),9,1700,20000,4,True,off,collect,float,3,1.5366189870983362,76.83094935491681,13015.588228391516,256027,0,28458528,1422.9264,
|
||||
opt4,+ pinned input (DMA H2D),9,1700,20000,4,True,off,collect,float,4,1.5039158621802926,75.19579310901463,13298.616300918009,127010,0,28459216,1422.9608,
|
||||
opt5,CUDA Graphs,9,1700,20000,4,True,auto,collect,float,0,2.0413262380752712,102.06631190376356,9797.552016407544,792580,0,28442005,1422.10025,rejected
|
||||
opt5,CUDA Graphs,9,1700,20000,4,True,auto,collect,float,1,1.603434408083558,80.1717204041779,12473.226157036392,255982,0,28452049,1422.60245,rejected
|
||||
opt5,CUDA Graphs,9,1700,20000,4,True,auto,collect,float,2,1.7725593589711934,88.62796794855967,11283.120025728305,519534,0,28456746,1422.8373,rejected
|
||||
opt5,CUDA Graphs,9,1700,20000,4,True,auto,collect,float,3,1.648476961068809,82.42384805344045,12132.410990465265,420630,0,28458528,1422.9264,rejected
|
||||
opt5,CUDA Graphs,9,1700,20000,4,True,auto,collect,float,4,1.5012192442081869,75.06096221040934,13322.504409107101,127494,0,28459216,1422.9608,rejected
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1700,20000,4,True,auto,collect,float,0,1.4985969089902937,74.92984544951469,13345.816930501582,460008,0,28442005,1422.10025,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1700,20000,4,True,auto,collect,float,1,1.5564289251342416,77.82144625671208,12849.928240876792,413394,0,28452048,1422.6024,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1700,20000,4,True,auto,collect,float,2,1.488227722933516,74.4113861466758,13438.80354585591,413549,0,28456746,1422.8373,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1700,20000,4,True,auto,collect,float,3,1.237005049129948,61.8502524564974,16168.082752828755,10128,0,28458528,1422.9264,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1700,20000,4,True,auto,collect,float,4,1.4880174759309739,74.4008737965487,13440.702359686373,341096,0,28459216,1422.9608,
|
||||
opt8,zero-copy collection (collect_view),9,1700,20000,4,True,auto,collect_view,float,0,0.5066126601304859,25.330633006524295,39477.89223200362,1443,0,28442005,1422.10025,
|
||||
opt8,zero-copy collection (collect_view),9,1700,20000,4,True,auto,collect_view,float,1,0.5032505870331079,25.162529351655394,39741.63272795991,0,0,28452049,1422.60245,
|
||||
opt8,zero-copy collection (collect_view),9,1700,20000,4,True,auto,collect_view,float,2,0.5028966551180929,25.144832755904645,39769.60235558436,0,0,28456746,1422.8373,
|
||||
opt8,zero-copy collection (collect_view),9,1700,20000,4,True,auto,collect_view,float,3,0.5031049428507686,25.15524714253843,39753.13755947816,0,0,28458528,1422.9264,
|
||||
opt8,zero-copy collection (collect_view),9,1700,20000,4,True,auto,collect_view,float,4,0.502825811970979,25.14129059854895,39775.2054963207,0,0,28459216,1422.9608,
|
||||
|
@@ -0,0 +1,2 @@
|
||||
artifact,kind,config,build,cites,produced_by,timestamp
|
||||
ladder_9x9.csv,end-to-end wall/FPS,9x9 cap=1700 N=20000 batch=2000 streams=4 consumer=streaming,float,"docs/ClusterFinderCUDA_benchmark_results.md §5, §6, §7, §8, §9, §11",perf/run_ladder.py,2026-08-20 11:25:16
|
||||
|
@@ -0,0 +1,90 @@
|
||||
# 9×9 cap A/B — what `max_clusters_per_frame` actually buys and costs
|
||||
|
||||
**Build**: `[f32]`, `COMPUTE_TYPE = float`, `DEVICE_PED_TYPE = float`, git `7177f00`.
|
||||
**Config**: 9×9, 20 000 frames, batch 2000, RTX 4090, idle GPU.
|
||||
Both caps measured **on one build in one session, back to back**, which is the
|
||||
whole point of this directory.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The cap is not a safety bound. The D2H slot is
|
||||
|
||||
output_bytes_per_frame = 4 + cap × sizeof(ClusterType) (328 B at 9×9)
|
||||
|
||||
and `ClusterFinderCUDA` copies that slot **whole**, every frame, regardless of how
|
||||
many clusters were actually found. So at 9×9 the cap sets the height of the D2H
|
||||
bar directly. At 3×3 the cluster is 40 B and the same headroom is nearly free —
|
||||
which is why this only ever mattered at 9×9.
|
||||
|
||||
The campaign ran 9×9 at **cap 1500**. Measured against the true per-frame
|
||||
distribution over the same 20 000-frame block:
|
||||
|
||||
mean 1422.1 std 25.0 min 1327 MAX 1633
|
||||
|
||||
so 1500 is **below the maximum**. It truncated 64 frames and discarded 2 715
|
||||
clusters — 0.0095 %. The loss was silent: the kernel bumps its counter for every
|
||||
detection and guards only the write (`if (write_idx >= max_clusters) return;`),
|
||||
and the host then clamps the count, so a truncated frame is indistinguishable
|
||||
from a short one. `ladder.py` now detects this and marks such rows `TRUNCATED`.
|
||||
|
||||
## The measurement
|
||||
|
||||
| cap | streams | kernel | H2D | D2H | binds | roofline | FPS |
|
||||
|--:|--:|--:|--:|--:|---|--:|--:|
|
||||
| 1500 | 4 | 24.11 | 19.64 | 22.77 | **kernel** | 24.11 µs | 41 480 |
|
||||
| 1500 | 1 | 23.97 | 13.22 | 19.50 | kernel | 23.97 µs | 41 715 |
|
||||
| 1700 | 4 | 23.94 | 20.54 | **25.24** | **D2H** | 25.24 µs | 39 614 |
|
||||
| 1700 | 1 | 23.70 | 13.22 | 21.95 | kernel | 23.70 µs | 42 197 |
|
||||
|
||||
Sustained, unprofiled, same session (`ladder_9x9.csv` in
|
||||
`../2026-08-20_f32_cap1700/`): opt8 reaches **39 775 FPS / 25.14 µs** at cap 1700,
|
||||
against 42 274 FPS / 23.66 µs at cap 1500. So covering every cluster costs
|
||||
**5.9 % of throughput**.
|
||||
|
||||
The kernel is unchanged across the two caps (24.11 vs 23.94 at s4, 0.7 % apart),
|
||||
as it must be — the cap affects only the write guard. D2H scales with the slot:
|
||||
at 1 stream, 492 000 B in 19.50 µs = 25.2 GB/s and 557 600 B in 21.95 µs =
|
||||
25.4 GB/s. Linear, and bandwidth-bound.
|
||||
|
||||
## What it means
|
||||
|
||||
**The cap chooses which engine binds.** Same code, same data, one parameter:
|
||||
|
||||
- **cap 1500** — 0.0095 % of clusters discarded, **kernel-bound** at 24.11 µs.
|
||||
Act III's premise holds: the kernel is the tallest bar, and opt7's −40 % kernel
|
||||
translates into end-to-end gain.
|
||||
- **cap 1700** — lossless, **D2H-bound** at 25.24 µs. opt7 still shortens the
|
||||
kernel by 40 %, but the frame no longer follows it: the result path is now the
|
||||
constraint.
|
||||
|
||||
Both are legitimate operating points and the choice belongs to the experiment,
|
||||
not to the library. If 1 in 10 000 clusters is inside your statistical error —
|
||||
and at 14 M clusters per 10 000 frames it usually is — cap 1500 is the faster,
|
||||
kernel-bound configuration and the kernel-optimization argument is the right one.
|
||||
If you need every cluster, take the 5.9 % and read D2H as the next target.
|
||||
|
||||
What is **not** legitimate is the state this campaign was in before today: a cap
|
||||
believed to be non-truncating, silently discarding clusters, with a
|
||||
kernel-bound conclusion resting on it and no way to notice.
|
||||
|
||||
## Artifacts
|
||||
|
||||
| file | what |
|
||||
|---|---|
|
||||
| `probes.csv` | the four rows above; `cap` is a column, so the A/B is machine-readable |
|
||||
| `probe_9x9_{s4,s1_uncontended}_cap{1500,1700}.nsys-rep` / `.sqlite` | nsys traces. **The cap is in the filename** — two probes of one label at two caps are different measurements and must not overwrite each other |
|
||||
| `env.json` | build identity. Trustworthy here: `common.assert_build_fresh()` ran first |
|
||||
|
||||
## Caveat on reading these
|
||||
|
||||
`roofline_fps` here is the **profiled** engine-occupancy estimate. Peak, as the
|
||||
report defines it, is the *lower* of that estimate and the best rate the
|
||||
unprofiled pipeline sustained. At cap 1700 the sustained 25.14 µs beats the
|
||||
25.24 µs estimate, so peak is 25.14 µs / 39 775 FPS and opt8 sits **on** the D2H
|
||||
floor.
|
||||
|
||||
An earlier attempt at this A/B on 2026-08-20 was discarded — see
|
||||
`../2026-08-20_INVALID_stale_build/INVALID.md`. The tree had not been rebuilt
|
||||
after the kernel header was edited, so an f64 kernel was measured and labelled
|
||||
`float`. `common.assert_build_fresh()` was added in response and now guards both
|
||||
`run_probes.py` and `run_ladder.py`.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"timestamp": "2026-08-20 11:22:24",
|
||||
"host": "pc-moench-04.psi.ch",
|
||||
"git_rev": "7177f00",
|
||||
"git_branch": "bench/opt2-pipeline",
|
||||
"git_dirty": true,
|
||||
"aare_version": "2026.7.2",
|
||||
"device_ped_type": "float",
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"driver": "595.71.05",
|
||||
"gpu_busy_pct": "0 %",
|
||||
"nvcc": "Build cuda_12.4.r12.4/compiler.34097967_0",
|
||||
"python": "3.11.15"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
artifact,kind,config,build,cites,produced_by,timestamp
|
||||
probe_9x9_s4_cap1500.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1500 N=20000 streams=4 batch=2000,float,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-20 11:21:53
|
||||
probe_9x9_s1_uncontended_cap1500.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1500 N=20000 streams=1 batch=2000,float,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-20 11:21:53
|
||||
probe_9x9_s4_cap1700.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1700 N=20000 streams=4 batch=2000,float,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-20 11:22:24
|
||||
probe_9x9_s1_uncontended_cap1700.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1700 N=20000 streams=1 batch=2000,float,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-20 11:22:24
|
||||
|
@@ -0,0 +1,5 @@
|
||||
n_frames,window_ms,window_us_per_frame,window_fps,kernel_n,kernel_sum_ms,kernel_busy_ms,kernel_overlap,kernel_duty_pct,kernel_us_per_frame,H2D_n,H2D_sum_ms,H2D_busy_ms,H2D_overlap,H2D_duty_pct,H2D_us_per_frame,D2H_n,D2H_sum_ms,D2H_busy_ms,D2H_overlap,D2H_duty_pct,D2H_us_per_frame,bottleneck,roofline_us_per_frame,roofline_fps,label,cluster_dim,cap,n_streams,batch,device_ped_type
|
||||
20000,1427.94668,71.397334,14006.125214703396,20000,497.912253,482.159812,1.0326705805999443,33.765953501849246,24.107990599999997,19999,392.881663,392.881663,1.0,27.51374883269451,19.64408315,20000,455.391991,455.391991,1.0,31.89138623859541,22.76959955,kernel,24.107990599999997,41480.02281036231,9x9_s4,9,1500,4,2000,float
|
||||
20000,1812.389592,90.61947959999999,11035.154962421568,20000,479.445687,479.445687,1.0,26.45378726054834,23.97228435,19999,264.357072,264.357072,1.0,14.586106274660178,13.2178536,20000,390.014943,390.014943,1.0,21.519376668325073,19.500747150000002,kernel,23.97228435,41714.83974575831,9x9_s1_uncontended,9,1500,1,2000,float
|
||||
20000,1398.131903,69.90659515,14304.801969746628,20000,486.97025,478.701589,1.0172731012179699,34.2386571662402,23.93507945,19999,410.704818,410.704818,1.0,29.37525544755415,20.5352409,20000,504.877265,504.877265,1.0,36.11084647426145,25.24386325,D2H,25.24386325,39613.58806679481,9x9_s4,9,1700,4,2000,float
|
||||
20000,1796.174109,89.80870544999999,11134.778026131764,20000,473.972052,473.972052,1.0,26.38786794805091,23.6986026,19999,264.40628,264.40628,1.0,14.720526182576213,13.220314000000002,20000,439.06546,439.06546,1.0,24.444482180207174,21.953273,kernel,23.6986026,42196.580822870965,9x9_s1_uncontended,9,1700,1,2000,float
|
||||
|
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"timestamp": "2026-08-20 11:36:32",
|
||||
"host": "pc-moench-04.psi.ch",
|
||||
"git_rev": "7177f00",
|
||||
"git_branch": "bench/opt2-pipeline",
|
||||
"git_dirty": true,
|
||||
"aare_version": "2026.7.2",
|
||||
"device_ped_type": "double",
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"driver": "595.71.05",
|
||||
"gpu_busy_pct": "0 %",
|
||||
"nvcc": "Build cuda_12.4.r12.4/compiler.34097967_0",
|
||||
"python": "3.11.15"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"timestamp": "2026-08-20 11:35:26",
|
||||
"host": "pc-moench-04.psi.ch",
|
||||
"git_rev": "7177f00",
|
||||
"git_branch": "bench/opt2-pipeline",
|
||||
"git_dirty": true,
|
||||
"aare_version": "2026.7.2",
|
||||
"device_ped_type": "double",
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"driver": "595.71.05",
|
||||
"gpu_busy_pct": "0 %",
|
||||
"nvcc": "Build cuda_12.4.r12.4/compiler.34097967_0",
|
||||
"python": "3.11.15"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
step,label,cluster_dim,cap,n_frames,n_streams,pinned,batch_chunk,collection,device_ped_type,rep,wall_s,us_per_frame,fps,minor_faults,major_faults,n_clusters,clusters_per_frame,notes
|
||||
cpu,"ClusterFinderMT, 32 threads",9,1700,20000,0,False,n/a,n/a,double,0,13.765519716078416,688.2759858039208,1452.9055504268088,2774547,0,28438219,1421.91095,reference
|
||||
opt3,"pipeline rework, no pinning",9,1700,20000,4,False,off,collect,double,0,1.8636698939371854,93.18349469685927,10731.514237077705,520379,0,28441991,1422.09955,
|
||||
opt3,"pipeline rework, no pinning",9,1700,20000,4,False,off,collect,double,1,1.7463798618409783,87.31899309204891,11452.262154991087,291716,0,28452038,1422.6019,
|
||||
opt3,"pipeline rework, no pinning",9,1700,20000,4,False,off,collect,double,2,1.6718548401258886,83.59274200629443,11962.761072303398,256041,0,28456731,1422.83655,
|
||||
opt3,"pipeline rework, no pinning",9,1700,20000,4,False,off,collect,double,3,1.648889538133517,82.44447690667585,12129.375278006357,127503,0,28458520,1422.926,
|
||||
opt3,"pipeline rework, no pinning",9,1700,20000,4,False,off,collect,double,4,1.8827759760897607,94.13879880448803,10622.61270272683,519376,0,28459199,1422.95995,
|
||||
opt4,+ pinned input (DMA H2D),9,1700,20000,4,True,off,collect,double,0,1.8503628321923316,92.51814160961658,10808.690950792481,520376,0,28441991,1422.09955,
|
||||
opt4,+ pinned input (DMA H2D),9,1700,20000,4,True,off,collect,double,1,1.6989846539217979,84.9492326960899,11771.736698052939,291717,0,28452038,1422.6019,
|
||||
opt4,+ pinned input (DMA H2D),9,1700,20000,4,True,off,collect,double,2,1.6217983360402286,81.08991680201143,12331.989468451337,256040,0,28456731,1422.83655,
|
||||
opt4,+ pinned input (DMA H2D),9,1700,20000,4,True,off,collect,double,3,1.5965895410627127,79.82947705313563,12526.701124878793,127503,0,28458520,1422.926,
|
||||
opt4,+ pinned input (DMA H2D),9,1700,20000,4,True,off,collect,double,4,1.8154389860574156,90.77194930287078,11016.619205382358,519489,0,28459199,1422.95995,
|
||||
opt5,CUDA Graphs,9,1700,20000,4,True,auto,collect,double,0,2.212348804110661,110.61744020553306,9040.1658015404,792737,0,28441991,1422.09955,rejected
|
||||
opt5,CUDA Graphs,9,1700,20000,4,True,auto,collect,double,1,2.0249657810200006,101.24828905100003,9876.710109108979,683768,0,28452038,1422.6019,rejected
|
||||
opt5,CUDA Graphs,9,1700,20000,4,True,auto,collect,double,2,1.8624301289673895,93.12150644836947,10738.6578905319,519533,0,28456731,1422.83655,rejected
|
||||
opt5,CUDA Graphs,9,1700,20000,4,True,auto,collect,double,3,1.8063955381512642,90.31977690756321,11071.772254524489,420440,0,28458520,1422.926,rejected
|
||||
opt5,CUDA Graphs,9,1700,20000,4,True,auto,collect,double,4,1.902979239821434,95.1489619910717,10509.83614612459,548306,0,28459199,1422.95995,rejected
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1700,20000,4,True,auto,collect,double,0,1.5712034509051591,78.56017254525796,12729.096278701618,460283,0,28441991,1422.09955,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1700,20000,4,True,auto,collect,double,1,1.3277159039862454,66.38579519931227,15063.463456266012,151601,0,28452038,1422.6019,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1700,20000,4,True,auto,collect,double,2,1.3467270429246128,67.33635214623064,14850.819329035752,95884,0,28456731,1422.83655,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1700,20000,4,True,auto,collect,double,3,1.6251181659754366,81.25590829877183,12306.797387865947,506241,0,28458521,1422.92605,
|
||||
opt7,"host<->GPU overlap, chunked internally",9,1700,20000,4,True,auto,collect,double,4,1.4793297110591084,73.96648555295542,13519.636528952858,334303,0,28459199,1422.95995,
|
||||
opt8,zero-copy collection (collect_view),9,1700,20000,4,True,auto,collect_view,double,0,0.611520430073142,30.576021503657103,32705.366847037083,2072,0,28441991,1422.09955,
|
||||
opt8,zero-copy collection (collect_view),9,1700,20000,4,True,auto,collect_view,double,1,0.6003484949469566,30.01742474734783,33313.98374167172,0,0,28452038,1422.6019,
|
||||
opt8,zero-copy collection (collect_view),9,1700,20000,4,True,auto,collect_view,double,2,0.600202470086515,30.010123504325747,33322.08878966649,0,0,28456731,1422.83655,
|
||||
opt8,zero-copy collection (collect_view),9,1700,20000,4,True,auto,collect_view,double,3,0.6002302598208189,30.011512991040945,33320.54602840319,0,0,28458520,1422.926,
|
||||
opt8,zero-copy collection (collect_view),9,1700,20000,4,True,auto,collect_view,double,4,0.6001883989665657,30.009419948328286,33322.87000954533,0,0,28459199,1422.95995,
|
||||
|
@@ -0,0 +1,2 @@
|
||||
artifact,kind,config,build,cites,produced_by,timestamp
|
||||
ladder_9x9.csv,end-to-end wall/FPS,9x9 cap=1700 N=20000 batch=2000 streams=4 consumer=streaming,double,"docs/ClusterFinderCUDA_benchmark_results.md §5, §6, §7, §8, §9, §11",perf/run_ladder.py,2026-08-20 11:35:26
|
||||
|
@@ -0,0 +1,68 @@
|
||||
# 9×9 cap A/B — the `[f64]` arm
|
||||
|
||||
**Build**: `COMPUTE_TYPE = float`, `DEVICE_PED_TYPE = double`, git `7177f00` — the
|
||||
mixed configuration the campaign's "f64 arm" has always been, not double/double.
|
||||
**Config**: 9×9, 20 000 frames, batch 2000, idle GPU, `assert_build_fresh()` passed.
|
||||
|
||||
Companion to `../2026-08-20_f32_capAB/README.md`, which explains the mechanism
|
||||
(the D2H slot is `4 + cap × 328 B` at 9×9 and is copied whole every frame) and
|
||||
why cap 1500 was truncating. Read that one first.
|
||||
|
||||
## The measurement
|
||||
|
||||
| cap | streams | kernel | H2D | D2H | binds | roofline |
|
||||
|--:|--:|--:|--:|--:|---|--:|
|
||||
| 1500 | 4 | 31.86 | 19.90 | 22.44 | kernel | 31.86 µs → 31 392 |
|
||||
| 1500 | 1 | 39.78 | 13.18 | 19.48 | kernel | 39.78 µs → 25 139 |
|
||||
| 1700 | 4 | 32.66 | 20.77 | 25.25 | **kernel** | 32.66 µs → 30 621 |
|
||||
| 1700 | 1 | 39.86 | 13.20 | 21.97 | kernel | 39.86 µs → 25 086 |
|
||||
|
||||
Sustained (`../2026-08-20_f64_cap1700/ladder_9x9.csv`): opt8 reaches
|
||||
**33 323 FPS / 30.01 µs** at cap 1700, against 33 301 / 30.03 at cap 1500.
|
||||
|
||||
## Why the cap is free here and not on `[f32]`
|
||||
|
||||
D2H grows identically on both arms — 22.4 → 25.2 µs — because the cluster payload
|
||||
is `int32` regardless of `COMPUTE_TYPE`, so the slot is the same 328 B either way.
|
||||
What differs is what it has to climb over:
|
||||
|
||||
arm kernel @ s4 D2H @ cap 1700 binds opt8 cost of the cap
|
||||
f64 32.66 25.25 kernel +0.1 % (nothing)
|
||||
f32 23.94 25.24 D2H -5.9 %
|
||||
|
||||
On the f64 arm the kernel is tall enough to hide any cap worth setting: 25.25 µs
|
||||
of D2H disappears entirely underneath a 32.66 µs kernel, and opt8 lands on the
|
||||
same 30.0 µs it did at cap 1500.
|
||||
|
||||
**opt7 is what makes the cap expensive.** Dropping the kernel 40 % — 32.66 → 23.94
|
||||
µs — moves it *below* the enlarged D2H bar. The result path was never the
|
||||
constraint until the kernel stopped being one. That is the same rule the whole
|
||||
ladder is ordered by, appearing once more and at the last possible moment: you
|
||||
cannot see the result path until the kernel gets out of its way.
|
||||
|
||||
So the honest statement about Act III is not that the cap invalidates it. It is:
|
||||
|
||||
- the kernel optimization is worth its full −40 % at cap 1500 (kernel-bound), and
|
||||
- at a lossless cap it is worth −40 % of a bar that is no longer the tallest,
|
||||
which is what success looks like when you optimize in bottleneck order.
|
||||
|
||||
## Non-obvious: opt5 loses 5.2 %, opt3/opt4 lose nothing
|
||||
|
||||
opt3 12 170 -> 12 129 -0.3 % run noise
|
||||
opt4 12 431 -> 12 527 +0.8 % run noise
|
||||
opt5 15 883 -> 15 063 -5.2 % real
|
||||
opt8 33 301 -> 33 323 +0.1 % run noise
|
||||
|
||||
opt3 and opt4 sit at ~30 % of the floor: they are host-bound with the GPU idle
|
||||
most of the frame, so 65 kB more per frame vanishes into slack. opt8 sits *on*
|
||||
the floor, but on this arm the floor is the kernel, which did not move. opt5 is
|
||||
the one caught in between — overlapped enough that transfer time is on the
|
||||
critical path, not fast enough to be floor-bound — so the extra bytes bill in
|
||||
full. The cap's cost is not uniform across the ladder; it depends on what each
|
||||
step is limited by.
|
||||
|
||||
## Artifacts
|
||||
|
||||
`probe_9x9_{s4,s1_uncontended}_cap{1500,1700}.nsys-rep` / `.sqlite`, and
|
||||
`probes.csv` carrying `cap` as a column. Filenames include the cap because two
|
||||
probes of one label at two caps are different measurements.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"timestamp": "2026-08-20 11:35:07",
|
||||
"host": "pc-moench-04.psi.ch",
|
||||
"git_rev": "7177f00",
|
||||
"git_branch": "bench/opt2-pipeline",
|
||||
"git_dirty": true,
|
||||
"aare_version": "2026.7.2",
|
||||
"device_ped_type": "double",
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"driver": "595.71.05",
|
||||
"gpu_busy_pct": "0 %",
|
||||
"nvcc": "Build cuda_12.4.r12.4/compiler.34097967_0",
|
||||
"python": "3.11.15"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
artifact,kind,config,build,cites,produced_by,timestamp
|
||||
probe_9x9_s4_cap1500.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1500 N=20000 streams=4 batch=2000,double,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-20 11:34:47
|
||||
probe_9x9_s1_uncontended_cap1500.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1500 N=20000 streams=1 batch=2000,double,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-20 11:34:47
|
||||
probe_9x9_s4_cap1700.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1700 N=20000 streams=4 batch=2000,double,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-20 11:35:07
|
||||
probe_9x9_s1_uncontended_cap1700.nsys-rep / .sqlite,nsys per-engine GPU times + duty cycles,9x9 cap=1700 N=20000 streams=1 batch=2000,double,"docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",perf/run_probes.py,2026-08-20 11:35:07
|
||||
|
@@ -0,0 +1,5 @@
|
||||
n_frames,window_ms,window_us_per_frame,window_fps,kernel_n,kernel_sum_ms,kernel_busy_ms,kernel_overlap,kernel_duty_pct,kernel_us_per_frame,H2D_n,H2D_sum_ms,H2D_busy_ms,H2D_overlap,H2D_duty_pct,H2D_us_per_frame,D2H_n,D2H_sum_ms,D2H_busy_ms,D2H_overlap,D2H_duty_pct,D2H_us_per_frame,bottleneck,roofline_us_per_frame,roofline_fps,label,cluster_dim,cap,n_streams,batch,device_ped_type
|
||||
20000,1371.403475,68.57017375000001,14583.600205621471,20000,867.936005,637.105409,1.3623114679913195,46.456452868474756,31.85527045,19999,397.97064,397.97064,1.0,29.01922353667654,19.898532,20000,448.886648,448.886648,1.0,32.731917060367664,22.4443324,kernel,31.85527045,31391.979596268033,9x9_s4,9,1500,4,2000,double
|
||||
20000,2095.919101,104.79595505,9542.353037604194,20000,795.58624,795.58624,1.0,37.958823869700495,39.779312,19999,263.508265,263.508265,1.0,12.572444464782803,13.17541325,20000,389.599353,389.599353,1.0,18.588472847740892,19.47996765,kernel,39.779312,25138.695209208247,9x9_s1_uncontended,9,1500,1,2000,double
|
||||
20000,1390.7386,69.53693,14380.847702077155,20000,863.510171,653.140187,1.3220900936539677,46.963547786765965,32.65700935,19999,415.436516,415.436516,1.0,29.871646332387698,20.7718258,20000,505.007753,505.007753,1.0,36.31219792130599,25.25038765,kernel,32.65700935,30621.29753776734,9x9_s4,9,1700,4,2000,double
|
||||
20000,2095.135366,104.75676829999999,9545.922580736962,20000,797.245324,797.245324,1.0,38.05221070379278,39.8622662,19999,263.965791,263.965791,1.0,12.598985024244968,13.198289550000002,20000,439.36096,439.36096,1.0,20.970528545791346,21.968048,kernel,39.8622662,25086.3810648076,9x9_s1_uncontended,9,1700,1,2000,double
|
||||
|
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# Both arms of the campaign, end to end: f32 ladder+probes, rebuild, f64
|
||||
# ladder+probes, rebuild back. ~1.5 h, mostly unattended.
|
||||
#
|
||||
# ./run_campaign.sh # both arms
|
||||
# ./run_campaign.sh f32 # one arm
|
||||
#
|
||||
# The GPU must be idle: run_ladder.py and run_probes.py both abort above 5 %
|
||||
# utilisation, because a competing process leaves per-operation averages intact
|
||||
# while destroying the duty cycle and the wall clock. Close any notebook first.
|
||||
#
|
||||
# The arm is selected by ONE line in the kernel header. Nothing else differs --
|
||||
# same commands, same parameters -- and env.json records which arm produced each
|
||||
# result set, so a stale build cannot silently mislabel a campaign.
|
||||
set -euo pipefail
|
||||
|
||||
REPO=/home/ferjao_k/aare
|
||||
HDR=$REPO/include/aare/clusterfinder_kernel.cuh
|
||||
PERF=$REPO/python/tests/perf
|
||||
PY=${PY:-/home/ferjao_k/.conda/envs/py/bin/python3.11}
|
||||
|
||||
set_ped() { # set_ped float|double
|
||||
sed -i "s/^using DEVICE_PED_TYPE = .*;/using DEVICE_PED_TYPE = $1;/" "$HDR"
|
||||
echo "=== rebuilding with DEVICE_PED_TYPE = $1 ==="
|
||||
cmake --build "$REPO/build" -j 16 2>&1 | grep -E "Built target _aare_cuda|error" || {
|
||||
echo "BUILD FAILED"; exit 1; }
|
||||
got=$(cd "$PERF" && $PY -c "import sys;sys.path.insert(0,'.');import common;print(common.device_ped_type())")
|
||||
[ "$got" = "$1" ] || { echo "header reports '$got', expected '$1' — aborting"; exit 1; }
|
||||
echo "verified: $got"
|
||||
}
|
||||
|
||||
run_arm() {
|
||||
cd "$PERF"
|
||||
echo "=== ladder ==="; $PY run_ladder.py
|
||||
echo "=== probes ==="; $PY run_probes.py
|
||||
}
|
||||
|
||||
arm=${1:-both}
|
||||
if [ "$arm" = "f32" ] || [ "$arm" = "both" ]; then
|
||||
set_ped float
|
||||
run_arm
|
||||
fi
|
||||
if [ "$arm" = "f64" ] || [ "$arm" = "both" ]; then
|
||||
set_ped double
|
||||
run_arm
|
||||
# f32 is the shipping default: never leave the tree on the f64 build, or the
|
||||
# next person's notebook silently runs a 40 % slower kernel at 9x9.
|
||||
set_ped float
|
||||
fi
|
||||
echo "=== CAMPAIGN COMPLETE ==="
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the opt1 → opt8 ladder and write one CSV row per (step, rep).
|
||||
|
||||
python run_ladder.py --dry-run # 2000 frames, 1 rep, both sizes
|
||||
python run_ladder.py # the real campaign
|
||||
python run_ladder.py --dims 9 --reps 3
|
||||
|
||||
Output lands in perf/results/<date>_<f32|f64>/ together with env.json and a
|
||||
manifest row, so every number in the report can be traced back to the run that
|
||||
produced it and the build it was taken on.
|
||||
|
||||
The build axis (opt6) is NOT a command-line option: it is compiled in. Check
|
||||
env.json's device_ped_type to know which arm a result set belongs to.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import common
|
||||
import ladder
|
||||
from common import Row
|
||||
|
||||
|
||||
# Per-cluster-size configuration. FIXED for the campaign. Optimising over these
|
||||
# is a separate exercise; what matters here is that every step sees the same ones.
|
||||
#
|
||||
# cap MEASURED against the per-frame maximum over each campaign block,
|
||||
# not guessed. With a probing cap high enough never to bind:
|
||||
#
|
||||
# frames mean max cap truncates?
|
||||
# 3x3 100 000 2 330.9 2 545 3000 no
|
||||
# 9x9 20 000 1 422.1 1 633 1700 no
|
||||
#
|
||||
# The 9x9 cap was 1500 for the whole earlier campaign, which is
|
||||
# BELOW the maximum: it silently dropped 2 715 clusters (0.0095 %)
|
||||
# across 64 frames, because the kernel guards only the write and the
|
||||
# host clamps the count. ladder.py now detects this — any frame that
|
||||
# returns exactly `cap` clusters is flagged and the row is marked
|
||||
# TRUNCATED — so the constant can never again be quietly wrong.
|
||||
#
|
||||
# What raising it costs, MEASURED (results/2026-08-20_{f32,f64}_capAB):
|
||||
# D2H copies the whole fixed-size slot (4 + cap * sizeof(ClusterType))
|
||||
# regardless of occupancy, so at 9x9 the cap is a throughput knob.
|
||||
# 1500 -> 1700 grows the slot 480.5 -> 544.5 KiB and D2H 22.8 -> 25.2
|
||||
# us/frame on BOTH arms (the payload is int32 either way). The cost
|
||||
# is arm-dependent, because it depends what the extra bytes hide under:
|
||||
#
|
||||
# arm kernel@s4 D2H@1700 binds opt8 cost of the cap
|
||||
# f64 32.66 25.25 kernel +0.1 % (free)
|
||||
# f32 23.94 25.24 D2H -5.9 %
|
||||
#
|
||||
# i.e. opt7's -40 % kernel is exactly what makes the cap expensive:
|
||||
# it drops the kernel below the enlarged D2H bar. At 3x3 the cluster
|
||||
# is 40 B and the cap is free at any sane value.
|
||||
# n_streams 4 everywhere. Campaign C used 8 at 9x9; §8 then showed 8 streams
|
||||
# buy no kernel concurrency there (instance time +1%) while
|
||||
# inflating the event timer 3.5x. Fixed at 4 and re-measured.
|
||||
# batch 2000 everywhere. The §10 opt7 measurement used 3000; nothing else did.
|
||||
# n_frames 100k at 3x3, 20k at 9x9 — the historical Campaign A and C values.
|
||||
# 9x9 is held at 20k because the result heap is ~5x larger per frame
|
||||
# (1422 x 328 B = 466 kB vs 2330 x 40 B = 93 kB), so 100k would need
|
||||
# 46.6 GB to retain against 98 GB free with no swap. At 20k it is
|
||||
# 9.3 GB, which keeps --retain feasible at both sizes.
|
||||
CONFIGS = {
|
||||
3: dict(cap=3000, n_frames=100_000, batch_size=2000, n_streams=4),
|
||||
9: dict(cap=1700, n_frames=20_000, batch_size=2000, n_streams=4),
|
||||
}
|
||||
|
||||
# Sustained per-frame roofline = tallest engine bar, from the nsys probes.
|
||||
# Used only to annotate the printed table; never written to the CSV, because a
|
||||
# roofline is a derived quantity and belongs in the report, not the raw data.
|
||||
ROOFLINE_US = {3: 16.2, 9: 23.9}
|
||||
|
||||
|
||||
def _run_isolated(step_name: str, dim: int, args, outdir: Path) -> list[Row]:
|
||||
"""Run one step in a FRESH process and read its rows back.
|
||||
|
||||
Required for the fault columns to mean anything: the heap is process-wide,
|
||||
so in a shared process every step inherits what the previous ones grew.
|
||||
The dataset is re-read per step, which is the cost of the isolation.
|
||||
"""
|
||||
tmp = outdir / f".{step_name}_{dim}.csv"
|
||||
cmd = [sys.executable, str(Path(__file__).resolve()),
|
||||
"--dims", str(dim), "--steps", step_name, "--reps", str(args.reps),
|
||||
"--_worker-out", str(tmp), "--allow-busy-gpu"]
|
||||
if args.frames:
|
||||
cmd += ["--frames", str(args.frames)]
|
||||
if args.retain:
|
||||
cmd += ["--retain"]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if not tmp.exists():
|
||||
raise RuntimeError(
|
||||
f"worker for {step_name} produced nothing "
|
||||
f"(exit {proc.returncode}): {proc.stderr.strip()[-400:]}")
|
||||
rows = []
|
||||
for d in csv.DictReader(tmp.open()):
|
||||
for k, f in Row.__dataclass_fields__.items():
|
||||
if f.type in ("int", int):
|
||||
d[k] = int(d[k])
|
||||
elif f.type in ("float", float):
|
||||
d[k] = float(d[k])
|
||||
elif f.type in ("bool", bool):
|
||||
d[k] = d[k] == "True"
|
||||
rows.append(Row(**d))
|
||||
tmp.unlink()
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--dims", type=int, nargs="+", default=[3, 9],
|
||||
help="cluster sizes to run (default: 3 9)")
|
||||
ap.add_argument("--reps", type=int, default=5,
|
||||
help="repetitions per step. 5 because collect() is bistable and 3 cannot separate a plateau from an oscillation (default: 5)")
|
||||
ap.add_argument("--frames", type=int, default=None,
|
||||
help="override n_frames for every size")
|
||||
ap.add_argument("--steps", nargs="+", default=None,
|
||||
help="subset of step names, e.g. --steps opt7 opt8")
|
||||
ap.add_argument("--tag", default="", help="suffix for the results directory")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="2000 frames, 1 rep — proves the matrix executes")
|
||||
ap.add_argument("--allow-busy-gpu", action="store_true",
|
||||
help="skip the idle-GPU check (results will not be quotable)")
|
||||
ap.add_argument("--no-isolate", action="store_true",
|
||||
help="run every step in ONE process. Faster (the dataset is "
|
||||
"loaded once) but fault counts become meaningless: the "
|
||||
"heap is process-wide, so each step inherits whatever "
|
||||
"the previous ones grew. Measured: opt3 reports 2 faults "
|
||||
"after opt1/opt2 have run, and 92,251 on its own. "
|
||||
"Throughput is unaffected either way.")
|
||||
ap.add_argument("--_worker-out", default=None,
|
||||
help=argparse.SUPPRESS) # internal: one step, write CSV, exit
|
||||
ap.add_argument("--retain", action="store_true",
|
||||
help="keep every ClusterVector instead of discarding each "
|
||||
"batch after counting. Measures the finder PLUS a "
|
||||
"growing result heap, which is what the notebook did. "
|
||||
"Needs N<=20000 at 9x9 or it will OOM (46.6 GB at 100k).")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.dry_run:
|
||||
args.frames, args.reps = 2000, 1
|
||||
args.tag = args.tag or "dryrun"
|
||||
|
||||
if not args.allow_busy_gpu and not getattr(args, "_worker_out", None):
|
||||
common.assert_build_fresh()
|
||||
common.assert_idle_gpu()
|
||||
|
||||
env = common.capture_env()
|
||||
outdir = common.results_dir(args.tag)
|
||||
common.write_env(outdir / "env.json", env)
|
||||
|
||||
print(f"build: DEVICE_PED_TYPE={env['device_ped_type']} "
|
||||
f"git={env['git_rev']}{'+dirty' if env['git_dirty'] else ''} "
|
||||
f"gpu={env['gpu']}")
|
||||
print(f"out: {outdir}\n")
|
||||
|
||||
for dim in args.dims:
|
||||
cfg = dict(CONFIGS[dim])
|
||||
if args.frames:
|
||||
cfg["n_frames"] = args.frames
|
||||
steps = ladder.steps_for(dim)
|
||||
if args.steps:
|
||||
steps = [s for s in steps if s.step in args.steps]
|
||||
if not steps:
|
||||
print(f"{dim}x{dim}: no runnable steps, skipping")
|
||||
continue
|
||||
|
||||
print(f"=== {dim}x{dim} cap={cfg['cap']} N={cfg['n_frames']:,} "
|
||||
f"batch={cfg['batch_size']} reps={args.reps} ===")
|
||||
skipped = [s.step for s in ladder.STEPS if s not in steps]
|
||||
if skipped and not args.steps:
|
||||
print(f" not available at this size: {', '.join(skipped)}")
|
||||
|
||||
rows: list[Row] = []
|
||||
for step in steps:
|
||||
try:
|
||||
if args.no_isolate or getattr(args, "_worker_out", None):
|
||||
got = ladder.measure(step, dim, cfg["cap"], cfg["n_frames"],
|
||||
cfg["batch_size"], args.reps,
|
||||
retain=args.retain)
|
||||
else:
|
||||
got = _run_isolated(step.step, dim, args, outdir)
|
||||
except Exception as exc: # one broken step must not lose the rest
|
||||
print(f" {step.step:<6} FAILED: {type(exc).__name__}: {exc}")
|
||||
continue
|
||||
if not got:
|
||||
print(f" {step.step:<6} produced no rows")
|
||||
continue
|
||||
rows.extend(got)
|
||||
last = got[-1]
|
||||
print(f" {step.step:<6} {last.fps:9,.0f} FPS "
|
||||
f"{last.us_per_frame:6.1f} us/f faults {last.minor_faults:>9,}")
|
||||
|
||||
if not rows:
|
||||
continue
|
||||
if getattr(args, "_worker_out", None):
|
||||
common.write_rows(Path(getattr(args, "_worker_out", None)), rows)
|
||||
continue
|
||||
csv_path = outdir / f"ladder_{dim}x{dim}.csv"
|
||||
common.write_rows(csv_path, rows)
|
||||
common.append_manifest(outdir / "manifest.csv", {
|
||||
"artifact": csv_path.name,
|
||||
"kind": "end-to-end wall/FPS",
|
||||
"config": f"{dim}x{dim} cap={cfg['cap']} N={cfg['n_frames']} "
|
||||
f"batch={cfg['batch_size']} streams={cfg['n_streams']} "
|
||||
f"consumer={'retain' if args.retain else 'streaming'}",
|
||||
"build": env["device_ped_type"],
|
||||
"cites": "docs/ClusterFinderCUDA_benchmark_results.md §5, §6, §7, §8, §9, §11",
|
||||
"produced_by": "perf/run_ladder.py",
|
||||
"timestamp": env["timestamp"],
|
||||
})
|
||||
|
||||
print(f"\n -> {csv_path.name} (all reps; cold = rep 0, warm = last)")
|
||||
common.print_table(rows, ROOFLINE_US.get(dim))
|
||||
print()
|
||||
|
||||
print(f"manifest: {outdir / 'manifest.csv'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""nsys probe sweep: per-engine GPU times, duty cycles and the roofline.
|
||||
|
||||
python run_probes.py # the campaign sweep
|
||||
python run_probes.py --frames 2000 # quick check
|
||||
|
||||
Produces, per config, an .nsys-rep + .sqlite in the results directory and one
|
||||
row in probes.csv. This is the ONLY source of the rooflines that run_ladder.py's
|
||||
"% of roofline" column divides by.
|
||||
|
||||
Why this cannot be merged with run_ladder.py: nsys inflates wall clock ~4x by
|
||||
tracing every CUDA API call, so a profiled run cannot produce a throughput
|
||||
number, and an unprofiled run cannot produce a per-engine breakdown. Two tools,
|
||||
two questions.
|
||||
|
||||
Why 20 000 frames and not 2 000: over a short run the GPU clocks never fully
|
||||
ramp (210 MHz idle -> 3.1 GHz boost), which under-reports the GPU by ~10 %. Every
|
||||
retained probe from the previous campaign used 2 000 frames, which is how a
|
||||
26.7 us/frame roofline was published for a pipeline that sustains 23.9.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import common
|
||||
import gpu_span
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
NSYS = "/opt/nvidia/nsight-systems/2024.5.1/bin/nsys"
|
||||
|
||||
# (cluster_dim, cap, n_streams, label)
|
||||
# s4 = the configuration the ladder runs, so its roofline is the one to quote.
|
||||
# Caps MUST track run_ladder.py's CONFIGS. At 9x9 the D2H slot is
|
||||
# 4 + cap * sizeof(ClusterType) and is copied whole regardless of
|
||||
# occupancy, so a probe at a different cap measures a different D2H bar
|
||||
# and its "roofline" would not be the ladder's.
|
||||
# s1 = the uncontended control: with one stream H2D and D2H never coexist, so
|
||||
# it separates "this engine is slow" from "these engines are fighting"
|
||||
# (docs §8.2 — H2D loses 23 % of its bandwidth against a busy D2H).
|
||||
CONFIGS = [
|
||||
(3, 3000, 4, "3x3_s4"),
|
||||
(3, 3000, 1, "3x3_s1_uncontended"),
|
||||
(9, 1700, 4, "9x9_s4"),
|
||||
(9, 1700, 1, "9x9_s1_uncontended"),
|
||||
]
|
||||
|
||||
|
||||
def run_one(cdim, cap, streams, label, n_frames, batch, outdir) -> dict | None:
|
||||
# The cap is in the filename because at 9x9 it SETS the D2H bar: the slot is
|
||||
# 4 + cap * sizeof(ClusterType) and is copied whole regardless of occupancy.
|
||||
# Two probes of the same label at different caps are different measurements,
|
||||
# and the earlier campaign's 9x9 probes were taken at cap=1500. Without the
|
||||
# suffix they overwrite each other and the difference disappears.
|
||||
rep = outdir / f"probe_{label}_cap{cap}"
|
||||
print(f"\n--- {label}: {cdim}x{cdim} cap={cap} streams={streams} "
|
||||
f"N={n_frames} ---")
|
||||
|
||||
prof = [NSYS, "profile", "--trace=cuda", "--sample=none", "--cpuctxsw=none",
|
||||
"--force-overwrite=true", "-o", str(rep),
|
||||
sys.executable, str(HERE / "nsys_kernel_probe.py"),
|
||||
str(streams), str(n_frames), str(cdim), str(cap), str(batch)]
|
||||
p = subprocess.run(prof, capture_output=True, text=True)
|
||||
for line in p.stdout.splitlines():
|
||||
if line.strip().startswith(("n_streams", "H2D/frame", "wall")):
|
||||
print(" ", line.strip())
|
||||
if not (rep.with_suffix(".nsys-rep")).exists():
|
||||
print(f" FAILED: {p.stderr.strip()[-400:]}")
|
||||
return None
|
||||
|
||||
# --force-export makes the .sqlite gpu_span.py reads
|
||||
subprocess.run([NSYS, "stats", "--force-export=true", "--report",
|
||||
"cuda_gpu_sum", str(rep.with_suffix(".nsys-rep"))],
|
||||
capture_output=True, text=True)
|
||||
sq = rep.with_suffix(".sqlite")
|
||||
if not sq.exists():
|
||||
print(" FAILED: no sqlite export")
|
||||
return None
|
||||
|
||||
r = gpu_span.analyze(sq, n_frames)
|
||||
r.update(label=label, cluster_dim=cdim, cap=cap, n_streams=streams,
|
||||
batch=batch, device_ped_type=common.device_ped_type())
|
||||
print(f" kernel {r['kernel_us_per_frame']:5.1f} us (duty {r['kernel_duty_pct']:4.1f}%) "
|
||||
f"H2D {r['H2D_us_per_frame']:5.1f} ({r['H2D_duty_pct']:4.1f}%) "
|
||||
f"D2H {r['D2H_us_per_frame']:5.1f} ({r['D2H_duty_pct']:4.1f}%)")
|
||||
print(f" -> roofline: {r['bottleneck']}-bound at "
|
||||
f"{r['roofline_us_per_frame']:.1f} us/frame = {r['roofline_fps']:,.0f} FPS")
|
||||
return r
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--frames", type=int, default=20_000)
|
||||
ap.add_argument("--batch", type=int, default=2000)
|
||||
ap.add_argument("--tag", default="")
|
||||
ap.add_argument("--only", nargs="+", default=None, help="subset of labels")
|
||||
ap.add_argument("--cap", type=int, default=None,
|
||||
help="override the cap for every selected config. At 9x9 the "
|
||||
"cap sets the D2H bar (the slot is copied whole), so this "
|
||||
"is how you A/B two caps ON ONE BUILD IN ONE SESSION "
|
||||
"rather than against a probe taken days earlier. Artifact "
|
||||
"filenames carry the cap, so runs do not overwrite.")
|
||||
args = ap.parse_args()
|
||||
|
||||
common.assert_build_fresh()
|
||||
common.assert_idle_gpu()
|
||||
env = common.capture_env()
|
||||
outdir = common.results_dir(args.tag)
|
||||
common.write_env(outdir / "env.json", env)
|
||||
print(f"build: DEVICE_PED_TYPE={env['device_ped_type']} git={env['git_rev']}")
|
||||
print(f"out: {outdir}")
|
||||
|
||||
rows = []
|
||||
for cdim, cap, streams, label in CONFIGS:
|
||||
if args.only and label not in args.only:
|
||||
continue
|
||||
if args.cap:
|
||||
cap = args.cap
|
||||
r = run_one(cdim, cap, streams, label, args.frames, args.batch, outdir)
|
||||
if r:
|
||||
rows.append(r)
|
||||
common.append_manifest(outdir / "manifest.csv", {
|
||||
"artifact": f"probe_{label}_cap{cap}.nsys-rep / .sqlite",
|
||||
"kind": "nsys per-engine GPU times + duty cycles",
|
||||
"config": f"{cdim}x{cdim} cap={cap} N={args.frames} "
|
||||
f"streams={streams} batch={args.batch}",
|
||||
"build": env["device_ped_type"],
|
||||
"cites": "docs §7 rooflines, §8 kernel/memcpy, §8.1 duty cycles, §9",
|
||||
"produced_by": "perf/run_probes.py",
|
||||
"timestamp": env["timestamp"],
|
||||
})
|
||||
|
||||
if rows:
|
||||
out = outdir / "probes.csv"
|
||||
# MERGE, do not clobber. A results directory may legitimately hold
|
||||
# several probe runs -- a cap A/B is exactly that -- and the artifact
|
||||
# filenames already carry the cap. Writing "w" here silently discarded
|
||||
# the first half of the first such A/B. Rows are keyed by
|
||||
# (label, cap, n_streams): re-running one config replaces its own row
|
||||
# and leaves every other row alone.
|
||||
def _key(r):
|
||||
return (str(r["label"]), str(r["cap"]), str(r["n_streams"]))
|
||||
|
||||
prior = list(csv.DictReader(out.open())) if out.exists() else []
|
||||
fresh = {_key(r) for r in rows}
|
||||
merged = [r for r in prior if _key(r) not in fresh] + rows
|
||||
with out.open("w", newline="") as fh:
|
||||
w = csv.DictWriter(fh, fieldnames=list(rows[0]))
|
||||
w.writeheader()
|
||||
w.writerows(merged)
|
||||
print(f"\n=== rooflines ({env['device_ped_type']} build) ===")
|
||||
print(f"{'config':<22} {'kernel':>8} {'H2D':>8} {'D2H':>8} "
|
||||
f"{'bottleneck':<10} {'roofline':>10} {'FPS':>10}")
|
||||
for r in rows:
|
||||
print(f"{r['label']:<22} {r['kernel_us_per_frame']:8.2f} "
|
||||
f"{r['H2D_us_per_frame']:8.2f} {r['D2H_us_per_frame']:8.2f} "
|
||||
f"{r['bottleneck']:<10} {r['roofline_us_per_frame']:9.2f}u "
|
||||
f"{r['roofline_fps']:10,.0f}")
|
||||
print(f"\n-> {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tiered CPU/CUDA agreement study for the deck's validation slides.
|
||||
|
||||
Runs the three finders that differ in exactly one thing each over the same
|
||||
frames, from the same pedestal, and scores every pair:
|
||||
|
||||
ClusterFinder serial CPU, pedestal pushed DURING the raster scan
|
||||
ClusterFinderFrozen same logic, pedestal frozen per frame + deferred push
|
||||
ClusterFinderCUDA frozen per frame, float32 device pedestal
|
||||
|
||||
so that serial vs frozen = update timing alone (a CPU-only effect)
|
||||
and frozen vs cuda = everything CUDA changes.
|
||||
|
||||
The question the deck needs answered is not "how many disagree" but "in which
|
||||
direction, and is a CUDA-only centre an invented photon or a second copy of one
|
||||
both finders already found". So every CUDA-only centre is also scored at tol=1:
|
||||
if it has a counterpart in the agreed set's 8-neighbourhood it is a duplicate,
|
||||
not an invention.
|
||||
|
||||
Writes tiers.json + spectra_valid.png next to itself.
|
||||
"""
|
||||
import sys, json, time
|
||||
sys.path.append('/home/ferjao_k/aare/build')
|
||||
sys.path.append('/home/ferjao_k/aare/python/tests')
|
||||
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import boost_histogram as bh
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
|
||||
from aare import File, ClusterFinder, ClusterFinderFrozen, ClusterFinderCUDA
|
||||
from helper import centers, only_sets, shift_dist
|
||||
|
||||
OUT = Path(__file__).resolve().parent
|
||||
BASE = Path('/mnt/sls_det_storage/moench_data/2603_MaxIVBeamtime/2026032408/'
|
||||
'process/xrf/')
|
||||
|
||||
N_PED, N, N_SIGMA, N_STREAMS = 1000, 10000, 5, 4
|
||||
CLUSTER = (3, 3)
|
||||
IMG = (400, 400)
|
||||
CAP = 50_000
|
||||
NBINS, ERANGE = 200, (-2, 4000)
|
||||
|
||||
f = File(BASE / 'Cu_factor_10_data_master_0.json')
|
||||
pd = File(BASE / 'Cu_factor_10_pedestal_master_0.json')
|
||||
|
||||
cf_cpu = ClusterFinder(IMG, CLUSTER, n_sigma=N_SIGMA, capacity=CAP)
|
||||
cf_frz = ClusterFinderFrozen(IMG, CLUSTER, n_sigma=N_SIGMA, capacity=CAP)
|
||||
cf_cud = ClusterFinderCUDA(IMG, CLUSTER, n_sigma=N_SIGMA,
|
||||
max_clusters_per_frame=3000, n_streams=N_STREAMS)
|
||||
finders = {'cpu': cf_cpu, 'frozen': cf_frz, 'cuda': cf_cud}
|
||||
|
||||
t0 = time.perf_counter()
|
||||
pd.seek(0)
|
||||
for _ in range(N_PED):
|
||||
img = pd.read_frame().copy()
|
||||
for cf in finders.values():
|
||||
cf.push_pedestal_frame(img)
|
||||
print(f'pedestal train: {time.perf_counter()-t0:.1f}s', flush=True)
|
||||
|
||||
f.seek(0)
|
||||
data = f.read_n(N)
|
||||
print('data:', data.shape, data.dtype, flush=True)
|
||||
|
||||
names = list(finders)
|
||||
totals = {n: 0 for n in names}
|
||||
hists = {n: bh.Histogram(bh.axis.Regular(NBINS, *ERANGE)) for n in names}
|
||||
pairs = {(a, b): dict(a_only=0, b_only=0) for i, a in enumerate(names)
|
||||
for b in names[i + 1:]}
|
||||
|
||||
# every CUDA-only centre, scored against the agreed set
|
||||
extras = [] # one record per frozen-vs-cuda cuda-only centre
|
||||
n_dup_tol1 = 0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
for fid in range(N):
|
||||
cs = {}
|
||||
for n, cf in finders.items():
|
||||
cf.find_clusters(data[fid])
|
||||
cv = cf.steal_clusters(realloc_same_capacity=True)
|
||||
cs[n] = centers(cv)
|
||||
totals[n] += len(cs[n])
|
||||
if cv.size:
|
||||
hists[n].fill(np.asarray(cv.sum()).ravel())
|
||||
|
||||
for (a, b), acc in pairs.items():
|
||||
a_only, b_only = only_sets(cs[a], cs[b], tol=0)
|
||||
acc['a_only'] += len(a_only)
|
||||
acc['b_only'] += len(b_only)
|
||||
|
||||
# the tier that matters: frozen vs cuda, one record per extra
|
||||
_, cu_only = only_sets(cs['frozen'], cs['cuda'], tol=0)
|
||||
for p in cu_only:
|
||||
d = shift_dist(p, cs['frozen'], R=4)
|
||||
extras.append(dict(frame=int(fid), x=int(p[0]), y=int(p[1]),
|
||||
shift=int(d)))
|
||||
if cu_only:
|
||||
_, cu_only_1 = only_sets(cs['frozen'], cs['cuda'], tol=1)
|
||||
n_dup_tol1 += len(cu_only) - len(cu_only_1)
|
||||
|
||||
if fid % 1000 == 0:
|
||||
print(f' {fid}/{N} {time.perf_counter()-t0:.0f}s', flush=True)
|
||||
|
||||
print(f'scan: {time.perf_counter()-t0:.0f}s', flush=True)
|
||||
|
||||
res = dict(n_frames=N, totals=totals,
|
||||
pairs={f'{a} vs {b}': v for (a, b), v in pairs.items()},
|
||||
extras=extras,
|
||||
n_cuda_only=len(extras),
|
||||
n_adjacent_to_agreed=n_dup_tol1,
|
||||
shift_histogram={str(k): int(v) for k, v in
|
||||
zip(*np.unique([e['shift'] for e in extras],
|
||||
return_counts=True))} if extras else {},
|
||||
hists={n: h.values().tolist() for n, h in hists.items()},
|
||||
edges=hists[names[0]].axes[0].edges.tolist())
|
||||
(OUT / 'tiers.json').write_text(json.dumps(res))
|
||||
|
||||
print('\n=== totals ===')
|
||||
for n in names:
|
||||
print(f' {n:8s} {totals[n]:>12,}')
|
||||
print('\n=== pairwise (tol=0) ===')
|
||||
for (a, b), v in pairs.items():
|
||||
tot = v['a_only'] + v['b_only']
|
||||
print(f' {a:>6s} vs {b:<6s} {a}-only {v["a_only"]:>4} '
|
||||
f'{b}-only {v["b_only"]:>4} total {tot:>4} '
|
||||
f'({tot/max(totals[a],1):.2e})')
|
||||
print('\n=== the frozen-vs-cuda extras ===')
|
||||
print(f' cuda-only centres (tol=0): {len(extras)}')
|
||||
print(f' of which adjacent to an agreed centre (tol=1): {n_dup_tol1}')
|
||||
print(f' chebyshev shift to nearest frozen centre: {res["shift_histogram"]}')
|
||||
Reference in New Issue
Block a user