Rust WASM 파서를 TypeScript로 다시 작성했더니 3배 빨라졌다

1 week ago 6

  • Rust로 작성된 WASM 파서는 구조적으로 빠르지만, JS-WASM 경계에서의 데이터 복사와 직렬화 오버헤드가 성능 병목으로 드러남
  • serde-wasm-bindgen을 통한 직접 객체 반환은 JSON 직렬화보다 9~29% 느렸으며, 이는 런타임 간 세밀한 변환 비용 때문임
  • TypeScript로 전체 파이프라인을 포팅하자 동일한 아키텍처에서 2.2~4.6배 빠른 단일 호출 성능을 달성
  • 스트리밍 처리에서는 문 단위 캐싱을 통한 O(N²)→O(N) 개선으로 2.6~3.3배 빠른 전체 처리 속도 확보
  • 결과적으로, WASM은 계산 집약적·저빈도 호출에 적합하고, JS 객체 파싱이나 잦은 호출 함수에는 부적합함이 확인됨

Rust WASM 파서의 구조와 한계

  • openui-lang 파서는 LLM이 생성한 DSL을 React 컴포넌트 트리로 변환하는 6단계 파이프라인으로 구성
    • 단계: autocloser → lexer → splitter → parser → resolver → mapper → ParseResult
    • 각 단계는 토큰화, 구문 분석, 변수 해석, AST 변환 등을 수행
  • Rust 코드 자체는 빠르지만, JS↔WASM 간 문자열 복사·JSON 직렬화·역직렬화 과정이 매 호출마다 발생
    • 입력 문자열 복사(JS→WASM), Rust 내부 파싱, 결과 JSON 직렬화, JSON 복사(WASM→JS), JS에서 역직렬화
  • 이 경계 오버헤드가 전체 성능을 지배했으며, Rust 계산 속도는 병목이 아님

serde-wasm-bindgen 시도와 실패

  • JSON 직렬화를 피하기 위해 Rust 구조체를 직접 JS 객체로 반환하는 serde-wasm-bindgen을 적용
  • 그러나 30% 느려짐이 관찰됨
    • Rust 구조체 메모리를 JS가 직접 읽을 수 없고, 런타임 간 메모리 레이아웃이 달라 필드 단위 변환이 필요
    • 반면 JSON 직렬화는 Rust 내부에서 한 번의 문자열 생성 후, JS에서 최적화된 JSON.parse로 처리
  • 벤치마크 결과
    Fixture JSON round-trip serde-wasm-bindgen 변화
    simple-table 20.5µs 22.5µs -9%
    contact-form 61.4µs 79.4µs -29%
    dashboard 57.9µs 74.0µs -28%

TypeScript로의 전환과 성능 향상

  • 동일한 6단계 구조를 TypeScript로 완전 포팅, WASM 경계를 제거하고 V8 힙 내에서 직접 실행
  • 단일 호출 기준 벤치마크 결과
    Fixture TypeScript WASM 속도 향상
    simple-table 9.3µs 20.5µs 2.2배
    contact-form 13.4µs 61.4µs 4.6배
    dashboard 19.4µs 57.9µs 3.0배
  • WASM 제거만으로 호출당 비용이 대폭 감소, 그러나 스트리밍 구조의 비효율은 여전히 존재

스트리밍 파싱의 O(N²) 문제와 개선

  • LLM 출력이 여러 청크로 전달될 때, 매번 전체 누적 문자열을 재파싱하는 O(N²) 비효율 발생
    • 예: 1000자 문서를 20자씩 50회 파싱 → 총 25,000자 처리
  • 해결책으로 문 단위 증분 캐싱(incremental caching) 도입
    • 완성된 문장은 캐시하고, 진행 중인 문장만 재파싱
    • 캐시된 AST와 새 AST를 병합해 결과 반환
  • 전체 스트림 기준 벤치마크
    Fixture 나이브 TS 증분 TS 속도 향상
    simple-table 69µs 77µs 없음
    contact-form 316µs 122µs 2.6배
    dashboard 840µs 255µs 3.3배
  • 문장이 많을수록 캐시 효과가 커지고, 전체 처리량이 선형적으로 개선

WASM 사용에 대한 교훈

  • 적합한 경우
    • 계산 집약적·상호작용 적은 작업: 이미지·비디오 처리, 암호화, 물리 시뮬레이션, 오디오 코덱 등
    • 기존 네이티브 라이브러리 이식: SQLite, OpenCV, libpng 등
  • 부적합한 경우
    • JS 객체로 구조화된 텍스트 파싱: 직렬화 비용이 지배적
    • 짧은 입력을 자주 호출하는 함수: 경계 비용이 계산보다 큼
  • 핵심 교훈
    1. 병목 위치를 프로파일링 후 언어를 선택해야 함
    2. serde-wasm-bindgen의 직접 객체 전달은 더 비쌈
    3. 알고리듬 복잡도 개선이 언어 전환보다 효과적
    4. WASM과 JS는 힙을 공유하지 않으며, 변환 비용은 항상 존재

최종 결과: TypeScript 전환과 증분 캐싱으로 호출당 2.2~4.6배, 전체 스트림 2.6~3.3배 성능 향상 달성

Read Entire Article