|
| 1 | +# Efficiency of the code the lifter generates |
| 2 | + |
| 3 | +Goal: **measure** the cost of the code the x86 → C static translation produces, |
| 4 | +find its source **at the instruction level**, and improve the lifter. Every step |
| 5 | +is generated in a separate directory and can be compared with `diff`; the step |
| 6 | +promoted to the main version becomes `gen/all`, and the previous version is moved |
| 7 | +under `arsiv/vN/` (version number: `VERSION` at the repository root). |
| 8 | + |
| 9 | +**Versions:** v1 = baseline (archive: `arsiv/v1/`). **v2 = opt6, the main version |
| 10 | +since 2026-10-18** (`gen/all`, `out/nfsu2_boot`, `out/nfsu2_hd_boot`; generated by |
| 11 | +`tools/gen_v2.sh`). |
| 12 | + |
| 13 | +The table below lists the steps up to that date. All the directories are now under |
| 14 | +`arsiv/v1/gen/` and `arsiv/v1/out/`; opt6 = today's `gen/all`. |
| 15 | + |
| 16 | +| directory | binary | contents | |
| 17 | +|---|---|---| |
| 18 | +| `gen/all` (v1) | `out/nfsu2_boot` | baseline (as generated at the time) | |
| 19 | +| `gen/prof` | `out/nfsu2_prof_boot` | baseline + every x86 instruction as a comment (`COMMENTS=1`), `-gline-tables-only` — profiling only | |
| 20 | +| `gen/opt` | `out/nfsu2_opt_boot` | + FLAGABI | |
| 21 | +| `gen/opt2` | `out/nfsu2_opt2_boot` | + FLAGABI + X87OPT | |
| 22 | +| `gen/opt3` | `out/nfsu2_opt3_boot` | opt2 + INLINE=50 (negative result, for comparison) | |
| 23 | +| `gen/opt4` | `out/nfsu2_opt4_boot` | opt2 + RETEIP=0 | |
| 24 | +| `gen/opt5` | `out/nfsu2_opt5_boot` | opt4 + FCMPBL=1 (branchless fcompare) | |
| 25 | +| `gen/opt6` → **v2** | `out/nfsu2_opt6_boot` | opt4 + MEMARG=1, compiled with `-fno-strict-aliasing` | |
| 26 | +| `gen/cov` | `out/nfsu2_cov_boot` | opt2 + call counter (`COVERAGE=1`) — measurement only | |
| 27 | +| (same sources) | `out/nfsu2_{nsa,opt4nsa,opt5nsa}_boot` | `gen/all`/opt4/opt5 with `-fno-strict-aliasing` (the runtime too: `out/obj_rtnsa`) | |
| 28 | + |
| 29 | +Regeneration (from the repository root; the `OVERRIDE` list is the same as in |
| 30 | +`PROJECT.md`): |
| 31 | + |
| 32 | +```bash |
| 33 | +./.venv/bin/python tools/flagabi.py <SPEED2.EXE> out/flagabi.json # ~30 s |
| 34 | +OVERRIDE=... GENOUT=gen/opt2 FLAGABI=out/flagabi.json X87OPT=1 \ |
| 35 | + ./.venv/bin/python tools/genall.py <SPEED2.EXE> 128 |
| 36 | +# gen/opt2 -> out/obj_opt2 (same flags as build.sh), runtime objects from out/obj |
| 37 | +``` |
| 38 | + |
| 39 | +The switches are off by default: `genall.py` without switches produces **v1**. To |
| 40 | +produce v2 use `tools/gen_v2.sh` (four switches + the override list; reproduces |
| 41 | +`gen/all` exactly). |
| 42 | + |
| 43 | +## Measurement method |
| 44 | + |
| 45 | +- **Metric:** the **ARM instructions and cycles executed** by the game thread per |
| 46 | + frame (`thread_selfcounts`, the `CPUWIN` report). Much more stable than time: |
| 47 | + two runs of the same binary differ by 0.7% (instructions) / 3.6% (time). |
| 48 | +- **Scenario:** automatic Quick Race (`AUTOSEQ`), `PACE60=1 CPUWIN=6200,8800`, |
| 49 | + 3024x1964, 2 alternating runs per version. |
| 50 | +- **Correctness:** the differential fuzzer (`tools/fuzzisa.py`, real x86 = |
| 51 | + Rosetta; 3969 cases), image comparison at frame 8900 (compared against the |
| 52 | + difference between two runs of the same binary). |
| 53 | +- **Instruction-level profile:** `PERFLOG` samples (1 ms, guest thread) → |
| 54 | + `atos -i` → `gen/prof` line → the preceding `/* address instruction */` comment |
| 55 | + (`tools/lift_profile.py`). |
| 56 | + |
| 57 | +## Starting point: where the time goes (race, inside lifted code) |
| 58 | + |
| 59 | +| kind | share | |
| 60 | +|---|---| |
| 61 | +| line 0 + function entry (compiler: register saves, memory traffic of `c->` fields) | 20.2% | |
| 62 | +| `mov` memory | 14.9% | |
| 63 | +| `push`/`pop` | 11.2% | |
| 64 | +| `cmp`/`test` (flags) | 10.3% | |
| 65 | +| x87 (load/store 8.7, arithmetic 8.5, compare 5.8) | 23.0% | |
| 66 | +| `call` + `ret` | 6.8% | |
| 67 | + |
| 68 | +Targets of memory accesses in the hot lifted functions: `c->esp` 10.1%, flags |
| 69 | +~12% (`zf` 4.4, `sf` 3.3, `af` 2.5, `cf` 1.9), general registers ~10%. |
| 70 | + |
| 71 | +## 1. FLAGABI — cross-function flag liveness |
| 72 | + |
| 73 | +**Problem:** the flag liveness analysis counted "all flags read" at `call` and |
| 74 | +"all flags live" at `ret`. All six flags of the last flag-producing instruction |
| 75 | +before a call (very often `add esp,N`, `xor eax,eax; ret`) were computed and |
| 76 | +written to memory. |
| 77 | + |
| 78 | +**Change** (`tools/lift.py`: `abi_cfg`, `abi_liveness`, `_flag_defuse_abi`; |
| 79 | +`tools/flagabi.py`): MSVC code doesn't read flags returned from a call, and the |
| 80 | +callee doesn't read the caller's flags on entry. This assumption is **proven over |
| 81 | +the whole program**: at a fixed point, each function's entry-live flags and the |
| 82 | +call sites "where flags are read after the call" are computed; conservative |
| 83 | +behavior is kept only for those. A flag left undefined by x86 counts as defined |
| 84 | +(otherwise `and/test` never kill AF, and AF spread falsely everywhere: 3171 false |
| 85 | +"flag-returning" functions in the first attempt). |
| 86 | + |
| 87 | +**Audit result:** across 23274 functions, 14 "flag-returning", 210 entry-live, 12 |
| 88 | +sites reading flags after an indirect call; every indirect site examined is either |
| 89 | +a data byte decoded as code (`retf 0x50ff`, `int3`, a random `adc`) or a |
| 90 | +conservative outer edge. |
| 91 | + |
| 92 | +## 2. X87OPT — x87 register allocation within a block |
| 93 | + |
| 94 | +**Problem:** `ST(n) = c->st[(c->top+n)&7]` is indexed at run time; clang can |
| 95 | +never put x87 values in registers, and every operation is a `top` read + address |
| 96 | ++ load/store. |
| 97 | + |
| 98 | +**Change** (`tools/lift.py`: `X87Region`): within a basic block the stack depth |
| 99 | +is known at compile time. At the start of the region `_T = c->top`; `ST(n)` → |
| 100 | +`_x[off+n+8]` (constant index → ARM FP register); `FPUSH/FPOP` only change the |
| 101 | +static `off`. A slot read before being written is loaded once; at the end of the |
| 102 | +region (branch, call, ret, an instruction touching `c->st/c->top`, block end) only |
| 103 | +the modified slots still on the stack are written back, and `c->top` is updated |
| 104 | +once. `fnstsw` gets the static top via `fsw_get_top`. |
| 105 | + |
| 106 | +## Result |
| 107 | + |
| 108 | +| version | instr/frame | cycles/frame | CPU ms/frame | binary | |
| 109 | +|---|---|---|---|---| |
| 110 | +| baseline | 43.22 M | 16.50 M | 5.97 | 46.1 MB | |
| 111 | +| + FLAGABI | 40.24 M (−6.9%) | 15.76 M (−4.5%) | 5.86 | 40.3 MB | |
| 112 | +| + X87OPT | **38.51 M (−10.9%)** | **14.83 M (−10.1%)** | **5.44** | **37.3 MB (−19%)** | |
| 113 | + |
| 114 | +Fuzzer: 3969/3969 matched (X87OPT on and off). The image difference (mean 0.21) |
| 115 | +is at the same level as the difference between two runs of the same binary (0.16). |
| 116 | + |
| 117 | +## 3. Call model |
| 118 | + |
| 119 | +**Measurement** (`gen/cov`, `COVREPORT` + `COVRESET_AT=6200`, 2800 frames): in a |
| 120 | +race **~104 thousand** lifted function calls per frame (~370 ARM instructions per |
| 121 | +call on average). 37% of the calls go to ≤50-instruction leaf functions that make |
| 122 | +no further calls (~38 thousand per frame); the most called is `__ftol2` |
| 123 | +(`sub_0075bed4`, 39 instructions) at 10.5 thousand/frame. The call cost in the |
| 124 | +machine code of a small function: 3 `stp` + 3 `ldp` at entry/exit, reloading |
| 125 | +`c->esp` and `g_mem` after every call, writing the return address to `c->eip` at |
| 126 | +every `ret`. |
| 127 | + |
| 128 | +**3a. INLINE (negative).** `genall.py INLINE=50`: the bodies of 4703 leaf |
| 129 | +functions are put into every shard as a |
| 130 | +`static inline __attribute__((always_inline)) inl_X` copy, and direct calls are |
| 131 | +redirected to it. Result (2 runs): instructions 37.82 → 38.20 M (+1%), cycles |
| 132 | +14.44 → 14.93 M (+3.4%), binary 37.3 → 43.6 MB. The fixed cost per call is smaller |
| 133 | +than estimated; the inlined bodies increase register pressure in the already large |
| 134 | +goto functions. ThinLTO (−1.8%, within noise) had given the same result. |
| 135 | + |
| 136 | +**3b. RETEIP.** `ret` → `c->esp += 4(+n); return;` instead of |
| 137 | +`POP32(c->eip); return;`. The only place reading `c->eip` after a lifted call was |
| 138 | +`genexec.c`; it now uses the return address it pushed (`next`) (the model already |
| 139 | +ignored modified return addresses). Result (opt2 → opt4, 2 runs): instructions |
| 140 | +37.83 → 37.59 M (−0.7%), cycles 13.95 → 13.58 M (**−2.7%**), image the same. |
| 141 | + |
| 142 | +## 4. Strict aliasing undefined behavior |
| 143 | + |
| 144 | +**Symptom.** opt5 (`FCMPBL=1`, `fcompare` → branchless `fcompare_bl`) noticeably |
| 145 | +changed the race frame (Y 57.3 → 59.5, the same in two runs). The two functions |
| 146 | +produce the same bits for every pair of `double`s (exhaustive test), and the |
| 147 | +generated C differs only in the function name. |
| 148 | + |
| 149 | +**Narrowing down** (no guessing, every step measured): |
| 150 | +1. bisecting shards (mixing opt4/opt5 objects and checking frame 8900 brightness) |
| 151 | + → `shard_0058`; |
| 152 | +2. bisecting functions (`fcompare_bl` only in selected functions) → |
| 153 | + `sub_005f0a40` (a quadtree search: compares the point against box midpoints and |
| 154 | + descends into a child); |
| 155 | +3. a function-level differential harness: runs the two builds side by side with |
| 156 | + random trees/points/boxes (±0, NaN, equality frequent) and compares the final |
| 157 | + state. opt5 `-O3` diverges **from its own `-O0`** (7854 in 100 thousand), opt4 |
| 158 | + doesn't; the difference is the sign of the midpoint on the stack (−0/+0); |
| 159 | +4. `-mllvm -opt-bisect-limit` → the breaking pass is **GVNPass** (pass 262 of 386); |
| 160 | +5. IR: an `i32` is written to the same guest stack address and a `float` is read, |
| 161 | + with different TBAA tags. |
| 162 | + |
| 163 | +**Root cause.** Lifted code accesses guest memory at the same address with |
| 164 | +different types (writing `MR32` and reading `MRF32`, `MR16`/`MR32` overlap). In C |
| 165 | +this is a strict aliasing violation; the compiler may assume that accesses of |
| 166 | +different types don't overlap and move the load ahead of the store. The build |
| 167 | +flags **never had** `-fno-strict-aliasing` — including the baseline `gen/all` and |
| 168 | +the runtime. With the flag opt5 = opt4 = baseline (image), difference 0. |
| 169 | +`fcompare_bl` is correct; the UB only became visible when the shape of the code |
| 170 | +changed. |
| 171 | + |
| 172 | +**Verification and cost** (against the Windows reference, frames 1300–8900, port |
| 173 | +`NFSRES=1280x960 VCLOCK=fixed`): with the flag, baseline/opt4/opt5 give the same |
| 174 | +image; no visible effect of this UB was measured in the baseline build. Raw cost |
| 175 | +(mean of 2 runs): |
| 176 | + |
| 177 | +| | instr/frame | cycles/frame | |
| 178 | +|---|---|---| |
| 179 | +| baseline | 42.61 M | 14.34 M | |
| 180 | +| baseline + `-fno-strict-aliasing` | 45.24 M (+6.2%) | 15.14 M (+5.6%) | |
| 181 | +| opt4 | 38.54 M | 12.81 M | |
| 182 | +| opt4 + `-fno-strict-aliasing` | 39.61 M (+2.8%) | 12.95 M (+1.1%) | |
| 183 | + |
| 184 | +Source of the cost (disassembly): without type information the value of the |
| 185 | +global `g_mem` is re-read after every guest write (`sub_005f0a40`: 2 → 10 |
| 186 | +`ldr x, [&g_mem]`). |
| 187 | + |
| 188 | +## 5. MEMARG — the guest memory base as a parameter |
| 189 | + |
| 190 | +**Observation.** With or without the flag, CPU fields like `c->eax` are re-read |
| 191 | +after every write to guest memory: even though `c` is `restrict`, `g_mem` is a |
| 192 | +pointer read from memory, and since `c` is passed to calls it has "escaped"; LLVM |
| 193 | +can't prove that a guest write doesn't touch `*c`. |
| 194 | + |
| 195 | +**Change** (`tools/genall.py`, `MEMARG=1`, a post-generation transform): every |
| 196 | +function becomes `void sub_X_m(CPU *restrict c, uint8_t *restrict _mb)`; the shard |
| 197 | +has `#define g_mem _mb`; lifted code calls each other with `sub_Y_m(c, _mb)`. The |
| 198 | +externally visible `void sub_X(CPU*)` stays as a thin wrapper (fntab, indirect |
| 199 | +calls, runtime → lifted calls and overrides don't change; the `_m` bridge for |
| 200 | +overrides and missing functions is in `stubs.c`). Two separate `noalias` |
| 201 | +parameters never alias in LLVM, with certainty — no escape analysis needed. On |
| 202 | +ARM64 `_mb` arrives in `x1`. |
| 203 | + |
| 204 | +In a single function (`sub_005f0a40`, `-fno-strict-aliasing`): CPU field reads |
| 205 | +25 → 4, guest reads 18 → 8; an int write followed by a float read is now forwarded |
| 206 | +directly (`str w12` → `fmov s0, w12`). |
| 207 | + |
| 208 | +**Result** (opt4 + `-fno-strict-aliasing` ↔ opt6, alternating; instructions 2+2, |
| 209 | +cycles excluding the noisy p4 run (IPC 2.72)): instructions 39.33 → **35.50 M |
| 210 | +(−9.7%)**, cycles 12.94 → **11.51 M (−11%)**, binary 38.1 → 34.8 MB. Image: all 16 |
| 211 | +frames under Windows conditions identical to opt4+flag. MEMARG doesn't just win |
| 212 | +back the cost of the correctness fix, it goes beyond it. |
| 213 | + |
| 214 | +## Total (with a correct build) |
| 215 | + |
| 216 | +| version | instr/frame | cycles/frame | binary | |
| 217 | +|---|---|---|---| |
| 218 | +| baseline (`gen/all`, strict aliasing UB present) | 42.61 M | 14.34 M | 46.1 MB | |
| 219 | +| opt6 (FLAGABI + X87OPT + RETEIP + MEMARG, `-fno-strict-aliasing`) | **35.50 M (−16.7%)** | **11.51 M (−19.7%)** | **34.8 MB (−25%)** | |
| 220 | + |
| 221 | +## 6. v2: opt6 as the main version (2026-10-18) |
| 222 | + |
| 223 | +opt6 was first played as a separate build with the current runtime. Once the user |
| 224 | +approved it, it became the main version (v2): `gen/opt6` → `gen/all`, v1 was moved |
| 225 | +under `arsiv/v1/`. |
| 226 | + |
| 227 | +```bash |
| 228 | +tools/gen_v2.sh <SPEED2.EXE> # gen/all (131 files, 0 differences from gen/opt6) |
| 229 | +./build.sh && ./link.sh # everything with -fno-strict-aliasing |
| 230 | +./pack.sh --embed [--hd] # out/personal/NFSU2[ HD].app, version "2.0" |
| 231 | +``` |
| 232 | + |
| 233 | +- The `tools/gen_v2.sh` recipe reproduced `gen/opt6` exactly; the 130 objects |
| 234 | + compiled with `build.sh` are byte-for-byte identical to the opt6 objects. |
| 235 | + `impargs.c` is the same as in v1. |
| 236 | +- Tests on v2: 439/439 ISA, 15/15 function verification. |
| 237 | + |
| 238 | +**Verification, image** (Windows NFSRef conditions, 16 frames): difference from the |
| 239 | +Windows reference in the menu 0.18–0.55. This is at the same level as the v1 |
| 240 | +build's 0.18–0.70, and the difference comes from the animation phase. Sharpness |
| 241 | +and edge profile identical. HD v2 vs HD v1: 0.07–0.64. Command buffer errors 0, |
| 242 | +unresolved indirect calls 0. |
| 243 | + |
| 244 | +**Verification, CPU** (same runtime, `PACE60 CPUWIN=6200,8800`, 3024x1964, |
| 245 | +alternating 3+3 runs): |
| 246 | + |
| 247 | +| | CPU ms/frame | instr/frame | cycles/frame | |
| 248 | +|---|---|---|---| |
| 249 | +| v1 | 5.71 | 41.99 M | 15.49 M | |
| 250 | +| v2 (opt6) | **4.44 (−22.3%)** | **34.17 M (−18.6%)** | **11.19 M (−27.7%)** | |
| 251 | + |
| 252 | +Since the game is at its 120 fps limit, the gain doesn't show in fps. Where it |
| 253 | +shows is the time the game thread spends asleep, and watts (see |
| 254 | +`tools/session.sh`, powermetrics). |
| 255 | + |
| 256 | +## Next candidates |
| 257 | + |
| 258 | +- **Call model:** `c->esp` and the general registers are written to memory and |
| 259 | + read back at every call (~20% of memory accesses in hot code). |
| 260 | +- **Fusing `fcom → fnstsw → test → jcc`:** reducing it to a single C comparison. |
| 261 | +- **Extending the x87 region across blocks** (merge points with consistent depth). |
0 commit comments