- FFmpegの最初のアセンブリ講義では、マルチメディア処理で 手書きのSIMDアセンブリ がなぜ必要なのかと、FFmpeg流の関数作成慣習を入門レベルで整理している
- 対象読者は Cのポインタ と高校レベルのスカラー・ベクトル、加算・乗算の概念を理解している必要があり、講義は x86 64-bit と Intel 構文を基準に進む
- FFmpegでは assembly function、SIMD、vectorise がほぼ同じ意味で使われ、複数のデータ要素を一度に処理する方式が画像・動画・音声処理に適している
- 性能面では 手書きアセンブリ を好み、intrinsics は通常 10〜15% 遅く、dav1d の自動ベクトル化は約 2x、手書き版は 8x まで到達した事例が比較されている
- 最初の例題関数は
x86inc.asm、INIT_XMM sse2、cglobal、movu、paddb、RET を使って、2つの uint8_t バッファの16バイト値を SIMD で加算し、その後最初のバッファに再保存する
講義の目的と前提知識
- FFmpeg Assembly Language Lesson One は、FFmpegでアセンブリ言語が書かれる方法の基礎を扱い、コンピュータ内部で実際に何が起きているのかを理解できるようにする入門講義である
- 必要な知識は次のとおり
アセンブリ言語とSIMD
- アセンブリ言語 は、CPUが処理する命令に直接対応するコードを人間が読める形で記述するプログラミング言語である
- 人間が読めるアセンブリコードはアセンブラを経て、CPUが理解する 機械語(machine code) のバイナリデータに変換される
- FFmpegのアセンブリコードの大半は SIMD(Single Instruction Multiple Data) 形式である
- 1つの命令が複数のデータ要素に同時に作用する
- ベクトルプログラミングとも呼ばれる
- 一般的なスカラープログラミングは一度に1つのデータ要素を処理する
- SIMDは、メモリ上に順序よく配置された大量のデータを扱う画像・動画・音声処理に適している
- FFmpegでは次の表現がほぼ同じ意味で使われる
assembly function
SIMD
vectorise
- 手書きのアセンブリ関数で複数のデータ要素を一度に処理するという意味である
FFmpegがアセンブリを直接書く理由
- 中核目的は マルチメディア処理の高速化 である
- アセンブリコードを書くことで 10x 以上の高速化が珍しくない
- リアルタイム動画再生でコマ落ちを減らすうえで重要である
- エネルギー消費を減らし、バッテリー寿命を延ばせる可能性がある
- 動画のエンコード・デコード関数はエンドユーザーとデータセンターの両方で非常に多く使われるため、小さな改善でもすぐに積み重なる
- FFmpegは intrinsics の代わりに 手書きアセンブリ を使う
- intrinsics はアセンブリ命令に対応する C 風の関数である
- 通常、手書きアセンブリより 10〜15% 遅い
- この数値はコンパイラによって変わり、intrinsics 支持者は同意しない可能性がある
- Hungarian Notation を使うため読みにくいという意見もある
- inline assembly は、FFmpeg の一部の旧コードや Linux Kernel のようなプロジェクトに残っている場合がある
- 別ファイルではなく C コード内に直接アセンブリを書く方式である
- FFmpeg のようなプロジェクトでは、読みにくく、コンパイラ対応が広くなく、保守しづらいという見方が強い
- コンパイラの自動ベクトル化だけで十分だという意見は、学習目的では無視してよいとしている
- dav1d プロジェクト の最近のテストでは、自動ベクトル化は約 2x の高速化を示した
- 手書き版は 8x まで到達できた
構文の範囲と参考資料
- 講義は x86 64-bit アセンブリ に集中している
- amd64 とも呼ばれ、Intel CPU でも動作する
- ARM、RISC-V など他CPU向けアセンブリへの拡張可能性もある
- x86 アセンブリ構文には AT&T と Intel がある
- AT&T 構文はより古く、Intel 構文より読みにくいとされる
- 講義では Intel 構文 を使う
- 一般的な書籍や Stack Overflow などのオンライン資料は、FFmpeg アセンブリの参考としてはとくに有用でない場合がある
- 手書きの Intel 構文アセンブリを使うためである
- 多くのオンライン資料は OS プログラミング、ハードウェアプログラミング、非SIMDコードに焦点を当てている
- FFmpeg アセンブリは高性能な画像処理に特化した独特のアプローチである
- The Art of 64-bit assembly 後半にある SIMD 命令と動作を視覚化した図は役立つ可能性がある
- 質問用の Discord サーバーが提供されている
レジスタの基本概念
- レジスタ は、CPU内でデータを処理する領域である
- CPUはメモリを直接演算せず、データをレジスタに読み込んでから処理し、再びメモリへ書き戻す
- アセンブリでは通常、あるメモリ位置から別のメモリ位置へデータを直接コピーすることはできず、まずレジスタを経由する必要がある
汎用レジスタ
- GPR(General Purpose Register) は、データまたはメモリアドレスを保持できる汎用レジスタである
- ここでは最大 64-bit の値を保持できる
- ポインタも保持できる
- 加算、乗算、シフトなどの演算が可能である
- 多くのアセンブリ本は GPR の詳細や歴史的背景を長く扱う
- FFmpeg のアセンブリコードでは GPR は主に 足場(scaffolding) のように使われ、複雑さの大半は不要か抽象化されている
ベクトルレジスタとデータサイズ
- ベクトルレジスタ は複数のデータ要素を保持する
- x86 の主なベクトルレジスタは次のとおり
mm: MMX レジスタ、64-bit サイズ、歴史的なもので現在はあまり使われない
xmm: XMM レジスタ、128-bit サイズ、広く利用可能
ymm: YMM レジスタ、256-bit サイズ、使用時にいくつか複雑さがある
zmm: ZMM レジスタ、512-bit サイズ、利用可能な場面が限られる
- 動画の圧縮・伸張の大半の計算は 整数ベース なので、講義も整数に焦点を当てる
- 1つの
xmm レジスタの 128-bit は次のように解釈できる
- 16 個の byte、各 8-bit
- 8 個の word、各 16-bit
- 4 個の doubleword、各 32-bit
- 2 個の quadword、各 64-bit
- 略語は以後重要になる
- byte: 8-bit データ
- word: 16-bit データ
- doubleword: 32-bit データ
- quadword: 64-bit データ
- double quadword: 128-bit データ
x86inc.asm の役割
x86inc.asm は FFmpeg、x264、dav1d で使われている 軽量な抽象化レイヤー である
- アセンブリプログラマがコードを書きやすくするさまざまな機能を提供する
- 初期段階で重要な機能の1つは、GPR に
r0、r1、r2 のようなラベルを付けることである
- 実際のレジスタ名を覚える必要がない
- FFmpeg では GPR が主に足場の役割を果たすため、記述の負担が減る
簡単なスカラー asm の例
mov r0q, 3
inc r0q
dec r0q
imul r0q, 5
- 1行目は 即値(immediate value)
3 を r0 レジスタに quadword として格納する
- 即値はメモリから読み出す値ではなく、アセンブリコード自体に埋め込まれた値である
- Intel 構文では右側のソースオペランドが左側のデスティネーションオペランドへ渡される
r0q = 3 のように読める
memcpy の動作と似た順序である
r0q の q 接尾辞は、レジスタが quadword として使われることを示す
- その後の動作は次のとおり
inc で値は 4 になる
dec で値は再び 3 になる
imul で 5 を掛け、最終的に r0q は 15 になる
mov、inc のような人間が読める命令語は mnemonic と呼ばれる
- アセンブラがこれを機械語に変換する
- 大文字の
MOV、INC と小文字の mov、inc は同じである
- FFmpeg では mnemonic は小文字で書き、大文字はマクロ用に残す
最初の SIMD 関数の例
%include "x86inc.asm"
SECTION .text
;static void add_values(uint8_t *src, const uint8_t * src2)
INIT_XMM sse2
cglobal add_values, 2, 2, 2, src, src2
movu m0, [srcq]
movu m1, [src2q]
paddb m0, m1
movu [srcq], m0
RET
- この関数は
src と src2 のデータを SIMD で加算し、結果を src の位置に再保存する
%include "x86inc.asm" は x264、FFmpeg、dav1d コミュニティが開発したヘルパー、事前定義名、マクロを含む
SECTION .text は実行されるコードが置かれるセクションを示す
;static void add_values(uint8_t *src, const uint8_t * src2) は C 関数の引数形式を示すコメントである
- アセンブリではセミコロン
; が C の // と同様にコメントとして機能する
INIT_XMM sse2 は XMM レジスタ と sse2 命令セットを使うよう設定する
cglobal add_values, 2, 2, 2, src, src2 は C 関数 add_values を定義する
- 関数引数は 2 個である
- 関数で使う GPR は引数を含めて 2 個である
- 使用する XMM レジスタ数は 2 個である
- 最後の2項目は関数引数ラベル
src、src2 である
- 古いコードでは引数ラベルなしで
r0、r1 のような GPR を直接使うこともある
load, packed add, store
movu m0, [srcq]
movu m1, [src2q]
movu は movdqu の短縮形で、move double quad unaligned を意味する
- アラインメント(alignment) は後の講義で扱い、ここでは
[srcq] から 128-bit を移動する命令と考えればよい
mov での角括弧はアドレスのデリファレンスを意味する
- C の
*src に近い概念である
- この動作は load である
q 接尾辞はポインタサイズを指す
- 64-bit システムでは C のポインタサイズである 8 バイトを表す
- x86asm は 32-bit システムでは 32-bit を使う
- 実際の load は 128-bit である
- ベクトルレジスタは
xmm0 のような正式名ではなく、抽象化された m0 で参照する
- これは後の講義で同じコードを複数の SIMD レジスタサイズに対応させる方法につながる
paddb m0, m1
paddb は各レジスタの byte 要素同士を加算する
p 接頭辞は packed を意味し、ベクトル命令とスカラー命令を区別するために使われる
b 接尾辞は byte 単位の加算を表す
- 16 個の byte を含む2つのレジスタを加算すると、各位置ごとに
a+q、b+r、c+s のように対応する要素同士が加算される
movu [srcq], m0
RET
movu [srcq], m0 は結果データを srcq ポインタが指すアドレスへ書き戻す
RET は関数が返ることを示すマクロである
- FFmpeg のアセンブリ関数のほとんどは値を返すのではなく、引数として受け取ったデータを変更する
- 課題では、利用可能なアセンブリ関数に対する関数ポインタを作り、それを使う形へと続いていく
1件のコメント
Hacker News のコメント
同じテーマの別資料: https://blogs.gnome.org/rbultje/2017/07/14/writing-x86-simd-...
ここのコメントを見ると、手書き SIMD の有用性については「まったく不明」から「ミッション必須」まで意見が分かれていますが、「まったく不明」側のほうが多いように見えるので、ミッション必須側を少し説明してみます。
FFmpeg は利用頻度の高さから明確な事例ですが、汎用のプロダクション向け AV1 デコーダである dav1d のほうが、手書き SIMD の影響を定量化しやすい例だと思います。
dav1d は主要ブラウザから Android OS まで、ほぼあらゆる場所で使われており、libgav1 を置き換えました。その成功の大きな理由は圧倒的な速度で、これはコードベースのかなりの部分が手書き SIMD だからこそ可能になっています。
Zig のような言語が組み込み SIMD サポートを備えるのは良いことですが、潜在的な性能差も調べる価値がある用途では、直接書く必要が出てきます。dav1d のあるコード行は 1 日に数兆回実行されるため、可能な限り速くなければならず、手書き SIMD とコンパイラ生成 SIMD の差が場合によっては最大 50% に達することもあるので重要です。
私も同じように非常に大量に実行されるコードを書く仕事にある程度関わっているので、こうした技術を失わせないためには、FFmpeg アセンブリ言語学校のような資料はかなり重要だと思います。
メソッドごとに呼び出し規約を変えることもできるので、通常のプラットフォーム呼び出し規約に従うコンパイラ生成コードよりも、スタックへの保存と読み出しがずっと少なくなります。
たとえば私たちの新しい行列積は、LLM 推論向けの有名ライブラリより高速で、相手が AMX を使い、こちらが AVX512BF16 を使っている場合でもそういうことがあります。理由はスレッディングのボトルネックのようにも見えますし、別の原因かもしれませんが、JIT が絡むと把握が難しくなります。
プラットフォーム別カーネルを自分で書かなければならなかったなら、この結果は出なかったでしょう。1 日の時間は限られており、Highway で単一実装を書けたおかげで、新しいカーネル種別やブロックサイズだけでなく、並列化戦略とそのパラメータまで選ぶオートチューナーを含め、より広い設計空間を探索できました。
第 2 段階で一部を手でチューニングすることはできますが、レジスタ割り当てや呼び出し規約を微細最適化する前に、まずより広い探索が行われてほしいと思います。
Zig は好きですが、ほとんどの場合は C/C++ のように CPU 別の組み込み関数を使う必要がある、と理解しています。
GCC と Clang は
vector_size属性と、その「ベクトル化された」型に対する算術演算子オーバーロードをサポートしており、それ以外にもはるかに多くの機能があります。実際、_mm256_mul_psのようなイントリンシックも#define _mm256_mul_ps(a,b) (__m256)((v8sf)(a) * (v8sf)(b))のように実装されています。これらの機能の有用性は、Zig で可能なことよりはるかに大きいです。
エンコーダ側では特に、ループやロードを早い段階で取り除けるように問題を「構造化」する必要がある場合が多いのですが、コンパイラはそうした種類の自動ベクトル化コードを生成できません。
以前はコア関数のSIMD版をかなり多く作っていたが、今はほとんどやっていない。試す価値のある方法は、そのコードを切り出して、素晴らしい Compiler Explorer [0] で動かしてみること
そして生成されたコードを眺めればよい
最近は、自動ベクトル化が関数のSIMD版をかなりうまく生成することが多く、コンパイラに「ヒント」を与えるだけで済む場合も多い。たとえばアラインメントを明示したり、ベクトルのソース/ターゲット型を直接与えたりする、といった具合
コンパイラが何をできるかを考えながらCコードを「スタイリング」すれば、多くのことができる。中間変数を増やし、望む演算を本当に細かく分解する、というやり方
最悪、コンパイラが十分に賢くなくても、生成されたアセンブリを基に、ボイラープレートを自分で書かずに修正できる
たいていの場合、結果のC関数は私が手で書いたものと同じくらい、またはそれ以上にうまくベクトル化されるし、それ以外の多くの場合でも「十分に近い」ので大きな差は出ない。また、そのコードはWASMやNEONなどでも、明示的な版なしにうまくベクトル化される可能性が高い
[0] https://godbolt.org/
GCCとClangはいずれも一部のベクトル拡張をサポートしているので、散在ロード/ストア、シャッフル、単一レジスタ内の要素マスクのように、「純粋な」Cでは人間に読みやすく、かつコンパイラのバージョン間でも常に期待したコードを生成するよう明確に表現するのが難しいものを、ある程度移植可能に実装できる
しかし他のコンパイラやプラットフォームもサポートする必要があるため、実際のビルドでは結局、そのソースファイルから生成されたアセンブリを取り込んで使うことになった
これは 符号なしバイトの飽和加算 である。x86-64とARM64のどちらでも、PADDUSBとUQADD.16Bという単一命令で直接サポートされている
しかしどのコンパイラも、直感的な記述からはひどい結果を出し、ベクトル化に失敗するか、必要以上に大きく遅いベクトル化コードを生成する
これは基本的で単純なベクトル化プリミティブ演算なのにこの有様だ。丸め付き縮小飽和右シフト(UQRSHRN)のような、より複雑な命令をコンパイラに使わせるのは難しいか、ほとんど不可能だ
たいていは、単に自分で書くほうが速い
pshufbを生成させる方法が分からない16バイト幅の場合でも、
pshufbの実際の定義を使うとpshufbは得られず、未定義動作を含むバージョンを使うとpshufbが出てくるこの講義の作者です
何でも聞いてください
アセンブリがそれほど多いなら、FFmpegが私のMacで動作しているのが奇跡のように見えます。手作業で移植しているのでしょうか?
どの時点で「これはアセンブリを書けば速くできそうだ」と気づくのかが気になります。アセンブリにすれば本当に速くなる関数を見つけたら、どう書くのでしょうか? コンパイラがアセンブリに変換した出力を取ってきて始めるのか、それとも最初から書くのか。そもそもそれは重要なのでしょうか?
開発者の使いやすさやコード性能は、従来のSIMDと比べてどうなのでしょうか? プログラミングすべきSIMD命令セットの種類が減っていく世界に向かっているのでしょうか?
x86asm.incが処理しているのか、それとも気にしていないのかが気になりますテストだけを書いて、LLMに10,000通りのアルゴリズムを試させ、その結果をプロファイルさせることはできないのでしょうか?
それとも、10,000個のランダムシードを与えても、LLMが最適解を見つけるのは難しいのでしょうか?
単純に気になって聞いています。x86を手で最適化するのは簡単ではありません。考えるには、すべてのレジスタを頭の中に収めて組み合わせを検討しなければならず、各命令の組み合わせにどれくらい時間がかかるかも知っている必要があります。また一部の命令には、人間が考慮しにくい奇妙な例外ケースがあり、ずっと遅くなったり、ずっと速くなったりすることがあります
実際にやってみた人たちに聞きたい。アセンブリを学んだり実装したりすることには、LISP や RISC-V のような何らかの楽しさがあるのか、それとも特定のシステム作業のために COBOL を学ぶように、別のことをするために学ぶものなのか?
ずっと興味はあったが、日常業務で掘り下げる理由が特にない。趣味として時間を投じる価値があるのか気になっている
今ではアセンブリでコーディングするのがかなり好きになった。チュートリアル以降は、C から呼び出せる配列ライブラリを作った以外はあまりやっていない
このレベルだと残る魔法がほとんどないので面白いのだと思う。本当に何が起きるべきかを正確に指示し、見えているものがだいたいそのまま結果になる
リンクについてもずっとよく理解できるようになったし、高いレベルでは知っていたが細部がぼんやりしていたことの理解にも役立った
今度はこの FFmpeg チュートリアルも見てみたい。これは ARM ではなく x86 だから
チューリングマシンやラムダ計算のような、より単純な理論モデルもあるが、プログラマーが実際に扱うアーキテクチャにはある程度寛容な特性がある
怖がる対象ではない。アセンブリは複雑というより冗長だ。何をするにもロードとストア、ロードとストアが延々と繰り返される
マクロとビルド時チェックを少し追加したり、「アセンブリの塊を実行する」インタープリタを囲んで対話的開発やスクリプティングを可能にする Forth システムの文脈に置いたりすると、C とも大きくは離れておらず、コンパイラの魔法を取り除いてくれる
レトロに取り組むのもおすすめだ。エミュレータ上の 8 ビットマシンは作業モデルを小さく文書化の行き届いた領域に閉じ込めてくれ、制約があるおかげでより多くの作業をアセンブリでやってみるという考えに価値が出る。32 ビット以降のアーキテクチャでリソースが豊富な場合、こうしたことはあまり多くない
仕事でアセンブリを開発している人にはもっと具体的な好みがあるだろうが、初心者にとって最も必要なのは、文書と例が充実した環境だ。Rosetta Code には学習用に使える良いアセンブリの例がある
大学の授業で、ある特定の作業について誰が最も高性能なアセンブリプログラムを作れるか競争したのを覚えている。皆が最高性能を絞り出し、悪い分岐予測を避けるために、ループ展開のさまざまなバリエーションを試していた
提出前夜に Ballmer Peak に到達していたのかは分からないが、ほかの人たちがほとんど見落としていた構成を試して、僅差で優勝した
また https://github.com/chrislgarry/Apollo-11 を見て「これは Unix システムだ、分かるぞ!」と冗談を言える大きな楽しさもある。人類を月へ送った言語を読めるという驚きは消えない
短く答えるなら、イエスだ
トランジスタ、論理ゲート、CPU アーキテクチャ、高水準プログラミングがどのように噛み合うのかを理解する瞬間は、職業上アセンブリをまったく使わないとしても、費やした労力に見合う価値がある
ときどき役に立つこともあるが、最後の 1 バイトまで所定の位置に収めたり、何十年も誰も覗いていないバイナリを掘り返したり、昔は不可能だったエミュレータを作ったりすることから来る楽しさが大きい
始めたころと同じように、今でも魔法を感じる数少ない分野の一つだ
個人的には、イントリンシックの代わりにアセンブリを直接書くことに大きな価値はないと思うが、アセンブリを読むことは本当に役に立った
Compiler Explorer(https://godbolt.org/)をよく使って生成されたアセンブリを見て、コンパイラが性能最適化時に行う最適化を理解した
「マルチメディア処理を高速化するためだ。アセンブリコードを書くと 10 倍以上の高速化が得られることは非常によくあり、途切れなくリアルタイムで動画を再生しようとするときには特に重要だ」
K&R への言及がうれしかった。C とプログラミング全般を学ぶために買った本だった
最初は C++ を最初の言語として試したが、内部で何が起きているのかがずっと気になって、抽象的すぎるように感じ、学ぶのが難しかった
この資料はちょうどいい。386 時代の x86 アセンブリは知っていたが、より高度なプロセッサでは複雑すぎた
最新 CPU のSIMDをもっと学びたかったので、素晴らしい資料に見える
「
q接尾辞はポインタサイズを指すことに注意せよ *(*つまり C では 64 ビットシステムで *sizeof(*src) == 8 を意味し、x86asm は 32 ビットシステムでは 32 ビットを使う程度には賢い) しかし実際のロードは 128 ビットだ」という文は混乱するi.eはi.e.,であるべきだと思うし、*(*は何を意味しているのか? 単なる開き括弧であるべきではないのか?どの文脈で
*sizeof(*src)が有効だと見なせるのかも分からない。私の理解ではsizeofはポインタを返さないその文には任意のアスタリスクがばらまかれているか、イタリックを意味するアスタリスクと C の文法を混ぜようとして失敗したように感じる
分割方法は気にしないが、このガイドは本当に良いと言いたい
以前、非常に低レベルなことに興味があったときに、こういう資料があればよかったと思う
アセンブリがCより10倍速いって? ある時点では確かにそうだったのだろうが、今でもそうなのか? コンパイラは本当にそこまで停滞していて、手書きアセンブリに近づくことすらできないのか?
FFmpegの開発者たちはイントリンシックにかなり悪名高いほど反対する傾向があり、記憶が正しければ、同等のアセンブリと同じくらい性能がよくてもコードベースでは許可しない。それでも記事自体の推定でも、イントリンシックとアセンブリの差はおおよそ10〜15%程度である
入念に最適化したアセンブリと素朴なCを比べると、ベクトル化が可能なのにコンパイラが活用できない場合、10倍の差を見ることがある。こういうことは珍しくなく、自動ベクトル化は些細なケースを超えると、まだ総じていまひとつである
ただし、専門家が書いたコードが素朴なコードを圧倒するのは驚くことではない
もっと簡単に言えば、Cコンパイラは普通のC実装だけを見て、特定の数学をより効率的なSIMDイントリンシックで表現しようとしているのだと推論することはできない。作者がやろうとしている数学的意図にアクセスできないからである
ターゲットごとの考慮事項もある。コンパイラは必然的に汎用コンパイラである。リソース、たとえばレジスタ割り当てのような問題はNP完全で、ナップサック問題と同等である。コンパイラが絶対的に最適なアセンブリ生成を探すために何時間も費やすことを望む人はほとんどいない。その最適性を静的に知ることができるかどうかも別問題である
コンパイラは一般的なコードでは賢いが、コーデックは一般的なコードではない。FFmpegプログラマではないが、オーディオを扱った経験はある
SIMDを適用できる問題では、素朴なスカラー実装より2倍から16倍程度速くなることを合理的に期待できる
私にはCより10倍良いアセンブリは書けないが、誰にもできないとは仮定しない