In this gist I will go through several strategies for jetting in urwasm, delineate their strengths and weaknesses and explain my motivation for choosing a particular strategy (Lia interpreter). I will then draw a rough sketch of the jetted function specification and the implementation of its jet.
urwasm is a project which aims at executing WebAssembly modules within an Urbit instance. Determinism is the key feature of Urbit computer, and in addition to that I want Wasm execution to be as fast as possible. Currently, a Wasm interpreter in Hoon is being developed, intended to be a complete Wasm runtime. However, it would not be a practical one due to its poor performance. To go from a merely complete interpreter to a practical Wasm runtime I will have to jet it.
To jet the interpreter, I would have to pair a function written in Hoon with a code in C that must be extensionally equivalent to the Hoon code. In that sense function definition in Hoon would act as a formal mathematical specification of what the interpreter returns, while C code would act as the actual implementation of Wasm runtime by arriving to the same conclusion as Hoon code but faster.
Since Wasm is a portable language for a state machine, each invocation of a function from a Wasm module would either return a succesful result with returned values and an updated state, or some flavor of failure (trap or blocking on unresolved external request, e.g. function call of an imported function). This gives us several possible strategies for jetting:
- Have a bespoke Wasm interpeter in C that operates on nouns, and jet
invoke
gate; - Use an established Wasm runtime in C, and add serializer/deserializer to the jet, to convert Hoon representation of the module state to a representation in C and vice versa, and jet
invoke
gate; - Use an established Wasm runtime in C, and add serializer/deserializer to the Hoon specification, and use a representation of state close to the one in C as input and output in Hoon
invoke
gate, and jetinvoke
gate; - Don't jet the
invoke
gate. Instead, have a higher level function that executes a series of operations on a module and doesn't return the entirety of Wasm module state. Hoon specification of this function would use the Wasm interpreter in Hoon, and the jetting code in C would use Wasm runtime in C.
But first, why having to return the state in the first place?
Even for the simplest of source codes, the generated Wasm code requires multiple function invocations to compute the function that we intended to compute. Consider this Rust code of a function that flips the characters in a given string:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn process(input: String) -> String {
let output_string: String = input.chars().rev().collect();
output_string
}
After compiling this function to Wasm, you would get a module with five exporetd functions: process
itself, add_to_stack_pointer
, malloc
, realloc
and free
. The call of the compiled function in JS would look like:
export function process(input) {
let deferred2_0;
let deferred2_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passStringToWasm0(input, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
wasm.process(retptr, ptr0, len0);
var r0 = getInt32Memory0()[retptr / 4 + 0];
var r1 = getInt32Memory0()[retptr / 4 + 1];
deferred2_0 = r0;
deferred2_1 = r1;
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
Multiple invocations are necessary, some with the arguments received from outputs of other invoked functions. Having the access to the state of the module is thus crucial for any practical interpreter.
Now, let's quickly go through our options:
Maybe it is the perfect option in long term, but this option is unfeasible in short/mid term due to it being a huge sink of dev time.
The first solution that comes to mind, but it would impose a lot of computational overhead due to Hoon model <-> C model conversions of the state.
A less obvious variation of the method above is to put the serializer in the formal specification of the interpreter, and have the jetted gate take and return module state in the same representation as in the Wasm interpreter in C. The serializer would then not be called, and instead the jet would operate on the given state directly. Here the problem is that the implementation strategy leaks into the formal specification, making Hoon code jet-dependent. Replacement of the jetting Wasm runtime, including in the case of switching from Vere to Ares, would make us have to change the Hoon code, which I find to be antithetical to the Urbit project itself.
This strategy would involve writing a function that takes a Wasm module, a list of operations to be performed and some other parameters, and then jetting this function. No intermediate state would be returned, saving us from having to convert it between different representations. However, a practical jet implementation would have to cache the intermediate state of the interpreter between Arvo events, otherwise it would have to reevaluate the operations each time the jetted function encounters a block on an unresolved import.
Let's return to our example with string flipping in Rust. How would our hypothetical high-level function perform the function call?
Ignoring imports for now, consider this gate:
++ lia
|= [module=octs actions=(list action) in=(list value)]
^- $% [%0 out=(list value)]
[%2 ~]
==
:: (...)
This gate would take a binary file of a Wasm module, a list of actions to be performed and input parameters, and return either a list of values in the event of succesfull resolution, or an error. To flip a string, you would pass it the module file obtained from the Rust compiler, a string to flip converted to $octs
and a list of actions obtained from parsing this code:
# Lia (Language for Invocation of Assembly) scripting language
# a script must start with at least one import line
#
# "add_to_stack_pointer", "malloc" and "process" are functions exported
# by the wasm module from Rust example above
#
import octs string0 # imports must have explicit types
retptr = add_to_stack_pointer(-16) # implicit type of input and output
ptr0 = malloc(string0.len, 1) # octs have attribute `len`
memory[0].write(string0, ptr0) #
process(retptr, ptr0, len0) #
i32 r0 = memory[0].read(retptr, 4) # explicit type, casts atom to i32
i32 r1 = memory[0].read(retptr+4, 4) # '4' is implicitly typed as i32,
# addition operator upcasts to i64
# or convert to fn if necessary
#
return memory[0].read(r0, r1) # `return` expression is 'return' + expression
This code is written in Language for Invocation of Assembly, Lia for short. I imagine it being a language with a tiny specification, whose only purpose it to describe sequences of actions to be performed with a Wasm module: function invocation, memory read/writes, as well as variable declarations, for
loops and conditionals for expressivity.
The code above is essentially identical to the JS example. Ideally, it would be generated by the compiler itself, just like the JS code was also generated.
A value
here is either a Wasm value (numerical, vector, maybe function reference?) or a value somehow related to the state of the module, typically slice of a linear memory called octs
.
Jet of ++lia
would have to perform the same operations but with an interpreter in C. But considering the tiny spec of Lia, implementing it in both Hoon and C is not going to be a challenge.
In the next part I will discuss the necessity of caching the intermediate state of the interpreter in ++lia
jet, as well as import and REPL support, which will necessitate adding some more input parameters to ++lia
gate.