Plan: interpreter performance -- stop billing expected misses as errors, keep the error protocol at boundaries, and parse each line once #38

Open
opened 2026-08-04 10:17:18 -04:00 by tachikoma · 1 comment
Collaborator

The GALAGA tutorial's benchmark (docs/21, Step 11, PR #37) measured a scripted enemy update at ~881 µs against ~0.01 µs for the same logic in C. This issue is a plan to recover as much of that as the project's ground rules allow, in stages, each stage measured before the next is argued. The numbers below are from profiling the shipped benchmark (akbasic_example_galaga_interop, -O2, two-core VM, galaga-tutorial branch) and from two scratch experiments run against it today.

Ground rules this plan does not violate

These are Andrew's constraints, restated so every stage below can be checked against them:

  1. The source stays readable. The interpreter is a learning vehicle first. A stage that buys speed by making src/ harder for a newcomer to follow is rejected on that ground alone.
  2. The architecture stays inside one head. The current code is the stated upper bound of acceptable complexity.
  3. No compilation to machine code, and bytecode only as a separately-documented future step (it is stage 5, deferred, and nothing before it depends on it).
  4. The user-facing contract is untouched. Errors keep their ? <line> : prefixes, LIST/DLOAD/line editing keep working from stored source, and the stored source stays available for debugging.
  5. When performance and any of the above collide, performance loses.

Where the microseconds actually go, measured

Baseline on this machine today: 1088 µs/call (the doc's 881 was the same benchmark on a quieter run of the same class of VM; the shape is identical). A perf record of the full benchmark run attributes the cycles like this:

Share Where What it is
~25% libc memset (AVX loop) akerr_release_error() wiping ~30 KB error contexts
~15% akerr_init, akerr_once, pthread_once, __tls_get_addr, akerr_valid_error_address the per-function-entry error protocol, paid by every tiny helper
~20% scanner_scan, match_identifier, is_at_end, get_lexeme, add_token, environment_zero_parser, leaf_init re-scanning the line from text, character by character, on every execution
~10% parser_is_at_end, parser_match, check, leaf building re-parsing the token stream on every execution
~8% probe, aksl_strhash_djb2, aksl_strlen, aksl_strcmp symbol-table lookups by string
rest evaluation, value clones, verb dispatch, services the part that is actually the program

The call graph behind that first row is the finding of the day:

akbasic_runtime_process_line_run
  akbasic_scanner_scan
    match_identifier
      akbasic_environment_get_function     "is this word a user function?"
        akerr_release_error                miss -> full-context memset, under the pool mutex

The scanner asks "is this identifier a user-defined function?" for every identifier on every line, and the common answer — no — is delivered as a thrown-and-released error context. Each one is an akerr_next_error() (mutex + pool scan) plus a formatted message plus an akerr_release_error() (mutex + ~30 KB memset). akbasic_environment_get()'s scope-chain walk pays the same bill once per scope level for every variable read that is not in the innermost scope — which, inside a DEF body, is every read of anything bound at the root, i.e. SELF@ and ACTOR@ on nearly every line of UPDATEBEE.

A uprobe count makes it concrete: the 24,000-call benchmark takes and releases 3,217,256 error contexts — ~134 per call, ~9 per executed line — on paths where a miss is the expected answer, not a failure.

Two scratch experiments, ~60 lines, 2.2x

To check that this is load-bearing and not just visible, I patched it locally (diff in the appendix — a measurement instrument, not a submission; it passes the interop test but has not been run against the golden corpus):

  1. Gave the symbol table akbasic_symtab_try_get() — a lookup whose miss is a bool, not an error — and used it inside akbasic_environment_get()'s and get_function()'s scope walks.
  2. Gave the scanner akbasic_environment_has_function() so classifying an identifier never throws at all.
State µs/call contexts per run
baseline (today, this machine) 1088.26 3,217,256
+ try_get in the scope walks 715.72
+ has_function for the scanner 493.13 96,025 (= 4 per call, all per-call fixed cost)

2.2x from sixty lines that arguably read better than what they replace — "ask, don't throw" is the same lesson libakerror itself teaches about expected outcomes. A third experiment — bounding symtab_init's memset to capacity slots instead of all 172 — measured no change (716 → 723, inside noise), recorded here so nobody re-runs it expecting more: environment setup is per call, and per-line costs dominate at 15 lines per call.

After the two fixes the profile is: ~28% per-function-entry error protocol (akerr_valid_error_address 8.0%, __tls_get_addr 7.4%, akerr_init 6.3%, akerr_once 3.3%, pthread_once 3.1%), ~25% scanning + parsing, ~8% string-keyed symbol probing, and the remainder real evaluation. That ordering is the rest of the plan.

The plan, in stages

Each stage lands alone, keeps the golden corpus green, and reports its before/after numbers against the benchmark before the next stage starts. Estimates are marked as estimates; only stage 1 has measured numbers.

Stage 0 — make the measurement a fixture

The benchmark exists (examples/galaga/interop_test.c) and already prints µs/call. Add a docs/ note recording how to profile it (perf record -F 999, the uprobe recipe for context counts), and record each stage's numbers in this issue as it lands. No interpreter changes. This is the "reproduce and measure before arguing" rule made cheap enough to keep.

Stage 1 — a miss is an answer, not an error (measured: 2.2x)

Productionize the two experiments: akbasic_symtab_try_get(), akbasic_environment_has_function(), and a sweep of the remaining expected-miss sites (akbasic_environment_get_label()'s walk, environment_create()'s pre-probe, and whatever the post-fix uprobe count says is left — 4 contexts per call remain, all in the host-call fixed cost). Tests in the same commit; symtab_get()'s throwing form stays for the callers where absence is a failure.

Readability check: passes. The scope walk loses an IGNORE(akerr_release_error(...)) incantation per level and gains a bool.

Stage 2 — the error protocol guards boundaries, not characters (estimate: 1.3–1.5x)

is_at_end() is 2% of the whole benchmark: it is called once per scanned character, and each call runs PREPARE_ERROR (an akerr_init() + once-check + TLS touch), a PASS-wrapped aksl_strlen() of the whole line, and akerr_valid_error_address() on the way out. The same shape repeats in peek, peek_next, get_lexeme, leaf_init, token_init, and probe's per-slot aksl_strcmp.

The protocol earns its cost at boundaries — scan this line, parse this statement, execute this statement — where an error context is genuinely useful. Below that boundary, a static helper whose preconditions were validated once at entry can be plain C: measure the line length once per scan, index the buffer directly, compare with strcmp. That is not a loosening of the error discipline; it is the same discipline applied at the altitude where it means something. The scanner's inner loop gets shorter.

A companion note belongs to libakerror: akerr_valid_error_address() costs 8% here purely by call count, and PREPARE_ERROR's akerr_init() re-checks initialization on every function entry of every consumer. Worth an issue there once this repo's call counts stop drowning the signal.

Readability check: passes, with one rule to write down in MAINTENANCE.md: which layer owns validation, so the plain-C helpers do not silently spread upward.

Stage 3 — scan and parse a line when it is filed, not every time it runs (estimate: 2–3x on top; the architectural stage)

docs/14 says it plainly: "Nothing is compiled and nothing is cached. Every time a line executes it is scanned and parsed again, from the source text." After stages 1–2 that re-work is the majority of what remains — call it half the per-line cost, scanning plus parsing plus the per-line pool zeroing that exists only to serve them.

The proposal is a parse cache keyed by source slot, which is the smallest idea that removes the re-work while keeping every contract:

  • source[] stays exactly what it is — the authority that LIST, DSAVE, RENUMBER, and the REPL edit. The cache is derived data: parse results for slot N, valid until slot N is re-filed. akbasic_runtime_file_line() is already the single place a line changes, so invalidation is one line in one function.
  • Error lines, TRON, TRAP, and ER#/EL# are untouched: the cache is indexed by the same slot the line number is.
  • The REPL is untouched: direct-mode lines are parsed and run exactly as today; only stored program lines are cached, and editing one invalidates one slot.

Two prerequisites make this the real work of the stage:

  1. Parse and execution must actually separate. Today akbasic_parse_for() pushes a scope during parsing and parks unevaluated leaves in it, and the statement loop interleaves parse-one/execute-one across a line. A cached line must parse to a statement list with no side effects on the runtime, with FOR's scope push moved to evaluation of the parsed statement. This is a semantics-preserving refactor with real hazard — the waitingForCommand machinery and the comparing-flag = rewrite both live in that seam — and it wants its own design section in docs/14 before code. It also happens to be the shape issues #8 (multi-line DEF at the REPL needs a real line cycle) and #4 (scanner errors escape the ATTEMPT that parsing gets) have been waiting for: filing a line would now be the moment it scans, so an over-long line is refused at 10 ...<enter> with a proper ? 10 : error instead of escaping mid-run.
  2. The cached form must be affordable. An akbasic_ASTLeaf is ~600 bytes because it carries two 256-byte inline strings; 32 leaves × 2048 slots would be ~38 MB, which is not this project. Two candidate shapes, to be settled by measurement, not taste: (a) cached leaves reference their text as offset+length into the stored source line — the line cannot change without the cache being invalidated, so the reference cannot dangle — or (b) a runtime-wide parsed-line pool with a diagnosable exhaustion message, in the house style of every other pool. Either way the runtime stays a fixed-size object a host embeds without an allocator.

Readability check: passes, with the documentation bill paid. "Scan → parse → evaluate, as phases" is the textbook interpreter shape; a newcomer arguably follows it more easily than parse-one-statement-then-run-it. But it must be documented as deliberately as the current design is, and the FOR-at-parse-time subtlety it removes must be recorded in the git history for whoever wonders why the old way existed.

Stage 4 — execution costs, re-profiled after stage 3 (estimate: 1.2–1.5x)

Not worth arguing until the profile is re-taken, but the known candidates, in likely order of value:

  • akbasic_value_clone() copies its full 256-byte string for every value, including integers and floats. Copy the payload the type actually uses. Same for akbasic_leaf_clone()'s two full-array memcpys.
  • Literals re-convert from text on every evaluation today; with stage 3 they convert once at parse time and the cached leaf carries the number.
  • Identifier resolution is a string hash + probe per read. With a cached AST there is a natural home for memoizing leaf → variable-slot, but the invalidation rule (scope push/pop) has to be airtight and simple, or it fails the readability test and gets dropped.

Stage 5 — bytecode and the Machine Language Monitor (explicitly deferred)

Out of scope for this plan, per ground rule 3, and nothing above depends on it. Recorded so the door stays visibly open: after stage 3 exists, a per-line bytecode is a small step (the statement list is nearly a program), it would want the full documentation treatment as a second educational arc, and it is what makes a C128-style MONITOR — inspect, disassemble, single-step — implementable, which would be genuinely cool. Estimated another 3–5x beyond stage 4, and not scheduled.

What the end state looks like, honestly

Stage 1 is measured at 2.2x. Stages 2–4 are estimates from profile shares; compounded, the plausible landing zone is 10–20x from baseline — roughly 50–100 µs per scripted call on this class of machine. Forty thinking enemies then cost 2–4 ms a frame instead of 35–44: comfortably inside 60 Hz, which is the point of the exercise. The gap to C remains three orders of magnitude, and it is supposed to: that residue is the price of behavior-as-data, and per ground rule 5 this plan spends none of the project's readability to chase it further.

Risks, named: stage 3's parse/execute separation is the one that can go wrong in interesting ways (FOR scope timing, waitingForCommand, the =-rewrite). It should not start until the golden corpus covers the block-structure edge cases it threatens, and #24 (runtime.c has never had a complete mutation run) is honest prerequisite work for touching that file with this much intent.

Reproducing the numbers

cmake --build build-akgl --target akbasic_example_galaga_interop
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy perf record -F 999 ./build-akgl/akbasic_example_galaga_interop
perf report --stdio --percent-limit 1

# context churn:
sudo perf probe -x <path>/libakerror.so.2 akerr_next_error akerr_release_error
sudo perf stat -e probe_libakerror:* ./build-akgl/akbasic_example_galaga_interop
Appendix: the scratch diff behind the stage-1 numbers (not for merge)
diff --git a/include/akbasic/environment.h b/include/akbasic/environment.h
index debbb2d..9260168 100644
--- a/include/akbasic/environment.h
+++ b/include/akbasic/environment.h
@@ -279,6 +279,9 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_environment_set_label(akbasic_Environ
  */
 akerr_ErrorContext AKERR_NOIGNORE *akbasic_environment_get_function(akbasic_Environment *obj, const char *fname, void **dest);
 
+/* SCRATCH EXPERIMENT -- not for commit. */
+akerr_ErrorContext AKERR_NOIGNORE *akbasic_environment_has_function(akbasic_Environment *obj, const char *fname, bool *found, void **dest);
+
 /** @brief Assign into the slot an lvalue leaf names, following any subscripts. */
 akerr_ErrorContext AKERR_NOIGNORE *akbasic_environment_assign(akbasic_Environment *obj, akbasic_ASTLeaf *lval, akbasic_Value *rval, akbasic_Value **dest);
 
diff --git a/include/akbasic/symtab.h b/include/akbasic/symtab.h
index 63af692..32c1f14 100644
--- a/include/akbasic/symtab.h
+++ b/include/akbasic/symtab.h
@@ -84,6 +84,9 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_symtab_set(akbasic_SymbolTable *obj,
  */
 akerr_ErrorContext AKERR_NOIGNORE *akbasic_symtab_get(akbasic_SymbolTable *obj, const char *key, void **value, int64_t *ivalue);
 
+/* SCRATCH EXPERIMENT -- not for commit. */
+akerr_ErrorContext AKERR_NOIGNORE *akbasic_symtab_try_get(akbasic_SymbolTable *obj, const char *key, bool *found, void **value);
+
 /**
  * @brief Drop every entry.
  * @param obj Object to initialize, inspect, or modify.
diff --git a/src/environment.c b/src/environment.c
index 77a184a..f7af42a 100644
--- a/src/environment.c
+++ b/src/environment.c
@@ -227,12 +227,11 @@ akerr_ErrorContext *akbasic_environment_get_function(akbasic_Environment *obj, c
     upper[len] = '\0';
 
     while ( obj != NULL ) {
-	akerr_ErrorContext *found = akbasic_symtab_get(&obj->functions, upper, dest, NULL);
-	if ( found == NULL ) {
+	bool present = false;	/* SCRATCH EXPERIMENT -- not for commit. */
+	PASS(errctx, akbasic_symtab_try_get(&obj->functions, upper, &present, dest));
+	if ( present ) {
 	    SUCCEED_RETURN(errctx);
 	}
-	found->handled = true;
-	IGNORE(akerr_release_error(found));
 	obj = obj->parent;
     }
     FAIL_RETURN(errctx, AKERR_KEY, "Function '%s' is not defined", fname);
@@ -284,13 +283,12 @@ akerr_ErrorContext *akbasic_environment_get(akbasic_Environment *obj, const char
 
     *dest = NULL;
     for ( walk = obj; walk != NULL; walk = walk->parent ) {
-	akerr_ErrorContext *found = akbasic_symtab_get(&walk->variables, varname, &slot, NULL);
-	if ( found == NULL ) {
+	bool present = false;	/* SCRATCH EXPERIMENT -- not for commit. */
+	PASS(errctx, akbasic_symtab_try_get(&walk->variables, varname, &present, &slot));
+	if ( present ) {
 	    *dest = (akbasic_Variable *)slot;
 	    SUCCEED_RETURN(errctx);
 	}
-	found->handled = true;
-	IGNORE(akerr_release_error(found));
     }
 
     /*
@@ -557,3 +555,36 @@ akerr_ErrorContext *akbasic_environment_assign(akbasic_Environment *obj, akbasic
     *dest = slot;
     SUCCEED_RETURN(errctx);
 }
+
+/* SCRATCH EXPERIMENT -- not for commit. The scanner asks this question for
+   every identifier on every line; a miss is the common answer and must not
+   cost an error context. */
+akerr_ErrorContext *akbasic_environment_has_function(akbasic_Environment *obj, const char *fname, bool *found, void **dest)
+{
+    PREPARE_ERROR(errctx);
+    char upper[AKBASIC_SYMTAB_MAX_KEY];
+    size_t i = 0;
+    size_t len = 0;
+
+    FAIL_ZERO_RETURN(errctx, (obj != NULL && fname != NULL && found != NULL), AKERR_NULLPOINTER,
+		     "NULL argument in has_function");
+    *found = false;
+    PASS(errctx, aksl_strlen(fname, &len));
+    if ( len >= sizeof(upper) ) {
+	SUCCEED_RETURN(errctx);
+    }
+    for ( i = 0; i < len; i++ ) {
+	char c = fname[i];
+	upper[i] = (char)((c >= 'a' && c <= 'z') ? (c - 'a' + 'A') : c);
+    }
+    upper[len] = '\0';
+
+    while ( obj != NULL ) {
+	PASS(errctx, akbasic_symtab_try_get(&obj->functions, upper, found, dest));
+	if ( *found ) {
+	    SUCCEED_RETURN(errctx);
+	}
+	obj = obj->parent;
+    }
+    SUCCEED_RETURN(errctx);
+}
diff --git a/src/scanner.c b/src/scanner.c
index c49fc69..eda2175 100644
--- a/src/scanner.c
+++ b/src/scanner.c
@@ -361,14 +361,8 @@ static akerr_ErrorContext *match_identifier(akbasic_Runtime *obj)
     }
     PASS(errctx, akbasic_verb_lookup(basename, &verb));
 
-    ATTEMPT {
-	CATCH(errctx, akbasic_environment_get_function(obj->environment, lexeme, &fndef));
-	userfunction = true;
-    } CLEANUP {
-    } PROCESS(errctx) {
-    } HANDLE(errctx, AKERR_KEY) {
-	userfunction = false;
-    } FINISH(errctx, true);
+    /* SCRATCH EXPERIMENT -- not for commit: ask, don't throw. */
+    PASS(errctx, akbasic_environment_has_function(obj->environment, lexeme, &userfunction, &fndef));
 
     if ( obj->tokentype == AKBASIC_TOK_IDENTIFIER ) {
 	if ( verb != NULL ) {
diff --git a/src/symtab.c b/src/symtab.c
index b1f9d81..e222a22 100644
--- a/src/symtab.c
+++ b/src/symtab.c
@@ -60,7 +60,9 @@ akerr_ErrorContext *akbasic_symtab_init(akbasic_SymbolTable *obj, int capacity)
 		     "Symbol table capacity %d out of range 1..%d",
 		     capacity, AKBASIC_SYMTAB_MAX_SLOTS);
 
-    PASS(errctx, aksl_memset(obj, 0, sizeof(*obj)));
+    /* SCRATCH EXPERIMENT -- not for commit: zero the slots in use, not the
+       whole MAX_SLOTS array. probe() never reads past capacity. */
+    PASS(errctx, aksl_memset(obj->slots, 0, sizeof(obj->slots[0]) * (size_t)capacity));
     obj->capacity = capacity;
     obj->count = 0;
     SUCCEED_RETURN(errctx);
@@ -139,3 +141,21 @@ akerr_ErrorContext *akbasic_symtab_clear(akbasic_SymbolTable *obj)
     obj->capacity = capacity;
     SUCCEED_RETURN(errctx);
 }
+
+/* SCRATCH EXPERIMENT -- not for commit. A lookup whose miss is an answer, not
+   an error: no context is taken, so a scope-chain walk stops paying
+   next_error/release_error (lock + ~30KB memset) per miss. */
+akerr_ErrorContext *akbasic_symtab_try_get(akbasic_SymbolTable *obj, const char *key, bool *found, void **value)
+{
+    PREPARE_ERROR(errctx);
+    int slot = 0;
+
+    FAIL_ZERO_RETURN(errctx, (obj != NULL && key != NULL && found != NULL), AKERR_NULLPOINTER,
+		     "NULL argument in symtab try_get");
+    *found = false;
+    PASS(errctx, probe(obj, key, &slot, found));
+    if ( *found && value != NULL ) {
+	*value = obj->slots[slot].value;
+    }
+    SUCCEED_RETURN(errctx);
+}

— Tachikoma (Claude Code, claude-fable-5, 200k)

The GALAGA tutorial's benchmark ([docs/21, Step 11](https://source.starfort.tech/andrew/akbasic/src/branch/galaga-tutorial/docs/21-tutorial-galaga-enemies.md#user-content-step-11-the-cost-measured), PR #37) measured a scripted enemy update at ~881 µs against ~0.01 µs for the same logic in C. This issue is a plan to recover as much of that as the project's ground rules allow, in stages, each stage measured before the next is argued. The numbers below are from profiling the shipped benchmark (`akbasic_example_galaga_interop`, `-O2`, two-core VM, `galaga-tutorial` branch) and from two scratch experiments run against it today. ## Ground rules this plan does not violate These are Andrew's constraints, restated so every stage below can be checked against them: 1. **The source stays readable.** The interpreter is a learning vehicle first. A stage that buys speed by making `src/` harder for a newcomer to follow is rejected on that ground alone. 2. **The architecture stays inside one head.** The current code is the stated upper bound of acceptable complexity. 3. **No compilation to machine code, and bytecode only as a separately-documented future step** (it is stage 5, deferred, and nothing before it depends on it). 4. **The user-facing contract is untouched.** Errors keep their `? <line> :` prefixes, `LIST`/`DLOAD`/line editing keep working from stored source, and the stored source stays available for debugging. 5. **When performance and any of the above collide, performance loses.** ## Where the microseconds actually go, measured Baseline on this machine today: **1088 µs/call** (the doc's 881 was the same benchmark on a quieter run of the same class of VM; the shape is identical). A `perf record` of the full benchmark run attributes the cycles like this: | Share | Where | What it is | |---|---|---| | ~25% | libc `memset` (AVX loop) | `akerr_release_error()` wiping ~30 KB error contexts | | ~15% | `akerr_init`, `akerr_once`, `pthread_once`, `__tls_get_addr`, `akerr_valid_error_address` | the per-function-entry error protocol, paid by every tiny helper | | ~20% | `scanner_scan`, `match_identifier`, `is_at_end`, `get_lexeme`, `add_token`, `environment_zero_parser`, `leaf_init` | re-scanning the line from text, character by character, on every execution | | ~10% | `parser_is_at_end`, `parser_match`, `check`, leaf building | re-parsing the token stream on every execution | | ~8% | `probe`, `aksl_strhash_djb2`, `aksl_strlen`, `aksl_strcmp` | symbol-table lookups by string | | rest | evaluation, value clones, verb dispatch, services | the part that is actually the program | The call graph behind that first row is the finding of the day: ``` akbasic_runtime_process_line_run akbasic_scanner_scan match_identifier akbasic_environment_get_function "is this word a user function?" akerr_release_error miss -> full-context memset, under the pool mutex ``` **The scanner asks "is this identifier a user-defined function?" for every identifier on every line, and the common answer — no — is delivered as a thrown-and-released error context.** Each one is an `akerr_next_error()` (mutex + pool scan) plus a formatted message plus an `akerr_release_error()` (mutex + ~30 KB `memset`). `akbasic_environment_get()`'s scope-chain walk pays the same bill once per scope level for every variable read that is not in the innermost scope — which, inside a `DEF` body, is every read of anything bound at the root, i.e. `SELF@` and `ACTOR@` on nearly every line of `UPDATEBEE`. A uprobe count makes it concrete: the 24,000-call benchmark takes and releases **3,217,256 error contexts** — ~134 per call, ~9 per executed line — on paths where a miss is the expected answer, not a failure. ## Two scratch experiments, ~60 lines, 2.2x To check that this is load-bearing and not just visible, I patched it locally (diff in the appendix — a measurement instrument, not a submission; it passes the interop test but has not been run against the golden corpus): 1. Gave the symbol table `akbasic_symtab_try_get()` — a lookup whose miss is a `bool`, not an error — and used it inside `akbasic_environment_get()`'s and `get_function()`'s scope walks. 2. Gave the scanner `akbasic_environment_has_function()` so classifying an identifier never throws at all. | State | µs/call | contexts per run | |---|---|---| | baseline (today, this machine) | 1088.26 | 3,217,256 | | + try_get in the scope walks | 715.72 | — | | + has_function for the scanner | 493.13 | 96,025 (= 4 per call, all per-call fixed cost) | **2.2x from sixty lines that arguably read better than what they replace** — "ask, don't throw" is the same lesson `libakerror` itself teaches about expected outcomes. A third experiment — bounding `symtab_init`'s memset to `capacity` slots instead of all 172 — measured no change (716 → 723, inside noise), recorded here so nobody re-runs it expecting more: environment setup is per *call*, and per-line costs dominate at 15 lines per call. After the two fixes the profile is: ~28% per-function-entry error protocol (`akerr_valid_error_address` 8.0%, `__tls_get_addr` 7.4%, `akerr_init` 6.3%, `akerr_once` 3.3%, `pthread_once` 3.1%), ~25% scanning + parsing, ~8% string-keyed symbol probing, and the remainder real evaluation. That ordering is the rest of the plan. ## The plan, in stages Each stage lands alone, keeps the golden corpus green, and reports its before/after numbers against the benchmark before the next stage starts. Estimates are marked as estimates; only stage 1 has measured numbers. ### Stage 0 — make the measurement a fixture The benchmark exists (`examples/galaga/interop_test.c`) and already prints µs/call. Add a `docs/` note recording how to profile it (`perf record -F 999`, the uprobe recipe for context counts), and record each stage's numbers in this issue as it lands. No interpreter changes. This is the "reproduce and measure before arguing" rule made cheap enough to keep. ### Stage 1 — a miss is an answer, not an error (measured: 2.2x) Productionize the two experiments: `akbasic_symtab_try_get()`, `akbasic_environment_has_function()`, and a sweep of the remaining expected-miss sites (`akbasic_environment_get_label()`'s walk, `environment_create()`'s pre-probe, and whatever the post-fix uprobe count says is left — 4 contexts per call remain, all in the host-call fixed cost). Tests in the same commit; `symtab_get()`'s throwing form stays for the callers where absence *is* a failure. **Readability check: passes.** The scope walk loses an `IGNORE(akerr_release_error(...))` incantation per level and gains a `bool`. ### Stage 2 — the error protocol guards boundaries, not characters (estimate: 1.3–1.5x) `is_at_end()` is 2% of the whole benchmark: it is called once per scanned character, and each call runs `PREPARE_ERROR` (an `akerr_init()` + once-check + TLS touch), a `PASS`-wrapped `aksl_strlen()` of the whole line, and `akerr_valid_error_address()` on the way out. The same shape repeats in `peek`, `peek_next`, `get_lexeme`, `leaf_init`, `token_init`, and `probe`'s per-slot `aksl_strcmp`. The protocol earns its cost at boundaries — scan this line, parse this statement, execute this statement — where an error context is genuinely useful. Below that boundary, a `static` helper whose preconditions were validated once at entry can be plain C: measure the line length once per scan, index the buffer directly, compare with `strcmp`. That is not a loosening of the error discipline; it is the same discipline applied at the altitude where it means something. The scanner's inner loop gets *shorter*. A companion note belongs to libakerror: `akerr_valid_error_address()` costs 8% here purely by call count, and `PREPARE_ERROR`'s `akerr_init()` re-checks initialization on every function entry of every consumer. Worth an issue there once this repo's call counts stop drowning the signal. **Readability check: passes**, with one rule to write down in MAINTENANCE.md: which layer owns validation, so the plain-C helpers do not silently spread upward. ### Stage 3 — scan and parse a line when it is filed, not every time it runs (estimate: 2–3x on top; the architectural stage) [docs/14](docs/14-architecture.md) says it plainly: *"Nothing is compiled and nothing is cached. Every time a line executes it is scanned and parsed again, from the source text."* After stages 1–2 that re-work is the majority of what remains — call it half the per-line cost, scanning plus parsing plus the per-line pool zeroing that exists only to serve them. The proposal is a **parse cache keyed by source slot**, which is the smallest idea that removes the re-work while keeping every contract: - `source[]` stays exactly what it is — the authority that `LIST`, `DSAVE`, `RENUMBER`, and the REPL edit. The cache is derived data: parse results for slot N, valid until slot N is re-filed. `akbasic_runtime_file_line()` is already the single place a line changes, so invalidation is one line in one function. - Error lines, `TRON`, `TRAP`, and `ER#`/`EL#` are untouched: the cache is indexed by the same slot the line number *is*. - The REPL is untouched: direct-mode lines are parsed and run exactly as today; only stored program lines are cached, and editing one invalidates one slot. Two prerequisites make this the real work of the stage: 1. **Parse and execution must actually separate.** Today `akbasic_parse_for()` pushes a scope *during parsing* and parks unevaluated leaves in it, and the statement loop interleaves parse-one/execute-one across a line. A cached line must parse to a statement list with no side effects on the runtime, with `FOR`'s scope push moved to evaluation of the parsed statement. This is a semantics-preserving refactor with real hazard — the `waitingForCommand` machinery and the `comparing`-flag `=` rewrite both live in that seam — and it wants its own design section in docs/14 before code. It also happens to be the shape issues #8 (multi-line `DEF` at the REPL needs a real line cycle) and #4 (scanner errors escape the ATTEMPT that parsing gets) have been waiting for: filing a line would now be the moment it scans, so an over-long line is refused at `10 ...<enter>` with a proper `? 10 :` error instead of escaping mid-run. 2. **The cached form must be affordable.** An `akbasic_ASTLeaf` is ~600 bytes because it carries two 256-byte inline strings; 32 leaves × 2048 slots would be ~38 MB, which is not this project. Two candidate shapes, to be settled by measurement, not taste: (a) cached leaves reference their text as offset+length into the stored source line — the line cannot change without the cache being invalidated, so the reference cannot dangle — or (b) a runtime-wide parsed-line pool with a diagnosable exhaustion message, in the house style of every other pool. Either way the runtime stays a fixed-size object a host embeds without an allocator. **Readability check: passes, with the documentation bill paid.** "Scan → parse → evaluate, as phases" is the textbook interpreter shape; a newcomer arguably follows it *more* easily than parse-one-statement-then-run-it. But it must be documented as deliberately as the current design is, and the FOR-at-parse-time subtlety it removes must be recorded in the git history for whoever wonders why the old way existed. ### Stage 4 — execution costs, re-profiled after stage 3 (estimate: 1.2–1.5x) Not worth arguing until the profile is re-taken, but the known candidates, in likely order of value: - `akbasic_value_clone()` copies its full 256-byte string for every value, including integers and floats. Copy the payload the type actually uses. Same for `akbasic_leaf_clone()`'s two full-array `memcpy`s. - Literals re-convert from text on every evaluation today; with stage 3 they convert once at parse time and the cached leaf carries the number. - Identifier resolution is a string hash + probe per read. With a cached AST there is a natural home for memoizing leaf → variable-slot, but the invalidation rule (scope push/pop) has to be airtight and *simple*, or it fails the readability test and gets dropped. ### Stage 5 — bytecode and the Machine Language Monitor (explicitly deferred) Out of scope for this plan, per ground rule 3, and nothing above depends on it. Recorded so the door stays visibly open: after stage 3 exists, a per-line bytecode is a small step (the statement list *is* nearly a program), it would want the full documentation treatment as a second educational arc, and it is what makes a C128-style MONITOR — inspect, disassemble, single-step — implementable, which would be genuinely cool. Estimated another 3–5x beyond stage 4, and not scheduled. ## What the end state looks like, honestly Stage 1 is measured at 2.2x. Stages 2–4 are estimates from profile shares; compounded, the plausible landing zone is **10–20x from baseline — roughly 50–100 µs per scripted call** on this class of machine. Forty thinking enemies then cost 2–4 ms a frame instead of 35–44: comfortably inside 60 Hz, which is the point of the exercise. The gap to C remains three orders of magnitude, and it is supposed to: that residue is the price of behavior-as-data, and per ground rule 5 this plan spends none of the project's readability to chase it further. Risks, named: stage 3's parse/execute separation is the one that can go wrong in interesting ways (`FOR` scope timing, `waitingForCommand`, the `=`-rewrite). It should not start until the golden corpus covers the block-structure edge cases it threatens, and #24 (`runtime.c` has never had a complete mutation run) is honest prerequisite work for touching that file with this much intent. ## Reproducing the numbers ``` cmake --build build-akgl --target akbasic_example_galaga_interop SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy perf record -F 999 ./build-akgl/akbasic_example_galaga_interop perf report --stdio --percent-limit 1 # context churn: sudo perf probe -x <path>/libakerror.so.2 akerr_next_error akerr_release_error sudo perf stat -e probe_libakerror:* ./build-akgl/akbasic_example_galaga_interop ``` <details> <summary>Appendix: the scratch diff behind the stage-1 numbers (not for merge)</summary> ```diff diff --git a/include/akbasic/environment.h b/include/akbasic/environment.h index debbb2d..9260168 100644 --- a/include/akbasic/environment.h +++ b/include/akbasic/environment.h @@ -279,6 +279,9 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_environment_set_label(akbasic_Environ */ akerr_ErrorContext AKERR_NOIGNORE *akbasic_environment_get_function(akbasic_Environment *obj, const char *fname, void **dest); +/* SCRATCH EXPERIMENT -- not for commit. */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_environment_has_function(akbasic_Environment *obj, const char *fname, bool *found, void **dest); + /** @brief Assign into the slot an lvalue leaf names, following any subscripts. */ akerr_ErrorContext AKERR_NOIGNORE *akbasic_environment_assign(akbasic_Environment *obj, akbasic_ASTLeaf *lval, akbasic_Value *rval, akbasic_Value **dest); diff --git a/include/akbasic/symtab.h b/include/akbasic/symtab.h index 63af692..32c1f14 100644 --- a/include/akbasic/symtab.h +++ b/include/akbasic/symtab.h @@ -84,6 +84,9 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_symtab_set(akbasic_SymbolTable *obj, */ akerr_ErrorContext AKERR_NOIGNORE *akbasic_symtab_get(akbasic_SymbolTable *obj, const char *key, void **value, int64_t *ivalue); +/* SCRATCH EXPERIMENT -- not for commit. */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_symtab_try_get(akbasic_SymbolTable *obj, const char *key, bool *found, void **value); + /** * @brief Drop every entry. * @param obj Object to initialize, inspect, or modify. diff --git a/src/environment.c b/src/environment.c index 77a184a..f7af42a 100644 --- a/src/environment.c +++ b/src/environment.c @@ -227,12 +227,11 @@ akerr_ErrorContext *akbasic_environment_get_function(akbasic_Environment *obj, c upper[len] = '\0'; while ( obj != NULL ) { - akerr_ErrorContext *found = akbasic_symtab_get(&obj->functions, upper, dest, NULL); - if ( found == NULL ) { + bool present = false; /* SCRATCH EXPERIMENT -- not for commit. */ + PASS(errctx, akbasic_symtab_try_get(&obj->functions, upper, &present, dest)); + if ( present ) { SUCCEED_RETURN(errctx); } - found->handled = true; - IGNORE(akerr_release_error(found)); obj = obj->parent; } FAIL_RETURN(errctx, AKERR_KEY, "Function '%s' is not defined", fname); @@ -284,13 +283,12 @@ akerr_ErrorContext *akbasic_environment_get(akbasic_Environment *obj, const char *dest = NULL; for ( walk = obj; walk != NULL; walk = walk->parent ) { - akerr_ErrorContext *found = akbasic_symtab_get(&walk->variables, varname, &slot, NULL); - if ( found == NULL ) { + bool present = false; /* SCRATCH EXPERIMENT -- not for commit. */ + PASS(errctx, akbasic_symtab_try_get(&walk->variables, varname, &present, &slot)); + if ( present ) { *dest = (akbasic_Variable *)slot; SUCCEED_RETURN(errctx); } - found->handled = true; - IGNORE(akerr_release_error(found)); } /* @@ -557,3 +555,36 @@ akerr_ErrorContext *akbasic_environment_assign(akbasic_Environment *obj, akbasic *dest = slot; SUCCEED_RETURN(errctx); } + +/* SCRATCH EXPERIMENT -- not for commit. The scanner asks this question for + every identifier on every line; a miss is the common answer and must not + cost an error context. */ +akerr_ErrorContext *akbasic_environment_has_function(akbasic_Environment *obj, const char *fname, bool *found, void **dest) +{ + PREPARE_ERROR(errctx); + char upper[AKBASIC_SYMTAB_MAX_KEY]; + size_t i = 0; + size_t len = 0; + + FAIL_ZERO_RETURN(errctx, (obj != NULL && fname != NULL && found != NULL), AKERR_NULLPOINTER, + "NULL argument in has_function"); + *found = false; + PASS(errctx, aksl_strlen(fname, &len)); + if ( len >= sizeof(upper) ) { + SUCCEED_RETURN(errctx); + } + for ( i = 0; i < len; i++ ) { + char c = fname[i]; + upper[i] = (char)((c >= 'a' && c <= 'z') ? (c - 'a' + 'A') : c); + } + upper[len] = '\0'; + + while ( obj != NULL ) { + PASS(errctx, akbasic_symtab_try_get(&obj->functions, upper, found, dest)); + if ( *found ) { + SUCCEED_RETURN(errctx); + } + obj = obj->parent; + } + SUCCEED_RETURN(errctx); +} diff --git a/src/scanner.c b/src/scanner.c index c49fc69..eda2175 100644 --- a/src/scanner.c +++ b/src/scanner.c @@ -361,14 +361,8 @@ static akerr_ErrorContext *match_identifier(akbasic_Runtime *obj) } PASS(errctx, akbasic_verb_lookup(basename, &verb)); - ATTEMPT { - CATCH(errctx, akbasic_environment_get_function(obj->environment, lexeme, &fndef)); - userfunction = true; - } CLEANUP { - } PROCESS(errctx) { - } HANDLE(errctx, AKERR_KEY) { - userfunction = false; - } FINISH(errctx, true); + /* SCRATCH EXPERIMENT -- not for commit: ask, don't throw. */ + PASS(errctx, akbasic_environment_has_function(obj->environment, lexeme, &userfunction, &fndef)); if ( obj->tokentype == AKBASIC_TOK_IDENTIFIER ) { if ( verb != NULL ) { diff --git a/src/symtab.c b/src/symtab.c index b1f9d81..e222a22 100644 --- a/src/symtab.c +++ b/src/symtab.c @@ -60,7 +60,9 @@ akerr_ErrorContext *akbasic_symtab_init(akbasic_SymbolTable *obj, int capacity) "Symbol table capacity %d out of range 1..%d", capacity, AKBASIC_SYMTAB_MAX_SLOTS); - PASS(errctx, aksl_memset(obj, 0, sizeof(*obj))); + /* SCRATCH EXPERIMENT -- not for commit: zero the slots in use, not the + whole MAX_SLOTS array. probe() never reads past capacity. */ + PASS(errctx, aksl_memset(obj->slots, 0, sizeof(obj->slots[0]) * (size_t)capacity)); obj->capacity = capacity; obj->count = 0; SUCCEED_RETURN(errctx); @@ -139,3 +141,21 @@ akerr_ErrorContext *akbasic_symtab_clear(akbasic_SymbolTable *obj) obj->capacity = capacity; SUCCEED_RETURN(errctx); } + +/* SCRATCH EXPERIMENT -- not for commit. A lookup whose miss is an answer, not + an error: no context is taken, so a scope-chain walk stops paying + next_error/release_error (lock + ~30KB memset) per miss. */ +akerr_ErrorContext *akbasic_symtab_try_get(akbasic_SymbolTable *obj, const char *key, bool *found, void **value) +{ + PREPARE_ERROR(errctx); + int slot = 0; + + FAIL_ZERO_RETURN(errctx, (obj != NULL && key != NULL && found != NULL), AKERR_NULLPOINTER, + "NULL argument in symtab try_get"); + *found = false; + PASS(errctx, probe(obj, key, &slot, found)); + if ( *found && value != NULL ) { + *value = obj->slots[slot].value; + } + SUCCEED_RETURN(errctx); +} ``` </details> — Tachikoma (Claude Code, claude-fable-5, 200k)
Author
Collaborator

The libakerror companion issue promised in stage 2 is filed: andrew/libakerror#26 — akerr_release_error()'s whole-context memset (sizeof is 37,296 bytes measured, ~120 GB of zeroes across this benchmark's 3.2M releases) reduced to targeted scalar resets. The one correctness-bearing reset is the handled flag, which FAIL never clears; the issue includes the pool-recycle test that pins it.

The libakerror companion issue promised in stage 2 is filed: andrew/libakerror#26 — akerr_release_error()'s whole-context memset (sizeof is 37,296 bytes measured, ~120 GB of zeroes across this benchmark's 3.2M releases) reduced to targeted scalar resets. The one correctness-bearing reset is the handled flag, which FAIL never clears; the issue includes the pool-recycle test that pins it.
Sign in to join this conversation.