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);
becomes something like:
float32x4_t r = vaddq_f32(a, b);
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
The implementation directly includes the Intel SSE header:
#include <xmmintrin.h>
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
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);
}
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));
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
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
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);
}
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));
I think this is important.
The original SSE implementation performs:
multiply
↓
round
↓
add
↓
round
A fused multiply-add can perform:
multiply + add
↓
round once
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);
}
The effective reduction order is approximately:
(a[0] + a[2]) + (a[1] + a[3])
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);
The scalar fallback was also written as:
return (a[0] + a[2]) + (a[1] + a[3]);
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()
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'
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()
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
};
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
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
The same source tree was then built for:
Linux ARM64
→ ELF AArch64
macOS ARM64
→ Mach-O arm64
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
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
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
and see how far the same approach can be pushed.
Top comments (0)