2024年9月29日日曜日

IkaLogの開発で得た知見: ニューラルネットワークを用いたブキ分類器の思想と実装

以前IkaLogの開発で得た知見:大量の動画や画像を取り扱う際の Tips にて、 IkaLog が使用していたブキ認識において、いかに実ユーザ環境からの送信データを活用しながらいかに効率的に教師データを揃えることができたかを紹介しました。

最近は LLM や生成 AI などが話題をもっていきがちですが、独自のモデルを構築してアプリケーションに組み込もうというアイデアで取り組まれている方も増えているのではないでしょうか。

私自身が取り組んだのは 2015-2017年頭の話なので昔話でしかないとはいえ、もしかしたら新規アプリケーションを作られる方の参考になるかもしれない?と思ったので、当時どのような思考で作業をしていたか、もう少しまとめてみる事にしました。

今回は、 IkaLog のブキ認識において、最終的にシンプルなニューラルネットワークに行きついたかについて解説します。

バックグラウンド: IkaLog で目指した性能と、当時のハードウェア制約

精度99%では満足できなかった理由

スプラトゥーンでは、合計8名のプレイヤーがふたつのチームに分かれて勝敗を競います。このときひとりひとりのプレイヤーが100クラス以上のブキのなかからひとつ選んで勝負に挑みます。


ブキにはそれぞれシューター/ローラー/ブラスターなどのクラスの特徴があるほか、攻撃力や距離、連射力で差別化がされています。また、ブキには戦局を有利に進めたり、不利な場面を打開するために活用できるサブウェポンやスペシャルウェポンが用意されています。

IkaLog で処理される勝敗データでは、各プレイヤーがどのメインブキを使用しているか、またサブウェポンやスペシャルウェポンが何であるかを正しく記録することが、戦績データを扱うためのしくみとして重要だと考えていました。

※ この統計自体には IkaLog は使われていません


先述のとおり、一回の対戦において8名のプレイヤーがゲームに参加します。これは 99% の分類器があったとしても、対戦で使われたブキすべてが正しく分類できる確率は 0.99^8 = 92.2% にしかなりません。このため IkaLog ではブキ分類器で 99.9% 以上の精度が必要であろうと考えていました。

スプラトゥーンのリザルト画面からのブキ認識は今のAIの仕組みから考えれば、とても簡単な仕組みです。画面の同じ場所に同じ画像が表示されるだけ。ただ、バグありのビデオキャプチャデバイス、HDMIでさりげなく入るノイズ、ユーザーのセットアップなどの理由で、ソフトウェア観点からみると「同じ画像が毎回表示される」前提ではブキ分類器を実現できませんでした。


当時のハードウェア事情

また、 IkaLog を開発していた 2015 年頃はまだゲーミングPCらしくゲーミングPCを一般的なプレイヤー層は持っていることは想定できませんでした。

ましてや Wii U でスプラトゥーンをプレイしているユーザがとなりに AI で十分な性能を発揮できるようなハードウェアを持っておらず、ゲームをしている人が最新CPU搭載のコンピュータを所有しているかも怪しいです(今でも多くのスプラトゥーンプレイヤーでも余裕のあるPCを持っておらず、むしろスマホのほうが性能が高い可能性すらあるでしょう)。当時の事情でいえば、x86 CPUでの演算において SSE4.2 程度の命令セットしか期待できなかった時期での取り組みでした。私自身が認識できていたユーザには Core 2 Duo で IkaLog を常用している方もいらっしゃいました。

IkaLog がリアルタイムで画像認識をするというコンセプトであったため、当時の記録によると、8キャラクタ分のブキ分類を含めてリザルト画面の解析をおよそ3秒以内で実現できることをひとつの目安としていたようです。


IkaLogの開発においてたどった当初の分類器のしくみや遷移

K近傍法

IkaLog では、当初は OpenCV の K近傍法 の実装を用いて、入力画像に対してもっとも特徴量ベクトルが近い近傍があるクラスに分類するアプローチで実装していました。

開発開始当初はブキ数が30程度だったかと思いますが、アップデート終了までにクラス数が100を超えるほどに増えました。結果論ですが、単純なテンプレートマッチングやSVMなどといったアプローチをとっていたら、実行時間の観点から、より苦労していたと思います(スプラトゥーン2向けの実装では数字などのキャラクタ認識でSVMを利用することも検討し取り組みましたが、このワークロードではKNNほどコスパよくありませんでした)。

KNN の実装は当時の CPU でアイコン程度を分類するのは十分に高速だったほか、分類器の訓練も高速でしたので、開発初期からこのアプローチにたどり着いていたことはとても助かりました。データ量が少ない状態から半自動的に訓練データを集めようとしたときに、いまでもとりあえず使うことが多いです。


色相のヒストグラム

ブキの分類器を作り始めた当初、最初に実装したものは色相のヒストグラムからパターンを見つけて分類が可能かどうかを試していました。


ただ、スプラトゥーンのリザルト画面では、所属チームによりブキ画像の背景にチーム色が映り込み、場合によってはプレイヤーが選んだ装備品もブキに重なります。プレイヤーのキャプチャーデバイスの設定などにも依存することもあり、あまり実用的な精度は出せませんでした。

ラプラシアンフィルタ

輪郭抽出は古典的な画像の分類アプローチでよく使われる手法の一つかと思います。IkaLogでもラプラシアンフィルタを適用し、カラー画像から次元数を削減した特徴量を生成し、ここから分類を行う方法を試していました。一時期のバージョンの IkaLog では実際にこのアプローチで提供していたと思います。

IkaLogのブキ分類のワークロードにおいてラプラシアンフィルタはチームの背景色の影響を排除し、ブキの形状に基づいて分類するには良い方法に思えます。しかし、ユーザの映像キャプチャ環境において元画像が 720/1080p だったり、それを480pにリスケールした映像が投入されたりといったかたちで想定外の入力がされると精度を保てないという問題が生じました。

最終的なチャレンジはスプラトゥーンのアップデートそのものでも発生しました。スプラトゥーンではメインブキに対して、サブウェポン・スペシャルウェポン違いのバージョンとして「カスタム」「コラボ」といった亜種が登場します。これらの亜種ではブキ画像の右下に小さな追加マークが表示されるのですが、ラプラシアンフィルタを介して色相情報を落とした状態でこれらの特徴を合理的に見分けることはできませんでした。



ニューラルネットワーク導入の決断

IkaLog を作り始めて自然と機械学習的アプローチに関わるようになっていたことから、 Cousera の Andrew Ng 先生のコースなども一通り修了していた頃に「もうニューラルネットワークにカラー画像をそのまま入力したほうがいいんじゃないか」考えるようになってきていました。

とはいえ、AlexNet などの既存のニューラルネットワークは100MB以上の重みデータがありますが、さすがにこれは過剰ですし、ユーザがそんなもので推論できるようなプロセッサを持っていませんし、CUDAをユーザのプロセッサで実行できるわけでもありません。このため IkaLog で目的に合わせたニューラルネットワークを実装することを考え始めました。

IkaLog のブキ分類においてニューラルネットワークに画像をそのまま入力することによる一つのメリットは、ニューラルネットワークであれば背景色などを無視できることがあります。チームカラーによって何色になるかわからないようなピクセルに基づく入力値は、結果的に無視されるようになります。ブキの形状によって適切な重みが自動的に形成されることを想定できたので、おそらく簡単にうまくいくだろうと思いました。


HSV色空間

RGBとHSVでしっかり比較したわけではないのですが、ブキ分類器での分類対象ではHSV色空間で取り扱ったほうがよいだろうと判断したので、何も考えずにHSV色空間を特徴量として使用しています。

HSV色空間を利用しようと思った最大の理由は、ブキの「カスタム」「コラボ」といった亜種の特徴を表現する色相がピクセルあたりひとつのパラメータで表現されることになるので、おそらくRGB色空間で扱われるよりもいいだろう、ぐらいにしか考えていませんでした。背景の色相を無視するという観点でも重みが小さくなることで簡単に表現できるでしょう。ここについては「こうなってくれたらいいな」という思想でしかなくて、現時点でもそう思っているだけで、これによる差があったかどうかは何も検証していません。


ネットワーク構成をシンプルに

使用するレイヤとしては単純な全結合とReLUに制約することにしました。理由はふたつあり、一つ目は計算量、二つ目は再実装のしやすさです。

計算量の観点では、畳み込みフィルタなども考えましたが、当時 MacBook Pro (2014) とその上の GeForce チップ、また Haswell Refresh プロセッサで走るニューラルネットワークの速度を見ていると、 CNN やプーリングをエンドユーザのプロセッサで実行させることは現実的ではないだろうと感じていました。単純な全結合と ReLU 程度であれば、当時で型落ちとなっていたプロセッサ上でも NumPy やその下位のライブラリが現実的なスピードで動いてくれるだろうと期待しました。

再実装の観点では IkaLog に取り組んでいた当時でニューラルネットワークを動かそうと思うと、 Caffe を使うとか、もしくは Chainer を使うとか、そういったいくつかのフレームワークを利用する方法でした。 ONNX ランタイムみたいなものはまだ出てきておらず、想定するユーザ層が中学生・高校生・大学生や社会人で、主に Windows ユーザであろうことを考えると、既存のフレームワークを IkaLog のためにセットアップさせるのは不可能でしょう。

IkaLog は zip ファイルを展開して実行すれば使える状態の配布形態を維持することを心がけていたので、ブキの分類器のためにフレームワークへ依存を追加することにためらいました。このため、シンプルなネットワーク構成とすることで、IkaLog用に推論コードを作成するコストを最低限に抑えることにしました。


実装を進める前の事前確認を Azure Machine Learning で実行

それまでの取り組みである程度のデータ量は確保できていたので、まずは手元のデータセットを用いて最低限の作業でアプローチを検証するため、 Azure Machine Learning に想定する特徴量をアップロードして、 MLPで期待するようなモデルが実現するのかを確認しました。

Azure ML はこの程度のワークロードであればファイルをアップロードしポチポチするだけでいいですし、 confusion matrix などもさくっと出してくれるので、アプローチ上問題がないことを簡単に確認できましたしコードを具体的に書く前に最低限の労力で検証できたことはとても助かりました。なおこの Azure ML の体験談は 2016 年頃当時の話であることに留意してください。





学習済みモデルのインポート、推論

実際の学習は Chainer と GeForce GTX 1080 (後半は Tesla P100)で行いました。そもそもどれぐらいのノード数で性能が飽和するかを Chainer 上で検証し、ネットワークの隠れ層のサイズを決めました。

本番の学習は 24 時間などのオーダーで1000エポック以上回したような覚えがあります。 Chainer のチェックポイントとして得られたものをいくつか評価して使用するモデルをきめました。

Chainer フレームワークからネットワークの重み・バイアスを取り出して、単純な NumPy コード上で推論できることを確認できたので、モデルをただのマトリックスとして pickle してファイルに保存、そこからモデルを復元・推論することで、機械学習フレームワークへの依存を断ち切りました。

実際に生成できた実行用モデルファイルをみてみると15MBほどになっていました。この中には32ビット浮動小数点数が並んでおり、zipなどでの圧縮効果がほとんどありません。配布ファイルが大きくなることを嫌って、pickcleする際に16ビット浮動小数点数として扱うことによってファイルサイズを半分に抑えることにしました。このワークロードとモデルにおいて浮動小数点数のビット数を抑えても実用上の影響はほとんど感じられません。最終的に戦績共有サイト stat.ink に投稿されるブキ画像を 99.99% で分類できる精度が得られました。


推論の実装

先述の理由で、 IkaLog ではフルスクラッチかつ最低限のコード量で推論を再実装しました。ここで実装した内容は、のちに発売されるオライリーの「ゼロから作る Deep Learning」の最初の100ページで解説された内容そのものともいえるかと思います。

このブキ分類器は、すでに型落ちとなっていた IvyBridge 2.0 の MacBook Air でも 0.02 秒で実行できました。Core 2 Duo などでも十分な速度で動きましたし、 PYNQ (ARMコア搭載 FPGA)でも1回あたり200ms未満の実行速度で収まりました。

FPGAならPLで実行すりゃもっといけるだろとかそういうツッコミはいくらでも可能かと思いますが、技術的には可能ですが、非営利の独りプロジェクトでここまでやれば十分かなと思っています。


おまけ

当時やってみたかったこと

ネットワークの蒸留や枝切りをしてより配布ファイルのサイズを小さくできるのではないかと考えていましたが、着手しませんでした。

単に手が回っていなかったほか、ネットワークの規模が小さくなったときに、どのクラスにも該当しない画像を特定のクラスに分類してしまう可能性などを恐れていたように思います(実際取り組んだらどうなったかはわかっていません)。


最近のエコシステムに思うこと

ここまでの内容を2017年以前に取り組んだ後、こんなフレームワーク便利だなと思ったものが幾つかでてきました(現時点の選択肢として筋がいいと言いたいわけではありません)。

  • ONNX Runtime の登場によりホストプログラムが雑に使える推論ライブラリが出てきたという印象
  • Intel が OpenVINO や Movidius VPU を出してきて、 Windows PC でハードウェア支援が期待できるようになった。最悪 AMD CPU でも SSE 相当で動くっぽい
  • OpenCV に DNN に対する推論機能が強化されており、配布方法を工夫すれば GPU アクセラレーションなどをホストプログラムから呼び出せるかもしれない。

さらに、2017年の iPhone X から Neural Engine が搭載され、 Android にも同じように推論エンジンがハードウェアとして搭載されるようになりました。 Mac であれば M1 から、 Windows でも Copilot PC が出てきました。

ようやく OS レベルの推論の抽象化が進んできた

ライブラリの観点では、Windows であれば DirectML 、 Apple であれば CoreML 、Androidであれば MLKit などが普通に使えるようになってきて、私が IkaLog を作るときに困った「推論のための仕組みがない。ターゲットごとに実装してられない」という状況に大きな変化が起きているように感じています。特に個人的には DirectML は(ごくたまにしか試してませんが) Windows 環境において OS が推論ワークロードをハードウェアで実行してくれるという、まさに OS らしい抽象化をしてくれるようになりました。

私自身はふだんほとんどプログラムを書かないのですが、ローカルで推論をするアプリケーションの開発難易度は10年前から比べると大きく下がってきたなと感じています。

ローカル LLM の話題などもみかけますが、 Copilot PC の話題などをみつつ 2024 年は、ローカルで推論をするタイプのアプリケーションの開発が加速する年になるだろうなと思っています。2025 年になるとウイルス対策ソフトすら推論用アクセラレータにオフロードするような世界がくるのかもしれませんね。

2024年3月9日土曜日

BOSE SoundBar 900 のメンテナンスと覚え書き

暫く iOS アプリなどから連携不可能な状態になっていた BOSE SoundBar 900 を復旧したので覚え書き。まとまった情報がなかったため理解するまでにかなりの時間を要しました……。


問題

私が対応した個体では下記の問題がありました。

  • ルータの設定上の問題か、 BOSE 社サイトへの通信が通らずアクティベーションできない (iOSアプリにて連携できない)
    • ネットワーク構成の問題かもしれないが、一般的な IPv4 通信はできる環境のはずで、なぜうまくいかないのかわからない
  • アクティベーションされた際、最新ファームウェアへのアップデートが始まるが、完了しない
これに対しての対応方針をかきのように定めました。
  • シンプルなネットワーク上にサウンドバー本体を有線LAN接続しアクティベーションを完了させる
    • 現地のネットワーク構成に依存した問題が発生しているように見える
    • ファクトリーリセット状態からのアクティベーションは LTE ルータをゲートウェイとしたシンプルなネットワークを使用する
  • ファームウェアアップデートをアクティベーション前に済ませる
    • 600MB近いファームウェアのダウンロードを繰り返しさせたくない(遅い)
    • 確実にファームウェアを更新完了した状態でアクティエーションすることで失敗要素を減らす

この問題を解決するための手順は、最終的に、下記のとおりになりました。
  • DHCPでアドレス取得ができるネットワークに有線で接続
  • ファクトリーリセットのため Bluetooth + 早送り> ボタンを長押しする
  • BOSE社サイトからファームウェアファイル product_update.zip をダウンロードする
  • SoundBar 900 が掴んでいる IP アドレスを見つける
    • 今回はルータの DHCP リース状況からIPアドレスを特定
    • BOSE社サイトで案内されているMACの prefix がアテにならない
  • http://x.x.x.x:17008/update.html にアクセスする
  • 表示されたファームウェアアップロード画面にて product_update.zip をアップロード
    • ファームウェアアップロード画面をリロードしてファームウェアバージョンが上がっても我慢
    • 白いダッシュ(−)LEDが点滅しているうちはFWアップデートが続いているので1~2時間かかるつもりで放置
    • サウンドバー本体からファクトリーリセット時の起動音がしたらアップデート完了
  • DHCPでアドレス取得ができ、素直に通信できるインターネット環境に接続
    • 今回はLTE無線&有線ルータを上流として使用
    • SoundBar 900 を同ルータに接続して有線 DHCP でアドレス取得した状態とする
    • iOS端末を同ルータのL2セグメントに接続
  • iOS端末にて SoundBar 900 を登録、アクティベート
  • 本来のネットワークにイーサネットで接続し DHCP でアドレスを取得しなおす
    • 有線 -> 有線であれば WiFi のパラメータが絡まず、上流を置き換えても問題がおきない模様
普通の人に「これやりなよ」とカジュアルに言えるかというと、かなり微妙です。
知人が同じ問題に悩んでいても、面倒なので、教えてあげたり手伝ってあげたりする気には、正直なりません。
このようにファームウェアの挙動が常に判りづらく、デジタルガジェット的観点な感想として、かなり低めの評価になります。


ファームウェアアップデートについて

事前にファームウェアをアップデートする理由は、アクティベート成功後、自動的にファームウェアアップデートが開始されますが、iOS上でファームウェアアップデートが開始された後に失敗しても何も理由が表示されません。アップデートは一度で30分弱かかると書かれていますが、ファイルの転送段階でアボートしていても把握できないようです。このため手動でファームウェアアップデートを済ませて解決します。

LTE経由でファームウェアアップデートが走ったとしても、ここでダウンロードされるファイルが 600MB ほどになり、光回線などがあれば、LTEで転送することは合理的ではありません。PC等でファームウェアを事前ダウンロードしておけばリトライも簡単ですし、試行あたりの待ち時間や総転送量も減るでしょう。

実際にファームウェア更新ページで試してみるとわかるのですが、ファームウェアアップロード時、 SoundBar 900 のフラッシュストレージ上に過去の失敗ファイルが残っていることがあるようで、その場合にはファームウェア更新のワークエリアが十分にとれずに失敗することがあるようです。この問題を回避するためには Bluetooth ボタン + 早送り> ボタンを同時長押しして、あらかじめファクトリーリセットしておくと、ワークエリアがリセットされるのか、空き容量不足になるエラーが解消できました。

いちどファームウェアが更新できれば、LTE回線を上流としたネットワーク環境でも、アクティベート後の自動的なファームウェアアップデートも発生せず、期待通りデバイスが制御できるようになりました。

最近のファームウェアでは HDMI で接続されたソースが起動したときにこちらに入力を切り替えるような変更が入っているようですね。何かの拍子に入力ソースが切り替わった後に、テレビ等からの音が聞こえずモヤモヤするということは減るのかもしれません。(音が鳴らないと HDMI のリンクが落ちているのか、単に入力ソースの問題なのかという二択が頭によぎる時点でかなりUXが低い印象でしたが、これが改善しているかもしれません)


中身は Android なのね

サービス用の Type-C コネクタを介して疑似イーサネット?接続することも可能なようですが、手元ではすぐにイーサネットとして認識されなかったので、切り分けも面倒なので早々に諦めて有線ネットワーク経由でアクセスしました(このアップロードページは、これはこれでセキュリティ的にどうなんだという作りですが)。

Type-Cで接続したところ Android デバイスとして認識されたので、これ中身の OS は Android なんですね。ファームウェアファイルが600MBもあったのに驚きましたが、なんか納得というかんじです。 Raspberry Pi にのっているような STB 用のプロセッサ等で制御しているんでしょうね。


HDMIのリンク落ち問題

経験してみて判ったのですが BOSE SoundBar シリーズは HDMI で接続中に HDMI のリンクが落ちるとコールドスタート(電源ケーブル抜き差し)まで復旧しないようで、ネットで調べるとフラストレーションを感じている人が日本語でも他の言語でもいっぱいいます。私が弄った個体でもこの問題は発生していて、以下の対策をしています。

1)Ultra High Speed HDMI 認証のケーブルに交換で接続
2)簡単にリセットできるように電源ケーブルにスイッチを増設 :-(
3)リンクモードを拡張から標準にダウンブレード


Ultra High Speed HDMI 認証のケーブルに交換で接続

Ultra High Speed HDMI は、 8K 対応として売られているケーブルです。メーカーがズルをしていなければ、このロゴを付けられるケーブルは 8K のデータが転送できることを確認している(よりテクニカルには 48Gbps のデータ伝送で問題ないことが確認された製品)ということになります。Ultra High Speed HDMI はいまのところ HDMI シリーズでいちばんノイズ耐性をもとめる規格です。1m程度のケーブルは銅線などで単に接続されているパッシブタイプのケーブルが一般的ですが、高ノイズ耐性なケーブルであれば迂闊な HDMI の信号ロックはずれに頻度も減るだろうという狙いです(自分で HDMI 信号を扱ってきた際の体験談に基づいてのロジックです)。実際、体感的には、コレでかなり音が消える頻度が下がった気がします。

SoundBar 900 は eARC の製品ですが、どうせオーディオしか伝送しないだろうに、高いビットクロックでソースとシンクが HDMI 信号をロックしてて、ちょっとしたノイズで簡単に HDMI 信号のロックが外れると電源入れ直しまで復旧しないとか、そんな感じの挙動に見えます。ネット上のクレームを見る限り、おそらく付属のケーブルでも同期がとれなくなるケース多いのでしょうが、私みたいに古いケーブルなどをうっかり使い回すような人は、かなり痛い目にあうのでしょう。

簡単にリセットできるように電源ケーブルにスイッチを増設 :-(

HDMI信号ロックが外れたときのために SoundBar 900 の通電用ケーブルは簡単にリセットできるように電源スイッチを自分で追加する加工した電源ケーブルに置き換えました。


電源タップ側で簡単に電源オンオフできればそれでいいのですが、設置先の電源タップがアウトレットごとの独立制御に対応していなかったので、ケーブルを加工しました。電源ケーブルを抜き差しする手間がないだけでもかなりストレス軽減になるでしょうです。

リンクモードを拡張から標準にダウンブレード

テレビ側の設定を見ていたら HDMI ソース側(今回は SONY BRAVIA)の外部入力設定において HDMI リンクモードを拡張から標準に変更しました。HDMI のリンクモードを拡張・標準で選べるようなので、これを変更したときに具体的に何がおきるかは判っていないのですが(テレビ側もスピーカー側も何も教えてくれません)、これで HDMI のビットクロックを落とせたら安定性が増すかなと期待して標準モードに変更しました。結果として、この設定が安定性面で一番効果があったようです。


追記
BRAVIAのリンクモードを標準に落とした場合に何がおきるかわかっていないのですが、仮にリンクモードが落ちてARCにダウングレードされると、 5.1ch 圧縮までのサポートとなり 5.1 非圧縮や7.1のフォーマットを伝送できなくなるようです。
今回の設置場所では非圧縮5.1chなどのコンテンツに用はなさそうなのでARCにダウングレードされても十分でしたが、7.1期待で買っている人はどうしたらいいんでしょうね...

2022年4月30日土曜日

ES Modules から YouTube IFrame Player API を使う

YouTube IFrame Player API は、手元の Web サイトなどに YouTube 動画を埋め込みできる YouTube 公式のAPIです。

iframe 組み込みの YouTube Player API リファレンス

以下に Iframe Player API のサイトにあるサンプルコードを引用します。

<!DOCTYPE html>
<html>
  <body>
    <!-- 1. The <iframe> (and video player) will replace this <div> tag. -->
    <div id="player"></div>

    <script>
      // 2. This code loads the IFrame Player API code asynchronously.
      var tag = document.createElement('script');

      tag.src = "https://www.youtube.com/iframe_api";
      var firstScriptTag = document.getElementsByTagName('script')[0];
      firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);

      // 3. This function creates an <iframe> (and YouTube player)
      //    after the API code downloads.
      var player;
      function onYouTubeIframeAPIReady() {
        player = new YT.Player('player', {
          height: '360',
          width: '640',
          videoId: 'M7lc1UVf-VE',
          events: {
            'onReady': onPlayerReady,
            'onStateChange': onPlayerStateChange
          }
        });
      }

      // 4. The API will call this function when the video player is ready.
      function onPlayerReady(event) {
        event.target.playVideo();
      }

      // 5. The API calls this function when the player's state changes.
      //    The function indicates that when playing a video (state=1),
      //    the player should play for six seconds and then stop.
      var done = false;
      function onPlayerStateChange(event) {
        if (event.data == YT.PlayerState.PLAYING && !done) {
          setTimeout(stopVideo, 6000);
          done = true;
        }
      }
      function stopVideo() {
        player.stopVideo();
      }
    </script>
  </body>
</html>


Iframe Player API は ESM として実装されていないので、 ESM として実装した Javascript などのコードから利用する場合にはいくらかの工夫が必要になります。

API の紹介サイトでは同 API の初期化終了後にコールバックされる onYouTubeIframeAPIReady() をグローバル名前空間で宣言していますが、これは ESM の名前空間からみれば window.onYouTubeIframeAPIReady() にあたります。また、同 API の初期化が完了すると YT が定義されますが、これは ESM の名前空間からみると window.YT になります。

この考慮点を反映して ESM としてサンプルを再実装したものが下記になります。

実際には ESM として実装する場合には、クラスとして実装するケースが多いでしょう。下記の例では、埋め込んだプレイヤーからのコールバックをインスタンスのメンバ変数として実装しています。

※コード上は動画のロードが完了し次第自動再生するような実装になっていますが、本記事執筆時点ではそのように動作していないようです。これは API のサイトにあるオリジナルのサンプルの問題です。

2022年3月28日月曜日

シェルスクリプトで unixtime を扱う

シェルスクリプト内で unixtime を得るには /bin/date +%s が利用できます。この方法で取得した unixtime はシェルスクリプト内で加算・減算したり、比較したりできます。

以下は、 sleep に頼らずに $TIMEOUT 秒間待機する例です。単純に sleep $TIMEOUT と記述するよりも、柔軟な待機ループを実装できます。

TIMEOUT=10
TIME_START=`/bin/date +%s`             # ループ開始時刻(いま)
TIME_END=$((${TIME_START} + ${TIMEOUT})) # ループ脱出時刻

echo -n running
while [ `/bin/date +%s` -lt ${TIME_END} ]; do
        sleep 1
        echo -n .
        
        # MySQL Server への接続性が確認できたら $TIMEOUT 秒待たずにループを抜ける 
        mysql -e 'SHOW STATUS' 1> /dev/null 2> /dev/null && break
done

2022年3月25日金曜日

Linux KVM + virtio-net のレイテンシーを削減するヘンな方法

Linux KVM の virtio-net を利用している際にリモートホストへの PING 結果がマイクロ秒のオーダーで安定しない(ブレる)という話を見かけました。

Linux KVM の仕組みや QEMU のデバイスエミュレーションの仕組みを考えれば「そんなもの」かなと思い、調査および追加の実験をしてみました。なお、本記事は x86 アーキテクチャの Linux KVM の動作に基づきます。


パケットが送信される仕組み

ping コマンドなどにより発生した、送信パケットは OS のネットワークスタック(L3, L2)を通じてイーサネットのドライバに引き渡されます。
今回はLinux KVMのデバイスエミュレーションを利用した際のレイテンシーに注目したいため、プロセスからデバイスドライバまでデータ届く流れについては触れません。 Linux KVM の仮想マシンで使われる virtio-net に注目します。
仮想的なネットワークインターフェイスである virtio-net 、またそのベースとなる、ホスト−ゲスト間の通信に使われる virtio-pci では、ゲストOSのメモリ空間にある送信対象データをバッファへのポインタをリングバッファに積み、I/Oポートを叩くことで VMM (Linux KVM) 側に通知します。
このあたりの仕組み(PCIデバイスに模倣する virtio のゲスト・ホスト間通信の仕組み)は過去にエンジニアなら知っておきたい仮想マシンのしくみ スライドにて解説しているので、そちらもご確認ください。

https://github.com/torvalds/linux/blob/4f50ef152ec652cf1f1d3031019828b170406ebf/drivers/net/virtio_net.c#L1762 に、ホストへ通知すべき送信データが準備できた際ホスト側の virtio-net に通知するブロックがあります。
        if (kick || netif_xmit_stopped(txq)) {
            if (virtqueue_kick_prepare(sq->vq) && virtqueue_notify(sq->vq)) {
			u64_stats_update_begin(&sq->stats.syncp);
			sq->stats.kicks++;
			u64_stats_update_end(&sq->stats.syncp);
		}
	}

virtqueue_notify(sq->vq) は仮想デバイスの I/O ポートへのアクセスする実装となっています。そのセンシティブ命令を契機としてゲストから VMM に制御が移ります。デバイスモデル(qemu-kvm)のI/Oポートハンドラがゲストからの割り込み理由を判断し、 virtio-net のゲスト側リングバッファにあるデータをバックエンドのネットワークインターフェイスに渡します。
この際(Intel 表記で) VMX non-root → VMX root の kvm → qemu-kvm (ring3) と遷移し、場合によっては qemu-kvm がネットワークスタックにパケットを渡す前にプリエンプションが発生する可能性があり、送信タイミングが遅れる原因になり得ます。

 パケットが受信される仕組み


では、パケットが届いた場合はどうなるでしょうか? qemu-kvm がゲスト行きの受信データを受け取ると、予め用意されているゲスト側のメモリ領域に受信データを書き込み「ゲストに対して割り込みをかけます」。
本物のx86のプロセッサであれば、IRQ(Interrupt ReQuest)を受け付けるための信号線があり、それを介して割り込みを通知する方法と、割り込み通知用のアドレス空間に対するメモリ書き込みトランザクションにより割り込みを通知するMSI(MSI-X)割り込みがあります。仮想マシンの場合、仮想プロセッサにはIRQ線、MSI通知用のアドレス空間にメモリトランザクションを行ってもそれを受けてくれる相手がいないので、仮想マシンのプロセッサを「割り込みがかかった状態」にすることで割り込む挿入を実現することになります。

https://github.com/torvalds/linux/blob/0564eeb71bbb0e1a566fb701f90155bef9e7a224/arch/x86/kvm/vmx/vmx.c#L4574 にある vmx_inject_irq() に、 Intel VT でゲストに割り込みを通知する実装があります。
static void vmx_inject_irq(struct kvm_vcpu *vcpu)
{
	struct vcpu_vmx *vmx = to_vmx(vcpu);
	uint32_t intr;
	int irq = vcpu->arch.interrupt.nr;

	trace_kvm_inj_virq(irq);

	++vcpu->stat.irq_injections;
	if (vmx->rmode.vm86_active) {
		int inc_eip = 0;
		if (vcpu->arch.interrupt.soft)
			inc_eip = vcpu->arch.event_exit_inst_len;
		kvm_inject_realmode_interrupt(vcpu, irq, inc_eip);
		return;
	}
	intr = irq | INTR_INFO_VALID_MASK;
	if (vcpu->arch.interrupt.soft) {
		intr |= INTR_TYPE_SOFT_INTR;
		vmcs_write32(VM_ENTRY_INSTRUCTION_LEN,
			     vmx->vcpu.arch.event_exit_inst_len);
	} else
		intr |= INTR_TYPE_EXT_INTR;
	vmcs_write32(VM_ENTRY_INTR_INFO_FIELD, intr);

	vmx_clear_hlt(vcpu);
}
ここでのキモは intr |= INTR_TYPE_EXT_INTR もしくは INTR_TYPE_SOFT_INTR です。 Intel VT では、仮想プロセッサの状態を管理するVMCS構造体に対して特定のビットを立てておくと、「次にその仮想マシンを実行するときに、ゲスト側で割り込みハンドラに処理が移される」仕組みです。ただし、ここでは実際に仮想マシンの実行し、割り込みハンドラに処理を移してはいないことに注意が必要です。

では、具体的にいつ、仮想マシンの割り込みハンドラが実行されるのでしょうか?答えは、Linux KVM + qemu-kvm の場合 qemu-kvm のメインループでCPUスレッドが改めて VMX non-root モードに移行するように要求したときです。

「次に仮想CPUを実行するときは割り込みハンドラを実行する必要があるよ」設定されても、その次にスケジュールされるタイミングは qemu-kvm の仮想CPU実行スケジュール、Linuxのスケジューラに依存することになります。仮想マシンのCPU負荷が低い場合には、qemu-kvmはVMX non-rootへの遷移を抑制して他のプロセスへのプリエンプションを許すことにより負荷を下げます。しかし、パケットが届いた後にqemu-kvmのCPUスレッドにプリエンプションが発生し、VMX non-rootへ遷移するまでがネットワークレイテンシーの増加として見えることになります。


VMX non-root でのビジーループは解決策になるか

では、ゲストがビジー状態でCPUを使い続ければ他のプロセスへのプリエンプションが発生せず、レイテンシーに改善が見られるのではないか?という仮説がありました。この方法で、ゲストOSがHLT命令などを発効してCPUを手放すことが減るというメリットは確かにありそうですが、2つの考慮点があります。
  • 仮想プロセッサが割り込みハンドラに制御を移すのはVMX rootからVMX non-rootに制御を移すタイミングです。このため、VMX non-rootでビジーループを回っていても割り込みハンドラに制御が移るわけではありません。
  • VMX non-rootでビジーループを回り続けた果てにVMX rootに遷移したとき、Linux カーネルから見れば、そのCPUスレッドは「大量のCPU時間を消費した」プロセスと認識します。このため、負荷が高いシステムでは、Linuxカーネルは他のプロセスに積極的にプリエンプションしようとするでしょう。これでは、次回のVMX non-rootへの遷移が遅れてしまいます。


ちょっとしたカーネルモジュールでレイテンシー性能を改善する

ここでは、ちょっとしたカーネルモジュールをロードして、レイテンシー改善が可能かを試してみました。コードは https://github.com/hasegaw/cheetah/blob/master/cheetah.c です。以下の関数をカーネルスレッドとしてビジーループで回します。

static void kthread_main(void)
{
        u32 vmx_msr_low, vmx_msr_high;

        rdmsr(MSR_IA32_UCODE_REV, vmx_msr_low, vmx_msr_high);

        schedule();
}

rdmsr は、 x86 アーキテクチャにある Machine-Specific Register を読み取ります。この命令はデバイスモデルでのエミュレーションが必要になるセンシティブ命令であるため、強制的に VMEXIT が発生して VMX non-root → VMX root への遷移が発生し、qemu-kvmでRDMSRのエミュレーションが行われ、その結果をもって再度VMENTERされます。
RDMSRのエミュレーションは未改造のqemu-kvmにおいて比較的コストの低いエミュレーション処理で、特に副作用が想定されないため、これを選んでいます。HLTなどを利用するとVMMがすぐにゲストに処理を戻さないかもしれませんが、RDMSRであれば、他プロセスへのプリエンプションがない限りは、すぐに再度VMENTERによりVMX non-rootに移行することが想定され、このタイミングで割り込みハンドラへの制御が移るわけです。


測定結果

Linux KVMで動作するシステム上のLinuxゲスト間でpingコマンドを実行した際のレイテンシーを測定してみました。下記が90%パーセンタイル値です。
  • RDMSRのビジーループなし(通常の状態) —— 614us (100.0%)
  • RDMSRのビジーループなし、ゲスト内で perl -e 'while(1) {}' でビジーループ —— 516us (84.0%)
  • RDMSRのビジーループあり —— 482us (78.5%)
ごく一般的なLinuxゲストと比較すると、先のRDMSRのビジーループを回したLinuxゲストでは20%程度のレイテンシー低下が確認できました。

このデータは2021年3月(本記事の執筆時点からちょうど1年前)に割とノリで試したもので、であまり細かいデータを取っていないため、上記の値しか残っていないことにはご容赦ください。


割り込み通知からポーリングループへの移行

ゲストがビジー状態で回り続けていても通知を受けられないのなら、ひとつの選択肢としてはポーリングループでホストからの通知が受けられたらいいのに、という発想になるかと思います。実際、DPDKではプロセスがループでポーリングし続けることで大幅にレイテンシーを短縮しているわけです。ホストOSからゲストOSへの通知をポーリングループで監視すればよいのではないでしょうか?

今回は、virtio-netに対して手を入れるような事はしなかったのですが、ホストOSからの割り込み通知を待たずにリングバッファへ新着データを拾いにいくことは可能な気がします。しかし、本当にレイテンシー面を気にするのでれば SRIOV などを検討するほうがいいでしょう。

Linux KVM ではありませんが、同じく Intel VT ベースで仮想マシン技術を提供する VMware 社のホワイトペーパー Best Practices for Performance Tuning of Latency-Sensitive Workloads in vSphere VMs には、下記の記述があります。



まとめ

Linux KVM の virtio-net を利用している際にリモートホストへの PING 結果がマイクロ秒のオーダーで安定しない(ブレる)理由について、Linux KVM、qemu-kvmとゲスト側カーネルの間で何がおきるかを考えてみました。
また、ゲストへの割り込み挿入タイミングを増やすために、ゲストカーネル上で数行で書けるカーネルスレッドを動かすことにとって、実際にレイテンシー性能がいくらか向上することを確認しました。
マイクロ秒オーダーの時間のブレを気にする場合は、最終的にはSRIOVやポーリングモードの導入、そもそも(VMENTER/VMEXITだって遅いので)VMM上ではなく物理的なマシンの利用が解決策になると思いますが、仮想マシンのしくみがこんな物なんだと理解しておくのは悪くない事かと思います。




2022年3月24日木曜日

シェルスクリプトで MySQL サーバーが起動してくるのを待つ

 10年ほど前にメッチャ MySQL で TPC-C を回していたときに使っていた方法です。

echo -n "Waiting for the database ready...."
while true; do
        sleep 1
        mysql -h ${TPCC_HOST} -P ${TPCC_PORT} -u ${TPCC_USER} --password=${TPCC_PASSWORD} \
                -e 'show status' 1> /dev/null 2> /dev/null  &&  break
        echo -n '.'
done
echo 'up'

MySQLクライアントでサーバに接続し適当なクエリを実行できるかでアクセス性があるか判断しています。このクエリが実行できるようになるまでここでブロックし、問題なくクエリが実行できれば終了コードとして 0 が返されますので && break が実行されループを抜けます。

今回はサーバがクエリを受け付けられるようになるまで待っていますが、同じ方法を用いて、クエリが実行できるかどうかで処理内容を条件分けすることもできますね。

mysql -h ${TPCC_HOST} -P ${TPCC_PORT} -u ${TPCC_USER} --password=${TPCC_PASSWORD} \
        -e 'show status' 1> /dev/null 2> /dev/null
# 上記 mysql コマンドの終了コードが格納された $? を見て処理を分岐する
if [ $? -eq 0 ]; then
    echo "クエリが成功した"
else
    echo "サーバに接続できなかった、クエリが失敗した"
    exit 1 # ゼロ以外の値でシェルスクリプトを異常終了させる
fi

exit 0 # 正常終了

2022年3月1日火曜日

IkaLogの開発で得た知見:大量の動画や画像を取り扱う際の Tips

スプラトゥーンの画像解析ツール IkaLog を作った際に得た知見をいくつか共有したいと思います。特に動画や画像を取り扱った際に苦しんだ点、工夫した点、良かった点などを紹介します。

資産として動画データを蓄積する

IkaLog では Nintendo Switch コンソールが出力した生の画像データを扱いますが、ただし開発にあたって「取りっぱなし」のビデオの利用が適切とは言えません。撮りっぱなしの 1 時間 1GB の動画ファイルと、1分間15MBの動画ファイルであれば、後者のほうが取り扱いやすいこと感覚的にわかるかと思います。

長い動画ファイルから目的のシーンを探し出すことには作業として手間がかかります。VLC などの動画再生ソフト、もしくは動画編集ソフトによる作業をしようとした場合、長い動画から目的のシーンを的確に見つけて作業するためには、シークバーを正確に操作しなければいけません。そもそもコンソール上で作業しづらいという事になります。

また、自動テストをまわすとしても時間がかかります。たくさんのフレームをデコードするためにはプロセッサなどの性能を使用するほか、ビデオ内の時間を指定してビデオをシークするとしても、ファイルの先頭から可変長のフレームデータをパースしフレーム数を数えなければいけないこともあり、時間がかかるのです。

この問題を解決するためには、以下のような方針でビデオを管理します。
  • 目的の画像がわかっていて、確実に取得する方法がある場合には、そのシーンを再現して、そのシーンだけ録画する。
  • 撮りっぱなしは1時間程度〜長くても数時間程度にとどめる。長くなりすぎた撮りっぱなしビデオは、ある程度の長さ(2-3時間程度)に分割してから扱う
  • 最終的には目的のシーンを切り出して、内容が明確にわかるファイル名などを付与して扱う
  • 必要なレベルのフレームレートに落とす(ファイルサイズ削減、画質向上が期待できる)

動画ファイルから目的の部分を切り出す

動画ファイルからの切り出しには FFMPEG を使用する場合、 ffmpeg -i 入力ファイル名 -ss 開始時間 -t 秒数 -c:a copy -c:v copy 出力ファイル名 などを使用します。開始時間が 38分22秒であれば、その時刻は bash 等のシェル上で $((38*60)+22) などとして秒数に変換ができることも憶えておくと便利です。

GUIで編集しやすいソフトウェアとして VidCutter があります。このソフトウェアは動画ファイル内にある圧縮済みの動画データを再エンコードしないため画質が劣化せず、高速にシーンを切り出せるため、とても便利です。

自分の目を活用する:画像タイルの中から違うものを探すのが得意

以下の画像を見て、分類に間違いがあるものを見つけてみてください。


どうでしょうでしょうか?不規則なものにすぐ気づけると思います。

画像をこのように並べて表示するにはどうしたらいいでしょうか? 分類済の画像ファイルがある場合、 IMG タグ(だけ)を並べたHTMLファイルを準備するだけでよいですから、最小限でよければシェル上でワンライナーとして書くこともできます。

多数のデータを扱う場合は、ほどほどの個数にわけて扱う

初代スプラトゥーン向けの IkaLog では、ブキ画像の分類問題の機械学習やその精度検証用としておよそ300万サンプルのブキ画像(PNGファイル)を用意していました。便利なデータ管理ツールなどを使っているわけではありません。このようなデータはいくつかのサブグループに分けて扱うほうが簡単です。

スプラトゥーンの場合およそ100のブキが存在し、それらのブキのクラス分類をするために機械学習を利用しました。1クラスあたり3万サンプルと思えばデータセットは少し小さくなってくるのですが、作業には手間がかかります。

データセットが大きいと「目 grep 」時に見落としたり、注目したデータを見つけたり加工したりするにも時間がかかります。一方、扱いやすい規模よりも小さなデータセットに細かく分割してしまうと、手間が増えるため効率が悪くなります。

経験上、先に示したようなブキ画像を扱う場合などでは、データセットを 5,000 ~ 10,000 程度ごとにグループ化すると扱いやすいです。この規模は、アイコンサイズの画像であれば、横 25カラムであれば縦200行〜400行程度。この規模であれば、先のIMGタグによるタイル表示や Finder / Explorer などを用いて目視で確認する場合も、ざっと確認したいだけなら10秒〜20秒で見渡すことができる分量です。

たとえば IMG タグを多数並べたファイルを作成した HTML ファイルを閲覧して誤分類された画像ファイルが 5つほど目に留まり、それを Finder などで除外したい場合を想像してみてください。30,000のファイルから5つに注目するより、5,000のファイルから5つに注目するほうが簡単で、素早く対応できます。


”不労所得”を得る:自分が努力しなくてもソースが集まるような工夫

大量のデータを用いて機械学習やそのテストをしたい場合、もしくは目的達成のために大量のデータを集めたい場合、自分自身でそのデータを集めてまわるのは非効率です。

IkaLog の場合は開発当初から常用してくれるユーザという資産、また stat.ink との連携ができたので、ここからリザルト画面などの画像データを得ることができました。自分ひとりがゲームをプレイして1日10ゲーム程度だとして、 stat.ink には毎日のように数千ゲームのスクリーンショットが投稿されていました。また、その中には録画状況が不適切なものなどが混じっているなど、実際のワークロードとしてどれぐらいの分散(variance)があるのかという情報も含まれています。一通りの開発が終わった後は、この基盤があることによりブキ判定の強化を素早く進めることができました。

2022年になって、スプラトゥーン2向けに幾らかエンジニアリングしなおしていますが、ここでは YouTube に投稿された有名配信者の生配信ビデオを多く使用しています。スプラトゥーン向けのIkaLogを開発していた際もほかの方のプレイ動画を利用できないか検討しましたが、当時の動画配信サービス上のビデオは開発にあたり必要としている画質を満たすものが少なく、難しく感じました。現在は、開発に利用できるほど良好な画質のものが多く投稿されています。

使えそうな動画があれば、コマンドラインで利用できるダウンローダを用いてそのアーカイブを取得するためにコマンドラインで一行入力すればよく、数時間分のビデオが十分ほどで入手できたりします。ある程度の精度で目的のシーンを検出できるようになっていれば、大量のビデオに対してバッチ実行しておけば目的のシーンを自動的に探し出せますし、機械学習用のデータを自動的に集められることになります。

ただし、特定のチャンネルの動画のみを利用すると、エンコードされた動画の画質が偏る、たとえばそのプレイヤーが色覚サポートモードを有効にしている場合ゲーム内のインク色が特定2色に偏る(実ワークロードに対して偏ったデータが集まる)といったこともあります。画質については手元でビットレートを変更して再エンコードし種を増やすことが可能ですが、データセットに含まれるインク色が偏る問題は、そういった偏りがないチャンネルを選んだり、(色覚サポートを使うユーザばかりにならないよう)複数のチャンネルをデータセットに加えるといった対策が必要です。