Skip to content

Find string keys that a script built at run time inline - #51

Merged
folbricht merged 2 commits into
folbricht:mainfrom
ebarkie:string-keys-inline
Oct 21, 2026
Merged

folbricht merged 2 commits into
folbricht:mainfrom
ebarkie:string-keys-inline

Conversation

@ebarkie

@ebarkie ebarkie commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

A key that a script builds at run time, by capture, concatenation or sub, never shared a
constant's pointer, so every read or write of it at a constant site missed the loop's hint,
went megamorphic and exited to the driver, and a lookup through such a key broke out of
GETTABLE's probe on the first equal-length key. A table parsed from input and then read by
field name ran 3 to 4 times slower than one written by constants.

Two commits:

  1. Merge the K-variant store cases of the loop into their register cases. SETFIELDK and
    SETTABLEK/SETINDEXK repeated SETFIELD and SETTABLE/SETINDEX with the constant for the
    register. One case each, choosing the value source by opcode as the arithmetic cases do,
    leaves the loop's spill counts as they were and makes it about 150 instructions smaller,
    which keeps the next commit under the inliner's big-function threshold.

  2. Find string keys that a script built at run time inline.

    • A new string key takes the pointer of the constant that names it, if a loaded script has
      one, as Table.Set has given Go keys: the registry lookup moves from Set and the map
      conversion into the insert path of set, where the VM's inserts pass too, behind a
      first-byte check that skips keys no name can match. Constant sites then hit their hints.
    • GETTABLE's inline probe compares short keys by their bytes instead of giving up on an
      equal-length key behind another pointer; the EQ case shares the loop. A byte loop is the
      only form the inliner takes into fast. The gate's baseline for GETTABLE's string path
      falls (8 to 2 on amd64-v1, 12 to 6 on v3), and GETFIELD's megamorphic hit path takes one
      more load on amd64-v1 (6 to 7); the dispatch head and the hint paths are unchanged.
    • quickSet (from Sweep: constructor sizing, hash-part keys, iterators, concat and resume #50) gives a new string key its canonical pointer too, unless the key is
      a constant, which has it already.
    • newindex handles a string key with a value, the common store, with one hash and probe:
      a key the table has takes the value, an absent one asks the metatable for __newindex
      and is then inserted when no resize is due, through tryInsert, which shares the load
      factor with set and constSet; a resize goes through tableSet, which charges it. A
      new field of an object with a metatable costs one probe fewer than before.
    • The same compare in the GETFIELD, SETFIELD and SETTABLE cases of the loop spills the
      dispatch head on amd64 even in its smallest form, so those stay as they are.

Times on an Apple M-series machine against upstream main at 80f93c7, two rounds each, best of
five per run, 1M iterations each:

Pattern upstream main this PR C Lua 5.4.9
Table from gmatch captures, read by field name 42 to 45 ms 14 ms 12 ms
Lookup through a captured key 56 to 58 ms 26 ms 15 ms
Write through a captured key (quickSet, from #50, covers it) 12.5 ms 12.5 ms 3 ms
Table of constant keys, read by field name (unchanged path) 13 ms 13 ms 13 ms
Build a 4-name table per line and drop it, 100k lines 66 to 67 ms 70 to 73 ms 54 ms

Inserting a new name costs the registry lookup, about 10 ns, which shows as 6 to 9 percent on
the build-and-drop pattern and 3 percent on the suite's json script; parse, which builds and
reads the fields back, is unchanged, and the other suite scripts are unchanged within noise.
The published suite numbers do not move: none of its scripts is dominated by run-time keys.

Tests: new tests cover canonical pointers for script-built keys, dynamic-key reads and writes
on both loops with same-length keys, keys over 16 bytes, deleted keys with __index,
__newindex, metamethod cache clearing through a dynamic __index key, reserved names and
frozen tables. vet, tests, race, checkptr, stack stress, asmgate and the generated-loop check
pass locally; fuzzing was not run. TestDifferential against a C Lua built on macOS shows
three pre-existing differences (os.time for years before 1, %A of denormals) that main
shows too; they come from macOS libc.

SETFIELDK and SETTABLEK/SETINDEXK repeated SETFIELD and SETTABLE/SETINDEX with
the constant for the register. One case each, choosing the value source by
opcode as the arithmetic cases do, leaves the loop's spill counts as they were
and makes it about 150 instructions smaller.
A key that a script builds at run time, by capture, concatenation or sub,
never shared a constant's pointer, so every read or write of it at a constant
site missed the loop's hint, went megamorphic and exited to the driver, and a
lookup through such a key broke out of GETTABLE's probe on the first
equal-length key. A table parsed from input and then read by field name ran
3 to 4 times slower than one written by constants.

A new string key now takes the pointer of the constant that names it, if a
loaded script has one, as Table.Set has given Go keys: the registry lookup
moves from Set and the map conversion into insertNew, where the VM's inserts
pass too, behind a first-byte check that skips keys no name can match.
Constant sites then hit their hints, and keys_dyn (a table from gmatch, read
by field) goes from 45 ms to 14 ms, C Lua's 13.

GETTABLE's inline probe compares short keys by their bytes instead of giving
up on an equal-length key behind another pointer; the EQ case shares the
loop. A byte loop is the only form the inliner takes into fast. The gate's
baseline for GETTABLE's string path falls, and GETFIELD's megamorphic hit
path takes one more load on amd64-v1. A lookup through a captured key goes
from 50 ms to 25 ms.

newindex handles a string key with a value, the common store, with one hash
and probe: a key the table has takes the value, an absent one asks the
metatable for __newindex and is then inserted when no resize is due, through
tryInsert, which shares the load factor with set and constSet; a resize goes
through tableSet, which charges it. Writes through a captured key go from
15 ms to 12 ms, and a new field of an object with a metatable costs one probe
fewer than before. The same compare in the GETFIELD, SETFIELD and SETTABLE
cases of the loop spills the dispatch head on amd64, so those stay as they
are.

Inserting a new name costs the registry lookup, about 10 ns, which shows as
6 percent on a script that builds a table of four names per line and drops
it, and 3 percent on json; parse, which builds and reads back, is unchanged.
@ebarkie
ebarkie force-pushed the string-keys-inline branch from c0e8af2 to 00115f6 Compare October 20, 2026 19:39
@folbricht
folbricht merged commit 789e8c9 into folbricht:main Oct 21, 2026
17 checks passed
@ebarkie
ebarkie deleted the string-keys-inline branch October 21, 2026 22:26
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.

2 participants