Module Splitting

wasm-split and the SPLIT_MODULE Emscripten integration are both in active development and may change and gain new features frequently. This page will be kept up-to-date with the latest changes.

Large codebases often contain a lot of code that is very rarely used in practice or is never used early in the application’s life cycle. Loading that unused code can noticeably delay application startup, so it would be good to defer loading that code until after the application has already started. One excellent solution for this is to use dynamic linking, but that requires refactoring an application into shared libraries and also comes with some performance overhead, so it is not always feasible. Module splitting is another approach where a module is split into separate pieces, the primary and secondary modules, after it is built normally. The primary module is loaded first and contains the code necessary to start the application, while the secondary module contains code that will be needed later or not at all. The secondary module will automatically be loaded on demand.

wasm-split is a Binaryen tool that performs module splitting. After running wasm-split, the primary module has all the same imports and exports as the original module and is meant to be a drop-in replacement for it. However, it also imports a placeholder function for each secondary function that was split out into the secondary module. Before the secondary module is loaded, calls of secondary functions will call the appropriate placeholder function instead. The placeholder functions are responsible for loading and instantiating the secondary module, which automatically replaces all the placeholder functions with the original secondary functions when it is instantiated. After the secondary module is loaded, the placeholder function that loaded it is also responsible for calling its corresponding newly-loaded secondary function and returning the result to its caller. The loading of the secondary module is therefore completely transparent to the primary module; it just looks like a function call took a long time to return.

Currently the only workflow for splitting modules involves instrumenting the original module to collect a profile of what functions are run, running that instrumented module with a number of interesting workloads, and using the resulting profiles to determine how to split the module. wasm-split will leave any function that was run during any of the profiled workloads in the primary module and will split all other functions out into the secondary module.

Emscripten has a prototype integration with wasm-split enabled by the -sSPLIT_MODULE option. This option will emit the original module with the wasm-split instrumentation applied so it is ready to collect profiles. It will also insert the placeholder functions responsible for loading a secondary module into the emitted JS. The developer is then responsible for running appropriate workloads, collecting the profiles, and using the wasm-split tool to perform the splitting. After the module is split, everything will work correctly with no further changes to the JS produced by the initial compilation.

Basic Example

Let’s run through a basic example of using SPLIT_MODULE with Node. Later in the “Running on the Web” section we will discuss how to adapt the example to run on the Web as well.

Here’s our application code:

// application.c

#include <stdio.h>
#include <emscripten.h>

void foo() {
  printf("foo\n");
}

void bar() {
  printf("bar\n");
}

void unsupported(int i) {
  printf("%d is not supported!\n", i);
}

EM_JS(int, get_number, (), {
  if (typeof prompt === 'undefined') {
    prompt = require('prompt-sync')();
  }
  return parseInt(prompt('Give me 0 or 1: '));
});

int main() {
  int i = get_number();
  if (i == 0) {
    foo();
  } else if (i == 1) {
    bar();
  } else {
    unsupported(i);
  }
}

This application prompts the user for some input and executes different functions depending on what the user provides. It uses the prompt-sync npm module to make the prompting behavior portable between Node and the Web. We will see that the input we provide during profiling will determine how our functions are split between the primary and secondary modules.

We can compile our application with -sSPLIT_MODULE:

$ emcc application.c -o application.js -sSPLIT_MODULE

In addition to the typical application.wasm and application.js files, this also produces an application.wasm.orig file. application.wasm.orig is the original, unmodified module that a normal Emscripten build would produce, while application.wasm has been instrumented by wasm-split to collect profiles.

The instrumented module has an additional exported function, __write_profile, that takes as arguments a pointer and length for an in-memory buffer to which it will write the profile. __write_profile returns the length of the profile, and only writes the data if the supplied buffer is large enough. __write_profile can be called externally from JS or internally, from the application itself. For simplicity, we will just call it at the end of our main function here, but note that this will mean that any functions called after main, such as destructors for global objects, will not be included in the profile.

Here’s the function to write the profile and our new main function:

EM_JS(void, write_profile, (), {
  var __write_profile = wasmExports.__write_profile;
  if (!__write_profile) {
    return;
  }

  // Get the size of the profile and allocate a buffer for it.
  var len = __write_profile(0, 0);
  var ptr = _malloc(len);

  // Write the profile data to the buffer.
  __write_profile(ptr, len);

  // Write the profile file.
  var profile_data = HEAPU8.subarray(ptr, ptr + len);
  const fs = require("fs");
  fs.writeFileSync('profile.data', profile_data);

  // Free the buffer.
  _free(ptr);
});

int main() {
  int i = get_number();
  if (i == 0) {
    foo();
  } else if (i == 1) {
    bar();
  } else {
    unsupported(i);
  }
  write_profile();
}

Note that we only try to write the profile if the __write_profile export exists. This is important because only the instrumented, unsplit module exports __write_profile. The split modules will not include the profiling instrumentation or this export.

Our new write_profile function depends on malloc and free being available to JS, so we need to explicitly export them on the command line:

$ emcc application.c -o application.js -sSPLIT_MODULE -sEXPORTED_FUNCTIONS=_malloc,_free,_main

Now we can run our application, which produces a profile.data file. The next step is to use wasm-split and the profile to split the original module, application.wasm:

$ wasm-split --enable-mutable-globals --export-prefix=% application.wasm.orig -o1 application.wasm -o2 application.deferred.wasm --profile=profile.data

Let’s break down what all those options are for.

--enable-mutable-globals

This option enables the mutable-global target feature, which allows mutable Wasm globals (as opposed to C/C++ globals) to be imported and exported. wasm-split has to share mutable globals between the primary and secondary modules, so it requires this feature to be enabled.

--export-prefix=%

This is a prefix added to all the new exports wasm-split creates to share module elements from the primary module to the secondary module. The prefix can be used to differentiate “true” exports from those that only exist to be consumed by the secondary module. Emscripten’s wasm-split integration expects “%” in particular to be used as the prefix.

-o1 application.wasm

Write the primary module to application.wasm. Note that this will overwrite the instrumented module previously produced by Emscripten, so the application will now use the split modules rather than the instrumented module.

-o2 application.deferred.wasm

Write the secondary module to application.deferred.wasm. Emscripten expects the name of the secondary module to be the same as the name of the primary module with “.wasm” replaced with “.deferred.wasm”.

--profile=profile.data

Directs wasm-split to use the profile in profile.data to guide the splitting.

Running application.js in node again, we can see that the application works just as it did before, but if we execute any code path besides the one used in the profiled workload, the application will print a console message about a placeholder function being called and the deferred module being loaded.

Profiling Multiple Workloads

wasm-split supports merging profiles from multiple profiling workloads into a single profile to guide splitting. Any function that was run in any of the workloads will be kept in the primary module and all other functions will be split out into the secondary module.

This command will merge any number of profiles (here just profile1.data and profile2.data) into a single profile:

$ wasm-split --merge-profiles profile1.data profile2.data -o profile.data

Multithreaded Programs

By default, the data gathered by the wasm-split instrumentation is stored in Wasm globals, so it is thread local. But in a multithreaded program, it is important to collect profile information from all threads. To do so, you can tell wasm-split to collect shared profile information in shared memory using the --in-memory wasm-split flag. This will use memory starting at address zero to store the profile information, so you must also pass -sGLOBAL_BASE=N to Emscripten, where N is at least the number of functions in the module, to prevent the program from clobbering that memory region.

After splitting, multithreaded applications will currently fetch and compile the secondary module separately on each thread. The compiled secondary module is not postmessaged to each thread the way the Emscripten postmessages the primary module to the threads. This is not as bad as it sounds because downloads of the secondary module from workers will be serviced from the cache if the appropriate Cache-Control headers are set, but improving this is an area for future work.

Running on the Web

One complication to keep in mind when using SPLIT_MODULE for Web applications is that the secondary module cannot be loaded both lazily and asynchronously, which means it cannot be loaded lazily on the main browser thread. The reason is that the placeholder functions need to be completely transparent to the functions in the primary module, so they can’t return until they have synchronously loaded and called the correct secondary function.

One workaround for this limitation would be to eagerly load and instantiate the secondary module and ensure that no secondary functions can possibly be called before it has been instantiated on the main browser thread. This may be difficult to ensure, though. Another fix would be to run the Asyncify transformation on the primary module to allow placeholder functions to return to the JS event loop while waiting for the secondary module to load asynchronously. This is on the wasm-split roadmap, although we do not yet know what the size and performance overhead of this solution will be.

This limitation on lazy loading means that the best way to run applications with SPLIT_MODULE is in a worker thread, for example using -sPROXY_TO_PTHREAD. In PROXY_TO_PTHREAD mode, it is important to collect a profile for the browser main thread in addition to the application main thread because the browser main thread runs some functions not run in the application main thread, such as the shim that wraps the proxied main function and the functions involved in handling calls proxied back to the main browser thread. See the previous section for how to collect profiles from multiple threads.

Another minor complication is that the profile data cannot be immediately written to a file from inside the browser. The data must instead be transmitted to developer machines some other way, such as posting it to the dev server or copying a base64 encoding of it from the console.

Here’s code implementing the base64 solution:

var profile_data = HEAPU8.subarray(ptr, ptr + len);
var binary = '';
for (var i = 0; i < profile_data.length; i++) {
    binary += String.fromCharCode(profile_data[i]);
}
console.log("===BEGIN===");
console.log(window.btoa(binary));
console.log("===END===");

Then the profile file can be created by by running:

$ echo [pasted base64] | base64 --decode > profile.data

or:

$ base64 --decode [base64 file] > profile.data

Usage with Dynamic Linking

Module splitting can be used in conjunction with dynamic linking, but making the two features work correctly together requires some developer intervention. wasm-split often needs to grow the table to make space for placeholder functions, but that means that the instrumented and split modules would have different table sizes. Normally this is not a problem, but MAIN_MODULE/SIDE_MODULE dynamic linking support currently requires the table size to be baked into the JS Emscripten emits, so the table size needs to be stable.

To ensure that the table size is the same between the instrumented module and the split modules, use the -sINITIAL_TABLE=N Emscripten setting, where N is the desired table size. Then, when using wasm-split to perform the splitting, pass --initial-table=N to wasm-split to ensure that the split modules have the correct table size as well.

If the specified table size is too small, you will get an error message when the primary module is loaded after splitting. Adjust the table size you specify until it is large enough. Besides taking up extra space at runtime, there is no downside to specifying a table size that is larger than necessary.

Custom Loading of the Secondary Module

The default logic for lazily loading the secondary module can be overridden by implementing the “loadSplitModule” custom hook function. The hook is called from placeholder functions and is responsible for returning the [instance, module] pair for the secondary module. The hook takes as arguments the name of the file to load (e.g. “my_program.deferred.wasm”), the imports object to instantiate the module with, and the property corresponding to the called placeholder function. Here is an example implementation that does the same thing as the default implementation with some extra logging:

Module["loadSplitModule"] = function(deferred, imports, prop) {
    console.log('Custom handler for loading split module.');
    console.log('Called with placeholder ', prop);

    return instantiateSync(deferred, imports);
}

If the module was eagerly loaded, then this hook could simply instantiate the module rather than fetching and compiling it as well. However, if the eagerly loaded module is instantiated eagerly as well, the placeholder functions will be patched out and never called in the first place, so this custom hook will never be called either.

When eagerly instantiating the secondary module, the imports object should be:

{'primary': wasmExports}

Debugging

wasm-split has several options to make debugging split modules easier.

-v

When splitting, print the primary and secondary functions. When merging profiles, print profiles that do not contribute to the merged profile.

-g

Preserve names in both the primary and secondary modules. Without this option, wasm-split will strip the names instead.

--emit-module-names

Generate and emit module names to differentiate the primary and secondary module in stack traces, even if -g is not used.

--symbolmap

Emit separate map files for the primary and secondary modules, mapping function indices to function names. When combined with –emit-module-names, these maps can be used to re-symbolify stack traces. To ensure that the function names are available for wasm-split to emit into the maps, pass –profiling-funcs to Emscripten.

--placeholdermap

Emit a map file mapping placeholder function indices to their corresponding secondary functions. This can be useful for figuring out what function caused the secondary module to be loaded.

Upcoming Changes

A list of changes and new features that have not yet been incorporated into this documentation.

Work is planned on an integration with the Asyncify instrumentation that will allow the secondary module to be asynchronously loaded on the main browser thread.