私が最速の画像サイズ変更をしたように。 パート3、固定小数点数

最新のx86プロセッサー上で最速のサイズ変更イメージを作成できる最適化手法について、引き続き詳しく説明します。 今回は、浮動小数点計算から整数計算への変換について説明します。 最初に、これがどのように機能するかについて少し理論を説明します。 次に、SIMDバージョンを含む実際のコードに戻ります。







前のパートでは:







パート0

パート1、一般的な最適化

パート2、SIMD







整数と浮動小数点



コンピューターのメモリで数値を表すには、主に2つの方法があることを誰もが知っていると思います。 主な機能は次のとおりです。







整数









浮動小数点数









浮動小数点数は、仮数と指数の2つの値としてメモリに保存されます。 仮数は、値自体を格納するビット(ほぼ整数)であり、指数は仮数値がシフトされる桁数を示します。 数値の真の値を見つけるには、仮数に指数のビット深度を掛ける必要があります:m・2ᵉ。 この場合の容量は2です。 2進数システム。













このシステムは、数値の整数表現と比較して非常に複雑であると思われます。 ただし、最新のプロセッサでは、浮動小数点数のほとんどの演算(乗算や加算など)は、整数の演算と同じクロックサイクル数で実行されます。 操作の複雑さは、プロセッサ内のトランジスタ数の増加のみにつながりますが、クロックサイクル数の増加にはつながりません。 しかし、操作自体の速度に加えて、生産性のコンテキストで考慮する必要があるいくつかの要素があります。









不動点



整数はパフォーマンスが向上する可能性があるため、算術を整数に変換してみることができます。 しかし、フロートをどこでもintに置き換えることでこれを行うことは、もちろん機能しません。 サイズ変更の際、0〜1の範囲で多くの計算が実行されます。つまり、 整数表現では、ゼロになります。







ここで、 固定小数点数が役立ちます。 厳密に言えば、整数も固定小数点数であり、そのポイントは最下位ビットの後に固定されます。 しかし、たとえば8桁の2桁に投機的に移動し、ユニットが実際に1/256であると想定することができます。 256は単位、512はデュース、384は1.5です。 それは何を与えますか? この形式では、数値の整数部分だけでなく、実数部分も書き込むことができます。 固定小数点数のかなり一般的な例は、一部のプログラミング言語で使用可能な通貨データ型です。 セントまたはセントの整数を格納します。ルーブルまたはドルを取得するには、値を100で除算する必要があります。







繰り返しますが、固定小数点数とは、計算の精度を高めるために定数を掛けた数値です。 浮動小数点数とは異なり、この定数は数値自体には格納されませんが、アルゴリズムの実装に直接組み込むことも、プロセスで計算することもできます。







一般に、固定小数点数を扱うことは大したことではありませんが、留意すべきことがいくつかあります。















精度カウント



コードを整数に変換する前に、正確さのために何ビットを割り当てることができるかを計算するとよいでしょう。 さらに、このような計算は各操作に対して意味があります。 そして、最後から始める方が良いです:







float ss, ss0, ss1; for (xx = 0; xx < imOut->xsize; xx++) { ss0 = ss1 = ss2 = 0.5; for (y = ymin; y < ymax; y++) { ss0 = ss0 + ((UINT8) imIn->image[y][xx*4+0]) * k[y-ymin]; ss1 = ss1 + ((UINT8) imIn->image[y][xx*4+1]) * k[y-ymin]; ss2 = ss2 + ((UINT8) imIn->image[y][xx*4+2]) * k[y-ymin]; } imOut->image[yy][xx*4+0] = clip8(ss0); imOut->image[yy][xx*4+1] = clip8(ss1); imOut->image[yy][xx*4+2] = clip8(ss2); }
      
      





ss0



- ss0



ss2



は、ピクセルごとの係数の積の合計が含まれています。 ピクセルの範囲は[0、255]で、係数の合計は1に等しいことがわかっています。 つまり、バッテリーの最終値ss0



- ss0



も範囲[ ss0



]になります。 しかし、これで終わりです! 一般に、一部の係数は負になる可能性があり、その結果、正の係数の合計が複数になる場合があります(記事0からフィルターグラフを見てください)。 したがって、中間値は範囲[0.255]をわずかに超える場合があります。 この場合、負数の場合は1ビット、上からのオーバーフローの場合はもう1ビットで十分です。 値を保存するには、合計で10ビット[-512,511]が必要です。 バッテリーを32ビット以上にすることは論理的であるため、バッテリーの精度を保存するために22ビットが残ります( PRECISION_BITS



と呼びましょう)。







係数によるピクセルの乗算を処理するために残っています。 固定小数点数に整数を掛ける場合、追加の変換は不要であると既に述べました。 この場合、整数はピクセル値です。 これは、係数の精度がバッテリーの精度-22ビットと同じであることを意味します。







固定小数点スカラーコンピューティング



これは驚くべきことですが、上記のコードでは、1行だけを変更して、固定小数点で動作するように変換する必要があります。 当初、バッテリーには0.5の値が割り当てられています。 新しい番号体系では、これは値1 << (PRECISION_BITS - 1)



対応します。 つまり、単位は精度より1ビットだけシフトします。 0.5新しいユニット。







 int ss, ss0, ss1; for (xx = 0; xx < imOut->xsize; xx++) { ss0 = ss1 = ss2 = 1 << (PRECISION_BITS -1); for (y = ymin; y < ymax; y++) { ss0 = ss0 + ((UINT8) imIn->image[y][xx*4+0]) * k[y-ymin]; ss1 = ss1 + ((UINT8) imIn->image[y][xx*4+1]) * k[y-ymin]; ss2 = ss2 + ((UINT8) imIn->image[y][xx*4+2]) * k[y-ymin]; } imOut->image[yy][xx*4+0] = clip8(ss0); imOut->image[yy][xx*4+1] = clip8(ss1); imOut->image[yy][xx*4+2] = clip8(ss2); }
      
      





他のすべての計算は変更されないままであり、これは間接的に正しい軌道に乗っていることを示唆しています。 結局のところ、概念を変更しても実装に問題は生じませんでしたが、パフォーマンスの獲得を期待できます。







しかし、範囲[ clip8



の最終ピクセル値を制限するclip8



関数は、大きく変わります。 それは:







 static inline UINT8 clip8(float in) { int out = (int) in; if (out >= 255) return 255; if (out <= 0) return 0; return (UINT8) out; }
      
      





次のようになりました:







 static inline UINT8 clip8(int in) { if (in >= (1 << PRECISION_BITS << 8)) return 255; if (in <= 0) return 0; return (UINT8) (in >> PRECISION_BITS); }
      
      





まず、受け入れられる値が変更されます-現在は32ビット整数です。 第二に、整数型にすぐにはキャストされません(以前は最初の行にありました)。 代わりに、 1 << PRECISION_BITS << 8



値と比較できます。 この値は固定小数点数システムでは256です。これは、小数部のビット数と別の8ビットだけシフトされるためです。 そしてご存じのように、 1 << 8



は正確に256です。すでに最後に、すべての比較で負の結果が得られた場合、値は実際にはポイントなしで通常の全体に減少します。 精度のビット数による通常のシフトによって与えられます。







ここで、係数を固定小数点にする必要があります。 最初に、係数は-1から1までの浮動小数点数であることを思い出してください。そして、1ピクセルを計算するためのすべての係数の合計は1に等しくなります。 実際に係数を計算するために整数演算を使用することは意味がないと確信しています。 第一に、係数の計算はそれらを使用するよりもはるかに短い時間です。 次に、一部のフィルター内で三角関数が使用されます。 したがって、浮動小数点係数を計算してから、それらを固定係数に変換して(1 << PRECISION_BITS)



乗算するのは正しいようです。







 for (x = 0; x < xsize * kmax; x++) { kk[x] = (int) (prekk[x] * (1 << PRECISION_BITS)); }
      
      





これは何を与えますか? 以下は、浮動小数点数で得られたスカラー計算の最新の結果です。







 Scale 2560×1600 RGB image to 320x200 bil 0.03009 s 136.10 Mpx/s to 320x200 bic 0.05187 s 78.97 Mpx/s to 320x200 lzs 0.08113 s 50.49 Mpx/s to 2048x1280 bil 0.14017 s 29.22 Mpx/s to 2048x1280 bic 0.17750 s 23.08 Mpx/s to 2048x1280 lzs 0.22597 s 18.13 Mpx/s to 5478x3424 bil 0.58726 s 6.97 Mpx/s to 5478x3424 bic 0.74648 s 5.49 Mpx/s to 5478x3424 lzs 0.90867 s 4.51 Mpx/s
      
      





コミット57e8925の結果。







そして、固定小数点の結果は次のとおりです。







 Scale 2560×1600 RGB image to 320x200 bil 0.02079 s 196.99 Mpx/s 44.7 % to 320x200 bic 0.03459 s 118.41 Mpx/s 50.0 % to 320x200 lzs 0.05649 s 72.50 Mpx/s 43.6 % to 2048x1280 bil 0.10483 s 39.07 Mpx/s 33.7 % to 2048x1280 bic 0.13362 s 30.66 Mpx/s 32.8 % to 2048x1280 lzs 0.17210 s 23.80 Mpx/s 31.3 % to 5478x3424 bil 0.46706 s 8.77 Mpx/s 25.7 % to 5478x3424 bic 0.59492 s 6.88 Mpx/s 25.5 % to 5478x3424 lzs 0.72819 s 5.62 Mpx/s 24.8 %
      
      





コミット15d0573の結果。







ご覧のとおり、すべてが無駄ではなく、成長は非常に深刻です。 何よりも、ピクセル値を変換するための操作が増えたため、大幅な減少が見られます。







固定小数点SIMDコンピューティング



3番目の部分から固定小数点計算へのSIMDコードの転送は、4つの段階に分けることができます。









これらのステージは非常に均一であるため、1つだけを慎重に検討することは理にかなっています。 これは、浮動小数点数のSSE4垂直パスの例です。







 ImagingResampleVerticalConvolution8u(UINT32 *lineOut, Imaging imIn, int ymin, int ymax, float *k) { int y, xx = 0; for (; xx < imIn->xsize; xx++) { __m128 sss = _mm_set1_ps(0.5); for (y = ymin; y < ymax; y++) { __m128i pix = _mm_cvtepu8_epi32(*(__m128i *) &imIn->image32[y][xx]); __m128 mmk = _mm_set1_ps(k[y - ymin]); __m128 mul = _mm_mul_ps(_mm_cvtepi32_ps(pix), mmk); sss = _mm_add_ps(sss, mul); } __m128i ssi = _mm_cvtps_epi32(sss); ssi = _mm_packs_epi32(ssi, ssi); lineOut[xx] = _mm_cvtsi128_si32(_mm_packus_epi16(ssi, ssi)); } }
      
      





__m128



データ__m128



は、4つの浮動小数点数が格納されます。 不要になりました__m128i



に置き換える必要があります。 _mm_set1_ps



関数の類似物は_mm_set1_epi32



です。 変換関数_mm_cvtepi32_ps



_mm_cvtps_epi32



不要になり、代わりに、最後の結果をPRECISION_BITS



によって右にシフトする必要があります。 _mm_mul_ps



関数を使用した_mm_mul_ps



のみ困難が発生する可能性があります。直接的な類似点がないためですが、見れば_mm_mullo_epi32



があり_mm_mullo_epi32



。 実際、2つの32ビット数を掛けると64ビット数になります。 Loは、結果の下位32ビットが返されることを意味します。これはまさに必要なものです。 すべてのコードは次のようになります。







 ImagingResampleVerticalConvolution8u(UINT32 *lineOut, Imaging imIn, int ymin, int ymax, int *intk) { int y, xx = 0; for (; xx < imIn->xsize; xx++) { __m128i sss = _mm_set1_epi32(1 << (PRECISION_BITS -1)); for (y = ymin; y < ymax; y++) { __m128i pix = _mm_cvtepu8_epi32(*(__m128i *) &imIn->image32[y][xx]); __m128i mmk = _mm_set1_epi32(intk[y - ymin]); __m128i mul = _mm_mullo_epi32(pix, mmk); sss = _mm_add_epi32(sss, mul); } sss = _mm_srai_epi32(sss, PRECISION_BITS); sss = _mm_packs_epi32(sss, sss); lineOut[xx] = _mm_cvtsi128_si32(_mm_packus_epi16(sss, sss)); } }
      
      





これで、SSE4バージョンで得られた結果を浮動小数点数で比較できます。







 Scale 2560×1600 RGB image to 320x200 bil 0.01151 s 355.87 Mpx/s to 320x200 bic 0.02005 s 204.27 Mpx/s to 320x200 lzs 0.03421 s 119.73 Mpx/s to 2048x1280 bil 0.04450 s 92.05 Mpx/s to 2048x1280 bic 0.05951 s 68.83 Mpx/s to 2048x1280 lzs 0.07804 s 52.49 Mpx/s to 5478x3424 bil 0.18615 s 22.00 Mpx/s to 5478x3424 bic 0.24039 s 17.04 Mpx/s to 5478x3424 lzs 0.30674 s 13.35 Mpx/s
      
      





コミット8d0412bの結果。







固定小数点数のSS4で得られた結果では:







 Scale 2560×1600 RGB image to 320x200 bil 0.01253 s 326.82 Mpx/s -8.1 % to 320x200 bic 0.02239 s 182.94 Mpx/s -10.5 % to 320x200 lzs 0.03663 s 111.83 Mpx/s -6.6 % to 2048x1280 bil 0.04712 s 86.92 Mpx/s -5.6 % to 2048x1280 bic 0.06731 s 60.86 Mpx/s -11.6 % to 2048x1280 lzs 0.08176 s 50.10 Mpx/s -4.5 % to 5478x3424 bil 0.19010 s 21.55 Mpx/s -2.1 % to 5478x3424 bic 0.25013 s 16.38 Mpx/s -3.9 % to 5478x3424 lzs 0.31413 s 13.04 Mpx/s -2.4 %
      
      





コミット7d8df66の結果。







そして、驚きが私を待っていました。 長い間、私は何がうまくいかなかったかを理解しようとしました。 ある時点で、ループで使用された各命令のタイミングを見に行きましたが、解決策はここにありました。







これは、Intel Intrinsics Guideには表示されません。これは、常に更新され、古いプロセッサのデータが時々削除されるためです。 しかし、私_mm_mullo_epi32



これを_mm_mullo_epi32



したとき、 _mm_mullo_epi32



オペレーションには次のタイミングテーブルがありました。







 Architecture Latency Throughput Broadwell 10 2 Haswell 10 2 Ivy Bridge 5 1
      
      





次に、浮動小数点数に関する同様の_mm_mul_ps



タイミングと比較します。







 Architecture Latency Throughput Broadwell 3 0.5 Haswell 5 0.5 Ivy Bridge 5 1
      
      





Haswellアーキテクチャから始めて、Intelは整数32ビット数のベクトル乗算で得点したことがわかります。 さらに、乗算の他のすべてのオプションはアーキテクチャからアーキテクチャへと高速に成長し続けるため、整数および32ビットです。







興味深いことに、これはコードのAVX2バージョンでは観察されず、遅延の増加による悪影響は固定小数点計算への切り替えによる肯定的な効果よりも優先されません。 また、固定小数点数のパフォーマンスは約10%向上します。 これには2つの理由があります。









聖杯の探求



Pillowバージョン3.3の整数計算を準備しました。 そして、私はPillowとPillow-SIMDのバージョンを多かれ少なかれ同期的にリリースし、同じ改善を試みました。 また、整数に切り替えるとPillowが顕著に増加したことは非常に残念でしたが、Pillow-SIMDで取るに足りない、またはまったく得られませんでした。 その後、リリースでは、ループを展開することでバックログをわずかに補うことができました。 これにより、命令パイプラインが改善され、低速乗算の影響がわずかに排除されました。 しかし、これについては、このシリーズの最後の記事でお伝えしたいと思います。







Pillowの通常バージョンのパフォーマンスがどのように変化したかを見ると、Pillow 3.3では整数計算によりかなりの増加があったことがわかります。 Pillow 3.4では、すべてがほぼ同じレベルのままでした。













一方、Pillow-SIMDの状況は反対です。バージョン3.3は、以前のものよりもほとんど遅いことが判明しました。 しかし、3.4では大きな飛躍がありました。これにより、Pillow-SIMDは現在、CPUで最も高速なサイズ変更の実装であると言えます。













Pillow-SIMD 3.4でこのような改善を実現するには、整数の32ビットベクトル乗算を取り除く必要がありました。 しかし、どのように? すべての計算を16ビットに変換しますか? この場合、係数( PRECISION_BITS



)が16-8-2 = 6ビット、つまり合計64個の値を残していることを計算するのは簡単です。 実際には、すべての係数の合計は1(つまり64)に等しくなければならないため、はるかに小さくなります。 係数の数は、フィルターウィンドウのサイズと縮小スケールに依存します(詳細については、 パート0を参照)。 Lanczosフィルターで画像を10倍に縮小すると、係数自体は60になります。 16ビットでの計算は明らかに十分に正確ではなく、他の何かを発明する必要がありました。







私はその考えに悩まされていました:何らかの理由で、Intelは掛け算をカットするという奇妙な方法で決定しました。 また、他の開発者はインターネット上で後悔することはありませんが、問題の解決に成功し続けています。 グラフィックスを扱うのに32ビットの乗算は本当に必要ないかもしれません。それなしではどうすればいいのかわかりません。







_mm_mullo_epi16



。 畳み込みの結果が32ビットになるように、係数のビット深度を慎重に選択することもできますが、ピクセル値に係数を乗算した結果は16ビット以内にとどまります。 次に、係数自体の精度のために7ビットが残ります(1ビットは符号に進みます)。 これは、すべての係数の合計が6ビットよりも大幅に優れていました。 偶然別の解決策を見つけたときに、これを実装しようとしていました。







しかし、バンドルに特別な指示があった場合はどうでしょうか?



さまざまな角度から持っているツールを見て、問題を解決しているときに、このタスクのために特別に考案されたツールに偶然出くわしたとします。







乗算の難しさは何ですか? 乗算結果を格納するには、オペランドの2倍のビットが必要です。 したがって、選択する必要があります。結果の上部または下部を取得する必要があります。 精度にはこの問題があり、有効ビットのごく一部のみがオペランドから使用されます。 乗算結果全体を取得できたらどうでしょうか? その場合、2倍のビット、つまり、結果を持つ2つのレジスタが必要になります。 しかし、乗算後にこれら2つのレジスタを追加するとどうなりますか? それでも、乗算の結果を加算する必要があります。これが畳み込みの意味です。 それから、乗算のためにXペアのオペランドを取り、それらを乗算し、X積を取得し、次に隣接するものを追加し、X / 2積の出力を出力する命令を取得します。 そして、奇妙なことに、そのような命令はすでにSSE2で見つかりました! _mm_madd_epi16



と呼ばれ_mm_madd_epi16



。 そして、彼女の遅延は_mm_mullo_epi32



遅延よりも2倍低く、彼女は3倍の操作を実行します。







繰り返しますが、入力には2つのレジスタがあり、それぞれに8つの16ビット符号付き整数があります。 これらの数値はペアで乗算され、8つの32ビット乗算結果が記憶されることを念頭に置いています。 隣接する乗算結果を合計して、4つの32ビット符号付き数値を取得します。 4つの低速乗算の代わりに1つのクイック命令で8つの乗算と4つの加算。 実質的に精度の損失はありません。







唯一の問題は、隣接する乗算結果が加算され、ピクセルの場合は隣接チャネルになることです。 額にコマンドを適用すると、最初のピクセルの赤のチャンネルが最初のピクセルの緑に追加され、最初のピクセルの青がアルファチャンネルに追加されます。 同じことが2番目のピクセルにも当てはまります。 畳み込みでは、最初のピクセルの赤チャンネルを2番目のピクセルの赤チャンネルに追加する必要があります。 つまり、この命令を適用する前に、値を少し混ぜる必要があります。







16ビット係数への切り替え



残念ながら、 int



型をINT16



置き換えるだけでは十分ではありません。係数はこの型を超える可能性があります。 最初に、指数(必要に応じて数値の精度または仮想不動点の位置)をアルゴリズム自体で設定し、プロセスで計算できると述べました。 そして、異なる入力データに応じて、異なる出展者を選択する必要がある場合にのみ当てはまります。 そして、この計算には、係数の最大値が必要です。







 #define MAX_COEFS_PRECISION (16 - 1) #define PRECISION_BITS (32 - 8 - 2) coefs_precision = 0; while ( maxkk < (1 << (MAX_COEFS_PRECISION-1)) && (coefs_precision < PRECISION_BITS) ) { maxkk *= 2; coefs_precision += 1; };
      
      





つまり、一方では最大係数の値が16ビットを超えないようにし(16ビット形式で表示されるため)、他方では畳み込み全体の値が32ビットを超えないようにする必要があります(この条件はこれまでに満たされています) coefs_precision < PRECISION_BITS



)。







私はすでにコードにかなり疲れているようですので、 _mm_madd_epi16



命令を適用できるように、何を変更する必要があるか、ピクセルをどのように混合するかを分析しません。 興味のある人は、いつものように、githubでコミットの変更を確認し、コメントで質問することができます。 浮動小数点数のSSE4バージョンに関連する16ビット係数のSSE4バージョンの結果をすぐに示します。







 Scale 2560×1600 RGB image to 320x200 bil 0,00844 s 485.20 Mpx/s 36,4 % to 320x200 bic 0,01289 s 317.79 Mpx/s 55,5 % to 320x200 lzs 0,01903 s 215.24 Mpx/s 79,8 % to 2048x1280 bil 0,04481 s 91.41 Mpx/s -0,7 % to 2048x1280 bic 0,05419 s 75.59 Mpx/s 9,8 % to 2048x1280 lzs 0,06930 s 59.11 Mpx/s 12,6 % to 5478x3424 bil 0,19939 s 20.54 Mpx/s -6,6 % to 5478x3424 bic 0,24559 s 16.68 Mpx/s -2,1 % to 5478x3424 lzs 0,29152 s 14.05 Mpx/s 5,2 %
      
      





コミット9b9a91fの結果。







そして、浮動小数点数のAVX2バージョンに関連する16ビット係数のAVX2バージョンの結果:







 Scale 2560×1600 RGB image to 320x200 bil 0.00682 s 600.15 Mpx/s 34.6 % to 320x200 bic 0.00990 s 413.86 Mpx/s 50.5 % to 320x200 lzs 0.01424 s 287.54 Mpx/s 60.6 % to 2048x1280 bil 0.03889 s 105.31 Mpx/s 7.6 % to 2048x1280 bic 0.04519 s 90.64 Mpx/s 11.3 % to 2048x1280 lzs 0.05226 s 78.38 Mpx/s 18.2 % to 5478x3424 bil 0.15195 s 26.96 Mpx/s 6.7 % to 5478x3424 bic 0.16977 s 24.13 Mpx/s 17.8 % to 5478x3424 lzs 0.20229 s 20.25 Mpx/s 15.6 %
      
      





コミット3ad4718の結果。







合計



全体として、整数計算への移行により、スカラーコードとSIMDの両方でゲインが得られました。 SSE4バージョンでは、いくつかのフィルターを使用して画像を拡大すると、パフォーマンスがわずかに低下します。 しかし、実際には、ここに示されているコードはPillow-SIMDバージョン3.3または3.4に含まれていたものとはまったく異なります。これは一種のビネグレットです。 実際のバージョンでは、パフォーマンスの低下はありませんでした。







振り返って最初のバージョンを思い出すと、同じハードウェアで現在のコードが10〜12倍高速であることがわかります。 2秒かかったことが1秒間に5回実行できるようになりました! しかし、 公式のベンチマークを見ると、AVX2を使用したPillow-SIMD 3.4の実際のパフォーマンスは、この記事の最後で判明したよりも2倍高いことがわかります。 したがって、次のパートには理由と資料があります。








All Articles