The compiler now takes almost everything a typical program runs: the TSCP chess bench executes 99.9% of its statements compiled. It still runs 47 times slower than the same search converted by mmb2csub. Coverage is no longer the limit. The cost of each compiled statement is, and that cost is set by the executor (RBRun, the stream and the helpers it calls), not by what the compiler accepts. This review measures that cost piece by piece on the PC3 and proposes eight changes to the executor, ranked by what they would buy and what they would risk. Every proposal keeps the design's contract: the interpreter's state is exactly the text interpreter's wherever anything can look at it.
Measured 5 October 2026 on COM9: PicoMiteHDMIWEB RP2350B, V7.0.00b4, 378 MHz, OPTION COMPILE ON, PSRAM fitted (the stream in PSRAM, the test arrays in SRAM). Programs: Bas/chess (the bench), and three timing programs listed under Method. Nothing in the firmware was changed for this review. R0, the measurement phase, ran the same day on COM16 (the released V7.0.00b5, 315 MHz) and COM9; its results are in R0 results and change the order of work.
x = j costs 209 with two ops.findvar.NEXT.The measurement phase ran on 5 October:
Nothing in the firmware was changed. The profiles are resolved against an ELF rebuilt from the same commit at a path of the same length, which reproduces the released image to within 8 bytes (the build date and time and one drive letter). COM16's flash was checked against the released uf2 with fwsum.py.
After any SUB return or FUNCTION call, the compiled chain stays broken until a string temporary is freed.
RBReturn (Stream.c:4634) and RBCallFun (Stream.c:4240) set g_TempMemoryIsChanged, as the text path's return does.ClearTempMemory (Memory.c:1967) clears the flag only inside its loop, that is, only when it frees a block.RunStream and RBTail.A 50-statement loop costs 252 cycles a statement; right after an empty SUB call, 391 to 411; after the call followed by s$ = Str$(1), 255 again. In the chess engine, which uses no strings, this holds for nearly the whole search. RunStream, RBTail, RBFind and ClearTempMemory together take 21.3% of the bench (Exile 11.4%, Elite 6.1%), most of it this round trip.
Fix: clear the flag when ClearTempMemory finishes, whether or not it freed anything. Every later allocation and every return sets the flag again, so nothing that needs the check can miss it. The text path calls the same function, and loses only an unneeded call per statement.
Expected: 15-20% off the chess bench, less on programs that use strings between calls.
Applied and measured (R1, 5 October). The flag is now cleared at the end of ClearTempMemory (core/Memory.c). It was built for HDMIWEB with no change in RAM (bss_end the same) and flashed on COM16, at 315 MHz:
Each ran the same number of statements as before. rbp4d, rbp5c, rbp3d, rbp3b and rbcache pass (OFF, ON and SHADOW identical). A leak check (40,000 rounds of string FUNCTIONs, a SUB with a LOCAL string and string temporaries) leaves the heap exactly as it was after its first round, as text and compiled.
A timing trap found on the way: the display mode moves solar_eclipse by about 8% through console scrolling (MODE 2 is faster). A b5 run left in MODE 2 by an earlier program first made D1 look 4.5% slower. Every timing now sets the mode and clears the screen first.
The share of all PC samples, compiled, on b5 at 315 MHz. The chess bench is depth 3 (622,142 samples). Exile is its physics test (165,412). Elite is its demo (74,182).
| Part | Chess | Exile | Elite | What it is |
|---|---|---|---|---|
| Record boundary inside RBRun | 21.4% | 17.4% | 11.0% | entry (header, bind stamp, cache) and end (counters, successor, chain test, return) |
| Chain round trips | 21.3% | 11.4% | 6.1% | RunStream, RBTail, RBFind, ClearTempMemory: mostly D1 |
| Calls and locals | about 18% | about 35% | about 12% | findvar (1.30 million calls in the chess bench, 99% making locals), RBMakeParams and its memcpy, FindStructType, checkstring, CheckIfTypeSpecified, RBLocalTypesOK, ClearVars, LeaveLocalFrame, SymLocalMade/Freed |
| Re-binding | 4.2% | 6.8% | 4.5% | RBBindAll after a stamp moved: the statements of a called routine |
| Ops and dispatch | 12.9% | 9.8% | 7.2% | the arithmetic, tests and jumps themselves |
| Array elements | 8.3% | 1.5% | 1.2% | RC_IDX and RC_LDEL inline, plus RBElementAt |
| Housekeeping | about 7% | 8.5% | 6.4% | the 100 µs routinechecks; the USB host task alone 3-4.5% |
| Everything else | about 7% | about 9% | about 51% | Elite: drawing (DrawRectangle16, DrawBitmap16), MATH and the statements that stay text |
Four things stand out:
The RP2350's XIP controller counts cache hits and accesses (XIP_CTRL CTR_HIT at 0x400C800C and CTR_ACC at 0x400C8010, cleared by writing them). BASIC reads them with PEEK, so misses can be counted around any run.
| Run | cycles a statement | XIP accesses | XIP misses |
|---|---|---|---|
50 distinct x = x + 1, stream in PSRAM (COM9, 378 MHz) | 252 | 21.0 | 0.03 |
| 400 distinct, PSRAM | 556 | 23.0 | 2.67 |
| 2,000 distinct, PSRAM | 895 | 25.5 | 6.42 |
| 50 / 400 / 2,000 distinct, stream in flash (PSRAM off) | 367 / 681 / 1,066 | 21-25 | 0.03 / 2.68 / 6.42 |
| Chess bench, iterations 2 and 3 (COM16, 315 MHz) | about 930 | 64 | 2.33 |
| A FUNCTION call: 1 parameter, 3 LOCALs, 5 statements | 13,264 a call | 1,271 | 14.9 |
| A SUB call: 2 parameters | 4,051 a call | 416 | 0.93 |
| TSCP bench | depth 3 | depth 5 | nodes/s |
|---|---|---|---|
| Compiled BASIC (RP2350, 378 MHz) | 47.0 s | 2,857 s | 193 |
| search() as a CSUB (same board) | about 1.0 s | 61.3 s | 8,986 |
| search() as a CSUB (RP2040, 252 MHz) | 2.0 s | 107.8 s | 5,110 |
At depth 3, MM.INFO(COMPILE) reports RAN 20,013,119, CODE 19,985,062 and MISS 1,431,782. That is 2,246 statements a node, 2.35 µs (about 890 cycles) a statement. The CSUB covers the same work at about 19 cycles a BASIC statement. The bench makes about 30 SUB and FUNCTION calls a node (counted on a Python model of the program that reproduces the bench to the node): the pawn evaluations 12.6, the king-shelter evaluations 5.8, gen_push 2.3, and one each of makemove, takeback, in_check, attack, set_hash, tscpSort, eval_ and the two king evaluations.
Each figure is a loop of 20,000 or 50,000 runs of one statement, less the cost of the loop itself, at 378 MHz, all compiled.
| Statement | cycles | Reading |
|---|---|---|
x = j | 209 | two ops; the rest is the record boundary |
x = x + 1 | 241 | four ops |
x = x + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 | 485 | seven more push-and-add pairs: about 35 a pair, 17 an op |
x = a(j) | 297 | a 1-D element: +88 over a scalar (RC_IDX and the inline path) |
x = b(k, j) | 473 | a 2-D element: +264 (RBElementAt) |
x = c(1, k, j) | 561 | a 3-D element: +352 |
If x < 0 Then y = 1 (false) | 313 | |
| two statements on one line, against two lines | 237 / 243 | the line bookkeeping (CurrentLinePtr, TraceBuff) is about 6 cycles |
an empty FOR … NEXT iteration | 281 | NEXT is a record of its own |
x = mb(mb64(j)+offset(pp(j),k)) (from attack) | 768 |
| Call | cycles | Reading |
|---|---|---|
s0: a SUB with no parameters and an empty body | 1,197 | the call, the frame and END SUB |
s3 j, k, x: three parameters | 3,727 | about 840 a parameter: each is made by findvar(…, V_LOCAL) |
x = f0(j): a FUNCTION, one statement | 3,918 | the parameter, the result variable, a nested ExecuteProgram |
x = f0l(j): the same with Local q1, q2 | 6,853 | about 1,350 a LOCAL name, plus the statement |
x = f8(j): one LOCAL, nine statements | 10,331 | the same nine statements inline: 2,123 |
| a loop on LOCALs inside one call | 254 | as cheap as on globals: the bind cache holds within a call |
The nine-statement function puts the re-binding at about 375 cycles a statement, a call, for statements on locals. The 400-statement body below gives about 190 for statements on globals: 720 cycles a statement as a SUB called 50 times, against 534 for the same statements in a main-level loop.
Distinct x = x + 1 statements in a main-level loop | cycles each | records they span (approx.) |
|---|---|---|
| 50 | 253 | about 2 KB |
| 400 | 534 | about 19 KB |
| 2,000 | 1,009 | about 95 KB |
Nothing differs between these but how much code the loop runs through. A record is about 48 bytes; with its map entry and the index, a stream takes 47-57 bytes a statement (the chess stream 85,996 bytes for 1,841 statements, this test 143,376 for 2,534). The stream lives in PSRAM, read through the XIP cache, which is 16 KB on the RP2350 and holds flash code as well. A loop whose records do not fit pays for PSRAM reads on every statement, up to about 760 cycles each here. The chess stream is 86 KB, and its search, move generation, evaluation and attack code all run every node.
The chess bench at depth 3 spends about 2.0 million cycles a node (47.03 s × 378 MHz ÷ 8,911 nodes). Four parts of that can be put in figures from the measurements and the counts. The rest cannot be split without R0.
| Part | cycles a node | share | From |
|---|---|---|---|
| Record boundaries | about 390,000 | about 20% | 2,246 statements × about 175 |
| Calls | 120,000-300,000 | 6-15% | about 30 calls × 4,000-10,000, by their parameters and LOCALs |
| Re-binding in called routines | 60,000-125,000 | 3-6% | about 330 records a node run for the first time in their call × 190-375 |
| Map lookups | about 20,000 | about 1% | 160 misses a node; a bucket read each since P1 (estimated) |
| The remainder: ops, element addressing and stream fetches | 1,150,000-1,400,000 | 55-70% | measured per unit, not counted per node |
The remainder is where most of the time is, and its parts each have a measured unit cost but an unknown count. From the program's shape: the engine's statements average several ops, most index one array and many two (offset(P(i),j), gd(i,0), hd(hply,k), hi(from,too), pawn_rank(LI,f)), and set_hash indexes a three-dimensional one, HP(H(i),P(i),i), in a 64-square loop. The stream-fetch share is the least certain part and potentially the largest. R0's profile and the flash-stream run settle it before V1 is designed in detail.
Each proposal says what it changes, why the measurements point at it, what it costs, what it might gain, and how it keeps the state identical. Gains are estimates from the unit costs above; R0 replaces them with measurements.
Why. The size table: the same statement costs up to four times as much once its loop's records outgrow the 16 KB XIP cache, and the chess stream is 86 KB. A x = x + 1 record is about 48 bytes: a six-word statement header (the STMT word, the text key, the CMD word, the token, the cmdline and nextstmt offsets), the code length, the bind count, the stamp, the bind descriptors, the bind cache and nine words of code. Of those, a compiled statement that binds from its cache reads the header, the stamp, the cache and the code; the descriptors, the token and the fallback offsets are read only when it binds or falls back.
Proposal.
x = x + 1 that halves what a run reads.Identical state. Nothing observable changes; only where the executor reads its own data. RB_VERSION moves, so every stream recompiles once.
Gain. Between nothing and most of the stream-fetch share, which may be the largest part of the remainder. 10-30% on the chess bench is a guess until R0. Every program whose hot code exceeds the cache gains: Exile's stream is 81 KB, Elite's 303 KB.
Risk. Low for correctness. It touches the compiler's writer and every executor read of a record, so it is a large mechanical change.
Why. A call costs 1,197 cycles before anything is passed, about 840 a parameter, and about 1,350 a LOCAL. Every statement of the called routine then binds its variables again, because the call made new locals at its level, at 190-375 cycles a statement. The bench makes about 30 calls a node to routines of a few statements each. Each parameter, the FUNCTION's result and each LOCAL is made by findvar with V_LOCAL: a name copied out of the definition, the symbol's paths, the checks for an existing local and a SUB of that name, a 64-byte entry filled, SymLocalMade. A FUNCTION's body also runs in a nested ExecuteProgram and RunStream, two C frames a level.
Proposal.
g_localtop in one go: the template copied, the level set, SymL pushed for each, and the value or pointer stored directly, with no findvar.ExecuteProgram. This also gives the C stack back at every FUNCTION level, which matters for recursion; rbdepth measures it.Identical state. The frame stays a run of g_vartbl entries, so the 298 places that read g_vartbl[g_VarIndex] see what they see now. Parameters are made in the same order with the same type rules, BYREF demotion and errors (an error while making them still unwinds through DefinedSubFun's recovery). LOCALs are not reserved at entry, so "Not enough Local variable memory" comes at exactly the point it does now; that was the reason the reserved frame was not made in P6. A FUNCTION run inside RBRun must save and restore what DefinedSubFun saves around its nested loop (nextstmt, cmdline, cmdtoken, CurrentLinePtr, ErrNext), and an interrupt, an error or END inside the body must find the stacks as the text path leaves them.
Gain. Calls perhaps from 1,200-10,000 cycles to 400-1,500, and the re-binding gone. 10-20% on the chess bench; more on programs built from many small SUBs (the anchor, Exile, Elite).
Risk. Medium. Calls have many paths, and interrupts and errors arrive at awkward moments. rbp3a, rbp3b, rbp3d, rbf2, rbcache and the interrupt tests cover them.
Why. A 2-D element costs 264 cycles over a scalar and a 3-D one 352, against 88 for a 1-D one. Anything but a 1-D element in bounds calls RBElementAt in flash. That function counts the array's dimensions again, checks each index against DimUpper and g_OptionBase, and multiplies its way to the element, on every access. Each index has already been through RC_IDX, its own op.
Proposal.
Identical state. The same checks in the same order, so the same errors: "Index out of bounds" after the conversion, "Dimensions" below OPTION BASE.
Gain. A 2-D element perhaps from 264 to about 110, a 3-D one from 352 to about 140. 5-15% on the chess bench, which indexes 2-D arrays on most lines of its evaluation and move generation.
Risk. Low. rbp4a and rbp5c already cover the element forms and errors.
Why. About 175 cycles of every statement are spent getting into and out of its record. x = j costs 209 with two ops, and line bookkeeping is only 6 of that.
At each boundary RBRun:
RB_HDR);RBRan and RBCode;OptionErrorSkip, g_TempMemoryIsChanged, TraceOn and the housekeeping countdown;RBImage(RBKeyAt(r))).Proposal.
OptionErrorSkip, temporary memory, TRACE, an abort, an interrupt. The chain then tests one word.nextstmt where the tail, a fallback or the chain's end needs it. The design's own table lists those as its only readers (interrupts, Ctrl-C, fallback control flow).Identical state. CurrentLinePtr and TraceBuff stay eager, since they cost 6 cycles and the PC sampler and errors read them. nextstmt is set wherever it can be read. Proposal 5 needs a short survey to confirm that nothing else reads nextstmt mid-chain.
Gain. Perhaps 175 down to 70-90 cycles a statement. About 10% on the chess bench and on any compiled loop.
Risk. Low to medium. The chain conditions were found one by one in P3c and P3e, and each must survive. rbp2c, rbnopchain, rbstamp and the interrupt tests cover them.
Why. An op costs about 17 cycles: a dispatch and an 8-byte cell moved to or from the stack. Statements are short sequences of the same few shapes. x = x + 1 is LDG, LK, ADDI, STG; an IF on a comparison is two loads, CMPI and JFI.
Proposal. A peephole pass picks the commonest pairs and triples from a count over the corpus's streams: add a small constant in place, compare-and-branch with a variable or literal operand, an element load whose index is a variable, and the store that copies one variable to another. Each fused op runs the same helpers in the same order as the ops it replaces.
Gain. 20-40% of the op share. About 5% on the chess bench.
Risk. Low for correctness. The cost is RAM: RBRun is RAM, which is stack, in 256-byte steps. Each op is perhaps 30-60 bytes, so this is weighed against recursion depth op by op.
Why. An empty FOR iteration costs 281 cycles. NEXT is a record of its own, so it pays a boundary. It then searches g_forstack from the top, calling skipspace on each entry's text, before it adds the step and tests the limit in integer or float. The chess program's board scans are 64-iteration loops, several of them every node.
Proposal.
Identical state. The same g_forstack entry and the same closing rule, including NEXT-in-recursion's level test (5b90865). EXIT FOR, CONTINUE FOR, a fallback NEXT and ClearVars see what they see now.
Gain. Perhaps 281 down to about 120 an iteration. A few percent on the chess bench, more on tight-loop programs.
Why. After V4 each statement still pays a boundary. The CSUB's 19 cycles a statement show what is spent once statements have no boundaries at all.
Proposal. A run of compiled statements with no fallback and no call (a basic block, typically a loop body) is compiled as one record:
CurrentLinePtr at a line start, counts down the housekeeping counter and tests the attention word (V4 proposal 2);Each statement keeps its map entry, which then points at its block and offset, so a GOTO, NEXT or RETURN into the middle of a block still lands correctly.
Identical state. Kept at every point anything can look at it, by materialising at the boundary where the executor stops. That is what the design already does at its chain's end, applied inside a record.
Gain. The boundary drops to tens of cycles for straight-line code. With V3 and V5, the largest single step available inside the VM.
Risk. High. It changes the record model that P1 to P6 were built on, and SHADOW, the map, binding and the stream dump tools all follow it. It belongs after V1 to V6 have shown what is left.
The CSUB sets the ceiling: 19 cycles a statement against about 890 now, and perhaps 300-450 after V1-V7. Closing the rest means generating Thumb-2 for the M33 from the stream: copy machine-code templates per op, keep the value stack in registers where a block allows, and call the firmware's helpers for anything not trivial. That needs an SRAM arena to execute from (PSRAM through XIP is slow for code), templates in flash, and the same materialise-on-attention scheme as V7 for errors and interrupts. It is named here so the VM work can be judged against it. It is not proposed.
Gate: the "Where the cycles go" table holds measured shares, and V1's size is decided from them.
One line in ClearTempMemory. The test is the after-a-call loop (252 against 391-411 cycles now) and the chess bench, with the goldens for the text path, which shares the function.
The successor in the record, one attention word, the stamp only where it can move, and the hot part of each record made small. The tests are the size table, which should flatten towards the 50-statement figure, the XIP misses a statement in the chess bench (2.33 now), and every program's profile.
After the decision on parameter templates. It starts with the type checks the compiler can settle (FindStructType, checkstring, CheckIfTypeSpecified at run time). The call tables are the test, with rbdepth for the stack and Exile, whose time is mostly calls.
For array-heavy programs; the element timings are the test.
V6 first (NEXT costs a boundary every iteration); V5 only by an op-pair census, each fused op weighed against its RAM.
Only if R1-R5 leave the boundary as the largest remaining part.
Written for Peter's review, 5 October 2026. Branch development at 696d52c. Related: docs/Interpreter_RouteB_Design.html (the design and its decisions), docs/Interpreter_RouteB_Coverage.html (what compiles), docs/mmb2csub.md (the CSUB route).