Skip to content

Optimize activation factory using binary search to make it 5x+ faster - #1633

Open
Yexuan Xiao (YexuanXiao) wants to merge 7 commits into
microsoft:masterfrom
YexuanXiao:opti-activation
Open

Yexuan Xiao (YexuanXiao) wants to merge 7 commits into
microsoft:masterfrom
YexuanXiao:opti-activation

Conversation

@YexuanXiao

@YexuanXiao Yexuan Xiao (YexuanXiao) commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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 constexpr tables, one of string_views and one of function pointers. Once the type is found from the string_view table, 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 use std::pair, which makes it possible to call lower_bound without writing a lambda, and allows it to be merged with other lower_bound<string_view*> calls.

@YexuanXiao Yexuan Xiao (YexuanXiao) changed the title Optimize activation factory using binary search Optimize activation factory using binary search to make it 5x+ faster Oct 4, 2026
@YexuanXiao

Copy link
Copy Markdown
Contributor Author

Jon Wiswall (@jonwis) Ryan Shepherd (@DefaultRyan) Can you take a look at this PR?

@jonwis

Copy link
Copy Markdown
Member

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 winrt_make_typename exists, so I cut & paste it here and avoid a mess of #include and inclusion paths." I'm not sure this model will enable 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 std::pair<std::wstring_view, void* (*)()> for searching for this module, but dispatches the call through the function pointer. Converting callers to fix inclusion graphs between distant components may be more of a challenge.

@YexuanXiao

Yexuan Xiao (YexuanXiao) commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Jon Wiswall (@jonwis) My patch only affects module.g.cpp, so if the entry point is hand-written, there's nothing it can do.
These code should be migrated to the C++/WinRT -optimize model to automatically get winrt_make_Typename and use the entry point of module.g.cpp.

@DefaultRyan

Copy link
Copy Markdown
Member

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 std::lower_bound is using - I think it's using operator <, which then calls string_view::compare. Changing from "equal to" to "less than" means we lose the benefit of short-circuiting the comparison when the strings have different lengths - if we're only concerned with equality, string_view::operator == immediately returns false if the sizes differm but a lexicographical compare still requires us to compare the contents of different-length strings.

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:

  • MSVC 14.51 (May 2026, default in VS 18.6) has the vectorization.
  • MSVC 14.50 (default in VS18.0 - 18.5) has a smaller optimization that calls wmemcmp in the UCRT. However, the version of wmemcmp you get in the UCRT depends on the version of the Windows SDK you build against.
    • 10.0.26100 and newer, you get a vectorized implementation.
    • 10.0.22621 and older, you get slow character-by-character loop.

Between avoiding pipeline stalls and the beefed up vectorized comparisons, you could be better off by simply plowing straightforward through the strings using the string_view::operator==.

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.

@DefaultRyan

Copy link
Copy Markdown
Member

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 Windows.Foundation.Uri and Windows.Foundation.WwwFormUrlDecoder because I believe those are the only two WinRT classes hosted in iertutil.dll, so the activation lookup logic is probably very fast. Output:

Duration for get_activation_factory<winrt::Windows::Foundation::Uri>(): 3288100 ns
Duration for get_activation_factory<winrt::Windows::Foundation::Uri>() (cached): 400 ns
Duration for get_activation_factory<winrt::Windows::Foundation::Uri>() (uncached): 19400 ns
Duration for get_activation_factory<winrt::Windows::Foundation::WwwFormUrlDecoder>(): 230800 ns

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 DllGetActivationFactory. 211k ns to get a different factory from the same DLL so we don't pay for DLL load, but perform a fresh lookup.

@YexuanXiao

Copy link
Copy Markdown
Contributor Author

I still hope it can be faster, because it might help with program startup time, so I think it still has some value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants