Привет, Хабр! Некоторое время назад меня заинтересовал вопрос: как эффективнее всего читать данные с диска (при условии, что у вас .Net)? Задача чтения кучи файлов встречается во множестве программ, которые при самом старте начинают вычитывать конфигурации, некоторые самостоятельно подгружают модули и т.д.

В интернете я не нашел подобных сравнений (если не считать тюнинга под определенные конфигурации).

Результаты можно посмотреть на GithubSSDHDD.

Способы чтения и алгоритм тестирования


Есть несколько основных способов:


Тестировал я все на SSD и HDD (в первом случае был компьютер с Xeon 24 cores и 16 Гб памяти и Intel SSD, во втором — Mac Mini MGEM2LL/A с Core i5, 4 Гб RAM и HDD 5400-rpm). Системы такие, чтобы по результатам можно было бы понять, как лучше вести себя на относительно современных системах и на не очень новых.

Проект можно посмотреть здесь, он представляет собой один главный исполняемый файл TestsHost и кучу проектов с названиями Scenario*. Каждый тест это:

  1. Запуск exe-файла, который посчитает чистое время.

  2. Раз в секунду проверяется нагрузка на процессор, потребление оперативной памяти, нагрузка на диск и еще ряд производных параметров (с помощью Performance Counters).

  3. Результат запоминается, тест повторяется несколько раз. Итоговый результат работы — это среднее время, без учета самых больших и самых малых значений.

Подготовка к тесту более хитрая. Итак, перед запуском:

  1. Определяемся с размером файлов и с их числом (я выбрал такие, чтобы суммарный объем был больше, чем объем RAM, чтобы подавить влияние дискового кеша);

  2. Ищем на компьютере файлы заданного размера (а заодно игнорируем недоступные файлы и еще ряд спецпапок, про которые написано ниже);

  3. Запускаем один из тестов на наборе файлов, игнорируем результат. Все это нужно для того, чтобы сбросить кеш ОС, убрать влияние от предыдущих тестов и просто прогреть систему.

И не забываем про обработку ошибок:

  1. Программа выдаст код возврата 0 только в случае, если все файлы были прочитаны.

  2. Иногда весь тест падает, если вдруг система начинает активно читать файл. Вздыхаем и перезапускаем еще раз, добавляя файл (или папку) в игнорируемые. Так как я использовал каталоги Windows & Program Files как хороший источник файлов, наиболее реалистично размазанный по диску, некоторые файлы могли быть ненадолго заблокированы.

  3. Иногда один Performance Counter мог выдать ошибку, так как процесс, например, уже начал завершаться. В этом случае игнорируются все счетчики за эту секунду.

  4. На больших файлах некоторые тесты стабильно выдавали Out Of Memory исключения. Их я убрал из результатов.

И плюс стандартные моменты про нагрузочное тестирование:

  1. Компиляция — в режиме Release в MSVS. Запуск идет как отдельное приложение, без отладчика и пр. Нет какого-то тюнинга, ведь суть проверок именно в том — как в обыкновенном ПО читать файлы быстрее.

  2. Антивирус отключен, обновление системы остановлено, активные программы остановлены тоже. Больше никаких тюнингов не было, по той же причине.

  3. Каждый тест — это запуск отдельного процесса. Overhead получился в рамках погрешности (т.е. jit, траты на старт процесса и пр.), а потому я оставил именно такую изоляцию.

  4. Некоторые Performance Counters выдавали нулевой результат всегда для HDD/SSD. Так как набор счетчиков вшит в программу, я их оставил.

  5. Все программы запускались как x64, попытка сделать swap означала неэффективность по памяти и сразу же уходила вниз в статистике из-за большого времени работы.

  6. Thread Priority и пр. тюнинги не использовались, так как не было попыток выжать именно максимум (который будет сильно зависеть от намного большего числа факторов).
  7. Технологии: .Net 4.6, x64

Результаты


Как я уже написал в шапке, результаты есть на GithubSSDHDD.

SSD диск


Минимальный размер файла (байты): 2, максимальный размер (байты): 25720320, средний размер (байты): 40953.1175
Сценарий
Время
ScenarioAsyncWithMaxParallelCount4
00:00:00.2260000
ScenarioAsyncWithMaxParallelCount8
00:00:00.5080000
ScenarioAsyncWithMaxParallelCount16
00:00:00.1120000
ScenarioAsyncWithMaxParallelCount24
00:00:00.1540000
ScenarioAsyncWithMaxParallelCount32
00:00:00.2510000
ScenarioAsyncWithMaxParallelCount64
00:00:00.5240000
ScenarioAsyncWithMaxParallelCount128
00:00:00.5970000
ScenarioAsyncWithMaxParallelCount256
00:00:00.7610000
ScenarioSyncAsParallel
00:00:00.9340000
ScenarioReadAllAsParallel
00:00:00.3360000
ScenarioAsync
00:00:00.8150000
ScenarioAsync2
00:00:00.0710000
ScenarioNewThread
00:00:00.6320000

Итак, при чтении множества мелких файлов два победителя — асинхронные операции. На деле в обоих случаях .Net использовал 31 поток.

По сути обе программы различались наличием или отсутствием ActionBlock для ScenarioAsyncWithMaxParallelCount32 (с ограничением), в итоге получилось, что чтение лучше не ограничивать, тогда будет использоваться больше памяти (в моем случае в 1,5 раза), а ограничение будет просто на уровне стандартных настроек (т.к. Thread Pool зависит от числа ядер и т.д.)

Минимальный размер файла (байты): 1001, максимальный размер (байты): 25720320, средний размер (байты): 42907.8608
Сценарий
Время
ScenarioAsyncWithMaxParallelCount4
00:00:00.4070000
ScenarioAsyncWithMaxParallelCount8
00:00:00.2210000
ScenarioAsyncWithMaxParallelCount16
00:00:00.1240000
ScenarioAsyncWithMaxParallelCount24
00:00:00.2430000
ScenarioAsyncWithMaxParallelCount32
00:00:00.3180000
ScenarioAsyncWithMaxParallelCount64
00:00:00.5100000
ScenarioAsyncWithMaxParallelCount128
00:00:00.7270000
ScenarioAsyncWithMaxParallelCount256
00:00:00.8190000
ScenarioSyncAsParallel
00:00:00.7590000
ScenarioReadAllAsParallel
00:00:00.3120000
ScenarioAsync
00:00:00.5080000
ScenarioAsync2
00:00:00.0670000
ScenarioNewThread
00:00:00.6090000

Увеличив минимальный размер файла, я получил:

  1. В лидерах остался запуск программы с числом потоков, близким к числу ядер процессоров.
  2. В ряде тестов один из потоков постоянно ждал освобождение блокировки (см. Performance Counter «Concurrent Queue Length»).
  3. Синхронный способ чтение с диска все еще в аутсайдерах.

Минимальный размер файла (байты): 10007, максимальный размер (байты): 62 444 171, средний размер (байты): 205102.2773
Сценарий
Время
ScenarioAsyncWithMaxParallelCount4
00:00:00.6830000
ScenarioAsyncWithMaxParallelCount8
00:00:00.5440000
ScenarioAsyncWithMaxParallelCount16
00:00:00.6620000
ScenarioAsyncWithMaxParallelCount24
00:00:00.8690000
ScenarioAsyncWithMaxParallelCount32
00:00:00.5630000
ScenarioAsyncWithMaxParallelCount64
00:00:00.2050000
ScenarioAsyncWithMaxParallelCount128
00:00:00.1600000
ScenarioAsyncWithMaxParallelCount256
00:00:00.4890000
ScenarioSyncAsParallel
00:00:00.7090000
ScenarioReadAllAsParallel
00:00:00.9320000
ScenarioAsync
00:00:00.7160000
ScenarioAsync2
00:00:00.6530000
ScenarioNewThread
00:00:00.4290000

И последний тест для SSD: файлы от 10 Кб, их число меньше, однако сами они больше. И как результат:

  1. Если не ограничивать число потоков, то время чтения становится ближе к синхронным операциям
  2. Ограничивать уже желательнее как (число ядер) * [2.5 — 5.5]

HDD диск


Если с SSD все было более-менее хорошо, здесь у меня участились падения, так что часть результатов с упавшими программами я исключил.

Минимальный размер файла (байты): 1001, максимальный размер (байты): 54989002, средний размер (байты): 210818,0652
Сценарий
Время
ScenarioAsyncWithMaxParallelCount4
00:00:00.3410000
ScenarioAsyncWithMaxParallelCount8
00:00:00.3050000
ScenarioAsyncWithMaxParallelCount16
00:00:00.2470000
ScenarioAsyncWithMaxParallelCount24
00:00:00.1290000
ScenarioAsyncWithMaxParallelCount32
00:00:00.1810000
ScenarioAsyncWithMaxParallelCount64
00:00:00.1940000
ScenarioAsyncWithMaxParallelCount128
00:00:00.4010000
ScenarioAsyncWithMaxParallelCount256
00:00:00.5170000
ScenarioSyncAsParallel
00:00:00.3120000
ScenarioReadAllAsParallel
00:00:00.5190000
ScenarioAsync
00:00:00.4370000
ScenarioAsync2
00:00:00.5990000
ScenarioNewThread
00:00:00.5300000

Для мелких файлов в лидерах опять асинхронное чтение. Однако и синхронная работа тоже показала неплохой результат. Ответ кроется в нагрузке на диск, а именно — в ограничении параллельных чтений. При попытке принудительно начать читать во много потоков система упирается в большую очередь на чтение. В итоге вместо параллельной работы время тратится на попытки параллельно обслужить много запросов.

Минимальный размер файла (байты): 1001, максимальный размер (байты): 54989002, средний размер (байты): 208913,2665
Сценарий
Время
ScenarioAsyncWithMaxParallelCount4
00:00:00.6880000
ScenarioAsyncWithMaxParallelCount8
00:00:00.2160000
ScenarioAsyncWithMaxParallelCount16
00:00:00.5870000
ScenarioAsyncWithMaxParallelCount32
00:00:00.5700000
ScenarioAsyncWithMaxParallelCount64
00:00:00.5070000
ScenarioAsyncWithMaxParallelCount128
00:00:00.4060000
ScenarioAsyncWithMaxParallelCount256
00:00:00.4800000
ScenarioSyncAsParallel
00:00:00.4680000
ScenarioReadAllAsParallel
00:00:00.4680000
ScenarioAsync
00:00:00.3780000
ScenarioAsync2
00:00:00.5390000
ScenarioNewThread
00:00:00.6730000

Для среднего размера файлов асинхронное чтение продолжало показывать лучший результат, разве что число потоков желательно ограничивать еще меньшим значением.

Минимальный размер файла (байты): 10008, максимальный размер (байты): 138634176, средний размер (байты): 429888,6019
Сценарий
Время
ScenarioAsyncWithMaxParallelCount4
00:00:00.5230000
ScenarioAsyncWithMaxParallelCount8
00:00:00.4110000
ScenarioAsyncWithMaxParallelCount16
00:00:00.4790000
ScenarioAsyncWithMaxParallelCount24
00:00:00.3870000
ScenarioAsyncWithMaxParallelCount32
00:00:00.4530000
ScenarioAsyncWithMaxParallelCount64
00:00:00.5060000
ScenarioAsyncWithMaxParallelCount128
00:00:00.5810000
ScenarioAsyncWithMaxParallelCount256
00:00:00.5540000
ScenarioReadAllAsParallel
00:00:00.5850000
ScenarioAsync
00:00:00.5530000
ScenarioAsync2
00:00:00.4440000

Опять в лидерах асинхронное чтение с ограничением на число параллельных операций. Причем, рекомендуемое число потоков стало еще меньше. А параллельное синхронное чтение стабильно стало показывать Out Of Memory.

При большем увеличении размера файла сценарии без ограничения на число параллельных чтений чаще падали с Out Of Memory. Так как результат не был стабильным от запуска к запуску, подобное тестирование я уже счел нецелесообразным.

Итог


Какой же результат можно почерпнуть из этих тестов?

  • Почти во всех случаях асинхронное чтение, по сравнению с синхронным, давало лучший результат по скорости.

  • При росте размера файла целесообразно ограничивать число потоков, так как иначе чтение будет медленным, плюс повысится риск OOM.

  • Во всех случаях не было радикально большого прироста в производительности, максимум — в 2-3 раза. А потому возможно, что переписывать старое legacy приложение на асинхронное чтение не стоит.

  • Однако для новых программ async доступ к файлам как минимум уменьшит вероятность падений и увеличит скорость.
Поделиться с друзьями
-->

Комментарии (17)


  1. KvanTTT
    27.06.2017 11:00
    +7

    По-моему целесообразно убрать лидирующие нули в таблицах и отображать время в миллисекундах.


    1. imanushin
      27.06.2017 12:12
      +1

      Согласен, исправлю чуть позже


  1. szKarlen
    27.06.2017 11:33
    +3

    Я все ждал, когда же в статье будет сравнение RandomAccess и SequentialScan, но его нет :(
    Кстати, размеры буферов для чтения тоже влияют на производительность.


    Сравнение количества потоков и используемого API (синхронное против асинхронного) лишь показывает, что мы в итоге упремся в ограничение роста производительности, т.е. закон Амдала. (кстати, тесты с 64/128/256 потоками наглядно это демонстрируют).


  1. gurux13
    27.06.2017 13:21

    1. ScenarioAsync2 00:00:00.0670000 в SSD для Минимальный размер файла (байты): 1001 почему не жирное?
    2. Сравнение миллисекунд — неблагодарное дело. Было бы здорово, если бы тесты работали хотя бы секунду, а то и десяток.
    3. OOM не должно быть ни при каком раскладе. Вы складируете всё прочитанное в память? Тогда на время повлияет уборка мусора. Может быть, стоит складировать всё время в один и тот же byte[], если не ReadAllText?
    4. Для больших файлов, разбросанных по диску (видео, например), последовательное чтение должно быть существенно быстрее на HDD, в предположении о дефрагментированном харде.
    5. Использование существующих файлов на харде — плохая идея, имхо. Доступы к ним со стороны системы непредсказуемы. Лучше создать некоторую тестовую папку с таким набором файлов, который Вам интересен. Минус в том, что они все создаются примерно одновременно и вряд ли оказываются размазанными. Но, с другой стороны, в реальных условиях программа тоже устанавливается за ограниченное время.


    1. imanushin
      27.06.2017 13:46

      1. Моя промашка, абсолютно согласен, спасибо!
      2. "Сравнение миллисекунд" — да, согласен. Результаты у меня стабильно воспроизводились, потому я оставил именно миллисекунды.
      3. И всё-таки, он получался. По моим предположениям (субъективным), всё это было от большого числа запущенных операций (и большого размера файлов), т.е. все объекты были действительно достижимы. Вы правы, для избежания подобного поведения стоило как минимум обрабатывать файлы блоками (например, используя Microsoft.IO.RecyclableMemoryStream. Здесь же моя идея была следующая: сравнить скорость, используя простую логику чтения (т.е. ту, которую чаще всего будет использовать разработчик при стандартных задачах чтения с диска). Я уверен, что при разработке IO-bound приложений используется немало оптимизаций, все они за рамками моих замеров
      4. Я запускал дефрагментацию заранее на HDD. Если есть силы и время — Вы можете запустить тест на своем компьютере, для этого достаточно сделать git pull и запустить проект TestHost. У меня теория не воспроизвелась.
      5. Возможно. Моё основное предположение — сделать замер скорости чтения случайного набора файлов. Т.е. когда они обновляются в разное время, а значит дефрагментация может постепенно разнести их по диску.


  1. FramePS2
    27.06.2017 19:07
    +2

    В асинхронных тестах вы открываете файл с помощью вызова File.OpenRead(), а этот вызов создаёт FileStream в синхронном режиме. В итоге тестируется работа ThreadPool, а не асинхронного ввода-вывода. Попробуйте повторить асинхронные тесты, создав явно FileStream с параметром useAsync = true.


    1. imanushin
      27.06.2017 19:08

      Интересный момент, окей, проверю чуть позже


  1. kayan
    27.06.2017 23:19
    +2

    Если вы не знакомы с BenchmarkDotNet — самое время познакомиться. Скорее всего, при общей простоте использования, он более грамотно замерит "миллисекунды". И — нет, я не Андрей Акиньшин. :)


    1. imanushin
      28.06.2017 00:06
      +1

      Да, я полностью согласен, однако я начал делать все замеры в марте 2016 года, когда BenchmarkDotNet от DreamWalker 'а была менее популярна.
      Сейчас я бы основывал свои проверки именно на ней.


  1. Fahrain
    28.06.2017 14:04

    У меня вот похуже задача сейчас есть… Надо не просто много файлов быстро прочитать, а перебрать содержимое папки со подпапками, причем внутри более миллиона файлов. Процесс занимает более получаса.
    Задача в том, чтобы найти новые файлы (которых еще нет в базе) и посчитать для них хеши по алгоритму. Но всё упирается именно в задачу перебора всех файлов в папке, чтобы найти именно новые


    1. Ogoun
      28.06.2017 20:47

      Можно сделать отдельный каталог для появления новых файлов, из которого скрипт будет переносить в каталоги с кучей файлов попутно отмечая в базе.


    1. Varim
      28.06.2017 21:48

      найти новые файлы
      Чем плох FileSystemWatcher?


      1. Fahrain
        29.06.2017 02:24
        +1

        Так мне не надо в реальном времени-то! Просто иногда запускаю процесс, который перебирает файлы и добавляет в базу новые, но далеко не каждый день это требуется делать.
        Ну даже если использовать FileSystemWatcher — мне это не особо поможет, т.к. программа же все равно не будет знать о том, что между ее запусками какие-то файлы в папке поменяли/добавили. Т.е. мы опять возвращаемся к тому, что надо перебрать весь список файлов во всех подпапках папки, а этот процесс занимает более получаса — причем без чтения содержимого файлов! Фактически, мне нужно имя, путь и размер файла. При этом я уже и так отказался от использования FileInfo, оказалось, что для определения размера быстрее всего — открыть файл на чтение, как-то так:

        using (var file = new FileStream(fn, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
        {
            return file.Length;
        }
        


        Судя по коду FileInfo, она запрашивает больше данных о файле и сильнее грузит жесткий диск в итоге, что при миллионе+ файлов уже в разы замедляет процесс скана.


        1. Varim
          29.06.2017 09:24
          +2

          Программа Everything очень быстро ищет файлы, читая лог файловой системы. То есть прочитать лог быстрее, чем найти файлы в каталоге. Может вам стоит посмотреть в сторону работы с логом ($LogFile, $UsnJrnl of NTFS).


          1. Fahrain
            29.06.2017 11:49

            Ну, видимо это единственный вариант. Хотя это будет тот еще квест :)
            Ну и тут может возникнуть другая проблема — размер MTF у меня 10,12 Гб…

            P.S.: Я так и не нашел в .net других функций, которые бы не базировались бы в итоге на findfirst/findnext со всеми вытекающими из этого проблемами.


            1. imanushin
              29.06.2017 15:37

              Кстати, если хочешь сделать совсем четко и реактивно, то использую Filter Driver. Вот ссылка на хабр.


              И картинка оттуда

              image


              1. Fahrain
                29.06.2017 15:39
                +2

                Ну это опять больше для риалтайма нужно… Да и тут C# уже не поможет и надо будет на C++ делать, со всеми вытекающими проблемами