Goal: measure the cost of the code the x86 → C static translation produces,
find its source at the instruction level, and improve the lifter. Every step
is generated in a separate directory and can be compared with diff; the step
promoted to the main version becomes gen/all, and the previous version is moved
under arsiv/vN/ (version number: VERSION at the repository root).
Versions: v1 = baseline (archive: arsiv/v1/). v2 = opt6, the main version
since 2026-10-18 (gen/all, out/nfsu2_boot, out/nfsu2_hd_boot; generated by
tools/gen_v2.sh).
The table below lists the steps up to that date. All the directories are now under
arsiv/v1/gen/ and arsiv/v1/out/; opt6 = today's gen/all.
| directory | binary | contents |
|---|---|---|
gen/all (v1) |
out/nfsu2_boot |
baseline (as generated at the time) |
gen/prof |
out/nfsu2_prof_boot |
baseline + every x86 instruction as a comment (COMMENTS=1), -gline-tables-only — profiling only |
gen/opt |
out/nfsu2_opt_boot |
+ FLAGABI |
gen/opt2 |
out/nfsu2_opt2_boot |
+ FLAGABI + X87OPT |
gen/opt3 |
out/nfsu2_opt3_boot |
opt2 + INLINE=50 (negative result, for comparison) |
gen/opt4 |
out/nfsu2_opt4_boot |
opt2 + RETEIP=0 |
gen/opt5 |
out/nfsu2_opt5_boot |
opt4 + FCMPBL=1 (branchless fcompare) |
gen/opt6 → v2 |
out/nfsu2_opt6_boot |
opt4 + MEMARG=1, compiled with -fno-strict-aliasing |
gen/cov |
out/nfsu2_cov_boot |
opt2 + call counter (COVERAGE=1) — measurement only |
| (same sources) | out/nfsu2_{nsa,opt4nsa,opt5nsa}_boot |
gen/all/opt4/opt5 with -fno-strict-aliasing (the runtime too: out/obj_rtnsa) |
Regeneration (from the repository root; the OVERRIDE list is the same as in
PROJECT.md):
./.venv/bin/python tools/flagabi.py <SPEED2.EXE> out/flagabi.json # ~30 s
OVERRIDE=... GENOUT=gen/opt2 FLAGABI=out/flagabi.json X87OPT=1 \
./.venv/bin/python tools/genall.py <SPEED2.EXE> 128
# gen/opt2 -> out/obj_opt2 (same flags as build.sh), runtime objects from out/objThe switches are off by default: genall.py without switches produces v1. To
produce v2 use tools/gen_v2.sh (four switches + the override list; reproduces
gen/all exactly).
- Metric: the ARM instructions and cycles executed by the game thread per
frame (
thread_selfcounts, theCPUWINreport). Much more stable than time: two runs of the same binary differ by 0.7% (instructions) / 3.6% (time). - Scenario: automatic Quick Race (
AUTOSEQ),PACE60=1 CPUWIN=6200,8800, 3024x1964, 2 alternating runs per version. - Correctness: the differential fuzzer (
tools/fuzzisa.py, real x86 = Rosetta; 3969 cases), image comparison at frame 8900 (compared against the difference between two runs of the same binary). - Instruction-level profile:
PERFLOGsamples (1 ms, guest thread) →atos -i→gen/profline → the preceding/* address instruction */comment (tools/lift_profile.py).
| kind | share |
|---|---|
line 0 + function entry (compiler: register saves, memory traffic of c-> fields) |
20.2% |
mov memory |
14.9% |
push/pop |
11.2% |
cmp/test (flags) |
10.3% |
| x87 (load/store 8.7, arithmetic 8.5, compare 5.8) | 23.0% |
call + ret |
6.8% |
Targets of memory accesses in the hot lifted functions: c->esp 10.1%, flags
~12% (zf 4.4, sf 3.3, af 2.5, cf 1.9), general registers ~10%.
Problem: the flag liveness analysis counted "all flags read" at call and
"all flags live" at ret. All six flags of the last flag-producing instruction
before a call (very often add esp,N, xor eax,eax; ret) were computed and
written to memory.
Change (tools/lift.py: abi_cfg, abi_liveness, _flag_defuse_abi;
tools/flagabi.py): MSVC code doesn't read flags returned from a call, and the
callee doesn't read the caller's flags on entry. This assumption is proven over
the whole program: at a fixed point, each function's entry-live flags and the
call sites "where flags are read after the call" are computed; conservative
behavior is kept only for those. A flag left undefined by x86 counts as defined
(otherwise and/test never kill AF, and AF spread falsely everywhere: 3171 false
"flag-returning" functions in the first attempt).
Audit result: across 23274 functions, 14 "flag-returning", 210 entry-live, 12
sites reading flags after an indirect call; every indirect site examined is either
a data byte decoded as code (retf 0x50ff, int3, a random adc) or a
conservative outer edge.
Problem: ST(n) = c->st[(c->top+n)&7] is indexed at run time; clang can
never put x87 values in registers, and every operation is a top read + address
- load/store.
Change (tools/lift.py: X87Region): within a basic block the stack depth
is known at compile time. At the start of the region _T = c->top; ST(n) →
_x[off+n+8] (constant index → ARM FP register); FPUSH/FPOP only change the
static off. A slot read before being written is loaded once; at the end of the
region (branch, call, ret, an instruction touching c->st/c->top, block end) only
the modified slots still on the stack are written back, and c->top is updated
once. fnstsw gets the static top via fsw_get_top.
| version | instr/frame | cycles/frame | CPU ms/frame | binary |
|---|---|---|---|---|
| baseline | 43.22 M | 16.50 M | 5.97 | 46.1 MB |
| + FLAGABI | 40.24 M (−6.9%) | 15.76 M (−4.5%) | 5.86 | 40.3 MB |
| + X87OPT | 38.51 M (−10.9%) | 14.83 M (−10.1%) | 5.44 | 37.3 MB (−19%) |
Fuzzer: 3969/3969 matched (X87OPT on and off). The image difference (mean 0.21) is at the same level as the difference between two runs of the same binary (0.16).
Measurement (gen/cov, COVREPORT + COVRESET_AT=6200, 2800 frames): in a
race ~104 thousand lifted function calls per frame (~370 ARM instructions per
call on average). 37% of the calls go to ≤50-instruction leaf functions that make
no further calls (~38 thousand per frame); the most called is __ftol2
(sub_0075bed4, 39 instructions) at 10.5 thousand/frame. The call cost in the
machine code of a small function: 3 stp + 3 ldp at entry/exit, reloading
c->esp and g_mem after every call, writing the return address to c->eip at
every ret.
3a. INLINE (negative). genall.py INLINE=50: the bodies of 4703 leaf
functions are put into every shard as a
static inline __attribute__((always_inline)) inl_X copy, and direct calls are
redirected to it. Result (2 runs): instructions 37.82 → 38.20 M (+1%), cycles
14.44 → 14.93 M (+3.4%), binary 37.3 → 43.6 MB. The fixed cost per call is smaller
than estimated; the inlined bodies increase register pressure in the already large
goto functions. ThinLTO (−1.8%, within noise) had given the same result.
3b. RETEIP. ret → c->esp += 4(+n); return; instead of
POP32(c->eip); return;. The only place reading c->eip after a lifted call was
genexec.c; it now uses the return address it pushed (next) (the model already
ignored modified return addresses). Result (opt2 → opt4, 2 runs): instructions
37.83 → 37.59 M (−0.7%), cycles 13.95 → 13.58 M (−2.7%), image the same.
Symptom. opt5 (FCMPBL=1, fcompare → branchless fcompare_bl) noticeably
changed the race frame (Y 57.3 → 59.5, the same in two runs). The two functions
produce the same bits for every pair of doubles (exhaustive test), and the
generated C differs only in the function name.
Narrowing down (no guessing, every step measured):
- bisecting shards (mixing opt4/opt5 objects and checking frame 8900 brightness)
→
shard_0058; - bisecting functions (
fcompare_blonly in selected functions) →sub_005f0a40(a quadtree search: compares the point against box midpoints and descends into a child); - a function-level differential harness: runs the two builds side by side with
random trees/points/boxes (±0, NaN, equality frequent) and compares the final
state. opt5
-O3diverges from its own-O0(7854 in 100 thousand), opt4 doesn't; the difference is the sign of the midpoint on the stack (−0/+0); -mllvm -opt-bisect-limit→ the breaking pass is GVNPass (pass 262 of 386);- IR: an
i32is written to the same guest stack address and afloatis read, with different TBAA tags.
Root cause. Lifted code accesses guest memory at the same address with
different types (writing MR32 and reading MRF32, MR16/MR32 overlap). In C
this is a strict aliasing violation; the compiler may assume that accesses of
different types don't overlap and move the load ahead of the store. The build
flags never had -fno-strict-aliasing — including the baseline gen/all and
the runtime. With the flag opt5 = opt4 = baseline (image), difference 0.
fcompare_bl is correct; the UB only became visible when the shape of the code
changed.
Verification and cost (against the Windows reference, frames 1300–8900, port
NFSRES=1280x960 VCLOCK=fixed): with the flag, baseline/opt4/opt5 give the same
image; no visible effect of this UB was measured in the baseline build. Raw cost
(mean of 2 runs):
| instr/frame | cycles/frame | |
|---|---|---|
| baseline | 42.61 M | 14.34 M |
baseline + -fno-strict-aliasing |
45.24 M (+6.2%) | 15.14 M (+5.6%) |
| opt4 | 38.54 M | 12.81 M |
opt4 + -fno-strict-aliasing |
39.61 M (+2.8%) | 12.95 M (+1.1%) |
Source of the cost (disassembly): without type information the value of the
global g_mem is re-read after every guest write (sub_005f0a40: 2 → 10
ldr x, [&g_mem]).
Observation. With or without the flag, CPU fields like c->eax are re-read
after every write to guest memory: even though c is restrict, g_mem is a
pointer read from memory, and since c is passed to calls it has "escaped"; LLVM
can't prove that a guest write doesn't touch *c.
Change (tools/genall.py, MEMARG=1, a post-generation transform): every
function becomes void sub_X_m(CPU *restrict c, uint8_t *restrict _mb); the shard
has #define g_mem _mb; lifted code calls each other with sub_Y_m(c, _mb). The
externally visible void sub_X(CPU*) stays as a thin wrapper (fntab, indirect
calls, runtime → lifted calls and overrides don't change; the _m bridge for
overrides and missing functions is in stubs.c). Two separate noalias
parameters never alias in LLVM, with certainty — no escape analysis needed. On
ARM64 _mb arrives in x1.
In a single function (sub_005f0a40, -fno-strict-aliasing): CPU field reads
25 → 4, guest reads 18 → 8; an int write followed by a float read is now forwarded
directly (str w12 → fmov s0, w12).
Result (opt4 + -fno-strict-aliasing ↔ opt6, alternating; instructions 2+2,
cycles excluding the noisy p4 run (IPC 2.72)): instructions 39.33 → 35.50 M
(−9.7%), cycles 12.94 → 11.51 M (−11%), binary 38.1 → 34.8 MB. Image: all 16
frames under Windows conditions identical to opt4+flag. MEMARG doesn't just win
back the cost of the correctness fix, it goes beyond it.
| version | instr/frame | cycles/frame | binary |
|---|---|---|---|
baseline (gen/all, strict aliasing UB present) |
42.61 M | 14.34 M | 46.1 MB |
opt6 (FLAGABI + X87OPT + RETEIP + MEMARG, -fno-strict-aliasing) |
35.50 M (−16.7%) | 11.51 M (−19.7%) | 34.8 MB (−25%) |
opt6 was first played as a separate build with the current runtime. Once the user
approved it, it became the main version (v2): gen/opt6 → gen/all, v1 was moved
under arsiv/v1/.
tools/gen_v2.sh <SPEED2.EXE> # gen/all (131 files, 0 differences from gen/opt6)
./build.sh && ./link.sh # everything with -fno-strict-aliasing
./pack.sh --embed [--hd] # out/personal/NFSU2[ HD].app, version "2.0"- The
tools/gen_v2.shrecipe reproducedgen/opt6exactly; the 130 objects compiled withbuild.share byte-for-byte identical to the opt6 objects.impargs.cis the same as in v1. - Tests on v2: 439/439 ISA, 15/15 function verification.
Verification, image (Windows NFSRef conditions, 16 frames): difference from the Windows reference in the menu 0.18–0.55. This is at the same level as the v1 build's 0.18–0.70, and the difference comes from the animation phase. Sharpness and edge profile identical. HD v2 vs HD v1: 0.07–0.64. Command buffer errors 0, unresolved indirect calls 0.
Verification, CPU (same runtime, PACE60 CPUWIN=6200,8800, 3024x1964,
alternating 3+3 runs):
| CPU ms/frame | instr/frame | cycles/frame | |
|---|---|---|---|
| v1 | 5.71 | 41.99 M | 15.49 M |
| v2 (opt6) | 4.44 (−22.3%) | 34.17 M (−18.6%) | 11.19 M (−27.7%) |
Since the game is at its 120 fps limit, the gain doesn't show in fps. Where it
shows is the time the game thread spends asleep, and watts (see
tools/session.sh, powermetrics).
- Call model:
c->espand the general registers are written to memory and read back at every call (~20% of memory accesses in hot code). - Fusing
fcom → fnstsw → test → jcc: reducing it to a single C comparison. - Extending the x87 region across blocks (merge points with consistent depth).