Skip to content

Commit 5efa087

Browse files
Perf: optimise take and skip with direct enumerators
Replace asyncSeq-builder implementations of `take` and `skip` with direct `IAsyncSeqEnumerator<'T>` types (OptimizedTakeEnumerator and OptimizedSkipEnumerator) following the same pattern as the existing optimised enumerators for mapAsync, filterAsync, chooseAsync, and foldAsync. The asyncSeq builder routes every element through the AsyncGenerator / GenerateCont machinery, allocating generator objects and dispatching through virtual calls at each step. Direct enumerators avoid this overhead entirely — they hold only the source enumerator and a small amount of mutable state (an int counter), with no intermediate allocations per element. Changes: - AsyncSeq.fs: add OptimizedTakeEnumerator<'T> and OptimizedSkipEnumerator<'T>; replace asyncSeq{} in take/skip - AsyncSeqBenchmarks.fs: add AsyncSeqSliceBenchmarks (Take, Skip, SkipThenTake benchmarks for 1 000 and 10 000 elements) - AsyncSeqTests.fs: 6 new edge-case tests (take >length, take -1, take from infinite, skip >length, skip -1, skip+take roundtrip) - RELEASE_NOTES.md: update 4.9.0 entry Co-authored-by: Copilot <[email protected]>
1 parent 7c774fe commit 5efa087

4 files changed

Lines changed: 150 additions & 28 deletions

File tree

RELEASE_NOTES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
* Performance: `filterAsync` — replaced `asyncSeq`-builder implementation with a direct optimised enumerator, reducing allocation and generator overhead.
44
* Performance: `chooseAsync` — fallback (non-`AsyncSeqOp`) path now uses a direct optimised enumerator instead of the `asyncSeq` builder.
55
* 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.
6+
* Performance: `take` — replaced `asyncSeq`-builder implementation with a direct optimised enumerator (`OptimizedTakeEnumerator`), eliminating generator-machinery overhead for this common slicing operation.
7+
* Performance: `skip` — replaced `asyncSeq`-builder implementation with a direct optimised enumerator (`OptimizedSkipEnumerator`), eliminating generator-machinery overhead for this common slicing operation.
8+
* Benchmarks: added `AsyncSeqFilterChooseFoldBenchmarks`, `AsyncSeqPipelineBenchmarks`, and `AsyncSeqSliceBenchmarks` benchmark classes.
79

810
### 4.8.0
911

src/FSharp.Control.AsyncSeq/AsyncSeq.fs

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,56 @@ module AsyncSeq =
974974
disposed <- true
975975
source.Dispose()
976976

977+
// Optimized take enumerator: stops after yielding `count` elements without asyncSeq builder overhead
978+
type private OptimizedTakeEnumerator<'T>(source: IAsyncSeqEnumerator<'T>, count: int) =
979+
let mutable disposed = false
980+
let mutable remaining = count
981+
982+
interface IAsyncSeqEnumerator<'T> with
983+
member _.MoveNext() = async {
984+
if remaining <= 0 then return None
985+
else
986+
let! result = source.MoveNext()
987+
match result with
988+
| None -> return None
989+
| Some value ->
990+
remaining <- remaining - 1
991+
return Some value }
992+
993+
member _.Dispose() =
994+
if not disposed then
995+
disposed <- true
996+
source.Dispose()
997+
998+
// Optimized skip enumerator: discards the first `count` elements without asyncSeq builder overhead
999+
type private OptimizedSkipEnumerator<'T>(source: IAsyncSeqEnumerator<'T>, count: int) =
1000+
let mutable disposed = false
1001+
let mutable toSkip = count
1002+
let mutable exhausted = false
1003+
1004+
interface IAsyncSeqEnumerator<'T> with
1005+
member _.MoveNext() = async {
1006+
if exhausted then return None
1007+
else
1008+
// Drain skipped elements on the first call (toSkip > 0 only initially)
1009+
let mutable doneSkipping = false
1010+
while toSkip > 0 && not doneSkipping do
1011+
let! result = source.MoveNext()
1012+
match result with
1013+
| None ->
1014+
toSkip <- 0
1015+
exhausted <- true
1016+
doneSkipping <- true
1017+
| Some _ ->
1018+
toSkip <- toSkip - 1
1019+
if exhausted then return None
1020+
else return! source.MoveNext() }
1021+
1022+
member _.Dispose() =
1023+
if not disposed then
1024+
disposed <- true
1025+
source.Dispose()
1026+
9771027
let mapAsync f (source : AsyncSeq<'T>) : AsyncSeq<'TResult> =
9781028
match source with
9791029
| :? AsyncSeqOp<'T> as source -> source.MapAsync f
@@ -1897,36 +1947,15 @@ module AsyncSeq =
18971947
let skipWhile p (source : AsyncSeq<'T>) =
18981948
skipWhileAsync (p >> async.Return) source
18991949

1900-
let take count (source : AsyncSeq<'T>) : AsyncSeq<_> = asyncSeq {
1901-
if (count < 0) then invalidArg "count" "must be non-negative"
1902-
use ie = source.GetEnumerator()
1903-
let n = ref count
1904-
if n.Value > 0 then
1905-
let! move = ie.MoveNext()
1906-
let b = ref move
1907-
while b.Value.IsSome do
1908-
yield b.Value.Value
1909-
n := n.Value - 1
1910-
if n.Value > 0 then
1911-
let! moven = ie.MoveNext()
1912-
b := moven
1913-
else b := None }
1950+
let take count (source : AsyncSeq<'T>) : AsyncSeq<_> =
1951+
if count < 0 then invalidArg "count" "must be non-negative"
1952+
AsyncSeqImpl(fun () -> new OptimizedTakeEnumerator<'T>(source.GetEnumerator(), count) :> IAsyncSeqEnumerator<'T>) :> AsyncSeq<'T>
19141953

19151954
let truncate count source = take count source
19161955

1917-
let skip count (source : AsyncSeq<'T>) : AsyncSeq<_> = asyncSeq {
1918-
if (count < 0) then invalidArg "count" "must be non-negative"
1919-
use ie = source.GetEnumerator()
1920-
let! move = ie.MoveNext()
1921-
let b = ref move
1922-
let n = ref count
1923-
while b.Value.IsSome do
1924-
if n.Value = 0 then
1925-
yield b.Value.Value
1926-
else
1927-
n := n.Value - 1
1928-
let! moven = ie.MoveNext()
1929-
b := moven }
1956+
let skip count (source : AsyncSeq<'T>) : AsyncSeq<_> =
1957+
if count < 0 then invalidArg "count" "must be non-negative"
1958+
AsyncSeqImpl(fun () -> new OptimizedSkipEnumerator<'T>(source.GetEnumerator(), count) :> IAsyncSeqEnumerator<'T>) :> AsyncSeq<'T>
19301959

19311960
let tail (source : AsyncSeq<'T>) : AsyncSeq<'T> = skip 1 source
19321961

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
@@ -3662,3 +3662,59 @@ let ``AsyncSeq.insertAt raises ArgumentException when index exceeds length`` ()
36623662
|> AsyncSeq.toArrayAsync
36633663
|> Async.RunSynchronously |> ignore)
36643664
|> ignore
3665+
3666+
[<Test>]
3667+
let ``AsyncSeq.take more than length returns all elements`` () =
3668+
let result =
3669+
AsyncSeq.ofSeq [ 1; 2; 3 ]
3670+
|> AsyncSeq.take 10
3671+
|> AsyncSeq.toArrayAsync
3672+
|> Async.RunSynchronously
3673+
Assert.AreEqual([| 1; 2; 3 |], result)
3674+
3675+
[<Test>]
3676+
let ``AsyncSeq.take raises ArgumentException for negative count`` () =
3677+
Assert.Throws<System.ArgumentException>(fun () ->
3678+
AsyncSeq.ofSeq [ 1; 2; 3 ]
3679+
|> AsyncSeq.take -1
3680+
|> AsyncSeq.toArrayAsync
3681+
|> Async.RunSynchronously |> ignore)
3682+
|> ignore
3683+
3684+
[<Test>]
3685+
let ``AsyncSeq.take from infinite sequence`` () =
3686+
let result =
3687+
AsyncSeq.replicateInfinite 7
3688+
|> AsyncSeq.take 5
3689+
|> AsyncSeq.toArrayAsync
3690+
|> Async.RunSynchronously
3691+
Assert.AreEqual([| 7; 7; 7; 7; 7 |], result)
3692+
3693+
[<Test>]
3694+
let ``AsyncSeq.skip more than length returns empty`` () =
3695+
let result =
3696+
AsyncSeq.ofSeq [ 1; 2; 3 ]
3697+
|> AsyncSeq.skip 10
3698+
|> AsyncSeq.toArrayAsync
3699+
|> Async.RunSynchronously
3700+
Assert.AreEqual([||], result)
3701+
3702+
[<Test>]
3703+
let ``AsyncSeq.skip raises ArgumentException for negative count`` () =
3704+
Assert.Throws<System.ArgumentException>(fun () ->
3705+
AsyncSeq.ofSeq [ 1; 2; 3 ]
3706+
|> AsyncSeq.skip -1
3707+
|> AsyncSeq.toArrayAsync
3708+
|> Async.RunSynchronously |> ignore)
3709+
|> ignore
3710+
3711+
[<Test>]
3712+
let ``AsyncSeq.take then skip roundtrip`` () =
3713+
let source = [| 1..20 |]
3714+
let result =
3715+
AsyncSeq.ofSeq source
3716+
|> AsyncSeq.skip 5
3717+
|> AsyncSeq.take 10
3718+
|> AsyncSeq.toArrayAsync
3719+
|> Async.RunSynchronously
3720+
Assert.AreEqual([| 6..15 |], result)

0 commit comments

Comments
 (0)