This PR follows @hafeidejiangyou advice to not only enable end users to
avoid reflection when calling host functions, but also use that approach
ourselves internally. The performance results are staggering and will be
noticable in high performance applications.
Before
```
BenchmarkHostCall/Call
BenchmarkHostCall/Call-16 1000000 1050 ns/op
Benchmark_EnvironGet/environGet
Benchmark_EnvironGet/environGet-16 525492 2224 ns/op
```
Now
```
BenchmarkHostCall/Call
BenchmarkHostCall/Call-16 14807203 83.22 ns/op
Benchmark_EnvironGet/environGet
Benchmark_EnvironGet/environGet-16 951690 1054 ns/op
```
To accomplish this, this PR consolidates code around host function
definition and enables a fast path for functions where the user takes
responsibility for defining its WebAssembly mappings. Existing users
will need to change their code a bit, as signatures have changed.
For example, we are now more strict that all host functions require a
context parameter zero. Also, we've replaced
`HostModuleBuilder.ExportFunction` and `ExportFunctions` with a new type
`HostFunctionBuilder` that consolidates the responsibility and the
documentation.
```diff
ctx := context.Background()
-hello := func() {
+hello := func(context.Context) {
fmt.Fprintln(stdout, "hello!")
}
-_, err := r.NewHostModuleBuilder("env").ExportFunction("hello", hello).Instantiate(ctx, r)
+_, err := r.NewHostModuleBuilder("env").
+ NewFunctionBuilder().WithFunc(hello).Export("hello").
+ Instantiate(ctx, r)
```
Power users can now use `HostFunctionBuilder` to define functions that
won't use reflection. There are two choices of interfaces to use
depending on if that function needs access to the calling module or not:
`api.GoFunction` and `api.GoModuleFunction`. Here's an example defining
one.
```go
builder.WithGoFunction(api.GoFunc(func(ctx context.Context, params []uint64) []uint64 {
x, y := uint32(params[0]), uint32(params[1])
sum := x + y
return []uint64{sum}
}, []api.ValueType{api.ValueTypeI32, api.ValueTypeI32}, []api.ValueType{api.ValueTypeI32})
```
As you'll notice and as documented, this approach is more verbose and
not for everyone. If you aren't making a low-level library, you are
likely able to afford the 1us penalty for the convenience of reflection.
However, we are happy to enable this option for foundational libraries
and those with high performance requirements (like ourselves)!
Fixes #825
Signed-off-by: Adrian Cole <adrian@tetrate.io>
359 lines
12 KiB
Go
359 lines
12 KiB
Go
package wazero
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/tetratelabs/wazero/api"
|
|
"github.com/tetratelabs/wazero/internal/testing/require"
|
|
"github.com/tetratelabs/wazero/internal/wasm"
|
|
)
|
|
|
|
// TestNewHostModuleBuilder_Compile only covers a few scenarios to avoid duplicating tests in internal/wasm/host_test.go
|
|
func TestNewHostModuleBuilder_Compile(t *testing.T) {
|
|
i32, i64 := api.ValueTypeI32, api.ValueTypeI64
|
|
|
|
uint32_uint32 := func(context.Context, uint32) uint32 {
|
|
return 0
|
|
}
|
|
uint64_uint32 := func(context.Context, uint64) uint32 {
|
|
return 0
|
|
}
|
|
|
|
gofunc1 := api.GoFunc(func(ctx context.Context, params []uint64) []uint64 {
|
|
return []uint64{0}
|
|
})
|
|
gofunc2 := api.GoFunc(func(ctx context.Context, params []uint64) []uint64 {
|
|
return []uint64{0}
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
input func(Runtime) HostModuleBuilder
|
|
expected *wasm.Module
|
|
}{
|
|
{
|
|
name: "empty",
|
|
input: func(r Runtime) HostModuleBuilder {
|
|
return r.NewHostModuleBuilder("")
|
|
},
|
|
expected: &wasm.Module{},
|
|
},
|
|
{
|
|
name: "only name",
|
|
input: func(r Runtime) HostModuleBuilder {
|
|
return r.NewHostModuleBuilder("env")
|
|
},
|
|
expected: &wasm.Module{NameSection: &wasm.NameSection{ModuleName: "env"}},
|
|
},
|
|
{
|
|
name: "WithFunc",
|
|
input: func(r Runtime) HostModuleBuilder {
|
|
return r.NewHostModuleBuilder("").
|
|
NewFunctionBuilder().WithFunc(uint32_uint32).Export("1")
|
|
},
|
|
expected: &wasm.Module{
|
|
TypeSection: []*wasm.FunctionType{
|
|
{Params: []api.ValueType{i32}, Results: []api.ValueType{i32}},
|
|
},
|
|
FunctionSection: []wasm.Index{0},
|
|
CodeSection: []*wasm.Code{wasm.MustParseGoReflectFuncCode(uint32_uint32)},
|
|
ExportSection: []*wasm.Export{
|
|
{Name: "1", Type: wasm.ExternTypeFunc, Index: 0},
|
|
},
|
|
NameSection: &wasm.NameSection{
|
|
FunctionNames: wasm.NameMap{{Index: 0, Name: "1"}},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "WithFunc WithName WithParameterNames",
|
|
input: func(r Runtime) HostModuleBuilder {
|
|
return r.NewHostModuleBuilder("").NewFunctionBuilder().
|
|
WithFunc(uint32_uint32).
|
|
WithName("get").WithParameterNames("x").
|
|
Export("1")
|
|
},
|
|
expected: &wasm.Module{
|
|
TypeSection: []*wasm.FunctionType{
|
|
{Params: []api.ValueType{i32}, Results: []api.ValueType{i32}},
|
|
},
|
|
FunctionSection: []wasm.Index{0},
|
|
CodeSection: []*wasm.Code{wasm.MustParseGoReflectFuncCode(uint32_uint32)},
|
|
ExportSection: []*wasm.Export{
|
|
{Name: "1", Type: wasm.ExternTypeFunc, Index: 0},
|
|
},
|
|
NameSection: &wasm.NameSection{
|
|
FunctionNames: wasm.NameMap{{Index: 0, Name: "get"}},
|
|
LocalNames: []*wasm.NameMapAssoc{{Index: 0, NameMap: wasm.NameMap{{Index: 0, Name: "x"}}}},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "WithFunc overwrites existing",
|
|
input: func(r Runtime) HostModuleBuilder {
|
|
return r.NewHostModuleBuilder("").
|
|
NewFunctionBuilder().WithFunc(uint32_uint32).Export("1").
|
|
NewFunctionBuilder().WithFunc(uint64_uint32).Export("1")
|
|
},
|
|
expected: &wasm.Module{
|
|
TypeSection: []*wasm.FunctionType{
|
|
{Params: []api.ValueType{i64}, Results: []api.ValueType{i32}},
|
|
},
|
|
FunctionSection: []wasm.Index{0},
|
|
CodeSection: []*wasm.Code{wasm.MustParseGoReflectFuncCode(uint64_uint32)},
|
|
ExportSection: []*wasm.Export{
|
|
{Name: "1", Type: wasm.ExternTypeFunc, Index: 0},
|
|
},
|
|
NameSection: &wasm.NameSection{
|
|
FunctionNames: wasm.NameMap{{Index: 0, Name: "1"}},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "WithFunc twice",
|
|
input: func(r Runtime) HostModuleBuilder {
|
|
// Intentionally out of order
|
|
return r.NewHostModuleBuilder("").
|
|
NewFunctionBuilder().WithFunc(uint64_uint32).Export("2").
|
|
NewFunctionBuilder().WithFunc(uint32_uint32).Export("1")
|
|
},
|
|
expected: &wasm.Module{
|
|
TypeSection: []*wasm.FunctionType{
|
|
{Params: []api.ValueType{i32}, Results: []api.ValueType{i32}},
|
|
{Params: []api.ValueType{i64}, Results: []api.ValueType{i32}},
|
|
},
|
|
FunctionSection: []wasm.Index{0, 1},
|
|
CodeSection: []*wasm.Code{wasm.MustParseGoReflectFuncCode(uint32_uint32), wasm.MustParseGoReflectFuncCode(uint64_uint32)},
|
|
ExportSection: []*wasm.Export{
|
|
{Name: "1", Type: wasm.ExternTypeFunc, Index: 0},
|
|
{Name: "2", Type: wasm.ExternTypeFunc, Index: 1},
|
|
},
|
|
NameSection: &wasm.NameSection{
|
|
FunctionNames: wasm.NameMap{{Index: 0, Name: "1"}, {Index: 1, Name: "2"}},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "WithGoFunction",
|
|
input: func(r Runtime) HostModuleBuilder {
|
|
return r.NewHostModuleBuilder("").
|
|
NewFunctionBuilder().
|
|
WithGoFunction(gofunc1, []api.ValueType{i32}, []api.ValueType{i32}).
|
|
Export("1")
|
|
},
|
|
expected: &wasm.Module{
|
|
TypeSection: []*wasm.FunctionType{
|
|
{Params: []api.ValueType{i32}, Results: []api.ValueType{i32}},
|
|
},
|
|
FunctionSection: []wasm.Index{0},
|
|
CodeSection: []*wasm.Code{
|
|
{IsHostFunction: true, GoFunc: gofunc1},
|
|
},
|
|
ExportSection: []*wasm.Export{
|
|
{Name: "1", Type: wasm.ExternTypeFunc, Index: 0},
|
|
},
|
|
NameSection: &wasm.NameSection{
|
|
FunctionNames: wasm.NameMap{{Index: 0, Name: "1"}},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "WithGoFunction WithName WithParameterNames",
|
|
input: func(r Runtime) HostModuleBuilder {
|
|
return r.NewHostModuleBuilder("").NewFunctionBuilder().
|
|
WithGoFunction(gofunc1, []api.ValueType{i32}, []api.ValueType{i32}).
|
|
WithName("get").WithParameterNames("x").
|
|
Export("1")
|
|
},
|
|
expected: &wasm.Module{
|
|
TypeSection: []*wasm.FunctionType{
|
|
{Params: []api.ValueType{i32}, Results: []api.ValueType{i32}},
|
|
},
|
|
FunctionSection: []wasm.Index{0},
|
|
CodeSection: []*wasm.Code{
|
|
{IsHostFunction: true, GoFunc: gofunc1},
|
|
},
|
|
ExportSection: []*wasm.Export{
|
|
{Name: "1", Type: wasm.ExternTypeFunc, Index: 0},
|
|
},
|
|
NameSection: &wasm.NameSection{
|
|
FunctionNames: wasm.NameMap{{Index: 0, Name: "get"}},
|
|
LocalNames: []*wasm.NameMapAssoc{{Index: 0, NameMap: wasm.NameMap{{Index: 0, Name: "x"}}}},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "WithGoFunction overwrites existing",
|
|
input: func(r Runtime) HostModuleBuilder {
|
|
return r.NewHostModuleBuilder("").
|
|
NewFunctionBuilder().
|
|
WithGoFunction(gofunc1, []api.ValueType{i32}, []api.ValueType{i32}).
|
|
Export("1").
|
|
NewFunctionBuilder().
|
|
WithGoFunction(gofunc2, []api.ValueType{i64}, []api.ValueType{i32}).
|
|
Export("1")
|
|
},
|
|
expected: &wasm.Module{
|
|
TypeSection: []*wasm.FunctionType{
|
|
{Params: []api.ValueType{i64}, Results: []api.ValueType{i32}},
|
|
},
|
|
FunctionSection: []wasm.Index{0},
|
|
CodeSection: []*wasm.Code{
|
|
{IsHostFunction: true, GoFunc: gofunc2},
|
|
},
|
|
ExportSection: []*wasm.Export{
|
|
{Name: "1", Type: wasm.ExternTypeFunc, Index: 0},
|
|
},
|
|
NameSection: &wasm.NameSection{
|
|
FunctionNames: wasm.NameMap{{Index: 0, Name: "1"}},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "WithGoFunction twice",
|
|
input: func(r Runtime) HostModuleBuilder {
|
|
// Intentionally out of order
|
|
return r.NewHostModuleBuilder("").
|
|
NewFunctionBuilder().
|
|
WithGoFunction(gofunc2, []api.ValueType{i64}, []api.ValueType{i32}).
|
|
Export("2").
|
|
NewFunctionBuilder().
|
|
WithGoFunction(gofunc1, []api.ValueType{i32}, []api.ValueType{i32}).
|
|
Export("1")
|
|
},
|
|
expected: &wasm.Module{
|
|
TypeSection: []*wasm.FunctionType{
|
|
{Params: []api.ValueType{i32}, Results: []api.ValueType{i32}},
|
|
{Params: []api.ValueType{i64}, Results: []api.ValueType{i32}},
|
|
},
|
|
FunctionSection: []wasm.Index{0, 1},
|
|
CodeSection: []*wasm.Code{
|
|
{IsHostFunction: true, GoFunc: gofunc1},
|
|
{IsHostFunction: true, GoFunc: gofunc2},
|
|
},
|
|
ExportSection: []*wasm.Export{
|
|
{Name: "1", Type: wasm.ExternTypeFunc, Index: 0},
|
|
{Name: "2", Type: wasm.ExternTypeFunc, Index: 1},
|
|
},
|
|
NameSection: &wasm.NameSection{
|
|
FunctionNames: wasm.NameMap{{Index: 0, Name: "1"}, {Index: 1, Name: "2"}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tc := tt
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
b := tc.input(NewRuntime(testCtx)).(*hostModuleBuilder)
|
|
compiled, err := b.Compile(testCtx)
|
|
require.NoError(t, err)
|
|
m := compiled.(*compiledModule)
|
|
|
|
requireHostModuleEquals(t, tc.expected, m.module)
|
|
|
|
require.Equal(t, b.r.store.Engine, m.compiledEngine)
|
|
|
|
// Built module must be instantiable by Engine.
|
|
mod, err := b.r.InstantiateModule(testCtx, m, NewModuleConfig())
|
|
require.NoError(t, err)
|
|
|
|
// Closing the module shouldn't remove the compiler cache
|
|
require.NoError(t, mod.Close(testCtx))
|
|
require.Equal(t, uint32(1), b.r.store.Engine.CompiledModuleCount())
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewHostModuleBuilder_Compile_Errors only covers a few scenarios to avoid
|
|
// duplicating tests in internal/wasm/host_test.go
|
|
func TestNewHostModuleBuilder_Compile_Errors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input func(Runtime) HostModuleBuilder
|
|
expectedErr string
|
|
}{
|
|
{
|
|
name: "error compiling", // should fail due to missing result.
|
|
input: func(rt Runtime) HostModuleBuilder {
|
|
return rt.NewHostModuleBuilder("").NewFunctionBuilder().
|
|
WithFunc(&wasm.HostFunc{
|
|
ExportNames: []string{"fn"},
|
|
ResultTypes: []wasm.ValueType{wasm.ValueTypeI32},
|
|
Code: &wasm.Code{IsHostFunction: true, Body: []byte{wasm.OpcodeEnd}},
|
|
}).Export("fn")
|
|
},
|
|
expectedErr: `invalid function[0] export["fn"]: not enough results
|
|
have ()
|
|
want (i32)`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tc := tt
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, e := tc.input(NewRuntime(testCtx)).Compile(testCtx)
|
|
require.EqualError(t, e, tc.expectedErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewHostModuleBuilder_Instantiate ensures Runtime.InstantiateModule is called on success.
|
|
func TestNewHostModuleBuilder_Instantiate(t *testing.T) {
|
|
r := NewRuntime(testCtx)
|
|
m, err := r.NewHostModuleBuilder("env").Instantiate(testCtx, r)
|
|
require.NoError(t, err)
|
|
|
|
// If this was instantiated, it would be added to the store under the same name
|
|
require.Equal(t, r.(*runtime).ns.Module("env"), m)
|
|
|
|
// Closing the module should remove the compiler cache
|
|
require.NoError(t, m.Close(testCtx))
|
|
require.Zero(t, r.(*runtime).store.Engine.CompiledModuleCount())
|
|
}
|
|
|
|
// TestNewHostModuleBuilder_Instantiate_Errors ensures errors propagate from Runtime.InstantiateModule
|
|
func TestNewHostModuleBuilder_Instantiate_Errors(t *testing.T) {
|
|
r := NewRuntime(testCtx)
|
|
_, err := r.NewHostModuleBuilder("env").Instantiate(testCtx, r)
|
|
require.NoError(t, err)
|
|
|
|
_, err = r.NewHostModuleBuilder("env").Instantiate(testCtx, r)
|
|
require.EqualError(t, err, "module[env] has already been instantiated")
|
|
}
|
|
|
|
// requireHostModuleEquals is redefined from internal/wasm/host_test.go to avoid an import cycle extracting it.
|
|
func requireHostModuleEquals(t *testing.T, expected, actual *wasm.Module) {
|
|
// `require.Equal(t, expected, actual)` fails reflect pointers don't match, so brute compare:
|
|
for _, tp := range expected.TypeSection {
|
|
tp.CacheNumInUint64()
|
|
}
|
|
require.Equal(t, expected.TypeSection, actual.TypeSection)
|
|
require.Equal(t, expected.ImportSection, actual.ImportSection)
|
|
require.Equal(t, expected.FunctionSection, actual.FunctionSection)
|
|
require.Equal(t, expected.TableSection, actual.TableSection)
|
|
require.Equal(t, expected.MemorySection, actual.MemorySection)
|
|
require.Equal(t, expected.GlobalSection, actual.GlobalSection)
|
|
require.Equal(t, expected.ExportSection, actual.ExportSection)
|
|
require.Equal(t, expected.StartSection, actual.StartSection)
|
|
require.Equal(t, expected.ElementSection, actual.ElementSection)
|
|
require.Equal(t, expected.DataSection, actual.DataSection)
|
|
require.Equal(t, expected.NameSection, actual.NameSection)
|
|
|
|
// Special case because reflect.Value can't be compared with Equals
|
|
// TODO: This is copy/paste with /internal/wasm/host_test.go
|
|
require.Equal(t, len(expected.CodeSection), len(actual.CodeSection))
|
|
for i, c := range expected.CodeSection {
|
|
actualCode := actual.CodeSection[i]
|
|
require.True(t, actualCode.IsHostFunction)
|
|
require.Equal(t, c.GoFunc, actualCode.GoFunc)
|
|
|
|
// Not wasm
|
|
require.Nil(t, actualCode.Body)
|
|
require.Nil(t, actualCode.LocalTypes)
|
|
}
|
|
}
|