From b0d814122710573975103dbb9c00735fc1401a2d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 13 Mar 2026 00:11:42 +0000 Subject: [PATCH 1/4] Perf: optimise filterAsync, chooseAsync, and foldAsync with direct enumerators - filterAsync: replace asyncSeq-builder with OptimizedFilterAsyncEnumerator, avoiding AsyncGenerator allocation and generator-chain dispatch per element. - chooseAsync (non-AsyncSeqOp path): replace asyncSeq-builder with OptimizedChooseAsyncEnumerator for the same reason. - foldAsync (non-AsyncSeqOp path): replace scanAsync+lastOrDefault composition with a direct loop, eliminating the intermediate async sequence and its generator machinery entirely. - Add AsyncSeqFilterChooseFoldBenchmarks and AsyncSeqPipelineBenchmarks to measure the affected operations and catch future regressions. All 317 existing tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RELEASE_NOTES.md | 7 ++ src/FSharp.Control.AsyncSeq/AsyncSeq.fs | 75 +++++++++++++++--- .../AsyncSeqBenchmarks.fs | 78 ++++++++++++++++++- 3 files changed, 148 insertions(+), 12 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c88d2e5..b66939f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,10 @@ +### 4.9.0 + +* Performance: `filterAsync` — replaced `asyncSeq`-builder implementation with a direct optimised enumerator, reducing allocation and generator overhead. +* Performance: `chooseAsync` — fallback (non-`AsyncSeqOp`) path now uses a direct optimised enumerator instead of the `asyncSeq` builder. +* Performance: `foldAsync` — fallback (non-`AsyncSeqOp`) path now uses a direct loop instead of composing `scanAsync` + `lastOrDefault`, avoiding intermediate sequence allocations. +* Benchmarks: added `AsyncSeqFilterChooseFoldBenchmarks` and `AsyncSeqPipelineBenchmarks` benchmark classes to measure `filterAsync`, `chooseAsync`, `foldAsync`, `toArrayAsync`, and common multi-step pipelines. + ### 4.8.0 * Added `AsyncSeq.mapFoldAsync` — maps each element using an asynchronous folder that also threads an accumulator state, returning both the array of results and the final state; mirrors `Seq.mapFold`. diff --git a/src/FSharp.Control.AsyncSeq/AsyncSeq.fs b/src/FSharp.Control.AsyncSeq/AsyncSeq.fs index bf536f4..90fdd40 100644 --- a/src/FSharp.Control.AsyncSeq/AsyncSeq.fs +++ b/src/FSharp.Control.AsyncSeq/AsyncSeq.fs @@ -924,6 +924,56 @@ module AsyncSeq = disposed <- true source.Dispose() + // Optimized filterAsync enumerator that avoids computation builder overhead + type private OptimizedFilterAsyncEnumerator<'T>(source: IAsyncSeqEnumerator<'T>, f: 'T -> Async) = + let mutable disposed = false + + interface IAsyncSeqEnumerator<'T> with + member _.MoveNext() = async { + let mutable result: 'T option = None + let mutable isDone = false + while not isDone do + let! moveResult = source.MoveNext() + match moveResult with + | None -> isDone <- true + | Some value -> + let! keep = f value + if keep then + result <- Some value + isDone <- true + return result } + + member _.Dispose() = + if not disposed then + disposed <- true + source.Dispose() + + // Optimized chooseAsync enumerator that avoids computation builder overhead + type private OptimizedChooseAsyncEnumerator<'T, 'U>(source: IAsyncSeqEnumerator<'T>, f: 'T -> Async<'U option>) = + let mutable disposed = false + + interface IAsyncSeqEnumerator<'U> with + member _.MoveNext() = async { + let mutable result: 'U option = None + let mutable isDone = false + while not isDone do + let! moveResult = source.MoveNext() + match moveResult with + | None -> isDone <- true + | Some value -> + let! chosen = f value + match chosen with + | Some u -> + result <- Some u + isDone <- true + | None -> () + return result } + + member _.Dispose() = + if not disposed then + disposed <- true + source.Dispose() + let mapAsync f (source : AsyncSeq<'T>) : AsyncSeq<'TResult> = match source with | :? AsyncSeqOp<'T> as source -> source.MapAsync f @@ -1008,12 +1058,7 @@ module AsyncSeq = match source with | :? AsyncSeqOp<'T> as source -> source.ChooseAsync f | _ -> - asyncSeq { - for itm in source do - let! v = f itm - match v with - | Some v -> yield v - | _ -> () } + AsyncSeqImpl(fun () -> new OptimizedChooseAsyncEnumerator<'T, 'U>(source.GetEnumerator(), f) :> IAsyncSeqEnumerator<'U>) :> AsyncSeq<'U> let ofSeqAsync (source:seq>) : AsyncSeq<'T> = asyncSeq { @@ -1022,10 +1067,8 @@ module AsyncSeq = yield v } - let filterAsync f (source : AsyncSeq<'T>) = asyncSeq { - for v in source do - let! b = f v - if b then yield v } + let filterAsync f (source : AsyncSeq<'T>) : AsyncSeq<'T> = + AsyncSeqImpl(fun () -> new OptimizedFilterAsyncEnumerator<'T>(source.GetEnumerator(), f) :> IAsyncSeqEnumerator<'T>) :> AsyncSeq<'T> let tryLast (source : AsyncSeq<'T>) = async { use ie = source.GetEnumerator() @@ -1271,7 +1314,17 @@ module AsyncSeq = let foldAsync f (state:'State) (source : AsyncSeq<'T>) = match source with | :? AsyncSeqOp<'T> as source -> source.FoldAsync f state - | _ -> source |> scanAsync f state |> lastOrDefault state + | _ -> async { + use ie = source.GetEnumerator() + let mutable st = state + let! move = ie.MoveNext() + let mutable b = move + while b.IsSome do + let! st' = f st b.Value + st <- st' + let! next = ie.MoveNext() + b <- next + return st } let fold f (state:'State) (source : AsyncSeq<'T>) = foldAsync (fun st v -> f st v |> async.Return) state source diff --git a/tests/FSharp.Control.AsyncSeq.Benchmarks/AsyncSeqBenchmarks.fs b/tests/FSharp.Control.AsyncSeq.Benchmarks/AsyncSeqBenchmarks.fs index d15ef8a..c27380e 100644 --- a/tests/FSharp.Control.AsyncSeq.Benchmarks/AsyncSeqBenchmarks.fs +++ b/tests/FSharp.Control.AsyncSeq.Benchmarks/AsyncSeqBenchmarks.fs @@ -113,6 +113,72 @@ type AsyncSeqBuilderBenchmarks() = |> AsyncSeq.iterAsync (fun _ -> async.Return()) |> Async.RunSynchronously +/// Benchmarks for filter, choose, and fold operations (optimised direct-enumerator implementations) +[] +[] +type AsyncSeqFilterChooseFoldBenchmarks() = + + [] + member val ElementCount = 0 with get, set + + /// Benchmark filterAsync — all elements pass the predicate + [] + member this.FilterAsyncAllPass() = + AsyncSeq.replicate this.ElementCount 1 + |> AsyncSeq.filterAsync (fun _ -> async.Return true) + |> AsyncSeq.iterAsync (fun _ -> async.Return()) + |> Async.RunSynchronously + + /// Benchmark filterAsync — no elements pass the predicate (entire sequence scanned) + [] + member this.FilterAsyncNonePass() = + AsyncSeq.replicate this.ElementCount 1 + |> AsyncSeq.filterAsync (fun _ -> async.Return false) + |> AsyncSeq.iterAsync (fun _ -> async.Return()) + |> Async.RunSynchronously + + /// Benchmark chooseAsync — all elements selected + [] + member this.ChooseAsyncAllSelected() = + AsyncSeq.replicate this.ElementCount 42 + |> AsyncSeq.chooseAsync (fun x -> async.Return (Some x)) + |> AsyncSeq.iterAsync (fun _ -> async.Return()) + |> Async.RunSynchronously + + /// Benchmark foldAsync — sum all elements + [] + member this.FoldAsync() = + AsyncSeq.replicate this.ElementCount 1 + |> AsyncSeq.foldAsync (fun acc x -> async.Return (acc + x)) 0 + |> Async.RunSynchronously + |> ignore + +/// Benchmarks for multi-step pipeline composition +[] +[] +type AsyncSeqPipelineBenchmarks() = + + [] + member val ElementCount = 0 with get, set + + /// Benchmark map → filter → fold pipeline (exercises the three optimised combinators together) + [] + member this.MapFilterFold() = + AsyncSeq.replicate this.ElementCount 1 + |> AsyncSeq.mapAsync (fun x -> async.Return (x * 2)) + |> AsyncSeq.filterAsync (fun x -> async.Return (x > 0)) + |> AsyncSeq.foldAsync (fun acc x -> async.Return (acc + x)) 0 + |> Async.RunSynchronously + |> ignore + + /// Benchmark collecting to an array + [] + member this.ToArray() = + AsyncSeq.replicate this.ElementCount 1 + |> AsyncSeq.toArrayAsync + |> Async.RunSynchronously + |> ignore + /// Entry point for running benchmarks module AsyncSeqBenchmarkRunner = @@ -138,15 +204,25 @@ module AsyncSeqBenchmarkRunner = printfn "Running Builder Pattern Benchmarks..." BenchmarkRunner.Run() |> ignore 0 + | Some "filter-choose-fold" -> + printfn "Running Filter/Choose/Fold Benchmarks..." + BenchmarkRunner.Run() |> ignore + 0 + | Some "pipeline" -> + printfn "Running Pipeline Composition Benchmarks..." + BenchmarkRunner.Run() |> ignore + 0 | Some "all" | None -> printfn "Running All Benchmarks..." BenchmarkRunner.Run() |> ignore BenchmarkRunner.Run() |> ignore BenchmarkRunner.Run() |> ignore + BenchmarkRunner.Run() |> ignore + BenchmarkRunner.Run() |> ignore 0 | Some suite -> printfn "Unknown benchmark suite: %s" suite - printfn "Available suites: core, append, builder, all" + printfn "Available suites: core, append, builder, filter-choose-fold, pipeline, all" 1 printfn "" From e6f59b2299b435de63027c6221e6bce5812f3027 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 13 Mar 2026 00:16:28 +0000 Subject: [PATCH 2/4] ci: trigger checks From 4c4275acbcf221c1c66a63ba1a20c0210d004f10 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 13 Mar 2026 23:38:48 +0000 Subject: [PATCH 3/4] benchmarks: switch runner to BenchmarkSwitcher for full CLI arg support Use BenchmarkSwitcher.FromAssembly instead of custom argument parsing, so BenchmarkDotNet CLI options (--filter, --job short, --inProcess, etc.) work out of the box when running the benchmarks directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AsyncSeqBenchmarks.fs | 60 ++++--------------- 1 file changed, 13 insertions(+), 47 deletions(-) diff --git a/tests/FSharp.Control.AsyncSeq.Benchmarks/AsyncSeqBenchmarks.fs b/tests/FSharp.Control.AsyncSeq.Benchmarks/AsyncSeqBenchmarks.fs index c27380e..b3189ca 100644 --- a/tests/FSharp.Control.AsyncSeq.Benchmarks/AsyncSeqBenchmarks.fs +++ b/tests/FSharp.Control.AsyncSeq.Benchmarks/AsyncSeqBenchmarks.fs @@ -179,53 +179,19 @@ type AsyncSeqPipelineBenchmarks() = |> Async.RunSynchronously |> ignore -/// Entry point for running benchmarks +/// Entry point for running benchmarks. +/// Delegates directly to BenchmarkSwitcher so all BenchmarkDotNet CLI options +/// (--filter, --job short, --exporters, etc.) work out of the box. +/// Examples: +/// dotnet run -c Release # run all +/// dotnet run -c Release -- --filter '*Filter*' # specific class +/// dotnet run -c Release -- --filter '*' --job short # quick smoke-run module AsyncSeqBenchmarkRunner = - + [] let Main args = - printfn "AsyncSeq Performance Benchmarks" - printfn "================================" - printfn "Running comprehensive performance benchmarks to establish baseline metrics" - printfn "and verify fixes for known performance issues (memory leaks, O(n²) patterns)." - printfn "" - - let result = - match args |> Array.tryHead with - | Some "core" -> - printfn "Running Core Operations Benchmarks..." - BenchmarkRunner.Run() |> ignore - 0 - | Some "append" -> - printfn "Running Append Operations Benchmarks..." - BenchmarkRunner.Run() |> ignore - 0 - | Some "builder" -> - printfn "Running Builder Pattern Benchmarks..." - BenchmarkRunner.Run() |> ignore - 0 - | Some "filter-choose-fold" -> - printfn "Running Filter/Choose/Fold Benchmarks..." - BenchmarkRunner.Run() |> ignore - 0 - | Some "pipeline" -> - printfn "Running Pipeline Composition Benchmarks..." - BenchmarkRunner.Run() |> ignore - 0 - | Some "all" | None -> - printfn "Running All Benchmarks..." - BenchmarkRunner.Run() |> ignore - BenchmarkRunner.Run() |> ignore - BenchmarkRunner.Run() |> ignore - BenchmarkRunner.Run() |> ignore - BenchmarkRunner.Run() |> ignore - 0 - | Some suite -> - printfn "Unknown benchmark suite: %s" suite - printfn "Available suites: core, append, builder, filter-choose-fold, pipeline, all" - 1 - - printfn "" - printfn "Benchmarks completed. Results provide baseline performance metrics" - printfn "for future performance improvements and regression detection." - result \ No newline at end of file + BenchmarkSwitcher + .FromAssembly(typeof.Assembly) + .Run(args) + |> ignore + 0 \ No newline at end of file From a3297caa01362a67d89ba2d0fc1e965b77fe777b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 13 Mar 2026 23:40:36 +0000 Subject: [PATCH 4/4] ci: trigger checks