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

experimental: configure custom memory allocator #2177

Merged
merged 11 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions experimental/memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package experimental

import (
"context"

"github.com/tetratelabs/wazero/internal/ctxkey"
)

// MemoryAllocator is a memory allocation hook which is invoked
// to create a new MemoryBuffer, with the given specification:
// min is the initial and minimum length (in bytes) of the backing []byte,
// cap a suggested initial capacity, and max the maximum length
// that will ever be requested.
ncruces marked this conversation as resolved.
Show resolved Hide resolved
type MemoryAllocator func(min, cap, max uint64) MemoryBuffer

// MemoryBuffer is a memory buffer that backs a Wasm memory.
type MemoryBuffer interface {
// Return the backing []byte for the memory buffer.
Buffer() []byte
Comment on lines +18 to +19
Copy link
Collaborator

Choose a reason for hiding this comment

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

Food for thoughts, this method could potentially be removed in favor of Grow(0).

// Grow the backing memory buffer to size bytes in length.
// To back a shared memory, Grow can't change the address
// of the backing []byte (only its length/capacity may change).
Comment on lines +20 to +22
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this requirement make it impossible to implement a version of this interface backed by the Go heap?

It seems like the sliceBuffer implementation using append would violate this constraint.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No:

  1. this requirement is only for shared memories;
  2. you're allowed to pre-allocate the max to avoid relocations if you need such behavior.

Note that my mmap implementation also reserves the max address space, because it depends on memory never moving.

PS: I can make this into a package (and, I guess, support Windows) if there's interest. It has interesting benefits. Makes startup/teardown slower, but can make growing memory faster, as it avoids copies. And with the trick of allocating 8GB, we could probably disable bounds checks altogether.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The API as-is is quite easy to misuse, the interface implementation has no way of knowing if it's being called to allocated a shared memory segment or not, and the user may also not know if the underlying memory is shared-ready or not.

In my experience, this calls for separating the concepts, maybe we can even get compile-time checks to ensure that the right kind of memory is being used:

type MemoryAllocator interface {
  AllocateLinearMemory(...) LinearMemory
  AllocateSharedMemory(...) SharedMemory
}

// It could be useful for these two interfaces to share a common set of methods,
// but also differentiate so the compiler can guarantee that they're never misused.

type LinearMemory interface {
  ...
}

type SharedMemory interface {
  ...
}

Copy link
Collaborator Author

@ncruces ncruces Apr 11, 2024

Choose a reason for hiding this comment

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

Yes, point taken on “the allocator doesn't know if the memory is shared.”

I thought of adding a parameter, but then decided on just documenting. An implementor can and should specify if memories move when grown. Mine don't regardless of sharing.

The allocator is configured at module instantiation, so you should know by then if you need shared memory. Same as I know I need a memory that I can mmap but this is not compile time checked.

Also with Go duck-typing your suggested API only differentiates the “kind” of memory if we add a dummy method.

Lastly, note that shared memories can still grow, they just can't move when grown. So the API is much the same.

With a large virtual address space it's perfectly feasible to reserve N×4GB of address space without committing any of it to memory, all on a 2GB “droplet" with no swap, and then organically decide at runtime which of the N gets 512MB while the other N-1 stay under 64MB.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I thought of adding a parameter, but then decided on just documenting. An implementor can and should specify if memories move when grown. Mine don't regardless of sharing.

The allocator is configured at module instantiation, so you should know by then if you need shared memory. Same as I know I need a memory that I can mmap but this is not compile time checked.

That's a bit more reliance on developers "getting it right" that I'm usually comfortable with; assumptions change over time especially in an evolving world like WebAssembly, I'd err on the side of caution and figure out how to leverage the compiler and API to be more future-proof. That being said, that's not a hill I'm willing to die on either, things working is better than things being perfect, I'm just trying to emphasize that most developers aren't Nuno and will likely get things wrong if the API allows for it ;)

Also with Go duck-typing your suggested API only differentiates the “kind” of memory if we add a dummy method.

I don't know if I'm suggesting differentiating via a dummy method, I'll have to think more about it, maybe the differences between linear and shared are more profound and could be expressed in the API.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Future Nuno will thank you for it!
I'm all for expressing stuff with the type system, as long as it doesn't become clunky.

Grow(size uint64) []byte
// Free the backing memory buffer.
Free()
}
Comment on lines +14 to +26
Copy link
Collaborator

Choose a reason for hiding this comment

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

Apologies because I'm a little late to the party, but since this is still in the experimental package it's maybe still OK to leave feedback; a few suggestions:

  • Turn the memory allocator into an interface since it's intended to be an abstraction; single-method interfaces are a very common idiom in Go that developers are familiar with:
type MemoryAllocator interface {
  Allocate(min, cap, max uint64) MemoryBuffer
}

// We can always add a helper to implement the interface on function values.
type MemoryAllocatorFunc func(min, cap, max uint64) MemoryBuffer
  • The use of 3 uint64 as argument makes the signature error prone to programs that would mistakenly swap the position of these arguments. For example, my inuition would be for min/max to be back-to-back, I will for sure make this mistake at some point and the Go compiler cannot detect those kind of errors. One way to reduce this risk would be to introduce a configuration struct, which forces named arguments:
type MemoryConfig struct {
  _ struct{} // forces named arguments
  Min uint64
  Max uint64
  Cap uint64
}
// Allocation must always explicilty name parameters:
alloc(MemoryConfig{
  Max: 64 * 1024 * 1024,
})
  • The MemoryBuffer type sound more like an region or linear memory, we have an opportunity to improve the name to be less generic I think ("memory buffer" can describe about anything in a program), for example:
type LinearMemory interface {
  ...
}
type MemoryArena interface {
   ...
}

etc...

Copy link
Collaborator Author

@ncruces ncruces Apr 11, 2024

Choose a reason for hiding this comment

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

Thanks! I'm:
🤷 on the single method interface
👎 on the args struct (this is called in one place inside wazero, users implement it, don't call it)
👍 on renaming MemoryBuffer

But I'll do whatever. I'm just ecstatic that I was able to mmap files and do WAL after one year. 🥳

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah OK, your argument on the arg struct makes sense, if users won't be calling it directly the risk is low 👍


// WithMemoryAllocator registers the given MemoryAllocator into the given
// context.Context.
func WithMemoryAllocator(ctx context.Context, allocator MemoryAllocator) context.Context {
if allocator != nil {
return context.WithValue(ctx, ctxkey.MemoryAllocatorKey{}, allocator)
}
return ctx
}
4 changes: 2 additions & 2 deletions imports/assemblyscript/assemblyscript_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ import (
"github.com/tetratelabs/wazero/api"
. "github.com/tetratelabs/wazero/experimental"
"github.com/tetratelabs/wazero/experimental/logging"
"github.com/tetratelabs/wazero/experimental/wazerotest"
. "github.com/tetratelabs/wazero/internal/assemblyscript"
"github.com/tetratelabs/wazero/internal/testing/proxy"
"github.com/tetratelabs/wazero/internal/testing/require"
"github.com/tetratelabs/wazero/internal/u64"
"github.com/tetratelabs/wazero/internal/wasm"
"github.com/tetratelabs/wazero/sys"
)

Expand Down Expand Up @@ -376,7 +376,7 @@ func Test_readAssemblyScriptString(t *testing.T) {
tc := tt

t.Run(tc.name, func(t *testing.T) {
mem := wasm.NewMemoryInstance(&wasm.Memory{Min: 1, Cap: 1, Max: 1})
mem := wazerotest.NewFixedMemory(wazerotest.PageSize)
tc.memory(mem)

s, ok := readAssemblyScriptString(mem, uint32(tc.offset))
Expand Down
3 changes: 3 additions & 0 deletions internal/ctxkey/memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package ctxkey

type MemoryAllocatorKey struct{}
64 changes: 49 additions & 15 deletions internal/wasm/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"unsafe"

"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/experimental"
"github.com/tetratelabs/wazero/internal/internalapi"
"github.com/tetratelabs/wazero/internal/wasmruntime"
)
Expand Down Expand Up @@ -57,12 +58,22 @@ type MemoryInstance struct {
// waiters implements atomic wait and notify. It is implemented similarly to golang.org/x/sync/semaphore,
// with a fixed weight of 1 and no spurious notifications.
waiters sync.Map

expBuffer experimental.MemoryBuffer
}

// NewMemoryInstance creates a new instance based on the parameters in the SectionIDMemory.
func NewMemoryInstance(memSec *Memory) *MemoryInstance {
var size uint64
if memSec.IsShared {
func NewMemoryInstance(memSec *Memory, allocator experimental.MemoryAllocator) *MemoryInstance {
minBytes := MemoryPagesToBytesNum(memSec.Min)
capBytes := MemoryPagesToBytesNum(memSec.Cap)
maxBytes := MemoryPagesToBytesNum(memSec.Max)

var buffer []byte
var expBuffer experimental.MemoryBuffer
if allocator != nil {
expBuffer = allocator(minBytes, capBytes, maxBytes)
buffer = expBuffer.Buffer()
} else if memSec.IsShared {
// Shared memory needs a fixed buffer, so allocate with the maximum size.
//
// The rationale as to why we can simply use make([]byte) to a fixed buffer is that Go's GC is non-relocating.
Expand All @@ -73,18 +84,17 @@ func NewMemoryInstance(memSec *Memory) *MemoryInstance {
// the memory buffer allocation here is virtual and doesn't consume physical memory until it's used.
// * https://github.com/golang/go/blob/8121604559035734c9677d5281bbdac8b1c17a1e/src/runtime/malloc.go#L1059
// * https://github.com/golang/go/blob/8121604559035734c9677d5281bbdac8b1c17a1e/src/runtime/malloc.go#L1165
size = MemoryPagesToBytesNum(memSec.Max)
buffer = make([]byte, minBytes, maxBytes)
} else {
size = MemoryPagesToBytesNum(memSec.Cap)
buffer = make([]byte, minBytes, capBytes)
}
ncruces marked this conversation as resolved.
Show resolved Hide resolved

buffer := make([]byte, MemoryPagesToBytesNum(memSec.Min), size)
return &MemoryInstance{
Buffer: buffer,
Min: memSec.Min,
Cap: memoryBytesNumToPages(uint64(cap(buffer))),
Max: memSec.Max,
Shared: memSec.IsShared,
Buffer: buffer,
Min: memSec.Min,
Cap: memoryBytesNumToPages(uint64(cap(buffer))),
Max: memSec.Max,
Shared: memSec.IsShared,
expBuffer: expBuffer,
}
}

Expand Down Expand Up @@ -222,6 +232,22 @@ func (m *MemoryInstance) Grow(delta uint32) (result uint32, ok bool) {
newPages := currentPages + delta
if newPages > m.Max || int32(delta) < 0 {
return 0, false
} else if m.expBuffer != nil {
buffer := m.expBuffer.Grow(MemoryPagesToBytesNum(newPages))
if m.Shared {
if unsafe.SliceData(buffer) != unsafe.SliceData(m.Buffer) {
panic("shared memory cannot move, this is a bug in the memory allocator")
}
// We assume grow is called under a guest lock.
// But the memory length is accessed elsewhere,
// so use atomic to make the new length visible across threads.
atomicStoreLength(&m.Buffer, uintptr(len(buffer)))
m.Cap = memoryBytesNumToPages(uint64(cap(buffer)))
} else {
m.Buffer = buffer
m.Cap = newPages
}
return currentPages, true
} else if newPages > m.Cap { // grow the memory.
if m.Shared {
panic("shared memory cannot be grown, this is a bug in wazero")
Expand All @@ -231,9 +257,10 @@ func (m *MemoryInstance) Grow(delta uint32) (result uint32, ok bool) {
return currentPages, true
} else { // We already have the capacity we need.
if m.Shared {
sp := (*reflect.SliceHeader)(unsafe.Pointer(&m.Buffer))
// Use atomic write to ensure new length is visible across threads.
atomic.StoreUintptr((*uintptr)(unsafe.Pointer(&sp.Len)), uintptr(MemoryPagesToBytesNum(newPages)))
// We assume grow is called under a guest lock.
// But the memory length is accessed elsewhere,
// so use atomic to make the new length visible across threads.
atomicStoreLength(&m.Buffer, uintptr(MemoryPagesToBytesNum(newPages)))
} else {
m.Buffer = m.Buffer[:MemoryPagesToBytesNum(newPages)]
}
Expand Down Expand Up @@ -267,6 +294,13 @@ func PagesToUnitOfBytes(pages uint32) string {

// Below are raw functions used to implement the api.Memory API:

// Uses atomic write to update the length of a slice.
func atomicStoreLength(slice *[]byte, length uintptr) {
slicePtr := (*reflect.SliceHeader)(unsafe.Pointer(slice))
lenPtr := (*uintptr)(unsafe.Pointer(&slicePtr.Len))
atomic.StoreUintptr(lenPtr, length)
}

// memoryBytesNumToPages converts the given number of bytes into the number of pages.
func memoryBytesNumToPages(bytesNum uint64) (pages uint32) {
return uint32(bytesNum >> MemoryPageSizeInBits)
Expand Down
44 changes: 40 additions & 4 deletions internal/wasm/memory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"unsafe"

"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/experimental"
"github.com/tetratelabs/wazero/internal/testing/require"
)

Expand All @@ -34,9 +35,11 @@ func TestMemoryInstance_Grow_Size(t *testing.T) {
tests := []struct {
name string
capEqualsMax bool
expAllocator bool
}{
{name: ""},
{name: "capEqualsMax", capEqualsMax: true},
{name: "expAllocator", expAllocator: true},
}

for _, tt := range tests {
Expand All @@ -46,10 +49,14 @@ func TestMemoryInstance_Grow_Size(t *testing.T) {
max := uint32(10)
maxBytes := MemoryPagesToBytesNum(max)
var m *MemoryInstance
if tc.capEqualsMax {
m = &MemoryInstance{Cap: max, Max: max, Buffer: make([]byte, 0, maxBytes)}
} else {
switch {
default:
m = &MemoryInstance{Max: max, Buffer: make([]byte, 0)}
case tc.capEqualsMax:
m = &MemoryInstance{Cap: max, Max: max, Buffer: make([]byte, 0, maxBytes)}
case tc.expAllocator:
expBuffer := sliceAllocator(0, 0, maxBytes)
m = &MemoryInstance{Max: max, Buffer: expBuffer.Buffer(), expBuffer: expBuffer}
}

res, ok := m.Grow(5)
Expand Down Expand Up @@ -814,6 +821,13 @@ func BenchmarkWriteString(b *testing.B) {
}
}

func Test_atomicStoreLength(t *testing.T) {
// Doesn't verify atomicity, but at least we're updating the correct thing.
slice := make([]byte, 10, 20)
atomicStoreLength(&slice, 15)
require.Equal(t, 15, len(slice))
}

func TestNewMemoryInstance_Shared(t *testing.T) {
tests := []struct {
name string
Expand All @@ -832,7 +846,7 @@ func TestNewMemoryInstance_Shared(t *testing.T) {
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
m := NewMemoryInstance(tc.mem)
m := NewMemoryInstance(tc.mem, nil)
require.Equal(t, tc.mem.Min, m.Min)
require.Equal(t, tc.mem.Max, m.Max)
require.True(t, m.Shared)
Expand Down Expand Up @@ -979,3 +993,25 @@ func requireChannelEmpty(t *testing.T, ch chan string) {
// fallthrough
}
}

func sliceAllocator(min, cap, max uint64) experimental.MemoryBuffer {
return &sliceBuffer{make([]byte, min, cap), max}
}

type sliceBuffer struct {
buf []byte
max uint64
}

func (b *sliceBuffer) Free() {}

func (b *sliceBuffer) Buffer() []byte { return b.buf }

func (b *sliceBuffer) Grow(size uint64) []byte {
if cap := uint64(cap(b.buf)); size > cap {
b.buf = append(b.buf[:cap], make([]byte, size-cap)...)
} else {
b.buf = b.buf[:size]
}
return b.buf
}
4 changes: 2 additions & 2 deletions internal/wasm/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,10 +652,10 @@ func paramNames(localNames IndirectNameMap, funcIdx uint32, paramLen int) []stri
return nil
}

func (m *ModuleInstance) buildMemory(module *Module) {
func (m *ModuleInstance) buildMemory(module *Module, allocator experimental.MemoryAllocator) {
memSec := module.MemorySection
if memSec != nil {
m.MemoryInstance = NewMemoryInstance(memSec)
m.MemoryInstance = NewMemoryInstance(memSec, allocator)
m.MemoryInstance.definition = &module.MemoryDefinitionSection[0]
}
}
Expand Down
22 changes: 13 additions & 9 deletions internal/wasm/module_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,20 +151,24 @@ func (m *ModuleInstance) ensureResourcesClosed(ctx context.Context) (err error)
}

if sysCtx := m.Sys; sysCtx != nil { // nil if from HostModuleBuilder
if err = sysCtx.FS().Close(); err != nil {
return err
}
err = sysCtx.FS().Close()
ncruces marked this conversation as resolved.
Show resolved Hide resolved
m.Sys = nil
}

if m.CodeCloser == nil {
return
if mem := m.MemoryInstance; mem != nil {
if mem.expBuffer != nil {
mem.expBuffer.Free()
mem.expBuffer = nil
}
}
if e := m.CodeCloser.Close(ctx); e != nil && err == nil {
err = e

if m.CodeCloser != nil {
if e := m.CodeCloser.Close(ctx); err == nil {
err = e
}
m.CodeCloser = nil
}
m.CodeCloser = nil
return
return err
}

// Memory implements the same method as documented on api.Module.
Expand Down
4 changes: 2 additions & 2 deletions internal/wasm/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -839,7 +839,7 @@ func TestModule_buildGlobals(t *testing.T) {
func TestModule_buildMemoryInstance(t *testing.T) {
t.Run("nil", func(t *testing.T) {
m := ModuleInstance{}
m.buildMemory(&Module{})
m.buildMemory(&Module{}, nil)
require.Nil(t, m.MemoryInstance)
})
t.Run("non-nil", func(t *testing.T) {
Expand All @@ -850,7 +850,7 @@ func TestModule_buildMemoryInstance(t *testing.T) {
m.buildMemory(&Module{
MemorySection: &Memory{Min: min, Cap: min, Max: max},
MemoryDefinitionSection: []MemoryDefinition{mDef},
})
}, nil)
mem := m.MemoryInstance
require.Equal(t, min, mem.Min)
require.Equal(t, max, mem.Max)
Expand Down
8 changes: 7 additions & 1 deletion internal/wasm/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"sync/atomic"

"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/experimental"
"github.com/tetratelabs/wazero/internal/ctxkey"
"github.com/tetratelabs/wazero/internal/internalapi"
"github.com/tetratelabs/wazero/internal/leb128"
Expand Down Expand Up @@ -362,8 +363,13 @@ func (s *Store) instantiate(
return nil, err
}

var allocator experimental.MemoryAllocator
if ctx != nil {
allocator, _ = ctx.Value(ctxkey.MemoryAllocatorKey{}).(experimental.MemoryAllocator)
}
Comment on lines +366 to +369
Copy link
Member

Choose a reason for hiding this comment

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

We don't check ctx != nil elsewhere, so please simplify as

Suggested change
var allocator experimental.MemoryAllocator
if ctx != nil {
allocator, _ = ctx.Value(ctxkey.MemoryAllocatorKey{}).(experimental.MemoryAllocator)
}
allocator := ctx.Value(ctxkey.MemoryAllocatorKey{}).(experimental.MemoryAllocator)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Actually, there's a specific test case covering this:

for _, ctx := range []context.Context{nil, testCtx} { // Ensure it doesn't crash on nil!
moduleName := t.Name()
m, err := s.Instantiate(ctx, &Module{}, moduleName, nil, nil)

Choose a reason for hiding this comment

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

nil contexts should not be common in Go. There's even a lint to use context.Background() instead of passing nil because a lot of places assume a valid context. It may be reasonable to change this test.


m.buildGlobals(module, m.Engine.FunctionInstanceReference)
m.buildMemory(module)
m.buildMemory(module, allocator)
m.Exports = module.Exports
for _, exp := range m.Exports {
if exp.Type == ExternTypeTable {
Expand Down
Loading