E | ngineering
캐시, 지역성, 코딩
덞웖이
2025. 4. 25. 12:04
지역성
프로그램이 메모리에 접근할 때, 가까운 메모리 주소들이 접근될 가능성이 높는 점을 활용,
캐시 메모리 효율 향상, 그에따른 성능 향상
- 시간적 지역성 (Temporal Locality): 최근 접근한 데이터를 다시 접근할 가능성이 높음
- 공간적 지역성 (Spatial Locality): 인접한 데이터가 함께 접근될 가능성이 높음
콤퓨우타는 이걸로 예측해서 캐시를 채워두는데, 지역성에 반하는 접근이 발생하면 캐시 미스가 낫다구 함
캐시 종류 | 위치 | 크기 | 속도 | 용도 |
L1 캐시 | 코어 내부 | 매우 작음 | 갓갓굿 | 가장 자주 접근하는 데이터 저장 |
L2 캐시 | 코어 내부 | 중간 | 굿굿 | L1 미스 시 대체 |
L3 캐시 | 코어간 공유 | 큼 | 긋…? | 코어 간 데이터 공유, L2 미스 시 사용 |
RAM (메인 메모리) | CPU 외부 | GB단위 | 느려 | 프로그램 상차🚚 |
CPU는 L1 → L2 → L3 → RAM 검색
L3는 코어전체가 공유… 그래서 상품설명에 L3만 적어두는구만? 🤧
그럼 어터케 하면 미스가 남?
- 공간 지역성 예시
# Python 3.12.9
import time
size = 1000
arr = [[i * j for j in range(size)] for i in range(size)]
# Column-wise, vertical traversal
start = time.time()
for j in range(size):
for i in range(size):
_ = arr[i][j]
end = time.time()
print(f"Column-wise exection time: {end - start:.4f}sec")
# Row-wise, horizontal traversal
start = time.time()
for i in range(size):
for j in range(size):
_ = arr[i][j]
end = time.time()
print(f"Row-wise execution time: {end - start:.4f}sec")
- 시간 지역성 예시
# Bad
cache = {}
start = time.time()
for i in range(10_000_000):
cache[i] = i * 2 # no second use
end = time.time()
print(f"Barbaric locality: {end - start:.4f}sec")
# Gut
cache = {}
start = time.time()
for _ in range(100):
for i in range(100_000): # 동일한 범위를 반복 접근
if i not in cache:
cache[i] = i * 2
end = time.time()
print(f"Yes locality: {end - start:.4f}sec")
두 줄로 풀어서 쓰니까 더 빠를지는 몰랏다 … 😥
그런데 넘파이로 실험을 해봄
- 사이즈 2x2부터 1000x1000인 2차원 배열 (가로세로가 같아야 행기반 열기반 비교가능하니까)
- 파이써닉한 루프로 돌림
-
더보기
import matplotlib.pyplot as plt import numpy as np # How could python not have a native truncate? def truncate(f, n): return int(f * 10**n) / 10**n ordinary_list = [[], [], []] x_axis = np.arange(2, 1001) # Traverse for size in x_axis: arr = np.random.rand(size, size) # Row-wise start = time.time() for i in range(size): for j in range(size): _ = arr[i, j] ordinary_list[0].append(truncate(time.time() - start, 4)) # Col-wise start = time.time() for j in range(size): for i in range(size): _ = arr[i, j] ordinary_list[1].append(truncate(time.time() - start, 4)) # Diff ordinary_list[2].append(ordinary_list[1][-1]-ordinary_list[0][-1]) # To numpy array and extract columns namba_py_list = np.array(ordinary_list) del ordinary_list row_wise, col_wise, diff = namba_py_list[0], namba_py_list[1], namba_py_list[2] # CHART fig, ax1 = plt.subplots(figsize=(14, 6)) width = 0.6 ax1.bar(x_axis - width/2, row_wise, width=width, label='Row-wise exc time', color='green') ax1.bar(x_axis + width/2, col_wise, width=width, label='Col-wise exc time', color='salmon') ax1.set_xlabel("Size of the matrix") ax1.set_ylabel("Execution time") ax2 = ax1.twinx() ax2.plot(x_axis, diff, label='Time difference', color='black', linewidth=1.5) ax2.set_ylabel("Time difference") ax1.legend() ax2.legend() plt.title("Locality experiment with py") plt.tight_layout() plt.show()
- 음수가 많이 나오는대? … 그럼 col-wise가 더 빠름?! 🥴
파이써닉 조은거 맞음? 왜이랴?
- 매 연산마다 인터프리터가 동작하고 타입 체크 등 추가 작업을 한다
- 넘파이가 C기반이긴 하지만 transpose같은 객체 생성자는 메모리를 스트라이드하게 사용하기도 함 (여기서는 안사용했지만...) 껑츙 🐇
- 파이써닉하게는 CPU 벤치마크를 하기 힘들다!
- 💡그래서 등장: np.ascontiguousarray()
- 🔍 np.ascontiguousarray()는 배열을 메모리에 연속되도록 강제 → 캐시미스 줄임
- 💡 또등장: numba.njit
- 🔍 Numba는 파이썬 코드를 자동으로 컴파일해줌. 어셈블리 레벨로 변환해줌
- → 파이썬 인터프리터가 원시인처럼 보임 🐒
- 결과적으로 Python이 아닌 C처럼 동작해서 캐시 성능 실험을 정확히 측정할 수 있게 하는 것이 목표
두번째 시도
더보기
!pip install numba
import numpy as np
import time
from numba import njit
# Aside from the decorators, nothing has changed.
@njit
def row_traverse(arr):
total = 0.0
for i in range(arr.shape[0]):
for j in range(arr.shape[1]):
total += arr[i, j]
return total
@njit
def col_traverse(arr):
total = 0.0
for j in range(arr.shape[1]):
for i in range(arr.shape[0]):
total += arr[i, j]
return total
# Apparently, njin needs a "Warm-up"
_ = row_traverse(np.ones((2,2)))
_ = col_traverse(np.ones((2,2)))
x_axis = np.arange(2, 1001)
row_wise = []
col_wise = []
diff = []
for size in x_axis:
# Converting it to use contiguos addresses
arr = np.ascontiguousarray(np.random.rand(size, size))
start = time.time()
row_traverse(arr)
row_wise.append(truncate(time.time() - start, 4))
start = time.time()
col_traverse(arr)
col_wise.append(truncate(time.time() - start, 4))
diff.append(truncate(col_wise[-1] - row_wise[-1], 4))
fig, ax1 = plt.subplots(figsize=(14, 6))
width = 0.6
ax1.bar(x_axis - width/2, row_wise, width=width, label='Row-wise exc time', color='green')
ax1.bar(x_axis + width/2, col_wise, width=width, label='Col-wise exc time', color='salmon')
ax1.set_xlabel("Size of the matrix")
ax1.set_ylabel("Execution time")
ax2 = ax1.twinx()
ax2.plot(x_axis, diff, label='Time difference', color='black', linewidth=1.5)
ax2.set_ylabel("Time difference")
ax1.legend()
ax2.legend()
plt.title("Locality experiment with py")
plt.tight_layout()
plt.show()
- 그런데 왜 계단형으로 나오는가?
- 캐시 계층 구조 (L1, L2, L3) 의 한계점 도달
- 즉, 크기가 캐시 경계(L1/L2/L3) 를 넘을 때마다 단계별 성능 저하가 발생
- 그래프에서 계단처럼 보임
- 작은 루프에선 CPU가 루프를 미리 전개(unroll) 하거나 predictive execution을 통해 거의 일정한 속도로 처리가능
- 하지만 크기가 커지면 이 최적화가 잘 안 되고 실제 접근 비용이 명확하게 증가하면서 계단현상 발생
- 실험 개선 방법
- time.time()은 초 단위로 측정하며 정밀도가 낮다 (ms ~ 15ms 단위)
- time.perf_counter() 로 정밀 측정
- arr.nbytes로 정확히 몇 바이트의 배열인지 표시하고, 캐시 경계 추정
- 캐시 경계 기반으로 그래프에 수직선 표시
- diff가 거의 0이라는 점... 더 큰 size를 사용해야 차이가 나타날지?
- 넘파이 인덱싱을 [i, j]가 아닌 [i][j]로 하면 성능차이가 있을지 체크
- 스레딩과 락을 사용하면 ... 로우가 뒤죽박죽되려나?
그 외의 예시
- 판다스에서의 필터링 방법에 따라 캐시 미스가 생길 수 있다
- 선택성이 높은 조건을 먼저 배치 ㅇㅇ
# Col1의 조건이 대부분 True라고 치고
# Col2가 'babo'인 경우가 희귀하다고 가정
# Bad locality
df[(df['col1'] > 1000) & (df['col2'] == 'babo')]
# Good locality: selectivity-first principle
# foo만 딱 걸러내고 나서 시작해새요
df[(df['col2'] == 'foo') & (df['col1'] > 1000)]
끗
- 좋은 로컬리티: 캐시 히트율을 높여 CPU가 기다리지 않게함 어터케 CPU를 기다리게할수이써
- 코드에서 루프 순서, 조건 순서, 데이터 배열 방법 등이 성능과 직결!
- 하드웨어를 이해하면 코드도 더 잘 최적화할 수 잇엇다니 … 😱