Review plan · PicoMite MMBasic interpreter · 24 September 2026

Interpreter Performance Plan

How MMBasic spends its time, what has already been learned, and a measured programme to make the interpreter faster without shrinking the largest program a user can run. It is led by one benchmark, solar_eclipse.bas, on both boards.

A read-only review of the development branch. Nothing was edited, built or run on a board to write it. Every figure is one of three things: Peter's measurement, a reading of the code cited as file:line, or an estimate marked as such.

Status · 26 September 2026 · development at 9650f69

Where the programme stands

Paused here

On 26 September Peter paused the programme to bring what it has learned to main, in this order:

  1. Remove the trace cache, keeping the DO loop fast path on the builds it suits.
  2. The ProcessWeb changes (S1a).
  3. The interrupt changes (S1c).
  4. The bug list, one bug at a time: docs/Main_Bug_Report_2026-09.html.

Route B resumes afterwards, from its design review.

Done on development

StepCommitsResult
Phase 0, Phase 10183e77 … a166789docs/Interpreter_Phase0_Results.html, docs/Interpreter_Phase1_Results.html
S10 allocator hintb4ef437top-down first-free hint only
S1c interrupt-pending counter791bf93, 6c6fdd8, 862ec45, 142734cIntReady counter signalled by the sources; InterruptUsed removed; tests in Bas/bench/inttests
S1a ProcessWeb cadencea07e8f2no network work without a connection; WEB SCAN polls; anchor 1.2-1.4% faster on HDMIWEB
Trace cache removed (S5/S6 phase 1a)04361c0DO fast path kept on all builds; frame tracking and per-SUB profiling under SUBPROFILE
S5/S6 symbols and bindingse3298b9, 9cdcfe8, b44dacc, 317da5bdesign in docs/Interpreter_S5S6_Symbols_Design.html. Symbols against text, same firmware: PC3 anchor -8.2%, Exile -5.7%, Elite -9.2%, Prince of Pico -11.4%; RP2040 anchor -12.4%, julia -19.1%, knivd -7.3%.
Two fixes found while designing Route B8d3ee25, 8740953CHAIN and SAVE/LOAD CONTEXT rebuild the symbol bindings (Bas/bench/symchain.py); the comment fast path no longer leaves a loop whose first line ends in : ' comment (Bas/bench/cmtloop.py, also on main as bug 21)

Route A's verdict and gate G3

Open on development

1 · Purpose

Purpose and ground rules

MMBasic spends most of its time interpreting, not computing. The work it repeats on every execution is fixed for the whole RUN. This plan measures that work first. It then moves the answers into the stored program, written once when the program is saved, and never into heap tables that grow with the program.

The short version

Why first principles, and not more tuning

Tuning hot functions has been tried several times and has not moved the overhead:

Integer and float benchmarks run at almost the same speed, so the arithmetic is not the cost. The question is not "which function is slow". It is "what does the interpreter work out again on every execution that it could have worked out once".

Ground rules

2 · The model

How the interpreter spends its time today

This section follows one statement through the firmware. Every step was read in the code. The cycle cost of each step has not been measured on either board; Phase 0 exists to measure it.

What the program looks like in memory

The program runs directly from its tokenised image in flash, read through the 16 KB XIP cache. On the PC3 the RAM library and the RAM slots sit in PSRAM behind the same cache (PSRAMbase 0x11000000, configuration.h:524).

The same image is the source for LIST, EDIT and SAVE, which is why it stays text.

One statement, step by step

  1. Line header (MMBasic.c:1834-1859), once per line: set CurrentLinePtr, write the TraceBuff ring, test TraceOn, and step over the line number and label.
  2. Pre-scan (1879-1881): skipelement(nextstmt) walks every byte to the terminating 0, including any trailing comment. ExecuteProgram uses the skip byte only to jump over comment-only lines (1863-1875).
  3. Dispatch (1890-1917): commandtbl_decode, then commandtbl[].fptr(). The table is const, so it is in flash. A user SUB name goes through FindSubFun and then DefinedSubFun. setjmp runs only while ON ERROR SKIP, IGNORE or RESTART is in effect, that is while OptionErrorSkip is not 0 (1925-1938; Commands.c:8546-8575).
  4. The command reads its own text again.
    • getargs/makeargs copy it into a 384 B stack buffer, calling strchr on each top-level byte (MMBasic.c:5551). A bracketed argument or function call is found with getclosebracket and copied in one memcpy (5600-5607).
    • LET scans for = with a tokenfunction table lookup on each byte (an inline macro, MMBasic.h:227-230; Commands.c:1787-1788), then skipvar, then findvar on the left side (Commands.c:1818).
  5. Expressions. evaluate calls getvalue for the first operand, then doexpr while an operator follows (MMBasic.c:2889-2891). doexpr calls getvalue for each next operand and recurses on precedence (3066, 3146).
    • Literals are parsed again on every evaluation (MMBasic.c:3336-3398).
    • Operators receive their operands through globals and are reached by an indirect call (3135).
  6. Every name. findvar upper-cases and FNV-hashes each character, then takes % maxglobalvars at run time (4689-4701). Inside a SUB it probes the local table before the global one.
  7. Every name(. FindSubFun runs first (3277-3279), so every array read asks "is this a function?".
    • On the RP2040 that starts by counting subfun[] one entry at a time (2027-2028).
    • Array indices then go through a getargs copy, a bracket re-scan and skipvar.
  8. Every built-in function that takes arguments (T_FUN, 3245-3253; Memory.c:1852-1860): getclosebracket, then a zeroed 256 B temp page, then a memcpy of the argument text. The page is freed at the end of the statement. Functions with no arguments, such as TIMER and PI, skip the copy (3254-3256).
  9. Tail (MMBasic.c:1940-1952):
    • ClearTempMemory if anything was allocated.
    • The core1 stack canary, which is not compiled on WEB builds (1944).
    • Unless NOCHECK is set, CheckAbort() and then check_interrupt():
      • CheckAbort calls ProcessWeb(1) on every statement on WEB builds (PicoMite.c:2682-2687), and HDMIWEB is one (CMakeLists.txt:798-806). ProcessWeb reads time_us_64, walks the PCBs, and polls cyw43 every 100 calls or every 1 ms (net/WiFi.c:295-345).
      • routinechecks calls time_us_64() and returns if less than 100 µs have passed (PicoMite.c:893-901).
      • check_interrupt calls ProcessTouch on every statement while GUI controls exist. Otherwise it returns unless InterruptUsed is set. Once any interrupt has been set, checkdetailinterrupts runs on every statement until END (MM_Misc.c:10558-10586). That is a 350-line function in flash (10209-10557) that tests every interrupt source in turn.

Control flow and calls

Where the code runs

The repeated work, in one table

WorkWhereHow oftenFixed for the RUN?Resolved once today?
Find the end of the statementMMBasic.c:1881Every statement, every byteYes (its length)Per line, and only to skip comment lines
Decode and dispatch1890-1917Every statementYesNo
Poll for events1940-1952; PicoMite.c:2682-2687, 893-901Every statementAlmost always nothing happenedNo
Split the argumentsmakeargsNearly every commandSplit points, yesNo
Resolve a namefindvar 4689-4701Every referenceWhich name, yes; which storage, noNo; globals never move once created
Ask whether name( is a function3277-3279Every array readYesNo
Convert a literal3336-3398Every evaluationYesTried, no gain (§3.4)
Copy built-in arguments3245-3253Every call of a function that takes argumentsYesNo
Find NEXT, LOOP or a CASE armCommands.c:5198-5235, 5378-5394, 4729-4912Every loop entry; every SELECTYesFOR and DO iterations and the IF jump table
Find a label or lineMMBasic.c:3547, 3742, 3802Every GOTO/GOSUBFor literal targetsRP2350 labels only
Parse a SUB's parameter list2392-2532Every callYesNo
Allocate and clear call scratch2370-2385Every callSize knownNo
What this model does not tell us is the split.
  • How much goes on the fixed frame (steps 1-3 and 9) and how much on operand work (steps 4-8) has not been measured on either chip.
  • The dossier's instruction counts, which put the frame at about 10-25% of A=A+1, are estimates.
  • The trace-cache figure that looks like a frame measurement cannot answer it either. That figure is 771 ms for 1M FOR/NEXT iterations at 252 MHz, about 194 cycles an iteration (docs/option-profiling-cache.md:300-311).
    • It came from an RP2040 PICO build. PICO is the only RP2040 variant with CACHE (CMakeLists.txt:297-309), and on that build cmd_next is in RAM. On COM7 it is in flash.
    • Setting it against the PC3's 6 µs per assignment (about 1,900 cycles if it was measured at 315 MHz, or 2,270 at 378 MHz; the clock was not recorded) crosses chip, build and XIP regime.
Phase 0 measures the split on each board and each build (H1, H5).
3 · Evidence

What has been learned already

3.1 The overhead, measured against CSUB twins of the same routine

Split the interpreted time as T = W + O: the work itself plus the interpreter's overhead. mmb2csub converts about 132 of the 166 routines in its corpus (mmb2c's tests/, plus se.bas, julia.bas and knivd.bas). The same routine therefore exists interpreted and native, and T_native is a measured floor for W.

RoutineBoardInterpretedNativeO = 1 − Tnative/TinterpSource
Graphics-heavy routinesboth——≈ 0.75 (4x)Peter
Julia setRP2040119 s5.9 s0.95docs/mmb2csub.md:33-34
Julia setRP235086 s2.5 s0.97same
Exile player kernel (hand-written C CSUB)PC3 (clock not recorded)9.8 ms/tick0.1 ms/tick≈ 0.99Exile_Port_Design_Review.html:405
solar_eclipse.bas, sefunc as CSUBPC3, 378 MHz~12.5 s (14.6 s on HDMIWEB with WiFi live)2.5 s≈ 0.80Peter
solar_eclipse.bas, sefunc as CSUBRP2350B, 252 MHz13 s2.7 s0.79docs/mmb2csub.md:670

What this implies:

This gives the yardstick used everywhere below. It is measured per board, within one build and one condition:

removed(X) = (T_interp − T_interp,X) / (T_interp − T_native)

Removing 10% of the overhead counts the same on a 4x routine as on a 35x one. The floor needs two caveats:

The sefunc CSUB barely scales with the clock: 2.7 s at 252 MHz and 2.48 s at 378 MHz (docs/mmb2csub.md:668-672). Between those two clocks the flash clock fell from 126 to 94.5 MHz while the CPU rose 1.5x. The interpreted remainder, flash fetch or the CallTable's double routines could each explain that. The plan does not chase it (flash timing is confounded by the QMI cache); it takes the floor as measured, on the same build as the interpreted time.

3.2 What MicroPython's lead teaches

On the same chips, MicroPython written to mimic BASIC is up to about 30% faster than MMBasic (Peter's figure). Peter's MicroPython is his own PC3 port (\\wsl.localhost\Ubuntu\home\peter\src\micropython\ports\rp2, board PICO_COMPUTER_3). It uses double-precision floats and arbitrary-precision (MPZ) integers, so it does the same arithmetic as MMBasic (mpconfigboard.h:24-26; DEVELOPMENT_NOTES.md §1). On the anchor, the line-faithful port tests/solar_eclipse.py takes about 8.8 s against MMBasic's 12.5 s at 378 MHz on the same chip, with digit-identical output. That removes (12.5 − 8.8)/(12.5 − 2.5) ≈ 37% of the overhead.

What it resolves once

  • It compiles once: lexer, whole-module parse tree, then bytecode (py/compile.c).
  • Identifiers are interned to qstrs at parse time, and bytecode names them by a small per-module index.
  • Function locals get fixed slots. LOAD_FAST is fastn[-n] plus a NULL check, and an unassigned slot is NULL.
  • Pending work is flagged by events. Ctrl-C and IRQ callbacks set two words, which are tested inline only in jump opcodes. Nothing polls per opcode.
  • for over range compiles to an inline counter, and const() is folded.
  • Line numbers live in a compressed table that is decoded only when an exception is raised.
  • Dispatch is by computed goto, about 10% faster than a switch on rp2.

What eats the gain

  • Type dispatch on every binary operation.
  • Floats are boxed. In Peter's double-precision build a 64-bit double cannot fit in the 32-bit object word, so every float result is a heap allocation from the GC under a mutex (his DEVELOPMENT_NOTES.md says so too).
  • Integers beyond 31 bits become MPZ bignums on the heap.
  • Its flash runs slower than MMBasic's: capped at 63 MHz (divider 6 at 378 MHz, MICROPY_HW_FLASH_MAX_FREQ), against MMBasic's 94.5 MHz. Its VM core is in RAM and its bytecode is in RAM.
  • Its sin is software double precision. The ulab variant, with the series vectorised, still takes 7.4 s, and its README puts the remainder down to sin.
  • BASIC-style module-level code uses LOAD_NAME/STORE_NAME dictionary operations, and builtins first miss in the globals.
  • nlr_push on every call.
  • Its own documentation puts the native emitter at about 2x bytecode. The big factors come only from viper, which uses machine integers.

Implication. A suffix-typed BASIC that adopts these resolution ideas without the dynamic object model has a higher ceiling than 1.3x. An Amdahl projection from the 300% overhead: cutting the overhead by 5x would give about 2.5x on graphics-heavy code and 4-5x on compute kernels. This is unverified.

A consistency check, also unverified. An assignment costs about 1,900-2,270 cycles in MMBasic (6 µs at 315 or 378 MHz), against a few hundred for MicroPython's x=x+1. That predicts far more than 1.3x on scalar code. The arithmetic is the same, so precision does not explain the gap. Two things could:

Either way, MicroPython carries costs MMBasic does not have and still wins. That points at MMBasic's operand handling, not at its arithmetic.

The E pairs in Phase 0 settle it.

Memory-model warning

MicroPython compiles in RAM. The whole-module parse tree is held until every scope has compiled, so peak RAM is O(source). It also keeps bytecode in RAM, for .py and for .mpy (persistentcode.c copies it). Only frozen bytecode, or ROMFS (off by default on rp2), runs from flash. An unverified anecdote: a 100 KB .py ran out of memory compiling on a Pico W, and a 28 KB .mpy used 60 KB of heap.

MMBasic today uses no RAM for program text; only small indexes, such as the IF table and the sub tables, live in RAM. Any compiled form of MMBasic must therefore be written when the program is saved, into flash (or into a RAM slot while testing), beside the text that LIST/EDIT/SAVE round-trip. BASIC can compile line by line with a forward-jump fixup list, so it needs no parse tree.

Do not adopt: dictionary globals, boxing, GC, compiling into RAM, or whole-module parse trees.

3.3 What is already resolved once

So block partners are mostly done already. The remaining gaps are the entry scans and SELECT.

3.4 Binary literals: tried, no gain

Peter stored numeric literals in binary behind a pretend function token, and there was no noticeable improvement. That fits the model. Converting a literal is a small part of a statement whose evaluator still walks the text around it, hashes the names and copies the arguments.

The lesson is general: removing one piece of text parsing buys little while the evaluator still re-walks the text. The gain arrives only when the walk itself stops. Symbols (S5-S6) start that, and a compiled stream (Route B) finishes it.

3.5 The trace cache, and why a per-statement cache fails to pay

OPTION TRACECACHE ON [size[,flags]] is off by default and exists only on CACHE builds (CMakeLists.txt:297-309): HDMIWEB has it, the RP2040 VGA does not. It compiles the right-hand sides of LET, IF, INC, DO and SELECT, and caches jump targets. Entries are keyed on the statement's source address, through a golden-ratio hash with up to 8 probes (MMtrace.c:407-452). A cached statement replays a small typed stack machine.

CaseResultSource
RP2040 PICO build, 252 MHz, 1M-iteration DO UNTIL3,822 ms → 2,204 ms. The equivalent FOR takes 771 ms.option-profiling-cache.md:300-311
Elite on the PC3, 128 → 256 slotsMisses 18,618 → 757; frame 41.9 → 37.7 ms, about 10%. The cache was on in both runs.commit d1b4b96
ExilePhysicsBenchHalves the integrate loop and changes nothing elseExile_Port_Design_Review.html:372
Exile player kernel9.8 → 11.5 ms, slower; wrong result with CollTiles cachedExile_Port_Design_Review.html:405

It fails for six reasons.

  1. The lookup costs about what it saves, and the frame stays.
    • Every hit pays a multiply-hash, up to 8 probes, and a replay with a stack check and a type tag on every op.
    • It skips findvar and the evaluator for one statement. It does not skip the pre-scan, the dispatch or the tail. IF's getargs runs before the cache is even checked (Commands.c:3694 against 3722).
    • The dispatch-level hook TraceCacheTryExec is a stub (MMtrace.c:3376-3381).
    • Even in the best case, a fully cached DO loop is still 2.9x slower than the FOR loop.
  2. The binding lifetime is wrong.
    • One global 16-bit counter, g_local_frame_gen (MMtrace.h:50-59), changes on every frame entry and exit.
    • After any call, every cached statement that uses a local re-resolves it with a linear scan (MMtrace.c:822-878).
    • For SUBs with locals called once a tick this costs more than findvar. That is why the Exile kernel got slower.
  3. The identity is wrong.
    • The key is a buffer address. The stack argbuf, which runs a single-line THEN a ELSE b (Commands.c:3787), and tknbuf, which runs EXECUTE and the prompt, present one key for different code.
    • physics.bas:450 and :452 are both If cy Then X = a Else X = 0. That is the probable cause of the CollTiles result. This comes from reading the code; it has not been reproduced.
  4. The coverage is wrong. It covers expression right-hand sides, including simple 1-D and 2-D array element reads and writes (MMtrace.c:15-18, 1482-1553). Real programs spend their time on calls, array traffic and command arguments ("calls and array traffic, not tight arithmetic", design review :372). It does not cover calls to user SUBs and FUNCTIONs, or command arguments.
  5. It is a second semantics.
    • Eight divergences are known from reading the code:
      • ^ is right-associative;
      • INT^INT goes through pow();
      • >> is an arithmetic shift;
      • NOT 0.4 gives 1;
      • NaN compares differently;
      • float-to-integer conversion truncates instead of rounding in the side paths: the DO fast-path limit, an INC float step on an integer, and SELECT CASE constants (MMtrace.c:3497, 3847, 4135). LET and IF round, as the interpreter does;
      • RND advances twice when a statement bails out;
      • computed GOTO targets are frozen.
    • The DO fast path (Commands.c:5415-5450) is gated only by #ifdef CACHE. On COM16's HDMIWEB build it is live today, with no option set.
  6. Its size must grow with the code, and it never shrinks.
    • The slab is allocated once, at the size the user sets (default 64 entries; MMtrace.c:79-83, 373-377). What grows with the code is the number of slots needed to stop missing. Each slot is 216 B (MMtrace.c:115, 314-329), so Elite's 256 slots take about 55 KB of heap.
    • It compiles on the first visit and never evicts. One bail-out marks a statement BAD for the rest of the run.
    • Every CACHE build pays about 10 KB of RAM-resident code plus 9 KB of flash whether the option is used or not. The HDMIUSB map (buildRP2350L) shows 10,038 B of .time_critical and 9,138 B of .text, 19.2 KB in all.
    • Part of the invalidation its header documents (MMtrace.c:74-77) is not wired up. NEW, RUN and EDIT drop the whole cache (Memory.c:2010; misc/Editor.c:629), and the file-manager relaunch frees it (misc/Editor.c:4440), but DIM, ERASE, OPTION BASE and OPTION EXPLICIT do not invalidate it.

Rules every new step must pass

RuleWhat it requires
R1 IdentityKey work on something carried in the program bytes (a symbol id), or on a small stable integer the interpreter already has (a subfun index, an offset in the immutable stored image). Never on a buffer address.
R2 WhenCompute static facts when the program is saved. Change dynamic bindings only at the events that change them: a variable created or destroyed, ERASE, CLEAR, RUN, CHAIN. Each event gets one reset point and a golden test. No global frame counter, and nothing re-derived per execution.
R3 Replace, don't prefixA fast path replaces the slow path it shortcuts. The only miss allowed is a name that is genuinely unbound, and a miss costs no more than today.
R4 One semanticsReuse evaluate, doexpr, the op_* functions and the runtime pairing scans. Anything new ships with a shadow mode that checks it against the old path.
R5 MemoryRAM is fixed, or O(unique names + call depth). Facts whose size grows with the code live in the stored image. The image may not grow beyond measured slack below MAX_PROG_SIZE, and today's largest programs must still run.
R6 CoverageTarget a bucket the measurements show is large, including polling and argument passing, not only expressions.

How symbol ids differ from the trace cache

A symbol id identifies a name as it appears in the program text. It does not identify a variable, and it does not identify a statement address.

The trace cache bound results to a statement address and invalidated them globally on every frame change. Ids bind to a name and change only on the events listed above.

The plan ignores the trace cache from here on. Peter will probably remove it from main, because mmb2csub is far more powerful. Nothing below measures it, keeps it or tries to match it; it appears in this document only as the lesson above. Removing it frees 19.2 KB of text on the CACHE builds, about 10 KB of it RAM-resident (HDMIUSB map in buildRP2350L). It also removes the always-on DoFast path and its truncation bug (§7).
4 · Method

The review method

Measure on both boards, lead with one real program, compare every interpreted number with its native twin, and state in advance which result would kill each hypothesis.

4.1 The anchor benchmark: solar_eclipse.bas

Bas/solar_eclipse.bas is byte-identical to Testfiles/solar_eclipse.BAS. It is 3,213 lines and 70 KB, of which about 44 KB is non-comment text, in 33 routines. It times itself: Timer =0 is at line 224, and "Time taken" is printed at line 242. The key routine is Sub sefunc(x, fx) at line 248. With its callees it covers 15 of the 20 profiled routines and 99.8% of the calls (docs/mmb2csub.md §9).

Reference inputs (Peter), typed in this order at the five prompts: 12,1,2000 · 39,40,36 · -104,57,12 · 1644 · 30. They give the 25 December 2000 eclipse. Golden output: JD 2451904.14541560, .19690359 and .25420764, the same to the last digit in MMBasic, in the CSUB version and in Peter's MicroPython port (boards/PICO_COMPUTER_3/tests/README.md in that tree). Every run uses these inputs and is checked against these digits.

ConfigurationBoard and conditionTimeSource
MMBasic, interpretedPC3, 378 MHz, reference inputs; build not recorded (July 2026)~12.5 sPeter's MicroPython tests/README.md
MMBasic, interpretedPC3 on COM16, HDMIWEB, 378 MHz, WiFi live, HDMI running14.6 sPeter, 2026-09-24
MMBasic, interpretedRP2350B, 252 MHz13 smmb2csub.md:668-672
sefunc as a CSUB (--library --lean, 37 KB blob)RP2350, 378 MHz2.48-2.5 sPeter; mmb2csub.md
sefunc as a CSUBRP2350B, 252 MHz2.7 smmb2csub.md
sefunc as a CSUBRP2040 PicoMiteVGA5.7 smmb2csub.md
MicroPython, tests/solar_eclipse.py (line-faithful port; double floats, MPZ integers)PC3, 378 MHz (flash at 63 MHz, divider 6)~8.8-9 sPeter; MicroPython tests/README.md
MicroPython, tests/solar_eclipse_ulab.py (series vectorised with ulab)PC3, 378 MHz~7.4 ssame README: the rest is software double sin
MMBasic, interpretedRP2040 VGA on COM7not measured—

What each ratio means:

What the anchor exercises (from reading the source):

Log with every anchor run:

4.2 Phase 0: harness, profiler, goldens

About 1-2 weeks (a guess). No behaviour change.

0aASK

Questions first

Peter's rule is to ask before spending a build cycle on something he may already know. The questions are in §8. Already answered by Peter:

  • COM16 runs HDMIWEB (14.6 s on the anchor), and any RP2350 build can be flashed onto it. PICORP2350 is the fastest, HDMIWEB probably the slowest. All auto-configure as a Pico Computer 3, but the CPU clocks must be checked for equivalence (MM.INFO(CPUSPEED)). A non-USBKEYBOARD build also brings up a CDC console on the USB lead used for flashing.
  • The anchor's reference inputs and golden output (§4.1).
  • The MicroPython build, a double-precision, MPZ-integer PC3 port at \\wsl.localhost\Ubuntu\home\peter\src\micropython\ports\rp2 (board PICO_COMPUTER_3), runs on a board on COM9. That board is a "PC2": the same hardware as the PC3 except that it has no WiFi module. So the like-for-like comparison is MicroPython on COM9 against MMBasic's HDMIUSB build on COM16, at the same CPU clock. Its tests/solar_eclipse.py is a line-faithful port of the anchor.
  • The mmb2csub toolchain is in mmb2csub/user-tools/ (mmb2csub.py, mmb2c.py, armcfgen.py). The corpus is at \\wsl.localhost\Ubuntu\home\peter\src\FUZIX\Applications\mmb2c\tests: 112 .bas programs and 90 .expected outputs. Claude can build every CSUB twin and run the goldens without Peter.
  • The trace cache is out of scope (§3.5).
0bNO BUILD

The no-build matrix on the anchor, first

One session per board. Peter runs it, or Claude drives a board only after Peter has said it is free. The anchor is delivered as a harness copy with the inputs as constants: by TFTP on the PC3, by paced serial on COM7.

  • The same anchor on PICORP2350, HDMIUSB and HDMIWEB on COM16, at one CPU clock, checked with MM.INFO(CPUSPEED) and set with OPTION CPUSPEED nnnn where the build allows it (the HDMI builds take their clock from OPTION RESOLUTION). Which build becomes the reference for the gates is decided from these results, and HDMIWEB with WiFi connected against disconnected. The board stays the same and only the build changes, so the differences price HDMI scan-out and the per-statement ProcessWeb (H5) directly. Reflashing only; no code change.
  • OPTION NOCHECK ON against OFF. The timed region is finite, so this is safe.
  • OPTION PROFILING ON, as it is today, gives the command mix, the local/global split of findvar calls and SUB call counts, plus per-SUB times on CACHE builds such as HDMIWEB (0e). The anchor ends with End (line 243), so the report prints. Timed runs are made with profiling off.
  • COM7. Run the anchor interpreted, which has never been measured; the 70 KB source fits the ~93 KB LOAD buffer. Run the CSUB too; 5.7 s is recorded.
  • Re-measure the CSUB on COM16's HDMIWEB build before computing any fraction against 14.6 s.
0cNO BUILD

BASIC and Python suites

Every micro-benchmark runs about 100k iterations. The empty-loop baseline is subtracted, the median of 5 is taken, and runs are interleaved ABAB. Every file ends with END and prints BENCH name iters us cpu_mhz device.

  • Micro set:
    • E0: empty FOR and DO loops, also under NOCHECK.
    • E1: s%=(s%+i%) AND 65535.
    • E1b: the same with 1- and 30-character names, and with a 40-character trailing comment.
    • E2: x=x*1.000001+0.5.
    • E3: 1-D and 2-D array reads and writes.
    • E4: string building, MID$ and STR$, with the heap empty and 80% full.
    • E5: a SUB with no arguments; a FUNCTION with 2; 6 parameters plus 4 LOCALs; a SUB creating LOCAL arrays.
    • E6: ABS and SIN.
    • E7: IF/ELSEIF chains; SELECT, first arm against last.
    • E8: a PIXEL loop over a framebuffer.
    • E9: BOX and LINE, a control whose ratio to MicroPython should be about 1.
  • Macro set: the anchor, plus the secondary benchmarks listed in §4.1.
  • Interpreted and CSUB pairs from the mmb2c corpus, run on identical inputs. Outputs are hashed, and a pair is compared only when the outputs agree.
  • MicroPython pairs on COM9, using Peter's double-precision PC3 port; tests/solar_eclipse.py is the anchor's twin. The MMBasic side of every pair runs on the HDMIUSB build on COM16 at the same CPU clock: the same hardware, no WiFi on either side. Each test is written twice, at module level (which mimics BASIC) and inside a def, and run from .py and frozen.
  • Driver: pc3.py on COM16 and COM7, with serial lines paced at 20 ms or more. Never NOCHECK in an unbounded loop.
  • PSRAM caveat: array micro-benchmarks on the PC3 may measure PSRAM, because large allocations spill there (Memory.c:2147).
0dGOLDENS

Correctness baselines

  • The mmb2c corpus's .expected outputs, run on the board.
  • A new PASS/FAIL core-semantics suite. None covers core semantics as a whole; the per-feature PASS/FAIL tests in Testfiles/ (SelectCaseTest.bas, doloop_test.bas, SubParamTest.bas, ErrLineTest.bas, ArrayCopyTest.bas, ArrayFuncTest.bas, MathClampTest.bas, StructMemberPassTest.bas) are a starting point. It covers:
    • expressions and type promotion, including ^, NOT, >> and NaN;
    • FOR/NEXT edge cases, SELECT, GOTO/GOSUB/labels and READ/DATA;
    • ON ERROR SKIP, interrupts, EXECUTE and CONTINUE.
  • A LIST/SAVE round-trip byte comparison over Bas/, PoP and Elite, needed before any format step.
  • A "never slower" micro set.
  • The hash of the anchor's output.
0eBUILD EACH

Extend OPTION PROFILING

Peter, 2026-09-24: "remember you have OPTION PROFILING ON to help with analysis and this could be extended as required". It is the main Phase 0 instrument. Claude builds, only when Peter says the shared build directory is free.

What it records today:

  • OPTION PROFILING ON|OFF is parsed at core/MM_Misc.c:5327-5347. ON allocates the counters from the heap (ProfilingAlloc, core/MMBasic.c:171-201) and OFF frees them (ProfilingFree). Every increment is gated, so when it is off it costs only the gate tests.
  • Dispatch counts per command token (MMBasic.c:1896-1897), the number of user SUB dispatches, findvar calls split into local and global, and call counts per SUB.
  • Inclusive and exclusive microseconds per SUB and FUNCTION (a FUNCTION's time is added up, but it is listed with 0 calls, and the table prints only if at least one SUB was dispatched as a command; MMBasic.c:1913-1915, 2504, 2705; Commands.c:4292-4299). These are taken in EnterLocalFrame/LeaveLocalFrame, inside #ifdef CACHE (MMBasic.c:235-311), so they exist only on CACHE builds: HDMIWEB has them, COM7's VGA build does not.
  • The [PERF] report is printed only by cmd_end (Commands.c:4161-4380), so the program must reach END.

Extensions. Each is selected by an argument to OPTION PROFILING, so it needs no new token and no option_s byte. They are added one at a time, as the hypotheses need them:

  • per-SUB timing on every build, not only on CACHE builds;
  • FUNCTION call counts (their times are already recorded on CACHE builds);
  • a per-line time histogram keyed on CurrentLinePtr, and a per-command time histogram, both sampled in the 1 ms timer_callback (PicoMite.c:2332). Sampling also catches time spent in built-in functions and operators;
  • counters: bytes walked by skipelement, getclosebracket and makeargs; temp pages allocated; FindSubFun misses; calls to, or time in, CheckAbort and check_interrupt; LOCAL arrays created;
  • a machine-readable report format for the host script.

Profiling perturbs timing. Timed runs are made with profiling off, and profiled runs are used only for shares.

Fallbacks, only if the extended PROFILING data leaves H1 open: a PC/LR sampler on a spare hardware alarm, and SWD DWT_PCSR sampling if Peter already has a probe working.

0f1 BUILD

Placement floor

A no-op size-change rebuild, measured on the anchor itself and on knivd. A claim on a macro benchmark must exceed twice this floor, or be proven on the step's own micro-benchmark.

0g1 BUILD

Name-lookup ceiling (throwaway)

A build with findvar memoised by program-text address and no invalidation. It gives an upper bound for S5-S6 before any real work is done.

It must be restricted to globals and scalars, or keyed on the address plus a frame serial. Otherwise sefunc's per-call LOCAL arrays would hand back freed pointers: wrong answers, or a crash. The build is never shipped.

Moved out of Phase 0: the VM-as-CSUB prototype. It only informs the Route B decision, and it is confounded (M0+ code on the M33, big blobs running from XIP, doubles through the CallTable). It now sits at G3.

Gate G0: the harness is trusted when
  • the spread of the median of 5 is ≤ 1% on micro-benchmarks and ≤ 2% on macro-benchmarks;
  • the placement floor has been measured on the anchor;
  • every golden passes on today's firmware on both boards;
  • the anchor has been measured interpreted and as a CSUB on one build per board.

4.3 Phase 1: characterisation

About 1 week (a guess). Both boards.

The output is one table per workload per chip:

Each of H1-H10 is marked confirmed or killed by the thresholds below.

4.4 Hypotheses, and the experiment that confirms or kills each

#HypothesisExperimentConfirmKill
H1Operand work in the evaluator is at least half of the overhead in compute code. That covers the getvalue/doexpr recursion, marshalling through globals, and re-walking names and brackets.Extended OPTION PROFILING (line and command time histograms, 0e counters) on the anchor, julia and fixed-work knivd; E1 − E0 per statement; the 0g ceiling separates lookup from evaluationEvaluator + lookup + text scan ≥ 50% of the overhead; (E1 − E0) ≥ 3 × E0 per statementEvaluator core under 25%
H2Name resolution is 20-40% of statement timeE1b, 1- against 30-character names; top level against inside a SUB against recursion depth 10; a CONST read inside a SUB (the anchor's pattern); COM7 y=a(i) with 0 and 200 dummy SUBs; the 0g ceilingSlope ≥ 10 cycles per character (PC3) or ≥ 15 (COM7); ceiling ≥ 15% on the anchorSlope under 3 cycles per character and ceiling under 8%
H3Call machinery, including LOCAL arrays created per call, dominates code built from SUBsE5: 0, 3 and 6 parameters, with and without AS INTEGER; a sefunc-shaped SUB with 7 LOCAL arrays against the same using global arrays; call counts from PROFILINGDefinition-side parse plus zeroing ≥ 25% of call cost; call bucket ≥ 20% of the anchor or game samples; LOCAL-array form ≥ 1.3xDefinition side under 10% of call cost, and LOCAL-array creation under 5% of a call
H4Marshalling the arguments of built-ins and commands is the 300% in firmware-heavy codex=SIN(y) minus x=y; SIN nested 1 to 5 deep; PIXEL x,y,c against PIXEL 10,20,1; LINE and BOX likewise; temp pages per statementVariable form ≥ 1.5x the literal form; cost grows faster than linearly with nestingVariable form under 1.2x the literal form
H5The per-statement tail is 10-25% of simple statements. It is larger on HDMIWEB, where ProcessWeb runs on every statement, and larger with any interrupt armed or GUI controls present.0b's WiFi and NOCHECK A/Bs; E0 and A=A+1 under NOCHECK; each again with SETTICK armed and with GUI controls allocated; the 0e CheckAbort/check_interrupt counterE0 ≥ 15% faster under NOCHECK; anchor ≥ 3%; SETTICK case ≥ 8%; WiFi-off delta ≥ 2% on the anchorAnchor under 2%, SETTICK under 4%, WiFi delta under 1%
H6Flash fetch through the XIP cache is a large cost.Not pursued. Peter: flash timing is not very relevant, because its effect is confounded by the QMI cache. Placement effects are already known to be about 4%, and every gain must beat that floor.
H7The pre-scan over trailing comments and spaces costs at least 5% on commented codeE1b: A=A+1 against the same with a 40-character trailing comment and with 40 spaces; DATA with a 60-byte list (a cmd_null statement)≥ 5% per 40 bytes (about 5 cycles per byte)Under 2%
H8Block-entry scans matter in real programsNested FOR, inner count 2 against 100, body 40 against 400 B; SELECT with 30 arms, first against last; COM7 GOSUB to a label on line 10 against line 1000; histogram share of FOR, DO and SELECT linesBlock and jump lines ≥ 3% of macro time, or the COM7 label case ≥ 2xUnder 1% on every macro
H9Temp-memory churn is ≥ 10% of string and built-in code, and depends on how full the heap isE4 with the heap empty against 80% full≥ 1.15x slower with the heap fullUnder 1.05x
H10MicroPython's lead comes from scalar, array and call mechanisms, not from work done in CPairs E0-E9, module-level and def versions of each Python test; Peter's own 30% pair firstE1, E3 and E5 ratios much larger than E0's; E9 about 1Ratios flat across the tests (a uniform dispatch cost). Then H5 and H7 move up.
5 · Options

Candidate architectures compared

Five strategies were written independently and then judged. None is adopted as written. The recommended programme takes the measurement discipline from one, the identity layer from another, a binding mechanism from a third, the yardstick from a fourth, and the design rules for a later compiler from the fifth.

measure-first: keep the text, add side tables

Profile first. Then remove structural repetition inside shared primitives, using tables built at PrepareProgram as the IF table is:

  • amortised polling;
  • a FOR/DO/SELECT structure table;
  • array indices evaluated in place;
  • call descriptors;
  • a binding cache.

RP2040: the tables compete with the 100 KB heap, and findvar and the FOR, IF, NEXT, DO and INC handlers run from flash. RP2350: a 12 KB binding cache.

Kept: the whole Phase 0/1 skeleton and the attention word.

Changed:

  • Its binding cache is keyed on the address of the name text, which is the trace cache's key shape. Symbol ids replace it.
  • Its heap structure table becomes a partner table stored in the image.

prepass: link the program at RUN

One pass at RUN builds a side structure in the heap:

  • a statement spine;
  • block links;
  • call descriptors;
  • symbols with shallow binding;
  • operand sites.

Cost: by its own estimate about 0.4 KB of heap per KB of program for the spine and links, and 0.9 KB per KB with sites. The speed-up disappears exactly on large programs.

Kept:

  • Its shallow binding (L5a), keyed by symbol id.
  • Its rule that the builder calls the runtime pairing scan, which keeps one semantics.
  • Its ceiling hack, made safe (0g).

bytecode: compile units to a typed VM

SUB bodies and hot loops compile to 16-bit typed wordcode in the heap. At every statement boundary, the state is the same as the text interpreter's.

Highest ceiling: 2.5-7x on kernels (its estimate).

Costs:

  • A second engine.
  • 15-25 KB of flash, which non-CACHE RP2040 builds do not have.
  • A heap budget of 8-16 KB on VGA that caps coverage.

Kept as Route B's design rules:

  • state equivalence at statement boundaries;
  • loops reuse the interpreter's stacks;
  • entry by subfun index;
  • the value splice;
  • shared operator helpers with a shadow mode;
  • the VM-as-CSUB prototype (at G3).

It is only acceptable as a stream written at save time into a slot, never compiled into the heap.

native: the CSUB boundary

This is not an interpreter speed-up. It contributes the yardstick, removed(X) against the CSUB floor, and the mmb2c corpus as a semantics oracle.

Its firmware items are cheap and touch only CSUB paths, at about 150-300 B each (its estimates):

  • F1: a CSUB call table indexed by subfun.
  • F2: a CSUB that returns a result, so a FUNCTION needs no interpreted wrapper.

Offer these separately from this plan.

format: symbol ids in the stored image

Repeated names become 2- or 3-byte references into a name block kept inside the image. The block holds each name's spelling, a hash16 identical to the text path's, and a static binding for every SUB, FUNCTION, CSUB and label. The tokeniser emits ids only in commands flagged as audited.

Costs:

  • RAM: under 1.2 KB, fixed.
  • Save-time scratch is borrowed from g_vartbl: 26,880 B on VGA and 47,104 B on the PC3.
  • Firmware: 3-5 KB (estimate); VGA has 13 KB free.

Speed alone is small: 3-20%, its estimate. But it is the identity layer every later step needs, and the only proposal that makes the stored program smaller.

Its gap: the speed-up rests on findvar still probing. Shallow binding fills it.

The judge's scorecard

On every axis 10 is best. For compatibility risk, 10 means lowest risk; for effort, 10 means least effort. These are judgements of each strategy as written.

StrategyGainRP2040 feasibilityProgram capacityCompat. riskEffortIncrementalMeasure firstTotal /70
measure-first5878891055
native396799851
format4710558847
prepass642657838
bytecode934326734

Bytecode's capacity score of 4 is for compiling into the heap. Moved to a stream written at save time into a separate slot, as Route B does, it costs nothing from the program area or the heap beyond names and call depth.

Program capacity: what each costs per KB of user program

"Heap" here means heap at run time, which comes out of the user's variables. Today both boards are limited by the source text that LOAD holds in the heap: GetTempMemory(EDIT_BUFFER_SIZE - 2048) (misc/FileIO.c:4345; configuration.h:561). That is about 93 KB on the VGA and about 137 KB on the PC3 at 640 pixels wide, less at the wider HDMI modes. A smaller image becomes a bigger program only once LOAD tokenises while it streams the file.

StrategyRAM per KB of programProgram flash per KBLargest program, RP2040 VGALargest program, PC3 HDMIWEB
measure-first, as writtenStructure table 16 B per block statement (about 0.1-0.2 KB/KB); binding cache fixed at 3 KB (VGA) or 12 KB (PC3); about 8 B per parameter0Limit unchanged, but a 90 KB program loses roughly 12-21 KB of variable heap unless the tables switch themselves off (estimate)Limit unchanged; roughly 26-40 KB of heap on a 140 KB program (estimate)
prepassAbout 0.4 KB/KB (spine, links, calls); about 0.9 KB/KB with operand sites (its own estimate: 12 KB and 28 KB for a 30 KB program)0A 90 KB program needs about 36 KB of heap, or about 80 KB with sites. The speed-up is lost on large programs.A 140 KB program needs about 56 KB, or about 126 KB of the 144 KB heap
bytecode, as written1.0-1.5x the compiled statements' size plus records, capped by a budget (8-16 KB VGA, 32-64 KB PC3)0 in the image; firmware +15-25 KBLimit unchanged; coverage stops at about 5-10 KB of hot codeLimit unchanged; coverage stops at about 20-40 KB of hot code
native0 for the interpreterA converted routine costs about 3.4x its blob in program memory: the hex text (about 2.4x) plus the binary built from it (1x). For sefunc's 37 KB blob that is about 121 KB (FileIO.c:3830; mmb2csub.md A.8). In the single library slot it costs 1x (mmb2csub.md §9)Interpreter unchanged; conversions are opt-insame
formatFixed, under 1.2 KB; +2 B per name for the optional slot cacheNegative on large programs. Emulator run for this review: Elite 110.4 → 101.5-105.5 KB (−8.1% to −4.4%); PoP player.min.bas 137.7 → 120.4-124.4 KB (−12.6% to −9.7%), or −14% to −17% with indent runs. The range depends on whether the block carries a 4 KB lookup index. The anchor comes out +0.6% to +2.1% (21% comments, 16% names).About 93 KB of source, unchanged until LOAD streams; then the image saving becomes capacityAbout 137 KB of source; after streaming LOAD, roughly 10-17% more PoP-style source in the same image
Recommended: Route A in the image + Route B stream in a slotAbout 4-8 B per unique name, plus a fixed array per variable slot and frames proportional to call depth. Elite's 876 names cost about 3.5-7 KB (2.4-4.9% of the PC3 heap). The IF table leaves the heap.Symbols shrink large images; descriptors, partner tables and a small section header add to them. Each step measures the net change against the slack below MAX_PROG_SIZE. The Route B stream takes nothing from the program area.Unchanged (about 93 KB of source), provided the stream is no larger than the image (a G3 condition)Unchanged (about 137 KB); PICORP2350 likewise

The emulator is measure.py in the session scratchpad: an approximate re-implementation of tokenise(), good to about ±2-3%. It was re-run for this document on Bas/elite/elite.bas, Bas/sandglass_tools/player.min.bas, Bas/solar_eclipse.bas and Bas/exile/*.bas.

6 · Plan

The recommended programme

Measure; characterise; then make the text cheaper one mechanism at a time, with every static answer stored in the image. Build a compiled stream only if Route A leaves a large, measured overhead and a prototype shows the stream can remove it.

P01-2 WK

Harness, profiler and goldens

Both boards. See §4.2. Ends at gate G0.

P1~1 WK

Characterisation

Both boards. See §4.3. Produces the per-workload tables and marks H1-H10.

G2DECIDE

Decision gate: the evidence picks the order

  • Tail (H5) ≥ 5% of the anchor or of any macro on either board: S1 comes first. S1a applies if the WiFi A/B shows ProcessWeb is the cost.
  • Calls (H3) ≥ 20% of the anchor or game samples: S3 comes before the symbol work.
  • Name ceiling (H2) ≥ 15% on the anchor: the symbol track (S5-S6) is a go.
  • Block scans (H8) ≥ 3%, or the COM7 label case ≥ 2x: S7 is a go.
  • Argument copies (H4) ≥ 20% on the graphics pairs or on the anchor's SIN/COS: S4 and S8 are a go.
  • If tail, names, calls, copies and scans together come to under 30% on both boards, and the evaluator core is over 50%: do only S1 and S2, then go straight to G3.

Route A: cheaper text, one step, one build, one commit each

Every step runs the goldens, the micro and macro sets, the never-slower set and a capacity check, on both boards. The capacity check has two parts: the image size from "Saved nnn bytes", and MM.INFO(HEAP) free at RUN for the largest corpus programs.

StepWhatTargetsCapacity costBoards
S1aProcessWeb moves behind the same 100 µs cadence as routinechecks, on WEB builds only. Verified with the WebMite TCP tests.H5, priced directly by the WiFi A/B0COM16 (HDMIWEB)
S1bAn inline raw-timer compare in front of the call to routinechecks. It keeps the 100 µs cadence exactly and removes the call, the register push and time_us_64 from most statements.H5 (NOCHECK A/B)0; a few bytes of RAM codeBoth
S1cAn interrupt pending word.
  • ISRs set it: the ticks at PicoMite.c:2409-2416, and WAV, IR, CSUB and key completion.
  • Sources that can only be polled (pins via ExtInp, COM rx, PIO levels) set an armed-class mask. checkdetailinterrupts then tests only the armed classes, still on every statement.
  • A shadow build counts any case where the full scan fired and the gate did not.
  • The GPIO IRQ path is not touched.
H5 (SETTICK A/B)0 per KB; about 0.2-0.4 KB of flash (estimate)Both
S2RP2040: cache the subfun[] count at PrepareProgram instead of counting on every call and every array read (MMBasic.c:2027-2028).H2 (COM7 with 200 dummy SUBs)0COM7 first
S3aAn image-section container: an append-only trailer after everything the image holds today, including CSUB and font data, with a magic number and a directory.
  • Every image writer produces it.
  • Images without it run the text path.
  • It is appended, never inserted, because CSUB addresses are offsets within the image (PicoMite.c:4705-4715).
Enables S3b, S5 and S7A few bytes per imageBoth
S3bCall descriptors, written at save time into the image and indexed by subfun. Each holds the parameter names, types, BYVAL/BYREF and array flags. They replace makeargs over the definition (MMBasic.c:2398) and the per-parameter tests.H3About 20-40 B per SUB in the image (estimate); 0 RAMBoth; Exile and PoP on the PC3
S4Array indices are evaluated in place. evaluate already stops at , and ) (MMBasic.c:2908-2911). findvar is changed to return its end pointer as well, so skipvar is not needed. Today it returns only the value pointer (MMBasic.c:4657), and callers use skipvar.H2, H4 (arrays)0Both
S5aThe name block and save-time dictionary are written, but no references are emitted yet. Round trip verified on every writer.InfrastructureTemporary image growth, measuredBoth
S5bLabels and GOTO/GOSUB/RESTORE targets become symbols, and llist decodes them. findlabel on the RP2040 becomes O(1).H8 (COM7 label case)Image shrinks slightlyCOM7 first
S5cSUB, FUNCTION and CSUB definitions, calls and name( become symbols with static bindings. This removes the FindSubFun hash, count and binary search.H2, H3Image shrinksBoth
S6Variables become symbols with shallow binding, flagged one command at a time with LET first. Details are below this table.H2About 4-8 B per unique name plus fixed per-slot arrays; image −4% to −13% on the largest programsBoth
S7A partner table in the image, built by the same runtime scan function.
  • It covers FOR→NEXT, DO→LOOP, SELECT→CASE arms→END SELECT, and literal GOTO/GOSUB/RESTORE targets.
  • The IF table moves from the heap into the image.
  • Computed targets stay dynamic.
H8Image grows (unmeasured; charged against measured slack); IF-table heap freedBoth
S8Built-in arguments in place for an audited set of functions, starting with single-argument numeric ones (SIN and COS for the anchor). The temp page and memcpy are skipped.H40Both
S9Only if H7 is confirmed: extend the existing skip byte, for example by making a trailing comment its own element so the comment-line fast path jumps it.H7About 2 B per trailing commentBoth

S6 in detail.

Not in the plan unless the measurements ask for it. Four hand micro-optimisations are left out: a static argument pool, zeroing sized by argument count, an early exit from ClearVars, and a register-local skipelement. Each is likely below the ~4% placement floor on the anchor. One is allowed only when the histogram shows its bucket at 2x the floor or more. Caching the subfun count (S2) is different: it is algorithmic, O(N) to O(1).

Kill rules for every Route A step
  • It delivers under 50% of its predicted gain on its own micro-benchmark.
  • Any macro benchmark is slower beyond the placement floor, or any never-slower micro regresses by more than 2%.
  • Any shadow-build mismatch that is not explained within the step.
  • Any corpus program stops fitting on COM7, or the image grows past measured slack.
  • RAM-resident growth crosses a 4 KB page with nothing removed to offset it.
When Route A is done or stops
  • Done: Route A has removed ≥ 37% of the anchor's overhead on the PC3 (MicroPython's bar; on the reference build, re-measure the interpreted and CSUB times first and take 37% of the difference) and ≥ 30% on COM7, with no macro slower. Go to G3.
  • Stop: under 15% removed on both boards after S1-S4. Stop adding interpreter tables and go to G3.
  • Firmware flash: VGAUSBRP2350's margin is not known until it is built again; it outgrew 1040 KB and moved to 1056 KB (commit 564fcff, 2026-09-21). Removing the trace cache, which Peter plans for main, frees 19.2 KB of text on every CACHE build, VGAUSBRP2350 included. The RP2040 VGA has about 13 KB.
G3DECIDE

Stop, or Route B

Route B is a go only when all of these hold:

  1. Overhead is still large. After Route A, the anchor's remaining overhead (Tinterp,A − Tcsub, same build and condition) is at least twice the CSUB time on both boards. On the PC3 that means at least 5 s.
  2. The prototype pays. The prototype is the VM as a CSUB, moved here from Phase 0: about 300 lines of C built through armcfgen, with hand-written wordcode for an integer loop, the Julia inner loop, an array routine and one sefunc callee. It must be ≥ 3x faster than the post-Route-A interpreter on the integer and array routines on both boards. Under 2x kills Route B. Its confounders are stated with the result.
  3. The subset covers the work. A census, weighted by the line histogram, shows ≥ 70% of executed statements on the anchor and the game macros fall in the compilable subset.
  4. Capacity. A host-side encoder shows the stream is no larger than the tokenised image for every corpus program (PoP, Elite, the anchor, Exile). Any program that fits today then gets its stream into one slot of MAX_PROG_SIZE, and RAM stays O(unique names + call depth).
  5. Firmware fits. On CACHE builds, the 19.2 KB freed by removing MMtrace pays for the VM. On the VGA it must fit within about 13 KB. Any 16 KB step is Peter's decision at that point.

The Exile kernel pair (9.8 ms against 0.1 ms) is a second floor. It checks that the anchor's result generalises to SUB-with-locals code.

RBIF G3

Route B: a compiled statement stream, written at save time

  • Where it is written. Save already tokenises line by line (tokenise(false) at PicoMite.c:4788). The compiler emits alongside it and appends a forward-jump fixup table, so there is no whole-program parse tree.
  • Records point back to the text for errors, TRACE and LIST. The per-line state (CurrentLinePtr, TraceBuff) is derived from the instruction pointer where possible.
  • Names and binding. Names are S5/S6 symbols, and binding uses the S6 table, so RAM stays O(unique names + call depth).
  • Typed operations with unboxed values. Types come from the suffix or from the binding (OPTION DEFAULT, DIM, LOCAL). Operations call the shared op_* helpers.
  • Loops reuse g_forstack, g_dostack and gosubstack. State is identical to the text interpreter at every statement boundary, so interrupts, ON ERROR, CONTINUE and EXECUTE work unchanged. S1c's pending word is the only per-statement test.
  • Fallback and splice. Anything not compiled falls back to its handler. Whitelisted commands take pre-evaluated arguments through the value splice.
  • Shadow mode: an OPTION argument, not a new token.

Delivery, each phase with its own gate:

  • P1 records only, calling the existing handlers. Gate: goldens pass and nothing is slower than text.
  • P2 numeric LET/IF/FOR/DO/EXIT on globals. Gate: shadow mode clean, and the prototype's speed holds in firmware.
  • P3 locals and calls. Gate: the Exile kernel matches all 2,384 ticks of run_phystest.py, and the anchor improves.
  • P4 arrays, SELECT, GOSUB, literal GOTO and simple strings.
  • P5 the value splice for SIN, COS and the other pure built-ins, then PIXEL, LINE and BOX.

Where the stream lives while testing:

  • PC3: a RAM slot. RAM 1-4 are image slots 4-7; RAM 5 holds the RAM library (configuration.h:400-410; ImageSlotAddress, misc/FileIO.c:1071). Each slot is MAX_PROG_SIZE. The stream is rebuilt at each save or RUN and is lost at power-off. This avoids repeated flash burns.
  • Timing check. PSRAM and flash both sit behind the XIP cache, with different timing. Before any figure is quoted as the flash result, it gets one confirming run from a flash slot.
  • RP2040 (no PSRAM, no RAM slots): flash slot 1 or 2, since slot 3 is the library. Iterations are batched to limit erases.

Where the stream lives once the concept is proven (in production, one of the user's flash slots while compiled mode is in use) is deferred until then. The capacity-optimal end state, a single executable image that LIST decompiles, is noted but not started.

Who does what, and on which hardware

ActivityPeterClaudeHardware
§8 questionsAnswers the remaining questionsAsks once—
0b no-build matrixRuns it, or frees the board; sets the resolution (which sets the PC3's clock) and WiFi; flashes the build to compareWrites the anchor harness copy with constant inputs; delivers it by TFTP (PC3) or paced serial (COM7); logsCOM16, COM7
0c/0d suites and goldensConfirms each board is free; types RUN on any board he is usingWrites the BASIC, the Python and the goldens; drives only free boards through pc3.pyCOM16, COM7; COM9 for MicroPython
0e PROFILING extensions; 0f-0g buildsSays when the build directory is free; flashes, or approves flashingOne PROFILING argument per build, added as a hypothesis needs it, never concurrent with his builds; a host script that reads the machine-readable reportBoth
Phase 1 and G2Decides with the dataWrites up the tables and a recommendation—
Each Route A stepReviews before landing; flashes; decides flash stepsOne step, one build, one commit; goldens, benchmarks and capacity check; reportsBoth; games on the PC3; labels and subfun on COM7 first
G3Decides go or no-goVM-as-CSUB prototype; host-side stream-size encoder; censusBoth
Route B, P1-P5Reviews each phase gateImplements; shadow mode; RAM-slot runs on the PC3; batched flash-slot runs on COM7Both

Effort, all guesses: Phase 0 1-2 weeks; Phase 1 about a week; S1-S4 a few days each including board runs; S5-S7 several weeks together; Route B 3-5 months part-time, the bytecode strategy's own estimate.

7 · Compatibility

Risks and compatibility checklist

AreaWhat can breakHow the plan guards it
EXECUTE, the prompt, CALL by stringCode in tknbuf or argbuf; names that never appear in the program textNothing is keyed on a buffer address (R1). Symbols travel with copied bytes. Names absent from the text take today's by-name path. Console tokenising looks the name up in the block, or emits raw text.
ON ERROR SKIP/IGNOREThe per-statement setjmp (MMBasic.c:1925-1938), the skip count, and its save and restore across interrupts (MM_Misc.c:10529-10533)Unchanged in Route A. Route B arms one setjmp per unit entry and keeps its program counter in memory. Covered by the golden suite.
InterruptsA source that sets a polled flag but not the pending bit would lose an interrupt. Pin interrupts are sampled with ExtInp once per statement.Polled classes are still tested on every statement. A shadow build counts misses. The nextstmt redirect and IRETURN are unchanged. The GPIO IRQ path is not touched.
Interrupt frame accountingInterrupt entry does g_LocalIndex++ twice with no EnterLocalFrame (MM_Misc.c:10528, 10552). cmd_return calls LeaveLocalFrame (Commands.c:5957-5959).S6 binding tests the live g_LocalIndex, which balances, and never uses the CACHE-only frame stack.
LIST, EDIT, SAVE, AUTOSAVE, XMODEMSymbols or sections that do not round-tripThe byte round-trip golden runs before and after every format step. llist decodes symbols. Commands are flagged one at a time.
Every image writerA writer that drops the new sectionsSaveProgramToFlash (PicoMite.c:4716), SaveLibraryImage, FLASH SAVE and RAM SAVE must all carry them; each is tested. SaveLibraryImage writes both libraries: the flash library with base=NULL and the RAM library with base=rambase (MM_Misc.c:2419, 2370); its CSUB fix-up is at FileIO.c ~4280-4312. RAM SAVE is a whole-slot copy (FileIO.c:865-875). RAM RUN and RAM CHAIN run a slot in place and write nothing (FileIO.c:917-958). Images without the sections run the text path.
LIBRARYThe library lives in flash slot 3 or in the PSRAM RAM slot. LIBRARY LOAD ,RAM must be the program's first statement (MM_Misc.c:2302-2333). It does a RUN-style ClearRuntime(true), then PrepareProgram, then restarts the program (MM_Misc.c:2377-2380).Library and program symbol blocks are merged, as IfTableBuild already handles two slices (Commands.c:3514-3527). The library load is a binding reset event, like RUN.
CHAIN; RUN "file"CHAIN: SaveContext copies g_vartbl, g_hashlist and the heap to PSRAM or to /.vars on A: (Commands.c:4465-4470, 4508-4510). Then ClearVars(0) and InitHeap run, the new program is loaded and prepared, and RestoreContext copies them back (Commands.c:4643-4657). RUN "file" is a ClearRuntime reset with no saved context (do_run, Commands.c:2460).Both are S6 reset events. g_vartbl is free while the new program loads, so it can be the save-time scratch under CHAIN too. The limit under CHAIN is the A: drive space SaveContext needs on boards without PSRAM, not the variable table.
CSUB ABICSUB addresses are offsets within the image (PicoMite.c:4705-4715). RunBasicSub exposes ExecuteProgram (PicoCFunctions.h:116, 268). mmb2csub wrappers take variable addresses.Sections are append-only. The text path stays complete. Variable storage never moves. The CallTable is append-only.
TRACE, TRACE LIST, error linesCurrentLinePtr and TraceBuff are set per lineUnchanged in Route A. Route B records point to the text.
Build variants
  • HDMIWEB is a CACHE build: DoFast is live, and EnterLocalFrame/LeaveLocalFrame are in flash. VGA is not a CACHE build.
  • LOWRAM VGA pays for RAM code in 4 KB pages.
  • VGAUSBRP2350's flash margin is not known: it moved from 1040 to 1056 KB on 2026-09-21 (564fcff), and no map of it is at hand.
  • WEB builds call ProcessWeb on every statement.
  • PICOMIN.
  • On the M0+, -O3 miscompiles and -Os drops array alignment.
Both boards are measured on every step. Map sizes are checked per variant. Hot paths on the RP2040 stay at -O2/-Os. Buffers get explicit alignment.
PSRAM or noneThe RP2040 has no PSRAM and no RAM slots. The PC3 spills large allocations to PSRAM (Memory.c:2147), and a PSRAM table or stream has different timing.Side tables are allocated from SRAM explicitly. Route B is tested in a RAM slot and confirmed once from a flash slot.
Program capacityThe LOAD text buffer limits both boards (FileIO.c:4345). It shrinks with the screen width (configuration.h:561), and on the full HDMI builds the heap depends on the display mode too.Capacity is checked on every step with the largest corpus programs. The image size is logged.
Old images and downgradesOld firmware cannot read new images. PEEK(PROGMEM) users see the new bytes.A magic number marks new images; images without it run raw. The change is documented.
Variable semanticsStructs, STATIC name mangling, OPTION EXPLICIT, ERASE tombstones, re-DIM, OPTION BASE, OPTION DEFAULTBinding happens only through findvar. Dotted names and STATIC stay on the slow path at first. A golden test covers each invalidation event.
Found along the way: correctness, not speed

These come from reading the code. Each has a short board repro in the dossier, and whether to act on each is Peter's call.

  • DoFast truncates a float DO limit. By reading, i%=0 : Do While i%<2.5 : Inc i% : Loop : Print i% prints 2 instead of 3. It is gated only by #ifdef CACHE (Commands.c:5415-5450), so it is live on COM16 today, with no option set. Removing the trace cache removes it, but the cmd_do/cmd_loop hooks must go with it.
  • Trace-cache keys alias through argbuf and tknbuf, the probable cause of the CollTiles result, and DIM, ERASE, OPTION BASE and OPTION EXPLICIT do not invalidate it (MMtrace.c:74-77). Both go away with the cache.
  • The RP2040 sub/fun name collision check is dead (MMBasic.c:5160-5166). x++ lands on the second token byte, which is 0x80 or more, so the test never matches. Symbols would make it live, and programs that pass today could start reporting a clash, as the RP2350 already does.
  • The interrupt frame imbalance is confirmed by reading, on CACHE builds only. g_LocalIndex itself balances through cmd_ireturn (MM_Misc.c:1883). The effect is limited to g_current_sub_idx, profiling and the trace cache's locals.
  • The dossier's RP2040 SIN/COS double evaluation is already fixed (fef1f0b, today).
8 · Before starting

Decisions needed from Peter

Questions and choices

Already answered by Peter: there are no existing build, NOCHECK or WiFi A/B figures, so 0b measures them. The reference build for the gates is chosen from the 0b results, with clocks matched through MM.INFO(CPUSPEED) and OPTION CPUSPEED nnnn. Flash timing is not pursued.

  1. The 30% figure. Does the "up to 30%" come only from solar_eclipse (12.5 s against 8.8 s), or also from other programs? If others, where are they?
  2. SWD. Is a probe already working on either board? If not, the plan does not need one.