docs: detail opt2, op3 and opt5->opt6 explanaitons

This commit is contained in:
kferjaoui
2026-08-26 14:38:23 +02:00
parent 5d9ded8fad
commit 9665df975c
13 changed files with 1883 additions and 1535 deletions
+73 -12
View File
@@ -76,7 +76,7 @@ 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`
- `docs/cf_cuda_performance.pptx` — the deck these numbers feed, built by `docs/deck/build_performance_deck.py`
---
@@ -733,12 +733,25 @@ internal chunking, which is how the ladder reproduces opt3/opt4 on the current c
### 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:
At 9×9, opt5 lands at **66.39 µs against a 30.01 µs peak**:
> **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.**
> ~36 µs/frame gap is host-side, and overlap cannot hide it because the host term is the
> larger one.**
Two caveats on that sentence, both of which matter and neither of which changes it.
**It is inference, not arithmetic.** Reading the host term straight off the end-to-end
time assumes the pipelined loop attains `max(GPU, host)` exactly. That is the design, but
it is not measured here, and if overlap were imperfect the host term could be smaller
with the balance being un-overlapped GPU. The independent support is opt6: same loop,
same two slots, and it lands on **30.01 µs — 100 % of the floor** (§9), which is
`max(GPU, host)` attained exactly. The machinery demonstrably works when the host term is
small.
**66.39 µs is not a plateau value.** It is the best of five reps, and it still carries
**151 601 minor faults**. opt5 at 9×9 is the one row in the ladder that never reaches a
fault-free steady state — see §8.3.
The host path is `collect()`'s materialization loop
([`materialize_slot`, `ClusterFinderCUDA.hpp:137`](../include/aare/ClusterFinderCUDA.hpp#L137)):
@@ -751,6 +764,47 @@ despite the far larger absolute gap: pipelining hides `min(GPU, host)`, so it pa
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.
### 8.3 opt5 at 9×9 never reaches a plateau — and what the host term actually is ★
Every other row in this document is quoted at steady state. This one cannot be. From
`ladder_9x9.csv` in `2026-08-20_f64_cap1700/`, with the §25 rate of **0.68 µs per
first-touch fault** applied out of sample:
| rep | µs/frame | minor faults | fault cost | fault-free |
|--:|--:|--:|--:|--:|
| 0 | 78.56 | 460 283 | 15.65 | 62.91 |
| **1** | **66.39** | **151 601** | **5.15** | **61.23** |
| 2 | 67.34 | 95 884 | 3.26 | 64.08 |
| 3 | 81.26 | 506 241 | 17.21 | 64.04 |
| 4 | 73.97 | 334 303 | 11.37 | 62.60 |
The fault count does not converge — 460 k → 152 k → 96 k → 506 k → 334 k — because each
rep meets a different allocator state for the 467 kB per-frame block. Contrast opt6 in the
same file: **2 072, 0, 0, 0, 0**. And contrast opt5 at **3×3**, which is clean (3096 k
faults ≈ 0.20.65 µs/frame, 1.6 % spread). The contamination is specific to this one cell
of the matrix, and its cause is exactly the allocation opt6 deletes.
**The host term is ~62 µs, not ~40.** Two independent routes agree:
1. **Fault correction.** Subtracting the fault term collapses a **22 % raw spread into
4.6 %**, at 6164 µs. A rate fitted at 3×3 and applied unchanged here should not be
able to do that unless it is describing the real mechanism.
2. **A rep that was already warm.** In the f32 arm, rep 3 happened to run with only
**10 128 faults** (0.34 µs/frame) and measured **61.85 µs** with no correction at all —
inside the corrected f64 band.
Annex A4's `opt5` row already contained this pair (`66.39 (152 k)` vs `61.85 (10 k)`,
verdict *not separable*); §8.3 is what it implies for the host term.
**Consequence.** Any figure drawing the 9×9 host cost at 40 µs understates it by about
half. The conclusion is unaffected — the host is the taller bar either way — but the
margin is roughly 2× the GPU floor, not 1.3×.
**Reproducing it.** Timing this row honestly needs a fresh process per rep and a
pre-touched result heap; without both, the number that comes out is an allocator state,
not a throughput.
**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
@@ -804,14 +858,21 @@ since the sustained rate is the lower of the two.
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**) |
| | bytes copied per frame by `collect()` | memcpy at bandwidth | **host term** | GPU floor | fits underneath? |
|---|--:|--:|--:|--:|:--:|
| 3×3 | 2 330.9 × 40 B ≈ 93 kB | ~8 µs | ~9.8 µs | 16.17 µs | **yes** → small win (×1.16) |
| 9×9 | 1 422.4 × 328 B ≈ 467 kB | ~40 µs | **~62 µs** | 30.01 µs | **no** → large win (**×2.21**) |
**The two middle columns are not the same quantity, and conflating them understates the
9×9 case by about half.** "memcpy at bandwidth" is 467 kB divided by a single-threaded
copy rate — it is what the loop would cost if it were bandwidth-bound. It is not:
[`ClusterFinderCUDA.hpp:130-136`](../include/aare/ClusterFinderCUDA.hpp#L130-L136) records
that the work is *one 467 kB malloc + first-touch per frame, allocation-bound rather than
bandwidth-bound*. The host-term column is the steady-state figure derived in §8.3.
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
the per-frame allocation and the fault floor — second-order. At 9×9 the host term is
**twice** 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
@@ -1306,7 +1367,7 @@ opt1/opt2 stop over-counting extended charge-shared events:
| 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` | 34 slides in the same three acts plus a 15-slide annex (A1A5); figures from `docs/deck/make_figs.py`. The `.pptx` is untracked — rebuild it from the script |
| deck | `docs/cf_cuda_performance.pptx` + `docs/deck/build_performance_deck.py` | 35 slides in the same three acts plus a 6-group annex (A1A6), 53 pages with dividers; figures from `docs/deck/make_figs.py` and `make_figs_kernel.py`. Rebuild with `python docs/deck/make_figs.py && python docs/deck/build_performance_deck.py` |
### CSV step labels
+96
View File
@@ -358,3 +358,99 @@ previously two-and-a-half. They now read as three kinds without being read:
| code panel | framed, lighter fill, rounded | source |
| callout | flat panel, coloured left spine | conclusion |
| caption | none | provenance |
---
## §9 — Permanent names
`fused` described how the deck was assembled (two decks merged, once, in
August 2026); `eng_slides` was a placeholder. Neither says what the file *is*,
which is what a name has to do a year from now. Renamed on both sides:
| was | now | what it is |
|---|---|---|
| `cf_cuda_fused.pptx` | **`cf_cuda_performance.pptx`** | the talk: kernel design, hardware limits, the seven steps |
| `cf_cuda_eng_slides.pptx` | **`cf_cuda_internals.pptx`** | the engineering addendum: contracts, ownership, API structure |
| `build_fused_deck.py` | **`build_performance_deck.py`** | |
| `build_eng_slides.py` | **`build_internals_deck.py`** | |
Both names are audience-neutral and describe content rather than construction or
readership, so neither has to change if the audience split changes. The
`cf_cuda_` family prefix is kept, and `cf_cuda_kernel.pptx` is untouched — it is
the PSI base deck that donates the theme, not an output.
References updated in `make_figs.py` (including the `_placements()` parse path,
which reads the deck script to keep the legibility gate in sync with the layout —
verified still resolving after the rename), `make_figs_kernel.py`,
`build_internals_deck.py` (its `SRC` path and the exec marker), the report
(§0 and the artefact table) and `python/tests/perf/kernel_resources.py`.
The earlier dated changelogs keep the old names on purpose: they record what the
files were called on those dates.
---
## §10 — opt3's other barrier, and the 9×9 bridge (33 → 35 slides)
Two slides inserted, which shifted every later number: 31 `chrome()` indices, 7
`section()` ranges with their item lists, and 23 prose cross-references were
remapped (`n ≤ 14 → n`, `1516 → n+1`, `≥ 17 → n+2`).
**New 15 — "One D2H per frame, not two".** opt3's title has always read "remove
the sync barriers", plural, but the deck told only one of them: the per-round
`cudaDeviceSynchronize`. The count-then-fetch round trip went at the same step and
appeared nowhere. Two step-flows carry it — `kernel copy 4 B BLOCK read
count copy N B BLOCK` against `kernel copy the whole envelope next frame`
— because the argument is a shape, not a listing. No table, no code panel: the
point is that a transfer whose length depends on the transfer before it cannot be
streamed, and that opt3 pays ~20 % more bytes to delete that edge.
**17 (opt5) is now 3×3 only.** It quoted ×1.31 in its title while its figure drew
3×3 proportions and its rail carried both geometries. `fig_overlap` is labelled
`3×3`, the rail keeps one column, and the freed space holds the six-line
submit/collect loop with the point that matters to an engineer: a synchronous call
became two calls and a token, and `find_clusters_batched()` wraps both so callers
who want one call keep one call.
**New 18 — "Overlap runs out: the host is the taller bar".** The bridge into opt6,
and the answer to the question the room reliably asks. `fig_overlap_9x9` draws the
pipeline twice at measured proportions (GPU 30.01, host ~62) — once with two slots
and once with three — and lands both on the same finish line, because the host lane
is already back-to-back in both. A deeper buffer relocates GPU idle; it cannot
close it.
## §11 — the 9×9 host term was understated by half
`fig_resultpath` drew the 9×9 host bar at **40 µs**. That figure was 467 kB divided
by a single-threaded copy rate — what the loop would cost if it were
bandwidth-bound. `ClusterFinderCUDA.hpp:130-136` says it is not: *one 467 kB malloc
+ first-touch per frame, allocation-bound rather than bandwidth-bound*.
Checking the raw measurement settled it. opt5 at 9×9 is the **one row in the ladder
that never reaches a fault-free plateau** — faults across five reps run
460 k → 152 k → 96 k → 506 k → 334 k with no convergence, against opt6's
2 072, 0, 0, 0, 0 in the same file, and against a clean opt5 at 3×3. Even the
quoted 66.39 µs carries 151 601 minor faults.
Correcting each rep at the deck's own 0.68 µs/fault collapses a **22 % spread into
4.6 %**, at 6164 µs. Independently, f32 rep 3 happened to run with 10 128 faults
and measured **61.85 µs** directly. So the host term is **~62 µs**, and the bar is
now drawn there. The ×2.21 conclusion is measured and unchanged; what changes is
that the picture explaining it no longer understates its own case.
Report: §8.2 gained the two caveats (the host term is inference, not arithmetic;
66.39 is not a plateau), §8.3 is new and documents the fault data, and §12's
payload table now separates "memcpy at bandwidth" from "host term" instead of
printing one under the other's name.
## §12 — housekeeping
- `docs/deck/README.md` is new: how to build, the two mechanical invariants
(9 pt legibility, nothing past the footer) and why each exists, the numbering
hazard, and where the numbers come from.
- The overflow checker had a hardcoded input path and ignored `argv`, so it had
been validating a stale PDF. Fixed, and every check in this session's later
passes was re-run against it.
- `build_internals_deck.py` and `cf_cuda_internals.pptx` deleted: the four
engineering slides they held are now folded into the single deck, in the arc,
which is what "one deck with a bit more detail" means.
+96
View File
@@ -0,0 +1,96 @@
# The CUDA ClusterFinder deck
`docs/cf_cuda_performance.pptx` — kernel design, hardware limits, and the opt1→opt7
optimization ladder, told in three acts ordered by which bar is tallest.
35 numbered slides plus a 6-group annex, 53 pages once dividers and the title page
are counted.
## Build
```bash
python docs/deck/make_figs.py # figures -> docs/figures/*.png
python docs/deck/make_figs_kernel.py # 3 more (fig_frame, fig_occupancy, fig_tile)
python docs/deck/build_performance_deck.py
```
Order matters: the deck embeds the PNGs, so regenerate figures first if you touched
either `make_figs*.py`. Running only the builder is fine when you have changed slide
text or layout alone.
Requires `python-pptx`, `matplotlib`, `pillow`, `lxml`. On this machine the only
interpreter with all four is `/home/ferjao_k/.conda/envs/py/bin/python` — the system
`python` is absent, `python3` is too old, and `python3.11` has no matplotlib.
`docs/cf_cuda_kernel.pptx` is an **input**, not an output: it donates the PSI theme and
the title slide, and every other slide of it is deleted at build time. Do not edit the
generated `.pptx` by hand — it is overwritten on every build. Edit the script.
## Layout guarantees, and how they are enforced
Two invariants are checked mechanically, because both fail silently otherwise.
**Nothing renders below 9 pt on the projected slide.** A figure's on-screen type size
is `raw_pt × (placement_width / figure_width)`, and neither factor is visible at the
point where the font size is written. `make_figs.py` closes that loop: `_placements()`
parses the placement width of every figure **out of the deck script itself**, so the
gate cannot drift from the layout it checks. Every run ends with either
```
legibility: every string in every figure renders at >= 9.0 pt on the slide.
```
or a list of offenders. Fix them; do not raise the floor. 9 pt on a 13.33 × 7.5 in
slide is about 1/60 of slide height, which is the conventional bound for readable
supporting detail at 67 m.
Note the feedback trap: `savefig(bbox_inches="tight")` grows the saved canvas to fit a
long in-figure caption, which shrinks the placement scale, which shrinks the caption.
Raising the font size can make text *smaller*. Shorten the string or re-lay the axes.
**No text runs past the footer line.** Convert and check:
```bash
libreoffice --headless --convert-to pdf --outdir /tmp/deck docs/cf_cuda_performance.pptx
python scratch/overflow.py /tmp/deck/cf_cuda_performance.pdf
```
Only page 1 may be flagged — that is the PSI template's own title slide. The same
script counts unrendered `**` markup, which is the usual symptom of putting markup in a
helper that does not parse it: `bullets`, `callout`, `table` and `code` understand
`**bold**`; `caption` does not, and nothing understands backticks or `*italics*`.
## Numbering
Slide indices are explicit — `chrome(s, 17, …)` — and so are the section ranges and the
prose cross-references ("expands slide 27"). **Inserting a slide shifts all three.** As
of this writing that is 31 `chrome()` calls, 7 `section(… rng=…)` ranges with their item
lists, and ~23 prose references. Renumber all of them in one pass and rebuild; the
progress track and the `N / 35` counter both read `N_SLIDES`.
## Where the numbers come from
`docs/ClusterFinderCUDA_benchmark_results.md`, quotable rows only. Two conventions the
deck depends on, both defined in slide 20:
- **s1** is one stream — true, exclusive engine durations.
- **s4** is the shipped four-stream pipeline — engine *occupancy*, the union of
intervals per frame. The kernel overlaps itself across streams (9×9 f64 reads 32.66 µs
at s4 against ~43.2 µs per kernel); H2D and D2H do not, because there is one copy
engine per direction.
- **floor** = `1 / max(H2D, kernel, D2H)` at s4, taking the lower of the nsys estimate
and the best rate actually sustained. One quantity, two units: 30.01 µs/frame =
33 323 FPS.
One row is not at steady state and is flagged as such on its slide and in §8.3 of the
report: opt5 at 9×9, whose per-frame allocation never lets the fault count converge.
## Files
| file | role |
|---|---|
| `build_performance_deck.py` | the deck: tokens, helpers, every slide |
| `make_figs.py` | most figures, plus the legibility gate |
| `make_figs_kernel.py` | `fig_frame`, `fig_occupancy`, `fig_tile` |
| `frame147.json`, `validation_tiers.json` | measured data two figures read |
| `CHANGELOG_2026-08-*.md` | dated records of past revisions; they keep the file names in use on those dates |
@@ -1,4 +1,4 @@
"""Build docs/cf_cuda_fused.pptx — the algorithm + kernel + hardware half of
"""Build docs/cf_cuda_performance.pptx — the algorithm + kernel + hardware half of
docs/cf_cuda_kernel.pptx fused with the opt1opt7 optimization story.
The ladder is told in three acts, ordered by which bar is tallest:
@@ -39,7 +39,7 @@ 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"
OUT = DOCS / "cf_cuda_performance.pptx"
# ---------------------------------------------------------------- design tokens
BG = RGBColor(0x0B, 0x10, 0x18)
@@ -78,7 +78,7 @@ 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 = 33
N_SLIDES = 35
# ------------------------------------------------------------------- helpers
@@ -457,8 +457,8 @@ def section(kicker, title, thesis, items, rng, col=ACCENT, carry=None,
Deliberately sparse it exists to buy 1015 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 333 keep the numbers they have, so the annex's
cross-references ("expands slide 25") stay true. What it lights up instead
progress track: slides 335 keep the numbers they have, so the annex's
cross-references ("expands slide 27") stay true. What it lights up instead
is the *range* the section covers, which is the thing the audience wants.
"""
s = new_slide()
@@ -1011,8 +1011,9 @@ section("Act I of III · feed the GPU",
[(12, "opt1 · first port"),
(13, "opt2 · streams + batching"),
(14, "opt3 · no barriers"),
(15, "opt4 · pinned memory")],
rng=(12, 15),
(15, "opt3 · one D2H, not two"),
(16, "opt4 · pinned memory")],
rng=(12, 16),
carry=("Starting from", "6 762 FPS",
"24-thread CPU · 14.8 s for 100 000 frames"))
@@ -1040,7 +1041,7 @@ callout(s, M, 4.62, COL,
"overlap, so the **floor** — the fastest a frame can go if the host cost "
"nothing — is **max(H2D, kernel, D2H)**, never the sum. At 3×3 that is "
"max(**16.17**, 15.17, 7.69) = **16.2 µs → 61 859 FPS**. Exactly how each of "
"those three is measured is slide 18; it does not change this one.",
"those three is measured is slide 20; it does not change this one.",
h=1.00, size=10.5)
figure(s, "fig_opt1_timeline", M, 5.80, COL)
rail(s, [
@@ -1123,9 +1124,74 @@ caption(s, 8.35, 6.15, 4.25,
"not sketched: H2D and D2H are one FIFO engine each, so a stream waits for the "
"copy engine, never for another stream's copy to finish overlapping it.")
# ===================================================== 15 · OPT3b · ONE D2H
# opt3's title has always said "barriers", plural, but the deck only ever told
# one of them: the per-round cudaDeviceSynchronize. The count-then-fetch round
# trip went at the same step and was never shown, which made opt2 -> opt3 look
# like a refactor instead of the change of contract it was.
s = new_slide()
chrome(s, 15, "Act I · opt3 · the other barrier",
"One D2H per frame, not two")
bullets(s, M, 1.92, 11.9, [
"opt2 asked the device **how many clusters**, blocked until the answer came "
"back, then asked for **that many**. The size of the second copy was a "
"function of data that had not arrived yet.",
"opt3 gives every frame a **fixed envelope** — count, then room for **cap** "
"clusters — so the copy's size is known at construction and can be queued "
"with the kernel. The count is still read, but **afterwards**, on the host.",
], size=11)
flow(s, M, 3.42, 11.9,
["kernel", "copy 4 B", "BLOCK", "read count", "copy N B", "BLOCK"], h=0.62)
caption(s, M, 4.12, 11.9,
"opt2 · two transfers and two stalls per frame, because the second one "
"cannot be issued until the first has landed.", size=9.5)
flow(s, M, 4.62, 11.9,
["kernel", "copy the whole envelope", "→ next frame, host not involved"], h=0.62)
caption(s, M, 5.32, 11.9,
"opt3 · one transfer, no stall. Nothing in the loop waits on a value.", size=9.5)
callout(s, M, 5.86, 11.9,
"**You cannot stream a transfer whose length depends on the transfer "
"before it.** opt3 pays bytes to delete that dependency: the envelope is "
"sized by the **cap**, not by how many clusters were found, so an empty "
"frame costs the same D2H as a full one — 120 kB at 3×3 against 93 kB of "
"real clusters.", h=0.94, size=10.5)
caption(s, M, 6.94, 11.9,
"Everything downstream needs that fixed layout: opt6 could not hand out a "
"view into a buffer whose shape was not known in advance.", size=9)
notes(s, """The point to say out loud: this is the one step in the ladder that is
not a setting. It changed the kernel signature, the buffer ownership and the
collection loop, and it is why the opt1/opt2 class is frozen in a separate header
(ClusterFinderCUDAOpt2.hpp) rather than being a flag on the current one.
The dependency edge is the whole argument. In opt2 the second memcpy's SIZE
argument is *sc.h_cluster_count -- host memory that only becomes valid after a
cudaStreamSynchronize (Opt2.hpp:277-294, then :442-446). So the sequence is
forced: kernel, copy 4 bytes, BLOCK, read, copy N bytes, BLOCK. Two of those six
steps are the host doing nothing, every frame.
opt3 fixes the size once in the constructor (m_output_bytes_per_frame =
m_clusters_offset + cap * sizeof(ClusterType), ClusterFinderCUDA.hpp:440-445), so
the copy is enqueued in the same loop iteration as the kernel launch (:725-728).
The kernel gets two pointers into ONE allocation (:701 and :709).
What it costs: 120 kB instead of 93 kB per frame at 3x3, 558 instead of 467 at
9x9. Roughly 20 % more bytes on an engine that had spare time, to buy back a
barrier that was stalling everything. That is also why the cap becomes a
throughput knob only from opt3 onward: under opt2 it bounded an allocation, under
opt3 it sets the D2H bar directly (report section 4.2).
If asked why opt2 did not simply copy a cap-sized buffer and skip the sync: that
IS opt3. It could not be done in opt2 because the clusters lived in their own
device allocation with no count field and no fixed per-frame stride -- there was
no single object to copy. Merging the two buffers is what created one.""")
# =========================================================== 14 · OPT4
s = new_slide()
chrome(s, 15, "Act I · opt4 · pinned (page-locked) memory",
chrome(s, 16, "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 "
@@ -1162,15 +1228,16 @@ 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. Still 3×3, "
"but 9×9 is where this act pays most.",
[(16, "opt5 · host↔GPU overlap"),
(17, "opt6 · zero-copy")],
rng=(16, 17), col=PALE,
[(17, "opt5 · host↔GPU overlap"),
(18, "9×9 · why overlap runs out"),
(19, "opt6 · zero-copy")],
rng=(17, 19), 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",
chrome(s, 17, "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 "
@@ -1179,10 +1246,14 @@ bullets(s, M, 1.92, COL, [
"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.")
figure(s, "fig_overlap", M - 0.15, 3.26, COL + 0.30)
code(s, M, 5.88, COL, [
"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 i+1",
" results.extend(cf.«collect»(tok)) // host unpacks i",
" tok = nxt",
], size=9, title="you never write these: find_clusters_batched() wraps them")
notes(s, """opt5 — host<->GPU overlap. Code and chunk sizing are on annex A3.
tok = cf.submit_batch(data[a0:b0], first_frame=a0)
@@ -1206,21 +1277,81 @@ 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 · 3×3 and 9×9 · no CUDA work at all"),
("label", "opt5 · 3×3 · no CUDA work at all"),
("gap", 0.10),
("stat", "3×3 throughput", "50 410 FPS", PALE),
("row", "per frame · step · of floor", "19.8 µs · ×1.31 · 81 %", ACCENT),
("gap", 0.14),
("stat", "9×9 throughput", "15 063 FPS", PALE),
("row", "per frame · step · of floor", "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."),
("gap", 0.16),
("label", "the whole change"),
("gap", 0.06),
("row", "CUDA API calls added", "none", TEXT2),
("row", "what moved", "the host loop", PALE),
("gap", 0.16),
("note", "Told at 3×3, where the host copy is SHORTER than the GPU floor and "
"tucks underneath it. 9×9 is the other case, and it is the next "
"slide. For clusters that must outlive the finder, 3×3 opt5 is the "
"endpoint: opt6 lends, it does not give."),
])
# ============================================ 18 · WHY OVERLAP RUNS OUT AT 9x9
# The bridge from opt5 to opt6. Slide 17 is told at 3x3, where the host copy fits
# underneath the GPU floor and two slots are plainly enough. At 9x9 the host is
# the taller bar and the room reliably guesses "add more slots" -- so the picture
# answers that guess directly, by drawing three slots and landing on the same
# finish line. Once buffering is ruled out, opt6 is the only move left.
s = new_slide()
chrome(s, 18, "Act II · opt5 at 9×9 · why overlap runs out",
"Overlap runs out: the host is the taller bar")
bullets(s, M, 1.84, 11.9, [
"At 3×3 the host copy is **shorter than the GPU floor** and hides underneath "
"it. At 9×9 it is **roughly twice the floor** — ~62 µs of malloc-and-copy "
"against 30.01 µs of GPU — so overlap still works, it just has less to hide. "
"That is why opt5 is worth **×1.20** here and ×1.31 at 3×3.",
], size=10.5)
figure(s, "fig_overlap_9x9", M + 0.15, 2.52, 11.3)
callout(s, M, 6.30, 11.9,
"A deeper buffer **relocates the GPU's idle, it does not close it** — the "
"host lane is already back-to-back in both strips, so it alone sets the "
"pace. **The only way down is to make the host term smaller.**",
h=0.66, size=10.5)
caption(s, M, 7.06, 11.9,
"Measured proportions: GPU 30.01 µs/frame, host ~62 µs steady-state. "
"Fault correction and the raw 66.4 µs are in the notes and annex A4.",
size=9)
notes(s, """This slide exists because "add more slots" is the reliable guess here,
and it is worth letting the room say it out loud before the second strip goes up.
The queueing argument, if it is asked for: with producer period G and consumer
period H, an N-buffer pipeline has steady-state period max(G, H) for every N >= 2.
Buffers DECOUPLE two stages; they do not speed up either. Depth beyond 2 only
helps when the periods vary -- it absorbs jitter, at the cost of latency and
pinned memory. Here the work per chunk is near constant, so there is no jitter to
absorb.
The concrete version lands better: a third slot needs somebody to fill it, and
the only host thread is inside collect(). Adding slots without adding a producer
thread is adding storage to a queue that is not storage-bound.
"So would a producer thread help?" It would let submit and collect overlap, but
the host term is dominated by malloc + first touch, which is allocator-serialised
anyway. That was measured and reverted (ClusterFinderCUDA.hpp:130-136): 2.27 M
faults at 8 threads against 9.7 k at 1, a 6 % gain at best and a 33 % LOSS when
results are freed promptly. The comment there ends with the right conclusion --
stop allocating per frame, do not copy faster. That is opt6.
ON THE 62 US, if challenged. It is not measured directly; no one timed the host
loop. Two independent routes agree on it. (1) Fault-correct the five f64 reps at
0.68 us/fault -- the same rate fitted at 3x3 and applied out of sample -- and a
22 % raw spread collapses to 4.6 %, at 61-64 us. (2) f32 rep 3 happened to run
with only 10 128 faults and measured 61.85 us with no correction at all. The
"~40 us" that used to be on the opt6 slide was the memcpy alone, computed at
bandwidth; the loop is allocation-bound, not bandwidth-bound, so it under-counted
the host term by about half.""")
# =========================================================== 19 · OPT6
s = new_slide()
chrome(s, 17, "Act II · opt6 · zero-copy collection",
chrome(s, 19, "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 "
@@ -1233,8 +1364,8 @@ bullets(s, M, 1.92, COL, [
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)
"a 16.2 µs floor and opt5 had already absorbed most of it; at 9×9 the ~62 µs "
"host term is **twice 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)
@@ -1280,11 +1411,11 @@ section("Act III of III · the kernel",
"Only now is the kernel the tallest bar",
"The story moves to 9×9, where the kernel is finally the tallest bar. First, "
"how the engine times in this act are measured.",
[(18, "how the engine times are measured"),
(19, "opt7 · FP32 pedestal"),
(20, "catastrophic cancellation"),
(21, "why it comes last")],
rng=(18, 21), col=AMBER,
[(20, "how the engine times are measured"),
(21, "opt7 · FP32 pedestal"),
(22, "catastrophic cancellation"),
(23, "why it comes last")],
rng=(20, 23), col=AMBER,
carry=("Arriving at", "58 495 FPS",
"opt6 · 3×3, and 33 323 FPS at 9×9, where this act pays"))
@@ -1295,7 +1426,7 @@ section("Act III of III · the kernel",
# grid of numbers; this slide keeps only the three definitions and the one
# picture that makes the middle one make sense.
s = new_slide()
chrome(s, 18, "How the engine times are measured",
chrome(s, 20, "How the engine times are measured",
"Two configurations, one floor")
cards = [
("s1", ACCENT, "One stream, nothing else running",
@@ -1361,7 +1492,7 @@ the floor is the busiest engine." The rest is in A1.""")
# =========================================================== 18 · OPT7 why
s = new_slide()
chrome(s, 19, "Act III · opt7 · FP32 device pedestal",
chrome(s, 21, "Act III · opt7 · FP32 device pedestal",
"FP32 halves pedestal traffic: 41 % kernel time")
bullets(s, M, 1.95, 7.5, [
"**~80 % of pixels** take the **pedestal-update** branch: it reads off, sum and "
@@ -1403,7 +1534,7 @@ s1 alone would say the kernel is the thing to optimise and stop there.
Right is s4, the shipped four-stream pipeline, and it says something different:
the f64 kernel's busy time falls to 32.66 (self-overlap, 1.32x) while every
transfer RISES under contention, and once opt7 puts the kernel at 23.94 the D2H
bar at 25.24 is above it. That is the handover slide 21 is about.
bar at 25.24 is above it. That is the handover slide 23 is about.
So: -40.5 % is the s1 kernel claim and is the honest headline for what the
typedef does to the arithmetic. -26.7 % is the same change measured at s4, where
@@ -1423,7 +1554,7 @@ nothing end to end, because at 3x3 H2D is the floor.""")
# two-line change that removes it. The error-floor curve and the full rewrite
# are annex A5.
s = new_slide()
chrome(s, 20, "Act III · opt7 · catastrophic cancellation",
chrome(s, 22, "Act III · opt7 · catastrophic cancellation",
"Accumulate what is small, not what is large")
bullets(s, M, 1.90, COL, [
(TEXT2, "**The trap.** The variance was computed as **var = E[X²] mean²**. "
@@ -1485,7 +1616,7 @@ change the update's arithmetic cost.""")
# =========================================================== 21 · OPT6 when
s = new_slide()
chrome(s, 21, "Act III · why this act comes last",
chrome(s, 23, "Act III · why this act comes last",
"The saving never grew — the frame around it shrank")
figure(s, "fig_f32_absolute", M, 1.95, 11.9)
callout(s, M, 4.86, 5.85,
@@ -1514,16 +1645,16 @@ caption(s, M, 6.30, 11.9,
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.",
[("2223", "Results, both cluster sizes"),
("24", "Where the time went"),
("2526", "What the numbers survived")],
rng=(22, 26), col=PALE,
[("2425", "Results, both cluster sizes"),
("26", "Where the time went"),
("2728", "What the numbers survived")],
rng=(24, 28), col=PALE,
carry=("Arriving at", "58 495 FPS",
"opt6 · everything after this is the ladder seen whole"))
# =========================================================== 22 · RESULTS
s = new_slide()
chrome(s, 22, "Results · 3×3", "×9.1 at 3×3, sitting on the H2D floor")
chrome(s, 24, "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, "
@@ -1532,7 +1663,7 @@ callout(s, 6.75, 5.80, 5.85,
"Every step is **monotonic**, and correctness is held constant **throughout**: "
"0.004 % against the CPU baseline; against the CPU twin that isolates the port, "
"**exact on the f64 pedestal** and **6 clusters in 23 M** on the shipped f32 "
"(slides 2730).", h=0.8, color=AMBER)
"(slides 2932).", 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 14 (collect() does not converge, it oscillates between "
@@ -1541,7 +1672,7 @@ caption(s, M, 6.62, 11.9,
# =========================================================== 23 · RESULTS 9x9
s = new_slide()
chrome(s, 23, "Results · 9×9", "×26.5 at 9×9, and opt7 hands the floor to D2H")
chrome(s, 25, "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 "
@@ -1559,7 +1690,7 @@ caption(s, M, 6.62, 11.9,
# =========================================================== 24 · WHERE TIME GOES
s = new_slide()
chrome(s, 24, "Where the time actually went",
chrome(s, 26, "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,
@@ -1585,7 +1716,7 @@ caption(s, M, 6.45, 11.9,
# themselves. The other two are instrument caveats: named here, worked through
# in A6. The full three-card version is A6·1.
s = new_slide()
chrome(s, 25, "Behind the numbers · the artefact that dominates",
chrome(s, 27, "Behind the numbers · the artefact that dominates",
"A GPU benchmark mostly measures the operating system")
bullets(s, M, 1.90, COL, [
"Every run that keeps its results materialises **~10 GB of clusters**. The "
@@ -1630,7 +1761,7 @@ rail(s, [
# =========================================================== 25 · FIRST RUN
s = new_slide()
chrome(s, 26, "Behind the numbers · what a user actually gets",
chrome(s, 28, "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,
@@ -1652,17 +1783,17 @@ caption(s, M, 6.76, 11.9,
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.",
[("2728", "The fair comparison"),
("2930", "The residual, dissected"),
("3132", "For users"),
("33", "What is next")],
rng=(27, 33), col=PALE,
[("2930", "The fair comparison"),
("3132", "The residual, dissected"),
("3334", "For users"),
("35", "What is next")],
rng=(29, 35), 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, 27, "Validation · why a CPU twin was needed",
chrome(s, 29, "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,
@@ -1708,7 +1839,7 @@ Frozen ships in the library as a diagnostic, not as the recommended finder.""")
# =========================================================== 26 · CORRECTNESS
s = new_slide()
chrome(s, 28, "Validation · isolating one variable at a time",
chrome(s, 30, "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 "
@@ -1748,7 +1879,7 @@ caption(s, M, 6.58, 11.9,
# ================================================ 28 · THE MISMATCH, SEEN
s = new_slide()
chrome(s, 29, "Validation · the disagreement, seen",
chrome(s, 31, "Validation · the disagreement, seen",
"The whole disagreement is one duplicate centre")
figure(s, "fig_mismatch147", 1.37, 1.72, 10.6)
callout(s, M, 6.30, 5.85,
@@ -1783,7 +1914,7 @@ reproducible from ClusterFinderFrozen_vs_CUDA.ipynb directly.""")
# ========================================================== 27 · RESIDUALS
s = new_slide()
chrome(s, 30, "Validation · the six residuals, dissected",
chrome(s, 32, "Validation · the six residuals, dissected",
"float32 cannot tell these two pixels apart")
code(s, M, 1.88, 6.35, [
"frame 147 centre (x=202, y=8) 3×3 window",
@@ -1850,7 +1981,7 @@ Consequences worth stating out loud if asked:
# =========================================================== 28 · API 1
s = new_slide()
chrome(s, 31, "For users · Python API", "The fast path in eight lines")
chrome(s, 33, "For users · Python API", "The fast path in eight lines")
code(s, M, 1.95, 7.6, [
"from aare import File, ClusterFinderCUDA",
"",
@@ -1890,7 +2021,7 @@ callout(s, 8.6, 6.35, 4.1,
# =========================================================== 27 · API 2
s = new_slide()
chrome(s, 32, "For users · choosing the knobs",
chrome(s, 34, "For users · choosing the knobs",
"Five knobs, and the one that silently truncates")
hdr = [("Parameter", 1.05), ("What it does", 3.6), ("Guidance", 5.2)]
y = 2.0
@@ -1940,7 +2071,7 @@ callout(s, M, 6.55, 11.2,
# =========================================================== 28 · NEXT
s = new_slide()
chrome(s, 33, "Where this leaves us",
chrome(s, 35, "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",
@@ -2008,12 +2139,12 @@ section("",
"the arc is finished; what follows answers questions"))
# ===========================================================================
# ANNEX — the measurement detail behind slides 2425, and the rejected routes
# ANNEX — the measurement detail behind slides 2627, and the rejected routes
# ===========================================================================
# ---- A1 · THE CONVENTION -------------------------------------------------
s = new_slide()
annex_chrome(s, 1, "measurement convention · expands slide 18",
annex_chrome(s, 1, "measurement convention · expands slide 20",
"Uncontended, or as the pipeline runs it")
bullets(s, M, 1.90, 12.0, [
"Every engine time in this deck is tagged **[build · s1|s4]**. **s1** is one "
@@ -2142,7 +2273,7 @@ rail(s, [
# Was a main-arc slide. It answers "did you try just making the copy faster?",
# which is a question, not a step in the argument, so it belongs here.
s = new_slide()
annex_chrome(s, 2, "rejected routes · the result copy · expands slide 18",
annex_chrome(s, 2, "rejected routes · the result copy · expands slide 20",
"The copy is allocation-bound, not bandwidth-bound", part=3, nparts=3)
rows = [
("B\u2032", "One allocation per chunk", "collect_packed()",
@@ -2180,7 +2311,7 @@ callout(s, M, 6.38, 11.9,
# ---- A4 · THE OPT5 CODE --------------------------------------------------
s = new_slide()
annex_chrome(s, 3, "opt5 · the overlap code · expands slide 17",
annex_chrome(s, 3, "opt5 · the overlap code · expands slide 19",
"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)",
@@ -2225,7 +2356,7 @@ caption(s, M, 6.80, 12.0,
# ---- A5 · THE FAULT MODEL ------------------------------------------------
s = new_slide()
annex_chrome(s, 4, "the fault model · expands slide 21",
annex_chrome(s, 4, "the fault model · expands slide 23",
"The fault model, tested against every step")
bullets(s, M, 1.90, 12.0, [
"Slide 26 fits **0.68 µs per first-touch fault** on the **3×3 f32** ladder, where "
@@ -2253,17 +2384,17 @@ caption(s, M, 6.52, 12.0,
"ladder_9x9.csv in results/2026-08-20_{f64,f32}_cap1700/, warm = best of reps "
"14, faults are that rep's own getrusage minor-fault count. Predicted = Δfaults "
"× 0.68 µs ÷ 20 000 frames, with 0.68 carried in unchanged from the 3×3 fit "
"(slide 25): nothing on this slide is tuned to make the columns agree. Observed "
"(slide 27): nothing on this slide is tuned to make the columns agree. Observed "
"vs predicted: +13.22 / +13.37, 4.63 / 0.02, 4.54 / 4.81, 4.87 / 0.00. "
"This table replaces an earlier figure that quoted opt3's +16 % as a measurement "
"of the result path; it is a measurement of two allocator states.")
# ---- A5 · THE VARIANCE REWRITE IN FULL -----------------------------------
# Was main-arc slide 21, plus the error-floor panel that used to share
# fig_cancellation. Both are the quantitative backing for slide 20's third
# Was main-arc slide 23, plus the error-floor panel that used to share
# fig_cancellation. Both are the quantitative backing for slide 22's third
# bullet, and neither is needed to follow the argument.
s = new_slide()
annex_chrome(s, 5, "the variance rewrite · expands slide 20",
annex_chrome(s, 5, "the variance rewrite · expands slide 22",
"The rewrite in full, and which pixels the error reached")
bullets(s, M, 1.90, 7.4, [
"Freeze a per-pixel baseline **X₀ = round(mean)** once, at the end of pedestal "
@@ -2299,10 +2430,10 @@ caption(s, 8.30, 2.00 + h + 0.18, 4.32,
size=9)
# ---- A6·1 · THE THREE ARTEFACTS ------------------------------------------
# Main-arc slide 25 keeps only the first of these three, because it is the only
# Main-arc slide 27 keeps only the first of these three, because it is the only
# one that moves a number the audience is shown. This is that slide as it stood.
s = new_slide()
annex_chrome(s, 6, "benchmark artefacts · expands slide 25",
annex_chrome(s, 6, "benchmark artefacts · expands slide 27",
"Three ways a GPU benchmark lies", part=1, nparts=4)
items = [
("First-touch page faults", AMBER,
@@ -2341,7 +2472,7 @@ callout(s, M, 6.68, 11.9,
# ---- A6 · FAULTS ---------------------------------------------------------
s = new_slide()
annex_chrome(s, 6, "benchmark artefacts · expands slide 25",
annex_chrome(s, 6, "benchmark artefacts · expands slide 27",
"First-touch page faults: two sources, one counter", part=2, nparts=4)
bullets(s, M, 1.90, 12.0, [
"A page exists in the process's address space but has no physical frame yet. "
@@ -2407,7 +2538,7 @@ rail(s, [
("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 floor, "
"subject to the sustained-rate rule on slide 18. Full grid: A1."),
"subject to the sustained-rate rule on slide 20. Full grid: A1."),
])
# ---- A8 · NSYS -----------------------------------------------------------
+92 -12
View File
@@ -1,4 +1,4 @@
"""Figures for docs/cf_cuda_fused.pptx — deck palette, dark.
"""Figures for docs/cf_cuda_performance.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
@@ -122,7 +122,7 @@ def _placements():
actually use. Where a figure appears twice, the narrowest placement wins,
since that is the one that sets the smallest text.
"""
deck = (Path(__file__).resolve().parent / "build_fused_deck.py")
deck = (Path(__file__).resolve().parent / "build_performance_deck.py")
if not deck.exists():
return {}
env = {"M": 0.7, "COL": 7.9, "RAIL_W": 3.5, "RAIL_X": 9.2,
@@ -149,7 +149,7 @@ def save(fig, name, place_w=None):
`place_w` is the width the deck places this figure at. Passing it turns the
check on; the figure list at the bottom of this module keeps it in sync with
build_fused_deck.py, and tools/audit prints both sides.
build_performance_deck.py, and tools/audit prints both sides.
"""
path = OUT / f"{name}.png"
texts = [(t.get_text(), t.get_fontsize())
@@ -739,27 +739,27 @@ def fig_resultpath():
for ax, title, floor, copy_us, gain, verdict, col in [
(a1, "3×3 · host copy 93 kB / frame", 16.17, 8.0, "×1.16",
"copy hides under the GPU\n→ small win", ACCENT),
(a2, "9×9 · host copy 467 kB / frame", 30.01, 40.0, "×2.21",
(a2, "9×9 · host copy 467 kB / frame", 30.01, 62.0, "×2.21",
"copy is larger than the GPU\n→ cannot hide at any overlap", AMBER),
]:
ax.bar([0], [floor], width=0.5, color=col, 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=10, ha="left")
ax.text(0, floor + 1.4, f"{floor:.1f} µs", ha="center", color=PALE,
ax.text(-0.55, floor + 1.8, "GPU floor", color=GREEN, fontsize=10, ha="left")
ax.text(0, floor + 2.1, 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,
ax.text(1, copy_us + 2.1, 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=10)
ax.set_xlim(-0.6, 1.7); ax.set_ylim(0, 52); ax.set_yticks([])
ax.set_xlim(-0.6, 1.7); ax.set_ylim(0, 78); 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=10, ha="right")
ax.text(-0.55, -11, verdict, color=col, fontsize=10, fontweight="bold",
ax.text(1.68, 75, gain, color=col, fontsize=16, fontweight="bold", ha="right")
ax.text(1.68, 68.5, "opt5 → opt6", color=MUTED, fontsize=10, ha="right")
ax.text(-0.55, -16, verdict, color=col, fontsize=10, fontweight="bold",
va="top")
fig.subplots_adjust(bottom=0.30)
save(fig, "fig_resultpath")
@@ -1075,7 +1075,7 @@ def fig_overlap():
ax.text(-0.25, y + lane_h / 2, lbl, ha="right", va="center",
color=TEXT2, fontsize=10.5)
ax.text(-0.25, yG + lane_h + 0.30, "submit → collect, serialized (opt4)",
ax.text(-0.25, yG + lane_h + 0.30, "submit → collect, serialized (opt4) · 3×3",
ha="left", va="bottom", color=TEXT2, fontsize=10.5, fontweight="bold")
ax.text(-0.25, yG2 + lane_h + 0.30,
"submit(i+1) before collect(i) (opt5)",
@@ -1103,6 +1103,85 @@ def fig_overlap():
save(fig, "fig_overlap")
# ----------------------------- 12b. the 9x9 case: overlap runs out (opt5->opt6)
def fig_overlap_9x9():
"""Why opt5 stops at 9x9, and why a deeper buffer cannot restart it.
Same pipelined loop as fig_overlap, but with the measured 9x9 proportions:
the GPU delivers a chunk every 30.01 us and the host needs ~62 (the
fault-corrected steady-state term; see the slide's caption). The host lane is
therefore the packed one, and it alone sets the finish line.
The second strip is the answer to "add more slots". With three, the GPU front-
loads instead of stalling between chunks 2 and 3 -- but the host lane is
IDENTICAL in both strips, because it is already saturated, so both finish at
exactly the same time. A deeper buffer relocates GPU idle; it does not remove
it, and it cannot speed up the stage that is binding.
"""
fig, ax = plt.subplots(figsize=(11.2, 3.5))
G, H = 30.0, 62.0
lane_h = 9.0
n = 4
def block(x, y, w, col, txt):
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=BG, fontsize=10, fontweight="bold", zorder=4)
# The host is saturated in both cases, so its lane is the same schedule twice:
# chunk i is collected as soon as the host is free, never before G.
host_start = [G + i * H for i in range(n)]
finish = host_start[-1] + H
# 2 slots: the GPU may only run one chunk ahead, so it waits for a slot to free
gpu2, free_at = [], [0.0, 0.0]
t = 0.0
for i in range(n):
t = max(t, free_at[i % 2])
gpu2.append(t); t += G
free_at[i % 2] = host_start[i] + H # slot returns when the host is done
# 3 slots: one more chunk of runway before the same wall
gpu3, free_at3 = [], [0.0, 0.0, 0.0]
t = 0.0
for i in range(n):
t = max(t, free_at3[i % 3])
gpu3.append(t); t += G
free_at3[i % 3] = host_start[i] + H
for row, (gpu, tag, col) in enumerate([
(gpu2, "2 slots · what ships", AMBER),
(gpu3, "3 slots · the natural next guess", MUTED)]):
yG = 49.0 - row * 33.0
yH = yG - 12.0
for i in range(n):
block(gpu[i], yG, G, ACCENT, f"GPU {i + 1}")
block(host_start[i], yH, H, PALE, f"host {i + 1}")
for lbl, y in (("GPU", yG), ("host", yH)):
ax.text(-6, y + lane_h / 2, lbl, ha="right", va="center",
color=TEXT2, fontsize=10.5)
ax.text(-6, yG + lane_h + 7.0, tag, ha="left", va="bottom",
color=col, fontsize=10.5, fontweight="bold")
# every gap the GPU sits through, marked where it happens
for i in range(1, n):
gap = gpu[i] - (gpu[i - 1] + G)
if gap > 4.0: # a 2 us seam is not an argument, only a real stall is
ax.annotate("", xy=(gpu[i], yG + lane_h / 2),
xytext=(gpu[i - 1] + G, yG + lane_h / 2),
arrowprops=dict(arrowstyle="<|-|>", color=AMBER, lw=1.3))
ax.text((gpu[i] + gpu[i - 1] + G) / 2, yG + lane_h + 1.0,
f"idle {gap:.0f}", ha="center", va="bottom",
color=AMBER, fontsize=9.5, fontweight="bold")
ax.plot([finish, finish], [2, 61], color=GREEN, lw=1.6, ls="--", zorder=6)
ax.text(finish + 5, 32, "same finish\nboth ways", ha="left", va="center",
color=GREEN, fontsize=11, fontweight="bold")
ax.set_xlim(-32, finish + 62)
ax.set_ylim(0, 70)
ax.axis("off")
save(fig, "fig_overlap_9x9")
# ------------------------------------- 13. pedestal update timing, three ways
def fig_pedtiming():
"""Why ClusterFinderFrozen exists: the one variable it holds still.
@@ -1176,6 +1255,7 @@ def fig_pedtiming():
fig_overlap()
fig_overlap_9x9()
fig_pedtiming()
+1 -1
View File
@@ -6,7 +6,7 @@ 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.
build_performance_deck.py.
"""
import sys
import matplotlib
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@ Reads the *compiled* extension — no rebuild and no source parsing:
cuobjdump -res-usage <lib.so>
then applies the sm_89 occupancy arithmetic. Reproduces the figures on slides 7
and 8 of docs/cf_cuda_fused.pptx, and cross-checks against
and 8 of docs/cf_cuda_performance.pptx, and cross-checks against
cudaOccupancyMaxActiveBlocksPerMultiprocessor (same blocks/SM).
python kernel_resources.py # shipping build, 16x16 blocks