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

15

我写了一个小的解析程序来比较旧的System.IO.Stream和新的System.IO.Pipelines在.NET Core中的性能。我期望管道代码的速度与或更快。然而,它慢了约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结果可在此存储库中找到。

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

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

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

  • GetLineNumberWithStreamAsync - 452.2 ms,分配了 366.19 MB
  • GetLineNumberWithPipeAsync - 203.8 ms,分配了 9.28 MB

可能会很有启发性,进行内存转储并查看您正在创建多少个对象。 - Ian Kemp
感谢@IanKemp。我在多次运行中捕获了几个内存转储,并且管道代码正在恒定的内存中运行。没有什么可疑的,主要是FileStream和数组池的内部细节。我尝试预先读取到MemoryStream中以避免使用FileStream,但基准测试结果相似。 - Will
1
我猜问题可能出在搜索算法上,string.Contains使用高级搜索算法,而TryReadTo则采用简单的O(n*m)解决方案。 - Irdis
3
感谢您更新问题并提供新的基准数据! - Matthew
2个回答

11

我认为原因是实现了SequenceReader.TryReadTo这里是该方法的源代码。 它使用了一个相当直接的算法(读取到第一个字节的匹配,然后检查该匹配后的所有后续字节是否匹配,如果不匹配,则向前移动1个字节并重复),请注意,在此实现中有相当多称为“slow”的方法(例如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;
}

这将使您的 pipes 代码比 streams 代码运行更快。


谢谢!在应用您的解决方案后,我将使用新的基准数字更新我的帖子。我不知道ReadOnlySpan.IndexOf有一个重载版本可以搜索多个字节!非常方便。 - Will

3

或许这不是您寻求的详细解释,但我希望它能提供一些见解:

浏览您两种解决方案,第二种解决方案比另一种计算上更加复杂,因为它有两个嵌套循环。

使用代码分析更深入地挖掘显示,第二种方法(GetLineNumberUsingPipeAsync)几乎比使用流的方法CPU密集程度高21.5% (请检查屏幕截图)。而且它足够接近我得到的基准结果:

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

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

enter image description here

enter image description here

enter image description here


1
谢谢您的见解!我认为您说得对,有更多的循环在进行中,尤其是在我调用的一些方法内部。在应用了@Evk的解决方案之后,数字已经反转,GetLineNumberWithPipeAsync现在更快了。 - Will

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接