Everything is just data, be it documents, images, video, spreadsheets, even applications, they are all just a collection of bytes in memory somewhere. The information is stored in such a way as to mean something to the application using it. You can create your own block of memory and format the data in any way you want. Working with memory in WAT has its limitations but understanding how it works is important.
A single instance of a WASM module has one block of memory.
You cannot create new blocks like other programming languages.
You need to manage how the data is stored within the block of memory yourself.
There is no memory management, no new
and delete
keywords, you need to work it all out on your own.
The size of the memory is based on pages. Each page contains 64 * 1024 bytes (65536). You can set up the initial size of the memory to be allocated and even grow the size later on. Also, the memory is guaranteed to be set to zero when allocated.
Memory is just a very long array of bytes. Each byte has a memory address, an offset from its start. You read and write to that byte of memory using this offset value.
Offset | Value |
---|---|
0 | 0x01 |
1 | 0xF2 |
2 | 0x42 |
3 | 0x6C |
4 | 0x92 |
Visualize memory as being a very long block of byte data, with each byte having its own offset address.
Reading and writing to memory is done using the memory offset location and a data type.
You can write an i32
value to memory and read it back again.
Because the data type is 32 bits long, 4 bytes, it will write all 4 bytes into 4 memory slots.
;; Push the memory offset to write to onto the stack i32.const 2 ;; Push the i32 value to write to memory onto the stack i32.const 0x12345678 ;; Pop the memory offset and the value off the stack ;; and set the memory as required i32.store
In this example we are setting a location in memory to an i32
value.
We do this with the i32.store
instruction, which requires the memory offset address and the value pushed on to the stack.
It will pop them both off the stack and perform the task to set the memory with the value.
Offset | Value |
---|---|
0 | 0x00 |
1 | 0x00 |
2 | 0x78 |
3 | 0x56 |
4 | 0x34 |
5 | 0x12 |
6 | 0x00 |
This is what the memory looks like afterwards.
The least significant bits are stored first followed by the most significant bits.
The order is important to understand.
The first 8 bits of the i32
value are stored in the first memory location, then the next 8 bits are stored in the next memory location, and so on.
;; Push the memory offset to read from onto the stack i32.const 1 ;; Pop the memory offset off the stack and load the value ;; stored in memory i32.load ;; Stack now contains an i32 value 0x34567800
In this example we are reading data from memory, but this time we are using a different memory location, using the offset address 1 instead of 2. This reads the 4 bytes from the memory location starting at offset 1. As you can see, the first value is 0x00, which is the starting 8 bits of the final value. This is followed by the next 8 bits of the value, and so on.
The size of the data you store in memory depends on the data type you are using.
For floating point numbers, a f32
contains 32 bits and therefore takes up 4 bytes in memory, and
a f64
has 64 bits which is 8 bytes in size.
With integers there are instructions to only read or write parts of the value, for example you can write just the first 8 bits of an i32
value.
;; Push the memory offset to write to onto the stack i32.const 3 ;; Push the i32 value to write to memory onto the stack i32.const 0xB1C2D3F4 ;; Pop the memory offset and the value off the stack ;; and set the first 8 bits to memory as required i32.store8
The above code takes the first 8 bits of the value (0xF4) and stores it in memory at the location offset 3. This is what the memory looks like afterwards. Only the memory byte at offset 3 has changed. The bytes following it have not been changed.
Offset | Value |
---|---|
0 | 0x00 |
1 | 0x00 |
2 | 0x78 |
3 | 0xF4 |
4 | 0x34 |
5 | 0x12 |
6 | 0x00 |
Reading smaller sized integers from memory needs to take the sign flag into account, it has to check if the value being loaded is signed (can be negative)
or unsigned (positive only value).
This is done using either load8_u
(unsigned) or load8_s
(signed) instructions (other sizes are available).
;; Load unsigned 8 bits i32.const 3 i32.load8_u ;; The i32 value on the stack is 0x000000F4 (244) ;; Load signed 8 bits i32.const 3 i32.load8_s ;; The i32 value on the stack is 0xFFFFFFF4 (-12)
We are loading in the value at memory offset 3.
This has the value 0xF4, which if you look at it as an unsigned value it is 244, but if you see it as signed then it becomes -12.
When loading the value as unsigned there is no checking for a signed flag bit and therefore the 8 bit value is copied into the lower part of the i32
value,
with the remaining higher parts set to zero.
When loading with the sign flag being checked, then if the sign bit flag is found, the higher part of the i32
value is set to 1s, converting
it into a negative value.
When memory is first allocated, as the WASM instance is created, each byte will be automatically set to zero. You can change this by setting the memory to something different before you start using the module. This is performed by using data blocks containing constant memory values.
;; Set memory data with string (data (i32.const 0) "Hello") ;; Set memory data with string at a different offset (data (i32.const 5) "World") ;; Set hexadecimal values (data (i32.const 12) "\F1\04\9A")
In this example we are using an S-Expression to set different data at different places in memory.
The first part is the offset location within memory to copy the data to.
This is an i32
data type value and needs to be within the limits of the available memory.
Afterwards is the data that will be copied into memory. It can be ASCII text or a hexadecimal value.
With the example above, the starting memory will end up containing the following data.
Offset | Value | Offset | Value |
---|---|---|---|
0 | 0x48 | 8 | 0x6C |
1 | 0x65 | 9 | 0x64 |
2 | 0x6C | 10 | 0x00 |
3 | 0x6C | 11 | 0x00 |
4 | 0x6F | 12 | 0xF1 |
5 | 0x57 | 13 | 0x04 |
6 | 0x6F | 14 | 0x9A |
7 | 0x72 | 15 | 0x00 |
This is used to set memory when it is allocated for the first time. However, we can keep different blocks of data containing fixed constant values, storing them until they are needed, and when required copying them into memory. This uses “passive” memory, data that is part of WASM but not automatically inserted into memory.
;; Create passive memory 1 (data $passive1 "This is data block") ;; Create passive memory 2 (data $passive2 "This is another data block")
Here we are declaring data similar to how we did it before, but this time, instead of the memory being given the location to store it, we have a label. This label will be used later when we are copying the data into memory. You can have any number of passive data blocks of any size.
;; Create passive memory 1 (data $passive1 "This is data block") (func $setupMemory ;; Set offset within main memory to copy data to i32.const 0 ;; Set offset within passive memory to copy data from i32.const 0 ;; Set the length to copy over i32.const 18 ;; Initialize memory with with passive data (copy the data over) memory.init $passive1 )
In this example we start off by declaring a passive data block.
This is stored inside WASM, but is not put inside the allocated memory when it is created.
The next part is a $setupMemory
function, which shows how we can set up some of the memory by copying passive memory into it.
This is done using the memory.init
instruction, which will first require some parameters pushed onto the stack.
The first thing needed is the offset in memory where we want to copy the data to.
This is followed by the offset within the passive memory block where the data is coming from.
We don't need to copy all the passive data, we can select only the parts we need.
Then finally we need to set the length of the data we want to copy over.
When calling the memory.init
instruction we need to give the passive memory label.
This is telling the instruction which passive memory we want to copy the data from.
Afterwards the main memory will contain the data we copied over, and the passive memory will still be untouched, so we can use it again at some other point.
Passive memory blocks are a great way of using constant data for many different reasons. Keep this in mind whenever you are using memory in your applications.
Moving memory around in large blocks is fast and easy, instead of doing it slowly byte by byte. You can copy a section of data in memory from one location to another using a single instruction.
;; Set offset within main memory to copy data to i32.const 100 ;; Set offset within main memory to copy data from i32.const 20 ;; Set size of memory to copy i32.const 50 ;; Copy the memory block memory.copy
To copy a block of memory from one location to another you need to use the memory.copy
instruction.
This requires a number of parameters to be pushed onto the stack.
The first thing needed is the offset location to copy the data to within memory.
This is followed by the offset where the data you want to copy is located.
The final part is the size of the data you want copied.
In the above example we are copying 50 bytes of data from offset 20 to memory location 100.
You are copying a block of memory within the same main memory. You could be copying data that overlaps itself, so be careful with offsets and sizes.
Another useful instruction is memory.fill
which is used to set all the bytes within a block of memory to a value.
;; Set offset within main memory to fill i32.const 10 ;; Set the value to fill with (we only look the first 8 bits) i32.const 0xAE ;; Set the size of the memory to fill i32.const 256 ;; Fill the memory with 0xAE value memory.fill
The instruction requires a number of parameters pushed onto the stack.
The first thing you need is the memory offset where the block you want to set is located.
This is followed by the value you want to set the memory to.
The value is an i32
data type, which is 32 bits, but it will only be looking at the first 8 bits of the value.
These 8 bits will be the value used when filling all the bytes in the memory block.
The final part is the size of the block of memory to fill.
In the example above, we are setting the memory located at offset 10, to the value of 0xAE, for a length of 256 bytes.
To connect the memory used within WASM to JavaScript you have to go through a number of extra steps. Once the link is made you are able to interact with the same memory block in both places, within WASM and outside in JavaScript. For example, you could take some image data, put it into memory, and have a WASM program process the data to filter the colors.
The importing process requires you to create a WebAssembly.Memory
object in JavaScript and to import it into WAT.
// Create memory const memory = new WebAssembly.Memory({ initial: 1, maximum: 2 }); // Create options object const options = { import: { memory: memory } }; // Create instance of WASM with options const wasm = await WebAssembly.instantiateStreaming( fetch('app.wasm'), options);
We need to first create the memory object giving some parameters about the size it should start with. The sizes are the number of memory pages (64 * 1024 bytes), so in the example above we are starting with a memory size of 1 page (65,536 bytes) and not allowing it to grow beyond 2 pages in length.
We then need to add the memory object to the list of options we are going to create the WASM instance with. We are using the property “import” and “memory” names, but you can call it whatever you want.
;; Import memory (import "import" "memory" (memory 1 2))
In order to import the JavaScript memory, we need to use the import
S-Expression keyword.
This is followed by the same object and property names we used in JavaScript, so that it can find the memory object to import.
After this is a memory S-Expression detailing what is being imported, with the initial and maximum sizes (again in pages).
Both the initial and maximum sizes set in WASM must match those used in JavaScript. However, you do not need to give the maximum size in either place. You can allow the memory to grow to whatever size you require.
In JavaScript you are able to use the WebAssembly.Memory
object and its buffer
property to read and write to the memory block.
You cannot use it directly, you will need to create one of the many different JavaScript array classes, like Uint8Array or Float64Array, to interact with the memory.
// Create Uint8Array of the memory let uint8ArrayMemory = new Uint8Array(memory.buffer); // Write data to memory uint8ArrayMemory[0] = 0x01; uint8ArrayMemory[1] = 0x02; // Read data from memory const byte0 = uint8ArrayMemory[0];
Any changes to the memory you make in JavaScript are seen within WASM, because they are using the same block of memory. This is also true when you make changes to memory through WASM, which are then seen in JavaScript too. This shared memory between them both can be used to process large amounts of data very quickly. Tasks which would take JavaScript a long time can now be processed very quickly, utilizing the power and low level instructions of WASM.
We can create a memory block within WASM and have it exported to JavaScript. This is the other way round from the importing process above. It is basically doing the same thing, linking memory within WASM to JavaScript, but instead of having JavaScript take the lead like before, this time WASM is in control.
;; Create memory and set it to be exported (memory (export "memory") 1 2)
We are using the memory
S-Expression to create the memory block, which also has an inner export
S-Expression.
This export is given a name which will be used later on.
This name is set as “memory” in the example above, but you can use whatever you want.
After this we have the initial and maximum page sizes the memory will have.
Here, it is WASM that is creating the memory, not JavaScript.
When the WASM instance is created the memory will be allocated and will be ready for use.
// Create instance of WASM const wasm = await WebAssembly.instantiateStreaming(fetch('app.wasm')); // Get exported memory object const memory = wasm.instance.exports.memory;
The WASM instance object contains an exports
object.
Anything within WASM that is marked as “export” will be listed here, including the “memory” object.
This has the same WebAssembly.Memory
object we used with the importing process.
You can use it in the exact same way too.
Importing and exporting memory is done in different ways but the end result is the same. They both create memory and allow both WASM and JavaScript to read and write to it.
You do not need to import or export memory. You can create memory in WASM and only use it inside without needing to give access to JavaScript.
By default the memory created in JavaScript can only be used with one WASM instance. But you can create it in a way that allows you to share the same memory block between many WASM instances, either from the same WASM module or even different modules.
You can import or export shared memory, the end result will be the same.
// Create shared memory const memory = new WebAssembly.Memory({ initial: 1, shared: true }); // Create options object const options = { import: { memory: memory } }; // Create instance of WASM with options const wasm = await WebAssembly.instantiateStreaming( fetch('app.wasm'), options);
This is almost exactly the same as before but this time we have included the extra memory object property shared
set to true.
By default this is false.
The memory object created is the same but behind the scenes there are extra steps being made to help it cope with memory being shared.
This does have a small impact on performance.
;; Import memory (import "import" "memory" (memory 1 shared))
We are importing the memory like before, but this time we have the extra shared
keyword in the memory
S-Expression.
This is used to state that the memory is shared.
The memory in both JavaScript and WASM must both be declared as shared for the import to work.
// Create shared memory const memory = new WebAssembly.Memory({ initial: 1, shared: true }); // Create options object const options = { import: { memory: memory } }; // Create instances of WASM with options const wasm1 = await WebAssembly.instantiateStreaming( fetch('app.wasm'), options); const wasm2 = await WebAssembly.instantiateStreaming( fetch('app.wasm'), options); const wasm3 = await WebAssembly.instantiateStreaming( fetch('app.wasm'), options); // Create arrays of memory let uint8ArrayMemory = new Uint8Array(memory.buffer); // Set memory uint8ArrayMemory[0] = 0x01; uint8ArrayMemory[1] = 0x02; uint8ArrayMemory[2] = 0x03; // Call WASM function to read data from memory const m1 = wasm1.instance.exports.readMemory(0); // m1 = 0x01 const m2 = wasm2.instance.exports.readMemory(1); // m2 = 0x02 const m3 = wasm3.instance.exports.readMemory(2); // m3 = 0x03
This is a simple example of creating 3 instances of the same WASM, but with them all using the same block of memory.
We set the first 3 bytes within JavaScript, then call an internal WASM function (not shown) to read from memory (using the i32.load8
instruction)
at the given offset location.
It illustrates how the shared memory is used by JavaScript and the 3 WASM instances, all at the same time.
When you create memory you give it an initial size. But there may come a time when you want the memory to be larger. You don't always know how big you need the memory to be, so you want to be able to dynamically control the size of the memory.
This can be done by growing the memory. You may initialise the memory to 1 page (65,536 bytes) and later on grow it to 2 pages (131,072 bytes) or even more. This process is creating a new block of memory, copying the old memory into the new memory block, and then setting the new memory block as the main memory. The larger the initial memory is, the longer this process will take.
If you created the memory with a maximum size then you will only be able to grow the memory to that limit. If no limit is given then you can allocate as much memory as is available.
;; Set the number of pages to grow by (each page is 64Kb) i32.const 1 ;; Grow the memory memory.grow
In this example we are pushing the number of pages we want the memory to grow by onto the stack.
This is then followed by calling the memory.grow
instruction.
// Create memory const memory = new WebAssembly.Memory({ initial: 1, maximum: 2 }); // Create instance ... // Grow memory by 1 page (each page is 64Kb) memory.grow(1);
Here we are growing the memory from JavaScript. The same thing is happening whether it is done in JavaScript or WASM.
You may need to know the current size of the memory.
You can find this out using the memory.size
instruction.
This will allow you to find the number of pages allocated.
;; Get the number of pages allocated to memory memory.size ;; This has pushed the number of pages onto the stack
This instruction pushes the size of the memory, in pages, onto the stack.
// Create memory const memory = new WebAssembly.Memory({ initial: 1, maximum: 2 }); // Create instance ... // Get size of memory (1 page is 64Kb) const size = memory.size();
Getting the size of the memory in JavaScript is also very simple to do. Both the JavaScript memory function and the WASM instruction are returning the same result, as they are both doing the same thing.
There are a number of ways you can interact with the memory in JavaScript. Memory is just a large block of bytes that you can change in any way you want, and there are a number of different classes you can create that view the data in different ways.
// Create different ways of viewing the same data let byte8Array = new Uint8Array(memory.buffer); let word16Array = new Uint16Array(memory.buffer); let int32Array = new Int32Array(memory.buffer); let float64Array = new Float64Array(memory.buffer); // Write data to memory byte8Array[0] = 0x01; word16Array[1] = 0xE2A3; int32Array[2] = 0xB2904CF1; float64[3] = 3.142;
Creating the array objects allows you to interact with the same memory.
It does not create a new copy of the memory, they are accessing the same block of memory, but in different ways.
The Uint8Array
is looking at each byte as an unsigned integer number, from 0 to 255.
Array index 0 points to the first memory location, and index 1 points to the next byte.
The Uint16Array
sees the memory as a list of unsigned 16 bit integer numbers.
Each item takes up 2 bytes.
Therefore index 0 points to memory location 0, but index 1 points to offset 2 within the block of memory.
The same type of thing is happening with the Int32Array
too.
This is 4 bytes for each index item, so that index 1 points to memory offset location 4.
Each one of these is looking at the memory block as a list of items of the same data type. It is difficult to handle more complex data structures instead of just a single list of items.
There is no standard string type in WAT so you need to handle text yourself. JavaScript handles strings internally as UTF-16, which means each character is a 16 bit value. However, with some foreign characters, a single character is made up of 2x 16 bit UTF-16 values. A single UTF character is known as a code point and can be encoded in a number of different ways. Below we will explore how UTF-8 can be used to copy string data into memory and out of it.
// Create a text encoder object. This encodes text to UTF-8 format let encoder = new TextEncoder(); // Encode the text into an array of unsigned bytes (in UTF-8 format) let uint8ArrayText = encoder.encode(text); // Create Uint8Array of the memory let uint8ArrayMemory = new Uint8Array(memory.buffer); // Copy the text to the WASM memory uint8ArrayMemory.set(uint8ArrayText);
We need to encode the text into an array of UTF-8 bytes. UTF-8 will encode standard ASCII characters as they are, but anything over this range can be encoded into either 2, 3 or 4 bytes. We then create another unsigned 8 bit byte array of the WASM memory block and copy the UTF-8 encoded data into the WASM memory.
It will be up to your WASM code to handle any UTF-8 encoding it finds when processing the string. This can be difficult to do if you have never done anything like this before.
If you have created some text and put it inside the WASM memory, then you will want to take it out and use it within JavaScript. This requires the following steps.
// Create Uint8Array from the WASM memory containing only the text const uint8ArrayText = new Uint8Array(memory.buffer, 0, sizeOfText); // Create a text decoder. This decodes UTF-8 text const textDecoder = new TextDecoder("UTF-8"); // Decode the encoded UTF-8 text const result = textDecoder.decode(uint8ArrayText);
We need to first take the UTF-8 encoded text from WASM memory and copy it into an unsigned 8 bit byte array.
We then use the TextDecoder
to convert the encoded text into a string object.
Moving text between JavaScript and WASM is more involved than you may first think. Therefore you need to be careful and not assume you are working with just ASCII text, like within a C/C++ application, but you could be interacting with any characters from any language in the Unicode character set.