Giới thiệu
Trong những năm gần đây, các ứng dụng web đã được tăng tốc đá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 "liệu web có đủ nhanh không?". Đối với một số ứng dụng, tốc độ này có thể là đủ, nhưng đối với các nhà phát triển đang làm việc trên các ứng dụng hiệu suất cao, chúng tôi biết rằng tốc độ này là chưa đủ nhanh. Bất chấp 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 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 giảm chu kỳ của một hệ thống có nghĩa là 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 cho mỗi khung hình, nếu không sẽ xảy ra 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 ứng dụng JavaScript, trong một câu chuyện từ chiến hào của các thám tử hiệu suất trong nhóm V8 theo dõi một vấn đề về hiệu suất khó hiểu trong Find Your Way to 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 không. Việc giảm mức sử dụng tài nguyên của một phần trong hệ thống sẽ giúp bạn có thêm tài nguyên cho phần khác hoặc giúp hệ thống chạy mượt mà hơn. 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 nhau. Người dùng yêu cầu các tính năng mới, đồng thời cũng mong đợi ứ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, vì nhiều nhà phát triển đã biết cách xử lý các vấn đề về hiệu suất trong ứng dụng web của họ. Trong các ứng dụng theo thời gian thực, tốc độ khung hình cao, áp lực để không bị giật là điều tối quan trọng. Insomniac Games đã đưa ra một nghiên cứu cho thấy tốc độ khung hình ổn định và duy trì 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 thiết kế tốt". Nhà phát triển web cần lưu ý.
Giải quyết các 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 xem xét kỹ bằng chứng, kiểm tra các nguyên nhân bị nghi ngờ và thử nghiệm nhiều giải pháp. Trong suốt quá trình, 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 không khác mấy so với cách các thám tử hình sự phá án. Các thám tử xem xét bằng chứng, thẩm vấn nghi phạm và tiến hành thử nghiệm với hy vọng tìm ra bằng chứng rõ ràng.
V8 CSI: Oz
Những phù thuỷ tuyệt vời xây dựng Find Your Way to Oz đã tiếp cận nhóm V8 để giải quyết một vấn đề về hiệu suất mà họ không thể tự giải quyết. Đôi khi, Oz sẽ bị treo, gây ra hiện tượng giật. Các nhà phát triển Oz đã thực hiện một số cuộc điều tra ban đầu bằng cách sử dụng Bảng điều khiển dòng thời gian trong Chrome DevTools. 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 sẽ thu thập 10 MB rác và các lần 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:
Jakob và Yang, hai thám tử của V8, đã nhận vụ này. Quá trình trao đổi qua lại giữa Jakob và Yang từ nhóm V8 và nhóm Oz đã diễn ra trong thời gian dài. 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 tìm ra vấn đề.
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 đó, ứng dụng này rất nhạy cảm với các lần tạm dừng do quá trình dọn 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 dành 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 vấn đề gì về hiệu suất?
Chúng ta thấy có hiện tượng tạm dừng, hay còn gọi là rớt khung hình hoặc giật. Những lần tạm dừng này tương ứng với các lần chạy dọn rác.
Nhà phát triển có đang làm theo các phương pháp hay nhất không?
Có, các nhà phát triển Oz rất am hiểu về hiệu suất và kỹ thuật tối ưu hoá máy ảo JavaScript. Cần lưu ý rằng 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 do sự khác biệt giữa mã do các nhà phát triển Oz viết và mã do V8 sử dụng. Chrome DevTools 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 dọn 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 một đối tượng tồn tại đủ lâu, đố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ũ. Đây là thiết kế có chủ đích, vì việc thu thập thế hệ trẻ sẽ tốn ít tài nguyên hơn nhiều. Thường thì bạn có thể giả định rằng việc tạm dừng GC thường xuyên là do quá trình thu thập thế hệ trẻ.
Trong V8, không gian bộ nhớ trẻ đượ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 bất kỳ thời điểm nào và được gọi là không gian đích. Khi còn bộ nhớ trong không gian đích, việc phân bổ một đối tượng mới sẽ không tốn nhiều tài nguyên. Con trỏ trong không gian đích đượ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 không gian đích hết dung lượng. 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.
Tại thời điểm này, không gian nguồn và không gian đích được hoán đổi. Không gian "đến" trước đây và hiện là không gian "từ" sẽ đượ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êm thông tin, 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 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 hơn đến quá trình thu gom rác và tạm dừng ứng dụng.
Ứng dụng này có dự kiến tạo ra 10 MB dữ liệu rác mỗi giây không?
Nói ngắn gọn là không. Nhà phát triển không làm gì để tạo ra 10 MB/giây dữ liệu rác.
Nghi phạm
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ăng rồi thu hẹp danh sách.
Nghi phạm số 1
Gọi hàm khởi tạo mới trong khung hình. 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 một lần tạm dừng GC. Đặc biệt, các ứng dụng chạy ở tốc độ khung hình cao nên cố gắng không 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ế đối tượng được cân nhắc 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 hàm khởi tạo mới. Trên thực tế, nhóm Oz đã biết rõ yêu cầu này và nói rằng "Điều đó thật đáng xấu hổ". Xoá việc này khỏi danh sách.
Nghi phạm số 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, quá trình 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à mã 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 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 đó loại trừ nghi phạm số 2.
Nghi phạm số 3
Phép toán 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ổ trong các đố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 được tạo. Ba biến đầu tiên là a, b và c. Giá trị thứ 4 là giá trị ẩn danh (a * b) và giá trị thứ 5 là từ #4 * c; Giá trị thứ 5 cuối cùng đượ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ì chúng có thể là nguyên nhân gây ra rác. Vì các phép tính trong phân bổ chưa tối ưu hoá 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 gấp đôi 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 được 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 việc 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 có độ chính xác kép), bạn có thể tránh hoàn toàn vấn đề cụ thể này vì bộ nhớ cho số thực 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 có thể là thủ phạm.
Pháp y
Tại thời điểm này, các thám tử có hai nghi phạm: 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 xảy ra bên trong các hàm chưa được tối ưu hoá. Đã đến lúc đến phòng thí nghiệm và xác định dứt khoát nghi phạm nào có tội. LƯU Ý: Trong phần này, tôi sẽ sử dụng bản sao của vấn đề tìm thấy trong mã nguồn thực tế của Oz. Bản sao này nhỏ hơn nhiều so với mã gốc, do đó dễ hiểu hơn.
Thử nghiệm 1
Kiểm tra đối tượng tình nghi số 3 (phép tính 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 về những gì đang diễn ra trong hệ thống.
Bắt đầu khi Chrome hoàn toàn không chạy, hãy chạy Chrome bằng các cờ:
--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"
rồi thoát hoàn toàn khỏi Chrome sẽ tạo ra một 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 v8, bạn có thể xử lý nhật ký bằng bộ xử lý đánh dấu:
$ 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ý dấu kiểm 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 kiểm 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. Hãy 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 lệnh này 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:
Ngoài biểu đồ ở dưới cùng, dữ liệu được 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:
Hàng V8.Execute có đườ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 thu thập thế hệ mới. Tương tự như vậy đối 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". Thanh 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à có màu đỏ và 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ừ mã được tối ưu hoá sang mã chưa được tối ưu hoá, rồi quay lại mã được tối ưu hoá:
Lý tưởng nhất là đường này sẽ có màu xanh lục đậm, nhưng không bao giờ có ngay lập tức. Điều này có nghĩa là chương trình của bạn đã chuyển sang trạng thái ổn định và tối ưu. 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 đã thực hiện đế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 shell 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ụ tick-processor và plot-timer-event. Một tác dụng phụ khác của việc sử dụng d8 là giúp dễ dàng tách biệt vấn đề thực tế, giảm lượng dữ liệu không liên quan.
Khi xem biểu đồ sự kiện hẹn giờ từ mã nguồn Oz, chúng ta thấy quá trình chuyển đổi từ mã được tối ưu hoá sang mã chưa được tối ưu hoá. Trong khi thực thi mã chưa được tối ưu hoá, nhiều tập hợp thế hệ mới đã được kích hoạt, tương tự như ảnh chụp màn hình sau (lưu ý rằng thời gian đã bị xoá ở giữa):
Nếu nhìn kỹ, bạn có thể thấy rằng 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 tại cùng thời điểm đánh dấu hồ sơ như các tập hợp thế hệ mới (đường màu xanh dương). Điều này cho thấy rõ ràng rằng trong khi thu gom rác, tập lệnh sẽ bị tạm dừng.
Khi xem kết quả của trình xử lý đánh dấu 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 chưa được tối ưu hoá. Điều này cho thấy rõ ràng rằng nghi phạm số 3 là thủ phạm. Nguồn cho 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.
}
}
Vì hiểu rõ V8, 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à một ngày nào đó V8 có thể 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 hiểu rõ V8 như lòng bàn tay, nên làm cách nào để xác định lý do updateSprites không được tối ưu hoá?
Thử nghiệm số 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á. Khi tìm kiếm trong dữ liệu cho updateSprites, chúng ta thấy:
[disabled optimization for updateSprites, reason: ForInStatement is not fast case]
Đúng như các thám tử giả thuyết, cấu trúc vòng lặp for-i-in chính là nguyên nhân.
Yêu cầu đã xử lý xong
Sau khi phát hiện 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, 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 việc có ít đối tượng HeapNumber hơn, dẫn đến việc tạm dừng GC ít thường xuyên hơn. 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 kỹ sẽ nhận thấy rằng các số thực có độ chính xác gấp đôi vẫn được lưu trữ dưới dạng thuộc tính. Nếu việc lập hồ sơ cho thấy điều này là xứng đáng, thì việc thay đổi vị trí thành một mảng số thực có độ chính xác gấp đôi hoặc một mảng dữ liệu có kiểu dữ liệu sẽ giúp giảm thêm 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 chỉ dừng lại ở đó. Nhờ các công cụ và kỹ thuật mà các thám tử V8 chia sẻ, họ đã tìm thấy một vài hàm khác bị 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 cao hơn nữa.
Hãy bắt đầu giải quyết một số vấn đề về hiệu suất!