はじめに
こんにちは、田中です。ポケットサインでエンジニアとして働いています。普段は「ポケットサイン防災」という Web アプリの開発をしています。
その一方で、趣味として WebRTC を用いた PC と Android 間の画面共有システムを作っています。このシステムでは、PC 側の画面を Android 端末に配信し、離れた端末から操作できるようにしています。
この種のシステムでは、体感の良し悪しを議論するうえで、遅延の把握が欠かせません。そこで今回は、そのシステムにおける映像の end-to-end レイテンシをどのように測るか、そのためにどのような計測アーキテクチャを設計したかを紹介します。
目次
- はじめに
- 目次
- 1. 何を測りたかったのか
- 2. なぜ素朴には測れないのか
- 3. 計測パイプラインの全体像
- 4. フレーム同定のために Java API の外で VideoFrame を取得する
- 5. まとめ
- 付録 A: ARM 実機クラッシュの調査過程
1. 何を測りたかったのか
本稿で測りたかったのは、送信側でキャプチャされたフレームが Android クライアントで表示されるまでの時間です。起点は送信側のキャプチャ時刻、終点は受信側の描画時刻です。ただし厳密には、実画面への反映完了時刻ではなく、render callback が呼ばれた時刻を表示時刻の近似として用いています。ネットワークだけでなく、エンコード、伝送、デコード、描画までを含めた end-to-end の時間を対象にします。
単純に考えれば、描画時刻からキャプチャ時刻を引くだけです。ただし実際に計測しようとすると、送信側のキャプチャ時刻と受信側の描画時刻はそのままでは比較できません。加えて、後述する Java API の問題によって、送信側で記録した時刻と受信側で観測したフレームを簡単に結び付けることもできません。計測を成立させるには、少なくとも次の 2 つの条件が必要です。
- その 2 つの時刻を同じ時間軸で比較できること
- 送信側と受信側で同じフレームを対応付けられること
前者は時計合わせの問題であり、後者はフレーム同定の問題です。WebRTC の E2E レイテンシ計測では、この 2 つを同時に満たす必要があります。次章では、なぜ素朴な方法ではそれが難しいのかを整理します。
2. なぜ素朴には測れないのか
2.1 送信側と受信側の時刻はそのままでは比較できない
最初の問題は時間軸です。送信側で記録するキャプチャ時刻と、Android クライアントで記録する描画時刻は、別のデバイスで取得された時刻であり、同じ基準でそのまま比較することはできません。
この状態では、送信側で得た値を受信側の値からそのまま引くことはできません。E2E レイテンシを計算するには、まず送信側の時刻を受信側の時間軸に対応付ける必要があります。
2.2 同じフレームを特定することが難しい
次の問題はフレーム同定です。E2E レイテンシは「送信側でそのフレームがいつキャプチャされたか」と「受信側でそのフレームがいつ表示されたか」の差なので、まず両者が同じフレームを指している必要があります。
素朴に考えると、Android 側で「いま描画されようとしているフレーム」を見つけて、そのフレームが送信側でいつキャプチャされたものなのかが分かれば十分に見えます。つまり、受信側で見えている 1 枚のフレームに対して、「いつのフレームなのか」と「いつ描画されたのか」を結び付けられればよさそうです。
しかし、Android アプリケーションコードから見えているのは、libwebrtc の C++ 側で扱われているフレーム情報のうち、Java API へ公開されている部分だけです。少なくとも今回利用していた Android 側の Java API(org.webrtc.VideoFrame)からは、abs-capture-time 由来の情報やそれに準ずる照合キーを直接取得できませんでした。
つまり、Java 側では「いま描画されようとしているフレーム」は見えても、それが送信側でいつキャプチャされたフレームなのかまでは分かりません。そのため、フレーム同定を成立させるには、C++ 側で保持されているフレーム情報を取得できる、より下位の層を扱う必要がありました。
3. 計測パイプラインの全体像
3.1 計測値が成立するまでの流れ
この計測では、時計合わせとフレームの対応付けが別々に進み、最後に合流します。DataChannel だけでも RTP だけでも完結しません。DataChannel だけでは実際に描画された映像フレームそのものを追えず、RTP / VideoFrame 側だけでは送信側 CLOCK_MONOTONIC と受信側 CLOCK_MONOTONIC の橋渡しができないためです。

この図は、最終的な E2E レイテンシが client 側で成立するまでの流れを表しています。まず DataChannel で送信側と client 側の CLOCK_MONOTONIC の時計差を推定します。並行して、受信した VideoFrame から送信フレーム照合に必要な値を取り出し、DataChannel で届いたフレームメタデータと紐づけます。さらに、描画用経路では render callback で表示時刻を取得し、VideoFrame.timestampUs を手がかりに表示されるフレームと対応付けます。最後に、時計差推定の結果と 2 段階のフレーム照合結果を合わせて、送信側キャプチャ時刻と client 側描画時刻の差を計算します。
3.2 図の読み方と各経路の役割
この図で重要なのは、時計合わせとフレーム同定が別々の問題として進み、最後に合流することです。時計合わせは「送信側のキャプチャ時刻を client 側の時間軸へ載せる」ための処理であり、フレーム同定は「送信側で記録した時刻が、client 側でどの表示フレームに対応するか」を特定するための処理です。
3.2.1 時計合わせで解いていること
最終的にレイテンシを計算するには、送信側のキャプチャ時刻と client 側の描画時刻を同じ時間軸で比較できる必要があります。しかし sender と client は別マシンなので、両者の CLOCK_MONOTONIC はそのままでは比較できません。
そこで DataChannel 上で時計合わせ要求と時計合わせ応答を往復させ、NTP と同型の方法で時計差を推定します。図では、client 側で「時計合わせ要求を送信」し、送信側が「時計合わせ要求を受信」して「時計合わせ応答を返す」流れに対応します。この往復によって、client 側では「CLOCK_MONOTONIC の時計差推定値を更新」できるようになります。
ここで得られる時計差推定値は、最後に「送信側 CLOCK_MONOTONIC のキャプチャ時刻を client 側 CLOCK_MONOTONIC に変換」するために使います。つまり時計合わせの役割は、送信側で記録したキャプチャ時刻を、client 側の描画時刻と直接比較できる形へ写像することです。
3.2.2 フレーム同定で解いていること
もうひとつ必要なのが、送信側で記録したキャプチャ時刻を、client 側で受信・表示された特定のフレームへ結び付けることです。本計測ではこれを 2 段階で行います。
まず送信フレーム照合では、DataChannel で受け取ったフレームメタデータと、計測用 VideoSink で取得した VideoFrame を紐づけます。図では、送信側で「フレームメタデータを送信」し、client 側で「フレームメタデータを受信」したあと、「送信フレーム照合」で計測用 VideoSink 側の VideoFrame と結び付ける流れです。ここで使うのは abs-capture-time と同じ絶対時刻系の値です。これにより、送信側 CLOCK_MONOTONIC のキャプチャ時刻を、受信側で得た VideoFrame に対応付けられます。
次に表示フレーム照合では、その VideoFrame と render callback を紐づけます。図では、計測用 VideoSink 側で VideoFrame.timestampUs に由来する値を取り出し、描画用経路では「Render callback」で表示時刻を取得し、最後に「表示フレーム照合」で両者を結び付けます。これによって、送信側メタデータと結び付いたフレームが、いつ描画に回されたかを取得できます。
3.2.3 VideoTrack から描画までの 2 経路
client 側では、受信した映像を VideoTrack として受け取り、その先で 2 つの経路を並行に持ちます。ひとつは計測用 VideoSink が C++ 側で VideoFrame を直接受け取る経路、もうひとつは既存の描画用 VideoSink から render callback へ進む経路です。
前者は送信フレーム照合に必要な情報を取り出すための経路であり、後者は表示時刻を取得するための経路です。この 2 経路が同じフレーム系列を共有している前提で、timestampUs を手がかりに最後の表示フレーム照合を行います。
3.2.4 最後に何を計算しているか
ここまでで、送信側 CLOCK_MONOTONIC のキャプチャ時刻は、特定の表示フレームへ対応付けられています。また、時計差推定によって、そのキャプチャ時刻を client 側 CLOCK_MONOTONIC へ写像できます。したがって最後は、「送信側 CLOCK_MONOTONIC のキャプチャ時刻を client 側 CLOCK_MONOTONIC に変換」した値と、render callback で得た描画時刻の差を取ることで、E2E レイテンシを計算できます。
4. フレーム同定のために Java API の外で VideoFrame を取得する
4.1 Java API だけでは必要な情報をそろえられない
第3章で整理したとおり、この計測を成立させるには 2 種類のキーが必要です。ひとつは DataChannel で届いたフレームメタデータと受信フレームを結び付けるための送信フレーム照合キー、もうひとつは受信フレームと render callback を結び付けるための表示フレーム照合キーです。
後者については、Android アプリケーションコードから扱える VideoFrame や render callback の情報で足ります。しかし前者に必要な abs-capture-time 由来の値は、少なくとも今回利用していた Android 側の Java API からは直接取得できませんでした。render callback 側で見えているのは、描画に進む段階のフレームであって、そのフレームが送信側でどの時刻にキャプチャされたものかを示す情報ではないためです。
そのため、Java 側だけで計測を完結させる構成は取りませんでした。必要だったのは、受信した VideoFrame に対して、送信フレーム照合に使う値と、表示フレーム照合に使う timestampUs を同時に扱える場所です。今回の実装では、その役割を C++ 側の OnFrame に持たせました。
計測用 VideoSink の OnFrame では、送信フレーム照合に必要な値と VideoFrame.timestamp_us() を同じフレームから取得し、Kotlin 側へコールバックします。こうすることで、送信側のメタデータと結び付けるための情報と、render callback 側と結び付けるための情報を、同一フレーム単位で扱えます。
そのため、既存の描画経路とは別に、計測専用の VideoSink を VideoTrack へ追加しました。受信した映像はもともと描画用の VideoSink に流れていますが、その経路を置き換えるのではなく、同じ VideoTrack から計測用の経路を分けています。
アプリケーションコードで受け取った VideoTrack に対して、C++ で実装した計測用 VideoSink を追加すると、OnFrame で VideoFrame を直接受け取れます。これにより、送信フレーム照合に使う値と timestampUs を、render callback より手前の段階で同じフレームから取得できます。render callback 側はそのまま残るため、表示フレーム照合の流れは変わりません。
ここで必要だったのは、WebRTC の公開 API を変更することではなく、受信トラックから計測用の経路をもうひとつ分けることでした。描画用 VideoSink と計測用 VideoSink は同じ VideoTrack を共有しますが、役割は分かれています。前者は表示のために使い、後者はフレーム照合に必要な情報を取り出すために使います。
4.2 配布済みの WebRTC ライブラリへどう組み込むか
ここまでで必要なのは、C++ で実装した計測用 VideoSink を、Android アプリが使っている org.webrtc の VideoTrack に接続することだと分かりました。そこで次に問題になるのが、配布済みの WebRTC ライブラリへ、こちらの C++ コードをどう組み込むかです。
ここで使う JNI は、Java や Kotlin から C++ の関数を呼び出したり、逆に C++ から Java や Kotlin のメソッドを呼び出したりするための仕組みです。この実装では、Kotlin 側から JNI を通じて C++ の計測用 VideoSink を生成し、それを VideoTrack へ接続しています。
またこの実装で扱っている org.webrtc は、Android 向けに配布されている WebRTC ライブラリです。アプリケーションコードはその Java API を使って PeerConnection や VideoTrack を扱いますが、内部ではネイティブライブラリが動いています。
ここで取り得る方法は大きく 2 つあります。ひとつは WebRTC 本体を自前でビルドし、必要な情報を Java API へ出せるように改造したうえで、その改造版をアプリへ組み込む方法です。もうひとつは、配布済みのライブラリはそのまま利用しつつ、アプリ側で追加した C++ コードだけを別ライブラリとしてビルドし、既存の VideoTrack に接続する方法です。今回は後者を選びました。
前者の方法では、必要な情報を Java 側から直接扱えるようにできますが、WebRTC 本体のビルド、改造差分の維持、ライブラリ更新時の追従が継続的に必要になります。今回必要だったのは、受信フレームから特定の情報を取り出すための処理であり、受信、デコード、描画の全体を作り替えることではありませんでした。そのため、ライブラリ本体には手を入れず、必要な処理だけをアプリ側の C++ コードとして追加する構成を採っています。
この構成では、アプリ本体はこれまでどおり org.webrtc の API を使い続け、追加した C++ コードは計測用 VideoSink の実装だけを担当します。Java や Kotlin と C++ の橋渡しは JNI が担うため、アプリ側では既存の VideoTrack に計測用 VideoSink を接続する処理と、C++ から返ってきた値を受け取る処理を追加すれば足ります。つまり、映像受信の本体は配布済みライブラリに任せたまま、計測に必要な部分だけを別のネイティブライブラリとして補う形です。
ただし、ここで「配布済みライブラリをそのまま使う」と言っても、単に Java からネイティブ関数を呼べば済むわけではありません。実際には、配布された AAR に含まれるネイティブ実装と、こちらがビルドする C++ コードが、同じ前提で VideoFrame やその関連構造を解釈できる必要があります。したがって難しさの中心は JNI の記法よりも、既存の WebRTC 実装へ安全に接続する条件を満たすことにありました。
4.3 ABI とビルド条件の整合が必要だった
この方式では、追加した C++ ライブラリと配布済みの libwebrtc が、同じ ABI で連携する必要があります。AAR には複数のネイティブライブラリが含まれますが、今回問題の中心だったのは WebRTC 本体にあたる libjingle_peerconnection_so.so です。一方、計測用 VideoSink を実装してアプリ側で追加した自前のネイティブライブラリを latency_sink とします。
ここで扱っているのは、単純な値の受け渡しではありません。libwebrtc 側で生成された VideoFrame を latency_sink 側の VideoSink が受け取り、packet_infos() のような C++ のメソッドや内部構造へアクセスします。そのため、2 つのライブラリのあいだで、オブジェクトのメモリレイアウト、メソッド呼び出し規約、inline 関数の展開結果まで揃っている必要があります。この整合を取るために、CMake では参照する WebRTC ヘッダを明示し、自前のライブラリが libwebrtc と同じ型定義を前提にビルドされるようにしています。
実際にも、x86_64 のエミュレータでは動作した一方で、ARM 実機ではクラッシュしました。調査の結果、ARM 側の libwebrtc が relative vtable ABI 前提で仮想関数を呼び出していたのに対し、当初の latency_sink はその前提でビルドされていなかったことが原因でした。この調査過程の詳細は付録 A に記載しています。
修正として、ARM 向けには -fexperimental-relative-c++-abi-vtables を有効にしました。これは libwebrtc と latency_sink のあいだで、vtable を使った仮想関数呼び出しの方式をそろえるためです。この修正によって実機で再現していたクラッシュを解消できました。
5. まとめ
元々は、前提条件を変えながらレイテンシを測定し、CPU や GPU、各種 codec の違いを分析する記事を書くつもりでした。しかし、レイテンシの測定系そのものが大変すぎてその部分だけでひとつの記事になりました。
かなりニッチな話題ではありますが、似た計測系を設計するときなどで参考になれば幸いです。
読んでいただきありがとうございます。
付録 A: ARM 実機クラッシュの調査過程
第 4.3 節で、ARM 実機でのクラッシュは relative vtable ABI の不整合が原因だったと書きました。ここでは、どうやってその結論にたどり着いたかを補足します。
ちなみにこの調査は「CPUアーキテクチャの違いが問題の原因なんじゃない?」と私は伝えただけで、コマンドの実行含め他は全て GPT-5.3 Codex xhigh が自律的に行いました。AIってすごいなぁ…
A.1 何が起きていたのか
クラッシュするのは ARM 実機(arm64)だけで、x86_64 のエミュレータでは問題なく動いていました。tombstone を見ると、状況は次のとおりでした。
- 映像フレーム受信スレッド(
IncomingVideoSt)で落ちている - Java/Kotlin 例外ではなく、ネイティブ領域の
SIGSEGV - backtrace の中心は
libjingle_peerconnection_so.so abiFiltersにはarm64-v8a/armeabi-v7a/x86_64がすべて入っており、ARM 向け.soが欠落しているわけではない
A.2 relative vtable ABI だとどうやって分かったのか
tombstone のクラッシュアドレスをもとに、libjingle_peerconnection_so.so の該当箇所を逆アセンブルしました。そこで目に入ったのが、仮想関数ディスパッチに ldrsw 命令が使われていたことです。
ここで、2 つの vtable 形式の違いを簡単に整理します。
- absolute vtable: vtable エントリに関数ポインタがそのまま入っている。
ldrで読み出してジャンプする。 - relative vtable: vtable エントリには「エントリ自身のアドレスから関数アドレスまでの 32-bit 相対オフセット」が入っている。
ldrsw(符号拡張付き 32-bit ロード)でオフセットを読み、エントリのアドレスに加算して関数アドレスを求める。
ldrsw を使った仮想関数ディスパッチと、後述する use_relative_vtables_abi のビルド条件、さらにリロケーション差分を合わせて考えると、libjingle_peerconnection_so.so 側が relative vtable 前提であるという解釈をしました。一方、liblatency_sink.so の vtable は absolute 形式のままだったため、そこには 64-bit の absolute アドレスが入っています。これを relative vtable 前提で ldrsw を使って下位 32-bit だけ符号拡張して読み出すと、まったく関係のないアドレスが計算されます。実際 tombstone に残っていたレジスタ値 x9=0xffffffffa06860ac はその符号拡張の結果で、ここへジャンプして SIGSEGV になっていました。
修正後の裏付けとして、llvm-readelf -r で liblatency_sink.so を確認したところ、修正前にあった LatencyVideoSink の vtable 周りの R_AARCH64_ABS64 リロケーションが消えていました。vtable が absolute から relative に切り替わったことが、ELF レベルでも確認できたということです。
A.3 なぜ x86_64 では動いて ARM だけ壊れたのか
libwebrtc のビルド設定で、relative vtable ABI が ARM ターゲットでのみ有効にされているためです。Chromium の build/config/compiler/compiler.gni を見ると、use_relative_vtables_abi は次の条件で有効になります。
use_relative_vtables_abi =
is_fuchsia || (is_android && current_cpu == "arm64" &&
use_custom_libcxx && !is_component_build)
is_android && current_cpu == "arm64" のときだけ対象であり、x86_64 は含まれません。コメントには "reduce the number of relocations"(リロケーション数の削減)が目的と書かれています。
そのため、x86_64 では libjingle_peerconnection_so.so も liblatency_sink.so も absolute vtable で一致しており、食い違いが起きません。ARM だけ libjingle_peerconnection_so.so 側が relative vtable でビルドされていて、liblatency_sink.so はデフォルトの absolute vtable のままだったため、ARM でだけ問題になりました。