【C#】Stream.Read/Write(Span<byte>) のパフォーマンス

# はじめに

`Span<T>` が登場した頃、ストリームの読み書きにスパンを引数に取るオーバーロードが追加されました。

- [`System.IO.Stream.Read(Span<T>)`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.stream.read?view=net-9.0)
- [`System.IO.Stream.Write(ReadOnlySpan<T>)`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.stream.write?view=net-9.0)

スパンを引数に取るということで、既存の `byte[]` を引数に取る元々のメソッドよりパフォーマンスがいいのかなと漠然と考えていましたが、実装を見てみるとそうでもなさそうです。

というわけで、今回はこれのパフォーマンスを検証してみます。

## 各ストリームの実装

### System.IO.Stream.Read(Span&lt;byte&gt;)

```cs
public virtual int Read(Span<byte> buffer)
{
    byte[] sharedBuffer = ArrayPool<byte>.Shared.Rent(buffer.Length);
    try
    {
        int numRead = Read(sharedBuffer, 0, buffer.Length);
        if ((uint)numRead > (uint)buffer.Length)
        {
            throw new IOException(SR.IO_StreamTooLong);
        }

        new ReadOnlySpan<byte>(sharedBuffer, 0, numRead).CopyTo(buffer);
        return numRead;
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(sharedBuffer);
    }
}
```

`Span<byte>` を引数に取るオーバーロードの基本的な実装は ↑ で、`virtual` というのがミソです。つまり派生クラスでオーバーライドされなければ、これが呼び出されます。

- 共有バッファの取得
- バッファのコピー
- 共有バッファの返却

の処理があるため、少しパフォーマンスが低くなりそうです。共有バッファに関しては排他処理が必要ですし、どの程度のコストになるかも気になります。

### System.IO.MemoryStream.Read(Span&lt;byte&gt;)

```cs
public override int Read(Span<byte> buffer)
{
    if (GetType() != typeof(MemoryStream))
    {
        // MemoryStream is not sealed, and a derived type may have overridden Read(byte[], int, int) prior
        // to this Read(Span<byte>) overload being introduced.  In that case, this Read(Span<byte>) overload
        // should use the behavior of Read(byte[],int,int) overload.
        return base.Read(buffer);
    }

    EnsureNotClosed();

    int n = Math.Min(_length - _position, buffer.Length);
    if (n <= 0)
        return 0;

    new Span<byte>(_buffer, _position, n).CopyTo(buffer);

    _position += n;
    return n;
}
```

`MemoryStream` はメソッドをオーバーライドしてあり、専用の実装になっています。見た感じパフォーマンスは `byte[]` と同程度になりそうです。

### System.IO.FileStream.Read(Span&lt;byte&gt;)

```cs
public override int Read(Span<byte> buffer) => _strategy.Read(buffer);
```

`FileStream` もメソッドをオーバーライドしてあり、専用の実装になっています。見た感じパフォーマンスは `byte[]` と同程度になりそうです。

## パフォーマンス検証

前段を踏まえてパフォーマンスを検証してみます。

<details>
<summary>テストコード</summary>

```cs
using System.IO.Compression;

public class __StreamPerformanceTest
{
    private static ReadOnlySpan<byte> Source => """
<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style type="text/css">
    body {
        background-color: #f0f0f2;
        margin: 0;
        padding: 0;
        font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
        
    }
    div {
        width: 600px;
        margin: 5em auto;
        padding: 2em;
        background-color: #fdfdff;
        border-radius: 0.5em;
        box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
    }
    a:link, a:visited {
        color: #38488f;
        text-decoration: none;
    }
    @media (max-width: 700px) {
        div {
            margin: 0 auto;
            width: auto;
        }
    }
    </style>    
</head>

<body>
<div>
    <h1>Example Domain</h1>
    <p>This domain is for use in illustrative examples in documents. You may use this
    domain in literature without prior coordination or asking for permission.</p>
    <p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>
"""u8;

    static void Read256Bytes(Performance p)
    {
        var buffer = new byte[256];

        var memoryStream = new MemoryStream(Source.ToArray());
        p.AddTest("MemoryStream:Array", () =>
        {
            memoryStream.Position = 0;
            _ = memoryStream.Read(buffer, 0, 256);
        });
        p.AddTest("MemoryStream:Span", () =>
        {
            memoryStream.Position = 0;
            _ = memoryStream.Read(buffer.AsSpan());
        });

        File.WriteAllBytes("__TMP__", Source);
        var fileStream = File.OpenRead("__TMP__");
        p.AddTest("FileStream:Array", () =>
        {
            fileStream.Position = 0;
            _ = fileStream.Read(buffer, 0, 256);
        });
        p.AddTest("FileStream:Span", () =>
        {
            fileStream.Position = 0;
            _ = fileStream.Read(buffer.AsSpan());
        });
        Program.Exit += () =>
        {
            fileStream.Dispose();
            File.Delete("__TMP__");
        };

        var zipStream = new MemoryStream();
        using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Create, true))
        {
            var entry = zip.CreateEntry("test");
            using var entryStream = entry.Open();
            entryStream.Write(Source);
        }
        p.AddTest("ZipStream:Array", () =>
        {
            zipStream.Position = 0;
            using var zip = new ZipArchive(zipStream, ZipArchiveMode.Read, true);
            var entry = zip.GetEntry("test")!;
            using var entryStream = entry.Open();
            _ = entryStream.Read(buffer, 0, 256);
        });
        p.AddTest("ZipStream:Span", () =>
        {
            zipStream.Position = 0;
            using var zip = new ZipArchive(zipStream, ZipArchiveMode.Read, true);
            var entry = zip.GetEntry("test")!;
            using var entryStream = entry.Open();
            _ = entryStream.Read(buffer.AsSpan());
        });
    }

    static void Write256Bytes(Performance p)
    {
        var buffer = new byte[256];
        Source[..buffer.Length].CopyTo(buffer);

        p.AddTest("MemoryStream:Array", () =>
        {
            using var memoryStream = new MemoryStream();
            memoryStream.Write(buffer, 0, 256);
        });
        p.AddTest("MemoryStream:Span", () =>
        {
            using var memoryStream = new MemoryStream();
            memoryStream.Write(Source[..256]);
        });

        p.AddTest("FileStream:Array", () =>
        {
            using var fileStream = new FileStream("__TMP2__", FileMode.Create);
            fileStream.Write(buffer, 0, 256);
        });
        p.AddTest("FileStream:Span", () =>
        {
            using var fileStream = new FileStream("__TMP3__", FileMode.Create);
            fileStream.Write(Source[..256]);
        });

        p.AddTest("WriteAllBytes:Array", () =>
        {
            File.WriteAllBytes("__TMP4__", buffer);
        });
        p.AddTest("WriteAllBytes:Span", () =>
        {
            File.WriteAllBytes("__TMP5__", Source[..256]);
        });
        Program.Exit += () =>
        {
            File.Delete("__TMP2__");
            File.Delete("__TMP3__");
            File.Delete("__TMP4__");
            File.Delete("__TMP5__");
        };

        p.AddTest("ZipStream:Array", () =>
        {
            using var zipStream = new MemoryStream();
            using var zip = new ZipArchive(zipStream, ZipArchiveMode.Create);
            var entry = zip.CreateEntry("test")!;
            using var entryStream = entry.Open();
            entryStream.Write(buffer, 0, 256);
        });
        p.AddTest("ZipStream:Span", () =>
        {
            using var zipStream = new MemoryStream();
            using var zip = new ZipArchive(zipStream, ZipArchiveMode.Create);
            var entry = zip.CreateEntry("test")!;
            using var entryStream = entry.Open();
            entryStream.Write(Source[..256]);
        });
    }
}
```

</details>

|Test|Score|%|CG0|
|---|---:|---:|---:|
|Read256Bytes (6)||||
|MemoryStream:Array|2,072,848|100.0%|0|
|MemoryStream:Span|1,749,351|84.4%|0|
|FileStream:Array|1,522,456|73.4%|0|
|FileStream:Span|1,464,853|70.7%|0|
|ZipStream(Memory):Array|21,206|1.0%|3|
|ZipStream(Memory):Span|21,520|1.0%|3|

実行環境: Windows11 x64 .NET Runtime 9.0.0  
**Score** は高いほどパフォーマンスがよいです。  
**GC0** はガベージコレクション回数を表します（少ないほどパフォーマンスがよい）。

- `MemoryStream` の場合、`[]byte` のほうが `Span<byte>` より若干パフォーマンスが良いです。`MemoryStream` を使う場面は結構限定的なので、あまり気にする必要はないかもしれません
- `FileStream` の場合、`[]byte` のほうが `Span<byte>` よりわずかにパフォーマンスが良いです。とはいえ、誤差レベルなのであまり気にする必要はなさそうです
- 圧縮ファイルの場合、両者にパフォーマンスの違いはあまりないです

|Test|Score|%|CG0|
|---|---:|---:|---:|
|Write256Bytes (8)||||
|MemoryStream:Array|1,187,451|100.0%|48|
|MemoryStream:Span|1,035,178|87.2%|42|
|FileStream:Array|392|0.0%|0|
|FileStream:Span|380|0.0%|0|
|WriteAllBytes:Array|377|0.0%|0|
|WriteAllBytes:Span|374|0.0%|0|
|ZipStream(Memory):Array|9,980|0.8%|2|
|ZipStream(Memory):Span|9,940|0.8%|2|

- 概ね読み取りと同じような傾向です
- 書き込みは読み取りに対して**かなり**コストが大きいです

## おわりに

当初の予想通りストリームの読み書きメソッドのオーバーロードは若干パフォーマンスに違いがありましたが、気にするほどではなさそうです。

やはり標準ライブラリはかなりパフォーマンスよく設計されているため、頼もしさを再確認できました。