Mô phỏng 20 triệu hạt bằng JavaScript
(dgerrells.com)- Bắt đầu từ mô phỏng hạt JavaScript chỉ dùng CPU, tác giả đạt mục tiêu 1 triệu hạt ở 60fps trên điện thoại và mở rộng ví dụ cuối cùng lên tới 20 triệu hạt
- Cốt lõi về hiệu năng là tránh mảng đối tượng, đặt dữ liệu hạt trong bộ nhớ liên tục bằng TypedArray và SharedArrayBuffer, đồng thời chia việc theo từng lõi CPU bằng Web Worker
- Nút thắt thực tế bộc lộ rõ hơn ở khâu vẽ màn hình và truy cập bộ nhớ thay vì tính vị trí hạt, và việc ghi ngẫu nhiên vào pixel buffer gây ra CPU cache miss
- Tác giả từng bước giảm bottleneck qua pixel buffer riêng cho từng Worker, đồng bộ bằng postMessage, double buffering, lưới đếm số hạt theo pixel, và render texture trên GPU
- Kiến trúc cuối cùng để CPU mô phỏng và GPU render texture lưới theo độ phân giải màn hình, xử lý khoảng 20 triệu hạt ở ~20fps trên M1 Mac
Mục tiêu và cách tiếp cận cơ bản
- Thử thách là mô phỏng 1 triệu hạt ở 60fps trên điện thoại chỉ bằng JavaScript thuần và CPU, không dùng WebAssembly
- Tác giả cho rằng mảng đối tượng JavaScript đơn giản có giới hạn về hiệu năng đơn lõi trên di động và cách bố trí bộ nhớ, nên chọn cách xử lý dữ liệu bằng mảng liên tục
- Với khối lượng tính toán lớn, mảng bộ nhớ liên tục và dày đặc để có thể đưa nhiều dữ liệu nhất có thể vào CPU cache là rất quan trọng
- Mảng và đối tượng JavaScript thông thường khó đảm bảo được bố trí dày đặc như mảng hay
structtrong ngôn ngữ cấp thấp TypedArraycho phép tạo mảng byte liên tục có kích thước cố định, giúp quản lý bộ nhớ ở mức tương đối thấp trong JavaScript
Bản triển khai đầu tiên: SharedArrayBuffer và Web Worker
- Dữ liệu hạt được lưu bằng cách đặt view
Float32Arraylên trênSharedArrayBuffer- Mỗi hạt được biểu diễn bằng bốn giá trị số thực 32-bit:
x,y,dx,dy - Dùng
stride = 4,byte_stride = stride * 4để index buffer phẳng
- Mỗi hạt được biểu diễn bằng bốn giá trị số thực 32-bit:
- Mô phỏng bắt đầu từ cập nhật vị trí đơn giản ở mỗi frame với
x += dx,y += dy - Multithreading được cấu thành từ Web Worker và
SharedArrayBuffer- Main thread chia các chunk hạt cho từng Worker
- Worker chỉ đọc và ghi chunk được giao để tránh nhiều thread cùng ghi vào một vùng nhớ
- Để dùng
SharedArrayBuffertrong trình duyệt cần các header sauCross-Origin-Embedder-Policy: require-corpCross-Origin-Opener-Policy: same-origin
- Cơ chế đồng bộ ban đầu dùng một SharedArrayBuffer chuyên để báo hiệu
- Main thread ghi tín hiệu chạy cho Worker
- Worker kiểm tra tín hiệu bằng
setInterval(..., 1), rồi ghi trạng thái sẵn sàng khi xong việc
- Render dùng
ImageDatatheo kiểu một rasterizer chạy trên CPU- Mỗi hạt được vẽ thành một pixel trên màn hình
- Hạt nằm ngoài màn hình sẽ bị bỏ qua để tránh truy cập ngoài phạm vi mảng
- Dùng
putImageDatađể vẽ kết quả lên Canvas
- Profiling ban đầu cho thấy phần việc của Worker chỉ tốn vài ms, còn phần lớn thời gian dành cho render trên main thread
- Bộ nhớ được cấp phát một lần khi khởi động và không tạo object mới trong vòng lặp mô phỏng, nên gần như không phát sinh garbage
Bản triển khai thứ hai: đầu vào và tương tác kiểu trọng lực
- Dữ liệu đầu vào được thêm vào
SharedArrayBufferđang dùng cho trạng thái mô phỏng- Lưu delta time, tọa độ chuột x/y, trạng thái chạm, chiều rộng/chiều cao màn hình
- Trạng thái chạm gần như là boolean nhưng vẫn dùng 4 byte
- Tác giả thêm hiệu ứng để hạt bị hút về điểm chạm khi người dùng nhấn lên màn hình
- Có tham khảo dạng
1 / r²của công thức trọng lực thật, nhưng được chỉnh lại để ưu tiên tương tác thú vị hơn là mô phỏng vật lý chính xác - Thêm suy hao tương đương ma sát để hạt không tăng tốc vô hạn rồi bay khỏi màn hình
- Có tham khảo dạng
- Màu render cũng được sửa để thay đổi theo vị trí hạt
- Điều chỉnh giá trị kênh RGB bằng
x / width,y / height - Nếu lưu màu riêng cho từng hạt thì kích thước hạt sẽ tăng lên, nên tác giả dùng màu theo vị trí để giảm lượng dữ liệu
- Điều chỉnh giá trị kênh RGB bằng
- Có thể thử nghiệm tới 2 triệu hạt và thay đổi số lượng hạt bằng query parameter
counttrong URL - Ở 10 triệu hạt, framerate giảm mạnh, và profiling cho thấy ghi pixel vào ImageData chậm hơn cả Worker
- Khi chỉ ghi kênh xanh dương và alpha thay vì toàn bộ RGB, thời gian render trên một số môi trường giảm từ khoảng 80~90ms xuống 16~18ms
- Mức chênh lệch này khác nhau tùy CPU, và trên desktop Ryzen còn rõ hơn so với M1 MacBook Air
Bản triển khai thứ ba: Worker ghi pixel buffer
- Để tận dụng nhiều lõi CPU hơn, Worker được giao thêm việc vẽ pixel chứ không chỉ mô phỏng
- Để tránh nhiều Worker cùng ghi vào một pixel buffer, tác giả dùng pixel buffer riêng cho từng Worker
- Dùng
SharedArrayBufferkích thướcCPU_CORES * window.innerWidth * window.innerHeight * 3 - Mỗi Worker có một RGB buffer toàn màn hình riêng
- Dùng
- Main thread cộng dồn toàn bộ pixel buffer của các Worker rồi ghi vào
ImageData - Bản này xuất hiện hiện tượng màn hình nhấp nháy
- Main thread đọc cùng buffer đúng lúc Worker đang xóa buffer của nó bằng
fill(0) - Ở các bản trước, việc đọc dữ liệu cũ từ frame trước không dễ nhận ra, còn lần này thao tác xóa của Worker lộ rõ thành hiện tượng nhấp nháy từng phần
- Main thread đọc cùng buffer đúng lúc Worker đang xóa buffer của nó bằng
- Các hướng xử lý được cân nhắc gồm đồng bộ bằng
Atomics, chờ Worker hoàn tất quapostMessage, và double buffering
Bản triển khai thứ tư: đồng bộ bằng postMessage
- Để loại bỏ nhấp nháy, tác giả bỏ mảng tín hiệu và đổi sang báo Worker hoàn tất bằng
postMessage - Main thread quản lý giá trị
activeWorkers- Mỗi khi một Worker xong việc sẽ gửi message và làm giảm
activeWorkers - Khi tất cả Worker xong, main thread mới render rồi yêu cầu
requestAnimationFrametiếp theo
- Mỗi khi một Worker xong việc sẽ gửi message và làm giảm
- Cách này giải quyết được hiện tượng nhấp nháy
- Tuy nhiên thời gian frame không cải thiện nhiều, thậm chí còn có thể tệ hơn
- Vì main thread không làm gì trong lúc chờ Worker hoàn tất
- Với 2 triệu hạt, có trường hợp tiết kiệm được khoảng 7ms nếu Worker không phải chờ main thread render
- Để giảm bottleneck này, tác giả áp dụng double buffering vốn phổ biến trong driver đồ họa
Bản triển khai thứ năm: double buffering và bottleneck cache
- Double buffering tạo hai pixel buffer và đổi buffer hoạt động sau mỗi frame
- Trong khi main thread vẽ một buffer, Worker chuẩn bị frame kế tiếp trên buffer còn lại
- Sau khi áp dụng, Worker không còn phải chờ main thread render, và một số frame trước đó vượt 50ms nay gần hơn với 16ms
- Phần code vẫn chậm là ba dòng cộng giá trị vào các kênh RGB của pixel buffer
- Nguyên nhân là dù dữ liệu được lưu liên tục, thứ tự truy cập lại không liên tục
pixelIndextính từx,ycủa hạt không được sắp theo thứ tự mảng hạt- Mỗi vòng lặp đều nhảy đến vị trí ngẫu nhiên trong pixel buffer, gần như một mẫu truy cập ngẫu nhiên
- Kiểu truy cập này gây cache miss khi dữ liệu hạt và dữ liệu pixel không cùng nằm gọn trong cache
- Ví dụ tính toán trên iPhone như sau
- Công thức:
screen width * screen height * bytes per pixel + bytes per particle * total particles / number of workers 2532 * 1170 * 3 + 16 * 2,000,000 / 5- Kết quả vượt 15MB, quá lớn để đưa vào L1 cache
- Công thức:
- Ở bản này, trên chip M1, mô phỏng 2 triệu hạt mất khoảng 4ms, truy cập dữ liệu pixel mất khoảng 3.5ms, và ghi vào pixel buffer mất khoảng 7ms
- Trên điện thoại có thể giữ 60fps với 1 triệu hạt, nhưng trên desktop có hơn 20 thread, main thread phải cộng dồn hơn 20 pixel buffer mỗi frame nên tốn tới 30ms
Bản triển khai thứ sáu: lực kéo về vị trí ban đầu
- Hiệu ứng mới là hạt càng rời xa vị trí ban đầu thì càng bị hút mạnh trở lại
- Để làm vậy, mỗi hạt được thêm hai giá trị vị trí ban đầu
sx,sy- Một hạt có sáu float:
x,y,dx,dy,sx,sy particleStridetăng lên6
- Một hạt có sáu float:
- Code Worker được thêm hàm tính lực và dùng object cache để tránh tạo garbage
- Kết quả tạo ra hiệu ứng hình ảnh giống thạch hoặc chất lỏng, với các xoáy hạt như giấy hay vải bị vò lại
- Từ đây xuất hiện ý tưởng không lưu pixel buffer theo từng Worker nữa mà chuyển sang một lưới chỉ đếm số hạt trên mỗi pixel
Bản triển khai thứ bảy: lưới đếm số hạt theo pixel
- Thay vì lưu giá trị RGB, chỉ lưu số lượng hạt ở từng vị trí pixel sẽ giảm bộ nhớ xuống còn 1/3
- Vì màu được tính từ vị trí
x,ychứ không phải dữ liệu riêng của từng hạt, nên chỉ cần số đếm theo pixel là đủ để render - Tác giả đổi sang để tất cả Worker cùng chia sẻ một lưới đếm số hạt
- Main thread không còn phải cộng dồn buffer riêng của từng Worker
- Vấn đề chi phí cộng render tăng theo số lượng Worker được giải quyết
- Công thức tính cache mới là
screen width * screen height + bytes per particle * total particles / number of workers- Trên iPhone:
2532 * 1170 + 16 * 2,000,000 / 5 - Khoảng 9.3MB, giảm khoảng 30% so với trước nhưng vẫn quá lớn cho L1 cache
- Render duyệt toàn bộ pixel, đọc số hạt tại pixel đó, tính RGB rồi đặt lại lưới bằng
fill(0) - Sau khi mô phỏng, Worker tính
pCountIndexcho hạt còn trong màn hình rồi thực hiệnactiveGrid[pCountIndex]++ - Thay đổi này đặc biệt hữu ích khi có nhiều Worker, và trên desktop có thể dùng đủ cả 24 thread
- Dù vậy, có trường hợp M1 MacBook Air vẫn nhanh hơn desktop Ryzen 9 3900X
- Ryzen 9 3900X có L3 cache 64MB nhưng L1 chỉ 64KB
- M1 thế hệ đầu có L1 cache 320KB, trong đó 128KB cho dữ liệu và 192KB cho lệnh
- Có vẻ L1 data cache lớn hơn giúp giảm độ trễ chờ bộ nhớ
Giới hạn của tối ưu hóa chỉ dùng CPU
- Sau nhiều tối ưu hóa, tác giả đạt khoảng 2x cải thiện tốc độ so với bản multithread đầu tiên
- Mục tiêu xử lý 1 triệu hạt 60fps trên điện thoại bằng JavaScript thuần chạy CPU đã hoàn thành
- Tuy nhiên kết quả tổng thể không hẳn quá ấn tượng, và tác giả kỳ vọng ngôn ngữ biên dịch có thể nhanh hơn 10 lần
- Nếu dùng được lệnh SIMD trong tight loop thì vẫn còn dư địa tăng tốc
- Ở bước tiếp theo, tác giả giữ mô phỏng trên CPU nhưng chuyển riêng phần render sang pipeline GPU truyền thống để so sánh
20 triệu hạt và render bằng GPU
- So sánh render GPU đầu tiên dùng GPU instancing của threejs để vẽ một plane/quad cho mỗi hạt
- Cách này có hiệu năng thấp hơn kỳ vọng
- Instancing trong threejs cần cập nhật matrix cho từng vị trí hạt, và việc này chạy trên CPU đơn luồng
- Dữ liệu biến đổi của hạt phải được upload lên GPU mỗi frame, và chính việc truyền này bị chậm
- Khi mô phỏng 4 triệu hạt trên chip M1, 80% thời gian frame dùng để gửi dữ liệu sang GPU
- Trên desktop, tỷ lệ thời gian truyền dữ liệu là 30%, nhưng đây vẫn là bottleneck lớn nhất
- Phương án thay thế là dùng lưới đếm số hạt đã tạo ở trên như một texture và render một quad toàn màn hình
- Lượng dữ liệu gửi sang GPU được cố định theo độ phân giải màn hình thay vì số lượng hạt
- Đây là một đánh đổi: mô phỏng lớn sẽ giảm lượng truyền, nhưng mô phỏng nhỏ có thể lại gửi nhiều dữ liệu hơn
- Với threejs và custom pixel shader, bản thử nghiệm được dựng rất nhanh và render trên main thread chỉ mất vài ms, gần như không phụ thuộc quy mô mô phỏng
- Trong kiến trúc này, Worker chạy nhanh nhất có thể, còn mô phỏng bị ràng buộc trực tiếp vào số lõi CPU ở cả bài toán lớn lẫn nhỏ
- Mỗi Worker dành khoảng 50% thời gian cho tính toán số học và khoảng 50% cho cập nhật số đếm trong lưới hạt
- Hiệu năng của bản cuối như sau
- Trên M1 Mac chạy bằng pin, 20 triệu hạt đạt khoảng 20fps
- Trên desktop, khoảng 30 triệu hạt cho fps tương tự
- Theo bài test của một người bạn dùng CPU 32 lõi, con số này lên tới khoảng 40 triệu hạt
- Demo liên quan:
Tổng kết
- CPU và GPU có thể xử lý phép tính số rất nhanh
- Phần chậm nằm ở việc di chuyển dữ liệu, đặc biệt là truy cập bộ nhớ ngẫu nhiên
- Muốn viết code nhanh, cần hiểu phần cứng cache và di chuyển dữ liệu như thế nào
SharedArrayBuffervà Web Worker rất hữu ích cho các thử nghiệm tận dụng nhiều lõi trong JavaScript- Tác giả dự định thử compute shader khi WebGPU được chấp nhận rộng rãi hơn
1 bình luận
Ý kiến trên Hacker News
Sẽ tốt hơn nếu nhúng trực tiếp mô phỏng vào trong bài blog
Tôi đã phải cuộn lên cuộn xuống khá lâu mới tìm ra link tới mô phỏng thực tế
Cũng có thể chọn một mức chạy ổn trên cả điện thoại cũ, hoặc cho nó tự điều chỉnh theo tốc độ khung hình, hay đơn giản là đặt vài liên kết ngay đầu bài
https://ciechanow.ski/ là một ví dụ đẳng cấp thế giới cho thấy việc đưa mô phỏng vào trong bài viết có thể tuyệt vời đến mức nào
Ngày xưa website nào cũng từng nhúng những yếu tố tương tác ngầu như vậy
Và tôi nghĩ vẫn có thể chạy mô phỏng hạt trên GPU mà không cần WebGPU
Ví dụ: https://news.ycombinator.com/item?id=19963640
Ước gì đây trở thành chuẩn mực cho việc chia sẻ thông tin. Nếu đã đáng để chia sẻ thì cũng đáng để làm cho dễ hiểu
Tôi đã cân nhắc nhúng từng phiên bản, nhưng lo là sẽ có quá nhiều worker chạy cùng lúc
Tôi dự định sẽ cập nhật để nhúng phiên bản cuối ở cuối bài
Blog đó có mức độ hoàn thiện cực cao ở từng ví dụ, thực sự rất hay
Chỉnh sửa: tôi đã thử thêm bản nhúng, nhưng các header cần thiết không hợp với những bản nhúng khác, và các phiên bản cũ vẫn còn bị khóa trong codesandbox
Tôi vừa thử trực tiếp bản demo trên di động [0], khá ấn tượng
[0] https://dgerrells.com/sabby
Thật ấn tượng khi đây là JavaScript thuần, và việc độ trễ tăng lên theo số ngón tay khiến tôi nghĩ là có nhiều dữ liệu hơn đang qua lại giữa các thread
Chỉ tò mò thôi, nhưng tôi tự hỏi liệu có thể mã hóa dữ liệu này vào một số JS duy nhất thay vì biểu diễn mỗi hạt bằng bốn số thực dấu phẩy động 32 bit là x, y, dx, dy hay không
Phạm vi số nguyên an toàn của JS là
2^53 - 1 = 9,007,199,254,740,991, còn phạm viFloat32Arraydùng trong bài là từ-3.4e38đến3.4e38Ví dụ nếu vị trí màn hình là canvas 1000x1000 thì có thể biểu diễn bằng các số từ 0~1,000,000, và ngay cả khi thêm 10 mức dưới pixel thì cũng mới là 100,000,000, vẫn dư sức nằm trong phạm vi của JS
Tốc độ
dx,dycũng được tính bằng số thực ngẫu nhiên trong khoảng -10 đến +10 qua(Math.random()*2-1)*10, nhưng nếu chỉ cần một chữ số thập phân là đủ thì có thể đổi sang phạm vi số nguyên từ -100~+100, và như vậy chỉ cần 10,000 giá trị khả dĩKết hợp cả hai thì
10,000 * 100,000,000 = 1,000,000,000,000, tức một nghìn tỷ giá trị để biểu diễn hạt, vẫn nằm trongMAX_SAFE_INTEGERcủa JSVậy nên có vẻ có thể nhét dữ liệu của một hạt vào một
MAX_SAFE_INTEGERduy nhất hoặc một phần tửFloat32Array, như vậy sẽ không cần stride và tính nhất quán của dữ liệu cũng có thể chắc chắn hơnTuy nhiên cũng có thể chi phí mã hóa/giải mã lớn hơn phần bộ nhớ tiết kiệm được, nên xét về hiệu năng thì hoàn toàn không đáng
Hệ sinh thái
@thi.ngcó một số triển khai hữu dụng, và cũng có cả triển khai WASM SIMD[0]: https://github.com/thi-ng/umbrella/tree/develop/packages/vec...
[1]: https://github.com/thi-ng/umbrella/tree/develop/packages/vec...
[2]: https://github.com/thi-ng/umbrella/tree/develop/packages/bit...
[3]: https://github.com/thi-ng/umbrella/tree/develop/packages/sim...
Ngoài ra còn có Structura, nơi triển khai một vài cấu trúc dữ liệu hiệu năng cao
[4]: https://github.com/zandaqo/structurae/blob/master/README.md#...
Dùng
Float16Arraycó thể lập tức giảm một nửa nhu cầu bộ nhớMột cách khác là tách các mảng theo mức độ chính xác
Ví dụ x, y có thể dùng
Float16Array, còn dx/dy dùngInt8Array, nhưng trong cả hai trường hợp đều có thể xuất hiện artifact chuyển động, và riêngInt8Arraythì điều này có thể lộ rõ do hiện tượng kẹp giá trị và aliasing của dx/dyRất ấn tượng, nhưng tôi thắc mắc đoạn này
Javascript does support an Atomics API but it uses promises which are gross. Eww sick.Ngoại trừ
waitAsync[1], Atomics API có vẻ không dùng promiseTrước đây tôi đã dùng Atomics nhưng không cần xử lý mã bất đồng bộ/promise
Tôi tò mò không biết bên trong nó có dùng promise hay là tôi đã bỏ sót điều gì
[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
Cũng có mô phỏng xử lý gần 20 triệu hạt
Có thể chạy và lập trình tại đây: https://tinlizzie.org/~ohshima/shadama2/
https://tinlizzie.org/~ohshima/shadama2/live2017/
https://www.youtube.com/watch?v=tqBO_5GMfWw
Video rất hay, nhưng các liên kết codesandbox trong
try it out herekhông hoạt động trên Chrome desktop trên macOSXuất hiện lỗi CORS như
Uncaught ReferenceError: SharedArrayBuffer is not definedvàERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoepNếu dùng trình duyệt chú trọng quyền riêng tư thì cũng có thể bị chặn
Sau này tôi sẽ thử thêm ví dụ nhúng
Có thể thử bản cuối tại đây: https://dgerrells.com/sabby
Tuyệt vời
Tôi đã dành một giờ để hiểu và vọc mã, rồi làm một bản triển khai thời gian thực ở đây: https://particules.kapochamo.com/index.html
Nó thật sự đẹp, và khó mà tin được JS lại có thể mô phỏng nhiều hạt đến vậy
Ấn tượng vì bạn đã đào sâu vào mã đến mức đó
Có lẽ nên thử xem
chrome://tracingcó mang lại thêm insight khôngTôi biết đến nó từ đây: https://youtu.be/easvMCCBFkQ?t=114
Tôi rất muốn gửi ngay cái này cho đội UI đang làm mấy ứng dụng React chậm chạp
JS thật sự rất nhanh, và đặc biệt là cực nhanh nếu được viết tốt
JS nhanh trông giống một phương ngữ C tệ hại, và bạn không nên cấp phát đối tượng cho garbage collection, nhưng ngôn ngữ này lại thiếu các lựa chọn tốt không cần cấp phát
Hầu như không ai trong một ứng dụng JS phức tạp lại xử lý mọi cấp phát bộ nhớ bằng các con trỏ trong một
ArrayBufferkhổng lồ, và nếu đã vậy thì chuyển sang WebAssembly còn dễ hơnĐiều đáng tiếc là không thể chỉ dựa vào JS mà còn phải trộn nhiều thao tác DOM, và trong UI chúng ta làm thì phần chậm thường chính là các thao tác DOM đó