Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* Fix recursive inline SRTP resolution being truncated by one currying level (e.g. FSharpPlus `memoizeN`), a regression from the function-domain unification order change in [PR #15181](https://github.com/dotnet/fsharp/pull/15181); the contravariant domain now keeps the inference variable that still carries the pending member constraint. ([PR #20247](https://github.com/dotnet/fsharp/pull/20247))
* Fix exponential (2^N) compile time in pattern matching with shared guards and partial active patterns. ([Issue #18425](https://github.com/dotnet/fsharp/issues/18425), [PR #20244](https://github.com/dotnet/fsharp/pull/20244))
* Fix incorrect Debug lowering of inline builders that compose low-level resumable state machines. ([Issue #20466](https://github.com/dotnet/fsharp/issues/20466), [PR #20469](https://github.com/dotnet/fsharp/pull/20469))
* Prevent idle caches and their contents from being retained indefinitely, allowing them to be garbage-collected without explicit disposal. ([Issue #20756](https://github.com/dotnet/fsharp/issues/20756), [PR #20758](https://github.com/dotnet/fsharp/pull/20758))
* Fix incorrect `StructLayout(Size = 1)` emission for data-less struct unions where the compiler-generated tag field makes the actual runtime size larger. ([PR #19759](https://github.com/dotnet/fsharp/pull/19759))
* Embed F# metadata-removal rules before resource collection, preserve custom rules, and combine generated rules when linking assemblies. ([PR #20527](https://github.com/dotnet/fsharp/pull/20527))
* Fix FS0750 "This construct may only be used within computation expressions" incorrectly raised for `let!`/`use!`/`do!` appearing in the right-hand side of a plain `let` binding inside a computation expression. The right-hand side is now desugared as a nested computation of the same builder whose result is bound with `let!`, keeping its bindings correctly scoped. ([Issue #19457](https://github.com/dotnet/fsharp/issues/19457), [PR #19868](https://github.com/dotnet/fsharp/pull/19868))
Expand Down
81 changes: 31 additions & 50 deletions src/Compiler/Utilities/Caches.fs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ module CacheMetrics =
let allCounters = [ adds; updates; hits; misses; evictions; evictionFails ]

let creations = Meter.CreateCounter<int64>("creations", "count")
let disposals = Meter.CreateCounter<int64>("disposals", "count")

let mkTags (name: string) =
// Avoid TagList(ReadOnlySpan<...>) to support net472 runtime
Expand All @@ -37,7 +36,6 @@ module CacheMetrics =
let Eviction (tags: inref<TagList>) = evictions.Add(1L, &tags)
let EvictionFail (tags: inref<TagList>) = evictionFails.Add(1L, &tags)
let Created (tags: inref<TagList>) = creations.Add(1L, &tags)
let Disposed (tags: inref<TagList>) = disposals.Add(1L, &tags)

type Stats() =
let totals = Map [ for counter in allCounters -> counter.Name, ref 0L ]
Expand Down Expand Up @@ -203,10 +201,30 @@ type CachedEntity<'Key, 'Value> =
override this.ToString() = $"{this.Key}"

[<Struct>]
type EvictionQueueMessage<'Entity, 'Target> =
| Add of 'Entity * 'Target
type EvictionQueueMessage<'Entity, 'Store> =
| Add of 'Entity * 'Store
| Update of 'Entity

module EvictionProcessor =
// Shared work queue for all caches running in EvictionMode.MailboxProcessor.
let private agent =
MailboxProcessor.Start(
Comment thread
majocha marked this conversation as resolved.
(fun inbox ->
let rec loop () =
async {
let! work = inbox.Receive()
work ()
return! loop ()
}

loop ()),
cancellationToken = CancellationToken.None
)

let Error = agent.Error

let post = agent.Post

[<Sealed; NoComparison; NoEquality>]
[<DebuggerDisplay("{DebugDisplay()}")>]
type Cache<'Key, 'Value when 'Key: not null> internal (options: CacheOptions<'Key>, ?name) =
Expand All @@ -228,7 +246,7 @@ type Cache<'Key, 'Value when 'Key: not null> internal (options: CacheOptions<'Ke
let capacity = options.TotalCapacity - headroom

let mutable store =
ConcurrentDictionary<'Key, CachedEntity<'Key, 'Value>>(Environment.ProcessorCount, capacity, options.Comparer)
ConcurrentDictionary(Environment.ProcessorCount, capacity, options.Comparer)

let evictionQueue = LinkedList<CachedEntity<'Key, 'Value>>()

Expand All @@ -237,16 +255,13 @@ type Cache<'Key, 'Value when 'Key: not null> internal (options: CacheOptions<'Ke

let tags = CacheMetrics.mkTags name

// Track disposal state (0 = not disposed, 1 = disposed)
let mutable disposed = 0

let mutable deadKeysCount = 0

// Keys with unreliable identity can prevent eviction, taking up space in the cache.
// In such case we rebuild the store to remove dead keys.
let rebuildStore () =
let newStore =
ConcurrentDictionary<'Key, CachedEntity<'Key, 'Value>>(Environment.ProcessorCount, capacity, options.Comparer)
ConcurrentDictionary(Environment.ProcessorCount, capacity, options.Comparer)

for entity in evictionQueue do
newStore.TryAdd(entity.Key, entity) |> ignore
Expand Down Expand Up @@ -286,38 +301,17 @@ type Cache<'Key, 'Value when 'Key: not null> internal (options: CacheOptions<'Ke
// Store updates are not synchronized with evictionQueue. It is possible the entity is no longer in the queue or already added.
| _ -> ()

let startEvictionProcessor ct =
MailboxProcessor.Start(
(fun mb ->
let rec processNext () =
async {
let! message = mb.Receive()
processEvictionMessage message
return! processNext ()
}

processNext ()),
ct
)

let immediate msg =
lock evictionQueue <| fun () -> processEvictionMessage msg

let post, disposeEvictionProcessor =
match options.EvictionMode with
| EvictionMode.NoEviction -> ignore, ignore
| EvictionMode.Immediate -> immediate, ignore
| EvictionMode.MailboxProcessor ->
let cts = new CancellationTokenSource()
let evictionProcessor = startEvictionProcessor cts.Token
let post = evictionProcessor.Post

let dispose () =
cts.Cancel()
cts.Dispose()
evictionProcessor.Dispose()
let queued msg =
EvictionProcessor.post <| fun () -> processEvictionMessage msg

post, dispose
let post =
match options.EvictionMode with
| EvictionMode.NoEviction -> ignore
| EvictionMode.Immediate -> immediate
| EvictionMode.MailboxProcessor -> queued

do CacheMetrics.Created &tags

Expand Down Expand Up @@ -384,19 +378,6 @@ type Cache<'Key, 'Value when 'Key: not null> internal (options: CacheOptions<'Ke
CacheMetrics.Update &tags
post (EvictionQueueMessage.Update result)

member _.Dispose() =
if Interlocked.Exchange(&disposed, 1) = 0 then
disposeEvictionProcessor ()
CacheMetrics.Disposed &tags

interface IDisposable with
member this.Dispose() =
this.Dispose()
GC.SuppressFinalize(this)

// Finalizer to ensure eviction loop is cancelled if Dispose wasn't called.
override this.Finalize() = this.Dispose()

#if DEBUG
// Shows the totals aggregated for this cache's name. Populated only while a metrics listener
// (CacheMetrics.ListenToAll, e.g. under --times or the editor's metrics view) is running.
Expand Down
7 changes: 4 additions & 3 deletions src/Compiler/Utilities/Caches.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type internal EvictionMode =
| NoEviction
/// Evict items immediately on the caller's thread when adding a new item that would exceed capacity.
| Immediate
/// Evict items in the background using a MailboxProcessor to queue eviction requests. This may lag behind during heavy load but avoids blocking callers.
/// Evict items in the background using a shared MailboxProcessor to queue eviction requests. This may lag behind during heavy load but avoids blocking callers.
| MailboxProcessor

[<Struct; RequireQualifiedAccess; NoComparison; NoEquality>]
Expand All @@ -53,6 +53,9 @@ module internal CacheOptions =
/// Set eviction mode to NoEviction.
val withNoEviction: CacheOptions<'Key> -> CacheOptions<'Key>

module internal EvictionProcessor =
val Error: IEvent<exn>

[<Sealed; NoComparison; NoEquality>]
type internal Cache<'Key, 'Value when 'Key: not null> =
new: options: CacheOptions<'Key> * ?name: string -> Cache<'Key, 'Value>
Expand All @@ -61,8 +64,6 @@ type internal Cache<'Key, 'Value when 'Key: not null> =
member GetOrAdd: key: 'Key * valueFactory: ('Key -> 'Value) -> 'Value
member AddOrUpdate: key: 'Key * value: 'Value -> unit

interface IDisposable

/// For testing only.
member Evicted: IEvent<unit>
/// For testing only.
Expand Down
56 changes: 38 additions & 18 deletions tests/FSharp.Compiler.ComponentTests/CompilerService/Caches.fs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module internal CompilerService.Caches
module CompilerService.Caches

open FSharp.Compiler.Caches
open Xunit
Expand All @@ -14,28 +14,48 @@ let shouldNeverTimeout = 15_000
let shouldNeverTimeout = 200_000
#endif

let defaultStructural() = CacheOptions.getDefault HashIdentity.Structural
let private defaultStructural() = CacheOptions.getDefault HashIdentity.Structural

[<System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)>]
let private createIdleCache useMailbox =
let options =
{ defaultStructural() with
TotalCapacity = 1
HeadroomPercentage = 0
EvictionMode = if useMailbox then EvictionMode.MailboxProcessor else EvictionMode.Immediate }
let cache = new Cache<string, obj>(options, name = "Idle cache collection")
let payload = obj()
let evicted = TaskCompletionSource<unit>()
cache.Evicted.Add evicted.SetResult
cache.TryAdd("first", obj()) |> shouldBeTrue
cache.TryAdd("last", payload) |> shouldBeTrue
evicted.Task.Wait shouldNeverTimeout |> shouldBeTrue
struct {| Cache = WeakReference(cache); Payload = WeakReference(payload) |}

[<Theory>]
[<InlineData(false)>]
[<InlineData(true)>]
let ``Idle cache and contents are collected without explicit disposal`` useMailbox =
let references = createIdleCache useMailbox
GC.Collect()
let collected =
System.Threading.SpinWait.SpinUntil(
(fun () -> not references.Cache.IsAlive && not references.Payload.IsAlive),
shouldNeverTimeout)
collected |> shouldBeTrue

// Metrics assertions below read absolute per-name totals via CacheMetrics.getTotalsByName. Those totals
// are aggregated process-globally while a CacheMetrics.ListenToAll() listener is running. This works
// because each test uses a unique cache name and this module is the only ListenToAll caller in the
// assembly, so nothing else increments those names. A second concurrently-active listener would
// double-count every measurement, so keep it that way.

[<Fact>]
let ``Create and dispose many`` () =
let caches =
[ for _ in 1 .. 100 do
new Cache<string, int>(defaultStructural(), name = "Create and dispose many") :> IDisposable ]

for c in caches do
c.Dispose()

[<Fact>]
let ``Basic add and retrieve`` () =
let name = "Basic_add_and_retrieve"
use _ = CacheMetrics.ListenToAll()
use cache = new Cache<string, int>(defaultStructural(), name = name)
let cache = new Cache<string, int>(defaultStructural(), name = name)

cache.TryAdd("key1", 1) |> shouldBeTrue
cache.TryAdd("key2", 2) |> shouldBeTrue
Expand All @@ -58,7 +78,7 @@ let ``Basic add and retrieve`` () =
let ``Eviction of least recently used`` () =
let name = "Eviction_of_least_recently_used"
use _ = CacheMetrics.ListenToAll()
use cache = new Cache<string, int>({ defaultStructural() with TotalCapacity = 2; HeadroomPercentage = 0 }, name = name)
let cache = new Cache<string, int>({ defaultStructural() with TotalCapacity = 2; HeadroomPercentage = 0 }, name = name)

cache.TryAdd("key1", 1) |> shouldBeTrue
cache.TryAdd("key2", 2) |> shouldBeTrue
Expand Down Expand Up @@ -92,7 +112,7 @@ let ``Stress test evictions`` () =
let name = "Stress test evictions"

use _ = CacheMetrics.ListenToAll()
use cache = new Cache<string, int>({ defaultStructural() with TotalCapacity = cacheSize; HeadroomPercentage = 0 }, name = name)
let cache = new Cache<string, int>({ defaultStructural() with TotalCapacity = cacheSize; HeadroomPercentage = 0 }, name = name)

let evictionsCompleted = new TaskCompletionSource<unit>()
let expectedEvictions = iterations - cacheSize
Expand Down Expand Up @@ -127,7 +147,7 @@ let ``Stress test evictions`` () =
let ``Metrics can be retrieved`` () =
let name = "test_metrics"
use _ = CacheMetrics.ListenToAll()
use cache = new Cache<string, int>({ defaultStructural() with TotalCapacity = 2; HeadroomPercentage = 0 }, name = name)
let cache = new Cache<string, int>({ defaultStructural() with TotalCapacity = 2; HeadroomPercentage = 0 }, name = name)

cache.TryAdd("key1", 1) |> shouldBeTrue
cache.TryAdd("key2", 2) |> shouldBeTrue
Expand All @@ -152,7 +172,7 @@ let ``Metrics can be retrieved`` () =
let ``GetOrAdd basic usage`` () =
let cacheName = "GetOrAdd_basic_usage"
use _ = CacheMetrics.ListenToAll()
use cache = new Cache<string, int>(defaultStructural(), name = cacheName)
let cache = new Cache<string, int>(defaultStructural(), name = cacheName)
let mutable factoryCalls = 0
let factory k = factoryCalls <- factoryCalls + 1; String.length k
let v1 = cache.GetOrAdd("abc", factory)
Expand All @@ -174,7 +194,7 @@ let ``GetOrAdd basic usage`` () =
let ``AddOrUpdate basic usage`` () =
let cacheName = "AddOrUpdate_basic_usage"
use _ = CacheMetrics.ListenToAll()
use cache = new Cache<string, int>(defaultStructural(), name = cacheName)
let cache = new Cache<string, int>(defaultStructural(), name = cacheName)
cache.AddOrUpdate("x", 1)
let mutable value = 0
cache.TryGetValue("x", &value) |> shouldBeTrue
Expand All @@ -199,7 +219,7 @@ type BoxedKey = BoxedKey of int * int
let ``GetOrAdd with reference identity`` () =
let cacheName = "GetOrAdd_with_Reference"
use _ = CacheMetrics.ListenToAll()
use cache = new Cache<BoxedKey, int>(CacheOptions.getReferenceIdentity(), cacheName)
let cache = new Cache<BoxedKey, int>(CacheOptions.getReferenceIdentity(), cacheName)
let t1 = BoxedKey (1, 2)
let t2 = BoxedKey (1, 2)
let t3 = BoxedKey (1, 2)
Expand Down Expand Up @@ -236,7 +256,7 @@ let ``GetOrAdd with reference identity`` () =
let ``AddOrUpdate with reference identity`` () =
let cacheName = "AddOrUpdate_with_Reference"
use _ = CacheMetrics.ListenToAll()
use cache = new Cache<obj, int>(CacheOptions.getReferenceIdentity(), name = cacheName)
let cache = new Cache<obj, int>(CacheOptions.getReferenceIdentity(), name = cacheName)
let t1 = box (3, 4)
let t2 = box (3, 4)
cache.AddOrUpdate(t1, 7)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ type internal FSharpWorkspaceServiceFactory
[<Composition.ImportingConstructor>]
(editorOptions: EditorOptions, metadataAsSourceService: FSharpMetadataAsSourceService) =

static do
FSharp.Compiler.Caches.EvictionProcessor.Error.Add(fun ex ->
ThreadHelper.JoinableTaskFactory.RunAsync(fun () ->
task {
do! ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync()
System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex).Throw()
})
|> ignore)

let tryGetMetadataSnapshot (workspace: VisualStudioWorkspace) (path, timeStamp) =
try
let md =
Expand Down
Loading