Skip to content

Commit 78bf31a

Browse files
authored
Merge pull request #287 from fsprojects/repo-assist/perf-mapiasync-2026-03-23-18b8b82a487eb386
[Repo Assist] Perf: optimise `mapiAsync` with direct enumerator
2 parents e24654a + 52f10a4 commit 78bf31a

File tree

3 files changed

+58
-10
lines changed

3 files changed

+58
-10
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
### 4.11.0
22

3+
* Performance: `mapiAsync` — replaced `asyncSeq`-builder + `collect` implementation with a direct optimised enumerator (`OptimizedMapiAsyncEnumerator`), eliminating `collect` overhead and bringing per-element cost in line with `mapAsync`. Benchmarks added in `AsyncSeqMapiBenchmarks`.
34
* Design parity with FSharp.Control.TaskSeq (#277, batch 2):
45
* Added `AsyncSeq.tryTail` — returns `None` if the sequence is empty; otherwise returns `Some` of the tail. Safe counterpart to `tail`. Mirrors `TaskSeq.tryTail`.
56
* Added `AsyncSeq.where` / `AsyncSeq.whereAsync` — aliases for `filter` / `filterAsync`, mirroring the naming convention in `TaskSeq` and F# 8 collection expressions.

src/FSharp.Control.AsyncSeq/AsyncSeq.fs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,29 @@ module AsyncSeq =
933933
disposed <- true
934934
source.Dispose()
935935

936+
// Optimized mapiAsync enumerator: avoids asyncSeq builder + collect overhead by
937+
// maintaining the index in a mutable field and iterating the source directly.
938+
type private OptimizedMapiAsyncEnumerator<'T, 'TResult>(source: IAsyncSeqEnumerator<'T>, f: int64 -> 'T -> Async<'TResult>) =
939+
let mutable disposed = false
940+
let mutable index = 0L
941+
942+
interface IAsyncSeqEnumerator<'TResult> with
943+
member _.MoveNext() = async {
944+
let! moveResult = source.MoveNext()
945+
match moveResult with
946+
| None -> return None
947+
| Some value ->
948+
let i = index
949+
index <- index + 1L
950+
let! mapped = f i value
951+
return Some mapped
952+
}
953+
954+
member _.Dispose() =
955+
if not disposed then
956+
disposed <- true
957+
source.Dispose()
958+
936959
// Optimized filterAsync enumerator that avoids computation builder overhead
937960
type private OptimizedFilterAsyncEnumerator<'T>(source: IAsyncSeqEnumerator<'T>, f: 'T -> Async<bool>) =
938961
let mutable disposed = false
@@ -1039,12 +1062,8 @@ module AsyncSeq =
10391062
| _ ->
10401063
AsyncSeqImpl(fun () -> new OptimizedMapAsyncEnumerator<'T, 'TResult>(source.GetEnumerator(), f) :> IAsyncSeqEnumerator<'TResult>) :> AsyncSeq<'TResult>
10411064

1042-
let mapiAsync f (source : AsyncSeq<'T>) : AsyncSeq<'TResult> = asyncSeq {
1043-
let i = ref 0L
1044-
for itm in source do
1045-
let! v = f i.Value itm
1046-
i := i.Value + 1L
1047-
yield v }
1065+
let mapiAsync f (source : AsyncSeq<'T>) : AsyncSeq<'TResult> =
1066+
AsyncSeqImpl(fun () -> new OptimizedMapiAsyncEnumerator<'T, 'TResult>(source.GetEnumerator(), f) :> IAsyncSeqEnumerator<'TResult>) :> AsyncSeq<'TResult>
10481067

10491068
#if !FABLE_COMPILER
10501069
let mapAsyncParallel (f:'a -> Async<'b>) (s:AsyncSeq<'a>) : AsyncSeq<'b> = asyncSeq {

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

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,6 @@ type AsyncSeqPipelineBenchmarks() =
183183
[<MemoryDiagnoser>]
184184
[<SimpleJob(RuntimeMoniker.Net80)>]
185185
type AsyncSeqSliceBenchmarks() =
186-
187-
[<Params(1000, 10000)>]
188-
member val ElementCount = 0 with get, set
189-
190186
/// Benchmark take: stops after N elements
191187
[<Benchmark(Baseline = true)>]
192188
member this.Take() =
@@ -214,6 +210,38 @@ type AsyncSeqSliceBenchmarks() =
214210
|> AsyncSeq.iterAsync (fun _ -> async.Return())
215211
|> Async.RunSynchronously
216212

213+
[<Params(1000, 10000)>]
214+
member val ElementCount = 0 with get, set
215+
216+
/// Benchmarks for map and mapi variants — ensures the direct-enumerator optimisation
217+
/// for mapiAsync is visible and comparable against mapAsync.
218+
[<MemoryDiagnoser>]
219+
[<SimpleJob(RuntimeMoniker.Net80)>]
220+
type AsyncSeqMapiBenchmarks() =
221+
/// Baseline: mapAsync (already uses direct enumerator)
222+
[<Benchmark(Baseline = true)>]
223+
member this.MapAsync() =
224+
AsyncSeq.replicate this.ElementCount 1
225+
|> AsyncSeq.mapAsync (fun x -> async.Return (x * 2))
226+
|> AsyncSeq.iterAsync (fun _ -> async.Return())
227+
|> Async.RunSynchronously
228+
229+
/// mapiAsync — now uses direct enumerator; should be close to mapAsync cost
230+
[<Benchmark>]
231+
member this.MapiAsync() =
232+
AsyncSeq.replicate this.ElementCount 1
233+
|> AsyncSeq.mapiAsync (fun i x -> async.Return (i, x * 2))
234+
|> AsyncSeq.iterAsync (fun _ -> async.Return())
235+
|> Async.RunSynchronously
236+
237+
/// mapi — synchronous projection variant; dispatches through mapiAsync
238+
[<Benchmark>]
239+
member this.Mapi() =
240+
AsyncSeq.replicate this.ElementCount 1
241+
|> AsyncSeq.mapi (fun i x -> (i, x * 2))
242+
|> AsyncSeq.iterAsync (fun _ -> async.Return())
243+
|> Async.RunSynchronously
244+
217245
/// Entry point for running benchmarks.
218246
/// Delegates directly to BenchmarkSwitcher so all BenchmarkDotNet CLI options
219247
/// (--filter, --job short, --exporters, etc.) work out of the box.

0 commit comments

Comments
 (0)