Skip to content

C++/WinRT ABI interop improvements#1608

Open
jonwis wants to merge 12 commits into
microsoft:masterfrom
jonwis:user/jonwis/throughput-perf
Open

C++/WinRT ABI interop improvements#1608
jonwis wants to merge 12 commits into
microsoft:masterfrom
jonwis:user/jonwis/throughput-perf

Conversation

@jonwis

@jonwis jonwis commented Jul 17, 2026

Copy link
Copy Markdown
Member

Why

Analysis of the Rust (windows-rs) projection plus code analysis of Windows components that use C++/WinRT showed improvements available to C++/WinRT on several common interop paths — windows-rs boxes scalars in-process, completes already-ready async without a coroutine frame, batches collection iteration, and builds HSTRING literals at compile time.

What this PR changes

Commit(s) Path Change Opt-in?
2eab28a, 0adfbeb String _hs compile-time HSTRING literal opt-in
a889be0, 1f16ab0 Async make_ready for pre-completed async operations opt-in
390e118 Map Buffered range-for over non-GetAt collections (IIterable, map IKeyValuePair) via one IIterator::GetMany per block transparent
ab1f299 IterateVector Batch IVector/IVectorView range-for via GetMany block prefetch; random-access + E_BOUNDS preserved transparent
71cc2c6, bee7550, 75bf146 Reference In-process scalar boxing (u8–u64, float, double, bool, char16, hstring, guid, DateTime, TimeSpan, Point), marshaling by value through combase PropertyValue transparent

Reference boxing is the largest piece. box_value on a scalar previously hopped into combase to allocate a general IPropertyValue carrying the full discriminated-union machinery. This branch points the scalar reference_traits at the in-proc impl::reference<T> that already backs non-scalar IReference<T>, and makes that type a correct IPropertyValue (right PropertyType, fixed IsNumericScalar, typed getters). To preserve cross-process behavior, stock scalars are marked non_agile and supply IMarshal lazily from query_interface_tearoff by building the equivalent combase PropertyValue — so the value still marshals by value, and the combase hop is paid only on marshal, never on box/unbox. This mirrors windows-rs' StockReference.

Iterator batching does have a slight change in semantics.

// Previously:
for (auto const& v : foo.ItemVector()) { // Call to v.First(), then j = i.Current()
   /* ... */
   // j is destroyed before i.MoveNext() & another i.Current()
}

// Now:
for (auto const& v : foo.ItemVector()) { // Call to v.First(), v.GetMany([N]), i = iterbuf[0]
   /* ... */
   // iteration continues, but iterbuf[0,N] are not destroyed until all N have been iterated
}

Existing code often used for (auto const& v : wil::to_vector(foo.ItemVector())) which has the same "Now" behavior.

Two honest deltas vs combase, both matching windows-rs: GetRuntimeClassName now returns the IReference<T> runtime class name rather than Windows.Foundation.PropertyValue, and cross-process the value marshals by value rather than as a proxy.

Results

Warmed, order-controlled benchmark of Rust (windows-rs), stock cppwinrt, and cppwinrt-new (this branch). Rust is Rust→Rust; cppwinrt columns are C++→C++. Average of two reversed-order samples, 4M measured + 2M warmup iterations. Relative results are stable across multiple attempts.

Path Rust cppwinrt (stock) cppwinrt-new new vs stock new vs Rust
Create 339 453 336 1.3× faster tie
Int32 14 18 10 1.8× faster tie
String 14 20 8 2.5× faster tie
Object 78 80 69 1.2× faster tie
Cast (QI) 178 164 140 1.2× faster tie
Event 185 214 180 1.2× faster tie
AddRemove 1,043 936 727 1.3× faster Rust ~1.4×
IterateVector 8 81 5 16× faster tie (new fastest)
GetMany 1 1 1 tie
Map 416 647 379 1.7× faster tie (new fastest)
Async 339 635 309 2.1× faster tie (new fastest)
Reference 435 2,065 541 3.8× faster Rust ~1.2×
Error 33 135,918 141,009 ≈ (unchanged) Rust ~4,300×

This branch delivers all five improvements — String, Async, Map, IterateVector, and Reference. IterateVector 81 → 5 ms ties Rust (ab1f299); 390e118 covers non-GetAt collections (Map) and ab1f299 extends the same batching to vectors. Error is the one path left untouched — origination still fires on every throw, and remains the sole order-of-magnitude gap (~4,300×).

Reference retains a ~1.2× Rust lead that is structural, not the box shape: cppwinrt's per-object module-lock accounting (a lock-prefixed RMW on a process-global at each box's birth/death) plus the CRT malloc/free layer vs windows-rs' direct HeapAlloc.

Testing

New tests: test/test/reference_boxing.cpp (PropertyType, numeric conversion, round-trip, marshal-by-value via GetUnmarshalClass parity with combase), test/test/make_ready.cpp, test/test/buffered_iterator.cpp, test/test_cpp20/hstring_literal.cpp. Built Release/x64; reference_boxing passes all 43 assertions.

References

(via Copilot)

jonwis and others added 9 commits July 16, 2026 15:13
Passing a wide string literal to a WinRT API that takes an hstring runs
wcslen and fills a seven-field HSTRING_HEADER on the stack on every call
(via param::hstring -> create_hstring_on_stack). For a literal, all of
that is knowable at compile time and only needs to happen once.

Add a `_hs` user-defined literal that builds the fast-pass reference
header as a constexpr static, so `L"value"_hs` reduces to a single
pointer load at the call site with no per-call wcslen or header fill.

The literal-operator-template is keyed on the characters themselves via a
C++20 non-type template parameter (hstring_literal_storage<N>), so each
distinct literal gets its own static header with static lifetime. It
returns a non-owning winrt::hstring_reference (a plain winrt::hstring
would assert/free the reference header in its destructor). A matching
param::hstring constructor lets the result bind to projected setters in a
single user-defined conversion.

A bare `param::hstring(wchar_t const(&)[N])` overload is deliberately not
added: it would also bind non-literal arrays (e.g. a partially-filled
wchar_t buf[260]) and infer length N-1 past the null, silently
corrupting; and a runtime constructor cannot produce a content-specific
static anyway. `_hs` is the explicit, safe opt-in; bare literal calls keep
their unchanged wcslen path.

Gated on __cpp_nontype_template_args >= 201911L. Tested under C++20 in
test_cpp20/hstring_literal.cpp; the non-gated types build under C++17.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 83914e59-f7cd-4284-ad4b-4cb7b79f28c1
Returning an already-available value through an async-typed API (a cache
hit, a fast path, or an API async-typed only for interface uniformity)
still pays the full coroutine cost when written as `co_return value`:
a heap-allocated coroutine frame plus a promise that is a complete COM
object implementing IAsyncOperation and IAsyncInfo, carrying a slim_mutex,
an agile completed-handler slot, an atomic status, and cancel machinery,
all built and torn down on every call.

Add winrt::make_ready<T>(value) and winrt::make_ready() that return a
minimal already-completed IAsyncOperation<T> / IAsyncAction: it holds just
the value with a fixed Completed status and no coroutine frame, no
slim_mutex, no handler slot, and no cancel machinery. Setting a Completed
handler on it invokes immediately, and Status()/GetResults() satisfy both
the .get() and co_await consumer paths, so it is a drop-in for the
synchronous-result case. `co_return` remains correct for genuinely
suspending work, where the frame is doing real work and the overhead is
amortized to noise.

The one-shot Completed assignment is guarded with a lock-free atomic flag
rather than a lock. Tested in test/make_ready.cpp.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 83914e59-f7cd-4284-ad4b-4cb7b79f28c1
Range-for over a collection that lacks GetAt (a plain IIterable<T>, or a
map yielding IKeyValuePair) drove the projected IIterator one element per
ABI crossing via Current/MoveNext. For a large sequence that is one vtable
call per item.

Route that path through a buffered_iterator that pulls a block of elements
with a single IIterator::GetMany call into a small stack buffer and yields
from it, refilling only when the buffer is drained. Existing
`for (auto&& x : v)` code gets the speedup with no source change. The block
is sized like windows-rs' BufferedIterator -- clamp(2048 / sizeof(T), 1,
128) -- to cap the buffer near 1-2 KB and bound over-fetch for large
element types.

Only the non-GetAt path changes. Collections with GetAt (IVector,
IVectorView) keep the existing random-access fast_iterator, so no
iterator-category guarantees are affected. The iterator is single-pass,
matching IIterator's own semantics.

Tested in test/buffered_iterator.cpp (multi-block, block boundary, empty,
and a non-trivial element type).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 83914e59-f7cd-4284-ad4b-4cb7b79f28c1
Use TResult&& + decay_t<TResult> so make_ready forwards its argument into
the operation instead of taking it by value, saving a move and matching the
make_unique/make_shared idiom.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 83914e59-f7cd-4284-ad4b-4cb7b79f28c1
Move the fast-pass header from a function-local static into an inline
constexpr variable template and mark hstring_reference and the operator
constexpr, so `constexpr auto s = L"x"_hs;` is a genuine compile-time
construction rather than per-call work. Add a constexpr construction to the
test to prove it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 83914e59-f7cd-4284-ad4b-4cb7b79f28c1
box_value on a scalar routed through the cached Windows.Foundation.
PropertyValue activation factory into combase, which allocates a general
IPropertyValue carrying the discriminated-union machinery for all property
types. For the common scalar cases that round-trip is pure overhead.

Point the scalar reference_traits (u8..u64, float, double, bool, char16,
hstring, guid) at the in-process impl::reference<T> that already backs
non-scalar IReference<T>, and make that type a correct IPropertyValue:
report the right PropertyType per T (was always OtherType), fix
IsNumericScalar (was true for bool), and return the value from the matching
typed getter (GetString/GetGuid/GetBoolean/GetChar16/GetSingle/GetDouble
previously threw). Mismatched numeric getters keep combase-style
conversion (GetInt16 on a boxed int32 converts), so consumers see the same
behavior minus the combase hop. This mirrors windows-rs' StockReference.

Composite/array/inspectable cases (DateTime, TimeSpan, Point, Size, Rect,
IReferenceArray, IInspectable) stay on combase PropertyValue.

Two honest deltas vs combase, both matching windows-rs: GetRuntimeClassName
is now the IReference`1<T> name rather than Windows.Foundation.
PropertyValue, and cross-process the value marshals as an IReference proxy
rather than by value. Tested in test/reference_boxing.cpp.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 83914e59-f7cd-4284-ad4b-4cb7b79f28c1
The in-proc scalar reference<T> introduced in the prior commit is agile via the
free-threaded marshaler, so cross-process it marshals by reference (an IReference
proxy) rather than by value the way combase PropertyValue does. Mirror windows-rs
and combase: for the stock scalar types, mark reference<T> non_agile and supply
IMarshal from query_interface_tearoff by lazily building the equivalent combase
PropertyValue and delegating marshaling to it, so the destination materializes a
real PropertyValue copy. IAgileObject is still advertised (the reference is
immutable and thread-safe) to keep the agile fast path. The combase hop is paid
only on marshal, never on box_value/unbox_value.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Extend the in-proc reference<T> to cover DateTime, TimeSpan, and Point: report
the correct PropertyType, return the value from the matching typed getter, and
mark them stock so they still marshal by value through combase PropertyValue.
Drop their combase reference_traits specializations so box_value routes to the
local reference<T> like the other scalars.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
fast_iterator (used for random-access, GetAt-capable collections like IVector/
IVectorView) now prefetches a block with a single GetMany call and serves in-window
reads from it, so range-for crosses the ABI ~once per block instead of one GetAt per
element. Random access is preserved: an out-of-window index re-anchors the block, and an
at/after-end index defers to GetAt so E_BOUNDS behavior is unchanged. Elements are copied
out (no move-out) so an index may be read repeatedly, as the random-access contract
requires. This extends the non-GetAt buffering to vectors, closing the IterateVector gap
to windows-rs.
@oldnewthing

Copy link
Copy Markdown
Member

Iterator batching does have a slight change in semantics.

Another semantic change is that if the iterator is invalidated (e.g., because it is an iterator over a view that has been mutated), we don't throw hresult_changed_state() until the next time we go to fetch a batch. Old code threw at next iteration.

Therefore old code never operated on stale data. (Throws before stale data can be returned.) New code could operate on stale data for the remainder of the batch.

jonwis and others added 2 commits July 17, 2026 11:59
Three portability breaks surfaced by CI across MSVC and clang-cl:

- Iterator batching assumed every collection/iterator has GetMany and every
  element type is default-constructible. IBindableVectorView has GetAt but no
  GetMany, IBindableIterator has no GetMany, and types like JsonValue have no
  default constructor. Gate the GetMany block buffer on both a GetMany detector
  and default-constructibility (can_batch); otherwise fall back to per-element
  GetAt / Current+MoveNext. The block buffer is elided entirely when unused.
- reference<T>::query_interface_tearoff called .as<IMarshal>() on a dependent
  expression; clang requires .template as<IMarshal>().
- hstring_reference::m_handle is only read via layout punning in get_abi, so
  clang -Werror,-Wunused-private-field rejected it. Mark it [[maybe_unused]].

Verified: msbuild cppwinrt + test/test_old (MSVC) and test/test_nocoro/test_old
(clang-cl), x64 Debug, all build clean.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
build_test_all.cmd only built the MSVC toolset, so a local pass did not catch
clang-cl -Werror breaks that CI's clang-cl leg rejects. Add an optional 5th
positional arg (default msvc); pass clang/clang-cl to append
Clang=1,PlatformToolset=ClangCl to the cppwinrt.sln compiler and test builds,
matching CI. natvis and NugetTest.sln stay on MSVC, as the clang CI leg does not
cover them. Also fix a stray trailing quote on the nuget restore line and
document the arg in README.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@YexuanXiao

YexuanXiao commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Is it possible to add an opt-in winrt::buffered_view<T, N> that buffers elements instead of altering the current iterator? It can avoid various issues with iterators.

…pilers

hstring_literal.cpp had an unconditional using-directive for winrt::literals,
but that namespace and its operator ""_hs only exist under
__cpp_nontype_template_args >= 201911L. clang-cl reports 201411L (no class-type
NTTP), so the namespace is absent and the using-directive failed to compile
("expected namespace name"), breaking the clang-cl test_cpp20 leg. Move the
using-directive inside the same feature guard the rest of the test already uses.

Verified: full CI test-project set builds clean on both MSVC v145 and clang-cl,
x64 Debug.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread strings/base_iterator.h
if (index < m_buffer_base || index >= m_buffer_base + m_buffer_size)
{
m_buffer_base = index;
m_buffer_size = m_collection->GetMany(index, m_buffer);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to reorder these assignments. Otherwise, if GetMany throws E_BOUNDS, we will propagate the exception but we've already updated the m_buffer_base, so the next query for the same out-of-bounds element will succeed and return a cached element from some earlier batch.

Comment thread strings/base_iterator.h
bool operator==(buffered_iterator const& other) const noexcept
{
return {};
return (m_size == 0) && (other.m_size == 0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does lead to the weird scenario where a non-end iterator is not equal to itself! That might break some algorithms?

Maybe

return m_size == other.m_size;

?
This gets us end==end as well as it==it.

Alternatively, have fill set m_iterator=null if the size is zero, and then this becomes m_iterator==other.m_iterator, which matches the behavior of the old iterator.

Comment thread strings/base_iterator.h
}

Iterator m_iterator{ nullptr };
std::array<value_type, buffer_capacity> m_buffer;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what algorithms we will tank now that iterator assignment is not cheap.

Comment thread strings/base_iterator.h
std::uint32_t m_index = 0;
mutable std::uint32_t m_buffer_base = 0;
mutable std::uint32_t m_buffer_size = 0;
mutable buffer_type m_buffer{};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

``[[no_unique_address]]so thatno_buffer` can disappear?

inline Windows::Foundation::IAsyncAction make_ready() noexcept
{
return make<impl::ready_async_action>();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also need WithProgress variants?

Also consider the versions here, which supports both with- and without progress , as well as failed_async for failed coroutines. https://devblogs.microsoft.com/oldnewthing/20240718-00/?p=109977

using reference_base_t = std::conditional_t<
is_stock_reference_v<T>,
implements<reference<T>, Windows::Foundation::IReference<T>, Windows::Foundation::IPropertyValue, non_agile>,
implements<reference<T>, Windows::Foundation::IReference<T>, Windows::Foundation::IPropertyValue>>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or

    using reference_base_t = implements<reference<T>, Windows::Foundation::IReference<T>, Windows::Foundation::IPropertyValue,
        std::conditional_t<is_stock_reference_v<T>, non_agile, std::monostate>>;

If a type parameter to implements<> is not a WinRT interface, not a COM interface, and not a marker class, then it is ignored.

@sylveon

sylveon commented Jul 17, 2026

Copy link
Copy Markdown
Contributor
// Previously:
for (auto const& v : foo.ItemVector()) { // Call to v.First(), then j = i.Current()
   /* ... */
   // j is destroyed before i.MoveNext() & another i.Current()
}

Slight nit, it actually does v.GetAt if it's available on ItemVector

@sylveon

sylveon commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Also, fixes #1416 :)

WINRT_EXPORT namespace winrt::impl
{
template <typename Derived, typename AsyncInterface>
struct ready_async_base : implements<Derived, AsyncInterface, Windows::Foundation::IAsyncInfo>

@sylveon sylveon Jul 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't this go in wil? There has been a prior desire to move the coroutine/etc stuff to wil and keep cppwinrt focused on purely projection, is this still true?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. No particular reason to have it here.

Comment thread strings/base_iterator.h
// bounds behavior (E_BOUNDS) is preserved. Elements are copied out -- no move-out -- so a given
// index may be read repeatedly, as the random-access contract requires. Collections without
// GetMany, or whose element type is not default-constructible, fall back to plain GetAt.
static constexpr bool can_batch = std::is_default_constructible_v<value_type> && has_GetMany_indexed<T>::value;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has there been any consideration on how this makes fast_iterator not cheap? They take far more space and require several AddRef to copy around. Would this slow down e.g. <algorithm> or <ranges>?

@jonwis jonwis Jul 18, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True; if we go after wil::buffered_iterator<> ... maybe that should be an input_iterator so it's clear you're only supposed to move it forward.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I can imagine someone using a reverse iterator over fast_iterator wouldn't be too happy about this.

Comment thread strings/base_iterator.h

template <typename T, std::enable_if_t<!has_GetAt<T>::value, int> = 0>
auto get_end_iterator([[maybe_unused]] T const& collection) noexcept -> decltype(collection.First())
auto get_end_iterator([[maybe_unused]] T const& collection)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where did noexcept go?

@jonwis

jonwis commented Jul 18, 2026

Copy link
Copy Markdown
Member Author

Is it possible to add an opt-in winrt::buffered_view<T, N> that buffers elements instead of altering the current iterator? It can avoid various issues with iterators.

I like this, @sylveon ... it does mean existing code won't be sped up, but that's probably OK. And it also means it could move into WIL as you suggest. We can write code-review rules to request people use it. It also means you have to be aware of the "the collection changed under you" as @oldnewthing suggests.

Iterator batching does have a slight change in semantics.

Another semantic change is that if the iterator is invalidated (e.g., because it is an iterator over a view that has been mutated), we don't throw hresult_changed_state() until the next time we go to fetch a batch. Old code threw at next iteration.

Therefore old code never operated on stale data. (Throws before stale data can be returned.) New code could operate on stale data for the remainder of the batch.

@oldnewthing how do you feel about this compared to wil::to_vector? I think I'm leaning towards the "don't mess with the existing iterators" path above. Maybe the answer then is this disappears entirely, and the guidance is "if you are going to iterate the entire vector, just snarf it all with wil::to_vector<> and be done." ?

@YexuanXiao

YexuanXiao commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

A independent winrt::buffered_view can use stack memory, which saves memory compared to wil::to_vector. Its iterator can be a bidirectional iterator.

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.

4 participants