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.
Features
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:
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
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:
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):
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) |
| generic | — | — | no 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
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:
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:
Compile Your First Program
Create hello.lua:
Compile and run:
Your Second Program
Let's try something more interesting:
Compile it with --fast flag for better performances:
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
Control Flow
Functions
Tables and Metatables
Coroutines
String Module
Bitwise Operations
Performance Tips
Use local variables, prefer numeric for loops, and avoid mixing types for optimal performance.
Common Issues
Debugging Compilation Errors
If you get a C++ compilation error, you can see the generated code with --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:
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.
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:
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:
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:
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
Options starting with - that are not recognized by clx are automatically passed through to the C++ compiler.
Build Mode
Output Options
Compilation Options
Choosing speed vs. size
The two common build modes boil down to a simple tradeoff:
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):
Give the output a custom name:
Build for the fastest execution (or use --size, the default, for the smallest binary):
Build a debuggable program so you can step through the Lua source in a debugger:
Write out the generated C++ without compiling (useful when debugging clx itself):
Pass your own compiler flags (this replaces clx's default flags):
Produce an object file or a static library instead of an executable:
Environment Variables
clx respects these environment variables:
Exit Codes
Build with CMake
If building from source:
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:
Inside main.lua, require them by name (filename without .lua):
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:
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:
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:
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:
The function must use CLX_API (which provides extern linkage and proper symbol visibility):
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
Compile it to an object file with your C++ compiler:
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/:
If your module depends on external libraries, pass link flags directly:
Compiling Lua to Libraries
Static Library
This produces libmylib.a on Linux/macOS or mylib.lib on Windows.
Object File
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:
Combining All Approaches
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
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.
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: