diff --git a/campaign_counts_3x3.npy.gz b/campaign_counts_3x3.npy.gz new file mode 100644 index 00000000..d399d2c6 Binary files /dev/null and b/campaign_counts_3x3.npy.gz differ diff --git a/campaign_counts_9x9.npy b/campaign_counts_9x9.npy new file mode 100644 index 00000000..061faf35 Binary files /dev/null and b/campaign_counts_9x9.npy differ diff --git a/counts_3x3.npy b/counts_3x3.npy new file mode 100644 index 00000000..756a5bd9 Binary files /dev/null and b/counts_3x3.npy differ diff --git a/docs/.~lock.ClusterFinderCUDA_optimizations.pptx# b/docs/.~lock.ClusterFinderCUDA_optimizations.pptx# deleted file mode 100644 index 5b76f65c..00000000 --- a/docs/.~lock.ClusterFinderCUDA_optimizations.pptx# +++ /dev/null @@ -1 +0,0 @@ -,ferjao_k,pc-moench-04.psi.ch,11.08.2026 11:46,file:///home/ferjao_k/.config/libreoffice/4; \ No newline at end of file diff --git a/docs/ClusterFinderCUDA_benchmark_results.md b/docs/ClusterFinderCUDA_benchmark_results.md new file mode 100644 index 00000000..ccfb8b71 --- /dev/null +++ b/docs/ClusterFinderCUDA_benchmark_results.md @@ -0,0 +1,1393 @@ +# ClusterFinderCUDA — Benchmark Results + +Verified performance numbers for the CUDA cluster-finder optimization ladder, told in +the order the bottleneck actually moves: **feed the GPU → get the results back → make +the GPU faster.** Every number is traced to a file in +[`python/tests/perf/results/`](../python/tests/perf/results/), and each is tagged +**quotable** or **not quotable** with the reason. + +> **Reading order.** §2 defines the ladder and §4 defines the measuring stick. After +> that the three acts (§5–§7, §8–§10, §11) are self-contained. §16 is the slide-ready +> summary. + +--- + +## 0. Provenance — where every number comes from + +The measurement harness is [`python/tests/perf/`](../python/tests/perf/) (see its +`README.md`); results live in `perf/results/_/`, each carrying an +`env.json` (build, git rev, driver, GPU) and a `manifest.csv` mapping artifact → config +→ build → citing section. + +| tag | directory | contents | +|---|---|---| +| **`[f64]`** | [`perf/results/2026-08-18_f64/`](../python/tests/perf/results/2026-08-18_f64/) | `ladder_3x3.csv`, `ladder_9x9.csv`, `probes.csv`, 4 × `.nsys-rep`/`.sqlite` — `DEVICE_PED_TYPE=double`. **Acts I and II.** | +| **`[f32]`** | [`perf/results/2026-08-18_f32/`](../python/tests/perf/results/2026-08-18_f32/) | same, `DEVICE_PED_TYPE=float`. **Act III.** | + +Both arms are git rev `7177f00` on `bench/opt2-pipeline`. Reproduce either with +`./run_campaign.sh f64` (or `f32`); the arm is selected by one line in +`include/aare/clusterfinder_kernel.cuh` and nothing else differs. The `step` column in +the CSVs carries the harness's internal labels — §15 maps them to the step names used +here. + +**Campaign parameters**, fixed across every step so the ladder measures the code and not +the configuration: + +| | 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 | + +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. Probes are 20 000 frames because shorter ones do not +let the GPU clocks ramp (210 MHz idle → 3.1 GHz) and under-report the device 7–10 %. + +### Two conventions used throughout + +**`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 (9×9 +opt4: 85.8 / 73.7 / 86.6 µs with faults 520 k / 127 k / 519 k), so "last rep" would let +the choice of rep do the work. Each step runs in **its own process**, because the heap +is process-wide: `opt3` reports 2 faults after opt1/opt2 have run and 92 251 alone. + +**At `n_streams=4` the probe's kernel column is engine occupancy** — the union of kernel +intervals per frame, not per-kernel duration. f64 9×9 reads 32.66 µs at `s4` while each +kernel is ~43.2 µs (`overlap = 1.32`). Use **`s1` for exclusive kernel times** (the +Act III claim) and **`s4` for the peak**, which is the configuration the ladder runs — +subject to §4's rule that the peak is the lower of that estimate and the best rate +actually sustained. + +Companion documents: +- `docs/pedestal_precision_f32_cancellation.md` — why the naive f32 pedestal failed and how B1 fixes it +- `docs/cf_cuda_fused.pptx` — the deck these numbers feed, built by `docs/deck/build_fused_deck.py` + +--- + +## 1. Environment + +| item | value | +|---|---| +| GPU | NVIDIA GeForce RTX 4090 (Ada, sm_89), 24 GB, driver 595.71.05 | +| GPU clocks | idle 210 MHz → boost 3120 MHz; **persistence mode disabled** | +| FP64 rate | 1/64 of FP32 on this part (relevant to Act III) | +| CPU | AMD Ryzen 9 7950X, 16 cores / 32 threads | +| RAM | 125 GiB, **no swap** | +| CUDA | 12.4 (nvcc V12.4.131) | +| Profiler | Nsight Systems 2024.5.1 (`/opt/nvidia/nsight-systems/2024.5.1`) | +| Host | `pc-moench-04` | +| Branch | `bench/opt2-pipeline` @ `7177f00` (off `feature/cuda_clusterfinder`) | + +**Dataset** — MOENCH, MAX IV beamtime, Cu fluorescence: + +``` +/mnt/sls_det_storage/moench_data/2603_MaxIVBeamtime/2026032408/process/xrf/ + Cu_factor_10_data_master_0.json (100 000 frames, 400×400 uint16) + Cu_factor_10_pedestal_master_0.json (1 000 frames used for pedestal training) +``` + +Frame size 400×400×2 B = 320 000 B (312.5 KiB). All finders trained on the +**same 1 000 pedestal frames**; `n_sigma = 5` throughout. Data pre-loaded into RAM with +`read_n()` so file I/O is outside every timing loop. + +--- + +## 2. The ladder — three acts + +The ladder is ordered by **which bar is tallest**. Each act removes the current binding +constraint and thereby *creates* the motivation for the next one. + +| act | the bar in the way | steps | build | +|---|---|---|---| +| **I — feeding the GPU** | host submission and transfer overhead | opt1 · opt2 · opt3 · opt4 | `[f64]` | +| **II — getting results back** | the host result path | opt5 · opt6 | `[f64]` | +| **III — the kernel itself** | the kernel, *now that nothing else is in the way* | opt7 | **`[f32]`** | + +The build axis falls on the act boundary: Acts I and II are entirely f64, and the flip +to f32 **is** Act III. + +| step | what changed | how it is measured | +|---|---|---| +| **baseline** | `ClusterFinderMT` at its **best** thread count — 24 at 3×3, 32 at 9×9 (§4.1) | `ClusterFinderMT(..., n_threads=N)` + `ClusterCollector` | +| **opt1** | first CUDA port: 1 stream, one launch per frame, no batching | `ClusterFinderCUDAOpt2(..., n_streams=1)` + `find_clusters()` per frame | +| **opt2** | multi-stream scaffolding + host-side batching (bulk memcpy) | `ClusterFinderCUDAOpt2(..., n_streams=4)` + `find_clusters_batched()`, batch 2000 | +| **opt3** | pipeline rework: remove per-round sync barriers, fixed-size D2H | `ClusterFinderCUDA(..., n_streams=4)` + `find_clusters_batched()`, **no pinning** | +| **opt4** | DMA-speed transfers via pinned host input | opt3 + `register_input_buffer(data)` | +| *route A* | *CUDA Graphs — pre-recorded H2D→kernel→D2H per stream* | **rejected**, §7 | +| **opt5** | host↔GPU overlap: the batch is chunked and chunk i+1 submitted before chunk i is collected | §8; now internal to `find_clusters_batched()` | +| *route B′* | *one allocation per chunk (`collect_packed`)* | **rejected**, §10 | +| *route B″* | *parallel materialization over a thread pool* | **rejected**, §10 | +| **opt6** | zero-copy collection: results are read in place from the pinned D2H buffer instead of being copied into per-frame `ClusterVector`s | `collect_view()` / `find_cluster_views_batched_iter()` — §9 | +| **opt7** | **the kernel**: f32 device pedestal + B1 variance rewrite | rebuild with `DEVICE_PED_TYPE = float` — §11 | + +### 2.1 The fork after opt4, and three rejected routes + +Both routes out of opt4 need pinned input, so the split comes after it, not before. + +**Route A — CUDA Graphs** (§7) attacks per-frame launch API cost. Against Act I's +bottleneck this was the right target; by the end of Act II launch overhead no longer +binds, and the graph finder never received the chunked pipeline of opt5. + +**Routes B′ and B″** (§10) both attack the 467 kB/frame result copy without removing it +— one allocation per chunk, and parallel materialization. Both lose to the allocator. + +All three are documented rather than deleted, because the reasoning that motivated them +was sound against the bottleneck of the time and the bottleneck moved. That is the thesis +of this document, and the failures are better evidence for it than the successes. + +**Event removal is not a rung.** CUDA-event kernel timing costs 10–15 % of end-to-end +throughput (§3.2) to produce a number that is unusable under multi-stream load. It is +switched off on *all three* finders for the whole campaign — a comparability requirement, +not an optimization. + +### Code behind each step + +| step | primary source | +|---|---| +| opt1, opt2 | [`include/aare/ClusterFinderCUDAOpt2.hpp`](../include/aare/ClusterFinderCUDAOpt2.hpp), [`include/aare/clusterfinder_kernel_opt2.cuh`](../include/aare/clusterfinder_kernel_opt2.cuh) — snapshot of commit `88e0e8d` (pre-refactor pipeline), namespace `aare::device_opt2` | +| opt3 – opt7 | [`include/aare/ClusterFinderCUDA.hpp`](../include/aare/ClusterFinderCUDA.hpp), [`include/aare/clusterfinder_kernel.cuh`](../include/aare/clusterfinder_kernel.cuh) | +| route A | [`include/aare/ClusterFinderCUDA_graph.hpp`](../include/aare/ClusterFinderCUDA_graph.hpp) | +| bindings | [`python/src/bind_ClusterFinderCUDAOpt2.hpp`](../python/src/bind_ClusterFinderCUDAOpt2.hpp), [`python/src/cuda_bindings.cu`](../python/src/cuda_bindings.cu) | +| factories | [`python/aare/ClusterFinder.py`](../python/aare/ClusterFinder.py) | + +Relevant history: `3ed773e` (multi-stream+batched) → `ac96d1f` (mixed precision) → +`88e0e8d` (**opt1/opt2 snapshot**) → `6a12e3d` (pipeline refactor = opt3) → +`4c66802` (FP32 pedestal, introduced the tail) → `5922c73` (async API) → +`1bf317f` (local-max fix) → `a42d71c` (graphs = route A). + +### Precision configuration + +Two type aliases in [`clusterfinder_kernel.cuh:16-17`](../include/aare/clusterfinder_kernel.cuh#L16-L17): + +```cpp +using COMPUTE_TYPE = float; // stencil arithmetic — float in ALL builds below +using DEVICE_PED_TYPE = double; // device pedestal — the opt7 knob (double → float) +``` + +- **"f64 build"** = `COMPUTE_TYPE=float`, `DEVICE_PED_TYPE=double` (mixed precision). **Acts I–II.** +- **"f32 build"** (opt7) = `COMPUTE_TYPE=float`, `DEVICE_PED_TYPE=float` (100 % f32). **Act III.** + +> ⚠️ `ClusterFinderCUDAOpt2` is templated on `PEDESTAL_TYPE`, which its binding pins +> to `double` ([`bind_ClusterFinderCUDAOpt2.hpp:14`](../python/src/bind_ClusterFinderCUDAOpt2.hpp#L14)). +> **opt1 and opt2 are unaffected by the opt7 flip** — their numbers are identical in both +> arms by construction, which makes them a useful cross-arm control. Only opt3–opt6 +> respond. + +--- + +## 3. Methodology — what is measurable and what is not + +Three measurement artifacts were identified and controlled. **All three matter for how +numbers may be quoted on a slide.** + +### 3.1 First-touch page faults — the dominant artifact + +This is the single largest source of bogus numbers in this campaign, so it is worth +stating the mechanism precisely. + +**What a minor fault is.** A page exists in the process's virtual address space but has +no physical frame behind it yet. On first touch the kernel finds a free frame, **zeroes +it** (mandatory, for security), and maps it. No disk I/O — that would be a *major* +fault, and `ru_majflt` stays at 0 throughout this work. At 4 kB/page, 1 GB of +freshly-touched memory costs **262 144 minor faults**. None of this is CUDA-specific; +it is how every anonymous allocation on Linux behaves. + +#### Two sources, both counted as `ru_minflt` + +`getrusage` cannot distinguish them, but they behave completely differently: + +| | **(a) result heap** | **(b) pinned D2H slots** | +|---|---|---| +| allocator | `malloc` → `mmap`; one `ClusterVector` per frame, in `collect()` **and in the CPU finder** | `cudaMallocHost` in `submit_batch` | +| trigger | first write after allocation | slot creation (or re-creation) | +| measured cost | **0.7 µs/page** (§12) | **1.0 µs/page** (§12.1) — the same fault, plus pinning and DMA mapping | +| recurs? | **yes** — every alloc/free cycle. `free` above glibc's mmap threshold `munmap`s, handing pages back to the OS | **no** — once per buffer, for its lifetime | +| control | reuse the heap: re-run until the counter plateaus; drop the previous results **before** entering the timed region. **Only partly effective at 9×9**, where the plateau is ~292 k/pass rather than 0 (§12.2) | `reserve_output_slots()` before starting the timer | +| eliminated by | `collect_view()` — it allocates nothing (opt6, §9) | nothing; it is a one-off startup cost, not a per-frame cost | + +The CPU baseline exhibits (a) and never (b) — it pins nothing. That is the cleanest +demonstration that (a) is a property of the allocator, not of CUDA. + +**What does *not* fault**, and is therefore never the explanation: + +- the GPU's D2H write into the pinned slot — pinning guarantees residency before any + DMA, which is the entire reason the buffer is pinned; +- the host *reading* that slot in `collect()` / `sums()` — those pages are resident. + `collect()`'s faults come from its heap **destination**, not from its source; +- `register_input_buffer` — `cudaHostRegister` pins pages the `data` array already + touched when it was read from file. + +#### Instrumentation + +Bracketing every timed region: + +```python +import resource +def _faults(): + r = resource.getrusage(resource.RUSAGE_SELF) + return r.ru_minflt, r.ru_majflt +``` + +#### Diagnostic procedure ★ + +When a run is slower than expected, resolve the fault count before looking anywhere else: + +1. **Is it the whole discrepancy?** Multiply the count by 0.7–1.0 µs and compare to the + gap in wall time. If it accounts for the gap, stop — there is no other bug. +2. **Which source?** Multiply the count by 4 kB and name the buffer: + - ≈ Σ clusters × `sizeof(ClusterType)` → **(a) heap**; + - ≈ `NUM_SLOTS × chunk × m_output_bytes_per_frame` → **(b) pinned slots**. + The second is easy to compute exactly and in practice matches to four digits (§12.1) + — which makes it a positive identification rather than a guess. +3. **Re-run.** (a) decays toward a plateau as the heap is reused. (b) is *identical on + every run* if the finder is constructed inside the timed region, and *zero* if it is + not — because the slots survive with the object. +4. **Confirm (a)** with `MALLOC_ARENA_MAX=1`, which collapsed 2.27 M faults to 138 k in + the parallel-materialization experiment (§10). +5. **Confirm the cost model** by correlating Δwall against Δfaults over successive runs + (§12) — it reconstructs run 1 from run 3 to 1 ms over 6 s. + +**Protocol: quote the run where the fault counter has plateaued.** Cold numbers are +inflated by 25–45 %. "Plateaued" means *stable across consecutive runs*, not below any +fixed threshold: at 3×3 with `collect()` the plateau is 0, but at 9×9 it is ~292 k/pass +and stays there forever (§12.2). Judge by stability, and if the plateau is non-zero, say +so alongside the number. + +### 3.2 CUDA-event `kernel_ms` inflates under multi-stream saturation + +`avg_kernel_time_ms()` uses a CUDA event pair on the kernel's own stream. It measures +**elapsed time on that stream's timeline**, which includes queue-wait when other streams +are competing for SMs. Consequences: + +- In transfer-paced regimes: `event ≈ true kernel + ~5–7 µs` launch gap → usable. +- In kernel-saturated regimes: **inflated up to 3.5×** → **not quotable**. +- Symptom of saturation: the derived `PCIe + overhead = wall/N − kernel_ms` **goes + negative** (kernels overlap, so wall/frame < kernel/frame). + +Ground truth requires Nsight Systems (§4). + +The instrumentation is also **not free**. Warm-vs-warm at 3×3, `N = 100 000`, identical +runs with only the flag differing: + +| path | events ON | events OFF | Δ | +|---|--:|--:|--:| +| `find_clusters_batched` | 27.4 µs | 24.6 µs | **−2.8 µs (−10 %)** | +| `submit`/`collect` serial | 29.9 µs | 26.0 µs † | **−3.9 µs (−13 %)** | +| `submit`/`collect` pipelined | 23.3 µs | 19.9 µs | **−3.4 µs (−15 %)** | + +† fault-corrected. An independent prediction from API accounting gives **3.64 µs/frame**; +three end-to-end paths land at 2.8–3.9 µs. The two methods agree. + +> **The kernel-timing instrumentation costs 10–15 % of end-to-end throughput to produce a +> number that is itself unusable under multi-stream load.** `time_kernels=False` is the +> default on all three finders and is held there for the whole campaign; kernel times +> come from nsys. + +This matters for comparability, not just cost: 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. `ClusterFinderCUDAOpt2` gained the flag for exactly this reason, +so opt1/opt2 are measured on the same terms as opt3+. + +### 3.3 Profiler distorts wall clock + +Under `nsys`, wall time per frame is ~4× the unprofiled value (API tracing overhead). +**Take per-operation GPU times from nsys; take wall times from unprofiled runs.** The +standalone probe script also pays the full first-touch fault tax inside its single timed +call (fresh process, no warm pass), so **its wall times are not throughput numbers** +either. + +The reverse also holds and is easy to forget: **a probe roofline is a mild over-estimate +of the floor**, by 1.9–6.8 % here. The cause is not dilated durations — the `_KERNEL` +table is hardware-stamped, and the `[f32]` 9×9 4-stream probe puts the binding D2H bar at +25.24 µs against 25.14 µs sustained — 0.4 % apart. It is that `kernel_us_per_frame` is a **union of intervals measured in +a loop the profiler slows to ~69 µs/frame**: sparser submission means less overlap, so the +union per frame reads high. §4 handles this by defining peak as the lower of the estimate +and the best sustained rate. + +### 3.4 Other controls + +- GPU clock ramp is < 0.1 % of a multi-second run; the invariance of `kernel_ms` across + loaded/idle runs confirms it is not a factor for the ladder. It *is* a factor for short + probes, which is why probes are 20 000 frames (§0). +- **The GPU must be idle.** `run_ladder.py` and `run_probes.py` both 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 do not check. +- `ClusterFinderMT` cannot restart after `stop()`; the CPU baseline is therefore always a + first-pass number and carries its own ~1.8 s of allocator faults. Every speedup column + below divides by a **cold** CPU and reads ~9 % generous. +- **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. `reserve_output_slots()` pre-pays the pinned allocation without transferring + or launching anything — verified to leave cluster counts bit-identical (§12.1). + +### 3.5 The CPU baseline — the thread count was wrong ★ + +Every speedup in this document divides by `ClusterFinderMT`. The campaign originally ran +it with `n_threads=48`. **`pc-moench-04` is a Ryzen 9 7950X: 16 physical cores, 32 +logical.** 48 threads oversubscribes it by 1.5×, so the denominator was not the CPU's +throughput — it was the CPU's throughput under contention it need not have had. + +**Source**: [`cpu_threads.py`](../python/tests/perf/cpu_threads.py) → +`results/2026-08-19_cpu_threads/cpu_threads.csv`. Same 1000 pedestal frames, same caps, +same frame counts and same retain semantics as the `cpu` step in `ladder.py`; a fresh +finder per point, one pass each (`stop()` is terminal). + +| threads | 3×3 FPS | 3×3 µs/f | 9×9 FPS | 9×9 µs/f | +|--:|--:|--:|--:|--:| +| 8 | 3 805 | 262.8 | 737 | 1357.0 | +| 16 | 6 594 | 151.6 | 1 237 | 808.4 | +| **24** | **6 762** | **147.9** | 1 348 | 741.6 | +| **32** | 5 942 | 168.3 | **1 503** | **665.2** | +| 48 | 5 121 | 195.3 | 1 338 | 747.3 | + +The 48-thread row reproduces the campaign's baseline to within run noise and lands on +identical cluster counts (233 085 343 at 3×3, 28 438 072 at 9×9), which is what +establishes that this sweep measures the same thing the ladder did. + +Three consequences: + +1. **The baseline moves, so every speedup does.** 3×3 divides by **6 762** (was 4 971 / + 5 229) and 9×9 by **1 503** (was 1 292 / 1 304). The 3×3 headline drops from ×11.7 to + **×9.1** and the 9×9 headline from ×32.4 to **×28.1**. Nothing about the GPU changed; + the CPU was being undersold by 24 % at 3×3 and 15 % at 9×9. +2. **The optimum is per cluster size** — 24 threads at 3×3, 32 at 9×9. `ClusterCollector`'s + drain is inside the timed region (`_drive()` does the drain before the timer stops, so + the GPU rows' own collection is the comparable thing), and that drain scales with thread + count while 9×9 clusters are 9× larger. At 9×9 the *loop alone* keeps getting faster all + the way to 48 threads (800 → 2 449 FPS); it is the drain that turns the curve over. +3. **There is no per-arm CPU baseline any more.** The old `[f64]` 201.16 µs and `[f32]` + 191.26 µs were the same code measured twice — `ClusterFinderMT` never touches + `DEVICE_PED_TYPE`. The 5 % between them was run-to-run noise being reported as a build + difference. One baseline per cluster size now serves every bar in both arms. + +Two timings are recorded per point because the campaign and +`ClusterFinderCUDA_perf.ipynb` do not measure the same region: `loop_s` is the +`find_clusters()` loop alone (what the notebook prints), `wall_s` adds `stop()` and the +collector drain (what `ladder.py` records, and what the GPU rows are comparable to). All +figures above are `wall_s`. + +--- + +## 4. The measuring stick — which bar is tallest ★ + +Everything after this section is read against one table. **Source**: `probes.csv` in +`[f32]` and `[f64]`, 20 000 frames, [`gpu_span.py`](../python/tests/perf/gpu_span.py) +interval-union analysis. + +| config | build | kernel | H2D | D2H | bottleneck | roofline | +|---|---|--:|--:|--:|---|--:| +| 3×3, 4 str | f64 | 15.17 | **16.17** | 7.69 | H2D (barely — 1.0 µs apart) | 16.17 µs → 61 859 FPS | +| 3×3, 4 str | f32 | 5.53 | **16.63** | 7.57 | H2D (decisively — 11.1 µs) | 16.63 µs → 60 140 FPS | +| 3×3, 1 str | f64 | **14.72** | 13.14 | 5.31 | **kernel** | 14.72 µs → 67 918 FPS | +| 3×3, 1 str | f32 | 4.32 | **13.15** | 5.27 | H2D | 13.15 µs → 76 042 FPS | +| 9×9, 4 str | f64 | **32.66** | 20.77 | 25.25 | kernel | 32.66 µs → 30 621 FPS | +| 9×9, 4 str | f32 | 23.94 | 20.54 | **25.24** | **D2H** (opt7 dropped the kernel under it) | 25.24 µs → 39 614 FPS | +| 9×9, 1 str | f64 | **39.86** | 13.20 | 21.97 | kernel | 39.86 µs → 25 086 FPS | +| 9×9, 1 str | f32 | **23.70** | 13.22 | 21.95 | kernel | 23.70 µs → 42 197 FPS | + +PCIe is full-duplex, so the floor is `max(H2D, D2H, kernel)` — **never the sum**. + +### Peak throughput: the definition every percentage in this document uses + +> **Peak = 1 / max(H2D, kernel, D2H)**, where each term is that engine's **busy time +> per frame** — the union of its intervals divided by frame count — at the ladder's +> **4 streams**; and taken as **the lower of two estimates: the profiled engine +> occupancy above, and the best rate the unprofiled pipeline sustained.** + +Three things this pins down, each of which was got wrong at some point: + +1. **Union, not duration.** At 9×9 `[f64]` one kernel *lasts* 43.18 µs at 4 streams, + but the engine is occupied only 32.66 µs per frame because kernels overlap 1.32×. + Quoting the duration understates the machine by a third. §14 rejects the reverse + error — quoting `s4` as if it were a kernel duration. +2. **At 4 streams, not 1.** The floor is a property of *(kernel, stream count)*, not of + the kernel. At 9×9 `[f64]` four streams lower it from 39.86 to 32.66 µs; at `[f32]` + the kernel is short enough that overlap reaches only 1.02× and four streams *raise* + it, 23.70 → 23.94 — where D2H, at 25.24 µs, has already overtaken it. The `1 str` rows answer "how long is one kernel", never "how fast + can this go". +3. **Lower of the two, because a sustained rate is an existence proof.** The probe is an + estimate made in a loop nsys slows to ~69 µs/frame; sparser submission means less + overlap, so its union per frame reads high. Where the pipeline sustained better, that + is the floor. + +| config | probe estimate | best sustained | **peak** | which binds | +|---|--:|--:|--:|---| +| 3×3 `[f64]` | 16.17 µs | 17.10 µs | **16.17 µs → 61 859 FPS** | probe | +| 3×3 `[f32]` | 16.63 µs | 16.31 µs | **16.31 µs → 61 312 FPS** | sustained | +| 9×9 `[f64]` | 32.66 µs | 30.01 µs | **30.01 µs → 33 323 FPS** (kernel) | sustained | +| 9×9 `[f32]` | 25.24 µs | 25.14 µs | **25.14 µs → 39 775 FPS** (**D2H**) | sustained | + +The 3×3 `[f64]` row is what keeps this from being circular: there the pipeline stopped +5.4 % **short** of the probe estimate, so the probe binds and opt6 reads 95 %, not 100 %. +Where the sustained rate does win, the probe still corroborates it — to 1.9 %, 6.8 % and +2.5 % respectively — which is the evidence that those runs were engine-bound rather than +merely fast. Quote both. + +> ### **The rule that explains the whole document: an optimization buys exactly the distance between the bar it attacks and the next-tallest bar.** +> It predicts every result below, including the three that look like failures. + +### The two 3×3 rooflines, and the difference between them + +| | H2D/frame | FPS | meaning | +|---|--:|--:|---| +| *uncontended*, 1 stream | 13.15 µs | 76 042 | best case: no D2H in flight. **Unreachable in this config** | +| *achieved-config*, 4 streams (probe) | 16.63 µs | 60 140 | nsys's estimate of what four streams sharing one DMA engine deliver | +| **best sustained** | **16.31 µs** | **61 312** | what the pipeline actually reached — the peak, per the definition above | + +Percent-of-peak figures use the **best sustained** value. The 3.2 µs between it and the +uncontended rate is +transfer granularity — 2 000 separate 320 kB descriptors — not host code, and closing it +is a different optimization from anything in Acts I–III. + +### H2D↔D2H interference, measured at both sizes + +H2D slows once D2H traffic runs against it, because H2D is a DMA **read** whose request +packets travel upstream against D2H's posted writes: + +| | 1 stream | 4 streams | penalty | +|---|--:|--:|--:| +| 3×3 H2D | 13.15 µs | 16.63 µs | **+26 %** | +| 9×9 H2D | 13.24 µs | 19.74 µs | **+49 %** | + +The 1-stream figures are identical across cluster sizes (13.15 / 13.24 µs) and builds — +the payload is the same 320 000 B frame — which proves the H2D path itself is unchanged +and the difference is contention alone. + +### 4.1 Engine duty cycles — is the GPU actually saturated? + +`nsys stats` reports per-operation sums, which cannot distinguish "the engine was busy" +from "the engine was idle waiting". `gpu_span.py` reads the SQLite export and computes +the **union** of each engine's activity intervals over the processing window (first +kernel start → last D2H end, which excludes the pedestal-upload H2Ds that precede any +kernel). **Source**: `probes.csv`, columns `*_duty_pct`, `*_overlap`. + +| config | build | kernel duty | H2D duty | D2H duty | kernel overlap | +|---|---|--:|--:|--:|--:| +| 3×3, 4 str | f64 | 65.1 % | **69.4 %** | 33.0 % | 1.06× | +| 3×3, 4 str | f32 | 24.2 % | **72.7 %** | 33.1 % | 1.00× | +| 9×9, 4 str | f64 | **47.0 %** | 29.9 % | 36.3 % | **1.32×** | +| 9×9, 4 str | f32 | 34.2 % | 29.4 % | **36.1 %** | 1.02× | + +Two readings: + +1. **At 3×3 the copy engine is the saturated one** (~70 % H2D in both builds) — the GPU + spends three quarters of its time being fed. At 9×9 no single engine exceeds 47 % + because all three are comparable and interleave; the kernel is tallest but not + dominant. +2. **`overlap` quantifies cross-stream kernel concurrency.** At f32/9×9 it is 1.02× — + one 9×9 kernel nearly fills the GPU, so extra streams buy transfer overlap, not + kernel co-execution. On f64 it rises to **1.32×**, because the longer f64 kernels + leave more opportunity to interleave. This is why the f64 `s4` kernel column + (32.66 µs) is well below its `s1` per-kernel duration (39.86 µs): **it is occupancy, + not duration.** Quote `s1` when you mean "how long is the kernel". + +### 4.2 The cluster cap is a throughput knob, not just a safety bound + +D2H is fixed-size at `cap × sizeof(Cluster)` regardless of how many clusters were found. +At 9×9 (`sizeof = 328 B`) the cap therefore sets the D2H bar directly. Measured D2H fill +at the campaign caps: **77 % at 3×3 (cap 3000), 84 % at 9×9 (cap 1700)** — printed by +`nsys_kernel_probe.py`. This is not hypothetical: going from the old cap of 1500 to the +lossless 1700 moved D2H 22.8 → 25.2 µs, which on `[f32]` already overtakes the 23.9 µs +kernel (§11.4). Doubling to 3000 would put D2H at ~45 µs/frame on both arms. Keep +the cap at the smallest value that never truncates. + +--- +--- + +# ACT I — feeding the GPU `[f64]` + +*The kernel is untouched throughout. What shrinks is everything around it.* + +## 5. Act I at 3×3 ★ headline + +**Source**: `ladder_3x3.csv` in `[f64]`. N = 100 000, cap 3000, 4 streams, 5 reps, each +step in its own process. Roofline: **H2D at 16.17 µs / 61 859 FPS** (§4). + +| step | what changed | cold µs/f | cold FPS | warm µs/f | warm FPS | spread | **vs CPU** | step gain | % of peak | +|---|---|--:|--:|--:|--:|--:|--:|--:|--:| +| baseline | `ClusterFinderMT`, 24 threads | 147.88 | 6 762 | 147.88 | 6 762 | — | 1.00× | — | 11 % | +| **opt1** | 1 stream, one launch per frame | 63.99 | 15 628 | 63.26 | 15 807 | 1.2 % | **2.34×** | 2.34× | 26 % | +| **opt2** | 4 streams + host batching | 41.87 | 23 883 | 40.44 | 24 726 | 5.3 % | **3.66×** | 1.56× | 40 % | +| **opt3** | pipeline rework, no pinning | 34.60 | 28 901 | 34.26 | 29 188 | 2.1 % | **4.32×** | 1.18× | 47 % | +| **opt4** | + pinned input (DMA H2D) | 26.95 | 37 110 | 25.98 | 38 486 | 3.2 % | **5.69×** | 1.32× | **62 %** | + +Cluster counts agree to 4 × 10⁻⁶ across every CUDA step (233 094 3xx against 233 085 343 +on the CPU; the residual is the per-frame vs per-pixel pedestal-update difference of §13, +not the pipeline). + +**opt4 is the largest single step in Act I (1.32×), and §4 says why**: at 3×3 the H2D bar +is the tallest, so pinning the input attacks the bar that actually binds. The same step +is worth only 1.02× at 9×9 (§6), where H2D is the *shortest* bar. This is the first +confirmation of the rule. + +**Where Act I ends: 62 % of peak.** The remaining 9.8 µs/frame is host-side, and +Act II is the argument that it is *all* the result path. + +**Status: quotable.** The CPU baseline is first-pass by necessity (`stop()` is terminal), +so every speedup divides by a cold CPU and reads ~9 % generous. State the convention. + +--- + +## 6. Act I at 9×9 ★ + +**Source**: `ladder_9x9.csv` in `2026-08-20_f64_cap1700/`. N = 20 000, **cap 1700**, +4 streams, 5 reps. The earlier cap of 1500 sat below the per-frame maximum of 1633 and +silently truncated 0.0095 % of clusters (§3.5); every 9×9 number here is the lossless +re-take. opt1/opt2 are absent: `ClusterFinderCUDAOpt2` is registered for 3×3 only, so +the 9×9 ladder starts at opt3. Peak: **30.01 µs / 33 323 FPS** — the best sustained +rate; the probe's engine-occupancy estimate is 32.66 µs / 30 621 FPS, 8.1 % lower (§4). + +| step | cold µs/f | cold FPS | warm µs/f | warm FPS | spread | **vs CPU** | step gain | % of peak | +|---|--:|--:|--:|--:|--:|--:|--:|--:| +| baseline CPU, 32 threads | 665.22 | 1 503 | 665.22 | 1 503 | — | 1.00× | — | 5 % | +| **opt3** | 93.18 | 10 732 | 82.44 | 12 129 | **14.2 %** | **8.07×** | 8.07× | 36 % | +| **opt4** | 92.52 | 10 809 | 79.83 | 12 527 | **13.7 %** | **8.33×** | 1.03× | **38 %** | + +Two things are different here and both matter: + +1. **opt4 is worth almost nothing (1.02×)** — exactly as §4 predicts. At 9×9, H2D is + 13.20 µs against a 39.86 µs kernel; making the shortest bar shorter changes nothing. +2. **The spread is 14 %.** At 3×3 the same steps vary 2–3 %. This is not noise, it is the + result heap: at 9×9 each pass allocates ~9.3 GB, above glibc's mmap threshold, so it is + `munmap`ed and re-faulted every pass and never plateaus (§12.2). **A 24 % spread means + the number depends on which run you quote** — and it is the first symptom of the + problem Act II solves. + +**Where Act I ends: 37 % of peak.** The GPU delivers a frame every 30 µs; the system +delivers one every 80. **Three quarters of the time is host-side**, and none of the +remaining device-side work can be reached until that is fixed. + +--- + +## 7. Route A — CUDA Graphs (rejected) + +The fork after opt4. Graphs pre-record H2D→kernel→D2H per stream, replacing per-frame +launch API calls with a single graph launch. Against Act I's bottleneck this was a sound +idea; it did not survive Act II. + +**Source**: `ladder_*.csv` in `[f64]`. + +| config | opt4 warm | route A warm | vs opt4 | spread | against the eventual opt5 | +|---|--:|--:|--:|--:|--:| +| 3×3 | 25.98 µs | 25.16 µs | ×1.03 | 0.7 % | opt5 is 19.84 → route A is **27 % behind** | +| 9×9 | 79.83 µs | **90.32 µs** | **×0.88 (worse)** | 12.1 % | opt5 is 66.39 → route A is **36 % behind** | + +Three reasons it is retired rather than pursued: + +1. **It is slower at 9×9 than the step it was meant to improve on**, by 12 %. +2. **Its 3×3 gain is not established.** The graph finder never recorded CUDA events while + the stream finder did, and the event tax (2.8 µs, §3.2) is larger than the measured gap + (0.8 µs). On an events-OFF build the two are within run-to-run noise. +3. **It never received the chunked pipeline of opt5**, and its original advantage — lower + per-frame launch API cost — is swamped by an overlap it does not have. + +Reviving it would mean giving the graph finder opt5 and opt6 first, at which point it is +competing on a per-frame API cost of ~2 µs against a 25.24 µs floor. **Launch overhead +stops being the binding constraint one step later**, so a technique aimed at it can no +longer pay. + +--- +--- + +# ACT II — getting the results back `[f64]` + +*Act I left the GPU 38 % idle at 3×3 and 60 % idle at 9×9. This act is the demonstration +that all of it is the result path, and the removal of it.* + +## 8. opt5 — chunked host↔GPU overlap ★ + +### Two orthogonal overlap axes + +These are routinely conflated; they compose rather than subsume: + +| axis | what it overlaps | delivered by | evidence | +|---|---|---|---| +| **GPU-internal** | H2D ∥ kernel ∥ D2H, *across streams within one batch* | opt3 | duty cycles, §4.1 | +| **host↔GPU** | host result materialization ∥ GPU execution, *across batches* | **opt5** | this section | + +`find_clusters_batched` **had** the first and not the second: it synchronized, then built +thousands of `ClusterVector`s on the host with the GPU idle. Keeping one batch in flight +while materializing the previous one hides that: + +```python +tok = cf.submit_batch(data[a0:b0], first_frame=a0) +for a, b in bounds[1:]: + nxt = cf.submit_batch(data[a:b], first_frame=a) # GPU starts batch N+1 … + results.extend(cf.collect(tok)) # … while host materializes batch N + tok = nxt +results.extend(cf.collect(tok)) # drain +``` + +**Source**: `ladder_*.csv` in `[f64]`. + +| config | opt4 warm | **opt5** warm | step gain | spread | % of peak | +|---|--:|--:|--:|--:|--:| +| 3×3 | 25.98 µs / 38 486 FPS | **19.84 µs / 50 410 FPS** | **×1.31** | 1.2 % | 62 % → **81 %** | +| 9×9 | 79.83 µs / 12 527 FPS | **66.39 µs / 15 063 FPS** | **×1.20** | 22.4 % | 38 % → **45 %** | + +> ### **opt5 result: ×1.31 at 3×3 and ×1.20 at 9×9 — for an API change with no CUDA work at all.** + +The hidden time is `min(GPU, host)` by construction. A cross-check on the events-ON build +gave serial 29.9 → pipelined 23.3 µs at 3×3, hiding **6.6 µs**; on the events-OFF build +it is 26.0 → 19.9, hiding **6.1 µs**. The build changed, the absolute times changed, and +the overlap window did not — as it should not. + +**No staging copy is needed.** `submit_batch` reads directly from `frames.data()`, so +slices of one already-pinned array can be handed to it as-is. The two-alternating-buffer +pattern in the `submit_batch` docstring costs ~6.5 µs/frame in `memcpy` and is only +required when frames arrive into a buffer that is being rewritten. + +### 8.1 opt5 is now internal — the manual pattern is retired + +The ping-pong above delivers the overlap but pushes token lifetime onto the caller. It is +now internal: `find_clusters_batched()` splits its batch into chunks and runs the same +submit-i+1-before-collect-i loop itself, so `collect()` callers get opt5 for free and the +two paths converge (×1.03–1.05 residual, i.e. noise). + +Three constraints shape the chunk size, all in `resolve_batch_chunk()` +([`ClusterFinderCUDA.hpp:182`](../include/aare/ClusterFinderCUDA.hpp#L182)): + +| constraint | rule | why | +|---|---|---| +| enough chunks to pipeline | `DEFAULT_BATCH_CHUNKS = 8` | fill/drain waste is `1/(C·max)`, per-chunk tail cost is `C·n_streams·kernel`; the product is flat near 8 | +| **multiple of `n_streams`** | round up | frames go to streams round-robin (`frame_idx % n_streams`) and the device pedestal is **per stream**. A chunk size that shifts the assignment changes which pedestal state a frame sees — **a correctness constraint, not tidiness** | +| bounded pinned footprint | `MAX_SLOT_BYTES = 128 MiB` | added later; see §12.1 for the failure it fixes | + +`set_batch_chunk(n)` overrides the auto rule; `chunk_size_for(n)` exposes what it would +choose, so a caller driving `submit_batch`/`collect_view` by hand can match the pipelining +without duplicating the rounding rules. Setting `set_batch_chunk(batch_size)` disables +internal chunking, which is how the ladder reproduces opt3/opt4 on the current class. + +### 8.2 What opt5 does *not* fix — the diagnosis that motivates opt6 ★ + +At 9×9, opt5 lands at **66.39 µs against a 30.01 µs peak**. The pipelined loop runs at +`max(GPU, host)` by construction, so this is arithmetic, not inference: + +> **The GPU delivers a frame every 30.01 µs and the system delivers one every 66.39. The +> entire ~36 µs/frame gap is host-side, and overlap cannot hide it because the host term +> is the larger one.** + +The host path is `collect()`'s materialization loop +([`materialize_slot`, `ClusterFinderCUDA.hpp:137`](../include/aare/ClusterFinderCUDA.hpp#L137)): +per frame it reads the counter, `resize()`s a `ClusterVector` (one allocation) and +`memcpy`s **1422 × 328 B ≈ 467 kB** out of the pinned slot — ~934 MB per 2 000-frame +batch, single-threaded, at roughly memcpy bandwidth. + +This also explains why pipelining helped no more at 9×9 (×1.20) than at 3×3 (×1.31) +despite the far larger absolute gap: pipelining hides `min(GPU, host)`, so it pays most +when the two terms are comparable. At 9×9 the host term is twice the GPU term, and hiding +the GPU underneath it recovers only the GPU's share. + +**Fault-fairness warning.** Freeing a ~10 GB result heap hands it back to the allocator +and a subsequent loop reuses it, so on a cold heap the first loop pays the entire +first-touch tax and any printed ratio is meaningless. Both loops must be at plateau; drop +stale result bindings before timing. + +--- + +## 9. opt6 — zero-copy collection ★★ + +Once the pipeline overlaps internally, the binding cost is no longer overlap but the +*transport*: one `ClusterVector` allocation and one copy per frame. + +| path | allocations | copy | ownership | status | +|---|---|---|---|---| +| `collect()` | one per frame | full | owned | **kept** — the default | +| `collect_packed()` | one per chunk | full | owned | **rejected**, §10 | +| **`collect_view()`** | **none** | **none** | borrowed until released | **kept — opt6** | + +`collect_view()` returns a `BatchView` whose `frame_data(i)` / `frame_xy(i)` are strided +numpy views directly over the pinned D2H slot. **It withholds ownership past the chunk, +not access** — every cluster's payload and coordinates are readable. +`find_cluster_views_batched_iter()` in +[`python/aare/ClusterFinder.py`](../python/aare/ClusterFinder.py) drives it as an iterator. + +Two defects in the *slot* logic had to be fixed first, both found by the fault accounting +of §12.1 and both adding cost inside the timed region: + +| defect | fix | effect | +|---|---|---| +| `resolve_batch_chunk` was `n_frames/8` with **no upper bound**, so one big call pinned 2.46 GB | `MAX_SLOT_BYTES = 128 MiB` caps the auto chunk **by bytes** | one large call now behaves like a loop over slices | +| the first `submit_batch` page-locks both slots **inside the caller's timer** | `reserve_output_slots(n)` — allocates only; no transfer, no launch, **no pedestal advance** | moves a one-off ~66–480 ms out of the measurement | + +### Result — `[f64]`, warm, 5 reps + +| config | peak (§4) | opt5 `collect()` | **opt6 `collect_view()`** | step gain | % of peak | +|---|--:|--:|--:|--:|--:| +| **3×3** cap 3000 | 16.17 µs / 61 859 FPS | 19.84 µs / 50 410 FPS | **17.10 µs / 58 495 FPS** | ×1.16 | 81 % → **95 %** | +| **9×9** cap 1700 | 30.01 µs / 33 323 FPS | 66.39 µs / 15 063 FPS | **30.01 µs / 33 323 FPS** | **×2.21** | 45 % → **100 %** | + +> ### **opt6 result: the host leaves the critical path in both regimes.** 3×3 reaches 95 % of its H2D floor; 9×9 goes from 45 % to the floor itself, a **×2.21** step. + +**On the 9×9 figure.** The measured 30.01 µs is 8.1 % *below* the profiled 32.66 µs floor, +against 0.4 % on `[f32]`, and the reason is specific: the f64 `s4` kernel column is an +*interval union* at `overlap = 1.32`, so the sparse submission of a profiled loop costs it +more overlap than it costs a barely-overlapping engine (`[f32]`, 1.02) — and on `[f32]` the +binding bar is D2H, a copy engine, which does not overlap with itself at all. **Read it as "at the floor"** — which is what §4's definition makes it, +since the sustained rate is the lower of the two. + +### Why the margin differs so much between the two configurations + +The win from `collect_view()` is `max(0, host_copy − gpu_floor)` plus the allocation it +avoids: + +| | bytes copied per frame by `collect()` | ≈ copy time | GPU floor | copy fits underneath? | +|---|--:|--:|--:|:--:| +| 3×3 | 2 324 × 40 B ≈ 93 kB | ~8 µs | 16.17 µs | **yes** → small win (×1.16) | +| 9×9 | 1 422 × 328 B ≈ 467 kB | ~40 µs | 30.01 µs | **no** → large win (**×2.21**) | + +At 3×3 the copy hides under the GPU and opt5 already absorbs it, so what opt6 removes is +the per-frame allocation and the fault floor — second-order. At 9×9 the copy is 1.5× the +GPU time and cannot hide at any amount of overlap. **This is the same "tallest bar" logic +as §4, applied to the host instead of the device.** + +### Reproducibility is the other half of the claim + +| | 3×3 spread | 9×9 spread | warm faults (3×3 / 9×9) | +|---|--:|--:|--:| +| opt5 `collect()` | 1.2 % | **12.3 %** | 30 795 / 48 608 | +| **opt6 `collect_view()`** | **0.2 %** | **0.2 %** | **0 / 0** | + +> ### **opt6 is not merely the fastest — it is the only step whose throughput is *reproducible*.** Every path that allocates per frame varies 1–25 % run to run; the path that allocates nothing varies 0.2 %. + +**Two things this section is *not* saying:** + +- It is **not** claiming a 3×3 → 9×9 speedup. They are different workloads; each is + compared only against its own roofline. +- The `collect_view()` column excludes downstream analysis, which is the point of the + comparison — `collect()` copies but does not reduce, so timing it against + `collect_view()` + `sums()` + histogram would compare different work. Measured + separately at 3×3: `sums()` ~2 µs/frame, histogram fill ~8.6 µs/frame. **Analysis, not + finder cost** — and now the dominant term. + +### 9.1 opt6's two uses + +The zero-copy path is worth having for two independent reasons, and they should be +presented as two: + +1. **A faster end-to-end path for streaming consumers.** Anything that reduces as it goes + — spectra, fitting, histogramming, writing to disk — never needs 28 M `ClusterVector`s + resident. It needs each cluster once. For those consumers opt6 is simply the fast API, + worth ×1.16 at 3×3 and ×2.21 at 9×9. The only consumer it excludes is one that needs + the entire cluster list in memory at once, which at 9×9 is 9.3 GB. +2. **A profiling instrument.** Because it removes the host from the critical path + entirely, it is the configuration in which the measured wall time *is* the GPU floor. + That is what makes §4's rooflines checkable end-to-end rather than merely computed: + opt6 lands on them, so the H2D ∥ kernel ∥ D2H overlap that the duty cycles claim is + confirmed by throughput, not just by the profiler. **Use 1 to ship; use 2 to prove.** + +--- + +## 10. Routes B′ and B″ — two rejected transports + +Both attack the 467 kB/frame copy without removing it. Both lose to the allocator, for +the same underlying reason, and that reason is the argument for opt6. + +### B′ — one allocation per chunk (`collect_packed`) + +It removes the *per-frame* malloc but keeps the copy, and the copy is ~80 % of the cost. +Worse, the single allocation it substitutes is enormous — chunk 2500 frames × +1422 × 328 B ≈ **1.17 GB** — far above any glibc mmap threshold, so it is `mmap`ed and +`munmap`ed every chunk and every page is faulted fresh: **606 566 faults, ~21 µs/frame.** +Many small allocations at least had a chance of heap reuse. + +Measured on the like-for-like harness where all three paths produce per-cluster sums: +`collect()` 99.4 µs, **B′ 69.3**, `collect_view()` **29.9**. B′ has been **deleted from +the API**; it is documented here so the experiment is not repeated. + +### B″ — parallel materialization + +Before `collect_view()` existed, the obvious attack was to spread the copy over a thread +pool. Implemented, measured, reverted: + +| threads | minor faults | outcome | +|--:|--:|---| +| 1 | 9 700 | reference | +| 8 | **2 270 000** | +6 % at best; **−33 %** (77 vs 57.8 µs/frame) when results were freed promptly | + +The mechanism is the allocator, not the copy. Each worker thread gets its own glibc arena, +which destroys the cross-run heap reuse that makes the single-threaded path cheap — +**confirmed by `MALLOC_ARENA_MAX=1`, which collapsed 2.27 M faults to 138 k.** + +> ### **The lesson both failures teach: the work is allocation-bound, not bandwidth-bound.** Copying faster does not help when the cost is the OS populating pages. The only winning move is not to allocate — which is opt6. + +`materialize_slot()` is deliberately single-threaded and carries a comment recording this +([`ClusterFinderCUDA.hpp:127-134`](../include/aare/ClusterFinderCUDA.hpp#L127-L134)). + +### Correctness across result paths + +| path | clusters | vs CPU | +|---|--:|--:| +| CPU | 28 438 072 | — | +| CUDA per-frame | 28 445 699 | +7 627 (0.0268 %) | +| `collect()` batched | 28 447 973 | +9 901 (0.0348 %) | +| route A (CUDA Graph) | 28 447 972 | +9 900 (0.0348 %) | +| opt5 (pipelined `collect()`) | 28 447 972 | +9 900 (0.0348 %) | +| B′ `collect_packed` | 28 439 289 | +1 217 (0.0043 %) | +| **opt6 `collect_view`** | 28 447 973 | +9 901 (0.0348 %) | + +opt6 is **bit-identical to `collect()`** — same device output, different transport, which +is the invariant that matters. B′'s row differs only because it runs first in its cell and +therefore sees a fresh device pedestal; on a like-for-like harness with a fresh finder per +path, all three give **identical counts** (2 847 776 over 2 000 frames), with checksums +differing by 3 × 10⁻⁷ (§13). + +--- +--- + +# ACT III — the kernel `[f32]` + +*Act II ended with the host off the critical path. What is left is the GPU, and at 9×9 +the kernel now stands alone.* + +## 11. opt7 — the f32 device pedestal ★ + +### The measurement that motivates the act + +At the end of Act II, `[f64]` 9×9 `s4`: + +| bar | µs/frame | +|---|--:| +| **kernel** | **32.66** | +| D2H | 25.25 | +| H2D | 20.77 | + +The kernel stands **9.6 µs above the next bar — 42 % taller.** By §4's rule, that 9.6 µs +is exactly what a kernel optimization can buy, and nothing before Act II could have +collected it because the host stood 31 µs taller still. + +The FP64 rate on this part is **1/64 of FP32**, and the pedestal is the only f64 arithmetic +in the kernel. Dropping it to f32 is therefore the obvious move — and it needs the B1 +rewrite of §11.3 to be correct at all. + +### The kernel itself — `probes.csv`, `*_s1_uncontended` (exclusive, no overlap) + +| config | f64 | f32 | change | +|---|--:|--:|--:| +| **9×9, 1 stream** | **39.86 µs** | **23.70 µs** | **−40.5 %** | +| 3×3, 1 stream | 14.72 µs | 4.32 µs | −70.6 % | + +> ### **opt7 result: the 9×9 kernel drops 40.7 %**, nsys-verified on both builds at 20 000 frames. + +### End-to-end, at the roofline + +**Source**: `ladder_*.csv`, warm, both arms. + +| config | opt6 `[f64]` | **opt6 + opt7 `[f32]`** | change | peak moves | vs peak | +|---|--:|--:|--:|--:|--:| +| **3×3** | 17.10 µs / 58 495 FPS | **16.31 µs / 61 312 FPS** | **−4.6 %** | 16.17 → 16.31 µs | 95 % → **100 %** | +| **9×9** | 30.01 µs / 33 323 FPS | **25.14 µs / 39 775 FPS** | **−16.2 %** | 30.01 (kernel) → 25.14 µs (**D2H**) | 100 % → **100 %** | + +The mechanism is arithmetic: the probe's 9×9 floor moves 32.66 (kernel) → 25.24 µs (D2H, −23 %) and +opt6 moves 30.01 → 25.14 (−16 %). **opt6 sits on the floor, so it inherits the floor's +improvement almost exactly.** + +Both beat the probe's engine-occupancy estimate — by 6.8 % at `[f64]` and 2.5 % at +`[f32]` — which is why §4 defines peak as the lower of estimate and sustained rate. Under +that definition each sits **at** its floor and the estimates corroborate it; a figure over +100 % is nonsense on its face and should never be written. + +### 11.1 Why this act comes last ★ + +Run the identical kernel change through each earlier step's result path and it disappears: + +| measured through | 3×3 f64 → f32 | 9×9 f64 → f32 | +|---|--:|--:| +| opt3 (`collect`, no overlap) | −0.4 % | +16.0 % *(+2 … +20)* | +| opt4 | −4.2 % | −5.8 % *(−17 … +12)* | +| route A (graphs) | −4.3 % | −16.9 % *(−26 … −2)* | +| **opt5** (`collect`) | −0.2 % | **−6.8 %** *(−24 … +17)* | +| **opt6** (`collect_view`) | −4.6 % | **−16.2 %** *(−16.2 … −16.2)* | + +The parenthesised interval is what the two arms' own rep spreads allow: `best case` is +`min(f32)/max(f64)`, `worst case` is `max(f32)/min(f64)`. It is the honest error bar on a +difference of two best-of-warm numbers, and at 9×9 it is devastating for every allocating +path — those steps oscillate between allocator states with 12–26 % spread (§12.2), so the +interval spans 19–41 points and, except for route A, straddles zero. **The point estimates +in that column are not measurements; only `collect_view()`'s is.** + +> ### **Through `collect_view()` the identical −40 % kernel is worth −16.2 %, resolvable to 0.0 points. Through every allocating path the effect cannot be measured at all.** The result path does not merely shrink the kernel win — it destroys the ability to observe it. + +This is a stronger claim than the point estimates it replaces, and it survives its own +error bar. It is also the sharpest form of the ordering rule in this document: you cannot +evaluate an optimization through a stage that is itself unstable, so bottleneck order is +not merely the fastest route — it is the only order in which the intermediate results mean +anything. The 3×3 column stays quotable because 3×3 steps vary 1–3 %, not 12–26 %. + +This is the single most important comparison in the document. Placed anywhere before +Act II, opt7 measures as noise and would reasonably be abandoned. Placed after, it is the +second-largest step in the ladder at 9×9. **The ordering is not presentational — it +determines whether the optimization is visible at all.** + +### 11.2 opt7 also flips the regime at 3×3 + +| config | build | kernel | H2D | bound by | +|---|---|--:|--:|---| +| 3×3, 1 stream | f64 | **14.72** | 13.14 | **kernel** | +| 3×3, 1 stream | f32 | 4.32 | **13.15** | H2D | +| 3×3, 4 streams | f64 | 15.17 | **16.17** | H2D (barely — 1.0 µs apart) | +| 3×3, 4 streams | f32 | 5.53 | **16.63** | H2D (decisively — 11.1 µs apart) | + +"opt7 does nothing at 3×3" is wrong. It does something structural: it moves 3×3 out of the +kernel-bound regime entirely, which is precisely the stated goal of the act — *keep H2D or +D2H the bottleneck*. The reason no large throughput gain appears is that the H2D bar it +lands under is the same height, so the 3×3 roofline barely moves and end-to-end barely +moves with it. Consistent, and the honest framing. + +It also buys 3×3 an **occupancy step**, which is invisible in the throughput because the +kernel is no longer the binding bar. Read from the built extension with +`cuobjdump -res-usage` (see [`perf/kernel_resources.py`](../python/tests/perf/kernel_resources.py)): + +| 3×3, `Cluster`, 16×16 blocks | registers | spills | blocks/SM | occupancy | +|---|--:|--:|--:|--:| +| `[f64]` | 47 | 0 | 5 | **83.3 %** | +| `[f32]` | **38** | 0 | 6 | **100 %** | + +The narrower accumulators free nine registers, which is exactly enough to fit a sixth +block per SM. **At 9×9 nothing moves — 128 registers on both builds** — because the +limiter there is the per-thread `clusterData[9][9]` staging array, not the pedestal +accumulators. Neither build spills. + +This is worth stating explicitly because it is the one place where a register count is +*build-dependent*: any occupancy figure quoted for 3×3 must say which arm it came from. + +### 11.3 opt7 is only shippable because of B1 + +The naive f32 pedestal produces **+28.06 % clusters and an unphysical high-energy tail** — +catastrophic cancellation in `var = sum2/n − mean²`, where both terms are ≈ 2.17 × 10⁷ +while the variance is ≈ 2025, drives quiet pixels to `rms = 0` so they fire every frame. + +**B1 — per-pixel offset accumulation**: freeze a per-pixel baseline `X0 ≈ round(mean)` at +t = 0 and accumulate centered `Y = X − X0`. After the rewrite, full-f32 matches f64 to +**3 × 10⁻⁷** (§13). Derivation in `docs/pedestal_precision_f32_cancellation.md`. + +**Status: quotable.** Both arms same git rev, same harness, `env.json` records each. + +### 11.4 Where Act III lands — and what is left + +| 9×9, 4 streams | f64 | f32 | | +|---|--:|--:|---| +| **kernel** | **32.66** | 23.94 | **no longer the tallest bar** | +| D2H | 25.25 | **25.24** | identical — the slot is cap-sized, not payload-sized | +| H2D | 20.77 | 20.54 | | + +At 3×3 the act achieves its goal decisively — the kernel ends 11.1 µs *below* H2D. At 9×9 +it very nearly does: kernel and D2H end **6 % apart**. Further kernel work at 9×9 is +therefore capped at ~6 % before D2H binds instead, and the next lever there is a smaller +`cap` (§4.2) or coarser transfer granularity, not a faster kernel. + +> ### **The arc, in one line: the bottleneck has been walked from the host, to the GPU, to the wire.** + +--- +--- + +## 12. Supporting study — the page-fault artifact + +### Synthetic isolation + +Allocating ~8 GB in ClusterVector-sized chunks (90 000 × 93 kB), touching every page, +freeing, repeating in one process: + +| run | wall | minor faults | +|---|--:|--:| +| 1 (cold heap) | 2.05 s | 2 046 594 | +| 2 (warm heap) | 0.07 s | 2 232 | +| 3 (warm heap) | 0.07 s | 2 016 | + +**30× faster, 1000× fewer faults**, same allocations — glibc retains the arenas. + +### In situ (opt2, three consecutive executions in one process) + +| run | wall [s] | FPS | minor faults | +|---|--:|--:|--:| +| 1 | 6.110 | 16 366 | 2 625 948 | +| 2 | 4.872 | 20 526 | 729 866 | +| 3 | 4.452 | 22 459 | 185 885 | + +Correlation Δwall vs Δfaults: + +| interval | Δwall | Δfaults | implied cost | +|---|--:|--:|--:| +| 1 → 2 | 1.238 s | 1 895 667 | **0.65 µs/fault** | +| 2 → 3 | 0.420 s | 543 981 | **0.77 µs/fault** | +| 1 → 3 | 1.658 s | 2 439 648 | **0.68 µs/fault** | + +Reconstruction of run 1 from run 3: +`4.452 s + 2 439 648 × 0.68 µs = 6.111 s` vs **measured 6.110 s** (1 ms error over 6 s). + +Kernel time was constant (0.022 ms) across all three — the GPU is not involved. +**Conclusion: the entire first-run penalty is OS page population of the host result heap.** +Persistence mode and clock ramp are not responsible. + +### 12.1 The second source — pinned slot allocation (`cudaMallocHost`) ★ + +opt6 removes source (a) almost entirely: `collect_view()` allocates nothing per frame. That +made source (b) visible for the first time, and it was initially misread as a defect in the +zero-copy path itself. + +**Symptom.** At 3×3, cap 3000, N = 20 000, the `collect_view()` loop reported **600 592 +minor faults and 50.1 µs/frame (19 949 FPS)** — with the loop body reduced to `continue`. +A loop that allocates nothing cannot fault 600 k times. + +**Identification, step 2 of the procedure.** At 9×9 cap 1500 (the cap in force when this +was diagnosed; it is 1700 now, 557 604 B), `m_output_bytes_per_frame` = 492 004 B. `resolve_batch_chunk` was then a pure `n/8` with no +upper bound, so handing it the whole array gave chunk = 2500: + +``` +2500 frames × 492 004 B = 1.23 GB per slot + × NUM_SLOTS (2) = 2.46 GB + / 4 kB = 600 586 pages vs 600 592 observed +``` + +Not a correlation — an identity. The `find_clusters_batched` path was unaffected only +because it loops in 2000-frame slices, giving 250-frame chunks and 123 MB slots. + +**Fix 1 — bound the allocation.** `MAX_SLOT_BYTES = 128 MiB` now caps the auto chunk by +bytes rather than frames, so one large call behaves like a loop over slices: + +``` +128 MiB / 120 004 B (3×3 cap 3000) = 1118 → rounded to n_streams → 1120 +``` + +**Fix 2 — move the remaining cost out of the timer.** Even bounded, the first +`submit_batch` still pins 2 × 128 MiB inside whatever region it lands in. +`reserve_output_slots(n_frames)` performs only the two `cudaMallocHost` calls — no +transfer, no launch, and critically **no pedestal advance**. + +Measured, 3×3 cap 3000, N = 20 000, fresh finder each time: + +| | reserve step | loop faults | loop | FPS | µs/frame | clusters | +|---|--:|--:|--:|--:|--:|--:| +| without `reserve_output_slots` | — | 67 070 | 0.387 s | 51 688 | 19.3 | 46 477 831 | +| with `reserve_output_slots` | 65.7 ms / 65 628 faults | **1 417** | 0.332 s | **60 173** | **16.6** | 46 477 831 | + +Two conclusions: + +- **Cost per pinned page = 65.7 ms / 65 628 = 1.0 µs**, against 0.7 µs for plain heap. The + 43 % excess is the driver work — pinning plus DMA mapping — on top of the same underlying + first touch. A case that must additionally `cudaFreeHost` an undersized slot runs ~40 % + higher again (measured 92 ms for the 1000 → 1120 re-allocation). +- **Cluster counts are bit-identical with and without the reserve**, confirming the call has + no effect on results. This is what distinguishes it from the obvious alternative — running + frames through as a warm-up — which is **invalid here**: the kernel pushes a pedestal + update for every pixel of every frame it processes, so a warm-up leaves that finder with a + pedestal advanced by however many frames it saw, and it can no longer be compared against + the others (§13). + +**Where it still applies.** `MAX_SLOT_BYTES` caps the *auto* chunk only; `submit_batch` +honours whatever batch size it is given. A caller submitting `BATCH_SIZE = 2000` directly +pins 2000 × 492 004 = **984 MB per slot, ~1.97 GB total** ≈ 480 k pages ≈ 480 ms at 9×9, +absorbed by whichever loop runs first. + +### 12.2 Separating the two sources — the A/B that closes the model ★ + +Pipelined `submit_batch`/`collect`, `BATCH_SIZE = 2000`, N = 20 000, **one fresh process per +row** so nothing inherits a warm heap: + +**3×3, cap 3000** (slot 229 MB × 2) + +| | pre-pin | rep 0 | rep 1 | rep 2 | +|---|--:|--:|--:|--:| +| no reserve | — | 572 292 faults / 39.5 µs·f⁻¹ | 9 / 19.4 | 0 / 19.3 | +| `reserve_output_slots` | 117 192 / 120 ms | 455 129 / 34.1 | 8 / 19.5 | 0 / 19.4 | + +**9×9, cap 1500** (slot 938 MB × 2 — diagnosed at the old cap; 1700 scales it to 1.06 GB) + +| | pre-pin | rep 0 | rep 1 | rep 2 | +|---|--:|--:|--:|--:| +| no reserve | — | 2 759 037 / 159.8 | 292 432 / 70.8 | 292 919 / 68.9 | +| `reserve_output_slots` | 480 474 / 478 ms | 2 278 567 / 134.9 | 747 468 / 83.2 | 291 544 / 67.6 | + +**The two sources are exactly additive.** Reserving subtracts precisely the pre-pin count +from run 0 and changes nothing else: + +``` +3×3: 572 292 − 455 129 = 117 163 vs pre-pin 117 192 +9×9: 2 759 037 − 2 278 567 = 480 470 vs pre-pin 480 474 +``` + +and both pre-pin counts equal the closed form to the digit — +`2 × 2000 × 120 004 / 4 kB = 117 191` and `2 × 2000 × 492 004 / 4 kB = 480 472`. The 3×3 +heap share checks out independently: 46 477 831 clusters × 40 B / 4 kB = 453 885 against +455 129 observed, the remainder being the 20 000 `ClusterVector` objects. + +Three conclusions: + +1. **The heap dominates**, ~80 % of first-pass faults in both configurations (455 k of + 572 k at 3×3; 2.28 M of 2.76 M at 9×9). `reserve_output_slots` addresses the other 20 %. + It makes run 0 honest; it does not change the plateau number that gets quoted. +2. **At 3×3 the heap faults vanish on re-run** (rep 1 → 9, rep 2 → 0). 1.86 GB per pass + stays within what glibc retains. +3. **At 9×9 they never do — they plateau at ~292 k per pass ★.** ~9.3 GB per pass is far + above the mmap threshold, so every pass `munmap`s the result heap and re-faults all of + it. That is **292 k × 0.7 µs ≈ 204 ms ≈ 10 µs/frame of the 68 µs, permanently**, and no + number of re-runs removes it. It is a floor `collect()` cannot get under. + +Point 3 is an independent argument for opt6, and it explains the 24 % spread at the end of +Act I (§6): `collect_view()` allocates nothing per frame, so it removes this floor outright +rather than amortizing it. + +### 12.3 What each mitigation actually removes + +| | pinned slot faults | result-heap faults | +|---|---|---| +| re-running | no — dies with the finder if it is built in the timed region | yes at 3×3; **no at 9×9** (§12.2) | +| `reserve_output_slots()` | **moves them out of the timer** (still paid once) | no | +| `collect_view()` (opt6) | no — the slots are still required | **eliminates them** | +| `MAX_SLOT_BYTES` | bounds them (2.46 GB → 268 MB) | no | + +Only opt6 *removes* work. The others relocate or bound it. A timed region that is genuinely +fault-free therefore needs both: `reserve_output_slots()` before the timer for source (b), +and `collect_view()` inside it for source (a) — measured at **1 417 faults on run 0 and 0 +thereafter**. + +--- + +## 13. Correctness (held constant across the whole arc) + +### 3×3, N = 100 000 + +| finder | clusters | /frame | diff vs CPU | +|---|--:|--:|--:| +| CPU MT | 233 085 343 | 2330.85 | — | +| opt1 | 233 094 984 | 2330.95 | 0.0041 % | +| opt2 | 233 094 462 | 2330.94 | 0.0039 % | +| opt3 – opt6 (**f64**, Acts I–II) | 233 094 390 | 2330.94 | 0.0039 % | +| opt3 – opt7 (**f32**, Act III) | 233 094 465 | 2330.94 | 0.0039 % | + +The residual ~0.004 % is the known per-frame vs per-pixel pedestal-update difference +(analysed in `ClusterFinderFrozen_vs_CUDA.ipynb`), **not** a precision effect. + +**f64 and f32 builds differ by 75 clusters out of 233 M (3 × 10⁻⁷)** — Act III is free of +correctness cost, which is the entire point of B1 (§11.3). + +### 9×9, N = 20 000 + +| build | clusters | /frame | +|---|--:|--:| +| f64 (opt6) | 28 447 962 | 1422.40 | +| f32 (opt6 + opt7) | 28 454 174 | 1422.71 | + +### The local-max fix that made opt1/opt2 comparable + +The Test3 local-max gate was backported into +[`clusterfinder_kernel_opt2.cuh`](../include/aare/clusterfinder_kernel_opt2.cuh) so +opt1/opt2 stop over-counting extended charge-shared events: + +| | before | after | +|---|--:|--:| +| opt1 | 233 940 268 (2339.40/fr) | 233 094 984 (2330.95/fr) | +| opt2 | 233 931 015 (2339.31/fr) | 233 094 462 (2330.94/fr) | + +−0.36 %, now matching CPU. Kernel time unchanged (the gate is an early `return`). + +--- + +## 14. Numbers that must NOT be used + +| number | why | +|---|---| +| **A cold-pass number quoted as throughput** | the `cold` column is a real measurement of a first pass, not the code's throughput. Say which you mean; at 9×9 they differ by up to 3.5× (§6, §9). | +| **The mean over reps, or the last rep, for any `collect()` step** | `collect()` oscillates between allocator states (9×9 opt4: 85.8 / 73.7 / 86.6 µs). Use best-of-warm and always print the spread. | +| **Any 9×9 `collect()` number without its fault count** | at 9×9 the result heap never reaches a zero-fault plateau (§12.2). "Warm" ≠ "fault-free" there. | +| **Any step's fault count taken from a shared-process run** | the heap is process-wide; `opt3` reports 2 faults after opt1/opt2 have run and 92 251 alone. One process per step, always. | +| **`kernel_ms` from CUDA events under multi-stream load** | queue-wait inflation up to 3.5× (§3.2). `time_kernels=False` is the default everywhere. Use nsys. | +| **Negative "PCIe + overhead"** | arithmetic artifact of `wall/N − inflated kernel`. A kernel-bound *indicator*, never a transfer cost. | +| **The `s4` kernel column as a kernel duration** | it is engine occupancy, the union of kernel intervals. f64 9×9 reads 32.66 µs at `s4` and 39.86 at `s1`. Use `s1` for "how long is the kernel" (§4.1). | +| **A probe roofline used as a hard denominator** | it is an estimate made in a loop nsys slows to ~69 µs/frame, where kernels overlap less and the union per frame reads 2–7 % high. Peak is the **lower** of it and the best sustained rate (§4). | +| **"102 % of peak"** | impossible by construction under §4's definition. If you compute one, you have used the probe estimate where the sustained rate was lower. Write *at the floor*. | +| **Any wall time from `nsys_kernel_probe.py`** | profiled: ~4× inflated by API tracing. Per-operation GPU times from probes, wall times from the ladder. | +| **Any number from a probe shorter than ~20 000 frames** | the GPU clocks never ramp, under-reading the device 7–10 % and inflating any roofline derived from it. | +| **Any duty cycle from a run on a contended GPU** | `run_ladder.py` / `run_probes.py` abort above 5 % utilisation for this reason; the f64 probe run did abort once and was re-run. | +| **9×9 numbers taken at `n_streams=8`** | 8 streams buy no kernel concurrency at 9×9 (+1 % instance time) while inflating the event timer 3.5×. The campaign fixes 4. | +| **`Speedup (CPU/CUDA)` without stating the convention** | the CPU baseline is first-pass only (`stop()` is terminal), so every speedup divides by a cold CPU and reads ~9 % generous. | +| **A CPU baseline at an unswept thread count** | the campaign used `n_threads=48` on a 16-core / 32-thread machine — 1.5× oversubscribed and 24 % slower than the best configuration. Sweep it, per cluster size: the optimum is 24 at 3×3 and 32 at 9×9 (§3.5). This one silently inflated every speedup in the deck. | +| **A CPU number timed over the loop but compared against a GPU number that includes collection** | `ladder.py` times the `ClusterCollector` drain; the notebook does not. At 9×9, 48 threads that is 2 449 FPS versus 1 338 (§3.5). | +| **Route A's ×1.03 at 3×3 as a step gain** | not established — the event tax (2.8 µs) exceeds the gap (0.8 µs), §3.2/§7. At 9×9 route A is 18 % *slower* than opt4. | +| **Any speedup measured across a heap that one loop warmed for another** | freeing a ~10 GB result heap hands it to the next loop; the first pays the whole first-touch tax. Both loops must be at plateau (§8.2). | + +--- + +## 15. Reproduction index + +> All measurement code is in [`python/tests/perf/`](../python/tests/perf/) — see its +> `README.md`. Every run writes `env.json` + `manifest.csv` into +> `perf/results/_/`. Reproduce both arms with `./run_campaign.sh`. + +| artifact | path | role | +|---|---|---| +| **whole campaign** | `perf/run_campaign.sh` | both arms end to end, with build verification between them | +| **ladder harness** | `perf/run_ladder.py` + `ladder.py` | Acts I–III end-to-end. One process per step; one CSV row per (step, rep) | +| **probe sweep** | `perf/run_probes.py` + `nsys_kernel_probe.py` | §4. Four configs × 20 000 frames → `probes.csv` | +| **duty cycles** | `perf/gpu_span.py` | interval-union analysis of an nsys SQLite export — the only way to separate "engine busy" from "engine idle" | +| **per-operation times** | `nsys stats` on a committed `.sqlite` | §4, §11. Kernel/memcpy durations without re-running anything — see below | +| **shared plumbing** | `perf/common.py` | dataset, pedestal, fault bracketing, env capture, idle-GPU guard, CSV/manifest format | +| **registers / occupancy** | `perf/kernel_resources.py` | §11.2. `cuobjdump -res-usage` on the built extension + the sm_89 occupancy arithmetic — no rebuild needed | +| f64 results (Acts I–II) | `perf/results/2026-08-18_f64/` | `ladder_3x3.csv`, `ladder_9x9.csv`, `probes.csv`, 4 × `.nsys-rep`/`.sqlite` | +| f32 results (Act III) | `perf/results/2026-08-18_f32/` | same, `DEVICE_PED_TYPE=float` | +| opt7 build axis | `include/aare/clusterfinder_kernel.cuh` lines 16–17 | `COMPUTE_TYPE` / `DEVICE_PED_TYPE` | +| opt1/opt2 class | `include/aare/ClusterFinderCUDAOpt2.hpp` | frozen pre-refactor pipeline; gained `time_kernels` for comparability | +| opt3 – opt7 | `include/aare/ClusterFinderCUDA.hpp`, `clusterfinder_kernel.cuh` | current pipeline | +| route A | `include/aare/ClusterFinderCUDA_graph.hpp` | graph-based finder (rejected) | +| exploratory notebook | `python/tests/ClusterFinderCUDA_perf.ipynb` | **stores only the last run** — not a record. Archive a copy per cluster size if used | +| correctness notebook | `python/tests/ClusterFinderFrozen_vs_CUDA.ipynb` | CPU↔CUDA agreement analysis | +| precision study | `docs/pedestal_precision_f32_cancellation.md` | B1 derivation | +| deck | `docs/cf_cuda_fused.pptx` + `docs/deck/build_fused_deck.py` | 29 slides in the same three acts; figures from `docs/deck/make_figs.py` | + +### CSV step labels + +The harness predates this document's step names. To read a `ladder_*.csv` row: + +| `step` column | step in this document | +|---|---| +| `cpu` | baseline | +| `opt1` · `opt2` · `opt3` · `opt4` | opt1 · opt2 · opt3 · opt4 | +| `opt5` | **route A** (CUDA Graphs, rejected — §7) | +| `opt7` | **opt5** (chunked host↔GPU overlap — §8) | +| `opt8` | **opt6** (zero-copy collection — §9) | +| *(build axis, not a row)* | **opt7** — `[f32]` vs `[f64]` directory (§11) | + +### Reading a profile without re-running it + +Every probe ships its SQLite export, so all of §4 can be recomputed offline on an idle +or busy machine — no GPU, no rebuild. + +```bash +cd python/tests/perf +NSYS=/opt/nvidia/nsight-systems/2024.5.1/bin/nsys + +# per-operation durations, straight from the recorded profile +$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 + +# duty cycles, overlap and the derived roofline +python gpu_span.py results/2026-08-18_f64/probe_9x9_s4.sqlite 20000 +``` + +The first command reproduces §11's kernel claim directly: 20 000 instances, +**avg 39 862 ns**, with D2H 21 969 and H2D 13 204 ns — the 39.86 / 21.97 / 13.20 row. + +Three things to know: + +1. **Pass the `.sqlite`, not the `.nsys-rep`.** The committed exports are older than + their reps, so the rep path refuses to run without `--force-export=true`, which + silently rewrites the export `gpu_span.py` reads. +2. **`gpu_span.py` needs the frame count as its second argument** (always `20000` + here). It is the divisor and is not stored in the profile. +3. **`nsys stats` cannot produce a roofline.** It reports per-operation *sums and + averages*, which cannot distinguish "the engine was busy" from "the engine was idle + waiting". It answers *how long is one kernel* — the `s1` column and the opt7 −40.7 % + — while duty cycle, `overlap` and the roofline need the interval-union pass of + `gpu_span.py` (§4.1). This is why the same f64 9×9 `s4` run reads 32.66 µs of kernel + occupancy while `nsys stats` reports ~39.9 µs per kernel instance. + +Other useful reports: `cuda_gpu_trace` (every operation with timestamps), +`cuda_gpu_mem_size_sum` (transfer bytes). `$NSYS stats --help-reports` lists them all; +`--format csv` pipes cleanly. + +### Protocol to reproduce a clean number + +1. **Idle GPU.** Both drivers abort above 5 % utilisation — close any notebook. A competing + process leaves per-op averages intact while destroying the duty cycle. +2. `./run_campaign.sh`. The arm is one line in the kernel header; the script verifies the + rebuild actually took effect before recording anything, and restores the f32 default at + the end. +3. **Quote the warm column, and print the spread.** Cold is a real number too — label it. +4. For kernel times use the **`s1`** probe (exclusive); for "% of peak" use **`s4`** (the + ladder's configuration), and prefer the f32 arm for percentages (§9). +5. Leave `time_kernels=False` everywhere. It is the default on all three finders. +6. Never warm up by processing frames — the kernel advances the pedestal per frame. + `reserve_output_slots()` pre-pays the pinned allocation without touching it. + +--- + +## 16. Slide-ready takeaways + +Every number here is from `[f32]`/`[f64]` (§0) and reproducible with +`perf/run_campaign.sh`. + +### The arc + +1. **One rule explains the whole deck**: an optimization buys the distance between the bar + it attacks and the next-tallest bar. It predicts every result, including the three that + look like failures. +2. **The ladder is ordered by that rule.** Feed the GPU (Act I), get the results back + (Act II), then — and only then — make the GPU faster (Act III). Each act removes the + constraint that makes the next one measurable. +3. **The bottleneck is measured, not assumed.** At 3×3 the kernel is 5.5 µs against a + 16.6 µs frame upload, with the copy engine **72.7 % busy** and the kernel 24.2 %. At 9×9 + the kernel is the tallest bar. Same code, opposite regimes. + +### Act I — feeding the GPU `[f64]` + +4. **3×3: 1.00× → 2.34× → 3.66× → 4.32× → 5.69×** over the best CPU configuration + (24 threads), at identical correctness (4 × 10⁻⁶). The kernel never changed; what + shrank was everything around it, 148 → 26 µs/frame. +5. **opt4 (pinned input) is worth 1.32× at 3×3 and 1.02× at 9×9** — the rule's first + confirmation. Pinning attacks H2D, which is the tallest bar at 3×3 and the shortest at + 9×9. +6. **Act I ends at 62 % of peak at 3×3 and 37 % at 9×9.** The GPU is idle much of the + time and the whole remainder is host-side. + +### Act II — getting the results back `[f64]` + +7. **opt5 — chunked host↔GPU overlap is ×1.31 at 3×3 and ×1.20 at 9×9**, for an API change + with no CUDA work at all. It is now internal to `find_clusters_batched()`, so callers get + it free. +8. **opt6 — zero-copy collection puts both configurations on their floor**: 3×3 reaches 95 % + of its 16.17 µs H2D peak, 9×9 goes from 45 % to the floor itself (**×2.21**). + Bit-identical results to `collect()`. +9. **The win from zero-copy is `max(0, host_copy − gpu_floor)`** — ×2.21 at 9×9 (467 kB/frame + ≈ 40 µs of copy against a 30 µs floor: cannot hide) and ×1.16 at 3×3 (93 kB ≈ 8 µs against + 16 µs: hides completely). Rule 1, applied to the host. +10. **opt6's real advantage is reproducibility.** Its spread over 5 reps is **0.2 %**; every + path that allocates per frame varies **1–25 %**. A 25 % spread means the number depends + on which run you quote. +11. **opt6 has two uses**: the fast path for any streaming consumer (spectra, fitting, disk), + and the profiling instrument that proves the GPU floor is real — it is the only + configuration whose wall time *is* the roofline. + +### Act III — the kernel `[f32]` + +12. **Act II is what makes Act III measurable.** At the end of Act II the f64 9×9 kernel + stands 9.6 µs above the next bar; before Act II the host stood 31 µs above the kernel. +13. **opt7 (f32 pedestal) cuts the 9×9 kernel 40.5 %** — 39.86 → 23.70 µs, nsys-verified on + both builds — and delivers **−16.2 % end-to-end** (30.01 → 25.14 µs / 39 775 FPS). It + lands on the **D2H** floor, not the kernel floor: the cut took the kernel *under* the + 25.24 µs result path, so the constraint changed engine. +14. **Through `collect()` the same −40 % kernel cannot be measured at all.** Its 9×9 rep + spread (22–26 %) puts the effect anywhere in −24 … +17 %; through `collect_view()` the + same quantity is −16.2 % with a 0.0-point interval. The ordering does not just decide + how large the win looks — it decides whether the measurement means anything. +15. **opt7 is only shippable because of the B1 variance rewrite** — the naive f32 pedestal + gives +28 % clusters and an unphysical tail; centered accumulation restores agreement + with f64 to 3 × 10⁻⁷. +16. **opt7 also flips the regime at 3×3**, moving it out of kernel-bound entirely (f64 s1: + kernel 14.72 > H2D 13.14; f32 s1: kernel 4.32 ≪ H2D 13.15). That *is* the goal of the act + — keep the transfers binding. + +### Discipline, and what is next + +17. **Three step ideas were measured and rejected**, and the rule predicts all three: **CUDA + Graphs** attacked launch overhead, which stops binding one step later (18 % *slower* than + opt4 at 9×9); **one-allocation-per-chunk** and **parallel materialization** attacked a + copy that is allocation-bound, not bandwidth-bound. +18. **Measurement discipline was necessary to get here**: first-touch page faults from two + independent allocators (heap 0.7 µs/page recurring, `cudaMallocHost` 1.0 µs/page one-off), + CUDA-event queue-wait inflation up to 3.5× (which is why event removal is *methodology*, + not a rung), per-step heap contamination (`opt3`: 2 faults in-process vs 92 251 isolated), + and clock ramp on short probes under-reading the GPU 10 %. +19. **The bottleneck has been walked from the host, to the GPU, to the wire.** What is left: + - **3×3**: the 16.63 µs achieved-config H2D floor sits 3.5 µs above the 13.15 µs + uncontended rate — transfer *granularity* (2 000 separate 320 kB descriptors) plus + H2D↔D2H contention costing 26 %. + - **9×9**: at the lossless cap D2H **already binds** — 25.24 µs against a 23.94 µs + kernel. Further kernel work buys nothing at all; the lever is the cluster cap + (§4.2), not the kernel. + - **Both**: the largest remaining per-frame cost is downstream *analysis*, not the finder + — histogram fill is ~8.6 µs/frame at 3×3, over half the 16.63 µs budget. diff --git a/docs/ClusterFinderCUDA_optimizations.pptx b/docs/ClusterFinderCUDA_optimizations.pptx deleted file mode 100644 index f09a07cf..00000000 Binary files a/docs/ClusterFinderCUDA_optimizations.pptx and /dev/null differ diff --git a/docs/benchmark_opt1_opt6_results.md b/docs/benchmark_opt1_opt6_results.md deleted file mode 100644 index 5ad28fd3..00000000 --- a/docs/benchmark_opt1_opt6_results.md +++ /dev/null @@ -1,467 +0,0 @@ -# ClusterFinderCUDA — Benchmark Results, opt1 → opt6 - -Consolidated, verified performance numbers for the CUDA cluster-finder optimization -ladder. Every number below is traced to the code, notebook cell, or profiler report -that produced it, and each is tagged **quotable** or **not quotable** with the reason. - -Companion documents: -- `docs/pedestal_precision_f32_cancellation.md` — why the naive f32 pedestal failed and how B1 fixes it -- `docs/cuda_optimization_recap.md`, `docs/optimization_summary.md` — earlier narrative/roadmap notes -- `docs/ClusterFinderCUDA_optimizations.pptx` — the deck these numbers feed - ---- - -## 1. Environment - -| item | value | -|---|---| -| GPU | NVIDIA GeForce RTX 4090 (Ada, sm_89), 24 GB, driver 595.71.05 | -| GPU clocks | idle 210 MHz → boost 3120 MHz; **persistence mode disabled** | -| FP64 rate | 1/64 of FP32 on this part (relevant to opt6) | -| CPU | AMD Ryzen 9 7950X, 16 cores / 32 threads | -| RAM | 125 GiB, **no swap** | -| CUDA | 12.4 (nvcc V12.4.131) | -| Profiler | Nsight Systems 2024.5.1 (`/opt/nvidia/nsight-systems/2024.5.1`) | -| Host | `pc-moench-04` | -| Branch | `bench/opt2-pipeline` (off `feature/cuda_clusterfinder` @ `ce256dd`) | - -**Dataset** — MOENCH, MAX IV beamtime, Cu fluorescence: - -``` -/mnt/sls_det_storage/moench_data/2603_MaxIVBeamtime/2026032408/process/xrf/ - Cu_factor_10_data_master_0.json (100 000 frames, 400×400 uint16) - Cu_factor_10_pedestal_master_0.json (1 000 frames used for pedestal training) -``` - -Frame size 400×400×2 B = 320 000 B (312.5 KiB). All finders trained on the -**same 1 000 pedestal frames**; -`n_sigma = 5` throughout. Data pre-loaded into RAM with `read_n()` so file I/O is -outside every timing loop. - ---- - -## 2. The optimization ladder - -| step | what changed | how it is measured | -|---|---|---| -| **baseline** | `ClusterFinderMT`, 48 threads | `ClusterFinderMT(..., n_threads=48)` + `ClusterCollector` | -| **opt1** | first CUDA port: 1 stream, one launch per frame, no batching | `ClusterFinderCUDAOpt2(..., n_streams=1)` + `find_clusters()` per frame | -| **opt2** | multi-stream scaffolding + host-side batching (bulk memcpy) | `ClusterFinderCUDAOpt2(..., n_streams=4)` + `find_clusters_batched()`, batch 2000 | -| **opt3** | pipeline rework: remove per-round sync barriers, fixed-size D2H | `ClusterFinderCUDA(..., n_streams=4)` + `find_clusters_batched()`, **no pinning** | -| **opt4** | DMA-speed transfers via pinned host input | opt3 + `register_input_buffer(data)` | -| **opt5** | CUDA Graphs (pre-recorded H2D→kernel→D2H per stream) | `ClusterFinderCUDAGraph(...)` + pinned input | -| **opt6** | **first kernel optimization**: f32 device pedestal + variance rewrite (B1) | rebuild with `DEVICE_PED_TYPE = float` | - -opt1–opt5 are **pipeline/host-side**; opt6 is the first change to the **kernel itself**. - -### Code behind each step - -| step | primary source | -|---|---| -| opt1, opt2 | [`include/aare/ClusterFinderCUDAOpt2.hpp`](../include/aare/ClusterFinderCUDAOpt2.hpp), [`include/aare/clusterfinder_kernel_opt2.cuh`](../include/aare/clusterfinder_kernel_opt2.cuh) — snapshot of commit `88e0e8d` (pre-refactor pipeline), namespace `aare::device_opt2` | -| opt3, opt4, opt6 | [`include/aare/ClusterFinderCUDA.hpp`](../include/aare/ClusterFinderCUDA.hpp), [`include/aare/clusterfinder_kernel.cuh`](../include/aare/clusterfinder_kernel.cuh) | -| opt5 | [`include/aare/ClusterFinderCUDA_graph.hpp`](../include/aare/ClusterFinderCUDA_graph.hpp) | -| bindings | [`python/src/bind_ClusterFinderCUDAOpt2.hpp`](../python/src/bind_ClusterFinderCUDAOpt2.hpp), [`python/src/cuda_bindings.cu`](../python/src/cuda_bindings.cu) | -| factories | [`python/aare/ClusterFinder.py`](../python/aare/ClusterFinder.py) | - -Relevant history: `3ed773e` (multi-stream+batched) → `ac96d1f` (mixed precision) → -`88e0e8d` (**opt1/opt2 snapshot**) → `6a12e3d` (pipeline refactor = opt3) → -`4c66802` (FP32 pedestal, introduced the tail) → `5922c73` (async API) → -`1bf317f` (local-max fix) → `a42d71c` (graphs = opt5). - -### Precision configuration - -Two type aliases in [`clusterfinder_kernel.cuh:16-17`](../include/aare/clusterfinder_kernel.cuh#L16-L17): - -```cpp -using COMPUTE_TYPE = float; // stencil arithmetic — float in ALL builds below -using DEVICE_PED_TYPE = double; // device pedestal — the opt6 knob (double → float) -``` - -- **"f64 build"** in this document = `COMPUTE_TYPE=float`, `DEVICE_PED_TYPE=double` (mixed precision). -- **"f32 build"** (opt6) = `COMPUTE_TYPE=float`, `DEVICE_PED_TYPE=float` (100 % f32). - -> ⚠️ `ClusterFinderCUDAOpt2` is templated on `PEDESTAL_TYPE`, which its binding pins -> to `double` ([`bind_ClusterFinderCUDAOpt2.hpp:14`](../python/src/bind_ClusterFinderCUDAOpt2.hpp#L14)). -> **opt1 and opt2 are therefore unaffected by the opt6 flip** — their numbers are -> identical in both campaigns by construction. Only opt3/opt4/opt5 respond to opt6. - ---- - -## 3. Methodology — what is measurable and what is not - -Three measurement artifacts were identified and controlled. **All three matter for -how numbers may be quoted on a slide.** - -### 3.1 First-run soft page faults (dominant, ~1.5–4 s per cell) - -Each timed cell materializes ~233 M clusters ≈ **10 GB of host result heap**. On the -first execution in a process, every 4 kB page must be faulted in and zeroed by the OS -(millions of minor faults). Measured cost on this machine: **0.7 µs/fault**. - -Instrumentation added to all seven timed notebook cells: - -```python -import resource -def _faults(): - r = resource.getrusage(resource.RUSAGE_SELF) - return r.ru_minflt, r.ru_majflt -``` -bracketing the timed region, printing `minor faults: N (~N×0.7µs est.)`. - -**Protocol: quote the run where the fault counter has plateaued** (< ~200 k). Cold -numbers are inflated by 25–45 %. Verified by direct correlation (§9). - -### 3.2 CUDA-event `kernel_ms` inflates under multi-stream saturation - -`avg_kernel_time_ms()` uses a CUDA event pair on the kernel's own stream. It measures -**elapsed time on that stream's timeline**, which includes queue-wait when other -streams are competing for SMs. Consequences: - -- In transfer-paced regimes: `event ≈ true kernel + ~5–7 µs` launch gap → usable. -- In kernel-saturated regimes: **inflated up to 3.5×** → **not quotable**. -- Symptom of saturation: the derived `PCIe + overhead = wall/N − kernel_ms` **goes - negative** (kernels overlap, so wall/frame < kernel/frame). - -Ground truth requires Nsight Systems (§8). - -### 3.3 Profiler distorts wall clock - -Under `nsys`, wall time per frame is ~4× the unprofiled value (API tracing overhead). -**Take per-operation GPU times from nsys; take wall times from unprofiled runs.** The -standalone probe script also pays the full first-touch fault tax inside its single -timed call (fresh process, no warm pass), so **its wall times are not throughput -numbers** either. - -### 3.4 Other controls - -- GPU clock ramp (210 MHz → 3.1 GHz) is real but < 0.1 % of a multi-second run; the - invariance of `kernel_ms` across loaded/idle runs confirms it is not a factor. -- The `cf_cuda_v1` notebook cell builds a histogram **inside** its timed loop - (~4.5 s over 100 k frames). It is a reference row, **not part of the arc**. -- `ClusterFinderMT` cannot restart after `stop()`; the CPU baseline is therefore - always a first-pass number and carries its own ~1.8 s of allocator faults. - ---- - -## 4. Campaign A — 3×3 pipeline arc (f64 pedestal) ★ headline - -**Config**: `cluster_size=(3,3)`, `N=100 000`, `n_frames_pd=1000`, `n_sigma=5`, -`BATCH_SIZE=2000`, `n_streams=4`, `max_clusters_per_frame=3000`. -**Source**: [`python/tests/ClusterFinderCUDA_perf.ipynb`](../python/tests/ClusterFinderCUDA_perf.ipynb), -warm pass (steady state, faults plateaued). - -| step | variant | wall [s] | FPS | µs/frame | kernel [µs/fr] | host ovhd [µs/fr] | **vs CPU** | step gain | minor faults | -|---|---|--:|--:|--:|--:|--:|--:|--:|--:| -| baseline | CPU MT (48 threads) | 21.00 | 4 761 | 210.0 | — | — | 1.00× | — | 2.63 M | -| **opt1** | 1 stream, per-frame | 6.68 | 14 968 | 66.8 | 23 | 44 | **3.14×** | 3.14× | 1 | -| **opt2** | 4 streams + batching | 4.32 | 23 134 | 43.2 | 22 | 21 | **4.86×** | 1.55× | 64 931 | -| **opt3** | pipeline rework, no pin | 3.76 | 26 588 | 37.6 | 24 | 14 | **5.58×** | 1.15× | 59 814 | -| **opt4** | + pinned input (DMA) | 2.72 | 36 810 | 27.2 | 24 | 3 | **7.73×** | 1.38× | 1 027 | -| **opt5** | + CUDA Graph | 2.53 | 39 472 | 25.3 | 25 * | — | **8.29×** | 1.07× | 1 032 | - -\* graph reports kernel+PCIe+overhead combined; the kernel is not timed separately. - -Reference row (not in the arc): current finder, per-frame, histogram inside the timed -loop — 11.35 s / 8 813 FPS / 1.85×. - -**Status: quotable.** All CUDA rows at steady state; CPU baseline is first-pass by -necessity (see §3.4) — if a fault-corrected CPU (≈19.2 s) is preferred, all speedups -shrink by ~9 % (e.g. opt5 8.29× → 7.57×). Be consistent and state the choice. - -### The story in one row — host overhead - -``` -opt1 → opt2 → opt3 → opt4 host overhead per frame - 44 → 21 → 14 → 3 µs (kernel fixed at ~23 µs) -``` - -opt4's 3 µs means H2D/kernel/D2H are essentially fully overlapped; opt5 then trims -residual launch overhead. **After opt4 the 3×3 pipeline is host/PCIe-bound, not -kernel-bound** — which is exactly why opt6 does nothing here (§5). - ---- - -## 5. Campaign B — opt6 at 3×3 (100 % f32) - -Same config as Campaign A; rebuilt with `DEVICE_PED_TYPE = float`. -**Source**: same notebook, warm pass. - -| step | wall f64 → f32 [s] | FPS f64 → f32 | kernel f64 → f32 [µs] | Δ wall | -|---|--:|--:|--:|--:| -| CPU baseline | 21.00 → 21.45 | 4 761 → 4 662 | — | (run-to-run) | -| v1 per-frame (serialized) | 11.35 → 10.57 | 8 813 → 9 465 | **24 → 13** | **−7 %** ✓ | -| opt3 (no pin) | 3.76 → 3.88 | 26 588 → 25 798 | **24 → 14** | +3 % (noise) | -| opt4 (pinned) | 2.72 → 2.83 | 36 810 → 35 372 | **24 → 16** | +4 % (noise) | -| opt5 (graph) | 2.53 → 2.53 | 39 472 → 39 503 | — | **0 %** | -| opt1 / opt2 | 6.68 → 6.73 / 4.32 → 4.88 | — | 23 / 22 unchanged | n/a — f64-pinned class | - -**Result: at 3×3, opt6 halves the kernel and buys nothing end-to-end.** The kernel -(13–16 µs) sits *below* the ~25–27 µs/frame transfer+host floor, so it is entirely -hidden by stream overlap. The control is the serialized `v1` path, where the kernel -*cannot* hide: there the saving does appear (−7 % wall ≈ the 8–10 µs/frame kernel gain). - -The f32 opt2 row (4.88 s) still carried 822 k faults and is **not quotable**; it is -also irrelevant, being the f64-pedestal class. - -**Status: quotable** (opt3/opt4/opt5/v1 rows). - ---- - -## 6. Campaign C — opt6 at 9×9 (where the kernel *is* the bottleneck) ★ - -**Config change**: `cluster_size=(9,9)`, `N=20 000`, `n_streams=8`, -`max_clusters_per_frame=1500`, everything else unchanged. -Cap 1500 chosen so the CUDA finders record **all** clusters the CPU finds. -**Source**: same notebook, warm runs (faults ≤ 102). - -| build | path | wall [s] | FPS | µs/frame | kernel [µs/fr] | derived ovhd | vs CPU | clusters/frame | -|---|---|--:|--:|--:|--:|--:|--:|--:| -| **f64 ped** | opt4 batched+pin | 1.540 | 12 988 | 77.0 | 171 † | **−94** † | 6.01× | 1422.13 | -| **f64 ped** | opt5 graph | 1.482 | 13 491 | 74.1 | 74 * | — | 6.24× | 1422.13 | -| **f32 ped** | opt4 batched+pin | 1.423 | 14 057 | 71.2 | 33 | +38 | 6.61× | 1422.33 | -| **f32 ped** | opt5 graph | 1.372 | 14 580 | 68.6 | 69 * | — | 6.86× | 1422.33 | - -† **Not quotable** — event-timer inflation under 8-stream saturation; the negative -overhead is the tell-tale. True kernel time is 43 µs (§8). \* combined metric. - -CPU baselines (derived from the printed speedup ratios): f64 run ≈ 9.25 s (2 162 FPS), -f32 run ≈ 9.41 s (2 126 FPS). - -**Result: at 9×9, opt6 is worth ~8 % end-to-end** (1.540 → 1.423 s batched; -1.482 → 1.372 s graph) **and flips the regime**: the f64 build is kernel-bound -(negative derived overhead), the f32 build is transfer-bound (+38 µs). - -**Status: wall/FPS/counts quotable; kernel column must come from §8.** - ---- - -## 7. The rule that unifies Campaigns B and C - -Per-frame GPU operation profile at 9×9 (nsys, §8): - -``` -f64: kernel 43 µs | D2H 19 µs | H2D 13 µs → kernel is the tallest bar -f32: kernel 26 µs | D2H 20 µs | H2D 13 µs → near-balanced -3×3: kernel 13–24 µs, below the ~27 µs host/PCIe floor → always hidden -``` - -> **f32 buys exactly the distance between the kernel bar and the next-tallest bar.** -> Nothing at 3×3 (kernel already hidden); ~8 % at 9×9/f64 (kernel on the critical -> path); more for larger windows, faster links, or fewer streams. - ---- - -## 8. Nsight Systems ground truth (9×9, cap 1500) - -**Script**: [`python/tests/nsys_kernel_probe.py`](../python/tests/nsys_kernel_probe.py) -(also in the session scratchpad). Trains 1 000 pedestal frames, runs one batched pass -over 2 000 frames, prints wall + event `kernel_ms`. - -```bash -nsys profile --trace=cuda --sample=none --cpuctxsw=none -o probe_s1 \ - python nsys_kernel_probe.py 1 2000 # 1 stream → kernels serialized -nsys profile --trace=cuda --sample=none --cpuctxsw=none -o probe_s8 \ - python nsys_kernel_probe.py 8 2000 # 8 streams → deck config -nsys stats --report cuda_gpu_kern_sum --report cuda_gpu_mem_time_sum probe_s1.nsys-rep -``` - -### Kernel — `aare::device::find_clusters_in_single_frame>` - -| build | streams | instances | **avg** | median | min | max | σ | -|---|--:|--:|--:|--:|--:|--:|--:| -| f64 ped | 1 | 2 000 | **43 011 ns** | 42 945 | 40 929 | 45 632 | 580 | -| f64 ped | 8 | 2 000 | 46 714 ns | 46 880 | 41 632 | 61 441 | 1 988 | -| f32 ped | 1 | 2 000 | **25 602 ns** | 25 569 | 24 737 | 27 073 | 411 | -| f32 ped | 8 | 2 000 | 25 840 ns | 25 728 | 24 864 | 30 624 | 578 | - -> ### **opt6 kernel result: 43.0 µs → 25.6 µs = −40 %** (9×9, exclusive time) - -### Memory operations (1-stream, uncontended) - -| op | f64 | f32 | payload | effective BW (decimal) | -|---|--:|--:|---|--:| -| D2H (clusters) | 19 424 ns | 19 845 ns | 1500 × 328 B = 492 000 B (480.5 KiB) | **25.3 GB/s** (23.6 GiB/s) | -| H2D (frame) | 13 210 ns | 13 451 ns | 400×400×2 B = 320 000 B (312.5 KiB) | **24.2 GB/s** (22.6 GiB/s) | -| memset | 359 ns | 365 ns | — | — | - -The H2D payload is **one frame**; the D2H payload is one frame's cluster buffer -(`max_clusters_per_frame` × `sizeof(Cluster)`, transferred at -fixed size regardless of how many clusters were actually found). - -PCIe 4.0 ×16 theoretical is 31.5 GB/s (16 GT/s × 16 lanes × 128b/130b), so H2D -reaches **77% of theoretical** — the signature of a real DMA path. Pageable -transfers, which the driver stages through a hidden pinned buffer, run ~15 GB/s. - -Transfers are identical between builds and at full DMA speed — confirming pinning -(opt4) is doing its job and that only the kernel changed. - -### Cross-validation of the event timer - -| build | streams | nsys avg | event `kernel_ms` | offset | -|---|--:|--:|--:|--:| -| f64 | 1 | 43.0 µs | 0.050 ms | +7.0 µs (launch gap) | -| f64 | 8 | 46.7 µs | 0.149 ms | **+102 µs (queue-wait)** | -| f32 | 1 | 25.6 µs | 0.033 ms | +7.4 µs | -| f32 | 8 | 25.8 µs | 0.033 ms | +7.2 µs | - -Independently reproduced unprofiled by the user (f32 build, `N=20000`): event -`kernel_ms` = **0.030–0.032** warm (= 25.6 µs + ~5–6 µs gap); 0.047 and 0.184 on -cold-clock first invocations. - -Note the 8-stream instance time stretches only +9 % (f64) / +1 % (f32): **one 9×9 -kernel nearly fills the GPU, so streams queue rather than co-execute.** Multi-streaming -at 9×9 buys transfer overlap, not kernel concurrency. - -**Reports retained** (openable in `nsys-ui` for timeline figures): -`probe_s1.nsys-rep`, `probe_s8.nsys-rep` (f64), `probe_f32_s1.nsys-rep`, -`probe_f32_s8.nsys-rep` (f32). - ---- - -## 9. Supporting study — the page-fault artifact - -### Synthetic isolation - -Allocating ~8 GB in ClusterVector-sized chunks (90 000 × 93 kB), touching every page, -freeing, repeating in one process: - -| run | wall | minor faults | -|---|--:|--:| -| 1 (cold heap) | 2.05 s | 2 046 594 | -| 2 (warm heap) | 0.07 s | 2 232 | -| 3 (warm heap) | 0.07 s | 2 016 | - -**30× faster, 1000× fewer faults**, same allocations — glibc retains the arenas. - -### In situ (opt2 cell, three consecutive executions) - -| run | wall [s] | FPS | minor faults | -|---|--:|--:|--:| -| 1 | 6.110 | 16 366 | 2 625 948 | -| 2 | 4.872 | 20 526 | 729 866 | -| 3 | 4.452 | 22 459 | 185 885 | - -Correlation Δwall vs Δfaults: - -| interval | Δwall | Δfaults | implied cost | -|---|--:|--:|--:| -| 1 → 2 | 1.238 s | 1 895 667 | **0.65 µs/fault** | -| 2 → 3 | 0.420 s | 543 981 | **0.77 µs/fault** | -| 1 → 3 | 1.658 s | 2 439 648 | **0.68 µs/fault** | - -Reconstruction of run 1 from run 3: -`4.452 s + 2 439 648 × 0.68 µs = 6.111 s` vs **measured 6.110 s** (1 ms error over 6 s). - -Kernel time was constant (0.022 ms) across all three — the GPU is not involved. -**Conclusion: the entire first-run penalty is OS page population of the host result -heap.** Persistence mode / clock ramp are not responsible. - ---- - -## 10. Correctness (held constant across the whole arc) - -### 3×3, N = 100 000 - -| finder | clusters | /frame | diff vs CPU | -|---|--:|--:|--:| -| CPU MT | 233 085 343 | 2330.85 | — | -| opt1 | 233 094 770 | 2330.95 | 0.0040 % | -| opt2 | 233 093 553 | 2330.94 | 0.0035 % | -| opt3 / opt4 / opt5 (f64) | 233 093 484 | 2330.93 | 0.0035 % | -| opt3 / opt4 / opt5 (**f32**) | 233 093 554 – 233 094 465 | 2330.94 | 0.0039 % | - -The residual ~0.004 % is the known per-frame vs per-pixel pedestal-update difference -(analysed in `ClusterFinderFrozen_vs_CUDA.ipynb`), **not** a precision effect. - -**f64 vs f32 builds differ by ~70 clusters out of 233 M (3 × 10⁻⁷).** - -### 9×9, N = 20 000 - -| build | clusters | /frame | -|---|--:|--:| -| f64 | 28 442 582 | 1422.13 | -| f32 | 28 446 667 | 1422.33 | - -### Two fixes that made this possible - -**(a) B1 — per-pixel offset accumulation** (`docs/pedestal_precision_f32_cancellation.md`). -Before: naive f32 pedestal produced **+28.06 % clusters and an unphysical high-energy -tail** — catastrophic cancellation in `var = sum2/n − mean²` (both terms ≈ 2.17 × 10⁷, -variance ≈ 2025) drove quiet pixels to `rms=0`, so they fired every frame. Fix: freeze -a per-pixel baseline `X0 ≈ round(mean)` at t=0 and accumulate centered `Y = X − X0`. -After: full-f32 matches f64 to **3 × 10⁻⁷**. **opt6 is only shippable because of B1.** - -**(b) Test3 local-max gate backported** into -[`clusterfinder_kernel_opt2.cuh`](../include/aare/clusterfinder_kernel_opt2.cuh) -so opt1/opt2 stop over-counting extended charge-shared events: - -| | before | after | -|---|--:|--:| -| opt1 | 233 940 268 (2339.40/fr) | 233 094 770 (2330.95/fr) | -| opt2 | 233 931 015 (2339.31/fr) | 233 093 553 (2330.94/fr) | - -−0.36 %, now matching CPU. Kernel time unchanged (the gate is an early `return`). - ---- - -## 11. Numbers that must NOT be used - -| number | why | -|---|---| -| 9×9 **cap = 1000** runs (f64: 1.277 s / 15 657 FPS / 7.37×; f32: 1.087 s / 18 395 FPS / 8.53×) | cluster cap saturated → **exactly** 1000.00/frame, ~30 % of clusters truncated; CPU found ~1422/frame. Timing story survives but counts and speedups are dishonest. Superseded by cap 1500 (§6). | -| `kernel_ms` under multi-stream saturation (f64 9×9: 0.171 / 0.192; 8-stream first-launch 0.184) | event-timer queue-wait inflation up to 3.5× (§3.2). Use nsys §8. | -| Negative "PCIe + overhead" (−0.094, −0.128, −0.007) | arithmetic artifact of `wall/N − inflated kernel`. Useful only as a *kernel-bound indicator*, never as a transfer cost. | -| Any wall time from `nsys_kernel_probe.py` (5.3–6.6 s for 20 k frames) | fresh process → full first-touch fault tax inside the timed call; single mega-batch; profiler overhead when traced. | -| Cold-pass notebook numbers (opt2 6.18 s, opt3 5.29 s, batched 4.45 s, graph 4.02 s) | 2.1–2.6 M unresolved page faults each (§9). | -| `cf_cuda_v1` cell as an arc data point | builds a histogram inside the timed loop (~4.5 s / 100 k frames). Valid only as the *serialized-path* control in §5. | - ---- - -## 12. Reproduction index - -| artifact | path | role | -|---|---|---| -| main benchmark notebook | `python/tests/ClusterFinderCUDA_perf.ipynb` | all wall/FPS/count numbers; 7 instrumented timed cells | -| nsys probe | `python/tests/nsys_kernel_probe.py` | exclusive kernel + memcpy times | -| correctness notebook | `python/tests/ClusterFinderFrozen_vs_CUDA.ipynb` | CPU↔CUDA agreement analysis | -| precision study | `docs/pedestal_precision_f32_cancellation.md` | B1 derivation | -| opt1/opt2 class | `include/aare/ClusterFinderCUDAOpt2.hpp` | pre-refactor pipeline snapshot | -| opt1/opt2 kernel | `include/aare/clusterfinder_kernel_opt2.cuh` | `88e0e8d` kernel + backported Test3 gate | -| opt3/opt4/opt6 | `include/aare/ClusterFinderCUDA.hpp`, `include/aare/clusterfinder_kernel.cuh` | current pipeline; precision knob at lines 16–17 | -| opt5 | `include/aare/ClusterFinderCUDA_graph.hpp` | graph-based finder | -| deck | `docs/ClusterFinderCUDA_optimizations.pptx` | target | - -### Protocol to reproduce a clean number - -1. Idle machine. Optionally `sudo nvidia-smi -pm 1` (kills ~1–3 s of per-process - driver init; does **not** affect the fault artifact). -2. Restart the Jupyter kernel; run the notebook top to bottom (cold pass). -3. **Re-run each timed CUDA cell** until its `minor faults` line plateaus - (< ~200 k; usually 2–3 executions). Quote that run. -4. The CPU baseline cannot be re-run (`ClusterFinderMT.stop()` is terminal) — - re-run the *Build finders* + pedestal cells first if a warm CPU number is needed. -5. For kernel times: use `nsys` at `n_streams=1`, never the event timer under load. - ---- - -## 13. Slide-ready takeaways - -1. **The pipeline arc (opt1 → opt5) is monotonic**: 3.14× → 4.86× → 5.58× → 7.73× → - **8.29×** over a 48-thread CPU baseline, at identical correctness (0.004 %). -2. **The optimization story is host overhead collapsing**: 44 → 21 → 14 → **3 µs/frame** - against a fixed ~23 µs kernel. By opt4 the GPU is fed almost perfectly. -3. **opt6 (f32 pedestal) is a kernel win, not always a throughput win**: kernel - **43.0 → 25.6 µs (−40 %, nsys-verified)**, but end-to-end **0 % at 3×3** and - **−8 % wall at 9×9**. The bottleneck is workload-dependent. -4. **The unifying rule**: f32 buys the distance between the kernel bar and the - next-tallest bar in the per-frame GPU profile. -5. **opt6 is only correct because of the B1 variance rewrite** — the naive f32 pedestal - gave +28 % clusters and an unphysical tail; the centered accumulation restores - agreement with f64 to 3 × 10⁻⁷. -6. **Measurement discipline was necessary to get here**: soft page faults (0.7 µs each, - up to 4 s per run) and CUDA-event queue-wait inflation (up to 3.5×) both had to be - identified and controlled before any number was trustworthy. -7. **Where the next win is**: after opt4 the 3×3 pipeline is PCIe/host-bound. Further - kernel work has no payoff at that window size; the next target is transfer volume - (on-GPU reduction, keeping results on device) rather than kernel speed. diff --git a/docs/deck/build_deck.py b/docs/deck/build_deck.py deleted file mode 100644 index 0c3b10da..00000000 --- a/docs/deck/build_deck.py +++ /dev/null @@ -1,744 +0,0 @@ -"""Rebuild docs/ClusterFinderCUDA_optimizations.pptx — opt1..opt6, in the deck's -own design language (extracted from the original file).""" -from pptx import Presentation -from pptx.util import Inches as In, Pt, Emu -from pptx.dml.color import RGBColor -from pptx.enum.text import PP_ALIGN, MSO_ANCHOR -from pptx.enum.shapes import MSO_SHAPE -from lxml import etree -from pathlib import Path -from PIL import Image - -FIGS = Path(__file__).parent / "figs" -OUT = Path("/home/ferjao_k/aare/docs/ClusterFinderCUDA_optimizations.pptx") - -# ---------------------------------------------------------------- design tokens -BG = RGBColor(0x0B, 0x10, 0x18) -PANEL = RGBColor(0x12, 0x1A, 0x28) -CODEBG = RGBColor(0x0E, 0x14, 0x20) -RULE = RGBColor(0x1E, 0x28, 0x36) -ACCENT = RGBColor(0x1E, 0x90, 0xC2) -AMBER = RGBColor(0xE8, 0xB2, 0x5C) -PALE = RGBColor(0xE7, 0xED, 0xF4) -TEXT2 = RGBColor(0xA5, 0xB2, 0xC4) -MUTED = RGBColor(0x6B, 0x7A, 0x90) - -UI, MONO = "Segoe UI", "Consolas" -W, H = 13.333, 7.5 -M = 0.7 # left margin -COL = 7.9 # left column width -RAIL_X, RAIL_W = 9.2, 3.5 # right rail - -prs = Presentation() -prs.slide_width, prs.slide_height = In(W), In(H) -BLANK = prs.slide_layouts[6] -N_SLIDES = 19 - - -# ------------------------------------------------------------------- helpers -def new_slide(): - s = prs.slides.add_slide(BLANK) - bg = etree.SubElement(s._element, "{http://schemas.openxmlformats.org/presentationml/2006/main}bg") - pr = etree.SubElement(bg, "{http://schemas.openxmlformats.org/presentationml/2006/main}bgPr") - fill = etree.SubElement(pr, "{http://schemas.openxmlformats.org/drawingml/2006/main}solidFill") - clr = etree.SubElement(fill, "{http://schemas.openxmlformats.org/drawingml/2006/main}srgbClr") - clr.set("val", "0B1018") - etree.SubElement(pr, "{http://schemas.openxmlformats.org/drawingml/2006/main}effectLst") - s._element.insert(0, bg) - return s - - -def rect(s, x, y, w, h, color, shape=MSO_SHAPE.RECTANGLE): - sh = s.shapes.add_shape(shape, In(x), In(y), In(w), In(h)) - sh.fill.solid(); sh.fill.fore_color.rgb = color - sh.line.fill.background(); sh.shadow.inherit = False - return sh - - -def tb(s, x, y, w, h, anchor=MSO_ANCHOR.TOP): - box = s.shapes.add_textbox(In(x), In(y), In(w), In(h)) - tf = box.text_frame - tf.word_wrap = True - tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0 - tf.vertical_anchor = anchor - return tf - - -def para(tf, first=False, space_after=0, space_before=0, line=None, align=None): - p = tf.paragraphs[0] if first else tf.add_paragraph() - p.space_after = Pt(space_after); p.space_before = Pt(space_before) - if line: p.line_spacing = line - if align: p.alignment = align - return p - - -def run(p, text, size=11, color=TEXT2, font=UI, bold=False, italic=False, spc=None): - r = p.add_run(); r.text = text - f = r.font - f.name, f.size, f.bold, f.italic = font, Pt(size), bold, italic - f.color.rgb = color - if spc is not None: - r.font._rPr.set("spc", str(int(spc * 100))) - return r - - -# ------------------------------------------------------------------ chrome -def chrome(s, idx, eyebrow, title, title_size=27): - rect(s, M, 0.60, 0.35, 0.035, ACCENT) - tf = tb(s, 1.17, 0.50, 10.33, 0.32) - run(para(tf, True), eyebrow.upper(), 9, MUTED, bold=True, spc=1.6) - - tf = tb(s, M, 0.86, 11.9, 1.0) - run(para(tf, True, line=1.05), title, title_size, PALE, bold=True) - - # progress bar - span, n = 11.0, N_SLIDES - pitch = span / n; wseg = pitch * 0.90 - for i in range(n): - rect(s, M + i * pitch, 7.28, wseg, 0.045, ACCENT if i <= idx - 1 else RULE) - tf = tb(s, 12.0, 7.14, 0.9, 0.3) - run(para(tf, True, align=PP_ALIGN.RIGHT), f"{idx} / {n}", 8.5, MUTED) - - -def bullets(s, x, y, w, items, size=11, gap=7): - tf = tb(s, x, y, w, 0.3) - for i, it in enumerate(items): - color, txt = (it if isinstance(it, tuple) else (TEXT2, it)) - p = para(tf, i == 0, space_after=gap, line=1.25) - run(p, "• ", size, MUTED) - # inline emphasis with **...** - for j, part in enumerate(txt.split("**")): - if part: - run(p, part, size, PALE if j % 2 else color, bold=bool(j % 2)) - return tf - - -def code(s, x, y, w, lines, size=8.5, title=None): - lh = 0.148 - h = 0.24 + len(lines) * lh + (0.22 if title else 0) - rect(s, x, y, w, h, CODEBG, MSO_SHAPE.ROUNDED_RECTANGLE) - ty = y + 0.12 - if title: - tf = tb(s, x + 0.18, ty, w - 0.36, 0.2) - run(para(tf, True), title, 7.5, MUTED, bold=True, spc=1.2) - ty += 0.22 - tf = tb(s, x + 0.18, ty, w - 0.36, h - 0.24) - for i, ln in enumerate(lines): - p = para(tf, i == 0, line=1.12) - if ln.strip().startswith(("//", "#")): - run(p, ln, size, MUTED, MONO) - continue - for j, part in enumerate(ln.split("«")): - for k, seg in enumerate(part.split("»")): - if not seg: continue - hi = (j > 0 and k == 0) - run(p, seg, size, ACCENT if hi else TEXT2, MONO, bold=hi) - return h - - -def callout(s, x, y, w, text, h=0.78, color=ACCENT, size=10.5): - rect(s, x + 0.045, y, w - 0.045, h, PANEL) - rect(s, x, y, 0.045, h, color) - tf = tb(s, x + 0.28, y + 0.10, w - 0.5, h - 0.2, MSO_ANCHOR.MIDDLE) - p = para(tf, True, line=1.2) - for j, part in enumerate(text.split("**")): - if part: - run(p, part, size, PALE if j % 2 else TEXT2, bold=bool(j % 2)) - - -def rail(s, items, y0=2.0, divider=True): - if divider: - rect(s, 8.95, 2.0, 0.012, 4.55, RULE) - y = y0 - for it in items: - kind = it[0] - if kind == "label": - tf = tb(s, RAIL_X, y, RAIL_W, 0.26) - run(para(tf, True), it[1].upper(), 8.5, MUTED, bold=True, spc=1.4) - y += 0.28 - elif kind == "stat": - _, lab, val, col = it - tf = tb(s, RAIL_X, y, RAIL_W, 0.24) - run(para(tf, True), lab.upper(), 8.5, MUTED, spc=1.2) - tf = tb(s, RAIL_X, y + 0.24, RAIL_W, 0.6) - run(para(tf, True), val, 26, col, bold=True) - y += 0.98 - elif kind == "row": - _, lab, val, col = it - tf = tb(s, RAIL_X, y, RAIL_W, 0.24) - run(para(tf, True), lab.upper(), 8.5, MUTED, spc=1.2) - tf = tb(s, RAIL_X, y + 0.22, RAIL_W, 0.3) - run(para(tf, True), val, 13, col, bold=True) - y += 0.66 - elif kind == "note": - tf = tb(s, RAIL_X, y, RAIL_W, 0.9) - run(para(tf, True, line=1.25), it[1], 9, TEXT2) - y += 0.30 + 0.17 * (len(it[1]) // 42 + 1) - elif kind == "gap": - y += it[1] - return y - - -def figure(s, name, x, y, w): - p = FIGS / f"{name}.png" - iw, ih = Image.open(p).size - h = w * ih / iw - s.shapes.add_picture(str(p), In(x), In(y), In(w), In(h)) - return h - - -def caption(s, x, y, w, text, size=9): - tf = tb(s, x, y, w, 0.3) - run(para(tf, True, line=1.25), text, size, MUTED) - - -# =========================================================== 1 · TITLE -s = new_slide() -rect(s, 0, 0, 0.16, H, ACCENT) -tf = tb(s, M + 0.3, 0.85, 11, 0.3) -run(para(tf, True), "AARE · PSI HYBRID PIXEL DETECTORS · CUDA CLUSTERFINDER", - 9.5, MUTED, bold=True, spc=1.8) - -tf = tb(s, M + 0.3, 1.35, 11.4, 1.7) -run(para(tf, True, line=1.02), "Feeding the GPU", 46, PALE, bold=True) -tf = tb(s, M + 0.3, 2.30, 11.4, 0.8) -run(para(tf, True, line=1.05), "Six optimization steps of the CUDA ClusterFinder", - 22, ACCENT) - -tf = tb(s, M + 0.3, 3.25, 9.6, 0.8) -run(para(tf, True, line=1.3), - "Five of the six steps never touch the arithmetic. They are about keeping " - "a 24 µs kernel supplied with data — and about learning to measure honestly.", - 12.5, TEXT2) - -stats = [("×8.29", "VS 48-THREAD CPU", ACCENT), ("39,472", "FRAMES / SECOND", PALE), - ("25.3 µs", "PER FRAME, END TO END", PALE), ("0.004%", "CLUSTER-COUNT DRIFT", AMBER)] -for i, (v, l, c) in enumerate(stats): - x = M + 0.3 + i * 2.85 - rect(s, x, 4.35, 0.035, 0.95, c) - tf = tb(s, x + 0.22, 4.35, 2.5, 0.55) - run(para(tf, True), v, 30, c, bold=True) - tf = tb(s, x + 0.22, 4.98, 2.5, 0.3) - run(para(tf, True), l, 8.5, MUTED, spc=1.2) - -rect(s, M + 0.3, 6.05, 11.0, 0.012, RULE) -tf = tb(s, M + 0.3, 6.25, 11.4, 0.6) -run(para(tf, True, line=1.35), - "RTX 4090 (Ada, sm_89) · PCIe 4.0 ×16 · Mönch 400×400 uint16 · 3×3 clusters · " - "100 000 frames · Cu fluorescence, MAX IV", 10, MUTED) -tf = tb(s, M + 0.3, 6.62, 11.4, 0.3) -run(para(tf, True), "Khalil Ferjaoui · Paul Scherrer Institut", 10, TEXT2) - -# =========================================================== 2 · PROBLEM -s = new_slide() -chrome(s, 2, "The problem & the baseline", "What has to happen to every frame") -bullets(s, M, 1.95, COL, [ - "Per pixel: subtract a **running pedestal** (mean ± rms), keep pixels above " - "**nσ · rms**, cut a 3×3 cluster around each local maximum.", - "400×400 = 160 k pixels, **312.5 kB per frame**; Cu data yields ~2 330 clusters " - "per frame at 3×3.", - "The pedestal is **updated by every non-photon pixel**, every frame — so the " - "arithmetic and the data movement are coupled.", -]) -code(s, M, 3.55, COL, [ - "// the whole algorithm, per pixel", - "v = frame[i] - pedestal_mean[i]", - "rms = sqrt(pedestal_sum2[i]/n - pedestal_mean[i]^2)", - "if (v > «nSigma» * rms && v == max(3x3 window)) -> emit cluster", - "else -> update pedestal", -], title="THE KERNEL IN FIVE LINES") -callout(s, M, 5.55, COL, - "**Thesis of this talk:** the compute was fast almost immediately. " - "Five of six steps are about feeding it.") -rail(s, [ - ("label", "Baseline · same data, same threshold"), - ("gap", 0.15), - ("stat", "CPU, 1 thread", "1.75 ms", MUTED), - ("stat", "CPU MT, 48 threads", "210 µs", PALE), - ("gap", 0.1), - ("row", "That is the bar", "4 761 frames / s", TEXT2), - ("gap", 0.25), - ("note", "Every CUDA number in this deck is measured against the 48-thread " - "CPU on the same 100 000 frames."), -]) - -# =========================================================== 3 · THE LADDER -s = new_slide() -chrome(s, 3, "Roadmap", "Two acts: feed the GPU, then speed up the kernel") -rows = [ - ("opt1", "First CUDA port", "1 stream, one launch per frame", "×3.14", ACCENT), - ("opt2", "Streams + batching", "4 streams, 2 000-frame batches", "×4.86", ACCENT), - ("opt3", "Pipeline rework", "sync barriers removed", "×5.58", ACCENT), - ("opt4", "Pinned memory", "DMA-speed host transfers", "×7.73", ACCENT), - ("opt5", "CUDA Graphs", "one launch replaces six", "×8.29", ACCENT), - ("opt6", "FP32 pedestal + variance rewrite", "the first kernel change", "kernel −40%", AMBER), -] -y = 2.05 -for i, (tag, name, sub, gain, col) in enumerate(rows): - rect(s, M, y, 11.9, 0.72, PANEL if i % 2 == 0 else BG) - rect(s, M, y, 0.035, 0.72, col) - tf = tb(s, M + 0.28, y + 0.13, 1.0, 0.4) - run(para(tf, True), tag, 15, col, bold=True, font=MONO) - tf = tb(s, M + 1.45, y + 0.10, 5.0, 0.3) - run(para(tf, True), name, 13, PALE, bold=True) - tf = tb(s, M + 1.45, y + 0.38, 5.6, 0.3) - run(para(tf, True), sub, 10, MUTED) - tf = tb(s, 9.4, y + 0.18, 3.1, 0.4) - run(para(tf, True, align=PP_ALIGN.RIGHT), gain, 15, col, bold=True) - y += 0.78 -rect(s, M, 2.05, 0.012, 4.68, RULE) -caption(s, M, 6.72, 11.9, - "opt1–opt5 change only how work is scheduled and moved — the arithmetic is " - "byte-identical. opt6 is the first step that changes the kernel itself.") - -# =========================================================== 4 · METHODOLOGY -s = new_slide() -chrome(s, 4, "Before any number is believed", "Three ways a GPU benchmark lies") -items = [ - ("First-touch page faults", AMBER, - "Each run materialises ~10 GB of clusters. The first pass faults in ~2.6 M " - "pages at 0.7 µs each — up to 4 s of pure OS work inside the timer.", - "Fix: re-run until getrusage() minor faults plateau (< 200 k)."), - ("CUDA-event kernel timing", AMBER, - "avg_kernel_time_ms() measures elapsed time on a stream — including waiting " - "for other streams. Under 8-stream load it over-reads by up to 3.5×.", - "Fix: Nsight Systems per-instance times; 1 stream for exclusive numbers."), - ("The profiler itself", AMBER, - "Under nsys, wall time per frame inflates ~4× from API tracing.", - "Fix: GPU op times from nsys, wall times from unprofiled runs."), -] -x = M -for title, col, body, fix in items: - rect(s, x, 2.0, 3.83, 3.15, PANEL) - rect(s, x, 2.0, 3.83, 0.035, col) - tf = tb(s, x + 0.26, 2.28, 3.3, 0.6) - run(para(tf, True, line=1.15), title, 13, PALE, bold=True) - tf = tb(s, x + 0.26, 3.02, 3.3, 1.5) - run(para(tf, True, line=1.3), body, 10, TEXT2) - tf = tb(s, x + 0.26, 4.42, 3.3, 0.65) - run(para(tf, True, line=1.25), fix, 9.5, ACCENT) - x += 4.03 -code(s, M, 5.4, 11.9, [ - "# every timed cell in the benchmark notebook is bracketed with:", - "mf0 = resource.getrusage(resource.RUSAGE_SELF).ru_minflt", - "... t = time.perf_counter() - t0 ...", - "print(f'minor faults: {mf1-mf0:,}') # quote the run where this plateaus", -], title="THE FAULT PROTOCOL — python/tests/ClusterFinderCUDA_perf.ipynb") -callout(s, M, 6.52, 11.2, - "Validated: **wall = steady-state + faults × 0.68 µs** reproduced a 6.110 s " - "run to within **1 ms**. Kernel time stayed constant throughout — the GPU was never the variable.", - h=0.70, size=9.5) - -# =========================================================== 5 · OPT1 -s = new_slide() -chrome(s, 5, "opt1 · the first CUDA port", "One frame, one stream, fully synchronous") -bullets(s, M, 1.95, COL, [ - "Shared-memory tiling with **halo loading** for any cluster size; pedestal " - "subtraction fused into the tile load.", - "Cluster geometry is a **compile-time template parameter** → the 3×3 stencil " - "is fully unrolled.", - "One cudaMemcpy in, one kernel, one cudaMemcpy out — **the host blocks " - "on every frame**.", -]) -code(s, M, 3.62, COL, [ - "// one frame at a time — the host waits at every step", - "cudaMemcpy(d_frame, h_frame, bytes, cudaMemcpyHostToDevice);", - "find_clusters_in_single_frame", - " <<>>(d_frame, d_pd_mean, ...);", - "cudaMemcpy(h_out, d_out, out_bytes, cudaMemcpyDeviceToHost);", -], title="ClusterFinderCUDAOpt2.hpp · find_clusters()") -callout(s, M, 5.62, COL, - "The stencil was **already fast**: 23 µs of kernel inside a 67 µs frame. " - "The other 44 µs is the host standing still.") -rail(s, [ - ("label", "opt1 · 3×3 · 100 k frames"), - ("gap", 0.15), - ("stat", "End to end", "66.8 µs", PALE), - ("stat", "vs 48-thread CPU", "×3.14", ACCENT), - ("gap", 0.05), - ("row", "Kernel (GPU)", "23 µs", TEXT2), - ("row", "Host + PCIe", "44 µs", AMBER), - ("gap", 0.2), - ("note", "Two thirds of the frame is spent not computing."), -]) - -# =========================================================== 6 · OPT2 -s = new_slide() -chrome(s, 6, "opt2 · streams and batching", "A CUDA stream is a queue the GPU can overlap") -bullets(s, M, 1.95, COL, [ - "A **stream** is an ordered queue of GPU work. Work in **different** streams may " - "overlap — so a copy can run while another stream computes.", - "Each stream gets its own **StreamContext**: device frame buffer, output buffer " - "and pedestal. Frames are handed out **round-robin**.", - "The host now submits **2 000 frames per call** instead of one.", -]) -code(s, M, 3.62, COL, [ - "struct StreamContext {", - " cudaStream_t stream;", - " FRAME_TYPE *d_frame; ClusterType *d_clusters;", - " PEDESTAL_TYPE *d_pd_mean, *d_pd_sum, *d_pd_sum2;", - "};", - "auto &sc = v_sc[frame_idx % «n_streams»]; // round-robin", -], title="ClusterFinderCUDA.hpp · per-stream state") -callout(s, M, 5.72, COL, - "**Scaffolding, not yet the payoff.** The streams exist, but the host still " - "synchronises after every round — see opt3.") -rail(s, [ - ("label", "opt2 · 4 streams · batch 2 000"), - ("gap", 0.15), - ("stat", "End to end", "43.2 µs", PALE), - ("stat", "vs CPU", "×4.86", ACCENT), - ("gap", 0.05), - ("row", "Step gain over opt1", "×1.55", ACCENT), - ("row", "Host + PCIe", "21 µs (was 44)", AMBER), -]) - -# =========================================================== 7 · OPT3 -s = new_slide() -chrome(s, 7, "opt3 · remove the sync barriers", "Stop draining the GPU between rounds") -bullets(s, M, 1.95, 7.4, [ - "opt2 synchronised **all streams after every round** of n_streams frames. " - "The GPU drained to empty each time.", - "opt3 submits every frame's H2D → kernel → D2H **asynchronously**, then " - "synchronises **once at the end of the batch**.", -], size=10.5) -figure(s, "fig_streams", M, 3.05, 7.55) -code(s, 8.35, 1.95, 4.25, [ - "// opt2: barrier after every round", - "for (round) {", - " submit(n_streams frames);", - " «cudaDeviceSynchronize»();", - "}", - "", - "// opt3: submit everything, sync once", - "for (frame : batch) {", - " cudaMemcpyAsync(..., sc.stream);", - " kernel<<<..., sc.stream>>>(...);", - " cudaMemcpyAsync(..., sc.stream);", - "}", - "for (sc : streams)", - " «cudaStreamSynchronize»(sc.stream);", -], size=8, title="THE ONE-LINE IDEA") -callout(s, 8.35, 5.05, 4.25, - "**37.6 µs/frame · ×5.58**\nHost overhead 21 → **14 µs**", h=0.86, size=11) -caption(s, 8.35, 6.15, 4.25, - "Each lane is one stream. Removing the barrier lets a stream start its next " - "frame while its neighbours are still copying.") - -# =========================================================== 8 · OPT4 -s = new_slide() -chrome(s, 8, "opt4 · pinned (page-locked) memory", "What pinning is, and why the GPU cares") -bullets(s, M, 1.95, 12.0, [ - "Normal host memory is **pageable** — the OS may move or swap it. A DMA engine " - "cannot safely read that, so the driver first copies your data into a **hidden " - "pinned staging buffer**. Every transfer is copied twice.", - "**Pinning** locks the pages in physical RAM. The GPU's DMA engine then reads " - "host memory **directly** — no staging copy, and the transfer can be truly asynchronous.", -], size=10.5) -figure(s, "fig_pinning", M, 3.15, 7.6) -code(s, 8.5, 3.15, 4.1, [ - "// pin the whole dataset once", - "«cudaHostRegister»(ptr, bytes,", - " cudaHostRegisterDefault);", - "", - "// ... run the whole campaign ...", - "", - "«cudaHostUnregister»(ptr);", -], size=8, title="ClusterFinderCUDA.hpp") -callout(s, 8.5, 4.72, 4.1, - "**27.2 µs/frame · ×7.73**\nHost overhead 14 → **3 µs**", h=0.86, size=11) -caption(s, 8.5, 5.82, 4.1, - "Measured H2D: one 400×400 uint16 frame (312.5 KiB = 320 000 B) in 13.2 µs " - "= 24.2 GB/s — 77% of PCIe 4.0 ×16 theoretical, i.e. true DMA speed. " - "Pageable staging runs ~15 GB/s.") -caption(s, M, 6.62, 7.6, - "Caveat: pinned memory is a finite system resource — it cannot be swapped. " - "aare exposes a budget helper (print_pinning_budget) before you pin 31 GB.") - -# =========================================================== 9 · OPT5 -s = new_slide() -chrome(s, 9, "opt5 · CUDA Graphs", "Record the pipeline once, replay it with one call") -bullets(s, M, 1.95, 12.0, [ - "Every cudaMemcpyAsync / kernel launch costs the **CPU** a few microseconds of " - "driver work — per frame, per operation. At 39 k frames/s that is the budget.", - "A **CUDA Graph** captures the whole dependency DAG once. Replaying it is a " - "**single** cudaGraphLaunch — the driver already knows every node and edge.", -], size=10.5) -figure(s, "fig_graphs", M, 3.15, 7.6) -code(s, 8.5, 3.15, 4.1, [ - "// record once, at setup", - "cudaStreamBeginCapture(sc.stream, ...);", - " submit_h2d_kernel_d2h(sc);", - "cudaStreamEndCapture(sc.stream, &sc.graph);", - "«cudaGraphInstantiate»(&sc.graphExec, ...);", - "", - "// per batch — one call", - "«cudaGraphLaunch»(sc.graphExec, sc.stream);", -], size=8, title="ClusterFinderCUDA_graph.hpp") -callout(s, 8.5, 4.88, 4.1, - "**25.3 µs/frame · ×8.29**\nBest end-to-end result", h=0.86, size=11) -caption(s, M, 6.62, 12.0, - "Worth 7% here because opt4 already removed the transfer cost — what remains " - "is CPU launch overhead, which is exactly what graphs eliminate. " - "Trade-off: shapes are frozen at record time, so the batch geometry must be fixed.") - -# =========================================================== 10 · OPT6 why -s = new_slide() -chrome(s, 10, "opt6 · FP32 device pedestal", "The first change to the kernel itself") -bullets(s, M, 1.95, 7.5, [ - "~99.9% of pixels take the **pedestal-update** branch, which reads and writes " - "mean, sum and sum². In FP64 that is **32 bytes per pixel**; in FP32, 16.", - "The kernel is **bandwidth-bound**, so halving that traffic nearly halves the time.", - "Second effect: on a GeForce part, **FP64 arithmetic runs at 1/64 of FP32**. " - "The pedestal update was paying that tax on every pixel.", -], size=10.5) -figure(s, "fig_f32_kernel", M, 4.05, 7.5) -code(s, 8.5, 1.95, 4.1, [ - "// clusterfinder_kernel.cuh", - "using COMPUTE_TYPE = float;", - "using DEVICE_PED_TYPE = «float»;", - "// was: double", -], size=8.5, title="ONE TYPEDEF") -callout(s, 8.5, 3.05, 4.1, - "Kernel, 9×9, measured with Nsight Systems\n**43.0 µs → 25.6 µs (−40%)**", - h=0.92, size=11) -caption(s, 8.5, 4.20, 4.1, - "Exclusive per-instance kernel time, 2 000 instances, 1 stream. " - "σ = 0.4 µs. Transfers are unchanged, as they must be.") -callout(s, 8.5, 5.30, 4.1, - "But a faster kernel is **not automatically a faster frame** — and naive FP32 " - "is **wrong**. Both on the next slides.", h=1.05, size=10, color=AMBER) - -# =========================================================== 11 · OPT6 trap -s = new_slide() -chrome(s, 11, "opt6 · the correctness trap", "Why the obvious FP32 pedestal is broken") -bullets(s, M, 1.95, 7.9, [ - "The running variance was computed as **var = E[X²] − mean²**. With a pedestal " - "mean of ~4 655 ADU, both terms are ≈ 2.17 × 10⁷ while the answer is ≈ 2 000.", - "In FP32 the spacing between representable numbers at 2.17 × 10⁷ is **2 048** — " - "larger than the variance itself. This is **catastrophic cancellation**.", -], size=10.5) -figure(s, "fig_cancellation", M, 3.25, 7.5) -rail(s, [ - ("label", "What it looked like"), - ("gap", 0.15), - ("stat", "Extra clusters", "+28.06%", AMBER), - ("gap", 0.05), - ("note", "Quiet pixels got rms → 0, so their threshold became 0 and they fired " - "on every single frame — producing a large unphysical high-energy tail " - "in the spectrum."), - ("gap", 0.35), - ("row", "Affected pixels", "~1–2% of the sensor", TEXT2), - ("row", "Written up in", "docs/pedestal_precision_…", MUTED), -]) - -# =========================================================== 12 · OPT6 fix -s = new_slide() -chrome(s, 12, "opt6 · the variance rewrite", "Accumulate what is small, not what is large") -bullets(s, M, 1.95, COL, [ - "Freeze a per-pixel baseline **X₀ = round(mean)** once, at the end of pedestal " - "training, and never move it again.", - "Accumulate the **centred** value Y = X − X₀ instead of X. Now both sums are " - "O(rms)-sized — the huge common term is gone before the subtraction.", - "The reported pedestal mean is still the full value, **X₀ + sum/n**, so nothing " - "downstream changes.", -]) -code(s, M, 4.0, COL, [ - "// before — both terms ~2.17e7, answer ~2000", - "var = sum2/n - mean*mean;", - "", - "// after — centred on a frozen per-pixel offset X0", - "DEVICE_PED_TYPE resid = mean - «d_pd_off»[i]; // ~O(1)", - "DEVICE_PED_TYPE var_px = sum2[i]/n - resid*resid; // no cancellation", -], title="clusterfinder_kernel.cuh") -callout(s, M, 6.05, COL, - "Result: the 100% FP32 build now matches the FP64 build to " - "**3 × 10⁻⁷** — 70 clusters out of 233 million.") -rail(s, [ - ("label", "Why it works"), - ("gap", 0.15), - ("note", "Precision is relative. Floats resolve small numbers finely and large " - "numbers coarsely — so never let a small answer be the difference of " - "two large numbers."), - ("gap", 0.5), - ("row", "f32 vs f64 counts", "3 × 10⁻⁷", ACCENT), - ("row", "vs CPU", "0.0039%", ACCENT), - ("gap", 0.3), - ("note", "X₀ must never be updated — the accumulators are defined relative to it."), -]) - -# =========================================================== 13 · OPT6 when -s = new_slide() -chrome(s, 13, "opt6 · when does a faster kernel help?", "Only if the kernel was the tallest bar") -figure(s, "fig_bottleneck", M, 2.05, 11.9) -callout(s, M, 5.35, 11.9, - "**f32 buys exactly the distance between the kernel and the next-tallest bar.** " - "At 3×3 the kernel already hides inside the transfers, so end-to-end throughput does not move at all. " - "At 9×9 the kernel is on the critical path, and the same change is worth 8% of the frame.", - h=0.95, size=11) -caption(s, M, 6.55, 11.9, - "9×9, 20 000 frames, 8 streams, cap 1 500 (all CPU clusters recorded): " - "1.540 s → 1.423 s batched, 1.482 s → 1.372 s with graphs. " - "The FP64 build shows the classic kernel-bound signature — per-frame wall time shorter than per-frame kernel time, because kernels from different streams queue.") - -# =========================================================== 14 · RESULTS -s = new_slide() -chrome(s, 14, "Results", "The whole ladder, one dataset, one baseline") -figure(s, "fig_arc", M, 1.95, 11.9) -callout(s, M, 5.55, 5.85, - "**×8.29 over 48 CPU threads** — 21.0 s → 2.53 s for 100 000 frames.", h=0.8) -callout(s, 6.75, 5.55, 5.85, - "Every step is **monotonic**, and correctness is held constant at **0.004%** throughout.", - h=0.8, color=AMBER) -caption(s, M, 6.55, 11.9, - "3×3 clusters · nσ = 5 · 100 000 frames · batch 2 000 · 4 streams · warm run " - "(page faults plateaued) · CPU baseline = ClusterFinderMT with 48 threads.") - -# =========================================================== 15 · WHERE TIME GOES -s = new_slide() -chrome(s, 15, "Where the time actually went", "The kernel never changed — the overhead collapsed") -figure(s, "fig_overhead", M, 2.05, 5.9) -bullets(s, 7.0, 2.15, 5.6, [ - "The GPU kernel is a **flat ~23 µs** across opt1–opt5. Not one of those steps " - "made the arithmetic faster.", - "What changed is everything around it: **44 → 21 → 14 → 3 µs** of host and PCIe " - "time per frame.", - "By opt4 the pipeline is **fed almost perfectly** — which is precisely why opt5 " - "(launch overhead) is the only lever left, and why opt6 shows nothing at 3×3.", -], size=10.5) -callout(s, 7.0, 5.15, 5.6, - "The bottleneck moved from **the host**, to **PCIe**, and finally — only for " - "large cluster windows — to **the kernel**.", h=0.95, size=10.5) -caption(s, M, 6.4, 11.9, - "Per-frame GPU operation profile at 9×9 (Nsight Systems): kernel 43 µs · " - "D2H 19.4 µs · H2D 13.2 µs. At 3×3 the kernel is 13–24 µs against a ~25 µs " - "transfer-and-host floor — which is the entire story of this deck in three numbers.") - -# =========================================================== 16 · CORRECTNESS -s = new_slide() -chrome(s, 16, "Validation", "Same physics out of every variant") -figure(s, "fig_correctness", M, 2.0, 7.6) -bullets(s, 8.6, 2.05, 4.1, [ - "233 million clusters over 100 000 frames.", - "All CUDA variants agree with the CPU to **0.004%**.", - "The residual is **not** precision: it is the CUDA finder updating the pedestal " - "**once per frame** vs the CPU's per-pixel update.", -], size=10) -code(s, M, 4.85, 7.6, [ - "CPU (ClusterFinderMT) 233 085 343 2330.85 / frame reference", - "opt1 .. opt5 (f64 ped) 233 093 484 2330.93 / frame 0.0035 %", - "opt6 (f32 ped) 233 094 465 2330.94 / frame 0.0039 %", -], size=8.5, title="CLUSTER COUNTS · 3×3 · 100 000 FRAMES") -callout(s, 8.6, 4.85, 4.1, - "A local-maximum gate had to be **back-ported** into the opt1/opt2 snapshot so " - "the whole ladder is compared at identical correctness.", h=1.0, size=10, color=AMBER) -caption(s, M, 6.35, 11.9, - "Cross-checks: ClusterFinderFrozen (a CPU finder with the CUDA pedestal-update " - "timing) isolates that residual; energy spectra overlay within statistics.") - -# =========================================================== 17 · API 1 -s = new_slide() -chrome(s, 17, "For users · Python API", "The fast path in eight lines") -code(s, M, 1.95, 7.6, [ - "from aare import File, ClusterFinderCUDA", - "", - "cf = ClusterFinderCUDA(image_size=(400, 400), cluster_size=(3, 3),", - " n_sigma=5, «n_streams»=4,", - " «max_clusters_per_frame»=3000)", - "", - "for _ in range(1000): # 1. train the pedestal", - " cf.push_pedestal_frame(pd.read_frame())", - "", - "data = f.read_n(100_000) # 2. one contiguous array", - "cf.«register_input_buffer»(data) # 3. pin it once", - "", - "for s in range(0, N, 2000): # 4. batch through it", - " clusters = cf.«find_clusters_batched»(data[s:s+2000], first_frame=s)", - "", - "cf.unregister_input_buffer() # 5. release the pages", -], size=9, title="THE RECOMMENDED PATTERN") -bullets(s, 8.6, 2.0, 4.1, [ - "find_clusters_batched returns **one ClusterVector per frame**, in order.", - "register_input_buffer is what turns opt3 into opt4 — **one call**.", - "Swap in ClusterFinderCUDAGraph for opt5; the API is identical.", -], size=10) -callout(s, 8.6, 4.55, 4.1, - "GIL is released for both find_clusters and find_clusters_batched, so " - "reading the next file can overlap with the GPU.", h=1.05, size=10) -callout(s, 8.6, 5.80, 4.1, - "Pin **once**, outside the loop. Slices of a registered array inherit the pinning.", - h=0.85, size=10, color=AMBER) - -# =========================================================== 18 · API 2 -s = new_slide() -chrome(s, 18, "For users · choosing the knobs", "What to set, and what it costs you") -hdr = [("Parameter", 1.05), ("What it does", 3.6), ("Guidance", 5.2)] -y = 2.0 -rect(s, M, y, 11.9, 0.4, PANEL) -for lab, dx in hdr: - tf = tb(s, M + dx - 0.85 if dx > 1.05 else M + 0.28, y + 0.09, 5.0, 0.3) - run(para(tf, True), lab.upper(), 9, MUTED, bold=True, spc=1.3) -y += 0.44 -params = [ - ("n_streams", "How many frames are in flight at once.", - "4 is right for 3×3. Larger windows saturate the GPU — 8 helps at 9×9."), - ("max_clusters_per_frame", "Fixed size of the per-frame D2H transfer.", - "Must exceed the real maximum or clusters are silently dropped. Too high wastes PCIe."), - ("batch size", "Frames per find_clusters_batched call.", - "2 000 amortises launch overhead without a large pinned footprint."), - ("cluster_size", "Compile-time stencil geometry.", - "3×3 and 9×9 are registered; 9×9 shifts the bottleneck onto the kernel."), - ("register_input_buffer", "Page-locks the host array for DMA.", - "Always, if the data is already in RAM. Check the pinning budget first."), -] -for i, (p_, what, guide) in enumerate(params): - if i % 2 == 0: - rect(s, M, y, 11.9, 0.82, PANEL) - tf = tb(s, M + 0.28, y + 0.14, 2.6, 0.5) - run(para(tf, True, line=1.1), p_, 9.5, ACCENT, font=MONO, bold=True) - tf = tb(s, M + 3.0, y + 0.14, 2.9, 0.6) - run(para(tf, True, line=1.2), what, 9.5, PALE) - tf = tb(s, M + 6.15, y + 0.14, 5.4, 0.6) - run(para(tf, True, line=1.2), guide, 9.5, TEXT2) - y += 0.80 -callout(s, M, 6.55, 11.2, - "The single most common mistake: leaving **max_clusters_per_frame** too low. " - "It does not error — it truncates, and every frame quietly returns the same count.", - h=0.66, size=10, color=AMBER) - -# =========================================================== 19 · NEXT -s = new_slide() -chrome(s, 19, "Where this leaves us", "The bottleneck has moved — twice") -cards = [ - ("DONE", ACCENT, "×8.29 over 48 CPU threads", - "25.3 µs/frame end to end, at 0.004% cluster agreement. Five pipeline steps " - "and one kernel step."), - ("DONE", ACCENT, "FP32 pedestal, safely", - "−40% kernel, and correct — because the variance is now accumulated on a frozen " - "per-pixel offset instead of a raw second moment."), - ("NEXT", AMBER, "Attack the transfers, not the kernel", - "At 3×3 the kernel is already hidden. The remaining per-frame cost is PCIe: " - "492 kB of clusters out, 312 kB of frame in."), - ("NEXT", AMBER, "Keep results on the device", - "On-GPU reduction, eta/interpolation on device, or compressed cluster formats — " - "so the D2H bar stops setting the floor."), -] -x, y = M, 2.05 -for i, (tag, col, title, body) in enumerate(cards): - cx = M + (i % 2) * 6.05 - cy = 2.05 + (i // 2) * 2.35 - rect(s, cx, cy, 5.85, 2.05, PANEL) - rect(s, cx, cy, 5.85, 0.035, col) - tf = tb(s, cx + 0.3, cy + 0.26, 1.4, 0.26) - run(para(tf, True), tag, 8.5, col, bold=True, spc=1.5) - tf = tb(s, cx + 0.3, cy + 0.60, 5.2, 0.4) - run(para(tf, True, line=1.1), title, 14, PALE, bold=True) - tf = tb(s, cx + 0.3, cy + 1.12, 5.2, 0.85) - run(para(tf, True, line=1.3), body, 10, TEXT2) -callout(s, M, 6.58, 11.2, - "Full numbers, methodology and reproduction steps: **docs/benchmark_opt1_opt6_results.md** · " - "notebook **python/tests/ClusterFinderCUDA_perf.ipynb** · profiler probe **python/tests/nsys_kernel_probe.py**", - h=0.66, size=9.5) - -prs.save(OUT) -print(f"saved {OUT} ({len(prs.slides.__iter__.__self__._sldIdLst)} slides)") diff --git a/docs/deck/build_fused_deck.py b/docs/deck/build_fused_deck.py new file mode 100644 index 00000000..3505dd00 --- /dev/null +++ b/docs/deck/build_fused_deck.py @@ -0,0 +1,1959 @@ +"""Build docs/cf_cuda_fused.pptx — the algorithm + kernel + hardware half of +docs/cf_cuda_kernel.pptx fused with the opt1→opt7 optimization story. + +The ladder is told in three acts, ordered by which bar is tallest: + + ACT I [f64] feed the GPU opt1 opt2 opt3 opt4 (+ route A, rejected) + ACT II [f64] get results back opt5 opt6 (+ routes B', B'', rejected) + ACT III [f32] the kernel opt7 + +Act III comes last because it cannot be justified earlier: measured through +collect() the f32 kernel is worth 1.5 % end-to-end, and only once the host is off +the critical path is the same change worth 21 %. + +cf_cuda_kernel.pptx is kept solely as the base presentation — it donates the PSI +theme and title slide, and every other slide of it is deleted below. + +All hardware numbers are re-measured against the CURRENT kernel, not taken +from the kernel deck (whose implementation details and timings are stale): + + nvcc -arch=sm_89 --ptxas-options=-v -> registers, spills + cudaOccupancyMaxActiveBlocksPerMultiprocessor -> blocks/SM, occupancy + + 3x3 : 34 regs/thread, 0 spill, 1296 B smem, 6 blocks/SM, 100.0% occupancy + 9x9 : 128 regs/thread, 0 spill, 2304 B smem, 2 blocks/SM, 33.3% occupancy + 32x32 blocks @ 9x9: 0 blocks/SM -- 1024 x 128 regs > 65536 regs/SM + +Performance numbers come from docs/ClusterFinderCUDA_benchmark_results.md (quotable +rows only). +""" +from pptx import Presentation +from pptx.util import Inches as In, Pt +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR +from pptx.enum.shapes import MSO_SHAPE +from lxml import etree +from pathlib import Path +from PIL import Image + +DOCS = Path(__file__).resolve().parent.parent +FIGS = DOCS / "figures" +BASE = DOCS / "cf_cuda_kernel.pptx" # PSI theme + title slide +OUT = DOCS / "cf_cuda_fused.pptx" + +# ---------------------------------------------------------------- design tokens +BG = RGBColor(0x0B, 0x10, 0x18) +PANEL = RGBColor(0x12, 0x1A, 0x28) +CODEBG = RGBColor(0x0E, 0x14, 0x20) +RULE = RGBColor(0x1E, 0x28, 0x36) +ACCENT = RGBColor(0x1E, 0x90, 0xC2) +AMBER = RGBColor(0xE8, 0xB2, 0x5C) +PALE = RGBColor(0xE7, 0xED, 0xF4) +TEXT2 = RGBColor(0xA5, 0xB2, 0xC4) +MUTED = RGBColor(0x6B, 0x7A, 0x90) +CARD = RGBColor(0xF4, 0xF6, 0xF9) # light card for white figures + +UI, MONO = "Segoe UI", "Consolas" +W, H = 13.333, 7.5 +M = 0.7 # left margin +COL = 7.9 # left column width +RAIL_X, RAIL_W = 9.2, 3.5 # right rail + +A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" +P = "{http://schemas.openxmlformats.org/presentationml/2006/main}" +R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" + +prs = Presentation(str(BASE)) +prs.slide_width, prs.slide_height = In(W), In(H) +BLANK = prs.slide_layouts[0] # 'Blank Slide' — zero shapes +N_SLIDES = 34 + + +# ------------------------------------------------------------------- helpers +def keep_only_slide(prs, keep=0): + """Drop every slide but one from the base presentation.""" + lst = prs.slides._sldIdLst + for i, sldId in enumerate(list(lst)): + if i != keep: + prs.part.drop_rel(sldId.get(f"{R}id")) + lst.remove(sldId) + + +def set_para_texts(shape, texts): + """Replace paragraph texts in-place, keeping each paragraph's formatting.""" + for p, txt in zip(shape.text_frame.paragraphs, texts): + if not p.runs: + continue + p.runs[0].text = txt + for r in p.runs[1:]: + r.text = "" + + +def new_slide(): + """A dark slide on the PSI master. + + Two independent guards, because the base template's master carries a PSI + background picture and logo that must not bleed through: + 1. showMasterSp="0" + a slide-level (correct schema position: + first child of ), which is what PowerPoint honours; + 2. a full-bleed rectangle as the first shape, which every renderer + honours regardless of how it treats (1). + """ + s = prs.slides.add_slide(BLANK) + s._element.set("showMasterSp", "0") + + cSld = s._element.find(f"{P}cSld") + bg = etree.Element(f"{P}bg") + pr = etree.SubElement(bg, f"{P}bgPr") + fill = etree.SubElement(pr, f"{A}solidFill") + clr = etree.SubElement(fill, f"{A}srgbClr") + clr.set("val", "0B1018") + etree.SubElement(pr, f"{A}effectLst") + cSld.insert(0, bg) + + rect(s, 0, 0, W, H, BG) + return s + + +def rect(s, x, y, w, h, color, shape=MSO_SHAPE.RECTANGLE): + sh = s.shapes.add_shape(shape, In(x), In(y), In(w), In(h)) + sh.fill.solid(); sh.fill.fore_color.rgb = color + sh.line.fill.background(); sh.shadow.inherit = False + return sh + + +def tb(s, x, y, w, h, anchor=MSO_ANCHOR.TOP): + box = s.shapes.add_textbox(In(x), In(y), In(w), In(h)) + tf = box.text_frame + tf.word_wrap = True + tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0 + tf.vertical_anchor = anchor + return tf + + +def para(tf, first=False, space_after=0, space_before=0, line=None, align=None): + p = tf.paragraphs[0] if first else tf.add_paragraph() + p.space_after = Pt(space_after); p.space_before = Pt(space_before) + if line: p.line_spacing = line + if align: p.alignment = align + return p + + +def run(p, text, size=11, color=TEXT2, font=UI, bold=False, italic=False, spc=None): + r = p.add_run(); r.text = text + f = r.font + f.name, f.size, f.bold, f.italic = font, Pt(size), bold, italic + f.color.rgb = color + if spc is not None: + r.font._rPr.set("spc", str(int(spc * 100))) + return r + + +# ------------------------------------------------------------------ chrome +def chrome(s, idx, eyebrow, title, title_size=27): + rect(s, M, 0.60, 0.35, 0.035, ACCENT) + tf = tb(s, 1.17, 0.50, 10.33, 0.32) + run(para(tf, True), eyebrow.upper(), 9, MUTED, bold=True, spc=1.6) + + tf = tb(s, M, 0.86, 11.9, 1.0) + run(para(tf, True, line=1.05), title, title_size, PALE, bold=True) + + span, n = 11.0, N_SLIDES + pitch = span / n; wseg = pitch * 0.90 + for i in range(n): + rect(s, M + i * pitch, 7.28, wseg, 0.045, ACCENT if i <= idx - 1 else RULE) + tf = tb(s, 12.0, 7.14, 0.9, 0.3) + run(para(tf, True, align=PP_ALIGN.RIGHT), f"{idx} / {n}", 8.5, MUTED) + + +N_ANNEX = 6 + + +def annex_chrome(s, idx, eyebrow, title, title_size=27): + """Same chrome, amber, on its own progress track — the annex is not part of + the 29-slide arc and should not look like it is.""" + rect(s, M, 0.60, 0.35, 0.035, AMBER) + tf = tb(s, 1.17, 0.50, 10.33, 0.32) + run(para(tf, True), f"ANNEX · {eyebrow}".upper(), 9, MUTED, bold=True, spc=1.6) + tf = tb(s, M, 0.86, 11.9, 1.0) + run(para(tf, True, line=1.05), title, title_size, PALE, bold=True) + pitch = 11.0 / N_ANNEX + for i in range(N_ANNEX): + rect(s, M + i * pitch, 7.28, pitch * 0.90, 0.045, + AMBER if i <= idx - 1 else RULE) + tf = tb(s, 12.0, 7.14, 0.9, 0.3) + run(para(tf, True, align=PP_ALIGN.RIGHT), f"A{idx} / {N_ANNEX}", 8.5, MUTED) + + +def table(s, x, y, w, header, rows, colw, size=9.5, rowh=0.62): + """Minimal header + zebra table. colw are fractions of w.""" + xs, acc = [], 0.0 + for c in colw: + xs.append(x + acc * w) + acc += c + rect(s, x, y, w, 0.34, PANEL) + for cx, h in zip(xs, header): + tf = tb(s, cx + 0.16, y + 0.08, w, 0.24) + run(para(tf, True), _up(h), 8, MUTED, bold=True, spc=1.2) + yy = y + 0.38 + for i, row in enumerate(rows): + if i % 2 == 0: + rect(s, x, yy, w, rowh, PANEL) + for j, (cx, cell) in enumerate(zip(xs, row)): + col = PALE if j == 0 else TEXT2 + tf = tb(s, cx + 0.16, yy + 0.10, colw[j] * w - 0.24, rowh) + p = para(tf, True, line=1.2) + for k, part in enumerate(str(cell).split("**")): + if part: + run(p, part, size, AMBER if k % 2 else col, bold=bool(k % 2)) + yy += rowh + 0.04 + return yy + + +def bullets(s, x, y, w, items, size=11, gap=7): + tf = tb(s, x, y, w, 0.3) + for i, it in enumerate(items): + color, txt = (it if isinstance(it, tuple) else (TEXT2, it)) + p = para(tf, i == 0, space_after=gap, line=1.25) + run(p, "• ", size, MUTED) + for j, part in enumerate(txt.split("**")): + if part: + run(p, part, size, PALE if j % 2 else color, bold=bool(j % 2)) + return tf + + +def code(s, x, y, w, lines, size=8.5, title=None): + lh = 0.148 + h = 0.24 + len(lines) * lh + (0.22 if title else 0) + rect(s, x, y, w, h, CODEBG, MSO_SHAPE.ROUNDED_RECTANGLE) + ty = y + 0.12 + if title: + tf = tb(s, x + 0.18, ty, w - 0.36, 0.2) + run(para(tf, True), title, 7.5, MUTED, bold=True, spc=1.2) + ty += 0.22 + tf = tb(s, x + 0.18, ty, w - 0.36, h - 0.24) + for i, ln in enumerate(lines): + p = para(tf, i == 0, line=1.12) + if ln.strip().startswith(("//", "#")): + run(p, ln, size, MUTED, MONO) + continue + for j, part in enumerate(ln.split("«")): + for k, seg in enumerate(part.split("»")): + if not seg: continue + hi = (j > 0 and k == 0) + run(p, seg, size, ACCENT if hi else TEXT2, MONO, bold=hi) + return h + + +def callout(s, x, y, w, text, h=0.78, color=ACCENT, size=10.5): + rect(s, x + 0.045, y, w - 0.045, h, PANEL) + rect(s, x, y, 0.045, h, color) + tf = tb(s, x + 0.28, y + 0.10, w - 0.5, h - 0.2, MSO_ANCHOR.MIDDLE) + p = para(tf, True, line=1.2) + for j, part in enumerate(text.split("**")): + if part: + run(p, part, size, PALE if j % 2 else TEXT2, bold=bool(j % 2)) + + +def _up(txt): + """upper() for labels, but 'µ'.upper() is Greek capital Mu — which renders as + an 'M' and turns 'µs' into 'MS', i.e. microseconds into milliseconds.""" + return txt.upper().replace("\u039c", "µ") + + +def rail(s, items, y0=2.0, divider=True): + if divider: + rect(s, 8.95, 2.0, 0.012, 4.55, RULE) + y = y0 + for it in items: + kind = it[0] + if kind == "label": + tf = tb(s, RAIL_X, y, RAIL_W, 0.26) + run(para(tf, True), _up(it[1]), 8.5, MUTED, bold=True, spc=1.4) + y += 0.28 + elif kind == "stat": + _, lab, val, col = it + tf = tb(s, RAIL_X, y, RAIL_W, 0.24) + run(para(tf, True), _up(lab), 8.5, MUTED, spc=1.2) + tf = tb(s, RAIL_X, y + 0.24, RAIL_W, 0.6) + run(para(tf, True), val, 26, col, bold=True) + y += 0.98 + elif kind == "row": + _, lab, val, col = it + tf = tb(s, RAIL_X, y, RAIL_W, 0.24) + run(para(tf, True), _up(lab), 8.5, MUTED, spc=1.2) + tf = tb(s, RAIL_X, y + 0.22, RAIL_W, 0.3) + run(para(tf, True), val, 13, col, bold=True) + y += 0.66 + elif kind == "note": + tf = tb(s, RAIL_X, y, RAIL_W, 0.9) + run(para(tf, True, line=1.25), it[1], 9, TEXT2) + y += 0.30 + 0.17 * (len(it[1]) // 42 + 1) + elif kind == "gap": + y += it[1] + return y + + +def figure(s, name, x, y, w): + p = FIGS / f"{name}.png" + iw, ih = Image.open(p).size + h = w * ih / iw + s.shapes.add_picture(str(p), In(x), In(y), In(w), In(h)) + return h + + +def card_figure(s, name, x, y, w, pad=0.10): + """A light-background figure (imported, not re-rendered) on a light card.""" + p = FIGS / f"{name}.png" + iw, ih = Image.open(p).size + h = w * ih / iw + rect(s, x - pad, y - pad, w + 2 * pad, h + 2 * pad, CARD, + MSO_SHAPE.ROUNDED_RECTANGLE) + s.shapes.add_picture(str(p), In(x), In(y), In(w), In(h)) + return h + 2 * pad + + +def notes(s, text): + """Speaker notes. Detail that belongs in the talk, not on the slide. + + python-pptx creates the notes slide on first access, so this is safe to call + on any slide. Used to relieve slides that carry a figure: the mechanism goes + on the screen, the API detail goes here. + """ + s.notes_slide.notes_text_frame.text = text + + +def caption(s, x, y, w, text, size=9): + tf = tb(s, x, y, w, 0.3) + run(para(tf, True, line=1.25), text, size, MUTED) + + +def flow(s, x, y, w, steps, h=0.62): + """Numbered left-to-right step strip.""" + n = len(steps); gap = 0.30 + bw = (w - gap * (n - 1)) / n + for i, t in enumerate(steps): + bx = x + i * (bw + gap) + rect(s, bx, y, bw, h, PANEL) + rect(s, bx, y, 0.03, h, ACCENT) + tf = tb(s, bx + 0.20, y + 0.05, bw - 0.32, h - 0.10, MSO_ANCHOR.MIDDLE) + p = para(tf, True, line=1.1) + run(p, f"{i + 1} ", 9, ACCENT, bold=True, font=MONO) + run(p, t, 9.5, PALE) + if i < n - 1: + tf = tb(s, bx + bw, y + 0.05, gap, h - 0.10, MSO_ANCHOR.MIDDLE) + run(para(tf, True, align=PP_ALIGN.CENTER), "›", 15, MUTED, bold=True) + + +def statstrip(s, x, y, w, items, h=0.80): + n = len(items); gap = 0.22 + bw = (w - gap * (n - 1)) / n + for i, (lab, val) in enumerate(items): + bx = x + i * (bw + gap) + rect(s, bx, y, bw, h, PANEL) + tf = tb(s, bx + 0.20, y + 0.12, bw - 0.4, 0.22) + run(para(tf, True), lab.upper(), 8, MUTED, bold=True, spc=1.2) + tf = tb(s, bx + 0.20, y + 0.37, bw - 0.4, 0.34) + run(para(tf, True), val, 15, PALE, bold=True) + + +# ------------------------------------------------------------ interstitial +_NARROW, _WIDE = set("ijlt.,;:'!|()[]"), set("mwMW") + + +def _em(txt): + """Width of `txt` in ems of Segoe UI Bold, near enough to wrap by. + + A character count is not good enough: 'now the tallest bar' and + 'measured, and in what' are the same length and differ by 8 % in width, which + is exactly the margin that decides whether a title takes two lines or three. + Weights are calibrated against a LibreOffice render of two title lines and + reproduce both to within 1 %. + """ + return sum(1.08 if c in _WIDE else + 0.37 if c in _NARROW else + 0.34 if c == " " else 0.68 for c in txt) + + +def _fit(title, w, sizes=(36, 32, 28, 24), lines=2): + """Largest size at which `title` wraps into at most `lines` lines across `w`. + + python-pptx cannot measure text and PowerPoint's autofit does not apply until + a render, so a title that grows one line silently walks over the rule beneath + it. No tolerance is granted — a title that only just fits is one font + substitution away from not fitting on someone else's machine. + """ + for size in sizes: + cap = w / (size / 72) + n, cur = 1, 0.0 + for word in title.split(): + need = _em(word) + (0.34 if cur else 0) + if cur + need > cap and cur: + n, cur = n + 1, _em(word) + else: + cur += need + if n <= lines: + return size + return sizes[-1] + + +def section(kicker, title, thesis, items, rng, col=ACCENT, carry=None, + annex=False): + """An unnumbered beat between sections: where we are, and what is coming. + + Deliberately sparse — it exists to buy 10–15 s of stage setting, so it has + to be readable at a glance and finished before the audience starts reading + ahead. It carries no slide number and takes no tick of its own on the + progress track: slides 3–34 keep the numbers they have, so the annex's + cross-references ("expands slide 26") stay true. What it lights up instead + is the *range* the section covers, which is the thing the audience wants. + """ + s = new_slide() + rect(s, 0, 0, 0.16, H, col) + + tf = tb(s, M + 0.3, 1.52, 5.8, 0.3) + run(para(tf, True), kicker.upper(), 9.5, MUTED, bold=True, spc=1.8) + tf = tb(s, M + 0.3, 1.90, 6.0, 1.35) + run(para(tf, True, line=1.03), title, _fit(title, 6.0), PALE, bold=True) + rect(s, M + 0.3, 3.42, 1.5, 0.03, col) + tf = tb(s, M + 0.3, 3.70, 5.7, 1.6) + run(para(tf, True, line=1.4), thesis, 13.5, TEXT2) + + if carry: + lab, val, sub = carry + rect(s, M + 0.3, 5.55, 5.7, 1.12, PANEL) + rect(s, M + 0.3, 5.55, 0.035, 1.12, col) + tf = tb(s, M + 0.60, 5.72, 5.2, 0.24) + run(para(tf, True), _up(lab), 8.5, MUTED, bold=True, spc=1.4) + tf = tb(s, M + 0.60, 5.94, 5.2, 0.4) + run(para(tf, True), val, 24, col, bold=True) + tf = tb(s, M + 0.60, 6.40, 5.2, 0.24) + run(para(tf, True), sub, 9.5, MUTED) + + rect(s, 7.15, 1.95, 0.012, 4.4, RULE) + step = 0.42 if len(items) > 7 else 0.46 if len(items) > 5 else 0.54 + y = 1.95 + (4.4 - len(items) * step) / 2 + tf = tb(s, 7.45, y - 0.42, 5.0, 0.26) + run(para(tf, True), "COMING UP", 8.5, MUTED, bold=True, spc=1.6) + for num, txt in items: + tf = tb(s, 7.45, y, 0.7, 0.3) + run(para(tf, True), str(num), 12, col, bold=True, font=MONO) + tf = tb(s, 8.15, y, 4.5, 0.3) + p = para(tf, True) + for j, part in enumerate(txt.split("**")): + if part: + run(p, part, 12, col if j % 2 else PALE, bold=bool(j % 2)) + y += step + + # The annex divider sits on the annex's own track: the main arc is finished + # behind it, so lighting main-track segments would misreport where we are. + n_track = N_ANNEX if annex else N_SLIDES + pitch = 11.0 / n_track + for i in range(n_track): + n = i + 1 + # the section ahead in its own colour, what is already behind us dimmed, + # the rest dark — so the divider agrees with the chrome on either side. + c = col if annex or rng[0] <= n <= rng[1] else ( + MUTED if n < rng[0] else RULE) + rect(s, M + i * pitch, 7.28, pitch * 0.90, 0.045, c) + return s + + +# =========================================================== 1 · PSI TITLE +keep_only_slide(prs, 0) +title_slide = prs.slides[0] +by_name = {sh.name: sh for sh in title_slide.shapes} +set_para_texts(by_name["TextShape 1"], ["The CUDA ClusterFinder"]) +set_para_texts(by_name["CustomShape 4"], + ["Kernel design · hardware limits · seven optimization steps"]) +set_para_texts(by_name["CustomShape 2"], ["Khalil Daniel Ferjaoui"]) +set_para_texts(by_name["CustomShape 3"], + ["Paul Scherrer Institut · aare", "August 2026"]) + +# =========================================================== 2 · HERO +s = new_slide() +rect(s, 0, 0, 0.16, H, ACCENT) +tf = tb(s, M + 0.3, 0.85, 11, 0.3) +run(para(tf, True), "AARE · HYBRID PIXEL DETECTORS · CUDA CLUSTERFINDER", + 9.5, MUTED, bold=True, spc=1.8) + +tf = tb(s, M + 0.3, 1.28, 11.4, 1.45) +run(para(tf, True, line=1.02), + "The kernel was never the bottleneck — feeding it was", 42, PALE, bold=True) +tf = tb(s, M + 0.3, 2.80, 11.4, 0.8) +run(para(tf, True, line=1.05), + "One kernel, one thread per pixel, and seven steps to keep it fed", 22, ACCENT) + +tf = tb(s, M + 0.3, 3.40, 10.2, 0.9) +run(para(tf, True, line=1.3), + "The stencil was fast almost immediately. At 3×3 the kernel needs 5.5 µs per " + "frame; moving that frame across PCIe costs 13.2 µs at best, and 16.3 as the " + "pipeline runs it. Six of the seven optimization steps never touch the " + "arithmetic, they get data in, get results back, and measure honestly.", + 12.5, TEXT2) + +stats = [("×9.1", "VS 24-THREAD CPU", ACCENT), ("61,312", "FRAMES / SECOND", PALE), + ("16.3 µs", "PER FRAME, END TO END", PALE), ("6 / 23 M", "CLUSTER MISMATCH VS CPU TWIN", AMBER)] +for i, (v, l, c) in enumerate(stats): + x = M + 0.3 + i * 2.85 + rect(s, x, 4.35, 0.035, 0.95, c) + tf = tb(s, x + 0.22, 4.35, 2.5, 0.55) + run(para(tf, True), v, 30, c, bold=True) + tf = tb(s, x + 0.22, 4.98, 2.5, 0.3) + run(para(tf, True), l, 8.5, MUTED, spc=1.2) + +rect(s, M + 0.3, 6.05, 11.0, 0.012, RULE) +tf = tb(s, M + 0.3, 6.25, 11.4, 0.6) +run(para(tf, True, line=1.35), + "RTX 4090 (Ada, sm_89) · PCIe 4.0 ×16 · Mönch 400×400 uint16 · 3×3 clusters · " + "100 000 frames · Cu fluorescence, MAX IV", 10, MUTED) + +# ------------------------------------------------------- divider · context +section("Context · what the code does", + "What the kernel does, and what limits it", + "No optimizations yet, only what the hardware has to do.", + [("3–4", "The algorithm"), + ("5–6", "**The two machines**"), + ("7–8", "The CUDA kernel"), + ("9–10", "The hardware limit")], + rng=(3, 10)) + +# =========================================================== 3 · THE PHYSICS +s = new_slide() +chrome(s, 3, "The algorithm · what it is for", + "A photon is not a pixel — it is a cluster") +bullets(s, M, 1.90, 7.9, [ + "Charge from one absorbed photon **spreads over neighbouring pixels**. " + "Summing that 3×3 patch recovers the photon energy; a single pixel does not.", + "The histogram of those cluster energies **is** the measurement: peak position " + "and width give the detector's gain and **energy resolution**.", +], size=10.5) +figure(s, "fig_frame", M, 2.98, 7.45) +rail(s, [("label", "Why it matters")], y0=1.95) +h = card_figure(s, "img_spectra", RAIL_X, 2.32, RAIL_W) +caption(s, RAIL_X, 2.32 + h + 0.10, RAIL_W, + "Cluster-energy spectra from an energy scan, against allpix² simulation. " + "Each peak is one beam energy; its width is the resolution being measured.", + size=8.5) +rail(s, [ + ("row", "Pixels per frame", "400 × 400 = 160 000", TEXT2), + ("row", "Peak pixels = photons / frame", "~2 330 · 1.5 %", ACCENT), + ("row", "Pixels above 5σ (2.4 per photon)", "~5 700 · 3.6 %", ACCENT), +], y0=5.10, divider=False) +caption(s, M, 6.68, 7.9, + "Real MOENCH data, Cu fluorescence, MAX IV beamtime. One cluster is emitted " + "per local maximum, so 2 330 counts photons, not lit pixels; the recorded " + "3×3 windows cover 12.7 % of the frame.") + +# =========================================================== 4 · PER FRAME +s = new_slide() +chrome(s, 4, "The algorithm · per frame", "Per pixel: subtract, threshold, update the pedestal") +bullets(s, M, 1.95, COL, [ + "Per pixel: subtract a **running pedestal** (mean ± rms), keep pixels above " + "**nσ · rms**, cut a 3×3 cluster around each local maximum.", + "400×400 = 160 k pixels, **312.5 kB per frame**; Cu data yields ~2 330 clusters " + "per frame at 3×3.", + "The pedestal is **updated by every non-photon pixel**, every frame, so the " + "arithmetic and the data movement are coupled.", +]) +code(s, M, 3.55, COL, [ + "// the whole algorithm, per pixel", + "v = frame[i] - pedestal_mean[i]", + "rms = sqrt(pedestal_sum2[i]/n - pedestal_mean[i]^2)", + "if (v > «nSigma» * rms && v == max(3x3 window)) -> emit cluster", + "else -> update pedestal", +], title="THE KERNEL IN FIVE LINES") +callout(s, M, 5.55, COL, + "**Thesis of this talk:** the compute was fast almost immediately. " + "Six of the seven steps are about feeding it.") +rail(s, [ + ("label", "The shape of the work"), + ("gap", 0.15), + ("stat", "Work items per frame", "160 000", PALE), + ("row", "Operations on each", "~5, identical", TEXT2), + ("gap", 0.12), + ("row", "Communication between them", "none", ACCENT), + ("row", "Order they may run in", "any", ACCENT), + ("gap", 0.22), + ("note", "What a pixel does never depends on what its neighbours decided, only " + "on what they measured. That one property is what the next two slides " + "point two very different machines at."), +]) + +# ==================================================== 5 · THE CPU +# The two machine slides. They exist because the audience is asked, from slide 7 +# on, to accept "one thread per pixel" and "occupancy" without ever having been +# shown what a thread costs on either machine. Both diagrams are the CS149 ones +# (credited in the captions): redrawing them would lose the shared visual +# grammar — orange fetch/decode, yellow ALU, blue execution context — which is +# the entire reason the pair reads at a glance. +s = new_slide() +chrome(s, 5, "The machine we are starting from", + "CPU: latency-oriented, built to finish one thread fast") +figure(s, "img_cpu_core", M, 1.98, 7.3) +callout(s, M, 5.58, COL, + "Count the boxes: **6 fetch/decode**, out-of-order instruction selection, two " + "levels of private cache, all of it to keep **two** instruction streams fed. " + "The ALUs are the small part.", h=0.86, size=10) +caption(s, M, 6.54, COL, + "One core, schematically. Intel Skylake is shown. The Zen 4 core in this machine " + "differs in detail (4 FP pipes rather than 3; AVX-512 double-pumped on 256-bit " + "datapaths) but not in kind: ~6-wide front end, 4 scalar ALUs, 2 SMT contexts, " + "private L1 + L2. Load/store units not drawn. After Stanford CS149, Fall 2025.", + size=8) +rail(s, [ + ("label", "pc-moench-04 · AMD Ryzen 9 7950X"), + ("gap", 0.10), + ("stat", "Cores × SMT", "16 × 2", PALE), + ("gap", 0.04), + ("stat", "CPU, 1 thread", "1.75 ms", MUTED), + ("stat", "CPU MT, 24 threads", "148 µs", PALE), + ("gap", 0.06), + ("row", "That is the bar", "6 762 frames / s", ACCENT), + ("gap", 0.16), + ("note", "Thread count is swept per cluster size, not assumed: " + "24 at 3×3, 32 at 9×9."), +]) +notes(s, """The CPU slide. The point is not that CPUs are bad -- it is what the +silicon is SPENT on. + +Six fetch/decode units, an out-of-order instruction selector, branch prediction +(not even drawn), L1 + L2 private cache: all of that machinery exists to find +independent work INSIDE one instruction stream, and to hide memory latency +behind a cache. It is the right design when you have a few threads that must +each finish fast. + +Our problem has the opposite shape: 160 000 work items that are already +independent. We do not need a machine to FIND the parallelism -- it is handed to +us. Every transistor spent looking for it is a transistor not doing arithmetic. + +On the thread sweep: 6 762 FPS is the best of a measured sweep (perf/cpu_threads.py), +not a default. 48 threads on 16 cores is 24 % SLOWER than 24 -- worth saying out +loud, because an oversubscribed baseline is the easiest way to inflate a GPU +speedup without lying about anything.""") + +# ==================================================== 6 · THE GPU +s = new_slide() +chrome(s, 6, "The machine we are moving to", + "GPU: throughput-oriented, the whole frame at once") +figure(s, "img_gpu_die", M, 2.10, 2.75) +caption(s, M, 4.92, 2.75, + "AD102 · 144 blocks, 128 enabled on this card. One SM boxed.", size=7.5) +figure(s, "img_gpu_sm", M + 3.05, 1.92, 4.75) +# The colour key is a separate crop: in the source it spans the full slide width +# while the diagram spans 60 % of it, so one rectangle cannot hold both. +figure(s, "img_gpu_legend", M + 3.05, 5.26, 4.75) +callout(s, M, 6.02, COL, + "Same grammar, inverted proportions: **4 fetch/decode** for **64 warp " + "contexts** and a wall of lanes. Nothing reorders instructions: when a warp " + "stalls on memory, the selector just **runs a different one**.", h=0.86, + size=10) +caption(s, M, 6.90, COL, + "One SM: a V100 is shown; this card's is the same idea (128 FP32 lanes, 48 warp " + "slots, 100 kB shared memory). Note how few of the units are FP64, and remember " + "it at opt7. Diagrams after Stanford CS149, Fall 2025.", size=8) +rail(s, [ + ("label", "NVIDIA GeForce RTX 4090"), + ("gap", 0.12), + ("stat", "FP32 lanes", "16 384", PALE), + ("row", "128 SMs × 128 lanes", "vs 32 CPU streams", TEXT2), + ("gap", 0.14), + ("stat", "Resident thread slots", "196 608", ACCENT), + ("row", "The frame needs", "160 000 → all at once", AMBER), + ("gap", 0.20), + ("note", "Device memory runs at 1 008 GB/s (384-bit, 21 Gbps); PCIe delivers 25. " + "Two copy engines, so H2D and D2H run at the same time."), +]) +notes(s, """The GPU slide, and the number to land. + +128 SMs x 1 536 threads = 196 608 thread slots that can be RESIDENT at the same +time. Our frame is 160 000 pixels. The entire frame fits in the machine at once, +one thread per pixel, with room left over -- which is why slide 7's "one thread +per pixel" is not a figure of speech, and why slide 9 can talk about occupancy +as a real quantity (3x3 achieves 100 %: 1 536 threads resident per SM). + +Latency hiding, in one sentence: the CPU hides memory latency with a cache and +out-of-order execution; the GPU hides it by having 48 other warps ready to run. +That is why there is no reorder buffer on this diagram and no branch predictor. + +The trap to pre-empt: 16 384 lanes vs 32 streams is a factor of 512, and we +measure x9.1. Say so before someone else does. We are not lane-limited; we are +BANDWIDTH-limited -- 1 TB/s of device memory, and 25 GB/s of PCIe to reach it. +That gap between 512 and 9 IS the talk.""") + +# =========================================================== 7 · THE KERNEL +s = new_slide() +chrome(s, 7, "The CUDA kernel · execution model", "One thread per pixel") +flow(s, M, 1.90, 11.9, ["load tile + halo", "__syncthreads", "stencil reduction", + "classify", "append or update pedestal"]) +bullets(s, M, 2.85, 7.5, [ + "A **16×16 block = 256 threads** covers 256 pixels; the grid tiles the " + "whole 400×400 frame. Cluster geometry is a **template parameter**, so the " + "stencil is fully unrolled at compile time.", + "The output is **sparse**: only detections touch global memory, through one " + "atomic bump of a per-frame counter. The **decision work is dense**: every " + "pixel is tested, independently and identically.", + "That is exactly the shape a GPU wants: regular, independent, repeated " + "160 000 times per frame.", +], size=10.5) +code(s, M, 4.35, 7.5, [ + "block = dim3(BLOCK_X, BLOCK_Y); // 16 x 16", + "grid = dim3((ncols + BLOCK_X - 1)/BLOCK_X,", + " (nrows + BLOCK_Y - 1)/BLOCK_Y);", + "device::find_clusters_in_single_frame<<>>(", + " d_frame, d_pd_mean, d_pd_sum, d_pd_sum2, d_pd_off, n_pd_samples,", + " nSigma, nrows, ncols, d_clusters, d_cluster_count, max_clusters);", +], size=8, title="LAUNCH CONFIGURATION · ClusterFinderCUDA.hpp") +caption(s, M, 5.80, 7.5, + "400×400 pixels → a 25×25 grid of 16×16 blocks = 625 blocks per frame, " + "handed to 128 SMs. Nothing about the launch depends on how many clusters " + "the frame happens to contain, which is what makes the work uniform.") +code(s, 8.5, 2.85, 4.1, [ + "if (!is_photon) {", + " // this pixel feeds the running", + " // pedestal for the NEXT frame", + " return;", + "}", + "uint32_t i = «atomicAdd»(d_count, 1u);", + "if (i < max_clusters)", + " d_clusters[i] = cluster;", +], size=8, title="THE CRITICAL BRANCH") +callout(s, 8.5, 4.62, 4.1, + "One counter per frame, bumped atomically. **The counter is the write " + "index**, a bump allocator, reset by a memset before each frame.", + h=1.15, size=10) +callout(s, 8.5, 5.95, 4.1, + "~99.9 % of threads take the **other** branch, which is why the pedestal " + "update, not the cluster write, dominates the kernel.", + h=1.1, size=10, color=AMBER) + +# =========================================================== 6 · TILING +s = new_slide() +chrome(s, 8, "The CUDA kernel · shared memory", "Load the tile once, reuse it nine times") +bullets(s, M, 1.92, 12.0, [ + "Neighbouring threads need **overlapping** 3×3 windows. Without shared memory " + "each pixel would be fetched from global memory up to nine times.", + "Each block stages a tile of (16 + 2r) × (16 + 2r) **pedestal-subtracted** " + "values, the halo is the price of the stencil, and it is loaded cooperatively " + "by the threads on the block edges.", +], size=10.5) +figure(s, "fig_tile", M, 3.05, 7.5) +code(s, 8.4, 3.05, 4.3, [ + "extern __shared__ unsigned char smem[];", + "auto *sh = (COMPUTE_TYPE*)smem;", + "auto stride = blockDim.x + 2*col_radius;", + "auto tid = (threadIdx.y + row_radius)*stride", + " + (threadIdx.x + col_radius);", + "// pedestal subtraction fused into the load", + "sh[tid] = d_frame[gid] - «d_pd_mean»[gid];", +], size=8, title="clusterfinder_kernel.cuh") +callout(s, 8.4, 4.60, 4.3, + "Only **odd** cluster sizes are supported (3×3, 5×5, 7×7, 9×9), so that " + "the centre pixel is unique and local-maximum suppression is well defined.", + h=1.15, size=10) +caption(s, 8.4, 5.95, 4.3, + "The tile is stored in COMPUTE_TYPE (float), not in the pedestal type: " + "1.3 KB for 3×3, 2.3 KB for 9×9, against 100 KB of shared memory per SM. " + "Even the old double-precision tile only reached 4.5 KB.") +caption(s, M, 6.70, 7.5, + "Halo cost falls with block size: 56 % of the tile at 8×8, 27 % at 16×16, " + "13 % at 32×32, which is the first half of the block-size argument. " + "The second half is registers, next slide.") + +# =========================================================== 7 · OCCUPANCY +s = new_slide() +chrome(s, 9, "Hardware · occupancy", "3×3 reaches 100 % occupancy, 9×9 only 33 %") +statstrip(s, M, 1.82, 11.9, [ + ("block size", "16 × 16"), + ("threads / block", "256"), + ("max threads / SM · 48 warps", "1 536"), + ("registers / SM", "65 536"), + ("shared mem / block", "1.3 – 2.3 KB"), +]) +figure(s, "fig_occupancy", 0.9, 2.78, 11.5) +callout(s, M, 6.12, 11.9, + "**16×16 is the balance point**, enough threads to amortise the halo, few " + "enough that 6 blocks still fit per SM. At 3×3 that is 48 of 48 warps " + "resident: **fully occupied**. Shared memory never binds; **registers do**.", + h=0.66, size=10.5) +caption(s, M, 6.84, 11.9, + "Shipped f32 build, measured not estimated: cuobjdump -res-usage for registers " + "and spills, cudaOccupancyMaxActiveBlocksPerMultiprocessor for blocks/SM. " + "RTX 4090 (sm_89). Reproduce: python/tests/perf/kernel_resources.py") + +# =========================================================== 8 · REGISTERS +s = new_slide() +chrome(s, 10, "Hardware · the real limiter", "Occupancy is a means, not the goal") +bullets(s, M, 1.92, COL, [ + "The limiter is **register pressure**: every thread keeps a private " + "clusterData[CSX × CSY] staging array, so demand grows with the **square** of " + "the cluster size. 3×3 costs **38 registers**, 9×9 costs **128**.", + "**Zero spills either way.** ptxas would rather give up occupancy than spill to " + "local memory, and on this kernel that is the right trade.", + "Low occupancy is not automatically a problem: at 9×9 one kernel nearly fills " + "the machine on its own.", +], size=10) +figure(s, "fig_regpressure", M, 3.24, 7.9) +callout(s, M, 5.00, COL, + "**Occupancy is an output.** At 9×9 the register file is full at two blocks, " + "so two thirds of the thread slots are stranded: nothing else can be resident " + "no matter how much work is queued.", h=0.80, size=10) +code(s, M, 5.92, COL, [ + "«cuobjdump -res-usage» build/aare/_aare_cuda*.so | c++filt", + " 3x3: REG:«38» STACK:0 LOCAL:0 # STACK/LOCAL 0 = no spills", + " 9x9: REG:«128» STACK:0 LOCAL:0", + "python python/tests/perf/«kernel_resources.py» # blocks/SM, runtime check", +], size=7.5, title="HOW THESE NUMBERS WERE OBTAINED · READ FROM THE BUILT .SO, NO REBUILD") +rail(s, [ + ("label", "Register budget · sm_89 · f32"), + ("gap", 0.15), + ("stat", "3×3 · occupancy", "100 %", ACCENT), + ("stat", "9×9 · occupancy", "33 %", AMBER), + ("gap", 0.05), + ("row", "Spills, either case", "0 bytes", TEXT2), + ("row", "3×3 on the f64 build", "47 regs → 83 %", AMBER), + ("gap", 0.20), + ("note", "Which is why the 9×9 kernel is the one worth optimising, see opt7."), +]) +notes(s, """The arithmetic behind the bars, and the one caveat. + +One SM holds 65 536 registers and 1 536 thread slots. A 16x16 block is 256 +threads, so a block costs regs_per_thread x 256 registers. At 3x3 that is 9 728, +six blocks fit, and the THREAD SLOTS run out first at 100 %. At 9x9 it is +32 768, so two blocks exactly fill the REGISTER FILE and strand two thirds of +the slots. Same kernel, same block size, opposite binding resource. + +The caveat on "low occupancy is fine": it is build-dependent. On the shipped f32 +build the 9x9 kernel is long enough that four streams overlap by only 1.02x, so +the extra streams buy nothing in kernel concurrency. On the f64 build the kernel +is 3.5x longer and DOES overlap, 1.32x, and there four streams genuinely lower +the floor. Quote the overlap factor, never "occupancy is fine" on its own. + +Build dependence of the register count itself: the f64 pedestal costs 3x3 nine +extra registers (47), which loses a block per SM and drops it to 83 %. 9x9 is +unmoved at 128 -- there the limiter is the clusterData[9][9] staging array, not +the pedestal, which is why opt7 helps 3x3's occupancy and not 9x9's.""") + +# =========================================================== 10 · THE LADDER +s = new_slide() +chrome(s, 11, "Roadmap", + "Three acts, ordered by which bar is tallest") +rows = [ + ("act", "ACT I · feed the GPU", "the host cannot submit work fast enough", "[f64]", ACCENT), + ("opt1", "First CUDA port", "1 stream, one launch per frame", "×2.34", ACCENT), + ("opt2", "Streams + batching", "4 streams, 2 000-frame batches", "×3.66", ACCENT), + ("opt3", "Pipeline rework", "sync barriers removed", "×4.32", ACCENT), + ("opt4", "Pinned memory", "DMA-speed host transfers", "×5.69", ACCENT), + ("act", "ACT II · get the results back", "the host copy is now the tallest bar", "[f64]", PALE), + ("opt5", "Host↔GPU overlap", "chunked submit / collect", "×7.45", PALE), + ("opt6", "Zero-copy collection", "read in place, never copy", "×8.65", PALE), + ("act", "ACT III · the kernel", "only now is the kernel the tallest bar", "[f32]", AMBER), + ("opt7", "FP32 pedestal + variance rewrite", "the only kernel change in the deck", + "kernel −41%", AMBER), +] +y = 1.70 +for i, (tag, name, sub, gain, col) in enumerate(rows): + if tag == "act": + rect(s, M, y + 0.30, 11.9, 0.016, col) + tf = tb(s, M + 0.02, y, 8.0, 0.28) + run(para(tf, True), name.upper(), 11, col, bold=True, spc=1.4) + tf = tb(s, M + 5.2, y + 0.03, 6.7, 0.26) + run(para(tf, True, align=PP_ALIGN.RIGHT), f"{sub} {gain}", 9, MUTED) + y += 0.40 + continue + rect(s, M, y, 11.9, 0.48, PANEL) + rect(s, M, y, 0.035, 0.48, col) + tf = tb(s, M + 0.28, y + 0.09, 1.0, 0.32) + run(para(tf, True), tag, 12.5, col, bold=True, font=MONO) + tf = tb(s, M + 1.45, y + 0.04, 5.0, 0.28) + run(para(tf, True), name, 11.5, PALE, bold=True) + tf = tb(s, M + 1.45, y + 0.26, 5.6, 0.26) + run(para(tf, True), sub, 9, MUTED) + tf = tb(s, 9.4, y + 0.09, 3.1, 0.35) + run(para(tf, True, align=PP_ALIGN.RIGHT), gain, 12.5, col, bold=True) + y += 0.52 +caption(s, M, 6.62, 11.9, + "Speedups are 3×3 vs the best CPU configuration, 24 threads. Two routes were measured and " + "rejected, two result transports inside Act II, shown next to the step they " + "lost to, and CUDA Graphs, which is in the annex. The rule that predicts " + "the wins predicts the failures too.") + +# --------------------------------------------------------- divider · ACT I +section("Act I of III · feed the GPU", + "The host cannot submit work fast enough", + "The kernel was fast almost immediately. This act is about the host.", + [(12, "opt1 · first port"), + (13, "opt2 · streams + batching"), + (14, "opt3 · no barriers"), + (15, "opt4 · pinned memory")], + rng=(12, 15), + carry=("Starting from", "6 762 FPS", + "24-thread CPU · 14.8 s for 100 000 frames")) + +# =========================================================== 11 · OPT1 +s = new_slide() +chrome(s, 12, "Act I · opt1 · the first CUDA port", + "The first port runs at 26 % of the GPU's floor") +bullets(s, M, 1.95, COL, [ + "Shared-memory tiling with **halo loading** for any cluster size; pedestal " + "subtraction fused into the tile load.", + "Cluster geometry is a **compile-time template parameter** → the 3×3 stencil " + "is fully unrolled.", + "One cudaMemcpy in, one kernel, one cudaMemcpy out; **the host blocks " + "on every frame**.", +]) +code(s, M, 3.62, COL, [ + "// one frame at a time, the host waits at every step", + "cudaMemcpy(d_frame, h_frame, bytes, cudaMemcpyHostToDevice);", + "find_clusters_in_single_frame", + " <<>>(d_frame, d_pd_mean, ...);", + "cudaMemcpy(h_out, d_out, out_bytes, cudaMemcpyDeviceToHost);", +], title="ClusterFinderCUDAOpt2.hpp · find_clusters()") +callout(s, M, 4.98, COL, + "**PCIe is full-duplex**: H2D, kernel and D2H run on independent engines and " + "overlap, so the floor is **max(H2D, kernel, D2H)**, never the sum. Each term " + "is that engine's **busy time per frame at 4 streams**, the union of its " + "intervals, not how long one operation takes. At 3×3: max(**16.17**, 15.17, " + "7.69) = **16.2 µs → 61 859 FPS**.", h=1.30, size=10.5) +callout(s, M, 6.40, COL, + "opt1 delivers **15 807 FPS**, or **26 % of that peak**. Three quarters of every " + "frame is the host standing still.", h=0.70, size=10.5, color=AMBER) +rail(s, [ + ("label", "opt1 · 3×3 · 100 k frames · f64"), + ("gap", 0.10), + ("stat", "Throughput", "15 807 FPS", PALE), + ("stat", "vs 24-thread CPU", "×2.34", ACCENT), + ("gap", 0.05), + ("row", "Per frame", "63.3 µs", TEXT2), + ("row", "Peak · the GPU floor", "61 859 FPS", ACCENT), + ("row", "% of peak", "26 %", AMBER), +]) + +# =========================================================== 12 · OPT2 +s = new_slide() +chrome(s, 13, "Act I · opt2 · streams and batching", + "Four streams and 2 000-frame batches: ×1.56") +bullets(s, M, 1.95, COL, [ + "A **stream** is an ordered queue of GPU work. Work in **different** streams may " + "overlap, so a copy can run while another stream computes.", + "Each stream gets its own **StreamContext**: device frame buffer, output buffer " + "and pedestal. Frames are handed out **round-robin**.", + "The host now submits **2 000 frames per call** instead of one.", +]) +code(s, M, 3.62, COL, [ + "struct StreamContext {", + " cudaStream_t stream;", + " FRAME_TYPE *d_frame; ClusterType *d_clusters;", + " PEDESTAL_TYPE *d_pd_mean, *d_pd_sum, *d_pd_sum2;", + "};", + "auto &sc = v_sc[frame_idx % «n_streams»]; // round-robin", +], title="ClusterFinderCUDA.hpp · per-stream state") +callout(s, M, 5.72, COL, + "**Scaffolding, not yet the payoff.** The streams exist, but the host still " + "synchronises after every round: see opt3.") +rail(s, [ + ("label", "opt2 · 4 streams · batch 2 000"), + ("gap", 0.10), + ("stat", "Throughput", "24 726 FPS", PALE), + ("stat", "vs 24-thread CPU", "×3.66", ACCENT), + ("gap", 0.05), + ("row", "Per frame", "40.4 µs", TEXT2), + ("row", "Step gain over opt1", "×1.56", ACCENT), + ("row", "% of peak · 61 859 FPS", "40 % (was 26)", AMBER), +]) + +# =========================================================== 13 · OPT3 +s = new_slide() +chrome(s, 14, "Act I · opt3 · remove the sync barriers", + "One sync per batch, not one per round: ×1.18") +bullets(s, M, 1.95, 7.4, [ + "opt2 synchronised **all streams after every round** of n_streams frames. " + "The GPU drained to empty each time.", + "opt3 submits every frame's H2D → kernel → D2H **asynchronously**, then " + "synchronises **once at the end of the batch**.", +], size=10.5) +figure(s, "fig_streams", M, 3.05, 6.90) +code(s, 8.35, 1.95, 4.25, [ + "// opt2: barrier after every round", + "for (round) {", + " submit(n_streams frames);", + " «cudaDeviceSynchronize»();", + "}", + "", + "// opt3: submit everything, sync once", + "for (frame : batch) {", + " cudaMemcpyAsync(..., sc.stream);", + " kernel<<<..., sc.stream>>>(...);", + " cudaMemcpyAsync(..., sc.stream);", + "}", + "for (sc : streams)", + " «cudaStreamSynchronize»(sc.stream);", +], size=8, title="THE ONE-LINE IDEA") +callout(s, 8.35, 5.05, 4.25, + "**29 188 FPS · ×4.32**\n34.3 µs/frame · 47 % of peak (was 40)", h=0.86, size=11) +caption(s, 8.35, 6.15, 4.25, + "Each lane is one stream. Removing the barrier lets a stream start its next " + "frame while its neighbours are still copying.") + +# =========================================================== 14 · OPT4 +s = new_slide() +chrome(s, 15, "Act I · opt4 · pinned (page-locked) memory", + "Pinning the input buys DMA-speed H2D: ×1.32") +bullets(s, M, 1.95, 12.0, [ + "Normal host memory is **pageable**: the OS may move or swap it. A DMA engine " + "cannot safely read that, so the driver first copies your data into a **hidden " + "pinned staging buffer**. Every transfer is copied twice.", + "**Pinning** locks the pages in physical RAM. The GPU's DMA engine then reads " + "host memory **directly**, no staging copy, and the transfer can be truly asynchronous.", +], size=10.5) +figure(s, "fig_pinning", M, 3.15, 7.6) +code(s, 8.5, 3.15, 4.1, [ + "// pin the whole dataset once", + "«cudaHostRegister»(ptr, bytes,", + " cudaHostRegisterDefault);", + "", + "// ... run the whole campaign ...", + "", + "«cudaHostUnregister»(ptr);", +], size=8, title="ClusterFinderCUDA.hpp") +callout(s, 8.5, 4.72, 4.1, + "**38 486 FPS · ×5.69**\n26.0 µs/frame · 62 % of peak, the largest step in Act I", + h=0.86, size=11) +caption(s, 8.5, 5.82, 4.1, + "Measured H2D: one 400×400 uint16 frame (312.5 KiB = 320 000 B) in 13.2 µs " + "= 24.2 GB/s, 77% of PCIe 4.0 ×16 theoretical, i.e. true DMA speed. " + "Pageable staging runs ~15 GB/s.") +callout(s, M, 6.45, 7.6, + "**The rule, first sighting: ×1.32 at 3×3 but only ×1.02 at 9×9.** Pinning " + "attacks H2D, the tallest bar at 3×3, the shortest at 9×9.", + h=0.72, size=10, color=AMBER) + +# -------------------------------------------------------- divider · ACT II +section("Act II of III · get the results back", + "The host copy is now the tallest bar", + "Frames go in at DMA speed. The results still come back slowly.", + [(16, "opt5 · host↔GPU overlap"), + (17, "opt6 · zero-copy"), + (18, "two rejected routes")], + rng=(16, 18), col=PALE, + carry=("Arriving at", "38 486 FPS", + "opt4 · 26.0 µs per frame · 62 % of the GPU floor")) + +# =========================================================== 15 · OPT5 +s = new_slide() +chrome(s, 16, "Act II · opt5 · host↔GPU overlap", + "Overlapping host and GPU hides min(host, GPU): ×1.31") +bullets(s, M, 1.92, COL, [ + "opt3 overlapped H2D ∥ kernel ∥ D2H **across streams, inside one batch**, but " + "never the **host** with the GPU: find_clusters_batched synchronised, then built " + "thousands of ClusterVectors with the GPU idle.", + "opt5 keeps **one batch in flight while materialising the previous one**: chunk " + "i+1 is submitted before chunk i is collected.", +]) +figure(s, "fig_overlap", M - 0.15, 3.28, COL + 0.30) +callout(s, M, 5.86, COL, + "The time hidden is **min(GPU, host)**, so this pays most when the two are " + "comparable, and cannot rescue a host term that is simply larger.") +notes(s, """opt5 — host<->GPU overlap. Code and chunk sizing are on annex A6. + + tok = cf.submit_batch(data[a0:b0], first_frame=a0) + for a, b in bounds[1:]: + nxt = cf.submit_batch(data[a:b], first_frame=a) # GPU starts N+1 ... + results.extend(cf.collect(tok)) # ... host unpacks N + tok = nxt + results.extend(cf.collect(tok)) # drain + +1. This is now INTERNAL to find_clusters_batched(), so every caller gets it for + free. submit_batch/collect stay public for anyone who wants the token by hand. + +2. The chunk size is rounded to a multiple of n_streams, because the device + pedestal is per-stream: an uneven chunk would advance the four stream + pedestals by different amounts and the finders would stop being comparable. + +Why the gain differs by cluster size: the saving is min(GPU, host) per chunk, so +it is largest when the two terms are comparable. At 3x3 they nearly are (GPU +16.2 us, host ~9.8) and opt5 is worth x1.31. At 9x9 the host term is roughly +twice the GPU term, so overlap hides only the smaller one and opt5 is worth +x1.20 - which is exactly the diagnosis that motivates opt6: you cannot overlap +your way out of a host term that is simply larger. Report SS8.2.""") +rail(s, [ + ("label", "opt5 · no CUDA work at all"), + ("gap", 0.10), + ("stat", "3×3 throughput", "50 410 FPS", PALE), + ("row", "per frame · step · peak", "19.8 µs · ×1.31 · 81 %", ACCENT), + ("gap", 0.14), + ("stat", "9×9 throughput", "15 063 FPS", PALE), + ("row", "per frame · step · peak", "66.4 µs · ×1.20 · 45 %", AMBER), + ("gap", 0.15), + ("note", "For clusters that must outlive the finder, this is the endpoint at " + "3×3: opt6 lends, it does not give."), +]) + +# =========================================================== 19 · OPT6 +s = new_slide() +chrome(s, 17, "Act II · opt6 · zero-copy collection", + "Read the results in place: ×2.21 at 9×9") +bullets(s, M, 1.92, COL, [ + "The D2H lands in a **pinned host buffer**. collect() then allocates one " + "ClusterVector per frame and memcpys into it; at 9×9 that is **467 kB per frame, " + "~9.3 GB per run**, single-threaded.", + "collect_view() returns a **BatchView**: strided numpy views straight onto the " + "pinned buffer. It withholds ownership past the chunk, **not access**, every " + "cluster's payload and coordinates are readable.", +], size=10.5) +figure(s, "fig_resultpath", M - 0.15, 3.05, COL + 0.30) +callout(s, M, 5.86, COL, + "The win is **max(0, host copy − GPU floor)**: at 3×3 the 8 µs copy hides under " + "a 16.2 µs floor and opt5 had already absorbed most of it; at 9×9 the 40 µs copy " + "is **larger than the floor** and cannot hide at any overlap.", h=0.80, size=10) +caption(s, M, 6.78, COL, + "The two bars are the competing costs, not the two steps; the step times are " + "on the right.", size=8.5) +rail(s, [ + ("label", "opt6 · collect_view() · zero-copy"), + ("gap", 0.10), + ("stat", "3×3 throughput", "58 495 FPS", PALE), + ("row", "per frame · step · peak", "17.1 µs · ×1.16 · 95 %", ACCENT), + ("gap", 0.14), + ("stat", "9×9 throughput", "33 323 FPS", PALE), + ("row", "per frame · step · peak", "30.0 µs · ×2.21 · 100 %", AMBER), + ("gap", 0.15), + ("note", "End to end, opt5 → opt6: 19.8 → 17.1 µs and 66.4 → 30.0 µs. " + "Bit-identical results. Spread over 5 reps drops to 0.2 % at both " + "sizes, with zero warm page faults."), +]) + +# =========================================================== 20 · ACT II rejected +s = new_slide() +chrome(s, 18, "Act II · two rejected routes", + "The copy is allocation-bound, not bandwidth-bound") +rows = [ + ("B′", "One allocation per chunk", "collect_packed()", + "Removes the per-frame malloc but keeps the copy, and the copy is ~80% of the " + "cost. Worse, the replacement allocation is 1.17 GB, far above glibc's mmap " + "threshold, so it is mmap'd and munmap'd every chunk: 606 566 faults, ~21 µs/frame.", + "69.3 µs · deleted from the API"), + ("B″", "Parallel materialisation", "8-thread copy pool", + "Each worker gets its own glibc arena, which destroys the cross-run heap reuse " + "that makes the single-threaded path cheap. Faults went 9 700 → 2 270 000; " + "MALLOC_ARENA_MAX=1 collapsed them back to 138 k, which is the proof.", + "+6% at best, −33% when results are freed promptly"), +] +y = 2.00 +for tag, name, how, body, verdict in rows: + rect(s, M, y, 11.9, 2.05, PANEL) + rect(s, M, y, 0.035, 2.05, AMBER) + tf = tb(s, M + 0.30, y + 0.22, 1.0, 0.4) + run(para(tf, True), tag, 20, AMBER, bold=True, font=MONO) + tf = tb(s, M + 1.30, y + 0.20, 6.0, 0.3) + run(para(tf, True), name, 14, PALE, bold=True) + tf = tb(s, M + 1.30, y + 0.52, 6.0, 0.3) + run(para(tf, True), how, 9.5, MUTED, font=MONO) + tf = tb(s, M + 1.30, y + 0.90, 10.2, 1.0) + run(para(tf, True, line=1.3), body, 10, TEXT2) + tf = tb(s, M + 1.30, y + 1.68, 10.2, 0.3) + run(para(tf, True), verdict, 10.5, AMBER, bold=True) + y += 2.25 +callout(s, M, 6.38, 11.9, + "**Copying faster does not help when the cost is the OS populating pages.** " + "The only winning move is not to allocate — which is exactly what opt6 does. " + "materialize_slot() is deliberately single-threaded and carries a comment " + "saying so, to stop the experiment being repeated.", h=0.80, size=10.5) + +# ------------------------------------------------------- divider · ACT III +section("Act III of III · the kernel", + "Only now is the kernel the tallest bar", + "The first change to the arithmetic, and the last step in the ladder.", + [(19, "opt7 · FP32 pedestal"), + (20, "the correctness trap"), + (21, "the variance rewrite"), + (22, "why it comes last")], + rng=(19, 22), col=AMBER, + carry=("Arriving at", "58 495 FPS", + "opt6 · 3×3, and 33 323 FPS at 9×9, where this act pays")) + +# =========================================================== 18 · OPT7 why +s = new_slide() +chrome(s, 19, "Act III · opt7 · FP32 device pedestal", + "FP32 halves pedestal traffic: −41 % kernel time") +bullets(s, M, 1.95, 7.5, [ + "~99.9% of pixels take the **pedestal-update** branch, which reads and writes " + "mean, sum and sum². In FP64 that is **32 bytes per pixel**; in FP32, 16.", + "The kernel is **bandwidth-bound**, so halving that traffic nearly halves the time.", + "Second effect: on a GeForce part, **FP64 arithmetic runs at 1/64 of FP32**. " + "The pedestal update was paying that tax on every pixel.", + "Third, quietly: the narrower accumulators free **9 registers at 3×3**, 47 → 38, " + "which buys back a block per SM and takes occupancy **83 % → 100 %**.", +], size=10.5) +figure(s, "fig_f32_kernel", M, 4.05, 7.5) +code(s, 8.5, 1.95, 4.1, [ + "// clusterfinder_kernel.cuh", + "using COMPUTE_TYPE = float;", + "using DEVICE_PED_TYPE = «float»;", + "// was: double", +], size=8.5, title="ONE TYPEDEF") +callout(s, 8.5, 3.05, 4.1, + "Kernel, 9×9, measured with Nsight Systems\n**39.9 µs → 23.7 µs (−40.7%)**", + h=0.92, size=11) +caption(s, 8.5, 4.20, 4.1, + "Exclusive per-kernel time, 20 000 frames, 1 stream, both builds at the same " + "git rev. Transfers are unchanged, as they must be.") +callout(s, 8.5, 5.30, 4.1, + "Naive FP32 is **wrong** (next two slides), and even when correct it only pays " + "**because Act II came first**.", h=1.05, size=10, color=AMBER) + +# =========================================================== 19 · OPT6 trap +s = new_slide() +chrome(s, 20, "Act III · opt7 · the correctness trap", + "The naive FP32 pedestal loses the variance to cancellation") +bullets(s, M, 1.95, 7.9, [ + "The running variance was computed as **var = E[X²] − mean²**. With a pedestal " + "mean of ~4 655 ADU, both terms are ≈ 2.17 × 10⁷ while the answer is ≈ 2 000.", + "In FP32 the spacing between representable numbers at 2.17 × 10⁷ is **2 048**, " + "larger than the variance itself. This is **catastrophic cancellation**.", +], size=10.5) +figure(s, "fig_cancellation", M, 3.25, 7.5) +rail(s, [ + ("label", "What it looked like"), + ("gap", 0.15), + ("stat", "Extra clusters", "+28.06%", AMBER), + ("gap", 0.05), + ("note", "Quiet pixels got rms → 0, so their threshold became 0 and they fired " + "on every single frame, producing a large unphysical high-energy tail " + "in the spectrum."), + ("gap", 0.35), + ("row", "Affected pixels", "~1–2% of the sensor", TEXT2), + ("row", "Written up in", "docs/pedestal_precision_…", MUTED), +]) + +# =========================================================== 20 · OPT6 fix +s = new_slide() +chrome(s, 21, "Act III · opt7 · the variance rewrite", + "Accumulate what is small, not what is large") +bullets(s, M, 1.95, COL, [ + "Freeze a per-pixel baseline **X₀ = round(mean)** once, at the end of pedestal " + "training, and never move it again.", + "Accumulate the **centred** value Y = X − X₀ instead of X. Now both sums are " + "O(rms)-sized: the huge common term is gone before the subtraction.", + "The reported pedestal mean is still the full value, **X₀ + sum/n**, so nothing " + "downstream changes.", +]) +code(s, M, 4.0, COL, [ + "// before: both terms ~2.17e7, answer ~2000", + "var = sum2/n - mean*mean;", + "", + "// after: centred on a frozen per-pixel offset X0", + "DEVICE_PED_TYPE resid = mean - «d_pd_off»[i]; // ~O(1)", + "DEVICE_PED_TYPE var_px = sum2[i]/n - resid*resid; // no cancellation", +], title="clusterfinder_kernel.cuh") +callout(s, M, 6.05, COL, + "Result: the 100% FP32 build now matches the FP64 build to " + "**3 × 10⁻⁷**, 70 clusters out of 233 million.") +rail(s, [ + ("label", "Why it works"), + ("gap", 0.15), + ("note", "Precision is relative. Floats resolve small numbers finely and large " + "numbers coarsely, so never let a small answer be the difference of " + "two large numbers."), + ("gap", 0.5), + ("row", "f32 vs f64 counts", "3 × 10⁻⁷", ACCENT), + ("row", "vs CPU", "0.0039%", ACCENT), + ("gap", 0.3), + ("note", "X₀ must never be updated: the accumulators are defined relative to it."), +]) + +# =========================================================== 21 · OPT6 when +s = new_slide() +chrome(s, 22, "Act III · why this act comes last", + "Before Act II the kernel win is not even measurable") +figure(s, "fig_bottleneck", M, 1.95, 11.9) +callout(s, M, 5.58, 11.9, + "**Through collect_view() the identical −40% kernel is worth −16.2%, resolvable " + "to 0.0 points. Through every allocating path the interval spans 19–41 points " + "and straddles zero.** The result path does not just shrink the win; it " + "destroys the ability to see it.", h=0.86, size=11) +caption(s, M, 6.56, 11.9, + "Placed anywhere before Act II this step measures as noise and would reasonably " + "have been abandoned; placed after, it is the second-largest step in the ladder " + "at 9×9. The ordering is not presentational; it decides whether the " + "optimization is visible at all. 9×9, 20 000 frames, 4 streams, cap 1 700, " + "5 reps per arm, both arms at the same git rev. The band is what those reps " + "allow: allocating paths oscillate between allocator states and swamp the effect.") + +# ------------------------------------------------------- divider · results +section("Results · what came out of it", + "The whole ladder, and how to use it", + "Both cluster sizes end to end, and the audit behind the numbers.", + [("23–24", "Results, both cluster sizes"), + ("25", "Where the time went"), + ("26–27", "What the numbers survived")], + rng=(23, 27), col=PALE, + carry=("Arriving at", "58 495 FPS", + "opt6 · everything after this is the ladder seen whole")) + +# =========================================================== 22 · RESULTS +s = new_slide() +chrome(s, 23, "Results · 3×3", "×9.1 at 3×3, sitting on the H2D floor") +figure(s, "fig_arc", 1.95, 1.88, 9.4) +callout(s, M, 5.80, 5.85, + "**×9.1 over 24 CPU threads**, 14.8 s → 1.63 s for 100 000 frames, " + "and **at the H2D floor**.", h=0.8) +callout(s, 6.75, 5.80, 5.85, + "Every step is **monotonic**, and correctness is held constant **throughout**, " + "0.004 % against the CPU baseline, and **exact** against the CPU twin " + "that isolates the port (slides 28–31).", h=0.8, color=AMBER) +caption(s, M, 6.62, 11.9, + "3×3 clusters · nσ = 5 · 100 000 frames · batch 2 000 · 4 streams · 5 reps · " + "warm = best of reps 1–4 (collect() does not converge, it oscillates between " + "allocator states) · each step in its own process · CPU baseline = " + "ClusterFinderMT at its best thread count, 24 here, first pass only.") + +# =========================================================== 23 · RESULTS 9x9 +s = new_slide() +chrome(s, 24, "Results · 9×9", "×26.5 at 9×9, and opt7 hands the floor to D2H") +figure(s, "fig_arc_9x9", 1.95, 1.88, 9.4) +callout(s, M, 5.80, 5.85, + "**×26.5 over 32 CPU threads**; the kernel is the tallest bar for the whole " + "f64 arm, and opt7's −40 % drops it **below D2H**.", h=0.8) +callout(s, 6.75, 5.80, 5.85, + "opt4 buys **×1.03** here and **×1.32** at 3×3. Same code, opposite regimes, " + "the rule, twice.", h=0.8, color=AMBER) +caption(s, M, 6.62, 11.9, + "9×9 · cap 1 700 (lossless; 1 500 truncated 0.0095 % of clusters) · 20 000 frames · " + "opt1/opt2 are 3×3-only · CPU baseline ClusterFinderMT at 32 threads. Peak is the " + "lower of the nsys estimate and the best sustained rate; both arms are sustained-bound, " + "corroborated to 8.8 % (f64) and 0.4 % (f32). Why the arms differ: 544.5 kB of D2H " + "costs 25.2 µs on both, hidden under the 32.7 µs f64 kernel, not under the 23.9 µs f32 one.") + +# =========================================================== 24 · WHERE TIME GOES +s = new_slide() +chrome(s, 25, "Where the time actually went", + "The host bar dies first, then the floor itself drops") +figure(s, "fig_overhead", 1.95, 1.95, 9.4) +callout(s, M, 5.30, 5.85, + "**Acts I and II never touch the arithmetic.** The GPU floor is a flat " + "16.2 µs / 30.0 µs; what collapses is everything stacked on top of it.", + h=0.86, size=10.5) +callout(s, 6.75, 5.30, 5.85, + "**Act III is the only step that lowers the floor itself**, and it could not " + "have been seen until the stack above it was gone.", h=0.86, size=10.5, + color=AMBER) +caption(s, M, 6.45, 11.9, + "Blue/white/amber = the GPU floor for that act's build (max of H2D, kernel, " + "D2H; PCIe is full duplex, so it is never the sum). Grey = everything the " + "host adds on top. At 9×9 the host contributes +50 µs at opt3 and nothing at " + "opt6.") + +# =========================================================== 24 · MEASUREMENT AUDIT +s = new_slide() +chrome(s, 26, "Behind the numbers · what they had to survive", + "Three ways a GPU benchmark lies") +items = [ + ("First-touch page faults", AMBER, + "Each run materialises ~10 GB of clusters. The first pass faults in ~2.6 M " + "pages at 0.7 µs each, up to 4 s of pure OS work inside the timer.", + "Fix: re-run until getrusage() minor faults plateau (< 200 k)."), + ("CUDA-event kernel timing", AMBER, + "avg_kernel_time_ms() measures elapsed time on a stream, including waiting " + "for other streams. Under 8-stream load it over-reads by up to 3.5×.", + "Fix: Nsight Systems per-instance times; 1 stream for exclusive numbers."), + ("The profiler itself", AMBER, + "Under nsys, wall time per frame inflates ~4× from API tracing.", + "Fix: GPU op times from nsys, wall times from unprofiled runs."), +] +x = M +for title, col, body, fix in items: + rect(s, x, 2.0, 3.83, 3.15, PANEL) + rect(s, x, 2.0, 3.83, 0.035, col) + tf = tb(s, x + 0.26, 2.28, 3.3, 0.6) + run(para(tf, True, line=1.15), title, 13, PALE, bold=True) + tf = tb(s, x + 0.26, 3.02, 3.3, 1.5) + run(para(tf, True, line=1.3), body, 10, TEXT2) + tf = tb(s, x + 0.26, 4.42, 3.3, 0.65) + run(para(tf, True, line=1.25), fix, 9.5, ACCENT) + x += 4.03 +code(s, M, 5.4, 11.9, [ + "# every timed cell in the benchmark notebook is bracketed with:", + "mf0 = resource.getrusage(resource.RUSAGE_SELF).ru_minflt", + "... t = time.perf_counter() - t0 ...", + "print(f'minor faults: {mf1-mf0:,}') # quote the run where this plateaus", +], title="THE FAULT PROTOCOL · python/tests/ClusterFinderCUDA_perf.ipynb") +callout(s, M, 6.52, 11.2, + "Validated: **wall = steady-state + faults × 0.68 µs** reproduced a 6.110 s " + "run to within **1 ms**. Kernel time stayed constant throughout; the GPU was never the variable.", + h=0.70, size=9.5) + +# =========================================================== 25 · FIRST RUN +s = new_slide() +chrome(s, 27, "Behind the numbers · what a user actually gets", + "A first run loses a third of its throughput to page faults") +figure(s, "fig_first_run", 1.37, 1.70, 10.6) +callout(s, M, 5.98, 5.85, + "**Everything that materialises clusters loses a third of its throughput " + "on the first run**, +7 to +20 µs per frame, depending on how much of it " + "the GPU can hide behind its own work.", h=0.68, size=9.5) +callout(s, 6.75, 5.98, 5.85, + "**Only the two ends escape, for opposite reasons.** opt1 never grows the " + "heap: it discards each frame. opt6 never needs one, and reaches **98 % " + "of its peak on a cold process**.", h=0.68, size=9.5, color=AMBER) +caption(s, M, 6.76, 11.9, + "Single pass, one process, every ClusterVector retained, " + "python/tests/ClusterFinderCUDA_perf.ipynb, f32, the same 100 000 frames. Not the " + "campaign's \"cold\" rep, which discards results and so never grows the heap. Repeat " + "the run and the amber bars climb onto the blue ones; opt1 and opt6 never move, " + "because neither ever paid.", size=8.5) + +# --------------------------------------------------- divider · VALIDATION +section("Validation · does it find the same photons", + "Every number so far assumed the answers are identical", + "Whether the CUDA finder returns the same clusters as the CPU.", + [("28–29", "The fair comparison"), + ("30–31", "The residual, dissected"), + ("32–33", "For users"), + ("34", "What is next")], + rng=(28, 34), col=PALE, + carry=("Established", "×9.1 and ×26.5", + "on the hardware floor at both cluster sizes, if the physics holds")) + +# ============================================ 26 · PEDESTAL UPDATE TIMING +s = new_slide() +chrome(s, 28, "Validation · why a CPU twin was needed", + "CPU and CUDA update the pedestal at different moments") +figure(s, "fig_pedtiming", M - 0.15, 1.90, 12.2) +callout(s, M, 5.30, 5.85, + "The serial CPU finder updates the pedestal **as the raster scan reaches each " + "pixel**. A CUDA thread cannot: 160 000 of them read the pedestal at once, so " + "the update is **applied at the frame boundary**.", h=1.02, size=10.5) +callout(s, 6.75, 5.30, 5.85, + "So a straight CPU↔CUDA comparison moves **two** things at once. " + "**ClusterFinderFrozen** is the serial finder with only the update moved to " + "the frame end: same arithmetic, same gates, same scan.", h=1.02, size=10.5, + color=AMBER) +caption(s, M, 6.52, 11.9, + "Frozen is a diagnostic twin, not a product: it exists so the next two slides " + "can attribute each disagreement to exactly one cause. cpu vs frozen = update " + "timing; frozen vs cuda = the port. Every finder on these slides is trained on " + "the same 1 000 pedestal frames and run over the same 10 000 data frames.") +notes(s, """Why this slide is here. + +The obvious experiment - run the CPU finder and the CUDA finder over the same +frames and count the differences - cannot answer the question anyone actually +asks, which is "is the port correct?" It moves two variables at once: + + 1. WHEN the pedestal is updated. The serial CPU finder updates per pixel, + during the raster scan, so a pixel late in the frame is judged against a + pedestal that already contains this frame's earlier pixels. That is + scan-order dependent by construction. + 2. WHAT arithmetic runs. float vs double, a different local-max expression, + a different rounding point. + +ClusterFinderFrozen holds (2) fixed and changes only (1): it is the serial +finder, same decision logic line for line, with the update deferred to the frame +boundary. That is the CUDA update model, on the CPU. + +So the three-way comparison factorises: + cpu vs frozen -> update timing alone (19 of 23.2 M) + frozen vs cuda -> the port alone (6 of 23.2 M) + +and the second is the number that answers the question. It is also why the +headline mismatch figure in this deck is 6 / 23 M and not 25 / 23 M: the larger +number is dominated by an effect that has nothing to do with CUDA. + +Frozen ships in the library as a diagnostic, not as the recommended finder.""") + +# =========================================================== 26 · CORRECTNESS +s = new_slide() +chrome(s, 29, "Validation · isolating one variable at a time", + "CUDA and its CPU twin agree exactly: 0 in 23 million") +bullets(s, M, 1.86, 12.0, [ + "**ClusterFinderFrozen** makes byte-for-byte the same decisions as ClusterFinder " + "and differs in exactly one thing: **when** the pedestal is updated. Frozen per " + "frame, pushed at frame end. That is the CUDA model, so comparing against it " + "isolates everything else the port changes.", +], size=10.5) +table(s, M, 2.52, 12.0, + ["comparison", "the one thing that differs", "A-only / B-only", + "% of clusters"], + [["serial CPU vs frozen CPU", "update timing alone: **a CPU-only effect**", + "8 / 11", "0.000082 %"], + ["serial CPU vs CUDA [f64 ped]", "the same thing, **and nothing else**", + "8 / 11", "0.000082 %"], + ["frozen CPU vs CUDA [f64 ped]", "**nothing**", "**0 / 0**", "**0 %**"], + ["frozen CPU vs CUDA [f32 ped]", "the float32 pedestal EMA drifting: " + "see next slide", "0 / 6", "0.000026 %"]], + colw=[0.28, 0.40, 0.17, 0.15], size=9.5, rowh=0.60) +callout(s, M, 5.76, 5.85, + "**Identical, frame by frame.** 23 244 605 clusters, not one disagreement, " + "and **cpu vs cuda** equals **cpu vs frozen** exactly, so the port adds nothing " + "of its own. Payloads: **99.9941 % bit-identical**, worst case 1 ADU on one " + "pixel.", h=0.74, size=9.5) +callout(s, 6.75, 5.76, 5.85, + "**The 0.004 % headline is the baseline disagreeing with itself.** " + "ClusterFinderMT builds **48 ClusterFinders, each with its own Pedestal**, " + "after 100 k frames each has ~2 083 updates, not 100 000. It measures the " + "baseline, not the port.", h=0.74, size=9.5, color=AMBER) +caption(s, M, 6.58, 11.9, + "3×3 · 10 000 frames · 23.2 M clusters · same pedestal, same frames · exact " + "centre-set difference at tol = 0 · python/tests/validation_tiers.py. [f64 ped] " + "and [f32 ped] are the same source built with DEVICE_PED_TYPE double / float; " + "COMPUTE_TYPE is float in both, so the stencil arithmetic is identical across " + "the two rows.") + +# ================================================ 28 · THE MISMATCH, SEEN +s = new_slide() +chrome(s, 30, "Validation · the disagreement, seen", + "The whole disagreement is one duplicate centre") +figure(s, "fig_mismatch147", 1.97, 1.78, 9.4) +callout(s, M, 5.82, 5.85, + "Only the **3×3 footprints of each finder's own centres** are drawn, " + "everything else is blank, so the panels differ **exactly** where the finders " + "do. Values are pedestal-subtracted ADU.", h=0.94, size=10.5) +callout(s, 6.75, 5.82, 5.85, + "cuda's patch is **one row taller**: a second centre directly below the one " + "both found, so the two 3×3 windows overlap. **The charge is already " + "counted**: a duplicate, not a new photon.", h=0.94, size=10.5, color=AMBER) +caption(s, M, 6.92, 11.9, + "Frame 147, the strongest of the six residuals · shipping f32 build · every " + "other centre in the patch agrees, including the ordinary photon at bottom right.") +notes(s, """The masked view, and how to read it. + +Each panel masks every pixel that is not inside the 3x3 footprint of one of THAT +finder's own cluster centres. So a pixel is visible on the left only if frozen +claimed it, and visible on the right only if cuda did. Where the panels look +identical, the finders agreed pixel for pixel. + +The red dots are cluster centres. The amber ring is the one centre cuda keeps +that frozen does not: (202, 8), directly below (202, 7) which both finders keep. +Because the two 3x3 windows overlap, cuda's patch is four rows tall where +frozen's is three - that visible extra row IS the disagreement. + +Note what is NOT happening: there is no cluster in one panel that is absent from +the other's neighbourhood entirely. Nothing was missed, and nothing was invented +out of noise. The two finders are arguing about which of two adjacent pixels owns +a photon they both found. + +Same construction as helper.plot_masked_mismatch() in the notebook, so this is +reproducible from ClusterFinderFrozen_vs_CUDA.ipynb directly.""") + +# ========================================================== 27 · RESIDUALS +s = new_slide() +chrome(s, 31, "Validation · the six residuals, dissected", + "float32 cannot tell these two pixels apart") +code(s, M, 1.92, 6.35, [ + "frame 147 centre (x=202, y=8) 3×3 window", + "", + "raw window (ADU) pedestal-subtracted, 1 decimal", + "[[4646 5282 4703] [[ 45.3 «638.4» -12.1] frozen and cuda", + " [4857 5318 4950] [ 43.7 «638.4» -7.5] print the SAME", + " [4763 4640 4858]] [ 1.1 70.7 136.4]] window", + "", + "the two contenders, at full precision:", + " rival (dy=-1) centre", + "frozen [f64 ped] 638.383019956 638.382773664", + "cuda [f32 ped] 638.382812500 «638.382812500»", + "", + "gate: accept if centre >= max(window)", + " frozen 638.382773664 >= 638.383019956 -> reject", + " cuda 638.382812500 >= 638.382812500 -> «ACCEPT»", +], size=8, title="THE ONLY TEST THAT FLIPS, AND WHY") +callout(s, M, 4.62, 6.35, + "Separation under the f64 pedestal: **0.000246 ADU**. One float32 ULP at " + "4 679.6 ADU is **0.000488**. The two pixels are **half a ULP apart**; in " + "float32 they are *the same number*, and the gate accepts on a tie.", + h=1.04, size=10) +figure(s, "fig_spectra_valid", 7.30, 1.92, 5.40) +callout(s, M, 5.82, 11.9, + "**With the f64 pedestal there are none at all.** In the shipping f32 build " + "each of the six sits **one pixel from a cluster both finders found**, a " + "duplicate neighbour, never a spurious photon and never a missed one.", + h=0.76, size=10.5, color=AMBER) +caption(s, M, 6.74, 11.9, + "The same frame 147 as the previous slide, recomputed under each finder's " + "decision-time pedestal. Both finders run the same gate (CPU value == max, CUDA " + "!(val < max)), so the tie separates them, not the expression.") +notes(s, """Frame 147, centre (x=202, y=8). The full numbers. + + centre rival centre - rival + frozen (f64 host ped) 638.382773664 638.383019956 -0.000246291 + cuda (f32 dev ped) 638.382812500 638.382812500 0.000000000 + + pedestal mean, centre: f64 4679.617226336 f32 4679.617187500 + pedestal mean, rival : f64 4643.616980044 f32 4643.617187500 + + 1 float32 ULP at 4679.6 ADU = 0.000488 ADU + the f64 separation between the two pixels = 0.50 ULP + +Read that last line slowly: the two pixels differ by HALF a float32 ULP. There is +no float32 number between them, so once the pedestal is stored as float32 they +round to the identical bit pattern. The local-max gate is `value >= max`, which +accepts on equality - so CUDA keeps the centre and frozen, which can still see +the rival is 2.5e-4 ADU higher, rejects it. + +This is not drift in the usual sense. It is not that the f32 EMA wandered away +from the f64 one over thousands of updates - look at the pedestal columns, they +agree to 4e-5 and 2e-4 ADU. It is that the QUESTION being asked ("which of these +two pixels is larger?") has an answer that float32 cannot represent. + +Consequences worth stating out loud if asked: + - It is one-directional. cuda-only 6, frozen-only 0. A tie can only ever ADD a + centre, never remove one, because >= accepts. + - All six are at Chebyshev distance 1 from a cluster both finders found: a + duplicate neighbour, never a spurious photon. + - With DEVICE_PED_TYPE = double the residual is 0 / 0 at both 3x3 and 9x9. + - Fixing it in f32 would mean a strict > in the gate, which changes the CPU's + documented behaviour on genuine ties. Not worth 6 clusters in 23 million.""") + +# =========================================================== 28 · API 1 +s = new_slide() +chrome(s, 32, "For users · Python API", "The fast path in eight lines") +code(s, M, 1.95, 7.6, [ + "from aare import File, ClusterFinderCUDA", + "", + "cf = ClusterFinderCUDA(image_size=(400, 400), cluster_size=(3, 3),", + " n_sigma=5, «n_streams»=4,", + " «max_clusters_per_frame»=3000)", + "", + "for _ in range(1000): # 1. train the pedestal", + " cf.push_pedestal_frame(pd.read_frame())", + "", + "data = f.read_n(100_000) # 2. one contiguous array", + "cf.«register_input_buffer»(data) # 3. pin it once", + "", + "for s in range(0, N, 2000): # 4. batch through it", + " clusters = cf.«find_clusters_batched»(data[s:s+2000], first_frame=s)", + "", + "cf.unregister_input_buffer() # 5. release the pages", +], size=9, title="THE RECOMMENDED PATTERN") +bullets(s, 8.6, 2.0, 4.1, [ + "find_clusters_batched returns **one ClusterVector per frame**, in order, and " + "does opt5's chunked overlap internally, so you get it for free.", + "register_input_buffer is what turns opt3 into opt4: **one call**.", +], size=10) +code(s, 8.6, 3.55, 4.1, [ + "# opt6: never copy", + "for v in cf.«find_cluster_views_", + " batched_iter»(data, 2000):", + " hist.fill(v.sums())", + " # views die with the chunk", +], size=8, title="IF YOU REDUCE AS YOU GO") +callout(s, 8.6, 5.25, 4.1, + "**×1.16 at 3×3, ×2.21 at 9×9.** The views expose every cluster; they only " + "withhold ownership past the chunk.", h=0.95, size=10) +callout(s, 8.6, 6.35, 4.1, + "Pin **once**, outside the loop. Slices of a registered array inherit the " + "pinning.", h=0.72, size=10, color=AMBER) + +# =========================================================== 27 · API 2 +s = new_slide() +chrome(s, 33, "For users · choosing the knobs", "Four knobs, and the one that silently truncates") +hdr = [("Parameter", 1.05), ("What it does", 3.6), ("Guidance", 5.2)] +y = 2.0 +rect(s, M, y, 11.9, 0.4, PANEL) +for lab, dx in hdr: + tf = tb(s, M + dx - 0.85 if dx > 1.05 else M + 0.28, y + 0.09, 5.0, 0.3) + run(para(tf, True), lab.upper(), 9, MUTED, bold=True, spc=1.3) +y += 0.44 +params = [ + ("n_streams", "How many frames are in flight at once.", + "4 at both sizes. 8 buys no kernel concurrency at 9×9 (+1% instance time) and " + "inflates the CUDA-event timer 3.5×."), + ("max_clusters_per_frame", "Fixed size of the per-frame D2H transfer.", + "Must exceed the real maximum or clusters are silently dropped, and it sets the " + "D2H bar directly. Measured at 9×9: the maximum is 1 633, and the lossless " + "cap of 1 700 already makes D2H the bottleneck on the f32 build."), + ("batch size", "Frames per find_clusters_batched call.", + "2 000 amortises launch overhead without a large pinned footprint."), + ("cluster_size", "Compile-time stencil geometry.", + "3×3 and 9×9 are registered; 9×9 moves the bottleneck off H2D and onto the " + "kernel on f64, and onto D2H once opt7 shortens it."), + ("register_input_buffer", "Page-locks the host array for DMA.", + "Always, if the data is already in RAM. Check the pinning budget first."), +] +for i, (p_, what, guide) in enumerate(params): + if i % 2 == 0: + rect(s, M, y, 11.9, 0.82, PANEL) + tf = tb(s, M + 0.28, y + 0.14, 2.6, 0.5) + run(para(tf, True, line=1.1), p_, 9.5, ACCENT, font=MONO, bold=True) + tf = tb(s, M + 3.0, y + 0.14, 2.9, 0.6) + run(para(tf, True, line=1.2), what, 9.5, PALE) + tf = tb(s, M + 6.15, y + 0.14, 5.4, 0.6) + run(para(tf, True, line=1.2), guide, 9.5, TEXT2) + y += 0.80 +callout(s, M, 6.55, 11.2, + "The single most common mistake: leaving **max_clusters_per_frame** too low. " + "It does not error; it truncates, and every frame quietly returns the same count.", + h=0.66, size=10, color=AMBER) + +# =========================================================== 28 · NEXT +s = new_slide() +chrome(s, 34, "Where this leaves us", + "The bottleneck has walked from the host, to the GPU, to the wire") +cards = [ + ("DONE", ACCENT, "×9.1 at 3×3, ×26.5 at 9×9", + "16.3 and 25.1 µs/frame end to end, both on their hardware floor. Against the CPU " + "twin that isolates the port the decisions are identical; the shipping f32 " + "pedestal adds 6 duplicates in 23 million."), + ("DONE", ACCENT, "FP32 pedestal, safely", + "−40.7% kernel, and correct, because the variance is now accumulated on a frozen " + "per-pixel offset instead of a raw second moment."), + ("NEXT", AMBER, "3×3: transfer granularity", + "The 16.31 µs achieved sits 3.16 µs above the uncontended 13.15 µs H2D rate: " + "2 000 separate 320 kB descriptors, plus 26% of H2D↔D2H contention."), + ("NEXT", AMBER, "9×9: the result path, not the kernel", + "At a lossless cap D2H already binds: 25.24 µs against a 23.94 µs kernel. More " + "kernel work buys nothing until the D2H slot stops being cap-sized."), +] +for i, (tag, col, title, body) in enumerate(cards): + cx = M + (i % 2) * 6.05 + cy = 2.05 + (i // 2) * 2.35 + rect(s, cx, cy, 5.85, 2.05, PANEL) + rect(s, cx, cy, 5.85, 0.035, col) + tf = tb(s, cx + 0.3, cy + 0.26, 1.4, 0.26) + run(para(tf, True), tag, 8.5, col, bold=True, spc=1.5) + tf = tb(s, cx + 0.3, cy + 0.60, 5.2, 0.4) + run(para(tf, True, line=1.1), title, 14, PALE, bold=True) + tf = tb(s, cx + 0.3, cy + 1.12, 5.2, 0.85) + run(para(tf, True, line=1.3), body, 10, TEXT2) +callout(s, M, 6.58, 11.2, + "Full numbers, methodology and reproduction steps: **docs/ClusterFinderCUDA_benchmark_results.md** · " + "notebook **python/tests/ClusterFinderCUDA_perf.ipynb** · measurement campaign **python/tests/perf/**", + h=0.66, size=9.5) + +# --------------------------------------------------------- divider · ANNEX +section("Annex · the evidence behind the claims", + "Kept back so the arc stays readable", + "The measurement artefacts, the rejected route, and the opt5 code.", + [("A1–A3", "The three benchmark artefacts"), + ("A4–A5", "CUDA Graphs, and why they lost"), + ("A6", "opt5 · the overlap code")], + rng=(1, N_ANNEX), col=AMBER, annex=True, + carry=("Everything so far", "34 slides", + "the arc is finished; what follows answers questions")) + +# =========================================================================== +# ANNEX — the measurement detail behind slides 26–27, and the rejected route A +# =========================================================================== + +# ------------------------------------------------------------ A1 · FAULTS +s = new_slide() +annex_chrome(s, 1, "expands slide 26 · artifact 1", + "First-touch page faults: two sources, one counter") +bullets(s, M, 1.90, 12.0, [ + "A page exists in the process's address space but has no physical frame yet. " + "On first touch the kernel finds one, **zeroes it** (mandatory), and maps it. " + "No disk I/O: ru_majflt stays 0 all campaign. At 4 kB/page, **1 GB = 262 144 faults**.", +], size=10.5) +table(s, M, 2.62, 12.0, + ["", "(a) result heap", "(b) pinned D2H slots"], + [["allocator", "malloc → mmap, one ClusterVector per frame", + "cudaMallocHost, in submit_batch"], + ["cost / page", "**0.7 µs**", "**1.0 µs**: same fault + pin + DMA map"], + ["recurs?", "**yes**, every alloc/free cycle above the mmap threshold", + "**no**: once per buffer, for its lifetime"], + ["removed by", "**collect_view()**: it allocates nothing", + "nothing; reserve_output_slots() only moves it out of the timer"]], + colw=[0.14, 0.43, 0.43], size=9) +callout(s, M, 5.62, 5.85, + "**The two are exactly additive.** Reserving subtracts precisely the pre-pin " + "count from run 0 and changes nothing else.", h=0.72, size=10) +code(s, 6.75, 5.55, 5.85, [ + "3x3: 572 292 - 455 129 = 117 163 vs 117 192 pre-pin", + "9x9: 2 759 037 - 2 278 567 = 480 470 vs 480 474", + "closed form: 2 slots x 2000 x 120 004 B / 4 kB = 117 191", +], size=7.5, title="NOT A CORRELATION, AN IDENTITY") +callout(s, M, 6.42, 11.9, + "**At 9×9 the heap never plateaus.** ~9.3 GB per pass is above glibc's mmap " + "threshold, so it is munmap'd and re-faulted every pass: ~292 k faults ≈ " + "**10 µs/frame, permanently**. No number of re-runs removes it, which is an " + "independent argument for opt6.", h=0.72, size=10, color=AMBER) + +# ------------------------------------------------------------ A2 · EVENTS +s = new_slide() +annex_chrome(s, 2, "expands slide 26 · artifact 2", + "CUDA events measure the stream, not the kernel") +bullets(s, M, 1.90, COL, [ + "avg_kernel_time_ms() brackets the launch with **cudaEventRecord on the " + "kernel's own stream**. What it returns is elapsed time on **that stream's " + "timeline**, which includes time spent **queued behind other streams**.", + "So it is honest at 1 stream and inflates under saturation: up to **3.5×** at " + "8 streams. The tell is that the derived *PCIe + overhead* = wall/N − kernel_ms " + "**goes negative**: kernels overlap, so wall/frame < kernel/frame.", + "It is also not free: **~3.6 µs/frame**, i.e. 10–15 % of end-to-end throughput " + "at 3×3, to produce a number that is unusable exactly when it matters.", +], size=10.5) +code(s, M, 4.75, COL, [ + "if (m_time_kernels) // OFF by default, all 3 finders", + " cudaEventRecord(start[slot][i], sc.stream);", + "find_clusters_in_single_frame<<>>(...);", + "if (m_time_kernels)", + " cudaEventRecord(stop[slot][i], sc.stream); // <- queue-wait lands here", +], size=8, title="ClusterFinderCUDA.hpp") +callout(s, M, 6.10, COL, + "The flag exists for **comparability**, not preference: with events on for one " + "finder and off for another, the step between them absorbs the tax.", + h=0.72, size=10) +rail(s, [ + ("label", "9×9 kernel · f64 · nsys"), + ("gap", 0.10), + ("stat", "1 stream: duration", "39.86 µs", ACCENT), + ("stat", "4 streams: occupancy", "32.66 µs", AMBER), + ("gap", 0.05), + ("row", "overlap factor", "1.32×", TEXT2), + ("gap", 0.20), + ("note", "Same kernel. The s4 column is the union of kernel intervals per frame, " + "not how long one kernel takes. Quote s1 for duration; s4 feeds the peak, " + "subject to the sustained-rate rule on slide 12."), +]) + +# ------------------------------------------------------------ A3 · NSYS +s = new_slide() +annex_chrome(s, 3, "expands slide 26 · artifact 3", + "Where nsys is sound, and where it is not") +bullets(s, M, 1.90, 12.0, [ + "Tracing a **host** call means running a callback on entry and on exit, " + "**inside the interval being measured**. GPU work is different: the hardware " + "stamps its own start/end and the host reads those records **afterwards**, so " + "nothing is injected into the execution path.", +], size=10.5) +table(s, M, 2.60, 12.0, + ["measurement", "sqlite table", "9×9 f64, 1 stream", "verdict"], + [["cudaLaunchKernel: the host call", "CUPTI_..._RUNTIME", "1.85 µs", + "**inflated ~4×**"], + ["the kernel executing", "CUPTI_..._KERNEL", "39.93 µs", "sound to ~2 %"], + ["cudaMemcpyAsync: the host call", "CUPTI_..._RUNTIME", "1.65 µs", + "**inflated ~4×**"], + ["the H2D / D2H transfer", "CUPTI_..._MEMCPY", "13.25 / 19.44 µs", + "sound to ~2 %"]], + colw=[0.36, 0.24, 0.22, 0.18], size=9, rowh=0.52) +callout(s, M, 5.30, 11.9, + "**Same cudaMemcpyAsync, two numbers: 1.65 µs to ask for the copy, 13.25 µs " + "for the copy to happen.** If the profiler must be present at the moment to " + "measure it, it distorts it; if the hardware records it anyway and the " + "profiler reads it later, it does not.", h=0.86, size=10.5) +caption(s, M, 6.40, 11.9, + "Every headline number in this deck (kernel times, transfer times, duty " + "cycles, overlap and every engine floor) comes from the _KERNEL and _MEMCPY " + "tables (see gpu_span.py). The one figure read from _RUNTIME is the graph " + "launch budget in A5, and it is quoted to one significant figure " + "for exactly this reason. Proof the GPU side is sound: opt7 sustains 25.14 µs " + "unprofiled against a 25.24 µs estimate measured under the profiler, 0.4 % " + "apart, which a 4× distortion could not survive.") + +# ----------------------------------------------------------- A4 · ROUTE A +s = new_slide() +annex_chrome(s, 4, "route A · the idea and the verdict", + "CUDA Graphs, a sound idea that the next act overtook") +bullets(s, M, 1.95, 12.0, [ + "Every cudaMemcpyAsync / kernel launch costs the **CPU** a few microseconds of " + "driver work, per frame and per operation. After opt4 that looked like the budget.", + "A **CUDA Graph** captures the whole dependency DAG once. Replaying it is a " + "**single** cudaGraphLaunch: the driver already knows every node and edge.", +], size=10.5) +figure(s, "fig_graphs", M, 3.15, 7.6) +code(s, 8.5, 3.15, 4.1, [ + "// record once, at setup", + "cudaStreamBeginCapture(sc.stream, ...);", + " submit_h2d_kernel_d2h(sc);", + "cudaStreamEndCapture(sc.stream, &sc.graph);", + "«cudaGraphInstantiate»(&sc.graphExec, ...);", + "", + "// per batch: one call", + "«cudaGraphLaunch»(sc.graphExec, sc.stream);", +], size=8, title="ClusterFinderCUDA_graph.hpp") +callout(s, 8.5, 4.88, 4.1, + "**REJECTED**\n3×3: 39 752 FPS, inside noise of opt4.\n9×9: **11 072 FPS, 12 % slower**.", + h=1.10, size=10.5, color=AMBER) +caption(s, M, 6.62, 12.0, + "Its 3×3 edge was never established: the graph finder recorded no CUDA events " + "while the stream finder did, and that instrumentation tax (2.8 µs) is larger " + "than the gap (0.8 µs). More decisively, it never received the chunked pipeline " + "of opt5, so it is competing on ~2 µs of launch cost against a 24 µs floor. " + "Launch overhead stops binding one step later; the technique aimed at it can no " + "longer pay.") + +# ------------------------------------------------------------ A5 · BUDGET +s = new_slide() +annex_chrome(s, 5, "route A · the arithmetic", + "What a CUDA Graph actually saves, in microseconds") +bullets(s, M, 1.90, COL, [ + "The stream path issues **four runtime calls per frame**, a memset to clear the " + "cluster counter, H2D, the launch, D2H. A graph replaces all four with **one** " + "cudaGraphLaunch, so the ceiling on what it can save is **¾ of the submission cost**.", +], size=10.5) +table(s, M, 2.72, COL, + ["call", "per frame", "host cost", "µs/frame"], + [["cudaMemcpyAsync", "2", "1.98 µs", "3.97"], + ["cudaLaunchKernel", "1", "2.13 µs", "2.13"], + ["cudaMemsetAsync", "1", "1.57 µs", "1.57"], + ["**submission total**", "**4**", "", "**7.67**"]], + colw=[0.40, 0.18, 0.22, 0.20], size=9, rowh=0.44) +code(s, M, 5.42, COL, [ + "7.67 us/frame x 3/4 = 5.75 us/frame eliminated, AS MEASURED (under nsys)", + "5.75 / 4 (see A3) ~ 1.4 us/frame eliminated, unprofiled estimate", +], size=8, title="THE ARITHMETIC") +callout(s, M, 6.28, COL, + "**~1.4 µs against a 16.17 µs floor = 8.7 %**, real while the host is the " + "critical path, and **worth nothing after opt5**, which hides host work under " + "the GPU entirely.", h=0.80, size=10, color=AMBER) +rail(s, [ + ("label", "route A · measured verdict"), + ("gap", 0.10), + ("stat", "3×3 vs opt4", "×1.03", TEXT2), + ("stat", "9×9 vs opt4", "×0.88", AMBER), + ("gap", 0.05), + ("row", "vs opt5 at 9×9", "36 % behind", AMBER), + ("gap", 0.20), + ("note", "Read from CUPTI_..._RUNTIME, the host table, so this is the one " + "order-of-magnitude number in the deck, ±50 %. It is enough to show " + "graphs cannot pay against a 16 µs floor, and not enough to quote to " + "two figures."), +]) + + +# ---------------------------------------------------- A6 · THE OPT5 CODE +s = new_slide() +annex_chrome(s, 6, "expands slide 16 · opt5", + "The overlap, in six lines, and why you never write them") +code(s, M, 1.86, 7.15, [ + "tok = cf.«submit_batch»(data[a0:b0], first_frame=a0)", + "for a, b in bounds[1:]:", + " nxt = cf.«submit_batch»(data[a:b], first_frame=a) # GPU starts N+1 …", + " results.extend(cf.«collect»(tok)) # … host unpacks N", + " tok = nxt", + "results.extend(cf.«collect»(tok)) # drain the last one", +], size=8.5, title="THE WHOLE OF OPT5") +bullets(s, M, 3.66, 7.15, [ + "**You do not write this.** It is inside find_clusters_batched(), which chunks " + "the batch and runs the loop for you, opt5 arrived as a **speedup, not an API " + "change**, and every existing caller got it without editing a line.", + "submit_batch() and collect() stay public for anyone who wants the token by " + "hand, streaming from a detector, interleaving other work between chunks.", +], size=10) +rail(s, [ + ("label", "chunk sizing · the two constraints"), + ("gap", 0.12), + ("row", "multiple of", "n_streams", ACCENT), + ("row", "capped at", "MAX_SLOT_BYTES", ACCENT), + ("gap", 0.16), + ("note", "The chunk MUST be a multiple of n_streams. The device pedestal is " + "per-stream and advances once per frame the stream sees, so an uneven " + "chunk leaves the four pedestals at different ages, the finder would " + "stop being reproducible, and two runs of the same data would not agree."), + ("gap", 0.14), + ("note", "It is also capped so the two pinned output slots stay bounded: the " + "slot is chunk × (4 + cap × sizeof(Cluster)), which at 9×9 and cap 1 700 " + "is 544.5 kB per frame. chunk_size_for(n) applies both rules; pass the " + "result to reserve_output_slots() to pre-pay the page-locking outside " + "any timed region."), +]) +callout(s, M, 5.92, 7.15, + "Two chunks in flight is enough. A third adds pinned memory and no overlap: " + "the host is already busy for the whole time the GPU is.", h=0.72, size=10) +caption(s, M, 6.80, 12.0, + "The saving is min(GPU, host) per chunk, so opt5 pays most when the two terms " + "are comparable, ×1.31 at 3×3, and least when one dominates, ×1.20 at 9×9, " + "where the host term is roughly twice the GPU term. That gap is the diagnosis " + "that motivates opt6 (report §8.2).") + +prs.save(OUT) +print(f"saved {OUT} ({len(prs.slides._sldIdLst)} slides)") diff --git a/docs/deck/frame147.json b/docs/deck/frame147.json new file mode 100644 index 00000000..0ea8ed37 --- /dev/null +++ b/docs/deck/frame147.json @@ -0,0 +1,2143 @@ +{ + "fid": 147, + "x0": 190, + "y0": 0, + "rx": 1, + "ry": 1, + "sub_frozen": [ + [ + 12.921162493563315, + 5.288333451388098, + 10.740714568929434, + -29.925203554168547, + 13.263081532059005, + 9.784118037243388, + 0.9026224220879158, + 17.62955556840916, + 22.268090270092216, + -24.395388769095007, + -11.80374064274838, + 21.50780685841528, + 1.501015157459733, + 5.497040551196733, + -6.029562185973191, + -37.83646557945394, + -4.041058513895223, + -18.837716059898412, + 0.39101802126424445, + -10.515601169217916, + 10.999918686149613, + 0.9688899931370543, + 10.117055817275286, + -5.655470197521026 + ], + [ + -8.848484458089843, + -4.039367127728838, + -15.351719742996465, + 13.62723476008432, + 16.553084408255017, + 10.045073678379595, + -2.060242187444601, + -2.0464017930335103, + -2.3505976793794616, + 10.113071887444676, + -8.194632149052268, + 36.24715759082301, + 5.669425638292523, + 15.395616407469788, + 13.690735920897168, + -7.1247454808762996, + -25.025787496703742, + -25.52430780324903, + -17.979943761637514, + 2.4214661587875526, + 13.946854570882351, + 10.229638378768868, + 29.378993733425887, + -7.0615008028935335 + ], + [ + -36.760829904144884, + 6.383710526059076, + -3.6048490153843886, + 16.21222232117634, + -13.034971726620824, + -10.782154223616999, + -3.9250343676767443, + 9.1575265883248, + 15.161602195576052, + 19.032740161060246, + -23.95151567832636, + -20.495847029741526, + -9.289512884339274, + -20.988495675268496, + 16.04339291436827, + 7.23997304486511, + 4.468537311230648, + -17.744646012630255, + -2.251630361491152, + -6.787705237979026, + -27.464879423345337, + -17.297560485659233, + -31.743380762502966, + -24.300955212924237 + ], + [ + -4.331961515129478, + 25.1643825232577, + -4.979692936104584, + -0.8108924309653958, + -1.2204522963284035, + 31.721727701548843, + 2.4227919356808343, + 14.945469630632033, + 10.507500564782276, + -2.4187632469665914, + 5.3052166570687405, + 0.6213298510720051, + 24.24585597508667, + 5.9358448881739605, + 6.244006682025429, + -60.3801776359287, + -6.077612475336537, + -14.459073247139713, + 10.330656843683755, + -14.984582963395042, + -6.916898673073774, + -11.853857820534358, + 14.057536431892004, + 11.046808950524792 + ], + [ + 38.87280129310784, + -4.689650268032892, + -15.918174287749025, + -12.41649579075056, + -25.182686375210324, + 8.01265322962172, + -23.905715609928848, + -17.964690550621526, + 6.078532478806665, + 3.4145768354301254, + -13.759182093147501, + 2.0549630323512247, + -20.600273795202156, + -1.5632586553911096, + -0.7873389448868693, + 0.5972969479398671, + 12.120569214996067, + -0.5773860942936153, + 1.7550903714400192, + -17.350666374421053, + -12.756888696911119, + -45.110155968120125, + 6.330529692962955, + -2.725323484418368 + ], + [ + -13.597581542633634, + -15.142038898266037, + -22.143962078670484, + -8.843025057699379, + 21.25276336881143, + 40.83503973437928, + -9.752964988342683, + -15.286062107516045, + -11.539165824412521, + -7.4034125759917515, + -9.708307710824556, + 23.329404536633774, + 5.860093651750503, + 4.983506474326532, + -9.408600202912567, + -26.68617250537227, + -13.13103796633095, + -11.266224197664087, + -18.740398115784046, + 1.8497740092698223, + 2.1030005985930984, + -22.47303499386817, + 2.5357603327311153, + 134.37020627499624 + ], + [ + -13.234855348428027, + 17.920978852675944, + 21.43993185997624, + -15.973747354271836, + 2.6564038749584142, + -1.5526777237382703, + -19.472120520079443, + 45.96376960415273, + 8.92987419214387, + -12.416254614468926, + 4.450413192140331, + 1.5227590498443533, + 14.813839589014606, + -6.782701466672734, + 6.0726822460046606, + 1.9336875679919103, + -4.934409102409518, + -8.705564007575049, + -3.278645706075622, + -8.2660924821339, + -64.99454795984911, + -13.172886877877318, + -24.802277859160313, + 226.01816670402877 + ], + [ + 5.795476038999368, + 21.6609150112954, + 5.452201357391459, + -10.361446105458526, + 5.32962368355129, + -29.628735400951882, + 6.7665750643418505, + -18.111837391922563, + 0.813373401755598, + 7.458823854892216, + 6.710668897215328, + 45.34197087515622, + 638.3830199557597, + -12.148925033803607, + 5.173535595384237, + -9.661124941920207, + 4.4062637880051625, + 2.8869443971789224, + 6.146489002141607, + -10.756888987728416, + -2.6326951725350227, + -13.51149489314048, + 11.355836131380784, + 77.05567964133479 + ], + [ + -11.589048395194368, + 599.9662238088467, + 527.7748706472821, + 22.83553958416178, + -9.817537841578087, + 14.300309685873799, + -25.31600986887679, + -27.719440979861247, + -18.529793321206853, + 6.787915689187685, + 25.488918367121187, + 43.74901194122231, + 638.3827736644862, + -7.4969628043818375, + 0.005266422993372544, + -29.89235265957086, + -8.143849636897357, + -22.12704969098195, + 9.976901514173733, + -7.459415141735008, + -19.445189759821915, + -20.398545286348053, + -17.012374294454276, + 66.31606874482895 + ], + [ + 12.423512381108594, + 28.213668897980824, + 43.06000181840591, + 13.807388755784814, + 18.606165483716723, + 1.2963294696874073, + -7.342505407417775, + 13.265687300769969, + -23.484658169301838, + 13.57896006890951, + -36.86599616820513, + 1.0897268198996244, + 70.67972301206737, + 136.38927946515923, + 25.34544459902736, + -13.979959964883165, + -1.1554311837026034, + 7.373458598260186, + 29.662336381306886, + -6.1331288541305184, + -12.744315328041012, + -21.527968925175628, + 21.00164388351368, + 34.022579816732105 + ], + [ + 24.28833053760991, + -5.1021570571147095, + 1.6855587828767966, + 1.5840036795425476, + -7.611161709753105, + -1.0291524775848302, + -12.419489903280919, + 10.205175197647804, + -8.531981714667381, + 5.312306130347679, + -3.748755721308953, + -4.210427856603019, + 2.625321423944115, + 28.912500752862798, + 4.746379274402898, + -1.248515621144179, + 14.002576576998763, + 8.0734652140236, + -5.290318142895558, + 4.852859856521718, + 2.700470700618098, + -9.939061380933708, + 19.786600502891815, + -10.708231816531224 + ], + [ + -14.109178900675033, + 5.862170201985464, + -10.530737417061573, + -13.99454938983854, + -26.236157597195415, + 15.940653660641146, + 15.228993787404761, + -11.904908972309386, + -10.129255311878296, + 11.966987758618416, + 25.81779207717682, + 0.523402025300129, + 9.921370441004001, + -10.962064281416133, + 43.09645219957656, + -17.841768096785927, + -3.916708156964887, + -28.0402198576503, + -2.932481117441057, + -23.188431543498154, + 13.541742480194443, + -15.404001391396378, + -7.112155732904284, + 14.227770053270433 + ], + [ + 16.660133548845806, + 1.5600030934892857, + 5.066056463694622, + 1.472805252696162, + -8.984627686890235, + 3.080779432762938, + 32.66533580590021, + -0.35865585218380147, + 3.935909085375897, + 2.236095191842651, + -21.624118410720257, + -3.0706969688844765, + 33.87150293387913, + 4.4263971352938825, + 24.540917292055383, + -13.792173232918685, + -5.173178579713749, + 14.075413809789097, + 12.921901043201615, + -14.761709539982803, + -39.95552868640516, + -7.462625631801529, + 21.42615951956941, + 0.21504328305400122 + ], + [ + 39.43227333159575, + 6.001652017557717, + -13.45284341929164, + -5.629658828725951, + 5.803839745453843, + -1.3662724921232439, + 15.6953052764311, + -36.08472645288748, + 22.017637458735408, + -0.04861656006778503, + 7.729388295978424, + 35.87472680436713, + 13.882569164993583, + 29.829162273359543, + -28.325634018943674, + 8.637839318815168, + -11.863474316583961, + 12.999003578725024, + 4.945329439251509, + -11.23608918346872, + -19.44868536795184, + -19.270218283182658, + 15.374093411343893, + -25.188485100013168 + ], + [ + -9.15665936887035, + -3.2271716113828006, + 23.464295099176525, + -18.88859629631952, + 20.989435398912065, + 12.180575367297024, + 8.102894701841251, + -29.40686244949393, + -17.903707806201965, + -5.803675444652981, + 2.526608391031914, + 5.87769651482995, + -9.144784151658314, + -6.145942739833117, + 14.070410752471616, + -7.808028158822708, + -28.604925194521456, + -29.705774358808412, + 0.3502796721531922, + 3.85777311900074, + -43.0830651989927, + -3.869073649702841, + 27.652003612412955, + -3.34546020545622 + ], + [ + 43.06738527589005, + -18.98587225931624, + -2.9024394711641435, + -0.8591949608835421, + 17.000377328096874, + -2.9740112657746067, + -28.87773550309703, + -4.470627490962215, + 23.417330010147452, + 20.531508634082456, + -6.6122498396625815, + 23.706508680584193, + 5.157018752627664, + -8.709672474020408, + 15.960344459668704, + -9.175134357175011, + -14.214305021034306, + -12.353041634059991, + 6.2963929005827595, + 11.165812117273163, + 719.6441425356579, + 9.3532628885323, + 8.058414415830157, + -1.5712937642983889 + ], + [ + 30.40096370926676, + 12.438231125383936, + -34.57001579290227, + -4.318584954138714, + -32.698928809480094, + 14.943186393963515, + -28.39428299528572, + 22.974126967478696, + 0.18413817430882773, + -6.377810877391312, + 17.671045631795096, + -13.838460549135561, + 24.75899526908779, + 5.028132799672676, + -25.935849099915686, + -8.46566573828568, + 27.8494353672977, + -11.616319198984456, + 18.966644206363526, + 29.405715411425263, + 376.5474665279662, + 1.5736470943938912, + 0.6937475946797349, + 9.810668487067232 + ], + [ + -5.4904438185531035, + -0.07605210042493127, + 7.616198621983131, + -7.6327861193531135, + 23.659508956449827, + -7.85526480802946, + 21.197511794209277, + 7.650185535405399, + 8.634735417706906, + -15.737734975966305, + 20.729497856586022, + -30.967858064764187, + -8.934531412986871, + 14.763477804860486, + 12.813563731302565, + 10.587730759548322, + -41.460624929196456, + -23.940632836201075, + -4.146464138508236, + -7.3179470817976835, + -39.875644408458356, + 9.654833034783223, + 18.096188545359837, + -8.883816426588055 + ], + [ + 8.448818599621518, + 12.628113638953437, + 6.188416071381653, + -35.816608165075195, + -16.985534728440143, + 22.880275381800857, + -4.046803524943243, + 27.514665262822746, + -9.909763841748827, + -5.546930561637055, + 0.46245595306481846, + -7.635762931872705, + -6.791793851920374, + 11.020681751570919, + -4.854974077436054, + 24.555744265892827, + -18.073538703525628, + -8.269075054588939, + -13.375259341022684, + 13.888622868475068, + 202.031523083685, + -23.274940910589066, + -2.937999796584336, + 8.606383590072255 + ], + [ + 19.12832410321971, + -10.150045256777958, + 1.6307044394106924, + -36.913376455650905, + -5.129363887418549, + 24.548643406991687, + -30.888283231282003, + -20.71378851188456, + -9.271266569484396, + -6.084735588528929, + 20.458618402091815, + 7.701799801496236, + -1.4930877926472021, + -22.813755609442524, + 3.1579815952136414, + -18.247823062823045, + -21.719293674520486, + -16.502911872871664, + -23.16940109708412, + 130.91473387949372, + 782.8527742559772, + 0.044157944266771665, + 6.297729351027556, + 12.652003416610569 + ] + ], + "sub_cuda": [ + [ + 12.92138671875, + 5.28857421875, + 10.74072265625, + -29.92529296875, + 13.26318359375, + 9.7841796875, + 0.90283203125, + 17.62939453125, + 22.26806640625, + -24.3955078125, + -11.8037109375, + 21.5078125, + 1.5009765625, + 5.4970703125, + -6.02978515625, + -37.83642578125, + -4.041015625, + -18.837890625, + 0.39111328125, + -10.515625, + 11.0, + 0.96875, + 10.1171875, + -5.6552734375 + ], + [ + -8.8486328125, + -4.03955078125, + -15.3515625, + 13.62744140625, + 16.55322265625, + 10.044921875, + -2.06005859375, + -2.04638671875, + -2.3505859375, + 10.11328125, + -8.19482421875, + 36.2470703125, + 5.66943359375, + 15.3955078125, + 13.69091796875, + -7.12451171875, + -25.02587890625, + -25.5244140625, + -17.97998046875, + 2.42138671875, + 13.94677734375, + 10.2294921875, + 29.37890625, + -7.0615234375 + ], + [ + -36.7607421875, + 6.3837890625, + -3.60498046875, + 16.21240234375, + -13.03515625, + -10.7822265625, + -3.9248046875, + 9.15771484375, + 15.16162109375, + 19.03271484375, + -23.95166015625, + -20.49560546875, + -9.28955078125, + -20.98828125, + 16.04345703125, + 7.23974609375, + 4.46875, + -17.74462890625, + -2.25146484375, + -6.78759765625, + -27.46484375, + -17.29736328125, + -31.7431640625, + -24.30078125 + ], + [ + -4.33203125, + 25.16455078125, + -4.9794921875, + -0.81103515625, + -1.22021484375, + 31.7216796875, + 2.4228515625, + 14.9453125, + 10.50732421875, + -2.4189453125, + 5.30517578125, + 0.62109375, + 24.24609375, + 5.93603515625, + 6.244140625, + -60.38037109375, + -6.07763671875, + -14.458984375, + 10.33056640625, + -14.984375, + -6.9169921875, + -11.85400390625, + 14.0576171875, + 11.046875 + ], + [ + 38.87255859375, + -4.689453125, + -15.91796875, + -12.41650390625, + -25.1826171875, + 8.0126953125, + -23.90576171875, + -17.96484375, + 6.07861328125, + 3.41455078125, + -13.75927734375, + 2.05517578125, + -20.60009765625, + -1.5634765625, + -0.787109375, + 0.59716796875, + 12.12060546875, + -0.5771484375, + 1.7548828125, + -17.3505859375, + -12.7568359375, + -45.1103515625, + 6.33056640625, + -2.72509765625 + ], + [ + -13.59765625, + -15.14208984375, + -22.14404296875, + -8.84326171875, + 21.2529296875, + 40.8349609375, + -9.7529296875, + -15.2861328125, + -11.5390625, + -7.4033203125, + -9.70849609375, + 23.32958984375, + 5.85986328125, + 4.9833984375, + -9.40869140625, + -26.68603515625, + -13.130859375, + -11.26611328125, + -18.740234375, + 1.849609375, + 2.10302734375, + -22.47314453125, + 2.53564453125, + 134.3701171875 + ], + [ + -13.23486328125, + 17.9208984375, + 21.43994140625, + -15.9736328125, + 2.65625, + -1.552734375, + -19.47216796875, + 45.9638671875, + 8.9296875, + -12.416015625, + 4.4501953125, + 1.52294921875, + 14.81396484375, + -6.78271484375, + 6.07275390625, + 1.93359375, + -4.9345703125, + -8.70556640625, + -3.27880859375, + -8.26611328125, + -64.99462890625, + -13.1728515625, + -24.80224609375, + 226.01806640625 + ], + [ + 5.79541015625, + 21.6611328125, + 5.4521484375, + -10.361328125, + 5.32958984375, + -29.62890625, + 6.7666015625, + -18.11181640625, + 0.8134765625, + 7.458984375, + 6.71044921875, + 45.341796875, + 638.3828125, + -12.14892578125, + 5.17333984375, + -9.6611328125, + 4.40625, + 2.88671875, + 6.146484375, + -10.7568359375, + -2.6328125, + -13.51171875, + 11.35595703125, + 77.0556640625 + ], + [ + -11.5888671875, + 599.96630859375, + 527.77490234375, + 22.83544921875, + -9.8173828125, + 14.30029296875, + -25.31591796875, + -27.71923828125, + -18.52978515625, + 6.7880859375, + 25.48876953125, + 43.7490234375, + 638.3828125, + -7.4970703125, + 0.00537109375, + -29.892578125, + -8.14404296875, + -22.126953125, + 9.97705078125, + -7.45947265625, + -19.4453125, + -20.3984375, + -17.01220703125, + 66.31591796875 + ], + [ + 12.42333984375, + 28.2138671875, + 43.06005859375, + 13.8076171875, + 18.60595703125, + 1.29638671875, + -7.34228515625, + 13.265625, + -23.48486328125, + 13.5791015625, + -36.8662109375, + 1.08984375, + 70.6796875, + 136.38916015625, + 25.34521484375, + -13.97998046875, + -1.1552734375, + 7.37353515625, + 29.662109375, + -6.13330078125, + -12.744140625, + -21.52783203125, + 21.00146484375, + 34.0224609375 + ], + [ + 24.28857421875, + -5.10205078125, + 1.685546875, + 1.583984375, + -7.611328125, + -1.029296875, + -12.41943359375, + 10.205078125, + -8.53173828125, + 5.3125, + -3.74853515625, + -4.21044921875, + 2.62548828125, + 28.91259765625, + 4.74658203125, + -1.24853515625, + 14.00244140625, + 8.0732421875, + -5.29052734375, + 4.85302734375, + 2.70068359375, + -9.93896484375, + 19.78662109375, + -10.7080078125 + ], + [ + -14.109375, + 5.8623046875, + -10.53076171875, + -13.99462890625, + -26.236328125, + 15.9404296875, + 15.22900390625, + -11.90478515625, + -10.12939453125, + 11.966796875, + 25.81787109375, + 0.5234375, + 9.92138671875, + -10.9619140625, + 43.0966796875, + -17.841796875, + -3.91650390625, + -28.0400390625, + -2.9326171875, + -23.1884765625, + 13.54150390625, + -15.40380859375, + -7.1123046875, + 14.2275390625 + ], + [ + 16.66015625, + 1.56005859375, + 5.06591796875, + 1.47265625, + -8.98486328125, + 3.08056640625, + 32.66552734375, + -0.35888671875, + 3.93603515625, + 2.236328125, + -21.6240234375, + -3.07080078125, + 33.87158203125, + 4.42626953125, + 24.541015625, + -13.7919921875, + -5.17333984375, + 14.0751953125, + 12.921875, + -14.76171875, + -39.95556640625, + -7.46240234375, + 21.42626953125, + 0.21484375 + ], + [ + 39.43212890625, + 6.00146484375, + -13.45263671875, + -5.6298828125, + 5.8037109375, + -1.3662109375, + 15.6953125, + -36.0849609375, + 22.017578125, + -0.048828125, + 7.7294921875, + 35.87451171875, + 13.8828125, + 29.8291015625, + -28.32568359375, + 8.6376953125, + -11.86328125, + 12.9990234375, + 4.9453125, + -11.236328125, + -19.44873046875, + -19.27001953125, + 15.3740234375, + -25.1884765625 + ], + [ + -9.15673828125, + -3.22705078125, + 23.46435546875, + -18.888671875, + 20.9892578125, + 12.1806640625, + 8.10302734375, + -29.40673828125, + -17.90380859375, + -5.8037109375, + 2.5263671875, + 5.8779296875, + -9.14501953125, + -6.14599609375, + 14.0703125, + -7.80810546875, + -28.60498046875, + -29.70556640625, + 0.35009765625, + 3.85791015625, + -43.0830078125, + -3.869140625, + 27.65185546875, + -3.345703125 + ], + [ + 43.0673828125, + -18.98583984375, + -2.90234375, + -0.859375, + 17.00048828125, + -2.97412109375, + -28.8779296875, + -4.470703125, + 23.41748046875, + 20.53173828125, + -6.6123046875, + 23.70654296875, + 5.1572265625, + -8.70947265625, + 15.96044921875, + -9.17529296875, + -14.21435546875, + -12.35302734375, + 6.29638671875, + 11.166015625, + 719.64404296875, + 9.35302734375, + 8.05859375, + -1.5712890625 + ], + [ + 30.40087890625, + 12.43798828125, + -34.56982421875, + -4.318359375, + -32.69873046875, + 14.943359375, + -28.39404296875, + 22.97412109375, + 0.18408203125, + -6.3779296875, + 17.6708984375, + -13.83837890625, + 24.7587890625, + 5.0283203125, + -25.93603515625, + -8.4658203125, + 27.849609375, + -11.6162109375, + 18.966796875, + 29.40576171875, + 376.54736328125, + 1.57373046875, + 0.69384765625, + 9.810546875 + ], + [ + -5.490234375, + -0.076171875, + 7.6162109375, + -7.6328125, + 23.65966796875, + -7.85546875, + 21.19775390625, + 7.650390625, + 8.634765625, + -15.73779296875, + 20.7294921875, + -30.9677734375, + -8.9345703125, + 14.763671875, + 12.8134765625, + 10.587890625, + -41.46044921875, + -23.9404296875, + -4.146484375, + -7.31787109375, + -39.87548828125, + 9.65478515625, + 18.09619140625, + -8.8837890625 + ], + [ + 8.44873046875, + 12.6279296875, + 6.1884765625, + -35.81640625, + -16.9853515625, + 22.88037109375, + -4.046875, + 27.5146484375, + -9.90966796875, + -5.546875, + 0.46240234375, + -7.6357421875, + -6.7919921875, + 11.0205078125, + -4.85498046875, + 24.5556640625, + -18.07373046875, + -8.26904296875, + -13.37548828125, + 13.888671875, + 202.03173828125, + -23.27490234375, + -2.93798828125, + 8.6064453125 + ], + [ + 19.12841796875, + -10.14990234375, + 1.630859375, + -36.91357421875, + -5.12939453125, + 24.548828125, + -30.88818359375, + -20.7138671875, + -9.271484375, + -6.0849609375, + 20.45849609375, + 7.70166015625, + -1.4931640625, + -22.81396484375, + 3.158203125, + -18.248046875, + -21.71923828125, + -16.5029296875, + -23.16943359375, + 130.91455078125, + 782.8525390625, + 0.0439453125, + 6.2978515625, + 12.65185546875 + ] + ], + "mask_frozen": [ + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ], + [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ], + [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ], + [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + true + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + true + ] + ], + "mask_cuda": [ + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ], + [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ], + [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ], + [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + true + ], + [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + false, + true + ] + ], + "c_frozen": [ + [ + 202, + 7 + ], + [ + 191, + 8 + ], + [ + 210, + 15 + ], + [ + 210, + 19 + ] + ], + "c_cuda": [ + [ + 202, + 8 + ], + [ + 202, + 7 + ], + [ + 210, + 15 + ], + [ + 191, + 8 + ], + [ + 210, + 19 + ] + ], + "only_cuda": [ + [ + 202, + 8 + ] + ], + "n_frozen": 2270, + "n_cuda": 2271 +} \ No newline at end of file diff --git a/docs/deck/make_figs.py b/docs/deck/make_figs.py index e147198e..b1bf9376 100644 --- a/docs/deck/make_figs.py +++ b/docs/deck/make_figs.py @@ -1,4 +1,60 @@ -"""Figures for ClusterFinderCUDA_optimizations.pptx — deck palette, dark, transparent.""" +"""Figures for docs/cf_cuda_fused.pptx — deck palette, dark. + +Every number here is a quotable row from docs/ClusterFinderCUDA_benchmark_results.md, +i.e. from python/tests/perf/results/. Acts I and II are the f64 arm, Act III is +the f32 arm. 3x3 comes from 2026-08-18_{f32,f64}/; 9x9 was RE-TAKEN on +2026-08-20_{f32,f64}_cap1700/ because the campaign's 9x9 cap of 1500 sat below the +per-frame maximum (1633) and silently truncated 0.0095 % of clusters. All 9x9 +numbers below are cap 1700, lossless. + +CPU baseline = the BEST thread count, one per cluster size, from +python/tests/perf/results/2026-08-19_cpu_threads/. The campaign originally used +n_threads=48 on a 16-core / 32-thread Ryzen 9 7950X -- 1.5x oversubscribed, and +slower than the CPU can actually go. Every speedup in the deck divides by these: + + threads 3x3 FPS 9x9 FPS + 8 3 805 737 + 16 6 594 1 237 + 24 >> 6 762 << 1 348 + 32 5 942 >> 1 503 << + 48 5 121 1 338 <- the campaign's original + +So 3x3 divides by 6 762 (147.9 us/fr) and 9x9 by 1 503 (665.2 us/fr). The optima +differ because ClusterCollector's drain -- inside the timed region, as in +ladder.py -- scales with thread count while 9x9 clusters are 9x larger. There is +no per-arm CPU baseline any more: the CPU finder never touches DEVICE_PED_TYPE, +so the old f64/f32 split (201.16 / 191.26 us) only encoded run-to-run noise. + +Ladder, warm, us/frame 3x3 9x9 (cap 1700) + CPU MT (best) 147.88 665.22 + opt1 63.26 -- + opt2 40.44 -- + opt3 34.26 82.44 + opt4 25.98 79.83 + route A (graphs) 25.16 90.32 + opt5 chunked overlap 19.84 66.39 + opt6 zero-copy 17.10 30.01 + opt7 = opt6 on f32 16.31 25.14 + +Peak throughput = 1 / max(H2D, kernel, D2H), each term being that engine's BUSY +TIME PER FRAME (the union of its intervals) at the ladder's 4 streams -- and taken +as the LOWER of two estimates: the profiled engine occupancy, and the best rate the +unprofiled pipeline sustained. A sustained rate is an existence proof; the probe is +an estimate made in a loop nsys slows to ~69 us/frame, where kernels overlap less +and the union per frame reads high. + + probe (nsys) best sustained PEAK binds + 3x3 f64 16.17 us 17.10 us 16.17 us -> 61 859 H2D + 3x3 f32 16.63 us 16.31 us 16.31 us -> 61 312 H2D + 9x9 f64 32.66 us 30.01 us 30.01 us -> 33 323 KERNEL + 9x9 f32 25.24 us 25.14 us 25.14 us -> 39 775 D2H + +The last row is the point of Act III. At 9x9 cap 1700 the D2H slot is 544.5 KiB +and costs 25.2 us on BOTH arms. Under the f64 kernel (32.66 us) that is invisible. +opt7 cuts the kernel 40 % to 23.94 us -- below the D2H bar -- so the f32 floor is +D2H, not the kernel. Optimizing the kernel in bottleneck order ended by handing +the constraint to the result path, which is what success looks like. +""" import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt @@ -6,17 +62,18 @@ import numpy as np from matplotlib.patches import FancyArrowPatch, Rectangle from pathlib import Path -OUT = Path(__file__).parent / "figs" +OUT = Path(__file__).resolve().parent.parent / "figures" OUT.mkdir(exist_ok=True) BG = "#0B1018" PANEL = "#121A28" RULE = "#1E2836" -ACCENT = "#1E90C2" # data 1 -AMBER = "#E8B25C" # data 2 -PALE = "#E7EDF4" # data 3 / primary text +ACCENT = "#1E90C2" # Act I / data 1 +AMBER = "#E8B25C" # Act III / data 2 / warnings +PALE = "#E7EDF4" # Act II / data 3 / primary text TEXT2 = "#A5B2C4" MUTED = "#6B7A90" # non-data only: grid, axes, annotation +GREEN = "#5CC8A0" # rooflines / floors plt.rcParams.update({ "font.family": "DejaVu Sans", "font.size": 9, @@ -40,59 +97,321 @@ def bare(ax, keep=("left", "bottom")): ax.spines[s].set_visible(s in keep) -# ---------------------------------------------------------------- 1. the arc -def fig_arc(): - steps = ["CPU MT\n48 threads", "opt1\n1 stream", "opt2\nstreams+batch", - "opt3\npipeline", "opt4\npinned", "opt5\ngraphs"] - fps = [4761, 14968, 23134, 26588, 36810, 39472] - spd = [1.0, 3.14, 4.86, 5.58, 7.73, 8.29] - colors = [MUTED] + [ACCENT] * 4 + [AMBER] +# ------------------------------------------------------ 0a. agreement study +def fig_spectra_valid(): + """Cluster-energy spectra of the three finders, and their ratio to the CPU. - fig, ax = plt.subplots(figsize=(11.4, 3.5)) + Data: validation_tiers.json, written by python/tests/validation_tiers.py — + serial ClusterFinder, ClusterFinderFrozen and ClusterFinderCUDA over the + same 10 000 frames from the same trained pedestal, 23.2 M clusters each. + + The overlay is deliberately unreadable as three curves: that is the result. + The ratio panel is where the claim is testable, and it holds every populated + bin inside +-0.1 %. + """ + import json + d = json.loads((Path(__file__).resolve().parent + / "validation_tiers.json").read_text()) + e = np.array(d["edges"]) + ctr = 0.5 * (e[1:] + e[:-1]) + h = {k: np.array(v) for k, v in d["hists"].items()} + + fig, (ax, axr) = plt.subplots(2, 1, figsize=(5.9, 3.9), sharex=True, + gridspec_kw={"height_ratios": [2.6, 1]}) + for name, col, lw, ls in [("cpu", ACCENT, 2.0, "-"), + ("frozen", PALE, 1.3, "--"), + ("cuda", AMBER, 1.3, ":")]: + ax.step(ctr, h[name], where="mid", color=col, lw=lw, ls=ls, + label=f"{name} ({d['totals'][name]:,})") + ax.set_yscale("log") + ax.set_ylim(3e2, 5e6) + ax.legend(frameon=False, fontsize=8, labelcolor=TEXT2, loc="upper right") + ax.set_ylabel("clusters / bin", fontsize=8) + bare(ax, keep=("left", "bottom")) + ax.set_title("cluster energy spectrum · 3×3, 10 000 frames, 23.2 M clusters", + color=MUTED, fontsize=8.5, loc="left", pad=6) + + m = h["cpu"] > 0 + axr.axhspan(0.999, 1.001, color=GREEN, alpha=0.18, zorder=1) + axr.axhline(1.0, color=MUTED, lw=0.8, zorder=2) + for name, col in [("frozen", PALE), ("cuda", AMBER)]: + axr.plot(ctr[m], h[name][m] / h["cpu"][m], color=col, lw=1.1, zorder=3) + dev = max(np.abs(h[n][m] / h["cpu"][m] - 1).max() for n in ("frozen", "cuda")) + axr.set_ylim(0.9955, 1.0045) + axr.set_yticks([0.996, 1.0, 1.004]) + axr.set_yticklabels(["−0.4 %", "0", "+0.4 %"], fontsize=7.5) + axr.set_xlabel("cluster sum [ADU]", fontsize=8) + axr.set_ylabel("vs CPU", fontsize=8) + bare(axr, keep=("left", "bottom")) + axr.text(0.985, 0.90, f"worst populated bin: {dev*100:.3f} % · band = ±0.1 %", + transform=axr.transAxes, ha="right", va="top", color=GREEN, + fontsize=7.5) + fig.subplots_adjust(hspace=0.10) + save(fig, "fig_spectra_valid") + + +# ------------------------------------------------ 0. what a first run gives +def fig_first_run(): + """Achievable vs what a user gets on their first, naive run. + + The campaign's own 'cold' rep is NOT this number: the harness discards each + chunk's clusters, so its result heap never grows and rep 0 shows only ~10^5 + faults. A user keeps their clusters. These are the retained, single-pass, + one-process numbers recorded in python/tests/ClusterFinderCUDA_perf.ipynb + (f32 build, 3x3, 100 000 frames), against the f32 campaign's warm ladder. + + The shape is the argument: the two ends of the ladder are untouched and + everything between them loses a third. opt1 escapes only because it discards + each frame as it goes and never grows the heap; opt6 escapes because it + allocates nothing at all. + """ + steps = ["opt1\n1 stream", "opt2\nstreams+batch", "opt3\nno barriers", + "opt4\npinned", "opt5\nhost overlap", "opt6\nzero-copy"] + warm = [15852, 25119, 29291, 40158, 50501, 61312] + cold = [15773, 16651, 18961, 30642, 32342, 60587] + faults = [0, 2626454, 2295417, 2294896, 2050300, 1] + + fig, ax = plt.subplots(figsize=(11.9, 3.35)) x = np.arange(len(steps)) - bars = ax.bar(x, fps, width=0.62, color=colors, zorder=3) - for b in bars: - b.set_linewidth(0) - for xi, (f, s) in enumerate(zip(fps, spd)): - ax.text(xi, f + 900, f"{f:,}", ha="center", va="bottom", - color=PALE, fontsize=11, fontweight="bold") - ax.text(xi, f + 3100, ("baseline" if s == 1.0 else f"×{s:.2f}"), - ha="center", va="bottom", color=AMBER if s > 8 else TEXT2, fontsize=9) + w = 0.36 + TOP = 76000 + + ax.bar(x - w / 2, warm, width=w, color=ACCENT, zorder=3, linewidth=0) + ax.bar(x + w / 2, cold, width=w, color=AMBER, zorder=3, linewidth=0) + + # Floor = the best rate sustained unprofiled. nsys's own estimate of the H2D + # engine occupancy is 60 140 FPS, 1.9 % lower, and corroborates it. + ax.axhline(61312, color=GREEN, lw=1.2, ls="--", zorder=4) + ax.text(2.6, 62100, "H2D floor · 61 312 FPS (nsys estimates 60 140)", + color=GREEN, fontsize=8.5, ha="center", va="bottom") + + for xi, (a, b, f) in enumerate(zip(warm, cold, faults)): + ax.text(xi - w / 2, a + 900, f"{a:,}", ha="center", va="bottom", + color=TEXT2, fontsize=9) + ax.text(xi + w / 2, b + 900, f"{b:,}", ha="center", va="bottom", + color=PALE, fontsize=9.5, fontweight="bold") + drop = 100 * (1 - b / a) + big = drop > 10 + ax.text(xi, -2600, f"{f:,} fault" + ("" if f == 1 else "s"), + ha="center", va="top", color=AMBER if big else MUTED, + fontsize=8, fontweight="bold" if big else "normal") + ax.text(xi, -6300, ("−%.0f %%" % drop) if drop >= 1 else "—", + ha="center", va="top", color=AMBER if big else MUTED, + fontsize=10 if big else 8.5, fontweight="bold") + + # the two steps that pay nothing, annotated just above their own bars + for xi, yi, why in [(0, 21500, "discards every frame\nas it goes"), + (5, 65200, "allocates nothing\nat all")]: + ax.text(xi, yi, why, ha="center", va="bottom", color=GREEN, + fontsize=8, linespacing=1.35) + ax.set_xticks(x) - ax.set_xticklabels(steps, fontsize=9, color=TEXT2) - ax.set_ylim(0, 47000) + ax.set_xticklabels(steps, fontsize=8.5, color=TEXT2) + ax.set_ylim(0, TOP) + ax.set_yticks([]) + ax.tick_params(axis="x", pad=34) + bare(ax, keep=("bottom",)) + ax.spines["bottom"].set_color(RULE) + + handles = [Rectangle((0, 0), 1, 1, color=ACCENT), + Rectangle((0, 0), 1, 1, color=AMBER)] + ax.legend(handles, ["achievable · warm, 5-rep campaign", + "first run · results retained, one process"], + frameon=False, fontsize=9, labelcolor=TEXT2, loc="upper left", + bbox_to_anchor=(0.0, 1.055), ncol=2, handlelength=1.1) + ax.set_title("frames / second · 3×3, 100 000 frames, f32 · " + "minor faults and the throughput they cost, per step", + color=MUTED, fontsize=9, loc="left", pad=8) + save(fig, "fig_first_run") + + +# ------------------------------------------------------- 1. the arc, 3x3 +def fig_arc(): + """The whole ladder at 3x3. Acts I-II on f64, opt7 is the f32 flip.""" + steps = ["CPU MT\n24 threads", "opt1\n1 stream", "opt2\nstreams+batch", + "opt3\nno barriers", "opt4\npinned", "opt5\nhost overlap", + "opt6\nzero-copy", "opt7\nf32 kernel"] + # CPU bar = the BEST thread count, not the campaign's original 48. This is a + # 16-core / 32-thread 7950X, so 48 oversubscribed it by 1.5x and understated + # the CPU by 29 % (cpu_threads.csv). One baseline for every bar: the CPU + # finder does not depend on DEVICE_PED_TYPE, so per-arm baselines only ever + # encoded run-to-run noise. + fps = [6762, 15807, 24726, 29188, 38486, 50410, 58495, 61312] + spd = [1.0, 2.34, 3.66, 4.32, 5.69, 7.45, 8.65, 9.07] + colors = [MUTED] + [ACCENT] * 4 + [PALE] * 2 + [AMBER] + + fig, ax = plt.subplots(figsize=(11.4, 3.8)) + x = np.arange(len(steps)) + TOP = 78000 + + # act bands, behind the bars, labelled along the top + for x0, x1, label, col in [(-0.5, 4.5, "ACT I · feed the GPU", ACCENT), + (4.5, 6.5, "ACT II · get results back", PALE), + (6.5, 7.5, "ACT III · kernel", AMBER)]: + ax.axvspan(x0, x1, color=col, alpha=0.05, zorder=0) + ax.plot([x0 + 0.08, x1 - 0.08], [TOP * 0.955] * 2, color=col, lw=2.2, + zorder=2) + ax.text((x0 + x1) / 2, TOP * 0.965, label, ha="center", va="bottom", + color=col, fontsize=8.5, fontweight="bold") + + ax.bar(x, fps, width=0.62, color=colors, zorder=3, linewidth=0) + + # The H2D floor: 61 859 FPS on f64 (the nsys estimate, which this arm never + # reached -- opt6 stops 5.4 % short) and 61 312 on f32 (the best rate actually + # sustained). One band at this scale. + ax.axhspan(61312, 61859, color=GREEN, alpha=0.20, zorder=1) + ax.axhline(61859, color=GREEN, lw=1.2, ls="--", zorder=4) + ax.text(-0.42, 63200, "H2D floor · 61–62 k FPS · the GPU cannot be fed faster", + color=GREEN, fontsize=8.5, ha="left", va="bottom") + + for xi, (f, s) in enumerate(zip(fps, spd)): + ax.text(xi, f + 1100, f"{f:,}", ha="center", va="bottom", + color=PALE, fontsize=10.5, fontweight="bold") + ax.text(xi, f - 1600, ("base" if s == 1.0 else f"×{s:.2f}"), + ha="center", va="top", color=BG, fontsize=9, fontweight="bold") + + ax.set_xticks(x) + ax.set_xticklabels(steps, fontsize=8.5, color=TEXT2) + ax.set_ylim(0, TOP) ax.set_yticks([]) bare(ax, keep=("bottom",)) ax.spines["bottom"].set_color(RULE) - ax.set_ylabel("") - ax.text(0, 45500, "frames / second · 3×3 clusters, 100 000 frames, warm run", - color=MUTED, fontsize=9, ha="left") + ax.set_title("frames / second · 3×3 clusters, 100 000 frames, warm run · " + "every bar against the best CPU configuration (24 threads)", + color=MUTED, fontsize=9, loc="left", pad=8) save(fig, "fig_arc") -# ------------------------------------------------- 2. host overhead collapse -def fig_overhead(): - steps = ["opt1", "opt2", "opt3", "opt4"] - ovhd = [44, 21, 14, 3] - kern = [23, 22, 24, 24] +# --------------------------------------------------- 2. the arc, 9x9 +def fig_arc_9x9(): + """9x9 is where Acts II and III actually pay. Same axes, different regime.""" + steps = ["CPU MT\n32 threads", "opt3\nno barriers", "opt4\npinned", + "opt5\nhost overlap", "opt6\nzero-copy", "opt7\nf32 kernel"] + # Best thread count at 9x9 is 32, not the 24 that wins at 3x3: the drain of + # ClusterCollector scales with thread count and 9x9 clusters are 9x larger, + # so the two sizes optimize differently (cpu_threads.csv). + # cap 1700, not 1500: the campaign's 9x9 cap was BELOW the per-frame maximum + # (1633) and silently truncated 0.0095 % of clusters. results/2026-08-20_*. + fps = [1503, 12129, 12527, 15063, 33323, 39775] + spd = [1.0, 8.07, 8.33, 10.02, 22.17, 26.46] + colors = [MUTED] + [ACCENT] * 2 + [PALE] * 2 + [AMBER] - fig, ax = plt.subplots(figsize=(5.6, 3.0)) + fig, ax = plt.subplots(figsize=(11.4, 3.8)) x = np.arange(len(steps)) - ax.bar(x, kern, width=0.55, color=ACCENT, zorder=3, label="kernel (GPU)") - ax.bar(x, ovhd, width=0.55, bottom=kern, color=AMBER, zorder=3, - label="host + PCIe overhead") - for xi, (k, o) in enumerate(zip(kern, ovhd)): - ax.text(xi, k + o + 1.6, f"{o} µs", ha="center", color=AMBER, - fontsize=10, fontweight="bold") - ax.set_xticks(x); ax.set_xticklabels(steps, color=TEXT2) - ax.set_ylabel("µs / frame", color=TEXT2) - ax.set_ylim(0, 78) - bare(ax) - ax.legend(frameon=False, fontsize=8.5, labelcolor=TEXT2, loc="upper right") + TOP = 56000 + + for x0, x1, label, col in [(-0.5, 2.5, "ACT I", ACCENT), + (2.5, 4.5, "ACT II", PALE), + (4.5, 5.5, "ACT III", AMBER)]: + ax.axvspan(x0, x1, color=col, alpha=0.05, zorder=0) + ax.plot([x0 + 0.08, x1 - 0.08], [TOP * 0.955] * 2, color=col, lw=2.2, + zorder=2) + ax.text((x0 + x1) / 2, TOP * 0.965, label, ha="center", va="bottom", + color=col, fontsize=8.5, fontweight="bold") + + ax.bar(x, fps, width=0.58, color=colors, zorder=3, linewidth=0) + + # Here the floor MOVES -- and CHANGES ENGINE, which is the point of Act III. + # + # On both arms the sustained rate beats the profiled estimate, so the sustained + # rate IS the floor (see module docstring); the probe's independent estimates + # are printed alongside so the floor is not merely the best bar restated. + # + # At cap 1700 the f64 kernel (32.66 us) is taller than BOTH transfers, so it + # still binds. opt7 cuts it 40 % to 23.94 -- below the 25.24 us D2H bar -- so + # the f32 floor is D2H, not the kernel. The kernel optimization succeeded so + # completely that it handed the constraint to the result path. + ax.hlines(33323, -0.5, 4.5, color=GREEN, lw=1.3, ls="--", zorder=4) + ax.text(-0.42, 33900, "f64 KERNEL floor · 33 323 FPS (nsys estimates 30 621)", + color=GREEN, fontsize=8.5, va="bottom") + ax.hlines(39775, 4.5, 5.5, color=AMBER, lw=1.3, ls="--", zorder=4) + ax.text(5.46, 45600, "f32 D2H floor · 39 775 FPS\n(nsys estimates 39 614)", + color=AMBER, fontsize=8.5, ha="right", va="bottom", linespacing=1.35) + ax.add_patch(FancyArrowPatch((4.62, 34100), (4.62, 39100), arrowstyle="-|>", + mutation_scale=11, color=AMBER, lw=1.5, zorder=5)) + ax.text(3.30, 37600, "−40 % kernel → D2H binds instead", color=AMBER, + fontsize=9, ha="center", va="center", fontweight="bold") + + for xi, (f, s) in enumerate(zip(fps, spd)): + ax.text(xi, f + 800, f"{f:,}", ha="center", va="bottom", + color=PALE, fontsize=10.5, fontweight="bold") + ax.text(xi, f - 1100, ("base" if s == 1.0 else f"×{s:.2f}"), + ha="center", va="top", color=BG, fontsize=9, fontweight="bold") + + ax.set_xticks(x) + ax.set_xticklabels(steps, fontsize=8.5, color=TEXT2) + ax.set_ylim(0, TOP) + ax.set_yticks([]) + bare(ax, keep=("bottom",)) + ax.spines["bottom"].set_color(RULE) + ax.set_title("frames / second · 9×9, 20 000 frames, cap 1700 (lossless) · " + "best CPU configuration (32 threads) · opt1/opt2 are 3×3 only", + color=MUTED, fontsize=9, loc="left", pad=8) + save(fig, "fig_arc_9x9") + + +# ------------------------------------------- 3. where the time actually went +def fig_overhead(): + """GPU floor vs host excess. Act II collapses the host bar; Act III lowers + the floor underneath it.""" + fig, axes = plt.subplots(1, 2, figsize=(11.4, 3.2)) + + panels = [ + # Floor = PEAK as the deck defines it (lower of probe and best sustained), + # so opt6/opt7 sit exactly ON their floor rather than under it. 9x9 is the + # cap-1700 re-take, where the f32 floor is D2H and not the kernel. + (axes[0], "3×3 · H2D-bound", + ["opt3", "opt4", "opt5", "opt6", "opt7"], + [16.17, 16.17, 16.17, 16.17, 16.31], # floor + [34.26, 25.98, 19.84, 17.10, 16.31], 48), # measured + (axes[1], "9×9 · kernel-bound, then D2H", + ["opt3", "opt4", "opt5", "opt6", "opt7"], + [30.01, 30.01, 30.01, 30.01, 25.14], + [82.44, 79.83, 66.39, 30.01, 25.14], 116), + ] + + for ax, title, steps, floor, meas, ymax in panels: + x = np.arange(len(steps)) + excess = [max(0.0, m - f) for m, f in zip(meas, floor)] + cols = [ACCENT, ACCENT, PALE, PALE, AMBER] + ax.bar(x, floor, width=0.56, color=cols, zorder=3, linewidth=0) + ax.bar(x, excess, width=0.56, bottom=floor, color=MUTED, zorder=3, + linewidth=0, alpha=0.55) + for xi, (f, e, m) in enumerate(zip(floor, excess, meas)): + ax.text(xi, m + ymax * 0.022, f"{m:.1f}", ha="center", color=PALE, + fontsize=9.5, fontweight="bold") + if e > ymax * 0.05: + ax.text(xi, f + e / 2, f"+{e:.0f}", ha="center", va="center", + color=BG, fontsize=8.5, fontweight="bold") + ax.set_xticks(x) + ax.set_xticklabels(steps, color=TEXT2, fontsize=9) + ax.set_ylim(0, ymax) + ax.set_yticks([]) + bare(ax, keep=("bottom",)) + ax.set_title(title, color=PALE, fontsize=10, pad=8, loc="left") + + axes[0].set_ylabel("µs / frame", color=TEXT2) + # label the two segments in place — the floor takes the act colour, so a + # colour-keyed legend would be wrong + axes[0].text(0, 16.17 / 2, "GPU\nfloor", ha="center", va="center", color=BG, + fontsize=8, fontweight="bold") + axes[0].annotate("host excess", xy=(0.30, 25), xytext=(1.15, 41), + color=TEXT2, fontsize=8.5, + arrowprops=dict(arrowstyle="-", color=MUTED, lw=0.9)) + + axes[1].text(1.5, 108, "Act II removes the host bar", color=PALE, fontsize=9, + ha="center", fontweight="bold") + axes[1].add_patch(FancyArrowPatch((1.5, 104), (3.25, 34), arrowstyle="-|>", + mutation_scale=10, color=PALE, lw=1.3, + connectionstyle="arc3,rad=-0.22")) + axes[1].text(4.0, 52, "Act III lowers\nthe floor", color=AMBER, fontsize=9, + ha="center", fontweight="bold") + axes[1].add_patch(FancyArrowPatch((4.0, 46), (4.0, 27), arrowstyle="-|>", + mutation_scale=10, color=AMBER, lw=1.3)) save(fig, "fig_overhead") -# ------------------------------------------------------ 3. streams timeline +# ------------------------------------------------------ 4. streams timeline def fig_streams(): fig, axes = plt.subplots(3, 1, figsize=(7.7, 3.9)) H, K, D = 12, 22, 12 @@ -149,10 +468,10 @@ def fig_streams(): save(fig, "fig_streams") -# ------------------------------------------------------------- 4. pinning +# ------------------------------------------------------------- 5. pinning def fig_pinning(): fig = plt.figure(figsize=(7.7, 3.0)) - ax = fig.add_axes([0, 0.05, 0.63, 0.95]); ax.axis("off") + ax = fig.add_axes([0, 0.05, 0.60, 0.95]); ax.axis("off") ax.set_xlim(0, 10.4); ax.set_ylim(0, 6.4) def box(x, y, w, h, label, sub=""): @@ -184,23 +503,32 @@ def fig_pinning(): ax.text(0, 0.42, "no staging copy, no page faults, fully async", color=MUTED, fontsize=7) - ax2 = fig.add_axes([0.75, 0.16, 0.25, 0.66]) - v = [14, 3] - ax2.bar([0, 1], v, width=0.55, color=[AMBER, ACCENT], zorder=3) - for i, val in enumerate(v): - ax2.text(i, val + 0.5, f"{val} µs", ha="center", color=PALE, - fontsize=10, fontweight="bold") - ax2.set_xticks([0, 1]) - ax2.set_xticklabels(["opt3\npageable", "opt4\npinned"], color=TEXT2, fontsize=8) - ax2.set_ylim(0, 18); ax2.set_yticks([]); bare(ax2, keep=("bottom",)) - ax2.set_title("host overhead / frame", color=MUTED, fontsize=7.5, pad=6) + # the rule, in one inset: pinning pays only where H2D is the tallest bar + ax2 = fig.add_axes([0.70, 0.16, 0.30, 0.62]) + x = np.arange(2) + before = [34.26, 82.17] + after = [25.98, 80.44] + ax2.bar(x - 0.19, before, width=0.36, color=AMBER, zorder=3, label="opt3") + ax2.bar(x + 0.19, after, width=0.36, color=ACCENT, zorder=3, label="opt4") + for xi, (b, a) in enumerate(zip(before, after)): + ax2.text(xi - 0.19, b + 2, f"{b:.0f}", ha="center", color=TEXT2, fontsize=7.5) + ax2.text(xi + 0.19, a + 2, f"{a:.0f}", ha="center", color=TEXT2, fontsize=7.5) + ax2.text(xi, 92, f"×{b / a:.2f}", ha="center", color=PALE, fontsize=9, + fontweight="bold") + ax2.set_xticks(x) + ax2.set_xticklabels(["3×3\nH2D-bound", "9×9\nkernel-bound"], color=TEXT2, + fontsize=7.5) + ax2.set_ylim(0, 104); ax2.set_yticks([]); bare(ax2, keep=("bottom",)) + ax2.set_title("µs / frame", color=MUTED, fontsize=7.5, pad=6) + ax2.legend(frameon=False, fontsize=7, labelcolor=TEXT2, loc="center left") save(fig, "fig_pinning") -# -------------------------------------------------------------- 5. graphs +# ----------------------------------------------- 6. graphs (rejected route) def fig_graphs(): - fig, ax = plt.subplots(figsize=(7.7, 2.6)) - ax.axis("off"); ax.set_xlim(0, 12.6); ax.set_ylim(0, 4.6) + fig = plt.figure(figsize=(7.7, 3.0)) + ax = fig.add_axes([0, 0.30, 0.66, 0.70]) + ax.axis("off"); ax.set_xlim(0, 11.2); ax.set_ylim(0, 4.6) def node(x, y, w, h, t, fc): ax.add_patch(Rectangle((x, y), w, h, facecolor=fc, edgecolor="none")) @@ -217,47 +545,104 @@ def fig_graphs(): ax.add_patch(FancyArrowPatch((x + 0.7, 3.72), (x + 0.7, 3.52), arrowstyle="-|>", mutation_scale=7, color=MUTED, lw=0.9)) - ax.text(12.5, 3.15, "CPU cost\n≈ 6 launches", ha="right", va="center", + ax.text(11.1, 3.15, "CPU cost\n≈ 6 launches", ha="right", va="center", color=MUTED, fontsize=7.5) - ax.text(0, 2.18, "WITH GRAPHS · opt5 · record once, replay with one launch", + ax.text(0, 2.18, "WITH GRAPHS · record once, replay with one launch", color=ACCENT, fontsize=8.5, fontweight="bold") - ax.add_patch(Rectangle((0.1, 0.72), 9.85, 1.15, facecolor=PANEL, + ax.add_patch(Rectangle((0.1, 0.72), 9.20, 1.15, facecolor=PANEL, edgecolor=ACCENT, lw=1.2)) for i, (t, c) in enumerate(ops): - node(0.38 + i * 1.58, 0.98, 1.34, 0.6, t, c) + node(0.32 + i * 1.48, 0.98, 1.26, 0.6, t, c) ax.add_patch(FancyArrowPatch((0.8, 2.02), (0.8, 1.90), arrowstyle="-|>", mutation_scale=8, color=ACCENT, lw=1.3)) - ax.text(12.5, 1.30, "CPU cost\n≈ 1 launch", ha="right", va="center", + ax.text(11.1, 1.30, "CPU cost\n≈ 1 launch", ha="right", va="center", color=ACCENT, fontsize=7.5, fontweight="bold") - ax.text(0.1, 0.32, "cudaGraphLaunch() — the whole DAG is submitted as one unit; " - "the driver already knows every dependency", - color=MUTED, fontsize=7) + + # the verdict + ax2 = fig.add_axes([0.72, 0.30, 0.28, 0.62]) + x = np.arange(2) + opt4 = [25.98, 80.44] + graph = [25.16, 94.78] + ax2.bar(x - 0.19, opt4, width=0.36, color=ACCENT, zorder=3, label="opt4") + ax2.bar(x + 0.19, graph, width=0.36, color=AMBER, zorder=3, label="graphs") + for xi, (a, g) in enumerate(zip(opt4, graph)): + ax2.text(xi - 0.19, a + 2.5, f"{a:.0f}", ha="center", color=TEXT2, fontsize=7.5) + ax2.text(xi + 0.19, g + 2.5, f"{g:.0f}", ha="center", color=TEXT2, fontsize=7.5) + ax2.text(1, 108, "18 % SLOWER", ha="center", color=AMBER, fontsize=8, + fontweight="bold") + ax2.set_xticks(x) + ax2.set_xticklabels(["3×3", "9×9"], color=TEXT2, fontsize=8) + ax2.set_ylim(0, 120); ax2.set_yticks([]); bare(ax2, keep=("bottom",)) + ax2.set_title("µs / frame", color=MUTED, fontsize=7.5, pad=6) + ax2.legend(frameon=False, fontsize=7, labelcolor=TEXT2, loc="upper left") + + fig.text(0.02, 0.10, + "REJECTED — launch overhead stops binding one step later. The graph finder " + "never got the chunked pipeline of opt5,\nand its original advantage is " + "swamped by an overlap it does not have.", + color=AMBER, fontsize=8, fontweight="bold", va="top") save(fig, "fig_graphs") -# ------------------------------------------------ 6. f32 kernel (nsys truth) +# -------------------------------------- 7. the result path (Act II, opt5/opt6) +def fig_resultpath(): + """Why zero-copy is worth x1.16 at 3x3 and x2.21 at 9x9: whether the host + copy fits underneath the GPU floor.""" + fig, (a1, a2) = plt.subplots(1, 2, figsize=(11.4, 3.2)) + + for ax, title, floor, copy_us, gain, verdict, col in [ + (a1, "3×3 · 93 kB / frame", 16.17, 8.0, "×1.16", + "copy hides under the GPU\n→ small win", ACCENT), + (a2, "9×9 · 467 kB / frame", 30.01, 40.0, "×2.21", + "copy is larger than the GPU\n→ cannot hide at any overlap", AMBER), + ]: + ax.bar([0], [floor], width=0.5, color=ACCENT, zorder=3, linewidth=0) + ax.bar([1], [copy_us], width=0.5, color=col, zorder=3, linewidth=0) + ax.axhline(floor, color=GREEN, lw=1.3, ls="--", zorder=4) + ax.text(-0.55, floor + 1.2, "GPU floor", color=GREEN, fontsize=8.5, ha="left") + ax.text(0, floor + 1.4, f"{floor:.1f} µs", ha="center", color=PALE, + fontsize=10, fontweight="bold") + ax.text(1, copy_us + 1.4, f"≈{copy_us:.0f} µs", ha="center", color=PALE, + fontsize=10, fontweight="bold") + ax.set_xticks([0, 1]) + ax.set_xticklabels(["GPU per frame\n(H2D ∥ kernel ∥ D2H)", + "host copy per frame\ncollect() memcpy + malloc"], + color=TEXT2, fontsize=8.5) + ax.set_xlim(-0.6, 1.7); ax.set_ylim(0, 52); ax.set_yticks([]) + bare(ax, keep=("bottom",)) + ax.set_title(title, color=PALE, fontsize=10, pad=10, loc="left") + ax.text(1.68, 46, gain, color=col, fontsize=16, fontweight="bold", ha="right") + ax.text(1.68, 40, "opt5 → opt6", color=MUTED, fontsize=8, ha="right") + ax.text(-0.55, -11, verdict, color=col, fontsize=8.5, fontweight="bold", + va="top") + fig.subplots_adjust(bottom=0.30) + save(fig, "fig_resultpath") + + +# ------------------------------------------------ 8. f32 kernel (nsys truth) def fig_f32_kernel(): fig, (ax, ax2) = plt.subplots(1, 2, figsize=(7.7, 2.6), gridspec_kw={"width_ratios": [1, 1.35]}) - v = [43.0, 25.6] + v = [39.93, 23.67] ax.bar([0, 1], v, width=0.5, color=[AMBER, ACCENT], zorder=3) for i, val in enumerate(v): - ax.text(i, val + 1.2, f"{val} µs", ha="center", color=PALE, + ax.text(i, val + 1.2, f"{val:.1f} µs", ha="center", color=PALE, fontsize=11, fontweight="bold") - ax.annotate("", xy=(1, 27.5), xytext=(0, 44.5), + ax.annotate("", xy=(1, 25.5), xytext=(0, 41.5), arrowprops=dict(arrowstyle="-|>", color=MUTED, lw=1.2, connectionstyle="arc3,rad=-0.25")) - ax.text(0.5, 37, "−40%", ha="center", color=PALE, fontsize=10, + ax.text(0.5, 34, "−40.7 %", ha="center", color=PALE, fontsize=10, fontweight="bold") ax.set_xticks([0, 1]); ax.set_xticklabels(["f64 pedestal", "f32 pedestal"], color=TEXT2, fontsize=8.5) - ax.set_ylim(0, 52); ax.set_yticks([]); bare(ax, keep=("bottom",)) - ax.set_title("kernel, exclusive (nsys, 9×9)", color=MUTED, fontsize=8, pad=8) + ax.set_ylim(0, 50); ax.set_yticks([]); bare(ax, keep=("bottom",)) + ax.set_title("kernel, exclusive (nsys, 9×9, 1 stream)", color=MUTED, + fontsize=8, pad=8) labels = ["kernel", "D2H", "H2D"] - f64 = [43.0, 19.4, 13.2] - f32 = [25.6, 19.8, 13.5] + f64 = [39.93, 19.44, 13.24] + f32 = [23.67, 19.44, 13.24] y = np.arange(3); h = 0.35 ax2.barh(y + h / 2, f64, height=h, color=AMBER, zorder=3, label="f64 ped") ax2.barh(y - h / 2, f32, height=h, color=ACCENT, zorder=3, label="f32 ped") @@ -265,14 +650,15 @@ def fig_f32_kernel(): ax2.text(a + 1, yi + h / 2, f"{a:.1f}", va="center", color=TEXT2, fontsize=8) ax2.text(b + 1, yi - h / 2, f"{b:.1f}", va="center", color=TEXT2, fontsize=8) ax2.set_yticks(y); ax2.set_yticklabels(labels, color=TEXT2, fontsize=8.5) - ax2.invert_yaxis(); ax2.set_xlim(0, 56); ax2.set_xticks([]) + ax2.invert_yaxis(); ax2.set_xlim(0, 52); ax2.set_xticks([]) bare(ax2, keep=("left",)) ax2.legend(frameon=False, fontsize=8, labelcolor=TEXT2, loc="lower right") - ax2.set_title("per-frame GPU operations (µs)", color=MUTED, fontsize=8, pad=8) + ax2.set_title("per-frame GPU operations (µs) — only the kernel moves", + color=MUTED, fontsize=8, pad=8) save(fig, "fig_f32_kernel") -# ------------------------------------------------------- 7. cancellation +# --------------------------------------------------------- 9. cancellation def fig_cancellation(): fig, (ax, ax2) = plt.subplots(1, 2, figsize=(7.7, 2.7), gridspec_kw={"width_ratios": [1.25, 1]}) @@ -306,58 +692,359 @@ def fig_cancellation(): save(fig, "fig_cancellation") -# ------------------------------------------------- 8. where f32 pays or not +# ------------------------------- 10. the same kernel, measured five ways def fig_bottleneck(): - fig, (a1, a2) = plt.subplots(1, 2, figsize=(11.4, 3.0)) + """Why Act III comes last -- and why it is not even MEASURABLE before Act II. - for ax, title, kern, floor, gain in [ - (a1, "3×3 clusters — pipeline-bound", (24, 13), 25, - "kernel already hidden → 0% end-to-end"), - (a2, "9×9 clusters — kernel-bound (f64)", (43, 26), 32, - "kernel on the critical path → −8% wall"), - ]: - x = [0, 1] - ax.bar(x, kern, width=0.5, color=[AMBER, ACCENT], zorder=3) - ax.axhline(floor, color=PALE, lw=1.4, ls="--", zorder=4) - ax.text(1.62, floor + 1.2, "transfer + host floor", color=PALE, - fontsize=8, ha="right") - for i, v in enumerate(kern): - ax.text(i, v + 1.2, f"{v} µs", ha="center", color=PALE, - fontsize=10, fontweight="bold") - ax.set_xticks(x); ax.set_xticklabels(["f64 pedestal", "f32 pedestal"], - color=TEXT2, fontsize=9) - ax.set_xlim(-0.6, 1.7); ax.set_ylim(0, 55); ax.set_yticks([]) - bare(ax, keep=("bottom",)) - ax.set_title(title, color=PALE, fontsize=9.5, pad=10) - ax.text(-0.55, -9, gain, color=AMBER if "0%" in gain else ACCENT, - fontsize=8.5, fontweight="bold") - fig.subplots_adjust(bottom=0.22) + The quantity is the end-to-end change from the identical -40 % kernel, measured + through each surviving step's result path at 9x9 (route A is annex-only). Each + step was run 5 times per arm; the bar is the point estimate from best-of-warm + and the whisker is what the two arms' own rep spreads allow. + + Through collect_view() the interval is 0.0 points wide. Through every allocating + path it is 19-41 points wide and straddles zero: the result path does not merely + shrink the kernel win, it destroys the ability to observe it at all. That is a + stronger statement than the point estimates were, and unlike them it is robust. + """ + fig, ax = plt.subplots(figsize=(11.4, 3.0)) + steps = ["opt3\nno overlap", "opt4\npinned", + "opt5\nhost overlap", "opt6\nzero-copy"] + pt = [16.0, -5.8, -6.8, -16.2] + lo = [1.6, -17.2, -23.9, -16.2] + hi = [20.3, 11.9, 17.2, -16.2] + cols = [MUTED, MUTED, MUTED, AMBER] + x = np.arange(len(steps)) + + ax.axhline(0, color=RULE, lw=1.2, zorder=1) + for xi, (p_, l_, h_, c) in enumerate(zip(pt, lo, hi, cols)): + ax.plot([xi, xi], [l_, h_], color=c, lw=7, alpha=0.30, + solid_capstyle="butt", zorder=2) + ax.plot([xi - 0.13, xi + 0.13], [p_, p_], color=c, lw=2.6, zorder=3) + if h_ - l_ < 1: + ax.text(xi, p_ - 5.0, f"{p_:.1f}%", ha="center", color=PALE, + fontsize=11.5, fontweight="bold") + ax.text(xi, p_ + 2.0, "resolvable to 0.0 pts", ha="center", + color=AMBER, fontsize=8) + else: + ax.text(xi, h_ + 1.6, f"{l_:+.0f} … {h_:+.0f}%", ha="center", + color=TEXT2, fontsize=9.5) + + ax.set_xticks(x); ax.set_xticklabels(steps, color=TEXT2, fontsize=9) + ax.set_xlim(-0.6, 3.6) + ax.set_ylim(-34, 30) + ax.set_yticks([-20, 0, 20]) + ax.set_yticklabels(["−20 %", "0", "+20 %"], fontsize=8) + bare(ax, keep=("left", "bottom")) + ax.spines["bottom"].set_color(RULE) + ax.set_title("end-to-end change from the SAME −40 % kernel, 9×9 · " + "bar = measurement, band = what the reps allow", + color=MUTED, fontsize=9, pad=10, loc="left") + ax.text(1.5, -29.5, "through an allocating result path the effect is not measurable " + "— the band straddles zero", color=MUTED, fontsize=8.5, + ha="center", va="center") + fig.subplots_adjust(bottom=0.24) save(fig, "fig_bottleneck") -# ----------------------------------------------------------- 9. correctness +# ---------------------------------------------------------- 11. correctness def fig_correctness(): - fig, ax = plt.subplots(figsize=(7.4, 2.4)) - names = ["CPU MT", "opt1", "opt2", "opt3", "opt4", "opt5", "opt6 (f32)"] - diff = [0.0, 0.0040, 0.0035, 0.0035, 0.0035, 0.0035, 0.0039] - colors = [MUTED] + [ACCENT] * 5 + [AMBER] + fig, ax = plt.subplots(figsize=(7.4, 2.05)) + names = ["CPU MT", "opt1", "opt2", "opt3–opt6\n(f64)", "opt7\n(f32)"] + diff = [0.0, 0.0041, 0.0039, 0.0039, 0.0039] + colors = [MUTED, ACCENT, ACCENT, PALE, AMBER] x = np.arange(len(names)) - ax.bar(x, diff, width=0.55, color=colors, zorder=3) + ax.bar(x, diff, width=0.5, color=colors, zorder=3) for xi, d in enumerate(diff): ax.text(xi, d + 0.00022, ("reference" if d == 0 else f"{d:.4f}%"), ha="center", color=PALE if d else MUTED, fontsize=8.5) ax.axhline(0.01, color=PALE, lw=1.2, ls="--") - ax.text(6.4, 0.0104, "0.01% — well inside statistical noise", color=PALE, + ax.text(4.4, 0.0104, "0.01% — well inside statistical noise", color=PALE, fontsize=8, ha="right") ax.set_xticks(x); ax.set_xticklabels(names, color=TEXT2, fontsize=8.5) ax.set_ylim(0, 0.0125); ax.set_yticks([]) bare(ax, keep=("bottom",)) - ax.set_title("cluster-count difference vs CPU · 233 million clusters, 3×3", + ax.set_title("cluster-count difference vs CPU · 233 M clusters, 3×3", color=MUTED, fontsize=8.5, pad=8) save(fig, "fig_correctness") -for f in (fig_arc, fig_overhead, fig_streams, fig_pinning, fig_graphs, - fig_f32_kernel, fig_cancellation, fig_bottleneck, fig_correctness): +for f in (fig_arc, fig_arc_9x9, fig_first_run, fig_overhead, fig_streams, fig_pinning, + fig_graphs, fig_resultpath, fig_f32_kernel, fig_cancellation, + fig_bottleneck, fig_correctness): f() print("done ->", OUT) + + +# ------------------------------------------------- 12. opt5: host/GPU overlap +def fig_overlap(): + """What `submit(i+1)` before `collect(i)` actually does to the timeline. + + Two lanes, GPU and HOST, drawn for the same four chunks under both schedules. + Serial: the host may only start chunk i once chunk i has come back, so the + lanes never coexist and the frame costs GPU + host. Pipelined: chunk i+1 is + submitted before chunk i is collected, so the lanes run together and the + frame costs max(GPU, host) -- the saving is min(GPU, host), which is why it + pays most when the two terms are comparable and least when one dominates. + """ + fig, ax = plt.subplots(figsize=(11.6, 3.5)) + G, H = 2.6, 1.7 # chunk durations, arbitrary but 3x3-like proportions + n = 4 + lane_h = 0.42 + + def block(x, y, w, col, txt, tcol=BG): + ax.add_patch(Rectangle((x, y), w, lane_h, facecolor=col, edgecolor=BG, + linewidth=1.4, zorder=3)) + ax.text(x + w / 2, y + lane_h / 2, txt, ha="center", va="center", + color=tcol, fontsize=8.5, fontweight="bold", zorder=4) + + # ---- serial: G H G H G H G H, strictly alternating ---------------------- + yG, yH = 3.30, 2.78 + t = 0.0 + for i in range(n): + block(t, yG, G, ACCENT, f"GPU {i+1}") + t += G + block(t, yH, H, PALE, f"host {i+1}") + t += H + serial_end = t + + # ---- pipelined: GPU back-to-back, host one chunk behind ---------------- + yG2, yH2 = 1.35, 0.83 + for i in range(n): + block(i * G, yG2, G, ACCENT, f"GPU {i+1}") + for i in range(n): + block(G + i * G, yH2, H, PALE, f"host {i+1}") + pipe_end = G * n + H + + for y, lbl in ((yG, "GPU"), (yH, "host"), (yG2, "GPU"), (yH2, "host")): + ax.text(-0.25, y + lane_h / 2, lbl, ha="right", va="center", + color=TEXT2, fontsize=9) + + ax.text(-0.25, yG + lane_h + 0.30, "submit → collect, serialized (opt4)", + ha="left", va="bottom", color=TEXT2, fontsize=9.5, fontweight="bold") + ax.text(-0.25, yG2 + lane_h + 0.30, + "submit(i+1) before collect(i) (opt5)", + ha="left", va="bottom", color=AMBER, fontsize=9.5, fontweight="bold") + + for x, y0, y1, col in ((serial_end, yH, yG + lane_h, MUTED), + (pipe_end, yH2, yG2 + lane_h, AMBER)): + ax.plot([x, x], [y0 - 0.18, y1 + 0.18], color=col, lw=1.2, ls="--", + zorder=5) + + ax.annotate("", xy=(pipe_end, 0.42), xytext=(serial_end, 0.42), + arrowprops=dict(arrowstyle="<|-|>", color=AMBER, lw=1.5)) + ax.text((pipe_end + serial_end) / 2, 0.20, + "saved: min(GPU, host) per chunk", ha="center", va="top", + color=AMBER, fontsize=9.5, fontweight="bold") + + ax.text(serial_end + 0.25, yG + lane_h / 2, "GPU + host per chunk", + ha="left", va="center", color=MUTED, fontsize=9) + ax.text(pipe_end + 0.25, yG2 + lane_h / 2, "max(GPU, host) per chunk", + ha="left", va="center", color=AMBER, fontsize=9, fontweight="bold") + + ax.set_xlim(-1.6, serial_end + 4.0) + ax.set_ylim(0, 4.25) + ax.axis("off") + save(fig, "fig_overlap") + + +# ------------------------------------- 13. pedestal update timing, three ways +def fig_pedtiming(): + """Why ClusterFinderFrozen exists: the one variable it holds still. + + Within a single frame the serial CPU finder updates the pedestal AS the raster + scan passes each pixel, so a decision late in the frame is taken against a + pedestal that already contains this frame's earlier pixels. Frozen and CUDA + both decide against the frame-start snapshot and apply every update at the + frame boundary. + + That makes the comparison factorable: cpu-vs-frozen isolates update TIMING with + the arithmetic held fixed, and frozen-vs-cuda isolates the PORT with the timing + held fixed. Comparing cuda straight to the serial CPU confounds the two. + """ + fig, ax = plt.subplots(figsize=(11.6, 3.6)) + XF, XE = 6.0, 7.4 # frame end, dotted continuation end + rows = [("ClusterFinder\nserial CPU", 2.75, ACCENT, True), + ("ClusterFinderFrozen\nCPU twin", 1.55, PALE, False), + ("ClusterFinderCUDA", 0.35, AMBER, False)] + + for label, y, col, stair in rows: + ax.text(-0.35, y + 0.26, label, ha="right", va="center", color=TEXT2, + fontsize=9.5, linespacing=1.35) + ax.plot([0, XE], [y, y], color=RULE, lw=1.0, zorder=1) + + if stair: + xs = np.linspace(0, XF, 22) + ys = y + 0.10 + 0.42 * xs / XF + ax.step(xs, ys, where="post", color=col, lw=2.2, zorder=3) + ax.plot([XF, XE], [y + 0.52, y + 0.62], color=col, lw=2.0, ls=":", + zorder=3) + ax.text(XF / 2, y + 0.64, "the pedestal moves DURING the scan", + ha="center", va="bottom", color=col, fontsize=9, + fontweight="bold") + ax.text(XF / 2, y - 0.13, + "a pixel late in the frame is judged against a pedestal that\n" + "already contains this frame's earlier pixels", + ha="center", va="top", color=MUTED, fontsize=8, + linespacing=1.4) + else: + ax.plot([0, XF], [y + 0.10] * 2, color=col, lw=2.2, zorder=3) + ax.plot([XF, XF], [y + 0.10, y + 0.52], color=col, lw=2.2, zorder=3) + ax.plot([XF, XE], [y + 0.52] * 2, color=col, lw=2.0, ls=":", zorder=3) + ax.text(XF / 2, y + 0.18, "every decision uses the frame-start snapshot", + ha="center", va="bottom", color=col, fontsize=9, + fontweight="bold") + + ax.axvline(XF, color=MUTED, lw=1.1, ls="--", zorder=2, ymin=0.02, ymax=0.93) + ax.text(XF / 2, 3.80, "one frame · 160 000 pixels in raster order", + ha="center", va="bottom", color=MUTED, fontsize=9.5) + ax.text(XF + 0.12, 3.80, "frame ends →\nupdates applied", ha="left", + va="bottom", color=MUTED, fontsize=8.5, linespacing=1.35) + + # what the middle row buys: two comparisons, one variable each + XA = 8.35 + ax.annotate("", xy=(XA, 1.72), xytext=(XA, 2.92), + arrowprops=dict(arrowstyle="<|-|>", color=PALE, lw=1.5)) + ax.text(XA + 0.18, 2.32, "update TIMING\narithmetic held fixed", ha="left", + va="center", color=PALE, fontsize=9, fontweight="bold", + linespacing=1.4) + ax.annotate("", xy=(XA, 0.52), xytext=(XA, 1.72), + arrowprops=dict(arrowstyle="<|-|>", color=AMBER, lw=1.5)) + ax.text(XA + 0.18, 1.12, "the PORT\ntiming held fixed", ha="left", + va="center", color=AMBER, fontsize=9, fontweight="bold", + linespacing=1.4) + + ax.set_xlim(-2.9, 12.0) + ax.set_ylim(-0.35, 4.30) + ax.axis("off") + save(fig, "fig_pedtiming") + + +fig_overlap() +fig_pedtiming() + + +# --------------------------------------- 14. frame 147, frozen | cuda, masked +def fig_mismatch147(): + """The mismatch as the notebook shows it: two masked frames, side by side. + + Only the 3x3 footprints of each finder's OWN centres are drawn; everything + else is blank, so the panels differ exactly where the finders do. Values are + pedestal-subtracted ADU under that finder's decision-time pedestal, and the + amber ring marks the one centre cuda keeps that frozen does not. + + Data: frame147.json, written by scratchpad/dump147.py. + """ + import json + d = json.loads((Path(__file__).resolve().parent + / "frame147.json").read_text()) + X0, Y0 = d["x0"], d["y0"] + only = {tuple(p) for p in d["only_cuda"]} + + sub = {"frozen": np.array(d["sub_frozen"]), "cuda": np.array(d["sub_cuda"])} + msk = {"frozen": np.array(d["mask_frozen"]), "cuda": np.array(d["mask_cuda"])} + cen = {"frozen": d["c_frozen"], "cuda": d["c_cuda"]} + + union = msk["frozen"] | msk["cuda"] + vmax = max(float(np.percentile(sub["frozen"][union], 99)), 50.0) + + cmap = plt.cm.viridis.copy() + cmap.set_bad(PANEL) + + fig, axes = plt.subplots(1, 2, figsize=(11.2, 4.3)) + for ax, name, col in ((axes[0], "frozen", PALE), (axes[1], "cuda", ACCENT)): + w, m = sub[name], msk[name] + ax.imshow(np.ma.masked_where(~m, w), cmap=cmap, vmin=0, vmax=vmax, + interpolation="nearest", origin="upper") + for i in range(w.shape[0]): + for j in range(w.shape[1]): + if m[i, j]: + ax.text(j, i, f"{w[i, j]:.0f}", ha="center", va="center", + fontsize=4.6, + color="white" if w[i, j] < 0.55 * vmax else "black") + for (cx, cy) in cen[name]: + ax.plot(cx - X0, cy - Y0, ".", color="#FF4B4B", ms=6, zorder=5) + for (cx, cy) in only: + if name == "cuda": + ax.add_patch(plt.Circle((cx - X0, cy - Y0), 2.6, fill=False, + ec=AMBER, lw=2.0, zorder=6)) + else: + ax.add_patch(plt.Circle((cx - X0, cy - Y0), 2.6, fill=False, + ec=AMBER, lw=1.4, ls=":", zorder=6)) + ax.set_title(f"{name} — {d['n_' + name]:,} clusters in this frame", + color=col, fontsize=10, fontweight="bold", pad=7) + ax.set_xticks([]); ax.set_yticks([]) + for sp in ax.spines.values(): + sp.set_color(RULE) + + axes[0].text(-0.03, 0.5, f"frame {d['fid']}\nzoom on (202, 8)", + transform=axes[0].transAxes, rotation=90, ha="right", + va="center", color=MUTED, fontsize=8, linespacing=1.4) + # anchored in DATA coordinates: the window is no longer square (the cut is + # clipped by the top edge of the frame), so axes fractions do not track the + # pixel once matplotlib letterboxes the image to keep aspect. + (ox, oy), = only + axes[1].annotate("cuda keeps this one;\nfrozen does not", + xy=(ox - X0 + 2.9, oy - Y0), xytext=(0.99, 0.93), + xycoords="data", textcoords="axes fraction", + color=AMBER, fontsize=8.5, fontweight="bold", ha="right", + linespacing=1.35, + arrowprops=dict(arrowstyle="-|>", color=AMBER, lw=1.3)) + fig.subplots_adjust(wspace=0.06) + save(fig, "fig_mismatch147") + + +fig_mismatch147() + + +# ------------------------------------ 15. what fills an SM first, per cluster size +def fig_regpressure(): + """Occupancy is an OUTPUT. This is the input: what runs out first. + + One SM holds 65 536 registers and 1 536 thread slots. A 16x16 block is 256 + threads, so a block costs regs_per_thread x 256 registers and 256 slots. At + 3x3 the slots run out first and the register file still has room; at 9x9 the + register file is exactly full at two blocks, which strands two thirds of the + slots. Same kernel, same block size, opposite binding resource. + """ + rows = [ + ("3×3", 38, 6, ACCENT), + ("9×9", 128, 2, AMBER), + ] + fig, ax = plt.subplots(figsize=(11.2, 2.55)) + + y, ticks, labels = 0.0, [], [] + for name, regs, blocks, col in rows: + for kind, used, cap in (("register file", regs * 256 * blocks, 65536), + ("thread slots", 256 * blocks, 1536)): + frac = 100.0 * used / cap + seg = frac / blocks + for b in range(blocks): # one segment per block + ax.barh(y, seg - 0.5, left=b * seg + 0.25, height=0.52, + color=col, zorder=3, linewidth=0) + full = abs(frac - 100.0) < 0.6 + ax.text(frac + 1.5, y, f"{frac:.0f} %", va="center", fontsize=9, + color=PALE if full else TEXT2, + fontweight="bold" if full else "normal") + ticks.append(y); labels.append(f"{name} · {kind}") + y -= 1.0 + y -= 0.45 + + ax.axvline(100, color=MUTED, lw=1.1, ls="--", zorder=4) + ax.text(99, 1.05, "capacity of one SM", color=MUTED, fontsize=8.5, + ha="right") + ax.set_yticks(ticks) + ax.set_yticklabels(labels, color=TEXT2, fontsize=9) + ax.set_xlim(0, 152); ax.set_xticks([]) + ax.set_ylim(y + 0.6, 1.5) + bare(ax, keep=("left",)) + ax.tick_params(axis="y", length=0) + + ax.text(115, ticks[0] - 0.5, "6 blocks resident\n38 × 256 = 9 728 regs each", + color=ACCENT, fontsize=8.5, va="center", linespacing=1.4) + ax.text(115, ticks[2] - 0.5, "2 blocks resident\n128 × 256 = 32 768 regs each", + color=AMBER, fontsize=8.5, va="center", linespacing=1.4) + fig.subplots_adjust(left=0.16, right=0.99, top=0.88, bottom=0.06) + save(fig, "fig_regpressure") + + +fig_regpressure() diff --git a/docs/deck/make_figs_kernel.py b/docs/deck/make_figs_kernel.py new file mode 100644 index 00000000..a552ab00 --- /dev/null +++ b/docs/deck/make_figs_kernel.py @@ -0,0 +1,236 @@ +"""Figures for the fused deck — kernel/algorithm/occupancy half. + +Same palette and conventions as make_figs.py (deck palette, dark, tight bbox). +Writes into docs/figures/ alongside the optimization figures. + +Occupancy numbers are not hand-computed: they come from +cudaOccupancyMaxActiveBlocksPerMultiprocessor + cudaFuncGetAttributes on the +real kernel (RTX 4090, sm_89), measured 2026-08-11 — see the table in +build_fused_deck.py. +""" +import sys +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.colors import LinearSegmentedColormap +from matplotlib.patches import Rectangle, FancyArrowPatch +from pathlib import Path + +OUT = Path(__file__).resolve().parent.parent / "figures" +OUT.mkdir(exist_ok=True) + +BG = "#0B1018" +PANEL = "#121A28" +RULE = "#1E2836" +ACCENT = "#1E90C2" +AMBER = "#E8B25C" +PALE = "#E7EDF4" +TEXT2 = "#A5B2C4" +MUTED = "#6B7A90" + +plt.rcParams.update({ + "font.family": "DejaVu Sans", "font.size": 9, + "text.color": PALE, "axes.labelcolor": TEXT2, + "xtick.color": TEXT2, "ytick.color": TEXT2, + "axes.edgecolor": RULE, "axes.facecolor": "none", + "figure.facecolor": BG, "savefig.facecolor": BG, + "axes.grid": False, "svg.fonttype": "none", +}) + +# deck-native sequential map: background → accent → amber → white-hot +CMAP = LinearSegmentedColormap.from_list( + "deck", ["#080C12", "#10202F", ACCENT, AMBER, "#FFF3DC"]) + + +def save(fig, name): + fig.savefig(OUT / f"{name}.png", dpi=220, transparent=False, + bbox_inches="tight", pad_inches=0.08) + plt.close(fig) + print("wrote", name) + + +def bare(ax, keep=("left", "bottom")): + for s in ("top", "right", "left", "bottom"): + ax.spines[s].set_visible(s in keep) + + +# ------------------------------------------------ 1. what a frame looks like +def fig_frame(): + """Pedestal-subtracted MOENCH frame + a zoom on one real 3×3 cluster.""" + sys.path.append("/home/ferjao_k/aare/build") + from aare import File + base = Path("/mnt/sls_det_storage/moench_data/2603_MaxIVBeamtime/" + "2026032408/process/xrf/") + pd = File(base / "Cu_factor_10_pedestal_master_0.json") + ped = np.mean([np.asarray(pd.read_frame(), dtype=np.float64) + for _ in range(200)], axis=0) + f = File(base / "Cu_factor_10_data_master_0.json") + frame = np.asarray(f.read_frame(), dtype=np.float64) - ped + + crop = frame[40:190, 40:190] + + fig = plt.figure(figsize=(7.4, 3.25)) + ax = fig.add_axes([0.0, 0.02, 0.44, 0.94]) + im = ax.imshow(crop, cmap=CMAP, vmin=-40, vmax=1200, interpolation="nearest") + ax.set_xticks([]); ax.set_yticks([]) + for s in ax.spines.values(): + s.set_color(RULE) + ax.set_title("one frame, pedestal subtracted · 150×150 crop", + color=MUTED, fontsize=8, pad=7) + cb = fig.colorbar(im, ax=ax, fraction=0.045, pad=0.02) + cb.outline.set_edgecolor(RULE) + cb.ax.tick_params(labelsize=7, color=RULE) + cb.set_label("ADU above pedestal", color=MUTED, fontsize=7.5) + + # Pick a clean, well-isolated charge-sharing event: a local maximum of + # moderate amplitude whose 3×3 core carries the charge and whose + # surrounding ring is quiet — i.e. what the algorithm is designed to find. + best, win = None, None + for r in range(6, frame.shape[0] - 6): + for c in range(6, frame.shape[1] - 6): + v = frame[r, c] + if not (500 < v < 2000): + continue + w = frame[r - 4:r + 5, c - 4:c + 5] + if w.max() > v: # must be the local max + continue + core = w[3:6, 3:6] + ring = np.concatenate([w[:3].ravel(), w[6:].ravel(), + w[3:6, :3].ravel(), w[3:6, 6:].ravel()]) + if ring.max() > 80: # neighbourhood must be quiet + continue + share = (core.sum() - v) / core.sum() # charge outside the peak + if best is None or share > best[0]: + best, win = (share, r, c), w + if win is None: # fallback: brightest pixel + inner = frame[6:-6, 6:-6] + r, c = np.unravel_index(np.argmax(inner), inner.shape) + win = frame[r + 2:r + 11, c + 2:c + 11] + + ax2 = fig.add_axes([0.60, 0.10, 0.30, 0.78]) + ax2.imshow(win, cmap=CMAP, vmin=-40, vmax=1200, interpolation="nearest") + ax2.set_xticks([]); ax2.set_yticks([]) + for s in ax2.spines.values(): + s.set_color(RULE) + ax2.add_patch(Rectangle((2.5, 2.5), 3, 3, fill=False, edgecolor=PALE, + lw=1.8, zorder=5)) + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + v = win[4 + dy, 4 + dx] + ax2.text(4 + dx, 4 + dy, f"{v:.0f}", ha="center", va="center", + color=BG if v > 500 else PALE, fontsize=7, + fontweight="bold" if dx == 0 and dy == 0 else "normal", + zorder=6) + ax2.set_title("9×9 zoom on one hit", color=MUTED, fontsize=8, pad=7) + ax2.text(4, 9.2, f"3×3 sum = {win[3:6, 3:6].sum():.0f} ADU — one photon.\n" + "The peak pixel holds only part of the charge.", + ha="center", va="top", color=TEXT2, fontsize=7.5) + save(fig, "fig_frame") + + +# --------------------------------------------- 2. shared-memory tile + halo +def fig_tile(): + B, r = 16, 1 # 16×16 block, 3×3 cluster → 1-px halo + n = B + 2 * r + fig = plt.figure(figsize=(7.6, 3.1)) + + ax = fig.add_axes([0.0, 0.0, 0.44, 1.0]) + ax.set_aspect("equal"); ax.axis("off") + ax.set_xlim(-0.6, n + 0.6); ax.set_ylim(-3.2, n + 1.3) + for i in range(n): + for j in range(n): + halo = i < r or j < r or i >= n - r or j >= n - r + ax.add_patch(Rectangle((j, n - 1 - i), 0.92, 0.92, + facecolor=RULE if halo else "#17394F", + edgecolor="none")) + # one thread's 3×3 neighbourhood + ti, tj = 6, 5 + for di in (-1, 0, 1): + for dj in (-1, 0, 1): + ax.add_patch(Rectangle((tj + r + dj, n - 1 - (ti + r + di)), 0.92, + 0.92, facecolor=ACCENT, edgecolor="none")) + ax.add_patch(Rectangle((tj + r, n - 1 - (ti + r)), 0.92, 0.92, + facecolor=AMBER, edgecolor="none")) + ax.text(n / 2, n + 0.45, "shared-memory tile · 18 × 18", + ha="center", color=MUTED, fontsize=8) + for y, c, t in [(-1.05, AMBER, "the thread's own pixel"), + (-1.85, ACCENT, "its 3×3 neighbourhood"), + (-2.65, RULE, "halo — loaded, never centred on")]: + ax.add_patch(Rectangle((0, y), 0.7, 0.36, facecolor=c, edgecolor="none")) + ax.text(1.0, y + 0.18, t, va="center", color=TEXT2, fontsize=7.5) + + # right: tile cost vs cluster size + ax2 = fig.add_axes([0.575, 0.20, 0.40, 0.62]) + labels = ["3×3\n18×18", "5×5\n20×20", "7×7\n22×22", "9×9\n24×24"] + kb = [(16 + 2 * (k // 2)) ** 2 * 4 / 1024 for k in (3, 5, 7, 9)] + ax2.bar(np.arange(4), kb, width=0.55, color=ACCENT, zorder=3) + for i, v in enumerate(kb): + ax2.text(i, v + 0.12, f"{v:.1f}", ha="center", color=PALE, fontsize=9, + fontweight="bold") + ax2.axhline(100, color=PALE, lw=1.2, ls="--") + ax2.set_xticks(np.arange(4)); ax2.set_xticklabels(labels, color=TEXT2, + fontsize=8) + ax2.set_ylim(0, 3.4); ax2.set_yticks([]) + bare(ax2, keep=("bottom",)) + ax2.set_title("KB of shared memory per 16×16 block (float tile)", + color=MUTED, fontsize=8, pad=8) + ax2.text(3.55, 3.15, "100 KB available per SM on Ada\n" + "— shared memory is never the limit", + ha="right", va="top", color=PALE, fontsize=7.5) + save(fig, "fig_tile") + + +# ------------------------------------------------- 3. occupancy / registers +def fig_occupancy(): + fig, (ax, ax2) = plt.subplots(1, 2, figsize=(11.2, 2.95), + gridspec_kw={"width_ratios": [1, 1.5]}) + + # left — registers set the occupancy, per cluster size + occ = [100.0, 33.3] + ax.bar([0, 1], occ, width=0.5, color=[ACCENT, AMBER], zorder=3) + for i, o in enumerate(occ): + ax.text(i, o + 3, f"{o:.0f}%", ha="center", color=PALE, fontsize=12, + fontweight="bold") + ax.set_xticks([0, 1]) + ax.set_xticklabels(["3×3 cluster\n38 regs/thread · 6 blocks/SM", + "9×9 cluster\n128 regs/thread · 2 blocks/SM"], + color=TEXT2, fontsize=8.5) + ax.set_ylim(0, 122); ax.set_yticks([]) + bare(ax, keep=("bottom",)) + ax.set_title("achieved occupancy, 16×16 block · f32 build", color=MUTED, + fontsize=8.5, pad=8) + + # right — block-size sweep, both cluster sizes + o3 = [100.0, 100.0, 66.7] + o9 = [33.3, 33.3, 0.0] + halo3 = [56, 27, 13] + blocks = [f"{b}\nhalo +{h}% of the tile" + for b, h in zip(["8×8 · 64 threads", "16×16 · 256 threads", + "32×32 · 1024 threads"], halo3)] + x = np.arange(3); w = 0.34 + ax2.bar(x - w / 2, o3, width=w, color=ACCENT, zorder=3, label="3×3 cluster") + ax2.bar(x + w / 2, o9, width=w, color=AMBER, zorder=3, label="9×9 cluster") + for xi, (a, b) in enumerate(zip(o3, o9)): + ax2.text(xi - w / 2, a + 3, f"{a:.0f}%", ha="center", color=TEXT2, + fontsize=8.5) + ax2.text(xi + w / 2, b + 3, + ("will not launch\n(registers)" if b == 0 else f"{b:.0f}%"), + ha="center", va="bottom", color=AMBER if b == 0 else TEXT2, + fontsize=8 if b == 0 else 8.5, + fontweight="bold" if b == 0 else "normal") + ax2.set_xticks(x); ax2.set_xticklabels(blocks, color=TEXT2, fontsize=8.5) + ax2.set_ylim(0, 122); ax2.set_yticks([]) + bare(ax2, keep=("bottom",)) + ax2.legend(frameon=False, fontsize=8.5, labelcolor=TEXT2, loc="upper right") + ax2.set_title("occupancy vs block size (halo overhead quoted for 3×3)", + color=MUTED, fontsize=8.5, pad=8) + fig.subplots_adjust(bottom=0.26) + save(fig, "fig_occupancy") + + +if __name__ == "__main__": + fig_tile() + fig_occupancy() + fig_frame() + print("done ->", OUT) diff --git a/docs/deck/validation_tiers.json b/docs/deck/validation_tiers.json new file mode 100644 index 00000000..2708fd0c --- /dev/null +++ b/docs/deck/validation_tiers.json @@ -0,0 +1,876 @@ +{ + "n_frames": 10000, + "totals": { + "cpu": 23244602, + "frozen": 23244605, + "cuda": 23244611 + }, + "pairs": { + "cpu vs frozen": { + "a_only": 8, + "b_only": 11 + }, + "cpu vs cuda": { + "a_only": 8, + "b_only": 17 + }, + "frozen vs cuda": { + "a_only": 0, + "b_only": 6 + } + }, + "extras": [ + { + "frame": 147, + "x": 202, + "y": 8, + "shift": 1 + }, + { + "frame": 1757, + "x": 102, + "y": 167, + "shift": 1 + }, + { + "frame": 2204, + "x": 397, + "y": 178, + "shift": 1 + }, + { + "frame": 2790, + "x": 1, + "y": 374, + "shift": 1 + }, + { + "frame": 4098, + "x": 348, + "y": 46, + "shift": 1 + }, + { + "frame": 8372, + "x": 272, + "y": 47, + "shift": 1 + } + ], + "n_cuda_only": 6, + "n_adjacent_to_agreed": 6, + "shift_histogram": { + "1": 6 + }, + "hists": { + "cpu": [ + 499.0, + 919.0, + 1474.0, + 2346.0, + 3498.0, + 4961.0, + 6307.0, + 7820.0, + 9216.0, + 10546.0, + 11475.0, + 11825.0, + 12529.0, + 12832.0, + 13093.0, + 13244.0, + 12611.0, + 12129.0, + 11832.0, + 11677.0, + 11595.0, + 11501.0, + 11484.0, + 11468.0, + 11354.0, + 11579.0, + 11549.0, + 11747.0, + 11641.0, + 11954.0, + 12136.0, + 12360.0, + 12613.0, + 13087.0, + 13153.0, + 13663.0, + 13840.0, + 14463.0, + 15204.0, + 16256.0, + 17384.0, + 18725.0, + 20249.0, + 22251.0, + 24573.0, + 27579.0, + 32342.0, + 40818.0, + 58221.0, + 90431.0, + 148680.0, + 247066.0, + 397450.0, + 607938.0, + 877275.0, + 1178976.0, + 1480418.0, + 1724824.0, + 1875375.0, + 1898011.0, + 1802008.0, + 1612942.0, + 1371944.0, + 1118841.0, + 893351.0, + 698921.0, + 540935.0, + 415181.0, + 315524.0, + 238485.0, + 177935.0, + 133335.0, + 101985.0, + 80613.0, + 66308.0, + 57704.0, + 52551.0, + 48730.0, + 46713.0, + 45263.0, + 45478.0, + 46818.0, + 48748.0, + 52268.0, + 55751.0, + 59752.0, + 63127.0, + 64355.0, + 64278.0, + 61790.0, + 57780.0, + 52398.0, + 46549.0, + 41177.0, + 36929.0, + 32859.0, + 29645.0, + 27949.0, + 26098.0, + 25284.0, + 25948.0, + 24170.0, + 23309.0, + 23092.0, + 22982.0, + 22740.0, + 22928.0, + 23247.0, + 24412.0, + 26263.0, + 29182.0, + 33590.0, + 38978.0, + 45376.0, + 52423.0, + 59269.0, + 64780.0, + 67378.0, + 68667.0, + 65197.0, + 61403.0, + 54807.0, + 48119.0, + 41254.0, + 34742.0, + 29510.0, + 24472.0, + 19725.0, + 15963.0, + 12877.0, + 10251.0, + 8289.0, + 6654.0, + 5526.0, + 4723.0, + 4074.0, + 3730.0, + 3395.0, + 3280.0, + 3208.0, + 3247.0, + 3332.0, + 3451.0, + 3619.0, + 3877.0, + 3903.0, + 4051.0, + 3885.0, + 3814.0, + 3604.0, + 3385.0, + 3074.0, + 2902.0, + 2613.0, + 2402.0, + 2127.0, + 1922.0, + 1855.0, + 1701.0, + 1610.0, + 1467.0, + 1489.0, + 1514.0, + 1444.0, + 1519.0, + 1478.0, + 1442.0, + 1531.0, + 1504.0, + 1641.0, + 1667.0, + 1845.0, + 1837.0, + 2052.0, + 2053.0, + 2149.0, + 2181.0, + 2211.0, + 2096.0, + 1974.0, + 1901.0, + 1720.0, + 1564.0, + 1336.0, + 1111.0, + 1043.0, + 860.0, + 717.0, + 659.0, + 529.0, + 457.0, + 424.0, + 298.0, + 294.0, + 233.0, + 236.0, + 208.0, + 193.0, + 164.0, + 147.0 + ], + "frozen": [ + 499.0, + 919.0, + 1474.0, + 2347.0, + 3501.0, + 4958.0, + 6305.0, + 7819.0, + 9216.0, + 10548.0, + 11475.0, + 11827.0, + 12528.0, + 12831.0, + 13094.0, + 13244.0, + 12612.0, + 12130.0, + 11833.0, + 11677.0, + 11595.0, + 11501.0, + 11484.0, + 11467.0, + 11355.0, + 11578.0, + 11550.0, + 11746.0, + 11641.0, + 11955.0, + 12136.0, + 12360.0, + 12613.0, + 13087.0, + 13153.0, + 13663.0, + 13839.0, + 14464.0, + 15203.0, + 16257.0, + 17384.0, + 18726.0, + 20248.0, + 22251.0, + 24573.0, + 27580.0, + 32341.0, + 40818.0, + 58221.0, + 90432.0, + 148679.0, + 247063.0, + 397447.0, + 607938.0, + 877277.0, + 1178973.0, + 1480418.0, + 1724821.0, + 1875374.0, + 1898013.0, + 1802009.0, + 1612947.0, + 1371943.0, + 1118842.0, + 893348.0, + 698924.0, + 540935.0, + 415183.0, + 315525.0, + 238483.0, + 177935.0, + 133335.0, + 101985.0, + 80614.0, + 66308.0, + 57703.0, + 52552.0, + 48730.0, + 46712.0, + 45264.0, + 45478.0, + 46817.0, + 48749.0, + 52268.0, + 55751.0, + 59751.0, + 63128.0, + 64355.0, + 64278.0, + 61789.0, + 57781.0, + 52397.0, + 46550.0, + 41177.0, + 36929.0, + 32859.0, + 29645.0, + 27949.0, + 26098.0, + 25284.0, + 25947.0, + 24171.0, + 23310.0, + 23091.0, + 22982.0, + 22740.0, + 22928.0, + 23246.0, + 24413.0, + 26263.0, + 29181.0, + 33591.0, + 38978.0, + 45376.0, + 52423.0, + 59269.0, + 64779.0, + 67379.0, + 68667.0, + 65197.0, + 61403.0, + 54807.0, + 48118.0, + 41254.0, + 34742.0, + 29510.0, + 24474.0, + 19724.0, + 15963.0, + 12877.0, + 10251.0, + 8289.0, + 6654.0, + 5526.0, + 4723.0, + 4074.0, + 3730.0, + 3395.0, + 3280.0, + 3208.0, + 3247.0, + 3332.0, + 3451.0, + 3619.0, + 3877.0, + 3903.0, + 4051.0, + 3885.0, + 3814.0, + 3604.0, + 3385.0, + 3074.0, + 2902.0, + 2613.0, + 2402.0, + 2127.0, + 1922.0, + 1855.0, + 1701.0, + 1610.0, + 1467.0, + 1489.0, + 1514.0, + 1444.0, + 1519.0, + 1478.0, + 1442.0, + 1531.0, + 1504.0, + 1641.0, + 1667.0, + 1845.0, + 1837.0, + 2052.0, + 2053.0, + 2149.0, + 2181.0, + 2211.0, + 2096.0, + 1974.0, + 1901.0, + 1720.0, + 1564.0, + 1336.0, + 1111.0, + 1043.0, + 860.0, + 717.0, + 659.0, + 529.0, + 457.0, + 424.0, + 298.0, + 294.0, + 233.0, + 236.0, + 208.0, + 193.0, + 164.0, + 147.0 + ], + "cuda": [ + 499.0, + 919.0, + 1474.0, + 2347.0, + 3501.0, + 4958.0, + 6304.0, + 7820.0, + 9216.0, + 10547.0, + 11476.0, + 11827.0, + 12527.0, + 12831.0, + 13095.0, + 13245.0, + 12611.0, + 12130.0, + 11833.0, + 11678.0, + 11594.0, + 11500.0, + 11484.0, + 11468.0, + 11355.0, + 11577.0, + 11553.0, + 11742.0, + 11642.0, + 11956.0, + 12136.0, + 12361.0, + 12610.0, + 13087.0, + 13155.0, + 13664.0, + 13837.0, + 14465.0, + 15204.0, + 16254.0, + 17386.0, + 18725.0, + 20248.0, + 22248.0, + 24576.0, + 27580.0, + 32341.0, + 40818.0, + 58220.0, + 90432.0, + 148681.0, + 247064.0, + 397436.0, + 607935.0, + 877264.0, + 1178956.0, + 1480420.0, + 1724770.0, + 1875420.0, + 1897953.0, + 1802017.0, + 1612959.0, + 1371965.0, + 1118856.0, + 893348.0, + 698930.0, + 540947.0, + 415204.0, + 315523.0, + 238491.0, + 177935.0, + 133333.0, + 101990.0, + 80619.0, + 66306.0, + 57704.0, + 52554.0, + 48728.0, + 46712.0, + 45263.0, + 45479.0, + 46816.0, + 48746.0, + 52273.0, + 55754.0, + 59745.0, + 63130.0, + 64358.0, + 64273.0, + 61792.0, + 57778.0, + 52400.0, + 46551.0, + 41180.0, + 36927.0, + 32858.0, + 29645.0, + 27949.0, + 26099.0, + 25286.0, + 25945.0, + 24172.0, + 23308.0, + 23091.0, + 22984.0, + 22737.0, + 22928.0, + 23248.0, + 24412.0, + 26263.0, + 29182.0, + 33592.0, + 38976.0, + 45376.0, + 52419.0, + 59270.0, + 64776.0, + 67382.0, + 68665.0, + 65199.0, + 61406.0, + 54805.0, + 48121.0, + 41250.0, + 34746.0, + 29508.0, + 24474.0, + 19725.0, + 15965.0, + 12876.0, + 10251.0, + 8289.0, + 6654.0, + 5527.0, + 4722.0, + 4075.0, + 3730.0, + 3395.0, + 3280.0, + 3208.0, + 3247.0, + 3332.0, + 3451.0, + 3619.0, + 3877.0, + 3903.0, + 4049.0, + 3887.0, + 3813.0, + 3605.0, + 3386.0, + 3073.0, + 2901.0, + 2614.0, + 2401.0, + 2128.0, + 1922.0, + 1855.0, + 1701.0, + 1610.0, + 1467.0, + 1489.0, + 1514.0, + 1444.0, + 1519.0, + 1478.0, + 1442.0, + 1531.0, + 1504.0, + 1641.0, + 1667.0, + 1845.0, + 1837.0, + 2051.0, + 2053.0, + 2150.0, + 2181.0, + 2211.0, + 2096.0, + 1974.0, + 1901.0, + 1720.0, + 1564.0, + 1336.0, + 1111.0, + 1043.0, + 860.0, + 717.0, + 659.0, + 529.0, + 457.0, + 424.0, + 298.0, + 294.0, + 233.0, + 236.0, + 208.0, + 193.0, + 164.0, + 147.0 + ] + }, + "edges": [ + -2.0, + 18.01, + 38.02, + 58.03, + 78.04, + 98.05, + 118.06, + 138.07, + 158.08, + 178.09, + 198.1, + 218.11, + 238.12, + 258.13, + 278.14, + 298.15, + 318.16, + 338.17, + 358.18, + 378.19, + 398.2, + 418.21, + 438.22, + 458.23, + 478.24, + 498.25, + 518.26, + 538.27, + 558.28, + 578.29, + 598.3, + 618.31, + 638.32, + 658.33, + 678.34, + 698.35, + 718.36, + 738.37, + 758.38, + 778.39, + 798.4, + 818.41, + 838.42, + 858.43, + 878.44, + 898.45, + 918.46, + 938.47, + 958.48, + 978.49, + 998.5, + 1018.51, + 1038.52, + 1058.53, + 1078.54, + 1098.55, + 1118.56, + 1138.57, + 1158.58, + 1178.59, + 1198.6, + 1218.61, + 1238.62, + 1258.63, + 1278.64, + 1298.65, + 1318.66, + 1338.67, + 1358.68, + 1378.69, + 1398.7, + 1418.71, + 1438.72, + 1458.73, + 1478.74, + 1498.75, + 1518.76, + 1538.77, + 1558.78, + 1578.79, + 1598.8, + 1618.81, + 1638.82, + 1658.83, + 1678.84, + 1698.85, + 1718.86, + 1738.87, + 1758.88, + 1778.89, + 1798.9, + 1818.91, + 1838.92, + 1858.93, + 1878.94, + 1898.95, + 1918.96, + 1938.97, + 1958.98, + 1978.99, + 1999.0, + 2019.01, + 2039.02, + 2059.03, + 2079.04, + 2099.05, + 2119.06, + 2139.07, + 2159.08, + 2179.09, + 2199.1, + 2219.11, + 2239.12, + 2259.13, + 2279.14, + 2299.15, + 2319.16, + 2339.17, + 2359.18, + 2379.19, + 2399.2, + 2419.21, + 2439.22, + 2459.23, + 2479.24, + 2499.25, + 2519.26, + 2539.27, + 2559.28, + 2579.29, + 2599.3, + 2619.31, + 2639.32, + 2659.33, + 2679.34, + 2699.35, + 2719.36, + 2739.37, + 2759.38, + 2779.39, + 2799.4, + 2819.41, + 2839.42, + 2859.43, + 2879.44, + 2899.45, + 2919.46, + 2939.47, + 2959.48, + 2979.49, + 2999.5, + 3019.51, + 3039.52, + 3059.53, + 3079.54, + 3099.55, + 3119.56, + 3139.57, + 3159.58, + 3179.59, + 3199.6, + 3219.61, + 3239.62, + 3259.63, + 3279.64, + 3299.65, + 3319.66, + 3339.67, + 3359.68, + 3379.69, + 3399.7, + 3419.71, + 3439.72, + 3459.73, + 3479.74, + 3499.75, + 3519.76, + 3539.77, + 3559.78, + 3579.79, + 3599.8, + 3619.81, + 3639.82, + 3659.83, + 3679.84, + 3699.85, + 3719.86, + 3739.87, + 3759.88, + 3779.89, + 3799.9, + 3819.91, + 3839.92, + 3859.93, + 3879.94, + 3899.95, + 3919.96, + 3939.97, + 3959.98, + 3979.99, + 4000.0 + ] +} \ No newline at end of file diff --git a/docs/figures/Intel_skylake.jpg b/docs/figures/Intel_skylake.jpg new file mode 100644 index 00000000..9aa4acd4 Binary files /dev/null and b/docs/figures/Intel_skylake.jpg differ diff --git a/docs/figures/SM_schematic_V100.jpg b/docs/figures/SM_schematic_V100.jpg new file mode 100644 index 00000000..09c2fc74 Binary files /dev/null and b/docs/figures/SM_schematic_V100.jpg differ diff --git a/docs/figures/fig_arc.png b/docs/figures/fig_arc.png index abb72fca..f0418cf5 100644 Binary files a/docs/figures/fig_arc.png and b/docs/figures/fig_arc.png differ diff --git a/docs/figures/fig_arc_9x9.png b/docs/figures/fig_arc_9x9.png new file mode 100644 index 00000000..2bf44ec2 Binary files /dev/null and b/docs/figures/fig_arc_9x9.png differ diff --git a/docs/figures/fig_bottleneck.png b/docs/figures/fig_bottleneck.png index 75019cb5..0470265f 100644 Binary files a/docs/figures/fig_bottleneck.png and b/docs/figures/fig_bottleneck.png differ diff --git a/docs/figures/fig_correctness.png b/docs/figures/fig_correctness.png index e32be63c..0e86a1cc 100644 Binary files a/docs/figures/fig_correctness.png and b/docs/figures/fig_correctness.png differ diff --git a/docs/figures/fig_f32_kernel.png b/docs/figures/fig_f32_kernel.png index bd17ff3f..65a4e3c3 100644 Binary files a/docs/figures/fig_f32_kernel.png and b/docs/figures/fig_f32_kernel.png differ diff --git a/docs/figures/fig_first_run.png b/docs/figures/fig_first_run.png new file mode 100644 index 00000000..0ad3017e Binary files /dev/null and b/docs/figures/fig_first_run.png differ diff --git a/docs/figures/fig_frame.png b/docs/figures/fig_frame.png new file mode 100644 index 00000000..94e4c46e Binary files /dev/null and b/docs/figures/fig_frame.png differ diff --git a/docs/figures/fig_graphs.png b/docs/figures/fig_graphs.png index fc89380f..bacad131 100644 Binary files a/docs/figures/fig_graphs.png and b/docs/figures/fig_graphs.png differ diff --git a/docs/figures/fig_mismatch147.png b/docs/figures/fig_mismatch147.png new file mode 100644 index 00000000..ff7a4a4c Binary files /dev/null and b/docs/figures/fig_mismatch147.png differ diff --git a/docs/figures/fig_occupancy.png b/docs/figures/fig_occupancy.png new file mode 100644 index 00000000..38d057cc Binary files /dev/null and b/docs/figures/fig_occupancy.png differ diff --git a/docs/figures/fig_overhead.png b/docs/figures/fig_overhead.png index c9eabeb0..9525c11f 100644 Binary files a/docs/figures/fig_overhead.png and b/docs/figures/fig_overhead.png differ diff --git a/docs/figures/fig_overlap.png b/docs/figures/fig_overlap.png new file mode 100644 index 00000000..d2918d75 Binary files /dev/null and b/docs/figures/fig_overlap.png differ diff --git a/docs/figures/fig_pedtiming.png b/docs/figures/fig_pedtiming.png new file mode 100644 index 00000000..ebb178c3 Binary files /dev/null and b/docs/figures/fig_pedtiming.png differ diff --git a/docs/figures/fig_pinning.png b/docs/figures/fig_pinning.png index 2b7242c6..7d7a7760 100644 Binary files a/docs/figures/fig_pinning.png and b/docs/figures/fig_pinning.png differ diff --git a/docs/figures/fig_regpressure.png b/docs/figures/fig_regpressure.png new file mode 100644 index 00000000..417715fa Binary files /dev/null and b/docs/figures/fig_regpressure.png differ diff --git a/docs/figures/fig_resultpath.png b/docs/figures/fig_resultpath.png new file mode 100644 index 00000000..a612f663 Binary files /dev/null and b/docs/figures/fig_resultpath.png differ diff --git a/docs/figures/fig_spectra_valid.png b/docs/figures/fig_spectra_valid.png new file mode 100644 index 00000000..43bbdf9f Binary files /dev/null and b/docs/figures/fig_spectra_valid.png differ diff --git a/docs/figures/fig_tile.png b/docs/figures/fig_tile.png new file mode 100644 index 00000000..e073d302 Binary files /dev/null and b/docs/figures/fig_tile.png differ diff --git a/docs/figures/img_cpu_core.png b/docs/figures/img_cpu_core.png new file mode 100644 index 00000000..694090cb Binary files /dev/null and b/docs/figures/img_cpu_core.png differ diff --git a/docs/figures/img_gpu_legend.png b/docs/figures/img_gpu_legend.png new file mode 100644 index 00000000..19dd50a7 Binary files /dev/null and b/docs/figures/img_gpu_legend.png differ diff --git a/docs/figures/img_rtx4090.jpg b/docs/figures/img_rtx4090.jpg new file mode 100644 index 00000000..430c8aea Binary files /dev/null and b/docs/figures/img_rtx4090.jpg differ diff --git a/docs/figures/img_spectra.png b/docs/figures/img_spectra.png new file mode 100644 index 00000000..52b3c073 Binary files /dev/null and b/docs/figures/img_spectra.png differ diff --git a/include/aare/ClusterFinderCUDA.hpp b/include/aare/ClusterFinderCUDA.hpp index 6bd90c03..b1dafe6a 100644 --- a/include/aare/ClusterFinderCUDA.hpp +++ b/include/aare/ClusterFinderCUDA.hpp @@ -53,6 +53,10 @@ class ClusterFinderCUDA { // Per-slot state consumed by collect() bool m_slot_in_flight[NUM_SLOTS] = {false, false}; + // A BatchView handed out by collect_view() still points into this slot's + // pinned buffer. The slot must not be reused until the view is released, so + // submit_batch() refuses it rather than overwriting live results. + bool m_slot_view_held[NUM_SLOTS] = {false, false}; size_t m_slot_n_frames[NUM_SLOTS] = {0, 0}; uint64_t m_slot_first_frame[NUM_SLOTS] = {0, 0}; int m_slot_streams_used[NUM_SLOTS] = {0, 0}; @@ -92,7 +96,18 @@ class ClusterFinderCUDA { float m_total_kernel_ms = 0.0f; size_t m_frames_processed = 0; - // Per-slot kernel timing event pools (sized lazily to the largest batch) + // Opt-in kernel timing. Off by default: it costs two cudaEventRecord per + // frame (real stream operations) plus one cudaEventElapsedTime query per + // frame on the host, and the number it produces is only meaningful when + // kernels cannot queue behind one another — see avg_kernel_time_ms(). + bool m_time_kernels = false; + + // Frames per internally pipelined chunk in find_clusters_batched(). + // 0 = auto (batch split so one chunk is marshaled while the next runs). + size_t m_batch_chunk = 0; + + // Per-slot kernel timing event pools (sized lazily to the largest batch). + // Left empty when m_time_kernels is false — no events are ever created. std::vector m_kernel_start_pools[NUM_SLOTS]; std::vector m_kernel_stop_pools[NUM_SLOTS]; @@ -107,6 +122,132 @@ class ClusterFinderCUDA { dim3 block; size_t shmem_bytes; + /// Copy every frame of a pinned output slot into results, turning the + /// packed device layout into owned ClusterVectors. This is exactly the work + /// collect_view() exists to avoid. + /// + /// Single-threaded on purpose. Spreading this copy over a thread pool was + /// tried and reverted: the work is one 467 kB malloc + first-touch per + /// frame at 9x9, so it is allocation-bound rather than bandwidth-bound. + /// Extra threads get their own glibc arenas, which destroys heap reuse + /// between calls and costs more in page faults than the parallel copy saves + /// — measured 2.27 M faults at 8 threads vs 9.7 k at 1, for a 6 % gain at + /// best and a 33 % *loss* when results are freed promptly. The fix is to + /// stop allocating per frame, not to copy faster. + void + materialize_slot(const void *slot_base, size_t n_frames, + std::vector> &results) const { + for (size_t frame_idx = 0; frame_idx < n_frames; ++frame_idx) { + const void *h_out = static_cast(slot_base) + + frame_idx * m_output_bytes_per_frame; + uint32_t n_found = *reinterpret_cast(h_out); + // The device counter increments past the cap (only the write is + // guarded), so this clamp is an out-of-bounds guard, not a tuning + // choice. + n_found = std::min( + n_found, static_cast(m_max_clusters_per_frame)); + + if (n_found > 0) { + const auto *src = reinterpret_cast( + static_cast(h_out) + m_clusters_offset); + results[frame_idx].resize(n_found); + std::memcpy(results[frame_idx].data(), src, + n_found * sizeof(ClusterType)); + } + } + } + + /// Default number of pipelined chunks per find_clusters_batched() call. + /// Two competing costs set this: + /// - fill/drain: the first submit and last collect are not overlapped, + /// costing min(GPU, host) per batch = min/(C*max) of the total, so this + /// term FALLS as C grows; + /// - per-chunk tail: every chunk ends with all streams draining, + /// ~n_streams * kernel_time of partly-idle GPU, so this term RISES + /// linearly with C. + /// Measured 9x9 rates (GPU 26.7 us/frame, host 58.8 us/frame, kernel + /// 25.4 us, 2000 frames) put the sum at 23 % for C=2, 11 % for C=4, + /// 6 % for C=8 and ~4 % for C=16-32 — a flat minimum past 16. C=8 sits + /// inside the knee while keeping chunks large enough that each stream still + /// gets a healthy number of frames. Override with set_batch_chunk() to + /// measure rather than trust this. + static constexpr size_t DEFAULT_BATCH_CHUNKS = 8; + + /// Upper bound on one pinned output slot. cudaMallocHost is page-locked, so + /// its first touch is charged to whoever triggers it — keep it modest. + static constexpr size_t MAX_SLOT_BYTES = 128ull << 20; // 128 MiB + + /// Frames per pipelined chunk in find_clusters_batched(). Rounded up to a + /// multiple of n_streams so chunking never changes which stream a frame + /// lands on (the device pedestal is per-stream, so a different assignment + /// would mean a different pedestal state per frame — a correctness issue, + /// not a tidiness one). + size_t resolve_batch_chunk(size_t n_frames) const { + const size_t ns = static_cast(n_streams); + size_t chunk = m_batch_chunk; + if (chunk == 0) { + chunk = + (n_frames + DEFAULT_BATCH_CHUNKS - 1) / DEFAULT_BATCH_CHUNKS; + // Keep at least a few frames per stream per chunk, or the per-chunk + // drain dominates. + const size_t min_chunk = ns * 4; + if (chunk < min_chunk) + chunk = min_chunk; + // Cap by BYTES, not frames. Each slot is cudaMallocHost'd at + // chunk * m_output_bytes_per_frame, and there are NUM_SLOTS of + // them. Without this, find_clusters_batched(whole_array) scales the + // pinned allocation with the array: 20 000 frames at 9x9 gives + // 2500-frame chunks = 1.23 GB per slot, 2.46 GB pinned, whose + // first-touch cost (~600 k page faults) lands inside the caller's + // timed region. Bounding it makes one big call behave like a loop + // over slices. + const size_t max_by_bytes = + std::max(1, MAX_SLOT_BYTES / m_output_bytes_per_frame); + if (chunk > max_by_bytes) + chunk = std::max(max_by_bytes, ns); + } + chunk = ((chunk + ns - 1) / ns) * ns; + return std::min(chunk, n_frames); + } + + /// Called by BatchView when it is released, making the slot reusable. + void release_slot(int slot) { + if (slot >= 0 && slot < NUM_SLOTS) + m_slot_view_held[slot] = false; + } + + /// Wait for a slot's D2H to land and hand back its bookkeeping. Shared by + /// collect() and collect_view() so the two cannot drift. + void finish_slot(int slot, size_t &n_frames, uint64_t &first_frame) { + if (!m_slot_in_flight[slot]) + throw std::runtime_error( + "ClusterFinderCUDA: collect() called on a slot that is not " + "in flight"); + n_frames = m_slot_n_frames[slot]; + first_frame = m_slot_first_frame[slot]; + // cudaEventSynchronize (not cudaStreamSynchronize) so a batch already + // queued behind this one in the same streams is not waited on. + for (int k = 0; k < m_slot_streams_used[slot]; ++k) + CUDA_CHECK(cudaEventSynchronize(m_batch_done[slot][k])); + accumulate_kernel_times(slot, n_frames); + m_frames_processed += n_frames; + m_slot_in_flight[slot] = false; + } + + /// Accumulate per-frame kernel times for a slot. Serial by construction: + /// it mutates m_total_kernel_ms and queries CUDA events. + void accumulate_kernel_times(int slot, size_t n_frames) { + if (!m_time_kernels) + return; + for (size_t frame_idx = 0; frame_idx < n_frames; ++frame_idx) { + float kernel_ms = 0.0f; + CUDA_CHECK(cudaEventElapsedTime( + &kernel_ms, m_kernel_start_pools[slot][frame_idx], + m_kernel_stop_pools[slot][frame_idx])); + m_total_kernel_ms += kernel_ms; + } + } + public: /** * @brief Opaque handle returned by submit_batch(). Pass to collect(). @@ -115,6 +256,130 @@ class ClusterFinderCUDA { int slot; }; + using value_type = typename ClusterType::value_type; + + /** + * @brief Zero-copy view of a batch, still in the pinned D2H buffer. + * + * The D2H staging buffer already holds correctly laid-out clusters at a + * fixed per-frame stride, so there is nothing to copy: this just exposes + * offsets into it. Host cost per frame drops to reading one counter. + * + * @warning The view borrows the finder's slot. It stays valid until + * release() (or destruction); until then submit_batch() will refuse that + * slot rather than overwrite live results. With NUM_SLOTS == 2 that means + * you must finish with a view before submitting two more batches — the + * intended pattern is consume-then-release, one chunk at a time. + */ + class BatchView { + friend class ClusterFinderCUDA; + + ClusterFinderCUDA *m_owner = nullptr; + const uint8_t *m_base = nullptr; + size_t m_n_frames = 0; + uint64_t m_first_frame = 0; + size_t m_stride = 0; // bytes per frame in the pinned buffer + size_t m_cl_offset = 0; // byte offset of the cluster array in a frame + size_t m_max_clusters = 0; + int m_slot = -1; + + BatchView(ClusterFinderCUDA *owner, const void *base, size_t n_frames, + uint64_t first_frame, size_t stride, size_t cl_offset, + size_t max_clusters, int slot) + : m_owner(owner), m_base(static_cast(base)), + m_n_frames(n_frames), m_first_frame(first_frame), + m_stride(stride), m_cl_offset(cl_offset), + m_max_clusters(max_clusters), m_slot(slot) {} + + public: + BatchView() = default; + BatchView(const BatchView &) = delete; + BatchView &operator=(const BatchView &) = delete; + BatchView(BatchView &&o) noexcept { steal(o); } + BatchView &operator=(BatchView &&o) noexcept { + if (this != &o) { + release(); + steal(o); + } + return *this; + } + ~BatchView() { release(); } + + /// Give the slot back. Idempotent; the view is unusable afterwards. + void release() { + if (m_owner) + m_owner->release_slot(m_slot); + m_owner = nullptr; + m_base = nullptr; + m_n_frames = 0; + m_slot = -1; + } + + bool valid() const { return m_base != nullptr; } + size_t n_frames() const { return m_n_frames; } + uint64_t first_frame() const { return m_first_frame; } + + uint32_t count(size_t i) const { + check(i); + uint32_t n = + *reinterpret_cast(m_base + i * m_stride); + // The device counter increments past the cap (only the write is + // guarded), so clamp: this is an out-of-bounds guard. + return std::min(n, static_cast(m_max_clusters)); + } + + const ClusterType *clusters(size_t i) const { + check(i); + return reinterpret_cast(m_base + i * m_stride + + m_cl_offset); + } + + size_t total_clusters() const { + size_t n = 0; + for (size_t i = 0; i < m_n_frames; ++i) + n += count(i); + return n; + } + + /// Per-cluster sums for the whole batch, concatenated frame by frame. + /// The common reduction, done here so callers never have to materialise + /// the clusters themselves. + std::vector sums() const { + std::vector out; + out.reserve(total_clusters()); + for (size_t i = 0; i < m_n_frames; ++i) { + const ClusterType *c = clusters(i); + const uint32_t n = count(i); + for (uint32_t j = 0; j < n; ++j) + out.push_back(c[j].sum()); + } + return out; + } + + private: + void steal(BatchView &o) { + m_owner = o.m_owner; + m_base = o.m_base; + m_n_frames = o.m_n_frames; + m_first_frame = o.m_first_frame; + m_stride = o.m_stride; + m_cl_offset = o.m_cl_offset; + m_max_clusters = o.m_max_clusters; + m_slot = o.m_slot; + o.m_owner = nullptr; + o.m_base = nullptr; + o.m_slot = -1; + } + void check(size_t i) const { + if (!m_base) + throw std::runtime_error( + "ClusterFinderCUDA::BatchView: view has been released"); + if (i >= m_n_frames) + throw std::out_of_range( + "ClusterFinderCUDA::BatchView: frame index out of range"); + } + }; + /** * @brief Construct a ClusterFinderCUDA * @@ -125,13 +390,19 @@ class ClusterFinderCUDA { * fixed-size D2H * @param n_streams_ number of CUDA streams for multi-frame * overlap + * @param time_kernels enable per-frame CUDA-event kernel + * timing. Off by default: it adds two event records per frame to the + * streams and one host-side query per frame, and the resulting number is + * only meaningful at n_streams == 1 (see avg_kernel_time_ms()). */ ClusterFinderCUDA(Shape<2> shape_, COMPUTE_TYPE nSigma = 5.0, - size_t max_clusters_per_frame = 2048, int n_streams_ = 5) + size_t max_clusters_per_frame = 2048, int n_streams_ = 5, + bool time_kernels = false) : m_shape(shape_), nrows(shape_[0]), ncols(shape_[1]), m_image_size(nrows * ncols), n_streams(n_streams_), m_max_clusters_per_frame(max_clusters_per_frame), m_nSigma(nSigma), - m_pedestal(shape_[0], shape_[1]), m_clusters(max_clusters_per_frame) { + m_pedestal(shape_[0], shape_[1]), m_clusters(max_clusters_per_frame), + m_time_kernels(time_kernels) { if (n_streams_ <= 0) { throw std::invalid_argument( "ClusterFinderCUDA: n_streams must be > 0"); @@ -407,22 +678,20 @@ class ClusterFinderCUDA { throw std::runtime_error( "ClusterFinderCUDA: both batch slots are in flight — call " "collect() before submitting a third batch"); + // Inert unless collect_view() was used: a live BatchView still points + // into this slot's pinned buffer, so reusing it would overwrite results + // the caller is still reading. + if (m_slot_view_held[slot]) + throw std::runtime_error( + "ClusterFinderCUDA: this batch slot is still held by a " + "BatchView — release() it before submitting another batch"); m_next_slot = 1 - slot; const size_t n_frames_batch = static_cast(frames.shape(0)); const uint32_t n_pd_samples = static_cast(m_pedestal.n_samples()); - // Grow pinned D2H output buffer for this slot if needed - if (n_frames_batch > m_output_slot_capacity[slot]) { - if (h_output_slots[slot]) - CUDA_CHECK(cudaFreeHost(h_output_slots[slot])); - CUDA_CHECK( - cudaMallocHost(&h_output_slots[slot], - n_frames_batch * m_output_bytes_per_frame)); - m_output_slot_capacity[slot] = n_frames_batch; - } - + grow_output_slot(slot, n_frames_batch); ensure_event_pool(slot, n_frames_batch); // Launch all frames round-robin across streams @@ -439,16 +708,18 @@ class ClusterFinderCUDA { auto *d_clusters = reinterpret_cast( sc.d_output + m_clusters_offset); - CUDA_CHECK(cudaEventRecord(m_kernel_start_pools[slot][frame_idx], - sc.stream)); + if (m_time_kernels) + CUDA_CHECK(cudaEventRecord( + m_kernel_start_pools[slot][frame_idx], sc.stream)); device::find_clusters_in_single_frame <<>>( sc.d_frame, sc.d_pd_mean, sc.d_pd_sum, sc.d_pd_sum2, sc.d_pd_off, n_pd_samples, m_nSigma, nrows, ncols, d_clusters, d_cluster_count, static_cast(m_max_clusters_per_frame)); - CUDA_CHECK(cudaEventRecord(m_kernel_stop_pools[slot][frame_idx], - sc.stream)); + if (m_time_kernels) + CUDA_CHECK(cudaEventRecord(m_kernel_stop_pools[slot][frame_idx], + sc.stream)); CUDA_CHECK(cudaGetLastError()); void *h_out = static_cast(h_output_slots[slot]) + @@ -506,140 +777,286 @@ class ClusterFinderCUDA { results.back().set_frame_number(first_frame + i); } - for (size_t frame_idx = 0; frame_idx < n_frames_batch; ++frame_idx) { - const void *h_out = - static_cast(h_output_slots[slot]) + - frame_idx * m_output_bytes_per_frame; - uint32_t n_found = *reinterpret_cast(h_out); - n_found = std::min( - n_found, static_cast(m_max_clusters_per_frame)); + // for (size_t frame_idx = 0; frame_idx < n_frames_batch; ++frame_idx) { + // const void *h_out = + // static_cast(h_output_slots[slot]) + + // frame_idx * m_output_bytes_per_frame; + // uint32_t n_found = *reinterpret_cast(h_out); + // n_found = std::min( + // n_found, static_cast(m_max_clusters_per_frame)); - if (n_found > 0) { - const auto *src = reinterpret_cast( - static_cast(h_out) + m_clusters_offset); - results[frame_idx].resize(n_found); - std::memcpy(results[frame_idx].data(), src, - n_found * sizeof(ClusterType)); - } + // if (n_found > 0) { + // const auto *src = reinterpret_cast( + // static_cast(h_out) + m_clusters_offset); + // results[frame_idx].resize(n_found); + // std::memcpy(results[frame_idx].data(), src, + // n_found * sizeof(ClusterType)); + // } - float kernel_ms = 0.0f; - CUDA_CHECK(cudaEventElapsedTime( - &kernel_ms, m_kernel_start_pools[slot][frame_idx], - m_kernel_stop_pools[slot][frame_idx])); - m_total_kernel_ms += kernel_ms; - } + // float kernel_ms = 0.0f; + // CUDA_CHECK(cudaEventElapsedTime( + // &kernel_ms, m_kernel_start_pools[slot][frame_idx], + // m_kernel_stop_pools[slot][frame_idx])); + // m_total_kernel_ms += kernel_ms; + // } + + // materialize_slot() is the original copy loop verbatim, just hoisted + // into a helper so find_clusters_batched() shares it; + // accumulate_kernel_times is the m_time_kernels branch that used to + // live in the same loop. + materialize_slot(h_output_slots[slot], n_frames_batch, results); + accumulate_kernel_times(slot, n_frames_batch); m_frames_processed += n_frames_batch; m_slot_in_flight[slot] = false; return results; } + /** + * @brief Collect a batch as a zero-copy view — no allocation, no copy. + * + * Nothing is copied and nothing is allocated: the returned view points into + * the finder's pinned D2H buffer. The slot is held until the view is + * released, so at most NUM_SLOTS - 1 further batches can be submitted while + * it is alive. Consume, then release. + */ + BatchView collect_view(BatchToken token) { + const int slot = token.slot; + size_t n_frames = 0; + uint64_t first_frame = 0; + finish_slot(slot, n_frames, first_frame); + + m_slot_view_held[slot] = true; + return BatchView(this, h_output_slots[slot], n_frames, first_frame, + m_output_bytes_per_frame, m_clusters_offset, + m_max_clusters_per_frame, slot); + } + /** * @brief Synchronous batched cluster finding across multiple frames, using * n_streams CUDA streams to overlap H2D, kernel, and D2H. * * Returns one ClusterVector per input frame (with frame_number set to - * first_frame + i). Does not go through submit_batch/collect so it carries - * no async-slot overhead. + * first_frame + i). + * + * Internally the batch is split into chunks and pipelined over the two + * async slots: chunk i+1 is submitted before chunk i is collected, so the + * host marshals one chunk while the GPU runs the next. A single call used + * to be strictly `run the whole batch, then copy the whole batch`, leaving + * the GPU idle for the entire host phase. + * + * Chunk size is rounded up to a multiple of n_streams so the frame->stream + * assignment is identical to processing the batch in one go. That matters + * for correctness, not just tidiness: the device pedestal is per-stream, so + * changing which stream a frame lands on would change the pedestal state it + * is evaluated against. + * + * @note Now shares the two batch slots with submit_batch()/collect(). A + * batch already in flight is no longer silently overwritten — you get + * an exception instead. */ std::vector> find_clusters_batched(NDView frames, uint64_t first_frame = 0) { - if (m_pedestal_dirty) { - sync_pedestal_to_device(); - m_pedestal_dirty = false; - } - const size_t n_frames_batch = static_cast(frames.shape(0)); - const uint32_t n_pd_samples = - static_cast(m_pedestal.n_samples()); + if (n_frames_batch == 0) + return {}; - // Lazy grow D2H output staging buffer (one slot per frame) - if (n_frames_batch > m_output_slot_capacity[0]) { - if (h_output_slots[0]) - CUDA_CHECK(cudaFreeHost(h_output_slots[0])); - CUDA_CHECK(cudaMallocHost( - &h_output_slots[0], n_frames_batch * m_output_bytes_per_frame)); - m_output_slot_capacity[0] = n_frames_batch; - } - - ensure_event_pool(0, n_frames_batch); + const size_t chunk = resolve_batch_chunk(n_frames_batch); std::vector> results; results.reserve(n_frames_batch); - for (size_t i = 0; i < n_frames_batch; ++i) { - results.emplace_back(); - results.back().set_frame_number(first_frame + i); + + auto drain = [&results](std::vector> part) { + for (auto &cv : part) + results.push_back(std::move(cv)); + }; + + BatchToken tok = submit_batch( + frames.sub_view( + 0, static_cast(std::min(chunk, n_frames_batch))), + first_frame); + for (size_t b = chunk; b < n_frames_batch; b += chunk) { + const size_t e = std::min(b + chunk, n_frames_batch); + // Submit before collecting: the GPU starts chunk b while the host + // is still copying chunk b - chunk out of the pinned slot. + BatchToken nxt = + submit_batch(frames.sub_view(static_cast(b), + static_cast(e)), + first_frame + b); + drain(collect(tok)); + tok = nxt; } + drain(collect(tok)); - // Launch all frames round-robin across streams. - // If the caller has called register_input_buffer() on frames.data(), - // H2D runs at pinned DMA bandwidth (~22 GB/s); otherwise the CUDA - // driver stages it internally (~15 GB/s for pageable memory). - for (size_t frame_idx = 0; frame_idx < n_frames_batch; ++frame_idx) { - auto &sc = v_sc[frame_idx % n_streams]; - - const FRAME_TYPE *h_src = frames.data() + frame_idx * m_image_size; - auto *d_cluster_count = reinterpret_cast(sc.d_output); - - CUDA_CHECK(cudaMemsetAsync(d_cluster_count, 0, sizeof(uint32_t), - sc.stream)); - CUDA_CHECK(cudaMemcpyAsync(sc.d_frame, h_src, m_image_bytes, - cudaMemcpyHostToDevice, sc.stream)); - - auto *d_clusters = reinterpret_cast( - sc.d_output + m_clusters_offset); - CUDA_CHECK( - cudaEventRecord(m_kernel_start_pools[0][frame_idx], sc.stream)); - device::find_clusters_in_single_frame - <<>>( - sc.d_frame, sc.d_pd_mean, sc.d_pd_sum, sc.d_pd_sum2, - sc.d_pd_off, n_pd_samples, m_nSigma, nrows, ncols, - d_clusters, d_cluster_count, - static_cast(m_max_clusters_per_frame)); - CUDA_CHECK( - cudaEventRecord(m_kernel_stop_pools[0][frame_idx], sc.stream)); - CUDA_CHECK(cudaGetLastError()); - - void *h_out = static_cast(h_output_slots[0]) + - frame_idx * m_output_bytes_per_frame; - CUDA_CHECK(cudaMemcpyAsync(h_out, sc.d_output, - m_output_bytes_per_frame, - cudaMemcpyDeviceToHost, sc.stream)); - } - - const int streams_used = - std::min(n_streams, static_cast(n_frames_batch)); - for (int k = 0; k < streams_used; ++k) - CUDA_CHECK(cudaStreamSynchronize(v_sc[k].stream)); - - for (size_t frame_idx = 0; frame_idx < n_frames_batch; ++frame_idx) { - const void *h_out = static_cast(h_output_slots[0]) + - frame_idx * m_output_bytes_per_frame; - uint32_t n_found = *reinterpret_cast(h_out); - n_found = std::min( - n_found, static_cast(m_max_clusters_per_frame)); - - if (n_found > 0) { - const auto *src = reinterpret_cast( - static_cast(h_out) + m_clusters_offset); - results[frame_idx].resize(n_found); - std::memcpy(results[frame_idx].data(), src, - n_found * sizeof(ClusterType)); - } - - float kernel_ms = 0.0f; - CUDA_CHECK(cudaEventElapsedTime(&kernel_ms, - m_kernel_start_pools[0][frame_idx], - m_kernel_stop_pools[0][frame_idx])); - m_total_kernel_ms += kernel_ms; - } - - m_frames_processed += n_frames_batch; return results; } + // Previous implementation: one launch loop over the whole batch, one + // cudaStreamSynchronize per stream, then one single-threaded copy loop over + // every frame. Kept for reference — it is what the numbers in + // docs/ClusterFinderCUDA_benchmark_results.md opt3/opt4 (Act I, sections + // 5-6) were measured against. + // + // std::vector> + // find_clusters_batched(NDView frames, + // uint64_t first_frame = 0) { + // if (m_pedestal_dirty) { + // sync_pedestal_to_device(); + // m_pedestal_dirty = false; + // } + // + // const size_t n_frames_batch = + // static_cast(frames.shape(0)); + // const uint32_t n_pd_samples = + // static_cast(m_pedestal.n_samples()); + // + // // Lazy grow D2H output staging buffer (one slot per frame) + // if (n_frames_batch > m_output_slot_capacity[0]) { + // if (h_output_slots[0]) + // CUDA_CHECK(cudaFreeHost(h_output_slots[0])); + // CUDA_CHECK(cudaMallocHost(&h_output_slots[0], + // n_frames_batch * + // m_output_bytes_per_frame)); + // m_output_slot_capacity[0] = n_frames_batch; + // } + // + // ensure_event_pool(0, n_frames_batch); + // + // std::vector> results; + // results.reserve(n_frames_batch); + // for (size_t i = 0; i < n_frames_batch; ++i) { + // results.emplace_back(); + // results.back().set_frame_number(first_frame + i); + // } + // + // for (size_t frame_idx = 0; frame_idx < n_frames_batch; + // ++frame_idx) { + // auto &sc = v_sc[frame_idx % n_streams]; + // + // const FRAME_TYPE *h_src = + // frames.data() + frame_idx * m_image_size; + // auto *d_cluster_count = + // reinterpret_cast(sc.d_output); + // + // CUDA_CHECK(cudaMemsetAsync(d_cluster_count, 0, + // sizeof(uint32_t), sc.stream)); + // CUDA_CHECK(cudaMemcpyAsync(sc.d_frame, h_src, m_image_bytes, + // cudaMemcpyHostToDevice, sc.stream)); + // + // auto *d_clusters = reinterpret_cast( + // sc.d_output + m_clusters_offset); + // if (m_time_kernels) + // CUDA_CHECK(cudaEventRecord( + // m_kernel_start_pools[0][frame_idx], sc.stream)); + // device::find_clusters_in_single_frame + // <<>>( + // sc.d_frame, sc.d_pd_mean, sc.d_pd_sum, sc.d_pd_sum2, + // sc.d_pd_off, n_pd_samples, m_nSigma, nrows, ncols, + // d_clusters, d_cluster_count, + // static_cast(m_max_clusters_per_frame)); + // if (m_time_kernels) + // CUDA_CHECK(cudaEventRecord( + // m_kernel_stop_pools[0][frame_idx], sc.stream)); + // CUDA_CHECK(cudaGetLastError()); + // + // void *h_out = static_cast(h_output_slots[0]) + + // frame_idx * m_output_bytes_per_frame; + // CUDA_CHECK(cudaMemcpyAsync(h_out, sc.d_output, + // m_output_bytes_per_frame, + // cudaMemcpyDeviceToHost, sc.stream)); + // } + // + // const int streams_used = + // std::min(n_streams, static_cast(n_frames_batch)); + // for (int k = 0; k < streams_used; ++k) + // CUDA_CHECK(cudaStreamSynchronize(v_sc[k].stream)); + // + // for (size_t frame_idx = 0; frame_idx < n_frames_batch; + // ++frame_idx) { + // const void *h_out = + // static_cast(h_output_slots[0]) + + // frame_idx * m_output_bytes_per_frame; + // uint32_t n_found = *reinterpret_cast(h_out); + // n_found = std::min( + // n_found, static_cast(m_max_clusters_per_frame)); + // + // if (n_found > 0) { + // const auto *src = reinterpret_cast( + // static_cast(h_out) + m_clusters_offset); + // results[frame_idx].resize(n_found); + // std::memcpy(results[frame_idx].data(), src, + // n_found * sizeof(ClusterType)); + // } + // + // if (m_time_kernels) { + // float kernel_ms = 0.0f; + // CUDA_CHECK(cudaEventElapsedTime( + // &kernel_ms, m_kernel_start_pools[0][frame_idx], + // m_kernel_stop_pools[0][frame_idx])); + // m_total_kernel_ms += kernel_ms; + // } + // } + // + // m_frames_processed += n_frames_batch; + // return results; + // } + + /// True if per-frame kernel timing was enabled at construction. + bool kernel_timing_enabled() const { return m_time_kernels; } + + /** + * @brief Frames per internally pipelined chunk in find_clusters_batched(). + * + * 0 (default) = auto: the batch is split into ~8 chunks so the host can + * marshal one chunk while the GPU runs the next. Rounded up to a multiple + * of n_streams. Set equal to the batch size to disable chunking and get + * the old submit-everything-then-copy-everything behaviour. + */ + void set_batch_chunk(size_t n) { m_batch_chunk = n; } + size_t get_batch_chunk() const { return m_batch_chunk; } + + /// The chunk size find_clusters_batched() would use for n_frames. Exposed + /// so a caller driving submit/collect_view by hand can match its pipelining + /// without duplicating the rounding rules. + size_t chunk_size_for(size_t n_frames) const { + return resolve_batch_chunk(n_frames); + } + + /** + * @brief Pre-allocate both pinned output slots for batches of n_frames. + * + * Processes nothing: no frame is transferred, no kernel is launched, and + * the pedestal is untouched. Only the two cudaMallocHost calls that + * submit_batch() would otherwise make on its first invocation happen here. + * + * The point is that page-locking is expensive and is charged to whoever + * triggers it: measured ~1.0 us per 4 kB page, i.e. ~66 ms for two 128 MiB + * slots (and ~40 % more if an undersized slot has to be freed first). Left + * to submit_batch() that cost lands inside the first timed region — worth + * 2.8 us/frame over 20 000 frames at 3x3, which is 17 % of the 16.2 us + * roofline. Call this before starting a timer, or before a + * latency-sensitive first batch. Slots only ever grow, so a later smaller + * batch is free and a larger one still re-allocates: pass the largest batch + * you intend to use, e.g. chunk_size_for(n) when driving + * find_clusters_batched() or the submit/collect_view loop over n frames. + */ + void reserve_output_slots(size_t n_frames) { + for (int slot = 0; slot < NUM_SLOTS; ++slot) + grow_output_slot(slot, n_frames); + } + + /** + * @brief Average per-frame kernel time in ms, or NaN if timing is disabled. + * + * @warning Only meaningful at n_streams == 1. The CUDA events bracket the + * kernel on its own stream, so under multi-stream contention the measured + * interval includes time queued behind kernels from other streams — it + * over-reads by up to ~3.5x. Use Nsight Systems for exclusive kernel times. + */ float avg_kernel_time_ms() const { + if (!m_time_kernels) + return std::numeric_limits::quiet_NaN(); return m_frames_processed > 0 ? m_total_kernel_ms / m_frames_processed : 0.0f; } @@ -704,7 +1121,21 @@ class ClusterFinderCUDA { CUDA_CHECK(cudaStreamSynchronize(sc.stream)); } + /// Grow one slot's pinned D2H buffer to hold n_frames. Only ever grows, so + /// a run whose batches keep the same shape allocates once. + void grow_output_slot(int slot, size_t n_frames) { + if (n_frames <= m_output_slot_capacity[slot]) + return; + if (h_output_slots[slot]) + CUDA_CHECK(cudaFreeHost(h_output_slots[slot])); + CUDA_CHECK(cudaMallocHost(&h_output_slots[slot], + n_frames * m_output_bytes_per_frame)); + m_output_slot_capacity[slot] = n_frames; + } + void ensure_event_pool(int slot, size_t n_frames) { + if (!m_time_kernels) + return; // no events are created when timing is disabled const size_t old_size = m_kernel_start_pools[slot].size(); if (n_frames <= old_size) return; diff --git a/include/aare/ClusterFinderCUDAOpt2.hpp b/include/aare/ClusterFinderCUDAOpt2.hpp index ee77689e..3c963538 100644 --- a/include/aare/ClusterFinderCUDAOpt2.hpp +++ b/include/aare/ClusterFinderCUDAOpt2.hpp @@ -67,6 +67,14 @@ class ClusterFinderCUDAOpt2 { float m_total_kernel_ms = 0.0f; size_t m_frames_processed = 0; + /// Per-frame CUDA-event kernel timing. Off by default, matching + /// ClusterFinderCUDA. This is a *comparability* requirement, not a + /// preference: with events forced on here and off there, opt1/opt2 would + /// pay a per-frame tax that opt3+ do not, inflating the opt2 -> opt3 step + /// by exactly the tax. That is the same methodological error the CUDA-Graph + /// row was faulted for. Kernel times come from nsys either way. + bool m_time_kernels = false; + // Kernel parameters dim3 grid; dim3 block; @@ -82,13 +90,18 @@ class ClusterFinderCUDAOpt2 { * @param capacity device-side cluster buffer size per stream * @param n_streams number of CUDA streams for multi-frame * overlap + * @param time_kernels record per-frame CUDA events. Off by + * default, matching ClusterFinderCUDA, so the opt1/opt2 rungs of the + * benchmark ladder carry the same instrumentation as the rungs above them. */ ClusterFinderCUDAOpt2(Shape<2> shape_, COMPUTE_TYPE nSigma = 5.0, - size_t capacity = 1000000, int n_streams_ = 1) + size_t capacity = 1000000, int n_streams_ = 1, + bool time_kernels = false) : m_shape(shape_), nrows(shape_[0]), ncols(shape_[1]), m_image_size(nrows * ncols), n_streams(n_streams_), m_capacity(capacity), m_nSigma(nSigma), - m_pedestal(shape_[0], shape_[1]), m_clusters(capacity) { + m_pedestal(shape_[0], shape_[1]), m_clusters(capacity), + m_time_kernels(time_kernels) { if (n_streams_ <= 0) { throw std::invalid_argument( "ClusterFinderCUDAOpt2: n_streams must be > 0"); @@ -125,8 +138,10 @@ class ClusterFinderCUDAOpt2 { auto &sc = v_sc[k]; CUDA_CHECK( cudaStreamCreateWithFlags(&sc.stream, cudaStreamNonBlocking)); - CUDA_CHECK(cudaEventCreate(&sc.kernel_start)); - CUDA_CHECK(cudaEventCreate(&sc.kernel_stop)); + if (m_time_kernels) { + CUDA_CHECK(cudaEventCreate(&sc.kernel_start)); + CUDA_CHECK(cudaEventCreate(&sc.kernel_stop)); + } CUDA_CHECK(cudaMalloc(&sc.d_frame, m_image_bytes)); CUDA_CHECK(cudaMalloc(&sc.d_pd_mean, m_image_size * sizeof(PEDESTAL_TYPE))); @@ -246,14 +261,16 @@ class ClusterFinderCUDAOpt2 { cudaMemcpyHostToDevice, sc.stream)); // Timed Kernel launch - CUDA_CHECK(cudaEventRecord(sc.kernel_start, sc.stream)); + if (m_time_kernels) + CUDA_CHECK(cudaEventRecord(sc.kernel_start, sc.stream)); device_opt2::find_clusters_in_single_frame <<>>( sc.d_frame, sc.d_pd_mean, sc.d_pd_sum, sc.d_pd_sum2, n_pd_samples, m_nSigma, nrows, ncols, sc.d_clusters, sc.d_cluster_count, static_cast(m_capacity)); - CUDA_CHECK(cudaEventRecord(sc.kernel_stop, sc.stream)); + if (m_time_kernels) + CUDA_CHECK(cudaEventRecord(sc.kernel_stop, sc.stream)); CUDA_CHECK(cudaGetLastError()); // Read back cluster count into pinned buffer @@ -326,7 +343,8 @@ class ClusterFinderCUDAOpt2 { cudaMemcpyAsync(sc_k.d_frame, sc_k.h_frame, m_image_bytes, cudaMemcpyHostToDevice, sc_k.stream)); - CUDA_CHECK(cudaEventRecord(sc_k.kernel_start, sc_k.stream)); + if (m_time_kernels) + CUDA_CHECK(cudaEventRecord(sc_k.kernel_start, sc_k.stream)); device_opt2::find_clusters_in_single_frame< ClusterType, FRAME_TYPE, PEDESTAL_TYPE> <<>>( @@ -334,7 +352,8 @@ class ClusterFinderCUDAOpt2 { sc_k.d_pd_sum2, n_pd_samples, m_nSigma, nrows, ncols, sc_k.d_clusters, sc_k.d_cluster_count, static_cast(m_capacity)); - CUDA_CHECK(cudaEventRecord(sc_k.kernel_stop, sc_k.stream)); + if (m_time_kernels) + CUDA_CHECK(cudaEventRecord(sc_k.kernel_stop, sc_k.stream)); CUDA_CHECK(cudaGetLastError()); // Queue count D2H immediately after the kernel @@ -370,11 +389,17 @@ class ClusterFinderCUDAOpt2 { return results; } + /// NaN when timing was not enabled — deliberately not 0.0f, which reads as + /// a measurement. Use nsys for kernel times; the event number is inflated + /// by queue-wait under multi-stream load anyway. float avg_kernel_time_ms() const { - return m_frames_processed > 0 ? m_total_kernel_ms / m_frames_processed - : 0.0f; + if (!m_time_kernels || m_frames_processed == 0) + return std::numeric_limits::quiet_NaN(); + return m_total_kernel_ms / m_frames_processed; } + bool kernel_timing_enabled() const { return m_time_kernels; } + void reset_timers() { m_total_kernel_ms = 0.0f; m_frames_processed = 0; @@ -424,7 +449,13 @@ class ClusterFinderCUDAOpt2 { cv.push_back(sc.h_clusters[i]); } + /// Called after cudaStreamSynchronize, so cudaEventElapsedTime never blocks + /// on an incomplete event. m_frames_processed is only advanced when timing + /// is on, which is what makes avg_kernel_time_ms() return NaN rather than a + /// plausible-looking zero when it is off. void record_kernel_time(SC &sc) { + if (!m_time_kernels) + return; float ms = 0.0f; CUDA_CHECK(cudaEventElapsedTime(&ms, sc.kernel_start, sc.kernel_stop)); m_total_kernel_ms += ms; diff --git a/include/aare/ClusterFinderCUDA_graph.hpp b/include/aare/ClusterFinderCUDA_graph.hpp index 0ea861ae..c2a7b8bb 100644 --- a/include/aare/ClusterFinderCUDA_graph.hpp +++ b/include/aare/ClusterFinderCUDA_graph.hpp @@ -400,7 +400,15 @@ class ClusterFinderCUDAGraph { return results; } - float avg_kernel_time_ms() const { return 0.0f; } + /// Always NaN: the graph variant does not instrument individual kernels + /// (the whole H2D->kernel->D2H DAG is replayed as one unit). Use Nsight + /// Systems, or wall-clock timing around find_clusters_batched(). + float avg_kernel_time_ms() const { + return std::numeric_limits::quiet_NaN(); + } + + /// False: this variant never instruments kernels. + bool kernel_timing_enabled() const { return false; } void reset_timers() { m_frames_processed = 0; } diff --git a/python/aare/ClusterFinder.py b/python/aare/ClusterFinder.py index 53eae39a..348360fb 100644 --- a/python/aare/ClusterFinder.py +++ b/python/aare/ClusterFinder.py @@ -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): diff --git a/python/aare/__init__.py b/python/aare/__init__.py index 9059912a..709b8462 100644 --- a/python/aare/__init__.py +++ b/python/aare/__init__.py @@ -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 diff --git a/python/src/bind_ClusterFinderCUDA.hpp b/python/src/bind_ClusterFinderCUDA.hpp index 2d555394..13a237fb 100644 --- a/python/src/bind_ClusterFinderCUDA.hpp +++ b/python/src/bind_ClusterFinderCUDA.hpp @@ -35,10 +35,84 @@ void define_ClusterFinderCUDA(py::module &m, const std::string &typestr) { py::class_(m, (class_name + "_BatchToken").c_str()); + using VT = typename ClusterType::value_type; + constexpr size_t NPIX = + static_cast(ClusterSizeX) * static_cast(ClusterSizeY); + + // Zero-copy view into the finder's pinned D2H buffer. + py::class_(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 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 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(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(); + const uint32_t n = v.count(i); + // Zero-copy (n, NPIX) view; stride skips each cluster's x/y. + return py::array_t( + {static_cast(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(); + const uint32_t n = v.count(i); + return py::array_t( + {static_cast(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_(m, class_name.c_str()) - .def(py::init, 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, 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(), 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(), + 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(), + 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.)") diff --git a/python/src/bind_ClusterFinderCUDAGraph.hpp b/python/src/bind_ClusterFinderCUDAGraph.hpp index af9dcd2a..45109528 100644 --- a/python/src/bind_ClusterFinderCUDAGraph.hpp +++ b/python/src/bind_ClusterFinderCUDAGraph.hpp @@ -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) diff --git a/python/src/bind_ClusterFinderCUDAOpt2.hpp b/python/src/bind_ClusterFinderCUDAOpt2.hpp index 4d8e6c54..cf156d5b 100644 --- a/python/src/bind_ClusterFinderCUDAOpt2.hpp +++ b/python/src/bind_ClusterFinderCUDAOpt2.hpp @@ -36,9 +36,10 @@ void define_ClusterFinderCUDAOpt2(py::module &m, const std::string &typestr) { py::class_(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, 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, 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); } diff --git a/python/tests/ClusterFinderCUDA_perf.ipynb b/python/tests/ClusterFinderCUDA_perf.ipynb index 7902491a..0e810081 100644 --- a/python/tests/ClusterFinderCUDA_perf.ipynb +++ b/python/tests/ClusterFinderCUDA_perf.ipynb @@ -1,427 +1,508 @@ { - "cells": - [ - { - "cell_type": "markdown", - "id": "md-title", - "metadata": {}, - "source": [ - "# ClusterFinder — CPU vs CUDA performance\n", - "\n", - "Throughput comparison of the serial CPU finder against the CUDA variants:\n", - "per-frame, batched+pinned, CUDA-Graph, and the async double-buffered pipeline.\n", - "\n", - "CPU↔CUDA **correctness** (why the cluster counts differ) is analysed\n", - "separately in `ClusterFinderFrozen_vs_CUDA.ipynb`; here we only sanity-check that\n", - "the counts and spectra agree, and focus on timing." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "imports", - "metadata": {}, - "outputs": [], - "source": [ - "import sys; sys.path.append('/home/ferjao_k/aare/build')\n", - "\n", - "from pathlib import Path\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import boost_histogram as bh\n", - "import time\n", - "from tqdm import tqdm\n", - "\n", - "from aare import (File, ClusterFinder, ClusterFinderFrozen, ClusterFinderMT, ClusterCollector,\n", - " ClusterFinderCUDA, ClusterFinderCUDAGraph, ClusterFinderCUDAOpt2)\n", - "from helper import print_pinning_budget\n", - "\n", - "import resource\n", - "\n", - "def _faults():\n", - " r = resource.getrusage(resource.RUSAGE_SELF)\n", - " return r.ru_minflt, r.ru_majflt" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "hist-helpers", - "metadata": {}, - "outputs": [], - "source": [ - "N_BINS = 200\n", - "\n", - "def make_hist(clusters):\n", - " h = bh.Histogram(bh.axis.Regular(N_BINS, -2, 4000))\n", - " h.fill(clusters.sum())\n", - " return h\n", - "\n", - "def make_hist_from_batch(result_list):\n", - " h = bh.Histogram(bh.axis.Regular(N_BINS, -2, 4000))\n", - " energies = [np.asarray(cv.sum()).ravel() for cv in result_list if cv.size > 0]\n", - " if energies:\n", - " h.fill(np.concatenate(energies))\n", - " return h" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "config", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Image size: (400, 400)\n", - "Pedestal frames: 1000\n", - "Data frames: 100000\n", - "Total in file: 100000\n" - ] - } - ], - "source": [ - "base = Path('/mnt/sls_det_storage/moench_data/2603_MaxIVBeamtime/2026032408/process/xrf/')\n", - "f = File(base / 'Cu_factor_10_data_master_0.json')\n", - "pd = File(base / 'Cu_factor_10_pedestal_master_0.json')\n", - "\n", - "n_frames_pd = 1000\n", - "N = 100000\n", - "cluster_size = (3, 3)\n", - "rows, cols = f.rows, f.cols\n", - "image_size = (rows, cols)\n", - "capacity = 1000\n", - "BATCH_SIZE = 3000\n", - "\n", - "N_STREAMS = 4\n", - "N_SIGMA = 5\n", - "\n", - "print(f'Image size: {image_size}')\n", - "print(f'Pedestal frames: {n_frames_pd}')\n", - "print(f'Data frames: {N}')\n", - "print(f'Total in file: {f.total_frames}')" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "pinning", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "── System RAM ──────────────────────────────────────────\n", - " Total RAM : 125.1 GiB\n", - " Currently available : 101.2 GiB (free + reclaimable cache)\n", - " Reserved headroom : 4.0 GiB (OS + CUDA context)\n", - " Safe pinning budget : 97.2 GiB\n", - "\n", - "── Frame layout ────────────────────────────────────────\n", - " Frame size : 400 × 400 × 2 B = 312.5 kB\n", - "\n", - "── Pinning estimate ────────────────────────────────────\n", - " Max frames pinnable : 326,220 (97.2 GiB)\n", - "\n", - " Note: no swap on this machine — exceeding available RAM\n", - " will trigger the OOM killer. Stay within the budget.\n" - ] - } - ], - "source": [ - "print_pinning_budget(rows, cols)" - ] - }, - { - "cell_type": "markdown", - "id": "md-build", - "metadata": {}, - "source": [ - "## Build finders\n", - "\n", - "`SERIAL` picks the CPU baseline: the sequential `ClusterFinder` or the\n", - "multi-threaded `ClusterFinderMT`. All finders are trained on the same pedestal\n", - "frames below." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "build", - "metadata": {}, - "outputs": [], - "source": [ - "SERIAL = False\n", - "\n", - "if SERIAL:\n", - " # cf_cpu = ClusterFinder(image_size, cluster_size, n_sigma=N_SIGMA, capacity=capacity)\n", - " cf_cpu = ClusterFinderFrozen(image_size, cluster_size, n_sigma=N_SIGMA, capacity=capacity)\n", - "else:\n", - " cf_cpu = ClusterFinderMT(image_size, cluster_size, n_sigma=N_SIGMA,\n", - " capacity=capacity, n_threads=48)\n", - " sink = ClusterCollector(cf_cpu)\n", - "\n", - "cf_cuda_v1 = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", - " max_clusters_per_frame=3000, n_streams=N_STREAMS)\n", - "cf_cuda = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", - " max_clusters_per_frame=3000, n_streams=N_STREAMS)\n", - "# cf_async = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", - "# max_clusters_per_frame=3000, n_streams=N_STREAMS)\n", - "cf_graph = ClusterFinderCUDAGraph(image_size, cluster_size, n_sigma=N_SIGMA,\n", - " max_clusters_per_frame=3000, n_streams=N_STREAMS)" - ] - }, - { - "cell_type": "markdown", - "id": "md-ped", - "metadata": {}, - "source": [ - "## Pedestal (all finders trained on identical frames)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "train", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Pedestal (1000 frames): 10.754s\n" - ] - } - ], - "source": [ - "t0 = time.perf_counter()\n", - "for _ in range(n_frames_pd):\n", - " img = pd.read_frame()\n", - " cf_cpu.push_pedestal_frame(img.copy())\n", - " cf_cuda_v1.push_pedestal_frame(img.copy())\n", - " cf_cuda.push_pedestal_frame(img.copy())\n", - " # cf_async.push_pedestal_frame(img.copy())\n", - " cf_graph.push_pedestal_frame(img.copy())\n", - "print(f'Pedestal ({n_frames_pd} frames): {time.perf_counter() - t0:.3f}s')" - ] - }, - { - "cell_type": "markdown", - "id": "md-io", - "metadata": {}, - "source": [ - "## Read all data frames into memory (I/O out of the timing loop)" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "io", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Reading 100000 frames: 39.114s (2557 FPS, 780.217 GB/s)\n" - ] - } - ], - "source": [ - "f.seek(0)\n", - "t0 = time.perf_counter()\n", - "data = f.read_n(N)\n", - "t_io = time.perf_counter() - t0\n", - "print(f'Reading {N} frames: {t_io:.3f}s ({N/t_io:.0f} FPS, '\n", - " f'{f.bytes_per_frame * N / 1024**2 / t_io:.3f} GB/s)')" - ] - }, - { - "cell_type": "markdown", - "id": "md-cpu", - "metadata": {}, - "source": [ - "## CPU clustering (baseline)" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "cpu", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "100%|███████████████████████████████████████████████████████████████████████████████████████| 100000/100000 [00:20<00:00, 4805.12it/s]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " minor faults: 2,570,943 (~1.80s est.) major: 1\n", - "=========================================================================================\n", - "CPU clustering: 20.815s (4804 FPS, 233085343 clusters, 2330.85/frame)\n" - ] - } - ], - "source": [ - "mf0, Mf0 = _faults()\n", - "t0 = time.perf_counter()\n", - "for frame in tqdm(data):\n", - " cf_cpu.find_clusters(frame)\n", - "t_cpu = time.perf_counter() - t0\n", - "mf1, Mf1 = _faults() # bracket the timed loop only; MT drain + hist below allocate too\n", - "\n", - "if SERIAL:\n", - " clusters_cpu = cf_cpu.steal_clusters(realloc_same_capacity=False)\n", - " n_clusters_cpu = clusters_cpu.size\n", - " hist_cpu = make_hist(clusters_cpu)\n", - "else:\n", - " cf_cpu.stop(); sink.stop()\n", - " clusters_cpu = sink.steal_clusters()\n", - " hist_cpu = bh.Histogram(bh.axis.Regular(N_BINS, -2, 4000))\n", - " n_clusters_cpu = 0\n", - " for cv in clusters_cpu:\n", - " hist_cpu.fill(cv.sum())\n", - " n_clusters_cpu += cv.size\n", - "\n", - "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", - "print(\"=========================================================================================\")\n", - "print(f'CPU clustering: {t_cpu:.3f}s ({N/t_cpu:.0f} FPS, '\n", - " f'{n_clusters_cpu} clusters, {n_clusters_cpu/N:.2f}/frame)')" - ] - }, - { - "cell_type": "markdown", - "id": "md-v1", - "metadata": {}, - "source": [ - "## CUDA — per-frame (1 launch/frame, pageable memory)\n", - "Simplest path: no batching, no pinning. Isolates per-frame launch + PCIe overhead." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "v1", - "metadata": {}, - "outputs": [ - { - "ename": "KeyboardInterrupt", - "evalue": "", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mKeyboardInterrupt\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[9], line 8\u001b[0m\n\u001b[1;32m 6\u001b[0m hist_cuda_v1 \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m\n\u001b[1;32m 7\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m frame \u001b[38;5;129;01min\u001b[39;00m data:\n\u001b[0;32m----> 8\u001b[0m \u001b[43mcf_cuda_v1\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mfind_clusters\u001b[49m\u001b[43m(\u001b[49m\u001b[43mframe\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 9\u001b[0m clusters_frame \u001b[38;5;241m=\u001b[39m cf_cuda_v1\u001b[38;5;241m.\u001b[39msteal_clusters(realloc_same_capacity\u001b[38;5;241m=\u001b[39m\u001b[38;5;28;01mTrue\u001b[39;00m)\n\u001b[1;32m 10\u001b[0m n_clusters_cuda_v1 \u001b[38;5;241m+\u001b[39m\u001b[38;5;241m=\u001b[39m clusters_frame\u001b[38;5;241m.\u001b[39msize\n", - "\u001b[0;31mKeyboardInterrupt\u001b[0m: " - ] - } - ], - "source": [ - "cf_cuda_v1.reset_timers()\n", - "mf0, Mf0 = _faults()\n", - "t0 = time.perf_counter()\n", - "\n", - "n_clusters_cuda_v1 = 0\n", - "hist_cuda_v1 = None\n", - "for frame in data:\n", - " cf_cuda_v1.find_clusters(frame)\n", - " clusters_frame = cf_cuda_v1.steal_clusters(realloc_same_capacity=True)\n", - " n_clusters_cuda_v1 += clusters_frame.size\n", - " h = make_hist(clusters_frame)\n", - " hist_cuda_v1 = h if hist_cuda_v1 is None else hist_cuda_v1 + h\n", - "\n", - "t_cuda_v1 = time.perf_counter() - t0\n", - "mf1, Mf1 = _faults()\n", - "kernel_ms = cf_cuda_v1.avg_kernel_time_ms()\n", - "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", - "print(\"=========================================================================================\")\n", - "print(f'CUDA per-frame: {t_cuda_v1:.3f}s ({N/t_cuda_v1:.0f} FPS, '\n", - " f'{n_clusters_cuda_v1} clusters, {n_clusters_cuda_v1/N:.2f}/frame)')\n", - "print(f' Kernel only: {kernel_ms:.3f} ms/frame')\n", - "print(f' PCIe + overhead: {t_cuda_v1*1000/N - kernel_ms:.3f} ms/frame')\n", - "print(f'Speedup (CPU/CUDA): {t_cpu / t_cuda_v1:.2f}x')" - ] - }, - { - "cell_type": "markdown", - "id": "md-batched", - "metadata": {}, - "source": [ - "## CUDA — batched + multi-streamed + pinned dataset\n", - "Pins the whole input once, then submits `BATCH_SIZE`-frame chunks across `N_STREAMS` streams so H2D / kernel / D2H overlap." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "batched", - "metadata": {}, - "outputs": [], - "source": [ - "cf_cuda.register_input_buffer(data) # pin the whole dataset once\n", - "clusters_cuda_per_frame = []\n", - "\n", - "cf_cuda.reset_timers()\n", - "mf0, Mf0 = _faults()\n", - "t0 = time.perf_counter()\n", - "for start in range(0, N, BATCH_SIZE):\n", - " stop = min(start + BATCH_SIZE, N)\n", - " clusters_cuda_per_frame.extend(\n", - " cf_cuda.find_clusters_batched(data[start:stop], first_frame=start))\n", - "t_cuda = time.perf_counter() - t0\n", - "mf1, Mf1 = _faults() # before make_hist_from_batch, which allocates GBs itself\n", - "\n", - "cf_cuda.unregister_input_buffer()\n", - "kernel_ms = cf_cuda.avg_kernel_time_ms()\n", - "n_clusters_cuda = sum(cv.size for cv in clusters_cuda_per_frame)\n", - "hist_cuda = make_hist_from_batch(clusters_cuda_per_frame)\n", - "\n", - "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", - "print(\"=========================================================================================\")\n", - "print(f'CUDA batched: {t_cuda:.3f}s ({N/t_cuda:.0f} FPS, '\n", - " f'{n_clusters_cuda} clusters, {n_clusters_cuda/N:.2f}/frame)')\n", - "print(f' Kernel only: {kernel_ms:.3f} ms/frame')\n", - "print(f' PCIe + overhead: {t_cuda*1000/N - kernel_ms:.3f} ms/frame')\n", - "print(f'Speedup (CPU/CUDA): {t_cpu / t_cuda:.2f}x')" - ] - }, - { - "cell_type": "markdown", - "id": "md-asyncpipe", - "metadata": {}, - "source": [ - "## Async pipeline — is the wall clock host-bound?\n", - "\n", - "Diagnostic for the \"next moves\" slide. Both loops submit **slices of the already-pinned\n", - "`data`**, so neither pays a staging memcpy (the two-buffer pattern in the cell below is\n", - "only needed when frames are streamed in, not when the whole dataset is pinned in RAM).\n", - "\n", - "- **serial** `submit(b); collect(b)` -> GPU + host, serialized\n", - "- **pipelined** `submit(b+1)` before `collect(b)` -> max(GPU, host)\n", - "\n", - "`serial - pipelined` = min(GPU, host), i.e. how much time is hideable. Compare\n", - "`pipelined` against the exclusive GPU floor from nsys (`nsys_kernel_probe.py`) to see\n", - "which of the two is the real limit. Re-run the cell until minor faults plateau." - ] - }, - { - "cell_type": "code", - "id": "asyncpipe", - "metadata": {}, - "execution_count": null, - "outputs": [], - "source": [ + "cells": [ + { + "cell_type": "markdown", + "id": "md-title", + "metadata": {}, + "source": [ + "# ClusterFinder — CPU vs CUDA performance\n", + "\n", + "Throughput comparison of the serial CPU finder against the CUDA variants:\n", + "per-frame, batched+pinned, CUDA-Graph, and the async double-buffered pipeline.\n", + "\n", + "CPU↔CUDA **correctness** (why the cluster counts differ) is analysed\n", + "separately in `ClusterFinderFrozen_vs_CUDA.ipynb`; here we only sanity-check that\n", + "the counts and spectra agree, and focus on timing." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "import sys; sys.path.append('/home/ferjao_k/aare/build')\n", + "\n", + "from pathlib import Path\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import boost_histogram as bh\n", + "import time\n", + "from tqdm import tqdm\n", + "\n", + "from aare import (File, ClusterFinder, ClusterFinderFrozen, ClusterFinderMT, ClusterCollector,\n", + " ClusterFinderCUDA, ClusterFinderCUDAGraph, ClusterFinderCUDAOpt2)\n", + "from helper import print_pinning_budget\n", + "\n", + "import resource\n", + "\n", + "def _faults():\n", + " r = resource.getrusage(resource.RUSAGE_SELF)\n", + " return r.ru_minflt, r.ru_majflt" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "hist-helpers", + "metadata": {}, + "outputs": [], + "source": [ + "N_BINS = 200\n", + "\n", + "def make_hist(clusters):\n", + " h = bh.Histogram(bh.axis.Regular(N_BINS, -2, 4000))\n", + " h.fill(clusters.sum())\n", + " return h\n", + "\n", + "def make_hist_from_batch(result_list):\n", + " h = bh.Histogram(bh.axis.Regular(N_BINS, -2, 4000))\n", + " energies = [np.asarray(cv.sum()).ravel() for cv in result_list if cv.size > 0]\n", + " if energies:\n", + " h.fill(np.concatenate(energies))\n", + " return h" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "config", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Build: DEVICE_PED_TYPE = float\n", + "Image size: (400, 400)\n", + "Pedestal frames: 1000\n", + "Data frames: 100000\n", + "Total in file: 100000\n", + "Cluster cap: 3000 (per-frame output slot; D2H copies it whole)\n" + ] + } + ], + "source": [ + "base = Path('/mnt/sls_det_storage/moench_data/2603_MaxIVBeamtime/2026032408/process/xrf/')\n", + "f = File(base / 'Cu_factor_10_data_master_0.json')\n", + "pd = File(base / 'Cu_factor_10_pedestal_master_0.json')\n", + "\n", + "n_frames_pd = 1000\n", + "N = 100000\n", + "cluster_size = (3, 3)\n", + "rows, cols = f.rows, f.cols\n", + "image_size = (rows, cols)\n", + "capacity = 10000\n", + "BATCH_SIZE = 2000\n", + "\n", + "N_STREAMS = 4\n", + "N_SIGMA = 5\n", + "\n", + "# Cluster cap = the fixed-size per-frame output slot the kernel writes into and\n", + "# D2H copies WHOLE every frame, regardless of how full it is. MEASURED against\n", + "# the per-frame maximum, never guessed: 2 545 at 3x3 (100k frames), 1 633 at 9x9\n", + "# (20k). The earlier campaigns ran 9x9 at 1500, which is BELOW that maximum --\n", + "# the kernel guards only the write and the host clamps the count, so it silently\n", + "# dropped 2 715 clusters (0.0095%) and made a truncation artefact look like a\n", + "# CPU-vs-CUDA precision bug. Raising it is not free at 9x9: the slot grows\n", + "# 480.5 -> 544.5 KiB and D2H 22.8 -> 25.2 us/frame, which on the f32 build is\n", + "# the binding engine. See run_ladder.py CONFIGS and report section 8.\n", + "CAP = 1700 if cluster_size == (9, 9) else 3000\n", + "\n", + "# Per-frame CUDA-event kernel timing. Off by default: it adds 2 event records\n", + "# per frame to the streams + a host query per frame, and the number is only\n", + "# meaningful at n_streams=1. Flip to True to A/B what the instrumentation costs.\n", + "TIME_KERNELS = False\n", + "\n", + "# Build identity. DEVICE_PED_TYPE is a COMPILE-TIME axis (the opt6/opt7 arm), so\n", + "# it cannot be read back off a finder: device_pedestal() hands out an NDArray of\n", + "# the HOST pedestal type and upcasts, so its dtype is float64 on both builds.\n", + "# common.device_ped_type() parses the header instead, and assert_build_fresh()\n", + "# stops that from being a lie by refusing to run if any kernel header is newer\n", + "# than the compiled .so -- the exact failure that produced one quarantined\n", + "# probe set (results/2026-08-20_INVALID_stale_build).\n", + "sys.path.insert(0, '/home/ferjao_k/aare/python/tests/perf')\n", + "import common\n", + "common.assert_build_fresh()\n", + "ARM = common.device_ped_type() # 'float' | 'double'\n", + "\n", + "print(f'Build: DEVICE_PED_TYPE = {ARM}')\n", + "print(f'Image size: {image_size}')\n", + "print(f'Pedestal frames: {n_frames_pd}')\n", + "print(f'Data frames: {N}')\n", + "print(f'Total in file: {f.total_frames}')\n", + "print(f'Cluster cap: {CAP} (per-frame output slot; D2H copies it whole)')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "pinning", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "── System RAM ──────────────────────────────────────────\n", + " Total RAM : 125.1 GiB\n", + " Currently available : 95.6 GiB (free + reclaimable cache)\n", + " Reserved headroom : 4.0 GiB (OS + CUDA context)\n", + " Safe pinning budget : 91.6 GiB\n", + "\n", + "── Frame layout ────────────────────────────────────────\n", + " Frame size : 400 × 400 × 2 B = 312.5 kB\n", + "\n", + "── Pinning estimate ────────────────────────────────────\n", + " Max frames pinnable : 307,378 (91.6 GiB)\n", + "\n", + " Note: no swap on this machine — exceeding available RAM\n", + " will trigger the OOM killer. Stay within the budget.\n" + ] + } + ], + "source": [ + "print_pinning_budget(rows, cols)" + ] + }, + { + "cell_type": "markdown", + "id": "md-build", + "metadata": {}, + "source": [ + "## Build finders\n", + "\n", + "`SERIAL` picks the CPU baseline: the sequential `ClusterFinder` or the\n", + "multi-threaded `ClusterFinderMT`. All finders are trained on the same pedestal\n", + "frames below." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "build", + "metadata": {}, + "outputs": [], + "source": [ + "SERIAL = False\n", + "\n", + "if SERIAL:\n", + " # cf_cpu = ClusterFinder(image_size, cluster_size, n_sigma=N_SIGMA, capacity=capacity)\n", + " cf_cpu = ClusterFinderFrozen(image_size, cluster_size, n_sigma=N_SIGMA, capacity=capacity)\n", + "else:\n", + " # Thread count is per cluster size, measured (perf/cpu_threads.py): 24 is the\n", + " # 3x3 optimum, 32 the 9x9 one. Past 32 the ClusterCollector drain -- which is\n", + " # inside the timed region -- costs more than the extra workers buy, and 9x9\n", + " # clusters are 9x larger, so the turnover happens later.\n", + " cf_cpu = ClusterFinderMT(image_size, cluster_size, n_sigma=N_SIGMA,\n", + " capacity=capacity,\n", + " n_threads=32 if cluster_size == (9, 9) else 24)\n", + " sink = ClusterCollector(cf_cpu)\n", + " \n", + "cf_cuda_v1 = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", + " max_clusters_per_frame=CAP,\n", + " n_streams=N_STREAMS, time_kernels=TIME_KERNELS)\n", + "cf_cuda = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", + " max_clusters_per_frame=CAP,\n", + " n_streams=N_STREAMS, time_kernels=TIME_KERNELS)\n", + "cf_graph = ClusterFinderCUDAGraph(image_size, cluster_size, n_sigma=N_SIGMA,\n", + " max_clusters_per_frame=CAP,\n", + " n_streams=N_STREAMS)" + ] + }, + { + "cell_type": "markdown", + "id": "md-ped", + "metadata": {}, + "source": [ + "## Pedestal (all finders trained on identical frames)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "train", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Pedestal (1000 frames): 4.496s\n" + ] + } + ], + "source": [ + "t0 = time.perf_counter()\n", + "for _ in range(n_frames_pd):\n", + " img = pd.read_frame()\n", + " cf_cpu.push_pedestal_frame(img.copy())\n", + " cf_cuda_v1.push_pedestal_frame(img.copy())\n", + " cf_cuda.push_pedestal_frame(img.copy())\n", + " # cf_async.push_pedestal_frame(img.copy())\n", + " cf_graph.push_pedestal_frame(img.copy())\n", + "print(f'Pedestal ({n_frames_pd} frames): {time.perf_counter() - t0:.3f}s')" + ] + }, + { + "cell_type": "markdown", + "id": "md-io", + "metadata": {}, + "source": [ + "## Read all data frames into memory (I/O out of the timing loop)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "io", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Reading 100000 frames: 44.883s (2228 FPS, 679.937 GB/s)\n" + ] + } + ], + "source": [ + "f.seek(0)\n", + "t0 = time.perf_counter()\n", + "data = f.read_n(N)\n", + "t_io = time.perf_counter() - t0\n", + "print(f'Reading {N} frames: {t_io:.3f}s ({N/t_io:.0f} FPS, '\n", + " f'{f.bytes_per_frame * N / 1024**2 / t_io:.3f} GB/s)')" + ] + }, + { + "cell_type": "markdown", + "id": "md-cpu", + "metadata": {}, + "source": [ + "## CPU clustering (baseline)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cpu", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|███████| 100000/100000 [00:14<00:00, 6886.40it/s]\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " minor faults: 2,502,285 (~1.75s est.) major: 0\n", + "=========================================================================================\n", + "CPU clustering: 14.524s (6885 FPS, 233087992 clusters, 2330.88/frame)\n" + ] + } + ], + "source": [ + "mf0, Mf0 = _faults()\n", + "t0 = time.perf_counter()\n", + "for frame in tqdm(data):\n", + " cf_cpu.find_clusters(frame)\n", + "t_cpu = time.perf_counter() - t0\n", + "mf1, Mf1 = _faults() # bracket the timed loop only; MT drain + hist below allocate too\n", + "\n", + "if SERIAL:\n", + " clusters_cpu = cf_cpu.steal_clusters(realloc_same_capacity=False)\n", + " n_clusters_cpu = clusters_cpu.size\n", + " hist_cpu = make_hist(clusters_cpu)\n", + "else:\n", + " cf_cpu.stop(); sink.stop()\n", + " clusters_cpu = sink.steal_clusters()\n", + " hist_cpu = bh.Histogram(bh.axis.Regular(N_BINS, -2, 4000))\n", + " n_clusters_cpu = 0\n", + " for cv in clusters_cpu:\n", + " hist_cpu.fill(cv.sum())\n", + " n_clusters_cpu += cv.size\n", + "\n", + "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", + "print(\"=========================================================================================\")\n", + "print(f'CPU clustering: {t_cpu:.3f}s ({N/t_cpu:.0f} FPS, '\n", + " f'{n_clusters_cpu} clusters, {n_clusters_cpu/N:.2f}/frame)')" + ] + }, + { + "cell_type": "markdown", + "id": "md-v1", + "metadata": {}, + "source": [ + "## CUDA — per-frame (1 launch/frame, pageable memory)\n", + "Simplest path: no batching, no pinning. Isolates per-frame launch + PCIe overhead." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "v1", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " minor faults: 1,602 (~0.00s est.) major: 0\n", + "=========================================================================================\n", + "CUDA per-frame: 10.572s (9459 FPS, 233094769 clusters, 2330.95/frame)\n", + " Kernel only: nan ms/frame\n", + " PCIe + overhead: nan ms/frame\n", + "Speedup (CPU/CUDA): 1.37x\n" + ] + } + ], + "source": [ + "cf_cuda_v1.reset_timers()\n", + "mf0, Mf0 = _faults()\n", + "t0 = time.perf_counter()\n", + "\n", + "n_clusters_cuda_v1 = 0\n", + "hist_cuda_v1 = None\n", + "for frame in data:\n", + " cf_cuda_v1.find_clusters(frame)\n", + " clusters_frame = cf_cuda_v1.steal_clusters(realloc_same_capacity=True)\n", + " n_clusters_cuda_v1 += clusters_frame.size\n", + " h = make_hist(clusters_frame)\n", + " hist_cuda_v1 = h if hist_cuda_v1 is None else hist_cuda_v1 + h\n", + "\n", + "t_cuda_v1 = time.perf_counter() - t0\n", + "mf1, Mf1 = _faults()\n", + "kernel_ms = cf_cuda_v1.avg_kernel_time_ms()\n", + "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", + "print(\"=========================================================================================\")\n", + "print(f'CUDA per-frame: {t_cuda_v1:.3f}s ({N/t_cuda_v1:.0f} FPS, '\n", + " f'{n_clusters_cuda_v1} clusters, {n_clusters_cuda_v1/N:.2f}/frame)')\n", + "print(f' Kernel only: {kernel_ms:.3f} ms/frame')\n", + "print(f' PCIe + overhead: {t_cuda_v1*1000/N - kernel_ms:.3f} ms/frame')\n", + "print(f'Speedup (CPU/CUDA): {t_cpu / t_cuda_v1:.2f}x')" + ] + }, + { + "cell_type": "markdown", + "id": "md-batched", + "metadata": {}, + "source": [ + "## CUDA — batched + multi-streamed + pinned dataset\n", + "Pins the whole input once, then submits `BATCH_SIZE`-frame chunks across `N_STREAMS` streams so H2D / kernel / D2H overlap." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "batched", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " minor faults: 2,294,417 (~1.61s est.) major: 0\n", + "=========================================================================================\n", + "CUDA batched: 3.244s total | 0.032 ms/frame (30823 FPS, 233093554 clusters, 2330.94/frame)\n", + " Kernel only: nan ms/frame\n", + " PCIe + overhead: nan ms/frame\n", + "Speedup (CPU/CUDA): 4.48x\n" + ] + } + ], + "source": [ + "cf_cuda.register_input_buffer(data) # pin the whole dataset once\n", + "clusters_cuda_per_frame = []\n", + "\n", + "cf_cuda.reset_timers()\n", + "mf0, Mf0 = _faults()\n", + "t0 = time.perf_counter()\n", + "for start in range(0, N, BATCH_SIZE):\n", + " stop = min(start + BATCH_SIZE, N)\n", + " clusters_cuda_per_frame.extend(\n", + " cf_cuda.find_clusters_batched(data[start:stop], first_frame=start))\n", + "t_cuda = time.perf_counter() - t0\n", + "mf1, Mf1 = _faults() # before make_hist_from_batch, which allocates GBs itself\n", + "\n", + "cf_cuda.unregister_input_buffer()\n", + "kernel_ms = cf_cuda.avg_kernel_time_ms()\n", + "n_clusters_cuda = sum(cv.size for cv in clusters_cuda_per_frame)\n", + "hist_cuda = make_hist_from_batch(clusters_cuda_per_frame)\n", + "\n", + "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", + "print(\"=========================================================================================\")\n", + "print(f'CUDA batched: {t_cuda:.3f}s total | {t_cuda*1000/N:.3f} ms/frame ({N/t_cuda:.0f} FPS, '\n", + " f'{n_clusters_cuda} clusters, {n_clusters_cuda/N:.2f}/frame)')\n", + "print(f' Kernel only: {kernel_ms:.3f} ms/frame')\n", + "print(f' PCIe + overhead: {t_cuda*1000/N - kernel_ms:.3f} ms/frame')\n", + "print(f'Speedup (CPU/CUDA): {t_cpu / t_cuda:.2f}x')" + ] + }, + { + "cell_type": "markdown", + "id": "md-asyncpipe", + "metadata": {}, + "source": [ + "## Async pipeline — is the wall clock host-bound?\n", + "\n", + "Diagnostic for the \"next moves\" slide. Both loops submit **slices of the already-pinned\n", + "`data`**, so neither pays a staging memcpy (the two-buffer pattern in the cell below is\n", + "only needed when frames are streamed in, not when the whole dataset is pinned in RAM).\n", + "\n", + "- **serial** `submit(b); collect(b)` -> GPU + host, serialized\n", + "- **pipelined** `submit(b+1)` before `collect(b)` -> max(GPU, host)\n", + "\n", + "`serial - pipelined` = min(GPU, host), i.e. how much time is hideable. Compare\n", + "`pipelined` against the exclusive GPU floor from nsys (`nsys_kernel_probe.py`) to see\n", + "which of the two is the real limit. Re-run the cell until minor faults plateau." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "asyncpipe", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " minor faults: pipelined 2,050,435 (slots pre-pinned; the rest is result heap)\n", + "=========================================================================================\n", + "pipelined (max of both): 3.014s ( 33,174 FPS) 30.1 us/frame 233093554 clusters\n", + " vs find_clusters_batched: 1.08x (32.4 -> 30.1 us/frame)\n", + " kernel (event timer): nan us/frame (inflated under 4 streams — use nsys for the true value)\n", + "\n", + "Interpretation: if pipelined ~= the nsys GPU floor, the remaining cost was host-side\n", + "and is now hidden. If pipelined stays well above it, the host is the binding limit.\n" + ] + } + ], + "source": + [ "# Async pipeline vs serial — no staging memcpy, slices of the pinned dataset.\n", + "# Drop the previous run's results FIRST. Otherwise the old res_pipe (~9.3 GB at\n", + "# 9x9) is still alive while the serial loop allocates its own, so the heap has to\n", + "# grow and the fault count never plateaus. res_pipe = [] below is too late: it\n", + "# frees inside the timed section.\n", + "for _v in ('res_serial', 'res_pipe'):\n", + " globals().pop(_v, None)\n", + "\n", + "# The serial loop is a reference point, but it also doubles as an accidental\n", + "# warm-up: it leaves both the heap and the pinned slots hot for the pipelined\n", + "# loop below. Set False to time the pipelined loop on its own and check that the\n", + "# fault count really is zero rather than merely absorbed upstream.\n", + "RUN_SERIAL = False\n", + "\n", "cf_async = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", - " max_clusters_per_frame=3000, n_streams=N_STREAMS)\n", + " max_clusters_per_frame=CAP,\n", + " n_streams=N_STREAMS, time_kernels=TIME_KERNELS)\n", "pd.seek(0)\n", "for _ in range(n_frames_pd):\n", " cf_async.push_pedestal_frame(pd.read_frame().copy())\n", @@ -429,48 +510,64 @@ "cf_async.register_input_buffer(data) # pin once; both loops reuse it\n", "bounds = [(s, min(s + BATCH_SIZE, N)) for s in range(0, N, BATCH_SIZE)]\n", "\n", + "# Pre-pin both output slots OUTSIDE any timed region. submit_batch() honours the\n", + "# batch size it is handed — MAX_SLOT_BYTES caps only the *auto* chunk — so these\n", + "# slots are BATCH_SIZE frames each: 240 MB at 3x3, 984 MB at 9x9, times two.\n", + "# Allocated lazily that is ~120 ms / ~480 ms of page-locking landing inside\n", + "# whichever loop runs first. Allocates only: no transfer, no launch, and the\n", + "# pedestal is NOT advanced, so cf_async stays comparable with the other finders.\n", + "\n", + "cf_async.reserve_output_slots(BATCH_SIZE)\n", + "\n", "# ---- 1. SERIAL: submit then immediately collect (no overlap) ----------------\n", - "cf_async.reset_timers()\n", - "mf0, Mf0 = _faults()\n", - "t0 = time.perf_counter()\n", - "res_serial = []\n", - "for a, b in bounds:\n", - " tok = cf_async.submit_batch(data[a:b], first_frame=a)\n", - " res_serial.extend(cf_async.collect(tok))\n", - "t_serial = time.perf_counter() - t0\n", - "mf1, Mf1 = _faults()\n", - "f_serial = mf1 - mf0\n", - "n_serial = sum(cv.size for cv in res_serial)\n", - "del res_serial # free before the next loop\n", + "if RUN_SERIAL:\n", + " cf_async.reset_timers()\n", + " mf0, Mf0 = _faults()\n", + " t0 = time.perf_counter()\n", + " res_serial = []\n", + " for a, b in bounds:\n", + " tok = cf_async.submit_batch(data[a:b], first_frame=a)\n", + " res_serial.extend(cf_async.collect(tok))\n", + " t_serial = time.perf_counter() - t0\n", + " mf1, Mf1 = _faults()\n", + " f_serial = mf1 - mf0\n", + " n_serial = sum(cv.size for cv in res_serial)\n", + " del res_serial # free before the next loop\n", "\n", "# ---- 2. PIPELINED: keep one batch in flight while collecting the previous ---\n", + "# Reset again: avg_kernel_time_ms() is total_kernel_ms / frames_processed, and\n", + "# both loops accumulate into the same counters. Without this the kernel time\n", + "# printed below is an average over 40 000 frames from two different regimes.\n", + "cf_async.reset_timers()\n", "mf2, Mf2 = _faults()\n", "t0 = time.perf_counter()\n", "res_pipe = []\n", "tok = cf_async.submit_batch(data[bounds[0][0]:bounds[0][1]], first_frame=bounds[0][0])\n", "for a, b in bounds[1:]:\n", " nxt = cf_async.submit_batch(data[a:b], first_frame=a) # GPU starts batch N+1\n", - " res_pipe.extend(cf_async.collect(tok)) # host marshals batch N\n", + " res_pipe.extend(cf_async.collect(tok)) # host materializes batch N\n", " tok = nxt\n", "res_pipe.extend(cf_async.collect(tok)) # drain the last one\n", "t_pipe = time.perf_counter() - t0\n", "mf3, Mf3 = _faults()\n", "f_pipe = mf3 - mf2\n", - "n_pipe = sum(cv.size for cv in res_pipe)\n", + "n_clusters_async = sum(cv.size for cv in res_pipe)\n", + "hist_async = make_hist_from_batch(res_pipe)\n", "\n", "cf_async.unregister_input_buffer()\n", "\n", "us = lambda t: t * 1e6 / N\n", - "hidden = us(t_serial) - us(t_pipe)\n", - "print(f' minor faults: serial {f_serial:,} pipelined {f_pipe:,} '\n", - " f'(re-run until both plateau)')\n", + "print(f' minor faults: ' + (f'serial {f_serial:,} ' if RUN_SERIAL else '')\n", + " + f'pipelined {f_pipe:,} (slots pre-pinned; the rest is result heap)')\n", "print(\"=========================================================================================\")\n", - "print(f'serial (GPU + host): {t_serial:.3f}s ({N/t_serial:8,.0f} FPS) {us(t_serial):6.1f} us/frame '\n", - " f'{n_serial} clusters')\n", + "if RUN_SERIAL:\n", + " print(f'serial (GPU + host): {t_serial:.3f}s ({N/t_serial:8,.0f} FPS) {us(t_serial):6.1f} us/frame '\n", + " f'{n_serial} clusters')\n", "print(f'pipelined (max of both): {t_pipe:.3f}s ({N/t_pipe:8,.0f} FPS) {us(t_pipe):6.1f} us/frame '\n", - " f'{n_pipe} clusters')\n", - "print(f' hidden by overlap: {hidden:6.1f} us/frame = min(GPU, host)')\n", - "print(f' speedup pipelined/serial: {t_serial / t_pipe:.2f}x')\n", + " f'{n_clusters_async} clusters')\n", + "if RUN_SERIAL:\n", + " print(f' hidden by overlap: {us(t_serial) - us(t_pipe):6.1f} us/frame = min(GPU, host)')\n", + " print(f' speedup pipelined/serial: {t_serial / t_pipe:.2f}x')\n", "try:\n", " print(f' vs find_clusters_batched: {t_cuda / t_pipe:.2f}x '\n", " f'({us(t_cuda):.1f} -> {us(t_pipe):.1f} us/frame)')\n", @@ -482,317 +579,490 @@ "print('Interpretation: if pipelined ~= the nsys GPU floor, the remaining cost was host-side')\n", "print('and is now hidden. If pipelined stays well above it, the host is the binding limit.')\n" ] - }, - { - "cell_type": "markdown", - "id": "md-graph", - "metadata": {}, - "source": [ - "## CUDA — Graph (pinned dataset)\n", - "Pre-records the H2D->kernel->D2H pipeline as a CUDA Graph per stream, cutting per-frame CPU API overhead (~21 us vs ~60 us for the streamed version)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "graph", - "metadata": {}, - "outputs": [], - "source": [ - "cf_graph.register_input_buffer(data)\n", - "clusters_graph_per_frame = []\n", - "\n", - "mf0, Mf0 = _faults()\n", - "t0 = time.perf_counter()\n", - "for start in range(0, N, BATCH_SIZE):\n", - " stop = min(start + BATCH_SIZE, N)\n", - " clusters_graph_per_frame.extend(\n", - " cf_graph.find_clusters_batched(data[start:stop], first_frame=start))\n", - "t_graph = time.perf_counter() - t0\n", - "mf1, Mf1 = _faults() # before make_hist_from_batch, which allocates GBs itself\n", - "\n", - "cf_graph.unregister_input_buffer()\n", - "n_clusters_graph = sum(cv.size for cv in clusters_graph_per_frame)\n", - "hist_graph = make_hist_from_batch(clusters_graph_per_frame)\n", - "\n", - "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", - "print(\"=========================================================================================\")\n", - "print(f'CUDA Graph: {t_graph:.3f}s ({N/t_graph:.0f} FPS, '\n", - " f'{n_clusters_graph} clusters, {n_clusters_graph/N:.2f}/frame)')\n", - "print(f' Kernel+PCIe+ovhd: {t_graph*1000/N:.3f} ms/frame (kernel not individually timed)')\n", - "print(f'Speedup (vs batched): {t_cuda / t_graph:.2f}x')\n", - "print(f'Speedup (CPU/Graph): {t_cpu / t_graph:.2f}x')" - ] - }, - { - "cell_type": "markdown", - "id": "md-async", - "metadata": {}, - "source": [ - "## CUDA — async double-buffered pipeline\n", - "Two pinned batch buffers: while the GPU processes `buf[cur]`, the CPU fills `buf[nxt]`, so H2D copies and kernels stay back-to-back." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "async", - "metadata": {}, - "outputs": [], - "source": [ - "# buf = [np.empty((BATCH_SIZE, rows, cols), dtype=np.uint16) for _ in range(2)]\n", - "# cf_async.pin_buffer(buf[0])\n", - "# cf_async.pin_buffer(buf[1])\n", - "\n", - "# clusters_async = []\n", - "# cf_async.reset_timers()\n", - "# t0 = time.perf_counter()\n", - "\n", - "# cur = 0\n", - "# n0 = min(BATCH_SIZE, N)\n", - "# buf[cur][:n0] = data[:n0]\n", - "# tok = cf_async.submit_batch(buf[cur][:n0], first_frame=0)\n", - "\n", - "# for start in range(BATCH_SIZE, N, BATCH_SIZE):\n", - "# nxt = 1 - cur\n", - "# stop = min(start + BATCH_SIZE, N)\n", - "# n = stop - start\n", - "# buf[nxt][:n] = data[start:stop] # fill next while GPU runs current\n", - "# next_tok = cf_async.submit_batch(buf[nxt][:n], first_frame=start)\n", - "# clusters_async.extend(cf_async.collect(tok)) # drain previous\n", - "# tok = next_tok\n", - "# cur = nxt\n", - "# clusters_async.extend(cf_async.collect(tok)) # drain final\n", - "\n", - "# t_async = time.perf_counter() - t0\n", - "# cf_async.unpin_buffer(buf[0]); cf_async.unpin_buffer(buf[1])\n", - "\n", - "# kernel_ms_async = cf_async.avg_kernel_time_ms()\n", - "# n_clusters_async = sum(cv.size for cv in clusters_async)\n", - "# hist_async = make_hist_from_batch(clusters_async)\n", - "\n", - "# print(f'CUDA async pipeline: {t_async:.3f}s ({N/t_async:.0f} FPS, '\n", - "# f'{n_clusters_async} clusters, {n_clusters_async/N:.2f}/frame)')\n", - "# print(f' Kernel only: {kernel_ms_async:.3f} ms/frame')\n", - "# print(f' PCIe + overhead: {t_async*1000/N - kernel_ms_async:.3f} ms/frame')\n", - "# print(f'Speedup (vs Graph): {t_graph / t_async:.2f}x')" - ] - }, - { - "cell_type": "markdown", - "id": "md-agree", - "metadata": {}, - "source": [ - "## Agreement sanity check\n", - "Counts should match to a hair. The residual comes from the CUDA finder updating the pedestal once per frame vs the CPU's per-pixel update (analysed in `ClusterFinderFrozen_vs_CUDA.ipynb`)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "agree", - "metadata": {}, - "outputs": [], - "source": [ - "for name, n in [('CPU', n_clusters_cpu), ('CUDA per-frame', n_clusters_cuda_v1),\n", - " ('CUDA batched', n_clusters_cuda), ('CUDA graph', n_clusters_graph)]:\n", - " # ('CUDA async', n_clusters_async)]:\n", - " d = abs(n - n_clusters_cpu)\n", - " print(f' {name:<16} {n:>12,} diff vs CPU {d:>6,} ({d/max(n_clusters_cpu,1):.4%})')" - ] - }, - { - "cell_type": "markdown", - "id": "md-plots", - "metadata": {}, - "source": [ - "## Spectrum: CPU vs CUDA variants" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "plots", - "metadata": {}, - "outputs": [], - "source": [ + }, + { + "cell_type": "markdown", + "id": "md-graph", + "metadata": {}, + "source": [ + "## CUDA — Graph (pinned dataset)\n", + "Pre-records the H2D->kernel->D2H pipeline as a CUDA Graph per stream, cutting per-frame CPU API overhead (~21 us vs ~60 us for the streamed version)." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "graph", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " minor faults: 2,107,303 (~1.48s est.) major: 0\n", + "=========================================================================================\n", + "CUDA Graph: 4.105s (24358 FPS, 233093554 clusters, 2330.94/frame)\n", + " Kernel only: nan ms/frame\n", + " Kernel+PCIe+ovhd: 0.041 ms/frame (kernel not individually timed)\n", + "Speedup (vs batched): 0.79x\n", + "Speedup (CPU/Graph): 3.54x\n" + ] + } + ], + "source": [ + "cf_graph.register_input_buffer(data)\n", + "clusters_graph_per_frame = []\n", + "\n", + "mf0, Mf0 = _faults()\n", + "t0 = time.perf_counter()\n", + "for start in range(0, N, BATCH_SIZE):\n", + " stop = min(start + BATCH_SIZE, N)\n", + " clusters_graph_per_frame.extend(\n", + " cf_graph.find_clusters_batched(data[start:stop], first_frame=start))\n", + "t_graph = time.perf_counter() - t0\n", + "mf1, Mf1 = _faults() # before make_hist_from_batch, which allocates GBs itself\n", + "\n", + "cf_graph.unregister_input_buffer()\n", + "n_clusters_graph = sum(cv.size for cv in clusters_graph_per_frame)\n", + "hist_graph = make_hist_from_batch(clusters_graph_per_frame)\n", + "\n", + "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", + "print(\"=========================================================================================\")\n", + "print(f'CUDA Graph: {t_graph:.3f}s ({N/t_graph:.0f} FPS, '\n", + " f'{n_clusters_graph} clusters, {n_clusters_graph/N:.2f}/frame)')\n", + "print(f' Kernel only: {cf_graph.avg_kernel_time_ms():.3f} ms/frame')\n", + "print(f' Kernel+PCIe+ovhd: {t_graph*1000/N:.3f} ms/frame (kernel not individually timed)')\n", + "print(f'Speedup (vs batched): {t_cuda / t_graph:.2f}x')\n", + "print(f'Speedup (CPU/Graph): {t_cpu / t_graph:.2f}x')" + ] + }, + { + "cell_type": "markdown", + "id": "md-zerocopy", + "metadata": {}, + "source": [ + "## Zero-copy collection — `collect_view()`\n", + "\n", + "`find_clusters_batched` / `collect()` allocate one `ClusterVector` **per frame** and copy\n", + "into it. `collect_view()` copies nothing: the pinned D2H buffer already holds correctly\n", + "laid-out clusters at a fixed per-frame stride, so the view just exposes offsets into it.\n", + "\n", + "The copy it removes is **~8 us/frame at 3x3 and ~40 us/frame at 9x9** (bytes / memcpy\n", + "bandwidth). What that is *worth* end to end is `max(0, copy - GPU floor)`, so the two\n", + "sizes pay very differently: 19.8 -> 17.1 us/frame at 3x3 (x1.16, the copy mostly hid\n", + "under the GPU already) but 66.4 -> 30.0 at 9x9 (x2.21, where it could not hide at any\n", + "overlap). Both are `[f64]`, warm, 5 reps -- report section 8.\n", + "\n", + "The view borrows the finder's slot and is released at the end of each loop iteration,\n", + "so **anything you need afterwards must be copied out**. `sums()` returns an owned array\n", + "and is safe; `frame_data(i)` / `frame_xy(i)` are views and are not.\n", + "\n", + "`sums()` is timed separately below: `collect()` copies but does not reduce, so comparing\n", + "it against `collect_view()` + `sums()` compares different work. The bare line is the one\n", + "to put next to the batched / graph / async cells." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "zerocopy", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " minor faults: 1 (should be ~0 after the warm-up above)\n", + " chunk: 1120 frames/chunk build float - cap 3000 - 4 streams\n", + "=========================================================================================\n", + "GPU roofline (report 4): 16.3 us/frame ( 61,312 FPS) = 1 / max(kernel, H2D, D2H)\n", + "==> transport only: 16.5 us/frame ( 60,670 FPS) 99% of roofline\n", + "\n", + "find_clusters_batched: 32.4 us/frame ( 30,823 FPS) 50% of roofline (copies clusters, no reduction)\n", + " -> zero-copy transport is 1.97x\n" + ] + } + ], + "source": [ + "# Zero-copy collection vs the collect() cells above.\n", + "from aare import find_cluster_views_batched_iter\n", + "\n", + "# Set False to time the transport alone (no reduction, no histogram) — that is\n", + "# the GPU-pipeline number to compare against the nsys roofline.\n", + "WITH_ANALYSIS = False\n", + "\n", + "# Roofline = 1 / max(kernel, H2D, D2H), each term that engine's BUSY TIME per\n", + "# frame -- the union of its intervals, not a duration -- at n_streams=4, and\n", + "# taken as the LOWER of two estimates: the nsys probe, and the best rate the\n", + "# unprofiled pipeline sustained. Source: perf/results/2026-08-20_{f32,f64}_cap1700\n", + "# probes.csv via gpu_span.py; the table is section 4 of the report.\n", + "#\n", + "# arm size cap probe sustained PEAK binds\n", + "# double 3x3 3000 16.17 17.10 16.17 H2D\n", + "# float 3x3 3000 16.63 16.31 16.31 H2D\n", + "# double 9x9 1700 32.66 30.01 30.01 kernel\n", + "# float 9x9 1700 25.24 25.14 25.14 D2H (opt7 put the kernel under it)\n", + "#\n", + "# Neither estimate is exact and the two bracket the truth to ~2%, so a run that\n", + "# lands BETWEEN them (16.5 us on f32 3x3, say) is at the floor, not short of it.\n", + "PEAK_US = {('double', (3, 3)): 16.17, ('float', (3, 3)): 16.31,\n", + " ('double', (9, 9)): 30.01, ('float', (9, 9)): 25.14}\n", + "GPU_FLOOR_US = PEAK_US[(ARM, cluster_size)] # ARM comes from the config cell\n", + "\n", + "cf_zc = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", + " max_clusters_per_frame=CAP,\n", + " n_streams=N_STREAMS, time_kernels=TIME_KERNELS)\n", + "pd.seek(0)\n", + "for _ in range(n_frames_pd):\n", + " cf_zc.push_pedestal_frame(pd.read_frame().copy())\n", + "cf_zc.register_input_buffer(data)\n", + "\n", + "# Pre-pin the two output slots OUTSIDE the timing. This allocates only; it\n", + "# transfers nothing and launches nothing, so the pedestal is NOT advanced —\n", + "# unlike running frames through as a warm-up, which would leave cf_zc with a\n", + "# pedestal 8000 steps ahead of every other finder here.\n", + "CHUNK = cf_zc.chunk_size_for(N)\n", + "cf_zc.reserve_output_slots(CHUNK)\n", + "\n", + "hist_B = bh.Histogram(bh.axis.Regular(N_BINS, -2, 4000))\n", + "n_B = 0\n", + "t_sums = 0.0 # C++ reduction over the pinned buffer\n", + "t_hist = 0.0 # boost_histogram fill\n", + "\n", + "mf0, _ = _faults()\n", + "t0 = time.perf_counter()\n", + "for v in find_cluster_views_batched_iter(cf_zc, data, chunk=CHUNK):\n", + " if not WITH_ANALYSIS:\n", + " continue\n", + " _t = time.perf_counter(); s = v.sums(); t_sums += time.perf_counter() - _t\n", + " n_B += s.size\n", + " _t = time.perf_counter(); hist_B.fill(s); t_hist += time.perf_counter() - _t\n", + "t_B = time.perf_counter() - t0\n", + "f_B = _faults()[0] - mf0\n", + "cf_zc.unregister_input_buffer()\n", + "\n", + "us = lambda t: t * 1e6 / N\n", + "pc = lambda t: 100 * GPU_FLOOR_US / us(t)\n", + "t_bare = t_B - t_sums - t_hist\n", + "\n", + "print(f' minor faults: {f_B:,} (should be ~0 after the warm-up above)')\n", + "print(f' chunk: {cf_zc.chunk_size_for(N)} frames/chunk '\n", + " f'build {ARM} - cap {CAP} - {N_STREAMS} streams')\n", + "print(\"=========================================================================================\")\n", + "print(f'GPU roofline (report 4): {GPU_FLOOR_US:6.1f} us/frame ({1e6/GPU_FLOOR_US:8,.0f} FPS) '\n", + " f'= 1 / max(kernel, H2D, D2H)')\n", + "print(f'==> transport only: {us(t_bare):6.1f} us/frame ({N/t_bare:8,.0f} FPS) '\n", + " f'{pc(t_bare):3.0f}% of roofline')\n", + "if WITH_ANALYSIS:\n", + " print(f' + sums(): {us(t_sums):6.1f} us/frame')\n", + " print(f' + histogram: {us(t_hist):6.1f} us/frame')\n", + " print(f' = total: {us(t_B):6.1f} us/frame ({N/t_B:8,.0f} FPS) '\n", + " f'{pc(t_B):3.0f}% of roofline {n_B} clusters')\n", + "try:\n", + " print()\n", + " print(f'find_clusters_batched: {us(t_cuda):6.1f} us/frame ({N/t_cuda:8,.0f} FPS) '\n", + " f'{pc(t_cuda):3.0f}% of roofline (copies clusters, no reduction)')\n", + " print(f' -> zero-copy transport is {t_cuda/t_bare:.2f}x')\n", + "except NameError:\n", + " pass\n" + ] + }, + { + "cell_type": "markdown", + "id": "md-agree", + "metadata": {}, + "source": [ + "## Agreement sanity check\n", + "Counts should match to a hair. The residual comes from the CUDA finder updating the pedestal once per frame vs the CPU's per-pixel update (analysed in `ClusterFinderFrozen_vs_CUDA.ipynb`)." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "agree", + "metadata": {}, + "outputs": + [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " CPU 233,087,992 diff vs CPU 0 (0.0000%)\n", + " CUDA per-frame 233,094,769 diff vs CPU 6,777 (0.0029%)\n", + " CUDA batched 233,093,554 diff vs CPU 5,562 (0.0024%)\n", + " CUDA graph 233,093,554 diff vs CPU 5,562 (0.0024%)\n", + " CUDA async 233,093,554 diff vs CPU 5,562 (0.0024%)\n" + ] + } + ], + "source": [ + "rows = [('CPU', n_clusters_cpu), ('CUDA per-frame', n_clusters_cuda_v1),\n", + " ('CUDA batched', n_clusters_cuda), ('CUDA graph', n_clusters_graph),\n", + " ('CUDA async', n_clusters_async)]\n", + "if globals().get('n_B'): # 0 when WITH_ANALYSIS=False: nothing counted\n", + " rows.append(('zero-copy view', n_B))\n", + "\n", + "for name, n in rows:\n", + " d = abs(n - n_clusters_cpu)\n", + " print(f' {name:<18} {n:>12,} diff vs CPU {d:>6,} ({d/max(n_clusters_cpu,1):.4%})')\n" + ] + }, + { + "cell_type": "markdown", + "id": "md-plots", + "metadata": {}, + "source": [ + "## Spectrum: CPU vs CUDA variants" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "plots", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": + "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cDZt2jQ5e/asZd47tLBduHBBZs6cqZ5j+eTJk9X7MI0CBlnBoCvWo2b26dNH6tWrJ59//rkKFpcsWSJr165VUygQEREREREZnVeDvI4dO8qNGzdk5MiRajJ0zIG3fPlyKViwoHodyxD06QoXLqxeRyvdlClTVDPlxIkTLdMnAFrs5s6dK0OGDFETomMydMzH5+oceURERERERP7Mq0EeYDJz3ByZMWNGjGX169eX3bt3x7rN9u3bq5uRoDvpsGHDYnQrJea3EbB8M7+NjOWb+W1kLN/MbyML9uP6twmjr3g7EUREREREROQe5hkpiYiIiIiIyBAY5BERERERERkIgzwiIiIiIiIDYZBHRERERERkIAzyiIiIiIiIDIRBHhERERERkYEwyCMiIiIiIjIQBnlEREREREQGwiCPiIiIiIjIQBjkERERERERGQiDPCIiIiIiIgNhkEdERERERGQgDPKIiIiIiIgMhEEeERERERGRgTDIIyIiIiIiMhAGeURERERERAbCII+IiIiIiMhAfDrIGz16tFSrVk3Sp08vOXLkkDZt2sixY8fifN/69eulSpUqkipVKilSpIh88803SZJeIiIiIiIib/PpIA/BWs+ePWXbtm2yZs0aiYyMlKZNm8r9+/edvufMmTPSokULqVu3ruzZs0c+/PBD6d27tyxcuDBJ005EREREROQNJk3TNPET165dUy16CP7q1avncJ3//e9/8vvvv8uRI0csy3r06CH79u2TrVu3JmFqiYiIiIiIkp5Pt+TZCw0NVfdZsmRxug4CObT2WWvWrJns3LlTIiIiPJ5GIiIiIiIib0ohfgINjv3795c6depI2bJlna53+fJlyZkzp80yPEdXz+vXr0vu3LljvCcsLEzddNHR0XLz5k3JmjWrmEwmN38TIiIiIiKihMVEd+/elTx58khAQID/B3m9evWS/fv3y6ZNm+Jc1z4w03ukOgvYMMDLiBEj3JRSIiIiIiIizzl37pzky5fPv4O8d999V11nt2HDhli/DOTKlUu15lm7evWqpEiRQrXMOTJo0CDVSmjdLbRAgQLy33//SYYMGcQXoHURLZHZsmWLNWon5rc/YvlmfhsZyzfz28hYvpnfRhbtg/XvO3fuSMGCBdXsA7Hx6SAPLXAI8BYtWiR///23FC5cOM731KxZU5YuXWqzbPXq1VK1alUJ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ERHpG5M8IiLSFWkNUy6rtB/atWuX59dXrlxZtZgpCZ00aiLJk/QrJ5csGo057xbl0kfpHkFarJR7/uQySmvrmtKNgb3Ro0ervvUK0uCK1KxJAmZfC2d/yaUkjvYNsFjjITWT0mee3G8otYjSjYJ0om4lrYdKUio1gXJvobyPJHg7duxAjRo18r2cRERUfBm07G4oICIiogd69913VaubMTExWS63lMRRWtn09vbO9T2kTz7pzkEuxSxOpHZy7dq1qssIIiLSF9bkERER5dHChQvVfW1yiae0nCndKdjfcyddHsh9eLNmzVKXdz4owbN/X2nlU+6jK2rjxo1Ty0JERPrFmjwiIqI8+uMf/4hVq1ape/qka4Rhw4Zh2rRptm4O3nrrLVW7J10myOWSeUmW5F665ORk9VjeM6+JoatIVxN3795Vj+Uy0NwaoyEiouKJSR4REREREZEb4eWaREREREREboRJHhERERERkRthkkdERERERORGmOQRERERERG5ESZ5REREREREboRJHhERERERkRthkkdERERERORGmOQRERERERG5ESZ5REREREREcB//D8xFPxNmET69AAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": + [ "fig, (ax_spec, ax_ratio) = plt.subplots(\n", - " 2, 1, figsize=(8, 6), sharex=True,\n", + " 2, 1, figsize=(9, 6.5), sharex=True,\n", " gridspec_kw={'height_ratios': [3, 1]})\n", "\n", "edges = hist_cpu.axes[0].edges\n", - "cpu_vals = hist_cpu.values()\n", - "cuda_vals_v1 = hist_cuda_v1.values()\n", - "cuda_vals = hist_cuda.values()\n", - "# async_vals = hist_async.values()\n", - "graph_vals = hist_graph.values()\n", "\n", - "ax_spec.stairs(cpu_vals, edges, label=f'CPU ({n_clusters_cpu} clusters)')\n", - "ax_spec.stairs(cuda_vals_v1, edges, label=f'CUDA single frames ({n_clusters_cuda} clusters)', linestyle='--')\n", - "ax_spec.stairs(cuda_vals, edges, label=f'CUDA batched ({n_clusters_cuda} clusters)', linestyle='--')\n", - "# ax_spec.stairs(async_vals, edges, label=f'CUDA async ({n_clusters_async} clusters)', linestyle='-.')\n", - "ax_spec.stairs(graph_vals, edges, label=f'CUDA graph ({n_clusters_graph} clusters)', linestyle=':')\n", + "# (label, histogram, cluster count, linestyle, colour). The result-path rows are\n", + "# appended only if the zero-copy cell has been run.\n", + "series = [\n", + " ('CPU', hist_cpu, n_clusters_cpu, '-', 'C0'),\n", + " ('CUDA per-frame', hist_cuda_v1, n_clusters_cuda_v1, '--', '0.55'),\n", + " ('CUDA batched', hist_cuda, n_clusters_cuda, '--', 'C1'),\n", + " ('CUDA async', hist_async, n_clusters_async, '-.', 'C2'),\n", + " ('CUDA graph', hist_graph, n_clusters_graph, ':', 'C3'),\n", + "]\n", + "if 'hist_B' in globals():\n", + " series.append(('zero-copy view', hist_B, n_B, (0, (1, 1)), 'C5'))\n", + "\n", + "for label, h, n, ls, col in series:\n", + " ax_spec.stairs(h.values(), edges, label=f'{label} ({n:,})',\n", + " linestyle=ls, color=col, linewidth=1.4)\n", + "\n", "ax_spec.set_ylabel('Counts')\n", - "ax_spec.set_title('Cluster energy spectrum: CPU vs CUDA variants')\n", - "ax_spec.legend()\n", + "ax_spec.set_title('Cluster energy spectrum: CPU vs CUDA variants and result paths')\n", + "ax_spec.legend(fontsize=8, ncol=2)\n", "ax_spec.grid(alpha=0.2)\n", "\n", + "# Ratio panel: everything against the CPU reference. All curves should sit on 1;\n", + "# departures are the pedestal-update difference, not the result path.\n", + "cpu_vals = hist_cpu.values()\n", "with np.errstate(divide='ignore', invalid='ignore'):\n", - " ratio_cuda = np.where(cpu_vals > 0, cuda_vals / cpu_vals, np.nan)\n", - " ratio_graph = np.where(cpu_vals > 0, graph_vals / cpu_vals, np.nan)\n", + " for label, h, n, ls, col in series[2:]: # skip CPU itself and v1\n", + " r = np.where(cpu_vals > 0, h.values() / cpu_vals, np.nan)\n", + " ax_ratio.stairs(r, edges, label=f'{label} / CPU',\n", + " linestyle=ls, color=col, linewidth=1.2)\n", "\n", - "ax_ratio.stairs(ratio_cuda, edges, label='CUDA batched / CPU', color='C1', linestyle='--')\n", - "ax_ratio.stairs(ratio_graph, edges, label='CUDA graph / CPU', color='C3', linestyle=':')\n", "ax_ratio.axhline(1.0, color='gray', linewidth=0.5)\n", "ax_ratio.set_ylabel('Variant / CPU')\n", "ax_ratio.set_xlabel('Energy [ADU]')\n", "ax_ratio.set_ylim(0.5, 2.0)\n", - "ax_ratio.legend(fontsize=8)\n", + "ax_ratio.legend(fontsize=7, ncol=2)\n", "ax_ratio.grid(alpha=0.3)\n", "\n", "plt.tight_layout()\n", - "plt.show()" + "plt.show()\n" ] - }, - { - "cell_type": "markdown", - "id": "5e4a3d4b", - "metadata": {}, - "source": [ - "## Optimization arc for the deck (opt1 → opt2 → opt3)\n", - "\n", - "Same pedestal and `data` as above, scored against the CPU baseline (`t_cpu`, `n_clusters_cpu`). Each cell is self-contained: it builds its finder, trains the pedestal, and runs.\n", - "\n", - "- **opt1** — first CUDA port: `ClusterFinderCUDAOpt2`, single stream, one launch per frame (pre-refactor pipeline, pageable H2D).\n", - "- **opt2** — multi-stream + batching: `ClusterFinderCUDAOpt2`, `n_streams=4`, `find_clusters_batched` (still pre-refactor, pageable H2D).\n", - "- **opt3** — current finder, batched but **without** input pinning: `ClusterFinderCUDA.find_clusters_batched` on pageable memory (no `register_input_buffer`). The pinned + Graph + async steps are the batched/graph cells above.\n", - "\n", - "Correctness is held constant across the arc: the Test3 local-max gate has been backported into the opt2 kernel, so opt1/opt2 counts match the CPU and current finders — only the pipeline changes from step to step." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9c456e2e", - "metadata": {}, - "outputs": [], - "source": [ - "# opt1 — first port: ClusterFinderCUDAOpt2, single stream, one launch per frame\n", - "cf_opt1 = ClusterFinderCUDAOpt2(image_size, cluster_size, n_sigma=N_SIGMA,\n", - " max_clusters_per_frame=3000, n_streams=1)\n", - "pd.seek(0)\n", - "for _ in range(n_frames_pd):\n", - " cf_opt1.push_pedestal_frame(pd.read_frame().copy())\n", - "\n", - "cf_opt1.reset_timers()\n", - "mf0, Mf0 = _faults()\n", - "t0 = time.perf_counter()\n", - "n_clusters_opt1 = 0\n", - "for i, frame in enumerate(data):\n", - " cf_opt1.find_clusters(frame, frame_number=i)\n", - " n_clusters_opt1 += cf_opt1.steal_clusters(realloc_same_capacity=True).size\n", - "t_opt1 = time.perf_counter() - t0\n", - "mf1, Mf1 = _faults()\n", - "kernel_ms = cf_opt1.avg_kernel_time_ms()\n", - "\n", - "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", - "print(\"=========================================================================================\")\n", - "print(f'opt1 per-frame: {t_opt1:.3f}s ({N/t_opt1:.0f} FPS, '\n", - " f'{n_clusters_opt1} clusters, {n_clusters_opt1/N:.2f}/frame)')\n", - "print(f' Kernel only: {kernel_ms:.3f} ms/frame')\n", - "print(f' PCIe + overhead: {t_opt1*1000/N - kernel_ms:.3f} ms/frame')\n", - "print(f'Speedup (CPU/opt1): {t_cpu / t_opt1:.2f}x')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f32b821f", - "metadata": {}, - "outputs": [], - "source": [ - "# opt2 — multi-stream + batching: ClusterFinderCUDAOpt2, pageable H2D (no pinning)\n", - "cf_opt2 = ClusterFinderCUDAOpt2(image_size, cluster_size, n_sigma=N_SIGMA,\n", - " max_clusters_per_frame=3000, n_streams=N_STREAMS)\n", - "pd.seek(0)\n", - "for _ in range(n_frames_pd):\n", - " cf_opt2.push_pedestal_frame(pd.read_frame().copy())\n", - "\n", - "cf_opt2.reset_timers()\n", - "mf0, Mf0 = _faults()\n", - "t0 = time.perf_counter()\n", - "clusters_opt2 = []\n", - "for start in range(0, N, BATCH_SIZE):\n", - " stop = min(start + BATCH_SIZE, N)\n", - " clusters_opt2.extend(cf_opt2.find_clusters_batched(data[start:stop], first_frame=start))\n", - "t_opt2 = time.perf_counter() - t0\n", - "mf1, Mf1 = _faults()\n", - "kernel_ms = cf_opt2.avg_kernel_time_ms()\n", - "n_clusters_opt2 = sum(cv.size for cv in clusters_opt2)\n", - "\n", - "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", - "print(\"=========================================================================================\")\n", - "print(f'opt2 batched (no pin): {t_opt2:.3f}s ({N/t_opt2:.0f} FPS, '\n", - " f'{n_clusters_opt2} clusters, {n_clusters_opt2/N:.2f}/frame)')\n", - "print(f' Kernel only: {kernel_ms:.3f} ms/frame')\n", - "print(f' PCIe + overhead: {t_opt2*1000/N - kernel_ms:.3f} ms/frame')\n", - "print(f'Speedup (CPU/opt2): {t_cpu / t_opt2:.2f}x')\n", - "print(f'Speedup (opt1/opt2): {t_opt1 / t_opt2:.2f}x')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cf8e1f9b", - "metadata": {}, - "outputs": [], - "source": [ - "# opt3 — current finder, batched but WITHOUT input pinning (pageable H2D)\n", - "cf_opt3 = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", - " max_clusters_per_frame=3000, n_streams=N_STREAMS)\n", - "pd.seek(0)\n", - "for _ in range(n_frames_pd):\n", - " cf_opt3.push_pedestal_frame(pd.read_frame().copy())\n", - "\n", - "cf_opt3.reset_timers()\n", - "mf0, Mf0 = _faults()\n", - "t0 = time.perf_counter()\n", - "clusters_opt3 = []\n", - "for start in range(0, N, BATCH_SIZE):\n", - " stop = min(start + BATCH_SIZE, N)\n", - " clusters_opt3.extend(cf_opt3.find_clusters_batched(data[start:stop], first_frame=start))\n", - "t_opt3 = time.perf_counter() - t0\n", - "mf1, Mf1 = _faults()\n", - "kernel_ms = cf_opt3.avg_kernel_time_ms()\n", - "n_clusters_opt3 = sum(cv.size for cv in clusters_opt3)\n", - "\n", - "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", - "print(\"=========================================================================================\")\n", - "print(f'opt3 batched (no pin): {t_opt3:.3f}s ({N/t_opt3:.0f} FPS, '\n", - " f'{n_clusters_opt3} clusters, {n_clusters_opt3/N:.2f}/frame)')\n", - "print(f' Kernel only: {kernel_ms:.3f} ms/frame')\n", - "print(f' PCIe + overhead: {t_opt3*1000/N - kernel_ms:.3f} ms/frame')\n", - "print(f'Speedup (CPU/opt3): {t_cpu / t_opt3:.2f}x')\n", - "print(f'Speedup (opt2/opt3): {t_opt2 / t_opt3:.2f}x')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cbd03f58-0068-4ad6-aec6-51ed9c8f8f1e", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8457b726-b4ce-444f-a8af-8cd911e77f06", - "metadata": {}, - "outputs": [], - "source": [] - } - ], + }, + { + "cell_type": "markdown", + "id": "5e4a3d4b", + "metadata": {}, + "source": [ + "## Optimization arc for the deck (opt1 → opt2 → opt3)\n", + "\n", + "Same pedestal and `data` as above, scored against the CPU baseline (`t_cpu`, `n_clusters_cpu`). Each cell is self-contained: it builds its finder, trains the pedestal, and runs.\n", + "\n", + "- **opt1** — first CUDA port: `ClusterFinderCUDAOpt2`, single stream, one launch per frame (pre-refactor pipeline, pageable H2D).\n", + "- **opt2** — multi-stream + batching: `ClusterFinderCUDAOpt2`, `n_streams=4`, `find_clusters_batched` (still pre-refactor, pageable H2D).\n", + "- **opt3** — current finder, batched but **without** input pinning: `ClusterFinderCUDA.find_clusters_batched` on pageable memory (no `register_input_buffer`). The pinned + Graph + async steps are the batched/graph cells above.\n", + "\n", + "Correctness is held constant across the arc: the Test3 local-max gate has been backported into the opt2 kernel, so opt1/opt2 counts match the CPU and current finders — only the pipeline changes from step to step." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "9c456e2e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " minor faults: 0 (~0.00s est.) major: 0\n", + "=========================================================================================\n", + "opt1 per-frame: 6.317s (15831 FPS, 233094770 clusters, 2330.95/frame)\n", + " Kernel only: nan ms/frame\n", + " PCIe + overhead: nan ms/frame\n", + "Speedup (CPU/opt1): 2.30x\n" + ] + } + ], + "source": [ + "# opt1 — first port: ClusterFinderCUDAOpt2, single stream, one launch per frame\n", + "cf_opt1 = ClusterFinderCUDAOpt2(image_size, cluster_size, n_sigma=N_SIGMA,\n", + " max_clusters_per_frame=CAP, n_streams=1)\n", + "pd.seek(0)\n", + "for _ in range(n_frames_pd):\n", + " cf_opt1.push_pedestal_frame(pd.read_frame().copy())\n", + "\n", + "cf_opt1.reset_timers()\n", + "mf0, Mf0 = _faults()\n", + "t0 = time.perf_counter()\n", + "n_clusters_opt1 = 0\n", + "for i, frame in enumerate(data):\n", + " cf_opt1.find_clusters(frame, frame_number=i)\n", + " n_clusters_opt1 += cf_opt1.steal_clusters(realloc_same_capacity=True).size\n", + "t_opt1 = time.perf_counter() - t0\n", + "mf1, Mf1 = _faults()\n", + "kernel_ms = cf_opt1.avg_kernel_time_ms()\n", + "\n", + "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", + "print(\"=========================================================================================\")\n", + "print(f'opt1 per-frame: {t_opt1:.3f}s ({N/t_opt1:.0f} FPS, '\n", + " f'{n_clusters_opt1} clusters, {n_clusters_opt1/N:.2f}/frame)')\n", + "print(f' Kernel only: {kernel_ms:.3f} ms/frame')\n", + "print(f' PCIe + overhead: {t_opt1*1000/N - kernel_ms:.3f} ms/frame')\n", + "print(f'Speedup (CPU/opt1): {t_cpu / t_opt1:.2f}x')" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "f32b821f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " minor faults: 2,626,488 (~1.84s est.) major: 0\n", + "=========================================================================================\n", + "opt2 batched (no pin): 6.085s (16433 FPS, 233093553 clusters, 2330.94/frame)\n", + " Kernel only: nan ms/frame\n", + " PCIe + overhead: nan ms/frame\n", + "Speedup (CPU/opt2): 2.39x\n", + "Speedup (opt1/opt2): 1.04x\n" + ] + } + ], + "source": [ + "# opt2 — multi-stream + batching: ClusterFinderCUDAOpt2, pageable H2D (no pinning)\n", + "cf_opt2 = ClusterFinderCUDAOpt2(image_size, cluster_size, n_sigma=N_SIGMA,\n", + " max_clusters_per_frame=CAP, n_streams=N_STREAMS)\n", + "pd.seek(0)\n", + "for _ in range(n_frames_pd):\n", + " cf_opt2.push_pedestal_frame(pd.read_frame().copy())\n", + "\n", + "cf_opt2.reset_timers()\n", + "mf0, Mf0 = _faults()\n", + "t0 = time.perf_counter()\n", + "clusters_opt2 = []\n", + "for start in range(0, N, BATCH_SIZE):\n", + " stop = min(start + BATCH_SIZE, N)\n", + " clusters_opt2.extend(cf_opt2.find_clusters_batched(data[start:stop], first_frame=start))\n", + "t_opt2 = time.perf_counter() - t0\n", + "mf1, Mf1 = _faults()\n", + "kernel_ms = cf_opt2.avg_kernel_time_ms()\n", + "n_clusters_opt2 = sum(cv.size for cv in clusters_opt2)\n", + "\n", + "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", + "print(\"=========================================================================================\")\n", + "print(f'opt2 batched (no pin): {t_opt2:.3f}s ({N/t_opt2:.0f} FPS, '\n", + " f'{n_clusters_opt2} clusters, {n_clusters_opt2/N:.2f}/frame)')\n", + "print(f' Kernel only: {kernel_ms:.3f} ms/frame')\n", + "print(f' PCIe + overhead: {t_opt2*1000/N - kernel_ms:.3f} ms/frame')\n", + "print(f'Speedup (CPU/opt2): {t_cpu / t_opt2:.2f}x')\n", + "print(f'Speedup (opt1/opt2): {t_opt1 / t_opt2:.2f}x')" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "cf8e1f9b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " minor faults: 2,295,411 (~1.61s est.) major: 1\n", + "=========================================================================================\n", + "opt3 batched (no pin): 5.198s (19238 FPS, 233093554 clusters, 2330.94/frame)\n", + " Kernel only: nan ms/frame\n", + " PCIe + overhead: nan ms/frame\n", + "Speedup (CPU/opt3): 2.79x\n", + "Speedup (opt2/opt3): 1.17x\n" + ] + } + ], + "source": [ + "# opt3 — current finder, batched but WITHOUT input pinning (pageable H2D)\n", + "cf_opt3 = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", + " max_clusters_per_frame=CAP, n_streams=N_STREAMS,\n", + " time_kernels=TIME_KERNELS)\n", + "pd.seek(0)\n", + "for _ in range(n_frames_pd):\n", + " cf_opt3.push_pedestal_frame(pd.read_frame().copy())\n", + "\n", + "cf_opt3.reset_timers()\n", + "mf0, Mf0 = _faults()\n", + "t0 = time.perf_counter()\n", + "clusters_opt3 = []\n", + "for start in range(0, N, BATCH_SIZE):\n", + " stop = min(start + BATCH_SIZE, N)\n", + " clusters_opt3.extend(cf_opt3.find_clusters_batched(data[start:stop], first_frame=start))\n", + "t_opt3 = time.perf_counter() - t0\n", + "mf1, Mf1 = _faults()\n", + "kernel_ms = cf_opt3.avg_kernel_time_ms()\n", + "n_clusters_opt3 = sum(cv.size for cv in clusters_opt3)\n", + "\n", + "print(f' minor faults: {mf1-mf0:,} (~{(mf1-mf0)*0.7e-6:.2f}s est.) major: {Mf1-Mf0:,}')\n", + "print(\"=========================================================================================\")\n", + "print(f'opt3 batched (no pin): {t_opt3:.3f}s ({N/t_opt3:.0f} FPS, '\n", + " f'{n_clusters_opt3} clusters, {n_clusters_opt3/N:.2f}/frame)')\n", + "print(f' Kernel only: {kernel_ms:.3f} ms/frame')\n", + "print(f' PCIe + overhead: {t_opt3*1000/N - kernel_ms:.3f} ms/frame')\n", + "print(f'Speedup (CPU/opt3): {t_cpu / t_opt3:.2f}x')\n", + "print(f'Speedup (opt2/opt3): {t_opt2 / t_opt3:.2f}x')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cbd03f58-0068-4ad6-aec6-51ed9c8f8f1e", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8457b726-b4ce-444f-a8af-8cd911e77f06", + "metadata": {}, + "outputs": [], + "source": [] + } + ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", @@ -814,4 +1084,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/python/tests/ClusterFinderFrozen_vs_CUDA.ipynb b/python/tests/ClusterFinderFrozen_vs_CUDA.ipynb index c81dc7ea..d968edf8 100644 --- a/python/tests/ClusterFinderFrozen_vs_CUDA.ipynb +++ b/python/tests/ClusterFinderFrozen_vs_CUDA.ipynb @@ -49,7 +49,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "image (400, 400) pedestal 1000 data 20000 streams 4\n" + "image (400, 400) pedestal 1000 data 10000 streams 1\n" ] } ], @@ -59,13 +59,13 @@ "pd = File(base / 'Cu_factor_10_pedestal_master_0.json')\n", "\n", "n_frames_pd = 1000\n", - "N = 20000\n", + "N = 10000\n", "cluster_size = (3, 3)\n", "rows, cols = f.rows, f.cols\n", "image_size = (rows, cols)\n", "capacity = 50_000\n", "\n", - "N_STREAMS = 4 # single device pedestal -> apples-to-apples with the CPU finders\n", + "N_STREAMS = 1 # single device pedestal -> apples-to-apples with the CPU finders\n", "N_SIGMA = 5\n", "sx, sy = cluster_size\n", "rx, ry = sx // 2, sy // 2\n", @@ -82,7 +82,8 @@ "cf_cpu = ClusterFinder(image_size, cluster_size, n_sigma=N_SIGMA, capacity=capacity)\n", "cf_frozen = ClusterFinderFrozen(image_size, cluster_size, n_sigma=N_SIGMA, capacity=capacity)\n", "cf_cuda = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", - " max_clusters_per_frame=3000, n_streams=N_STREAMS)" + " max_clusters_per_frame=1700 if cluster_size == (9, 9) else 3000,\n", + " n_streams=N_STREAMS)" ] }, { @@ -95,8 +96,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "pedestal train: 0.78s\n", - "data: (20000, 400, 400) uint16\n" + "pedestal train: 0.77s\n", + "data: (10000, 400, 400) uint16\n" ] } ], @@ -135,23 +136,23 @@ "name": "stdout", "output_type": "stream", "text": [ - "Scanned 20000 frames\n", + "Scanned 10000 frames\n", "\n", "Total clusters per finder:\n", - " cpu 46,478,554\n", - " frozen 46,478,563\n", - " cuda 46,478,563\n", + " cpu 23,244,602\n", + " frozen 23,244,605\n", + " cuda 23,244,611\n", "\n", "Pairwise exact mismatches (tol=0):\n", " pair A-only B-only total\n", - " cpu vs frozen 13 22 35 (0.0001%)\n", - " cpu vs cuda 13 22 35 (0.0001%)\n", - " frozen vs cuda 0 0 0 (0.0000%)\n" + " cpu vs frozen 8 11 19 (0.0001%)\n", + " cpu vs cuda 8 17 25 (0.0001%)\n", + " frozen vs cuda 0 6 6 (0.0000%)\n" ] } ], "source": [ - "SCAN = 20000 # frames sampled across `data` (set to len(data) for the full block)\n", + "SCAN = 10000 # frames sampled across `data` (set to len(data) for the full block)\n", "\n", "totals, pairs, nscan, hists = compare_finders(\n", " # {'frozen': cf_frozen, 'cuda': cf_cuda}, data, scan_count=SCAN)\n", @@ -191,7 +192,7 @@ { "data": { "image/png": - "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3aEk3+S9y7EZjumQgFZaslraMtNuNAfD6WY7D07xadv5+MOGj9TUsZI2164T+Vr8g9+p1J6wLDIfbQWnm214ulmZXlamDHVzc9Pi4uK0Z555pu759evXa6effrqa0nP06NFqik/T6WZlGlCZrlSmm7344ovrTTeblJSkphCV52X6z9dee00bO3asds899zR6zh566CHtmmuuaXQd0+lmhUyvev/996vpbWX60nPPPbfetLXyfmUa04Y0NN3s7Nmz1XuVfDNn8+bN2oQJE9T0p7Lv4cOHaytWrKi37UcffVRNN1tSUqLWl6lW9fyX6WeTk5PrbXvevHlq3zKV64gRI7Rff/217rmdO3eqPJb9enh4aAkJCdq0adPUFLqNkSlU77vvPi0qKqpuutl33323wemG33zzTXUOZdrg66+/Xnv66afrpptNT09X0w3r29Lfp55X5eXl2uTJk7XAwECj6WZlSt67775bi46OVnkg27/uuuvq8sDcNLcy/evEiRNVvsn77dmzp/bqq68arSPLPvnkE81Wppt1kv/Bgcho/oCAADWiX48+O5pkuexfjqM5A6iI+WiP5fHbvZvw8OZb8czQd3BxH/P9Tk2t37EGb619GNPGPoNRAxrvr21L+JlmPjpKWSwvL1dXY+XKuemg486Wj9KdRfq4t/fvdEZGhurGIgN8LTmrjwwMloHGclVcpsftSPq+ZWC2dOHpiHx0xLL4/fffq1YemfZWulm112e7JXVndoUiovbh5GycNkNJ6UnsCCjCtoPfqH/XeAQhKvI0DIpufh9sIiJ7IlO1yngGGaBsycBCxhTIdK4dHVSY7tt0vAFZjsxEJgPJ2xpUWJLtHAkRdSpuRalGaXNEhSTAs1bDwtJVwMFVapm2ww1LJi5lcEFEnVZD96toi/PPP189rEHft4N1iulw5saIWBsDCyJqF041ZUZpc/TvORKLsBBpOadmUtmel44Ps95DavZxBhZEREQ2joEFEdkUCS7646+7vG78GB9mAW4lJ619WERERNSE5nd+JiKy0k32JCUiIiLbxsCCiGz6JntnlZWrlIhsR0tuhEZEjvOZZlcoImoXgUF9cXp6XwQO69uhN9kjovYjN5iTOyzLzb3kxm7yd2ecRrQjp5vtzJiPtp+Hsv3Kykp14z35bMtnui0YWBBRu/DxS8CGvCl42C+h1dsorC3H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g1iPSHVBWHpCVLt4IiR8KGprqpCethrQgAj/RPQ7bbBVAwupO+fn56vvFpscvJ2dnY2amhpERJyqzOrk7/R048qoTpabW7+6ulptLyrq1IfckLR8zJ176gvRkLmuVp3F39eL9IJ8dYPr/onbOuCIiIjIPhUY/Y4kN7H2pbi73Y+IiKyjqKjIdgMLnWn0JVdbGovIzK1vbrlu9uzZarC3rra2Frm5uQgJCbFq5BcbG4uUlBSrtZp0BsxH5qOtYFlkPtoKlkXmoy1heewceSh1bQkqZNKkplgtsAgNDYWLi0u91onMzMx6rRK6yMhIs+u7urqqQMEcDw8P9TAUGBgIWyAFhIEF89FWsDwyD20FyyLz0FawLDIfbYW/leuMTbVU6BofHduO3N3dMXjwYKxc+feAYiF/jxxpfiaKESNG1Fv/p59+wpAhQ8yOryAiIiIioo5htcBCSBclmV723XffVTM93XfffWpQtX5fCunGdP3119etL8uPHz+uXifry+sWLVqEmTNnWvFdEBERERGRVcdYXH311cjJycETTzyhbpAn96hYsWIF4uNPjdCXZRJo6BITE9XzEoC8/vrrqq/XK6+80ux7WNgK6Zr12GOP1euiRcxHa2B5ZB7aCpZF5qGtYFlkPtoKDzurM1r9zttERERERGT/rNoVioiIiIiIOgcGFkRERERE1GYMLIiIiIiIqM0YWBARERERUZsxsCAiIiIiojZjYEFERERERG3GwIKIiIiIiNqMgQUREREREbUZAwsiIiIiImozBhZERERERNRmDCyIiIiIiKjNGFgQEREREVGbMbAgIiIiIqI2Y2BBRERERERtxsCCiIiIiIjajIEFERERERG1GQMLIiIiIiJqMwYWRERERERk34HFvHnzMHToUPj5+SE8PByXXXYZDhw40OTr1q5di8GDB8PT0xNdu3bFW2+91SHHS0RERERENhhYSIBw5513YtOmTVi5ciWqq6sxYcIElJSUNPiapKQkTJo0CaNHj8a2bdvw8MMPY/r06Vi6dGmHHjsREREREf3NSdM0DTYiKytLtVxIwDFmzBiz6zz44INYvnw59u3bV7ds2rRp2LFjBzZu3NiBR0tERERERDpX2JCCggKVBgcHN7iOBA/SqmFo4sSJWLRoEaqqquDm5mb0XEVFhXroamtrkZubi5CQEDg5OVn8PRARERER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YOTm5qp/JyYmqueIiIiIiMjxtLjF4s4778TVV1+NN998Ey4uLnV3277jjjvUc7t3726P4yQiIiIios7UYnHkyBHcf//9dUGFkH/PmDFDPUdERERERI6nxYHFGWecocZWmJJlAwcOtNRxERERERFRZ+4KNX36dNxzzz04fPgwhg8frpZt2rQJr7/+Op599lns3Lmzbt3+/ftb9miJiIiIiKhzBBbXXnutSmfNmmX2OZklSm6aJ6mMvSAiIiIios6vxYFFUlJS+xwJERERERE5TmARHx/fPkdCRERERESOM3h73rx5ePfdd+stl2XPPfecpY6LiIiIiIg6c2Dx9ttv47TTTqu3vG/fvnjrrbcsdVxERERERNSZA4v09HRERUXVWx4WFoa0tDRLHRcREREREXXmwCI2Nha//fZbveWyLDo62lLHRUREREREnXnw9i233IJ7770XVVVVOOecc9Syn3/+WU0/K3fkJiIiIiIix9PiwEICiNzcXNxxxx2orKxUyzw9PfHggw9i9uzZ7XGMRERERETU2QILufGdzP7073//G/v27YOXlxd69OgBDw+P9jlCIiIiIiLqfIGFztfXF0OHDrXs0RARERERkWMM3iYiIiIiIjLFwIKIiIiIiNqMgQUREREREXVcYPHwww/jjz/+aPseiYiIiIjIcQMLuav2RRddpO66/a9//Qvff/89Kioq2vfoiIiIiIiocwUW7733HjIyMvD5558jMDBQ3QwvNDQUV1xxBRYvXozs7OwW73zdunW4+OKL1R27ZRrbr7/+usnXrF27FoMHD1b3zujatSveeuutFu+XiIiIiIisOMZCKv+jR4/Gf/7zH+zfv191jRo+fDjeeecdxMTEYMyYMZg/fz5SU1Obtb2SkhIMGDAAr732WrPWT0pKwqRJk9QxbNu2TXXPmj59OpYuXdqSt0FERERERLZyHwvRu3dv9ZC7cWdlZWH58uXqIWbOnNnk6y+44AL1aC5pnYiLi8OCBQvq9r9lyxYVzEyePLkN74SIiIiIiKwWWBgKCwvDzTffrB7tZePGjZgwYYLRsokTJ2LRokWoqqqCm5tbu+2biIiIiIg6ILDoCOnp6YiIiDBaJn9XV1erMR4ysNyUDDA3HGReWFio0traWvWwBtlvTmkOspP3wamsAL3949XyYyVp8PAJQ1TUYJSX5SMpeZ3xC50A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ImpWZSV5CHY8qVIiotbujTfewG233QZ7+3NVMqnoy2PBggXYs2ePahn56aefMGXKFJPXyQ2G//Of/+DQoUPqxsHHjh3DNddcU+V+5B5g//77L5566imVfv311zh8+LCq6JuzaNEiVbmvqWIrAUptyPENHToUnTt3Vq0Eu3btUscigZHxMV5yySV44oknqtxOcXExrr32Wtxzzz1oyUpKar4hbHBwMEaPHo333nsPVkOjSrKysuSufSq1lPLyci0jI0OlxPxrSWXwpy2fad2XdVdpU77GGvBzzPxraWWwoKBA279/v0ptSVlZmfbiiy9q7dq105ydnbWoqCjtueeeU8+tX79e/eZLPul27Nihlp04cULlXUlJibZ06VL1Ojc3N+3KK6/UFixYoPn4+Bhec/ToUe3yyy/XgoODNQ8PD61fv37a2rVrqz2ulJQUzc7OTtu7d2+16/3nP/9Rxy3HUZXVq1erbRUXF9c6X/7++2/1Pk+dOmWyfOfOnVpkZKSWmJionl+1alWl165Zs0br3Lmztm/fPrWO5Jk5ev5NnDhRu/nmm2t1XObOSUUfffSRSf5Xp7CwUHv00UfVe5J8bN++vfbhhx+a3decOXO0Xr16mbx+4cKFWkxMjMnx9e/fX3N3d1fHMHjwYO3kyZPqmGRbxg9ZJjIzM7U777xTCwoK0ry8vLSRI0eqfNbp+12yZIkWGxurzqXk3VdffaV1795dc3V11fz9/bWLLrpIy83NNbxu2bJlqlw25We7LvVitlgQUbMKcwnCe0nJKq0tzcHZJCUiqotZs2bhpZdeUlfI9+/fr8YzhISE1Pr1f/31l2oxuPfee1WXH2kteO6550zWyc3Nxbhx47Bu3Trs2LEDY8aMwfjx4026+lS0efNm1aWnS5cu1e5fukFJ9yBHR/P3NZbuU5999pnqIuTk5FTr9yXblVYJ4y5F0mpwww034K233kJoaKjZ1509exZ33nknli9fro6/JuXl5fjhhx/QsWNHlS9ypX3AgAFVdrFqbNLl64svvlCtQ9IdS67we3p61mtbpaWluPLKKzFixAjs3r0bW7duxV133aXyceLEiXjkkUfQrVs3JCYmqocsk25ll156qerytmbNGmzfvh3nnXceLrroInXudEePHlWtUNICJeVM1pdzIS1a0nq1fv16XH311Wp7uvPPPx/x8fE4deoU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R85gS0RERETU2tSrxaJt27ZIS0urtLywsBAdO3ZsjOMiIiIiIqKWHlicPHkSZWVllZYXFRXh9OnTjXFcRERERERkQ+rUb+nbb781/P/nn3+Gj4+P4W8JNH799VfExsY27hESkU3y9u6IXsmx8O7XfK2Y5S7eJikRERFZaWAhd9cWdnZ2mDx5sslzTk5OaNOmDV599dXGPUIisklevh2wOe1uPO7bodn2WeoWbJISERGRlQYW+r0rpFXin3/+QWAg54onIvPyc0+jv/dK5OfKDfL+17rZlOzKikxSIiIiaj71msJJ7ldBRFSdjPRdOBjxj0qBbs2SWU458SYpERERNZ96zw0r4ynkkZycbGjJ0C1durQxjo2IbFgbt0hsPRmPhF6RzbbPEOcALDibgpDuAc22TyIiImpAYDFv3jw888wz6g7cYWFhaswFEZExBzt7eGqaSpuLp6MHeucX4KijB08GERGRLQQW7733HpYtW4ZJkyY1/hERUYtwpigZr4QE4aaiZLRvpn1mlmTjY28vdCvJbqY9EhERka5elxKLi4sxePBgNNSmTZswfvx4hIeHq1aPb775psbXbNy4EX379oWrq6u6UZ8EORWtXLkSXbt2hYuLi0pXrVrV4GMlorrJRzm2uLuptLlklObgHT8flRIREZENBBZ33HEHVqxY0eCd5+XloVevXnjrrbdqPWh83LhxGDZsGHbs2IEnnngCDz74oAokdFu3bsXEiRNVa8quXbtUet111+Gvv/5q8PESUe2VeEWbpM0hLOoSnD30skqJiIjIBrpCFRYWYvHixVi3bh169uyp7mFh7LXXXqvVdsaOHasetSWtE9HR0Vi0aJH6u0uXLti2bRsWLFiACRMmqGXy3KhRozBr1iz1t6TSyiHLP//88zq8SyIiIiIiatIWi927d6N3796wt7fH3r17VeuB/ti5cyeairRGjB492mTZmDFjVHBRUlJS7TpbtmxpsuMiosqcM4+ZpM0h+cx6dGn3mEqJiIjIBlos1q+3zI92UlISQkJCTJbJ36WlpUhNTVUzVFW1jiyvSlFRkXrosrPPDfzUNE09LEHft6X2b+uYf5bPQ3mdHeyatRwXl+Qjwdlepdbw2WE5ZP5ZGssg89DSWAZtPw/rst9638fCUipObau/WePl5tapbkrc+fPnqyl0K8rKyrLoSczNzVX/53S+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", "text/plain": [ "
" ] @@ -218,7 +219,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 15, "id": "resid", "metadata": {}, "outputs": [ @@ -226,22 +227,34 @@ "name": "stdout", "output_type": "stream", "text": [ - "frozen-only: 0 cuda-only: 0 frames shown: 0\n" + "frozen-only: 0 cuda-only: 6 frames shown: 6\n" ] + }, + { + "data": { + "image/png": + "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HIEkCAAAAAAAcASSIAAAAAAAwBFIggAAAAAAAEcgCQIAAAAAAByBJAgAAAAAAHAEkiAAAAAAAMARSIIAAAAAAABHIAkCAAAAAAAcgSQIAAAAAABwBJIgAAAAAADAEUiCAAAAAAAARyAJAgAAAAAAHIEkCAAAAAAAcASSIAAAAAAAwBFIggAAAAAAAEcgCQIAAAAAAByBJAgAAAAAAHAEkiAAAAAAAMARSIIAAAAAAABHIAkCAAAAAAAcgSQIAAAAAABwBJIgAAAAAADAEUiCAAAAAAAARyAJAgAAAAAAHIEkCAAAAAAAcASSIAAAAAAAwBFIggAAAAAAAEcgCQIAAAAAAByBJAgAAAAAAHAEkiAAAAAAAMARSIIAAAAAAABHIAkCAAAAAAAcgSQIAAAAAABwBJIgAAAAAADAEUiCAAAAAAAARyAJAgAAAAAAHIEkCAAAAAAAcASSIAAAAAAAwBFIggAAAAAAAEcgCQIAAAAAAByBJAgAAAAAAHAEkiAAAAAAAMARSIIAAAAAAABHIAkCAAAAAAAcgSQIAAAAAABwBJIgAAAAAADAEUiCAAAAAAAARyAJAgAAAAAAHIEkCAAAAAAAcASSIAAAAAAAwBFIggAAAAAAAEcgCQIAAAAAAByBJAgAAAAAAHAEkiAAAAAAAMARSIIAAAAAAABHIAkCAAAAAAAcgSQIAAAAAABwBJIgAAAAAADAEUiCAAAAAAAARyAJAgAAAAAAHIEkCAAAAAAAcASSIAAAAAAAwBFIggAAAAAAAEcgCQIAAAAAAByBJAgAAAAAAHAEkiAAAAAAAMARSIIAAAAAAABHIAkCAAAAAAAcgSQIAAAAAABwBJIgAAAAAADAEUiCAAAAAAAARyAJAgAAAAAAHIEkCAAAAAAAcASSIAAAAAAAwBFIggAAAAAAAEcgCQIAAAAAAByBJAgAAAAAAHAEkiAAAAAAAMARSIIAAAAAAABHIAkCAAAAAAAcgSQIAAAAAABwBJIgAAAAAADAEUiCAAAAAAAARyAJAgAAAAAAHIEkCAAAAAAAcASSIAAAAAAAwBFIggAAAAAAAEcgCQIAAAAAAByBJAgAAAAAAHAEkiAAAAAAAMARSIIAAAAAAABHIAkCAAAAAAAcgSQIAAAAAABwBJIgAAAAAADAEUiCAAAAAAAARyAJAgAAAAAAHIEkCAAAAAAAcASS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AAAAANwCRRAAAAAAAOAWKIIAAAAAAAC3QBEEAAAAAAC4BYogAAAAAADALVAEAQAAAAAAboEiCAAAAAAAcAsUQQAAAAAAgFugCAIAAAAAANwCRRAAAAAAAOAWKIIAAAAAAAC3QBEEAAAAAAC4BYogAAAAAADALVAEQZbkdDrVu3dv5c6dWx4eHvrjjz/Su0vATWU+54sWLWItA8B1kA+Aa2vatKkGDBjAKkKWRxEEWdLKlSv14YcfatmyZTpx4oQqV66szODkyZPq3r27ChYsqOzZs6tGjRr66quvLntO27ZtVaxYMfn6+qpQoULW848fP570uHnf5g/iq32dPn3aes66det0//33W68336d69er65JNPbul7/f3339WxY0cVKFDAei/lypXTE088od27d1uPHzx48LK+58qVS40bN9b69etvuLM2xQDzmpT4+uuv1aJFC+XLl0+BgYGqV6+evv3228ue895776lRo0ZWX8xX8+bN9dNPP132nDFjxvzfejfb80o7duywtmVQUJACAgJUt25dHT58WBmB+XyYfp8/fz69uwIALkU+yPz54Hr7KNNfsx9OVKJEiaT36OfnZ93v1KmT1qxZc9XvHR0dbe3fzUE0czul/vzzT3Xu3FlFixa1vl+FChX0+uuvX/ac5KzjxPd45dfOnTsvy0BXe859992njIIDNMioKIIgS9q3b5+1c6lfv771B6iXl9f/PSc2NlYZjSlo7Nq1S0uWLNHff/+tdu3a6aGHHrICQaI777xTX375pfW8BQsWWO+1Q4cOSY+b55vCz6VfrVq1UpMmTZQ/f37rOZs3b1bVqlWt1//111/q1auXHnnkES1duvSWvE9TnDJ/9MfExFg7flMQ+Pjjj62CwKhRoy577nfffWe9B1P8MMWJe++9VwcOHHB5nzZs2GAVQVasWKFff/3VWs9t2rS5bN2bUGLCzdq1a7VlyxarGNWyZUsdO3bssrYqVap02fo32/JSZps1bNhQt912m9WmCU3mfZuwl9WOuMbHx6d3NwAgCfkg6+SD5HrppZes92py09y5c5UzZ07rIMa4ceP+77nmfZsDZxUrVrQOjqSUyQ/mYMq8efO0fft2vfDCC3r++ef15ptvJj0nJevY9PnS7VW2bNmkx0z/Ln1s27ZtstlsVgEpq4mLi0vvLiCrcQJZzKOPPuo0H+3Er+LF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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" } ], "source": [ "cf_frozen2 = ClusterFinderFrozen(image_size, cluster_size, n_sigma=N_SIGMA, capacity=capacity)\n", "cf_cuda2 = ClusterFinderCUDA(image_size, cluster_size, n_sigma=N_SIGMA,\n", - " max_clusters_per_frame=3000, n_streams=N_STREAMS)\n", - "train_pedestal([cf_frozen2, cf_cuda2], f, n_frames_pd, seek=0)\n", + " max_clusters_per_frame=1700 if cluster_size == (9, 9) else 3000,\n", + " n_streams=N_STREAMS)\n", + "train_pedestal([cf_frozen2, cf_cuda2], pd, n_frames_pd, seek=0)\n", "\n", "# scan_mismatches(a, b, ...) snapshots a.pedestal/noise (host) and b.device_*(0).\n", "show, res = scan_mismatches(cf_frozen2, cf_cuda2, data, rx, ry,\n", " scan_count=SCAN, n_show=8, tol=0)\n", "print('frozen-only:', res['cpu_only'], ' cuda-only:', res['cu_only'], ' frames shown:', len(show))\n", "if show:\n", - " plot_masked_mismatch(show, data, rx, ry, rows, cols, zoom=30, show_vals=True)" + " plot_masked_mismatch(show, data, rx, ry, rows, cols, zoom=25, show_vals=True)" ] }, { @@ -257,15 +270,48 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 16, "id": "c27d894d-64b2-45b2-aaa8-bfddc754b3f9", - "metadata": {}, + "metadata": { + "scrolled": true + }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "No residual mismatches to walk through.\n" + "frame 147 centre (x=202, y=8) accepted only by cuda\n", + "raw window (ADU):\n", + "[[4646 5282 4703]\n", + " [4857 5318 4950]\n", + " [4763 4640 4858]]\n", + "\n", + "--- frozen ---\n", + " subtracted window:\n", + " [[ 45.3 638.4 -12.1]\n", + " [ 43.7 638.4 -7.5]\n", + " [ 1.1 70.7 136.4]]\n", + " centre value = 638.38 (local max? False)\n", + " max = 638.38 total = 1554.37\n", + " rms(centre) = 19.109\n", + " Test1: max > 5*rms = 95.54 -> True\n", + " Test3: total > 3*5*rms = 286.63 -> True\n", + " ACCEPT = False\n", + "\n", + "--- cuda ---\n", + " subtracted window:\n", + " [[ 45.3 638.4 -12.1]\n", + " [ 43.7 638.4 -7.5]\n", + " [ 1.1 70.7 136.4]]\n", + " centre value = 638.38 (local max? True)\n", + " max = 638.38 total = 1554.37\n", + " rms(centre) = 19.109\n", + " Test1: max > 5*rms = 95.54 -> True\n", + " Test3: total > 3*5*rms = 286.63 -> True\n", + " ACCEPT = True\n", + "\n", + "RESULT: frozen = reject , cuda = ACCEPT\n", + "pedestal mean gap @centre = 0.0000 ADU; rms gap = 0.0000\n" ] } ], diff --git a/python/tests/nsys_kernel_probe.py b/python/tests/nsys_kernel_probe.py deleted file mode 100644 index 9f96abfc..00000000 --- a/python/tests/nsys_kernel_probe.py +++ /dev/null @@ -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}') \ No newline at end of file diff --git a/python/tests/perf/README.md b/python/tests/perf/README.md new file mode 100644 index 00000000..8a853e8a --- /dev/null +++ b/python/tests/perf/README.md @@ -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/_/` 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_x.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 .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/_[_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. diff --git a/python/tests/perf/common.py b/python/tests/perf/common.py new file mode 100644 index 00000000..5e6e656b --- /dev/null +++ b/python/tests/perf/common.py @@ -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/_[_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.") diff --git a/python/tests/perf/cpu_threads.py b/python/tests/perf/cpu_threads.py new file mode 100644 index 00000000..78fda887 --- /dev/null +++ b/python/tests/perf/cpu_threads.py @@ -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() diff --git a/python/tests/perf/gpu_span.py b/python/tests/perf/gpu_span.py new file mode 100755 index 00000000..89bd2624 --- /dev/null +++ b/python/tests/perf/gpu_span.py @@ -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 .nsys-rep # makes the .sqlite + python gpu_span.py .sqlite + +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") diff --git a/python/tests/perf/kernel_resources.py b/python/tests/perf/kernel_resources.py new file mode 100755 index 00000000..480c8022 --- /dev/null +++ b/python/tests/perf/kernel_resources.py @@ -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 + +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() diff --git a/python/tests/perf/ladder.py b/python/tests/perf/ladder.py new file mode 100644 index 00000000..66cd3aa8 --- /dev/null +++ b/python/tests/perf/ladder.py @@ -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 diff --git a/python/tests/perf/nsys_kernel_probe.py b/python/tests/perf/nsys_kernel_probe.py new file mode 100644 index 00000000..6727a5f4 --- /dev/null +++ b/python/tests/perf/nsys_kernel_probe.py @@ -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)') \ No newline at end of file diff --git a/python/tests/perf/results/.gitignore b/python/tests/perf/results/.gitignore new file mode 100644 index 00000000..6e56b11b --- /dev/null +++ b/python/tests/perf/results/.gitignore @@ -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 diff --git a/python/tests/perf/results/2026-08-12_f32_legacy/SUPERSEDED.md b/python/tests/perf/results/2026-08-12_f32_legacy/SUPERSEDED.md new file mode 100644 index 00000000..5a0e8622 --- /dev/null +++ b/python/tests/perf/results/2026-08-12_f32_legacy/SUPERSEDED.md @@ -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. diff --git a/python/tests/perf/results/2026-08-18_f32/env.json b/python/tests/perf/results/2026-08-18_f32/env.json new file mode 100644 index 00000000..2d4472f2 --- /dev/null +++ b/python/tests/perf/results/2026-08-18_f32/env.json @@ -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." +} \ No newline at end of file diff --git a/python/tests/perf/results/2026-08-18_f32/ladder_3x3.csv b/python/tests/perf/results/2026-08-18_f32/ladder_3x3.csv new file mode 100644 index 00000000..047ee4c1 --- /dev/null +++ b/python/tests/perf/results/2026-08-18_f32/ladder_3x3.csv @@ -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, diff --git a/python/tests/perf/results/2026-08-18_f32/ladder_9x9.csv b/python/tests/perf/results/2026-08-18_f32/ladder_9x9.csv new file mode 100644 index 00000000..5f73389c --- /dev/null +++ b/python/tests/perf/results/2026-08-18_f32/ladder_9x9.csv @@ -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, diff --git a/python/tests/perf/results/2026-08-18_f32/manifest.csv b/python/tests/perf/results/2026-08-18_f32/manifest.csv new file mode 100644 index 00000000..df752ee3 --- /dev/null +++ b/python/tests/perf/results/2026-08-18_f32/manifest.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-18_f32/probes.csv b/python/tests/perf/results/2026-08-18_f32/probes.csv new file mode 100644 index 00000000..0bd0834a --- /dev/null +++ b/python/tests/perf/results/2026-08-18_f32/probes.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-18_f64/env.json b/python/tests/perf/results/2026-08-18_f64/env.json new file mode 100644 index 00000000..f94a2ccc --- /dev/null +++ b/python/tests/perf/results/2026-08-18_f64/env.json @@ -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" +} diff --git a/python/tests/perf/results/2026-08-18_f64/ladder_3x3.csv b/python/tests/perf/results/2026-08-18_f64/ladder_3x3.csv new file mode 100644 index 00000000..077bf99d --- /dev/null +++ b/python/tests/perf/results/2026-08-18_f64/ladder_3x3.csv @@ -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, diff --git a/python/tests/perf/results/2026-08-18_f64/ladder_9x9.csv b/python/tests/perf/results/2026-08-18_f64/ladder_9x9.csv new file mode 100644 index 00000000..21a0b341 --- /dev/null +++ b/python/tests/perf/results/2026-08-18_f64/ladder_9x9.csv @@ -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, diff --git a/python/tests/perf/results/2026-08-18_f64/manifest.csv b/python/tests/perf/results/2026-08-18_f64/manifest.csv new file mode 100644 index 00000000..25e167cc --- /dev/null +++ b/python/tests/perf/results/2026-08-18_f64/manifest.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-18_f64/probes.csv b/python/tests/perf/results/2026-08-18_f64/probes.csv new file mode 100644 index 00000000..284eb77e --- /dev/null +++ b/python/tests/perf/results/2026-08-18_f64/probes.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-19_cpu_threads/cpu_threads.csv b/python/tests/perf/results/2026-08-19_cpu_threads/cpu_threads.csv new file mode 100644 index 00000000..e798033c --- /dev/null +++ b/python/tests/perf/results/2026-08-19_cpu_threads/cpu_threads.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-19_cpu_threads/env.json b/python/tests/perf/results/2026-08-19_cpu_threads/env.json new file mode 100644 index 00000000..afb4de73 --- /dev/null +++ b/python/tests/perf/results/2026-08-19_cpu_threads/env.json @@ -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" +} diff --git a/python/tests/perf/results/2026-08-19_cpu_threads/manifest.csv b/python/tests/perf/results/2026-08-19_cpu_threads/manifest.csv new file mode 100644 index 00000000..10fb6ba3 --- /dev/null +++ b/python/tests/perf/results/2026-08-19_cpu_threads/manifest.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-20_INVALID_stale_build/INVALID.md b/python/tests/perf/results/2026-08-20_INVALID_stale_build/INVALID.md new file mode 100644 index 00000000..5d3766e3 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_INVALID_stale_build/INVALID.md @@ -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. diff --git a/python/tests/perf/results/2026-08-20_INVALID_stale_build/env.json b/python/tests/perf/results/2026-08-20_INVALID_stale_build/env.json new file mode 100644 index 00000000..98bf2ebb --- /dev/null +++ b/python/tests/perf/results/2026-08-20_INVALID_stale_build/env.json @@ -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" +} diff --git a/python/tests/perf/results/2026-08-20_INVALID_stale_build/manifest.csv b/python/tests/perf/results/2026-08-20_INVALID_stale_build/manifest.csv new file mode 100644 index 00000000..fb438c43 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_INVALID_stale_build/manifest.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-20_INVALID_stale_build/probes.csv b/python/tests/perf/results/2026-08-20_INVALID_stale_build/probes.csv new file mode 100644 index 00000000..8af9a1ae --- /dev/null +++ b/python/tests/perf/results/2026-08-20_INVALID_stale_build/probes.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-20_f32/env.json b/python/tests/perf/results/2026-08-20_f32/env.json new file mode 100644 index 00000000..d8de4507 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f32/env.json @@ -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" +} diff --git a/python/tests/perf/results/2026-08-20_f32_cap1700/env.json b/python/tests/perf/results/2026-08-20_f32_cap1700/env.json new file mode 100644 index 00000000..7f387bcc --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f32_cap1700/env.json @@ -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" +} diff --git a/python/tests/perf/results/2026-08-20_f32_cap1700/ladder_9x9.csv b/python/tests/perf/results/2026-08-20_f32_cap1700/ladder_9x9.csv new file mode 100644 index 00000000..0f3e93d3 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f32_cap1700/ladder_9x9.csv @@ -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, diff --git a/python/tests/perf/results/2026-08-20_f32_cap1700/manifest.csv b/python/tests/perf/results/2026-08-20_f32_cap1700/manifest.csv new file mode 100644 index 00000000..42e049c9 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f32_cap1700/manifest.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-20_f32_capAB/README.md b/python/tests/perf/results/2026-08-20_f32_capAB/README.md new file mode 100644 index 00000000..2b414de9 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f32_capAB/README.md @@ -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`. diff --git a/python/tests/perf/results/2026-08-20_f32_capAB/env.json b/python/tests/perf/results/2026-08-20_f32_capAB/env.json new file mode 100644 index 00000000..2dd7bd97 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f32_capAB/env.json @@ -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" +} diff --git a/python/tests/perf/results/2026-08-20_f32_capAB/manifest.csv b/python/tests/perf/results/2026-08-20_f32_capAB/manifest.csv new file mode 100644 index 00000000..3249377d --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f32_capAB/manifest.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-20_f32_capAB/probes.csv b/python/tests/perf/results/2026-08-20_f32_capAB/probes.csv new file mode 100644 index 00000000..2f3c09bf --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f32_capAB/probes.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-20_f64/env.json b/python/tests/perf/results/2026-08-20_f64/env.json new file mode 100644 index 00000000..9be4e695 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f64/env.json @@ -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" +} diff --git a/python/tests/perf/results/2026-08-20_f64_cap1700/env.json b/python/tests/perf/results/2026-08-20_f64_cap1700/env.json new file mode 100644 index 00000000..b5405bb3 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f64_cap1700/env.json @@ -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" +} diff --git a/python/tests/perf/results/2026-08-20_f64_cap1700/ladder_9x9.csv b/python/tests/perf/results/2026-08-20_f64_cap1700/ladder_9x9.csv new file mode 100644 index 00000000..b8c1f43d --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f64_cap1700/ladder_9x9.csv @@ -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, diff --git a/python/tests/perf/results/2026-08-20_f64_cap1700/manifest.csv b/python/tests/perf/results/2026-08-20_f64_cap1700/manifest.csv new file mode 100644 index 00000000..0169e669 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f64_cap1700/manifest.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-20_f64_capAB/README.md b/python/tests/perf/results/2026-08-20_f64_capAB/README.md new file mode 100644 index 00000000..0b681bda --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f64_capAB/README.md @@ -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. diff --git a/python/tests/perf/results/2026-08-20_f64_capAB/env.json b/python/tests/perf/results/2026-08-20_f64_capAB/env.json new file mode 100644 index 00000000..c2e11280 --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f64_capAB/env.json @@ -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" +} diff --git a/python/tests/perf/results/2026-08-20_f64_capAB/manifest.csv b/python/tests/perf/results/2026-08-20_f64_capAB/manifest.csv new file mode 100644 index 00000000..0be6df9b --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f64_capAB/manifest.csv @@ -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 diff --git a/python/tests/perf/results/2026-08-20_f64_capAB/probes.csv b/python/tests/perf/results/2026-08-20_f64_capAB/probes.csv new file mode 100644 index 00000000..0933f57e --- /dev/null +++ b/python/tests/perf/results/2026-08-20_f64_capAB/probes.csv @@ -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 diff --git a/python/tests/perf/run_campaign.sh b/python/tests/perf/run_campaign.sh new file mode 100755 index 00000000..47b12807 --- /dev/null +++ b/python/tests/perf/run_campaign.sh @@ -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 ===" diff --git a/python/tests/perf/run_ladder.py b/python/tests/perf/run_ladder.py new file mode 100644 index 00000000..299cd9ef --- /dev/null +++ b/python/tests/perf/run_ladder.py @@ -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/_/ 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()) diff --git a/python/tests/perf/run_probes.py b/python/tests/perf/run_probes.py new file mode 100644 index 00000000..a288a45d --- /dev/null +++ b/python/tests/perf/run_probes.py @@ -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()) diff --git a/python/tests/validation_tiers.py b/python/tests/validation_tiers.py new file mode 100644 index 00000000..d7409da2 --- /dev/null +++ b/python/tests/validation_tiers.py @@ -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"]}')