String.Substring () scheint diesen Code zu beeinträchtigen

73

Einführung

Ich habe diesen Lieblingsalgorithmus, den ich vor einiger Zeit gemacht habe und den ich immer in neuen Programmiersprachen, Plattformen usw. schreibe und neu schreibe, als eine Art Benchmark. Obwohl meine Hauptprogrammiersprache C # ist, habe ich den Code buchstäblich kopiert und die Syntax leicht geändert, ihn in Java erstellt und festgestellt, dass er 1000x schneller ausgeführt wird.

Der Code

Es gibt ziemlich viel Code, aber ich werde nur diesen Ausschnitt präsentieren, der das Hauptproblem zu sein scheint:

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))
         ...

Die Daten

Es ist wichtig darauf hinzuweisen, dass die Zeichenfolge s1 in diesem speziellen Test eine Länge von 1 Million Zeichen (1 MB) hat.

Messungen

Ich habe meine Codeausführung in Visual Studio profiliert, weil ich dachte, dass die Art und Weise, wie ich meinen Baum erstelle oder wie ich ihn durchquere, nicht optimal ist. Nach Prüfung der Ergebnisse scheint die Leitung string _s1 = s1.Substring(i, j);mehr als 90% der Ausführungszeit aufzunehmen!

Zusätzliche Beobachtungen

Ein weiterer Unterschied, den ich bemerkt habe, ist, dass Java, obwohl mein Code Single-Threaded ist, es schafft, ihn mit allen 8 Kernen (100% CPU-Auslastung) auszuführen, während mein C # -Code selbst mit Parallel.For () - und Multi-Threading-Techniken 35- schafft. Höchstens 40%. Da der Algorithmus linear mit der Anzahl der Kerne (und der Frequenz) skaliert, habe ich dies kompensiert und dennoch führt das Snippet in Java eine Größenordnung von 100-1000x schneller aus.

Argumentation

Ich gehe davon aus, dass der Grund dafür in der Tatsache liegt, dass Strings in C # unveränderlich sind, sodass String.Substring () eine Kopie erstellen muss. Da es sich um eine verschachtelte for-Schleife mit vielen Iterationen handelt, gehe ich davon aus, dass viel kopiert wird Die Speicherbereinigung wird fortgesetzt, ich weiß jedoch nicht, wie Substring in Java implementiert ist.

Frage

Welche Möglichkeiten habe ich derzeit? An der Anzahl und Länge der Teilzeichenfolgen führt kein Weg vorbei (dies ist bereits maximal optimiert). Gibt es eine Methode, die ich nicht kenne (oder vielleicht die Datenstruktur), die dieses Problem für mich lösen könnte?

Angeforderte minimale Implementierung (aus Kommentaren)

Ich habe die Implementierung des Suffixbaums ausgelassen, der im Aufbau O (n) und im Durchlauf O (log (n)) ist

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;
}

Screenshot-Ausschnitt des Profilers

Beachten Sie, dass dies mit der Zeichenfolge s1 mit einer Größe von 300.000 Zeichen getestet wurde. Aus irgendeinem Grund werden 1 Million Zeichen in C # nie beendet, während es in Java nur 0,75 Sekunden dauert. Der verbrauchte Speicher und die Anzahl der Speicherbereinigungen scheinen kein Speicherproblem anzuzeigen. Der Peak betrug ungefähr 400 MB, aber angesichts des riesigen Suffixbaums scheint dies normal zu sein. Auch keine seltsamen Müllsammelmuster wurden entdeckt.

CPU-Profiler

Speicherprofiler

Ilhan
quelle
5
Stringin Java ist auch unveränderlich. Hast du es StringBuilderstattdessen versucht ?
Dan
1
Ich denke, Sie haben ein Speicherproblem. Hast du es dir angesehen?
Tim Schmelter
2
Sieben dieser acht Kerne in Java werden wahrscheinlich zum Müllsammeln Ihrer Teilzeichenfolgen verwendet :)
Hoodaticus
1
Haha das könnte es sein ..: '). Haben Sie eine syntaktische Idee, wie ich meine Teilzeichenfolgen erhalten könnte, ohne ständig Kopien in C # zu erstellen? Ich kann nicht einfach const char * & und Zeigerarithmetik wie in C ++ verwenden.
Ilhan
4
Bis C # wird Span<char>, wie andere Kommentatoren betonten, einfach (string, startIndex, endIndex)in Methoden wie verwenden stree.has. Verwenden Sie innerhalb der Methoden den String indexer ( s[i]), der ohne charZuordnung zurückgibt .
Ivan Stoev

Antworten:

84

Problemursprung

Nach einem glorreichen Kampf, der zwei Tage und drei Nächte dauerte (und erstaunlichen Ideen und Gedanken aus den Kommentaren), habe ich es endlich geschafft, dieses Problem zu beheben!

Ich möchte eine Antwort für alle veröffentlichen, die auf ähnliche Probleme stoßen, bei denen die string.Substring(i, j)Funktion keine akzeptable Lösung ist, um den Teilstring eines Strings zu erhalten, da der String entweder zu groß ist und Sie sich das Kopieren nicht leisten können string.Substring(i, j)(es muss) Erstellen Sie eine Kopie, da C # -Strings unveränderlich sind string.Substring(i, j)(oder in meinem Fall für verschachtelte for-Schleifen), und dies wird dem Garbage Collector schwer fällt, oder wie in meinem Fall beiden !

Versuche

Ich habe viele vorgeschlagene Dinge ausprobiert, wie den StringBuilder , Streams , die nicht verwaltete Speicherzuweisung mit Intptr und Marshal innerhalb des unsafe{}Blocks und sogar das Erstellen einer IEnumerable und die Rückgabe der Zeichen als Referenz innerhalb der angegebenen Positionen. Alle diese Versuche scheiterten letztendlich, weil irgendeine Form des Zusammenfügens der Daten durchgeführt werden musste, da es für mich keine einfache Möglichkeit gab, meinen Baum Zeichen für Zeichen zu durchlaufen, ohne die Leistung zu gefährden. Wenn es nur eine Möglichkeit gäbe, mehrere Speicheradressen innerhalb eines Arrays gleichzeitig zu überspannen, wie Sie es in C ++ mit einer Zeigerarithmetik tun könnten .. außer es gibt .. (Dank an @Ivan Stoevs Kommentar)

Die Lösung

Die Lösung war die Verwendung System.ReadOnlySpan<T>(möglicherweise nicht System.Span<T>aufgrund unveränderlicher Zeichenfolgen), die es uns unter anderem ermöglicht, Unterarrays von Speicheradressen innerhalb eines vorhandenen Arrays zu lesen, ohne Kopien zu erstellen.

Dieser Teil des Codes gepostet:

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

Wurde wie folgt geändert:

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

Wobei stree.has()nun zwei ganze Zahlen (Position und Länge des Teilstrings) benötigt werden und:

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

Beachten Sie, dass die substrVariable buchstäblich eine Referenz auf eine Teilmenge von Zeichen des ursprünglichen s1Arrays und keine Kopie ist! (Die s1Variable wurde über diese Funktion zugänglich gemacht.)

Beachten Sie, dass ich zum Zeitpunkt des Schreibens C # 7.2 und .NET Framework 4.6.1 verwende. Um die Span-Funktion zu erhalten, musste ich zu Projekt> NuGet-Pakete verwalten gehen, das Kontrollkästchen "Vorabversion einschließen" aktivieren und nach System suchen .Speichern und installieren.

Beim erneuten Ausführen des ersten Tests (bei Zeichenfolgen mit einer Länge von 1 Million Zeichen, dh 1 MB) wurde die Geschwindigkeit von 2+ Minuten (ich habe das Warten nach 2 Minuten aufgegeben) auf ~ 86 Millisekunden erhöht !!

Ilhan
quelle
2
Kann Slice als Teil der Erstellung der Span: s1.AsSpan(i, j)etwas schneller sein?
Ben Adams
Es könnte sein, dass ich nicht weiß, wie genau span implementiert ist. Es scheint nicht schneller zu sein, aber es ist intuitiv zu glauben, dass es so ist. Zumindest denke ich das. Ich werde meinen Beitrag bearbeiten und Ihren Vorschlag verwenden, da dies wahrscheinlich die beabsichtigte Art ist, span @BenAdams
Ilhan
2
Weitere Informationen zu Span, wenn Sie interessiert sind. (Nur der Vollständigkeit halber
El Mac