Skip to content

Write File span and memory payloads without copying them - #701

Merged
SimonCropp merged 1 commit into
mainfrom
perf/file-bytes-no-copy
Oct 12, 2026
Merged

SimonCropp merged 1 commit into
mainfrom
perf/file-bytes-no-copy

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

Three of the File span/memory polyfills (active below net9) copy the whole payload with ToArray() before writing it:

  • AppendAllBytes(string, ReadOnlySpan<byte>)
  • WriteAllBytesAsync(string, ReadOnlyMemory<byte>, CancellationToken)
  • AppendAllBytesAsync(string, ReadOnlyMemory<byte>, CancellationToken)

Change

  • netcoreapp2.1+ / netstandard2.1 (so netcoreapp3.1 through net8 in practice): the span or memory is handed straight to Stream.Write(ReadOnlySpan<byte>) / Stream.WriteAsync(ReadOnlyMemory<byte>).
  • .NET Framework / netstandard2.0, memory overloads: MemoryMarshal.TryGetArray reaches the backing array and writes segment.Array, segment.Offset, segment.Count; only memory that is not array backed is still copied.
  • .NET Framework / netstandard2.0, span overload: a span has no array to reach, so it is copied through an ArrayPool buffer of at most 81,920 bytes, a chunk at a time, rather than into a new array the size of the payload.

AppendAllBytesAsync(ReadOnlyMemory) used to forward to the byte[] overload; it now opens its own stream so that it can write the memory. That stream is opened the way WriteAllBytesAsync next to it opens its stream (bufferSize: 4096, useAsync: true).

WriteAllBytes(string, ReadOnlySpan<byte>) is left alone; its copy is documented by its //Note:.

Measurements

net48, x64, Release, writing to a temp file. Time includes opening and closing the file and is noisy from run to run (file system); allocated bytes are per call on the calling thread.

Payload Overload Before After
1 KB AppendAllBytes(span) 133 us / 5,352 B 128 us / 4,304 B
1 KB WriteAllBytesAsync(memory) 399 us / 6,192 B 457 us / 5,154 B
1 KB AppendAllBytesAsync(memory) 451 us / 1,881 B 552 us / 5,154 B
1 MB AppendAllBytes(span) 911 us / 1,048,784 B 634 us / 184 B
1 MB WriteAllBytesAsync(memory) 1,284 us / 1,049,761 B 2,208 us / 1,118 B
1 MB AppendAllBytesAsync(memory) 1,511 us / 1,049,500 B 945 us / 1,129 B
16 MB AppendAllBytes(span) 12,352 us / 16,777,424 B 7,037 us / 184 B
16 MB WriteAllBytesAsync(memory) 10,599 us / 16,778,376 B 6,559 us / 1,136 B
16 MB AppendAllBytesAsync(memory) 10,651 us / 16,778,064 B 6,833 us / 1,136 B

At 1 KB the time differences are noise; the saving is the payload-sized array. In the 1 KB AppendAllBytesAsync(memory) row the "before" figure does not include the FileStream's 4 KB buffer, because the old code wrote through a synchronous stream on a pool thread, which the per-thread counter does not see.

Tests

In a unique temp directory that is deleted afterwards, for payloads of 0, 1, 100, 4096, 5000, 81,920, 81,921 and 300,000 bytes (empty, below and above the FileStream buffer, at and past the chunk size):

  • AppendAllBytes(span): creates the file, then appends a slice taken from the middle of a larger array; only the slice is written and the source array is untouched. Also a stackalloc span.
  • WriteAllBytesAsync(memory): a slice of an array (non-zero offset) and a slice of a MemoryManager<byte> that TryGetArray cannot see through; over an existing longer file (truncated) and to a missing file.
  • AppendAllBytesAsync(memory): the same two sources, creating the file and then appending twice.
  • default(ReadOnlyMemory<byte>) for both memory overloads, and a missing directory for all three.

The existing test that an already cancelled token neither creates nor truncates the file covers both memory overloads.

@SimonCropp SimonCropp modified the milestones: 11.4.4, 11.4.5 Oct 12, 2026
@SimonCropp
SimonCropp merged commit 03228ec into main Oct 12, 2026
6 of 7 checks passed
@SimonCropp
SimonCropp deleted the perf/file-bytes-no-copy branch October 12, 2026 12:09
This was referenced Oct 12, 2026
This was referenced Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant