Skip to content

Perf follow-up: batched reads, stdin coalescing, iodata output - #11

Merged
nyo16 merged 4 commits into
masterfrom
perf-followup
Sep 1, 2026
Merged

nyo16 merged 4 commits into
masterfrom
perf-followup

Conversation

@nyo16

@nyo16 nyo16 commented Sep 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

Measurement-driven performance cycle from the 2026-08-31 perf reports
(perf-otp + perf-hotpath). Every phase was bench-gated: MIX_ENV=prod
medians, ≥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)

Metric Before After
16 MiB stdin as ~80-byte list elements 711 ms 30 ms (1.5× a single-binary write)
Same, lazy stream + input_buffer: 65_536 641 ms 33 ms (opt-in; default write-through unchanged)
run/2 vs stream! 64 MiB gap (output: :iodata) ~35% behind ~18% behind
All baseline metrics (spawn, throughput, stderr, 128-way) — within spread

Added

  • Process.read_batch/3 / read_stderr_batch/3 — up to max_chunks
    pipe 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/2 is unchanged.
  • :input_buffer opt (run/2, stream!/2) — opt-in coalescing for
    lazy stdin enumerables; default 0 keeps element-granular write-through
    (interactive/PTY stdin depends on it).
  • output: :iodata opt (run/2) — skips the terminal
    IO.iodata_to_binary/1 flatten (a full-size alloc+copy for large
    outputs). Default :binary unchanged.

Changed

  • Eager list :input coalesces into flat 1 MiB batches in the caller's
    process
    before any task closure captures it. Root cause of the 711 ms:
    Task.async closure capture copies a 200k-cons list (~20 ms) — and the
    run path crossed two task boundaries, paying it twice.
  • 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 — server occupancy no longer
    multiplies by parked-writer count (kill/2 during a 4×2 MiB fan-in
    replies <1 s, tested).
  • stream!/2 may emit several ≤64 KiB chunks per resource step (sizes
    and 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 --strict
    clean, --warnings-as-errors, formatted
  • Reviewed by 3 parallel specialist agents — 0 blockers; all 10 warnings
    fixed in-branch (incl. a double-coalesce copy and Linux-vacuous test
    assertions)
  • CI fixes included: 2 MiB fan-in payloads (1 MiB fit the Linux pipe
    exactly and never parked), busybox-safe parent-pid lookup, zombie-aware
    liveness probe for containerized runners, ExUnit in the dialyzer PLT

nyo16 added 4 commits August 31, 2026 21:34
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.
@nyo16
nyo16 merged commit f7788b4 into master Sep 1, 2026
15 checks passed
@nyo16
nyo16 deleted the perf-followup branch September 1, 2026 01:48
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