저번 [C++]SIMD 병렬화와 컴파일러 최적화 / ARM Neon 글에서
직접 <arm_neon.h> 헤더를 불러와서 병렬화를 하는 작업을 해보면서,
직접 병렬화를 하지 않더라도 어느정도는 컴파일러가 스스로 최적화를 한다는 것을 확인했다.
하지만, 어떤 경우에는 병렬화가 가능한 구조임에도 자동으로 컴파일러가 최적화를 하지 않아서 직접 NEON으로 최적화 한 코드가 3배 정도 성능이 향상된 경우도 있었다.
그래서 이번에는 컴파일러가 자동 벡터화를 언제 스스로 하기 어려운지에 대해 좀더 자세히 정리해보려고 한다.
SIMD 병렬화 (NEON, AVX2) 등의 방법을 이해했다면 쉽게 생각해볼 수 있는 경우다.
병렬화를 하기 쉬운 형태의 예시로는 아래와 같은 형태가 있다.
for (int i=0; i<1000; i++) {
C[i] = A[i] + B[i];
}
이 코드의 경우, C[i+k]를 결정하기 위해 C[i]를 사용하지 않는,
즉, C라는 배열의 각 원소들은 서로 저 for loop안에서 독립적이다.
위 코드는 i=0부터 i=1000순서로 수행하든, 아니면 1000부터 0까지 수행하든,
아니면 랜덤으로 10->3->8->9->17->14.. 이렇게 수행하든 최종 C를 완성하는데에는 아무
지장이 없다.
그러므로 위와 같은 코드는 컴파일러가 자동으로 벡터화를 진행하기 아주 편한 코드다.
반면에, 아래 코드를 보자
for (int i = 1; i < N; i++) {
A[i] = A[i-1] + B[i];
}
A[i]를 갱신하기 위해 A[i-1]을 사용하고, 또 그 다음 A[i+1]을 위해 A[i]가 사용된다.
이런 경우에는 i=0,1,2,3처럼 한번에 4개 또는 8개씩 묶어서 연산을 할 수가 없다.
만약에 4개를 묶어서 병렬화를 한다고 해보자.
A[1] = A[0] + B[1];
A[2] = A[1] + B[2];
A[3] = A[2] + B[3];
A[4] = A[3] + B[4];
이걸 순차적으로 실행하면, A[2]를 구할 때 A[0]+B[1]+B[2]가 되지만,
만약에 병렬로 동시에 실행한다면 A[2]는 기존의 A[1]에다가 B[2]만 더하므로 두 식이
항상 같음을 절대 보장할 수 없다.
이는 두 포인터가 동일한 메모리를 가리킬 수 있는 경우를 의미한다.
예를들어
void add(int* A, int* B, int* C, int n) {
for (int i = 0; i < n; i++) {
A[i] = B[i] + C[i];
}
}
이 코드에서, 만약에 A와 B가 같은 곳, 또는 A가 B의 바로 +1 주소만큼을 가리킨다고 생각하면,
이를 병렬화해서 A[0],A[1],A[2],A[3] 값을 동시에 계산해서 저장하는 것과
순차적으로 계산해서 할당하는 것은 큰 값의 차이가 있을 것이다.
컴파일러 입장에서는 개발자가 어떤걸 의도했는지 알 수가 없으니 마음대로 최적화를 진행할 수가 없다.
참고로 이 부분은 컴파일러가 똑똑하게 조건 분기를 통해서 처리한다.
실험에 사용할 전체코드는 아래와 같다
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <cmath>
#include <arm_neon.h> // Apple Silicon NEON 헤더
// 데이터 크기 설정 (5천만 개)
const int DATA_SIZE = 50000000;
// 1. 일반적인 스칼라 덧셈 함수 (SISD)
void add_scalar(const float* a, const float* b, float* result, int n) {
// void add_scalar(const float* RESTRICT a, const float* RESTRICT b, float* RESTRICT result, int n) {
for (int i = 0; i < n; ++i) {
result[i] = a[i] + b[i];
}
}
// 2. NEON SIMD 덧셈 함수
// float는 32비트이므로, 128비트 NEON 레지스터에 4개씩 담을 수 있음
void add_neon(const float* a, const float* b, float* result, int n) {
int i = 0;
// 4개씩 묶어서 처리 (Loop Unrolling)
// n-4까지 루프를 돌리는 이유는 마지막에 남는 자투리(4개 미만) 처리를 위함
for (; i <= n - 4; i += 4) {
// 메모리에서 데이터 로드 (Load)
float32x4_t va = vld1q_f32(&a[i]); // a[i] ~ a[i+3]
float32x4_t vb = vld1q_f32(&b[i]); // b[i] ~ b[i+3]
// 벡터 덧셈 (Add) - 한 번의 명령어로 4개의 덧셈 수행
float32x4_t vr = vaddq_f32(va, vb);
// 결과를 메모리에 저장 (Store)
vst1q_f32(&result[i], vr);
}
// 남은 데이터(나머지) 처리 (일반 방식으로 처리)
for (; i < n; ++i) {
result[i] = a[i] + b[i];
}
}
int main() {
std::cout << "Initializing data (" << DATA_SIZE << " elements)..." << std::endl;
// 메모리 할당 (std::vector 사용)
// *주의: NEON 로드/스토어 성능 최적화를 위해 메모리 정렬(align)을 하면 더 좋지만,
// 최신 아키텍처에서는 정렬되지 않은 로드도 성능 저하가 적으므로 기본 vector 사용
std::vector<float> a(DATA_SIZE);
std::vector<float> b(DATA_SIZE);
std::vector<float> res_scalar(DATA_SIZE);
std::vector<float> res_neon(DATA_SIZE);
// 랜덤 데이터 채우기
std::mt19937 gen(42);
std::uniform_real_distribution<float> dis(0.0f, 100.0f);
for (int i = 0; i < DATA_SIZE; ++i) {
a[i] = dis(gen);
b[i] = dis(gen);
}
std::cout << "Data ready. Starting benchmark.\n" << std::endl;
// --- Scalar 테스트 ---
auto start = std::chrono::high_resolution_clock::now();
add_scalar(a.data(), b.data(), res_scalar.data(), DATA_SIZE);
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> scalar_duration = end - start;
std::cout << "Scalar Logic Time: " << scalar_duration.count() << " ms" << std::endl;
// --- NEON 테스트 ---
start = std::chrono::high_resolution_clock::now();
add_neon(a.data(), b.data(), res_neon.data(), DATA_SIZE);
end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> neon_duration = end - start;
std::cout << "NEON SIMD Time : " << neon_duration.count() << " ms" << std::endl;
// --- 결과 검증 ---
// 결과가 같은지 확인 (부동소수점 오차 고려)
bool correct = true;
for (int i = 0; i < DATA_SIZE; ++i) {
if (std::abs(res_scalar[i] - res_neon[i]) > 1e-5) {
correct = false;
std::cout << "Mismatch at index " << i << std::endl;
break;
}
}
if (correct) {
std::cout << "\nResults match! Verification successful." << std::endl;
std::cout << "Speedup: " << scalar_duration.count() / neon_duration.count() << "x" << std::endl;
} else {
std::cout << "\nResults do NOT match!" << std::endl;
}
return 0;
}
void add_scalar(const float* a, const float* b, float* result, int n) {
for (int i = 0; i < n; ++i) {
result[i] = a[i] + b[i];
}
}
이거를 https://godbolt.org 사이트에서 컴파일러를 ARM64 gcc에 맞춰서 컴파일 해보면
add_scalar(float const*, float const*, float*, int):
cmp w3, 0
ble .L11
sub w4, w3, #1
cmp w4, 3
bls .L13
sub x4, x2, #4
sub x5, x4, x0
sub x4, x4, x1
cmp x5, 8
ccmp x4, 8, 0, hi
bls .L13
lsr w5, w3, 2
mov x4, 0
lsl x5, x5, 4
.L14:
ldr q31, [x0, x4]
ldr q30, [x1, x4]
fadd v30.4s, v31.4s, v30.4s
str q30, [x2, x4]
add x4, x4, 16
cmp x5, x4
bne .L14
and w5, w3, -4
cmp w3, w5
beq .L11
ubfiz x4, x5, 2, 32
add w6, w5, 1
ldr s29, [x0, x4]
ldr s28, [x1, x4]
fadd s28, s29, s28
str s28, [x2, x4]
cmp w3, w6
ble .L11
add x6, x4, 4
add w5, w5, 2
ldr s27, [x0, x6]
ldr s26, [x1, x6]
fadd s26, s27, s26
str s26, [x2, x6]
cmp w3, w5
ble .L11
add x4, x4, 8
ldr s25, [x0, x4]
ldr s24, [x1, x4]
fadd s24, s25, s24
str s24, [x2, x4]
.L11:
ret
.L13:
ubfiz x3, x3, 2, 32
mov x4, 0
.L16:
ldr s23, [x0, x4]
ldr s22, [x1, x4]
fadd s22, s23, s22
str s22, [x2, x4]
add x4, x4, 4
cmp x3, x4
bne .L16
ret
중요한 부분만 확인해보자.
핵심은 컴파일러가 메모리 범위 체크를 통해 서로 침범하는 경우에는 최적화가 없는 기존의 for loop로,
침범하지 않아서 벡터화가 가능한 경우에는 최적화된 방식을 사용하게 컴파일된다.
(어셈블리어 해석 부분은 제미나이3 Pro의 도움을 받았다)
sub x4, x2, #4 ; Result 포인터 - 4바이트
sub x5, x4, x0 ; (Result - 4) - A 포인터 거리 계산
sub x4, x4, x1 ; (Result - 4) - B 포인터 거리 계산
cmp x5, 8 ; A와 거리가 8바이트 이내인가?
ccmp x4, 8, 0, hi ; B와 거리가 8바이트 이내인가?
bls .L13 ; 겹친다면 -> .L13 (느린 스칼라 루프)로 이동
L13 부분은
.L13: ;
ubfiz x3, x3, 2, 32
mov x4, 0
.L16: ; (스칼라 루프 시작)
ldr s23, [x0, x4] ; A에서 1개 로드 (s 레지스터)
ldr s22, [x1, x4] ; B에서 1개 로드
fadd s22, s23, s22 ; 1개 덧셈
str s22, [x2, x4] ; Result에 1개 저장
add x4, x4, 4 ; 4바이트 이동
cmp x3, x4
bne .L16 ; 반복
ret
L13 부분이 최적화 되지 않은, 개발자가 의도한 result[i] = a[i] + b[i]; 을 정직하게 수행하는 코드로 볼 수 있다.
이번에는 컴파일러가 메모리 겹침 문제가 없을 때 실행하는 최적화 코드를 확인해보자.
lsr w5, w3, 2 ; n / 4 (4개씩 묶으면 몇 번 반복?)
mov x4, 0 ; 인덱스 0으로 초기화
lsl x5, x5, 4 ; 바이트 단위로 변환 (float 4개 = 16바이트)
.L14: ; [메인 SIMD 루프 시작]
ldr q31, [x0, x4] ; A에서 128비트(4개) 로드 (q 레지스터)
ldr q30, [x1, x4] ; B에서 128비트(4개) 로드
fadd v30.4s, v31.4s, v30.4s ; 4개 동시 덧셈 (Vector Add)
str q30, [x2, x4] ; Result에 128비트 저장
add x4, x4, 16 ; 인덱스 16바이트 이동
cmp x5, x4 ; 다 했나?
bne .L14 ; 안 끝났으면 반복
(참고로 이 코드 이후 부분은 4개씩 짤라서 처리하고 남은 1~3개가 있으면 따로 처리하는 방식이므로 설명에서 스킵했다)
이렇게 컴파일러는 메모리 앨리어싱 여부를 확인하여 병렬화를 하는 코드와 병렬화를 진행하지 않는 코드로 분기해서 수행한다.
참고로, 개발자가 직접 키워드를 통해 메모리 범위 침범하는 문제 없어라고 플래그를 줄 수도 있는데,
바로 restrict키워드를 이용하는 방법이다.
(키워드 사용은 컴파일러에 따라 다를 수 있음을 주의하자)
아까 코드의 add_scalar()함수에서
void add_scalar(const float* __restrict a, const float* __restrict b, float* __restrict result, int n) {
for (int i = 0; i < n; ++i) {
result[i] = a[i] + b[i];
}
}
이렇게 각 포인터마다 restrict 키워드를 추가해주자.
그 다음에 이를 godbolt에서 다시 컴일해서 어셈블리를 확인해보면
add_scalar(float const*, float const*, float*, int):
cmp w3, 0
ble .L11
sub w4, w3, #1
cmp w4, 2
bls .L16
lsr w5, w3, 2
mov x4, 0
lsl x5, x5, 4
.L14:
ldr q31, [x0, x4]
ldr q30, [x1, x4]
fadd v30.4s, v31.4s, v30.4s
str q30, [x2, x4]
add x4, x4, 16
cmp x4, x5
bne .L14
and w5, w3, -4
cmp w3, w5
beq .L11
.L13:
ubfiz x4, x5, 2, 32
add w6, w5, 1
ldr s29, [x0, x4]
ldr s28, [x1, x4]
fadd s28, s29, s28
str s28, [x2, x4]
cmp w3, w6
ble .L11
add x6, x4, 4
add w5, w5, 2
ldr s27, [x0, x6]
ldr s26, [x1, x6]
fadd s26, s27, s26
str s26, [x2, x6]
cmp w3, w5
ble .L11
add x4, x4, 8
ldr s25, [x1, x4]
ldr s24, [x0, x4]
fadd s24, s25, s24
str s24, [x2, x4]
.L11:
ret
.L16:
mov w5, 0
b .L13
메모리 겹침을 검사하는 부분이 제거 되고 바로 병렬화를 이용한다.
메모리가 비연속적인 경우에도 자동으로 최적화를 잘 진행하지 못한다.
저번 [C++]SIMD 병렬화와 컴파일러 최적화 / ARM Neon 글에서 마지막 행렬 곱 예시를 다시 가져와보겠다.
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <cmath>
#include <arm_neon.h> // Apple Silicon NEON 헤더
// 행렬 크기 설정 (1024 x 1024) -> 약 10억 번의 연산 수행
const int N = 1024;
// 1. 일반 스칼라 행렬 곱셈 (Naive implementation)
// O(N^3) 알고리즘
void matmul_scalar(const float* A, const float* B, float* C, int n) {
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
float sum = 0.0f;
for (int k = 0; k < n; ++k) {
// C[i][j] += A[i][k] * B[k][j]
sum += A[i * n + k] * B[k * n + j];
}
C[i * n + j] = sum;
}
}
}
// 2. NEON SIMD 행렬 곱셈
// 한 번에 4개의 열(column)을 계산
void matmul_neon(const float* A, const float* B, float* C, int n) {
for (int i = 0; i < n; ++i) {
// j는 4씩 증가 (한 번에 4개의 float 결과 계산)
for (int j = 0; j < n; j += 4) {
// 결과 누적을 위한 레지스터 초기화 (0.0f, 0.0f, 0.0f, 0.0f)
float32x4_t sum_vec = vdupq_n_f32(0.0f);
for (int k = 0; k < n; ++k) {
// A[i][k] 값 하나를 가져와서 4개짜리 벡터로 복사 (Broadcast)
// 예: A[i][k]가 2.0이면 -> {2.0, 2.0, 2.0, 2.0}
float32x4_t a_val = vdupq_n_f32(A[i * n + k]);
// B[k][j] 부터 4개를 로드
// { B[k][j], B[k][j+1], B[k][j+2], B[k][j+3] }
float32x4_t b_vec = vld1q_f32(&B[k * n + j]);
// FMA (Fused Multiply-Add) 연산 수행
// sum_vec += a_val * b_vec
// 곱하기와 더하기를 한 번의 사이클에 처리하여 매우 빠름
sum_vec = vfmaq_f32(sum_vec, a_val, b_vec);
}
// 계산된 4개의 결과를 메모리 C[i][j] ~ C[i][j+3]에 저장
vst1q_f32(&C[i * n + j], sum_vec);
}
}
}
int main() {
std::cout << "Initializing matrices (" << N << "x" << N << ")..." << std::endl;
// 데이터 정렬 등은 생략하고 일반 vector 사용 (충분한 성능 차이 확인 가능)
// 1차원 배열로 펼쳐서 사용 (Row-major)
std::vector<float> A(N * N);
std::vector<float> B(N * N);
std::vector<float> C_scalar(N * N, 0.0f);
std::vector<float> C_neon(N * N, 0.0f);
// 랜덤 데이터 채우기
std::mt19937 gen(42);
std::uniform_real_distribution<float> dis(0.0f, 1.0f);
for (int i = 0; i < N * N; ++i) {
A[i] = dis(gen);
B[i] = dis(gen);
}
std::cout << "Benchmarking started.\n" << std::endl;
// --- Scalar Test ---
auto start = std::chrono::high_resolution_clock::now();
matmul_scalar(A.data(), B.data(), C_scalar.data(), N);
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> scalar_time = end - start;
std::cout << "Scalar Time: " << scalar_time.count() << " ms" << std::endl;
// --- NEON Test ---
start = std::chrono::high_resolution_clock::now();
matmul_neon(A.data(), B.data(), C_neon.data(), N);
end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> neon_time = end - start;
std::cout << "NEON Time : " << neon_time.count() << " ms" << std::endl;
// --- 검증 (Verification) ---
std::cout << "\nVerifying results..." << std::endl;
bool correct = true;
for (int i = 0; i < N * N; ++i) {
// 부동소수점 연산 순서 차이로 인한 미세한 오차 허용 (epsilon)
if (std::abs(C_scalar[i] - C_neon[i]) > 1e-3) {
std::cout << "Mismatch at index " << i << ": "
<< C_scalar[i] << " vs " << C_neon[i] << std::endl;
correct = false;
break;
}
}
if (correct) {
std::cout << "Results Match!" << std::endl;
std::cout << "------------------------------------------------" << std::endl;
std::cout << "Speedup: " << scalar_time.count() / neon_time.count() << "x" << std::endl;
std::cout << "------------------------------------------------" << std::endl;
} else {
std::cout << "Results DO NOT Match!" << std::endl;
}
return 0;
}
이 코드는 O3 최적화로 놓고 빌드해봐도 NEON(벡터화)가 3배 정도 빠르다.
물론 이 속도는 -fno-vectorize 플래그를 없앴을 때를 기준이다.
-fno-vectorize 옵션을 사용했던 이유는 직접 작성한 SIMD병렬화 코드가 정말 제대로 작성 되었는지를 확인하고 최적화 전과 성능을 비교해보기 위해서였는데, 일부 경우에서는 직접 최적화를 한 것보다 자동 벡터화 옵션을 켰을 때 컴파일러가 최적화하는게 더 성능이 좋은 경우도 있었다.
하지만 이거는 컴파일러에게 스스로 벡터화를 허용하더라도, NEON 벡터화를 직접 한 것이 3배가 더 빠르다.
왜 이건 컴파일러가 스칼라버전을 최적화를 못하는걸까?
그 원인은 바로
for (int k = 0; k < n; ++k) {
// C[i][j] += A[i][k] * B[k][j]
sum += A[i * n + k] * B[k * n + j];
}
에 있다.
지금 k가 1씩 증가하는 변하는 루프에 있기 때문에 저 루프 안에서는 i,n,j값은 고정일 것이다.
하지만, B[k * n + j]에서 k가 1씩 증가함에따라 결국 B에 접근하는 인덱스는 순차적으로 1씩 늘어나지 않고 n씩 늘어난다.
예를들어 n=4, i=0, j=0이라고 예를들면,
k=0 : B[0]
k=1 : B[4]
k=2 : B[8]
이다.
하지만 연속적으로 위치한 4개(또는 8개 등)를 한번에 올려서 처리하려면 데이터가 연속적이여야하는데, 당연히 인덱스0, 인덱스4, 인덱스 8 이렇게 접근하는건 연속적이지 못하니 최적화를 할 수가 없다.
이것도 for loop에서 메모리 배치를 조금 바꿔주면 컴파일러가 자동으로 최적화가 가능하게 만들어줄 수 있다.
위 전체 코드에서 아래 함수를 추가해주자
// i -> k -> j 순서로 변경 (Loop Interchange)
void matmul_optimized_scalar(const float* A, const float* B, float* C, int n) {
for (int i = 0; i < n; ++i) {
for (int k = 0; k < n; ++k) { // k가 중간으로 옴
float a_val = A[i * n + k]; // A값 고정
// B와 C 모두 연속적인 인덱스(j)로 접근하기 때문에 최적화 가능
for (int j = 0; j < n; ++j) {
C[i * n + j] += a_val * B[k * n + j];
}
}
}
}
그리고 시간 테스트 코드는 아래 코드를 추가해서 같이 시간 측정을 해보자.
// --- Scalar(Optimized) Test ---
start = std::chrono::high_resolution_clock::now();
matmul_optimized_scalar(A.data(), B.data(), C_neon.data(), N);
end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> scalar_optimized_time = end - start;
std::cout << "Scalar(Optimized) Time : " << scalar_optimized_time.count() << " ms" << std::endl;
이제 빌드해주고 확인해보면
Scalar Time: 823.332 ms
Scalar(Optimized) Time : 258.79 ms
NEON Time : 226.866 ms
컴파일러가 자동 최적화 해준 부분이 NEON에 거의 근접한 속도까지 컴파일러가 최적화를 해주었다.
실제로 위에서 언급한 사이트에서 다시 optimized 코드의 어셈블리어를 확인해보면
matmul_optimized_scalar(float const*, float const*, float*, int):
cmp w3, 0
ble .L34
stp x29, x30, [sp, -32]!
mov x10, x2
lsr w2, w3, 2
and w12, w3, -4
mov x29, sp
ubfiz x7, x3, 2, 32
lsl x5, x2, 4
add x14, x0, x7
add w15, w12, 1
add w17, w12, 2
mov x2, x10
stp x19, x20, [sp, 16]
mov x20, -4
sub w19, w3, #1
sub x20, x20, x1
mov w18, 0
mov w30, 0
.L20:
add w11, w18, w12
add w13, w18, w15
add w16, w18, w17
sub x6, x14, x7
lsl x11, x11, 2
lsl x13, x13, 2
lsl x16, x16, 2
mov x4, x1
add x9, x20, x2
mov w8, 0
.L28:
ldr s31, [x6]
cmp w19, 3
bls .L21
cmp x9, 8
bls .L21
.L37:
dup v30.4s, v31.s[0]
mov x0, 0
.L22:
ldr q7, [x4, x0]
ldr q6, [x2, x0]
fmla v6.4s, v7.4s, v30.4s
str q6, [x2, x0]
add x0, x0, 16
cmp x5, x0
bne .L22
cmp w3, w12
beq .L23
add w0, w12, w8
ldr s4, [x10, x11]
ldr s5, [x1, x0, lsl 2]
fmadd s4, s5, s31, s4
str s4, [x10, x11]
cmp w3, w15
ble .L23
add w0, w8, w15
ldr s2, [x10, x13]
ldr s3, [x1, x0, lsl 2]
fmadd s2, s3, s31, s2
str s2, [x10, x13]
cmp w3, w17
ble .L23
add w0, w8, w17
ldr s0, [x10, x16]
ldr s1, [x1, x0, lsl 2]
fmadd s0, s1, s31, s0
str s0, [x10, x16]
.L23:
add x6, x6, 4
cmp x6, x14
beq .L26
sub x9, x9, x7
ldr s31, [x6]
add w8, w8, w3
add x4, x4, x7
cmp x9, 8
bhi .L37
.L21:
mov x0, 0
.L24:
ldr s29, [x4, x0]
ldr s28, [x2, x0]
fmadd s28, s29, s31, s28
str s28, [x2, x0]
add x0, x0, 4
cmp x7, x0
bne .L24
add x6, x6, 4
add w8, w8, w3
add x4, x4, x7
sub x9, x9, x7
cmp x14, x6
bne .L28
.L26:
add w30, w30, 1
add x14, x14, x7
add w18, w18, w3
add x2, x2, x7
cmp w3, w30
bne .L20
ldp x19, x20, [sp, 16]
ldp x29, x30, [sp], 32
ret
.L34:
ret
중간에 NEON레지스터를 활용하는
dup v30.4s, v31.s[0]
fmla v6.4s, v7.4s, v30.4s
같은 부분들이 확인되었다.
또 최적화가 어려운 다른 케이스로는 대표적으로 루프에 "조건문"이 들어가있는 경우가 있다.
아까 처음에 예로 들었던 C[i] = A[i] + B[i] 작업을 하는데,
만약에 A[i]의 값이 특정 값보다 크면 그때만 작업한다든가 그러면 한번에 올려서 처리하기가 까다롭다.
이런 경우 컴파일러가 자동으로 최적화 해주기도 위험하다.