Uh oh!
There was an error while loading. Please reload this page.
Generalize IsBitwiseEquatable to field-wise IEquatable value types - #130723
Generalize IsBitwiseEquatable to field-wise IEquatable value types#130723tannergooding wants to merge 32 commits into
Conversation
Recognize an unmanaged value type that implements IEquatable<T> of self as bitwise-equatable when its Equals is provably a plain field-wise comparison (equivalent to memcmp), including a single forward through a field-wise op_Equality. Implemented in the CoreCLR VM and mirrored in the ILC/NativeAOT intrinsic, with tests covering the ILC scanner. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
InitializeFieldDescs accumulated only `1 << dwLog2FieldSize` into totalDeclaredFieldSize for by-value instance fields, where dwLog2FieldSize is forced to 0. Any struct containing a multi-byte value-type field was therefore always flagged NotTightlyPacked, needlessly pushing ValueType.Equals and GetHashCode onto the reflection slow path. Accumulate the real GetNumInstanceFieldBytes() for by-value fields so the flag accurately reflects whether the declared fields exactly cover the instance size at each level. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A nested value-type field compared through its own IEquatable<F>.Equals is now accepted, recursing per level so a struct-of-structs whose Equals is a plain field-wise comparison is reported bitwise-equatable in both the VM and ILC. MethodTable::IsNotTightlyPacked() cannot gate this: the layout builder accounts a nested value-type field as a single byte, so it flags every value type containing another value type. The scan now computes tight-packing directly from the field offsets and sizes, matching the ILC ComparerIntrinsics.IsTightlyPacked, and the ILC IEquatable path is made self-contained on layout so both sides agree even when a nested type overrides object.Equals. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Azure Pipelines: Successfully started running 3 pipeline(s). 12 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
EgorBo
commented
Jul 14, 2026
just a note: the actual reason I didn't try to do this is the fact the API is not public, so it has very few public-facing ways to hit it, might not worth the complexity |
This would be a pattern matching approach. Since we cache the tightly packed and equatable checks the perf shouldn't be "too bad" and we'll bail out early on anything that trivially fails it, otherwise we'll check per field as part of building a types own check. It then essentially just checks for I didn't want to complicate it too much and I think we can push users towards this shape with an analyzer rather than expanding on it. But should generally be good for most types It doesn't handle inline arrays (that one is already weird because Equals does the wrong thing) as we'd need to match SequenceEquals (doable, but not as important), skips unions, anything with transitive padding, etc. |
tannergooding
commented
Jul 14, 2026
I think now that its "useful" we could consider making it public. |
tannergooding
commented
Jul 14, 2026
I'll kick off mihubot for diffs after CI comes back clean and any early feedback is addressed, if any. |
jkotas
commented
Jul 14, 2026
Does it work for the code auto-generated by Roslyn for records? |
There was a problem hiding this comment.
Pull request overview
This PR enhances RuntimeHelpers.IsBitwiseEquatable<T>() so it can treat unmanaged, tightly-packed value types that implement IEquatable<T> as bitwise-equatable when their Equals(T) is provably a straightforward field-by-field comparison (memcmp-equivalent), rather than relying primarily on a hardcoded allowlist. It also fixes how the VM determines “tightly packed” value types by correctly accounting for by-value field sizes and propagating padding information transitively through nested structs.
Changes:
- CoreCLR: add an IL pattern scanner for
IEquatable<T>.Equals(including common forwarding toop_Equality) and use it to decide bitwise-equatability forIEquatable<T>value types. - CoreCLR: fix and make transitive the “NotTightlyPacked” flag computation by summing true by-value instance sizes and propagating nested padding.
- NativeAOT/ILC: mirror the VM logic in
ComparerIntrinsicsand add targeted unit tests + test assets.
Show a summary per file
| File | Description |
|---|---|
| src/coreclr/vm/methodtablebuilder.cpp | Fixes declared-size accumulation for by-value fields and propagates “not tightly packed” transitively through nested value-type fields. |
| src/coreclr/vm/jitinterface.cpp | Implements VM-side IL scanning for field-wise IEquatable<T>.Equals and integrates it into IsBitwiseEquatable. |
| src/coreclr/vm/corelib.h | Adds binder metadata for IEquatable<T>.Equals. |
| src/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs | Updates ILC intrinsic expansion to use the new field-wise IEquatable<T> scan. |
| src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs | Adds the ILC-side field-wise Equals scanner and a transitive tightly-packed layout check. |
| src/coreclr/tools/aot/ILCompiler.Compiler.Tests/ILCompiler.Compiler.Tests.csproj | Wires in new unit test source + test asset project. |
| src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/EquatableAssets.csproj | New test-asset assembly compiled with optimizations to stabilize the expected IL shapes. |
| src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/BitwiseEquatable.cs | Adds positive/negative struct patterns to exercise the scanner (including nested cases and padding rejection). |
| src/coreclr/tools/aot/ILCompiler.Compiler.Tests/BitwiseEquatableTests.cs | Adds tests validating expected acceptance/rejection of field-wise IEquatable<T>.Equals patterns. |
| src/coreclr/nativeaot/Test.CoreLib/src/Test.CoreLib.csproj | Includes System/IEquatable.cs in Test.CoreLib to support the new test asset assembly. |
| src/coreclr/nativeaot/Test.CoreLib/src/System/IEquatable.cs | Introduces IEquatable<T> definition for the NativeAOT test CoreLib surface. |
Copilot's findings
- Files reviewed: 11/11 changed files
- Comments generated: 2
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
This comment was marked as resolved.
This comment was marked as resolved.
tannergooding
commented
Jul 14, 2026
I think that would need a tweak, because records currently emit: That shouldn't be terribly hard to match, but it'd be slightly nicer if they reused |
…seEquatable Roslyn-generated record structs compare each field via EqualityComparer<F>.Default.Equals(field, other.field) rather than == or F.Equals. Recognize this shape in both the VM and ILC scanners, accepting it only when F is itself bitwise-equatable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ValueType.Equals/GetHashCode throw NotSupportedException for inline arrays, but only via the QCALL entry point and only while the type's CanCompareBits flag is unchecked. The internal helper previously cached 'false' for inline arrays alongside GC/not-tightly-packed types. That was latent until the IsNotTightlyPacked fix let a struct wrapping an inline array be tightly packed, so CanCompareBitsOrUseFastGetHashCode now recurses into the inline array field and caches its flag, permanently suppressing the throw for that type. Return false without caching for inline arrays so the managed fast path keeps routing through the throwing QCALL. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cast bytes to uint32_t before shifting in ReadILToken so decoding a token with the high bit set is well-defined, and correct a stale ILC comment that referenced CanCompareValueTypeBits instead of the field-wise scan. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
tannergooding
commented
Jul 14, 2026
Pushed a few follow-ups:
Generic value types are a noted follow-up: the blocker isn't layout/caching (that's per-instantiation and correct) but that the scanner resolves IL tokens against the open definition, so a Note This comment was drafted by Copilot (AI-generated) on @tannergooding's behalf. |
Check ILReader.HasNext before PeekILOpcode at the top of the scan loop so a malformed or truncated body returns false instead of indexing past the IL. Also drop the unnecessary CS660/CS661 suppressions in the test asset (the only operator == also overrides Equals/GetHashCode). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
tannergooding
commented
Jul 15, 2026
Addressed the latest review feedback and pushed:
All open review threads are resolved. Note This comment was drafted by Copilot. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
The field-wise scan matches Roslyn's optimized IL shape, so the test must be built optimized (Debug otherwise defaults Optimize to false). Guid is a known bitwise-equatable type special-cased by the runtime, so it reports true. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
tannergooding
commented
Aug 28, 2026
Sorry for the automatic tag -- copilot made a bad merge and auto pushed it despite the instructions to not do that, then caught itself and tried to undo its mistake which made things even worse. Should be fixed now. |
There was a problem hiding this comment.
Copilot review overview
Review tier: Lite
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs — TryGetOpEqualityForward uses ILReader.ReadILToken() (which throws… |
Issues resolved since last review (2)
| Severity | Finding |
|---|---|
src/coreclr/gc/env/gcenv.sync.h — CrstStatic.Init now discards the return value from CLRCriticalSection.Initialize(). On Unix,… View resolved comment | |
src/coreclr/inc/staticcontract.h — The PR description/title focuses on generalizing RuntimeHelpers.IsBitwiseEquatable<T>(), but this… View resolved comment |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot review overview
Review tier: Lite
Findings: None
Issues resolved since last review (1)
| Severity | Finding |
|---|---|
src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs — TryGetOpEqualityForward uses ILReader.ReadILToken() (which throws… View resolved comment |
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/tests/Loader/classloader/BitwiseEquatable/BitwiseEquatable.cs:86
- The PR description says the generalized IsBitwiseEquatable logic is limited to “non-generic” value types, but this test suite (and the VM/ILC implementation) explicitly exercises constructed generic value types (e.g., GenPair, GenPair, GenMixed) and expects them to be accepted/rejected per instantiation. Please reconcile this discrepancy (update the PR description / acceptance criteria, or tighten the implementation to match the intended non-generic restriction).
// Generic value types: the exact instantiation must be threaded through field and token
// resolution. A 'T' field is compared via EqualityComparer<T>.Default.Equals (Roslyn cannot
// emit inline '==' for a type parameter), so this also exercises that path per instantiation.
Check<GenPair<int>>(true);
Check<GenPair<Point>>(true);
Check<GenPair<RecTwo>>(true);
Check<GenPair<string>>(false); // reference argument: contains GC pointers
Check<GenPair<float>>(false); // float: Default.Equals is not bitwise
Check<GenMixed<int>>(true); // inline '==' for an int field plus EqualityComparer for T
Check<GenMixed<float>>(false);
Check<GenForwardsToOp<int>>(true);// forwards into a generic op_Equality
Check<GenPadded<int>>(false); // leading byte forces padding before the T field
There was a problem hiding this comment.
Copilot review overview
Review tier: Lite
Findings: None
Suppressed comments (1)
src/libraries/System.Private.CoreLib/src/System/Guid.cs:1090
- This change removes Guid's prior SIMD/bitcast-based equality fast-path and replaces it with 11 scalar field comparisons. Even if the motivation is to make the Equals IL "field-wise" for IsBitwiseEquatable scanning, this risks regressing Guid.Equals/== throughput in hot paths. At minimum, consider forcing inlining of the new Equals body so callers (including operator==/!=) don't pay call overhead, and please ensure perf impact has been measured/considered.
// Field-wise so the runtime can prove Guid is bitwise-equatable (see RuntimeHelpers.IsBitwiseEquatable).
// Equality funnels through Equals; == and != defer to it so this stays the single canonical comparison.
public bool Equals(Guid g) =>
_a == g._a && _b == g._b && _c == g._c && _d == g._d && _e == g._e && _f == g._f &&
_g == g._g && _h == g._h && _i == g._i && _j == g._j && _k == g._k;
Uh oh!
There was an error while loading. Please reload this page.
jkotas
commented
Aug 28, 2026
LGTM otherwise. @MichalStrehovsky Could you please review as well? |
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot review overview
Review tier: Lite
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
src/tests/Loader/classloader/BitwiseEquatable/BitwiseEquatable.cs — The test suite exercises structs containing enum fields, but it doesn’t validate the top-level… |
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/coreclr/vm/jitinterface.cpp:8002
- Enums are no longer treated as bitwise-equatable here:
methodTable->GetInternalCorElementType()isELEMENT_TYPE_VALUETYPEfor all enums, so the newIsBitwiseComparablePrimitive(...)check won’t match and the fallback path will typically return false (enums inherit Enum.Equals/GetHashCode). This is a behavioral/perf regression vs the priormethodTable->IsEnum()special-case, and it also contradicts the nearby comment claiming enums are handled by element type.
StackSArray<MethodDesc*> scannedMethods;
if (IsBitwiseComparablePrimitive(methodTable->GetInternalCorElementType())
|| IsBitwiseEquatable(typeHandle, methodTable, scannedMethods))
src/libraries/System.Private.CoreLib/src/System/Guid.cs:1090
- This change removes the previous vectorized / wide-load
EqualsCorefast path and replaces it with an 11-field scalar comparison. Given Guid equality is a hot primitive, this is a potential perf regression unless there’s evidence the JIT reliably re-vectorizes this shape across target ISAs. It would be good to either (a) include perf data showing no regression, or (b) teach the IsBitwiseEquatable scanner to accept the existing optimized equals shape so Guid can keep its fast path while still being provably bitwise-equatable.
public override bool Equals([NotNullWhen(true)] object? o) => o is Guid g && Equals(g);
// Field-wise so the runtime can prove Guid is bitwise-equatable (see RuntimeHelpers.IsBitwiseEquatable).
// Equality funnels through Equals; == and != defer to it so this stays the single canonical comparison.
public bool Equals(Guid g) =>
_a == g._a && _b == g._b && _c == g._c && _d == g._d && _e == g._e && _f == g._f &&
_g == g._g && _h == g._h && _i == g._i && _j == g._j && _k == g._k;
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
Review tier: Lite
Findings: None
Issues resolved since last review (1)
| Severity | Finding |
|---|---|
src/tests/Loader/classloader/BitwiseEquatable/BitwiseEquatable.cs — The test suite exercises structs containing enum fields, but it doesn’t validate the top-level… View resolved comment |
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/tests/Loader/classloader/BitwiseEquatable/BitwiseEquatable.cs:126
Equals(object)overrides use a non-nullableobjectparameter, which will produce nullability-mismatch warnings (e.g., CS8765) when nullable annotations are enabled (override should matchobject?). Please update these overrides throughout the file to useobject?(and keep the pattern-matching body as-is).
public struct GenMixed<T> : IEquatable<GenMixed<T>>
{
public int X; public T Y;
public bool Equals(GenMixed<T> o) => X == o.X && System.Collections.Generic.EqualityComparer<T>.Default.Equals(Y, o.Y);
public override bool Equals(object o) => o is GenMixed<T> p && Equals(p);
public override int GetHashCode() => 0;
src/libraries/System.Private.CoreLib/src/System/Guid.cs:1090
- This change removes the existing SIMD / memcmp-style equality implementation and replaces it with 11 scalar field comparisons. That’s a potentially significant regression for
Guid.Equals/==, which is widely used. If the intent is purely to make the VM’s field-wise scan succeed, it would be good to either (a) provide benchmark evidence that current JIT codegen is comparable, or (b) consider an approach that preserves the vectorized fast path while still enablingIsBitwiseEquatable<Guid>()(e.g., a targeted special-case in the intrinsic logic for Guid).
public override bool Equals([NotNullWhen(true)] object? o) => o is Guid g && Equals(g);
// Field-wise so the runtime can prove Guid is bitwise-equatable (see RuntimeHelpers.IsBitwiseEquatable).
// Equality funnels through Equals; == and != defer to it so this stays the single canonical comparison.
public bool Equals(Guid g) =>
_a == g._a && _b == g._b && _c == g._c && _d == g._d && _e == g._e && _f == g._f &&
_g == g._g && _h == g._h && _i == g._i && _j == g._j && _k == g._k;
| @@ -7378,29 +7363,609 @@ static bool getILIntrinsicImplementationForInterlocked(MethodDesc * ftn, | |||
| return true; | |||
| } | |||
| bool IsBitwiseEquatable(TypeHandle typeHandle, MethodTable * methodTable) | |||
| namespace | |||
There was a problem hiding this comment.
Can this be in the JIT so that the logic is shared between runtime and AOT compilers?
There was a problem hiding this comment.
I'll see if it can. We don't really have IL scanning like that in the JIT today (at least to my knowledge) and so unsure how complex it will end up
There was a problem hiding this comment.
It is kind of similar to things like impMatchTaskAwaitPattern. It is not very pretty. Places like this could benefit from a more ergonomic IL reading API.



Generalizes
RuntimeHelpers.IsBitwiseEquatable<T>()so that an unmanaged value type which implementsIEquatable<T>of self is reported bitwise-equatable when itsEqualsis provably a plain field-wise comparison (i.e. equivalent tomemcmp), rather than only recognizing a hardcoded set of types.This picks up the thread from #75642 (closed on the premise that we could/should do an IL pattern match instead) and #130644 (which special-cased
Guid/Int128). Instead of maintaining a list, the VM now scans theIEquatable<T>.Equalsbody and accepts it when every instance field is compared exactly once and the results are ANDed, which is what makes the whole comparison amemcmp. A single forward through a field-wiseop_Equality(the very commonEquals(T other) => this == other) is followed as well.The implementation lives in the CoreCLR VM (
getILIntrinsicImplementationForRuntimeHelperscontinues to fold the result to a JIT-time constant) and is mirrored in the ILC/NativeAOTComparerIntrinsicsso both report the same answer.IsBitwiseEquatableis deliberately conservative for this first pass -- the type must be unmanaged (IsReferenceOrContainsReferencesisfalse), non-generic, tightly packed, and not an inline array.Fields accepted by the scan:
==or its ownEquals(both lower to a bit-for-bit compare)IEquatable<F>.Equals, recursing per level so a struct-of-structs is handled when each level is itself field-wisefloat/doubleare excluded -- neither==norEqualsis amemcmpfor them (NaN and signed-zero handling differs).Fixing this exposed a latent
MethodTable::IsNotTightlyPacked()bug.InitializeFieldDescsaccumulated only1 << dwLog2FieldSize(withdwLog2FieldSizeforced to0) for by-value instance fields, so any struct containing a multi-byte value-type field was always flaggedNotTightlyPacked, needlessly pushingValueType.Equals/GetHashCodeonto the reflection slow path. It now accumulates the realGetNumInstanceFieldBytes(), and the flag is transitive so nested padding (e.g.struct S { S2 value; }whereS2has trailing padding) is reflected at every level.Out of scope / deferred:
InlineArrayof a compatibleT(could be handled later)Equalsshapes (SequenceEqualover a span, SIMD, etc.)Test.CoreLib's primitives are bare structs with no
IEquatable<T>, so the ILC unit tests can only exercise the==and nested-Equalsforms; the primitive-.Equalspath is validated in the VM against the real CoreLib. Nosrc/coreclr/jitfiles changed, soclrjit.dllis unchanged and SuperPMI asmdiffs would report nothing by construction; codegen impact only shows up against a re-collected MCH.Marking as draft for early feedback on the approach and the scan's shape.
Note
This PR description and portions of the code/comments were drafted with GitHub Copilot.