Perf follow-up: batched reads, stdin coalescing, iodata output - #11
Merged
Merged
Conversation
Performance cycle from the 2026-08-31 perf reports, bench-gated per phase (MIX_ENV=prod medians, M1 Max; scenarios committed in bench/perf.exs). Added: - Process.read_batch/3 + read_stderr_batch/3: up to max_chunks pipe reads per GenServer round trip; never waits once it has data (batch ends at first EAGAIN), EOF/error after >=1 chunk deferred to the next call. All four drain consumers (run/2, stream!/2, Daemon x2) use it. - :input_buffer opt (run/2, stream!/2): opt-in coalescing for lazy stdin enumerables; default 0 keeps element-granular write-through for interactive/PTY stdin. 16 MiB of ~80-byte lines: 641 -> 33 ms. - output: :iodata opt (run/2): skips the terminal flatten; closes the run-vs-stream! 64 MiB gap from ~35% to ~18%. Changed: - Eager list :input coalesces into flat 1 MiB batches in the CALLER before any task closure captures it (closure capture of a 200k-cons list copied the whole structure twice, ~20 ms each): 711 -> 32 ms, 1.5x a single-binary write. - Operations folded from four maps to two (monitor ref rides in the pending entry); multi-writer resume passes share ONE write budget with a deduplicated :continue_writes self-send, so server occupancy no longer multiplies by parked-writer count (kill/2 during a 4x1 MiB fan-in replies <1 s, tested). - Read batching yields ~1-chunk batches on macOS (pipe capacity == read size); kept as a measured no-regression with the projected gain expected on Linux's 1 MiB shepherd-grown pipes. Also includes the previously uncommitted remainder of the 1.4.0 audit-remediation batch (c_src hardening, docs, CI) already described in CHANGELOG 1.4.0. 236 tests green x3, credo --strict clean, all baseline bench metrics within run-to-run spread.
Process-boundary copy cost, batch-size verification, and bench discipline from the perf-followup cycle (full writeups in the local .claude/solutions/ knowledge base).
- kill-during-fan-in: a 1 MiB payload fit Linux's 1 MiB shepherd-grown pipe exactly, so the first writer completed without parking and the parked-writer count never reached 4. Payloads are now 2 MiB — larger than every platform's pipe — so all four writers must park. - TEST-1 shepherd-crash: busybox ps (Alpine) supports neither -p nor ppid=; read PPid from /proc/<pid>/status on Linux, fall back to ps elsewhere (macOS).
- os_pid_alive?/1: kill -0 alone is wrong in containerized CI — an orphan whose PID 1 never reaps it stays a signalable zombie forever, hanging every 'child died' eventually/2 probe (Alpine job). A zombie has already exited, so check /proc/<pid>/stat state Z on Linux. - dialyzer: test/support calls ExUnit.Assertions.flunk/1; add :ex_unit to plt_add_apps so the PLT knows it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Measurement-driven performance cycle from the 2026-08-31 perf reports
(
perf-otp+perf-hotpath). Every phase was bench-gated:MIX_ENV=prodmedians, ≥3 runs, ~11% run-to-run spread as the noise floor, one change at
a time. Scenarios are committed in
bench/perf.exs.Also carries the previously uncommitted remainder of the 1.4.0
audit-remediation batch (c_src hardening, docs, CI) already described in
CHANGELOG 1.4.0.
Results (M1 Max, prod medians)
input_buffer: 65_536output: :iodata)Added
Process.read_batch/3/read_stderr_batch/3— up tomax_chunkspipe reads per GenServer round trip. A batch never waits once it has data
(ends at first EAGAIN); EOF/error after ≥1 chunk is deferred to the next
call. All four drain consumers (
run/2,stream!/2,Daemon×2) use it.read/2is unchanged.:input_bufferopt (run/2,stream!/2) — opt-in coalescing forlazy stdin enumerables; default
0keeps element-granular write-through(interactive/PTY stdin depends on it).
output: :iodataopt (run/2) — skips the terminalIO.iodata_to_binary/1flatten (a full-size alloc+copy for largeoutputs). Default
:binaryunchanged.Changed
:inputcoalesces into flat 1 MiB batches in the caller'sprocess before any task closure captures it. Root cause of the 711 ms:
Task.asyncclosure capture copies a 200k-cons list (~20 ms) — and therun path crossed two task boundaries, paying it twice.
Operationsfolded from four maps to two (monitor ref rides in thepending entry); multi-writer resume passes share one write budget with
a deduplicated
:continue_writesself-send — server occupancy no longermultiplies by parked-writer count (
kill/2during a 4×2 MiB fan-inreplies <1 s, tested).
stream!/2may emit several ≤64 KiB chunks per resource step (sizesand ordering unchanged; noted in CHANGELOG as the only observable timing
change).
Platform note
Read batching yields ~1-chunk batches on macOS (pipe capacity = read size;
probed: 941 calls / 1024 chunks). Kept on measured no-regression (−3–7% on
three metrics, tighter variance); the projected +30–40% applies to Linux's
1 MiB shepherd-grown pipes.
Verification
mix test×3 green (236 tests, 4 properties),mix credo --strictclean,
--warnings-as-errors, formattedfixed in-branch (incl. a double-coalesce copy and Linux-vacuous test
assertions)
exactly and never parked), busybox-safe parent-pid lookup, zombie-aware
liveness probe for containerized runners, ExUnit in the dialyzer PLT