Lobsters

Making dns_erlang Fast: Binary Tricks for the BEAM

dns_erlang을 빠르게 만들기: BEAM의 바이너리 최적화 기법

DNSimple은 Erlang DNS 라이브러리 dns_erlang의 핫패스를 손봐 쿼리당 CPU 사용량을 12% 줄이고 포화 시 처리량을 14% 높였습니다. 단일 바이너리 누적, 매치 컨텍스트 유지, SWAR와 DNS 압축 키 변경 등 BEAM의 바이너리 처리 방식에 맞춘 최적화와 실제 워크로드 측정 결과를 설명합니다.

에디터 노트

이 사례에서 가장 설득력 있는 근거는 마이크로벤치마크가 아니라 실제 질의 형태를 본뜬 부하 테스트입니다. 쿼리당 CPU와 포화 처리량이 함께 개선됐고 라운드 편차도 1% 미만이었습니다. 반면 소문자 변환의 단위 성능 향상은 서버 전체에서 효과가 없었습니다. 최적화 대상의 비중을 측정해야 한다는 설명이 수치로 드러납니다. 댓글의 대소문자 정규화 의견은 DNS 이름 압축에서 반복되는 비교와 분할 비용을 더 줄일 가능성을 짚습니다. 다만 원문은 이름이 메시지에 쓰이기 전 검증을 생략해도 되는지 답하지 않습니다. 서버 내부 데이터가 생성 단계에서 유효하다는 전제와 입력 검증의 경계가 이 최적화에 남은 조건입니다.

AI 요약

DNSimple의 권한 DNS 서버 erldns는 캐시에서 처리하지 못한 질의를 받아 응답을 만듭니다. 모든 요청을 디코딩하고 응답을 인코딩하는 dns_erlang이 쿼리마다 두 차례 실행되므로, 이 라이브러리의 핫패스 비용을 줄이는 작업이 서버 처리량에 직접 영향을 줍니다. DNSimple은 몇 달 동안 라이브러리를 고친 뒤 실제 트래픽 형태를 본뜬 부하 테스트에서 쿼리당 CPU 사용량 12% 감소와 포화 시 초당 처리량 14% 증가를 확인했습니다. NIF나 C 코드는 쓰지 않았습니다.

BEAM 바이너리의 작동 방식

BEAM은 불변 데이터 의미를 유지하면서 컴파일러와 런타임이 안전하다고 판단하면 메모리를 재사용합니다. 바이너리 일부를 매칭하면 보통 원본을 가리키는 sub-binary가 만들어지고, 재귀 함수가 나머지 바이너리를 계속 매칭하면 컴파일러가 match context를 유지해 sub-binary 생성도 피합니다. 바이너리를 하나의 누적값에 이어 붙이는 방식도 효율적입니다. 누적값이 다른 곳으로 빠져나가지 않으면 런타임이 여유 공간에 이어 쓰므로 추가 작업이 상각 O(1)이 됩니다. 반대로 중간 결과를 따로 만들거나 누적값을 다른 곳에 전달하면 복사가 생길 수 있습니다.

BEAM의 작은 정수는 64비트 환경에서 부호를 포함해 60비트 범위에 들어갑니다. 그보다 큰 값은 힙에 bignum으로 저장됩니다. 글에서는 이 제약이 SWAR 최적화에 중요하다고 설명합니다. 컴파일 옵션 +bin_opt_info로 바이너리 매치 최적화가 유지되는 지점을 확인하고, +JDdump true로 JIT이 생성한 어셈블리도 살펴볼 수 있습니다.

인코딩과 디코딩의 할당 줄이기

기존 인코더는 DNS 응답의 각 섹션과 레코드 데이터를 별도 바이너리로 만든 뒤 합쳤습니다. 변경 후에는 헤더용 12바이트 공간을 먼저 확보하고, 질문과 레코드를 하나의 누적 바이너리에 차례로 덧붙입니다. 레코드 데이터 길이도 별도 결과물을 만든 뒤 재지 않고, 데이터와 함께 한 번에 씁니다. 메시지 끝에 실제 카운터가 담긴 헤더를 붙일 때 본문을 한 번 복사합니다. M4 Pro에서 단순 UDP 응답은 708ns에서 583ns로 빨라졌고 할당량은 1.89KB에서 1.23KB로 줄었습니다. 일반적인 DNSSEC 응답은 1.33마이크로초에서 1.13마이크로초로 줄었습니다.

디코더에서는 TXT 문자열을 처리하는 보조 함수가 {Rest, String} 튜플을 반환하면서 튜플을 할당하고 나머지 바이너리를 materialize하던 구조를 바꿨습니다. 재귀 함수의 패턴에서 문자열과 나머지를 직접 매칭해 match context를 유지합니다. 또 디코딩한 각 섹션을 19워드 크기 레코드에 매번 기록하지 않고 함수 인자로 전달한 뒤 마지막에 레코드를 한 번 만듭니다. 이 변경으로 질의 디코딩의 reductions가 11%, 할당량이 10% 줄었습니다.

청크 처리와 SWAR

도메인 이름을 소문자로 바꾸던 기존 코드는 입력 길이를 8, 7, 6바이트 등으로 나눌 수 있는지 검사했습니다. x86-64 JIT에서 8, 4, 2로 나누는 연산은 비트 마스크로 처리되지만 7, 6, 5, 3으로 나누는 연산은 20~90사이클이 드는 idiv로 이어질 수 있습니다. 새 구현은 큰 청크부터 패턴 매칭하고 남은 바이트를 처리해 나눗셈을 없앴습니다.

SWAR는 여러 바이트를 하나의 정수로 읽어 비트 연산과 산술 연산으로 동시에 검사하는 기법입니다. 64비트 정수는 BEAM의 작은 정수 범위를 벗어나 bignum 할당을 유발하므로, 이 구현은 7바이트인 56비트 단위로 검사합니다. 제로 바이트 검출식을 이용해 각 청크에 마침표나 역슬래시가 있는지 가드에서 확인하고, 특수 문자가 없는 청크는 그대로 누적값에 붙입니다. 이 방식은 Erlang/OTP의 JSON 및 string 모듈과 Elixir의 String 함수에도 적용됐습니다. 글에 따르면 관련 변경은 OTP 29와 Elixir 1.20에 포함됐으며, 일부 ASCII 문자열 연산은 최대 두 배 빨라졌습니다.

DNS 이름 압축과 측정 결과

DNS 이름 압축은 메시지 앞부분에 이미 나온 이름이나 접미사를 2바이트 포인터로 참조합니다. 기존에는 소문자화한 레이블 리스트를 맵 키로 사용해 이름을 쪼개고 검증하고 소문자로 바꾼 뒤 조회해야 했습니다. 변경 과정에서 접미사 키를 한꺼번에 맵에 넣어 반복 복사를 줄였고, 키도 소문자 바이너리로 바꿨습니다. 접미사 바이너리는 같은 이름의 sub-binary라 만드는 비용이 들지 않으며 비교는 메모리 비교 한 번으로 처리합니다. 50개의 한 글자 레이블이 있는 이름은 211마이크로초에서 9.4마이크로초로 줄었고, 보통 8개 레이블을 가진 이름은 1.51마이크로초에서 0.54마이크로초로 줄었습니다. 메시지 전체 인코딩은 반복되는 이름의 양에 따라 1.1~8.9배 빨라졌습니다.

테스트는 합성 DNS 존 2만 개와 약 1,900만 건의 질의를 사용했습니다. 존의 10%는 DNSSEC 서명이 있었고 질의의 83%가 DNSSEC을 요청했습니다. dns_erlang 5.0.5는 쿼리당 CPU 24.7마이크로초, 초당 최대 25만 3천 건을 기록했습니다. 5.0.21은 각각 21.8마이크로초와 28만 5천 건을 기록했습니다. 라운드별 편차는 1% 미만이었습니다. 별도 실험에서 함수 단위로 최대 30% 빨라진 소문자 변환은 서버 전체 성능에 변화를 주지 않았습니다. 해당 함수가 서버 작업 시간에서 차지하는 비중이 작았기 때문입니다. 저자들은 운영 서버에서 dns_erlang이 바쁜 CPU 시간의 약 9%를 차지하며, 다음 최적화 후보로 erldns의 존 캐시 조회를 꼽습니다.

최적화가 성과를 내지 못한 사례

레이블을 먼저 검색한 뒤 통째로 붙이는 방식은 sub-binary 할당을 늘려 메모리 할당량이 15~20% 증가했습니다. 짧은 DNS 레이블에 binary:match/2를 여러 번 적용하는 방법도 설정 비용 탓에 인라인 순회보다 1.4~2.4배 느렸습니다. NSEC 비트맵을 인코딩할 때 비트 단위 누적을 바이트 단위로 바꾸면 할당량만 25% 늘고 속도 이득은 없었습니다. 반면 디코딩에서 바이트 단위로 읽는 변경은 reductions를 절반으로 줄였습니다.

Lobsters 반응

  • @giacomo_cavalieri — 정말 훌륭한 기술 글입니다!
  • @travisgriggs — 정말 훌륭한 글입니다. 제가 가장 좋아하는 대목은 끝부분의 이 간결한 말입니다. BEAM은 생각보다 똑똑하며, 알아내는 유일한 방법은 측정하는 것입니다. 여기서 BEAM을 어떤 스택으로 바꿔도 됩니다. 너무 당연한 말처럼 들릴 수 있지만 저는 이 점을 강조하고 싶습니다. 경험이 많은 사람도 이 원칙을 받아들이지 않는 경우를 계속 봅니다. 여기에 "지금 알 수 있는 유일한 방법은 지금 측정하는 것"이라고 덧붙이고 싶습니다. 엔진과 실행 방식은 바뀝니다. 예전 버전에서 비싸거나 쌌던 작업이 오늘 버전에서는 저렴하거나 비싸지는 경우가 많습니다.
    • @pzel — 생성된 BEAM 어셈블리를 확인하라는 조언도 훌륭합니다. 성능에 민감한 코드를 쓰면서 함수 헤드에 따라 분기할 때 기존 캡처를 재사용하는지 궁금했던 적이 있습니다. look_at(<<A:16, B:16, C:32, constant:32>>)와 look_at(<<A:16, B:16, C:32, other_constant:32>>) 같은 형태입니다. 컴파일 결과를 어셈블리로 확인하니 Erlang 컴파일러가 서로 다른 오른쪽 패턴을 시도하기 전에 필요한 매칭 값을 레지스터에 넣더군요. 초안 글이 어딘가 남아 있을 것 같지만, 성능에 민감한 Erlang을 다루지 않은 기간이 대부분인 채로 10년쯤 지났습니다.
    • @pzel — 관심 있는 분이 있을까 해서 초안의 도입부를 찾았습니다: https://paste.sr.ht/blob/15719cd5ea56414c9fd5c7e98f6aa02824379688
  • @fanf — 정말 좋습니다! DNS 이름 압축 인코딩을 설명하는 부분에서 이름을 분리하고 검증하고 소문자로 바꾸기 전에는 조회할 수 없었다는 점이 눈에 띄었습니다. DNS 서버는 BIND처럼 대소문자를 보존하거나 NSD처럼 이름을 소문자로 정규화할 수 있습니다. 후자는 각각의 대소문자 무시 비교가 아주 비싸지는 않아도 반복 횟수가 많아 생기는 자잘한 비용을 없앨 수 있습니다. 메시지에 이름을 쓸 때 검증이 필요한지도 궁금합니다. 서버 내부의 이름은 모두 생성 단계에서 이미 유효해야 하지 않을까요? 유효성 조건에 소문자도 포함된다면 그 단계도 건너뛸 수 있습니다.

원문: DNSimple 블로그 / 번역·요약: Trawling