TypeScript modules With Emscripten and CMake, part 5
When I set out to create an NPM package for SoundSwallower, I was unable to find much relevant information in the Emscripten documentation or elsewhere on the Web, so I have written this guide, which walks through the process of compiling a library to WebAssembly and packaging it as a CommonJS or ES6 module.
This is part of a series of posts. Start here to read from the beginning.
Optimizing for Size
Really, “for Size” is redundant here. It’s the Web. People are loading your page/app/whatever on mobile phones over metered connections. There is no other optimization you should reasonably care about. So, let’s see how we’re doing:
$ ls -lh jsbuild
-rw-rw-r-- 1 dhd dhd 64K fév 27 12:09 kissfft.cjs.js
-rwxrwxr-x 1 dhd dhd 197K fév 27 12:09 kissfft.cjs.wasm
Not good! Well, we did configure this with
CMAKE_BUILD_TYPE=Debug
, and we didn’t bother adding any optimization
flags at compilation or link time. When optimizing for size, however,
we should not immediately switch to Release
build, as the
minimization that it does makes it impossible to understand what parts
of the output are wasting space.
Let’s set up CMake to build everything with maximum optimization with
the -Oz
option, which should be passed at both compile and link
time. (Note: I will not discuss -flto
here, because it is only
useful when dealing with the eldritch horrors of C++). While we’re at
it we’ll also disable support for the longjmp
function which we know
our library doesn’t use:
target_compile_options(kissfft PRIVATE -Oz -sSUPPORT_LONGJMP=0 -sSTRICT=1)
target_link_options(kissfft.cjs PRIVATE
-sMODULARIZE=1 -Oz -sSUPPORT_LONGJMP=0
-sEXPORTED_FUNCTIONS=@${CMAKE_CURRENT_SOURCE_DIR}/exported_functions.txt)
target_link_options(kissfft.esm PRIVATE
-sMODULARIZE=1 -sEXPORT_ES6=1 -Oz -sSUPPORT_LONGJMP=0
-sEXPORTED_FUNCTIONS=@${CMAKE_CURRENT_SOURCE_DIR}/exported_functions.txt)
Now let’s see where we’re at:
$ cmake --build jsbuild
$ ls -lh jsbuild
-rw-rw-r-- 1 dhd dhd 40K fév 27 12:11 kissfft.cjs.js
-rwxrwxr-x 1 dhd dhd 139K fév 27 12:11 kissfft.cjs.wasm
Already much better! Now, if you have
WABT installed, you can use
wasm-objdump
to quickly see which functions are taking the most
space:
$ wasm-objdump -x jsbuild/kissfft.cjs.wasm | grep size
- func[13] size=5194 <dlmalloc>
- func[14] size=1498 <dlfree>
Now, Emscripten has an option to use a smaller, less full-featured memory allocator, which, since we know that our library is quite simple and doesn’t do a lot of allocation, is a good idea. Let’s change the link flags again:
target_link_options(kissfft.cjs PRIVATE
-sMODULARIZE=1 -Oz -sSUPPORT_LONGJMP=0 -sMALLOC=emmalloc
-sEXPORTED_FUNCTIONS=@${CMAKE_CURRENT_SOURCE_DIR}/exported_functions.txt)
target_link_options(kissfft.esm PRIVATE
-sMODULARIZE=1 -sEXPORT_ES6=1 -Oz -sSUPPORT_LONGJMP=0 -sMALLOC=emmalloc
-sEXPORTED_FUNCTIONS=@${CMAKE_CURRENT_SOURCE_DIR}/exported_functions.txt)
This saves another 20K (unminimized and uncompressed):
$ cmake --build jsbuild
$ ls -lh jsbuild
-rw-rw-r-- 1 dhd dhd 40K fév 27 12:14 kissfft.cjs.js
-rwxrwxr-x 1 dhd dhd 119K fév 27 12:14 kissfft.cjs.wasm
If we look again at wasm-objdump
we can see that there isn’t much
else we can do, as what’s left consists of runtime support, including
some stubs to allow debug printf()
to work.
What about the .js
file? Here’s where it gets a bit more
complicated. First, let’s rebuild in Release
mode and see where
we’re at after minimization. It’s also important to look at the
compressed size of the .js
and .wasm
files, as a good webserver
should be configured to serve them with gzip
compression:
$ emcmake cmake -S. -B jsbuild -DCMAKE_BUILD_TYPE=Release
$ cmake --build jsbuild
$ ls -lh jsbuild
-rw-rw-r-- 1 dhd dhd 12K fév 27 12:32 kissfft.cjs.js
-rwxrwxr-x 1 dhd dhd 12K fév 27 12:32 kissfft.cjs.wasm
$ gzip -c jsbuild/kissfft.cjs.js | wc -c
3599
$ gzip -c jsbuild/kissfft.cjs.wasm | wc -c
7307
So, our total payload size is about 11K. This is quite acceptable in most circumstances, so you may wish to skip to the next section at this point.
Now, Emscripten also has an option -sMINIMAL_RUNTIME=1
(or 2)
which can shrink this a bit more, but the problem is that it doesn’t
actually produce a working CommonJS or ES6 module with
-sMODULARIZE=1
and -sEXPORT_ES6=1
, and worse yet, it cannot
produce working code for the Web or ES6 modules, because it loads the
WebAssembly like this:
if (ENVIRONMENT_IS_NODE) {
var fs = require('fs');
Module['wasm'] = fs.readFileSync(__dirname + '/kissfft.cjs.wasm');
}
Basically your only option if you use -sMINIMAL_RUNTIME
is to
postprocess the generated JavaScript to work properly in the target
environment, because even if you enable streaming compilation, it will
still include the offending snippet above, among other things. Doing
this is quite complex and beyond the scope of this guide, but you can
look at the build.js script used by
wasm-audio-encoders,
for example.
The other option, if your module is not too big and you don’t mind that it all gets loaded at once by the browser, is to do a single-file build:
Single-File Builds (WASM or JS-only)
In many cases it is a super huge
pain to get the
separate .wasm
file packaged and loaded correctly when you are using
a “framework” or even just a run-of-the-mill JavaScript bundler like
Webpack or
ESBuild. This is, for instance, the case
if you use
Angular, which
requires a custom Webpack configuration in order for it to work at
all with modules that use WebAssembly. (note that by default, Webpack
works just fine, as long as you have a pretty recent version)
We will go into the details of this in the next installment, but
suffice it to say that, if you have a small enough library, you can
save yourself a lot of trouble by simply making a single-file build,
which you can do by adding -sSINGLE_FILE=1
to the linker
options. This gives a quite acceptable size, which is only slightly
large due to the fact that the WebAssembly gets encoded as base64:
$ ls -lh jsbuild
-rw-rw-r-- 1 dhd dhd 29K fév 27 16:07 kissfft.cjs.js
$ gzip -c jsbuild/kissfft.cjs.js | wc -c
13321
Note, however, that in this case if you load the resulting JavaScript
before your page contents, your users will have to wait until it
downloads to see anything, whereas with a separate .wasm
file, the
downloading can be done asynchronously.
Alternately, if you want to support, say, Safari 13, iOS 12, or
anything else that predates the final WebAssembly spec, you can simply
disable WebAssembly entirely and compile to JavaScript with
-sWASM=0
. Sadly, at the moment, this is also incompatible with
-sEXPORT=ES6=1
.
In the next episode, stay tuned for how to actually use this module in a simple test application!