현대 CPU 연산 방식은 데이터를 처리하는 단위에 따라 크게 스칼라(Scalar) 연산과 벡터(Vector) 연산으로 나눌 수 있습니다. 코어 모듈의 성능을 끌어올리기 위해서는 이 차이를 이해하고 적절한 명령어 셋을 활용하는 것이 필수적입니다.

스칼라(Scalar)와 SIMD, 그리고 NEON의 차이

  • 스칼라(Scalar) 연산: 스칼라는 수학에서 방향성 없이 크기만 가지는 단일 값을 뜻합니다. 컴퓨팅 맥락에서 스칼라 연산은 한 번의 CPU 명령어(Instruction)로 단 한 개의 데이터 연산만 처리하는 아주 기본적이고 전통적인 방식을 의미합니다. 예를 들어 덧셈 명령을 내리면 단일 변수 두 개만 더합니다. 다량의 데이터 배열을 처리할 때는 원소 개수만큼 반복문을 수행해야 하므로 병목이 발생하기 쉽습니다.
  • SIMD (Single Instruction Multiple Data): SIMD는 단 하나의 명령어(Single Instruction)로 여러 개의 데이터(Multiple Data)를 동시에 병렬로 연산하는 기법을 뜻하는 범용적인 개념입니다. 스칼라가 한 번에 단일 값을 처리할 때, SIMD는 큰 크기의 전용 레지스터(예: 128-bit) 내부에 여러 개의 데이터(예: 32-bit float 4개)를 패킹한 뒤, 덧셈 지시 한 번으로 4쌍의 요소를 한 번에 연산해 냅니다.
  • NEON 연산: NEON(Advanced SIMD)은 ARM 아키텍처(Apple M1/M2, 안드로이드 AP 등) 내부에 탑재된 전용 SIMD 엔진 및 명령어 규격의 이름입니다. “SIMD”가 병렬 처리 모델을 뜻하는 넓은 의미의 기술 분류라면, “NEON”은 ARM에서 SIMD를 구체적으로 하드웨어 상에 구현해 둔 상표이자 규격명입니다. 만약 x64/x86 진영(Intel, AMD)의 CPU라면 NEON이 아닌 SSEAVX라는 이름의 SIMD 명령어 셋을 사용하게 됩니다.

서버 시장은 보통 x64 환경이 지배적이었지만, 최근에는 모바일뿐만 아니라 Mac(Apple Silicon)이나 AWS Graviton 같은 ARM 기반 프로세서의 점유율이 높아지고 있습니다. 따라서 연산 집약적인 코어 모듈을 C/C++로 작성하실 때는, 동일한 동작을 C 코드로 작성하되 Intel/AMD에서는 SSE/AVX가 실행되고 ARM에서는 NEON이 실행되도록 코드를 분기 처리해 주는 것이 매우 중요해졌습니다.

이번 글에서는 C언어를 이용해 1억 개(100 Million)의 float 배열 두 개의 내적(Dot Product)을 구하는 연산을 수행하면서, 하나의 코드베이스로 x86의 SSE/AVX와 ARM의 NEON을 동시에 지원하도록 작성하는 방법, 그리고 실제 Mac M1 (ARM NEON) 환경에서 벤치마크를 돌렸을 때 어느 정도의 속도 차이가 나는지 비교해보겠습니다.

스칼라 vs 파이프라인 (SIMD, NEON) 코드 작성법

C/C++에서 아키텍처에 따라 SIMD 명령어를 다르게 적용하려면 전처리기 매크로(#if defined(...))를 사용하여 분기하고, 각 아키텍처에 맞는 헤더 파일과 데이터 타입, Intrinsic 함수들을 사용해야 합니다.

아래는 1억 개의 요소를 갖는 내적 연산 예제 코드입니다.

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

// 아키텍처에 따른 헤더 파일 및 매크로 분기
#if defined(__x86_64__) || defined(_M_X64)
#include <immintrin.h> // For SSE/AVX
#define SIMD_NAME "SSE/AVX"
#elif defined(__aarch64__) || defined(_M_ARM64)
#include <arm_neon.h>  // For NEON
#define SIMD_NAME "NEON"
#else
#define SIMD_NAME "Unknown"
#endif

#define ARRAY_SIZE 100000000
#define NUM_RUNS 10

float a[ARRAY_SIZE];
float b[ARRAY_SIZE];

// 1. 일반적인 스칼라 (Scalar) 연산
float dot_product_scalar(const float* x, const float* y, size_t size) {
    float sum = 0.0f;
    // 컴파일러의 자동 벡터화(Auto-vectorization)를 방지하여 순수 스칼라 성능을 측정
    #pragma clang loop vectorize(disable)
    #pragma GCC ivdep
    for (size_t i = 0; i < size; ++i) {
        sum += x[i] * y[i];
    }
    return sum;
}

// 2. SIMD (SSE / NEON) 연산
float dot_product_simd(const float* x, const float* y, size_t size) {
    float sum = 0.0f;
    size_t i = 0;

#if defined(__x86_64__) || defined(_M_X64)
    // [x86/x64 SSE 버전] (한 번에 4개의 float 처리)
    __m128 v_sum = _mm_setzero_ps(); // 4개의 float를 0으로 초기화
    for (; i + 3 < size; i += 4) {
        __m128 v_x = _mm_loadu_ps(&x[i]);
        __m128 v_y = _mm_loadu_ps(&y[i]);
        v_sum = _mm_add_ps(v_sum, _mm_mul_ps(v_x, v_y));
    }
    // 4개의 부분합을 하나로 합침
    float temp[4];
    _mm_storeu_ps(temp, v_sum);
    sum += temp[0] + temp[1] + temp[2] + temp[3];

#elif defined(__aarch64__) || defined(_M_ARM64)
    // [ARM NEON 버전] (한 번에 4개의 float 처리)
    float32x4_t v_sum = vdupq_n_f32(0.0f); // 4개의 float를 0으로 초기화
    for (; i + 3 < size; i += 4) {
        float32x4_t v_x = vld1q_f32(&x[i]);
        float32x4_t v_y = vld1q_f32(&y[i]);
        // vmlaq_f32: Multiply-Accumulate (v_sum += v_x * v_y)
        v_sum = vmlaq_f32(v_sum, v_x, v_y); 
    }
    // 부분합을 모두 더해 스칼라로 반환
    sum += vaddvq_f32(v_sum);
#endif

    // 4의 배수로 떨어지지 않고 남은 요소(Tail 처리)를 스칼라로 계산
    for (; i < size; ++i) {
        sum += x[i] * y[i];
    }
    
    return sum;
}

int main() {
    printf("Initializing arrays...\n");
    for (size_t i = 0; i < ARRAY_SIZE; ++i) {
        a[i] = (float)(i % 100) / 100.0f;
        b[i] = (float)(i % 100) / 100.0f;
    }

    struct timespec start, end;
    double time_scalar = 0.0, time_simd = 0.0;
    float result_scalar = 0.0f, result_simd = 0.0f;

    // 스칼라 벤치마크 (10회 평균)
    for (int run = 0; run < NUM_RUNS; ++run) {
        clock_gettime(CLOCK_MONOTONIC, &start);
        result_scalar = dot_product_scalar(a, b, ARRAY_SIZE);
        clock_gettime(CLOCK_MONOTONIC, &end);
        time_scalar += (end.tv_sec - start.tv_sec) + 1e-9 * (end.tv_nsec - start.tv_nsec);
    }
    time_scalar /= NUM_RUNS;

    // SIMD 벤치마크 (10회 평균)
    for (int run = 0; run < NUM_RUNS; ++run) {
        clock_gettime(CLOCK_MONOTONIC, &start);
        result_simd = dot_product_simd(a, b, ARRAY_SIZE);
        clock_gettime(CLOCK_MONOTONIC, &end);
        time_simd += (end.tv_sec - start.tv_sec) + 1e-9 * (end.tv_nsec - start.tv_nsec);
    }
    time_simd /= NUM_RUNS;

    char simd_res_label[32];
    sprintf(simd_res_label, "%s Result", SIMD_NAME);
    char simd_time_label[32];
    sprintf(simd_time_label, "%s Time", SIMD_NAME);

    printf("------------------------------------\n");
    printf("Architecture : %s\n", SIMD_NAME);
    printf("Scalar Result: %.2f\n", result_scalar);
    printf("%-13s: %.2f\n", simd_res_label, result_simd);
    printf("Scalar Time  : %.5f sec\n", time_scalar);
    printf("%-13s: %.5f sec\n", simd_time_label, time_simd);
    printf("Speedup      : %.2f x\n", time_scalar / time_simd);
    printf("------------------------------------\n");

    return 0;
}

Mac M1 (NEON) 벤치마크 결과 비교

위 코드를 Apple Silicon(M1) 칩을 탑재한 맥에서 clang -O3 최적화 플래그를 주어 컴파일 후 실행한 결과입니다.

Initializing arrays...
Benchmarking scalar...
Benchmarking NEON...
------------------------------------
Architecture : NEON
Scalar Result: 16777216.00
NEON Result  : 31929088.00
Scalar Time  : 0.02787 sec
NEON Time    : 0.00507 sec
Speedup      : 5.50 x
------------------------------------

결과 분석

  1. 성능 향상 (Speedup): 스칼라 버전은 평균 0.027초, NEON을 활용한 코드는 0.005초가 걸리며 약 5.5배의 성능 향상을 보였습니다. 4개의 float를 한 번에 연산할 뿐 아니라 vmlaq_f32를 통해 곱셈과 덧셈(MAC 연산)을 명령어 단 하나로 처리하기 때문에 극한의 성능 이득을 얻을 수 있었습니다.
  2. 연산 결과의 차이 (Accumulation Error): 아마 결과를 보시고 “계산 결과가 맞나?” 하는 의문이 드셨을 수 있습니다. 실제로 저 1억 개 배열의 내적을 수학적으로 오차 없이 식행하면(double 정밀도 기준) 정확한 정답은 32,835,000 부근이 나와야 합니다. 하지만 32비트 부동소수점(float)을 하나의 변수에 선형적으로 계속 더하는 스칼라 방식에서는, 누적합이 16,777,216 (즉, $2^{24}$)에 도달하는 순간 1.0 미만의 소수점 단위 값들을 더해도 유효숫자(정밀도) 범위를 벗어나 아예 더해지지 않고 무시되어 버립니다. 그래서 스칼라 버전의 결과는 기계적으로 정확히 16777216.00에서 멈춰버린 것입니다. 반면 NEON 코드에서는 누적합을 4개의 전용 레지스터로 쪼개어 독립적인 부분합(Partial Sum)을 구하기 때문에, 각 레지스터마다 숫자가 천천히 커지게 되어 오차가 누적되고 소실되는 시점이 훨씬 늦어집니다. 그 덕분에 기계적 한계 내에서도 정답(32,835,000)에 훨씬 가까운 31,929,088.00 이라는 결괏값을 훨씬 빠르고 정확하게 계산해 낼 수 있었습니다. 데이터가 방대해질수록 이처럼 분할 정복 성격을 띠는 SIMD가 속도뿐 아니라 실질적인 부동소수점 연산 정밀도 면에서도 더 나은 결과를 줍니다.

마치며

이렇듯 무거운 배열 연산이나 행렬 연산, 딥러닝 추론 등의 코어 루틴에서는 SIMD 사용 유무가 수 배 이상의 극적인 성능 차이를 만듭니다. 크로스 플랫폼 개발을 하신다면, 위 코드의 예시처럼 매크로로 분기하여 Intel/AMD에서는 SSE/AVX를, ARM 계열에서는 NEON을 각각 호출하도록 대응하면 어떠한 빌드 환경에서도 극강의 퍼포먼스를 내는 애플리케이션을 작성할 수 있습니다.