•21 min read

WebAssembly SIMDによる高性能Rust: ブラウザ計算を10倍高速化

WebAssembly SIMDによる高性能Rust: ブラウザ計算を10倍高速化

最新のウェブアプリケーションでは、ブラウザ内で直接高性能な計算能力がますます求められています。従来のJavaScriptの実行は最適化されているとはいえ、大規模なデータセットの処理、複雑な数学的演算、高度なグラフィックのレンダリングにおいては、しばしば限界に直面します。WebAssembly (Wasm) は、ほぼネイティブに近いパフォーマンスの代替手段を提供し、WebAssembly SIMD (Single Instruction, Multiple Data) の登場により、データ並列ワークロードにおいて桁違いの改善を実現できるようになりました。

このガイドでは、RustとWebAssembly SIMDを活用してブラウザベースの計算で大幅なパフォーマンス向上を達成するためのアーキテクチャ上の考慮事項と実装戦略について詳しく説明します。v128ベクターレジスタ、コンパイラの自動ベクトル化、手書きのSIMD組み込み関数を探求し、画像処理や行列乗算といった実際のシナリオでの応用を実演します。

Audio Briefing
0:00 / 0:00

WebAssembly SIMDの基礎

SIMDは、単一の命令で複数のデータポイントを同時に操作できる並列コンピューティングの一種です。これは、一度に1つのデータポイントを操作するスカラ処理とは対照的です。大規模なデータ配列に対する反復操作を伴うタスクでは、SIMDは大幅なスループット向上をもたらします。

v128ベクターレジスタ

WebAssembly SIMDは、128ビットのベクターを表す新しい値型v128を導入します。このベクターは様々なデータ型を保持でき、以下の並列操作を可能にします。

  • 16個のi8(8ビット整数)
  • 8個のi16(16ビット整数)
  • 4個のi32(32ビット整数)
  • 2個のi64(64ビット整数)
  • 4個のf32(32ビット浮動小数点数)
  • 2個のf64(64ビット浮動小数点数)

これらのv128レジスタは、以下を含む一連のSIMD命令によって操作されます。

  • ロード/ストア: メモリとレジスタ間でデータを移動するためのv128.load、v128.store。
  • 算術演算: i32x4.add、f32x4.mul、i16x8.subなど。
  • 論理演算: v128.and、v128.or、v128.xor。
  • シャッフル/スウィズル: ベクター内の要素を再配置するためのv8x16.shuffle。
  • 比較: i33x4.eq、f32x4.lt。

基盤となるハードウェア(CPU)は、これらのv128操作をネイティブSIMDユニット(例:x86のSSE、AVX、ARMのNEON)を使用して実行し、高性能なベクター処理のためのポータブルな抽象化レイヤーを提供します。

Wasm向けRustでのSIMDの有効化

Wasm SIMDサポート付きでRustコードをコンパイルするには、コンパイル時に特定のターゲット機能を有効にする必要があります。

まず、wasm32-unknown-unknownターゲットがインストールされていることを確認してください。

rustup target add wasm32-unknown-unknown

wasm-packプロジェクトの場合、通常はこれをCargo.tomlで設定し、rustcにwasm-pack buildを介してフラグを渡します。

# Cargo.toml
[package]
name = "wasm-simd-lib"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2.92"

[profile.release]
# Enable LTO for better optimization
lto = true
# Optimize for size
opt-level = 's'
# Enable SIMD target feature
# This flag is crucial for both auto-vectorization and intrinsics.
# It tells the Rust compiler (and LLVM) to emit Wasm SIMD instructions.
rustflags = ["-C", "target-feature=+simd128"]

wasm-packでビルドする場合、profile.release内のrustflagsは自動的に認識されます。

wasm-pack build --target web --release

このコマンドは、RustコードをWasmモジュールにコンパイルし、相互作用のためのJavaScriptバインディングを生成します。--releaseフラグは、target-feature=+simd128を含むprofile.release設定が適用されることを保証します。

Advertisement

Rustによる自動ベクトル化

Rustコンパイラは、LLVMを活用して、ループをベクトル化し、SIMD命令を発行する機会を自動的に検出できます。これは、明示的なSIMDコードを書かずにパフォーマンス上の利点を得るための最も簡単な方法であることがよくあります。

単純な配列加算を考えてみましょう。

// src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add_arrays_scalar(a: &[f32], b: &[f32], c: &mut [f32]) {
    assert_eq!(a.len(), b.len());
    assert_eq!(a.len(), c.len());

    for i in 0..a.len() {
        c[i] = a[i] + b[i];
    }
}

// To enable auto-vectorization, ensure `target-feature=+simd128` is set in Cargo.toml.
// The compiler will attempt to vectorize this loop if possible.
#[wasm_bindgen]
pub fn add_arrays_auto_vectorized(a: &[f32], b: &[f32], c: &mut [f32]) {
    assert_eq!(a.len(), b.len());
    assert_eq!(a.len(), c.len());

    // This loop is identical to the scalar version.
    // The compiler's optimizer will attempt to vectorize it.
    for i in 0..a.len() {
        c[i] = a[i] + b[i];
    }
}

target-feature=+simd128でコンパイルすると、add_arrays_auto_vectorized関数のループは、LLVMによってf32x4.load、f32x4.add、f32x4.storeのようなWasm SIMD命令に変換される可能性があります。

自動ベクトル化の制限事項:

  • コンパイラのヒューリスティック: コンパイラのベクトル化能力は、ループ構造、メモリアクセスパターン、データ依存性によって異なります。複雑なループや非連続なメモリアクセスは、しばしば自動ベクトル化を妨げます。
  • アライメント: Wasm SIMDは非アラインなロード/ストアを処理できますが、アラインされたアクセスの方が一般的に高速です。コンパイラは常に最適なアライメントを保証するとは限りません。
  • 特定のアルゴリズム: 一部のアルゴリズムは、汎用自動ベクトル化器が推測するのが難しい特定のSIMDパターンを本質的に必要とします。

最大限の制御とパフォーマンスを得るには、特にクリティカルなセクションでは、手書きのSIMD組み込み関数が必要です。

手書きのSIMD組み込み関数 (core::arch::wasm32)

自動ベクトル化が不十分な場合、Rustはcore::arch::wasm32モジュールを介してWasm SIMD組み込み関数への直接アクセスを提供します。これにより、開発者はC++でSSE/AVX組み込み関数を使用するのと同様に、v128操作を明示的に使用できます。

core::arch::wasm32モジュールは、Wasm SIMD命令に直接マッピングされる関数を公開します。これらの関数は通常unsafeであり、低レベルで動作するため、メモリとデータ型の慎重な処理が必要です。

// src/lib.rs
use wasm_bindgen::prelude::*;
use core::arch::wasm32::*; // Import all Wasm SIMD intrinsics

#[wasm_bindgen]
pub fn add_arrays_simd(a: &[f32], b: &[f32], c: &mut [f32]) {
    assert_eq!(a.len(), b.len());
    assert_eq!(a.len(), c.len());
    assert!(a.len() % 4 == 0, "Input length must be a multiple of 4 for f32x4 SIMD");

    let len = a.len();
    let a_ptr = a.as_ptr() as *const f32;
    let b_ptr = b.as_ptr() as *const f32;
    let c_ptr = c.as_mut_ptr() as *mut f32;

    // Process 4 f32 elements at a time
    for i in (0..len).step_by(4) {
        unsafe {
            // Load 4 f32 values from array 'a' into a v128 register
            let va = v128_load(a_ptr.add(i) as *const v128);
            // Load 4 f32 values from array 'b' into a v128 register
            let vb = v128_load(b_ptr.add(i) as *const v128);

            // Perform element-wise addition on the two v128 registers
            let vc = f32x4_add(va, vb);

            // Store the resulting 4 f32 values back into array 'c'
            v128_store(c_ptr.add(i) as *mut v128, vc);
        }
    }
}

使用される主な組み込み関数:

  • v128_load(ptr as *const v128): メモリから16バイト(128ビット)をv128レジスタにロードします。ポインタは*const v128にキャストする必要があります。
  • v128_store(ptr as *mut v128, value): v128レジスタの値を16バイトのメモリにストアします。ポインタは*mut v128にキャストする必要があります。
  • f32x4_add(a, b): 2つのv128レジスタに対して要素ごとの加算を実行し、それらを4つのf32値として扱います。他のデータ型(例:i32x4_mul、i8x16_sub)にも同様の組み込み関数が存在します。

組み込み関数に関する重要な考慮事項:

  • unsafeブロック: すべてのSIMD組み込み関数はunsafeです。これは、それらが生ポインタで動作し、プログラマがメモリ安全性(例:有効なポインタ、正しいアライメント、境界チェック)を確保する必要があるためです。
  • データアライメント: v128_loadとv128_storeは非アラインアクセスを処理できますが、16バイトアラインされたメモリの方が一般的にパフォーマンスが向上します。RustのVec<T>は通常、その要素に対して十分なアライメントを提供しますが、カスタムデータ構造や生ポインタには明示的なアライメントが必要になる場合があります。wasm-bindgenの場合、js_sys::WebAssembly::MemoryとUint8Arrayビューは通常バイトレベルのアクセスを提供し、アライメントは慎重に管理する必要があります。
  • ループアンローリング/ベクトル化係数: 例のstep_by(4)は、一度に4つのf32要素を明示的に処理し、f32x4ベクター幅と一致させます。これは効率的なSIMD利用に不可欠です。
  • 残りの処理: 入力長がベクター幅で完全に割り切れない場合(例:len % 4 != 0)、残りの要素を処理するためにスカラ演算を使用する「テール」ループが必要です。上記の例では、簡略化のためにlen % 4 == 0をアサートしています。

実世界での応用:画像処理(畳み込み/ぼかしフィルター)

画像処理、特にぼかしのような畳み込みフィルターは、その高度な並列性のため、SIMD最適化の主要な候補です。各ピクセルの新しい値は、その隣接ピクセルに基づいて計算され、画像全体で繰り返される操作です。

3x3ガウスぼかしを実装します。簡略化のため、各要素がピクセル強度であるUint8ClampedArray(またはVec<u8>)として表されるグレースケール画像を想定します。

アルゴリズム:3x3ガウスぼかし

3x3ガウスぼかしのカーネル(近似)は次のとおりです。

[ 1  2  1 ]
[ 2  4  2 ] * (1/16)
[ 1  2  1 ]

各出力ピクセルP_out(x, y)は、入力画像P_in内の9つの隣接ピクセルの重み付き合計として計算されます。

P_out(x, y) = (1/16) * [ P_in(x-1, y-1)*1 + P_in(x, y-1)*2 + P_in(x+1, y-1)*1 + P_in(x-1, y)*2 + P_in(x, y)*4 + P_in(x+1, y)*2 + P_in(x-1, y+1)*1 + P_in(x, y+1)*2 + P_in(x+1, y+1)*1 ]

Rust実装

3つのRust実装を提供します:スカラ、自動ベクトル化、手書きSIMD。

// src/lib.rs
use wasm_bindgen::prelude::*;
use core::arch::wasm32::*;
use js_sys::Uint8ClampedArray;

// Helper to convert Uint8ClampedArray to Vec<u8> and vice-versa
fn to_vec_u8(arr: &Uint8ClampedArray) -> Vec<u8> {
    let mut vec = Vec::with_capacity(arr.length() as usize);
    arr.copy_to(&mut vec);
    vec
}

fn to_uint8_clamped_array(vec: Vec<u8>) -> Uint8ClampedArray {
    Uint8ClampedArray::from(&vec[..])
}

// --- Scalar Wasm Implementation ---
#[wasm_bindgen]
pub fn blur_scalar(input_pixels: &Uint8ClampedArray, width: u32, height: u32) -> Uint8ClampedArray {
    let input_vec = to_vec_u8(input_pixels);
    let mut output_vec = vec![0u8; input_vec.len()];

    let w = width as usize;
    let h = height as usize;

    for y in 1..h - 1 {
        for x in 1..w - 1 {
            let mut sum = 0;
            sum += input_vec[(y - 1) * w + (x - 1)] as u32 * 1;
            sum += input_vec[(y - 1) * w + x] as u32 * 2;
            sum += input_vec[(y - 1) * w + (x + 1)] as u32 * 1;
            sum += input_vec[y * w + (x - 1)] as u32 * 2;
            sum += input_vec[y * w + x] as u32 * 4;
            sum += input_vec[y * w + (x + 1)] as u32 * 2;
            sum += input_vec[(y + 1) * w + (x - 1)] as u32 * 1;
            sum += input_vec[(y + 1) * w + x] as u32 * 2;
            sum += input_vec[(y + 1) * w + (x + 1)] as u32 * 1;

            output_vec[y * w + x] = (sum / 16) as u8;
        }
    }
    to_uint8_clamped_array(output_vec)
}

// --- Auto-Vectorized Wasm Implementation ---
// This function is identical to blur_scalar, but with `target-feature=+simd128`
// the compiler might auto-vectorize parts of the inner loop.
#[wasm_bindgen]
pub fn blur_auto_vectorized(input_pixels: &Uint8ClampedArray, width: u32, height: u32) -> Uint8ClampedArray {
    let input_vec = to_vec_u8(input_pixels);
    let mut output_vec = vec![0u8; input_vec.len()];

    let w = width as usize;
    let h = height as usize;

    for y in 1..h - 1 {
        for x in 1..w - 1 {
            let mut sum = 0;
            sum += input_vec[(y - 1) * w + (x - 1)] as u32 * 1;
            sum += input_vec[(y - 1) * w + x] as u32 * 2;
            sum += input_vec[(y - 1) * w + (x + 1)] as u32 * 1;
            sum += input_vec[y * w + (x - 1)] as u32 * 2;
            sum += input_vec[y * w + x] as u32 * 4;
            sum += input_vec[y * w + (x + 1)] as u32 * 2;
            sum += input_vec[(y + 1) * w + (x - 1)] as u32 * 1;
            sum += input_vec[(y + 1) * w + (x - 1)] as u32 * 1; // Typo fix: (y+1)*w + (x-1)
            sum += input_vec[(y + 1) * w + x] as u32 * 2;
            sum += input_vec[(y + 1) * w + (x + 1)] as u32 * 1;

            output_vec[y * w + x] = (sum / 16) as u8;
        }
    }
    to_uint8_clamped_array(output_vec)
}

// --- Hand-Crafted SIMD Wasm Implementation ---
// This is a simplified SIMD blur for demonstration.
// A full SIMD convolution is complex due to boundary conditions and u8->u32 widening.
// We'll focus on processing 16 pixels (u8) at a time.
#[wasm_bindgen]
pub fn blur_simd(input_pixels: &Uint8ClampedArray, width: u32, height: u32) -> Uint8ClampedArray {
    let input_vec = to_vec_u8(input_pixels);
    let mut output_vec = vec![0u8; input_vec.len()];

    let w = width as usize;
    let h = height as usize;
    let input_ptr = input_vec.as_ptr();
    let output_ptr = output_vec.as_mut_ptr();

    // The kernel values as u8
    let k1 = i8x16_splat(1);
    let k2 = i8x16_splat(2);
    let k4 = i8x16_splat(4);
    let k_div16 = i8x16_splat(16); // For division, we'd typically use multiplication by reciprocal or shift

    // This SIMD implementation is highly simplified and does not fully implement
    // the 3x3 convolution for all pixels due to the complexity of boundary conditions
    // and widening `u8` to `u32` for sums with `i8x16` intrinsics.
    // A proper SIMD convolution would involve:
    // 1. Loading 3 rows of 16 bytes (or more)
    // 2. Shifting/shuffling to align neighbors
    // 3. Widening `u8` to `i16` or `i32` for accumulation to prevent overflow
    // 4. Performing multiplications and additions
    // 5. Narrowing back to `u8` and storing.
    // For a true 10x speedup, this would be a much larger code block.
    // This example focuses on demonstrating basic `v128` load/store and arithmetic.

    // Process rows, skipping borders
    for y in 1..h - 1 {
        // Process columns, skipping borders and ensuring 16-byte alignment for simplicity
        // In a real scenario, you'd handle unaligned loads or ensure alignment.
        // Also, process in chunks of 16 pixels (bytes)
        for x_start in (1..w - 1).step_by(16) {
            if x_start + 16 > w - 1 { // Handle remainder
                for x in x_start..w - 1 {
                    let mut sum = 0;
                    sum += unsafe { *input_ptr.add((y - 1) * w + (x - 1)) } as u32 * 1;
                    sum += unsafe { *input_ptr.add((y - 1) * w + x) } as u32 * 2;
                    sum += unsafe { *input_ptr.add((y - 1) * w + (x + 1)) } as u32 * 1;
                    sum += unsafe { *input_ptr.add(y * w + (x - 1)) } as u32 * 2;
                    sum += unsafe { *input_ptr.add(y * w + x) } as u32 * 4;
                    sum += unsafe { *input_ptr.add(y * w + (x + 1)) } as u32 * 2;
                    sum += unsafe { *input_ptr.add((y + 1) * w + (x - 1)) } as u32 * 1;
                    sum += unsafe { *input_ptr.add((y + 1) * w + x) } as u32 * 2;
                    sum += unsafe { *input_ptr.add((y + 1) * w + (x + 1)) } as u32 * 1;
                    unsafe { *output_ptr.add(y * w + x) = (sum / 16) as u8; }
                }
                continue;
            }

            unsafe {
                // Load 16 pixels from the current row
                let current_row_pixels = v128_load(input_ptr.add(y * w + x_start) as *const v128);

                // This is a placeholder for actual convolution logic.
                // A full SIMD convolution would involve loading multiple rows,
                // shuffling, widening, multiplying by kernel, summing, and narrowing.
                // For demonstration, we'll just do a simple operation.
                // Example: Multiply current pixels by 4 (center kernel value)
                let processed_pixels = i8x16_mul(current_row_pixels, k4);

                // Store the result. This is NOT a full blur, but demonstrates SIMD usage.
                v128_store(output_ptr.add(y * w + x_start) as *mut v128, processed_pixels);
            }
        }
    }
    to_uint8_clamped_array(output_vec)
}

画像処理のアーキテクチャ的説明:

  • データ転送: JavaScriptからのUint8ClampedArrayは、copy_toを使用してRustのVec<u8>に効率的に変換されます。これにより、直接的なメモリ共有の問題が回避され、安全なRustのVecが提供されます。結果は元に戻されます。
  • スカラ/自動ベクトル化: これらのバージョンはピクセルごとに反復処理を行い、重み付き合計を計算します。自動ベクトル化器は、データ並列パターンを識別できれば、内部ループを最適化する可能性があります。
  • 手書きSIMD(簡略化): 提供されているblur_simdは簡略化された例です。完全なSIMD畳み込みは、以下の理由によりはるかに複雑です。
    • 拡張: u8ピクセル値は、中間合計が255を超える可能性があるため、オーバーフローを防ぐために乗算と合計の前にi16またはi32に拡張する必要があります。これにはi16x8_widen_low_u8とi16x8_widen_high_u8組み込み関数が含まれます。
    • 隣接アクセス: (x-1, y-1)、(x, y-1)などへのアクセスには、複数のv128ベクター(例:3行)をロードし、シャッフル/スウィズル操作(i8x16_shuffle)を使用して、正しい隣接ピクセルを新しいv128レジスタにアラインして並列計算を行う必要があります。
    • 境界条件: 画像の端にあるピクセルは、完全な隣接ピクセルセットを持たないため、特別な処理が必要です。これには、条件付きロジックや画像のパディングが含まれることがよくあります。
    • カーネル適用: 各カーネル係数(1、2、4)は、v128レジスタ(i8x16_splat)にブロードキャストされ、対応するピクセルベクターと乗算する必要があります。
    • 累積: 複数のv128加算が実行され、最終的な合計はu8(i16x8_narrow_i8x16)に縮小されます。

完全なSIMD畳み込みの複雑さは、自動ベクトル化が機能する場合にそれが好まれる理由を浮き彫りにしますが、特定の複雑なアルゴリズムで最高のパフォーマンスを得るために手書きの組み込み関数が不可欠である理由も示しています。

Advertisement

実世界での応用:行列乗算

行列乗算C = A * Bは、SIMDに適したもう1つの計算負荷の高いタスクです。2つのN x N行列の場合、標準アルゴリズムにはN^3回の乗算と加算が含まれます。

アルゴリズム:標準i, j, kループ

C[i][j] = sum(A[i][k] * B[k][j])の場合

for i from 0 to N-1:
  for j from 0 to N-1:
    C[i][j] = 0
    for k from 0 to N-1:
      C[i][j] += A[i][k] * B[k][j]

この例ではf32行列を使用します。

Rust実装

// src/lib.rs
use wasm_bindgen::prelude::*;
use core::arch::wasm32::*;
use js_sys::Float32Array;

// Helper to convert Float32Array to Vec<f32> and vice-versa
fn to_vec_f32(arr: &Float32Array) -> Vec<f32> {
    let mut vec = Vec::with_capacity(arr.length() as usize);
    arr.copy_to(&mut vec);
    vec
}

fn to_float32_array(vec: Vec<f32>) -> Float32Array {
    Float32Array::from(&vec[..])
}

// --- Scalar Wasm Implementation ---
#[wasm_bindgen]
pub fn matrix_mul_scalar(a: &Float32Array, b: &Float32Array, n: u32) -> Float32Array {
    let n_usize = n as usize;
    let a_vec = to_vec_f32(a);
    let b_vec = to_
Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement