joeylib2/NATIVE-PERF-PLAN.md

272 lines
16 KiB
Markdown

# NATIVE-PERF-PLAN: match hand-coded native, per port
Directive (Scott, 2026-07-06): floor parity against the IIgs is not the
bar anymore -- "still way too slow across the board." The new target is
**each port within ~1.5x of what hand-coded native technique achieves on
that hardware**, proven by cycle-math ceilings and workload benchmark
rows, not vibes. The IIgs-floor rule remains a secondary invariant
(never ship a 68k port slower than the 65816).
Why the current table lies about games: UBER measures best-case single
ops (aligned sprite x, one tile, one call). Real games pay (a) the
alignment cliff -- only aligned-x sprite draws are compiled; other x
falls to interpreters/per-pixel walkers 3-20x slower -- and (b) a
measured ~35-50% per-call wrapper tax (dispatch + validation +
dirty-mark) around cores that are already at silicon parity, and (c)
have no batch paths, so a tilemap repaint pays that tax per tile.
Frame-budget reality check (2026-07-06 table): at 25% CPU and 50/60 Hz,
~3 sprites/frame on ST/Amiga/IIgs; a 40x25 tilemap repaint costs
~0.44-0.64s on the 68k ports. Hand-coded games on these machines did
far better. Close that gap.
Ground rules (inherited from PERF-AUDIT-PLAN, all still binding):
- Source toolchains/env.sh before any make; delete stale binaries after
.s edits; Scott owns git; style rules apply.
- Every phase is hash-gated: existing goldens stay bit-identical
(diff-uber-hashes 45/45 x4) unless a phase EXPLICITLY adds rows, in
which case new goldens are frozen only after 4-port cross-identity.
- Every phase ends with a 4-port bench + PERF.md regeneration.
- Cycle models are hypotheses; the emulator measurement decides (lesson:
the fillCircle asm probe and the 17% ST clock bug).
## Phase 0 -- Ceilings + honest workload rows [items 1+2 DONE 2026-07-06]
### Item 1 RESULT -- native-technique ceilings (cycle-math, emulator-class)
Ops/sec, hand-coded best technique per port. "Meas" = current UBER
(2026-07-06, millis clock). Gap = ceiling / measured. Full derivations
with instruction-level arithmetic are in the Phase-0 workflow reports
(session scratchpad); re-derive on demand -- the arithmetic styles were
validated where models met measurements (ST present model 53/sec vs 56
measured; IIgs 29/sec present ceiling already in PERF.md).
| Op (16x16 sprite, 8x8 tile) | ST ceiling | meas | Amiga ceiling | meas | IIgs ceiling | meas | DOS ceiling | meas |
|---|---|---|---|---|---|---|---|---|
| Sprite draw, arbitrary x | ~2,235-2,750 (pre-shifted movem RMW) | 585 aligned / far less unaligned | ~3,600-4,300 (blitter cookie-cut; CPU-compiled ~1,350) | 903 aligned only | 4,300 aligned / 2,490 odd (compiled stores) | 907 | ~11,000 | 6,094 |
| Sprite save or restore | ~5,300 (move.l window) | 703/553 | ~6,300-7,400 (A->D blit) | 567/553 | ~2,520 (MVN) | 543/568 | ~14,500-18,000 | 8,964/5,744 |
| Tile paste 8x8 | ~12,700-15,500 | 1,574 | ~10,700 | 2,253 | ~5,830 | 1,803 | ~57,000 | 9,454 |
| 40-tile row repaint | 352-880 | -- | ~316 | -- | ~190 | -- | ~1,900 | -- |
| Present (native answer) | page flip ~free + dirty bands (full copy ~53/s) | 56 | page flip ~free (8 copper pokes; full blit ~110/s) | 362* | dirty bands (full PEI 20-29/s; real HW ~14-32) | 68* | dirty bands (real ISA full blast 10-31 fps!) | 354* |
*Present measurement artifacts found: Amiga/DOS measured presents
EXCEED their physical full-copy ceilings because UBER's timed present
loop never re-dirties the stage -- after the first copy it measures the
dirty-union idle early-out, not a copy. IIgs 68 is the known SEI
inflation. The gameFrame workload row (below) fixes this by dirtying
every call.
Headline: the wrapper tax is universal and dominates -- tile paste runs
3-6x (up to ~10x ST, ~90% overhead DOS) below hand-coded ceilings on
EVERY port with cores already at parity; IIgs C glue measured at
~800-4,000 cycles per call (jlDrawPixel = 820 cycles for a 1-byte op).
Sprite ceilings are 2.5-4x above current aligned numbers (ST via
movem long-RMW + all 16 phases; Amiga via the BLITTER, which also
frees the CPU -- its compiled-CPU path caps at ~1,350).
**ERRATUM 2026-07-07: every ST "meas" number above (and every ST
number anywhere dated before 2026-07-07) was captured with ~49.5% of
the CPU consumed by the idle 12.3 kHz Timer-A audio ISR that
jlpAudioInit started unconditionally (fixed in src/atarist/audio.c:
demand-driven pump; PERF.md artifact #4). Honest ST values are ~2x the
table above: tile paste 3,203/s (gap to ceiling 4.0-4.8x, not ~10x),
aligned sprite draw 1,188/s (gap 1.9-2.3x), gameFrame 19 fps. The
probe also measured the true ST layer split for tile paste: port op
~1,094 cyc + dirty mark ~804 + wrapper/call ~590 = 2,484 cyc/call,
within ~11% of instruction-count models (the remainder is ST 4-cycle
bus rounding) -- the cycle-model methodology is validated. Amiga
layer split from the same probe: full call ~3,120 cyc, port op alone
~1,719 (chip-RAM contention), steady-state mark ~970. Amiga/DOS/IIgs
audited for the same bug class: clean (~1.8% / ~0.5% / 0% idle).**
### Item 2 RESULT -- alignment-cliff code truth (supersedes all memory)
SPRITE_SHIFT_INDEX (spriteInternal.h:24-30): Amiga x&7, ST
(x&7)?2:((x>>3)&1), chunky (IIgs/DOS) x&1. Emitters:
| Port | DRAW compiled | SAVE/RESTORE compiled | Fallback at NOT_COMPILED |
|---|---|---|---|
| IIgs | shifts 0+1 = EVERY x | shifts 0+1 = every x | (n/a -- no cliff) |
| DOS | shifts 0+1 = EVERY x | shifts 0+1 = every x | (n/a -- no cliff) |
| ST | 0,1 only (x%8==0) | NEVER (stubs) | draw: byte-wide shifted c2p walker (hal.c:1815+); save/restore: asm byte copy when x%8==0 else per-pixel pair walker |
| Amiga | shift 0 ONLY (x%8==0) | NEVER (stubs) | draw: shifted c2p walker (hal.c:1491); save/restore: plane-major copy when x%8==0 else pair walker |
So the cliff is 68k-ONLY: 7/8 x positions run walkers on ST/Amiga, and
jlSpriteSaveAndDraw's combined fast path NEVER fires on 68k (SAVE never
compiled). IIgs/DOS need nothing in Phase 1.
Phase-1 design constraints discovered: (a) ST's shift-2 bucket cannot
hold compiled code -- ST needs SPRITE_SHIFT_INDEX changed to x&7 (a
dispatcher-contract change) plus emitter support; (b) routineOffsets
are uint16_t so one sprite's compiled bytes must stay < 64KB -- 8 DRAW
variants of a mixed 32x32 (~8-16KB each) blow it; needs selective
emission or offset-scheme change (16x16 sprites fit fine); (c) the
jlSpriteRestoreUnder gate accepts only copyBytes == bytesPerRow or +1
(spriteCompile.c:547-552) -- planar shifted saves record +4, so the
gate must widen or 7/8 alignments stay interpreted even after emitters
land; (d) stale doc: sprite.h:32-40 contradicts spriteInternal.h:16-23
(fix during Phase 1); (e) uber.c:972 comment claims Amiga has 8
variants -- aspirational, false today.
### Item 3 -- workload rows [IN PROGRESS]
Four rows appended AFTER the surfaceMarkDirtyRect timeOp (cumulative-
hash safety: correctness checks clear the stage first, so the frozen 45
stay bit-identical; Phase-7 precedent):
- "jlSpriteDraw unaligned" -- x=71,y=120 (odd AND x%8==7: worst case on
all four ports simultaneously).
- "jlSpriteDraw sweep16" -- all 16 x phases (208..223) inside ONE call
so the op is idempotent regardless of per-port iteration counts.
- "jlTilePaste map 10x5" -- 50 pastes of two SNAPPED patterned tiles
(tile pixels are platform-private; only snap round-trips portably).
- "gameFrame composite" -- 256x96 repaint + 16 HUD tiles + 3 sprite
saveAndDraw + 3 restores + present, net-idempotent per call; all
sprite x mod-16-aligned so this row isolates wrapper tax + present
(rows above own the alignment signal). Also fixes the present-row
artifact (dirties every call).
Golden contract goes 45 -> 49. Refreeze procedure: regression gate
first (each port: exactly 45 match + 4 extra vs OLD golden), then
cross-identity (49/49 IIgs vs each), only then freeze + regenerate
PERF.md.
Gate: ceilings documented (DONE above); new rows golden'd x4; existing
45 untouched.
## Phase 1 -- Kill the alignment cliff
All-shift compiled sprites everywhere. ST: emit shifts covering all
x%16 phases in spriteEmitInterleaved68k (pre-shifted plane bytes +
masks spanning byte halves/groups; the Amiga 8-variant planar emitter
is the proven template). IIgs/DOS: odd-x nibble-shifted DRAW variants
(chunky). Save/restore already widen via shift where applicable --
verify per port. Arena sizing: 8x DRAW variants (Amiga precedent:
8-32KB arena; re-check DEFAULT_CODEGEN_BYTES per port).
Gate: unaligned-sprite UBER rows reach ~parity with aligned rows on
every port; goldens hold.
## Phase 2 -- Strip the per-op tax
Plane-pointer caching (kill per-draw jlpSurfacePlanePtr x4), flattened
hot-op dispatch, inline dirty marks for the remaining hot ops (extend
the 2026-07-06 surfaceMarkDirtyRows inline that bought 2-5%), remove
redundant validation layers (core wrapper already proves s/bounds).
Gate: small-op rows (tileFill/Paste/Copy, sprite ops, fillRect small)
move measurably toward Phase-0 ceilings on all ports; goldens hold.
## Phase 3 -- Batch APIs
jlTileMapPaste (N tiles, one validation, one dirty union -- the
jlDrawText pattern generalized) + sprite batch draw/save/restore.
API shape needs Scott's sign-off before landing in public headers.
Gate: tilemap workload row approaches ceiling (per-tile overhead ~0);
goldens hold (new API rows get their own goldens).
## Phase 4 -- Port-native heavy weapons
Amiga: blitter cookie-cut sprite draw for larger-than-16x16 objects;
blitter tile/tilemap paste evaluation. ST: evaluate movem-based
row engines + restore-from-clean-buffer strategy for backgrounds.
IIgs: PEI-slam variants for batch paths where applicable.
Gate: per-op measurements vs ceilings; keep only what beats the
compiled-CPU path on the emulator (fillCircle lesson: no dead asm).
## Phase 5 -- Acceptance
Every workload row within ~1.5x of its Phase-0 ceiling per port, or a
documented cycle-proof that the remaining gap is structural (e.g. ST
interleaved-planar RMW). PERF.md gets a ceilings column. Update the
perf-directive memory with the outcome.
## Progress tracker
- [x] Phase 0 COMPLETE 2026-07-06. Ceilings + code-truth in this file
(above); four workload rows landed in uber.c and golden'd: regression
gate exactly 45 match + 4 extras on every port, cross-identity 49/49
x4 (bonus: sweep16 validated the ST/Amiga shifted c2p walkers pixel-
perfect at all 14 untested phases). Golden contract is now 49 lines.
BASELINE the later phases must move (ops/sec, millis clock):
jlSpriteDraw unaligned: ST 40 (5% of floor!) | Amiga 75 (9%) |
IIgs 842 | DOS 5115 -- vs aligned 607/903/902/6218 = the
15x/12x 68k cliff, Phase 1's gate.
jlSpriteDraw sweep16 (x16 = draws/sec): ST 32 | Amiga 80 |
IIgs 896 | DOS 5648 -- real arbitrary-x sprite rate.
jlTilePaste map 10x5 (x50 = tiles/sec): ST 1600 | Amiga 2300 |
IIgs 1750 | DOS 10000 -- Phase 2/3's gate (ceilings say
10-15k on 68k, 57k DOS).
gameFrame composite: ST 9 | Amiga 18 | IIgs 10 | DOS 18 fps --
the bottom-line number; the program ends when this is at/near
the port's refresh-limited ceiling or a documented structural
bound.
- [~] Phase 1 DRAW SIDE COMPLETE 2026-07-06 (steps 1-4 of the
adversary-reconciled order; save/restore = step 5 still open):
step 1 fused per-port shift ladder (ST 16/x&15, Amiga 8, chunky 2;
IIgs cache fields #ifdef'd) -- gated 49/49 x4 bit-identical;
step 2 plan-based jlSpriteCompile (measure-all, uint16-cap greedy,
draws tier 1 / save+restore atomic pairs tier 2, arena-full degrade
to SPRITE_DEGRADE_DRAW_MASK, 68k-only even-size guard); step 3 Amiga
all-shift JIT (accumulator merge, shift-0 byte-identical, host-
verified); step 4 ST 16-phase draw (spriteWalk.s masked-movem walker
+ per-phase 22B thunk + 12B/group/row pre-shifted tables). GATE:
49/49 x4; unaligned ST 40->500 (12.5x), Amiga 75->803 (10.7x);
sweep16 ST 2->32, Amiga 5->50; unaligned now ~82-84% of aligned on
both. BUGS FOUND BY THE GATES: even-size guard demoted odd-sized
IIgs/DOS routines to the interpreter (fixed: 68k-only); Amiga bench
task stack was the shell 4KB default -- the deeper compile path
overflowed it nondeterministically (fixed: stack 32768 in
bench-amiga.sh + emitter accumulators to file statics).
Steps 5+6 COMPLETE 2026-07-06 -- **PHASE 1 DONE**, final gate 49/49
x4. Step 5: group-window save/restore emitters both ports (2 width
classes/sprite, IIgs-MVN-style; ST raw-span move.l chains, Amiga
plane-run move.l/move.w), format-law gates (SPRITE_SAVE_CLASS /
SPRITE_RESTORE_CLASS / SPRITE_BACKUP_PTR_OK in spriteInternal.h;
restore derives class from RECORDED geometry, (bx&15)==0 term is
correctness-critical), ST HAL raw-span interpreted branch (save +
restore switched atomically incl. the 16x16 asm reroute), planar
metadata-probe widening, JOEY_SPRITE_BACKUP_BYTES capacity macro +
all 4 example owners migrated and 2-aligned. SaveAndDraw's combined
path fires on 68k at every x for the first time. Step 6:
DEFAULT_CODEGEN_BYTES 64KB Amiga/ST; doc sweep done. RESULTS
(floor-relative): Amiga Save 276% / Restore 236% / SaveAndDraw 160%,
ST 203% / 163% / 107%; Amiga gameFrame 18 -> 23 fps. Host-verified
emitted streams (objdump); every intermediate gate 49/49 x4.
- [x] INTERLUDE 2026-07-07 (pre-Phase-2, found by the Phase-2 V1
probe): ST idle-audio ISR tax discovered and fixed -- see the
Phase-0 ERRATUM above and PERF.md artifact #4. All four ports
re-gated 49/49 after a power-failure clean-rebuild recovery AND
after the audio fix (pixels untouched). The Phase-2+ gate baselines
are now (millis clock, post-fix): jlSpriteDraw unaligned ST 992 /
Amiga 803 / IIgs 845 / DOS 5118; sweep16 ST 64 / Amiga 50; jlTilePaste
single ST 3203 / Amiga 2253 / IIgs 1741 / DOS 9806; map 10x5 (x50 =
tiles/s) ST 3100 / Amiga 2300 / IIgs 1750 / DOS 10000; gameFrame
ST 19 / Amiga 23 / IIgs 10 / DOS 18 fps. Floor parity: the ST has
ZERO below-floor cells for the first time; remaining below-floor is
Amiga-only (circle family + unaligned 95% / sweep16 89%) + DOS
audioFrameTick (gates nothing).
- [~] Phase 2 IN PROGRESS 2026-07-07. Recon: 6-agent workflow +
merged synthesis (12 ranked items R1-R12) + calibrated-probe layer
split (see ERRATUM above). Step A LANDED (gate 49/49 x4):
plane-pointer inlining via new src/amiga/amigaPlanar.h +
src/atarist/stPlanar.h internal headers (jlpSurfacePlanePtr stanza
deleted -- its only callers were spriteCompile.c's 68k dispatchers),
dead-validation cleanup in both 68k HALs. Amiga sprite ops +10-23%
(SaveUnder 1503->1853), ST +3.5-5.2%; Amiga unaligned draw now ABOVE
floor (903 vs IIgs 845). Step B LANDED (gate 49/49 x4): whole-tile
dirty mark specialized in tile.c (band = [bx*2, bx*2+1] with
compile-time proof, 8-row widen unrolled ~480 cyc vs 804 measured;
IIgs keeps its asm marker). Tile rows: ST +9-15%, Amiga +13-18%,
DOS +24-29%, map row ST 72 / Amiga 53 / DOS 258; tileSnap (no mark)
flat everywhere = clean control. Step C LANDED (gate 49/49 x4):
jlDrawRect raw-edge fills + ONE mark (band-interval proof in
draw.c) -- ST +25% / Amiga +40% / DOS +22% on the drawRect row; the
x2 widen unroll in surfaceMarkDirtyRows is PER-PORT by measurement:
it lifted Amiga sprite rows +4-6% but cost the ST -5% in BOTH the
pointer-walking and counted forms (gcc-mint materializes 4 live
pointers either way; simple loop re-measured exactly at ST
baseline; DOS flat) -- so Amiga unrolls, everyone else keeps the
simple loop (documented at the #if in surface.c).
REMAINING: R4a 68k tile call-chain flatten + R8 Amiga unroll check;
Step D IIgs glue (R2/R5/R6/R10 fused asm entries -- biggest IIgs
upside, ~2x its tile rows; own session recommended); R9 palette/SCB
memcmp->flags on 68k/DOS presents (hygiene).
- [ ] Phase 3
- [ ] Phase 4
- [ ] Phase 5