Skip to content

perf(gitread): join a git call's output once - #750

Merged
ThomasK33 merged 4 commits into
mainfrom
perf-gitread-run-copy
Nov 7, 2026
Merged

ThomasK33 merged 4 commits into
mainfrom
perf-gitread-run-copy

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Why

ThomasK33 asked to fix the open coder/openstorage issues and land them on main. The review of #739 measured that every budgeted git call peaks at 3 to 5 times its output: on a real serve, a listing of 31 MB raised VmHWM (peak resident memory) by 100 MB or more (see Verification). This PR removes the copy that causes it. It is not a change of #739.

gitread.Run kept git's output in a bytes.Buffer and returned Buffer.String(), which copies it. The buffer also grows by doubling, which copies it again and leaves the old arrays as garbage. The peak was the output 3 to 5 times.

What changes

  • limitedBuffer (internal/gitread/budget.go) keeps the output in chunks that never move. A chunk is a quarter of what is held (64 bytes to 8 MiB, or the size of the write when that is larger), so the unused part of the last chunk stays within a quarter of the output. String joins the chunks once, into a string of exactly the output's size. The peak is the chunks plus the string, 2 times the output.
  • Run still returns a string, still counts the output once against the allowance, and still returns the partial output with an error. A small output (a git call that prints 41 bytes) takes one more allocation of 24 bytes and 235 to 315 ns per call in BenchmarkLimitedBuffer, where the old buffer took 80 to 262 ns (8 alternating runs, load average 36 to 43, so the ranges overlap). A git process start costs 8 to 18 ms (two measurements of git rev-parse --git-dir).
  • Callers: Run is called by runGit (gitread.go), CommitsBySHA (log.go), ReadBlob (tree.go), wcScratch.git (internal/api/handlers_write_commit.go) and mergeBaseSHA (internal/api/handlers_read_query.go). Each reads the result as a string. The buffer is local to Run, and os/exec waits for its copy goroutines before Wait returns, also after WaitDelay, so nothing writes to it once Run reads it. No non-test code outside vendor imports unsafe. I changed no caller. ReadBlob (its one caller reads symlink targets in archive.go) converts the string to bytes, which is one more copy. A target can be as large as the output allowance, so a cap on its length is a separate change.
  • A plain strings.Builder (no copy in String) does not help: it grows by 1.25 times, and the copies it leaves raise the peak (table below). Chunks that never move do.
  • Tests (internal/gitread/limitedbuffer_test.go): the chunks join into what was written for odd write sizes, and after every write the buffer holds what was written in at most 80 chunks (the bound follows from the growth: 4 chunks of 64 bytes, then 1.25 times per chunk from 256 bytes to 32 MiB, so 61 is the most at the 64 MiB allowance, and the tests reach 31 to 56), an overflow is refused and kills git, the allocation of 16 MiB of output is at most 2.5 times its size (3 times or more before), 1 MiB and 16 MiB written in 32 KiB pieces cost at most 30 and 45 allocations (the code makes 20 and 33), a small output costs at most 3 allocations, also when it comes a byte at a time, the unused part of the last chunk stays within a quarter and under 8 MiB, and a budgeted Run of a 16 MiB blob allocates at most 2.5 times its size and counts it once. BenchmarkLimitedBuffer shows the copies as bytes and allocations per call.

Verification

Peak resident memory of one budgeted Run of git cat-file blob on a blob of listing-like text. A child process runs it once and reads VmHWM after resetting the peak (clear_refs). The table is the peak above the baseline of the process: the median of 7 runs, and in brackets the lowest to the highest run. The host was shared (load average 62 to 72), so read the ratios. The "before" runs move between runs (garbage collection timing), so read them as a range. "Before" is this branch's parent, da289aa2. A reviewer repeated the three sizes on this head (median of 7) and got 3.57, 4.28 and 3.26 times before and 2.06, 2.04 and 2.03 times after.

Output Before After
20,000,064 bytes 84.5 MB, 4.43 times (68.3 to 84.6 MB, 3.58 to 4.44 times) 39.6 MB, 2.07 times (39.5 to 39.7 MB)
31,000,032 bytes 94.9 MB, 3.21 times (94.6 to 95.1 MB) 60.6 MB, 2.05 times (60.3 to 60.8 MB)
45,000,060 bytes 172.5 MB, 4.02 times (140.0 to 172.5 MB, 3.26 to 4.02 times) 87.4 MB, 2.04 times (87.3 to 87.6 MB)

Two other buffers, same harness, median of 5 runs, for 20, 31 and 45 MB: a plain strings.Builder gave 3.16, 4.03 and 3.74 times (and 5.2 to 5.7 times the output in allocations). A bytes.Buffer returned with unsafe.String gave 3.42, 2.21 and 3.02 times. A reviewer measured a real serve on the #739 listing (a directory of 500,001 entries, 31.1 MB), with a fresh serve for each of 4 samples per build. The first request raised VmHWM by 132.6 to 150.1 MB (4.3 to 4.8 times) at this branch's base, c9c8ae7e, and by 63.2 to 63.5 MB (2.04 times) with this change. An earlier measurement of 2 samples read 100.7 and 100.8 MB before (3.24 times) and 63.8 and 64.4 MB after (2.05 to 2.07 times).

BenchmarkLimitedBuffer (writes of 32 KiB, then a join). 8 alternating runs of the old and the new buffer, with 400,000 iterations for 41 bytes, 300 for 1 MiB and 30 for 16 MiB, load average 36 to 43. The time is the lowest to the highest run, so the ranges overlap on this host. The bytes and allocations do not vary at the precision shown, except the old buffer's 11 to 12 allocations at 16 MiB.

Output Before After
41 bytes 80 to 262 ns, 112 B, 2 allocs 235 to 315 ns, 136 B, 3 allocs
1 MiB 1.42 to 2.33 ms, 3.11 MB, 7 allocs 0.96 to 1.90 ms, 2.30 MB, 20 allocs
16 MiB 11.4 to 20.7 ms, 50.3 MB, 11 to 12 allocs 9.8 to 13.9 ms, 34.6 MB, 33 allocs

Round 1: Codex code and security reviews of head 98ed394 were clean. Round 2 (tests that fail fast): the reviews of head 6af97ca were clean. Round 3 (this head): a verifier found that the one mutant I had called equivalent changes the allocation counts, and that some numbers here did not reproduce. This head pins that count with a test, and the numbers above are repeated measurements.

Checks on the head:

  • go build ./..., go vet -tags=openstorage_viewcheck on internal/gitread, internal/api and cmd/...: clean. golangci-lint 2.15.0 on ./internal/gitread/... and ./internal/api/...: 0 issues.
  • go test -tags=openstorage_viewcheck -count=1 -race ./internal/gitread: pass on this head (122.2 s). ./internal/api and ./cmd/openstorage passed the same command on the previous head, 6af97ca0, with main merged in (346.4 s and 44.9 s). This head changes only internal/gitread/limitedbuffer_test.go, which neither package compiles, so I did not run them again. python3 scripts/check-doc-links.py: 0 broken links.
  • Mutation check: I changed limitedBuffer 18 times, one change at a time, and ran the new tests. All 18 turned a test red. The red ones: the old bytes.Buffer with String() (3.00 times its output), a strings.Builder (5.2 times), no Grow before the join, a Grow of half the output, chunks as large as what is held, no bound on a chunk, a bound of 1 KiB, an eighth of what is held for a chunk, chunks joined in reverse, a write that reports one byte less, a refused write that does not set the overflow flag, a first chunk of 1 MiB, the output counted twice, the bytes held not counted, the count taken from the whole write, a new chunk for every write, a chunk that ignores the size of the write (the write splits into small chunks, and 1 MiB costs 50 allocations, not 20), and a smallest chunk of 1 byte (a 41-byte output written a byte at a time costs 22 allocations, not 3). The first version of the tests caught a chunk for every write and the bytes not counted only by their timeout, and the count from the whole write not at all. Now the write loops check after every write that the buffer holds what was written in at most 80 chunks, and a verifier's five mutants (a chunk for every write, bytes not counted, a count from the whole write, an eighth, a bound of 1 KiB) fail with a message within 1.3 s of test time (6.5 to 8.8 s with the compile).

Refs #739


Generated with xum • Model: anthropic:claude-sonnet-5-5 • Thinking: xhigh

Run kept the output of a budgeted git call in a bytes.Buffer and returned
Buffer.String(), which copies it. The buffer doubles by copying as well, so
one call peaked at 3 to 4 times its output (3.2 times on a 31 MB listing).

limitedBuffer now keeps the output in chunks that never move, each a quarter
of what is held (64 bytes to 8 MiB), and String joins them once into a string
of exactly the output's size. The peak is the chunks plus the string, 2 times
the output. Run still returns a string and still counts the output once
against the allowance. A plain strings.Builder was measured and is no better:
it grows by 1.25 times, and the copies it leaves raise the peak to 3 to 4
times.

Change-Id: I5c81caba0567af251ad51b7fd1e20a582717abc9
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T08:01:30.442745Z 3e8c99e Manual request
🔒 Security Review ✅ Completed 2026-11-07T08:05:33.446136Z 3e8c99e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 98ed3949d1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 98ed3949d1

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Change-Id: I6d63e76001856af8ac4e153749e6ac309810944d
Signed-off-by: Thomas Kosiewski <tk@coder.com>
A chunk for every write, bytes held that are not counted, and a count taken
from the whole write were caught only by a test that ran until its timeout
(CI would hold the job for 30 minutes and print a stack) or not at all.

The write loops of TestLimitedBufferKeepsWhatIsWritten and
TestLimitedBufferChunksStayWithinAQuarter now check after every write that
the buffer holds what was written (l.n) in at most 80 chunks. The bound
follows from the growth: 4 chunks of 64 bytes, then 1.25 times per chunk
from 256 bytes to 32 MiB, then chunks of 8 MiB; the tests hold 31 to 59.
Each mutant now fails within about a second of test time with a message.

Change-Id: Ic2da6a859f47d5d23e6f0d92ca7c36229d66433b
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 6af97ca043

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 6af97ca043

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

A chunk that ignores the size of its write split the first 32 KiB write
into many small chunks. 1 MiB then cost 50 allocations instead of 20, and
no test failed. A smallest chunk of 1 byte cost a 41-byte output written
a byte at a time 22 allocations instead of 3, and no test failed.

TestLimitedBufferAllocationsAtSize bounds the allocations of 1 MiB and
16 MiB written in 32 KiB pieces (limits 30 and 45, the code makes 20 and
33). TestLimitedBufferSmallOutputAllocations also writes the small output
a byte at a time. The maxChunks comment names only runs that a test does.

Change-Id: I34f90bb95fdb342bdebeefb2419da14cfdfc901a
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@ThomasK33

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 3e8c99e3b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 3e8c99e3b7

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@ThomasK33
ThomasK33 added this pull request to the merge queue Nov 7, 2026
Merged via the queue into main with commit df18c1b Nov 7, 2026
10 of 11 checks passed
@ThomasK33
ThomasK33 deleted the perf-gitread-run-copy branch November 7, 2026 12:04
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