🛠

ぼくがかんがえたさいきょうのWasm(Emscripten/C++)テスト環境

2021/12/03に公開

TL; DR

WebAssembly でビルドした GoogleTest のテストコードのバイナリが、ブラウザで実行されているのにコンソールで実行結果が見えるよ!
ついでにベンチマークも出来るよ!

はじめに

WebAssembly のコードを C++ で書いたとき、テスト実行はどうしようというのが問題でした。
GoogleTest はあるものの、WebAssembly はブラウザで実行しないとならないし、
Emscripten は Wasmer などの他のランタイムでは動かないようだし…

そんな中、ふと見た emrun が気になりました。
結果から言えばやりたいことが出来る素晴らしいソリューションでした。

また、WebAssembly を書くモチベーションとして一番多いだろう理由は実行パフォーマンスだと思います。パフォーマンスを確認することも重要です。この記事では emrun を使った GoogleTest の実行方法に加えて、Google Benchmark を使ったWasmコードのベンチマークを VS Code で行う方法と GitHub Actions を使って継続的に行う方法について記述します。

ビルド環境

Emscripten を利用する C++ コードの開発環境ということで、まずは必要なものを揃えていきましょう。ぼくがかんがえたさいきょうのWasmビルド環境の記事で紹介した通り、VS Code Remote Containers 拡張を使ってビルドするのが色々手っ取り早いですが、今回の記事の範囲程度であればローカルホストに Emscripten の環境を用意するだけでも構わないです。

devcontainer 関連

まずは devcontainer.jsonDockerfile.devcontainer ディレクトリに用意しましょう。

.devcontainer/devcontainer.json
{
	"name": "Emscripten",
	"build": {
		"dockerfile": "Dockerfile",
	},
	"runArgs": [
		"--cap-add=SYS_PTRACE",
		"--security-opt",
		"seccomp=unconfined"
	],
	// Set *default* container specific settings.json values on container create.
	"settings": {},
	// Add the IDs of extensions you want installed when the container is created.
	"extensions": [
		"ms-vscode.cpptools",
		"twxs.cmake"
	],
	// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
	"remoteUser": "vscode"
}

.devcontainer/Dockerfile
ARG VARIANT=ubuntu-20.04
FROM mcr.microsoft.com/vscode/devcontainers/cpp:0-${VARIANT} AS base

RUN apt-get update && apt-get install -y libappindicator1 fonts-liberation libnss3 libdrm2 libgbm1 xdg-utils firefox
RUN curl -O https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \
  dpkg -i google-chrome-stable_current_amd64.deb && \
  rm google-chrome-stable_current_amd64.deb
RUN curl -sSLO https://github.com/emscripten-core/emsdk/archive/refs/tags/3.0.0.tar.gz && \
  mkdir /emsdk && \
  tar zxvf 3.0.0.tar.gz -C /emsdk --strip-components=1 && \
  chown -R vscode:vscode /emsdk && \
  rm 3.0.0.tar.gz
RUN /emsdk/emsdk install latest && /emsdk/emsdk activate latest
RUN echo '. /emsdk/emsdk_env.sh > /dev/null 2>&1' >> /home/vscode/.bashrc

Docker イメージの中には Chrome と Firefox を入れておきます。Chrome はイメージをビルドしたときの最新版になるので、必要に応じてビルドし直したりして最新版を保つようにしましょう。

リポジトリ・エディタ設定関連

ついでに補助的な設定ファイルをいくつか。

.gitignore
build/
.clang-format
BasedOnStyle: 'LLVM'
.editorconfig
[*]
indent_style = space
indent_size = 2

ソースコード

簡略化のため今回は全部のソースコードをリポジトリのルートに置きます。

メインとなる、ブラウザ JS から呼び出されることを目的とした関数群を wasm.cpp に記述します。
今回は加算を行う add と 減算を行う sub を実装することにします。

wasm.cpp
#ifdef __EMSCRIPTEN__
#include "emscripten.h"
#else
#define EMSCRIPTEN_KEEPALIVE
#endif

extern "C" {
EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
  // simply return a + b
  return a + b;
}

EMSCRIPTEN_KEEPALIVE
int sub(int a, int b) {
  // Ooops!
  return a + b;
}
}

おっと、いかにもコピペの弊害的なバグが入ってますが、今は見て見ぬふりをしてください。このバグをテストで検出できるか、後の方で確認してみます。

テストやベンチマークで呼び出すのでヘッダも書いておきましょう。

wasm.hpp
#ifndef WASM_HPP_
#define WASM_HPP_

extern "C" {
int add(int a, int b);
int sub(int a, int b);
}

#endif

もう一つ、emrun の実験のためのソースコードを書いておきます。Hello World についでに引数表示機能をつけたような単純なコードです(バリバリのC言語ソースコードですが、cpp拡張子にしてC++でビルドしておきます)。

hello.cpp
#include <stdio.h>

int main(int argc, char *argv[]) {
  // simple hello world
  printf("Hello, world! argc:%d\n", argc);
  for (int i = 0; i < argc; ++i) {
    printf("argv[%d]:[%s]\n", i, argv[i]);
  }
}

テストコードを記述する wasm_test.cpp とベンチマークコードを記述する wasm_perf.cpp、実行用のシェルスクリプト run_test.sh も同じようにトップレベルに置くのですが、内容は後述します。

ビルド用ファイル

テスト・ベンチマークを含んだ CMakeLists.txt はこのようになります。今回、Wasm を呼び出すコードを書くのをちょっとサボって .html を Emscripten に生成させています(set_property(TARGET wasm PROPERTY SUFFIX ".html") の部分)。

CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(wasm LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)

# Main WebAssembly wasm/js/(sample)html
if(CMAKE_CROSSCOMPILING)
  add_executable(wasm wasm.cpp)
  set_property(TARGET wasm PROPERTY SUFFIX ".html")
endif()

# Hello world
add_executable(hello hello.cpp)
if(CMAKE_CROSSCOMPILING)
  set_property(TARGET hello PROPERTY SUFFIX ".html")
  target_link_options(hello PRIVATE "--emrun")
endif()

include(FetchContent)
FetchContent_Declare(
  googletest
  GIT_REPOSITORY https://github.com/google/googletest.git
  GIT_TAG release-1.11.0
)
FetchContent_Declare(
  googlebenchmark
  GIT_REPOSITORY https://github.com/google/benchmark.git
  GIT_TAG v1.6.0
)
set(BENCHMARK_ENABLE_TESTING OFF)
FetchContent_MakeAvailable(googletest googlebenchmark)

# Test
enable_testing()
include(GoogleTest)

add_executable(wasm_test wasm_test.cpp wasm.cpp)
target_link_libraries(wasm_test gtest_main)

if(CMAKE_CROSSCOMPILING)
  file(COPY "${CMAKE_SOURCE_DIR}/run_test.sh" DESTINATION ${CMAKE_BINARY_DIR} USE_SOURCE_PERMISSIONS)
  target_link_options(wasm_test PRIVATE "--emrun")
  set_property(TARGET wasm_test PROPERTY SUFFIX ".html")
  set_property(TARGET wasm_test PROPERTY CROSSCOMPILING_EMULATOR "./run_test.sh")
endif()

gtest_discover_tests(wasm_test DISCOVERY_TIMEOUT 20)

# Performance benchmark
add_executable(wasm_perf wasm_perf.cpp wasm.cpp)
target_link_libraries(wasm_perf benchmark_main)
if(CMAKE_CROSSCOMPILING)
  target_link_options(wasm_perf PRIVATE "--emrun")
  set_property(TARGET wasm_perf PROPERTY SUFFIX ".html")
endif()

ビルド

vscode ➜ /workspaces/docker-wasm-cmake-gtest (master ✗) $ mkdir -p build/wasm
vscode ➜ /workspaces/docker-wasm-cmake-gtest (master ✗) $ cd build/wasm
vscode ➜ /workspaces/docker-wasm-cmake-gtest/build/wasm (master ✗) $ emcmake cmake ../..
configure: cmake ../.. -DCMAKE_TOOLCHAIN_FILE=/emsdk/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake -DCMAKE_CROSSCOMPILING_EMULATOR=/emsdk/node/14.15.5_64bit/bin/node
-- Found Python: /usr/bin/python3.8 (found version "3.8.10") found components: Interpreter 
-- Performing Test CMAKE_HAVE_LIBC_PTHREAD
-- Performing Test CMAKE_HAVE_LIBC_PTHREAD - Success
-- Found Threads: TRUE  
-- Found Git: /usr/bin/git (found version "2.25.1") 
-- git version: v1.6.0 normalized to 1.6.0
-- Version: 1.6.0
-- Performing Test HAVE_CXX_FLAG_STD_CXX11
-- Performing Test HAVE_CXX_FLAG_STD_CXX11 - Success
-- Performing Test HAVE_CXX_FLAG_WALL
-- Performing Test HAVE_CXX_FLAG_WALL - Success
-- Performing Test HAVE_CXX_FLAG_WEXTRA
-- Performing Test HAVE_CXX_FLAG_WEXTRA - Success
-- Performing Test HAVE_CXX_FLAG_WSHADOW
-- Performing Test HAVE_CXX_FLAG_WSHADOW - Success
-- Performing Test HAVE_CXX_FLAG_WERROR
-- Performing Test HAVE_CXX_FLAG_WERROR - Success
-- Performing Test HAVE_CXX_FLAG_WSUGGEST_OVERRIDE
-- Performing Test HAVE_CXX_FLAG_WSUGGEST_OVERRIDE - Success
-- Performing Test HAVE_CXX_FLAG_PEDANTIC
-- Performing Test HAVE_CXX_FLAG_PEDANTIC - Success
-- Performing Test HAVE_CXX_FLAG_PEDANTIC_ERRORS
-- Performing Test HAVE_CXX_FLAG_PEDANTIC_ERRORS - Success
-- Performing Test HAVE_CXX_FLAG_WSHORTEN_64_TO_32
-- Performing Test HAVE_CXX_FLAG_WSHORTEN_64_TO_32 - Success
-- Performing Test HAVE_CXX_FLAG_FSTRICT_ALIASING
-- Performing Test HAVE_CXX_FLAG_FSTRICT_ALIASING - Success
-- Performing Test HAVE_CXX_FLAG_WNO_DEPRECATED_DECLARATIONS
-- Performing Test HAVE_CXX_FLAG_WNO_DEPRECATED_DECLARATIONS - Success
-- Performing Test HAVE_CXX_FLAG_WNO_DEPRECATED
-- Performing Test HAVE_CXX_FLAG_WNO_DEPRECATED - Success
-- Performing Test HAVE_CXX_FLAG_WSTRICT_ALIASING
-- Performing Test HAVE_CXX_FLAG_WSTRICT_ALIASING - Success
-- Performing Test HAVE_CXX_FLAG_WD654
-- Performing Test HAVE_CXX_FLAG_WD654 - Failed
-- Performing Test HAVE_CXX_FLAG_WTHREAD_SAFETY
-- Performing Test HAVE_CXX_FLAG_WTHREAD_SAFETY - Success
-- Performing Test HAVE_THREAD_SAFETY_ATTRIBUTES
-- Performing Test HAVE_THREAD_SAFETY_ATTRIBUTES -- failed to compile
-- Performing Test HAVE_CXX_FLAG_COVERAGE
-- Performing Test HAVE_CXX_FLAG_COVERAGE - Failed
-- Performing Test HAVE_STD_REGEX
CMake Warning at build/wasm/_deps/googlebenchmark-src/cmake/CXXFeatureCheck.cmake:43 (message):
  If you see build failures due to cross compilation, try setting
  HAVE_STD_REGEX to 0
Call Stack (most recent call first):
  build/wasm/_deps/googlebenchmark-src/CMakeLists.txt:279 (cxx_feature_check)


-- Performing Test HAVE_STD_REGEX -- success
-- Performing Test HAVE_GNU_POSIX_REGEX
-- Performing Test HAVE_GNU_POSIX_REGEX -- failed to compile
-- Performing Test HAVE_POSIX_REGEX
CMake Warning at build/wasm/_deps/googlebenchmark-src/cmake/CXXFeatureCheck.cmake:43 (message):
  If you see build failures due to cross compilation, try setting
  HAVE_POSIX_REGEX to 0
Call Stack (most recent call first):
  build/wasm/_deps/googlebenchmark-src/CMakeLists.txt:281 (cxx_feature_check)


-- Performing Test HAVE_POSIX_REGEX -- success
-- Performing Test HAVE_STEADY_CLOCK
CMake Warning at build/wasm/_deps/googlebenchmark-src/cmake/CXXFeatureCheck.cmake:43 (message):
  If you see build failures due to cross compilation, try setting
  HAVE_STEADY_CLOCK to 0
Call Stack (most recent call first):
  build/wasm/_deps/googlebenchmark-src/CMakeLists.txt:290 (cxx_feature_check)


-- Performing Test HAVE_STEADY_CLOCK -- success
-- Configuring done
-- Generating done
-- Build files have been written to: /workspaces/docker-wasm-cmake-gtest/build/wasm
vscode ➜ /workspaces/docker-wasm-cmake-gtest/build/wasm (master ✗) $ cmake --build . --config Release --parallel $(nproc)
Scanning dependencies of target wasm
Scanning dependencies of target gtest
Scanning dependencies of target hello
Scanning dependencies of target benchmark
[  2%] Building CXX object CMakeFiles/hello.dir/hello.cpp.o
[  5%] Building CXX object CMakeFiles/wasm.dir/wasm.cpp.o
[  7%] Building CXX object _deps/googletest-build/googletest/CMakeFiles/gtest.dir/src/gtest-all.cc.o
[ 10%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/benchmark.cc.o
[ 12%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/benchmark_api_internal.cc.o
[ 17%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/benchmark_runner.cc.o
[ 17%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/colorprint.cc.o
[ 20%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/benchmark_name.cc.o
[ 22%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/commandlineflags.cc.o
[ 25%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/complexity.cc.o
[ 27%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/benchmark_register.cc.o
[ 30%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/console_reporter.cc.o
[ 32%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/counter.cc.o
[ 35%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/csv_reporter.cc.o
[ 42%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/reporter.cc.o
[ 42%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/perf_counters.cc.o
[ 42%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/sleep.cc.o
[ 45%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/json_reporter.cc.o
[ 47%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/statistics.cc.o
[ 50%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/string_util.cc.o
[ 55%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/timers.cc.o
[ 55%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark.dir/sysinfo.cc.o
[ 57%] Linking CXX executable hello.html
[ 60%] Linking CXX executable wasm.html
[ 60%] Built target wasm
[ 60%] Built target hello
[ 62%] Linking CXX static library libbenchmark.a
[ 62%] Built target benchmark
Scanning dependencies of target benchmark_main
[ 65%] Building CXX object _deps/googlebenchmark-build/src/CMakeFiles/benchmark_main.dir/benchmark_main.cc.o
[ 67%] Linking CXX static library ../../../lib/libgtest.a
[ 70%] Linking CXX static library libbenchmark_main.a
[ 70%] Built target gtest
Scanning dependencies of target gtest_main
Scanning dependencies of target gmock
[ 72%] Building CXX object _deps/googletest-build/googletest/CMakeFiles/gtest_main.dir/src/gtest_main.cc.o
[ 75%] Building CXX object _deps/googletest-build/googlemock/CMakeFiles/gmock.dir/src/gmock-all.cc.o
[ 75%] Built target benchmark_main
Scanning dependencies of target wasm_perf
[ 80%] Building CXX object CMakeFiles/wasm_perf.dir/wasm_perf.cpp.o
[ 80%] Building CXX object CMakeFiles/wasm_perf.dir/wasm.cpp.o
[ 82%] Linking CXX executable wasm_perf.html
[ 85%] Linking CXX static library ../../../lib/libgtest_main.a
[ 85%] Built target wasm_perf
[ 85%] Built target gtest_main
Scanning dependencies of target wasm_test
[ 90%] Building CXX object CMakeFiles/wasm_test.dir/wasm.cpp.o
[ 90%] Building CXX object CMakeFiles/wasm_test.dir/wasm_test.cpp.o
[ 92%] Linking CXX static library ../../../lib/libgmock.a
[ 92%] Built target gmock
Scanning dependencies of target gmock_main
[ 95%] Building CXX object _deps/googletest-build/googlemock/CMakeFiles/gmock_main.dir/src/gmock_main.cc.o
[ 97%] Linking CXX executable wasm_test.html
[100%] Linking CXX static library ../../../lib/libgmock_main.a
[100%] Built target wasm_test
[100%] Built target gmock_main
vscode ➜ /workspaces/docker-wasm-cmake-gtest/build/wasm (master ✗) $ ls
 bin                   CTestTestfile.cmake   hello.wasm    wasm.html        wasm_perf.wasm                wasm_test.js
 CMakeCache.txt        _deps                 lib           wasm.js         'wasm_test[1]_include.cmake'   wasm_test.wasm
 CMakeFiles            hello.html            Makefile      wasm_perf.html  'wasm_test[1]_tests.cmake'     wasm.wasm
 cmake_install.cmake   hello.js              run_test.sh   wasm_perf.js     wasm_test.html
vscode ➜ /workspaces/docker-wasm-cmake-gtest/build/wasm (master ✗) $ 

色々生成されました。意味ある生成物をピックアップすると…

  • wasm.*: 本来欲しい WebAssembly のバイナリ関連
    • wasm.wasm : ビルドされた WebAssembly のバイナリ
    • wasm.js : 上の wasm をロードする JavaScript
    • wasm.html : 上の .js を呼び出すサンプルコード
  • hello.*: Hello World のバイナリ関連
  • wasm_test.*: テストのバイナリ関連
  • wasm_perf.*: パフォーマンスベンチマークのバイナリ関連

emrun

ここで改めて emrun について見てみましょう。
何が出来るかというのが Features の項に書いてあります。

  • コマンドラインからブラウザで Emscripten で生成された html を開きます
  • アプリケーション実行中の標準出力・標準エラー出力をターミナルに表示したりファイルに保存したりします
  • コマンドライン引数を main()argc, argv として受け渡します
  • exit(returncode) でアプリケーションが終了したのを検知して実行を終了、リターンコードとして与えられたものを返します
  • 実行するブラウザは選択することが出来ます。adb 経由で Android 上で実行することもできます

ここに書いてなくて注意が必要なのは、あくまでも WebAssembly をブラウザで実行して、上のことだけを行うということです。具体的に出来ないことを挙げると例えばファイルの読み書きは出来ません(一応、読む方だけならビルド時にちょっと工夫すれば wasm の中に組み込める)。また、環境変数も受け渡しされません。

続けて使い方が Quick how-to の項に書いてありますが、デスクトップ環境の話なので Docker だと少々事情が違います。--emrun をリンカフラグとして指定するというのは共通です。

では、hello.cpp をビルドして出来た hello.html を実行してみましょう。

ビルド

まずはビルドの説明です。CMakeLists.txt で関係あるのは以下の部分です。

add_executable(hello hello.cpp)
if(CMAKE_CROSSCOMPILING)
  set_property(TARGET hello PROPERTY SUFFIX ".html")
  target_link_options(hello PRIVATE "--emrun")
endif()

特別なことをやってるのは2か所。まずは set_property(TARGET hello PROPERTY SUFFIX ".html") で生成物に拡張子 .html を付与しています。次に target_link_options(hello PRIVATE "--emrun") で上にあったようにリンカフラグとして --emrun を追加しています。

実行

まずは emrun が使えるブラウザ一覧を見てみます。firefox と chrome がありますね。

vscode ➜ /workspaces/docker-wasm-cmake-gtest/build/wasm (master ✗) $ emrun --list_browsers
emrun has automatically found the following browsers in the default install locations on the system:

  - firefox: Mozilla Firefox 94.0
  - chrome: Google Chrome 

You can pass the --browser <id> option to launch with the given browser above.
Even if your browser was not detected, you can use --browser /path/to/browser/executable to launch with that browser.

続いて普通に実行してみます。引数に a b c を与えてみましょう。

vscode ➜ /workspaces/docker-wasm-cmake-gtest/build/wasm (master) $ emrun --browser chrome --browser_args="--headless --remote-debugging-port=9222" --kill_exit hello.html a b c
[1202/202259.463804:ERROR:bus.cc(393)] Failed to connect to the bus: Failed to connect to socket /var/run/dbus/system_bus_socket: No such file or directory

DevTools listening on ws://127.0.0.1:9222/devtools/browser/95379138-785f-4101-8053-c3f3540db74c
[1202/202259.489407:ERROR:sandbox_linux.cc(376)] InitializeSandbox() called with multiple threads in process gpu-process.
[1202/202259.493476:ERROR:command_buffer_proxy_impl.cc(125)] ContextResult::kTransientFailure: Failed to send GpuControl.CreateCommandBuffer.
Hello, world! argc:4
argv[0]:[./this.program]
argv[1]:[a]
argv[2]:[b]
argv[3]:[c]

なんか余計な出力も混ざってますがこれらは標準エラー出力に出ているのでリダイレクトで消すこともできます。argv[0]hello とか hello.html でないのは仕方ないんですかね…

一点、注意が必要で引数に --foo -b など、ハイフンで始まる引数を与えたい場合は、その前に -- を渡す必要があります。

vscode ➜ /workspaces/docker-wasm-cmake-gtest/build/wasm (master) $ emrun --browser chrome --browser_args="--headless --remote-debugging-port=9222" --kill_exit hello.html -- -a -b -c --foo --bar --baz
[1202/202605.181010:WARNING:discardable_shared_memory_manager.cc(198)] Less than 64MB of free space in temporary directory for shared memory files: 57
[1202/202605.182535:ERROR:bus.cc(393)] Failed to connect to the bus: Failed to connect to socket /var/run/dbus/system_bus_socket: No such file or directory
[1202/202605.183494:ERROR:socket_posix.cc(150)] bind() failed: Address already in use (98)
[1202/202605.183540:ERROR:socket_posix.cc(150)] bind() failed: Cannot assign requested address (99)
[1202/202605.183565:ERROR:devtools_http_handler.cc(298)] Cannot start http server for devtools.
[1202/202605.207628:ERROR:sandbox_linux.cc(376)] InitializeSandbox() called with multiple threads in process gpu-process.
[1202/202605.210585:ERROR:command_buffer_proxy_impl.cc(125)] ContextResult::kTransientFailure: Failed to send GpuControl.CreateCommandBuffer.
Hello, world! argc:7
argv[0]:[./this.program]
argv[1]:[-a]
argv[2]:[-b]
argv[3]:[-c]
argv[4]:[--foo]
argv[5]:[--bar]
argv[6]:[--baz]

このように、WebAssembly にビルドした実行バイナリがまるでネイティブコードかのように実行されています。

emrun に与えているオプションを見ていきましょう。まず --browser chrome は使うブラウザに Chrome を指定しています。--browser_args でブラウザに与える引数を指定しています。ここでは --headless--remote-debugging-port=9222 を与えています。ちょっとくわしく追えていないのですが Chrome の場合は後者もつけないとヘッドレスモードで正常に動作しません。--kill_exit をつけることで終了時にブラウザを kill します。つけてないとブラウザのプロセスが残ります。

run_test.sh

ここでちょっとしたシェルスクリプトを書いておきます。./run_test.sh <.html ファイル> <引数...> の形式で実行できるようにするためのもので、CMake の中で CROSSCOMPILING_EMULATOR に使います。

run_test.sh
#!/bin/bash

HTML=$1
shift

# Chrome
# なぜかremote-debugging-portをつけないと動かない。rootのときは--no-sandboxも付ける
# exec emrun --browser chrome --browser_args="--headless --no-sandbox --disable-gpu --remote-debugging-port=9222" --kill_exit ${HTML} -- "$@" 2>/dev/null
exec emrun --browser chrome --browser_args="--headless --disable-gpu --remote-debugging-port=9222" --kill_exit ${HTML} -- "$@" 2>/dev/null

# Firefox
# Firefoxは多重起動できないので念のためstartでもkillしておく
# exec emrun --browser firefox --browser_args="--headless" --kill_start --kill_exit ${HTML} -- "$@"

テスト環境

テストコード

これでテストコードが書ける環境が整いました。早速テストを書いてみましょう(説明の都合上最低限のテストですが)。

wasm_test.cpp
#include "wasm.hpp"
#include "gtest/gtest.h"

TEST(WasmAdd, Test1) {
  // 1 + 2
  EXPECT_EQ(3, add(1, 2));
}

TEST(WasmSub, Test1) {
  // 2 - 1
  EXPECT_EQ(1, sub(2, 1));
}

実行

実行してみましょう。emrun の引数を全部書くのも面倒なので run_test.sh スクリプトを使います。

vscode ➜ /workspaces/docker-wasm-cmake-gtest/build/wasm (master ✗) $ ./run_test.sh wasm_test.html --gtest_color=yes
Running main() from /workspaces/docker-wasm-cmake-gtest/build/wasm/_deps/googletest-src/googletest/src/gtest_main.cc
[==========] Running 2 tests from 2 test suites.
[----------] Global test environment set-up.
[----------] 1 test from WasmAdd
[ RUN      ] WasmAdd.Test1
[       OK ] WasmAdd.Test1 (0 ms)
[----------] 1 test from WasmAdd (2 ms total)

[----------] 1 test from WasmSub
[ RUN      ] WasmSub.Test1
/workspaces/docker-wasm-cmake-gtest/wasm_test.cpp:11: Failure
Expected equality of these values:
  1
  sub(2, 1)
    Which is: 3
[  FAILED  ] WasmSub.Test1 (8 ms)
[----------] 1 test from WasmSub (11 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 2 test suites ran. (70 ms total)
[  PASSED  ] 1 test.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] WasmSub.Test1

 1 FAILED TEST

上のコードフェンスでの張り付けでは色分けされてませんが、実行時に --gtest_color=yes を付けているので、OK/FAILEDが色分けされています。

ctest でも動作します。

vscode ➜ /workspaces/docker-wasm-cmake-gtest/build/wasm (master ✗) $ ctest
Test project /workspaces/docker-wasm-cmake-gtest/build/wasm
    Start 1: WasmAdd.Test1
1/2 Test #1: WasmAdd.Test1 ....................   Passed    0.21 sec
    Start 2: WasmSub.Test1
2/2 Test #2: WasmSub.Test1 ....................***Failed    0.22 sec

50% tests passed, 1 tests failed out of 2

Total Test time (real) =   0.42 sec

The following tests FAILED:
          2 - WasmSub.Test1 (Failed)
Errors while running CTest

注意事項

ctest の -j 引数で並列実行すると、Chrome との通信が上手くいかなくなり正常に動作しません。

テストの中でファイルの読み書きをすることが出来ません。ただし、ファイルの読み込みはちょっと工夫すればできます。--embed-file を使います。

そもそもテストランナー自体もファイルの読み書きが出来ません。--gtest_output やカバレッジの出力などが出来ないということになります。

テストのデバッグを行うことが出来ません。

パフォーマンスベンチマーク環境

Google Benchmark を使ってベンチマーク結果を取得してみましょう。サイトの説明を参考にちょこちょこっと書くとこんな感じになります。BENCHMARK_MAIN() マクロを呼ぶ代わりに CMakeLists.txtbenchmark_main をリンクしてあります。

wasm_perf.cpp
#include "wasm.hpp"
#include <benchmark/benchmark.h>

static void BM_Add(benchmark::State &state) {
  for (auto _ : state) {
    int ret = add(1, 2);
  }
}
// Register the function as a benchmark
BENCHMARK(BM_Add);

// Define another benchmark
static void BM_Sub(benchmark::State &state) {
  for (auto _ : state) {
    int ret = sub(3, 2);
  }
}
BENCHMARK(BM_Sub);

これも run_test.sh 経由で実行してみましょう。

vscode ➜ /workspaces/docker-wasm-cmake-gtest/build (master ✗) $ ./run_test.sh wasm_perf.html --benchmark_color=true
-----------------------------------------------------
Benchmark           Time             CPU   Iterations
-----------------------------------------------------
BM_Add           5.67 ns         5.67 ns    121760306
BM_Sub           6.02 ns         6.02 ns    116472546

これもちゃんと色付けされて出力されます。

注意事項

次のように、--benchmark_out という引数がありますが、テストと同じくベンチマークでもファイル出力は出来ません。標準出力のフォーマットを --benchmark_format で変更しましょう。

vscode ➜ /workspaces/docker-wasm-cmake-gtest/build (master ✗) $ ./run_test.sh wasm_perf.html --help
benchmark [--benchmark_list_tests={true|false}]
          [--benchmark_filter=<regex>]
          [--benchmark_min_time=<min_time>]
          [--benchmark_repetitions=<num_repetitions>]
          [--benchmark_enable_random_interleaving={true|false}]
          [--benchmark_report_aggregates_only={true|false}]
          [--benchmark_display_aggregates_only={true|false}]
          [--benchmark_format=<console|json|csv>]
          [--benchmark_out=<filename>]
          [--benchmark_out_format=<json|console|csv>]
          [--benchmark_color={auto|true|false}]
          [--benchmark_counters_tabular={true|false}]
          [--benchmark_perf_counters=<counter>,...]
          [--benchmark_context=<key>=<value>,...]
          [--v=<verbosity>]

GitHub Actions

ここまでは VS Code Remote Containers で実行することを想定していましたが、せっかくなので GitHub Actions で継続的に実行されるようにしましょう。
ベンチマークについても benchmark-action/github-action-benchmark を使うことで、継続的なベンチマーク実行&結果のグラフ化が出来ます。

ビルド&テスト

ビルドとテストを行います。本当はフォーマットチェックとかを先にやった方がいいんでしょうが、今回は説明を割愛ということで…

.github/workflows/build.yml
name: CMake

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

env:
  BUILD_TYPE: Release
  EM_VERSION: 3.0.0
  EM_CACHE_FOLDER: "emsdk-cache"

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2

      - name: Setup cache
        id: cache-system-libraries
        uses: actions/cache@v2
        with:
          path: ${{env.EM_CACHE_FOLDER}}
          key: ${{env.EM_VERSION}}-${{ runner.os }}
      - name: Setup emsdk
        uses: mymindstorm/setup-emsdk@v11
        with:
          # Make sure to set a version number!
          version: ${{env.EM_VERSION}}
          # This is the name of the cache folder.
          # The cache folder will be placed in the build directory,
          #  so make sure it doesn't conflict with anything!
          actions-cache-folder: ${{env.EM_CACHE_FOLDER}}

      - name: Configure CMake
        # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
        # See https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html?highlight=cmake_build_type
        run: emcmake cmake -B ${{github.workspace}}/build -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}}

      - name: Build
        # Build your program with the given configuration
        run: cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}}

      - name: Test
        working-directory: ${{github.workspace}}/build
        # Execute tests defined by the CMake configuration.
        # See https://cmake.org/cmake/help/latest/manual/ctest.1.html for more detail
        run: ctest -C ${{env.BUILD_TYPE}}

パフォーマンスベンチマーク

事前に gh-pages ブランチを作って GitHub Pages がそこを参照するようにしておきます。Actions が勝手にグラフを作って dev/bench/ 以下に置いてくれます。

あんまり細かい設定は詰めれていないです。また、サイトにも書いてありますが共用のランナーでパフォーマンスベンチマークすることに意味があるのか微妙なところもありますが、まあ記録を残すぞ、という程度の意味で…

.github/workflows/benchmark.yml
name: Wasm Benchmark
on:
  push:
    branches:
      - master

env:
  # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.)
  BUILD_TYPE: Release
  EM_VERSION: 3.0.0
  EM_CACHE_FOLDER: "emsdk-cache"

permissions:
  contents: write
  deployments: write

jobs:
  benchmark:
    name: Run C++ benchmark example
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Cache Benchmark library
        uses: actions/cache@v1
        with:
          path: ${{github.workspace}}/build/benchmark
          key: ${{ runner.os }}-googlebenchmark-v1.5.0
      - name: Setup cache
        id: cache-system-libraries
        uses: actions/cache@v2
        with:
          path: ${{env.EM_CACHE_FOLDER}}
          key: ${{env.EM_VERSION}}-${{ runner.os }}
      - name: Setup emsdk
        uses: mymindstorm/setup-emsdk@v11
        with:
          # Make sure to set a version number!
          version: ${{env.EM_VERSION}}
          # This is the name of the cache folder.
          # The cache folder will be placed in the build directory,
          #  so make sure it doesn't conflict with anything!
          actions-cache-folder: "emsdk-cache"

      - name: Configure CMake
        # Configure CMake in a 'build' subdirectory. `CMAKE_BUILD_TYPE` is only required if you are using a single-configuration generator such as make.
        # See https://cmake.org/cmake/help/latest/variable/CMAKE_BUILD_TYPE.html?highlight=cmake_build_type
        run: emcmake cmake -B ${{github.workspace}}/build -DBENCHMARK_ENABLE_TESTING=OFF -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}}

      - name: Build
        # Build your program with the given configuration
        run: cmake --build ${{github.workspace}}/build --config ${{env.BUILD_TYPE}}

      - name: Run Benchmark
        run: ./run_test.sh wasm_perf.html --benchmark_format=json | tee benchmark_result.json
        working-directory: ${{github.workspace}}/build

      - name: Store benchmark result
        uses: benchmark-action/github-action-benchmark@v1
        with:
          name: C++ Benchmark
          tool: "googlecpp"
          output-file-path: ${{github.workspace}}/build/benchmark_result.json
          github-token: ${{ secrets.GITHUB_TOKEN }}
          auto-push: true
          # Show alert with commit comment on detecting possible performance regression
          alert-threshold: "200%"
          comment-on-alert: true
          fail-on-alert: true

GitHub Pages の dev/bench/ にアクセスすると、こんな感じにベンチマーク結果がグラフとして表示されます。

ネイティブでのビルド

ところで、今回説明した CMakeLists.txt では Emscripten 関係の設定をちょっと丁寧に if(CROSSCOMPILING) でくくってあります。そのため、単純に cmake でビルドすればネイティブコードとしてテストとパフォーマンスベンチマークがビルドされます。実行例でわざわざ mkdir -p build/wasm としておいたのはここで build/native ディレクトリを作ってそちらにネイティブのバイナリを生成させるためです。

こんな感じにすればネイティブでデバッグ版バイナリが出来ます。

$ mkdir -p build/native
$ cd build/native
$ cmake ../..
$ cmake --build . --config Debug

また、build ディレクトリに普通に cmake することで、VS Code の CMake 関係の拡張機能を入れて簡単にデバッグが出来たりします。拡張機能を追加するときは単純に VS Code の UI でインストールすると、コンテナの再ビルドで消えてしまいますので、devcontainer.json に書きましょう。

おわりに

いかがでしたでしょうか。WebAssembly としてビルドされたバイナリそのものに対してテストコードを実行出来て、パフォーマンスベンチマークを取得することも出来ました。きちんと WebAssembly としてビルドされたバイナリを、ブラウザそのもので実行しているので、コンパイラのバグやブラウザの Wasm ランタイムのバグがあってもテストがちゃんとコケます(まあこういうの踏んじゃうと解決は困難でしょうが)。wasm でデバッグすることこそ出来ないものの、同等のコードをネイティブビルドすることが出来ます。失敗するテストに対してデバッガを使ってデバッグしていく、ということもネイティブコードに対してなら出来ます。万全とは言えませんがかなり快適な開発環境が整ったのではないでしょうか。

この記事で説明したソースコードは kounoike/docker-wasm-cmake-gtest リポジトリにあります。
ベンチマークの結果は https://kounoike.github.io/docker-wasm-cmake-gtest/dev/bench/ にあります。

Discussion