joeylib2/PERF.md

200 lines
12 KiB
Markdown

# JoeyLib cross-port performance (UBER benchmark)
Regenerated 2026-07-05 (PERF-AUDIT-PLAN.md Phase 8, #25b) from
same-day captures of all four ports. This supersedes the 2026-07-04
Phase 0 baseline; every phase of the audit fix plan (0-8) has landed,
so these are the numbers the library actually ships with.
**2026-07-06 update:** the IIgs sprite column is now COMPILED. Finding
#77 (the loader/segment-BSS + malloc-heap reservation that forced the
codegen arena into the app's own BSS) was fixed toolchain-side and
JOEY_IIGS_SPRITE_CODEGEN was re-enabled; the four IIgs sprite rows were
re-captured (bench-iigs.sh) and jumped 5-25x (SaveUnder 104->543,
Draw 48->907, RestoreUnder 110->568, SaveAndDraw 15->376 ops/sec),
bit-identical to the interpreter goldens (diff-uber-hashes 45/45). The
ST and DOS columns are unchanged from the 2026-07-05 capture.
Also 2026-07-06: the Amiga `jlpFillRect` partial-width path was moved
onto the blitter (BLTAFWM/BLTALWM hardware edge masks + masked minterm),
which lifted `jlFillRect 16x16` 567->1153, `80x80` 71->269 (both from
below-floor to well above it), and -- because they build on fillRect --
`jlDrawLine V` 144->269 and `jlDrawRect 100x100` 122->203, all
hash-identical (Amiga 45/45). The one remaining Amiga below-floor cell
is `jlFillCircle r=40` (74%); a whole-circle asm routine was tried and
did NOT help (per-span dispatch is not the cost -- the 8-way-symmetry
overlap fill dominates), so it stays on the C span walker for now.
Correctness statement backing this table: all four ports produce
BYTE-IDENTICAL pixels on every measured op and every correctness
check -- 45/45 surface hashes match the frozen goldens in
tests/goldens/uber/ (34 timed ops + 11 hashed checks; the PASS/FAIL
checks -- palette round-trip, PRNG golden sequence, sprite clip
round-trip, arena churn, sprite-from-surface -- pass everywhere).
The Phase 0-era divergences (#75 tilePasteMono three-way, #78 ST
fillCircle/tileFill, #87 IIgs circle outline) are all fixed.
## Measurement honesty (read before trusting any number)
Three clock artifacts have shaped this table's history. Two are fixed,
one is inherent -- know them before comparing rows:
1. **Per-iteration poll tax -- FIXED (Phase 0).** The pre-audit bench
loop called jlFrameCount() between every op; on the IIgs that was a
GetTick toolbox chain (~600-800 cycles), so every small-op number
carried the harness cost, not the op cost. Ops now run in adaptive
batches per poll. Small-op numbers jumped 2-10x on every port when
this landed; never compare against pre-Phase-0 captures.
2. **SEI tick loss -- INHERENT on IIgs, rows tagged below.** The
benchmark clock is jlFrameCount = the VBL-interrupt-driven GetTick.
An op that holds SEI while it runs suppresses those interrupts, the
clock loses ticks, and the op over-reports. The three SEI-holding
rows -- `jlSurfaceClear`, `jlFillRect 320x200`, and
`jlStagePresent full` -- are CLOCK-UNRELIABLE in the IIgs column.
The physics check: a full-screen op moves 32000 bytes at ~6
cyc/word, so ~29 ops/sec is the hard ceiling at 2.8 MHz. The
present row's 68 and the clear row's 35 sit above it -- inflated;
the honest values are at or below the ceiling.
3. **ST frame-rate miscalibration -- FOUND and FIXED 2026-07-06; the
ST column below is the corrected (millis-clock) capture.** Before the
fix, ops/sec was `iters * jlFrameHz() / actualFrames` and the ST's
`jlpFrameHz()` hardcoded 50 -- but Hatari runs the emulated ST at
~60 Hz VBL, so every ST number was scaled by 50/60 ~= 0.83 (~17%
pessimistic). Caught by cross-checking against UBER's independent
Timer-C 200 Hz millis clock (refresh-independent), which ran ~16-22%
faster; the Amiga's two clocks agreed exactly (FS-UAE = PAL 50 Hz),
confirming it was ST-only. Two fixes landed: (a) UBER now computes
ops/sec from `jlMillisElapsed` (refresh-independent on all four
ports; identical to the old formula on IIgs where millis is
GetTick-derived), and (b) ST `jlpFrameHz()` now reads the shifter
resolution + sync-mode registers to return the true 50/60/71 Hz. The
per-op `UBER-CLK:` log line reports frame-vs-millis as a standing
cross-check (they now agree). ST cells rose ~17% (e.g. jlSpriteDraw
503->585, jlFillRect 80x80 97->115); DOS/IIgs/Amiga unchanged.
## Baseline (2026-07-05): absolute ops/sec per port
The IIgs is the reference and the perf floor (every other port must
meet or beat it -- project directive). Non-IIgs cells show absolute
ops/sec plus percentage of the IIgs number; sub-100% cells are bolded
as below-floor. `tools/uber-perf-table` emits this table directly;
regenerate after any re-capture (Amiga needs TIMEOUT=600 -- the
default cuts off the final checks).
| Op | IIgs (ops/sec) | Amiga (ops/sec, % of IIGS) | Atari ST (ops/sec, % of IIGS) | DOS (ops/sec, % of IIGS) |
| --- | --- | --- | --- | --- |
| jlSurfaceClear | 35 | 75 (214%) | 44 (126%) | 70 (200%) |
| jlPaletteSet | 1623 | 11503 (709%) | 5256 (324%) | 29474 (1816%) |
| jlScbSetRange | 163 | 3953 (2425%) | 2418 (1483%) | 8474 (5199%) |
| jlDrawPixel | 3423 | 5853 (171%) | **3322 (97%)** | 23524 (687%) |
| jlDrawLine H | 1143 | 1703 (149%) | 1341 (117%) | 5674 (496%) |
| jlDrawLine V | 99 | 269 (272%) | 120 (121%) | 494 (499%) |
| jlDrawLine diag | 42 | **40 (95%)** | 42 (100%) | 110 (262%) |
| jlDrawRect 100x100 | 90 | 203 (226%) | 100 (111%) | 399 (443%) |
| jlDrawCircle r=16 | 306 | **285 (93%)** | **178 (58%)** | 424 (139%) |
| jlDrawCircle r=80 | 70 | **63 (90%)** | **40 (57%)** | 96 (137%) |
| jlFillRect 16x16 | 603 | 1153 (191%) | **585 (97%)** | 2874 (477%) |
| jlFillRect 80x80 | 110 | 269 (245%) | 115 (105%) | 380 (345%) |
| jlFillRect 320x200 | 21 | 66 (314%) | 38 (181%) | 56 (267%) |
| jlFillCircle r=40 | 42 | **31 (74%)** | 75 (179%) | 153 (364%) |
| jlSamplePixel | 5223 | 7253 (139%) | **4862 (93%)** | 44244 (847%) |
| jlTileFill | 2043 | 2303 (113%) | **1516 (74%)** | 8894 (435%) |
| jlTileCopy | 1443 | 1853 (128%) | **1307 (91%)** | 8404 (582%) |
| jlTileCopyMasked | 624 | 953 (153%) | 677 (108%) | 3084 (494%) |
| jlTilePaste | 1803 | 2303 (128%) | **1544 (86%)** | 9454 (524%) |
| jlTileSnap | 2643 | 3503 (133%) | 2907 (110%) | 18624 (705%) |
| jlSpriteSaveUnder | 543 | 567 (104%) | 833 (153%) | 8964 (1651%) |
| jlSpriteDraw | 907 | 953 (105%) | **585 (64%)** | 6094 (672%) |
| jlSpriteRestoreUnder | 568 | **553 (97%)** | 655 (115%) | 5744 (1011%) |
| jlSpriteSaveAndDraw | 376 | **332 (88%)** | **318 (85%)** | 4204 (1118%) |
| jlStagePresent full | 68 | 359 (528%) | **56 (82%)** | 354 (521%) |
| jlInputPoll | 243 | 4003 (1647%) | 1425 (586%) | 3084 (1269%) |
| jlKeyDown | 12303 | 39753 (323%) | 22826 (186%) | 76514 (622%) |
| jlKeyPressed | 14043 | 38003 (271%) | 22826 (163%) | 74064 (527%) |
| jlMouseX | 30123 | 96703 (321%) | 59354 (197%) | 153864 (511%) |
| joeyJoyConnected | 12303 | 39303 (319%) | 22283 (181%) | 77144 (627%) |
| jlAudioFrameTick | 50223 | 56503 (113%) | **11724 (23%)** | 58664 (117%) |
| jlAudioIsPlayingMod | 13503 | 56403 (418%) | 29870 (221%) | 127334 (943%) |
| surfaceMarkDirtyRect (via jlFillRect 32x32) | 323 | 853 (264%) | 376 (116%) | 1474 (456%) |
> **IIgs full-screen ceiling (~29 ops/sec).** A full-screen op moves
> 32000 bytes = 16000 word-stores x 6 cyc (STA long,X / PEI, both
> 3 cyc/byte) / 2.8 MHz ~= 34 ms ~= 29 ops/sec maximum. So
> `jlSurfaceClear`, `jlFillRect 320x200`, and `jlStagePresent full`
> cannot legitimately exceed ~29; a higher reading means the benchmark
> clock was perturbed. The clock is `jlFrameCount` = the VBL-interrupt-
> driven GetTick, so an op that holds `SEI` while it runs can over-
> report its ops/sec. Treat full-screen IIgs numbers above ~29 as
> inflated and cross-check against the cycle model.
## Reading notes
* **Sprite rows measure different code per port.** DOS runs COMPILED
sprites (finding #74 fixed 2026-07-05). Amiga/ST run their
interpreted planar paths (compiled save/restore emitters are stubs
by design, Phase 4 #27). The IIgs now runs COMPILED sprites too
(finding #77 fixed 2026-07-06; JOEY_IIGS_SPRITE_CODEGEN back on) --
the IIgs sprite rows jumped 5-25x and the huge non-IIgs ratios on
these rows collapsed accordingly, which re-exposes a real 68k sprite
gap (see below).
* **The below-floor cells are the standing punch list** (ST column
corrected +17% by the 2026-07-06 clock fix, artifact #3 above -- many
ST cells crossed above the floor; what remains is genuinely below).
ST circles (57-58%) are the worst honest graphics gap -- pure-68000
constraint, no blitter allowed (STF baseline). The IIgs circle-outline
case is a known structural loss (3-register 65816 vs 16-register 68000
Bresenham; see feedback memory). With IIgs sprites now compiled, a
68k sprite gap re-opened: ST jlSpriteDraw 64%, ST jlSpriteSaveAndDraw
85%, ST jlTileFill 74%, and Amiga jlSpriteRestoreUnder 97% +
jlSpriteSaveAndDraw 88% remain below the IIgs floor (they were masked
while the IIgs interpreted; ST jlSpriteRestoreUnder is now 115%, above
floor, after the clock fix). The ST jlSpriteDraw case was investigated
2026-07-06 and is ARCHITECTURAL, the same root as the ST circle gap:
DRAW is already compiled (JIT, shifts 0/1), but the word-interleaved
planar layout forces `move.b #imm,(d16,An)` (~18 cyc) per plane byte,
which loses in wall-time to the IIgs chunky `sta` (~5 cyc @ 2.8 MHz) --
ST actually BEATS the IIgs on the block-copy SAVE (833 vs 543) and only
trails on the c2p+mask DRAW. The one real DRAW win (merge the two tile
columns into one `move.w` per plane word) exists only at shift 0; the
UBER benchmark draws at x=40 = shift 1, where the columns fall in
different interleaved groups, so it is byte-bound and the metric cannot
move. jlSpriteSaveAndDraw (85%) is save+draw and is dominated by that
same DRAW (SAVE is already fast). ST jlAudioFrameTick at 23% is the YM mixer
cost, tracked separately. Amiga jlFillCircle r=40 (74%) was probed
2026-07-06 with a full whole-circle asm routine (Bresenham + inline
4-plane long-fill span): it held 45/45 hashes but did NOT move the
number (34 vs 31, within the coarse 11-iter sample), so the per-span
C dispatch is NOT the bottleneck -- the asm was reverted. The real cost
is the midpoint fill's 8-way symmetry filling each row 2-4x (idempotent
overlap): the true lever is a scanline rewrite (one span per row, no
overlap), which would roughly halve the byte writes and stay
hash-identical. NB: this op is timed at batch=1 (~29 ms each) by the
50 Hz frame counter, a coarse measurement -- the same ~4-5x
model-vs-measured gap shows on ST sprite-draw, so part of these two
cells may be measurement, not silicon.
* **jlRandom has no timed row.** Phase 8 (#43) rewrote its xorshift32
update in 16-bit halves on the IIgs only (~2-3x fewer cycles per
call on the 65816; 32-bit shifts there are per-bit helper loops).
Output is bit-identical -- host-proven over the golden sequence and
a 50M-state sweep, and gated by UBER's random-golden check on all
four ports.
* **Input predicates stay functions** (Phase 8 #40 decision): the
post-clock-fix numbers (12K-77K ops/sec) put call overhead nowhere
near a real frame budget; macro forms in the public header were
rejected as not worth the API exposure.
* **IIgs libc memset/memcpy remain ~4x slow** (finding #79,
toolchain-owned): llvm-mos runtime byte loops vs ORCA's MVN. The
jlScbSetRange and jlPaletteSet IIgs rows carry that tax; library-side
mitigations landed (#16 unrolled word copy, #21), the libc fix is
flagged to the llvm816 session.
## History
Pre-2026-07-04 tables (ORCA-era IIgs column, artifact-inflated DOS
numbers, un-batched loops) live in git history only -- every
conclusion drawn from them was re-derived from Phase 0 onward. The
Phase 0 baseline (2026-07-04) and per-phase deltas are recorded in
PERF-AUDIT-PLAN.md's progress tracker; headline gains since Phase 0:
IIgs interpreted spriteDraw 18 -> 48 ops/sec (Phase 4), IIgs
tilePasteMono asm 3.35x (Phase 7 #3), ST flood plane hooks 5.6x
(Phase 7 #4), DOS paletteSet +92% / tileSnap +42% (Phases 2/5),
DOS compiled sprites 284 -> ~6100 ops/sec (#74).