Trying out WASM Support

Thank you, with 0.0.1 it works!

@lbguilherme

Saw this news in twitter.

6 Likes

@lbguilherme phenomenal work on WASM support for Crystal, can’t thank you enough :grinning:

I’ve got the basic example working from the Crystal 1.4.0 is released! - The Crystal Programming Language announcement.

Having an issue trying to get an exported sum function runnable.

Crystal code sum.cr:

# Exported functions need to use `fun`. They must be fully typed. 
fun sum(a : Int32, b : Int32) : Int32
  return a + b
end

Crystal version:

Crystal 1.5.1 [2d2b7799c] (2022-09-08)

LLVM: 10.0.0
Default target: x86_64-unknown-linux-gnu

Notes: also attempted using nightly build 1.6.0-dev, but that gives an unrelated error I believe due to new WASM functionality coming down the pipe for that version.

LLD version: LLD 12.0.0

Compiling / linking via:

crystal build sum.cr --cross-compile --target wasm32-wasi
wasm-ld-12 sum.wasm -o sum-final --export=sum -lc -L"$PWD/wasm32-wasi-libs" -lpcre

Notes: I’ve tried with and without --export and --export sum

Receiving this error with wasmer cli:

wasmer sum-final -i sum 1 2
error: failed to run `sum-final`
╰─▶ 1: Error while importing "wasi_snapshot_preview1"."args_get": unknown import. Expected Function(FunctionType { params: [I32, I32], results: [I32] })

Any direction and help is greatly appreciated, I’m probably overlooked something simple! :man_facepalming:

1 Like

Hi @noaheverett!

Currently Crystal targets wasm32-wasi: a WebAssembly environment with WASI enabled. It does not target wasm32-unknown: a pure WebAssembly environment without WASI. Using WASI allows for most of the stdlib to work unchanged.

This is only an issue because wasmer disables WASI when using the invoke flag (-i). This behavior was reported here Using WASI from wasmer cli · Issue #2978 · wasmerio/wasmer · GitHub and here Support no_main wasi binaries · Issue #1716 · wasmerio/wasmer · GitHub.

Finally, this issue was resolved by this PR: Fix "run --invoke [function]" to behave the same as "run" by fschutt · Pull Request #2997 · wasmerio/wasmer · GitHub and was released as wasmer 3.0.0-alpha. The final 3.0.0 version isn’t released yet.

For now you can try with wasmtime, it will work!

wasmtime run --invoke sum sum-final.wasm 1 2

Also, starting with Crystal 1.6.0 you won’t need to add --export sum anymore.

Tip: you can build in a single command without a cross-compile step:

CRYSTAL_LIBRARY_PATH=$PWD/wasm32-wasi-libs crystal build sum.cr -o sum.wasm --target wasm32-wasi --link-flags="--export sum"
5 Likes

Oh wow that’s great thank you very much for the help, it works with wasmtime! That new build command is way easier as well, cheers!

This makes me think a pure Crystal based WASM runtime would be sweet, like Go’s Wazero

Looking forward to Crystal 1.6 and thank you for all your work on it.

Happy that worked! Out of curiosity, what is your use case?

About a runtime, there is GitHub - naqvis/wasmer-crystal: WebAssembly runtime for Crystal for running Wasmer inside Crystal.
Maintaining an entirely new wasm runtime would be a lot of work for very little benefit, IMHO.

That’s the shard I’m looking to use and I’ve been testing the Ruby and Crystal version of the wasmer runtime, but I’m getting a similar error as above, but I’m hoping that will be resolved with wasmer v3.0 as you listed above :crossed_fingers:

Trying to run the same sum.wasm file that works with wasmtime gives below error for wasmer-crystal:

Unhandled exception: Missing hash key: "wasi_snapshot_preview1" (KeyError)
  from /usr/share/crystal/src/hash.cr:1077:11 in '[]'
  from lib/wasmer/src/wasmer/import.cr:25:86 in 'into_inner'
  from lib/wasmer/src/wasmer/instance.cr:23:17 in 'initialize'
  from lib/wasmer/src/wasmer/instance.cr:22:5 in 'new'
  from lib/wasmer/src/wasmer/instance.cr:32:7 in 'new'
  from src/test.cr:23:12 in '__crystal_main'
  from /usr/share/crystal/src/crystal/main.cr:115:5 in 'main_user_code'
  from /usr/share/crystal/src/crystal/main.cr:101:7 in 'main'
  from /usr/share/crystal/src/crystal/main.cr:127:3 in 'main'
  from /lib/x86_64-linux-gnu/libc.so.6 in '__libc_start_main'
  from /home/vagrant/.cache/crystal/crystal-run-test.tmp in '_start'
  from ???

Code:

require "wasmer"
# Let's define the engine, that holds the compiler.
engine = Wasmer::Engine.new
# Let's define the store, that holds the engine, that holds the compiler.
store = Wasmer::Store.new(engine)
# Let's compile the module to be able to execute it!
module_ = Wasmer::Module.new store, File.read("/vagrant/wasm/sum.wasm")
# Now the module is compiled, we can instantiate it.
instance = Wasmer::Instance.new module_
# get the exported `sum` function
# function methods returns nil if it can't find the requested function. we know its there, so let's add `not_nil!` 
sum = instance.function("sum").not_nil!
# Call the exported `sum` function with Crystal standard values. The WebAssembly
# types are inferred and values are casted automatically.
result = sum.call(5, 37)
puts result # => 42
1 Like

It seems like you have to expose WASI manually. See this example: wasmer-crystal/wasi.cr at main · naqvis/wasmer-crystal · GitHub

1 Like

Oh boy my ignorance is showing :crazy_face: You’re right again, I appreciate it @lbguilherme!

In case anyone needs it, below is the full code I used to run a Crystal wasm module with wasi and call an exported function via the wasmer-crystal shard:

Note: most of the code was pulled from examples/wasi.cr from wasmer-crystal

require "wasmer"

# Let's get the `wasi.wasm` bytes
file = File.read("/vagrant/wasm/sum.wasm")

# Create a store
store = Wasmer.default_engine.new_store

# Let's compile the Wasm module, as usual
module_ = Wasmer.module(store, file)

# Here we go.
#
# First, let's extract the WASI version from the module. Why? Because
# WASI already exists in multiple versions, and it doesn't work the
# same way. So, to ensure compatibility, we need to know the version.
wasi_version = Wasmer::Wasi.version(module_)

# Second, create a `Wasmer::Wasi::Environment`. It contains everything related
# to WASI. To build such an environment, we must use the
# `Wasmer::Wasi::StateBuilder`.
#
# In this case, we specify the program name is `wasi_test_program`. We
# also specify the program is invoked with the `--test` argument, in
# addition to two environment variable: `COLOR` and
# `APP_SHOULD_LOG`. Finally, we map the `the_host_current_dir` to the
# current directory. There it is:
# NOTE: CHANGE THESE TO SUIT YOUR USE CASE
env = Wasmer::Wasi::StateBuilder.builder("wasi_test_program") {
  with_arg("--test")
  with_env("COLOR", "true")
  with_env("APP_SHOULD_LOG", "false")
  with_map_dir("the_host_current_dir", ".")
  with_capture_stdout
}
imp_obj = env.generate_import_object(store, module_)

# Now can instantiate the module
instance = Wasmer.new_instance(module_, imp_obj)
#instance.wasi_start_func.call

sum = instance.function("sum").not_nil!
# Call the exported `sum` function with Crystal standard values. The WebAssembly
# types are inferred and values are casted automatically.
result = sum.call(11, 22)
puts result

Compile command:
CRYSTAL_LIBRARY_PATH=$PWD/wasm32-wasi-libs crystal build sum.cr -o sum.wasm --target wasm32-wasi --link-flags="--export sum"

sum.cr:

# Exported functions need to use `fun`. They must be fully typed. 
fun sum(a : Int32, b : Int32) : Int32
  return a + b
end
6 Likes

If it’s possible to support String? I got this error when use String as parameter of fun:

# Exported functions need to use `fun`. They must be fully typed. 
fun sum(a : String) : String
  return a
end

then build to wasm and got error:

Showing last frame. Use --error-trace for full trace.

In tmp.cr:3:13

 3 | fun sum(a : String) : String
                 ^-----
Error: only primitive types, pointers, structs, unions, enums and tuples are allowed in lib declarations, not String

when doing C Bindings or declaring fun you need to follow C semantics. C doesn’t have concept of String, so you should declare that as LibC::Char* or simply UInt8*.

HIH

1 Like

If it’s possible to export module or class or def or mose beside fun when crystal build to webassembly?

WebAssembly only has numeric types, there is no such thing as String or classes. Thus, you can only export simple fun functions with primitive numeric arguments. WASI by itself does not define the concept of “string”. But as the raw memory is exported it is possible to write low level functions that will take pointers and manipulate the memory to transfer arbitrary data.

If you are running WebAssembly on the Web or with a JS runtime such as Node.js or Deno, you can use this shard: GitHub - lbguilherme/crystal-js: Bindings to use Crystal compiled to WebAssembly in a JavaScript environment, such as Node.js, Deno or the Web.

It allows you to call JS methods and export def methods with more complex types. For example:

require "js"

JS.export def split_spaces(data : String) : Array(String)
  data.split(" ").map &.strip
end

And then it will generate a .js module that exports this function and handles the data conversion internally.

It is still a proof of concept and doesn’t yet support many types.

2 Likes

My one use case is:
1. use Python code to do most of one task, and I want to try write Crystal code(ex: core.cr) for core part of the task which cost time.
2.Then crystal build core.cr to got core.wasm then invoke fun of core.wasm in the Python code by wasm runtime(wasmer or wasmtime).
3.The argument of fun in core.wasm usually is number or String or other.

How to pass a String argument from Python into Crystal:

Crystal side:

fun get_string_type_id : Int32
  String::TYPE_ID
end

fun print_string(str : Void*)
  str = str.as(String)

  p str
end

Build command (for Crystal 1.5):

CRYSTAL_LIBRARY_PATH=$PWD/wasm32-wasi-libs crystal build --target wasm32-wasi test.cr -o test.wasm --link-flags="--export print_string --export __crystal_malloc_atomic --export get_string_type_id"

Python side, using wasmer-python:

from wasmer import wasi, Store, Module, Instance
import codecs
import os

__dir__ = os.path.dirname(os.path.realpath(__file__))
wasm_bytes = open(__dir__ + '/test.wasm', 'rb').read()

store = Store()
module = Module(store, wasm_bytes)
wasi_version = wasi.get_version(module, strict=True)
wasi_env = wasi.StateBuilder('test').finalize()
import_object = wasi_env.generate_import_object(store, wasi_version)

instance = Instance(module, import_object)
instance.exports._start()

crystal_string_type_id = instance.exports.get_string_type_id()

def make_crystal_string(str):
  str_bytes = codecs.encode(str, 'utf-8')
  pointer = instance.exports.__crystal_malloc_atomic(13 + len(str_bytes))

  header = instance.exports.memory.uint32_view(offset = pointer // 4)
  header[0] = crystal_string_type_id # typeid
  header[1] = len(str_bytes) # bytesize
  header[2] = len(str) # size

  body = instance.exports.memory.uint8_view(offset = pointer + 12)
  for i in range(len(str_bytes)):
    body[i] = str_bytes[i]

  body[len(str_bytes)] = 0 # null byte at the end

  return pointer


instance.exports.print_string(make_crystal_string("Hello!"))
1 Like

That solution uses a lot of Crystal’s runtime internals. That’s not a very pleasant API and could technically break when there are changes to the internal representation.

Instead, I’d recommend to either use C-style strings (char array). It can be passed to String.new in Crystal and you can pass a pointer to such a string around through the WASM interface.

fun make_string(str : Void*)
  String.new(str.as(LibC::Char*))
end
def make_crystal_string(str):
  bytes = codecs.encode(str, 'utf-8')
  crystal_string = instance.exports.make_string(ctypes.c_char_p(bytes))
  return crystal_string
1 Like

@straight-shoota it is still necessary to copy the data into the wasm instance memory before a pointer can be passed in. So this would be a solution using less internals:

fun alloc_buffer(size : Int32) : Void*
  Pointer(Void).malloc(size)
end

fun make_string(data : LibC::Char*, bytesize : Int32) : Void*
  String.new(data, bytesize).as(Void*)
end

fun print_string(str : Void*)
  str = str.as(String)

  p str
end
def make_crystal_string(str):
  str_bytes = codecs.encode(str, 'utf-8')
  pointer = instance.exports.alloc_buffer(len(str_bytes) + 1)

  buffer = instance.exports.memory.uint8_view(offset = pointer)
  for i in range(len(str_bytes)):
    buffer[i] = str_bytes[i]

  buffer[len(str_bytes)] = 0

  return instance.exports.make_string(pointer, len(str_bytes))

The downside is that the data is copied twice. But now it uses only public API.

I didn’t know you can’t pass an externally allocated pointer into WASM. But I guess it makes sense.

To avoid double allocation you would usually use the yielding String constructor. But I suppose that won’t work easily with the interface.

Maybe we could have something like Array.unsafe_build for String to use with such APIs.

Trying to require "http" to manually build an HTTP::Request via passed in strings (not trying to do any HTTP requests since WASM by itself doesn’t support this), but getting some compile errors.

CRYSTAL_LIBRARY_PATH=$PWD/wasm32-wasi-libs crystal build sum.cr -o sum.wasm --target wasm32-wasi
wasm-ld: error: unable to find library -lz (this usually means you need to install the development package for libz)
wasm-ld: error: unable to find library -lssl (this usually means you need to install the development package for libssl)
wasm-ld: error: unable to find library -lcrypto (this usually means you need to install the development package for libcrypto)
Error: execution of command failed with code: 1: `wasm-ld "${@}" -o /vagrant/glue/sum.wasm  -lc -L/vagrant/glue/wasm32-wasi-libs -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'``

I’ve tried a few variations of passing /usr/lib/crystal/lib/x86_64-linux-gnu path via the CRYSTAL_LIBRARY_PATH or --link-flags but get this error:

CRYSTAL_LIBRARY_PATH=$PWD/wasm32-wasi-libs:/usr/lib/crystal/lib/x86_64-linux-gnu/ crystal build sum.cr -o sum.wasm --target wasm32-wasi
wasm-ld: error: unknown file type: crc32.o
Error: execution of command failed with code: 1: `wasm-ld "${@}" -o /vagrant/glue/sum.wasm  -lc -L/vagrant/glue/wasm32-wasi-libs -L/usr/lib/crystal/lib/x86_64-linux-gnu/ -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'``

-or-

CRYSTAL_LIBRARY_PATH=$PWD/wasm32-wasi-libs crystal build sum.cr -o sum.wasm --target wasm32-wasi --link-flags="-L/usr/lib/x86_64-linux-gnu/"
wasm-ld: error: unknown file type: errno.o
Error: execution of command failed with code: 1: `wasm-ld "${@}" -o /vagrant/glue/sum.wasm -L/usr/lib/x86_64-linux-gnu/ -lc -L/vagrant/glue/wasm32-wasi-libs -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'``

My first thought was possibly the full Crystal stdlib isn’t compiled to WASM yet?

You can try building with -Dwithout_openssl and -Dwithout_zlib.

I don’t have a version of those libraries compiled for wasm32-wasi. (yet?)

1 Like