Lobsters

Janet on x32: 32-bit Pointers, 64-bit Speed, 25% Less RAM

Janet을 x32로 빌드하기 — 메모리는 줄이고 64비트 명령어는 유지합니다

Linux x32 ABI는 64비트 명령어 세트를 유지하면서 포인터를 32비트로 줄입니다. Janet 실험에서 메모리 사용량은 벤치마크별로 8~32% 감소했고 실행 시간은 대체로 비슷했습니다. 다만 x32 지원을 제공하는 배포판이 드물고, 구형 32비트 모드인 -m32는 성능이 크게 떨어졌습니다.

AI 요약

Linux x32 ABI는 64비트 시스템에서 32비트 포인터를 쓰는 실행 환경입니다. 포인터가 많은 힙에서는 메모리를 아끼고, 더 많은 데이터가 캐시에 들어가 실행 속도에도 이득을 볼 수 있습니다. 글쓴이는 Janet을 x32로 빌드해 메모리와 실행 시간을 비교하고, 배포판의 지원 부족과 빌드 과정에서 마주친 문제를 기록합니다.

x32와 일반 32비트 빌드

글쓴이는 먼저 Arch 계열 CachyOS에서 Janet을 -m32로 빌드했습니다. 가짜 cc 실행 파일을 PATH 앞에 두고, Janet 라이브러리도 -m32 -msse2 -mfpmath=sse 플래그를 받게 했습니다. 200만 개 요소를 만드는 테스트에서 최대 메모리는 145,196KB에서 113,716KB로 줄었지만 실행 시간은 0.30초에서 0.50초로 늘었습니다. 2천만 개로 늘린 테스트에서도 메모리는 약 22% 줄고 시간은 3.31초에서 5.00초로 늘었습니다. Janet 테스트 98개 실행 시간도 0.45초에서 0.72초로 증가했습니다.

이 결과는 x32의 성능을 보여주지 않습니다. -m32는 구형 32비트 x86 실행 모드로, 글에 따르면 레지스터 8개만 씁니다. x32의 목적은 64비트 명령어 세트를 쓰면서 포인터만 줄이는 -mx32입니다. 이를 쓰려면 커널이 CONFIG_X86_X32_ABI를 켜고 시스템 호출 진입점을 제공해야 합니다. 글쓴이는 Arch와 Ubuntu에서 지원을 바로 쓸 수 없었고, Ubuntu 20.04 서버에서 커널 설정 CONFIG_X86_X32=y를 확인해 비교를 진행했습니다.

Janet 빌드와 측정 결과

Janet의 헤더는 __ILP32__를 확인하지 않아 x32에서도 64비트 값 레이아웃을 고를 수 있었습니다. 글쓴이는 src/include/janet.h의 조건문에 !defined(__ILP32__)를 추가했습니다. 또 -mx32 빌드에서 FFI 빌드 테스트가 실패해 -DJANET_NO_FFI를 지정했습니다. Janet과 Spork, 테스트용 패키지를 각각 같은 커밋과 최적화 플래그로 빌드해 비교했습니다.

declarative-dsls 테스트는 64비트 빌드에서 2.42초, 61,668KB를 기록했고 x32 빌드에서는 2.36초, 53,472KB였습니다. 이어 단어 분리·집계, 레코드 그룹화, 이진 트리 생성과 순회, PEG 파싱을 각각 여러 차례 실행했습니다. 메모리는 words에서 약 42MB에서 32MB로, tree에서 약 50MB에서 35MB로, parse에서 약 58MB에서 44MB로 줄었습니다. records는 약 138MB에서 127MB로 감소했습니다. 실행 시간은 words에서 약 0.6초가 0.67초로 늘었고, records는 약 6.3~6.7초에서 5.8~6.0초로 줄었습니다. tree와 parse도 대체로 비슷한 수준이었습니다.

글쓴이는 측정 전체에서 x32가 메모리를 8~32% 아꼈고, 속도는 13% 느린 경우부터 10% 빠른 경우까지 분포했다고 정리합니다. Janet은 nanboxing으로 값 자체를 이미 8바이트에 담으므로, 포인터 폭 감소의 효과가 제한적인 사례라고 설명합니다. Mastodon을 x32로 배포했을 때 메모리 사용량이 650MB에서 350MB로 줄었다는 별도 실험도 소개합니다. 다만 그 사례는 글쓴이가 인용한 경험이며, Janet 벤치마크와 같은 조건의 비교는 아닙니다.

런타임 객체 크기와 배포 지원

Janet 힙 객체 헤더는 가비지 컬렉터를 위해 16바이트를 씁니다. 글쓴이는 그중 플래그 4바이트, 패딩 4바이트, 다음 객체를 가리키는 포인터 8바이트로 구성된다고 설명합니다. 타입별 필드도 문자열은 길이와 해시 8바이트, 튜플은 여기에 소스 위치 정보 8바이트를 더합니다. 배열은 개수·용량과 데이터 포인터를, 테이블은 개수·용량·삭제 항목 수와 포인터 필드들을 둡니다. 구조체와 테이블은 필드 배치를 바꾸면 8바이트를 줄일 여지가 있고, 코드에서 읽어 들인 튜플이 아니라면 소스 위치 정보를 생략하는 방안도 거론합니다. 다만 glibc malloc은 8바이트를 더하고 할당 크기를 16바이트 단위로 올림하므로, 실제 절감 폭은 객체마다 다릅니다.

글쓴이는 배포판이 -mx32 지원을 제공하고, 데몬과 유틸리티를 x32로 빌드하면 메모리를 아낄 수 있다고 제안합니다. 하지만 x32는 커널 설정과 라이브러리 지원이 필요하고 패키지 선택지도 제한적입니다. 글 끝에서는 Zig의 x32 크로스 컴파일과 mimalloc 같은 빠른 할당자를 쓸 때의 차이를 후속 질문으로 남깁니다.

Lobsters 반응

  • @alexrp — 참고로 Zig 0.17.0에 x32 전체 지원이 막 들어갔습니다. libc 없이 직접 시스템 호출을 쓰거나, glibc 또는 musl을 사용할 수 있습니다. CI에서도 네이티브 테스트를 합니다. Debian은 커널 명령행의 syscall.x32=y 설정을 아직 지원합니다. MIPS에서 이에 해당하는 N32 지원도 넣었지만, 그쪽에 관심을 보일 사람은 많지 않을 것 같습니다. 재미로 해본 작업이자 CI에서 잘못된 포인터 크기 가정을 잡으려는 목적이었는데, 실제로 유용해진다면 좋겠습니다.
    • @veqq — 좋네요! 배포를 훨씬 쉽게 해줄 zig cc -target x86_64-linux-muslx32 크로스 컴파일을 지금 시험하고 있습니다!
  • @jmmv — 이 내용을 2년 전에 좀 더 자세히 썼습니다. (네, 네, “별로인 생성형 AI 표지 이미지”라고 하시겠지만, 글 내용은 전부 직접 조사하고 썼습니다.) amd64가 나왔을 때 더 큰 포인터가 메모리를 더 쓴다는 우려가 있었던 토론을 기억합니다. 자원이 계속 늘어날 거라고 보고 그런 우려를 중요하지 않게 여겼던 점이 흥미롭습니다. 살 수 있는 자원에 한계가 생긴 지금, 그 선택을 다시 살펴보자는 데 동의합니다.
    • @mort — 글을 쓰는 분도 그 표지 이미지 때문에 사람들이 블로그 글을 저품질 글이라고 생각한다고 아는데, 왜 그대로 두나요? 글을 이야기할 때마다 표지 이미지를 미리 변호하는 걸 좋아하나요?
  • @vpr — LWN에서 x32를 또 없애자는 제안을 다루며 Corbet이 이렇게 썼습니다. “메모리 사용량이 늘어 기본적인 ‘hello world’ 앱조차 32비트 포인터가 허용하는 가상 주소 공간 2~3GB에 넣기 어렵습니다.” 칩 부족이 메모리를 아끼려는 움직임을 다시 불러오는 긍정적 부수 효과를 낼지도 모르겠습니다. 메모리 용량이 계속 두 배로 늘었다고 해서 소프트웨어가 그만큼 메모리를 써야 하는 건 아닙니다. “사용하지 않는 RAM은 낭비된 RAM”이라는 말이 있더라도요.
    • @jaredkrinke — “hello world 앱이 32비트 주소 공간에 들어가지 않는다”는 말은 농담인가요? 그렇게 생각했는데, 실제로 물어보기가 겁나네요.
    • @veqq — 좋은 글입니다. Arch에서 빌드가 실패하고 나서야 -mx32를 알게 됐고, 컴파일된 지원이 무엇을 뜻하는지 알아보려 했습니다. 조금 늦었을지 모르지만 x32 사용을 늘리고, Mastodon에서 @hailey가 본 큰 성능 이득이 언제 나타나는지도 확인하고 싶습니다. 그래서 Linux 배포판이 -mx32를 지원하고 데몬과 유틸리티를 메모리 효율적으로 빌드하자는 요청을 글에 추가했습니다. 플래그 하나면 됩니다!
    • @jcelerier — 저는 예전에 x32를 쓸 때마다 성능이 정말 좋았습니다. 10~25% 정도 빨라졌습니다. 새 AVX10 CPU에서 레지스터를 더 쓰는 x32+ 같은 방식이 가능할지 궁금합니다.

원문: Alex Alejandre / 번역·요약: Trawling