💻

llvmでmemsetのコードサイズを小さくする

2023/12/18に公開

はじめに

解決したいこと

Rust で配列などをゼロ初期化すると、memset 関数呼び出しが生成されることがあります。このとき呼び出される memset 関数はコードサイズがわりと大きめです。RISC-V の rv32imc ターゲットだと92バイトの memset が生成されます。

組み込み向けのプログラムなどを作っているとできるだけコードサイズを小さくしたくなります。もっとコンパイラにがんばってもらって小さくするにはどうしたら良いでしょうか。

理想と現実

rustc でゼロ埋めを含むコードをコンパイルして RISC-V の rv32imc ターゲットでコード生成すると長い memset が生成されます。(説明のためのコメントは筆者が追加しています。)

rustc が生成する memset
memset:
    li      a3, 15
    bgeu    a3, a2, memset_8 ; サイズが15バイト以下なら memset_8 にジャンプ
    neg     a3, a0
    andi    a3, a3, 3
    add     a4, a0, a3
    beqz    a3, memset_4     ; 先頭アドレスが4の倍数なら memset_4 にジャンプ
    mv      a5, a0
memset_3:
    sb      a1, 0(a5)        ; 最初の半端分を1バイト単位で埋める
    addi    a5, a5, 1
    bltu    a5, a4, memset_3
memset_4:
    sub     a2, a2, a3
    andi    a5, a2, -4
    add     a3, a4, a5
    blez    a5, memset_7     ; 残りサイズが3バイト以下なら memset_7 にジャンプ
    andi    a6, a1, 255
    lui     a5, 4112
    addi    a5, a5, 257
    mul     a5, a6, a5       ; 埋める値を4つ並べた4バイト値を作る
memset_6:
    sw      a5, 0(a4)        ; 4バイト単位で埋める
    addi    a4, a4, 4
    bltu    a4, a3, memset_6
memset_7:
    andi    a2, a2, 3
    bnez    a2, memset_9     ; 4バイトに満たない半端があれば memset_9 にジャンプ
    j       memset_11
memset_8:
    mv      a3, a0
    beqz    a2, memset_11    ; サイズが0なら memset_11 にジャンプ
memset_9:
    add     a2, a2, a3
memset_10:
    sb      a1, 0(a3)        ; 半端な部分は1バイト単位で埋める
    addi    a3, a3, 1
    bltu    a3, a2, memset_10
memset_11:
    ret

簡単に言うと、アラインメントが揃っていないかもしれない、埋める値は0でないかもしれない、サイズがワードサイズの倍数でないかもしれない、と様々なケースを想定してコードが書かれているため長くなっています。

しかし、memset 対象領域のアラインメントはワードサイズに揃っている場合が多く、書き込む値も0であるケースが多いです。

もしアラインメントが揃っていてサイズが0より大きくワード境界になっていて埋める値も0である、とわかっていれば次のような12バイトのコードで済みます。

aligned_bzero:
    add     a2, a2, a0
aligned_bzero_1:
    sw      zero, (a0)
    addi    a0, a0, 4
    bne     a0, a2, aligned_bzero_1

しかもこの程度であればインラインで展開してしまえば、呼び出し側でリンクレジスタを始めとする callee-saved レジスタ退避が不要になりさらにコードが短くなる可能性もあります。

LLVM で memset がどう生成されるか

LLVM IR では memset は intrinsic (組み込み命令)として表現されます。

サイズが固定である程度短い場合はメモリストアループのアンローリングが行われてインライン展開され、そうでない場合は memset 関数呼び出しが生成されるようになっているようです。アーキテクチャによってはより最適な専用命令を使ったコード生成が行われるものもあります。

今回ほしいのは、インライン展開されるけどループのアンローリングまではしないというものです。 LLVM を改造してこういうコード生成をできるようにしていきます。

memset に対する SelectionDAG ノード生成

memset intrinsic を SelectionDAG というグラフ形式に変換しているのが SelectionDAG::getMemset です。

ここで前記のメモリストアループアンローリングまたは memset 関数呼び出しが生成されるのですが、さらにアーキテクチャに依存した最適化のためにフックが可能になっています。これは SelectionDAGTargetInfo というクラスを派生することで実現できます。

今回対象アーキテクチャは RISC-V とします。RISC-V では SelectionDAGTargetInfo を使った特殊化はしていないので実装クラス自体がまだありません。SelectionDAGTargetInfo を派生した RISCVSelectionDAGInfo クラスを作ります。 EmitTargetCodeForMemset という仮想関数が memset の生成時に呼ばれる関数なので、これを実装します。

RISCVSelectionDAGInfo.h
class RISCVSelectionDAGInfo : public SelectionDAGTargetInfo {
public:
  SDValue EmitTargetCodeForMemset(SelectionDAG &DAG, const SDLoc &dl,
                                  SDValue Chain, SDValue Op1, SDValue Op2,
                                  SDValue Op3, Align Alignment, bool isVolatile,
                                  bool AlwaysInline,
                                  MachinePointerInfo DstPtrInfo) const override;
}

条件を満たしたときに最適化された SelectionDAG を生成するようにします。RISC-V には 32bit と 64bit がありますがここでは簡単のため 32bit 決め打ちとします。すなわち、条件は以下を満たした場合とします。

  • 最適化オプションが0でない
  • 書き込む値が定数であり0である
  • サイズが定数であり0でなく4の倍数である
  • 書き込み先アドレスのアラインメントが4の倍数である

サイズは定数でなくても良い気がしますが、非定数で4の倍数の判定方法がわからなかったため定数としました。

このとき ALIGNED_BZERO4 というノードを持つ SelectionDAG を生成します。このノードは自分で定義したもので、後ほど詳しく説明しますがアドレスを2つ受け取りそのアドレス範囲を0で埋めてくれる内部的な命令を表すノードです。

一般的な bzero のように先頭アドレスとサイズを受け取るのではなく、メモリ領域を示す2つのアドレスを受け取るようにしている理由は、そのほうが生成コードが最適化されやすいためです。ただし終了アドレスを計算するコードを生成する必要があるため、 DAG.getMemBasePlusOffset を使って DAG を構築します。

RISCVSelectionDAGInfo.cpp
static bool shouldGenerateAlignedBzero4(SelectionDAG &DAG,
    ConstantSDNode *ConstantSrc, ConstantSDNode *ConstantSize, Align Alignment) {

  auto &F = DAG.getMachineFunction().getFunction();

  if (F.hasOptNone())
    return false;
  if (!(ConstantSrc && ConstantSrc->getZExtValue() == 0))
    return false;
  if (!(ConstantSize && ConstantSize->getZExtValue() > 0 && (ConstantSize->getZExtValue() & 3) == 0))
    return false;
  if (!((Alignment.value() & 3) == 0))
    return false;

  return true;
}

SDValue RISCVSelectionDAGInfo::EmitTargetCodeForMemset(
    SelectionDAG &DAG, const SDLoc &dl, SDValue Chain, SDValue Dst, SDValue Src,
    SDValue Size, Align Alignment, bool isVolatile, bool AlwaysInline,
    MachinePointerInfo DstPtrInfo) const {

  ConstantSDNode *ConstantSrc = dyn_cast<ConstantSDNode>(Src);
  ConstantSDNode *ConstantSize = dyn_cast<ConstantSDNode>(Size);

  if (!AlwaysInline && shouldGenerateAlignedBzero4(DAG, ConstantSrc, ConstantSize, Alignment))
    return DAG.getNode(RISCVISD::ALIGNED_BZERO4, dl, MVT::Other, Chain, Dst,
                       DAG.getMemBasePlusOffset(Dst, DAG.getZExtOrTrunc(Size, dl, MVT::i32), dl));

  return SDValue();
}

これで RISCVSelectionDAGInfo は完成ですが、デフォルトの SelectionDAGTargetInfo の代わりに今回作った RISCVSelectionDAGInfo を使うように RISCVSubtarget.h にある RISCVSubtarget クラスのメンバを差し替えます。

-  SelectionDAGTargetInfo TSInfo;
+  RISCVSelectionDAGInfo TSInfo;

SelectionDAG ノードからコード生成

SelectionDAG は Lowering と呼ばれる処理を経てターゲットアーキテクチャのコードになります。

ALIGNED_BZERO4 ノードを定義します。ノード定義は RISCVInstrInfo.td で行います。

RISCVInstrInfo.td
def SDT_RISCVAlignedBzero4 : SDTypeProfile<0, 2, [
  SDTCisPtrTy<0>, SDTCisPtrTy<1>
]>;

def riscv_aligned_bzero4 : SDNode<"RISCVISD::ALIGNED_BZERO4",
                                  SDT_RISCVAlignedBzero4,
                                  [SDNPHasChain, SDNPMayStore]>;

let usesCustomInserter = 1, hasNoSchedulingInfo = 1 in
def AlignedBzero4 : Pseudo<(outs), (ins GPR:$dst, GPR:$lst), [(riscv_aligned_bzero4 GPR:$dst, GPR:$lst)]>;

ポインタを2つ受け取り、戻り値がないノードを定義しています。 usesCustomInserter = 1 によって Lowering 処理を自分で書けるようにします。

コード生成の流れです。

ストア中のポインタを保持するために一時レジスタが必要になるため、 RegInfo.createVirtualRegister を使って定義します。
ここでは RISC-V の命令である SW, ADDI, BNE に対応するものが生成可能なので生成したい命令列を作っていきます。

RISCVISelLowering.cpp
static MachineBasicBlock *emitAlignedBzero4Pseudo(MachineInstr &MI,
                                                  MachineBasicBlock *BB) {
  assert(MI.getOpcode() == RISCV::AlignedBzero4 && "Unexpected instruction");

  MachineFunction &MF = *BB->getParent();
  const BasicBlock *LLVM_BB = BB->getBasicBlock();
  MachineFunction::iterator It = ++BB->getIterator();

  MachineBasicBlock *EntryMBB = BB;
  MachineBasicBlock *LoopMBB = MF.CreateMachineBasicBlock(LLVM_BB);
  MF.insert(It, LoopMBB);
  MachineBasicBlock *DoneMBB = MF.CreateMachineBasicBlock(LLVM_BB);
  MF.insert(It, DoneMBB);

  // Transfer the remainder of BB and its successor edges to DoneMBB.
  DoneMBB->splice(DoneMBB->begin(), EntryMBB,
                  std::next(MachineBasicBlock::iterator(MI)), EntryMBB->end());
  DoneMBB->transferSuccessorsAndUpdatePHIs(EntryMBB);

  EntryMBB->addSuccessor(LoopMBB);

  MachineRegisterInfo &RegInfo = MF.getRegInfo();
  Register CurrDstReg = RegInfo.createVirtualRegister(&RISCV::GPRRegClass);
  Register NextDstReg = RegInfo.createVirtualRegister(&RISCV::GPRRegClass);
  Register Dst = MI.getOperand(0).getReg();
  Register Lst = MI.getOperand(1).getReg();
  DebugLoc DL = MI.getDebugLoc();

  const TargetInstrInfo *TII = MF.getSubtarget().getInstrInfo();

  BuildMI(LoopMBB, DL, TII->get(RISCV::PHI), CurrDstReg)
      .addUse(Dst)
      .addMBB(EntryMBB)
      .addUse(NextDstReg)
      .addMBB(LoopMBB);
  // sw zero, 0(CurrDstReg)
  BuildMI(LoopMBB, DL, TII->get(RISCV::SW))
      .addUse(RISCV::X0)
      .addUse(CurrDstReg)
      .addImm(0);
  // addi NextDstReg, CurrDstReg, 4
  BuildMI(LoopMBB, DL, TII->get(RISCV::ADDI), NextDstReg)
      .addUse(CurrDstReg)
      .addImm(4);
  // bne NextDstReg, Lst, LoopMBB
  BuildMI(LoopMBB, DL, TII->get(RISCV::BNE))
      .addUse(NextDstReg)
      .addUse(Lst)
      .addMBB(LoopMBB);

  LoopMBB->addSuccessor(LoopMBB);
  LoopMBB->addSuccessor(DoneMBB);

  MI.eraseFromParent();

  return DoneMBB;
}

ADDI のところは、CurrDstReg を直接更新して addi CurrDstReg, CurrDstReg, 4 でも良さそうな気がしますが、それだと実行時にエラーになってしまいます。LLVM には SSA (Static Single Assignment) という形で中間コードを生成する必要があるためです。

SSA は一つの変数へのアサインは1度だけできるという表現方法で、これにより最適化がしやすくなるようです。

SSA で更新を表現するには、PHI という疑似命令を使います。 PHI は、直前のブロックが何であったかによって変数に値を代入します。上のコードの PHI 命令では、直前が EntryMBB だったときは Dst を、直前が LoopMBB だったときは NextDstReg を CurrDstReg に代入しています。

以上の変更で固定長のゼロ埋めに対して短いコードが生成されるようになります。

変更点のまとめ

固定長の memcpy に対しても同じような変更を加えることで短いコードを生成できるようになります。

bzero と memcpy の両方に対応したものをLLVM 17 へのパッチとしてまとめています。

今後の課題

とりあえず 32 bit 向けでは動くものになっているのですが、実用には 32 bit だけでなく 64 bit のサポートも必要になるでしょう。今のコードだと 64 bit のコードがちゃんと生成できません。他には、浮動小数点命令やベクタ命令を使った効率化も考えられます。

Rust で書いたカーネルをコンパイルするのに使ってみたところ memset や memcpy が生成されず全てインラインに展開されていい感じなのですが、固定長の配列を多用しているためというのもあるかもしれません。普通のプログラムだとアライメントはされているけれど可変長の配列を使用する場合も多そうなので、そのあたりの対応も課題になりそうです。

Discussion