Cách chúng tôi tăng tốc dấu vết ngăn xếp Công cụ của Chrome cho nhà phát triển lên 10 lần

Benedikt Meurer
Benedikt Meurer

Các nhà phát triển web kỳ vọng rằng hiệu suất sẽ không bị ảnh hưởng nhiều hoặc thậm chí không nhiều khi gỡ lỗi mã của họ. Tuy nhiên, kỳ vọng này không hẳn là phổ biến. Nhà phát triển C++ sẽ không bao giờ mong đợi một bản dựng gỡ lỗi của ứng dụng đạt được hiệu suất sản xuất. Và trong những năm đầu của Chrome, việc chỉ cần mở Công cụ cho nhà phát triển đã ảnh hưởng đáng kể đến hiệu suất của trang.

Trên thực tế, chúng tôi không còn cảm nhận được sự suy giảm hiệu suất này là kết quả của nhiều năm đầu tư vào khả năng gỡ lỗi của DevToolsV8. Tuy nhiên, chúng tôi sẽ không bao giờ có thể giảm mức hao tổn hiệu suất của Công cụ cho nhà phát triển xuống bằng 0. Việc đặt điểm ngắt, duyệt qua mã, thu thập dấu vết ngăn xếp, ghi lại dấu vết hiệu suất, v.v. đều tác động đến tốc độ thực thi ở một mức độ khác nhau. Suy cho cùng thì việc quan sát điều gì đó sẽ làm thay đổi nó.

Nhưng tất nhiên, chi phí của Công cụ cho nhà phát triển (như mọi trình gỡ lỗi) phải hợp lý. Gần đây, chúng tôi nhận thấy số lượng báo cáo gia tăng đáng kể, trong đó trong một số trường hợp nhất định, Công cụ cho nhà phát triển sẽ làm chậm ứng dụng đến mức không thể sử dụng được nữa. Dưới đây, bạn có thể xem phần so sánh song song từ báo cáo chromium:1069425, minh hoạ mức hao tổn hiệu suất khi chỉ mở Công cụ cho nhà phát triển.

Như bạn có thể thấy từ video, tốc độ giảm tốc độ theo thứ tự 5-10 lần, điều này rõ ràng là không được chấp nhận. Bước đầu tiên là tìm hiểu thời gian trôi qua và nguyên nhân gây ra tình trạng chậm lại nghiêm trọng này khi Công cụ cho nhà phát triển đang mở. Việc sử dụng Linux perf trong quá trình Trình kết xuất Chrome đã cho thấy sự phân bổ tổng thời gian thực thi trình kết xuất sau đây:

Thời gian thực thi Trình kết xuất Chrome

Mặc dù có phần nào đó chúng tôi dự kiến sẽ thấy điều gì đó liên quan đến việc thu thập dấu vết ngăn xếp, nhưng chúng tôi không nghĩ rằng khoảng 90% tổng thời gian thực thi chuyển sang biểu tượng cho khung ngăn xếp. Biểu tượng hoá ở đây đề cập đến hành động phân giải tên hàm và vị trí nguồn cụ thể – số dòng và cột trong tập lệnh – từ khung ngăn xếp thô.

Suy luận tên phương thức

Điều đáng ngạc nhiên hơn nữa là hầu như mọi lúc đều chuyển đến hàm JSStackFrame::GetMethodName() trong V8 – mặc dù từ các cuộc điều tra trước đó, chúng tôi đã biết rằng JSStackFrame::GetMethodName() không còn lạ lẫm trong các vấn đề về hiệu suất. Hàm này cố gắng tính toán tên phương thức cho các khung được coi là lệnh gọi phương thức (các khung đại diện cho lệnh gọi hàm dưới dạng obj.func() thay vì func()). Việc xem nhanh mã sẽ cho thấy mã hoạt động bằng cách truyền tải đầy đủ đối tượng và chuỗi nguyên mẫu của nó, đồng thời tìm kiếm

  1. các thuộc tính dữ liệu có value là phần đóng func, hoặc
  2. các thuộc tính truy cập có get hoặc set tương đương với trạng thái đóng func.

Mặc dù bản thân việc này nghe có vẻ không đặc biệt rẻ, nhưng cũng có vẻ như nó không giải thích được cho sự chậm lại đáng kinh ngạc này. Vì vậy, chúng tôi bắt đầu đi sâu vào ví dụ được báo cáo trong chromium:1069425 và nhận thấy rằng dấu vết ngăn xếp đã được thu thập cho các tác vụ không đồng bộ cũng như cho các thông điệp nhật ký bắt nguồn từ classes.js – tệp JavaScript 10MiB. Khi xem xét kỹ hơn, chúng ta thấy rằng về cơ bản, đây là một môi trường thời gian chạy Java cộng với mã xử lý ứng dụng được biên dịch thành JavaScript. Dấu vết ngăn xếp chứa một số khung có các phương thức được gọi trên đối tượng A, vì vậy, chúng ta nên tìm hiểu về loại đối tượng mà chúng ta đang xử lý.

dấu vết ngăn xếp của một đối tượng

Rõ ràng trình biên dịch Java sang JavaScript đã tạo một đối tượng duy nhất chứa 82.203 hàm khổng lồ – điều này rõ ràng đã bắt đầu trở nên thú vị. Tiếp theo, chúng ta quay lại JSStackFrame::GetMethodName() của V8 để xem liệu có thể chọn quả nào bị treo ở đó hay không.

  1. Trước tiên, hàm này hoạt động bằng cách tra cứu "name" của hàm dưới dạng thuộc tính trên đối tượng. Nếu thuộc tính này được tìm thấy, hãy kiểm tra để đảm bảo giá trị thuộc tính khớp với hàm.
  2. Nếu hàm không có tên hoặc đối tượng không có thuộc tính phù hợp, thì hàm sẽ quay lại tra cứu ngược bằng cách truyền tải tất cả thuộc tính của đối tượng và nguyên mẫu của đối tượng.

Trong ví dụ của chúng ta, tất cả các hàm đều ẩn danh và có thuộc tính "name" trống.

A.SDV = function() {
   // ...
};

Phát hiện đầu tiên là quá trình tra cứu ngược được chia thành hai bước (được thực hiện cho chính đối tượng và từng đối tượng trong chuỗi nguyên mẫu):

  1. Trích xuất tên của tất cả các thuộc tính có thể liệt kê và
  2. Thực hiện thao tác tra cứu thuộc tính chung cho từng tên, kiểm tra xem giá trị thuộc tính thu được có khớp với trạng thái đóng mà chúng ta đang tìm kiếm hay không.

Đây có vẻ là một kết quả khá thấp, vì việc trích xuất tên đòi hỏi phải đi qua tất cả các thuộc tính. Thay vì thực hiện hai lần truyền - O(N) để trích xuất tên và O(N log(N)) cho các kiểm thử - chúng tôi có thể làm mọi thứ trong một lần và kiểm tra trực tiếp các giá trị thuộc tính. Điều đó giúp toàn bộ hàm nhanh hơn khoảng 2-10 lần.

Phát hiện thứ hai thậm chí còn thú vị hơn. Mặc dù về mặt kỹ thuật thì các hàm này là những hàm ẩn danh, nhưng công cụ V8 vẫn đã ghi lại cái mà chúng tôi gọi là tên được phỏng đoán. Đối với giá trị cố định hàm xuất hiện ở bên phải phần chỉ định dưới dạng obj.foo = function() {...}, trình phân tích cú pháp V8 ghi nhớ "obj.foo"tên được suy luận cho giá trị cố định hàm. Vì vậy, trong trường hợp này, điều đó có nghĩa là mặc dù chúng ta không có tên thích hợp để tra cứu, nhưng chúng ta đã có một cái gì đó đủ gần: Trong ví dụ A.SDV = function() {...} ở trên, chúng ta có "A.SDV" dưới dạng tên suy luận và chúng ta có thể lấy tên thuộc tính từ tên được suy luận bằng cách tìm dấu chấm cuối cùng, sau đó tìm thuộc tính "SDV" trên đối tượng. Trong hầu hết mọi trường hợp, thuật toán này đã thay thế một hoạt động truyền tải toàn bộ tốn kém bằng một thao tác tra cứu thuộc tính duy nhất. Hai điểm cải tiến này đều nằm trong CL này và giảm đáng kể tình trạng chậm lại ở ví dụ được báo cáo trong chromium:1069425.

Error.stack

Lẽ ra chúng tôi đã có thể dừng lại ở đây. Tuy nhiên, có một điều đáng ngờ xảy ra, vì Công cụ cho nhà phát triển chưa từng sử dụng tên phương thức cho các khung ngăn xếp. Trên thực tế, lớp v8::StackFrame trong API C++ thậm chí còn không cho thấy cách để truy cập vào tên phương thức. Vì vậy, có vẻ như chúng ta sẽ gọi JSStackFrame::GetMethodName() ngay từ đầu. Thay vào đó, nơi chúng tôi sử dụng (và hiển thị) tên phương thức duy nhất là trong API theo dõi ngăn xếp JavaScript. Để hiểu rõ cách sử dụng này, hãy xem xét error-methodname.js ví dụ đơn giản sau:

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

Ở đây, chúng ta có một hàm foo được cài đặt dưới tên "bar" trên object. Chạy đoạn mã này trong Chromium mang lại kết quả sau:

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

Ở đây, chúng ta thấy hoạt động tra cứu tên phương thức: Khung ngăn xếp trên cùng được hiển thị để gọi hàm foo trên một thực thể của Object thông qua phương thức có tên là bar. Vì vậy, thuộc tính error.stack không chuẩn sử dụng rất nhiều JSStackFrame::GetMethodName() và trên thực tế, các bài kiểm thử hiệu suất cũng cho thấy rằng những thay đổi của chúng ta giúp mọi thứ nhanh hơn đáng kể.

Tăng tốc trên các phép đo điểm chuẩn vi mô StackTrace

Nhưng quay lại chủ đề về Công cụ của Chrome cho nhà phát triển, thực tế là tên phương thức được tính toán mặc dù error.stack không được sử dụng có vẻ không đúng. Có một số lịch sử đã chứng minh được ở đây: Theo truyền thống, V8 có hai cơ chế riêng biệt để thu thập và thể hiện dấu vết ngăn xếp cho hai API được mô tả ở trên (API C++ v8::StackFrame và API dấu vết ngăn xếp JavaScript). Việc có hai cách khác nhau để thực hiện (gần như) giống nhau dễ xảy ra lỗi và thường dẫn đến những điểm không thống nhất và lỗi, vì vậy, vào cuối năm 2018, chúng tôi đã khởi động một dự án để giải quyết một điểm tắc nghẽn duy nhất về việc ghi lại dấu vết ngăn xếp.

Dự án đó đã thành công rực rỡ và đã giảm đáng kể số lượng vấn đề liên quan đến việc thu thập dấu vết ngăn xếp. Hầu hết thông tin được cung cấp thông qua thuộc tính error.stack không chuẩn cũng được tính toán từng phần và chỉ khi thực sự cần thiết, nhưng trong quá trình tái cấu trúc, chúng tôi đã áp dụng thủ thuật tương tự cho các đối tượng v8::StackFrame. Tất cả thông tin về khung ngăn xếp đều được tính toán trong lần đầu tiên gọi bất kỳ phương thức nào trên khung đó.

Cách này thường giúp cải thiện hiệu suất, nhưng tiếc là hoá ra lại có phần trái ngược với cách các đối tượng API C++ này được sử dụng trong Chromium và Công cụ cho nhà phát triển. Cụ thể, kể từ khi chúng tôi ra mắt một lớp v8::internal::StackFrameInfo mới, lớp này chứa tất cả thông tin về khung ngăn xếp được hiển thị thông qua v8::StackFrame hoặc thông qua error.stack, chúng tôi sẽ luôn tính toán siêu tập hợp thông tin do cả hai API cung cấp, có nghĩa là đối với việc sử dụng v8::StackFrame (và cụ thể là cho Công cụ cho nhà phát triển), chúng tôi cũng sẽ tính toán tên phương thức, ngay khi bất kỳ thông tin nào về khung ngăn xếp được yêu cầu. Hoá ra Công cụ cho nhà phát triển luôn yêu cầu thông tin nguồn và tập lệnh ngay lập tức.

Dựa trên nhận thức đó, chúng tôi đã có thể tái cấu trúc và đơn giản hoá đáng kể việc biểu diễn khung ngăn xếp và làm cho nó trở nên lười biếng hơn nữa. Nhờ đó, việc sử dụng toàn bộ V8 và Chromium giờ đây chỉ phải trả chi phí cho việc tính toán thông tin mà họ yêu cầu. Điều này đã giúp tăng hiệu suất đáng kể cho Công cụ cho nhà phát triển và các trường hợp sử dụng Chromium khác, vốn chỉ cần một phần thông tin về khung ngăn xếp (về cơ bản chỉ cần tên tập lệnh và vị trí nguồn dưới dạng độ lệch dòng và cột) và mở ra nhiều cơ hội cải thiện hiệu suất hơn.

Tên hàm

Nhờ việc tái cấu trúc nêu trên, chi phí sử dụng biểu tượng (thời gian dành cho v8_inspector::V8Debugger::symbolize) đã giảm xuống còn khoảng 15% tổng thời gian thực thi. Chúng ta có thể thấy rõ hơn nơi V8 dành thời gian (thu thập và) biểu tượng các khung ngăn xếp để tiêu thụ trong Công cụ cho nhà phát triển.

Chi phí thay thế bằng biểu tượng

Điểm nổi bật đầu tiên là chi phí tích luỹ cho số dòng và số cột tính toán. Phần đắt đỏ ở đây thực sự là tính toán độ lệch ký tự trong tập lệnh (dựa trên độ lệch mã byte mà chúng tôi nhận được từ V8) và hoá ra là do việc tái cấu trúc ở trên, chúng tôi đã làm điều đó hai lần, một lần khi tính số dòng và lần khác khi tính số cột. Việc lưu vào bộ nhớ đệm vị trí nguồn trên các bản sao v8::internal::StackFrameInfo đã giúp nhanh chóng giải quyết vấn đề này và loại bỏ hoàn toàn v8::internal::StackFrameInfo::GetColumnNumber khỏi mọi hồ sơ.

Điều thú vị hơn đối với chúng tôi là v8::StackFrame::GetFunctionName có số lượng hồ sơ cao một cách đáng kinh ngạc trong tất cả hồ sơ mà chúng tôi xem xét. Tìm hiểu sâu hơn ở đây, chúng tôi nhận ra rằng việc tính toán tên mà chúng tôi sẽ hiển thị cho hàm trong khung ngăn xếp trong Công cụ cho nhà phát triển là rất tốn kém,

  1. tìm thuộc tính "displayName" không chuẩn và nếu thuộc tính đó trả về thuộc tính dữ liệu có giá trị chuỗi, thì chúng ta sẽ sử dụng thuộc tính đó,
  2. nếu không, hãy quay lại tìm thuộc tính "name" chuẩn và kiểm tra lại xem điều đó có mang lại thuộc tính dữ liệu có giá trị là một chuỗi hay không,
  3. và cuối cùng quay lại tên gỡ lỗi nội bộ do trình phân tích cú pháp V8 suy ra và lưu trữ trên giá trị cố định hàm.

Thuộc tính "displayName" được thêm vào như một giải pháp thay thế cho thuộc tính "name" trên các thực thể Function ở chế độ chỉ đọc và không thể định cấu hình trong JavaScript, nhưng chưa bao giờ được chuẩn hoá và không được sử dụng rộng rãi, vì các công cụ của nhà phát triển trình duyệt đã thêm chức năng dự đoán tên hàm, thực hiện công việc này trong 99,9% trường hợp. Ngoài ra, ES2015 còn giúp thuộc tính "name" trên các thực thể Function có thể định cấu hình, giúp loại bỏ hoàn toàn nhu cầu về thuộc tính "displayName" đặc biệt. Vì việc tra cứu phủ định cho "displayName" khá tốn kém và không thực sự cần thiết (ES2015 đã được phát hành hơn 5 năm trước), chúng tôi quyết định ngừng hỗ trợ thuộc tính fn.displayName không chuẩn khỏi phiên bản V8 (và Công cụ cho nhà phát triển).

Với tính năng tra cứu phủ định của "displayName", một nửa chi phí của v8::StackFrame::GetFunctionName đã bị xoá. Nửa còn lại sẽ dùng để tra cứu thuộc tính "name" chung. May mắn là chúng tôi đã có sẵn một số logic để tránh việc tra cứu tốn kém thuộc tính "name" trên các thực thể Function (chưa bị ảnh hưởng) mà chúng tôi đã giới thiệu trong phiên bản V8 cách đây không lâu để giúp Function.prototype.bind() nhanh hơn. Chúng tôi đã chuyển các bước kiểm tra cần thiết, nhờ đó, chúng tôi có thể bỏ qua quá trình tra cứu chung tốn kém ngay từ đầu, và kết quả là v8::StackFrame::GetFunctionName không hiển thị trong bất kỳ hồ sơ nào mà chúng tôi xem xét nữa.

Kết luận

Với những điểm cải tiến nêu trên, chúng tôi đã giảm đáng kể chi phí của Công cụ cho nhà phát triển xét về dấu vết ngăn xếp.

Chúng tôi biết vẫn còn nhiều điểm cải tiến có thể xảy ra – ví dụ như mức hao tổn khi sử dụng MutationObserver vẫn đáng chú ý, như báo cáo trong chromium:1077657 – nhưng hiện tại, chúng tôi đã giải quyết các vấn đề lớn và có thể sẽ quay lại trong tương lai để tinh giản hơn nữa hiệu suất gỡ lỗi.

Tải các kênh xem trước xuống

Hãy cân nhắc sử dụng Chrome Canary, Dev hoặc Beta làm trình duyệt phát triển mặc định. Những kênh xem trước này cung cấp cho bạn quyền truy cập vào các tính năng mới nhất của Công cụ cho nhà phát triển, kiểm thử API nền tảng web tiên tiến và tìm ra các sự cố trên trang web của bạn trước khi người dùng làm việc đó!

Liên hệ với nhóm Công cụ của Chrome cho nhà phát triển

Hãy sử dụng các lựa chọn sau để thảo luận về các tính năng mới và thay đổi trong bài đăng hoặc bất kỳ vấn đề nào khác liên quan đến Công cụ cho nhà phát triển.

  • Gửi đề xuất hoặc phản hồi cho chúng tôi qua crbug.com.
  • Báo cáo sự cố Công cụ cho nhà phát triển bằng cách sử dụng mục Tuỳ chọn khác   Thêm   > Trợ giúp > Báo cáo sự cố Công cụ cho nhà phát triển trong Công cụ cho nhà phát triển.
  • Tweet tại @ChromeDevTools.
  • Để lại nhận xét về Video trên YouTube của chúng tôi về Tính năng mới trong Video trên YouTube của Công cụ cho nhà phát triển hoặc mẹo Công cụ cho nhà phát triển.