Home

Awesome

K4os.Streams

NuGet Stats

Description

The need for this library was triggered by a project which used MemoryStream a lot and I was told by memory profiler that is very heavy on memory allocation.

I was aware that RecyclableMemoryStream exists but I wanted something lighter (the question if I succeeded is a different matter, lol).

There are two (so far) stream implementations in this library: ResizingByteBufferStream and ChunkedByteBufferStream. Both of them are using ArrayPool<byte> but ResizingByteBufferStream stores data in one (potentially) large array (the same approach as MemoryStream) while ChunkedByteBufferStream stores data in a list of chunks.

Measuring performance

Measuring performance if form of magic and it is very hard to get objective numbers.

It is hard to measure performance, because lot of it depends on usage patterns.

Are you using small or large streams? Do they stay in memory for long? Do you read/write them in small or large chunks? What are the thresholds for certain actions (like resizing or chunking)? Do you measure it just before threshold or just after?

Let's say we measure a data structure which rebuilds itself around 1024 elements. You measure performance at 1023 and it might the best, you measure at 1025 and it is 20% behind all other competitors.

What I measured was continuous writing (no Seek) of small chunks (1K) and then continuous reading but in bigger chunks (8K). This was based on usage pattern where I was building a json payload from data (small Writes) and then sending them over network (bigger Reads).

Note, I think I already notices that RecyclableMemoryStream prefer larger chunks, so YMMV.

All measurements were done using:

BenchmarkDotNet=v0.13.5, OS=Windows 11 (10.0.22621.1848/22H2/2022Update/SunValley2)
AMD Ryzen 5 3600, 1 CPU, 12 logical and 6 physical cores
.NET SDK=6.0.410
  [Host]     : .NET 6.0.18 (6.0.1823.26907), X64 RyuJIT AVX2
  DefaultJob : .NET 6.0.18 (6.0.1823.26907), X64 RyuJIT AVX2

NOTE: in first column names of streams has been shortened to fit in table:

NameActual class
MemoryStreamMemoryStream from System.IO
RecyclableStreamRecyclableMemoryStream from Microsoft.IO.RecyclableMemoryStream
ResizingStreamResizingByteBufferStream from K4os.Streams
ChunkedStreamChunkedByteBufferStream from K4os.Streams

Small streams (128B - 64KB)

MethodLengthMeanRatioGen0Gen1
MemoryStream12851.95 ns1.000.0411-
RecyclableStream :poop:128278.25 ns5.360.0324-
ResizingStream :trophy:12844.52 ns0.860.0401-
ChunkedStream :thumbsup:12846.08 ns0.890.0421-
MemoryStream1024101.99 ns1.000.13290.0005
RecyclableStream :poop:1024312.58 ns3.060.0324-
ResizingStream :trophy:102485.31 ns0.790.0067-
ChunkedStream :thumbsup:102490.07 ns0.880.0086-
MemoryStream :poop:8192972.6 ns1.001.85390.0668
RecyclableStream8192627.3 ns0.640.0324-
ResizingStream :thumbsup:8192503.8 ns0.520.0067-
ChunkedStream :trophy:8192476.3 ns0.490.0086-
MemoryStream :poop:653367,328.8 ns1.0015.50293.8681
RecyclableStream :trophy:653363,460.7 ns0.470.0305-
ResizingStream :thumbsup:653363,664.8 ns0.500.0038-
ChunkedStream :thumbsup:653363,705.0 ns0.510.0076-

Medium streams (128KB - 8MB)

MethodLengthMeanRatioGen0Gen1Gen2
MemoryStream :poop:13107260.229 us1.0041.626041.626041.6260
RecyclableStream :trophy:1310726.554 us0.110.0305--
ResizingStream1310727.403 us0.12---
ChunkedStream :thumbsup:1310726.836 us0.110.0458--
MemoryStream :poop:1048576770.487 us1.00499.0234499.0234499.0234
RecyclableStream :thumbsup:104857652.645 us0.070.0610--
ResizingStream104857660.258 us0.08---
ChunkedStream :trophy:104857646.239 us0.06---
MemoryStream :poop:83886087,484.830 us1.00742.1875742.1875742.1875
RecyclableStream :thumbsup:8388608439.533 us0.062.4414--
ResizingStream83886081,543.618 us0.22---
ChunkedStream :trophy:8388608380.532 us0.05---

Large streams (128MB - 512MB)

MethodLengthMeanRatioGen0Gen1Gen2
MemoryStream :poop:134217728123.99 ms1.004800.00004800.00004800.0000
RecyclableStream :thumbsup:13421772828.94 ms0.23500.000031.2500-
ResizingStream13421772841.55 ms0.33---
ChunkedStream :trophy:13421772828.85 ms0.23125.0000125.0000125.0000
MemoryStream :poop:536870912753.93 ms1.006000.00006000.00006000.0000
RecyclableStream :thumbsup:536870912138.75 ms0.188000.0000800.0000-
ResizingStream536870912163.87 ms0.20---
ChunkedStream :trophy:536870912136.63 ms0.18---

Observations

Decision tree

I just roughly scored choosing given stream implementations for certain ranges:

What I would say, the result can be read as: ResizingByteBufferStream is the best for small streams, while ChunkedByteBufferStream is the best all-rounder. MemoryStream is terrible for large streams, while RecyclableMemoryStream is quite bad for small streams.

SizeMemoryStreamResizingStreamChunkedStreamRecyclableStream
tinyBA* :trophy:A :thumbsup:F :poop:
smallD :poop:A* :trophy:A :thumbsup:B
mediumF :poop:BA* :trophy:A :thumbsup:
largeF :poop:CA* :trophy:A* :trophy:

Usage

One very important note is those streams need to be disposed to get the benefit, if you don't dispose them the performance will be roughly the same as MemoryStream.

It is a little bit problematic though as memory is disposed at Dispose so you may not access it .ToArray() after that.

If you need to get data from stream, do it before disposing it!

using var stream = new ChunkedByteBufferStream();
using var writer = new StreamWriter(stream, leaveOpen: true); // NOTE: leaveOpen!
writer.Write("Hello, world!");
writer.Flush();
Console.WriteLine(Encoding.UTF8.GetString(stream.ToArray());

There are some memory specific methods available on both streams allowing quickly access data in them:

class ResizingByteBufferStream: Stream
{
    Span<byte> AsSpan();
    
    int ExportTo(Span<byte> target);
    byte[] ToArray();
}

class ChunkedByteBufferStream: Stream
{
    int ExportTo(Span<byte> target);
    byte[] ToArray();
}

(NOTE: no AsSpan() for ChunkedByteBufferStream because it is not a single block of memory, I may add AsReadOnlySequence one day though).

Other than that it is just a Stream.

Build

build