Ngày phát hành: 30 tháng 1 năm 2025
Nhiều ứng dụng WebAssembly trên web được hưởng lợi từ tính năng đa luồng, giống như các ứng dụng gốc. Nhiều luồng cho phép nhiều công việc diễn ra song song và chuyển công việc nặng ra khỏi luồng chính để tránh các vấn đề về độ trễ. Cho đến gần đây, có một số vấn đề thường gặp có thể xảy ra với các ứng dụng đa luồng như vậy, liên quan đến việc phân bổ và I/O. May mắn thay, các tính năng gần đây trong Emscripten có thể giúp ích rất nhiều cho những vấn đề đó. Hướng dẫn này cho thấy cách các tính năng này có thể giúp cải thiện tốc độ gấp 10 lần trở lên trong một số trường hợp.
Chuyển tỷ lệ
Biểu đồ sau đây cho thấy khả năng mở rộng hiệu quả nhiều luồng trong một khối lượng công việc toán học thuần tuý (từ điểm chuẩn mà chúng ta sẽ sử dụng trong bài viết này):
Chỉ số này đo lường hoạt động tính toán thuần tuý, một hoạt động mà mỗi lõi CPU có thể tự thực hiện, vì vậy, hiệu suất sẽ cải thiện khi có nhiều lõi hơn. Đường biểu đồ giảm dần về hiệu suất nhanh hơn chính là hình ảnh của việc điều chỉnh theo tỷ lệ tốt. Điều này cho thấy rằng nền tảng web có thể thực thi mã gốc đa luồng rất tốt, mặc dù sử dụng trình chạy web làm cơ sở cho tính song song, sử dụng Wasm thay vì mã gốc thực sự và các chi tiết khác có vẻ như không tối ưu.
Quản lý vùng nhớ khối xếp: malloc
/free
malloc
và free
là các hàm thư viện chuẩn quan trọng trong tất cả các ngôn ngữ bộ nhớ tuyến tính (ví dụ: C, C++, Rust và Zig) được dùng để quản lý tất cả bộ nhớ không hoàn toàn tĩnh hoặc trên ngăn xếp. Theo mặc định, Emscripten sử dụng dlmalloc
. Đây là một phương thức triển khai nhỏ gọn nhưng hiệu quả (cũng hỗ trợ emmalloc
, nhỏ gọn hơn nữa nhưng chậm hơn trong một số trường hợp). Tuy nhiên, hiệu suất nhiều luồng của dlmalloc
bị giới hạn vì nó sẽ khoá từng malloc
/free
(vì có một trình phân bổ toàn cục duy nhất). Do đó, bạn có thể gặp phải tình trạng tranh chấp và chậm nếu có nhiều lượt phân bổ trong nhiều luồng cùng một lúc. Sau đây là những gì xảy ra khi bạn chạy một phép đo điểm chuẩn cực kỳ nặng về malloc
:
Hiệu suất không chỉ không cải thiện khi có nhiều lõi hơn mà còn ngày càng xấu đi, vì mỗi luồng đều phải chờ trong thời gian dài để khoá malloc
. Đây là trường hợp tồi tệ nhất có thể xảy ra, nhưng có thể xảy ra trong khối lượng công việc thực tế nếu có đủ mức phân bổ.
mimalloc
Có các phiên bản dlmalloc
được tối ưu hoá cho nhiều luồng, chẳng hạn như ptmalloc3
, triển khai một thực thể bộ phân bổ riêng cho mỗi luồng, tránh xung đột.
Có một số trình phân bổ khác tồn tại với các tính năng tối ưu hoá nhiều luồng, chẳng hạn như jemalloc
và tcmalloc
. Emscripten quyết định tập trung vào dự án mimalloc
gần đây. Đây là một trình phân bổ được thiết kế đẹp mắt của Microsoft với khả năng di chuyển và hiệu suất rất tốt. Sử dụng như sau:
emcc -sMALLOC=mimalloc
Sau đây là kết quả của điểm chuẩn malloc
sử dụng mimalloc
:
Tuyệt lắm! Giờ đây, hiệu suất sẽ mở rộng một cách hiệu quả, ngày càng nhanh hơn với mỗi nhân.
Nếu xem xét kỹ dữ liệu về hiệu suất của một lõi trong hai biểu đồ cuối cùng, bạn sẽ thấy dlmalloc
mất 2660 mili giây và mimalloc
chỉ mất 1466 mili giây, cải thiện tốc độ gần 2 lần. Điều đó cho thấy rằng ngay cả trên một ứng dụng đơn luồng, bạn vẫn có thể thấy lợi ích từ các hoạt động tối ưu hoá phức tạp hơn của mimalloc
, mặc dù lưu ý rằng việc này sẽ làm tăng kích thước mã và mức sử dụng bộ nhớ (vì lý do đó, dlmalloc
vẫn là mặc định).
Tệp và I/O
Nhiều ứng dụng cần sử dụng tệp vì nhiều lý do. Ví dụ: để tải các cấp độ trong trò chơi hoặc phông chữ trong trình chỉnh sửa hình ảnh. Ngay cả một thao tác như printf
cũng sử dụng hệ thống tệp vì thao tác này in bằng cách ghi dữ liệu vào stdout
.
Trong các ứng dụng đơn luồng, đây thường không phải là vấn đề và Emscripten sẽ tự động tránh liên kết trong tính năng hỗ trợ hệ thống tệp đầy đủ nếu tất cả những gì bạn cần là printf
. Tuy nhiên, nếu bạn sử dụng tệp, thì việc truy cập hệ thống tệp đa luồng sẽ rất phức tạp vì quyền truy cập tệp phải được đồng bộ hoá giữa các luồng. Phương thức triển khai hệ thống tệp ban đầu trong Emscripten, được gọi là "JS FS" vì được triển khai trong JavaScript, sử dụng mô hình đơn giản chỉ triển khai hệ thống tệp trên luồng chính. Bất cứ khi nào một luồng khác muốn truy cập vào một tệp, luồng đó sẽ chuyển tiếp yêu cầu đến luồng chính. Điều này có nghĩa là luồng khác sẽ chặn trên một yêu cầu xuyên luồng mà luồng chính cuối cùng sẽ xử lý.
Mô hình đơn giản này là tối ưu nếu chỉ luồng chính truy cập vào các tệp, đây là một mẫu phổ biến. Tuy nhiên, nếu các luồng khác thực hiện việc đọc và ghi, thì sự cố sẽ xảy ra. Trước tiên, luồng chính sẽ thực hiện công việc cho các luồng khác, gây ra độ trễ mà người dùng có thể nhìn thấy. Sau đó, các luồng ở chế độ nền sẽ chờ luồng chính rảnh để thực hiện công việc cần thiết, vì vậy mọi thứ sẽ chậm hơn (hoặc tệ hơn, bạn có thể bị tắc nghẽn nếu luồng chính hiện đang chờ luồng worker đó).
WasmFS
Để khắc phục vấn đề này, Emscripten có một cách triển khai hệ thống tệp mới, đó là WasmFS. WasmFS được viết bằng C++ và biên dịch sang Wasm, không giống như hệ thống tệp gốc được viết bằng JavaScript. WasmFS hỗ trợ quyền truy cập vào hệ thống tệp từ nhiều luồng với mức hao tổn tối thiểu, bằng cách lưu trữ các tệp trong bộ nhớ tuyến tính Wasm được chia sẻ giữa tất cả các luồng. Giờ đây, tất cả luồng đều có thể thực hiện I/O tệp với hiệu suất ngang nhau và thường thì các luồng này thậm chí có thể tránh chặn lẫn nhau.
Một điểm chuẩn hệ thống tệp đơn giản cho thấy lợi thế to lớn của WasmFS so với JS FS cũ.
Việc này so sánh việc chạy mã hệ thống tệp trực tiếp trên luồng chính với việc chạy mã trên một pthread duy nhất. Trong FS JS cũ, mọi thao tác hệ thống tệp phải được chuyển tiếp đến luồng chính, khiến thao tác này chậm hơn nhiều lần trên pthread! Đó là vì thay vì chỉ đọc/ghi một số byte, JS FS thực hiện giao tiếp xuyên luồng, bao gồm các khoá, hàng đợi và trạng thái chờ. Ngược lại, WasmFS có thể truy cập vào các tệp từ bất kỳ luồng nào một cách bình đẳng, do đó, biểu đồ cho thấy rằng thực tế không có sự khác biệt nào giữa luồng chính và pthread. Do đó, WasmFS nhanh hơn 32 lần so với JS FS khi ở trên một pthread.
Xin lưu ý rằng cũng có sự khác biệt trên luồng chính, trong đó WasmFS nhanh hơn 2 lần. Đó là do JS FS gọi ra JavaScript cho mọi thao tác hệ thống tệp, điều mà WasmFS tránh. WasmFS chỉ sử dụng JavaScript khi cần (ví dụ: để sử dụng API Web), điều này khiến hầu hết các tệp WasmFS đều nằm trong Wasm. Ngoài ra, ngay cả khi cần JavaScript, WasmFS vẫn có thể sử dụng luồng trợ giúp thay vì luồng chính để tránh độ trễ mà người dùng nhìn thấy. Do đó, bạn có thể thấy tốc độ cải thiện khi sử dụng WasmFS ngay cả khi ứng dụng của bạn không có nhiều luồng (hoặc nếu có nhiều luồng nhưng chỉ sử dụng các tệp trên luồng chính).
Sử dụng WasmFS như sau:
emcc -sWASMFS
WasmFS được dùng trong bản phát hành chính thức và được coi là ổn định, nhưng chưa hỗ trợ tất cả tính năng của FS JS cũ. Mặt khác, phiên bản này có một số tính năng mới quan trọng như hỗ trợ hệ thống tệp riêng tư gốc (OPFS, rất nên dùng cho bộ nhớ cố định). Trừ phi bạn tình cờ cần một tính năng chưa được chuyển, nhóm Emscripten khuyên bạn nên sử dụng WasmFS.
Kết luận
Nếu có một ứng dụng đa luồng thực hiện nhiều hoạt động phân bổ hoặc sử dụng tệp, thì bạn có thể hưởng lợi rất nhiều khi sử dụng WasmFS và/hoặc mimalloc
. Bạn có thể thử cả hai trong một dự án Emscripten bằng cách biên dịch lại với các cờ được mô tả trong bài đăng này.
Bạn thậm chí có thể muốn thử các tính năng đó nếu không sử dụng luồng: Như đã đề cập trước đó, các phương thức triển khai hiện đại hơn đi kèm với các tính năng tối ưu hoá đáng chú ý ngay cả trên một lõi trong một số trường hợp.