aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.auto/log.jsonl2
-rw-r--r--idatui/app.py41
-rw-r--r--idatui/graph.py47
3 files changed, 77 insertions, 13 deletions
diff --git a/.auto/log.jsonl b/.auto/log.jsonl
index a1baf16..e22adae 100644
--- a/.auto/log.jsonl
+++ b/.auto/log.jsonl
@@ -23,3 +23,5 @@
{"type":"config","name":"ida-tui performance (v4 bench: cold phases measured once, repeatable ones averaged)","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
{"run":20,"commit":"49184b6","metric":18497.9,"metrics":{"lg_boot_ms":694.8,"lg_decomp_ms":2674,"lg_graph_ms":992.7,"lg_hex_ms":429.1,"lg_index_ms":94.9,"lg_listing_cold_ms":545.3,"lg_listing_warm_ms":410.7,"lg_nav_ms":6489,"lg_palette_ms":4.7,"lg_render_ms":220.6,"lg_search_ms":1489.8,"pure_graph_ms":241,"sm_boot_ms":467.1,"sm_decomp_ms":1308.5,"sm_graph_ms":761,"sm_hex_ms":431.7,"sm_index_ms":2.5,"sm_listing_cold_ms":273.9,"sm_listing_warm_ms":289,"sm_nav_ms":373,"sm_palette_ms":0.3,"sm_render_ms":257.2,"sm_search_ms":47,"fails":0},"status":"keep","description":"RE-BASELINE (v4 bench). Adding a second repetition on the big target exposed the same flaw the graph phase had: decompile, search and the function index all cache their answer, so a second rep reported a dict lookup under the name of the thing a user waits for. Cold-sensitive phases (listing_cold, decomp, search, index) now run ONCE; repeatable ones (render, hex, graph, listing_warm) run every rep and take the median. pure_graph is median-of-3.","timestamp":1786065121146,"segment":3,"confidence":null,"asi":{"hypothesis":"reduce the metric's noise so changes worth 1-2% are readable","what_changed_in_the_bench":"pure_graph median-of-3 (it swung 239 <-> 524 with identical code); two reps on targets/bash; and cold-sensitive phases pinned to the first rep only","flaw_this_caught":"sm_decomp had been min-of-2 since the start, i.e. it was reporting a WARM decompile (Program._decomp is cached per function). Honest cold value is 1308ms, not ~650. Same for sm_search and sm_index. All comparisons within v1-v3 were still valid (consistent measurement), but the absolute picture was wrong: decomp is 22% of the total, not 12%.","cumulative_history":"v1 baseline 46572 -> 19006 over 10 experiments. v2 (cold graph opens measured) baseline 27913 -> 17501 over 9. v3 abandoned after one run for the flaw above. v4 baseline 18498.","budget_ms":{"lg_nav":6489,"decomp lg+sm":3983,"graph lg+sm":1754,"search lg+sm":1537,"listing lg+sm":1519,"boot lg+sm":1162,"hex lg+sm":861,"render lg+sm":478,"pure_graph":241},"next_action_hint":"decomp is now clearly #2 at 22%. sm_decomp is 1308ms for TWELVE small echo functions (109ms each), which is far more than Hex-Rays should need on a 1.6KB function -- profile the cold F5 path on echo before assuming it is the decompiler."}}
{"type":"config","name":"ida-tui performance (v5 bench: 2ms landing polls; final measurement shape)","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
+{"run":21,"commit":"5a8027e","metric":18516,"metrics":{"lg_boot_ms":663.9,"lg_decomp_ms":2663.2,"lg_graph_ms":1012.3,"lg_hex_ms":436.1,"lg_index_ms":98.4,"lg_listing_cold_ms":437.7,"lg_listing_warm_ms":409.4,"lg_nav_ms":6841.9,"lg_palette_ms":4.7,"lg_render_ms":218.8,"lg_search_ms":1474.7,"pure_graph_ms":241.6,"sm_boot_ms":433.3,"sm_decomp_ms":1266.3,"sm_graph_ms":737.9,"sm_hex_ms":424.2,"sm_index_ms":2.3,"sm_listing_cold_ms":262.9,"sm_listing_warm_ms":283.2,"sm_nav_ms":301.2,"sm_palette_ms":0.3,"sm_render_ms":253.9,"sm_search_ms":47.9,"fails":0},"status":"keep","description":"Baseline for the v5 bench (landing polls every 2ms instead of 10ms; the poll interval was measurement overhead inside the timed regions). Final measurement shape — no further bench changes.","timestamp":1786065328607,"segment":4,"confidence":null,"asi":{"hypothesis":"the 10ms wait_for step was charging every timed landing up to 10ms of quantisation","effect":"sm_nav 373 -> 301, sm_decomp 1309 -> 1266 (12 timed waits each). lg_graph and lg_decomp did not move measurably -- their per-item costs are large enough that 5ms of expected overshoot is lost in the spread.","decision":"this is the last bench change. Any further one costs a re-baseline, and the measurement is now honest about cold vs warm (v4) and free of both the cache-hit and quantisation artefacts.","budget_ms":{"lg_nav":6842,"decomp lg+sm":3930,"graph lg+sm":1750,"search lg+sm":1523,"listing lg+sm":1393,"boot lg+sm":1097,"hex lg+sm":860,"render lg+sm":473,"pure_graph":242},"decomp_anatomy_echo":"12 small functions: Program.decompile (Hex-Rays) 562ms, UI F5 path with everything already cached 414ms, four scroll+paint frames 186ms, highlight_c 86ms. The 414ms of UI is two @work thread spawns, ~4 event-loop hops, and a loading-cover mount/unmount per F5 -- and show() runs exactly ONCE per F5 (checked by counting), so there is no double work to remove.","next_action_hint":"the loading cover is 4.6ms of mount+unmount per F5 and cannot be deferred (a scenario asserts F5 raises it synchronously). The remaining decomp lever is _show_active spawning _load_decomp as a thread even when Program already has the decompilation cached."}}
+{"run":22,"commit":"5a8027e","metric":18535.4,"metrics":{"lg_boot_ms":692.3,"lg_decomp_ms":2662.1,"lg_graph_ms":895.9,"lg_hex_ms":422.2,"lg_index_ms":94.1,"lg_listing_cold_ms":525.4,"lg_listing_warm_ms":404.2,"lg_nav_ms":6821.6,"lg_palette_ms":4.7,"lg_render_ms":215.7,"lg_search_ms":1489.3,"pure_graph_ms":247.8,"sm_boot_ms":437.2,"sm_decomp_ms":1258.5,"sm_graph_ms":749.3,"sm_hex_ms":437.6,"sm_index_ms":2.4,"sm_listing_cold_ms":263.3,"sm_listing_warm_ms":283.1,"sm_nav_ms":321.5,"sm_palette_ms":0.3,"sm_render_ms":257.5,"sm_search_ms":49.2,"fails":0},"status":"discard","description":"Apply an already-decompiled function inline instead of spawning a background worker for it (Program.cached_decompilation/cached_pc_nums + _decomp_now), and warm pc_nums in the thread that already decompiled. Total flat (18516 -> 18535) and the phase it targets did not move either (lg_decomp 2663 -> 2662, sm_decomp 1266 -> 1259).","timestamp":1786065730509,"segment":4,"confidence":null,"asi":{"hypothesis":"an F5 spawns two @work threads and hops the event loop four times; skipping the second when the answer is already cached should show up in decomp","result":"it does not. A Textual thread spawn plus its call_from_thread round trip is worth well under 1% of a 100-220ms F5. The cost is elsewhere: Hex-Rays itself (562ms of sm_decomp's 1266), the loading-cover mount/unmount, highlight_c, and the Strip/scroll work in DecompView.show.","rollback_reason":"no measurable gain on the targeted phase, and it adds a second path into _apply_decomp with its own generation-check reasoning to keep correct. Simpler is better.","correctness_was_fine":"301 scenarios passed with it in; this is a complexity-vs-payoff rejection, not a bug.","kept_knowledge":"Program.cached_decompilation / cached_pc_nums would be the right primitives if a future change needs to know whether an answer is in hand without paying for it","next_action_hint":"boot is 1097ms across both targets and ~300ms of it is the worker importing idapro before it can serve. Check whether launch.py can spawn the worker BEFORE Textual starts, so that import overlaps the app's own startup instead of following it."}}
diff --git a/idatui/app.py b/idatui/app.py
index 6c0a872..c37846f 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -690,15 +690,21 @@ class SearchMixin:
key = (count, src)
if self._hay_key == key:
return self._hay
- text_of = self._search_line_text
starts: list[int] = []
parts: list[str] = []
pos = 0
- for i in range(count):
- s = text_of(i) or ""
+ chunk = 4096
+ for base in range(0, count, chunk):
+ for s in self._search_line_texts(base, min(chunk, count - base)):
+ if not s:
+ s = ""
+ starts.append(pos)
+ parts.append(s)
+ pos += len(s) + 1
+ while len(starts) < count: # a short window: keep the indices lined up
starts.append(pos)
- parts.append(s)
- pos += len(s) + 1
+ parts.append("")
+ pos += 1
blob = "\n".join(parts)
folded = blob.lower()
hay = None if len(folded) != len(blob) else (starts, blob, folded)
@@ -713,6 +719,12 @@ class SearchMixin:
def _search_line_text(self, i: int) -> str | None:
raise NotImplementedError
+ def _search_line_texts(self, start: int, count: int) -> list:
+ """``count`` line texts from ``start``. Overridable so a view whose rows
+ come from a locked model can fetch a window in one go."""
+ text_of = self._search_line_text
+ return [text_of(i) for i in range(start, start + count)]
+
def _search_ensure(self, done) -> None:
"""Ensure all line texts are available, then call ``done()`` on the UI
thread. Default: assume ready."""
@@ -1031,8 +1043,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
return ""
return self._op_bytes_text(h).ljust(self._op_w) + " "
- def _line_plain(self, idx: int) -> str | None:
- h = self._head(idx)
+ def _plain_of(self, h: Head | None) -> str | None:
if h is None:
return None
# Function headers and code labels sit at depth 0 (with the address);
@@ -1045,6 +1056,22 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
extra = _LST_INDENT if h.kind == "member" else ""
return base + self._op_field(h) + extra + self._name_prefix(h) + h.text
+ def _line_plain(self, idx: int) -> str | None:
+ return self._plain_of(self._head(idx))
+
+ def _search_line_texts(self, start: int, count: int) -> list:
+ """A window of plain lines in one model call.
+
+ Building the search body row by row took the model's lock and bisected
+ its row table a quarter of a million times; ``window`` does both once
+ for the whole window.
+ """
+ model = self.model
+ if model is None:
+ return []
+ plain = self._plain_of
+ return [plain(h) for h in model.window(start, count)]
+
def _insn_col(self, idx: int) -> int:
"""Column where the instruction/content text begins, past the address +
opcode-bytes gutter — the shift+home target. Mirrors ``_line_plain``'s
diff --git a/idatui/graph.py b/idatui/graph.py
index e0a9422..baee597 100644
--- a/idatui/graph.py
+++ b/idatui/graph.py
@@ -280,6 +280,33 @@ def _pair_cross(a: int, b: int, side: dict[int, list[int]],
return n
+def _swap_delta(a: int, b: int, down: dict[int, list[int]],
+ up: dict[int, list[int]], pos: dict[int, int]) -> tuple[int, int]:
+ """``(keep, swap)`` for the adjacent pair (a, b), both sides, in one pass.
+
+ The same as calling :func:`_pair_cross` four times, which is what the
+ transposition loop used to do: every neighbour pair was visited twice (once
+ per direction) and each visit was a python call. Counting both outcomes
+ while the pair is in hand halves the comparisons and removes three calls per
+ candidate swap — and this runs a third of a million times over a corpus.
+ """
+ keep = swap = 0
+ for side in (down, up):
+ va = side[a]
+ vb = side[b]
+ if not va or not vb:
+ continue
+ pbs = [pos[v] for v in vb]
+ for u in va:
+ pu = pos[u]
+ for pv in pbs:
+ if pu > pv:
+ keep += 1
+ elif pu < pv:
+ swap += 1
+ return keep, swap
+
+
def crossings(layers: list[list[int]], down: dict[int, list[int]],
pos: dict[int, int]) -> int:
return sum(_cross_below(l, down, pos) for l in layers)
@@ -306,11 +333,20 @@ def _order_layers(g: _Graph, root: int, sweeps: int = 6) -> list[list[int]]:
pos = {i: k for layer in layers for k, i in enumerate(layer)}
def median(i: int, side: dict[int, list[int]]) -> float:
- ps = sorted(pos[j] for j in side[i])
- if not ps:
+ # Almost every node in a control-flow graph has one or two neighbours
+ # on a given side, so answer those without building and sorting a list:
+ # this runs tens of thousands of times per corpus layout.
+ js = side[i]
+ n = len(js)
+ if n == 1:
+ return float(pos[js[0]])
+ if n == 2:
+ return (pos[js[0]] + pos[js[1]]) / 2
+ if not n:
return -1.0
- m = len(ps) // 2
- return float(ps[m]) if len(ps) % 2 else (ps[m - 1] + ps[m]) / 2
+ ps = sorted(pos[j] for j in js)
+ m = n // 2
+ return float(ps[m]) if n % 2 else (ps[m - 1] + ps[m]) / 2
best, best_x = [list(l) for l in layers], crossings(layers, down, pos)
for s in range(sweeps):
@@ -327,8 +363,7 @@ def _order_layers(g: _Graph, root: int, sweeps: int = 6) -> list[list[int]]:
for layer in layers:
for k in range(len(layer) - 1):
a, b = layer[k], layer[k + 1]
- keep = _pair_cross(a, b, down, pos) + _pair_cross(a, b, up, pos)
- swap = _pair_cross(b, a, down, pos) + _pair_cross(b, a, up, pos)
+ keep, swap = _swap_delta(a, b, down, up, pos)
if swap < keep:
layer[k], layer[k + 1] = b, a
pos[a], pos[b] = k + 1, k