Skip to content
This repository was archived by the owner on Aug 17, 2022. It is now read-only.
This repository was archived by the owner on Aug 17, 2022. It is now read-only.

Supporting caller-allocated, callee-written buffer without views #68

Open
@lukewagner

Description

@lukewagner

For an API like the C standard library read:

ssize_t read(int fildes, void *buf, size_t nbyte);

where the caller allocates a buffer which the callee (partially) fills in, to express the signature in wasm, ideally we want to avoid buf being an i32 argument since (1) this implies linear memory (preventing GC buffers), (2) it isn't multi-memory future-compatible.

One solution is to have read take a "slice"/"view" parameter (analogous to a Web IDL Uint8Array). However, views on linear memory somewhat break encapsulation, by giving the callee a permanent window into the caller's linear memory, leading to use-after-free types of bugs, so I think it's worthwhile to ask whether we can support use cases like read with value/copy semantics, without loss of performance.

Setting aside the issue of how to signal failure (probably via variant/option return type), I think a natural interface-typed signature would be:

(func (export "read") (param $fildes (ref $FD)) (param $nbyte u32) (result (list u8)))

where the API's contract is that the returned list's length is <= $nbyte. The question is how we can have this interface, but achieve the same performance, in particular, allowing the caller to supply a fixed-size buffer.


One idea would be to introduce a variant of the *-to-memory instructions which, instead of calling an exported function to allocate linear memory, would take a (pointer, length) i32 pair as operands from the stack (trapping if the required space is greater than the length). E.g.:

(func (import "" "read_") (param (ref $FD) i32 i32) (result i32))
(@interface func $read (import "libc" "read") (param (ref $FD) i32) (result (list u8)))
(@interface implement (import "" " read_")
    (param $fd (ref $FD)) (param $nbytes i32) (param $ptr i32)
    (result i32)
  arg.get $fd
  arg.get $nbytes
  call-import $read
  arg.get $ptr
  arg.get $nbytes
  list-to-preallocated-memory  ;; [list i32 i32] -> [i32], returning actual list size
)

However, there will be many *-to-memory instructions, and it feels wrong to have to add a *-to-preallocated-memory variant for each.

An alternative is to generalize the *-to-memory functions to, instead of only calling exports, to also be able to call any helper function (as described in #65), and for it to be possible for helper functions to be defined inline as unnamed (lambda) functions, so that they can reference the enclosing scope. This would allow the above example to be equivalently expressed as:

(func (import "" "read_") (param (ref $FD) i32 i32) (result i32))
(@interface func $read (import "libc" "read") (param (ref $FD) i32) (result (list u8)))
(@interface implement (import "" " read_")
    (param $fd (ref $FD)) (param $nbytes i32) (param $ptr i32)
    (result i32)
  arg.get $fd
  arg.get $nbytes
  call-import $read
  list-to-memory (func (param $needed i32) (result i32)
    ;; maybe assert $needed <= $nbytes
    arg.get $ptr
  )  ;; [list] -> [i32], returning actual list size
)

This generalization would also allow interesting hybrid schemes, e.g., wherein a caller-supplied buffer was used opportunistically, falling back to malloc in too-big cases, which is another common C++ optimization pattern.

With the understanding that helper functions are always designed to be inlined at the callsite (which is always statically determinable), then these lambda functions should compile into direct stack access, with no worse perf than list-to-preallocated-memory.

Thoughts?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions