第九報:MMX 最適化

 最近はプレーンペンティアムを使っているマシンも、増してや i486 マシンも少なくなり、既にほとんどの環境では MMX を使えると考えて構わないと思われます。これからの時代は MMX を使ったルーチンの採用もスタンダードになっていくことでしょう。MMX なしの環境を完全に切り捨てることはまだ危険かもしれませんが、使える環境では使うようにルーチンを切りかえるようにしておくことは問題ありませんね。

 MMX 最適化ですが、インテルコンパイラのような MMX 最適化してくれるコンパイラを使ってもよいのですが、ない場合でもインラインアセンブラで自力でゴリゴリ書けるようになっていると便利だと思います。

 細かい情報については第一報の例の資料に載っていますが、ここではその内容をかいつまんでまとめてみました。あの資料、誤植も結構ありますし。

 いささか経験不足なのでまずいところもあるかもしれませんが(特に最適化ガイド)、副読資料と思って勘弁してください。あと、インラインアセンブラの知識は前提条件とします。

  1. MMX とは何なのか?
  2. MMX 命令の使い方
  3. MMX 命令セット
    1. データ転送
    2. 加算/減算/乗算
    3. 論理演算
    4. 比較
    5. パック/アンパック
    6. EMMS
  4. CPUID
  5. MMX 最適化ガイド

1.MMX とは何なのか?

 「MMX を使うと速くなる」とはいうものの、何故速くなるのか、どんな処理だろうと速くなるのか、そもそもどういう機能なのか、疑問に思っている人も多いことでしょう。先ずはここから話しましょう。

 MMX とは「1つのレジスタに複数の値を入れておき、それらを1つの命令で同時に計算する」という機能のことです。一度に何個もの計算をやってしまうのですから、それは速くなるというものです。このような演算のことを SIMD(Single Instruction, Multiple Data:単一演算−複数データ)演算と呼びます。

 MMX の機能を使うためには、それ用の命令を使う必要があります。例えば代入には Mov ではなく MovD や MovQ を、XORには Xor ではなく PXor を使います。

 そして、MMX 命令のオペランド(引数)には主に MMX レジスタかメモリを指定します。特に、第一オペランドには代入命令以外では MMX レジスタしか使えません。そして、基本的には普通のレジスタと即値には MMX 命令は使えません。例外として、普通のレジスタは代入命令に、即値はシフト命令に使いますが、その場合にしても必ず MMX レジスタを含んでいる必要があります。

 この MMX レジスタというのは MMX 命令を使うときに利用されるレジスタです。サイズはなんと64ビット(8バイト)で、普通のレジスタの倍のサイズです。そして、個数は8個です。アセンブリ言語では mm0 〜 mm7 という名前が付けられています。扱えるのは整数だけです。

 ...ということなのですが、実はその正体は浮動小数点レジスタです。浮動小数点レジスタは80ビット(10バイト)ですが、MMX レジスタとしてはこのうち64ビットしか使わないわけです。ということで、浮動小数演算と MMX 演算を混在させることはできません。

 まとめると、

MMX レジスタという64ビットのレジスタが用意されていて、そこに複数の整数値を入れることができる。
そして、それらの値を同時に演算することができる。

ということです。


2.MMX 命令の使い方

 では、具体的に使ってみることにしましょう。「ある配列に別の配列の値を加える」という処理でもやってみましょう。

プログラム
// MemLeak1.cpp
#include <windows.h>
#include <iostream>

using namespace std;

#define for   if(false); else for

// マルチメディアタイマーを利用するために、winmm.lib をリンクします
#pragma comment(lib, "winmm.lib")

void AddCpp(int* bufA, const int* bufB, int nSize);
void AddMMX(int* bufA, const int* bufB, int nSize);

typedef void (*FPADD)(int* bufA, const int* bufB, int nSize);

void TryAdd(FPADD fpFunc, const char* psName, int* pA, const int* pB, int nSize);

int main()
{
    int* bufA1 = NULL;
    int* bufA2 = NULL;
    int* bufB  = NULL;
    const int nSize = 1000000;

    try
    {
        // メモリの確保
        bufA1 = new int[nSize + 7];
        if(bufA1 == NULL) throw 1;
        bufA2 = new int[nSize + 7];
        if(bufA2 == NULL) throw 1;
        bufB  = new int[nSize + 7];
        if(bufB  == NULL) throw 1;

        // 8バイト境界に合わせます
        // これについては後述します
        int* bufA1Tmp = (int*)(((unsigned)bufA1 + 7) &~ 7);
        int* bufA2Tmp = (int*)(((unsigned)bufA2 + 7) &~ 7);
        int* bufBTmp  = (int*)(((unsigned)bufB  + 7) &~ 7);

        // 乱数を配列に代入
        for(int i = 0; i < nSize; i++)
        {
            bufA1Tmp[i] = bufA2Tmp[i] = rand();
            bufBTmp [i] = rand();
        }

        // 加算を行います
        // C++の時は bufA1Tmp に、MMX の時は bufA2Tmp に
        // それぞれ結果を書きこみます
        TryAdd(AddCpp, "C++", bufA1Tmp, bufBTmp, nSize);
        TryAdd(AddMMX, "MMX", bufA2Tmp, bufBTmp, nSize);

        // 結果を比べます
        // もし問題がなければ、何も表示されないはずです
        for(int i = 0; i < nSize; i++)
        {
            if(bufA1Tmp[i] != bufA2Tmp[i])
                cout << "データが異なります No." << i << endl;
        }
    }
    catch(...)
    {
    }

    // メモリの開放
    if(bufA1 != NULL)
        delete [] bufA1;
    if(bufA2 != NULL)
        delete [] bufA2;
    if(bufB  != NULL)
        delete [] bufB;

    return 0;
}

// スタンダードなC++のコードです
static void AddCpp(int* bufA, const int* bufB, int nSize)
{
    for(int i = 0; i < nSize; i++)
        bufA[i] += bufB[i];
}

// MMX で書きなおしたものです
static void AddMMX(int* bufA, const int* bufB, int nSize)
{
    __asm
    {
        Mov   ecx, nSize
        JEcxZ END_FUNC

        Mov   eax, bufA
        Mov   edx, bufB

        Test  ecx, 1
        JZ    ADJUST_COUNTER

        // サイズが奇数の場合の処理
        Dec   ecx
        MovD  mm0, [eax][ecx * 4]
        MovD  mm1, [edx][ecx * 4]
        PAddD mm0, mm1
        MovD  [eax][ecx * 4], mm0

ADJUST_COUNTER:
        ShR   ecx, 1
        Dec   ecx
        JS    END_FUNC

LOOP_HEAD:
        MovQ  mm0, [eax][ecx * 8]
        MovQ  mm1, [edx][ecx * 8]
        PAddD mm0, mm1
        MovQ  [eax][ecx * 8], mm0

        Dec   ecx
        JNS   LOOP_HEAD

END_FUNC:
        EMMS
    }
}

// 加算を実行
void TryAdd(FPADD fpFunc, const char* psName, int* bufA, const int* bufB, int nSize)
{
    const int nTimes = 60;
    DWORD nBefore, nAfter;

    nBefore = timeGetTime();
    for(int i = 0; i < nTimes; i++)
        fpFunc(bufA, bufB, nSize);
    nAfter  = timeGetTime();

    cout << psName << " : " << (nAfter - nBefore) << "ms" << endl;
}
実行結果(Pentium III 1GHz)
C++ : 2086ms
MMX : 1716ms
実行結果(MMX Pentium 300MHz)
C++ : 7370ms
MMX : 6780ms

 100万バイトの加算を60回行ったときの時間と結果を比べてみました。結果は見れば分かるとおり、MMX の方が高速化されていることが分かります。大体1.2倍程度速くなっていますね。メモリアクセスの速度にある程度律速されるので2倍とはいきませんでしたが、これ以上最適化できそうもない単純な処理ですら、MMX を使えば高速化することができるのです。

 では、本題の MMX を使っている部分を見ていきましょう。AddMMX の部分がそうです。一応インラインアセンブラの部分全体を見ていきます。

Mov   ecx, nSize
JEcxZ END_FUNC

Mov   eax, bufA
Mov   edx, bufB

 これは引数を単にレジスタに移しているだけです。サイズが0のときは JEcxZ を使って関数の最後に飛んでいます。また、for(ecx = nSize - 1; ecx >= 0; ecx--) のような処理を行うつもりであるということを覚えておいて下さい。

Test  ecx, 1
JZ    ADJUST_COUNTER

Dec   ecx
MovD  mm0, [eax][ecx * 4]
MovD  mm1, [edx][ecx * 4]
PAddD mm0, mm1
MovD  [eax][ecx * 4], mm0

 始めのところでサイズが奇数かどうかを確認しています。奇数の場合は第0ビットが1になっているので、これで確認できます。奇数の場合はその下にあるコードを実行します。8バイトずつ、即ち2要素ずつ処理するので、奇数の場合は1要素だけ別に処理してやる必要があるわけです。

 で、そのコードですが、MMX を使っています。もちろん ebx を使って計算したのでいいのですが、折角なので MMX を使いました。

 初めの MovD というのは、ダブルワード(4バイト)データを代入する命令です。ここでは [eax][ecx * 4] つまり、bufA[nSize - 1] を mm0 レジスタに代入しています。Mov ebx, [eax][ecx * 4] と同じような処理を、MMX レジスタに対して行っているわけです。次の MovD も同じです。今度は bufB からデータを取りだし、mm1 レジスタに代入しています。

 そして、次はこれらを足す必要があります。その命令が PAddD です。これは上4バイト同士、下4バイト同士をそれぞれ加算する命令です。つまり、

 上位4バイト下位4バイト
mm033214120
 
mm154222341
 
mm0386326461

という計算を行っているわけです。MMX を使わなければそれぞれの加算ごとに Add を呼んでやる必要がありますが、MMX を使えばこのように PAddD 1つ呼んでやればいいだけなのです。これが速さの秘密なわけです。ここでは下位4バイトだけ加算すればいいのですが、下位4バイトだけを加算するという命令は存在しません。仕方がないので PAddD を使っています。

 また、MovD, MovQ, EMMS 以外の MMX 命令の頭には、このように P がついています。そして Add という機能を表す言葉が続いて、操作単位のサイズを表す B(バイト), W(ワード=2バイト), D(ダブルワード=4バイト), Q(クアドワード=8バイト)という文字で終わります。つまり、1バイトごとに加算したければ PAddB を呼べばいいわけです。

 そして、最後に MovD を使って計算結果をメモリに書き込んでいます。

 では、次にいきましょう。

ADJUST_COUNTER:
ShR ecx, 1 Dec ecx JS END_FUNC

 先ず ecx を右に1シフト、つまり2で割っています。8バイトずつ扱う場合は扱うデータ数は半分になるので、カウンタを2で割ったわけです。

 そして ecx を1つ減らしています。for(i = nSize - 1; i >= 0; i--) のようなことをしたいわけで、ecx は初めに1引いておく必要があるわけです。最初の1バイトを処理した際にも、i-- の部分を行う必要がありますね。その両方の役割を担っています。

 で、最後の JS は前の演算で負になったときに飛ぶ命令です。i >= 0 の部分の処理ですね。

 次はいよいよループのところです。

LOOP_HEAD:
MovQ mm0, [eax][ecx * 8] MovQ mm1, [edx][ecx * 8] PAddD mm0, mm1 MovQ [eax][ecx * 8], mm0 Dec ecx JNS LOOP_HEAD

 ここからは2要素(8バイト)ずつ処理を行うので、データの転送には MovD でなく MovQ を使っています。最後についているのはデータのサイズでしたね。Q はクアドワード(8バイト)の Q です。つまり、MovQ は8バイトのデータを転送する命令です。

 そして、加算にはやはり PAddD を用いています。今度は上位4バイト、下位4バイトの両方にデータが入っているので、MMX 命令の本領が発揮できますね。

 そして、計算結果をメモリに戻しています。その後にカウンタを減らし、ループ条件を確認します。JNS は結果が負でないときに飛びます。

 で、最後に妙なものがついています。

EMMS

 これはMMX 命令を使った後に必ず呼ぶ命令です。厳密には違うのですが、そう考えておいて構いません。3−6でもう少し詳しく話します。

 今までのことをまとめると、

 ・ ニーモニックは P + <機能名> + <操作単位> となっている。
 ・ 8バイトでまとめて処理できない部分は別に処理してやる必要がある。
 ・ 必ず最後に EMMS を呼ぶ。

となります。


3.MMX 命令セット

 MMX 命令セットは Pentium III や Pentium 4 で拡張されていますが、ここでは MMX Pentium からある基本的な命令セットについて紹介します。

 MMX 命令セットは大きく6つに分けることができます。データ転送算術演算論理演算比較パック演算EMMS です。

3−1.データ転送

 これは MovDMovQ の2つです。どちらも上で使いましたね。MovD はダブルワード(4バイト)の、MovQ はクアドワード(8バイト)の転送を行います。

 MovD ですが、引数に MMX レジスタを指定した場合は(とはいっても、必ず1つは指定するのですが)下位4バイトだけが操作を受けます。

3−2.加算/減算/乗算

 この題を見て「あれ? 除算は?」と思った方も多いでしょう。そうです。MMX には除算はありません。強いて言えばビットシフトが2の累乗での除算になるというくらいです。

 では、先ずは加算と減算から話しましょう。

 加算と減算にはそれぞれ3種類あります。ラップアラウンド符号付き飽和演算符号なし飽和演算です。

 ラップアラウンドというのは今までの Add と同じものです。0xFFFFFFFF に 1 を足せば 0 になるという、あの演算のことです。つまり、加算で最大値に達すると最小値に戻り、減算で最小値に達すると最大値に戻るという演算のことです。機能名は Add / Sub になります。

 これに対し、飽和演算というのは加算で最大値を越える場合は最大値に、減算で最小値を超える場合は最小値になるという演算です。例えば符号なし1バイトの場合、240 に 30 を足しても 270 - 256 である 14 にはならず 255 になるわけです。

 飽和演算の機能名は符号付きの場合は AddS / SubS に、符号なしの場合は AddUS / SubUS になります。S は飽和(Saturation)の S で、U は符号なし(Unsigned)の U です。サイズには B(バイト), W(ワード=2バイト)と、ラップアラウンドでは D(ダブルワード)も指定できます。

 乗算にも2種類あります。単なる乗算と、乗加算です。

 単なる乗算は符号付きワード(2バイト)の乗算を行います。符号なしワード、ダブルワードの乗算は Pentium III から使えます。乗算の結果は Mul と引数なし IMul ではサイズが2倍になりましたが、これは MMX でも同じです。しかし、上位ワードと下位ワードはそれぞれ単独にしか得ることはできません。両方とも得るためには2回命令を呼ぶ必要があります。

 機能名は、MulH / MulL です。上位ワードを得る時は MulH を、下位ワードを得るときは MulL を使います。操作サイズは W(ワード)のみです。

 一方乗加算は、符号付きワードの乗算と加算を両方行います。先ず普通に乗算を行います。このときワード単位で行うので、結果はダブルワードのものが4つ出てきます。そして、上位の2つ、下位の2つをそれぞれ足し合わせます。結果はダブルワード2つになり、ちょうど8バイトに収まります。

 流れとしてはこんな感じです。

X3 X2 X1 X0
Y3 Y2 Y1 Y0


Z3=X3×Y3 Z2=X2×Y2 Z1=X1×Y1 Z0=X0×Y0


Z3+Z2 Z1+Z0

 機能名は MAdd で、サイズは W(ワード)から D(ダブルワード)に変わるので両方指定します。つまり、PMAddWD になります。乗加算は2次元ベクトルの内積を求めるのに使えますね。

3−3.論理演算

 論理演算には ANDORXORAND NOTビットシフトがあります。NOT は単体では行えませんが、0xFFFFFFFFFFFFFFFF で XOR するとか、NOT をとりつつ AND をとるということならできます。AND NOT は、第1オペランド(引数)を NOT したあとに第2オペランドと AND をとります

 AND, OR, XOR, AND NOT の操作には SIMD の概念は必要ありません。従って、サイズは常に Q(クアドワード)になります。機能名はそれぞれ And / Or / XOr / AndN となります。

 ビットシフトでは SIMD の概念が必要になります。そして、普通のビットシフトと同じく、符号を考慮しない論理シフトと、符号を考慮する算術シフトがあります。算術シフトでは右シフトでしか関係がありません。算術シフトはシフトした際に空くビットに符号ビットを詰め込みます。論理シフトでは 0 を詰めます。

 機能名は S です。左シフトの場合はこれに L が、右シフトの場合は R がつきます。さらに、論理シフトの場合は L が、算術シフトの場合は A がつきます。つまり、機能名は SLL / SRL / SRA の3つになるわけです。そして、サイズには W(ワード), D(ダブルワード), Q(クアドワード)が指定できます。ただし、算術シフトは W, D の右シフトでしか使えません。

3−4.比較

 MMX の比較命令は普通の比較命令とはちょっと違います。比較した結果はビットマスクとして得られます。

 比較した結果が真の場合は、そのデータ単位が全て 1 で埋められます。逆に偽の場合は 0 で埋められます。そして、比較の種類は等しいかどうか、そしてより符号付きで大きいかの2種類です。

 例えば、ワード単位で等しいかどうかを比較した時は次のようになります。

52 63 98 71
52 89 98 43




0xFFFF 0x0000 0xFFFF 0x0000

 機能名は CmpEq / CmpGT の2つです。それぞれ等しいかどうか、符号付きで大きいか、に対応します。サイズには B(バイト), W(ワード), D(ダブルワード)が指定できます。

3−5.パック/アンパック

 これは MMX 固有の機能ですね。データサイズの変換を行うというものです。

 先ずはパックです。パックはダブルワード→ワード、ワード→バイトという変換を行うというものです。この時、必ず飽和演算されます。

 パックはデータのサイズを半分に減らすので、空きが半分できてしまいます。なので、実際には2つのデータを同時にパックし、それを1つのレジスタに格納します。第1オペランドにあるものが下位4バイトに、第2オペランドにあるものが上位4バイトにパックされます。

 機能名は Pack ですが、この最初が P だからか、頭に P はつけません。そして、符号付き飽和演算の場合は SS を、符号なし飽和演算の場合は US を後ろにつけます。サイズは WB(ワード→バイト), DW(ダブルワード→ワード)の2つを指定できますが、符号なしの場合は WB(ワード→バイト)しか指定できません。

 次に PackSSDW の例を挙げます。

第2オペランド
0x000049F1 0xFFFFF924
第1オペランド
0xFFF93742 0x0046FFF3


0x49F1 0xF924 0x8000 0x7FFF

 次はアンパックです。アンパックはパックの逆で、バイト→ワード、ワード→ダブルワード、ダブルワード→クアドワードの変換を行います

 引数は2つで、それぞれからデータを出し合って倍のサイズのデータを作ります。やはり、第1オペランドから下位データが、第2オペランドから上位データが提供されます。

 もちろんアンパックした結果は16バイトになるので、乗算と同じく一度に全部を取得することはできません。上位8バイト、下位8バイト、それぞれ別の命令を使って得ることになります。

 機能名は Unpck で、上位8バイトをとるときは H を、下位8バイトをとるときは L をつけます。サイズには BW(バイト→ワード)、WD(ワード→ダブルワード)、DQ(ダブルワード→クアドワード)が指定できます。

 次に PUnpckHWD / PUnpckLWD の例を挙げます。

0x4567
0xCDEF
0x2345
0xABCD
0x0123
0x89AB
0xEF01
0x6789


PUnpckHWD PUnpckLWD
0x01234567 0x89ABCDEF 0xEF012345 0x6789ABCD

3−6.EMMS

 EMMS はMMXを使ったあとに浮動小数演算を使う前に呼んでおくべき命令です。

 MMX レジスタは浮動小数点レジスタを使い回したものだと話しました。浮動小数点レジスタに入っている値が有効かどうかなどを示すフラグが(浮動小数点レジスタとは別に)存在します。MMX 命令を使った後にレジスタに入っているデータは、浮動小数としては無効な値であることがほとんどです。しかし、MMX 命令を使うときには有効な値なので、このフラグは「有効」になっているわけです。このままでは浮動小数計算をするときに変な事になりかねないので、このフラグを「空」に設定する必要があるわけです。そのための命令が EMMS(Empty MMX State)なのです。

 後で浮動小数計算を行わない場合は呼ばなくても問題ないのかもしれませんが、インラインアセンブラだと呼ばないと怒られます。それでなくても、EMMS 命令1つくらい呼んでおいた方が無難かと思います。


 以上3節で出てきた命令をまとめます。

種類 ニーモニック
ラップアラウンド演算 符号付き飽和演算 符号なし飽和演算
データ転送 MovD/Q
算術演算 加算 PAddB/W/D PAddSB/W PAddUSB/W
減算 PSubB/W/D PSubSB/W PSubUSB/W
乗算 PMulL/H    
乗加算 PMAddWD    
比較 等価比較 PCmpEqB/W/D
大なり比較 PCmpGTB/W/D
パック演算 AND PAnd
AND NOT PAndN
OR POr
XOR PXOr
左論理シフト PSLLW/D/Q
右論理シフト PSRLW/D/Q
右算術シフト PSRAW/D
パック演算 パック   PackSSWB/DW PackUSWB
上位アンパック PUnpckHBW/WD/DQ    
下位アンパック PUnpckLBW/WD/DQ    
EMMS EMMS

4.CPUID

 さて、最初に「使える環境では使うようにルーチンを切りかえるようにしておく」と言いました。このために使われる命令である CPUID について話そうと思います。

 CPUID は後期 i486 から導入された命令で、CPU に関する情報を返すという命令です。CPU の種類や機能のサポート状況などを取得することができます。

 しかしドキュメントで10ページ以上にもなる命令をここで詳しく説明するつもりはないので、よく使うであろう「機能のサポート状況」の取得方法にしぼって話したいと思います。

 CPUID を使うときには、先ずどの機能を使うかを eax レジスタに指定しておきます。機能のサポート状況を取得する場合には 1 を指定します。サポート状況は edx レジスタに格納されます。

 edx の値は次のようになります。

ビット 情報 ビット 情報
0 FPU- x87 オンチップ FPU 16 PAT- ページ属性テーブル
1 VME- 仮想 8086 モード強化 17 PSE- ページサイズ拡張
2 DE- デバッグ拡張 18 PSN- プロセッサシリアル番号
3 PSE- ページサイズ拡張 19 CLFSH- CFlush 命令
4 TSC- タイムスタンプカウンタ 20 予約領域
5 MSR- RDMSR および WRMSR のサポート 21 DS- デバッグストア
6 PAE- 物理アドレス拡張 22 ACPI- 温度モニタ及びクロック制御
7 MCE- マシンチェック例外 23 MMX- MMX テクノロジ
8 CX8- CmpXchg8B 命令 24 FXSR- FXSave / FXRstor
9 APIC- オンチップ APIC 25 SSE- SSE 拡張命令
10 予約領域 26 SSE2- SSE2 拡張命令
11 SEP- SysEnter および SysExit 27 SS- セルフスヌープ
12 MTRR- メモリタイプ範囲レジスタ 28 予約領域
13 PGE- PTE グローバルビット 29 TM- 温度モニタ
14 MCA マシンチェックアーキテクチャ 30 予約領域
15 CMOV- 条件付き転送/比較命令 31 予約領域

 つまり、edx の 23 ビット目が立っていれば MMX が使えるというわけです。

char bMMX;

__asm
{
    Mov    eax, 1
    CPUID
    BT     edx, 23
    SetC   bMMX
}

 BT は指定したビットをキャリーフラグにセットするという命令です。そして、それを SetC で bMMX に代入しています。これで MMX が使えるときは bMMX は 1(真)になり、使えないときは 0(偽)になります。

 その他にも知っておいた方がいいビットは、15(CMovxx), 26(SSE), 27(SSE2)です。15 の CMovxx 命令は条件が満たされたときのみ Mov するという命令で、条件分岐を減らして最適化に貢献するものです。Pentium Pro からサポートされました。SSE(Streaming SIMD Extension)は SIMD 演算をさらに拡張し、128ビット(16バイト)のレジスタを8つ増やし浮動小数にも対応したもので、Pentium III からのサポートです。SSE2 は SSE をさらに拡張したもので、Pentium 4 からのサポートです。余裕があればこれらを使った最適化も行ってみてはどうでしょうか?

 では、まとめます。

eax に 1 を入れ CPUID を呼ぶと、edx の 23 ビット目で MMX が使えるかどうかが確認できる

5.MMX 最適化ガイド

 これで MMX がどういうものかは大体分かったと思います。MMX の機能をフルに使うためにはいくつか気をつけることがありそうだということも分かったと思います。ここではその気をつけることについて話していこうと思います。

 先ず最適化に関して基本的な話をしておきます。最適化はどこでもすればいいというものではありません。最適化したコードというものは可読性が著しく低下し、コードの保守は難しくなります。従って、どうしても最適化せざるを得ない部分のみを最適化するようにします。それはプログラムの実行速度を低下させている原因となる場所で、ホットスポットと呼ばれます。そして、そのような処理のことをタイムクリティカルな処理と呼びます。

 そして、どんなときにでもアセンブリ言語で最適化すればいいというものでもありません。先ず行うべきはアルゴリズムの改善です。アルゴリズムを改善すればアセンブリ言語を使わなくてもよい速度が得られることもあります。下手なアセンブリ言語コードよりも、よいアルゴリズムを採用したC/C++コードの方が速い場合も往々にしてあります。

 タイムクリティカルな処理として重要なものは巨大ループ処理I/Oの部分です。画像、音声、巨大配列データなど、数百キロ〜数メガバイトにもなるようなデータの処理では、ループの1ステップでの実行速度をちょっと改善しただけでもその数万倍の速度向上が期待できます。あと、ディスクへのI/Oアクセスも低速なので、その回数をできるだけ減らすということも速度向上に寄与します。

 先ずは、このようにホットスポットがどこにあるのかをきちんと突き止めることが重要です。インテルの VTune というソフトを使えばホットスポットを突き止めることができるらしいですが、そこまでしなくてもある程度はあたりはつくと思います(って、VTune 買う金がないだけです、はい)。

 ホットスポットがディスクI/Oならば、さっきいったとおりI/Oの回数を減らす必要がありますが、これにはアセンブリ言語での最適化というのはあまり関係しません。

 巨大なループ処理を行う時も、すぐにアセンブリ言語で最適化しようと思わず、アルゴリズムの改善を試みてみましょう。アセンブリ言語の知識があれば、レジスタのサイズでデータを扱った方がいい、キャッシュが利用できるようにリニアに(アドレスの順に)アクセスした方がいい、など、どうやれば速くなりそうかは分かると思います。それをC/C++で書いてやればいいわけです。

 それでもまだ実行速度に満足がいかなかった場合に、アセンブリ言語の出番がやってきます。このような処理では、MMX 最適化の出番もやってくるでしょう。

 では、次に MMX 最適化についての話に進みましょう。

 MMX で処理する際に重要なことは、先ず8バイト境界をまたいで処理しないということです。メモリのアクセスはそのサイズのバイト境界ごとに行われるため、この境界をまたぐことはメモリのアクセス速度を低下させるということを意味します。4バイトごとにアクセスする場合は4バイト境界で構わないのですが、8バイトごとにアクセスする場合は8バイト境界が重要になります。

 上の例ではデータを8バイト境界に置くことで、両方のデータとも8バイト境界をまたがずに処理できるようにしています。これを行っておらず例えば片方4バイトずれている場合には、必ず速度が低下するという状況になってしまいます。

 もちろん、キャッシュも重要です。例えば巨大2次元配列の処理の際にキャッシュは重要になります。縦向きに([j][i])アクセスするより横向きに([i][j])アクセスした方がキャッシュヒット率は上がりますね。このあたりの話は MMX の話ではありませんが、ループ処理で能力を発揮する MMX には避けることのできない話です。

 次は MMX 命令のサイクル数についてです。MMX 命令は乗算命令が3サイクルなのを除いて全て1サイクルで処理されます。しかも、全てパイプライン化可能です。この「全て」というのが強力ですね。

 しかし、命令の並べ方によってはペナルティが必要となります。1つ目は同じレジスタに続けて書き込むと2サイクルのペナルティが必要になることです。どうしてもそうなってしまう場合は仕方がありませんが、なるべく同じレジスタへの書き込みは離してやる必要があります。その間に別の処理を移動してやることができれば、速度の向上が期待できますね。

 2つ目は乗算の結果は3サイクル経たなければ使えないことです。つまり、乗算を行ってからその結果を使う間に余分に2サイクル分の命令を置いても速度は変わらないわけです。その後に行う処理を一部乗算の後に移動しておけば、速度の向上が期待できるわけです。

 3つ目は同じメモリ領域で大きい書き込みの後に小さい読み出しや、小さい書き込みの後に大きい読み出しをするするとペナルティが必要になります。なるべく操作サイズは一定、つまり8バイトにしておくことが重要になります。

 また、MMX は整数演算を一括して行うことで高速化するものですから、そういうアルゴリズムが使えない処理では役に立ちません。小数演算は固定小数点処理を自分で工夫することでしか使えません。浮動小数には SSE が使えますが、Pentium III 以降ということでまだ使える環境は限られているでしょう(2001年11月現在)。

 そのままでは MMX が効率よく使えないというデータも、MMX 用にデータ構造を見直すことで高速化することも可能です。これをしてしまうと、MMX を使わないルーチンの方で苦労する可能性もありますが。

 他にも命令のデコード時の問題など複雑な問題もありますが、これ以上は長くなりますし、CPU によって微妙に変わる話なので、あとは資料の方を参考にして下さい。


 以上で、メモ帳で編集できなくなるという程長くなった第九報は終わりです。アセンブリ言語が使える人であれば MMX もそんなにとっつきにくいものでもありませんね。むしろレジスタが増えてウッハウハです。

 というわけでこの MMX 、思う存分使い倒して下さい。


補足:MMX は何の略かということですが、資料には一切載っていません。自分は MultiMedia eXtension の略だと勝手に思ってますが、どうなんでしょうね...。


目次に戻る

トップページに戻る

Last update was done on 2001.11.11