Skip to content

Phase 1: async substrate + eval canary - #41

Merged
ausimian merged 1 commit into
mainfrom
phase-1-async-substrate
Apr 18, 2026
Merged

Phase 1: async substrate + eval canary#41
ausimian merged 1 commit into
mainfrom
phase-1-async-substrate

Conversation

@ausimian

@ausimian ausimian commented Apr 18, 2026

Copy link
Copy Markdown
Owner

First PR of the async-worker migration. See docs/planning/async-worker-exploration.md on PR #40 for the full plan and Spike findings.

What changes

  • c_src/emily/async.hpp (new): async_reply helper that mints a ref, captures the caller PID, enqueues onto the worker thread, and posts {ref, {:ok, payload}} / {ref, {:error, reason}} back via enif_send. Exception classification matches fine's sync ladder.
  • c_src/emily/worker.hpp: adds non-blocking run_async alongside existing run_sync. Other NIFs still use run_sync — this PR only converts one canary.
  • c_src/emily_nif.cpp: evaleval_nif + async_reply. Drops the ERL_NIF_DIRTY_JOB_CPU_BOUND flag.
  • lib/emily/native.ex: eval/2 wraps eval_nif/2 via Async.call/1. Public signature unchanged.
  • lib/emily/native/async.ex (new): call/1 awaits the worker's reply and re-raises errors as ArgumentError / RuntimeError.

What's the user-visible effect

Nothing. Emily.Native.eval(worker, tensor) still blocks until materialisation and returns :ok. The caller's process now does a receive instead of blocking the scheduler on a std::future, so the scheduler is free to run other processes during MLX work.

Test coverage

  • Full existing suite passes (421 prior + 4 new = 425 tests, 0 failures).
  • New test/emily/async_eval_test.exs:
    • mailbox stays empty across single and 1k-iteration eval loops
    • caller killed mid-eval: worker drains dead-PID messages and remains usable for fresh callers
    • resident-tensor eval latency averages <20 µs locally (budgeted under 1 ms)

Follow-ups (not in this PR)

  • Phase 2: bulk-convert op NIFs in c_src/ops/*.cpp (~800 LOC mechanical diff).
  • Phase 3: convert to_binary with strategy 3a validated by Spike B.
  • Phase 4 (optional): bounded queue + telemetry.

Test plan

  • mix precommit
  • mix test full suite
  • Run conformance suite (mix test --include conformance) before merge to verify no regression

Introduces the async NIF machinery and converts `eval/2` as the
first canary. No behaviour change from the caller's perspective —
`Emily.Native.eval/2` still blocks until the tensor is materialised
and returns `:ok`. Internally the NIF now enqueues the eval onto
the worker thread, returns a fresh ref, and awaits the worker's
reply via `Emily.Native.Async.call/1` on the caller's mailbox. No
BEAM scheduler thread is blocked during MLX work.

The four spikes on the exploration plan (A-D) validated the
plumbing before this landed; see docs/planning/async-worker-exploration.md.

New:
- `c_src/emily/async.hpp` — `async_reply` helper that mints a ref,
  captures the caller PID, enqueues onto the worker, and posts
  `{ref, {:ok, payload}}` or `{ref, {:error, reason}}` back via
  `enif_send`. Exception classification mirrors `fine::nif_impl`'s
  catch ladder (`std::invalid_argument` → `:argument`,
  `std::runtime_error` → `:runtime`, anything else → `:unknown`).
- `lib/emily/native/async.ex` — `call/1` awaits the reply and
  re-raises errors as `ArgumentError` / `RuntimeError` to match the
  sync path's semantics.

Changed:
- `c_src/emily/worker.hpp` — added `run_async` (non-blocking
  enqueue, task owns error propagation). `run_sync` is unchanged;
  other NIFs continue to use it.
- `c_src/emily_nif.cpp` — `eval` renamed to `eval_nif` and
  converted to async via `async_reply`. Drops the
  `ERL_NIF_DIRTY_JOB_CPU_BOUND` flag (no longer dirty-appropriate;
  enqueue is sub-microsecond and the work runs off-scheduler on the
  worker thread).
- `lib/emily/native.ex` — `eval/2` wraps `eval_nif/2` with
  `Async.call/1`. Public signature and return type unchanged.

Regression tests in `test/emily/async_eval_test.exs` cover:
mailbox hygiene (empty after 1k evals), caller killed mid-flight
(worker remains usable), latency on a resident 4x4 tensor
(averages <20 us locally, budgeted under 1 ms).

Full test suite passes (425 tests, 0 failures).
@ausimian
ausimian marked this pull request as ready for review April 18, 2026 23:34
@ausimian
ausimian merged commit 08d0c0f into main Apr 18, 2026
1 check passed
@ausimian
ausimian deleted the phase-1-async-substrate branch April 18, 2026 23:34
ausimian added a commit that referenced this pull request Apr 19, 2026
After the Phase 1-3 async-worker migration (#41-#43), NIFs no longer
block a scheduler on a sync promise — they enqueue on the worker and
the public wrapper awaits a reply via enif_send. Update the README
Concurrency model section and the CHANGELOG worker-thread-dispatch
bullet so they describe the shipping behaviour.
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