Upgrade to Pro — share decks privately, control downloads, hide ads and more …

System.IO.Pipelines で utf_ken_all.csv を爆速で読み込む

System.IO.Pipelines で utf_ken_all.csv を爆速で読み込む

.NETの System.IO.Pipelines を使って、12万行超の郵便番号CSVを爆速で読み込む実験記録です。 「CSVを固定長として扱う」アプローチにより、StreamReader と比較して速度3.5倍、メモリ割り当て1/9を実現しました。 BenchmarkDotNetによる .NET 8 と .NET 10 の比較計測結果や、SequenceReader を使った具体的な実装テクニックも公開しています。

Avatar for HideyukiKitao

HideyukiKitao PRO

November 22, 2025
Tweet

More Decks by HideyukiKitao

Other Decks in Programming

Transcript

  1. 実装しました public async Task<List<KenAll>> ParseAsync(Stream input) { List<KenAll> records =

    new(capacity: 125_000); string? line; using var reader = new StreamReader(input); while ((line = await reader.ReadLineAsync()) != null) { var fields = line.Split(","); if (fields.Length < 15) continue; var kenAll = new KenAll( CityCode5: fields[0].Trim('"'), PostalCode7: fields[2].Trim('"')); records.Add(kenAll); } return records; }
  2. 「扱いやすい新フォーマット」のデータはこうです。 27383,"585 ","5850051","オオサカフ","ミナミカワチグンチハヤアカサカムラ","チハヤ","大阪府","南河内郡... 27383,"585 ","5850052","オオサカフ","ミナミカワチグンチハヤアカサカムラ","ナカツハラ","大阪府","南河内郡... 27383,"585 ","5850042","オオサカフ","ミナミカワチグンチハヤアカサカムラ","ニガラベ","大阪府","南河内郡... 27383,"585 ","5850044","オオサカフ","ミナミカワチグンチハヤアカサカムラ","モリヤ","大阪府","南河内郡... 27383,"585

    ","5850054","オオサカフ","ミナミカワチグンチハヤアカサカムラ","ヨドシ","大阪府","南河内郡... 28101,"658 ","6580000","ヒョウゴケン","コウベシヒガシナダク","イカニケイサイガナイバアイ","兵庫県","神戸市... 28101,"658 ","6580083","ヒョウゴケン","コウベシヒガシナダク","ウオザキナカマチ","兵庫県","神戸市東灘区"... 28101,"658 ","6580026","ヒョウゴケン","コウベシヒガシナダク","ウオザキニシマチ","兵庫県","神戸市東灘区"... 28101,"658 ","6580025","ヒョウゴケン","コウベシヒガシナダク","ウオザキミナミマチ","兵庫県","神戸市東灘区"... 28101,"658 ","6580082","ヒョウゴケン","コウベシヒガシナダク","ウオザキキタマチ","兵庫県","神戸市東灘区"...
  3. JIS コードから郵便番号までは「固定長」です。 27383,"585 ","5850051","オオサカフ","... 27383,"585 ","5850052","オオサカフ","... 27383,"585 ","5850042","オオサカフ","... 27383,"585 ","5850044","オオサカフ","...

    27383,"585 ","5850054","オオサカフ","... 28101,"658 ","6580000","ヒョウゴケン",... 28101,"658 ","6580083","ヒョウゴケン",... 28101,"658 ","6580026","ヒョウゴケン",... 28101,"658 ","6580025","ヒョウゴケン",... 28101,"658 ","6580082","ヒョウゴケン",...
  4. 「System.IO.Pipelines とは?」 リリースの歴史 正式リリース: 2018 年 (.NET Core 2.1) 目的:

    ASP.NET Core (Kestrel) を世界最速にするため Microsoft の David Fowler 氏(ASP.NET Core のアーキテクト)らによって 作られた「魔改造パーツ」 。 (Gemini の見解ですw) その後のSpan<T> の実装により、使い勝手がさらにUP しました。 System.IO.Pipelines を使い byte のまま処理します。
  5. 全体像(後のページで個別に再掲します) readonly byte[] _crlf = Encoding.UTF8.GetBytes(Environment.NewLine); public async Task<List<KenAll>> ParseAsync(Stream

    input) { List<KenAll> records = new(capacity: 125_000); await PipeReadHelper.ReadFileAsync( input, _crlf, (in ReadOnlySpan<byte> buffer) => { if (buffer.Length < (15 + 7)) return; var k = new KenAll( CityCode5: Encoding.UTF8.GetString(buffer[..5]), PostalCode7: Encoding.UTF8.GetString(buffer[15..(15 + 7)]) ); records.Add(k); }); return records; }
  6. ReadOnlySpan<byte> で1 行分を受け取り 位置を決め打ちで取得します(Hardcoded Offsets ) 。 await PipeReadHelper.ReadFileAsync( input,

    _crlf, (in ReadOnlySpan<byte> buffer) => { if (buffer.Length < (15 + 7)) return; var k = new KenAll( CityCode5: Encoding.UTF8.GetString(buffer[..5]), PostalCode7: Encoding.UTF8.GetString(buffer[15..(15 + 7)]) ); records.Add(k); });
  7. クラス生成部を拡大します var k = new KenAll( CityCode5: Encoding.UTF8.GetString(buffer[..5]), PostalCode7: Encoding.UTF8.GetString(buffer[15..(15

    + 7)]) ); PostalCode7: 『先頭から15 バイト進んで7 バイト取る』 27383,"585 ","5850051","オオサカフ","... これでいいんです。
  8. また、必要な部分のみ文字列にします。 var k = new KenAll( CityCode5: Encoding.UTF8.GetString(buffer[..5]), PostalCode7: Encoding.UTF8.GetString(buffer[15..(15

    + 7)]) ); 全データを文字列化する必要などありません。 メモリにやさしく効率的ですね
  9. ベンチマーク utf_ken_all.csv 12 万行超 17MB | Method | Job |

    Runtime | Mean | Ratio | Allocated | Alloc Ratio | |------------- |---------- |---------- |----------:|------:|----------:|------------:| | Pipe | .NET 10.0 | .NET 10.0 | 28.29 ms | 1.00 | 13.31 MB | 1.00 | | StreamReader | .NET 10.0 | .NET 10.0 | 97.57 ms | 3.46 | 115.89 MB | 8.71 | 速度: Pipe 版 は StreamReader 版 よりも 約3.5 倍 高速です。 メモリ: Pipe 版 は StreamReader 版 の 約1/9 (8.7 分の1) です。 (ディスクアクセスなし。データはメモリへロード済みで計測)
  10. (おまけ 1 ).NET8 と .NET10 の比較 |Method |.NET 10.0 |.NET

    8.0 | 改善率 | |----------- |--------- |--------- |-------------- | |Pipe | 27.91 ms| 29.85 ms| 約 6.5% 高速化 | |StreamReader| 97.17 ms| 103.61 ms| 約 6.2% 高速化 | メモリの使用量は同じなので 純粋に内部処理だけで高速化しています。
  11. ( おまけ 2) PipeReadHelper について public delegate void LineReadHandler(in ReadOnlySpan<byte>

    buffer); public static async Task ReadFileAsync( Stream input, ReadOnlyMemory<byte> delimiter, LineReadHandler onLineRead, CancellationToken ct = default) { var pipe = new Pipe(); var fillTask = FillPipeAsync(input, pipe.Writer, ct); var readTask = ReadPipeAsync(pipe.Reader, delimiter, onLineRead, ct); try { await Task.WhenAll(fillTask, readTask).ConfigureAwait(false); } catch (Exception ex) {(略)} finally {(略)} }
  12. 1.SequenceReader<byte> の活用 TryReadTo は SIMD で検索するので for ループより高速です。 var sequenceReader

    = new SequenceReader<byte>(buffer); while (sequenceReader.TryReadTo( out ReadOnlySequence<byte> line, delimiter.Span))
  13. 3.ArrayPool によるフォールバック 運悪く行がセグメント境界を跨いだ場合のみ、 ArrayPool から一時配列を借りてコピーしています。 int len = (int)line.Length; var

    arr = ArrayPool<byte>.Shared.Rent(len); line.CopyTo(arr); onLineRead(arr.AsSpan(0, len)); ArrayPool<byte>.Shared.Return(arr); new byte[] をしないので「new 警察」もにっこりです。