메모리 정렬(Alignment)이란 무엇인가
SIMD 벡터화를 다루다 보면 피할 수 없는 개념이 바로 **메모리 정렬(Alignment)**이다. 정렬이란 특정 메모리 주소가 alignment_size의 배수인지를 나타내는 속성이다. 수식으로 표현하면 address % alignment_size == 0일 때 해당 주소는 alignment_size-aligned라고 말한다.
실무에서 자주 마주치는 예시는 다음과 같다.
- 4바이트
int접근 → 4-byte alignment - 8바이트 Object 포인터 → 8-byte alignment
- 16개의
int원소를 담는 벡터(총 64바이트) → 64-byte alignment - 캐시라인 정렬(cacheline-alignment) → 일반적으로 64-byte alignment
여기서 중요한 포인트는 벡터 접근의 정렬 기준은 원소의 크기가 아니라 벡터 전체 크기라는 점이다. 이를 놓치면 스칼라 코드에서 벡터 코드로 전환할 때 예상치 못한 성능 저하나 오류를 마주하게 된다.
Java 환경에서의 정렬 보장 범위
Java는 JVM이 메모리 관리를 담당하기 때문에 개발자가 직접 주소를 제어하지 않는다. 그럼에도 JVM 스펙 상 일정한 정렬 보장이 존재한다.
- 모든 Java Object의 포인터는 8-byte aligned가 보장된다.
- 배열의 각 원소는 원소 타입의 크기만큼 정렬이 보장된다. 예를 들어
int[]의 각 원소는 4-byte aligned다.
따라서 스칼라(non-vectorized) 접근은 항상 정렬이 보장되어 문제가 없다. 그러나 벡터 접근으로 전환하는 순간 이 보장은 깨진다. int 원소는 4-byte aligned일 뿐이므로, 16개의 int를 한 번에 로드하는 64바이트 벡터 연산에 필요한 64-byte alignment를 만족하지 못할 수 있다.
// Vector API를 활용한 벡터 연산 예시
VectorSpecies<Integer> SPECIES = IntVector.SPECIES_512; // 64바이트
int[] array = new int[1024];
for (int i = 0; i < array.length; i += SPECIES.length()) {
IntVector v = IntVector.fromArray(SPECIES, array, i);
// array의 시작 주소가 64-byte aligned가 아니라면 unaligned load 발생
v = v.mul(2);
v.intoArray(array, i);
}
즉, 오토-벡터화든 Vector API를 통한 명시적 벡터화든, 정렬 문제는 동일하게 적용된다.
아키텍처별 미정렬 접근의 결과
아키텍처에 따라 미정렬(unaligned) 접근을 처리하는 방식이 다르다. 일부 구형 아키텍처는 정렬되지 않은 접근을 아예 허용하지 않으며, 이 경우 SIGBUS와 같은 오류가 발생하거나 잘못된 값을 읽어오는 버그로 이어진다.
반면 x86과 같은 현대 아키텍처는 미정렬 접근을 허용하지만, 성능 패널티가 존재한다. 캐시라인 경계를 넘는 벡터 로드는 내부적으로 두 번의 메모리 접근으로 분리되어 처리되기 때문에 처리량(throughput)이 떨어지고 레이턴시가 증가한다. 특히 대용량 배열을 반복 처리하는 배치 연산이나 스트림 집계 로직에서는 이 차이가 측정 가능한 수준으로 나타난다.
정렬 크기가 항상 2의 거듭제곱인 이유도 여기에 있다. 모든 벡터 및 캐시라인 크기가 2의 거듭제곱이기 때문에, 정렬 연산을 비트마스크로 단순하게 처리할 수 있다.
정리
- 벡터 접근의 정렬 기준은 벡터 전체 크기이며, 스칼라 시절의 정렬 보장이 벡터화 후에도 유지된다고 가정하면 안 된다.
- Java의
int[]같은 기본 배열은 원소 단위 정렬만 보장하므로, Vector API나 오토-벡터화 사용 시 미정렬 접근이 발생할 수 있다. - 미정렬 벡터 접근은 아키텍처에 따라 오류 또는 성능 저하로 이어지므로, 고성능 배치 처리 코드에서는 정렬 상태를 의식적으로 설계해야 한다.