Even better Source Maps with C++, WebAssembly and Cheerp
A few months ago I saw this blog post by Mozilla, which is about rewriting part of their source-map library in rust code compiled to WebAssembly.
I also noticed that the current limitations of WebAssembly lead to what I think are some sub-optimal design choices, and I thought that it could be an interesting experiment to write an implementation in C++ using the Cheerp compiler.
Cheerp features summary
In order to explain why I think Cheerp is particularly fit to solve some of the problems that I saw in the Rust version, I will briefly summarize what makes Cheerp unique as a compiler for the web.
Cheerp 2.0 (currently in RC2) supports two different memory models, that can coexist in a single codebase:
- The second one is the linear memory model introduced with Cheerp 2.0: This is the model of traditional architectures like x86 and Arm, and the one used by Emscripten. The code in this case is compiled to Asm.js or WebAssembly. The main strength of this model is the better performance and the better support for type-unsafe operations (pointer arithmetic, arbitrary casting, unions). Functions and structs/classes that follow this memory model is tagged with the attribute
With Cheerp, functions with different tags can call each other, but some restrictions apply: for example it is not allowed for functions with the
[[cheerp::wasm]] attribute to have parameters (or return values) of types with the
[[cheerp::genericjs]] attribute. The reason is that
Handling WebAssembly objects from a
genericjs functions can not only handle WebAssembly objects through pointers, but they can also allocate values both on the stack and on the heap.
The architecture of the source-map library is based on the strengths of both memory models:
- The core algorithms and data structures are compiled to WebAssembly, in order to gain the maximum performance benefits. They are exposed by the
String) as arguments and return values. The API is provided by the
Improving the Interoperability
Most of the code in
raw_mappings.cpp is just a direct translation from Rust to C++. What I think is more interesting is the code in
mappings.cpp, because it showcases some of the interoperability capabilities of Cheerp.
In particular, I want to focus on three design choices that I believe are suboptimal in the original Rust implementation, and on how I implemented them with Cheerp.
1. Allocating memory for the mappings string
In the Rust implementation, a user must perform the following steps in order to pass the encoded mappings to the library:
allocate_mappings(size: usize) -> *mut u8in order to receive a pointer to a buffer allocated in the linear memory:
3. Call the actual
Now that the buffer is filled, we can finally call the parsing function.
This function manually reconstructs the
Vec from the raw pointer, and calls the real implementation function.
The signature of the entry point is:
create function is pretty simple:
It first converts the JS string to
std::string. This internally does the same loop as the manual JS code above, but it is written in C++, so we can avoid manually handling memory from JS. Then it passes the
std::string to the
RawMappings creation function, that will parse it in a vector of
RawMapping objects and sort it (The code for this is pretty similar to the Rust version).
2- Error handling
The Rust version handles parsing errors with a global variable
VlqOverflow error (code 5) is missing.
parse_mappings and checks for errors:
In the Cheerp version, I defined the error codes in C++, and threw the error directly from there. There is no need for the global error variable, and within the C++ code errors are passed around as return values:
The full code of the
Mappings::create function is then:
Note that the
throw_error function does not throw a real C++ exception (they are not supported in Cheerp yet), and so it does not guarantee that destructors will be called. It should be used carefully and manual resource cleanup may be needed before calling it.
This is the declaration of the callback on the Rust side:
There is an utility function used to call the callback with a Rust Mapping object:
The dispatcher function keeps a stack of callbacks to avoid reentrancy issues:
Also, the actual callback needs to be defined. Here is the matching callback of the for loop example seen above:
[[cheerp::jsexported]] attribute on a class, or declaring a class in the special
client namespace), but this is an easy way of creating a simple object literal initialized with a few properties.
By just returning objects, we can avoid a convoluted control flow, with no performance penalties. In fact, it should be faster to directly populate an object in this way than using the callback trick.
I ran the same benchmarks as the original Rust version (look here for a detailed description of each benchmark and test source map).
All tests were performed on a MacBook Pro (Retina, 13-inch, Early 2015) with a 2.9GHz dual-core Intel Core i5 processor and 8GB of 1866MHz LPDDR3 onboard memory. The tests were performed in Chrome 67, Firefox 60.01, and Safari 11.0.3.
An example is the
set.first.breakpoint benchmark, the two are almost the same.
Another interesting difference is the implementation of the
Mappings::each_mapping method (benchmarks
iterate.already.parsed). It just iterates through all the mappings (the first also parse them first), calling a user provided callback for each one.
Compiling it in WebAssembly is inefficient, as the Mozilla blog post also points out:
iterate.already.parsed benchmark with the Scala.js source map, C++ compiled with Cheerp is 1.80x faster in Firefox, 1.99x faster in Chrome and 2.05x faster in Safari, compared to Rust.
In the Cheerp implementation the method is tagged with the
In the other benchmarks, the FFI overhead of the callbacks is indeed not noticeable.
Comparing code size fairly is not as simple as it seems. The Mozilla blog post compares the size of the entire library before and after the Rust rewrite of the BasicSourceMapConsumer component. The whole library though contains also the IndexedSourceMapConsumer and SourceMapGenerator components, and some utility code that is used by both.
BasicSourceMapConsumer, and implement it in C++, but the external API of the module is still there, mostly to ensure compliance with the test suite.
Compared to the Rust implementation, the total size in C++ with Cheerp is 0.69x.
In order to achieve the size shown here, the Rust .wasm binary is passed through the following external programs (the original size is above 110KB):
- wasm-gc: removes all the code that is not reachable from any exported function
- wasm-snip: replaces a function with an
unreachablestatement. Used to remove functions that the author knows will never be called
- wasm-opt: part of the binaryen project, runs some wasm-specific optimizations
Conclusions and future improvements
There are plans to improve Rust in these regards: the wasm-bindgen project seems promising, and hopefully some of the tooling necessary for the post-processing of the wasm file will be integrated in the compiler. It would also be nice to have a way to automatically generate the boilerplate for loading the WebAssembly module (which Cheerp has).
Cheerp will also keep improving: for example right now there is no native support for 64 bit integers. This means for example that
memcpy only copies 32 bits at a time, and this was a bottleneck in one of the benchmarks.