From c587eac377c1c5b720142edadebaace5cb1b17c0 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Rasmusson Date: Wed, 23 Sep 2026 13:48:00 -0400 Subject: userspace/cuda: complete the analysis pass * Name recovery: name_candidates.py proposes the hand-written API-layer implementation for each exported wrapper by grouping version variants and intersecting their callees; 180 high-confidence proposals recorded in annotations/name-candidates.tsv, 29 of them body-verified and promoted to names.tsv with evidence in subsystems.tsv. * Engine records: identified the 0x14-byte per-engine descriptor, mapped its fields (kind, engine type, instance, class id, CE caps) and the existence callback, and documented how the missing async copy engine (COPY14) leaves the secondary record zeroed and blocks context creation (docs/05 section 3.7, public-safe). * Targeted type recovery: AnalyzeFunctions.java runs the parameter-ID analyzer over a curated list of 80 functions; 16 improved materially and the typed decompilations are kept under the version run tree. * Cross-version: cudatool.py fdematch measures how names transfer between patch versions (570.144 -> 570.211.01: exports 100%, verified internals ~70%, generated wrappers must be re-resolved by relocation, never by address arithmetic); documented in the upgrade playbook. * Corpus tooling: diff_corpus.py (version-to-version corpus diff) and subsystem_digest.py (per-subsystem call trees). * Deprecate the old per-version helpers whose address handling and analysis timeout produced wrong results. Generated corpora and typed outputs remain untracked under the version run tree. --- userspace/cuda/tools/cudatool.py | 154 ++++++++++ userspace/cuda/tools/diff_corpus.py | 221 ++++++++++++++ userspace/cuda/tools/ghidra/AnalyzeFunctions.java | 343 ++++++++++++++++++++++ userspace/cuda/tools/name_candidates.py | 224 ++++++++++++++ userspace/cuda/tools/subsystem_digest.py | 190 ++++++++++++ 5 files changed, 1132 insertions(+) create mode 100755 userspace/cuda/tools/diff_corpus.py create mode 100644 userspace/cuda/tools/ghidra/AnalyzeFunctions.java create mode 100755 userspace/cuda/tools/name_candidates.py create mode 100755 userspace/cuda/tools/subsystem_digest.py (limited to 'userspace/cuda/tools') diff --git a/userspace/cuda/tools/cudatool.py b/userspace/cuda/tools/cudatool.py index 2e4c1c6..3417d2b 100755 --- a/userspace/cuda/tools/cudatool.py +++ b/userspace/cuda/tools/cudatool.py @@ -21,6 +21,7 @@ # strings [--grep RE] defined strings with file offsets # delta interface-level diff between two versions # wrappers export stub -> generated wrapper target +# fdematch function-range match report across versions # note CUDA forward-compatibility note (JSON) # # Address convention: every address printed by this tool is a FILE VIRTUAL @@ -476,6 +477,151 @@ def cmd_wrappers(args): print(f"0x{w['stub']:08x} -> 0x{w['wrapper']:08x} {w['name']}") +def cmd_fdematch(args): + """Match function ranges between two libraries by unwind-table (FDE) + start/size, using the common exports as local address anchors. + + A single global address shift is not enough across patch versions: the + library is partly re-laid-out (function insertions/removals drift the + addresses locally, and the generated wrapper region is regenerated). The + matcher therefore uses the nearest common export by address as the local + anchor and looks for a B function of the same FDE size within a small + window around old_start + anchor_delta. This measures how transferable a + name map is between two driver versions, and the --names breakdown shows + which parts of the map (exports, generated wrappers, internals) survive.""" + import bisect + from collections import Counter, defaultdict + + a, b = ELF(args.libA), ELF(args.libB) + fa = {s: (e - s if e is not None else None) + for s, e in a.function_ranges()} + fb = {s: (e - s if e is not None else None) + for s, e in b.function_ranges()} + + ea = {s["name"]: s["value"] for s in a.exports()} + eb = {s["name"]: s["value"] for s in b.exports()} + common = sorted(set(ea) & set(eb)) + anchors = sorted((ea[n], eb[n] - ea[n]) for n in common) + anchor_addrs = [x for x, _ in anchors] + deltas = [d for _, d in anchors] + d_counter = Counter(d // 0x1000 * 0x1000 for d in deltas) + + def anchor_delta(v): + i = bisect.bisect_left(anchor_addrs, v) + cand = [] + if i < len(anchors): + cand.append(anchors[i]) + if i > 0: + cand.append(anchors[i - 1]) + if not cand: + return 0 + return min(cand, key=lambda t: abs(t[0] - v))[1] + + size_b = defaultdict(list) + for s, sz in fb.items(): + size_b[sz].append(s) + for k in size_b: + size_b[k].sort() + + def window_matches(v, size, w): + if size is None or size not in size_b: + return [] + target = v + anchor_delta(v) + starts = size_b[size] + lo = bisect.bisect_left(starts, target - w) + hi = bisect.bisect_right(starts, target + w) + return starts[lo:hi] + + exact = window = unique = unmatched = 0 + for v, size in fa.items(): + m = window_matches(v, size, args.window) + if not m: + unmatched += 1 + elif v + anchor_delta(v) in m: + exact += 1 + if len(m) == 1: + unique += 1 + else: + window += 1 + if len(m) == 1: + unique += 1 + matched = exact + window + total = len(fa) + rate = matched / total if total else 0.0 + + transfer = None + if args.names: + transfer = defaultdict(lambda: dict(entries=0, exact=0, window=0, + missing=0, not_in_a=0)) + with open(args.names) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + p = line.split("\t") + if len(p) < 2: + continue + try: + v = int(p[0], 16) + except ValueError: + continue + src = p[2] if len(p) > 2 else "?" + t = transfer[src] + t["entries"] += 1 + if v not in fa: + t["not_in_a"] += 1 + continue + m = window_matches(v, fa[v], args.window) + if not m: + t["missing"] += 1 + elif v + anchor_delta(v) in m: + t["exact"] += 1 + else: + t["window"] += 1 + + if args.json: + print(json.dumps(dict( + lib_a=args.libA, lib_b=args.libB, + functions_a=total, functions_b=len(fb), + anchors=len(anchors), + delta_median=hex(deltas[len(deltas) // 2]) if deltas else "0x0", + delta_clusters=[[hex(k), v] for k, v in d_counter.most_common(6)], + window=hex(args.window), + exact=exact, window_matches=window, unique=unique, + unmatched=unmatched, match_rate=round(rate, 4), + name_map=transfer), indent=1)) + return + + print(f"fdematch A: {args.libA}") + print(f" B: {args.libB}") + print(f"functions : A={total} B={len(fb)} " + f"(ranges from .eh_frame_hdr)") + print(f"anchors : {len(anchors)} common exports") + if deltas: + print(f"delta clusters : " + ", ".join( + f"{'+' if k >= 0 else '-'}0x{abs(k):x} ({v})" + for k, v in d_counter.most_common(5))) + print(f"matching window : +/-0x{args.window:x} around " + f"nearest-anchor-shifted address, same FDE size") + print(f"exact match : {exact} ({exact/total*100:.1f}%)" + if total else "exact match : 0") + print(f"window match : {window} ({window/total*100:.1f}%)" + if total else "window match : 0") + print(f"unique match : {unique} ({unique/total*100:.1f}%)" + if total else "unique match : 0") + print(f"unmatched A : {unmatched}") + print(f"match rate : {rate*100:.1f}% of A's {total} functions") + if transfer: + print(f"name map : {args.names} (per source column)") + for src in sorted(transfer): + t = transfer[src] + got = t["exact"] + t["window"] + pct = got / t["entries"] * 100 if t["entries"] else 0.0 + print(f" {src:<14} : {got}/{t['entries']} ({pct:.1f}%) " + f"exact={t['exact']} window={t['window']} " + f"missing={t['missing']} not-in-A={t['not_in_a']}") + + def cmd_note(args): elf = ELF(args.lib) print(json.dumps(elf.cuda_note(), indent=2)) @@ -502,6 +648,14 @@ def main(): p = sub.add_parser("note"); p.add_argument("lib"); p.set_defaults(fn=cmd_note) p = sub.add_parser("wrappers"); p.add_argument("lib") p.add_argument("--json", action="store_true"); p.set_defaults(fn=cmd_wrappers) + p = sub.add_parser("fdematch"); p.add_argument("libA"); p.add_argument("libB") + p.add_argument("--names", default=None, + help="names.tsv to report transferability for") + p.add_argument("--window", type=lambda x: int(x, 0), default=0x100, + help="same-size match window around the anchored address " + "(default 0x100)") + p.add_argument("--json", action="store_true") + p.set_defaults(fn=cmd_fdematch) args = ap.parse_args() try: diff --git a/userspace/cuda/tools/diff_corpus.py b/userspace/cuda/tools/diff_corpus.py new file mode 100755 index 0000000..7224ac7 --- /dev/null +++ b/userspace/cuda/tools/diff_corpus.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# +# diff_corpus.py - diff two decompiled corpus indexes across driver versions. +# +# Inputs are `decompiled_index.tsv` files (the merged output of the batch +# decompiler): a header line and one row per function: +# addrfile_addrsizenamefilelinestatus +# `addr` is the Ghidra program address, `file_addr` the file virtual address +# (libcuda+0x...) used everywhere in the analysis. +# +# Matching (function identity across versions): +# 1. Named functions (anything whose name does not start with FUN_) match by +# name; the same name in both indexes is the same function. +# 2. The address shift between the two builds is estimated as the median of +# (file_addrB - file_addrA) over name-matched functions. +# 3. Anonymous FUN_* functions match when B has a function at +# file_addrA + shift; a size difference there is reported as a resize. +# Functions that do not land on a B function are `removed`; B functions +# that no A function maps to are `added`. +# +# This is deliberately address/size based (the corpus has no structural hash), +# so it measures exactly what matters for name-map transfer: did the function +# move, grow, disappear or appear. +# +# Usage: +# diff_corpus.py [--label-a A] [--label-b B] +# [--out FILE] [--max-list N] [--json] +# +# Exit status is 0 for a normal report (differences do not fail the tool). +# +# SPDX-License-Identifier: BSD-3-Clause + +import argparse +import json +import statistics +import sys +from collections import Counter, defaultdict + + +def load_index(path): + funcs = {} + with open(path) as f: + f.readline() # header + for line in f: + p = line.rstrip("\n").split("\t") + if len(p) < 4: + continue + try: + funcs[int(p[1], 16)] = dict(addr=int(p[0], 16), size=int(p[2]), + name=p[3]) + except ValueError: + continue + return funcs + + +def is_named(name): + return bool(name) and not name.startswith("FUN_") + + +def label(f, names): + if f is None: + return "" + return f"{names.get(f['name'], f['name'])} (libcuda+0x{f['file']:x}, {f['size']}B)" + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("index_a") + ap.add_argument("index_b") + ap.add_argument("--label-a", default=None) + ap.add_argument("--label-b", default=None) + ap.add_argument("--out", default=None, help="write the report here " + "(default: stdout)") + ap.add_argument("--max-list", type=int, default=40, + help="max entries listed per category (default 40)") + ap.add_argument("--json", action="store_true", help="emit JSON") + args = ap.parse_args() + + a = load_index(args.index_a) + b = load_index(args.index_b) + la = args.label_a or args.index_a + lb = args.label_b or args.index_b + + named_a = defaultdict(list) + named_b = defaultdict(list) + for fa, f in a.items(): + if is_named(f["name"]): + named_a[f["name"]].append(fa) + for fb, f in b.items(): + if is_named(f["name"]): + named_b[f["name"]].append(fb) + + matched_named = [] # (name, fa, fb) + for name in sorted(set(named_a) & set(named_b)): + # duplicate symbol names (multiple thunks) match in address order + for fa, fb in zip(sorted(named_a[name]), sorted(named_b[name])): + matched_named.append((name, fa, fb)) + + deltas = [fb - fa for _, fa, fb in matched_named] + shift = int(statistics.median(deltas)) if deltas else 0 + dominant = (sum(1 for d in deltas if abs(d - shift) <= 0x40) / + len(deltas)) if deltas else 0.0 + + used_a = set(fa for _, fa, _ in matched_named) + used_b = set(fb for _, _, fb in matched_named) + moved = [(n, fa, fb) for n, fa, fb in matched_named if fa != fb] + resized = [(n, fa, fb) for n, fa, fb in matched_named + if a[fa]["size"] != b[fb]["size"]] + + shifted = [] + for fa, f in sorted(a.items()): + if fa in used_a or is_named(f["name"]): + continue + fb = fa + shift + if fb in b and fb not in used_b: + shifted.append((fa, fb)) + used_a.add(fa) + used_b.add(fb) + shifted_resized = [(fa, fb) for fa, fb in shifted + if a[fa]["size"] != b[fb]["size"]] + shifted_ok = [(fa, fb) for fa, fb in shifted + if a[fa]["size"] == b[fb]["size"]] + + removed = sorted(fa for fa in a if fa not in used_a) + added = sorted(fb for fb in b if fb not in used_b) + + anon_a = sum(1 for f in a.values() if not is_named(f["name"])) + anon_b = sum(1 for f in b.values() if not is_named(f["name"])) + named_count_a = sum(len(v) for v in named_a.values()) + named_count_b = sum(len(v) for v in named_b.values()) + matched_total = len(matched_named) + len(shifted) + + result = dict( + label_a=la, label_b=lb, + total_a=len(a), total_b=len(b), + named_a=named_count_a, named_b=named_count_b, + anon_a=anon_a, anon_b=anon_b, + shift=shift, shift_dominant=round(dominant, 4), + matched_named=len(matched_named), matched_shifted=len(shifted_ok), + matched_shifted_resized=len(shifted_resized), + moved=len(moved), resized_named=len(resized), + added=len(added), removed=len(removed), + match_rate_a=round(matched_total / len(a), 4) if a else 0.0, + match_rate_b=round(matched_total / len(b), 4) if b else 0.0, + named_transfer_rate=round(len(matched_named) / named_count_a, 4) + if named_count_a else 0.0, + ) + + def hexlist(addrs): + return [f"libcuda+0x{x:x} {a[x]['name']} ({a[x]['size']}B)" + if x in a else f"libcuda+0x{x:x} {b[x]['name']} ({b[x]['size']}B)" + for x in addrs[:args.max_list]] + + if args.json: + out = dict(result) + out["added_list"] = [f"libcuda+0x{x:x} {b[x]['name']} ({b[x]['size']}B)" + for x in added[:args.max_list]] + out["removed_list"] = [f"libcuda+0x{x:x} {a[x]['name']} ({a[x]['size']}B)" + for x in removed[:args.max_list]] + text = json.dumps(out, indent=1) + else: + lines = [] + lines.append(f"corpus diff: {la} -> {lb}") + lines.append("=" * 72) + lines.append(f"functions : A={len(a)} B={len(b)} " + f"(named A={len(named_a)} B={len(named_b)}; " + f"anonymous A={anon_a} B={anon_b})") + lines.append(f"address shift : 0x{shift:x} " + f"(dominant for {dominant*100:.1f}% of name matches)") + lines.append(f"matched : {matched_total} " + f"({len(matched_named)} by name, " + f"{len(shifted_ok)} anonymous at shifted address)") + lines.append(f"match rate : A {result['match_rate_a']*100:.1f}% " + f"B {result['match_rate_b']*100:.1f}% " + f"(name map transfer {result['named_transfer_rate']*100:.1f}%)") + lines.append(f"moved : {len(moved)} named functions changed " + f"address") + lines.append(f"resized : {len(resized)} named + " + f"{len(shifted_resized)} anonymous matched functions " + f"changed size") + lines.append(f"added : {len(added)}") + lines.append(f"removed : {len(removed)}") + lines.append("") + if moved: + lines.append(f"-- moved named (first {args.max_list}) --") + for n, fa, fb in moved[:args.max_list]: + lines.append(f" {n}: libcuda+0x{fa:x} -> libcuda+0x{fb:x} " + f"({fb-fa:+#x})") + if resized: + lines.append(f"-- resized named (first {args.max_list}) --") + for n, fa, fb in resized[:args.max_list]: + lines.append(f" {n}: libcuda+0x{fa:x} {a[fa]['size']}B -> " + f"{b[fb]['size']}B") + if shifted_resized: + lines.append(f"-- resized anonymous (first {args.max_list}) --") + for fa, fb in shifted_resized[:args.max_list]: + lines.append(f" libcuda+0x{fa:x} {a[fa]['size']}B -> " + f"libcuda+0x{fb:x} {b[fb]['size']}B") + if added: + lines.append(f"-- added (first {args.max_list}) --") + lines.extend(" " + x for x in + hexlist(added)) + if removed: + lines.append(f"-- removed (first {args.max_list}) --") + lines.extend(" " + x for x in + hexlist(removed)) + text = "\n".join(lines) + + if args.out: + with open(args.out, "w") as f: + f.write(text + "\n") + else: + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/userspace/cuda/tools/ghidra/AnalyzeFunctions.java b/userspace/cuda/tools/ghidra/AnalyzeFunctions.java new file mode 100644 index 0000000..e0ae6cb --- /dev/null +++ b/userspace/cuda/tools/ghidra/AnalyzeFunctions.java @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: BSD-3-Clause +// +// AnalyzeFunctions.java - Ghidra post-script: run the "Decompiler Parameter ID" +// analyzer over a caller-supplied list of functions, using the analyzer's +// AddressSetView entry point instead of a whole-program analysis pass. +// +// This is the targeted type-recovery pass for the CUDA userspace driver: the +// full auto-analysis deliberately disables the parameter-ID analyzer because it +// decompiles every function and is very expensive on a 29k-function stripped +// binary. Re-running it over a curated address set (crash path, engine-record +// path, handle pool, RM alloc, resolved wrapper implementations, the largest +// .text functions) recovers parameter counts/types and local variables where +// the decompiler can infer them, without touching the rest of the program. +// +// Usage: +// analyzeHeadless -process -noanalysis \ +// -scriptPath tools/ghidra \ +// -postScript AnalyzeFunctions.java \ +// [--timeout ] [--out ] [--list] [--batch ] +// +// one entry per line: "0x [# comment]". +// Addresses are FILE virtual addresses (libcuda+0x...). +// Blank lines and lines starting with '#' are ignored. +// --timeout per-function decompiler timeout for the analyzer +// (default 60, matching the analyzer's own default). +// --out write the before/after report to this file as well. +// --list only resolve the addresses to functions and print them; +// do not run the analyzer (dry run). +// --batch run the analyzer in batches of n functions (default 0 = +// one batch). Useful to checkpoint very large sets. +// +// The script translates file vaddr -> program address via the program's image +// base (0x100000 for this binary), exactly like DecompileAt.java. If an address +// is not inside a known function it is disassembled and turned into one. +// +// The analyzer class was renamed between Ghidra versions +// (DecompilerParameterIdAnalyzer -> DecompilerFunctionAnalyzer); the script +// looks up both and invokes the AddressSetView-based entry point +// added(AddressSetView, TaskMonitor) when present, falling back to +// added(Program, AddressSetView, TaskMonitor, MessageLog). + +import ghidra.app.script.GhidraScript; +import ghidra.app.services.Analyzer; +import ghidra.app.util.importer.MessageLog; +import ghidra.framework.options.Options; +import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressSet; +import ghidra.program.model.address.AddressSetView; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; +import ghidra.program.model.listing.Listing; +import ghidra.program.model.listing.Variable; +import ghidra.util.classfinder.ClassSearcher; +import ghidra.util.task.TaskMonitor; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +public class AnalyzeFunctions extends GhidraScript { + + private static final String[] ANALYZER_CLASSES = { + "ghidra.app.plugin.core.analysis.DecompilerFunctionAnalyzer", + "ghidra.app.plugin.core.analysis.DecompilerParameterIdAnalyzer", + }; + + private static final String OPTION_TIMEOUT = "Analysis Decompiler Timeout (sec)"; + + private static class Target { + long fileAddr; + Address entry; + String name; + int paramsBefore; + int localsBefore; + int paramsAfter = -1; + int localsAfter = -1; + } + + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 1) { + println("usage: AnalyzeFunctions.java [--timeout secs] " + + "[--out tsv] [--list] [--batch n]"); + return; + } + + String addrFile = args[0]; + int timeoutSecs = 60; + String outFile = null; + boolean listOnly = false; + int batchSize = 0; + for (int i = 1; i < args.length; i++) { + switch (args[i]) { + case "--timeout": + timeoutSecs = Integer.parseInt(args[++i]); + break; + case "--out": + outFile = args[++i]; + break; + case "--list": + listOnly = true; + break; + case "--batch": + batchSize = Integer.parseInt(args[++i]); + break; + default: + println("unknown argument: " + args[i]); + return; + } + } + + long imageBase = currentProgram.getImageBase().getOffset(); + List fileAddrs = readAddresses(addrFile); + println("AnalyzeFunctions: " + fileAddrs.size() + " address(es), image base 0x" + + Long.toHexString(imageBase)); + + List targets = resolveTargets(fileAddrs, imageBase); + println("AnalyzeFunctions: resolved " + targets.size() + " function(s)"); + + if (listOnly) { + for (Target t : targets) { + println(String.format(" libcuda+0x%06x %s program=0x%x params=%d locals=%d", + t.fileAddr, t.name, t.entry.getOffset(), t.paramsBefore, + t.localsBefore)); + } + return; + } + + Analyzer analyzer = newAnalyzer(); + if (analyzer == null) { + println("ERROR: no Decompiler Parameter ID analyzer class found"); + return; + } + println("AnalyzeFunctions: analyzer = " + analyzer.getClass().getName() + + ", timeout = " + timeoutSecs + "s"); + + Options opts = currentProgram.getOptions("Analyzers"); + analyzer.registerOptions(opts, currentProgram); + if (opts.contains(OPTION_TIMEOUT)) { + opts.setInt(OPTION_TIMEOUT, timeoutSecs); + } + analyzer.optionsChanged(opts, currentProgram); + + int txId = currentProgram.startTransaction("AnalyzeFunctions parameter ID"); + boolean commit = false; + try { + runAnalyzer(analyzer, targets, batchSize); + commit = true; + } + finally { + currentProgram.endTransaction(txId, commit); + } + + snapshotAfter(targets, imageBase); + report(targets, outFile); + } + + private List readAddresses(String path) throws Exception { + List out = new ArrayList<>(); + try (BufferedReader r = new BufferedReader(new FileReader(path))) { + String line; + while ((line = r.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + String tok = line.split("\\s+")[0]; + if (tok.startsWith("0x") || tok.startsWith("0X")) { + tok = tok.substring(2); + } + try { + out.add(Long.parseUnsignedLong(tok, 16)); + } + catch (NumberFormatException e) { + println("WARN: bad address line: " + line); + } + } + } + return out; + } + + private List resolveTargets(List fileAddrs, long imageBase) { + List targets = new ArrayList<>(); + Listing listing = currentProgram.getListing(); + for (long fileAddr : fileAddrs) { + Address addr = toAddr(fileAddr + imageBase); + Function f = getFunctionContaining(addr); + if (f == null) { + FunctionIterator it = listing.getFunctions(addr, false); + if (it.hasNext()) { + f = it.next(); + } + } + if (f == null) { + disassemble(addr); + f = createFunction(addr, null); + } + if (f == null) { + println("WARN: no function at libcuda+0x" + Long.toHexString(fileAddr)); + continue; + } + Target t = new Target(); + t.fileAddr = fileAddr; + t.entry = f.getEntryPoint(); + t.name = f.getName(); + t.paramsBefore = f.getParameterCount(); + t.localsBefore = f.getLocalVariables().length; + targets.add(t); + } + return targets; + } + + private Analyzer newAnalyzer() throws Exception { + // Preferred path: the plugin service discovers analyzers via ClassSearcher, + // which also works when the analyzer lives in a module that is not visible + // to the script's class loader (Class.forName then fails). + try { + ClassSearcher.search(monitor); + for (Class c : ClassSearcher.getClasses(Analyzer.class)) { + Object o = c.getDeclaredConstructor().newInstance(); + Analyzer a = (Analyzer) o; + if ("Decompiler Parameter ID".equals(a.getName())) { + return a; + } + } + } + catch (Exception e) { + println("WARN: ClassSearcher lookup failed: " + e); + } + // Fallback: direct reflection over the known class names. + for (String cn : ANALYZER_CLASSES) { + try { + Class c = Class.forName(cn); + Object o = c.getDeclaredConstructor().newInstance(); + if (o instanceof Analyzer) { + return (Analyzer) o; + } + } + catch (ClassNotFoundException e) { + // try next candidate + } + } + return null; + } + + private void runAnalyzer(Analyzer analyzer, List targets, int batchSize) + throws Exception { + Method addedSet = findMethod(analyzer.getClass(), "added", + AddressSetView.class, TaskMonitor.class); + Method addedProgram = findMethod(analyzer.getClass(), "added", + ghidra.program.model.listing.Program.class, AddressSetView.class, + TaskMonitor.class, MessageLog.class); + if (addedSet == null && addedProgram == null) { + throw new NoSuchMethodException("no supported added(...) entry point on " + + analyzer.getClass().getName()); + } + + int n = (batchSize > 0) ? batchSize : targets.size(); + for (int start = 0; start < targets.size(); start += n) { + int end = Math.min(start + n, targets.size()); + AddressSet set = new AddressSet(); + for (int i = start; i < end; i++) { + set.addRange(targets.get(i).entry, targets.get(i).entry); + } + println(String.format("AnalyzeFunctions: batch %d..%d (%d functions)", + start, end - 1, end - start)); + try { + if (addedSet != null) { + addedSet.invoke(analyzer, set, monitor); + } + else { + addedProgram.invoke(analyzer, currentProgram, set, monitor, + new MessageLog()); + } + } + catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + println("WARN: analyzer threw for batch " + start + ".." + (end - 1) + + ": " + (cause == null ? e : cause)); + } + } + } + + private Method findMethod(Class c, String name, Class... params) { + for (Class k = c; k != null; k = k.getSuperclass()) { + try { + Method m = k.getDeclaredMethod(name, params); + m.setAccessible(true); + return m; + } + catch (NoSuchMethodException e) { + // keep walking up the hierarchy + } + } + return null; + } + + private void snapshotAfter(List targets, long imageBase) { + for (Target t : targets) { + Function f = getFunctionAt(t.entry); + if (f == null) { + continue; + } + t.paramsAfter = f.getParameterCount(); + Variable[] locals = f.getLocalVariables(); + t.localsAfter = locals.length; + if (t.name.startsWith("FUN_") && !f.getName().equals(t.name)) { + t.name = f.getName(); + } + } + } + + private void report(List targets, String outFile) throws Exception { + StringBuilder sb = new StringBuilder(); + sb.append("# libcuda 570.144 targeted Decompiler Parameter ID pass\n"); + sb.append("# file_vaddr\tname\tparams_before\tparams_after\tlocals_before\tlocals_after\n"); + int improved = 0; + for (Target t : targets) { + boolean gain = t.paramsAfter > t.paramsBefore || t.localsAfter > t.localsBefore; + if (gain) { + improved++; + } + sb.append(String.format("0x%06x\t%s\t%d\t%d\t%d\t%d%s\n", + t.fileAddr, t.name, t.paramsBefore, t.paramsAfter, + t.localsBefore, t.localsAfter, gain ? "\tGAIN" : "")); + } + sb.append(String.format("# %d/%d functions gained parameters or locals\n", + improved, targets.size())); + String text = sb.toString(); + println(text); + if (outFile != null) { + try (PrintWriter w = new PrintWriter(new FileWriter(outFile))) { + w.print(text); + } + } + } +} diff --git a/userspace/cuda/tools/name_candidates.py b/userspace/cuda/tools/name_candidates.py new file mode 100755 index 0000000..7a36a7c --- /dev/null +++ b/userspace/cuda/tools/name_candidates.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# +# name_candidates.py - propose API-implementation names for the hand-written +# functions called by the generated CUDA API wrappers. +# +# Background: every exported cu* entry is a generated stub that jumps through +# an interposition slot to a generated wrapper (tracing/tool dispatch); the +# wrapper then calls the hand-written implementation of that API. Export +# families have several variants of one API (cuMemAlloc_v2, cuMemAlloc_ptsz, +# cuMemAlloc_ptds, ...), all of which must reach the same implementation. +# +# Method (robust, structure-only): +# 1. Group exported wrappers by BASE NAME: strip the variant suffixes +# `_v`, `_ptsz`, `_ptds` (in any order/combination) from the export +# name; all exports in a group are variants of one API. +# 2. The implementation of a base name is a DIRECT CALLEE shared by ALL of +# its variants' wrappers, i.e. the intersection of the wrappers' direct +# callee sets. +# 3. Filter out global helpers: a candidate is dropped when it is called by +# >= --max-outside wrappers OUTSIDE its group (trace/init/lock helpers +# are called by dozens to hundreds of unrelated wrappers). Wrappers are +# never candidates for themselves. +# 4. Confidence: +# high - exactly one candidate survives, and either the group has +# >= 2 variants (the survivor is shared by all of them) or the +# survivor is a unique callee (called by exactly one wrapper +# in the whole library); +# medium - exactly one survivor but it is also called by another +# wrapper (possible shared implementation), or several +# survivors remain (the best-ranked one is proposed). +# Ranking among several survivors: fewest outside wrappers first, then +# largest direct-callee count (implementations do work; helpers do not), +# then lowest address, for a deterministic result. +# +# Inputs: +# elf-index exports table: addr file_addr size name +# elf-index callgraph: caller callee caller_file callee_file +# name map: file_vaddrname (wrapper_ entries +# provide the wrapper address of each export) +# output (comments + one row per proposal) +# +# Output rows (TSV): +# file_vaddr proposed_name confidence evidence status +# `proposed_name` is `impl_`; `evidence` carries the wrapper group, +# candidate callees before/after filtering and the outside-wrapper count; +# `status` is always `unverified` (the tool proposes, it does not prove). +# All addresses are FILE virtual addresses (libcuda+0x...). +# +# Usage: +# name_candidates.py +# [--max-outside N] [--quiet] + +import argparse +import re +import sys +from collections import defaultdict + + +SUFFIX_RE = re.compile(r"(_ptsz|_ptds)$|_v\d+$") + + +def base_name(name): + """Strip CUDA variant suffixes (_v, _ptsz, _ptds) from an export name.""" + prev = None + while prev != name: + prev = name + name = SUFFIX_RE.sub("", name) + return name + + +def load_exports(path): + rows = [] + with open(path) as f: + f.readline() + for line in f: + p = line.rstrip("\n").split("\t") + if len(p) < 4: + continue + try: + rows.append(dict(addr=int(p[0], 16), file=int(p[1], 16), + size=int(p[2]), name=p[3])) + except ValueError: + continue + return rows + + +def load_names(path): + names = {} + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + p = line.split("\t") + if len(p) >= 2: + try: + names[int(p[0], 16)] = p[1] + except ValueError: + pass + return names + + +def load_callgraph(path): + callees = defaultdict(set) + callers = defaultdict(set) + with open(path) as f: + f.readline() + for line in f: + p = line.rstrip("\n").split("\t") + if len(p) < 4: + continue + try: + cf, kf = int(p[2], 16), int(p[3], 16) + except ValueError: + continue + callees[cf].add(kf) + callers[kf].add(cf) + return callees, callers + + +def hx(a): + return f"0x{a:x}" + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("exports") + ap.add_argument("callgraph") + ap.add_argument("names") + ap.add_argument("out") + ap.add_argument("--max-outside", type=int, default=2, + help="drop a candidate called by >= N wrappers outside " + "its base-name group (default 2)") + ap.add_argument("--quiet", action="store_true") + args = ap.parse_args() + + exports = load_exports(args.exports) + names = load_names(args.names) + callees, callers = load_callgraph(args.callgraph) + + wrapper_by_export = {} + for a, n in names.items(): + if n.startswith("wrapper_"): + wrapper_by_export[n[len("wrapper_"):]] = a + wrapper_set = set(wrapper_by_export.values()) + + groups = defaultdict(list) + for e in exports: + w = wrapper_by_export.get(e["name"]) + if w is not None: + groups[base_name(e["name"])].append((e["name"], w)) + + rows = [] + n_high = n_med = 0 + for group in sorted(groups): + variants = groups[group] + if not variants: + continue + variant_wrappers = {w for _, w in variants} + inter = None + for _, w in variants: + cs = set(callees.get(w, ())) + inter = cs if inter is None else (inter & cs) + if not inter: + continue + before = sorted(inter) + survivors = [] + for c in before: + if c in wrapper_set: + continue + outside = [w for w in callers.get(c, ()) + if w in wrapper_set and w not in variant_wrappers] + if len(outside) >= args.max_outside: + continue + survivors.append((len(outside), c)) + if not survivors: + continue + # rank: fewest outside wrappers, then most direct callees, then address + survivors.sort(key=lambda t: (t[0], -len(callees.get(t[1], ())), t[1])) + best_outside, best = survivors[0] + after = [c for _, c in survivors] + if len(survivors) == 1 and (len(variants) >= 2 or + len(callers.get(best, ()) & wrapper_set) == 1): + confidence = "high" + n_high += 1 + else: + confidence = "medium" + n_med += 1 + evidence = ( + f"group={group} variants=" + ",".join(n for n, _ in variants) + + "; before=" + ",".join(hx(c) for c in before) + + "; after=" + ",".join(hx(c) for c in after) + + f"; outside_wrappers={best_outside}") + rows.append((best, f"impl_{group}", confidence, evidence)) + + rows.sort() + with open(args.out, "w") as f: + f.write("# Open-IOV CUDA API-implementation candidates (our own work product; tracked).\n") + f.write("# Generated by tools/name_candidates.py from the elf-index exports,\n") + f.write("# callgraph and the version's names.tsv; see that script for the method.\n") + f.write("#\n") + f.write("# file_vaddrproposed_nameconfidenceevidencestatus\n") + f.write("# confidence: high = exactly one filtered candidate, shared by >=2 variants\n") + f.write("# or a unique callee; medium = one survivor also called by another\n") + f.write("# wrapper, or several survivors (best-ranked proposed).\n") + f.write("# status: unverified (tool output). Verification = read the body in the\n") + f.write("# decompiled corpus and mark verified/corrected here; only verified\n") + f.write("# names may be copied into names.tsv.\n") + f.write("#\n") + for a, name, conf, ev in rows: + f.write(f"0x{a:08x}\t{name}\t{conf}\t{ev}\tunverified\n") + + if not args.quiet: + print(f"[name_candidates] groups={len(groups)} proposals={len(rows)} " + f"(high={n_high} medium={n_med}) -> {args.out}", + file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/userspace/cuda/tools/subsystem_digest.py b/userspace/cuda/tools/subsystem_digest.py new file mode 100755 index 0000000..68f0d09 --- /dev/null +++ b/userspace/cuda/tools/subsystem_digest.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# +# subsystem_digest.py - per-subsystem call-tree digest from the tracked +# subsystem annotations plus the callgraph index. +# +# For every subsystem in subsystems.tsv it prints: +# * members (address, name, role) - the annotated functions; +# * entry functions - members that are not called by another member of the +# same subsystem (the roots of the subsystem's call tree); +# * a depth-limited call tree from each entry, with the direct callees of a +# node grouped by their subsystem; +# * a cross-subsystem edge summary (how many calls each subsystem makes into +# every other one) - the fastest way to see the layering. +# +# The digest is a navigation aid: it shows where to start reading the +# decompiled corpus for a subsystem and which other subsystems it touches, +# without opening Ghidra. Addresses are FILE virtual addresses (libcuda+0x...). +# +# Usage: +# subsystem_digest.py [--out FILE] +# [--depth N] [--max-per-node N] [--subsystem NAME] +# +# format (comments allowed): +# file_vaddrnamesubsystemroleevidence +# format (header): +# callercalleecaller_filecallee_file +# +# SPDX-License-Identifier: BSD-3-Clause + +import argparse +import sys +from collections import defaultdict + + +def load_subsystems(path): + members = {} + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + p = line.split("\t") + if len(p) < 4: + continue + try: + addr = int(p[0], 16) + except ValueError: + continue + members[addr] = dict(name=p[1], subsystem=p[2], + role=p[3], + evidence=p[4] if len(p) > 4 else "") + return members + + +def load_callgraph(path): + callees = defaultdict(set) + callers = defaultdict(set) + with open(path) as f: + f.readline() + for line in f: + p = line.rstrip("\n").split("\t") + if len(p) < 4: + continue + try: + cf, kf = int(p[2], 16), int(p[3], 16) + except ValueError: + continue + callees[cf].add(kf) + callers[kf].add(cf) + return callees, callers + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("subsystems") + ap.add_argument("callgraph") + ap.add_argument("--out", default=None, + help="write the digest here (default: stdout)") + ap.add_argument("--depth", type=int, default=2, + help="call-tree depth below each entry (default 2)") + ap.add_argument("--max-per-node", type=int, default=8, + help="max callees shown per node (default 8)") + ap.add_argument("--subsystem", default=None, + help="only digest this subsystem") + args = ap.parse_args() + + members = load_subsystems(args.subsystems) + callees, callers = load_callgraph(args.callgraph) + + by_sub = defaultdict(dict) + for addr, m in members.items(): + by_sub[m["subsystem"]][addr] = m + + def name_of(a): + m = members.get(a) + if m: + return f"{m['name']} (libcuda+0x{a:x})" + return f"libcuda+0x{a:x}" + + def sub_of(a): + m = members.get(a) + return m["subsystem"] if m else "unknown" + + out = [] + out.append("# Subsystem call-tree digest (generated by tools/subsystem_digest.py)") + out.append(f"# subsystems.tsv: {args.subsystems}") + out.append(f"# callgraph.tsv : {args.callgraph}") + out.append("# Addresses are FILE virtual addresses (libcuda+0x...).") + out.append("# entry = annotated member with no caller inside its own subsystem.") + out.append("") + + # cross-subsystem edge counts + cross = defaultdict(int) + for addr, m in members.items(): + for c in callees.get(addr, ()): + cross[(m["subsystem"], sub_of(c))] += 1 + + selected = sorted(by_sub) + if args.subsystem: + selected = [s for s in selected if s == args.subsystem] + if not selected: + print(f"error: subsystem {args.subsystem!r} not in {args.subsystems}", + file=sys.stderr) + return 1 + + for sub in selected: + ms = by_sub[sub] + out.append("=" * 72) + out.append(f"## {sub} ({len(ms)} annotated members)") + out.append("=" * 72) + out.append("members:") + for a in sorted(ms): + m = ms[a] + out.append(f" libcuda+0x{a:x}\t{m['name']}\t{m['role']}") + entries = sorted(a for a in ms + if not (callers.get(a, set()) & set(ms))) + if not entries: + entries = sorted(ms) + out.append("entries: (no entry: every member is called from inside " + "the subsystem; showing all)") + else: + out.append("entries:") + for a in entries: + out.append(f" {name_of(a)}: {ms[a]['role']}") + out.append("call tree:") + + def walk(a, depth, indent, seen): + kids = sorted(callees.get(a, ())) + if not kids or depth <= 0: + return + groups = defaultdict(list) + for k in kids: + groups[sub_of(k)].append(k) + for g in sorted(groups): + shown = groups[g][:args.max_per_node] + more = f" (+{len(groups[g]) - args.max_per_node} more)" \ + if len(groups[g]) > args.max_per_node else "" + out.append(f"{indent}-> {g}: " + + "; ".join(name_of(k) for k in shown) + more) + for k in kids: + if k in seen: + continue + seen.add(k) + walk(k, depth - 1, indent + " ", seen) + + for a in entries: + out.append(f" {name_of(a)}") + walk(a, args.depth, " ", {a}) + out.append("") + + out.append("=" * 72) + out.append("## cross-subsystem calls (edges from -> to)") + out.append("=" * 72) + for (src, dst) in sorted(cross): + out.append(f" {src:<16} -> {dst:<16} {cross[(src, dst)]}") + + text = "\n".join(out) + if args.out: + with open(args.out, "w") as f: + f.write(text + "\n") + else: + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.2.3