Variable table · design for review

A compact variable table

Today every possible variable has a full 56- or 64-byte entry in a static table, used or not. That is 26.9 KB of the RP2040 VGA's RAM, more than a quarter of its 100 KB heap, and 47-49 KB on the RP2350 builds. This document reviews Peter's proposal to keep only a small slot per variable in the table and to give each variable its full record from the heap when it is created. It recommends doing it, with four changes, and sets out the design, what it costs, what it would break, and how to build it in stages.

Branch development at 0b7528f (V7.0.00b2) · evidence: two read-only code surveys for this review (the table's internals; everything outside the interpreter that depends on its layout), an offline count of the names in six real programs and the 112-program test corpus, and the 2026-09-30 heap investigations of la_24_2.bas and Bad Apple on the RP2040 VGA. No code has been changed.

Summary

How it works today

g_vartbl is a static array of MAXVARS entries in BSS (MMBasic.c:121), separate from the heap. Each entry is a struct s_vartbl (MMBasic.h:279-299):

BytesFieldNotes
0-31name[32]Upper case, no suffix, not NUL-terminated at 32 characters
32-35type, level, size, namelentype uses all 8 bits (T_NBR ... T_STRUCT); namelen now holds flags only
36-47 / 36-55dimtbl6 shorts on the RP2040, 5 ints on the RP2350. A short string (up to 9 / 15 characters) is stored here, in dimtbl[1..]
48-55 / 56-63val8 bytes, 8-aligned: the number, or a pointer to string, array or struct data
BuildGlobals + localsEntryTableg_hashlist
RP2040 PICO, PICOUSB, PICOMIN256 + 2405627,7761,856
RP2040 VGA, VGAUSB240 + 2405626,8801,792
WebMite RP2040240 + 1925624,1921,600
RP2350 VGA, VGAUSB, HDMI, HDMIUSB, HDMIBTH, HDMIWEB544 + 1926447,1042,816
RP2350 PICO, PICOUSB, BT, BTH, WebMite RP2350576 + 1926449,1522,944

What programs use

Distinct names in each program, counted offline: everything that is not a keyword (the command and function tables plus every sub-keyword the firmware checks for). "Globals" is an upper bound, since it includes CONSTs, which also take an entry, and anything the counter could not prove local. For Prince of Persia it must really be under 544, its build's limit.

ProgramNamesSUB/FUNCTIONOnly localGlobals ≤Most locals in one SUBNames > 8 charsShare first 3 charsShare length + hash16
la_24_2 (logic analyser, RP2040 VGA)1102339611310 of 8832%0
Bad Apple (RP2040 VGA)432339214 of 4135%0
Exile3475966175810 of 23249%0
Arkanoid419607428112114 of 34758%0
Elite87826014749615106 of 61858%0
Prince of Persia98425211164011223 of 73264%0
Test corpus, 112 programsglobals: median 2, 90th percentile 9, largest 290156 of 850

Two conclusions shape the design. Programs written for the RP2040 use a small fraction of its table, which is where the gain is. And the large games come close to the RP2350's limit, so the design must not cost them memory.

Scripts: scratchpad varcount.py and prefix.py (2026-10-01). Sources: B:/la_24_2.bas, b:/badapple/badtweaked.bas, Bas/arkanoid.bas, Bas/bench/phase1/games/{exile/gbxphys, elite/gbelite, pop/gbpop}.bas, and the mmb2c test corpus.

The proposal, point by point

1. A small slot per variable yes

The slot table replaces the entries for globals. 8 bytes per slot: a state byte, the name's length, a 16-bit hash, and the record's address. 240 global slots take 1,920 bytes instead of 13,440.

The proposal puts the type in the slot. The design keeps it in the record only: the type and its flags are changed after creation (T_PTR on parameters and STATICs, the marker DIM sets while it allocates, ERASE), and read in many places, so a copy in the slot would have to follow every change. The slot needs only free, in use or blocked.

2. Records on demand, in 7-page chunks yes, adjusted

Seven pages hold exactly 32 RP2040 records, as proposed. On the RP2350 seven pages hold 28; eight hold 32. Using 32 on both chips makes a local's record a shift and a mask from its slot number.

Chunks must come from the SRAM heap only. GetMemory can fall back to PSRAM, which would slow every variable access, and SAVE CONTEXT does not capture PSRAM contents.

3. First three letters in the slot change

The aim, settling a probe without touching the record, is right. Three letters do not do it: 32-64% of names share their first three letters with another, and every STATIC's hidden global starts with its SUB's name. The FNV hash findvar already computes supplies a 16-bit check for free, and with the length it separated every name in all six programs. The record is still read once to confirm a match.

4. ERASE: waste, or reuse via a LIFO LIFO

A free list costs almost nothing: an erased record's first word links to the next free one, and creation takes from the list before the chunk. The slot itself is freed or marked blocked exactly as today, so chains stay intact and the slot is reused by the next new variable that probes through it.

5. 8-byte names, pointer for longer not now

It would save 24 bytes per variable (records of 32 / 40 bytes). But 4-34% of names in the programs measured, and 18% in the test corpus, are longer than 8 characters and would need a second allocation; every name comparison and print would need a helper; and the fields after the name would move, which breaks PEEK(VARHEADER) offsets (Bad Apple's +36), the layout CSUBs are given, and the Structures manual. With items 1-4 even full tables break even, so the saving is not needed. Revisit only as a deliberate compatibility break.

Recommended design

Data

struct s_gslot {                // one per global slot (MAXGLOBALVARS of them)
    uint8_t  state;             // GS_FREE 0, GS_USED 1, GS_BLOCKED 2 (T_BLOCKED today)
    uint8_t  namelen;           // 1..32
    uint16_t hash16;            // top 16 bits of the FNV-1a hash
    struct s_vartbl *rec;       // the record; NULL when free
};
struct s_vartbl                 // unchanged: 56 bytes on the RP2040, 64 on the RP2350
#define VREC_PER_CHUNK 32       // 7 pages on the RP2040, 8 on the RP2350

static struct s_gslot   g_gslot[MAXGLOBALVARS];
static struct s_vartbl *g_lchunk[(MAXLOCALLIST + 31) / 32];   // local record chunks, kept for the run
static struct s_vartbl *g_gfree;                               // erased global records (LIFO)
static struct s_vartbl *g_gnext, *g_gend;                      // next unused record in the current chunk

The index space stays as it is: slots below maxlocalvars are locals, the rest globals. So g_VarIndex, SymG, SymL, SymLShadow, SymLCanon, g_hashlist and the FOR/DO stacks keep their meaning. One accessor maps an index to its record:

#define VREC(i) ((i) < maxlocalvars ? &g_lchunk[(i) >> 5][(i) & 31] \
                                     : g_gslot[(i) - maxlocalvars].rec)

The ~350 uses of the variable findvar has just found take VREC(g_VarIndex). A cached g_VarRec, set by findvar, was tried in phase 1 and dropped: solar_eclipse ran 0.8% slower on the RP2040 VGA and 1.6% slower on the PC3. Whether to cache the record is decided in phase 2, measured against the real VREC.

Creating, finding and erasing a global

Locals

A local's record is g_lchunk[slot >> 5][slot & 31]. When the stack first reaches a new multiple of 32, a chunk is allocated; chunks are kept until the run ends, so a program allocates them only the first time its call depth reaches a new high. Making and dropping locals is otherwise unchanged. OPTION LOCAL VARIABLES n becomes a cap on the local stack alone: raising it no longer takes slots from the globals.

Seeding at RUN

RUN allocates the first chunk of globals and the first chunk of locals before the program runs (3.5 KB on the RP2040, 4 KB on the RP2350). A program with up to 32 globals and 32 live locals then never allocates for variables during the run, and so can never fail to make one for lack of heap. Larger programs allocate further chunks mostly while declaring their globals, near the top of the program, before their large buffers.

Things that must change with it

Memory

Static RAM freed, and heap gained

BuildTable todaySlots + chunk pointersFreedHeap, 4 KB stepsLeft to margin
RP2040 PICO, PICOUSB, PICOMIN27,7762,10825,668+24 KB1,092
RP2040 VGA, VGAUSB26,8801,97624,904+24 KB328
WebMite RP204024,1921,97222,220+20 KB1,740
RP2350 VGA and HDMI builds47,1044,44042,664+40 KB1,704
RP2350 PICO builds, WebMite RP235049,1524,70044,452+40 KB3,492

The builds' fit checks have the final word, including the newlib C-heap rule on the USB builds (at least 4 KB between the end of BSS and the stack).

What a program has left, compared with today

Program and buildChunks (globals + locals)RecordsHeap compared with today
la_24_2, RP2040 VGA2 + 15,376+18.8 KB
Bad Apple, RP2040 VGA2 + 15,376+18.8 KB
Any program with ≤ 32 globals and ≤ 32 live locals, RP2040 VGA1 + 13,584+20.5 KB
A full RP2040 VGA table: 239 globals, 32 live locals8 + 116,128+8.3 KB
Worst case, RP2040 VGA: 239 globals and 239 live locals at once8 + 828,672−4.0 KB
Exile, RP23506 + 216,384+24.0 KB
Elite, RP235016 + 236,864+4.0 KB
Prince of Persia, RP235017 + 238,912+2.0 KB
Worst case, RP2350 HDMI: 543 globals and 191 live locals at once17 + 647,104−6.0 KB

The games' counts are upper bounds, so their real gains are a little larger. The two worst cases need every local slot live at the same moment, which no measured program comes near: the largest single SUB declares 15.

The symbol tables (3-5 KB) and the per-SUB parameter layouts (1 KB plus a page per SUB called) are unaffected. With 19 KB more heap, la_24_2 and Bad Apple would run with them, and OPTION SYMBOLS OFF would become a rarity.

Compatibility

WhatEffect
PEEK(VARADDR v), PEEK(VAR v, off), POKE VARUnchanged. The value stays inside its record, 8-aligned, and never moves while the variable exists.same
PEEK(VARHEADER v) and offsets from itUnchanged: it returns the record, whose layout is identical. Bad Apple's +36 keeps working.same
VAR SAVE / VAR RESTOREUnchanged: the flash format is keyed by name and type, not by entry.same
Pointers held by drivers (IR, KEYPAD, PLAY STREAM, TCP STREAM), the FOR and DO stacks, Route BUnchanged: records never move.same
MM.INFO(VARCNT), LIST VARIABLES, LIST COLLISIONSSame output. Collisions are still those of the global slots.same
OPTION LOCAL VARIABLES nCaps the local stack without taking slots from the globals.better
MEMORY, MM.INFO(HEAP)The heap is larger, and falls as variables are created. Figures differ from today.differs
Creating a variableCan fail with "Not enough memory" once the heap is full, beyond the seeded chunks. Today only the slot limits apply.new
PEEK(VARTBL, off), POKE VARTBLThere is no longer one array. Can be emulated: off / sizeof(entry) is the index, the remainder the byte in its record. A free slot reads as zeros.emulate
CSUB call table 0x60 (&g_vartbl) and 0x64 (&g_varcnt), the layout in PicoCFunctions.hA CSUB that walks g_vartbl breaks. None is known: mmb2csub reaches globals through VARADDR, which is unaffected. The offsets are an append-only ABI, so 0x60 must keep some meaning.breaks
SAVE CONTEXT images (/.vars on A:, the PSRAM reserve) from older firmwareRefused, once the header is added. Today they would be restored blindly into the wrong layout.refused

Documentation to update: help.txt and the manual (MEMORY, OPTION LOCAL VARIABLES, PEEK/POKE VARTBL, VARHEADER), MMBasic_Structures_Manual.md:2589-2610, armcfgen.md section 1.5 and its 0x60/0x64 table, PicoCFunctions.h:395-416, and the planned mmb2csub phase 3 (MMBasic_To_CSUB_Plan.md:296-298), which assumed it could walk the table.

Speed

Risks

Hazards to design for
  • Short strings inside records would be freed into the heap as if they were allocations. Every free site is guarded (phase 1).
  • Running out of heap while making a variable: mitigated by seeding a chunk of each kind at RUN, and by keeping local chunks for the run.
  • Width of the change: about 615 lines in 27 files mention g_vartbl, and about 155 call findvar. Most uses follow findvar and take VREC(g_VarIndex); the walkers (ClearVars, erase, LIST, MEMORY, SAVE CONTEXT, Route B binds, the symbol layer) need care. This is why phase 1 changes no behaviour.
  • A record in PSRAM would be slow and invisible to SAVE CONTEXT: the chunk allocator must use the SRAM heap only.
  • Interrupt writers pointing at a LOCAL already write into reused memory after the SUB returns. Heap records would make that worse, not new. See Found on the way.

Phases

1refactor

Accessors, no change in behaviour

Every indexed use of g_vartbl goes through VREC(i), defined for now as &g_vartbl[i]: 664 sites in 22 files, and every object the same size as before on both builds. The short-string guard at every free site. The SAVE CONTEXT header is covered by the magic-number decision. A cached g_VarRec was tried and moved to phase 2 (see Recommended design).

Gate: goldens identical on the RP2040 VGA and the PC3; solar_eclipse and bench_micro within noise; flash size noted.

Result: goldens unchanged on both boards apart from timings (and webudp, now fixed). solar_eclipse 26.683 s on the VGA (b2 26.684 s) and 16.219 s on the PC3 (b2 16.207 s). Flash +72 bytes (VGA), +176 bytes (HDMIWEB).

2globals

Global records in heap chunks

VREC(i) reads g_slotrec[i], one record pointer per variable number, so a record is one load away whether it is a local's or a global's. Measured on solar_eclipse before anything moved: the RP2040 about 1% slower (one more cycle per record access; accepted for the memory), the RP2350 no slower. A global's record comes from an SRAM-only chunk of 32, the first seeded at RUN. An erased global's record goes on a free list, linked through its name, which no background writer touches. A free or blocked slot points at a shared placeholder record, so every probe and walker reads a slot as before. CLEAR keeps the chunks and hands their records out again, as IR, KEYPAD, PLAY STREAM and TCP STREAM may still be writing to a global's value; the chunks go when the heap is wiped. g_vartbl holds only the locals, so OPTION LOCAL VARIABLES is capped at MAXLOCALVARS until phase 3. PEEK/POKE VARTBL stop with an error naming PEEK(VARHEADER); call-table 0x60 is g_slotrec. SAVE CONTEXT keeps g_vartbl, g_slotrec and the chunk state; the magic numbers change once, in the commit that finishes the redesign, as each phase changes the format again and every change wipes a board's options and A:.

Gate: goldens; varcnt14, symchain, static5, rbf2, rbp5d; ERASE and REDIM, CLEAR, STATIC, CONST short strings, SAVE/LOAD CONTEXT; solar_eclipse. (locvars6 waits for phase 3.)

Result: goldens unchanged on the RP2040 VGA and the PC3; varcnt14, symchain, static5 and rbp5d pass; rbf2 matches every recorded item, and the VGA's deep recursion, which stopped at depth 8, now completes. solar_eclipse 26.816 s on the VGA (phase 1 26.683) and 16.032 s on the PC3 (16.219). RAM margin 12.4 KB on the VGA (was 1.5) and 33.6 KB on HDMIWEB (was 2.9), for phase 4.

2bprobe

Probe on length and hash16

A 4-byte entry per global slot (state, length, hash16), so a probe reads a 32-byte name only to confirm a match.

Gate: as phase 2, and solar_eclipse no slower.

3locals

Locals' records in chunks

Each chunk of 32 local slots takes its records from the heap the first time the local stack reaches it, fills those 32 pointers, and is kept for the run. g_vartbl goes, and the cap on OPTION LOCAL VARIABLES with it.

Gate: as phase 2, plus locvars6, deep recursion (rbdepth), by-reference parameters and interrupts during SUBs.

Result: goldens unchanged on both boards; locvars6 (390 live locals), varcnt14, static5, symchain, rbdepth and rbp5d pass; rbf2 as in phase 2. solar_eclipse 27.155 s on the VGA (phase 1 26.683, +1.8%) and 16.047 s on the PC3 (16.219). RAM margin 25.4 KB on the VGA and 45.5 KB on HDMIWEB, until phase 4 gives it to the heap; meanwhile the stack uses it (rbdepth on the VGA: FUNCTION depth 35).

4heap

Give the RAM to the heap

Per build, in whole 4 KB steps, with MAX_PROG_SIZE pinned so the flash layout and A: are unchanged.

Gate: every build's fit check; la_24_2 and Bad Apple with symbols on and no OPTION SYMBOLS OFF; the games on the PC3.

Result: heaps +20-24 KB on the RP2040 builds and +40-56 KB on the RP2350, leaving 3.4-6.6 KB of RAM for the stack (Peter: about 4 KB); MAX_PROG_SIZE pinned where it followed the heap, so options and A: survived on both boards. All 16 variants build; goldens unchanged on both boards; solar_eclipse 27.140 s on the VGA, 16.031 s on the PC3. la_24_2 on the VGA runs with its symbols: loop 81 ms, where b2 took 110.8 ms with OPTION SYMBOLS OFF (and the new firmware 111.8 ms without symbols). Bad Apple decode and draw per frame, symbols on/off: b2 83.2/90.9 ms, phase 1 90.7/103.2 ms, phase 4 85.9/97.0 ms.

5optional

Afterwards, if wanted

Raise MAXGLOBALVARS where useful, since a slot costs 8 bytes; bind globals to record pointers rather than slot numbers.

Decisions

Decided, 2026-10-01
  1. Phase 1 goes ahead, on the branch compact-vars from development.
  2. CSUB call table 0x60 will point at the new slot table, with its layout published in PicoCFunctions.h. 0x64 (g_varcnt) stays.
  3. PEEK/POKE VARTBL are removed: they were unusable with the hashed table anyway.
  4. One chunk of globals and one of locals is seeded at RUN.
  5. Names stay 32 bytes in the record; 8-byte names are not pursued now.
  6. SAVE CONTEXT: the phase that changes its format also regenerates every build's magic number, so A: (and an old /.vars) is wiped on the first boot, as for any layout change.
  7. No g_VarRec in phase 1: it cost 0.8% on solar_eclipse (VGA). It is reconsidered in phase 2.
  8. One record pointer per variable number (g_slotrec) rather than the slot's own pointer: VREC without a branch. The RP2040's 1% is accepted for the memory.
  9. Order: globals' records first (phase 2), the hash16 probe after it (2b), then the locals (3); until phase 3 OPTION LOCAL VARIABLES is capped at its default on this branch.
  10. Magic numbers change once, in the commit that finishes the redesign (done for V7.0.00b3). Phase 2b (the hash16 probe) is not done: with symbols almost no lookup reaches the probe.

Found on the way

From the two surveys. Fixed on development before this branch was made:

Still as found: