Rust 아레나를 도입해 Gleam 포매터의 성능을 높이고 메모리 사용량을 줄인 과정을 설명합니다.
저는 Rust로 작성된 작고 친근한 함수형 프로그래밍 언어인 Gleam의 코어 팀에서 일하고 있습니다.
얼마 전, 언어의 pretty printer를 더 빠르게 만들기 위해 아레나를 사용하기 시작할 수 있는 방법을 제안하는 3년 묵은 이슈를 발견했습니다. 꽤 불길하게도 그 이슈는 이렇게 끝납니다:
"이건 꽤 긴 수작업이 될 것이다."
근사하게 들리죠. 저는 사실 그런 지루하고 반복적인 일을 꽤 재미있어합니다. 머리를 비우고 키보드만 두드리면 되는 작업 말이죠. ^llms
며칠 동안 조심스럽게 find-and-replace를 반복하고 +2963/-1032 pull request를 올린 끝에, Gleam 포매터를 훨씬 더 빠르게 만들었고 최고 메모리 사용량도 10%나 줄일 수 있었습니다!
이슈에서 지적하듯이, Gleam의 pretty printer는 재귀적 자료구조인 Document를 기반으로 합니다. 아래는 Gleam이 사용하는 것을 약간 단순화한 버전입니다:
pub enum Document<'string> {
/// A literal string that will be printed
/// exactly as it is.
String(&'string str),
/// A possible point where the rendered
/// document could be broken if any line
/// line gets too long.
Break {
/// The string to render if this is broken
broken: &'string str,
/// The string to render if this in not broken
unbroken: &'string str
},
/// A document that can be broken along its
/// `Break`s.
Group(Vec<Self>),
/// If this document is broken, increase its nesting
/// by some amount.
Nest(Box<Self>),
// ...and many more...
}
이 구조 덕분에 어떤 코드 조각을 어떻게 렌더링해야 하는지, 그리고 줄 길이 제한을 넘었을 때 pretty printer가 그것을 어떻게 나눌 수 있는지를 표현할 수 있습니다. 예를 들어 리스트는 이렇게 표현됩니다:
Group(vec![
String("["),
Nest(Box::new(Break { broken: "", unbroken: "" })),
String("1"),
Nest(Box::new(Break { broken: ",", unbroken: ", " })),
String("2"),
Break { broken: ",", unbroken: "" },
String("]")
])
이는 포매터가 리스트를 전혀 줄바꿈하지 않고 렌더링하거나, 또는 현재 줄에 들어가지 않으면 주어진 break 지점에서 줄바꿈하며 렌더링할 수 있다는 뜻입니다:^how-this-works
```// Rendered with none of the Breaks broken...
[1, 2]
// ...or rendered breaking it along the Breaks!
[
1,
2,
]
`Document`의 일부 variant는 `Nest`처럼 다른 document를 담을 수 있으므로, 그런 값들은 힙에 boxed 되어야 합니다. 그리고 꽤 많은 시간이 바로 그 작업에만 쓰일 수 있습니다!
## [#](https://giacomocavalieri.me/writing/gleam-rust-arenas#arenas)아레나
그 대신 다른 document에 대한 참조만 저장할 수 있다면 어떨까요? 그러려면 `Document` enum을 조금 바꿔야 합니다. 그 참조들을 위한 새로운 lifetime이 필요합니다:
pub enum Document<'doc, 'string> {
// ...and the other variants...
}
Rust에서 참조를 다뤄본 적이 있다면 lifetime을 처리하는 일이 꽤 성가실 때가 있다는 걸 아실 겁니다. 하지만 아레나를 사용하면 훨씬 수월해질 수 있습니다.
이 경우 저는 [`typed_arena`](https://docs.rs/typed-arena/latest/typed_arena/) 크레이트를 사용하기로 했습니다. API는 꽤 단순합니다. 아레나에 새 값을 `alloc`하고 참조를 돌려받을 수 있습니다. 사실상 그게 전부입니다! 아레나가 살아 있는 동안에는 그 안의 데이터를 사용할 수 있고, 아레나가 스코프를 벗어나 drop되면 그 안의 데이터도 모두 함께 drop됩니다.
let arena = Arena::new();
let comma_break = arena.alloc(Break { broken: ",", unbroken: ", " });
let nested_break = arena.alloc(Nest(comma_break));
// ...render the docs or whatever...
borrow checker는 아레나가 drop된 뒤에 그 안의 데이터를 참조하지 못하도록 보장해 줍니다:
pub fn alloc(&self, value: T) -> &mut T // ^ ^^^^ // The reference to the value allocated in // the arena can't outlive it!
또 다른 장점은 코드 전반에서 반복해서 나타나는 많은 document를 매번 새로 할당하지 않고 캐시할 수 있다는 점입니다. 예를 들어 언어의 모든 키워드에 해당하는 document인 `String("fn")`, `String("pub")`, `String("type")`, 혹은 리스트 항목을 구분하는 데 쓰는 쉼표 `Break { unbroken: ", ", broken: "," }` 같은 것들입니다. 실제로 [수백 개의 작은 document](https://github.com/gleam-lang/gleam/pull/5824/changes#diff-fd77bddd869b2a4e981c876948b9c61e195184ef23a2becbc4ac023aed51f920R590)가 한 번만 할당되고, 계속해서 boxed 되지 않게 되었습니다.
## [#](https://giacomocavalieri.me/writing/gleam-rust-arenas#the-result)결과
아레나는 사용하기 편했지만, 큰 코드 덩어리를 바꿔서 이를 사용하게 만드는 데에는 어느 정도의 단순 반복 작업이 필요했습니다. 이전에는 그냥 `Box::new`로 충분했던 모든 코드 조각이 이제는 데이터를 할당할 아레나를 추가 인자로 받아야 했습니다:
const comma_break = Break { broken: ",", unbroken: ", ") };
pub fn format_list(
Nest(Box::new(comma_break));
arena.alloc(Nest(comma_break));
Group(vec![ String("["), items .iter() .map(|item|
Nest(Box::new(format_expression(item)))
Nest(format_expression(arena, item))
)
.intersperse(comma)
.collect(),
String("]")
])
}
결국 결과는 제가 예상했던 것보다 훨씬 좋았습니다. pretty printer 하나만 놓고 봐도 엄청난 속도 향상이 있었습니다. [`squirrel`](https://github.com/giacomocavalieri/squirrel) 같은 실제 Gleam 프로젝트를 포매팅하는 데 걸리는 시간이 13ms에서 9,8ms로 줄었습니다. _무려 24% 더 빠른 것입니다!_
포매팅은 `gleam format` 실행에 들어가는 작업의 일부일 뿐입니다. 그 전에 먼저 프로젝트의 소스 코드를 읽고 파싱해야 하니까요. 그럼에도 개선은 실제로 체감됩니다. `gleam format` 실행 전체가 13% 더 빨라졌습니다. 최고 메모리 사용량도 8.4MB에서 7.6MB로 내려갔습니다.
결국 힙에 이것저것 할당하는 데 시간을 덜 쓸수록 코드가 더 빨라지고 메모리도 덜 먹게 되며, Rust에서 아레나는 이를 위한 정말 좋은 방법이라는 사실을 알게 됐습니다.
* * *
[^how-this-works](https://giacomocavalieri.me/writing/gleam-rust-arenas#^how-this-works-origin)
pretty printing 알고리즘 자체도 꽤 흥미롭습니다. 어떻게 동작하는지 궁금하다면 ["Strictly Pretty"](https://lindig.github.io/papers/strictly-pretty-2000.pdf) 논문을 읽어보셔도 좋습니다. 정말 재미있게 읽었고, Gleam pretty printer도 바로 그것을 기반으로 합니다!
[^llms](https://giacomocavalieri.me/writing/gleam-rust-arenas#^llms-origin)
이것도 제가 LLM을 그렇게 싫어하는 이유 중 하나입니다. 저는 그것들을 쓰지 않을 겁니다. 저는 직접 타이핑하고 스스로 생각하는 걸 좋아하거든요. 감사합니다!