Generalize IsBitwiseEquatable to field-wise IEquatable value types - #130723

Open
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable
Open

Generalize IsBitwiseEquatable to field-wise IEquatable value types#130723
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable

Conversation

@tannergooding

Copy link
Copy Markdown
Member

Generalizes RuntimeHelpers.IsBitwiseEquatable<T>() so that an unmanaged value type which implements IEquatable<T> of self is reported bitwise-equatable when its Equals is provably a plain field-wise comparison (i.e. equivalent to memcmp), 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 the IEquatable<T>.Equals body and accepts it when every instance field is compared exactly once and the results are ANDed, which is what makes the whole comparison a memcmp. A single forward through a field-wise op_Equality (the very common Equals(T other) => this == other) is followed as well.

The implementation lives in the CoreCLR VM (getILIntrinsicImplementationForRuntimeHelpers continues to fold the result to a JIT-time constant) and is mirrored in the ILC/NativeAOT ComparerIntrinsics so both report the same answer. IsBitwiseEquatable is deliberately conservative for this first pass -- the type must be unmanaged (IsReferenceOrContainsReferences is false), non-generic, tightly packed, and not an inline array.

Fields accepted by the scan:

  • an integer-like primitive compared with == or its own Equals (both lower to a bit-for-bit compare)
  • a nested value-type field compared through its own IEquatable<F>.Equals, recursing per level so a struct-of-structs is handled when each level is itself field-wise

float/double are excluded -- neither == nor Equals is a memcmp for them (NaN and signed-zero handling differs).


Fixing this exposed a latent MethodTable::IsNotTightlyPacked() bug. InitializeFieldDescs accumulated only 1 << dwLog2FieldSize (with dwLog2FieldSize forced to 0) for by-value instance fields, so any struct containing a multi-byte value-type field was always flagged NotTightlyPacked, needlessly pushing ValueType.Equals/GetHashCode onto the reflection slow path. It now accumulates the real GetNumInstanceFieldBytes(), and the flag is transitive so nested padding (e.g. struct S { S2 value; } where S2 has trailing padding) is reflected at every level.


Out of scope / deferred:

  • InlineArray of a compatible T (could be handled later)
  • fancier Equals shapes (SequenceEqual over 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-Equals forms; the primitive-.Equals path is validated in the VM against the real CoreLib. No src/coreclr/jit files changed, so clrjit.dll is 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.

tannergoodingand others added 3 commits July 14, 2026 12:49
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

Copy link
Copy Markdown
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

Copy link
Copy Markdown
Member

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

@tannergooding

tannergooding commented Jul 14, 2026

Copy link
Copy Markdown
MemberAuthor

CC. @jkotas, @EgorBo

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 field1.Equals(other.field2) && ... being done for each field. It does notably allow field1 == other.field2 for primitives (but not for other types) and handles the common case of Equals(other) => this == other.

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 and for what C# emits for records. (needs to recognize EqualityComparer<T>.Default.Equals for struct records)

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

Copy link
Copy Markdown
MemberAuthor

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

I think now that its "useful" we could consider making it public.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

I'll kick off mihubot for diffs after CI comes back clean and any early feedback is addressed, if any.

@jkotas

Copy link
Copy Markdown
Member

Does it work for the code auto-generated by Roslyn for records?

CopilotAI left a comment

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.

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 to op_Equality) and use it to decide bitwise-equatability for IEquatable<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 ComparerIntrinsics and add targeted unit tests + test assets.
Show a summary per file
FileDescription
src/coreclr/vm/methodtablebuilder.cppFixes declared-size accumulation for by-value fields and propagates “not tightly packed” transitively through nested value-type fields.
src/coreclr/vm/jitinterface.cppImplements VM-side IL scanning for field-wise IEquatable<T>.Equals and integrates it into IsBitwiseEquatable.
src/coreclr/vm/corelib.hAdds binder metadata for IEquatable<T>.Equals.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.csUpdates ILC intrinsic expansion to use the new field-wise IEquatable<T> scan.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.csAdds the ILC-side field-wise Equals scanner and a transitive tightly-packed layout check.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/ILCompiler.Compiler.Tests.csprojWires in new unit test source + test asset project.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/EquatableAssets.csprojNew test-asset assembly compiled with optimizations to stabilize the expected IL shapes.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/BitwiseEquatable.csAdds positive/negative struct patterns to exercise the scanner (including nested cases and padding rejection).
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/BitwiseEquatableTests.csAdds tests validating expected acceptance/rejection of field-wise IEquatable<T>.Equals patterns.
src/coreclr/nativeaot/Test.CoreLib/src/Test.CoreLib.csprojIncludes System/IEquatable.cs in Test.CoreLib to support the new test asset assembly.
src/coreclr/nativeaot/Test.CoreLib/src/System/IEquatable.csIntroduces IEquatable<T> definition for the NativeAOT test CoreLib surface.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/vm/jitinterface.cpp
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
@EgorBo

This comment was marked as resolved.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Does it work for the code auto-generated by Roslyn for records?

I think that would need a tweak, because records currently emit: EqualityComparer<T>.Default.Equals(field1, other.field1)

That shouldn't be terribly hard to match, but it'd be slightly nicer if they reused Equals directly

tannergoodingand others added 3 commits July 14, 2026 14:37
…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>
CopilotAI review requested due to automatic review settings July 14, 2026 23:19

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 12/12 changed files
  • Comments generated: 3

Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Pushed a few follow-ups:

  • Records: now recognizes the EqualityComparer<F>.Default.Equals(this.F, other.F) shape Roslyn emits for record structs, accepted only when F is itself bitwise-equatable (same recursion/float-exclusion as the .Equals call form). Handled in both the VM and ILC scanners.
  • CI fix: the Loader/classloader/InlineArray failure was a real regression from the IsNotTightlyPacked change. Inline arrays throw NotSupportedException from ValueType.Equals/GetHashCode only via the QCALL, and only while the type's CanCompareBits flag is uncached. Once a struct wrapping an inline array was correctly reported tightly-packed, CanCompareBitsOrUseFastGetHashCode began recursing into the inline-array field and caching its flag as false, permanently suppressing the throw. Fix is to return false for inline arrays without caching, so the managed fast path keeps routing through the throwing QCALL -- this also removes a pre-existing ordering dependency where hashing a wrapper first would poison the inner type.
  • Addressed the review nits (unsigned shift in ReadILToken, stale comment).

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 T-typed field/EqualityComparer<!0> comes back as the formal variable rather than the concrete type. Making it work needs instantiation-aware token resolution. InlineArray-of-compatible-T is likewise deferred.

Note

This comment was drafted by Copilot (AI-generated) on @tannergooding's behalf.

tannergoodingand others added 2 commits July 14, 2026 16:53
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>
CopilotAI review requested due to automatic review settings July 15, 2026 00:16
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Addressed the latest review feedback and pushed:

  • ReadILToken now casts each byte to uint32_t before shifting (well-defined token decode).
  • ILC comment updated to reference IsIEquatableEqualsFieldwise/IsTightlyPacked.
  • ILC field-wise scan guards PeekILOpcode() with a HasNext check so a truncated IL stream returns false conservatively.
  • Dropped the unnecessary CS660/CS661 suppressions from the ILC test asset (kept CS0649).
  • Added CoreCLR test coverage under src/tests/Loader/classloader/BitwiseEquatable that calls the internal IsBitwiseEquatable<T>() via reflection and covers the positive/negative cases (field-wise IEquatable<T>, ==-forwarding, nested structs, primitive .Equals, records, and float/padding/ignored-field rejections) plus a tight-packing behavioral check.

All open review threads are resolved.

Note

This comment was drafted by Copilot.

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 14/14 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
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

Copy link
Copy Markdown
MemberAuthor

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.

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Medium severity

New issues introduced by this change (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward uses ILReader.ReadILToken() (which throws…
Issues resolved since last review (2)
SeverityFinding
High severitysrc/​coreclr/​gc/​env/​gcenv.sync.h — CrstStatic.Init now discards the return value from CLRCriticalSection.Initialize(). On Unix,… View resolved comment
Low severitysrc/​coreclr/​inc/​staticcontract.h — The PR description/title focuses on generalizing RuntimeHelpers.IsBitwiseEquatable<T>(), but this… View resolved comment

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/vm/methodtablebuilder.cpp
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 16:53
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward 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

CopilotAI review requested due to automatic review settings August 28, 2026 17:02

CopilotAI left a comment

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.

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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
@jkotas

Copy link
Copy Markdown
Member

LGTM otherwise. @MichalStrehovsky Could you please review as well?

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 22:10

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Low severity

New issues introduced by this change (1)
SeverityFinding
Low severitysrc/​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() is ELEMENT_TYPE_VALUETYPE for all enums, so the new IsBitwiseComparablePrimitive(...) 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 prior methodTable->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 EqualsCore fast 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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 31, 2026 18:53

CopilotAI left a comment

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.

Copilot review overview

🔵 Needs a closer look

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Low severitysrc/​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-nullable object parameter, which will produce nullability-mismatch warnings (e.g., CS8765) when nullable annotations are enabled (override should match object?). Please update these overrides throughout the file to use object? (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 enabling IsBitwiseEquatable<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

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.

Can this be in the JIT so that the logic is shared between runtime and AOT compilers?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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

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.

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.

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIlinkable-frameworkIssues associated with delivering a linker friendly framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants

@tannergooding@EgorBo@jkotas@hez2010@jkoritzinsky@huoyaoyuan@MichalStrehovsky@hamarb123@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Generalize IsBitwiseEquatable to field-wise IEquatable value types - #130723

Open
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable
Open

Generalize IsBitwiseEquatable to field-wise IEquatable value types#130723
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable

Conversation

@tannergooding

Copy link
Copy Markdown
Member

Generalizes RuntimeHelpers.IsBitwiseEquatable<T>() so that an unmanaged value type which implements IEquatable<T> of self is reported bitwise-equatable when its Equals is provably a plain field-wise comparison (i.e. equivalent to memcmp), 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 the IEquatable<T>.Equals body and accepts it when every instance field is compared exactly once and the results are ANDed, which is what makes the whole comparison a memcmp. A single forward through a field-wise op_Equality (the very common Equals(T other) => this == other) is followed as well.

The implementation lives in the CoreCLR VM (getILIntrinsicImplementationForRuntimeHelpers continues to fold the result to a JIT-time constant) and is mirrored in the ILC/NativeAOT ComparerIntrinsics so both report the same answer. IsBitwiseEquatable is deliberately conservative for this first pass -- the type must be unmanaged (IsReferenceOrContainsReferences is false), non-generic, tightly packed, and not an inline array.

Fields accepted by the scan:

  • an integer-like primitive compared with == or its own Equals (both lower to a bit-for-bit compare)
  • a nested value-type field compared through its own IEquatable<F>.Equals, recursing per level so a struct-of-structs is handled when each level is itself field-wise

float/double are excluded -- neither == nor Equals is a memcmp for them (NaN and signed-zero handling differs).


Fixing this exposed a latent MethodTable::IsNotTightlyPacked() bug. InitializeFieldDescs accumulated only 1 << dwLog2FieldSize (with dwLog2FieldSize forced to 0) for by-value instance fields, so any struct containing a multi-byte value-type field was always flagged NotTightlyPacked, needlessly pushing ValueType.Equals/GetHashCode onto the reflection slow path. It now accumulates the real GetNumInstanceFieldBytes(), and the flag is transitive so nested padding (e.g. struct S { S2 value; } where S2 has trailing padding) is reflected at every level.


Out of scope / deferred:

  • InlineArray of a compatible T (could be handled later)
  • fancier Equals shapes (SequenceEqual over 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-Equals forms; the primitive-.Equals path is validated in the VM against the real CoreLib. No src/coreclr/jit files changed, so clrjit.dll is 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.

tannergoodingand others added 3 commits July 14, 2026 12:49
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

Copy link
Copy Markdown
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

Copy link
Copy Markdown
Member

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

@tannergooding

tannergooding commented Jul 14, 2026

Copy link
Copy Markdown
MemberAuthor

CC. @jkotas, @EgorBo

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 field1.Equals(other.field2) && ... being done for each field. It does notably allow field1 == other.field2 for primitives (but not for other types) and handles the common case of Equals(other) => this == other.

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 and for what C# emits for records. (needs to recognize EqualityComparer<T>.Default.Equals for struct records)

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

Copy link
Copy Markdown
MemberAuthor

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

I think now that its "useful" we could consider making it public.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

I'll kick off mihubot for diffs after CI comes back clean and any early feedback is addressed, if any.

@jkotas

Copy link
Copy Markdown
Member

Does it work for the code auto-generated by Roslyn for records?

CopilotAI left a comment

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.

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 to op_Equality) and use it to decide bitwise-equatability for IEquatable<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 ComparerIntrinsics and add targeted unit tests + test assets.
Show a summary per file
FileDescription
src/coreclr/vm/methodtablebuilder.cppFixes declared-size accumulation for by-value fields and propagates “not tightly packed” transitively through nested value-type fields.
src/coreclr/vm/jitinterface.cppImplements VM-side IL scanning for field-wise IEquatable<T>.Equals and integrates it into IsBitwiseEquatable.
src/coreclr/vm/corelib.hAdds binder metadata for IEquatable<T>.Equals.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.csUpdates ILC intrinsic expansion to use the new field-wise IEquatable<T> scan.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.csAdds the ILC-side field-wise Equals scanner and a transitive tightly-packed layout check.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/ILCompiler.Compiler.Tests.csprojWires in new unit test source + test asset project.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/EquatableAssets.csprojNew test-asset assembly compiled with optimizations to stabilize the expected IL shapes.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/BitwiseEquatable.csAdds positive/negative struct patterns to exercise the scanner (including nested cases and padding rejection).
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/BitwiseEquatableTests.csAdds tests validating expected acceptance/rejection of field-wise IEquatable<T>.Equals patterns.
src/coreclr/nativeaot/Test.CoreLib/src/Test.CoreLib.csprojIncludes System/IEquatable.cs in Test.CoreLib to support the new test asset assembly.
src/coreclr/nativeaot/Test.CoreLib/src/System/IEquatable.csIntroduces IEquatable<T> definition for the NativeAOT test CoreLib surface.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/vm/jitinterface.cpp
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
@EgorBo

This comment was marked as resolved.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Does it work for the code auto-generated by Roslyn for records?

I think that would need a tweak, because records currently emit: EqualityComparer<T>.Default.Equals(field1, other.field1)

That shouldn't be terribly hard to match, but it'd be slightly nicer if they reused Equals directly

tannergoodingand others added 3 commits July 14, 2026 14:37
…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>
CopilotAI review requested due to automatic review settings July 14, 2026 23:19

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 12/12 changed files
  • Comments generated: 3

Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Pushed a few follow-ups:

  • Records: now recognizes the EqualityComparer<F>.Default.Equals(this.F, other.F) shape Roslyn emits for record structs, accepted only when F is itself bitwise-equatable (same recursion/float-exclusion as the .Equals call form). Handled in both the VM and ILC scanners.
  • CI fix: the Loader/classloader/InlineArray failure was a real regression from the IsNotTightlyPacked change. Inline arrays throw NotSupportedException from ValueType.Equals/GetHashCode only via the QCALL, and only while the type's CanCompareBits flag is uncached. Once a struct wrapping an inline array was correctly reported tightly-packed, CanCompareBitsOrUseFastGetHashCode began recursing into the inline-array field and caching its flag as false, permanently suppressing the throw. Fix is to return false for inline arrays without caching, so the managed fast path keeps routing through the throwing QCALL -- this also removes a pre-existing ordering dependency where hashing a wrapper first would poison the inner type.
  • Addressed the review nits (unsigned shift in ReadILToken, stale comment).

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 T-typed field/EqualityComparer<!0> comes back as the formal variable rather than the concrete type. Making it work needs instantiation-aware token resolution. InlineArray-of-compatible-T is likewise deferred.

Note

This comment was drafted by Copilot (AI-generated) on @tannergooding's behalf.

tannergoodingand others added 2 commits July 14, 2026 16:53
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>
CopilotAI review requested due to automatic review settings July 15, 2026 00:16
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Addressed the latest review feedback and pushed:

  • ReadILToken now casts each byte to uint32_t before shifting (well-defined token decode).
  • ILC comment updated to reference IsIEquatableEqualsFieldwise/IsTightlyPacked.
  • ILC field-wise scan guards PeekILOpcode() with a HasNext check so a truncated IL stream returns false conservatively.
  • Dropped the unnecessary CS660/CS661 suppressions from the ILC test asset (kept CS0649).
  • Added CoreCLR test coverage under src/tests/Loader/classloader/BitwiseEquatable that calls the internal IsBitwiseEquatable<T>() via reflection and covers the positive/negative cases (field-wise IEquatable<T>, ==-forwarding, nested structs, primitive .Equals, records, and float/padding/ignored-field rejections) plus a tight-packing behavioral check.

All open review threads are resolved.

Note

This comment was drafted by Copilot.

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 14/14 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
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

Copy link
Copy Markdown
MemberAuthor

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.

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Medium severity

New issues introduced by this change (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward uses ILReader.ReadILToken() (which throws…
Issues resolved since last review (2)
SeverityFinding
High severitysrc/​coreclr/​gc/​env/​gcenv.sync.h — CrstStatic.Init now discards the return value from CLRCriticalSection.Initialize(). On Unix,… View resolved comment
Low severitysrc/​coreclr/​inc/​staticcontract.h — The PR description/title focuses on generalizing RuntimeHelpers.IsBitwiseEquatable<T>(), but this… View resolved comment

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/vm/methodtablebuilder.cpp
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 16:53
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward 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

CopilotAI review requested due to automatic review settings August 28, 2026 17:02

CopilotAI left a comment

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.

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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
@jkotas

Copy link
Copy Markdown
Member

LGTM otherwise. @MichalStrehovsky Could you please review as well?

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 22:10

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Low severity

New issues introduced by this change (1)
SeverityFinding
Low severitysrc/​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() is ELEMENT_TYPE_VALUETYPE for all enums, so the new IsBitwiseComparablePrimitive(...) 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 prior methodTable->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 EqualsCore fast 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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 31, 2026 18:53

CopilotAI left a comment

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.

Copilot review overview

🔵 Needs a closer look

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Low severitysrc/​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-nullable object parameter, which will produce nullability-mismatch warnings (e.g., CS8765) when nullable annotations are enabled (override should match object?). Please update these overrides throughout the file to use object? (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 enabling IsBitwiseEquatable<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

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.

Can this be in the JIT so that the logic is shared between runtime and AOT compilers?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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

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.

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.

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIlinkable-frameworkIssues associated with delivering a linker friendly framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants

@tannergooding@EgorBo@jkotas@hez2010@jkoritzinsky@huoyaoyuan@MichalStrehovsky@hamarb123@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Generalize IsBitwiseEquatable to field-wise IEquatable value types - #130723

Open
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable
Open

Generalize IsBitwiseEquatable to field-wise IEquatable value types#130723
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable

Conversation

@tannergooding

Copy link
Copy Markdown
Member

Generalizes RuntimeHelpers.IsBitwiseEquatable<T>() so that an unmanaged value type which implements IEquatable<T> of self is reported bitwise-equatable when its Equals is provably a plain field-wise comparison (i.e. equivalent to memcmp), 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 the IEquatable<T>.Equals body and accepts it when every instance field is compared exactly once and the results are ANDed, which is what makes the whole comparison a memcmp. A single forward through a field-wise op_Equality (the very common Equals(T other) => this == other) is followed as well.

The implementation lives in the CoreCLR VM (getILIntrinsicImplementationForRuntimeHelpers continues to fold the result to a JIT-time constant) and is mirrored in the ILC/NativeAOT ComparerIntrinsics so both report the same answer. IsBitwiseEquatable is deliberately conservative for this first pass -- the type must be unmanaged (IsReferenceOrContainsReferences is false), non-generic, tightly packed, and not an inline array.

Fields accepted by the scan:

  • an integer-like primitive compared with == or its own Equals (both lower to a bit-for-bit compare)
  • a nested value-type field compared through its own IEquatable<F>.Equals, recursing per level so a struct-of-structs is handled when each level is itself field-wise

float/double are excluded -- neither == nor Equals is a memcmp for them (NaN and signed-zero handling differs).


Fixing this exposed a latent MethodTable::IsNotTightlyPacked() bug. InitializeFieldDescs accumulated only 1 << dwLog2FieldSize (with dwLog2FieldSize forced to 0) for by-value instance fields, so any struct containing a multi-byte value-type field was always flagged NotTightlyPacked, needlessly pushing ValueType.Equals/GetHashCode onto the reflection slow path. It now accumulates the real GetNumInstanceFieldBytes(), and the flag is transitive so nested padding (e.g. struct S { S2 value; } where S2 has trailing padding) is reflected at every level.


Out of scope / deferred:

  • InlineArray of a compatible T (could be handled later)
  • fancier Equals shapes (SequenceEqual over 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-Equals forms; the primitive-.Equals path is validated in the VM against the real CoreLib. No src/coreclr/jit files changed, so clrjit.dll is 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.

tannergoodingand others added 3 commits July 14, 2026 12:49
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

Copy link
Copy Markdown
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

Copy link
Copy Markdown
Member

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

@tannergooding

tannergooding commented Jul 14, 2026

Copy link
Copy Markdown
MemberAuthor

CC. @jkotas, @EgorBo

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 field1.Equals(other.field2) && ... being done for each field. It does notably allow field1 == other.field2 for primitives (but not for other types) and handles the common case of Equals(other) => this == other.

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 and for what C# emits for records. (needs to recognize EqualityComparer<T>.Default.Equals for struct records)

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

Copy link
Copy Markdown
MemberAuthor

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

I think now that its "useful" we could consider making it public.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

I'll kick off mihubot for diffs after CI comes back clean and any early feedback is addressed, if any.

@jkotas

Copy link
Copy Markdown
Member

Does it work for the code auto-generated by Roslyn for records?

CopilotAI left a comment

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.

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 to op_Equality) and use it to decide bitwise-equatability for IEquatable<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 ComparerIntrinsics and add targeted unit tests + test assets.
Show a summary per file
FileDescription
src/coreclr/vm/methodtablebuilder.cppFixes declared-size accumulation for by-value fields and propagates “not tightly packed” transitively through nested value-type fields.
src/coreclr/vm/jitinterface.cppImplements VM-side IL scanning for field-wise IEquatable<T>.Equals and integrates it into IsBitwiseEquatable.
src/coreclr/vm/corelib.hAdds binder metadata for IEquatable<T>.Equals.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.csUpdates ILC intrinsic expansion to use the new field-wise IEquatable<T> scan.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.csAdds the ILC-side field-wise Equals scanner and a transitive tightly-packed layout check.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/ILCompiler.Compiler.Tests.csprojWires in new unit test source + test asset project.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/EquatableAssets.csprojNew test-asset assembly compiled with optimizations to stabilize the expected IL shapes.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/BitwiseEquatable.csAdds positive/negative struct patterns to exercise the scanner (including nested cases and padding rejection).
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/BitwiseEquatableTests.csAdds tests validating expected acceptance/rejection of field-wise IEquatable<T>.Equals patterns.
src/coreclr/nativeaot/Test.CoreLib/src/Test.CoreLib.csprojIncludes System/IEquatable.cs in Test.CoreLib to support the new test asset assembly.
src/coreclr/nativeaot/Test.CoreLib/src/System/IEquatable.csIntroduces IEquatable<T> definition for the NativeAOT test CoreLib surface.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/vm/jitinterface.cpp
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
@EgorBo

This comment was marked as resolved.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Does it work for the code auto-generated by Roslyn for records?

I think that would need a tweak, because records currently emit: EqualityComparer<T>.Default.Equals(field1, other.field1)

That shouldn't be terribly hard to match, but it'd be slightly nicer if they reused Equals directly

tannergoodingand others added 3 commits July 14, 2026 14:37
…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>
CopilotAI review requested due to automatic review settings July 14, 2026 23:19

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 12/12 changed files
  • Comments generated: 3

Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Pushed a few follow-ups:

  • Records: now recognizes the EqualityComparer<F>.Default.Equals(this.F, other.F) shape Roslyn emits for record structs, accepted only when F is itself bitwise-equatable (same recursion/float-exclusion as the .Equals call form). Handled in both the VM and ILC scanners.
  • CI fix: the Loader/classloader/InlineArray failure was a real regression from the IsNotTightlyPacked change. Inline arrays throw NotSupportedException from ValueType.Equals/GetHashCode only via the QCALL, and only while the type's CanCompareBits flag is uncached. Once a struct wrapping an inline array was correctly reported tightly-packed, CanCompareBitsOrUseFastGetHashCode began recursing into the inline-array field and caching its flag as false, permanently suppressing the throw. Fix is to return false for inline arrays without caching, so the managed fast path keeps routing through the throwing QCALL -- this also removes a pre-existing ordering dependency where hashing a wrapper first would poison the inner type.
  • Addressed the review nits (unsigned shift in ReadILToken, stale comment).

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 T-typed field/EqualityComparer<!0> comes back as the formal variable rather than the concrete type. Making it work needs instantiation-aware token resolution. InlineArray-of-compatible-T is likewise deferred.

Note

This comment was drafted by Copilot (AI-generated) on @tannergooding's behalf.

tannergoodingand others added 2 commits July 14, 2026 16:53
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>
CopilotAI review requested due to automatic review settings July 15, 2026 00:16
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Addressed the latest review feedback and pushed:

  • ReadILToken now casts each byte to uint32_t before shifting (well-defined token decode).
  • ILC comment updated to reference IsIEquatableEqualsFieldwise/IsTightlyPacked.
  • ILC field-wise scan guards PeekILOpcode() with a HasNext check so a truncated IL stream returns false conservatively.
  • Dropped the unnecessary CS660/CS661 suppressions from the ILC test asset (kept CS0649).
  • Added CoreCLR test coverage under src/tests/Loader/classloader/BitwiseEquatable that calls the internal IsBitwiseEquatable<T>() via reflection and covers the positive/negative cases (field-wise IEquatable<T>, ==-forwarding, nested structs, primitive .Equals, records, and float/padding/ignored-field rejections) plus a tight-packing behavioral check.

All open review threads are resolved.

Note

This comment was drafted by Copilot.

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 14/14 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
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

Copy link
Copy Markdown
MemberAuthor

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.

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Medium severity

New issues introduced by this change (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward uses ILReader.ReadILToken() (which throws…
Issues resolved since last review (2)
SeverityFinding
High severitysrc/​coreclr/​gc/​env/​gcenv.sync.h — CrstStatic.Init now discards the return value from CLRCriticalSection.Initialize(). On Unix,… View resolved comment
Low severitysrc/​coreclr/​inc/​staticcontract.h — The PR description/title focuses on generalizing RuntimeHelpers.IsBitwiseEquatable<T>(), but this… View resolved comment

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/vm/methodtablebuilder.cpp
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 16:53
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward 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

CopilotAI review requested due to automatic review settings August 28, 2026 17:02

CopilotAI left a comment

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.

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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
@jkotas

Copy link
Copy Markdown
Member

LGTM otherwise. @MichalStrehovsky Could you please review as well?

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 22:10

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Low severity

New issues introduced by this change (1)
SeverityFinding
Low severitysrc/​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() is ELEMENT_TYPE_VALUETYPE for all enums, so the new IsBitwiseComparablePrimitive(...) 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 prior methodTable->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 EqualsCore fast 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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 31, 2026 18:53

CopilotAI left a comment

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.

Copilot review overview

🔵 Needs a closer look

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Low severitysrc/​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-nullable object parameter, which will produce nullability-mismatch warnings (e.g., CS8765) when nullable annotations are enabled (override should match object?). Please update these overrides throughout the file to use object? (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 enabling IsBitwiseEquatable<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

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.

Can this be in the JIT so that the logic is shared between runtime and AOT compilers?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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

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.

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.

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIlinkable-frameworkIssues associated with delivering a linker friendly framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants

@tannergooding@EgorBo@jkotas@hez2010@jkoritzinsky@huoyaoyuan@MichalStrehovsky@hamarb123@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Generalize IsBitwiseEquatable to field-wise IEquatable value types - #130723

Open
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable
Open

Generalize IsBitwiseEquatable to field-wise IEquatable value types#130723
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable

Conversation

@tannergooding

Copy link
Copy Markdown
Member

Generalizes RuntimeHelpers.IsBitwiseEquatable<T>() so that an unmanaged value type which implements IEquatable<T> of self is reported bitwise-equatable when its Equals is provably a plain field-wise comparison (i.e. equivalent to memcmp), 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 the IEquatable<T>.Equals body and accepts it when every instance field is compared exactly once and the results are ANDed, which is what makes the whole comparison a memcmp. A single forward through a field-wise op_Equality (the very common Equals(T other) => this == other) is followed as well.

The implementation lives in the CoreCLR VM (getILIntrinsicImplementationForRuntimeHelpers continues to fold the result to a JIT-time constant) and is mirrored in the ILC/NativeAOT ComparerIntrinsics so both report the same answer. IsBitwiseEquatable is deliberately conservative for this first pass -- the type must be unmanaged (IsReferenceOrContainsReferences is false), non-generic, tightly packed, and not an inline array.

Fields accepted by the scan:

  • an integer-like primitive compared with == or its own Equals (both lower to a bit-for-bit compare)
  • a nested value-type field compared through its own IEquatable<F>.Equals, recursing per level so a struct-of-structs is handled when each level is itself field-wise

float/double are excluded -- neither == nor Equals is a memcmp for them (NaN and signed-zero handling differs).


Fixing this exposed a latent MethodTable::IsNotTightlyPacked() bug. InitializeFieldDescs accumulated only 1 << dwLog2FieldSize (with dwLog2FieldSize forced to 0) for by-value instance fields, so any struct containing a multi-byte value-type field was always flagged NotTightlyPacked, needlessly pushing ValueType.Equals/GetHashCode onto the reflection slow path. It now accumulates the real GetNumInstanceFieldBytes(), and the flag is transitive so nested padding (e.g. struct S { S2 value; } where S2 has trailing padding) is reflected at every level.


Out of scope / deferred:

  • InlineArray of a compatible T (could be handled later)
  • fancier Equals shapes (SequenceEqual over 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-Equals forms; the primitive-.Equals path is validated in the VM against the real CoreLib. No src/coreclr/jit files changed, so clrjit.dll is 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.

tannergoodingand others added 3 commits July 14, 2026 12:49
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

Copy link
Copy Markdown
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

Copy link
Copy Markdown
Member

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

@tannergooding

tannergooding commented Jul 14, 2026

Copy link
Copy Markdown
MemberAuthor

CC. @jkotas, @EgorBo

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 field1.Equals(other.field2) && ... being done for each field. It does notably allow field1 == other.field2 for primitives (but not for other types) and handles the common case of Equals(other) => this == other.

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 and for what C# emits for records. (needs to recognize EqualityComparer<T>.Default.Equals for struct records)

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

Copy link
Copy Markdown
MemberAuthor

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

I think now that its "useful" we could consider making it public.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

I'll kick off mihubot for diffs after CI comes back clean and any early feedback is addressed, if any.

@jkotas

Copy link
Copy Markdown
Member

Does it work for the code auto-generated by Roslyn for records?

CopilotAI left a comment

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.

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 to op_Equality) and use it to decide bitwise-equatability for IEquatable<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 ComparerIntrinsics and add targeted unit tests + test assets.
Show a summary per file
FileDescription
src/coreclr/vm/methodtablebuilder.cppFixes declared-size accumulation for by-value fields and propagates “not tightly packed” transitively through nested value-type fields.
src/coreclr/vm/jitinterface.cppImplements VM-side IL scanning for field-wise IEquatable<T>.Equals and integrates it into IsBitwiseEquatable.
src/coreclr/vm/corelib.hAdds binder metadata for IEquatable<T>.Equals.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.csUpdates ILC intrinsic expansion to use the new field-wise IEquatable<T> scan.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.csAdds the ILC-side field-wise Equals scanner and a transitive tightly-packed layout check.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/ILCompiler.Compiler.Tests.csprojWires in new unit test source + test asset project.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/EquatableAssets.csprojNew test-asset assembly compiled with optimizations to stabilize the expected IL shapes.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/BitwiseEquatable.csAdds positive/negative struct patterns to exercise the scanner (including nested cases and padding rejection).
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/BitwiseEquatableTests.csAdds tests validating expected acceptance/rejection of field-wise IEquatable<T>.Equals patterns.
src/coreclr/nativeaot/Test.CoreLib/src/Test.CoreLib.csprojIncludes System/IEquatable.cs in Test.CoreLib to support the new test asset assembly.
src/coreclr/nativeaot/Test.CoreLib/src/System/IEquatable.csIntroduces IEquatable<T> definition for the NativeAOT test CoreLib surface.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/vm/jitinterface.cpp
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
@EgorBo

This comment was marked as resolved.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Does it work for the code auto-generated by Roslyn for records?

I think that would need a tweak, because records currently emit: EqualityComparer<T>.Default.Equals(field1, other.field1)

That shouldn't be terribly hard to match, but it'd be slightly nicer if they reused Equals directly

tannergoodingand others added 3 commits July 14, 2026 14:37
…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>
CopilotAI review requested due to automatic review settings July 14, 2026 23:19

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 12/12 changed files
  • Comments generated: 3

Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Pushed a few follow-ups:

  • Records: now recognizes the EqualityComparer<F>.Default.Equals(this.F, other.F) shape Roslyn emits for record structs, accepted only when F is itself bitwise-equatable (same recursion/float-exclusion as the .Equals call form). Handled in both the VM and ILC scanners.
  • CI fix: the Loader/classloader/InlineArray failure was a real regression from the IsNotTightlyPacked change. Inline arrays throw NotSupportedException from ValueType.Equals/GetHashCode only via the QCALL, and only while the type's CanCompareBits flag is uncached. Once a struct wrapping an inline array was correctly reported tightly-packed, CanCompareBitsOrUseFastGetHashCode began recursing into the inline-array field and caching its flag as false, permanently suppressing the throw. Fix is to return false for inline arrays without caching, so the managed fast path keeps routing through the throwing QCALL -- this also removes a pre-existing ordering dependency where hashing a wrapper first would poison the inner type.
  • Addressed the review nits (unsigned shift in ReadILToken, stale comment).

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 T-typed field/EqualityComparer<!0> comes back as the formal variable rather than the concrete type. Making it work needs instantiation-aware token resolution. InlineArray-of-compatible-T is likewise deferred.

Note

This comment was drafted by Copilot (AI-generated) on @tannergooding's behalf.

tannergoodingand others added 2 commits July 14, 2026 16:53
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>
CopilotAI review requested due to automatic review settings July 15, 2026 00:16
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Addressed the latest review feedback and pushed:

  • ReadILToken now casts each byte to uint32_t before shifting (well-defined token decode).
  • ILC comment updated to reference IsIEquatableEqualsFieldwise/IsTightlyPacked.
  • ILC field-wise scan guards PeekILOpcode() with a HasNext check so a truncated IL stream returns false conservatively.
  • Dropped the unnecessary CS660/CS661 suppressions from the ILC test asset (kept CS0649).
  • Added CoreCLR test coverage under src/tests/Loader/classloader/BitwiseEquatable that calls the internal IsBitwiseEquatable<T>() via reflection and covers the positive/negative cases (field-wise IEquatable<T>, ==-forwarding, nested structs, primitive .Equals, records, and float/padding/ignored-field rejections) plus a tight-packing behavioral check.

All open review threads are resolved.

Note

This comment was drafted by Copilot.

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 14/14 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
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

Copy link
Copy Markdown
MemberAuthor

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.

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Medium severity

New issues introduced by this change (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward uses ILReader.ReadILToken() (which throws…
Issues resolved since last review (2)
SeverityFinding
High severitysrc/​coreclr/​gc/​env/​gcenv.sync.h — CrstStatic.Init now discards the return value from CLRCriticalSection.Initialize(). On Unix,… View resolved comment
Low severitysrc/​coreclr/​inc/​staticcontract.h — The PR description/title focuses on generalizing RuntimeHelpers.IsBitwiseEquatable<T>(), but this… View resolved comment

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/vm/methodtablebuilder.cpp
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 16:53
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward 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

CopilotAI review requested due to automatic review settings August 28, 2026 17:02

CopilotAI left a comment

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.

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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
@jkotas

Copy link
Copy Markdown
Member

LGTM otherwise. @MichalStrehovsky Could you please review as well?

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 22:10

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Low severity

New issues introduced by this change (1)
SeverityFinding
Low severitysrc/​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() is ELEMENT_TYPE_VALUETYPE for all enums, so the new IsBitwiseComparablePrimitive(...) 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 prior methodTable->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 EqualsCore fast 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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 31, 2026 18:53

CopilotAI left a comment

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.

Copilot review overview

🔵 Needs a closer look

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Low severitysrc/​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-nullable object parameter, which will produce nullability-mismatch warnings (e.g., CS8765) when nullable annotations are enabled (override should match object?). Please update these overrides throughout the file to use object? (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 enabling IsBitwiseEquatable<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

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.

Can this be in the JIT so that the logic is shared between runtime and AOT compilers?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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

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.

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.

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIlinkable-frameworkIssues associated with delivering a linker friendly framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants

@tannergooding@EgorBo@jkotas@hez2010@jkoritzinsky@huoyaoyuan@MichalStrehovsky@hamarb123@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Generalize IsBitwiseEquatable to field-wise IEquatable value types - #130723

Open
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable
Open

Generalize IsBitwiseEquatable to field-wise IEquatable value types#130723
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable

Conversation

@tannergooding

Copy link
Copy Markdown
Member

Generalizes RuntimeHelpers.IsBitwiseEquatable<T>() so that an unmanaged value type which implements IEquatable<T> of self is reported bitwise-equatable when its Equals is provably a plain field-wise comparison (i.e. equivalent to memcmp), 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 the IEquatable<T>.Equals body and accepts it when every instance field is compared exactly once and the results are ANDed, which is what makes the whole comparison a memcmp. A single forward through a field-wise op_Equality (the very common Equals(T other) => this == other) is followed as well.

The implementation lives in the CoreCLR VM (getILIntrinsicImplementationForRuntimeHelpers continues to fold the result to a JIT-time constant) and is mirrored in the ILC/NativeAOT ComparerIntrinsics so both report the same answer. IsBitwiseEquatable is deliberately conservative for this first pass -- the type must be unmanaged (IsReferenceOrContainsReferences is false), non-generic, tightly packed, and not an inline array.

Fields accepted by the scan:

  • an integer-like primitive compared with == or its own Equals (both lower to a bit-for-bit compare)
  • a nested value-type field compared through its own IEquatable<F>.Equals, recursing per level so a struct-of-structs is handled when each level is itself field-wise

float/double are excluded -- neither == nor Equals is a memcmp for them (NaN and signed-zero handling differs).


Fixing this exposed a latent MethodTable::IsNotTightlyPacked() bug. InitializeFieldDescs accumulated only 1 << dwLog2FieldSize (with dwLog2FieldSize forced to 0) for by-value instance fields, so any struct containing a multi-byte value-type field was always flagged NotTightlyPacked, needlessly pushing ValueType.Equals/GetHashCode onto the reflection slow path. It now accumulates the real GetNumInstanceFieldBytes(), and the flag is transitive so nested padding (e.g. struct S { S2 value; } where S2 has trailing padding) is reflected at every level.


Out of scope / deferred:

  • InlineArray of a compatible T (could be handled later)
  • fancier Equals shapes (SequenceEqual over 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-Equals forms; the primitive-.Equals path is validated in the VM against the real CoreLib. No src/coreclr/jit files changed, so clrjit.dll is 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.

tannergoodingand others added 3 commits July 14, 2026 12:49
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

Copy link
Copy Markdown
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

Copy link
Copy Markdown
Member

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

@tannergooding

tannergooding commented Jul 14, 2026

Copy link
Copy Markdown
MemberAuthor

CC. @jkotas, @EgorBo

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 field1.Equals(other.field2) && ... being done for each field. It does notably allow field1 == other.field2 for primitives (but not for other types) and handles the common case of Equals(other) => this == other.

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 and for what C# emits for records. (needs to recognize EqualityComparer<T>.Default.Equals for struct records)

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

Copy link
Copy Markdown
MemberAuthor

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

I think now that its "useful" we could consider making it public.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

I'll kick off mihubot for diffs after CI comes back clean and any early feedback is addressed, if any.

@jkotas

Copy link
Copy Markdown
Member

Does it work for the code auto-generated by Roslyn for records?

CopilotAI left a comment

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.

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 to op_Equality) and use it to decide bitwise-equatability for IEquatable<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 ComparerIntrinsics and add targeted unit tests + test assets.
Show a summary per file
FileDescription
src/coreclr/vm/methodtablebuilder.cppFixes declared-size accumulation for by-value fields and propagates “not tightly packed” transitively through nested value-type fields.
src/coreclr/vm/jitinterface.cppImplements VM-side IL scanning for field-wise IEquatable<T>.Equals and integrates it into IsBitwiseEquatable.
src/coreclr/vm/corelib.hAdds binder metadata for IEquatable<T>.Equals.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.csUpdates ILC intrinsic expansion to use the new field-wise IEquatable<T> scan.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.csAdds the ILC-side field-wise Equals scanner and a transitive tightly-packed layout check.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/ILCompiler.Compiler.Tests.csprojWires in new unit test source + test asset project.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/EquatableAssets.csprojNew test-asset assembly compiled with optimizations to stabilize the expected IL shapes.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/BitwiseEquatable.csAdds positive/negative struct patterns to exercise the scanner (including nested cases and padding rejection).
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/BitwiseEquatableTests.csAdds tests validating expected acceptance/rejection of field-wise IEquatable<T>.Equals patterns.
src/coreclr/nativeaot/Test.CoreLib/src/Test.CoreLib.csprojIncludes System/IEquatable.cs in Test.CoreLib to support the new test asset assembly.
src/coreclr/nativeaot/Test.CoreLib/src/System/IEquatable.csIntroduces IEquatable<T> definition for the NativeAOT test CoreLib surface.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/vm/jitinterface.cpp
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
@EgorBo

This comment was marked as resolved.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Does it work for the code auto-generated by Roslyn for records?

I think that would need a tweak, because records currently emit: EqualityComparer<T>.Default.Equals(field1, other.field1)

That shouldn't be terribly hard to match, but it'd be slightly nicer if they reused Equals directly

tannergoodingand others added 3 commits July 14, 2026 14:37
…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>
CopilotAI review requested due to automatic review settings July 14, 2026 23:19

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 12/12 changed files
  • Comments generated: 3

Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Pushed a few follow-ups:

  • Records: now recognizes the EqualityComparer<F>.Default.Equals(this.F, other.F) shape Roslyn emits for record structs, accepted only when F is itself bitwise-equatable (same recursion/float-exclusion as the .Equals call form). Handled in both the VM and ILC scanners.
  • CI fix: the Loader/classloader/InlineArray failure was a real regression from the IsNotTightlyPacked change. Inline arrays throw NotSupportedException from ValueType.Equals/GetHashCode only via the QCALL, and only while the type's CanCompareBits flag is uncached. Once a struct wrapping an inline array was correctly reported tightly-packed, CanCompareBitsOrUseFastGetHashCode began recursing into the inline-array field and caching its flag as false, permanently suppressing the throw. Fix is to return false for inline arrays without caching, so the managed fast path keeps routing through the throwing QCALL -- this also removes a pre-existing ordering dependency where hashing a wrapper first would poison the inner type.
  • Addressed the review nits (unsigned shift in ReadILToken, stale comment).

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 T-typed field/EqualityComparer<!0> comes back as the formal variable rather than the concrete type. Making it work needs instantiation-aware token resolution. InlineArray-of-compatible-T is likewise deferred.

Note

This comment was drafted by Copilot (AI-generated) on @tannergooding's behalf.

tannergoodingand others added 2 commits July 14, 2026 16:53
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>
CopilotAI review requested due to automatic review settings July 15, 2026 00:16
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Addressed the latest review feedback and pushed:

  • ReadILToken now casts each byte to uint32_t before shifting (well-defined token decode).
  • ILC comment updated to reference IsIEquatableEqualsFieldwise/IsTightlyPacked.
  • ILC field-wise scan guards PeekILOpcode() with a HasNext check so a truncated IL stream returns false conservatively.
  • Dropped the unnecessary CS660/CS661 suppressions from the ILC test asset (kept CS0649).
  • Added CoreCLR test coverage under src/tests/Loader/classloader/BitwiseEquatable that calls the internal IsBitwiseEquatable<T>() via reflection and covers the positive/negative cases (field-wise IEquatable<T>, ==-forwarding, nested structs, primitive .Equals, records, and float/padding/ignored-field rejections) plus a tight-packing behavioral check.

All open review threads are resolved.

Note

This comment was drafted by Copilot.

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 14/14 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
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

Copy link
Copy Markdown
MemberAuthor

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.

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Medium severity

New issues introduced by this change (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward uses ILReader.ReadILToken() (which throws…
Issues resolved since last review (2)
SeverityFinding
High severitysrc/​coreclr/​gc/​env/​gcenv.sync.h — CrstStatic.Init now discards the return value from CLRCriticalSection.Initialize(). On Unix,… View resolved comment
Low severitysrc/​coreclr/​inc/​staticcontract.h — The PR description/title focuses on generalizing RuntimeHelpers.IsBitwiseEquatable<T>(), but this… View resolved comment

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/vm/methodtablebuilder.cpp
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 16:53
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward 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

CopilotAI review requested due to automatic review settings August 28, 2026 17:02

CopilotAI left a comment

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.

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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
@jkotas

Copy link
Copy Markdown
Member

LGTM otherwise. @MichalStrehovsky Could you please review as well?

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 22:10

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Low severity

New issues introduced by this change (1)
SeverityFinding
Low severitysrc/​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() is ELEMENT_TYPE_VALUETYPE for all enums, so the new IsBitwiseComparablePrimitive(...) 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 prior methodTable->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 EqualsCore fast 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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 31, 2026 18:53

CopilotAI left a comment

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.

Copilot review overview

🔵 Needs a closer look

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Low severitysrc/​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-nullable object parameter, which will produce nullability-mismatch warnings (e.g., CS8765) when nullable annotations are enabled (override should match object?). Please update these overrides throughout the file to use object? (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 enabling IsBitwiseEquatable<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

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.

Can this be in the JIT so that the logic is shared between runtime and AOT compilers?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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

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.

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.

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIlinkable-frameworkIssues associated with delivering a linker friendly framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants

@tannergooding@EgorBo@jkotas@hez2010@jkoritzinsky@huoyaoyuan@MichalStrehovsky@hamarb123@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Generalize IsBitwiseEquatable to field-wise IEquatable value types - #130723

Open
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable
Open

Generalize IsBitwiseEquatable to field-wise IEquatable value types#130723
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable

Conversation

@tannergooding

Copy link
Copy Markdown
Member

Generalizes RuntimeHelpers.IsBitwiseEquatable<T>() so that an unmanaged value type which implements IEquatable<T> of self is reported bitwise-equatable when its Equals is provably a plain field-wise comparison (i.e. equivalent to memcmp), 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 the IEquatable<T>.Equals body and accepts it when every instance field is compared exactly once and the results are ANDed, which is what makes the whole comparison a memcmp. A single forward through a field-wise op_Equality (the very common Equals(T other) => this == other) is followed as well.

The implementation lives in the CoreCLR VM (getILIntrinsicImplementationForRuntimeHelpers continues to fold the result to a JIT-time constant) and is mirrored in the ILC/NativeAOT ComparerIntrinsics so both report the same answer. IsBitwiseEquatable is deliberately conservative for this first pass -- the type must be unmanaged (IsReferenceOrContainsReferences is false), non-generic, tightly packed, and not an inline array.

Fields accepted by the scan:

  • an integer-like primitive compared with == or its own Equals (both lower to a bit-for-bit compare)
  • a nested value-type field compared through its own IEquatable<F>.Equals, recursing per level so a struct-of-structs is handled when each level is itself field-wise

float/double are excluded -- neither == nor Equals is a memcmp for them (NaN and signed-zero handling differs).


Fixing this exposed a latent MethodTable::IsNotTightlyPacked() bug. InitializeFieldDescs accumulated only 1 << dwLog2FieldSize (with dwLog2FieldSize forced to 0) for by-value instance fields, so any struct containing a multi-byte value-type field was always flagged NotTightlyPacked, needlessly pushing ValueType.Equals/GetHashCode onto the reflection slow path. It now accumulates the real GetNumInstanceFieldBytes(), and the flag is transitive so nested padding (e.g. struct S { S2 value; } where S2 has trailing padding) is reflected at every level.


Out of scope / deferred:

  • InlineArray of a compatible T (could be handled later)
  • fancier Equals shapes (SequenceEqual over 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-Equals forms; the primitive-.Equals path is validated in the VM against the real CoreLib. No src/coreclr/jit files changed, so clrjit.dll is 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.

tannergoodingand others added 3 commits July 14, 2026 12:49
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

Copy link
Copy Markdown
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

Copy link
Copy Markdown
Member

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

@tannergooding

tannergooding commented Jul 14, 2026

Copy link
Copy Markdown
MemberAuthor

CC. @jkotas, @EgorBo

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 field1.Equals(other.field2) && ... being done for each field. It does notably allow field1 == other.field2 for primitives (but not for other types) and handles the common case of Equals(other) => this == other.

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 and for what C# emits for records. (needs to recognize EqualityComparer<T>.Default.Equals for struct records)

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

Copy link
Copy Markdown
MemberAuthor

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

I think now that its "useful" we could consider making it public.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

I'll kick off mihubot for diffs after CI comes back clean and any early feedback is addressed, if any.

@jkotas

Copy link
Copy Markdown
Member

Does it work for the code auto-generated by Roslyn for records?

CopilotAI left a comment

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.

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 to op_Equality) and use it to decide bitwise-equatability for IEquatable<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 ComparerIntrinsics and add targeted unit tests + test assets.
Show a summary per file
FileDescription
src/coreclr/vm/methodtablebuilder.cppFixes declared-size accumulation for by-value fields and propagates “not tightly packed” transitively through nested value-type fields.
src/coreclr/vm/jitinterface.cppImplements VM-side IL scanning for field-wise IEquatable<T>.Equals and integrates it into IsBitwiseEquatable.
src/coreclr/vm/corelib.hAdds binder metadata for IEquatable<T>.Equals.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.csUpdates ILC intrinsic expansion to use the new field-wise IEquatable<T> scan.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.csAdds the ILC-side field-wise Equals scanner and a transitive tightly-packed layout check.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/ILCompiler.Compiler.Tests.csprojWires in new unit test source + test asset project.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/EquatableAssets.csprojNew test-asset assembly compiled with optimizations to stabilize the expected IL shapes.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/BitwiseEquatable.csAdds positive/negative struct patterns to exercise the scanner (including nested cases and padding rejection).
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/BitwiseEquatableTests.csAdds tests validating expected acceptance/rejection of field-wise IEquatable<T>.Equals patterns.
src/coreclr/nativeaot/Test.CoreLib/src/Test.CoreLib.csprojIncludes System/IEquatable.cs in Test.CoreLib to support the new test asset assembly.
src/coreclr/nativeaot/Test.CoreLib/src/System/IEquatable.csIntroduces IEquatable<T> definition for the NativeAOT test CoreLib surface.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/vm/jitinterface.cpp
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
@EgorBo

This comment was marked as resolved.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Does it work for the code auto-generated by Roslyn for records?

I think that would need a tweak, because records currently emit: EqualityComparer<T>.Default.Equals(field1, other.field1)

That shouldn't be terribly hard to match, but it'd be slightly nicer if they reused Equals directly

tannergoodingand others added 3 commits July 14, 2026 14:37
…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>
CopilotAI review requested due to automatic review settings July 14, 2026 23:19

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 12/12 changed files
  • Comments generated: 3

Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Pushed a few follow-ups:

  • Records: now recognizes the EqualityComparer<F>.Default.Equals(this.F, other.F) shape Roslyn emits for record structs, accepted only when F is itself bitwise-equatable (same recursion/float-exclusion as the .Equals call form). Handled in both the VM and ILC scanners.
  • CI fix: the Loader/classloader/InlineArray failure was a real regression from the IsNotTightlyPacked change. Inline arrays throw NotSupportedException from ValueType.Equals/GetHashCode only via the QCALL, and only while the type's CanCompareBits flag is uncached. Once a struct wrapping an inline array was correctly reported tightly-packed, CanCompareBitsOrUseFastGetHashCode began recursing into the inline-array field and caching its flag as false, permanently suppressing the throw. Fix is to return false for inline arrays without caching, so the managed fast path keeps routing through the throwing QCALL -- this also removes a pre-existing ordering dependency where hashing a wrapper first would poison the inner type.
  • Addressed the review nits (unsigned shift in ReadILToken, stale comment).

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 T-typed field/EqualityComparer<!0> comes back as the formal variable rather than the concrete type. Making it work needs instantiation-aware token resolution. InlineArray-of-compatible-T is likewise deferred.

Note

This comment was drafted by Copilot (AI-generated) on @tannergooding's behalf.

tannergoodingand others added 2 commits July 14, 2026 16:53
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>
CopilotAI review requested due to automatic review settings July 15, 2026 00:16
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Addressed the latest review feedback and pushed:

  • ReadILToken now casts each byte to uint32_t before shifting (well-defined token decode).
  • ILC comment updated to reference IsIEquatableEqualsFieldwise/IsTightlyPacked.
  • ILC field-wise scan guards PeekILOpcode() with a HasNext check so a truncated IL stream returns false conservatively.
  • Dropped the unnecessary CS660/CS661 suppressions from the ILC test asset (kept CS0649).
  • Added CoreCLR test coverage under src/tests/Loader/classloader/BitwiseEquatable that calls the internal IsBitwiseEquatable<T>() via reflection and covers the positive/negative cases (field-wise IEquatable<T>, ==-forwarding, nested structs, primitive .Equals, records, and float/padding/ignored-field rejections) plus a tight-packing behavioral check.

All open review threads are resolved.

Note

This comment was drafted by Copilot.

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 14/14 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
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

Copy link
Copy Markdown
MemberAuthor

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.

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Medium severity

New issues introduced by this change (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward uses ILReader.ReadILToken() (which throws…
Issues resolved since last review (2)
SeverityFinding
High severitysrc/​coreclr/​gc/​env/​gcenv.sync.h — CrstStatic.Init now discards the return value from CLRCriticalSection.Initialize(). On Unix,… View resolved comment
Low severitysrc/​coreclr/​inc/​staticcontract.h — The PR description/title focuses on generalizing RuntimeHelpers.IsBitwiseEquatable<T>(), but this… View resolved comment

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/vm/methodtablebuilder.cpp
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 16:53
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward 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

CopilotAI review requested due to automatic review settings August 28, 2026 17:02

CopilotAI left a comment

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.

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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
@jkotas

Copy link
Copy Markdown
Member

LGTM otherwise. @MichalStrehovsky Could you please review as well?

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 22:10

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Low severity

New issues introduced by this change (1)
SeverityFinding
Low severitysrc/​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() is ELEMENT_TYPE_VALUETYPE for all enums, so the new IsBitwiseComparablePrimitive(...) 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 prior methodTable->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 EqualsCore fast 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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 31, 2026 18:53

CopilotAI left a comment

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.

Copilot review overview

🔵 Needs a closer look

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Low severitysrc/​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-nullable object parameter, which will produce nullability-mismatch warnings (e.g., CS8765) when nullable annotations are enabled (override should match object?). Please update these overrides throughout the file to use object? (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 enabling IsBitwiseEquatable<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

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.

Can this be in the JIT so that the logic is shared between runtime and AOT compilers?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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

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.

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.

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIlinkable-frameworkIssues associated with delivering a linker friendly framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants

@tannergooding@EgorBo@jkotas@hez2010@jkoritzinsky@huoyaoyuan@MichalStrehovsky@hamarb123@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Generalize IsBitwiseEquatable to field-wise IEquatable value types - #130723

Open
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable
Open

Generalize IsBitwiseEquatable to field-wise IEquatable value types#130723
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable

Conversation

@tannergooding

Copy link
Copy Markdown
Member

Generalizes RuntimeHelpers.IsBitwiseEquatable<T>() so that an unmanaged value type which implements IEquatable<T> of self is reported bitwise-equatable when its Equals is provably a plain field-wise comparison (i.e. equivalent to memcmp), 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 the IEquatable<T>.Equals body and accepts it when every instance field is compared exactly once and the results are ANDed, which is what makes the whole comparison a memcmp. A single forward through a field-wise op_Equality (the very common Equals(T other) => this == other) is followed as well.

The implementation lives in the CoreCLR VM (getILIntrinsicImplementationForRuntimeHelpers continues to fold the result to a JIT-time constant) and is mirrored in the ILC/NativeAOT ComparerIntrinsics so both report the same answer. IsBitwiseEquatable is deliberately conservative for this first pass -- the type must be unmanaged (IsReferenceOrContainsReferences is false), non-generic, tightly packed, and not an inline array.

Fields accepted by the scan:

  • an integer-like primitive compared with == or its own Equals (both lower to a bit-for-bit compare)
  • a nested value-type field compared through its own IEquatable<F>.Equals, recursing per level so a struct-of-structs is handled when each level is itself field-wise

float/double are excluded -- neither == nor Equals is a memcmp for them (NaN and signed-zero handling differs).


Fixing this exposed a latent MethodTable::IsNotTightlyPacked() bug. InitializeFieldDescs accumulated only 1 << dwLog2FieldSize (with dwLog2FieldSize forced to 0) for by-value instance fields, so any struct containing a multi-byte value-type field was always flagged NotTightlyPacked, needlessly pushing ValueType.Equals/GetHashCode onto the reflection slow path. It now accumulates the real GetNumInstanceFieldBytes(), and the flag is transitive so nested padding (e.g. struct S { S2 value; } where S2 has trailing padding) is reflected at every level.


Out of scope / deferred:

  • InlineArray of a compatible T (could be handled later)
  • fancier Equals shapes (SequenceEqual over 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-Equals forms; the primitive-.Equals path is validated in the VM against the real CoreLib. No src/coreclr/jit files changed, so clrjit.dll is 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.

tannergoodingand others added 3 commits July 14, 2026 12:49
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

Copy link
Copy Markdown
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

Copy link
Copy Markdown
Member

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

@tannergooding

tannergooding commented Jul 14, 2026

Copy link
Copy Markdown
MemberAuthor

CC. @jkotas, @EgorBo

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 field1.Equals(other.field2) && ... being done for each field. It does notably allow field1 == other.field2 for primitives (but not for other types) and handles the common case of Equals(other) => this == other.

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 and for what C# emits for records. (needs to recognize EqualityComparer<T>.Default.Equals for struct records)

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

Copy link
Copy Markdown
MemberAuthor

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

I think now that its "useful" we could consider making it public.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

I'll kick off mihubot for diffs after CI comes back clean and any early feedback is addressed, if any.

@jkotas

Copy link
Copy Markdown
Member

Does it work for the code auto-generated by Roslyn for records?

CopilotAI left a comment

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.

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 to op_Equality) and use it to decide bitwise-equatability for IEquatable<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 ComparerIntrinsics and add targeted unit tests + test assets.
Show a summary per file
FileDescription
src/coreclr/vm/methodtablebuilder.cppFixes declared-size accumulation for by-value fields and propagates “not tightly packed” transitively through nested value-type fields.
src/coreclr/vm/jitinterface.cppImplements VM-side IL scanning for field-wise IEquatable<T>.Equals and integrates it into IsBitwiseEquatable.
src/coreclr/vm/corelib.hAdds binder metadata for IEquatable<T>.Equals.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.csUpdates ILC intrinsic expansion to use the new field-wise IEquatable<T> scan.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.csAdds the ILC-side field-wise Equals scanner and a transitive tightly-packed layout check.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/ILCompiler.Compiler.Tests.csprojWires in new unit test source + test asset project.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/EquatableAssets.csprojNew test-asset assembly compiled with optimizations to stabilize the expected IL shapes.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/BitwiseEquatable.csAdds positive/negative struct patterns to exercise the scanner (including nested cases and padding rejection).
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/BitwiseEquatableTests.csAdds tests validating expected acceptance/rejection of field-wise IEquatable<T>.Equals patterns.
src/coreclr/nativeaot/Test.CoreLib/src/Test.CoreLib.csprojIncludes System/IEquatable.cs in Test.CoreLib to support the new test asset assembly.
src/coreclr/nativeaot/Test.CoreLib/src/System/IEquatable.csIntroduces IEquatable<T> definition for the NativeAOT test CoreLib surface.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/vm/jitinterface.cpp
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
@EgorBo

This comment was marked as resolved.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Does it work for the code auto-generated by Roslyn for records?

I think that would need a tweak, because records currently emit: EqualityComparer<T>.Default.Equals(field1, other.field1)

That shouldn't be terribly hard to match, but it'd be slightly nicer if they reused Equals directly

tannergoodingand others added 3 commits July 14, 2026 14:37
…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>
CopilotAI review requested due to automatic review settings July 14, 2026 23:19

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 12/12 changed files
  • Comments generated: 3

Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Pushed a few follow-ups:

  • Records: now recognizes the EqualityComparer<F>.Default.Equals(this.F, other.F) shape Roslyn emits for record structs, accepted only when F is itself bitwise-equatable (same recursion/float-exclusion as the .Equals call form). Handled in both the VM and ILC scanners.
  • CI fix: the Loader/classloader/InlineArray failure was a real regression from the IsNotTightlyPacked change. Inline arrays throw NotSupportedException from ValueType.Equals/GetHashCode only via the QCALL, and only while the type's CanCompareBits flag is uncached. Once a struct wrapping an inline array was correctly reported tightly-packed, CanCompareBitsOrUseFastGetHashCode began recursing into the inline-array field and caching its flag as false, permanently suppressing the throw. Fix is to return false for inline arrays without caching, so the managed fast path keeps routing through the throwing QCALL -- this also removes a pre-existing ordering dependency where hashing a wrapper first would poison the inner type.
  • Addressed the review nits (unsigned shift in ReadILToken, stale comment).

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 T-typed field/EqualityComparer<!0> comes back as the formal variable rather than the concrete type. Making it work needs instantiation-aware token resolution. InlineArray-of-compatible-T is likewise deferred.

Note

This comment was drafted by Copilot (AI-generated) on @tannergooding's behalf.

tannergoodingand others added 2 commits July 14, 2026 16:53
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>
CopilotAI review requested due to automatic review settings July 15, 2026 00:16
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Addressed the latest review feedback and pushed:

  • ReadILToken now casts each byte to uint32_t before shifting (well-defined token decode).
  • ILC comment updated to reference IsIEquatableEqualsFieldwise/IsTightlyPacked.
  • ILC field-wise scan guards PeekILOpcode() with a HasNext check so a truncated IL stream returns false conservatively.
  • Dropped the unnecessary CS660/CS661 suppressions from the ILC test asset (kept CS0649).
  • Added CoreCLR test coverage under src/tests/Loader/classloader/BitwiseEquatable that calls the internal IsBitwiseEquatable<T>() via reflection and covers the positive/negative cases (field-wise IEquatable<T>, ==-forwarding, nested structs, primitive .Equals, records, and float/padding/ignored-field rejections) plus a tight-packing behavioral check.

All open review threads are resolved.

Note

This comment was drafted by Copilot.

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 14/14 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
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

Copy link
Copy Markdown
MemberAuthor

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.

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Medium severity

New issues introduced by this change (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward uses ILReader.ReadILToken() (which throws…
Issues resolved since last review (2)
SeverityFinding
High severitysrc/​coreclr/​gc/​env/​gcenv.sync.h — CrstStatic.Init now discards the return value from CLRCriticalSection.Initialize(). On Unix,… View resolved comment
Low severitysrc/​coreclr/​inc/​staticcontract.h — The PR description/title focuses on generalizing RuntimeHelpers.IsBitwiseEquatable<T>(), but this… View resolved comment

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/vm/methodtablebuilder.cpp
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 16:53
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward 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

CopilotAI review requested due to automatic review settings August 28, 2026 17:02

CopilotAI left a comment

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.

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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
@jkotas

Copy link
Copy Markdown
Member

LGTM otherwise. @MichalStrehovsky Could you please review as well?

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 22:10

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Low severity

New issues introduced by this change (1)
SeverityFinding
Low severitysrc/​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() is ELEMENT_TYPE_VALUETYPE for all enums, so the new IsBitwiseComparablePrimitive(...) 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 prior methodTable->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 EqualsCore fast 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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 31, 2026 18:53

CopilotAI left a comment

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.

Copilot review overview

🔵 Needs a closer look

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Low severitysrc/​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-nullable object parameter, which will produce nullability-mismatch warnings (e.g., CS8765) when nullable annotations are enabled (override should match object?). Please update these overrides throughout the file to use object? (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 enabling IsBitwiseEquatable<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

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.

Can this be in the JIT so that the logic is shared between runtime and AOT compilers?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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

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.

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.

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIlinkable-frameworkIssues associated with delivering a linker friendly framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants

@tannergooding@EgorBo@jkotas@hez2010@jkoritzinsky@huoyaoyuan@MichalStrehovsky@hamarb123@MichalPetryka
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Generalize IsBitwiseEquatable to field-wise IEquatable value types - #130723

Open
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable
Open

Generalize IsBitwiseEquatable to field-wise IEquatable value types#130723
tannergooding wants to merge 32 commits into
dotnet:mainfrom
tannergooding:tannergooding-runtimehelpers-isbitwiseequatable

Conversation

@tannergooding

Copy link
Copy Markdown
Member

Generalizes RuntimeHelpers.IsBitwiseEquatable<T>() so that an unmanaged value type which implements IEquatable<T> of self is reported bitwise-equatable when its Equals is provably a plain field-wise comparison (i.e. equivalent to memcmp), 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 the IEquatable<T>.Equals body and accepts it when every instance field is compared exactly once and the results are ANDed, which is what makes the whole comparison a memcmp. A single forward through a field-wise op_Equality (the very common Equals(T other) => this == other) is followed as well.

The implementation lives in the CoreCLR VM (getILIntrinsicImplementationForRuntimeHelpers continues to fold the result to a JIT-time constant) and is mirrored in the ILC/NativeAOT ComparerIntrinsics so both report the same answer. IsBitwiseEquatable is deliberately conservative for this first pass -- the type must be unmanaged (IsReferenceOrContainsReferences is false), non-generic, tightly packed, and not an inline array.

Fields accepted by the scan:

  • an integer-like primitive compared with == or its own Equals (both lower to a bit-for-bit compare)
  • a nested value-type field compared through its own IEquatable<F>.Equals, recursing per level so a struct-of-structs is handled when each level is itself field-wise

float/double are excluded -- neither == nor Equals is a memcmp for them (NaN and signed-zero handling differs).


Fixing this exposed a latent MethodTable::IsNotTightlyPacked() bug. InitializeFieldDescs accumulated only 1 << dwLog2FieldSize (with dwLog2FieldSize forced to 0) for by-value instance fields, so any struct containing a multi-byte value-type field was always flagged NotTightlyPacked, needlessly pushing ValueType.Equals/GetHashCode onto the reflection slow path. It now accumulates the real GetNumInstanceFieldBytes(), and the flag is transitive so nested padding (e.g. struct S { S2 value; } where S2 has trailing padding) is reflected at every level.


Out of scope / deferred:

  • InlineArray of a compatible T (could be handled later)
  • fancier Equals shapes (SequenceEqual over 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-Equals forms; the primitive-.Equals path is validated in the VM against the real CoreLib. No src/coreclr/jit files changed, so clrjit.dll is 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.

tannergoodingand others added 3 commits July 14, 2026 12:49
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

Copy link
Copy Markdown
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

Copy link
Copy Markdown
Member

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

@tannergooding

tannergooding commented Jul 14, 2026

Copy link
Copy Markdown
MemberAuthor

CC. @jkotas, @EgorBo

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 field1.Equals(other.field2) && ... being done for each field. It does notably allow field1 == other.field2 for primitives (but not for other types) and handles the common case of Equals(other) => this == other.

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 and for what C# emits for records. (needs to recognize EqualityComparer<T>.Default.Equals for struct records)

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

Copy link
Copy Markdown
MemberAuthor

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

I think now that its "useful" we could consider making it public.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

I'll kick off mihubot for diffs after CI comes back clean and any early feedback is addressed, if any.

@jkotas

Copy link
Copy Markdown
Member

Does it work for the code auto-generated by Roslyn for records?

CopilotAI left a comment

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.

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 to op_Equality) and use it to decide bitwise-equatability for IEquatable<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 ComparerIntrinsics and add targeted unit tests + test assets.
Show a summary per file
FileDescription
src/coreclr/vm/methodtablebuilder.cppFixes declared-size accumulation for by-value fields and propagates “not tightly packed” transitively through nested value-type fields.
src/coreclr/vm/jitinterface.cppImplements VM-side IL scanning for field-wise IEquatable<T>.Equals and integrates it into IsBitwiseEquatable.
src/coreclr/vm/corelib.hAdds binder metadata for IEquatable<T>.Equals.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.csUpdates ILC intrinsic expansion to use the new field-wise IEquatable<T> scan.
src/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.csAdds the ILC-side field-wise Equals scanner and a transitive tightly-packed layout check.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/ILCompiler.Compiler.Tests.csprojWires in new unit test source + test asset project.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/EquatableAssets.csprojNew test-asset assembly compiled with optimizations to stabilize the expected IL shapes.
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/EquatableAssets/BitwiseEquatable.csAdds positive/negative struct patterns to exercise the scanner (including nested cases and padding rejection).
src/coreclr/tools/aot/ILCompiler.Compiler.Tests/BitwiseEquatableTests.csAdds tests validating expected acceptance/rejection of field-wise IEquatable<T>.Equals patterns.
src/coreclr/nativeaot/Test.CoreLib/src/Test.CoreLib.csprojIncludes System/IEquatable.cs in Test.CoreLib to support the new test asset assembly.
src/coreclr/nativeaot/Test.CoreLib/src/System/IEquatable.csIntroduces IEquatable<T> definition for the NativeAOT test CoreLib surface.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/vm/jitinterface.cpp
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
@EgorBo

This comment was marked as resolved.

@tannergooding

Copy link
Copy Markdown
MemberAuthor

Does it work for the code auto-generated by Roslyn for records?

I think that would need a tweak, because records currently emit: EqualityComparer<T>.Default.Equals(field1, other.field1)

That shouldn't be terribly hard to match, but it'd be slightly nicer if they reused Equals directly

tannergoodingand others added 3 commits July 14, 2026 14:37
…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>
CopilotAI review requested due to automatic review settings July 14, 2026 23:19

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 12/12 changed files
  • Comments generated: 3

Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Pushed a few follow-ups:

  • Records: now recognizes the EqualityComparer<F>.Default.Equals(this.F, other.F) shape Roslyn emits for record structs, accepted only when F is itself bitwise-equatable (same recursion/float-exclusion as the .Equals call form). Handled in both the VM and ILC scanners.
  • CI fix: the Loader/classloader/InlineArray failure was a real regression from the IsNotTightlyPacked change. Inline arrays throw NotSupportedException from ValueType.Equals/GetHashCode only via the QCALL, and only while the type's CanCompareBits flag is uncached. Once a struct wrapping an inline array was correctly reported tightly-packed, CanCompareBitsOrUseFastGetHashCode began recursing into the inline-array field and caching its flag as false, permanently suppressing the throw. Fix is to return false for inline arrays without caching, so the managed fast path keeps routing through the throwing QCALL -- this also removes a pre-existing ordering dependency where hashing a wrapper first would poison the inner type.
  • Addressed the review nits (unsigned shift in ReadILToken, stale comment).

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 T-typed field/EqualityComparer<!0> comes back as the formal variable rather than the concrete type. Making it work needs instantiation-aware token resolution. InlineArray-of-compatible-T is likewise deferred.

Note

This comment was drafted by Copilot (AI-generated) on @tannergooding's behalf.

tannergoodingand others added 2 commits July 14, 2026 16:53
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>
CopilotAI review requested due to automatic review settings July 15, 2026 00:16
@tannergooding

Copy link
Copy Markdown
MemberAuthor

Addressed the latest review feedback and pushed:

  • ReadILToken now casts each byte to uint32_t before shifting (well-defined token decode).
  • ILC comment updated to reference IsIEquatableEqualsFieldwise/IsTightlyPacked.
  • ILC field-wise scan guards PeekILOpcode() with a HasNext check so a truncated IL stream returns false conservatively.
  • Dropped the unnecessary CS660/CS661 suppressions from the ILC test asset (kept CS0649).
  • Added CoreCLR test coverage under src/tests/Loader/classloader/BitwiseEquatable that calls the internal IsBitwiseEquatable<T>() via reflection and covers the positive/negative cases (field-wise IEquatable<T>, ==-forwarding, nested structs, primitive .Equals, records, and float/padding/ignored-field rejections) plus a tight-packing behavioral check.

All open review threads are resolved.

Note

This comment was drafted by Copilot.

CopilotAI left a comment

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.

Copilot's findings

  • Files reviewed: 14/14 changed files
  • Comments generated: 2

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
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

Copy link
Copy Markdown
MemberAuthor

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.

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Medium severity

New issues introduced by this change (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward uses ILReader.ReadILToken() (which throws…
Issues resolved since last review (2)
SeverityFinding
High severitysrc/​coreclr/​gc/​env/​gcenv.sync.h — CrstStatic.Init now discards the return value from CLRCriticalSection.Initialize(). On Unix,… View resolved comment
Low severitysrc/​coreclr/​inc/​staticcontract.h — The PR description/title focuses on generalizing RuntimeHelpers.IsBitwiseEquatable<T>(), but this… View resolved comment

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/vm/methodtablebuilder.cpp
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 16:53
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Medium severitysrc/​coreclr/​tools/​Common/​TypeSystem/​IL/​Stubs/​ComparerIntrinsics.csTryGetOpEqualityForward 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

CopilotAI review requested due to automatic review settings August 28, 2026 17:02

CopilotAI left a comment

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.

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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/RuntimeHelpersIntrinsics.cs Outdated
@jkotas

Copy link
Copy Markdown
Member

LGTM otherwise. @MichalStrehovsky Could you please review as well?

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 28, 2026 22:10

CopilotAI left a comment

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.

Copilot review overview

Review tier: Lite
Findings: 1 Low severity

New issues introduced by this change (1)
SeverityFinding
Low severitysrc/​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() is ELEMENT_TYPE_VALUETYPE for all enums, so the new IsBitwiseComparablePrimitive(...) 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 prior methodTable->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 EqualsCore fast 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;

Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Comment threadsrc/coreclr/tools/Common/TypeSystem/IL/Stubs/ComparerIntrinsics.cs Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings August 31, 2026 18:53

CopilotAI left a comment

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.

Copilot review overview

🔵 Needs a closer look

Review tier: Lite
Findings: None

Issues resolved since last review (1)
SeverityFinding
Low severitysrc/​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-nullable object parameter, which will produce nullability-mismatch warnings (e.g., CS8765) when nullable annotations are enabled (override should match object?). Please update these overrides throughout the file to use object? (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 enabling IsBitwiseEquatable<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

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.

Can this be in the JIT so that the logic is shared between runtime and AOT compilers?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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

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.

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.

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

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIlinkable-frameworkIssues associated with delivering a linker friendly framework

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants

@tannergooding@EgorBo@jkotas@hez2010@jkoritzinsky@huoyaoyuan@MichalStrehovsky@hamarb123@MichalPetryka