ASMの書き方 (その3)

次はメモリコピー関数memcpy()を作ってみます.


まず作ってみる
memcpy(void *dest, const void *src, size_t n); の中身としてまず思いつくのは大体次のようでしょう.
    char *dest;
    char *src;
    // -------------
    int i;
    for (i = 0; i < n; i++) {
        *dest++ = *src++;
    }

これをASMで書いてみます. eaxにdest, edxにsrc, ecxにn, blに*srcの値を入れることにします.

%assign _P (4*1)    ;pushした数
;memcpy(void *dest, const void *src, size_t n);
proc    mymemcpy
    push   ebx
    mov    eax, [esp+_P+4]
    mov    edx, [esp+_P+8]
    mov    ecx, [esp+_P+12]
.lp:
    mov    bl, [edx]
    inc    edx
    mov    [eax], bl
    inc    eax
    dec    ecx
    jnz    .lp
    pop    ebx
    ret
    %assign _P 4*1

Cでの#define _P 4*1に相当します. こうしておくとpushした数が変わった時に対処し易くなります(私は, ですが).

    push ebx

ebxの下位8bitのblを使うので退避しておきます.

    inc eax
    inc edx

eax, edxの値を1増やします (INCrement by 1). 1減らすならdecです (DECrement by 1).

    dec ecx
    jnz .lp

Cで書くところの

    ecx--;
    if (ecx != 0) goto .lp;

の意味です. jnzはもし0でないならジャンプせよという意味のJump if Non Zeroの略です.

高速化 (その1) :32bitアクセス
上のプログラムを実際に作って速度を計測された方はCのmemcpyに比べて遅い (下手すると4倍) ので驚かれたかもしれません.

原因はどこにあるかと言うと細切れに書き込んだことにあります. つまり, 32bitCPUというからには32bitずつアクセクした方が速いのです. 折角32bitレジスタを持っているのだからそれを使いましょう.

ここから簡単にするためにnは4の倍数であると仮定します.

まずループ内を簡潔に表現します. 上でeax, edx, ecxは同じ値ずつ増減していましたが, 両方計算するのは無駄です. Cで書くなら次のようでしょうか.

    int diff;
    char *endp;
    diff = src - dest;
    endp = dest + n;
    for (;dest < endp; dest += 4) {
        *(int *)(dest + diff) = *(int *)dest;
    }

eaxにdest, edxにsrc - dest, ecxにdest + nを入れることにします.

%assign _P 4*1   ;pushした数
;memcpy(void *dest, const void *src, size_t n);
proc    mymemcpy
    push   ebx
    mov    eax, [esp+_P+4]
    mov    edx, [esp+_P+8]
    dec    edx, eax
    mov    ecx, [esp+_P+12]
    add    ecx, eax
.lp:
    mov    ebx, [eax+edx]
    mov    [eax], ebx
    add    eax, 4
    cmp    eax, ecx
    jb     .lp
    pop    ebx
    ret
    cmp eax,ecx
    jb .lp

if (eax < ecx) goto .lp;の意味です. jbはeax - ecx < 0ならジャンプという意味のJump if Belowの略です. これで大分速くなったはずです.

ただ関数に渡された時点でのdestやsrcの値が奇数だとまたもや遅くなるのです. 32bitアクセスが速いと言っても本当は4の倍数のアドレスをアクセスする時のみ 速いのです. そうでない場合はペナルティが発生します. Cコンパイラはこれを知っていて, mallocなどで確保されたメモリの先頭アドレスは4の倍数(or 8, 16の倍数)に調節されています. しかし, ASMで自分でスタックなどからメモリを確保するときにはその点に注意しなければなりません.

面倒なので省略しますがdestが奇数でも高速に動作させるには例えばこういう風にする必要があります. ま, 極力そうならないようにデータを注意して配置することですね.
静的なデータの場合, NASMには4の倍数のアドレスに強制的に置く命令として
align 4というのがあり, これを使います.

高速化 (その2) :rep string命令
x86系CPUの特徴として, string命令というメモリの連続アクセスを速くする命令があります. 今度はそれを使ってみましょう.

ただこの命令自身, ほとんど過去の遺物でして現在では使っても使わなくてもほとんど速度に影響しません.
さて,

    rep movsd

これは

    dest = ds:[esi];
    src = es:[edi];
    memcpy(dest, src, ecx*4);

という意味です. つまりmemcpyは殆ど1命令で出来るというわけでした(^^;

#df (ディレクションフラグ) が1の時はまた違う結果になるのですが今は省略.
%assign _P    4*3    ;pushした数
;memcpy(void *dest, const void *src, size_t n);
proc    mymemcpy
    push   es
    push   esi
    push   edi
    mov    esi, [esp+_P+4]
    mov    edi, [esp+_P+8]
    mov    ecx, [esp+_P+12]
    shr    ecx, 2
    mov    ax, ds
    mov    es, ax
    rep    movsd
    pop    edi
    pop    esi
    pop    es
    ret
    shr ecx, 2

ecxを2だけ論理右シフトします. 1だけ論理右シフトするとは

    [a31][a30]...[a1][a0]

という数値を(a0~a31は1bitを表す:a0が一番下を表す)

    [数値の0][a31][a30]...[a2][a1]

と右にずらして一番左の空いたところに0を詰める操作のことです.

#0で無くa31が入るのを算術右シフトと言い, sarと書きます.

1だけ論理右シフトするごとに値は2分の1 (端数は切捨て) となるので shr ecx, 2はecxを4で割るという意味です.

    mov ax, ds
    mov es, ax

普通は同じセグメント間の数値を複写するのでes = dsと設定します.

    push ds
    pop  es

としてもかまいませんが, mov es, dsとは出来ません.


御意見は光成滋生<herumi@nifty.com>までお願いします