Skip to content

Commit 374cfdc

Browse files
authored
Merge branch 'main' into repo-assist/design-parity-277-batch3-da69f793ddf235cc
2 parents 9c31b7b + 78bf31a commit 374cfdc

5 files changed

Lines changed: 62 additions & 15 deletions

File tree

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
build:
1010
runs-on: ubuntu-latest
1111
steps:
12-
- uses: actions/checkout@v2
12+
- uses: actions/checkout@v4
1313
- name: Setup .NET
1414
uses: actions/[email protected]
1515
with:

.github/workflows/pull-request.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ jobs:
88
build:
99
runs-on: ubuntu-latest
1010
steps:
11-
- uses: actions/checkout@v2
11+
- uses: actions/checkout@v4
1212
- name: Setup .NET
1313
uses: actions/[email protected]
1414
with:
1515
dotnet-version: '8.0.x'
1616
- name: Setup Node.js environment
17-
uses: actions/setup-node@v2.4.0
17+
uses: actions/setup-node@v4
1818
with:
19-
node-version: 14.17.*
19+
node-version: '20'
2020
- name: Install tools
2121
run: dotnet tool restore
2222
- name: Build and Test

RELEASE_NOTES.md

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

3-
* Part of ongoing design-parity work with FSharp.Control.TaskSeq (see #277).
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`.
44
* Design parity with FSharp.Control.TaskSeq (#277, batch 2):
55
* Added `AsyncSeq.tryTail` — returns `None` if the sequence is empty; otherwise returns `Some` of the tail. Safe counterpart to `tail`. Mirrors `TaskSeq.tryTail`.
66
* 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)