joeylib2/NATIVE-PERF-PLAN.md

10 KiB

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).

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

  • 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 (68k all-shift emitters + save/restore emitters; constraints (a)-(e) in the Phase-0 item-2 section above)
  • Phase 2
  • Phase 3
  • Phase 4
  • Phase 5