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


73

介绍

我有一个很喜欢的算法,我很早以前就做了,我一直在用新的编程语言,平台等来编写和重新编写某种基准。尽管我的主要编程语言是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;
}

探查器的屏幕截图

请注意,这是使用大小为300.000个字符的字符串s1进行测试的。出于某种原因,一百万个字符从未在C#中完成,而在Java中仅花费了0.75秒。峰值约为400 MB,但是考虑到巨大的后缀树,这似乎是正常的。也没有发现任何奇怪的垃圾收集模式。

CPU分析器

内存分析器


5
StringJava中的变量也是不可变的。您尝试过StringBuilder吗?

1
我猜你有一个记忆问题。你看过了吗?
蒂姆·施密特

2
Java的八个核心中的七个可能用于垃圾回收您的子字符串:)
hoodaticus '18

1
哈哈,可能就是..:')。您是否有语法上的主意,我该如何在不使用C#的情况下始终获取副本的情况下获取子字符串?我不能只使用const char *&并使用C ++中的指针算法
。。– Ilhan

4
Span<char>就像其他评论者指出的那样,直到C#得到使用时(string, startIndex, endIndex),才可以在诸如此类的方法中使用stree.has。在方法内部使用字符串索引器(s[i]),该索引器返回无char分配。
伊万·斯托耶夫

Answers:


84

发行来源

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

我想为遇到类似问题的任何人发布答案,其中string.Substring(i, j)函数不是获取字符串子字符串的可接受解决方案,因为该字符串太大,并且您负担不起复制string.Substring(i, j)(因为C#字符串是不可变的,所以无法复制),或者在string.Substring(i, j)同一字符串上被调用了很多次(例如在我嵌套的for循环中),这给垃圾收集器带来了麻烦,或者就像我这样!

尝试次数

我尝试了很多建议的事情,例如StringBuilderStreams,在块内使用IntptrMarshal进行非托管内存分配unsafe{},甚至创建了IEnumerable并让其通过给定位置内的引用返回字符。所有这些尝试最终都以失败告终,因为必须进行某种形式的数据连接,因为没有一种简单的方法可以让我一个又一个地遍历我的树而又不损害性能。如果只有一种方法可以一次跨越数组中的多个内存地址,就像您可以在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包”,选中“包括预发行版”复选框,然后浏览到“系统”。 。内存并安装。

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


2
可以在创建Span:时进行切片s1.AsSpan(i, j)吗,应该快一点吗?
本·亚当斯

可能是因为我不知道跨度是如何实现的。它似乎没有更快,但直觉上认为它是..至少我是这样认为的。我将编辑我的帖子并使用您的建议,因为这可能是使用span @BenAdams的预期方式
Ilhan

2
如果您有兴趣,请查看有关Span的更多信息。(仅出于完整性考虑)msdn.microsoft.com/zh-cn/magazine/mt814808.aspx
El Mac,
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.