Skip to content

Commit

Permalink
co_await support for Embind
Browse files Browse the repository at this point in the history
This adds support for `co_await`-ing Promises represented by `emscripten::val`.

The surrounding coroutine should also return `emscripten::val`, which will
be a promise representing the whole coroutine's return value.

Note that this feature uses LLVM coroutines and so, doesn't depend on
either Asyncify or JSPI. It doesn't pause the entire program, but only
the coroutine itself, so it serves somewhat different usecases even though
all those features operate on promises.

Nevertheless, if you are not implementing a syscall that must behave as-if
it was synchronous, but instead simply want to await on some async operations
and return a new promise to the user, this feature will be much more efficient.

Here's a simple benchmark measuring runtime overhead from awaiting on a no-op Promise
repeatedly in a deep call stack:

```cpp

using namespace emscripten;

// clang-format off
EM_JS(EM_VAL, wait_impl, (), {
  return Emval.toHandle(Promise.resolve());
});
// clang-format on

val wait() { return val::take_ownership(wait_impl()); }

val coro_co_await(int depth) {
  co_await wait();
  if (depth > 0) {
    co_await coro_co_await(depth - 1);
  }
  co_return val();
}

val asyncify_val_await(int depth) {
  wait().await();
  if (depth > 0) {
    asyncify_val_await(depth - 1);
  }
  return val();
}

EMSCRIPTEN_BINDINGS(bench) {
  function("coro_co_await", coro_co_await);
  function("asyncify_val_await", asyncify_val_await, async());
}
```

And the JS runner also comparing with pure-JS implementation:

```js
import Benchmark from 'benchmark';
import initModule from './async-bench.mjs';

let Module = await initModule();
let suite = new Benchmark.Suite();

function addAsyncBench(name, func) {
	suite.add(name, {
		defer: true,
		fn: (deferred) => func(1000).then(() => deferred.resolve()),
	});
}

for (const name of ['coro_co_await', 'asyncify_val_await']) {
  addAsyncBench(name, Module[name]);
}

addAsyncBench('pure_js', async function pure_js(depth) {
  await Promise.resolve();
  if (depth > 0) {
    await pure_js(depth - 1);
  }
});

suite
  .on('cycle', function (event) {
    console.log(String(event.target));
  })
  .run({async: true});
```

Results with regular Asyncify (I had to bump up `ASYNCIFY_STACK_SIZE` to accomodate said deep stack):

```bash
> ./emcc async-bench.cpp -std=c++20 -O3 -o async-bench.mjs --bind -s ASYNCIFY -s ASYNCIFY_STACK_SIZE=1000000
> node --no-liftoff --no-wasm-tier-up --no-wasm-lazy-compilation --no-sparkplug async-bench-runner.mjs

coro_co_await x 727 ops/sec ±10.59% (47 runs sampled)
asyncify_val_await x 58.05 ops/sec ±6.91% (53 runs sampled)
pure_js x 3,022 ops/sec ±8.06% (52 runs sampled)
```

Results with JSPI (I had to disable `DYNAMIC_EXECUTION` because I was getting "RuntimeError: table index is out of bounds" in random places depending on optimisation mode - JSPI miscompilation?):

```bash
> ./emcc async-bench.cpp -std=c++20 -O3 -o async-bench.mjs --bind -s ASYNCIFY=2 -s DYNAMIC_EXECUTION=0
> node --no-liftoff --no-wasm-tier-up --no-wasm-lazy-compilation --no-sparkplug --experimental-wasm-stack-switching async-bench-runner.mjs

coro_co_await x 955 ops/sec ±9.25% (62 runs sampled)
asyncify_val_await x 924 ops/sec ±8.27% (62 runs sampled)
pure_js x 3,258 ops/sec ±8.98% (53 runs sampled)
```

So the performance is much faster than regular Asyncify, and on par with JSPI.

Fixes #20413.
  • Loading branch information
RReverser committed Nov 3, 2023
1 parent c1475fa commit 44b2c0d
Show file tree
Hide file tree
Showing 9 changed files with 301 additions and 12 deletions.
2 changes: 2 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ See docs/process.md for more on how version tagging works.
sidestep some of the issues with legacy cmd.exe, but developers must
explicitly opt-in to running PowerShell scripts in system settings or
via the `Set-ExecutionPolicy` command. (#20416)
- `emscripten::val` now supports C++20 `co_await` operator for JavaScript
`Promise`s. (#20420)

3.1.47 - 10/09/23
-----------------
Expand Down
1 change: 1 addition & 0 deletions emscripten.py
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,7 @@ def create_pointer_conversion_wrappers(metadata):
'stbi_load_from_memory': 'pp_ppp_',
'emscripten_proxy_finish': '_p',
'emscripten_proxy_execute_queue': '_p',
'_emval_coro_resume': '_pp',
}

for function in settings.SIGNATURE_CONVERSIONS:
Expand Down
58 changes: 47 additions & 11 deletions site/source/docs/api_reference/val.h.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ Guide material for this class can be found in :ref:`embind-val-guide`.
}
See :ref:`embind-val-guide` for other examples.


.. warning:: JavaScript values can't be shared across threads, so neither can ``val`` instances that bind them.

For example, if you want to cache some JavaScript global as a ``val``, you need to retrieve and bind separate instances of that global by its name in each thread.
The easiest way to do this is with a ``thread_local`` declaration:

Expand Down Expand Up @@ -108,11 +108,11 @@ Guide material for this class can be found in :ref:`embind-val-guide`.

.. _val_as_handle:
.. cpp:function:: EM_VAL as_handle() const

Returns a raw handle representing this ``val``. This can be used for
passing raw value handles to JavaScript and retrieving the values on the
other side via ``Emval.toValue`` function. Example:

.. code:: cpp
EM_JS(void, log_value, (EM_VAL val_handle), {
Expand All @@ -130,16 +130,16 @@ Guide material for this class can be found in :ref:`embind-val-guide`.
from JavaScript, where the JavaScript side should wrap a value with
``Emval.toHandle``, pass it to C++, and then C++ can use ``take_ownership``
to convert it to a ``val`` instance. Example:

.. code:: cpp
EM_ASYNC_JS(EM_VAL, fetch_json_from_url, (const char *url_ptr), {
var url = UTF8ToString(url);
var response = await fetch(url);
var json = await response.json();
return Emval.toHandle(json);
});
val obj = val::take_ownership(fetch_json_from_url("https://httpbin.org/json"));
std::string author = obj["slideshow"]["author"].as<std::string>();
Expand Down Expand Up @@ -169,12 +169,12 @@ Guide material for this class can be found in :ref:`embind-val-guide`.


.. cpp:function:: val(val&& v)

Moves ownership of a value to a new ``val`` instance.


.. cpp:function:: val(const val& v)

Creates another reference to the same value behind the provided ``val`` instance.


Expand All @@ -184,7 +184,7 @@ Guide material for this class can be found in :ref:`embind-val-guide`.


.. cpp:function:: val& operator=(val&& v)

Removes a reference to the currently bound value and takes over the provided one.


Expand Down Expand Up @@ -217,7 +217,7 @@ Guide material for this class can be found in :ref:`embind-val-guide`.


.. cpp:function:: val operator()(Args&&... args) const

Assumes that current value is a function, and invokes it with provided arguments.


Expand Down Expand Up @@ -262,6 +262,42 @@ Guide material for this class can be found in :ref:`embind-val-guide`.

.. note:: This method requires :ref:`Asyncify` to be enabled.

.. cpp:function:: val operator co_await() const

The ``co_await`` operator allows awaiting JavaScript promises represented by ``val``.

It's compatible with any C++20 coroutines, but should be normally used inside
a ``val``-returning coroutine which will also become a ``Promise``.

For example, it allows you to implement the equivalent of this JavaScript ``async``/``await`` function:

.. code:: javascript
async function foo() {
const response = await fetch("http://url");
const json = await response.json();
return json;
}
export { foo };
as a C++ coroutine:

.. code:: cpp
val foo() {
val response = co_await val::global("fetch")(std::string("http://url"));
val json = co_await response.call<val>("json");
return json;
}
EMSCRIPTEN_BINDINGS(module) {
function("foo", &foo);
}
Unlike the ``await()`` method, it doesn't need Asyncify as it uses native C++ coroutine transform.

:returns: A ``val`` representing the fulfilled value of this promise.

.. cpp:type: EMSCRIPTEN_SYMBOL(name)
Expand Down
29 changes: 29 additions & 0 deletions src/embind/emval.js
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,35 @@ var LibraryEmVal = {
var result = iterator.next();
return result.done ? 0 : Emval.toHandle(result.value);
},

_emval_coro_suspend__deps: ['$Emval', '_emval_coro_resume'],
_emval_coro_suspend: (promiseHandle, awaiterPtr) => {
Emval.toValue(promiseHandle).then(result => {
__emval_coro_resume(awaiterPtr, Emval.toHandle(result));
});
},

_emval_coro_make_promise__deps: ['$Emval', '__cxa_rethrow'],
_emval_coro_make_promise: (resolveHandlePtr, rejectHandlePtr) => {
return Emval.toHandle(new Promise((resolve, reject) => {
const rejectWithCurrentException = () => {
try {
// Use __cxa_rethrow which already has mechanism for generating
// user-friendly error message and stacktrace from C++ exception
// if EXCEPTION_STACK_TRACES is enabled and numeric exception
// with metadata optimised out otherwise.
___cxa_rethrow();
} catch (e) {
// But catch it so that it rejects the promise instead of throwing
// in an unpredictable place during async execution.
reject(e);
}
};

{{{ makeSetValue('resolveHandlePtr', '0', 'Emval.toHandle(resolve)', '*') }}};
{{{ makeSetValue('rejectHandlePtr', '0', 'Emval.toHandle(rejectWithCurrentException)', '*') }}};
}));
},
};

addToLibrary(LibraryEmVal);
2 changes: 2 additions & 0 deletions src/library_sigs.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,8 @@ sigs = {
_emval_await__sig: 'pp',
_emval_call__sig: 'dpppp',
_emval_call_method__sig: 'dppppp',
_emval_coro_make_promise__sig: 'ppp',
_emval_coro_suspend__sig: 'vpp',
_emval_decref__sig: 'vp',
_emval_delete__sig: 'ipp',
_emval_equals__sig: 'ipp',
Expand Down
167 changes: 167 additions & 0 deletions system/include/emscripten/val.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
#include <cstdint> // uintptr_t
#include <vector>
#include <type_traits>
#if _LIBCPP_STD_VER >= 20
#include <coroutine>
#include <variant>
#endif


namespace emscripten {
Expand Down Expand Up @@ -110,6 +114,11 @@ EM_VAL _emval_await(EM_VAL promise);
EM_VAL _emval_iter_begin(EM_VAL iterable);
EM_VAL _emval_iter_next(EM_VAL iterator);

#if _LIBCPP_STD_VER >= 20
void _emval_coro_suspend(EM_VAL promise, void* coro_ptr);
EM_VAL _emval_coro_make_promise(EM_VAL *resolve, EM_VAL *reject);
#endif

} // extern "C"

template<const char* address>
Expand Down Expand Up @@ -586,6 +595,10 @@ class val {
// our iterators are sentinel-based range iterators; use nullptr as the end sentinel
constexpr nullptr_t end() const { return nullptr; }

#if _LIBCPP_STD_VER >= 20
struct promise_type;
#endif

private:
// takes ownership, assumes handle already incref'd and lives on the same thread
explicit val(EM_VAL handle)
Expand Down Expand Up @@ -646,6 +659,160 @@ inline val::iterator val::begin() const {
return iterator(*this);
}

#if _LIBCPP_STD_VER >= 20
namespace internal {
struct val_awaiter {
val_awaiter(val&& promise)
: state(std::in_place_index<STATE_PROMISE>, std::move(promise)) {}

// just in case, ensure nobody moves / copies this type around
val_awaiter(val_awaiter&&) = delete;

bool await_ready() { return false; }

void await_suspend(std::coroutine_handle<val::promise_type> handle) {
internal::_emval_coro_suspend(std::get<STATE_PROMISE>(state).as_handle(), this);
state.emplace<STATE_CORO>(handle);
}

void resume_with(val&& result) {
auto coro = std::move(std::get<STATE_CORO>(state));
state.emplace<STATE_RESULT>(std::move(result));
coro.resume();
}

val await_resume() { return std::move(std::get<STATE_RESULT>(state)); }

private:
// State machine holding awaiter's current state. One of:
// - initially created with promise
// - waiting with a given coroutine handle
// - completed with a result
std::variant<val, std::coroutine_handle<val::promise_type>, val> state;

constexpr static std::size_t STATE_PROMISE = 0;
constexpr static std::size_t STATE_CORO = 1;
constexpr static std::size_t STATE_RESULT = 2;
};

extern "C" {
void _emval_coro_resume(val_awaiter* awaiter, EM_VAL result) {
awaiter->resume_with(val::take_ownership(result));
}
}
}

// Note: this type can't be internal because coroutines look for the public `promise_type` member.
struct val::promise_type {
promise_type() {
EM_VAL resolve_handle;
EM_VAL reject_handle;
promise = val(internal::_emval_coro_make_promise(&resolve_handle, &reject_handle));
resolve = val(resolve_handle);
reject_with_current_exception = val(reject_handle);
}

val get_return_object() { return promise; }

auto initial_suspend() noexcept { return std::suspend_never{}; }

auto final_suspend() noexcept { return std::suspend_never{}; }

void unhandled_exception() {
reject_with_current_exception();
}

template<typename T>
void return_value(T&& value) {
resolve(std::forward<T>(value));
}

internal::val_awaiter await_transform(val promise) {
return {std::move(promise)};
}

private:
val promise, resolve, reject_with_current_exception;
};
#endif

#if _LIBCPP_STD_VER >= 20
namespace internal {
struct val_awaiter {
val_awaiter(val&& promise)
: state(std::in_place_index<STATE_PROMISE>, std::move(promise)) {}

// just in case, ensure nobody moves / copies this type around
val_awaiter(val_awaiter&&) = delete;

bool await_ready() { return false; }

void await_suspend(std::coroutine_handle<val::promise_type> handle) {
internal::_emval_coro_suspend(std::get<STATE_PROMISE>(state).as_handle(), this);
state.emplace<STATE_CORO>(handle);
}

void resume_with(val&& result) {
auto coro = std::move(std::get<STATE_CORO>(state));
state.emplace<STATE_RESULT>(std::move(result));
coro.resume();
}

val await_resume() { return std::move(std::get<STATE_RESULT>(state)); }

private:
// State machine holding awaiter's current state. One of:
// - initially created with promise
// - waiting with a given coroutine handle
// - completed with a result
std::variant<val, std::coroutine_handle<val::promise_type>, val> state;

constexpr static std::size_t STATE_PROMISE = 0;
constexpr static std::size_t STATE_CORO = 1;
constexpr static std::size_t STATE_RESULT = 2;
};

extern "C" {
void _emval_coro_resume(val_awaiter* awaiter, EM_VAL result) {
awaiter->resume_with(val::take_ownership(result));
}
}
}

// Note: this type can't be internal because coroutines look for the public `promise_type` member.
struct val::promise_type {
promise_type() {
EM_VAL resolve_handle;
EM_VAL reject_handle;
promise = val(internal::_emval_coro_make_promise(&resolve_handle, &reject_handle));
resolve = val(resolve_handle);
reject_with_current_exception = val(reject_handle);
}

val get_return_object() { return promise; }

auto initial_suspend() noexcept { return std::suspend_never{}; }

auto final_suspend() noexcept { return std::suspend_never{}; }

void unhandled_exception() {
reject_with_current_exception();
}

template<typename T>
void return_value(T&& value) {
resolve(std::forward<T>(value));
}

internal::val_awaiter await_transform(val promise) {
return {std::move(promise)};
}

private:
val promise, resolve, reject_with_current_exception;
};
#endif

// Declare a custom type that can be used in conjunction with
// emscripten::register_type to emit custom TypeScript definitions for val
// types.
Expand Down
Loading

0 comments on commit 44b2c0d

Please sign in to comment.