Validating MLX Beamforming Against MACH/CUDA

I read a cool blog post about a company building BCIs (https://alephneuro.com/blog/ultrasound-brain) so naturally I decided to check out their github and I came across a repo called "Microbubbles" The microbubbles beamforming path was CUDA-only in practice. The dependency that mattered was MACH, a CUDA/CuPy beamforming kernel. The data format, preprocessing, grid construction, and downstream ULM pipeline were not inherently NVIDIA-specific. The missing piece on a MacBook was the actual delay-and-sum backend.

I added an MLX backend for Apple Silicon and kept the rest of the path fixed. The goal was not to make a new beamformer with different assumptions. The goal was a backend swap: same neutral ultratrace input, same grid math, same transmit-delay handling, and the same output H5 layout:

acquisitions/<id>/meta/compound_image
acquisitions/<id>/meta/grid/{x,y,z}

Backend Port

The MLX backend implements the same delay-and-sum pieces as the MACH path:

  • neutral IQ preprocessing
  • transmit delay normalization
  • imaging grid construction
  • receive aperture selection from f-number
  • linear interpolation over the sample axis
  • Tukey receive apodization
  • modulation phase correction
  • transmit-angle compounding
  • final layout as (frames, elev, z, x)

The CLI now exposes both backends:

ultratrace-ulm beamform --backend mlx ...
ultratrace-ulm beamform --backend mach ...

The backend dispatch stays small:

def beamform_iq(
    iq_frames: np.ndarray,
    tx_delays: np.ndarray,
    tx_delays_elev: np.ndarray,
    config: NeutralConfig,
    *,
    backend: str = "mach",
    scan_chunk: int = 2048,
    receive_chunk: int = 128,
    frame_chunk: int = 16,
) -> tuple[np.ndarray, Grid]:
    resolved = resolve_beamform_backend(backend)
    if resolved == "mach":
        return beamform_iq_mach(iq_frames, tx_delays, tx_delays_elev, config)
    return beamform_iq_mlx(
        iq_frames,
        tx_delays,
        tx_delays_elev,
        config,
        scan_chunk=scan_chunk,
        receive_chunk=receive_chunk,
        frame_chunk=frame_chunk,
    )

The main risk was not wiring. The risk was a quiet numerical mismatch: one backend looking plausible while drifting from the CUDA reference. So I treated the port as a reference-comparison problem.

The MLX loop is still the same delay calculation, interpolation, apodization, phase correction, and receive sum:

delta = rx[None, :, :] - scan[:, None, :]
horizontal_sq = delta[:, :, 0] * delta[:, :, 0] + delta[:, :, 1] * delta[:, :, 1]
inside = horizontal_sq <= aperture_radius_squared[:, None]
rx_distance = mx.sqrt(horizontal_sq + delta[:, :, 2] * delta[:, :, 2])
physical_tau = scan_tx[:, None] + rx_distance * inv_c
sample_idx = (physical_tau - float(rx_start_s)) * float(sampling_freq_hz)
valid = inside & (sample_idx >= 0.0) & (sample_idx <= float(n_samples - 1))

samples = _linear_interpolate_mlx(ch, sample_idx, valid, mx)
if tukey_alpha > 0.0:
    horizontal = mx.sqrt(mx.maximum(horizontal_sq, 0.0))
    r_norm = horizontal / aperture_radius[:, None]
    samples = samples * _tukey_apodization_mlx(r_norm, tukey_alpha, mx)[:, :, None]

if modulation_freq_hz:
    phase = mx.exp((1j * modulation_freq_rad) * physical_tau)
    samples = samples * phase[:, :, None]

accum = accum + mx.sum(samples, axis=1)
mx.eval(accum)

Synthetic Reference

I started with a deterministic synthetic case. I ran the same small input through MACH on Modal, returned the result as an .npz, and compared it locally against MLX.

shape: (2, 1, 3, 2)
relative_l2: 1.6909404e-05
magnitude_corr: 1

This covered the basic mechanics: interpolation, apodization, phase correction, and compounding. It was useful, but still too clean. A synthetic test can pass while the real H5 path is wrong.

Real Acquisition

The public sample ultratrace H5 is about 98 GB. I did not want to move the full file just to check one backend. I added an HTTP range reader and extracted only acquisition 0 into a one-acquisition neutral H5.

The extracted input was:

file: sample_acq0.h5
iq_frames: (702, 5, 1, 134, 256) complex64, ~918.6 MB
tx_delays: (5, 134) float32
tx_delays_elev: (5, 8) float32

Both backends used the same deliberately coarse validation grid:

--acq-start 0
--num-acqs 1
--elev-planes 1
--z-coarseness 2.0
--x-coarseness 2.0
--no-large-fov

MLX ran locally on Apple Silicon. MACH ran on a Modal A10G instance. The output files were:

MLX:  mlx_acq0_real.h5
MACH: mach_acq0_real.h5

Comparison

Both outputs had the same compound image shape:

compound_image shape: (700, 1, 56, 47)

The saved grid arrays matched exactly:

grid x max_abs: 0.0
grid y max_abs: 0.0
grid z max_abs: 0.0

The compound image comparison was:

max_abs: 8.93097
mean_abs: 1.29289
relative_l2: 4.5689e-05
magnitude_corr: 1

That is the result I cared about. The absolute differences are nonzero, as expected across different GPU backends and floating-point execution paths, but the relative error is small and the magnitude correlation is 1 at the printed precision.

Local tests also pass:

.venv/bin/python -m pytest -q
2 passed

Closing Notes

This is not a full production-grid validation. The real-acquisition comparison used:

elev_planes = 1
z_coarseness = 2.0
x_coarseness = 2.0
large_fov = false

The useful practical outcome is that beamforming now has a MacBook path. MACH can remain the CUDA reference, and MLX can be used for local Apple Silicon work without changing the neutral ultratrace schema or downstream pipeline.