mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 09:51:02 +00:00
Every lifted instruction read and wrote c->x[n] and c->sp, so each register passed from one guest instruction to the next went through a store and a load, and clang could not keep it in a host register across a guest store. recomp/regs.py now copies the six most used X registers and SP of a function into locals at its entry. A forward pass over the function's flow tracks which locals changed since the last sync, and only those are stored back before a call, an SVC, a trap, a return or a tail exit. After an SVC every local reloads; after a call only X0-X18 and X30, since AAPCS64 has the callee preserve X19-X29 and SP. TotK does not break that: main and subsdk0 import neither longjmp nor the unwinder, sdk's have no direct callers, and the runtime writes no register above X3 except X30. gen.py --no-callee-saved-trust reloads them too, --reg-locals sets the count. Functions with an unresolved computed br keep their registers in the Cpu, as they keep NZCV. All registers in locals was slower than none: clang spills them to the stack and the syncs stay. Over 300 hot functions, 4-6 locals gave the fewest instructions and memory operands. 1.4.3 .text 299 -> 266 MiB. Fight bench (bench-fight2-2026-10-27b, frames 5400-7500, uncapped): 11.30 and 11.92 ms vs 13.5-13.9 on main; frames 2400-5300 11.24 and 11.36 vs 13.25. Without the callee-saved trust 12.58 ms. tests/diff/func.py: 0 failing on 1.4.3 (889 functions, 583 with calls) and 1.2.1 (810); a plan with no spills fails at once. Probes kernel, vkexec, nvn-tex and shader pass in both versions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
5.7 KiB
Python
108 lines
5.7 KiB
Python
"""Function-local general registers for recomp/gen.py.
|
|
|
|
The lifter reads and writes the guest's X0-X30 and SP in the Cpu (c->x[n], c->sp), so every guest instruction loads
|
|
and stores memory, and the C compiler cannot keep a register in a host register across a guest store: guest stores go
|
|
through pointers that may alias the Cpu. A generated function instead copies the LOCALS registers it uses most into
|
|
locals at its entry (xN, sp_), works on those, and syncs with the Cpu only where something else reads or writes them:
|
|
before a call, an SVC, a trap, a return or a tail exit it stores the registers it changed since the last sync (spill),
|
|
and after an SVC it loads them all again (reload). The other registers stay in the Cpu.
|
|
|
|
After a call it reloads only X0-X18 and X30: AAPCS64 has the callee preserve X19-X29 and SP, so their locals still
|
|
hold what the Cpu holds. What would break that does not occur in TotK: main and subsdk0 import neither longjmp nor
|
|
the C++ unwinder, sdk's longjmp, __cxa_throw and _Unwind_* have no direct callers, and the runtime writes no register
|
|
above X3 of a running thread's Cpu except X30, which rt_call restores (runtime/hostfn.c). Guest fibers each run on a host fiber of their own with their own Cpu (runtime/kernel.c),
|
|
and ExitThread leaves by longjmp without returning. gen.py --no-callee-saved-trust reloads those registers too.
|
|
|
|
Only plain assignments write registers in the lifted C (`c->x[n] = ...`, `c->sp = ...`), and no code takes a
|
|
register's address, so the rewrite is textual. A register counts as dirty from a write until the next spill on some
|
|
path: plan() runs that forward over the function's control flow, so a call spills only what changed since the
|
|
previous call or the entry. Spilling a register that is not dirty is harmless (the local equals the Cpu), so where a
|
|
case is unclear the plan errs on spilling more.
|
|
"""
|
|
import re
|
|
from collections import Counter
|
|
|
|
REG = re.compile(r'c->x\[(\d+)\]|c->sp\b')
|
|
WRITE = re.compile(r'c->(?:x\[(\d+)\]|(sp))\s*=(?!=)')
|
|
# Lifted instructions that hand the Cpu to the runtime: SVCs return, BRK and undefined words do not.
|
|
ESCAPE = re.compile(r'\brt_(?:svc|brk|udf)\(c\b')
|
|
SP = 31 # index of SP in the register sets
|
|
# Markers gen.py puts where the Cpu must be current, resolved by Plan.resolve(): spill before; reload all, or reload
|
|
# what a call may change, after.
|
|
SPILL, RELOAD, RELOAD_CALL = '\x00S', '\x00R', '\x00C'
|
|
CALLEE_SAVED = frozenset(range(19, 30)) | {SP}
|
|
# Locals per function: the most used registers. More cost more than they save, as clang then spills them to the stack
|
|
# and the syncs stay (300 hot TotK functions: fewest instructions and memory operands at 4-6).
|
|
LOCALS = 6
|
|
|
|
|
|
def name(r):
|
|
return 'sp_' if r == SP else f'x{r}'
|
|
|
|
|
|
def cpu(r):
|
|
return 'c->sp' if r == SP else f'c->x[{r}]'
|
|
|
|
|
|
def refs(text):
|
|
"""Registers text reads or writes, once per occurrence."""
|
|
return [SP if m.group(0) == 'c->sp' else int(m.group(1)) for m in REG.finditer(text)]
|
|
|
|
|
|
def written(text):
|
|
return {SP if m.group(2) else int(m.group(1)) for m in WRITE.finditer(text)}
|
|
|
|
|
|
class Plan:
|
|
def __init__(self, regs, spill, kept):
|
|
self.regs = regs # sorted registers the function keeps in locals
|
|
self.local = set(regs)
|
|
self.spill = spill # per instruction: registers to store at its sync points
|
|
self.kept = kept # registers a call leaves as they were (CALLEE_SAVED, or none)
|
|
|
|
def decl(self):
|
|
"""Locals at the top of the function, loaded from the Cpu."""
|
|
return 'uint64_t ' + ', '.join(f'{name(r)} = {cpu(r)}' for r in self.regs) + ';' if self.regs else ''
|
|
|
|
def resolve(self, i, text):
|
|
"""text of instruction i with its registers on the locals and its sync markers expanded."""
|
|
def local(m):
|
|
r = SP if m.group(0) == 'c->sp' else int(m.group(1))
|
|
return name(r) if r in self.local else m.group(0)
|
|
|
|
text = REG.sub(local, text)
|
|
spill = ''.join(f'{cpu(r)} = {name(r)}; ' for r in sorted(self.spill[i]))
|
|
reload = ''.join(f' {name(r)} = {cpu(r)};' for r in self.regs)
|
|
reload_call = ''.join(f' {name(r)} = {cpu(r)};' for r in self.regs if r not in self.kept)
|
|
return text.replace(SPILL, spill).replace(RELOAD, reload).replace(RELOAD_CALL, reload_call)
|
|
|
|
|
|
def plan(texts, succ, clean, n_locals=LOCALS, kept=CALLEE_SAVED):
|
|
"""texts[i]: instruction i's C with markers; succ[i]: successor indices inside the function; clean: instructions
|
|
after which every local equals the Cpu (a call or an SVC that spills and reloads on every path through it);
|
|
n_locals: how many registers to keep in locals; kept: registers a call does not reload."""
|
|
n = len(texts)
|
|
uses = Counter(r for t in texts for r in refs(t))
|
|
regs = {r for r, _ in sorted(uses.items(), key=lambda kv: (-kv[1], kv[0]))[:n_locals]}
|
|
writes = [written(t) & regs for t in texts]
|
|
dirty = [set() for _ in range(n)]
|
|
seen = [False] * n
|
|
work = [0] if n else []
|
|
if n:
|
|
seen[0] = True
|
|
while work:
|
|
i = work.pop()
|
|
out = set() if i in clean else dirty[i] | writes[i]
|
|
for s in succ[i]:
|
|
if not seen[s] or not out <= dirty[s]:
|
|
seen[s] = True
|
|
dirty[s] |= out
|
|
work.append(s)
|
|
every = set().union(*writes) if writes else set()
|
|
for i in range(n):
|
|
if not seen[i]: # not reached from the entry in the plan's flow: anything may be dirty
|
|
dirty[i] = every
|
|
# A sync point stores what was dirty on entry to its instruction and what the instruction wrote before it
|
|
# (c->x[30] of a call): all the instruction's writes, which is at most a little more.
|
|
return Plan(sorted(regs), [dirty[i] | writes[i] if SPILL in texts[i] else set() for i in range(n)], kept)
|