Stock iOS apps cannot JIT, so on a real iPhone QEMU has to use UTM's TCTI backend. TCTI turns each TCG op into the address of a precompiled "gadget" and threads through them. This document covers the ahead-of-time cache that replaces the hottest TBs' gadget streams with native functions. Those functions are generated offline from QEMU's own optimized TCG ops, compiled by clang, and signed as part of the app.
Everything here was measured on macOS (Apple Silicon, same ISA as the
iPhone) with the AOSP API 33 r02 arm64 image, -M virt -smp 4 -m 2048,
-accel tcg,thread=multi, -cpu cortex-a76, using a fresh userdata image for
every run (tools/run-macos.sh --fresh --tcg).
Times in seconds: host wall clock since launch, with the guest kernel
timestamp from serial.log in parentheses. The board does not have goldfish
pipe yet, so boot_completed cannot be reached; surfaceflinger is the last
milestone that every run reaches.
Each cell is run 1 / run 2.
| milestone | TCG JIT | TCTI | TCTI+AOT v1 | + native goto_ptr | + inline LDP/STP (final) |
|---|---|---|---|---|---|
kernel Run /init |
1.80 | 7.18 / 6.66 | 3.60 / 4.38 | 2.83 / 3.07 | 2.81 / 2.82 |
| init second stage | 6.68 | 16.94 / 16.16 | 9.50 / 10.53 | 8.48 / 8.46 | 7.68 / 7.94 |
start zygote |
69.6 | 365.8 / 380.3 | 171.7 / 179.7 | 131.8 / 141.9 | 109.5 / 110.8 |
start surfaceflinger |
71.1 | 383.9 / 398.4 | 179.6 / 203.5 | 139.3 / 152.6 | 115.7 / 117.4 |
| speedup vs TCTI (zygote, mean) | 5.4x | 1.0x | 2.1x | 2.7x | 3.4x |
Columns:
- TCG JIT: guest kernel time 0.95 / 6.03 / 68.9 / 70.3 s for the four milestones.
- TCTI: run 1 is the unmodified worktree; run 2 is the AOT-capable binary with no table loaded. Its numbers match run 1, so the hooks cost nothing.
- TCTI+AOT v1: per-TB native functions and goto_tb native chaining.
References: HVF reaches zygote at about 10 s guest time, and the official
emulator (HVF) reaches boot_completed in 26 s. TCTI is 5.4x slower than the
TCG JIT; final TCTI+AOT is 1.6x slower than the JIT.
Run data and serial logs are in .logs/aot-results/. The final rows are
tcti-aot-i128{,-2}.json.
Where time goes. With macOS sample, 15 s around zygote preload,
"busy" means vCPU threads not idle in WFI. These are per-function shares of
busy vCPU samples:
| configuration | i128 ld/st helpers | next-TB lookup (helper_lookup_tb_ptr, cpu_get_tb_cpu_state, qht) |
native code |
|---|---|---|---|
| AOT v1 | ~30 % | ||
| goto_ptr build | ~20 % | ~17 % (the rest is softmmu slow path, GIC, locks) |
The i128 helpers handle every LDP/STP of X registers, so every prologue and epilogue. The goto_ptr and i128 columns of the results table are the two fixes that followed from this profile.
Recorded with tools/aot/tbprof.c, a TCG plugin with inline per-vCPU
counters. It runs on the JIT build because plugin-instrumented TBs do not
match AOT fingerprints. Two independent fresh boots were profiled: prof1
runs until surfaceflinger plus 120 s, prof2 stops at surfaceflinger.
Analysis is done by tools/aot/analyze-prof.py.
| prof1 | prof2 | |
|---|---|---|
| distinct TBs (content keys) | 608,290 | 433,380 |
| guest code bytes covered by TBs | 12.8 MB | 8.7 MB |
| TB executions | 1.53e10 | 1.04e10 |
| guest instructions | 7.55e10 | 5.06e10 |
| avg insns / TB exec | 4.93 | 4.88 |
| TBs for 50 / 90 / 99 % of executions | 1,406 / 19,325 / 69,250 | 1,351 / 17,028 / 56,764 |
| top 10k / 50k / 100k TBs cover | 81.1 / 97.8 / 99.6 % | |
| kernel share of execs (insns) | 28.6 % (31.4 %) | 17.4 % (20.3 %) |
| user, mmap area (libs, oat, JIT cache) | 68.8 % | 79.4 % |
| user, PIE executables | 2.5 % | 3.1 % |
Execution is heavily concentrated: 20k TBs, about 400 KB of guest code, cover 90 % of executed TBs.
The keys are also stable across boots (KASLR and ASLR do not matter because the key is content-based). Of prof2's executions, 100.0 % belong to TBs whose content key also appears in prof1. In the other direction, 98.7 % of prof1's executions are covered by prof2's keys. The missing 1.3 % comes almost entirely from the extra 120 s in prof1: 1.0 % user-mmap, 0.25 % executables, 0.09 % kernel. That 1.3 % is an upper bound on code that is generated at runtime or not reproducible (ART JIT and similar) within this window. During boot up to surfaceflinger, ART JIT code is negligible. It will grow once apps run, which is why the guest-side mitigation below matters.
accel/tcg/translate-all.c: aftertranslate_code, callsaot_tb_translated()with the guest bytes (host_pc,tb->size). TBs that span two pages or come from MMIO are not cacheable.tcg/tcg.c: aftertcg_optimizeand liveness,aot_tb_ops()walks the final op list. It does two things:- capture (
QEMU_AOT_CAPTURE): dump the ops in a self-describing text form. - lookup: find the content key in the loaded table, then recompute the op fingerprint and compare it.
- capture (
- TCTI backend (
tcg/aarch64-tcti): on a hit,tcg_out_tb_startemits a 2-word header[gadget_aot_enter, AotRec*]in front of the normal TCTI stream.tcg_out_exit_tbandtcg_out_goto_tbrecord the stream offset of every exit. The new gadgetaot_entercallsrec->call(env, rec->w, &ra)and continues at the stream address that the function returns. tb_gen_code:aot_tb_finalize()writes theAotRecafter the search data. It holds three things, all found by the native code in oneconst uintptr_t *R:- exit addresses
- per-insn unwind return addresses
- pooled constants
- Everything else in QEMU is unchanged:
- The TB still owns a complete TCTI stream, so the slow paths stay as they
are:
tb_add_jumppatches the same slots, invalidation works as before, andcpu_restore_stateunwinds through the same search data. - On a miss, TCTI runs as before.
- The TB still owns a complete TCTI stream, so the slow paths stay as they
are:
bkey = H(guest insn bytes of the TB, page offset of pc, byte size) // also used by tbprof
key = H(bkey, tb->flags, tb->cs_base, tb->cflags & ~CF_INVALID)
fp = H(canonical optimized op list)
Because CF_PCREL is set, the generated code depends only on these inputs.
The fingerprint guards against everything else: a different QEMU build,
different CPU features or a backend option. It is computed over:
- opcodes, op params and constant args
- temp kinds, types and indices
- global env offsets
MemOpIdxplus the TLB table offset of every guest access- helper name, typemask and flags
- label ids,
goto_tb/exit_tbindices insn_startdata
Constants that may be host pointers (2^32 ≤ v < 2^48) are excluded from the
fingerprint and passed through R[]. If the fingerprint, exit count or icount
differs, the native function is not used: the counter is fp_mismatch, and it
was 0 in every run.
The table also checks the ABI and layout when it loads:
- magic number and ABI version
sizeof(CPUArchState), the TLB entry layout and page bits- the helper binding: offsets of the 8 slow-path
helper_*_mmurelative toaot_anchor
If any check fails, the table is rejected as a whole. That happened (by
design) every time I rebuilt QEMU. tcg2c.py --qemu BIN rebinds the cache by
running nm; only aot_table.c changes and relinking takes 2.6 s.
QEMU's page-level SMC tracking is untouched. A guest store to a page that
holds TBs invalidates them, exactly as before. The native code runs only as
part of an existing, valid TB. A retranslation recomputes the key from the
new bytes, so a modified TB simply misses, or hits another entry whose bytes
match. The native store fast path compares against addr_write including the
TLB_NOTDIRTY/MMIO/watchpoint flag bits, the same way as the JIT backend.
Stores to code pages therefore always go through helper_st*_mmu, which
performs the invalidation.
Each TB becomes one C function,
uintptr_t f(void *env, const uintptr_t *R, uintptr_t *ra):
-
TCG globals become loads and stores at fixed env offsets, so they are always coherent across helper calls.
-
EBB and TB temps become C locals (scalar or 128-bit vector types). Labels become
goto. -
All integer ops are covered, including add2/mulu2/extract/deposit and the bswap flags. Vector ops cover what the TCTI backend accepts (ld/st/dup/arith/logic/shifts/cmp/bitsel/sat/minmax/
aa64_sshl/aa64_sli). Div/rem use AArch64 sdiv/udiv semantics, matching TCTI. -
qemu_ld/stuse an inline softmmu TLB fast path (same comparison as the aarch64 JIT backend, alignment included). On a miss they call the samehelper_ld*/st*_mmuwith the per-insn return address, so faults unwind to the right guest pc through the TB's search data. -
callcalls the helper through its real C prototype, rebuilt from the typemask, with i128 passed as__uint128_t. Before every call it sets*ra = R[ra_index], which is what TCTI'sGETPC()reads. -
exit_tbreturns the stream address of the TB's own exit gadget, so TCTI produces the exact sametb|idxvalue. -
exit_tbfromlookup_and_goto_ptr: TCTI runs every TB withCF_NO_GOTO_PTR, so every indirect branch and return used to go back tocpu_exec.tcg_gen_lookup_and_goto_ptrnow marks thatexit_tb(TCGOP_FLAGS = 1, ignored by the TCTI backend, part of the fingerprint). At such sites the native code callsaot_rt_goto_ptr, which does the same as JITgoto_ptr:- It calls
helper_lookup_tb_ptr. - If the next TB is native, it tail-calls it.
- Otherwise it continues in that TB's TCTI stream.
- On a miss it takes the old exit.
The marker is not applied to single-step, one-insn or
CF_NO_GOTO_TBTBs. Effect: zygote 176 → 137 s. - It calls
-
helper_ld_i128/helper_st_i128(LDP/STP of X registers,MO_ATOM_IFALIGN_PAIR) are inlined. The fast path does a 16-byte TLB check (page-crossing checked) and then two 8-byte host accesses, which are single-copy atomic when aligned; that is allIFALIGN_PAIRrequires. The slow path calls the same helper. Effect: 137 → 110 s. -
goto_tbreads the TB's own jump slot. If that slot is chained to a TB that also has native code (header ==gadget_aot_enter), the function tail-calls it withmusttail. Hot loops therefore stay native without returning to the dispatcher, and every TB still runs its own icount/exit check first. Otherwise it returns the slot's stream address and TCTI follows it. -
Build flags:
-O2 -fno-strict-aliasing -fwrapv(TCG freely aliases env with different widths).
The final cache, from one TCTI capture boot:
- 98,534 TBs (the top 100k bkeys of prof1 that were translated during the capture boot), all supported (0 skipped)
- 70 helpers
libaot.dylib47.6 MB: 38.1 MB__text(about 390 B per TB) plus a 4.7 MB table- compiled in about 30 s wall on 6 cores per variant
Measured against prof1 and prof2, those bkeys cover 96.7 % and 97.3 % of all TB executions (99.3 % / 99.6 % of kernel executions).
QEMU_AOT_LIB=libaot-verify.dylib QEMU_AOT_VERIFY=N checks each AOT TB on its
first 4 executions and on every Nth one after that. A check runs these steps:
- Snapshot env.
- Run the journaled native variant (
-DAOT_VERIFY: every fast-path store records the old bytes). - Roll back memory and env.
- Run the original TCTI stream of the same TB with its
goto_tbslots temporarily unchained. - Compare the exit value, the whole
CPUArchState(78 KB) and the stored bytes. The TCTI result is the one that is kept.
Some executions are not compared because they cannot be replayed, and are
counted as v_unsafe:
- helpers with external side effects
- slow-path or MMIO accesses
- TBs that store while
CF_PARALLELis set
Fields that other threads write asynchronously are never written back and
are not compared: irq_line_state and the generic-timer ctl/cval.
Two things look like failures but are races between vCPUs, and are classified separately:
v_flaky: a store-free mismatch where a re-run of the native code from the same snapshot agrees with TCTI (spin loops over lock words).v_chainrace: another vCPU re-chained the slot during the reference run.
Results are in "Correctness" below.
Debugging aids:
QEMU_AOT_KEYRANGE=lo-hiandQEMU_AOT_PC=kernel|userrestrict the table.tools/aot/bisect.pybisects a broken boot down to one TB. This found the only miscompile so far (i128 helper argument halves swapped, now fixed).
- Profile on macOS with the JIT build and
tbprofover several scripted sessions (boot, launcher, common apps) and merge the TSVs. This tells us which bkeys are hot. - Capture with the exact release QEMU configuration. The op list depends on
the backend, so for iOS this means the TCTI build: run
QEMU_AOT_CAPTURE=ops.txt QEMU_AOT_HOT=hot.txton the macOS TCTI build of the same source. - Run
tcg2c.py ops.txt --qemu <binary> --out gen/, compile with clang forarm64-apple-iosand link the objects statically into the app framework.- Production builds should use
-DAOT_STATIC(direct helper calls, noaot_Hbinding table) and drop the verify variant. - The table is checked against the same layout signature when the app starts.
- Production builds should use
- Size budget: about 390 B of native code plus 48 B of table per TB
(unwind info comes on top). A 100k-TB cache is about 48 MB on disk; 200k
TBs (99.96 % of profiled executions) would be about 95 MB. 20k TBs cover
90 % for about 10 MB.
- iOS has no limit on signed code size beyond the app's install size.
- The hottest-first order lets us cut at whatever size we choose.
- The cache is tied to the QEMU build (fingerprints, layout), so it has to be regenerated with every engine change. This is fully automated and takes about 7 min of TCTI capture plus 1 min of compilation.
- Guest-side mitigation: make ART compile everything AOT
(
pm compile -m speed -a, ordalvik.vm.dex2oat-filter=speed, anddalvik.vm.usejit=falsewhere acceptable, at first boot or baked into a prepared userdata/snapshot). JIT-generated code can never be in an offline cache. With full AOT, the oat files have deterministic content per image and their TBs become cacheable like any other user code.
Guest and host are both AArch64, so many helper calls of a hot TB have an
architectural result that is one host instruction. tcg2c.py (default on,
--no-passthrough for A/B, engine/scripts/ios/build_aot.sh --no-passthrough or AOT_PASSTHROUGH=0) emits those calls as
aot_pt_<helper>() from aot_rt.h; the table is PASSTHROUGH in tcg2c.py
(158 helpers). The exactness rules are in the comment block of aot_rt.h;
in short:
- Integer, crypto and CRC (
muluh/mulsh_i64,udiv64/sdiv64,rbit*,neon_narrow/widen_u*,neon_clz_u16, AES/SHA-256,crc32c_64, integer pairwise andcnt/rbitbytes) always produce the helper's result.crc32_64(zlib) falls back when the accumulator has bits above 31. - FP (
vfp_*arithmetic, fma, sqrt, min/max, compares, conversions,rint*, the AdvSIMDgvec_f*/gvec_vcvt_*forms; never half precision) runs inline only when it can prove a bit-identical result andfloat_status: host FPCR == 0 (read on every call),float_statusbytes 3..17 equal to FPCR.FZ = FIZ = DN = AH = 0, RNE where the op rounds, no NaN or denormal input, and for rounding arithmetic Inexact already set and a result that cannot be tiny or infinite (QEMU's own hardfloat rule). Conversions compute Inexact exactly. Everything else calls the helper with the same arguments, so NaN propagation, default NaN, FZ/FIZ/AH and all other flags still come from softfloat.QEMU_AOT_PT_FP=0turns the FP part off at run time. - gvec helpers: the descriptor must be a TB constant with maxsz 16 (so SVE
vector lengths keep the helper); the tail is cleared like
clear_tail()and every input is loaded before vd is written (aliasing). --pt-fpsralso inlines rounding FP ops while Inexact is clear: one asm block clears the host FPSR, runs the instruction and reads FPSR back; only IXC is accepted. Off by default.- The build adds
-mcrc(CRC32 is not in the arm64 iOS baseline; the app's iOS 17 deployment target implies A12 or later, which has it) and-ffp-contract=off.--no-passthroughoutput is byte-identical to the generator before the passthrough.
Tests (all under tools/aot/tests):
pt-unit/run.sh: linkspt_unit.cagainst the real helper objects ofengine/build-macos-aot-tctiand compares helper and passthrough on random and edge-case inputs under default/AH behaviours, all rounding modes, FZ, FIZ, DN, random flags, host FPCR != 0 and aliased gvec registers (all 18 bytes offloat_status, every vector byte; a declining passthrough must not change anything). Default and-DAOT_PT_FPSRbuilds.pt_unit benchgives rough per-call times.pt-proto-check.py(run first) compiles every table entry against its DEF_HELPER prototype.fp-passthrough/run.sh [--cpu max]: bare-metal guest program, every passed-through instruction over special values and FPCR settings (also FEAT_AFP AH/FIZ/NEP with--cpu max); JIT, TCTI and TCTI+AOT (passthrough, production,--pt-fpsr,--no-passthrough, verify variant withQEMU_AOT_VERIFY=1) must print identical output.pt-bench/run.sh: hot guest loops timed with the guest counter.
Results (2026-11-04, M2 Max, shared host): pt-unit 158 helpers x 2M random
cases (two seeds; -DAOT_PT_FPSR build 1M), 0 failures (ten deliberately injected bugs in
aot_rt.h, nine are caught, the tenth is the conservative crc32_64
accumulator check). fp-passthrough on cortex-a76 and max: all outputs
identical to the JIT, verify variant 51.0 M / 40.9 M executions compared,
0 mismatches. pt-bench (ns per loop iteration, best of 3, -smp 1):
| loop | JIT | TCTI | AOT no passthrough | AOT passthrough | speedup |
|---|---|---|---|---|---|
| FP scalar (fmadd, fmul, fadd, fdiv) | 22.4 | 70.4 | 19.0 | 16.5 | 1.15x |
| FP vector (fmla, fmul, fadd 2d, fmla by element) | 82.1 | 139.1 | 83.2 | 22.0 | 3.8x |
| FP conversions (fcvtzs, scvtf, frintm, fcmp) | 57.3 | 159.8 | 43.1 | 14.7 | 2.9x |
| AES/SHA-256 | 27.7 | 57.3 | 27.7 | 6.6 | 4.2x |
| CRC32/udiv/umulh/rbit | 41.4 | 63.8 | 43.3 | 5.5 | 7.9x |
--pt-fpsr is within 2 % of the default build on these loops (they keep
Inexact set). In the ranchu capture/profile (prof-r1) the passed-through
helpers are 2.4e9 of the 4.6e9 dynamic helper calls that were still calls
(ld/st_i128 are already inline), mostly AES/SHA-256, muluh_i64,
crc32c_64 and udiv64; FP is 0.2e9.
UTM's TCTI tree disabled the 128-bit cmpxchg helper ("FIXME: this doesn't
work in TCTI" in host/include/generic/host/atomic128-cas.h.inc and
accel/tcg/cputlb.c), so HAVE_CMPXCHG128 was 0. Then every guest CASP and
every LDXP/STXP pair in a CF_PARALLEL TB is translated to
helper_exit_atomic: the vCPU leaves the TB, cpu_exec_step_atomic stops
all vCPUs (start_exclusive) and runs that one instruction alone. The kernel
uses CASP for SLUB's cmpxchg_double (every kmalloc/kfree fast path), and
bionic/ART use 128-bit atomics in user space.
Measured with macOS sample on the simulator TCTI+AOT slice (prebaked
userdata, kpti=0), share of the vCPU threads' busy time spent in
cpu_exec_step_atomic plus waiting in cpu_exec_start/cpu_exec_end for
another vCPU's exclusive section:
| phase | busy | stop-the-world share of busy |
|---|---|---|
| idle home screen | 13 % | 11 % |
| Settings scrolling | 42 % | 27 % |
| steady UI workload | 37 % | 44 % |
| cold app start | 80 % | 51 % |
Why it was disabled: helper_atomic_cmpxchgo_le(env, addr, cmpv, newv, oi)
needs seven argument slots (two i128), and tcg_target_call_iarg_regs had
only R0-R5. The seventh went to TCTI's temp buffer (R15 as call stack), which
the C helper, called by the call gadget with the host's x0-x7, never sees.
The fix (engine/qemu-ios, engine/qemu-aot and engine/qemu, uncommitted; kept
for release: the device slice ios-arm64 a003aa34bab9 and its va_aot
ca8200466819 in engine/build-ios/xcframeworks carry it):
tcg/aarch64-tcti/tcg-target.c.inc: R0-R7 as argument registers. TCG registers R0-R13 are host x0-x13 and thecallgadget branches with them untouched, so this is AAPCS64. Darwin passes an i128 in any two consecutive registers, which is whatTCG_CALL_ARG_NORMAL(already set) produces.- The two
!defined(CONFIG_TCG_THREADED_INTERPRETER)exclusions removed, soHAVE_CMPXCHG128is 1 and theDATA_SIZE 16atomic helpers are built.
Only the TBs that contain such an instruction change their op list (the fingerprint differs, so an old table simply misses them): 45 bkeys of the 250k-TB capture, 0.17 % of profiled TB executions.
tools/aot/tests/atomic128/run.sh races 4 threads on one 16-byte pair (CASP,
LDAXP/STLXP, CASP readers against one writer, plus 64-bit CAS/LDXR for
reference) under -smp 4 MTTCG, as /init of an initramfs on the image's
kernel, and checks count, invariant and atomicity. All pass on JIT, old TCTI,
new TCTI and new TCTI with an AOT cache of the test. ns per successful update,
macOS M2 Max:
| test | JIT | TCTI before | TCTI after | TCTI after + AOT |
|---|---|---|---|---|
| CASP, 4 writers | 28 | 10,688 | 195 | 191 |
| LDAXP/STLXP, 4 writers | 17 | 5,195 | 141 | 165 |
| CASP, 1 writer + 3 CASP readers | 16 | 48,631 | 35 | 175 |
| 64-bit CAS (unchanged) | 76 | 71 | 27 | 50 |
The TCTI differential fuzzer (tcg/aarch64-tcti/tests/run-fuzz.sh, which
emits CASP among its atomics) passes 40/40 seeds against the JIT, and
run-tests.sh passes. The verify variant on this test reports mismatches
only in kernel TBs without any 128-bit cmpxchg (spinning loads of lock words,
the known class in "Correctness").
Cache: the 45 bkeys were recaptured (top-up capture with QEMU_AOT_HOT = those
bkeys during a boot of the fixed simulator slice, 53 TBs) and merged in front of
cap-r2-final with analyze-prof.py --merge-out ... --merge-max 250000:
.logs/aot-ios/cap-r2-a128-final.txt, 250,000 TBs, 0 exit_atomic calls,
53 helper_atomic_cmpxchgo_le calls, coverage of prof-r2 unchanged (99.35 %).
With the r2 cache the fixed binary reports 93 fingerprint mismatches (exactly
those TBs, which then run as gadgets); with the merged cache 0.
Simulator A/B (2026-11-05, iPhone 17 Pro simulator on M2 Max, TCTI + AOT,
prebaked userdata, kpti=0, no va-bluetooth; with the fix: the real
engine/build-ios slice and its cap-r2-a128-final cache; without: an isolated
build of the same engine/qemu-ios tree with the fix reverted and a cap-r2-final
cache bound to it; workload.py --steady-only --steady-rounds 1 --scale 3 --qmp, i.e. touches from the host; five app cold starts with am start -W
2 min after Launcher3 is focused; production AOT settings, i.e. only
QEMU_AOT_LIB). Host load average about 17 (Spotlight indexing), the same for
all three runs. Results in .logs/a128/ab3/.
| without fix | with fix (2 runs) | |
|---|---|---|
start zygote |
25.8 s | 23.8 / 21.7 s |
sys.boot_completed |
208.4 s | 73.1 / 70.7 s |
| Launcher3 focused | 307.5 s | 90.0 / 88.4 s |
| cold start DeskClock / Calendar / Dialer / Settings | 8.4 / 4.3 / 9.3 / 7.8 s | 2.5 / 1.6 / 1.9 / 2.4 s (both runs within 0.2 s) |
| steady UI: frames drawn / janky | 3123 / 37.1 % | 4056 / 20.1 %, 4194 / 17.1 % |
| steady workload wall time | 480.6 s | 449.8 / 449.0 s |
| tb_flush during the session | 2 | 1 |
Per step (p50 / p90 frame time, ms, without -> with): Settings app list 28 / 250 -> 21-26 / 36-38, recents 44 / 150 -> 26 / 44-48, notification shade 44 / 69 -> 34-36 / 48-57, app drawer 28 / 109 -> 20-22 / 34-38, Settings search 44 / 150 -> 19-20 / 34. Plain Settings scrolling was already smooth without the fix (6-9 % janky either way). AOT: 0 fingerprint mismatches in all runs.
An earlier set of runs (.logs/a128/sim-new, sim-old) had
QEMU_AOT_STATS set on both sides. That switches off native chaining between
AOT TBs and the native goto_ptr (tcg/aot.c, "stats/verify mode") and adds
timing, so its absolute numbers are about 1.5x slower (boot_completed 109-114 s
with the fix, 274-278 s without); the comparison between the two builds
pointed the same way. Its sample profiles gave the stop-the-world share of
active vCPU time: 51.6 % during an app cold start and 17.4 % in the steady
workload without the fix, 2.6-2.7 % and 0.8-0.9 % with it.
Open issue: rare early-boot hang. 2 of 137 simulator boots with the fix hung
at about 5 s guest time (one A/B boot, .logs/a128/sim-new/run-2/serial-hang.log,
and one of 30 boots to zygote, .logs/a128/hang/hz17.serial.log): soft lockup
of kworker/0:1 in virtio_console add_port -> __send_control_msg,
spinning on virtqueue_get_buf for the PORT_READY control buffer, right after
init loaded virtio_pci.ko and enabled the PCI devices behind the console; init
itself stops there too and zygote never starts. 0 of about 87 boots without the
fix hung (40 in an interleaved A/B, 20 with 26 extra virtio-serial ports, the
others from earlier sessions), but at this rate that is no evidence either way
(Fisher exact p = 0.37). The 135 boots after the first two hung none: 40
interleaved pairs, 20 stressed pairs (26 extra ports, about 10x the control
messages per boot), 40 plain fixed boots under host load 20, 3 BT validation
boots. Neither the guest code on that path nor QEMU's control_out uses 128-bit
atomics, and with ioeventfd=off the device handles the kick synchronously in
the kicking vCPU, so the plausible shapes are a lost kick or the device going
broken after a virtio_error (which would be in qemu.out; neither hang kept
it). .logs/a128/hang3.sh now keeps qemu.out, two x-query-virtio-queue-status
dumps of every virtio-serial queue and a QEMU sample when a boot hangs.
Measured on the iPhone 17 Pro Max with Xiaohongshu (com.xingin.xhs, 190 MB
APK, 104 MB of native libs, installed from CoolApk inside the guest; userdata
not prebaked, JIT on; guest simpleperf record -a and logcat over adb):
- The install-time dexopt never finished:
dumpsys package dexoptshowedstatus=run-from-apk. installd kills dex2oat after 570 s ("Child process N timed out after 570000ms";kLongTimeoutMs, not scaled byro.hw_timeout_multiplier, and dex2oat's own watchdog has the same 9.5 minutes). Even theverifyfilter of this APK takes 7.7 min (4 threads) on an idle guest, and the guest was not idle while the app was installed. - run-from-apk means every start inflates the dex files out of the APK into
anonymous memory, verifies every class at runtime (446-1775 "Verification
of ... took" warnings per start) and finds classes without type lookup
tables. On the app's main thread
DexFile::FindClassDefalone was 20 % of the samples, the verifier about 15 %, zlibinflate_fast6 %. Cold start (Displayed): 2m47s. Aftercmd package compile -m verify -f com.xingin.xhs(7.7 min): 46 s, no runtime verification, no lowmemorykiller kill during the start. A following-m speed-profile(with the JIT profile) hit the 570 s limit again. - Memory: 2 GB guest, no swap (the image's fstab has no zram entry, so
swapon_alldoes nothing; the official emulator is the same). The app runs four or five processes (about 1.2 GB RSS together, the main one 486 MB while run-from-apk); with the ~35 system apps that BOOT_COMPLETED starts, lowmemorykiller thrashed and killed the launcher, SystemUI's neighbours and finally the app itself in the foreground (oom_score_adj 0), which then started again from scratch (10 process starts in an hour). Page reclaim showed up in the kernel samples (page_referenced_one,try_to_unmap_one). - Once running, most CPU goes to the app's own work: a hot-update download
and 7-zip decompression (
libun7zip.so11 %), its logging thread (libxylog.so8-10 %), anti-tamper and tracking SDKs, plus JIT-compiled code (8 %). App native libraries and JIT code are never in the offline cache, so they run as TCTI gadgets; only the system parts (libart, boot.oat, libc, kernel) can hit it, and the r2 capture has no dex2oat at all (prebaked userdata boots without compiling).
Consequences: the prebaked initrd profile no longer turns the JIT off or
compiles installs with speed (an app installed later would interpret its
code, and a speed install of a big app cannot finish within 570 s); the
packs prebaked with the old profile must be prebaked again, because odrefresh
records every dalvik.vm.* property and recompiles the boot classpath when one
changes.
Fixes (engine guest setup on the console, hw/android/guest_time.c, -machine
options of ranchu):
zram-size=MB(default 1024): once the guest has booted, /dev/block/zram0 (lz4, set by init.ranchu.rc) becomes swap. With it, the same app session on macOS TCTI had one lowmemorykiller kill instead of dozens.dexopt-rescue=on(default): a background job checks every minute for third-party apps whose oat directory has nobase.vdex(installd gave up) while no dex2oat runs. It asks PackageManager for averifycompile, takes the command line and the environment of the dex2oat that installd starts for it (the console shell lacksBOOTCLASSPATH/DEX2OATBOOTCLASSPATH), kills that dex2oat, and runs the same compile itself with--no-watch-dog, nice 10, on its own fds and in installd's mount namespace (nsenter: the console shell lives in init's bootstrap namespace, which has no/apex/com.android.art), then movesbase.vdex/base.odexinto place as system:all_aNNN 0644 (the directory's SELinux label). PackageManager reportsstatus=verify [reason=cmdline]; the app starts without runtime verification (HVF: Xiaohongshu cold start 570 ms afterwards). Failures are logged with dex2oat's last stderr line; a package is given up after three. On the simulator TCTI slice the same app started in 29-31 s while run-from-apk and in 5.4 s with its verify vdex; the session used 240 MB of the zram swap and had no lowmemorykiller kill.- The prebaked pack was prebaked again with the JIT on (
app/build/ImagePackPrebaked). - App workload for the profile and capture:
tools/aot/appload.sh(install two big APKs, two cold starts each with swipes, onecompile -m verify -f). Its JIT profile (prof-r3) showed the r2 cache covering 85.1 % of its TB executions. A top-up capture of its 20k hottest missing TBs on the simulator TCTI slice (12,840 of them were translated there) merged with the r2 capture (cap-r3-final.txt, 262,840 TBs, ordered by prof-r2 + prof-r3,prof-r2r3.tsv) covers 94.45 % of the app workload and 99.38 % of the UI workload (r2: 85.1 % / 99.35 %).
-
Remaining overhead on hit TBs:
helper_lookup_tb_ptron every indirect branch (about 20 % of busy time)- softmmu slow paths
- helpers: as in the JIT, except the AES/SHA-256/CRC, integer and the FP calls that the same-ISA passthrough inlines (see above)
- cold TBs that still run as gadgets
The overall gain is 3.4x; the JIT is still 1.6x faster.
-
Only TBs seen during profiling are covered. Code that is new to a session (apps, JIT) runs at TCTI speed.
-
A table is valid for one QEMU binary layout. Different CPU models or machine options only cost hit rate (keys and fingerprints differ), never correctness.
-
The verifier cannot replay stores in
CF_PARALLELTBs. Those are covered only by the boot milestones and the guest behaving identically. A-smp 1verification run with an-smp 1capture would also compare them. -
Not measured yet: an iPhone, UI frame rate, and boot_completed. The last one needs the goldfish pipe board.
- Indirect branches are native already (goto_ptr). Next: a per-site inline
cache in
R[], keyed on pc plus a flags check, before callinghelper_lookup_tb_ptr, to cut the remaining ~20 %. Also return-address prediction for RET. - Superblocks: merge chains of hot TBs (profiled edges) into one C function
so clang can keep guest registers in host registers across TBs.
Correctness on an exit check or fault stays per-insn through
R[ra]. - Specialize hot helpers by inlining them: flag computations,
vfp_*, crypto. The same-ISA passthrough (above) does this for helpers whose result is one host instruction; the rest would mean compiling the generated code together with QEMU's helper sources (static link). - iOS integration: done as a separate
va_aot.frameworkloaded throughQEMU_AOT_LIB(see "iOS integration" below); TCTI+AOT reachesboot_completedin the simulator and on an iPhone. Left: a plain-TCTI device run toboot_completedfor the A/B, and a stricter binding check. - A larger profile corpus (launcher, Settings, a few apps) plus the full-AOT ART configuration, then pick the cache size by the coverage curve.
-
Boot behaviour, TCTI vs TCTI+AOT (
serial.log):- no kernel panic or oops in either
- the same set of services started, in the same order apart from known exec races
- the same exit statuses apart from
ranchu-setup, whose status also varies between runs of the official emulator (seereference/emulator_fullboot.log)
-
Verification run over the whole boot to surfaceflinger (
QEMU_AOT_VERIFY=4096,-smp 4). The final cache isstats-verify3.json, and it reaches surfaceflinger at 204 s wall. It shows 1,353,217 executions compared and 105 mismatches in 11 TBs, all loads of concurrently written words, the same classes as listed below.The table that follows is the earlier, pre-goto_ptr cache (
stats-verify.json):counter value TBs translated / AOT TBs installed 488,473 / 98,470 fingerprint or shape mismatches 0 / 0 AOT TB executions 5.68e9 executions checked 1,726,787 executions compared against TCTI 1,237,770 (902 of them faulting) not replayable (side effects, MMIO, parallel stores) 488,997 racy, resolved by re-run 1,258 chain races 20 remaining mismatches 135, in 14 kernel TBs All 14 remaining TBs read words that other vCPUs write concurrently:
- 12 are exclusive or acquire loads (memop
0xe24) of lock or atomic words, followed by a barrier; the two runs saw values one increment apart. - One reads a jiffies-like counter (0xffffafb9 vs 0xffffafba).
- One reads a bitmap word.
I inspected the generated C for the two most frequent ones: it is a plain
ldaxr/eor/cbnz. None of the mismatches is attributable to translation.Two miscompiles were found and fixed while bringing this up:
- i128 helper argument halves were swapped.
tools/aot/bisect.pyisolated the single TB, a kernelstpthroughhelper_st_i128. - Strict aliasing was not disabled (now
-fno-strict-aliasing).
Three verifier bugs were also fixed:
- Lost async updates of CNTV_CTL.ISTATUS caused an IRQ storm.
exit_tb(0)exits were misclassified as chain races.- A reference run could leave its TB through a slot re-chained by another vCPU and nest into a second verification.
- 12 are exclusive or acquire loads (memop
The runtime is merged into the iOS engine source (engine/qemu-ios, branch
ios at 231f0b92c: master 0e17cd59b + aot up to a1a9daff4, including the
TCTI fix 08b6a2cac, plus 55bb17924; merged into engine master as
16d34a09f). 55bb17924 keeps the translation-time counters always on and
prints
aot: translated N TBs, … table hits, … mismatches, … native at exit when a
table is loaded. The cache ships as its own framework, va_aot.framework,
next to the TCTI QEMU slice it is bound to; the app sets QEMU_AOT_LIB to it
under TCTI (-VAAOT NO turns it off for A/B runs).
Pipeline (each step reruns after an engine change):
- Profile on macOS with the JIT build and tbprof, ranchu, fresh userdata,
then a scripted UI session (
tools/aot/workload.py: Settings and three of its pages, app drawer, notification shade, nine AOSP apps):tools/run-macos.sh --fresh --tcg --machine ranchu … -- -plugin libtbprof.dylib,out=prof.tsvplusworkload.py --serial 127.0.0.1:PORT --quit-monitor RUN/monitor.sock.analyze-prof.py --hot-out hot.txt --hot-n 200000. - Capture with the simulator TCTI build (same source and configuration as
the device slice):
SIMCTL_CHILD_QEMU_AOT_CAPTURE=cap.txt SIMCTL_CHILD_QEMU_AOT_HOT=hot.txt engine/scripts/ios/run_sim.sh -b tcti --fresh. engine/scripts/ios/build_aot.sh -p ios_simulator|ios -c cap.txt --prof prof.tsv(tcg2c.py with the platform's clang, sysroot and target, helpers bound to that platform's QEMU binary with nm), thenmake_xcframeworks.sh, which addsva_aot.xcframeworkonly for TCTI slices whose binary matchesqemu-tcti/aot/bound-qemu.sha1.
Profile (prof-r1: boot to boot_completed plus the UI session, JIT, 2.09 M
distinct TBs, 3.6e10 executions): the top 10k / 50k / 100k / 200k TBs cover
74.2 / 93.1 / 97.2 / 99.1 % of executions. App use spreads execution much
more than the boot-only profile of the virt board did (top 10k: 81.1 %).
Bugs found while bringing it up on ranchu (all fixed):
- TCTI
cmp_vecLT/LE and LTU/LEU picked the wrong instruction (08b6a2cac onaot): NEONCMLT #0/CMLE #0were wrong on zero lanes, and zygote died in every preload (ICUMissingResourceException). The differential fuzzer intcg/aarch64-tcti/tests(a1a9daff4, TCTI vs JIT, random integer/memory/atomic/NEON streams) found nothing else. - tcg2c translated
shlv_vecas a generic left shift. TCTI'sshlvgadget isUSHL(signed per-lane count) and itsshrv/rotlv/rotrvexpansions use negative counts, so register right shifts came out as left shifts. Under TCTI+AOT zygote then hung right afterUsing memfd for future sealing. Nowaot_ushl{8,16,32,64}inaot_rt.h(checked against the hardwareUSHL/SSHLfor counts -70..70 on every lane size). - Not a CPU bug: under TCTI apexd timed out waiting for new
/dev/block/loopNnodes,com.android.mediastayed inactive and zygote preload died withNoClassDefFoundError(SystemServiceRegistry). The kernel command line now hasloop.max_loop=64(a50c9f3, run_sim.sh), and the initrd setsro.hw_timeout_multiplier=5so the system_server watchdog tolerates TCTI speed (b5b73bf).
Final cache: 211,716 TBs from three TCTI captures (kernel to zygote preload;
boot to boot_completed; boot plus the UI session), 129 helpers, 97 MB on
the device slice.
Simulator, fresh userdata, host wall clock, iPhone 17 Pro simulator on M2 Max, no other VM running:
| milestone | TCTI | TCTI+AOT |
|---|---|---|
| init second stage | 20.7 s | 9.3 s |
start zygote |
418.9 s | 175.8 s (2.4x) |
start surfaceflinger |
434.4 s | 192.7 s |
start bootanim |
452.9 s | 204.0 s |
sys.boot_completed=1 |
1061.0 s | 463.1 s (2.3x) |
zygote BeginPreload |
7.6 s | 2.8 s |
Both runs: zygote deaths 0, ANRs 0, watchdog kills 0. TCTI+AOT: 2,100,256 TBs translated, 384,855 table hits (18 %), 0 fingerprint or shape mismatches.
Device (iPhone 17 Pro Max, A19 Pro, iOS 27.2, TCTI+AOT, same slices, fresh
userdata, guest seconds, measured by the device session): Run /init 1.45,
init second stage 6.3, zygote 154.2, surfaceflinger 162.4, bootanim 175.1,
boot_completed 433.2; Launcher3 displayed about 2 min later; Settings and
Camera open (docs/iphone-home-screen.png). No plain-TCTI run to boot_completed on the device yet (an
earlier one with the ICU bug reached zygote at 357 s).
Remaining:
- Only 18 % of translated TBs hit the table after boot; the rest of the time is TCTI gadgets. A larger corpus (more apps, longer sessions) and the full-AOT ART configuration (step 5 above) are the next levers.
- A cache is only checked against the layout and the 8 slow-path helper
offsets. A stale
va_aot.frameworkleft in an app bundle next to a rebuilt QEMU (the embed script copies, it never deletes) could call the wrong helpers.make_xcframeworks.shpairs by binary hash, but the app should also check the binding, for example the full helper offset table or a QEMU build id.
- engine/qemu-aot (branch
aot):include/tcg/aot.h,tcg/aot.c- hooks in
accel/tcg/translate-all.c,tcg/tcg.c,tcg/aarch64-tcti/tcg-target.c.inc - the
aot_entergadget intcg/aarch64-tcti/tcti-gadget-gen.py
- tools/aot:
build-variants.sh: builds the JIT and TCTI variantsboot-bench.py: milestone timertbprof.c: profiling pluginanalyze-prof.pytcg2c.py,aot_rt.h: generator and runtime headertry-boot.sh,bisect.pyworkload.py: scripted UI session over adb (profile, capture, timing)tests/: same-ISA passthrough tests (pt-unit/,pt-proto-check.py,fp-passthrough/,pt-bench/) andhelper_rank.py(dynamic helper call ranking from a capture plus a tbprof TSV)
- engine/scripts/ios/build_aot.sh: cross-compiles the cache into
va_aot.frameworkfor one platform (iOS integration above)