Skip to content

Memmove: handle overlapping forward copies in managed code - #132695

Draft
EgorBo wants to merge 7 commits into
dotnet:mainfrom
EgorBo:memmove-overlapped-forward
Draft

Memmove: handle overlapping forward copies in managed code#132695
EgorBo wants to merge 7 commits into
dotnet:mainfrom
EgorBo:memmove-overlapped-forward

Conversation

@EgorBo

@EgorBo EgorBo commented Aug 24, 2026

Copy link
Copy Markdown
Member

Experiment to test with EgorBot.

SpanHelpers.Memmove currently P/Invokes the CRT memmove for any overlap at any length. Its forward copy loop is tuned for disjoint buffers, so same-array left shifts (List<T>.RemoveAt and friends) hit two separate cliffs — see #132690:

  • rep movsb collapses to ~3 GB/s when the buffers are less than a cache line apart (Emerald Rapids, 40 KB copy)
  • for large copies it switches to non-temporal stores, evicting the very lines the rest of the copy reads back (Zen 4, 4 MB copy: 23 GB/s)

This copies the dest < src case in managed code, in strictly ascending order with the destination aligned. Right shifts are untouched — the CRT's backward loop has neither problem.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 20467548-69a0-453c-af07-8259bf6415fb
Copilot AI lite review requested due to automatic review settings August 24, 2026 12:50
@EgorBo
EgorBo marked this pull request as draft August 24, 2026 12:50
@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-memory
See info in area-owners.md if you want to be subscribed.

@EgorBo

This comment was marked as low quality.

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 changes SpanHelpers.Memmove’s handling of overlapping forward copies (dest < src) to avoid always calling into native memmove, adding a managed, strictly-forward copy strategy (including a vectorized path) intended to improve throughput for common same-buffer left-shift patterns.

Changes:

  • Adds a forward-overlap fast path that routes dest < src overlapping copies to a new managed helper.
  • Introduces MemmoveOverlappedForward and CopyForwardVectorized to copy in strictly ascending order and avoid the native overlap cliff.
  • Chunks native memmove calls for large overlapping forward copies when the managed vectorized path isn’t taken.

Comment thread src/libraries/System.Private.CoreLib/src/System/SpanHelpers.ByteMemOps.cs Outdated
@EgorBo

EgorBo commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -windows_amd -windows_intel -windows_arm -macos_arm

using System.Collections.Generic;
using System.Runtime.InteropServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

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

public class Bench {
    private List<int> _list;

    [Params(4, 10, 16, 100, 1000, 10000, 100000)]
    public int Count;

    [GlobalSetup]
    public void Setup() {
        _list = new List<int>(Count);
        for (int i = 0; i < Count; i++)
            _list.Add(i);
    }

    [Benchmark]
    public void RemoveSecond() {
        List<int> list = _list;
        list.RemoveAt(1);
        CollectionsMarshal.SetCount(list, Count);
    }
}

Aligning the destination closes the gap with rep movsb at every size, so the
distance heuristic and the chunked memmove are no longer needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 20467548-69a0-453c-af07-8259bf6415fb
@EgorBo

EgorBo commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -windows_amd -windows_intel -windows_arm -macos_arm

using System.Collections.Generic;
using System.Runtime.InteropServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

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

public class Bench {
    private List<int> _list;

    [Params(4, 10, 16, 100, 1000, 10000, 100000)]
    public int Count;

    [GlobalSetup]
    public void Setup() {
        _list = new List<int>(Count);
        for (int i = 0; i < Count; i++)
            _list.Add(i);
    }

    [Benchmark]
    public void RemoveSecond() {
        List<int> list = _list;
        list.RemoveAt(1);
        CollectionsMarshal.SetCount(list, Count);
    }
}

EgorBo and others added 4 commits August 24, 2026 18:02
Factors the copy into shared block primitives so the forward and backward
routines are mirror images of each other. Backward overlapping copies up to
256 bytes now skip the QCall as well; above that the platform memmove's
backward loop is still about twice as fast as a managed descending one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 20467548-69a0-453c-af07-8259bf6415fb
Replaces the hand-written vector blocks, which assumed the JIT would keep all
of a block's loads ahead of its stores, with constant-length Memmove calls.
The JIT unrolls those into exactly that shape by design, and picks the widest
vector the target has. Blocks are capped at 64 bytes so a call the JIT did not
unroll re-enters with a length the tails handle without calling Memmove again,
which keeps these routines from recursing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 20467548-69a0-453c-af07-8259bf6415fb
Other targets keep using the CRT memmove exactly as before, which removes the
32-bit split in the tail, the Vector128 gate and the Intrinsics using.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 20467548-69a0-453c-af07-8259bf6415fb
Mono turns every SpanHelpers.Memmove call into OP_MEMMOVE rather than an
unrolled block, so the block loops would degrade into one native call per 64
bytes there. Leave Mono on the CRT memmove.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 20467548-69a0-453c-af07-8259bf6415fb
@EgorBo

EgorBo commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -windows_amd -windows_intel -windows_arm -macos_arm

using System;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Bench<>).Assembly).Run(args);

[GenericTypeArguments(typeof(byte))]
[GenericTypeArguments(typeof(int))]
[GenericTypeArguments(typeof(long))]
public class Bench<T> {
    private T[] _array;

    [Params(4, 10, 16, 100, 1000, 10000, 100000)]
    public int Count;

    [GlobalSetup]
    public void Setup() => _array = new T[Count];

    [Benchmark]
    public void ShiftLeft() {
        Span<T> span = _array;
        span.Slice(1).CopyTo(span);
    }

    [Benchmark]
    public void ShiftRight() {
        Span<T> span = _array;
        span.Slice(0, span.Length - 1).CopyTo(span.Slice(1));
    }
}

Memmove is big enough that inlining it burns a lot of the caller's budget, and
when it was inlined into the overlapped copies the JIT no longer had a call to
unroll - the inlined body re-dispatched on overlap and called straight back in,
which cost ~8x at tier1. Keeping the call also makes the unroll reliable, so the
block can be the widest the JIT still expands: 256 bytes with Vector512.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 20467548-69a0-453c-af07-8259bf6415fb
@EgorBo

EgorBo commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

@EgorBot -windows_amd -windows_intel -windows_arm -macos_arm -linux_amd

using System;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(Bench<>).Assembly).Run(args);

[GenericTypeArguments(typeof(byte))]
[GenericTypeArguments(typeof(int))]
[GenericTypeArguments(typeof(long))]
public class Bench<T> {
    private T[] _array;

    [Params(4, 10, 16, 100, 1000, 10000, 100000)]
    public int Count;

    [GlobalSetup]
    public void Setup() => _array = new T[Count];

    [Benchmark]
    public void ShiftLeft() {
        Span<T> span = _array;
        span.Slice(1).CopyTo(span);
    }

    [Benchmark]
    public void ShiftRight() {
        Span<T> span = _array;
        span.Slice(0, span.Length - 1).CopyTo(span.Slice(1));
    }
}

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.

2 participants