GitHub Copilot and .NET Performance: Benchmark Every Optimization

GitHub Copilot can generate a performance optimization in seconds. Finding out whether that optimization actually helps takes engineering. While working on my open-source Spargine libraries, I have seen convincing suggestions make code slower, add unnecessary complexity, or change behavior. I have also seen suggestions deliver worthwhile improvements. The challenge is telling the difference before the code ships.

At the 2026 Microsoft MVP Summit, I saw many demonstrations involving AI. I cannot discuss the confidential details, but my takeaway was clear: developers need to learn how to work effectively with these tools. That includes knowing when to challenge their answers.

I have been using GitHub Copilot to help improve Spargine and the Spargine Dev Tool. In my own projects, a substantial share of the suggestions I try still need corrections before they compile or meet my quality standards. That is a personal observation, not a measured failure rate, and this article is not a controlled comparison of AI models.

The examples below include suggestions from Claude Opus 4.6 and GPT-6 Astra. Some suggestions produced clear regressions, some delivered worthwhile gains, and several produced mixed or negligible differences. Those results changed several conclusions in my original draft.

The central lesson A convincing explanation is a hypothesis. A performance improvement needs evidence from the paths and inputs that matter.

BenchmarkDotNet explains why a ratio has variability of its own in its ratio statistics documentation. Absolute timings also matter: a large percentage can represent very little application time.

The code listings are method excerpts. Surrounding type declarations, generic constraints, imports, and Spargine helpers are omitted. The excerpts are not standalone programs. Each comparison begins with the declaring Spargine type and method name so readers can find the implementation on GitHub.

Results at a Glance

Benchmark variantResult for the Copilot suggestionDecision
AddLast, reference arraysSlower by 8.8% to 41.6% at counts 64, 128, 256, 512, 2,048, and 4,096; 14.0% faster at 1,024; 2.7% slower at 8,192Keep original
AddLast, value arrays46.0%, 7.6%, and 7.0% slower at counts 64, 128, and 8,192; 8.3%, 6.6%, and 6.4% faster at 512, 2,048, and 4,096; less than 3% different at 256 and 1,024Keep original
LastIndexOf, reference arraysSlower by 5.4% to 11.4% at counts 128, 256, and 4,096; less than 4% different at the other five countsKeep original
LastIndexOf, value arrays7.2% slower at count 1,024; 5.7% faster at 8,192; less than 5% different at the other six countsKeep original
RemoveFirst, reference arraysSlower by 8.8% to 38.0% at counts 64, 1,024, and 2,048; 22.2% and 5.7% faster at 256 and 8,192; less than 5% different at 128, 512, and 4,096Keep original
RemoveFirst, value arrays5.6% slower at count 128; 25.6%, 8.2%, and 7.9% faster at 64, 512, and 4,096; less than 4% different at the other four countsKeep original
ToDistinct, reference arraysEvery tested count differed by less than 2.5%Keep original
ToDistinct, value arrays5.1% faster at count 4,096; every other count differed by less than 3%Keep original
IndexAtLooped, half-count index2.45× to 2.88× slower at every tested power-of-two list sizeKeep original
AreEqual, different reference arrays7.6% to 45.3% slower at every tested countUse suggestion
AreEqual, different value arrays12.1% to 79.3% slower at every tested countUse suggestion
AreEqual, same reference arrayAbout 830× to 175,357× fasterUse suggestion
AreEqual, same value arrayAbout 60× to 20,470× fasterUse suggestion
IsNotEmpty with predicate, reference arrays5.1% to 8.4% faster at counts 128, 256, and 8,192; 11.6% slower at 2,048; less than 5% different at the other four countsUse suggestion
IsNotEmpty with predicate, value arrays7.2% to 42.7% faster at every tested countUse suggestion
GenerateCoordinate2.44× faster: 92.64 ns instead of 226.43 nsUse after behavior tests
GenerateUrl1.2% faster: 722.80 ns instead of 731.94 nsKeep original

Each row names the path or data type being measured. That distinction matters: reference and value arrays often moved in different directions, and AreEqual changed from a large win to a regression depending on whether both arguments referenced the same array.

One Clear Regression

IndexAtLooped

The Spargine method in this comparison is ListExtensions.IndexAtLooped(). Claude Opus 4.6 suggested replacing its existing wrapping logic with a compact double-remainder expression.

Copilot suggestion

public T IndexAtLooped(in int index)
{
    list = list.ArgumentNotNull();
    var count = list.Count;

    if (count == 0)
    {
        ExceptionThrower.ThrowArgumentException(
            Resources.CollectionIsEmpty,
            nameof(list));
    }

    var indexWrap = ((index % count) + count) % count;
    return list[indexWrap];
}

Original implementation

public T IndexAtLooped(in int index)
{
    list = list.ArgumentNotNull();
    var count = list.Count;

    if (count == 0)
    {
        ExceptionThrower.ThrowArgumentException(
            Resources.CollectionIsEmpty,
            nameof(list));
    }

    var isPowerOfTwo = (count & (count - 1)) == 0;
    int indexWrap;

    if (isPowerOfTwo)
    {
        var mask = count - 1;
        indexWrap = index & mask;

        if (index < 0)
        {
            indexWrap = (count + (index % count)) & mask;
        }
    }
    else
    {
        indexWrap = index % count;
        indexWrap += (indexWrap >> 31) & count;
    }

    return list[indexWrap];
}

Across all eight tested counts, the suggested expression took 2.45× to 2.88× as long as the original implementation. The median was 2.61×. Both versions recorded zero managed allocations.

The disassembly explains the result. The suggested expression emits two sequential integer division operations for the two remainder calculations. The original implementation checks whether the count is a power of two and, on the positive half-count path, reduces the index with an AND mask. Every tested count was a power of two, so that optimized path was always available.

The rewrite is a clear regression for positive indices into power-of-two-sized lists. Non-power-of-two counts and negative indices exercise different branches of the original method and deserve separate measurements.

The compact expression also has an overflow edge case. Adding count to a positive remainder can exceed Int32.MaxValue before the second remainder is evaluated. Boundary tests therefore matter even when the algebra looks correct. C# defines remainder and integer overflow behavior in the arithmetic operators documentation.

Recommendation Keep the original implementation. The Copilot rewrite was slower in every measured case and adds an overflow risk.

Mixed and Neutral Results

Five rejected rewrites did not produce a clean across-the-board regression. None demonstrated a stable advantage over the simpler original code.

AddLast

The Spargine method in this comparison is ArrayExtensions.AddLast(). GPT-6 Astra suggested changing both the destination allocation and the copy operation. The suggestion uses GC.AllocateUninitializedArray() and a span copy; the original uses a normal array allocation and Array.Copy().

// Copilot suggestion
var result = GC.AllocateUninitializedArray<T>(length + 1);
array.AsSpan().CopyTo(result);
result[length] = item;

// Original implementation
var result = new T[length + 1];
Array.Copy(array, 0, result, 0, length);
result[length] = item;

For reference arrays, the suggestion was slower by 41.6% at 64 elements, 17.8% at 128, 28.1% at 256, 8.8% at 512, 11.8% at 2,048, and 16.4% at 4,096. It was 14.0% faster at 1,024 elements and 2.7% slower at 8,192.

For value arrays, it was 46.0% slower at 64 elements, 7.6% slower at 128, and 7.0% slower at 8,192. It was 8.3% faster at 512, 6.6% faster at 2,048, and 6.4% faster at 4,096. The remaining results changed by less than 3%: 2.2% faster at 256 and 2.7% slower at 1,024. Every reference- and value-array pair reported the same allocated bytes.

Because the suggestion changes both allocation and copying, this benchmark cannot attribute the differences to either change individually. The reference-array results mostly favor the original, while the value-array results have no consistent direction. The data supplies no general reason to accept the added complexity.

Uninitialized allocation can help in suitable cases, but it does not promise a faster allocation for every array.

Recommendation Keep the original implementation. The suggestion adds complexity without a stable timing or allocation benefit.

LastIndexOf

The Spargine method in this comparison is ArrayExtensions.LastIndexOf(). Claude Opus 4.6 suggested replacing Array.LastIndexOf() with array.AsSpan().LastIndexOf(item).

// Copilot suggestion
return array.AsSpan().LastIndexOf(item);

// Original implementation
return Array.LastIndexOf(array, item);

For reference arrays, the suggestion was 11.0% slower at 128 elements, 5.4% slower at 256, and 11.4% slower at 4,096. Each of the other five reference-array results differed by less than 4%.

For value arrays, the suggestion was 7.2% slower at 1,024 elements and 5.7% faster at 8,192. Each of the other six value-array results differed by less than 5%. Neither implementation allocated managed memory.

The defensible conclusion is workload-specific: the span rewrite did not establish a repeatable advantage for these types, values, and sizes. There is also a contract concern. Creating a writable span from a covariant reference-type array can fail in cases the array API accepts, so this rewrite needs correctness tests as well as benchmarks. The generic array API uses the default equality comparer, as documented for Array.LastIndexOf.

Recommendation Keep the original implementation. The suggestion provides no repeatable speed advantage and introduces an array-covariance risk.

RemoveFirst

The Spargine method in this comparison is ArrayExtensions.RemoveFirst(). This suggestion isolates the allocation choice more cleanly because both versions use the same span copy.

// Copilot suggestion
var result = GC.AllocateUninitializedArray<T>(newLength);
array.AsSpan(1, newLength).CopyTo(result);

// Original implementation
var result = new T[newLength];
array.AsSpan(1, newLength).CopyTo(result);

For reference arrays, the suggestion was 19.7% slower at 64 elements, 38.0% slower at 1,024, and 8.8% slower at 2,048. It was 22.2% faster at 256 elements and 5.7% faster at 8,192. The results at 128, 512, and 4,096 elements differed by less than 5%.

For value arrays, the suggestion was 5.6% slower at 128 elements. It was 25.6% faster at 64, 8.2% faster at 512, and 7.9% faster at 4,096. The remaining value-array results differed by less than 4%. Allocated bytes were identical for every reference- and value-array pair.

With no consistent direction and no allocation reduction, the normal array allocation remains the more defensible choice. If this path matters enough to revisit, the next step is repeated runs grouped by element type and array size, not a broader claim about uninitialized allocation.

Recommendation Keep the original implementation. The mixed timings and identical allocations do not justify the alternate allocation API.

ToDistinct

The Spargine method in this comparison is ArrayExtensions.ToDistinct(). GPT-6 Astra replaced span indexing with reference arithmetic and changed how the result array was materialized. The suggested code is more complex, but the measurements are almost entirely neutral.

// Copilot suggestion
var seen = new HashSet<T>(array.Length, comparer);
ref var start = ref MemoryMarshal.GetArrayDataReference(array);

for (var index = 0; index < array.Length; index++)
{
    _ = seen.Add(Unsafe.Add(ref start, index));
}

var result = GC.AllocateUninitializedArray<T>(seen.Count);
seen.CopyTo(result);
return result;

// Original implementation
var seen = new HashSet<T>(array.Length, comparer);
var span = array.AsSpan();

for (var index = 0; index < span.Length; index++)
{
    _ = seen.Add(span[index]);
}

return [.. seen];

Every reference-array result differed by less than 2.5%. For value arrays, the suggestion was 5.1% faster at 4,096 elements; each of the other seven results differed by less than 3%. Allocations were effectively the same. That is not evidence that the low-level rewrite improves this method.

The original implementation is easier to read and maintain. Unsafe.Add() and MemoryMarshal.GetArrayDataReference() should earn their place with a repeatable benefit, not merely create the appearance of lower-level optimization.

Recommendation Keep the original implementation. One isolated 5.1% improvement does not justify the added low-level code.

GenerateUrl

The Spargine method in this comparison is RandomData.GenerateUrl(). Claude Opus 4.6 suggested an explicit string.Concat() call in place of interpolation.

// Copilot suggestion
return string.Concat(
    GenerateUrlHostName(),
    GenerateRelativeUrl());

// Original implementation
return $"{GenerateUrlHostName()}{GenerateRelativeUrl()}";

The suggestion measured 722.80 ns versus 731.94 ns for the original, a 1.2% reduction. That small difference does not establish that explicit concatenation is generally faster. The compiler can lower simple string-only interpolation to concatenation, so the two forms may compile equivalently.

The allocation columns also deserve skepticism: one result reports zero bytes for a method that returns a newly constructed URL, while companion URL benchmarks report allocations. I would not use that value to claim an allocation elimination without first isolating the method and checking the diagnoser output. Readability is the better deciding factor until a repeatable difference emerges.

Recommendation Keep the original interpolation. The 1.2% timing difference is too small and uncertain to justify changing the source.

Accepted Improvements with Tradeoffs

Three accepted suggestions produced meaningful gains in at least part of the benchmark matrix. Two also show why an aggregate ratio can be misleading.

AreEqual

The Spargine method in this comparison is ArrayExtensions.AreEqual(). GPT-6 Astra added a same-reference check.

// Copilot suggestion
if (array is null || arrayToCheck is null)
{
    return false;
}

if (ReferenceEquals(array, arrayToCheck))
{
    return true;
}

return ((ReadOnlySpan<T>)array).SequenceEqual(arrayToCheck);

// Original implementation
return array is null || arrayToCheck is null
    ? false
    : array.LongLength != arrayToCheck.LongLength
        ? false
        : array.AsSpan().SequenceEqual(arrayToCheck);

When both arguments referenced the same reference array, the suggestion was about 830× faster at 64 elements and 175,357× faster at 8,192, with the speedup increasing as the array grew. For the same value array, the range was about 60× faster at 64 elements to 20,470× faster at 8,192. The suggestion stayed around 0.4 ns because it returned immediately, while the original compared the array contents and scaled with length.

When the arguments referenced different arrays, the result reversed. The suggestion was slower at every tested count: 7.6% to 45.3% slower for reference arrays and 12.1% to 79.3% slower for value arrays. The disassembly confirms the mechanism: the suggested code tests whether the two array references are identical and returns before sequence comparison, while the original continues into the comparison. When the references differ, that branch adds work without helping.

This is therefore not a universal 2.05× speedup. It is a path-dependent tradeoff. The change is valuable if same-instance comparisons occur often enough to matter; if they are rare, the distinct-array regression deserves more weight. Production call patterns should decide the outcome.

The same-reference shortcut also assumes that returning true without invoking element equality matches the intended contract. That is normally reasonable for an equality helper, but it should be explicit and covered by tests.

Recommendation Use the Copilot suggestion after confirming that same-reference equality matches the method contract. The same-instance gain is large enough to justify the added branch, but production profiling should confirm that this path occurs often enough to offset the distinct-array regression.

IsNotEmpty

The Spargine method in this comparison is ArrayExtensions.IsNotEmpty(). GPT-6 Astra replaced Any(predicate) with a direct span loop.

// Copilot suggestion
if (array is null || actionFunction is null)
{
    return false;
}

var span = array.AsSpan();
for (var index = 0; index < span.Length; index++)
{
    if (actionFunction(span[index]))
    {
        return true;
    }
}

return false;

// Original implementation
return array is null || actionFunction is null
    ? false
    : array.Any(actionFunction);

For reference arrays, the direct loop was 5.1% faster at 128 elements, 8.4% faster at 256, and 6.6% faster at 8,192. It was 11.6% slower at 2,048 elements. The results at 64, 512, 1,024, and 4,096 elements differed by less than 5%.

For value arrays, the direct loop was faster at every tested count, with improvements ranging from 7.2% to 42.7%.

This is stronger evidence than the earlier single 1.33× headline, but the conclusion is still bounded by the benchmark inputs. Both versions short-circuit on the first matching item and invoke the same predicate. Match position, no-match cases, empty arrays, and predicate cost can change the relative result.

Modern .NET already includes optimized paths in Enumerable.Any, so this is not evidence that LINQ is always slow.

Recommendation Use the Copilot suggestion. It produced consistent gains for value arrays and worthwhile gains at several reference-array sizes, despite one reference-array regression.

GenerateCoordinate

The Spargine method in this comparison is RandomData.GenerateCoordinate<T>(). Claude Opus 4.6 suggested filling one stack buffer with random bytes instead of calling GenerateInteger() three times.

// Copilot suggestion
Span<int> values = stackalloc int[3];
RandomNumberGenerator.Fill(MemoryMarshal.AsBytes(values));

return new()
{
    X = values[0],
    Y = values[1],
    Z = values[2]
};

// Original implementation
return new()
{
    X = GenerateInteger(),
    Y = GenerateInteger(),
    Z = GenerateInteger()
};

The suggestion measured 92.64 ns versus 226.43 ns for the original, a 2.44× speedup. Both reported 32 bytes allocated. This is a clear timing improvement.

Correctness remains the gate. Filling the bytes of three Int32 values can produce the entire signed integer range. The change is equivalent only if GenerateInteger() used the same range and distribution and the coordinate contract accepts negative values and both endpoints. If the helper applies different rules, the faster version changes behavior.

RandomNumberGenerator.Fill supplies cryptographically strong random bytes to the destination span.

Recommendation Use the Copilot suggestion after tests confirm that it preserves the original integer range and distribution. If those semantics differ, keep the original implementation.

Cache Key Construction

This comparison covers cache-key construction in TypeHelper.GetAllAbstractMethods(), TypeHelper.GetAllDeclaredFields(), TypeHelper.GetAllDeclaredMethods(), TypeHelper.GetAllFields(), TypeHelper.GetAllMethods(), and TypeHelper.GetAllProperties(). Copilot repeatedly changed their simple interpolated cache keys to a string.Create() pattern.

// Copilot suggestion
var cacheKey = string.Create(
    null,
    stackalloc char[256],
    $"{type.FullName}.{nameof(GetAllDeclaredFields)}");

// Original implementation
var cacheKey =
    $"{type.FullName}.{nameof(GetAllDeclaredFields)}";

The overload matters. This call uses string.Create(IFormatProvider?, Span<char>, ref DefaultInterpolatedStringHandler). It is not the overload that accepts a SpanAction delegate. The compiler passes an interpolated-string handler, which can use the supplied stack buffer before the final string is materialized.

The cached method results were mixed:

Cached methodResult for the string.Create() suggestionReported allocation
GetAllAbstractMethods40.6% slower: 107.34 ns instead of 76.32 ns152 bytes for both
GetAllDeclaredFields0.3% slower: 74.88 ns instead of 74.69 ns152 bytes for both
GetAllDeclaredMethods3.7% faster: 11.52 ns instead of 11.96 ns64 bytes for both
GetAllFields10.4% slower: 12.85 ns instead of 11.63 ns88 bytes for both
GetAllMethods1.0% slower: 12.37 ns instead of 12.26 ns88 bytes for both
GetAllProperties0.8% faster: 12.82 ns instead of 12.93 ns88 bytes versus zero reported

GetAllDeclaredFields is the clearest matching comparison: the timing was effectively unchanged, both forms reported 152 bytes allocated, and the relevant generated code constructs the final-sized string and copies the same components. In this case, the additional source complexity did not produce a measurable benefit.

The other cache-key rows need care. Several TypeHelper methods return iterators. Their benchmark entry points can measure creation of the iterator state machine without advancing it far enough to execute the deferred method body where the cache key is built. The 40.6% and 10.4% regressions should not be attributed to string.Create() unless the benchmark proves that the key construction actually executed. The reported zero-byte allocation for GetAllProperties also conflicts with its matching comparison and should be verified before drawing an allocation conclusion.

The right follow-up is a focused benchmark that builds each key directly from fixed inputs, consumes the resulting string, and records both timing and allocations. Iterator-returning methods should also be enumerated when the work inside the iterator is the subject of the benchmark.

The benchmark does not demonstrate a cache-key improvement from string.Create(). The clearest comparison is effectively identical, and some broader rows do not isolate key construction.

Recommendation Keep the original interpolation. The string.Create() pattern adds source complexity without a demonstrated timing or allocation benefit.

What the Disassembly Proves

Disassembly strengthens a benchmark explanation when it shows the mechanism that produced a measured result. It does not replace timing, allocation measurements, or representative inputs.

  • IndexAtLooped provides the strongest connection. The suggested expression emits two integer divisions, while the original hot path uses a mask for the power-of-two counts in the benchmark.
  • AreEqual confirms the same-reference early return. That explains the near-constant same-instance timing and the length-dependent work in the original implementation.
  • Cache-key disassembly does not show a unique advantage for the string.Create() source form in the clearest matching method. Equivalent generated work is consistent with the effectively identical timing and allocation result.
  • Small timing changes such as GenerateUrl still need isolated tests. When two source forms lower to equivalent or near-equivalent code, work performed by surrounding helpers can dominate the measurement.

BenchmarkDotNet includes separate diagnosers for generated code and managed allocations.

A Better Review Process

The evidence changed several conclusions in this article. That is what I want from a performance review.

  • Preserve behavior first. Test null handling, exceptions, equality semantics, array covariance, numeric boundaries, and random-value ranges before considering speed.
  • Keep hardware, runtime, SDK, build configuration, and benchmark configuration constant when isolating a source change.
  • Use a representative matrix. Include reference and value types, meaningful sizes, duplicate rates, match positions, negative indices, and non-power-of-two counts where applicable.
  • Separate paths that scale differently. Same-instance and distinct-array equality tests should never be collapsed into one ratio.
  • Change one thing at a time when investigating causality. A rewrite that changes allocation, copying, and iteration can be measured as a whole but cannot identify which change caused the result.
  • Consume deferred work. If a method returns an iterator, enumerate it when the benchmark is intended to measure the iterator body.
  • Publish absolute times, errors, distributions, allocated bytes, and the full environment alongside ratios. Treat small differences as hypotheses until they repeat.
  • Inspect generated code only when it answers a concrete question about branches, divisions, calls, inlining, or allocation paths.
  • Keep complexity only when a repeatable benefit on a relevant path justifies its maintenance cost.

BenchmarkDotNet baselines can make the direction of each comparison explicit.

Conclusion

These benchmarks produced three Copilot changes worth keeping and seven that should be rejected or reverted. AreEqual, IsNotEmpty, and GenerateCoordinate earned a recommendation to use the suggestion, subject to the behavior conditions described above. IndexAtLooped, AddLast, LastIndexOf, RemoveFirst, ToDistinct, GenerateUrl, and the cache-key string.Create() pattern should use the original code.

Using one decision for each of those ten code changes, Copilot was correct in 3 cases, or 30%, and incorrect in 7 cases, or 70%. Those percentages describe only the ten changes reviewed in this article. They are not a general Copilot accuracy rate.

That result reinforces the two lessons at the center of this article: Copilot is not always right, and every performance suggestion must be benchmarked before it is accepted. A plausible explanation is only a hypothesis until representative benchmarks confirm it.

That is why one headline ratio is rarely enough. Performance lives in paths, inputs, generated code, runtime behavior, and the frequency with which real applications exercise each case.

I will continue using Copilot to explore improvements for Spargine. I will also continue checking correctness, measuring execution time and allocations, examining disassembly when needed, and keeping only the changes that earn their place.

Final takeaway Copilot is not always right. Benchmark every performance suggestion before deciding whether it belongs in the code.

You can find more examples in the Spargine repository search for slower Copilot suggestions.

Pick up any books by David McCarter by going to Amazon.com: http://bit.ly/RockYourCodeBooks

If you liked this article, please buy David a cup of Coffee by going here: https://www.buymeacoffee.com/dotnetdave

© The information in this article is copywritten and cannot be reproduced in any way without express permission from David McCarter.


Discover more from dotNetTips.com

Subscribe to get the latest posts sent to your email.

Leave a Reply