Skip to content

proposal: x/net/http2: allow configuring the buffer allocator to avoid copies during reads #73560

Open
@arjan-bal

Description

@arjan-bal

Proposal Details

The Framer has an unexported field that holds a closure responsible for allocating byte slices for reading frame payloads. The existing implementation keeps re-using the same slice, requiring users to copy the contents of data frames before reading the next frame. gRPC Go has a buffer pool which is used to recycle byte slices used during different parts of an RPC. The existing sequence of events when reading a data frame is as follows:

  1. The framer reads to its own buffer.
  2. gRPC copies framer's buffer to a new slice from its buffer pool, incurring a copy.
  3. The buffer from the pool is passed around without needing any further copies.
  4. The buffer is returned to the pool after it’s contents are processed.

I propose the addition of a method to the Framer to allow setting a custom allocator.

// SetBufferAllocator sets a custom buffer allocator that will be called for
// getting a buffer slice for each frame payload. The allocator must return a 
// slice of the requested length. For data frames, calling DataFrame.Data() will
// always return a prefix of the slice returned by the allocator.
func (fr *Framer) SetBufferAllocator(allocator func(uint32) []byte) {
	fr.allocator = allocator
}

For gRPC, the allocator will return a buffer from the shared buffer pool every time. To make it easy for callers to re-use the entire slice, I also propose changing the data framer parsing code to read the optional padding length byte to a different buffer than the one from the allocator. This ensures calling DataFrame.Data() returns a prefix of the allocator’s buffer, without reducing its capacity.

With the new framer API, a sample implementation of gRPC’s allocator function will look as follows:

func (b *bufferAllocator) get(size uint32) []byte {
	// Try to re-use a buffer if possible.
	if b.curBuf != nil && cap(*b.curBuf) >= int(size) {
		return (*b.curBuf)[:int(size)]
	}
	// Can't re-use, recycle the buffer.
	if b.curBuf != nil {
		b.bufferPool.Put(b.curBuf)
	}
	// Get a new buffer.
	b.curBuf = b.bufferPool.Get(int(size))
	return *b.curBuf
}

The new sequence of events when reading a data frame will be:

  1. Framer requests the allocator for a buffer. The allocator gets a buffer from the pool.
  2. The framer reads to the allocator's buffer.
  3. gRPC takes ownership of the allocator's buffer by saving the buffer and setting the allocator’s buffer to nil, avoiding a copy.

Benchmarks

When running a Google Cloud Storage benchmark that runs on a Compute Engine VM and downloads 10 MB objects in a loop until it’s downloaded a fixed amount of data, we’re seeing a ~4% reduction in CPU time.

Profiles

Before

Image

After

Image

cc @dfawley @easwars

Metadata

Metadata

Assignees

No one assigned

    Labels

    LibraryProposalIssues describing a requested change to the Go standard library or x/ libraries, but not to a toolPerformanceProposal

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions