Having some weird behavior in WASM when assigning values to an array in JS

Hi everyone!

Here’s what I am trying to do, with the latest Zig nightly build.

I have an array of values rgba that is generated in JavaScript, and want to process it in Zig.
That works perfectly.

However, in Zig, I have an array of values nums that I can’t access anymore once once I set the array to contain the rgba values.
When I try to access the nums array, I get only negative values (all -1).

At first, I thought it was because I didn’t have enough memory, so I increased using the grow() method, but that didn’t help.
The funny thing is that, I am not even reading the array in Zig, and if I change its size from 640 * 480 * 4 to 640 * 480 * 3, everything works.

Here’s all the code, using a minimal example. I would really appreciate it if you could point out what I am doing wrong. Thank you in advance!

main.zig:

extern fn print(val: i32) void;

const nums = [_]i32{ 291, 409, 270, 269, 267, 0, 37, 39, 40, 185, 61, 146, 91, 181, 84, 17, 314, 405, 321, 375 };
export fn iterate() void {
    print(nums.len);
    for (nums, 0..) |val, i| {
        print(@intCast(i));
        print(val);
    }
}

index.html:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" http-equiv="X-UA-Compatible" content="chrome=1">
  <script>

  function setup() {
    const imports = {
      env: {
        print: function(x) { console.log(x); }
      }
    };
    return WebAssembly.instantiateStreaming(fetch("zig-out/lib/main.wasm"), imports).then((result) => {
      return result.instance.exports;
    });
  }

  setup().then((wasm) => {
    wasm.memory.grow(4096);
    // changing this size to 640 * 480 * 3 makes it work.
    const size = 640 * 480 * 4;
    var rgba = new Uint8Array(size);
    for (let i = 0; i < size; ++i) {
      rgba[i] = 255;
    }
    var array = new Uint8Array(wasm.memory.buffer, 0, size);
    // this line breaks the `wasm.iterate()` function, commenting it out works.
    array.set(rgba);
    console.log(array);
    wasm.iterate();
  });

  </script>
</head>
</html>

build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const lib = b.addSharedLibrary(.{
        .name = "main",
        .root_source_file = .{ .path = "main.zig" },
        .optimize = .Debug,
        .target = .{ .cpu_arch = .wasm32, .os_tag = .freestanding },
        .use_llvm = false,
        .use_lld = false,
    });
    // export all functions marked with "export"
    lib.rdynamic = true;
    b.installArtifact(lib);
}

Maybe an ever simpler example that can be run with node.js.

main.zig

extern fn print(val: i32) void;

const nums = [_]i32{ 0, 1, 2, 3, 4, 6, 7, 8, 9 };
export fn iterate() void {
    for (nums) |val| {
        print(val);
    }
}

sript.js

const fs = require('fs');
async function main() {
    const buf = fs.readFileSync('zig-out/lib/main.wasm');
    const res = await WebAssembly.instantiate(buf, {env: {print:function(x) {console.log(x);}}});
    const { iterate, memory } = res.instance.exports;
    memory.grow(100); // this will increase by 100 * 64 KiB ≅ 6.5 MiB
    const size = 480 * 640 * 4; // this is using only ≅ 1.2 MiB, and changing the 4 to 3 makes it work
    var array = new Uint8Array(memory.buffer, 0, size)
    var rgba = new Uint8Array(size)
    for (let i = 0; i < size; ++i) { rgba[i] = 255; }
    iterate(); // works fine
    array.set(rgba);
    iterate(); // doesn't work
}
main().then(() => console.log('done'));

Which prints:

0
1
2
3
4
6
7
8
9
-1
-1
-1
-1
-1
-1
-1
-1
-1
done

Any help will be much appreciated, since I have no idea what’s going on. I tried increasing the offset when creating the var array, but nothing changed…

I have rewritten the Zig part in C to double-check myself.

Here’s the C version:

void print(int);

const int nums[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

void iterate() {
    for (int i = 0; i < 10; ++i) {
        print(nums[i]);
    }
}

compiled with this command:

clang main.c \
  --target=wasm32-unknown-unknown-wasm \
  --optimize=3 \
  -nostdlib \
  -Wl,--export-all \
  -Wl,--no-entry \
  -Wl,--allow-undefined \
  --output main.wasm

And using the same JS file as before, but loading the newly generated WASM file instead, the output is:

0
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
6
7
8
9
done

However, it only works for --optimize=2 and --optimize=3, any lower value gives me the same behavior as Zig. I tried changing the optimization in Zig to .Debug, .ReleaseSafe, .ReleaseSmall, and .ReleaseFast, and they all output -1 the second time iterate is called.

The more I dig into this, the more confused I am.

array.set(rgba) thrashes the region of memory where nums resides.

By default, Zig reserves 1MiB (1048576 bytes) of wasm memory for the stack. Data for global variables start usually start appearing in memory after this point. You can verify this by opening your built wasm file in a text format viewer (using your browser’s dev tools or wasm2wat online) and looking at this line near the very bottom, which matches the contents of the nums array:

  (data (;0;) (i32.const 1048576) "\00\00\00\00\01\00\00\00\02\00\00\00\03\00\00\00\04\00\00\00\06\00\00\00\07\00\00\00\08\00\00\00\09\00\00\00"))

Your array begins at offset 0 of wasm memory, which means that calling array.set(rgba) without passing the second optional targetOffset argument copies the rgba data into wasm memory starting at offset 0. Your rgba array is 480 * 640 * 4 = 1228800 bytes long, which is greater than 1048576 and will therefore overwrite the nums array data. Four consecutive 255 bytes is the i32 representation of -1 in memory, which is why -1 gets printed ten times.

The reason the optimized C code is unaffected might be because the compiler unrolls the loop, meaning that instead of emitting for (nums) |val| print(val) it emits code matching print(1); print(2); print(3) and so on.

As you might see you shouldn’t arbitrarily write to wasm memory from JS as it will corrupt global variables. What is it that you want your program to do? Do you just want to reserve a fixed 480 * 640 * 4 block of memory or do you need to dynamically allocate and free memory? There are multiple “correct” ways to pass data from JS to Zig but which one is better depends on what you want to do.

4 Likes

Thank you for your reply, really insightful.
Actually, what I want to do is get some images from the webcam (rgba) and do some image processing on it. So I need to pass the memory from Javascript to Zig, modify it and read it back.

I already implemented it in C++ and Emscripten, but I wanted to port it to Zig as a learning exercise. Emscripten exposes malloc for the memory allocations, so I’m guessing I should export some kind of allocator from zig, as well? If you have any pointers that might help me, that’d be very useful. Thank you!

I haven’t worked with dynamic memory allocation in wasm much so there might be details I’m missing and some better ways to do this, but for starters you could expose some functions for allocating/freeing sequences of bytes by using std.heap.wasm_allocator:

const std = @import("std");

// We can't pass/return slices to/from `export`/`extern` functions,
// so we need to pass/return pointers and lengths separately.

export fn allocBytes(len: usize) ?[*]u8 {
    return if (std.heap.wasm_allocator.alloc(u8, len)) |slice| slice.ptr else |_| null;
}

export fn freeBytes(ptr: ?[*]const u8, len: usize) void {
    if (ptr) |valid_ptr| std.heap.wasm_allocator.free(valid_ptr[0..len]);
}

export fn processValues(ptr: [*]const i32, len: usize) void {
    for (ptr[0..len]) |byte| print(byte);
}

extern fn print(value: i32) void;

Then in JS you could use the exposed functions like this:

const instantiateResult = await WebAssembly.instantiateStreaming(fetch("..."), {
  env: {
    print: value => console.log(value),
  },
})

const { memory, allocBytes, freeBytes, processValues } = instantiateResult.instance.exports

const values_len = 10
const values_ptr = allocBytes(values_len * Int32Array.BYTES_PER_ELEMENT)
// Address 0 isn't protected in wasm so don't forget to check for null!
if (values_ptr === 0) throw new Error("OOM")
try {
  const values = new Int32Array(memory.buffer, values_ptr, values_len)
  for (let i = 0; i < values_len; i++) values[i] = 2 * i - 6
  processValues(values_ptr, values_len)
} finally {
  freeBytes(values_ptr, values_len * Int32Array.BYTES_PER_ELEMENT)
}
1 Like

Oh, thank you so much! I was about to post a reply that I made it work using your previous comments, but using the std.heap.page_allocator instead. I read somewhere that it maps to memory.grow in WASM.

export fn allocBytes(len: usize) [*]const u8 {
    const slice = std.heap.page_allocator.alloc(u8, len) catch @panic("failed to allocate memory");
    return slice.ptr;
}

Again, thank you so much, I will finish my port soon. I am really enjoying the language.

OK, then I have another question, how can I allocate more than once?
I read in the MDN docs that each call to memory.grow discards the previous buffer. As a result, when I do:

const fs = require('fs');
async function main() {
    const buf = fs.readFileSync('zig-out/lib/main.wasm');
    const res = await WebAssembly.instantiate(buf, {env: {print: (arg) => console.log(arg) }});
    const { iterate, memory, allocBytes, freeBytes } = res.instance.exports;
    const size = 480 * 640 * 4; // this is using only ≅ 1.2 MiB
    const array_ptr = allocBytes(size);
    var array = new Uint8Array(memory.buffer, array_ptr, size)
    console.log(array);
    console.log(array_ptr);
    var list_ptr = allocBytes(10);
    console.log(array);
    console.log(array_ptr);
    freeBytes(list_ptr, 10);
    freeBytes(array_ptr, size);
}
main().then(() => console.log('done'));

The memory from the array gets reset, and I get the following:

Uint8Array(1228800) [
  170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170,
  170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170,
  170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170,
  170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170,
  170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170,
  170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170,
  170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170,
  170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170, 170,
  170, 170, 170, 170,
  ... 1228700 more items
]
1114112
Uint8Array(0) []
1114112
done

I tried with both page and wasm allocators and also calling memory.grow(4096) before any allocator calls.

Maybe I should allocate a big enough chunk at the beginning, and then play with the offsets myself?

Something like this:

const fs = require('fs');
async function main() {
    const buf = fs.readFileSync('zig-out/lib/main.wasm');
    const res = await WebAssembly.instantiate(buf, {env: {print: (arg) => console.log(arg) }});
    const { iterate, memory, allocBytes, freeBytes } = res.instance.exports;
    memory.grow(4096);
    const size = 480 * 640 * 4; // this is using only ≅ 1.2 MiB
    const array_ptr = allocBytes(size + 10);
    const list_ptr = array_ptr + size;
    var array = new Uint8Array(memory.buffer, array_ptr, size)
    console.log(array);
    console.log(array_ptr);
    var list = new Uint8Array(memory.buffer, list_ptr, 10);
    console.log(array);
    console.log(array_ptr);
    freeBytes(list_ptr, 10);
    freeBytes(array_ptr, size);
}
main().then(() => console.log('done'));

Growing the memory invalidates the previous ArrayBuffer obtained from memory.buffer and all views created from it, so you need to obtain a new buffer by accessing memory.buffer anew after growing. Your first code snippet will work if you add array = new Uint8Array(memory.buffer, array_ptr, size) below the allocBytes(10) line.

When accessing wasm memory from JS you should assume that the ArrayBuffer returned from memory.buffer is only valid until the next time you call an exported wasm function (or explicitly grow the memory from JS). If you need to store array somewhere more permanent, store away array_ptr and size and then recreate the Uint8Array every time you need to access its data.

1 Like

Thank you so much, that was really helpful.
So, my takeaway, is that I should perform all the allocations upfront.
I learned a lot, thank you!