Plush 인터프리터, Rust enum을 64비트 태그 값으로 바꿔 17% 가속
- Plush는 16바이트인 Rust Value enum을 u64 새 타입의 저비트 태깅 표현으로 교체해 인터프리터 성능을 17% 높였음
- 기존 enum은 각 변형이 64비트 데이터와 작은 태그만 필요해도 정렬 제약 때문에 128비트를 차지했고, 값 배열에서는 그만큼 빈 공간이 누적됐음
- 64비트 시스템의 힙 포인터는 보통 8바이트 정렬이라 하위 3비트가 0인 점을 이용해 포인터에 타입 정보를 넣고, 정수도 전체 64비트 범위가 드물다는 가정 아래 하위 비트를 태그로 사용함
- 정수 태그를 0으로 두면 시프트한 정수의 덧셈과 뺄셈이 추가 언패킹 없이 일반 add, sub 명령으로 유지되지만, 부동소수점 인코딩과 디코딩에는 여러 비트 연산이 필요함
- 작성자는 Claude와 함께 u64 기반 값 표현을 설계했고, 인터프리터 루프에서 자주 호출하는 접근 메서드 대부분에 항상 인라인을 지정했음
Hacker News opinions
비트 연산을 직접 다루는 쪽이 Rust enum보다 읽고 유지보수하기 훨씬 나쁜 것 아닌가?
인터프리터는 수정하는 시간보다 실행하는 시간이 훨씬 길잖아. 태그 비트도 복잡한 수준은 아니고, 실행 경로가 뜨거우면 기계 쪽 사정을 우선할 만함.
작성자가 편의 메서드로 추상을 다시 만들었으니 대부분의 코드는 내부 비트 조작을 몰라도 됨. Rust의 enum 추상이 모든 특수 사례에서 최적일 수는 없고, 여기서는 측정값으로 손해와 이득을 확인한 경우임.
레지스터 하나에 들어가야 한다는 요구를 정확성 조건에 넣으면, 소스 코드 인지 부담이 적다는 이유만으로는 선택할 수 없음.
뜨거운 경로의 고도 최적화 코드는 대체로 읽기 좋지 않음. 성능 때문에 유지보수성을 일부 포기할 때가 있음.
triomphe의 ArcUnion을 보고 64비트 union 전용 crate로 분리하는 방법도 있음. unsafe를 거기에 가두고 Miri로 검사하면 안전한 64비트 타입과 match를 함께 쓸 수 있는데, 포인터 provenance를 보존하는 조정 함수를 써야 함.
Rust 컴파일러가 이런 걸 자동으로 못 한다는 건 좀 씁쓸함. enum을 이런 비트 덩어리로 바꿔서 17%가 난다는 건 추상화 비용이 꽤 크다는 뜻처럼 보임.
컴파일러가 NaN boxing을 하려고 Float 변형에서 특정 비트 패턴을 금지해도 되는지부터 합의가 안 됨. 그런 의미 변경을 컴파일러가 멋대로 해서는 안 됨.
i64, f64, 여러 원시 포인터는 각각 64비트를 온전히 쓰니 64비트 합 타입에 마법처럼 같이 넣을 수 없음. 정렬 보장 포인터나 값 범위를 타입으로 표현하게 개선할 수는 있어도, 마스킹과 인코딩은 결국 개발자가 정해야 함.
제목은 컴파일러가 저수준 연산을 최적화하지 못했다는 얘기처럼 읽히지만, 실제로는 128비트 표현을 대부분 64비트에 담는 새 인코딩을 설계한 것임. 드문 값은 힙 할당과 포인터 간접 참조를 받아들였으니 컴파일러가 자동으로 찾아낼 종류의 최적화가 아님.
그래도 언어가 인코딩 스키마를 선언할 방법을 더 잘 제공한다면, 개발자가 raw u64를 직접 다루지 않고도 이 선택을 표현할 수는 있다고 봄.
정수 태그 비트를 0으로 두면 시프트된 정수끼리 덧셈과 뺄셈이 그대로 add, sub가 된다는 발상은 바로 써먹을 만함. OCaml도 63비트 정수를 씀.
17%가 전부 64비트 표현 덕분이라고 보기는 어려움. 새 add 구현은 흔한 경우를 먼저 검사하고 드문 경우를 별도 cold 함수로 보내는데, 예전 큰 match는 뜨거운 경로에서도 여러 분기를 거치는 형태임. 이 fast path는 enum에서도 적용할 수 있었고 성능에 크게 기여했을 듯함.