Skip to content

[AUTOMATED] Cache full IR keys for inline arena checks - #1107

Merged
mahaloz merged 8 commits into
Noelo-Lab:mainfrom
mgostIH:perf/arena-lookup
Oct 29, 2026
Merged

mahaloz merged 8 commits into
Noelo-Lab:mainfrom
mgostIH:perf/arena-lookup

Conversation

@mgostIH

@mgostIH mgostIH commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1088: the first two commits are #1088 unchanged; the change here is the last commit, [AUTOMATED] Cache full IR keys for inline arena checks.

Varnode and PcodeOp lookups by id are the second-largest CPU category on ordinary binaries (14.6% of self cycles on whole Bash), and they repeat: counting public bank get/get_mut calls inside Actions on Bash, 2.98 billion lookups were made and 92% of them hit an id already looked up in the same Action call. Each one pays SlotMap's out-of-line generation check. This change makes the check a single inlined compare, so the repeated lookups become cheap without rewriting dozens of rules.

curl -fL 'https://huggingface.co/datasets/noelo-lab/decbench-dataset/resolve/815eb96f130b6d2ef7fa600e04669bf20139bf27/binaries/O2-noinline/dash/dash' -o /tmp/dash.bin
strip --strip-all -o /tmp/dash /tmp/dash.bin
perf stat -e instructions:u kuna decompile-all /tmp/dash --jobs 1 --json --max-fn-seconds 600 > /tmp/dash.json

Main 4aac7a27: 51.12 G instructions. #1088 plus this commit: 44.75 G (−12.5%), identical JSON; #1088 alone accounts for about half of that.

Set Instructions CPU
5 ordinary x86-64 binaries (dash, ssh-add, find, man2html, bzip2), #1088 + this commit vs main 4aac7a27 −13.4% −15.6% (one pair)
same 5 binaries, this commit alone −5.6%
12 costliest DecBench sample binaries, this commit alone (on top of #1088) −6.57% −8.96% (3 fresh pairs: −9.3%, −9.6%, −8.1%)

Complete JSON is identical on every measured binary. Bash peak RSS moves 252.7 → 254.0 MiB.

  • kuna_checkedarena::Arena wraps the SlotMap: SlotMap still owns storage and allocates every id. Next to it, a Vec<u64> holds each live slot's full key (index and generation, 8 bytes per slot); a vacant slot holds a marker whose index is never allocated.
  • get/get_mut compare the requested key with that word and then read the slot with get_unchecked. insert, remove and clear are the only paths that change slot membership, and each keeps the word in step with it (removal and clear invalidate it before touching storage), so a matching word means a live key. A stale, removed or null id still returns None, as before.
  • The bank accessors, PcodeOp::code and Varnode::has_no_descend become inlinable.

The unsafe is the review point. It relies on one invariant held inside a 100-line module: the cached word equals the key only while that key is live. Debug builds keep SlotMap's own check (get_unchecked asserts contains_key), and a differential test drives random insert/remove/clear sequences against a plain SlotMap. The safe alternative, the same cache returning Option<Key> with SlotMap's checked access, measured −4.1% instead of −5.6% on the same set.

Tests: 326 substrate:: unit tests pass, including the new arena differential and index tests and accessor-validity tests for both banks (stale, retired, null and cleared ids).

Share compact comparator snapshots and precompute address and flag ordering. Preserve creation order, SeqNum quirks, and unification failure states. IR instructions fall 7.06% with identical output; 66 focused bank and Funcdata checks pass.
Pack signed size and class ordering into one location-key prefix. Reuse
immutable address and size snapshots when a node changes class, and construct
birth keys directly. Immediate teardown clears the live definition without
inserting a temporary free entry before removing the indexed snapshot.

Preserve combined class masks, signed bounds and SeqNum's uniq-only equality;
add comparator, range, snapshot and teardown coverage and update the spec.

Focused validation: 127 unit/integration tests, test layout, spec anchors and
diff checks passed. Final candidate reduces instructions:u by 8.16% on ir and
9.07% on core against edba009, with identical outputs; smoke also matches.
Three fresh core CPU pairs remain pending at the user's requested pause.
Keep SlotMap storage and ID allocation while validating Varnode and op
accesses against a compact array of full FFI keys. Invalidate cached keys
before removal or clear, and retain SlotMap validation on debug reads.
Expose the checked bank accessors and two hot record getters to inlining.

The IR panel uses 5.56% fewer user instructions and smoke uses 6.98% fewer;
all outputs are identical. All 52 focused arena/bank tests and the spec,
test-layout, formatting, and diff checks pass. Core measurements continue.
@mahaloz

mahaloz commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

[AUTOMATED] I re-measured the arena commit (b1259a4) alone, rebased onto main cb68db18f. It is still slower on our host, so we are not merging it yet.

The output is unchanged. decompile-all --json is byte-identical on 121 binaries (18,066 functions: decbench O0/O2/O2-noinline, plus x86-64, i386, ARM, AArch64, MIPS, PPC, SPARC and RISC-V fixtures). The parity gates pass.

Speed was measured on an Intel Xeon Silver 4316 (Ice Lake-SP) with kuna decompile-all <bin> --json --jobs 1. Base and head ran at the same time, each pinned to an idle physical core whose SMT sibling was also idle, and the sides swapped every round. Rounds where a sibling got busy, or a run waited for CPU, were dropped. In one batch a second copy of the base binary ran alongside as an A/A noise control.

binary head/base user CPU, median range n A/A control
dash (O2-noinline, stripped) 1.027 0.971..1.098 40 1.018
bzip2 (O2-noinline) 1.024 0.963..1.100 36 1.007
find (O2-noinline) 1.031 0.983..1.088 42 1.005
ssh-add (O2-noinline) 1.020 0.979..1.072 20
gzip (O2) 1.033 0.943..1.102 40 1.009
libopencm3 lcd-dma (O2, ARM) 1.016 0.931..1.120 30 0.988
nuttx (O2, ARM) 1.019 0.939..1.115 24
  • Head was slower in 190 of 232 rounds.
  • Wall time tracks user CPU, and peak RSS is up 0.4-1.4%.
  • An earlier A/B on the same machine found the same pattern. The #[inline] attributes alone were neutral, and the safe cached variant (checked get behind the key cache) was 4.5-6% slower.

We can't read perf counters here, so I can't check the instruction counts. The −5.6% instructions you measured may well be real, but on this CPU the change costs cycles. We would merge it with a measurement that shows a gain on an Intel server part, or with a variant that is neutral or better here.

@mahaloz

mahaloz commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

[AUTOMATED] Re-measured after main gained #1203 (FxHash id maps), on this PR merged with main abace00. Output is byte-identical (60 binaries, kuna decompile-all <bin> --json --jobs 1). On our Xeon Silver 4316 the arena change is still about 2% slower in user CPU. We ran 3-way concurrent runs (base, a second copy of base as an A/A control, and this PR), each pinned to an idle physical core, with cores rotated every round over 10 rounds per binary. The PR was slower than base in 63 of 90 rounds. Median ratios: dash 1.032, gzip-O2 1.019, find 1.021, libz-O0 1.023, nuttx-ARM 1.018, tar-O2-noinline 1.025. The A/A median was 0.995. Earlier variants (a u32-only cache, and inline attributes without the cache) were also not a net win: shrinking the cache halved the cost, which points at the extra side-array load per lookup. If you have a version that is neutral or faster on a multi-socket Xeon, we're happy to re-measure. Repro: taskset -c <idle core> /usr/bin/time -f %U kuna decompile-all <decbench O2 gzip> --json --jobs 1 >/dev/null, base vs this PR, interleaved.

mahaloz and others added 2 commits October 29, 2026 16:26
Resolve docs/spec/00-overview.md: keep main's block-order and
delete_varnode paragraphs, then the checked-arena paragraph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BBaWTixHX2E5cBwneY5aW
The separate key array made lookups about 2% slower in user CPU on an
Ice Lake-SP Xeon. Every lookup loaded one more cache line, and it had to
build the 64-bit key from its two halves first. SlotMap's version word
already sat next to the flag words that most lookups read.

Each slot now stores its full key word directly after the value, so the
inline check reads a line the caller already touches. Allocation,
free-list order and versions replicate slotmap::SlotMap, so ids and
output are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BBaWTixHX2E5cBwneY5aW
@mahaloz

mahaloz commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

[AUTOMATED] I pushed two commits: a merge of current main, and 3e42e5c, which moves each cached key out of the side array and into its slot, directly after the value.

Why: on our Xeon Silver 4316 (rustc 1.90), the side-array version measured about 1.6-2% more user CPU in five separate reviews. Main already inlines VarnodeBank::get/PcodeOpBank::get at every call site, so those checks were never out-of-line calls on this toolchain. SlotMap's version word sits 8-16 bytes after the Varnode/PcodeOp flag words, so its check reads a cache line the caller touches anyway. The side array added a second cache line per lookup, plus a shl/or to build the u64 key. The slot still holds the full 64-bit key, so the lookup is still an inline full-id compare with no SlotMap call. Allocation, free-list order and versions replicate SlotMap, so ids and output are unchanged: 33 binaries / 6,173 functions are byte-identical.

Repro (user CPU vs main, interleaved, each run on an idle physical core):
taskset -c <core> /usr/bin/time -f %U kuna decompile-all <bin> --json --jobs 1 >/dev/null
Across 6 binaries (dash, gzip, find, libz, nuttx-ARM, tar), with base, an A/A copy of base and the candidate run concurrently, 83 clean rounds:

  • this layout: median 0.998, slower in 38/83 rounds
  • A/A: median 0.997
  • the side-array version: median 1.016, slower in 30/39 rounds

If you can re-measure on the machine where you saw the gain, we'd like to know whether it still holds.

thotypous and others added 3 commits October 29, 2026 17:08
SPARC32 callers of libgcc's `__divdi3` decompile with four 32-bit arguments and an unsigned, word-swapped quotient:

```sh
printf '%s\n' 'void g(void);' \
 'long long q(long long a,long long b){return a/b;}' \
 'long long keep(long long a,long long b){long long r=a/b;g();return r;}' \
 'long long guard(long long a,long long b){if(b==0)return -1;return a/b;}' \
 > /tmp/di-returns.c
clang -target sparc-linux-gnu -integrated-as -O2 -c /tmp/di-returns.c -o /tmp/di-returns.o
kuna decompile-all /tmp/di-returns.o --option libgccsigs on
```

Default output is `unsigned long long keep(...) { unsigned long long v1; // o1:o0  v1 = __divdi3(a0,a1,a2,a3); ...`. With `--option libgccsigs on`, `q`, `keep` and `guard` return `long long` from `__divdi3(CONCAT44(a0,a1),CONCAT44(a2,a3))`; `keep` keeps the intervening call and `guard` returns `-1` without dividing.

- `libgccsigs` (default off) seeds `long long __divdi3(long long, long long)` on SPARC32 big-endian ELF images that name `__divdi3`; DWARF and `--assert` declarations keep precedence. It stays opt-in because callers that test or clamp the quotient before returning it still lose words.
- On high-word-first 32-bit pair models (SPARC32, PowerPC32, MIPS32 BE, ARM/Thumb BE), a caller's return of a declared eight-byte integer call result is kept whole and in ABI order when nothing but the RETURN (or a later call's unresolved input) reads it: return recovery waits for frame SSA, and contained-output guards on four-byte-word pairs are split into per-word trials that only the whole-pair check can activate. A `void` caller that stores or tests such a result stays `void`.
- `RuleLoadVarnode` reads `(base + c) | k` as an offset when the base's known-zero bits prove the OR is an addition.
- Known limit: a SPARC32 `-O0` caller without its own debug info that stores and then returns a declared 64-bit result returns only the high word (main printed `void`).

Tests: `libgcc_divdi3` (generated C recompiled and run at `-O0`/`-O2`, option on and off, BE void-caller regressions), `call_result_pair_returns`, `call_wide`, a `tests/stages/` testcase, and the restored ARM stage assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BBaWTixHX2E5cBwneY5aW
…ents in images mapped low (Noelo-Lab#1264)

ARM code compiled `-fPIC` but linked as a fixed-address executable at 0x10000 loses two kinds of reference: function pointers read through the GOT have no xref at all, and a string whose PC-relative displacement happens to be a mapped address gets none either. The raw displacement is filed as a pointer instead. Thumb code loses every one of these, because its literal loads were not recognised.

```
$ kuna xrefs decompiler/crates/kuna-analysis/tests/fixtures/gotxref_arm_le32 --from arm_timers
...
0x110dc	data	-	@0x100f8	ldr r0,[0x10164]
0x110e4	data	-	@0x100d4	ldr r0,[0x1015c]
0x120f4	data	-	@0x100dc	ldr r6,[0x10160]
$ kuna xrefs .../gotxref_arm_le32 --to on_expire
# 0 references to on_expire @ 0x10098
$ kuna xrefs .../gotxref_thumb_le32 --to on_alarm
# 0 references to on_alarm @ 0x100b0
```

0x110dc, 0x110e4 and 0x120f4 are displacements, not pointers. The fix, in `listing/kuna_picpool.rs` with its plumbing in `xrefs.rs`:

- In an image with a GOT or `.dynamic`, a pool word is a displacement whenever the PC is added to it, whatever it looks like, and Thumb literal loads (16-bit `ldr`, `ldr.w`) are read too. Static GOT-less firmware keeps the previous rule and cost.
- A PC-composed value that lands in `.got` becomes a GOT base, tracked along every edge (switch cases, calls in callee-saved registers, predicated skips) with a meet at joins. A load through the base plus a pool word files a read of the slot and a reference to the slot's word. `poolref`'s pointer row for a consumed word is dropped.
- An odd target in code names the Thumb function (bit 0 cleared) when the walk decoded code or knows a function there, or a `$t` mapping symbol covers it; under `$d`/`$a` it keeps its exact address.
- Only data and read rows change; the decompiler reads call rows only. On ARM PIC images the new data references can reorder `decompile-all`'s callee-first pass (struct numbering only).

Tests: `gotxref_{arm,thumb}_le32` and `picodd_thumb_le32` fixtures with console verify tests, three `tests/cli/` probes, `kuna_picpool` unit tests, spec prose for the shapes still not covered, and the closed `arm-pic-got-xrefs` need. Speed: GOT-less firmware reference walk within ~1% of main; ARM PIC libraries +7-8% walk time for their new rows; no measurable `decompile-all` change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BBaWTixHX2E5cBwneY5aW
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BBaWTixHX2E5cBwneY5aW
@mahaloz
mahaloz merged commit cb2279c into Noelo-Lab:main Oct 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants