Viết mã Javascript hiệu suất cao mà không bị thu nhỏ


10

Khi viết mã nhạy cảm hiệu năng trong Javascript hoạt động trên các mảng số lớn (nghĩ là gói đại số tuyến tính, hoạt động trên các số nguyên hoặc số dấu phẩy động), người ta luôn muốn JIT giúp đỡ hết mức có thể. Điều này có nghĩa là:

  1. Chúng tôi luôn muốn các mảng của chúng tôi được đóng gói SMI (số nguyên nhỏ) hoặc Nhân đôi, tùy thuộc vào việc chúng tôi đang thực hiện các phép tính số nguyên hay dấu phẩy động.
  2. Chúng tôi luôn muốn truyền cùng một loại điều cho các chức năng, để chúng không bị gắn nhãn "megamorphic" và bị thu nhỏ. Chẳng hạn, chúng tôi luôn muốn được gọi vec.add(x, y)bằng cả hai xyđược đóng gói các mảng SMI hoặc cả hai mảng kép được đóng gói.
  3. Chúng tôi muốn các chức năng được nội tuyến càng nhiều càng tốt.

Khi một người đi lạc bên ngoài những trường hợp này, một sự sụt giảm hiệu suất đột ngột và quyết liệt xảy ra. Điều này có thể xảy ra vì nhiều lý do vô hại:

  1. Bạn có thể biến một mảng SMI được đóng gói thành một mảng Double được đóng gói thông qua một hoạt động dường như vô hại, giống như tương đương myArray.map(x => -x). Đây thực sự là trường hợp xấu "tốt nhất", vì các mảng kép được đóng gói vẫn còn rất nhanh.
  2. Bạn có thể biến một mảng được đóng gói thành một mảng được đóng hộp chung, ví dụ bằng cách ánh xạ mảng qua một hàm (bất ngờ) trả về nullhoặc undefined. Trường hợp xấu này là khá dễ dàng để tránh.
  3. Bạn có thể mở rộng toàn bộ một chức năng, chẳng hạn như vec.add()chuyển qua quá nhiều loại vật và biến nó thành siêu hình. Điều này có thể xảy ra nếu bạn muốn thực hiện "lập trình chung", vec.add()được sử dụng cả trong trường hợp bạn không cẩn thận về các loại (vì vậy sẽ thấy rất nhiều loại xuất hiện) và trong trường hợp bạn muốn tăng hiệu suất tối đa (ví dụ, nó chỉ nên nhận được gấp đôi đóng hộp).

Câu hỏi của tôi là nhiều hơn một câu hỏi nhẹ, về cách một người viết mã Javascript hiệu suất cao theo các cân nhắc ở trên, trong khi vẫn giữ mã tốt và dễ đọc. Một số câu hỏi phụ cụ thể để bạn biết loại câu trả lời nào tôi đang nhắm đến:

  • Có một bộ hướng dẫn nào đó về cách lập trình trong khi ở trong thế giới của các mảng SMI được đóng gói (ví dụ) không?
  • Có thể thực hiện lập trình hiệu suất cao chung trong Javascript mà không cần sử dụng một cái gì đó như hệ thống macro để thực hiện những thứ nội tuyến như vec.add()trong các cuộc gọi?
  • Làm thế nào để một mô-đun mã hiệu suất cao thành các libaries trong bối cảnh của những thứ như các trang web cuộc gọi siêu mô hình và deoptimisations? Chẳng hạn, nếu tôi vui vẻ sử dụng gói Đại số tuyến tính Aở tốc độ cao, sau đó tôi nhập một gói Bphụ thuộc vào A, nhưng Bgọi nó với các loại khác và mở ra nó, đột nhiên (không thay đổi mã của tôi) mã của tôi chạy chậm hơn.
  • Có công cụ đo lường nào dễ sử dụng để kiểm tra xem công cụ Javascript đang làm gì với các loại không?

1
Đó là một chủ đề rất thú vị, và một bài viết rất hay cho thấy bạn đã thực hiện đúng phần nghiên cứu của mình. Tuy nhiên, tôi e rằng (các) câu hỏi quá rộng đối với định dạng SO và chắc chắn nó sẽ thu hút nhiều ý kiến ​​hơn là sự thật. Tối ưu hóa mã là một vấn đề rất phức tạp và hai phiên bản của một động cơ có thể không hoạt động giống nhau. Tôi nghĩ đôi khi có một trong những người chịu trách nhiệm về V8 JIT, vì vậy có lẽ họ có thể đưa ra câu trả lời thích hợp cho động cơ của họ, nhưng ngay cả đối với họ, tôi nghĩ rằng nó sẽ quá rộng đối tượng cho một Q / A duy nhất .
Kaiido

"Câu hỏi của tôi là một câu hỏi nhẹ, về cách một người viết mã Javascript hiệu suất cao ..." Bên cạnh đó, lưu ý rằng javascript cung cấp cho việc sinh ra các quy trình nền (nhân viên web) và cũng có các thư viện khai thác việc cung cấp GPU (Tensorflow.js và gpu.js) có nghĩa là ngoài việc chỉ dựa vào việc biên dịch để tăng thông lượng tính toán của một ứng dụng dựa trên javascript ...
Jon Trent

@JonTrent Thật ra tôi đã nói dối một chút trong bài viết của mình, tôi không quan tâm lắm đến các ứng dụng đại số tuyến tính cổ điển, nhưng nhiều hơn cho đại số máy tính qua các số nguyên. Điều này có nghĩa là rất nhiều gói số hiện có bị loại trừ ngay lập tức, vì (ví dụ) trong khi giảm hàng ma trận mà chúng có thể chia cho 2, "không được phép" trong thế giới tôi đang làm việc kể từ (1/2) không phải là số nguyên. Tôi đã xem xét các nhân viên web (đặc biệt là đối với một vài tính toán dài hạn mà tôi muốn hủy bỏ), nhưng vấn đề tôi đang giải quyết ở đây là giảm độ trễ đủ để đáp ứng tương tác.
Joppy

Đối với số học số nguyên trong JavaScript, có lẽ bạn đang xem mã kiểu asm.js, đại khái là "đặt |0phía sau mọi hoạt động". Nó không đẹp, nhưng tốt nhất bạn có thể làm trong một ngôn ngữ không có số nguyên thích hợp. Bạn cũng có thể sử dụng BigInt, nhưng cho đến ngày nay, chúng không nhanh lắm trong bất kỳ động cơ phổ biến nào (chủ yếu là do thiếu nhu cầu).
jmrk

Câu trả lời:


8

Nhà phát triển V8 tại đây. Với số lượng quan tâm trong câu hỏi này và thiếu câu trả lời khác, tôi có thể đưa ra câu hỏi này; Tôi sợ rằng nó sẽ không phải là câu trả lời mà bạn đã hy vọng mặc dù.

Có một bộ hướng dẫn nào đó về cách lập trình trong khi ở trong thế giới của các mảng SMI được đóng gói (ví dụ) không?

Câu trả lời ngắn: nó ở ngay đây : const guidelines = ["keep your integers small enough"].

Câu trả lời dài hơn: đưa ra một bộ hướng dẫn toàn diện là khó khăn vì nhiều lý do. Nói chung, ý kiến ​​của chúng tôi là các nhà phát triển JavaScript nên viết mã có ý nghĩa với họ và trường hợp sử dụng của họ và các nhà phát triển công cụ JavaScript nên tìm ra cách chạy mã đó nhanh trên các công cụ của họ. Mặt khác, rõ ràng có một số hạn chế đối với lý tưởng đó, theo nghĩa là một số mẫu mã hóa sẽ luôn có chi phí hiệu năng cao hơn các loại khác, bất kể lựa chọn thực hiện động cơ và nỗ lực tối ưu hóa.

Khi chúng tôi nói về lời khuyên về hiệu suất, chúng tôi cố gắng ghi nhớ điều đó và ước tính cẩn thận những đề xuất nào có khả năng cao còn hiệu lực trên nhiều động cơ và trong nhiều năm, và cũng khá hợp lý / không xâm phạm.

Quay trở lại ví dụ trong tay: sử dụng Smis trong nội bộ được coi là một chi tiết triển khai mà mã người dùng không cần phải biết. Nó sẽ làm cho một số trường hợp hiệu quả hơn, và không nên làm tổn thương trong các trường hợp khác. Không phải tất cả các công cụ đều sử dụng Smis (ví dụ, AFAIK Firefox / Spidermonkey trong lịch sử không có; tôi đã nghe nói rằng đối với một số trường hợp họ sử dụng Smis ngày nay; nhưng tôi không biết bất kỳ chi tiết nào và không thể nói với bất kỳ cơ quan nào về vấn đề). Trong V8, kích thước của Smis là một chi tiết bên trong và thực sự đã thay đổi theo thời gian và qua các phiên bản. Trên các nền tảng 32 bit, từng là trường hợp sử dụng đa số, Smis luôn là số nguyên có chữ ký 31 bit; trên nền tảng 64 bit, chúng từng là số nguyên có chữ ký 32 bit, gần đây có vẻ như là trường hợp phổ biến nhất, cho đến khi trong Chrome 80, chúng tôi đã chuyển "nén con trỏ" đối với kiến ​​trúc 64 bit, yêu cầu giảm kích thước Smi xuống 31 bit được biết đến từ các nền tảng 32 bit. Nếu bạn tình cờ dựa trên một triển khai dựa trên giả định rằng Smis thường là 32 bit, bạn sẽ gặp những tình huống không may nhưnày .

Rất may, như bạn đã lưu ý, mảng kép vẫn rất nhanh. Đối với mã nặng về số, có thể có ý nghĩa khi giả định / nhắm mục tiêu mảng kép. Với tỷ lệ tăng gấp đôi trong JavaScript, thật hợp lý khi giả định rằng tất cả các công cụ đều hỗ trợ tốt cho các mảng kép và kép.

Có thể thực hiện lập trình hiệu suất cao chung trong Javascript mà không cần sử dụng một cái gì đó như hệ thống macro để nội tuyến những thứ như vec.add () vào các cuộc gọi?

"Chung" thường mâu thuẫn với "hiệu suất cao". Điều này không liên quan đến JavaScript hoặc các triển khai công cụ cụ thể.

Mã "chung" có nghĩa là các quyết định phải được đưa ra trong thời gian chạy. Mỗi khi bạn thực thi một hàm, mã phải chạy để xác định, "có phải là xsố nguyên không? Nếu vậy, hãy lấy đường dẫn mã đó. Có phải là xmột chuỗi không? Sau đó nhảy qua đây. Nó có phải là một đối tượng không .valueOf? có lẽ .toString()? Có lẽ trên chuỗi nguyên mẫu của nó? Gọi đó và khởi động lại từ đầu với kết quả của nó ". Mã tối ưu hóa "hiệu suất cao" về cơ bản được xây dựng trên ý tưởng để loại bỏ tất cả các kiểm tra động này; Điều đó chỉ có thể khi công cụ / trình biên dịch có một số cách để suy ra các loại trước thời hạn: nếu nó có thể chứng minh (hoặc giả sử với xác suất đủ cao) xluôn luôn là một số nguyên, thì nó chỉ cần tạo mã cho trường hợp đó ( được bảo vệ bởi một kiểm tra loại nếu các giả định chưa được chứng minh có liên quan).

Nội tuyến là trực giao cho tất cả điều này. Hàm "chung" vẫn có thể được nội tuyến. Trong một số trường hợp, trình biên dịch có thể truyền thông tin kiểu vào hàm được nội tuyến để giảm tính đa hình ở đó.

(Để so sánh: C ++, là ngôn ngữ được biên dịch tĩnh, có các mẫu để giải quyết vấn đề liên quan. Tóm lại, họ để lập trình viên chỉ thị rõ ràng cho trình biên dịch tạo các bản sao chuyên biệt của các hàm (hoặc toàn bộ các lớp), được tham số hóa trên các loại đã cho. Giải pháp tốt cho một số trường hợp, nhưng không phải không có nhược điểm riêng, ví dụ thời gian biên dịch dài và nhị phân lớn. JavaScript, tất nhiên, không có thứ gì giống như mẫu. Bạn có thể sử dụng evalđể xây dựng một hệ thống có phần giống nhau, nhưng sau đó bạn Bạn sẽ gặp phải những hạn chế tương tự: bạn phải thực hiện tương đương với công việc của trình biên dịch C ++ khi chạy và bạn phải lo lắng về số lượng mã bạn đang tạo.)

Làm thế nào để một mô-đun mã hiệu suất cao thành các libaries trong bối cảnh của những thứ như các trang web cuộc gọi siêu mô hình và deoptimisations? Chẳng hạn, nếu tôi vui vẻ sử dụng gói Đại số tuyến tính A ở tốc độ cao, sau đó tôi nhập gói B phụ thuộc vào A, nhưng B gọi nó với các loại khác và phát hiện ra nó, đột nhiên (không thay đổi mã của tôi) mã của tôi chạy chậm hơn .

Vâng, đó là một vấn đề chung với JavaScript. V8 được sử dụng để triển khai một số nội dung nhất định (giống như Array.sort) trong JavaScript và vấn đề này (mà chúng tôi gọi là "ô nhiễm phản hồi kiểu") là một trong những lý do chính khiến chúng tôi hoàn toàn tránh xa kỹ thuật đó.

Điều đó nói rằng, đối với mã số, không có nhiều loại (chỉ có Smis và nhân đôi) và như bạn lưu ý rằng chúng nên có hiệu suất tương tự trong thực tế, vì vậy trong khi ô nhiễm phản hồi thực sự là mối quan tâm về mặt lý thuyết, và trong một số trường hợp có thể có tác động đáng kể, cũng có khả năng là trong các kịch bản đại số tuyến tính, bạn sẽ không thấy sự khác biệt có thể đo lường được.

Ngoài ra, bên trong động cơ còn có nhiều tình huống hơn "một loại == nhanh" và "nhiều hơn một loại == chậm". Nếu một thao tác nhất định đã thấy cả Smis và nhân đôi, điều đó hoàn toàn tốt. Tải các phần tử từ hai loại mảng cũng tốt. Chúng tôi sử dụng thuật ngữ "megamorphic" cho tình huống khi tải đã thấy rất nhiều loại khác nhau mà từ bỏ theo dõi chúng riêng lẻ và thay vào đó sử dụng một cơ chế chung hơn để chia tỷ lệ tốt hơn cho số lượng lớn các loại - một chức năng chứa các tải như vậy có thể vẫn được tối ưu hóa. "Deoptimization" là hành động rất cụ thể của việc phải loại bỏ mã được tối ưu hóa cho một hàm vì một loại mới được nhìn thấy chưa từng thấy trước đó và do đó mã được tối ưu hóa không được trang bị để xử lý. Nhưng ngay cả điều đó cũng tốt: chỉ cần quay lại mã chưa được tối ưu hóa để thu thập thêm phản hồi loại và tối ưu hóa lại sau. Nếu điều này xảy ra một vài lần, thì không có gì phải lo lắng; nó chỉ trở thành một vấn đề trong các trường hợp xấu về mặt bệnh lý.

Vì vậy, tóm tắt của tất cả đó là: đừng lo lắng về nó . Chỉ cần viết mã hợp lý, để cho động cơ đối phó với nó. Và theo "hợp lý", ý tôi là: điều gì hợp lý cho trường hợp sử dụng của bạn, có thể đọc được, có thể bảo trì, sử dụng thuật toán hiệu quả, không chứa các lỗi như đọc vượt quá độ dài của mảng. Lý tưởng nhất, đó là tất cả những gì có, và bạn không cần phải làm gì khác. Nếu việc này khiến bạn cảm thấy tốt hơn khi làm điều gì đó và / hoặc nếu bạn thực sự quan sát các vấn đề về hiệu suất, tôi có thể đưa ra hai ý tưởng:

Sử dụng TypeScript có thể giúp đỡ. Cảnh báo lớn về chất béo: Các loại của TypeScript nhắm đến năng suất của nhà phát triển, chứ không phải hiệu suất thực thi (và hóa ra, hai quan điểm đó có các yêu cầu rất khác nhau từ một hệ thống loại). Điều đó nói rằng, có một số trùng lặp: ví dụ: nếu bạn luôn chú thích mọi thứ như number, thì trình biên dịch TS sẽ cảnh báo bạn nếu bạn vô tình đưa nullvào một mảng hoặc hàm được cho là chỉ chứa / hoạt động trên các số. Tất nhiên, kỷ luật vẫn được yêu cầu: một number_func(random_object as number)lối thoát duy nhất có thể âm thầm phá hoại mọi thứ, bởi vì tính chính xác của các chú thích loại không được thi hành ở bất cứ đâu.

Sử dụng TypedArrays cũng có thể giúp đỡ. Chúng có chi phí hoạt động cao hơn một chút (mức tiêu thụ bộ nhớ và tốc độ phân bổ) cho mỗi mảng so với mảng JavaScript thông thường (vì vậy nếu bạn cần nhiều mảng nhỏ, thì mảng thông thường có thể hiệu quả hơn) và chúng kém linh hoạt hơn vì chúng không thể phát triển hoặc thu hẹp sau khi phân bổ, nhưng chúng đảm bảo rằng tất cả các yếu tố có chính xác một loại.

Có công cụ đo lường nào dễ sử dụng để kiểm tra xem công cụ Javascript đang làm gì với các loại không?

Không, và đó là cố ý. Như đã giải thích ở trên, chúng tôi không muốn bạn điều chỉnh cụ thể mã của mình theo bất kỳ mẫu nào V8 có thể tối ưu hóa đặc biệt tốt hiện nay và chúng tôi không tin rằng bạn thực sự muốn làm điều đó. Tập hợp các thứ đó có thể thay đổi theo một trong hai hướng: nếu có một mẫu bạn muốn sử dụng, chúng tôi có thể tối ưu hóa cho phiên bản đó trong tương lai (trước đây chúng tôi đã từng có ý tưởng lưu trữ các số nguyên 32 bit không được lưu trữ dưới dạng các phần tử mảng .. . nhưng làm việc trên đó chưa bắt đầu, vì vậy không có lời hứa); và đôi khi nếu có một mẫu mà chúng ta đã sử dụng để tối ưu hóa trong quá khứ, chúng ta có thể quyết định bỏ nó nếu nó theo cách tối ưu hóa khác, quan trọng hơn / có tác động hơn. Ngoài ra, những thứ như heuristic nội tuyến rất khó để có được đúng, do đó, đưa ra quyết định nội tuyến đúng vào đúng thời điểm là một lĩnh vực nghiên cứu đang diễn ra và những thay đổi tương ứng đối với hành vi của công cụ / trình biên dịch; Điều này làm cho một trường hợp khác mà nó sẽ không may cho tất cả mọi người (bạn chúng tôi) nếu bạn dành nhiều thời gian để chỉnh sửa mã của mình cho đến khi một số phiên bản trình duyệt hiện tại thực hiện xấp xỉ các quyết định nội tuyến mà bạn nghĩ (hoặc biết?) là tốt nhất, chỉ quay lại nửa năm sau để nhận ra rằng các trình duyệt hiện tại đã thay đổi heuristic của họ.

Tất nhiên, bạn luôn có thể đo lường hiệu suất của toàn bộ ứng dụng của mình - đó là điều quan trọng cuối cùng, không phải là lựa chọn cụ thể nào mà động cơ thực hiện trong nội bộ. Coi chừng các dấu hiệu vi mô, vì chúng gây hiểu nhầm: nếu bạn chỉ trích xuất hai dòng mã và điểm chuẩn, thì có khả năng kịch bản sẽ đủ khác nhau (ví dụ: phản hồi loại khác nhau) mà động cơ sẽ đưa ra các quyết định rất khác nhau.


2
Cảm ơn câu trả lời tuyệt vời này, nó xác nhận nhiều nghi ngờ của tôi về cách mọi thứ hoạt động, và quan trọng là cách chúng dự định hoạt động. Nhân tiện, có bài viết nào trên blog về vấn đề "phản hồi kiểu" mà bạn đề cập Array.sort()không? Tôi muốn đọc thêm một chút về nó.
Joppy

Tôi không nghĩ rằng chúng tôi đã viết blog về khía cạnh cụ thể đó. Về cơ bản, đó là những gì bạn tự mô tả trong câu hỏi của mình: khi các nội dung được triển khai bằng JavaScript, chúng "giống như một thư viện" theo nghĩa là nếu các đoạn mã khác nhau gọi chúng với các loại khác nhau, thì hiệu suất có thể bị ảnh hưởng - đôi khi chỉ là một chút, đôi khi nhiều hơn nữa. Nó không phải là duy nhất, và thậm chí còn không phải là vấn đề lớn nhất với kỹ thuật đó; Tôi hầu như chỉ muốn nói rằng tôi quen thuộc với vấn đề chung.
jmrk
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.