Skip to content

Phase 2: convert all op NIFs to async enif_send - #42

Merged
ausimian merged 1 commit into
mainfrom
phase-2-async-ops
Apr 19, 2026
Merged

Phase 2: convert all op NIFs to async enif_send#42
ausimian merged 1 commit into
mainfrom
phase-2-async-ops

Conversation

@ausimian

@ausimian ausimian commented Apr 19, 2026

Copy link
Copy Markdown
Owner

Second PR of the async-worker migration. Phase 1 merged as #41; this PR converts every worker-bound op NIF in `c_src/ops/*.cpp` to the async pattern.

Scope

15 files touched on the C++ side, one new helper (`emily::async_encoded`), and `lib/emily/native.ex` updated to wrap every NIF with `Async.call/1`. The public Elixir surface is unchanged — callers see the same blocking semantics.

Ops converted (everything that takes a worker):

  • unary + round, binary (23 arithmetic/compare/logical/bitwise ops)
  • reduce (sum/mean/prod/max/min/all/any/logsumexp, var/std, argmax/argmin, cumulative)
  • cast (astype, bitcast)
  • creation (zeros, ones, full, arange, eye)
  • shape (12 ops)
  • sort (5 ops)
  • misc (clip, roll, softmax, logcumsumexp, array_equal)
  • index (10 slicing/gather/scatter ops)
  • linalg (matmul, tensordot, outer, inner, 7 CPU decomp/solvers, 3 quantization ops)
  • fast (rms_norm, layer_norm, rope, scaled_dot_product_attention)
  • random (except `random_key` which is pure and stays sync)
  • fft, pooling, conv

Stays sync: `from_binary`, `shape`, `dtype`, `to_binary` (Phase 3), `create_worker`, memory introspection NIFs, `random_key`.

What's the user-visible effect

Same as Phase 1 — none at the API level. Every `Emily.Native.*` call still blocks until the result is ready and returns the tensor. Internally, every op now runs without blocking a BEAM scheduler.

Notable detail: memory_test tolerance bump

`test/soak/memory_test.exs` tolerance raised from 4 MB to 16 MB. The async path has a bounded ~9-10 MB working set (heap-fragment messages in flight + worker queue lag) that the sync path didn't. Measured as a plateau — not a leak — across 100 to 8000 iters. A real per-iter leak would grow past 16 MB and still fail.

Dialyzer

The pre-existing `:invalid_contract` warnings in `backend.ex`, `quantization.ex`, and `quantized_weight.ex` now report at different line numbers because the widened inferred return type from `Async.call` (`term()`) propagates through the callers. Hardcoded line numbers in `.dialyzer_ignore.exs` removed. Behaviour unchanged — dialyzer still skips 0 new warnings.

Test plan

  • `mix precommit` — 425 tests, 0 failures
  • `mix test --include conformance` — 442 tests, 0 failures (tiny-random Qwen3, Whisper, ViT, DistilBERT conformance)
  • `mix dialyzer` — 0 new warnings
  • Run a heavy conformance suite (`--include qwen3_full` or similar) before merging if you want a belt-and-braces check with a real model

Bulk-converts every worker-bound op NIF across c_src/ops/*.cpp to
the async model introduced in Phase 1. Each NIF now enqueues its
lambda onto the worker thread via emily::async_encoded and returns
a ref immediately; the worker posts {ref, {:ok, result}} or
{ref, {:error, reason}} back to the caller via enif_send. Public
Elixir wrappers in Emily.Native retain their sync return semantics
by awaiting the reply through Emily.Native.Async.call/1.

Files converted (all ops flipped from fine::ResourcePtr<Tensor>
returns under run_sync to fine::Term returns via async_encoded):

- unary.cpp       (40 elementwise + round)
- binary.cpp      (23 arithmetic/compare/logical/bitwise)
- reduce.cpp      (sum/mean/prod/max/min/all/any/logsumexp,
                   var/std, argmax/argmin, cumulative)
- cast.cpp        (astype, bitcast)
- creation.cpp    (zeros, ones, full, arange, eye)
- shape.cpp       (reshape, transpose, squeeze, expand_dims,
                   broadcast_to, concatenate, stack, flatten,
                   tile, swapaxes, pad, repeat)
- sort.cpp        (sort, argsort, partition, argpartition, topk)
- misc.cpp        (clip, roll, softmax, logcumsumexp, array_equal)
- index.cpp       (slice, slice_update, take, where,
                   take_along_axis, put_along_axis,
                   scatter_add_axis, gather, scatter, scatter_add)
- linalg.cpp      (matmul, tensordot, outer, inner, quantize,
                   dequantize, quantized_matmul, and CPU-only
                   decompositions: lu, svd, qr, cholesky, eigh,
                   solve, solve_triangular)
- fast.cpp        (rms_norm, layer_norm, rope, scaled_dot_product_attention)
- random.cpp      (split, uniform, normal, randint, bernoulli,
                   gumbel, categorical; random_key stays sync —
                   no worker needed)
- fft.cpp         (fftn, ifftn, rfftn, irfftn)
- pooling.cpp     (window_sum/max/min/product, window_scatter_{max,min})
- conv.cpp        (conv_general)

Stays sync (no worker involvement): from_binary, shape, dtype,
to_binary (Phase 3), create_worker, memory introspection NIFs,
random_key. async_encoded itself is added alongside the existing
async_reply helper in emily/async.hpp.

Lambda captures now move their ResourcePtr arguments (std::move)
rather than copying, saving a pair of enif_keep/enif_release per
call. Refcount accounting is unchanged; move is net-neutral.

Tolerance adjustment in test/soak/memory_test.exs: 4 MB -> 16 MB.
The async path has a bounded ~9-10 MB working set (heap-fragment
messages in flight + worker queue lag between enif_send and
lambda destruction) that the sync path did not have. Measured
empirically as a plateau — not a per-iter leak — across 100 to
8000 iters on my local test. A real leak would grow linearly,
still caught by the new ceiling.

Dialyzer ignore entries for invalid_contract warnings in
backend.ex, quantization.ex, and quantized_weight.ex dropped their
hardcoded line numbers: dialyzer reports at different positions
now because the widened inferred typing from Async.call flows
through to these callers. The warnings themselves are unchanged.

Full test suite: 425 tests, 0 failures.
Conformance (tiny-random HuggingFace models): 442 tests, 0 failures.
Dialyzer: 0 new warnings.
@ausimian
ausimian marked this pull request as ready for review April 19, 2026 00:07
@ausimian
ausimian merged commit 75ebeed into main Apr 19, 2026
1 check passed
@ausimian
ausimian deleted the phase-2-async-ops branch April 19, 2026 00:07
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