38 KiB
/luau-optimize - Roblox Luau Performance Optimization
Optimize Roblox Luau files for maximum performance, type safety, and native codegen quality using luau-compile, luau-analyze, luau-ast, and the luau CLI.
All scripts are Roblox Luau. Roblox APIs are always available. The vector type is Vector3 (Roblox userdata). We are NOT using the new type solver. Studio testing uses --!optimize 1 by default; live experiences use --!optimize 2. All luau-compile invocations must include --vector-lib=Vector3 --vector-ctor=new --vector-type=Vector3. Prefer .luau extension over .lua everywhere.
Workspace hygiene: CLI tools generate residual files (stats.json, profile.out, coverage.out, trace.json). At the start of execution, create a temp working directory and use it for all tool output:
# Windows
$LUAU_TMP = New-Item -ItemType Directory -Path "$env:TEMP\luau-optimize-$(Get-Random)"
# Linux/Mac
LUAU_TMP=$(mktemp -d)
Route all output there: --stats-file=$LUAU_TMP\stats.json, write benchmark files to $LUAU_TMP\bench.luau, run profiling/coverage tools with $LUAU_TMP as working directory. The OS handles cleanup -- no manual deletion needed.
Phase 0 -- Scope Resolution and Configuration
Resolve target
- If user specifies a file: optimize that file.
- If user specifies a directory: discover all
.luau/.luafiles, enter Phase 6 (multi-file mode). - If no argument: optimize the currently open file.
Infer script context
Check in this order:
- Filename suffix (highest priority):
*.server.luau/*.server.lua-> server (native codegen budget is server-side; benefit depends on code content)*.client.luau/*.client.lua-> client (native codegen less effective due to device architecture diversity)*.legacy.luau/*.legacy.lua-> ambiguous (could be cloned/moved at runtime). Ask the user.*.luau/*.lua(no suffix) -> ModuleScript, fall through to path check.
- Path-based inference (for ModuleScripts and ambiguous cases):
ServerScriptService/ServerStoragein path -> server-side moduleStarterPlayer/StarterGui/StarterCharacterScripts/ReplicatedFirstin path -> client-side moduleReplicatedStorage/sharedin path -> shared module- Ambiguous or outside Roblox project -> ask the user
Quiz the user
Use the AskQuestion tool with these three questions:
Question 1: "Compilation optimization intensity"
minimal-- Headers (--!strict/--!optimize 2), type annotations on all function signatures, deprecated pattern replacements, native codegen analysismoderate-- + function restructuring for inlining, import hoisting, compound operators, fastcall enablement, allocation reductioninsane-- + every micro-optimization from the pattern catalog, full bytecode analysis, register pressure optimization, closure caching analysis
Question 2: "Algorithm optimization"
none-- Don't touch logic or data flowlow hanging fruit-- Obvious O(n^2)->dict, redundant iterations, missing cachesmoderate-- + data structure changes, caching strategies, event-driven refactors, loop fusioninsane-- + full algorithmic redesign where beneficial, dynamic programming, architectural restructuring
Question 3: "Benchmarking"
yes-- Benchmark all non-trivial changes withluauCLI (when function is standalone)no-- Trust bytecode/codegen metrics only+EV only-- Benchmark only when algorithmic changes or ambiguous bytecode results warrant it
Derived behavior
| Setting | minimal | moderate | insane |
|---|---|---|---|
| Headers | Always | Always | Always |
| Type annotations | Function signatures | + hot path locals | + all locals |
| Pattern replacements | Deprecated only | Full Priority 2 | + Priority 4 |
| Function restructuring | No | Yes (Priority 3) | Aggressive splitting |
| Bytecode verification | After all changes | After each priority | After each change |
| Algorithmic changes | Per algo quiz | Per algo quiz | Per algo quiz |
| Native codegen strategy | Per-function analysis; @native on beneficial functions; --!native only if most are native-worthy |
Selective @native on hot computational functions |
Full per-function cost/benefit with budget tracking |
Restructuring scope scales with compilation intensity:
- minimal: No restructuring. Only additive changes (headers, annotations, pattern swaps).
- moderate: Break up monoliths, reorder locals for inlining, hoist imports. Preserve exports/API.
- insane: Aggressive restructuring permitted. May change internal function boundaries, rewrite data flow.
Phase 1 -- Deep Code Read and Algorithmic Analysis
This phase comes FIRST. Algorithmic improvements produce order-of-magnitude speedups that dwarf any bytecode optimization. This is also the largest code change, so it must happen before any bytecode-level work.
Read the entire source file thoroughly before touching any CLI tools.
Step 1 -- Structural decomposition
Monolithic functions hide their algorithmic structure behind interleaved concerns. Before analyzing complexity, understand what the code is actually doing:
- Identify responsibilities: A 200-line function usually contains 3-5 logical steps that should be separate functions.
- Trace data flow: What goes in, what comes out, what is mutated along the way.
- Separate concerns: Validation, transformation, I/O, state management, error handling are often tangled. Separating them reveals the core algorithm.
- Name the steps: If you can't name what a code block does in 3-5 words, it's doing too much.
- Look for hidden abstractions: Repeated "iterate, filter, transform, collect" patterns that could be a single helper.
When the quiz allows restructuring (moderate/insane):
- Physically decompose monoliths into focused local functions. Each does one thing.
- This decomposition often reveals optimization opportunities invisible in the monolith: step 2 recomputes what step 1 already knew, or an inner loop could be a lookup table built in the outer loop.
- Smaller functions are easier for the compiler to inline at
-O2-- clearer code AND better bytecode. - Preserve the original function as a thin orchestrator calling the decomposed pieces.
Even at "minimal" intensity, still perform this mental decomposition to inform the analysis below.
Step 2 -- Algorithmic analysis
With the structure understood (or cleaned up), analyze:
Complexity analysis -- Identify time complexity of every significant function:
- O(n^2) or worse: nested loops, repeated linear searches, quadratic string building
- O(n) where O(1) is possible: linear search replaceable with dictionary/set lookup
- Repeated work: same computation done multiple times, cacheable/memoizable
- Unnecessary copies: cloning data that could be referenced or mutated in place
- Hidden quadratics: O(n) function that calls another O(n) function inside a loop
Data structure fitness -- Is the right data structure being used?
- Array for membership testing -> set (dictionary with
truevalues) - Linear scan for lookup by key -> dictionary
- Repeated sort of mostly-sorted data -> maintain sorted order on insert
- Flat list with frequent removal from middle -> swap-and-pop
- Large string built incrementally ->
table.concatpattern orbuffer - Multiple parallel arrays -> single array of structs (table of tables)
- Unbounded growth without cleanup -> size limits, LRU eviction, periodic pruning
Caching and memoization:
- Pure functions called repeatedly with same args -> memoize
- Expensive property reads in loops -> cache in local before loop
- Computed values unchanged within a scope -> hoist out
- Derived state recomputed from scratch on every access -> maintain incrementally
Redundant work elimination:
- Multiple passes over same data -> fuse into one
- Recomputing derived state -> maintain incrementally
- Sort/filter that could be done once and reused
- Deep clone where shallow clone or reference suffices
- Intermediate result immediately discarded after extracting one field
Architectural patterns:
- Per-frame polling where event-driven would work (RunService events are expensive)
- Synchronous work that could be chunked across frames with
task.wait() - Undisconnected event connections (memory leak + wasted computation)
- Rebuilding entire state on small change -> incremental update
- God-object owning too much state -> split responsibilities
Hot path identification:
- Event handlers on RunService (PreAnimation, PreRender, PreSimulation, PostSimulation, Heartbeat)
- Functions called inside loops
- Recursive functions
- Functions called from other hot functions
Step 3 -- Present findings and quiz the user
Before applying any changes, present a summary of all findings organized by impact. For each finding:
- What: One-line description of the issue
- Where: Function name / line range
- Why: What makes it slow (e.g., "O(n^2) nested scan on every frame")
- Fix: Proposed change in plain language
- Impact: Estimated improvement (order-of-magnitude, constant-factor, or clarity-only)
Then quiz the user using the AskQuestion tool:
- For fewer than 5 findings: one question per finding with options
apply/skip/modify - For 5+ findings: group by category with options
apply all/cherry-pick/skip all:- Structural decomposition
- Data structure changes
- Algorithm changes
- Architectural changes
If the algorithm quiz from Phase 0 was "none", present findings as informational only (no apply options). The user still sees what was found.
If the user selects "modify", wait for their input before proceeding with that specific change.
Step 4 -- Apply approved changes
Apply only user-approved changes, in this order (dependencies flow downward):
- Structural decomposition -- break monoliths into focused functions (foundation for everything else)
- Data structure changes -- swap arrays for dicts, add indexes, etc.
- Algorithm changes -- replace O(n^2) with O(n), add caching/memoization
- Architectural changes -- event-driven refactors, incremental updates
Benchmark algorithmic changes if the benchmarking quiz warrants it (see Phase 4.5).
Phase 2 -- Baseline Capture
Run all diagnostic tools on the code (post-algorithmic changes if any were applied in Phase 1). These metrics are the baseline for bytecode optimization phases.
Tools to run
Detect OS: use --target=x64_ms on Windows, --target=x64 on Linux/Mac. Route stats output to temp directory.
luau-analyze --mode=strict <file>
luau-analyze --annotate <file>
luau-compile --remarks -O2 --vector-lib=Vector3 --vector-ctor=new --vector-type=Vector3 <file>
luau-compile --text -O2 --vector-lib=Vector3 --vector-ctor=new --vector-type=Vector3 <file>
luau-compile --codegen --target=x64_ms --record-stats=function --stats-file=$LUAU_TMP\stats.json -O2 --vector-lib=Vector3 --vector-ctor=new --vector-type=Vector3 <file>
Metrics to capture
- Total bytecode instruction count (from
--text) - Allocation count: NEWTABLE, NEWCLOSURE occurrences (from
--remarks) - Inlining success/failure count (from
--remarks: "inlining succeeded"/"inlining failed") - Type coverage: count of
anytypes in--annotateoutput - Register spills and skipped functions (from
--record-statsJSONlowerStats) - Lint warning count (from
--mode=strict)
Phase 3 -- Compilation-Level Code Analysis
Using both the source (already read in Phase 1) and tool output from Phase 2, identify bytecode-level optimization opportunities:
- Missing types:
anyinferences hurting native codegen (especiallyVector3,CFrame,bufferparams). JIT uses annotations directly -- no runtime type analysis. Unannotated params assumed to be tables. - Import patterns:
math.maxresolved at load time via GETIMPORT. Broken bygetfenv/setfenv/loadstring(marks env "impure"). - Allocation in loops: NEWTABLE, NEWCLOSURE in hot loops. High allocation rate = more GC assist work.
- Closure caching: Repeated function expressions cached when: no upvalues, or all upvalues immutable and module-scope. Mutable captures prevent caching.
- Upvalue mutability: ~90% immutable in typical code. Immutable = no allocation, no closing, faster access. Mutable = extra object.
- Method call patterns:
obj:Method()uses fast method call instruction. Avoidobj.Method(obj).__indexshould point at a table directly (not function or deep chain). - Inline caching: Best when field name known at compile time, no metatables, uniform shapes.
- pcall in hot paths: Prevents native codegen optimization.
- String concatenation:
..in loops ->table.concator string interpolation. - Deoptimizing APIs:
getfenv/setfenv(even read-only!),loadstringdisable ALL import optimization and fastcalls. - Builtin global writes:
math = ...disables fastcall. LintBuiltinGlobalWritecatches this. - Metamethod cost:
__eqalways called on==/~=even for rawequal values. - GC pressure: Incremental GC with "GC assists" -- allocating code pays proportional GC work.
Phase 4 -- Compilation Optimization
Apply changes in priority order. Re-run luau-compile --remarks -O2 --vector-lib=Vector3 --vector-ctor=new --vector-type=Vector3 <file> after structural changes to verify the compiler benefits.
Priority 1 -- Headers, type safety, and native codegen (always apply)
Add headers:
--!strict/--!optimize 2. (--!optimize 2is default in live but not Studio testing.)- Do NOT blindly add
--!native. Native codegen is analyzed separately (see "Native codegen strategy" below).
Remove deoptimizers:
- Replace
getfenv/setfenv-- disables builtins, imports, and optimizations globally. - Replace
table.getn->#t,table.foreach/table.foreachi->for..in.
Type accuracy -- the first big battle:
Getting the file to pass --!strict cleanly is the single most impactful compilation change. Every any type is a function the native codegen cannot specialize. But the goal is accurate types, not silencing errors with casts:
- Run
luau-analyze --annotateand identify everyanyinference. These are the targets. - Add real type annotations to function signatures first (parameters and return types). This has the highest leverage -- it propagates type info to every caller and every local inside the function.
- Then annotate locals in hot paths where inference still falls to
any. - Annotate
Vector3,CFrame,bufferparams explicitly -- native codegen generates specialized vector code. Unannotated params are assumed to be generic tables with extra type checks. - Do NOT paper over type errors with
:: anycasts. A cast toanyis worse than no annotation -- it actively tells the compiler to give up. If a type error is hard to fix, use the narrowest cast possible (:: SpecificType), or restructure the code so the type flows naturally. - Narrow
anyin dictionaries.{[string]: any}is common but hurts codegen. Ask: what values actually go in there? If it's a known set of types, use a union:{[string]: string | number | boolean}. If the dictionary has known keys, use a typed table:{name: string, score: number}. Only fall back toanywhen the value type is genuinely unbounded (e.g., serialized data from an external source). - Use type refinements (
type(),typeof(),:IsA(),assert()) to provide type info from runtime checks instead of casts. - For OOP patterns: use
typeof(setmetatable(...))with explicitself: ClassNameon methods (old typechecker compatible). - For generic code: use proper generics (
<T>) instead ofany. If the function truly accepts anything, useunknownand narrow explicitly. - Track type coverage: count
anyin--annotateoutput. The goal is zero (or as close as practical).
Native codegen strategy -- --!native vs @native vs neither:
Native codegen has two limits: a 1M instruction cap per module and a per-experience memory ceiling shared across all native scripts. Applying --!native indiscriminately wastes both budgets on functions that don't benefit, crowding out functions that would.
-
Classify every function in the file by its dominant work:
- Native-worthy: Tight computational loops, math-heavy logic (
Vector3/CFramearithmetic,bufferops,bit32ops, numerical algorithms, physics calculations). These get genuine speedups from JIT compilation. - Not native-worthy: Roblox API calls (
Instance:Clone(),:FindFirstChild(),:GetChildren(), event connections), table construction without computation (allocation-bound), heavy Luau library calls (string.format, pattern matching -- C implementations native codegen can't speed up), one-shot initialization, coroutine/task scheduling. The dominant cost is the API/library call or GC, not Luau instruction dispatch.
- Native-worthy: Tight computational loops, math-heavy logic (
-
Decision matrix:
- All/most functions are native-worthy (math utility modules, physics solvers, buffer serializers): Use
--!nativeon the whole file. - Some functions are native-worthy (mixed scripts with hot computational loops alongside API-heavy code): Use
@nativeon individual functions that benefit. This is the common case for real game scripts. - No functions are native-worthy (typical game scripts: event handlers, UI logic, Roblox API orchestration): Skip native codegen entirely. Don't waste the budget.
- All/most functions are native-worthy (math utility modules, physics solvers, buffer serializers): Use
-
@nativeplacement rules:- Place
@nativedirectly above the function declaration. - Inner/nested functions do NOT inherit
@native-- annotate them individually if needed. - Top-level module code runs once at
require()time;@nativehas no meaningful benefit there. - Prioritize functions identified as hot paths in Phase 1 (RunService handlers, loop bodies, recursive functions) -- but only if their work is computational, not API-bound.
- Place
-
Budget awareness: Check native codegen stats from
--record-stats. If the module's native instruction count is high, be more selective with@nativeto leave budget for other scripts in the experience.
Priority 2 -- Low-hanging structural wins
- Hoist library functions:
local floor = math.floorenables GETIMPORT fastcall. Fastcall builtins:assert,type,typeof,rawget/rawset/rawequal,getmetatable/setmetatable,tonumber/tostring, mostmath.*(notnoise,random/randomseed),bit32.*, somestring.*/table.*. Partial specializations:assert(unused return + truthy),bit32.extract(constant field/width),select(n, ...)O(1). - Replace
pairs(t)/ipairs(t)->for k, v in t do. Generalized iteration skips thepairs()call.for i=1,#tis slightly slower. math.floor(a / b)->a // b(dedicated VM opcode,//=compound form).- Use compound assignment (
+=,-=,*=,//=,..=) -- LHS evaluated once. - String concat in loops ->
table.concator backtick interpolation (lowers to optimizedstring.format). table.create(n)for known-size arrays. Sequential fill:local t = table.create(N); for i=1,N do t[i] = ... end.table.insert(t, v)for unknown-size append --#tis O(1) cached, worst case O(log N).a + (b - a) * t->math.lerp(a, b, t)(exact at endpoints, monotonic).- Manual byte swap ->
bit32.byteswap(n)(CPU bswap). - Manual log2 ->
bit32.countlz(n)(CPU instruction, ~8x faster). - Manual linear search ->
table.find(t, v). - Manual
pairsclone ->table.clone(t). - Manual
string.byte/string.char->string.pack/string.unpack. rawlen(t)when metamethods not needed.- Explicit
.//..//@prefixes inrequire().
Priority 3 -- Function structure for inlining
- Break monoliths into small local functions (compiler inlines at
-O2). - Inlining requirements: local, non-mutated, non-recursive, not OOP (
:syntax), not metamethod. Disabled bygetfenv/setfenv. - Inlining + constant folding cascade:
local function double(x) return x*2 end; local y = double(5)folds toy = 10. - Create local function wrappers for frequently-called imported module methods.
- Move
pcallout of hot loops. - Use
obj:Method()notobj.Method(obj)-- fast method call instruction. __indexshould point at a table directly for inline caching.- Reduce mutable upvalue captures in loop closures. Immutable upvalues = no allocation, no closing.
- Set all table fields at once (compiler infers hash capacity from subsequent assignments).
- Loop unrolling: constant bounds only, simple body.
- Native codegen size limits: 64K instructions/block, 32K blocks/function, 1M instructions/module. Split if exceeded.
Priority 4 -- Micro-optimizations (hot paths only)
- Table shape consistency (same fields, same order) -- inline caching predicts hash slots.
bufferfor binary data (fixed-size, offset-based, efficient native lowering).buffer.readu32+bit32overbuffer.readbitswhen schema known.- Strength reduction:
* 2^n->bit32.lshift,/ 2^n->bit32.rshift. - Minimize
tostring/tonumberin tight loops. table.freezefor readonly config (avoids proxy__indexoverhead).- Keep
__eqcheap -- fires on every==/~=andtable.find. math.isfinite/math.isnan/math.isinfoverx ~= x.- Annotate
Vector3params for native specialization. if expr then A else Bovercond and A or B(one branch, falsy-safe).
Phase 4.5 -- Benchmark with luau CLI
When a non-trivial optimization is applied to an isolated, pure-logic function (no Roblox API dependencies), write a small benchmark harness:
local function original(...)
-- paste original implementation
end
local function optimized(...)
-- paste optimized implementation
end
local ITERATIONS = 1_000_000
local clock = os.clock
for _ = 1, 1000 do original(...) end
for _ = 1, 1000 do optimized(...) end
local t0 = clock()
for _ = 1, ITERATIONS do original(...) end
local t1 = clock()
for _ = 1, ITERATIONS do optimized(...) end
local t2 = clock()
print(`Original: {t1 - t0:.4f}s`)
print(`Optimized: {t2 - t1:.4f}s`)
print(`Speedup: {(t1 - t0) / (t2 - t1):.2f}x`)
Run with both paths:
luau -O2 bench.luau
luau -O2 --codegen bench.luau
When to benchmark: Self-contained function, algorithmic/data structure change, ambiguous bytecode diff, tiebreak between approaches.
When NOT to benchmark: Roblox API dependencies (use Studio Script Profiler instead), purely additive changes, cold code.
Write the benchmark file to $LUAU_TMP\bench.luau, not the project directory. Run luau from $LUAU_TMP as working directory so profile.out and other residuals land there too.
Phase 5 -- Verification
Re-run all Phase 2 tools and present a before/after comparison:
- Bytecode instruction count delta
- Allocation count delta
- Inlining success rate delta
- Type coverage improvement (fewer
anytypes) - Register spill delta
- Lint warning delta
- Benchmark results (if Phase 4.5 was used)
If any metric regressed, investigate and explain why (or revert).
Phase 6 -- Multi-file Mode
When optimizing a directory:
- Run Phase 1 (code read) on all files to identify algorithmic improvement potential.
- Run Phase 2 (baseline) on all files to identify bytecode optimization potential.
- Prioritize by: worst algorithmic complexity, most lint warnings, most
anytypes, most allocations, most failed inlines. - Process files one at a time through Phases 1-5.
- Present aggregate metrics at the end.
Key Principles
These principles govern every optimization decision:
- Same implementation, better performance -- behavioral equivalence is non-negotiable. Never change what the code does, only how fast it does it.
- Algorithms first, bytecode second -- an O(n) algorithm with unoptimized bytecode beats an O(n^2) algorithm with perfect bytecode every time.
- Low-hanging fruit first -- headers and types before restructuring, restructuring before micro-optimizations.
- Compiler feedback loop -- verify each structural change with
luau-compile --remarks. If the compiler didn't benefit, the change wasn't worth it. - Small functions over monoliths -- easier for the compiler to inline, easier for humans to maintain, easier to reason about algorithmically.
- Local functions for inlining -- even at the cost of duplicating imported behavior in high-traffic code.
- Quantify everything -- before/after metrics for every optimization pass. No "I think this is faster" -- prove it.
- Type annotations drive native codegen quality -- every
anytype is a missed specialization opportunity. The JIT uses annotations directly with no runtime analysis. - Native codegen is a budget, not a checkbox --
--!nativeon every file wastes the per-experience native code memory limit on functions that don't benefit. Apply@nativeselectively to computational hot functions; reserve--!nativefor genuinely math-heavy modules. - Event-driven over polling -- avoid per-frame calculations when events suffice.
Reference: Type System (Old Typechecker Only)
Available NOW:
--!strict/--!nonstrict/--!nocheck- Basic annotations:
local x: number,function f(a: string): boolean - Optional:
string?, Union:number | string, Intersection:T1 & T2 - Generics:
function id<T>(x: T): T, explicit instantiation:f<<number>>() - Cast:
x :: string - Array:
{number}, Dict:{[string]: number}, Table:type T = { field: type } typeof(),export type, typed variadics, alias defaults, singletonsnever,unknown,read/writemodifiers- Type refinements:
type(),typeof(),:IsA(), truthiness, equality,assert()
DO NOT recommend (requires new typechecker):
keyof<T>,index<T,K>,rawkeyof<T>,rawget<T,K>getmetatable<T>,setmetatable<T,M>- User-defined type functions, negation types (
~T) - Relaxed recursive type restrictions
Reference: Type Refinement Patterns
The compiler narrows types after certain checks, improving both type safety and native codegen. Use refinements to provide type information without explicit annotations where the type is known from a runtime check:
- Truthiness:
if x thennarrowsxfrom falsy (nil/false) - Type guards:
if type(x) == "number" thennarrowsxtonumber - Typeof guards (Roblox):
if typeof(x) == "Vector3" thennarrows toVector3;x:IsA("TextLabel")narrowsInstanceto subclass - Equality:
if x == "hello" thennarrowsxto singleton"hello" - Assert:
assert(type(x) == "string")narrowsxtostringafter the call - Composition: Supports
and,or,notfor compound refinements
Reference: Deprecated Patterns
| Deprecated | Replacement | Impact |
|---|---|---|
getfenv() / setfenv() |
debug.info(i, "snl") |
Disables ALL optimizations. Even read-only getfenv() deoptimizes. |
loadstring() |
Restructure with require |
Marks env "impure", disables imports |
table.getn(t) |
#t |
Slower |
table.foreach(t, f) |
for k, v in t do |
Slower |
table.foreachi(t, f) |
for i, v in t do |
Slower |
wait() |
task.wait() |
Deprecated Roblox API |
obj.Method(obj, ...) |
obj:Method(...) |
Misses fast method call instruction |
string:method() |
string.method(s) |
Fastcall: string.byte(s) faster than s:byte() |
Reference: Lint Rules
| Lint | Name | Optimization impact |
|---|---|---|
| 10 | BuiltinGlobalWrite | Overwriting builtins disables fastcall |
| 22 | DeprecatedApi | Deprecated APIs: perf/correctness issues |
| 23 | TableOperations | Wrong index, redundant insert, # on non-arrays |
| 3 | GlobalUsedAsLocal | Global in one function -> should be local |
| 7 | LocalUnused | Dead locals |
| 12 | UnreachableCode | Dead code |
| 25 | MisleadingAndOr | a and false or c bugs |
Reference: Bytecode Pattern Catalog
| Slow | Fast | Why |
|---|---|---|
math.floor(a / b) |
a // b |
Dedicated VM opcode |
data[i].x = data[i].x + 1 |
data[i].x += 1 |
LHS evaluated once |
a + (b - a) * t |
math.lerp(a, b, t) |
Exact at endpoints, monotonic |
"pre" .. v .. "suf" |
`pre{v}suf` |
Optimized string.format |
| Manual byte swap | bit32.byteswap(n) |
CPU bswap |
| Manual log2 loop | bit32.countlz(n) |
CPU instruction, ~8x faster |
cond and A or B |
if cond then A else B |
Safe for falsy, one branch |
Reference: Allocation Reduction
| Slow | Fast | Why |
|---|---|---|
local t = {} in loop |
table.clear(t) + reuse |
Avoids GC pressure |
Repeated table.insert |
table.create(n) + indexed writes |
Preallocated |
Manual pairs clone |
table.clone(t) |
Faster, copies layout |
string.byte/char loops |
string.pack/unpack |
Native implementation |
| String binary data | buffer type |
Fixed-size, efficient |
Reference: Inlining Rules
| Blocks inlining | Enables inlining | Why |
|---|---|---|
function Module.foo() |
local function foo() |
Mutable table prevents |
function Obj:method() |
local function method(self) |
OOP syntax not inlineable |
| Recursive function | Split base/recursive | Recursion prevents |
| Deep upvalue captures | Pass as parameters | Reduces closure cost |
Reference: Native Codegen
--!native (whole-file) -- use sparingly:
- Compiles every function in the file to native code, consuming the per-module 1M instruction limit and the per-experience memory budget.
- Only appropriate for files where most/all functions are computational: math utilities, physics solvers, buffer serializers, numerical algorithms.
- Wasteful on typical game scripts where most functions are Roblox API calls, table construction, or string operations -- these don't benefit from native codegen but still eat the budget.
@native (per-function) -- preferred for most files:
- Annotate only functions that perform computational work benefiting from JIT: tight loops,
Vector3/CFramearithmetic,bufferops,bit32ops, numerical math. - Place directly above the function declaration. Inner functions do NOT inherit it.
- Top-level code runs once at
require()time;@nativeprovides no meaningful benefit there. - This is the correct default strategy for mixed scripts with both hot computational code and API-heavy orchestration.
Functions that do NOT benefit from @native:
- Roblox API calls (
Instance:Clone(),:FindFirstChild(),:GetChildren(),:WaitForChild(), event connections) -- execution time is in C++/engine, not Luau bytecode - Table construction without computation (allocation-bound; the allocator is C code native codegen can't speed up)
- Heavy Luau library calls (
string.format,string.match,string.gmatch-- C implementations that native codegen can't accelerate) - One-shot initialization code
- Event handler registration and wiring
require()calls and module setup- Coroutine/task scheduling (
task.spawn,task.defer,task.wait)
Functions that benefit from @native:
- Tight numerical loops (physics, interpolation, noise generation)
- Mathematical operations on table data (reading values, computing, writing back -- the Roblox docs explicitly cite this)
Vector3/CFramearithmetic (native generates specialized vector code with proper type annotations)bufferread/write operations (efficient native lowering)bit32operations (maps to CPU instructions)- Hot paths called every frame (RunService handlers with computational work, not API orchestration)
Hurts native: getfenv/setfenv, wrong types to typed functions, non-numeric math args, breakpoints, size limits (64K instructions/block, 32K blocks/function, 1M/module).
Budget management: The 1M instruction limit is per-module; the memory budget for native code is per-experience, shared across ALL native scripts. Use --record-stats=function to check each function's native instruction count and debug.dumpcodesize() in Studio to see per-experience usage. A few well-chosen @native functions outperform blanket --!native across many files.
Reference: CLI Tools
luau-compile -- Static bytecode/codegen analysis (primary tool)
Modes (mutually exclusive): binary (raw bytecode), text (human-readable opcodes with source annotations), remarks (source with inlining/allocation comments -- most useful), codegen (native assembly, requires --target).
Options: -O<n> (0-2, default 1; -O2 enables inlining), -g<n> (debug level 0-2), --target=<arch> (x64, x64_ms, a64, a64_nf; use x64_ms on Windows), --record-stats=<total|file|function> (JSON stats: bytecodeInstructionCount, lowerStats with spills/skipped/blocks/errors), --bytecode-summary (opcode distribution, requires --record-stats=function), --stats-file=<name> (default stats.json), --timetrace (trace.json), --vector-lib/--vector-ctor/--vector-type (always set for Roblox).
luau-analyze -- Type checking and linting
Modes: omitted (typecheck + lint), --annotate (source with all inferred types inline -- critical for finding any).
Options: --mode=strict (force strict even without directive), --formatter=plain (machine-parseable), --formatter=gnu (grep-friendly), --timetrace.
luau -- Runtime execution, profiling, and coverage
Limited for Roblox scripts (most depend on engine APIs). Only usable for pure-logic modules. For Roblox scripts, use Studio Script Profiler and debug.dumpcodesize().
Options: -O<n>, -g<n>, --codegen (native execution), --profile[=N] (sampling at N Hz, default 10000, outputs profile.out), --coverage (outputs coverage.out), --timetrace, -a (program args).
luau-ast -- AST dump
No flags. Outputs full JSON AST with node types, source locations, variable scopes, type annotations. Useful for programmatic analysis: function nesting depth, function body size (monolith detection), closure captures (upvalue analysis), table construction patterns, loop-invariant expression detection.
Reference: VM Architecture
Fastcall builtins: assert, type, typeof, rawget/rawset/rawequal/rawlen, getmetatable/setmetatable, tonumber/tostring, most math.* (not noise/random/randomseed), bit32.*, some string.*/table.*. Partial: assert (unused return + truthy), bit32.extract (constant field/width), select(n, ...) O(1). With -O2: constant-arg builtins folded; math.pi, math.huge folded.
Inline caching: HREF-style for table fields. Compiler predicts hash slot; VM corrects. Best: compile-time field name, no metatables, uniform shapes.
Import resolution: math.max resolved at load time. Invalidated by getfenv/setfenv/loadstring.
GC: Incremental mark-sweep with "GC assists" (allocation pays proportional GC). Paged sweeper (16 KB pages). No __gc metamethod.
Compiler: Multi-pass. Constant folding across functions/locals. Upvalue optimization. -O2: inlining, loop unrolling (constant bounds), aggressive constant folding. Interprocedural limited to single modules.
Table length: #t is O(1) cached, worst case O(log N) branch-free binary search. table.insert/table.remove update the cache.
Upvalues: ~90% immutable. Immutable = no allocation, no closing, faster access. Mutable = extra object.
Closures: Cached when no upvalues, or all upvalues immutable and module-scope.
Reference: Documentation to Consult
Read these docs during execution if needed for specific detail. Two doc trees:
.cursor/rules/luau/-- Official Luau site docs (guides, types, reference, getting-started).cursor/rules/luau-rfcs/-- Luau RFCs (language proposals, library additions)
Luau site docs (primary):
.cursor/rules/luau/guides/performance.md-- The official Luau performance guide. Covers interpreter, compiler, fastcalls, inline caching, imports, table creation, GC, inlining, loop unrolling, upvalues, closures..cursor/rules/luau/guides/profile.md-- Profiling guide (flame graphs, sampling).cursor/rules/luau/types/-- Full type system docs (basic-types, tables, generics, refinements, unions-and-intersections, object-oriented-programs, roblox-types, considerations).cursor/rules/luau/getting-started/lint.md-- All 28 lint rules.cursor/rules/luau/reference/library.md-- Complete library reference.cursor/rules/luau/getting-started/compatibility.md-- Luau vs Lua differences
Luau RFCs (specific features):
.cursor/rules/luau-rfcs/function-inlining.md-- Inlining RFC (no user@inline, automatic at-O2).cursor/rules/luau-rfcs/syntax-attribute-functions-native.md--@nativeper-function attribute.cursor/rules/luau-rfcs/function-table-create-find.md,function-table-clear.md,function-table-clone.md,function-table-freeze.md-- Table operations.cursor/rules/luau-rfcs/function-math-lerp.md--math.lerp.cursor/rules/luau-rfcs/function-bit32-byteswap.md,function-bit32-countlz-countrz.md-- CPU-level bit ops.cursor/rules/luau-rfcs/function-buffer-bits.md,type-byte-buffer.md-- Buffer type and ops.cursor/rules/luau-rfcs/vector-library.md-- Vector library and fastcalls.cursor/rules/luau-rfcs/function-string-pack-unpack.md-- Binary string operations.cursor/rules/luau-rfcs/syntax-floor-division-operator.md--//dedicated opcode.cursor/rules/luau-rfcs/syntax-compound-assignment.md--+=single LHS evaluation.cursor/rules/luau-rfcs/deprecate-getfenv-setfenv.md-- Why fenv disables optimizations.cursor/rules/luau-rfcs/deprecate-table-getn-foreach.md-- Deprecated table functions.cursor/rules/luau-rfcs/generalized-iteration.md-- Modern iteration patterns.cursor/rules/luau-rfcs/generic-functions.md,never-and-unknown-types.md,syntax-type-ascription.md-- Type system RFCs
Cursor rules:
.cursor/rules/luau.mdc-- Luau language rules and optimization tips
Roblox docs:
.cursor/rules/roblox/en-us/luau/native-code-gen.md-- Native codegen (size limits, Vector3 annotation impact).cursor/rules/roblox/en-us/luau/type-checking.md-- Type annotation syntax and modes.cursor/rules/roblox/en-us/performance-optimization/improve.md-- Script computation, memory, physics, rendering.cursor/rules/roblox/en-us/performance-optimization/design.md-- Event-driven patterns, frame budgets.cursor/rules/roblox/en-us/luau/variables.md-- Local vs global performance.cursor/rules/roblox/en-us/luau/scope.md-- Scope performance implications