Repository navigation
perf: warm up plugins at startup and log startup timings #2041
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| // Copyright © 2017-2026 QL-Win Contributors | ||
| // | ||
| // This file is part of QuickLook program. | ||
| // | ||
| // This program is free software: you can redistribute it and/or modify | ||
| // it under the terms of the GNU General Public License as published by | ||
| // the Free Software Foundation, either version 3 of the License, or | ||
| // (at your option) any later version. | ||
| // | ||
| // This program is distributed in the hope that it will be useful, | ||
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| // GNU General Public License for more details. | ||
| // | ||
| // You should have received a copy of the GNU General Public License | ||
| // along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
|
|
||
| using QuickLook.Common.Helpers; | ||
| using System; | ||
| using System.Collections.Concurrent; | ||
| using System.IO; | ||
| using System.Text; | ||
| using System.Threading; | ||
|
|
||
| namespace QuickLook.Helpers; | ||
|
|
||
| /// <summary> | ||
| /// Diagnostic log for the keyboard pipeline, written to QuickLook.Keyboard.log in the data folder. | ||
| /// Only QuickLook's own keys and anomalies are logged, never other keystrokes. | ||
| /// </summary> | ||
| internal static class KeyboardLog | ||
| { | ||
| private const long MAX_SIZE = 512 * 1024; | ||
|
|
||
| /// <summary>Preview phases slower than this block switching to the next file and get logged.</summary> | ||
| private const long SLOW_PREVIEW_PHASE_MS = 100; | ||
|
|
||
| private static readonly string LogPath = Path.Combine(SettingHelper.LocalDataPath, "QuickLook.Keyboard.log"); | ||
|
|
||
| // Bounded so that a stuck disk can never make the keyboard hook wait or grow memory. | ||
| private static readonly BlockingCollection<string> Queue = new(new ConcurrentQueue<string>(), 1000); | ||
|
|
||
| static KeyboardLog() | ||
| { | ||
| new Thread(WriteLoop) | ||
| { | ||
| IsBackground = true, | ||
| Name = "QuickLook.KeyboardLog", | ||
| Priority = ThreadPriority.BelowNormal, | ||
| }.Start(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Never blocks; safe to call from inside the low-level keyboard hook. | ||
| /// </summary> | ||
| public static void Write(string message) | ||
| { | ||
| Queue.TryAdd($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [T{Thread.CurrentThread.ManagedThreadId}] {message}"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Logs a preview phase that ran on the UI thread only when it was slow enough to delay | ||
| /// the next selection change, naming the plugin and file type, never the file name. | ||
| /// </summary> | ||
| public static void WriteIfSlow(string phase, long elapsedMs) | ||
| { | ||
| if (elapsedMs >= SLOW_PREVIEW_PHASE_MS) | ||
| Write($"Slow preview {phase}: {elapsedMs} ms"); | ||
| } | ||
|
|
||
| private static void WriteLoop() | ||
| { | ||
| var batch = new StringBuilder(); | ||
|
|
||
| foreach (var line in Queue.GetConsumingEnumerable()) | ||
| { | ||
| batch.AppendLine(line); | ||
| while (Queue.TryTake(out var more)) | ||
| batch.AppendLine(more); | ||
|
|
||
| try | ||
| { | ||
| Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!); | ||
|
|
||
| var file = new FileInfo(LogPath); | ||
| if (file.Exists && file.Length > MAX_SIZE) | ||
| { | ||
| File.Delete(LogPath + ".old"); | ||
| File.Move(LogPath, LogPath + ".old"); | ||
| } | ||
|
|
||
| File.AppendAllText(LogPath, batch.ToString()); | ||
| } | ||
| catch | ||
| { | ||
| // logging must never take QuickLook down | ||
| } | ||
|
|
||
| batch.Clear(); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,13 +18,18 @@ | |
| using QuickLook.Common.ExtensionMethods; | ||
| using QuickLook.Common.Helpers; | ||
| using QuickLook.Common.Plugin; | ||
| using QuickLook.Helpers; | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Diagnostics; | ||
| using System.IO; | ||
| using System.Linq; | ||
| using System.Reflection; | ||
| using System.Runtime.CompilerServices; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using System.Windows; | ||
| using System.Windows.Threading; | ||
| using UnblockZoneIdentifier; | ||
|
|
||
| namespace QuickLook; | ||
|
|
@@ -35,9 +40,13 @@ public sealed class PluginManager | |
|
|
||
| private PluginManager() | ||
| { | ||
| var sw = Stopwatch.StartNew(); | ||
| LoadPlugins(App.UserPluginPath); | ||
| LoadPlugins(Path.Combine(App.AppPath, @"QuickLook.Plugin\")); | ||
| var loadMs = sw.ElapsedMilliseconds; | ||
| sw.Restart(); | ||
| InitLoadedPlugins(); | ||
| KeyboardLog.Write($"Startup: plugins loaded in {loadMs} ms, initialized in {sw.ElapsedMilliseconds} ms"); | ||
| } | ||
|
|
||
| internal IViewer DefaultPlugin { get; } = new Plugin.InfoPanel.Plugin(); | ||
|
|
@@ -79,6 +88,104 @@ internal IViewer FindMatch(string path) | |
| return (matched ?? DefaultPlugin).GetType().CreateInstance<IViewer>(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Pays the first-use costs (JIT, static constructors, dependent assemblies) at startup instead of | ||
| /// on the first preview. Each plugin's CanHandle runs once on the UI thread at idle priority, one plugin | ||
| /// per dispatcher operation so a key press is never held up; then every plugin assembly is compiled | ||
| /// ahead on a low-priority background thread, which also covers Prepare and View. | ||
| /// </summary> | ||
| internal void WarmUp() | ||
| { | ||
| var sw = Stopwatch.StartNew(); | ||
| var probe = Path.Combine(Path.GetTempPath(), "QuickLook.WarmUp.nonexistent.txt"); | ||
|
|
||
| foreach (var plugin in LoadedPlugins) | ||
| { | ||
| Application.Current.Dispatcher.BeginInvoke(() => | ||
| { | ||
| try | ||
| { | ||
| plugin.CanHandle(probe); | ||
|
Comment on lines
+104
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Keep long-running warm-up probes off the UI thread.
Normal preview matching also calls
|
||
| } | ||
| catch | ||
| { | ||
| // ignored | ||
| } | ||
| }, DispatcherPriority.ApplicationIdle); | ||
| } | ||
|
|
||
| Application.Current.Dispatcher.BeginInvoke(() => | ||
| { | ||
| KeyboardLog.Write($"Startup: CanHandle warm-up done in {sw.ElapsedMilliseconds} ms"); | ||
|
|
||
| var assemblies = LoadedPlugins.Select(p => p.GetType().Assembly).Distinct().ToArray(); | ||
| new Thread(() => PrepareAssemblies(assemblies)) | ||
| { | ||
| IsBackground = true, | ||
| Name = "QuickLook.WarmUp", | ||
| Priority = ThreadPriority.Lowest, | ||
| }.Start(); | ||
| }, DispatcherPriority.ApplicationIdle); | ||
| } | ||
|
|
||
| private static void PrepareAssemblies(Assembly[] assemblies) | ||
| { | ||
| var sw = Stopwatch.StartNew(); | ||
| var count = 0; | ||
|
|
||
| foreach (var assembly in assemblies) | ||
| { | ||
| Type[] types; | ||
| try | ||
| { | ||
| types = assembly.GetTypes(); | ||
| } | ||
| catch (ReflectionTypeLoadException e) | ||
| { | ||
| types = [.. e.Types.Where(t => t != null)]; | ||
| } | ||
|
|
||
| foreach (var type in types) | ||
| { | ||
| if (type.IsGenericTypeDefinition || type.IsInterface) | ||
| continue; | ||
|
|
||
| const BindingFlags flags = BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Static | ||
| | BindingFlags.Public | BindingFlags.NonPublic; | ||
|
|
||
| MethodBase[] methods; | ||
| try | ||
| { | ||
| methods = [.. type.GetMethods(flags), .. type.GetConstructors(flags)]; | ||
| } | ||
| catch | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| foreach (var method in methods) | ||
| { | ||
| if (method.IsAbstract || method.ContainsGenericParameters | ||
| || (method.MethodImplementationFlags & MethodImplAttributes.InternalCall) != 0 | ||
| || method.GetMethodBody() == null) | ||
| continue; | ||
|
|
||
| try | ||
| { | ||
| RuntimeHelpers.PrepareMethod(method.MethodHandle); | ||
| count++; | ||
| } | ||
| catch | ||
| { | ||
| // a missing optional dependency, etc.: that method is simply compiled on first use | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| KeyboardLog.Write($"Startup: precompiled {count} plugin methods in {sw.ElapsedMilliseconds} ms"); | ||
| } | ||
|
|
||
| private void LoadPlugins(string folder) | ||
| { | ||
| if (!Directory.Exists(folder)) | ||
|
|
@@ -90,6 +197,7 @@ private void LoadPlugins(string folder) | |
| .ToList() | ||
| .ForEach(lib => | ||
| { | ||
| var sw = Stopwatch.StartNew(); | ||
| try | ||
| { | ||
| Assembly.LoadFrom(lib) | ||
|
|
@@ -110,6 +218,7 @@ private void LoadPlugins(string folder) | |
| ProcessHelper.WriteLog($"Failed to load plugin {Path.GetFileName(lib)}: {ex}"); | ||
| failedPlugins.Add((Path.GetFileName(lib), ex)); | ||
| } | ||
| KeyboardLog.WriteIfSlow($"startup load {Path.GetFileNameWithoutExtension(lib)}", sw.ElapsedMilliseconds); | ||
| }); | ||
|
|
||
| LoadedPlugins = [.. LoadedPlugins.OrderByDescending(i => i.Priority)]; | ||
|
|
@@ -132,6 +241,7 @@ private void InitLoadedPlugins() | |
| { | ||
| LoadedPlugins.ForEach(i => | ||
| { | ||
| var sw = Stopwatch.StartNew(); | ||
| try | ||
| { | ||
| i.Init(); | ||
|
|
@@ -140,6 +250,7 @@ private void InitLoadedPlugins() | |
| { | ||
| ProcessHelper.WriteLog(e.ToString()); | ||
| } | ||
| KeyboardLog.WriteIfSlow($"startup init {i.GetType().Namespace}", sw.ElapsedMilliseconds); | ||
| }); | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔎 Supported by static analysis
Script executed:
Repository: QL-Win/QuickLook
Length of output: 1536
Script executed:
Repository: QL-Win/QuickLook
Length of output: 6379
Web query:
Microsoft .NET Framework 4.6.2 System.Runtime.ProfileOptimization.StartProfile existing profile overwritten documentationResult:
Start profile recording only for the primary instance.
QuickLooktargets .NET Framework 4.6.2.StartProfilerecords current method use and later overwrites the specified profile. TheAppstatic constructor runs beforeOnStartup, so a forwarded second launch can replace the primary launch’s startup profile.Move the
SetProfileRootandStartProfilecalls to afterEnsureFirstInstancesucceeds.