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.
funtbl, a net saving. On the RP2040 they take about 7 KB of heap for a 500-name program.findvar. Also removed are the +46-48% cost of a LOCAL read at recursion depth 10, the RP2040's +21-47% on calls and array reads with 200 SUBs, and its 67x cost for a far GOSUB. These figures are estimates; phases 2 and 3 measure them.This is the path for reading an existing global scalar, from the variable survey (instruction estimates, not measurements):
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.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.hash % maxglobalvars on every call, even when the name turns out to be local. On the RP2040 this is a library call.Two earlier measurements shape the design:
findvar reads them, so the address is lost. A symbol id is carried along by those copies.0-9 A-Z a-z _.
0x04 program long, 0x05 program short, 0x06 library long, 0x07 library short. That gives 63 short ids and 3,969 long ids per image.tokenise already turns every control character in source text into a space, so 0x04-0x07 cannot come from what the user typed.a$(i) becomes <sym a> $ ( <sym i> ). A dotted name p.x is a single symbol, because . is a name character.Foo and FOO list back exactly as typed. Both map to the same binding (see Binding).0,0 end marker and before the CSUB and font records.
SaveProgramToFlash computes them after tokenising anyway.| Reader | Sites | Change |
|---|---|---|
checkstring (sub-keywords such as INTEGER, OUTPUT, X) | 1,742 | One 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, …) | 173 | Audit. 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) | 105 | Audit. 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 count | Audit, then use a helper that accepts a one-letter symbol. |
skipvar, getvalue, findvar, FindSubFun, findlabel, makeargs, DefinedSubFun, llist, error messages | core | Rewritten 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.
PrepareProgram builds it at RUN, CHAIN, LIBRARY LOAD, and before each prompt command, as it builds funtbl and subfun today.
funtbl, hashlabels, the RP2040 sort, first-letter index and binary search, and the RP2040's scan of subfun[] for name clashes.Each canonical entry holds:
| Field | Bytes | Meaning |
|---|---|---|
gbind | 2 | global slot in g_vartbl, or none |
lbind | 2 | newest local slot with this name, at any level, or none |
sbind | 2 | subfun index, or none (set at PrepareProgram) |
labind | 2 | index into the label pointer table, or none (set at PrepareProgram) |
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
FindSubFun test becomes one load of sbind.labind.lbind[canon] in a per-slot array lshadow[slot], then sets lbind[canon] = slot.ClearVars(level) already frees locals newest first, using g_hashlist. For each one it restores lbind[canon] = lshadow[slot]. A second array, lcanon[slot], supplies the canonical index without hashing again.x sees the caller's x in lbind, but at the wrong level, so it falls through to the global. GOSUB and interrupts raise the level as today.g_vartbl as today, because slots never move.A binding is cleared at the moment its slot is freed.
ClearVars(level) (locals), ClearVars(0) (everything: RUN, NEW, CLEAR, CHAIN, errors at the top level), and erase() (ERASE and REDIM).gbind. ClearVars(0) clears every binding.', REM, /* */);findvar, FindSubFun and findlabel text path. It gets its answers from the canonical table instead of funtbl. When it creates a variable it also fills the bindings, so the next symbol lookup is fast.g_vartbl and the CSUB interface are unchanged. A chained program starts with empty bindings and fills them as each name is used.SaveProgramToFlash, SaveProgramToRAM and SaveLibraryImage gain a first pass that collects names and counts their uses (for the short ids). The symbol table is written after the text.llist. It prints each symbol's spelling from the right table.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.
| Program | Name uses | Name bytes now | Distinct | Short-id share | Symbols | Table | Change |
|---|---|---|---|---|---|---|---|
| Sandglass player | 10,565 | 55,850 | 1,019 | 46% | 26,864 | 10,571 | −18,415 |
| Elite (built) | 10,162 | 43,777 | 975 | 49% | 25,505 | 9,365 | −8,907 |
| Picanoid | 3,251 | 15,320 | 447 | 55% | 7,962 | 4,316 | −3,042 |
| Arkanoid | 3,142 | 14,815 | 433 | 55% | 7,688 | 4,210 | −2,917 |
| Thrust | 2,376 | 10,690 | 332 | 55% | 5,811 | 2,996 | −1,883 |
| Chuckie | 2,539 | 10,274 | 304 | 60% | 6,102 | 2,518 | −1,654 |
| Exile physics | 2,451 | 9,592 | 313 | 66% | 5,733 | 3,201 | −658 |
| solar_eclipse (anchor) | 4,387 | 11,388 | 254 | 80% | 9,650 | 1,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.
funtbl, a net saving of about 4 KB.lshadow and lcanon cost 4 bytes per local slot: 1 KB on the RP2350 and 960 bytes on the RP2040.| Risk | Containment |
|---|---|
| Code the audit misses still reads names as text | It 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 stale | There 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 up | Separate introducer bytes, and LIBRARY SAVE renumbers. The library test suite (Bas/libtest) covers it. |
| Old images in slots, the library and .lib files | They run on the fallback at today's speed; saving again gives them symbols. |
| The CSUB ABI and PEEK/POKE VARTBL | Unchanged: the layout of g_vartbl does not change in phases 1-3. |
| Cost of PrepareProgram before each prompt command | Building 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. |
llist; the canonical table in PrepareProgram, replacing funtbl, hashlabels and the RP2040 sort.checkstring change and the audit fixes; LIBRARY SAVE merging; the trace cache removed.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.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.lbind shadow stack. Tests: the recursion micro-tests (depth 1 against depth 10), call loops, the games and the goldens.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.1 yes; 2 yes; 3 yes; 4 remove the trace cache; 5 yes, for the moment; 6 keep for later.
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.)g_vartbl, which changes the CSUB interface): consider later, or rule it out now?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.
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.g_hashlist (MMBasic.c:332, 5359).hashlabels' guard originalhash<0 is unsigned, so it is never true.FindSubFun's probe has no bound.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.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.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.rti[2] IRETURN stub has no terminator, so skipelement and checkend read past it.CountLines uses p[0] where it means p[1] (MMBasic.c:4060), on lines longer than 253 bytes.MM.INFO(VARCNT) is stale after ERASE and after locals are freed.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.DefinedSubFunMem, the outer call's frame and buffers leak.