Skip to content

Size the meta pipe, decouple chunk size from it, and take work off the source loop - #5

Merged
tamirms merged 2 commits into
mainfrom
pipe-and-loop
Aug 22, 2026
Merged

Size the meta pipe, decouple chunk size from it, and take work off the source loop#5
tamirms merged 2 commits into
mainfrom
pipe-and-loop

Conversation

@tamirms

@tamirms tamirms commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

The bug this fixes

A read cannot return more than the pipe holds, and the source framed one chunk per read — so chunk size was really a function of pipe size. Against the 64 KiB default, a 14.48 MiB ledger shipped as 234 chunks of 64,926 bytes instead of 58 of 262,144, quadrupling the per-chunk cost on both ends: compression frames, WebSocket frames, decode calls, scheduling, assembly.

That is exactly the "~234 chunks per ledger" the two-box run reported and attributed to Core's burst pattern. It reproduces here, and the mean chunk size names the real culprit: -chunk-size 262144 was silently a no-op on the real-Core path.

It also explains the 18 raw fallbacks seen during Core's setup burst — 4× the chunk rate is 4× the pressure on the encoder pool.

Changes

  • -pipe-size on corestreamd (default 1 MiB), and fillChunk tops a chunk up across reads so chunk size no longer tracks pipe size. Real Core now produces 58 chunks of 261,941 bytes with the pipe left at the cache-friendly 64 KiB — matching the synthetic source exactly.
  • store.PutParts retains the chunks the arena already holds, instead of building a second complete 14.48 MiB copy per ledger purely to hand Put one contiguous slice.
  • A run-scoped hasher goroutine overlaps the ~1.4 ms ledger checksum with reading rather than running it between two pipe reads.
  • benchrunner -mode localtap — the same pipe tap, same Core, no network — so the remote path's penalty is a difference against a real local baseline at its own best setting rather than against zero.

setPipeSize goes through SyscallConn, not Fd: Fd takes the file out of the runtime poller and silently disables SetReadDeadline, which is the mechanism a cancelled context uses to unblock a parked read. The first version used Fd and reintroduced the shutdown hang fixed in #4; the test written for that hang caught it immediately.

Honest accounting

This is not a latency win. Total per-ledger went 9.28 → 9.11 ms p50, against the ~2.9 ms the removed copy and hash would suggest. That work was already hidden in pipe-wait time — the loop spends most of its life blocked on Core, so filling the gaps cost nothing and emptying them frees nothing.

The case for merging is correctness and instrumentation: -chunk-size now means what it says, 14.5 MiB less copying and allocation per ledger, and a local baseline that can actually be measured against.

🤖 Generated with Claude Code

https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou

Tamir Sen and others added 2 commits August 22, 2026 09:18
…eader

A read cannot return more than the pipe holds, and the source framed one chunk
per read — so the CHUNK size was really a function of the PIPE size. Against
the 64 KiB default a 14.48 MiB ledger shipped as 234 chunks of 64,926 bytes
instead of 58 of 262,144, quadrupling the per-chunk cost on both ends:
compression frames, WebSocket frames, decode calls, scheduling, assembly.
That is the 234-chunks-per-ledger the two-box run reported and attributed to
core's burst pattern; it reproduces exactly here, and the mean chunk size
names the real culprit.

corestreamd now asks for a capacity (-pipe-size, default 1 MiB). The SDK does
not do this either, so a local captive core runs on the same 64 KiB default —
worth an upstream one-liner in cmd_posix.go, since a bare reader measured
5.80 -> 4.73 ms per ledger just from the larger pipe.

setPipeSize goes through SyscallConn rather than Fd. Fd takes the file out of
the runtime poller and puts it in blocking mode, which silently disables
SetReadDeadline — the mechanism a cancelled context uses to unblock a parked
read. The first version used Fd and reintroduced the shutdown hang fixed in
the previous commit; the test written for that hang caught it immediately.

benchrunner grows -mode localtap: the same pipe tap, same core, no network,
no compression. A local consumer HAS the ledger once the last byte is read, so
there is nothing after emission to measure — which is the point. It makes the
remote path's penalty a difference against a real local baseline at its own
best setting, rather than against zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou
The loop reading core's meta pipe sets the emission window, and whatever it
does between reads is time core spends blocked on a full pipe. Measured
against a bare reader on the same pipe and the same ledgers, the relay
stretched that window from 4.73 ms to 7.85 ms, so what runs there is the
schedule, not bookkeeping.

Three things it no longer does:

The ledger was held twice. The arena already contains every chunk's bytes, and
`assembly` built a second complete copy of them, 14.48 MiB of memcpy per
ledger, purely so store.Put could be handed one contiguous slice. Put now has
a vectored form and retains the chunks as they are.

The checksum ran inline. Hashing 14.48 MiB costs ~1.4 ms and landed between
two pipe reads; a run-scoped hasher goroutine overlaps it with reading
instead. sum() fences before the next ledger reuses the arena, and the test
pins both the fold order and that fence.

Chunks were framed at whatever one read returned. fillChunk tops a chunk up
across reads, so chunk size no longer tracks pipe size: with the pipe left at
the cache-friendly 64 KiB, real core now produces 58 chunks of 261,941 bytes,
matching the synthetic source exactly, where it produced 234 of 64,926.

Honest accounting of what this bought: total per-ledger 9.28 -> 9.11 ms p50,
against the ~2.9 ms the removed copy and hash would suggest. That work was
already hidden in pipe-wait time — the loop spends most of its life blocked on
core, so filling the gaps cost nothing and emptying them frees nothing. The
changes stand on their own (14.5 MiB less copying and allocation per ledger,
and chunk size that means what it says) but they are not the lever, and the
remote-vs-local gap is still ~4.4 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou
Copilot AI lite review requested due to automatic review settings August 22, 2026 12:00
@tamirms
tamirms merged commit 24c170e into main Aug 22, 2026
1 check passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR decouples chunk size from pipe capacity, reduces ledger copying, overlaps hashing, and adds local pipe benchmarking.

Changes:

  • Adds configurable pipe sizing and multi-read chunk filling.
  • Retains ledger parts and hashes asynchronously.
  • Adds localtap benchmarking and platform-specific pipe support.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 10 comments.

Show a summary per file
File Final review comments
internal/store/store.go Nit (2 votes): separate the PutParts documentation so Put remains correctly documented.
internal/server/server.go Moderate (2 votes): clear reused parts entries to avoid retaining prior ledger arenas.
internal/server/pipesource.go Nits (4 and 2 votes): update comments to explain pipe backpressure separately from chunk sizing.
internal/server/pipesource_test.go Critical (4 votes): make the pipe-size assertion conditional on Linux or the supported implementation.
internal/server/pipesize_other.go No final comments.
internal/server/pipesize_linux.go Nit (2 votes): update the outdated comment about pipe capacity determining chunk count.
internal/server/ledgerwork.go No final comments.
internal/server/ledgerwork_test.go No final comments.
go.mod No final comments.
cmd/corestreamd/main.go Nit (3 votes): revise the -pipe-size help text to describe buffering/read capacity.
cmd/benchrunner/main.go Moderate (2 votes): do not count local reads as protocol chunks. Moderate (2 votes): reject negative pipe capacities. Nit (2 votes): document the new localtap mode and flags.
Suppressed comments (6)

cmd/benchrunner/main.go:435

  • This mode records hasEmit and hasPipeline, but collector.summary only renders those metrics inside the len(deliveries) > 0 branch. Consequently localtap reports "no emit stamps" and hides the t_emit/pipeline measurements it was added to provide. Update the summary path to render these source-local metrics even when delivery is intentionally absent.
			emit: done.Sub(start), hasEmit: true,
			pipeline: done.Sub(start), hasPipeline: true,

cmd/benchrunner/main.go:146

  • The new localtap mode is accepted here, but the default validation error below still says the mode must be loopback, remote or local. An invalid mode now produces an error that omits a valid option; include localtap in that message.
	case "localtap":
		if o.pipeCmd == "" {
			return o, errors.New("--pipe-cmd is required in localtap mode")

cmd/corestreamd/main.go:97

  • Negative values are accepted by this new flag, but PipeSource passes them through as a nonzero request and setPipeSize treats want <= 0 as “do not resize”; the daemon therefore silently runs with the kernel's smaller default instead of rejecting an invalid capacity. Validate pipeBytes < 0 during flag parsing (while preserving the documented zero/default behavior).
	fs.IntVar(&o.pipeBytes, "pipe-size", server.DefaultPipeBytes,
		"kernel pipe capacity for -source pipe, in bytes; bounds how much one read returns and so how large a chunk gets")

internal/server/pipesize_linux.go:41

  • A negative pipeBytes is silently treated as "do not resize" because F_SETPIPE_SZ is only called when want > 0; both new CLI parsers accept --pipe-size -1 and then run with an unspecified capacity. Reject negative values before starting the source (while retaining the documented best-effort handling for positive kernel refusals) so an invalid benchmark setting cannot silently change the measurement.
	if cerr := rc.Control(func(fd uintptr) {
		if want > 0 {
			if _, e := unix.FcntlInt(fd, unix.F_SETPIPE_SZ, want); e != nil {
				opErr = e
			}
		}

internal/server/pipesource.go:80

  • A negative --pipe-size currently parses successfully and reaches this branch; setPipeSize skips resizing for any non-positive request, so the command silently uses the OS default instead of the requested capacity. Reject negative values here (zero can remain the default sentinel) so invalid configuration is reported rather than ignored.
		want := p.pipeBytes
		if want == 0 {
			want = DefaultPipeBytes
		}
		if _, err := setPipeSize(r, want); err != nil {

internal/store/store.go:179

  • The server now uses PutParts, but the store tests only call Put, which wraps a single slice and never exercises this new multi-part write loop. Add a store test that writes several parts, reopens the store, and verifies Get returns their exact concatenation; otherwise ordering/partial-piece regressions can pass unnoticed.
	for _, part := range parts {
		if _, werr := f.Write(part); werr != nil {
			f.Close()
			return fmt.Errorf("store: write ledger %d: %w", seq, werr)
		}

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cmd/benchrunner/main.go
n, rerr := em.Body.Read(buf)
if n > 0 {
assembly = append(assembly, buf[:n]...)
chunks++
Comment thread cmd/benchrunner/main.go
Comment on lines +105 to +106
fs.IntVar(&o.pipeBytes, "pipe-size", server.DefaultPipeBytes,
"localtap: kernel pipe capacity in bytes")
Comment thread cmd/benchrunner/main.go
Comment on lines 80 to +81
fs.SetOutput(out)
fs.StringVar(&o.mode, "mode", "loopback", "loopback|remote|local")
fs.StringVar(&o.mode, "mode", "loopback", "loopback|remote|local|localtap")
Comment thread cmd/corestreamd/main.go
Comment on lines +96 to +97
fs.IntVar(&o.pipeBytes, "pipe-size", server.DefaultPipeBytes,
"kernel pipe capacity for -source pipe, in bytes; bounds how much one read returns and so how large a chunk gets")
Comment on lines +14 to +18
// This matters more than it looks. A read can never return more than the pipe
// holds, and the source frames one chunk per read — so with the 64 KiB default
// a 14.48 MiB ledger ships as ~232 undersized chunks instead of ~58 full ones,
// quadrupling every per-chunk cost on both ends: compression frames, WebSocket
// frames, decode calls, scheduling, assembly.
Comment on lines +43 to +45
// pipeBytes is the kernel pipe capacity to ask for. It bounds what one
// read can return, and the source frames one chunk per read, so it is
// really the chunk-size knob for this source. Zero keeps the default.
Comment on lines +71 to +73
// Sizing the pipe before the child inherits the write end: capacity
// belongs to the pipe, not to an end, so this governs how much core
// can hand over per read — and therefore how large a chunk gets. A
Comment on lines +289 to +296
before, err := setPipeSize(r, 0)
if err != nil {
t.Skipf("pipe sizing unavailable here: %v", err)
}
got, err := setPipeSize(r, DefaultPipeBytes)
if err != nil {
t.Skipf("kernel refused %d bytes (pipe-max-size?): %v", DefaultPipeBytes, err)
}
Comment thread internal/server/server.go
assembly = make([]byte, 0, em.Size)
}
hasher.Reset()
parts = parts[:0]
Comment thread internal/store/store.go
Comment on lines 124 to +125
// wrap, and accepting it would let a wrapped ring look contiguous.
// PutParts retains a ledger held as consecutive pieces, without asking the
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.

2 participants