Route A · S5/S6 · design for review

Symbols with lazy, per-frame binding

MMBasic spends 16-34% of every measured workload finding names. Each time a statement runs, it reads the name's text again, hashes it, probes a table and compares strings. This design stores each name in the program as a 2- or 3-byte symbol, and binds each symbol once to its SUB, label or global. Locals are bound per call frame. The binding happens the first time a symbol is used, so variables are still created on first use, as today.

Branch development at a07e8f2 · evidence: Phase 1 report, and three read-only code surveys (variables, program format, calls) done for this design.

Summary

Where the time goes today

This is the path for reading an existing global scalar, from the variable survey (instruction estimates, not measurements):

  1. getvalue walks the name. An array reference also runs FindSubFun first: a second hash on the RP2350, and on the RP2040 a count of the whole SUB table followed by a binary search.
  2. findvar reads the name one character at a time, upper-casing it and hashing it. That is about 12-15 cycles a character on the RP2350 and 20-25 on the RP2040.
  3. It divides hash % maxglobalvars on every call, even when the name turns out to be local. On the RP2040 this is a library call.
  4. Inside a SUB it also runs the local probe. That probe does another division, and then one more for each probe step. Every recursion level's copy of a local sits in the same chain, so a lookup at depth D does D name compares and D divisions.
  5. It compares names in the table. Then comes the type and dimension checking, which stays after this change.

Two earlier measurements shape the design:

Symbols in the stored program

Encoding

The symbol table travels inside the image

Who reads names, and what changes

ReaderSitesChange
checkstring (sub-keywords such as INTEGER, OUTPUT, X)1,742One function. A symbol matches a keyword when its canonical name equals the keyword. No call site changes.
Direct string compares on argument text (str_equal, strcasecmp, …)173Audit. Many compare evaluated strings, not program text, and need nothing. Those that compare argument text move to a symbol-aware helper.
Hand-written name parsing (isnamestart/isnamechar)105Audit. 47 are in MMBasic.c's core readers, which are being rewritten anyway; 11 are in the trace cache.
Single-letter keywords tested by character (toupper(*p)=='X')to countAudit, then use a helper that accepts a one-letter symbol.
skipvar, getvalue, findvar, FindSubFun, findlabel, makeargs, DefinedSubFun, llist, error messagescoreRewritten to understand symbols. makeargs and getargs copy bytes, so symbols reach the evaluator unchanged.

Why not keep sub-keywords as text? The firmware compares 893 distinct words against argument text, and they include most single letters as well as INTEGER, FLOAT and STRING. Those collide with the commonest names: Elite's N alone is used 883 times, and a third of solar_eclipse's name uses match one of these words. Any word kept as text would stay slow, so every identifier becomes a symbol and the comparisons learn to read symbols.

Binding

The canonical table

PrepareProgram builds it at RUN, CHAIN, LIBRARY LOAD, and before each prompt command, as it builds funtbl and subfun today.

Each canonical entry holds:

FieldBytesMeaning
gbind2global slot in g_vartbl, or none
lbind2newest local slot with this name, at any level, or none
sbind2subfun index, or none (set at PrepareProgram)
labind2index into the label pointer table, or none (set at PrepareProgram)

Lookup (the fast path)

canon = map[id]
s = lbind[canon]
if (s != NONE && g_vartbl[s].level == g_LocalIndex)  -> the local
else if ((s = gbind[canon]) != NONE)                  -> the global
else -> slow path: today's name lookup, which creates the
        variable if needed and then fills gbind / pushes lbind

Locals: the per-frame shadow stack (S6)

One rule for staleness

A binding is cleared at the moment its slot is freed.

What stays text, and the kept fallback

Images, slots, library, editing

Size and RAM

Program size

Estimated from the source files with the command and function tables applied. Strings, comments, DATA and CSUB blocks are excluded. The 63 most used names get 2-byte symbols and the rest 3 bytes. The symbol table costs each spelling plus 3 bytes.

ProgramName usesName bytes nowDistinctShort-id shareSymbolsTableChange
Sandglass player10,56555,8501,01946%26,86410,571−18,415
Elite (built)10,16243,77797549%25,5059,365−8,907
Picanoid3,25115,32044755%7,9624,316−3,042
Arkanoid3,14214,81543355%7,6884,210−2,917
Thrust2,37610,69033255%5,8112,996−1,883
Chuckie2,53910,27430460%6,1022,518−1,654
Exile physics2,4519,59231366%5,7333,201−658
solar_eclipse (anchor)4,38711,38825480%9,6501,870+132

The largest programs shrink the most, so the program-capacity rule is met with room to spare. On HDMIWEB, sandglass should load without the ,C crunch it needs today.

RAM

Risks and how each is contained

RiskContainment
Code the audit misses still reads names as textIt sees byte 0x04 and raises a syntax error, never a wrong result. Phase 1 has no speed work, so every golden, game check, interrupt test and a LIST round trip of every program in Bas/ must match today before phase 2 starts.
Sub-keywords (INTEGER, OUTPUT, single letters)The change to checkstring covers 1,742 call sites at once. The 173 direct compares and the character-level single-letter tests are audited and listed in the phase 1 commit.
A binding goes staleThere is one rule: bindings are cleared where slots are freed, in three functions. A debug build can also check every fast-path hit against the slow path, running both and comparing the result.
Library and program symbols mixed upSeparate introducer bytes, and LIBRARY SAVE renumbers. The library test suite (Bas/libtest) covers it.
Old images in slots, the library and .lib filesThey run on the fallback at today's speed; saving again gives them symbols.
The CSUB ABI and PEEK/POKE VARTBLUnchanged: the layout of g_vartbl does not change in phases 1-3.
Cost of PrepareProgram before each prompt commandBuilding the canonical table hashes each name once, about a thousand hashes for the largest program. If it is ever noticeable, it can be skipped when neither image has changed.

Phases and tests

  1. The format, at today's speed.
    • Tokeniser and image table; llist; the canonical table in PrepareProgram, replacing funtbl, hashlabels and the RP2040 sort.
    • The symbol-aware core readers resolve by name through the table, with no bindings yet.
    • The checkstring change and the audit fixes; LIBRARY SAVE merging; the trace cache removed.
    • Tests: LIST round trip of every Bas/ program and the goldens corpus; goldens on both boards; the three games' checks; the interrupt tests; libtest; EDIT and AUTOSAVE by hand; program sizes; MEMORY before and after.
  2. S5, global, SUB and label bindings. gbind, sbind and labind, the fast path in getvalue, findvar, FindSubFun and findlabel, and clearing in the three functions that free slots. Tests: the anchor, the standard set and the goldens; the Phase 1 name micro-tests (200 SUBs, far labels); the debug build's check of fast path against slow path.
  3. S6, per-frame locals. The lbind shadow stack. Tests: the recursion micro-tests (depth 1 against depth 10), call loops, the games and the goldens.
  4. Optional, later. Store a symbol id in g_vartbl in place of the 32-byte name. That saves 14-28 KB of RAM per variant but changes the CSUB interface and PEEK(VARHEADER). Also cache struct members per symbol, and add call descriptors (S3b), which build on symbols.
Decided 2026-09-25

1 yes; 2 yes; 3 yes; 4 remove the trace cache; 5 yes, for the moment; 6 keep for later.

Decisions for Peter
  1. Symbolise every identifier and teach checkstring and the audited compares to read symbols, rather than keeping sub-keywords as text? (Recommended; keeping them as text would leave the commonest names slow.)
  2. Keep every spelling so that LIST output is unchanged? (Recommended; it costs a few table entries.) The alternative is to list each name as first spelled.
  3. Older images run on the text path until saved again, with no forced conversion? (Recommended.)
  4. Remove the trace cache in phase 1? It reads names itself, and it keeps global slot indices that nothing invalidates.
  5. RP2040 bindings on the heap (about 5-7 KB for a 500-name program): acceptable?
  6. Phase 4 (a symbol id in g_vartbl, which changes the CSUB interface): consider later, or rule it out now?

Found on the way (not part of this design)

The surveys turned these up by reading the code. None has been tested on a board. They are listed for you to prioritise, and none is fixed here.

  1. CurrentSubFunName is not restored after a nested call returns (MMBasic.c:2736). A STATIC executed after a call is mangled with the callee's name, so it may pick up the wrong backing variable. It probably deserves a test.
  2. OPTION LOCAL VARIABLES above the compile-time MAXLOCALVARS can overflow g_hashlist (MMBasic.c:332, 5359).
  3. Probe loops that can spin forever:
    • The global probe has no wrap guard at level 0.
    • hashlabels' guard originalhash<0 is unsigned, so it is never true.
    • FindSubFun's probe has no bound.
    Each can spin when its table is full.
  4. On the RP2350 findlabel does not filter out SUB entries, so GOTO to a label that has a SUB's name would use a small integer as a pointer.
  5. FindSubFun ignores its type argument on the RP2350 but not on the RP2040, so a CSUB or SUB name used as a function behaves differently on the two chips.
  6. cmd_call and fun_call append the argument text to a 256-byte buffer with no length check. tokenise also has no bound on its output in tknbuf.
  7. The rti[2] IRETURN stub has no terminator, so skipelement and checkend read past it.
  8. CountLines uses p[0] where it means p[1] (MMBasic.c:4060), on lines longer than 253 bytes.
  9. Unbalanced frames on CACHE builds: a SUB-style interrupt and ON…GOSUB never enter a frame but do leave one, so profiling misattributes the time.
  10. MM.INFO(VARCNT) is stale after ERASE and after locals are freed.
  11. The CSUB header's int dims[] layout is selected only #ifdef PICORP2350, although every RP2350 build uses int dimensions (PicoCFunctions.h:393-413). Worth checking against a CSUB that reads g_vartbl on HDMIUSB.
  12. If an argument errors under ON ERROR SKIP while a nested call has cleared DefinedSubFunMem, the outer call's frame and buffers leak.