Skip to content

Cut TimeoutRecoveryReader CPU and syscalls for get_multi of small values - #88

Merged
nherson merged 2 commits into
mainfrom
nherson/recovery-get-multi-small
Oct 22, 2026
Merged

nherson merged 2 commits into
mainfrom
nherson/recovery-get-multi-small

Conversation

@nherson

@nherson nherson commented Oct 22, 2026

Copy link
Copy Markdown

Problem

With exp_async_timeout_recovery, a get_multi of many small values costs more CPU than the default read path. #87 put this down to Ruby-level line parsing. The async_get benchmark target shows it over TCP (40 × 4 KB, Ruby 3.4, YJIT, under Sync):

default recovery
main 5.68k i/s 5.20k i/s

The perf profile points elsewhere. Without YJIT, for 40 × 12 KB values, three costs grow with the bytes read, not with the number of values:

  • coderange_scan, 7.2% of samples. 3.9% comes from byteindex. ConnectionManager passes UTF-8 separator literals and the buffer is binary, so rb_enc_check scans the whole buffer's coderange after every fill. The other 3.3% comes from String#<<, which scans each appended chunk.
  • str_make_independent_expand, 3.2% (memcpy). data << @buffer.byteslice(@offset, available) takes a slice that runs to the end of @buffer. Such a slice shares @buffer's memory, so the next read_nonblock into @buffer first copies the whole buffer.
  • 8 KB reads. A reply of 40 × 4 KB takes 22 reads.

A reply's first read also always returns :wait_readable, because it comes straight after the request's write. A 1 KB get therefore takes 4 syscalls (recvfrom, ppoll, recvfrom, write) where the default path takes 3.

Change

  • Binary separators. ConnectionManager passes TimeoutRecoveryReader::LF and ::CRLF, which are binary, so byteindex skips the scan.
  • No scans or shared slices when appending. append uses String#append_as_bytes on Ruby 3.4+, which skips the coderange scan and keeps the capacity the value string was created with. Older Rubies keep <<. The buffered start of a value is copied with unpack1('a*', offset:), which copies only those bytes and never shares @buffer. I tried the five-argument bytesplice first. It avoids both costs too, but it reallocates the destination to its exact new size on every call, which threw away the presized capacity and made large gets ~25% slower.
  • One buffer, 64 KiB reads. Reads into the buffer start at 8 KiB and grow to 64 KiB once one comes back full. Large values now go through this buffer, so the direct-read path for values of 8 KB or more and its separate 64 KiB scratch string are gone. Retained memory is at most 64 KiB per connection, plus any partial line carried over a read, down from 8 KiB + 64 KiB.
  • Wait before a reply's first read. ConnectionManager#start_request! calls reader.expect_reply for flagged requests. On plain sockets the first read of the reply waits first, through the same wait_for (a scheduler wait with the socket timeout, then the zero-timeout poll). TLS sockets still read first, because OpenSSL can hold decrypted bytes that the socket's readiness doesn't show. Later reads in a reply still read first.

Large values now land in the buffer, so a read can take bytes past the end of its value: the trailing \r\n and the next lines of the same reply. pending? still counts exactly the bytes taken but not returned. A reply can't contain bytes past its own end, so finish_request!'s check is unaffected. I dropped the fuzz test's "large read took bytes past its end" assertion and its scratch-size check, and kept the pending? consistency check.

Results

Linux arm64 (Docker on Apple Silicon), Ruby 3.4.11 with YJIT, glibc malloc, memcached 1.6.38 on a UNIX socket, async 2.46 / io-event 1.22 with EPoll. Raw values, socket_timeout: 0.05. Each figure is the median of 3 runs that alternate between main and this branch, and each run is the median of 3 × 3000 iterations.

CPU µs per op (wall µs in parentheses):

case default recovery, main recovery, this PR
get 1 KB 14 (43) 15 (43) 14 (38)
get 512 KB 120 (197) 96 (163) 81 (151)
get 2 MB 426 (655) 331 (532) 255 (486)
get_multi 40 × 100 B 95 (130) 92 (126) 92 (126)
get_multi 400 × 100 B 707 (712) 708 (712) 673 (678)
get_multi 40 × 1 KB 102 (138) 111 (146) 93 (129)
get_multi 40 × 4 KB 136 (174) 163 (200) 118 (156)
get_multi 40 × 12 KB 206 (274) 261 (302) 164 (204)
get_multi 20 × 64 KB 333 (476) 276 (416) 266 (382)

Under Sync (EPoll):

case default recovery, main recovery, this PR
get 1 KB 22 (47) 19 (44) 20 (45)
get 512 KB 161 (215) 102 (154) 97 (162)
get 2 MB 599 (760) 343 (500) 303 (497)
get_multi 40 × 100 B 98 (130) 92 (122) 91 (123)
get_multi 400 × 100 B 728 (732) 718 (722) 659 (662)
get_multi 40 × 1 KB 112 (144) 117 (150) 99 (131)
get_multi 40 × 4 KB 153 (187) 167 (200) 124 (158)
get_multi 40 × 12 KB 239 (276) 270 (307) 175 (208)
get_multi 20 × 64 KB 420 (510) 290 (401) 280 (374)

Syscalls per op, counted with perf stat -e raw_syscalls:sys_enter (2000 iterations minus 0, divided by 2000). I didn't use strace here: it slows the client enough that replies are always waiting, which hides the read that returns :wait_readable.

case default recovery, main recovery, this PR under Sync: default / main / this PR
get 1 KB 3.1 4.1 3.1 7.1 / 5.1 / 4.1
get_multi 40 × 100 B 3.7 4.6 3.6 7.4 / 5.4 / 4.4
get_multi 40 × 1 KB 13.7 9.7 3.7 17.4 / 10.4 / 4.4
get_multi 40 × 4 KB 45.3 25.0 6.1 47.4 / 25.5 / 6.4
get_multi 40 × 12 KB 128.2 65.4 12.8 127.8 / 65.7 / 11.8
get_multi 20 × 64 KB 327.0 57.9 34.4 344.7 / 58.9 / 38.4
get 512 KB 131.6 18.5 15.2 141.5 / 17.7 / 16.3
get 2 MB 519.5 57.2 56.9 550.9 / 58.4 / 59.1

BENCH_TARGET=async_get bin/benchmark (TCP, now with a 40 × 4 KB get_multi):

default recovery
get 700 KB, main 5.23k i/s 7.90k i/s
get 700 KB, this PR 5.23k i/s 9.44k i/s
get_multi 40 × 4 KB, main 5.68k i/s 5.20k i/s
get_multi 40 × 4 KB, this PR 5.72k i/s 6.69k i/s

Caveats:

  • For large single gets, glibc's heap trimming swamps the difference between the branches. Both copy a large value socket → buffer → value (this branch copies the first ≤ 64 KiB once more, through unpack1). What differs is the allocation pattern around the per-value string, and so how often glibc trims and regrows its heap. A loop of 512 KB gets under Sync costs this branch ~10–20% more CPU than main with glibc's defaults (~40 page faults per get against ~9). With MALLOC_TRIM_THRESHOLD_=268435456 it costs ~35% less (main goes to ~128 faults per get). Under jemalloc the two are within ±3% for 512 KB and 2 MB. The get_multi gains hold under both glibc and jemalloc (−17% to −24% CPU at 40 × 4 KB under jemalloc).
  • On macOS (Ruby 4.0, no scheduler), get_multi of 40 × 3 KB / 40 × 12 KB costs 15% / 27% less CPU than main. A 1 KB get costs ~1.5 µs more (10 µs against 8.5 µs), although it makes one syscall fewer. I couldn't trace syscalls there to find out why.
  • Ruby 3.2 and 3.3 still pay <<'s coderange scan, because append_as_bytes doesn't exist there. They get the other changes.

Tests

  • Many small values: a reply of 40 × ~4 KB values takes at most 5 socket reads. This fails on main with 21.
  • Reply wait: after expect_reply, the first read of a delayed reply never returns :wait_readable. This fails on main.
  • Buffer bound: the buffer stays within LARGE_READ_SIZE after a 1 MB read. This replaces the 8 KiB bound.
  • Mid-value timeout: the test where a wait in the middle of a large value reports a timeout now lets the first wait pass through, so its timed-out wait still falls mid-value.
  • Fuzz: removed the "large read took bytes past its end" assertion and the scratch-size check, and kept the pending? consistency check.

Runs:

  • macOS, Ruby 4.0.7 (KQueue, Select): 660 runs, 0 failures. test_rack_session.rb was excluded because cgi/cookie is gone in Ruby 4.0.
  • Linux, Ruby 3.4.11, EPoll and URing: 701 runs, 0 failures. The 10 errors are all toxiproxy tests, because toxiproxy wasn't running.
  • The reader, fuzz, and recovery integration tests ran 13 more times with FUZZ_ITERATIONS=24 under EPoll and URing.
  • Ruby 3.2 (the << fallback): reader, fuzz, recovery integration, and ConnectionManager tests pass.
  • RuboCop 1.92 reports no offenses in the changed files.

- Pass binary separators to byteindex so it no longer scans the buffer's coderange after every fill.
- Append with String#append_as_bytes (Ruby 3.4+), which skips the coderange scan String#<< does, and
  copy the buffered start of a value with unpack1 so the buffer never shares its memory with a value
  and gets copied on the next read.
- Grow buffer reads from 8 KiB to 64 KiB once a reply outgrows 8 KiB, and read large values through
  the same buffer instead of a separate 64 KiB scratch string.
- Wait before a reply's first read on plain sockets instead of reading, getting :wait_readable, and
  then waiting.
- Add a 40 x 4 KB get_multi to the async_get benchmark target.
@nherson nherson self-assigned this Oct 22, 2026

@mrattle mrattle 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.

This looks good to me and will be a performance improvement as-is, but my robot friend flagged a bit more juice that you could squeeze here. Might be worth checking against your benchmarks before finalizing this.

Comment on lines +51 to +57
data = String.new(capacity: count, encoding: Encoding::BINARY)
data << @buffer.byteslice(@offset, available) if available.positive?
# A byteslice that runs to the end of @buffer shares its memory, and the next fill would then
# copy the whole buffer before overwriting it. unpack1 copies just the bytes asked for.

@mrattle mrattle Oct 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Robot suggestion 1:

A value that ends exactly at the buffer's end shares the 64 KiB buffer.

consume → byteslice shares memory with @buffer whenever the slice runs to the buffer's end. Two things follow:

  • The returned value keeps the whole 64 KiB buffer alive for as long as the caller holds it.
  • The next read_nonblock into @buffer first copies the whole buffer.

This already happens on main, but with an 8 KiB buffer. This PR makes it 64 KiB.

Measured on Ruby 4.0.7 with 6,400 raw values kept, 400 of which ended on a read boundary. RSS grew by 56 MiB with this PR's existing reader and by 27 MiB with the fix. On a reply built so that every read ends at a value's end, the fix was 12.9% faster, and it made no measurable difference on get_multi of 40 × 4 KB or 400 × 100 B.

Using > sends the exact-end case through the existing unpack1 copy path. It adds no work on the common path:

      def read(count)
        available = @buffer.bytesize - @offset
-        return consume(count) if available >= count
+        # A slice that runs to the end of @buffer shares it; see below.
+        return consume(count) if available > count

        data = String.new(capacity: count, encoding: Encoding::BINARY)

The comment at :56-57 already explains why, so it could move up here instead of adding a new one.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Applied in 084e7d1, in consume rather than with >: when a slice would run to the end of @buffer, consume returns tail, which copies it with unpack1 and consumes it. That covers read and gets, and copies the value once instead of through a temp and then into a presized string. read uses the same tail for a value's buffered start, and the comment moved there.

Comment on lines 112 to 120
def fill
partial = @buffer.byteslice(@offset, @buffer.bytesize - @offset) if pending?
filled = !read_partial(CHUNK_SIZE, @buffer).nil?
filled = !read_partial(@read_size, @buffer).nil?
@read_size = LARGE_READ_SIZE if @buffer.bytesize == @read_size
@buffer.clear unless filled
@offset = 0
@buffer.prepend(partial) if partial
filled
end

@mrattle mrattle Oct 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Robot suggestion 2:

The leftover partial line makes the next read copy the whole buffer.

When a line is split across a read, fill saves the leftover with a slice that runs to the buffer's end. For leftovers of about 20 bytes or more, that slice shares @buffer. read_nonblock then allocates and copies up to 64 KiB before reading into the buffer. This is the same str_make_independent_expand cost the PR removes from read. With long keys, header lines are long enough to hit it.

A probe showed the buffer shared before 4 of 5 reads with this PR's code, and before 0 of 5 with the fix. On a reply built so that every read splits a header line, the fix was 15.6% faster, with no difference on ordinary replies.

      def fill
-        partial = @buffer.byteslice(@offset, @buffer.bytesize - @offset) if pending?
+        partial = @buffer.unpack1('a*', offset: @offset) if pending?
        filled = !read_partial(@read_size, @buffer).nil?

The comment above fill (:109-111) could say why the copy is needed, for example: "unpack1 copies the leftover; a byteslice running to the end would share @buffer and make the read copy all of it."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Applied in 084e7d1: fill copies the leftover with unpack1, and the comment above fill now points at tail for why.

Comment thread README.md
fails after `socket_timeout`. Writes are unchanged, and so is the pipelined `get_multi` path used with multiple
servers or a block. The buffer is about 8 KiB per connection, is allocated only when a request on that connection
opts in, and is released when the connection closes. The `exp_` prefix marks the option as experimental.
servers or a block. The buffer is 8 KiB per connection and grows to 64 KiB once a reply on that connection

@mrattle mrattle Oct 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The buffer is 8 KiB per connection and grows to 64 KiB

The existing change can apparently keep the 64KiB buffer alive indefinitely (which isn't a huge deal) but this becomes accurate if robot suggestion 1 is applied.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. With 084e7d1 no returned string shares the buffer, so the README's "grows to 64 KiB" holds. Left the text as is.

Comment on lines 532 to 538
@@ -548,12 +537,11 @@ def run_ops(test_case)
return :stalled
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The fuzz test never calls expect_reply. Each fuzz case uses one reader for all its ops, so the new wait-before-read path only runs on the first op of each case. When the buffer is empty, randomly marking a reply boundary would put that path under the slow-scheduler and spurious-wakeup faults on every simulated request.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in 084e7d1: before each op, with nothing buffered, the fuzz calls expect_reply half the time (STATS[:expected_replies], ~830 per 24-iteration run). The wait before the first read now runs under the starved waits, spurious EAGAINs, and stalls. 3 × FUZZ_ITERATIONS=24 runs pass on Linux.

@mrattle mrattle Oct 22, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested regression test for robot suggestion 1:

  it 'returns a value that ends at the end of a read without sharing the buffer' do
    with_reader do |reader, peer|
      peer.write("VA 1000 k\r\n", 'v' * 1000)
      sleep(0.01)

      assert_equal "VA 1000 k\r\n", reader.gets("\r\n")
      value = reader.read(1000)
      peer.write("\r\n")

      assert_equal "\r\n", reader.read(2)
      refute_includes ObjectSpace.dump(value), '"shared":true'
    end
  end

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added as is in 084e7d1. It fails on the previous commit ("shared":true) and passes now.

- Values and leftovers that run to the end of the buffer are copied with unpack1, so they neither
  keep the 64 KiB buffer alive nor make the next read copy all of it.
- The fuzz test marks a new reply at random when nothing is buffered, so the wait before a reply's
  first read runs under the injected faults.
@nherson

nherson commented Oct 22, 2026

Copy link
Copy Markdown
Author

Thanks @mrattle. Applied all five in 084e7d1. Benchmarked against the previous commit: median of 3 alternating runs, Linux, Ruby 3.4 + YJIT, glibc, UNIX socket, recovery path, CPU µs per op.

case before after
get 512 KB 80.1 79.4
get 2 MB 261.2 251.1
get_multi 400 × 100 B 668.9 679.4
get_multi 40 × 4 KB 116.5 117.3
get_multi 40 × 12 KB 165.6 164.5
get 512 KB under Sync 115.1 100.1
get 2 MB under Sync 389.0 336.4
get_multi 40 × 4 KB under Sync 121.1 127.1

The 40 × 4 KB Sync row is noise: 4 more alternating pairs gave 124.6/123.8, 136.7/125.9, 123.3/123.2, and 121.3/124.0. The large gets under Sync are ~13% cheaper, which also closes most of the glibc large-get gap noted in the description.

@nherson
nherson merged commit 4b6af49 into main Oct 22, 2026
18 checks passed
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