dotnet WASI applications in .NET 8.0

Β· 1768 words Β· 9 minutes to read

At the end of last year I blogged about using .NET 7 and the prototype dotnet WASI SDK from Steve Sanderson to build WASM-WASI applications. That SDK is now deprecated and the WASI workload has instead been integrated into the main .NET 8, which will ship in November this year. The workload is still flagged as experimental, but there is now commitment from the .NET runtime and SDK teams to make the WASM-WASI experience first class in .NET.

In this post we will explore where this workload is at today, and what we can and can’t do with it at this stage.

Getting started πŸ”—

To begin, we need to prepare our environment with the required packages. First, it is necessary to install .NET 8 SDK. At the time of writing, the latest release is RC1.

Next, we need it to add the new (experimental) WASM-WASI workload to our .NET 8 installation. This can be done by invoking:

dotnet workload install wasi-experimental

In order for the workload to work correctly, the WASI SDK must be installed as well. It will bring in the wasi-libc, as well as the necessary Clang and LLVM compiler bits. The easiest way to get it, is to go to the official Github releases page, download the release appropriate for your platform and extract it. Then add a WASI_SDK_PATH environment variable pointing to the extracted directory.

Alteratively, it’s also possible to do this per project - using an MSBuild variable in every WASM-WASI .NET project, pointing to the extracted directory.

We will also need to install the preferred WASM runtime/CLI runner. If you have followed any of my previous posts, or if you have done any WASM work in the post, you probably have that already. The recommended one is wasmtime, but others such as wasm3 or wasmer should also work.

Finally, we have an optional step to download binaryen, extract and then add extracted bin folder to the PATH. This allows us to use wasm-opt for optimization of the built WASM file.

Basic example πŸ”—

Our simplest application can be created using a new application template, which is installed together with the WASI workload - wasiconsole.

dotnet new wasiconsole -o hello  

This creates a basic WASI project in the hello folder, with the following csproj project file:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
    <OutputType>Exe</OutputType>
    <PublishTrimmed>true</PublishTrimmed>
  </PropertyGroup>
</Project>

and the following Program.cs:

using System;

Console.WriteLine("Hello, Wasi Console!");

Building this project using the regular:

dotnet build -c Release

would build our program into bin/Release/net8.0/wasm-wasi/AppBundle folder, where our hello.dll (as well as a bunch of other managed DLLs) would be found in a managed folder. There would also be a dotnet.wasm there, which will be responsible for booting the Mono runtime and loading and executing our program.

This can then be run (using wasmtime) by invoking:

wasmtime run --dir . dotnet.wasm hello

Now, this is a bit awkward default build layout, because typically you’d want to have everything bundled into a single WASM time. The main benefit of such behaviour is that the WASI SDK does not need to be installed for it to work. That said, we already should have had that set up so we can achieve a much more idiomatic WASM single file output by adding the following to our project file:

<WasmSingleFileBundle>true</WasmSingleFileBundle>

With this in place, the SDK will use Clang compiler bundled into the WASI SDK to produce a single file. So running the build would produce a self contained app under bin/Release/net8.0/wasm-wasi/AppBundle, called hello.wasm. This is much nicer and we can invoke it in a very simple way:

wasmtime hello.wasm

What is not so great is the size of the produced WASM file - it is (I am using version 8.0.0-rc.1.23419.4) 25.2 MB for a hello world app with no dependencies. The reason behind this is that we are dragging in the entire .NET - there is no trimming happening unless we go through the publish workflow. So instead of building, we can do:

dotnet publish -c Release

This will produce a trimmed WASM file which is around 6.8 MB on my machine. This is much better, but it’s still pretty big for a WASM file. We can optimize it further in two ways. First, we can set some additional MSBuild flags, such as disable debugger support:

<EventSourceSupport>false</EventSourceSupport>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<EnableUnsafeUTF7Encoding>false</EnableUnsafeUTF7Encoding>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<DebuggerSupport>false</DebuggerSupport>

This should help as shave off several hundred kilobytes - on my machine the produced WASM file size is at this stage 6 MB.

The second thing we can do, is to run our WASM file through wasm-opt optimization - which is the reason why we listed this tool as a prerequisite in the beginning of this post. Note that enable-bulk-memory is required by .NET.

wasm-opt -Oz --enable-bulk-memory hello.wasm -o helloOpt.wasm

The optimized file should be about 5.4 MB at this point. At the end of the day, this is not a great size in the world where WASM files can be tiny, but in either case, we can fit our built WASM app to something like Cloudflare WASI Worker where the limit in premium plan is 10MB.

Exporting a function πŸ”—

So we managed to get a “hello world” which is great. What else could we do? A common use case is to expose functions from a WASM file. This allows the host to invoke various methods from our WASM-WASI app - instead of just going through the single entry point. Once the app surfaces any functions it effectively becomes a cross platform library - one that can be used on any platform that supports WASM.

Consider the following code:

public class Program
{
    public static void Hello()
    {
        Console.WriteLine("hello world");
    }

    static void Main() {
        // no need to do anything, this will just bootstrap Mono
    }
}

Normally the Main method of our C# program would get translated to WASM _start() function, which is also its entry point. In this example we would like to expose Hello() to the external callers instead (and, if you close your eyes, you could imagine exposing many other “library” functions like these).

We can do this by adding a C bridge that will surface it:

#include <assert.h>
#include <driver.h>
#include <mono/metadata/object.h>
#include <mono/metadata/exception.h>

MonoObject* invoke_hello(MonoObject* target_instance, void* method_params[]) {
    static MonoMethod* method_hello = NULL;

    if (!method_hello) {
        method_hello = lookup_dotnet_method("WasiExport.dll", "WasiExport", "Program", "Hello", -1);
        assert(method_hello);
    }

    MonoObject* exception = NULL;
    MonoObject* res = mono_runtime_invoke(method_hello, target_instance, method_params, &exception);
    assert(!exception);

    return res;
}

extern void _start(void);

__attribute__((export_name("hello")))
void hello() {
    static int runtime_initialized = 0;

    if (runtime_initialized == 0) {
        _start();
        runtime_initialized = 1;
    }

    void *params[] = {  };
    invoke_hello(NULL, params);
}

A few words of explanation about this code. The invoke_hello C function will use Mono’s ability to locate a corresponding .NET method using the fully qualified name. Once we have its handle, we can invoke it. However, the question that arises is which Mono function should we use for that?

Interestingly, there is a function mono_wasm_invoke_method declared in the driver.h headers. At first glance, it seems appropriate in such situation but it does not exist in the WASI C driver, so it would not work. There is also mono_wasm_invoke_method_ref in the driver, and we can define it in our code as extern, which would be enough to make this work - the implementation will be supplied by Mono WASI C driver. However, such approach would not have an API stability guarantee, so sooner or later it would likely break. Thankfully, we can safely invoke our method using Mono’s mono_runtime_invoke function instead, which would be the idiomatic way of doing it across all Mono supported platforms.

Finally, we have to take care of one other thing - we cannot just invoke our .NET method straight up - it would only work once the Mono runtime has booted up. To ensure that, we need to make sure Mono is booted not only when going through the conventional program entry point (the Main method, which gets compiled to WASM _start()), but also when going through our hello function. To achieve that, we could define _start as extern and make sure (via static state) that it has been called once before our function runs.

We also need to instruct the .NET WASI workload to include our C file into the arguments passed to Clang. This can be done using the following MSBuild config (assuming the C file is called interop.c):

    <ItemGroup>
        <_WasiFilePathForFixup Include="interop.c" />
    </ItemGroup>

With this in place, we can compile our application the same way as before:

dotnet publish -c Release

We can of course also apply the same size optimizations as before if we want. With our WASM file produced, we can now skip going through the main entrypoint of the app, and instead use it more like a library. This is even possible from the wasmtime CLI, by passing the function name and its parameters to the runner using the –invoke argument (note: hello.wasm is the app, but hello is the exported function).

wasmtime hello.wasm --invoke hello

This works well, but naturally manually building a C bridge for every function is not a great experience. It is on the roadmap for .NET WASI SDK to be able to declaratively export functions using an UnmanagedCallersOnlyAttribute.

More sophisticated integrations πŸ”—

So far we managed to build a simple hello world (using the Main as entry point) and we also managed to export a .NET function to host application to call into. This is already a decent start, but more complicated integrations would require us to attach internal .NET calls to externally provided C functions. In principle, this is how Spin SDK worked, about which I blogged in my previous WASI post, and how many other WASM-WASI programs integrate with their hosts.

Unfortunately at the moment it is not possible to port Spin SDK (or any other integration relying on two-way FFI calls) to the new experimental workload. First of all, the new workload would include the bridging C code in a wrong place during the startup. I opened a .NET runtime PR to fix and it has already been merged. Secondly, the MSBuild task that allows supplying these integration calls to Clang as arguments is also not working properly. For that I opened an issue and described a possible workaround. With these things in place we should be able to start doing a lot more interesting work with .NET 8 based WASM-WASI application.

The overall issue tracking the WASI support in .NET, listing all the currently completed milestones, as well as all the open points can be found here. As its always the case, all the source code for this article is available on Github.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP