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

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); 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 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)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.