Skip to content

Commit 0761f5d

Browse files
authored
Merge pull request #279 from fsprojects/repo-assist/perf-take-skip-enumerators-b793dd82cef306c6
[Repo Assist] Perf: optimise `take` and `skip` with direct enumerators
2 parents 136df98 + 4ee76f3 commit 0761f5d

File tree

4 files changed

+150
-28
lines changed

4 files changed

+150
-28
lines changed

RELEASE_NOTES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
* Performance: `filterAsync` — replaced `asyncSeq`-builder implementation with a direct optimised enumerator, reducing allocation and generator overhead.
2424
* Performance: `chooseAsync` — fallback (non-`AsyncSeqOp`) path now uses a direct optimised enumerator instead of the `asyncSeq` builder.
2525
* Performance: `foldAsync` — fallback (non-`AsyncSeqOp`) path now uses a direct loop instead of composing `scanAsync` + `lastOrDefault`, avoiding intermediate sequence allocations.
26-
* Benchmarks: added `AsyncSeqFilterChooseFoldBenchmarks` and `AsyncSeqPipelineBenchmarks` benchmark classes to measure `filterAsync`, `chooseAsync`, `foldAsync`, `toArrayAsync`, and common multi-step pipelines.
26+
* Performance: `take` — replaced `asyncSeq`-builder implementation with a direct optimised enumerator (`OptimizedTakeEnumerator`), eliminating generator-machinery overhead for this common slicing operation.
27+
* Performance: `skip` — replaced `asyncSeq`-builder implementation with a direct optimised enumerator (`OptimizedSkipEnumerator`), eliminating generator-machinery overhead for this common slicing operation.
28+
* Benchmarks: added `AsyncSeqFilterChooseFoldBenchmarks`, `AsyncSeqPipelineBenchmarks`, and `AsyncSeqSliceBenchmarks` benchmark classes.
2729

2830
### 4.8.0
2931

src/FSharp.Control.AsyncSeq/AsyncSeq.fs

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,56 @@ module AsyncSeq =
983983
disposed <- true
984984
source.Dispose()
985985

986+
// Optimized take enumerator: stops after yielding `count` elements without asyncSeq builder overhead
987+
type private OptimizedTakeEnumerator<'T>(source: IAsyncSeqEnumerator<'T>, count: int) =
988+
let mutable disposed = false
989+
let mutable remaining = count
990+
991+
interface IAsyncSeqEnumerator<'T> with
992+
member _.MoveNext() = async {
993+
if remaining <= 0 then return None
994+
else
995+
let! result = source.MoveNext()
996+
match result with
997+
| None -> return None
998+
| Some value ->
999+
remaining <- remaining - 1
1000+
return Some value }
1001+
1002+
member _.Dispose() =
1003+
if not disposed then
1004+
disposed <- true
1005+
source.Dispose()
1006+
1007+
// Optimized skip enumerator: discards the first `count` elements without asyncSeq builder overhead
1008+
type private OptimizedSkipEnumerator<'T>(source: IAsyncSeqEnumerator<'T>, count: int) =
1009+
let mutable disposed = false
1010+
let mutable toSkip = count
1011+
let mutable exhausted = false
1012+
1013+
interface IAsyncSeqEnumerator<'T> with
1014+
member _.MoveNext() = async {
1015+
if exhausted then return None
1016+
else
1017+
// Drain skipped elements on the first call (toSkip > 0 only initially)
1018+
let mutable doneSkipping = false
1019+
while toSkip > 0 && not doneSkipping do
1020+
let! result = source.MoveNext()
1021+
match result with
1022+
| None ->
1023+
toSkip <- 0
1024+
exhausted <- true
1025+
doneSkipping <- true
1026+
| Some _ ->
1027+
toSkip <- toSkip - 1
1028+
if exhausted then return None
1029+
else return! source.MoveNext() }
1030+
1031+
member _.Dispose() =
1032+
if not disposed then
1033+
disposed <- true
1034+
source.Dispose()
1035+
9861036
let mapAsync f (source : AsyncSeq<'T>) : AsyncSeq<'TResult> =
9871037
match source with
9881038
| :? AsyncSeqOp<'T> as source -> source.MapAsync f
@@ -1984,36 +2034,15 @@ module AsyncSeq =
19842034
let skipWhile p (source : AsyncSeq<'T>) =
19852035
skipWhileAsync (p >> async.Return) source
19862036

1987-
let take count (source : AsyncSeq<'T>) : AsyncSeq<_> = asyncSeq {
1988-
if (count < 0) then invalidArg "count" "must be non-negative"
1989-
use ie = source.GetEnumerator()
1990-
let n = ref count
1991-
if n.Value > 0 then
1992-
let! move = ie.MoveNext()
1993-
let b = ref move
1994-
while b.Value.IsSome do
1995-
yield b.Value.Value
1996-
n := n.Value - 1
1997-
if n.Value > 0 then
1998-
let! moven = ie.MoveNext()
1999-
b := moven
2000-
else b := None }
2037+
let take count (source : AsyncSeq<'T>) : AsyncSeq<_> =
2038+
if count < 0 then invalidArg "count" "must be non-negative"
2039+
AsyncSeqImpl(fun () -> new OptimizedTakeEnumerator<'T>(source.GetEnumerator(), count) :> IAsyncSeqEnumerator<'T>) :> AsyncSeq<'T>
20012040

20022041
let truncate count source = take count source
20032042

2004-
let skip count (source : AsyncSeq<'T>) : AsyncSeq<_> = asyncSeq {
2005-
if (count < 0) then invalidArg "count" "must be non-negative"
2006-
use ie = source.GetEnumerator()
2007-
let! move = ie.MoveNext()
2008-
let b = ref move
2009-
let n = ref count
2010-
while b.Value.IsSome do
2011-
if n.Value = 0 then
2012-
yield b.Value.Value
2013-
else
2014-
n := n.Value - 1
2015-
let! moven = ie.MoveNext()
2016-
b := moven }
2043+
let skip count (source : AsyncSeq<'T>) : AsyncSeq<_> =
2044+
if count < 0 then invalidArg "count" "must be non-negative"
2045+
AsyncSeqImpl(fun () -> new OptimizedSkipEnumerator<'T>(source.GetEnumerator(), count) :> IAsyncSeqEnumerator<'T>) :> AsyncSeq<'T>
20172046

20182047
let tail (source : AsyncSeq<'T>) : AsyncSeq<'T> = skip 1 source
20192048

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,41 @@ type AsyncSeqPipelineBenchmarks() =
179179
|> Async.RunSynchronously
180180
|> ignore
181181

182+
/// Benchmarks for take and skip — common slicing operations
183+
[<MemoryDiagnoser>]
184+
[<SimpleJob(RuntimeMoniker.Net80)>]
185+
type AsyncSeqSliceBenchmarks() =
186+
187+
[<Params(1000, 10000)>]
188+
member val ElementCount = 0 with get, set
189+
190+
/// Benchmark take: stops after N elements
191+
[<Benchmark(Baseline = true)>]
192+
member this.Take() =
193+
AsyncSeq.replicateInfinite 1
194+
|> AsyncSeq.take this.ElementCount
195+
|> AsyncSeq.iterAsync (fun _ -> async.Return())
196+
|> Async.RunSynchronously
197+
198+
/// Benchmark skip then iterate remaining elements
199+
[<Benchmark>]
200+
member this.Skip() =
201+
let skipCount = this.ElementCount / 2
202+
AsyncSeq.replicate this.ElementCount 1
203+
|> AsyncSeq.skip skipCount
204+
|> AsyncSeq.iterAsync (fun _ -> async.Return())
205+
|> Async.RunSynchronously
206+
207+
/// Benchmark skip then take (common pagination pattern)
208+
[<Benchmark>]
209+
member this.SkipThenTake() =
210+
let page = this.ElementCount / 10
211+
AsyncSeq.replicate this.ElementCount 1
212+
|> AsyncSeq.skip page
213+
|> AsyncSeq.take page
214+
|> AsyncSeq.iterAsync (fun _ -> async.Return())
215+
|> Async.RunSynchronously
216+
182217
/// Entry point for running benchmarks.
183218
/// Delegates directly to BenchmarkSwitcher so all BenchmarkDotNet CLI options
184219
/// (--filter, --job short, --exporters, etc.) work out of the box.

tests/FSharp.Control.AsyncSeq.Tests/AsyncSeqTests.fs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3710,6 +3710,62 @@ let ``AsyncSeq.insertAt raises ArgumentException when index exceeds length`` ()
37103710
|> Async.RunSynchronously |> ignore)
37113711
|> ignore
37123712

3713+
[<Test>]
3714+
let ``AsyncSeq.take more than length returns all elements`` () =
3715+
let result =
3716+
AsyncSeq.ofSeq [ 1; 2; 3 ]
3717+
|> AsyncSeq.take 10
3718+
|> AsyncSeq.toArrayAsync
3719+
|> Async.RunSynchronously
3720+
Assert.AreEqual([| 1; 2; 3 |], result)
3721+
3722+
[<Test>]
3723+
let ``AsyncSeq.take raises ArgumentException for negative count`` () =
3724+
Assert.Throws<System.ArgumentException>(fun () ->
3725+
AsyncSeq.ofSeq [ 1; 2; 3 ]
3726+
|> AsyncSeq.take -1
3727+
|> AsyncSeq.toArrayAsync
3728+
|> Async.RunSynchronously |> ignore)
3729+
|> ignore
3730+
3731+
[<Test>]
3732+
let ``AsyncSeq.take from infinite sequence`` () =
3733+
let result =
3734+
AsyncSeq.replicateInfinite 7
3735+
|> AsyncSeq.take 5
3736+
|> AsyncSeq.toArrayAsync
3737+
|> Async.RunSynchronously
3738+
Assert.AreEqual([| 7; 7; 7; 7; 7 |], result)
3739+
3740+
[<Test>]
3741+
let ``AsyncSeq.skip more than length returns empty`` () =
3742+
let result =
3743+
AsyncSeq.ofSeq [ 1; 2; 3 ]
3744+
|> AsyncSeq.skip 10
3745+
|> AsyncSeq.toArrayAsync
3746+
|> Async.RunSynchronously
3747+
Assert.AreEqual([||], result)
3748+
3749+
[<Test>]
3750+
let ``AsyncSeq.skip raises ArgumentException for negative count`` () =
3751+
Assert.Throws<System.ArgumentException>(fun () ->
3752+
AsyncSeq.ofSeq [ 1; 2; 3 ]
3753+
|> AsyncSeq.skip -1
3754+
|> AsyncSeq.toArrayAsync
3755+
|> Async.RunSynchronously |> ignore)
3756+
|> ignore
3757+
3758+
[<Test>]
3759+
let ``AsyncSeq.take then skip roundtrip`` () =
3760+
let source = [| 1..20 |]
3761+
let result =
3762+
AsyncSeq.ofSeq source
3763+
|> AsyncSeq.skip 5
3764+
|> AsyncSeq.take 10
3765+
|> AsyncSeq.toArrayAsync
3766+
|> Async.RunSynchronously
3767+
Assert.AreEqual([| 6..15 |], result)
3768+
37133769
// ===== withCancellation =====
37143770

37153771
[<Test>]

0 commit comments

Comments
 (0)