From fe722f6a43f409a4231fd0441b0a09a2e76e9330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20L=C3=BCning?= Date: Wed, 21 Oct 2026 21:22:01 +0200 Subject: [PATCH] defk-w8r.2: record the handwired study's speed lessons Five entries in docs/performance.md (2.19, 2.20, 6.43, 6.44 and a pointer in 3.1): anchor-then-verify with :binary.matches beats :re and a defk NFA, literal search ties a shift-and kernel, a backtracking regex spelling costs 8x, the NFA kernel's per-byte cost, and exact float32 through an [f32] scratch write. The GPU data point goes to docs/research/nx-boundary.md; the scope line (GPU is Nx's) is unchanged. The research note and its scripts were deleted; the ticket is the record. --- docs/performance.md | 99 ++++++++++++++++++++++++++++++++++++ docs/research/nx-boundary.md | 8 ++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/docs/performance.md b/docs/performance.md index 9c216ea3..6fcfae2a 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -750,6 +750,56 @@ appends each f64 to one binary. Building on the library's terms costs more. - **Source:** [`numeric_loader.exs`](https://github.com/DanielLuning/defk/blob/main/examples/numeric_loader.exs), `defk-9p6.21`. - **Status:** measured (the last run); the variant lines provisional. +### 2.19 Pattern search: anchor on a literal byte with `:binary.matches/2`, then verify the bytes around it + +Most patterns that look like regex work contain a literal byte: a date has +`-`, a float has `.`, a snake-case identifier has `_`. `:binary.matches/2` +finds that anchor in C, and an Elixir clause checks the few bytes around +each hit. For date, float and snake-case patterns over 100k JSON documents +this beat both `:re` and a defk bit-parallel NFA kernel by 1.5 to 2 times. +The kernel wins only where no literal anchor exists (6.43). + +- **Numbers:** 12 chunks, three patterns: anchor and verify 7.2-9.6 ms; + the defk NFA kernel 14.2-14.9 ms; `:re` 19-26 ms. +- **What did not help:** `:re` with a backtracking spelling. `([a-z]+)+x` + took 8 times as long as `[a-z]+x` for the same matches. Write the simple + form first. +- **Conditions:** M-series Mac, one run each, quiet machine. +- **Source:** `defk-w8r.2` holds the numbers. The research note + (`docs/research/handwired.md`) and its bench scripts were deleted with + their unmerged branch on 2026-10-20 and are not restored, so there is no + file to re-run. +- **Status:** measured, one run; cannot be re-checked from source. + + +```elixir +# "YYYY-MM-DD": the anchor is "-"; the digits around it are checked in Elixir +dash = :binary.compile_pattern("-") # once, e.g. in :persistent_term + +for {pos, 1} <- :binary.matches(doc, dash), + pos >= 4 and pos + 6 <= byte_size(doc), + date?(binary_part(doc, pos - 4, 10)), # <> + do: pos - 4 +``` + +### 2.20 Literal search over many documents: `:binary.matches/3` with a scope matches a kernel + +For a literal needle over 100k JSON documents packed into one blob, a +`:binary.matches/3` call per chunk with a `scope` of that chunk matched a +defk shift-and kernel. Elixir is the answer for literals; see also 3.1. + +- **Numbers:** 12 chunks: `:binary.matches/3` 6.8-8.6 ms; the shift-and + kernel 9.2-9.5 ms. +- **Conditions:** M-series Mac, one run each, quiet machine. +- **Source:** `defk-w8r.2` (the research note was deleted; see 2.19). +- **Status:** measured, one run; cannot be re-checked from source. + + +```elixir +# one packed blob, one scope per chunk; the matches are offsets into the blob +:binary.matches(blob, compiled, scope: {chunk_start, chunk_len}) +``` + --- ## 3. BIFs and OTP libraries that are already native @@ -767,6 +817,8 @@ they are reduction-counted, so they already keep the scheduler responsive. numbers single runs, noisy machine. - **Source:** [`poor_substring_search.exs`](https://github.com/DanielLuning/defk/blob/main/examples/poor_substring_search.exs) (a00f961), `defk-9p6.23`; [`string-workloads.md`](https://github.com/DanielLuning/defk/blob/main/docs/research/string-workloads.md). - **Status:** measured; research provisional. +- **Also:** `:binary.matches/3` with a `scope` over one packed blob matched + a defk shift-and kernel over 100k JSON documents (2.20). ```elixir @@ -2717,6 +2769,53 @@ else: 0))`; it now says `if first < 0 and n > 0, do: raise(ArgumentError, compiler side is `defk-9za` / `defk-b38` (6.34). - **Status:** C and report checked; not timed. +### 6.43 A bit-parallel NFA kernel costs about 3.2 ns per byte whatever the pattern + +A regex-class matcher written as a kernel (a bit-parallel NFA over byte +classes) ran at about 3.2 ns per byte for every pattern tried: the generated +C makes 18 checked helper calls per byte, and several of them operate on +values that are provably 0 or 1 (`band`/`bsr` results, `hit * (1 - ext)`). +The kernel wins only where the +pattern has no literal anchor byte; with an anchor, `:binary.matches/2` and +an Elixir check are 1.5 to 2 times faster (2.19). Which of those checks +`Defk.Facts` could prove away is `defk-w8r.3`. + +- **Numbers:** 100k JSON documents in 12 chunks: the kernel 14.2-14.9 ms + for date, float and snake-case patterns; anchor and verify in Elixir + 7.2-9.6 ms; `:re` 19-26 ms. +- **Conditions:** M-series Mac, one run each, quiet machine. +- **Source:** `defk-w8r.2` (the research note was deleted; see 2.19). +- **Status:** measured, one run; cannot be re-checked from source. + +### 6.44 Exact float32: an `[f32]` scratch write per operation is free, and 60x faster than rounding in Elixir + +defk computes in f64 and has f32 only as an array element. An f64 operation +followed by one `[f32]` write gives the float32 result bit for bit: 0 +differences in 15,000,030 results for `+`, `-`, `*`, `/` and `sqrt` against a +hardware float reference built with `-ffp-contract=off`, subnormals and ties +included. A sparse layer that rounds after every operation ran at 0.91x the +f64 time, so the write costs nothing. The same rounding in Elixir, through +`<>`, was 60x slower. + +- **What did not help:** the rounding trick `(x + 8388609) - 8388609` is the + identity in f64 (749,972 of 1M inputs differed from f32); it is exact only + with a scratch write and read after the add. +- **Numbers:** sparse layer, one gather per term: 0.378 us per item with a + rounding after every operation, 0.417 us in f64. The hand-written C float + loop took 0.069 us: the 5.5x gap is the runtime checks in the hot loop + (`defk-w8r.3`). Whether this pattern becomes a builtin is `defk-vcx.5`. +- **Conditions:** M-series Mac, one thread, one run each, quiet machine. +- **Source:** `defk-w8r.2` and `defk-vcx.5` hold the numbers; the research + note was deleted (see 2.19). +- **Status:** measured, one run; cannot be re-checked from source. + + +```elixir +# s is a one-element [f32] scratch array +s[0] = a * b # the write rounds to float32 (raises if the value does not fit) +p = s[0] # read back the rounded value as f64 +``` + --- ## 7. When a hand-written or wrapper NIF is still the answer diff --git a/docs/research/nx-boundary.md b/docs/research/nx-boundary.md index 07090c88..5a1f0019 100644 --- a/docs/research/nx-boundary.md +++ b/docs/research/nx-boundary.md @@ -250,7 +250,13 @@ normal schedulers, free the frame on process death) targets exactly that gap. - 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. + Still not measured: Evaluator and Torchx `while` costs. + GPU, one data point (`defk-w8r.2`; the handwired research note and its + scripts were deleted on 2026-10-20 and are not restored): for pattern + search over 100k JSON documents, the best CPU times here (7 to 15 ms in + 12 chunks, Elixir anchor-and-verify and a defk NFA kernel) are in the range + of handwired's WebGPU search (4.5 to 5.2 ms on its author's machine), an + order of magnitude only. The scope line stays: GPU is Nx's. - 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.