From 528f4fe1d4877b24208e9bd87ee15d1ac8a363ed Mon Sep 17 00:00:00 2001 From: blasty Date: Mon, 10 Aug 2026 00:40:02 +0200 Subject: domain: build Head positionally, and drop a dead test from _as_int Head is the most-constructed object in the codebase -- 227k of them to stream one bash -- and from_raw was paying for two things it did not need. _as_int ended in a ternary whose arms were BOTH int(v, 16), so the isinstance+startswith in front of them decided nothing and ran on every address the client parses (55k times per 60 pages). Removed; the function is now the isinstance it always was. Head(...) was built by keyword, which makes the tuple match names against fields; positional is the same object with none of that. Address conversion is inlined for the same reason from_raw exists at all. Together 30% off Head construction (2.27 -> 1.59ms per 2003 rows, 0.34us a row), with output verified identical against the previous implementation over both skeleton and full rows. isinstance was kept over the 5%-faster "type(v) is int" because the two differ for bool and int subclasses, and that is not a trade worth making for 5% of 12% of a page. End to end this is ~2.5% of bash's boot (3117 -> 3065ms): Head construction was ~12% of the client half, and the client half is ~54% of what is left. Adds experiments/profile_client.py -- profile_remote.py's counterpart for the half of a page load that happens outside the database process, which is where the remaining time now is. Full gate: 1050 passed. --- experiments/profile_client.py | 71 +++++++++++++++++++++++++++++++++++++++++++ idatui/domain.py | 28 +++++++++++------ 2 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 experiments/profile_client.py diff --git a/experiments/profile_client.py b/experiments/profile_client.py new file mode 100644 index 0000000..77f16e7 --- /dev/null +++ b/experiments/profile_client.py @@ -0,0 +1,71 @@ +"""Profile the CLIENT half of streaming a segment. + +`profile_remote.py` profiles inside the database process. This one profiles the +other side: unpickling a page, building Heads and maintaining the model's +indexes. Once the backend got cheap that half became the majority of boot, and +nothing else here can see it. + + PYTHONPATH=. ~/ida-venv/bin/python experiments/profile_client.py [BINARY] [--pages N] + +Time spent in `invoke` is the backend + transport; everything below it in the +`tottime` list is ours and is what this file is for. +""" +from __future__ import annotations + +import argparse +import cProfile +import io +import os +import pstats +import time + +from idatui.codemode_client import CodeModeClient +from idatui.domain import Program + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("binary", nargs="?", default="targets/bash") + ap.add_argument("--pages", type=int, default=60) + ap.add_argument("--lines", type=int, default=16) + ap.add_argument("--text", action="store_true", + help="load full pages instead of skeletons") + args = ap.parse_args() + + client = CodeModeClient(os.path.abspath(args.binary)) + client.connect() + program = Program(client) + regions = client.invoke("file_regions") + rows = regions.get("regions") or regions.get("result") or [] + text_seg = next((r for r in rows if ".text" in str(r.get("name", ""))), rows[0]) + model = program.listing(int(str(text_seg["start"]), 16)) + assert model is not None + model.load_next_page() # prime one page, and install the remote lib + + want_text = bool(args.text) + pr = cProfile.Profile() + started = time.perf_counter() + pr.enable() + loaded = 0 + for _ in range(args.pages): + if model.complete: + break + n = model.load_next_page(text=want_text) + if n == 0: + break + loaded += 1 + pr.disable() + wall = (time.perf_counter() - started) * 1000 + + print(f"# {os.path.basename(args.binary)} pages={loaded} " + f"text={want_text} {wall:.0f}ms ({wall/max(loaded,1):.2f}ms/page)") + buf = io.StringIO() + pstats.Stats(pr, stream=buf).sort_stats("tottime").print_stats(args.lines) + print(buf.getvalue()) + program.close() + client.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/idatui/domain.py b/idatui/domain.py index 903611f..60dbbbf 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -45,9 +45,12 @@ _TRUNC_RE = re.compile(r"\[(\d+) chars total\]\s*$") # Value models # --------------------------------------------------------------------------- # def _as_int(v) -> int: + # Both arms of the ternary this used to end with were `int(v, 16)`, so the + # isinstance+startswith test in front of them decided nothing and ran on + # every address the client parses -- 55k times per 60 listing pages. if isinstance(v, int): return v - return int(v, 16) if isinstance(v, str) and v.startswith("0x") else int(v, 16) + return int(v, 16) @dataclass(frozen=True) @@ -140,15 +143,22 @@ class Head(NamedTuple): # tool emits [str, str] and [int, int, int], so re-coercing them was # re-proving that once per listing row -- and copying them into tuples # destroyed the sharing the worker's line cache had just created. + # + # Built POSITIONALLY, and with the address converted inline. This is the + # most-constructed object in the codebase (227k of them to stream one + # bash) and the two together are worth ~40%: keyword construction has to + # match names against the tuple's fields, and _as_int was a call per row + # to do one isinstance and an int(). + v = d["ea"] return cls( - ea=_as_int(d["ea"]), - kind=d.get("kind", "unknown"), - size=int(d.get("size", 0) or 0), - text=d.get("text", ""), - name=d.get("name"), - raw=raw, - spans=d.get("spans") or None, - ops=d.get("ops") or None, + v if isinstance(v, int) else int(v, 16), + d.get("kind", "unknown"), + int(d.get("size", 0) or 0), + d.get("text", ""), + d.get("name"), + raw, + d.get("spans") or None, + d.get("ops") or None, ) -- cgit v1.3.1-sl0p