Chúng tôi đã viết lại Trình phân tích cú pháp Rust WASM của mình trong TypeScript - và nó nhanh hơn gấp 3 lần
Backend·Hacker News·3 lượt xem

Chúng tôi đã viết lại Trình phân tích cú pháp Rust WASM của mình trong TypeScript - và nó nhanh hơn gấp 3 lần

We rewrote our Rust WASM Parser in TypeScript – and it got 3x Faster

AI Summary

Đội ngũ ban đầu xây dựng một parser Rust WASM cho LLM-generated DSL của họ, kỳ vọng hiệu năng gần như native. Tuy nhiên, họ nhận ra nút thắt cổ chai thực sự không phải là việc thực thi Rust, mà là chi phí phát sinh khi truyền dữ liệu giữa bộ nhớ JS và WASM, chủ yếu qua quá trình JSON serialization/deserialization. Trớ trêu thay, việc cố gắng bỏ qua JSON bằng `serde-wasm-bindgen` để truyền thẳng object JS lại dẫn đến hiệu năng tệ hơn nữa do các chuyển đổi dữ liệu quá chi tiết. Bài học cho các developer là cần profiling cẩn thận toàn bộ ranh giới thực thi, không chỉ logic cốt lõi; đôi khi, một cách tiếp cận đơn giản, trực tiếp hơn trong host runtime (như TypeScript thuần) có thể vượt trội WASM đáng kể nhờ giảm thiểu chi phí giao tiếp giữa các runtime.

Chúng tôi đã xây dựng trình phân tích cú pháp openui-lang trong Rust và biên dịch nó thành WASM. Logic rất hợp lý: Rust rất nhanh, WASM mang lại cho bạn tốc độ gần như nguyên gốc trong trình duyệt và trình phân tích cú pháp của chúng tôi là một trình phân tích nhiều giai đoạn khá phức tạp...

Chúng tôi đã xây dựng trình phân tích cú pháp openui-lang trong Rust và biên dịch nó thành WASM. Logic rất hợp lý: Rust nhanh, WASM mang đến cho bạn tốc độ gần như nguyên gốc trong trình duyệt, và trình phân tích cú pháp của chúng tôi là một đường dẫn nhiều giai đoạn khá phức tạp. Tại sao bạn không muốn điều đó ở Rust?

Hóa ra chúng tôi đã tối ưu hóa sai cách.

Trình phân tích cú pháp openui-lang chuyển đổi DSL tùy chỉnh do LLM phát ra thành cây thành phần React. Nó chạy trên mọi đoạn phát trực tuyến — vì vậy độ trễ rất quan trọng. Quy trình có sáu giai đoạn:

trình tự động đóng → lexer → bộ tách → trình phân tích cú pháp → trình phân giải → trình ánh xạ → ParseResult
  • Trình tự động đóng: làm cho văn bản một phần (giữa luồng) hợp lệ về mặt cú pháp bằng cách thêm dấu ngoặc/dấu ngoặc kép đóng tối thiểu
  • Lexer : trình quét ký tự một lần, phát ra mã thông báo đã nhập
  • Bộ chia: cắt luồng mã thông báo thành các câu lệnh id = biểu thức
  • Trình phân tích cú pháp: trình phân tích biểu thức gốc đệ quy, xây dựng AST
  • Trình giải quyết: nội tuyến tất cả các tham chiếu biến (hỗ trợ nâng lên, phát hiện tham chiếu vòng tròn)
  • Mapper: chuyển đổi AST nội bộ thành OutputNode công khai định dạng được trình kết xuất React sử dụng

Mỗi lệnh gọi tới trình phân tích cú pháp WASM đều phải trả một chi phí bắt buộc bất kể mã Rust chạy nhanh đến mức nào:

Thế giới JS Thế giới WASM
───────────────────────────── ───────────────────────────
wasmParse(đầu vào)

 ├─ sao chép chuỗi: JS heap → Bộ nhớ tuyến tính WASM (phân bổ + memcpy)

 │ Rust phân tích cú pháp ✓ nhanh
 │ serde_json::to_string() ← tuần tự hóa kết quả

 ├─ sao chép chuỗi JSON: WASM → đống JS (phân bổ + memcpy)

 JSON.parse(jsonString) ← kết quả giải tuần tự hóa

 trả về ParseResult

Bản thân quá trình phân tích cú pháp của Rust chưa bao giờ là phần chậm. Chi phí hoàn toàn nằm trong giới hạn: sao chép chuỗi vào, tuần tự hóa kết quả thành chuỗi JSON, sao chép chuỗi JSON ra, sau đó V8 giải tuần tự hóa chuỗi đó trở lại thành đối tượng JS.

Câu hỏi tự nhiên là: điều gì sẽ xảy ra nếu WASM trả về trực tiếp một đối tượng JS, bỏ qua bước tuần tự hóa JSON? Chúng tôi đã tích hợp serde-wasm-bindgen để thực hiện chính xác điều này — nó chuyển đổi cấu trúc Rust thành JsValue và trả về trực tiếp.

Đó là Chậm hơn 30%.

Đây là lý do tại sao. JS không thể đọc các byte của cấu trúc Rust từ bộ nhớ tuyến tính WASM dưới dạng đối tượng JS gốc — hai thời gian chạy sử dụng bố cục bộ nhớ hoàn toàn khác nhau. Để xây dựng một đối tượng JS từ dữ liệu Rust, serde-wasm-bindgen phải cụ thể hóa đệ quy dữ liệu Rust thành các mảng và đối tượng JS thực, bao gồm nhiều chuyển đổi chi tiết trên ranh giới thời gian chạy cho mỗi lệnh gọi parse().

So sánh điều đó với cách tiếp cận JSON: serde_json::to_string() chạy trong Rust thuần túy với các ranh giới bằng 0, tạo ra một chuỗi, một memcpy sao chép nó vào vùng nhớ heap JS, sau đó JSON.parse C++ gốc của V8 xử lý nó trong một lượt được tối ưu hóa duy nhất. Các hoạt động ít hơn, lớn hơn và được tối ưu hóa hơn sẽ chiến thắng nhiều hoạt động nhỏ.

Điểm chuẩn: Chuỗi JSON so với JsValue trực tiếp (1000 lượt chạy, µs mỗi cuộc gọi)

Cố địnhChuyến đi khứ hồi JSONserde-wasm-bindgenThay đổi
bảng đơn giản20,522,5-9% chậm hơn
biểu mẫu liên hệ61,479,4Chậm hơn 29%
trang tổng quan57,974,0Chậm hơn 28%

Chúng tôi đã hoàn nguyên thay đổi này ngay lập tức.

Chúng tôi đã chuyển toàn bộ quy trình phân tích cú pháp sang TypeScript. Cùng một kiến trúc sáu giai đoạn, cùng hình dạng đầu ra ParseResult — không WASM, không ranh giới, chạy hoàn toàn trong vùng nhớ V8.

Phương pháp đo điểm chuẩn: Phân tích cú pháp một lần

Những gì được đo lường: Một lệnh gọi parse(completeString) duy nhất trên chuỗi đầu ra đã hoàn thành. Điều này tách biệt chi phí của trình phân tích cú pháp mỗi cuộc gọi.

Cách thực hiện: 30 lần khởi động để ổn định JIT, sau đó là 1000 lần lặp theo thời gian bằng cách sử dụng performance.now() (µs độ chính xác). Trung vị được báo cáo. Lịch thi đấu là các cây thành phần thực do LLM tạo được tuần tự hóa theo cú pháp phát trực tuyến thực của từng định dạng.

Lịch thi đấu:

  • bảng đơn — gốc + một Bảng có 3 cột và 5 hàng (~180 ký tự)
  • biểu mẫu liên hệ — bố cục gốc + biểu mẫu với 6 trường nhập liệu + nút gửi (~400 ký tự)
  • trang tổng quan — gốc + điều hướng thanh bên + 3 thẻ số liệu + biểu đồ + bảng dữ liệu (~950 ký tự)

Kết quả: Phân tích cú pháp một lần (trung vị µs, 1000 lượt chạy)

Sửa lỗiTypeScriptWASMTăng tốc
bảng đơn giản9.320,52,2 lần
biểu mẫu liên hệ 13,461,44,6x
trang tổng quan19,457,93,0x

Việc loại bỏ WASM đã khắc phục được chi phí mỗi cuộc gọi nhưng kiến trúc phát trực tuyến vẫn kém hiệu quả hơn.

Trình phân tích cú pháp được gọi trên mỗi đoạn LLM. Cách tiếp cận ngây thơ tích lũy các khối và phân tích lại toàn bộ chuỗi từ đầu:

Đoạn 1: parsing("root = Root([t") → 14 ký tự
Đoạn 2: phân tích ("root = Root([tbl])\ntbl = T") → 27 ký tự
Đoạn 3: phân tích cú pháp (full_accumulated_string) → ...

Đối với đầu ra 1000 ký tự được phân phối theo khối 20 ký tự: 50 lệnh gọi phân tích cú pháp xử lý tổng cộng tích lũy ~25.000 ký tự. O(N²) về số lượng khối.

Cách khắc phục: Bộ nhớ đệm gia tăng cấp câu lệnh

Các câu lệnh được kết thúc bằng dòng mới có độ sâu 0 là bất biến - LLM sẽ không bao giờ quay lại và sửa đổi chúng. Chúng tôi đã thêm một trình phân tích cú pháp phát trực tuyến để lưu trữ các câu lệnh AST đã hoàn thành vào bộ nhớ đệm:

Trạng thái: { buf, CompleteEnd, CompleteSyms, firstId 

Mỗi lần đẩy(chunk):
 1. Quét buf từ CompleteEnd để tìm dòng mới có độ sâu 0
 2. Đối với mỗi câu lệnh hoàn chỉnh được tìm thấy: phân tích cú pháp + bộ đệm AST → hoàn thành trướcEnd
 3. Câu lệnh đang chờ xử lý (cuối cùng, chưa đầy đủ): tự động đóng + phân tích cú pháp mới
 4. Hợp nhất đã lưu trong bộ nhớ đệm + đang chờ xử lý → giải quyết + bản đồ → trả về ParseResult

Các câu lệnh đã hoàn thành sẽ không bao giờ được phân tích cú pháp lại. Chỉ có câu lệnh đang thực hiện ở cuối mới được phân tích cú pháp lại trên mỗi đoạn. O(total_length) thay vì O(N²).

Phương pháp điểm chuẩn: Tổng chi phí phân tích cú pháp toàn luồng

Những gì được đo lường: Tổng chi phí phân tích được tích lũy qua mỗi lệnh gọi chunk cho một tài liệu hoàn chỉnh. Điều này khác với điểm chuẩn một lần — nó đo tổng của tất cả các lệnh gọi phân tích cú pháp trong một luồng thực chứ không phải một lệnh gọi. Đây là con số ảnh hưởng đến khả năng phản hồi thực tế mà người dùng nhận thấy.

Cách chạy: Tài liệu được phát lại theo từng đoạn 20 ký tự. Mỗi đoạn kích hoạt một lệnh gọi parse() (ngây thơ) hoặc push() (gia tăng). Tổng thời gian trên tất cả các cuộc gọi được ghi lại. 100 lượt phát lại toàn bộ luồng, lấy trung bình.

Kết quả: Tổng chi phí phân tích toàn luồng (trung bình µ trên tất cả khối)

Bản sửa lỗiTS ngây thơ (phân tích lại từng đoạn) TS tăng dần (bộ đệm đã hoàn thành)Tăng tốc
bảng đơn giản6977không có (câu lệnh đơn, không có bộ đệm lợi ích)
biểu mẫu liên hệ3161222,6x
trang tổng quan8402553,3x

bảng đơn lịch thi đấu là một câu lệnh duy nhất — không có gì để lưu vào bộ đệm, vì vậy cả hai cách tiếp cận đều tương đương nhau. Lợi ích sẽ tăng theo số lượng câu lệnh vì nhiều tài liệu được lưu vào bộ nhớ đệm và bị bỏ qua trên mỗi đoạn.

Tại sao hai số TS trông khác nhau

Bảng một lần hiển thị 13,4µs cho biểu mẫu liên hệ; bảng phát trực tuyến hiển thị 316µs (ngây thơ). Những điều này không hề mâu thuẫn - chúng đo lường những thứ khác nhau:

  • 13,4µs = chi phí của một lệnh gọi parse() trên chuỗi 400 ký tự hoàn chỉnh
  • 316µs = tổng chi phí ~20 parse() lệnh gọi trong luồng (đoạn 1 phân tích 20 ký tự, đoạn 2 phân tích 40 ký tự, ..., đoạn 20 phân tích 400 ký tự — tổng tích lũy của tất cả các lệnh gọi ngày càng tăng đó)
Phương pháp tiếp cậnChi phí mỗi cuộc gọiTổng toàn bộ luồngGhi chú
WASM + JSON khứ hồi20-61µsđường cơ sởSao chép chi phí của mỗi cuộc gọi
WASM + serde-wasm-bindgen22-79µs +Chậm hơn 9-29%Hàng trăm lần vượt qua ranh giới nội bộ
TypeScript (phân tích lại ngây thơ)9-19µs69-840µsKhông có ranh giới, nhưng truyền phát O(N²)
TypeScript (gia tăng)9-19µs69-255µsKhông có ranh giới + phát trực tuyến O(N)

Kết quả cuối cùng: mỗi cuộc gọi nhanh hơn 2,2-4,6 lần và tổng chi phí phát trực tuyến thấp hơn 2,6-3,3 lần.

Trải nghiệm này đã giúp chúng tôi mài giũa suy nghĩ về các trường hợp sử dụng phù hợp cho WASM:

Giới hạn tính toán với khả năng tương tác tối thiểu: xử lý hình ảnh/video, mật mã, mô phỏng vật lý, codec âm thanh. Đầu vào lớn → đầu ra vô hướng hoặc đột biến tại chỗ. Ranh giới hiếm khi bị vượt qua.

Thư viện gốc di động: chuyển thư viện C/C++ (SQLite, OpenCV, libpng) sang trình duyệt mà không cần viết lại JS đầy đủ.

Phân tích văn bản có cấu trúc thành các đối tượng JS: bạn phải trả chi phí tuần tự hóa theo cách nào đó. Tính toán phân tích cú pháp đủ nhanh để JIT của V8 loại bỏ mọi lợi thế của Rust. Chi phí ranh giới chiếm ưu thế.

Các hàm được gọi thường xuyên trên đầu vào nhỏ: nếu hàm được gọi 50 lần mỗi luồng và quá trình tính toán mất 5µs, bạn không thể khấu hao chi phí biên.

  1. Hồ sơ trong đó thời gian thực sự được sử dụng trước khi chọn ngôn ngữ triển khai. Đối với chúng tôi, chi phí không bao giờ nằm ở tính toán - mà luôn nằm ở việc truyền dữ liệu qua ranh giới WASM-JS.

  2. "Đối tượng trực tiếp đi qua" qua serde-wasm-bindgen không rẻ hơn. Việc xây dựng từng trường đối tượng JS từ Rust đòi hỏi nhiều lần vượt qua ranh giới hơn so với việc chuyển một chuỗi JSON chứ không phải ít hơn. Việc vượt qua ranh giới xảy ra bên trong lệnh gọi FFI duy nhất, một cách vô hình.

  3. Những cải tiến về độ phức tạp của thuật toán chiếm ưu thế trong việc tối ưu hóa cấp độ ngôn ngữ. Việc chuyển từ O(N²) sang O(N) trong trường hợp phát trực tuyến có tác động thực tế lớn hơn so với việc chuyển từ WASM sang TypeScript.

  4. WASM và JS không chia sẻ một đống. WASM có bộ nhớ tuyến tính phẳng (WebAssembly.Memory ) mà JS có thể đọc dưới dạng byte thô, nhưng những byte đó là bố cục bên trong của Rust - con trỏ, phân biệt đối xử enum, phần đệm căn chỉnh - hoàn toàn không rõ ràng đối với thời gian chạy JS. Việc chuyển đổi luôn được yêu cầu và luôn tốn một khoản chi phí nào đó.

Tác giả: zahlekhan

#discussion