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>
255 lines
6.9 KiB
Go
255 lines
6.9 KiB
Go
package wasm
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"testing"
|
|
"unsafe"
|
|
|
|
"github.com/tetratelabs/wazero/api"
|
|
"github.com/tetratelabs/wazero/internal/testing/require"
|
|
)
|
|
|
|
// testCtx is an arbitrary, non-default context. Non-nil also prevents linter errors.
|
|
var testCtx = context.WithValue(context.Background(), struct{}{}, "arbitrary")
|
|
|
|
func Test_parseGoFunc(t *testing.T) {
|
|
var tests = []struct {
|
|
name string
|
|
input interface{}
|
|
expectNeedsModule bool
|
|
expectedType *FunctionType
|
|
}{
|
|
{
|
|
name: "(ctx) -> ()",
|
|
input: func(context.Context) {},
|
|
expectedType: &FunctionType{},
|
|
},
|
|
{
|
|
name: "(ctx, mod) -> ()",
|
|
input: func(context.Context, api.Module) {},
|
|
expectNeedsModule: true,
|
|
expectedType: &FunctionType{},
|
|
},
|
|
{
|
|
name: "all supported params and i32 result",
|
|
input: func(context.Context, uint32, uint64, float32, float64, uintptr) uint32 { return 0 },
|
|
expectedType: &FunctionType{Params: []ValueType{i32, i64, f32, f64, externref}, Results: []ValueType{i32}},
|
|
},
|
|
{
|
|
name: "all supported params and i32 result - context.Context and api.Module",
|
|
input: func(context.Context, api.Module, uint32, uint64, float32, float64, uintptr) uint32 { return 0 },
|
|
expectNeedsModule: true,
|
|
expectedType: &FunctionType{Params: []ValueType{i32, i64, f32, f64, externref}, Results: []ValueType{i32}},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tc := tt
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
paramTypes, resultTypes, code, err := parseGoReflectFunc(tc.input)
|
|
require.NoError(t, err)
|
|
_, isModuleFunc := code.GoFunc.(api.GoModuleFunction)
|
|
require.Equal(t, tc.expectNeedsModule, isModuleFunc)
|
|
require.Equal(t, tc.expectedType, &FunctionType{Params: paramTypes, Results: resultTypes})
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_parseGoFunc_Errors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
expectedErr string
|
|
}{
|
|
{
|
|
name: "no context",
|
|
input: func() {},
|
|
expectedErr: "invalid signature: context.Context must be param[0]",
|
|
},
|
|
{
|
|
name: "module no context",
|
|
input: func(api.Module) {},
|
|
expectedErr: "invalid signature: api.Module parameter must be preceded by context.Context",
|
|
},
|
|
{
|
|
name: "not a func",
|
|
input: struct{}{},
|
|
expectedErr: "kind != func: struct",
|
|
},
|
|
{
|
|
name: "unsupported param",
|
|
input: func(context.Context, uint32, string) {},
|
|
expectedErr: "param[2] is unsupported: string",
|
|
},
|
|
{
|
|
name: "unsupported result",
|
|
input: func(context.Context) string { return "" },
|
|
expectedErr: "result[0] is unsupported: string",
|
|
},
|
|
{
|
|
name: "error result",
|
|
input: func(context.Context) error { return nil },
|
|
expectedErr: "result[0] is an error, which is unsupported",
|
|
},
|
|
{
|
|
name: "incorrect order",
|
|
input: func(api.Module, context.Context) error { return nil },
|
|
expectedErr: "invalid signature: api.Module parameter must be preceded by context.Context",
|
|
},
|
|
{
|
|
name: "multiple context.Context",
|
|
input: func(context.Context, uint64, context.Context) error { return nil },
|
|
expectedErr: "param[2] is a context.Context, which may be defined only once as param[0]",
|
|
},
|
|
{
|
|
name: "multiple wasm.Module",
|
|
input: func(context.Context, api.Module, uint64, api.Module) error { return nil },
|
|
expectedErr: "param[3] is a api.Module, which may be defined only once as param[0]",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tc := tt
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, _, _, err := parseGoReflectFunc(tc.input)
|
|
require.EqualError(t, err, tc.expectedErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
// stack simulates the value stack in a way easy to be tested.
|
|
type stack struct {
|
|
vals []uint64
|
|
}
|
|
|
|
func (s *stack) pop() (result uint64) {
|
|
stackTopIndex := len(s.vals) - 1
|
|
result = s.vals[stackTopIndex]
|
|
s.vals = s.vals[:stackTopIndex]
|
|
return
|
|
}
|
|
|
|
func TestPopValues(t *testing.T) {
|
|
stackVals := []uint64{1, 2, 3, 4, 5, 6, 7}
|
|
var tests = []struct {
|
|
name string
|
|
count int
|
|
expected []uint64
|
|
}{
|
|
{
|
|
name: "pop zero doesn't allocate a slice ",
|
|
},
|
|
{
|
|
name: "pop 1",
|
|
count: 1,
|
|
expected: []uint64{7},
|
|
},
|
|
{
|
|
name: "pop 2",
|
|
count: 2,
|
|
expected: []uint64{6, 7},
|
|
},
|
|
{
|
|
name: "pop 3",
|
|
count: 3,
|
|
expected: []uint64{5, 6, 7},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tc := tt
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
vals := PopValues(tc.count, (&stack{stackVals}).pop)
|
|
require.Equal(t, tc.expected, vals)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_callGoFunc(t *testing.T) {
|
|
tPtr := uintptr(unsafe.Pointer(t))
|
|
callCtx := &CallContext{}
|
|
|
|
var tests = []struct {
|
|
name string
|
|
input interface{}
|
|
inputParams, expectedResults []uint64
|
|
}{
|
|
{
|
|
name: "context.Context void return",
|
|
input: func(ctx context.Context) {
|
|
require.Equal(t, testCtx, ctx)
|
|
},
|
|
},
|
|
{
|
|
name: "context.Context and api.Module void return",
|
|
input: func(ctx context.Context, m api.Module) {
|
|
require.Equal(t, testCtx, ctx)
|
|
require.Equal(t, callCtx, m)
|
|
},
|
|
},
|
|
{
|
|
name: "all supported params and i32 result - context.Context",
|
|
input: func(ctx context.Context, v uintptr, w uint32, x uint64, y float32, z float64) uint32 {
|
|
require.Equal(t, testCtx, ctx)
|
|
require.Equal(t, tPtr, v)
|
|
require.Equal(t, uint32(math.MaxUint32), w)
|
|
require.Equal(t, uint64(math.MaxUint64), x)
|
|
require.Equal(t, float32(math.MaxFloat32), y)
|
|
require.Equal(t, math.MaxFloat64, z)
|
|
return 100
|
|
},
|
|
inputParams: []uint64{
|
|
api.EncodeExternref(tPtr),
|
|
math.MaxUint32,
|
|
math.MaxUint64,
|
|
api.EncodeF32(math.MaxFloat32),
|
|
api.EncodeF64(math.MaxFloat64),
|
|
},
|
|
expectedResults: []uint64{100},
|
|
},
|
|
{
|
|
name: "all supported params and i32 result - context.Context and api.Module",
|
|
input: func(ctx context.Context, m api.Module, v uintptr, w uint32, x uint64, y float32, z float64) uint32 {
|
|
require.Equal(t, testCtx, ctx)
|
|
require.Equal(t, callCtx, m)
|
|
require.Equal(t, tPtr, v)
|
|
require.Equal(t, uint32(math.MaxUint32), w)
|
|
require.Equal(t, uint64(math.MaxUint64), x)
|
|
require.Equal(t, float32(math.MaxFloat32), y)
|
|
require.Equal(t, math.MaxFloat64, z)
|
|
return 100
|
|
},
|
|
inputParams: []uint64{
|
|
api.EncodeExternref(tPtr),
|
|
math.MaxUint32,
|
|
math.MaxUint64,
|
|
api.EncodeF32(math.MaxFloat32),
|
|
api.EncodeF64(math.MaxFloat64),
|
|
},
|
|
expectedResults: []uint64{100},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
tc := tt
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, _, code, err := parseGoReflectFunc(tc.input)
|
|
require.NoError(t, err)
|
|
|
|
var results []uint64
|
|
switch code.GoFunc.(type) {
|
|
case api.GoFunction:
|
|
results = code.GoFunc.(api.GoFunction).Call(testCtx, tc.inputParams)
|
|
case api.GoModuleFunction:
|
|
results = code.GoFunc.(api.GoModuleFunction).Call(testCtx, callCtx, tc.inputParams)
|
|
default:
|
|
t.Fatal("unexpected type.")
|
|
}
|
|
require.Equal(t, tc.expectedResults, results)
|
|
})
|
|
}
|
|
}
|