Performance, garbage collection and LuaJIT

Size tables correctly, avoid allocation in hot paths, tune the collector, and know when LuaJIT's traces or FFI are worth it.

Tables and allocation

-- build tables once, outside the loop, and reuse them
local buffer = {}
for i = 1, 1000 do
  buffer[i] = i                        -- no growth after this point
end

-- reserve the right size: the array part grows in powers of two and rehashes
local function makeTable(n)
  local t = {}
  local i = 1
  while i <= n do t[i] = 0; i = i + 1 end   -- forces the array part to n slots
  return t
end

-- reuse element tables instead of creating a new one per row
local row = { x = 0, y = 0, z = 0 }
for i = 1, 100000 do
  row.x, row.y, row.z = i, i * 2, i * 3
  consume(row)                        -- no allocation per iteration
end

-- avoid creating closures in an inner loop
local function handler() end
for i = 1, 100000 do
  -- WRONG for a hot loop: a closure per iteration
  -- register(function() return handler() end)
end

-- string concatenation: one table, one concat
local parts = {}
for i = 1, 1000 do parts[#parts + 1] = tostring(i) end
local joined = table.concat(parts, "")

-- cache global lookups in a local, especially in tight loops
local sqrt  = math.sqrt
local floor = math.floor
local tinsert = table.insert
for i = 1, 100000 do
  local v = floor(sqrt(i))
  tinsert(parts, v)
end
  • A table has an array part and a hash part. Filling keys 1..n makes the array part, which is much faster than the same values under hash keys.
  • A nil hole in the array part makes Lua treat the sequence as ending there, so never build t[1], t[3], t[5] expecting array performance.
  • Every string concatenation allocates. table.concat with a preallocated table is the standard fix.
  • Closures and varargs tables allocate. In a per-frame inner loop, everything that creates a table is a candidate for removal.
  • LuaJIT is far more sensitive to this than the reference interpreter because a trace that allocates cannot be compiled as well.

The garbage collector

-- inspect and tune the collector
print(collectgarbage("count"))            -- kilobytes in use

-- the incremental collector (5.1 through 5.4, default in 5.4 is generational)
collectgarbage("setpause", 200)           -- start a new cycle at 200% of the live set
collectgarbage("setstepmul", 200)         -- work per step

-- a full collection: useful at a loading screen or between levels
collectgarbage("collect")

-- stop the collector while building a large structure, then restart
collectgarbage("stop")
local big = {}
for i = 1, 1000000 do big[i] = { i } end
collectgarbage("restart")

-- 5.4 generational mode: better for short-lived garbage
collectgarbage("generational")
collectgarbage("incremental")

-- weak tables: a cache that does not keep its keys alive
local cache = setmetatable({}, { __mode = "v" })     -- weak values
local keyed = setmetatable({}, { __mode = "k" })     -- weak keys

-- finalisers: run when an object is collected, do not rely on the timing
local resource = setmetatable({ handle = 1 }, {
  __gc = function(self) closeHandle(self.handle) end,
})
ModeBehaviourBest for
IncrementalPauses spread across the programSteady workloads, latency matters
GenerationalMinor collections for young objectsShort-lived garbage, Lua 5.4
Collector stoppedNo collection until restartedBulk loading, with a known memory bound
Weak valuesEntry disappears when the value is collectedCaches keyed by something you own
Weak keysEntry disappears when the key is collectedAssociating data with objects weakly

A finaliser (__gc) is not a destructor. It runs at an unspecified time, possibly never if the program exits, and an object with a finaliser is resurrected for at least one cycle. Use it for logging a leak, not for correctness.

LuaJIT: traces and FFI

-- run under LuaJIT: check which build you are on
print(jit.version)                        --> LuaJIT 2.1.0-beta3
print(jit.arch, jit.os)

-- is this function being compiled?
jit.on()                                  -- enable the JIT for the current function
jit.off()                                 -- disable it, for debugging
local compiled = jit.status()
print(compiled)

-- warm up the call site before measuring: the first runs are interpreted
for i = 1, 100 do f(i) end                -- let the trace form

-- why a trace aborts: the -jv flag shows the reason
--   luajit -jv app.lua
-- common aborts: NYI (not yet implemented), a table rehash, a sparse array

-- FFI: call C without a binding library
local ffi = require("ffi")
ffi.cdef[[
  typedef struct { double x, y; } point_t;
  double sqrt(double x);
  int getpid(void);
]]
print(ffi.C.sqrt(2))
local pt = ffi.new("point_t", 1, 2)
print(pt.x + pt.y)
local arr = ffi.new("double[1024]")       -- C memory, not collected by Lua
  • LuaJIT is Lua 5.1. Integer division //, the bitwise operators, goto from 5.2 and the <close> attribute from 5.4 are not available.
  • A trace forms after a loop runs the same way repeatedly. Measuring a single call measures the interpreter.
  • Trace aborts are the main performance story. A table that starts as an array and becomes a hash, or a call into an uncompiled C function, throws the trace away.
  • ffi.new allocates memory outside the Lua collector. You must keep a reference and free it explicitly with ffi.gc or a manual free, or you leak C memory.
  • FFI turns memory-safety mistakes into crashes rather than Lua errors: a wrong struct layout or an out-of-bounds index corrupts memory silently.
⚠️
Profile before optimising. -jv under LuaJIT, or a sampling profile, tells you where the time actually goes. Restructuring a loop that is not hot wastes the readability budget that Lua's design was meant to protect.

FAQ

Should I use LuaJIT or the reference implementation?
LuaJIT when raw throughput matters and the code can target Lua 5.1 semantics, which is the norm for game and proxy workloads. Use the reference Lua for 5.4 features, for a smaller trusted sandbox, and for long-lived code where the language version matters more than speed.
Why does the collector not free my memory?
Something still references the object, or a finaliser resurrected it. The usual culprits are a module-level cache, a closure holding an upvalue, a coroutine that was never finished, and a weak-table entry whose key is still reachable.

Coroutines and cooperative multitasking Lua idioms, style and common pitfalls

Last refreshed 2026-09-18.