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 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.
Porting Intel SSE Intrinsics to ARM NEON in a Real C Project
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.