killercup / wasm-experiments

Please don't use this! Check out this instead:
https://rustwasm.github.io/
MIT License
62 stars 11 forks source link

Figure out when to free memory #7

Open killercup opened 7 years ago

killercup commented 7 years ago

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

chrysn commented 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.

chrysn commented 7 years ago

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 Vec for it. (Quite possibly, js_pop_argument_bytes_into and js_push_argument_bytes could work on the same JS data structure to reduce code duplication there.)

badboy commented 7 years ago

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)

chrysn commented 7 years ago

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.

badboy commented 7 years ago
  1. Hmmm, you're probably right. The amount of calls is the same. However we might keep the memory around for fat-pointers (yey, more state on the JS side...)
  2. By that I meant it needs to export the alloc/dealloc function.
  3. That's for us to find out. For now you want to move as much computation as possible into a single call and only return small results if possible (I heard even copying into the memory buffer is already quite expensive)