mirror of
https://github.com/slsdetectorgroup/aare.git
synced 2026-09-02 23:20:43 +02:00
docs: detail opt2, op3 and opt5->opt6 explanaitons
This commit is contained in:
@@ -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 (30–96 k
|
||||
faults ≈ 0.2–0.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 61–64 µ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 (A1–A5); 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 (A1–A6), 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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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`, `15–16 → 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 61–64 µ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.
|
||||
|
||||
@@ -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 6–7 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 opt1→opt7 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 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–33 keep the numbers they have, so the annex's
|
||||
cross-references ("expands slide 25") stay true. What it lights up instead
|
||||
progress track: slides 3–35 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.",
|
||||
[("22–23", "Results, both cluster sizes"),
|
||||
("24", "Where the time went"),
|
||||
("25–26", "What the numbers survived")],
|
||||
rng=(22, 26), col=PALE,
|
||||
[("24–25", "Results, both cluster sizes"),
|
||||
("26", "Where the time went"),
|
||||
("27–28", "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 27–30).", h=0.8, color=AMBER)
|
||||
"(slides 29–32).", 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 "
|
||||
@@ -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.",
|
||||
[("27–28", "The fair comparison"),
|
||||
("29–30", "The residual, dissected"),
|
||||
("31–32", "For users"),
|
||||
("33", "What is next")],
|
||||
rng=(27, 33), col=PALE,
|
||||
[("29–30", "The fair comparison"),
|
||||
("31–32", "The residual, dissected"),
|
||||
("33–34", "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 24–25, and the rejected routes
|
||||
# ANNEX — the measurement detail behind slides 26–27, 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 "
|
||||
"1–4, 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
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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 |
+1057
-1084
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user