En este tutorial, implementamos sobre Nsight Python, la interfaz de Python de código abierto de NVIDIA para sus perfiladores de kernels. En lugar de ejecutar un script bajo la línea de comandos de Nsight Compute y luego examinar un informe, marcamos el código que nos interesa con nsight.annotate, decoramos una función de benchmark con nsight.analyze.kernel, y obtenemos un DataFrame de pandas con métricas de hardware por región anotada, por configuración y por ejecución. Desde la versión 1.0, la biblioteca adjunta Nsight Compute al proceso de Python en ejecución, lo que la hace utilizable en Jupyter y Colab, y la próxima versión 1.1 añade un backend experimental CUPTI, gestión explícita de herramientas y colectores de información personalizados. Ejecutamos todo en una T4 gratuita de Colab: instalamos una versión actual de Nsight Compute junto a la que Colab incluye, perfilamos un primer kernel, decidimos cuántas ejecuciones necesita una medición, medimos qué hacen el control de caché y de reloj sobre el mismo kernel, barremos tamaños de matriz en fp32 y fp16 con una métrica derivada de TFLOP/s, usamos contadores de hardware para distinguir trabajo limitado por cómputo de trabajo limitado por memoria, medimos la fusión de kernels en bytes movidos, registramos relojes y temperatura por ejecución, comparamos el backend CUPTI con Nsight Compute, y conservamos el informe en bruto para un análisis más profundo.
Copiar códigoimport 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.")
Empezamos comprobando la GPU y el perfilador. Nsight Python requiere la herramienta de línea de comandos de Nsight Compute, ncu, en la versión 2026.2.1 o posterior, y la encuentra buscándola en el PATH. La imagen de GPU de Colab incluye una versión más antigua de Nsight Compute. Aun así, ya tiene configurado el repositorio de paquetes CUDA de NVIDIA, así que instalamos Nsight Compute 2026.3.1 con apt-get, le preguntamos a dpkg dónde quedó el lanzador ncu, y ponemos ese directorio primero en el PATH. Luego instalamos Nsight Python, fijado al commit de la pre-versión 1.1.0 con la que se escribió este notebook, y el paquete cupti-python en un directorio separado, porque necesita una biblioteca CUPTI más nueva que aquella a la que está fijado el PyTorch de Colab. El orden de importación importa: import nsight carga la biblioteca de inyección de Nsight Compute en el proceso de Python, y solo puede adjuntarse a trabajo CUDA iniciado después de eso, así que nsight debe importarse antes de que torch toque la GPU.
Copiar códigoADD = 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()
El primer perfil es una única multiplicación de matrices en fp32. La función de benchmark crea sus entradas y luego envuelve solo la multiplicación en nsight.annotate, de modo que los kernels de números aleatorios que construyen las entradas no se miden. Llamar a la función decorada inicia ncu en modo attach sobre el propio proceso del notebook, ejecuta la función y devuelve un objeto ProfileResults cuyo método to_dataframe da una fila por anotación, configuración y métrica. Imprimimos las columnas y leemos la fila: el nombre del kernel, el gpu__time_duration.sum medido en nanosegundos con su unidad, el número de ejecuciones, el nombre de la GPU y los atributos de reloj, y convertimos el tiempo a TFLOP/s a mano. El argumento combine_kernel_metrics suma el tiempo si cuBLAS divide una multiplicación en más de un kernel; con una sola ejecución, la marca StableMeasurement queda indefinida, porque una sola muestra no tiene dispersión.
Copiar códigodef 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()
Una única medición no dice nada sobre el ruido, así que el siguiente paso ejecuta cada configuración veinte veces. El DataFrame lleva entonces la media, la desviación estándar, un intervalo de confianza del 95 por ciento, la desviación estándar relativa en porcentaje y una marca StableMeasurement, que en el código fuente es simplemente una desviación estándar relativa por debajo del dos por ciento. Perfilamos la suma de vectores en dos tamaños, uno de dieciséis mil elementos que se ejecuta en microsegundos y otro de dieciséis millones de elementos, junto con una multiplicación de matrices de 2048 por 2048, y comparamos cuánto varía cada uno. La regla práctica es ejecutar los kernels cortos más veces y comparar intervalos de confianza en lugar de promedios individuales.
Copiar códigodef 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()
Dos valores predeterminados del perfilador determinan lo que significa una medición, y este paso mide ambos sobre el mismo kernel: escalar un vector por dos. cache_control decide si las cachés se vacían antes de cada repetición del kernel: Nsight Python las vacía de forma predeterminada, de modo que una entrada pequeña que de otro modo permanecería en la caché L2 de 4 MB de la T4 se lee de la memoria cada vez. clock_control decide si los relojes de la GPU se fijan: Nsight Python los deja sin fijar de forma predeterminada, mientras que la línea de comandos simple de ncu los fija a los relojes base. Ejecutamos las cuatro combinaciones a 1 MB y 64 MB y también recogemos gpc__cycles_elapsed.avg.per_second, que mide el reloj al que el kernel realmente se ejecutó. Esto importa porque, como muestra el código fuente, la columna ComputeClock del DataFrame es el atributo de reloj nominal del dispositivo y no una medición.
Copiar códigoSIZES = [(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()
A continuación, barremos el tamaño de la matriz de 512 a 4096 y ejecutamos tanto fp32 como fp16 en una sola función de benchmark, con una anotación por precisión. Una función derive_metric recibe el tiempo medido, seguido de los argumentos del benchmark, y devuelve un valor con su unidad, aquí TFLOP/s, que el DataFrame almacena como una métrica adicional junto al tiempo. Los picos de la hoja de datos de la T4 son 8.1 TFLOP/s en fp32 y 65 TFLOP/s en fp16 en sus tensor cores, lo que da una escala a los números medidos. Luego ejecutamos el mismo barrido con normalize_against establecido en la anotación fp32, lo que divide cada AvgValue por el valor fp32 de la misma configuración. Como muestra el código fuente de la transformación, solo se divide AvgValue: StdDev, el intervalo de confianza y la columna Unit siguen describiendo la medición cruda en nanosegundos, lo que es fácil de malinterpretar.
Copiar códigoBOUND_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()
Los contadores de hardware nos dicen por qué un kernel tarda lo que tarda. Pasar una lista de métricas hace que Nsight Compute reproduzca cada kernel tantas veces como necesite para recolectarlas todas, y Nsight Python sigue devolviendo una fila por anotación y métrica. Recolectamos la duración, el rendimiento del SM y el rendimiento de la DRAM, cada uno como porcentaje del pico sostenido, para tres kernels de carácter diferente: una multiplicación de matrices en fp16, un escalado de un vector de 256 MB y una softmax sobre 8192 filas de 1024 valores, y etiquetamos cada uno por el rendimiento que sea mayor. Para el escalado del vector, también calculamos el ancho de banda efectivo a partir de los bytes que debe mover y el tiempo medido, y lo comparamos con el pico de 320 GB/s de la hoja de datos de la T4 y con el porcentaje de DRAM que reporta Nsight Compute.
Copiar códigoBYTES = ["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()
La fusión de kernels suele describirse en términos de tiempo; aquí la medimos en bytes. La expresión a + b * c en PyTorch eager materializa b * c en memoria antes de sumar a, mientras que torch.addcmul calcula el mismo resultado en una sola operación. Primero perfilamos la expresión sin fusionar sin combine_kernel_metrics para mostrar el error que Nsight Python lanza cuando una región anotada contiene más de un kernel, y luego perfilamos ambas versiones con combine_kernel_metrics sumando la duración y los bytes de DRAM leídos y escritos de cada kernel en cada región, sobre tensores de 2^25, unos 33.5 millones, de elementos float32.
Copiar códigoimport 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()
La versión 1.1 añade colectores de información personalizados, una API experimental para adjuntar valores específicos de la aplicación a los resultados. Una función decorada con nsight.experimental.collect y un ámbito de colección se llama una vez por sesión, por configuración, por ejecución o por anotación, y su valor de retorno se convierte en una columna. Registramos la versión del driver una vez y leemos el reloj del SM y la temperatura de la GPU a través de NVML antes de cada ejecución de una multiplicación larga en fp16, con output_csv habilitado para que las filas por ejecución se escriban junto a las agregadas. Ejecutamos el benchmark dos veces, con el control térmico desactivado y con el modo automático por defecto, en el que Nsight Python hace pausas entre ejecuciones cuando el margen térmico de la GPU baja demasiado, y comparamos la dispersión y el tiempo de reloj de pared de ambas.
Copiar códigoCUPTI_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()
El backend CUPTI es la otra característica principal de la 1.1: duraciones de kernel medidas a través de la API de actividad de CUPTI, sin Nsight Compute. Las dos herramientas son mutuamente excluyentes dentro de un mismo proceso. Nsight Compute no puede descargarse una vez activo, así que escribimos un pequeño script que establece NSPY_NCU_INIT_AT_IMPORT en 0 antes de importar nsight, activa la herramienta CUPTI, perfila los mismos cuatro tamaños de matriz, y lo ejecutamos en un proceso hijo con el directorio cupti-python en su path. CUPTI solo admite la métrica de duración y registra una ejecución por lanzamiento, sin repetición, sin vaciado de caché ni control de reloj, así que comparamos tanto las duraciones como el costo de tiempo de reloj de pared del perfilado con el mismo benchmark bajo Nsight Compute.
Copiar código@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()
Cada llamada perfilada deja su rastro. Con un prefijo de salida y output_csv, el directorio contiene el reporte de Nsight Compute, su registro, el CSV per-run de perfilado profiled_data y el CSV agregado processed_data. Listamos los archivos y abrimos el reporte con ncu_report, la interfaz de reportes en Python de Nsight Compute, que el propio Nsight Python utiliza: lista los lanzamientos de kernel perfilados, y cada uno lleva cada métrica que fue recolectada. El mismo archivo .ncu-rep se abre en la interfaz de Nsight Compute para análisis a nivel de código fuente.
Copiar códigobanner("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
""")
El resumen imprime el resultado en una línea de cada paso y los hábitos que vale la pena conservar: importar nsight antes de que cualquier cosa inicialice CUDA, anotar exactamente el trabajo que se pretende medir, elegir deliberadamente el control de caché y de reloj, usar suficientes ejecuciones y leer los intervalos de confianza, y recordar que solo AvgValue se normaliza. Termina con referencias a los ejemplos de Triton y cuTile en el repositorio y al decorador de graficación.
En conclusión, Nsight Python convierte la creación de perfiles de kernels de una sesión de línea de comandos separada en unas pocas líneas dentro del código que se está midiendo: un decorador, una anotación y un DataFrame que lleva la medición, su dispersión y su contexto. Trabajar con él en una Colab T4 también muestra dónde la conveniencia oculta decisiones que cambian las cifras. El profiler debe ser más nuevo que el que incluye Colab y debe cargarse antes de que se inicie CUDA. El vaciado de caché y el bloqueo de reloj tienen como valor predeterminado lo contrario de lo que hace una ejecución normal de ncu; la columna de reloj informa la nominal de la tarjeta en lugar del reloj al que se ejecutó el kernel; la normalización divide solo el promedio. Se espera que cada región anotada contenga exactamente un kernel, a menos que se indique cómo combinarlos. Una vez que esas elecciones son explícitas, las mismas pocas líneas responden a las preguntas que importan en la práctica: qué tan estable es un tiempo, qué limita a un kernel, qué ahorra realmente la fusión y a qué renuncia un profiler más barato como el nuevo backend de CUPTI por su menor sobrecarga.
Echa un vistazo al CÓDIGO COMPLETO aquí. Todo el mérito es del investigador de este proyecto. Además, no duden en seguirnos en Twitter y no olviden unirse a nuestro SubReddit de ML de más de 150k y suscribirse a nuestro boletín. ¡Espera! ¿estás en telegram? ahora también puedes unirte a nosotros en telegram.
La entrada Tutorial de NVIDIA Nsight Python: Cómo crear perfiles de kernels de GPU apareció primero en MarkTechPost.