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 thấy 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 luôn trôi qua ở đâu và điều gì gây ra tình trạng giảm tốc độ rất lớn khi Công cụ cho nhà phát triển 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 của Chrome

Mặc dù chúng ta đã kỳ vọng sẽ thấy một điều gì đó 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% tổng thời gian thực thi sẽ dành cho việc biểu tượng hoá các khung ngăn xếp. Quá trình 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à thực tế hầu như mọi lúc đều tập trung vào hàm JSStackFrame::GetMethodName() trong V8 – mặc dù chúng ta biết từ các cuộc điều tra trước đó rằng JSStackFrame::GetMethodName() không còn xa lạ trong lĩnh vực 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 (các khung biểu thị lệnh gọi hàm có dạng obj.func() thay vì func()). Việc xem nhanh mã này cho thấy mã hoạt động bằng cách thực hiện truyền tải toàn bộ đố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.

Hiện tại, mặc dù bản thân nó nghe có vẻ không quá rẻ, nhưng nghe có vẻ không giải thích được cho hiện tượng giảm tốc 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 ta nghĩ có thể đáng để tìm hiểu loại đối tượng mà mình đ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, thì 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à 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à mỗi đố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.

Việc này có vẻ dễ dàng vì việc trích xuất tên yêu cầu bạn phải đi 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 đến 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 giá trị cố định của hàm xuất hiện ở bên phải của các mục chỉ định có dạng obj.foo = function() {...}, trình phân tích cú pháp V8 ghi nhớ "obj.foo"tên dự đoán cho giá trị cố định của 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

Đáng lẽ chúng tôi có thể gọi cho bạn một ngày ở đâ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 rất nhiều JSStackFrame::GetMethodName() và trên thực tế, các phép kiểm thử hiệu suất cũng cho thấy các thay đổi của chúng tôi đã làm cho 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 giúp chúng ta: Thường thì V8 có 2 cơ chế riêng biệt để thu thập và biểu thị dấu vết ngăn xếp cho 2 API khác nhau đượ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ư) 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 đều được tính toán vào lần đầu tiên phương thức bất kỳ được gọi trên khung này.

Đ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ể là vì chúng tôi đã giới thiệu một lớp v8::internal::StackFrameInfo mới chứa tất cả thông tin về khung ngăn xếp được hiển thị qua v8::StackFrame hoặc qua error.stack, nên chúng tôi luôn tính toán tập siêu của 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 biệt là đối với 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 có 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 đị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 tăng đáng kể hiệu suất 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 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ạng bù trừ dòng và cột) và mở ra cánh cửa để 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

Điều nổi bật đầu tiên là chi phí tích luỹ để tính toán 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ơ 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 mà chúng tôi 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 đó tạo ra một 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" 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 như một giải pháp 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 trên diện rộng, vì các công cụ dành cho nhà phát triển của trình duyệt đã thêm suy luận tên hàm để thực hiện công việc trong 99, 9% các 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 cải tiến ở trên, chúng tôi đã giảm đáng kể mức hao tổn của Công cụ cho nhà phát triển về mặt dấu vết ngăn xếp.

Chúng tôi biết rằng 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 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.