Skip to content

Commit 7e99085

Browse files
authored
Merge pull request #320 from fsprojects/repo-assist/perf-fix-pairwise-splitat-20260425-0ce5ee841dd9747c
[Repo Assist] perf+fix: optimize pairwise, fix splitAt/tryTail enumerator disposal on exception
2 parents aed3052 + 82ab2fe commit 7e99085

4 files changed

Lines changed: 92 additions & 44 deletions

File tree

RELEASE_NOTES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
### 4.17.0
2+
3+
* Performance: Optimised `AsyncSeq.pairwise` to use a `hasPrev` flag and a direct `mutable` field instead of wrapping the previous element in `Some`. Previously, each iteration allocated a new `'T option` object on the heap; the new implementation eliminates that allocation entirely, reducing GC pressure for long sequences.
4+
* Bug fix: `AsyncSeq.splitAt` and `AsyncSeq.tryTail` now correctly dispose the underlying enumerator when an exception or cancellation occurs during the initial `MoveNext` call. Previously the enumerator could leak if the source sequence threw during the first few steps.
5+
16
### 4.16.0
27

38
* Performance: Replaced `ref` cells with `mutable` locals in the `ofSeq`, `tryWith`, and `tryFinally` enumerator state machines. Each call to `ofSeq` (or any async CE block using `try...with` / `try...finally` / `use`) previously heap-allocated a `Ref<T>` wrapper object per enumerator; it now uses a direct mutable field in the generated class, reducing GC pressure. The change is equivalent to the `mutable`-for-`ref` improvement introduced in 4.11.0 for other enumerators.

src/FSharp.Control.AsyncSeq/AsyncSeq.fs

Lines changed: 52 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1306,13 +1306,14 @@ module AsyncSeq =
13061306
use ie = source.GetEnumerator()
13071307
let! v = ie.MoveNext()
13081308
let mutable b = v
1309-
let mutable prev = None
1309+
// Use a flag + mutable field instead of Option to avoid per-element heap allocation
1310+
let mutable hasPrev = false
1311+
let mutable prev = Unchecked.defaultof<'T>
13101312
while b.IsSome do
13111313
let v = b.Value
1312-
match prev with
1313-
| None -> ()
1314-
| Some p -> yield (p, v)
1315-
prev <- Some v
1314+
if hasPrev then yield (prev, v)
1315+
hasPrev <- true
1316+
prev <- v
13161317
let! moven = ie.MoveNext()
13171318
b <- moven }
13181319

@@ -2152,51 +2153,59 @@ module AsyncSeq =
21522153

21532154
let tryTail (source: AsyncSeq<'T>) : Async<AsyncSeq<'T> option> = async {
21542155
let ie = source.GetEnumerator()
2155-
let! first = ie.MoveNext()
2156-
match first with
2157-
| None ->
2156+
try
2157+
let! first = ie.MoveNext()
2158+
match first with
2159+
| None ->
2160+
ie.Dispose()
2161+
return None
2162+
| Some _ ->
2163+
return Some (asyncSeq {
2164+
try
2165+
let! next = ie.MoveNext()
2166+
let mutable b = next
2167+
while b.IsSome do
2168+
yield b.Value
2169+
let! moven = ie.MoveNext()
2170+
b <- moven
2171+
finally
2172+
ie.Dispose() })
2173+
with ex ->
21582174
ie.Dispose()
2159-
return None
2160-
| Some _ ->
2161-
return Some (asyncSeq {
2162-
try
2163-
let! next = ie.MoveNext()
2164-
let mutable b = next
2165-
while b.IsSome do
2166-
yield b.Value
2167-
let! moven = ie.MoveNext()
2168-
b <- moven
2169-
finally
2170-
ie.Dispose() }) }
2175+
return raise ex }
21712176

21722177
/// Splits an async sequence at the given index, returning the first `count` elements as an array
21732178
/// and the remaining elements as a new AsyncSeq. The source is enumerated once.
21742179
let splitAt (count: int) (source: AsyncSeq<'T>) : Async<'T array * AsyncSeq<'T>> = async {
21752180
if count < 0 then invalidArg "count" "must be non-negative"
21762181
let ie = source.GetEnumerator()
2177-
let ra = ResizeArray<'T>()
2178-
let! m = ie.MoveNext()
2179-
let mutable b = m
2180-
while b.IsSome && ra.Count < count do
2181-
ra.Add b.Value
2182-
let! next = ie.MoveNext()
2183-
b <- next
2184-
let first = ra.ToArray()
2185-
let rest =
2186-
if b.IsNone then
2187-
ie.Dispose()
2188-
empty<'T>
2189-
else
2190-
let cur = ref b
2191-
asyncSeq {
2192-
try
2193-
while cur.Value.IsSome do
2194-
yield cur.Value.Value
2195-
let! next = ie.MoveNext()
2196-
cur.Value <- next
2197-
finally
2198-
ie.Dispose() }
2199-
return first, rest }
2182+
try
2183+
let ra = ResizeArray<'T>()
2184+
let! m = ie.MoveNext()
2185+
let mutable b = m
2186+
while b.IsSome && ra.Count < count do
2187+
ra.Add b.Value
2188+
let! next = ie.MoveNext()
2189+
b <- next
2190+
let first = ra.ToArray()
2191+
let rest =
2192+
if b.IsNone then
2193+
ie.Dispose()
2194+
empty<'T>
2195+
else
2196+
let mutable cur = b
2197+
asyncSeq {
2198+
try
2199+
while cur.IsSome do
2200+
yield cur.Value
2201+
let! next = ie.MoveNext()
2202+
cur <- next
2203+
finally
2204+
ie.Dispose() }
2205+
return first, rest
2206+
with ex ->
2207+
ie.Dispose()
2208+
return raise ex }
22002209

22012210
let toArrayAsync (source : AsyncSeq<'T>) : Async<'T[]> = async {
22022211
let ra = (new ResizeArray<_>())

src/FSharp.Control.AsyncSeq/FSharp.Control.AsyncSeq.fsproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
</ItemGroup>
2525
<ItemGroup>
2626
<PackageReference Update="FSharp.Core" Version="4.7.2" />
27-
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
27+
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.7" />
2828
<PackageReference Include="System.Threading.Channels" Version="*" />
2929
<Content Include="*.fsproj; **\*.fs; **\*.fsi;" PackagePath="fable\" />
3030
</ItemGroup>

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2816,6 +2816,13 @@ let ``AsyncSeq.pairwise with three elements should produce two pairs`` () =
28162816
let result = AsyncSeq.pairwise source |> AsyncSeq.toListSynchronously
28172817
Assert.AreEqual([(1, 2); (2, 3)], result)
28182818

2819+
[<Test>]
2820+
let ``AsyncSeq.pairwise with many elements produces correct pairs`` () =
2821+
let source = AsyncSeq.ofSeq [1..10]
2822+
let result = AsyncSeq.pairwise source |> AsyncSeq.toListSynchronously
2823+
let expected = [(1,2); (2,3); (3,4); (4,5); (5,6); (6,7); (7,8); (8,9); (9,10)]
2824+
Assert.AreEqual(expected, result)
2825+
28192826
[<Test>]
28202827
let ``AsyncSeq.windowed empty sequence returns empty`` () =
28212828
let result = AsyncSeq.windowed 3 AsyncSeq.empty<int> |> AsyncSeq.toListSynchronously
@@ -3746,6 +3753,19 @@ let ``AsyncSeq.splitAt with negative count throws ArgumentException`` () =
37463753
Assert.Throws<System.ArgumentException>(fun () ->
37473754
AsyncSeq.splitAt -1 AsyncSeq.empty<int> |> Async.RunSynchronously |> ignore) |> ignore
37483755

3756+
[<Test>]
3757+
let ``AsyncSeq.splitAt disposes enumerator when source throws during collection`` () =
3758+
let mutable disposed = false
3759+
let source = asyncSeq {
3760+
use _ = { new System.IDisposable with member _.Dispose() = disposed <- true }
3761+
yield 1
3762+
yield 2
3763+
failwith "source error"
3764+
}
3765+
try AsyncSeq.splitAt 10 source |> Async.RunSynchronously |> ignore
3766+
with _ -> ()
3767+
Assert.IsTrue(disposed, "enumerator should be disposed after exception during collection")
3768+
37493769
// ===== removeAt =====
37503770

37513771
[<Test>]
@@ -4190,6 +4210,20 @@ let ``AsyncSeq.tryTail returns all-but-first elements`` () =
41904210
let tail = result.Value |> AsyncSeq.toListAsync |> Async.RunSynchronously
41914211
Assert.AreEqual([2;3;4;5], tail)
41924212

4213+
[<Test>]
4214+
let ``AsyncSeq.tryTail disposes enumerator when source throws on first MoveNext`` () =
4215+
let mutable disposed = false
4216+
// Use a pre-failed task so the exception occurs during MoveNext() (async), not during GetEnumerator()
4217+
let failedTask = System.Threading.Tasks.Task.FromException<unit>(System.Exception("source error"))
4218+
let source = asyncSeq {
4219+
use _ = { new System.IDisposable with member _.Dispose() = disposed <- true }
4220+
let! _ = failedTask |> Async.AwaitTask
4221+
yield 1
4222+
}
4223+
try AsyncSeq.tryTail source |> Async.RunSynchronously |> ignore
4224+
with _ -> ()
4225+
Assert.IsTrue(disposed, "enumerator should be disposed after exception on first MoveNext")
4226+
41934227
[<Test>]
41944228
let ``AsyncSeq.where is alias for filter`` () =
41954229
let result =

0 commit comments

Comments
 (0)