PartialExecuter: Reducing WebAssembly size by exploring all executions in LLVM (leaningtech.com)
As part of the latest release of Cheerp, our C++ to WebAssembly/JavaScript compiler, we have introduced a powerful new LLVM optimization that aggressively reduce WebAssembly output size at compile time.
We have named this optimization 'PartialExecuter', the key idea behind it being taking advantage of known function parameters to find inner code blocks that cannot ever be possibly executed.
Such blocks can then be completely removed from the compiled output, significantly reducing its size.
What makes this pass more powerful than typical Dead Code Elimination is the ability of reasoning over all the possible executions that the code can take, while being robust to memory stores and side-effects. Moreover, PartialExecuter can even reason over loads as far as they refer to read-only memory. This latter capability is especially useful to drop code from complex functions whose behavior depend on input strings (i.e. printf).
We think this work may be of interest for the HN community, and we welcome feedback and questions.
In-depth blog post: https://leaningtech.com/reducing-webassembly-size-by-explori...
110 comments
[ 5.4 ms ] story [ 183 ms ] threadSome literature:
https://dl.acm.org/doi/10.1145/5956.5957
https://ndmitchell.com/downloads/paper-rethinking_supercompi...
Wasm + SC would be a killer deal.
edit: actually I was thinking of superoptimizers, I guess it's a different concept.
[1] https://arxiv.org/abs/1211.0557
This paper from the same project was also cool: https://raw.githubusercontent.com/StanfordPL/stoke/develop/d...
Quote from abstract: " For many applications, the best possible code is conditionally correct: the optimized kernel is equal to the code that it replaces only under certain preconditions on the kernel’s inputs. The main technical challenge in producing conditionally correct opti- mizations is in obtaining non-trivial and useful conditions and proving conditional equivalence formally in the pres- ence of loops. We combine abstract interpretation, decision procedures, and testing to yield a verification strategy that can address both of these problems. This approach yields a superoptimizer for x86 that in our experiments produces binaries that are often multiple times faster than those pro- duced by production compilers"
In general the whole concept behind Cheerp (C++ to WebAssembly + JavaScript compiler) is doing as much as possible at the LLVM IR level, JavaScript concept included, since it's easier and more powerful to do code transformation there.
There are some general gains to be had with this optimization, and probably the same ideas were already around for a while, but putting together in this way was helped by thinking about WebAssembly specific constraints.
For example, suppose you have a library that is configured with some options struct that contains a bunch of flags/settings. The behavior of the library changes based on these flags. The compiler will see a bunch of branches like "if (opts.foo) { ... }" and will generate code for all of these branches of. Now let's say you statically compile this library, and in practice in your code you only ever has one set of options enabled. In principle the compiler could figure out which branches can be eliminated based on the single instantiation of the opts struct in your code, and eliminate dead branches. But in practice neither Clang nor GCC will actually do this kind of DCE even at -O3 because it's simply not worth it. By the way, this kind of example is exactly the kind of DCE that the blog post is talking about and could be removed by the new DCE pass implemented by the author.
How big of an impact would this kind of DCE make on performance? Well the compiled binary size will be a bit smaller, which is kind of nice. But in practice this will have almost no impact on performance. Loading and mapping an ELF file is practically instantaneous even on huge executables. The branch predictor will predict all of the options branches that are hit repeatedly at close to 100%. Eliminating the branch entirely is in theory better than having a branch with a 100% hit rate, but hard to demonstrate in real world benchmarks for all but the most critical code paths. If there are large pieces of code in the executable that are unused they'll be mapped but won't even be page faulted during program execution. There are some kind of hand wavy arguments you can make about the extra code wasting space in the icache but again it would probably be difficult to actually demonstrate the impact even in microbenchmarks.
This isn't to say that there are no benefits to more expensive DCE passes. But they're generally extremely meager, so it's not worth increasing compile times for most applications. Wasm is an exception because compiled assets need to be transferred over the network and apparently it takes longer to load wasm code than it does to map an ELF executable. It's also worth noting that Clang and GCC do a lot of other types of simpler DCE, and these simpler DCE passes can be critical for performance, so I'm not trying to suggest that DCE entirely is worthless; just that the type of DCE presented here is less useful for traditional compilation.
From my understanding, the main issue is mostly that it is very complex / risky to do these optimizations, and the reward is deemed low for the JVM.
The main JVM target I can think about where it could probably be profitable is the Android subsystem, but why would you care to reduce OS / Apps size when the fact that phones get bloated is very good for your business since you also sell phones, so it's quite useful to push the user to renew its phone on a regular basis.
What kind of compilation performance do you see for how long this pass takes?
Do you apply this to all functions, or to all functions with certain properties, or to functions tagged some particular way?
Or in straight C++ for that matter.
Though possibly the optimisation passes relies on specific properties of the target which don't exist for non-wasm? I didn't see anything in the writeup but that doesn't mean they don't exist.
Target-wise, all information is kept encoded in the IR, so that part is easier (the only strange thing that might happens is bumping some alignment to 8, but I expect every target to be able to handle the prescribed IR alignemtn)
I also believe it would be cool to upstream this, we will have to sit down and do some planning.
Compilation time could improve (I was actually working on this today), but it's already in line with other optimizations, taking < 10% of the time spent doing optimizations on a big codebase we use as benchmark.
Currently it's applied to all functions, since runtime it's anyhow somehow linear in the number of Instructions a Function has, but possibly in more costly versions of this (that we have on paper but yet to implement) some logic to filter functions in advance could be used.
Same!
Also a JVM to WASM transpiler.
There's a fantastic Meetup presentation given by one of them where they show running a C++ multiplayer game with both client AND server running in a browser, using WebRTC as a networking polyfill. Really mindblowing:
https://youtu.be/7JUs4c99-mo?t=167
Direct link to our latest demo in case anybody would like to see this tech in action: https://webvm.io
When it no longer requires an OS to run.
The absence of friction of just executing something quickly, and being able to share it with a single text string, is what makes this appealing to people, I reckon. I dunno why people seemingly get offended by this.
CheerpX is a Virtual Machine environment, it JIT compiles Wasm code from x86 binaries and it is fully sandboxed by the browser. It _cannot_ access your system even if it tried.
Likening it to Java applets or Flash is deceptive -- yes, you can still hack them and exploit vulnerabilities. But the scope of what you can do with such vulnerabilities is wildly, dramatically different. Even when sandboxed, Flash has an enormously wider attack surface to play with. WebAssembly has barely anything.
It's like the difference between patching a leak in your roof with a sponge vs tar paper. In theory, water could find a path through the tar paper.
But wasm being used to control the brightness pattern of a blinky light on the console of the machine that controls nuclear launches? That is fundamentally safer than native sandboxed C++ code being used to control the blinky light.
I'm guessing we don't actually disagree on anything here -- I also feel like people are making unwarranted assumptions that wasm gives you more safety than it actually does. (It reminds me of another incorrect assumption that seems to get made a lot, that running unsafe code in a VM means you don't need to worry that it'll escape to the host or other VMs on that host.)
How so? There are established techniques for developing safety-critical software, and they don't tend to rely on the assumption that a sophisticated JIT compiler is free of bugs.
Or do you mean untrusted code for controlling the blinky light?
So yes, untrusted code, but "untrusted" is ambiguous. There's Web-style untrusted code, where the code could be malicious. Then there's unverified code, where the author is presumed to have good intentions but you can't trust the correctness. WebAssembly is useful for both as long as your outputs are properly constrained (as in, it's the blinky light case, not the nuclear launch case.)
The distinctions aren't even 100%. Perhaps your adversary, shortly before launching their nukes, triggers the malware in your blinky light controller to blink at the exact frequency that you know will send the operator into a seizure, preventing them from triggering the counter-launch. You know, because you surreptitiously tested all the people a decade ago when they were applying for the nuclear launch controlling position, and did character assassination on all those who were not susceptible to blinky light seizures... ;-)
Under what circumstances would it be appropriate to use WASM in a safety-critical system?
To my knowledge there's no WASM implementation intended or approved for use in safety-critical systems.
> WebAssembly is useful for both
I don't think so. I can't see a reason to let lives hang in the balance of a WASM implementation being free of bugs. Use an approved C/C++/Ada compiler and be done with it.
You're right that WASM is pretty robust against malicious code that aims to escape the sandbox. Sames goes for JavaScript. Web browsers are of course very motivated to focus on the security of their language engines.
> Perhaps your adversary, shortly before launching their nukes, triggers the malware in your blinky light controller to blink at the exact frequency that you know will send the operator into a seizure
I don't see WASM having a place in the development of ultra-low-defect software. If you're serious about that kind of thing you use a language and framework like SPARK Ada.
I agree though that WASM may be useful in mitigating the consequences of undefined behaviour in buggy C/C++ code, in some circumstances. In this regard it's nothing new. C/C++ can be compiled to just about anything, including other sandbox-oriented languages like JavaScript. WASM just improves the performance. Linux has been 'ported' to JavaScript at least twice. [0][1]
[0] https://bellard.org/jslinux/tech.html
[1] https://medium.com/@retrage/lkl-js-running-linux-kernel-on-j...
That’s the intent, at least. I’m sure we’ll get to see exploits that manage to do exactly this.
But rowhammer is still a thing.
There's a whole stack of abstractions, that all _may be_ vulnerable. I'm sure CheerpX is very good, but there's no way to _know_ that all the dependencies from the toolchain used to build all the way down to the running environment is actually bug free.
As a first line of investigation, I'd suspect cheerpX, just because so many eyes look at browser sandboxes. _shrug_ your milage may vary.
I don't know how to prove the absence of a thing. I can only prove existence.
I was trying to highlight that every layer of abstraction has vulnerabilities all the way down to the hardware level.
I'm perfectly willing to accept that CheerpX has no known vulnerabilities.
You can just as easily compile heartbleed into WebAssembly and call it a day.
Yes it is sandboxed, but hardly any different than running an OS process with sandboxing lock down regarding which OS syscalls are allowed.
Think it this way, just because an OS container cannot exploit its host (minus hypervisor bugs), that doesn't mean that what is inside isn't subject to security flaws.
The syscall surface is zero by default. WASI of course expands that by a lot, but sane WASI implementations require restrictive whitelisting of available resources like specific files or directories that can be accessed. (wasmtime-wasi does this well,for example). The whole wasi spec is aiming for capability based security, including things like only providing access to pre-opened sockets.
All production deployments of server side Wasm I have seen have very tight scoping.
Sandboxing on Linux, in contrast, is a complicated mess, with a myriad of partial and leaky solutions (SELinux, Apparmor, Docker, Snap, Flatpak, ...) .
Having a good cross platform toolkit is completely out of reach.
The only OS that has comparable sandboxing that is practical is Fuchsia.
There is no magic in WASM, only when the only thing it does is warming up a CPU.
Also, how does wasm relate to traditional sandboxes that “just” hijack system calls? Is there a difference?
You can expose the required functionality via JavaScript bridges.
The current Rust (wasm-bindgen) and C/C++ (emscripten) tooling exposes the the whole browser API by default though, which isn't ideal.
The discussion also is breaking into an unrelated branch about security within WebAssembly, so it’s worth pointing out that the security model of WebAssembly is that it should prevent the host machine from being attacked by code running in the sandbox, not that it makes code running in the sandbox any more secure. It doesn’t make code in the sandbox not susceptible to most classes of errors, it just isolates those errors to runtime errors and not exploitable bugs that can escalate privilege.
ActiveX, of course, did not deal with either security model, instead shrugging it off and just assuming that code signing would be a good enough deterrent to malware.
(Of course, the problem with this is manyfold, including the fact that just because a module is trustworthy and not malicious, does not mean it should be exposed to all users, and the fact that even if you trust the company behind the module, that doesn’t make the module any more guaranteed to be secure.)
It gives a new meaning to the slogan "write once, run everywhere".
2. The original (real) power issues with the original/early AVX-512 desktop chips have basically gone away. It's not far off the timescale that you'd looking back to be bashing AMD for bulldozer.
I'd measure it for you on my machine but I don't have an accurate power-o-meter for my computer.
In a JIT language you might be able to save time by running the JIT less often though.
Ok, now you have my attention. Been waiting for that possibility for a while!
Normally you run a program at runtime (obviously) at which point you have the full environment and inputs, so you can run the program fully.
However... you can also kind of "run" a program at compile time. You can do this using some known and some unknown values in the source code, so you can "partially" run it.
https://en.wikipedia.org/wiki/Partial_evaluation
Or you can use abstract/pretend values instead of real values, so you can "abstractly" interpret it.
https://en.wikipedia.org/wiki/Abstract_interpretation
Running at compile time lets you learn things about the program at compile time, allowing advanced optimisations and error checking.
You might realise that some code can never execute, so you can remove it (as in the project above). Or you can learn that some code is incorrect and give an error at compile time...
My code is pretty messy, but I take the same exact approach of taking known function parameters, interpreting the instructions, and removing any condition and the instructions which built its arguments if it evaluates to a constant value. Even called it partial execution as well :p
I am wondering if this technique could be hybridized with abstract interpretation or partial evaluation, which are known techniques in compilers. In essence, this would become a type of concolic execution.
On the second part, I have to do some thinking and coming back, thanks!
[1]: https://scala-native.readthedocs.io/en/latest/blog/interflow...
I'm wondering, what exactly are the differences of this approach from "classical" partial evaluation? This seems to be a special case of it, unless I missed something.
For the first image to render correctly, please change the theme to light mode
> You might have heard of dead-code elimination, an LLVM optimization pass that removes code proven as unreachable. I was actually interested in less-obvious situations. In particular blocks that are reachable on the control flow graph, but not when consider wider execution invariants.
I've got a trivial example here: https://gcc.godbolt.org/z/7EnPG5WM6 noinline is added to foo to demonstrate Clang is actually changing the number of arguments foo takes, and its inlined the arguments from bar and baz.
> Can we use information on the call-sites and the corresponding format strings to prove that some code paths, for example formatting for floating point numbers, are actually never taken?
Seems to already have that effect in Clang. Using Emscripten 3.1.3, compiling 2 different main's with just
And For the single int arg, the top 3 symbols by size are And for the one that added a float arg, So when a float argument wasn't passed into printf, it did not include fmt_fp, a delegate that handles floating point for printfOne issue that can complicate this analysis is linking multiple libraries that reference standard library (or functions in other libraries). If you're linking together object code, without more IR context, LLVM is going to have to be more conservative. So I think if you link in a WASM object code file that references printf, it won't be able to perform all the IPO that will allow it to trim the CFG.
Here by playing HumanCompiler it should be possible to prove that the if condition never evaluates to true, so removing the if is safe.
This is an example of optimization that PartialExecuter is able to do. Note that some combinations of other optimizations might also be potentially able to get to this result (say adding a tag "is always power of 2", but doing this in a general way it's what PartialExecuter does well).
Somehow similarly, clang might be instructed to enable a check to avoid including printf_float and somehow detect it and exclude it (this is what happens here), but this is hardly generalizable.
I would naively expect that for most functions you know too little about their arguments or the state in which they will be executed to be able to tell that certain branches will definitely not be taken. Especially since any function that has some mildly interesting side-effects (like loading and storing from a pointer or a class/struct field, or even _calling into any function that does that_) would seem to foil the analysis and turn large chunks of code into "unknown reachability".
Rephrasing, I would expect the set of basic blocks that can be removed just by essentially repeated constant propagation of statically-known function arguments to be pretty small. But I would be happy to be proved wrong if that intuition is not right.
Printf is somewhat of an exception, since the format strings are usually known at compile time, meaning that a lot of the control flow can indeed be deduced by compile-time evaluation, but for most functions I would not expect that.