A structure or pointer parameter spends a value-pool slot per call, and sustained calling exhausts the pool #36

Open
opened 2026-08-04 07:27:26 -04:00 by tachikoma · 0 comments
Collaborator

A structure or pointer parameter spends a value-pool slot on every call, and nothing ever gives it back. Sustained calling exhausts the pool — measured at 1,015 calls of a two-pointer-parameter DEF on feature/reduce_memory_usage @ 17af2d4, after which every further call fails with Maximum values per line reached's sibling, Array of 1 elements does not fit in the 0 remaining value slots.

Where

  • src/runtime.c bind_structure_parameter() (~line 992): every call binds each AS TYPE / AS PTR TO TYPE parameter with akbasic_variable_init(variable, &obj->valuepool, sizes, 1).
  • src/variable.c akbasic_variable_init() (~line 95): the @ suffix is the exclusion from the inline-value optimisation, so the init always draws from the pool. The comment states the rule: "A @ name … keeps pool storage because a pointer may outlive the scope that DIMmed it — docs/16-structures.md says nothing is reclaimed and akbasic_runtime_prev_environment() relies on it."
  • src/runtime.c akbasic_runtime_prev_environment() (~line 158): scope pop returns the variable slot and deliberately not its storage.

Each rule is right on its own. Their intersection is the defect: a parameter is a local that dies with the call scope, but it pays the storage price of a DIM that must survive one.

Functional consequence

  • A host calling a DEF with structure or pointer parameters per enemy per frame — the interface issue #34 explored for the GALAGA embed — is dead after ~25 frames at 40 enemies with two pointer args each (2,048-slot pool / 2 slots per call). akbasic_environment_zero() after each call does not help; it resets the per-line scratch, not the value pool.
  • The same wall is reachable from pure BASIC with no host involved: a program that calls DEF F(S@ AS RECT)-shaped functions in a loop dies after ~2,000 calls. A game loop reaches that in about a minute.

Measured on the spike that decided issue #34's interface (pointer args work semantically — writes through P@->X% land in the host struct, the type check refuses a wrong type, by-value copies exactly as documented — so this pool behaviour is the only thing that rules them out):

1. MOVEIT(BEE)  ret=0  BEE.x=104.0 (want 104.0)  BEE.hp=2 (want 2)
2. MOVEIT(WASP) ret=0  WASP.x=204.0 (want 204.0)  WASP.hp=4 (want 4)
3. COPYIT(BEE)  ret=999 (want 999)  BEE.hp=2 (want 2: the copy took the write)
4. pool drained after 1015 calls (251.4 us/call); at 40 enemies x 2 pointer args that is 25.4 frames

Blast radius

Medium. Nothing in tests/ or examples/ trips it today (the reference and language suites call struct-parameter functions a handful of times), so it costs nothing until someone writes the natural thing: a struct-taking function called from a loop. Then it is a hard stop with a message about arrays that names neither the function nor the parameter.

The reduction, for whoever fixes it

The two parameter kinds are not equally hard:

  • A pointer parameter is provably reclaimable. Its own storage is one slot holding the reference; structbase points at the target's slots, not its own. RETURN P@ clones the reference into the caller's scratch, so nothing can retain the parameter's own slot past the pop. PTR TO PTR does not exist, so nothing can point at the parameter either. The cheapest fix may be to let a pointer parameter use the variable's inlinevalue exactly as a scalar does — the exclusion test in akbasic_variable_init() is the spelling of the name, and for ispointer parameters with totalsize == 1 the reason behind the exclusion does not apply.
  • A by-value structure parameter is not, without escape analysis: POINT Q@ AT B@ inside the body, where B@ is the parameter and Q@ an outer pointer, legitimately retains the copy's slots after the pop. Reclaiming those means either proving no POINT ... AT <param> happened or accepting the documented never-reclaim rule for this case and saying so in docs/16.

Until then the guard is: do not call structure-parameter functions from a loop or from a per-frame host path; bind a host global and rebind per instance instead (akbasic_host_rebind(), docs/16-structures.md), which spends nothing per call.


Filed by Tachikoma (Claude Code, Fable 5, 1M context) while executing #34.

A structure or pointer parameter spends a value-pool slot on every call, and nothing ever gives it back. Sustained calling exhausts the pool — measured at 1,015 calls of a two-pointer-parameter `DEF` on `feature/reduce_memory_usage` @ 17af2d4, after which every further call fails with `Maximum values per line reached`'s sibling, `Array of 1 elements does not fit in the 0 remaining value slots`. ## Where - `src/runtime.c` `bind_structure_parameter()` (~line 992): every call binds each `AS TYPE` / `AS PTR TO TYPE` parameter with `akbasic_variable_init(variable, &obj->valuepool, sizes, 1)`. - `src/variable.c` `akbasic_variable_init()` (~line 95): the `@` suffix is the exclusion from the inline-value optimisation, so the init always draws from the pool. The comment states the rule: *"A `@` name … keeps pool storage because a pointer may outlive the scope that DIMmed it — docs/16-structures.md says nothing is reclaimed and akbasic_runtime_prev_environment() relies on it."* - `src/runtime.c` `akbasic_runtime_prev_environment()` (~line 158): scope pop returns the variable *slot* and deliberately not its storage. Each rule is right on its own. Their intersection is the defect: a *parameter* is a local that dies with the call scope, but it pays the storage price of a `DIM` that must survive one. ## Functional consequence - A host calling a `DEF` with structure or pointer parameters per enemy per frame — the interface issue #34 explored for the GALAGA embed — is dead after ~25 frames at 40 enemies with two pointer args each (2,048-slot pool / 2 slots per call). `akbasic_environment_zero()` after each call does not help; it resets the per-line scratch, not the value pool. - The same wall is reachable from pure BASIC with no host involved: a program that calls `DEF F(S@ AS RECT)`-shaped functions in a loop dies after ~2,000 calls. A game loop reaches that in about a minute. Measured on the spike that decided issue #34's interface (pointer args work semantically — writes through `P@->X%` land in the host struct, the type check refuses a wrong type, by-value copies exactly as documented — so this pool behaviour is the *only* thing that rules them out): ``` 1. MOVEIT(BEE) ret=0 BEE.x=104.0 (want 104.0) BEE.hp=2 (want 2) 2. MOVEIT(WASP) ret=0 WASP.x=204.0 (want 204.0) WASP.hp=4 (want 4) 3. COPYIT(BEE) ret=999 (want 999) BEE.hp=2 (want 2: the copy took the write) 4. pool drained after 1015 calls (251.4 us/call); at 40 enemies x 2 pointer args that is 25.4 frames ``` ## Blast radius Medium. Nothing in tests/ or examples/ trips it today (the reference and language suites call struct-parameter functions a handful of times), so it costs nothing until someone writes the natural thing: a struct-taking function called from a loop. Then it is a hard stop with a message about arrays that names neither the function nor the parameter. ## The reduction, for whoever fixes it The two parameter kinds are not equally hard: - **A pointer parameter is provably reclaimable.** Its own storage is one slot holding the reference; `structbase` points at the *target's* slots, not its own. `RETURN P@` clones the reference into the caller's scratch, so nothing can retain the parameter's own slot past the pop. `PTR TO PTR` does not exist, so nothing can point *at* the parameter either. The cheapest fix may be to let a pointer parameter use the variable's `inlinevalue` exactly as a scalar does — the exclusion test in `akbasic_variable_init()` is the spelling of the name, and for `ispointer` parameters with `totalsize == 1` the reason behind the exclusion does not apply. - **A by-value structure parameter is not**, without escape analysis: `POINT Q@ AT B@` inside the body, where `B@` is the parameter and `Q@` an outer pointer, legitimately retains the copy's slots after the pop. Reclaiming those means either proving no `POINT ... AT <param>` happened or accepting the documented never-reclaim rule for this case and saying so in docs/16. Until then the guard is: **do not call structure-parameter functions from a loop or from a per-frame host path**; bind a host global and rebind per instance instead (`akbasic_host_rebind()`, docs/16-structures.md), which spends nothing per call. --- Filed by Tachikoma (Claude Code, Fable 5, 1M context) while executing #34.
tachikoma added the api-gapblast-radius:mediumdefectstatus::grooming labels 2026-08-04 07:27:37 -04:00
Sign in to join this conversation.