TLB
고친 사람 github-actions[bot]
TLB 는 프로그램이 부른 주소를 메모리의 실제 주소로 바꾼 결과를 곁에 적어 둡니다. 그래서 같은 곳을 다시 읽거나 쓸 때는 바꾸는 일을 되풀이하지 않습니다. 주소를 바꾸려면 메모리에 놓인 표를 읽어야 합니다. TLB 가 그 읽기를 대부분 없앱니다.
쉽고 빠른 이해
TLB 는 방금 한 주소 바꾸기의 답을 적어 두는 작은 저장소입니다. 같은 곳을 다시 읽거나 쓰면 메모리에 놓인 표를 읽지 않고 적어 둔 답을 바로 씁니다.
표를 안 읽고 끝내는 것이 중요한 까닭은 프로그램이 메모리를 읽거나 쓸 때마다 주소를 바꿔야 하기 때문입니다. 바꿀 때마다 표를 읽으면 메모리 읽기 한 번이 여러 번으로 불어납니다.
어떻게 도는가:
- 주소가 나오면 TLB 부터 뒤집니다
- 있으면 실제 주소를 바로 얻습니다
- 없으면 표를 읽어 답을 구하고, 그 답을 TLB 에 넣어 둡니다
대가도 있습니다. TLB 는 작아서, 프로그램이 넓은 범위를 띄엄띄엄 읽으면 번번이 빗나갑니다. 담고 있는 것이 복사본이라는 문제도 있습니다. 프로세스가 바뀌거나 표가 고쳐지면 누군가 그 복사본을 비워 줘야 합니다.
상세
번호를 하나 찾으려면 두꺼운 명부를 뒤져야 해서, 찾은 번호는 쪽지에 적어 모니터 가장자리에 붙여 둡니다. 붙일 자리는 몇 칸뿐입니다. 새 쪽지를 붙이려면 옆의 것을 떼어냅니다. 며칠 같은 사람들하고만 통화할 때는 붙은 쪽지로 다 됩니다. 걸 때마다 다른 사람이면 쪽지는 붙이자마자 떼어내게 됩니다.
TLB 가 하는 것이 그 쪽지 붙이기입니다.
먼저 낱말부터 세웁니다. 프로그램이 코드 안에서 부르는 주소를 가상 주소라고 합니다. 메모리에 꽂혀 있는 칸을 가리키는 주소, 곧 앞에서 말한 실제 주소를 물리 주소라고 합니다. 둘을 이어 주는 표가 페이지 테이블입니다. 이 표도 메모리 안에 놓여 있습니다.
두 주소는 한 칸씩 짝지어지지 않습니다. 일정한 크기로 자른 덩어리끼리 짝지어집니다. 가상 쪽의 덩어리는 페이지, 물리 쪽의 덩어리는 프레임이라고 부릅니다.
그러니 주소를 바꾼다는 것은 「이 페이지가 어느 프레임에 올라앉아 있나」를 찾는다는 뜻입니다. 이것을 주소 변환이라고 합니다.
TLB(Translation Lookaside Buffer, 변환 색인 버퍼)는 방금 한 변환의 답을 담아 두는 작은 저장소입니다. 우리말로는 주소 변환 캐시라고 옮기기도 하지만 실무에서는 대개 TLB 라고 부릅니다. 담기는 답 하나는 페이지 하나와 그 페이지가 올라앉은 프레임의 짝입니다.
담을 수 있는 칸은 수십에서 수백 개쯤입니다. 이 작은 저장소는 MMU(Memory Management Unit, 메모리 관리 장치)라는 주소 변환 전담 하드웨어 안에 있습니다. MMU 는 다시 CPU(Central Processing Unit, 중앙처리장치) 안에 얹혀 있습니다. 그래서 TLB 를 뒤질 때는 메모리까지 다녀오지 않고 프로세서 안에서 끝납니다.
flowchart TD
subgraph CPU["CPU"]
subgraph MMU["MMU · 주소 변환 전담 하드웨어"]
TLB["TLB · 방금 한 변환의 답"]
end
end
subgraph MEM["메모리"]
PT["페이지 테이블"]
FR["프레임"]
end
TLB -.->|"빗나가면 여기까지 간다"| PT
PT -->|"어느 프레임인지 적혀 있다"| FR
변환 한 번에 드는 메모리 읽기
변환 한 번이 메모리를 몇 번 읽는지를 봅니다. 그러려면 페이지 테이블의 생김새부터 봐야 합니다.
페이지 테이블은 평평한 표 한 벌이 아닙니다. 프로그램이 쓰는 주소 범위는 대부분 비어 있어서, 평평한 표를 두면 아무도 안 쓰는 구간의 줄까지 전부 갖고 있어야 합니다.
표를 층으로 쌓으면 이 문제가 풀립니다. 가상 주소는 비트 묶음입니다. 그 가운데 위쪽 비트를 층 수만큼 조각냅니다. 조각 하나가 그 층에서 몇 번째 줄인지를 고릅니다.
고른 줄은 다음 층의 표를 가리킵니다. 맨 아래 층에 가서야 프레임 번호가 나옵니다. 안 쓰는 구간은 위층 줄을 비워 두면 그 아래 표를 아예 안 만들어도 됩니다. 표가 작아지는 까닭이 이것입니다.
남은 아래쪽 비트는 페이지 안에서 몇 번째 칸인지를 말합니다. 프레임 번호에 이 페이지 안 위치를 붙이면 물리 주소가 됩니다.
표는 작아졌지만 읽는 횟수가 늘었습니다. 층이 셋인 표라면 변환 한 번에 메모리를 세 번 읽습니다. 그러고 나서야 원래 읽고 싶던 값을 한 번 더 읽습니다.
flowchart TD
subgraph VA["가상 주소"]
P1["조각 1"]
P2["조각 2"]
P3["조각 3"]
OFF["페이지 안 위치"]
end
P1 -->|"① 메모리 읽기"| L1["1층 표의 한 줄"]
L1 -->|"다음 층 표를 가리킨다"| L2["2층 표의 한 줄"]
P2 -->|"② 메모리 읽기"| L2
L2 -->|"다음 층 표를 가리킨다"| L3["3층 표의 한 줄 · 프레임 번호"]
P3 -->|"③ 메모리 읽기"| L3
L3 --> PA["물리 주소"]
OFF --> PA
PA -->|"④ 메모리 읽기"| VAL["원래 읽고 싶던 값"]
이 표 읽기가 메모리를 읽거나 쓸 때마다 붙습니다. 값 하나를 읽으려고 표를 여러 번 읽는 셈이라, 변환이 메모리 읽기를 몇 곱절로 불립니다. 이 되풀이를 없애려고 둔 것이 TLB 입니다.
TLB 를 뒤지는 순서
프로그램이 가상 주소를 내면 하드웨어는 페이지 테이블보다 TLB 를 먼저 뒤집니다. 찾던 페이지의 답이 거기 있는 것이 TLB 히트이고, 없는 것이 TLB 미스입니다. 미스일 때 표를 층층이 걸어 내려가는 것을 페이지 테이블 워크라고 부릅니다.
flowchart TD
A["프로그램이 가상 주소를 낸다"] --> B{"TLB 에 이 페이지의 답이 있나"}
B -->|있다| C["프레임 번호를 얻는다"]
B -->|없다| D["페이지 테이블을 층마다 읽는다"]
D --> E["얻은 답을 TLB 에 넣는다"]
E --> C
C -->|"페이지 안 위치를 붙인다"| F["물리 주소로 메모리를 읽거나 쓴다"]
걸어서 얻은 답은 쓰고 버리지 않고 TLB 에 넣어 둡니다. 그래서 같은 페이지를 다음에 읽을 때는 맞습니다.
표를 걷는 것은 하드웨어가 알아서 하기도 하고, 예외를 내서 커널에게 맡기기도 합니다. 어느 쪽이든 미스 한 번은 히트보다 훨씬 비쌉니다. 걷다가 그 페이지에 아직 물리 메모리가 붙어 있지 않다는 것을 알게 되면 페이지 폴트가 납니다. 그러면 커널이 끼어들어 메모리를 붙여 줍니다.
flowchart TD
M["TLB 미스"] --> W{"누가 표를 걷나"}
W -->|"하드웨어가 알아서"| K["표를 층마다 걷는다"]
W -->|"예외를 내서 커널이"| K
K --> Q{"그 페이지에 프레임이 붙어 있나"}
Q -->|있다| T["답을 TLB 에 넣고 끝낸다"]
Q -->|없다| PF["페이지 폴트 · 커널이 메모리를 붙인다"]
PF --> K
TLB 한 칸의 구성
한 칸은 「어느 페이지가 어느 프레임인가」 하나를 담습니다. 여기에 이 칸을 믿어도 되는지와 무엇을 해도 되는지가 함께 붙습니다.
| 담기는 값 | 무엇을 말하나 |
|---|---|
| 페이지 번호 | 이 칸이 어느 페이지의 답인지 |
| 프레임 번호 | 그 페이지가 올라앉은 물리 메모리 덩어리의 번호 |
| 유효 표시 | 이 칸을 지금 믿어도 되는지 |
| 권한 표시 | 읽기·쓰기·실행 가운데 무엇이 허락됐는지 |
권한 표시가 함께 있어서 하드웨어는 주소를 바꾸는 김에 권한도 봅니다. 쓰면 안 되는 페이지에 쓰려고 하면 TLB 를 뒤지는 동안 걸려서 예외가 납니다.
작은데도 잘 맞는 이유
칸은 수백 개도 안 됩니다. 그런데도 대부분의 접근이 맞습니다. 프로그램이 짧은 동안에는 몇 안 되는 페이지만 되풀이해 읽기 때문입니다. 이 성질을 참조 지역성이라고 합니다.
반대로 잘 빗나가는 접근도 성질이 뚜렷합니다. 넓은 범위를 띄엄띄엄 읽으면 읽을 때마다 다른 페이지라 칸이 금세 밀려납니다. 큰 표를 무작위 순서로 훑는 조회가 그렇습니다.
잘 맞나 못 맞나를 가르는 잣대가 TLB 가 덮는 범위입니다. 칸 수에 페이지 크기를 곱한 값입니다. 프로그램이 짧은 동안 읽고 쓰는 범위가 그보다 넓으면 미스가 잦아집니다.
페이지를 크게 잡는 거대 페이지가 이 범위를 넓히는 수단입니다. 칸 하나가 덮는 넓이가 커지니 같은 칸 수로 더 넓은 범위를 덮습니다.
문맥 교환과 TLB 비우기
같은 가상 주소라도 프로세스마다 가리키는 프레임이 다릅니다. 그래서 문맥 교환으로 다른 프로세스가 올라오면 TLB 에 남아 있던 답은 남의 답이 됩니다. 그대로 두면 엉뚱한 메모리를 읽으므로 비워야 합니다. 이렇게 비우는 것이 무효화입니다.
flowchart TD
subgraph PA["프로세스 A"]
APG["페이지 5"] --> AFR["프레임 11"]
end
subgraph PB["프로세스 B"]
BPG["페이지 5"] --> BFR["프레임 27"]
end
TLBE["TLB 에 남아 있는 칸 · 페이지 5 → 프레임 11"]
TLBE -.->|"지금 도는 것은 B 다 · 남의 답"| PB
비우는 방법은 둘입니다. 통째로 비우면 명령 몇 개로 끝납니다. 대신 새 프로세스는 빈 TLB 로 시작해 한동안 미스를 겪습니다.
다른 방법은 칸마다 주인 번호를 붙여 두는 것입니다. 이 번호를 ASID(Address Space Identifier, 주소 공간 식별자)라고 합니다. 번호가 다르면 남의 답으로 보고 그냥 지나칩니다. 그러면 비우지 않고도 여러 프로세스의 답을 같이 들고 있을 수 있습니다.
커널 코드처럼 모든 프로세스가 같은 곳을 보는 페이지는 프로세스가 바뀌어도 답이 그대로입니다. 이런 페이지를 전역 페이지로 표시해 두면 비울 때 남겨 둡니다.
코어가 여럿이면 할 것이 하나 더 붙습니다. 코어마다 자기 TLB 를 들고 있습니다. 이 복사본들은 CPU 캐시와 달리 하드웨어가 서로 맞춰 주지 않습니다.
그래서 커널이 페이지 테이블을 고치면 그 표를 같이 쓰는 다른 코어에게 「네 TLB 의 그 칸을 버려라」고 직접 알려야 합니다. 이 알림이 TLB 슛다운(TLB shootdown)입니다.
sequenceDiagram
participant C0 as 코어 0
participant C1 as 코어 1
participant C2 as 코어 2
C0->>C0: 페이지 테이블을 고친다
C0->>C1: 네 TLB 의 그 칸을 버려라
C0->>C2: 네 TLB 의 그 칸을 버려라
C1-->>C0: 버렸다
C2-->>C0: 버렸다
Note over C0: 답이 다 와야 그때 진행한다
백엔드 개발자가 이것을 떠올릴 때
평소에는 잊어도 됩니다. TLB 는 켜고 끄는 물건이 아니라 CPU 가 도는 내내 쓰는 부품입니다. 비우고 채우는 것은 하드웨어와 커널이 합니다. 애플리케이션 코드에는 TLB 를 가리키는 문법이 없습니다.
떠올릴 만한 때는 둘입니다. 하나는 메모리를 크게 잡아 두고 넓게 흩어 읽는 프로그램을 다룰 때입니다. 큰 인메모리 캐시나 큰 힙을 쓰는 서비스에서 거대 페이지 설정이 성능 항목으로 나오는 까닭이 이것입니다.
다른 하나는 프로세스를 잘게 나눠 문맥 교환이 잦은 구성을 다룰 때입니다. 교환이 잦으면 그때마다 TLB 를 비우거나 주인 번호를 갈아 끼웁니다. 올라온 쪽은 빈 TLB 로 다시 시작합니다. 문맥 교환이 눈에 보이는 시간보다 비싼 이유 가운데 하나가 이 뒷정리입니다.
관련 항목
TLB 가 한 부품으로 들어가는 주소 변환 장치
가상 메모리 · MMU · 페이지 테이블 · 페이지 테이블 엔트리 · 페이지 테이블 워크 · 주소 공간
TLB 가 다루는 주소와 그 단위
가상 주소 · 물리 주소 · 페이지 · 프레임 · 페이지 크기 · 거대 페이지
TLB 를 뒤진 결과와 그 결과를 재는 지표
TLB 히트 · TLB 미스 · 적중률 · 페이지 폴트 · 참조 지역성
TLB 를 비우게 만드는 사건과 그 방법
문맥 교환 · 무효화 · ASID · 전역 페이지 · TLB 슛다운 · 스래싱
같은 원리로 값을 곁에 두는 다른 캐시
CPU 캐시 · 캐싱 · 캐시 라인 · 페이지 캐시 · 캐시 미스
TLB 를 품는 하드웨어와 그것을 부리는 소프트웨어
다른 이름: Translation Lookaside Buffer · 변환 색인 버퍼 · 주소 변환 캐시