Skip to content

VM performance pass: pooled VMs, slab-allocated numbers, fast paths, stack-overflow fix - #4

Open
xnacly wants to merge 16 commits into
masterfrom
perf-improvements
Open

xnacly wants to merge 16 commits into
masterfrom
perf-improvements

Conversation

@xnacly

@xnacly xnacly commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Baseline (master) vs branch tip, benchstat over 6 interleaved runs, AMD Ryzen 7 3700X, go1.27.1.

benchmark time allocs
ShortScript (per Compiled.Run fixed cost) 10.8us -> 0.96us (-91%) 90KB -> 560B
FloatLoop -30% -97%
ArithLoop / CompareLoop -26% -97%
ClosureCalls / MethodCalls -27% / -19% -97%
ArrayIter / ArrayIndex -13% / -16% -97%
MapAccess -12% -97%
StdlibCalls -36% -82%
MapIter -15% -96%
Fib (pure dispatch, no allocs) -9% 21 -> 0
geomean over all VM benchmarks -30.7% time, -58% bytes/op
Compile (1000 functions) +16% +15% bytes

xnacly added 7 commits October 4, 2026 13:01
Most embedded uses compile a script once and run it many times, so the
fixed cost of a single Run matters as much as loop throughput. ShortScript
builds a small map, indexes it and formats a string, finishing in a few
microseconds; it exposes per-Run overheads that the loop-heavy benchmarks
hide.
The dispatch loop read and wrote ip, sp, the current instruction slice,
the current frame and the alloc counter through the *VM pointer on every
instruction. They now live in locals for the whole run and are written
back to the VM once when the loop exits (error, OpSuspend or abort),
which is all the error reporting in Run needs. Callees are matched with
a type switch so compiled, builtin and user functions skip the
CanCall/Call interface calls; argument windows are moved with copy
instead of element loops; iterator opcodes replace the stack top in
place instead of pop-then-push.

Measured on its own this is neutral. The Go register allocator spills
the locals to frame slots at the loop header before the jump-table
dispatch (eight stores per instruction), so the memory traffic moved
from *VM fields to the stack frame rather than disappearing. Fib gains
5.5%; ArrayIndex loses 20% without executing any changed code, which is
code-layout sensitivity of this large function, not a cost of the change
(see the hardware-counter comparison in the PR description). The
restructuring is kept because the following commits are written against
it: the single exit point, the in-loop fast paths and the per-VM
allocators all rely on the state being local.

Benchmarks: benchstat over 6 runs, previous commit vs this one
(AMD Ryzen 7 3700X, go1.27.1):

goos: linux
goarch: amd64
pkg: github.com/d5/tengo/v2
cpu: AMD Ryzen 7 3700X 8-Core Processor
                   │ 0-baseline.txt │            1-locals.txt            │
                   │     sec/op     │   sec/op     vs base               │
Compile-16             37.51m ±  3%   36.79m ± 2%        ~ (p=0.240 n=6)
VM/ArithLoop-16        33.77m ±  1%   33.80m ± 1%        ~ (p=0.485 n=6)
VM/FloatLoop-16        50.91m ±  1%   51.08m ± 1%        ~ (p=0.093 n=6)
VM/CompareLoop-16      31.85m ±  0%   32.34m ± 0%   +1.55% (p=0.002 n=6)
VM/Fib-16              3.678m ±  0%   3.474m ± 1%   -5.55% (p=0.002 n=6)
VM/ClosureCalls-16     20.55m ±  1%   21.06m ± 1%   +2.44% (p=0.002 n=6)
VM/MapAccess-16        43.95m ±  0%   45.29m ± 1%   +3.05% (p=0.002 n=6)
VM/MethodCalls-16      43.40m ±  0%   42.95m ± 1%   -1.03% (p=0.002 n=6)
VM/ArrayIter-16        10.83m ±  0%   10.74m ± 1%   -0.87% (p=0.009 n=6)
VM/ArrayIndex-16       26.32m ±  1%   31.49m ± 2%  +19.67% (p=0.002 n=6)
VM/StringOps-16        11.98m ±  1%   12.42m ± 0%   +3.67% (p=0.002 n=6)
VM/Builtins-16         32.08m ±  1%   32.81m ± 1%   +2.30% (p=0.002 n=6)
VM/StdlibCalls-16      14.41m ±  3%   13.63m ± 1%   -5.41% (p=0.002 n=6)
VM/MapIter-16          24.17m ±  1%   24.84m ± 0%   +2.76% (p=0.002 n=6)
VM/ShortScript-16      12.56µ ± 15%   13.51µ ± 8%        ~ (p=0.065 n=6)
geomean                13.91m         14.16m        +1.76%

                   │ 0-baseline.txt │             1-locals.txt             │
                   │      B/op      │     B/op      vs base                │
Compile-16             20.09Mi ± 0%   20.08Mi ± 0%       ~ (p=0.258 n=6)
VM/ArithLoop-16        9.226Mi ± 0%   9.226Mi ± 0%       ~ (p=0.160 n=6)
VM/FloatLoop-16        16.86Mi ± 0%   16.86Mi ± 0%       ~ (p=0.253 n=6)
VM/CompareLoop-16      3.507Mi ± 0%   3.507Mi ± 0%       ~ (p=0.870 n=6)
VM/Fib-16              88.16Ki ± 0%   88.16Ki ± 0%       ~ (p=1.000 n=6)
VM/ClosureCalls-16     3.122Mi ± 0%   3.122Mi ± 0%       ~ (p=0.156 n=6)
VM/MapAccess-16        6.166Mi ± 0%   6.166Mi ± 0%       ~ (p=0.669 n=6)
VM/MethodCalls-16      3.122Mi ± 0%   3.122Mi ± 0%       ~ (p=0.506 n=6)
VM/ArrayIter-16        1.677Mi ± 0%   1.677Mi ± 0%       ~ (p=0.139 n=6)
VM/ArrayIndex-16       3.186Mi ± 0%   3.186Mi ± 0%  +0.00% (p=0.050 n=6)
VM/StringOps-16        5.445Mi ± 0%   5.445Mi ± 0%       ~ (p=0.266 n=6)
VM/Builtins-16         3.122Mi ± 0%   3.122Mi ± 0%       ~ (p=0.965 n=6)
VM/StdlibCalls-16      3.893Mi ± 0%   3.511Mi ± 0%  -9.80% (p=0.002 n=6)
VM/MapIter-16          14.40Mi ± 0%   14.40Mi ± 0%       ~ (p=0.623 n=6)
VM/ShortScript-16      88.56Ki ± 0%   88.56Ki ± 0%       ~ (p=1.000 n=6) ¹
geomean                3.113Mi        3.092Mi       -0.69%
¹ all samples are equal

                   │ 0-baseline.txt │             1-locals.txt             │
                   │   allocs/op    │  allocs/op   vs base                 │
Compile-16              273.2k ± 0%   273.2k ± 0%        ~ (p=0.892 n=6)
VM/ArithLoop-16         1.198M ± 0%   1.198M ± 0%        ~ (p=1.000 n=6) ¹
VM/FloatLoop-16         2.199M ± 0%   2.199M ± 0%        ~ (p=1.000 n=6) ¹
VM/CompareLoop-16       448.5k ± 0%   448.5k ± 0%        ~ (p=1.000 n=6) ¹
VM/Fib-16                21.00 ± 0%    21.00 ± 0%        ~ (p=1.000 n=6) ¹
VM/ClosureCalls-16      398.0k ± 0%   398.0k ± 0%        ~ (p=1.000 n=6) ¹
VM/MapAccess-16         796.9k ± 0%   796.9k ± 0%        ~ (p=1.000 n=6) ¹
VM/MethodCalls-16       398.0k ± 0%   398.0k ± 0%        ~ (p=1.000 n=6) ¹
VM/ArrayIter-16         201.2k ± 0%   201.2k ± 0%        ~ (p=1.000 n=6) ¹
VM/ArrayIndex-16        399.9k ± 0%   399.9k ± 0%        ~ (p=1.000 n=6) ¹
VM/StringOps-16         203.0k ± 0%   203.0k ± 0%        ~ (p=1.000 n=6) ¹
VM/Builtins-16          398.0k ± 0%   398.0k ± 0%        ~ (p=1.000 n=6) ¹
VM/StdlibCalls-16       299.0k ± 0%   249.0k ± 0%  -16.72% (p=0.002 n=6)
VM/MapIter-16           411.1k ± 0%   411.1k ± 0%        ~ (p=1.000 n=6)
VM/ShortScript-16        12.00 ± 0%    12.00 ± 0%        ~ (p=1.000 n=6) ¹
geomean                 115.1k        113.7k        -1.21%
¹ all samples are equal
…l tests

Arithmetic and comparisons on two Ints or two Floats are handled in the
loop without the BinaryOp interface call and the operand type switch
behind it. A comparison immediately followed by OpJumpFalsy (every loop
condition and most ifs) jumps directly without materializing a Bool on
the stack. Falsiness tests read the Bool field directly, and == / != on
two Ints compare values inline. Division and remainder keep their
previous behaviour for a zero divisor by falling back to BinaryOp.

Measured on its own this is within layout noise (geomean -1.2%). The
interface call and type switch it removes are a small part of the ~75
native instructions per bytecode op; allocation dominates, and this
commit still allocates every result. FloatLoop is 7.8% slower here
because Float.BinaryOp returned the left operand unchanged when the
result equalled it, saving an allocation that the fast path does not
save; the next commit makes the allocation itself cheap. The fast paths
are where the slab allocator of the next commit plugs in, which is where
the measured gain appears.

Benchmarks: benchstat over 6 runs, previous commit vs this one
(AMD Ryzen 7 3700X, go1.27.1):

goos: linux
goarch: amd64
pkg: github.com/d5/tengo/v2
cpu: AMD Ryzen 7 3700X 8-Core Processor
                   │ 1-locals.txt │             2-fast.txt              │
                   │    sec/op    │    sec/op     vs base               │
Compile-16            36.79m ± 2%   37.57m ±  1%   +2.10% (p=0.041 n=6)
VM/ArithLoop-16       33.80m ± 1%   35.10m ±  0%   +3.85% (p=0.002 n=6)
VM/FloatLoop-16       51.08m ± 1%   55.05m ±  1%   +7.76% (p=0.002 n=6)
VM/CompareLoop-16     32.34m ± 0%   32.73m ±  0%   +1.20% (p=0.002 n=6)
VM/Fib-16             3.474m ± 1%   3.475m ±  1%        ~ (p=0.818 n=6)
VM/ClosureCalls-16    21.06m ± 1%   19.76m ±  0%   -6.17% (p=0.002 n=6)
VM/MapAccess-16       45.29m ± 1%   46.54m ±  1%   +2.77% (p=0.002 n=6)
VM/MethodCalls-16     42.95m ± 1%   44.85m ±  0%   +4.44% (p=0.002 n=6)
VM/ArrayIter-16       10.74m ± 1%   10.95m ±  1%   +1.97% (p=0.002 n=6)
VM/ArrayIndex-16      31.49m ± 2%   28.44m ±  1%   -9.70% (p=0.002 n=6)
VM/StringOps-16       12.42m ± 0%   11.99m ±  1%   -3.48% (p=0.002 n=6)
VM/Builtins-16        32.81m ± 1%   31.84m ±  0%   -2.97% (p=0.002 n=6)
VM/StdlibCalls-16     13.63m ± 1%   12.67m ±  3%   -7.07% (p=0.002 n=6)
VM/MapIter-16         24.84m ± 0%   24.67m ±  2%   -0.69% (p=0.041 n=6)
VM/ShortScript-16     13.51µ ± 8%   12.12µ ± 10%  -10.30% (p=0.015 n=6)
geomean               14.16m        13.99m         -1.22%

                   │ 1-locals.txt │              2-fast.txt              │
                   │     B/op     │     B/op      vs base                │
Compile-16           20.08Mi ± 0%   20.08Mi ± 0%       ~ (p=0.732 n=6)
VM/ArithLoop-16      9.226Mi ± 0%   9.226Mi ± 0%       ~ (p=0.673 n=6)
VM/FloatLoop-16      16.86Mi ± 0%   16.86Mi ± 0%  +0.00% (p=0.002 n=6)
VM/CompareLoop-16    3.507Mi ± 0%   3.507Mi ± 0%       ~ (p=0.201 n=6)
VM/Fib-16            88.16Ki ± 0%   88.16Ki ± 0%       ~ (p=1.000 n=6)
VM/ClosureCalls-16   3.122Mi ± 0%   3.122Mi ± 0%       ~ (p=0.452 n=6)
VM/MapAccess-16      6.166Mi ± 0%   6.166Mi ± 0%  -0.00% (p=0.004 n=6)
VM/MethodCalls-16    3.122Mi ± 0%   3.122Mi ± 0%       ~ (p=0.292 n=6)
VM/ArrayIter-16      1.677Mi ± 0%   1.677Mi ± 0%       ~ (p=0.058 n=6)
VM/ArrayIndex-16     3.186Mi ± 0%   3.186Mi ± 0%       ~ (p=0.139 n=6)
VM/StringOps-16      5.445Mi ± 0%   5.445Mi ± 0%       ~ (p=0.095 n=6)
VM/Builtins-16       3.122Mi ± 0%   3.122Mi ± 0%       ~ (p=0.907 n=6)
VM/StdlibCalls-16    3.511Mi ± 0%   3.511Mi ± 0%       ~ (p=0.870 n=6)
VM/MapIter-16        14.40Mi ± 0%   14.40Mi ± 0%       ~ (p=0.535 n=6)
VM/ShortScript-16    88.56Ki ± 0%   88.56Ki ± 0%       ~ (p=1.000 n=6) ¹
geomean              3.092Mi        3.092Mi       -0.00%
¹ all samples are equal

                   │ 1-locals.txt │             2-fast.txt              │
                   │  allocs/op   │  allocs/op   vs base                │
Compile-16            273.2k ± 0%   273.2k ± 0%       ~ (p=1.000 n=6)
VM/ArithLoop-16       1.198M ± 0%   1.198M ± 0%       ~ (p=1.000 n=6) ¹
VM/FloatLoop-16       2.199M ± 0%   2.199M ± 0%  +0.00% (p=0.002 n=6)
VM/CompareLoop-16     448.5k ± 0%   448.5k ± 0%       ~ (p=1.000 n=6) ¹
VM/Fib-16              21.00 ± 0%    21.00 ± 0%       ~ (p=1.000 n=6) ¹
VM/ClosureCalls-16    398.0k ± 0%   398.0k ± 0%       ~ (p=1.000 n=6) ¹
VM/MapAccess-16       796.9k ± 0%   796.9k ± 0%       ~ (p=1.000 n=6) ¹
VM/MethodCalls-16     398.0k ± 0%   398.0k ± 0%       ~ (p=1.000 n=6) ¹
VM/ArrayIter-16       201.2k ± 0%   201.2k ± 0%       ~ (p=1.000 n=6) ¹
VM/ArrayIndex-16      399.9k ± 0%   399.9k ± 0%       ~ (p=1.000 n=6) ¹
VM/StringOps-16       203.0k ± 0%   203.0k ± 0%       ~ (p=1.000 n=6) ¹
VM/Builtins-16        398.0k ± 0%   398.0k ± 0%       ~ (p=1.000 n=6) ¹
VM/StdlibCalls-16     249.0k ± 0%   249.0k ± 0%       ~ (p=1.000 n=6) ¹
VM/MapIter-16         411.1k ± 0%   411.1k ± 0%       ~ (p=1.000 n=6)
VM/ShortScript-16      12.00 ± 0%    12.00 ± 0%       ~ (p=1.000 n=6) ¹
geomean               113.7k        113.7k       +0.00%
¹ all samples are equal
After the previous change, allocation was the largest remaining cost in
numeric code: every Int above the small-int table and every Float is a
separate 8-byte heap object, and mallocgc plus GC accounting cost far
more than the arithmetic itself. The arithmetic fast paths now carve Ints
and Floats out of 32-element slabs owned by the VM, so one malloc serves
32 results.

Trade-off: a slab stays alive while any number in it is referenced, so a
script that keeps one result out of every 32 it computes retains 256
bytes instead of 8 for it. The alloc-limit accounting is unchanged; it
still counts objects.

Benchmarks: benchstat over 6 runs, previous commit vs this one
(AMD Ryzen 7 3700X, go1.27.1):

goos: linux
goarch: amd64
pkg: github.com/d5/tengo/v2
cpu: AMD Ryzen 7 3700X 8-Core Processor
                   │  2-fast.txt   │             3-slab.txt             │
                   │    sec/op     │   sec/op     vs base               │
Compile-16            37.57m ±  1%   37.02m ± 3%        ~ (p=0.132 n=6)
VM/ArithLoop-16       35.10m ±  0%   29.48m ± 0%  -16.03% (p=0.002 n=6)
VM/FloatLoop-16       55.05m ±  1%   42.75m ± 2%  -22.34% (p=0.002 n=6)
VM/CompareLoop-16     32.73m ±  0%   32.52m ± 0%   -0.62% (p=0.002 n=6)
VM/Fib-16             3.475m ±  1%   3.758m ± 0%   +8.14% (p=0.002 n=6)
VM/ClosureCalls-16    19.76m ±  0%   21.11m ± 0%   +6.86% (p=0.002 n=6)
VM/MapAccess-16       46.54m ±  1%   40.85m ± 1%  -12.24% (p=0.002 n=6)
VM/MethodCalls-16     44.85m ±  0%   40.62m ± 0%   -9.43% (p=0.002 n=6)
VM/ArrayIter-16      10.950m ±  1%   9.319m ± 1%  -14.90% (p=0.002 n=6)
VM/ArrayIndex-16      28.44m ±  1%   26.22m ± 2%   -7.80% (p=0.002 n=6)
VM/StringOps-16       11.99m ±  1%   12.15m ± 0%   +1.39% (p=0.002 n=6)
VM/Builtins-16        31.84m ±  0%   30.23m ± 1%   -5.04% (p=0.002 n=6)
VM/StdlibCalls-16     12.67m ±  3%   12.76m ± 0%        ~ (p=0.065 n=6)
VM/MapIter-16         24.67m ±  2%   23.37m ± 0%   -5.27% (p=0.002 n=6)
VM/ShortScript-16     12.12µ ± 10%   12.39µ ± 5%        ~ (p=0.485 n=6)
geomean               13.99m         13.23m        -5.44%

                   │  2-fast.txt  │              3-slab.txt              │
                   │     B/op     │     B/op      vs base                │
Compile-16           20.08Mi ± 0%   20.09Mi ± 0%       ~ (p=0.699 n=6)
VM/ArithLoop-16      9.226Mi ± 0%   9.226Mi ± 0%  +0.00% (p=0.002 n=6)
VM/FloatLoop-16      16.86Mi ± 0%   16.86Mi ± 0%  +0.00% (p=0.002 n=6)
VM/CompareLoop-16    3.507Mi ± 0%   3.508Mi ± 0%  +0.01% (p=0.002 n=6)
VM/Fib-16            88.16Ki ± 0%   88.25Ki ± 0%  +0.11% (p=0.002 n=6)
VM/ClosureCalls-16   3.122Mi ± 0%   3.122Mi ± 0%  +0.01% (p=0.002 n=6)
VM/MapAccess-16      6.166Mi ± 0%   6.166Mi ± 0%  +0.00% (p=0.013 n=6)
VM/MethodCalls-16    3.122Mi ± 0%   3.123Mi ± 0%  +0.01% (p=0.002 n=6)
VM/ArrayIter-16      1.677Mi ± 0%   1.677Mi ± 0%  +0.01% (p=0.002 n=6)
VM/ArrayIndex-16     3.186Mi ± 0%   3.186Mi ± 0%  +0.00% (p=0.002 n=6)
VM/StringOps-16      5.445Mi ± 0%   5.258Mi ± 0%  -3.43% (p=0.002 n=6)
VM/Builtins-16       3.122Mi ± 0%   3.122Mi ± 0%  +0.01% (p=0.002 n=6)
VM/StdlibCalls-16    3.511Mi ± 0%   3.512Mi ± 0%  +0.01% (p=0.002 n=6)
VM/MapIter-16        14.40Mi ± 0%   14.40Mi ± 0%  +0.00% (p=0.002 n=6)
VM/ShortScript-16    88.56Ki ± 0%   88.56Ki ± 0%       ~ (p=1.000 n=6) ¹
geomean              3.092Mi        3.085Mi       -0.22%
¹ all samples are equal

                   │  2-fast.txt   │              3-slab.txt              │
                   │   allocs/op   │  allocs/op   vs base                 │
Compile-16             273.2k ± 0%   273.2k ± 0%        ~ (p=1.000 n=6)
VM/ArithLoop-16      1197.95k ± 0%   37.44k ± 0%  -96.87% (p=0.002 n=6)
VM/FloatLoop-16      2198.98k ± 0%   68.72k ± 0%  -96.87% (p=0.002 n=6)
VM/CompareLoop-16     448.45k ± 0%   14.02k ± 0%  -96.87% (p=0.002 n=6)
VM/Fib-16              21.000 ± 0%    2.000 ± 0%  -90.48% (p=0.002 n=6)
VM/ClosureCalls-16    397.96k ± 0%   12.44k ± 0%  -96.87% (p=0.002 n=6)
VM/MapAccess-16       796.90k ± 0%   24.91k ± 0%  -96.87% (p=0.002 n=6)
VM/MethodCalls-16     397.96k ± 0%   12.44k ± 0%  -96.87% (p=0.002 n=6)
VM/ArrayIter-16      201.168k ± 0%   7.462k ± 0%  -96.29% (p=0.002 n=6)
VM/ArrayIndex-16      399.94k ± 0%   13.48k ± 0%  -96.63% (p=0.002 n=6)
VM/StringOps-16        203.0k ± 0%   151.7k ± 0%  -25.28% (p=0.002 n=6)
VM/Builtins-16        397.96k ± 0%   12.44k ± 0%  -96.87% (p=0.002 n=6)
VM/StdlibCalls-16      249.0k ± 0%   153.1k ± 0%  -38.51% (p=0.002 n=6)
VM/MapIter-16          411.1k ± 0%   214.5k ± 0%  -47.82% (p=0.002 n=6)
VM/ShortScript-16       12.00 ± 0%    12.00 ± 0%        ~ (p=1.000 n=6) ¹
geomean                113.7k        11.23k       -90.12%
¹ all samples are equal
- indexed assignment (OpSetSel*) read its selectors into a fresh slice on
  every store; they are now read in place from the operand stack, which
  nothing writes to until indexAssign has returned
- OpArray grew its element slice with append; it is presized and copied
- OpMap sized its map for twice the number of entries
- Array/ImmutableArray Copy and String presize their slices; Map copies
  presize the map
- Bytes indexing, bytes/string iterator keys and time subtraction use the
  shared small-int table instead of allocating

Benchmarks: benchstat over 6 runs, previous commit vs this one
(AMD Ryzen 7 3700X, go1.27.1):

goos: linux
goarch: amd64
pkg: github.com/d5/tengo/v2
cpu: AMD Ryzen 7 3700X 8-Core Processor
                   │ 3-slab.txt  │            4-alloc.txt             │
                   │   sec/op    │   sec/op     vs base               │
Compile-16           37.02m ± 3%   36.39m ± 5%        ~ (p=0.394 n=6)
VM/ArithLoop-16      29.48m ± 0%   37.16m ± 0%  +26.07% (p=0.002 n=6)
VM/FloatLoop-16      42.75m ± 2%   49.78m ± 0%  +16.44% (p=0.002 n=6)
VM/CompareLoop-16    32.52m ± 0%   35.54m ± 1%   +9.30% (p=0.002 n=6)
VM/Fib-16            3.758m ± 0%   3.579m ± 0%   -4.76% (p=0.002 n=6)
VM/ClosureCalls-16   21.11m ± 0%   19.98m ± 0%   -5.39% (p=0.002 n=6)
VM/MapAccess-16      40.85m ± 1%   44.90m ± 1%   +9.93% (p=0.002 n=6)
VM/MethodCalls-16    40.62m ± 0%   41.35m ± 0%   +1.78% (p=0.002 n=6)
VM/ArrayIter-16      9.319m ± 1%   9.726m ± 0%   +4.36% (p=0.002 n=6)
VM/ArrayIndex-16     26.22m ± 2%   33.49m ± 0%  +27.74% (p=0.002 n=6)
VM/StringOps-16      12.15m ± 0%   12.52m ± 0%   +3.04% (p=0.002 n=6)
VM/Builtins-16       30.23m ± 1%   34.23m ± 1%  +13.24% (p=0.002 n=6)
VM/StdlibCalls-16    12.76m ± 0%   13.15m ± 0%   +3.12% (p=0.002 n=6)
VM/MapIter-16        23.37m ± 0%   22.72m ± 0%   -2.78% (p=0.002 n=6)
VM/ShortScript-16    12.39µ ± 5%   12.33µ ± 2%        ~ (p=0.554 n=6)
geomean              13.23m        14.05m        +6.20%

                   │  3-slab.txt  │             4-alloc.txt              │
                   │     B/op     │     B/op      vs base                │
Compile-16           20.09Mi ± 0%   20.09Mi ± 0%       ~ (p=0.818 n=6)
VM/ArithLoop-16      9.226Mi ± 0%   9.226Mi ± 0%       ~ (p=0.264 n=6)
VM/FloatLoop-16      16.86Mi ± 0%   16.86Mi ± 0%       ~ (p=0.056 n=6)
VM/CompareLoop-16    3.508Mi ± 0%   3.508Mi ± 0%       ~ (p=1.000 n=6)
VM/Fib-16            88.25Ki ± 0%   88.25Ki ± 0%       ~ (p=1.000 n=6) ¹
VM/ClosureCalls-16   3.122Mi ± 0%   3.122Mi ± 0%  +0.00% (p=0.006 n=6)
VM/MapAccess-16      6.166Mi ± 0%   6.166Mi ± 0%       ~ (p=0.242 n=6)
VM/MethodCalls-16    3.123Mi ± 0%   3.123Mi ± 0%       ~ (p=0.727 n=6)
VM/ArrayIter-16      1.677Mi ± 0%   1.677Mi ± 0%       ~ (p=0.165 n=6)
VM/ArrayIndex-16     3.186Mi ± 0%   3.186Mi ± 0%       ~ (p=0.361 n=6)
VM/StringOps-16      5.258Mi ± 0%   5.258Mi ± 0%       ~ (p=0.781 n=6)
VM/Builtins-16       3.122Mi ± 0%   3.122Mi ± 0%  -0.00% (p=0.002 n=6)
VM/StdlibCalls-16    3.512Mi ± 0%   3.512Mi ± 0%       ~ (p=0.167 n=6)
VM/MapIter-16        14.40Mi ± 0%   14.40Mi ± 0%       ~ (p=0.084 n=6)
VM/ShortScript-16    88.56Ki ± 0%   88.55Ki ± 0%  -0.02% (p=0.002 n=6)
geomean              3.085Mi        3.085Mi       -0.00%
¹ all samples are equal

                   │ 3-slab.txt  │             4-alloc.txt             │
                   │  allocs/op  │  allocs/op   vs base                │
Compile-16           273.2k ± 0%   273.2k ± 0%       ~ (p=1.000 n=6)
VM/ArithLoop-16      37.44k ± 0%   37.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/FloatLoop-16      68.72k ± 0%   68.72k ± 0%       ~ (p=1.000 n=6) ¹
VM/CompareLoop-16    14.02k ± 0%   14.02k ± 0%       ~ (p=1.000 n=6) ¹
VM/Fib-16             2.000 ± 0%    2.000 ± 0%       ~ (p=1.000 n=6) ¹
VM/ClosureCalls-16   12.44k ± 0%   12.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/MapAccess-16      24.91k ± 0%   24.91k ± 0%       ~ (p=1.000 n=6) ¹
VM/MethodCalls-16    12.44k ± 0%   12.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/ArrayIter-16      7.462k ± 0%   7.462k ± 0%       ~ (p=1.000 n=6) ¹
VM/ArrayIndex-16     13.48k ± 0%   13.48k ± 0%       ~ (p=1.000 n=6) ¹
VM/StringOps-16      151.7k ± 0%   151.7k ± 0%       ~ (p=1.000 n=6) ¹
VM/Builtins-16       12.44k ± 0%   12.44k ± 0%  -0.02% (p=0.002 n=6)
VM/StdlibCalls-16    153.1k ± 0%   153.1k ± 0%       ~ (p=1.000 n=6) ¹
VM/MapIter-16        214.5k ± 0%   214.5k ± 0%       ~ (p=1.000 n=6) ¹
VM/ShortScript-16     12.00 ± 0%    11.00 ± 0%  -8.33% (p=0.002 n=6)
geomean              11.23k        11.16k       -0.58%
¹ all samples are equal
Compiled.Run and RunContext allocated a fresh VM per call. A VM is about
80KB (2048 operand-stack slots and 1024 frames), so every run of even a
trivial script paid for a large zeroed allocation and the GC work to free
it. VMs now come from a sync.Pool; before a VM is returned, its stack and
the frames it used are cleared so a pooled VM does not keep the last
run's objects alive. NewVM is unchanged for callers that drive a VM
directly.

Benchmarks: benchstat over 6 runs, previous commit vs this one
(AMD Ryzen 7 3700X, go1.27.1):

goos: linux
goarch: amd64
pkg: github.com/d5/tengo/v2
cpu: AMD Ryzen 7 3700X 8-Core Processor
                   │  4-alloc.txt  │             5-pool.txt              │
                   │    sec/op     │    sec/op     vs base               │
Compile-16             36.39m ± 5%    37.33m ± 3%        ~ (p=0.180 n=6)
VM/ArithLoop-16        37.16m ± 0%    31.77m ± 0%  -14.51% (p=0.002 n=6)
VM/FloatLoop-16        49.78m ± 0%    45.31m ± 0%   -8.97% (p=0.002 n=6)
VM/CompareLoop-16      35.54m ± 1%    33.42m ± 0%   -5.98% (p=0.002 n=6)
VM/Fib-16              3.579m ± 0%    3.610m ± 1%   +0.86% (p=0.009 n=6)
VM/ClosureCalls-16     19.98m ± 0%    18.39m ± 1%   -7.96% (p=0.002 n=6)
VM/MapAccess-16        44.90m ± 1%    43.78m ± 0%   -2.51% (p=0.002 n=6)
VM/MethodCalls-16      41.35m ± 0%    42.45m ± 0%   +2.67% (p=0.002 n=6)
VM/ArrayIter-16        9.726m ± 0%   10.647m ± 1%   +9.47% (p=0.002 n=6)
VM/ArrayIndex-16       33.49m ± 0%    27.66m ± 0%  -17.42% (p=0.002 n=6)
VM/StringOps-16        12.52m ± 0%    11.53m ± 0%   -7.88% (p=0.002 n=6)
VM/Builtins-16         34.23m ± 1%    30.79m ± 1%  -10.06% (p=0.002 n=6)
VM/StdlibCalls-16      13.15m ± 0%    13.84m ± 1%   +5.20% (p=0.002 n=6)
VM/MapIter-16          22.72m ± 0%    23.14m ± 0%   +1.87% (p=0.002 n=6)
VM/ShortScript-16    12327.0n ± 2%    967.5n ± 1%  -92.15% (p=0.002 n=6)
geomean                14.05m         11.40m       -18.81%

                   │ 4-alloc.txt  │              5-pool.txt              │
                   │     B/op     │     B/op       vs base               │
Compile-16           20.09Mi ± 0%   20.09Mi ±  0%        ~ (p=0.855 n=6)
VM/ArithLoop-16      9.226Mi ± 0%   9.144Mi ±  0%   -0.88% (p=0.002 n=6)
VM/FloatLoop-16      16.86Mi ± 0%   16.78Mi ±  0%   -0.48% (p=0.002 n=6)
VM/CompareLoop-16    3.508Mi ± 0%   3.426Mi ±  0%   -2.32% (p=0.002 n=6)
VM/Fib-16            90368.0 ± 0%     438.0 ± 62%  -99.52% (p=0.002 n=6)
VM/ClosureCalls-16   3.122Mi ± 0%   3.040Mi ±  0%   -2.65% (p=0.002 n=6)
VM/MapAccess-16      6.166Mi ± 0%   6.086Mi ±  0%   -1.30% (p=0.002 n=6)
VM/MethodCalls-16    3.123Mi ± 0%   3.042Mi ±  0%   -2.59% (p=0.002 n=6)
VM/ArrayIter-16      1.677Mi ± 0%   1.593Mi ±  0%   -5.01% (p=0.002 n=6)
VM/ArrayIndex-16     3.186Mi ± 0%   3.104Mi ±  0%   -2.56% (p=0.002 n=6)
VM/StringOps-16      5.258Mi ± 0%   5.176Mi ±  0%   -1.58% (p=0.002 n=6)
VM/Builtins-16       3.122Mi ± 0%   3.041Mi ±  0%   -2.61% (p=0.002 n=6)
VM/StdlibCalls-16    3.512Mi ± 0%   3.429Mi ±  0%   -2.36% (p=0.002 n=6)
VM/MapIter-16        14.40Mi ± 0%   14.32Mi ±  0%   -0.57% (p=0.002 n=6)
VM/ShortScript-16    90672.0 ± 0%     560.0 ±  0%  -99.38% (p=0.002 n=6)
geomean              3.085Mi        1.515Mi        -50.90%

                   │ 4-alloc.txt │               5-pool.txt                │
                   │  allocs/op  │  allocs/op   vs base                    │
Compile-16           273.2k ± 0%   273.2k ± 0%         ~ (p=1.000 n=6)
VM/ArithLoop-16      37.44k ± 0%   37.44k ± 0%         ~ (p=1.000 n=6) ¹
VM/FloatLoop-16      68.72k ± 0%   68.72k ± 0%         ~ (p=1.000 n=6) ¹
VM/CompareLoop-16    14.02k ± 0%   14.02k ± 0%         ~ (p=1.000 n=6) ¹
VM/Fib-16             2.000 ± 0%    0.000 ± 0%  -100.00% (p=0.002 n=6)
VM/ClosureCalls-16   12.44k ± 0%   12.44k ± 0%         ~ (p=1.000 n=6) ¹
VM/MapAccess-16      24.91k ± 0%   24.91k ± 0%    +0.00% (p=0.002 n=6)
VM/MethodCalls-16    12.44k ± 0%   12.44k ± 0%         ~ (p=1.000 n=6) ¹
VM/ArrayIter-16      7.462k ± 0%   7.461k ± 0%    -0.01% (p=0.002 n=6)
VM/ArrayIndex-16     13.48k ± 0%   13.48k ± 0%         ~ (p=1.000 n=6) ¹
VM/StringOps-16      151.7k ± 0%   151.7k ± 0%         ~ (p=1.000 n=6) ¹
VM/Builtins-16       12.44k ± 0%   12.44k ± 0%         ~ (p=1.000 n=6) ¹
VM/StdlibCalls-16    153.1k ± 0%   153.1k ± 0%         ~ (p=1.000 n=6) ¹
VM/MapIter-16        214.5k ± 0%   214.5k ± 0%    +0.00% (p=0.002 n=6)
VM/ShortScript-16     11.00 ± 0%    10.00 ± 0%    -9.09% (p=0.002 n=6)
geomean              11.16k                     ?                      ² ³
¹ all samples are equal
² summaries must be >0 to compute geomean
³ ratios must be >0 to compute geomean
Deep non-tail recursion could overflow the 2048-slot operand stack long
before reaching MaxFrames, and the result was a Go index-out-of-range
panic in the host process rather than ErrStackOverflow:

    f := func(n) {
        a := 1; b := 2; c := 3; d := 4
        if n == 0 { return 0 }
        return f(n-1) + a + b + c + d
    }
    f(2000)

The compiler now records, per function, the operand-stack depth its body
needs above its locals (maxStackDepth: every opcode has a fixed stack
effect and compiler output is structured, so a linear pass is exact). A
call is rejected with ErrStackOverflow when base + locals + depth would
exceed StackSize, the main function is checked once in Run, and spread
calls, whose expansion is only known at run time, are checked when the
array is expanded. Bytecode decoded from gob recomputes the depth since
the field is not serialized. CompiledFunction.Copy also now preserves
SourceMap.

Not a performance change; the numbers are included to show the check is
free.

Benchmarks: benchstat over 6 runs, previous commit vs this one
(AMD Ryzen 7 3700X, go1.27.1):

goos: linux
goarch: amd64
pkg: github.com/d5/tengo/v2
cpu: AMD Ryzen 7 3700X 8-Core Processor
                   │  5-pool.txt  │            6-depth.txt            │
                   │    sec/op    │   sec/op     vs base              │
Compile-16            37.33m ± 3%   38.12m ± 7%       ~ (p=0.310 n=6)
VM/ArithLoop-16       31.77m ± 0%   32.01m ± 0%  +0.76% (p=0.002 n=6)
VM/FloatLoop-16       45.31m ± 0%   42.79m ± 1%  -5.55% (p=0.002 n=6)
VM/CompareLoop-16     33.42m ± 0%   32.57m ± 1%  -2.55% (p=0.002 n=6)
VM/Fib-16             3.610m ± 1%   3.644m ± 0%  +0.94% (p=0.002 n=6)
VM/ClosureCalls-16    18.39m ± 1%   18.57m ± 1%  +0.99% (p=0.002 n=6)
VM/MapAccess-16       43.78m ± 0%   41.82m ± 0%  -4.47% (p=0.002 n=6)
VM/MethodCalls-16     42.45m ± 0%   39.17m ± 0%  -7.72% (p=0.002 n=6)
VM/ArrayIter-16      10.647m ± 1%   9.801m ± 0%  -7.95% (p=0.002 n=6)
VM/ArrayIndex-16      27.66m ± 0%   27.44m ± 0%  -0.79% (p=0.002 n=6)
VM/StringOps-16       11.53m ± 0%   12.29m ± 0%  +6.57% (p=0.002 n=6)
VM/Builtins-16        30.79m ± 1%   30.30m ± 0%  -1.59% (p=0.002 n=6)
VM/StdlibCalls-16     13.84m ± 1%   12.83m ± 2%  -7.31% (p=0.002 n=6)
VM/MapIter-16         23.14m ± 0%   22.57m ± 0%  -2.47% (p=0.002 n=6)
VM/ShortScript-16     967.5n ± 1%   952.1n ± 0%  -1.59% (p=0.002 n=6)
geomean               11.40m        11.16m       -2.12%

                   │  5-pool.txt   │             6-depth.txt             │
                   │     B/op      │     B/op       vs base              │
Compile-16           20.09Mi ±  0%   20.10Mi ±  0%  +0.08% (p=0.002 n=6)
VM/ArithLoop-16      9.144Mi ±  0%   9.144Mi ±  0%       ~ (p=0.323 n=6)
VM/FloatLoop-16      16.78Mi ±  0%   16.78Mi ±  0%  -0.00% (p=0.002 n=6)
VM/CompareLoop-16    3.426Mi ±  0%   3.426Mi ±  0%  -0.01% (p=0.015 n=6)
VM/Fib-16              438.0 ± 62%     441.0 ± 62%       ~ (p=0.113 n=6)
VM/ClosureCalls-16   3.040Mi ±  0%   3.040Mi ±  0%       ~ (p=0.448 n=6)
VM/MapAccess-16      6.086Mi ±  0%   6.086Mi ±  0%       ~ (p=0.383 n=6)
VM/MethodCalls-16    3.042Mi ±  0%   3.042Mi ±  0%  -0.00% (p=0.002 n=6)
VM/ArrayIter-16      1.593Mi ±  0%   1.593Mi ±  0%  -0.01% (p=0.002 n=6)
VM/ArrayIndex-16     3.104Mi ±  0%   3.104Mi ±  0%       ~ (p=0.974 n=6)
VM/StringOps-16      5.176Mi ±  0%   5.176Mi ±  0%  +0.00% (p=0.041 n=6)
VM/Builtins-16       3.041Mi ±  0%   3.040Mi ±  0%       ~ (p=1.000 n=6)
VM/StdlibCalls-16    3.429Mi ±  0%   3.429Mi ±  0%       ~ (p=0.058 n=6)
VM/MapIter-16        14.32Mi ±  0%   14.32Mi ±  0%  -0.00% (p=0.045 n=6)
VM/ShortScript-16      560.0 ±  0%     560.0 ±  0%       ~ (p=0.455 n=6)
geomean              1.515Mi         1.515Mi        +0.05%

                   │  5-pool.txt   │             6-depth.txt             │
                   │   allocs/op   │  allocs/op   vs base                │
Compile-16           273.2k ± 0%     273.2k ± 0%       ~ (p=0.697 n=6)
VM/ArithLoop-16      37.44k ± 0%     37.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/FloatLoop-16      68.72k ± 0%     68.72k ± 0%       ~ (p=1.000 n=6) ¹
VM/CompareLoop-16    14.02k ± 0%     14.02k ± 0%       ~ (p=1.000 n=6) ¹
VM/Fib-16             0.000 ± 0%      0.000 ± 0%       ~ (p=1.000 n=6) ¹
VM/ClosureCalls-16   12.44k ± 0%     12.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/MapAccess-16      24.91k ± 0%     24.91k ± 0%       ~ (p=1.000 n=6) ¹
VM/MethodCalls-16    12.44k ± 0%     12.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/ArrayIter-16      7.461k ± 0%     7.461k ± 0%       ~ (p=1.000 n=6) ¹
VM/ArrayIndex-16     13.48k ± 0%     13.48k ± 0%       ~ (p=1.000 n=6) ¹
VM/StringOps-16      151.7k ± 0%     151.7k ± 0%       ~ (p=1.000 n=6) ¹
VM/Builtins-16       12.44k ± 0%     12.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/StdlibCalls-16    153.1k ± 0%     153.1k ± 0%       ~ (p=1.000 n=6) ¹
VM/MapIter-16        214.5k ± 0%     214.5k ± 0%       ~ (p=1.000 n=6) ¹
VM/ShortScript-16     10.00 ± 0%      10.00 ± 0%       ~ (p=1.000 n=6) ¹
geomean                          ²                +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean
@xnacly
xnacly force-pushed the perf-improvements branch from 1093ee5 to 0584ab9 Compare October 4, 2026 11:03
xnacly added 8 commits October 4, 2026 13:09
Int / Int and Int % Int with a zero divisor executed a Go integer
division by zero, which panics. Compiled.Run does not recover, so a
script could crash the host process:

    x := 0
    y := 1 / x

Both operators now return ErrDivisionByZero, which the VM reports as an
ordinary runtime error with position information. Float division is
unchanged (IEEE infinity). The VM's int fast path already fell back to
BinaryOp for a zero divisor, so it inherits the check.
The tail-call optimization reused the frame both for `return f(x)` and
for `f(x)` followed by an implicit return. In the second form the
function's own result is undefined by the language rules, but the
optimization returned whatever the final callee returned:

    f := func(n) {
        if n == 0 { return 42 }
        f(n-1)
    }
    f(3)   // was 42, is now undefined

The frame remembers that a tail call dropped its result; OpReturn then
yields undefined for that frame. This keeps frame reuse (the existing
deep-recursion tests rely on it) and costs one store per call and one
branch per return. Inherited from upstream tengo.
…tack

Calling a UserFunction copied the arguments into a fresh slice on every
call because the VM cannot know whether the Go function keeps the slice.
UserFunction gains a StackArgs flag with the same meaning as the
builtins' internal one: the function promises not to retain args, and
the VM passes a window of its operand stack instead. The stdlib sets the
flag for every module function in an init, since none of them keeps
args (audited: the only slices passed on are to Format, which consumes
them synchronously). Host code can opt in the same way.

Benchmarks: benchstat over 4 runs, previous commit vs this one:

                   │    sec/op    │    sec/op      vs base               │
VM/ArithLoop-16      30.74m ± ∞ ¹    31.01m ± ∞ ¹   +0.91% (p=0.029 n=4)
VM/FloatLoop-16      41.39m ± ∞ ¹    42.26m ± ∞ ¹   +2.10% (p=0.029 n=4)
VM/CompareLoop-16    31.32m ± ∞ ¹    30.19m ± ∞ ¹   -3.59% (p=0.029 n=4)
VM/Fib-16            3.434m ± ∞ ¹    3.598m ± ∞ ¹   +4.78% (p=0.029 n=4)
VM/ClosureCalls-16   17.12m ± ∞ ¹    18.44m ± ∞ ¹   +7.70% (p=0.029 n=4)
VM/MapAccess-16      39.03m ± ∞ ¹    40.57m ± ∞ ¹   +3.95% (p=0.029 n=4)
VM/MethodCalls-16    36.88m ± ∞ ¹    37.84m ± ∞ ¹   +2.62% (p=0.029 n=4)
VM/ArrayIter-16      9.341m ± ∞ ¹   10.072m ± ∞ ¹   +7.82% (p=0.029 n=4)
VM/ArrayIndex-16     25.45m ± ∞ ¹    26.66m ± ∞ ¹   +4.74% (p=0.029 n=4)
VM/StringOps-16      12.00m ± ∞ ¹    12.23m ± ∞ ¹   +1.94% (p=0.029 n=4)
VM/Builtins-16       28.60m ± ∞ ¹    30.36m ± ∞ ¹   +6.16% (p=0.029 n=4)
VM/StdlibCalls-16    11.84m ± ∞ ¹    10.25m ± ∞ ¹  -13.45% (p=0.029 n=4)
VM/MapIter-16        22.16m ± ∞ ¹    23.15m ± ∞ ¹   +4.47% (p=0.029 n=4)
VM/ShortScript-16    940.6n ± ∞ ¹    938.5n ± ∞ ¹        ~ (p=0.486 n=4)
geomean              9.727m          9.921m         +1.99%

                   │  x_full.txt   │           8-stackargs.txt            │
                   │     B/op      │     B/op       vs base               │
VM/ArithLoop-16      9.144Mi ± ∞ ¹   9.144Mi ± ∞ ¹        ~ (p=0.314 n=4)
VM/FloatLoop-16      16.78Mi ± ∞ ¹   16.78Mi ± ∞ ¹        ~ (p=0.886 n=4)
VM/CompareLoop-16    3.426Mi ± ∞ ¹   3.426Mi ± ∞ ¹        ~ (p=0.057 n=4)
VM/Fib-16              447.5 ± ∞ ¹     461.5 ± ∞ ¹        ~ (p=0.257 n=4)
VM/ClosureCalls-16   3.040Mi ± ∞ ¹   3.040Mi ± ∞ ¹   +0.00% (p=0.029 n=4)
VM/MapAccess-16      6.086Mi ± ∞ ¹   6.086Mi ± ∞ ¹        ~ (p=0.314 n=4)
VM/MethodCalls-16    3.042Mi ± ∞ ¹   3.042Mi ± ∞ ¹        ~ (p=0.257 n=4)
VM/ArrayIter-16      1.593Mi ± ∞ ¹   1.593Mi ± ∞ ¹        ~ (p=0.286 n=4)
VM/ArrayIndex-16     3.104Mi ± ∞ ¹   3.104Mi ± ∞ ¹   +0.00% (p=0.029 n=4)
VM/StringOps-16      5.176Mi ± ∞ ¹   5.176Mi ± ∞ ¹        ~ (p=0.057 n=4)
VM/Builtins-16       3.041Mi ± ∞ ¹   3.041Mi ± ∞ ¹   +0.01% (p=0.029 n=4)
VM/StdlibCalls-16    3.429Mi ± ∞ ¹   1.138Mi ± ∞ ¹  -66.81% (p=0.029 n=4)
VM/MapIter-16        14.32Mi ± ∞ ¹   14.32Mi ± ∞ ¹        ~ (p=0.200 n=4)
VM/ShortScript-16      560.0 ± ∞ ¹     560.0 ± ∞ ¹        ~ (p=1.000 n=4)
geomean              1.261Mi         1.168Mi         -7.37%

                   │  x_full.txt   │            8-stackargs.txt            │
                   │   allocs/op   │  allocs/op    vs base                 │
VM/ArithLoop-16       37.44k ± ∞ ¹   37.44k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/FloatLoop-16       68.72k ± ∞ ¹   68.72k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/CompareLoop-16     14.02k ± ∞ ¹   14.02k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/Fib-16              0.000 ± ∞ ¹    0.000 ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/ClosureCalls-16    12.44k ± ∞ ¹   12.44k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/MapAccess-16       24.91k ± ∞ ¹   24.91k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/MethodCalls-16     12.44k ± ∞ ¹   12.44k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/ArrayIter-16       7.461k ± ∞ ¹   7.461k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/ArrayIndex-16      13.48k ± ∞ ¹   13.48k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/StringOps-16       151.7k ± ∞ ¹   151.7k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/Builtins-16        12.44k ± ∞ ¹   12.44k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/StdlibCalls-16    153.10k ± ∞ ¹   53.09k ± ∞ ¹  -65.32% (p=0.029 n=4)
VM/MapIter-16         214.5k ± ∞ ¹   214.5k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/ShortScript-16      10.00 ± ∞ ¹    10.00 ± ∞ ¹        ~ (p=1.000 n=4) ²
geomean                          ³                  -7.29%               ³
² all samples are equal
Iterating a map allocated a []string of the keys and then a new String
object for every key handed out by Key(). The iterator now snapshots the
keys straight into a []String and Key returns a pointer into it, so an
iteration costs two allocations instead of len(map)+1. The key objects
are only reachable through the iterator or through variables the script
assigned them to, and they stay valid for as long as either does.

Benchmarks: benchstat over 4 runs, previous commit vs this one:

                   │     sec/op      │    sec/op     vs base               │
VM/ArithLoop-16         31.01m ± ∞ ¹   31.23m ± ∞ ¹        ~ (p=0.343 n=4)
VM/FloatLoop-16         42.26m ± ∞ ¹   42.97m ± ∞ ¹        ~ (p=0.057 n=4)
VM/CompareLoop-16       30.19m ± ∞ ¹   30.23m ± ∞ ¹        ~ (p=0.886 n=4)
VM/Fib-16               3.598m ± ∞ ¹   3.601m ± ∞ ¹        ~ (p=0.886 n=4)
VM/ClosureCalls-16      18.44m ± ∞ ¹   18.91m ± ∞ ¹        ~ (p=0.886 n=4)
VM/MapAccess-16         40.57m ± ∞ ¹   42.98m ± ∞ ¹   +5.93% (p=0.029 n=4)
VM/MethodCalls-16       37.84m ± ∞ ¹   40.17m ± ∞ ¹   +6.14% (p=0.029 n=4)
VM/ArrayIter-16         10.07m ± ∞ ¹   10.72m ± ∞ ¹   +6.45% (p=0.029 n=4)
VM/ArrayIndex-16        26.66m ± ∞ ¹   28.06m ± ∞ ¹   +5.26% (p=0.029 n=4)
VM/StringOps-16         12.23m ± ∞ ¹   13.02m ± ∞ ¹   +6.41% (p=0.029 n=4)
VM/Builtins-16          30.36m ± ∞ ¹   31.20m ± ∞ ¹        ~ (p=0.343 n=4)
VM/StdlibCalls-16       10.25m ± ∞ ¹   10.07m ± ∞ ¹   -1.70% (p=0.029 n=4)
VM/MapIter-16           23.15m ± ∞ ¹   19.56m ± ∞ ¹  -15.50% (p=0.029 n=4)
VM/ShortScript-16       938.5n ± ∞ ¹   932.2n ± ∞ ¹        ~ (p=0.686 n=4)
geomean                 9.921m         10.05m         +1.29%

                   │ 8-stackargs.txt │            9-mapiter.txt             │
                   │      B/op       │     B/op       vs base               │
VM/ArithLoop-16        9.144Mi ± ∞ ¹   9.145Mi ± ∞ ¹        ~ (p=1.000 n=4)
VM/FloatLoop-16        16.78Mi ± ∞ ¹   16.78Mi ± ∞ ¹        ~ (p=0.886 n=4)
VM/CompareLoop-16      3.426Mi ± ∞ ¹   3.426Mi ± ∞ ¹        ~ (p=0.657 n=4)
VM/Fib-16                461.5 ± ∞ ¹     312.0 ± ∞ ¹        ~ (p=0.657 n=4)
VM/ClosureCalls-16     3.040Mi ± ∞ ¹   3.040Mi ± ∞ ¹        ~ (p=0.343 n=4)
VM/MapAccess-16        6.086Mi ± ∞ ¹   6.086Mi ± ∞ ¹        ~ (p=0.457 n=4)
VM/MethodCalls-16      3.042Mi ± ∞ ¹   3.042Mi ± ∞ ¹        ~ (p=0.286 n=4)
VM/ArrayIter-16        1.593Mi ± ∞ ¹   1.593Mi ± ∞ ¹        ~ (p=0.229 n=4)
VM/ArrayIndex-16       3.104Mi ± ∞ ¹   3.104Mi ± ∞ ¹        ~ (p=1.000 n=4)
VM/StringOps-16        5.176Mi ± ∞ ¹   5.176Mi ± ∞ ¹        ~ (p=0.886 n=4)
VM/Builtins-16         3.041Mi ± ∞ ¹   3.041Mi ± ∞ ¹   +0.00% (p=0.029 n=4)
VM/StdlibCalls-16      1.138Mi ± ∞ ¹   1.138Mi ± ∞ ¹        ~ (p=0.914 n=4)
VM/MapIter-16         14.318Mi ± ∞ ¹   9.557Mi ± ∞ ¹  -33.25% (p=0.029 n=4)
VM/ShortScript-16        560.0 ± ∞ ¹     560.0 ± ∞ ¹        ~ (p=1.000 n=4)
geomean                1.168Mi         1.104Mi         -5.52%

                   │ 8-stackargs.txt │             9-mapiter.txt             │
                   │    allocs/op    │  allocs/op    vs base                 │
VM/ArithLoop-16         37.44k ± ∞ ¹   37.44k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/FloatLoop-16         68.72k ± ∞ ¹   68.72k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/CompareLoop-16       14.02k ± ∞ ¹   14.02k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/Fib-16                0.000 ± ∞ ¹    0.000 ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/ClosureCalls-16      12.44k ± ∞ ¹   12.44k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/MapAccess-16         24.91k ± ∞ ¹   24.91k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/MethodCalls-16       12.44k ± ∞ ¹   12.44k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/ArrayIter-16         7.461k ± ∞ ¹   7.461k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/ArrayIndex-16        13.48k ± ∞ ¹   13.48k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/StringOps-16         151.7k ± ∞ ¹   151.7k ± ∞ ¹        ~ (p=0.429 n=4)
VM/Builtins-16          12.44k ± ∞ ¹   12.44k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/StdlibCalls-16       53.09k ± ∞ ¹   53.09k ± ∞ ¹        ~ (p=1.000 n=4) ²
VM/MapIter-16          214.50k ± ∞ ¹   14.50k ± ∞ ¹  -93.24% (p=0.029 n=4)
VM/ShortScript-16        10.00 ± ∞ ¹    10.00 ± ∞ ¹        ~ (p=1.000 n=4) ²
geomean                            ³                 -17.50%               ³
² all samples are equal
The VM no longer interprets CompiledFunction.Instructions byte by byte.
Each function is translated once (vm_code.go) into fixed-width instrs in
which operand stack slots have become frame-relative registers: the stack
depth before every instruction is static, so the slot an instruction
reads or writes is known at translation time, no stack pointer is kept
while running, OpPop disappears, and operands are decoded once instead
of on every execution. Operands carry a kind (temp slot, local, constant,
global, free variable, literal), which is what the next commit's fusion
pass builds on; on its own this commit keeps one instruction per opcode,
with the common loads and stores specialized into dedicated opcodes.

The serialized bytecode, the compiler and all bytecode-level tests are
unchanged. The translation happens after RemoveDuplicates (which
rewrites constant indexes in place) in Script.Compile and in Decode, and
lazily for bytecode assembled by hand. Error positions come from the
originating opcode offset recorded on each instr. The tail-call and
stack-overflow checks carry over.

On its own this is not faster: the numbers below are the price of the
operand-kind dispatch without the fusion that pays for it.

Benchmarks: benchstat over 6 interleaved runs, previous commit vs this one:

                   │   sec/op    │    sec/op     vs base               │
VM/ArithLoop-16      30.73m ± 1%    33.44m ± 2%   +8.83% (p=0.002 n=6)
VM/FloatLoop-16      42.09m ± 1%    45.40m ± 3%   +7.86% (p=0.002 n=6)
VM/CompareLoop-16    30.02m ± 1%    34.44m ± 1%  +14.73% (p=0.002 n=6)
VM/Fib-16            3.583m ± 1%    4.391m ± 1%  +22.55% (p=0.002 n=6)
VM/ClosureCalls-16   18.25m ± 1%    19.76m ± 1%   +8.27% (p=0.002 n=6)
VM/MapAccess-16      40.43m ± 1%    45.71m ± 0%  +13.06% (p=0.002 n=6)
VM/MethodCalls-16    37.64m ± 0%    49.20m ± 1%  +30.73% (p=0.002 n=6)
VM/ArrayIter-16      9.918m ± 1%   10.008m ± 1%        ~ (p=0.240 n=6)
VM/ArrayIndex-16     26.24m ± 1%    28.90m ± 1%  +10.12% (p=0.002 n=6)
VM/StringOps-16      12.07m ± 1%    13.78m ± 1%  +14.22% (p=0.002 n=6)
VM/Builtins-16       30.12m ± 2%    36.03m ± 0%  +19.62% (p=0.002 n=6)
VM/StdlibCalls-16    9.404m ± 1%   11.090m ± 0%  +17.93% (p=0.002 n=6)
VM/MapIter-16        20.19m ± 1%    22.40m ± 2%  +10.92% (p=0.002 n=6)
VM/ShortScript-16    934.5n ± 1%    975.6n ± 1%   +4.39% (p=0.002 n=6)
geomean              9.696m         10.95m       +12.91%

                   │  ab_prev.txt  │               ab_A.txt                │
                   │     B/op      │     B/op       vs base                │
VM/ArithLoop-16      9.145Mi ±  0%   9.145Mi ±  0%  +0.00% (p=0.002 n=6)
VM/FloatLoop-16      16.78Mi ±  0%   16.78Mi ±  0%  +0.00% (p=0.009 n=6)
VM/CompareLoop-16    3.426Mi ±  0%   3.427Mi ±  0%  +0.01% (p=0.028 n=6)
VM/Fib-16              314.0 ± 53%     527.0 ± 68%       ~ (p=0.078 n=6)
VM/ClosureCalls-16   3.040Mi ±  0%   3.040Mi ±  0%  +0.01% (p=0.002 n=6)
VM/MapAccess-16      6.086Mi ±  0%   6.086Mi ±  0%  +0.01% (p=0.017 n=6)
VM/MethodCalls-16    3.042Mi ±  0%   3.043Mi ±  0%  +0.04% (p=0.026 n=6)
VM/ArrayIter-16      1.593Mi ±  0%   1.593Mi ±  0%       ~ (p=0.554 n=6)
VM/ArrayIndex-16     3.104Mi ±  0%   3.105Mi ±  0%       ~ (p=0.061 n=6)
VM/StringOps-16      5.176Mi ±  0%   5.175Mi ±  0%       ~ (p=0.937 n=6)
VM/Builtins-16       3.041Mi ±  0%   3.041Mi ±  0%       ~ (p=0.061 n=6)
VM/StdlibCalls-16    1.138Mi ±  0%   1.138Mi ±  0%       ~ (p=0.680 n=6)
VM/MapIter-16        9.557Mi ±  0%   9.557Mi ±  0%       ~ (p=0.394 n=6)
VM/ShortScript-16      560.0 ±  0%     560.0 ±  0%       ~ (p=1.000 n=6) ¹
geomean              1.104Mi         1.146Mi        +3.77%

                   │  ab_prev.txt  │              ab_A.txt               │
                   │   allocs/op   │  allocs/op   vs base                │
VM/ArithLoop-16      37.44k ± 0%     37.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/FloatLoop-16      68.72k ± 0%     68.72k ± 0%       ~ (p=1.000 n=6) ¹
VM/CompareLoop-16    14.02k ± 0%     14.02k ± 0%       ~ (p=1.000 n=6) ¹
VM/Fib-16             0.000 ± 0%      0.000 ± 0%       ~ (p=1.000 n=6) ¹
VM/ClosureCalls-16   12.44k ± 0%     12.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/MapAccess-16      24.91k ± 0%     24.91k ± 0%       ~ (p=1.000 n=6) ¹
VM/MethodCalls-16    12.44k ± 0%     12.44k ± 0%       ~ (p=1.000 n=6)
VM/ArrayIter-16      7.461k ± 0%     7.461k ± 0%       ~ (p=1.000 n=6) ¹
VM/ArrayIndex-16     13.48k ± 0%     13.48k ± 0%       ~ (p=1.000 n=6) ¹
VM/StringOps-16      151.7k ± 0%     151.7k ± 0%       ~ (p=1.000 n=6) ¹
VM/Builtins-16       12.44k ± 0%     12.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/StdlibCalls-16    53.09k ± 0%     53.09k ± 0%       ~ (p=1.000 n=6) ¹
VM/MapIter-16        14.50k ± 0%     14.50k ± 0%       ~ (p=1.000 n=6) ¹
VM/ShortScript-16     10.00 ± 0%      10.00 ± 0%       ~ (p=1.000 n=6) ¹
geomean                          ²                +0.00%               ²
A peephole pass over the translated code folds a load into the
instruction that consumes it, a store into the instruction that produces
its value, and a comparison into the conditional jump that tests it.
`s += (i * 3 + 1) % 7` becomes four instructions instead of eleven stack
ops; the loop body of the ArithLoop benchmark goes from 20 stack ops to 8
instructions. Fusion never crosses a jump target, except that a load
which is itself a target may be absorbed by its consumer (a jump to it
then executes the same work), and nothing before such a load is absorbed.

Numbers are from interleaved runs of the two binaries (A B A B ...) so
that clock-frequency and background drift cancels; per-benchmark swings
of around ten percent in either direction on code the change does not
touch (iterators) remain and are layout effects of the large dispatch
function.

Benchmarks: benchstat over 6 interleaved runs, previous commit vs this one:

                   │    sec/op    │   sec/op     vs base               │
VM/ArithLoop-16       33.32m ± 0%   24.68m ± 0%  -25.93% (p=0.002 n=6)
VM/FloatLoop-16       45.03m ± 1%   35.07m ± 1%  -22.11% (p=0.002 n=6)
VM/CompareLoop-16     34.12m ± 1%   23.50m ± 0%  -31.13% (p=0.002 n=6)
VM/Fib-16             4.397m ± 1%   3.354m ± 1%  -23.72% (p=0.002 n=6)
VM/ClosureCalls-16    19.82m ± 0%   14.85m ± 0%  -25.06% (p=0.002 n=6)
VM/MapAccess-16       45.50m ± 1%   38.17m ± 1%  -16.11% (p=0.002 n=6)
VM/MethodCalls-16     49.13m ± 1%   34.43m ± 1%  -29.91% (p=0.002 n=6)
VM/ArrayIter-16       9.973m ± 1%   9.329m ± 1%   -6.46% (p=0.002 n=6)
VM/ArrayIndex-16      28.90m ± 0%   21.97m ± 0%  -23.99% (p=0.002 n=6)
VM/StringOps-16       13.71m ± 1%   11.60m ± 1%  -15.36% (p=0.002 n=6)
VM/Builtins-16        35.99m ± 0%   29.61m ± 0%  -17.74% (p=0.002 n=6)
VM/StdlibCalls-16    11.089m ± 2%   9.198m ± 0%  -17.06% (p=0.002 n=6)
VM/MapIter-16         22.16m ± 1%   20.73m ± 1%   -6.45% (p=0.002 n=6)
VM/ShortScript-16     977.1n ± 1%   963.8n ± 1%   -1.37% (p=0.015 n=6)
geomean               10.91m        8.820m       -19.20%

                   │  ab_A2.txt   │                ab_B.txt                 │
                   │     B/op     │      B/op       vs base                 │
VM/ArithLoop-16      9.145Mi ± 0%   9.144Mi ±   0%   -0.01% (p=0.035 n=6)
VM/FloatLoop-16      16.78Mi ± 0%   16.78Mi ±   0%   -0.01% (p=0.002 n=6)
VM/CompareLoop-16    3.426Mi ± 0%   3.426Mi ±   0%   -0.03% (p=0.017 n=6)
VM/Fib-16              531.0 ± 1%     166.0 ± 167%  -68.74% (p=0.002 n=6)
VM/ClosureCalls-16   3.040Mi ± 0%   3.040Mi ±   0%   -0.01% (p=0.002 n=6)
VM/MapAccess-16      6.086Mi ± 0%   6.086Mi ±   0%        ~ (p=0.331 n=6)
VM/MethodCalls-16    3.043Mi ± 0%   3.041Mi ±   0%   -0.05% (p=0.037 n=6)
VM/ArrayIter-16      1.593Mi ± 0%   1.593Mi ±   0%   -0.00% (p=0.041 n=6)
VM/ArrayIndex-16     3.105Mi ± 0%   3.104Mi ±   0%        ~ (p=0.061 n=6)
VM/StringOps-16      5.176Mi ± 0%   5.176Mi ±   0%   -0.00% (p=0.041 n=6)
VM/Builtins-16       3.041Mi ± 0%   3.041Mi ±   0%        ~ (p=0.058 n=6)
VM/StdlibCalls-16    1.138Mi ± 0%   1.138Mi ±   0%        ~ (p=1.000 n=6)
VM/MapIter-16        9.557Mi ± 0%   9.557Mi ±   0%        ~ (p=0.065 n=6)
VM/ShortScript-16      560.0 ± 0%     560.0 ±   0%        ~ (p=1.000 n=6) ¹
geomean              1.147Mi        1.055Mi          -7.98%

                   │   ab_A2.txt   │              ab_B.txt               │
                   │   allocs/op   │  allocs/op   vs base                │
VM/ArithLoop-16      37.44k ± 0%     37.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/FloatLoop-16      68.72k ± 0%     68.72k ± 0%       ~ (p=1.000 n=6) ¹
VM/CompareLoop-16    14.02k ± 0%     14.02k ± 0%       ~ (p=1.000 n=6) ¹
VM/Fib-16             0.000 ± 0%      0.000 ± 0%       ~ (p=1.000 n=6) ¹
VM/ClosureCalls-16   12.44k ± 0%     12.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/MapAccess-16      24.91k ± 0%     24.91k ± 0%       ~ (p=1.000 n=6) ¹
VM/MethodCalls-16    12.44k ± 0%     12.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/ArrayIter-16      7.461k ± 0%     7.461k ± 0%       ~ (p=1.000 n=6) ¹
VM/ArrayIndex-16     13.48k ± 0%     13.48k ± 0%       ~ (p=1.000 n=6) ¹
VM/StringOps-16      151.7k ± 0%     151.7k ± 0%       ~ (p=0.455 n=6)
VM/Builtins-16       12.44k ± 0%     12.44k ± 0%       ~ (p=1.000 n=6) ¹
VM/StdlibCalls-16    53.09k ± 0%     53.09k ± 0%       ~ (p=1.000 n=6) ¹
VM/MapIter-16        14.50k ± 0%     14.50k ± 0%       ~ (p=1.000 n=6) ¹
VM/ShortScript-16     10.00 ± 0%      10.00 ± 0%       ~ (p=1.000 n=6) ¹
geomean                          ²                +0.00%               ²
Translating a function used half a dozen scratch slices sized by its
instruction count and kept the fusion pass's slack in the retained code.
Bytecode.prepare now carries one translator whose buffers are reused for
every function, and only an exact-size copy of the result is kept. Scanned
depths are int32 instead of int.

Compile-time cost of the translation, BenchmarkCompile (40 modules, 1000
functions), benchstat, previous commit vs this one:

    sec/op      41.13m ± 4%   41.87m ± 5%  ~ (p=0.635)
    B/op        32.09Mi       23.09Mi      -28.04%
    allocs/op   284.6k        277.4k       -2.54%

Against the baseline before the register form, compiling is still about
16% slower and uses 15% more memory: that is the translation itself,
paid once per compile, with the retained instrs accounting for the
memory (about 20 bytes per original instruction).
golint rejects a leading k on identifiers (kTemp, kConst, ...); the
operand kinds in vm_code.go are now kindTemp, kindConst and so on. No
behaviour change.
@xnacly
xnacly marked this pull request as ready for review October 4, 2026 13:08
Comment thread bytecode.go Outdated
Comment thread bytecode.go Outdated
… decode

Decode recomputed the unserialized stack bound only for the functions in
Constants, so a decoded main function ran with a zero bound and could index
past the operand stack instead of returning ErrStackOverflow. Decoding into
a Bytecode that an earlier Decode had already prepared also left the
prepared flag set, so the new functions were never translated and the first
call into them dereferenced nil code.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant