Skip to content

BEAM-native JS engine and compiler#5

Open
dannote wants to merge 389 commits intomasterfrom
beam-vm-interpreter
Open

BEAM-native JS engine and compiler#5
dannote wants to merge 389 commits intomasterfrom
beam-vm-interpreter

Conversation

@dannote
Copy link
Copy Markdown
Member

@dannote dannote commented Apr 15, 2026

Adds a second QuickJS execution backend on the BEAM.

What’s in here

  • QuickJS bytecode decoder in Elixir
  • interpreter for QuickJS bytecode on the BEAM
  • hybrid compiler from QuickJS bytecode to BEAM modules
  • raw BEAM disassembly for the :beam backend via QuickBEAM.disasm/2
  • mode: :beam support in the public API
  • require(), module loading, dynamic import, globals, handlers, and interop for the VM path
  • stack traces, source positions, and Error.captureStackTrace

Runtime coverage

  • Object, Array, Function, String, Number, Boolean
  • Math, JSON, Date, RegExp
  • Map, Set, WeakMap, WeakSet, Symbol
  • Promise, async/await, generators, async generators
  • Proxy, Reflect
  • TypedArray, ArrayBuffer, BigInt
  • classes, inheritance, super, private fields, private methods, private accessors, static private members, brand checks

Validation

  • QUICKBEAM_BUILD=1 MIX_ENV=test mix test
  • MIX_ENV=test QUICKBEAM_BUILD=1 mix test test/vm/js_engine_test.exs --include js_engine --seed 0
  • mix compile --warnings-as-errors
  • mix format --check-formatted
  • mix credo --strict
  • mix dialyzer
  • mix ex_dna
  • zlint lib/quickbeam/*.zig lib/quickbeam/napi/*.zig
  • bunx oxlint -c oxlint.json --type-aware --type-check priv/ts/
  • bunx jscpd lib/quickbeam/*.zig priv/ts/*.ts --min-tokens 50 --threshold 0

Current local result:

  • 2363 tests, 0 failures, 1 skipped, 54 excluded

@dannote dannote force-pushed the beam-vm-interpreter branch from 0eb3475 to 7c1c574 Compare April 15, 2026 14:06
@dannote dannote changed the title BEAM-native JS interpreter (Phase 0-1) BEAM-native JS interpreter Apr 16, 2026
@dannote dannote marked this pull request as ready for review April 16, 2026 08:41
dannote added 20 commits April 19, 2026 09:24
…boilerplate

Converted 8 modules (163 macro calls replacing 94+ boilerplate clauses):

  Array:    31 proto + 3 static macros (was 31 + 3 hand-written)
  String:   29 proto + 2 static macros
  Number:   5 proto + 5 static + 7 static_val macros
  Date:     27 proto macros
  Object:   14 static macros
  RegExp:   3 proto macros
  Math:     js_object with 25 method + 11 val entries
  Console:  js_object with 5 method entries

Before: def proto_property("push"), do: {:builtin, "push", fn args, this -> push(this, args) end}
After:  proto "push" do push(this, args) end

Catch-all fallbacks (proto_property(_) -> :undefined) auto-generated
by @before_compile — removed from all 8 files.

733 tests pass in both modes, 0 warnings.
New focused modules:
- boolean.ex (16 lines): proto toString/valueOf + constructor
- symbol.ex (55 lines): constructor + statics (well-known symbols)
- promise.ex (121 lines): constructor + statics (resolve/reject/
  all/allSettled/any/race) with extracted helper functions

Promise refactored:
- promise_all/all_settled/any/race extracted to named functions
  (were 200+ lines of inline closures in promise_statics map)
- Uses Heap.to_list instead of copy-paste coerce-to-list blocks

Builtins.ex (81 lines) now contains ONLY simple constructors:
object, array, string, number, function, bigint, error, regexp.

Removed dead code: date_constructor, date_static_property were
duplicates of Date module code.

733 tests pass in both modes, 0 warnings.
…es, map_set, symbol

Date statics: extracted inline lambdas into now_static/2, parse_static/2,
utc_static/2. Removed dead static_now/0.

Promise statics: extracted all 6 inline lambdas into builtin_resolve/2,
builtin_reject/2, builtin_all/2, etc. Multi-clause resolve fn → single
defp with pattern match.

Prototypes: extracted all 9 map_proto, 7 set_proto, and 3
function_proto_property inline lambdas into named functions
(map_get/2, map_set/2, set_has/2, fn_call/3, fn_apply/3, fn_bind/3).

MapSet: extracted 12 set_*_fn lambda bodies into do_set_*/1-2 functions.

Symbol: extracted for/keyFor lambdas into do_symbol_for/1, do_symbol_key_for/1.

Remaining 20 {:builtin} instances are irreducible closures capturing
variables (set_ref, fun) — cannot use & captures.

733 tests pass in both modes, 0 warnings.
Moved Heap.Keys from the top of heap.ex to heap/keys.ex.
Elixir convention: one module per file. The two-modules-in-one-file
hack was there because nested modules can't be imported by their
parent during compilation — keeping them as separate files with
import works correctly.

733 tests pass, 0 warnings.
New macros in Builtin module:
  build_methods do ... end  → returns %{"name" => {:builtin, ...}, ...}
  build_statics do ... end  → returns [{"name", {:builtin, ...}}, ...]

Both use the same method/val entries as js_object. All 5 builtin
definition contexts now share the same vocabulary:

  1. proto "name" do end       → def proto_property("name")
  2. static "name" do end      → def static_property("name")
  3. js_object "X" do end      → def object()
  4. build_methods do end        → inline %{} map
  5. build_statics do end        → inline [{}, ...] list

Converted:
- Date.statics: build_statics with 3 methods (was raw {:builtin} list)
- Promise.statics: build_methods with 6 methods (was raw {:builtin} map)
- Symbol.statics: build_methods with 11 vals + 2 methods
- MapSet.build_set_object: build_methods with 15 methods + 2 vals
  (eliminated 14 set_*_fn wrapper functions)
- JSON.object: converted to js_object macro

Remaining 37 {:builtin} instances are all irreducible closures
capturing instance state (ta_ref, set_ref, fun, pos_ref) or
clean &fn/2 captures.

733 tests pass in both modes, 0 warnings.
Date, Promise, Symbol now use module-level static macros instead of
returning statics data from a statics() function:

  # Before (data approach):
  def statics do
    [{"now", {:builtin, "now", &now_static/2}}, ...]
  end

  # After (uniform macro approach):
  static "now" do System.system_time(:millisecond) end
  static "parse" do parse_date_string(to_string(hd(args))) end

register_builtin now accepts module: option. get_own_property checks
the module's static_property/1 for statics lookup instead of PD.

Symbol: 13 static_val + 2 static macros replace statics() map.
Promise: 6 static macros replace statics() map + 6 builtin_* wrappers.
Date: 3 static macros replace statics() list + 3 *_static wrappers.

Removed build_statics macro (no longer needed). build_methods kept
for Set instance objects (per-instance closures).

Every module now follows the same convention:
  proto "name" do body end    — instance methods
  static "name" do body end   — static methods
  static_val "name", value    — static constants
  js_object "name" do end     — named objects (Math, Console, JSON)
  build_methods do end          — per-instance method maps (Set)

733 tests pass in both modes, 0 warnings.
Parsing:
- DateTime.from_iso8601 for full ISO strings
- Date.from_iso8601 for YYYY-MM-DD
- Integer.parse + String.split for partial dates (YYYY, YYYY-MM)
- No Regex anywhere

UTC:
- :calendar.datetime_to_gregorian_seconds for direct conversion
- @epoch_gregorian_seconds constant (62_167_219_200)

Getters: all use with_dt/2 helper → DateTime struct fields
Setters: all use set_field/3 → NaiveDateTime.new + DateTime.from_naive
Formatting: all use Calendar.strftime
Helpers: to_dt, with_dt, with_ms, fmt, put_ms, gregorian_to_ms

733 tests pass in both modes, 0 warnings.
Inlined 6 private functions directly into static macro bodies:
- freeze: 2-line case → inline
- hasOwn: 4-line case → inline
- getPrototypeOf: 2-line case → inline
- create: 3-line case → inline
- is: cond with NaN/neg-zero handling → inline
- setPrototypeOf: 3-clause case → inline

These were all ≤5 line single-use functions where the private
function name added no clarity over the inline body.

Array/String/Date functions kept as named private functions —
they have multi-clause pattern matching (obj vs list vs fallback)
and are 10-30 lines each. Delegation is appropriate there.

733 tests pass in both modes, 0 warnings.
Replaced 9 fully-qualified QuickBEAM.BeamVM.X.y references with
short alias forms in files that already have the alias:
- runtime.ex: Heap.get_ctx, Bytecode.decode, etc (4)
- number.ex: Values.to_js_string (2)
- array.ex: Runtime.global_bindings (1)
- json.ex: Heap.make_error, Runtime.invoke_getter (2)

Removed dead js_is/2 function from object.ex.

Remaining 66 FQ refs are in files without the relevant alias —
adding aliases would risk circular dependencies (e.g. Interpreter
inside interpreter/ submodules).

733 tests pass in both modes, 0 warnings.
Reflect: extracted from inline 30-line map in build_global_bindings
to runtime/reflect.ex using js_object macro.

Error types: replaced 7 copy-paste register_builtin calls with
for loop over @error_types list.

TypedArray (603→462 lines):
- build_methods macro for 15 method declarations
- Extracted all inline {:builtin} closures into named ta_* functions
- Shared helpers: ta/1, ta_buf/1, ta_len/1, ta_type/1, cb_call/2,
  truthy?/1, to_idx/1
- parse_ta_args extracts constructor arg parsing

Runtime (639→578 lines):
- Reflect extracted to module
- require uses Heap.make_error
- Proxy simplified (2-arity)

Prototypes: left as-is — {:builtin, "name", &fn/2} dispatch pattern
is already clean and can't be improved with module attributes.

733 tests pass in both modes, 0 warnings.
- Remove Interpreter.Dispatch (8-line defdelegate wrapper → call Builtin directly)
- Runtime: js_truthy → truthy?, js_strict_eq → strict_equal?, js_to_string → stringify
- Runtime: js_string_length → string_length, obj_new → new_object
- Runtime: call_builtin_callback → call_callback, extract_regexp_flags → regexp_flags
- Objects: get_array_el → get_element, put_array_el → put_element, list_set_at → set_list_at
- Fix ok/3 test helper that silently rebinded instead of matching (tag 6 pre-existing failures as pending_beam)
- Runtime.truthy? was a buggy reimplementation missing +0.0 and {:bigint,0}
  cases; now delegates to Values.truthy? which handles all edge cases
- Remove vestigial interp parameter from call_callback and ~30 call sites
  across array.ex, prototypes.ex, map_set.ex, objects.ex, interpreter.ex
- Make internal functions private: numeric_add, inf_or_nan, abstract_eq,
  ensure_vref_size, track_alloc
- Add @moduledoc false to Objects, Scope, Values
- Remove dead microtask_queue_empty?
- Fix moduledoc: {:ref, ref} → {:obj, ref}, {:function, ...} → %Bytecode.Function{},
  add symbol and bigint types, remove non-existent {:array, ...} tag
- Move @moduledoc before @compile per Elixir convention
- Extract throw_or_catch/4 to deduplicate 4 identical catch_stack dispatch blocks
- Replace __MODULE__.new_object() with new_object() in runtime.ex
- Extract dispatch_call/5 from 4 identical call/tail_call dispatch blocks
- Use throw_or_catch in catch_js_throw handler (was inlined)
- Extract throw_null_property_error for get_field/get_field2 null checks
- Extract sort_numeric_keys into Runtime (shared by interpreter + Object)
- Extract unwrap_value in Runtime.Promise (was copy-pasted 3x)
- Extract div_numbers from duplicated div cond block in Values
- Reuse make_list_iterator in for_await_of_start (was inlined copy)
- Extract set_private_field for put/define_private_field opcodes
Prototypes was a grab-bag of Map, Set, and Function prototype logic.
Map/Set prototypes belong with their constructors in MapSet (241→408 lines).
Prototypes now contains only Function.prototype (255→91 lines).
…e decode

Real bugs fixed:
- TypedArray.from called 2-arity constructor with 1 arg (runtime.ex)
- Set operations called 2-arity set_constructor with 1 arg (map_set.ex)
- Dead is_integer guard on key that's always binary (runtime.ex)
- Decoder.decode spec claimed tuple return, actually returns list
- Bytecode.decode used unreachable || fallback and false pattern
- Add @type t to Bytecode.Function struct

8 remaining warnings are false positives (defensive error handling
in try/catch blocks that dialyzer can't prove reachable).
Property resolution (get → get_own → get_from_prototype) is now in its
own module. All callers updated to use Property directly — no delegates.
@dannote dannote force-pushed the beam-vm-interpreter branch from a3ae334 to 7ee5139 Compare April 19, 2026 08:59
dannote added 6 commits April 19, 2026 12:09
…prototype

- Move build_global_bindings from Runtime into Globals.build/0
- Absorb all Builtins constructors into Globals as named private functions
- Move Object.prototype setup to Object.build_prototype/0
- Convert inline lambdas (eval, require, Proxy, queueMicrotask) to named fns
- Unify constructor registration through Globals.register/3
- Delete Builtins module (was a junk drawer only called from one place)
- Runtime is now 84 lines of pure helpers (was 592)
- Remove dead a != a NaN detection in Object.is (Elixir != is not JS !==)
- Replace length(list) > 0 guards with [_ | _] = list pattern match
- Replace Enum.map |> Enum.join with Enum.map_join across 4 files
- Sort alias blocks alphabetically across 17 files
- Fix trailing whitespace and consecutive blank lines
- Convert single-branch cond to if in Globals.parse_int
- Simplify identity with in LEB128.read_i32

Credo: 8W→0W (lib), 69F→60F, 48R→39R in beam_vm/
Gas budget was hardcoded as 1B in 12 call sites. Now:
- Context struct carries the configured gas from eval opts
- All internal callbacks (call_callback, invoke_with_receiver, etc.)
  read gas from the active context via Runtime.gas_budget/0
- Single @default_gas constant in Context (1B)
- Callers can configure via QuickBEAM.eval(rt, code, mode: :beam, gas: n)
dannote added 30 commits April 22, 2026 15:36
…undled version)

Reproducer: quickjs-ng-test/repro2.c — crashes with priv/c_src/quickjs.c
but passes with latest upstream quickjs-ng. The fix is to update the
bundled QuickJS-NG source.
- Regenerate predefined_atoms.ex from new quickjs-atom.h (rawJSON added)
- Update BC_VERSION 24→25, js_atom_end 229→230
- Fix use-after-free in JS_GetCoverage (stack buffer used after scope)
- Adjust max_stack_size test thresholds for new QuickJS frame sizes
- Adjust reduction limit test for new interrupt counter frequency
Like QuickJS's js_trigger_gc, but at function return instead of
allocation time (BEAM stack frames are opaque to the mark phase).

Uses invoke depth tracking to only GC at the outermost call level,
where the result + original args are the complete root set.

Keeps process dictionary at ~115 entries instead of growing unbounded,
which makes all Process.get/put operations dramatically faster.

Preact SSR: VM.Interpreter 140 ips, VM.Compiler 131 ips (3.4× faster than NIF).
…uple building

Heap.wrap for 13-field VNode: 2555ns → 1291ns (2× faster).

Key insight: for Erlang flatmaps (≤32 keys), Map.keys returns sorted
keys and Map.values returns values in corresponding order. This matches
the shape's offset order, so we can build the values tuple with a single
:erlang.list_to_tuple(:maps.values(map)) instead of per-key lookups.

Shape resolution is cached by {map_size, phash2(keys)}.

Preact SSR: Compiler 141 ips (1.27× faster than interpreter, 2.9× faster than NIF).
Stack: dup1, dup3, nip, nip1, insert4, swap2, rot3l/r, rot4l, rot5l, perm4/5
Private: get/put/define_private_field, check_brand, private_in
Class: set_proto, get/put_super_value, check_ctor_return, init_ctor, define_class_computed
Refs: make_loc_ref, make_arg_ref, make_var_ref, make_var_ref_ref, get/put_ref_value
Misc: rest, throw_error, push_bigint_i32, delete_var

Remaining unimplemented (by design):
- async/generators (6) — need coroutine support
- async iterators (5) — need coroutine support
- eval/with/import (10) — can't compile statically
- gosub/ret (2) — finally blocks
- catch (1) — try/catch flow control
- invalid (1) — placeholder opcode

Compiler now handles 214 of 246 opcodes (87%).
Generators use throw/catch as cooperative coroutine mechanism:
- yield throws {:generator_yield, value, continuation}
- continuation is a fun(arg) that resumes at the next block
- GeneratorIterator wraps the protocol (next/return)

Async functions:
- await synchronously resolves promises (BEAM VM has no event loop)
- return_async wraps return value in promise

Also adds gosub/ret (finally blocks), catch opcode support,
and CFG/stack/type analysis for all generator/async opcodes.

Compiler now handles 226 of 246 opcodes (92%).
Remaining: eval/with (10), import (1), invalid (1) — all uncompilable by design.
eval/apply call Invocation.invoke_runtime (compiler-first, not
interpreter-only). with_* opcodes delegate to Get/Put/Delete directly.
import creates a rejected promise stub. Async iterator ops delegate
to runtime callbacks.

Compiler now handles 245 of 246 opcodes (99.6%).
The invocation_ctx clause for %{this: this_obj} matched before the more
specific %{this: this_obj, new_target: new_target}, silently dropping
new_target from constructor contexts. Reorder clauses so the two-field
pattern matches first.

Also fix init_ctor to propagate new_target through Runner.invoke_constructor
and add apply_super/update_this runtime helpers for super(...args) calls.
- Group wrap/1 clauses together (compiler warning)
- Remove unused eligible?/1 from Shapes (compiler warning)
- Remove semicolon statement separator (Credo)
- Remove consecutive blank lines (Credo)
- Break long line in ops.ex (Credo)
- Remove unnecessary alias braces (Credo)
- Remove dead code: var/1 integer/atom clauses (Dialyzer)
- Remove dead code: current_atoms/trace_enabled fallback clauses (Dialyzer)
- Extract throw_error_message/2 shared between RuntimeHelpers and Interpreter
- Extract shape_put/6 to deduplicate shape property write logic in Put
- Extract invoke_closure/4 to deduplicate compiled closure dispatch in Invocation
- Add proper aliases: Bytecode, Runner, Interpreter, Promise, GeneratorIterator
- Replace full module paths with aliases throughout compiler modules
- Reorder aliases alphabetically (Credo)
Change shape representation from {:shape, shape_id, vals, proto} to
{:shape, shape_id, offsets, vals, proto}. The offsets map is inlined
from the shape table, eliminating the Process.get + Map.fetch! chain
on every property read.

Get.get shape hit: 775ns → 648ns (16% faster).
~380us saved per render at 3000 property reads.
…s.lookup

fprof showed Shapes.get_shape (54K calls, 108ms) as the #1 bottleneck.
It was still called from Put.put via Shapes.lookup on the hot path.

Replace Shapes.lookup(shape_id, key) with Map.fetch(offsets, key)
in shape_put, Store.put_obj_key, and put/3 for length.

Get.get: 648ns → 406ns (37% faster)
Preact render: 6.95ms → 6.55ms (5.8% faster)
…calls

transition/2 now returns {shape_id, offsets, offset} instead of
{shape_id, offset}. Callers no longer need a separate get_shape
call to fetch the new shape's offsets after transition.

Eliminates ~13K redundant shape table lookups per render.
Transition cache now stores {child_id, child_offsets} instead of
just child_id. Eliminates get_shape(child_id) on every cache hit.

get_shape calls per render: 27,930 → 14,819 (verified via fprof).
Preact render: 6.55ms → 6.2ms.
The grow path (adding a property to a shape-backed object) was doing:
Tuple.to_list → ++ List.duplicate → ++ [val] → List.to_tuple (1693ns)

Now uses :erlang.append_element for the common case where offset ==
tuple_size (sequential property addition): 217ns — 8× faster.

Preact render: 6.1ms → 5.4ms (12% faster).
Heap.frozen? now checks a global :qb_has_frozen flag before doing
per-object Process.get. In Preact SSR (where nothing is frozen),
this eliminates 13,552 process dictionary lookups per render.

Preact render: 5.37ms → 5.25ms.
Detect 'object; (push_val; define_field)* ...' patterns during lowering
and batch them into a single Heap.wrap(%{k1 => v1, k2 => v2, ...}).

Eliminates ~10K individual Put.put calls per Preact render (881 VNodes
× ~12 batched fields each). Each Put.put was doing: Process.get + shape
transition + put_val + Process.put.

Supported value opcodes: integer literals, null, undefined, booleans,
get_arg, get_loc, push_atom_value, empty string.
Falls back to individual define_field for values that can't be lowered
at compile time (function calls, computed values).

Preact render: 5.25ms → 4.4ms (16% faster).
Preact's h() function has 5 args, called 881 times per render.
The generic Enum.take path was 14.5ms (8,504 calls). Direct
pattern matching eliminates the list traversal overhead.
Replaces Enum.with_index + Enum.reduce with direct :maps.from_list
for shape-to-map reconstruction.
Replace make_ref() + {:qb_obj, ref} tuple keys with monotonic integer
counter + raw integer keys in the process dictionary.

From the OTP JIT source (erl_process_dict.c), the hash function for
small integers is just 'unsigned_val(Term)' — essentially free. Tuple
keys go through the full erts_internal_hash which is much more
expensive. EQ comparison is also a single pointer compare for integers
vs deep tuple comparison for {:qb_obj, ref}.

Measured: Process.put with raw integer keys is 2× faster than tuple
keys. Process.get is 1.35× faster.

Preact render: 4.3ms → 3.55ms (17% faster).
Total session: 6.95ms → 3.55ms (49% faster).
- Replace List.zip with manual keys_vals_to_map recursion (1.6× faster)
- Replace PD counter with :erlang.unique_integer (2.6× faster)

Preact render: 3.55ms → 3.4ms.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant