Skip to content

defk-25s: break down the per-call cost; a cheaper slice clock on Apple platforms - #78

Merged
DanielLuning merged 2 commits into
mainfrom
defk-25s/per-call-cost
Oct 22, 2026
Merged

DanielLuning merged 2 commits into
mainfrom
defk-25s/per-call-cost

Conversation

@DanielLuning

Copy link
Copy Markdown
Owner

What changed

  • bench/call_cost.exs and bench/call_cost_nif.c: the base cost of a kernel call, stage by stage, measured on the generated C itself. The bench module's C is varied with string replacements (no sigsetjmp, no fault-containment stores, no entry clock read, no slice init), each variant built and loaded as its own library, against a bare-NIF floor.
  • priv/defk_runtime.h: the slice clock is defk_now_us. On Apple platforms it reads mach_absolute_time, converted with the timebase read once per library at load; other platforms keep enif_monotonic_time. The forced-yield test path still reads the ERTS clock once per forced yield (see below).
  • Docs: docs/performance.md 5.13 holds the breakdown; 5.1, 4.9 and 8.2 point at it; docs/project.md has a dated line under the results snapshot; docs/beam-nifs.md names the clock. The four small-input rows are re-measured and stored (examples/scoreboard.json, README tables regenerated).

Results

Median of 9 interleaved rounds of 1M calls, minus the empty loop, M-series Mac, quiet (other processes at 0.1-0.8 cores):

call before after
Elixir remote call 0.2 ns
bare NIF / plain NIF add2 / add2 + sigsetjmp 3.7 / 6.2 / 7.7 ns
defk add/2 (no loop) 11.5 ns 10.7-11.0 ns
yielding kernel, one trip, NIF only 33.4 ns 12.4-12.6 ns
yielding kernel through its function (hint BIF) 37.3 ns 21.2-21.6 ns
enif_monotonic_time / clock_gettime / mach_absolute_time over a bare NIF 26.7 / 15.6 / 4.5 ns

The entry clock read was 22 ns of a tiny yielding call. A kernel without a loop adds about 5 ns to a plain NIF that decodes the same terms (the sigsetjmp 2.5, the fault-containment stores 1.2, the rest 1.6): nothing worth cutting there. The hint BIF (about 9 ns) stays: it is what spreads short calls (4.9).

Stored rows (quiet, 2026-10-19 -> now): ws_masking defk 1.231 -> 1.129 ms per 10,000 frames (123 -> 113 ns a frame; 1.46x -> 1.47x because the Elixir side also measured faster this session), geohash_crc one point per call 1.953 -> 1.657 ms (1.29x -> 1.58x), poor_tiny_calls 53 -> 45 ns a call (no loop, so nothing changed on its path: session spread), utf8_valid unchanged. bench/tick_gap.exs on one scheduler: yielding kernel p99 2.26 ms, worst 2.27-2.44 ms, the same as the snapshot's 2.25-2.3.

Found on the way: an ERTS clock read stores the time in the scheduler's data (erts_get_monotonic_time sets last_monotonic_time and resets check_time_reds), and the scheduler bumps its timer wheel from that stored time. Forced yields charge no reductions, so with the cheap clock alone a test process that slept 1 ms on the kernel's scheduler woke only when the kernel ended (bitstring_test, "a process killed mid-build" timed out). The forced path now reads the ERTS clock once per forced yield, as the old entry read did at every re-entry. A normal yield charges reductions through enif_consume_timeslice, so the scheduler reads its own clock there; the tick-gap numbers above confirm it.

How it was checked

mix test twice after the header changes (2305 passed; the one failure above found and fixed between them), mix ci on the M-series Mac (running at the time of opening; the result follows as a comment), bench/tick_gap.exs, and the four examples through mix defk.examples --bench --only ... --snapshot. The stored rows' stamps name the measured commit (0377111); the second commit is docs wording only.

  • Touches a promised surface? The runtime contract's promise (a kernel yields, the slice length) is unchanged; the clock behind it is. A CHANGELOG.md entry says so under "no promise changes", added for visibility: drop it if the changelog is for promises only.
  • Touches test/stability/? No.

…e platforms

bench/call_cost.exs measures a kernel call stage by stage on the generated
C itself (variants of the bench module's C, each its own library) against a
bare NIF floor (bench/call_cost_nif.c). A kernel without a loop costs
11-13 ns, 6 of them the BEAM's NIF call and the terms; a yielding kernel
cost 37 ns, 22 of them the ERTS clock read at entry.

The slice clock (defk_now_us) now reads mach_absolute_time on Apple
platforms, converted with the timebase read once per library at load;
other platforms keep enif_monotonic_time. A tiny yielding call is 21 ns.
The forced-yield test path still reads the ERTS clock once per yield: that
read advances the scheduler's last_monotonic_time, which its timer wheel
needs, and forced yields charge no reductions.

The small-input rows are re-measured and stored: ws_masking 1.5x -> 1.77x,
geohash one point per call 1.3x -> 1.62x; poor_tiny_calls and utf8_valid
within noise. docs/performance.md 5.13 holds the breakdown; 5.1, 4.9, 8.2,
docs/project.md and docs/beam-nifs.md point at it.
DanielLuning pushed a commit that referenced this pull request Oct 21, 2026
…cost, worktree ../defk-25s). Breakdown in docs/performance.md 5.13: a kernel without a loop 11-13 ns (6 of it the BEAM's NIF call), a yielding kernel 37 -> 21 ns with the mach_absolute_time slice clock on Apple platforms. Four small-input rows re-measured and stored. Local mix ci was still running when the PR opened; its result goes on the PR. Waits for the owner's merge."
@DanielLuning

Copy link
Copy Markdown
Owner Author

Local mix ci on the M-series Mac passed after the PR opened: 2305 passed (34 doctests, 906 properties, 1365 tests), every generated region up to date, exit 0.

@DanielLuning
DanielLuning merged commit d0a23f8 into main Oct 22, 2026
2 checks passed
@DanielLuning
DanielLuning deleted the defk-25s/per-call-cost branch October 22, 2026 08:44
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