多くの人が MemCpyOpt を見つけるのは同じようなきっかけです: opt -O2 後の IR を眺めていて、連続するストアが 1 つの llvm.memset に統合されているのに気づき、「どのパスがこれをやったのか?」と疑問に思う。その答えが MemCpyOpt です — memcpy, memset, ストア、関数引数渡しの周辺の雑多な最適化を担当する 1 つの関数パスです。

LLVM の基準からすると小さなパス(約 2,200 行)ですが、驚くほど多くの重労働をこなしています。担当するのは:

この記事ではそれぞれを順に見ていきます。行番号は全て llvm/lib/Transforms/Scalar/MemCpyOptimizer.cpp のもの(ヘッダは llvm/include/llvm/Transforms/Scalar/MemCpyOptimizer.h)。テストケースは llvm/test/Transforms/MemCpyOpt/ から取ります。

パスの場所

MemCpyOpt は関数パス(MemCpyOptimizer.h:23-53)で、いくつかの解析に依存します:

class MemCpyOptPass : public PassInfoMixin<MemCpyOptPass> {
  TargetLibraryInfo *TLI = nullptr;
  AAResults *AA = nullptr;
  AssumptionCache *AC = nullptr;
  DominatorTree *DT = nullptr;
  PostDominatorTree *PDT = nullptr;
  MemorySSA *MSSA = nullptr;
  MemorySSAUpdater *MSSAU = nullptr;
  EarliestEscapeAnalysis *EEA = nullptr;
};

本題に入る前に知っておくべき 4 つの解析:

パスは run()MemCpyOptimizer.cpp:2228)から走り、これが runImpl()MemCpyOptimizer.cpp:2246)に委譲します:

PreservedAnalyses MemCpyOptPass::run(Function &F, FunctionAnalysisManager &AM) {
  // ... obtain analyses ...
  bool MadeChange = runImpl(F, &TLI, AA, AC, DT, PDT, &MSSA->getMSSA());
  if (!MadeChange)
    return PreservedAnalyses::all();

  PreservedAnalyses PA;
  PA.preserveSet<CFGAnalyses>();
  PA.preserve<MemorySSAAnalysis>();
  return PA;
}

runImpl は何も変化しなくなるまで iterateOnFunction をループさせます — 古典的な不動点反復です。中の iterateOnFunctionMemCpyOptimizer.cpp:2181-2226)がディスパッチャ:

for (BasicBlock &BB : F) {
  if (!DT->isReachableFromEntry(&BB)) continue;

  for (BasicBlock::iterator BI = BB.begin(), BE = BB.end(); BI != BE;) {
    Instruction *I = &*BI++;
    bool RepeatInstruction = false;

    if (auto *SI = dyn_cast<StoreInst>(I))
      MadeChange |= processStore(SI, BI);
    else if (auto *M = dyn_cast<MemSetInst>(I))
      RepeatInstruction = processMemSet(M, BI);
    else if (auto *M = dyn_cast<MemCpyInst>(I))
      RepeatInstruction = processMemCpy(M, BI);
    else if (auto *M = dyn_cast<MemMoveInst>(I))
      RepeatInstruction = processMemMove(M, BI);
    else if (auto *CB = dyn_cast<CallBase>(I)) {
      for (unsigned i = 0; i != e; ++i) {
        if (CB->isByValArgument(i))
          MadeChange |= processByValArgument(*CB, i);
        else if (CB->onlyReadsMemory(i))
          MadeChange |= processImmutArgument(*CB, i);
      }
    }
    // ...
  }
}

以下で見るどの変換も、これらの process* エントリポイントのいずれかにぶら下がっています。

最適化 1: 隣接するストアを memset に融合

最もイメージしやすい変換です。test/Transforms/MemCpyOpt/form-memset.ll からの関数:

; 同じバイト値の連続した 19 個のストア
store i8 %c, ptr %tmp,  align 1
store i8 %c, ptr %tmp5, align 1
store i8 %c, ptr %tmp9, align 1
; ... さらに 16 個 ...
store i8 %c, ptr %tmp73, align 1

MemCpyOpt 後:

call void @llvm.memset.p0.i64(ptr align 1 %tmp, i8 %c, i64 19, i1 false)

19 個のストアが 1 つの memset に畳まれました。

仕事は tryMergingIntoMemsetMemCpyOptimizer.cpp:352-501)で行われます。中核ループは開始ストアから前方に走査し、同じバイト値の隣接ストアを集めようとします:

MemsetRanges Ranges(DL);

BasicBlock::iterator BI(StartInst);
MemoryUseOrDef *MemInsertPoint = nullptr;
for (++BI; !BI->isTerminator(); ++BI) {
  auto *CurrentAcc = cast_or_null<MemoryUseOrDef>(MSSA->getMemoryAccess(&*BI));
  if (CurrentAcc)
    MemInsertPoint = CurrentAcc;

  if (!isa<StoreInst>(BI) && !isa<MemSetInst>(BI)) {
    if (BI->mayWriteToMemory() || BI->mayReadFromMemory())
      break;       // 他のメモリ操作 -> 停止
    continue;      // 無害な命令、探索継続
  }

  if (auto *NextStore = dyn_cast<StoreInst>(BI)) {
    Value *StoredByte = isBytewiseValue(StoredVal, DL);
    if (ByteVal != StoredByte) break;

    std::optional<int64_t> Offset =
        NextStore->getPointerOperand()->getPointerOffsetFrom(StartPtr, DL);
    if (!Offset) break;

    Ranges.addStore(*Offset, NextStore);
  }
}

2 つ注目すべき点があります。

isBytewiseValue(StoredVal, DL) は「この値の全バイトは同じか?」を問います。0x42 のような定数は当然 0x42 を返す。0x4242424242424242i64)も 0x42 を返す。%ci8 変数)のような非定数は %c 自身を返します — i8 は 1 バイトしかないため。これが memset に必要な性質です: 単一バイトパターンの繰り返し。

MemsetRangesMemCpyOptimizer.cpp:91-199)は、ストア済みのバイト範囲を追跡する小さな区間マージデータ構造です。スキャン終了後、memset として具現化する価値がある範囲があるかチェックします — ヒューリスティックは大まかに「4 個以上のストアか 16 バイト以上」(isProfitableToUseMemset 参照)。

なぜ安全か。 ループは、収集しているストア以外でメモリを読み書きする命令を見つけ次第打ち切ります。したがって構造上、最初と最後のストアの間に中間状態を観測できるものは何もなく、それらを 1 つの一括ストアに畳むことはプログラムの他の部分には見えません。

最適化 2: load; storememcpy

C++ で大きな集約型に対して *dst = *src と書くと、Clang は構造体の load とそれに続く store を生成します。MemCpyOpt はこれをちゃんとした memcpy に変換し、バックエンドがブロックコピー命令を使えるようにします。

関数は processStoreOfLoadMemCpyOptimizer.cpp:631-743)。核となるビルダーコード(MemCpyOptimizer.cpp:674-684):

IRBuilder<> Builder(P);
Value *Size = Builder.CreateTypeSize(Builder.getInt64Ty(),
                                     DL.getTypeStoreSize(T));
Instruction *M;
if (UseMemMove)
  M = Builder.CreateMemMove(SI->getPointerOperand(), SI->getAlign(),
                            LI->getPointerOperand(), LI->getAlign(), Size);
else
  M = Builder.CreateMemCpy(SI->getPointerOperand(), SI->getAlign(),
                           LI->getPointerOperand(), LI->getAlign(), Size);

UseMemMove フラグに注目: エイリアス解析が srcdst が重ならないと証明できない場合、パスは memcpy ではなく memmove を出力します。memcpy は領域が互いに素であることを要求しますが、memmove は重なりを正しく扱います。この「強い方を証明できないときは弱い方の intrinsic を選ぶ」パターンは MemCpyOpt のいたる所に登場します。

安全性。 ロードは単一の使用者(ストア)を持たねばなりません。ロードとストアは同じ基本ブロックになければならず、その間でソース位置に書き込むものがあってはなりません。これら全てはエイリアスクエリと MemorySSA のウォークで書き換え前にチェックされます。

最適化 3: チェインされた memcpy の転送

これは実際の C++ コードで最もよく遭遇するもので、一時オブジェクトを片付けてくれます。test/Transforms/MemCpyOpt/memcpy.ll より:

; Before
%memtmp = alloca %0, align 16
call void @llvm.memcpy.p0.p0.i32(ptr align 16 %memtmp, ptr align 16 %P, i32 32, i1 false)
call void @llvm.memcpy.p0.p0.i32(ptr align 16 %Q, ptr align 16 %memtmp, i32 32, i1 false)

最初のコピーは一時 alloca を埋め、2 つ目はその一時を %Q にコピーします。MemCpyOpt は 2 つ目のコピーを書き換えて直接 %P から読ませます:

; After
call void @llvm.memmove.p0.p0.i32(ptr align 16 %Q, ptr align 16 %P, i32 32, i1 false)

(中間 alloca と最初の memcpy は、誰も読まなくなった時点で DSE などの後続パスに掃除されます。)

変換は processMemCpyMemCpyDependenceMemCpyOptimizer.cpp:1102-1267)にあります。興味深いのはオフセットの扱いです — 2 つ目の memcpy が 1 つ目の memcpy の dest の先頭から始まるとは限りません:

IRBuilder<> Builder(M);
auto *CopySource = MDep->getSource();
Instruction *NewCopySource = nullptr;

if (MForwardOffset > 0) {
  std::optional<int64_t> MDestOffset =
      M->getRawDest()->getPointerOffsetFrom(MDep->getRawSource(), DL);
  if (MDestOffset == MForwardOffset)
    CopySource = M->getDest();
  else {
    CopySource = Builder.CreateInBoundsPtrAdd(
        CopySource, Builder.getInt64(MForwardOffset));
    NewCopySource = dyn_cast<Instruction>(CopySource);
  }
}
if (writtenBetween(MSSA, BAA, MCopyLoc, MSSA->getMemoryAccess(MDep),
                   MSSA->getMemoryAccess(M)))
  return false;
Instruction *NewM = UseMemMove
    ? Builder.CreateMemMove(...)
    : Builder.CreateMemCpy(...);

オフセットケースは test/Transforms/MemCpyOpt/memcpy-memcpy-offset.ll でテストされています:

; Before
call void @llvm.memcpy.p0.p0.i64(ptr %cpy_tmp, ptr %src, i64 7, i1 false)
%cpy_tmp_offset = getelementptr inbounds i8, ptr %cpy_tmp, i64 1
call void @llvm.memcpy.p0.p0.i64(ptr %dest, ptr %cpy_tmp_offset, i64 6, i1 false)

; After
call void @llvm.memcpy.p0.p0.i64(ptr %cpy_tmp, ptr %src, i64 7, i1 false)
%src.offset = getelementptr inbounds i8, ptr %src, i64 1
call void @llvm.memmove.p0.p0.i64(ptr %dest, ptr %src.offset, i64 6, i1 false)

2 つ目の memcpy は %cpy_tmp から 1..7 バイトを読んでおり、これは最初の memcpy が %src から埋めた領域です。だから %src から 1..7 バイトを読むのと等価 — ゆえに src + 1 の新しい GEP になるわけです。

安全性。 重要な呼び出しは writtenBetween(...): 最初と 2 つ目のコピーの間で %src(元のソース)を変更するものが何もないことを証明する必要があります。もし何かが変更していたら、転送後の 2 つ目のコピーは古いデータを読むことになります。MemorySSA がこれを安価なウォークにしてくれます。

最適化 4: 定数からの memcpymemset

小さくて綺麗な畳み込みです。memcpy のソースが、繰り返しバイトで埋められたと分かっているメモリなら、コピー自体を memset にできます。

関数は performMemCpyToMemSetOptznMemCpyOptimizer.cpp:1429-1502):

IRBuilder<> Builder(MemCpy);
Value *DestPtr = MemCpy->getRawDest();
MaybeAlign Align = MemCpy->getDestAlign();
if (MOffset < 0) {
  DestPtr = Builder.CreatePtrAdd(DestPtr, Builder.getInt64(-MOffset));
  if (Align)
    Align = commonAlignment(*Align, -MOffset);
}

Instruction *NewM = Builder.CreateMemSet(DestPtr, MemSet->getOperand(1),
                                         CopySize, Align);
auto *LastDef = cast<MemoryDef>(MSSA->getMemoryAccess(MemCpy));
auto *NewAccess = MSSAU->createMemoryAccessAfter(NewM, nullptr, LastDef);
MSSAU->insertDef(cast<MemoryDef>(NewAccess), /*RenameUses=*/true);

ビルダー部分は特別なことはなく、仕事は前提条件の証明にあります。ソースは単一バイトの繰り返しパターン(以前の memset、または isBytewiseValue が受け入れる定数のいずれか)でなければならず、コピーは初期化された領域より大きくてはいけません。発火した場合の結果:

; Before: ゼロ初期化されたソースからコピー
call void @llvm.memset.p0.i64(ptr %src, i8 0, i64 32, ...)
call void @llvm.memcpy.p0.p0.i64(ptr %dst, ptr %src, i64 32, ...)

; After: dst へ直接 memset
call void @llvm.memset.p0.i64(ptr %src, i8 0, i64 32, ...)
call void @llvm.memset.p0.i64(ptr %dst, i8 0, i64 32, ...)

後続のパスは多くの場合、元の %src への memset が死んでいると気づいて除去します。

最適化 5: call-slot 最適化

パス中で最も重要な変換です。これが C++ の値渡し return を安くしてくれます。概念的に:

// C++ source
Big f();
void g() {
  Big x = f();
}

Clang はこれを次のようなものに下げます:

%tmp = alloca %struct.Big        ; f の戻り値用の一時
call void @f(ptr sret %tmp)      ; f は sret パラメータ経由で %tmp に書き込む
%x   = alloca %struct.Big        ; x のストレージ
call void @llvm.memcpy.p0.p0.i64(ptr %x, ptr %tmp, i64 N, i1 false)

call-slot 最適化は %tmp が「f の sret 書き込み先」と「memcpy のソース」としてしか使われていないことに気づき、%x を直接 f に渡せると判断します:

%x = alloca %struct.Big
call void @f(ptr sret %x)        ; f は x に直接書き込む

一時なし、コピーなし。これが C++ で言う NRVO にほぼ相当します。ここではフロントエンドが行ったかどうかに関係なく最適化器が実行します。

仕事は performCallSlotOptznMemCpyOptimizer.cpp:842-1098)。250 行あり、そのほとんどが安全性チェックです。理解しておくべきもの:

(1) ソースはぴったりのサイズの新鮮な alloca。 もし alloca がコピーより小さければ、関数は memcpy が読む範囲を超えて書いている可能性があり、それを %x にリダイレクトすると %x の外のメモリを壊してしまう。(MemCpyOptimizer.cpp:867-878

(2) 呼び出しと memcpy の間で誰も dest にアクセスしない。 もし誰かが間で %x を読み書きしていたら、最適化の前後で dest の値が変わる。(MemCpyOptimizer.cpp:903-907

if (accessedBetween(BAA, DestLoc, MSSA->getMemoryAccess(C),
                    MSSA->getMemoryAccess(cpyStore), &SkippedLifetimeStart)) {
  LLVM_DEBUG(dbgs() << "Call Slot: Dest pointer modified after call\n");
  return false;
}

(3) dest はフルサイズ分、書き込み可能で参照可能。 そうでなければ呼び出しのリダイレクトでトラップする可能性。(MemCpyOptimizer.cpp:923-929

if (!isWritableObject(getUnderlyingObject(cpyDest),
                      ExplicitlyDereferenceableOnly) ||
    !isDereferenceableAndAlignedPointer(cpyDest, Align(1),
                                        APInt(64, cpySize), DL, C, AC, DT)) {
  return false;
}

(4) ソース alloca は呼び出しと memcpy のみ に使われる。MemCpyOptimizer.cpp:964-979

SmallVector<User *, 8> srcUseList(srcAlloca->users());
while (!srcUseList.empty()) {
  User *U = srcUseList.pop_back_val();
  if (isa<AddrSpaceCastInst>(U)) {
    append_range(srcUseList, U->users());
    continue;
  }
  if (isa<LifetimeIntrinsic>(U)) continue;
  if (U != C && U != cpyLoad) {
    return false;   // 誰かがこの alloca を見ている、中止
  }
}

他の何か(デバッグ intrinsic がアドレスを保存、それからのロード、エスケープ)が一時を観測していたら、安全にリダイレクトできません。

(5) 呼び出しが他の理由で dest にアクセスしない。 f(sret *ret, Big *other) を考えてみてください — ret%x にリダイレクトしたけど、その呼び出しが既に other 引数として %x を持っていたら、以前にはなかったエイリアシングを作ってしまいます。(MemCpyOptimizer.cpp:1047-1053

MemoryLocation DestWithSrcSize(cpyDest, LocationSize::precise(srcSize));
ModRefInfo MR = BAA.getModRefInfo(C, DestWithSrcSize);
if (isModOrRefSet(MR))
  MR = BAA.callCapturesBefore(C, DestWithSrcSize, DT);
if (isModOrRefSet(MR)) return false;

test/Transforms/MemCpyOpt/callslot.ll のテストケースは、dest が別の alloca の GEP であっても最適化が発火する様子を示しています:

; Before
%dest    = alloca [16 x i8]
%src     = alloca [8 x i8]
%dest.i8 = getelementptr [16 x i8], ptr %dest, i64 0, i64 8
call void @accept_ptr(ptr %src) nounwind
call void @llvm.memcpy.p0.p0.i64(ptr %dest.i8, ptr %src, i64 8, i1 false)

; After
%dest    = alloca [16 x i8]
%src     = alloca [8 x i8]             ; 今は未使用、後で DSE される
%dest.i8 = getelementptr [16 x i8], ptr %dest, i64 0, i64 8
call void @accept_ptr(ptr %dest.i8) nounwind
ret void

呼び出しは %dest の後半に直接書き込み、memcpy は消えました。1 つのスタック alloca 分のメモリと 1 つの完全な memcpy が節約されます — 呼び出しごとに。

最適化 6: stack-move — 2 つの alloca を統合

1 つの alloca が他方に memcpy されるためだけに生きているというパターンに遭遇することがあります。test/Transforms/MemCpyOpt/stack-move-offset.ll の例:

%src = alloca [16 x i8], align 4
%dest = alloca [8 x i8], align 8
%src.gep = getelementptr inbounds i8, ptr %src, i64 8
store i64 42, ptr %src.gep
call void @use_nocapture(ptr %src.gep)
call void @llvm.memcpy.p0.p0.i64(ptr %dest, ptr %src.gep, i64 8, i1 false)
call void @use_nocapture(ptr %dest)

%dest%src の後半の鏡写しに過ぎません。stack-move 最適化はこれらを統合します — %dest の全使用を %src の GEP に書き換え、%dest を削除します:

%src = alloca [16 x i8], align 8        ; アライメントを両者の最大に引き上げ
%src.gep = getelementptr inbounds i8, ptr %src, i64 8
store i64 42, ptr %src.gep
call void @use_nocapture(ptr %src.gep)
; memcpy 消滅
call void @use_nocapture(ptr %src.gep)  ; 元々 "use %dest" だったもの

関数 performStackMoveOptznMemCpyOptimizer.cpp:1516-1777)はパス中で最長です。前提条件のスケッチ(MemCpyOptimizer.cpp:1548-1569):

auto DestOffset = DestPtr->getPointerOffsetFrom(DestAlloca, DL);
if (!DestOffset) return false;
auto SrcOffset = SrcPtr->getPointerOffsetFrom(SrcAlloca, DL);
if (!SrcOffset || *SrcOffset < *DestOffset || *SrcOffset < 0)
  return false;
// オフセット差は dest alloca のアライメントを保存する必要がある
if ((*SrcOffset - *DestOffset) % DestAlloca->getAlign().value() != 0)
  return false;
// コピーサイズは dest alloca のサイズと等しくなければならない
if (Size != *DestSize || *DestOffset != 0) {
  return false;
}

書き換え自体(MemCpyOptimizer.cpp:1723-1777):

if (MoveSrc)
  SrcAlloca->moveBefore(DestAlloca->getIterator());
SrcAlloca->setAlignment(
    std::max(SrcAlloca->getAlign(), DestAlloca->getAlign()));
// ...
Value *NewDestPtr = SrcAlloca;
if (*SrcOffset != *DestOffset) {
  IRBuilder<> Builder(DestAlloca);
  NewDestPtr = Builder.CreateInBoundsPtrAdd(
      SrcAlloca, Builder.getInt64(*SrcOffset - *DestOffset));
}
DestAlloca->replaceAllUsesWith(NewDestPtr);
eraseInstruction(DestAlloca);

なぜアライメント調整が必要か? 統合された alloca は両方の元の alloca のアライメント要件を満たさなければなりません。だから %src のアライメントは max(src_align, dest_align) に引き上げられ、上のテストでは 48 になりました。

安全性。 本当に微妙な要件は、どちらの alloca のアドレスも観測可能な形で関数からエスケープしないことです。もし他のコードが %dest へのポインタをキャプチャしていたら、それを %src と統合するとそのポインタが観測するものが変わってしまいます。パスは安全性予算の大部分(MemCpyOptimizer.cpp:1577-1721)を CaptureTrackingWithModRef ラムダに費やし、両方の alloca の全ユーザーを歩いてエスケープや mod/ref の衝突が起きないことを検証します。

最適化 7: byval 引数転送

C で構造体を値渡しすると、ABI はしばしば呼び出し先が自分のコピーを見ることを要求します。IR レベルでは、byval 引数はこれを暗黙的に行います。呼び出し側が単に byval パラメータに渡すためだけにスタック一時にソースを memcpy していたなら、MemCpyOpt はそのコピーを除去して元のものを直接渡せる場合があります:

; Before
%a = alloca %struct.S
call void @llvm.memcpy.p0.p0.i64(ptr %a, ptr %b, i64 N, i1 false)
call void @g(ptr byval(%struct.S) %a)

; After
call void @g(ptr byval(%struct.S) %b)

関数 g はそれでも自分のプライベートコピーを受け取ります(それが byval の意味です)。観測可能なセマンティクスは保持されます — コピーを IR ではなく呼び出し規約に任せているだけです。

実装は processByValArgumentMemCpyOptimizer.cpp:2007-2074)。重要な安全性チェックはおなじみの「memcpy と呼び出しの間で %b に書き込むものがない」テスト(MemCpyOptimizer.cpp:2061-2063):

if (writtenBetween(MSSA, BAA, MemoryLocation::getForSource(MDep),
                   MSSA->getMemoryAccess(MDep), CallAccess))
  return false;

対になる変換 processImmutArgumentMemCpyOptimizer.cpp:2090-2168)は readonly nocapture マークされたパラメータ用で、同じロジックが適用されます: 呼び出し先は書かないと約束しているので、防御的コピーをスキップできる。

共通の安全性パターン

これらの変換は全て、同じ形の安全性の議論に依存しています:

「ソース」イベント(memset、memcpy、書き込む呼び出し)と「dest」イベント(書き換えようとしている memcpy や使用)の間で、他のメモリ操作が干渉し得ない。

この問いこそ、MemorySSA が安価に答えるために作られたものです。ヘルパー writtenBetweenprocessMemCpyMemCpyDependence, performMemCpyToMemSetOptzn, processByValArgument などに登場 — は MSSA の clobber ウォークをラップし、エイリアス解析にフォールバックします。MemorySSA なしだと、これらの変換の多くはチェックに O(n²) かかり、おもちゃ以上のテストケースではすぐに諦めることになるでしょう。

もう一つの共通パターンは「memcpy を証明できないときは memmove を使う」。memcpy はソースとデストが互いに素であることを要求しますが、memmove はしません。MemCpyOpt が互いに素を証明できないときは単純に memmove を出し、後の AA が賢くなれば別のパス(InstCombine)が再び memcpy に絞り込むことを知っているので安心できます。

次に読むもの

続きを読むなら、テストディレクトリ llvm/test/Transforms/MemCpyOpt/ には約 120 の .ll ファイルがあり、それぞれ特定のコーナーケースを検証しています。memcpy.ll, form-memset.ll, callslot.ll, stack-move-offset.ll が最良のスタート地点で — これらでこの記事の内容のほとんどをカバーできます。読んで、対応する process* または perform* 関数を開いて追ってみてください。小さな IR と名前の良い安全性チェックの組み合わせが、これを勉強するのに気持ちの良い最適化パスの 1 つにしています。

上記の 7 つの変換は、他の 最適化パスが何に頼れるかを理解する参照点としても有用です。IR が LoopVectorize や GVN に到達する頃には、MemCpyOpt が既に load-of-store を memcpy に、チェインされたコピーを単一のコピーに変えてあります — 多くの下流のロジックは、そのカノニカライゼーションが既に起きていることを前提に組まれています。