Оптимизируя ChunkedMemoryStream

Этот пост частично является продолжением “Не используйте MemoryStream”. Помимо изначально корректной имплементации добавил:

  1. Хранение в unmanaged памяти
  2. Пул буфферов памяти

Показывает неплохие результаты. Краткое по перфомансу:

  1. Побить MemoryStream на малых размерах (что стрима, что размера блоков копирования) нереально. Просто потому что внутри массив. Проседание в два раза. С другой стороны, на малых размерах все работает настолько быстро, что эта разница не имеет значения.
  2. На средних стримах различия в скорости на уровне погрешности. Так происходит из-за того, что постоянные реаллокации в MemoryStream существенно его замедляют.
  3. На больших стримах благодаря пулу буфферов выигрыш по скорости от ChunkedMemoryStream получается существенным.
  4. DotMemory показывает, что с памятью все замечательно, в отличие от MemoryStream, который с легкостью превращает код в тыкву.

А теперь о разных моментах, связанных с реализацией:

  1. Marshal.Copy (managed <-> unmanaged) слишком медленный по сравнению с Buffer.BlockCopy (managed <-> managed). Причем в основном плохой перфоманс проявляется на копировании малых блоков.
  2. Пришлось использовать unsafe и вызовы к internal методу Buffer.Memcpy (он вызывает метод Memmove, внутри которого куча хитрых оптимизаций для блоков <= 512 и фоллбек на PInvoke в случае блоков > 512). Скорость получше, чем у Marshal.Copy, но все равно медленее, чем Buffer.BlockCopy. Но нативные массивы не вариант, потому что как говорится “если ваш объект попадает в LOH, то вы лох”.
  3. Внезапно, рантайм шарпа для обычных массивов гарантирует инициализацию их дефолтным значениями. Я даже когда-то спецификацию про это читал, но вспомнил об этом, только когда увидел мусор в стриме. На всякий случай пришлось добавить и это. Поскольку реинициализация объектов (особенно в пуле) — это достаточно трудозатратная операция, пришлось изобрести хак в виде счетчика инициализированных байт. Т.е. мы разбиваем наш буффер на неициализированную часть (правая часть памяти) и на инициализированную часть (левая часть памяти). При последовательной append-only записи нам не придется инициализировать массив (потому что он перезапишется). Если мы вдруг решили писать дальше инициализированной памяти, то приходится выполнять очищение памяти, чтобы поддерживать буффер в консистентном состоянии. При возврате буффера в пул просто сбрасываем счетчик инициализированных байт.
  4. Marshal.AllocHGlobal практически бесплатен. А вот GC.AddMemoryPressure имеет время выполнения сравнимое с Marshal.AllocHGlobal (особенно на мелких блоках).
  5. Тим-лиду не понравилось, что GC.AddMemoryPressure дергается каждый раз, сказал дергать пореже, ибо неизвестно как GC реагирует на частые вызовы. Завел себе магический класс, который через Interlocked операции держит количество занятой unmanaged памяти и при превышении 16 мегабайт дергает GC.AddMemoryPressure. Работает примерно в три раза быстрее, чем прямой вызов. Но вообще это уже микрооптимизация, ибо все работает быстро.
  6. Пулл буферов реализован в виде трех пуллов буфферов фиксированного размера. Размеры буфферов: 4 килобайта, 32 килобайта, 1 мегабайт. Слишком маленький буффер делать бесполезно — оверхед по памяти от четырех килобайт незначим, а вот производительность сильно проседает. Буфферы больше 1 мегабайта будут пожалуй трудно переиспользуемыми. Потом можно подобрать размеры буфферов получше.
  7. У каждого пула есть ограничение на максимальное количество занимаемой памяти. При превышении мы просто создаем буффер, который не вернется в пул. Таким образом у нас на стримы отводится фиксированное минимальное количество памяти. Это проще, чем любые механизмы очистки.
  8. Заметил, что пул буфферов по семантике похож кэш. Возможно когда-нибудь мне это знание пригодится, если захочется прикрутить динамическую балансировку. Но динамическая балансировка вообще довольно сложное занятие, потому что скорее всего будут слишком частые выбросы на графиках, с которыми не понятно что делать в рантайме.
  9. Для хранения свободных буфферов использую ConcurrentQueue. Если посмотреть на исходники ConcurrentQueue, то можно там обнаружить CAS для руками написанного LinkedList’а массивов+ SpinWait. Побить это практически нереально, тем более я вообще не упираюсь в скорость работы пулов.
  10. Замена руками написанного LinkedList на List<> ухудшила производительность. Это довольно странно, потому что у List<> адекватное динамическое расширение и data locality. Но судя по всему, если наши блоки от 4 килобайт, то мы будем слишком редко между ними двигаться, чтобы data locality дало какой-то выигрыш по сравнению с оверхедом на List<>.
  11. Изначально код был копипастой Remoting.ChunkedMemoryStream. По мере доработки кода для поддержки всех возможностей стрима я понял что так жить нельзя и припилил ленивое выделение буфферов, вместо постоянных проверок и аллокаций в методах Read/Write. Код стал чище. Потом оказалось, что ленивое выделение буфферов оказалось не нужным, потому что всегда можно увеличивать capacity. А если юзер-код установил внезапно позицию в стриме в лям от начала, то это его проблемы — пускай ждет, когда аллоцируются буфферы. В итоге, инстанс стрима занимает минимум 4 килобайта. И это пожалуй один единственный недостаток этой реализации стрима.
  12. В отличие от Remoting.ChunkedMemoryStream этот код теперь можно понимать без особых усилий. Плюс обмазал код адским количеством комментарием. Да-да, clean code не нуждается в комментариях, но вот fast code & hard code нуждаются в них. Хотя часть комментариев стоит продублировать в контракт класса.
Show your support

Clapping shows how much you appreciated Viktor Love’s story.