open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-backend-apple/examples/bf16_gemm_bench.rs
Transaction Science 78b4a9aa8c backend-apple wave 83: M5 Neural-Accelerator matmul — reachable + fast, correctness WIP
Chasing beyond-2x on M5. Three example artifacts:
- bf16_gemm_bench: bit-correct bf16 simdgroup_matrix GEMM, but
  ties M3 (~4.8 TFLOP/s both) — simdgroup is NOT the M5 Neural
  Accelerator path.
- mpp_gemm_bench: the accelerator IS mpp::tensor_ops::matmul2d
  (Metal-4 Metal-Performance-Primitives). Reachable from our
  pure-Rust + runtime-MSL stack, runs at 7.4-12 TFLOP/s
  (1.5-2.5x simdgroup) — beyond-2x headroom proven at the
  kernel level. NOT yet bit-correct: operand-layout convention
  differs from the naive reading.
- mpp_diag: one-hot layout diagnostic showing the output
  doesn't match the assumed A[K,M].B[N,K]->C[N,M] contraction.

Honest status: lever reachable + fast; correctness is the
remaining work (next: static_slice inside-tile fast path).
Not integrated into the substrate.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-13 02:36:32 -04:00

169 lines
6.4 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! bf16-native tiled GEMM via `simdgroup_matrix<bfloat,8,8>`
//! cooperative matmul — the Metal-4 / M5-Neural-Accelerator
//! path. Weights stay bf16 (no f16 conversion); activations
//! widen f32→bf16 once per call; accumulation is f32.
//!
//! Computes C[m,n] = A[m,k] · B[n,k]ᵀ (B is the weight in its
//! natural transposed `[n,k]` layout, same as gemm_bt). It
//! verifies against a CPU bf16-input reference and times the
//! kernel against the classic f16 `MPSMatrix` resident path.
//!
//! Run on an M5 (Metal 4) for the numbers that matter; it
//! also runs on M3 (simdgroup bf16 is Metal 3+) as a control.
#[cfg(target_os = "macos")]
fn main() {
use std::time::Instant;
use objc2::rc::Retained;
use objc2::runtime::ProtocolObject;
use objc2_foundation::NSString;
use objc2_metal::{
MTLBuffer, MTLCommandBuffer, MTLCommandEncoder, MTLCommandQueue,
MTLComputeCommandEncoder, MTLComputePipelineState, MTLCreateSystemDefaultDevice,
MTLDevice, MTLLibrary, MTLResourceOptions, MTLSize,
};
// f32 → bf16 bits, round-to-nearest-even.
fn f32_to_bf16(v: f32) -> u16 {
let b = v.to_bits();
let round = ((b >> 16) & 1) + 0x7fff;
((b + round) >> 16) as u16
}
fn bf16_to_f32(h: u16) -> f32 {
f32::from_bits((h as u32) << 16)
}
const KERNEL: &str = r#"
#include <metal_stdlib>
using namespace metal;
// One simdgroup (32 threads) per 8×8 output tile.
kernel void gemm_bf16_bt(
device const bfloat* A [[buffer(0)]], // [M, K] bf16 (row-major)
device const bfloat* B [[buffer(1)]], // [N, K] bf16 (weights, bt)
device float* C [[buffer(2)]], // [M, N] f32
constant uint& M [[buffer(3)]],
constant uint& N [[buffer(4)]],
constant uint& K [[buffer(5)]],
uint2 tg [[threadgroup_position_in_grid]])
{
uint mi = tg.y * 8;
uint nj = tg.x * 8;
if (mi >= M || nj >= N) return;
simdgroup_matrix<float, 8, 8> acc = make_filled_simdgroup_matrix<float, 8, 8>(0.0f);
for (uint kt = 0; kt < K; kt += 8) {
simdgroup_matrix<bfloat, 8, 8> a, b;
simdgroup_load(a, A + mi * K + kt, K);
// transpose=true: b[l,j] = B[nj+j, kt+l] → C = A·Bᵀ
simdgroup_load(b, B + nj * K + kt, K, 0, true);
simdgroup_multiply_accumulate(acc, a, b, acc);
}
simdgroup_store(acc, C + mi * N + nj, N);
}
"#;
let device = MTLCreateSystemDefaultDevice().expect("no Metal device");
println!("device: {}", device.name());
let queue = device.newCommandQueue().expect("no queue");
let src = NSString::from_str(KERNEL);
let lib = device
.newLibraryWithSource_options_error(&src, None)
.expect("kernel compile failed");
let func = lib
.newFunctionWithName(&NSString::from_str("gemm_bf16_bt"))
.expect("no fn");
let pipe: Retained<ProtocolObject<dyn MTLComputePipelineState>> = device
.newComputePipelineStateWithFunction_error(&func)
.expect("no pipeline");
// Prefill-ish shapes (multiples of 8). m = batch tokens.
let shapes = [(64usize, 512usize, 512usize), (256, 2048, 2048), (512, 4096, 4096)];
for &(m, n, k) in &shapes {
// Synthetic A[m,k], B[n,k].
let a_f: Vec<f32> =
(0..m * k).map(|i| (((i % 17) as f32) - 8.0) * 0.05).collect();
let b_f: Vec<f32> =
(0..n * k).map(|i| (((i % 13) as f32) - 6.0) * 0.04).collect();
let a_bf: Vec<u16> = a_f.iter().map(|&v| f32_to_bf16(v)).collect();
let b_bf: Vec<u16> = b_f.iter().map(|&v| f32_to_bf16(v)).collect();
let mk_buf = |data: &[u16]| unsafe {
device
.newBufferWithBytes_length_options(
std::ptr::NonNull::new(data.as_ptr() as *mut _).unwrap(),
std::mem::size_of_val(data),
MTLResourceOptions::StorageModeShared,
)
.unwrap()
};
let buf_a = mk_buf(&a_bf);
let buf_b = mk_buf(&b_bf);
let buf_c = device
.newBufferWithLength_options(m * n * 4, MTLResourceOptions::StorageModeShared)
.unwrap();
let dispatch = || unsafe {
let cb = queue.commandBuffer().unwrap();
let enc = cb.computeCommandEncoder().unwrap();
enc.setComputePipelineState(&pipe);
enc.setBuffer_offset_atIndex(Some(&buf_a), 0, 0);
enc.setBuffer_offset_atIndex(Some(&buf_b), 0, 1);
enc.setBuffer_offset_atIndex(Some(&buf_c), 0, 2);
for (slot, &v) in [m as u32, n as u32, k as u32].iter().enumerate() {
enc.setBytes_length_atIndex(
std::ptr::NonNull::new(&v as *const u32 as *mut _).unwrap(),
4,
3 + slot,
);
}
enc.dispatchThreadgroups_threadsPerThreadgroup(
MTLSize { width: n / 8, height: m / 8, depth: 1 },
// one simdgroup = 32 threads
MTLSize { width: 32, height: 1, depth: 1 },
);
enc.endEncoding();
cb.commit();
cb.waitUntilCompleted();
};
// Correctness: CPU reference over bf16-rounded inputs.
dispatch();
let c_ptr = MTLBuffer::contents(&*buf_c).as_ptr() as *const f32;
let c_gpu: Vec<f32> = (0..m * n).map(|i| unsafe { *c_ptr.add(i) }).collect();
let mut max_rel = 0f32;
for i in 0..m.min(8) {
for j in 0..n.min(8) {
let mut acc = 0f32;
for l in 0..k {
acc += bf16_to_f32(a_bf[i * k + l]) * bf16_to_f32(b_bf[j * k + l]);
}
let g = c_gpu[i * n + j];
let rel = (g - acc).abs() / (acc.abs() + 1e-3);
max_rel = max_rel.max(rel);
}
}
// Time it.
let iters = 50;
for _ in 0..5 {
dispatch();
}
let t0 = Instant::now();
for _ in 0..iters {
dispatch();
}
let bf16_ms = t0.elapsed().as_secs_f64() * 1e3 / iters as f64;
let gflop = 2.0 * m as f64 * n as f64 * k as f64 / 1e9;
println!(
" m={m:<4} n={n:<5} k={k:<5} bf16-simdgroup {bf16_ms:6.3} ms \
{:7.1} GFLOP/s max_rel={max_rel:.2e}",
gflop / (bf16_ms / 1e3),
);
}
}
#[cfg(not(target_os = "macos"))]
fn main() {
println!("macOS only");
}