Rust WASM 파서를 TypeScript로 다시 작성했더니 3배 빨라졌다
(openui.com)- 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 객체로 구조화된 텍스트 파싱: 직렬화 비용이 지배적
- 짧은 입력을 자주 호출하는 함수: 경계 비용이 계산보다 큼
- 핵심 교훈
- 병목 위치를 프로파일링 후 언어를 선택해야 함
-
serde-wasm-bindgen의 직접 객체 전달은 더 비쌈 - 알고리듬 복잡도 개선이 언어 전환보다 효과적
- WASM과 JS는 힙을 공유하지 않으며, 변환 비용은 항상 존재
최종 결과: TypeScript 전환과 증분 캐싱으로 호출당 2.2~4.6배, 전체 스트림 2.6~3.3배 성능 향상 달성
Hacker News 의견들
-
진짜 핵심은 Rust보다 TypeScript가 아니라, O(N²)에서 O(N) 으로 줄인 스트리밍 알고리즘 수정임
문(statement) 단위 캐싱으로 이뤄진 이 변경만으로도 3.3배 향상이 있었음
언어 선택과는 별개로, 사용자 입장에서 느끼는 지연(latency) 개선의 주된 요인은 이 부분임
제목이 이런 흥미로운 엔지니어링 포인트를 과소평가한 느낌임- uv 프로젝트도 마찬가지임. 사람들은 “rust rulez!”만 외치지만, 실제 이득은 언어가 아니라 알고리즘 개선에서 나옴
- 클릭베이트를 뚫고 본질을 짚어줘서 고마움
글 자체는 흥미롭지만, 요즘엔 과도한 클릭 유도형 제목에 지쳐 있음 - n² 표현은 다소 과장된 듯함
각 호출 시간을 측정하고 중앙값(median) 을 쓰는 방식인데, 브라우저 환경에서 타이밍 공격 방어 로직이 있는 JS 엔진이라 정확도에 의문이 있음 - 결국 오해를 부르는 제목에 가깝다고 생각함
-
“언어 L에서 M으로 코드를 다시 썼더니 빨라졌다”는 말은 당연한 결과임
얽히고 잘못된 결정을 바로잡고, 새로 생긴 더 나은 접근법을 적용할 기회였기 때문임
사실 L=M이어도 마찬가지로, 속도 향상은 언어가 아니라 리라이트와 재설계 과정에서 나오는 것임- 이제 제3자가 원본을 모르는 상태에서 TypeScript 버전을 Rust로 다시 리라이트하면 또 성능이 오를지도 모름
- 같은 언어로 다시 써도 개선 효과가 생기는 걸 자주 봄
-
Rust와 JS 경계에서 객체 직렬화 성능을 개선하려고 더 깊이 파봤음
serde의 접근 방식이 성능상 좋지 않아 보였고, 이를 개선한 시도를 내 블로그 글에 정리했음 -
Open UI가 WASM 관련 작업을 안 하는 이유가 궁금했음
그런데 이번 새 회사가 Open UI라는 이름을 써서 혼란스러웠음
원래 Open UI W3C Community Group은 5년 넘게 HTML의 popover, 커스터마이즈 가능한 select, invoker command, accordion 같은 표준을 만드는 그룹임
그들은 정말 훌륭한 일을 하고 있음 -
“JSON 왕복을 건너뛰자”는 시도에서 serde-wasm-bindgen을 통합했다는데, 결국 바이너리 형태의 JSON 재발명처럼 보임
요즘 V8의 JSON은 이미 매우 최적화되어 있고, simdjson 같은 구현은 초당 기가바이트 단위로 처리 가능함
JSON이 병목일 가능성은 낮다고 봄 -
그 블로그의 디자인이 정말 마음에 들었음
스크롤 위치에 따라 헤딩을 하이라이트하는 ‘scrollspy’ 사이드바가 특히 좋았음
Claude가 알려준 바로는 fumadocs.dev로 만든 것 같음- 흥미로움. 나도 곧 좋은 문서 사이트를 만들어야겠다고 생각함
-
Rust WASM 파서의 목적이 잘 이해되지 않았음
글에서 그 부분이 명확하지 않아 더 설명이 필요함- LLM이 생성한 UI 컴포넌트를 정의하는 전용 언어를 사용한다고 함
이는 프롬프트 인젝션으로 인한 정보 유출을 막기 위한 것으로 보임
파서는 LLM에서 스트리밍되는 청크를 컴파일해 실시간 UI를 구성함
이전에는 청크마다 파서를 처음부터 다시 시작했는데, 이를 점진적 처리 방식으로 바꾸면서 (Rust→TypeScript 포팅 중) 성능이 크게 향상되었음
- LLM이 생성한 UI 컴포넌트를 정의하는 전용 언어를 사용한다고 함
-
TypeScript가 요즘 Golang 기반으로 돌아가는 게 아닌가 하는 의문이 있었음
- TypeScript 컴파일러를 Go로 다시 쓰는 프로젝트가 진행 중인데, 그걸 말하는 것 같음
-
농담이지만, 다시 Rust로 리라이트하면 또 3배 성능 향상이 있을지도 모르겠음 /s