perf(dsp): idle skip admits loops with a forward exit jr (#952) (#1117)

Cybermorph's DSP spends ~91% of its instructions in a two-bit register
poll (btst ; jr NE,out ; nop ; btst ; jr EQ,head) that idle skip
rejected only for its second branch. Admit a jr whose target lies past
the closing jr's delay slot, and back it with a taken-jr count: each
probe iteration must take exactly one jr, the closing one, which closes
the detour route the opcode-count check alone cannot see.

Cybermorph RISC 2x: p50 5.49 -> 2.34 ms, p99 14.34 -> 11.13 ms on an M4
Max; host instructions per frame -45%. Skip on/off byte-identical across
all 160 corpus carts; four titles newly skip. dsp_idle_probe_falsify
gains Cybermorph's shape (must fire and still exit) and an exit-detour
exploit (must not fire; fires with the check removed).

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Joe MattielloandClaude Opus 5.5 authored and GitHub committed 2026-10-28 19:00:12 -04:00
1 parent 615a5588d4
commit aaa9b5946c
2 files changed
+159 -10

No files matched your search

+52 -5
View File
@@ -1778,7 +1778,14 @@ void DSPSyncToM68K(void)
* Body = target .. jr inclusive, plus the inlined delay-slot
* instruction. Static decode (register-independent) requires:
* - every opcode on the whitelist below (no store of any kind, no
* accumulator/latency opcode, no second branch);
* accumulator/latency opcode, no `jump`), except that the body may
* contain EXIT branches: a `jr` before the loop-closing one whose
* target lies past the closing jr's delay slot (#952 -- Cybermorph
* polls two bits of a register with `btst ; jr NE,out ; nop ;
* btst ; jr EQ,head`). An exit that is never taken leaves the
* body straight-line; the probe proves it was not taken (see the
* EXIT-BRANCH CHECK in DSPIdleLoopProbe) and the fixed-point proof
* below then proves it never will be;
* - the decode walk lands exactly on the jr, so the body really is
* straight-line (movei carries a 32-bit immediate, i.e. 3 words);
* - no movei in the delay slot (its immediate would be fetched from
@@ -1826,6 +1833,9 @@ void DSPSyncToM68K(void)
* zero-delta registers stay put -- while each counter register is only
* ever incremented by a constant. So iteration k's state is
* S1 + (k-1)*delta for all k, and the branch condition never changes.
* The same holds at every point INSIDE the body, so an exit jr sees the
* same flags on every iteration: not taken from S1 to S2 (the probe
* checks that) means not taken on any extrapolated iteration either.
*
* Then n = (cycles_remaining / cost) - 1 (always leave one full
* iteration to execute normally), reg += n*delta, cycles -= n*cost,
@@ -1874,6 +1884,14 @@ uint32_t dsp_idle_skip_opcodes = 0; /* opcodes NOT interpreted -- the
* over a skip on purpose, so it does
* NOT show the saving. */
/* Every taken `jr`, counted inside dsp_opcode_jr's taken branch rather
* than in DSPExec's loop: a test there changed the loop's code generation
* and cost Iron Soldier 2 +0.7% host instructions per frame (#952). Here
* it measured +0.35% on Iron Soldier 2 (jr-heavy DSP, no idle skip to win
* back) and +0.09% on Doom. Only differences are read, so it never needs
* a reset. */
static uint32_t dsp_jr_taken;
static int idleProbeStage; /* 0 = none, 1 = have S0, 2 = have S1 */
static uint32_t idleProbeHead;
static uint32_t idleProbeJr;
@@ -1881,6 +1899,7 @@ static uint32_t *idleProbeBank; /* dsp_reg at S0 -- see theorem (3) */
static int32_t idleProbeCyc0, idleProbeCyc1;
static uint32_t idleProbeOpc0, idleProbeOpc1;
static uint32_t idleProbeS0[64], idleProbeS1[64];
static uint32_t idleProbeJrt0, idleProbeJrt1; /* dsp_jr_taken at S0/S1 */
static uint64_t idleProbeLdxFix; /* dsp_load_wb_fixups at S0 */
static uint64_t idleProbeDivFix; /* dsp_div_store_fixups at S0 */
static uint8_t idleProbeFz0, idleProbeFn0, idleProbeFc0;
@@ -1929,7 +1948,9 @@ static int dsp_idle_op_admitted(uint32_t idx)
* PC-modifying opcode but the loop-closing jr itself is load-bearing
* for the executed-path check below (idleBodyCount forces exactly
* one route from head to jrAddr), not only for the store-freedom
* this whitelist was originally written to guarantee. */
* this whitelist was originally written to guarantee. The one
* exception, a forward exit jr, is admitted by dsp_idle_decode
* itself and backed by the probe's EXIT-BRANCH CHECK. */
default:
return 0;
}
@@ -2004,6 +2025,7 @@ static int dsp_idle_operands(uint32_t idx, uint32_t p1, uint32_t p2,
src[(*nsrc)++] = cur + (int)p1;
*dst = cur + (int)p2;
return 1;
case 53: /* exit jr -- reads flags only */
case 57: /* nop */
return 1;
default:
@@ -2049,7 +2071,18 @@ static int dsp_idle_decode(uint32_t head, uint32_t jrAddr)
return 0;
op = DSP_IDLE_FETCH(pc);
idx = (uint32_t)(op >> 10);
if (!dsp_idle_op_admitted(idx))
if (idx == 53)
{
/* Exit branch: only a jr that leaves the loop entirely, past
* the closing jr's delay slot. Anything landing inside
* [head, jr+2] would make a second route around the loop. */
int32_t off = (int32_t)((op >> 5) & 0x1F);
if (off & 0x10)
off -= 0x20;
if (pc + 2 + (uint32_t)(off * 2) <= jrAddr + 2)
return 0;
}
else if (!dsp_idle_op_admitted(idx))
return 0;
idleBodyIdx[n] = (uint8_t)idx;
idleBodyP1[n] = (uint8_t)((op >> 5) & 0x1F);
@@ -2284,6 +2317,7 @@ static int32_t DSPIdleLoopProbe(int32_t cycles, uint32_t head, uint32_t jrAddr)
idleProbeFc0 = dsp_flag_c;
idleProbeCyc0 = cycles;
idleProbeOpc0 = dsp_exec_opcode_count;
idleProbeJrt0 = dsp_jr_taken;
idleProbeHead = head;
idleProbeJr = jrAddr;
idleProbeBank = dsp_reg;
@@ -2301,6 +2335,7 @@ static int32_t DSPIdleLoopProbe(int32_t cycles, uint32_t head, uint32_t jrAddr)
idleProbeFc1 = dsp_flag_c;
idleProbeCyc1 = cycles;
idleProbeOpc1 = dsp_exec_opcode_count;
idleProbeJrt1 = dsp_jr_taken;
idleProbeStage = 2;
return cycles;
}
@@ -2386,8 +2421,19 @@ static int32_t DSPIdleLoopProbe(int32_t cycles, uint32_t head, uint32_t jrAddr)
* array.) Any other route from one arrival to the next must execute
* the whole body AND at least one further instruction to get back,
* so it costs strictly more. Requiring exact equality therefore
* admits the straight-line traversal and nothing else. */
if (opcost != (uint32_t)idleBodyCount + 1)
* admits the straight-line traversal and nothing else.
*
* EXIT-BRANCH CHECK (#952). That argument needs a branch-free body.
* With an exit jr in it, a taken exit can leave, run undecoded code
* (a store) and come back through a `jump` to the closing jr with
* exactly the straight-line opcode count -- dsp_idle_probe_falsify
* program D is such a route. So also require that each probe
* iteration took exactly ONE jr, the closing one: a taken exit is a
* taken jr and would make it two. With every exit untaken, the body
* is straight-line again and the count argument above applies. */
if (opcost != (uint32_t)idleBodyCount + 1
|| idleProbeJrt1 - idleProbeJrt0 != 1
|| dsp_jr_taken - idleProbeJrt1 != 1)
{
dsp_idle_memo_reject(head, jrAddr);
return cycles;
@@ -2730,6 +2776,7 @@ INLINE static void dsp_opcode_jr(void)
int32_t delayed_pc = dsp_pc + (offset * 2);
uint16_t ds_opcode;
uint32_t ds_index;
dsp_jr_taken++; /* idle-skip exit-branch check (#952) */
/* Same retire-window commits as dsp_opcode_jump. */
if (dspFlagsRetireDelay)
{
+107 -5
View File
@@ -38,6 +38,22 @@
* Only the executed-path check (opcost == idleBodyCount + 1)
* rejects it.
*
* C. a loop with an exit branch (#952, Cybermorph's DSP wait loop)
* -> the probe MUST fire, and the loop must still leave once the
* polled register changes.
* head: btst #0,r22 ; jr NE,out ; nop ; btst #1,r22 ; jr EQ,head
* nop <- delay slot
*
* D. the exit-branch exploit -> the probe MUST NOT fire.
* head: subq #1,r1 ; jr EQ,out ; nop x5 ; jr always,head ; nop
* out: store r3,(r4) ; movei #1,r1 ; jump (r6) ; nop
* r6 = the closing jr. The exit is taken every time, the detour
* runs an undecoded store and re-enters through a `jump` (never
* probed), and the route costs 9 opcodes, exactly the 7 decoded
* + delay slot + 1 of a straight pass. Registers and flags match
* at every arrival, so only the exit-branch check (one taken jr per
* iteration) rejects it.
*
* Build: cc -O2 -Wall -std=c99 $(INCFLAGS) -o test/tools/dsp_idle_probe_falsify \
* test/tools/dsp_idle_probe_falsify.c test/harness/harness.c -ldl -lm
* Needs the wide test ABI (make TEST_EXPORTS=1). Uses test/roms/yarc.j64,
@@ -54,6 +70,7 @@
/* RISC opcode indices (src/jerry/dsp.c dsp_opcode[]) */
#define OP_ADDQT 3
#define OP_SUBQ 6
#define OP_BTST 13
#define OP_STORE 47
#define OP_JUMP 52
#define OP_JR 53
@@ -63,12 +80,16 @@
/* Branch condition codes: 0 = always, 1 = NE (fails when Z is set). */
#define CC_ALWAYS 0
#define CC_NE 1
#define CC_EQ 2
#define DSP_RAM_BASE 0x00F1B000u
#define PROG_A_OFF 0x0100 /* $F1B100 */
#define PROG_B_OFF 0x0200 /* $F1B200 */
#define PROG_C_OFF 0x0300 /* $F1B300 */
#define PROG_C_OUT 0x0320 /* $F1B320 -- C's exit target */
#define SCRATCH_OFF 0x0400 /* $F1B400 -- the exploit's store target */
#define PROG_D_OFF 0x0500 /* $F1B500 */
static uint8_t *dsp_ram;
static uint32_t *p_dsp_pc;
@@ -127,6 +148,47 @@ static void build_prog_b(void)
setreg(5, DSP_RAM_BASE + PROG_B_OFF); /* jump target = head */
}
static void build_prog_c(void)
{
/* jr offsets: target = (jr + 2) + off * 2. */
put16(PROG_C_OFF + 0, enc(OP_BTST, 0, 22)); /* btst #0,r22 */
put16(PROG_C_OFF + 2, enc(OP_JR, 14, CC_NE)); /* jr ne,out */
put16(PROG_C_OFF + 4, enc(OP_NOP, 0, 0));
put16(PROG_C_OFF + 6, enc(OP_BTST, 1, 22)); /* btst #1,r22 */
put16(PROG_C_OFF + 8, enc(OP_JR, 0x1B /* -5 */, CC_EQ)); /* jr eq,head */
put16(PROG_C_OFF + 10, enc(OP_NOP, 0, 0)); /* (delay slot) */
/* out: park in an idle loop of its own so the PC stays readable. */
put16(PROG_C_OUT + 0, enc(OP_ADDQT, 1, 7)); /* addqt #1,r7 */
put16(PROG_C_OUT + 2, enc(OP_JR, 0x1E /* -2 */, CC_ALWAYS));
put16(PROG_C_OUT + 4, enc(OP_NOP, 0, 0));
setreg(22, 0); /* nothing to do yet */
}
static void build_prog_d(void)
{
unsigned i;
put16(PROG_D_OFF + 0, enc(OP_SUBQ, 1, 1)); /* subq #1,r1 */
put16(PROG_D_OFF + 2, enc(OP_JR, 7, CC_EQ)); /* jr eq,out */
for (i = 4; i <= 12; i += 2)
put16(PROG_D_OFF + i, enc(OP_NOP, 0, 0));
put16(PROG_D_OFF + 14, enc(OP_JR, 0x18 /* -8 */, CC_ALWAYS)); /* jr head */
put16(PROG_D_OFF + 16, enc(OP_NOP, 0, 0)); /* (delay slot) */
/* out = head + 18 */
put16(PROG_D_OFF + 18, enc(OP_STORE, 4, 3)); /* store r3,(r4) */
put16(PROG_D_OFF + 20, enc(OP_MOVEI, 0, 1)); /* movei #1,r1 */
put16(PROG_D_OFF + 22, 0x0001); /* LSW */
put16(PROG_D_OFF + 24, 0x0000); /* MSW */
put16(PROG_D_OFF + 26, enc(OP_JUMP, 6, CC_ALWAYS)); /* jump (r6) */
put16(PROG_D_OFF + 28, enc(OP_NOP, 0, 0)); /* (delay slot) */
setreg(1, 1);
setreg(3, 0xDEADBEEFu);
setreg(4, DSP_RAM_BASE + SCRATCH_OFF);
setreg(6, DSP_RAM_BASE + PROG_D_OFF + 14); /* jump target = closing jr */
}
/* Run one hand-assembled program from `off` for `cycles`, returning how
* many times the fast-forward fired. */
static uint32_t run_prog(uint32_t off, int32_t cycles,
@@ -148,11 +210,13 @@ static uint32_t run_prog(uint32_t off, int32_t cycles,
int main(int argc, char **argv)
{
harness_config cfg = HARNESS_CONFIG_DEFAULT;
harness_result res[3];
harness_result res[6];
unsigned nres = 0;
uint32_t fires_a, fires_b, rej_a, rej_b, ops_a, ops_b;
uint32_t fires_c, fires_d, rej_c, rej_d, ops_c, ops_d, pc_c;
static char detail_a[192], detail_b[192], detail_c[192];
int ok_a, ok_b;
static char detail_d[192], detail_e[192], detail_f[192];
int ok_a, ok_b, ok_c, ok_d, ok_e;
cfg.frames = 2;
cfg.quiet = 1;
@@ -182,14 +246,26 @@ int main(int argc, char **argv)
return 1;
}
/* Build each program just before it runs: B and D both use r1. */
build_prog_a();
build_prog_b();
fires_a = run_prog(PROG_A_OFF, 20000, &rej_a, &ops_a);
build_prog_b();
fires_b = run_prog(PROG_B_OFF, 20000, &rej_b, &ops_b);
build_prog_c();
fires_c = run_prog(PROG_C_OFF, 20000, &rej_c, &ops_c);
/* The polled bit arrives: C must leave the loop it was skipping. */
setreg(22, 1);
p_dspexec(200);
pc_c = *p_dsp_pc;
build_prog_d();
fires_d = run_prog(PROG_D_OFF, 20000, &rej_d, &ops_d);
ok_a = (fires_a > 0);
ok_b = (fires_b == 0);
ok_c = (fires_c > 0);
ok_d = (pc_c >= DSP_RAM_BASE + PROG_C_OUT
&& pc_c <= DSP_RAM_BASE + PROG_C_OUT + 4);
ok_e = (fires_d == 0);
snprintf(detail_a, sizeof detail_a,
"genuinely idle loop (addqt/jr/nop) fired %u time(s), "
@@ -216,7 +292,33 @@ int main(int argc, char **argv)
res[nres].detail = detail_c;
nres++;
snprintf(detail_d, sizeof detail_d,
"loop with an exit jr (btst/jr ne/nop/btst/jr eq) fired %u "
"time(s), %u reject(s), %u opcodes charged",
fires_c, rej_c, ops_c);
res[nres].status = ok_c ? "PASS" : "FAIL";
res[nres].name = "exit_branch_loop_fires";
res[nres].detail = detail_d;
nres++;
snprintf(detail_e, sizeof detail_e,
"after r22 bit 0 set, DSP PC $%06X (must be in $%06X..$%06X)",
pc_c, DSP_RAM_BASE + PROG_C_OUT, DSP_RAM_BASE + PROG_C_OUT + 4);
res[nres].status = ok_d ? "PASS" : "FAIL";
res[nres].name = "exit_branch_still_exits";
res[nres].detail = detail_e;
nres++;
snprintf(detail_f, sizeof detail_f,
"taken exit + undecoded store + jump back, same opcode count, "
"fired %u time(s) (must be 0), %u reject(s), %u opcodes charged",
fires_d, rej_d, ops_d);
res[nres].status = ok_e ? "PASS" : "FAIL";
res[nres].name = "taken_exit_detour_rejected";
res[nres].detail = detail_f;
nres++;
harness_report(&cfg, res, nres);
harness_shutdown(&cfg);
return (ok_a && ok_b) ? 0 : 1;
return (ok_a && ok_b && ok_c && ok_d && ok_e) ? 0 : 1;
}