joeylib2/PERF.md

17 KiB

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.

2026-07-06, NATIVE-PERF Phase 0: four WORKLOAD rows were added (see NATIVE-PERF-PLAN.md) and the goldens refrozen at 49 lines after a clean regression gate (45 match + exactly 4 extras per port) and full 4-port cross-identity. The new rows measure what games actually do, and they expose the real problem the per-op rows hid: jlSpriteDraw unaligned runs at 5% (ST) / 9% (Amiga) of the IIgs floor -- a 15x/12x cliff vs the aligned row, because only x%8==0 draws are compiled on the 68k ports (see the plan's code-truth table). The gameFrame composite row is the bottom line: ST 9 / Amiga 18 / IIgs 10 / DOS 18 frames/sec for a modest game frame. Bonus finding: cross-identity of the sweep16 row validated the ST/Amiga shifted c2p walkers as pixel-perfect at all 14 previously-untested x phases. (Re-capture note: IIgs input/audio rows wobbled vs the prior MAME run -- e.g. jlAudioFrameTick 50223 -> 27612 -- run-to-run MAME variance on cheap ops; hashes are unaffected and those rows gate nothing.)

2026-07-06, NATIVE-PERF Phase 1 (draw side) LANDED: all-shift compiled sprite DRAW on both 68k ports. Amiga emits pre-shifted JIT variants for all 8 bit phases; the ST emits per-phase thunk + data tables driven by a shared masked-movem walker (spriteWalk.s) for all 16 intra-group phases, with the dispatcher's ST shift contract changed to x & 15. jlSpriteCompile is now plan-based: it measures every (shift, op) variant, greedily fits them under the uint16 offset cap (draws first, save/restore as atomic pairs), and degrades to the byte-aligned phases when the arena is too small instead of losing the sprite. RESULT -- the alignment cliff is CLOSED: jlSpriteDraw unaligned ST 40 -> 500 ops/sec (12.5x), Amiga 75 -> 803 (10.7x); sweep16 ST 2 -> 32 sweeps/sec (512 draws/sec), Amiga 5 -> 50 (800 draws/sec). Unaligned now performs like aligned on both ports (ST 82% / Amiga 84% of their aligned rows), and every one of the 49 golden hashes still matches -- the compiled paths are bit-identical to the interpreted walkers they replace, on all four ports. (Found along the way: the Amiga bench task stack was ~4 KB -- one deep call chain from overflow -- now 32 KB in bench-amiga.sh; the emitters' accumulators are file statics.)

Phase 1 step 5 (save/restore) ALSO LANDED, completing Phase 1: compiled save/restore on both 68k ports operate on the 16-px-group window (two width classes per sprite, like the IIgs MVN pair), the backup format is a pure function of the recorded geometry (identical for compiled and interpreted producers/consumers -- the ST HAL gained a raw-span interpreted branch, switched atomically with restore), and the gates route by SPRITE_SAVE_CLASS / SPRITE_RESTORE_CLASS with an odd- buffer-pointer demotion (68000 word ops need even addresses). The combined jlSpriteSaveAndDraw path now fires on 68k at EVERY x for the first time. RESULTS (floor-relative): Amiga SaveUnder 104% -> 276%, RestoreUnder 98% -> 236%, SaveAndDraw 94% -> 160%; ST SaveUnder 158% -> 203%, RestoreUnder 122% -> 163%, SaveAndDraw 87% -> 107%; Amiga gameFrame 18 -> 23 fps. Backup capacity contract: JOEY_SPRITE_BACKUP_BYTES in sprite.h (window worst case; all in-repo examples migrated + 2-aligned). DEFAULT_CODEGEN_BYTES is 64 KB on Amiga/ST (full variant sets). All 49 golden hashes held throughout.

2026-07-07, THE ST COLUMN DOUBLED -- idle-audio ISR tax found and fixed: every previous ST capture ran with ~49.5% of the CPU silently consumed by the 12.3 kHz Timer-A PWM ISR that jlpAudioInit started unconditionally -- it streamed buffer silence to the YM even with nothing playing, and UBER inits audio before its timed run. Found by a calibrated probe (known-cycle dbra spin + per-layer direct-call rows under the bench Hatari config): every probe row slowed by exactly 1.98x once audio was initialized, and the probe's un-taxed tilePaste cycles/call matched the instruction-count model that UBER's number mysteriously doubled. Fix: src/atarist/audio.c Timer A is now demand-driven -- installed on the first PlayMod/PlaySfx, parked again once the mix buffer drains back to silence with nothing live (drain-to-silence handshake in jlpAudioFrameTick; StopMod parks immediately when no SFX is riding the pump). Probe-verified: idle rows match the never-initialized rows exactly, the tax correctly returns while an SFX drains, and it vanishes after the drain. Full ST re-bench: 49/49 hashes bit-identical (pixels untouched) and EVERY row ~2x: gameFrame 9 -> 19 fps, tilePaste 1,574 -> 3,203/s, sprite draw 607 -> 1,188/s, unaligned draw 508 -> 992/s. The other three ports were audited for the same bug class and are clean: Amiga ~1.8% idle tax (PTPlayer rides Paula DMA), DOS ~0.5% (SB auto-init DMA, ~11 Hz half-buffer IRQ), IIgs 0% (no handler installed until PlayMod). ALL pre-2026-07-07 ST comparisons -- including the Phase-0 ceiling gaps and the Phase-1 relative results -- understated the ST by ~2x.

Correctness statement backing this table: all four ports produce BYTE-IDENTICAL pixels on every measured op and every correctness check -- 49/49 surface hashes match the frozen goldens in tests/goldens/uber/ (37 timed ops + 12 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)

Four measurement artifacts have shaped this table's history. Three 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.
  4. ST idle-audio ISR tax -- FOUND and FIXED 2026-07-07; the ST column below is the post-fix capture. jlpAudioInit started the 12.3 kHz Timer-A PWM ISR at init and left it running forever, even silent (~320 cycles/tick = ~49.5% of the CPU), and UBER inits audio before timing anything. Every ST capture before 2026-07-07 is ~2x pessimistic. Timer A is demand-driven now (runs only while a mod or SFX is audible), so the benches measure the op and an idle game gets the whole CPU. Amiga/DOS/IIgs audited clean for the same bug class (~1.8% / ~0.5% / 0% idle cost).

Baseline (2026-07-07): 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). Capture provenance: ST 2026-07-07 (post-audio-fix); IIgs/Amiga/DOS 2026-07-06 (recovery-gate rerun, unaffected by the ST fix).

Op IIgs (ops/sec) Amiga (ops/sec, % of IIGS) Atari ST (ops/sec, % of IIGS) DOS (ops/sec, % of IIGS)
jlSurfaceClear 33 75 (227%) 89 (270%) 67 (203%)
jlPaletteSet 1561 11453 (734%) 10255 (657%) 29828 (1911%)
jlScbSetRange 163 4003 (2456%) 4744 (2910%) 8491 (5209%)
jlDrawPixel 3179 5853 (184%) 6645 (209%) 23689 (745%)
jlDrawLine H 1142 1703 (149%) 2660 (233%) 5708 (500%)
jlDrawLine V 99 269 (272%) 265 (268%) 456 (461%)
jlDrawLine diag 42 40 (95%) 86 (205%) 108 (257%)
jlDrawRect 100x100 89 203 (228%) 216 (243%) 378 (425%)
jlDrawCircle r=16 303 285 (94%) 359 (118%) 461 (152%)
jlDrawCircle r=80 70 61 (87%) 83 (119%) 98 (140%)
jlFillRect 16x16 605 1153 (191%) 1211 (200%) 3017 (499%)
jlFillRect 80x80 110 269 (245%) 236 (215%) 412 (375%)
jlFillRect 320x200 21 66 (314%) 78 (371%) 59 (281%)
jlFillCircle r=40 42 31 (74%) 155 (369%) 156 (371%)
jlSamplePixel 4815 7253 (151%) 9603 (199%) 46266 (961%)
jlTileFill 1981 2253 (114%) 3144 (159%) 9301 (470%)
jlTileCopy 1441 1853 (129%) 2720 (189%) 8764 (608%)
jlTileCopyMasked 623 953 (153%) 1366 (219%) 3147 (505%)
jlTilePaste 1741 2253 (129%) 3203 (184%) 9834 (565%)
jlTileSnap 2520 3503 (139%) 5751 (228%) 19290 (765%)
jlSpriteSaveUnder 545 1503 (276%) 2196 (403%) 9248 (1697%)
jlSpriteDraw 902 953 (106%) 1188 (132%) 6234 (691%)
jlSpriteRestoreUnder 510 1203 (236%) 1662 (326%) 5925 (1162%)
jlSpriteSaveAndDraw 376 603 (160%) 803 (214%) 4271 (1136%)
jlStagePresent full 67 359 (536%) 125 (187%) 360 (537%)
jlInputPoll 228 4003 (1756%) 2788 (1223%) 3148 (1381%)
jlKeyDown 10191 39303 (386%) 45286 (444%) 77548 (761%)
jlKeyPressed 11389 38053 (334%) 45286 (398%) 75092 (659%)
jlMouseX 20214 96703 (478%) 117981 (584%) 155372 (769%)
joeyJoyConnected 10191 39303 (386%) 43440 (426%) 77548 (761%)
jlAudioFrameTick 27509 55853 (203%) 40637 (148%) 11527 (42%)
jlAudioIsPlayingMod 17627 56453 (320%) 60441 (343%) 127708 (725%)
surfaceMarkDirtyRect (via jlFillRect 32x32) 323 853 (264%) 774 (240%) 1472 (456%)
jlSpriteDraw unaligned 845 803 (95%) 992 (117%) 5118 (606%)
jlSpriteDraw sweep16 56 50 (89%) 64 (114%) 363 (648%)
jlTilePaste map 10x5 35 46 (131%) 62 (177%) 200 (571%)
gameFrame composite 10 23 (230%) 19 (190%) 18 (180%)

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 COMPILED code on all four ports since NATIVE-PERF Phase 1 (2026-07-06): the 68k ports JIT all-shift draw variants plus group-window save/restore (previously stubs that fell to interpreters), IIgs codegen re-enabled via finding #77, DOS via finding #74. The interpreted walkers remain only as arena-full degrade paths -- and are proven bit-identical to the compiled code by the 49-hash gate.
  • The below-floor cells are the standing punch list, and after the 2026-07-07 audio-tax fix the ST has NONE -- every remaining below-floor graphics cell is the Amiga's: the circle family (diag line 95%, circle r=16 94%, r=80 87%, fillCircle 74% -- planar 4-plane RMW tax) and the two sprite workload rows a hair under floor (unaligned 95%, sweep16 89%; chip-RAM display-DMA contention that the fast-RAM-code ST does not pay). DOS jlAudioFrameTick (42%) is the SB half-buffer silence refill on an otherwise-trivial call -- it gates nothing. Floor parity is effectively DONE; the open work is ceiling-relative (NATIVE-PERF-PLAN.md Phase 2+), where every port still runs 2-5x below hand-coded technique on tile/sprite glue. 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 old note here blaming a "4-5x model-vs-measured gap" partly on measurement was vindicated 2026-07-07: on the ST that gap was the idle-audio tax, artifact #4.)
  • 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).