I'm trying to make ordinary function calls cheap in GoSSR's interpreter. A page can call plenty of small helpers, and I don't want their calls spending much time finding somewhere for a handful of numbers to live.

My preferred arrangement is fairly lazy. Lend the call some working space, let it get on with the job, then take the space back. A returned closure complicates that by keeping a variable in use. First, though, here's the case I'd like to stay easy:

func sumDown(n int) int {
    if n == 0 {
        return 0
    }
    return n + sumDown(n-1)
}

sumDown(3) returns six. While the call with n = 2 is running, the outer call still needs its three. They share instructions. Each call gets separate locals.

The compiled function already knows how much storage it needs, so I can reserve a region of a larger block, an arena, before making the call. That region can be lent again. First, the call's results and escaping values must be safe. Each small helper avoids negotiating fresh allocations.

Save the caller's place

Here is the allocation sequence as pseudocode:

saved = arena.position
registers = arena.reserve(function.requirements)
run the function using registers
after results and escaping values are safe:
    arena.restore(saved)

For sumDown, this is pleasantly close to the arrangement I had in mind. The calls nest, so their working storage can nest with them. The condition before restore is where the less convenient cases will arrive.

An arena reserves adjacent regions from a larger slab. The register banks hold different kinds of value in separate arrays, so the integer bank can grow with demand without giving every call an equally large string bank.

The inner call returns. Its region is available again. The caller's region stays put. Storage demand follows calls alive together rather than every call made since execution began. My mental picture is a bookmark at the caller's position, with another call working beyond it and a return taking me back to that bookmark. I keep coming back to this because there is so little to do on an ordinary return. Then I start picturing one small helper after another, and.... The bookmark doesn't account for a value somebody keeps, though.

Three states of one register bank: the caller occupies its region, a child borrows the next region, then returning restores the saved cursor so another call can borrow that space.
Figure 1 - Returning the borrowed space. The child works beyond the saved cursor. Once its results and escaping values are safe, restoring the cursor makes that region available again. The caller keeps its own region throughout.

Write zero when the program needs it

Suppose I release an integer slot holding 73, then overwrite it with nine for another call before anybody reads it. Clearing the slot between those operations would add a write whose result nobody observes.

Now a call declares an integer without an initialiser:

var count int
fmt.Println(count) // 0

It must see zero. Reusing the slot doesn't change Go's rules. The compiled instructions have to initialise this variable before it is read. Named results need the same attention.

The page author gets that zero

I'm happy leaving old numbers lying about in the slab if nobody can read them. It looks messy when I inspect the storage, but I can live with messy. What I won't do is write a zero only to overwrite it with nine a moment later. var count int is different though. The page author has every right to that zero.

Clear the references that keep data alive

String and general-value banks have another concern. A string header holds a pointer to the text and its length, rather than the text itself. Old headers and other references in a reachable slab can keep their backing objects alive too.

Those references need clearing when the region is released. Go's garbage collector sees the host program's storage, including the arena. My wonderfully cheap cursor rewind hasn't told it that I've stopped caring about the values above the cursor. I've kept the headers reachable, so it keeps their data for me. Clearing released references and initialising new variables are separate jobs, and neither needs a blanket write over every number in the arena.

Returning a value can extend its life

An integer result can be copied into the caller's destination before I restore the cursor, leaving its old register slot free for another call. A string also needs its backing bytes to survive. Copying its header doesn't give it independent text.

Restoring the cursor gives another call permission to overwrite the region. A value that still needs the old slot has to move somewhere safe first.

I can miss a stale reference while the old slot still holds the expected number, so I'd want another call to reuse that region before trusting a read of the result.

Copying the result works for sumDown. A closure can ask for more than the number currently in a slot. It can keep reading and changing the variable itself after the call has returned.

Give the closures one shared variable

These two functions need to remember the same variable:

func cell() (func(int), func() int) {
    n := 0
    set := func(v int) { n = v }
    get := func() int { return n }
    return set, get
}

Call cell, keep the returned functions, then run set(11). get() must return eleven even though the call that declared n has finished.

I want to reuse cell's region for another call, but these closures still have business there. The call's locals and execution state belong to its frame. Keeping the frame alive would preserve n, along with everything else I wanted to lend again. I'd rather give the shared variable storage that survives the frame.

I find it useful to distinguish keeping a value from keeping a variable. The initial value is zero. The variable is the thing both functions must continue to read and change.

Two independent copies would make the storage straightforward. A copy of n for each closure could stay safe from later calls, but safe in two different variables, while I need the setter to change what the getter reads.

After set(11), the difference is visible in what the getter can read:

Storage for nSetter's valueGetter's value
Separate copies110
One shared cell1111

The bytes survived. The program is wrong.

Giving the pair a shared cell provides an addressable home for captured state. Compiler and runtime code often call a captured variable an upvalue. The name describes access from a function nested inside the declaring function.

Copying the initial value would give the closure a souvenir when it asked for continued access to the variable.

Now make another pair:

setA, getA := cell()
setB, getB := cell()
setA(11)
setB(29)
fmt.Println(getA(), getB()) // 11 29

Every call to cell creates a separate n variable. Sharing belongs within the pair, so I can't use one cell for every closure in the program.

I keep the picture below in mind while working on allocation. It's small enough that I can see the relationship without reading the allocator again. One shared variable per pair, and no sharing between the pairs. The representation can get elaborate underneath. I still want to be able to draw what it is doing.

setA and getA refer to one shared variable holding 11. setB and getB refer to a separate shared variable holding 29. Neither pair shares its variable with the other pair.
Figure 2 - The variable survives the frame. Arrows mean refers to. Each pair keeps its own shared variable after the call that created it returns.

Every update has to reach it

Allocating the cell is only part of implementing the relationship. Reads and writes must use it consistently, including operations that don't look like a plain assignment:

func counter() (func(), func() int) {
    n := 0
    inc := func() { n++ }
    get := func() int { return n }
    return inc, get
}

Two calls to inc should make get return two, but lowering n++ through the ordinary local-register path would update the wrong storage even if ordinary assignment correctly found the captured variable.

My interpreter still has this gap. Increment and decrement statements fail to find the captured variable, and the known-bug tests record it. Other skipped cases cover compound assignment and capture through nested functions. This is frustratingly small code to still have wrong. I've given the variable somewhere to live, then let an ordinary n++ update somewhere else.

At least I can work on it without dragging a whole page through the debugger, two operations, one variable and one expected answer are enough. I can follow the update directly instead of wondering which part of the page lost it.

The outer function can also read or change the variable while it is still running. Creating a closure doesn't freeze n until return, the declaring frame and its closures must agree while their lifetimes overlap, then the cell must survive for whichever closures remain.

I'm still attached to the bookmark for ordinary locals. Those in sumDown can go back with it. A capture whose value never changes can use a cheaper copy where the compiler establishes that copying preserves every observation.

The two functions returned by cell pay for a shared variable elsewhere. Each pair keeps working after its declaring call returns, while the frame goes back into use. I've acquired more bookkeeping than save-and-rewind, but I don't have to make every plain local pay for a closure another function might return.

Everybody's dead, Dave. Except n. There's always one. Watch on YouTube.

This entry came over from blog.haddon.me. I’ve tidied up the grammar and flow, bringing it closer to how I write now. I used AI to help with the grammar, structure and flow. I have dyslexia, and my writing often comes out as a rambling scrawl, so it can be hard to see what I was actually working on at the time. I think the tidied versions are easier to digest and more interesting to read. The artefacts got the same tidy-up while I was at it.

  • Read next

    Don't Panic

    Recovering a panic doesn't finish a function's work. I'm trying to keep its defers and final answer together.