aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.auto/log.jsonl2
-rw-r--r--.auto/wip-renamekeep.patch211
2 files changed, 213 insertions, 0 deletions
diff --git a/.auto/log.jsonl b/.auto/log.jsonl
index 9bcce42..68e1473 100644
--- a/.auto/log.jsonl
+++ b/.auto/log.jsonl
@@ -33,3 +33,5 @@
{"run":28,"commit":"722025a","metric":17464.6,"metrics":{"lg_boot_ms":744.8,"lg_decomp_ms":2600.3,"lg_graph_ms":889.3,"lg_hex_ms":436.1,"lg_index_ms":95.5,"lg_listing_cold_ms":436.4,"lg_listing_warm_ms":407.8,"lg_nav_ms":6747.4,"lg_palette_ms":4.6,"lg_render_ms":218.9,"lg_search_ms":760.9,"pure_graph_ms":215.7,"sm_boot_ms":429.7,"sm_decomp_ms":1263.5,"sm_graph_ms":650.2,"sm_hex_ms":435.3,"sm_index_ms":2.3,"sm_listing_cold_ms":262.8,"sm_listing_warm_ms":264.7,"sm_nav_ms":305.4,"sm_palette_ms":0.3,"sm_render_ms":251.4,"sm_search_ms":41.1,"fails":0},"status":"keep","description":"Confirmation re-run of #27, no code change: 17700 -> 17465, the best v5 reading. Confirms the wire-shape change holds and that the run-to-run spread is ~250ms even on the now-busier box.","timestamp":1786069196018,"segment":4,"confidence":2.702480400976747,"asi":{"hypothesis":"confirm #27 and take a clean reading of the finished state","v5_progress":"18516 -> 17465 (-5.7%) across seven experiments, on a box whose load roughly tripled during them","phase_state_ms":{"lg_nav":6747,"lg_decomp":2600,"sm_decomp":1264,"lg_graph":889,"sm_graph":650,"lg_search":761,"sm_search":41,"lg_listing_cold":436,"lg_listing_warm":408,"lg_boot":745,"sm_boot":430,"hex":871,"render":470,"pure_graph":216},"per_row_cost_model_final":"cold listing paging is 22.7us/row: 19.85 in the worker (generate_disasm_line 5.9 of it, and the _idatui_line_parts cache absorbing the rest) + 2.5 building Heads + 0.37 indexing them. Before this session it was 116us/row.","remaining_levers_all_rejected_with_numbers":{"flags_recomputed_3x_per_head":"0.36us/head = 0.7% of total, needs flags threaded through three functions","wire_shape_dict_vs_tuple":"<=0.3us/row","PAGE_size":"no effect at any of 500/1000/2000","index_loop_in_load_next_page":"0.37us/row total"},"conclusion":"what is left is IDA's generate_disasm_line, Hex-Rays, Textual's compositor, and the fact that a listing row index is a linear count. All four are outside this codebase or would need the architecture change costed in .auto/ideas.md."}}
{"type":"config","name":"ida-tui performance (v6 bench: split view covered)","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
{"run":29,"commit":"7ea3ca1","metric":33502.3,"metrics":{"lg_boot_ms":772.2,"lg_decomp_ms":2546.9,"lg_graph_ms":903.4,"lg_hex_ms":434.1,"lg_index_ms":97.7,"lg_listing_cold_ms":420.2,"lg_listing_warm_ms":397.4,"lg_nav_ms":6501.1,"lg_palette_ms":4.9,"lg_render_ms":212.4,"lg_search_ms":760.4,"lg_split_ms":10189.2,"pure_graph_ms":212.3,"sm_boot_ms":443.8,"sm_decomp_ms":1275.3,"sm_graph_ms":720.5,"sm_hex_ms":451,"sm_index_ms":2.5,"sm_listing_cold_ms":263.3,"sm_listing_warm_ms":264.7,"sm_nav_ms":302.8,"sm_palette_ms":0.3,"sm_render_ms":248.4,"sm_search_ms":43.9,"sm_split_ms":6033.6,"fails":0},"status":"keep","description":"RE-BASELINE (v6 bench). The split view ('s') was not covered at all, and it turns out to be the most expensive thing in the app: 16.2s of a 33.5s session (lg_split 10189 + sm_split 6034), or 500ms per function on echo and 850ms on bash, just to open it.","timestamp":1786070710925,"segment":5,"confidence":null,"asi":{"hypothesis":"the bench covers listing, decomp, graph, hex, search, nav and boot -- but not the split view, and an earlier aside measured decomp_map at 280ms per function","finding":"split is 48% of the whole benchmarked session once measured. 12 echo functions take 6.0s to open side by side; 12 bash functions take 10.2s.","why_it_was_missed":"the phase list was written from the README's headline features and 's' was not one of them. Same class of gap as v2 (graph opens timed a cache hit): if a feature is not in the bench, its cost is invisible however carefully you profile the ones that are.","cause_already_diagnosed":"server/patch_server.py decomp_map sweeps EVERY COLUMN of every pseudocode line, allocating three ctree_item_t SWIG objects per column and calling item.dstr() (which formats a whole 'EA: description' string) each time. Three separate wastes: the allocations, sweeping len(sl.line) which is the TAGGED length (124 columns for a 23-column line), and re-formatting for columns that report the same ctree item.","fix_ready":".auto/wip-decompmap.patch -- verified byte-identical over 165 functions across echo/ls_ttl/bash, 8.9-9.8x faster with Hex-Rays warm for both sides (bash's 25 largest: 67.4s -> 6.8s of sweeping)","next_action_hint":"apply it"}}
+{"run":30,"commit":"da1dfe8","metric":19834.8,"metrics":{"lg_boot_ms":676.9,"lg_decomp_ms":2698.8,"lg_graph_ms":892.1,"lg_hex_ms":440.4,"lg_index_ms":93.6,"lg_listing_cold_ms":438.5,"lg_listing_warm_ms":420.8,"lg_nav_ms":6646.7,"lg_palette_ms":4.7,"lg_render_ms":215.1,"lg_search_ms":767,"lg_split_ms":1327.6,"pure_graph_ms":213.6,"sm_boot_ms":468,"sm_decomp_ms":1317.1,"sm_graph_ms":730.7,"sm_hex_ms":483.2,"sm_index_ms":2.6,"sm_listing_cold_ms":266.1,"sm_listing_warm_ms":268.1,"sm_nav_ms":288.6,"sm_palette_ms":0.3,"sm_render_ms":253,"sm_search_ms":47.1,"sm_split_ms":874.2,"fails":0},"status":"keep","description":"decomp_map: stop sweeping every column three times over. It allocated three ctree_item_t SWIG objects PER COLUMN, swept the tagged line length (124 columns for a 23-column line), and called dstr() — which formats a whole 'EA: description' string — for every column even though consecutive columns report the same ctree item. Now: one item, no head/tail, visible columns only, and dstr() only when the item's obj_id changes.","timestamp":1786070905591,"segment":5,"confidence":null,"asi":{"gains":"total 33502 -> 19835 (-40.8%); lg_split 10189 -> 1328 (-87%); sm_split 6034 -> 874 (-86%)","work_unchanged_proof":"split_mapped_lines is 985 (echo) and 2069 (bash) BEFORE and AFTER -- the same per-line instruction sets are produced, from the same number of lines","equivalence":"/tmp/dmapeq2.py calls the shipped tool and a copy of the pre-change implementation for the same functions and compares the whole payload: 0 real mismatches over 165 functions on echo/ls_ttl/bash. (Two apparent ones were my reference's placeholder error string for functions Hex-Rays refuses.) With Hex-Rays warm for both sides the sweep is 8.9-9.8x faster; bash's 25 largest went 67.4s -> 6.8s.","the_three_wastes":["three ctree_item_t SWIG allocations per COLUMN -- one per call is enough, and head/tail are filled but never read, so pass None","range(len(sl.line)) is the TAGGED length: sl.line still carries IDA's colour tags, so a 23-column line was swept 124 times. tag_remove's length is the real bound.","item.dstr() formats a description string for every column; consecutive columns are nearly always the same ctree item. Comparing item.it.obj_id first collapses that to one format per item -- and the result is deduped by `seen` anyway, so skipping a repeat cannot change it."],"lesson_repeated":"this is the third time a cost was invisible because the benchmark did not exercise the feature (graph opens in v2, the non-scenario suites in v5, split view here). Coverage of the FEATURE matters more than precision on the ones already covered.","next_action_hint":"budget now: lg_nav 6647 (34%), decomp 4016, split 2202, graph 1623, listing 1394, boot 1145, hex 924, search 814, render 468. Split is still 2.2s for 24 functions -- what is left there is ida_hexrays.decompile inside decomp_map, which duplicates the decompile the view already did."}}
+{"type":"config","name":"ida-tui performance (v7 bench: rename covered too)","metricName":"total_ms","metricUnit":"ms","bestDirection":"lower"}
diff --git a/.auto/wip-renamekeep.patch b/.auto/wip-renamekeep.patch
new file mode 100644
index 0000000..2c3344c
--- /dev/null
+++ b/.auto/wip-renamekeep.patch
@@ -0,0 +1,211 @@
+diff --git a/idatui/domain.py b/idatui/domain.py
+index b5081cf..386b3e8 100644
+--- a/idatui/domain.py
++++ b/idatui/domain.py
+@@ -642,6 +642,11 @@ class ListingModel:
+ """
+
+ PAGE = 500 # heads per server call (well under the tool's 2000 cap)
++ #: Heads refreshed together when a rename makes their text stale. One server
++ #: call per block, so a viewport costs one round trip rather than forty --
++ #: and the same size as a load page, so refreshing everything costs about
++ #: what rebuilding everything would have.
++ TEXT_BLOCK = 500
+
+ def __init__(self, program: "Program", seg_start: int, seg_end: int,
+ name: str | None = None):
+@@ -659,6 +664,19 @@ class ListingModel:
+ # demand. _row_at[i] is the logical row where physical head i starts.
+ self._row_at: list[int] = []
+ self._head_eas: list[int] = [] # parallel to _heads, for bisect
++ #: Which name generation each head's TEXT was rendered at, parallel to
++ #: _heads. A rename bumps :attr:`_text_gen`; the rows themselves stay
++ #: (their addresses and row numbers are unchanged) and are re-rendered a
++ #: block at a time when something asks for them. See invalidate_text.
++ self._head_gen: list[int] = []
++ self._text_gen = 0
++ #: Whether a rename has ever staled this model. Until one has, every
++ #: read takes exactly the path it always did.
++ self._renamed = False
++ #: Set if a text refresh came back with a different head sequence, which
++ #: means something DID move the walk. Program.listing() throws the model
++ #: away when it sees this, so the next read rebuilds from scratch.
++ self.stale_structure = False
+ self._rows = 0 # total logical rows loaded
+ self._ubytes: dict[int, bytes] = {} # lazily-read bytes for those rows
+ self._next: int | None = seg_start # next address to fetch from
+@@ -739,6 +757,7 @@ class ListingModel:
+ cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
+ page = self._build_page(rows)
+ with self._lock:
++ gen = self._text_gen
+ for h in page:
+ # Banner/label rows (function headers, separators, code labels)
+ # are display-only; don't index them so navigation lands on the
+@@ -747,6 +766,7 @@ class ListingModel:
+ self._by_ea.setdefault(h.ea, self._rows)
+ self._row_at.append(self._rows)
+ self._head_eas.append(h.ea)
++ self._head_gen.append(gen)
+ self._heads.append(h)
+ self._rows += self._span(h)
+ nxt = cur.get("next")
+@@ -878,6 +898,78 @@ class ListingModel:
+ def __len__(self) -> int:
+ return self.loaded()
+
++ def invalidate_text(self) -> None:
++ """A rename changed how rows READ, not which rows exist.
++
++ Item boundaries are untouched by a rename, so every row keeps its
++ address and its row number — which the edit path already relies on, since
++ it restores the cursor by INDEX afterwards. Dropping the whole model
++ instead means the next jump re-walks the segment from its start: 6.4
++ seconds on bash's .text, after every single rename.
++
++ So keep the walk and mark the rendered text stale; :meth:`_ensure_text`
++ re-renders a block at a time, and refuses to splice anything back if the
++ head sequence has moved under it (which a rename cannot do, but a
++ mis-routed structural edit could).
++ """
++ with self._lock:
++ self._text_gen += 1
++ self._renamed = True
++
++ def _ensure_text(self, j0: int, j1: int) -> None:
++ """Re-render physical heads [j0, j1) if a rename staled them.
++
++ The block is snapped out to whole ADDRESS groups. A function start emits
++ three banner rows and its code row at the same ea, and a labelled
++ instruction emits two -- so a block boundary that fell inside one of
++ those groups would refetch the whole group and never line up again.
++ """
++ with self._lock:
++ gen = self._text_gen
++ n = len(self._heads)
++ j0 = max(j0, 0)
++ j1 = min(j1, n)
++ if j1 <= j0:
++ return
++ head_gen = self._head_gen
++ if all(head_gen[j] == gen for j in range(j0, j1)):
++ return
++ blk = self.TEXT_BLOCK
++ a = (j0 // blk) * blk
++ b = min(((j1 - 1) // blk + 1) * blk, n)
++ eas = self._head_eas
++ while a > 0 and eas[a - 1] == eas[a]:
++ a -= 1
++ while b < n and eas[b - 1] == eas[b]:
++ b += 1
++ last = self._heads[b - 1]
++ lo = eas[a]
++ hi = last.ea + max(last.size, 1)
++ want = [(h.ea, h.kind) for h in self._heads[a:b]]
++ try:
++ payload = self._prog.client.call(
++ "heads", addr=hex(lo), end=hex(hi),
++ count=min(len(want) + 64, 2000), annotate=True)
++ except Exception: # noqa: BLE001 -- keep the old text rather than blank
++ return
++ rows = payload.get("heads", []) if isinstance(payload, dict) else []
++ page = self._build_page(rows)[:len(want)]
++ with self._lock:
++ if self._text_gen != gen or len(self._heads) < b:
++ return
++ if [(h.ea, h.kind) for h in page] != want:
++ # Something moved the walk, which a rename cannot do -- so this
++ # was not one. Say so and let Program.listing() rebuild, rather
++ # than sit here re-fetching a block that will never line up (and
++ # showing the old names while doing it).
++ self.stale_structure = True
++ for j in range(a, b):
++ self._head_gen[j] = gen
++ return
++ self._heads[a:b] = page
++ for j in range(a, b):
++ self._head_gen[j] = gen
++
+ def get(self, i: int) -> Head | None:
+ with self._lock:
+ if not (0 <= i < self._rows):
+@@ -885,8 +977,23 @@ class ListingModel:
+ j, off = self._phys(i)
+ if j < 0:
+ return None
+- span = self._span(self._heads[j])
+- h = self._heads[j]
++ stale = self._renamed and self._head_gen[j] != self._text_gen
++ if not stale:
++ span = self._span(self._heads[j])
++ h = self._heads[j]
++ if stale:
++ # A rename staled this row's text; re-render its block (one call for
++ # the block around it, so a viewport costs one round trip). Only
++ # this path re-takes the lock -- the ordinary read stays atomic.
++ self._ensure_text(j, j + 1)
++ with self._lock:
++ if not (0 <= i < self._rows):
++ return None
++ j, off = self._phys(i)
++ if j < 0:
++ return None
++ span = self._span(self._heads[j])
++ h = self._heads[j]
+ # Synthesis reads bytes, so do it OUTSIDE the lock: an RPC under the
+ # model lock deadlocks the page loader that is filling it.
+ return self._row_head(j, off) if span > 1 else h
+@@ -894,6 +1001,16 @@ class ListingModel:
+ def window(self, start: int, count: int) -> list[Head]:
+ """``count`` logical rows from ``start`` (synthesising undefined ones)."""
+ self.ensure(start + count)
++ with self._lock:
++ # _renamed stays set once a rename has happened; _ensure_text then
++ # does the precise, range-limited staleness check. Before the first
++ # rename this is one boolean and the read is exactly as it was.
++ dirty = self._renamed
++ if dirty:
++ j0 = max(self._phys(max(start, 0))[0], 0)
++ j1 = self._phys(max(min(self._rows, start + count) - 1, 0))[0] + 1
++ if dirty:
++ self._ensure_text(j0, j1)
+ with self._lock:
+ rows = min(self._rows, start + count)
+ spans = [self._phys(i) for i in range(max(start, 0), max(rows, 0))]
+@@ -1223,6 +1340,8 @@ class Program:
+ start, end, name = seg
+ with self._lock:
+ m = self._listings.get(start)
++ if m is not None and m.stale_structure:
++ m = None # a refresh found the walk had moved; start over
+ if m is None:
+ m = ListingModel(self, start, end, name)
+ self._listings[start] = m
+@@ -1406,15 +1525,24 @@ class Program:
+
+ def bump_names(self) -> None:
+ """Signal that symbol names changed (a rename). Disasm/listing names are
+- live in the IDB, so clearing the cached rows is enough for those;
+- decompilation is generation-checked and force-recompiled lazily."""
++ live in the IDB, so the cached rows have to be re-rendered; decompilation
++ is generation-checked and force-recompiled lazily.
++
++ The listing keeps its WALK. A rename cannot move an item boundary, so
++ every row keeps its address and its row number -- the edit path already
++ assumes exactly that, since it restores the cursor by index afterwards.
++ Dropping the segment model instead made the reload re-walk it from the
++ start, which is 6.4 seconds on bash after every rename.
++ """
+ with self._lock:
+ self._name_gen += 1
+ models = list(self._disasm.values())
+- self._listings.clear() # listing head rows cache names -> refetch
++ listings = list(self._listings.values())
+ self._pc_nums.clear() # a reformat moves every literal on its line
+ for m in models:
+ m.invalidate()
++ for lm in listings:
++ lm.invalidate_text()
+
+ def bump_items(self) -> None:
+ """Signal that item/function STRUCTURE changed (define code/data/func,