インラインアセンブリ

15 分で読了
このページの目次

asm!は、ターゲットアーキテクチャのアセンブリをRustの生成コードに埋め込み、オペランドとオプションを通じてコンパイラに対してその振る舞いを伝えます。これは「すべての最適化を禁止する壁」ではありません。最適化子は、あなたが宣言した入力、出力、副作用に基づいて、周囲のコードを配置します。

使用する前に、標準ライブラリやアーキテクチャのintrinsicで必要な操作が表現されているか確認してください。直接アセンブリは、特定の命令やABIの要件がある境界部分に適しており、通常の算術演算をパフォーマンスの直感だけで書き換える必要はありません。

入力エントリ時に読み取り可能な値出力と破壊どのレジスタが書き込まれる可能性があるかメモリとフラグメモリにアクセスするか、条件フラグを変更するか制御フローとスタック戻るかどうか、スタックを使用するか、ABIインラインアセンブリの契約:コンパイラが知っているのは宣言された部分だけアセンブリがアセンブルできるからといって、これらの宣言が真実であるとは限りません。誤った制約は、最適化後に露呈します。

明確な境界を持つ命令から始める

以下はx86-64向けで、asm!のデフォルトであるIntel構文を使用しています:

#[cfg(target_arch = "x86_64")]
fn add_wrapping(mut left: u64, right: u64) -> u64 {
    // SAFETY: 宣言されたレジスタのみを操作し、メモリやスタックにアクセスせず、addはフラグを変更するが追加の保持は宣言しない。
    unsafe {
        core::arch::asm!(
            "add {left}, {right}",
            left = inout(reg) left,
            right = in(reg) right,
            options(pure, nomem, nostack),
        );
    }
    left
}

fn main() {
    #[cfg(target_arch = "x86_64")]
    {
        assert_eq!(add_wrapping(4, 5), 9);
        assert_eq!(add_wrapping(u64::MAX, 1), 0);
    }
}

関数名は2^64でのラップアラウンド(オーバーフロー)セマンティクスを明確に示しており、オーバーフローチェック有効時の通常のRustの+演算子の完全な代替にはなりません。addはフラグを変更するため、preserves_flagsを付加することはできません。pureはここでは、出力が入力のみ依存し、他に観測可能な副作用がないことを示し、未使用の結果の削除などの最適化を許可します。

inoutとinlateoutの重要な約束の違い

オペランド約束
in(reg)初期入力;アセンブリ終了時に入力として残るレジスタの制約を満たす必要がある
out(reg)出力は、入力はまだ使用されている場合でも、入力と不当に重複しないように事前に書き込める
inout(reg)同一オペランドが入力と出力の両方であり、割り当てをより保守的に制約する
lateout(reg)すべての入力が読み込まれてから書き込み、入力レジスタと再利用できる
inlateout(reg)入力と出力の両方であり、条件を満たす入力レジスタの再利用を許可する

lateoutは、書き込みのタイミングが制約を満たす限り、入力レジスタとの再利用を許可します。複数の命令テンプレートで、出力を先に修正してから別の入力を引き続き読み取る場合、lateout/inlateoutを誤って使用すると、最適化子がレジスタを再利用した後に誤った結果を得る可能性があります。Rustインラインアセンブリ

regはコンパイラによって割り当てられ、明示的な"eax"などは物理レジスタを指定します。明示的なレジスタオペランドはテンプレートのプレースホルダで参照できず、テンプレート内でレジスタ名を直接書く必要があります。サブレジスタの幅も、命令とRustの型に合わせて対応させる必要があります。

命令1:mov output, leftoutputに書き込み;leftは既に読み込まれている命令2:add output, right元のrightを読み取る必要があるoutputはrightのレジスタを事前に上書きしてはならない複数命令テンプレート:出力の書き込み開始時に、まだ読み込まれていない入力が残っているかout(reg)を使用し、2番目の命令がまだ読み取っていない入力を保持するテンプレートの終了時にRustに出力を返すことと、テンプレート内部でレジスタに最初に書き込むタイミングは、異なる概念です。

複数命令の出力を無条件にlateoutに変更できない

#[cfg(target_arch = "x86_64")]
fn sum(left: u64, right: u64) -> u64 {
    let output: u64;
    // SAFETY: すべての読み書きレジスタが宣言されている;outputは事前に書き込まれるため、outを使用して未読の入力を上書きしない。
    // レジスタ計算のみを行い、メモリ/スタックにはアクセスしない;addはフラグを変更する。
    unsafe {
        core::arch::asm!(
            "mov {output}, {left}",
            "add {output}, {right}",
            output = out(reg) output,
            left = in(reg) left,
            right = in(reg) right,
            options(pure, nomem, nostack),
        );
    }
    output
}
fn main() {
    #[cfg(target_arch = "x86_64")]
    for left in [0, 1, 7, u64::MAX, 1 << 63] {
        for right in [0, 1, 7, u64::MAX, 1 << 63] {
            assert_eq!(sum(std::hint::black_box(left), std::hint::black_box(right)),
                       left.wrapping_add(right));
        }
    }
}

outputとrightが同じレジスタに割り当てられると、1番目のmovは2番目でまだ使用されていないrightを破壊します。出力をlateoutとしてマークすると、コンパイラにこの再利用を許可することになりますが、変数名が異なるからといって、デバッグビルドでたまたま異なるレジスタに割り当てられたからといって、安全だと見なしてはいけません。オペランド制約の例

Rustの型幅とテンプレートのレジスタ幅を一緒に確認する

#[cfg(target_arch = "x86_64")]
fn add_u32(mut left: u32, right: u32) -> u32 {
    // SAFETY: e修飾子で32ビットのサブレジスタを選択;宣言された出力と変更可能なフラグのみを変更する。
    unsafe {
        core::arch::asm!(
            "add {left:e}, {right:e}",
            left = inout(reg) left,
            right = in(reg) right,
            options(pure, nomem, nostack),
        );
    }
    left
}
fn main() {
    #[cfg(target_arch = "x86_64")]
    {
        assert_eq!(add_u32(10, 20), 30);
        assert_eq!(add_u32(u32::MAX, 1), 0);
    }
}

:eはx86レジスタテンプレートで32ビットの名前を指定します。u32をregに格納すると、テンプレートがデフォルトで印刷するより広いレジスタの上位ビットに、ある程度依存できる値が含まれていると想定してはいけません。命令の幅を実際の入力と一致させる必要があります。アーキテクチャ間の修飾子とレジスタカテゴリは異なるため、ターゲットドキュメントに従って選択する必要があります。

第 2 命令は元の入力を読めるか

変数名が違っても物理レジスタが別とは限りません。output と right を共有させると、最初の mov が次の add の入力を壊します。この反例で、out が保つ独立した配置の意味を確認できます。

図を準備しています
第 2 命令は元の入力を読めるか

早い output の書き込みが未読の right を壊すため、このテンプレートには out が必要です。

RDTSC:下位はEAX、上位はEDX

以下はカウント値のみを読み取るものであり、完全なマイクロベンチマークの境界ではありません。実行環境はユーザーモードでTSCを読み取れるようにする必要があります:

#[cfg(target_arch = "x86_64")]
fn read_tsc() -> u64 {
    let low: u32;
    let high: u32;
    // SAFETY: ユーザーモードでのRDTSCが許可されているターゲット環境では、宣言されたEAX/EDXのみを破壊する。
    unsafe {
        core::arch::asm!(
            "rdtsc",
            out("eax") low,
            out("edx") high,
            options(nomem, nostack, preserves_flags),
        );
    }
    ((high as u64) << 32) | low as u64
}

fn main() {
    #[cfg(target_arch = "x86_64")]
    println!("{}", read_tsc());
}

RAXに対して複数の競合する出力を同時に宣言することはできず、RAXのみを読み取って64ビットの結果が自動的に組み立てられたと主張することもできません。RDTSCは外部カウンターに応じて変化するため、pureとマークすることはできません。

RDTSCは完全な命令シリアライゼーションを提供しません。前後のコードを測定するには、プロセッサマニュアルに従って順序付け、タイマーの読み取り、コンパイラからの可視性を設計し、コアの移動、仮想化、周波数変動を評価する必要があります。TSCのティックは、現在のコアの実際の各実行サイクルとは等しくありません。Intel SDM

AArch64のCNTVCT_EL0は仮想システムカウンターであり、周波数とアクセス権限には独自の規約があります。一般的に「コアサイクルカウンター」と呼ぶべきではありません。アーキテクチャ間の移植では、単にmrs命令を置き換えるだけでなく、測定定義を再構築する必要があります。Armシステムカウンター

optionsはパフォーマンスヒントではなく、信頼できる宣言である

nomemは、アセンブリから見える外部メモリへの読み書きがないことを示し、コンパイラが周囲のメモリ操作をより積極的に処理することを許可します。これは「CPUキャッシュをフラッシュするかどうか」ではありません。readonlyとpureにもそれぞれ要件があり、バッファに書き込んだりデバイスにアクセスしたりする命令を純粋な読み取りや純粋な計算とマークしてはいけません。

nostackは、規定以外のスタックスペースを使用しないことを約束し、ターゲットABIのred zoneルールを含みます。preserves_flagsは、対応するアーキテクチャのフラグを保持することを約束します。noreturnは、後続のRustコードに戻らないことを宣言します。虚偽の宣言は、デバッグビルドで正常に見える場合でも、未定義動作を引き起こす可能性があります。

通常のメモリ読み書き、volatile、コンパイラの並べ替え制約、CPUのメモリバリアは異なるレベルです。asm!のデフォルトの保守的なメモリへの影響は、非アトミックな共有アクセスをスレッドセーフにするものではありません。

アドレスを渡すことは、メモリ読み取りを宣言したことを意味しない

#[cfg(target_arch = "x86_64")]
fn load(value: &u64) -> u64 {
    let output: u64;
    // SAFETY: valueは呼び出し期間中、有効で整列されており、競合する書き込みがないu64である;
    // movはこのアドレスを読み取り、メモリ/スタックには書き込まず、フラグも変更しない。
    unsafe {
        core::arch::asm!(
            "mov {output}, qword ptr [{address}]",
            address = in(reg) value as *const u64,
            output = lateout(reg) output,
            options(readonly, nostack, preserves_flags),
        );
    }
    output
}
fn main() {
    #[cfg(target_arch = "x86_64")]
    {
        let values = [0u64, 42, u64::MAX];
        for value in &values { assert_eq!(load(value), *value); }
    }
}

addressはレジスタ入力であり、[address]が実際にメモリ読み取りを実行するため、nomemとマークすることはできません。1命令のmovは、出力に書き戻す前にアドレスオペランドを読み取るため、ここではlateoutをアドレス入力と再利用できます。これは、前述の2命令のテンプレートとは異なります。

readonlyは、関連する外部メモリへの書き込みがないことを示すが、読み取りは行う。この例の安全なエントリは&u64を受け取り、参照の既存の保証を使用して読み取り範囲とライフタイムを制約している。任意の生アドレスに置き換える場合、ラッパーは追加の呼び出し前提条件を増やす必要がある。MMIO、volatile、スレッド同期は、この通常の読み取りテンプレートで代替することはできない。

実際の動作宣言時に考慮すべき内容
レジスタのみの演算入力、出力、フラグ;条件を満たす場合はpure + nomem
ポインタ経由での通常のメモリ読み取りnomemにできない、読み取りの前提条件はインターフェースによって確立される
バッファへの書き込みreadonlyにできない、正しい排他または同期と有効範囲が必要
変化するハードウェアカウンターへの読み取りメモリに書き込まないからといって、単にpureとマークしてはいけない
外部関数の呼び出しABI、レジスタの破壊、スタック、メモリ、展開規約を一緒に確認する

アセンブリで関数を呼び出すには、完全なABI義務がある

clobber_abi("C")は、指定されたABIの呼び出し側破壊レジスタセットを宣言するが、引数の配置、スタックアラインメントの維持、Windowsのshadow spaceの予約、または展開情報の実装を代行するものではない。出力と一緒に使用する場合、明示的なレジスタなどのルールがあり、ドキュメントに従って項目ごとに確認する必要がある。

したがって、call一行にclobber_abiを付けるだけでは、どこにでもコピーして使用できる安全な呼び出しラッパーにはならない。通常の関数呼び出しは、通常Rustのextern宣言に任せるべきである。手書きのエントリポイントや完全な関数が必要な場合にのみ、global_asm!/naked_asm!と対応するターゲット制約を検討する。

検証は「コンパイルできる」かどうかだけを見てはいけない

ターゲットプラットフォームで境界入力をテストし、純粋な演算とRustの明確なセマンティクスを持つ実装と比較する。少なくとも最適化ビルドをチェックすること。レジスタの再利用エラーは、最適化後にのみ現れる可能性があるためである。最終的な逆アセンブリを確認し、命令、レジスタの幅、周囲のコードの配置を確認する。

コンパイラは一部の競合や不正なオペランドを検出できるが、テンプレートとclobber/optionsの整合性を証明することは通常できない。Miriはインラインアセンブリに対して能力が限られており、カバーされていない実行結果によってアセンブリが安全であることを証明することはできない。条件付きコンパイルでアーキテクチャ構文を保護し、CPU命令拡張の実行条件は別途処理する。詳細はcfgを参照。

命令の神話としてマイクロベンチマークを書かないように検証するには

まず、計算セマンティクスを安全なRustのリファレンス実装として書き、0値、境界値、同じ入力、オーバーフローをテストし、異なる最適化レベルの結果と比較する。同じ入力は特に、コンパイラのレジスタ共用の選択に影響を与える可能性がある。テストは、互いに異なる少数の値だけでなく、多様なケースをカバーするべきである。

rustc --edition=2024 -O --emit=asm example.rsを使用して最適化後のアセンブリを出力し、オペランドの割り当てとテンプレートの周囲のコードを確認する。あるアセンブリ断片を見ることは、現在のターゲット、バージョン、最適化構成の証拠に過ぎず、レジスタ名を言語契約に固定して書き込むべきではない。

パフォーマンスを測定する際は、不要な計算の削除防止、測定オーバーヘッド、分岐分布、キャッシュ、周波数を別途考慮する必要がある。black_boxは一部の最適化を防ぐのに役立つが、正確なタイマーを保証するものでもなく、安全な境界でもない。数回のRDTSCの差を取っても、これらの変数を自動的に除外するものではない。標準Rustが同じ命令を生成している場合、手書きのasmは保守性とクロスプラットフォームのコストを増やすだけである。