Nine Rules for Running Rust on WASM WASI

-

Based on my experience with range-set-blaze, a knowledge structure project, listed here are the choices I like to recommend, described one by one. To avoid wishy-washiness, I’ll express them as rules.

In 2019, Docker co-creator Solomon Hykes tweeted:

If WASM+WASI existed in 2008, we wouldn’t have needed to created Docker. That’s how necessary it’s. Webassembly on the server is the longer term of computing. A standardized system interface was the missing link. Let’s hope WASI is as much as the duty.

Today, in case you follow technology news, you’ll see optimistic headlines like these:

A collage of headlines about WebAssembly System Interface (WASI). The first headline from Forbes reads “WebAssembly Is Finally Usable, Almost.” A YouTube video thumbnail shows “WASI Will Change .NET Forever! Run WebAssembly Outside The Browser!” with James Montemagno. The bottom headline says “Unexpectedly Useful: A Real World Use Case For WebAssembly System Interface (WASI).”

If WASM WASI were truly ready and useful, everyone would already be using it. The incontrovertible fact that we keep seeing these headlines suggests it’s not yet ready. In other words, they wouldn’t have to keep insisting that WASM WASI is prepared if it really were.

As of WASI Preview 1, here is how things stand: You’ll be able to access some file operations, environment variables, and have access to time and random number generation. Nonetheless, there isn’t a support for networking.

WASM WASI might be useful for certain AWS Lambda-style web services, but even that’s uncertain. Because wouldn’t you like to compile your Rust code natively and run twice as fast at half the associated fee in comparison with WASM WASI?

Possibly WASM WASI is helpful for plug ins and extensions. In genomics, I actually have a Rust extension for Python, which I compile for 25 different combos (5 versions of Python across 5 OS targets). Even with that, I don’t cover every possible OS and chip family. Could I replace those OS targets with WASM WASI? No, it will be too slow. Could I add WASM WASI as a sixth “catch-all” goal? Possibly, but when I actually need portability, I’m already required to support Python and may just use Python.

So, what’s WASM WASI good for? At once, its fundamental value lies in being a step toward running code within the browser or on embedded systems.

In Rule 1, I discussed “OS targets” in passing. Let’s look deeper into Rust targets — essential information not only for WASM WASI, but in addition for general Rust development.

On my Windows machine, I can compile a Rust project to run on Linux or macOS. Similarly, from a Linux machine, I can compile a Rust project to focus on Windows or macOS. Listed here are the commands I take advantage of so as to add and check the Linux goal to a Windows machine:

rustup goal add x86_64-unknown-linux-gnu
cargo check --target x86_64-unknown-linux-gnu

Aside: While cargo check verifies that the code compiles, constructing a completely functional executable requires additional tools. To cross-compile from Windows to Linux (GNU), you’ll also need to put in the Linux GNU C/C++ compiler and the corresponding toolchain. That may be tricky. Fortunately, for the WASM targets we care about, the required toolchain is straightforward to put in.

To see all of the targets that Rust supports, use the command:

rustc --print target-list

It’s going to list over 200 targets including x86_64-unknown-linux-gnu, wasm32-wasip1, and wasm32-unknown-unknown.

Goal names contain as much as 4 parts: CPU family, vendor, OS, and environment (for instance, GNU vs LVMM):

A diagram explaining the components of the target triple x86_64-unknown-linux-gnu. It breaks down as follows: CPU architecture (64-bit x86), Vendor (unspecified, hence ‘unknown’), Operating system (Linux), and Environment (GNU C library).
Goal Name parts — figure from creator

Now that we understand something of targets, let’s go ahead and install the one we want for WASM WASI.

To run our Rust code on WASM outside of a browser, we want to focus on wasm32-wasip1 (32-bit WebAssembly with WASI Preview 1). We’ll also install WASMTIME, a runtime that permits us to run WebAssembly modules outside of the browser, using WASI.

rustup goal add wasm32-wasip1
cargo install wasmtime-cli

To check our setup, let’s create a brand new “Hello, WebAssembly!” Rust project using cargo recent. This initializes a brand new Rust package:

cargo recent hello_wasi
cd hello_wasi

Edit src/fundamental.rs to read:

fn fundamental() {
#[cfg(not(target_arch = "wasm32"))]
println!("Hello, world!");
#[cfg(target_arch = "wasm32")]
println!("Hello, WebAssembly!");
}

Aside: We’ll look deeper into the #[cfg(...)] attribute, which enables conditional compilation, in Rule 4.

Now, run the project with cargo run, and you need to see Hello, world! printed to the console.

Next, create a .cargo/config.toml file, which specifies how Rust should run and test the project when targeting WASM WASI.

[target.wasm32-wasip1]
runner = "wasmtime run --dir ."

Aside: This .cargo/config.toml file is different from the fundamental Cargo.toml file, which defines your project’s dependencies and metadata.

Now, in case you say:

cargo run --target wasm32-wasip1

It’s best to see Hello, WebAssembly!. Congratulations! You’ve just successfully run some Rust code within the container-like WASM WASI environment.

Now, let’s investigate #[cfg(...)]—an important tool for conditionally compiling code in Rust. In Rule 3, we saw:

fn fundamental() {
#[cfg(not(target_arch = "wasm32"))]
println!("Hello, world!");
#[cfg(target_arch = "wasm32")]
println!("Hello, WebAssembly!");
}

The #[cfg(...)] lines tell the Rust compiler to incorporate or exclude certain code items based on specific conditions. A “code item” refers to a unit of code equivalent to a function, statement, or expression.

With #[cfg(…)] lines, you may conditionally compile your code. In other words, you may create different versions of your code for various situations. For instance, when compiling for the wasm32 goal, the compiler ignores the #[cfg(not(target_arch = "wasm32"))] block and only includes the next:

fn fundamental() {
println!("Hello, WebAssembly!");
}

You specify conditions via expressions, for instance, target_arch = "wasm32". Supported keys include target_os and target_arch. See the Rust Reference for the total list of supported keys. You may as well create expressions with Cargo features, which we are going to study in Rule 6.

You might mix expressions with the logical operators not, any, and all. Rust’s conditional compilation doesn’t use traditional if...then...else statements. As a substitute, it’s essential to use #[cfg(...)] and its negation to handle different cases:

#[cfg(not(target_arch = "wasm32"))]
...
#[cfg(target_arch = "wasm32")]
...

To conditionally compile a complete file, place #![cfg(...)] at the highest of the file. (Notice the “!”). This is helpful when a file is barely relevant for a particular goal or configuration.

You may as well use cfg expressions in Cargo.toml to conditionally include dependencies. This lets you tailor dependencies to different targets. For instance, this says “rely on Criterion with Rayon when not targeting wasm32”.

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
criterion = { version = "0.5.1", features = ["rayon"] }

Aside: For more information on using cfg expressions in Cargo.toml, see my article: Nine Rust Cargo.toml Wats and Wat Nots: Master Cargo.toml formatting rules and avoid frustration | Towards Data Science (medium.com).

It’s time to attempt to run your project on WASM WASI. As described in Rule 3, create a .cargo/config.toml file to your project. It tells Cargo tips on how to run and test your project on WASM WASI.

[target.wasm32-wasip1]
runner = "wasmtime run --dir ."

Next, your project — like all good code — should already contain tests. My range-set-blaze project includes, for instance, this test:

#[test]
fn insert_255u8() {
let range_set_blaze = RangeSetBlaze::::from_iter([255]);
assert!(range_set_blaze.to_string() == "255..=255");
}

Let’s now try and run your project’s tests on WASM WASI. Use the next command:

cargo test --target wasm32-wasip1

If this works, you might be done — however it probably won’t work. After I do that on range-set-blaze, I get this error message that complains about using Rayon on WASM.

 error: Rayon can't be used when targeting wasi32. Try disabling default features.
--> C:Userscarlk.cargoregistrysrcindex.crates.io-6f17d22bba15001fcriterion-0.5.1srclib.rs:31:1
|
31 | compile_error!("Rayon can't be used when targeting wasi32. Try disabling default features.");

To repair this error, we must first understand Cargo features.

To resolve issues just like the Rayon error in Rule 5, it’s necessary to grasp how Cargo features work.

In Cargo.toml, an optional [features] section lets you define different configurations, or versions, of your project depending on which features are enabled or disabled. For instance, here’s a simplified a part of the Cargo.toml file from the Criterion benchmarking project:

[features]
default = ["rayon", "plotters", "cargo_bench_support"]
rayon = ["dep:rayon"]
plotters = ["dep:plotters"]
html_reports = []
cargo_bench_support = []

[dependencies]
#...
# Optional dependencies
rayon = { version = "1.3", optional = true }
plotters = { version = "^0.3.1", optional = true, default-features = false, features = [
"svg_backend",
"area_series",
"line_series",
] }

This defines 4 Cargo features: rayon, plotters, html_reports, and cargo_bench_support. Since each feature may be included or excluded, these 4 features create 16 possible configurations of the project. Note also the special default Cargo feature.

A Cargo feature can include other Cargo features. In the instance, the special default Cargo feature includes three other Cargo features — rayon, plotters, and cargo_bench_support.

A Cargo feature can include a dependency. The rayon Cargo feature above includes the rayon crate as a dependent package.

Furthermore, dependent packages could have their very own Cargo features. For instance, the plotters Cargo feature above includes the plotters dependent package with the next Cargo features enabled: svg_backend, area_series, and line_series.

You’ll be able to specify which Cargo features to enable or disable when running cargo check, cargo construct, cargo run, or cargo test. As an illustration, in case you’re working on the Criterion project and need to ascertain only the html_reports feature with none defaults, you may run:

cargo check --no-default-features --features html_reports

This command tells Cargo not to incorporate any Cargo features by default but to specifically enable the html_reports Cargo feature.

Inside your Rust code, you may include/exclude code items based on enabled Cargo features. The syntax uses #cfg(…), as per Rule 4:

#[cfg(feature = "html_reports")]
SOME_CODE_ITEM

With this understanding of Cargo features, we will now try and fix the Rayon error we encountered when running tests on WASM WASI.

Once we tried running cargo test --target wasm32-wasip1, a part of the error message stated: Criterion ... Rayon can't be used when targeting wasi32. Try disabling default features. This means we must always disable Criterion’s rayon Cargo feature when targeting WASM WASI.

To do that, we want to make two changes in our Cargo.toml. First, we want to disable the rayon feature from Criterion within the [dev-dependencies] section. So, this starting configuration:

[dev-dependencies]
criterion = { version = "0.5.1", features = ["html_reports"] }

becomes this, where we explicitly turn off the default features for Criterion after which enable all of the Cargo features except rayon.

[dev-dependencies]
criterion = { version = "0.5.1", features = [
"html_reports",
"plotters",
"cargo_bench_support"],
default-features = false }

Next, to make sure rayon continues to be used for non-WASM targets, we add it back in with a conditional dependency in Cargo.toml as follows:

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
criterion = { version = "0.5.1", features = ["rayon"] }

Generally, when targeting WASM WASI, you might need to change your dependencies and their Cargo features to make sure compatibility. Sometimes this process is easy, but other times it will possibly be difficult — and even unimaginable, as we’ll discuss in Rule 8.

Aside: In the following article on this series — about WASM within the Browser — we’ll go deeper into strategies for fixing dependencies.

After running the tests again, we move past the previous error, only to come across a brand new one, which is progress!

#[test]
fn test_demo_i32_len() {
assert_eq!(demo_i32_len(i32::MIN..=i32::MAX), u32::MAX as usize + 1);
^^^^^^^^^^^^^^^^^^^^^ try and compute
`usize::MAX + 1_usize`, which might overflow
}

The compiler complains that u32::MAX as usize + 1 overflows. On 64-bit Windows the expression doesn’t overflow because usize is identical as u64 and might hold u32::MAX as usize + 1. WASM, nevertheless, is a 32-bit environment so usize is identical as u32 and the expression is one too big.

The fix here is to switch usize with u64, ensuring that the expression doesn’t overflow. More generally, the compiler won’t at all times catch these issues, so it’s necessary to review your use of usize and isize. When you’re referring to the dimensions or index of a Rust data structure, usize is correct. Nonetheless, in case you’re coping with values that exceed 32-bit limits, you need to use u64 or i64.

Aside: In a 32-bit environment, a Rust array, Vec, BTreeSet, etc., can only hold as much as 2³²−1=4,294,967,295 elements.

So, we’ve fixed the dependency issue and addressed a usize overflow. But can we fix all the things? Unfortunately, the reply is not any.

WASM WASI Preview 1 (the present version) supports file access (inside a specified directory), reading environment variables, and dealing with time and random numbers. Nonetheless, its capabilities are limited in comparison with what you would possibly expect from a full operating system.

In case your project requires access to networking, asynchronous tasks with Tokio, or multithreading with Rayon, Unfortunately, these features aren’t supported in Preview 1.

Fortunately, WASM WASI Preview 2 is anticipated to enhance upon these limitations, offering more features, including higher support for networking and possibly asynchronous tasks.

So, your tests pass on WASM WASI, and your project runs successfully. Are you done? Not quite. Because, as I prefer to say:

If it’s not in CI, it doesn’t exist.

Continuous integration (CI) is a system that may routinely run your tests each time you update your code, ensuring that your code continues to work as expected. By adding WASM WASI to your CI, you may guarantee that future changes won’t break your project’s compatibility with the WASM WASI goal.

In my case, my project is hosted on GitHub, and I take advantage of GitHub Actions as my CI system. Here’s the configuration I added to .github/workflows/ci.yml to check my project on WASM WASI:

test_wasip1:
name: Test WASI P1
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Arrange Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
targets: wasm32-wasip1
- name: Install Wasmtime
run: |
curl https://wasmtime.dev/install.sh -sSf | bash
echo "${HOME}/.wasmtime/bin" >> $GITHUB_PATH
- name: Run WASI tests
run: cargo test --verbose --target wasm32-wasip1

By integrating WASM WASI into CI, I can confidently add recent code to my project. CI will routinely test that each one my code continues to support WASM WASI in the longer term.

ASK ANA

What are your thoughts on this topic?
Let us know in the comments below.

0 0 votes
Article Rating
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments

Share this article

Recent posts

0
Would love your thoughts, please comment.x
()
x