summaryrefslogtreecommitdiffstats
path: root/userspace/cuda/tools/callwalk.py
diff options
context:
space:
mode:
authorArthur Hanson Rasmusson <arthur@vgpu.io>2026-09-23 12:11:04 -0400
committerArthur Hanson Rasmusson <arthur@vgpu.io>2026-09-23 12:11:04 -0400
commitf874d709e5a45d63c8ee9ab6b0300195aa5d20a5 (patch)
tree2d07208f789e402686ac960e5ad7456b6fd21cfd /userspace/cuda/tools/callwalk.py
parentae18e08d02500579f084616874d8f2ae57f8281b (diff)
downloadopen-iov-f874d709e5a45d63c8ee9ab6b0300195aa5d20a5.tar.gz
open-iov-f874d709e5a45d63c8ee9ab6b0300195aa5d20a5.zip
userspace/cuda: add CUDA userspace driver analysis, tooling and name maps
Add the analysis of the NVIDIA CUDA userspace driver used by Open-IOV VMs: * docs/01-08: architecture (layers, object model, threads, sync, memory, channels, launch), build and execution model, the driver/kernel/GPU interface contract, virtualization approaches, the 570.144 decompilation map with the corrected libcuda crash path and the current engine-topology blocker, tooling, and a version-upgrade playbook. * tools/: version-agnostic toolchain - stdlib ELF/interface toolkit (exports, unwind-table function map, wrapper resolution, version delta, crash address decode), callgraph walker, corpus annotator, and Ghidra analysis/decompilation/name-map scripts with resumable parallel drivers. * 570.144/annotations/: our name map (exports + resolved wrappers + verified internals) and subsystem index, tracked so naming work accumulates. * READ-ME-FIRST.md: reading guide with the fast lookup recipes. The full decompiled corpus and annotated corpus stay untracked (vendor- derived, gitignored under the version run/ tree).
Diffstat (limited to 'userspace/cuda/tools/callwalk.py')
-rwxr-xr-xuserspace/cuda/tools/callwalk.py133
1 files changed, 133 insertions, 0 deletions
diff --git a/userspace/cuda/tools/callwalk.py b/userspace/cuda/tools/callwalk.py
new file mode 100755
index 0000000..15feb45
--- /dev/null
+++ b/userspace/cuda/tools/callwalk.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+#
+# callwalk.py - walk the Open-IOV callgraph index and print callee trees /
+# caller trees around a function. This is the quick way to go from an export
+# wrapper to the internal implementation chain without opening Ghidra.
+#
+# Usage:
+# callwalk.py <callgraph.tsv> callees <hex_file_vaddr> [depth] [max_per_node]
+# callwalk.py <callgraph.tsv> callers <hex_file_vaddr> [depth] [max_per_node]
+# callwalk.py <callgraph.tsv> chain <hex_from> <hex_to> # shortest path
+#
+# Addresses are FILE virtual addresses (libcuda+0x...); the callgraph columns
+# `caller_file`/`callee_file` are used, so Ghidra image-base offsets do not
+# matter. `names.tsv` (file_vaddr<TAB>name) is picked up next to the callgraph
+# when present, and used to label nodes.
+#
+# SPDX-License-Identifier: BSD-3-Clause
+
+import sys
+from collections import defaultdict, deque
+
+
+def load_graph(path):
+ callees = defaultdict(list)
+ callers = defaultdict(list)
+ with open(path) as f:
+ header = f.readline()
+ for line in f:
+ parts = line.rstrip("\n").split("\t")
+ if len(parts) < 4:
+ continue
+ cf, kf = int(parts[2], 16), int(parts[3], 16)
+ callees[cf].append(kf)
+ callers[kf].append(cf)
+ return callees, callers
+
+
+def load_names(path):
+ names = {}
+ try:
+ with open(path) as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ parts = line.split("\t")
+ if len(parts) >= 2:
+ try:
+ names[int(parts[0], 16)] = parts[1]
+ except ValueError:
+ pass
+ except FileNotFoundError:
+ pass
+ return names
+
+
+def label(a, names):
+ return f"0x{a:08x}({names[a]})" if a in names else f"0x{a:08x}"
+
+
+def walk(adj, start, depth, limit):
+ seen = {start}
+ frontier = [start]
+ for d in range(depth):
+ nxt = []
+ for node in frontier:
+ kids = sorted(set(adj.get(node, [])))
+ if not kids:
+ continue
+ print(" " * (d + 1) + " | ".join(label(k, NAMES) for k in kids[:limit])
+ + (" ..." if len(kids) > limit else ""))
+ for k in kids:
+ if k not in seen:
+ seen.add(k)
+ nxt.append(k)
+ frontier = nxt
+ if not frontier:
+ break
+
+
+def shortest_path(callees, start, goal):
+ if start == goal:
+ return [start]
+ prev = {start: None}
+ q = deque([start])
+ while q:
+ node = q.popleft()
+ for k in callees.get(node, []):
+ if k in prev:
+ continue
+ prev[k] = node
+ if k == goal:
+ path = [goal]
+ while prev[path[-1]] is not None:
+ path.append(prev[path[-1]])
+ return list(reversed(path))
+ q.append(k)
+ return None
+
+
+def main():
+ if len(sys.argv) < 4:
+ print(__doc__)
+ sys.exit(2)
+ graph = sys.argv[1]
+ mode = sys.argv[2]
+ addr = int(sys.argv[3], 16)
+ depth = int(sys.argv[4]) if len(sys.argv) > 4 else 4
+ limit = int(sys.argv[5]) if len(sys.argv) > 5 else 8
+
+ global NAMES
+ callees, callers = load_graph(graph)
+ NAMES = load_names(graph.rsplit("/", 1)[0] + "/names.tsv")
+
+ if mode == "chain":
+ goal = int(sys.argv[4], 16)
+ path = shortest_path(callees, addr, goal)
+ if not path:
+ print("no direct path found")
+ else:
+ for a in path:
+ print(label(a, NAMES))
+ return
+
+ print(f"{mode} of {label(addr, NAMES)}:")
+ walk(callees if mode == "callees" else callers, addr, depth, limit)
+
+
+NAMES = {}
+
+if __name__ == "__main__":
+ main()