The PASS lines in this chapter are the Stage 0 result: add/2 on this Mac and on Ubuntu, plus one Apple GPU call. Dirty scheduling, resources, and a 4-wide GPU batch were measured later. Those rows are stages/report.md, not a rewrite of the lines below. Unload of the NIF entry is still not a free. Mojoler.Own does free its private box.
Learning objectives
- Apply the word PASS only to the platform that was executed.
- Split blockers into compiler, ABI, runtime, and build tooling.
- Recommend a next step that does not pretend a C shim was required.
Why this matters
A green test on one Mac is a real result and a narrow one. The useful report says both.
Mental model
PASS macOS arm64, OTP 28.3.1, Elixir 1.20.2, Mojo 1.1.0, add/2
PASS Linux x86-64, same OTP, Elixir, and Mojo, add/2, GitHub-hosted ubuntu-24.04
PASS one Apple GPU call, MAX 26.6.0, separate NIF, this Mac only
UNTESTED other OTP minors, NVIDIA, Linux GPU, parallelize, unloadTheory
Verdict: PASS for the Stage 0 minimum on the tested platform.
Criteria used:
Mojoler.POC.add(20, 22) == 42in ExUnit, in-process and in a fresh OS process.- The library is the output of
mojo build native/poc.mojowith no C input. nmshows_nif_initand undefined_enif_get_long,_enif_make_long,_enif_make_badarg.- Layout offsets match the diagnostic clang program.
- Bad integers and overflow become
ArgumentErrorrather than wraparound.
Compatibility matrix:
- OS/CPU: macOS 26.5 arm64, tested. Linux x86-64 tested by Actions run 39451722783: doctor ready, layout offsets match,
runtime ping 7, ExUnit11 passed. See ledger E18 and E19. That job has no GPU. - OTP: 28.3.1, erts 16.2, NIF 2.17, tested.
- Elixir: 1.20.2-otp-28, tested.
- Mojo: 1.1.0 (
8189361e), tested. Nightly was not required.
Blockers and workarounds:
- Mojo: no module-level
var. Workaround:calloc. This is still direct Mojo. - Mojo:
fnremoved; file-scopealiasof a function type did not parse. Workaround:defand an inline thin function type. - Mojo:
Pointeris non-nullable, andOptionalof a thin function is 16 bytes. Workaround:Optional[Pointer]for NULL callbacks, measured as 8 zero bytes. - OTP ABI:
enif_get_int64is a macro on this build. Workaround: callenif_get_longandenif_make_long. - OTP ABI: Darwin
load_nifappends.so. Workaround: name the filelibpoc.so. - Runtime: on Darwin,
libKGENCompilerRTShared.dylibmust be on the embedded rpath. Workaround: build inside the project venv and do not relocate the.sowithout that dylib. On the Linux runner,lddoflibpoc.solists libc and the dynamic linker only (E19). The venv still containslibKGENCompilerRTShared.so; thisaddimage does not need it as aDT_NEEDEDentry. - Runtime: GPU code lives in the
maxpackage (26.6.0 here), not in the Mojo 1.1.0 standard library. A one-element Apple GPU add returned 42 from a separate NIF. See ledger E17. NVIDIA, concurrency, and long kernels were not run. - Build tooling: macOS two-level namespace. Workaround that was verified:
-Xlinker -undefined -Xlinker dynamic_lookup.
Not a blocker: expressing ErlNifEntry in Mojo. Experiment 03 matched clang.
Safety:
- A Mojo trap kills the OS process. Bad arguments do not; they return badarg.
envandargvare not stored. Integer results are new terms.- Two calloc blocks leak until process exit. Unload was not implemented.
addcan stall a scheduler only for the duration of a short call. It does not take a dirty flag.- Upgrade and reload callbacks are NULL. OTP rejects a second load with the message recorded in the ledger.
- The Mojo runtime dylib is in the process even when
initialize_runtimeis not called. Itsdlopenbehavior beyond "the tests passed" was not audited. - Concurrent
addpassed. Concurrent use of the Mojo thread pool was not run.
Rustler and Zigler, on the axes the prompt asked for:
- ABI generation: they generate the entry and the conversions. Mojoler writes them once and checks offsets with a C diagnostic that is not linked in.
- Type conversion: Rustler and Zigler cover binaries, atoms, lists, and resources. Mojoler converts two
longs and returns badarg otherwise. - Scheduler safety: all three can set dirty flags. Only the integer flag value was verified here, and it is 0.
- Compile-time integration: Rustler uses a crate and a build script; Zigler uses Mix plus the Zig compiler. Mojoler shells out to
mojo buildfromtest/test_helper.exs. - Memory ownership: Rustler has resource types; Zigler has explicit alloc/free. Mojoler has calloc without free and no zero-copy binary.
- Runtime complexity: Zig is the smallest, Rust has its std but not a required thread pool for an add, Mojo links
libKGENCompilerRTShared.dylib. - Maintenance: a NIF minor bump means a new offset measurement in this repo. Rustler and Zigler absorb that in their generators, and they also break when they lag OTP.
Recommendation: proceed with direct Mojo for integer-scale NIFs on this ABI. Do not open Stage 0's success to include a C adapter. A C adapter is unnecessary for add/2 and would throw away the result.
Stage 1, justified by the PASS:
- Turn the measured struct declarations into a Mojo module that other NIFs import, still compiled only from
.mojofiles. - Generate the constants (
minor, resource size,min_erts,vm_variant) from the diagnostic program's output so an OTP bump is a regenerated file plus a re-run, not a hand edit. - Add
enif_make_atomand a copied binary only after a lifetime test like chapter 07's stretch. - Keep
-undefined dynamic_lookupbehind the same build script, and faildoctorwhen the KGEN dylib is missing. - The CPU matrix has now been repeated on Linux x86-64 (E18, E19). Portability still does not include a GPU on Linux, another OTP minor, or NVIDIA.
Hands-on exercise
Run scripts/doctor.sh, scripts/run-abi-experiments.sh, and scripts/test-isolated.sh. PASS for a new checkout means doctor prints ready, the layout sections agree, and ExUnit prints 11 passed. Any one of those failing moves that checkout to PARTIAL or BLOCKED. Do not copy this chapter's verdict onto a log you have not produced.
Line-by-line walkthrough
The verdict is tied to test/poc_test.exs:
add(20, 22) == 42is the minimum demonstration.- The overflow tests are the "checked Int64" requirement.
- The
nmtest is the "no silent C shim" requirement inside the suite. - The fresh
elixir -etest is the "separate OS process" requirement.
If the nm test passes and the arithmetic tests fail, the ABI loaded and the logic is wrong. If load_nif fails, the arithmetic tests never run and the verdict is not PASS.
Under the hood
The compiler-inserted libc symbols (write, dup, fdopen, fflush, fclose) are visible in the LLVM module for poc.mojo. They are why "no C source file" and "the process never executes libc" are different sentences. The proof in direct_mojo_proof.md claims the first. It does not claim the second.
Failure modes
- Reporting PASS for Linux because arm64 matched the manual's LP64 comment.
- Reporting BLOCKED because a C file exists under
experiments/abi/diagnostic/. That file is not linked. Checkscripts/build-native.sh. - Reporting that Mojo is faster than Elixir. The measured loop is slower. See chapter 08.
Rustler and Zigler
Choose Rustler or Zigler today if you need binaries, resources, dirty schedulers, and more than one OTP version without re-measuring structs by hand. Choose more Mojo only if the point of the project is the Mojo implementation itself. Stage 0 shows that the implementation is possible for this function. It does not show that it is a better default than Rustler for an ordinary Elixir library.
Checkpoint
- What is the verdict on this Mac? PASS.
- What is the verdict on Linux CPU
add/2? PASS, from the green Actions log in E18 and E19. That is not a GPU verdict. - What remains untested? NVIDIA, a GPU call on Linux, concurrent GPU calls, and unload.