Rust로 작성한 ISO C23 문법 파서(cgrammar)를 대규모 코드베이스로 벤치마킹하고, samply로 프로파일링해 Rc::clone 오버헤드와 파서 콤비네이터 프로파일링의 어려움을 분석한 뒤 상태 설계를 개선해 성능을 끌어올린 경험을 정리한다.
URL: https://blog.wybxc.cc/blog/profile-cgrammar/
2025년에 저는 chumsky 파서 콤비네이터 라이브러리를 활용해 Rust로 작성한, ISO C23 문법을 포괄적으로 파싱하는 cgrammar를 개발했습니다. 원래는 제 내부 프로젝트를 위해 설계했지만, 범용 C 파서로 오픈소스화했습니다. 이 라이브러리는 C23 속성(attribute)을 동형(isomorphic) 방식으로 다룬다는 점이 특징입니다. C의 구문 요소를 재사용 가능한 파서 콤비네이터로 노출함으로써, 언어의 핵심 문법과 일관된 방식으로 속성 내용물을 세밀하게 파싱할 수 있게 합니다.
파서를 만들 당시에는 견고성과 C 언어의 복잡성에만 집중하기 위해 성능은 의도적으로 고려 대상에서 제외했습니다. 하지만 이후 파서의 런타임 특성이 궁금해졌고, 그래서 라이브러리를 프로파일링해 본 뒤 그 결과를 이 글에 정리하기로 했습니다.
핵심 요약은 다음과 같습니다.
Rc::clone은 예상보다 무겁다.파서를 프로파일링하려면 큰 C 코드베이스가 필요했습니다. 다행히도 이미 완벽한 후보가 있었는데, 바로 파서 자체의 테스트 케이스입니다.¹ 전처리를 거치고 나니 대략 120만 줄의 C 코드가 되었고(혹은 주석과 공백을 제거하면 2만 줄 정도), 충분히 큰 입력이었습니다.
벤치마크 러너 구현은 단순했습니다. 디렉터리를 순회하면서 파일을 하나씩 파싱하기만 하면 됩니다. 이 로직을 benches/bench.rs에 넣고, Cargo.toml에 다음 설정을 추가했습니다. 이 설정은 실행 시 Cargo가 벤치마크 프로파일을 사용하도록 보장합니다. 즉, 릴리즈 모드로 실행하되 디버그 심볼을 포함합니다. 또한 기본 벤치마크 하네스는 끄고 제 main 함수를 사용했습니다.
[profile.bench]debug = true[[bench]]name = "bench"harness = false
samply로 프로파일링저는 범용 샘플링 프로파일러인 samply를 선택했습니다. UI로 Firefox 프로파일러를 사용하는데, 여러 옵션 중에서 가장 사용하기 편하다고 느꼈습니다.
프로파일러 실행은 벤치마크 명령 앞에 samply record만 붙이면 됩니다.
samply record cargo bench
벤치마크가 끝나면 samply가 기본 브라우저에서 Firefox 프로파일러 UI를 열고, 콜 트리와 플레임 그래프를 포함한 수집된 프로파일링 데이터를 보여줍니다.

예상대로 파싱 로직이 실행 시간을 지배했습니다. 하지만 더 깊게 파고드는 건 어려웠습니다. 대부분의 함수 호출이 일반적인 <chumsky::... as chumsky::Parser<I, O, E>>::go> 형태로 보였기 때문에, 어떤 특정 콤비네이터가 병목인지 알아내기가 불가능했습니다.
이는 대체로 추상화의 대가입니다. 파서 콤비네이터가 합성되고 나면, 런타임에서는 고수준 구조가 지워져서 samply 같은 비침습(non-invasive) 프로파일러가 그 계층을 꿰뚫어 볼 수 없습니다.
저는 거의 samply를 포기할 뻔했지만, 마지막으로 콜 트리를 한 번 더 살펴보기로 했습니다. 그때 이상한 점을 발견했습니다. 전체 실행 시간의 20% 이상이 Cell::get에 소비되고 있었고, 이는 주로 Rc::clone과 Rc::drop에 의해 유발되고 있었습니다.²
충격이었습니다. Rc 연산이 이렇게 큰 부담이 될 거라고는 전혀 예상하지 못했습니다. 대체 무엇이 벌어지고 있었을까요?
콜 트리를 보면 범인이 드러났습니다. 엄청나게 많은 Rc::clone과 Rc::drop 호출이 렉서와 파서의 상태(state) 관리와 직접 연결되어 있었습니다.
왜 그런지 이해하기 위해 cgrammar의 배경을 조금 설명하겠습니다. 렉서는 전처리된 C 코드를 소비하는데, 전처리기는 원본 소스 파일로 다시 매핑하기 위해 line directive를 삽입합니다. 오류를 정확히 보고하려면 렉서가 현재 파일명과 라인 번호를 추적해야 하고, 이런 directive를 만나면 이를 업데이트해야 합니다. 그런데 파서는 백트래킹을 지원하므로, 렉서는 어느 시점에서든 상태를 스냅샷으로 저장하고 복원할 수 있어야 합니다.
저는 이를 chumsky의 상태 기반 파싱 기능으로 구현했습니다. 구체적으로는 Inspector 트레이트와 커스텀 Checkpoint 타입을 사용해, chumsky가 현재 상태를 Checkpoint에 저장하고 필요할 때 복원할 수 있게 했습니다.
여기서 Rc가 등장합니다. 파일명이 렉서 상태의 일부였기 때문에, 저는 이를 Rc<str>로 감쌌습니다. 백트래킹 때마다 문자열 데이터를 깊은 복사(deep copy)하는 건 너무 비쌀 거라고 판단했기 때문입니다. Rc를 사용하면 렉서 상태를 값싼 clone 가능한 타입으로 만들 수 있고, 상태 자체를 Checkpoint 타입으로 그대로 사용할 수 있습니다.
깊은 복사를 피한 판단 자체는 맞았지만, 상태가 너무 자주 clone되어 참조 카운팅 오버헤드 자체가 병목이 될 거라는 점을 예상하지 못했습니다.
이 의심을 확인하기 위해 렉서에 clone 횟수를 세는 계측을 넣었습니다. 결과는 믿기 힘들 정도였습니다. 120만 줄의 C 코드를 파싱하는 동안 렉서 상태가 4억 번 넘게 clone되었습니다.
결론적으로 Rc 자체가 느린 건 아니었습니다. 평균 Rc::clone은 약 6ns 정도였고, 이는 L2 캐시 접근 비용으로 흔히 볼 수 있는 수준입니다. 큰 캐시 미스 페널티도 없었습니다. 문제는 제 설계가 단순히 연산량을 지나치게 많이 만들어냈다는 점이었습니다. typedef 같은 문맥 의존(context-sensitive) 구문을 추적하는 파서 상태도 같은 문제를 겪었고(다만 clone 볼륨은 약 20% 수준), 본질은 동일했습니다.
해결책은 명확했습니다. 상태에서 Rc를 완전히 제거해야 합니다. 상태를 다시 설계하면 체크포인트를 Copy 타입으로 만들 수 있고, 저장/복원은 간접 메모리 접근 없이 사소한 비용으로 끝낼 수 있습니다.
렉서 상태와 파서 상태는 구조가 다르기 때문에, Rc 오버헤드를 제거하기 위해 두 가지 서로 다른 전략을 채택했습니다.
렉서의 경우 Rc를 사용하는 필드는 파일명 하나뿐이었습니다. 저는 이를 “전역 문자열 풀(global string pool)”로 대체하기로 했습니다. 솔직히 말하면, 파일명 문자열을 _누수(leak)_시켜 Copy를 구현하는 &'static str을 얻는 방식입니다.
메모리 누수라는 말에 당황하지 마세요. 프로그램 실행 전체 기간 동안 유지되는 풀에 데이터를 저장한다면, 어차피 사실상 누수와 다름없습니다. 소스 파일 수는 현실적으로 작고 상한이 있으므로, 참조 카운팅을 완전히 우회하기 위한 트레이드오프로 충분히 받아들일 만합니다.³
파서 상태는 훨씬 크고 복잡해서 단순히 Copy를 구현할 수 없습니다. 대신 저는 slab 할당기를 사용해 파서 상태의 스냅샷을 관리했습니다.
여기서 핵심 통찰은 이렇습니다. 파서는 상태를 자주 저장하고 복원하지만, “서로 다른 상태 버전”의 개수 자체는 상대적으로 적습니다(보통 파일당 수백 개 수준). 실제 상태 데이터는 slab에 저장하고, 체크포인트는 단순한 정수 인덱스로 표현할 수 있습니다. 이는 Rc 포인터보다 훨씬 저렴합니다. 상태를 변경해야 할 때는 이전 체크포인트가 유효하도록 상태를 새 slab 엔트리로 clone해 넣습니다.
이 설계는 파싱이 끝날 때까지 오래된 상태를 정리하지 않으므로, 성능을 위해 메모리를 더 쓰는 트레이드오프가 있습니다. 하지만 큰 문제는 아닙니다. 상태 구조 내부에서는 버전 간 데이터 공유를 위해 여전히 Rc를 사용할 수도 있고, 그렇게 해서 얻는 속도 향상은 늘어난 메모리 사용량을 감수할 만한 가치가 있습니다.
이 변경을 적용한 후 벤치마크를 다시 돌려 보니 전체적으로 **14%**의 속도 향상을 얻었습니다. 파싱 로직 자체는 전혀 건드리지 않았는데도 말이죠.
Rust에서 Rc는 흔히 “값싼 clone”의 대표처럼 여겨지고, 저도 예전에는 성능을 크게 걱정하지 않고 마음껏 clone해도 된다고 생각했습니다. 하지만 이번 경우에는 clone의 빈도가 너무 높아서 그것 자체가 큰 병목이 되었습니다.
여기서 얻은 교훈은, 개별 연산의 비용만 보지 말고 **연산의 총량(볼륨)**을 항상 고려하라는 것입니다. 수백만/수억 번의 연산을 다루게 되면, 메모리 트래픽에서 오는 오버헤드(캐시 히트 접근이라 하더라도)가 누적되어 눈에 띄는 성능 페널티가 될 수 있습니다.
사실 이런 Rc 문제와 씨름하다 보니, 원래 목표였던 “파서 콤비네이터를 효과적으로 프로파일링하는 방법”을 찾는 일은 옆길로 새버렸습니다. 이건 다음에 더 파고들 주제입니다. 언젠가 제대로 조사해서 별도의 글로 정리하게 될지도 모르겠네요.
¹ 테스트 케이스의 원 출처인 cake 프로젝트에 크레딧을 드립니다.
² 이전 스크린샷에서 이것들이 보이지 않을 수도 있는데, 스크린샷을 찍기 전에 이미 코드를 최적화했기 때문입니다.
³ 업데이트: Reddit에서의 토론(https://www.reddit.com/r/rust/comments/1qdeko7/i_profiled_my_parser_and_found_rcclone_to_be_the/) 이후, yoke 같은 것을 사용해 소스 입력을 파서 출력에 붙여서, 파일명이 입력 데이터에 대한 단순 참조가 되게 만드는 편이 더 낫다는 걸 깨달았습니다. 이렇게 하면 메모리 누수도 없고 전역 상태도 필요 없습니다.