Sử dụng dịch vụ pháp y và điều tra để giải quyết những bí ẩn về hiệu suất của JavaScript

John McCutchan
John McCutchan

Giới thiệu

Trong những năm gần đây, tốc độ của các ứng dụng web đã tăng lên đáng kể. Nhiều ứng dụng hiện chạy đủ nhanh đến mức tôi nghe thấy một số nhà phát triển tự hỏi "web có đủ nhanh không?". Đối với một số ứng dụng, có thể là như vậy, nhưng đối với các nhà phát triển đang làm việc trên các ứng dụng có hiệu suất cao, chúng tôi biết rằng tốc độ này là chưa đủ nhanh. Mặc dù công nghệ máy ảo JavaScript đã có những tiến bộ đáng kinh ngạc, nhưng một nghiên cứu gần đây cho thấy các ứng dụng của Google dành từ 50% đến 70% thời gian bên trong V8. Ứng dụng của bạn có một khoảng thời gian hữu hạn, việc giảm chu kỳ của một hệ thống có nghĩa là hệ thống khác có thể làm được nhiều việc hơn. Hãy nhớ rằng các ứng dụng chạy ở tốc độ 60 khung hình/giây chỉ có 16 mili giây cho mỗi khung hình, nếu không sẽ bị gián đoạn. Hãy đọc tiếp để tìm hiểu về cách tối ưu hoá JavaScript và phân tích các ứng dụng JavaScript, trong câu chuyện thực tế về các thám tử hiệu suất trong nhóm V8 đang theo dõi một vấn đề hiệu suất khó hiểu trong Find Your Way to Oz (Tìm đường đến xứ Oz).

Phiên Google I/O 2013

Tôi đã trình bày tài liệu này tại Google I/O 2013. Hãy xem video dưới đây:

Tại sao hiệu suất lại quan trọng?

Chu kỳ CPU là một trò chơi có tổng bằng 0. Việc giảm mức sử dụng của một phần hệ thống cho phép bạn sử dụng nhiều hơn trong một phần khác hoặc chạy mượt mà hơn tổng thể. Việc chạy nhanh hơn và làm được nhiều việc hơn thường là những mục tiêu cạnh tranh. Người dùng đòi hỏi các tính năng mới nhưng cũng mong muốn ứng dụng của bạn chạy mượt mà hơn. Máy ảo JavaScript ngày càng nhanh hơn, nhưng đó không phải là lý do để bỏ qua các vấn đề về hiệu suất mà bạn có thể khắc phục ngay hôm nay, như nhiều nhà phát triển đã biết khi xử lý các vấn đề về hiệu suất trong ứng dụng web của họ. Trong thời gian thực, tốc độ khung hình cao, các ứng dụng phải đảm bảo không bị giật là điều tối quan trọng. Insomniac Games đã thực hiện một nghiên cứu cho thấy tốc độ khung hình ổn định, liên tục là yếu tố quan trọng đối với sự thành công của trò chơi: "Tốc độ khung hình ổn định vẫn là dấu hiệu của một sản phẩm chuyên nghiệp, được làm tốt". Nhà phát triển web lưu ý.

Giải quyết vấn đề về hiệu suất

Giải quyết vấn đề về hiệu suất cũng giống như giải quyết một vụ án. Bạn cần kiểm tra kỹ bằng chứng, kiểm tra các nguyên nhân đáng ngờ và thử nghiệm nhiều giải pháp. Trong suốt quá trình này, bạn phải ghi lại các kết quả đo lường để đảm bảo rằng bạn đã thực sự khắc phục được vấn đề. Phương pháp này có rất ít điểm khác biệt so với cách các thám tử hình sự phá án. Các thám tử kiểm tra bằng chứng, thẩm vấn nghi phạm và chạy các thử nghiệm để tìm ra bằng chứng xác thực.

V8 CSI: Oz

Các pháp sư tuyệt vời đang xây dựng Find Your Way to Oz đã liên hệ với nhóm V8 về một vấn đề về hiệu suất mà họ không thể tự giải quyết. Đôi khi, Oz bị treo, gây ra hiện tượng giật. Các nhà phát triển của Oz đã tiến hành một số điều tra ban đầu bằng cách sử dụng Bảng điều khiển tiến trình trong Công cụ của Chrome cho nhà phát triển. Khi xem xét mức sử dụng bộ nhớ, họ gặp phải biểu đồ răng cưa đáng sợ. Mỗi giây, trình thu gom rác thu thập 10 MB rác và các điểm tạm dừng thu gom rác tương ứng với hiện tượng giật. Tương tự như ảnh chụp màn hình sau đây từ Tiến trình trong Công cụ của Chrome cho nhà phát triển:

Tiến trình phát triển công cụ

Các thám tử V8, Jakob và Yang đã tiếp nhận trường hợp này. Sau đó, Jakob và Yang của nhóm V8 đã trao đổi qua lại rất lâu với nhóm Oz. Tôi đã tóm tắt cuộc trò chuyện này thành những sự kiện quan trọng giúp theo dõi vấn đề này.

Bằng chứng

Bước đầu tiên là thu thập và nghiên cứu bằng chứng ban đầu.

Chúng ta đang xem xét loại ứng dụng nào?

Bản minh hoạ Oz là một ứng dụng 3D tương tác. Do đó, phương thức này rất nhạy cảm với các khoảng tạm dừng do việc thu gom rác gây ra. Hãy nhớ rằng một ứng dụng tương tác chạy ở tốc độ 60 khung hình/giây có 16 mili giây để thực hiện tất cả công việc JavaScript và phải để lại một phần thời gian đó để Chrome xử lý các lệnh gọi đồ hoạ và vẽ màn hình.

Oz thực hiện nhiều phép tính số học trên các giá trị kép và thường xuyên gọi đến WebAudio và WebGL.

Chúng ta đang gặp phải loại vấn đề về hiệu suất nào?

Chúng tôi thấy hiện tượng tạm dừng (còn gọi là rớt khung hình hoặc giật). Các điểm tạm dừng này tương quan với các lần chạy thu gom rác.

Nhà phát triển có tuân thủ các phương pháp hay nhất không?

Có, các nhà phát triển của Oz rất am hiểu về hiệu suất và kỹ thuật tối ưu hoá máy ảo JavaScript. Xin lưu ý rằng các nhà phát triển Oz đang sử dụng CoffeeScript làm ngôn ngữ nguồn và tạo mã JavaScript thông qua trình biên dịch CoffeeScript. Điều này khiến một số cuộc điều tra trở nên khó khăn hơn do sự ngắt kết nối giữa mã do các nhà phát triển Oz viết và mã do V8 sử dụng. Công cụ của Chrome cho nhà phát triển hiện hỗ trợ bản đồ nguồn giúp bạn dễ dàng thực hiện việc này hơn.

Tại sao trình thu gom rác lại chạy?

Bộ nhớ trong JavaScript được máy ảo tự động quản lý cho nhà phát triển. V8 sử dụng một hệ thống thu gom rác phổ biến, trong đó bộ nhớ được chia thành hai (hoặc nhiều) thế hệ. Thế hệ trẻ chứa các đối tượng được phân bổ gần đây. Nếu tồn tại đủ lâu, đối tượng sẽ được chuyển sang thế hệ cũ.

Hệ thống thu thập dữ liệu của thế hệ mới với tần suất cao hơn nhiều so với thế hệ cũ. Điều này là do thiết kế, vì việc thu thập thế hệ mới rẻ hơn nhiều. Bạn thường có thể giả định rằng việc tạm dừng GC thường xuyên là do hoạt động thu thập thế hệ mới.

Trong V8, không gian bộ nhớ mới được chia thành hai khối bộ nhớ liền kề có kích thước bằng nhau. Chỉ một trong hai khối bộ nhớ này được sử dụng tại một thời điểm bất kỳ và khối bộ nhớ này được gọi là không gian đến. Mặc dù còn bộ nhớ trong không gian đến, nhưng việc phân bổ đối tượng mới sẽ không tốn kém. Con trỏ trong không gian đến được di chuyển về phía trước số byte cần thiết cho đối tượng mới. Quá trình này tiếp tục cho đến khi hết không gian. Tại thời điểm này, chương trình sẽ dừng và quá trình thu thập bắt đầu.

Bộ nhớ mới của V8

Tại thời điểm này, không gian từ và đến được hoán đổi. Không gian đến (to space) trước đây và hiện là không gian từ (from space) được quét từ đầu đến cuối và mọi đối tượng vẫn còn hoạt động sẽ được sao chép vào không gian đến hoặc được chuyển lên vùng nhớ khối xếp thế hệ cũ. Nếu muốn biết thông tin chi tiết, bạn nên đọc về Thuật toán của Cheney.

Bạn nên hiểu rằng mỗi khi một đối tượng được phân bổ ngầm ẩn hoặc rõ ràng (thông qua lệnh gọi đến new, [], hoặc {}), ứng dụng của bạn sẽ ngày càng tiến gần đến việc thu gom rác và tạm dừng ứng dụng đáng sợ.

Ứng dụng này có dự kiến tạo ra 10 MB/giây rác không?

Tóm lại là không. Nhà phát triển không làm gì để tạo ra 10 MB/giây rác.

Nghi phạm

Giai đoạn tiếp theo của cuộc điều tra là xác định những nghi phạm tiềm năng, sau đó loại bỏ dần những nghi phạm đó.

Nghi phạm số 1

Gọi new trong khung. Hãy nhớ rằng mỗi đối tượng được phân bổ sẽ đưa bạn đến gần hơn với thời điểm tạm dừng GC. Đặc biệt, các ứng dụng chạy ở tốc độ khung hình cao phải cố gắng không phân bổ bộ nhớ cho mỗi khung hình. Thông thường, việc này đòi hỏi một hệ thống tái chế đối tượng được suy nghĩ kỹ lưỡng, dành riêng cho ứng dụng. Các thám tử V8 đã kiểm tra với nhóm Oz và họ không gọi là mới. Trên thực tế, nhóm Oz đã nắm rõ yêu cầu này và nói rằng "Điều đó sẽ rất khó xử". Hãy loại bỏ mục này khỏi danh sách.

Nghi phạm #2

Sửa đổi "hình dạng" của một đối tượng bên ngoài hàm khởi tạo. Điều này xảy ra bất cứ khi nào một thuộc tính mới được thêm vào một đối tượng bên ngoài hàm khởi tạo. Thao tác này sẽ tạo một lớp ẩn mới cho đối tượng. Khi mã được tối ưu hoá thấy lớp ẩn mới này, một thao tác huỷ tối ưu hoá sẽ được kích hoạt, mã chưa được tối ưu hoá sẽ thực thi cho đến khi mã được phân loại là nóng và được tối ưu hoá lại. Quá trình huỷ tối ưu hoá,tối ưu hoá lại này sẽ gây ra hiện tượng giật nhưng không liên quan chặt chẽ đến việc tạo rác quá mức. Sau khi kiểm tra kỹ mã, chúng tôi xác nhận rằng hình dạng đối tượng là tĩnh, do đó, nghi phạm #2 đã bị loại trừ.

Nghi phạm #3

Số học trong mã chưa được tối ưu hoá. Trong mã chưa được tối ưu hoá, tất cả kết quả tính toán đều được phân bổ cho các đối tượng thực tế. Ví dụ: đoạn mã sau:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

Kết quả là 5 đối tượng HeapNumber được tạo. Ba biến đầu tiên là a, b và c. Giá trị thứ 4 là dành cho giá trị ẩn danh (a * b) và giá trị thứ 5 là từ #4 * c; Cuối cùng, giá trị thứ 5 được gán cho point.x.

Oz thực hiện hàng nghìn thao tác này trên mỗi khung hình. Nếu bất kỳ phép tính nào trong số này xảy ra trong các hàm không bao giờ được tối ưu hoá, thì đó có thể là nguyên nhân gây ra rác. Vì các phép tính trong bộ nhớ chưa được tối ưu hoá sẽ phân bổ bộ nhớ ngay cả đối với kết quả tạm thời.

Nghi phạm số 4

Lưu trữ một số có độ chính xác kép vào một thuộc tính. Bạn phải tạo một đối tượng HeapNumber để lưu trữ số và thuộc tính được thay đổi để trỏ đến đối tượng mới này. Việc thay đổi thuộc tính để trỏ đến HeapNumber sẽ không tạo ra rác. Tuy nhiên, có thể có nhiều số có độ chính xác kép được lưu trữ dưới dạng thuộc tính đối tượng. Mã này chứa đầy các câu lệnh như sau:

sprite.position.x += 0.5 * (dt);

Trong mã được tối ưu hoá, mỗi khi x được gán một giá trị mới tính toán, một câu lệnh có vẻ vô hại, một đối tượng HeapNumber mới sẽ được phân bổ ngầm, đưa chúng ta đến gần hơn với thời điểm tạm dừng thu gom rác.

Xin lưu ý rằng bằng cách sử dụng mảng đã nhập (hoặc mảng thông thường chỉ chứa số thực dấu phẩy động), bạn có thể tránh hoàn toàn vấn đề cụ thể này vì bộ nhớ cho số có độ chính xác kép chỉ được phân bổ một lần và việc thay đổi giá trị nhiều lần không yêu cầu phân bổ bộ nhớ mới.

Nghi phạm số 4 là một khả năng.

Pháp y

Tại thời điểm này, các thám tử có hai nghi phạm có thể: lưu trữ số vùng nhớ khối xếp dưới dạng thuộc tính đối tượng và tính toán số học diễn ra bên trong các hàm chưa được tối ưu hoá. Đã đến lúc quay lại phòng thí nghiệm để xác định chắc chắn nghi phạm nào có tội. LƯU Ý: Trong phần này, tôi sẽ sử dụng một bản sao của vấn đề tìm thấy trong mã nguồn Oz thực tế. Mã tái tạo này nhỏ hơn nhiều cấp so với mã ban đầu, do đó dễ hiểu hơn.

Thử nghiệm #1

Kiểm tra đối tượng đáng ngờ #3 (tính toán số học bên trong các hàm chưa được tối ưu hoá). Công cụ JavaScript V8 có một hệ thống ghi nhật ký tích hợp sẵn, có thể cung cấp thông tin chi tiết tuyệt vời về những gì đang diễn ra.

Bắt đầu với Chrome không chạy, hãy khởi chạy Chrome bằng các cờ:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

sau đó thoát hoàn toàn Chrome sẽ tạo ra tệp v8.log trong thư mục hiện tại.

Để diễn giải nội dung của v8.log, bạn phải tải xuống cùng một phiên bản v8 mà Chrome đang sử dụng (kiểm tra about:version) và tạo phiên bản đó.

Sau khi tạo thành công phiên bản v8, bạn có thể xử lý nhật ký bằng trình xử lý đánh dấu nhịp độ khung hình:

$ tools/linux-tick-processor /path/to/v8.log

(Thay thế mac hoặc windows cho linux tuỳ thuộc vào nền tảng của bạn.) (Bạn phải chạy công cụ này từ thư mục nguồn cấp cao nhất trong v8.)

Trình xử lý đánh dấu nhịp độ khung hình hiển thị một bảng dựa trên văn bản gồm các hàm JavaScript có nhiều dấu đánh dấu nhịp độ khung hình nhất:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

Bạn có thể thấy demo.js có 3 hàm: opt, unopt và main. Các hàm được tối ưu hoá có dấu hoa thị (*) bên cạnh tên. Quan sát thấy hàm opt được tối ưu hoá và unopt không được tối ưu hoá.

Một công cụ quan trọng khác trong túi công cụ của thám tử V8 là plot-timer-event. Bạn có thể thực thi như sau:

$ tools/plot-timer-event /path/to/v8.log

Sau khi chạy, một tệp png có tên timer-events.png sẽ nằm trong thư mục hiện tại. Khi mở tệp này, bạn sẽ thấy nội dung như sau:

Sự kiện hẹn giờ

Ngoài biểu đồ ở dưới cùng, dữ liệu sẽ hiển thị theo hàng. Trục X là thời gian (mili giây). Bên trái bao gồm các nhãn cho từng hàng:

Trục Y của sự kiện bộ hẹn giờ

Hàng V8.Execute có một đường dọc màu đen được vẽ trên đó tại mỗi dấu kiểm hồ sơ mà V8 đang thực thi mã JavaScript. V8.GCScavenger có một đường dọc màu xanh dương được vẽ trên đó tại mỗi dấu kiểm hồ sơ mà V8 đang thực hiện một bộ sưu tập thế hệ mới. Tương tự như vậy đối với các trạng thái V8 còn lại.

Một trong những hàng quan trọng nhất là "loại mã đang được thực thi". Dấu này sẽ có màu xanh lục bất cứ khi nào mã được tối ưu hoá đang thực thi và kết hợp màu đỏ và xanh dương khi mã chưa được tối ưu hoá đang thực thi. Ảnh chụp màn hình sau đây cho thấy quá trình chuyển đổi từ mã được tối ưu hoá sang mã chưa được tối ưu hoá, sau đó quay lại mã được tối ưu hoá:

Loại mã đang được thực thi

Lý tưởng nhất là dòng này sẽ có màu xanh lục đậm, nhưng không phải lúc nào cũng như vậy. Tức là chương trình của bạn đã chuyển sang trạng thái ổn định được tối ưu hoá. Mã chưa được tối ưu hoá sẽ luôn chạy chậm hơn mã được tối ưu hoá.

Nếu đã làm đến bước này, bạn nên lưu ý rằng bạn có thể làm việc nhanh hơn nhiều bằng cách tái cấu trúc ứng dụng để ứng dụng có thể chạy trong vỏ gỡ lỗi v8: d8. Việc sử dụng d8 giúp bạn có thời gian lặp lại nhanh hơn nhờ các công cụ trình xử lý đánh dấu nhịp độ khung hình và sự kiện trình lập biểu đồ bộ hẹn giờ. Một tác dụng phụ khác của việc sử dụng d8 là bạn có thể dễ dàng tách riêng vấn đề thực tế, giảm lượng nhiễu có trong dữ liệu.

Khi xem biểu đồ sự kiện bộ hẹn giờ từ mã nguồn Oz, bạn sẽ thấy quá trình chuyển đổi từ mã được tối ưu hoá sang mã chưa được tối ưu hoá và trong khi thực thi mã chưa được tối ưu hoá, nhiều bộ sưu tập thế hệ mới đã được kích hoạt, tương tự như ảnh chụp màn hình sau (lưu ý thời gian đã bị xoá ở giữa):

Biểu đồ sự kiện bộ hẹn giờ

Nếu quan sát kỹ, bạn có thể thấy các đường màu đen cho biết thời điểm V8 đang thực thi mã JavaScript bị thiếu chính xác cùng một thời điểm đánh dấu hồ sơ với các bộ sưu tập thế hệ mới (đường màu xanh dương). Điều này minh hoạ rõ ràng rằng trong khi rác đang được thu thập, tập lệnh sẽ bị tạm dừng.

Khi xem xét đầu ra của bộ xử lý đánh dấu nhịp độ khung hình từ mã nguồn Oz, hàm trên cùng (updateSprites) chưa được tối ưu hoá. Nói cách khác, hàm mà chương trình dành nhiều thời gian nhất cũng không được tối ưu hoá. Điều này cho thấy rõ rằng nghi phạm số 3 là thủ phạm. Nguồn cho updateSprites chứa các vòng lặp như sau:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

Nắm rõ V8 như họ, họ ngay lập tức nhận ra rằng cấu trúc vòng lặp for-i-in đôi khi không được V8 tối ưu hoá. Nói cách khác, nếu một hàm chứa cấu trúc vòng lặp for-i-in, thì hàm đó có thể không được tối ưu hoá. Đây là một trường hợp đặc biệt hiện nay và có thể sẽ thay đổi trong tương lai, tức là V8 có thể sẽ tối ưu hoá cấu trúc vòng lặp này vào một ngày nào đó. Vì chúng ta không phải là thám tử V8 và không biết rõ V8, làm cách nào để xác định lý do updateSprites không được tối ưu hoá?

Thử nghiệm #2

Chạy Chrome bằng cờ này:

--js-flags="--trace-deopt --trace-opt-verbose"

hiển thị nhật ký chi tiết về dữ liệu tối ưu hoá và huỷ tối ưu hoá. Tìm kiếm trong dữ liệu cho updateSprites, chúng ta thấy:

[tắt tính năng tối ưu hoá cho updateSprites, lý do: ForInStatement không phải là trường hợp nhanh]

Như các thám tử đã giả định, cấu trúc vòng lặp for-i-in là nguyên nhân.

Trường hợp đã xử lý xong

Sau khi tìm ra lý do updateSprites không được tối ưu hoá, cách khắc phục rất đơn giản, chỉ cần di chuyển phép tính vào hàm riêng của nó, tức là:

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

updateSprite sẽ được tối ưu hoá, dẫn đến số lượng đối tượng HeapNumber ít hơn nhiều, nhờ đó giảm tần suất tạm dừng GC. Bạn có thể dễ dàng xác nhận điều này bằng cách thực hiện các thử nghiệm tương tự với mã mới. Người đọc cẩn thận sẽ nhận thấy rằng các số thực vẫn đang được lưu trữ dưới dạng thuộc tính. Nếu việc phân tích tài nguyên cho thấy điều này đáng làm, thì việc thay đổi vị trí thành một mảng số thực hoặc mảng dữ liệu đã nhập sẽ làm giảm thêm số lượng đối tượng được tạo.

Lời kết

Các nhà phát triển Oz không dừng lại ở đó. Nhờ có các công cụ và kỹ thuật mà các thám tử V8 chia sẻ, họ đã có thể tìm thấy một vài hàm khác bị mắc kẹt trong tình trạng huỷ tối ưu hoá và đưa mã tính toán vào các hàm lá đã được tối ưu hoá, nhờ đó mang lại hiệu suất tốt hơn nữa.

Hãy bắt đầu giải quyết một số tội phạm về hiệu suất!