Возникла необходимость вспомнить старые добрые времена и решить «студенческую» задачку: выравнивание текста по ширине. Дабы привнести немного разнообразия решено было реализовать несколько алгоритмов. Первый попроще, через string.Split(' ', StringSplitOptions.RemoveEmptyEntries);
. Второй максимально оптимальный. Третий — многопоточный. После того, как всё было сделано приступил к замерам производительности в консольке используя DateTime.Now.Ticks
. В качестве тестовых данных был выбран первый том романа «Война и мир» Льва Николаевича Толстого. Всего 114 154 слов, 1,2МБ данных в кодировке UTF-8. Получил следующие результаты: попроще — 600K, оптимальный — 200K, многопоточный — 5500K. Но как выяснилось позже полученные цифры чуть менее чем полностью не соответствуют действительности, потому что DateTime.Now
работает очень и очень медленно. Поэтому никогда, слышите, никогда-никогда не используйте DateTime.Now
для замеров производительности. На платформе .net для этого есть специальный класс Stopwatch. Или ещё можно использовать Environment.TickCount — количество миллисекунд с момента загрузки системы. В этом случае получаем следующие результаты: попроще — 125, оптимальный — 15, многопоточный — 78. Теперь понятно почему нельзя использовать DataTime? Однако это ещё не всё: от теста к тесту результаты менялись порой значительно. Например иногда оптимальный = 30, а многопоточный = 32. Такой разброс меня не устраивал, и решено было познакомиться с BenchmarkDotNet, тем более, что желание было давно, а сейчас подвернулся удобный случай. На обзорной странице BenchmarkDotNet имеется простенький пример использования в консольном приложении. Тесты производительности должны быть запущены в режиме Release, так что мне показалось удобным использовать препроцессорные директивы для разделения непосредственно тестов и режима отладки:
#if DEBUG // Ручное тестирование в режиме отладки #else var summary = BenchmarkRunner.Run(typeof(Program).Assembly); #endif
После запуска приложения, согласно описанию на странице How it works, BenchmarkDotNet будет выполнять предварительный «прогрев», а затем прогонять тесты несколько раз чтобы получить наиболее достоверные результаты. В результате получим вот это:
Method | Mean | Error | StdDev | Ratio | RatioSD |
---|---|---|---|---|---|
Simple | 42.545 ms | 0.8496 ms | 1.5958 ms | 5.33 | 0.23 |
SingleThread | 7.901 ms | 0.1555 ms | 0.2279 ms | 1.00 | 0.00 |
MultyThread1 | 9.984 ms | 0.1988 ms | 0.4878 ms | 1.25 | 0.08 |
Благодаря оптимизациям режима Release производительность значительно выросла, а благодаря многократным запускам тестируемого метода отклонения в измерениях от раза к разу стали незначительны. Profit!
Но я на этом не остановился 🙂 Мне захотелось поиграться с многопоточным исполнением чтобы обойти однопоточный вариант. Было реализовано 4 варианта:
- MultyThread1 — на основе потокобезопасной коллекции BlockingCollection. Анализ входной строки был в фоновом потоке, а сборка результата в текущем.
- MultyThread2 — аналогично предыдущему, но анализ в текущем, а сборка в фоновом.
- MultyThread3 — с использованием массива на 5 тысяч элементов в качестве промежуточного хранилища и ManualResetEventSlim для синхронизации потоков. Анализ в текущем потоке, сборка в фоновом.
- MultyThread4 — аналогично предыдущему, но для синхронизации потоки «молотили» вхолостую используя SpinWait.
Method | Mean | Error | StdDev | Ratio | RatioSD |
---|---|---|---|---|---|
Simple | 41.251 ms | 0.8195 ms | 1.4985 ms | 5.24 | 0.21 |
SingleThread | 7.877 ms | 0.1530 ms | 0.2426 ms | 1.00 | 0.00 |
MultyThread1 | 9.618 ms | 0.1920 ms | 0.4413 ms | 1.21 | 0.05 |
MultyThread2 | 10.391 ms | 0.2061 ms | 0.5208 ms | 1.31 | 0.09 |
MultyThread3 | 8.112 ms | 0.1604 ms | 0.3555 ms | 1.03 | 0.05 |
MultyThread4 | 89.374 ms | 6.8980 ms | 20.3390 ms | 10.74 | 2.35 |
Как видно из результатов я приблизился к скорости однопоточного исполнения, но не превзошёл его. Видимо затраты на сборку результирующей строки настолько малы, что не перекрывают издержек по созданию и синхронизации второго потока.