Emscripten & WebAssembly Night #8 で「AssemblyScriptでライブラリコードの高速化をしてみる」という発表をした
発表の機会をいただきありがとうございました。会場を提供していただいたメルカリさんにも感謝いたします。
「AsssemblyScriptはTypeScriptのサブセットだから実質TypeScriptを書くだけでパフォーマンスアップ!」みたいな言説をみるにつけ、「ええ〜ほんとか〜??ほんとにやってみて言ってるんか〜??」と思っていたので、小規模とはいえ実際にやってみて検証&考察できたのはよかったなあと。
特に、最適化JITの効きずらい状況(たとえばスタートアップタイムの高速化)での高速化に寄与する可能性を示唆できたのは大きな発見です。V8チームの目下の関心事はスタートアップタイムのようですし(ただしJSのダウンロード&パースなどの時間も含む)、FacebookもHermes Engineという新しいJSエンジンを開発してまでスタートアップタイムを改善しようとしています。結論としては、いま着手する必要はなくて「まだ早い」になりましたが、WebAssemblyが今後注目の技術であることには違いありません。
ただ、少なくとも現状だと、AssemblyScriptを学ぶ意味はあまりないと思います。もし同じことをするにせよ、ランタイムの小さいCまたはRustが現実的な選択肢になるのではないでしょうか。今回のコードもAssemblyScripは捨ててRustで再実装してみようとは思っています。
webpack v4.35.3 の JSON loader は JSON.parse() を実行時に行うコードを生成する
ちょっとまえに The cost of JavaScript in 2019 · V8 というブログが話題になりました。
このなかで次のように説明している箇所があります:
As long as the JSON string is only evaluated once, the
JSON.parse
approach is much faster compared to the JavaScript object literal, especially for cold loads.(JSON stringを一度しか評価しないのであれば、オブジェクトリテラルよりも
JSON.parse
のほうがはるかに高速です。コールドロード(サイトの初回アクセス時、何のキャッシュも利用できないケース)では特にそうです。)
この巨大なJSONリテラルは、たとえばwebpackの組み込み JSON loader で *.json
を読み込むときに作られていました。たとえば、I18Nをしているサイトにおける翻訳メッセージファイルなどは典型例です。
件のエントリは主にV8について触れていますが、「JSONのほうが言語仕様的にシンプルなので速い」というのはおそらくどのJSエンジンでも真でしょう。だったらwebpackの JSON loader が JSON.parse()
ベースのコードを生成すればいいのでは!ということでPRを出してみました。
このパッチは webpack v4.35.3 に含まれているので、最新のwebpack を使っているかぎり、何も気にしなくてもこの変更が適用されて高速になるはずです。
なお、2019年7月9日現在β版である Chrome 76 / v8 7.6 では JSON.parse()
が2倍以上高速になっているとのこと:
このwebpackの変更はChrome 76以降はさらに効果的でしょう。
実際のパフォーマンスはJSの性質によって違うと思われるので、もし余裕があればプロダクションでlighthouseなどを使って測定してみてほしいです。
なお、この機能自体はwebpack built-in json loaderを使う限り自動的に有効になりますが、json-loader
moduleを使うことで無効化できます。
const config = { // ... module: { rules: [ { test: /\.json$/, type: "javascript/auto", use: { loader: "json-loader", }, }, ], // ... } };
graphql-ruby で MessagePack の timestamp 型を使う
GraphQL APIのシリアライザとしてMessagePackを使うとGraphQLのcustom scalar typeを活用したくなりますね。ということで、graphql-rubyの ISO8601DateTypeを参考にMessagePackのtimestamp型にシリアライズ・デシリアライズできるようにします。
これまでの話
- GraphQLとMessagePackは相性がよさそう - Islands in the byte stream
- msgpack-ruby に timestamp型を実装した - Islands in the byte stream
コード
# frozen_string_literal: true module Types class DateTime < GraphQL::Schema::Scalar description "A datetime type, encoded in ISO 8601 string in JSON, or timestamp type in MessagePack" # @param value [ActiveSupport::TimeWithZone] # @return [String] def self.coerce_result(value, _ctx) value # use to_json or equivalent converter end # @param value [String,ActiveSupport::TimeWithZone] # @return [ActiveSupport::TimeWithZone] def self.coerce_input(value, _ctx) if value.is_a?(ActiveSupport::TimeWithZone) value else Time.zone.parse(value) end rescue ArgumentError nil end end end
解説
graphql-rubyのISO8601DateTIme型そのままだと文字列化してしまうのですが、シリアライズをRailsのrendererに任せるならActiveSupport::TimeWithZoneオブジェクトそのままのほうが都合がいいのです。そうするとrendererから呼び出されるシリアライザが適切にシリアライズフォーマットとRubyのデータ型をマッピングできるという感じです。
これによりJSONの場合とMessagePackの場合でデシリアライズ結果が変わってしまいますが、そこは互換性よりもMessagePackを使い倒すことを重視したというい感じです。というか、そもそもがバイナリをそのまま扱いたいというのがMessagePackを採用した理由なので、この時点でJSONとの完全な互換性は捨ててるんですよね。
DateTime型はわりと頻出するうえにJavaScriptで表示するときはかならず Intl.DateTimeFormat での加工が必要で、このAPI体系は組み込みのDateオブジェクトを要求するので、文字列ではなくMessagePackのtimestamp型でやりとりするのはそこそこ効果があるのではないかと思っています。
msgpack-ruby に timestamp型を実装した
timestamp型というのは2017年8月にMessagePackに追加された型です。
しかし、おそらくMessagePack開発元がtimestamp型を使っていないためか、長らく msgpack-ruby には実装されていませんでしたし、msgpack-java の実装もpull-request はあるもののマージされていません。
ところでKibela Web APIはMessagePackをサポートしています。
ここでGraphQLのDateTIme型をMessagePackのtimestamp型で表現したくなったので msgpack-ruby にもtimestamp型を実装しました。
MessagePack timestamp type by gfx · Pull Request #168 · msgpack/msgpack-ruby · GitHub
こちらは msgpack-ruby v1.3.0 で利用可能になっています。
なお v1.3.0 の時点ではデフォルトではtimestamp型を扱えるようにはなっていません。というのも、Ruby on RailsではRuby built-in classのTimeではなく ActiveSupport::TimeWithZone を使うのが通例であり、デフォルトで MessagePack timestamp <-> Ruby Time のマッピングがあるとかえって誤解を招くのではないかと考えたからです。
AS::TWZを MessagePack timestamp typeにマッピングするにはこんな感じでいけます。
# for Rails v5.2 + msgpack-ruby v1.3 MessagePack::DefaultFactory.register_type( MessagePack::Timestamp::TYPE, ActiveSupport::TimeWithZone, packer: -> (time) do MessagePack::Timestamp.to_msgpack_ext(time.tv_sec, time.tv_nsec) end, unpacker: -> (payload) do tv = MessagePack::Timestamp.from_msgpack_ext(payload) # FIXME: Rails v6 supports Time.zone.at(sec, nsec, :nanoseconds) ::Time.at(tv.sec, tv.nsec, :nanosecond).utc.in_time_zone(Time.zone) end, )
GraphQLとMessagePackは相性がよさそう
MessagePackはJSONのようなデータをシリアライズできるbinary formatで、JavaScript実装である msgpack-javascriptを基準で考えると次のような特徴があります:
- JSONよりencodeもdecodeも少し速い
- かつ、streaming decodeができるので
fetch()
のresponseのdecodeの効率がとてもよい - とはいえ実用上は「JSONより遅くない」ということのほうが重要ではある
- かつ、streaming decodeができるので
- binaryを直接扱える
- これに対してJSONでbinaryを扱うときははbase64などでエンコードする必要がある
- timestamp型があり、デフォルトではJSのDateにマッピングされる
- Intl.DateTimeFormatへの入力としてならこれで必要十分
- マッピングをあとから変えることはできる
特にバイナリを直接扱えるのはJSONとくらべて非常にすぐれた点で、たとえばGraphQL APIを実装するときはファイルのアップロードの扱いに常に悩まされるわけですが、MessagePackを使えばオーバーヘッドなしにバイナリを扱えます。
あとは現在は手元にコードは一切ないのですが、GraphQL schemaを読みながら適切にGraphQLのcustom scalar typeを適切なオブジェクトにマッピングするMessagePack decoderがあるとさらに捗りそうです。
MessagePackを単なるJSONの代替として考えるとJavaScript的にはメリットに乏しいのですが、JSONよりも表現が豊富で拡張性にすぐれたシリアライズフォーマットと捉えるとGraphQLと相性がいいなあ…ということを考えています。というわけで最近MessagePack for JavaScriptの実装を頑張っているのでした。まずは優れた実装がないと話になりませんからね。
JavaScript libraryのREADMEにブラウザ互換性表バッジを表示することについての諸々
ブラウザ互換性表 (a.k.a. browser matrix) とは、こういうやつです。
TypeScriptで MessagePack encoder/decoder を実装した - Islands in the byte stream で作った msgpack/msgpack-javascript にこのバッジをつけようとして苦労しました。今回は単に一度やってみたかったというのもあって頑張りましたが、いろいろ大変だったので記録を残しておきます。
しかし、どんなプロジェクトでもやるべきかというと微妙で、ブラウザの機能に大きく依存するライブラリでもない限りはバッジは頑張らなくてもいいかなあという結論です。ブラウザに依存した機能をもっと多用するのであれば、バッジの価値があるのかもしれません。
今回はブラウザテストをはじめてから1つだけIE11で発現するバグを直したんですが、修正サイクル自体はIE11 on VirtualBoxで回しましたしね。
一方で、CI環境という意味ではIE11などテストするのが少し面倒なブラウザでCIを回すのは意味があるとは思います。
ブラウザ互換性表バッジサービス
SauceLabs や TestingBot や testling などがあります…が、testlingは今は動いてなさそう? SauceLabs は時間制限があるもののfree trialがあって、OSSの場合は別途Open Sauceを申請することもできます*1。
今回は一番つかわれてそうなSauceLabsにしました。なおSauceLabsにsignupしたら即アカウントがdisableされました…。問い合わせたら「乱用防止ロジックが誤動作したのかも。確認するのでテストしたいリポジトリを教えてくれる?」みたいなやり取りをして有効にしてもらったりしました。サポートのやりとり自体はスムーズにできたのでよかったです。
SauceLabsはそれなりに設定すればちゃんと動きます。が、今回使ったKarmaだと設定方法がいまいち不明で、githubで filename:karma.conf.js saucelabs
などして動いているものをみつけて参考にする、などのがんばりが必要でした。
Karma
任意のユニットテストをブラウザで実行するフレームワークです。
karma-chrome-launcher
や karma-firefox-launcher
などを使ってヘッドレスブラウザで動かすとローカルでもサクッと動くしTravisなどでも実行できます。IE11も modern.IE のVMをもともとVirtualBoxにいれていて、 karma-virtualbox-ie11-launcher
で無事テストできました。
SauceLabs用の karma-sacelabs-launcher
もありましたが、READMEに書いてあることがあまり信頼できないわ複数のブラウザを起動するテストが進まないバグがあって1つづつ起動する必要があるわと、ほんとにこれメンテされてるのかってレベルで荒れてました。最終的には動きましたが、バッジを表示させるのでなければターゲットはIE11だけでいいかもしれません。
さらに、TypeScriptのためには karma-typescript
か karma-webpack
(+ts-loader
) を使う必要があります。前者は結局モジュールのバンドルを自前でやっているようで、それなら一般的に使われているであろうwebpackを利用する karma-webpack
にしました。ただ karma-webpack
もイマイチ安定していなくて、現行の karma-webpack 3.x + ts-loader だとなぜか node_modules/ 以下のdtsを読んでくれません。やむを得ず transpileOnly: true
で型チェックはしないことに。とはいえ、いずれにせよ他のフェーズで型チェックはするのでこれによってテスト結果が信頼できなくなるということはないのでいいでしょう。
さらに、 msgpack-javascript
は mocha
+ NodeJS assert module でテストを書いています。ブラウザで動かすにはこのassert moduleのpolyfillを行なわなければならず、webpackはデフォルトでこのpolyfill差し込みを行います。それを管理しているのがこのモジュール:
これによれば assert module のpolyfillは browserify/commonjs-assert
なんですが、NodeJS用のテストを実際にブラウザで実行しても動きません。どうも commonjs-assert
1.x はベースにしているNodeJS assertが古かったようです。ちょうど最近 commonjs-assert
が v2.0.0 になって最新のNodeJSに追従したんですが、 node-libs-browser
のほうがまだこのメジャーバージョンアップに対応されていないので webpack 組み込みのpolyfillを無効化して事なきを得ました。というか、webpackがnodejs modulesのpolyfillを自動で差し込むのはいろいろ罠が多いのでデフォルト無効化してほしいくらいです。
最終的には karma.conf.ts
はこうなりました。
https://github.com/msgpack/msgpack-javascript/blob/master/karma.conf.ts
ローカルのNodeJSテストが5秒くらいで終わるのに対して、Karma+ChromeHeadlessテストが8秒くらい、Karma+FirefoxHeadlessでのテストが15秒くらいです。これくらい高速に実行できるとブラウザでユニットテストを走らせるのも苦ではないですね。
まあしかし、設定は苦労しましたが、総じて見るとKarmaはよく出来てるなあと思います。Headless ChromeやHeadless Firefoxの安定版が普通に使えるようになったこともあり、ユニットテストをブラウザで実行するのはわりと簡単にできるようになりましたね。
*1:その後 Open Sauce 申請が通ったので時間制限なしで使えるようになりました
TypeScriptで MessagePack encoder/decoder を実装した
npm install @msgpack/msgpack でインスコできます。NodeJS v12 でベンチマークしたかぎり、JSONと同程度の速度で、これまで最速といわれてきた msgpack-lite よりもさらに少しだけ高速です。
もともとこのリポジトリには uupaaさんによる実装(tagged as classic
) があったんですが、メンテされなくなって久しく npmjs.com にもリリースされていないという状況でした。
https://github.com/msgpack/msgpack-javascript/tree/classic
その後kawanetさんが msgpack-lite を実装したのが2015年。これが2019年現在、もっとも週間ダウンロード数の多いMessagePack for JSの実装です。
msgpack-lite ピュアJavaScript実装の速いMessagePackライブラリ - Qiita
msgpack-liteはちゃんと動いてメンテされていて stream encoder / decoder もある優秀な実装なんですが、NodeJS向けに最適化されていて、実際くだんのmsgpack-liteの紹介エントリでもBufferに大きく依存しているとあり、ブラウザサポートは「Bufferのpolyfillがあればブラウザでも動く」という水準です。用途を察するに fluentd client for NodeJS で使うことを想定しての実装であり、これはこれでベストな実装です。
ただ今回はブラウザ向けに最適化された実装がほしかったのと、ES2018 (async iteration) とTypeScriptという条件で設計したらいろいろ変わるはずだなあという思いがありました。
要件
- ES2018+ な設計と実装
- TypedArraysに依存した設計やESクラスの構文などを活用したい
AsyncIterable<Uint8Array>
をうけとるストリーミングデコーダがほしい
- ES5 (IE11) でも動く
- そうはいってもまだIE11サポートを完全には切れないので…
- TypeScriptのdownlevel compile (
target: "es5"
やdownlevelIteration: true
を利用) と polyfill (core-js) を想定する
- streaming decoder
- Fetch APIの streaming download と繋いでstreaming decodeすると、特に回線の遅い環境で効果的だと思われる
実装
ブラウザ向けに最適化といいつつ、いろいろがんばったら NodeJS v12 ではmsgpack-liteより速くなりました。@msgpack/msgpack
はDataViewに大きく依存していて、 Improving DataView performance in V8 · V8 が実装されたV8が入ったのがNodeJS v12 だからこそですね。
なお↓のベンチマークは mspgack-lite に同梱されているものを流用しています。 Buffer.from(JSON.stringify(...))
はI/Oを想定しているからで、JSON.stringifyの戻り値であるJavaScriptのstringはUTF8 encodeしてバイト列にしないとファイルに保存したりネットワークで転送したりできないため、ベンチマークの公平性という観点から入れたと思われます。MessagePackのエンコードはバイト列を生成するのでそのままI/Oに載せられるため、加工はしていません。
なおベンチマークはいまのところNodeJSでしか行ってませんが、@msgpack/msgpack
はECMA-262の機能しか使ってないのでChromeでも同じ傾向になるはずです。またMessagePack実装はmsgpack-liteとしか行ってませんが、(すくなくとも2015年時点では)msgpack-liteが最速ということだったので他の実装との比較はここでは省略しています。
Benchmark on NodeJS v12
NodeJS v12 だと encodeは Buffer.from(JSON.stringify(obj))
よりも少し速く、decodeはJSONほぼ同水準という結果になりました。
Benchmark on NodeJS/v12.1.0
operation | op | ms | op/s |
---|---|---|---|
buf = Buffer.from(JSON.stringify(obj)); | 493600 | 5000 | 98720 |
buf = JSON.stringify(obj); | 959600 | 5000 | 191920 |
obj = JSON.parse(buf); | 346100 | 5000 | 69220 |
buf = require("msgpack-lite").encode(obj); | 358300 | 5000 | 71660 |
obj = require("msgpack-lite").decode(buf); | 270400 | 5000 | 54080 |
buf = require("@msgpack/msgpack").encode(obj); | 594300 | 5000 | 118860 |
obj = require("@msgpack/msgpack").decode(buf); | 343100 | 5000 | 68620 |
Benchmark on NodeJS v10
参考までに NodejS v10 の結果も載せておきますが、見ての通りかなり遅いです。
Benchmark on NodeJS/v10.15.3
operation | op | ms | op/s |
---|---|---|---|
buf = Buffer.from(JSON.stringify(obj)); | 369500 | 5000 | 73900 |
buf = JSON.stringify(obj); | 942100 | 5000 | 188420 |
obj = JSON.parse(buf); | 356800 | 5000 | 71360 |
buf = require("msgpack-lite").encode(obj); | 283300 | 5000 | 56660 |
obj = require("msgpack-lite").decode(buf); | 211200 | 5000 | 42240 |
buf = require("@msgpack/msgpack").encode(obj); | 147000 | 5000 | 29400 |
obj = require("@msgpack/msgpack").decode(buf); | 132400 | 5000 | 26480 |
ユースケース
Kibela Web API (beta) がMessagePack (application/x-msgpack
) を扱えるので、こんな感じで decodeAsync
を使えます。
まだブラウザでのテスト(特にIE11)が十分でないので @msgpack/msgpack
のバージョンは v1.0.0 にしてませんが、ブラウザでの挙動を十分に確認したら v1.0.0
にしようと思っています。
エンジニアHubにTypeScriptの記事を書きました
「GraphQL」徹底入門 ─ RESTとの比較、API・フロント双方の実装から学ぶ に引き続き、TypeScriptの記事を書きました。
TypeScriptに苦手意識を持っている人に向けて再び触るきっかけを作りたいと考えて書いたので、細かい言語仕様にはまったく触れてませんが、「ああこれならやってみてもいいな」と思ってもらえたら幸いです。
TypeScriptの型情報を利用したCustom Transformerの現状確認
TypeScriptのコンパイラ・プラグインとして振る舞いASTの操作を行えるcustom transformer (AST preprocessor) が実装されたのは TypeScript 2.4 (2017年) でした。
そのときの様子は次のエントリに非常によくまとまっています。
[TypeScript 2.4] custom transformer を利用して実行時に型情報を参照可能にする - Qiita
さて、現在のTypeScript v3.3 でのTransformer APIの状況はというと、TypeScript compiler本体としては依然としてドキュメントに乗ってない水準の扱いです。特に、コンパイラAPI そのままではtransformerは型情報を利用できません。ts.TransformerFactory
に渡される ts.TransformationContext
に ts.Program
や ts.TypeChecker
が存在しないためです。これは、TypeScriptコンパイラ開発チームによれば意図的な設計ということです。
(あると便利だから再検討してほしい!とコメントはしましたが、望みは薄いでしょう)
つまり、標準的なtransformerができることは、babel pluginとできることに本質的な違いがない、ということで、これではあまり面白くないですね。
しかし先に紹介したエントリではコンパイル時型情報を実行時型情報(=JavaScriptのオブジェクト)に変化しています。コンパイラAPIを駆使すればtransformerが型情報も扱えるということですね。しかしそれはtransformer用にカスタムコンパイラを作ることに等しいため、transformerは開発するのもテストするのも利用するのも難易度が高い状態です。
とはいえ、2019年現在では、有志によって開発されたプラガブルなカスタムコンパイラでtransformerの開発・利用が少しだけ楽になりました。
- ts-loader
- getCustomTransformers はtransformerに
ts.Program
を与えます ts.Program#getTypeChecker()
でts.TypeChecker
にアクセスできるので、これを通じて型情報を利用できます
- getCustomTransformers はtransformerに
- ttypescript (TypeScript with Transformers)
- TypeScript compilerのかわりに利用できるカスタムコンパイラで、tsconfig.jsonにtransformerを設定できます
- 設定できるtransformerは
ts.Program
やts.TypeChecker
を受けとるようにできます
- 設定できるtransformerは
ts-node
は--typescript
オプションでカスタムコンパイラを設定できるため、ts-node --typescript=ttypescript
とすると ts-node でも型情報を利用するtransformerを利用できますts-loader
もtypescript
オプションでttypescript
を利用できます
- TypeScript compilerのかわりに利用できるカスタムコンパイラで、tsconfig.jsonにtransformerを設定できます
このあたりは先のエントリの作者による ts-transform-keys のREADMEに詳しく載っています。
https://github.com/kimamula/ts-transformer-keys
とはいえ、有志によるカスタムコンパイラに依存しすぎるのもあまり望ましくないので、できれば標準のコンパイラAPIとして型情報へのアクセス方法を解放してほしいところです。
Transformerが型情報にアクセスできると何ができるか
ぱっと考えただけですが、次のようなことができます。
まず、TypeScriptの型情報を React の prop types に変換できるはずです。通常であればTypeScriptの型情報はコンパイル時に失われるので次のような typeToPropTypes<T>()
は実装しようがないのですが、transformerがこの関数呼び出しをprop typesに置き換えてしまえばいいというわけです(まったく試していませんが、このアイデアを実装したtransformerはすでにあるようです https://github.com/joelday/ts-proptypes-transformer )。
// これはイメージです interface Props { /* ... */ } cass SomeComponent extends React.Component<Props> { static redonly propTypes = typeToPropTypes<Props>(); }
次に、TypeScriptの型情報をGraphQLのクエリ(クエリフラグメント)に変換できるのではないかと考えています。
// これはイメージです interface Tweet { author: { id }; content: string; tweetedAt: Date; } const GetTweet = gql` query { tweets { edges { // { author { id }; content; tweetedAt } に変換する ...${typeToQueryFragment<Tweet>()} } } } `;
というわけで、TypeScriptの型情報を利用したtransformerを安定して開発・利用できるようになるといろいろ夢がありますね。ただし、繰り返しになりますが、現在はその利用のためにTypeScriptのカスタムコンパイラを作る必要があります。プロダクションに入れる際は自己責任でどうぞ。
Rails.application.credentials.foo は fetch(:foo) にすべき
表題のとおりで、fetch(...)
はエントリが存在しないと例外を投げるため、エントリが存在しないときに nil
を返すアクセサよりもtypoに対して安全です。
こういうのは rubocop-rails でカバーできるといいなと思って起票して、良さそうというコメントはもらったんですが:
https://github.com/rubocop-hq/rubocop-rails/issues/42
いかんせん rubocop-hq/rubocop-rails 自体がまだ開発中なのでいつ使えるかわかりません。
こういうときにquerlyを使うといいですね。
(cf. プロジェクト固有のルールを指定できるLinterであるところのQuerlyがめちゃ便利 - Islands in the byte stream)
というわけでruleはこんな感じです。
rules: - id: kibela.prefer_fetch_for_rails_secrets pattern: subject: "Rails.application.'credentials.'key()" where: key: '/.+/' credentials: '/(?:secrets|credentials)/' message: "`Rails.application.secrets.fetch(...)` を使いましょう。" before: - "Rails.application.secrets.foo" - "Rails.application.credentials.foo" after: - "Rails.application.secrets.fetch(:foo)" - "Rails.application.credentials.fetch(:foo)"