String.Substring()似乎使此代码成为瓶颈

时间:2018-08-03 13:22:14

标签: c# performance substring

简介

我有一个很喜欢的算法,我很早以前就做了,我总是用新的编程语言,平台等来编写和重写,以此作为某种基准。尽管我的主要编程语言是C#,但我只是从字面上复制粘贴了代码,并稍稍更改了语法,使用Java进行了构建,发现其运行速度提高了1000倍。

代码

有很多代码,但是我只打算介绍这个片段,这似乎是主要问题:

for (int i = 0; i <= s1.Length; i++) 
{
    for (int j = i + 1; j <= s1.Length - i; j++)
    {
        string _s1 = s1.Substring(i, j);
        if (tree.hasLeaf(_s1))
         ...

数据

必须指出,在此特定测试中,字符串s1的长度为1百万个字符(1MB)。

度量

我已经在Visual Studio中描述了我的代码执行情况,因为我认为构造树或遍历树的方法不是最佳的。检查结果后,似乎string _s1 = s1.Substring(i, j);行容纳了90%以上的执行时间!

其他观察结果

我注意到的另一个区别是,尽管我的代码是单线程的Java还是设法使用所有8个内核(100%CPU利用率)执行它,而即使使用Parallel.For()和多线程技术,我的C#代码也可以做到最多使用35-40%。由于该算法随内核数(和频率)线性扩展,因此我对此进行了补偿,而Java中的代码段执行速度却快了100-1000倍。

理由

我认为发生这种情况的原因与以下事实有关:C#中的字符串是不可变的,因此String.Substring()必须创建一个副本,并且由于它位于嵌套的for循环中且包含许多迭代,因此我推测很多复制和垃圾回收的过程正在进行中,但是,我不知道Substring是如何在Java中实现的。

问题

目前我有什么选择?子字符串的数量和长度没有办法解决(这已经得到了最大程度的优化)。是否有我不知道的方法(或数据结构)可以为我解决这个问题?

要求的最低限度实施(根据评论)

我省略了后缀树的实现,后缀树在构造中为O(n),在遍历中为O(log(n))

public static double compute(string s1, string s2)
{
    double score = 0.00;
    suffixTree stree = new suffixTree(s2);
    for (int i = 0; i <= s1.Length; i++) 
    {
        int longest = 0;
        for (int j = i + 1; j <= s1.Length - i; j++)
        {
            string _s1 = s1.Substring(i, j);
            if (stree.has(_s1))
            {
                score += j - i;
                longest = j - i;
            }
            else break;
         };

        i += longest;
    };
    return score;
}

探查器的屏幕截图

请注意,已使用字符串s1进行了测试,该字符串的大小为300.000个字符。出于某种原因,一百万个字符在C#中从未完成,而在Java中仅需要0.75秒。.消耗的内存和垃圾回收的数量似乎并不表示存在内存问题。峰值约为400 MB,但考虑到巨大的后缀树,这似乎是正常的。也没有发现奇怪的垃圾收集模式。

CPU profiler

Memory profiler

1 个答案:

答案 0 :(得分:85)

问题来源

经过持续两天三夜的光荣战役(以及评论中令人惊奇的想法和想法),我终于设法解决了这个问题!

我想为遇到类似问题的任何人发布答案,其中string.Substring(i, j)函数不是获取字符串子字符串的可接受解决方案,因为字符串太大或者您负担不起由string.Substring(i, j)完成的复制(由于C#字符串是不可变的,因此无法复制,因此必须进行复制),或者string.Substring(i, j)被同一字符串多次调用(例如在我的嵌套for循环)给垃圾收集器带来了麻烦,或者在我的情况下都是如此!

尝试

我尝试了许多建议的操作,例如 StringBuilder ,使用 Intptr Marshal 的非托管内存分配在unsafe{}块中,甚至创建IEnumerable并产生yield,都通过给定位置中的引用返回字符。所有这些尝试最终都以失败告终,因为必须进行某种形式的数据连接,因为没有一种简单的方法可以让我逐字遍历我的树而又不损害性能。如果只有一种方法可以一次跨越数组中的多个内存地址,就像您可以在C ++中使用某些指针算法那样。 (获得@Ivan Stoev的评论)

解决方案

该解决方案使用的是System.ReadOnlySpan<T>(由于字符串是不可变的,因此无法System.Span<T>),它使我们能够读取现有数组中的内存地址子数组而无需创建副本

这段代码发布了:

string _s1 = s1.Substring(i, j);
if (stree.has(_s1))
{
    score += j - i;
    longest = j - i;
}

已更改为以下内容:

if (stree.has(i, j))
{
    score += j - i;
    longest = j - i;
}

stree.has()现在取两个整数(子串的位置和长度)并执行:

ReadOnlySpan<char> substr = s1.AsSpan(i, j);

请注意,substr变量实际上是对初始s1数组的字符子集的引用,而不是副本! (已通过该函数访问s1变量)

请注意,在撰写本文时,我正在使用C#7.2和.NET Framework 4.6.1,这意味着要获得Span功能,我必须转到“项目”>“管理NuGet程序包”,选中“包括预发行版”复选框,然后浏览以找到System.Memory并安装它。

重新运行初始测试(在长度为1百万个字符(即1MB)的字符串上),速度从2分钟以上(2分钟后我放弃等待)增加到约86毫秒!