Nếu bạn quan tâm tìm hiểu thêm về các cải tiến hiệu suất trong phiên bản mới nhất của ReSharper, hãy xem bài viết này trên blog JetBrains.
18 tháng 9, 2025
Nếu bạn theo dõi tôi trên mạng xã hội, có lẽ bạn biết rằng tôi vừa chuyển sang làm việc tại JetBrains, nơi tôi tập trung vào việc cải thiện hiệu suất của ReSharper. Vì không thể sửa mọi thứ cùng một lúc, chúng tôi quyết định ưu tiên hai lĩnh vực dựa trên phản hồi người dùng: độ trễ khi gõ phím/khả năng phản hồi và thời gian khởi động. Mục tiêu là làm cho các tính năng được sử dụng nhiều nhất của ReSharper (như điều hướng) có sẵn càng sớm càng tốt, trong khi tải các tính năng khác trong nền. Nhưng để điều này có ý nghĩa, chúng tôi phải đảm bảo rằng người dùng có thể sử dụng Visual Studio với sự can thiệp tối thiểu từ quá trình tải nền. Do đó, cần phải bắt đầu đo lường và định lượng khả năng phản hồi giao diện người dùng.
Để làm điều này, tôi đã xây dựng một công cụ liên tục đo lường thời gian mà luồng chính xử lý các đầu vào đang chờ. Tôi muốn thứ gì đó rất trực quan, để tôi có thể sử dụng Visual Studio bình thường trong khi nhận phản hồi tức thì về mức độ phản hồi của IDE. Tôi đạt được điều này bằng cách thêm một lớp phủ, lấy cảm hứng trực tiếp từ các bộ đếm FPS được sử dụng để đánh giá hiệu suất trò chơi.
Mục lục
Profiler giao diện người dùng
Vì tôi sẽ làm mọi cách để tránh viết C++, tôi đã xây dựng thư viện .NET để viết profiler bằng C#, có tên là Silhouette. Tôi đã viết về nó, nên tôi sẽ không đi sâu vào chi tiết.
Tôi bắt đầu bằng cách tạo một solution với hai dự án: một dự án C# ClassLibrary với NativeAOT cho profiler và một ứng dụng WPF cho lớp phủ. Trong profiler, tôi đã thêm gói Silhouette nuget, sau đó tôi khai báo một lớp DllMain xuất hiện hàm DllGetClassObject được .NET gọi khi tải profiler.
internal class DllMain
{
private static ClassFactory? _instance;
[UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
public static unsafe HResult DllGetClassObject(Guid* rclsid, Guid* riid, nint* ppv)
{
if (!string.Equals(Process.GetCurrentProcess().ProcessName, "devenv", StringComparison.OrdinalIgnoreCase))
{
return unchecked((int)0x80131375); /* CORPROF_E_PROFILER_CANCEL_ACTIVATION */
}
if (*rclsid != new Guid("0A96F866-D763-4099-8E4E-ED1801BE9FBD"))
{
Logger.Log($"DllGetClassObject: Invalid CLSID {*rclsid}");
return HResult.E_NOINTERFACE;
}
// Disable profiling for any child processes
Environment.SetEnvironmentVariable("COR_ENABLE_PROFILING", "0");
_instance = new ClassFactory(new CorProfilerCallback());
*ppv = _instance.IClassFactory;
Logger.Log("Profiler initialized");
return HResult.S_OK;
}
}
Câu hỏi tiếp theo là: làm thế nào để đo lường hoạt động của luồng giao diện người dùng? Cách tiếp cận đầu tiên của tôi là sử dụng API SendMessage, gửi đồng bộ một thông điệp đến cửa sổ và chờ nó được xử lý. Tôi chỉ cần đo thời gian cần thiết cho lệnh gọi hoàn tất. Ban đầu nó có vẻ hoạt động, nhưng sau khi kiểm tra kỹ hơn, tôi phát hiện ra rằng nó không phát hiện được nhiều lần đóng băng. Lý do là các thông điệp cửa sổ thực sự có độ ưu tiên, và SendMessage được thực thi với độ ưu tiên cao nhất. Hãy tưởng tượng cửa sổ đang nhận một luồng thông điệp liên tục được đăng bằng SendMessage: những thông điệp đó sẽ được xử lý khi chúng đến, vì vậy có vẻ như luồng giao diện người dùng đang phản hồi. Tuy nhiên, các thông điệp đầu vào ưu tiên thấp sẽ ở lại vô thời hạn ở cuối hàng đợi, bị chiếm quyền mãi mãi bởi một SendMessage mới, và do đó giao diện người dùng thực sự không phản hồi đầu vào của người dùng.
Sau đó, tôi thử nghiệm với bộ đếm thời gian, sử dụng SetTimer. Giải pháp này có vấn đề ngược lại: bộ đếm thời gian chạy với độ ưu tiên thấp nhất, thậm chí thấp hơn cả thông điệp đầu vào. Vì vậy, khi làm tràn hàng đợi thông điệp với các thông điệp đầu vào, chẳng hạn bằng cách di chuyển chuột xung quanh, các thông điệp bộ đếm thời gian sẽ bị trì hoãn và profiler sẽ báo cáo rằng giao diện người dùng không phản hồi, mặc dù không phải vậy.
Cuối cùng, để có kết quả chính xác, cách tốt nhất là bằng cách nào đó được thông báo khi các thông điệp đầu vào thực sự được xử lý. May mắn thay có một cách: API SetWindowsHookEx cho phép móc vào các giai đoạn khác nhau của vòng lặp thông điệp, và đặc biệt từ khóa WH_MOUSE thông báo khi các thông điệp chuột được lấy ra khỏi hàng đợi. Vẫn còn một vấn đề: người dùng sẽ không liên tục di chuyển chuột, nhưng tôi cần một luồng đầu vào không bị gián đoạn để phát hiện những khoảnh khắc giao diện người dùng trở nên không phản hồi. Giải pháp ở đây đơn giản là gọi SendInput trong một vòng lặp để mô phỏng hoạt động của chuột.
Triển khai cuối cùng sử dụng ba luồng: một để mô phỏng đầu vào chuột, một để đo độ trễ giữa mỗi đầu vào và một để chuyển tiếp thông tin đến quy trình lớp phủ bằng cách sử dụng named pipes.
private void InputThread()
{
int mouseDelta = 2;
try
{
var inputs = new NativeMethods.INPUT[1];
while (true)
{
Thread.Sleep(10);
inputs[0] = new()
{
mi = new()
{
dx = mouseDelta,
dy = 0,
dwFlags = 0x1 /* MOUSEEVENTF_MOVE */
},
type = 0x0 /* INPUT_MOUSE */
};
mouseDelta = -mouseDelta;
var res = NativeMethods.SendInput(1, inputs, Marshal.SizeOf<NativeMethods.INPUT>());
if (res != 1)
{
var error = Marshal.GetLastWin32Error();
Logger.Log($"SendInput returned {res} - {error:x2}");
}
}
}
catch (Exception ex)
{
Logger.Log($"InputThread failed: {ex}");
}
}
Để móc vòng lặp thông điệp, tôi gọi SetWindowsHookEx, cung cấp cho nó một callback được quản lý. Trong callback, tôi đặt một mutex sẽ được theo dõi bởi một luồng khác:
private readonly ManualResetEventSlim _responsiveMutex = new(false);
private void SetHook(int threadId)
{
NativeMethods.SetWindowsHookEx(NativeMethods.HookType.WH_MOUSE, HookProc, 0, threadId);
IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam)
{
if (code >= 0)
{
_responsiveMutex.Set();
}
return NativeMethods.CallNextHookEx(0, code, wParam, lParam);
}
}
Lớp phủ
Vai trò của lớp phủ là lắng nghe thông tin về khả năng phản hồi giao diện người dùng từ named pipe và hiển thị nó trên cửa sổ Visual Studio. Đó là một ứng dụng WPF nhỏ với một vài điểm tinh tế. Nó sử dụng LiveChartsCore để hiển thị biểu đồ.
Điểm tinh tế đầu tiên là cách định vị cửa sổ Visual Studio để hiển thị lớp phủ trên đó. Nó nhận pid Visual Studio làm đối số khi khởi động, sau đó nó lặp qua tất cả các cửa sổ của quy trình đó cho đến khi tìm thấy cửa sổ có chú thích “Visual Studio Preview” (để bỏ qua màn hình splash).
private IntPtr FindVsWindow(int pid)
{
while (true)
{
var mainWindow = FindWindowWithCaption((uint)pid, "Visual Studio Preview");
if (mainWindow != IntPtr.Zero)
{
return mainWindow;
}
// The main window wasn't found, try again in a while
Thread.Sleep(100);
}
}
private static IntPtr FindWindowWithCaption(uint processId, string windowCaption)
{
var foundWindow = IntPtr.Zero;
// Enumerate all top-level windows
NativeMethods.EnumWindows(EnumWindowsCallback, IntPtr.Zero);
return foundWindow;
bool EnumWindowsCallback(IntPtr hWnd, IntPtr lParam)
{
// Filter by process ID
NativeMethods.GetWindowThreadProcessId(hWnd, out uint windowProcessId);
if (windowProcessId != processId)
{
return true; // Skip this window (continue enumeration)
}
// Get the window's title/caption
var windowTitle = new StringBuilder(256);
NativeMethods.GetWindowText(hWnd, windowTitle, 256);
// Check if the window is visible and matches the desired caption
if (NativeMethods.IsWindowVisible(hWnd) && windowTitle.ToString().Contains(windowCaption, StringComparison.OrdinalIgnoreCase))
{
foundWindow = hWnd; // We found the window
return false; // Stop enumeration
}
return true; // Continue enumeration
}
}
Điểm tinh tế khác là đặt kiểu WS_EX_TRANSPARENT trên cửa sổ. Nó cho phép hiển thị lớp phủ trên cửa sổ Visual Studio mà không đánh cắp đầu vào của nó. Theo như tôi biết, WPF không có API để đặt nó, vì vậy tôi phải sử dụng một số interop:
private static OverlayWindow LoadOverlay()
{
var overlay = new OverlayWindow
{
ShowInTaskbar = false,
ShowActivated = false,
Topmost = true
};
overlay.SourceInitialized += (s, e) =>
{
var handle = new WindowInteropHelper(overlay).Handle;
var extendedStyle = NativeMethods.GetWindowLong(handle, NativeMethods.GWL_EXSTYLE);
// Add WS_EX_TRANSPARENT style to allow clicks to pass through
_ = NativeMethods.SetWindowLong(
handle,
NativeMethods.GWL_EXSTYLE,
extendedStyle | NativeMethods.WS_EX_TRANSPARENT);
};
return overlay;
}
Tổng kết
Đây là một dự án rất giáo dục, hóa ra lại rất hữu ích. Đo lường là một phần quan trọng của bất kỳ công việc tối ưu hóa hiệu suất nào và khả năng hình dung khả năng phản hồi của giao diện người dùng (thay vì chỉ “cảm nhận” nó) thực sự giúp định hướng nỗ lực. Có nhiều điểm đau cần khắc phục trong công cụ này (lớp phủ không theo cửa sổ VS nếu di chuyển hoặc thay đổi kích thước, cũng như nó nhầm tưởng rằng giao diện người dùng không phản hồi nếu con trỏ chuột không ở trên cùng cửa sổ), nhưng tôi đã sử dụng nó gần như hàng ngày để đánh giá tác động của các tối ưu hóa của mình và tìm ra các lĩnh vực mới cần cải thiện.
Tôi cũng rất hài lòng với cách Silhouette đơn giản hóa việc sử dụng API .NET profiling. Phiên bản đã xuất bản của profiler giao diện người dùng chỉ sử dụng API profiling để tiêm vào quy trình mục tiêu, nhưng các phiên bản trước đó cũng lấy mẫu luồng giao diện người dùng để tìm hiểu những gì đang chạy trong thời gian đóng băng. Tôi có thể sẽ đưa tính năng này trở lại vào một thời điểm nào đó.
Được gắn thẻ trong: dotnet, nativeaot, profiler, performance



