使用Emscripten编译Eigen算法模块为WebAssembly

1,864 阅读5分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

问题场景

在Web3D开发当中,我们面临这样一些问题:团队中已经有成熟的算法模块,通常使用C/C++编写,我们需要实现同样的功能,如何快速实现功能,并且保持功能的一致性和长期的可维护性

技术选型

  1. 一方面我们可以使用前端Js的各类数学运算库如:math.glgpu.js,对已有的算法模块进行Js版本的重新实现
  2. 使用WebAssembly,通过辅助的编译工具如emscripten将已有的算法模块编译为前端可直接调用的模块

考虑到我们已经有成熟的C++算法模块,此时如果使用方案1,会带来较大的人力成本,并且在开发过程中,并不能确保逻辑的一致性,因此使用选用WebAssembly

实现

根据emscripten的官方教程进行emscripten的安装,再进行cmake的安装

基于Emscripten,有两种实现方案:

  1. emcc直接命令行编译,适用于依赖较少的情况,这次我们遇到的场景只需要用到Eigen,并且Eigen只需要以头文件的形式引入
  2. emcmake编译 适用于依赖较多的情况,如使用Eigen,OpenCV做一些图像处理时,推荐编写CMakeFile,维护起来更方便

下面对这两种编译方式进行介绍,读者可按照上述两种方式按需选择

使用emcc
emcc -I ./eigen/ main.cpp -o main.js -s EXPORTED_FUNCTIONS="['_solve','_free']"  -s WASM=1 -s EXPORTED_RUNTIME_METHODS="['setValue','getValue']"

上述是编译Eigen算法模块的emcc命令,解释一下其中参数的含义,-I指gcc需要包含的头文件路径,main.cpp是主cpp文件,-o指输出,同时可以指定gcc优化级别,如果要使用wabt进行查看,就不要使用O3了

-s指options:

EXPORTED_FUNCTIONS:指希望导出的函数,需要加下划线

EXPORTED_RUNTIME_METHODS :指需要导出的Js runtime函数,根据使用的emscripten版本不同,并非所有的情况下,胶水JS代码都能完美的导出所有C++模块需要用到的函数,这个时候就需要用到wabt工具进行调试,没有的函数需要补充上,github链接附在最后

使用cmake
cmake_minimum_required(VERSION 2.8)
project(STEREO)

set(THIRDPARTY ${CMAKE_SOURCE_DIR}/../ThirdParty)
#wasm编译指令
set(EMSCRIPTENOPTIONS "SHELL:-s EXPORTED_FUNCTIONS=['_TriangulateDLTNView','_test'] -s EXPORTED_RUNTIME_METHODS=['ccall','cwrap'] --no-entry")
set(CMAKE_BUILD_TYPE Release)
set(CMAKE_CXX_FLAGS_RELEASE "-O1")
set(CMAKE_CXX_STANDARD 17)

#配置Eigen
include_directories(${THIRDPARTY}/Eigen)
add_executable(STEREO main.cpp)
target_link_options(STEREO PRIVATE ${EMSCRIPTENOPTIONS})

需要注意的是,最后的target_link_options,在这里可以链接上所需的emscripten的编译参数 编译成功后我们会得到:

image.png

其中main.wasm编译出的wasm文件,main.js是胶水js代码,main.js中默认通过fetch的方式进行加载,我们可以直接在html中引入

完成编译以后,进行Js与wasm模块的数据传递(函数传参),官方推荐使用arraybuffer进行传参,这里再介绍一种方式,

JS参数,传入到cpp(wasm)中
	function getPoint(arr)
		{
			const BYTES=8;
			const point = Module._malloc(arr.length * BYTES);
			for(let i=0;i<arr.length;i++)
			{
				Module.setValue(point+i*BYTES, arr[i], 'double')
			}
			return point;
		}

可以将js中的数据块,以指针的形式传入到cpp中

cpp计算结果,JS读取:

我们定义的函数:

const char* solve(int rayLength, double* raw){}

在Js中定义函数Pointer_stringify

		function Pointer_stringify(ptr, length) {
			if (length === 0 || !ptr) return '';
			// TODO: use TextDecoder
			// Find the length, and check for UTF while doing so
			var hasUtf = 0;
			var t;
			var i = 0;
			while (1) {
				assert(ptr + i < TOTAL_MEMORY);
				t = HEAPU8[(((ptr)+(i))>>0)];
				hasUtf |= t;
				if (t == 0 && !length) break;
				i++;
				if (length && i == length) break;
			}
			if (!length) length = i;

			var ret = '';

			if (hasUtf < 128) {
				var MAX_CHUNK = 1024; // split up into chunks, because .apply on a huge string can overflow the stack
				var curr;
				while (length > 0) {
					curr = String.fromCharCode.apply(String, HEAPU8.subarray(ptr, ptr + Math.min(length, MAX_CHUNK)));
					ret = ret ? ret + curr : curr;
					ptr += MAX_CHUNK;
					length -= MAX_CHUNK;
				}
				return ret;
			}
			return Module['UTF8ToString'](ptr);
		}

注意,在17年后,emscirpten默认取消导出了上述函数,因此上述函数不会默认存在于胶水JS文件中,Module对象是Js胶水代码中定义在全局上的,我们可以通过这个函数完成。

调用函数

最后我们调用:我们导出的solve函数

	const rawPoint = getPoint(data)
        const targetPoint = _solve(2, rawPoint);   
        const result = Pointer_stringify(targetPoint);

第一行的rawPoint就是JS数据在js和wasm共享内存中的指针,传到solve函数中,最后取指针按指针地址顺序读取数据得到最后结果

总结

在面临此类问题的时候,团队在做技术选型的时候,需要考虑一下几个问题,再决定是否需要使用WebAssembly:

  1. 开发成本:团队中是否有人力进行WebAssembly的开发,开发工作由前端承担还是由后端算法自己维护,如果由前者承担,在市面上相关技术人员并不充足的情况下,要保持好技术沉淀,梳理出一套较为通用的编译流程,对于大多数团队而言,如果由后端算法承担,需要让开发人员理解前端模块化知识,通常直接编译出的Js胶水文件可能需要修改,在定义函数,指针传参等知识点上,算法同学也有一些理解成本,团队应该权衡考虑。
  2. 场景选择,在做技术选型的时候,应该首先明确功能是否应该由前端承担,对于一些高性能计算的C++算法模块,如果用户的场景是用完即走,则用户通常对WebAPP的性能要求较高,此时不适合在前端做一些很重的计算,让用户等待太久,如在前端用PCL库做耗时较高的重建并不是什么明智的选择,首先应当考虑的还是在服务端计算,网络传输结果;对于一些工具类的,如三维家这种一次加载,长时使用的,使用WebAssembly去移植已有的CAD系统,显然是一种很不错的选择。

wasm调试工具wabtgithub.com/WebAssembly… 可以通过wabt对编译出的wasm文件进行调试,可以看到导出了哪些函数