Skip to content

drpcstream: introduce shared BufferPool for ring buffer#55

Merged
shubhamdhama merged 1 commit into
mainfrom
shubham/buffer-pool-for-ringbuffer
Jun 12, 2026
Merged

drpcstream: introduce shared BufferPool for ring buffer#55
shubhamdhama merged 1 commit into
mainfrom
shubham/buffer-pool-for-ringbuffer

Conversation

@shubhamdhama

@shubhamdhama shubhamdhama commented Apr 17, 2026

Copy link
Copy Markdown

Add a BufferPool backed by sync.Pool that is shared across all streams
within a Manager. The ring buffer now obtains buffers from the pool on
Enqueue and transfers ownership to the caller on Dequeue, which advances
the tail immediately. This removes the two-step Dequeue/Done protocol
and simplifies Close (no longer needs to wait for held buffers).

The pool is a required parameter in the Stream constructor, created once
per Manager and passed to all streams it creates.

@shubhamdhama

Copy link
Copy Markdown
Author

I had an idea which I ran through claude and below is the summary of it. I'm not planning to do it but in future we can re-consider if profiling shows any gain.

Buffer pool: further optimization ideas

Right now the data copy chain for an incoming message looks like this:

  1. PacketAssembler.AppendFrame: copies frame data into pa.pk.Data via append
  2. ringBuffer.Enqueue: copies pkt.Data into a pooled buffer via append
  3. RawRecv copies the pooled buffer out, or MsgRecv unmarshals from it

We could eliminate copy #2 by having the packet assembler get its buffer from the pool directly. The assembler already has a TODO for buffer reuse. Instead of reusing its own backing array across packets (lines 84-87), it would pool.Get a buffer, assemble into it, hand it off through the ring buffer, and pool.Get a fresh one for the next packet.

Another idea: size-bucketed pools (e.g. 1KiB, 16KiB, 32KiB) so that Enqueue's append doesn't have to reallocate when messages are larger than the default capacity. You could even have a pool.Append(buf, data...) method that detects when the buffer needs to grow and fetches from the right bucket.

I think we should keep the pool simple for now. sync.Pool already gives you natural high-water-mark behavior: a buffer that grew to 32KiB stays at 32KiB when returned, so after warm-up the pool self-tunes to the workload's size distribution. Size buckets would add real maintenance cost (choosing boundaries, handling cross-bucket transitions) for a gain that append + sync.Pool already provides. Latency here is dominated by network IO anyway, not memcpy.

The assembler integration is the more interesting optimization since it removes a full copy per message. Worth revisiting once we have benchmarks to measure the actual impact.

@shubhamdhama shubhamdhama force-pushed the shubham/enable-stream-multiplexing branch from a17330d to b91bf1b Compare April 17, 2026 14:57
@shubhamdhama shubhamdhama force-pushed the shubham/buffer-pool-for-ringbuffer branch from cafa1dc to b3d2355 Compare April 17, 2026 14:57
@shubhamdhama shubhamdhama force-pushed the shubham/enable-stream-multiplexing branch from b91bf1b to a58986c Compare April 17, 2026 15:00
@shubhamdhama shubhamdhama force-pushed the shubham/buffer-pool-for-ringbuffer branch from b3d2355 to 38c84dc Compare April 17, 2026 15:00
Base automatically changed from shubham/enable-stream-multiplexing to stream-multiplexing April 17, 2026 16:30
@shubhamdhama shubhamdhama force-pushed the shubham/buffer-pool-for-ringbuffer branch from 38c84dc to f2f767f Compare May 11, 2026 11:03
@shubhamdhama shubhamdhama force-pushed the shubham/buffer-pool-for-ringbuffer branch from f2f767f to a305254 Compare June 9, 2026 07:36
@shubhamdhama shubhamdhama changed the base branch from stream-multiplexing to main June 9, 2026 07:37
Comment thread drpcstream/buffer_pool.go
Comment thread drpcstream/ring_buffer.go Outdated
}

rb.buf[rb.head] = append(rb.buf[rb.head][:0], data...)
b := rb.pool.Get()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These can me move outside the lock now.

Comment thread drpcstream/ring_buffer.go
Comment thread drpcstream/stream.go Outdated
data = append([]byte(nil), data...)
s.recvQueue.Done()
data = append([]byte(nil), *b...)
s.pool.Put(b)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We should continue to use s.recvQueue.Done(). Refer to other comments for context.

@shubhamdhama shubhamdhama force-pushed the shubham/buffer-pool-for-ringbuffer branch from a305254 to f5edd92 Compare June 11, 2026 14:50

@cthumuluru-crdb cthumuluru-crdb 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.

Added a couple of comments. Besides that the changes look good.

Comment thread drpcstream/ring_buffer.go Outdated
head int // next write position (producer)
tail int // next read position (consumer)
count int // number of occupied slots
pool *BufferPool // shared pool buffers are obtained from

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: in case of Close() we are no longer waiting for the held buffer to be released. I don't see any risk in that but that would also mean, the consumer may return the buffer back to the pool after the packet buffer is closed (maybe due to context cancellation). The buffer should still be returned to the pool since the pool is shared across streams and is at the connection level.

Can you add a comment that buffer pool is shared across streams and is owned by the manager and not by ring buffer (just to be more specific about the ownership)?

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.

I will add the comment but as for the rest of the comment, i couldn't understand the problem outlined.

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.

I see you were talking about adding the comment only. Updated.

Comment thread drpcstream/ring_buffer.go
Comment thread drpcstream/stream.go
// increasing stream ids within a single transport.
func New(ctx context.Context, sid uint64, wr *drpcwire.MuxWriter) *Stream {
return NewWithOptions(ctx, sid, wr, Options{})
func New(ctx context.Context, sid uint64, wr *drpcwire.MuxWriter, pool *BufferPool) *Stream {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since buffer pool is an internal option to a stream, I prefer Internal options to pass it around.

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.

I don't understand this one. It's not an optional parameter so I don't see the value in wrapping it behind Internal struct.

Comment thread drpcmanager/manager.go
m.pendingStreams = make(map[uint64]*pendingStream)

m.streams = newActiveStreams()
m.recvPool = drpcstream.NewBufferPool()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since manager uses it, how about we move it to the package where manager belongs? Also, keeping the name as buffer pool would enable us to use it for send buffer if needed in the future.

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.

I want to move it to drpcwire in an upcoming PR as it's a lower in dependency hierarchy and contains similar fundamental pieces. I don't want to move it to drpcmanager or drpcstream because we have to create interfaces all over the place.

Add a BufferPool backed by sync.Pool that is shared across all streams
within a Manager. The ring buffer now obtains a pooled buffer on Enqueue
and copies the message into it, instead of growing a fixed slice per
slot. Dequeue hands the buffer's data to the consumer and advances the
tail immediately, and Done releases the buffer back to the pool once the
consumer is finished with it.

Keeping the Dequeue/Done contract means the consumer works with a plain
[]byte and never has to know whether the queue is backed by a pool or by
fixed buffers. Because Dequeue advances the tail right away rather than
waiting for Done, Close no longer has to block until in-flight buffers
are released.

The pool is a required parameter in the Stream constructor, created once
per Manager and passed to all streams it creates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@shubhamdhama shubhamdhama force-pushed the shubham/buffer-pool-for-ringbuffer branch from f5edd92 to 03f1339 Compare June 12, 2026 09:33
@shubhamdhama shubhamdhama merged commit 9029d2e into main Jun 12, 2026
3 checks passed
@shubhamdhama shubhamdhama deleted the shubham/buffer-pool-for-ringbuffer branch June 12, 2026 09:33
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