Привет, Хабр! Некоторое время назад меня заинтересовал вопрос: как эффективнее всего читать данные с диска (при условии, что у вас .Net)? Задача чтения кучи файлов встречается во множестве программ, которые при самом старте начинают вычитывать конфигурации, некоторые самостоятельно подгружают модули и т.д.
В интернете я не нашел подобных сравнений (если не считать тюнинга под определенные конфигурации).
Результаты можно посмотреть на Github: SSD, HDD.
Способы чтения и алгоритм тестирования
Есть несколько основных способов:
- ScenarioReadAllAsParallel — читать с помощью ReadAllText на ThreadPool;
- ScenarioSyncAsParallel — читать с помощью Stream'ов синхронно на Thread Pool;
- ScenarioNewThread — читать с помощью Stream'ов синхронно и на отдельном потоке для каждого чтения, время на запуск нового потока тоже учитывается;
- ScenarioAsync2 — читать с помощью Stream'ов асинхронно (т.е. async/await, если файловая система долго отвечает, то параллельно может запуститься множество операций);
- ScenarioAsync — асинхронное чтение, однако и старт происходит тоже во много потоков (а не последовательно на одном и том же потоке Main, как в предыдущем тесте);
- ScenarioAsyncWithMaxParallelCount — читать с помощью Stream'ов асинхронно (т.е. async/await), но не больше, чем в N параллельных операций.
Тестировал я все на SSD и HDD (в первом случае был компьютер с Xeon 24 cores и 16 Гб памяти и Intel SSD, во втором — Mac Mini MGEM2LL/A с Core i5, 4 Гб RAM и HDD 5400-rpm). Системы такие, чтобы по результатам можно было бы понять, как лучше вести себя на относительно современных системах и на не очень новых.
Проект можно посмотреть здесь, он представляет собой один главный исполняемый файл TestsHost и кучу проектов с названиями Scenario*. Каждый тест это:
- Запуск exe-файла, который посчитает чистое время.
- Раз в секунду проверяется нагрузка на процессор, потребление оперативной памяти, нагрузка на диск и еще ряд производных параметров (с помощью Performance Counters).
- Результат запоминается, тест повторяется несколько раз. Итоговый результат работы — это среднее время, без учета самых больших и самых малых значений.
Подготовка к тесту более хитрая. Итак, перед запуском:
- Определяемся с размером файлов и с их числом (я выбрал такие, чтобы суммарный объем был больше, чем объем RAM, чтобы подавить влияние дискового кеша);
- Ищем на компьютере файлы заданного размера (а заодно игнорируем недоступные файлы и еще ряд спецпапок, про которые написано ниже);
- Запускаем один из тестов на наборе файлов, игнорируем результат. Все это нужно для того, чтобы сбросить кеш ОС, убрать влияние от предыдущих тестов и просто прогреть систему.
И не забываем про обработку ошибок:
- Программа выдаст код возврата 0 только в случае, если все файлы были прочитаны.
- Иногда весь тест падает, если вдруг система начинает активно читать файл. Вздыхаем и перезапускаем еще раз, добавляя файл (или папку) в игнорируемые. Так как я использовал каталоги Windows & Program Files как хороший источник файлов, наиболее реалистично размазанный по диску, некоторые файлы могли быть ненадолго заблокированы.
- Иногда один Performance Counter мог выдать ошибку, так как процесс, например, уже начал завершаться. В этом случае игнорируются все счетчики за эту секунду.
- На больших файлах некоторые тесты стабильно выдавали Out Of Memory исключения. Их я убрал из результатов.
И плюс стандартные моменты про нагрузочное тестирование:
- Компиляция — в режиме Release в MSVS. Запуск идет как отдельное приложение, без отладчика и пр. Нет какого-то тюнинга, ведь суть проверок именно в том — как в обыкновенном ПО читать файлы быстрее.
- Антивирус отключен, обновление системы остановлено, активные программы остановлены тоже. Больше никаких тюнингов не было, по той же причине.
- Каждый тест — это запуск отдельного процесса. Overhead получился в рамках погрешности (т.е. jit, траты на старт процесса и пр.), а потому я оставил именно такую изоляцию.
- Некоторые Performance Counters выдавали нулевой результат всегда для HDD/SSD. Так как набор счетчиков вшит в программу, я их оставил.
- Все программы запускались как x64, попытка сделать swap означала неэффективность по памяти и сразу же уходила вниз в статистике из-за большого времени работы.
- Thread Priority и пр. тюнинги не использовались, так как не было попыток выжать именно максимум (который будет сильно зависеть от намного большего числа факторов).
- Технологии: .Net 4.6, x64
Результаты
Как я уже написал в шапке, результаты есть на Github: SSD, HDD.
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 |
Увеличив минимальный размер файла, я получил:
- В лидерах остался запуск программы с числом потоков, близким к числу ядер процессоров.
- В ряде тестов один из потоков постоянно ждал освобождение блокировки (см. Performance Counter «Concurrent Queue Length»).
- Синхронный способ чтение с диска все еще в аутсайдерах.
Минимальный размер файла (байты): 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 Кб, их число меньше, однако сами они больше. И как результат:
- Если не ограничивать число потоков, то время чтения становится ближе к синхронным операциям
- Ограничивать уже желательнее как (число ядер) * [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 доступ к файлам как минимум уменьшит вероятность падений и увеличит скорость.