/react-wasm-demo

A simple demo of using C/C++ WebAssembly with React (create-react-app)

Primary LanguageJavaScriptMIT LicenseMIT

React C/C++ WASM demo

This project is a minimal create-react-app project that demonstrates how to compile C/C++ code into an ES6 WebAssembly module and use it in a create-react-app React app (without having to eject).

This is useful for getting native performance out of a computation-heavy part of a React app - for example, scientific/engineering simulations, video processing, or any other WebAssembly Use Case.

Table of contents

How to run

Prerequisites:

Run make.

The default Makefile target will compile matrixMultiply.c into matrixMultiply.mjs, which is imported in App.js.

After this, you can npm install and npm start to run the local dev server.

At localhost:3000 (which should be automatically opened by npm start), you will briefly see Loading webassembly..., then some output which shows some math (which is done using WebAssembly).

How to get here from a fresh create-react-app

  1. Add src/matrixMultiply.c
  2. Add Makefile with command to compile src/matrixMultiply.mjs (and move matrixMultiply.wasm into the public folder)
  3. Add "ignorePatterns": ["src/matrixMultiply.mjs"] to eslintConfig in package.json
    • This is required because the ES6 module (.mjs file) fails linting
  4. Import createModule from the .mjs file in App.js, instantiate it (which returns a Promise), and resolve the Promise to do things with the resulting module (Module in App.js).

All the interesting code is in src/matrixMultiply.c and App.js. The Makefile shows how to compile the .c file into the .mjs file. The ESLint config change is just required to build the app.

Extending

To make changes to the React code, edit App.js.

To make changes to the C code, edit matrixMultiply.c and run make again.

You can play with the emcc command if you need something else from the compiler (make -B is useful to force re-run the command during development).

Notes

Motivation

My friend Louis was writing an educational mechanical engineering game, where you are given the image of a stress distribution and need to draw in the forces that would produce it.

An image of two rainbow stress distribution diagrams. The top diagram is labeled Target. The bottom diagram shows a user-provided force vector and the resulting stress diagram.

But, it was slow, causing my browser to hang on the larger levels - in profiling we found almost all the time was spent in a large matrix multiply in a finite element method calculation.

Matrix multiply felt like an ideal use case for WebAssembly: a highly numerical, all-computational task where native performance would help. But when I tried to use WebAssembly with React, it seemed to be very hard without doing one of the following:

  • Ejecting from create-react-app to mess with the webpack config
  • Using react-app-rewired or craco to mess with webpack without ejecting
  • Hosting the .wasm file somewhere else entirely and fetching it

Eventually I ended up with the solution shown in README-inlined-version.md, which inlines the WASM into the .mjs file. That's not ideal because the WASM binary is Base64-encoded, which makes files larger.

(Then over a year later, I realized there's a better way to do it entirely by using the --pre-js option to read the WASM file out of the public folder, which serves the file directly. Thanks Evangelos for helping me figure this out.)

Compared against the original implementation with math.js, our WASM naive matrix multiply at -O0 (no optimization) was ~50% (1.5x) faster in Chrome and ~5,000% faster (51x) faster in Safari (*). It got 10x faster again at -O3, which gave us a new performance bottleneck in a pure JS matrix inversion! There is a lot more performance to squeeze out: this matrix multiply implementation can get a lot faster (as any 213/CS:APP student would know from Cache Lab), and we can continue to move more work into the WASM module. That work is still in progress, but when it's done I'll link it here.

* I didn't look into why this was such a big difference (or if it was some mistake in recording times). The pure JS code was faster in Chrome than in Safari, maybe because of V8 engine performance over JavaScriptCore. But, Safari's WASM code also ran twice as fast as Chrome's WASM code.

How I got here

Most of the intro-to-WebAssembly-type articles I found while my search involved using compiling to a .wasm file, and then fetching and instantiating it with instantiateStreaming. I instantly ran into problems when I tried this with create-react-app, because the default webpack configuration wouldn't let me serve a .wasm file. The rest of the intro articles used .html scaffolding targets - I also had issues getting this to work with create-react-app.

So, initially I would generate an .wasm file, and use the approach from sipavlovic/wasm2js to include it as base64 (you can see this in older commits on this repo). This worked well for my simple "add two integers" function. But I ran into issues when I needed methods on the Module object to work with memory to pass around arrays for matrixMultiply.

Eventually I figured out emcc can directly generate ES6 Javascript modules, with base64-inlined code.

-o <target>

    [link] When linking an executable, the target file name extension defines the output type to be generated:

            <name> .mjs : ES6 JavaScript module (+ separate <name>.wasm file if emitting WebAssembly).

Starting from this cryptic note in the emcc docs, I tried and failed a bunch of times. With the help of Github issues and source code, I eventually ended up with the command in the README-inlined-version.md.

Then I ran into a similar issue related to loading .data files for the WASM virtual filesystem. For some reason this made me remember the public file exists, and I spent some time reading through the prettier-formatted .mjs file to see where I could tweak the path that the WASM file is loaded from. It wasn't very hard to search for a literal .wasm, which led me to locateFile and the solution described here.

Explaining the emcc compiler invocation

The Makefile target has this command to generate the target src/matrixMultiply.js:

src/matrixMultiply.mjs: src/matrixMultiply.c
	emcc --no-entry src/matrixMultiply.c -o src/matrixMultiply.mjs  \
	  --pre-js src/locateFile.js  \
	  -s ENVIRONMENT='web'  \
	  -s EXPORT_NAME='createModule'  \
	  -s USE_ES6_IMPORT_META=0  \
	  -s EXPORTED_FUNCTIONS='["_add", "_matrixMultiply", "_malloc", "_free"]'  \
	  -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]'  \
	  -O3
	mv src/matrixMultiply.wasm public/matrixMultiply.wasm

Let's go line-by-line.


emcc --no-entry src/matrixMultiply.c -o src/matrixMultiply.mjs  \

emcc src/matrixMultiply.c -o src/matrixMultiply.mjs says, compile the source .c file into a .mjs file (ES6 Javascript module).

--no-entry is an argument for the linker wasm-ld that says we do not have an entrypoint (by default, the main() function). This is because we basically have a library that we are just picking functions out of.


--pre-js src/locateFile.js  \

This uses emcc's --pre-js function to override the behavior of the locateFile function, as described here:

oldLocateFile = (path, scriptDirectory) => scriptDirectory + path;
newLocateFile = (path, scriptDirectory_unused) => path;

This function determines the location of the .wasm file that is fetched. With the old implementation, it expects ./static/js, where create-react-app places the bundle.js created from the Javascript in src. But because of the webpack pain mentioned above, we'd rather look in the root directory, where unmodified public files go.


-s ENVIRONMENT='web'  \

All of the -s options are documented only in the settings.js source code, not anywhere on the docs site.

Here we want to run in the normal web environment for our React app. So, we disable the environments for webview, web worker, Node.js, and JS shell because we will never run there.


-s SINGLE_FILE=1  \

This option inlines the .wasm file into the .mjs file, as the base64 string wasmBinaryFile. This is the main change that allows us to run our code without changing the webpack configuration.


-s EXPORT_NAME='createModule'  \

Since we set the output type as .mjs above, emcc will automatically set MODULARIZE=1 and EXPORT_ES6=1. This will create an ES6 Javascript module, with a function that returns a Promise that resolves to the Module object (that is constantly referred to in the docs).

By default, that factory function is called Module, which is confusing because to use it you would need to write something like this:

import Module from "./matrixMultiply.mjs";
const myModule = await Module();
myModule.ccall(/* or whatever */);

...even though the emscripten docs constantly refer to Module.ccall, Module._malloc, and so on.

So instead, we follow the advice in the FAQ to rename it to createModule.


-s USE_ES6_IMPORT_META=0  \

By default, the generated module uses import.meta.url. This caused my webpack to error out with Module parse failed: Unexpected token; setting USE_ES6_IMPORT_META=0 falls back to a polyfill which does run without error:

-  var _scriptDir = import.meta.url;
+  var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined;

^ This diff shows the change when setting that flag to 0.


-s EXPORTED_FUNCTIONS='["_add", "_matrixMultiply", "_malloc", "_free"]'  \

Exporting these C function names ensures that they will not be optimized out. Actually, since we have EMSCRIPTEN_KEEPALIVE on add and matrixMultiply, we technically don't need these here. But I think it's nice to have explicit reminders of what these functions are, plus it adds a little snippet that aborts with error if you mistakenly call ._add() or ._matrixMultiply() on the Promise (as opposed to the Module that the Promise resolves to).


-s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]'  \

These are the standard ways to call compiled C functions from Javascript. In the example App.js, we use cwrap to get functions that we can call again later. We could also use ccall to make a single call to the function. See Emscripten docs for more info.


-O3

This flag optimizes the compiled code to make it load and run faster. See Emscripten docs on Optimizing Code for details.


	mv src/matrixMultiply.wasm public/matrixMultiply.wasm

Finally, we move the .wasm file into the public folder. I don't think there's an easy way to do this from the emcc command, but it's not like calling mv is very hard.

This approach can also be used for the .data files generated by --preload-file for the WebAssembly virtual filesystem. You'll want to check the generated .mjs for the logic around REMOTE_PACKAGE_NAME to see if the right path is being fetched.


Other helpful resources