순수 TypeScript 기반 flex 레이아웃 엔진이 마지막 WASM‑Yoga 격차를 메우다
출처: Dev.to
TL;DR
나는 순수 TypeScript 로만 구현한 터미널 UI용 flex 레이아웃 엔진 Pilates 를 만들고 있다. 지난 주 기준으로, 내 벤치마크 스위트에 포함된 9가지 시나리오 모두에서 순수 TS 엔진이 Ink가 사용하는 WASM Yoga 보다 빠르다 — 구조 변형 작업(프레임당 행을 추가·제거)까지 포함해서, Yoga가 약 5배 빠르던 것이 15~17단계에서 차이를 좁힌 뒤, 이제는 순수 TypeScript 로 약 1.7배 빠른 Pilates 가 승리했다.
네이티브 바인딩도, WASM 포팅도 없다. 해결책은 알고리즘적인 것이었으며, 그 알고리즘 수정은 TS 에서도 그대로 동작했다.
Median latency, win32‑x64, Node 22, ~5초 tinybench 윈도우, 부트스트랩 CI95:
| 시나리오 | Pilates | yoga‑layout (WASM) | 비율 |
|---|---|---|---|
| tiny (10 nodes) | 4.5µs | 19.0µs | 4.2× faster |
| realistic (~100) | 121µs | 328µs | 2.7× faster |
| stress (~1000) | 601µs | 1.94ms | 3.2× faster |
| big (~5000) | 3.32ms | 9.17ms | 2.8× faster |
| huge (~10000) | 8.62ms | 18.5ms | 2.1× faster |
| hot‑relayout | 16.3µs | 83.0µs | 5.1× faster |
| hot‑relayout + boundaries | 15.8µs | 77.8µs | 4.9× faster |
| hot‑relayout (text mutation) | 8.9µs | 90.6µs | 10× faster |
| hot‑structural | 71.3µs | 118.3µs | 1.7× faster |
주의 사항: 9개의 직접 선정한 시나리오에 대한 결과이며, 보편적인 주장이라 할 수는 없다. pnpm bench 로 재현 가능 – 최신 머신에서 약 5분 정도 소요된다.
터미널 UI는 WASM 엔진에 매우 적합하지 않은 워크로드다. 트리는 작지만(10~10,000 노드) 업데이트가 빈번하다—키 입력 하나, 틱 하나, 프레임 하나. JS → WASM 로의 호출 비용이 지배적이다: Yoga의 호출당 커널은 몇 마이크로초 수준이지만, node.setWidth(N) 같은 JS→WASM 호출도 몇 마이크로초가 든다. 순수 TS 엔진은 이런 교차 비용이 전혀 없다.
이것이 시작점이었고, 15~17단계는 “트리를 미리 만든 뒤 구조 변형 레이아웃만 측정”하는 최악의 경우에도 이 가설이 유지된다는 증거다.
두 가지 알고리즘 변화가 핵심이었다.
- 메인 축 위치 규칙을 누적 합에서 선형 재귀식으로 바꿨다.
이전 규칙 – 각 셀은 앞선 모든 형제의 크기를 읽는다.
// Old rule — every cell reads every prior sibling mainPos[N] = sum(siblings[0..N-1].mainSize + margin + gap)
*새 규칙* – 각 셀은 바로 앞 셀만 읽는다.
// New rule — each cell only reads the previous one mainPos[N] = mainPos[N-1] + prev.mainSize + prev.marginEnd + me.marginStart + gap
`row-reverse`/`column-reverse` 같은 역방향에서는 누적 합을 그대로 사용한다. 재귀식은 “앞 셀의 이미 해결된 위치”에 의존하기 때문에 역방향에서는 적용되지 않는다.
2. **기본값 최적화**. 문법(grammar) 내 절반 정도의 입력 필드가 영원히 기본값(예: `margin: 0`, `minWidth: 0`, `maxWidth: undefined`)을 유지하고 있었다. 이들은 여전히 dirty‑flag 슬롯을 차지하고, 의존자에게 전파되며, 의존성 집합에 포함되었다.
*Phase 17*에서는 이러한 기본값들을 컴파일 시점에 상수로 접어들게 했다. 각 셀 노드가 약 15개의 필드에서 7개 정도로 줄어들었다. `nodeSig`에 접어들기 판정 비트를 추가해, 기본값 → 비기본값으로 변할 때 구조 재구성을 올바르게 트리거한다.
두 변화를 합치면 `hot‑structural`이 ~450µs에서 ~70µs 로 크게 빨라졌다.
알고리즘 작업을 시작하기 전에 엔진을 네이티브‑WASM 언어로 포팅하려고 고민했지만, 하지 않은 것이 다행이었다.
Yoga의 강점은 산술 연산 속도가 아니라 구조 변형 알고리즘에 있었다. Yoga는 이 부분을 네이티브 수준으로 처리했지만, 순수 TS 엔진은 변형당 너무 많은 작업을 다시 수행하고 있었다.
내가 만든 네이티브‑컴파일 포팅도 같은 알고리즘 구조를 물려받아 최선의 경우에만 동등해졌을 것이다. 결국 해결책은 알고리즘적인 것이었고, 그 수정은 TypeScript 에서도 그대로 작동했다. “순수 TS가 이 워크로드에서 네이티브 코드와 경쟁한다”는 것이 실제로 흥미로운 결과다.
- 1,470개의 단위·통합 테스트 통과
- 구조 차분 퍼저가 3,000회 실행에서 모두 성공
- 33개의 Yoga 오라클 픽스처(셀‑대‑셀 비교)
- 바이트 동일 캐시‑대‑콜드 차분 모드 833회 실행
작은 사건 하나를 언급하자면, 2.0.0 배포 몇 시간 뒤 `fast-check` 프로퍼티 퍼저가 실제 버그를 잡았다. `createStyleDirtier` 가 전체 스타일이 접어들어 버린 노드에서 예외를 던졌는데, 내 분석에서는 이런 상황이 발생하지 않을 것이라고 판단했었다. 퍼저가 즉시 찾아냈고, 같은 날 2.0.1 로 수정 및 회귀 테스트를 추가했으며, 2.0.0 은 npm 에서 2.0.1 로 대체되었다.
프로퍼티 기반 퍼징은 유지할 가치가 있다. 퍼저를 계속 유지할지 고민했었는데, 이번에 확실히 답을 얻었다.
`public calculateLayout()` 은 1.x 와 2.x 사이에 바이트 단위로 동일하다. SemVer‑major 상승은 내부 API와 메모리 특성 변화 때문이다:
- Typed‑array 런타임 (`Field.id` 정수 + 배열 저장소가 `Map` 대체)
- LayoutPool 무제한 성장 (`phase 15C` 에서 FinalizationRegistry 기반 재활용 시도했지만 2배 회귀 발생, 제거)
- 단일 dirty bool 대신 per‑property dirty 비트마스크
- 선형 재귀식 + 기본값 접기 (위에서 설명한 알고리즘 변화)
공개된 API만 사용한다면, 업그레이드 시 속도 향상이 투명하게 적용된다.
```bash
git clone https://github.com/pilatesjs/pilates
cd pilates
pnpm install
pnpm bench # ~5분
또는 엔진을 바로 설치한다:
npm install @pilates/core
전체 React 스택(리컨실러 + 위젯):
npm install @pilates/react @pilates/widgets react
적대적인 벤치마크를 언제든 환영한다 — 이 접근법이 무너지는 워크로드가 있다면 꼭 찾아보고 싶다. 현재 프로젝트가 가장 필요로 하는 피드백이다.
레포 (MIT): https://github.com/pilatesjs/pilates