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

Nhà phát triển web đã mong đợi hiệu suất không bị ảnh hưởng nhiều khi gỡ lỗi mã. Tuy nhiên, kỳ vọng này không phải lúc nào cũng đúng. Nhà phát triển C++ sẽ không bao giờ mong đợi bản 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.

Việc 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 ta không bao giờ có thể giảm chi phí hiệu suất của DevTools xuống còn 0. Việc đặt điểm ngắt, từng bước thực thi 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 ảnh hưởng đến tốc độ thực thi ở mức độ khác nhau. Xét cho cùng, việc quan sát một điều gì đó sẽ làm thay đổi điều đó.

Nhưng tất nhiên, mức hao tổn của DevTools (giống như mọi trình gỡ lỗi khác) phải ở mức hợp lý. Gần đây, chúng tôi nhận thấy số lượng báo cáo tăng lên đáng kể trong một số trường hợp, DevTools 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ể thấy bảng 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ở DevTools.

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

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

Mặc dù chúng ta đã dự kiến sẽ thấy một số nội dung liên quan đến việc thu thập dấu vết ngăn xếp, nhưng chúng ta không ngờ rằng khoảng 90% thời gian thực thi tổng thể sẽ dành cho việc biểu tượng hoá các khung ngăn xếp. Việc 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à số cột trong tập lệnh – từ các 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 hết thời gian đều dành cho hàm JSStackFrame::GetMethodName() trong V8 – mặc dù chúng tôi đã biết từ các cuộc điều tra trước đó rằng JSStackFrame::GetMethodName() không còn xa lạ với các vấn đề về hiệu suất. Hàm này cố gắng tính toán tên của phương thức cho các khung được coi là lệnh gọi phương thức (khung đại diện cho lệnh gọi hàm ở dạng obj.func() thay vì func()). Một lượt xem nhanh vào mã cho thấy rằng mã này hoạt động bằng cách thực hiện một lượt truy cập đầy đủ đối tượng và chuỗi nguyên mẫu của đối tượng đó, đồ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. thuộc tính phương thức truy cập trong đó get hoặc set bằng với hàm đóng func.

Mặc dù bản thân điều này không phải là quá rẻ, nhưng cũng không giải thích được sự chậm chạp khủng khiếp này. Vì vậy, chúng tôi bắt đầu tìm hiểu ví dụ được báo cáo trong chromium:1069425 và nhận thấy 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 thông điệp nhật ký bắt nguồn từ classes.js – một tệp JavaScript 10 MiB. Khi xem xét kỹ hơn, chúng tôi nhận thấy đây về cơ bản là một môi trường thời gian chạy Java cộng với mã ứng dụng được biên dịch sang 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 tôi nghĩ rằng bạn nên tìm hiểu 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 là trình biên dịch Java sang JavaScript đã tạo một đối tượng duy nhất với 82.203 hàm – rõ ràng là điều này bắt đầu trở nên thú vị. Tiếp theo, chúng ta quay lại JSStackFrame::GetMethodName() của V8 để tìm hiểu xem có thể chọn một số quả chín trên cây ở đó hay không.

  1. Trước tiên, hàm này sẽ tra cứu "name" của hàm dưới dạng một thuộc tính trên đối tượng và nếu 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 trùng khớp, hàm sẽ quay lại phương thức tra cứu ngược bằng cách duyệt qua 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à việc 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 tra cứu thuộc tính chung cho mỗi tên, kiểm tra xem giá trị thuộc tính thu được có khớp với hàm đóng mà chúng ta đang tìm hay không.

Đó có vẻ là một việc khá dễ dàng, vì việc trích xuất tên đòi hỏi phải xem qua tất cả các thuộc tính. Thay vì thực hiện hai lượt – O(N) để trích xuất tên và O(N log(N)) để kiểm thử – chúng ta có thể thực hiện mọi việc trong một lượt và trực tiếp kiểm tra 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 còn thú vị hơn nữa. Mặc dù về mặt kỹ thuật, các hàm này là hàm ẩn danh, nhưng công cụ V8 vẫn ghi lại những gì chúng ta gọi là tên suy luận cho các hàm đó. Đối với hằng hàm xuất hiện ở bên phải của các chỉ định ở dạng obj.foo = function() {...}, trình phân tích cú pháp V8 sẽ ghi nhớ "obj.foo" dưới dạng tên suy luận cho hằng hàm. Vì vậy, trong trường hợp của chúng ta, mặc dù không có tên thích hợp để tra cứu, nhưng chúng ta có một tên gần giống: Đối với ví dụ A.SDV = function() {...} ở trên, chúng ta có "A.SDV" làm tên suy ra và chúng ta có thể lấy tên thuộc tính từ tên suy ra 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. Điều đó đã giải quyết được vấn đề trong hầu hết các trường hợp, thay thế một lượt truy cập toàn bộ tốn kém bằng một lượt tra cứu thuộc tính duy nhất. Hai điểm cải tiến này đã được đưa vào CL này và giúp giảm đáng kể tình trạng chậm trong ví dụ được báo cáo trong chromium:1069425.

Error.stack

Chúng ta có thể kết thúc ngày hôm nay tại đây. Nhưng có điều gì đó không ổn đang diễn ra, vì DevTools không bao giờ 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í không hiển thị 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 duy nhất chúng ta sử dụng (và hiển thị) tên phương thức là trong API dấu vết ngăn xếp JavaScript. Để hiểu cách sử dụng này, hãy xem xét ví dụ đơn giản sau error-methodname.js:

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. Khi chạy đoạn mã này trong Chromium, bạn sẽ nhận được 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 quá trình tra cứu tên phương thức đang diễn ra: Khung ngăn xếp trên cùng 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 bar. Vì vậy, thuộc tính error.stack không chuẩn sử dụng nhiều JSStackFrame::GetMethodName() và trên thực tế, các thử nghiệm hiệu suất của chúng tôi cũng cho thấy rằng những thay đổi này đã giúp mọi thứ nhanh hơn đáng kể.

Tăng tốc độ trên đ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, việc 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ố thông tin trước đây có thể giúp chúng ta: Theo truyền thống, V8 có hai cơ chế riêng biệt để thu thập và biểu thị dấu vết ngăn xếp cho hai API khác nhau được mô tả ở trên (API v8::StackFrame C++ 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ư) cùng một việc dễ xảy ra lỗi và thường dẫn đến sự không nhất quán và lỗi. Vì vậy, vào cuối năm 2018, chúng tôi đã bắt đầu một dự án để giải quyết một nút thắt cổ chai duy nhất cho việc ghi lại dấu vết ngăn xếp.

Dự án đó đã gặt hái được nhiều thành công 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 một cách lười biếng 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 cùng một thủ thuật cho các đối tượng v8::StackFrame. Tất cả thông tin về khung ngăn xếp được tính toán trong lần đầu tiên bất kỳ phương thức nào được gọi trên khung đó.

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

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

Tên hàm

Khi các hoạt động tái cấu trúc nêu trên đã hoàn tất, hao tổn của quá trình biểu tượng hoá (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 và chúng ta có thể thấy rõ hơn thời điểm V8 dành thời gian để (thu thập và) biểu tượng hoá các khung ngăn xếp để sử dụng trong DevTools.

Chi phí ký hiệu hoá

Điều đầu tiên nổi bật là chi phí tích luỹ để tính số dòng và số cột. Phần tốn kém ở đây thực sự là tính toán độ dời ký tự trong tập lệnh (dựa trên độ dời mã byte mà chúng ta nhận được từ V8). Và hóa ra do việc tái cấu trúc ở trên, chúng ta đã thực hiện việc đó hai lần, một lần khi tính số dòng và một lần khác khi tính số cột. Việc lưu vị trí nguồn vào bộ nhớ đệm trên các thực thể 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ơ.

Phát hiện thú vị hơn đối với chúng tôi là v8::StackFrame::GetFunctionName cao một cách đáng ngạc nhiên trong tất cả các hồ sơ mà chúng tôi xem xét. Khi tìm hiểu sâu hơn, chúng tôi nhận thấy việc tính toán tên hiển thị cho hàm trong khung ngăn xếp trong DevTools là không cần thiết,

  1. trước tiên hãy tìm thuộc tính "displayName" không chuẩn và nếu thuộc tính đó trả về một thuộc tính dữ liệu có giá trị chuỗi, 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" tiêu chuẩn và kiểm tra lại xem thuộc tính đó có trả về một thuộc tính dữ liệu có giá trị là 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 luận và lưu trữ trên giá trị cố định của hàm.

Thuộc tính "displayName" được thêm vào để khắc phục vấn đề cho thuộc tính "name" trên các thực thể Function chỉ có thể đọ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ụ dành cho nhà phát triển trình duyệt đã thêm suy luận tên hàm để thực hiện công việc trong 99,9% trường hợp. Ngoài ra, ES2015 đã giúp thuộc tính "name" trên các thực thể Function có thể định cấu hình, hoàn toàn không cần đến thuộc tính "displayName" đặc biệt. Vì việc tra cứu âm tính cho "displayName" khá tốn kém và không thực sự cần thiết (ES2015 được phát hành cách đây hơn 5 năm), nên chúng tôi quyết định xoá tính năng hỗ trợ cho thuộc tính fn.displayName không chuẩn khỏi V8 (và DevTools).

Khi không cần tìm nạp phủ định "displayName" nữa, một nửa chi phí của v8::StackFrame::GetFunctionName đã bị xoá. Nửa còn lại sẽ chuyển đến mục tra cứu thuộc tính "name" chung. May mắn là chúng tôi đã có một số logic để tránh các lượt tra cứu tốn kém của thuộc tính "name" trên các thực thể Function (chưa được chạm vào). Chúng tôi đã giới thiệu logic này trong 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 để có thể bỏ qua việc tra cứu chung tốn kém ngay từ đầu, nhờ đó v8::StackFrame::GetFunctionName không xuất hiện 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ể mức hao tổn của DevTools về dấu vết ngăn xếp.

Chúng tôi biết vẫn còn nhiều điểm có thể cải thiện, chẳng hạn như hao tổn khi sử dụng MutationObserver vẫn đáng kể, 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 đề chính và có thể sẽ quay lại trong tương lai để đơn giản hoá 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. Các kênh xem trước này cho phép bạn sử dụng các tính năng mới nhất của DevTools, kiểm thử các API nền tảng web tiên tiến và giúp bạn tìm thấy vấn đề trên trang web của mình trước khi người dùng phát hiện ra!

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, bản cập nhật mới hoặc bất kỳ nội dung nào khác liên quan đến Công cụ cho nhà phát triển.