- NumPy의 행렬 곱셈은 외부 BLAS 라이브러리에 기대지만, 이 구현은 순수 C와 FMA3·AVX2만으로 단일 스레드와 멀티스레드 성능을 BLAS 수준까지 끌어올리는 것을 목표로 함
- 성능의 중심은 $C$를 작은 블록으로 나누고 16×6 마이크로커널이
YMM레지스터 안에서 rank-1 update를 반복해 메모리 접근을 줄이는 구조임 - 임의 크기 행렬에서는 경계 처리가 병목이 되기 쉬워, 마스크 저장과 0 패딩 버퍼를 조합해 마스크 로드의 성능 저하를 피함
- 캐시 재사용은
k_c,m_c,n_c블로킹으로 확보하며, 실제 최고 성능은 스레드 수·커널 크기·타일 크기 튜닝에 크게 좌우됨 - AVX-512는 더 넓은 CPU 지원을 위해 제외했으므로 AVX-512 CPU에서는 BLAS가 더 빠를 수 있고, OpenBLAS 비교도 AVX-512를 끈 조건에서 수행함
구현 목표와 비교 대상
- 구현 코드는 sgemm.c에 공개되어 있으며, 최신 프로세서에서 멀티스레드 FP32 행렬 곱셈을 최적화함
- NumPy는 행렬 곱셈 같은 선형대수 연산을 외부 BLAS 라이브러리에 의존함
- 예시로 Intel MKL, Accelerate, BLIS, GotoBLAS, OpenBLAS가 있음
- OpenBLAS, GotoBLAS, BLIS는 C/FORTRAN/Assembly로 작성되고, CPU 마이크로아키텍처별 수동 최적화 행렬 곱셈 구현을 포함함
- 목표는 저수준 어셈블리 없이 순수 C로 작성하면서도 다음 조건을 만족하는 행렬 곱셈 구현임
- 임의 행렬 크기에서 동작함
- 최신 x86-64 프로세서에서 실행됨
- 기존 BLAS 라이브러리와 경쟁함
- 코드가 단순하고 확장하기 쉬움
- 참고 자료는 Simon Boehm의 Fast Multidimensional Matrix Multiplication on CPU from Scratch, Sergey Slotin의 Matrix Multiplication, Geohot의 Can you multiply a matrix?, GotoBLAS·BLIS 관련 논문임
벤치마크 조건과 FLOPS 계산
- 테스트 환경은 AMD Ryzen 7 9700X, 32GB DDR5 6000 MHz CL36, OpenBLAS 0.3.26, GCC 13.3, Ubuntu 24.04.1 LTS임
- 컴파일 플래그는
-O3 -march=native -mno-avx512f -fopenmp를 사용함 - 공정한 비교를 위해 OpenBLAS 설치 시 적절한
TARGET을 설정하고 AVX-512 명령어를 비활성화해야 함- Zen4/5 프로세서는
make TARGET=ZEN으로 컴파일함 - 그렇지 않으면 OpenBLAS가 AVX-512 명령어를 기본 사용함
- Zen4/5 프로세서는
- OpenBLAS FP32 행렬 곱셈은
cblas_sgemmAPI로 실행함 - 벤치마크는 정사각 행렬을 대상으로 함
m=n=k=200부터m=n=k=10000까지200간격으로 평가함- 행렬 곱셈을
n_iter번 반복하고, 중앙 실행 시간을 성능 측정에 사용함
- $M \times K$ 행렬 $A$와 $K \times N$ 행렬 $B$를 곱하면 총 연산량은 $2MNK$ FLOP임
- 성능은
FLOPS=(2*m*n*k)/exec_time으로 계산함
- 성능은
이론적 한계와 SIMD 기반
- 최신 x86-64 CPU는 SIMD 확장으로 여러 데이터를 병렬 처리함
- 주요 명령어는 AVX2와 FMA임
- 둘 다 256비트
YMM레지스터를 사용함 - 각
YMM레지스터는 32비트 float 8개를 담을 수 있음
- 둘 다 256비트
- FMA 명령어
VFMADD231PS는YMM1 = YMM2 * YMM3 + YMM1형태의 packed single 연산을 수행함 - Ryzen 9700X에서 fused multiply-add 처리량은 0.5 cycles/instruction, 즉 사이클당 2개 명령어임
- 이론적으로 Ryzen 9700X는 단일 코어에서 사이클당 32 FLOP를 수행할 수 있음
- 계산식은
8 floats × 2(add+mul) × 2(1/TP)임 - 8코어에서 4.7GHz 지속 클럭을 가정하면 멀티스레드 이론 피크는 1203 FLOPS로 추정됨
- 계산식은
기본 구현과 마이크로커널
- 행렬은 column-major 순서로 저장함
A[row][col]은 C 포인터에서ptr[col*M + row]로 접근함
- 가장 단순한 구현은 $C$의 모든 행과 열을 순회하며 각 원소마다 $A$의 행과 $B$의 열의 내적을 계산함
- 고성능 구현의 핵심은 $C$를 $m_R \times n_R$ 부분 행렬로 나누고, 각 부분 행렬을 효율적으로 계산하는 마이크로커널임
- 커널은 $\bar{C}$를 레지스터에 0으로 초기화한 뒤 $K$ 차원을 따라 반복함
- $\bar{A}$의 열 벡터와 $\bar{B}$의 행 벡터를 레지스터로 가져옴
- 두 벡터의 외적을 계산해 $\bar{C}$ 누산기에 더함
- 각 단계는 rank-1 update임
- 이 방식은 naive 방식의 메모리 접근량 $2K m_R n_R$과 비교해, 레지스터로 가져오는 원소 수를 $(m_R+n_R)K$로 줄임
- AVX CPU에는 16개 YMM 레지스터가 있으므로 커널 크기는 다음 제약을 만족해야 함
- $(m_R/8) \cdot n_R + m_R/8 + 1 \le 16$
- $m_R$은 8의 배수여야 함
- 이론적으로는 $m_R$과 $n_R$이 크고 같은 값일수록 메모리 접근 감소가 커지지만, 실제 Ryzen 9700X에서는 16×6 커널이 가장 좋은 성능을 보임
- 구현은
immintrin.h의 intrinsic을 사용함__m256은 256비트 벡터 타입이며YMM레지스터 내용을 나타냄_mm256_loadu_ps로A열 벡터를 로드함_mm256_broadcast_ss로B의 스칼라 값을 8개 float 벡터로 브로드캐스트함_mm256_fmadd_ps로 누산기를 갱신함_mm256_storeu_ps로 결과를 메모리에 저장함
- 생성된 어셈블리에는
vfmadd231ps와vbroadcastss같은 SIMD FMA 명령어가 포함됨
임의 크기 행렬을 위한 패딩
- 기본 16×6 커널은 $M$과 $N$이 각각 16과 6의 배수일 때 바로 동작함
- 경계 영역에서 열 수 $n$이 6보다 작으면 저장 루프를
j < n까지만 수행함 - 행 수 $m$이 16보다 작을 때는
_mm256_storeu_ps가 8개 원소를 한 번에 저장하므로 마스크 저장이 필요함_mm256_maskstore_ps는 마스크 비트가 켜진 원소만 메모리에 저장함- 마스크는 겹치는 행 수 $m$에 따라 생성함
- 경계에서 로드까지
_mm256_maskload_ps로 처리하면 커널 성능이 크게 떨어질 수 있음- 마스크 계산 추가 명령어가 오버헤드를 만듦
- $n$이 컴파일타임 상수가 아니어서 컴파일러가 루프를 효율적으로 언롤하기 어려움
- 대신 $m \neq m_R$이면 $\bar{A}$를 버퍼에 복사해 0으로 패딩하고, $n \neq n_R$이면 $\bar{B}$도 버퍼에 복사해 0으로 채움
- 관련 구현은 matmul_pad.h에 있음
캐시 블로킹과 데이터 재사용
- 레지스터와 DRAM 사이에는 CPU 캐시 계층이 있으며, 최신 데스크톱 CPU는 보통 L1, L2, L3 캐시를 사용함
- 캐시는 DRAM보다 빠르지만 용량이 제한되어, 전체 $A$, $B$, $C$를 모두 캐시에 담는 방식은 불가능함
- 행렬을 작은 블록으로 나누어 캐시에 올리고 같은 데이터를 여러 rank-1 update에 재사용하는 방식이 캐시 블로킹 또는 타일링임
- 단일 스레드 캐시 블로킹은 BLIS 구조와 유사한 5중 루프 형태임
- 가장 바깥 루프는 $N$ 차원을 따라 $C_j$와 $B_j$ 블록을 만듦
- 다음 루프는 $K$ 차원을 따라 $A_j$와 $B_p$ 블록을 만듦
- $B_p$는 패킹되어 $\tilde{B}_p$가 되고, 필요한 경우 0으로 패딩되어 L3 캐시 재사용을 노림
- 다음 루프는 $M$ 차원을 따라 $C_i$와 $A_j$ 블록을 만들고, $A_j$는 패킹되어 $\tilde{A}_j$가 됨
- 마지막 두 루프는 캐시 블록을 $m_R \times k_c$, $k_c \times n_R$ 패널로 나누어 커널에 전달함
- 패킹된 $\tilde{A}_j$와 $\tilde{B}_p$는 서로 다르게 저장됨
- $\tilde{A}_j$ 내부 패널은 column-major로 저장됨
- $\tilde{B}_p$ 내부 패널은 row-major로 저장됨
- 캐시 블로킹 파라미터는 CPU 모델별 캐시 크기에 맞춰 조정해야 함
- $k_c \times n_c$는 L3 캐시를 채우는 출발점이 됨
- $m_c \times k_c$는 L2 캐시를 채우는 출발점이 됨
- $k_c \times n_R$은 L1 캐시를 채우는 출발점이 됨
- 실제로는 이론값보다 큰 값이 더 좋은 성능을 내는 경우가 많고, CPU가 캐시 배치를 자동 관리하므로 알고리듬 수준에서 루프와 접근 패턴을 설계해야 함
- 구현은 matmul_cache.h에 있음
커널 미세 최적화
__m256 C_buffer[6][2]처럼 배열로 누산기를 정의하는 대신, 누산기 변수를 명시적으로 펼쳐 선언함- 이 방식은 GCC가 코드를 더 잘 최적화하고 레지스터 spilling을 피하는 데 도움을 줌
- 마스크 계산도 벡터 명령어를 사용하도록 바꿈
mask[32]정적 배열을 두고_mm256_cvtepi8_epi32와_mm_loadu_si64를 사용함
- 해당 구현은 matmul_micro.h에 있음
멀티스레딩 전략
- 병렬화 대상은 산술 연산과 패킹 모두임
- 마이크로커널 바깥의 5번째, 4번째, 3번째 루프는 캐시 블록 크기 단위로 반복함
- 모든 스레드를 바쁘게 유지하려면 반복 횟수가 스레드 수 이상이어야 함
- 입력 행렬 차원은 대략
스레드 수 × 캐시 블록 크기이상이어야 함
- Ryzen 9700X 단일 스레드에서 좋은 성능을 보인 캐시 블록 크기는 $n_c=1535$, $m_c=1024$임
- 8코어를 모두 활용하려면 최소 $\max(m_c,n_c) \times 8 = 1535 \times 8 = 12280$ 크기 차원이 필요함
- 반대로 마지막 두 루프는 작은 $m_R$, $n_R$ 블록을 반복하므로 병렬화에 적합함
- 일반적으로 $m_R$, $n_R$은 20보다 작음
- $m_c$, $n_c$를 코어 수의 배수로 선택하면 작업을 균등하게 나눌 수 있음
- Ryzen 9700X에서는 두 개의 내부 루프를
#pragma omp parallel for collapse(2) num_threads(NTHREADS)로 함께 병렬화하는 방식이 가장 좋은 성능을 냄 - 많은 코어를 가진 프로세서, 특히 16코어 초과 환경에서는 중첩 병렬성과 2~3개 루프 병렬화를 고려할 수 있음
- $\tilde{A}$와 $\tilde{B}$ 패킹도 OpenMP로 병렬화함
pack_blockA는mc를MR단위로 순회하며 병렬화함pack_blockB는nc를NR단위로 순회하며 병렬화함
- 멀티스레드 구현에서 Ryzen 9700X 기준 좋은 성능을 보인 파라미터는 다음과 같음
- $m_c = m_R \times \text{number of threads} \times 5$
- $n_c = n_R \times \text{number of threads} \times 50$
- 최종 멀티스레드 구현은 matmul_parallel.h에 있음