ist der Teil von a Reihe von Beiträgen zum Thema Analyse und Optimierung von PyTorch-Modellen. Während der gesamten Serie haben wir uns für die Verwendung von ausgesprochen PyTorch-Profiler in der Entwicklung von KI-Modellen und demonstrierte die potenziellen Auswirkungen der Leistungsoptimierung auf die Geschwindigkeit und die Kosten der Ausführung von KI/ML-Workloads. Ein häufiges Phänomen, das wir beobachtet haben, ist, wie scheinbar harmloser Code die Laufzeitleistung beeinträchtigen kann. In diesem Beitrag untersuchen wir einige der Nachteile, die mit der naiven Verwendung von Tensoren mit variabler Kind verbunden sind – Tensoren, deren Kind von vorherigen Berechnungen und/oder Eingaben abhängt. Obwohl dies nicht auf alle Situationen anwendbar ist, gibt es Zeiten, in denen die Verwendung von Tensoren mit variabler Kind vermieden werden kann – obwohl dies möglicherweise auf Kosten zusätzlicher Rechenleistung und/oder Speicher geht. Wir werden die Kompromisse dieser Alternativen anhand einer Spielzeugimplementierung der Datenerfassung in PyTorch demonstrieren.

Drei Nachteile variabel geformter Tensoren

Wir motivieren die Diskussion, indem wir drei Nachteile der Verwendung von Tensoren mit variabler Kind aufzeigen:

Host-Gerät-Synchronisierungsereignisse

In einem idealen Szenario können CPU und GPU asynchron parallel laufen, wobei die CPU die GPU kontinuierlich mit Eingabebeispielen versorgt, den erforderlichen GPU-Speicher zuweist und GPU-Rechenkerne lädt und die GPU die geladenen Kernel auf den bereitgestellten Eingaben unter Verwendung des zugewiesenen Speichers ausführt. Das Vorhandensein dynamisch geformter Tensoren macht dieser Parallelität einen Strich durch die Rechnung. Um die entsprechende Speichermenge zuzuweisen, muss die CPU darauf warten, dass die GPU die Kind des Tensors meldet, und dann muss die GPU darauf warten, dass die CPU den Speicher zuweist und mit dem Laden des Kernels fortfährt. Der Overhead dieses Synchronisierungsereignisses kann zu einem Rückgang der GPU-Auslastung und einer langsameren Laufzeitleistung führen.

Ein Beispiel dafür haben wir gesehen Teil drei Teil dieser Serie, als wir eine naive Umsetzung des Gemeinsamen untersuchten Kreuzentropieverlust Dazu gehörten Anrufe an Torch.nonzero Und Taschenlampe.einzigartig. Beide APIs geben Tensoren mit Formen zurück, die dynamisch und vom Inhalt der Eingabe abhängig sind. Wenn diese Funktionen auf der GPU ausgeführt werden, tritt ein Host-Gerät-Synchronisierungsereignis auf. Im Fall der Kreuzentropieverlusthaben wir die Ineffizienz durch die Verwendung von entdeckt PyTorch-Profiler und konnten es leicht mit einer alternativen Implementierung überwinden, die die Verwendung von Tensoren mit variabler Kind vermeidet und eine viel bessere Laufzeitleistung zeigte.

Diagrammerstellung

In einem letzter Beitrag Wir haben die Leistungsvorteile der Bewerbung untersucht just-in-time (JIT)-Kompilierung mit der Torch.compile Operator. Eine unserer Beobachtungen warfare, dass die Diagrammerstellung viel bessere Ergebnisse lieferte, wenn das Diagramm statisch warfare. Das Vorhandensein dynamischer Formen im Diagramm schränkt den Umfang der Optimierung durch Kompilierung ein: In einigen Fällen schlägt sie vollständig fehl; in anderen führt es zu geringeren Leistungssteigerungen. Die gleichen Implikationen gelten auch für andere Formen der Diagrammerstellung, wie z XLA, ONNX, OpenVINOUnd TensorRT.

Datenstapelung

Eine weitere Optimierung, die uns in mehreren unserer Beiträge begegnet ist (z. B. Hier) ist Probenbatchierung. Die Stapelverarbeitung verbessert die Leistung hauptsächlich auf zwei Arten:

  1. Reduzierung des Overheads beim Laden des Kernels: Anstatt die für die Berechnungspipeline erforderlichen GPU-Kernel einmal professional Eingabebeispiel zu laden, kann die CPU die Kernel einmal professional Stapel laden.
  2. Maximierung der Parallelisierung über Recheneinheiten hinweg: GPUs sind hochparallele Rechenmaschinen. Je mehr wir die Berechnungen parallelisieren können, desto stärker können wir die GPU auslasten und ihre Auslastung steigern. Durch die Stapelverarbeitung können wir den Grad der Parallelisierung potenziell um den Faktor der Stapelgröße erhöhen.

Trotz ihrer Nachteile ist die Verwendung von Tensoren mit variabler Kind oft unvermeidbar. Aber manchmal können wir unsere Modellimplementierung ändern, um sie zu umgehen. Manchmal sind diese Änderungen unkompliziert (wie im Beispiel des Kreuzentropieverlusts). In anderen Fällen ist möglicherweise etwas Kreativität erforderlich, um eine andere Sequenz von PyTorch-APIs mit fester Kind zu entwickeln, die das gleiche numerische Ergebnis liefern. Oftmals kann dieser Aufwand zu erheblichen Vorteilen bei Laufzeit und Kosten führen.

In den nächsten Abschnitten werden wir die Verwendung von variablen Tensoren im Kontext der Datenabtastoperation untersuchen. Wir beginnen mit einer trivialen Implementierung und analysieren deren Leistung. Wir werden dann eine GPU-freundliche Different vorschlagen, die die Verwendung von Tensoren mit variabler Kind vermeidet.

Um unsere Implementierungen zu vergleichen, verwenden wir eine Amazon EC2 g6e.xlarge mit einem NVIDIA L40S Ausführen eines AWS Deep Studying AMI (DLAMI) mit PyTorch (2.8). Der Code, den wir teilen, ist für Demonstrationszwecke gedacht. Bitte verlassen Sie sich nicht auf die Genauigkeit oder Optimalität. Bitte interpretieren Sie unsere Erwähnung eines Frameworks, einer Bibliothek oder einer Plattform nicht als Befürwortung ihrer Verwendung.

Stichprobenerhebung in KI-Modell-Workloads

Im Kontext dieses Beitrags bezieht sich Stichprobe auf die Auswahl einer Teilmenge von Elementen aus einer großen Menge von Kandidaten zum Zwecke der Recheneffizienz, des Ausgleichs von Datentypen oder der Regularisierung. Stichproben sind in vielen KI/ML-Modellen üblich, beispielsweise in Erkennungs-, Rating- und kontrastiven Lernsystemen.

Wir definieren eine einfache Variation des Stichprobenproblems: Gegeben sei eine Liste von N Tensoren mit jeweils einer binären Bezeichnung werden wir gebeten, eine Teilmenge davon zurückzugeben Ok Tensoren, die sowohl constructive als auch adverse Beispiele in zufälliger Reihenfolge enthalten. Wenn die Eingabeliste genügend Beispiele für jedes Etikett enthält (Ok/2), sollte die zurückgegebene Teilmenge gleichmäßig aufgeteilt sein. Fehlen Stichproben einer Artwork, sollten diese durch Stichproben der zweiten Artwork aufgefüllt werden.

Der folgende Codeblock enthält eine PyTorch-Implementierung unserer Sampling-Funktion. Die Umsetzung orientiert sich am Populären Detectron2 Bibliothek (z. B. siehe Hier Und Hier). Für die Experimente in diesem Beitrag legen wir das Stichprobenverhältnis fest 1:10.

import torch

INPUT_SAMPLES = 10000
SUB_SAMPLE = INPUT_SAMPLES // 10
FEATURE_DIM = 16

def sample_data(input_array, labels):
    gadget = labels.gadget
    constructive = torch.nonzero(labels == 1, as_tuple=True)(0)
    adverse = torch.nonzero(labels == 0, as_tuple=True)(0)
    num_pos = min(constructive.numel(), SUB_SAMPLE//2)
    num_neg = min(adverse.numel(), SUB_SAMPLE//2)
    if num_neg < SUB_SAMPLE//2:
        num_pos = SUB_SAMPLE - num_neg
    elif num_pos < SUB_SAMPLE//2:
        num_neg = SUB_SAMPLE - num_pos

    # randomly choose constructive and adverse examples
    perm1 = torch.randperm(constructive.numel(), gadget=gadget)(:num_pos)
    perm2 = torch.randperm(adverse.numel(), gadget=gadget)(:num_neg)

    pos_idxs = constructive(perm1)
    neg_idxs = adverse(perm2)

    sampled_idxs = torch.cat((pos_idxs, neg_idxs), dim=0)
    rand_perm = torch.randperm(SUB_SAMPLE, gadget=labels.gadget)
    sampled_idxs = sampled_idxs(rand_perm)
    return input_array(sampled_idxs), labels(sampled_idxs)

Leistungsanalyse mit PyTorch Profiler

Auch wenn die Verwendung dynamischer Formen nicht sofort offensichtlich ist, ist sie in der PyTorch Profiler Hint-Ansicht leicht zu erkennen. Wir verwenden die folgende Funktion, um PyTorch Profiler zu aktivieren:

def profile(fn, enter, labels):
    
    def export_trace(p):
        p.export_chrome_trace(f"{fn.__name__}.json")
        
    with torch.profiler.profile(
            actions=(torch.profiler.ProfilerActivity.CPU,
                        torch.profiler.ProfilerActivity.CUDA),
            with_stack=True,
            schedule=torch.profiler.schedule(wait=0, warmup=10, lively=5),
            on_trace_ready=export_trace
    ) as prof:
        for _ in vary(20):
            fn(enter, labels)
            torch.cuda.synchronize()  # specific sync for hint readability
            prof.step()

# create random enter
input_samples = torch.randn((INPUT_SAMPLES, FEATURE_DIM), gadget='cuda')
labels = torch.randint(0, 2, (INPUT_SAMPLES,), 
                       gadget='cuda', dtype=torch.int64)

# run with profiler
profile(sample_data, input_samples, labels)

Das Bild unten wurde für den Wert von zehn Millionen Eingabeproben aufgenommen. Es zeigt deutlich das Vorhandensein von Synchronisierungsereignissen, die vom Torch.nonzero-Aufruf ausgehen, sowie die entsprechenden Rückgänge bei der GPU-Auslastung:

Profiler Hint of Sampler (vom Autor)

Die Verwendung von Torch.nonzero in unserer Implementierung ist nicht supreme, aber kann sie vermieden werden?

Ein GPU-freundlicher Datensampler

Wir schlagen eine various Implementierung unserer Sampling-Funktion vor, die die dynamische Funktion Torch.nonzero durch eine kreative Kombination der Statik ersetzt Torch.count_nonzero, Torch.topkund andere APIs:

def opt_sample_data(enter, labels):
    pos_mask = labels == 1
    neg_mask = labels == 0
    num_pos_idxs = torch.count_nonzero(pos_mask, dim=-1)
    num_neg_idxs = torch.count_nonzero(neg_mask, dim=-1)
    half_samples = labels.new_full((), SUB_SAMPLE // 2)
    num_pos = torch.minimal(num_pos_idxs, half_samples)
    num_neg = torch.minimal(num_neg_idxs, half_samples)
    num_pos = torch.the place(
        num_neg < SUB_SAMPLE // 2,
        SUB_SAMPLE - num_neg,
        num_pos
    )
    num_neg = SUB_SAMPLE - num_pos

    # create random ordering on pos and neg entries
    rand = torch.rand_like(labels, dtype=torch.float32)
    pos_rand = torch.the place(pos_mask, rand, -1)
    neg_rand = torch.the place(neg_mask, rand, -1)

    # choose prime pos entries and invalidate others
    # since CPU would not know num_pos, we assume most to keep away from sync
    top_pos_rand, top_pos_idx = torch.topk(pos_rand, okay=SUB_SAMPLE)
    arange = torch.arange(SUB_SAMPLE, gadget=labels.gadget)
    if num_pos.numel() > 1:
        # unsqueeze to help batched enter
        arange = arange.unsqueeze(0)
        num_pos = num_pos.unsqueeze(-1)
        num_neg = num_neg.unsqueeze(-1)
    top_pos_rand = torch.the place(arange >= num_pos, -1, top_pos_rand)

    # repeat for neg entries
    top_neg_rand, top_neg_idx = torch.topk(neg_rand, okay=SUB_SAMPLE)
    top_neg_rand = torch.the place(arange >= num_neg, -1, top_neg_rand)

    # mix and blend collectively constructive and adverse idxs
    cat_rand = torch.cat((top_pos_rand, top_neg_rand), dim=-1)
    cat_idx = torch.cat((top_pos_idx, top_neg_idx), dim=-1)
    topk_rand_idx = torch.topk(cat_rand, okay=SUB_SAMPLE)(1)
    sampled_idxs = torch.collect(cat_idx, dim=-1, index=topk_rand_idx)
    sampled_input = torch.collect(enter, dim=-2, 
                                 index=sampled_idxs.unsqueeze(-1))
    sampled_labels = torch.collect(labels, dim=-1, index=sampled_idxs)
    return sampled_input, sampled_labels

Offensichtlich erfordert diese Funktion mehr Speicher und mehr Operationen als unsere erste Implementierung. Die Frage ist: Überwiegen die Leistungsvorteile einer statischen, synchronisierungsfreien Implementierung die zusätzlichen Kosten für Speicher und Rechenleistung?

Um die Kompromisse zwischen den beiden Implementierungen zu bewerten, führen wir das folgende Benchmarking-Dienstprogramm ein:

def benchmark(fn, enter, labels):
    # warm-up
    for _ in vary(20):
        _ = fn(enter, labels)

    iters = 100
    begin = torch.cuda.Occasion(enable_timing=True)
    finish = torch.cuda.Occasion(enable_timing=True)
    torch.cuda.synchronize()
    begin.document()
    for _ in vary(iters):
        _ = fn(enter, labels)
    finish.document()
    torch.cuda.synchronize()
    avg_time = begin.elapsed_time(finish) / iters
    
    print(f"{fn.__name__} common step time: {(avg_time):.4f} ms")

benchmark(sample_data, input_samples, labels)
benchmark(opt_sample_data, input_samples, labels)

Die folgende Tabelle vergleicht die durchschnittliche Laufzeit jeder Implementierung für verschiedene Eingabestichprobengrößen:

Vergleichende Schrittzeitleistung – Je niedriger, desto besser (vom Autor)

Bei den meisten Eingabestichprobengrößen ist der Overhead des Host-Gerät-Synchronisierungsereignisses entweder vergleichbar oder geringer als der zusätzliche Rechenaufwand der statischen Implementierung. Enttäuschenderweise sehen wir erst dann einen großen Nutzen aus der synchronisierungsfreien Different, wenn die Eingabestichprobengröße zehn Millionen erreicht. Derart große Stichprobengrößen sind in KI/ML-Umgebungen ungewöhnlich. Aber es ist nicht unsere Tendenz, so schnell aufzugeben. Wie oben erwähnt, ermöglicht die statische Implementierung andere Optimierungen wie die Kompilierung von Diagrammen und die Stapelverarbeitung von Eingaben.

Diagrammerstellung

Im Gegensatz zur ursprünglichen Funktion – die nicht kompiliert werden kann – ist unsere statische Implementierung vollständig kompatibel mit Torch.compile:

benchmark(torch.compile(opt_sample_data), input_samples, labels)

Die folgende Tabelle enthält die Laufzeiten unserer kompilierten Funktion:

Vergleichende Schrittzeitleistung – Je niedriger, desto besser (vom Autor)

Die Ergebnisse sind deutlich besser und bieten eine Steigerung von 70 bis 75 Prozent gegenüber der ursprünglichen Sampler-Implementierung im Bereich von 1 bis 10.000. Aber wir haben noch eine weitere Optimierung in petto.

Maximierung der Leistung mit Batch-Enter

Da die ursprüngliche Implementierung variablenförmige Operationen enthält, kann sie Batch-Eingaben nicht direkt verarbeiten. Um einen Stapel zu verarbeiten, haben wir keine andere Wahl, als ihn in einer Python-Schleife auf jede Eingabe einzeln anzuwenden:

BATCH_SIZE = 32

def batched_sample_data(inputs, labels):
    sampled_inputs = ()
    sampled_labels = ()
    for i in vary(inputs.dimension(0)):
        inp, lab = sample_data(inputs(i), labels(i))
        sampled_inputs.append(inp)
        sampled_labels.append(lab)
    return torch.stack(sampled_inputs), torch.stack(sampled_labels)

Im Gegensatz dazu unterstützt unsere optimierte Funktion Batch-Eingaben unverändert – es sind keine Änderungen erforderlich.

input_batch = torch.randn((BATCH_SIZE, INPUT_SAMPLES, FEATURE_DIM),
                          gadget='cuda')
labels = torch.randint(0, 2, (BATCH_SIZE, INPUT_SAMPLES),
                       gadget='cuda', dtype=torch.int64)

benchmark(batched_sample_data, input_batch, labels)
benchmark(opt_sample_data, input_batch, labels)
benchmark(torch.compile(opt_sample_data), input_batch, labels)

Die folgende Tabelle vergleicht die Schrittzeiten unserer Stichprobenfunktionen bei einer Chargengröße von 32:

Schrittzeitleistung bei Batch-Enter – Je niedriger, desto besser (vom Autor)

Jetzt sind die Ergebnisse endgültig: Durch die Verwendung einer statischen Implementierung des Datensamplers können wir die Leistung je nach Eingabestichprobengröße um das 2- bis 52-fache (!!) der variablenförmigen Choice steigern.

Beachten Sie, dass unsere Experimente zwar auf einem GPU-Gerät ausgeführt wurden, die Modellkompilierungs- und Eingabe-Batching-Optimierungen jedoch auch für eine CPU-Umgebung gelten. Daher könnte die Vermeidung variabler Formen auch Auswirkungen auf die Leistung des AI/ML-Modells auf der CPU haben.

Zusammenfassung

Der Optimierungsprozess, den wir in diesem Beitrag demonstriert haben, verallgemeinert sich über den speziellen Fall der Datenstichprobe hinaus:

  • Entdeckung über Leistungsprofilierung: Mit der PyTorch-Profiler Wir konnten einen Rückgang der GPU-Auslastung identifizieren und ihre Ursache ermitteln: das Vorhandensein von Tensoren mit variabler Kind, die aus der Operation „torch.nonzero“ resultieren.
  • Eine various Implementierung: Unsere Profilierungsergebnisse ermöglichten es uns, eine various Implementierung zu entwickeln, die das gleiche Ziel erreichte und gleichzeitig die Verwendung von Tensoren mit variabler Kind vermeidete. Dieser Schritt ging jedoch mit einem zusätzlichen Rechen- und Speicheraufwand einher. Wie in unseren ersten Benchmarks zu sehen warfare, zeigte die synchronisierungsfreie Different bei gängigen Eingabegrößen eine schlechtere Leistung.
  • Weiteres Optimierungspotenzial erschließen: Der wahre Durchbruch gelang, weil die statisch geformte Implementierung kompilierungsfreundlich warfare und Batchverarbeitung unterstützte. Diese Optimierungen sorgten für Leistungssteigerungen, die den anfänglichen Overhead in den Schatten stellten und zu einer 2- bis 52-fachen Beschleunigung gegenüber der ursprünglichen Implementierung führten.

Natürlich enden nicht alle Geschichten so glücklich wie unsere. In vielen Fällen stoßen wir möglicherweise auf PyTorch-Code, der auf der GPU eine schlechte Leistung erbringt, aber über keine various Implementierung verfügt, oder über eine, die deutlich mehr Rechenressourcen erfordert. Angesichts des Potenzials für erhebliche Leistungssteigerungen und Kostensenkungen ist der Prozess der Identifizierung von Laufzeitineffizienzen und der Erforschung alternativer Implementierungen jedoch ein wesentlicher Bestandteil der KI/ML-Entwicklung.

Von admin

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert