summaryrefslogtreecommitdiffstats
path: root/.auto/ideas.md
blob: 133993c4ad53ce63a26829605aa16ff620df0de0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# Ideas backlog

## Perf, not yet tried

- **Skeleton walk for `ensure_ea`** — navigation only needs the *row index* of an
  address, yet `ListingModel` walks the segment loading fully-rendered rows.
  **Costed, and it only nets ~5%**: a text-free walk would be ~3 µs/row instead
  of ~23, but search then has to fetch the text anyway (it builds its haystack
  from `_line_plain`), so most of the saving moves rather than disappears.
  Worth it only if the goal changes from "total session time" to "no single
  foreground wait over 200 ms" — which is arguably the better goal for a TUI.
- **`_grow` should start from where the user is**, not sweep from the segment
  start, so a jump into the middle doesn't wait behind everything before it.
- **Prefetch the decompilation of adjacent/called functions** while the user
  reads the current one. The worker is idle then and `Program.decompile` caches.
- **Persist the worker's `_idatui_line_parts` cache** — it is warm only within
  one worker, and the same binary is reopened constantly during a session.

## Bugs found while optimising (not perf work)

- **`idatui/graph.py` edge routing is non-deterministic.** Laying out the *same*
  function twice with the *unchanged* engine gives different
  `painting.vruns` for 71 of 128 corpus functions. Node placement is stable;
  only the routing moves. So a graph redraws differently when you reopen it, and
  any old-vs-new painting diff is worthless as a regression test (old-vs-old
  fails it too). Find the set/dict iteration or `id()`-keyed order behind it.
- **Sticky graph mode makes a keypress ambiguous.** With `_graph_sticky` on, a
  navigation schedules the next function's graph asynchronously; until it lands
  the app is in the listing. So `space` right after a jump either enters or
  leaves the graph depending on which won. `graph_minimap` was silently relying
  on losing that race. A fix would be to enter graph mode immediately, with a
  loading state, when a sticky navigation starts.
- **`domain.decomp_map` costs ~280 ms per function** — more than the decompile
  itself — and is on the split-view (`s`) path, which the bench doesn't cover.

## Verification patterns that worked (reuse them)

- **Differential against a git ref.** `.auto/diff_spans.py` loads the *current*
  and a *past* `server/patch_server.py`, execs the same slice of each `BODY`,
  and compares outputs over every disassembly line of a real binary. Catch:
  `BODY` is a normal triple-quoted string, so you must import the module and
  read `mod.BODY` — slicing the file text tests an undecoded program.
- **Compare what reaches the screen, not the data structure.** Merging Segments
  is *supposed* to change the segments; expand both to `(char, style)` per cell
  and compare that (`/tmp/hexeq.py`, `/tmp/gveq.py`).
- **Check the old code against itself first.** The graph routing diff looked
  like a regression until old-vs-old failed identically.
- **`.auto/check_search.py`** is the permanent version of this idea and runs in
  `checks.sh`: it compares the search fast paths against the plain per-line loop
  for every typed prefix. Cache staleness returns a *plausible wrong answer*,
  which no scenario test can catch.
- The bench's `NOTES` counters (`search_hits`, `graph_blocks`, `decomp_ok`,
  `render_cells`, `nav_rows`, `listing_cells`) are the standing guard against
  "faster because it did less".

## Two "predict what changed" schemes, both measured and rejected

Both would have made a rename nearly free. Both fail for the same reason: **IDA
and Hex-Rays drift on their own**, so "what the edit changed" is not the same
question as "what is different now".

- **Listing rows, predicted from `xrefs_to` + the function's extent.**
  `/tmp/whatchanges.py` rebuilds the segment before and after and diffs every
  row. echo: 19/19 changed rows covered. ls_ttl: 53/54 — the miss was
  `lea rcx, unk_1D7A0``byte_1D7A0`, which the rename did not cause; IDA's own
  analysis defined that byte.
- **Decompilations, predicted from "the old name appears in the cached text".**
  `/tmp/decchanges.py` decompiles 25 functions, renames one, recompiles all and
  diffs. **16 misses over 4 renames, every one of them Hex-Rays' type inference
  moving** — e.g. `unsigned __int64 f(..., unsigned int a4)``..., int a4)` in
  functions with no connection to the rename.

The lesson generalises: predicting the effect of an edit on a database that has
its own opinions is unsound. Verify instead — `heads(digest=True)` works because
it asks what the row renders as *now*, not what should have changed.

(This also explains the `lg_decomp_lines` drift blamed on CPU starvation in v5
#4: it is probably the same Hex-Rays instability.)

## Added late in the session

- ~~**Refresh only the rows a rename actually changed** (xrefs-driven).~~
  **MEASURED AND REJECTED.** `/tmp/whatchanges.py` rebuilds the whole segment
  before and after a rename and diffs every row. echo: all 19 changed rows over
  4 renames were covered by (function extent + `xrefs_to`). ls_ttl: 53 of 54 —
  and the one that was not, `lea rcx, unk_1D7A0``byte_1D7A0`, **was not
  caused by the rename at all**: IDA's own analysis defined that byte. Any
  address-predicted invalidation leaves such a row stale for good. Superseded by
  the digest scheme, which is exact because it looks at the rendered line.
- **Features the bench still doesn't drive end to end**, in the order they seem
  worth probing: xrefs (`x`), the strings browser (`"`), literal formats (`o`),
  make-code/data/function edits, history (`back`), execution traces, the RPC
  layer. Use the `/tmp/featprobe.py` shape: call the domain API for each and look
  for a number that is absurd for the work done. That is how the flowchart hull,
  `decomp_map` and the rename walk were all found.

## The UI is much slower while a big segment streams (measured, not fixed)

Opening a big binary leaves `ListingView._grow` streaming the segment in the
background for ~7s. During that window every UI action is several times slower:
an xrefs dialog measured **691ms while streaming against 105ms after** on bash.
Two mechanisms, both confirmed:

* **`@work(exclusive=True)` does not stop a thread worker that is already
  running**, and navigating inside the same segment re-primes against the SAME
  model — so every jump leaves another streamer behind, all queueing on the
  model's load lock. Five jumps into bash meant five streamers.
* Each streamer reports growth to the UI every four pages; a report is a thread
  hop, a `virtual_size` change and a full repaint.

Two fixes were tried and **both measured worse**, so neither was kept:
a time-based report throttle (10/s) made it worse because the count is per
*streamer* and there are several; adding a token so only the newest streamer
survives did not reduce the report count either, which means the retirement is
not happening where it looks like it should — worth understanding before trying
again. Probe: `/tmp/streamresp.py` (navigate + press `x` repeatedly while
`lst.model.complete` is still False, counting `_grew` calls).

Caveat: these numbers were taken on a loaded box and the probe re-primes the
view on every iteration, which is itself what spawns the extra streamers. Build
a cleaner probe first.

## Pre-existing crash, unrelated to performance

`StringsPalette.on_mount` calls `self._apply("")`, which does
`query_one(OptionList)` before `compose`'s children are mounted:
`NoMatches: No nodes match 'OptionList' on StringsPalette()`. Reproduces 3/3 on
`targets/bash` with `/tmp/strcrash.py`, **and 3/3 on the pre-autoresearch commit
2b0ae8d** — so it is not something this work introduced. `ProjectPalette` has the
same shape and the same latent race.

## Trace memory reads scale with trace LENGTH (measured, not fixed)

`idatui/trace.py` loads linearly (36.6 / 73.3 / 143.9 / 280.7 ms for 20k / 40k /
80k / 160k rows — x1.95 per doubling, exactly right) and `register_state` is
effectively O(1). But `Trace.memory(addr, length, idx)` costs 16.3 / 31.7 / 63.4
/ 125.8 ms for 200 calls over those same traces: **linear in trace length per
call.**

`_mem_index` sorts accesses by address and bisects to the window, which is the
right idea — but it then iterates *every* access in that address window across
all time, filtering by `t > idx`. A hot stack slot in a loop is written once per
iteration, so the stack pane's cost grows with how long the trace ran. On a
10M-instruction trace a single step could scan millions of entries.

The fix is to find, per byte, the latest access with `t <= idx` rather than
scanning them all. The sort is already stable, so entries within one address are
in time order — but accesses have variable length and overlap, so grouping is
not trivial. Probe: `/tmp/traceprof.py`.

**Do not attempt this until `tests/test_trace_vs_tenet.py` can run** — it is the
differential against Tenet's own reference reader and it is currently skipped
here, which leaves `tests/test_trace.py`'s 35 synthetic checks as the only guard
on a subtle indexing change.

## A bench phase for item edits hangs (attempted, reverted)

`bump_items(ea)` keeping the listing's walk is worth 257x (4890ms → 19ms on
bash) but is **not visible in total_ms**, because the bench has no item-edit
phase. One was written and reverted: driving `undefine` from inside the pilot
hangs the run (no output, killed at the timeout), while the identical sequence
against `Program` directly is fine, and the same sequence with a `print` between
`prog.listing(ea)` and `ensure_ea` is also fine.

**That explanation was wrong** — corrected by a stack dump
(`faulthandler.dump_traceback_later`, `/tmp/hangdiag2.py`). At the moment of the
hang there are **no idatui threads at all**: the main thread is idle in
`selectors.select()` and the only others are idle asyncio executor threads. The
app is stuck *before* `app.run_test()` even returns — nothing to do with
`bump_items`, `_prime`/`_grow`, or `_load_lock`.

It is **pilot start-up flakiness**, and it is partly environmental: several
orphaned `idatui/worker.py` processes had accumulated from runs killed by
`timeout`, and clearing them (`pkill -f idatui/worker.py`) made the next run
boot fine — but it recurred afterwards, so that is not the whole story. Not the
kitty-graphics query either (`IDATUI_KITTY=0` still hangs).

Two things to take from it: **kill stray workers between probe runs**, and a
bench phase should not be built on this until app start-up under the pilot is
reliable. The item-edit win is carried by `/tmp/itemedit.py` (direct
measurement) and `.auto/check_edit.py` (correctness, in the gate).

## decomp_map: what is actually left (measured, corrects run #30)

Run #30 said "what is left in `decomp_map` is `ida_hexrays.decompile`, which
duplicates the decompile the view already did". **That is wrong.** A warm
`ida_hexrays.decompile()` is **0.01 ms** (`/tmp/hxcache.py`) — Hex-Rays' own
cache is free, and the duplicate costs nothing.

The sweep's real split, over 15 417 lines of bash (`/tmp/sweepprof.py`):

| part | cost | calls |
|---|---|---|
| `dstr()` | **2 581 ms (79%)** | 106 594 @ 24.2 µs |
| `get_line_item` | 691 ms | 445 337 @ 1.55 µs |
| `tag_remove` for the length | 14 ms | 15 417 |

Fixed by memoising `obj_id -> ea` for the whole function (v7 #44). What remains
is `get_line_item` per column, which is a real probe per screen column.

Ideas for the remainder, in order of appeal:

- **Map only the lines the split view can show.** The pane paints ~40 lines but
  the map is built for all 3 486. This is the same "compute on demand" shape
  that won for search highlight ranges (v5 #6). It needs a windowed tool
  (`first`/`count`) and a lazy container, because `app.py` and `trace_ctl.py`
  both index the whole list.
- Stepping over columns instead of probing each one is **not** safe: an item
  occupying one or two columns (a single-character variable) would be skipped
  entirely, silently dropping an EA from the region highlight.

## Re-printing instead of re-decompiling after a rename (measured, NOT applied)

`cfunc.refresh_func_ctext()` on a cached ctree is **46x faster** than the
recompile a rename currently forces (31 ms vs 1 432 ms for ten functions), and
it is arguably what a user expects: only the name changes.

**Not applied, because it changes what is on screen.** Only 2 of 10 functions
re-printed to the same text as a real recompile; the other eight differ by
Hex-Rays *type inference*: `char *` vs `const char *`, `__int64` vs
`signed __int64`, `unsigned int a4` vs `int a4`. Same drift already recorded
above — a full recompile re-runs inference with more accumulated knowledge, so
the two disagree even where the rename is irrelevant. Choosing the stabler text
is a product decision about what the pseudocode pane should show, not a
performance change, so it needs a human call. Probe: `/tmp/reprint.py`.

Also checked and **not** a bug: the split view's text and its `decomp_map` do
come from the same ctree. `Program.decompile` calls the `force_recompile` tool
(which does exist) before refetching, so the tool's plain
`ida_hexrays.decompile()` repopulates the cache and `decomp_map` then hits it.
`/tmp/mapalign.py` appeared to show a mismatch only because the probe itself
used `DECOMP_NO_CACHE`, which the app never does.

## PARKED: a 3.6x faster decompile_function_safe (measured, byte-identical, discarded on the metric)

ida-pro-mcp's `decompile_function_safe` — the function that produces the
pseudocode the pane shows — has the **same three faults** that were fixed in
`decomp_map`:

* it allocates **three** `ctree_item_t` SWIG objects per pseudocode line, and
  `_head` and `_tail` are never read (`get_line_item` takes None for both);
* it calls `dstr()` per line to recover the `/*0xEA*/` marker — 24 µs a call —
  where consecutive lines of a multi-line expression report the same ctree item,
  so memoising by `obj_id` (unique within a cfunc) skips most of them.

Measured, on a **warm** cfunc (so this is pure post-processing, no Hex-Rays):

| workload | before | after |
|---|---|---|
| bash's 8 largest, 18 991 lines (`/tmp/decprof.py`) | 2 302 ms (121.2 µs/line) | 429 ms (22.6 µs/line) |
| the same through the real worker (`/tmp/verifybind.py`) | 2 695 ms | 746 ms |
| echo's 12 largest, 3 298 lines (`/tmp/splitprof.py`) | 219 ms | 120 ms (30 µs/line) |

Byte-identical: `check_decomp.py` runs both implementations against the same
cfunc with `include_addresses` both ways — **128/128 echo and 372/372 ls_ttl**.
It is a real gate: keying the memo on `it.op` instead of `it.obj_id` fails 69 of
128.

**Discarded anyway**, because it does not move `total_ms`. Three runs with it
(25 367 / 25 058 / 25 171) against three without (24 514 / 25 122 / 25 783) —
the means are 25 199 vs 25 140, i.e. indistinguishable. The reason is in the
third row of the table: the saving is 98 µs/line on bash's *largest* functions
but only 30 µs/line on small ones, and the bench's fixed set averages 286 lines
a function. Expected effect ~150–250 ms against a run-to-run spread of ±400–600
on this box.

**It is still a real win for the operation a user waits on** — an F5 on a
2 374-line function drops 287 ms → 54 ms of post-processing — so it is parked
rather than deleted:

* `.auto/parked/fast_decompile.patch` (applies to `server/patch_server.py` and
  `idatui/worker.py`)
* `.auto/parked/check_decomp.py` (re-wire into `checks.sh` if the patch is
  re-applied; it will crash if run without it, since it slices the function out
  of `BODY`)

Re-apply it if the benchmark ever decompiles large functions, or if the goal
moves from total session time to per-operation latency. Do **not** re-shape the
bench's fixture set to make this win visible — that would be fitting the
benchmark to the change.

## PARKED: pc_nums, the third instance of the same bug (2.03x, byte-identical)

`_idatui_pc_nums` allocated **three `ctree_item_t` SWIG objects per candidate
column** — inside a scan that probes every literal-looking character of every
pseudocode line. `a``f` are hex digits, so `a1`, `v6` and `sub_1F4C0` all
qualify and most columns of a line get probed. `head` and `tail` were never
read. This is the same fault as `decomp_map`'s sweep and
`decompile_function_safe`'s loop — **three instances of one bug**.

It is on the F5 path: `app.py:_load_decomp` fetches `pc_nums` after every
successful decompile so the view can mark the literal under the cursor without a
round trip per keypress.

Measured warm (Hex-Rays already cached), bash's 8 largest, 18 991 lines:
**1 247 ms → 614 ms (2.03x), 66 → 32 µs/line**, with the literal count identical
(7 015). Also stopped `tag_remove` running twice over every line (`pc_nums` and
`_idatui_pc_nums` each called it); worth ~nothing on its own but it is strictly
less work.

Equivalence: the tool's whole output dumped per function and compared across
revisions (`/tmp/pcnumdump.py`, cross-process — an in-process differential
**segfaults**, two SWIG item objects over one cfunc). echo: 128 functions,
1 463 literals, **0 mismatches**. `tests/test_scenarios.py` also drives literal
cycling through these exact column extents.

Parked at `.auto/parked/fast_pc_nums.patch` for the same reason as the decompile
patch: real work removed, but ~170 ms against a ±500 ms run-to-run spread.

## Both parked patches, measured together

Applied together (they are both on the F5 path) over four runs: 25 367 / 25 058 /
25 171 / 24 585, against three without: 24 514 / 25 122 / 25 783. Means **25 045
with, 25 140 without** — a 95 ms edge inside a 500 ms spread, i.e. still not
resolvable. Discarded on the metric, kept on disk.

Apply both if the goal moves to per-operation latency: an F5 on a 2 374-line
function loses ~233 ms of text post-processing and halves its `pc_nums` cost.

## A 27 283 ms outlier, and how to recognise one

One run came back at 27 283 (against ~24 500) with `lg_split` 2 200 → 6 917 and
`lg_search` 3 441 → 840. It was NOT the change under test: the **work counters
moved with it** — `lg_decomp_lines` 3 436 → 3 233, `lg_split_mapped_lines`
2 070 → 1 993, `lg_search_hits` 91 783 → 92 733. Hex-Rays decompiled bash
differently that run (the drift documented above), which changed how much of the
post-rename re-render the split phase absorbed before search got to it. The next
run reproduced 24 585 with every counter back to its usual value.

**The NOTES counters are what tell an outlier from a regression.** A real
regression moves the time and leaves the work alone.