A shared metatable lets code read arrays and buffers by field name. In the test, 100 records use about of the script's memory with named fields, or as buffers with names.
Scripts often keep many records with the same fields, such as inventory items or path waypoints. My test record is the lighting preset from Pack records before they fill linkset data. It has 14 fields, including a group, a mode, intensity, radius, three color channels, and five on/off switches.
I stored it five ways in SLua:
Named fields keep each value under its name, the usual way to build a record in Lua
An array keeps only the values, in numbered positions, so code reads intensity as preset[3]
A buffer packs the values into raw bytes, and you decide how many bytes each one gets
A table with names keeps the array's positions and adds a metatable, a set of lookup functions every record shares, so code can still write preset.intensity
A buffer with names adds the same kind of metatable to a buffer
The sample preset in each layout
The highlighted value is intensity, and each row shows the code that reads it
Named fields
preset.intensity
group2
mode"pulse"
intensity0.75
glow0.1
radius10
red255
green180
blue96
fadeMs1500
enabledtrue
fullbrightfalse
castLighttrue
synctrue
ownerOnlyfalse
Array
preset[3]
12
2"pulse"
30.75
40.1
510
6255
7180
896
91500
10true
11false
12true
13true
14false
Table with names
preset.intensity
A shared metatable looks up position 3
Buffer
buffer.readu8(preset, 2) / 100
02
12
275
310
410
5255
6180
796
8220
95
1013
Buffer with names
preset.intensity
A shared metatable reads byte 2 and divides by 100
The small label on each value is how code finds it. Arrays count positions from 1, and buffers count bytes from 0. The buffer keeps intensity as the whole percentage 75, gives the fade time two bytes, and fits all five on/off switches into the last byte.
The memory tests store 1, 10, and 100 records, and a separate test times reads and writes. With named fields, each record takes 544 bytes. The 100 records, their list, and the code that builds them use about of the script's memory. Plain buffers bring that down to about .
Memory for 100 records
Memory each layout adds · measured in Second Life, code size from the SLua compiler
Records
100
Named fields
record.field
56,523 B of 128 KiB
Array
record[POSITION]
27,582 B of 128 KiB
Table with names
record.field
29,735 B of 128 KiB
Buffer
buffer.readu8(record, OFFSET)
5,273 B of 128 KiB
Buffer with names
record.field
14,111 B of 128 KiB
Bars scale to the largest total
Paid once: code, shared tables, stack
Grows with each record
The buffer with names costs the most at one record, because its code and shared tables take memory before any record exists. Named fields cost the most at 100. Totals add compiled code size to Second Life readings at 1, 10, and 100 records on both grids.
Adding the names back raises the buffer total for 100 records to 13.8 KiB, about of the script's memory.
Each layout has a fixed cost plus a cost per record. A metatable raises the fixed cost, and smaller records win it back one record at a time. For this preset, a table with names pulls ahead of named fields at 7 records, and a buffer with names at 11. Different fields change both numbers.
Where each layout starts to pay off
Memory by record count · both axes on log scales
at 10 records
The table with names beats named fields from 7 records, and the buffer with names from 11. Circles mark the tested counts.
Shared costs dominate at small counts. With one record, the buffer with names takes 5,498 bytes, five times what named fields take. At 100 records, it takes a quarter as much. Plain buffers use the least memory from 5 records on, and plain arrays below that.
Keep named fields when
The shared code would outweigh the per-record savings
A loop that runs often reads the fields
The fields still change often while you build the script
The table goes somewhere that expects names, such as JSON
Put names over arrays or buffers when
The per-record savings outweigh the shared code
Field reads are rare or take little of the script's running time
The layout is settled, so every field has a fixed place
Readable names matter more to you than read speed
Avoid repeated metatable calls
When one function reads a few fields many times, read by position or offset. You can also copy the values into local variables, as Keep hot values in variables shows.
A table stores each named field in a 32-byte slot that holds its key and value. SLua counts these bytes against the script's memory limit, and they can differ from what the allocator reserves.
The table rounds its named-field slots up to a power of two, so the 14 fields take 16 slots. With the 32-byte header, one record costs 544 bytes.
Memory for one record
Each square is one byte SLua charges
544 Bper record · 54,400 B for 100 records alone
14 named fields round up to 16 slots, each holding a 16 B name and a 16 B value.
Table or buffer header
Field name
Value
Empty slot
SLua's charges for the 14-field sample record, matched by every Second Life test.
An array drops the names and keeps each value in a 16-byte slot. The 14 fields take 256 bytes, under half of the 544 for named fields. Plain arrays also timed at least as fast.
A buffer lets you pick each field's width. Small whole numbers can take one byte, and on/off switches can share one. You need to know each value's range and how much precision to keep. The sample packs into 11 bytes plus 12 for the buffer itself, with field sizes in Choose the record layout.
A metatable is a table that changes how another table behaves. Lua calls its __index function when code reads a key the table doesn't hold, and __newindex when code assigns one. Every record can share one metatable, so Lua stores its functions and name map once.
For an array, the map gives each field a position:
presets.slua
-- each field's position in the arraylocal={=1,=2,=3,=4,=5,=6,=7,=8,=9,=10,=11,=12,=13,=14,}localfunctionslotFor()localslot=[]ifslot==nilthen(`unknown preset field: {}`)endreturnslotendlocalArrayPreset={__index=function(preset,)returnrawget(preset,slotFor())end,__newindex=function(preset,,)rawset(preset,slotFor(),)end,}localfunction()localpreset=(table.(14),ArrayPreset)forname, valueindopreset[name] =valueendreturnpresetend
For another layout, change the field map and the array size. The figure follows one read and one write through this code, then a read after a raw write.
What each line runs
The hops from a name to a position, for three lines of the listing
read
preset.intensity
no key
__index
slots.intensity = 3
rawget(preset, 3)
0.75
write
preset.glow = 0.2
no key
__newindex
slots.glow = 4
rawset(preset, 4, 0.2)
read after rawset(preset, "intensity", 0.9)
preset.intensity
intensity = 0.9
0.9
The metatable never runs, so position 3 still holds 0.75.
The lookup runs in Lua either way, since TypeScript's preset.intensity compiles to the same read.
The third path is the trap. If the record itself holds a key such as intensity, reads and writes use it and skip the metatable. Writing a field before attaching the metatable creates one, and so does rawset, which writes straight to the table. That's why newPreset attaches the metatable first and writes every field through it.
The names only exist in the metatable. A misspelled name like preset.intensitty raises an error, though rawget and rawset skip that check. Looping over the record gives positions, not names, so copy the values into a table with named fields before encoding JSON.
The buffer version replaces the position map with a schema, a table that gives each field's byte offset and kind. The kind sets how many bytes a field takes and which values fit:
byte holds a whole number from 0 to 255 in one byte
u16 holds a whole number from 0 to 65,535 in two bytes, enough for the fade time in milliseconds
percent stores a value such as 0.753 as the whole percentage 75, so it reads back as 0.75
mode stores steady, fade, pulse, or cycle as a number from 0 to 3
flag is one on/off switch, and all five switches share byte 10
__index reads a field's bytes and converts them back. __newindex converts a value and writes it, and newPreset builds each record the same way as before.
presets.slua
-- each field's byte offset, how it's stored, and its bit for switcheslocal={={0,"byte"},={1,"mode"},={2,"percent"},={3,"percent"},={4,"byte"},={5,"byte"},={6,"byte"},={7,"byte"},={8,"u16"},={10,"flag",1},={10,"flag",2},={10,"flag",4},={10,"flag",8},={10,"flag",16},}local={"steady","fade","pulse","cycle"}local={=0,=1,=2,=3}localfunction()localfield=[]iffield==nilthen(`unknown preset field: {}`)endreturnfieldendlocal={=function(,)local, , =table.(())local=(,1)if=="percent"thenreturnbuffer.(,)/100elseif=="mode"thenreturn[buffer.(,)+1]elseif=="u16"thenreturnbuffer.(,)elseif=="flag"thenreturnbit32.(buffer.(,),)endreturnbuffer.(,)end,=function(,,)local, , =table.(())local=(,1)if=="percent"thenbuffer.(,,math.(*100))elseif=="mode"thenbuffer.(,,[])elseif=="u16"thenbuffer.(,,)elseif=="flag"thenlocal=buffer.(,)buffer.(,,ifthenbit32.(,)elsebit32.(,bit32.()))elsebuffer.(,,)endend,}localfunction()local=({buffer.(11)},)forname, valueindo[name] =valueendreturnend
setmetatable only works on tables, so each record wraps its 11-byte buffer in a one-slot table. That wrapper adds 48 bytes per record, just over twice what the buffer costs.
Numbers too big for their field wrap around, so 256 in a byte field reads back as 0. The example leaves out range checks, so add them to __newindex before it handles external input. If you save records, a layout change breaks the old ones unless you convert them.
Before the first record, the buffer version with names had used 2,531 bytes of code and 2,048 bytes of shared tables and functions. The table version with names used 919 and 784 bytes.
Named access also grew each test script's stack by 800 bytes. The stack is the memory Lua keeps for function calls in progress. The metatable call needed more room, and SLua roughly doubles the stack when that happens. A larger script with deeper calls may already have the room. Without the stack growth, the table with names pulls ahead at 5 records instead of 7, and the buffer with names at 9 instead of 11.
Luau remembers where each field sits in a table, and its guide recommends keeping fields in the table rather than behind __index functions. The timing test checked each record's enabled field. For the half set to true, it also read intensity, radius, and glow. Those reads took about 11 times as long through the table with names, and about 18 times through the buffer's schema.
Read and write time
A read checks enabled, then reads three fields if it's true. A write sets one field.
MedianRangeRatio
Named fields
Read
5.4 µs median
Range: 5.1– to 6.1 µs
baseline
Write
3.6 µs median
Range: 3.4– to 4.3 µs
baseline
Array
Read
4.5 µs median
Range: 4.2– to 4.8 µs
0.8×
Write
3.6 µs median
Range: 3.3– to 3.8 µs
1.0×
Table with names
Read
58.0 µs median
Range: 53.7– to 63.8 µs
10.7×
Write
23.9 µs median
Range: 22.2– to 29.8 µs
6.6×
Buffer
Read
7.9 µs median
Range: 7.4– to 16.5 µs
1.5×
Write
5.3 µs median
Range: 5.1– to 5.5 µs
1.5×
Buffer with names
Read
95.1 µs median
Range: 88.9– to 115.8 µs
17.6×
Write
39.1 µs median
Range: 37.3– to 42.7 µs
10.9×
020406080100120
Time in µs · lower is better
Medians of 8 trials on Agni and Aditi, each over 6 rounds of 50 records. Reads are per record, writes per field.
The times include the simulator's scheduling, so compare the ratios rather than the microseconds. Even through the schema, this pattern takes under a tenth of a millisecond per record. A batch of 100 comes to about 9.5 milliseconds, against 0.5 for named fields.
Compare whole-script memory, since the metatable's code counts too
Time the code that reads and writes fields most often
Try a misspelled field and a raw write before you rely on the names
ll.GetUsedMemory() reports memory at the moment of the call. It misses anything allocated and freed between checkpoints, including an operation's peak.
A metatable keeps field names in the running script. TypeScript can check them at build time and emit plain positions instead. For 100 buffer records, that's of the script's memory rather than , with reads about 12 times as fast.
Data··9 min
Pack records before they fill linkset data
For this 14-field test record, linkset data holds about 660 JSON objects or 6,000 packed buffers. Compare storage savings with script memory and code costs.
Capacity··10 min
Size lists before they round up
An appended 285-entry list in SLua uses 384 slots at 16 bytes each. I test when table.create saves memory and how to size my HUD's paint lists.