원문 캡처 · bernsteinbear.com
원문 캡처 · bernsteinbear.com

파이썬·루비·자바스크립트 같은 동적 언어의 객체는 이름과 값의 대응 관계로 볼 수 있다. 하지만 속성마다 해시 테이블을 조회하면 문자열 해싱과 여러 번의 메모리 접근이 필요하다. 런타임은 이를 줄이기 위해 객체의 속성 배치를 나타내는 ‘맵’이나 ‘셰이프’를 사용하고, 인라인 캐시로 조회 결과를 재활용한다.

해시 테이블 대신 객체의 모양을 기록한다

객체 속성을 해시 테이블에 저장하면 구현은 단순하지만, 자주 일어나는 속성 조회에 해싱과 탐색이 필요하다. 속성 저장 공간이 객체와 떨어져 있으면 메모리를 추가로 읽어야 한다. 이 때문에 원문은 a.b 같은 접근이 여러 번의 메모리 접근과 많은 CPU 명령을 유발할 수 있다고 설명한다.

대안은 각 객체의 속성 이름과 위치를 기록하는 맵이다. 맵은 객체의 배치를 나타내는 안정적인 식별자 역할을 한다. 객체에 새 속성이 추가되면 새 맵을 만들며, 맵은 불변인 트리 형태의 자료구조로 관리할 수 있다. 파이썬 예제에서는 속성 값이 별도 저장 공간에 놓이지만, C++이나 Rust 같은 언어로 만든 런타임에서는 보통 객체 헤더와 함께 저장한다.

이 방식은 속성 이름을 매번 문자열로 찾아야 한다는 문제를 인라인 캐시로 줄인다. 캐시는 이전에 본 맵과 속성의 인덱스를 저장한다. 다음 조회에서 객체의 맵이 캐시된 맵과 같으면 인덱스를 이용해 값을 바로 읽고, 다르면 속성 이름으로 인덱스를 찾은 뒤 맵과 인덱스를 갱신한다. 속성이 없을 때의 조회 결과는 이 예제에서 캐시하지 않는다.

맵이 불변이므로 예제는 필드 전체를 비교하는 대신 포인터 동일성으로 맵이 같은지 확인한다. 글의 구현은 인라인 캐시를 설명하기 위한 스케치이며, 실제 런타임은 빠른 경로를 인라인화하고 느린 경로를 분리하거나 JIT 컴파일러로 인터프리터의 처리 비용을 줄일 수 있다.

속성 순서와 클래스는 별개의 정보다

속성을 추가한 순서가 다르면 객체의 모양도 달라진다. 예를 들어 a 다음에 b를 추가한 객체와 반대 순서로 추가한 객체는 서로 다른 맵을 가질 수 있다. 초기화 과정에서 조건에 따라 속성을 추가하는 경우도 모양을 다양하게 만들 수 있다. 이 때문에 인라인 캐시가 자주 빗나가면 성능 저하가 생길 수 있으며, 원문은 자주 실행되는 경로에서 캐시 적중과 실패의 차이가 2배 이상일 수도 있다고 설명한다.

반대로 클래스가 달라도 속성 배치가 같으면 같은 모양을 공유할 수 있다. 이는 서로 다른 클래스의 객체가 같은 캐시를 활용하게 할 수 있지만, 모양만으로 클래스까지 알아낼 수는 없다는 뜻이기도 하다. 예컨대 인스턴스 변수 접근 메서드를 처리할 때는 클래스에 따른 메서드 조회와 객체 모양에 따른 변수 조회가 각각 필요하다.

원문은 객체가 대체로 작고(약 20개 미만의 필드) 필드가 지나치게 많으면 해시 테이블 방식으로 돌아갈 수 있다고 설명한다. 루비는 객체 안에 저장할 인스턴스 변수 수에 한도가 있으며, 이를 넘으면 모양이 복잡해진다.

관련 구현과 연구

CF 볼츠-테레이크(CF Bolz-Tereick)의 간단한 객체 모델은 클래스와 인스턴스 속성을 다루는 구현을 소개한다. 원문은 여기서 맵 관련 부분을 추려 설명하며, 별도로 맵을 다루는 절을 참고하라고 권한다.

속성뿐 아니라 컬렉션의 특성에 맞춰 저장 방식을 달리하는 연구도 있다. 볼츠-테레이크와 루카스 디크만(Lukas Diekmann), 로런스 트랫(Laurence Tratt)의 동적 언어 컬렉션을 위한 저장 전략은 빈 리스트나 작은 정수 리스트 같은 흔한 경우를 최적화하는 접근을 다룬다. 후속 연구인 타입 고정은 맵에 속성의 위치뿐 아니라 타입 정보도 기록해 검사 부담을 줄이는 방법을 제시한다.

관련 자료로는 SELF 구현, 실행 시점 타입 정보를 활용한 동적 호출 최적화, Truffle 언어 구현 프레임워크의 객체 저장 모델, 타입이 있는 객체 셰이프로 기본 블록 버전 관리 확장, 적시 데이터 구조, 동적 언어를 위한 효율적이고 스레드 안전한 객체, 동적 언어 병렬화와 내장 컬렉션 동기화, 임베디드 시스템의 자바스크립트 VM에서 숨겨진 클래스 그래프를 오프라인 최적화하는 방법, 동적 컬렉션 최적화가 소개된다.

원문은 맵 기반 객체 모델을 보여 주기 위해 작은 C++ 런타임도 만들었다고 밝히며, 코드를 공개했다. 이 구현은 오버플로 슬롯이 없고 버그가 있을 수 있는 스케치라고 설명한다. Skybison, V8, JSC 같은 런타임의 메모리 배치 모델을 보여 주는 예시다.

자바스크립트 객체 표기와 관련한 각주, 속성 순서가 캐시에 미치는 영향을 설명하는 각주도 참고 자료에 포함돼 있다. 인라인 캐시 구현을 더 살펴볼 자료로는 슬라바(Slava)의 모노모픽 인라인 캐시 설명과 자바스크립트 VM의 인라인 캐시 설명, 카이우(Caio)의 JavaScriptCore 인라인 캐시 분석, 매슈(Matthew)의 인라인 캐시가 단순한 캐시가 아닌 이유가 있다.

글은 셰이프가 다형성인 읽기를 ZJIT가 처리하지 못해 시작된 Rails 변경 요청과 optcarrot 변경 요청도 언급한다. ZJIT는 이후 다형성 셰이프를 더 잘 처리하게 됐지만, 원문은 이 문제가 여러 언어와 런타임에서 중요하다고 설명한다. 관련 연구 자료로는 최적화 정보를 애플리케이션 개발자에게 보여 주는 방법에 관한 자료와 자바스크립트 최적화 자료를 제시한다.

부록: 원문 예시

text
# The base object model code: class Base:     def __init__(self, cls, fields):         self.cls = cls         self._fields = fields     def read_attr(self, fieldname):         return self._read_dict(fieldname)     def write_attr(self, fieldname, value):         return self._write_dict(fieldname, value)     def _read_dict(self, fieldname):         return self._fields.get(fieldname, MISSING)     def _write_dict(self, fieldname, value):         self._fields[fieldname] = value MISSING = object() class Instance(Base):     def __init__(self, cls: "Class"):         super().__init__(cls, {}) class Class(Base):     def __init__(self, name, base_class, fields, metaclass):         super().__init__(metaclass, fields)         self.name = name         self.base_class = base_class OBJECT = Class(name="object", base_class=None, fields={}, metaclass=None) TYPE = Class(name="type", base_class=OBJECT, fields={}, metaclass=None) TYPE.cls = TYPE OBJECT.cls = TYPE 
text
A = Class(name="A", base_class=OBJECT, fields={}, metaclass=TYPE) obj = Instance(A) obj.write_attr("a", 1) assert obj.read_attr("a") == 1 obj.write_attr("b", 5) assert obj.read_attr("a") == 1 assert obj.read_attr("b") == 5 obj.write_attr("a", 2) assert obj.read_attr("a") == 2 assert obj.read_attr("b") == 5 
text
Point = Class(name="Point", base_class=OBJECT, fields={}, metaclass=TYPE) P = Instance(Point) P.write_attr("x", 5) P.write_attr("y", 6) # String API :( P.read_attr("x") # Always looking up index :( P.read_attr("x") # Every time :( P.read_attr("x") 
text
Foo = Class(name="Foo", base_class=OBJECT, fields={}, metaclass=TYPE) F = Instance(Foo) F.write_attr("x", 1) F.write_attr("y", 2) Bar = Class(name="Bar", base_class=OBJECT, fields={}, metaclass=TYPE) B = Instance(Bar) B.write_attr("x", 3) B.write_attr("y", 4) # Same shape/map! assert F.map is B.map # But different class! assert F.cls != B.cls