Skip to content

Reduce allocations in TakeLast#131029

Draft
artl93 wants to merge 2 commits into
dotnet:mainfrom
artl93:artl93-takelast-arraypool
Draft

Reduce allocations in TakeLast#131029
artl93 wants to merge 2 commits into
dotnet:mainfrom
artl93:artl93-takelast-arraypool

Conversation

@artl93

@artl93 artl93 commented Jul 19, 2026

Copy link
Copy Markdown
Member

Fixes #118312

Motivation

Enumerable.TakeLast (via TakeRangeFromEndIterator) buffers the tail of a sequence when the source count isn't known ahead of time. It used a Queue<TSource>, which allocates a backing array plus per-element enqueue/dequeue overhead for what is only ever a fixed-size sliding window.

Change

Retain the tail in a pooled ArrayPool<TSource> ring buffer instead: rent a small array, grow it by doubling up to the window size, then overwrite the oldest element in place once full, and return the buffer to the pool — clearing it for reference-containing types — when enumeration completes or is abandoned. A single owning try/finally spans buffering, source disposal, and yielding so the rented array is returned exactly once on every path (including if the source throws mid-buffer or its Dispose() throws) and never double-returned. Known-count sources (arrays, List<T>, etc.) still bypass this path entirely; the SkipLast branch is unchanged.

Benchmarks

Local BenchmarkDotNet (MemoryDiagnoser). A fresh @EgorBot -linux_amd -osx_arm64 run against the current head is in the comments.

Scenario Before After Improvement
int, 1M source, window 10 4.46 ms 1.68 ms 2.6x
int, 1K source, window 10 3.61 us 1.13 us 3.2x
string, 1M source, window 1000 4.82 ms 3.05 ms 1.6x
Allocation Before After Reduction
int, window 10 344 B 136 B 60%
int, window 1000 8,552 B 136 B 98%
string, window 1000 16,736 B 144 B 99%

Validation

  • All 52,260 System.Linq tests pass (0 failures, 8 pre-existing skips); TakeLast/SkipLast cases confirmed to run.
  • Cross-checked the old and new algorithms against the BCL TakeLast over 182 size/window combinations — 0 mismatches.

Risks

  • For unknown-count value-type sequences where the window is close to the source length, time can be slightly slower (the buffer stays in the doubling-growth phase and rarely reaches steady-state ring overwrite) while still allocating far less. Known-count sources bypass this path entirely.
  • Reference/reference-containing element types incur array clearing on return (required for correctness); this is the expected ArrayPool cost and still nets a large allocation reduction.

Note

This pull request description was created with GitHub Copilot.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d7060115-2ee5-46f4-b6e0-acad31d5a484
Copilot AI review requested due to automatic review settings July 19, 2026 07:10
@artl93

artl93 commented Jul 19, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -linux_amd -osx_arm64

using System;
using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(TakeLastBench).Assembly).Run(args);

[MemoryDiagnoser]
public class TakeLastBench
{
    // Source length (unknown-count, lazy) vs. retained window size.
    [Params(1_000, 1_000_000)]
    public int SourceSize;

    [Params(10, 1_000)]
    public int Window;

    private int[] _ints = default!;
    private string[] _strings = default!;

    [GlobalSetup]
    public void Setup()
    {
        _ints = new int[SourceSize];
        _strings = new string[SourceSize];
        for (int i = 0; i < SourceSize; i++)
        {
            _ints[i] = i;
            _strings[i] = i.ToString();
        }
    }

    // yield-based source => TryGetNonEnumeratedCount fails => TakeLast must buffer the tail.
    private static IEnumerable<T> Lazy<T>(T[] a)
    {
        foreach (T x in a) yield return x;
    }

    [Benchmark]
    public long TakeLast_Int()
    {
        long sum = 0;
        foreach (int x in Lazy(_ints).TakeLast(Window)) sum += x;
        return sum;
    }

    [Benchmark]
    public int TakeLast_String()
    {
        int n = 0;
        foreach (string x in Lazy(_strings).TakeLast(Window)) n += x.Length;
        return n;
    }
}

Note

This benchmark request was created with GitHub Copilot.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-linq
See info in area-owners.md if you want to be subscribed.

@artl93
artl93 marked this pull request as draft July 19, 2026 07:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the unknown-count Take/TakeLast-from-end implementation in System.Linq to avoid Queue<T> allocations and per-element enqueue/dequeue overhead by buffering the tail of the sequence in a pooled ArrayPool<T> ring buffer.

Changes:

  • Replace the Queue<TSource> tail buffer in the isStartIndexFromEnd path with an ArrayPool<TSource>-backed ring buffer that grows up to the requested window size.
  • Ensure the pooled buffer is returned when enumeration completes or the consumer abandons enumeration (via iterator finally).
  • Keep the known-count fast paths and the SkipLast-style (!isStartIndexFromEnd && isEndIndexFromEnd) queue-based branch unchanged.
Show a summary per file
File Description
src/libraries/System.Linq/src/System/Linq/Take.cs Reworks the from-end buffering path to use an ArrayPool<T> ring buffer for reduced allocations and improved throughput.

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 1

Comment thread src/libraries/System.Linq/src/System/Linq/Take.cs Outdated
Wrap the pooled ring-buffer window in a single owning try/finally that
spans buffering, the source enumerator's disposal, and yielding, so the
rented array is returned to ArrayPool exactly once on every path -
including when MoveNext/Current throws mid-buffering or the source's
Dispose throws as the using block exits. Previously an exception on
those paths could orphan the rented array (pool degradation).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d7060115-2ee5-46f4-b6e0-acad31d5a484
Copilot AI review requested due to automatic review settings July 19, 2026 18:35
@artl93

artl93 commented Jul 19, 2026

Copy link
Copy Markdown
Member Author

Superseding my earlier @EgorBot request above — the PR head changed (added exception-safe single-finally buffer ownership in a0c9351), so this re-runs against the current head. The prior request/results remain above for context.

This exercises the public Enumerable.TakeLast API over unknown-count lazy IEnumerable<T> sources (forces the pooled-buffer path) across value (int), reference (string), and a large value struct, plus a countable array control that takes the count-known fast path (should be unchanged). MemoryDiagnoser on.

@EgorBot -linux_amd -osx_arm64

using System;
using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(TakeLastBench).Assembly).Run(args);

public struct Big32 { public long A, B, C, D; } // 32-byte pure value type (no clearing on return)

[MemoryDiagnoser]
public class TakeLastBench
{
    [Params(1_000, 1_000_000)]
    public int SourceSize;

    // Small window vs. window near/at source length (growth-dominated crossover).
    [Params(10, 1_000)]
    public int Window;

    private int[] _ints = default!;
    private string[] _strings = default!;
    private Big32[] _structs = default!;

    [GlobalSetup]
    public void Setup()
    {
        _ints = new int[SourceSize];
        _strings = new string[SourceSize];
        _structs = new Big32[SourceSize];
        for (int i = 0; i < SourceSize; i++)
        {
            _ints[i] = i;
            _strings[i] = i.ToString();
            _structs[i] = new Big32 { A = i };
        }
    }

    // yield-based source => TryGetNonEnumeratedCount fails => TakeLast must buffer the tail.
    private static IEnumerable<T> Lazy<T>(T[] a)
    {
        foreach (T x in a) yield return x;
    }

    [Benchmark]
    public long TakeLast_Int_Lazy()
    {
        long sum = 0;
        foreach (int x in Lazy(_ints).TakeLast(Window)) sum += x;
        return sum;
    }

    [Benchmark]
    public int TakeLast_String_Lazy()
    {
        int n = 0;
        foreach (string x in Lazy(_strings).TakeLast(Window)) n += x.Length;
        return n;
    }

    [Benchmark]
    public long TakeLast_Struct_Lazy()
    {
        long sum = 0;
        foreach (Big32 x in Lazy(_structs).TakeLast(Window)) sum += x.A;
        return sum;
    }

    // Control: countable source hits the count-known fast path, not the pooled buffer.
    [Benchmark]
    public long TakeLast_Int_Countable()
    {
        long sum = 0;
        foreach (int x in _ints.TakeLast(Window)) sum += x;
        return sum;
    }
}

Note

This benchmark request was created with GitHub Copilot.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 0 new

@artl93

artl93 commented Jul 19, 2026

Copy link
Copy Markdown
Member Author

Correcting the runner flags on my previous request (the prior -linux_amd flag was invalid). This supersedes both earlier @EgorBot requests and runs against the current head a0c9351; the earlier requests/context are retained above. Only these corrected-run results are the benchmark gate.

This exercises the public Enumerable.TakeLast API over unknown-count lazy IEnumerable<T> sources (forces the pooled-buffer path) across value (int), reference (string), and a 32-byte value struct, plus a countable array control that takes the count-known fast path (should be unchanged). MemoryDiagnoser on.

@EgorBot -amd -osx_arm64

using System;
using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(TakeLastBench).Assembly).Run(args);

public struct Big32 { public long A, B, C, D; } // 32-byte pure value type (no clearing on return)

[MemoryDiagnoser]
public class TakeLastBench
{
    [Params(1_000, 1_000_000)]
    public int SourceSize;

    // Small window vs. window near/at source length (growth-dominated crossover).
    [Params(10, 1_000)]
    public int Window;

    private int[] _ints = default!;
    private string[] _strings = default!;
    private Big32[] _structs = default!;

    [GlobalSetup]
    public void Setup()
    {
        _ints = new int[SourceSize];
        _strings = new string[SourceSize];
        _structs = new Big32[SourceSize];
        for (int i = 0; i < SourceSize; i++)
        {
            _ints[i] = i;
            _strings[i] = i.ToString();
            _structs[i] = new Big32 { A = i };
        }
    }

    // yield-based source => TryGetNonEnumeratedCount fails => TakeLast must buffer the tail.
    private static IEnumerable<T> Lazy<T>(T[] a)
    {
        foreach (T x in a) yield return x;
    }

    [Benchmark]
    public long TakeLast_Int_Lazy()
    {
        long sum = 0;
        foreach (int x in Lazy(_ints).TakeLast(Window)) sum += x;
        return sum;
    }

    [Benchmark]
    public int TakeLast_String_Lazy()
    {
        int n = 0;
        foreach (string x in Lazy(_strings).TakeLast(Window)) n += x.Length;
        return n;
    }

    [Benchmark]
    public long TakeLast_Struct_Lazy()
    {
        long sum = 0;
        foreach (Big32 x in Lazy(_structs).TakeLast(Window)) sum += x.A;
        return sum;
    }

    // Control: countable source hits the count-known fast path, not the pooled buffer.
    [Benchmark]
    public long TakeLast_Int_Countable()
    {
        long sum = 0;
        foreach (int x in _ints.TakeLast(Window)) sum += x;
        return sum;
    }
}

Note

This benchmark request was created with GitHub Copilot.

}
else

buffer = ArrayPool<TSource>.Shared.Rent(Math.Min(startIndex, 4));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this is going to require an unsafe {} context soon. are we sure it's worth adding an unsafe code here? I assume Take* is already well optimized when the input is some non-lazy data structure

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would ask even a stronger question: Are we sure that this use of ArrayPool does not come with subtle memory safety security issues?

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.

TakeLast Performance Improvement suggestion

4 participants