C++/WinRT ABI interop improvements#1608
Conversation
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.
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 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. |
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>
|
Is it possible to add an opt-in |
…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>
| 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); |
There was a problem hiding this comment.
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.
| bool operator==(buffered_iterator const& other) const noexcept | ||
| { | ||
| return {}; | ||
| return (m_size == 0) && (other.m_size == 0); |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| Iterator m_iterator{ nullptr }; | ||
| std::array<value_type, buffer_capacity> m_buffer; |
There was a problem hiding this comment.
I wonder what algorithms we will tank now that iterator assignment is not cheap.
| 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{}; |
There was a problem hiding this comment.
``[[no_unique_address]]so thatno_buffer` can disappear?
| inline Windows::Foundation::IAsyncAction make_ready() noexcept | ||
| { | ||
| return make<impl::ready_async_action>(); | ||
| } |
There was a problem hiding this comment.
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>>; |
There was a problem hiding this comment.
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.
Slight nit, it actually does v.GetAt if it's available on ItemVector |
|
Also, fixes #1416 :) |
| WINRT_EXPORT namespace winrt::impl | ||
| { | ||
| template <typename Derived, typename AsyncInterface> | ||
| struct ready_async_base : implements<Derived, AsyncInterface, Windows::Foundation::IAsyncInfo> |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Sure. No particular reason to have it here.
| // 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; |
There was a problem hiding this comment.
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>?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yeah, I can imagine someone using a reverse iterator over fast_iterator wouldn't be too happy about this.
|
|
||
| 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) |
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.
@oldnewthing how do you feel about this compared to |
|
A independent |
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
HSTRINGliterals at compile time.What this PR changes
2eab28a,0adfbeb_hscompile-timeHSTRINGliterala889be0,1f16ab0make_readyfor pre-completed async operations390e118forover non-GetAtcollections (IIterable, mapIKeyValuePair) via oneIIterator::GetManyper blockab1f299IVector/IVectorViewrange-forviaGetManyblock prefetch; random-access +E_BOUNDSpreserved71cc2c6,bee7550,75bf146PropertyValueReference boxing is the largest piece.
box_valueon a scalar previously hopped into combase to allocate a generalIPropertyValuecarrying the full discriminated-union machinery. This branch points the scalarreference_traitsat the in-procimpl::reference<T>that already backs non-scalarIReference<T>, and makes that type a correctIPropertyValue(rightPropertyType, fixedIsNumericScalar, typed getters). To preserve cross-process behavior, stock scalars are markednon_agileand supplyIMarshallazily fromquery_interface_tearoffby building the equivalent combasePropertyValue— 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.
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:
GetRuntimeClassNamenow returns theIReference<T>runtime class name rather thanWindows.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.This branch delivers all five improvements — String, Async, Map, IterateVector, and Reference.
IterateVector81 → 5 ms ties Rust (ab1f299);390e118covers non-GetAtcollections (Map) andab1f299extends 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×).Referenceretains a ~1.2× Rust lead that is structural, not the box shape: cppwinrt's per-object module-lock accounting (alock-prefixed RMW on a process-global at each box's birth/death) plus the CRTmalloc/freelayer vs windows-rs' directHeapAlloc.Testing
New tests:
test/test/reference_boxing.cpp(PropertyType, numeric conversion, round-trip, marshal-by-value viaGetUnmarshalClassparity with combase),test/test/make_ready.cpp,test/test/buffered_iterator.cpp,test/test_cpp20/hstring_literal.cpp. Built Release/x64;reference_boxingpasses all 43 assertions.References
jonwis/perf-bench(via Copilot)