Repository navigation
Optimize activation factory using binary search to make it 5x+ faster - #1633
Yexuan Xiao (YexuanXiao) wants to merge 7 commits into
Conversation
71f7592 to
842a9d9
Compare
|
Jon Wiswall (@jonwis) Ryan Shepherd (@DefaultRyan) Can you take a look at this PR? |
|
There are some DLLs in Windows that link multiple static libraries together into a single main.cpp (not even the module.g.cpp, hand-authored) entrypoint that rely on this "I know that Maybe try splitting apart the two steps? I agree that "search a table of names" is likely better than "scan." So your writer produces an constexpr array of |
|
Jon Wiswall (@jonwis) My patch only affects module.g.cpp, so if the entry point is hand-written, there's nothing it can do. |
|
First question, is this lookup time a significant factor for type activation? Improving the lookup from 170ns to 30ns is nice, but if activating an uncached type typically takes upwards of, say, 10,000ns, then this improvement is unlikely to have any real-world benefit. Also, if the calling code if C++/WinRT, it tries to cache those activation factories anyway, and I'm pretty sure CsWinRT does the same, so callers using these libraries will only pay the lookup cost once per type. If we answer that question and determine that this is still a bottleneck, then... I'm not convinced that a binary search is what we want here - it pessimizes the branch predictors in a CPU and will cause pipeline stalls. You also have a confounding variable in your benchmark by changing from reverse string compare (originally chosen because type names often have identical prefixes) to whatever For an apples-to-apples, ahem, comparison, you should compare before and after using the same reverse string compare. Another reason I bring this is up is that I was looking to ditch the reverse string compare myself. I've noticed that more recent builds of MSVC's STL have vectorized these string comparisons so now they batch compare 16 or even 32 bytes at a time, meaning that even with type name prefixes, we're likely better off just using the default comparator. The catch is that this vectorization is a more recent addition:
Between avoiding pipeline stalls and the beefed up vectorized comparisons, you could be better off by simply plowing straightforward through the strings using the But I can see how with 200-odd names to compare, even the short-circuit length check still leads to a lot of checks. One strategy I've used in the past is to sort the table by length, not lexicographically, and have an indexer that tells you where to start and end your search so that you only check candidates with a matching length. A hand-written entry point could go even further and strip common prefixes, e.g., reject any request that doesn't start with "Windows.UI.Xaml.", and then compare only the suffixes. Perhaps a generated approach could replicate similar logic, along with omitting the prefixes from the string table to keep it more compact and CPU cache friendly. I'll just end by reiterating that this is all fun, but it doesn't matter if this time is insignificant compared to the actual cost of obtaining an activation factory. |
|
I ran a little test to get some rough real-world numbers. TL;DR: I don't think this optimization will matter. int main()
{
winrt::init_apartment();
{
auto start = chrono::high_resolution_clock::now();
auto factory = winrt::get_activation_factory<winrt::Windows::Foundation::Uri>();
auto end = chrono::high_resolution_clock::now();
auto duration = chrono::duration_cast<chrono::nanoseconds>(end - start);
cout << "Duration for get_activation_factory<winrt::Windows::Foundation::Uri>(): " << duration.count() << " ns" << endl;
}
{
// Get the Uri factory again to test C++/WinRT caching performance
auto start = chrono::high_resolution_clock::now();
auto factory = winrt::get_activation_factory<winrt::Windows::Foundation::Uri>();
auto end = chrono::high_resolution_clock::now();
auto duration = chrono::duration_cast<chrono::nanoseconds>(end - start);
cout << "Duration for get_activation_factory<winrt::Windows::Foundation::Uri>() (cached): " << duration.count() << " ns" << endl;
}
{
// Get the Uri factory again without C++/WinRT caching (COM internals probably still cached some of the lookup)
auto start = chrono::high_resolution_clock::now();
auto factor = winrt::get_activation_factory(winrt::name_of<winrt::Windows::Foundation::Uri>());
auto end = chrono::high_resolution_clock::now();
auto duration = chrono::duration_cast<chrono::nanoseconds>(end - start);
cout << "Duration for get_activation_factory<winrt::Windows::Foundation::Uri>() (uncached): " << duration.count() << " ns" << endl;
}
{
// Get a different factory from hopefully the same dll to see if it matters when the dll is already loaded
auto start = chrono::high_resolution_clock::now();
auto factory = winrt::get_activation_factory<winrt::Windows::Foundation::WwwFormUrlDecoder>();
auto end = chrono::high_resolution_clock::now();
auto duration = chrono::duration_cast<chrono::nanoseconds>(end - start);
cout << "Duration for get_activation_factory<winrt::Windows::Foundation::WwwFormUrlDecoder>(): " << duration.count() << " ns" << endl;
}
}I chose So, 3ms (3M ns) for a cold load where the DLL is not yet in the process. 400 ns when it's already in the C++/WinRT cache. 19k ns when bypassing the C++/WinRT cache, but Windows/COM has probably still cached the lookup information to speed things along to the call to |
|
I still hope it can be faster, because it might help with program startup time, so I think it still has some value. |
Currently, C++/WinRT uses reverse-order string comparison and checks strings one by one with if statements to find the appropriate make function, which can be very inefficient. I observed that the WinUI repository uses 219 runtime classes (the module.g.cpp from my local WinUI build), and searching through them sequentially is undoubtedly slow and inefficient. By switching to binary search, the speed can be improved by a factor of five or more.
I wrote a benchmark that simulates both the original lookup logic and the updated logic. https://github.com/YexuanXiao/winrt-activation-bench
Before optimization, a single lookup took 170 ns; after optimization, it takes 30 ns.
The patch generates two separate
constexprtables, one ofstring_views and one of function pointers. Once the type is found from thestring_viewtable, the make function is called from the same position in the function pointer table. There's a macro for generating the declarations and these two tables, avoiding having to write the function name twice (once for the function declaration and once for the table element). Since the tables are only used once, I didn't usestd::pair, which makes it possible to calllower_boundwithout writing a lambda, and allows it to be merged with otherlower_bound<string_view*>calls.