Skip to content

인라인 캐싱과 특수화

인라인 캐싱은 어떤 연산이 지난번에 무엇이었는지를 그 명령어 자리에 기록해 두고 다음번에 재사용하는 기법이고, 특수화는 그 기록을 근거로 명령어 자체를 전용 명령으로 바꿔치기하는 것이다.

동적 타입 언어의 비용은 연산 자체가 아니라 매번 무엇을 해야 하는지 다시 알아내는 데 있다.

def add(a, b):
return a + b

a + b 한 줄을 실행하려면 a의 타입을 보고, 그 타입의 덧셈 구현을 찾고, 없으면 b의 타입에서 역방향 연산을 찾고, 정수라면 크기를 확인해 알맞은 경로로 간다. ab가 언제나 작은 정수인 함수에서도 이 과정을 매 호출마다 반복한다. 정적 타입 언어라면 컴파일 시점에 add 기계어 명령 하나로 끝났을 일이다.

출발점은 같은 자리에는 대체로 같은 타입이 온다는 관찰이다. 이 성질을 타입 안정성(type stability)이라 하고, 실제 프로그램에서 대부분의 연산 자리는 한두 개의 타입만 본다.

아래 내용은 CPython main(3.16.0a0)의 Python/specialize.c, Python/bytecodes.c 기준이고, 실행 결과는 3.14.6이다.

“인라인”이라는 말은 캐시가 별도 자료구조가 아니라 바이트코드 스트림 안에 자리를 차지한다는 뜻이다. p.x를 컴파일해서 캐시까지 함께 보면 이렇다.

LOAD_FAST_BORROW 0 (p)
LOAD_ATTR 0 (x)
CACHE (counter)
CACHE (version)
CACHE
CACHE (keys_version)
CACHE
CACHE (descr)
CACHE
CACHE
CACHE
RETURN_VALUE

LOAD_ATTR 하나에 CACHE 9칸이 딸려 있다. 이 칸들은 실행되지 않는다. 명령어 포인터가 건너뛰고, 대신 LOAD_ATTR 구현이 자기 뒤의 이 자리를 직접 읽고 쓴다.

구조는 C에 선언되어 있다.

typedef struct {
_Py_BackoffCounter counter;
uint16_t type_version[2];
union {
uint16_t keys_version[2];
uint16_t dict_offset;
};
uint16_t descr[4];
} _PyLoadMethodCache;

counter 1 + type_version 2 + keys_version 2 + descr 4 = 9칸. 위에서 본 CACHE 개수와 정확히 맞는다. 그만큼 코드 크기도 늘어난다. LOAD_ATTR 하나에 9칸, 18바이트가 붙는 셈이다.

캐시를 코드 옆에 두는 이유는 지역성이다. 명령어를 읽으면 캐시도 같은 캐시 라인에 딸려 들어온다. 명령어마다 해시 테이블을 조회하는 방식과 비교하면 차이가 크다.

모든 자리를 처음부터 특수화할 수는 없다. 한 번만 실행되는 코드에 특수화 비용을 쓰면 손해다. 그래서 카운터를 두고 충분히 자주 실행된 자리만 특수화한다.

bytecodes.c에 그 지점이 있다.

specializing op(_SPECIALIZE_TO_BOOL, (counter/1, value -- value)) {
if (ADAPTIVE_COUNTER_TRIGGERS(counter)) {
next_instr = this_instr;
_Py_Specialize_ToBool(value, next_instr);
DISPATCH_SAME_OPARG();
}
ADVANCE_ADAPTIVE_COUNTER(this_instr[1].counter);
}

카운터가 0에 닿으면 _Py_Specialize_*()를 부르고, 그 함수가 명령어를 제자리에서 덮어쓴다. 그리고 같은 자리를 다시 실행한다(DISPATCH_SAME_OPARG). 다음번부터는 특수화된 명령이 돈다.

카운터는 단순한 감소 카운터가 아니라 지수 백오프를 쓴다.

/* The 16-bit counter is structured as a 13-bit unsigned 'value'
and a 3-bit 'backoff' field. */
static const uint16_t value_and_backoff_next[] = {
MAKE_VALUE_AND_BACKOFF(1, 1),
MAKE_VALUE_AND_BACKOFF(6, 2),
MAKE_VALUE_AND_BACKOFF(30, 3),
MAKE_VALUE_AND_BACKOFF(126, 4),
MAKE_VALUE_AND_BACKOFF(508, 5),
MAKE_VALUE_AND_BACKOFF(2052, 6),
MAKE_VALUE_AND_BACKOFF(8190, 6),
};

특수화를 시도했다가 실패하면(타입이 들쭉날쭉해서 어느 전용 명령도 맞지 않으면) 다음 시도까지의 간격을 1, 6, 30, 126, 508, 2052로 늘린다. 특수화가 안 되는 자리에 계속 비용을 쓰지 않기 위해서다. 16비트 안에 13비트 값과 3비트 백오프 단계를 함께 담는다.

특수화된 명령은 가드로 시작한다

Section titled “특수화된 명령은 가드로 시작한다”

특수화는 “이 자리는 늘 정수더라”라는 추측이다. 추측이 틀릴 수 있으므로 전용 명령은 반드시 검사부터 한다.

macro(BINARY_OP_ADD_INT) =
_GUARD_TOS_INT + _GUARD_NOS_INT + unused/5
+ _BINARY_OP_ADD_INT + _POP_TOP_INT + _POP_TOP_INT;
op(_GUARD_TOS_INT, (value -- value)) {
PyObject *value_o = PyStackRef_AsPyObjectBorrow(value);
EXIT_IF(!_PyLong_CheckExactAndCompact(value_o));
}
pure op(_BINARY_OP_ADD_INT, (left, right -- res, l, r)) {
assert(PyLong_CheckExact(left_o));
assert(PyLong_CheckExact(right_o));
res = _PyCompactLong_Add((PyLongObject *)left_o, (PyLongObject *)right_o);
...
}

_GUARD_TOS_INT_GUARD_NOS_INT가 스택 위 두 값이 작은 정수인지 확인하고, 아니면 일반 경로로 빠진다(역최적화, deoptimization). 통과하면 _PyCompactLong_Add를 바로 부른다. 타입 슬롯 조회도, 역방향 연산 탐색도 없다. 일반 BINARY_OP가 하던 여러 단계의 판단이 가드 두 개로 압축된 것이다.

살아 있는 인터프리터에서 확인할 수 있다.

import dis
def add(a, b):
return a + b
dis.dis(add, adaptive=True)
# LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
# BINARY_OP 0 (+)
# RETURN_VALUE
for _ in range(500):
add(1, 2)
dis.dis(add, adaptive=True)
# LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (a, b)
# BINARY_OP_ADD_INT 0 (+) ← 바뀌었다
# RETURN_VALUE

소스도 바이트코드 객체도 그대로인데 실행된 명령이 바뀌었다. RESUMERESUME_CHECK로 바뀐다.

타입이 바뀌면 다시 특수화된다.

for _ in range(500): add(1, 2)
# → BINARY_OP_ADD_INT
for _ in range(500): add("x", "y")
# → BINARY_OP_ADD_UNICODE

BINARY_OP만 그런 것이 아니다.

class P:
def __init__(self): self.x = 1
def get(p): return p.x
# 500회 후: LOAD_ATTR → LOAD_ATTR_INSTANCE_VALUE
def target(x): return x
def caller(): return target(1)
# 500회 후: CALL → CALL_PY_EXACT_ARGS

LOAD_ATTR_INSTANCE_VALUE는 “이 타입의 이 속성은 인스턴스 딕셔너리의 몇 번째 슬롯”이라는 정보를 캐시에서 읽어 바로 접근한다. 속성 조회 프로토콜(__getattribute__, 디스크립터, MRO 탐색)을 전부 건너뛴다.

opcode 243개 중 90개가 특수화 명령인 것이 이 기법의 규모다.

한 자리에 여러 타입이 오면 이렇게 된다.

def add2(a, b): return a + b
vals = itertools.cycle([(1,2), (1.0,2.0), ("a","b")])
for _ in range(1000):
x, y = next(vals)
add2(x, y)
# → BINARY_OP_ADD_FLOAT

CPython의 인라인 캐시는 단형적(monomorphic)이다. 한 자리에 한 가지 추측만 담는다. 타입이 섞이면 마지막에 관찰된 것으로 특수화됐다가 가드에서 실패하기를 반복한다. 가드를 통과하지 못하면 검사만 하고 일반 경로로 가므로, 타입이 계속 바뀌는 자리는 특수화가 없을 때보다 오히려 느려질 수 있다. 백오프 카운터가 시도 간격을 늘려 이 손실을 제한하지만 없애지는 못한다.

다형적 인라인 캐시(PIC, 여러 개의 추측을 나란히 담는 것)를 쓰는 구현도 있지만, CPython은 캐시가 코드 스트림 안에 있어 크기를 늘리기 어렵고 대부분의 자리가 단형적이라는 관찰에 기대어 단순한 쪽을 택했다.

특수화는 명령어 하나 단위의 최적화다. 그 위에 두 층이 더 있다.

Tier 2 (마이크로 연산). 자주 실행되는 구간을 감지해 명령어를 더 잘게 쪼갠 uop 열로 펼친다. 위에서 본 _GUARD_TOS_INT, _BINARY_OP_ADD_INT가 그 uop이다. 명령어 경계를 넘어 최적화할 수 있어서, 연속된 정수 연산에서 중복 가드를 지우는 것 같은 일이 가능해진다. uop 종류는 653개다.

JIT. uop 열을 기계어로 컴파일한다(Python/jit.c).

특수화는 이 두 층의 토대다. 가드가 없으면 uop을 최적화할 근거도, 기계어로 굳힐 근거도 없다. “여기는 정수다”라는 추측이 있어야 그 아래 최적화가 성립한다.

관련된 별개의 최적화로 _PyStackRef가 있다. 스택 위 참조에 소유/차용을 태그 비트로 표시해 참조 카운트 조작 자체를 없애는 것으로, 위 디스어셈블에 나온 LOAD_FAST_BORROW가 그 산물이다.


참고