2023年6月2日にオンラインで開催されたGo Conference 2023に参加してきました。今回はreBakoというバーチャル空間で開催されました。この記事では、全32のセッションのなかから、4つのセッションを取り上げてレポートします。
なお、Go Conferenceは一般社団法人Gophers Japanが年に1回開催しているプログラミング言語Goに関するカンファレンスで、今回で10周年を迎えました。
バーチャル空間に設置されたスポンサーやコミュニティのブース
セッションのレポートに入る前に、バーチャル空間に設置されたブースについて触れておきます。
設置された多くのスポンサー企業のブースには、イベント参加者が自由に入って会話できる形になっていました。各社のブース内容も、CTFに挑戦できたりエキスパートなエンジニアの会話が聞けたりと、工夫が凝らされていました。筆者が所属するナレッジワークでも_YOUR WORKというGoエンジニア向けの新規プロダクト
また、Goに関する問題に回答して景品がもらえるビンGoカードというアトラクションもありました。
@octu0さん「Go/Cgoで映像・音声のリアルタイム処理をやるまでの道のり」
@octu0さんによるセッションは、スマホのポップアップ通知にリアルタイムでぼかし処理を入れるまでの道のりをストーリー仕立てで語っていくものでした。
課題と背景
はじめに要件として、スマホを使ったライブ配信中に通知ダイアログが映り込んで雰囲気が壊れないようにするために、リアルタイムで通知ダイアログを検出して中身をぼかしたいことがあったそうです。
また背景として、次のような説明がありました。
- スマホ端末上でぼかし処理を行いながら配信する実装はすでにあったそうですが、ゲーム中に配信すると端末が高負荷になりがちなので、配信サーバー上で処理をしたかった。
- 配信サーバーは調整のしやすさから汎用的なサーバーを選択しているため、GPUを使わずにCPUだけで画像処理を行いたかった。
一般的な画像形式のおさらい
スマホの映像は30fpsから60fps程度のパラパラ漫画を見ているようなものだそうです。そのため、オブジェクトを検出してぼかし処理を入れるまで30ms程度に収める必要があると述べていました。
実際の映像処理では輝度と色差信号で表現されたYCbCrが使用されるそうですが、当時は直感的に扱い辛いため一度RGBA
GoのimageパッケージのRGBA型では1次元スライスで表現されています。たとえば400*300の画像だと400*300*4=480,000個の要素となります。この画像処理は重たそうに感じます。
ダイアログの検出
ダイアログの検出にはTemplate Matchingという手法を使っているそうです。事前に、グレースケール化を行うと効率が良いと述べていました。画像に含まれている明るさや種類など不要な情報を減らして単一の色にすることで関心事が1/
その後、オブジェクトの検出を行う際に画像の類似度はSAD
ぼかし処理
ぼかし処理の仕組みは上下左右を数pxずつずらしながら平均化することだと述べていました。スマホのカメラに搭載されている手ぶれ補正の逆をやるイメージだそうです。
初期実装でのテスト実行
ここまでの処理速度を考慮しない初期実装でテストを実施すると、30ms以内に処理しないといけないのに580msもかかってしまったそうです。処理ごとに時間を見ていくと内訳は次のとおりで、もっと処理を削る必要がでてきました。
- 画像の読み込み:13.
68ms - 検出のための前処理
(グレースケール):2. 11ms - 検出のための前処理
(エッジ処理):1. 28ms - 検出するロジック:38ms
- ぼかし処理:520ms
並列処理とSIMD
先頭からスライスを読み込んで処理をするのは効率が悪いと考え、SIMDというまとめて処理をできるCPUの機能を使おうと考えたそうです。128bitレジスタであれば32bitずつ4個まとめて処理できると述べていました。
残念ながら標準のGoではSIMDで書けませんが、cgo経由であれば実行できるそうです。グレースケールをSIMDで書き直すと1.
libyuv
さらに調べていくとlibyuvというライブラリを発見し、やりたいことがほとんど実装されていたようです。libyuvに置き換えることで、下記のように580ms→47msと12倍くらい改善したと述べていました。
- 画像の読み込み → I420ToARGB (13.
68ms→0. 14ms) - グレースケール → ARGBGrayTo (2.
11ms→0. 18ms) - エッジ処理 → ARGBSobel (1.
28ms→0. 29ms) - 検出ロジック → 自前 (38ms)
- ぼかし処理 → ARGBBlur (520ms→8.
49ms)
Halide言語
一方で、スマホを回転すると画像全体の二次元配列も入れ替えないといけないなど、libyuvでは解決できないスマホ特有の課題もありました。そこで、さらにいろいろと探したところ、Halide言語に出会ったそうです。
Halide言語は、次の特徴を持っていました。
- C++のDSL
- 画像処理に特化コード
- 最適化されたコードを静的ライブラリとして出力できるためGoに組み込める
- アルゴリズムとスケジューラを分離することがきてチューニングしやすい
Halide言語で書き直した結果下記のように高速化できたそうです。
- 画像の読み込み → YCbCrのまま処理するようにした
(13. 68ms→0. 14ms) - グレースケール → Halide化
(2. 11ms→0. 36ms) - エッジ処理 → グレースケール時にまとめた
(1. 28ms→0) - 検出ロジック → Halide化
(38ms→0. 28ms) - ぼかし処理 → ARGBBlur
(520ms→0. 11ms)
1画像1ms未満で処理できるようになり、これでリアルタイム処理が可能になったそうです。
作成したライブラリ
今回のリアルタイム処理において、登壇者が作成して紹介していたライブラリは次のとおりです。
- Halideで作った画像処理ライブラリ
「blurry」 - Leaky bufferライブラリ
「bp」 - cgo↔goの変換やcgo.
Handle 「cgobytepool」 - go generateを使ったHalide連携
「example-halide-go」
@N9tE9さん「EchoやGinはなぜ速いのか?Goで高速なHTTP routerを作るコツ」
@N9tE9さんのセッションでは、高速なHTTP routerを作るコツを取り上げました。
HTTP router
このセッションにおいて、HTTP routerはリクエストされたURLを解析してHTTPハンドラを決定する機能として扱います。その際、HTTP routerでは次の2つのルーティングがあります。
- 静的ルーティング
( /health
などURLのパスが変わらないもの) - パスパラメータルーティング
( /:user
のような動的に値が変わるもの)
このときHTTP routerの実装は、Trie TreeやRadix Treeといった木構造をベースにしていることが多いと言います。たとえば/health
というパスでリクエストが来た場合は、木構造から対応するハンドラを返すそうです。
sync.Pool型
GoでHTTP routerを実装するにはリクエスト→レスポンスを制御する箇所でルーティングのロジックを呼び出す必要があると述べていました。具体的には、http.
(*http.
メソッドでは、1リクエストごとにHTTPハンドラで処理するために、それぞれ個別のゴールーチンを起動しています。複数リクエストに対しては、各リクエストに対応するハンドラによる処理が行われます。
複数ハンドラから共通の機能を参照したい時、ゴールーチンごとに必要なメモリをアロケートするのは無駄があり、高速化のためにプールしたいと述べていました。
プールを実現するための候補として、次の特徴があるsync.
- ① スレッドセーフである
- ② sync.
Pool型はGCによって削除される可能性がある - > Any item stored in the Pool may be removed automatically at any time without notification.
- ③ プールが足りない場合は都度アロケートされる
HTTP routerでは複数のゴールーチンからアクセスされるため、①を満たす必要があります。また、リクエストごとに送られてくる異なる情報を効率良くプールするためには②と③も求められます。つまり、HTTP routerの実装にsync.
実際に大きくない構造体でベンチマークを取った時、sync.
- アロケーションの回数は1/
2に - メモリのアロケーションの容量
(B/ op) は1/ 5に - 速度は1.
3倍程度早くなる
高機能になるとレスポンスごとに使い回せるコンテキストの構造体が大きくなるため、さらに恩恵が大きくなるであろうと説明していました。
HTTP router内部で高速に文字列処理を扱う方法
HTTP router内部で高速に文字列処理を扱うために、次の方法を挙げていました。
- 次のノードの探索はインデックスを使って検索する
- 文字列の抽出はスライスのインデックスを利用する
- ただし標準パッケージの最適化によって変わる可能性はある
- データ構造の長所や短所を考慮しながら実装する
- Trie Treeはバックトラックに弱い
- Radix Treeは実装が複雑になる
EchoやGinで工夫されているところ
EchoやGinで高速に処理するために、次の工夫を挙げていました。
- sync.
Pool型で必要になる分は予めアロケートしておく - パスパラメータは独自のコンテキストで持つ
- 関数呼び出しを減らす
- Echo:ルーティングの一部でgoto文が利用されている
- Gin:ルーティングアルゴリズムは大きなトランザクションスクリプトで実装する
備考
登壇内容から少し話は逸れますが、標準ライブラリのhttp.
@convtoさん「Go1.20からサポートされるtree構造のerrの紹介と、treeを考慮した複数マッチができるライブラリを作った話」
@convtoさんのセッションでは、Go1.errors.
関数によって形成されるerror型のツリー構造ついて紹介がありました。
ツリー構造を形成するエラー
ラップされたエラー同士の関係は、Go1.errors.
関数が導入され、複数のエラーを1つにまとめる機能がサポートされました。
errors.
関数によって、エラー同士の関係はツリー構造で表現する必要がでてきました。
package main
import (
"errors"
"fmt"
)
func main() {
errA := errors.New("error a")
errB := errors.New("error b")
errC := errors.New("error c")
errAB := errors.Join(errA, errB)
errABC := errors.Join(errAB, errC)
fmt.Println(errors.Is(errABC, errA))
// Output: true
}
Go1.20で導入されたツリー構造の探査方法
セッションでは、errors.
関数導入時の機能提案
- 複数のエラーを1つのエラーにしたい
errors.
関数やJoin fmt.
関数で複数エラーの結合が可能にErrorf - 結合されたエラーは
Unwrap() []error
を実装していて取り出し可能 - 当初は
Split(error) []error
関数も提案されていた(後の議論でスコープ外に)
また、Go1.Is/
関数の探索について次のようにまとめていました。
- ツリーを深さ優先探索する
- マッチしたものがあればそこで探索を打ち切り結果を返す
- ツリー上の全ての枝が
Is/
関数にマッチすることを保証しないAs - 当時存在したエコシステム上のuberのmultierrライブラリの一般的な挙動などを参考にして設定する
複雑な探査処理を行うライブラリの作成
エラー同士の関係を表すデータ構造が複雑になったため、標準ライブラリで提供しているerrors.
関数のようなシンプルな探査処理では物足りないケースがあると述べていました。
探査方法やエラーのマッチング処理はerrorsパッケージの内部実装に依存します。セッションでは、次のような実装が可能か検証していました。
- より正確なIs関数:すべての分岐にエラーがある場合にtrueを返す。
- 全件抽出するAs関数:ヒットした全てのエラーを返す。
これらの実装はライブラリとして公開されています。今後エコシステム全体で実験を重ねていけば、よりよい形が見えてくるのではと説明していました。
@k1LoWさん「net/http/httptest.Server のアプローチをテスト戦略に活用する」
@k1LoWさんによるセッションでは、net/
テストサイズ分類におけるnet/http/httptest.Server型のメリット
テストサイズとは書籍
- Smallテスト:単一プロセス内で実行されなければならない。スリープ禁止。I/
O禁止。ブロック禁止。 - Mediumテスト:単一マシン内で実行されなければならない。localhost以外のシステムへのネットワーク呼び出し禁止。
- Largeテスト:複数マシンにまたがるテスト。
ここで、Mediumテストに分類されたテスト
具体的には、同一のランタイム上で実行すると値の相互受け渡しなどの連携が容易に実現でき、テストダブル
ゴールーチンベースのMediumテストを広く活用するテスト戦略
開発において、自動テストを含むテストスイートの用意は早ければ早いほど得られる効果が大きいと述べています。しかし、開発初期はアプリケーションのアーキテクチャは完成されてなく、大幅な変更の余地が残っているためSmallテストは書き辛いそうです。
それゆえ、開発初期の有効なテスト戦略として、外側からのMediumテストを厚くすることで内部のアーキテクチャ変更に強くすることを主張していました。
ゴールーチンベースのMediumテストの作成をサポートする
ゴールーチンベースのMediumテストの作成をサポートするライブラリとして、@k1LoWさんは次の4つを開発したそうです。
- httpstub
-
- 任意のレスポンスを返すHTTPサーバーを簡単に組み立てて起動できる
- OpenAPI Spec v3のDocumentを読み込んでリクエストとレスポンスのバリデーションができる
- OpenAPI Spec v3のDocumentのexamplesセクションの値を使ってレスポンスを返せる
- grpcstub
-
- 任意のレスポンスを返すgRPCサーバーを簡単に組み立てて起動できる
- protoファイルの定義を使用してリクエストとレスポンスのバリデーションができる
- protoファイルの定義を使用して動的にレスポンスを組み立てて返せる
- smtptest
-
- net/
http/ httptest. Serverと同じような使い勝手でSMTPサーバーを起動できる - 受信したメールを確認できる
- net/
- runn
-
- シナリオをYAMLで書いてそれをもとに操作を自動化できる
- HTTP、gRPC、Database
(SQL)、Chrome DevTools Protocol、任意コマンドの実行 (ローカル/SSH) などに対応
実践して感じるメリットとデメリット
こうして作られたテストについて、そのメリットを次のように挙げていました。
- 一般的なMediumテストと比べると、同じGoのコードベースに閉じており認知負荷が小さく感じる
- 「クライアントをモックするテスト」
よりも、本来の挙動に近い 「サーバ-クライアントで実際に通信するテスト」 の方が直感的かもしれない - 1シナリオあたりのカバレッジが大きく、結果リファクタリングへの耐性が大きくなっている
- シナリオの拡充もYAMLを書くだけで良い。一度組み込んでしまえば、あとはYAMLを書くだけになる
- シナリオがOpenAPI Specライクであることからエンジニアにとって読みやすく、そのままオンボーディングに役に立った
また、デメリットを次のように挙げていました。
- MediumテストはSmallテストと比べると実行時間が長い。Mediumテストが容易に拡充できるようになった結果、全体のテスト実行時間が大きくなりがち
- アプリケーション外部に気軽にスタブサーバーを立ててテストができるようになることで、アプリケーション自体のテスト容易性が下がりがち
おわりに
この記事では4つのセッションをレポートしましたが、紹介できなかった発表についてはConnpass上にまとめられている一部の発表資料や、YouTubeに公開されているセッションごとに録画をご覧ください。
筆者は初めてのGo Conferenceの参加でしたが、多くのセッションを聞き多くのブースに参加することで、企業の開発に関するリアルな話など聞けたり、他の参加者と交流できたりしました。楽しみながらGoに関する学びを深められたように思います。
今回の経験によって、Goが多様な開発で使われていることを体感でき、Goコミュニティの盛り上がりを感じました。来年もぜひ参加したいです。
冬にはGo Conference mini 2023 Winter in Kyoto(仮)も予定されているらしいので、そちらの開催動向にも注目していきたいです。