346 Stimmen

Leistungsüberraschung mit "as" und nullbaren Typen

Ich überarbeite gerade Kapitel 4 von C# in Depth, das sich mit nullable types beschäftigt, und ich füge einen Abschnitt über die Verwendung des "as"-Operators hinzu, mit dem Sie schreiben können:

object o = ...;
int? x = o as int?;
if (x.HasValue)
{
    ... // Use x.Value in here
}

Ich dachte, dass dies wirklich nett war, und dass es die Leistung über die C# 1 Äquivalent verbessern könnte, mit "is", gefolgt von einem Cast - immerhin, auf diese Weise müssen wir nur für dynamische Typüberprüfung einmal zu fragen, und dann eine einfache Wertprüfung.

Dies scheint jedoch nicht der Fall zu sein. Ich habe unten ein Beispiel für eine Testanwendung beigefügt, die im Grunde alle Ganzzahlen in einem Objekt-Array summiert - aber das Array enthält eine Menge Null-Referenzen und String-Referenzen sowie eingeschlossene Ganzzahlen. Der Benchmark misst den Code, den Sie in C# 1 verwenden müssten, den Code, der den "as"-Operator verwendet, und - nur zum Spaß - eine LINQ-Lösung. Zu meinem Erstaunen ist der C# 1-Code in diesem Fall 20 Mal schneller - und sogar der LINQ-Code (von dem ich angesichts der beteiligten Iteratoren erwartet hätte, dass er langsamer ist) schlägt den "as"-Code.

Ist die .NET-Implementierung von isinst für löschbare Typen nur sehr langsam? Ist es die zusätzliche unbox.any die das Problem verursacht? Gibt es eine andere Erklärung für dieses Problem? Im Moment sieht es so aus, als müsste ich eine Warnung vor der Verwendung in leistungssensiblen Situationen einfügen...

Ergebnisse:

Besetzung: 10000000 : 121
Als: 10000000 : 2211
LINQ: 10000000 : 2143

Code:

using System;
using System.Diagnostics;
using System.Linq;

class Test
{
    const int Size = 30000000;

    static void Main()
    {
        object[] values = new object[Size];
        for (int i = 0; i < Size - 2; i += 3)
        {
            values[i] = null;
            values[i+1] = "";
            values[i+2] = 1;
        }

        FindSumWithCast(values);
        FindSumWithAs(values);
        FindSumWithLinq(values);
    }

    static void FindSumWithCast(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            if (o is int)
            {
                int x = (int) o;
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Cast: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }

    static void FindSumWithAs(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;
            if (x.HasValue)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }

    static void FindSumWithLinq(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = values.OfType<int>().Sum();
        sw.Stop();
        Console.WriteLine("LINQ: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }
}

217voto

Hans Passant Punkte 894572

Der Maschinencode, den der JIT-Compiler für den ersten Fall erzeugen kann, ist natürlich viel effizienter. Eine Regel, die hier sehr hilfreich ist, besagt, dass ein Objekt nur in eine Variable entpackt werden kann, die denselben Typ hat wie der gepackte Wert. Dadurch kann der JIT-Compiler sehr effizienten Code erzeugen, da keine Wertumwandlungen berücksichtigt werden müssen.

En es Der Operatortest ist einfach, man muss nur prüfen, ob das Objekt nicht null ist und den erwarteten Typ hat. Das erfordert nur ein paar Maschinencode-Anweisungen. Der Cast ist ebenfalls einfach, der JIT-Compiler kennt die Position der Wertbits im Objekt und verwendet sie direkt. Es findet kein Kopieren oder Konvertieren statt, der gesamte Maschinencode ist inline und benötigt nur etwa ein Dutzend Anweisungen. Dies musste in .NET 1.0, als Boxing noch üblich war, wirklich effizient sein.

Das Casting nach int? erfordert viel mehr Arbeit. Die Wertedarstellung von boxed integer ist nicht kompatibel mit dem Speicherlayout von Nullable<int> . Eine Konvertierung ist erforderlich, und der Code ist aufgrund möglicher boxed enum types kompliziert. Der JIT-Compiler generiert einen Aufruf an eine CLR-Hilfsfunktion namens JIT_Unbox_Nullable, um die Aufgabe zu erledigen. Dies ist eine Allzweckfunktion für jeden Werttyp, die eine Menge Code zur Überprüfung der Typen enthält. Und der Wert wird kopiert. Die Kosten sind schwer abzuschätzen, da dieser Code in der mscorwks.dll eingeschlossen ist, aber wahrscheinlich sind es Hunderte von Maschinencode-Anweisungen.

Die Linq OfType()-Erweiterungsmethode verwendet auch die es Betreiber und die Besetzung. Dies ist jedoch ein Cast auf einen generischen Typ. Der JIT-Compiler erzeugt einen Aufruf einer Hilfsfunktion, JIT_Unbox(), die einen Cast auf einen beliebigen Werttyp durchführen kann. Ich habe keine großartige Erklärung dafür, warum dies so langsam ist wie der Cast nach Nullable<int> da weniger Arbeit notwendig sein sollte. Ich vermute, dass ngen.exe hier Probleme verursachen könnte.

27voto

Dirk Vollmar Punkte 166522

Es scheint mir, dass die isinst ist einfach sehr langsam bei nullbaren Typen. In der Methode FindSumWithCast Ich änderte

if (o is int)

zu

if (o is int?)

was die Ausführung ebenfalls erheblich verlangsamt. Der einzige Unterschied in IL, den ich erkennen kann, ist, dass

isinst     [mscorlib]System.Int32

wird geändert in

isinst     valuetype [mscorlib]System.Nullable`1<int32>

22voto

Johannes Rudolph Punkte 34512

Ursprünglich war dies ein Kommentar zu Hans Passants ausgezeichneter Antwort, aber er wurde zu lang, so dass ich hier ein paar Teile hinzufügen möchte:

Erstens, die C# as Operator wird eine isinst IL-Anweisung (ebenso wie die is Operator). (Eine weitere interessante Anweisung ist castclass Der Compiler weiß, dass die Laufzeitprüfung nicht unterlassen werden kann).

Hier ist, was isinst tut ( ECMA 335 Teilbereich III, 4.6 ):

Format: isinst typeTok

typeTok ist ein Metadaten-Token (ein typeref , typedef o typespec ), die die gewünschte Klasse angibt.

Si typeTok ein nicht-nullbarer Werttyp oder ein allgemeiner Parametertyp ist, wird er als "boxed" interpretiert typeTok .

Si typeTok ist ein löschbarer Typ, Nullable<T> wird es als "verpackt" interpretiert. T

Das Wichtigste:

Wenn der tatsächliche Typ (nicht der von der Prüfstelle verfolgte Typ) von obj es verifier-assignable-to der Typ typeTok dann isinst erfolgreich ist und obj (als Ergebnis ) wird unverändert zurückgegeben, während die Überprüfung seinen Typ als typeTok . Im Gegensatz zu Zwang (§1.6) und Umwandlungen (§3.27), isinst ändert nie den tatsächlichen Typ eines Objekts und bewahrt die Objektidentität (siehe Teilung I).

Der Performance-Killer ist also nicht isinst in diesem Fall, aber die zusätzliche unbox.any . Dies ging aus der Antwort von Hans nicht eindeutig hervor, da er sich nur den JIT-Code ansah. Im Allgemeinen gibt der C#-Compiler eine unbox.any nach einer isinst T? (aber ich werde es weglassen, falls Sie es tun isinst T wenn T ist ein Referenztyp).

Warum tut sie das? isinst T? hat nie den Effekt, der naheliegend gewesen wäre, d.h. man erhält eine T? . Stattdessen sorgen diese Anweisungen lediglich dafür, dass Sie eine "boxed T" die ausgepackt werden können, um T? . Um eine aktuelle T? müssen wir noch unsere "boxed T" a T? Deshalb gibt der Compiler eine unbox.any nach isinst . Wenn man darüber nachdenkt, macht dies Sinn, weil das "Kastenformat" für T? ist nur eine "boxed T" und die Herstellung castclass y isinst Die Durchführung des Unboxing wäre inkonsequent.

Zur Untermauerung der Erkenntnisse von Hans mit einigen Informationen aus dem Standard , geht es los:

(ECMA 335 Teil III, 4.33): unbox.any

Bei Anwendung auf die Boxed-Form eines Wertetyps wird die unbox.any Anweisung extrahiert den in obj enthaltenen Wert (vom Typ O ). (Es ist gleichbedeutend mit unbox gefolgt von ldobj .) Bei Anwendung auf einen Referenztyp wird die unbox.any Anweisung hat die gleiche Wirkung wie castclass TypTok.

(ECMA 335 Teil III, 4.32): unbox

Typischerweise, unbox berechnet einfach die Adresse des Werttyps, der sich bereits innerhalb des Box-Objekts befindet. Dieser Ansatz ist nicht möglich, wenn nullbare Werttypen unboxed werden. Weil Nullable<T> Werte werden in Boxen umgewandelt Ts während der Box-Operation, muss eine Implementierung oft eine neue Nullable<T> auf dem Heap und berechnen die Adresse des neu zugewiesenen Objekts.

19voto

Marc Gravell Punkte 970173

Interessanterweise habe ich das Feedback zur Unterstützung durch die Betreiber über dynamic um eine Größenordnung langsamer für Nullable<T> (ähnlich wie bei dieser frühe Test ) - ich vermute, aus sehr ähnlichen Gründen.

Muss man lieben Nullable<T> . Ein weiterer lustiger Aspekt ist, dass, obwohl die JIT erkennt (und entfernt) null für nicht-nullbare Strukturen, es funktioniert nicht für Nullable<T> :

using System;
using System.Diagnostics;
static class Program {
    static void Main() { 
        // JIT
        TestUnrestricted<int>(1,5);
        TestUnrestricted<string>("abc",5);
        TestUnrestricted<int?>(1,5);
        TestNullable<int>(1, 5);

        const int LOOP = 100000000;
        Console.WriteLine(TestUnrestricted<int>(1, LOOP));
        Console.WriteLine(TestUnrestricted<string>("abc", LOOP));
        Console.WriteLine(TestUnrestricted<int?>(1, LOOP));
        Console.WriteLine(TestNullable<int>(1, LOOP));

    }
    static long TestUnrestricted<T>(T x, int loop) {
        Stopwatch watch = Stopwatch.StartNew();
        int count = 0;
        for (int i = 0; i < loop; i++) {
            if (x != null) count++;
        }
        watch.Stop();
        return watch.ElapsedMilliseconds;
    }
    static long TestNullable<T>(T? x, int loop) where T : struct {
        Stopwatch watch = Stopwatch.StartNew();
        int count = 0;
        for (int i = 0; i < loop; i++) {
            if (x != null) count++;
        }
        watch.Stop();
        return watch.ElapsedMilliseconds;
    }
}

15voto

Glenn Slayden Punkte 15791

Um diese Antwort auf dem neuesten Stand zu halten, sollte man erwähnen, dass der Großteil der Diskussion auf dieser Seite nun hinfällig ist, da C# 7.1 y .NET 4.7 die eine schlanke Syntax unterstützt, die auch den besten IL-Code erzeugt.

Das ursprüngliche Beispiel des Auftraggebers...

object o = ...;
int? x = o as int?;
if (x.HasValue)
{
    // ...use x.Value in here
}

wird einfach...

if (o is int x)
{
    // ...use x in here
}

Ich habe festgestellt, dass die neue Syntax häufig verwendet wird, wenn Sie eine .NET Werttyp (d.h. struct en C# ), die Folgendes implementiert IEquatable<MyStruct> (wie es die meisten tun sollten). Nach der Implementierung der stark typisierten Equals(MyStruct other) Methode können Sie nun die untypisierte Equals(Object obj) Override (geerbt von Object ) wie folgt zu:

public override bool Equals(Object obj) => obj is MyStruct o && Equals(o);

Anhang: Die Release bauen IL Code für die ersten beiden Beispielfunktionen, die oben in dieser Antwort gezeigt werden, sind hier angegeben. Während der AWL-Code für die neue Syntax in der Tat 1 Byte kleiner ist, gewinnt er vor allem dadurch, dass er keine Aufrufe macht (im Gegensatz zu zwei) und die unbox wenn möglich ganz zu vermeiden.

// static void test1(Object o, ref int y)
// {
//     int? x = o as int?;
//     if (x.HasValue)
//         y = x.Value;
// }

[0] valuetype [mscorlib]Nullable`1<int32> x
        ldarg.0
        isinst [mscorlib]Nullable`1<int32>
        unbox.any [mscorlib]Nullable`1<int32>
        stloc.0
        ldloca.s x
        call instance bool [mscorlib]Nullable`1<int32>::get_HasValue()
        brfalse.s L_001e
        ldarg.1
        ldloca.s x
        call instance !0 [mscorlib]Nullable`1<int32>::get_Value()
        stind.i4
L_001e: ret

// static void test2(Object o, ref int y)
// {
//     if (o is int x)
//         y = x;
// }

[0] int32 x,
[1] object obj2
        ldarg.0
        stloc.1
        ldloc.1
        isinst int32
        ldnull
        cgt.un
        dup
        brtrue.s L_0011
        ldc.i4.0
        br.s L_0017
L_0011: ldloc.1
        unbox.any int32
L_0017: stloc.0
        brfalse.s L_001d
        ldarg.1
        ldloc.0
        stind.i4
L_001d: ret

Für weitere Tests, die meine Bemerkung über die Leistung des neuen Systems untermauern C#7 Syntax, die über die bisher verfügbaren Optionen hinausgeht, siehe aquí (insbesondere das Beispiel "D").

CodeJaeger.com

CodeJaeger ist eine Gemeinschaft für Programmierer, die täglich Hilfe erhalten..
Wir haben viele Inhalte, und Sie können auch Ihre eigenen Fragen stellen oder die Fragen anderer Leute lösen.

Powered by:

X