diff options
| author | blasty <blasty@local> | 2026-08-09 23:35:32 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-08-09 23:35:32 +0200 |
| commit | 933efafa9fa3e74d435ae580bedbd8ff54597bef (patch) | |
| tree | d563dc3b47c744784d41d631363533272268497f /experiments/profile_remote.py | |
| parent | experiments: count backend round-trips per user action (diff) | |
| download | ida-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.py | 95 |
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()) |
