ただ残念ながら唯一の欠点として、処理速度が遅いという問題があったので、これをいろんな方法で高速化してきた。まあ試行錯誤とかでだいぶ時間も突っ込んできたし、自動フィールドシフト高速化は(おそらくあまり期待してる人はいないと思うけど)結構いろいろやってみているもののひとつ。
自動フィールドシフトの処理の概要
前も書いた気がするけど、画像処理の高速化では、演算をSIMD化してしまうと、演算は十分速くなってしまい、演算そのものよりもその処理でどのくらいのメモリアクセスがあって、どういったアクセスパターンになっているかを把握することが重要になってくる。
自動フィールドシフトも重い処理はほぼSIMD化されているので、ここでは、主にどういう重い処理があって、その処理が読むデータと書き出すデータはどうなっているかを確認すると、まあおおまかにはこんな感じ。
get_ycp_cahce入力プラグインがデコードしたフレームを受け取り、フレームバッファにコピーする。基本的には、フレームのデコードとメモリコピーにかかる時間。
scan_frame最も重い処理。スレッド並列化されていて、設定の「スレッド数」はここのスレッド数。フレームバッファの2枚のフレームを参照して縞や動きがあるかを解析し、解析データとして書き出す。
count_motion解析データから、動きありとして判定された画素の数をフィールドごとに数える。解除パターン決定に使われる。
analyze_frame2枚の解析データから、解析マップを作成する。
analyze_map_filter解析マップにぼかしフィルタをかけ、細かなノイズによると思われる判定ノイズを取り除く。
count_stripe解析マップから、縞ありとして判定された画素の数を数える。解除パターン決定に使われる。
blendフレームバッファと解析マップから、インタレ解除されたフレームを再構成する。
というわけで、まあいろいろな処理をやるので、当然遅いのだ…。
計算時間の確認
計測環境OS | Win10 x64 | Win10 x64 |
---|
CPU | i7 4770K (4C/8T) | i7 6700K (4C/8T) |
CPU世代 | Haswell | Skylake |
Core | 4.0GHz | 4.3GHz |
UnCore | 4.0GHz | 4.1GHz |
キャッシュ | L3=8MB | L3=8MB |
メモリ | DDR3-1600, 2ch | DDR4-2933, 2ch |
CL | 8-8-8-24-2 | 16-18-18-34-2 |
その他計測環境・計測条件Aviutl 1.00
入力プラグイン: L-SMASH Works r917 (POP氏ビルド)
入力: MPEG2 1920x1080 29.97fps 10240フレーム
一発勝負
自動フィールドシフトのオリジナル( = r0 )と、これまでのいくつかの高速化版で、どのくらい速くなったのか測定した。
i7 4770K 測定結果
i7 6700K 測定結果
まあ、最初は劇的に、その後は少しずつ速くなっていっているのが分かる。オリジナル(=r0)からくらべると、まあ、だいたい3倍ぐらい速くなっているのが分かる。
それぞれどういう高速化をしたか振り返ると、
r0 (オリジナル) → r5SSE化とSIMD化されていない箇所のSIMD化・もともとのMMX命令(64bit幅)からSSE命令(128bit幅)に更新。
もともとはasmだったのだけど、ちょっとアセンブラを書く知識はなかったので、intrinsicで書いてみた。それなりに性能が出せた。
・count_motionなどSIMD化されていなかった処理をSIMD化。
count_motionのSIMD化は劇的な効果があった(15倍以上)。
まあ、これを
for(pos_y = top; pos_y < scan_h - bottom - ((scan_h - top - bottom) & 1); pos_y++){
sip = sp->map + pos_y * si_w + left;
if(is_latter_field(pos_y, sp->tb_order)){
for(pos_x = left; pos_x < scan_w - right; pos_x++){
lf_motion += ~*sip & 0x40;
sip++;
}
}else{
for(pos_x = left; pos_x < scan_w - right; pos_x++){
ff_motion += ~*sip & 0x40;
sip++;
}
}
}
こうする古典的なSIMD化である。
#if USE_POPCNT
#define popcnt32(x) _mm_popcnt_u32(x)
#else
#define popcnt32(x) popcnt32_c(x)
#endif
const __m128i xMotion = _mm_set1_epi8(0x40);
for (int pos_y = sp->clip.top; pos_y < y_fin; pos_y++) {
BYTE *sip = sp->map + pos_y * si_w + sp->clip.left;
const int is_latter_feild = is_latter_field(pos_y, sp->tb_order);
const int x_count = scan_w - sp->clip.right - sp->clip.left;
BYTE *sip_fin = sip + (x_count & ~31);
for ( ; sip < sip_fin; sip += 32) {
x0 = _mm_loadu_si128((__m128i*)(sip + 0));
x1 = _mm_loadu_si128((__m128i*)(sip + 16));
x0 = _mm_andnot_si128(x0, xMotion);
x1 = _mm_andnot_si128(x1, xMotion);
x0 = _mm_cmpeq_epi8(x0, xMotion);
x1 = _mm_cmpeq_epi8(x1, xMotion);
DWORD count0 = _mm_movemask_epi8(x0);
DWORD count1 = _mm_movemask_epi8(x1);
motion_count[is_latter_feild] += popcnt32(((count1 << 16) | count0));
}
if (x_count & 16) {
x0 = _mm_loadu_si128((__m128i*)sip);
x0 = _mm_andnot_si128(x0, xMotion);
x0 = _mm_cmpeq_epi8(x0, xMotion);
DWORD count0 = _mm_movemask_epi8(x0);
motion_count[is_latter_feild] += popcnt32(count0);
sip += 16;
}
sip_fin = sip + (x_count & 15);
for ( ; sip < sip_fin; sip++) {
motion_count[is_latter_feild] += ((~*sip & 0x40) >> 6);
}
}
いまとなっては、別にわざわざmovemask + popcntをしなくても途中まではSIMDレジスタで和を取っていってもいい気がするが…実はr16あたりでscan_frameに吸収されたので使われていないコードだったりする。
r5 → r8メモリアクセス最適化やはりメモリアクセスの最適化は重要で、かなり高速化した。
・scan_frameで分かれていた関数を統合してメモリアクセスを低減。
・scan_frameなどのフレームを縦にスキャンする部分で、メモリに対してとびとびにアクセスしたのを、なるべく連続アクセスができるように変更。
scan_frameでフレームを縦にスキャンするというのは、まあ図にするとこんな感じで

上下のラインについて計算を行い、加えて一時変数がさらに下のラインの計算結果に影響するので、縦方向に依存関係が発生する。つまり最内ループが縦方向になっている。縦方向にデータを読むと、メモリ上では飛び飛びの位置を読むことになり、メモリアクセスがかなり遅くなる。オリジナル版では上の図のようにMMXレジスタ(64bit幅)を使って、なるべく一度に読み込もうとしていた。
r5では、SSEレジスタ(128bit幅)を使用したことで、一度に処理する量が広がった。

とはいえ、まだわずか128bit( = 16byte)なので、まだまだ縦に飛び飛びのアクセスという感じ。
そこで、r8では、縦方向のループのさらに内側に、横方向にループを追加し、横方向に一度に処理する量を飛躍的に増やした(最大1536byte x4)。

一時変数はレジスタには収まらないので、メモリに読み書きすることになるけど、1.5KB x4 = 6KBなので、L1-Dキャッシュ(32KB)に余裕を持って収まる量、になっている。
こうした最適化はループ構造が無駄に複雑になって、ちゃんと動くコードにするのが大変なのだけど、それなりに成果は出る。
r8 → r10AVX2対応・HaswellのAVX2に対応した。256bit整数演算がついに可能になり、さぞかし速くなるだろう…と思いきや、shuffle命令のスループットが半減したり、そもそも一部のshuffle命令がなんじゃそりゃという仕様だったり(vpalignrとか、vpshufbとか、unpack系とか)、期待したよりは効果がなかった。まあメモリ帯域で律速してしまっていると演算速度はあまり関係ないというのもある。
スレッド分割法の改善によるロードアンバランス(負荷不均衡)の抑制・この時点では、scan_frameだけがスレッド並列化されて高速化されていた。ただ、基本的に並列化では、仕事を各スレッドに分配して計算させて並列に計算させて速くするのだけど、すべてのスレッドが計算を終了しないと、次の計算に進むことができない。

r10まではスレッド間の負荷のバランスがとれていなくて、仕事の割り当ての多い1つのスレッドが終わるまで次に進めず、全体の速度が下がってしまっていたので、分割方法を見直して負荷の不均衡を改善したことで、それなりに高速化に繋がった。r8 → r10での速度向上は、AVX2の効果よりも、こちらの効果のほうが大きい。
r10 → r12・ソフトウェアプリフェッチ再投入により高速化。
r8で連続アクセスが多くなったのでいらないかなと思ってやめていたprefetch命令を再投入した。Intelのマニュアルとかにはハードウェアプリフェッチがよしなにやってくれるからソフトウェアプリフェッチはいらない的なことを書いてるけど、afsの場合にはそんなことはないみたい。まあ、ハードウェアプリフェッチは4Kページ境界を越えられないので、次の行のアクセスなどは明示的にプリフェッチしておくと速くなるということかも。あるいは、D-TLBキャッシュミスも隠ぺいできるのかもしれない。
・フレームを最後に再構成する際にメモリアライメントを考慮して書き出すことで高速化。アライメントが取れていないメモリアクセスは遅いということなので、とにかくアライメントを調整すれば速くなる。アライメント大事。
r12 → r16・scan_frameにcount_motionを統合。
メモリアクセスをさらに減らして、微々たるものだけど高速化。速くするためなら何でもするという方針のもと、0.1ms短縮するために、えらい労力をかけた例。おかげで、count_motion分の0.1msを消すことができた。
・scan_frame以外もスレッド並列を導入(サブスレッド)して高速化。
ただし、対象の処理がほとんどメモリ律速なためか、増やすとすぐ遅くなってしまうので、2スレッドまで。まあ
5960Xとかの4chだと、もう少しスレッドを増やしたほうが速くなったりする。
r16 → r19一部の計算にMMX命令を使ったり(MMXレジスタが使えるので…)、こまごまとした最適化をした。まあ、こんなことではあまり速くならない…。
と、執念深くいろいろやってきた。まあ、忘れたのも多いけどこれ以外に試したことはたくさんあって、いろんなネタが没になった…。コードを書いては捨て、書いては捨て…。
もっと確実に速くなるネタをぱっと思いつけるようになりたいけど、まあ、わたしの知識不足というのがあるのだと思う。
ともかく、ここまでいろいろやってしまうと、もう限界かな、と思うようになってきた。とにかく自動フィールドシフトは参照するデータ量が多く、メモリ帯域との戦いをやっている感じなので、辛い…。
で、もうこうなったら「同じ計算をより高速に」は一度おいておいて、「処理をケチって高速に」という方向性を考え始めた。
(
続く>>)