Skip to content

[Repo Assist] Perf: optimise take and skip with direct enumeratorsΒ #280

@github-actions

Description

@github-actions

πŸ€– This PR was created by Repo Assist, an automated AI assistant.

Summary

Replaces the asyncSeq-builder implementations of AsyncSeq.take and AsyncSeq.skip with direct IAsyncSeqEnumerator<'T> types β€” OptimizedTakeEnumerator and OptimizedSkipEnumerator β€” following the same pattern established by the existing optimised enumerators for mapAsync, filterAsync, chooseAsync, and foldAsync.

Root Cause

Both take and skip previously used the asyncSeq { } computation builder. This routes every element through the AsyncGenerator / GenerateCont machinery, allocating generator objects and dispatching through virtual calls at each step. For operations as simple as counting/discarding elements this overhead is unnecessary.

Fix

OptimizedTakeEnumerator<'T>:

  • Holds the source enumerator and a mutable remaining counter.
  • On each MoveNext() call, returns None immediately when remaining ≀ 0; otherwise delegates to the source and decrements the counter.
  • No allocations beyond the single object instance.

OptimizedSkipEnumerator<'T>:

  • Holds the source enumerator, a mutable toSkip counter, and an exhausted flag.
  • First MoveNext() call drains toSkip elements from the source; subsequent calls delegate directly to the source.
  • Correctly handles sequences shorter than the skip count.

Changes

  • src/FSharp.Control.AsyncSeq/AsyncSeq.fs β€” add OptimizedTakeEnumerator, OptimizedSkipEnumerator; replace asyncSeq {} implementations of take/skip
  • tests/FSharp.Control.AsyncSeq.Benchmarks/AsyncSeqBenchmarks.fs β€” add AsyncSeqSliceBenchmarks class (Take, Skip, SkipThenTake for N=1000/10000)
  • tests/FSharp.Control.AsyncSeq.Tests/AsyncSeqTests.fs β€” 6 new edge-case tests
  • RELEASE_NOTES.md β€” update 4.9.0 entry

New Tests

Test Description
AsyncSeq.take more than length returns all elements take 10 of a 3-element seq returns all 3
AsyncSeq.take raises ArgumentException for negative count take -1 throws
AsyncSeq.take from infinite sequence take 5 of replicateInfinite terminates
AsyncSeq.skip more than length returns empty skip 10 of a 3-element seq returns []
AsyncSeq.skip raises ArgumentException for negative count skip -1 throws
AsyncSeq.take then skip roundtrip skip 5 |> take 10 over [1..20] returns [6..15]

Test Status

βœ… Build succeeded (0 errors, pre-existing warnings only β€” NU1605, FS9999 for groupByAsync, FS0044 for deprecated toAsyncEnum/ofAsyncEnum in existing tests).

βœ… All 323 tests pass (dotnet test FSharp.Control.AsyncSeq.sln).

Generated by Repo Assist Β· β—·

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/repo-assist.md@346204513ecfa08b81566450d7d599556807389f

Note

This was originally intended as a pull request, but PR creation failed. The changes have been pushed to the branch repo-assist/perf-take-skip-enumerators-b793dd82cef306c6.

Original error: Validation Failed: {"resource":"Label","code":"unprocessable","field":"data","message":"Could not resolve to a node with the global id of 'PR_kwDOAfaq5M7KqnNT'."}

To create the pull request manually:

gh pr create --title "[Repo Assist] Perf: optimise `take` and `skip` with direct enumerators" --base main --head repo-assist/perf-take-skip-enumerators-b793dd82cef306c6 --repo fsprojects/FSharp.Control.AsyncSeq
Show patch preview (271 of 271 lines)
From 25957d20c4b818ea0b2f6bb8658a0a5513f1592a Mon Sep 17 00:00:00 2001
From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com>
Date: Sun, 15 Mar 2026 00:14:12 +0000
Subject: [PATCH] Perf: optimise `take` and `skip` with direct enumerators
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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 <223556219+Copilot@users.noreply.github.com>
---
 RELEASE_NOTES.md                              |  4 +-
 src/FSharp.Control.AsyncSeq/AsyncSeq.fs       | 83 +++++++++++++------
 .../AsyncSeqBenchmarks.fs                     | 35 ++++++++
 .../AsyncSeqTests.fs                          | 56 +++++++++++++
 4 files changed, 150 insertions(+), 28 deletions(-)

diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index b66939f..06d6aa8 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -3,7 +3,9 @@
 * Performance: `filterAsync` β€” replaced `asyncSeq`-builder implem
... (truncated)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions