Skip to content

JavaScript 컴파일러를 만들며 추구한 성능

원문: https://rustmagazine.org/issue-3/javascript-compiler/

성능을 말하며

Rust를 쓴 지 2년이 지나자 성능은 메모리를 덜 할당하고 CPU 사이클을 덜 쓰는 것으로 요약되는 습관이 됐습니다.

다만 문제 영역 지식 없이 최적까지 가기는 어렵고 가능한 해를 모르면 더욱 그렇습니다.

다음 절에서는 제가 겪은 성능·최적화 여정을 이어 가겠습니다. 저는 조사와 시행착오를 섞어 배우는 편이라 절 구성도 그에 맞춥니다.

파싱

Oxc는 AST, 렉서, 재귀 하강 파서를 갖춘 일반적인 컴파일러입니다.

추상 구문 트리(AST)

컴파일러의 첫 설계 과제가 AST입니다.

모든 JS 도구는 AST 위에서 동작합니다:

  • 린터(예: ESLint)는 AST에서 오류를 찾습니다
  • 포매터(예: prettier)는 AST를 다시 JS 텍스트로 출력합니다
  • 축약기(예: terser)는 AST를 변환합니다
  • 번들러는 파일마다 다른 AST 사이의 import/export를 연결합니다

AST가 쓰기 불편하면 이 도구들을 만들기 괴롭습니다.

JavaScript에서는 estree가 가장 많이 쓰이는 AST 명세입니다. 제 첫 AST 버전은 estree를 따라 만들었습니다:

rust
pub struct Program {
    pub node: Node,
    pub body: Vec<Statement>,
}

pub enum Statement {
    VariableDeclarationStatement(VariableDeclaration),
}

pub struct VariableDeclaration {
    pub node: Node,
    pub declarations: Vec<VariableDeclarator>,
}

Rust에서는 트리를 struct와 enum으로 선언하는 것은 비교적 단순합니다.

메모리 할당

몇 달 동안 파서를 짜며 이 버전 AST를 쓰다가 프로파일을 돌렸습니다. 프로그램이 drop 호출에 시간을 많이 씁니다.

💡 AST 노드는 BoxVec로 힙에 따로 할당되어 순차적으로 드롭됩니다.

이를 줄일 방법이 있을까요?

파서 작업 중 Rust로 쓴 다른 JS 파서, 주로 rateljsparagus를 살폈습니다.

둘 다 AST에 수명 매개변수가 붙어 있고:

rust
pub enum Statement<'ast> {
    Expression(ExpressionNode<'ast>),
}

arena.rs라는 동반 파일도 있습니다.

역할을 몰라 무시했다가 메모리 아레나 설명을 읽고 나서야 이해했습니다: bumpalotoolshed.

요약하면 아레나는 청크/페이지 단위로 미리 메모리를 잡았다가 아레나를 드롭할 때 한꺼번에 해제합니다. AST가 아레나에 올라가면 드롭이 매우 빨라집니다.

부수 효과로 AST 생성 순서와 순회 순서가 맞아 방문(visitation) 시 선형 접근 패턴이 됩니다. 인접 메모리가 페이지 단위로 캐시에 올라와 접근이 빨라집니다.

다만 초보에게는 모든 자료구조·함수에 수명 매개변수가 붙는 아레나가 어렵습니다. bumpalo 안에 AST를 넣으려 다섯 번은 시도했습니다.

AST를 아레나로 바꾸니 대략 20% 정도 빨라졌습니다.

열거형 크기

AST는 재귀적이라 "간접 참조 없이 재귀" 오류를 피하도록 타입을 정의해야 합니다:

error[E0072]: recursive types `Enum` and `Variant` have infinite size
 --> crates/oxc_linter/src/lib.rs:1:1
  |
1 | enum Enum {
  | ^^^^^^^^^
2 |     Variant(Variant),
  |             ------- recursive without indirection
3 | }
4 | struct Variant {
  | ^^^^^^^^^^^^^^
5 |     field: Enum,
  |            ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 ~     Variant(Box<Variant>),
3 | }
4 | struct Variant {
5 ~     field: Box<Enum>,

방법은 둘: enum 변안을 박스로 감싸거나 구조체 필드를 박스로 갑니다.

2017년 러스트 포럼에 같은 질문이 있었습니다: Is there a better way to represent an abstract syntax tree?

Aleksey(matklad)는 Expression enum을 작게 유지하려면 변안을 박스에 넣으라고 했습니다.

Rust enum 레이아웃은 모든 변안 크기에 의존하며 크기는 가장 큰 변안을 따릅니다. 예: 다음 enum은 56바이트(태그 1바이트 + 페이로드 48바이트 + 정렬 8바이트)입니다.

rust
enum Enum {
    A, // 0 byte payload
    B(String), // 24 byte payload
    C { first: String, last: String }, // 48 byte payload
}

전형적인 JS AST에서 Expression은 변안이 45개, Statement는 20개입니다. 변안 박스가 없으면 200바이트를 넘습니다. 그 200바이트를 들고 다녀야 하고 matches! 검사마다 접근해야 해 성능과 캐시에 불리합니다.

그래서 변안 박스가 낫습니다.

큰 타입 찾기는 perf-book을 참고하세요.

작은 enum 크기를 강제하는 테스트도 그대로 옮겼습니다.

rust
#[cfg(all(target_arch = "x86_64", target_pointer_width = "64"))]
#[test]
fn no_bloat_enum_sizes() {
    use std::mem::size_of;
    use crate::ast::*;
    assert_eq!(size_of::<Statement>(), 16);
    assert_eq!(size_of::<Expression>(), 16);
    assert_eq!(size_of::<Declaration>(), 16);
}

변안 박싱으로 대략 10% 가속이 났습니다.

스팬(Span)

가끔 자료구조를 더 들여다보기 전까지 메모리를 줄일 여지가 있다는 걸 놓칩니다.

여기서는 모든 AST 잎이 소스 바이트 오프셋을 저장하는 작은 구조체 "span"을 갖고, 두 개의 usize였습니다.

rust
pub struct Node {
    pub start: usize,
    pub end: usize,
}

리뷰 코멘트 덕분에 u32로 안전히 바꿀 수 있었고(u32보다 크면 곧 4GB 파일),

u32 전환으로 큰 파일에서 최대 약 5%까지 좋아졌습니다.

문자열과 식별자

식별자 이름·문자열 리터럴을 소스 텍스트 참조로 두고 싶을 수 있습니다.

rust
pub struct StringLiteral<'a> {
    pub value: &'a str,
}

pub struct Identifier<'a> {
    pub name: &'a str,
}

하지만 JavaScript 문자열과 식별자에는 이스케이프 시퀀스가 있습니다. 예: 저작권 기호에서 '\251', '\xA9', '©'는 같습니다.

따라서 이스케이프 값을 계산하고 새 String을 할당해야 합니다.

문자열 인턴(string interning)

힙에 많은 문자열이 쌓이면 문자열 인턴으로 서로 다른 값마다 하나의 복사만 두어 메모리를 줄일 수 있습니다.

servo 팀의 string-cache를 처음엔 AST 식별자·문자열에 썼습니다. 단일 스레드 파서 성능은 좋았는데 rayon으로 여러 파서를 돌린 린터에서는 전체 코어의 약 50% 이용률에 머물렀습니다.

프로파일에서 parking_lot::raw_mutex::RawMutex::lock_slow가 상단에 보였습니다. 락·멀티코어를 잘 몰랐지만 글로벌 락은 이상했고, string-cache를 빼 전 코어 활용도를 올렸습니다.

AST에서 string-cache를 제거하니 병렬 파싱이 약 30% 빨라졌습니다.

string-cache

반 년 뒤 다른 성능 크리티컬 프로젝트에서 string-cache가 다시 나왔고, 병렬 텍스트 파싱 때 스레드를 막았습니다.

Mara Bos의 Rust Atomics and Locks를 읽은 뒤라 이번엔 무엇을 하는지 들여다보기로 했습니다.

락 주변 코드는 다음입니다(2015년, 8년 전 코드입니다).

rust
pub(crate) static DYNAMIC_SET: Lazy<Mutex<Set>> = Lazy::new(|| {
    Mutex::new({

// ... in another place
let ptr: std::ptr::NonNull<Entry> =
    DYNAMIC_SET.lock().insert(string_to_add, hash.g);

문자열을 넣을 때마다 Set 전체를 락합니다. 파서 안에서 자주 불리므로 동기화 비용이 큽니다.

Set 구현을 보면:

rust
pub(crate) fn insert(&mut self, string: Cow<str>, hash: u32) -> NonNull<Entry> {
    let bucket_index = (hash & BUCKET_MASK) as usize;
    {
        let mut ptr: Option<&mut Box<Entry>> = self.buckets[bucket_index].as_mut();

        while let Some(entry) = ptr.take() {
            if entry.hash == hash && *entry.string == *string {
                if entry.ref_count.fetch_add(1, SeqCst) > 0 {
                    return NonNull::from(&mut **entry);
                }
                entry.ref_count.fetch_sub(1, SeqCst);
                break;
            }
            ptr = entry.next_in_bucket.as_mut();
        }
    }
    debug_assert!(mem::align_of::<Entry>() >= ENTRY_ALIGNMENT);
    let string = string.into_owned();
    let mut entry = Box::new(Entry {
        next_in_bucket: self.buckets[bucket_index].take(),
        hash,
        ref_count: AtomicIsize::new(1),
        string: string.into_boxed_str(),
    });
    let ptr = NonNull::from(&mut *entry);
    self.buckets[bucket_index] = Some(entry);

    ptr
}

버킷을 찾아 없으면 넣는 구조입니다.

💡 선형 조사인가요? 그렇다면 이름만 다른 HashMap일 수 있습니다. 💡 HashMap이라면 Mutex<HashMap>은 동시성 해시맵입니다.

무엇을 찾아야 할지 알면 단순해 보이지만, 문제 자체를 몰랐을 땐 한 달 걸렸습니다. 동시 해시맵이라는 게 명확해지자 통째 락이 아니라 버킷 단위 락이 논리적 해법이었습니다. 바꿔 넣은 지 한 시간 만에 PR을 올렸고 결과에 만족했습니다 😃.

https://github.com/servo/string-cache/pull/268

문자열 인턴은 Rust 커뮤니티에서도 격전지입니다. 이 글처럼 string-interner, lasso, lalrpop-intern, intaglio, strena 같은 단일 스레드 크레이트가 있습니다.

파일 병렬 파싱에는 ustr 같은 멀티스레드 인턴이 선택지입니다. 그러나 ustr와 개선된 string-cache를 프로파일해도 아래에서 설명할 접근보다는 기대 이하였습니다.

성능이 덜 나온 이유 추정:

  • 해시 — 중복 제거를 위해 문자열 해시 필요
  • 간접 참조 — 힙 “먼 곳”에서 문자열을 읽어 캐시에 불리

문자열 인라인(string inlining)

다시 문자열을 많이 할당해야 하는 원점으로 돌아왔습니다. 다루는 데이터가 짧은 JS 변수 이름·짧은 문자열이라면 부분 해가 있습니다: 문자열 인라인, 즉 바이트를 스택에 넣는 기법입니다.

본질적으로 문자열은 다음 enum에 담고 싶습니다.

rust
enum Str {
    Static(&'static str),
    Inline(InlineReprensation),
    Heap(String),
}

enum 크기를 최소화하려면 InlineRepresentationString과 같은 크기여야 합니다.

rust
#[cfg(all(target_arch = "x86_64", target_pointer_width = "64"))]
#[test]
fn test_size() {
    use std::mem::size_of;
    assert_eq!(size_of::<String>(), size_of::<InlineReprensation>());
}

메모리 사용을 줄이려는 크레이트도 많습니다. 대표적으로

특성과 트레이드오프가 각각이라 고를 때 신중해야 합니다. 예: smol_strflexstr 클론은 O(1). 64비트에서 flexstr는 22바이트, smol_str·smartstring은 23바이트, compact_str는 24바이트까지 인라인합니다.

fasterthanli.me깊은 글도 참고하세요.

Stringcompact_str::CompactStr로 바꾸면 할당이 크게 줄었습니다.

렉서(Lexer)

토큰(Token)

렉서(토크나이저)는 소스를 토큰이라 불리는 구조화 데이터로 바꿉니다.

rust
pub struct Token {
    pub kind: Kind,
}

다루기 쉽게 토큰 종류를 Rust enum으로 두고 변안마다 해당 데이터를 넣습니다.

rust
pub enum Kind {
    // Keywords
    For,
    While,
    ...
    // Literals
    String(String),
    Num(f64),
    ...
}

이 enum은 지금 32바이트이고 렉서는 종종 이런 Kind를 수백만 번 만듭니다. Kind::ForKind::While 하나마다 스택에 32바이트를 쓴 셈입니다.

개선하려면 Kind를 1바이트로 유지하고 값은 다른 enum으로 빼 내는 방법이 있습니다,

rust
pub struct Token<'a> {
    pub kind: Kind,
    pub value: TokenValue
}

pub enum TokenValue {
    None,
    String(String),
    Num(f64),
}

파싱 코드는 우리 손 안에 있으니 종류와 값을 항상 맞추는 책임은 우리에게 있습니다.

TokenValue가 32바이트라도 자주 만들면 성능을 깎을 수 있습니다.

에디터에서 String 정의를 따라가며 String -> Vec -> RawVec를 살펴봅시다:

rust
pub struct String {
    vec: Vec<u8>,
}

pub struct Vec {
    buf: RawVec<T, A>,
    len: usize,
}

pub struct RawVec {
    ptr: Unique<T>,
    cap: usize,
    alloc: A,
}

말했듯 Stringu8Vec이고 Vec은 길이와 용량을 갖습니다. 이 문자열을 바꿀 계획이 없다면 메모리 측면에서 cap 필드를 버리고 문자열 슬라이스 &str로 가는 게 낫습니다.

rust
pub enum TokenValue<'a> {
    None,
    String(&'a str),
    Num(f64),
}

TokenValue는 24바이트가 됩니다.

String 대신 슬라이스를 쓰면 메모리는 줄지만 수명 어노테이션이 붙습니다. 대여 검사기와 충돌하기 쉽고 수명 주석이 코드베이스 전파되어 관리가 어렵습니다. 8개월 전에는 빌려 쓰기 게임에서 졌지만 결국 이겼습니다.

타당하면 참조 대신 불변 소유 데이터를 택할 수도 있습니다. 예: String 대신 Box<str>, Vec<u8> 대신 Box<[u8]>.

요약하면 자료구조를 작게 유지하는 요령은 항상 있고, 종종 성능 보상도 따라옵니다.

Cow

jsparagus 코드를 공부하다 처음 Cow를 봤는데 AutoCow라는 인프라가 있습니다.

JS 문자열을 토큰화할 때 이스케이프를 만나면 새 문자열을 할당하고, 아니면 원본 슬라이스를 반환합니다:

rust
fn finish(&mut self, lexer: &Lexer<'alloc>) -> &'alloc str {
    match self.value.take() {
        Some(arena_string) => arena_string.into_bump_str(),
        None => &self.start[..self.start.len() - lexer.chars.as_str().len()],
    }
}

이스케이프 문자열은 드물어 99.9%는 새 할당이 없습니다.

그런데 Cow나 “clone-on-write 스마트 포인터”라는 이름은 여전히 와닿지 않았습니다.

Cow는 빌린 데이터를 불변으로 감싸고, 변경이나 소유권이 필요할 때 게으르게 클론하는 스마트 포인터…

처음 Rust를 배울 땐(지금도) 이 설명이 별 도움이 안 됩니다.

Twitter 글에서 clone-on-write는 사실 특수한 사용 사례일 뿐이라고 했습니다. 더 나은 이름은 RefOrOwned — 소유 데이터와 참조 둘 중 하나를 담는 타입입니다.

SIMD

옛 Rust 블로그를 보다 Portable SIMD Project Group 발표에 눈길이 갔습니다. SIMD를 써 보고 싶었는데 기회가 없었죠. 조사 끝에 파서에 쓸 만한 글을 찾았습니다: Daniel Lemire의 문자열에서 공백을 얼마나 빨리 제거할 수 있나? RapidJSON이 SIMD로 공백을 건너뛰기도 했다는 걸 알았습니다.

portable SIMD와 RapidJSON 코드를 빌려 공백 건너뛰기여러 줄 주석 건너뛰기까지 했습니다.

둘 다 몇 퍼센트씩 개선했습니다.

키워드 매칭

프로파일 상단에 전체 시간의 약 1–2%를 먹는 핫 패스가 있습니다.

문자열을 JS 키워드와 맞춥니다:

rust
fn match_keyword(s: &str) -> Self {
    match s {
        "as" => As,
        "do" => Do,
        "if" => If,
        ...
        "constructor" => Constructor,
        _ => Ident,
    }
}

TypeScript까지 합치면 맞춰야 할 문자열이 84개입니다. V8 블로그 Blazingly fast parsing, part 1gperf 기반 키워드 매칭이 자세히 나옵니다.

키워드 목록이 정적이므로 각 식별자에 후보가 최대 하나인 완전 해시 함수를 만들 수 있습니다. V8은 gperf로 길이와 앞 두 글자로 후보 하나를 고릅니다. 입력 길이가 그 키워드 길이와 같을 때만 문자열 비교를 합니다.

짧은 해시 + 정수 비교면 84번 문자열 비교보다 빠를 것 같지만 여러 번 시도했지만 소용 없었습니다.

LLVM이 이미 최적화해 준 상태였습니다. rustc --emit=llvm-ir을 보면:

switch i64 %s.1, label %bb6 [
  i64 2, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit.i"
  i64 3, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit280.i"
  i64 4, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit325.i"
  i64 5, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit380.i"
  i64 6, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit450.i"
  i64 7, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit540.i"
  i64 8, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit590.i"
  i64 9, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit625.i"
  i64 10, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit655.i"
  i64 11, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit665.i"
], !dbg !191362

%s는 문자열, %s.1은 길이… 문자열 길이로 분기합니다! 컴파일러가 더 똑똑했습니다 😃.

(네, LLVM IR와 어셈블리까지 봤습니다.)

@strager의 YouTube 영상 Faster than Rust and C++: the PERFECT hash table도 유익합니다.

결국 키워드 매칭은 전체의 1–2%라 단순한 구현으로 충분하고, 며칠 쏟은 완전 해시맵은 Rust 도구만으로 만들기 어렵기도 해서 접었습니다.

린터(Linter)

린터는 소스의 문제를 분석하는 프로그램입니다.

가장 단순한 형태는 AST 노드를 돌며 규칙을 검사하는 것입니다. 방문자 패턴을 쓸 수 있습니다:

rust
pub trait Visit<'a>: Sized {
    // ... lots of visit functions

    fn visit_debugger_statement(&mut self, stmt: &'a DebuggerStatement) {
        // report error
    }
}

부모를 가리키는 트리

방문자로 아래로 내려가기는 쉬운데, 정보를 위해 위로 올라가야 하면 어떨까요?

Rust에서는 AST 노드에 포인터를 더할 수 없어 까다롭습니다.

잠시 AST를 잊고 부모 포인터를 갖는 일반 트리를 생각해 봅시다. 노드 타입은 모두 같은 Node로 두고 Rc로 부모를 가리킬 수 있습니다:

rust
struct Node {
    parent: Option<Rc<Node>>,
}

변경이 필요하면 번거롭고, 노드를 서로 다른 시점에 드롭해 성능도 떨어집니다.

더 나은 방법은 Vec을 저장소로 쓰고 인덱스로 포인터를 표현하는 것입니다.

rust
struct Tree {
    nodes: Vec<Node>
}

struct Node {
    parent: Option<usize> // index into `nodes`
}

indextree가 잘 맞습니다.

다시 AST로 돌아와, 모든 종류를 감싸는 enum을 노드가 가리키게 indextree를 씁니다. 이를 비타입(untyped) AST라 부릅니다.

rust
struct Node<'a> {
    kind: AstKind<'a>
}

enum AstKind<'a> {
    BlockStatement(&'a BlockStatement<'a>),
    // ...
    ArrayExpression(&'a ArrayExpression<'a>),
    // ...
    Class(&'a Class<'a>),
    // ...
}

마지막으로 방문자 안에 트리를 만드는 콜백을 넣습니다.

rust
pub trait Visit<'a> {
    fn enter_node(&mut self, _kind: AstKind<'a>) {}
    fn leave_node(&mut self, _kind: AstKind<'a>) {}

    fn visit_block_statement(&mut self, stmt: &'a BlockStatement<'a>) {
        let kind = AstKind::BlockStatement(stmt);
        self.enter_node(kind);
        self.visit_statements(&stmt.body);
        self.leave_node(kind);
    }
}

impl<'a> Visit<'a> for TreeBuilder<'a> {
    fn enter_node(&mut self, kind: AstKind<'a>) {
        self.push_ast_node(kind);
    }

    fn leave_node(&mut self, kind: AstKind<'a>) {
        self.pop_ast_node();
    }
}

최종 형태는 indextree::Arena<Node<'a>>로 각 노드가 AstKind<'a>를 가리킵니다. indextree::Node::parent로 부모를 얻을 수 있습니다.

부모 포인터 트리를 만들면 방문자를 새로 안 짜도 노드를 돌기 쉽습니다. 린터는 indextree 안의 모든 노드를 도는 간단한 루프가 됩니다:

rust
for node in nodes {
    match node.get().kind {
        AstKind::DebuggerStatement(stmt) => {
        // report error
        }
        _ => {}
    }
}

전체 예는 여기입니다.

겉보기에 느려 보여도 타입 든 AST를 아레나로 방문하고 indextree에 포인터만 넣는 건 선형 접근이라 효율적입니다. 벤치마크상 ESLint보다 약 84배 빠르고 목적에는 충분합니다.

파일 병렬 처리

린터는 디렉터리 순회에 ignore 크레이트를 씁니다. .gitignore.eslintignore 같은 추가 무시 규칙을 지원합니다.

병렬 API는 없어 ignore::Walk::new(".")par_iter도 없습니다.

대신 원시(primitive) 패턴을 씁니다

rust
let walk = Walk::new(&self.options);
rayon::spawn(move || {
    walk.iter().for_each(|path| {
        tx_path.send(path).unwrap();
    });
});

let linter = Arc::clone(&self.linter);
rayon::spawn(move || {
    while let Ok(path) = rx_path.recv() {
        let tx_error = tx_error.clone();
        let linter = Arc::clone(&linter);
        rayon::spawn(move || {
            if let Some(diagnostics) = Self::lint_path(&linter, &path) {
                tx_error.send(diagnostics).unwrap();
            }
            drop(tx_error);
        });
    }
});

모든 진단을 한 스레드에서 출력하는 데 도움이 됩니다. 글의 마지막 주제로 이어집니다.

출력이 느리다

진단 출력 자체는 빨랐는데, 거대 모노레포를 린트할 때마다 수천 줄을 출력하는 시간이 무한히 길게 느껴졌습니다. Rust 이슈를 뒤지다 다음을 찾았습니다:

요약하면 println!은 줄마다 stdout을 잠글 수 있어 라인 버퍼링이라 느립니다. 블록 버퍼링을 선택해야 하는데 설명은 여기입니다.

rust
use std::io::{self, Write};

let stdout = io::stdout(); // get the global stdout entity
let mut handle = io::BufWriter::new(stdout); // optional: wrap that handle in a buffer
writeln!(handle, "foo: {}", 42); // add `?` if you care about errors here

또는 stdout에 락을 잡습니다.

rust
let stdout = io::stdout(); // get the global stdout entity
let mut handle = stdout.lock(); // acquire a lock on it
writeln!(handle, "foo: {}", 42); // add `?` if you care about errors here