Skip to main content

Command Palette

Search for a command to run...

GoloScript and WebAssembly

Addendum: Optimizing the WASM Communication Protocol

Published
6 min read

This morning, my brain woke me up by reminding me of an article I had written a few years ago about how to exchange string data between the host and the WebAssembly guest: [Wazero Cookbook - Part One: WASM function & Host application](https://k33g.hashnode.dev/wazero-cookbook-part-one-wasm-function-host-application). And the method was better, so I needed to revisit the implementation of wasm support in GoloScript. But let me first explain the changes I've made.

In the previous article, we saw how to enable communication between a Go host and a WASM module using a 3-function protocol. This approach works well, but it can be optimized to reduce the number of function calls and eliminate the need for global variables.

This addendum presents a significant improvement to this protocol, inspired by WASM community best practices.

The Problem with the 3-Function Approach

Let's recall the original protocol:

// WASM module (guest)
func hello(ptr, size uint32)      // Process and store result
func getResultPtr() uint32         // Return pointer
func getResultLen() uint32         // Return length

// Required global variables
var resultBuffer []byte

Drawbacks:

  • 3 function calls to get one result

  • Global variables to store state

  • More complexity in the code

  • Performance overhead

The Optimized Solution: uint64 Encoding

The idea is simple yet powerful: encode both the pointer AND the length into a single uint64 value.

The Principle

A uint64 contains 64 bits. We can store:

  • Upper 32 bits → the pointer (uint32)

  • Lower 32 bits → the length (uint32)

Benefits

  • 1 function call instead of 3 (66% reduction)

  • No global variables in the WASM module

  • Cleaner code and easier to maintain

  • Better performance through reduced FFI calls

  • Thread-safe by design (no shared state)

WASM-Side Implementation (Guest)

New Function Signature

// Before: 3 functions
//export hello
func hello(ptr, size uint32) {
    // ...
    resultBuffer = []byte(result)  // Global!
}
//export getResultPtr
func getResultPtr() uint32 { ... }
//export getResultLen
func getResultLen() uint32 { ... }

// After: 1 function
//export hello
func hello(ptr, size uint32) uint64 {
    // Read input
    inputBytes := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size)
    name := string(inputBytes)

    // Process
    result := "Hello, " + name + "!!!"
    resultData := []byte(result)

    // Get result pointer
    resultPtr := uintptr(unsafe.Pointer(&resultData[0]))
    resultLen := uint32(len(resultData))

    // Encode: pointer in upper 32 bits, length in lower 32 bits
    return (uint64(resultPtr) << 32) | uint64(resultLen)
}

The Encoding Technique

// Encoding
ptr := uint32(unsafePtr)
size := uint32(len(buffer))

// Shift pointer 32 bits to the left
// and combine with size using binary OR
return (uint64(ptr) << uint64(32)) | uint64(size)

Concrete Example:

ptr  = 0x00001234  (4660 in decimal)
size = 0x000000FF  (255 in decimal)

Step 1: Shift pointer
uint64(ptr) << 32
= 0x0000123400000000

Step 2: Combine with OR
  0x0000123400000000
| 0x00000000000000FF
= 0x00001234000000FF

Host-Side Implementation (Go)

New Call Function

// Before: 3 calls
_, err = helloFn.Call(ctx, uint64(namePtr), uint64(len(nameBytes)))
ptrResults, _ := getResultPtrFn.Call(ctx)
lenResults, _ := getResultLenFn.Call(ctx)
resultPtr := uint32(ptrResults[0])
resultLen := uint32(lenResults[0])

// After: 1 call
result, err := helloFn.Call(ctx, uint64(namePtr), uint64(len(nameBytes)))

// Extract pointer (upper 32 bits)
resultPtr := uint32(result[0] >> 32)

// Extract length (lower 32 bits)
resultLen := uint32(result[0])

// Read result
resultBytes, ok := mod.Memory().Read(resultPtr, resultLen)

The Decoding Technique

// Right shift by 32 bits to get the pointer
resultPtr := uint32(result[0] >> 32)

// Cast to uint32 to automatically get the lower 32 bits
resultLen := uint32(result[0])

Concrete Example:

result[0] = 0x00001234000000FF

Extract pointer:
result[0] >> 32
= 0x0000000000001234
uint32(...) = 0x00001234

Extract length:
uint32(result[0])
= 0x000000FF  (lower 32 bits only)

Complete Flow Diagram with the New Approach

Application to Host Functions

This optimization also applies to host functions! Here's how:

WASM Module (Guest)

// Declaration of the host function that now returns a uint64
//export hostCallStringHandler
func hostCallStringHandler(ptr, length uint32) uint64

//export processWithHost
func processWithHost(ptr, length uint32) uint64 {
    input := ptrToString(ptr, length)
    message := "Message from WASM: " + input

    messageBytes := []byte(message)
    messagePtr := uintptr(unsafe.Pointer(&messageBytes[0]))
    messageLen := uint32(len(messageBytes))

    // Call the host function that returns encoded ptr+len
    hostResult := hostCallStringHandler(uint32(messagePtr), messageLen)

    // Extract pointer and length from host result
    hostPtr := uint32(hostResult >> 32)
    hostLen := uint32(hostResult)

    // Read and process the result
    hostResultStr := ptrToString(hostPtr, hostLen)
    resultData := []byte("Result from host: " + hostResultStr)
    resultPtr := uintptr(unsafe.Pointer(&resultData[0]))
    resultLen := uint32(len(resultData))

    // Encode and return
    return (uint64(resultPtr) << 32) | uint64(resultLen)
}

Host (Go with Wazero)

// Define the host function that returns I64 (uint64)
_, err := r.NewHostModuleBuilder("env").
    NewFunctionBuilder().
    WithGoModuleFunction(
        api.GoModuleFunc(runner.hostCallStringHandler),
        []api.ValueType{api.ValueTypeI32, api.ValueTypeI32},  // Params: ptr, len
        []api.ValueType{api.ValueTypeI64},                     // Return: uint64
    ).
    Export("hostCallStringHandler").
    Instantiate(ctx)

// Host function implementation
func (w *WasmRunner) hostCallStringHandler(ctx context.Context, m api.Module, stack []uint64) {
    ptr := uint32(stack[0])
    length := uint32(stack[1])

    data, _ := m.Memory().Read(ptr, length)
    input := string(data)

    // Process
    result := strings.ToUpper(input) + " [PROCESSED BY HOST]"

    // Write to WASM memory
    resultBytes := []byte(result)
    resultPtr := uint32(2048)
    m.Memory().Write(resultPtr, resultBytes)

    // Encode and return via the stack
    stack[0] = (uint64(resultPtr) << 32) | uint64(len(resultBytes))
}

Example with Rust

This technique works with any language compiled to WASM:

// Rust WASM module
#[no_mangle]
pub extern "C" fn hello(ptr: u32, len: u32) -> u64 {
    unsafe {
        let input = ptr_to_string(ptr, len);
        let result = format!("Hello, {}!!!", input);
        let (result_ptr, result_len) = string_to_ptr_len(result);

        // Encode: same technique as in Go
        ((result_ptr as u64) << 32) | (result_len as u64)
    }
}

Why It Works So Well

1. Reduced FFI Crossings

Each function call between the host and WASM module crosses the FFI barrier (Foreign Function Interface). This is expensive in terms of performance.

Before: 3 FFI crossings
Host → WASM (hello)
WASM → Host (getResultPtr)
WASM → Host (getResultLen)

After: 1 FFI crossing
Host → WASM (hello) → returns everything

2. No Global State

Global variables cause problems:

  • Not thread-safe

  • Difficult to test

  • Can cause subtle bugs

The new approach is stateless.

3. Universal Compatibility

This technique works with:

  • Go (TinyGo)

  • Rust

  • C/C++

  • AssemblyScript

  • All WASM languages

Because it only uses basic binary operations. And of cours the performances will be better.

And on the GoloScript side, what does it look like?

Theoretically, no changes (even though the implementation behind it has changed)

With string parameter

module examples.wasm.StringExample

function main = |args| {
  # Load the WASM module (TinyGo compiled)
  let runner = wasmLoad("./guest/guest.wasm")
  println("✓ WASM module loaded successfully")
  println("")

  # Check if the 'hello' function exists
  if wasmHasFunction(runner, "hello") {
    println("✓ Function 'hello' found in module")

    # Call the hello function with different names
    let names = ["🌸 Alice", "🤗 Bob", "🥸 Charlie", "🤓 GoloScript User"]

    foreach name in names {
      let result = wasmCallString(runner, "hello", name)
      println("  → " + result)
    }
  } else {
    println("✗ Function 'hello' not found")
  }

  println("")
  println("Closing WASM runner...")
  wasmClose(runner)
}

With host function

module examples.wasm.HostFunction

function main = |args| {

    println("🚀 Testing WASM host functions...")

    # Load the WASM module
    let runner = wasmLoad("./guest/main.wasm")
    println("✅ WASM module loaded")

    # Create a handler function that will be called from WASM
    let myHandler = |input| {
        println("🔵 Host (GoloScript) receives: " + input)

        # Process the input - convert to uppercase and add a message
        let result = input: toUpperCase() + " [PROCESSED BY GOLO HOST]"

        println("🟢 Host (GoloScript) returns: " + result)
        return result
    }

    # Register the handler (always uses "default" as the handler name)
    wasmRegisterStringHandler(runner, myHandler)
    println("✅ Handler registered")

    # Call the WASM function that will call our handler
    println("\n📞 Calling WASM function 'processWithHost'...")
    let result = wasmCallString(runner, "processWithHost", "Hello from GoloScript!")

    println("\n✨ Final result: " + result)

    # Clean up
    wasmClose(runner)
}

And so naturally, I've published a new release of GoloScript:

https://codeberg.org/TypeUnsafe/golo-script/releases/tag/v0.0.0-alpha.5

Have fun with Golo 🤓