aboutsummaryrefslogtreecommitdiffstats
path: root/experiments/profile_remote.py
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-09 23:35:32 +0200
committerblasty <blasty@local>2026-08-09 23:35:32 +0200
commit933efafa9fa3e74d435ae580bedbd8ff54597bef (patch)
treed563dc3b47c744784d41d631363533272268497f /experiments/profile_remote.py
parentexperiments: count backend round-trips per user action (diff)
downloadida-tui-933efafa9fa3e74d435ae580bedbd8ff54597bef.tar.gz
ida-tui-933efafa9fa3e74d435ae580bedbd8ff54597bef.tar.xz
ida-tui-933efafa9fa3e74d435ae580bedbd8ff54597bef.zip
remote_tools: hoist the IDAPython imports to module scope (~3%)
_idatui_head_row ran 'import ida_bytes/ida_lines/ida_name' on every LISTING ROW -- 25,500 sys.modules lookups per 500-row page. 50 such imports across the file, all pointless: this module is never imported by the client (codemode_client reads it as TEXT and installs it in the database process), so the no-IDA house rule that forces function-local imports elsewhere does not apply here. Measured honestly, it is worth about 3%: heads(500) best-of-40 goes 9.37ms -> 9.07ms, median 9.73 -> 9.65. A microbenchmark of the bare import predicted 3.2ms/page and was 10x optimistic -- the A/B is what counts, and it is a small win, not the big one. ida_hexrays deliberately stays function-local: it is licence-dependent, and hoisting it would break the whole remote library for someone without the decompiler rather than failing only when they decompile. Verified by AST that every ida_* reference still resolves. Adds experiments/profile_remote.py, which ships cProfile INTO the database process -- the only way to see the split between IDA's own calls and our python, which no client-side timer can show. Full gate: 1042 passed.
Diffstat (limited to '')
-rw-r--r--experiments/profile_remote.py95
1 files changed, 95 insertions, 0 deletions
diff --git a/experiments/profile_remote.py b/experiments/profile_remote.py
new file mode 100644
index 0000000..f35bac2
--- /dev/null
+++ b/experiments/profile_remote.py
@@ -0,0 +1,95 @@
+"""Profile an operation INSIDE the database process.
+
+`bench_ops.py` says how long an operation takes; this says where that time
+goes. The snippet ships cProfile into the Code Mode sandbox, runs the real
+remote-library function there in a loop, and returns the stats as text -- so
+the split between IDA's own calls and OUR python in `remote_tools.py` is
+visible, which no client-side timer can see.
+
+ PYTHONPATH=. ~/ida-venv/bin/python experiments/profile_remote.py [BINARY]
+ PYTHONPATH=. ~/ida-venv/bin/python experiments/profile_remote.py --op decompile
+
+Read the `tottime` column: time in that function excluding subcalls. IDA
+builtins (generate_disasm_line, get_flags, next_head...) are the floor; a
+python frame from ida_tui_remote near the top is ours, and ours is fixable.
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+
+from idatui.codemode_client import CodeModeClient, _REMOTE_MODULE, _script
+
+# Runs in the database process. `a` is the bound argument dict.
+PROFILE = '''
+import cProfile, pstats, io, sys
+_m = sys.modules.get(%(mod)r)
+if _m is None:
+ result = {"error": "remote lib not installed yet"}
+else:
+ call = a["call"]
+ reps = int(a["reps"])
+ ns = {"_m": _m, "a": a}
+ src = "for _ in range(%%d):\\n _m.%%s" %% (reps, call)
+ code = compile(src, "<profile>", "exec")
+ pr = cProfile.Profile()
+ pr.enable()
+ exec(code, ns)
+ pr.disable()
+ buf = io.StringIO()
+ st = pstats.Stats(pr, stream=buf).sort_stats("tottime")
+ st.print_stats(int(a["lines"]))
+ result = {"stats": buf.getvalue(), "total": st.total_tt, "reps": reps}
+''' % {"mod": _REMOTE_MODULE}
+
+CALLS = {
+ # One full listing page, exactly as the background grower asks for it.
+ "heads": 'heads(addr=a["addr"], count=500, annotate=True)',
+ "heads_plain": 'heads(addr=a["addr"], count=500, annotate=False)',
+ "decompile": 'decompile(a["addr"])',
+ "disasm": 'disasm(a["addr"], 500)',
+}
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("binary", nargs="?", default="targets/bash")
+ ap.add_argument("--op", default="heads", choices=sorted(CALLS))
+ ap.add_argument("--reps", type=int, default=20)
+ ap.add_argument("--lines", type=int, default=18)
+ ap.add_argument("--addr", default=None, help="default: the .text start")
+ args = ap.parse_args()
+
+ client = CodeModeClient(os.path.abspath(args.binary))
+ client.connect()
+
+ addr = args.addr
+ if addr is None:
+ regions = client.invoke("file_regions")
+ rows = regions.get("regions") or regions.get("result") or []
+ text = next((r for r in rows if ".text" in str(r.get("name", ""))), None)
+ addr = (text or rows[0])["start"] if rows else "0x0"
+ print(f"# {os.path.basename(args.binary)} op={args.op} addr={addr} reps={args.reps}")
+
+ # Prime: the remote lib installs lazily, and its lru_caches must be warm or
+ # the profile measures cache misses that the real workload never pays.
+ client.invoke("heads", addr=addr, count=500, annotate=True)
+
+ # _script binds the args as JSON and adds the pack epilogue, exactly as a
+ # real operation is shipped -- so this measures the same path, not a
+ # special one.
+ out = client._unpack(client.execute_python(_script(
+ {"call": CALLS[args.op], "addr": addr,
+ "reps": args.reps, "lines": args.lines}, PROFILE), timeout=600))
+ if "error" in out:
+ print("FAILED:", out["error"])
+ return 1
+ per = out["total"] / out["reps"] * 1000
+ print(f"# {out['total']*1000:.0f}ms total, {per:.1f}ms per call\n")
+ print(out["stats"])
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())