Rust Glancer: Bước Đột Phá Trong Việc Giảm Bộ Nhớ Cho LSP Server
Rust Glancer, một LSP server cho Rust với khả năng tiêu thụ RAM ít hơn tới hai bậc độ lớn, đang thu hút sự chú ý của cộng đồng lập trình viên. Bài viết này phân tích sâu về kiến trúc hiện tại của rust-analyzer và đề xuất một hướng tiếp cận mới, lấy cảm hứng từ IntelliJ, để tối ưu hiệu suất và giảm tải bộ nhớ.
Rust Glancer — một máy chủ LSP (Language Server Protocol) chức năng cho Rust với khả năng sử dụng ít RAM hơn tới hai bậc độ lớn — là một dự án cực kỳ ấn tượng. Hãy nhanh chóng khám phá nó! Bài viết này khởi nguồn từ một bình luận trên lobste.rs, nhưng tôi nhận ra rằng nên đăng tải nó một cách trang trọng hơn. Đừng mong đợi văn phong trau chuốt nhé!
Kiến trúc Rowan và vấn đề hiệu năng
rust-analyzer hiện đang sử dụng rowan để biểu diễn cây cú pháp (syntax tree). Và thẳng thắn mà nói, rowan không thực sự tốt: tôi đã từng cân nhắc rất nhiều về việc phân tích cú pháp tăng dần (incremental parsing) và các thao tác refactoring theo kiểu DOM-mutation.
Rowan khá phù hợp cho những mục đích đó. Nhưng đó chỉ là 1% trường hợp sử dụng. 99% còn lại là toàn bộ mã nguồn trong 6666 dependency mà bạn sẽ không bao giờ ngó tới, nhưng vẫn cần được phân tích một cách nông cạn. Ngay cả đối với một công cụ tăng dần với mục tiêu chính là refactoring, cấu trúc AST chính nên chỉ là một danh sách các mảng.
Thách thức về dữ liệu và bộ nhớ
Không gian làm việc (workspace) của Rust thực sự chứa một lượng lớn thông tin cần được lập chỉ mục: hàng nghìn hàm, struct, trait, mối quan hệ giữa chúng, thân hàm và các câu lệnh bên trong. Mỗi mục đều cần được phân tích và ghi nhớ, và bạn không thể gian lận nếu muốn có tính năng như "tìm tất cả tham chiếu đến struct này".
Nếu tôi hiểu đúng, Rust Glancer muốn xử lý từng thân hàm một cách riêng lẻ. Tôi nghĩ phần này có thể được làm lười biếng (lazy) nhưng không tăng dần (incremental) với chi phí rất nhỏ. Hãy lập chỉ mục tất cả các mục, nhưng đối với hàm, chỉ xử lý file đang mở. Điều này có thể kết hợp những điểm mạnh của cả hai phương pháp.
Sẽ thú vị nếu so sánh mức sử dụng bộ nhớ với Rust Rover. Nếu loại bỏ phần GUI của IDE, tôi tin rằng Rust Rover sẽ gọn nhẹ hơn.
Vấn đề với Proc Macros
Một số tính năng khó có thể được hỗ trợ, chẳng hạn như build scripts hoặc proc macros thông qua lời gọi proc macro. Có thể tôi đang hợp lý hóa hoặc nhớ sai, nhưng tôi nhớ chính xác là khi thêm proc macros, mọi thứ bắt đầu trở nên cồng kềnh một cách bất hợp lý.
Việc mở rộng proc macros rất chậm vì chúng ta đang chạy mã thực tế, không thể sử dụng các chiêu trò IDE thông thường. Ngoài ra, proc macros còn sinh ra rất nhiều mã mới. Có thời điểm tôi đo lường và nhận thấy khoảng 30% kích thước binary của rust-analyzer là do mã phân tích JSON. Nếu không ai nhìn thấy mã đó, nó chẳng thể gây hại cho ai, phải không?
Một hướng tiếp cận tiềm năng là áp dụng mẹo của Sorbet: không chạy meta programming mà thay vào đó sử dụng một giao diện plugin để "giải thích" tác động của những gì nó sẽ làm. Thay vì chạy serde, chúng ta chỉ cần thêm một shim để chèn impl Serialize for T {} với phần thân rỗng.
Vấn đề đồng bộ hóa trong LSP
Tôi không chắc tại sao, nhưng trong rust-analyzer tôi nhận thấy khi các agent chỉnh sửa mã, inlay hints có thể bị lệch vị trí. Mô hình dữ liệu cốt lõi của rust-analyzer rất nghiêm ngặt về việc luôn quan sát các snapshot mã nhất quán, và cố gắng đảm bảo client và server có một góc nhìn chung, tuần tự hóa nghiêm ngặt về thế giới.
Thật đáng tiếc khi LSP không cho phép điều đó đúng hoàn toàn, chỉ đúng theo phương pháp heuristic, không giống như giao thức Dart Analyzer cũ, có đồng bộ dữ liệu âm thanh. Hơn nữa, việc triển khai theo dõi file của chúng tôi còn sơ sài! Có hai backend: yêu cầu editor theo dõi hoặc sử dụng theo dõi phía server. Hãy thử thay đổi tùy chọn này và xem liệu nó có giúp ích không? Tuy nhiên, theo trí nhớ của tôi, API của watcher gốc về cơ bản là racy, và tôi không thực hiện công việc phức tạp cụ thể cho từng nền tảng để khắc phục.
Bài học từ IntelliJ: Kiến trúc Multi-Backend
Điều chính tôi muốn viết, và là lý do tôi rời khỏi vùng an toàn của lobste.rs để đến với sự thoải mái xa xỉ của một buffer Emacs, là việc rust-analyzer hiện tại hơi giống bức tranh nửa con ngựa — chỉ vẽ được phần đầu.
Một ý tưởng lớn của IntelliJ là PSI API (về cơ bản là AST với các kiểu đã được phân giải) thực sự là một interface với nhiều nguồn cung cấp. Trong một cách sử dụng điển hình, có ít nhất ba backend tham gia:
- Đối với file đang mở trong editor, được người dùng chỉnh sửa tích cực: PSI được hỗ trợ bởi cây cú pháp cụ thể.
- Đối với các file dự án còn lại: PSI được hỗ trợ bởi Stub Tree — một biểu diễn nhỏ gọn trên đĩa chỉ lưu trữ các phần "hiển thị ra bên ngoài" (không bao gồm thân hàm). Khi người dùng điều hướng đến một file mới, PSI của nó chuyển đổi minh bạch từ stubs sang syntax tree.
- Đối với dependencies: PSI thường được hỗ trợ bởi các file .class đã biên dịch do javac tạo ra. Nếu bạn điều hướng đến đó, IDE sẽ giải mã (decompile) mọi thứ cho bạn! Cực kỳ ngầu!
Đề xuất cho Rust Analyzer và Glancer
Tôi tin rằng đây là cách mọi thứ nên hoạt động: rust-analyzer không nên sử dụng salsa cho tất cả 6666 dependencies mà bạn chưa từng nhìn tới. Thay vào đó, nó nên dùng các file .rmeta của rustc, và chuyển sang salsa một cách minh bạch, chỉ khi người dùng bắt đầu động vào thư mục ~/.cargo/registry/src.
Điều kiện tiên quyết là xác định một API trừu tượng để truy cập mã Rust. Đó luôn là kế hoạch ban đầu, và chúng tôi đã bắt đầu thực hiện tại: hackmd.io. Điểm mấu chốt là rmeta-transparent: mã nguồn có thể không có sẵn cho một số crate, vì vậy API nên hỗ trợ các file rmeta đã biên dịch trước làm đầu vào.
Nhưng tôi nghĩ công việc đó chưa bao giờ được hoàn thành. Đây vẫn có vẻ là "quả dưa hấu" dễ hái nhất: chia thế giới thành hai phần — phần tăng dần nhanh nhạy ở đỉnh băng chìm, và phần đọc-gần-như-chỉ-đọc, nằm gọn trên đĩa, tối tăm và ẩm ướt — nơi sinh sôi của các cuộc tấn công chuỗi cung ứng.
Một kiến trúc glance analyzer như vậy, theo tôi, sẽ tuyệt vời!