我们如何在Yandex.Maps中实现WebAssembly以及为什么要离开JavaScript

我叫Valery Shavel,来自Yandex.Maps向量引擎的开发团队。 最近,我们在引擎中实现了WebAssembly技术。 下面我将告诉您为什么选择它,我们得到了什么结果以及如何在项目中使用该技术。



在Yandex.Maps中,矢量地图由称为图块的块组成。 实际上,图块是地图的索引区域。 地图是矢量,因此每个图块都包含许多几何图元。 切片从服务器到客户端进行编码,并且在显示之前必须处理所有原语。 有时需要花费大量时间。 一个图块可以包含2000多条折线和多边形。



处理基元时,性能至关重要。 如果磁贴准备得不够快,那么用户将看到它太迟了,下一个磁贴将在队列中延迟。 为了加快处理速度,我们决定尝试使用相对较新的WebAssembly(Wasm)技术。

在地图中使用WebAssembly


现在,大多数原语的处理都在后台线程(Web Worker)中进行,该线程独立存在。 这样做是为了尽可能多地卸载主线程。 因此,当用于显示卡的代码嵌入在服务页面中时,该页面本身可能会增加很大的负担,因此制动次数会减少。 缺点是您需要正确配置主线程和Web Worker之间的消息传递。

后台线程中发生的处理部分主要包括两个步骤:

  1. 来自服务器的protobuf格式被解码
  2. 生成几何并将其写入缓冲区。

在第二步, 形成WebGL的顶点和索引缓冲区。 渲染时使用以下缓冲区。 顶点缓冲区为每个顶点包含其参数,这些参数对于确定其在特定时刻在屏幕上的位置是必需的。 索引缓冲区由三重索引组成。 每个三元组意味着应该在屏幕上显示一个三角形,该三角形的顶点位于顶点缓冲区的指定索引处。 因此,必须将图元划分为三角形,这也可能是一项耗时的任务:



显然,在第二步中,有很多内存操作和数学计算,因为要正确渲染图元,您需要有关图元每个顶点的大量信息:



我们对JavaScript代码的性能不满意。 这时,每个人都开始写有关WebAssembly的文章,该技术在不断发展和改进。 阅读研究报告后,我们建议Wasm可以加快我们的运营。 尽管我们并不完全确定:事实证明,在如此庞大的项目中很难找到有关Wasm使用情况的数据。

Wasm也比TypeScript差一些:

  1. 我们需要使用所需的功能初始化一个特殊的模块。 在此功能开始工作之前,这可能会导致延迟。
  2. 编译Wasm时,源代码的大小比TS中的大得多。
  3. 开发人员必须支持代码执行的替代版本,对于前端,该替代版本也以非典型语言编写。

尽管如此,尽管如此,我们仍有使用Wasm重写部分代码的风险。

Web装配体常规信息




Wasm是二进制格式; 您可以将不同的语言编译到其中,然后在浏览器中运行代码。 通常,这样的预编译代码比经典JavaScript更快。 WebAssembly格式的代码无法访问页面的DOM元素,通常,它用于在客户端上执行耗时的计算任务。

我们选择C ++作为编译语言,因为它非常方便快捷。

为了在WebAssembly中编译C ++,我们使用了emscripten 。 将其安装并添加到C ++项目后,为了获得该模块,您需要以某种方式编写主项目文件。 例如,它可能看起来像这样:

#include <emscripten/bind.h> #include <emscripten.h> #include <math.h> struct Point { double x; double y; }; double sqr(double x) { return x * x; } EMSCRIPTEN_BINDINGS(my_value_example) { emscripten::value_object<Point>("Point") .field("x", &Point::x) .field("y", &Point::y) ; emscripten::register_vector<Point>("vector<Point>"); emscripten::function("distance", emscripten::optional_override( [](Point point1, Point point2) { return sqrt(sqr(point1.x - point2.x) + sqr(point1.y - point2.y)) ; })); } 

接下来,我将描述如何在TypeScript项目中使用此代码。

在代码中,我们定义Point结构并将其映射到TypeScript中的Point接口,其中将有两个字段-x和y,它们对应于该结构的字段。

此外,如果要将标准向量容器从C ++返回到TypeScript,则需要将其注册为Point类型。 然后在TypeScript中,具有必要功能的接口将与之对应。

最后,代码显示了如何注册您的函数,以便使用相应的名称从TypeScript调用它。

使用emscripten编译文件,然后将结果模块添加到TypeScript项目中。 现在我们可以为任意emscripten模块编写通用的d.ts文件,其中预定义了有用的功能和类型:

 declare module "emscripten_module" { interface EmscriptenModule { readonly wasmMemory: WebAssembly.Memory; readonly HEAPU8: Uint8Array; readonly HEAPF64: Float64Array; locateFile: (path: string) => string; onRuntimeInitialized: () => void; _malloc: (size: size_t) => uintptr_t; _free: (addr: size_t) => uintptr_t; } export default EmscriptenModule; export type uintptr_t = number; export type size_t = number; } 

我们可以为模块编写d.ts文件:

 declare module "emscripten_point" { import EmscriptenModule, {uintptr_t, size_t} from 'emscripten_module'; interface NativeObject { delete: () => void; } interface Vector<T> extends NativeObject { get(index: number): T; size(): number; } interface Point { readonly x: number; readonly y: number; } interface PointModule extends EmscriptenModule { distance: (point1: Point, point2: Point) => number; } type PointModuleUninitialized = Partial<PointModule>; export default function createModuleApi(Module: Partial<PointModule>): PointModule; } 

现在我们可以编写一个函数来创建Promise进行模块初始化,并使用它:

 import EmscriptenModule from 'emscripten_module'; import createPointModuleApi, {PointModule} from 'emscripten_point'; import * as pointModule from 'emscripten_point.wasm'; /** * Promisifies initialization of emscripten module. * * @param moduleUrl URL to wasm file, it could be encoded data URL. * @param moduleInitializer Escripten module factory, * see https://emscripten.org/docs/compiling/WebAssembly.html#compiler-output. */ export default function initEmscriptenModule<ModuleT extends EmscriptenModule>( moduleUrl: string, moduleInitializer: (module: Partial<EmscriptenModule>) => ModuleT ): Promise<ModuleT> { return new Promise((resolve) => { const module = moduleInitializer({ locateFile: () => moduleUrl, onRuntimeInitialized: function (): void { // module itself is thenable, to prevent infinite promise resolution delete (<any>module).then; resolve(module); } }); }); } const initialization = initEmscriptenModule( 'data:application/wasm;base64,' + pointModule, createPointModuleApi ); 

现在,对于这个Promise,我们将获取模块以及distance函数。

不幸的是,您无法在浏览器中逐行调试Wasm代码。 因此,有必要像正常的C ++一样编写测试并在其上运行代码,这样您将有机会进行方便的调试。 但是,即使在浏览器中,您也可以访问标准cout流,该流会将所有内容输出到浏览器控制台。

可通过此链接获得本文中的项目示例,您可以在其中查看webpack.config和CMakeLists的设置。

结果


因此,我们重写了部分代码,并启动了一个考虑分析多边形和多边形的实验。 该图显示了Wasm和JavaScript的一个图块的中间结果:



结果,我们获得了每个指标的相对系数:



从纯原始解析时间和切片解码时间可以看到,Wasm的速度快了四倍以上。 如果看一下总解析时间,则差异也很明显,但仍然要少一些。 这是由于将数据传输到Wasm和收集结果的成本。 还值得注意的是,在前几块中,总体增益非常高(前十块中-超过五倍)。 但是,相对系数减小到大约三。

结果,所有这些共同帮助将后台线程中一个图块的处理时间减少了20–25%。 当然,这种区别并不像以前那样大,但是您需要了解解析虚线和多边形与所有平铺处理相差甚远。

如果我们谈论初始化模块的必要性,那么大约有一半的用户在解析第一个图块之前会有延迟。 中值延迟为188毫秒。 延迟仅发生在第一个图块之前,并且解析的获胜是恒定的,因此您可以在开始时稍稍停顿一下,而不会将其视为严重的问题。

另一个不利方面是源代码文件的大小。 不带Wasm的整个矢量地图引擎的Gzip压缩最小代码-85 KB,带Wasm的191 KB。 同时,在Wasm中仅实现了对虚线和矩形的解析,而并非所有可能位于图块中的图元。 此外,要解码protobuf,我必须选择纯C语言的库实现,而C ++实现的大小甚至更大。 在编译C ++时,可以通过使用-Oz编译器标志而不是-O3来稍微减小这种差异,但是仍然很明显。 另外,通过这种替换,我们损失了生产率。

尽管如此,信号源的大小并没有显着影响卡的初始化速度。 仅在速度较慢的设备上,瓦斯更糟,差异小于2%。 但是向用户展示了在使用Wasm的实现中初始可见的矢量图块集,其速度比使用JS实现的更快。 这是由于在未处理JS的情况下,第一个处理后的图块具有更大的增益。

因此,如果您对JavaScript代码的性能不满意,现在就可以选择Wasm。 同时,您获得的性能提升可能会比我们少,或者根本无法获得。 这是由于有时JavaScript本身可以非常快速地运行,并且在Wasm中,您需要传输数据并收集结果。

我们的地图现在运行常规JavaScript。 这是由于以下事实:在一般背景下,解析的增益不是很大,并且由于在Wasm中仅实现了某些类型的基元。 如果这种情况发生变化,也许我们将使用Wasm。 反对此问题的另一个有力论据是组装和调试的复杂性:只有当性能提高值得时,用两种语言支持项目才有意义。

Source: https://habr.com/ru/post/zh-CN475382/


All Articles