DEV Community

melpon for 合同会社Wandbox

Posted on

WebRTC Load Testing Tool Zakuro を作った話

先日、WebRTC Load Testing Tool Zakuro がリリースされました。

Zakuro は WebRTC SFU Sora に対して負荷を掛けるためのクライアントで、自分(正確には合同会社Wandbox)が時雨堂から仕事として請けて開発したものになります。

ここでは、この Zakuro を実装するためにどんなことをしたのかを紹介します。

Zakuro の概要について知りたい場合は @voluntas さんの書いている

あたりを参照してください。

Momo から実装をコピーしてくる

Zakuro は大体 Momo と同じような機能を備えているので、Momo から以下のような実装をコピペしてきました。

  • Momo のビルドシステム
  • 引数パーサー
  • Sora クライアント、Sora API サーバー
  • WebSocket、SSL などの通信周り
  • WebRTC の PeerConnection を確立してハンドリングする部分
  • カメラからのキャプチャ

その後、Zakuro を動かすために不要な部分、例えば Windows 専用のコード(Linux で動けば良い)、SDL を利用しているコード(受信側は不要)、ハードウェアエンコーダのコード(ハードウェアエンコーダは使わない)などを削除していきました。

これでひとまずビルドが通り、Zakuro でカメラキャプチャした映像が Sora Labo などで見れるようになりました。

複数クライアントに対応する

Momo は 1 プロセス 1 クライアントで接続しますが、Zakuro は大量のクライアントを接続して負荷を掛けるツールなので、複数クライアントに対応する必要があります。
これは元の Momo のコードが整理されていたので、RTCManagerSoraClient をクライアントの数だけ生成すれば動きました。

こんなコードになります。

class VirtualClient {
 public:
  VirtualClient(boost::asio::io_context& ioc,
                rtc::scoped_refptr<ScalableVideoTrackSource> capturer,
                RTCManagerConfig rtcm_config,
                SoraClientConfig sorac_config) {
    rtc_manager_.reset(
        new RTCManager(std::move(rtcm_config), std::move(capturer), nullptr));
    sora_client_ =
        SoraClient::Create(ioc, rtc_manager_.get(), std::move(sorac_config));
  }

  void Connect() { sora_client_->Connect(); }
  void Clear() { sora_client_.reset(); }

 private:
  std::unique_ptr<RTCManager> rtc_manager_;
  std::shared_ptr<SoraClient> sora_client_;
};

このように実装して、--vcs オプションで指定された数だけクライアントを生成するだけで Sora 側に負荷を掛けれるようになりました。

ただし、このまま一気に大量のクライアントを接続するのは Sora に接続時の負荷が掛かり過ぎるし、現実にも則していないので、徐々に接続するための --hatch-rate オプションも実装しました。

Safari みたいな Fake 映像/音声を実装する

Safari には Fake 映像/音声を生成するためのデバイスが用意されていて、そのデバイスを使うとこんな表示になります。

Safari の Fake 映像

スクリーンショットだと分からないですが、複数のサイン波を組み合わせた音も流れています。

この Fake 映像/音声を Zakuro にも移植しました。

Fake 映像

Safari の場合、Fake 映像を作るためにグラフィックスライブラリである cairo を利用しています。
cairo は LGPL または MPL のデュアルライセンスで、まあ問題なさそうってことで当初はこれを使おうかと思っていました。

ただ、もう少し緩いライセンスのライブラリは無いかなと調べてみると Blend2D というのを見つけました。
これは Zlib ライセンスで、JIT コンパイルする上にマルチスレッド使えるとかいうなかなか凄いライブラリのようです。

どっちを使っても Fake 映像を作るには問題ないんですが、Blend2D の方が面白そうということで、Blend2D を使うことにしました。

実際使ってみた感じ、ほぼ問題なく使えて Fake 映像が実装できてしまったのであまり書くことは無いんですが、ちょっと嵌った点としては以下の2点ぐらいです。

  • BLContext の rotation や translate がどういう順番で適用されるかが分からなくてちょっと時間を使いました。実験コード書いてみた感じ、最後に書いたのから順番に適用されていくようです(OpenGL と同じ)。
  • 点線がどういう風に書いても反映されなくて、おかしいなーって調べてみたら、点線は未実装でした (#48)。なので点線を表示するのは諦めています。

あとはこの生成した画像をあたかもカメラから取り込んだかのようにエンコーダに送ってやると、映像がエンコードされて Sora に送信されます。

最終的な Fake 映像の実装は zakuro/fake_video_capturer.cpp at develop · shiguredo/zakuro にあります。

これで以下のような Fake 映像になりました。

Zakuro の Fake 映像

Fake 音声

Safari の Fake 音声を調べると、プログラム上で以下の4種類のサイン波を生成していることが分かります。

  • hum 音。150 Hz で常時鳴り続けてる。
  • noise 音。3000 Hz で常時鳴り続けてる。
  • bip 音。1500 Hz で、bop 音の1秒後に一瞬だけ鳴る。
  • bop 音。500 Hz で、bip 音の1秒後に一瞬だけ鳴る。

Safari の Fake 音声の場合、ノイズキャンセリング機能を ON にしてると noise 音が消えるという実装が入ってましたが、Zakuro では実装していません。

Fake 音声は、これらのサイン波の合成をプログラムで作ってやるだけなので、作るのはすごく簡単でした。

大変だったのは、このデータを渡す部分です。
Fake 音声を生成して録音したデータとしてエンコードしてもらうには、AudioDeviceModule (ADM) を自前で実装する必要があります。
audio_device.h を見れば分かるように、ADM が要求している関数は凄い大量にあり、今のところ 58 個の純粋仮装関数を実装する必要があります。
大抵は無視するか何もしないで 0 を返せばいいだけなんですが、中にはちょっとした処理を書かないといけない部分もあってなかなか大変でした。

最終的な Fake 音声の実装は zakuro/zakuro_audio_device_module.cpp at develop · shiguredo/zakuro にあります。

映像と音声の受信を無視する

Zakuro は Sora クライアントとして振る舞うので、映像と音声の受信も出来る必要があります。
しかし、これらの映像を表示したり、音声を再生したりといったことをすると、その環境でのデバイスが必要になってしまうため、動かせる場所が限定されてしまいます。
そのため、映像データと音声データを受信した上で破棄する処理が必要になります。

映像に関しては、デコーダで CPU を使うのが勿体ないので、何もしないデコーダを作りました。こんな感じになっています。

class NopVideoDecoder : public webrtc::VideoDecoder {
 public:
  int32_t InitDecode(const webrtc::VideoCodec* codec_settings,
                     int32_t number_of_cores) override {
    return WEBRTC_VIDEO_CODEC_OK;
  }

  int32_t Decode(const webrtc::EncodedImage& input_image,
                 bool missing_frames,
                 int64_t render_time_ms) override {
    if (callback_ == nullptr) {
      return WEBRTC_VIDEO_CODEC_UNINITIALIZED;
    }

    // 適当に小さいフレームをデコーダに渡す
    rtc::scoped_refptr<webrtc::I420Buffer> i420_buffer =
        webrtc::I420Buffer::Create(320, 240);

    webrtc::VideoFrame decoded_image =
        webrtc::VideoFrame::Builder()
            .set_video_frame_buffer(i420_buffer)
            .set_timestamp_rtp(input_image.Timestamp())
            .build();
    callback_->Decoded(decoded_image, absl::nullopt, absl::nullopt);

    return WEBRTC_VIDEO_CODEC_OK;
  }

  int32_t RegisterDecodeCompleteCallback(
      webrtc::DecodedImageCallback* callback) override {
    callback_ = callback;
    return WEBRTC_VIDEO_CODEC_OK;
  }

  int32_t Release() override { return WEBRTC_VIDEO_CODEC_OK; }
  bool PrefersLateDecoding() const override { return false; }
  const char* ImplementationName() const override { return "NOP Decoder"; }

 private:
  webrtc::DecodedImageCallback* callback_ = nullptr;
};

何もしないとか言いつつ小さいフレームを生成して渡しているのは、WebRTC はいろんな場所の統計情報を使って処理を変えているため、ここで結果を作って渡してやらないと、どこかで不整合が起きるんじゃないかと思ったからです。

あとはこの NopVideoDecoder を生成する factory を実装して…

class NopVideoDecoderFactory : public webrtc::VideoDecoderFactory {
 public:
  std::vector<webrtc::SdpVideoFormat> GetSupportedFormats() const override {
    std::vector<webrtc::SdpVideoFormat> supported_codecs;
    supported_codecs.push_back(webrtc::SdpVideoFormat(cricket::kVp8CodecName));
    for (const webrtc::SdpVideoFormat& format : webrtc::SupportedVP9Codecs()) {
      supported_codecs.push_back(format);
    }
    return supported_codecs;
  }

  std::unique_ptr<webrtc::VideoDecoder> CreateVideoDecoder(
      const webrtc::SdpVideoFormat& format) override {
    return std::unique_ptr<webrtc::VideoDecoder>(
        absl::make_unique<NopVideoDecoder>());
  }
};

cricket::MediaEngineDependencies::video_decoder_factoryNopVideoDecoderFactory を設定するだけです。

media_dependencies.video_decoder_factory.reset(new NopVideoDecoderFactory());

これで映像データを受信して無視するようになりました。

音声に関しては、せっかく Fake 音声で ADM を実装したので ADM で音声データを無視するようにしました。
これだと音声のデコード処理は走ってしまいますが、映像データに比べれば大したことは無いと思うのでこのままにしています。
これで音声データも受信して無視するようになりました。

フォントを Zakuro バイナリに埋め込む

Blend2D は ttf 形式のフォントを読んで利用できるので、最初は macOS や Ubuntu の標準フォントのパスを適当に指定して表示していました。
ただ、これだと CentOS や他の OS 向けにビルドを追加する際に毎回フォントパスを探すことになってしまいます。
流石にそれは大変そうなので、Zakuro にフリーフォントを同封することにしました。

利用するフォントは Kosugi です。
Google Fonts の中から日本語が扱えて Apache ライセンスのものを探しました。

で、この Kosugi フォントを同封することにはしたのですが、Zakuro は今まで単体のバイナリで動くようになっていたので、出来れば変わらず単体で動かしたいところです。
なので Zakuro バイナリにフォントデータを埋め込むことにしました。

一番最初に考えたのは、フォントデータを C のソースに変換することです。

// 以下のコードを Kosugi.ttf ファイルから自動生成する
const char KOSUGI_TTF[] = {
  0x00, 0x01, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x80,
  0x00, 0x03, 0x00, 0x70, 0x47, 0x53, 0x55, 0x42,
  ...
};

このようなコードを自動生成してコンパイルすれば、フォントデータを Zakuro バイナリに埋め込めます。

ただし、これはコードを自動生成するためのツールが必要になってきます。
公式に対応する予定は無いですが、Zakuro は一応 macOS でもビルド可能になっているため、macOS, Ubuntu, CentOS 上で動くコードの自動生成ツールが必要になります。
このようなツールを作るのも探すのも手間だし、そのツールのメンテナンスも必要になってきて大変なので、この方法は諦めることにしました。

もう少し調べてみると、Linux の場合、ld コマンドや objdump コマンドで、バイナリをそのまま .o ファイルに変換できることが分かりました。

$ ld -r -b binary Kosugi.ttf -o Kosugi.o
$ nm Kosugi.o
00000000001d4648 D _binary_Kosugi_ttf_end
00000000001d4648 A _binary_Kosugi_ttf_size
0000000000000000 D _binary_Kosugi_ttf_start

macOS の場合は、リンク時に -sectcreate <segment名> <section名> <対象ファイル> という指定をすると、そのファイルを指定したセクションに埋め込めるようです。

これらを利用すれば Zakuro バイナリにフォントデータを埋め込んでビルドできるということが分かりました。あとは CMake にバイナリを埋め込むコードを書いて、

if (TARGET_OS STREQUAL "macos")
  # バイナリの組み込み
  target_link_options(zakuro PRIVATE -sectcreate __DATA __kosugi_ttf Kosugi.ttf)
elseif (TARGET_OS STREQUAL "linux")
  # バイナリの組み込み
  add_custom_command(OUTPUT kosugi_ttf.o
    COMMAND ld
    ARGS -r -b binary -o ${CMAKE_CURRENT_BINARY_DIR}/kosugi_ttf.o ${CMAKE_CURRENT_SOURCE_DIR}/Kosugi.ttf
    MAIN_DEPENDENCY ${CMAKE_CURRENT_SOURCE_DIR}/Kosugi.ttf)
  set_source_files_properties(kosugi_ttf.o
    PROPERTIES
      EXTERNAL_OBJECT true
      GENERATED true
  )
  target_sources(zakuro PRIVATE kosugi_ttf.o)
endif()

macOS と Linux で共通してバイナリを返すクラスを作ってやるだけです。

#ifndef EMBEDDED_BINARY_H_
#define EMBEDDED_BINARY_H_

#ifdef __APPLE__
#include <mach-o/getsect.h>
#include <mach-o/ldsyms.h>
#endif

struct EmbeddedBinaryContent {
  const void* ptr;
  size_t size;
};

class EmbeddedBinary {
 public:
  static EmbeddedBinaryContent kosugi_ttf() {
#ifdef __APPLE__
    size_t size;
    const void* ptr =
        getsectiondata(&_mh_execute_header, "__DATA", "__kosugi_ttf", &size);
#else
    extern const unsigned char _binary_Kosugi_ttf_start[];
    extern const unsigned char _binary_Kosugi_ttf_end[];
    const void* ptr = _binary_Kosugi_ttf_start;
    size_t size =
        _binary_Kosugi_ttf_end - _binary_Kosugi_Regular_ttf_start;
#endif
    EmbeddedBinaryContent content;
    content.ptr = ptr;
    content.size = size;
    return content;
  }
};

#endif

こうすることで、無事 Zakuro バイナリにフォントを埋め込んで利用できるようになりました。

y4m ファイル、wav ファイルからのキャプチャを用意する

Zakuro は Fake 映像/音声だけではなく、指定した y4m ファイルや wav ファイルを流すことができます。

y4m ファイルは、YUV 形式の生データが並んでいる動画ファイルです。
これのパーサは探せばいくつかあったんですが、欲しい要件を満たすのが無かったのと、フォーマットを見てみると割と簡単だったので自前でパーサを書きました。

欲しかった機能は、

  • YUV420 形式のデータが取得できること
  • y4m は相当でかいファイルになることが予想されるので、メモリ上に全データを読まずにロードできること
  • WebRTC に送信する FPS と動画の FPS が一致しているとは限らないので、「何ミリ秒時点のフレームが欲しい」という要求をすればそこのフレームが取得できること
  • ファイルの終端を超えた時間を要求されたら、ループして最初から再生できること

あたりです。y4m_reader.cpp ではそのあたりに対応しています。

wav ファイルも、用途を限定すれば難しいフォーマットじゃないので、これも自前のパーサを書きました。

これはそんな大したサイズにならないだろうと思ったので、全データをメモリ上に乗せています。

これらを使って Fake 映像/音声の代わりに送信することで、無事 y4m と wav ファイルの映像/音声を流すことが出来るようになりました。

OpenH264 に対応する

Zakuro は VP8/VP9 に対応しているので H.264 にも対応したいところですが、基本的に H.264 を利用するにはライセンス費用を払う必要があります。
ただし、Cisco がビルドして公開している OpenH264 バイナリを利用すれば、Cisco がライセンス費用を肩代わりしてくれます。
なので Cisco が公開している OpenH264 バイナリを使って H.264 に対応しました。

Zakuro に --openh264 <libopenh264.so の絶対パス> を指定すれば、H.264 で映像を流せるようになります。

実装としては、WebRTC は OpenH264 を使ったエンコード機能を既に持っているので、これをコピーしてきて、OpenH264 の呼び出しを動的なものに書き換えただけです。

これで無事、Cisco の公開している OpenH264 バイナリを使った H.264 エンコードに対応できました。

WebRTC の利用する OpenH264 について

上記で WebRTC のソースを利用しているのを見れば分かるように、WebRTC ライブラリはデフォルトで OpenH264 に対応しています。
しかしこれは、OpenH264 のソースを組み込んでビルドしたもので、「Cisco がビルドして公開している OpenH264 バイナリ」を利用したものではありません。
そのため WebRTC ライブラリにデフォルトで入っている H.264 エンコーダを利用するとライセンス費用が発生してしまいます(ブラウザ経由で使う分には Google とか Mozilla が何とかしてくれるので問題ないです)。

そのあたりの問題があるため、Zakuro が依存している shiguredo-webrtc-build/webrtc-build のビルド済みバイナリでは、全て OpenH264 を無効にして提供しています。

その上で、Zakuro では Cisco がビルドして公開している OpenH264 バイナリを利用して H.264 エンコードを復活させたということになります。

まとめ

このような機能を入れて、無事 Zakuro はリリースされました。
いろいろ面白いことができて楽しかったです。

今後もいろいろと改善/開発をやっていく予定なので、Sora に負荷を掛けてみたいということがあれば使ってみてください。

Top comments (0)