Size the meta pipe, decouple chunk size from it, and take work off the source loop - #5
Merged
Conversation
…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
There was a problem hiding this comment.
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
localtapbenchmarking 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
hasEmitandhasPipeline, butcollector.summaryonly renders those metrics inside thelen(deliveries) > 0branch. Consequently localtap reports "no emit stamps" and hides thet_emit/pipelinemeasurements 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
localtapmode is accepted here, but the default validation error below still says the mode must beloopback, remote or local. An invalid mode now produces an error that omits a valid option; includelocaltapin 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
PipeSourcepasses them through as a nonzero request andsetPipeSizetreatswant <= 0as “do not resize”; the daemon therefore silently runs with the kernel's smaller default instead of rejecting an invalid capacity. ValidatepipeBytes < 0during 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
pipeBytesis silently treated as "do not resize" becauseF_SETPIPE_SZis only called whenwant > 0; both new CLI parsers accept--pipe-size -1and 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-sizecurrently parses successfully and reaches this branch;setPipeSizeskips 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 callPut, 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 verifiesGetreturns 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.
| n, rerr := em.Body.Read(buf) | ||
| if n > 0 { | ||
| assembly = append(assembly, buf[:n]...) | ||
| chunks++ |
Comment on lines
+105
to
+106
| fs.IntVar(&o.pipeBytes, "pipe-size", server.DefaultPipeBytes, | ||
| "localtap: kernel pipe capacity in bytes") |
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 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) | ||
| } |
| assembly = make([]byte, 0, em.Size) | ||
| } | ||
| hasher.Reset() | ||
| parts = parts[:0] |
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 |
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.
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 262144was 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-sizeon corestreamd (default 1 MiB), andfillChunktops 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.PutPartsretains the chunks the arena already holds, instead of building a second complete 14.48 MiB copy per ledger purely to handPutone contiguous slice.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.setPipeSizegoes throughSyscallConn, notFd:Fdtakes the file out of the runtime poller and silently disablesSetReadDeadline, which is the mechanism a cancelled context uses to unblock a parked read. The first version usedFdand 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-sizenow 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