为什么此System.IO.Pipelines代码比基于Stream的代码慢得多?

问题描述

我编写了一个小程序来比较.NET Core中较旧的System.IO.Stream和较新的system.io.pipelines。我期望管道代码具有相同的速度或更快的速度。但是,速度要慢40%。

程序很简单:它在100Mb的文本文件搜索关键字,然后返回关键字的行号。这是Stream版本:

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中的字符串。管道代码通过对字节进行操作来避免这种情况:

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结果可用in this repo

  • GetLineNumberWithStreamAsync- 435.6 ms ,同时分配366.19 MB
  • GetLineNumberUsingPipeAsync- 619.8 ms ,同时分配9.28 MB

我在管道代码中做错了吗?

更新:Evk回答了该问题。应用他的修复后,这是新的基准数字:

  • GetLineNumberWithStreamAsync- 452.2毫秒,同时分配366.19 MB
  • GetLineNumberWithPipeAsync- 203.8毫秒,而分配了9.28 MB

解决方法

我相信原因是SequenceReader.TryReadTo的实现。 Here is the source code这种方法。它使用了非常简单的算法(读取到第一个字节的匹配项,然后检查该匹配项之后的所有后续字节,如果不匹配,则前进1个字节并重复),并注意在此实现中有多少方法称为“慢速” (IsNextSlowTryReadToSlow等),因此至少在某些情况下,在某些情况下,它会退回到某些缓慢的路径。它还必须处理可能包含多个段的事实序列并保持位置。

在您的情况下,您可以避免使用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;
}

这将使您的管道代码比流一运行得快。

,

这可能不完全是您要寻找的解释,但我希望它能提供一些见解:

浏览一下您所拥有的两种方法,它表明在第二种解决方案中,由于具有两个嵌套循环,因此在计算上比另一种更为复杂。

使用代码概要分析进行更深入的研究表明,第二个(GetLineNumberUsingPipeAsync)的CPU密集度比使用Stream的第二个(请检查屏幕截图)高出21.5%,它与我得到的基准测试结果非常接近:>

  • 解决方案#1:683.7 ms,365.84 MB

  • 解决方案2:777.5毫秒,9.08 MB

enter image description here

enter image description here

enter image description here