MarkTechPostVeröffentlichung der Quelle

NVIDIA Nsight Python Tutorial: So profilet man GPU-Kernel

In diesem Tutorial implementieren wir auf Nsight Python, NVIDIAs Open-Source-Python-Schnittstelle zu seinen Kernel-Profilern.

In diesem Tutorial implementieren wir auf Nsight Python, NVIDIAs Open-Source-Python-Schnittstelle zu seinen Kernel-Profilern. Statt ein Skript unter der Nsight Compute-Kommandozeile auszuführen und anschließend einen Report zu durchforsten, markieren wir den Code, der uns interessiert, mit nsight.annotate, dekorieren eine Benchmark-Funktion mit nsight.analyze.kernel und erhalten einen pandas-DataFrame mit Hardware-Metriken pro annotierter Region, pro Konfiguration und pro Lauf. Seit Version 1.0 hängt die Bibliothek Nsight Compute an den laufenden Python-Prozess an, was sie in Jupyter und Colab nutzbar macht, und das kommende Release 1.1 fügt ein experimentelles CUPTI-Backend, explizites Tool-Management und benutzerdefinierte Informationssammler hinzu. Wir führen alles auf einem kostenlosen Colab T4 aus: Wir installieren eine aktuelle Nsight Compute neben der Version, die Colab mitbringt, profilieren einen ersten Kernel, entscheiden, wie viele Läufe eine Messung benötigt, messen, was Cache- und Taktsteuerung mit demselben Kernel anstellen, variieren Matrixgrößen in fp32 und fp16 mit einer abgeleiteten TFLOP/s-Metrik, nutzen Hardware-Zähler, um compute-gebundene von memory-gebundenen Aufgaben zu unterscheiden, messen Kernel-Fusion in bewegten Bytes, zeichnen Takte und Temperatur pro Lauf auf, vergleichen das CUPTI-Backend mit Nsight Compute und bewahren den Roh-Report für eine tiefere Analyse auf.

Code kopieren
import os
import sys
import glob
import json
import time
import shutil
import textwrap
import traceback
import subprocess
 
RESULTS = {}
 
 
def banner(title):
    print("\n" + "=" * 78)
    print(title)
    print("=" * 78)
 
 
def section(name):
    def wrap(fn):
        def run(*a, **kw):
            banner(name)
            try:
                out = fn(*a, **kw)
                RESULTS[name] = out if isinstance(out, str) else "ok"
                return out
            except Exception as e:
                RESULTS[name] = f"SKIPPED / FAILED -> {type(e).__name__}: {str(e).splitlines()[0][:160]}"
                print(f"\n[!] {name} did not complete: {type(e).__name__}: {e}")
                traceback.print_exc(limit=3)
                return None
        return run
    return wrap
 
 
def sh(cmd):
    """Run a shell command and return its stdout, stripped."""
    return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout.strip()
 
 
banner("1. Install a current Nsight Compute next to Colab's, then import nsight before CUDA")
print(f"  GPU: {sh('nvidia-smi --query-gpu=name,driver_version,memory.total,compute_cap --format=csv,noheader')}")
preinstalled = shutil.which("ncu")
print(f"  ncu already on PATH: {preinstalled} -> {sh('ncu --version | tail -1') if preinstalled else 'none'}")
 
# nsight-python needs Nsight Compute 2026.2.1 or newer. Colab ships an older ncu, but its image already
# carries NVIDIA's CUDA apt repository, so a current release is one apt-get away. It installs into
# /opt/nvidia/nsight-compute/<version>/ and leaves the preinstalled one untouched.
subprocess.run("apt-get update -qq > /tmp/ncu_install.log 2>&1", shell=True)
for NCU_VERSION in ("2026.3.1", "2026.2.1"):
    if subprocess.run(f"apt-get install -y -qq nsight-compute-{NCU_VERSION} >> /tmp/ncu_install.log 2>&1",
                      shell=True).returncode == 0:
        break
else:
    print(sh("tail -5 /tmp/ncu_install.log"))
    raise RuntimeError("could not install Nsight Compute 2026.2.1+ from NVIDIA's apt repository")
# ask dpkg where the package put the ncu launcher rather than assuming /opt/nvidia/nsight-compute/<version>/
ncu_paths = sh(f"dpkg -L nsight-compute-{NCU_VERSION} | grep -E '/ncu$'").split()
if not ncu_paths:
    raise RuntimeError(f"dpkg lists no ncu executable in nsight-compute-{NCU_VERSION}")
NCU_DIR = os.path.dirname(min(ncu_paths, key=len))  # the top-level launcher, not a per-platform copy
os.environ["PATH"] = NCU_DIR + os.pathsep + os.environ["PATH"]  # nsight-python looks ncu up on PATH
print(f"  installed: {shutil.which('ncu')} -> {sh('ncu --version | tail -1')}")
 
# nsight-python 1.1.0 (CUPTI backend, tool APIs, info collectors) is tagged but not yet on PyPI;
# pin the commit this notebook was written against. The [cupti] extra is used in step 9.
NSIGHT_COMMIT = "bb96db4260b1823d86ecab9acfed0646d7af79ad"
subprocess.run([sys.executable, "-m", "pip", "install", "-q",
                f"nsight-python @ git+https://github.com/NVIDIA/nsight-python@{NSIGHT_COMMIT}"], check=True)
# The CUPTI backend needs cupti-python 13.4, which wants a newer libcupti than Colab's torch is pinned to.
# Install it into a separate directory that only step 9's child process puts on its path.
CUPTI_DIR = "/content/cupti_env"
CUPTI_EXTRA = subprocess.run([sys.executable, "-m", "pip", "install", "-q", "--target", CUPTI_DIR, "--no-deps",
                              "cupti-python>=13.4.0", "nvidia-cuda-cupti~=13.4", "pyelftools"]).returncode == 0
 
import nsight  # activates Nsight Compute's injection library: must come before anything initializes CUDA
from nsight.collection import ncu as ncu_backend
 
import numpy as np
import pandas as pd
import torch
 
SILENT = nsight.VerbosityLevel.SILENT
pd.set_option("display.width", 200)
pd.set_option("display.max_columns", 30)
print(f"  nsight-python {nsight.__version__} | active tool: {nsight.get_active_tool()}"
      f" | injection error at import: {ncu_backend.injection_load_error}")
print(f"  torch {torch.__version__} (CUDA {torch.version.cuda}) | {torch.cuda.get_device_name(0)}"
      f" | cupti-python installed: {CUPTI_EXTRA}")
print("\n  Order matters: `import nsight` loads Nsight Compute into this Python process, and it can only")
print("  attach to CUDA work started after that. Every profiled function below runs in this same")
print("  process; nsight-python starts `ncu --mode attach` on its own PID for each one.")

Wir beginnen damit, die GPU und den Profiler zu prüfen. Nsight Python benötigt das Nsight Compute-Kommandozeilen-Tool ncu in Version 2026.2.1 oder höher und findet es, indem es auf dem PATH danach sucht. Colabs GPU-Image bringt eine ältere Nsight Compute mit. Es hat jedoch bereits NVIDIAs CUDA-Paket-Repository konfiguriert, daher installieren wir Nsight Compute 2026.3.1 mit apt-get, fragen dpkg, wo der ncu-Starter gelandet ist, und setzen dieses Verzeichnis an den Anfang des PATH. Anschließend installieren wir Nsight Python, festgesetzt auf den Commit des 1.1.0-Pre-Releases, gegen das dieses Notebook geschrieben wurde, sowie das Paket cupti-python in ein separates Verzeichnis, weil es eine neuere CUPTI-Bibliothek benötigt als die, auf die Colabs PyTorch festgesetzt ist. Die Importreihenfolge ist wichtig: import nsight lädt Nsight Computes Injection-Bibliothek in den Python-Prozess, und sie kann sich nur an CUDA-Arbeit anhängen, die danach gestartet wird, daher muss nsight importiert werden, bevor torch die GPU berührt.

Code kopieren
ADD = lambda x, y: x + y   # sums a metric if cuBLAS splits one matmul into several kernels
 
 
@nsight.analyze.kernel(thermal_mode="off", combine_kernel_metrics=ADD)
def first_matmul(n: int) -> None:
    a = torch.randn(n, n, device="cuda")
    b = torch.randn(n, n, device="cuda")
    with nsight.annotate("matmul"):
        _ = a @ b
 
 
@section("2. Profile one kernel: what a ProfileResults row contains")
def first_profile():
    df = first_matmul(2048).to_dataframe()
    print(f"\n  to_dataframe() columns: {list(df.columns)}\n")
    r = df.iloc[0]
    t = float(r["AvgValue"])
    print(f"  annotation {r['Annotation']!r}, n = {r['n']}, kernel {r['Kernel']}")
    print(f"  {r['Metric']} = {t:,.0f} {r['Unit']}  (runs {r['NumRuns']}, stable: {r['StableMeasurement']})")
    print(f"  GPU {r['GPU']}, ComputeClock {r['ComputeClock']}, MemoryClock {r['MemoryClock']}")
    print(f"  -> {2 * 2048 ** 3 / (t * 1e-9) / 1e12:.2f} TFLOP/s for a 2048 x 2048 fp32 matmul")
    print("\n  Only the kernel launched inside nsight.annotate() is profiled; torch.randn above it is not.")
    print("  With a single run, StableMeasurement is <NA>: one sample has no spread to judge.")
    return f"fp32 2048 matmul {t / 1e6:.3f} ms ({2 * 2048 ** 3 / (t * 1e-9) / 1e12:.2f} TFLOP/s)"
 
 
first_profile()

Das erste Profiling ist eine einzelne fp32-Matrixmultiplikation. Die Benchmark-Funktion erzeugt ihre Eingaben und umschließt dann nur die Multiplikation mit nsight.annotate, sodass die Zufallszahlen-Kernel, die die Eingaben aufbauen, nicht gemessen werden. Der Aufruf der dekorierten Funktion startet ncu im Attach-Modus auf dem eigenen Prozess des Notebooks, führt die Funktion aus und gibt ein ProfileResults-Objekt zurück, dessen Methode to_dataframe eine Zeile pro Annotation, Konfiguration und Metrik liefert. Wir geben die Spalten aus und lesen die Zeile: den Kernel-Namen, die gemessene gpu__time_duration.sum in Nanosekunden mit ihrer Einheit, die Anzahl der Läufe, den GPU-Namen und die Taktattribute, und wir rechnen die Zeit von Hand in TFLOP/s um. Das Argument combine_kernel_metrics summiert die Zeit, falls cuBLAS eine Multiplikation in mehr als einen Kernel aufteilt; bei einem einzelnen Lauf ist das Flag StableMeasurement undefiniert, weil eine einzelne Stichprobe keine Streuung hat.

Code kopieren
def show(df, cols, scale=None, fmt="{:,.2f}"):
    out = df[cols].copy()
    for c, f in (scale or {}).items():
        out[c] = out[c] * f
    print(textwrap.indent(out.to_string(index=False, float_format=lambda v: fmt.format(v)), "  "))
 
 
@nsight.analyze.kernel(configs=[1 << 14, 1 << 24], runs=20, thermal_mode="off", verbosity=SILENT,
                       combine_kernel_metrics=ADD)
def noisy_or_not(n: int) -> None:
    x = torch.randn(n, device="cuda")
    y = torch.randn(n, device="cuda")
    a = torch.randn(2048, 2048, device="cuda")
    with nsight.annotate("vector add"):
        _ = x + y
    with nsight.annotate("matmul 2048"):
        _ = a @ a
 
 
@section("3. How many runs? Spread, confidence intervals and the stability flag")
def stability():
    df = noisy_or_not().to_dataframe()
    print("  20 runs per configuration; times in microseconds:\n")
    show(df, ["Annotation", "n", "AvgValue", "StdDev", "CI95_Lower", "CI95_Upper", "RelativeStdDevPct",
              "StableMeasurement"], scale={"AvgValue": 1e-3, "StdDev": 1e-3, "CI95_Lower": 1e-3, "CI95_Upper": 1e-3})
    tiny = df[(df.Annotation == "vector add") & (df.n == 1 << 14)].iloc[0]
    print(f"\n  StableMeasurement is simply RelativeStdDevPct < 2. The smallest kernel ({tiny['AvgValue'] / 1e3:.1f} us)"
          f" varies by {tiny['RelativeStdDevPct']:.1f}%:")
    print("  at microsecond scale, launch and clock jitter are a large share of the time. Judge small kernels")
    print("  with more runs, and compare their confidence intervals rather than single averages.")
    return f"16K-element add RSD {tiny['RelativeStdDevPct']:.1f}%; " + \
        f"2048 matmul RSD {df[df.Annotation == 'matmul 2048'].RelativeStdDevPct.max():.1f}%"
 
 
stability()

Eine einzelne Messung sagt nichts über Rauschen aus, daher führt der nächste Schritt jede Konfiguration zwanzig Mal aus. Der DataFrame trägt dann den Mittelwert, die Standardabweichung, ein 95-Prozent-Konfidenzintervall, die relative Standardabweichung in Prozent und ein StableMeasurement-Flag, das im Quellcode schlicht eine relative Standardabweichung unter zwei Prozent ist. Wir profilieren Vektoraddition in zwei Größen, eine mit sechzehntausend Elementen, die in Mikrosekunden läuft, und eine mit sechzehn Millionen Elementen, sowie eine 2048 mal 2048 Matrixmultiplikation, und vergleichen, wie stark jede variiert. Die praktische Regel lautet, kurze Kernel öfter laufen zu lassen und Konfidenzintervalle statt einzelner Mittelwerte zu vergleichen.

Code kopieren
def make_scale_bench(cache_control, clock_control):
    @nsight.analyze.kernel(configs=[1 << 18, 1 << 24], runs=10, cache_control=cache_control,
                           clock_control=clock_control, thermal_mode="off", verbosity=SILENT,
                           metrics=["gpu__time_duration.sum", "gpc__cycles_elapsed.avg.per_second"])
    def scale(n: int) -> None:
        x = torch.randn(n, device="cuda")
        with nsight.annotate("x * 2"):
            _ = x * 2.0
    return scale
 
 
@section("4. Cache and clock control: two defaults that change the answer")
def cache_and_clocks():
    rows = []
    for cache in ("all", "none"):
        for clock in ("none", "base"):
            try:
                df = make_scale_bench(cache, clock)().to_dataframe()
            except Exception as e:
                print(f"  cache_control={cache!r}, clock_control={clock!r}: failed -> {type(e).__name__}: {str(e)[:120]}")
                continue
            t = df[df.Metric == "gpu__time_duration.sum"].set_index("n").AvgValue
            hz = df[df.Metric == "gpc__cycles_elapsed.avg.per_second"].set_index("n").AvgValue
            for n in t.index:
                rows.append({"cache_control": cache, "clock_control": clock, "MB": 4 * n / 2 ** 20,
                             "time_us": t[n] / 1e3, "GB/s": 2 * 4 * n / t[n], "SM_MHz": hz[n] / 1e6})
    tab = pd.DataFrame(rows)
    print(textwrap.indent(tab.to_string(index=False, float_format=lambda v: f"{v:,.1f}"), "  "))
    print("\n  The T4's L2 cache is 4 MB. A 1 MB input stays in L2 between runs unless caches are flushed,")
    print("  so cache_control='none' measures a warm-cache kernel and 'all' (the default) a cold one.")
    print("  clock_control='base' locks the SM clock to base frequency for every replay (the plain ncu CLI")
    print("  default); nsight-python defaults to 'none', so the GPU boosts. SM_MHz is measured per kernel")
    print("  (gpc__cycles_elapsed.avg.per_second). The ComputeClock column is not: it is the device's")
    print("  rated clock attribute and stays the same whatever the kernel ran at.")
    globals()["CLOCK_TABLE"] = tab
    small = tab[tab.MB < 2].set_index(["cache_control", "clock_control"]).time_us
    return (f"1 MB scale: cold/boost {small.get(('all', 'none'), float('nan')):.1f} us, "
            f"warm/boost {small.get(('none', 'none'), float('nan')):.1f} us, "
            f"cold/base {small.get(('all', 'base'), float('nan')):.1f} us")
 
 
cache_and_clocks()

Zwei Profiler-Standardeinstellungen bestimmen, was eine Messung bedeutet, und dieser Schritt misst beide an demselben Kernel: die Skalierung eines Vektors mit zwei. cache_control entscheidet, ob die Caches vor jedem Kernel-Replay geleert werden: Nsight Python leert sie standardmäßig, sodass eine kleine Eingabe, die sonst im 4-MB-L2-Cache der T4 bleiben würde, jedes Mal aus dem Speicher gelesen wird. clock_control entscheidet, ob die GPU-Takte gesperrt werden: Nsight Python lässt sie standardmäßig ungesperrt, während die nackte ncu-Kommandozeile sie auf Basistakte sperrt. Wir führen alle vier Kombinationen bei 1 MB und 64 MB aus und sammeln zusätzlich gpc__cycles_elapsed.avg.per_second, das den Takt misst, mit dem der Kernel tatsächlich lief. Das ist wichtig, denn wie der Quellcode zeigt, ist die Spalte ComputeClock im DataFrame das Nenntakt-Attribut des Geräts und keine Messung.

Code kopieren
SIZES = [(512,), (1024,), (2048,), (4096,)]
 
 
def tflops(time_ns: float, n: int) -> tuple[float, str]:
    return 2 * n ** 3 / (time_ns * 1e-9) / 1e12, "TFLOP/s"
 
 
def make_precision_body():
    def precision(n: int) -> None:   # a fresh function object for each decorator
        a = torch.randn(n, n, device="cuda")
        b = torch.randn(n, n, device="cuda")
        a16, b16 = a.half(), b.half()
        with nsight.annotate("fp32"):
            _ = a @ b
        with nsight.annotate("fp16"):
            _ = a16 @ b16
    return precision
 
 
precision_tflops = nsight.analyze.kernel(configs=SIZES, runs=5, derive_metric=tflops, thermal_mode="off",
                                         verbosity=SILENT, combine_kernel_metrics=ADD)(make_precision_body())
precision_ratio = nsight.analyze.kernel(configs=SIZES, runs=5, normalize_against="fp32", thermal_mode="off",
                                        verbosity=SILENT, combine_kernel_metrics=ADD)(make_precision_body())
 
 
@section("5. A size sweep, a derived TFLOP/s metric, and normalisation")
def precision_sweep():
    df = precision_tflops().to_dataframe()
    tf = df[df.Metric == "tflops"].pivot(index="n", columns="Annotation", values="AvgValue")
    print("  derive_metric=tflops receives (gpu__time_duration.sum, n) and returns (value, 'TFLOP/s'):\n")
    print(textwrap.indent(tf[["fp32", "fp16"]].to_string(float_format=lambda v: f"{v:,.2f}"), "  "))
    print("\n  T4 datasheet peaks: 8.1 TFLOP/s fp32, 65 TFLOP/s fp16 on tensor cores.")
    rd = precision_ratio().to_dataframe()
    fp16 = rd[rd.Annotation == "fp16"][["n", "AvgValue", "StdDev", "Unit", "Normalized"]]
    print("\n  normalize_against='fp32' divides each AvgValue by the fp32 AvgValue of the same configuration:\n")
    print(textwrap.indent(fp16.to_string(index=False, float_format=lambda v: f"{v:,.3f}"), "  "))
    print("\n  Read the row carefully: AvgValue is now a ratio (fp16 time / fp32 time), but StdDev and Unit are")
    print("  still those of the raw nanosecond measurement, because only AvgValue is divided.")
    best = tf.loc[4096]
    return f"n=4096: fp32 {best['fp32']:.2f}, fp16 {best['fp16']:.2f} TFLOP/s"
 
 
precision_sweep()

Als Nächstes variieren wir die Matrixgröße von 512 bis 4096 und führen fp32 und fp16 in einer Benchmark-Funktion aus, mit einer Annotation pro Präzision. Eine derive_metric-Funktion erhält die gemessene Zeit, gefolgt von den Argumenten des Benchmarks, und gibt einen Wert mit seiner Einheit zurück, hier TFLOP/s, den das DataFrame als zusätzliche Metrik neben der Zeit speichert. Die Spitzenwerte im Datenblatt der T4 liegen bei 8.1 TFLOP/s in fp32 und 65 TFLOP/s in fp16 auf den Tensor Cores, was den gemessenen Zahlen eine Größenordnung gibt. Anschließend führen wir denselben Sweep mit normalize_against aus, das auf die fp32-Annotation gesetzt ist, wodurch jeder AvgValue durch den fp32-Wert derselben Konfiguration geteilt wird. Wie der Quelltext der Transformation zeigt, wird nur AvgValue dividiert: StdDev, das Konfidenzintervall und die Spalte Unit beschreiben weiterhin die rohe Nanosekunden-Messung, was leicht misszuverstehen ist.

Code kopieren
BOUND_METRICS = ["gpu__time_duration.sum",
                 "sm__throughput.avg.pct_of_peak_sustained_elapsed",
                 "dram__throughput.avg.pct_of_peak_sustained_elapsed"]
 
 
@nsight.analyze.kernel(metrics=BOUND_METRICS, runs=3, thermal_mode="off", verbosity=SILENT)
def what_limits(n: int) -> None:
    a = torch.randn(n, n, device="cuda").half()
    x = torch.randn(64 * 2 ** 20, device="cuda")
    s = torch.randn(8192, 1024, device="cuda")
    with nsight.annotate("fp16 matmul 4096"):
        _ = a @ a
    with nsight.annotate("x * 2 (256 MB)"):
        _ = x * 2.0
    with nsight.annotate("softmax 8192x1024"):
        _ = torch.softmax(s, dim=-1)
 
 
@section("6. Compute-bound or memory-bound? Three metrics from one replayed kernel")
def bound():
    df = what_limits(4096).to_dataframe()
    tab = df.pivot(index="Annotation", columns="Metric", values="AvgValue")
    tab = tab.rename(columns={"gpu__time_duration.sum": "ms",
                              "sm__throughput.avg.pct_of_peak_sustained_elapsed": "SM % of peak",
                              "dram__throughput.avg.pct_of_peak_sustained_elapsed": "DRAM % of peak"})
    tab["ms"] = tab["ms"] / 1e6
    tab["limited by"] = np.where(tab["SM % of peak"] > tab["DRAM % of peak"], "compute", "memory")
    print(textwrap.indent(tab.to_string(float_format=lambda v: f"{v:,.2f}"), "  "))
    x2 = tab.loc["x * 2 (256 MB)"]
    gbs = 2 * 4 * 64 * 2 ** 20 / (x2["ms"] * 1e6)
    print(f"\n  Cross-check: x * 2 moves 2 x 256 MB in {x2['ms']:.2f} ms = {gbs:.0f} GB/s, against the T4's 320 GB/s")
    print(f"  datasheet peak ({100 * gbs / 320:.0f}%); Nsight Compute reports {x2['DRAM % of peak']:.0f}% DRAM throughput.")
    print("  Collecting several metrics makes Nsight Compute replay each kernel once per pass it needs;")
    print("  nsight-python still returns one row per annotation and metric.")
    return f"x*2: {gbs:.0f} GB/s ({x2['DRAM % of peak']:.0f}% DRAM); fp16 matmul SM {tab.loc['fp16 matmul 4096', 'SM % of peak']:.0f}%"
 
 
bound()

Hardware-Zähler verraten uns, warum ein Kernel so lange dauert, wie er dauert. Die Übergabe einer Liste von Metriken veranlasst Nsight Compute, jeden Kernel so oft zu wiederholen, wie nötig ist, um alle zu sammeln, und Nsight Python gibt weiterhin eine Zeile pro Annotation und Metrik zurück. Wir erfassen die Dauer, den SM-Durchsatz und den DRAM-Durchsatz, jeweils als Prozentsatz des nachhaltigen Peak-Werts, für drei Kernel unterschiedlicher Charakteristik: eine fp16-Matrixmultiplikation, eine Skalierung eines 256 MB großen Vektors und eine Softmax über 8192 Zeilen mit je 1024 Werten, und beschriften jeden mit dem höheren der beiden Durchsätze. Für die Vektorskalierung berechnen wir außerdem die effektive Bandbreite aus den Bytes, die bewegt werden müssen, und der gemessenen Zeit, und vergleichen sie mit dem 320 GB/s-Datenblatt-Peak der T4 und dem von Nsight Compute gemeldeten DRAM-Prozentsatz.

Code kopieren
BYTES = ["gpu__time_duration.sum", "dram__bytes_read.sum", "dram__bytes_write.sum"]
 
 
@nsight.analyze.kernel(metrics=BYTES, runs=5, thermal_mode="off", verbosity=SILENT,
                       combine_kernel_metrics=lambda x, y: x + y)
def fusion(n: int) -> None:
    a, b, c = (torch.randn(n, device="cuda") for _ in range(3))
    with nsight.annotate("a + b * c (unfused)"):
        _ = a + b * c
    with nsight.annotate("torch.addcmul (fused)"):
        _ = torch.addcmul(a, b, c)
 
 
@nsight.analyze.kernel(runs=1, thermal_mode="off", verbosity=SILENT)
def fusion_without_combine(n: int) -> None:
    a, b, c = (torch.randn(n, device="cuda") for _ in range(3))
    with nsight.annotate("a + b * c"):
        _ = a + b * c
 
 
@section("7. Kernel fusion, measured in bytes: combine_kernel_metrics")
def fused():
    n = 32 * 2 ** 20
    try:
        fusion_without_combine(n)
        print("  (the unfused expression profiled without combine_kernel_metrics)")
    except Exception as e:
        print(f"  Profiled without combine_kernel_metrics, the unfused expression raises:\n    {type(e).__name__}: {str(e).splitlines()[0][:200]}\n")
    df = fusion(n).to_dataframe()
    tab = df.pivot(index="Annotation", columns="Metric", values="AvgValue")
    tab = pd.DataFrame({"ms": tab["gpu__time_duration.sum"] / 1e6,
                        "MB read": tab["dram__bytes_read.sum"] / 2 ** 20,
                        "MB written": tab["dram__bytes_write.sum"] / 2 ** 20})
    print(f"  {n:,} float32 elements per tensor (128 MB each); metrics summed over the kernels in each region:\n")
    print(textwrap.indent(tab.to_string(float_format=lambda v: f"{v:,.1f}"), "  "))
    u, f = tab.loc["a + b * c (unfused)"], tab.loc["torch.addcmul (fused)"]
    print(f"\n  The unfused version writes b * c to memory and reads it back: {u['MB read'] + u['MB written']:,.0f} MB of")
    print(f"  traffic against {f['MB read'] + f['MB written']:,.0f} MB, and {u['ms'] / f['ms']:.2f}x the time.")
    return f"fusion: {u['ms'] / f['ms']:.2f}x faster, {(u['MB read'] + u['MB written']) / (f['MB read'] + f['MB written']):.2f}x less traffic"
 
 
fused()

Kernel-Fusion wird üblicherweise in Bezug auf Zeit beschrieben; hier messen wir sie in Bytes. Der Ausdruck a + b * c materialisiert in eager PyTorch b * c im Speicher, bevor a addiert wird, während torch.addcmul dasselbe Ergebnis in einer einzigen Operation berechnet. Wir profilieren zunächst den unfusionierten Ausdruck ohne combine_kernel_metrics, um den Fehler zu zeigen, den Nsight Python auslöst, wenn eine annotierte Region mehr als einen Kernel enthält, und profilieren dann beide Versionen mit combine_kernel_metrics, das die Dauer sowie die gelesenen und geschriebenen DRAM-Bytes über jeden Kernel in jeder Region summiert, auf Tensoren mit 2^25, etwa 33.5 Millionen, float32-Elementen.

Code kopieren
import pynvml
 
pynvml.nvmlInit()
NVML = pynvml.nvmlDeviceGetHandleByIndex(0)
 
 
@nsight.experimental.collect(scope=nsight.CollectionScope.ONCE)
def driver_version(*args):
    return pynvml.nvmlSystemGetDriverVersion()
 
 
@nsight.experimental.collect(scope=nsight.CollectionScope.RUN)
def sm_clock_mhz(*args):
    return pynvml.nvmlDeviceGetClockInfo(NVML, pynvml.NVML_CLOCK_SM)
 
 
@nsight.experimental.collect(scope=nsight.CollectionScope.RUN)
def gpu_temp_c(*args):
    return pynvml.nvmlDeviceGetTemperature(NVML, pynvml.NVML_TEMPERATURE_GPU)
 
 
def make_heat_body():
    def heat(n: int) -> None:
        a = torch.randn(n, n, device="cuda").half()
        with nsight.annotate("fp16 matmul"):
            _ = a @ a
    return heat
 
 
@section("8. Custom info collectors and thermal control (experimental API)")
def collectors():
    out = {}
    for mode in ("off", "auto"):
        bench = nsight.analyze.kernel(configs=[4096], runs=30, thermal_mode=mode, thermal_timeout=60,
                                      info_collectors=[driver_version, sm_clock_mhz, gpu_temp_c],
                                      verbosity=SILENT, output_prefix=f"/content/nspy_thermal_{mode}/",
                                      output_csv=True)(make_heat_body())
        t0 = time.time()
        try:
            df = bench().to_dataframe()
        except Exception as e:
            print(f"  thermal_mode={mode!r}: failed -> {type(e).__name__}: {str(e).splitlines()[0][:150]}")
            continue
        wall = time.time() - t0
        csvs = sorted(glob.glob(f"/content/nspy_thermal_{mode}/profiled_data-*.csv"))
        raw = pd.read_csv(csvs[-1]) if csvs else pd.DataFrame()
        out[mode] = (df.iloc[0], wall, raw)
        r = df.iloc[0]
        print(f"  thermal_mode={mode!r}: {r['AvgValue'] / 1e6:.3f} ms +/- {r['RelativeStdDevPct']:.1f}%  "
              f"wall {wall:.0f}s  driver {r['driver_version']}")
    print(f"\n  The collectors became DataFrame columns; RUN-scoped ones were called before every run.")
    if "off" not in out:
        return "info-collector run failed"
    raw = out["off"][2]
    tcol = "Value" if "Value" in raw.columns else raw.columns[-1]
    print(f"  raw per-run CSV columns: {list(raw.columns)}")
    if {"sm_clock_mhz", "gpu_temp_c"} <= set(raw.columns):
        corr = raw[[tcol, "sm_clock_mhz", "gpu_temp_c"]].corr().iloc[0]
        print(f"  over 30 runs: SM clock {raw.sm_clock_mhz.min()}-{raw.sm_clock_mhz.max()} MHz, "
              f"temperature {raw.gpu_temp_c.min()}-{raw.gpu_temp_c.max()} C")
        print(f"  correlation of kernel time with SM clock {corr['sm_clock_mhz']:+.2f}, with temperature {corr['gpu_temp_c']:+.2f}")
    print("\n  thermal_mode='auto' (the default) pauses between runs when the GPU's thermal headroom runs low;")
    print("  the wall-time difference is the price of that, the spread difference what it buys.")
    return "; ".join(f"{m} {out[m][0]['RelativeStdDevPct']:.1f}% RSD in {out[m][1]:.0f}s" for m in out)
 
 
collectors()

Version 1.1 führt benutzerdefinierte Informationssammler ein, eine experimentelle API zum Anhängen anwendungsspezifischer Werte an die Ergebnisse. Eine mit nsight.experimental.collect und einem Collection-Scope dekorierte Funktion wird einmal pro Sitzung, pro Konfiguration, pro Lauf oder pro Annotation aufgerufen, und ihr Rückgabewert wird zu einer Spalte. Wir zeichnen die Treiberversion einmal auf und lesen die SM-Taktfrequenz und die GPU-Temperatur über NVML vor jedem Lauf einer langen fp16-Multiplikation, mit aktiviertem output_csv, sodass die Zeilen pro Lauf neben den aggregierten geschrieben werden. Wir führen die Benchmark zweimal aus, mit deaktivierter Thermalkontrolle und mit dem Standardmodus auto, bei dem Nsight Python zwischen den Läufen pausiert, wenn der thermische Headroom der GPU gering wird, und vergleichen die Streuung und die Wall-Clock-Zeit der beiden.

Code kopieren
CUPTI_SCRIPT = r'''
import os, json, time
os.environ["NSPY_NCU_INIT_AT_IMPORT"] = "0"     # do not load Nsight Compute in this process
import nsight
nsight.activate(nsight.Tool.CUPTI)
import torch
@nsight.analyze.kernel(configs=[512, 1024, 2048, 4096], runs=10, thermal_mode="off",
                       verbosity=nsight.VerbosityLevel.SILENT, combine_kernel_metrics=lambda x, y: x + y)
def matmul(n):
    a = torch.randn(n, n, device="cuda")
    b = torch.randn(n, n, device="cuda")
    with nsight.annotate("matmul"):
        _ = a @ b
t0 = time.time()
df = matmul().to_dataframe()
wall = time.time() - t0
nsight.deactivate()
print(json.dumps({"wall": wall, "rows": df[["n", "AvgValue", "RelativeStdDevPct"]].to_dict("records")}))
'''
 
 
@nsight.analyze.kernel(configs=[512, 1024, 2048, 4096], runs=10, thermal_mode="off", verbosity=SILENT,
                       combine_kernel_metrics=ADD)
def matmul_ncu(n: int) -> None:
    a = torch.randn(n, n, device="cuda")
    b = torch.randn(n, n, device="cuda")
    with nsight.annotate("matmul"):
        _ = a @ b
 
 
@section("9. The experimental CUPTI backend, in its own process, against Nsight Compute")
def cupti_vs_ncu():
    t0 = time.time()
    ncu_df = matmul_ncu().to_dataframe().set_index("n")
    ncu_wall = time.time() - t0
    if not CUPTI_EXTRA:
        return f"cupti-python could not be installed; Nsight Compute run took {ncu_wall:.0f}s"
    with open("/content/cupti_matmul.py", "w") as f:
        f.write(CUPTI_SCRIPT)
    env = {k: v for k, v in os.environ.items() if k not in ("NVTX_INJECTION64_PATH", "NV_COMPUTE_PROFILER_PERFWORKS_DIR",
                                                             "NV_COMPUTE_PROFILER_IN_PROCESS")}
    env["PYTHONPATH"] = CUPTI_DIR + os.pathsep + env.get("PYTHONPATH", "")
    p = subprocess.run([sys.executable, "/content/cupti_matmul.py"], capture_output=True, text=True, env=env, timeout=900)
    if p.returncode != 0:
        print(f"  CUPTI run failed (exit {p.returncode}); last lines of its stderr:")
        print(textwrap.indent("\n".join(p.stderr.strip().splitlines()[-8:]), "    "))
        return f"CUPTI backend failed (exit {p.returncode}); Nsight Compute run took {ncu_wall:.0f}s"
    res = next((json.loads(line) for line in reversed(p.stdout.strip().splitlines()) if line.startswith('{"wall"')), None)
    if res is None:
        return f"CUPTI run printed no result; Nsight Compute run took {ncu_wall:.0f}s"
    cu = pd.DataFrame(res["rows"]).set_index("n")
    print(f"  {'n':>5s} {'ncu ms':>8s} {'cupti ms':>9s} {'ratio':>6s} {'ncu RSD%':>9s} {'cupti RSD%':>11s}")
    for n in ncu_df.index:
        a, b = ncu_df.loc[n, "AvgValue"] / 1e6, cu.loc[n, "AvgValue"] / 1e6
        print(f"  {n:5d} {a:8.3f} {b:9.3f} {b / a:6.2f} {ncu_df.loc[n, 'RelativeStdDevPct']:9.1f} {cu.loc[n, 'RelativeStdDevPct']:11.1f}")
    print(f"\n  wall time for 4 sizes x 10 runs: Nsight Compute {ncu_wall:.1f}s, CUPTI {res['wall']:.1f}s"
          f" ({ncu_wall / res['wall']:.1f}x)")
    print("  The two tools are mutually exclusive in one process and Nsight Compute cannot be unloaded, so")
    print("  CUPTI runs in a child process with NSPY_NCU_INIT_AT_IMPORT=0. CUPTI records one execution per")
    print("  launch, with no replay, no cache flush and no clock control: cheaper, and warmer caches.")
    return f"CUPTI {ncu_wall / res['wall']:.1f}x faster to profile; durations within {abs(1 - cu.AvgValue.div(ncu_df.AvgValue)).max():.0%}"
 
 
cupti_vs_ncu()

Das CUPTI-Backend ist das zweite Highlight von 1.1: Kernel-Dauern, gemessen über Cuptis Activity-API, ohne Nsight Compute. Die beiden Werkzeuge schließen sich innerhalb eines Prozesses gegenseitig aus. Nsight Compute kann nach der Aktivierung nicht mehr entladen werden, daher schreiben wir ein kleines Skript, das NSPY_NCU_INIT_AT_IMPORT auf 0 setzt, bevor nsight importiert wird, das CUPTI-Werkzeug aktiviert, dieselben vier Matrixgrößen profiliert, und führen es in einem Kindprozess mit dem cupti-python-Verzeichnis auf seinem Pfad aus. CUPTI unterstützt nur die Dauer-Metrik und zeichnet eine Ausführung pro Launch auf, ohne Replay, Cache-Flushing oder Taktkontrolle, weshalb wir sowohl die Dauern als auch die Wall-Clock-Kosten des Profilierens mit derselben Benchmark unter Nsight Compute vergleichen.

Code kopieren
@section("10. Keep the evidence: CSVs and the full Nsight Compute report")
def evidence():
    files = sorted(glob.glob("/content/nspy_thermal_off/*"))
    for f in files:
        print(f"  {os.path.basename(f):60s} {os.path.getsize(f) / 1024:9.1f} KB")
    import ncu_report
    rep_path = next((f for f in files if f.endswith(".ncu-rep")), None)
    if rep_path is None:
        raise RuntimeError("no .ncu-rep in /content/nspy_thermal_off/ (step 8 did not complete)")
    rep = ncu_report.load_report(rep_path)
    actions = [rep.range_by_idx(i).action_by_idx(j) for i in range(rep.num_ranges())
               for j in range(rep.range_by_idx(i).num_actions())]
    a0 = actions[0]
    print(f"\n  the .ncu-rep holds {len(actions)} profiled kernel launches; the first is")
    print(f"    {a0.name()}")
    print(f"    gpu__time_duration.sum = {a0['gpu__time_duration.sum'].value():,.0f} {a0['gpu__time_duration.sum'].unit()}")
    print(f"    {len(a0.metric_names())} metrics available in the report for this launch")
    print("\n  output_csv=True writes profiled_data (one row per run) and processed_data (the aggregated")
    print("  DataFrame). The .ncu-rep opens in the Nsight Compute GUI for source-level analysis.")
    return f"{len(actions)} launches in the report, {len(a0.metric_names())} metrics each"
 
 
evidence()

Jeder profilierte Aufruf hinterlässt seine Spuren. Mit einem Output-Präfix und output_csv enthält das Verzeichnis den Nsight-Compute-Bericht, sein Protokoll, die pro-Lauf-Datei profiled_data CSV und die aggregierte Datei processed_data CSV. Wir listen die Dateien auf und öffnen den Bericht mit ncu_report, Nsight Computes Python-Berichtschnittstelle, die auch Nsight Python selbst verwendet: Sie listet die profilierten Kernel-Launches auf, und jeder trägt jede Metrik, die gesammelt wurde. Dieselbe .ncu-rep-Datei lässt sich in der Nsight-Compute-Oberfläche für Quellcode-Analyse öffnen.

Code kopieren
banner("SUMMARY")
for name, res in RESULTSitems():
    print(f"  {name:<76s}  {res}")
print("""
What to carry over
 - import nsight first; it loads Nsight Compute into the process before CUDA starts.
 - Annotate exactly the work you mean to measure; one kernel per region unless you pass
   combine_kernel_metrics.
 - Decide cache and clock control on purpose: nsight-python flushes caches and leaves clocks
   unlocked by default, which is not what a plain ncu run does.
 - Use runs and the CI95 / RelativeStdDevPct columns; one run tells you nothing about noise.
 - After normalize_against, only AvgValue is a ratio.
Where to go next
 - Triton and cuTile kernels: examples/07_triton_minimal.py and examples/12_cutile.py.
 - Plots: @nsight.analyze.plot(...) on top of @nsight.analyze.kernel, with row/col panels.
 - Docs: docs.nvidia.com/nsight-python    Code: github.com/NVIDIA/nsight-python
""")

Die Zusammenfassung druckt das Einzeiler-Ergebnis jedes Schritts und die empfehlenswerten Gewohnheiten: nsight importieren, bevor irgendetwas CUDA initialisiert, genau die Arbeit annotieren, die man messen will, Cache- und Taktsteuerung bewusst wählen, genug Durchläufe verwenden und die Konfidenzintervalle lesen, und daran denken, dass nur AvgValue normalisiert wird.

Abschließend lässt sich sagen: Nsight Python macht aus dem Kernel-Profiling eine separate Kommandozeilensitzung ein paar Zeilen im gemessenen Code: einen Decorator, eine Annotation und einen DataFrame, der die Messung, ihre Streuung und ihren Kontext trägt. Die Arbeit damit auf einer Colab T4 zeigt auch, wo der Komfort Entscheidungen versteckt, die die Zahlen verändern. Der Profiler muss neuer sein als der, den Colab mitliefert, und muss geladen werden, bevor CUDA startet. Cache-Leeren und Clock-Locking sind standardmäßig das Gegenteil dessen, was ein einfacher ncu-Lauf tut; die Clock-Spalte gibt die Nennleistung der Karte an statt des Clocks, mit dem der Kernel tatsächlich lief; die Normierung dividiert nur den Durchschnitt. Von jeder annotierten Region wird erwartet, dass sie genau einen Kernel enthält, sofern man nicht angibt, wie sie kombiniert werden sollen. Sobald diese Entscheidungen explizit sind, beantworten dieselben wenigen Zeilen die Fragen, die in der Praxis zählen: wie stabil ein Timing ist, was einen Kernel begrenzt, was Fusion tatsächlich einspart und was ein günstigerer Profiler wie das neue CUPTI-Backend für seinen geringeren Overhead einbüßt.


Schauen Sie sich die VOLLSTÄNDIGEN CODES hieran. Alle Credits gehen an den Forscher dieses Projekts. Folgen Sie uns gerne auch auf Twitter und vergessen Sie nicht, unserem 150k+ML SubReddit beizutreten und unseren Newsletterzu abonnieren. Moment! Sind Sie auf Telegram? jetzt können Sie uns auch auf Telegram beitreten.

Der Beitrag NVIDIA Nsight Python Tutorial: How to Profile GPU Kernels erschien zuerst auf MarkTechPost.

Originalquelle

MarkTechPost

Hinweise zum Inhalt

Originalveröffentlichung und Rechte liegen bei der Quelle.

Maschinelle Übersetzung · Original beachten