Reduce allocations in TakeLast#131029
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7060115-2ee5-46f4-b6e0-acad31d5a484
|
@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: 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. |
|
Tagging subscribers to this area: @dotnet/area-system-linq |
There was a problem hiding this comment.
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 theisStartIndexFromEndpath with anArrayPool<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
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
|
Superseding my earlier This exercises the public @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. |
|
Correcting the runner flags on my previous request (the prior This exercises the public @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)); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
I would ask even a stronger question: Are we sure that this use of ArrayPool does not come with subtle memory safety security issues?
Fixes #118312
Motivation
Enumerable.TakeLast(viaTakeRangeFromEndIterator) buffers the tail of a sequence when the source count isn't known ahead of time. It used aQueue<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 owningtry/finallyspans buffering, source disposal, and yielding so the rented array is returned exactly once on every path (including if the source throws mid-buffer or itsDispose()throws) and never double-returned. Known-count sources (arrays,List<T>, etc.) still bypass this path entirely; theSkipLastbranch is unchanged.Benchmarks
Local BenchmarkDotNet (MemoryDiagnoser). A fresh
@EgorBot -linux_amd -osx_arm64run against the current head is in the comments.Validation
TakeLast/SkipLastcases confirmed to run.TakeLastover 182 size/window combinations — 0 mismatches.Risks
ArrayPoolcost and still nets a large allocation reduction.Note
This pull request description was created with GitHub Copilot.