🖼

ピクシブのインターンに参加してアニメーションのエンコードをGIFから12倍高速にした

2022/10/05に公開

インターンに参加した

夏はピクシブ![1]
ということで、9月15日から28日[2]までの平日8日間に「PIXIV SUMMER BOOT CAMP 2022」に参加していました。
私が参加したのはImageFluxという画像変換/配信・ライブ動画配信サービスの開発に取り組むコースです。


応募

個人的に画像処理や配信についてかなり関心があり、FFmpegを触ったりWebRTCの記事を読んだりしていました。
これまでお仕事ではWebフロントエンドの経験しかなく画像関連の仕事を一度経験してみたかったため、むしろImageFluxコースがあるからという理由でピクシブのインターンに行きたいと考えていました。

応募時には何個か志望コースを選ぶ必要があり、技術基盤コース、広告配信コース、機械学習コース、VRoid Hubコースなどに興味がありましたが、現実的に今の技術スタックで後者2つは難しそうだなと思い、プログラマを支援する取り組みに興味があったので技術基盤コースとImageFluxコースに応募しました。

応募方法はESに近い通常エントリーと、GitHubアカウント+通常エントリーで提出する回答項目の部分集合を提出するGitHubエントリーの2つがあり、私はGitHubエントリーを選択しました。
同じような目標で開発しているフレームワークを作っていることをアピールするにはGitHubを中心的に見ていただく方が嬉しいと思いました。

面接

5月半ばに書類審査通過の通知をいただき、オンラインで面接が行われました。

面接官はImageFluxコースのメンターさん・サブメンターさん、技術基盤コースのメンターさんの3人でした。
志望動機やGitHubで公開している開発物についての質問、開発中にこういうことが起こったらどうするのか、などいくつかの質問を受けました。
面接経験が浅く、受けた質問に返答することで精一杯だったので逆質問の時にかなり詰まってしまいました。

また、コーディングテストも行われます。
どのような思考でプログラムを組み立てていて、その手段を選択した理由は何かなどを説明することが重視されていると感じました。
正解/不正解がその場できちんと判定されるものではなくFigma上でコードを書いていく形式だったので、かなり気楽に取り組めました。
ImageFluxコースの技術要件にGo言語が含まれていたので面接前に泣きながらA Tour of Goを読んでいましたが、面接では好きな言語で書いて良かったのでNim[3]で書きました。

取り組んだ課題

インターンの8日間で取り組んだ課題については、以下のスライドにまとまっています。
これは最終日に社内で発表を行ったもので、ピクシブ社の方から公開許可をいただいています。
ざっくり説明すると、GIF画像をImageFlux上でH.264エンコードして配信できるようにし、さらに処理の高速化を図りました。
結果的にはこの実装により、テストデータとして用意しているGIF画像を12〜19倍高速に変換することが可能になりました。

0日目

インターン募集ページにあるように、使用技術・条件が Go/TypeScript/Vue.js/nginx/lua/Apache TrafficServer/ansible だということはわかっていたのですが、ほとんど何も全然わからない状態[4]だったので緊張しすぎて身体壊していました。

1日目

絵馬がたくさん飾られていて迫力がありました。
また、特徴的な形のテーブルや開けたオフィス、フリードリンクなど働きやすい工夫がたくさんされていて心地よく過ごせました。

そしてノベルティをいただきました!嬉しい!
パーカーは発表の時や自己紹介の際に着ていました。

初日の午前中は人事の方から説明を受け契約書を作成したり、オフィスツアー[5]をしていただいたりしました。
また、インターン中はApple siliconのMacBook Airを貸与していただいていたのでメンターさんとそのセットアップをしました。

初日の午後からはウォーミングアップとして簡単な課題に取り組みました。
ImageFluxではスケーリングにlibswscaleを使っており、その他にある条件によって自前のスケーリングパッケージも使われていたのでそれらのコードが混在している状態でした。そこで条件を受け取って内部的にswscaleを呼ぶか自前のスケーリングを呼ぶかを分岐する関数を作る、という課題でした。
課題自体はシンプルでしたがGoで列挙型を書く方法やモジュールシステム、Goのlinterなど初めて知ることが多かったので良い練習になりました。

レビューを経てめでたくマージされた後、8日間取り組むメイン課題を選択しました。
1つは本インターンで取り組んだGIF画像をH.264に変換して配信すること、もう1つはWebP[6]画像配信関連の課題で、後者も面白そうでしたが前者の方がよく知っている事柄で興味も強かったのでこちらを選びました。

2日目

go-ffmpegというFFmpegのGo bindingを発見したので、ひとまずこれでH.264エンコーダを構成しようと考えました。

しかしImageFlux内にH.264エンコードができるFFmpegがインストールされていなかったのでそれらを先にインストールする必要があり、ソースコードからビルドしました。

https://thr3a.hatenablog.com/entry/20180718/1531920275

こちらの記事のビルド手順に倣うことで大体問題がないですが、バージョン等はこまめに変わっているので変更する必要があります。
特にnasm 2.13.03はgcc8関連の問題があるらしく[7]環境によってはビルドの際に落ちるので気をつける必要があります。

go-ffmpegのREADMEによると、エンコーディングはできるがその他に関するAPIはまだバイディング途中...[8]ということなので既に雲行きが怪しいですが、必要なのはエンコーダでこれで構成できるならそれに越したことはない気もするので一旦実装を進めてみました。
ビデオエンコーディングのサンプルコードが用意されているので興味があれば参照してみてください。
かなり便利で、標準ライブラリのimage.Image型をそのままパケットに投入できる仕様になっており適当に抽象化されており印象が良いです。

しかしサンプル通りに実装したにも関わらず出力されたmp4ファイルは壊れて開けない状態であり、何がクリティカルな原因かは分からないですが[9]、このままラッパーと擦り合わせながら頑張るのは難しいなという感想を持って2日目を終えました。

3・4日目

cgoでH.264エンコーダを実装しました[10]
エンコーダの実装はFFmpegのサンプルを多分に参考にしていますが、H.264の場合にはストリームを多重化する必要があります。

cgoとはGo言語のC FFIで、かなり簡単にCのシンボルを呼び出したり、またその逆をすることができます。
個人的にはコメントでガリガリとcgoの設定を書くことに驚きました。

こういうの
//#cgo pkg-config: libavcodec
//#include <avcodec/codec.h>
import "C"

cgo関連で分からないことが多発したので[11]これらの記事に助けられました。ありがとうございます。

https://r9y9.github.io/blog/2014/03/22/cgo-tips/

https://qiita.com/yugui/items/e71d3d0b3d654a110188

個人的には誤ったシンボル名を記述してしまったときに、そのシンボル名だけエラーが通知されるのではなくcgoに関連するシンボルすべてからエラーが通知されてしまうのがかなり辛かったです。cgo自身の問題なのか、linterの設定なのかはわかりませんが前者な気がします。

エンコーダの実装により、GIFアニメーションをVLCプレイヤーやブラウザで視聴可能なmp4ファイルに変換できました。
一方でAppleのQuickTimeではブラックスクリーンになってしまい視聴できませんでした。QuickTimeはかなり気難しいプレイヤーらしく[12]、ピクセルフォーマットがYUV420Pであることを確かめ、いくつか有効だと主張されていたコーデックオプションなど[13]を試しましたが原因は最後まで明らかになりませんでした[14]

5・6日目

5・6日目は主にH.264への変換をImageFlux上で行えるようにパラメータの追加を行いました。

https://console.imageflux.jp/docs/image/conversion-parameters

ImageFluxのパラメータは上記で確認できますが、f=mp4という新しいフォーマットパラメータを追加してブラウザ上でmp4動画を受け取ることができるようにします。

ところで、ImageFluxは複数行のピクセル情報を受け取って処理済みのピクセル行を返す抽象であるコンバータが定義されており、複数個のそれらをつなぎ合わせて処理を書いていくことができます。
その実装にはGoのInterfaceが多用されており、画像のフォーマットや色空間について意識する必要がありません。
ここまでH.264のエンコーダはファイルを書き出すためにFFmpegのIO管理構造体であるAVIOContextを利用していましたが、URLを渡すだけでよしなにコンテクストを作成してくれるavio_open関数を叩くのではなく、Goのio.Writerインターフェースと繋ぎ合わせたカスタムなIO構造体が必要になります。

https://www.ffmpeg.org/doxygen/2.5/avio_8h.html#a853f5149136a27ffba3207d8520172a5

avio_alloc_context関数によって任意のパケット読み出し関数、パケット書き出し関数、パケットをシークする関数を渡すことでコンテクストを作成できます。
しかしcgo側の仕様でCの関数にGoの関数ポインタを渡すことは少し難しく面倒だったので、Goの関数をC側にexportしてCでコンテクストを作成する関数を定義し、再度Goから呼ぶことで実装しました。
ここではパケットに書き込む際はシーク関数を渡しておく必要がある罠に注意が必要で、io.Writerインターフェースの値はシークできることが保証されていないため[15]、シーク可能なバッファに一度書き込んでからまとめて書き出すことで実装しました。

その後、~/c!/f=mp4/testdata/images/rotating_earth.gif にアクセスすることで、GIF画像をmp4に変換して配信することが可能になりました。

また、この2日にOpenH264への差し替えも行いました。
nasm、yasm、x264らを削除し、Ciscoがビルドして頒布するOpenH264バイナリを呼ぶように実装しました。

7・8日目

7日目からは成果報告のスライドを作成しており、残りの時間はエンコーダのチューニングを行いました。
GIFエンコードとH.264エンコードのベンチマークをそれぞれ用意して計測する形になりました。

GIFのエンコードは1回の実行に561ミリ秒かかっていましたが、H.264エンコードに差し替えた時点で69ミリ秒まで短縮されました。
また、ファイルサイズも約半分に落ちました[16]

ただしこの時点ではメモリアロケーションがGIFエンコードの43倍であり、改善する必要がありました。
これについてメモリ確保の計測を繰り返したところNRGBAをYUV420Pに変換する際に、すべてのピクセルを走査する際に、color.NRGBA型がcolor.ColorインターフェースにBoxingされることでアロケーション回数が跳ね上がることがわかりました。
今回はGIF変換のみを対象にしており、image.NRGBA型以外の実装をしていなかったためアサーションすることでアロケーションを回避し、176万回確保回数を減らしたことで245回に落ち着き、処理速度も向上しました。
この改善により1回の実行時間は46ミリ秒になり、GIFエンコーダより12.2倍高速になりました。また、メモリの確保回数は 0.013% になりました。

さらに、色空間変換で毎フレーム変換のためにメモリを確保していたので、バッファを作成して使い回すことでさらに回数を抑えました。
1回の実行は38ミリ秒に落ち、元のGIFエンコードと比較して14.8倍高速になりました。

また、ここまで色空間を触っていましたが最終日に自前の色空間変換を捨ててlibswscaleの色空間変換を使ってみました。
SWS_FAST_BILINEARフラグ[17]で変換したところ変換の速度が上がり、29ミリ秒[18]まで落ちました(19.1倍)。
ただし、自前の色空間変換と比較すると変換時間にはブレがあるように感じられました。自前の場合は安定して37ms〜42msを推移しましたが、swsvaleでは高速ですがたまに60msに跳ね上がったりしており、この原因は突き止めることができませんでした。

終わりに

複数の環境で問題なく動作することや[19]、いずれ本番環境に乗せることを見据えて品質を保つことなどを強く意識させられた[20]8日間でした。
朝会・夕会や進捗報告会などで、自分が今触っているコードが実際にドクドクと動作して別のサービスの技術的な基盤になっていることを感じ、とても興奮しましたが同じくらい緊張感を持ちました。
それはそれとして画像処理やその配信に興味があるな〜とぼんやり感じつつそれが直接的に仕事になるとは思っていなかったので、普段趣味で遊んでいるFFmpegを永遠に触ることができたことはとても楽しく、有意義な経験だったなと思います。

メンターのnontanさんや配信技術部のみなさま、人事のみなさまには大変お世話になりました、ありがとうございました!
毎年春や夏にインターンシップが募集されているので、画像処理や配信に関心がある大学生や院生の方はぜひ応募してみてください!

https://twitter.com/momeemt/status/1575098354107043842?s=20&t=fnzUXXG3qFKgkvubOYRMww

脚注
  1. 諸説 ↩︎

  2. ↩︎

  3. オタクになっても問題なさそうだったのでピクシブ本社の本棚に技術書典で頒布したNimのメタプロ本を置かせていただいた ↩︎

  4. 絶望 ↩︎

  5. オフィスめっちゃ綺麗で毎日住みたいな〜と思って出社していた。でも住んだらダメっぽい ↩︎

  6. ウェッピー。最初にウェブピーと読んでしまったのでもうウェッピーとは読めない。 ↩︎

  7. 深追いはしていないのでよく分からない ↩︎

  8. 自分でバインディングするとわかるのですが、デカすぎ ↩︎

  9. パケットは書き込まれていたのでおそらくAVFormatContext周りの実装に問題があったように感じる ↩︎

  10. 疲れた... ↩︎

  11. CStringが明示的に解放が必要なのが個人的に罠だった ↩︎

  12. オブラートに包んだ方で、調べると同じ問題を抱え、そして原因がわからないコミュニティからえらい勢いで言われている ↩︎

  13. 調べると真っ先に上がるのはYUV420Pで、次にプロファイルの設定でした。ここら辺も試しましたが...。そもそも再生できないならまだしも、真っ黒な映像が流れるというのはどういうことだったのだろう。ストリームか何かを壊してしまったのだろうか ↩︎

  14. 悔しい ↩︎

  15. io.WriteSeekerは書き込みもできるしシークもできる ↩︎

  16. ただしFFmpeg CLIでH.264エンコードして変換すると約80KBまで落ちていたためファイルサイズに関してもさらに見直す必要がありました。 ↩︎

  17. あまりにも何かを犠牲にしていそうなフラグ名だが、元がGIF画像でPalette8という制限のある色空間を使っていることと、ベンチマークを注意深く回したが視認できるレベルの劣化はなかったため実装した ↩︎

  18. 何度かベンチマークを回したが、およそ28ミリ秒〜35ミリ秒で推移した ↩︎

  19. CIを落としまくった ↩︎

  20. 私のコード、メモリ解放し忘れすぎ...!? ↩︎

Discussion