Skip to content

[Breaking] Avoid boxing on equality and comparison #1280

Open

Description

@Happypig375

I propose we solve dotnet/fsharp#526 (accepting the breaking change at dotnet/fsharp#9404 due to use of IEquatable<T> and IComparable<T>). We will include a BackCompat module to enable past behaviour. These current behaviours typically have no known instance of usage and the breaking change is only theoretical for code that already is designed poorly in the first place, like .Equals behaving differently from IEquatable<T> and relying on nan <> nan without using System.Double.IsNaN (IEquatable<double> makes nan = nan).

The existing way of approaching this problem in F# is taking the cost of boxing every equality and comparison for structs. Retaining the same behaviour has made F# perform worse than C#. We are keeping a reason for C# people not to move over simply because we retain a behaviour no one wants.

dotnet/fsharp#526 (comment)

Anyway, it has lost me as an fsharp developer, which is a shame as I really love writing code in fsharp, but with advancements in csharp over the years, it's, for the type of code I write, a lesser worse option

Pros and Cons

The advantages of making this adjustment to F# are

  1. Great performance
  2. One less reason for C# people not to move over
  3. One less reason for people to abandon F#

The disadvantage of making this adjustment to F# is this is a breaking change after all. But as mentioned, it's a hypothetical breaking change only. There is no known instance of people depending on IEquatable<T> behaving differently from .Equals as this is poor design.

Extra information

Estimated cost (XS, S, M, L, XL, XXL): S

Related suggestions:

We should do this together with another wart in FSharp.Core that has gone unnoticed, in the same update: #472

Affidavit (please submit!)

Please tick this by placing a cross in the box:

  • This is not a question (e.g. like one you might ask on stackoverflow) and I have searched stackoverflow for discussions of this issue
  • I have searched both open and closed suggestions on this site and believe this is not a duplicate
  • This is not something which has obviously "already been decided" in previous versions of F#. If you're questioning a fundamental design decision that has obviously already been taken (e.g. "Make F# untyped") then please don't submit it.
    This is arguing against a previous decision not to accept this breaking change.

Please tick all that apply:

  • This is not a breaking change to the F# language design
  • I or my company would be willing to help implement and/or test this

For Readers

If you would like to see this issue implemented, please click the 👍 emoji on this issue. These counts are used to generally order the suggestions by engagement.

Activity

  1. T-Gro commented on Jul 3, 2023

    @T-Gro
    Contributor

    I personally believe the likelihood would increase if this would be an opt-in behavior, e.g. a separate project-wide setting. Like a language-feature flag which is preview only, not becoming the default for vNext automatically.

  2. vzarytovskii commented on Jul 3, 2023

    @vzarytovskii

    I personally believe the likelihood would increase if this would be an opt-in behavior, e.g. a separate project-wide setting. Like a language-feature flag which is preview only, not becoming the default for vNext automatically.

    I think for this particular change an opt out is good. Nobody will be able to explore and opt into it.

  3. charlesroddie commented on Jul 3, 2023

    @charlesroddie

    I would love to get the perf benefits here, would love to work with generic IEquatable<'T>/IComparable<'T> rather than the non-generic ones, and would love to have the property always satisfied that x = x (i.e. putting compliance with logic above compliance with IEEE).

  4. cartermp commented on Aug 5, 2023

    @cartermp
    Member

    Just here to say that this:

    Estimated cost (XS, S, M, L, XL, XXL): S

    Is something I'd put in L or XL size. There were several attempts to land this in the compiler before and one the reasons why is because it's complex and difficult to implement. There's also a long tail of legacy code that will now fail at runtime in subtle ways, which needs to be dealt with.

  5. charlesroddie commented on Aug 6, 2023

    @charlesroddie

    Is something I'd put in L or XL size. There were several attempts...

    I would say this suggestion (deferring to IEquatable<T> whenever possible) is S. Other approaches that were tried were L or XL because they tried to tiptoe around the nan bug.

    We would need a notice similar to https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/7.0/equals-nan .

    There's also a long tail of legacy code that will now fail at runtime in subtle ways...

    It would be good to see a concrete example. Is there some existing plausible code which would depend, for correctness, on x <> x where x = nan?

    I think it's much more likely that there will be a long tail of subtle bugs that would be fixed by this change, rather than created by this change, since it is much more likely that programs would assume that x = x rather than x <> x.

  6. Happypig375 commented on Aug 6, 2023

    @Happypig375
    ContributorAuthor
  7. vzarytovskii commented on Aug 6, 2023

    @vzarytovskii

    It would be good to see a concrete example. Is there some existing plausible code which would depend, for correctness, on x <> x where x = nan?

    Pretty much all code which uses floating point comparison, may implicitly rely on it. So it will be hugely runtime breaking. Another question, whether these applications relying on NaNs.

  8. vzarytovskii commented on Nov 6, 2023

    @vzarytovskii

    General discussion for breaking changes in corelib: dotnet/fsharp#16231

  9. u3r commented on Jun 10, 2024

    @u3r

    Question: is dotnet/fsharp#526
    a) a partial fix for this
    b) a full fix for this
    c) unrelated?

  10. vzarytovskii commented on Jun 10, 2024

    @vzarytovskii

    It's a partial fix for the problem, a workaround really, at the cost of additional codegen.

  11. T-Gro commented on Oct 20, 2025

    @T-Gro
    Contributor

    I believe this suggestion needs an update to better frame the current limitations - the amount of boxing for equality in generic contexts has been reduced since this suggestion was created.
    ( I do not want to close the suggestion and ask for a new one because this issue has collected a lot of upvotes)

    i.e. taking the dotnet/fsharp#16857 PR in mind and stating/linking remaining scenarios where it has not lead to satisfaction. (dotnet/fsharp#526 (comment) comes to mind). Some of those could be addressed as bugfixes if they do not carry a breaking change associated to them.

    The remaining issues should then be contrasted with the "cost" of the break - patterns of F# code that will or will not behave differently, incl. indirect effect on collection combinators that rely on equality.

    TLDR: It would make the suggestion+acceptance process easier if this issue states it here directly what is being asked and what the costs are.

  12. smoothdeveloper commented on Oct 24, 2026

    @smoothdeveloper
    Contributor

    Following up on @T-Gro's request to list scenarios that still box after dotnet/fsharp#16857:

    comparison on single-field struct records (min, max, <, compare, Array.max, sort, …) still boxes both operands on .NET 10, while equality no longer does.

    Since struct-record comparison is fully determined by its fields, a typed CompareTo path, like the one #16857 added for Equals, seems possible without a semantic break, at least for records without float fields.

    Context

    I'm working on fsdsp56k, which compiles F# kernels to Motorola DSP56300 assembly (the chip in several classic synthesizers). The same F# source also runs as a bit-exact reference on .NET and, through Fable, in JS and Rust/wasm.

    Fixed-point values are modelled as single-field struct records:

    [<Struct>]
    type Frac = { Raw : int   }  // 24-bit signed fraction, sign-extended
    
    [<Struct>]
    type Acc  = { Raw : int64 }  // 56-bit accumulator, sign-extended

    The generated structural comparison has exactly the semantics we want: signed order of Raw. Idiomatic min, max, < and clamp are therefore correct on every target. On .NET, though, they go through GenericLessThanIntrinsic / GenericCompare and box both operands. In a DSP inner loop that is an allocation per sample.

    Measurements (.NET 10.0.9, dotnet fsi --optimize+)

    Expression Bytes per call
    min a b on Frac 48
    a < b on Frac 48
    min a b with IComparable<Frac> / custom comparison 48
    if a.Raw < b.Raw then a else b 0
    min on int (baseline) 0

    The same numbers show up in a Release build with tiered compilation and PGO; the JIT does not remove the boxes.

    Current workarounds and their cost

    • .Raw helpers (Frac.Min a b): allocation-free, but non-idiomatic, and FSharp.Core combinators (Array.max, sort, maxBy without a key) still box.
    • (# "clt" … #) inline IL: allocation-free, but warning FS0042 and not supported by Fable.
    • when ^T : Frac = … static conditionals: error FS0817 outside FSharp.Core.
    • Implementing IComparable<T>: still boxes, as generic comparison goes through the non-generic path.

    Repro: struct-comparison-boxing.fsx

    // Struct records with default comparison box on generic comparison.
    // Run: dotnet fsi --optimize+ struct-comparison-boxing.fsx
    
    open System
    
    [<Struct>]
    type Frac = { Raw : int }
    
    [<Struct; CustomEquality; CustomComparison>]
    type FracCmp =
      { Raw : int }
      override x.Equals o       = match o with :? FracCmp as y -> x.Raw = y.Raw | _ -> false
      override x.GetHashCode () = x.Raw
      interface IComparable<FracCmp> with
        member x.CompareTo y = compare x.Raw y.Raw
      interface IComparable with
        member x.CompareTo o = compare x.Raw (o :?> FracCmp).Raw
    
    let n = 10_000
    
    let measure name f =
      f 1000 |> ignore                       // warm up
      let before = GC.GetAllocatedBytesForCurrentThread ()
      let r      = f n                       // keep the result so the loop is not optimised away
      let after  = GC.GetAllocatedBytesForCurrentThread ()
      GC.KeepAlive r
      printfn "%-32s %5.1f B/call" name (float (after - before) / float n)
    
    let fracs   = Array.init 2 (fun i -> { Frac.Raw    = i * 7 - 3 })
    let fracCmp = Array.init 2 (fun i -> { FracCmp.Raw = i * 7 - 3 })
    
    let minFrac k =
      let mutable a = 0
      for i in 1 .. k do
        a <- a + (min fracs[i % 2] fracs[(i + 1) % 2]).Raw
      a
    
    let ltFrac k =
      let mutable a = 0
      for i in 1 .. k do
        if fracs[i % 2] < fracs[(i + 1) % 2] then a <- a + 1
      a
    
    let minFracCmp k =
      let mutable a = 0
      for i in 1 .. k do
        a <- a + (min fracCmp[i % 2] fracCmp[(i + 1) % 2]).Raw
      a
    
    let selectRaw k =
      let mutable a = 0
      for i in 1 .. k do
        let x, y = fracs[i % 2], fracs[(i + 1) % 2]
        a <- a + (if x.Raw < y.Raw then x else y).Raw
      a
    
    let minInt k =
      let mutable a = 0
      for i in 1 .. k do
        a <- a + min (i % 2) ((i + 1) % 2)
      a
    
    measure "min on Frac"                     minFrac
    measure "a < b on Frac"                   ltFrac
    measure "min on FracCmp (IComparable<T>)" minFracCmp
    measure "a.Raw < b.Raw select"            selectRaw
    measure "min on int (baseline)"           minInt
    
    printfn "%s" (Runtime.InteropServices.RuntimeInformation.FrameworkDescription)

    Output:

    min on Frac                       48.0 B/call
    a < b on Frac                     48.0 B/call
    min on FracCmp (IComparable<T>)   48.0 B/call
    a.Raw < b.Raw select               0.0 B/call
    min on int (baseline)              0.0 B/call
    .NET 10.0.9
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions