首发于寒雁Talk
创造一个编程语言最好的时间是10年前,其次是现在。
从Emscripten到asm.js再到WebAssembly,从一个业余项目到W3C标准,差不多是整整10年。
Emscripten
2010年,创业失败的Alon Zakai博士加入Firefox的开发商Mozilla。虽然放弃了创业,不过他还在继续倒腾创业时用C++开发的游戏。Alon想把C++游戏运行在浏览器中,但是又不想手动用JavaScript重写,于是他希望把C++编译为JavaScript。
这个想法有点不靠谱,即便10年后的现在听起来都很难让人相信,俗话说"talk is cheap, show me the code",Alon开始利用晚上和周末的时间开发Emscripten。(论晚上和周末不加班的重要性…)
JavaScript也属于C/C++语言家族,因此两者的基本语法非常相似,例如add函数,两者的差别仅在于函数的定义方式以及是否有类型:
// C++版本add函数
int add(int a, int b) {
return a + b;
}
// JavaScript版本add函数
function add(a, b) {
return a + b;
}
看起来,把C++编译为JavaScript似乎也不难。不过,实际上坑还是挺多的,举个最简单的例子,整数除法:
// C++版本divide函数
int divde(int a, int b) {
return a / b;
}
// JavaScript版本divide函数
function divide(a, b) {
return Math.floor(a / b);
}
C++中整数除法的结果为整数,因此JavaScript必须使用Math.floor来取整。
2011年,Emscripten正式发布。Emscripten利用LLVM将C++编译为字节码,然后再将字节码编译为JavaScript,成功地将C++游戏运行在浏览器中,小伙伴们看了Alon的演示之后都惊呆了,这样也可以?
![img](data:image/svg+xml;utf8,)
Alon Zakai使用Emscripten做了一些很有意思的Demo,即使现在听起来还是挺神奇的:
- 把Python编译为JavaScript,在浏览器中运行Python脚本
- 把SQLite编译为JavaScript,在浏览器中运行数据库,执行CURD
世界上有2种程序员,一种是写编译器的,剩下的都属于第二种,前者创造编程语言,后者使用编程语言。Alon显然属于前者,而我们绝大部分人都属于后者:(
JavaScript的作者Brendan Eich当时是Mozilla的CTO,果断让Alon全职开发Emscripten。让一个业余的开源项目成为一个正式的公司项目,这事从短期来看对商业公司没啥好处,不过长期来看,Firefox正是通过Emscripten巩固了它在浏览器中的地位,避免被Chrome边缘化。
Brendan Eich一直以来的梦想是增加Web的能力,提升Web的性能,从而让Web可以运行任何应用,Alon正在实现他的梦想,于是大力支持。
![img](data:image/svg+xml;utf8,)
asm.js
自从V8引擎加入浏览器的性能战争以来,通过引入JIT (just-in-time)编译技术以及各种优化技巧,JavaScript的性能奇迹般地提升了1个数量级,这是JS生态系统走向繁荣的根本原因之一。但是,JavaScript是一门动态类型语言,这就注定了它的性能是存在先天缺陷的。简单地说,JavaScript代码中的变量类型是不确定的,这就意味着JS引擎在生成机器代码时,必须动态地判断变量类型,这些判断逻辑会带来大量的冗余代码,理论上必然存在性能损耗。
因此,仅仅将C++游戏编译为JavaScript还不够,虽然能在浏览器中运行了,但是性能不太好。当使用Emscripten将游戏引擎Unity编译为JS然后在浏览器中运行时,性能挺糟糕的。
Mozilla的JS引擎工程师Luke Wagner发现,对于那些能够隐式推断类型的JavaScript代码,JS引擎的执行性能会很好,也许可以利用这一点来提高性能。于是他告诉Alon,可以让Emscripten生成特定的JS代码,性能就可以提升了,而且Luke还可以通过优化JS引擎来进一步优化特定的JS代码。(论掌握JS引擎这类底层技术的重要性,遇到真正的技术难点,上层应用再这么倒腾也没法从根本上解决问题……)
![img](data:image/svg+xml;utf8,)
例如以下代码,通过or zero, 即 | 0
, JS引擎可以推断出add函数的参数和返回值都是32位整数,这样可以直接编译为整数加法:
function add(x, y){
a = x | 0; // 参数x为整数
b = y | 0; // 参数y为整数
return a + b | 0; // add函数的返回值也是整数
}
Bitwise OR (|)运算符可以把浮点数转换为32位整数:
var x = 3.5 | 0;
console.log(x) // 3
因此,如果Emscripten可以将C++编译为假装带了类型的JS代码,则可以大幅提升性能。把所谓"假装带了类型的JS代码"进一步规范化,就是asm.js,它是JavaScript代码的子集。asm.js作为Emscripten的编译结果,并不是让开发者直接手写。
amj.js究竟是个啥样子呢?不妨试试使用Emscripten将C++代码编译为asm.js。不过最新版本的Emscripten只支持编译为WebAssembly而不支持编译为asm.js了,因此需要安装Emscripten 1.38.0:
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install sdk-fastcomp-1.38.0-64bit
./emsdk activate sdk-fastcomp-1.38.0-64bit
source ./emsdk_env.sh
emcc -v
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 1.38.0
clang version 5.0.0 (emscripten 1.38.0 : 1.38.0)
Target: x86_64-apple-darwin20.5.0
Thread model: posix
InstalledDir: /Users/kiwenlau/Desktop/emsdk/fastcomp-clang/e1.38.0_64bit
INFO:root:(Emscripten: Running sanity checks)
add.cpp为:
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
double add(double a, double b) {
return a + b;
}
使用Emscripten将C++编译为asm.js:
emcc add.cpp -O2 -profiling -s ONLY_MY_CODE=1 -g2 --separate-asm -o add.js
生成的asm.js代码add.asm.js为:
function __Z3adddd(d1, d2) {
d1 = +d1;
d2 = +d2;
return +(d1 + d2);
}
对于浮点数,asm.js在变量前面添加一个+号就行了,因为Unary plus (+)运算符可以将各种类型变量转换为number:
console.log(+true); // 1
2013年,asm.js开发得差不多之后,他们和游戏公司Epic合作,花了4天时间,成功将100万行C++代码的Unreal游戏引擎编译为asm.js,移植到浏览器中,流畅地运行了《史诗城堡》。
这个视频都已经8年了,那个时候王者荣耀和和平精英还没有诞生,不过依然还是挺惊艳的,这是游戏的魅力,也是asm.js的功劳。
为了进一步优化asm.js性能,还需要JS引擎做一些优化,这事对于编译器大神来说并不是特别难,Luke一个人花了几个月时间就搞定了。通过不断的优化,asm.js的性能达到了原生C语言代码的50%甚至70%。比如,由于asm.js是隐含类型信息的,因此JS引擎可以对其进行Ahead Of Time(AOT) 编译,以提高性能。
之后,其他浏览器比如Edge、Chrome、Safari相继优化其JS引擎执行asm.js的性能,Facebook、Adobe、Wikipedia等公司也在产品中广泛地应用asm.js。浏览器的竞争对手们从asm.js开始合作,也奠定了后来一起开发WebAssembly的基础。
WebAssembly
故事讲到asm.js,并没有结束。
Asm.js只是JavaScript子集,将JavaScript作为编译目标是一个非常hack的方式,也会遇到这样或者那样的问题。
大名鼎鼎的ES6中,厉害的特性很多,其中有2个特性相信大家没怎么听过:Math.imul(x, y)和Math.fround(x),前者用于32位整数的乘法,后者用于将64位双精度浮点数转换为32位单精度浮点数。JavaScript的Number本身就是64位浮点数,并没有所谓的32位、64位、单精度、双精度之分,新增Math.imul(x, y)和Math.fround(x)这2个奇怪的API,其实就是为了更好地支持asm.js。为了支持asm.js,去改变JavaScript本身,这事有点尴尬。
虽然asm.js只是JS的子集,可以运行在任意浏览器中,但是为了获得更好地性能,还是需要对ECMAScript本身进行拓展,这就不太好操作了,有谁希望在JavaScript为了支持asm.js添加很多奇奇怪怪的特性吗?
JavaScript因为没有类型而备受诟病,但是这也是它简单易用的原因之一,易用性和性能很难在同一个编程语言中同时做到。否则,TypeScript怎么会被大部分程序员(有时我也会)写成了AnyScript呢?如果为了支持asm.js,给JavaScript添加很多类型及其相关的特性,现阶段可能并不能被社区所接受。
要不要给JavaScript添加类型是个争议很大的话题,其实这个到还好,加不加无所谓,反正用不用各有偏好。但是,如果asm.js要实现更复杂的特性,比如更高效的内存管理,那就难了…
asm.js还有一个容易理解的问题,它是js文件,浏览器执行时还是得去解析和编译,这会导致启动时间变慢。如果游戏执行变快了,但是启动变慢了,也是会被用户吐槽的。
总之,使用asm.js作为Emscripten的编译目标,有它的局限性。
Asm.js虽然不算完美,但是它证明了一件事情,将类型化的语言编译为一种目标语言,然后运行到浏览器中,以提升一些对性能要求比较高的Web应用的性能,在技术上是完全可行的,并且也很有市场。
既然如此,为啥不为Web创造一个新的编程语言,让它作为编译目标?既然都用到了Ahead Of Time(AOT) 编译,为啥不直接编译为Intermediate Representation(IR), 直接运行IR就好了,那还费什么劲去把IR再去转换为JS代码,而且还是奇奇怪怪的asm.js代码呢?
其实我在《V8引擎是如何工作的?》也表达过类似的想法,那时候我还没有去认真了解过WebAssembly,其实人家早就开始在做了,而我只是随口说一说。
制定一个Intermediate Representation(IR)规范,在浏览器中增加一个对应的虚拟机,这样既摆脱了JavaScript的束缚,也可以优化编译速度和编译结果的体积。从技术上来说,这对于倒腾了多年的编译器大神们并不难。
![img](data:image/svg+xml;utf8,)
这事难点在于各个浏览器产商愿意合作吗?Chrome是浏览器的带头大哥,Edge是浏览器的落魄贵族,Safari来自喜欢独树一帜的Apple,它们凭什么追随Firefox?这事可以类比一下国内五花八门的小程序平台,井水不犯河水,每一个小程序开发者得安装N个各个厂家的IDE!
![img](data:image/svg+xml;utf8,)
这事能不能做成,主要取决于Chrome的态度,因为它是老大,是掌握话语权的一方。Chrome的江湖地位无可撼动,在商业上没什么好担心的。另外,正如我在《Chrome是如何成功的?》的博客中论述过,Chrome的使命就是推动Web技术的发展,一个更快、更好的浏览器,可以促进Web技术的发展,网页会越来越多,越来越好,用户花在Web上的时间越来越多,长期来看这对Google的核心搜索业务是至关重要的。WebAssembly可以让浏览器变得更快,应用场景变得更多,这符合Google的公司战略以及Chrome的产品战略。
2015年,4个浏览器产商(Firefox, Chrome, Safari, Edge)达成合作,4个竞争者宣布联手开发WebAssembly。
2017年,Firefox、Chrome、Safari、Edge相继支持WebAssembly。并且在在程序语言领域的顶级会议PLDI发表了Bringing the Web up to Speed with WebAssembly论文。
2019年,W3C发布WebAssembly正式标准,WebAssembly成为继HTML、CSS、JavaScript之后第4种Web语言。可能有的人要抬杠HTML、CSS不是编程语言,这话可是W3C说的啊。
WebAssembly究竟是个啥样子呢?不妨试试使用最新版本的Emscripten将C++代码编译为WebAssembly。
安装最新的Emscripten 2.0.24:
git clone https://github.com/emscripten-core/emsdk.git
./emsdk install 2.0.24
./emsdk activate 2.0.24
source ./emsdk_env.sh
emcc -v
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 2.0.24 (416685fb964c14cde4be3e8a45ad26d75bac3e33)
clang version 13.0.0 (https://github.com/llvm/llvm-project 91f147792e815d401ae408989992f3c1530cc18a)
Target: wasm32-unknown-emscripten
Thread model: posix
InstalledDir: /Users/kiwenlau/Desktop/emsdk/upstream/bin
使用Emscripten将C++编译为asm.js,生成的WebAssembly代码a.out.wasm有200多行,但是不难发现其中一段就是add函数:
(func $_Z3adddd (type $t4) (param $p0 f64) (param $p1 f64) (result f64)
(local $l2 i32) (local $l3 i32) (local $l4 i32) (local $l5 f64) (local $l6 f64) (local $l7 f64)
global.get $g0
local.set $l2
i32.const 16
local.set $l3
local.get $l2
local.get $l3
i32.sub
local.set $l4
local.get $l4
local.get $p0
f64.store offset=8
local.get $l4
local.get $p1
f64.store
local.get $l4
f64.load offset=8
local.set $l5
local.get $l4
f64.load
local.set $l6
local.get $l5
local.get $l6
f64.add
local.set $l7
local.get $l7
return)
我们不需要了解这段代码每一行的含义,只需要看懂第一行就够了,$_Z3adddd函数有2个参数$p0和$p0,它们的类型都是f64,即双精度浮点数,而它的返回值resut也是双精度浮点数。
从结果来看,根据**Bringing the Web up to Speed with WebAssembly**这篇论文,WebAssembly比asm.js平均快了33.7%,且文件体积平均小了62.5%,另外WebAssembly的编译速度、启动速度、内存使用都表现非常好。
对于绝大多数前端开发者来说,WebAssembly还是一个非常陌生的技术,但是其实它已经有了一些非常让人惊艳的应用。
2018年,AutoCAD被编译为WebAssembly,完整移植到浏览器中( https://web.autocad.com ),既保证了桌面应用和Web应用的一致性,极大地降低了开发成本,又提高了性能。AutoCAD是建筑和机械专业必须掌握的软件,相当于程序员的代码编辑器。我大一的时候学的是机械,所以也用AutoCAD画过图,功能很强大,可以想见其代码会有多复杂。后来我发现自己对编程更感兴趣,于是换到了计算机专业。
![img](data:image/svg+xml;utf8,)
2019年,Google Earth被编译为WebAssembly,移植到浏览器中( https://earth.google.com ),实现了跨浏览器支持。Google Earth在2017年就可以在Chrome使用了,不过当时使用的是与WebAssembly类似的Native Client技术,只是Native Client存在一些技术问题,且只有Chrome支持,去年已经被deprecated了。
![img](data:image/svg+xml;utf8,)
2020年,Google Meet借助WebAssembly实现了视频的实时背景虚化以及背景替代,这样可以让参加线上会议的人把注意力集中在人而不是他所在的环境。对视频数据进行实时处理的话,性能的要求非常高,因此所有CPU密集型的计算都是通过C++实现的,然后编译为了WebAssembly,并且利用了最新的WebAssembly SIMD特性。
结论
WebAssembly是一个革命性的技术,它给Web带来了更多的可能性,对于CPU密集型的应用,比如图片、音频、视频、直播、机器学习、AR、VR、游戏、在线会议、在线文档、在线IDE、在线游戏等等,WebAssembly既可以帮助我们突破性能瓶颈,也可以让我们得以利用其他语言的代码库。可以预见,WebAssembly的应用还刚刚开始,有潜在需求的团队和业务,是时候开始深入研究一下了。
这篇博客讲的更多的是技术历史,而不是技术细节。一方面,想要彻底理解一个技术,第一件事情是理解它的历史。另一方面,我也没在生产环境中实际使用过WebAssembly,只跑过Hello, World,所以还不敢乱写。等我对WebAssembly的技术细节更加熟悉之后,可以再写一篇博客单独介绍。
本来我是打算把这篇博客也纳入《了不起的Chrome浏览器》系列,不过,当我熟悉了WebAssembly的发展历史之后,显而易见,主导WebAssembly技术发展的是Mozilla Firefox而不是Google Chrome。写作要尊重客观事实,每一句话都要经得起推敲,何况是标题。
从Emscripten到Asm.js再到WebAssembly,天才的开发者们探索了10年时间,才找到最佳的解决方案。从后视镜来看,一切都顺其自然,其实并不简单。致敬这些疯狂到相信自己真的可以改变世界的天才们!Only the Paranoid Survive!
Chrome确实非常了不起,它推动了很多Web技术的飞速进步,但是如果只有Chrome浏览器一家独大,也是一件挺可怕的事情。所幸,还有Firefox这样值得尊敬的竞争对手,才得以让WebAssembly这样革命性的技术发展起来。
参考资料
- JavaScript深入浅出第4课:V8引擎是如何工作的?
- Chrome是如何成功的?
- 了不起的Chrome浏览器(3):Chrome 91支持WebAssembly SIMD,加速Web在AI等领域的应用
- Bringing the Web up to Speed with WebAssembly
- Emscripten: An LLVM-to-JavaScript Compiler
- Alon Zakai:Emscripten
- Alon Zakai: The History of WebAssembly
- Luke Wagner: WebAssembly Will Finally Let You Run High-Performance Applications in Your Browser
- Unreal Engine 3 in Firefox with asm.js
- WebAssembly: Under the hood with Mozilla
- WebAssembly: A game changer for the Web
- World Wide Web Consortium (W3C) brings a new language to the Web as WebAssembly becomes a W3C Recommendation
- Background Features in Google Meet, Powered by Web ML
- How we’re bringing Google Earth to the web
- asm.js: closing the gap between JavaScript and native
- A cartoon intro to WebAssembly
- Introducing the WebAssembly backend for TensorFlow.js
- Supercharging the TensorFlow.js WebAssembly backend with SIMD and multi-threading
- From ASM.JS to WebAssembly
- Celebrating 10 years of V8
编辑于 07-01