From 131fa4e1943cb4bba16bcaafeb494e882d3b4a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20L=C3=BCning?= Date: Tue, 20 Oct 2026 19:56:41 +0200 Subject: [PATCH 1/3] defk-9p6.29: threshold_cross, an early-exit loop in Elixir, a kernel and Nx defn with EXLA --- examples/threshold_cross.exs | 444 +++++++++++++++++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 examples/threshold_cross.exs diff --git a/examples/threshold_cross.exs b/examples/threshold_cross.exs new file mode 100644 index 00000000..e951a279 --- /dev/null +++ b/examples/threshold_cross.exs @@ -0,0 +1,444 @@ +# threshold_cross: the first index where a running sum passes a budget, an +# early-exit loop, in Elixir, in a kernel and in Nx defn with EXLA +# +# The job: split a packet stream at a byte budget (rate limiting, chunking +# an upload, batching writes): the index of the first packet whose running +# total passes the budget. Each step depends on the last and the loop stops +# early, the shape docs/research/nx-boundary.md argues from source is a +# kernel's and not Nx's. This example measures it, to find the line, not +# to beat Nx: where EXLA wins, that is the result. +# +# elixir examples/threshold_cross.exs +# +# Expected verdict (against Elixir): good. EXLA is the native comparison. +# +# Four ways, all integer-exact (i64 sizes, so every side gives the same +# index): +# E1, E2 Elixir binary matching, one and four packets per clause (E2 +# is the baseline: 1.7-2.1x E1 from 4k packets up). +# defk Enum.reduce_while over the packets, halting at the crossing. +# while defn with `while` and the stop flag in its condition (Nx has +# no break), compiled once per shape with Nx.Defn.compile/3 for +# EXLA, on tensors resident on EXLA.Backend. +# scan defn without a loop: Nx.cumulative_sum, then Nx.argmax of the +# mask (the tensor way: no early exit, all the work). +# Nx has no empty tensors (Nx.from_binary(<<>>) raises), so an Nx caller +# needs a guard for an empty batch; the kernel takes one. +# +# Agreement: every side gives the same index on 80 inputs (1 to 4097 +# packets; the budget passed at the first packet, at 1%, 25%, 99%, never), +# including the kernel's Elixir mode (`elixir_mode: true`). +# +# Measured (M-series Mac, OTP 29, Elixir 1.20, Nx and EXLA 0.13.1 on the +# CPU, 2026-10-20; two runs, provisional: the first shared the machine +# with a `mix ci`). Per call, us, the budget passed at 25% (best of 5 +# batches): +# +# n E2 defk EXLA while EXLA scan scan from a binary +# 16 0.14-0.16 0.05 40-48 37-126 46-134 +# 256 0.20-0.26 0.06-0.11 37-41 40 47-49 +# 4096 1.03-1.09 0.36-0.42 40-41 50 55-56 +# 65536 15.7-17.0 4.4-4.6 62-63 154-168 173-177 +# 1M 249 91-92 319-344 1333-1341 1896-2355 +# 16M 4336-4467 1473-1478 4272-4359 24780-25650 41807-48137 +# +# The report, 64k packets (realistic size: a few seconds of traffic): +# Elixir 0.017 ms, defk 0.006 ms (2.8x, good), EXLA while 0.07 ms (defk +# 11.7x). Ticker gap on one scheduler while 16M packets are summed: about +# 2 ms on every side (the floor under load): Elixir is preempted, the +# kernel yields, EXLA runs on a dirty scheduler. +# +# Crossovers: the kernel is faster than EXLA at every size, from 790x at 16 +# packets (EXLA's ~40 us per call: a GenServer lock per device, a +# dirty-scheduler hop) to 2.9x at 16M. Optimised Elixir is faster than +# EXLA's while up to 1M packets and level at 16M. The scan form is slower +# than Elixir at every size: XLA's cumulative sum on the CPU took 1.5 ns a +# packet, the kernel 0.35 ns a packet it reads. +# +# Concurrency, calls per second at 4096 packets (300 ms windows, one run): +# 1 process E2 0.97M, defk 3.2M, EXLA while 24.5k, scan 19.6k +# 48 processes E2 7.9M, defk 22.3M, EXLA while 46.8k, scan 32.0k +# EXLA scales 1.6-1.9x from 1 to 48 callers (one host device, one lock); +# the kernel 7x, on the normal schedulers. +# +# Inputs whose length changes per call: 300 calls, 298 distinct lengths +# from 101 to 20000 packets, total ms: E2 0.8-1.0, defk 0.4; EXLA while +# with the jit compiling each new shape 1892-1948 (about 6.4 ms per +# compile), 19-20 once all are compiled; EXLA scan padded to 8 +# power-of-two buckets 244-272 cold, 35-36 warm (the bucket workaround: 8 +# compiles, then the padding and the larger shapes cost 2x). +# +# Recommendation: for a sequential loop with early exit, a kernel; plain +# Elixir is second and still beats EXLA up to about a million elements. +# EXLA is for whole-tensor work at scale (see cosine_topk.exs and +# matmul.exs), not for a scalar loop, small or variable-length inputs, or +# many concurrent small callers. + +Mix.install([ + {:defk, path: Path.expand("..", __DIR__)}, + {:nx, "== 0.13.1"}, + {:exla, "== 0.13.1"} +]) + +defmodule Cross.Kernels do + use Defk, elixir_mode: true + + # The index of the first packet whose running total of bytes passes the + # budget, or -1. + defk first_cross(x :: [i64], budget :: i64) :: i64 do + n = length(x) + + {_s, idx} = + Enum.reduce_while(0..(n - 1)//1, {0, -1}, fn i, {s, _idx} -> + s1 = s + x[i] + if s1 > budget, do: {:halt, {s1, i}}, else: {:cont, {s1, -1}} + end) + + idx + end +end + +defmodule Cross.Elixir do + # E1: one packet per clause + def first_cross(bin, budget), do: go(bin, budget, 0, 0) + + defp go(<>, b, s, i) do + s = s + x + if s > b, do: i, else: go(rest, b, s, i + 1) + end + + defp go(<<>>, _b, _s, _i), do: -1 + + # E2: four packets per clause, then one at a time + def first_cross4(bin, budget), do: go4(bin, budget, 0, 0) + + defp go4( + <>, + t, + s, + i + ) do + s4 = s + a + b + c + d + + if s4 > t, + do: + go( + <>, + t, + s, + i + ), + else: go4(rest, t, s4, i + 4) + end + + defp go4(rest, t, s, i), do: go(rest, t, s, i) +end + +defmodule Cross.Nx do + import Nx.Defn + + # The early-exit form: a while loop whose condition folds in the stop flag + defn first_cross_while(x, budget) do + n = Nx.size(x) + zero = Nx.tensor(0, type: :s64) + + {_i, _s, found, _x, _b} = + while {i = zero, s = zero, found = zero - 1, x, budget}, i < n and found < 0 do + s = s + x[i] + found = Nx.select(s > budget, i, found) + {i + 1, s, found, x, budget} + end + + found + end + + # The tensor form: the whole prefix sum, then the first index over budget + defn first_cross_scan(x, budget) do + hit = Nx.cumulative_sum(x) > budget + idx = Nx.argmax(hit, tie_break: :low) + Nx.select(hit[idx], idx, -1) + end +end + +defmodule Cross.Data do + # Packet sizes 40..1500 bytes; the budget is passed at about `at` of the + # way through (the mean packet is 770 bytes). + def packets(n, seed \\ 1) do + :rand.seed(:exsss, {seed, 9, 11}) + for _ <- 1..n//1, into: <<>>, do: <<39 + :rand.uniform(1461)::signed-native-64>> + end + + def budget(n, at), do: trunc(n * at * 770) +end + +alias Cross.{Data, Kernels} + +# ---- the EXLA sides: tensors resident on EXLA.Backend, as an Nx app keeps +# them; one Nx.Defn.compile/3 per shape (the jit cache lookup skipped), and +# the jit for the variable-length runs ---- + +compiled = fn fun, n -> + Nx.Defn.compile(fun, [Nx.template({n}, :s64), Nx.template({}, :s64)], compiler: EXLA) +end + +to_dev = fn bin -> Nx.from_binary(bin, :s64, backend: EXLA.Backend) end +scalar = fn v -> Nx.tensor(v, type: :s64, backend: EXLA.Backend) end + +while_jit = Nx.Defn.jit(&Cross.Nx.first_cross_while/2, compiler: EXLA) +scan_jit = Nx.Defn.jit(&Cross.Nx.first_cross_scan/2, compiler: EXLA) +exla = fn f, bin, b -> Nx.to_number(f.(to_dev.(bin), scalar.(b))) end + +# ---- agreement ---- + +oracle = fn bin, b -> Kernels.__defk_elixir__(:first_cross, [bin, b]) end + +cases = + for n <- [1, 2, 3, 5, 16, 99, 1000, 4097], at <- [0.0, 0.01, 0.25, 0.99, 2.0], seed <- [1, 2] do + {Data.packets(n, seed + n), Data.budget(n, at)} + end + +agree = + Enum.all?(cases, fn {bin, b} -> + e = Cross.Elixir.first_cross(bin, b) + + Enum.all?( + [ + Cross.Elixir.first_cross4(bin, b), + Kernels.first_cross(bin, b), + oracle.(bin, b), + exla.(while_jit, bin, b), + exla.(scan_jit, bin, b) + ], + &(&1 == e) + ) + end) + +# A kernel takes an empty array; Nx has no empty tensors +empty = Kernels.first_cross(<<>>, 0) == -1 and Cross.Elixir.first_cross(<<>>, 0) == -1 + +nx_empty = + try do + to_dev.(<<>>) + "built" + rescue + e -> "raises #{inspect(e.__struct__)}" + end + +IO.puts(""" +agreement: Elixir (2), the kernel, its Elixir mode and EXLA (while, scan) on #{length(cases)} inputs \ +(budget passed at the first packet, 1%, 25%, 99%, never) #{agree}; empty input #{empty}, \ +Nx.from_binary(<<>>) #{nx_empty} +""") + +# ---- per call, by size: best of 5 batches, each long enough to time ---- + +per_call = fn f, n -> + reps = min(20_000, max(1, div(2_000_000, n))) + f.() + + us = + for _ <- 1..5 do + {t, _} = :timer.tc(fn -> for _ <- 1..reps, do: f.() end) + t / reps + end + + Enum.min(us) +end + +sizes = [16, 256, 4096, 65_536, 1_048_576, 16_777_216] + +IO.puts( + "per call, us (budget passed at 25%; EXLA on resident tensors, one Nx.Defn.compile per shape):" +) + +IO.puts( + " #{String.pad_trailing("n", 10)} #{Enum.map_join(~w(E1 E2 defk while scan from_bin), " ", &String.pad_leading(&1, 10))}" +) + +for n <- sizes do + bin = Data.packets(n, 3) + b = Data.budget(n, 0.25) + xt = to_dev.(bin) + bt = scalar.(b) + cw = compiled.(&Cross.Nx.first_cross_while/2, n) + cs = compiled.(&Cross.Nx.first_cross_scan/2, n) + true = Nx.to_number(cw.(xt, bt)) == Kernels.first_cross(bin, b) + + row = [ + per_call.(fn -> Cross.Elixir.first_cross(bin, b) end, n), + per_call.(fn -> Cross.Elixir.first_cross4(bin, b) end, n), + per_call.(fn -> Kernels.first_cross(bin, b) end, n), + per_call.(fn -> Nx.to_number(cw.(xt, bt)) end, n), + per_call.(fn -> Nx.to_number(cs.(xt, bt)) end, n), + per_call.(fn -> Nx.to_number(cs.(to_dev.(bin), bt)) end, n) + ] + + IO.puts( + " #{String.pad_trailing(Integer.to_string(n), 10)} #{Enum.map_join(row, " ", &String.pad_leading(:erlang.float_to_binary(&1 * 1.0, decimals: 2), 10))}" + ) +end + +# ---- concurrency: 1 process, then P processes, calling for 300 ms each at +# n = 4096 (a fixed count of calls was too short for the CPU sides: the +# Task setup was what got timed) ---- + +p = 4 * System.schedulers_online() +cbin = Data.packets(4096, 5) +cb = Data.budget(4096, 0.25) +cxt = to_dev.(cbin) +cbt = scalar.(cb) +ccw = compiled.(&Cross.Nx.first_cross_while/2, 4096) +ccs = compiled.(&Cross.Nx.first_cross_scan/2, 4096) + +loop_until = fn f, deadline, count -> + if System.monotonic_time(:millisecond) >= deadline do + count + else + f.() + f.() + f.() + f.() + {:cont, count + 4} + end +end + +calls_per_s = fn f, procs -> + f.() + + for _ <- 1..3 do + deadline = System.monotonic_time(:millisecond) + 300 + + count = + 1..procs + |> Task.async_stream( + fn _ -> + Stream.iterate(0, & &1) + |> Enum.reduce_while(0, fn _, c -> + case loop_until.(f, deadline, c) do + {:cont, c} -> {:cont, c} + c -> {:halt, c} + end + end) + end, + max_concurrency: procs, + timeout: :infinity + ) + |> Enum.reduce(0, fn {:ok, c}, acc -> acc + c end) + + count / 0.3 + end + |> Enum.max() +end + +IO.puts("concurrency at n = 4096, calls per second (best of 3 windows of 300 ms):") + +IO.puts( + " #{String.pad_trailing("", 12)} #{String.pad_leading("1 process", 12)} #{String.pad_leading("#{p} processes", 14)}" +) + +for {label, f} <- [ + {"E2", fn -> Cross.Elixir.first_cross4(cbin, cb) end}, + {"defk", fn -> Kernels.first_cross(cbin, cb) end}, + {"EXLA while", fn -> Nx.to_number(ccw.(cxt, cbt)) end}, + {"EXLA scan", fn -> Nx.to_number(ccs.(cxt, cbt)) end} + ] do + one = round(calls_per_s.(f, 1)) + many = round(calls_per_s.(f, p)) + + IO.puts( + " #{String.pad_trailing(label, 12)} #{String.pad_leading(Integer.to_string(one), 12)} #{String.pad_leading(Integer.to_string(many), 14)}" + ) +end + +# ---- variable length: 300 calls, lengths 101..20000, data arriving as +# binaries; EXLA's jit compiles once per new shape (cold pass), then +# reuses (warm pass); buckets pad to the next power of two with zero bytes, +# which never pass a budget ---- + +:rand.seed(:exsss, {21, 22, 23}) +lens = for _ <- 1..300, do: 100 + :rand.uniform(19_900) +calls = for {n, k} <- Enum.with_index(lens), do: {Data.packets(n, 100 + k), Data.budget(n, 0.25)} + +pad = fn bin -> + n = div(byte_size(bin), 8) + m = max(128, 2 ** ceil(:math.log2(n))) + bin <> :binary.copy(<<0::signed-native-64>>, m - n) +end + +total_ms = fn f -> elem(:timer.tc(fn -> for {bin, b} <- calls, do: f.(bin, b) end), 0) / 1000 end +fresh_jit = fn fun -> Nx.Defn.jit(fun, compiler: EXLA) end + +wj = fresh_jit.(&Cross.Nx.first_cross_while/2) +bj = fresh_jit.(&Cross.Nx.first_cross_scan/2) +buckets = calls |> Enum.map(fn {bin, _} -> byte_size(pad.(bin)) end) |> Enum.uniq() |> length() + +IO.puts( + "variable length: 300 calls, #{length(Enum.uniq(lens))} distinct lengths, #{buckets} buckets; total ms:" +) + +for {label, f} <- [ + {"E2", fn bin, b -> Cross.Elixir.first_cross4(bin, b) end}, + {"defk", fn bin, b -> Kernels.first_cross(bin, b) end} + ] do + IO.puts(" #{String.pad_trailing(label, 26)} #{Float.round(total_ms.(f), 1)}") +end + +for {label, j, prep} <- [ + {"EXLA while, jit per shape", wj, & &1}, + {"EXLA scan, jit, buckets", bj, pad} + ] do + f = fn bin, b -> Nx.to_number(j.(to_dev.(prep.(bin)), scalar.(b))) end + cold = total_ms.(f) + warm = total_ms.(f) + + IO.puts( + " #{String.pad_trailing(label, 26)} cold #{Float.round(cold, 1)}, warm #{Float.round(warm, 1)}" + ) +end + +# ---- the verdict: the kernel against the best Elixir at 64k packets, +# EXLA's while form (its faster form here) beside them; the tick gap while +# 16M packets are summed with a budget never passed ---- + +rbin = Data.packets(65_536, 7) +rb = Data.budget(65_536, 0.25) +rxt = to_dev.(rbin) +rbt = scalar.(rb) +rcw = compiled.(&Cross.Nx.first_cross_while/2, 65_536) + +big = Data.packets(16_777_216, 8) +bigb = Data.budget(16_777_216, 2.0) +bigt = to_dev.(big) +bigbt = scalar.(bigb) +bigw = compiled.(&Cross.Nx.first_cross_while/2, 16_777_216) + +Defk.Bench.report( + name: "threshold_cross", + expected: :good, + baseline: :optimized, + runs: 15, + realistic_size: "64k packets, the budget passed at 25%", + elixir: fn -> Cross.Elixir.first_cross4(rbin, rb) end, + defk: fn -> Kernels.first_cross(rbin, rb) end, + native: {fn -> Nx.to_number(rcw.(rxt, rbt)) end, "Nx 0.13.1 + EXLA 0.13.1, while"}, + check: fn -> + agree and empty and Kernels.first_cross(rbin, rb) == Cross.Elixir.first_cross(rbin, rb) and + Nx.to_number(rcw.(rxt, rbt)) == Cross.Elixir.first_cross(rbin, rb) + end, + sweep: + {[16_384, 65_536, 1_048_576], + fn n -> + bin = Data.packets(n, 9) + b = Data.budget(n, 0.25) + {fn -> Cross.Elixir.first_cross4(bin, b) end, fn -> Kernels.first_cross(bin, b) end} + end}, + sweep_runs: 5, + responsiveness: [ + elixir: fn -> Cross.Elixir.first_cross4(big, bigb) end, + defk: fn -> Kernels.first_cross(big, bigb) end, + native: fn -> Nx.to_number(bigw.(bigt, bigbt)) end + ], + reason: + "a sequential loop that stops early: the kernel runs it at C speed with a call cost of tens of ns; EXLA pays about 40 us per call and its while loop is several times slower per step" +) From 3602f17d30b64dc7158bae4769f5107325ac429d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20L=C3=BCning?= Date: Tue, 20 Oct 2026 19:58:24 +0200 Subject: [PATCH 2/3] defk-9p6.29: docs for threshold_cross: scope, nx-boundary's measured gaps, performance 3.10, tables --- docs/performance.md | 24 ++++++++++++++++++++++++ docs/research/nx-boundary.md | 24 ++++++++++++++++++++---- docs/scope.md | 10 +++++++--- examples/README.md | 8 ++++++++ examples/_tables.exs | 10 ++++++++++ 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/docs/performance.md b/docs/performance.md index 0ca080f4..1643fcd5 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -910,6 +910,30 @@ Elixir answer for BLAKE2b; BLAKE3 is not in OpenSSL. - **Source:** [`blake.exs`](https://github.com/DanielLuning/defk/blob/main/examples/blake.exs), `defk-52v.2`. - **Status:** provisional. +### 3.10 Nx with EXLA on an early-exit loop: ~40 us a call, a compile per shape, and plain Elixir is faster up to ~1M elements + +EXLA's strength is whole-tensor work (3.8, `cosine_topk`). On a sequential +loop that stops early it loses to a kernel at every size, and to plain +Elixir below about a million elements. + +- **Numbers** (the first index where a running i64 sum passes a budget, + passed at 25%; per call): 16 elements Elixir 0.14 us, kernel 0.05, EXLA + `while` 40-48 (compiled with `Nx.Defn.compile/3`, tensors resident); 64k + Elixir 15.7-17.0 us, kernel 4.4-4.6, EXLA 62-63; 16M Elixir 4.3-4.5 ms, + kernel 1.47-1.48, EXLA 4.3-4.4. The tensor form (`cumulative_sum` + + `argmax`, no early exit) is 1.5 ns an element and slower than Elixir at + every size; building the tensor from a binary adds up to 2x at 16M. +- **Concurrency** (4096 elements): one process EXLA 24.5k calls/s, 48 + processes 46.8k (the host device's lock); the kernel 3.2M and 22.3M. +- **Variable length:** 300 calls of 298 lengths: the jit compiles each new + shape, ~6.4 ms (1.9 s in all; 20 ms once compiled). Padding to 8 + power-of-two buckets: 0.25 s cold, 36 ms warm. Elixir 0.8-1.0 ms, the + kernel 0.4 ms. +- **Conditions:** M-series Mac, OTP 29, Elixir 1.20.2, Nx and EXLA 0.13.1 + CPU; two runs, one beside a `mix ci`. +- **Source:** [`threshold_cross.exs`](https://github.com/DanielLuning/defk/blob/main/examples/threshold_cross.exs), `defk-9p6.29`; [`nx-boundary.md`](https://github.com/DanielLuning/defk/blob/main/docs/research/nx-boundary.md). +- **Status:** provisional. + --- ## 4. Parallelism and schedulers diff --git a/docs/research/nx-boundary.md b/docs/research/nx-boundary.md index 3964d32d..07090c88 100644 --- a/docs/research/nx-boundary.md +++ b/docs/research/nx-boundary.md @@ -231,10 +231,26 @@ normal schedulers, free the frame on process death) targets exactly that gap. ## Gaps and open questions -- Not measured here: EXLA-CPU per-call latency, compile time for small - kernels, and `while` iteration cost on each compiler. A catalog entry that - writes one early-exit kernel in Elixir, defk, and `defn`+EXLA would turn the - inferences above into numbers (fits defk-9p6.3's "measure against Nx" style). +- **Measured since** (`examples/threshold_cross.exs`, `defk-9p6.29`, + 2026-10-20, Nx and EXLA 0.13.1 on the CPU, provisional): one early-exit + job (the first index where a running sum of i64 passes a budget) in + Elixir, defk, `defn` with `while` and `defn` with `Nx.cumulative_sum`. + These inferences above are now numbers: + - Per-call cost: EXLA-CPU takes 37-48 us a call on a 16-element tensor + (compiled with `Nx.Defn.compile/3`, tensors resident), against 0.05 us + for the kernel and 0.14 us for Elixir. + - `while` iteration cost on EXLA: about 1.0 ns a step at 16M elements + (4.3 ms for 4.2M steps), 3x the kernel's 0.35; the `cumulative_sum` form + costs 1.5 ns an element and loses to plain Elixir at every size. + - The per-device lock: 48 concurrent callers raised EXLA's throughput + only 1.6-1.9x over one caller (the kernel 7x). + - Compile per shape: about 6.4 ms per new input length; padding to 8 + power-of-two buckets cut 300 variable-length calls from 1.9 s to + 0.25 s cold, at 2x the warm cost. + - Responsiveness: a 1 ms ticker on one normal scheduler saw the same gap + (about 2 ms, the floor under load) for all three: EXLA's dirty NIF does + not hold a normal scheduler. + Still not measured: Evaluator and Torchx `while` costs, and GPU. - Whether XLA CPU parallelises a single executable across threads internally (intra-op thread pool) was not traced in source; it affects how much one EXLA call occupies the machine beyond its one dirty scheduler. diff --git a/docs/scope.md b/docs/scope.md index fbeb4467..20b50365 100644 --- a/docs/scope.md +++ b/docs/scope.md @@ -97,9 +97,13 @@ the call must stay preemptible and stop with its process, or when it is the one numeric job in an app that doesn't otherwise need Nx and a compiled backend. Some overlap with Nx is fine: a kernel that saves you adding EXLA for one loop is a good use of defk. A kernel takes the same packed layout as -`Nx.to_binary/1`, so the two work side by side. The small-input and -early-exit cases are not measured -against EXLA yet. +`Nx.to_binary/1`, so the two work side by side. On an early-exit loop (the +first element where a running sum passes a limit) a kernel was faster than +Nx with EXLA at every size measured: about 800x at 16 elements, where EXLA +costs some 40 us a call, and 2.9x at 16M. Plain Elixir beat EXLA up to about +a million elements. With 48 concurrent callers EXLA's throughput rose only +1.6-1.9x (one host device, one lock), and an input of a new length costs +EXLA a compile of about 6 ms (provisional). **A wrapper NIF (Rustler, Zigler or an existing binding) is the right choice** when the value is in an existing native library rather than in a diff --git a/examples/README.md b/examples/README.md index acd4e737..6752b199 100644 --- a/examples/README.md +++ b/examples/README.md @@ -201,6 +201,14 @@ has them in full. best Elixir; rusty_csv's NIF builds the same sub-binaries in C, 2.6× faster again. Timed in one long-lived process, the verdict moved from 0.8× to 2.1× run to run with the heap the earlier runs left. +- [`threshold_cross.exs`](threshold_cross.exs): the first packet whose + running total passes a byte budget, an early-exit loop, in Elixir, a + kernel and Nx `defn` on EXLA (a `while` with a stop flag, and a + cumulative sum). The kernel is faster than EXLA at every size, 790× at + 16 packets and 2.9× at 16M; optimised Elixir beats EXLA up to about 1M. + EXLA costs ~40 µs a call, scales 1.6–1.9× over 48 callers (one host + lock), and compiles each new input length (~6.4 ms; power-of-two buckets + bring 298 compiles down to 8). - [`lz4_snappy_decode.exs`](lz4_snappy_decode.exs): Snappy and LZ4 block decode from scratch on untrusted input, against snappyer, lz4_erl and nimble_lz4. Against the NIFs as installed from Hex (built with no `-O`) diff --git a/examples/_tables.exs b/examples/_tables.exs index 8bb9b9a3..26cf9824 100644 --- a/examples/_tables.exs +++ b/examples/_tables.exs @@ -165,6 +165,11 @@ native: "{native} rusty_csv", line: "in a long-lived process 0.8–2.1× run to run; an [i64] tape of field events is slower than the cleaned copy" }, + "threshold_cross" => %{ + job: "first packet past a byte budget, 64k packets", + native: "{native} Nx + EXLA `while`", + line: "EXLA costs ~40 µs a call and scales 1.9× over 48 callers; Elixir beats it up to ~1M packets" + }, "utf8_valid" => %{ job: "validate 16 MB of UTF-8", line: "`String.valid?` is C already" @@ -237,6 +242,11 @@ "CSV to NimbleCSV's rows through a kernel that cleans the separators for `:binary.split` (rusty_csv's NIF is faster still)", examples: ["tape_csv"] }, + %{ + text: + "an early-exit loop (the first packet past a byte budget), where Nx with EXLA pays ~40 µs a call and recompiles per shape", + examples: ["threshold_cross"] + }, %{ text: "a fuzzy-search web service, Myers bit-parallel on both sides: `/health` latency stays at Elixir's while a blocking NIF raises it", From c357d9bfa07203e55053816f0565826bff546e05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20L=C3=BCning?= Date: Tue, 20 Oct 2026 19:59:30 +0200 Subject: [PATCH 3/3] defk-9p6.29: store threshold_cross's quiet row at 3602f17d; report ranges over four runs --- README.md | 1 + examples/README.md | 5 +++-- examples/scoreboard.json | 1 + examples/threshold_cross.exs | 8 ++++---- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6c9644ef..380b5edc 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Speedups over an optimised Elixir baseline, measured end to end on Apple M4 Pro | ✅ | sorting packed keys, nearest of 500k locations, Monte Carlo slot simulation with `:rand` bit for bit, Gorilla encoding | 2.4–6.0× | | ✅ | a numeric CSV or a JSON array of numbers straight into packed f64s (NimbleCSV and Jason stay for anything else) | 2.7× | | ✅ | CSV to NimbleCSV's rows through a kernel that cleans the separators for `:binary.split` (rusty_csv's NIF is faster still) | 2.1× | +| ✅ | an early-exit loop (the first packet past a byte budget), where Nx with EXLA pays ~40 µs a call and recompiles per shape | 2.5× | | ✅ | a fuzzy-search web service, Myers bit-parallel on both sides: `/health` latency stays at Elixir's while a blocking NIF raises it | [its own measurement](https://github.com/DanielLuning/defk/blob/main/examples/fuzzy_search/README.md) | | 🟡 | a dictionary-wide Levenshtein batch over all schedulers: Myers bit-parallel in Elixir is close | 1.5× | | 🟡 | small WebSocket frames, UTF-8 validation, substring search: the BIFs are native already | 1.0–1.5× | diff --git a/examples/README.md b/examples/README.md index 6752b199..072659a7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,7 +43,7 @@ script tried and what to take from it; the numbers behind them are in each script's header. -Measured on Apple M4 Pro (12 logical cores) in 4 runs, from 2026-10-19 to 2026-10-20. The newest at commit `71e29777`, OTP 29.0.3, Elixir 1.20.2: `mix defk.examples --bench --only tape_csv --snapshot --fresh`. A quiet measurement: other processes used at most 1.0 cores before and after it. A row from an earlier run says when it was measured, and at which commit. +Measured on Apple M4 Pro (12 logical cores) in 5 runs, from 2026-10-19 to 2026-10-20. The newest at commit `3602f17d`, OTP 29.0.3, Elixir 1.20.2: `mix defk.examples --bench --only threshold_cross --snapshot --fresh`. A quiet measurement: other processes used at most 1.0 cores before and after it. A row from an earlier run says when it was measured, and at which commit. | | Example | The job | defk vs best Elixir | vs the package's NIF | In one line | |---|---|---|---|---|---| @@ -69,8 +69,9 @@ Measured on Apple M4 Pro (12 logical cores) in 4 runs, from 2026-10-19 to 2026-1 | ✅ | [`geo_nearest`](geo_nearest.exs) (measured 2026-10-19 at `8fa900b7`) | 10 nearest of 500k locations | **3.2×** | – | 14× in one process; a grid prefilter helps both sides | | ✅ | [`numeric_loader`](numeric_loader.exs) (measured 2026-10-20 at `0d6e7c78`) | numeric CSV of 26 MB into packed f64s | **2.7×** | – | a JSON array of numbers 4.0×; a fixed number grammar, and NimbleCSV or Jason for anything else | | ✅ | [`cdcl_sat_per_call`](cdcl_sat_per_call.exs) (measured 2026-10-19 at `8fa900b7`) | one SAT solve per call, 26–28 vars | **2.6×** | 1.6× picosat | ≈1× on tiny formulas; batch the solves when one question makes several | +| ✅ | [`threshold_cross`](threshold_cross.exs) | first packet past a byte budget, 64k packets | **2.5×** | 13× Nx + EXLA `while` | EXLA costs ~40 µs a call and scales 1.9× over 48 callers; Elixir beats it up to ~1M packets | | ✅ | [`gorilla_encode`](gorilla_encode.exs) (measured 2026-10-19 at `684a3592`) | Gorilla encoding of 100-point blocks | **2.4×** | 4.2× gorilla_stream's NIF | close to the bar at this size; 4.1× at 1M points | -| ✅ | [`tape_csv`](tape_csv.exs) | CSV of 20 MB to NimbleCSV's rows, a fresh process per parse | **2.1×** | 0.38× rusty_csv | in a long-lived process 0.8–2.1× run to run; an [i64] tape of field events is slower than the cleaned copy | +| ✅ | [`tape_csv`](tape_csv.exs) (measured 2026-10-20 at `71e29777`) | CSV of 20 MB to NimbleCSV's rows, a fresh process per parse | **2.1×** | 0.38× rusty_csv | in a long-lived process 0.8–2.1× run to run; an [i64] tape of field events is slower than the cleaned copy | | ✅ | [`fuzzy_search/`](fuzzy_search) | a search service over 234k words behind Bandit | its own measurement, see its README | – | Myers on both sides; `/health` latency stays at Elixir's while a blocking NIF raises it | | 🟡 | [`batched_levenshtein`](batched_levenshtein.exs) (measured 2026-10-19 at `8fa900b7`) | one query against 234k words, 12 chunks each side | 1.5× | 13× ex_strsim per pair | Myers bit-parallel in plain Elixir is close; use it | | 🟡 | [`ws_masking`](ws_masking.exs) (measured 2026-10-19 at `8fa900b7`) | mask 125-byte WebSocket frames | 1.5× | 1.2× bandit_native | the call is the cost; 3.4× at 1 MiB, crossover 1024 B | diff --git a/examples/scoreboard.json b/examples/scoreboard.json index e222dec5..947d52f2 100644 --- a/examples/scoreboard.json +++ b/examples/scoreboard.json @@ -34,6 +34,7 @@ {"name":"rolling_stats","quiet":true,"results":[{"baseline":"optimized","cache":"stored","correct":true,"defk_ms":1.089,"defk_spread_ms":{"max":1.88,"median":1.701,"runs":5},"elixir_ms":10.252,"elixir_spread_ms":{"max":11.131,"median":10.366,"runs":5},"example":"rolling_stats","expected":"good","reason":"one pass over packed floats, no allocation per sample","speedup":9.41,"stamp":{"c":{"Rolling.Kernels":"741787c6de035e89fde5d5b14392c9f2"},"commit":"8fa900b7","defk":"0.1.0","measured_at":"2026-10-19T11:40:43Z","quiet":{"limit":1.0,"other_cpu":[0.34,0.07],"quiet":true,"waited_s":0}},"verdict":"good"}],"run":{"command":"mix defk.examples --bench --all --snapshot","commit":"8fa900b7","elixir":"1.20.2","measured_at":"2026-10-19T11:41:41Z","otp":"29.0.3","quiet_limit":1.0},"status":"ok"}, {"name":"sort_u64_keys","quiet":true,"results":[{"baseline":"optimized","cache":"stored","correct":true,"defk_ms":59.478,"defk_spread_ms":{"max":60.181,"median":60.172,"runs":5},"elixir_ms":355.392,"elixir_spread_ms":{"max":418.879,"median":369.938,"runs":5},"example":"sort_packed","expected":"good","reason":"packed in, packed out: no list is ever built on the defk side","speedup":5.98,"stamp":{"c":{"Keys.Kernels":"a49c2e57190d29d9d5d46c60ecd6535f"},"commit":"684a3592","defk":"0.1.0","measured_at":"2026-10-19T11:59:29Z","quiet":{"limit":1.0,"other_cpu":[0.12,0.04],"quiet":true,"waited_s":0}},"verdict":"good"},{"baseline":"optimized","cache":"reused","correct":true,"defk_ms":94.191,"defk_spread_ms":{"max":118.427,"median":96.442,"runs":5},"elixir_ms":362.119,"elixir_spread_ms":{"max":396.991,"median":371.298,"runs":5},"example":"sort_to_list","expected":"good","measured_at":"2026-10-19T11:41:02Z","reason":"decoding the sorted result to a list costs 40 ms, the margin shrinks to about 2.5x but holds","speedup":3.84,"stamp":{"c":{"Keys.Kernels":"a49c2e57190d29d9d5d46c60ecd6535f"},"commit":"8fa900b7","defk":"0.1.0","measured_at":"2026-10-19T11:41:02Z","quiet":{"limit":1.0,"other_cpu":[0.54,0.22],"quiet":true,"waited_s":6}},"verdict":"good"}],"run":{"command":"mix defk.examples --bench --only codecs,gorilla_encode,monte_carlo_rtp,sort_u64_keys --snapshot","commit":"684a3592","elixir":"1.20.2","measured_at":"2026-10-19T11:59:29Z","otp":"29.0.3","quiet_limit":1.0},"status":"ok"}, {"name":"tape_csv","quiet":true,"results":[{"baseline":"optimized","cache":"stored","correct":true,"crossover":"1000000 rows","defk_ms":131.174,"defk_spread_ms":{"max":133.365,"median":132.849,"runs":15},"elixir_ms":274.473,"elixir_spread_ms":{"max":279.982,"median":277.864,"runs":15},"example":"tape_csv","expected":"good","native_label":"rusty_csv 0.4.6","native_ms":49.459,"native_speedup":0.38,"native_spread_ms":{"max":52.635,"median":50.216,"runs":15},"realistic_size":"20 MB: a 200k-row customer export","reason":"the kernel cleans 20 MB in 36 ms and two :binary.split passes build the rows, with less garbage than a parser in Elixir; rusty_csv builds them in C, 2.7x faster still","speedup":2.09,"stamp":{"c":{"Tape.Kernels":"9a6a5466f9da258adf6f944d858351d0"},"commit":"71e29777","defk":"0.1.0","measured_at":"2026-10-20T13:41:22Z","quiet":{"limit":1.0,"other_cpu":[0.29,0.82],"quiet":true,"waited_s":0}},"sweep":[{"defk_ms":1.082,"elixir_ms":1.71,"size":"2000 rows","speedup":1.58},{"defk_ms":10.814,"elixir_ms":17.611,"size":"20000 rows","speedup":1.63},{"defk_ms":646.619,"elixir_ms":1524.704,"size":"1000000 rows","speedup":2.36}],"verdict":"good"}],"run":{"command":"mix defk.examples --bench --only tape_csv --snapshot --fresh","commit":"71e29777","elixir":"1.20.2","measured_at":"2026-10-20T13:41:22Z","otp":"29.0.3","quiet_limit":1.0},"status":"ok"}, + {"name":"threshold_cross","quiet":true,"results":[{"baseline":"optimized","cache":"stored","correct":true,"crossover":16384,"defk_gap_ms":{"max":2.018,"p99":2.018},"defk_ms":0.006,"defk_spread_ms":{"max":0.008,"median":0.006,"runs":15},"elixir_gap_ms":{"max":2.018,"p99":2.018},"elixir_ms":0.015,"elixir_spread_ms":{"max":0.021,"median":0.015,"runs":15},"example":"threshold_cross","expected":"good","native_gap_ms":{"max":2.003,"p99":2.003},"native_label":"Nx 0.13.1 + EXLA 0.13.1, while","native_ms":0.076,"native_speedup":12.67,"native_spread_ms":{"max":0.137,"median":0.09,"runs":15},"realistic_size":"64k packets, the budget passed at 25%","reason":"a sequential loop that stops early: the kernel runs it at C speed with a call cost of tens of ns; EXLA pays about 40 us per call and its while loop is several times slower per step","speedup":2.5,"stamp":{"c":{"Cross.Kernels":"a9b05d807cb008926d11bd6381803e21"},"commit":"3602f17d","defk":"0.1.0","measured_at":"2026-10-20T17:59:00Z","quiet":{"limit":1.0,"other_cpu":[0.98,0.95],"quiet":true,"waited_s":0}},"sweep":[{"defk_ms":0.001,"elixir_ms":0.003,"size":16384,"speedup":3.0},{"defk_ms":0.006,"elixir_ms":0.016,"size":65536,"speedup":2.67},{"defk_ms":0.102,"elixir_ms":0.262,"size":1048576,"speedup":2.57}],"verdict":"good"}],"run":{"command":"mix defk.examples --bench --only threshold_cross --snapshot --fresh","commit":"3602f17d","elixir":"1.20.2","measured_at":"2026-10-20T17:59:00Z","otp":"29.0.3","quiet_limit":1.0},"status":"ok"}, {"name":"utf8_valid","quiet":true,"results":[{"baseline":"optimized","cache":"stored","correct":true,"crossover":null,"defk_ms":26.992,"defk_spread_ms":{"max":27.922,"median":27.5,"runs":15},"elixir_ms":28.037,"elixir_spread_ms":{"max":29.813,"median":29.156,"runs":15},"example":"utf8_valid","expected":"marginal","realistic_size":"16 MB","reason":"a scalar byte DFA competes with a C BIF; :unicode.characters_to_binary is already C and the DFA is only about 1.07x ahead, so use String.valid? (a SIMD validator is a wrapper job)","speedup":1.04,"stamp":{"c":{"Utf8.Kernels":"5439915fa11707ea174a3f68e6e8af0b"},"commit":"8fa900b7","defk":"0.1.0","measured_at":"2026-10-19T11:41:05Z","quiet":{"limit":1.0,"other_cpu":[0.14,0.38],"quiet":true,"waited_s":0}},"sweep":[{"defk_ms":0.002,"elixir_ms":0.0,"size":1024,"speedup":0.0},{"defk_ms":0.102,"elixir_ms":0.057,"size":65536,"speedup":0.56},{"defk_ms":1.718,"elixir_ms":1.812,"size":1048576,"speedup":1.05},{"defk_ms":27.234,"elixir_ms":29.351,"size":16777216,"speedup":1.08}],"verdict":"marginal"}],"run":{"command":"mix defk.examples --bench --all --snapshot","commit":"8fa900b7","elixir":"1.20.2","measured_at":"2026-10-19T11:41:41Z","otp":"29.0.3","quiet_limit":1.0},"status":"ok"}, {"name":"ws_masking","quiet":true,"results":[{"baseline":"optimized","cache":"stored","correct":true,"crossover":"1024 B","defk_gap_ms":{"max":1.751,"p99":1.751},"defk_ms":1.231,"defk_spread_ms":{"max":1.369,"median":1.287,"runs":15},"elixir_gap_ms":{"max":2.008,"p99":2.008},"elixir_ms":1.793,"elixir_spread_ms":{"max":2.786,"median":2.397,"runs":15},"example":"ws_masking","expected":"marginal","native_gap_ms":{"max":8.345,"p99":8.345},"native_label":"bandit_native 0.1.2 Bandit.Native.mask/2 (Rust, byte at a time, normal scheduler)","native_ms":1.424,"native_speedup":1.16,"native_spread_ms":{"max":1.556,"median":1.461,"runs":15},"realistic_size":"125 B frames (the largest 7-bit length), 10,000 frames per timed run","reason":"a 125 B frame is ~0.13 us of kernel call against the BEAM's already-compiled 32-bit loop; use Bandit's own ws_mask/2 for small frames","speedup":1.46,"stamp":{"c":{"Ws.Defk":"aa97b7005e38e3435e015619bd5af1e8"},"commit":"8fa900b7","defk":"0.1.0","measured_at":"2026-10-19T11:41:21Z","quiet":{"limit":1.0,"other_cpu":[0.34,0.11],"quiet":true,"waited_s":8}},"sweep":[{"defk_ms":1.226,"elixir_ms":1.626,"size":"125 B","speedup":1.33},{"defk_ms":2.176,"elixir_ms":5.996,"size":"1024 B","speedup":2.76},{"defk_ms":1.35,"elixir_ms":4.582,"size":"8192 B","speedup":3.39},{"defk_ms":1.222,"elixir_ms":3.95,"size":"65536 B","speedup":3.23},{"defk_ms":1.203,"elixir_ms":4.109,"size":"1048576 B","speedup":3.42},{"defk_ms":2.547,"elixir_ms":9.134,"size":"16777216 B","speedup":3.59}],"verdict":"marginal"},{"baseline":"optimized","cache":"stored","correct":true,"defk_ms":1.2,"defk_spread_ms":{"max":1.23,"median":1.209,"runs":15},"elixir_ms":4.103,"elixir_spread_ms":{"max":7.095,"median":4.194,"runs":15},"example":"ws_masking_1mib","expected":"good","native_label":"bandit_native 0.1.2 Bandit.Native.mask/2 (Rust, byte at a time, normal scheduler)","native_ms":4.215,"native_speedup":3.51,"native_spread_ms":{"max":4.418,"median":4.38,"runs":15},"realistic_size":"1 MiB frames, 8 per timed run","reason":"the vectorised kernel runs at memory bandwidth (~38 GB/s) while the best Elixir (:crypto.exor/2) and bandit_native's byte-at-a-time loop run at 2 GB/s","speedup":3.42,"stamp":{"c":{"Ws.Defk":"aa97b7005e38e3435e015619bd5af1e8"},"commit":"8fa900b7","defk":"0.1.0","measured_at":"2026-10-19T11:41:21Z","quiet":{"limit":1.0,"other_cpu":[0.11,0.06],"quiet":true,"waited_s":0}},"verdict":"good"}],"run":{"command":"mix defk.examples --bench --all --snapshot","commit":"8fa900b7","elixir":"1.20.2","measured_at":"2026-10-19T11:41:41Z","otp":"29.0.3","quiet_limit":1.0},"status":"ok"} ] diff --git a/examples/threshold_cross.exs b/examples/threshold_cross.exs index e951a279..8a7cc13e 100644 --- a/examples/threshold_cross.exs +++ b/examples/threshold_cross.exs @@ -42,10 +42,10 @@ # 1M 249 91-92 319-344 1333-1341 1896-2355 # 16M 4336-4467 1473-1478 4272-4359 24780-25650 41807-48137 # -# The report, 64k packets (realistic size: a few seconds of traffic): -# Elixir 0.017 ms, defk 0.006 ms (2.8x, good), EXLA while 0.07 ms (defk -# 11.7x). Ticker gap on one scheduler while 16M packets are summed: about -# 2 ms on every side (the floor under load): Elixir is preempted, the +# The report, 64k packets, best of 15 in four runs (the last stored, quiet): +# Elixir 0.015-0.031 ms, defk 0.006-0.009 ms (2.5-3.4x, good), EXLA while +# 0.07-0.12 ms (defk 11.7-13.2x). Ticker gap on one scheduler, 16M packets: +# about 2 ms on every side (the floor under load): Elixir is preempted, the # kernel yields, EXLA runs on a dirty scheduler. # # Crossovers: the kernel is faster than EXLA at every size, from 790x at 16