Open killercup opened 7 years ago
I think we should consider a different approach to memory management than exporting alloc and free functions in which memory is managed by JavaScript, but have Rust call back to JavaScript to read from or write to the WASM buffer (think a little of how you might implement DMA). Examples:
pub fn rot13(input: &str) -> String { ... }
should be (finally) called from JS as something_exported.rot13("foo") == "sbb"
.
If we construct a little wrapper code on both sides, we can rely more on Rust's memory management. The JS wrapper would look like
module.rot13 = function(input) {
console.assert(this._arguments.length == 0 && \
this._return === undefined, \
"Somebody didn't respect the calling conventions");
/* The code for "there comes a string argument: */
this._arguments.push(new TextEncoder().encode(input));
/* Now on to the Rust wrapper. Our convention for strings is that only */
/* the length is passed in. */
this._module.exports.wrapped_rot13(this._arguments[0].length);
(skipping the rest of JS code because here is what the wrapper should look like on the Rust side:)
pub fn wapped_rot13(input_length: i32) -> () {
let input: String = fetch_string_argument(input_length);
/* by now, all in-/output argument stacks on the JS side are clean again, */
/* so we don't get reentrancy issues. Whether we pass input or &input or */
/* whatsonot depends on which signature we wrap, but is immaterial to the */
/* JS side or the rest of the wrappers */
let result = rot13(&input);
push_return_string(&result);
}
fn fetch_string_argument(length: i32) -> String {
let mut data = Vec<u8>::with_capacity(length);
unsafe { js_pop_argument_bytes_into(data.as_ptr()) };
String::from_utf8(data).unwrap()
}
fn push_return_string(response: &str) -> () {
unsafe { js_push_argument_bytes(response.as_ptr(), str.length) };
}
... and this is where we can resume the outer JS code:
this._module.exports.wrapped_rot13(this._arguments[0].length);
/* The code for "extract a string return value": */
var result = new TextDecoder().decode(this._return);
this._return = undefined;
return result;
}
What's missing is the helper callbacks which should be rather trivial, but manage the access to the VM memory:
{
'js_pop_argument_bytes_into': function(pointer) {
/* can we use `this` here to access the instance? I'll assume so, */
/* otherwise we'll figure out */
var input = this._arguments.shift();
new Uint8Array(this.exports.memory.buffer, pointer, input.length).set(
new Uint8Array(input));
},
'js_push_argument_bytes': function(pointer, length) {
this._return = new ArrayBuffer(length);
new Uint8Array(dst).set(new Uint8Array(this.exports.memory.buffer, pointer, length));
}
This runs a string to string function with one JS->Rust call and two Rust->JS calls, which is I think not worse than three JS->Rust calls (two for allocation, one for the function proper), but never lets memory region leave Rust's firm grip.
A similar approach would work in the other direction for Rust->JS calls; the calling convention would be a little different: strings passed as arguments would be wrapped into two arguments (pointer and length), and the wrapper must read out all arguments into strings before calling the wrapped JS function can convert them into strings at leisure because Rust gave JS an immutable borrow (but the implementation might want to convert all arguments before calling the wrappee anyway). String results of JS would be stored in this._return
and returned as their length, and the Rust side of the wrapper would need to call something like js_pop_argument_bytes_into
after having allocated the Vecjs_pop_argument_bytes_into
and js_push_argument_bytes
could work on the same JS data structure to reduce code duplication there.)
With your approach you keep bouncing back into JS from Rust, which is currently veeeery slow (that is two boundary-crossings per call into a Rust function). With the current approach we copy out Strings into JS strings anyway, so it would not be too hard to just call the deallocation afterwards as well. In either case we need to have support from the wasm module for that (or ... you know ... reimplement the same memory allocator in JS again so it can handle the memory buffer in just the same way)
ad "keep bouncing back into JS": Are nested calls (JS->RS->JS) more expensive than the same number of un-nested calls (2x JS->RS)? For if deallocation is considered, I counted the same numbers for both approaches (eg. alloc/call/free for rot13 with the existing approach vs. alloc/callback-get-argument/callback-paste-result, or call/free vs call/callback-paste-result for a get-string-time function).
ad "need to have support from the wasm module for that": i don't understand what you mean by that. (if it's that both something-in-rust and something-in-javascript needs to support a common calling convention: yes.)
At any rate, if this is a bottleneck, we could consider setting aside memory for function calls once only. That gives one more copy per argument, but reduces the overhead in terms of language crossings to one each time a larger buffer is required.
alloc
/dealloc
function.
Wait, we are responsible for memory management? Aren't we using Rust for that?
Jokes aside, we actually might deal with data of a type that tells us we are moving it. And we need to manage it.
https://github.com/killercup/wasm-experiments/blob/5bd74e9e8d199f42036773d9f8041db8b50d6e58/src/wrap.js#L21