rojo/.cursor/commands/luau-optimize.md
2026-03-01 08:50:26 -06:00

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/.lua files, enter Phase 6 (multi-file mode).
  • If no argument: optimize the currently open file.

Infer script context

Check in this order:

  1. 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.
  2. Path-based inference (for ModuleScripts and ambiguous cases):
    • ServerScriptService/ServerStorage in path -> server-side module
    • StarterPlayer/StarterGui/StarterCharacterScripts/ReplicatedFirst in path -> client-side module
    • ReplicatedStorage/shared in 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 analysis
  • moderate -- + function restructuring for inlining, import hoisting, compound operators, fastcall enablement, allocation reduction
  • insane -- + 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 flow
  • low hanging fruit -- Obvious O(n^2)->dict, redundant iterations, missing caches
  • moderate -- + data structure changes, caching strategies, event-driven refactors, loop fusion
  • insane -- + full algorithmic redesign where beneficial, dynamic programming, architectural restructuring

Question 3: "Benchmarking"

  • yes -- Benchmark all non-trivial changes with luau CLI (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 true values)
  • 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.concat pattern or buffer
  • 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):

  1. Structural decomposition -- break monoliths into focused functions (foundation for everything else)
  2. Data structure changes -- swap arrays for dicts, add indexes, etc.
  3. Algorithm changes -- replace O(n^2) with O(n), add caching/memoization
  4. 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 any types in --annotate output
  • Register spills and skipped functions (from --record-stats JSON lowerStats)
  • 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: any inferences hurting native codegen (especially Vector3, CFrame, buffer params). JIT uses annotations directly -- no runtime type analysis. Unannotated params assumed to be tables.
  • Import patterns: math.max resolved at load time via GETIMPORT. Broken by getfenv/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. Avoid obj.Method(obj). __index should 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.concat or string interpolation.
  • Deoptimizing APIs: getfenv/setfenv (even read-only!), loadstring disable ALL import optimization and fastcalls.
  • Builtin global writes: math = ... disables fastcall. Lint BuiltinGlobalWrite catches this.
  • Metamethod cost: __eq always 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 2 is 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 --annotate and identify every any inference. 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, buffer params 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 :: any casts. A cast to any is 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 any in 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 to any when 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 explicit self: ClassName on methods (old typechecker compatible).
  • For generic code: use proper generics (<T>) instead of any. If the function truly accepts anything, use unknown and narrow explicitly.
  • Track type coverage: count any in --annotate output. 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.

  1. Classify every function in the file by its dominant work:

    • Native-worthy: Tight computational loops, math-heavy logic (Vector3/CFrame arithmetic, buffer ops, bit32 ops, 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.
  2. Decision matrix:

    • All/most functions are native-worthy (math utility modules, physics solvers, buffer serializers): Use --!native on the whole file.
    • Some functions are native-worthy (mixed scripts with hot computational loops alongside API-heavy code): Use @native on 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.
  3. @native placement rules:

    • Place @native directly 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; @native has 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.
  4. Budget awareness: Check native codegen stats from --record-stats. If the module's native instruction count is high, be more selective with @native to leave budget for other scripts in the experience.

Priority 2 -- Low-hanging structural wins

  • Hoist library functions: local floor = math.floor enables GETIMPORT fastcall. Fastcall builtins: assert, type, typeof, rawget/rawset/rawequal, getmetatable/setmetatable, tonumber/tostring, most math.* (not noise, random/randomseed), bit32.*, some string.*/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 the pairs() call. for i=1,#t is slightly slower.
  • math.floor(a / b) -> a // b (dedicated VM opcode, //= compound form).
  • Use compound assignment (+=, -=, *=, //=, ..=) -- LHS evaluated once.
  • String concat in loops -> table.concat or backtick interpolation (lowers to optimized string.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 -- #t is 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 pairs clone -> table.clone(t).
  • Manual string.byte/string.char -> string.pack/string.unpack.
  • rawlen(t) when metamethods not needed.
  • Explicit .//..//@ prefixes in require().

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 by getfenv/setfenv.
  • Inlining + constant folding cascade: local function double(x) return x*2 end; local y = double(5) folds to y = 10.
  • Create local function wrappers for frequently-called imported module methods.
  • Move pcall out of hot loops.
  • Use obj:Method() not obj.Method(obj) -- fast method call instruction.
  • __index should 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.
  • buffer for binary data (fixed-size, offset-based, efficient native lowering).
  • buffer.readu32 + bit32 over buffer.readbits when schema known.
  • Strength reduction: * 2^n -> bit32.lshift, / 2^n -> bit32.rshift.
  • Minimize tostring/tonumber in tight loops.
  • table.freeze for readonly config (avoids proxy __index overhead).
  • Keep __eq cheap -- fires on every ==/~= and table.find.
  • math.isfinite/math.isnan/math.isinf over x ~= x.
  • Annotate Vector3 params for native specialization.
  • if expr then A else B over cond 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 any types)
  • 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:

  1. Run Phase 1 (code read) on all files to identify algorithmic improvement potential.
  2. Run Phase 2 (baseline) on all files to identify bytecode optimization potential.
  3. Prioritize by: worst algorithmic complexity, most lint warnings, most any types, most allocations, most failed inlines.
  4. Process files one at a time through Phases 1-5.
  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 any type is a missed specialization opportunity. The JIT uses annotations directly with no runtime analysis.
  • Native codegen is a budget, not a checkbox -- --!native on every file wastes the per-experience native code memory limit on functions that don't benefit. Apply @native selectively to computational hot functions; reserve --!native for 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, singletons
  • never, unknown, read/write modifiers
  • 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 then narrows x from falsy (nil/false)
  • Type guards: if type(x) == "number" then narrows x to number
  • Typeof guards (Roblox): if typeof(x) == "Vector3" then narrows to Vector3; x:IsA("TextLabel") narrows Instance to subclass
  • Equality: if x == "hello" then narrows x to singleton "hello"
  • Assert: assert(type(x) == "string") narrows x to string after the call
  • Composition: Supports and, or, not for 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/CFrame arithmetic, buffer ops, bit32 ops, numerical math.
  • Place directly above the function declaration. Inner functions do NOT inherit it.
  • Top-level code runs once at require() time; @native provides 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/CFrame arithmetic (native generates specialized vector code with proper type annotations)
  • buffer read/write operations (efficient native lowering)
  • bit32 operations (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 -- @native per-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