#c# #performance #.net-core #system.io.pipelines
#c# #Производительность #.net-core #system.io.pipelines
Вопрос:
Я написал небольшую программу синтаксического анализа, чтобы сравнить старые System.IO.Stream
и новые System.IO.Pipelines
в .NET Core. Я ожидаю, что код pipelines будет иметь эквивалентную скорость или быстрее. Однако это примерно на 40% медленнее.
Программа проста: она ищет ключевое слово в текстовом файле размером 100 МБ и возвращает номер строки ключевого слова. Вот потоковая версия:
public static async Task<int> GetLineNumberUsingStreamAsync(
string file,
string searchWord)
{
using var fileStream = File.OpenRead(file);
using var lines = new StreamReader(fileStream, bufferSize: 4096);
int lineNumber = 1;
// ReadLineAsync returns null on stream end, exiting the loop
while (await lines.ReadLineAsync() is string line)
{
if (line.Contains(searchWord))
return lineNumber;
lineNumber ;
}
return -1;
}
Я бы ожидал, что приведенный выше код потока будет медленнее, чем приведенный ниже код конвейеров, потому что код потока кодирует байты в строку в StreamReader. Код pipelines позволяет избежать этого, работая с байтами:
public static async Task<int> GetLineNumberUsingPipeAsync(string file, string searchWord)
{
var searchBytes = Encoding.UTF8.GetBytes(searchWord);
using var fileStream = File.OpenRead(file);
var pipe = PipeReader.Create(fileStream, new StreamPipeReaderOptions(bufferSize: 4096));
var lineNumber = 1;
while (true)
{
var readResult = await pipe.ReadAsync().ConfigureAwait(false);
var buffer = readResult.Buffer;
if(TryFindBytesInBuffer(ref buffer, searchBytes, ref lineNumber))
{
return lineNumber;
}
pipe.AdvanceTo(buffer.End);
if (readResult.IsCompleted) break;
}
await pipe.CompleteAsync();
return -1;
}
Вот связанные вспомогательные методы:
/// <summary>
/// Look for `searchBytes` in `buffer`, incrementing the `lineNumber` every
/// time we find a new line.
/// </summary>
/// <returns>true if we found the searchBytes, false otherwise</returns>
static bool TryFindBytesInBuffer(
ref ReadOnlySequence<byte> buffer,
in ReadOnlySpan<byte> searchBytes,
ref int lineNumber)
{
var bufferReader = new SequenceReader<byte>(buffer);
while (TryReadLine(ref bufferReader, out var line))
{
if (ContainsBytes(ref line, searchBytes))
return true;
lineNumber ;
}
return false;
}
static bool TryReadLine(
ref SequenceReader<byte> bufferReader,
out ReadOnlySequence<byte> line)
{
var foundNewLine = bufferReader.TryReadTo(out line, (byte)'n', advancePastDelimiter: true);
if (!foundNewLine)
{
line = default;
return false;
}
return true;
}
static bool ContainsBytes(
ref ReadOnlySequence<byte> line,
in ReadOnlySpan<byte> searchBytes)
{
return new SequenceReader<byte>(line).TryReadTo(out var _, searchBytes);
}
Я использую SequenceReader<byte>
выше, потому что, насколько я понимаю, он более интеллектуальный / быстрый, чем ReadOnlySequence<byte>
; у него быстрый путь, когда он может работать с одним Span<byte>
.
Вот результаты тестирования (.NET Core 3.1). Полный код и результаты BenchmarkDotNet доступны в этом репозитории.
- GetLineNumberWithStreamAsync — 435,6 мс при выделении 366,19 МБ
- GetLineNumberUsingPipeAsync — 619,8 мс при выделении 9,28 МБ
Я делаю что-то не так в коде pipelines?
Обновление: Evk ответил на вопрос. После применения его исправления, вот новые контрольные цифры:
- GetLineNumberWithStreamAsync — 452,2 мс при выделении 366,19 МБ
- GetLineNumberWithPipeAsync — 203,8 мс при выделении 9,28 МБ
Комментарии:
1. Возможно, было бы полезно взять дамп памяти и посмотреть, сколько объектов вы создаете.
2. Спасибо @IanKemp. Я захватил несколько дампов памяти при повторных запусках, и код канала выполняется в постоянной памяти. Ничего подозрительного, в основном внутренние детали FileStream и объединения массивов. Я попытался предварительно прочитать MemoryStream, чтобы избежать использования FileStream, но результаты тестов аналогичны.
3. Я предполагаю, что проблема может быть в алгоритме поиска, string . Containes использует расширенный алгоритм поиска, в то время как TryReadTo использует простое решение o (n * m)
4. Спасибо за обновление вашего вопроса с помощью новых тестов!
Ответ №1:
Я считаю, что причина в реализации SequenceReader.TryReadTo
. Вот исходный код этого метода. Он использует довольно простой алгоритм (считывает до совпадения первого байта, затем проверяет, совпадают ли все последующие байты после этого, если нет — продвиньте 1 байт вперед и повторите), и обратите внимание, что в этой реализации есть довольно много методов, называемых «медленными» ( IsNextSlow
, TryReadToSlow
и так далее), поэтому в разделе atпри определенных обстоятельствах и в некоторых случаях он возвращается к некоторому медленному пути. Он также должен иметь дело с тем фактом, что последовательность может содержать несколько сегментов, и с сохранением позиции.
В вашем случае вы можете избежать использования SequenceReader
специально для поиска соответствия (но оставить его для фактического чтения строк), например, с помощью этих незначительных изменений (эта перегрузка TryReadTo
также более эффективна в этом случае):
private static bool TryReadLine(ref SequenceReader<byte> bufferReader, out ReadOnlySpan<byte> line) {
// note that both `match` and `line` are now `ReadOnlySpan` and not `ReadOnlySequence`
var foundNewLine = bufferReader.TryReadTo(out ReadOnlySpan<byte> match, (byte) 'n', advancePastDelimiter: true);
if (!foundNewLine) {
line = default;
return false;
}
line = match;
return true;
}
Затем:
private static bool ContainsBytes(ref ReadOnlySpan<byte> line, in ReadOnlySpan<byte> searchBytes) {
// line is now `ReadOnlySpan` so we can use efficient `IndexOf` method
return line.IndexOf(searchBytes) >= 0;
}
Это заставит ваш код pipelines работать быстрее, чем streams .
Комментарии:
1. Спасибо! Я обновлю свой пост новыми контрольными номерами после применения вашего решения. Я не знал, что существует перегрузка
ReadOnlySpan.IndexOf
, которая может выполнять поиск по нескольким байтам! Очень удобно.
Ответ №2:
Возможно, это не совсем то объяснение, которое вы ищете, но я надеюсь, что оно даст некоторое представление:
Взглянув на два подхода, которые у вас есть, это показывает, что во 2-м решении вычислительно сложнее, чем в другом, из-за наличия двух вложенных циклов.
Копание глубже с использованием профилирования кода показывает, что 2-й (GetLineNumberUsingPipeAsync) потребляет почти на 21,5% больше ресурсов процессора, чем тот, который использует поток (пожалуйста, проверьте скриншоты), И он достаточно близок к результату теста, который я получил:
-
Решение # 1: 683,7 мс, 365,84 МБ
-
Решение № 2: 777,5 мс, 9,08 МБ
Комментарии:
1. Спасибо за понимание! Я думаю, вы правы, что было больше циклов, чем я предполагал, особенно внутренних для некоторых методов, которые я вызывал. После применения решения @Evk цифры поменялись местами и
GetLineNumberWithPipeAsync
теперь работают быстрее.