Skip to content

Commit 7c774fe

Browse files
[Repo Assist] Perf: optimise filterAsync, chooseAsync, and foldAsync with direct enumerators (#276)
* 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 <[email protected]> * ci: trigger checks * 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 <[email protected]> * ci: trigger checks --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <[email protected]>
1 parent 57ce45e commit 7c774fe

3 files changed

Lines changed: 150 additions & 48 deletions

File tree

RELEASE_NOTES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
### 4.9.0
2+
3+
* Performance: `filterAsync` — replaced `asyncSeq`-builder implementation with a direct optimised enumerator, reducing allocation and generator overhead.
4+
* Performance: `chooseAsync` — fallback (non-`AsyncSeqOp`) path now uses a direct optimised enumerator instead of the `asyncSeq` builder.
5+
* Performance: `foldAsync` — fallback (non-`AsyncSeqOp`) path now uses a direct loop instead of composing `scanAsync` + `lastOrDefault`, avoiding intermediate sequence allocations.
6+
* Benchmarks: added `AsyncSeqFilterChooseFoldBenchmarks` and `AsyncSeqPipelineBenchmarks` benchmark classes to measure `filterAsync`, `chooseAsync`, `foldAsync`, `toArrayAsync`, and common multi-step pipelines.
7+
18
### 4.8.0
29

310
* 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`.

src/FSharp.Control.AsyncSeq/AsyncSeq.fs

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,56 @@ module AsyncSeq =
924924
disposed <- true
925925
source.Dispose()
926926

927+
// Optimized filterAsync enumerator that avoids computation builder overhead
928+
type private OptimizedFilterAsyncEnumerator<'T>(source: IAsyncSeqEnumerator<'T>, f: 'T -> Async<bool>) =
929+
let mutable disposed = false
930+
931+
interface IAsyncSeqEnumerator<'T> with
932+
member _.MoveNext() = async {
933+
let mutable result: 'T option = None
934+
let mutable isDone = false
935+
while not isDone do
936+
let! moveResult = source.MoveNext()
937+
match moveResult with
938+
| None -> isDone <- true
939+
| Some value ->
940+
let! keep = f value
941+
if keep then
942+
result <- Some value
943+
isDone <- true
944+
return result }
945+
946+
member _.Dispose() =
947+
if not disposed then
948+
disposed <- true
949+
source.Dispose()
950+
951+
// Optimized chooseAsync enumerator that avoids computation builder overhead
952+
type private OptimizedChooseAsyncEnumerator<'T, 'U>(source: IAsyncSeqEnumerator<'T>, f: 'T -> Async<'U option>) =
953+
let mutable disposed = false
954+
955+
interface IAsyncSeqEnumerator<'U> with
956+
member _.MoveNext() = async {
957+
let mutable result: 'U option = None
958+
let mutable isDone = false
959+
while not isDone do
960+
let! moveResult = source.MoveNext()
961+
match moveResult with
962+
| None -> isDone <- true
963+
| Some value ->
964+
let! chosen = f value
965+
match chosen with
966+
| Some u ->
967+
result <- Some u
968+
isDone <- true
969+
| None -> ()
970+
return result }
971+
972+
member _.Dispose() =
973+
if not disposed then
974+
disposed <- true
975+
source.Dispose()
976+
927977
let mapAsync f (source : AsyncSeq<'T>) : AsyncSeq<'TResult> =
928978
match source with
929979
| :? AsyncSeqOp<'T> as source -> source.MapAsync f
@@ -1008,12 +1058,7 @@ module AsyncSeq =
10081058
match source with
10091059
| :? AsyncSeqOp<'T> as source -> source.ChooseAsync f
10101060
| _ ->
1011-
asyncSeq {
1012-
for itm in source do
1013-
let! v = f itm
1014-
match v with
1015-
| Some v -> yield v
1016-
| _ -> () }
1061+
AsyncSeqImpl(fun () -> new OptimizedChooseAsyncEnumerator<'T, 'U>(source.GetEnumerator(), f) :> IAsyncSeqEnumerator<'U>) :> AsyncSeq<'U>
10171062

10181063
let ofSeqAsync (source:seq<Async<'T>>) : AsyncSeq<'T> =
10191064
asyncSeq {
@@ -1022,10 +1067,8 @@ module AsyncSeq =
10221067
yield v
10231068
}
10241069

1025-
let filterAsync f (source : AsyncSeq<'T>) = asyncSeq {
1026-
for v in source do
1027-
let! b = f v
1028-
if b then yield v }
1070+
let filterAsync f (source : AsyncSeq<'T>) : AsyncSeq<'T> =
1071+
AsyncSeqImpl(fun () -> new OptimizedFilterAsyncEnumerator<'T>(source.GetEnumerator(), f) :> IAsyncSeqEnumerator<'T>) :> AsyncSeq<'T>
10291072

10301073
let tryLast (source : AsyncSeq<'T>) = async {
10311074
use ie = source.GetEnumerator()
@@ -1271,7 +1314,17 @@ module AsyncSeq =
12711314
let foldAsync f (state:'State) (source : AsyncSeq<'T>) =
12721315
match source with
12731316
| :? AsyncSeqOp<'T> as source -> source.FoldAsync f state
1274-
| _ -> source |> scanAsync f state |> lastOrDefault state
1317+
| _ -> async {
1318+
use ie = source.GetEnumerator()
1319+
let mutable st = state
1320+
let! move = ie.MoveNext()
1321+
let mutable b = move
1322+
while b.IsSome do
1323+
let! st' = f st b.Value
1324+
st <- st'
1325+
let! next = ie.MoveNext()
1326+
b <- next
1327+
return st }
12751328

12761329
let fold f (state:'State) (source : AsyncSeq<'T>) =
12771330
foldAsync (fun st v -> f st v |> async.Return) state source

tests/FSharp.Control.AsyncSeq.Benchmarks/AsyncSeqBenchmarks.fs

Lines changed: 79 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -113,43 +113,85 @@ type AsyncSeqBuilderBenchmarks() =
113113
|> AsyncSeq.iterAsync (fun _ -> async.Return())
114114
|> Async.RunSynchronously
115115

116-
/// Entry point for running benchmarks
116+
/// Benchmarks for filter, choose, and fold operations (optimised direct-enumerator implementations)
117+
[<MemoryDiagnoser>]
118+
[<SimpleJob(RuntimeMoniker.Net80)>]
119+
type AsyncSeqFilterChooseFoldBenchmarks() =
120+
121+
[<Params(1000, 10000)>]
122+
member val ElementCount = 0 with get, set
123+
124+
/// Benchmark filterAsync — all elements pass the predicate
125+
[<Benchmark(Baseline = true)>]
126+
member this.FilterAsyncAllPass() =
127+
AsyncSeq.replicate this.ElementCount 1
128+
|> AsyncSeq.filterAsync (fun _ -> async.Return true)
129+
|> AsyncSeq.iterAsync (fun _ -> async.Return())
130+
|> Async.RunSynchronously
131+
132+
/// Benchmark filterAsync — no elements pass the predicate (entire sequence scanned)
133+
[<Benchmark>]
134+
member this.FilterAsyncNonePass() =
135+
AsyncSeq.replicate this.ElementCount 1
136+
|> AsyncSeq.filterAsync (fun _ -> async.Return false)
137+
|> AsyncSeq.iterAsync (fun _ -> async.Return())
138+
|> Async.RunSynchronously
139+
140+
/// Benchmark chooseAsync — all elements selected
141+
[<Benchmark>]
142+
member this.ChooseAsyncAllSelected() =
143+
AsyncSeq.replicate this.ElementCount 42
144+
|> AsyncSeq.chooseAsync (fun x -> async.Return (Some x))
145+
|> AsyncSeq.iterAsync (fun _ -> async.Return())
146+
|> Async.RunSynchronously
147+
148+
/// Benchmark foldAsync — sum all elements
149+
[<Benchmark>]
150+
member this.FoldAsync() =
151+
AsyncSeq.replicate this.ElementCount 1
152+
|> AsyncSeq.foldAsync (fun acc x -> async.Return (acc + x)) 0
153+
|> Async.RunSynchronously
154+
|> ignore
155+
156+
/// Benchmarks for multi-step pipeline composition
157+
[<MemoryDiagnoser>]
158+
[<SimpleJob(RuntimeMoniker.Net80)>]
159+
type AsyncSeqPipelineBenchmarks() =
160+
161+
[<Params(1000, 10000)>]
162+
member val ElementCount = 0 with get, set
163+
164+
/// Benchmark map → filter → fold pipeline (exercises the three optimised combinators together)
165+
[<Benchmark(Baseline = true)>]
166+
member this.MapFilterFold() =
167+
AsyncSeq.replicate this.ElementCount 1
168+
|> AsyncSeq.mapAsync (fun x -> async.Return (x * 2))
169+
|> AsyncSeq.filterAsync (fun x -> async.Return (x > 0))
170+
|> AsyncSeq.foldAsync (fun acc x -> async.Return (acc + x)) 0
171+
|> Async.RunSynchronously
172+
|> ignore
173+
174+
/// Benchmark collecting to an array
175+
[<Benchmark>]
176+
member this.ToArray() =
177+
AsyncSeq.replicate this.ElementCount 1
178+
|> AsyncSeq.toArrayAsync
179+
|> Async.RunSynchronously
180+
|> ignore
181+
182+
/// Entry point for running benchmarks.
183+
/// Delegates directly to BenchmarkSwitcher so all BenchmarkDotNet CLI options
184+
/// (--filter, --job short, --exporters, etc.) work out of the box.
185+
/// Examples:
186+
/// dotnet run -c Release # run all
187+
/// dotnet run -c Release -- --filter '*Filter*' # specific class
188+
/// dotnet run -c Release -- --filter '*' --job short # quick smoke-run
117189
module AsyncSeqBenchmarkRunner =
118-
190+
119191
[<EntryPoint>]
120192
let Main args =
121-
printfn "AsyncSeq Performance Benchmarks"
122-
printfn "================================"
123-
printfn "Running comprehensive performance benchmarks to establish baseline metrics"
124-
printfn "and verify fixes for known performance issues (memory leaks, O(n²) patterns)."
125-
printfn ""
126-
127-
let result =
128-
match args |> Array.tryHead with
129-
| Some "core" ->
130-
printfn "Running Core Operations Benchmarks..."
131-
BenchmarkRunner.Run<AsyncSeqCoreBenchmarks>() |> ignore
132-
0
133-
| Some "append" ->
134-
printfn "Running Append Operations Benchmarks..."
135-
BenchmarkRunner.Run<AsyncSeqAppendBenchmarks>() |> ignore
136-
0
137-
| Some "builder" ->
138-
printfn "Running Builder Pattern Benchmarks..."
139-
BenchmarkRunner.Run<AsyncSeqBuilderBenchmarks>() |> ignore
140-
0
141-
| Some "all" | None ->
142-
printfn "Running All Benchmarks..."
143-
BenchmarkRunner.Run<AsyncSeqCoreBenchmarks>() |> ignore
144-
BenchmarkRunner.Run<AsyncSeqAppendBenchmarks>() |> ignore
145-
BenchmarkRunner.Run<AsyncSeqBuilderBenchmarks>() |> ignore
146-
0
147-
| Some suite ->
148-
printfn "Unknown benchmark suite: %s" suite
149-
printfn "Available suites: core, append, builder, all"
150-
1
151-
152-
printfn ""
153-
printfn "Benchmarks completed. Results provide baseline performance metrics"
154-
printfn "for future performance improvements and regression detection."
155-
result
193+
BenchmarkSwitcher
194+
.FromAssembly(typeof<AsyncSeqCoreBenchmarks>.Assembly)
195+
.Run(args)
196+
|> ignore
197+
0

0 commit comments

Comments
 (0)