JIT Compilation

AtomVM includes an optional JIT (Just-In-Time) compiler that translates BEAM bytecodes into native machine code for significantly faster execution.

Overview

The JIT compiler works at module load time: when a module is loaded, its BEAM bytecodes are compiled to native code for the target architecture. The compiled native code is stored alongside the BEAM bytecodes and used for execution.

Modules can also be ahead-of-time compiled: the native code is generated at build time and embedded into the .beam file as a avmN chunk. At load time, the precompiled native code is used directly without any compilation step.

Supported architectures

The JIT compiler supports the following target architectures:

Name

Description and example devices

x86_64

64-bit x86 (Linux, macOS, FreeBSD)

aarch64

64-bit ARM (Linux, macOS)

arm32

32-bit ARM (Linux)

armv6m

ARM Cortex-M0+ (Raspberry Pi Pico, STM32 with Cortex-M0/M0+)

armv6m+thumb2

ARM Cortex-M3+ with Thumb-2 support, ARMv7-M or later (Raspberry Pi Pico 2, STM32 with Cortex-M3/M4/M7/M33)

riscv32

32-bit RISC-V (ESP32Cx, ESP32Hx, ESP32P4)

riscv64

64-bit RISC-V (Linux)

wasm32

WebAssembly (nodeJS, browsers)

Requirements

  • Erlang/OTP 28 or later is required to run the JIT compiler at load time (ahead-of-time compiled modules can be executed with OTP 26+).

Building with JIT support

Generic UNIX

To enable JIT compilation, pass -DAVM_DISABLE_JIT=OFF to CMake:

$ mkdir build
$ cd build
$ cmake -DAVM_DISABLE_JIT=OFF ..
$ make -j 8

The target architecture is auto-detected based on the host platform. To cross-compile or override, use -DAVM_JIT_TARGET_ARCH=<arch>.

DWARF debug information

DWARF debug information support can be enabled to allow debugging JIT-compiled code with LLDB or GDB. It is disabled by default and can be enabled with:

$ cmake -DAVM_DISABLE_JIT=OFF -DAVM_DISABLE_JIT_DWARF=OFF ..

When enabled, each precompiled module includes an ELF object with DWARF debug sections containing:

  • Function symbols (module:function/arity)

  • BEAM opcode location symbols

  • Label symbols

  • Source file and line number mappings

  • Context structure type information for inspecting VM registers

  • Named variable locations (when compiled with beam_debug_info, OTP 28+)

DWARF debug support

Debugging with LLDB

When DWARF support is enabled and plugin.jit-loader.gdb.enable is turned on, LLDB can set breakpoints on JIT-compiled Erlang functions by name, inspect VM registers, and show backtraces through JIT code.

Setting breakpoints

$ lldb -- tests/test-erlang add
(lldb) settings set plugin.jit-loader.gdb.enable on
(lldb) breakpoint set -n 'add:add/2'
(lldb) run

When the breakpoint is hit, LLDB shows the function name and the Context pointer:

Process stopped
* thread #1, stop reason = breakpoint 1.1
    frame #0: 0x00000001002440fa JIT(0x...)`add:add/2(ctx=0x00007f...)

Inspecting VM registers

The DWARF debug information includes location tracking for Erlang x registers. Use frame variable to see the VM state:

(lldb) frame variable
(Context *) ctx = 0x00007fc066804280
(unsigned long) x[0] = 143

The x register values are displayed as raw tagged terms. For small integers, the value is (term >> 4), so x[0] = 143 means the integer 8 (since 143 = 8 << 4 | 0xf).

When the JIT compiler has cached an x register in a native CPU register, the debugger reads it directly from the CPU register instead of memory, this is tracked automatically through DWARF location lists.

Named variable inspection

When an Erlang module is compiled with the beam_debug_info option (OTP 28+), the compiler emits debug_line opcodes that carry variable-to-register mappings. The JIT DWARF backend uses these to generate named variable entries, so the debugger can display Erlang variable names instead of raw register indices.

To enable this, add beam_debug_info as a compile attribute in your module:

-compile([beam_debug_info]).

Or pass it as a compiler flag:

$ erlc +beam_debug_info my_module.erl

Warning

The beam_debug_info option disables several compiler optimizations (constant folding, binary match optimization, etc.) to preserve variable-to-register mappings. Use it only for modules you intend to debug, not for production builds.

With beam_debug_info enabled, the debugger shows named variables:

$ lldb -- ./AtomVM my_module.avm
(lldb) settings set plugin.jit-loader.gdb.enable on
(lldb) breakpoint set -f my_module.erl -l 10
(lldb) run
Process stopped
* thread #1, stop reason = breakpoint 1.1
    frame #0: JIT`my_module:my_fun/1(ctx=0x...) at my_module.erl:10
(lldb) frame variable N M
(unsigned long) N = 687
(unsigned long) M = 703

The variables are displayed as raw tagged terms, just like x registers. Use term_to_int() to convert small integers:

(lldb) print term_to_int(N)
(avm_int_t) 42
(lldb) print term_to_int(M)
(avm_int_t) 43

The variable locations are tracked through DWARF location lists, so the debugger shows the correct value at each point in the function. A variable that moves from one register to another (or goes out of scope) is handled automatically.

Backtraces

(lldb) bt
* frame #0: add:add/2(ctx=0x...)
  frame #1: scheduler_entry_point at opcodesswitch.h
  frame #2: context_execute_loop at opcodesswitch.h
  frame #3: main at test.c

Source line mapping

If the Erlang source was compiled with debug information and the BEAM Line chunk is present, the debugger maps JIT code addresses to source file and line numbers. You can set breakpoints by file and line:

(lldb) breakpoint set -f my_module.erl -l 15

Note

Upstream LLVM LLDB 19 and Apple LLDB versions earlier than lldb-2100 (shipped in Xcode 26.4 and later) hang when resolving pending source-line breakpoints against JIT-emitted DWARF.

Workarounds without changing lldb:

  • Set breakpoints by symbol name (breakpoint set -n 'mod:fun/N') instead of file/line

  • Defer source-line breakpoints until after the relevant module has been JIT-registered (e.g. break first on a symbol, then add the source-line breakpoint, then continue).

For a fresh lldb:

  • On macOS 26, the LLDB shipped with the CommandLineTools is already at lldb-2100 (independent of the active Xcode), so a quick fix is to invoke it directly:

$ /Library/Developer/CommandLineTools/usr/bin/lldb -- ...

Alternatively, switch the active Xcode to 26.4 or later (sudo xcode-select -s /Applications/Xcode_26.4.app).

  • On any platform, install upstream LLDB 20 or later with sudo port install lldb-20 from MacPorts, brew install llvm (Homebrew), or build from the LLVM project source. A self-signed lldb binary on macOS needs a debugger entitlement.

lldb --version reports the version: lldb-1703.x and earlier are affected; lldb-2100.x and upstream LLDB 20+ are fixed.

### Disassembling precompiled modules

Precompiled `.beam` files contain an ELF object with the native code and symbol table. You can extract and disassemble it to inspect the generated code.

When DWARF is enabled, the ELF is embedded in the `avmN` chunk of the `.beam` file. At runtime, `jit_dwarf:elf/2` produces the ELF binary that can be written to a file for offline analysis:

```erlang
{ok, _TextOffset, ElfBinary} = jit_dwarf:elf(DwarfState, NativeCode),
file:write_file("module.elf", ElfBinary).

The resulting ELF file can be disassembled with standard tools:

$ objdump -d module.elf

This will show the disassembly with function names like module:function/arity, making it easy to correlate the generated machine code with the original Erlang source.

Extracting ELF from a precompiled .beam file

The ELF object is stored in the avmN chunk of the .beam file, after a small header. You can extract it from the Erlang shell using beam_lib:

{ok, {_, [{_, ChunkData}]}} = beam_lib:chunks("module.beam", ["avmN"]),
<<InfoSize:32/big, _Info:InfoSize/binary, ElfData/binary>> = ChunkData,
<<16#7f, "ELF", _/binary>> = ElfData,  % verify ELF magic
file:write_file("module.elf", ElfData).

Then disassemble with symbols:

# x86_64
$ objdump -d module.elf

# aarch64
$ aarch64-elf-objdump -d module.elf

# arm32
$ arm-elf-objdump -d module.elf

# armv6m
$ arm-elf-objdump -d --disassembler-options=force-thumb module.elf

# riscv32
$ riscv32-elf-objdump -d module.elf

# riscv64
$ riscv64-elf-objdump -d module.elf

CMake options reference

Option

Default

Description

AVM_DISABLE_JIT

ON

Disable JIT compilation

AVM_DISABLE_JIT_DWARF

ON

Disable DWARF debug information in JIT

AVM_JIT_TARGET_ARCH

auto-detected

Target architecture (x86_64, aarch64, arm32, armv6m, armv6m+thumb2, riscv32, riscv64)