Skip to content
Open
1 change: 1 addition & 0 deletions docs/release-notes/.FSharp.Compiler.Service/11.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Type relations cache: handle potentially "infinite" types ([PR #19010](https://github.com/dotnet/fsharp/pull/19010))
* Disallow recursive structs with lifted type parameters ([Issue #18993](https://github.com/dotnet/fsharp/issues/18993), [PR #19031](https://github.com/dotnet/fsharp/pull/19031))
* Fix units-of-measure changes not invalidating incremental builds. ([Issue #19049](https://github.com/dotnet/fsharp/issues/19049))
* Type relations cache: handle unsolved type variables ([Issue #19037](https://github.com/dotnet/fsharp/issues/19037)) ([PR #19040](https://github.com/dotnet/fsharp/pull/19040))

### Added

Expand Down
27 changes: 15 additions & 12 deletions src/Compiler/Checking/TypeRelations.fs
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@ type CanCoerce =
type TTypeCacheKey =
| TTypeCacheKey of TypeStructure * TypeStructure * CanCoerce
static member TryGetFromStrippedTypes(ty1, ty2, canCoerce) =
let t1, t2 = getTypeStructure ty1, getTypeStructure ty2
if t1.IsPossiblyInfinite || t2.IsPossiblyInfinite then
ValueNone
else
ValueSome (TTypeCacheKey(t1, t2, canCoerce))
let tryGetTypeStructure ty =
match ty with
| TType_app _ ->
tryGetTypeStructureOfStrippedType ty
| _ -> ValueNone

(tryGetTypeStructure ty1, tryGetTypeStructure ty2)
||> ValueOption.map2(fun t1 t2 -> TTypeCacheKey(t1, t2, canCoerce))

let getTypeSubsumptionCache =
let factory (g: TcGlobals) =
Expand Down Expand Up @@ -137,10 +140,6 @@ let rec TypeFeasiblySubsumesType ndeep (g: TcGlobals) (amap: ImportMap) m (ty1:

let checkSubsumes ty1 ty2 =
match ty1, ty2 with
| TType_measure _, TType_measure _
| TType_var _, _ | _, TType_var _ ->
true

| TType_app (tc1, l1, _), TType_app (tc2, l2, _) when tyconRefEq g tc1 tc2 ->
List.lengthsEqAndForall2 (TypesFeasiblyEquiv ndeep g amap m) l1 l2

Expand All @@ -160,13 +159,17 @@ let rec TypeFeasiblySubsumesType ndeep (g: TcGlobals) (amap: ImportMap) m (ty1:
// See if any interface in type hierarchy of ty2 is a supertype of ty1
List.exists (TypeFeasiblySubsumesType (ndeep + 1) g amap m ty1 NoCoerce) interfaces

if g.langVersion.SupportsFeature LanguageFeature.UseTypeSubsumptionCache then
match ty1, ty2 with
| TType_measure _, TType_measure _
| TType_var _, _ | _, TType_var _ ->
true

| _ when g.langVersion.SupportsFeature LanguageFeature.UseTypeSubsumptionCache ->
match TTypeCacheKey.TryGetFromStrippedTypes(ty1, ty2, canCoerce) with
| ValueSome key ->
(getTypeSubsumptionCache g).GetOrAdd(key, fun _ -> checkSubsumes ty1 ty2)
| _ -> checkSubsumes ty1 ty2
else
checkSubsumes ty1 ty2
| _ -> checkSubsumes ty1 ty2

and TypeFeasiblySubsumesTypeWithSupertypeCheck g amap m ndeep ty1 ty2 =
match GetSuperTypeOfType g amap m ty2 with
Expand Down
143 changes: 92 additions & 51 deletions src/Compiler/Utilities/TypeHashing.fs
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
module internal Internal.Utilities.TypeHashing

open Internal.Utilities.Rational
open Internal.Utilities.Library
open FSharp.Compiler.AbstractIL.IL
open FSharp.Compiler.Syntax
open FSharp.Compiler.TcGlobals
open FSharp.Compiler.Text
open FSharp.Compiler.TypedTree
open FSharp.Compiler.TypedTreeBasics
open FSharp.Compiler.TypedTreeOps
open System.Collections.Immutable

type ObserverVisibility =
| PublicOnly
Expand Down Expand Up @@ -126,7 +124,6 @@ module HashAccessibility =
| _ -> true

module rec HashTypes =
open Microsoft.FSharp.Core.LanguagePrimitives

/// Hash a reference to a type
let hashTyconRef tcref = hashTyconRefImpl tcref
Expand Down Expand Up @@ -380,110 +377,154 @@ module HashTastMemberOrVals =
/// * Uses per-compilation stamps (entities, typars, anon records, measures).
/// * Emits shape for union cases (declaring type stamp + case name), tuple structness,
/// function arrows, forall binders, nullness, measures, generic arguments.
/// * Unknown/variable nullness => NeverEqual token to force inequality (avoid unsound hits).
/// * Does not include type constraints.
///
/// Non-goals:
/// * Cross-compilation stability.
/// * Perfect canonicalisation or alpha-equivalence collapsing.
///
/// </summary>
module StructuralUtilities =
[<Struct; CustomEquality; NoComparison>]
type NeverEqual =
struct
interface System.IEquatable<NeverEqual> with
member _.Equals _ = false

override _.Equals _ = false
override _.GetHashCode() = 0
end

static member Singleton = NeverEqual()
open Internal.Utilities.Library.Extras

[<Struct; NoComparison; RequireQualifiedAccess>]
type TypeToken =
| Stamp of stamp: Stamp
| UCase of name: string
| Nullness of nullness: NullnessInfo
| NullnessUnsolved
| TupInfo of b: bool
| Forall of int
| MeasureOne
| MeasureRational of int * int
| NeverEqual of never: NeverEqual
| Unsolved of int
| Rigid of int

type TypeStructure =
| TypeStructure of TypeToken[]
| PossiblyInfinite of never: NeverEqual
| Stable of TypeToken[]
| Unstable of TypeToken[]
| PossiblyInfinite

type private EmitContext =
{
typarMap: System.Collections.Generic.Dictionary<Stamp, int>
mutable stable: bool
}

let inline toNullnessToken (n: Nullness) =
let private emitNullness env (n: Nullness) =
match n.TryEvaluate() with
| ValueSome k -> TypeToken.Nullness k
Copy link
Member

Choose a reason for hiding this comment

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

The trace based mutation+rollback might be a problem for the weakcache, see e.g. here

| Nullness.Variable nv1, _ ->
trace.Exec (fun () -> nv1.Set nullness2) (fun () -> nv1.Unset())
CompleteD
| _, Nullness.Variable nv2 ->
trace.Exec (fun () -> nv2.Set nullness1) (fun () -> nv2.Unset())
CompleteD
.

Or another one here:

trace.Exec (fun () -> r.typar_solution <- Some ty) (fun () -> r.typar_solution <- None)

The typestructure has this covered, but weakcache has not.
But fully clearing the weakcache when a trace's undo is called is way too defensive and would likely sacrifice a lot of perf potential :-(

Copy link
Contributor Author

@majocha majocha Nov 6, 2025

Choose a reason for hiding this comment

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

I think the weakcache is not as crucial now, when we limit only to TType_app. Is nullness taken into account for TypeFeasiblySubsumesType? Maybe it's possible to not emit it at all.
(In the long run type structure generation could be made more configurable if needed, too.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, this happens not just for nullness.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, happens for typar solutions - e.g. method overloading, also in context of picking SRTP overloads.

If we are in a Trace environment within constraint solving, it would be safest to never add things to the WeakCache.

(alternatively add it there, but then remove at Undo - but there might be overhead in keeping track what to remove, I guess safer not to add...)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, as first approximation we can just not memoize when any typars come into play, solved or not. Good thing the performance is still way better than before.

| _ -> TypeToken.NeverEqual NeverEqual.Singleton
| ValueNone ->
env.stable <- false
TypeToken.NullnessUnsolved

let rec private accumulateMeasure (m: Measure) =
let rec private emitMeasure (m: Measure) =
seq {
match m with
| Measure.Var mv -> TypeToken.Stamp mv.Stamp
| Measure.Const(tcref, _) -> TypeToken.Stamp tcref.Stamp
| Measure.Prod(m1, m2, _) ->
yield! accumulateMeasure m1
yield! accumulateMeasure m2
| Measure.Inv m1 -> yield! accumulateMeasure m1
yield! emitMeasure m1
yield! emitMeasure m2
| Measure.Inv m1 -> yield! emitMeasure m1
| Measure.One _ -> TypeToken.MeasureOne
| Measure.RationalPower(m1, r) ->
yield! accumulateMeasure m1
yield! emitMeasure m1
TypeToken.MeasureRational(GetNumerator r, GetDenominator r)
}

let rec private accumulateTType (ty: TType) =
and private emitTType (env: EmitContext) (ty: TType) =
seq {
match ty with
| TType_ucase(u, tinst) ->
TypeToken.Stamp u.TyconRef.Stamp
TypeToken.UCase u.CaseName

for arg in tinst do
yield! accumulateTType arg
yield! emitTType env arg

| TType_app(tcref, tinst, n) ->
TypeToken.Stamp tcref.Stamp
toNullnessToken n
emitNullness env n

for arg in tinst do
yield! accumulateTType arg
yield! emitTType env arg

| TType_anon(info, tys) ->
TypeToken.Stamp info.Stamp

for arg in tys do
yield! accumulateTType arg
yield! emitTType env arg

| TType_tuple(tupInfo, tys) ->
TypeToken.TupInfo(evalTupInfoIsStruct tupInfo)

for arg in tys do
yield! accumulateTType arg
yield! emitTType env arg

| TType_forall(tps, tau) ->
for tp in tps do
TypeToken.Stamp tp.Stamp
env.typarMap.[tp.Stamp] <- env.typarMap.Count

TypeToken.Forall tps.Length

yield! emitTType env tau

yield! accumulateTType tau
| TType_fun(d, r, n) ->
yield! accumulateTType d
yield! accumulateTType r
toNullnessToken n
yield! emitTType env d
yield! emitTType env r
emitNullness env n

| TType_var(r, n) ->
TypeToken.Stamp r.Stamp
toNullnessToken n
| TType_measure m -> yield! accumulateMeasure m
emitNullness env n

let typarId =
match env.typarMap.TryGetValue r.Stamp with
| true, idx -> idx
| _ ->
let idx = env.typarMap.Count
env.typarMap.[r.Stamp] <- idx
idx

match r.Solution with
| Some ty -> yield! emitTType env ty
| None ->
if r.Rigidity = TyparRigidity.Rigid then
TypeToken.Rigid typarId
else
env.stable <- false
TypeToken.Unsolved typarId
Comment on lines 493 to 503
Copy link
Member

Choose a reason for hiding this comment

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

A basic checklist, please check each:

  • Infinity chains of typars are handled (by laziness and truncation)
  • Solve typar's hash is equal to its solution's hash (after nullness token filtering)
  • Solved typar's hash is not equal to unsolved typar's hash with the same stamp
  • Unsolved typar's are never weakly cached on the reference itself (solved by boolean flag to reference caching)
  • This is never called in parallel for the same input ttype (since the context access it not thread-safe)
  • The cache is kept small by caching only top-level Ttype_app and their contents are normalized and stamp-independent
  • The weak cache still makes sense, it just is not used whenever an unsolved typar is encountered. But for all other scenarios, it can avoid the getTypeStructureOfStrippedType call.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A basic checklist, please check each:

  • Infinity chains of typars are handled (by laziness and truncation)
  • Solved typar's hash is equal to its solution's hash (after nullness token filtering)
  • Solved typar's hash is not equal to unsolved typar's hash with the same stamp
  • Unsolved typar's are never weakly cached on the reference itself (solved by boolean flag to reference caching)
  • This is never called in parallel for the same input ttype (since the context access it not thread-safe)
  • The cache is kept small by caching only top-level Ttype_app and their contents are normalized and stamp-independent
  • The weak cache still makes sense, it just is not used whenever an unsolved typar is encountered. But for all other scenarios, it can avoid the getTypeStructureOfStrippedType call.

This one I am not entirely sure. I'm trying to informally test by building FCS net10.0 and also OpenTK 5.0, both of which have quite different profiles of cache use, OpenTK now does not benefit from weak memoization. I left it in with the editor use in mind, but now that the cache works only on TType_apps, I wonder if it still helps.

| TType_measure m -> yield! emitMeasure m
}

// If the sequence got too long, just drop it, we could be dealing with an infinite type.
let private toTypeStructure tokens =
let tokens = tokens |> Seq.truncate 256 |> Array.ofSeq

if tokens.Length = 256 then
PossiblyInfinite NeverEqual.Singleton
else
TypeStructure tokens

/// Get the full structure of a type as a sequence of tokens, suitable for equality
let getTypeStructure =
Extras.WeakMap.getOrCreate (fun ty -> accumulateTType ty |> toTypeStructure)
let private getTypeStructureOfStrippedType (ty: TType) =

let env =
{
typarMap = System.Collections.Generic.Dictionary<Stamp, int>()
stable = true
}

let tokens =
emitTType env ty
|> Seq.filter (fun t -> t <> TypeToken.Nullness NullnessInfo.WithoutNull)
Copy link
Member

Choose a reason for hiding this comment

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

You might as well solve it at emission time (i.e. not emit a nullness token at all into the sequence), or?

|> Seq.truncate 256
|> Seq.toArray

// If the sequence got too long, just drop it, we could be dealing with an infinite type.
if tokens.Length = 256 then PossiblyInfinite
elif not env.stable then Unstable tokens
else Stable tokens

// Speed up repeated calls by memoizing results for types that yield a stable structure.
let private memoize =
WeakMap.cacheConditionally
(function
| Stable _ -> true
| _ -> false)
getTypeStructureOfStrippedType

let tryGetTypeStructureOfStrippedType ty =
match memoize ty with
| PossiblyInfinite -> ValueNone
| ts -> ValueSome ts
12 changes: 12 additions & 0 deletions src/Compiler/Utilities/lib.fs
Original file line number Diff line number Diff line change
Expand Up @@ -473,3 +473,15 @@ module WeakMap =
// Cached factory to avoid allocating a new lambda per lookup.
let factory = ConditionalWeakTable.CreateValueCallback(fun k -> valueFactory k)
fun (key: 'Key when 'Key: not null) -> table.GetValue(key, factory)

/// Like getOrCreate, but only cache the value if it satisfies the given predicate.
let cacheConditionally shouldCache valueFactory =
let table = ConditionalWeakTable<_, _>()
fun (key: 'Key when 'Key: not null) ->
match table.TryGetValue key with
| true, value -> value
| false, _ ->
let value = valueFactory key
if shouldCache value then
try table.Add(key, value) with _ -> ()
value
5 changes: 5 additions & 0 deletions src/Compiler/Utilities/lib.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,8 @@ module internal WeakMap =
val internal getOrCreate:
valueFactory: ('Key -> 'Value) -> ('Key -> 'Value)
when 'Key: not struct and 'Key: not null and 'Value: not struct

/// Like getOrCreate, but only cache the value if it satisfies the given predicate.
val cacheConditionally:
shouldCache: ('Value -> bool) -> valueFactory: ('Key -> 'Value) -> ('Key -> 'Value)
when 'Key: not struct and 'Key: not null and 'Value: not struct
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@
<Compile Include="Interop\Literals.fs" />
<Compile Include="Scripting\Interactive.fs" />
<Compile Include="Scripting\TypeCheckOnlyTests.fs" />
<Compile Include="TypeChecks\TypeRelations.fs" />
<Compile Include="TypeChecks\SeqTypeCheckTests.fs" />
<Compile Include="TypeChecks\CheckDeclarationsTests.fs" />
<Compile Include="TypeChecks\Graph\Utils.fs" />
Expand Down
34 changes: 34 additions & 0 deletions tests/FSharp.Compiler.ComponentTests/TypeChecks/CrgpLibrary.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module MyModule

type IFoo<'T when 'T :> IFoo<'T>> =
abstract member Bar: other:'T -> unit

[<AbstractClass>]
type FooBase() =

interface IFoo<FooBase> with
member this.Bar (other: FooBase) = ()

[<Sealed>]
type FooDerived<'T>() =
inherit FooBase()

interface IFoo<FooDerived<'T>> with
member this.Bar other = ()

type IFooContainer<'T> =
abstract member Foo: FooDerived<'T>

let inline bar<'a when 'a :> IFoo<'a>> (x: 'a) (y: 'a) = x.Bar y
let inline takeSame<'a> (x: 'a) (y: 'a) = ()

// Successfully compiles under .NET 9 + F# 9
// Error under .NET 10 + F# 10: Program.fs(26,13): Error FS0193 : The type 'FooDerived<'TId>' does not match the type 'FooBase'
let callBar_NewlyBroken (foo1: IFooContainer<'TId>) (foo2: IFooContainer<'TId>) =
bar foo1.Foo foo2.Foo

// Successfully compiles under both versions
let callBar (foo1: IFooContainer<'TId>) (foo2: IFooContainer<'TId>) =
let id1 = foo1.Foo
let id2 = foo2.Foo
bar id1 id2
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module TypeChecks.TypeRelations

open Xunit
open FSharp.Test.Compiler
open FSharp.Test

[<Theory; FileInlineData("CrgpLibrary.fs")>]
let ``Unsolved type variables are not cached`` compilation =
compilation
|> getCompilation
|> typecheck
|> shouldSucceed
Loading