Skip to content

[Repo Assist] perf: replace ref cells with mutable in ofSeq, tryWith, tryFinally enumerators#317

Merged
dsyme merged 2 commits intomainfrom
repo-assist/perf-mutable-enumerator-state-20260421-7e9394f68c077fa1
Apr 22, 2026
Merged

[Repo Assist] perf: replace ref cells with mutable in ofSeq, tryWith, tryFinally enumerators#317
dsyme merged 2 commits intomainfrom
repo-assist/perf-mutable-enumerator-state-20260421-7e9394f68c077fa1

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

🤖 This is an automated pull request from Repo Assist.

Summary

Eliminates unnecessary heap allocations in three fundamental async sequence enumerators by replacing ref cell state fields with mutable locals.

Problem

Three enumerator implementations — ofSeq, tryWith, and tryFinally — each allocated a Ref<T> wrapper object on every call to store the enumerator's state machine variable:

// Before (allocates a Ref<MapState> on the heap)
let state = ref (MapState.NotStarted inp)
...
state.Value <- MapState.HaveEnumerator e
match state.Value with

tryWith also allocated two short-lived Ref<Choice<_,_>> objects per invocation to bridge synchronous try...with logic inside the async { } block.

Fix

Replace ref with mutable, consistent with the pattern already used by collectSeq and takeWhileInclusive (introduced in 4.11.0 / 4.12.0):

// After (mutable becomes a field in the generated class — no separate Ref<T> allocation)
let mutable state = MapState.NotStarted inp
...
state <- MapState.HaveEnumerator e
match state with

In F# object expressions, let mutable values captured by member methods are promoted to fields of the compiler-generated class, so no extra allocation is needed. The Ref<T> wrapper is entirely eliminated.

Impact

  • ofSeq: Called any time a seq<'T> is wrapped as an async sequence. One Ref<MapState> allocation eliminated per enumeration.
  • tryWith: Used by the asyncSeq { try ... with ... } CE builder. One Ref<TryWithState> + two Ref<Choice> allocations eliminated per enumerator.
  • tryFinally: Used by asyncSeq { try ... finally ... } and use bindings. One Ref<TryFinallyState> allocation eliminated per enumerator.

Trade-offs

No behaviour change; purely a GC pressure reduction. The F# compiler generates equivalent IL — the difference is that the state now lives directly as a field of the enumerator class instead of as a field pointing to a separately allocated Ref<T> object.

Test Status

Build: succeeded (0 errors, pre-existing warnings only)
Tests: 422/422 passed (418 existing + 4 new tests for ofSeq, tryFinally resource disposal, and tryWith handler yield)

Generated by 🌈 Repo Assist, see workflow run. Learn more.

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/repo-assist.md@96b9d4c39aa22359c0b38265927eadb31dcf4e2a

…umerators

Each call to AsyncSeq.ofSeq, or any async CE block using try...with,
try...finally, or use expressions, previously heap-allocated a Ref<T>
wrapper object to hold the enumerator's state machine field.

Converting from:
  let state = ref (SomeState.NotStarted inp)
  ...
  state.Value <- SomeState.Next ...

to:
  let mutable state = SomeState.NotStarted inp
  ...
  state <- SomeState.Next ...

eliminates the Ref<T> heap allocation because the mutable local is
promoted to a direct field in the compiler-generated object-expression
class (the same pattern already used by collectSeq and takeWhileInclusive
since 4.11.0/4.12.0).

Also eliminates two short-lived Ref<Choice<_,_>> locals per tryWith
invocation (used to bridge synchronous try...with inside the async block).

422/422 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dsyme dsyme marked this pull request as ready for review April 21, 2026 09:55
@dsyme dsyme merged commit 2e24353 into main Apr 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant