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, các ứng dụng web đã tăng tốc đáng kể. Nhiều ứng dụng hiện chạy đủ nhanh đến mức tôi nghe 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 thì có thể như vậy, nhưng đối với các nhà phát triển làm việc về các ứng dụng hiệu suất cao, chúng tôi biết rằng tốc độ vẫn chưa đủ nhanh. Mặc dù có những tiến bộ đáng kinh ngạc trong công nghệ máy ảo JavaScript, một nghiên cứu gần đây cho thấy các ứng dụng của Google vẫn dành từ 50% đến 70% thời gian trong V8. Ứng dụng của bạn có một khoảng thời gian hữu hạn, việc loại bỏ chu kỳ khỏi một hệ thống đồng nghĩa với việc một 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 trên mỗi khung hình, nếu không thì hiện tượng giật. Hãy đọc tiếp, để tìm hiểu về cách tối ưu hoá JavaScript và phân tích tài nguyên cho các ứng dụng JavaScript, trong câu chuyện về các thám tử hiệu suất của nhóm V8 theo dõi một sự cố hiệu suất mơ hồ trong Tìm đường đến xứ Oz.

Phiên hội thảo 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à trò chơi tổng bằng 0. Khi giảm bớt một phần trong hệ thống, bạn có thể sử dụng nhiều hơn ở phần khác hoặc chạy mượt mà hơn về tổng thể. Chạy nhanh hơn và làm được nhiều việc hơn thường là mục tiêu cạnh tranh. Người dùng yêu cầu các tính năng mới trong khi cũng mong đợi ứng dụng của bạn chạy mượt mà hơn. Các 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, vì nhiều nhà phát triển xử lý các vấn đề về hiệu suất trong các ứng dụng web của họ đã biết. Trong thời gian thực, tốc độ khung hình cao, các ứng dụng chịu áp lực để không có hiện tượng giật là vô cùng quan trọng. Insomniac Games đã đưa ra một nghiên cứu cho thấy rằng tốc độ khung hình ổn định, ổn định 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 thiết kế tốt." Các nhà phát triển web lưu ý đến điều này.

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

Giải quyết vấn đề hiệu suất cũng giống như giải quyết tội phạm. 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 chép lại các số đo của mình để có thể chắc chắn rằng bạn đã thực sự khắc phục vấn đề. Phương pháp này không có nhiều điểm khác biệt và cách các thám tử hình sự phá vỡ một vụ á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 với hy vọng tìm ra khẩu súng hút thuốc.

V8 CSI: Oz

Phù thuỷ tuyệt vời trong xây dựng Tìm đường đến xứ Oz đã tiếp cận đội ngũ V8 về một vấn đề hiệu suất mà họ không thể tự giải quyết. Thỉnh thoảng Oz bị đóng băng, gây ra hiện tượng giật. Các nhà phát triển Oz đã thực hiện 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 một lần, trình thu gom rác lại thu thập 10 MB rác và thời gian 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ừ Dòng thời gian trong Chrome Devtools:

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

Hai thám tử V8, Jakob và Dương đã tiếp nhận vụ án. Điều đã diễn ra là một cuộc qua lại giữa Jakob và Dương từ đội V8 và đội Oz. Tôi đã tóm lược cuộc trò chuyện này để chỉ ra các sự kiện quan trọng giúp tìm ra 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 tôi đ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 đó, bạn rất dễ gặp phải các trường hợp tạm dừng do 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 mọi thao tác với JavaScript và phải dành một khoảng thời gian đó để Chrome xử lý các lệnh gọi đồ hoạ và vẽ màn hình.

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

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

Chúng tôi thấy có các đoạn tạm dừng, hay còn gọi là rớt khung hình, hay giật. Các lần tạm dừng này tương ứng với các lần chạy thu gom rác.

Nhà phát triển có làm theo các phương pháp hay nhất không?

Có, các nhà phát triển ở Oz nắm rõ các kỹ thuật tối ưu hoá và hiệu suất của máy ảo JavaScript. Điểm đáng chú ý là các nhà phát triển Oz đã 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 vì sự tách biệt giữa đoạn mã do các nhà phát triển Oz viết và đoạn mã mà 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 việc này trở nên dễ dàng hơn.

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

Bộ nhớ trong JavaScript được máy ảo quản lý tự động 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) generations. Thế hệ trẻ lưu giữ các đối tượng được phân bổ gần đây. Nếu một đối tượng tồn tại đủ lâu, thì đối tượng đó sẽ được chuyển sang thế hệ cũ.

Thế hệ trẻ được thu thập với tần suất cao hơn nhiều so với thế hệ cũ được thu thập. Điều này là do thiết kế, vì bộ sưu tập thế hệ trẻ rẻ hơn nhiều. Thông thường, bạn có thể giả định rằng các lần tạm dừng GC thường xuyên là do việc thu thập thế hệ trẻ gây ra.

Trong V8, không gian bộ nhớ non được chia thành hai khối bộ nhớ liền kề có kích thước bằng nhau. Tại một thời điểm bất kỳ, chỉ một trong hai khối bộ nhớ này được sử dụng và khối này được gọi là không gian. Mặc dù bộ nhớ còn lại trong không gian, việc phân bổ một đối tượng mới sẽ rẻ. Con trỏ trong không gian đượ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 sẽ tiếp tục cho đến khi hết dung lượng. Tại thời điểm này, chương trình kết thúc và quá trình thu thập sẽ bắt đầu.

Ký ức trẻ tuổi V8

Lúc này, không gian và không gian hoán đổi nhau. Những gì trong không gian và bây giờ là từ không gian, được quét từ đầu đến cuối và mọi đối tượng vẫn còn sống sẽ được sao chép vào không gian hoặc được đẩy 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.

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

Ứng dụng này có dự kiến dung lượng rác là 10 MB/giây không?

Nói ngắn gọn là không. Nhà phát triển không làm gì để mong đợi tốc độ dữ liệu rác là 10 MB/giây.

Nghi ngờ

Giai đoạn tiếp theo của cuộc điều tra là xác định các nghi phạm tiềm ẩn và sau đó tố tụng họ.

Nghi ngờ 1

Gọi điện thoại mới trong khung hình. Hãy nhớ rằng mỗi đối tượng được phân bổ sẽ giúp bạn tiến gần hơn đến bước tạm dừng GC. Các ứng dụng chạy ở tốc độ khung hình cao nên cố gắng không phân bổ mức phân bổ 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ế vật thể được cân nhắc kỹ lưỡng và dành riêng cho ứng dụng. Các thám tử của V8 đã kiểm tra với nhóm Oz và họ không gọi mới. Trên thực tế, đội bóng xứ Oz đã biết rõ yêu cầu này và nói rằng: "Điều đó thật đáng xấu hổ". Hãy xoá câu hỏi này trong danh sách.

Nghi ngờ 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, việc bỏ chọn 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. Việc huỷ tối ưu hoá và tối ưu hoá lại này sẽ dẫn đến hiện tượng giật nhưng không liên quan chặt chẽ đến việc tạo quá nhiều rác. Sau khi kiểm tra mã kỹ lưỡng, chúng tôi xác nhận rằng hình dạng đối tượng là tĩnh, do đó nghi ngờ số 2 đã được loại trừ.

Nghi ngờ 3

Số học trong mã chưa được tối ưu hóa. Trong mã không được tối ưu hoá, tất cả các phép tính đều dẫn đến việc phân bổ đối tượng thực tế. Ví dụ: đoạn mã này:

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 đang được tạo. Ba yếu tố đầu tiên là dành cho các biến, a, b và c. Thứ 4 là dành cho giá trị ẩn danh (a * b) và thứ 5 là từ #4 * c; thứ 5 cuối cùng được gán cho point.x.

Oz thực hiện hàng nghìn thao tác như vậy trong 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ì chúng có thể là nguyên nhân gây ra rác. Vì các phép tính trong quá trình chưa được tối ưu hoá sẽ phân bổ bộ nhớ ngay cả với kết quả tạm thời.

Nghi ngờ #4

Lưu trữ 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à thay đổi thuộc tính để 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 đang được lưu trữ dưới dạng thuộc tính đối tượng. Mã này có đầ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 được tính toán, một câu lệnh dường như 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 việc tạm dừng thu gom rác.

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

Có thể nghi ngờ số 4.

Pháp y

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

Thử nghiệm 1

Đang kiểm tra nghi ngờ số 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ó hệ thống ghi nhật ký tích hợp có thể cung cấp thông tin chi tiết hữu ích về những gì đang xảy ra.

Khi Chrome hoàn toàn không chạy, hãy khởi chạy Chrome kèm theo cờ:

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

sau đó thoát hoàn toàn Chrome sẽ dẫn đến 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 chính 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 phiên bản 8 thành công, bạn có thể xử lý nhật ký bằng trình xử lý kim đánh dấu nhịp độ khung hình:

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

(Thay thế Linux hoặc Mac, tuỳ thuộc vào nền tảng của bạn.) (Công cụ này phải chạy từ thư mục nguồn cấp cao nhất trong phiên bản 8.)

Trình xử lý kim đánh dấu nhịp độ khung hình hiển thị một bảng gồm các hàm JavaScript dựa trên văn bản có nhiều kim đánh dấu 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ó ba chức năng: opt, unopt và main. Hàm được tối ưu hoá có dấu hoa thị (*) bên cạnh tên. Lưu ý rằng hàm chọn được tối ưu hoá và hàm bỏ chọn đượ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, tệp png có tên làtimer-events.png nằm trong thư mục hiện tại. Khi mở ứng dụng này, bạn sẽ thấy giao diện như sau:

Sự kiện bộ tính giờ

Ngoài biểu đồ dọc theo dưới cùng, dữ liệu còn hiển thị theo hàng. Trục X là thời gian (mili giây). Phía bên trái bao gồm các nhãn cho mỗi hàng:

Trục Y của các sự kiện bộ tính giờ

Hàng V8.Execute có đường thẳng đứng màu đen được vẽ trên đó ở mỗi kim đánh dấu nhịp độ khung hình mà V8 đang thực thi mã JavaScript. V8.GCScavenger có một đường dọc màu xanh dương được vẽ ở mỗi kim đánh dấu nhịp độ khung hình ở vị trí V8 đang thực hiện bộ sưu tập thế hệ mới. Tương tự như vậy với các trạng thái còn lại của V8.

Một trong những hàng quan trọng nhất là "loại mã đang được thực thi". Mã 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, đồng thời kết hợp màu đỏ và màu xanh dương khi mã chưa được tối ưu hoá đang được thực thi. Ảnh chụp màn hình sau đây cho thấy quá trình chuyển đổi từ trạng thái được tối ưu hoá thành không được tối ưu hoá, sau đó quay lại thành mã được tối ưu hoá:

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

Tốt nhất là nhưng không bao giờ ngay lập tức, đường này sẽ có màu xanh lục đồng nhất. 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ã không được tối ưu hoá sẽ luôn chạy chậm hơn mã được tối ưu hoá.

Lưu ý: Bạn có thể hoạt động nhanh hơn nhiều bằng cách tái cấu trúc ứng dụng để có thể chạy trong trình gỡ lỗi v8 shell: d8. Khi sử dụng d8, bạn sẽ có thời gian lặp lại nhanh hơn nhờ bộ xử lý kim đánh dấu nhịp độ khung hình và công cụ plot-timer-event. Một tác dụng phụ khác của việc sử dụng d8 là nó trở nên dễ dàng hơn để tách biệt vấn đề thực tế, giảm lượng nhiễu có trong dữ liệu.

Nhìn vào biểu đồ sự kiện bộ tính giờ từ mã nguồn Oz, cho thấy sự 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ộ tính giờ

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

Nhìn vào kết quả của bộ xử lý kim đánh dấu nhịp độ khung hình từ mã nguồn Oz, hàm hàng đầu (updateSprites) chưa được tối ưu hoá. Nói cách khác, chức năng 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 rằng nghi phạm số 3 chính là thủ phạm. Nguồn của updateSprites chứa các vòng lặp có dạng như sau:

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

Khi hiểu rõ về V8, họ nhận ra ngay 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á. Hiện tại, đây là một trường hợp đặc biệt và có thể sẽ thay đổi trong tương lai, tức là V8 có thể một ngày nào đó sẽ tối ưu hoá cấu trúc vòng lặp này. Vì chúng ta không phải là thám tử V8 và không biết V8 như mu bàn tay, làm thế nào chúng ta có thể xác định lý do tại sao updateSprites không được tối ưu hoá?

Thử nghiệm 2

Chạy Chrome với 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á. Khi tìm kiếm trong dữ liệu cho updateSprites, chúng tôi tìm 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]

Đúng như giả thuyết của các thám tử, nguyên nhân chính là cấu trúc vòng lặp for-i-in.

Yêu cầu đã được xử lý

Sau khi khám phá nguyên nhân khiến updateSprites không được tối ưu hoá, cách khắc phục rất đơn giản, bạn chỉ cần di chuyển phép tính vào hàm riêng, 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á, giúp giảm số đối tượng HeapNumber, dẫn đến việc tạm dừng GC ít thường xuyên hơn. Bạn phải 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 số kép 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 việc này là đáng giá, thì việc thay đổi vị trí thành một mảng kép hoặc một mảng dữ liệu đã nhập sẽ làm giảm hơn nữa số lượng đối tượng được tạo.

Lời kết

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

Hãy ra ngoài và bắt đầu xử lý một số vấn đề về hiệu suất!