Почему этот System.IO.Pipelines кодирует намного медленнее, чем код на основе потока?

#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 теперь работают быстрее.