Go Conference 2023 参加レポート

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(Red/Green/Blue/Alpha)に変換して画像の各種処理をしてから再びYCbCrに戻すようにしていたそうです。

GoのimageパッケージのRGBA型では1次元スライスで表現されています。たとえば400*300の画像だと400*300*4=480,000個の要素となります。この画像処理は重たそうに感じます。

ダイアログの検出

ダイアログの検出にはTemplate Matchingという手法を使っているそうです。事前に、グレースケール化を行うと効率が良いと述べていました。画像に含まれている明るさや種類など不要な情報を減らして単一の色にすることで関心事が1/4になるためです。

その後、オブジェクトの検出を行う際に画像の類似度はSAD(Sum of Absolute Difference)という手法で計算したそうです。閾値を使ってさらに色を減らして0と1で二値化するとハミング距離を計算できるようになり、検出処理が効率化できたと述べていました。

ぼかし処理

ぼかし処理の仕組みは上下左右を数pxずつずらしながら平均化することだと述べていました。スマホのカメラに搭載されている手ぶれ補正の逆をやるイメージだそうです。

初期実装でのテスト実行

ここまでの処理速度を考慮しない初期実装でテストを実施すると、30ms以内に処理しないといけないのに580msもかかってしまったそうです。処理ごとに時間を見ていくと内訳は次のとおりで、もっと処理を削る必要がでてきました。

  • 画像の読み込み:13.68ms
  • 検出のための前処理(グレースケール⁠⁠:2.11ms
  • 検出のための前処理(エッジ処理⁠⁠:1.28ms
  • 検出するロジック:38ms
  • ぼかし処理:520ms

並列処理とSIMD

先頭からスライスを読み込んで処理をするのは効率が悪いと考え、SIMDというまとめて処理をできるCPUの機能を使おうと考えたそうです。128bitレジスタであれば32bitずつ4個まとめて処理できると述べていました。

残念ながら標準のGoではSIMDで書けませんが、cgo経由であれば実行できるそうです。グレースケールをSIMDで書き直すと1.329ms→0.127msと10倍くらい高速になったそうですが、書き換えるのがとても大変なようです。

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.Handlecgobytepool
  • 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.Handlerインタフェースを実装し、ServeHTTPメソッドをHTTP routerの構造体に実装すれば良いそうです。

(*http.Server).Serveメソッドでは、1リクエストごとにHTTPハンドラで処理するために、それぞれ個別のゴールーチンを起動しています。複数リクエストに対しては、各リクエストに対応するハンドラによる処理が行われます。

複数ハンドラから共通の機能を参照したい時、ゴールーチンごとに必要なメモリをアロケートするのは無駄があり、高速化のためにプールしたいと述べていました。

プールを実現するための候補として、次の特徴があるsync.Pool型を紹介しました。

  1. スレッドセーフである
  2. sync.Pool型はGCによって削除される可能性がある
    • > Any item stored in the Pool may be removed automatically at any time without notification.
  3. プールが足りない場合は都度アロケートされる

HTTP routerでは複数のゴールーチンからアクセスされるため、①を満たす必要があります。また、リクエストごとに送られてくる異なる情報を効率良くプールするためには②と③も求められます。つまり、HTTP routerの実装にsync.Pool型を用いることは相性が良いそうです。

実際に大きくない構造体でベンチマークを取った時、sync.Pool型を使うことで次の結果が得られたそうです。

  • アロケーションの回数は1/2に
  • メモリのアロケーションの容量(B/op)は1/5に
  • 速度は1.3倍程度早くなる

高機能になるとレスポンスごとに使い回せるコンテキストの構造体が大きくなるため、さらに恩恵が大きくなるであろうと説明していました。

HTTP router内部で高速に文字列処理を扱う方法

HTTP router内部で高速に文字列処理を扱うために、次の方法を挙げていました。

  • 次のノードの探索はインデックスを使って検索する
    • 有名なWebフレームワークであるEchoGinでは頭文字をインデックスとして扱っている
  • 文字列の抽出はスライスのインデックスを利用する
    • ただし標準パッケージの最適化によって変わる可能性はある
  • データ構造の長所や短所を考慮しながら実装する
    • Trie Treeはバックトラックに弱い
    • Radix Treeは実装が複雑になる

EchoやGinで工夫されているところ

EchoやGinで高速に処理するために、次の工夫を挙げていました。

  • sync.Pool型で必要になる分は予めアロケートしておく
  • パスパラメータは独自のコンテキストで持つ
  • 関数呼び出しを減らす
    • Echo:ルーティングの一部でgoto文が利用されている
    • Gin:ルーティングアルゴリズムは大きなトランザクションスクリプトで実装する

備考

登壇内容から少し話は逸れますが、標準ライブラリのhttp.ServeMuxにおいてもルーティングの機能を強化しようという議論が始まっているようです

@convtoさん「Go1.20からサポートされるtree構造のerrの紹介と⁠treeを考慮した複数マッチができるライブラリを作った話」

@convtoさんのセッションでは、Go1.20で導入されたerrors.Join関数によって形成されるerror型のツリー構造ついて紹介がありました。

ツリー構造を形成するエラー

ラップされたエラー同士の関係は、Go1.19までだと連結リスト的に表現でき、枝分かれがありませんでした。Go1.20でerrors.Join関数が導入され、複数のエラーを1つにまとめる機能がサポートされました。

errors.Join関数によって、エラー同士の関係はツリー構造で表現する必要がでてきました。

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.Join関数導入時の機能提案プロポーザルの概要を次のようにまとめていました。

  • 複数のエラーを1つのエラーにしたい
  • errors.Join関数やfmt.Errorf関数で複数エラーの結合が可能に
  • 結合されたエラーはUnwrap() []errorを実装していて取り出し可能
  • 当初はSplit(error) []error関数も提案されていた(後の議論でスコープ外に)

また、Go1.20以降におけるerrors.Is/As関数の探索について次のようにまとめていました。

  • ツリーを深さ優先探索する
  • マッチしたものがあればそこで探索を打ち切り結果を返す
  • ツリー上の全ての枝がIs/As関数にマッチすることを保証しない
  • 当時存在したエコシステム上のuberのmultierrライブラリの一般的な挙動などを参考にして設定する

複雑な探査処理を行うライブラリの作成

エラー同士の関係を表すデータ構造が複雑になったため、標準ライブラリで提供しているerrors.Is/As関数のようなシンプルな探査処理では物足りないケースがあると述べていました。

探査方法やエラーのマッチング処理はerrorsパッケージの内部実装に依存します。セッションでは、次のような実装が可能か検証していました。

  • より正確なIs関数:すべての分岐にエラーがある場合にtrueを返す。
  • 全件抽出するAs関数:ヒットした全てのエラーを返す。

これらの実装はライブラリとして公開されています。今後エコシステム全体で実験を重ねていけば、よりよい形が見えてくるのではと説明していました。

@k1LoWさん「net/http/httptest.Server のアプローチをテスト戦略に活用する」

@k1LoWさんによるセッションでは、net/http/httptest.Server型をテスト戦略に活用するための話がありました。

テストサイズ分類におけるnet/http/httptest.Server型のメリット

テストサイズとは書籍『Googleのソフトウェアエンジニアリング』によると、テストの実行速度と決定性に着目したテスト分類方法とされているそうです。具体的には次のように分類されます。

  • Smallテスト:単一プロセス内で実行されなければならない。スリープ禁止。I/O禁止。ブロック禁止。
  • Mediumテスト:単一マシン内で実行されなければならない。localhost以外のシステムへのネットワーク呼び出し禁止。
  • Largeテスト:複数マシンにまたがるテスト。

ここで、Mediumテストに分類されたテスト(以下、単にMediumテストと記述)を行うことを考えてみます。その際に、Dockerなど別プロセスでHTTPサーバーを起動する場合に比べて、net/http/httptest.Server型を使用すると、構成要素の管理をGoのランタイムに任せられるメリットがあると主張しました。

具体的には、同一のランタイム上で実行すると値の相互受け渡しなどの連携が容易に実現でき、テストダブル(モック、スタブ、スパイなど)の実装がしやすいそうです。

ゴールーチンベースの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サーバーを起動できる
  • 受信したメールを確認できる
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(仮)も予定されているらしいので、そちらの開催動向にも注目していきたいです。

おすすめ記事

記事・ニュース一覧