Skip to content

Commit b0d8141

Browse files
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]>
1 parent 57ce45e commit b0d8141

3 files changed

Lines changed: 148 additions & 12 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: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,72 @@ type AsyncSeqBuilderBenchmarks() =
113113
|> AsyncSeq.iterAsync (fun _ -> async.Return())
114114
|> Async.RunSynchronously
115115

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+
116182
/// Entry point for running benchmarks
117183
module AsyncSeqBenchmarkRunner =
118184

@@ -138,15 +204,25 @@ module AsyncSeqBenchmarkRunner =
138204
printfn "Running Builder Pattern Benchmarks..."
139205
BenchmarkRunner.Run<AsyncSeqBuilderBenchmarks>() |> ignore
140206
0
207+
| Some "filter-choose-fold" ->
208+
printfn "Running Filter/Choose/Fold Benchmarks..."
209+
BenchmarkRunner.Run<AsyncSeqFilterChooseFoldBenchmarks>() |> ignore
210+
0
211+
| Some "pipeline" ->
212+
printfn "Running Pipeline Composition Benchmarks..."
213+
BenchmarkRunner.Run<AsyncSeqPipelineBenchmarks>() |> ignore
214+
0
141215
| Some "all" | None ->
142216
printfn "Running All Benchmarks..."
143217
BenchmarkRunner.Run<AsyncSeqCoreBenchmarks>() |> ignore
144218
BenchmarkRunner.Run<AsyncSeqAppendBenchmarks>() |> ignore
145219
BenchmarkRunner.Run<AsyncSeqBuilderBenchmarks>() |> ignore
220+
BenchmarkRunner.Run<AsyncSeqFilterChooseFoldBenchmarks>() |> ignore
221+
BenchmarkRunner.Run<AsyncSeqPipelineBenchmarks>() |> ignore
146222
0
147223
| Some suite ->
148224
printfn "Unknown benchmark suite: %s" suite
149-
printfn "Available suites: core, append, builder, all"
225+
printfn "Available suites: core, append, builder, filter-choose-fold, pipeline, all"
150226
1
151227

152228
printfn ""

0 commit comments

Comments
 (0)