Did algebraic_* in Rust 1.98 make audio DSP faster? I tried to verify it.

Did algebraic_* in Rust 1.98 make audio DSP faster? I tried to verify it.

The performance of gain and mixing was almost the same as normal arithmetic. Dot product was 2.75 to 6.94 times faster, and 64-tap FIR was approximately 3.5 times faster. On the other hand, in exchange for the speedup, differences from normal arithmetic occurred.
2026.09.10

This page has been translated by machine translation. View original

Introduction

In audio DSP, floating-point operations are repeated many times per sample within a short time window. It can be difficult to know how well normal Rust code is utilizing the CPU's SIMD capabilities, and you may find yourself wondering whether to reach for CPU-specific intrinsics.

Rust 1.98 added algebraic_* methods to f32 and f64. This API relaxes constraints related to floating-point operation ordering, allowing the compiler to optimize more aggressively. While it has the potential to speed up multiply-accumulate operations in standard Rust, the results are not guaranteed to match those of normal operations.

With that in mind, I compared behavior across gain, mixing, division, dot product, and a 64-tap FIR. The results showed that gain and mixing were nearly identical to normal operations, while dot product reached 2.751–6.936× and FIR reached 3.466–3.513×. However, the speedup came at the cost of differences from normal operation results.

What are algebraic_*?

The five methods that became stable in Rust 1.98 are:

  • algebraic_add
  • algebraic_sub
  • algebraic_mul
  • algebraic_div
  • algebraic_rem

In normal floating-point addition, a + b + c + d is evaluated in the order ((a + b) + c) + d. Since rounding occurs at each operation, changing the order of addition can change the result.

When chaining algebraic_add, the compiler can compute partial sums simultaneously, such as (a + b) + (c + d). The official documentation permits optimizations such as reassociating and reordering operations, converting division to reciprocal multiplication, and not distinguishing negative zero. The exact set of optimizations is unspecified, and results may vary even for the same input. (However, it does not constitute undefined behavior.)

Test Environment

  • MacBook Pro, Apple M4, 10-core, 16 GB memory
  • macOS 26.6.2, arm64
  • rustc 1.98.1, LLVM 22.1.8
  • Rust 2024 Edition, opt-level = 3
  • codegen-units = 1, target-cpu=apple-m4
  • No external crates

Target Audience

  • Those implementing numerical computation or audio DSP in Rust
  • Those wondering whether to write CPU-specific intrinsics for SIMD
  • Those who want to understand the performance of algebraic_* and how its results differ from normal operations

References

Methodology

I prepared five types of processing with different characteristics.

  • gain: multiply each sample by the same coefficient
  • mixing: add corresponding samples from two signals
  • division: divide each sample by the same value
  • dot product: multiply elements of two arrays and sum them into a single value
  • 64-tap FIR: perform 64 multiply-accumulate operations for each output sample

The normal and algebraic_* versions were structured with identical loop shapes, differing only in the operations themselves. For division, I also added an implementation that computes the reciprocal of the divisor once and then performs normal multiplication.

I targeted 64, 128, 256, and 512 frames, determined the number of iterations needed for a single trial to exceed 50 ms, then measured each condition 15 times while varying execution order. The reported values are medians per function call.

For numerical differences, I used finite values in [-1, 1] generated from a fixed seed, and compared the results of computing the same multiply-accumulate with normal operations, algebraic_*, and f64. For dot product, I ran 10,000 cases per size; for FIR, 1,000 blocks per size.

Implementation

The essential part of the dot product, where differences are most apparent, is as follows:

fn dot_normal(left: &[f32], right: &[f32]) -> f32 {
    left.iter()
        .zip(right)
        .fold(0.0, |sum, (&left_sample, &right_sample)| {
            sum + left_sample * right_sample
        })
}

fn dot_algebraic(left: &[f32], right: &[f32]) -> f32 {
    left.iter()
        .zip(right)
        .fold(0.0, |sum, (&left_sample, &right_sample)| {
            sum.algebraic_add(left_sample.algebraic_mul(right_sample))
        })
}

The FIR also zips input and coefficients and uses a fold of the same shape to sum 64 elements. The input range is sliced before the tap loop to avoid leaving bounds checks inside it.

Results

The speed ratio is normal / algebraic_*. Values greater than 1 indicate that algebraic_* is faster.

Processing 64 frames 128 frames 256 frames 512 frames
gain 1.007× 1.003× 0.997× 0.992×
mixing 1.002× 0.999× 1.003× 0.980×
division 0.981× 1.012× 1.047× 1.361×
dot product 2.751× 3.945× 5.408× 6.936×
64-tap FIR 3.466× 3.486× 3.507× 3.513×

For gain and mixing, the ratio ranged from 0.980× to 1.007×, showing almost no performance difference. Since there are no dependencies between elements in either case, both the normal and algebraic_* versions produced the same kind of NEON instructions. These are operations that can be vectorized with normal operations as well.

The speed ratio for dot product grew with element count; at 512 elements, the normal version took 174.995 ns compared to 25.231 ns for the algebraic_* version. The 64-tap FIR was approximately 3.5× regardless of frame count; at 512 frames it went from 5.024 µs to 1.430 µs.

For division, there was little difference at 64 elements, but the ratio reached 1.361× at 512 elements. Examining the assembly, the normal version executed vector division inside the loop, while the algebraic_div version computed a reciprocal and then performed vector multiplication. The manual reciprocal-multiplication version showed a ratio of 1.398× at 512 elements, exhibiting the same trend.

The assembly for dot product showed the following differences. The normal version multiplies 4 elements together and then extracts each element to scalar registers to add them sequentially.

fmul.4s  v1, v1, v5
mov      s18, v1[1]
fadd     s0, s0, s1
fadd     s0, s0, s18

The algebraic_* version updated 4 vector partial sums in parallel using fmla.4s, then performed a horizontal addition at the end.

fmla.4s  v0, v16, v4
fmla.4s  v1, v17, v5
fmla.4s  v2, v18, v6
fmla.4s  v3, v19, v7
fadd.4s  v0, v1, v0
fadd.4s  v1, v3, v2
fadd.4s  v0, v1, v0
faddp.4s v0, v0, v0
faddp.2s s0, v0

In the LLVM IR, the algebraic_* side received the flags reassoc nsz arcp contract. The flags nnan and ninf were not present, so this is not an implementation that enables all fast-math flags.

The performance gain came at the cost of numerical differences from normal operations. The dot product results are as follows:

Elements Max absolute difference from normal Max absolute error of normal Max absolute error of algebraic_*
64 2.861023e-6 2.636478e-6 1.015914e-6
128 4.768372e-6 4.905520e-6 1.293832e-6
256 9.536743e-6 1.030360e-5 2.252584e-6
512 2.098083e-5 2.178359e-5 3.726489e-6

"Max absolute error" is the difference from a reference value computed by widening the input to f64. For the inputs used here, the algebraic_* version was closer to the reference value.

The maximum absolute difference from normal operations for the 64-tap FIR was 5.215406e-8 to 6.705523e-8 across all sizes. For 10,000 division cases, the difference between normal division and algebraic_div was at most 1 ULP, or 5.960464e-8 in absolute value.

Differences due to operation ordering and zero sign could also be observed.

Input and processing Normal operations algebraic_*
Add [1e10, -1e10, 1, 0] repeated 4 times 1.0 4.0
Add [MAX, MAX, -MAX, -MAX] Infinity NaN
Add 0.0 to -0.0 0.0 -0.0

Even with only finite values, reassociation caused a split between 1.0 and 4.0. For an addition where intermediate results overflow, normal operations and algebraic_* diverged into Infinity and NaN. Negative zero is numerically equal to positive zero, but differs at the bit level.

Discussion

For operations that process each element independently, such as gain and mixing, I think it is sufficient to check whether the normal loop is auto-vectorized rather than immediately switching to algebraic_*. If the same kind of instructions are generated as in this case, there is no need to accept differences in computation results just to make the switch.

On the other hand, in dot product and FIR, the result of the previous addition is used in the next, so normal operations proceed through additions sequentially. If reordering is acceptable, computing multiple partial sums in parallel seems worth trying. However, bounds checks or loop structure may hinder vectorization. It is necessary to inspect the instructions the compiler outputs and measure processing time for representative block sizes.

For division by a shared value, converting to reciprocal multiplication reduced processing time for larger element counts. If the code can express that the divisor does not change within the loop, manually computing the reciprocal once is also a viable alternative to compare. In either case, it will likely be necessary to decide whether it is acceptable for per-element division and multiplication by a pre-rounded reciprocal to return different results.

For real-time audio processing, it would be best to identify multiply-accumulate operations where differences in operation ordering and rounding results are acceptable, then verify the callback processing time and output signal on the target CPU before adopting the approach.

Summary

Using algebraic_* from Rust 1.98, I compared five types of processing intended for audio DSP. Gain and mixing, which could also be vectorized with normal operations, showed nearly identical performance. In contrast, dot product reached 2.751–6.936× and 64-tap FIR reached 3.466–3.513×.

algebraic_* is not an API that speeds up all floating-point processing. It is best to focus on multiply-accumulate operations where differences in operation ordering and rounding results are acceptable, and to verify results with representative inputs, the instructions the compiler outputs, processing time, and error together.

Share this article

Related articles