Memory Is Not One Thing
한국어 원문으로 표시합니다.
목표
컴퓨터 구조는 추상적으로 배우면 남는 게 없습니다. 이 파드의 진짜 CPU 로 직접 재 봅니다.
여기서 재는 숫자들은 나중에 "왜 이 코드가 느리지", "왜 계산이 1원 어긋나지" 같은 질문의 답이 됩니다.
볼 곳
grep -m1 'model name' /proc/cpuinfo
cat /sys/devices/system/cpu/cpu0/cache/index0/size
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
nproc
시간을 잴 때
파이썬은 느립니다. 그래서 읽는 횟수를 고정하고 배열 크기만 바꿔야 파이썬의 느림이 상쇄되고 메모리 차이만 남습니다.
그리고 워밍업 한 번은 꼭 하세요 — 첫 실행에는 배열을 만드는 비용이 섞입니다.
단계
- CPU 와 캐시 →
01-cpu.txt - 캐시 밖 지연 →
02-latency.txt - 캐시 라인 →
03-line.md - 0.1 + 0.2 →
04-float.txt - 더하는 순서 →
05-order.txt - 엔디안 →
06-endian.txt - 2의 보수 →
07-int.txt - 정리 →
08-notes.md
참고
2단계의 숫자는 기계마다 다릅니다. 절대값이 아니라 배수를 보세요 — 캐시 안과 밖이 두 배 넘게 차이 나면 제대로 잰 것입니다.
이 CPU 와 캐시를 읽는다
이 기계의 CPU 이름과 캐시 계층(L1/L2/L3 크기와 라인 크기)을 뽑아 01-cpu.txt 에 남기세요.
CPU 는 grep -m1 'model name' /proc/cpuinfo. 캐시는 /sys/devices/system/cpu/cpu0/cache/index*/ 아래 level, type, size, coherency_line_size 를 읽으면 됩니다.
캐시가 계단처럼 여러 단이라는 것과, 단계가 올라갈수록 커지고 느려진다는 것을 눈으로 보는 단계입니다.
캐시 밖으로 나가면 얼마나 느려지나
읽는 횟수는 같게 두고 배열 크기만 바꿔 가며 읽기 한 번에 걸리는 시간을 재어 02-latency.txt 에 남기세요. 4KB · 1MB · 64MB 세 가지면 충분합니다.
핵심은 읽는 횟수를 고정하는 것입니다. 그래야 파이썬 자체의 느림이 상쇄되고 메모리 차이만 남습니다.
import array, random, time
def bench(elems, idx):
a = array.array('i', [1]) * elems
for i in idx[:1000]: a[i] # 워밍업
t = time.perf_counter()
s = 0
for i in idx: s += a[i]
return (time.perf_counter() - t) / len(idx) * 1e9
무작위 인덱스 30만 개를 미리 만들어 세 배열에 같은 횟수로 쓰세요. 캐시 안과 밖의 차이가 두 배 넘게 나야 합니다.
왜 순서대로 읽으면 빠른가
캐시 라인 크기를 확인하고, int32 배열이라면 한 줄에 몇 개가 실리는지 계산해 03-line.md 에 적으세요.
라인 크기는 1단계에서 봤습니다(대개 64B). int32 는 4바이트니 한 줄에 16개입니다.
메모리는 바이트 하나씩 가져오지 않고 라인 통째로 가져옵니다. 그래서 배열을 순서대로 읽으면 한 번 가져온 줄에서 16개를 공짜로 씁니다. 무작위로 읽으면 매번 새 줄을 가져오고 나머지 15개는 버립니다.
같은 데이터를 다루는데 접근 순서만 바꿔 몇 배가 빨라지는 이유가 이것입니다.
0.1 + 0.2 는 0.3 이 아니다
0.1 + 0.2 의 결과와 == 0.3 의 판정, 그리고 0.1 을 소수점 아래 20자리까지 출력한 값을 04-float.txt 에 남기세요.
print(f"{0.1:.20f}"). 0.1 은 2진수로 딱 떨어지지 않습니다 — 10진수로 1/3 을 못 쓰는 것과 같습니다.
그래서 돈을 float 으로 다루면 안 됩니다. 정수(원 단위)나 십진 타입을 씁니다.
더하는 순서가 결과를 바꾼다
같은 세 수를 순서만 바꿔 더했을 때 결과가 달라지는 예를 만들어 05-order.txt 에 남기세요.
1e16, 1.0, -1e16 을 써 보세요.
1e16 + 1 - 1e16 = 0.0
1e16 - 1e16 + 1 = 1.0
큰 수에 작은 수를 더하면 작은 쪽이 자리수 밖으로 밀려나 사라집니다. 그래서 부동소수점 덧셈은 결합법칙이 성립하지 않고, 합계를 구할 때 작은 것부터 더하는 편이 정확합니다.
같은 숫자, 다른 바이트 순서
0x12345678 을 리틀엔디안과 빅엔디안으로 각각 4바이트로 만들어 06-endian.txt 에 남기세요. 이 기계는 어느 쪽인지도 함께.
struct.pack('<I', n).hex() 와 struct.pack('>I', n).hex(), 그리고 sys.byteorder.
리틀엔디안은 78563412 로 뒤집혀 보입니다. 파일이나 네트워크로 숫자를 주고받을 때 이걸 안 맞추면 값이 엉뚱해집니다 — 네트워크 바이트 순서가 빅엔디안으로 정해져 있는 이유입니다.
2의 보수와 넘침
2**31 을 부호 있는 32비트로 읽으면 무엇이 되는지, -1 을 부호 없는 32비트로 읽으면 무엇이 되는지 확인해 07-int.txt 에 남기세요.
struct.unpack('<i', struct.pack('<I', 2**31))[0] 와 그 반대.
2**31 → -2147483648, -1 → 4294967295 가 나옵니다. 같은 비트를 어떻게 읽기로 했느냐의 차이일 뿐입니다.
다른 언어에서 정수가 갑자기 음수가 되는 사고(오버플로)의 정체가 이것입니다. 파이썬 정수는 알아서 커지지만, 그건 파이썬이 특별한 것입니다.
세 가지를 정리한다
08-notes.md 에 세 줄 이상. 캐시 밖 접근이 왜 느린지, 돈을 float 으로 다루면 안 되는 이유, 바이트 순서를 맞춰야 하는 때.
본문에 캐시, 소수, 순서 가 들어가야 합니다.