Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets

This commit is contained in:
Kolp
2026-09-24 13:22:23 +07:00
commit 642cc11a9f
18968 changed files with 5683248 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""Post-process a profiler trace to add CUDA graph kernel annotations.
Reads a profiler trace (gzipped or plain JSON) and a kernel annotations
pickle, matches kernel events by their graph node id, and writes an
annotated trace with the annotation fields added to each kernel event's
args (displayed alongside grid/block size in trace viewers).
The annotations pickle is auto-discovered from the trace file's parent
directory (one level up, matching the rank from the trace filename).
Usage:
python -m torch.cuda._annotate_cuda_graph_trace <trace_file> [-a <annotations_pkl>] [-o <output_file>]
Examples:
# Auto-discover annotations pickle from trace location
python -m torch.cuda._annotate_cuda_graph_trace \\
traces/step_000000000014/000000.*.pt.trace.json.gz
# Explicit annotations pickle
python -m torch.cuda._annotate_cuda_graph_trace trace.json.gz -a annotations.pkl
"""
import argparse
import gzip
import json
import pickle
import re
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any
_WORK_CATEGORIES = {"kernel", "gpu_memcpy", "gpu_memset"}
def _move_overlapping_to_stream(
trace: dict, default_stream: int = 7, overlap_stream: int = 8
) -> int:
"""Move graphed kernels that overlap with their predecessor to a separate stream.
Perfetto cannot display overlapping (non-nested) events on the same
stream -- they get hidden. This pass detects graphed kernel events on
*default_stream* whose start timestamp falls before the previous
kernel's end, and moves them to *overlap_stream* so they're visible.
Returns the number of events moved.
"""
graphed_on_default = [
e
for e in trace["traceEvents"]
if e.get("cat") == "kernel"
and e.get("tid") == default_stream
and e.get("args", {}).get("graph node id", 0) != 0
]
graphed_on_default.sort(key=lambda e: e["ts"])
moved = 0
prev_end = 0.0
for event in graphed_on_default:
ts = event["ts"]
dur = event.get("dur", 0)
if ts < prev_end:
event["tid"] = overlap_stream
event.get("args", {})["stream"] = overlap_stream
moved += 1
else:
prev_end = ts + dur
return moved
def _fix_overlapping_timestamps(trace: dict, max_adjust_us: float = 1.0) -> int:
"""Clamp graphed kernel/memcpy timestamps so they don't overlap on the same stream.
CUPTI can produce slightly overlapping timestamps for consecutive graphed
events, causing Perfetto to hide events that sit entirely "under" their
neighbours. This pass sorts graphed work events per stream and ensures
each event starts at or after the previous event's end.
Overlaps larger than *max_adjust_us* are flagged as warnings and left
unchanged, since they likely indicate a real issue rather than CUPTI
timestamp jitter.
Returns the number of events adjusted.
"""
per_stream: dict[int, list[dict]] = defaultdict(list)
for event in trace["traceEvents"]:
if (
event.get("cat") in _WORK_CATEGORIES
and event.get("args", {}).get("graph node id", 0) != 0
):
per_stream[event.get("tid")].append(event)
adjusted = 0
for tid, events in per_stream.items():
events.sort(key=lambda e: e["ts"])
prev_end = 0.0
for event in events:
ts = event["ts"]
dur = event.get("dur", 0)
if ts < prev_end:
overlap = prev_end - ts
if overlap > max_adjust_us:
print(
f"WARNING: large overlap {overlap:.3f}us on stream {tid} "
f"for {event.get('name', '?')[:60]}, skipping adjustment"
)
else:
event["ts"] = prev_end
adjusted += 1
prev_end = event["ts"] + dur
return adjusted
def annotate_trace(
trace: dict,
annotations: dict[int, list[Any]],
default_stream: int = 7,
) -> int:
"""Add annotation fields to kernel events matching the annotations dict.
Each annotation entry is a list (from nested ``mark_kernels`` scopes).
Fields from all annotations are merged into the event args; if multiple
annotations define the same key, later entries in the list win.
For graphed events (graph_node_id != 0), reassigns ``tid`` and
``args["stream"]`` to the stream recorded in annotations, or to
*default_stream* if there is no annotation. Also moves the
corresponding ``ac2g`` flow-finish events to the new tid so that
CPU-to-GPU correlation arrows are preserved.
Removes ``gpu_user_annotation`` events and orphaned ``ac2g`` events
from streams that have no kernel or memcpy events after reassignment,
since CUPTI replicates these onto every stream during graph replay.
Returns the number of events annotated.
"""
# Build an index of ac2g 'f' events keyed by (tid, ts) so we can
# move them together with the kernel events they correspond to.
ac2g_f_index: dict[tuple, list] = {}
for event in trace["traceEvents"]:
if event.get("cat") == "ac2g" and event.get("ph") == "f":
key = (event.get("tid"), event.get("ts"))
ac2g_f_index.setdefault(key, []).append(event)
annotated = 0
for event in trace.get("traceEvents", []):
args = event.get("args", {})
graph_node_id = args.get("graph node id")
if graph_node_id is None or graph_node_id == 0:
continue
stream_id = None
if graph_node_id in annotations:
for ann in annotations[graph_node_id]:
if isinstance(ann, dict):
for key, value in ann.items():
args[key] = str(value)
if "stream" in ann:
stream_id = int(ann["stream"])
else:
args["annotation"] = str(ann)
annotated += 1
# Reassign stream: use annotated stream if available, else default
if stream_id is None:
stream_id = default_stream
old_key = (event.get("tid"), event.get("ts"))
event["tid"] = stream_id
args["stream"] = stream_id
# Move the matching ac2g 'f' event(s) to the same new tid
for ac2g_event in ac2g_f_index.get(old_key, ()):
ac2g_event["tid"] = stream_id
# Remove gpu_user_annotation events and ac2g flow-finish events from
# streams that have no real kernel/memcpy/memset work -- these are
# noise replicated by CUPTI onto every stream during graph replay.
tids_with_work = set()
for event in trace["traceEvents"]:
if event.get("cat") in _WORK_CATEGORIES:
tids_with_work.add(event.get("tid"))
def _is_noise(event: dict) -> bool:
cat = event.get("cat")
if cat == "gpu_user_annotation":
return event.get("tid") not in tids_with_work
if cat == "ac2g" and event.get("ph") == "f":
return event.get("tid") not in tids_with_work
return False
original_count = len(trace["traceEvents"])
trace["traceEvents"] = [
event for event in trace["traceEvents"] if not _is_noise(event)
]
removed = original_count - len(trace["traceEvents"])
if removed:
print(f"Removed {removed} noise events from empty streams")
# Clean up metadata: remove thread_name / thread_sort_index entries
# for noise streams that have no real (non-metadata) events, and add
# thread_name entries for our new annotation streams.
all_tids_in_trace = {
e.get("tid") for e in trace["traceEvents"] if e.get("ph") != "M"
}
# Find the GPU process pid from existing thread_name metadata
gpu_pid = 0
for event in trace["traceEvents"]:
if (
event.get("ph") == "M"
and event.get("name") == "thread_name"
and str(event.get("args", {}).get("name", "")).startswith("stream ")
):
gpu_pid = event.get("pid", 0)
break
# Remove metadata entries for tids with no non-metadata events
trace["traceEvents"] = [
event
for event in trace["traceEvents"]
if event.get("ph") != "M" or event.get("tid") in all_tids_in_trace
]
# Add thread_name metadata for new annotation tids that lack one
existing_thread_names = {
e.get("tid")
for e in trace["traceEvents"]
if e.get("ph") == "M" and e.get("name") == "thread_name"
}
for tid in sorted(tids_with_work - existing_thread_names):
trace["traceEvents"].append(
{
"ph": "M",
"pid": gpu_pid,
"tid": tid,
"name": "thread_name",
"args": {"name": f"stream {tid}"},
}
)
return annotated
def load_trace(path: Path) -> dict:
if path.suffix == ".gz" or path.name.endswith(".json.gz"):
with gzip.open(path, "rt") as f:
return json.load(f)
else:
with open(path) as f:
return json.load(f)
def save_trace(trace: dict, path: Path) -> None:
if path.suffix == ".gz" or path.name.endswith(".json.gz"):
with gzip.open(path, "wt") as f:
json.dump(trace, f)
else:
with open(path, "w") as f:
json.dump(trace, f)
def _find_annotations_pkl(trace_file: Path) -> Path | None:
"""Auto-discover the annotations pickle from the trace file location.
Trace files live in e.g. ``traces/step_000000000014/000000.<id>.pt.trace.json.gz``
where the leading digits are the rank. The pickle lives one level up:
``traces/kernel_annotations_rank0_*.pkl``.
"""
match = re.match(r"^(\d+)", trace_file.name)
if not match:
return None
rank = int(match.group(1))
traces_dir = trace_file.parent.parent
candidates = sorted(traces_dir.glob(f"kernel_annotations_rank{rank}_*.pkl"))
if candidates:
return candidates[0]
return None
def main() -> None:
parser = argparse.ArgumentParser(
description="Annotate a profiler trace with CUDA graph kernel annotations."
)
parser.add_argument(
"trace_file", type=Path, help="Input trace file (.json or .json.gz)"
)
parser.add_argument(
"-a",
"--annotations",
type=Path,
default=None,
help="Kernel annotations pickle file. Auto-discovered if omitted.",
)
parser.add_argument(
"-o",
"--output",
type=Path,
default=None,
help="Output file path. Defaults to <trace_file>.annotated.<ext>",
)
parser.add_argument(
"--default-stream",
type=int,
default=7,
help="Stream ID to assign to unannotated graphed events (default: 7).",
)
args = parser.parse_args()
annotations_pkl = args.annotations
if annotations_pkl is None:
annotations_pkl = _find_annotations_pkl(args.trace_file)
if annotations_pkl is None:
print(
f"Could not auto-discover annotations pickle for {args.trace_file}. "
f"Use -a to specify it explicitly.",
file=sys.stderr,
)
sys.exit(1)
print(f"Auto-discovered annotations: {annotations_pkl}")
with open(annotations_pkl, "rb") as f:
annotations = pickle.load(f)
print(f"Loaded {len(annotations)} kernel annotations")
trace = load_trace(args.trace_file)
total_events = len(trace.get("traceEvents", []))
print(f"Loaded trace with {total_events} events")
count = annotate_trace(trace, annotations, default_stream=args.default_stream)
print(f"Annotated {count} kernel events")
overlap_moved = _move_overlapping_to_stream(
trace, default_stream=args.default_stream
)
if overlap_moved:
print(f"Moved {overlap_moved} overlapping events to stream 8")
ts_fixed = _fix_overlapping_timestamps(trace)
if ts_fixed:
print(f"Fixed {ts_fixed} overlapping graphed event timestamps")
output = args.output
if output is None:
name = args.trace_file.name
if name.endswith(".json.gz"):
output = args.trace_file.with_name(
name.replace(".json.gz", ".annotated.json.gz")
)
elif name.endswith(".json"):
output = args.trace_file.with_suffix(".annotated.json")
else:
output = args.trace_file.with_suffix(args.trace_file.suffix + ".annotated")
save_trace(trace, output)
print(f"Saved annotated trace to {output}")
if __name__ == "__main__":
main()
@@ -0,0 +1,140 @@
import torch
from torch._C import dtype
__all__ = ["GPULimits"]
class GPULimits:
r"""Utility class that provides the theoretical limits of Nvidia GPU devices. The
limits don't take into account thermal throttling (assume that the GPU run at its
peak rated frequency). This is because user hardware configuration may influence
power behavior.
"""
def __init__(self, target_device: torch.device):
# The device properties object is obtained by calling 'cudaGetDeviceProperties' CUDA
# runtime function. We need the total memory bus width and the memory clock rate to
# calculate the memory bandwidth.
self.device_properties = torch.cuda.get_device_properties(target_device)
# The compute capability is needed to determine the number of FLOPs per cycle per SM
self.compute_capability = int(
f"{self.device_properties.major}{self.device_properties.minor}"
)
# FLOPs per cycle information derived from Table 2 in:
# https://resources.nvidia.com/en-us-hopper-architecture/nvidia-h100-tensor-c
# Returns the number of FMA instructions retired per cycle per SM for a given
# data type, when tensor cores are NOT used
def get_fma_per_cycle_per_sm_cuda_cores(self, data_type: dtype) -> int:
hardcoded_device_values = {
# Ampere Architecture
"fp16_80": 256,
"fp32_80": 64,
"fp64_80": 32,
# Hopper Architecture
"fp16_90": 64,
"fp32_90": 128,
"fp64_90": 64,
# Blackwell Architecture
"fp16_100": 256,
"fp32_100": 128,
"fp64_100": 64,
}
dict_key = ""
if data_type is torch.float16:
dict_key = f"fp16_{self.compute_capability}"
elif data_type is torch.float32:
dict_key = f"fp32_{self.compute_capability}"
elif data_type is torch.float64:
dict_key = f"fp64_{self.compute_capability}"
else:
dict_key = "unknown"
if dict_key not in hardcoded_device_values:
raise RuntimeError(
f"No data for sm_{self.compute_capability} and {data_type}."
)
return hardcoded_device_values[dict_key]
# Returns the number of FMA instructions retired per cycle per SM for a given
# data type, when tensor cores ARE used
def get_fma_per_cycle_per_sm_tensor_cores(self, data_type: dtype) -> int:
hardcoded_device_values = {
# Ampere Architecture
"int8_80": 2048,
"fp16_80": 1024,
"fp32_80": 512,
"fp64_80": 64,
# Hopper Architecture
"int8_90": 4096,
"fp8_90": 4096,
"fp16_90": 2048,
"fp32_90": 1024,
"fp64_90": 128,
# Blackwell Architecture
"int8_100": 8192,
"fp8_100": 8192,
"fp16_100": 4096,
"fp32_100": 2048,
}
dict_key = ""
if data_type is torch.float16:
dict_key = f"fp16_{self.compute_capability}"
elif data_type is torch.bfloat16:
# FP16 and BF16 are equivalent in terms of FLOPs per cycle per SM
dict_key = f"fp16_{self.compute_capability}"
elif data_type is torch.float32:
dict_key = f"fp32_{self.compute_capability}"
elif data_type is torch.int8:
dict_key = f"int8_{self.compute_capability}"
elif data_type is torch.float64:
dict_key = f"fp64_{self.compute_capability}"
else:
dict_key = "unknown"
if dict_key not in hardcoded_device_values:
raise RuntimeError(
f"No data for sm_{self.compute_capability} and {data_type}."
)
return hardcoded_device_values[dict_key]
def get_tflops_per_second(
self, data_type: dtype, use_tensor_cores: bool = True
) -> float:
num_sms = self.device_properties.multi_processor_count
clock_rate = self.device_properties.clock_rate # KHz
fma_per_cycle = 0
if use_tensor_cores:
fma_per_cycle = self.get_fma_per_cycle_per_sm_tensor_cores(data_type)
else:
fma_per_cycle = self.get_fma_per_cycle_per_sm_cuda_cores(data_type)
# 1 FMA counts as 2 floating point operations
# Clock rate is in KHz
tflops_per_second = num_sms * fma_per_cycle * 2 * clock_rate / 1e9
return tflops_per_second
def get_memory_bandwidth_Bps(self) -> int:
# DRAM devices are Double-Data which means they provide an output at both fronts of
# a clock beat
bus_bytes_per_cycle = int(2 * self.device_properties.memory_bus_width / 8)
mem_clock_rate_Hz = self.device_properties.memory_clock_rate * 1000
bytes_per_second = bus_bytes_per_cycle * mem_clock_rate_Hz
return bytes_per_second
def get_shared_memory_bandwidth_Bps(self) -> int:
# Each warp can LD or ST 32 x 4 bytes per cycle. To calculate the
# device's throughput we need to multiply with frequency and number of SMs.
num_sms = self.device_properties.multi_processor_count
bytes_per_cycle_per_sm = 128
bytes_per_cycle_per_device = num_sms * bytes_per_cycle_per_sm
bytes_per_second = (
bytes_per_cycle_per_device * self.device_properties.clock_rate * 1000
)
return bytes_per_second
@@ -0,0 +1,73 @@
from collections.abc import Callable
from torch._utils import CallbackRegistry
EventCreationCallbacks: "CallbackRegistry[int]" = CallbackRegistry(
"CUDA event creation"
)
EventDeletionCallbacks: "CallbackRegistry[int]" = CallbackRegistry(
"CUDA event deletion"
)
EventRecordCallbacks: "CallbackRegistry[int, int]" = CallbackRegistry(
"CUDA event record"
)
EventWaitCallbacks: "CallbackRegistry[int, int]" = CallbackRegistry("CUDA event wait")
MemoryAllocationCallbacks: "CallbackRegistry[int]" = CallbackRegistry(
"CUDA memory allocation"
)
MemoryDeallocationCallbacks: "CallbackRegistry[int]" = CallbackRegistry(
"CUDA memory deallocation"
)
StreamCreationCallbacks: "CallbackRegistry[int]" = CallbackRegistry(
"CUDA stream creation"
)
DeviceSynchronizationCallbacks: "CallbackRegistry[[]]" = CallbackRegistry(
"CUDA device synchronization"
)
StreamSynchronizationCallbacks: "CallbackRegistry[int]" = CallbackRegistry(
"CUDA stream synchronization"
)
EventSynchronizationCallbacks: "CallbackRegistry[int]" = CallbackRegistry(
"CUDA event synchronization"
)
def register_callback_for_event_creation(cb: Callable[[int], None]) -> None:
EventCreationCallbacks.add_callback(cb)
def register_callback_for_event_deletion(cb: Callable[[int], None]) -> None:
EventDeletionCallbacks.add_callback(cb)
def register_callback_for_event_record(cb: Callable[[int, int], None]) -> None:
EventRecordCallbacks.add_callback(cb)
def register_callback_for_event_wait(cb: Callable[[int, int], None]) -> None:
EventWaitCallbacks.add_callback(cb)
def register_callback_for_memory_allocation(cb: Callable[[int], None]) -> None:
MemoryAllocationCallbacks.add_callback(cb)
def register_callback_for_memory_deallocation(cb: Callable[[int], None]) -> None:
MemoryDeallocationCallbacks.add_callback(cb)
def register_callback_for_stream_creation(cb: Callable[[int], None]) -> None:
StreamCreationCallbacks.add_callback(cb)
def register_callback_for_device_synchronization(cb: Callable[[], None]) -> None:
DeviceSynchronizationCallbacks.add_callback(cb)
def register_callback_for_stream_synchronization(cb: Callable[[int], None]) -> None:
StreamSynchronizationCallbacks.add_callback(cb)
def register_callback_for_event_synchronization(cb: Callable[[int], None]) -> None:
EventSynchronizationCallbacks.add_callback(cb)
@@ -0,0 +1,434 @@
"""Annotate CUDA graph kernel nodes during capture.
During CUDA graph capture, ``mark_kernels`` uses ``cudaGraphGetNodes``
to count nodes before and after the wrapped region. Nodes at indices
``[before, after)`` are the ones added within the scope. Each kernel
or memcpy node found is annotated by its ``toolsId`` so it can later
be matched to profiler trace events.
The annotations can be pickled and later merged into a Chrome profiler
trace using ``torch.cuda._annotate_cuda_graph_trace``.
Requires ``cuda.bindings`` package and a CUDA driver that supports
``cudaGraphNodeGetToolsId`` (CUDA >= 13.1 or appropriate cuda-compat).
When unavailable, ``mark_kernels`` silently becomes a no-op.
Usage during capture::
from torch.cuda._graph_annotations import (
enable_annotations,
mark_kernels,
resolve_pending_annotations,
remap_to_exec_graph,
)
enable_annotations()
with torch.cuda.graph(graph):
with mark_kernels("phase_A"):
y = workload_a(x)
with mark_kernels("phase_B"):
z = workload_b(y)
resolve_pending_annotations()
remap_to_exec_graph(graph)
"""
from collections import defaultdict
from contextlib import contextmanager
from logging import getLogger
from typing import Any
import torch
from torch.cuda._utils import _check_cuda_bindings, _HAS_CUDA_BINDINGS
try:
from cuda.bindings import ( # pyrefly: ignore[missing-import]
runtime as _cuda_runtime,
)
except ImportError:
_cuda_runtime = None # type: ignore[assignment]
logger = getLogger(__name__)
# Tri-state: None = not probed, True = available, False = unavailable.
# Deferred to first use to avoid premature CUDA initialization.
_tools_id_available: bool | None = None
# Global kill switch. When False, mark_kernels and mark_stream are no-ops.
_annotations_enabled: bool = False
def enable_annotations() -> None:
"""Enable kernel annotation recording."""
global _annotations_enabled
_annotations_enabled = True
def disable_annotations() -> None:
"""Disable kernel annotation recording."""
global _annotations_enabled
_annotations_enabled = False
def _is_tools_id_unavailable() -> bool:
"""Return True if we already know cudaGraphNodeGetToolsId is missing."""
if not _HAS_CUDA_BINDINGS:
return True
if _tools_id_available is False:
return True
if not hasattr(_cuda_runtime, "cudaGraphNodeGetToolsId"):
return True
return False
def _get_tools_id(node: Any) -> int | None:
"""Return the toolsId for a graph node, or None if unavailable."""
global _tools_id_available
if _tools_id_available is None:
try:
tools_id = _check_cuda_bindings(
_cuda_runtime.cudaGraphNodeGetToolsId( # pyrefly: ignore[missing-attribute]
node
)
)
except Exception:
_tools_id_available = False
logger.info(
"cudaGraphNodeGetToolsId not available; "
"CUDA graph kernel annotations will be disabled"
)
return None
_tools_id_available = True
return tools_id
return _check_cuda_bindings(
_cuda_runtime.cudaGraphNodeGetToolsId( # pyrefly: ignore[missing-attribute]
node
)
)
def _get_capture_graph(stream: Any) -> Any:
"""Return the graph handle for the active capture, or None."""
status, _id, graph, _deps, _edge_data, _num_deps = _check_cuda_bindings(
_cuda_runtime.cudaStreamGetCaptureInfo( # pyrefly: ignore[missing-attribute]
stream
)
)
if (
status
!= _cuda_runtime.cudaStreamCaptureStatus.cudaStreamCaptureStatusActive # pyrefly: ignore[missing-attribute]
):
return None
return graph
def _get_node_count(graph: Any) -> int:
"""Return the number of nodes currently in the graph."""
_, num = _check_cuda_bindings(
_cuda_runtime.cudaGraphGetNodes( # pyrefly: ignore[missing-attribute]
graph, numNodes=0
)
)
return num
# toolsId -> list of annotation objects.
_kernel_annotations: defaultdict[int, list[Any]] = defaultdict(list)
# Node types we annotate. Initialized lazily to avoid touching cuda.bindings
# at import time.
_ANNOTATABLE_TYPES: set[Any] | None = None
def _get_annotatable_types() -> set[Any]:
global _ANNOTATABLE_TYPES
if _ANNOTATABLE_TYPES is None:
_ANNOTATABLE_TYPES = {
_cuda_runtime.cudaGraphNodeType.cudaGraphNodeTypeKernel, # pyrefly: ignore[missing-attribute]
_cuda_runtime.cudaGraphNodeType.cudaGraphNodeTypeMemcpy, # pyrefly: ignore[missing-attribute]
}
return _ANNOTATABLE_TYPES
# Pending scopes: (annotation, start_node_index, end_node_index).
_pending_scopes: list[tuple[Any, int, int]] = []
# Graph handle saved during capture for post-capture resolution.
_capture_graph: Any = None
# Capture graph ID saved by resolve_pending_annotations for remap_to_exec_graph.
_last_capture_graph_id: int | None = None
@contextmanager # type: ignore[arg-type]
def mark_kernels(annotation: str | dict[str, Any]):
"""Context manager that records node index ranges for later annotation.
During capture, calls ``cudaGraphGetNodes`` to count graph nodes before
and after the scope. Nodes at indices ``[before, after)`` were added
inside the scope. After capture, ``resolve_pending_annotations``
enumerates all nodes and annotates kernel/memcpy nodes in those ranges.
Must be called inside an active ``torch.cuda.graph()`` capture. If the
current stream is not capturing, or if ``cudaGraphNodeGetToolsId`` is not
available, the context manager is a no-op.
Args:
annotation: Arbitrary object appended to the annotation list for
every kernel/memcpy node whose index falls within this scope.
"""
if not _annotations_enabled or _is_tools_id_unavailable():
yield
return
if isinstance(annotation, str):
annotation = {"str": annotation}
stream = _cuda_runtime.cudaStream_t( # pyrefly: ignore[missing-attribute]
init_value=torch.cuda.current_stream().cuda_stream
)
graph = _get_capture_graph(stream)
if graph is None:
yield
return
global _capture_graph
_capture_graph = graph
start_count = _get_node_count(graph)
yield
end_count = _get_node_count(graph)
if end_count > start_count:
_pending_scopes.append((annotation, start_count, end_count))
def resolve_pending_annotations() -> None:
"""Resolve pending scope index ranges into kernel annotations.
Enumerates all graph nodes and annotates kernel/memcpy nodes whose
indices fall within recorded scope ranges. Must be called while still
inside the ``torch.cuda.graph()`` capture context.
"""
global _capture_graph
if not _pending_scopes:
_capture_graph = None
return
# Get a fresh graph handle from the active capture.
stream = _cuda_runtime.cudaStream_t( # pyrefly: ignore[missing-attribute]
init_value=torch.cuda.current_stream().cuda_stream
)
graph = _get_capture_graph(stream)
if graph is None:
graph = _capture_graph
if graph is None:
logger.warning("resolve_pending_annotations: no graph handle available")
_pending_scopes.clear()
return
try:
num = _get_node_count(graph)
if num == 0:
_pending_scopes.clear()
_capture_graph = None
return
nodes, num = _check_cuda_bindings(
_cuda_runtime.cudaGraphGetNodes( # pyrefly: ignore[missing-attribute]
graph, numNodes=num
)
)
# Save capture graph ID for remap_to_exec_graph.
global _last_capture_graph_id
if num > 0:
first_tid = _get_tools_id(nodes[0])
_last_capture_graph_id = (first_tid >> 32) if first_tid else None
annotatable = _get_annotatable_types()
# Sort by (start, -end, -append_index). The append index encodes
# nesting depth: inner context managers exit first, so they are
# appended to _pending_scopes first (smaller index). Using
# -append_index as tiebreaker ensures that for same-range scopes
# the outer scope (larger index) sorts first and is pushed onto
# the stack first, leaving the inner scope on top.
sorted_scopes = sorted(
(
(ann, start, end, i)
for i, (ann, start, end) in enumerate(_pending_scopes)
),
key=lambda s: (s[1], -s[2], -s[3]),
)
scope_ptr = 0
active_stack: list[tuple[int, Any]] = [] # (end_idx, annotation)
for i in range(num):
# Pop scopes whose range ended.
while active_stack and active_stack[-1][0] <= i:
active_stack.pop()
# Push scopes that start at or before this index.
while scope_ptr < len(sorted_scopes) and sorted_scopes[scope_ptr][1] <= i:
ann, _start_idx, end_idx, _idx = sorted_scopes[scope_ptr]
if end_idx > i:
active_stack.append((end_idx, ann))
scope_ptr += 1
if not active_stack:
continue
node = nodes[i]
node_type = _check_cuda_bindings(
_cuda_runtime.cudaGraphNodeGetType( # pyrefly: ignore[missing-attribute]
node
)
)
if node_type not in annotatable:
continue
tools_id = _get_tools_id(node)
if tools_id is None:
logger.warning(
"resolve_pending_annotations: toolsId unavailable, aborting"
)
_pending_scopes.clear()
_capture_graph = None
return
if len(active_stack) == 1:
_kernel_annotations[tools_id].append(active_stack[0][1])
else:
# Merge all active scopes into one dict. Inner scopes sit
# on top of the stack. Iterating reversed (inner first)
# with setdefault lets the inner scope's values win for
# overlapping keys (e.g. name, stream) while outer scopes
# fill in any missing keys.
merged: dict[str, Any] = {}
for _, ann in reversed(active_stack):
if isinstance(ann, dict):
for ak, av in ann.items():
merged.setdefault(ak, av)
else:
merged.setdefault("name", ann)
_kernel_annotations[tools_id].append(merged)
except Exception:
logger.exception("resolve_pending_annotations failed")
finally:
_pending_scopes.clear()
_capture_graph = None
def remap_to_exec_graph(torch_cuda_graph: torch.cuda.CUDAGraph) -> None:
"""Remap annotation keys from capture graph ID to exec graph ID.
During capture, toolsId encodes the capture graph's ID in the upper
32 bits. After instantiation, the profiler uses the exec graph's ID.
This function rewrites the keys so annotations match the trace.
Must be called after the ``torch.cuda.graph()`` context exits.
"""
if not _kernel_annotations:
return
exec_handle = _cuda_runtime.cudaGraphExec_t( # pyrefly: ignore[missing-attribute]
init_value=torch_cuda_graph.raw_cuda_graph_exec()
)
exec_graph_id = _check_cuda_bindings(
_cuda_runtime.cudaGraphExecGetId( # pyrefly: ignore[missing-attribute]
exec_handle
)
)
# Only remap annotations from the most recent capture graph.
# Previously remapped annotations (from earlier captures) keep their
# correct exec graph IDs.
capture_graph_id = _last_capture_graph_id
remapped: dict[int, list[Any]] = {}
for tools_id, ann_list in _kernel_annotations.items():
graph_id = tools_id >> 32
if capture_graph_id is not None and graph_id != capture_graph_id:
# Belongs to a different graph — keep as-is.
remapped[tools_id] = ann_list
continue
node_id = tools_id & 0xFFFFFFFF
new_tools_id = (exec_graph_id << 32) | node_id
if new_tools_id in remapped:
remapped[new_tools_id].extend(ann_list)
else:
remapped[new_tools_id] = list(ann_list)
_kernel_annotations.clear()
_kernel_annotations.update(remapped)
def get_kernel_annotations() -> dict[int, list[Any]]:
"""Return the current kernel annotation map (toolsId -> annotations)."""
return _kernel_annotations
def clear_kernel_annotations() -> None:
"""Clear all recorded kernel annotations and pending scopes."""
global _capture_graph
_kernel_annotations.clear()
_pending_scopes.clear()
_capture_graph = None
# Counter-based stream ID registry. IDs start at 60 (above the highest
# observed non-graphed CUDA stream ID) so every assigned lane is visually
# distinct in Perfetto and doesn't collide with real streams.
_stream_id_counter: int = 60
_stream_id_map: dict[int, int] = {}
def _get_stream_id(stream: torch.cuda.Stream) -> int:
"""Return a small, stable stream ID for the given CUDA stream."""
global _stream_id_counter
key = stream.cuda_stream
if key not in _stream_id_map:
_stream_id_map[key] = _stream_id_counter
_stream_id_counter += 1
return _stream_id_map[key]
def get_stream_for_pg(pg_key: str) -> int:
"""Return a unique stream ID for the given process group key."""
global _stream_id_counter
if pg_key not in _stream_id_map:
_stream_id_map[pg_key] = _stream_id_counter # type: ignore[assignment]
_stream_id_counter += 1
return _stream_id_map[pg_key] # type: ignore[return-value]
@contextmanager # type: ignore[arg-type]
def mark_stream(stream: torch.cuda.Stream, annotation: str | dict[str, Any]):
"""Switch to stream, inject its ID into annotation, and mark kernels.
If *stream* is already the current stream, no stream switch or stream ID
injection happens — the kernels stay on whatever stream is active (which
keeps the trace faithful when e.g. FSDP uses the current stream for
copy-in instead of a separate one).
"""
if not _annotations_enabled:
with torch.cuda.stream(stream):
yield
return
if stream.cuda_stream == torch.cuda.current_stream().cuda_stream:
with mark_kernels(annotation):
yield
else:
if isinstance(annotation, str):
annotation = {"str": annotation}
if isinstance(annotation, dict):
annotation["stream"] = _get_stream_id(stream)
with torch.cuda.stream(stream):
with mark_kernels(annotation):
yield
@@ -0,0 +1,802 @@
# mypy: allow-untyped-defs
import base64
import io
import json
import operator
import os
import pickle
import subprocess
import sys
import warnings
from functools import lru_cache
from itertools import groupby
from typing import Any
cache = lru_cache(None)
__all__ = ["format_flamegraph", "segments", "memory", "compare"]
def _frame_fmt(f, full_filename=False):
i = f["line"]
fname = f["filename"]
if not full_filename:
fname = fname.split("/")[-1]
func = f["name"]
return f"{fname}:{i}:{func}"
@cache
def _frame_filter(name, filename):
omit_functions = [
"unwind::unwind",
"CapturedTraceback::gather",
"gather_with_cpp",
"_start",
"__libc_start_main",
"PyEval_",
"PyObject_",
"PyFunction_",
]
omit_filenames = [
"core/boxing",
"/Register",
"/Redispatch",
"pythonrun.c",
"Modules/main.c",
"Objects/call.c",
"Objects/methodobject.c",
"pycore_ceval.h",
"ceval.c",
"cpython/abstract.h",
]
for of in omit_functions:
if of in name:
return False
for of in omit_filenames:
if of in filename:
return False
return True
def _frames_fmt(frames, full_filename=False, reverse=False):
if reverse:
frames = reversed(frames)
return [
_frame_fmt(f, full_filename)
for f in frames
if _frame_filter(f["name"], f["filename"])
]
def _block_extra_legacy(b):
if "history" in b:
frames = b["history"][0].get("frames", [])
real_size = b["history"][0]["real_size"]
else:
real_size = b.get("requested_size", b["size"])
frames = []
return frames, real_size
def _block_extra(b):
if "frames" not in b:
# old snapshot format made it more complicated to get frames/allocated size
return _block_extra_legacy(b)
return b["frames"], b["requested_size"]
def format_flamegraph(flamegraph_lines, flamegraph_script=None):
if flamegraph_script is None:
cache_dir = os.path.expanduser("~/.cache/")
os.makedirs(cache_dir, exist_ok=True)
flamegraph_script = f"{cache_dir}/flamegraph.pl"
if not os.path.exists(flamegraph_script):
import tempfile
import urllib.request
print(f"Downloading flamegraph.pl to: {flamegraph_script}")
with tempfile.NamedTemporaryFile(mode="wb", suffix=".pl") as f:
urllib.request.urlretrieve(
"https://raw.githubusercontent.com/brendangregg/FlameGraph/master/flamegraph.pl",
f.name,
)
try:
os.chmod(f.name, 0o755)
os.rename(f.name, flamegraph_script)
except OSError: # noqa: B001,E722
# Ok to skip, the file will be removed by tempfile
pass
args = [flamegraph_script, "--countname", "bytes"]
with subprocess.Popen(
args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding="utf-8"
) as p:
if p.stdin is None:
raise AssertionError("p.stdin is None")
if p.stdout is None:
raise AssertionError("p.stdout is None")
p.stdin.write(flamegraph_lines)
p.stdin.close()
result = p.stdout.read()
p.stdout.close()
p.wait()
if p.wait() != 0:
raise AssertionError(f"flamegraph process exited with code {p.wait()}")
return result
def _write_blocks(f, prefix, blocks):
def frames_fragment(frames):
if not frames:
return "<non-python>"
return ";".join(_frames_fmt(frames, reverse=True))
for b in blocks:
if "history" not in b:
frames, accounted_for_size = _block_extra(b)
f.write(
f"{prefix};{b['state']};{frames_fragment(frames)} {accounted_for_size}\n"
)
else:
accounted_for_size = 0
for h in b["history"]:
sz = h["real_size"]
accounted_for_size += sz
if "frames" in h:
frames = h["frames"]
f.write(f"{prefix};{b['state']};{frames_fragment(frames)} {sz}\n")
else:
f.write(f"{prefix};{b['state']};<no-context> {sz}\n")
gaps = b["size"] - accounted_for_size
if gaps:
f.write(f"{prefix};{b['state']};<gaps> {gaps}\n")
def segments(snapshot, format_flamegraph=format_flamegraph):
f = io.StringIO()
for seg in snapshot["segments"]:
prefix = f"stream_{seg['stream']};seg_{seg['address']}"
_write_blocks(f, prefix, seg["blocks"])
return format_flamegraph(f.getvalue())
def memory(snapshot, format_flamegraph=format_flamegraph):
f = io.StringIO()
for seg in snapshot["segments"]:
prefix = f"stream_{seg['stream']}"
_write_blocks(f, prefix, seg["blocks"])
return format_flamegraph(f.getvalue())
def compare(before, after, format_flamegraph=format_flamegraph):
def _seg_key(seg):
return (seg["address"], seg["total_size"])
def _seg_info(seg):
return f"stream_{seg['stream']};seg_{seg['address']}"
f = io.StringIO()
before_segs = {_seg_key(seg) for seg in before}
after_segs = {_seg_key(seg) for seg in after}
print(f"only_before = {[a for a, _ in (before_segs - after_segs)]}")
print(f"only_after = {[a for a, _ in (after_segs - before_segs)]}")
for seg in before:
if _seg_key(seg) not in after_segs:
_write_blocks(f, f"only_before;{_seg_info(seg)}", seg["blocks"])
for seg in after:
if _seg_key(seg) not in before_segs:
_write_blocks(f, f"only_after;{_seg_info(seg)}", seg["blocks"])
return format_flamegraph(f.getvalue())
def _format_size(num):
# https://stackoverflow.com/questions/1094841/get-human-readable-version-of-file-size
for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
if abs(num) < 1024.0:
return f"{num:3.1f}{unit}B"
num /= 1024.0
return f"{num:.1f}YiB"
class Bytes:
def __init__(self, value):
self.value = value
def __add__(self, rhs):
return Bytes(self.value + rhs)
def __repr__(self):
return _format_size(self.value)
def calc_active(seg):
return sum(b["size"] for b in seg["blocks"] if b["state"] == "active_allocated")
def _report_free(free_external, free_internal):
total = free_external + free_internal
suffix = ""
if total != 0:
pct = (free_internal / total) * 100
suffix = f" ({pct:.1f}% internal)"
return f"{Bytes(total)}{suffix}"
PAGE_SIZE = 1024 * 1024 * 20
legend = f"""\
Legend:
[a ] - a segment in the allocator
^-- a page {Bytes(PAGE_SIZE)} of memory in the segment
a-z: pages filled with a single block's content
' ': page is completely free
*: page if completely full with multiple blocks
0-9: page is partially full with tensors of multiple blocks (9 == 90% full)
(X% internal) - of the free memory, X% is free because we rounded the size of the allocation.
"""
def segsum(data):
r"""Visually reports how the allocator has filled its segments.
This printout can help debug fragmentation issues since free fragments
will appear as gaps in this printout. The amount of free space is reported
for each segment.
We distinguish between internal free memory which occurs because the
allocator rounds the allocation size, and external free memory, which are
the gaps between allocations in a segment.
Args:
data: snapshot dictionary created from _snapshot()
"""
out = io.StringIO()
out.write(f"Summary of segments >= {Bytes(PAGE_SIZE)} in size\n")
total_reserved = 0
total_allocated = 0
free_external = 0
free_internal = 0
for seg in sorted(
data["segments"], key=lambda x: (x["total_size"], calc_active(x))
):
total_reserved += seg["total_size"]
seg_free_external = 0
seg_free_internal = 0
seg_allocated = 0
all_ranges = []
boffset = 0
for b in seg["blocks"]:
active = b["state"] == "active_allocated"
if active:
_, allocated_size = _block_extra(b)
all_ranges.append((boffset, allocated_size, True))
seg_allocated += allocated_size
seg_free_internal += b["size"] - allocated_size
else:
seg_free_external += b["size"]
boffset += b["size"]
total_allocated += seg_allocated
free_external += seg_free_external
free_internal += seg_free_internal
nseg = (seg["total_size"] - 1) // PAGE_SIZE + 1
occupied = [" " for _ in range(nseg)]
frac = [0.0 for _ in range(nseg)]
active_size = 0
for i, (start_, size, active) in enumerate(all_ranges):
active_size += size
finish_ = start_ + size
start = start_ // PAGE_SIZE
finish = (finish_ - 1) // PAGE_SIZE + 1
m = chr(ord("a" if active else "A") + (i % 26))
for j in range(start, finish):
s = max(start_, j * PAGE_SIZE)
e = min(finish_, (j + 1) * PAGE_SIZE)
frac[j] += (e - s) / PAGE_SIZE
if occupied[j] != " ":
occupied[j] = "0123456789*"[int(frac[j] * 10)]
else:
occupied[j] = m
stream = "" if seg["stream"] == 0 else f", stream_{seg['stream']}"
body = "".join(occupied)
if seg_free_external + seg_free_internal + seg_allocated != seg["total_size"]:
raise AssertionError(
f"Segment size mismatch: {seg_free_external} + {seg_free_internal} + {seg_allocated} != {seg['total_size']}"
)
stream = f" stream_{seg['stream']}" if seg["stream"] != 0 else ""
if seg["total_size"] >= PAGE_SIZE:
out.write(
f"[{body}] {Bytes(seg['total_size'])} allocated, "
f"{_report_free(seg_free_external, seg_free_internal)} free{stream}\n"
)
out.write(f"segments: {len(data['segments'])}\n")
out.write(f"total_reserved: {Bytes(total_reserved)}\n")
out.write(f"total_allocated: {Bytes(total_allocated)}\n")
out.write(f"total_free: {_report_free(free_external, free_internal)}\n")
out.write(legend)
if free_internal + free_external + total_allocated != total_reserved:
raise AssertionError(
f"Memory accounting error: {free_internal} + {free_external} + {total_allocated} != {total_reserved}"
)
return out.getvalue()
def trace(data):
out = io.StringIO()
def format(entries):
segment_intervals: list = []
segment_addr_to_name = {}
allocation_addr_to_name = {}
free_names: list = []
next_name = 0
def _name():
nonlocal next_name
if free_names:
return free_names.pop()
r, m = next_name // 26, next_name % 26
next_name += 1
return f"{chr(ord('a') + m)}{'' if r == 0 else r}"
def find_segment(addr):
for name, saddr, size in segment_intervals:
if addr >= saddr and addr < saddr + size:
return name, saddr
for i, seg in enumerate(data["segments"]):
saddr = seg["address"]
size = seg["allocated_size"]
if addr >= saddr and addr < saddr + size:
return f"seg_{i}", saddr
return None, None
count = 0
out.write(f"{len(entries)} entries\n")
total_reserved = 0
for seg in data["segments"]:
total_reserved += seg["total_size"]
for count, e in enumerate(entries):
if e["action"] == "alloc":
addr, size = e["addr"], e["size"]
n = _name()
seg_name, seg_addr = find_segment(addr)
if seg_name is None:
seg_name = "MEM"
offset = addr
else:
offset = addr - seg_addr
out.write(f"{n} = {seg_name}[{offset}:{Bytes(size)}]\n")
allocation_addr_to_name[addr] = (n, size, count)
count += size
elif e["action"] == "free_requested":
addr, size = e["addr"], e["size"]
name, _, _ = allocation_addr_to_name.get(addr, (addr, None, None))
out.write(f"del {name} # {Bytes(size)}\n")
elif e["action"] == "free_completed":
addr, size = e["addr"], e["size"]
count -= size
name, _, _ = allocation_addr_to_name.get(addr, (addr, None, None))
out.write(f"# free completed for {name} {Bytes(size)}\n")
if name in allocation_addr_to_name:
free_names.append(name)
del allocation_addr_to_name[name]
elif e["action"] == "segment_alloc":
addr, size = e["addr"], e["size"]
name = _name()
out.write(f"{name} = cudaMalloc({addr}, {Bytes(size)})\n")
segment_intervals.append((name, addr, size))
segment_addr_to_name[addr] = name
elif e["action"] == "segment_free":
addr, size = e["addr"], e["size"]
name = segment_addr_to_name.get(addr, addr)
out.write(f"cudaFree({name}) # {Bytes(size)}\n")
if name in segment_addr_to_name:
free_names.append(name)
del segment_addr_to_name[name]
elif e["action"] == "oom":
size = e["size"]
free = e["device_free"]
out.write(
f"raise OutOfMemoryError # {Bytes(size)} requested, {Bytes(free)} free in CUDA\n"
)
else:
out.write(f"{e}\n")
out.write(f"TOTAL MEM: {Bytes(count)}")
for i, d in enumerate(data["device_traces"]):
if d:
out.write(f"Device {i} ----------------\n")
format(d)
return out.getvalue()
_memory_viz_template = r"""
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<script type="module">
import {add_local_files} from "https://cdn.jsdelivr.net/gh/pytorch/pytorch@main/torch/utils/viz/MemoryViz.js"
const local_files = $SNAPSHOT
add_local_files(local_files, $VIZ_KIND)
</script>
</body>
"""
def _format_viz(data, viz_kind, device):
if device is not None:
warnings.warn(
"device argument is deprecated, plots now contain all device",
FutureWarning,
stacklevel=3,
)
buffer = pickle.dumps(data)
buffer += b"\x00" * (3 - len(buffer) % 3)
# Encode the buffer with base64
encoded_buffer = base64.b64encode(buffer).decode("utf-8")
json_format = json.dumps([{"name": "snapshot.pickle", "base64": encoded_buffer}])
return _memory_viz_template.replace("$VIZ_KIND", repr(viz_kind)).replace(
"$SNAPSHOT", json_format
)
def filter_alloc_free_pairs(data):
for dev_id in range(len(data["device_traces"])):
# set of indexes of trace events for alloc-free pairs
filterSet = set()
# map from addr to index of alloc event
allocMap = {}
# set of addrs from free_requested events
freeRequested = set()
for idx, event in enumerate(data["device_traces"][dev_id]):
if event["action"] == "alloc":
allocMap[event["addr"]] = idx
elif event["action"] == "free_requested":
freeRequested.add(event["addr"])
if allocMap.get(event["addr"]) is not None:
filterSet.add(idx)
filterSet.add(allocMap[event["addr"]])
allocMap.pop(event["addr"])
elif event["action"] == "free_completed":
if event["addr"] in freeRequested:
freeRequested.remove(event["addr"])
filterSet.add(idx)
else:
print(f"free_completed without free_requested: {event}")
# Remove events whose index is in filterSet
if filterSet:
# Create a new list excluding events with indices in filterSet
data["device_traces"][dev_id] = [
event
for idx, event in enumerate(data["device_traces"][dev_id])
if idx not in filterSet
]
return data
def trace_plot(data, device=None, plot_segments=False, filter_freed=False):
"""Generate a visualization over time of the memory usage recorded by the trace as an html file.
Args:
data: Memory snapshot as generated from torch.cuda.memory._snapshot()
device (torch.device, optional): Generate the trace for this device, needed if multiple devices have allocations.
plot_segments (bool, optional): Plots memory returned from cudaMalloc, rather than individual allocations.
Defaults to False.
filter_freed (bool, optional): Filter out alloc-free paired events to only plot allocations that are not freed yet.
Defaults to False to plot all trace events.
Returns:
str: HTML of visualization
"""
if filter_freed:
data = filter_alloc_free_pairs(data)
return _format_viz(
data,
"Active Memory Timeline"
if not plot_segments
else "Active Cached Memory Timeline",
device,
)
def _profile_to_snapshot(profile):
import torch
from torch._C._profiler import _EventType
from torch.profiler._memory_profiler import Action, TensorKey
memory_profile = profile._memory_profile()
allocation_stacks = {}
for event in memory_profile._op_tree.sorted_nodes:
if event.tag == _EventType.Allocation:
parent = event.parent
python_parents = []
while parent:
if parent.tag in (_EventType.PyCall, _EventType.PyCCall):
python_parents.append(parent)
parent = parent.parent
key = TensorKey.from_allocation(event.extra_fields)
# Corner case: If allocation doesn't have an ID (can't prove it was used as a Tensor)
# key will be None. I should add some way to identify these, I just haven't yet.
if key and event.extra_fields.alloc_size > 0:
allocation_stacks[key] = python_parents
device_count = torch.cuda.device_count()
snapshot: dict[str, list[Any]] = {
"device_traces": [[] for _ in range(device_count + 1)],
"segments": [
{
"device": device,
"address": None,
"total_size": 0,
"stream": 0,
"blocks": [],
}
for device in range(device_count + 1)
],
}
def to_device(device):
if device.type == "cuda":
return device.index
else:
return device_count
def allocate(size, tensor_key, version, during_trace=True):
device = to_device(tensor_key.device)
addr = tensor_key.storage.ptr
seg = snapshot["segments"][device] # type: ignore[index]
if seg["address"] is None or seg["address"] > addr:
seg["address"] = addr
seg["total_size"] = max(
seg["total_size"], addr + size
) # record max addr for now, we will make it the size later
category = memory_profile._categories.get(tensor_key, version)
category = category.name.lower() if category is not None else "unknown"
stack = allocation_stacks.get(tensor_key, ())
stack = [{"filename": "none", "line": 0, "name": p.name} for p in stack]
r = {
"action": "alloc",
"addr": addr,
"size": size,
"stream": 0,
"frames": stack,
"category": category,
}
if during_trace:
snapshot["device_traces"][device].append(r)
return r
def free(alloc, device):
for e in ("free_requested", "free_completed"):
snapshot["device_traces"][device].append(
{
"action": e,
"addr": alloc["addr"],
"size": alloc["size"],
"stream": 0,
"frames": alloc["frames"],
}
)
kv_to_elem = {}
# create the device trace
for _time, action, (tensor_key, version), size in memory_profile.timeline:
if not isinstance(tensor_key, TensorKey):
continue
if action == Action.CREATE:
kv_to_elem[(tensor_key, version)] = allocate(size, tensor_key, version)
elif action == Action.DESTROY:
free(kv_to_elem.pop((tensor_key, version)), to_device(tensor_key.device))
elif action == Action.INCREMENT_VERSION:
free(kv_to_elem.pop((tensor_key, version)), to_device(tensor_key.device))
kv_to_elem[(tensor_key, version + 1)] = allocate(
size, tensor_key, version + 1
)
elif action == Action.PREEXISTING:
kv_to_elem[(tensor_key, version)] = allocate(
size, tensor_key, version, during_trace=False
)
# create the final snapshot state
blocks_at_end = [
(to_device(tensor_key.device), event["addr"], event["size"], event["frames"])
for (tensor_key, version), event in kv_to_elem.items()
]
for device, blocks in groupby(sorted(blocks_at_end), key=operator.itemgetter(0)):
seg = snapshot["segments"][device] # type: ignore[index]
last_addr = seg["address"]
for _, addr, size, frames in blocks:
if last_addr < addr:
seg["blocks"].append({"size": addr - last_addr, "state": "inactive"})
seg["blocks"].append(
{
"size": size,
"state": "active_allocated",
"requested_size": size,
"frames": frames,
}
)
last_addr = addr + size
if last_addr < seg["total_size"]:
seg["blocks"].append(
{"size": seg["total_size"] - last_addr, "state": "inactive"}
)
snapshot["segments"] = [seg for seg in snapshot["segments"] if seg["blocks"]] # type: ignore[attr-defined]
for seg in snapshot["segments"]: # type: ignore[attr-defined, name-defined, no-redef]
seg["total_size"] -= seg["address"]
if not seg["blocks"]:
seg["blocks"].append({"size": seg["total_size"], "state": "inactive"})
return snapshot
def profile_plot(profile, device=None):
"""Generate a visualization over time of the memory usage recorded by kineto memory profiling as an html file.
Args:
profile: profile as generated by `torch.profiler.profile(profile_memory=True)`
device (torch.device, optional): Generate the trace for this device, needed if multiple devices have allocations.
Returns:
str: HTML of visualization
"""
snapshot = _profile_to_snapshot(profile)
return _format_viz(snapshot, "Active Memory Timeline", device)
def segment_plot(data: Any, device=None):
return _format_viz(data, "Allocator State History", device)
if __name__ == "__main__":
import os.path
thedir = os.path.realpath(os.path.dirname(__file__))
if thedir in sys.path:
# otherwise we find cuda/random.py as random...
sys.path.remove(thedir)
import argparse
fn_name = "torch.cuda.memory._snapshot()"
pickled = f"pickled memory statistics from {fn_name}"
parser = argparse.ArgumentParser(
description=f"Visualize memory dumps produced by {fn_name}"
)
subparsers = parser.add_subparsers(dest="action")
def _output(p):
p.add_argument(
"-o",
"--output",
default="output.svg",
help="flamegraph svg (default: output.svg)",
)
description = "Prints overall allocation statistics and a visualization of how the allocators segments are currently filled."
stats_a = subparsers.add_parser("stats", description=description)
stats_a.add_argument("input", help=pickled)
description = "Prints buffer of the most recent allocation events embedded in the snapshot in a Pythonic style."
trace_a = subparsers.add_parser("trace", description=description)
trace_a.add_argument("input", help=pickled)
description = "Generate a flamegraph that visualizes what memory is stored in each allocator segment (aka block)"
segments_a = subparsers.add_parser("segments", description=description)
segments_a.add_argument("input", help=pickled)
_output(segments_a)
description = (
"Generate a flamegraph the program locations contributing to CUDA memory usage."
)
memory_a = subparsers.add_parser("memory", description=description)
memory_a.add_argument("input", help=pickled)
_output(memory_a)
description = (
"Generate a flamegraph that shows segments (aka blocks) that have been added "
"or removed between two different memorys snapshots."
)
compare_a = subparsers.add_parser("compare", description=description)
compare_a.add_argument("before", help=pickled)
compare_a.add_argument("after", help=pickled)
_output(compare_a)
plots = (
(
"trace_plot",
"Generate a visualization over time of the memory usage recorded by the trace as an html file.",
),
(
"segment_plot",
"Visualize how allocations are packed into allocator segments at each point in a trace as an html file.",
),
)
for cmd, description in plots:
trace_plot_a = subparsers.add_parser(cmd, description=description)
trace_plot_a.add_argument("input", help=pickled)
help = "visualize trace from this device (default: chooses the only device with trace info or errors)"
trace_plot_a.add_argument("-d", "--device", type=int, default=None, help=help)
help = "path to save the visualization(default: output.html)"
trace_plot_a.add_argument("-o", "--output", default="output.html", help=help)
if cmd == "trace_plot":
help = "visualize change to segments rather than individual allocations"
trace_plot_a.add_argument(
"-s", "--segments", action="store_true", help=help
)
help = (
"filter out allocation-free pairs to only visualize the allocations that are not freed yet;"
"useful to reduce the number of events for large traces for debugging OOM"
)
trace_plot_a.add_argument(
"-f", "--filter_freed", action="store_true", help=help
)
args = parser.parse_args()
def _read(name):
if name == "-":
data = pickle.load(sys.stdin.buffer)
else:
with open(name, "rb") as f:
data = pickle.load(f)
if isinstance(data, list): # segments only...
data = {"segments": data, "traces": []}
return data
def _write(name, data):
with open(name, "w") as f:
f.write(data)
if args.action == "segments":
data = _read(args.input)
_write(args.output, segments(data))
elif args.action == "memory":
data = _read(args.input)
_write(args.output, memory(data))
elif args.action == "stats":
data = _read(args.input)
print(segsum(data))
elif args.action == "trace":
data = _read(args.input)
print(trace(data))
elif args.action == "compare":
before = _read(args.before)
after = _read(args.after)
_write(args.output, compare(before, after))
elif args.action == "trace_plot":
data = _read(args.input)
_write(
args.output,
trace_plot(
data,
device=args.device,
plot_segments=args.segments,
filter_freed=args.filter_freed,
),
)
elif args.action == "segment_plot":
data = _read(args.input)
_write(args.output, segment_plot(data, device=args.device))
@@ -0,0 +1,25 @@
import torch
def pin_memory(data_ptr: int, size: int) -> None:
cudart = torch.cuda.cudart()
succ = int(
cudart.cudaHostRegister(
data_ptr,
size,
1, # lines up with 'cudaHostRegisterPortable'
)
)
if succ != 0:
raise RuntimeError(
f"Registering memory failed with cudaError: {succ}."
" It's possible that this is an asynchronous error raised from a previous cuda operation."
" Consider launching with CUDA_LAUNCH_BLOCKING=1 to debug."
)
def unpin_memory(data_ptr: int) -> None:
succ = int(torch.cuda.cudart().cudaHostUnregister(data_ptr))
if succ != 0:
raise AssertionError(f"Unpinning shared memory failed with error-code: {succ}")
@@ -0,0 +1,670 @@
# mypy: allow-untyped-defs
r"""
This module introduces CUDA Sanitizer, a tool for detecting synchronization errors between kernels ran on different streams.
It stores information on accesses to tensors to determine if they are synchronized
or not. When enabled in a python program and a possible data race is detected, a
detailed warning will be printed and the program will exit.
It can be enabled either by importing this module and calling
:func:`enable_cuda_sanitizer()` or by exporting the ``TORCH_CUDA_SANITIZER``
environment variable.
"""
import enum
import functools
import inspect
import io
import logging
import re
import sys
import textwrap
import traceback
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any, TypeVar
import torch
import torch.cuda._gpu_trace as gpu_trace
from torch.utils import _pytree as pytree
from torch.utils._python_dispatch import TorchDispatchMode
aten = torch.ops.aten
DEFAULT_STREAM_ID = 0
TK = TypeVar("TK")
TVa = TypeVar("TVa")
TVb = TypeVar("TVb")
DataPtr = int
StreamId = int
EventId = int
SeqNum = int
logger = logging.getLogger(__name__)
# Note that this is only factories that take Tensor as input as they are
# the ones we care about.
FACTORY_FUNCTION_REGEX = re.compile("(new_.*|.*_like)")
class AccessType(enum.Enum):
READ = enum.auto()
WRITE = enum.auto()
def __str__(self):
return "reading from" if self is AccessType.READ else "writing to"
@dataclass
class Access:
r"""Stores information about a single access to a tensor by a kernel.
Args:
type: either AccessType.READ or AccessType.Write.
seq_num: the sequential number of the kernel performing the access.
stream: the stream id of the stream executing the kernel.
operator: the schema of the launched kernel, which lists the
arguments and return type.
aliases: the arguments in the schema this access corresponds to.
is_output: Whether the tensor was an output of the kernel.
stack_trace: the stack summary object captured during access.
"""
type: AccessType
seq_num: SeqNum
stream: StreamId
operator: str
aliases: list[str]
is_output: bool
stack_trace: traceback.StackSummary
class SynchronizationError(Exception):
"""Base class for errors detected by CUDA Sanitizer."""
class UnsynchronizedAccessError(SynchronizationError):
"""Stores information about two unsynchronized accesses to one data pointer."""
def __init__(
self,
data_ptr: DataPtr,
allocation_stack_trace: traceback.StackSummary | None,
current_access: Access,
previous_access: Access,
):
self.data_ptr = data_ptr
self.allocation_stack_trace = allocation_stack_trace
self.current_access = current_access
self.previous_access = previous_access
def __str__(self):
def format_access(access: Access):
message.write(f"{access.operator}\n{access.type}")
if access.aliases:
message.write(" argument(s) " + ", ".join(access.aliases))
if access.is_output:
message.write(", and to")
if access.is_output:
message.write(" the output")
message.write(
f"\nWith stack trace:\n{''.join(access.stack_trace.format())}\n"
)
with io.StringIO() as message:
message.write(
textwrap.dedent(
f"""\
============================
CSAN detected a possible data race on tensor with data pointer {self.data_ptr}
Access by stream {self.current_access.stream} during kernel:
"""
)
)
format_access(self.current_access)
message.write(
f"Previous access by stream {self.previous_access.stream} during kernel:\n"
)
format_access(self.previous_access)
if self.allocation_stack_trace:
message.write(
"Tensor was allocated with stack trace:\n"
f"{''.join(self.allocation_stack_trace.format())}"
)
else:
message.write("Trace for tensor allocation not found.")
return message.getvalue()
class CUDASanitizerErrors(Exception):
"""Wrapper class for errors reported by CUDA Sanitizer."""
def __init__(self, errors: list[SynchronizationError]):
self.errors = errors
def __str__(self):
return f"detected {len(self.errors)} errors"
@dataclass
class TensorInfo:
r"""Stores information about a single tensor and recent accesses to it.
Args:
allocation_stack_trace: the stack summary object captured during tensor
allocation. Can be ``None`` if the allocation wasn't caught by CSAN.
reads: list of read accesses to the tensor that were performed since
the last write.
write: the last write access to the tensor.
"""
allocation_stack_trace: traceback.StackSummary | None
reads: list[Access] = field(default_factory=list)
write: Access | None = None
class _TensorsAccessed:
def __init__(self) -> None:
self.accesses: dict[DataPtr, TensorInfo] = {}
def ensure_tensor_exists(self, data_ptr: DataPtr) -> None:
if data_ptr not in self.accesses:
logger.info(
"Found tensor with pointer: %s, but no matching tensor "
"allocation in the trace. Backfilling the trace now. "
"Perhaps the sanitizer was enabled after some torch operations?",
data_ptr,
)
self.create_tensor(data_ptr, None)
def ensure_tensor_does_not_exist(self, data_ptr: DataPtr) -> None:
if data_ptr in self.accesses:
logger.info(
"Found duplicate tensor allocation in the trace for tensor with "
"pointer: %s. Assuming the trace for tensor deallocation "
"wasn't caught and backfilling it now. "
"Perhaps the sanitizer was enabled after some torch operations?",
data_ptr,
)
self.delete_tensor(data_ptr)
def create_tensor(
self, data_ptr: DataPtr, stack_trace: traceback.StackSummary | None
) -> None:
self.accesses[data_ptr] = TensorInfo(stack_trace)
def delete_tensor(self, data_ptr: DataPtr) -> None:
del self.accesses[data_ptr]
def were_there_reads_since_last_write(self, data_ptr: DataPtr) -> bool:
return bool(self.accesses[data_ptr].reads)
def get_allocation_stack_trace(
self, data_ptr: DataPtr
) -> traceback.StackSummary | None:
return self.accesses[data_ptr].allocation_stack_trace
def get_write(self, data_ptr: DataPtr) -> Access | None:
return self.accesses[data_ptr].write
def get_reads(self, data_ptr: DataPtr) -> list[Access]:
return self.accesses[data_ptr].reads
def add_read(self, data_ptr: DataPtr, access: Access) -> None:
self.accesses[data_ptr].reads.append(access)
def set_write(self, data_ptr: DataPtr, access: Access) -> None:
self.accesses[data_ptr].write = access
self.accesses[data_ptr].reads = []
class StreamSynchronizations:
def __init__(self) -> None:
self.current_sync_states: dict[StreamId, dict[StreamId, SeqNum]] = {}
self.recorded_sync_states: dict[EventId, dict[StreamId, SeqNum]] = {}
self.host_sync_state: dict[StreamId, SeqNum] = {}
self.create_stream(DEFAULT_STREAM_ID)
def _ensure_stream_exists(self, stream: StreamId) -> None:
if stream not in self.current_sync_states:
logger.info(
"Found Stream with id: %s, but no matching stream "
"creation in the trace. Backfilling the trace now. "
"Perhaps the sanitizer was enabled after some torch operations?",
stream,
)
self.create_stream(stream)
def _ensure_event_exists(self, event: EventId) -> None:
if event not in self.recorded_sync_states:
logger.info(
"Found Event with id: %s, but no matching event "
"creation in the trace. Backfilling the trace now. "
"Perhaps the sanitizer was enabled after some torch operations?",
event,
)
self.create_event(event)
def _ensure_event_does_not_exist(self, event: EventId) -> None:
if event in self.recorded_sync_states:
logger.info(
"Found duplicate event creation in the trace for event with "
"id: %s. Assuming the trace for event deletion wasn't caught "
"and backfilling it now. "
"Perhaps the sanitizer was enabled after some torch operations?",
event,
)
self.delete_event(event)
def create_stream(self, stream: StreamId) -> None:
if stream in self.current_sync_states:
logger.info(
"Found duplicate Stream creation in the trace for Stream with "
"id: %s. PyTorch Streams are only created once, so this "
"trace entry is ignored.",
stream,
)
else:
self.host_sync_state[stream] = 0
self.current_sync_states[stream] = self.host_sync_state.copy()
def create_event(self, event: EventId) -> None:
self._ensure_event_does_not_exist(event)
self.recorded_sync_states[event] = {}
def delete_event(self, event: EventId) -> None:
self._ensure_event_exists(event)
del self.recorded_sync_states[event]
def update_seq_num(self, stream: StreamId, seq_num: SeqNum) -> None:
self._ensure_stream_exists(stream)
self.current_sync_states[stream][stream] = seq_num
def record_state(self, event: EventId, stream: StreamId) -> None:
self._ensure_event_exists(event)
self._ensure_stream_exists(stream)
self.recorded_sync_states[event] = self.current_sync_states[stream].copy()
def _state_wait_for_other(
self, state: dict[StreamId, SeqNum], other: dict[StreamId, SeqNum]
) -> None:
for stream, seq_num in other.items():
state[stream] = max(state.get(stream, -1), seq_num)
def stream_wait_for_event(self, stream: StreamId, event: EventId) -> None:
self._ensure_stream_exists(stream)
self._ensure_event_exists(event)
self._state_wait_for_other(
self.current_sync_states[stream], self.recorded_sync_states[event]
)
def all_streams_wait_for_event(self, event: EventId) -> None:
self._ensure_event_exists(event)
for stream in self.current_sync_states:
self.stream_wait_for_event(stream, event)
self._state_wait_for_other(
self.host_sync_state, self.recorded_sync_states[event]
)
def all_streams_wait_for_stream(self, stream: StreamId) -> None:
self._ensure_stream_exists(stream)
for state in self.current_sync_states.values():
self._state_wait_for_other(state, self.current_sync_states[stream])
self._state_wait_for_other(
self.host_sync_state, self.current_sync_states[stream]
)
def sync_all_streams(self) -> None:
for stream, state in self.current_sync_states.items():
self.host_sync_state[stream] = state[stream]
for state in self.current_sync_states.values():
self._state_wait_for_other(state, self.host_sync_state)
def is_ordered_after(
self, current_stream: StreamId, seq_num: SeqNum, other_stream: StreamId
) -> bool:
self._ensure_stream_exists(current_stream)
self._ensure_stream_exists(other_stream)
return seq_num <= self.current_sync_states[current_stream].get(other_stream, -1)
class EventHandler:
"""Analyzes CSAN trace for synchronization errors.
Stores information on each stream's synchronizations with other streams as well
as tensor accesses to determine whether a given kernel launch might cause a
data race.
"""
def __init__(self) -> None:
self.tensors_accessed = _TensorsAccessed()
self.syncs = StreamSynchronizations()
self.seq_num: SeqNum = 0
def _handle_kernel_launch(
self,
stream: StreamId,
read_only: set[DataPtr],
read_write: set[DataPtr],
outputs: set[DataPtr],
operator: str,
tensor_aliases: dict[int, list[str]],
) -> list[SynchronizationError]:
def check_conflict(
data_ptr: DataPtr, current_access: Access, previous_access: Access | None
) -> None:
if previous_access is None:
return
if not self.syncs.is_ordered_after(
current_access.stream, previous_access.seq_num, previous_access.stream
):
error_list.append(
UnsynchronizedAccessError(
data_ptr,
self.tensors_accessed.get_allocation_stack_trace(data_ptr),
current_access,
previous_access,
)
)
error_list: list[SynchronizationError] = []
self.seq_num += 1
self.syncs.update_seq_num(stream, self.seq_num)
stack_trace = traceback.StackSummary.extract(
traceback.walk_stack(inspect.currentframe()), lookup_lines=False
)
# The stack trace generated in this way is in the inverse order, so it must be
# reversed.
stack_trace.reverse()
for data_ptr in read_only:
self.tensors_accessed.ensure_tensor_exists(data_ptr)
current_access = Access(
AccessType.READ,
self.seq_num,
stream,
operator,
tensor_aliases[data_ptr],
data_ptr in outputs,
stack_trace,
)
check_conflict(
data_ptr, current_access, self.tensors_accessed.get_write(data_ptr)
)
self.tensors_accessed.add_read(data_ptr, current_access)
for data_ptr in read_write:
self.tensors_accessed.ensure_tensor_exists(data_ptr)
current_access = Access(
AccessType.WRITE,
self.seq_num,
stream,
operator,
tensor_aliases[data_ptr],
data_ptr in outputs,
stack_trace,
)
if self.tensors_accessed.were_there_reads_since_last_write(data_ptr):
for previous_access in self.tensors_accessed.get_reads(data_ptr):
check_conflict(data_ptr, current_access, previous_access)
else:
check_conflict(
data_ptr, current_access, self.tensors_accessed.get_write(data_ptr)
)
self.tensors_accessed.set_write(data_ptr, current_access)
return error_list
def _handle_event_creation(self, event: EventId) -> None:
self.syncs.create_event(event)
def _handle_event_deletion(self, event: EventId) -> None:
self.syncs.delete_event(event)
def _handle_event_record(self, event: EventId, stream: StreamId) -> None:
self.syncs.record_state(event, stream)
def _handle_event_wait(self, event: EventId, stream: StreamId) -> None:
self.syncs.stream_wait_for_event(stream, event)
def _handle_memory_allocation(self, data_ptr: DataPtr) -> None:
self.tensors_accessed.ensure_tensor_does_not_exist(data_ptr)
stack_trace = traceback.StackSummary.extract(
traceback.walk_stack(inspect.currentframe()), lookup_lines=False
)
# The stack trace generated in this way is in the inverse order, so it must be
# reversed.
stack_trace.reverse()
self.tensors_accessed.create_tensor(
data_ptr,
stack_trace,
)
def _handle_memory_deallocation(self, data_ptr: DataPtr) -> None:
self.tensors_accessed.ensure_tensor_exists(data_ptr)
self.tensors_accessed.delete_tensor(data_ptr)
def _handle_stream_creation(self, stream: StreamId) -> None:
self.syncs.create_stream(stream)
def _handle_device_synchronization(self) -> None:
self.syncs.sync_all_streams()
def _handle_stream_synchronization(self, stream: StreamId) -> None:
self.syncs.all_streams_wait_for_stream(stream)
def _handle_event_synchronization(self, event: EventId) -> None:
self.syncs.all_streams_wait_for_event(event)
def zip_by_key(a: dict[TK, TVa], b: dict[TK, TVb]) -> Iterator[tuple[TK, TVa, TVb]]:
for arg, value in a.items():
if arg in b:
yield arg, value, b[arg]
def zip_arguments(
schema: torch.FunctionSchema, args: tuple[Any, ...], kwargs: dict[str, Any]
) -> Iterator[tuple[torch.Argument, Any]]:
schema_args = schema.arguments[: len(args)]
schema_kwargs = {arg.name: arg for arg in schema.arguments[len(args) :]}
yield from zip(schema_args, args)
for _, argument, value in zip_by_key(schema_kwargs, kwargs):
yield (argument, value)
class ArgumentHandler:
def __init__(self) -> None:
self.dataptrs_read: set[DataPtr] = set()
self.dataptrs_written: set[DataPtr] = set()
self.tensor_aliases: dict[DataPtr, list[str]] = {}
self.outputs: set[DataPtr] = set()
def _handle_argument(
self,
value: Any,
is_write: bool,
metadata_only: bool,
name: str | None = None,
is_output: bool = False,
) -> None:
if isinstance(value, torch.Tensor) and value.is_cuda:
# data_ptr() is preferred, but distinguish Tensors with null data_ptr()
# otherwise two empty Tensors could incorrectly match as a conflict
data_ptr = value.data_ptr() if value.data_ptr() else id(value)
if is_write:
self.dataptrs_written.add(data_ptr)
elif not metadata_only:
self.dataptrs_read.add(data_ptr)
self.tensor_aliases.setdefault(data_ptr, [])
if name is not None:
self.tensor_aliases[data_ptr].append(name)
if is_output:
self.outputs.add(data_ptr)
def parse_inputs(
self,
schema: torch.FunctionSchema,
args: tuple[Any, ...],
kwargs: dict[str, Any],
*,
is_factory: bool,
) -> None:
for argument, value in zip_arguments(schema, args, kwargs):
is_write = argument.alias_info is not None and argument.alias_info.is_write
# A change is metadata only if it is a view or a factory function that
# reads only metadata
metadata_only = is_factory or (
argument.alias_info is not None and not argument.alias_info.is_write
)
pytree.tree_map_(
functools.partial(
self._handle_argument,
is_write=is_write,
name=argument.name,
metadata_only=metadata_only,
),
value,
)
def parse_outputs(
self, schema: torch.FunctionSchema, outputs: Any, *, is_factory: bool
) -> None:
for res, value in zip(schema.returns, (outputs,)):
metadata_only = is_factory or (
res.alias_info is not None and not res.alias_info.is_write
)
pytree.tree_map_(
functools.partial(
self._handle_argument,
is_write=not metadata_only,
is_output=True,
metadata_only=metadata_only,
),
value,
)
class CUDASanitizerDispatchMode(TorchDispatchMode):
def __init__(self) -> None:
self.event_handler = EventHandler()
torch._C._activate_gpu_trace()
gpu_trace.register_callback_for_event_creation(
self.event_handler._handle_event_creation
)
gpu_trace.register_callback_for_event_deletion(
self.event_handler._handle_event_deletion
)
gpu_trace.register_callback_for_event_record(
self.event_handler._handle_event_record
)
gpu_trace.register_callback_for_event_wait(
self.event_handler._handle_event_wait
)
gpu_trace.register_callback_for_memory_allocation(
self.event_handler._handle_memory_allocation
)
gpu_trace.register_callback_for_memory_deallocation(
self.event_handler._handle_memory_deallocation
)
gpu_trace.register_callback_for_stream_creation(
self.event_handler._handle_stream_creation
)
gpu_trace.register_callback_for_device_synchronization(
self.event_handler._handle_device_synchronization
)
gpu_trace.register_callback_for_stream_synchronization(
self.event_handler._handle_stream_synchronization
)
gpu_trace.register_callback_for_event_synchronization(
self.event_handler._handle_event_synchronization
)
def __torch_dispatch__(self, func, types, args=(), kwargs=None):
if kwargs is None:
kwargs = {}
# record_stream is not a kernel dispatch, skip it
if func is aten.record_stream.default:
return func(*args, **kwargs)
is_factory = bool(FACTORY_FUNCTION_REGEX.match(func._schema.name))
argument_handler = ArgumentHandler()
argument_handler.parse_inputs(func._schema, args, kwargs, is_factory=is_factory)
outputs = func(*args, **kwargs)
argument_handler.parse_outputs(func._schema, outputs, is_factory=is_factory)
errors = self.event_handler._handle_kernel_launch(
torch.cuda.current_stream().cuda_stream,
argument_handler.dataptrs_read - argument_handler.dataptrs_written,
argument_handler.dataptrs_written,
argument_handler.outputs,
func._schema,
argument_handler.tensor_aliases,
)
if errors:
for error in errors:
print(error, file=sys.stderr)
raise CUDASanitizerErrors(errors)
return outputs
class CUDASanitizer:
"""Manages the lifetime of a CUDASanitizer dispatch mode object.
The CUDASanitizer class wraps the entering/exiting functions of the dispatch mode
context manager in the enable function/destructor, respectively. This is to
explicitly set the lifetime of the dispatch mode object to that of the application.
This approach was deemed more elegant than using the atexit module.
"""
def __init__(self) -> None:
self.dispatch = CUDASanitizerDispatchMode()
self.enabled = False
def enable(self):
self.dispatch.__enter__()
self.enabled = True
def disable(self):
self.dispatch.__exit__(None, None, None)
self.enabled = False
def __del__(self):
# Since this object lifetime is linked to the `torch.cuda._sanitizer` python
# module, it often gets deleted as part of the overall `torch` module cleanup
# At that time, depending on CPython version, the torch.* module might be in
# different states of being already cleaned up.
# Similarly other imports might already have been cleaned up so `sys` might
# be already gone as well.
# Skip exiting the mode if it outlived the runtime.
if (sys is not None) and (not sys.is_finalizing()) and self.enabled:
self.disable()
def enable_cuda_sanitizer():
"""Enable CUDA Sanitizer.
The sanitizer will begin to analyze low-level CUDA calls invoked by torch functions
for synchronization errors. All data races found will be printed to the standard
error output along with stack traces of suspected causes. For best results, the
sanitizer should be enabled at the very beginning of the program.
"""
cuda_sanitizer.enable()
cuda_sanitizer = CUDASanitizer()
@@ -0,0 +1,586 @@
import ctypes
import sys
from typing import Any
import torch
try:
from cuda.bindings import ( # pyrefly: ignore[missing-import]
runtime as _cuda_bindings_runtime,
)
_HAS_CUDA_BINDINGS = True
except ImportError:
_cuda_bindings_runtime = None # type: ignore[assignment]
_HAS_CUDA_BINDINGS = False
# The _get_device_index has been moved to torch.utils._get_device_index
from torch._utils import _get_device_index as _torch_get_device_index
def _get_hip_runtime_library() -> ctypes.CDLL:
# If ROCm python packages are available, query the OS-independent absolute
# path to the library provided by those packages, including any version suffix.
# See https://github.com/ROCm/TheRock/blob/main/docs/packaging/python_packaging.md#dynamic-library-resolution
try:
# pyrefly: ignore [import-error, missing-import]
import rocm_sdk
lib = ctypes.CDLL(str(rocm_sdk.find_libraries("amdhip64")[0]))
except (ImportError, IndexError):
if sys.platform == "win32":
lib = ctypes.CDLL(f"amdhip64_{torch.version.hip[0]}.dll")
else: # Unix-based systems
lib = ctypes.CDLL("libamdhip64.so")
lib.cuGetErrorString = lib.hipGetErrorString # type: ignore[attr-defined]
lib.cuModuleLoadData = lib.hipModuleLoadData # type: ignore[attr-defined]
lib.cuModuleGetFunction = lib.hipModuleGetFunction # type: ignore[attr-defined]
lib.cuLaunchKernel = lib.hipModuleLaunchKernel # type: ignore[attr-defined]
lib.cuFuncSetAttribute = lib.hipFuncSetAttribute # type: ignore[attr-defined]
return lib
def _get_cuda_library() -> ctypes.CDLL:
if sys.platform == "win32":
return ctypes.CDLL("nvcuda.dll")
else: # Unix-based systems
return ctypes.CDLL("libcuda.so.1")
# Load GPU driver runtime
def _get_gpu_runtime_library() -> ctypes.CDLL:
if torch.version.hip:
return _get_hip_runtime_library()
else:
return _get_cuda_library()
# Helper: check CUDA errors
def _check_cuda(result: int) -> None:
if result == 0:
return
err_str = ctypes.c_char_p()
libcuda = _get_gpu_runtime_library() # Get reference to CUDA library
libcuda.cuGetErrorString(result, ctypes.byref(err_str))
error_message = (
err_str.value.decode() if err_str.value is not None else "Unknown CUDA error"
)
raise RuntimeError(f"CUDA error: {error_message}")
def _check_cuda_bindings(result: Any) -> Any:
"""Check a cuda.bindings (cuda-python) call result for errors.
All cuda.bindings runtime calls return ``(error, *outputs)``. This
helper unpacks the tuple, raises on non-success, and returns the
outputs (``None`` for zero outputs, scalar for one, tuple otherwise).
"""
if not _HAS_CUDA_BINDINGS:
raise RuntimeError("cuda.bindings is not available")
err, *out = result
if (
err
!= _cuda_bindings_runtime.cudaError_t.cudaSuccess # pyrefly: ignore[missing-attribute]
):
_, err_str = (
_cuda_bindings_runtime.cudaGetErrorString( # pyrefly: ignore[missing-attribute]
err
)
)
if isinstance(err_str, bytes):
err_str = err_str.decode()
raise RuntimeError(f"CUDA error: {err} ({err_str})")
if len(out) == 0:
return None
if len(out) == 1:
return out[0]
return out
def _get_hiprtc_library() -> ctypes.CDLL:
try:
# pyrefly: ignore [import-error, missing-import]
import rocm_sdk
lib = ctypes.CDLL(str(rocm_sdk.find_libraries("hiprtc")[0]))
except (ImportError, IndexError):
if sys.platform == "win32":
version_str = "".join(
["0", torch.version.hip[0], "0", torch.version.hip[2]]
)
lib = ctypes.CDLL(f"hiprtc{version_str}.dll")
else:
lib = ctypes.CDLL("libhiprtc.so")
# Provide aliases for HIP RTC functions to match NVRTC API
lib.nvrtcGetErrorString = lib.hiprtcGetErrorString # type: ignore[attr-defined]
lib.nvrtcCreateProgram = lib.hiprtcCreateProgram # type: ignore[attr-defined]
lib.nvrtcDestroyProgram = lib.hiprtcDestroyProgram # type: ignore[attr-defined]
lib.nvrtcCompileProgram = lib.hiprtcCompileProgram # type: ignore[attr-defined]
lib.nvrtcGetCUBINSize = lib.hiprtcGetCodeSize # type: ignore[attr-defined]
lib.nvrtcGetCUBIN = lib.hiprtcGetCode # type: ignore[attr-defined]
lib.nvrtcGetProgramLogSize = lib.hiprtcGetProgramLogSize # type: ignore[attr-defined]
lib.nvrtcGetProgramLog = lib.hiprtcGetProgramLog # type: ignore[attr-defined]
lib.nvrtcAddNameExpression = lib.hiprtcAddNameExpression # type: ignore[attr-defined]
lib.nvrtcGetLoweredName = lib.hiprtcGetLoweredName # type: ignore[attr-defined]
return lib
def _get_nvrtc_library() -> ctypes.CDLL:
major_version = int(torch.version.cuda.split(".")[0]) # type: ignore[union-attr]
if sys.platform == "win32":
nvrtc_libs = [
f"nvrtc64_{major_version}0_0.dll",
]
else:
nvrtc_libs = [
f"libnvrtc.so.{major_version}",
"libnvrtc.so", # Fallback to unversioned
]
for lib_name in nvrtc_libs:
try:
return ctypes.CDLL(lib_name)
except OSError:
continue
raise OSError("Could not find any NVRTC library")
def _get_gpu_rtc_library() -> ctypes.CDLL:
# Since PyTorch already loads the GPU RTC library, we can use the system library
# which should be compatible with PyTorch's version
if torch.version.hip:
return _get_hiprtc_library()
else:
return _get_nvrtc_library()
def _get_gpu_rtc_compatible_flags() -> list[str]:
"""
Get HIPCC/NVCC flags that are compatible with NVRTC compilation.
Returns:
List of HIPCC/NVCC flags that can be safely used with NVRTC.
"""
from torch.utils.cpp_extension import COMMON_HIPCC_FLAGS, COMMON_NVCC_FLAGS
nvrtc_unsupported_flags = {
"--expt-relaxed-constexpr",
}
# Filter out unsupported flags
compatible_flags = [
flag for flag in COMMON_NVCC_FLAGS if flag not in nvrtc_unsupported_flags
]
if torch.version.hip:
compatible_flags.extend(COMMON_HIPCC_FLAGS)
return compatible_flags
def _nvrtc_compile(
kernel_source: str,
kernel_name: str,
compute_capability: str | None = None,
cuda_include_dirs: list | None = None,
nvcc_options: list | None = None,
auto_pch: bool = False,
) -> tuple[bytes, str]:
"""
Compiles a CUDA kernel using NVRTC and returns the PTX code.
Args:
kernel_source (str): The CUDA kernel source code as a string
kernel_name (str): The name of the kernel function to compile
compute_capability (str, None): The compute capability to target (e.g., "86").
If None, will detect from current device.
cuda_include_dirs (list, None): List of directories containing CUDA headers
nvcc_options (list, None): Additional options to pass to NVRTC
auto_pch (bool): Enable automatic precompiled headers (CUDA 12.8+)
Returns:
Tuple[bytes, str]: The compiled PTX code and mangled kernel name
"""
# Ensure CUDA is initialized
import torch.cuda
# Load NVRTC library
libnvrtc = _get_gpu_rtc_library()
# NVRTC constants
NVRTC_SUCCESS = 0
# Helper: check NVRTC errors
def check_nvrtc(result: int) -> None:
if result != NVRTC_SUCCESS:
err_str = ctypes.c_char_p()
libnvrtc.nvrtcGetErrorString(result, ctypes.byref(err_str))
error_message = (
err_str.value.decode()
if err_str.value is not None
else "Unknown CUDA error"
)
raise RuntimeError(f"CUDA error: {error_message}")
# Convert source to bytes
source_bytes = kernel_source.encode("utf-8")
# Get compute capability if not provided
if compute_capability is None:
props = torch.cuda.get_device_properties(torch.cuda.current_device())
if torch.version.hip:
compute_capability = f"{props.gcnArchName}"
else:
compute_capability = f"{props.major}{props.minor}"
# Prepare compilation options
options = []
if torch.version.hip:
options.append(f"--offload-arch={compute_capability}".encode())
else:
options.append(f"--gpu-architecture=sm_{compute_capability}".encode())
# Auto-detect and add CUDA include paths
from torch.utils.cpp_extension import include_paths
cuda_include_paths = include_paths("cuda")
for cuda_path in cuda_include_paths:
options.append(f"-I{cuda_path}".encode())
# Add custom include directories
if cuda_include_dirs:
for directory in cuda_include_dirs:
options.append(f"-I{directory}".encode())
# Enable automatic precompiled headers (CUDA 12.8+)
if auto_pch:
if str(torch.version.cuda) < "12.8":
raise AssertionError(f"PCH requires CUDA 12.8+, got {torch.version.cuda}")
if nvcc_options is None:
nvcc_options = []
nvcc_options.append("--pch")
# Add custom NVCC options
if nvcc_options:
for option in nvcc_options:
options.append(option.encode("utf-8"))
nvrtc_compatible_flags = _get_gpu_rtc_compatible_flags()
options.extend([flag.encode("utf-8") for flag in nvrtc_compatible_flags])
# Convert options to C array
num_options = len(options)
options_array = (ctypes.c_char_p * num_options)(*options)
# Create program
prog = ctypes.c_void_p()
check_nvrtc(
libnvrtc.nvrtcCreateProgram(
ctypes.byref(prog),
source_bytes,
f"{kernel_name}.cu".encode(),
0,
None,
None,
)
)
# Add kernel name, which can be a template expression
c_kernel_name = kernel_name.encode("utf-8")
check_nvrtc(libnvrtc.nvrtcAddNameExpression(prog, c_kernel_name))
# Compile program
res = libnvrtc.nvrtcCompileProgram(prog, num_options, options_array)
# Handle compilation errors
if res != NVRTC_SUCCESS:
# Get log
log_size = ctypes.c_size_t()
libnvrtc.nvrtcGetProgramLogSize(prog, ctypes.byref(log_size))
log = ctypes.create_string_buffer(log_size.value)
libnvrtc.nvrtcGetProgramLog(prog, log)
raise RuntimeError(f"Kernel compilation failed:\n{log.value.decode()}")
# Get binary
binary_size = ctypes.c_size_t()
check_nvrtc(libnvrtc.nvrtcGetCUBINSize(prog, ctypes.byref(binary_size)))
binary = ctypes.create_string_buffer(binary_size.value)
check_nvrtc(libnvrtc.nvrtcGetCUBIN(prog, binary))
# Get mangled name
c_mangled_name = ctypes.c_char_p()
check_nvrtc(
libnvrtc.nvrtcGetLoweredName(prog, c_kernel_name, ctypes.byref(c_mangled_name))
)
if c_mangled_name.value is not None:
mangled_name = c_mangled_name.value.decode() # make a copy
else:
mangled_name = ""
libnvrtc.nvrtcDestroyProgram(ctypes.byref(prog))
# For some reason, ".value" causes the string to be truncated,
# likely due to the presence of '\0' in the string. So we use .raw instead.
return binary.raw, mangled_name
class _CudaModule:
def __init__(self, module: ctypes.c_void_p) -> None:
self._module = module
self._kernels: dict[str, _CudaKernel] = {}
def __getattr__(self, name: str) -> "_CudaKernel":
if name in self._kernels:
return self._kernels[name]
# Import the CUDA library inside the method
# pyrefly: ignore [missing-module-attribute]
from torch.cuda._utils import _get_gpu_runtime_library
libcuda = _get_gpu_runtime_library()
func = ctypes.c_void_p()
try:
_check_cuda(
libcuda.cuModuleGetFunction(
ctypes.byref(func), self._module, name.encode("utf-8")
)
)
kernel = _CudaKernel(func, self._module)
self._kernels[name] = kernel
return kernel
except RuntimeError as err:
raise AttributeError(f"No kernel named '{name}' in this module") from err
class _CudaKernel:
"""
Represents a compiled CUDA kernel that can be called with PyTorch tensors.
"""
def __init__(self, func: ctypes.c_void_p, module: ctypes.c_void_p) -> None:
self.func = func
self.module = module
self._max_shared_mem_bytes = 0
def __call__(
self,
grid: tuple[int, int, int] = (1, 1, 1),
block: tuple[int, int, int] = (1, 1, 1),
args: list | None = None,
shared_mem: int = 0,
stream: Any | None = None,
) -> None:
"""
Call the compiled CUDA kernel
Args:
grid (tuple): Grid dimensions (grid_x, grid_y, grid_z)
block (tuple): Block dimensions (block_x, block_y, block_z)
args (list): List of arguments to pass to the kernel.
PyTorch tensor arguments will be automatically converted to pointers.
shared_mem (int): Shared memory size in bytes
stream (torch.cuda.Stream): CUDA stream to use. If None, uses current stream.
"""
import torch
libcuda = torch.cuda._utils._get_gpu_runtime_library()
if not args:
args = []
# Process arguments and convert tensors to pointers
processed_args: list[ctypes.c_void_p] = []
c_args = []
for arg in args:
if isinstance(arg, torch.Tensor):
if not arg.is_cuda and not (arg.is_cpu and arg.is_pinned()):
raise ValueError(
"All tensor arguments must be CUDA tensors or pinned CPU tensors"
)
# Get pointer to tensor data
ptr = ctypes.c_void_p(arg.data_ptr())
processed_args.append(ptr)
c_args.append(ctypes.byref(ptr))
elif isinstance(arg, int):
# Convert integers to C int
c_int = ctypes.c_int(arg)
# Store the C int for reference keeping, not in processed_args
c_args.append(ctypes.byref(c_int))
elif isinstance(arg, float):
# Python floats are doubles - use double by default
c_double = ctypes.c_double(arg)
# Store the C double for reference keeping, not in processed_args
c_args.append(ctypes.byref(c_double))
else:
raise TypeError(f"Unsupported argument type: {type(arg)}")
# Convert to array of void pointers
c_args_array = (ctypes.c_void_p * len(c_args))()
for i, arg in enumerate(c_args):
c_args_array[i] = ctypes.cast(arg, ctypes.c_void_p)
# Get the stream
if stream is None:
# Defer import to avoid circular imports
import torch.cuda
stream = torch.cuda.current_stream()
# Check if kernel requires large shared memory but hasn't been configured
if shared_mem >= 48 * 1024 and (
self._max_shared_mem_bytes == 0 or shared_mem > self._max_shared_mem_bytes
):
configured_msg = (
"not configured"
if self._max_shared_mem_bytes == 0
else f"only {self._max_shared_mem_bytes} bytes configured"
)
raise RuntimeError(
f"Kernel requires {shared_mem} bytes of shared memory (>= 48KB), "
f"but {configured_msg}. "
"Call kernel.set_shared_memory_config(shared_mem) after compilation "
"and before launching the kernel."
)
_check_cuda(
libcuda.cuLaunchKernel(
self.func,
grid[0],
grid[1],
grid[2],
block[0],
block[1],
block[2],
shared_mem,
stream._as_parameter_,
c_args_array,
None,
)
)
def set_shared_memory_config(self, shared_mem_bytes: int) -> None:
if shared_mem_bytes < 48 * 1024:
# No configuration needed for <= 48KB, just update the value
self._max_shared_mem_bytes = shared_mem_bytes
return
libcuda = _get_gpu_runtime_library()
# Get device properties to validate against limits
device_props = torch.cuda.get_device_properties()
# HIP doesn't have shared_memory_per_block_optin in device properties, so we hard-code it here
if torch.version.hip:
# navi, CDNA1-CDNA3 allows a max of 64KB shared memory
# CDNA4 allows a max of 160KB shared memory
max_shared_mem = (
65536 if device_props.gcnArchName != "gfx950" else 160 * 1024
)
else:
max_shared_mem = getattr(
device_props, "shared_memory_per_block_optin", 49152
)
if shared_mem_bytes > max_shared_mem:
raise RuntimeError(
f"Requested shared memory ({shared_mem_bytes} bytes) exceeds "
f"device limit ({max_shared_mem} bytes). "
"Consider reducing block size or shared memory usage."
)
# Set the function attribute once
# https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__TYPES.html
cudaFuncAttributeMaxDynamicSharedMemorySize = 8
_check_cuda(
libcuda.cuFuncSetAttribute(
self.func,
cudaFuncAttributeMaxDynamicSharedMemorySize,
shared_mem_bytes,
)
)
self._max_shared_mem_bytes = shared_mem_bytes
def _cuda_load_module(
ptx: str | bytes, kernel_names: list[str] | None = None
) -> _CudaModule | dict[str, "_CudaKernel"]:
"""
Loads a CUDA module from PTX code and returns a module object that can access kernels.
Args:
ptx (bytes or str): The PTX code to load
kernel_names (list, optional): List of kernel names to extract from the module.
If None, will return a module object with __getattr__.
Returns:
object: If kernel_names is None, returns a module object with __getattr__ to access kernels.
If kernel_names is provided, returns a dict mapping kernel names to _CudaKernel objects.
"""
# Ensure CUDA is initialized
import torch.cuda
# Load CUDA driver library
libcuda = _get_gpu_runtime_library()
# Convert PTX to bytes if it's a string
if isinstance(ptx, str):
ptx = ptx.encode("utf-8")
# Load PTX module
module = ctypes.c_void_p()
# Get the current stream without directly importing torch.cuda at module level
stream = torch.cuda.current_stream()
with stream:
_check_cuda(libcuda.cuModuleLoadData(ctypes.byref(module), ptx))
if not kernel_names:
return _CudaModule(module)
# Return specific kernels
kernels = {}
for name in kernel_names:
func = ctypes.c_void_p()
_check_cuda(
libcuda.cuModuleGetFunction(
ctypes.byref(func), module, name.encode("utf-8")
)
)
kernels[name] = _CudaKernel(func, module)
return kernels
def _get_device_index(
device: Any, optional: bool = False, allow_cpu: bool = False
) -> int:
r"""Get the device index from :attr:`device`, which can be a torch.device object, a Python integer, or ``None``.
If :attr:`device` is a torch.device object, returns the device index if it
is a CUDA device. Note that for a CUDA device without a specified index,
i.e., ``torch.device('cuda')``, this will return the current default CUDA
device if :attr:`optional` is ``True``. If :attr:`allow_cpu` is ``True``,
CPU devices will be accepted and ``-1`` will be returned in this case.
If :attr:`device` is a Python integer, it is returned as is.
If :attr:`device` is ``None``, this will return the current default CUDA
device if :attr:`optional` is ``True``.
"""
if isinstance(device, int):
return device
if isinstance(device, str):
device = torch.device(device)
if isinstance(device, torch.device):
if allow_cpu:
if device.type not in ["cuda", "cpu"]:
raise ValueError(f"Expected a cuda or cpu device, but got: {device}")
elif device.type != "cuda":
raise ValueError(f"Expected a cuda device, but got: {device}")
if not torch.jit.is_scripting():
if isinstance(device, torch.cuda.device):
return device.idx
return _torch_get_device_index(device, optional, allow_cpu)
@@ -0,0 +1,13 @@
# pyrefly: ignore [deprecated]
from .autocast_mode import autocast, custom_bwd, custom_fwd
from .common import amp_definitely_not_available
from .grad_scaler import GradScaler
__all__ = [
"amp_definitely_not_available",
"autocast",
"custom_bwd",
"custom_fwd",
"GradScaler",
]
@@ -0,0 +1,110 @@
# mypy: allow-untyped-defs
import functools
import sys
from typing import Any
from typing_extensions import deprecated
import torch
__all__ = ["autocast", "custom_fwd", "custom_bwd"]
@deprecated(
"`torch.cuda.amp.autocast(args...)` is deprecated. "
"Please use `torch.amp.autocast('cuda', args...)` instead.",
category=FutureWarning,
)
class autocast(torch.amp.autocast_mode.autocast):
r"""See :class:`torch.autocast`.
``torch.cuda.amp.autocast(args...)`` is deprecated. Please use ``torch.amp.autocast("cuda", args...)`` instead.
"""
# TODO: remove this conditional once we stop supporting Python < 3.13
# Prior to Python 3.13, inspect.signature could not retrieve the correct
# signature information for classes decorated with @deprecated (unless
# the __new__ static method was explicitly defined);
#
# However, this issue has been fixed in Python 3.13 and later versions.
if sys.version_info < (3, 13):
def __new__(
cls,
enabled: bool = True,
dtype: torch.dtype = torch.float16,
cache_enabled: bool = True,
):
return super().__new__(cls)
def __init_subclass__(cls):
pass
def __init__(
self,
enabled: bool = True,
dtype: torch.dtype = torch.float16,
cache_enabled: bool = True,
):
if torch._jit_internal.is_scripting():
self._enabled = enabled
self.device = "cuda"
self.fast_dtype = dtype
return
super().__init__(
"cuda", enabled=enabled, dtype=dtype, cache_enabled=cache_enabled
)
def __enter__(self):
if torch._jit_internal.is_scripting():
return self
return super().__enter__()
# TODO: discuss a unified TorchScript-friendly API for autocast
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any): # type: ignore[override]
if torch._jit_internal.is_scripting():
return
return super().__exit__(exc_type, exc_val, exc_tb)
def __call__(self, func):
if torch._jit_internal.is_scripting():
return func
return super().__call__(func)
# Preserved only for BC reasons
@deprecated(
"`torch.cuda.amp.autocast_mode._cast(value, dtype)` is deprecated. "
"Please use `torch.amp.autocast_mode._cast(value, 'cuda', dtype)` instead.",
category=FutureWarning,
)
def _cast(value, dtype):
return torch.amp.autocast_mode._cast(value, "cuda", dtype)
@deprecated(
"`torch.cuda.amp.custom_fwd(args...)` is deprecated. "
"Please use `torch.amp.custom_fwd(args..., device_type='cuda')` instead.",
category=FutureWarning,
)
def custom_fwd(fwd=None, *, cast_inputs=None):
"""
``torch.cuda.amp.custom_fwd(args...)`` is deprecated. Please use
``torch.amp.custom_fwd(args..., device_type='cuda')`` instead.
"""
return functools.partial(torch.amp.custom_fwd, device_type="cuda")(
fwd=fwd, cast_inputs=cast_inputs
)
@deprecated(
"`torch.cuda.amp.custom_bwd(args...)` is deprecated. "
"Please use `torch.amp.custom_bwd(args..., device_type='cuda')` instead.",
category=FutureWarning,
)
def custom_bwd(bwd):
"""
``torch.cuda.amp.custom_bwd(args...)`` is deprecated. Please use
``torch.amp.custom_bwd(args..., device_type='cuda')`` instead.
"""
return functools.partial(torch.amp.custom_bwd, device_type="cuda")(bwd)
@@ -0,0 +1,11 @@
# mypy: allow-untyped-defs
from importlib.util import find_spec
import torch
__all__ = ["amp_definitely_not_available"]
def amp_definitely_not_available():
return not (torch.cuda.is_available() or find_spec("torch_xla"))
@@ -0,0 +1,38 @@
from typing_extensions import deprecated
import torch
# We need to keep this unused import for BC reasons
from torch.amp.grad_scaler import OptState # noqa: F401
__all__ = ["GradScaler"]
class GradScaler(torch.amp.GradScaler):
r"""
See :class:`torch.amp.GradScaler`.
``torch.cuda.amp.GradScaler(args...)`` is deprecated. Please use ``torch.amp.GradScaler("cuda", args...)`` instead.
"""
@deprecated(
"`torch.cuda.amp.GradScaler(args...)` is deprecated. "
"Please use `torch.amp.GradScaler('cuda', args...)` instead.",
category=FutureWarning,
)
def __init__(
self,
init_scale: float = 2.0**16,
growth_factor: float = 2.0,
backoff_factor: float = 0.5,
growth_interval: int = 2000,
enabled: bool = True,
) -> None:
super().__init__(
"cuda",
init_scale=init_scale,
growth_factor=growth_factor,
backoff_factor=backoff_factor,
growth_interval=growth_interval,
enabled=enabled,
)
@@ -0,0 +1,19 @@
# The functions here have been moved to torch.nn.parallel.comm
from torch.nn.parallel.comm import (
broadcast,
broadcast_coalesced,
gather,
reduce_add,
reduce_add_coalesced,
scatter,
)
__all__ = [
"broadcast",
"broadcast_coalesced",
"reduce_add",
"reduce_add_coalesced",
"scatter",
"gather",
]
@@ -0,0 +1,177 @@
import os
import sys
from collections.abc import Callable
import torch
from torch.types import Storage
__all__: list[str] = [
"gds_register_buffer",
"gds_deregister_buffer",
"GdsFile",
]
def _dummy_fn(name: str) -> Callable:
def fn(*args, **kwargs): # type: ignore[no-untyped-def]
raise RuntimeError(f"torch._C.{name} is not supported on this platform")
return fn
if not hasattr(torch._C, "_gds_register_buffer"):
if hasattr(torch._C, "_gds_deregister_buffer"):
raise AssertionError(
"_gds_deregister_buffer exists but _gds_register_buffer does not"
)
if hasattr(torch._C, "_gds_register_handle"):
raise AssertionError(
"_gds_register_handle exists but _gds_register_buffer does not"
)
if hasattr(torch._C, "_gds_deregister_handle"):
raise AssertionError(
"_gds_deregister_handle exists but _gds_register_buffer does not"
)
if hasattr(torch._C, "_gds_load_storage"):
raise AssertionError(
"_gds_load_storage exists but _gds_register_buffer does not"
)
if hasattr(torch._C, "_gds_save_storage"):
raise AssertionError(
"_gds_save_storage exists but _gds_register_buffer does not"
)
# Define functions
torch._C.__dict__["_gds_register_buffer"] = _dummy_fn("_gds_register_buffer")
torch._C.__dict__["_gds_deregister_buffer"] = _dummy_fn("_gds_deregister_buffer")
torch._C.__dict__["_gds_register_handle"] = _dummy_fn("_gds_register_handle")
torch._C.__dict__["_gds_deregister_handle"] = _dummy_fn("_gds_deregister_handle")
torch._C.__dict__["_gds_load_storage"] = _dummy_fn("_gds_load_storage")
torch._C.__dict__["_gds_save_storage"] = _dummy_fn("_gds_save_storage")
def gds_register_buffer(s: Storage) -> None:
"""Registers a storage on a CUDA device as a cufile buffer.
Example::
>>> # xdoctest: +SKIP("gds filesystem requirements")
>>> src = torch.randn(1024, device="cuda")
>>> s = src.untyped_storage()
>>> gds_register_buffer(s)
Args:
s (Storage): Buffer to register.
"""
torch._C._gds_register_buffer(s)
def gds_deregister_buffer(s: Storage) -> None:
"""Deregisters a previously registered storage on a CUDA device as a cufile buffer.
Example::
>>> # xdoctest: +SKIP("gds filesystem requirements")
>>> src = torch.randn(1024, device="cuda")
>>> s = src.untyped_storage()
>>> gds_register_buffer(s)
>>> gds_deregister_buffer(s)
Args:
s (Storage): Buffer to register.
"""
torch._C._gds_deregister_buffer(s)
class GdsFile:
r"""Wrapper around cuFile.
cuFile is a file-like interface to the GPUDirect Storage (GDS) API.
See the `cufile docs <https://docs.nvidia.com/gpudirect-storage/api-reference-guide/index.html#cufile-io-api>`_
for more details.
Args:
filename (str): Name of the file to open.
flags (int): Flags to pass to ``os.open`` when opening the file. ``os.O_DIRECT`` will
be added automatically.
Example::
>>> # xdoctest: +SKIP("gds filesystem requirements")
>>> src1 = torch.randn(1024, device="cuda")
>>> src2 = torch.randn(2, 1024, device="cuda")
>>> file = torch.cuda.gds.GdsFile(f, os.O_CREAT | os.O_RDWR)
>>> file.save_storage(src1.untyped_storage(), offset=0)
>>> file.save_storage(src2.untyped_storage(), offset=src1.nbytes)
>>> dest1 = torch.empty(1024, device="cuda")
>>> dest2 = torch.empty(2, 1024, device="cuda")
>>> file.load_storage(dest1.untyped_storage(), offset=0)
>>> file.load_storage(dest2.untyped_storage(), offset=src1.nbytes)
>>> torch.equal(src1, dest1)
True
>>> torch.equal(src2, dest2)
True
"""
def __init__(self, filename: str, flags: int):
if sys.platform == "win32":
raise RuntimeError("GdsFile is not supported on this platform.")
self.filename = filename
self.flags = flags
self.fd = os.open(filename, flags | os.O_DIRECT) # type: ignore[attr-defined]
self.handle: int | None = None
self.register_handle()
def __del__(self) -> None:
if self.handle is not None:
self.deregister_handle()
os.close(self.fd)
def register_handle(self) -> None:
"""Registers file descriptor to cuFile Driver.
This is a wrapper around ``cuFileHandleRegister``.
"""
if self.handle is not None:
raise AssertionError("Cannot register a handle that is already registered.")
self.handle = torch._C._gds_register_handle(self.fd)
def deregister_handle(self) -> None:
"""Deregisters file descriptor from cuFile Driver.
This is a wrapper around ``cuFileHandleDeregister``.
"""
if self.handle is None:
raise AssertionError("Cannot deregister a handle that is not registered.")
torch._C._gds_deregister_handle(self.handle)
self.handle = None
def load_storage(self, storage: Storage, offset: int = 0) -> None:
"""Loads data from the file into the storage.
This is a wrapper around ``cuFileRead``. ``storage.nbytes()`` of data
will be loaded from the file at ``offset`` into the storage.
Args:
storage (Storage): Storage to load data into.
offset (int, optional): Offset into the file to start loading from. (Default: 0)
"""
if self.handle is None:
raise AssertionError("Cannot load data from a file that is not registered.")
torch._C._gds_load_storage(self.handle, storage, offset)
def save_storage(self, storage: Storage, offset: int = 0) -> None:
"""Saves data from the storage into the file.
This is a wrapper around ``cuFileWrite``. All bytes of the storage
will be written to the file at ``offset``.
Args:
storage (Storage): Storage to save data from.
offset (int, optional): Offset into the file to start saving to. (Default: 0)
"""
if self.handle is None:
raise AssertionError("Cannot save data to a file that is not registered.")
torch._C._gds_save_storage(self.handle, storage, offset)
@@ -0,0 +1,645 @@
# pylint: disable=useless-parent-delegation
from __future__ import annotations
import gc
import typing
from collections.abc import Callable
from typing import overload, TYPE_CHECKING, TypeAlias, Union
from typing_extensions import ParamSpec, Self, TypeVar
import torch
from torch import Tensor
if TYPE_CHECKING:
# importing _POOL_HANDLE at runtime toplevel causes an import cycle
from torch.cuda import _POOL_HANDLE
from .._utils import _dummy_type
__all__ = [
"is_current_stream_capturing",
"graph_pool_handle",
"CUDAGraph",
"graph",
"make_graphed_callables",
]
_R = TypeVar("_R")
_P = ParamSpec("_P")
if not hasattr(torch._C, "_CudaStreamBase"):
# Define dummy base classes
torch._C.__dict__["_CUDAGraph"] = _dummy_type("_CUDAGraph")
torch._C.__dict__["_graph_pool_handle"] = _dummy_type("_graph_pool_handle")
torch._C.__dict__["_cuda_isCurrentStreamCapturing"] = _dummy_type(
"_cuda_isCurrentStreamCapturing"
)
from torch._C import _cuda_isCurrentStreamCapturing, _CUDAGraph, _graph_pool_handle
def is_current_stream_capturing() -> bool:
r"""Return True if CUDA graph capture is underway on the current CUDA stream, False otherwise.
If a CUDA context does not exist on the current device, returns False without initializing the context.
"""
return _cuda_isCurrentStreamCapturing()
# Python shim helps Sphinx process docstrings more reliably.
def graph_pool_handle() -> _POOL_HANDLE:
r"""Return an opaque token representing the id of a graph memory pool.
See :ref:`Graph memory management<graph-memory-management>`.
.. warning::
This API is in beta and may change in future releases.
"""
return torch.cuda._POOL_HANDLE(_graph_pool_handle())
# Python shim helps Sphinx process docstrings more reliably.
class CUDAGraph(_CUDAGraph):
r"""Wrapper around a CUDA graph.
Arguments:
keep_graph (bool, optional): If ``keep_graph=False``, the
cudaGraphExec_t will be instantiated on GPU at the end of
``capture_end`` and the underlying cudaGraph_t will be
destroyed. Users who want to query or otherwise modify the
underlying cudaGraph_t before instantiation can set
``keep_graph=True`` and access it via ``raw_cuda_graph`` after
``capture_end``. Note that the cudaGraphExec_t will not be
instantiated at the end of ``capture_end`` in this
case. Instead, it will be instantiated via an explicit called
to ``instantiate`` or automatically on the first call to
``replay`` if ``instantiate`` was not already called. Calling
``instantiate`` manually before ``replay`` is recommended to
prevent increased latency on the first call to ``replay``. It
is allowed to modify the raw cudaGraph_t after first calling
``instantiate``, but the user must call ``instantiate`` again
manually to make sure the instantiated graph has these
changes. Pytorch has no means of tracking these changes.
.. warning::
This API is in beta and may change in future releases.
"""
def __new__(cls, keep_graph: bool = False) -> Self:
return super().__new__(cls, keep_graph)
def capture_begin(
self, pool: _POOL_HANDLE | None = None, capture_error_mode: str = "global"
) -> None:
r"""Begin capturing CUDA work on the current stream.
Typically, you shouldn't call ``capture_begin`` yourself.
Use :class:`~torch.cuda.graph` or :func:`~torch.cuda.make_graphed_callables`,
which call ``capture_begin`` internally.
Arguments:
pool (optional): Token (returned by :func:`~torch.cuda.graph_pool_handle` or
:meth:`other_Graph_instance.pool()<torch.cuda.CUDAGraph.pool>`) that hints this graph may share memory
with the indicated pool. See :ref:`Graph memory management<graph-memory-management>`.
capture_error_mode (str, optional): specifies the cudaStreamCaptureMode for the graph capture stream.
Can be "global", "thread_local" or "relaxed". During cuda graph capture, some actions, such as cudaMalloc,
may be unsafe. "global" will error on actions in other threads, "thread_local" will only error for
actions in the current thread, and "relaxed" will not error on these actions. Do NOT change this setting
unless you're familiar with `cudaStreamCaptureMode <https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__STREAM.html#group__CUDART__STREAM_1g9d0535d93a214cbf126835257b16ba85>`_
""" # noqa: B950
super().capture_begin(pool=pool, capture_error_mode=capture_error_mode)
def capture_end(self) -> None:
r"""End CUDA graph capture on the current stream.
After ``capture_end``, ``replay`` may be called on this instance.
Typically, you shouldn't call ``capture_end`` yourself.
Use :class:`~torch.cuda.graph` or :func:`~torch.cuda.make_graphed_callables`,
which call ``capture_end`` internally.
"""
super().capture_end()
def instantiate(self) -> None:
r"""Instantiate the CUDA graph. Will be called by
``capture_end`` if ``keep_graph=False``, or by ``replay`` if
``keep_graph=True`` and ``instantiate`` has not already been
explicitly called. Does not destroy the cudaGraph_t returned
by ``raw_cuda_graph``.
"""
super().instantiate()
def replay(self) -> None:
r"""Replay the CUDA work captured by this graph."""
super().replay()
def reset(self) -> None:
r"""Delete the graph currently held by this instance."""
super().reset()
def pool(self) -> _POOL_HANDLE:
r"""Return an opaque token representing the id of this graph's memory pool.
This id can optionally be passed to another graph's ``capture_begin``,
which hints the other graph may share the same memory pool.
"""
return super().pool()
def enable_debug_mode(self) -> None:
r"""Enable debugging mode for CUDAGraph.debug_dump."""
return super().enable_debug_mode()
def debug_dump(self, debug_path: str) -> None:
r"""
Arguments:
debug_path (required): Path to dump the graph to.
Calls a debugging function to dump the graph if the debugging is
enabled via CUDAGraph.enable_debug_mode()
"""
return super().debug_dump(debug_path)
def raw_cuda_graph(self) -> int:
r"""Returns the underlying cudaGraph_t. ``keep_graph`` must be True.
See the following for APIs for how to manipulate this object: `Graph Managmement <https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__GRAPH.html>`_ and `cuda-python Graph Management bindings <https://nvidia.github.io/cuda-python/cuda-bindings/latest/module/runtime.html#graph-management>`_
""" # noqa: B950
return super().raw_cuda_graph()
def raw_cuda_graph_exec(self) -> int:
r"""Returns the underlying cudaGraphExec_t. ``instantiate`` must have been called if ``keep_graph`` is True, or ``capture_end`` must have been called if ``keep_graph`` is False. If you call ``instantiate()`` after ``raw_cuda_graph_exec()``, the previously returned cudaGraphExec_t will be destroyed. It is your responsibility not to use this object after destruction.
See the following for APIs for how to manipulate this object: `Graph Execution <https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__GRAPH__EXEC.html>`_ and `cuda-python Graph Execution bindings <https://nvidia.github.io/cuda-python/cuda-bindings/latest/module/runtime.html#graph-execution>`_
""" # noqa: B950
return super().raw_cuda_graph_exec()
class graph:
r"""Context-manager that captures CUDA work into a :class:`torch.cuda.CUDAGraph` object for later replay.
See :ref:`CUDA Graphs <cuda-graph-semantics>` for a general introduction,
detailed use, and constraints.
Arguments:
cuda_graph (torch.cuda.CUDAGraph): Graph object used for capture.
pool (optional): Opaque token (returned by a call to :func:`~torch.cuda.graph_pool_handle()` or
:meth:`other_Graph_instance.pool()<torch.cuda.CUDAGraph.pool>`) hinting this graph's capture
may share memory from the specified pool. See :ref:`Graph memory management<graph-memory-management>`.
stream (torch.cuda.Stream, optional): If supplied, will be set as the current stream in the context.
If not supplied, ``graph`` sets its own internal side stream as the current stream in the context.
capture_error_mode (str, optional): specifies the cudaStreamCaptureMode for the graph capture stream.
Can be "global", "thread_local" or "relaxed". During cuda graph capture, some actions, such as cudaMalloc,
may be unsafe. "global" will error on actions in other threads, "thread_local" will only error for
actions in the current thread, and "relaxed" will not error on actions. Do NOT change this setting
unless you're familiar with `cudaStreamCaptureMode <https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__STREAM.html#group__CUDART__STREAM_1g9d0535d93a214cbf126835257b16ba85>`_
enable_annotations (bool, optional): If ``True``, enables kernel annotation
recording on entry and automatically calls
:func:`~torch.cuda._graph_annotations.resolve_pending_annotations` before
the capture ends. Annotations are **not** cleared on exit so that multiple
graphs in the same workload can accumulate annotations.
Requires ``cuda.bindings`` package and cuda-compat >= 13.1 or CUDA driver >= 13.1.
.. note::
For effective memory sharing, if you pass a ``pool`` used by a previous capture and the previous capture
used an explicit ``stream`` argument, you should pass the same ``stream`` argument to this capture.
.. warning::
This API is in beta and may change in future releases.
.. _cudaStreamCaptureMode:
https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__STREAM.html#group__CUDART__STREAM_1g9d0535d93a214cbf126835257b16ba85
""" # noqa: B950
default_capture_stream: torch.cuda.Stream | None = None
def __init__(
self,
cuda_graph: CUDAGraph,
pool: _POOL_HANDLE | None = None,
stream: torch.cuda.Stream | None = None,
capture_error_mode: str = "global",
enable_annotations: bool = False,
):
# Lazy-init of default_capture_stream helps avoid circular-import errors.
# Not thread safe, but graphs already have the general (explicitly documented)
# restriction that only one capture may be underway at a time in the process.
if stream is None and self.__class__.default_capture_stream is None:
self.__class__.default_capture_stream = torch.cuda.Stream()
self.pool: tuple[()] | tuple[_POOL_HANDLE] = () if pool is None else (pool,)
self.capture_stream = (
stream if stream is not None else self.__class__.default_capture_stream
)
if self.capture_stream is None:
raise AssertionError("capture_stream must not be None")
self.stream_ctx = torch.cuda.stream(self.capture_stream)
self.cuda_graph = cuda_graph
self.capture_error_mode = capture_error_mode
self._enable_annotations = enable_annotations
def __enter__(self) -> None:
# Free as much memory as we can for the graph
torch.cuda.synchronize()
if torch.compiler.config.force_cudagraph_gc:
# Originally we unconditionally garbage collected here. On one hand
# that's nice because we have a chance to collect more memory, but
# on the other hand it is REALLY expensive, especially for doing
# multiple cudagraph captures in a row. In theory it will only help
# when a dead python cycle is holding onto CUDA memory.
gc.collect()
torch.cuda.empty_cache()
# pyrefly: ignore [missing-attribute]
torch._C._host_emptyCache()
if self._enable_annotations:
from torch.cuda._graph_annotations import enable_annotations as _enable_ann
_enable_ann()
# Stackoverflow seems comfortable with this pattern
# https://stackoverflow.com/questions/26635684/calling-enter-and-exit-manually#39172487
self.stream_ctx.__enter__()
self.cuda_graph.capture_begin(
# type: ignore[misc]
*self.pool,
# pyrefly: ignore [bad-keyword-argument]
capture_error_mode=self.capture_error_mode,
)
def __exit__(self, *args: object) -> None:
if self._enable_annotations:
from torch.cuda._graph_annotations import resolve_pending_annotations
resolve_pending_annotations()
self.cuda_graph.capture_end()
self.stream_ctx.__exit__(*args)
if self._enable_annotations:
from torch.cuda._graph_annotations import remap_to_exec_graph
remap_to_exec_graph(self.cuda_graph)
# returning None should propagate exceptions from either capture_end or stream_ctx.__exit__()
_ModuleOrCallable: TypeAlias = Union["torch.nn.Module", Callable[..., object]]
@overload
def make_graphed_callables(
callables: _ModuleOrCallable,
sample_args: tuple[Tensor, ...],
num_warmup_iters: int = 3,
allow_unused_input: bool = False,
pool: _POOL_HANDLE | None = None,
) -> _ModuleOrCallable: ...
@overload
def make_graphed_callables(
callables: tuple[_ModuleOrCallable, ...],
sample_args: tuple[tuple[Tensor, ...], ...],
num_warmup_iters: int = 3,
allow_unused_input: bool = False,
pool: _POOL_HANDLE | None = None,
) -> tuple[_ModuleOrCallable, ...]: ...
def make_graphed_callables(
callables: _ModuleOrCallable | tuple[_ModuleOrCallable, ...],
sample_args: tuple[Tensor, ...] | tuple[tuple[Tensor, ...], ...],
num_warmup_iters: int = 3,
allow_unused_input: bool = False,
pool: _POOL_HANDLE | None = None,
) -> _ModuleOrCallable | tuple[_ModuleOrCallable, ...]:
r"""Accept callables (functions or :class:`nn.Module<torch.nn.Module>`\ s) and returns graphed versions.
Each graphed callable's forward pass runs its source callable's
forward CUDA work as a CUDA graph inside a single autograd node.
The graphed callable's forward pass also appends
a backward node to the autograd graph. During backward, this node runs the
callable's backward work as a CUDA graph.
Therefore, each graphed callable should be a drop-in replacement for its source callable
in an autograd-enabled training loop.
See :ref:`Partial-network capture<partial-network-capture>` for detailed use and constraints.
If you pass a tuple of several callables, their captures will use the same memory pool.
See :ref:`Graph memory management<graph-memory-management>` for when this is appropriate.
Arguments:
callables (torch.nn.Module or Python function, or tuple of these): Callable or callables to graph.
See :ref:`Graph memory management<graph-memory-management>` for when passing a tuple of callables
is appropriate. If you pass a tuple of callables, their order in the tuple must be the same order
they'll run in the live workload.
sample_args (tuple of Tensors, or tuple of tuples of Tensors): Samples args for each callable.
If a single callable was passed, ``sample_args`` must be a single tuple of argument Tensors.
If a tuple of callables was passed, ``sample_args`` must be tuple of tuples of argument Tensors.
num_warmup_iters (int): The number of warmup iterations. Currently, ``DataDistributedParallel`` needs
11 iterations for warm up. Default: ``3``.
allow_unused_input (bool): If False, specifying inputs that were not used when computing outputs
(and therefore their grad is always zero) is an error. Defaults to False.
pool (optional): Token (returned by :func:`~torch.cuda.graph_pool_handle` or
:meth:`other_Graph_instance.pool()<torch.cuda.CUDAGraph.pool>`) that hints this graph may share memory
with the indicated pool. See :ref:`Graph memory management<graph-memory-management>`.
.. note::
The ``requires_grad`` state of each Tensor in ``sample_args`` must match the state
that's expected for the corresponding real input in the training loop.
.. warning::
This API is in beta and may change in future releases.
.. warning::
``sample_args`` for each callable must contain only Tensors. Other types are not allowed.
.. warning::
Returned callables do not support higher order differentiation (e.g., double backward).
.. warning::
In any :class:`~torch.nn.Module` passed to :func:`~make_graphed_callables`, only parameters
may be trainable. Buffers must have ``requires_grad=False``.
.. warning::
After you pass a :class:`torch.nn.Module` through :func:`~make_graphed_callables`,
you may not add or remove any of that Module's parameters or buffers.
.. warning::
:class:`torch.nn.Module`\s passed to :func:`~torch.cuda.make_graphed_callables` must not have module hooks
registered on them at the time they are passed. However, registering hooks on modules *after* passing them
through :func:`~torch.cuda.make_graphed_callables` is allowed.
.. warning::
When running a graphed callable, you must pass its arguments in the same order and format
they appeared in that callable's ``sample_args``.
.. warning::
The automatic mixed precision is supported in :func:`~torch.cuda.make_graphed_callables` only with disabled
caching. The context manager `torch.cuda.amp.autocast()` must have `cache_enabled=False`.
"""
if torch.is_autocast_enabled() and torch.is_autocast_cache_enabled():
raise RuntimeError(
"make_graphed_callables does not support the autocast caching. Please set `cache_enabled=False`."
)
just_one_callable = False
_sample_args: tuple[tuple[Tensor, ...], ...]
if not isinstance(callables, tuple):
just_one_callable = True
callables = (callables,)
_sample_args = (typing.cast(tuple[Tensor, ...], sample_args),)
else:
_sample_args = typing.cast(tuple[tuple[Tensor, ...], ...], sample_args)
flatten_sample_args = []
for c, args in zip(callables, _sample_args):
if isinstance(c, torch.nn.Module):
if not (
len(c._backward_hooks) == 0
and len(c._forward_hooks) == 0
and len(c._forward_pre_hooks) == 0
):
raise AssertionError(
"Modules must not have hooks registered at the time they are passed. However, registering hooks "
+ "on modules after passing them through make_graphed_callables is allowed."
)
if not all(b.requires_grad is False for b in c.buffers()):
raise AssertionError(
"In any :class:`~torch.nn.Module` passed to "
+ ":func:`~make_graphed_callables`, only parameters may be trainable. All buffers must have "
+ "``requires_grad=False``."
)
flatten_arg = torch.utils._pytree.arg_tree_leaves(*args)
flatten_sample_args.append(tuple(flatten_arg))
if not all(isinstance(arg, torch.Tensor) for arg in flatten_arg):
raise AssertionError(
"In the beta API, sample_args "
+ "for each callable must contain only Tensors. Other types are not allowed."
)
# If a callable is an nn.Module, its graph's full input surface is the args the user explicitly
# passes to forward (ie, its sample_args) AND the module's parameter attributes.
per_callable_len_user_args = [len(args) for args in flatten_sample_args]
per_callable_module_params = [
tuple(c.parameters()) if isinstance(c, torch.nn.Module) else ()
for c in callables
]
per_callable_static_input_surfaces = [
flatten_sample_args[i] + per_callable_module_params[i]
for i in range(len(callables))
]
fwd_graphs = [torch.cuda.CUDAGraph() for _ in range(len(callables))]
bwd_graphs = [torch.cuda.CUDAGraph() for _ in range(len(callables))]
mempool = graph_pool_handle() if pool is None else pool
# Warmup
# Hopefully prevents cudnn benchmarking and other lazy-initialization cuda work
# from ending up in any captures.
torch.cuda.synchronize()
with torch.cuda.stream(torch.cuda.Stream()):
for func, args, static_input_surface in zip(
callables, _sample_args, per_callable_static_input_surfaces
):
grad_inputs, outputs, outputs_grad = None, None, None
for _ in range(num_warmup_iters):
outputs = torch.utils._pytree.tree_leaves(func(*args))
outputs_grad = tuple(o for o in outputs if o.requires_grad)
if len(outputs_grad) > 0:
grad_inputs = torch.autograd.grad(
outputs=outputs_grad,
inputs=tuple(
i for i in static_input_surface if i.requires_grad
),
grad_outputs=tuple(
torch.empty_like(o) for o in outputs if o.requires_grad
),
only_inputs=True,
allow_unused=allow_unused_input,
)
for v in [outputs, outputs_grad, grad_inputs]:
del v
torch.cuda.synchronize()
# All captures here share a mempool. To avoid replays corrupting each other's memory,
# the safest approach is to capture all passes in the same order they'll run:
# fwd 1, fwd 2, ... fwd N, then bwd N, bwd N-1, ... bwd 1.
# Capture forward graphs
per_callable_static_outputs = []
per_callable_output_unflatten_spec = []
for func, args, fwd_graph in zip(callables, _sample_args, fwd_graphs):
with torch.cuda.graph(fwd_graph, pool=mempool):
func_outputs = func(*args)
flatten_outputs, spec = torch.utils._pytree.tree_flatten(func_outputs)
per_callable_static_outputs.append(tuple(flatten_outputs))
per_callable_output_unflatten_spec.append(spec)
# Capture backward graphs in reverse order
per_callable_static_grad_outputs = []
per_callable_static_grad_inputs = []
for static_input_surface, static_outputs, bwd_graph in zip(
reversed(per_callable_static_input_surfaces),
reversed(per_callable_static_outputs),
reversed(bwd_graphs),
):
# For now, assumes all static_outputs require grad
# assert all(o.requires_grad for o in static_outputs), "Outputs of graphed callables must require grad."
static_grad_outputs = tuple(
torch.empty_like(o) if o.requires_grad else None for o in static_outputs
)
outputs_grad = tuple(o for o in static_outputs if o.requires_grad)
grad_inputs = None
if len(outputs_grad) > 0:
with torch.cuda.graph(bwd_graph, pool=mempool):
grad_inputs = torch.autograd.grad(
outputs=outputs_grad,
inputs=tuple(i for i in static_input_surface if i.requires_grad),
grad_outputs=tuple(o for o in static_grad_outputs if o is not None),
only_inputs=True,
allow_unused=allow_unused_input,
)
# Constructs a tuple suitable for returning from Graphed.backward:
# Pads out the actually-needed grads with Nones in gradient slots for inputs that don't require grad.
# I couldn't think of a slick one-liner for this pattern.
static_grad_inputs = []
grad_idx = 0
for arg in static_input_surface:
if arg.requires_grad and grad_inputs is not None:
static_grad_inputs.append(grad_inputs[grad_idx])
grad_idx += 1
else:
static_grad_inputs.append(None) # type: ignore[arg-type]
static_grad_inputs = tuple(static_grad_inputs) # type: ignore[assignment]
per_callable_static_grad_outputs.append(static_grad_outputs)
per_callable_static_grad_inputs.append(static_grad_inputs)
# Reverses the most recent two lists
per_callable_static_grad_outputs.reverse()
per_callable_static_grad_inputs.reverse()
# Now for every per_callable list, per_callable_*[i] holds the stuff for the ith callable.
def make_graphed_autograd_function(
fwd_graph: CUDAGraph,
bwd_graph: CUDAGraph,
module_params: tuple[torch.nn.Parameter, ...],
len_user_args: int,
output_unflatten_spec: torch.utils._pytree.TreeSpec,
static_input_surface: tuple[Tensor, ...],
static_outputs: tuple[Tensor, ...],
static_grad_outputs: tuple[Tensor | None, ...],
static_grad_inputs: tuple[Tensor, ...],
) -> Callable[..., object]:
class Graphed(torch.autograd.Function):
@staticmethod
# pyrefly: ignore [bad-override]
def forward(ctx: object, *inputs: Tensor) -> tuple[Tensor, ...]:
# At this stage, only the user args may (potentially) be new tensors.
for i in range(len_user_args):
if static_input_surface[i].data_ptr() != inputs[i].data_ptr():
static_input_surface[i].copy_(inputs[i])
fwd_graph.replay()
if not isinstance(static_outputs, tuple):
raise AssertionError(
f"static_outputs must be tuple, got {type(static_outputs)}"
)
return tuple(o.detach() for o in static_outputs)
@staticmethod
@torch.autograd.function.once_differentiable
# pyrefly: ignore [bad-override]
def backward(ctx: object, *grads: Tensor) -> tuple[Tensor, ...]:
if len(grads) != len(static_grad_outputs):
raise AssertionError(
f"len(grads)={len(grads)} != len(static_grad_outputs)={len(static_grad_outputs)}"
)
for g, grad in zip(static_grad_outputs, grads):
if g is not None:
# don't copy if autograd gods have been kind and the
# incoming grad is already in the right place
if g.data_ptr() != grad.data_ptr():
g.copy_(grad)
bwd_graph.replay()
# Input args that didn't require grad expect a None gradient.
if not isinstance(static_grad_inputs, tuple):
raise AssertionError(
f"static_grad_inputs must be tuple, got {type(static_grad_inputs)}"
)
return tuple(
# pyrefly: ignore [bad-argument-type]
b.detach() if b is not None else b
for b in static_grad_inputs
)
def functionalized(*user_args: object) -> object:
# Runs the autograd function with inputs == all inputs to the graph that might require grad
# (explicit user args + module parameters)
# Assumes module params didn't change since capture.
flatten_user_args = torch.utils._pytree.arg_tree_leaves(*user_args)
out = Graphed.apply(*(tuple(flatten_user_args) + module_params))
return torch.utils._pytree.tree_unflatten(out, output_unflatten_spec)
return functionalized
# Put together the final graphed callables
ret: list[_ModuleOrCallable] = []
for i, func in enumerate(callables):
graphed = make_graphed_autograd_function(
fwd_graphs[i],
bwd_graphs[i],
per_callable_module_params[i],
per_callable_len_user_args[i],
per_callable_output_unflatten_spec[i],
per_callable_static_input_surfaces[i],
per_callable_static_outputs[i],
per_callable_static_grad_outputs[i],
per_callable_static_grad_inputs[i],
)
if isinstance(func, torch.nn.Module):
def make_graphed_forward(
func: torch.nn.Module,
graph_training_state: bool,
graphed: Callable[_P, _R],
orig_fwd: Callable[_P, _R],
) -> Callable[_P, _R]:
def new_fwd(*user_args: _P.args, **user_kwargs: _P.kwargs) -> _R:
# If the module's training-or-eval state matches what we graphed,
# run the graph, otherwise run the original forward method
if func.training == graph_training_state:
return graphed(*user_args, **user_kwargs)
else:
return orig_fwd(*user_args, **user_kwargs)
return new_fwd
func.forward = make_graphed_forward(
func, func.training, graphed, func.forward
)
ret.append(func)
else:
ret.append(graphed)
if just_one_callable:
return ret[0]
return tuple(ret)
@@ -0,0 +1,92 @@
import torch
__all__ = [
"GreenContext",
]
_GreenContext = object
SUPPORTED = False
if hasattr(torch._C, "_CUDAGreenContext"):
_GreenContext = torch._C._CUDAGreenContext # type: ignore[misc]
SUPPORTED = True
# Python shim helps Sphinx process docstrings more reliably.
# pyrefly: ignore [invalid-inheritance]
class GreenContext(_GreenContext):
r"""Wrapper around a CUDA green context.
.. warning::
This API is in beta and may change in future releases.
"""
@staticmethod
def create(
*,
num_sms: int | None = None,
workqueue_scope: str | None = None,
workqueue_concurrency_limit: int | None = None,
device_id: int | None = None,
) -> _GreenContext:
r"""Create a CUDA green context.
At least one of ``num_sms`` or ``workqueue_scope`` must be specified.
Both can be combined to partition SMs and configure workqueues in the
same green context.
Arguments:
num_sms (int, optional): The number of SMs to use in the green
context. When ``None``, SMs are not partitioned.
workqueue_scope (str, optional): Workqueue sharing scope. One of
``"device_ctx"`` (shared across all contexts, default driver
behaviour) or ``"balanced"`` (non-overlapping workqueues with
other balanced green contexts). When ``None``, no workqueue
configuration is applied.
workqueue_concurrency_limit (int, optional): Maximum number of
concurrent stream-ordered workloads for the workqueue. Requires
``workqueue_scope`` to be set.
device_id (int, optional): The device index of green context.
When ``None``, the current device is used.
"""
if not SUPPORTED:
raise RuntimeError("PyTorch was not built with Green Context support!")
return _GreenContext.create( # type: ignore[attr-defined]
device_id=device_id,
num_sms=num_sms,
workqueue_scope=workqueue_scope,
workqueue_concurrency_limit=workqueue_concurrency_limit,
)
@staticmethod
def max_workqueue_concurrency(device_id: int | None = None) -> int:
r"""Return the maximum workqueue concurrency limit for the device.
This queries the device for the default number of concurrent
stream-ordered workloads supported by workqueue configuration
resources.
Arguments:
device_id (int, optional): The device index to query. When
``None``, the current device is used.
"""
if not SUPPORTED:
raise RuntimeError("PyTorch was not built with Green Context support!")
return _GreenContext.max_workqueue_concurrency(device_id=device_id) # type: ignore[attr-defined]
# Note that these functions are bypassed but we define them here
# for Sphinx documentation purposes
def set_context(self) -> None: # pylint: disable=useless-parent-delegation
r"""Make the green context the current context."""
return super().set_context() # type: ignore[misc]
def pop_context(self) -> None: # pylint: disable=useless-parent-delegation
r"""Assuming the green context is the current context, pop it from the
context stack and restore the previous context.
"""
return super().pop_context() # type: ignore[misc]
def Stream(self) -> "torch.cuda.Stream":
r"""Return the CUDA Stream used by the green context."""
return super().Stream()
@@ -0,0 +1,192 @@
# mypy: allow-untyped-defs
import re
from collections.abc import Callable
import torch
from torch import Tensor
__all__: list[str] = []
class _CodeParser:
def __init__(self, code_string: str):
optional_ws = r"\s*"
required_ws = r"\s+"
template_params = r"(?P<template_params>\<.+\>)"
return_type = r"(?P<return_type>\w+)"
function_name = r"(?P<function_name>\w+)"
function_params = r"(?P<function_params>\(.+\))"
function_body = r"(?P<function_body>\{.+\})"
pattern = (
optional_ws
+ "template"
+ optional_ws
+ template_params
+ optional_ws
+ return_type
+ required_ws
+ function_name
+ optional_ws
+ function_params
+ optional_ws
+ function_body
+ optional_ws
)
result = re.match(
pattern, code_string, re.DOTALL
) # DOTALL for matching multiline
if result is None:
raise Exception( # noqa: TRY002
f"Couldn't parse code, please check correctness:\n {code_string}"
)
self.template_params = result["template_params"]
self.return_type = result["return_type"]
self.function_name = result["function_name"]
self.function_params = result["function_params"]
self.function_body = result["function_body"]
class _JittedFunction:
def __init__(
self, code_string: str, return_by_ref: bool, num_outputs: int, **kwargs
):
self.code_string = code_string
if not (return_by_ref or num_outputs == 1):
raise AssertionError("Return by value only works for single output.")
self.return_by_ref = return_by_ref
self.num_outputs = num_outputs
parsed_code = _CodeParser(code_string)
self.kernel_name = parsed_code.function_name
self.kwargs_dict = kwargs
self.is_cuda_available = torch.cuda.is_available()
def __call__(self, *tensors: Tensor, **kwargs):
# Jiterator follow torch.cuda's lazy initialization behavior
# Defer checking cuda's availability at the function invocation time
if not self.is_cuda_available:
raise AssertionError(
"Jiterator is only supported on CUDA and ROCm GPUs, none are available."
)
if len(tensors) > 8:
raise AssertionError(
f"jiterator only supports up to 8 tensor inputs, got {len(tensors)}"
)
expanded_kwargs = self.kwargs_dict.copy()
for key, value in kwargs.items():
if key in self.kwargs_dict:
expanded_kwargs[key] = value
else:
raise KeyError(f"{key} is not declared in function definition")
return torch._C._cuda_jiterator_compile_and_launch_kernel(
self.code_string,
self.kernel_name,
self.return_by_ref,
self.num_outputs,
tensors,
expanded_kwargs,
)
def _create_jit_fn(code_string: str, **kwargs) -> Callable:
"""
Create a jiterator-generated cuda kernel for an elementwise op.
The code string has to be a valid CUDA function that describes the computation for a single element. The code
string has to follow the c++ template pattern, as shown in the example below. This function will be inlined
into elementwise kernel template, and compiled on the fly. Compiled kernel will be cached in memory, as well as
local temp dir.
Jiterator-generated kernels accepts noncontiguous tensors, and supports broadcasting and type promotion.
Args:
code_string (str): CUDA code string to be compiled by jiterator. The entry functor must return by value.
kwargs (Dict, optional): Keyword arguments for generated function
Example::
code_string = "template <typename T> T my_kernel(T x, T y, T alpha) { return -x + alpha * y; }"
jitted_fn = create_jit_fn(code_string, alpha=1.0)
a = torch.rand(3, device="cuda")
b = torch.rand(3, device="cuda")
# invoke jitted function like a regular python function
result = jitted_fn(a, b, alpha=3.14)
code_string also allows multiple function definitions, and the last function will be treated as the entry function.
Example::
code_string = (
"template <typename T> T util_fn(T x, T y) { return ::sin(x) + ::cos(y); }"
)
code_string += "template <typename T> T my_kernel(T x, T y, T val) { return ::min(val, util_fn(x, y)); }"
jitted_fn = create_jit_fn(code_string, val=0.0)
a = torch.rand(3, device="cuda")
b = torch.rand(3, device="cuda")
# invoke jitted function like a regular python function
result = jitted_fn(a, b) # using default val=0.0
Jiterator can be used together with python registration to override an operator's cuda kernel.
Following example is overriding gelu's cuda kernel with relu.
Example::
code_string = "template <typename T> T my_gelu(T a) { return a > 0 ? a : 0; }"
my_gelu = create_jit_fn(code_string)
my_lib = torch.library.Library("aten", "IMPL")
my_lib.impl("aten::gelu", my_gelu, "CUDA")
# torch.nn.GELU and torch.nn.function.gelu are now overridden
a = torch.rand(3, device="cuda")
torch.allclose(torch.nn.functional.gelu(a), torch.nn.functional.relu(a))
.. warning::
This API is in beta and may change in future releases.
.. warning::
This API only supports up to 8 inputs and 1 output
.. warning::
All input tensors must live in CUDA device
"""
return _JittedFunction(code_string, return_by_ref=False, num_outputs=1, **kwargs)
def _create_multi_output_jit_fn(
code_string: str, num_outputs: int, **kwargs
) -> Callable:
"""
Create a jiterator-generated cuda kernel for an elementwise op that supports returning one or more outputs.
Args:
code_string (str): CUDA code string to be compiled by jiterator. The entry functor must return value by reference.
num_outputs(int): number of outputs return by the kernel
kwargs (Dict, optional): Keyword arguments for generated function
Example::
code_string = "template <typename T> void my_kernel(T x, T y, T alpha, T& out) { out = -x + alpha * y; }"
jitted_fn = create_jit_fn(code_string, alpha=1.0)
a = torch.rand(3, device="cuda")
b = torch.rand(3, device="cuda")
# invoke jitted function like a regular python function
result = jitted_fn(a, b, alpha=3.14)
.. warning::
This API is in beta and may change in future releases.
.. warning::
This API only supports up to 8 inputs and 8 outputs
"""
return _JittedFunction(
code_string, return_by_ref=True, num_outputs=num_outputs, **kwargs
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
# mypy: allow-untyped-defs
import collections
import warnings
from collections.abc import Sequence
import torch.cuda
__all__ = ["all_reduce", "reduce", "broadcast", "all_gather", "reduce_scatter"]
SUM = 0 # ncclRedOp_t
def is_available(tensors):
if not hasattr(torch._C, "_nccl_all_reduce"):
warnings.warn("PyTorch is not compiled with NCCL support", stacklevel=2)
return False
devices = set()
for tensor in tensors:
if tensor.is_sparse:
return False
if not tensor.is_contiguous():
return False
if not tensor.is_cuda:
return False
device = tensor.get_device()
if device in devices:
return False
devices.add(device)
return True
def version():
"""
Returns the version of the NCCL.
This function returns a tuple containing the major, minor, and patch version numbers of the NCCL.
The suffix is also included in the tuple if a version suffix exists.
Returns:
tuple: The version information of the NCCL.
"""
ver = torch._C._nccl_version()
major = ver >> 32
minor = (ver >> 16) & 65535
patch = ver & 65535
suffix = torch._C._nccl_version_suffix().decode("utf-8")
if suffix == "":
return (major, minor, patch)
else:
return (major, minor, patch, suffix)
def unique_id():
return torch._C._nccl_unique_id()
def init_rank(num_ranks, uid, rank):
return torch._C._nccl_init_rank(num_ranks, uid, rank)
def _check_sequence_type(inputs: torch.Tensor | Sequence[torch.Tensor]) -> None:
if not isinstance(inputs, collections.abc.Container) or isinstance(
inputs, torch.Tensor
):
raise TypeError("Inputs should be a collection of tensors")
def all_reduce(inputs, outputs=None, op=SUM, streams=None, comms=None):
_check_sequence_type(inputs)
if outputs is None:
outputs = inputs
_check_sequence_type(outputs)
torch._C._nccl_all_reduce(inputs, outputs, op, streams, comms)
# `output` used to be `outputs`, taking in a list of tensors. So we have two
# arguments for BC reasons.
def reduce(
inputs: Sequence[torch.Tensor],
output: torch.Tensor | Sequence[torch.Tensor] | None = None,
root: int = 0,
op: int = SUM,
streams: Sequence[torch.cuda.Stream] | None = None,
comms=None,
*,
outputs: Sequence[torch.Tensor] | None = None,
) -> None:
_check_sequence_type(inputs)
_output: torch.Tensor
if outputs is not None:
if output is not None:
raise ValueError(
"'output' and 'outputs' can not be both specified. 'outputs' is deprecated in "
"favor of 'output', taking in a single output tensor. The signature of reduce is: "
"reduce(inputs, output=None, root=0, op=SUM, streams=None, comms=None)."
)
else:
warnings.warn(
"`nccl.reduce` with an output tensor list is deprecated. "
"Please specify a single output tensor with argument 'output' instead instead.",
FutureWarning,
stacklevel=2,
)
_output = outputs[root]
elif not isinstance(output, torch.Tensor) and isinstance(
output, collections.abc.Sequence
):
# User called old API with positional arguments of list of output tensors.
warnings.warn(
"nccl.reduce with an output tensor list is deprecated. "
"Please specify a single output tensor.",
FutureWarning,
stacklevel=2,
)
_output = output[root]
else:
_output = inputs[root] if output is None else output
torch._C._nccl_reduce(inputs, _output, root, op, streams, comms)
def broadcast(
inputs: Sequence[torch.Tensor], root: int = 0, streams=None, comms=None
) -> None:
_check_sequence_type(inputs)
torch._C._nccl_broadcast(inputs, root, streams, comms)
def all_gather(
inputs: Sequence[torch.Tensor],
outputs: Sequence[torch.Tensor],
streams=None,
comms=None,
) -> None:
_check_sequence_type(inputs)
_check_sequence_type(outputs)
torch._C._nccl_all_gather(inputs, outputs, streams, comms)
def reduce_scatter(
inputs: Sequence[torch.Tensor],
outputs: Sequence[torch.Tensor],
op: int = SUM,
streams=None,
comms=None,
) -> None:
_check_sequence_type(inputs)
_check_sequence_type(outputs)
torch._C._nccl_reduce_scatter(inputs, outputs, op, streams, comms)
@@ -0,0 +1,129 @@
# mypy: allow-untyped-defs
r"""This package adds support for NVIDIA Tools Extension (NVTX) used in profiling."""
from contextlib import contextmanager
try:
from torch._C import _nvtx
except ImportError:
class _NVTXStub:
@staticmethod
def _fail(*args, **kwargs):
raise RuntimeError(
"NVTX functions not installed. Are you sure you have a CUDA build?"
)
rangePushA = _fail
rangePop = _fail
markA = _fail
_nvtx = _NVTXStub() # type: ignore[assignment]
__all__ = ["range_push", "range_pop", "range_start", "range_end", "mark", "range"]
def range_push(msg):
"""
Push a range onto a stack of nested range span. Returns zero-based depth of the range that is started.
Args:
msg (str): ASCII message to associate with range
"""
return _nvtx.rangePushA(msg)
def range_pop():
"""Pop a range off of a stack of nested range spans. Returns the zero-based depth of the range that is ended."""
return _nvtx.rangePop()
def range_start(msg) -> int:
"""
Mark the start of a range with string message. It returns an unique handle
for this range to pass to the corresponding call to rangeEnd().
A key difference between this and range_push/range_pop is that the
range_start/range_end version supports range across threads (start on one
thread and end on another thread).
Returns: A range handle (uint64_t) that can be passed to range_end().
Args:
msg (str): ASCII message to associate with the range.
"""
# pyrefly: ignore [missing-attribute]
return _nvtx.rangeStartA(msg)
def range_end(range_id) -> None:
"""
Mark the end of a range for a given range_id.
Args:
range_id (int): an unique handle for the start range.
"""
# pyrefly: ignore [missing-attribute]
_nvtx.rangeEnd(range_id)
def _device_range_start(msg: str, stream: int = 0) -> object:
"""
Marks the start of a range with string message.
It returns an opaque heap-allocated handle for this range
to pass to the corresponding call to device_range_end().
A key difference between this and range_start is that the
range_start marks the range right away, while _device_range_start
marks the start of the range as soon as all the tasks on the
CUDA stream are completed.
Returns: An opaque heap-allocated handle that should be passed to _device_range_end().
Args:
msg (str): ASCII message to associate with the range.
stream (int): CUDA stream id.
"""
# pyrefly: ignore [missing-attribute]
return _nvtx.deviceRangeStart(msg, stream)
def _device_range_end(range_handle: object, stream: int = 0) -> None:
"""
Mark the end of a range for a given range_handle as soon as all the tasks
on the CUDA stream are completed.
Args:
range_handle: an unique handle for the start range.
stream (int): CUDA stream id.
"""
# pyrefly: ignore [missing-attribute]
_nvtx.deviceRangeEnd(range_handle, stream)
def mark(msg):
"""
Describe an instantaneous event that occurred at some point.
Args:
msg (str): ASCII message to associate with the event.
"""
return _nvtx.markA(msg)
@contextmanager
def range(msg, *args, **kwargs):
"""
Context manager / decorator that pushes an NVTX range at the beginning
of its scope, and pops it at the end. If extra arguments are given,
they are passed as arguments to msg.format().
Args:
msg (str): message to associate with the range
"""
range_push(msg.format(*args, **kwargs))
try:
yield
finally:
range_pop()
@@ -0,0 +1,56 @@
# mypy: allow-untyped-defs
import contextlib
from . import check_error, cudart
__all__ = ["start", "stop", "profile"]
DEFAULT_FLAGS = [
"gpustarttimestamp",
"gpuendtimestamp",
"gridsize3d",
"threadblocksize",
"streamid",
"enableonstart 0",
"conckerneltrace",
]
def start():
r"""Starts cuda profiler data collection.
.. warning::
Raises CudaError in case of it is unable to start the profiler.
"""
check_error(cudart().cudaProfilerStart())
def stop():
r"""Stops cuda profiler data collection.
.. warning::
Raises CudaError in case of it is unable to stop the profiler.
"""
check_error(cudart().cudaProfilerStop())
@contextlib.contextmanager
def profile():
"""
Enable profiling.
Context Manager to enabling profile collection by the active profiling tool from CUDA backend.
Example:
>>> # xdoctest: +REQUIRES(env:TORCH_DOCTEST_CUDA)
>>> import torch
>>> model = torch.nn.Linear(20, 30).cuda()
>>> inputs = torch.randn(128, 20).cuda()
>>> with torch.cuda.profiler.profile() as prof:
... model(inputs)
"""
try:
start()
yield
finally:
stop()
@@ -0,0 +1,181 @@
# mypy: allow-untyped-defs
from collections.abc import Iterable
import torch
from torch import Tensor
from . import _lazy_call, _lazy_init, current_device, device_count, is_initialized
__all__ = [
"get_rng_state",
"get_rng_state_all",
"set_rng_state",
"set_rng_state_all",
"manual_seed",
"manual_seed_all",
"seed",
"seed_all",
"initial_seed",
]
def get_rng_state(device: int | str | torch.device = "cuda") -> Tensor:
r"""Return the random number generator state of the specified GPU as a ByteTensor.
Args:
device (torch.device or int, optional): The device to return the RNG state of.
Default: ``'cuda'`` (i.e., ``torch.device('cuda')``, the current CUDA device).
.. warning::
This function eagerly initializes CUDA.
"""
_lazy_init()
if isinstance(device, str):
device = torch.device(device)
elif isinstance(device, int):
device = torch.device("cuda", device)
idx = device.index
if idx is None:
idx = current_device()
default_generator = torch.cuda.default_generators[idx]
return default_generator.get_state()
def get_rng_state_all() -> list[Tensor]:
r"""Return a list of ByteTensor representing the random number states of all devices."""
results = [get_rng_state(i) for i in range(device_count())]
return results
def set_rng_state(new_state: Tensor, device: int | str | torch.device = "cuda") -> None:
r"""Set the random number generator state of the specified GPU.
Args:
new_state (torch.ByteTensor): The desired state
device (torch.device or int, optional): The device to set the RNG state.
Default: ``'cuda'`` (i.e., ``torch.device('cuda')``, the current CUDA device).
"""
if not is_initialized():
with torch._C._DisableFuncTorch():
# Clone the state because the callback will be triggered
# later when CUDA is lazy initialized.
new_state = new_state.clone(memory_format=torch.contiguous_format)
if isinstance(device, str):
device = torch.device(device)
elif isinstance(device, int):
device = torch.device("cuda", device)
def cb():
idx = device.index
if idx is None:
idx = current_device()
default_generator = torch.cuda.default_generators[idx]
default_generator.set_state(new_state)
_lazy_call(cb)
def set_rng_state_all(new_states: Iterable[Tensor]) -> None:
r"""Set the random number generator state of all devices.
Args:
new_states (Iterable of torch.ByteTensor): The desired state for each device.
"""
for i, state in enumerate(new_states):
set_rng_state(state, i)
def manual_seed(seed: int) -> None:
r"""Set the seed for generating random numbers for the current GPU.
It's safe to call this function if CUDA is not available; in that
case, it is silently ignored.
Args:
seed (int): The desired seed.
.. warning::
If you are working with a multi-GPU model, this function is insufficient
to get determinism. To seed all GPUs, use :func:`manual_seed_all`.
"""
seed = int(seed)
def cb():
idx = current_device()
default_generator = torch.cuda.default_generators[idx]
default_generator.manual_seed(seed)
_lazy_call(cb, seed=True)
def manual_seed_all(seed: int) -> None:
r"""Set the seed for generating random numbers on all GPUs.
It's safe to call this function if CUDA is not available; in that
case, it is silently ignored.
Args:
seed (int): The desired seed.
"""
seed = int(seed)
def cb():
for i in range(device_count()):
default_generator = torch.cuda.default_generators[i]
default_generator.manual_seed(seed)
_lazy_call(cb, seed_all=True)
def seed() -> None:
r"""Set the seed for generating random numbers to a random number for the current GPU.
It's safe to call this function if CUDA is not available; in that
case, it is silently ignored.
.. warning::
If you are working with a multi-GPU model, this function will only initialize
the seed on one GPU. To initialize all GPUs, use :func:`seed_all`.
"""
def cb():
idx = current_device()
default_generator = torch.cuda.default_generators[idx]
default_generator.seed()
_lazy_call(cb)
def seed_all() -> None:
r"""Set the seed for generating random numbers to a random number on all GPUs.
It's safe to call this function if CUDA is not available; in that
case, it is silently ignored.
"""
def cb():
random_seed = 0
seeded = False
for i in range(device_count()):
default_generator = torch.cuda.default_generators[i]
if not seeded:
default_generator.seed()
random_seed = default_generator.initial_seed()
seeded = True
else:
default_generator.manual_seed(random_seed)
_lazy_call(cb)
def initial_seed() -> int:
r"""Return the current random seed of the current GPU.
.. warning::
This function eagerly initializes CUDA.
"""
_lazy_init()
idx = current_device()
default_generator = torch.cuda.default_generators[idx]
return default_generator.initial_seed()
@@ -0,0 +1 @@
# The Tensor classes are added to this module by python_tensor.cpp
@@ -0,0 +1,271 @@
# mypy: allow-untyped-defs
# pylint: disable=useless-parent-delegation
from __future__ import annotations
import ctypes
import torch
from torch._utils import _dummy_type
if not hasattr(torch._C, "_CudaStreamBase"):
# Define dummy base classes
torch._C.__dict__["_CudaStreamBase"] = _dummy_type("_CudaStreamBase")
torch._C.__dict__["_CudaEventBase"] = _dummy_type("_CudaEventBase")
class Stream(torch._C._CudaStreamBase):
r"""Wrapper around a CUDA stream.
A CUDA stream is a linear sequence of execution that belongs to a specific
device, independent from other streams. It supports with statement as a
context manager to ensure the operators within the with block are running
on the corresponding stream. See :ref:`cuda-semantics` for details.
Args:
device(torch.device or int, optional): a device on which to allocate
the stream. If :attr:`device` is ``None`` (default) or a negative
integer, this will use the current device.
priority(int, optional): priority of the stream, which can be positive, 0, or negative.
A lower number indicates a higher priority. By default, the priority is set to 0.
If the value falls outside of the allowed priority range, it will automatically be
mapped to the nearest valid priority (lowest for large positive numbers or
highest for large negative numbers).
"""
def __new__(cls, device=None, priority=0, **kwargs):
# Check CUDA availability
if not torch.backends.cuda.is_built():
raise RuntimeError("torch.cuda.Stream requires CUDA support")
# setting device manager is expensive, so we avoid it unless necessary
if device is None or ("stream_id" in kwargs and "device_index" in kwargs):
return super().__new__(cls, priority=priority, **kwargs)
else:
with torch.cuda.device(device):
return super().__new__(cls, priority=priority, **kwargs)
def wait_event(self, event: Event | torch.Event) -> None:
r"""Make all future work submitted to the stream wait for an event.
Args:
event (Event, torch.Event): an event to wait for.
.. note:: This is a wrapper around ``cudaStreamWaitEvent()``: see
`CUDA Stream documentation`_ for more info.
This function returns without waiting for :attr:`event`: only future
operations are affected.
.. _CUDA Stream documentation:
https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__STREAM.html
"""
event.wait(self)
def wait_stream(self, stream: Stream | torch.Stream) -> None:
r"""Synchronize with another stream.
All future work submitted to this stream will wait until all kernels
submitted to a given stream at the time of call complete.
Args:
stream (Stream, torch.Stream): a stream to synchronize.
.. note:: This function returns without waiting for currently enqueued
kernels in :attr:`stream`: only future operations are affected.
"""
self.wait_event(stream.record_event())
def record_event(self, event: Event | torch.Event | None = None):
r"""Record an event.
Args:
event (Event, torch.Event, optional): event to record. If not given, a new one
will be allocated.
Returns:
Recorded event.
"""
if event is None:
event = Event()
event.record(self)
return event
def query(self) -> bool:
r"""Check if all the work submitted has been completed.
Returns:
A boolean indicating if all kernels in this stream are completed.
"""
return super().query()
def synchronize(self) -> None:
r"""Wait for all the kernels in this stream to complete.
.. note:: This is a wrapper around ``cudaStreamSynchronize()``: see
`CUDA Stream documentation`_ for more info.
"""
super().synchronize()
@property
def _as_parameter_(self):
return ctypes.c_void_p(self.cuda_stream)
def __eq__(self, o) -> bool:
if isinstance(o, Stream):
return super().__eq__(o)
return False
def __hash__(self):
return hash((self.cuda_stream, self.device))
def __repr__(self):
return f"<torch.cuda.Stream device={self.device} cuda_stream={self.cuda_stream:#x}>"
def __cuda_stream__(self):
"""Implements the CUDA Stream Protocol:
https://nvidia.github.io/cuda-python/cuda-core/latest/interoperability.html#cuda-stream-protocol
Returns:
tuple: A 2-tuple of (version, handle) where version is the protocol version
and handle is the address of cudaStream_t (CUDA) or hipStream_t (ROCm) as a Python int.
"""
return (0, self.cuda_stream)
class ExternalStream(Stream):
r"""Wrapper around an externally allocated CUDA stream.
This class is used to wrap streams allocated in other libraries in order
to facilitate data exchange and multi-library interactions.
.. note:: This class doesn't manage the stream life-cycle, it is the user
responsibility to keep the referenced stream alive while this class is
being used.
Args:
stream_ptr(int): Integer representation of the `cudaStream_t` value.
allocated externally.
device(torch.device or int, optional): the device where the stream
was originally allocated. If device is specified incorrectly,
subsequent launches using this stream may fail.
"""
def __new__(cls, stream_ptr, device=None, **kwargs):
with torch.cuda.device(device):
return super().__new__(cls, stream_ptr=stream_ptr, **kwargs)
class Event(torch._C._CudaEventBase):
r"""Wrapper around a CUDA event.
CUDA events are synchronization markers that can be used to monitor the
device's progress, to accurately measure timing, and to synchronize CUDA
streams.
The underlying CUDA events are lazily initialized when the event is first
recorded or exported to another process. After creation, only streams on the
same device may record the event. However, streams on any device can wait on
the event.
Args:
enable_timing (bool, optional): indicates if the event should measure time
(default: ``False``)
blocking (bool, optional): if ``True``, :meth:`wait` will be blocking (default: ``False``)
interprocess (bool): if ``True``, the event can be shared between processes
(default: ``False``)
external (bool, optional): indicates whether this event should create event record and event wait nodes, or create an internal cross-stream dependency, when captured in a cuda graph. See `cross-stream dependencies <https://docs.nvidia.com/cuda/archive/12.9.0/cuda-c-programming-guide/index.html#cross-stream-dependencies-and-events>`_, `cudaEventRecordExternal <https://docs.nvidia.com/cuda/archive/12.9.0/cuda-runtime-api/group__CUDART__TYPES.html#group__CUDART__TYPES_1g3457b81d1d32c6a00f6132fbc2693d47>`_, and `cudaEventWaitExternal <https://docs.nvidia.com/cuda/archive/12.9.0/cuda-runtime-api/group__CUDART__TYPES.html#group__CUDART__TYPES_1g0c23426b7252eaa9cef695859991304e>`_ for more information about internal vs. external events. (default: ``False``)
.. _CUDA Event Documentation:
https://docs.nvidia.com/cuda/cuda-runtime-api/group__CUDART__EVENT.html
""" # noqa: B950
def __new__(
cls, enable_timing=False, blocking=False, interprocess=False, external=False
):
return super().__new__(
cls,
enable_timing=enable_timing,
blocking=blocking,
interprocess=interprocess,
external=external,
)
@classmethod
def from_ipc_handle(cls, device, handle):
r"""Reconstruct an event from an IPC handle on the given device."""
return super().from_ipc_handle(device, handle)
def record(self, stream: Stream | torch.Stream | None = None):
r"""Record the event in a given stream.
Args:
stream (Stream, torch.Stream, optional): Uses ``torch.cuda.current_stream()`` if no stream is specified.
The stream's device must match the event's device.
"""
if stream is None:
stream = torch.cuda.current_stream()
# pyrefly: ignore [bad-argument-type]
super().record(stream)
def wait(self, stream: Stream | torch.Stream | None = None) -> None:
r"""Make all future work submitted to the given stream wait for this event.
Args:
stream (Stream, torch.Stream, optional): Uses ``torch.cuda.current_stream()`` if no stream is specified.
.. note:: This is a wrapper around ``cudaStreamWaitEvent()``: see
`CUDA Event documentation`_ for more info.
"""
if stream is None:
stream = torch.cuda.current_stream()
# pyrefly: ignore [bad-argument-type]
super().wait(stream)
def query(self):
r"""Check if all work currently captured by event has completed.
Returns:
A boolean indicating if all work currently captured by event has
completed.
"""
return super().query()
def elapsed_time(self, end_event: Event):
r"""Return the time elapsed.
Time reported in milliseconds after the event was recorded and
before the end_event was recorded.
Args:
end_event (Event): the end event.
"""
return super().elapsed_time(end_event)
def synchronize(self) -> None:
r"""Wait for the event to complete.
Waits until the completion of all work currently captured in this event.
This prevents the CPU thread from proceeding until the event completes.
.. note:: This is a wrapper around ``cudaEventSynchronize()``: see
`CUDA Event documentation`_ for more info.
"""
super().synchronize()
def ipc_handle(self):
r"""Return an IPC handle of this event.
If not recorded yet, the event will use the current device.
"""
return super().ipc_handle()
@property
def _as_parameter_(self):
return ctypes.c_void_p(self.cuda_event)
def __repr__(self) -> str:
if self.cuda_event:
return f"<torch.cuda.Event {self._as_parameter_.value:#x}>"
else:
return "<torch.cuda.Event uninitialized>"
@@ -0,0 +1,834 @@
r"""
This module exposes a TunableOp interface.
Some operations, such as GEMMs, could be implemented using more than one library
or more than one technique. For example, a GEMM could be implemented for CUDA or
ROCm using either the blas or blasLt libraries. Further, ROCm's rocblas and
hipblaslt libraries allow the user to query for all possible algorithms and then
choose one. How does one know which implementation is the fastest and should be
chosen? That's what TunableOp provides.
Enabling TunableOp and Tuning Separately
========================================
The TunableOp feature is enabled separately from enabling the tuning phase
itself. Enabling TunableOp means that PyTorch will replace any standard
operators with their Tunable implementations. Any call to a TunableOp first
checks whether it has already been tuned for the given operator inputs. If so,
it will immediately call the tuned operation; no further tuning will take place
even when the tuning setting is enabled. Instead if no tuning result is found,
and tuning is enabled, the TunableOp will benchmark every registered
implementation of that operator for the given set of inputs and select the
fastest.
File Input and Output
=====================
The first time any TunableOp is invoked, the internal database of tuned
operations will be prepared by attempting to read the results from the given
file. The default filename is 'tunableop_results.csv'. To support tuning when
multiple GPUs are used across multiple processes, the GPU device ordinal is
automatically inserted into the filename to avoid multiple processes overwriting
the same file.
If tuning is enabled and new tunings are discovered during the course of your
workload, it will also write out to this same filename with all tunings, both
the ones it read in at startup as well as the new ones found at runtime. This
can be used, for example, to build up a tunings file across many workloads by
reusing the same file. The output file is automatically created when the
application terminates. This behavior can be controlled by the C++ and Python
APIs but not the environment variables.
Assuming you specified a filename, you'll end up with a CSV file with contents
like so::
Validator,PT_VERSION,2.2.0
Validator,ROCM_VERSION,6.0.0.0-12969-1544e39
Validator,HIPBLASLT_VERSION,0.6.0-a9c5cc7
Validator,ROCBLAS_VERSION,4.0.0-72e57364-dirty
GemmTunableOp_float_NT,nt_25088_4096_64,Gemm_Hipblaslt_1219,1.262
GemmTunableOp_float_NT,nt_4096_4096_64,Gemm_Rocblas_1216,0.033
Note the "Validator" lines. If you change a library version, or ROCm version, or
PyTorch version, TunableOp will detect this and reject the tunings file because
the prior tunings are likely affected by other software changes.
The remaining lines are the tuned solutions for each TunableOp encountered
during your execution. Each line consists of 4 comma-separated fields: operator
name, operator parameters, solution name, and average execution time. The
execution time is an optional field. The CSV file can be edited, but with
caution. For example, the solution name (field 3) can be changed to "Default"
and it will fall back to the original PyTorch untuned implementation. Or, in the
case of ROCm's hipBLAS or hipBLASLt libraries, if you know the specific solution
index you can override the solution that TunableOp selected by replacing the
value. The operator name and parameters (fields 1 and 2) are internally named
and should not be modified. In the case of GemmTunableOp, field 1 indicates the
datatype and whether the inputs are transposed (T) or not (N) and field 2
indicates the M, N, K input shapes.
There is an option to enable verbose output but it is only recommended for
debugging purposes. This will produce a lot of diagnostic messages but may be
useful to see if TunableOp is being used at all. Otherwise, TunableOp is
completely silent, besides file output, unless there is a warning or error
during its use. The verbose option is only available by setting the environment
variable PYTORCH_TUNABLEOP_VEROBSE=1.
A Note on Tuning Behavior, Warmup, and Cache Effects
====================================================
Tuning an operator consists of iterating through the list or registered
implementations and profiling each one. The profile is established by running a
single implementation in a loop multiple times and taking the average execution
time. There is also an optional warmup phase prior to tuning that can help with
reaching stable power states by the hardware. During tuning of a workload the
various hardware caches will more likely produce hits than when not tuning.
There are options for flushing the instruction cache and rotate the input tensors
which might help produce a more faithful profile of the tuned operator as if the
operator were run within a larger workload instead of in a tight, repetitive loop.
By default, each possible solution for a given operator will be run for either
100 iterations or as many iterations that can be run within 30ms, whichever is
smaller, and its average execution will be calculated. The fastest solution
among all that were successfully profiled will be chosen. A profile might fail
if the given solution doesn't achieve the same accuracy as the default
implementation or if the solution returns an error code.
Current Tunable Operators
=========================
TunableGemm for ROCm
--------------------
Currently only a TunableGemm for ROCm is implemented. Note that CUDA builds of
PyTorch will function correctly when using TunableOp but the only solution
available to CUDA builds is the 'Default' implementation i.e. the original
cuBLAS default, now called through TunableOp. Any call to at::cuda::blas::gemm()
or ::bgemm() will be routed through TunableOp when enabled. Calling gemm() for a
given set of input arguments (transa, transb, m, n, k) will attempt to use the
fastest available implementation across both rocblas and hipblaslt.
Offline Tuning
==============
Motivation
----------
There are several use cases for offline tuning.
One use case involves a workload with a high-memory utilization, where regular tuning might lead to running out of memory.
Another use case is for compute-intensive workloads. In such cases, it is more resource-efficient to collect
the GEMMs for the workload once and then tune repeatedly with different tuning parameters or libraries.
Workflow
--------
There are basically two steps:
1) Set the environment variables to collect the untuned GEMM and this will generate ``tunableop_untuned0.csv``:
.. code-block:: bash
export PYTORCH_TUNABLEOP_ENABLED=1
export PYTORCH_TUNABLEOP_TUNING=0
export PYTORCH_TUNABLEOP_RECORD_UNTUNED=1
...
2) Run a Python script that reads the ``tunableop_untuned0.csv`` and generates the ``tunableop_results0.csv``, like this:
.. code-block:: python
import torch.cuda.tunable as tunable
import os
os.putenv("PYTORCH_TUNABLEOP_ENABLED", "1")
os.putenv("PYTORCH_TUNABLEOP_TUNING", "1")
os.putenv("PYTORCH_TUNABLEOP_RECORD_UNTUNED", "0")
tunable.tune_gemm_in_file("tunableop_untuned0.csv")
It is also possible to take multiple untuned files and distribute the GEMMs for tuning to multiple GPUs
within a single node. In the first step, the GEMMs are first gathered and duplicate GEMMs are eliminated.
Next, the GEMMs are distributed to different GPUs for tuning. After all GEMMs are tuned, the results from
all the GPUs are then gathered into a single file whose base filename has ``_full0`` appended to it
(for example ``tunableop_results_full0.csv``). Finally, this new file, containing the gathered results, will be
duplicated N times, once for each GPU as convenience to the user will run the workload with the tuned
configuration on N GPUs.
.. code-block:: python
if __name__ == "__main__":
num_gpus = 8 # number of GPUs that will be used during the tuning process
tunable.mgpu_tune_gemm_in_file("tunableop_untuned?.csv", num_gpus)
Note that the usage of the ``mgpu_tune_gemm_in_file`` API is different from its single GPU counterpart
(``tune_gemm_in_file``). The body of the Python script that calls the API must be wrapped in ``main()`` as shown
due to the use of concurrent futures module. The argument to ``mgpu_tune_gemm_in_file`` must contain a wild card
expression (``?`` or ``*``) to generate the list of untuned files containing the GEMMs to be processed. The ``num_gpus``
must between 1 and the total number of GPUs available.
Tuning Context
==============
The behavior of TunableOp is currently manipulated through environment
variables, the C++ interface of at::cuda::tunable::getTuningContext(), or the
torch.cuda.tunable python interfaces. The environment variables take precedence
over any setting you manipulate using the C++ or Python APIs.
Environment Variable Interface
------------------------------
Environment variables are cached the first time they are read. You cannot use the
environment variable interface programmatically since the settings become fixed.
Use the C++ or Python APIs instead.
"""
import concurrent.futures
import glob
import multiprocessing as mp
import os
import shutil
import warnings
import torch
__all__ = [
"enable",
"is_enabled",
"tuning_enable",
"tuning_is_enabled",
"record_untuned_enable",
"record_untuned_is_enabled",
"set_max_tuning_duration",
"get_max_tuning_duration",
"set_max_tuning_iterations",
"get_max_tuning_iterations",
"set_filename",
"get_filename",
"get_results",
"get_validators",
"read_file",
"tune_gemm_in_file",
"mgpu_tune_gemm_in_file",
"set_rotating_buffer_size",
"get_rotating_buffer_size",
"set_numerical_check_tolerances",
]
def enable(val: bool = True) -> None:
r"""This is the big on/off switch for all TunableOp implementations."""
torch._C._cuda_tunableop_enable(val) # type: ignore[attr-defined]
def is_enabled() -> bool:
r"""Returns whether the TunableOp feature is enabled."""
return torch._C._cuda_tunableop_is_enabled() # type: ignore[attr-defined]
def tuning_enable(val: bool = True) -> None:
r"""Enable tuning of TunableOp implementations.
When enabled, if a tuned entry isn't found, run the tuning step and record
the entry.
"""
torch._C._cuda_tunableop_tuning_enable(val) # type: ignore[attr-defined]
def tuning_is_enabled() -> bool:
r"""Returns whether TunableOp implementations can be tuned."""
return torch._C._cuda_tunableop_tuning_is_enabled() # type: ignore[attr-defined]
def record_untuned_enable(val: bool = True) -> None:
r"""Enable recording untuned of TunableOp perations for offline tuning.
When enabled, if a tuned entry isn't found, write it to the untuned file.
"""
torch._C._cuda_record_untuned_enable(val) # type: ignore[attr-defined]
def record_untuned_is_enabled() -> bool:
r"""Returns whether TunableOp operations are recorded for offline tuning."""
return torch._C._cuda_record_untuned_is_enabled() # type: ignore[attr-defined]
def set_max_tuning_duration(duration: int) -> None:
r"""Set max time in milliseconds to spend tuning a given solution.
If both max tuning duration and iterations are set, the smaller of the two
will be honored. At minimum 1 tuning iteration will always be run.
"""
torch._C._cuda_tunableop_set_max_tuning_duration(duration) # type: ignore[attr-defined]
def get_max_tuning_duration() -> int:
r"""Get max time to spend tuning a given solution."""
return torch._C._cuda_tunableop_get_max_tuning_duration() # type: ignore[attr-defined]
def set_max_tuning_iterations(iterations: int) -> None:
r"""Set max number of iterations to spend tuning a given solution.
If both max tuning duration and iterations are set, the smaller of the two
will be honored. At minimum 1 tuning iteration will always be run.
"""
torch._C._cuda_tunableop_set_max_tuning_iterations(iterations) # type: ignore[attr-defined]
def get_max_tuning_iterations() -> int:
r"""Get max iterations to spend tuning a given solution."""
return torch._C._cuda_tunableop_get_max_tuning_iterations() # type: ignore[attr-defined]
def set_filename(filename: str, insert_device_ordinal: bool = False) -> None:
r"""Set the filename to use for input/output of tuning results.
If :attr:`insert_device_ordinal` is ``True`` then the current device ordinal
will be added to the given filename automatically. This can be used in a
1-process-per-gpu scenario to ensure all processes write to a separate file.
"""
torch._C._cuda_tunableop_set_filename(filename, insert_device_ordinal) # type: ignore[attr-defined]
def get_filename() -> str:
r"""Get the results filename."""
return torch._C._cuda_tunableop_get_filename() # type: ignore[attr-defined]
def get_results() -> tuple[str, str, str, float]:
r"""Return all TunableOp results."""
return torch._C._cuda_tunableop_get_results() # type: ignore[attr-defined]
def get_validators() -> tuple[str, str]:
r"""Return the TunableOp validators."""
return torch._C._cuda_tunableop_get_validators() # type: ignore[attr-defined]
def read_file(filename: str | None = None) -> bool:
r"""Read results from a TunableOp CSV file.
If :attr:`filename` is not given, ``get_filename()`` is called.
"""
if filename is None:
filename = get_filename()
return torch._C._cuda_tunableop_read_file(filename) # type: ignore[attr-defined]
def set_rotating_buffer_size(buffer_size: int) -> None:
r"""Set rotating buffer size to this value in MB, if the buffer size is greater than zero.
If less than zero, query L2 cache size. If equal to zero, means deactivate rotating buffer.
"""
return torch._C._cuda_tunableop_set_rotating_buffer_size(buffer_size) # type: ignore[attr-defined]
def get_rotating_buffer_size() -> int:
r"""Get the rotating buffer size in kilobytes."""
return torch._C._cuda_tunableop_get_rotating_buffer_size() # type: ignore[attr-defined]
def set_numerical_check_tolerances(
enable: bool, atol: float = 1e-5, rtol: float = 1e-5
) -> None:
r"""Set the atol and rtol values in numeric check"""
return torch._C._cuda_tunableop_set_numerical_check_tolerances(enable, atol, rtol) # type: ignore[attr-defined]
def tune_gemm_in_file(filename: str) -> None:
r"""tune GEMM in file."""
if not is_enabled():
raise AssertionError("TunableOp is not enabled")
if not tuning_is_enabled():
raise AssertionError("Tuning is not enabled")
deviceid = torch.cuda.current_device()
with open(filename) as file:
for line in file:
if line.startswith(("Gemm", "ScaledGemm")):
_process_single_offline_gemm(line, deviceid)
def _gather_unique_untuned_gemm_from_files(filename_pattern: str) -> set[str]:
r"""Process multiple untuned results file and return a set with duplicates removed."""
unique_gemm_entries = set() # set will avoid duplicates
for file_path in glob.glob(filename_pattern):
with open(file_path) as file:
for line in file:
if line.startswith(("Gemm", "ScaledGemm")):
unique_gemm_entries.add(line)
return unique_gemm_entries
def _gather_tunableop_results() -> None:
r"""Gather results from multiple tunableop results file and create a single file."""
gemm_lines = set()
validator_lines = []
# Need to allow for the possibility that results filename was
# set with the Python API instead of with environment variable.
# Also possible that results filename was not set at all.
# There are several test cases to check, but ultimately we
# need a glob-able expression
results_filename = get_filename() # Note empty string could be returned here
if (
results_filename is not None and results_filename != ""
): # Case were the Python API was used to set the filename
dot_pos = results_filename.find(".")
if dot_pos != -1 and dot_pos > 0:
# Replace the character just to the left of the dot
filename_pattern = (
results_filename[: dot_pos - 1] + "?" + results_filename[dot_pos:]
)
else:
filename_pattern = "" # Needed to make linter happy
else: # Case where the environment variable was used to set the filename.
results_filename_env = os.getenv("PYTORCH_TUNABLEOP_FILENAME")
if results_filename_env is None or results_filename_env == "":
filename_pattern = "tunableop_results?.csv"
elif "%d" in results_filename_env:
filename_pattern = results_filename_env.replace("%d", "?")
else:
filename_pattern = results_filename_env.replace(".", "?.")
if "?" not in filename_pattern:
raise AssertionError(
f"filename_pattern must contain '?', got {filename_pattern!r}"
)
FirstFile = False
matching_files = glob.glob(filename_pattern)
num_matching_files = len(matching_files)
for file_path in matching_files:
with open(file_path) as file:
for line in file:
if line.startswith("Validator"):
if not (FirstFile):
# Only read Validator from first file
validator_lines.append(line)
else:
gemm_lines.add(line)
FirstFile = True
output_file = filename_pattern.replace("?", "_full0")
with open(output_file, "w") as out_file:
for line in validator_lines:
out_file.write(line)
for line in gemm_lines:
out_file.write(line)
# Create num_matching_copies of the results file
for i in range(1, num_matching_files):
duplicate_file = output_file.replace("0", str(i))
shutil.copy(output_file, duplicate_file)
def _create_matrices(
m: int,
n: int,
k: int,
lda: int,
ldb: int,
ldc: int,
transA: bool,
transB: bool,
dtypeA: torch.dtype,
deviceid: str,
dtypeB: torch.dtype | None = None,
randn: bool = True,
subMatrix: bool = False,
) -> tuple[torch.Tensor, torch.Tensor]:
r"""Helper function for _process_single_offline_gemm.
Creates matrices that are then consumed by one of the Torch GEMM APIs.
"""
# Fill parameters set for use with ScaledGEMM
fillA = 0.25
fillB = 0.75
if dtypeB is None:
dtypeB = dtypeA
if subMatrix:
# User reference for understanding leading dimension:
# https://github.com/Reference-LAPACK/lapack/blob/master/BLAS/SRC/dgemm.f
# TO DO: According to lines 108 - 133, there is no lower bound on rowsA,
# but there is a restriction on rowsB. Using this formula for now as it
# seems to work for all UTs.
rowsA = rowsB = max(ldc, k)
if randn:
matA = torch.randn(rowsA, lda, dtype=dtypeA, device=deviceid)
matB = torch.randn(rowsB, ldb, dtype=dtypeA, device=deviceid)
else:
matA = torch.full((rowsA, lda), fillA, dtype=dtypeB, device=deviceid)
matB = torch.full((rowsB, ldb), fillB, dtype=dtypeB, device=deviceid)
subA = matA[:k, :m].t() if transA else matA[:m, :k]
subB = matB[:n, :k].t() if transB else matB[:k, :n]
return subA, subB
else:
if randn:
matA = (
torch.rand(k, m, dtype=dtypeA, device=deviceid).t()
if transA
else torch.rand(m, k, dtype=dtypeA, device=deviceid)
)
matB = (
torch.rand(n, k, dtype=dtypeB, device=deviceid).t()
if transB
else torch.rand(k, n, dtype=dtypeB, device=deviceid)
)
else:
matA = (
torch.full((k, m), fillA, dtype=dtypeA, device=deviceid).t()
if transA
else torch.full((m, k), fillA, dtype=dtypeA, device=deviceid)
)
matB = (
torch.full((n, k), fillB, dtype=dtypeB, device=deviceid).t()
if transB
else torch.full((k, n), fillB, dtype=dtypeB, device=deviceid)
)
return matA, matB
def _create_batch_matrices(
m: int,
n: int,
k: int,
b: int,
lda: int,
ldb: int,
ldc: int,
transA: bool,
transB: bool,
dtype: torch.dtype,
deviceid: str,
subMatrix: bool = False,
) -> tuple[torch.Tensor, torch.Tensor]:
r"""Helper function for _process_single_offline_gemm.
Creates batch matrices that are then consumed by one of the Torch GEMM APIs.
Similar to _create_matrices but for 3D batch matrices.
"""
if subMatrix:
# User reference for understanding leading dimension:
# https://github.com/Reference-LAPACK/lapack/blob/master/BLAS/SRC/dgemm.f
# TO DO: According to lines 108 - 133, there is no lower bound on rowsA,
# but there is a restriction on rowsB. Using this formula for now as it
# seems to work for all UTs.
rowsA = rowsB = max(ldc, k)
matA = torch.randn(b, rowsA, lda, dtype=dtype, device=deviceid)
matB = torch.randn(b, rowsB, ldb, dtype=dtype, device=deviceid)
subA = matA[:b, :k, :m].transpose(1, 2) if transA else matA[:b, :m, :k]
subB = matB[:b, :n, :k].transpose(1, 2) if transB else matB[:b, :k, :n]
return subA, subB
else:
matA = (
torch.rand(b, k, m, dtype=dtype, device=deviceid)
if transA
else torch.rand(b, m, k, dtype=dtype, device=deviceid)
)
matB = (
torch.rand(b, n, k, dtype=dtype, device=deviceid)
if transB
else torch.rand(b, k, n, dtype=dtype, device=deviceid)
)
matA = matA.transpose(1, 2) if transA else matA
matB = matB.transpose(1, 2) if transB else matB
return matA, matB
def _process_single_offline_gemm(untuned_gemm_line: str, gpu_id: int) -> None:
r"""Process a single untuned GEMM."""
deviceid = "cuda:" + str(gpu_id)
dtype_dict = {
"float": torch.float32,
"tf32": torch.float32,
"double": torch.float64,
"BFloat16": torch.bfloat16,
"Half": torch.half,
"c10::complex<double>": torch.complex128,
"c10::complex<float>": torch.complex64,
"Float8_e4m3fn": torch.float8_e4m3fn,
"Float8_e5m2": torch.float8_e5m2,
"Float8_e4m3fnuz": torch.float8_e4m3fnuz,
"Float8_e5m2fnuz": torch.float8_e5m2fnuz,
}
untuned_gemm = untuned_gemm_line.strip().split(",")[:]
underscore_count = untuned_gemm[0].count("_")
# Initialize dtype to make linter happy
dtype = None
dtypeA = None
dtypeB = None
dtypeC = None
# Extract BLAS parameters
if underscore_count == 2:
[op_sig, data_type, layout] = untuned_gemm[0].split("_")
transB = layout[0] == "T"
transA = layout[1] == "T"
dtype = dtype_dict.get(data_type)
if data_type == "tf32":
torch.backends.cuda.matmul.allow_tf32 = True
else:
torch.backends.cuda.matmul.allow_tf32 = False
else: # ScaledGEMM
count = untuned_gemm[0].count("_")
if count not in [6, 7]:
raise AssertionError(f"count must be 6 or 7, got {count}")
untuned_gemm_temp = untuned_gemm[0].split("_")
# dtypeC = might not be FP8 type, keep track
# of the number of underscores
op_sig = untuned_gemm_temp[0]
data_typeA = untuned_gemm_temp[1] + "_" + untuned_gemm_temp[2]
data_typeB = untuned_gemm_temp[3] + "_" + untuned_gemm_temp[4]
if count == 7:
data_typeC = untuned_gemm_temp[5] + "_" + untuned_gemm_temp[6]
else:
data_typeC = untuned_gemm_temp[5]
transB = untuned_gemm_temp[count][0] == "T"
transA = untuned_gemm_temp[count][1] == "T"
dtypeA = dtype_dict.get(data_typeA)
dtypeB = dtype_dict.get(data_typeB)
dtypeC = dtype_dict.get(data_typeC)
untuned_gemm_temp = untuned_gemm[1].split("_")
[n, m, k] = [int(g) for g in untuned_gemm_temp[1:4]]
if op_sig == "GemmStridedBatchedTunableOp":
if untuned_gemm_temp[6] != "ld":
raise AssertionError(
f"expected 'ld' at index 6, got {untuned_gemm_temp[6]!r}"
)
[ldb, lda, ldc] = [int(g) for g in untuned_gemm_temp[7:10]]
else:
if untuned_gemm_temp[4] != "ld":
raise AssertionError(
f"expected 'ld' at index 4, got {untuned_gemm_temp[4]!r}"
)
[ldb, lda, ldc] = [int(g) for g in untuned_gemm_temp[5:8]]
# Detect subMatrix case
if all(item in [n, m, k] for item in [lda, ldb, ldc]):
subMatrix = False
else:
subMatrix = True
if op_sig == "GemmTunableOp":
# Warnings for unsupported cases:
if m == 1 or n == 1 or k == 1:
if (not transA) and (not transB):
pass # case is supported
elif transA and n == 1:
pass # case is supported
else:
warnings.warn(
"Offline tuning is not supported for this GEMM. Use online tuning instead. "
+ f"Skipped tuning for: {untuned_gemm[1]}",
stacklevel=2,
)
return
# Resolve linter issue
if dtype is None or not isinstance(dtype, torch.dtype):
raise TypeError(f"dtype must be a torch.dtype, but got {dtype}")
matA, matB = _create_matrices(
m, n, k, lda, ldb, ldc, transA, transB, dtype, deviceid, subMatrix=subMatrix
)
torch.mm(matA, matB)
elif op_sig == "GemmStridedBatchedTunableOp":
# Warnings for unsupported cases:
if m == 1 or n == 1 or k == 1:
warnings.warn(
"Offline tuning is not support for this GEMM. Use online tuning instead. "
+ f"Skipped tuning for: {untuned_gemm[1]}",
stacklevel=2,
)
return
[b] = [int(g) for g in untuned_gemm_temp[5:6]]
# Resolve linter issue
if dtype is None or not isinstance(dtype, torch.dtype):
raise TypeError(f"dtype must be a torch.dtype, but got {dtype}")
matA, matB = _create_batch_matrices(
m,
n,
k,
b,
lda,
ldb,
ldc,
transA,
transB,
dtype,
deviceid,
subMatrix=subMatrix,
)
torch.bmm(matA, matB)
elif op_sig == "ScaledGemmTunableOp":
# Only combination supported by PyTorch
if transB is not True:
raise AssertionError(
f"transB must be True for ScaledGemmTunableOp, got {transB}"
)
if transA is not False:
raise AssertionError(
f"transA must be False for ScaledGemmTunableOp, got {transA}"
)
# Resolve linter issue
if dtypeA is None or not isinstance(dtypeA, torch.dtype):
raise TypeError(f"dtype must be a torch.dtype, but got {dtypeA}")
matA, matB = _create_matrices(
m,
n,
k,
lda,
ldb,
ldc,
transA,
transB,
dtypeA,
deviceid,
dtypeB=dtypeB,
randn=False,
subMatrix=subMatrix,
)
if untuned_gemm_temp[8] != "rw":
raise AssertionError(
f"expected 'rw' at index 8, got {untuned_gemm_temp[8]!r}"
)
if untuned_gemm_temp[9] == "1":
rowwise = True
else:
rowwise = False
if rowwise:
scaleA = (
torch.ones((1, m), device=deviceid)
if transA
else torch.ones((m, 1), device=deviceid)
)
scaleB = (
torch.ones((1, n), device=deviceid)
if transB
else torch.ones((n, 1), device=deviceid)
)
else:
scaleA = torch.tensor(0.8, device=deviceid)
scaleB = torch.tensor(0.9, device=deviceid)
if untuned_gemm_temp[10] != "bias":
raise AssertionError(
f"expected 'bias' at index 10, got {untuned_gemm_temp[10]!r}"
)
if untuned_gemm_temp[11] == "None": # no bias vector
torch._scaled_mm(
matA, matB, scale_a=scaleA, scale_b=scaleB, out_dtype=dtypeC
)
else: # bias vector present
fillbias = 0.10
bias_dtype = dtype_dict.get(untuned_gemm_temp[11])
bias = (
torch.full((n,), fillbias, dtype=bias_dtype, device=deviceid)
if transB
else torch.full((m,), fillbias, dtype=bias_dtype, device=deviceid)
)
torch._scaled_mm(
matA, matB, scale_a=scaleA, scale_b=scaleB, out_dtype=dtypeC, bias=bias
)
elif op_sig == "GemmAndBiasTunableOp":
# y = x*A^T + b
if transA == transB:
raise AssertionError(
f"transA and transB must differ for GemmAndBiasTunableOp, got transA={transA}, transB={transB}"
)
# Resolve linter issue
if dtype is None or not isinstance(dtype, torch.dtype):
raise TypeError(f"dtype must be a torch.dtype, but got {dtype}")
bias = torch.rand(n, dtype=dtype, device=deviceid)
X, matA = _create_matrices(
m, n, k, lda, ldb, ldc, transA, transB, dtype, deviceid, subMatrix=subMatrix
)
matA = matA.t()
torch.nn.functional.linear(X, matA, bias)
else:
warnings.warn(f"error: unknown op {op_sig}", stacklevel=2)
def _check_tuning_assertions() -> None:
r"""Helper function for multi-GPU tuning case. Need to check that TunableOp feature
is enabled and that tuning is enabled.
"""
if is_enabled() is False:
warnings.warn("TunableOp was disabled. Trying to enable now.", stacklevel=2)
enable(True)
if is_enabled() is not True:
raise AssertionError("is_enabled() must be True")
if tuning_is_enabled() is not True:
raise AssertionError("tuning_is_enabled() must be True")
if record_untuned_is_enabled() is not False:
raise AssertionError("record_untuned_is_enabled() must be False")
def mgpu_tune_gemm_in_file(filename_pattern: str, num_gpus: int) -> None:
r"""Process one or more files and distribute work over one or more GPUs."""
unique_gemm_entries = _gather_unique_untuned_gemm_from_files(filename_pattern)
total_gpus = torch.cuda.device_count()
if not (1 <= num_gpus <= total_gpus):
raise AssertionError(
f"num_gpus must be between 1 and {total_gpus}, got {num_gpus}"
)
mp_context = mp.get_context("spawn")
futures = [] # empty list to hold futures
# GEMM are assigned to GPUs in a round robin manner
h = 0
with concurrent.futures.ProcessPoolExecutor(
max_workers=num_gpus,
mp_context=mp_context,
initializer=_check_tuning_assertions,
) as executor:
# The workers are a separate process. TunableOp will be
# enabled in the child processes if PYTORCH_TUNABLEOP_ENABLED=1
# In the initializer, we also try to enable TunableOP if th
# environment variable was NOT set.
for line in unique_gemm_entries:
future = executor.submit(_process_single_offline_gemm, line, h)
futures.append(future)
h = (h + 1) % num_gpus
for future in concurrent.futures.as_completed(futures):
future.result()
torch.cuda.synchronize()
_gather_tunableop_results()