Skip to content

vm + cranelift: native map HOF dispatch (PR 2 of 4)#277

Merged
danieljohnmorris merged 5 commits into
mainfrom
fix/hof-map-native
May 14, 2026
Merged

vm + cranelift: native map HOF dispatch (PR 2 of 4)#277
danieljohnmorris merged 5 commits into
mainfrom
fix/hof-map-native

Conversation

@danieljohnmorris
Copy link
Copy Markdown
Collaborator

Summary

PR 2 of the HOF dispatch chain. PR 1 (#274) wired FnRef NaN-tagging so a function reference could survive a NanVal round-trip on VM and Cranelift. This PR puts that plumbing to work for the first HOF.

map fn xs now runs natively on every engine. The compiler emits a real bytecode loop using the OP_CALL_DYN opcode that PR 1 reserved, and Cranelift lowers OP_CALL_DYN through a new jit_call_dyn helper that re-enters the VM for user-fn callbacks and routes builtin callbacks through the tree-bridge.

Token-cost framing: the manifesto bet is that each engine should agree with the others on whatever ilo prints, so an agent never has to second-guess which engine its -- run-vm example ran under. engine-skip: vm lines and "tree-only" hedges in HOF examples were a tax on that promise. This PR cashes in the first slice of that for map.

Repro before / after

sq x:n>n;*x x
main xs:L n>L n;map sq xs

Before:

$ ilo file.ilo --run-vm main [1,2,3,4,5]
Compile error: undefined function: map   # the HOF arm errored out

$ ilo file.ilo --run-cranelift main [1,2,3,4,5]
Compile error: ...                       # same

After:

$ ilo file.ilo --run-tree main [1,2,3,4,5]
[1, 4, 9, 16, 25]
$ ilo file.ilo --run-vm main [1,2,3,4,5]
[1, 4, 9, 16, 25]
$ ilo file.ilo --run-cranelift main [1,2,3,4,5]
[1, 4, 9, 16, 25]

What's in the diff (per commit)

  • vm: thread-local active program + jit_call_dyn helper for OP_CALL_DYN — adds the runtime plumbing Cranelift needs. New ACTIVE_PROGRAM TLS slot published by with_active_registry (cleared by the same drop guard as the existing FUNC_NAMES / REGISTRY TLS). New jit_call_dyn(callee_bits, argc, regs_ptr) -> u64 extern "C" helper: Builtin FnRef → tree-bridge (same path as jit_call_builtin_tree), User FnRef → fresh VM::new(active_program).call(idx, args). The TLS-plus-helper combo is the minimum surface that lets Cranelift dispatch a FnRef whose target id is only known at runtime.

  • cranelift: lower OP_CALL_DYN through the jit_call_dyn helper — replaces the PR-1 placeholders in both Cranelift backends (compile_cranelift returned an error, jit_cranelift bailed JIT compilation with None). Mirrors the OP_CALL_BUILTIN_TREE pattern: spill arg slots into a sized stack slot and pass (callee_bits, argc, regs_ptr) to the helper. Both backends register the helper FuncId.

  • vm: native map HOF loop using OP_CALL_DYN(Builtin::Map, 2) arm in the bytecode compiler. Emits OP_LISTNEW for the accumulator, allocates contiguous result + arg scratch regs (so OP_CALL_DYN's R[A+1..=A+argc] layout is correct), drives the iteration with OP_FOREACHPREP / OP_FOREACHNEXT (the same pair @i xs uses), and grows the accumulator with OP_LISTAPPEND on the in-place RC=1 fast path. is_tree_bridge_eligible doc-comment loses map from the not-yet list. Also drops #[ignore] from vm_map_squares and vm_map_wrong_list_arg.

  • test + example: cross-engine coverage for native map HOF — six cross-engine tests in tests/regression_hof_map.rs covering user-fn / builtin / empty / single-element / chained / tail-position shapes. examples/map-fnref.ilo doubles as an agent-facing learning sample and runs through tests/examples_engines.rs on every engine.

  • example: lift vm/cranelift skip from window.ilosums>L n;map sum (window 3 [1,2,3,4,5]) now runs on every engine and no longer needs the skip annotation.

Test plan

  • cargo build --release --features cranelift clean
  • cargo test --release --features cranelift — all 4803 tests pass, 0 failures across 113 suites (includes the new regression_hof_map and the cross-engine examples_engines harness which exercises the updated window.ilo)
  • cargo clippy --release --features cranelift --all-targets -- -D warnings clean
  • cargo fmt --check clean
  • Manual cross-engine smoke: map sq [1,2,3,4,5] and map abs [-3,0,4,-7] return identical output on --run-tree, --run-vm, --run-cranelift

Follow-ups (PR 3, not in this diff)

  • flt, fld, grp, flatmap, uniqby, partition, 2-arg srt, dynamic-fmt wr — same native-loop pattern, each with its own emitter arm
  • The 23+ tests still gated with #[ignore] // Dynamic dispatch (OP_CALL_DYN emission) arrives in PR 2. for the other HOFs
  • 3-arg closure-bind map fn ctx xs (verifier accepts it; the emitter currently only handles 2-arg)
  • Value::Text-as-callable dispatch (the vm_map_with_text_fn_name shape) — needs OP_CALL_DYN to accept a Text fallback alongside FnRef

Adds the runtime plumbing Cranelift needs to lower OP_CALL_DYN. The
JIT has no re-entrant function-pointer table for dynamic dispatch, so
the helper resolves a FnRef NanVal at runtime and:

  - for a Builtin FnRef, routes through the tree-bridge (the same
    path jit_call_builtin_tree uses) with program-aware arg
    conversion so FnRef args render with their source names
  - for a User FnRef, re-enters the bytecode VM on the same program
    via VM::new(active_program).call(idx, args)

The active program is published through a new ACTIVE_PROGRAM TLS slot
set inside with_active_registry, paired with the existing
ACTIVE_FUNC_NAMES and cleared by the same drop guard. PR 1 wired the
FnRef NaN-tagging side of this; the helper lets Cranelift consume it.
Replaces the PR-1 placeholders (compile_cranelift returned an error,
jit_cranelift bailed JIT compilation with None) with a real lowering.
Mirrors the OP_CALL_BUILTIN_TREE pattern: spill the contiguous arg
slots into a sized stack slot and pass (callee_bits, argc, regs_ptr)
to jit_call_dyn. Both the JIT and AOT codegen paths get the new
helper FuncId registered in their helpers struct.

The lowering treats every FnRef uniformly. Per-builtin specialisation
(inline numeric ops, etc.) is a perf follow-up; correctness across
both kinds first.
Compiles `map fn xs` (2-arg form) to a real bytecode loop instead of
falling through to the "unsupported HOF" error path. The emitter:

  - compiles `fn` to a register, where Expr::Ref of a user or builtin
    name already routes to OP_LOADFN (PR 1), so the FnRef NanVal
    lives in a register with no heap traffic
  - compiles `xs`, allocates an acc list (OP_LISTNEW 0) and index +
    item + result + arg scratch slots, with arg adjacent to result
    so OP_CALL_DYN's R[A+1..=A+argc] arg layout is correct
  - drives the iteration with OP_FOREACHPREP / OP_FOREACHNEXT (the
    same pair `@i xs` uses), invokes the callee via OP_CALL_DYN, and
    grows acc with OP_LISTAPPEND on the in-place RC=1 fast path

is_tree_bridge_eligible's doc-comment loses `map` from the not-yet
list; the rest of the HOFs are still parked for PR 3. Also drops
the `#[ignore]` from vm_map_squares and vm_map_wrong_list_arg, which
pinned the absence of this code path.
Adds six cross-engine regression tests covering the shapes that were
previously gated with `engine-skip: vm / cranelift`:

  - user-function callback (`map sq xs`)
  - builtin callback (`map abs xs`) — exercises the Cranelift
    helper's tree-bridge dispatch arm
  - empty list (early-exit before the first OP_CALL_DYN fires)
  - single-element list (one trip through the loop body)
  - chained map (two HOF calls in series)
  - tail-position map (result is the function's return value)

Each test runs the same source against `--run-tree`, `--run-vm`, and
`--run-cranelift` and asserts identical output. examples/map-fnref.ilo
gives agents a learning sample and doubles as an engine-harness
regression via tests/examples_engines.rs.

Also drops the `#[ignore]` from `vm_map_squares` and
`vm_map_wrong_list_arg`, which pinned the absence of OP_CALL_DYN
emission. PR-3 will lift the remaining ignores for fld/flt/grp etc.
`sums>L n;map sum (window 3 [1,2,3,4,5])` now runs on every engine:
`map` is natively dispatched (this PR) and `sum` is a pure builtin
promoted to `F n n` by the verifier, which the Cranelift OP_CALL_DYN
helper routes through the tree-bridge.

The other window.ilo entries (basic/pairs/singles/toobig) only exercise
the already-native `window` builtin; the engine-skip annotations
existed solely to keep `sums` from breaking the cross-engine harness.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 14, 2026

Codecov Report

❌ Patch coverage is 77.50000% with 27 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/vm/compile_cranelift.rs 5.26% 18 Missing ⚠️
src/vm/mod.rs 90.12% 8 Missing ⚠️
src/vm/jit_cranelift.rs 95.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@danieljohnmorris danieljohnmorris merged commit 81c715c into main May 14, 2026
4 of 5 checks passed
@danieljohnmorris danieljohnmorris deleted the fix/hof-map-native branch May 14, 2026 20:38
danieljohnmorris added a commit that referenced this pull request May 15, 2026
- slc / take / drop accept negative indices counting from end (bounds
  clamp), matching at xs i. Closes the quant-trader fencepost and the
  slc xs -np 1 np ergonomics gap (#266).
- Map keys are typed: text or integer. mset m 7 v and mget m 7 work
  directly, no str conversion. Int(1) and Text("1") are distinct.
  Float keys floor to i64; jdmp stringifies numeric keys for JSON (#267).
- Add map / flt / fld to the builtin reference. All HOFs (map, flt,
  fld, srt, grp, uniqby, partition, flatmap) now work cross-engine
  on tree, VM, Cranelift JIT, and AOT (#274 #277 #278 #279 #280 #283).
- New Inline lambdas subsection: Phase 1 literals are cross-engine,
  Phase 2 closure capture is tree-only with automatic fallthrough
  surfacing ILO-R012 on VM and Cranelift (#265 #284).
- AOT-compiled binaries from ilo compile now strip the top-level
  ~/^ wrapper byte-for-byte the same as in-process runners (#281).
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