Compile Lua into Fast, Standalone Native Executables

clx is an ahead-of-time (AOT) Lua compiler and runtime for Linux, macOS, and Windows. It turns your Lua 5.5 scripts into fast, self-contained native binaries — no interpreter, no virtual machine, and no runtime dependencies to ship. Build once, and your program runs instantly, anywhere.

Everything you already know about Lua just works: the full language, standard libraries, and coroutines. When you need to load code at runtime, clx's optional dynamic mode will run a Lua virtual machine alongside your compiled code, giving you the best of both worlds: speed and flexibility.

Binary modules are supported, but must be compiled with the clx C++ API. See the Modules section for more information.

Multiplatform
Lua compiler
C++20
Backend
MIT
Open Source License
Lua 5.5
Compatibility

Features

Native AOT
Compile Lua directly to optimized C++20 code. No interpreter layer, no bytecode overhead.
Zero Dependencies
Normal AOT binaries are fully self-contained, with no libraries to install alongside your program.
Lua 5.5 runtime
Lua 5.5 language and the clx standard modules, ready to use out of the box.
Modern garbage collector
Mark-and-sweep collector with reusable worklists and explicit incremental GC options.
Cross-Platform
Available on Linux, macOS, and Windows.
Optimizations
Optimizer using static analysis to speedup program execution.
C++20 Backend
SROA, SIMD vectorization, CPU cache friendly optimizations...
Extendable C++ API
Build third party modules using the clx C++ library

Installation

clx is available as source code (build from any platform) and as pre-built binaries for Linux (x86_64), macOS (ARM64), and Windows (x86_64) from GitHub Releases, built automatically via CI. Linux binaries require glibc ≥ 2.39.

Build from source

Clone the repository and run the build script on macOS or Linux:

shmacOS / Linux
$ git clone https://github.com/samyeyo/clx $ cd clx $ ./build.sh install

This installs the clx compiler to /usr/local/bin, the runtime libraries (libclx.a, libclx_size.a, libclx_lua.a) to /usr/local/lib, and the headers to /usr/local/include. Run ./build.sh uninstall to remove it.

Windows

CMDWindows
> git clone https://github.com/samyeyo/clx > cd clx > build.bat install

This installs the compiler to %ProgramFiles%\clx\bin, the libraries (clx.lib, clx_size.lib) to %ProgramFiles%\clx\lib, and the headers to %ProgramFiles%\clx\include. Run build.bat uninstall to remove it.

On either platform, override the install location with -DCMAKE_INSTALL_PREFIX=<dir> when configuring.

Pre-built binaries

The archives from GitHub Releases ship the same layout as a source install, inside a clx-<platform>/ folder (bin/, include/, lib/), so you can extract them directly into the install prefix of your choice:

shPre-built archive (Linux / macOS)
$ tar xzf clx-linux-x86_64.tar.gz $ sudo cp -r clx-linux-x86_64/* /usr/local/ $ clx --version

After this, clx is on your PATH with the libraries and headers under /usr/local.

Target architecture (CLX_ARCH)

By default clx targets the widest compatibility baseline: sse2 on x86_64 (-msse2 / /arch:SSE2) and native on ARM64 (-mcpu=native). The same flag is baked into the runtime libraries and injected into every binary clx compiles (including --debug builds), unless you override it with an explicit compiler flag.

Override with an environment variable before the wrapper script (any CLX_* CMake option works the same way):

shLinux / macOS — x86 / ARM
# x86: sse2 (default) | avx | avx2 | native $ CLX_ARCH=avx2 ./build.sh $ CLX_ARCH=avx2 ./build.sh install # ARM: native (default) | generic (portable, no -mcpu flag) $ CLX_ARCH=generic ./build.sh $ CLX_ARCH=generic ./build.sh install # Optimize for the build machine $ CLX_ARCH=native ./build.sh
batWindows
> set CLX_ARCH=avx2 && build.bat > set CLX_ARCH=avx2 && build.bat install

When invoking cmake directly, use -D instead: cmake -S . -B build -DCLX_ARCH=avx2.

CLX_ARCH x86 flag (Clang/GCC) x86 flag (MSVC) ARM flag
sse2 (x86 default)-msse2/arch:SSE2
avx-mavx/arch:AVX
avx2-mavx2/arch:AVX2
native-march=native/arch:AVX2-mcpu=native (ARM default)
genericno flag, portable

If you pass an explicit arch flag to clx itself, it takes precedence and the CLX_ARCH default is not added: clx file.lua -march=native.

Verify installation

shVerify
$ clx --version clx 0.3.0 MIT License - Copyright (c) 2026 Tine Samir

Benchmarks

Performance comparison against Lua 5.5 and LuaJIT. The speedup is relative to Lua 5.5. Results are averages from 10 runs using hyperfine on a single CPU; they vary with the C++ toolchain and environment.

Runtime Time Speedup

Getting Started

Build clx

Clone the repository and build from source:

shBuild
$ git clone https://github.com/samyeyo/clx $ cd clx $ ./build.sh

Add a flag to vary the build: ./build.sh debug for a debug build, ./build.sh install to install, or ./build.sh clean to clear the build directory.

Alternatively, build manually with CMake:

$ mkdir build $ cd build $ cmake .. -DCMAKE_BUILD_TYPE=Release $ cmake --build .

Compile Your First Program

Create hello.lua:

luahello.lua
print("Hello, World!")

Compile and run:

$ ./build/clx hello.lua $ ./hello Hello, World!

Your Second Program

Let's try something more interesting:

luafib.lua
-- fib.lua function fib(n) if n <= 1 then return n end return fib(n - 1) + fib(n - 2) end print("Fibonacci(20) = " .. fib(20))

Compile it with --fast flag for better performances:

$ ./build/clx --fast fib.lua $ ./fib Fibonacci(20) = 6765

Language Features

clx supports most Lua 5.5 features including variables, control flow, functions, tables, metatables, coroutines, standard libraries, and bitwise operations. load(), loadfile(), and dofile() are registered only for programs compiled with --dynamic; they execute source in the embedded Lua VM and are absent from ordinary AOT and --minimal builds. require() of Lua file modules through package.path also requires --dynamic: those files are compiled and executed on the embedded VM rather than AOT-compiled. The AOT runtime does not provide string.dump() or debug directly; the dynamic VM provides its own library behavior.

Variables and Types

lua
-- Numbers local x = 42 local pi = 3.14159 -- Strings local greeting = "Hello" local name = 'World' -- Booleans local flag = true -- Tables local t = { a = 1, b = 2 } local arr = { 1, 2, 3 } -- Functions local function add(a, b) return a + b end -- Closures local function counter() local n = 0 return function() n = n + 1 return n end end

Control Flow

lua
-- If/else if x > 10 then print("big") elseif x > 5 then print("medium") else print("small") end -- While loop while x > 0 do print(x) x = x - 1 end -- For loop (numeric) for i = 1, 10 do print(i) end -- For loop (generic) for k, v in pairs(t) do print(k, v) end -- Repeat/until repeat x = x - 1 until x == 0

Functions

lua
-- Basic function function greet(name) return "Hello, " .. name end -- Multiple return values function divmod(a, b) return math.floor(a / b), a % b end -- Variadic function sum(...) local total = 0 for i = 1, select("#", ...) do total = total + select(i, ...) end return total end -- Method syntax local obj = { value = 10 } function obj:double() self.value = self.value * 2 end

Tables and Metatables

lua
-- Table with methods local vector = { x = 0, y = 0, add = function(self, other) return { x = self.x + other.x, y = self.y + other.y } end, __tostring = function(self) return "(" .. self.x .. "," .. self.y .. ")" end } -- Metatable for operator overloading setmetatable(vector, { __add = function(a, b) return a:add(b) end }) local v1 = { x = 1, y = 2 } local v2 = { x = 3, y = 4 } local v3 = v1 + v2 -- Uses __add

Coroutines

lua
-- Producer/consumer with coroutines local function producer(max) for i = 1, max do coroutine.yield(i) end end local function consumer() local co = coroutine.create(producer) while true do local status, value = coroutine.resume(co) if not status or value == nil then break end print("Received: " .. value) end end consumer()

String Module

lua
-- Basic operations local s = "Hello, World!" print(string.len(s)) -- 13 print(string.sub(s, 1, 5)) -- Hello print(string.upper(s)) -- HELLO, WORLD! print(string.lower(s)) -- hello, world! print(string.reverse(s)) -- !dlroW ,olleH -- Character conversion print(string.byte("A")) -- 65 print(string.char(65, 66, 67)) -- ABC -- Repetition print(string.rep("ab", 3)) -- ababab print(string.rep("x", 5, "-")) -- x-x-x-x-x -- Format print(string.format("Pi: %.2f", 3.14159)) -- Pi: 3.14 -- Pattern matching local start, finish = string.find("hello world", "world") print(start, finish) -- 7 11 local match = string.match("hello world", "(%a+)") print(match) -- hello for word in string.gmatch("hello world from lua", "%a+") do print(word) end local result, count = string.gsub("hello world", "world", "lua") print(result, count) -- hello lua, 1

Bitwise Operations

lua
-- Bitwise AND, OR, XOR print(0xFF & 0x0F) -- 15 print(0xF0 | 0x0F) -- 255 print(0xFF ~ 0xF0) -- 15 -- Bitwise shifts print(1 << 8) -- 256 print(256 >> 4) -- 16 -- Bitwise NOT print(~0) -- -1

Performance Tips

Use local variables, prefer numeric for loops, and avoid mixing types for optimal performance.

luaTips
-- Good: local variables are faster local function compute() local result = 0 for i = 1, 1000 do local temp = i * 2 result = result + temp end return result end -- Prefer numeric for loops for i = 1, 1000000 do -- body end -- Avoid mixed types: 1 + "2" is slower than 1 + 2

Common Issues

Debugging Compilation Errors

If you get a C++ compilation error, you can see the generated code with --cpp:

$ clx script.lua --cpp

This creates script.cpp in the current directory, which you can examine to see what's being generated.

Understanding Runtime Errors

Runtime errors show the Lua line where the error occurred:

Error: script.lua:10: attempt to perform arithmetic on a number value

The format is filename:line: message.

Next Steps

Read the Dynamic execution, CLI, Modules, and Compatibility documentation.

Dynamic execution

What is it?

Usually clx compiles your Lua ahead of time into a native program. Sometimes you also want to run Lua source at runtime — for example a user-supplied script, a config file, or a plugin. The optional --dynamic switch enables this by embedding a Lua 5.5 engine.

shBuild and compile
$ cmake -S . -B build $ cmake --build build $ ./build/clx main.lua --dynamic --output myapp $ ./myapp

Don't combine --dynamic with --minimal if you need runtime loading — minimal builds leave out the library setup that enables it.

Runtime loading

A --dynamic build adds three familiar functions. load compiles a string of source and returns a callable function, loadfile does the same for a file, and dofile loads and immediately runs a file:

local chunk, err = load(source) local chunk, err = loadfile(filename) local result = dofile(filename)
luaExample
local chunk, err = load("return 6 * 7", "smoke", "t") assert(chunk, err) assert(chunk() == 42)

Always check the first result before calling the chunk, especially if the source comes from an untrusted user.

Requiring modules at runtime

A --dynamic build also completes require(): package.searchers[2] finds Lua files via package.path and runs them on the embedded VM — the same way loadfile does. Required modules are not AOT-compiled, and the value they return crosses the boundary back to your compiled code:

luaExample
package.path = package.path .. ";./lib/?.lua;./lib/?/init.lua" local greet = require("greet") -- ./lib/greet.lua, executed on the embedded VM print(greet.hello("world"))

Modules bundled at compile time or registered in package.preload are found by the preload searcher first and never touch the VM. In plain AOT builds the file searcher locates the file but reports that --dynamic is required.

Things to keep in mind

  • Your compiled code is usually faster. Dynamic calls cross between the two environments with some overhead, so keep performance-critical loops out of loaded code.
  • Libraries are self-contained. Loaded code uses its own copy of the standard libraries; a compiled module isn't automatically visible to it through require.
  • Values are converted, not shared. A table passed across the boundary is a copy or a proxy — don't rely on shared identity or metatables.
  • Coroutines stay on one side. Keep create/resume/yield inside either the compiled code or the loaded chunk, not across the boundary.

Sharing values with loaded code

A loaded chunk can read your program's globals. Here your program sets a global that a loaded chunk then reads back:

shared_value = 123 local chunk = load("return shared_value", "g", "t") assert(chunk() == 123)

You can also give a chunk its own environment table instead of using your globals.

Limitations

  • Requires compiling with --dynamic; skipped in --minimal builds.
  • load accepts a source string only (no reader-function form).
  • string.dump and loading dumped bytecode aren't provided by the compiled runtime.
  • Tables, metatables, userdata, and coroutines don't share identity across the two environments.

For full details, see doc/dynamic-lua.md.

CLI Reference

Usage

$ clx [options] <file.lua> [<compiler-options>]

Options starting with - that are not recognized by clx are automatically passed through to the C++ compiler.

Build Mode

--executable Compile to executable (default) --object Compile to object file (.o/.obj) --static Compile to static module (.a/.lib)

Output Options

--output <name> Specify output file name

Compilation Options

--debug Enable debug symbols, disable optimizations; #line directives map debugger views to Lua source --size Optimize for size (default) --fast Optimize for speed --cpp Generate C++ source files, don't compile --minimal Exclude non-essential modules (string, table, io, os, math, utf8, coroutine); keeps base + package --dynamic Link the embedded Lua 5.5 VM and enable load, loadfile, and dofile --modules <list> Precompiled modules to link (comma-separated)

Choosing speed vs. size

The two common build modes boil down to a simple tradeoff:

--size (default) Smallest possible binary — best for scripts and installers --fast Fastest execution — best for heavy math or computation

For most ordinary programs the difference is small. Pick --fast when your program is dominated by computation, and --size when binary size matters more.

If you pass your own compiler options (for example -O2 or -march=native), clx uses exactly those and skips its default flags.

Platform-Specific

The C++ compiler is fixed when clx is built (the same compiler that built clx compiles your Lua scripts), which keeps toolchains consistent.

  • Linux/macOS produce an executable with no extension, an object as .o, and a static library as .a.
  • Windows produces .exe, .obj, and .lib.

Examples

Compile to an executable (the default):

$ clx script.lua

Give the output a custom name:

$ clx script.lua --output myapp

Build for the fastest execution (or use --size, the default, for the smallest binary):

$ clx script.lua --fast

Build a debuggable program so you can step through the Lua source in a debugger:

$ clx script.lua --debug

Write out the generated C++ without compiling (useful when debugging clx itself):

$ clx script.lua --cpp

Pass your own compiler flags (this replaces clx's default flags):

$ clx script.lua -O2

Produce an object file or a static library instead of an executable:

$ clx script.lua --object $ clx script.lua --static

Environment Variables

clx respects these environment variables:

CXX (Not read — compiler fixed at build time)

Exit Codes

0 Success 1 Usage or compilation error

Build with CMake

If building from source:

$ mkdir build $ cd build $ cmake .. -DCMAKE_BUILD_TYPE=Release $ cmake --build . $ ./clx --help

Modules

clx supports three ways to organize and load modules: Lua source modules compiled alongside your entry point (static preload), statically linked C++ modules with --modules, and — with --dynamic — Lua files loaded at runtime through package.path, which execute on the embedded Lua 5.5 VM. All three are consumed via Lua's require().

Binary modules must be compiled with the clx C++ API, as the Lua C API is not supported.

Lua Source Modules

Pass multiple .lua files to clx — the first is the entry point, the rest become modules loadable via require:

$ clx main.lua mymodule.lua utils.lua --output myapp

Inside main.lua, require them by name (filename without .lua):

luamain.lua
local mymodule = require("mymodule") local utils = require("utils") mymodule.say_hello() utils.help()

How it works

clx compiles each .lua file into a function luaopen_<module>, and your generated main() registers each module so it becomes available to require:

main() { open(); openlibs(L); register_module("mymodule", luaopen_mymodule); register_module("utils", luaopen_utils); luaopen_main(L); close(L); }

When your code calls require("mymodule"), clx checks whether the module was already loaded, and if not, runs its luaopen_ function once and caches the result. Later calls return the cached value without re-running it.

Linking

All builds link statically against libclx.a. No shared library is needed at runtime.

Module convention

A Lua source module should return a table (or any value) that becomes the result of require:

luamymodule.lua
local M = {} function M.say_hello() print("hello from mymodule") end return M

Runtime-Loaded Lua Modules (package.path)

With --dynamic, require() can also load Lua files at runtime through package.path, using the same searcher chain as stock Lua (package.searchers: preload → Lua file → C). Required files are compiled and executed on the embedded Lua 5.5 VM — they are not AOT-compiled, so keep hot loops in your bundled modules:

shBuild
$ clx main.lua --dynamic --output myapp
luamain.lua
package.path = package.path .. ";./lib/?.lua;./lib/?/init.lua" local greet = require("greet") -- ./lib/greet.lua, runs on the embedded VM local util = require("util") -- ./lib/util/init.lua

In plain AOT builds, package.path, package.searchers, and package.searchpath exist, but require of a file module reports that --dynamic is required. See Dynamic execution.

C++ Native Modules (Statically Linked)

You can link precompiled C++ code using --modules:

$ clx main.lua --modules my_native_mod

The function must use CLX_API (which provides extern linkage and proper symbol visibility):

CLX_API clx::LValue luaopen_my_native_mod(clx::LState* L);

The generated main() calls register_module, which stores the wrapper in package.preload — the function runs only on first require().

Writing a C++ native module

cppmy_native_mod.cpp
#include <clx.h> CLX_API clx::LValue luaopen_my_native_mod(clx::LState* L) { clx::LValue t = L->create_table(); clx::LTable* mod = static_cast<clx::LTable*>(t.as_pointer()); mod->bind(L, "add", [](clx::LState* L, const clx::LValue* args, size_t n) -> clx::MultiValue { double a = args[0].as_number(); double b = args[1].as_number(); return clx::MultiValue(clx::LValue(a + b)); }); return t; }

Compile it to an object file with your C++ compiler:

Linux/macOSg++/clang++
$ g++ -c -std=c++20 -I./include my_native_mod.cpp -o my_native_mod.o
WindowsMSVC
> cl /c /std:c++20 /I.\include my_native_mod.cpp /Fomy_native_mod.obj

Then link with your Lua script. clx looks for my_native_mod.a (or .lib on Windows) in the current directory, then in <clx-install-dir>/lib/clx/, and on POSIX also in /usr/local/lib/clx/:

$ clx main.lua --modules my_native_mod

If your module depends on external libraries, pass link flags directly:

$ clx main.lua --modules my_native_mod -lm -lz

Compiling Lua to Libraries

Static Library

$ clx mylib.lua --static --output mylib

This produces libmylib.a on Linux/macOS or mylib.lib on Windows.

Object File

$ clx mylib.lua --object --output mylib

This produces mylib.o on Linux/macOS or mylib.obj on Windows.

All export luaopen_mylib. A host C++ program links against the static library:

cpphost.cpp
#include <clx.h> CLX_API clx::LValue luaopen_mylib(clx::LState* L); int main() { clx::LState* L = clx::open(); clx::openlibs(L); L->register_module("mylib", luaopen_mylib); clx::close(L); return 0; }

Combining All Approaches

$ clx main.lua utils.lua --modules native_processor --output app
luamain.lua
local utils = require("utils") local proc = require("native_processor") local extra = require("extra_plugin")

All AOT modules in this build are registered in clx's package.preload and loaded via the AOT require. This does not populate the embedded VM's package registry.

Options Reference

--modules <list> Comma-separated list of precompiled C++ modules --minimal Exclude non-essential modules (string, table, io, os, math, utf8, coroutine); keeps base + package --dynamic Link the embedded Lua 5.5 VM and enable load, loadfile, and dofile --static Compile to static library (exports luaopen_*) --object Compile to object file (exports luaopen_*)

Writing native modules

A native module is a C++ file that exports one luaopen_<name> function returning a table, then gets linked with --modules. The full worked example and the complete C++ API live in doc/modules.md and doc/api.md.

Lua 5.5 Compatibility

clx targets Lua 5.5 compatibility. The entire core language, control flow, tables, metatables, coroutines, and standard libraries are supported out of the box — the items below are grouped by area.

Core Language
Variables Arithmetic operators Logical operators Comparisons Functions Closures _ENV Varargs Multiple returns Local & global variables
Control Flow
if / elseif / else while repeat / until numeric for generic for break goto & labels
Tables
Table constructors Array part Hash part Mixed tables Table iteration
Metatables
__index / __newindex Arithmetic metamethods __len / __concat / __eq / __lt / __le __call / __tostring __ipairs / __pairs
Coroutines
create / resume / yield status / wrap
Standard Libraries
base math string table coroutine io os utf8 package debug

Every library above ships in the AOT runtime except debug, which is only available in the embedded VM via --dynamic.

Conditional & Unsupported in AOT

These run in the embedded Lua VM rather than the AOT-compiled binary, so they require --dynamic:

load()
Requires --dynamic — the current bridge accepts source strings, not reader functions.
loadfile()
Requires --dynamic — compiles a file in the embedded Lua VM.
dofile()
Requires --dynamic — loads and executes a file through the bridge.
require() of file modules
Requires --dynamic — loads package.path matches and executes them on the embedded VM; bundled and --modules modules always work.
string.dump()
Not provided by the clx AOT runtime; available in the embedded VM path.
debug library
Not provided as a clx AOT global; embedded VM behavior applies to dynamic code.