DEV Community

Cover image for Porting Intel SSE Intrinsics to ARM NEON in a Real C Project
Yuna
Yuna

Posted on

Porting Intel SSE Intrinsics to ARM NEON in a Real C Project

I recently wanted to see what actually happens when an existing C project containing Intel SSE intrinsics is ported to ARM64.

The usual example is straightforward:

__m128 r = _mm_add_ps(a, b);
Enter fullscreen mode Exit fullscreen mode

becomes something like:

float32x4_t r = vaddq_f32(a, b);
Enter fullscreen mode Exit fullscreen mode

But real porting work is rarely just a table of intrinsic names.

There are also:

  • compiler flags
  • floating-point semantics
  • reduction order
  • architecture-specific headers
  • build-system assumptions
  • fallback implementations

So I created a deliberately x86-specific C project and passed it through a tool I have been building called Miruri.

Miruri:

https://github.com/yuna-r/miruri

The interesting part was not only that the code compiled on ARM64, but also how the resulting implementation was structured.


The original project

The project is intentionally small:

intel-intrinsic-demo/
├── CMakeLists.txt
├── Makefile
├── include/
│   └── simd_math.h
└── src/
    ├── main.c
    └── simd_math.c
Enter fullscreen mode Exit fullscreen mode

The implementation directly includes the Intel SSE header:

#include <xmmintrin.h>
Enter fullscreen mode Exit fullscreen mode

There is no ARM compatibility code in the original source.

The project uses these SSE intrinsics:

_mm_loadu_ps
_mm_add_ps
_mm_mul_ps
_mm_storeu_ps
_mm_movehl_ps
_mm_shuffle_ps
_mm_add_ss
_mm_cvtss_f32
Enter fullscreen mode Exit fullscreen mode

SIMD addition

The original implementation looks like this:

void simd_add4(
    const float a[4],
    const float b[4],
    float out[4])
{
    const __m128 va = _mm_loadu_ps(a);
    const __m128 vb = _mm_loadu_ps(b);
    const __m128 vr = _mm_add_ps(va, vb);

    _mm_storeu_ps(out, vr);
}
Enter fullscreen mode Exit fullscreen mode

This is a classic 128-bit SSE operation.

Four 32-bit floating-point values are loaded, added in parallel, and stored.

The ARM64 implementation became:

const float32x4_t va = vld1q_f32(a);
const float32x4_t vb = vld1q_f32(b);

vst1q_f32(
    out,
    vaddq_f32(va, vb));
Enter fullscreen mode Exit fullscreen mode

The mapping is very direct:

SSE NEON
_mm_loadu_ps vld1q_f32
_mm_add_ps vaddq_f32
_mm_storeu_ps vst1q_f32

Both operate naturally on four 32-bit floats in a 128-bit vector.


The resulting code kept both architectures

What I liked about the generated port was that it did not simply delete the x86 implementation.

Instead, it became something like this:

#if defined(__SSE__)

    /* SSE implementation */

#elif defined(__aarch64__) || defined(_M_ARM64)

    /* NEON implementation */

#else

    /* portable scalar implementation */

#endif
Enter fullscreen mode Exit fullscreen mode

So an originally x86-only function effectively became a small portable SIMD abstraction.

The result now has three backends:

x86
 └─ SSE

ARM64
 └─ NEON

other architectures
 └─ scalar C
Enter fullscreen mode Exit fullscreen mode

For a small library, I prefer this structure over pretending the Intel intrinsic API itself is portable.

For very large codebases with thousands of _mm_* calls, a compatibility layer such as sse2neon may make more sense.


Multiply-add was not converted to FMA

I also added this operation:

void simd_mul_add4(
    const float a[4],
    const float b[4],
    const float c[4],
    float out[4])
{
    const __m128 va = _mm_loadu_ps(a);
    const __m128 vb = _mm_loadu_ps(b);
    const __m128 vc = _mm_loadu_ps(c);

    const __m128 product = _mm_mul_ps(va, vb);
    const __m128 result = _mm_add_ps(product, vc);

    _mm_storeu_ps(out, result);
}
Enter fullscreen mode Exit fullscreen mode

A tempting ARM implementation would use a fused multiply-add.

But the generated NEON code instead preserved the two operations:

const float32x4_t product =
    vmulq_f32(va, vb);

vst1q_f32(
    out,
    vaddq_f32(product, vc));
Enter fullscreen mode Exit fullscreen mode

I think this is important.

The original SSE implementation performs:

multiply
↓
round
↓
add
↓
round
Enter fullscreen mode Exit fullscreen mode

A fused multiply-add can perform:

multiply + add
↓
round once
Enter fullscreen mode Exit fullscreen mode

That can produce slightly different floating-point results.

For many programs that difference is irrelevant.

For numerical software, codecs, simulations, databases, or HPC code, it may not be.

Porting SIMD code is therefore not always equivalent to selecting the shortest native instruction sequence.


Horizontal reduction was more interesting

I intentionally implemented a four-element sum using SSE1 operations:

float simd_sum4(const float a[4])
{
    const __m128 v = _mm_loadu_ps(a);

    const __m128 hi = _mm_movehl_ps(v, v);
    const __m128 pair_sum = _mm_add_ps(v, hi);

    const __m128 shuffled =
        _mm_shuffle_ps(
            pair_sum,
            pair_sum,
            _MM_SHUFFLE(1, 1, 1, 1));

    const __m128 total =
        _mm_add_ss(pair_sum, shuffled);

    return _mm_cvtss_f32(total);
}
Enter fullscreen mode Exit fullscreen mode

The effective reduction order is approximately:

(a[0] + a[2]) + (a[1] + a[3])
Enter fullscreen mode Exit fullscreen mode

The NEON version became:

const float32x4_t v =
    vld1q_f32(a);

const float32x2_t pair_sum =
    vadd_f32(
        vget_low_f32(v),
        vget_high_f32(v));

return vget_lane_f32(
    vpadd_f32(pair_sum, pair_sum),
    0);
Enter fullscreen mode Exit fullscreen mode

The scalar fallback was also written as:

return (a[0] + a[2]) + (a[1] + a[3]);
Enter fullscreen mode Exit fullscreen mode

That was interesting because the transformation preserved the basic pairwise reduction structure instead of reducing the vector in an unrelated order.

Floating-point addition is not associative, so this can matter.


Architecture-specific build flags matter too

The original CMake project deliberately contained:

if(CMAKE_C_COMPILER_ID MATCHES "Clang|GNU")
    target_compile_options(simd_math PRIVATE -msse)
endif()
Enter fullscreen mode Exit fullscreen mode

That is perfectly reasonable on x86.

It is not reasonable on ARM64.

On Apple Silicon, Clang rejects it:

clang: error: unsupported option '-msse' for target 'arm64-apple-darwin'
Enter fullscreen mode Exit fullscreen mode

The port therefore also had to modify the build logic:

if(CMAKE_C_COMPILER_ID MATCHES "Clang|GNU" AND
   CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64|i[3-6]86)$")
    target_compile_options(simd_math PRIVATE -msse)
endif()
Enter fullscreen mode Exit fullscreen mode

This is a small example of something that becomes important in larger ports:

architecture dependencies are not limited to source files.

They also exist in:

  • CMake
  • Makefiles
  • configure scripts
  • compiler options
  • linker options
  • dependency selection

Testing the result

The demo contains a small self-test.

Input:

const float a[4] = {
    1.0f, 2.0f, 3.5f, -4.0f
};

const float b[4] = {
    10.0f, -2.0f, 0.5f, 8.0f
};

const float c[4] = {
    0.25f, 1.0f, -1.0f, 2.0f
};
Enter fullscreen mode Exit fullscreen mode

Expected result:

a + b
= [11.000, 0.000, 4.000, 4.000]

a * b + c
= [10.250, -3.000, 0.750, -30.000]

sum(a)
= 2.500
Enter fullscreen mode Exit fullscreen mode

On x86:

a + b = [11.000, 0.000, 4.000, 4.000]
a * b + c = [10.250, -3.000, 0.750, -30.000]
sum(a) = 2.500
SELFTEST: PASS
Enter fullscreen mode Exit fullscreen mode

The same source tree was then built for:

Linux ARM64
→ ELF AArch64

macOS ARM64
→ Mach-O arm64
Enter fullscreen mode Exit fullscreen mode

The Linux ARM64 sysroot was provisioned automatically.


What I learned

For simple operations, SSE-to-NEON conversion can look almost trivial:

_mm_loadu_ps
→ vld1q_f32

_mm_add_ps
→ vaddq_f32

_mm_mul_ps
→ vmulq_f32

_mm_storeu_ps
→ vst1q_f32
Enter fullscreen mode Exit fullscreen mode

But that is only the visible part of the problem.

A serious port also has to consider:

instruction semantics
floating-point behavior
operation ordering
compiler flags
build configuration
fallback paths
ABI
target libraries
artifact architecture
Enter fullscreen mode Exit fullscreen mode

That is where automated source transformation becomes more interesting than a simple intrinsic translation table.

This experiment only uses basic SSE.

Next I want to try more complicated examples involving:

SSE2
SSE4
AVX
AVX2
AVX-512
Enter fullscreen mode Exit fullscreen mode

and see how far the same approach can be pushed.

Top comments (0)