Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

mem: introduce mem package to facilitate memory reuse #7432

Merged
merged 15 commits into from
Aug 1, 2024
Next Next commit
Introduce mem package
This package is required for #7356 but is
introduced early here to allow additional features to be built on top of it. It
provides a mechanism to reuse and recycle buffers to reduce GC pressure.
  • Loading branch information
PapaCharlie committed Jul 23, 2024
commit be4d4ab8d90af3d547096fd09e7b910d0326cc3e
184 changes: 184 additions & 0 deletions mem/buffer_pool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
*
* Copyright 2024 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package mem

import (
"sort"
"sync"
"sync/atomic"
)

// BufferPool is a pool of buffers that can be shared, resulting in
Copy link
Member

Choose a reason for hiding this comment

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

Nit: that can be shared and reused? The "reuse" is the part that reduces allocations, right?

Copy link
Contributor

Choose a reason for hiding this comment

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

Done.

// decreased memory allocation.
type BufferPool interface {
// Get returns a buffer with specified length from the pool.
Get(length int) []byte

// Put returns a buffer to the pool.
Put([]byte)
}

var defaultBufferPoolSizes = []int{
256,
4 << 10, // 4KB (go page size)
16 << 10, // 16KB (max HTTP/2 frame size used by gRPC)
32 << 10, // 32KB (default buffer size for io.Copy)
// TODO: Report the buffer sizes requested with Get to tune this properly.
easwars marked this conversation as resolved.
Show resolved Hide resolved
1 << 20, // 1MB
}

var defaultBufferPool = func() *atomic.Pointer[BufferPool] {
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it be better to declare a var defaultBufferPool *atomic.Pointer[BufferPool] and initialize it in an init function?

Copy link
Member

Choose a reason for hiding this comment

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

Since it's not exported I'm not too picky either way, but I agree that might be nicer.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, went ahead and changed it along with removing the atomic.

pool := NewBufferPool(defaultBufferPoolSizes...)
ptr := new(atomic.Pointer[BufferPool])
ptr.Store(&pool)
return ptr
}()

// DefaultBufferPool returns the current default buffer pool. It is a BufferPool
// created with NewBufferPool that uses a set of default sizes optimized for
// expected workflows.
func DefaultBufferPool() BufferPool {
return *defaultBufferPool.Load()

Check warning on line 57 in mem/buffer_pool.go

View check run for this annotation

Codecov / codecov/patch

mem/buffer_pool.go#L56-L57

Added lines #L56 - L57 were not covered by tests
Copy link
Member

Choose a reason for hiding this comment

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

Hmmm, can we make this not an atomic in production code? I.e. in production the user needs to initialize it once at init time and we never use it until after init time. Tests need to change it dynamically, but generally, users shouldn't do that. (That doesn't have to block this PR.)

Copy link
Contributor

Choose a reason for hiding this comment

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

Done.

}

// SetDefaultBufferPoolForTesting updates the default buffer pool, for testing
// purposes.
func SetDefaultBufferPoolForTesting(pool BufferPool) {
PapaCharlie marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

I'd like to work on moving this to internal instead to keep the API surface clean. Unless we think users need this for their own testing. As above, this PR is probably OK this way.

Copy link
Contributor

Choose a reason for hiding this comment

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

Done.

defaultBufferPool.Store(&pool)

Check warning on line 63 in mem/buffer_pool.go

View check run for this annotation

Codecov / codecov/patch

mem/buffer_pool.go#L62-L63

Added lines #L62 - L63 were not covered by tests
}

// NewBufferPool returns a BufferPool implementation that uses multiple
// underlying pools of the given pool sizes. When a buffer is requested from the
// returned pool, it will have a
PapaCharlie marked this conversation as resolved.
Show resolved Hide resolved
func NewBufferPool(poolSizes ...int) BufferPool {
sort.Ints(poolSizes)
pools := make([]*sizedBufferPool, len(poolSizes))
for i, s := range poolSizes {
pools[i] = newBufferPool(s)
}
return &tieredBufferPool{
sizedPools: pools,
}
}

// tieredBufferPool implements the BufferPool interface with multiple tiers of
// buffer pools for different sizes of buffers.
type tieredBufferPool struct {
sizedPools []*sizedBufferPool
fallbackPool simpleBufferPool
easwars marked this conversation as resolved.
Show resolved Hide resolved
}

func (p *tieredBufferPool) Get(size int) []byte {
return p.getPool(size).Get(size)
}

func (p *tieredBufferPool) Put(buf []byte) {
p.getPool(cap(buf)).Put(buf)
}

func (p *tieredBufferPool) getPool(size int) BufferPool {
poolIdx := sort.Search(len(p.sizedPools), func(i int) bool {
return p.sizedPools[i].defaultSize >= size
})

if poolIdx == len(p.sizedPools) {
return &p.fallbackPool
}

return p.sizedPools[poolIdx]
}

// sizedBufferPool is a BufferPool implementation that is optimized for specific
// buffer sizes. For example, HTTP/2 frames within grpc are always 16kb and a
easwars marked this conversation as resolved.
Show resolved Hide resolved
// sizedBufferPool can be configured to only return buffers with a capacity of
// 16kb. Note that however it does not support returning larger buffers and in
// fact panics if such a buffer is requested.
type sizedBufferPool struct {
pool sync.Pool
defaultSize int
}

func (p *sizedBufferPool) Get(size int) []byte {
bs := *p.pool.Get().(*[]byte)
return bs[:size]
}

func (p *sizedBufferPool) Put(buf []byte) {
if cap(buf) < p.defaultSize {
// Ignore buffers that are too small to fit in the pool. Otherwise, when Get is
// called it will panic as it tries to index outside the bounds of the buffer.
easwars marked this conversation as resolved.
Show resolved Hide resolved
return
}
buf = buf[:cap(buf)]
clear(buf)
Copy link
Member

Choose a reason for hiding this comment

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

Is the clearing necessary? I would think we could remove it. Or is there an attack vector for code already running inside your binary that wouldn't otherwise be possible?

If the clearing is important, then we should add a test for it.

Copy link
Contributor

Choose a reason for hiding this comment

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

I remember making the same comment in one of the older PRs. Paul had some rationale for it, but I don't remember it now. Will wait for Paul on this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll throw a test around this. It seems like a good idea to have since anything that remains in the buffer can leak when it's reused.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added test

p.pool.Put(&buf)
}

func newBufferPool(size int) *sizedBufferPool {
PapaCharlie marked this conversation as resolved.
Show resolved Hide resolved
return &sizedBufferPool{
pool: sync.Pool{
New: func() any {
buf := make([]byte, size)
return &buf
},
},
defaultSize: size,
}
}

var _ BufferPool = (*simpleBufferPool)(nil)

// simpleBufferPool is an implementation of the BufferPool interface that
// attempts to pool buffers with a sync.Pool. When Get is invoked, it tries to
// acquire a buffer from the pool but if that buffer is too small, it returns it
// to the pool and creates a new one.
type simpleBufferPool struct {
pool sync.Pool
}

func (p *simpleBufferPool) Get(size int) []byte {
bs, ok := p.pool.Get().(*[]byte)
if ok && cap(*bs) >= size {
return (*bs)[:size]

Check warning on line 158 in mem/buffer_pool.go

View check run for this annotation

Codecov / codecov/patch

mem/buffer_pool.go#L158

Added line #L158 was not covered by tests
}

if ok {
p.pool.Put(bs)

Check warning on line 162 in mem/buffer_pool.go

View check run for this annotation

Codecov / codecov/patch

mem/buffer_pool.go#L162

Added line #L162 was not covered by tests
}

return make([]byte, size)
}

func (p *simpleBufferPool) Put(buf []byte) {
buf = buf[:cap(buf)]
clear(buf)
p.pool.Put(&buf)
}

var _ BufferPool = NopBufferPool{}

// NopBufferPool is a buffer pool just makes new buffer without pooling.
type NopBufferPool struct{}

func (NopBufferPool) Get(length int) []byte {
return make([]byte, length)
}

func (NopBufferPool) Put([]byte) {
}
61 changes: 61 additions & 0 deletions mem/buffer_pool_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
*
* Copyright 2023 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package mem

import "testing"

func TestSharedBufferPool(t *testing.T) {
pools := []BufferPool{
NopBufferPool{},
NewBufferPool(defaultBufferPoolSizes...),
}

testSizes := append(defaultBufferPoolSizes, 1<<20+1)

for _, p := range pools {
for _, l := range testSizes {
bs := p.Get(l)
if len(bs) != l {
t.Fatalf("Expected buffer of length %d, got %d", l, len(bs))
}

p.Put(bs)
}
}
}

func TestTieredBufferPool(t *testing.T) {
pool := &tieredBufferPool{
sizedPools: []*sizedBufferPool{
newBufferPool(10),
newBufferPool(20),
},
}
buf := pool.Get(1)
if cap(buf) != 10 {
t.Fatalf("Unexpected buffer capacity: %d", cap(buf))
}

// Insert a short buffer into the pool, which is currently empty.
pool.Put(make([]byte, 1))
// Then immediately request a buffer that would be pulled from the pool where the
// short buffer would have been returned. If the short buffer is pulled from the
// pool, it could cause a panic.
pool.Get(10)
}
Loading
Loading