Skip to content

FIXES #137: make calling wasm from python 7x faster #139

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

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c724ce1
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Mar 25, 2023
d0dcca2
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Mar 25, 2023
52a0f1d
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Mar 25, 2023
da545aa
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Mar 26, 2023
68fa2a8
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Mar 26, 2023
f00d32a
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Mar 26, 2023
7bee159
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Mar 27, 2023
d0913e8
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Mar 27, 2023
d383a05
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Mar 28, 2023
383cad6
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 2, 2023
dc3d697
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 2, 2023
1446d79
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 4, 2023
1ca73e9
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 4, 2023
e6c054b
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 4, 2023
8bc29e6
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 4, 2023
37b6067
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 4, 2023
2b1d2c0
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 5, 2023
8417fba
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 5, 2023
5bb5c39
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 5, 2023
a67ecc0
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 5, 2023
0388b16
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 5, 2023
4cd48d3
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 5, 2023
9285e6c
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 5, 2023
818bb8d
FIXES #137: make calling wasm from python 7x faster
muayyad-alsadi Apr 5, 2023
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
7 changes: 5 additions & 2 deletions examples/gcd.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Example of instantiating a wasm module and calling an export on it

from wasmtime import Store, Module, Instance

from functools import partial
Copy link
Member

Choose a reason for hiding this comment

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

How come this is being imported and used in the examples? Is this required? If not I think it'd be best to leave this out.

store = Store()
module = Module.from_file(store.engine, './examples/gcd.wat')
instance = Instance(store, module, [])
gcd = instance.exports(store)["gcd"]

gcd_func = partial(gcd, store)
gcd_func_val = partial(gcd._call_val, store)
print("gcd(6, 27) = %d" % gcd(store, 6, 27))
print("gcd(6, 27) = %d" % gcd_func(6, 27))
print("gcd(6, 27) = %d" % gcd_func_val(6, 27))
25 changes: 25 additions & 0 deletions examples/gcd_perf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import time
from math import gcd as math_gcd
from gcd import gcd_func as wasm_gcd, gcd_func_val as wasm_gcd_old


def python_gcd(x, y):
while y:
x, y = y, x % y
return abs(x)


a = 16516842
b = 154654684

print(math_gcd(a, b), python_gcd(a, b), wasm_gcd(a, b), wasm_gcd_old(a, b))

N = 1_000
by_name = locals()
for name in "math_gcd", "python_gcd", "wasm_gcd", "wasm_gcd_old":
gcdf = by_name[name]
start_time = time.perf_counter()
for _ in range(N):
g = gcdf(a, b)
total_time = time.perf_counter() - start_time
print(total_time, "\t\t", name)
36 changes: 36 additions & 0 deletions examples/simd_i8x16.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""
how to call v128 SIMD operations
for more details see https://github.com/WebAssembly/simd/blob/main/proposals/simd/SIMD.md#integer-addition
"""
import ctypes

from functools import partial
from wasmtime import Store, Module, Instance, Func


store = Store()
module = Module(
store.engine,
"""
(module
(func $add_v128 (param $a v128) (param $b v128) (result v128)
local.get $a
local.get $b
i8x16.add
)
(export "add_v128" (func $add_v128))
)
""",
)

instance = Instance(store, module, [])
vector_type = ctypes.c_uint8 * 16
Copy link
Member

Choose a reason for hiding this comment

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

This is not intended to be a public detail of this library that ctypes can be used to pass in data. Currently there's no support for v128 but I think that adding it should be done in a "first class" way that doesn't accidentally expose the internal representation at this time. For example I don't know that this array-based representation is the best for v128.

add_v128_f = instance.exports(store)["add_v128"]
if not isinstance(add_v128_f, Func):
raise TypeError("expecting Func")
add_v128 = partial(add_v128_f, store)
a = vector_type(*(i for i in range(16)))
b = vector_type(*(40 + i for i in range(16)))
c: list[int] = add_v128(a, b) # type: ignore
print([v for v in c])
print([v for v in c] == [i + j for i, j in zip(a, b)])
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[pytest]
addopts = --doctest-modules --flake8 --mypy

30 changes: 30 additions & 0 deletions tests/test_func.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import unittest
import ctypes

from functools import partial
from wasmtime import *


Expand All @@ -17,6 +19,34 @@ def test_add(self):
func = Func(store, ty, lambda a, b: a + b)
self.assertEqual(func(store, 1, 2), 3)

def test_simd_i8x16_add(self):
# i8x16.add is SIMD 128-bit vector of i8 items of size 16
store = Store()
module = Module(
store.engine,
"""
(module
(func $add_v128 (param $a v128) (param $b v128) (result v128)
local.get $a
local.get $b
i8x16.add
)
(export "add_v128" (func $add_v128))
)
""",
)

instance = Instance(store, module, [])
vector_type = ctypes.c_uint8 * 16
add_v128_f = instance.exports(store)["add_v128"]
if not isinstance(add_v128_f, Func):
raise TypeError("expecting Func")
add_v128 = partial(add_v128_f, store)
a = vector_type(*(i for i in range(16)))
b = vector_type(*(40 + i for i in range(16)))
c: list[int] = add_v128(a, b) # type: ignore
self.assertEqual([v for v in c], [i + j for i, j in zip(a, b)])

def test_calls(self):
store = Store()
ty = FuncType([ValType.i32()], [])
Expand Down
102 changes: 87 additions & 15 deletions wasmtime/_func.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
from typing import Callable, Optional, Generic, TypeVar, List, Union, Tuple, cast as cast_type, Sequence
from ._exportable import AsExtern
from ._store import Storelike

from ._bindings import wasmtime_val_raw_t
from ._value import get_valtype_attr, val_getter, val_setter

T = TypeVar('T')
FUNCTIONS: "Slab[Tuple]"
Expand All @@ -16,6 +17,13 @@

class Func:
_func: ffi.wasmtime_func_t
_ty: FuncType
_params_n: int
_results_n: int
_params_str: list[str]
Copy link
Member

Choose a reason for hiding this comment

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

Is it still performant to store strings here? I would expect naively that storing the integer kinds would be more efficient since then checking for funcref/externref would be integer comparisons instead of string comparisons.

_results_str: list[str]
_results_str0: str
Copy link
Member

Choose a reason for hiding this comment

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

I think this would be best stored as Optional[str] to avoid having a dummy value listed for empty results.

_vals_raw_type: type[ctypes.Array[wasmtime_val_raw_t]]

def __init__(self, store: Storelike, ty: FuncType, func: Callable, access_caller: bool = False):
"""
Expand All @@ -27,11 +35,11 @@ def __init__(self, store: Storelike, ty: FuncType, func: Callable, access_caller
set to `True` then the first argument given to `func` is an instance of
type `Caller` below.
"""

if not isinstance(store, Store):
raise TypeError("expected a Store")
if not isinstance(ty, FuncType):
raise TypeError("expected a FuncType")
self._init_call(ty)
idx = FUNCTIONS.allocate((func, ty.results, access_caller))
_func = ffi.wasmtime_func_t()
ffi.wasmtime_func_new(
Expand All @@ -56,21 +64,39 @@ def type(self, store: Storelike) -> FuncType:
ptr = ffi.wasmtime_func_type(store._context, byref(self._func))
return FuncType._from_ptr(ptr, None)

def __call__(self, store: Storelike, *params: IntoVal) -> Union[IntoVal, Sequence[IntoVal], None]:
"""
Calls this function with the given parameters

Parameters can either be a `Val` or a native python value which can be
converted to a `Val` of the corresponding correct type

Returns `None` if this func has 0 return types
Returns a single value if the func has 1 return type
Returns a list if the func has more than 1 return type
def _create_raw_vals(self, *params: IntoVal) -> ctypes.Array[wasmtime_val_raw_t]:
raw = self._vals_raw_type()
for i, param_str in enumerate(self._params_str):
val_setter(self._func.store_id, raw[i], param_str, params[i])
return raw

Note that you can also use the `__call__` method and invoke a `Func` as
if it were a function directly.
def _extract_return(self, vals_raw: ctypes.Array[wasmtime_val_raw_t]) -> Union[IntoVal, Sequence[IntoVal], None]:
if self._results_n == 0:
return None
if self._results_n == 1:
return val_getter(self._func.store_id, vals_raw[0], self._results_str0)
# we can use tuple construct, but I'm using list for compatability
Copy link
Member

Choose a reason for hiding this comment

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

This comment can be removed. Either the result should continue to be a list in which case there's no need for the comment, or the result should change to a tuple, in which case there's no need for the comment.

return [val_getter(self._func.store_id, val_raw, ret_str) for val_raw, ret_str in zip(vals_raw, self._results_str)]

def _init_call(self, ty: FuncType) -> None:
"""init signature properties used by call"""
self._ty = ty
ty_params = ty.params
ty_results = ty.results
params_n = len(ty_params)
results_n = len(ty_results)
self._params_str = [get_valtype_attr(i) for i in ty_params]
self._results_str = [get_valtype_attr(i) for i in ty_results]
self._results_str0 = get_valtype_attr(ty_results[0]) if results_n else 'i32'
self._params_n = params_n
self._results_n = results_n
n = max(params_n, results_n)
self._vals_raw_type = wasmtime_val_raw_t * n

def _call_val(self, store: Storelike, *params: IntoVal) -> Union[IntoVal, Sequence[IntoVal], None]:
"""
internal implementation of calling a function that uses `wasmtime_func_call`
"""

ty = self.type(store)
param_tys = ty.params
if len(params) > len(param_tys):
Expand Down Expand Up @@ -110,6 +136,52 @@ def __call__(self, store: Storelike, *params: IntoVal) -> Union[IntoVal, Sequenc
else:
return results

def _call_raw(self, store: Storelike, *params: IntoVal) -> Union[IntoVal, Sequence[IntoVal], None]:
"""
internal implementation of calling a function that uses `wasmtime_func_call_unchecked`
"""
if getattr(self, "_ty", None) is None:
Copy link
Member

Choose a reason for hiding this comment

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

Instead of falling back to getattr can the mypy-listed-type of self._ty be Optional?

self._init_call(self.type(store))
params_n = len(params)
if params_n > self._params_n:
raise WasmtimeError("too many parameters provided: given %s, expected %s" %
(params_n, self._params_n))
if params_n < self._params_n:
raise WasmtimeError("too few parameters provided: given %s, expected %s" %
(params_n, self._params_n))
vals_raw = self._create_raw_vals(*params)
vals_raw_ptr = ctypes.cast(vals_raw, ctypes.POINTER(wasmtime_val_raw_t))
# according to https://docs.wasmtime.dev/c-api/func_8h.html#a3b54596199641a8647a7cd89f322966f
# it's safe to call wasmtime_func_call_unchecked because
# - we allocate enough space to hold all the parameters and all the results
# - we set proper types by reading types from ty
# - externref and funcref are valid within the store being called
with enter_wasm(store) as trap:
error = ffi.wasmtime_func_call_unchecked(
store._context,
byref(self._func),
vals_raw_ptr,
trap)
if error:
raise WasmtimeError._from_ptr(error)
return self._extract_return(vals_raw)

def __call__(self, store: Storelike, *params: IntoVal) -> Union[IntoVal, Sequence[IntoVal], None]:
"""
Calls this function with the given parameters

Parameters can either be a `Val` or a native python value which can be
converted to a `Val` of the corresponding correct type

Returns `None` if this func has 0 return types
Returns a single value if the func has 1 return type
Returns a list if the func has more than 1 return type

Note that you can also use the `__call__` method and invoke a `Func` as
if it were a function directly.
"""
return self._call_raw(store, *params)

def _as_extern(self) -> ffi.wasmtime_extern_t:
union = ffi.wasmtime_extern_union(func=self._func)
return ffi.wasmtime_extern_t(ffi.WASMTIME_EXTERN_FUNC, union)
Expand Down
2 changes: 1 addition & 1 deletion wasmtime/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def __str__(self) -> str:
return 'anyref'
if kind == ffi.WASM_FUNCREF.value:
return 'funcref'
return 'ValType(%d)' % kind.value
return 'ValType(%d)' % kind # type: ignore

def __del__(self) -> None:
if not hasattr(self, '_owner') or not hasattr(self, '_ptr'):
Expand Down
69 changes: 69 additions & 0 deletions wasmtime/_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@
import ctypes
import typing

val_id2attr = {
WASMTIME_I32.value: 'i32',
WASMTIME_I64.value: 'i64',
WASMTIME_F32.value: 'f32',
WASMTIME_F64.value: 'f64',
WASMTIME_V128.value: 'v128',
WASMTIME_FUNCREF.value: 'funcref',
WASMTIME_EXTERNREF.value: 'externref',
WASM_FUNCREF.value: 'funcref',
WASM_ANYREF.value: 'externref',
}


@ctypes.CFUNCTYPE(None, c_void_p)
def _externref_finalizer(extern_id: int) -> None:
Expand All @@ -25,6 +37,63 @@ def _unintern(val: int) -> typing.Any:
return Val._id_to_extern.get(val)


def get_valtype_attr(ty: ValType) -> str:
Copy link
Member

Choose a reason for hiding this comment

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

Can this have documentation for what it's doing?

return val_id2attr[wasm_valtype_kind(ty._ptr)] # type: ignore
Copy link
Member

Choose a reason for hiding this comment

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

How come this is type: ignore?



def val_getter(store_id: int, val_raw: wasmtime_val_raw_t, attr: str) -> typing.Union[int, float, "wasmtime.Func", typing.Any]:
val = getattr(val_raw, attr)

if attr == 'externref':
ptr = ctypes.POINTER(wasmtime_externref_t)
if not val:
return None
ffi = ptr.from_address(val)
if not ffi:
return None
extern_id = wasmtime_externref_data(ffi)
return _unintern(extern_id)
Comment on lines +48 to +55
Copy link
Member

Choose a reason for hiding this comment

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

Can this avoid duplicating Val._as_externref?

elif attr == 'funcref':
if val == 0:
return None
f = wasmtime_func_t()
f.store_id = store_id
f.index = val
return wasmtime.Func._from_raw(f)
Comment on lines +57 to +62
Copy link
Member

Choose a reason for hiding this comment

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

Can this avoid duplicating Val._as_func?

return val


def val_setter(store_id: int, dst: wasmtime_val_raw_t, attr: str, val: "IntoVal") -> None:
casted: typing.Union[Any, int, float, None, wasmtime.Func]
if attr == 'externref':
if isinstance(val, Val) and val._raw and val._raw.kind == WASMTIME_EXTERNREF.value:
casted = ctypes.addressof(val._raw.of.externref)
else:
ex = Val.externref(val)._raw
if ex:
casted = ctypes.addressof(ex.of.externref)
Copy link
Member

Choose a reason for hiding this comment

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

Like below for funcref I don't think that this is correct. This needs to call wasmtime_externref_to_raw.

else:
casted = 0
elif attr == 'funcref':
if isinstance(val, Val) and val._raw and val._raw.kind == WASMTIME_FUNCREF.value:
casted = val._raw.of.funcref.index
Copy link
Member

Choose a reason for hiding this comment

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

I don't think that this is quite right because it's not copying over the store_id. Same for below this is only copying in the index and not the store_id. Additionally the store needs to be checked for this branch.

elif isinstance(val, wasmtime.Func):
if val._func.store_id != store_id:
raise WasmtimeError("passed funcref does not belong to same store")
casted = val._func.index
Copy link
Member

Choose a reason for hiding this comment

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

Passing the index here is not correct. This needs to invoke wasmtime_func_to_raw. This also shows that this needs to have tests passing funcref, for example.

else:
raise WasmtimeError("expecting param of type funcref got " + type(val).__name__)
else:
if isinstance(val, Val):
if val._raw:
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be best to assert(val._raw) here and a few places above. Handling a case where that is None I don't think is appropriate to use casted = 0 in all situations.

casted = getattr(val._raw.of, attr)
else:
casted = 0
else:
casted = val
setattr(dst, attr, casted)


class Val:
# We can't let the extern values we wrap `externref`s around be GC'd, so we
# pin them in `_id_to_extern`. Additionally, we might make multiple
Expand Down