21 KiB
proot, glibc and where the time goes
Research notes for feat/performance-fixes-2 (2026-09-30). The question was whether Steam and the
games should keep running under proot, run directly on glibc, or a mix of the two, and what the
fastest correct setup is. Everything below was measured on a Snapdragon 8 Elite Gen 5 (SM8850, kernel
6.12.38-android16, Android 16) over adb, unless it says otherwise.
For Steam SD allocation measurements on AYN Thor (Android 13), see the Thor storage investigation. It separates observed allocation-wrapper time, FUSE directory access and native exFAT metadata waits; it does not establish a full-install PRoot overhead percentage.
Short answer
- Don't drop proot. On Android it does two jobs. It translates paths. It also answers every
syscall that Android's app seccomp policy blocks (
rseq,set_robust_list,faccessat2,fchmodat2,openat2, theset*idfamily,futex_waitv, ...).- Stock Arch glibc without a tracer is killed by
SIGSYSon its firstpthread_create. That stays true even with an in-processSIGSYShandler, because glibc makes those calls with every signal blocked. - Going "pure glibc" means building Termux-style glibc (relocated prefix, blocked syscalls
rewritten), an exec shim for every ELF Steam self-updates, a
/proc/self/exeemulator for Chromium, path redirects for/tmp,/dev/shm,/mnt/*and the fake/procand/sysfiles, and Wine's hard-coded/tmp/.wine-<uid>. - That is a project of its own, and its failure mode is a crash instead of a slow call.
- Stock Arch glibc without a tracer is killed by
- During gameplay, proot already costs almost nothing, thanks to patches 0004–0013. GPU submits
(
ioctl),futex,read/write,mmap,ppoll/epoll,sendmsg/recvmsgandclock_gettime(via the vDSO) all run at native speed under the filter. - What proot still costs is every path syscall,
execve,brk, thread and process creation, and every Android-blocked syscall: 15–60 µs each, against ~0.5 µs natively. All of it goes through one tracer thread, which tops out around 75–95k traps/s. Steam start-up, game loading and asset streaming, Proton's Python, and any burst on several threads at once all hit this. - Most of that cost is not proot's code but the two cross-core wake-ups per trap. Any out-of-process mechanism pays them: ptrace, and seccomp user-notification too (measured the same). The only real fix is to not leave the process.
- Recommendation: proot plus an in-process fast path. A preloaded library answers the common
path calls inside the process. It sends them through a trampoline page that proot's filter lets
through, and it falls back to proot for anything it can't prove proot would answer the same way.
- The prototype is in this branch:
tools/proot/fastpath/andpatches/0014. - Speed:
stat25 µs → 0.6 µs,open25 µs → 0.9 µs, missing-file lookups 23 µs → 1.6 µs. Eight threads doingstatwent from 89k/s to 3.6M/s. - Correctness: an equivalence suite of awkward paths produced byte-identical results.
- It only uses syscalls the app policy allows.
- The prototype is in this branch:
How a session runs today
SessionService ─HostProcess─▶ libproot.so (ONE tracer process, nice -6 after 2 s)
└─ /usr/bin/env -i … droiddeck-session steam
└─ gamescope ─▶ droiddeck-session (BL_INSIDE) ─▶ steamrtarm64/steam (+ steamwebhelper/CEF tree)
└─ reaper ─▶ droiddeck-game-env ─▶ droiddeck-proton ─▶ Valve ARM64 Proton (python)
└─ wine (arm64ec, FEX loaded in-process as a DLL) ─▶ wineserver, game.exe, …
- Every process in the tree, from the session script to the game, is a tracee of the same proot.
- The options are
--kill-on-exit,--kernel-release=…(kompat, for the hostname),-i uid:uid(fake_id0, for Xwayland'ssetgid/setuid), and about 25 binds:/dev,/proc,/sys, fake/procand/sysfiles, the GPU node,/dev/shm, storage and the game libraries. /etc/ld.so.preloadloadslibblsession.so(thetools/linuxfs/preload/*.cshims) into every guest process.- The Flatpak sandboxes (
BwrapSpawner) already run in separate proot instances, started from the Android side.
What proot does per traced syscall
The tracee runs into proot's seccomp filter, which returns SECCOMP_RET_TRACE. Then:
- The kernel stops the tracee and wakes the tracer: a cross-core IPI, and possibly an exit from a deep idle state.
- The tracer reads the registers (
PTRACE_GETREGSET) and reads the path (process_vm_readv). - It canonicalises the path against the rootfs and bindings: an
lstatper component, or oneO_PATHopen plus a/proc/self/fdreadlink with patch 0002. - It writes the host path into a scratch area in the tracee (
process_vm_writev) and updates the registers. - It resumes the tracee, which is another cross-core wake-up.
Calls flagged FILTER_SYSEXIT stop a second time on the way out, for example to translate the
result of readlink or getcwd. Android-blocked syscalls arrive as a SIGSYS signal stop instead,
and proot emulates them (src/tracee/seccomp.c). Fork, clone, exec and thread exit each add
PTRACE_EVENT_* stops.
Measurements
The tools are in tools/proot/bench/; run-device.sh explains the device layout.
Caveat: adb shell is not under the app seccomp filter. Mechanism costs are the same, but
syscalls that Android blocks only for apps (see below) can't be reproduced there.
Caveat: the phone in Doze (screen off) runs everything about 7× slower. All numbers below were taken with the screen awake.
Per call, as a session runs proot today
sysbench in the patched (patched) and unpatched (vanilla) columns, using the session's own
proot options and a similar set of binds.
| call | direct glibc | termux proot (vanilla) | this repo's proot (0001–0013) | + fast path (0014) |
|---|---|---|---|---|
| getppid / futex / ioctl / pread (untraced) | 0.1–0.4 µs | futex 16.6, fstat 32.8, ioctl 30.5 µs | 0.2–0.4 µs | 0.2–0.4 µs |
| stat, deep absolute path | 0.8 µs | 41.7 | 25.3 | 0.6 |
| open + close | 1.7 µs | 40.4 | 25.3 | 0.9 |
| access | 27.2 | 26.2 | 1.0 | |
| fstatat relative to a dirfd | 45.5 | 26.8 | 1.9 | |
| stat of a missing file | 38.9 | 22.9 | 1.6 | |
| readlink /proc/self/exe | 42.8 | 38–43 | (proot) | |
| getcwd / uname / brk | 31–35 | 30–33 | (proot) | |
| memfd_create | 20.4 | 18.6 | (proot) | |
| pthread create+join | 58 | 48 | 48 | |
| fork + exec + wait | ~1.1 ms | 1.16 ms | 0.70–0.78 ms | +0.2–0.4 ms (see open items) |
The patches already in the repo pay off: kompat (0011) and fake_id0 (0012) took futex, fstat and
sendmsg off the trap list, and 0013 did the same for ioctl.
What one trap costs, by mechanism (mechbench; the handler does nothing)
| mechanism | cost |
|---|---|
| native syscall | 0.1 µs |
| seccomp filter of 0 / 50 / 150 / 300 compares, allowed syscall | no difference (kernel ≥5.11 caches constant decisions per syscall number) |
| ptrace + seccomp, tracer and tracee on the same core | 6.2 µs (6.7 µs with register get/set) |
| ptrace + seccomp, unpinned (as the app runs it) | 15.5 µs |
| ptrace + seccomp, different cores (same cluster / across clusters) | 34–36 / 42–61 µs |
| ptrace with a spinning tracer, different cores | 20 µs (the tracee's side still has to wake) |
| seccomp user-notification (the "modern proot" design) | 5.7 µs same core, 34–58 µs across, 43 µs unpinned; SYNC_WAKE_UP doesn't help |
in-process SIGSYS (SECCOMP_RET_TRAP) |
0.83 µs |
openat2 RESOLVE_IN_ROOT (kernel-side chroot lookup) |
1.0 µs, but blocked in apps |
Takeaways:
- The tracer's own work is about 8 µs of the ~14 µs same-core trap. Everything above that is scheduling.
- Moving to seccomp user-notification would be a rewrite for no gain.
- The filter's length doesn't matter. What matters is not breaking the kernel's cache:
- Any check on an argument or the instruction pointer must come after the syscall-number dispatch. The first version of 0014 checked the address first. That made every syscall in the session run the whole filter, and cost +0.5 ms per exec.
- Patches 0004 and 0013 already follow this rule.
One tracer for everything (parstat: N threads doing stat)
| 1 thread | 4 threads | 8 threads | |
|---|---|---|---|
| one proot | 37k/s, 27 µs | 74–95k/s, 42–54 µs | 68–89k/s, 90–118 µs each |
| two proots, 2 threads each | 187k/s total, 21 µs | ||
| proot + fast path | 1.64M/s, 0.6 µs | 2.76M/s | 3.60M/s, 2.2 µs |
A burst of path calls on several threads therefore queues behind the single tracer. CEF loading assets, Proton's Python, a game streaming files on worker threads and the shader-cache threads all produce such bursts.
CPU placement
The workload ran with taskset; no fast path. Times are per traced call, and the last column is one
fork + exec.
| affinity | stat | exec |
|---|---|---|
| default | 25.8 µs | 750 µs |
| one core | 14.0 µs | 370 µs |
| the two prime cores | 14.6 µs | |
| the six-core cluster | 25.9 µs |
The scheduler's wake-affine placement helps when it can put the tracer and the tracee together, and
spreading them apart hurts. Never pin the tracer away from its tracees. Today raiseTracer
only renices it, which is correct.
Whole programs
Times are in ms, each with proot's ~40 ms start-up already subtracted.
| direct glibc | vanilla proot | this repo's proot | + fast path | |
|---|---|---|---|---|
| python3: 10 stdlib imports | 75–89 | ~130 | ~110 | ~90 (878 of 879 path calls answered in-process) |
| tar of 2.7k files | 10–40 | ~180 | ~80 | ~20 |
200 × fork+exec of true |
230 | ~600 | ~500 | ~500 |
| find over 2.7k files | 16–23 | noisy | noisy | unchanged: glibc's fts calls internal entry points |
Android's app seccomp policy decides more than proot does
The app filter is generated from bionic's SYSCALLS.TXT, minus the blocklists, plus the
allowlists. It is the same for every app whatever its targetSdk, and anything not listed gets
SECCOMP_RET_TRAP, a SIGSYS. Filters stack, and the strictest one wins: a blocked call never
reaches proot's RET_TRACE. It arrives as a SIGSYS stop, and proot answers it (usually ENOSYS).
Checked against bionic android16-release:
| syscall | app policy | who calls it |
|---|---|---|
rseq, set_robust_list |
blocked | glibc, at the start of every thread, while all signals are blocked |
faccessat2 |
blocked | glibc's faccessat(), which tries it on every call before falling back, so 2 round trips (access() calls the plain faccessat syscall directly) |
fchmodat2, openat2 |
blocked | newer glibc, Flatpak/libglnx, FEX's rootfs lookups (libblsession.so turns openat2 into openat) |
setuid, setgid and the rest of the family |
blocked | Xwayland (hence -i) |
futex_waitv, io_uring_*, landlock_* |
blocked | Proton's fsync (droiddeck-fsync answers futex_waitv in libblsession.so), some engines |
clone3 |
blocked before Android 15 | glibc pthread_create |
close_range |
blocked on Android 12 | |
statx, process_vm_readv/writev, memfd_create, pidfd_*, seccomp |
allowed |
On top of the ptrace round trips, every one of these costs a signal round trip through proot.
Every traced syscall: what it needs and whether it can bypass proot
This is the filter of termux proot at source.env plus patches 0001–0013. "Fast path" means the
call can be answered in-process with the 0014 design.
| syscalls | why proot traps them | can it bypass proot? |
|---|---|---|
openat/open/creat, newfstatat/stat/lstat, statx, faccessat/access, readlinkat (exit), mkdirat, unlinkat, renameat(2), symlinkat, linkat, fchmodat, fchownat, utimensat, truncate, *xattr, inotify_add_watch, name_to_handle_at |
path translation (guest → host), plus exit fixups for readlink and rename |
Yes, through libc wrappers. The prototype covers open, stat, statx, access, readlink, opendir and fopen; the write family is next. Raw svc and glibc's internal callers (ld.so, fts, realpath, nss) still go to proot. |
chdir/fchdir, getcwd (exit) |
proot emulates the cwd; until 0014 the kernel's cwd never moved | With 0014 the kernel's cwd follows the guest's. getcwd stays in proot (cheap, rare). |
execve/execveat |
runs the loader, maps PT_INTERP inside the rootfs, shebangs, tracks /proc/self/exe |
No. This is the core of what proot is for. Costs 0.4–0.8 ms per exec; the session scripts already avoid exec-heavy loops (nap). |
brk (entry and exit) |
heap emulation: programs are mapped by proot's loader, so the kernel's brk area is the loader's | No (PR_SET_MM needs CAP_SYS_RESOURCE). Fewer calls: GLIBC_TUNABLES=glibc.malloc.top_pad=… grows the heap in larger steps. |
bind, connect, accept(4), getsockname, getpeername |
sun_path of Unix sockets |
Same-path binds (runtime dir, files dir) need no translation, but the filter can't see the address. Possible fast-path candidate. |
wait4/waitpid, ptrace |
ptrace emulation inside the guest (breakpad, gdb) | Could become opt-in; then wait4 is free (one stop per wait today). |
prctl(PR_SET_DUMPABLE), setrlimit/prlimit64(RLIMIT_STACK) |
loader and stack fixups | Already narrowed to these arguments (0004). |
ioctl(TCSETSF, termios2, FICLONE) |
Android pty policy, FICLONE EACCES |
Already narrowed to these requests; GPU ioctls are free (0013). |
uname, sethostname, setdomainname |
the DroidDeck hostname (kompat) |
Rare. Keep. |
set*id, *setxattr |
fake_id0 identity mode (0012) | Rare. Keep. |
memfd_create |
Qt JIT and php workarounds (string argument) | Every Wayland wl_shm buffer and Chromium shared memory pays one stop. Could be dropped if the DroidDeck runtime doesn't need those workarounds. |
statfs (exit) |
fakes tmpfs for /dev/shm |
Rare. |
open_tree, move_mount, fspick, mount_setattr, openat2 |
answered ENOSYS (0008, 0010) |
Fine. |
| Android-blocked calls (previous table) | SIGSYS emulation |
Avoidable at the source: GLIBC_TUNABLES=glibc.pthread.rseq=0 removes one stop per thread. The rest need a patched glibc. |
| clone/fork/vfork/exec/exit events | tracking tracees | Needed while proot traces. |
Options weighed
-
Pure glibc, no proot. Rejected as the main path, for the reasons in the short answer.
- Closest prior art: huntergdavis/steamclienttermux, which runs
steamrtarm64on a patched Termux glibc 2.44 with an exec shim and a/tmpand/dev/shmredirect preload. - They report Steam's first window 7× faster and about 5% more FPS, with proot having used 60–65% of a core.
- They still run Proton and the games under proot.
- DroidDeck also has four package names (
tools/release/variants.txt), so a relocated prefix would need four builds or Winlator-style padded path rewriting.
- Closest prior art: huntergdavis/steamclienttermux, which runs
-
Hybrid by process (Steam in proot, game on glibc, or the reverse). The game side is the harder half. Wine re-execs itself and
wineserver, which hitsPT_INTERP. Its server directory is hard-coded under/tmp, and Proton's esync and fsync useshm_open(/dev/shm) andfutex_waitv, which is blocked (droiddeck-fsync emulates it in the session preload, which a glibc-side game would have to load too). A game's steady state is already mostly untraced, so the gain is in loading. Not worth it before the fast path. -
Seccomp user-notification instead of ptrace. It measures the same as ptrace, and it can't rewrite arguments, so every call would have to be emulated. No.
-
In-process
SIGSYSfor everything (0.8 µs). The kernel resets a blockedSIGSYSto the default action and kills the process (force_sig_seccomp,HANDLER_CURRENT). glibc and Wine block all signals around exactly these calls. Unsafe without also trappingrt_sigprocmask. No. -
proot plus an in-process fast path (this branch). Recommended. Details below.
-
A proot-aware glibc, the follow-up to 5. Rebuild the rootfs's own glibc (same version, same Arch package) with two changes:
- its internal path entry points (
__open_nocancel,__fstatat64, ld.so's opens) go through the fast path, which covers ld.so, fts, realpath and nss; - it never makes the Android-blocked calls, as Termux's
fakesyscall.jsondoes.
This removes the remaining library-loading traps and all the
SIGSYSround trips. The rootfs is versioned (linuxfs-rN), so glibc rebuilds stay under control. - its internal path entry points (
-
On the tracer side, with no in-process changes:
- A second proot for the game tree. Measured 2.5× trap throughput, and Steam/CEF bursts no
longer stall the game.
BwrapSpawneralready shows the pattern: a stand-in process in the session relays stdio, the exit status and signals to a proot started from the app. - Leave the tracer unpinned (above).
- Set
GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216in the session environment.
- A second proot for the game tree. Measured 2.5× trap throughput, and Steam/CEF bursts no
longer stall the game.
The fast path (prototype)
The code is tools/proot/fastpath/fastpath.c, preloaded into every guest process. On the proot side
it needs patches/0014, enabled with PROOT_FASTPATH=1.
- Trampoline. The library maps a 4 KiB executable page at the fixed address
0xffff00000. It holdsmov x8,x0 … svc #0; ret. WithPROOT_FASTPATH, proot's filter checks the caller's address just before eachRET_TRACEand allows syscalls coming from that page. - Mapping a path. The library takes the literal guest path and finds the longest binding whose
guest path is a prefix of it; otherwise the path is under the rootfs.
- It opens the parent host directory
O_PATHand requires/proc/self/fdto name exactly that path. That proves no component on the way was a symlink, so proot would have walked the same directories. - The last component is never followed: a symlink there goes to proot.
- It opens the parent host directory
- Missing files. For a missing file, the deepest existing ancestor is verified the same way, and
the next component must not exist even as a symlink. Then
ENOENTis exact. - What always goes to proot:
.., a trailing/,/proc,/dev/fdand/dev/std*;O_TMPFILE,AT_EACCESS, and any flag not handled;- any doubt at all. "Go to proot" means calling the real libc function, so proot decides.
- Relative paths. Patch 0014 keeps the kernel's cwd at the guest cwd's host directory. Relative
and dirfd lookups are mapped through
/proc/self/{cwd,fd/N}. - Verified-parent cache. Verified parents are cached for 2 s (
PROOT_FP_TTL_MS). That is the same trade-offpreload/pathcache.calready makes for the Steam client. A parent directory turned into a symlink inside that window would be missed;PROOT_FP_TTL_MS=0verifies every call, at 2.0 µs instead of 0.6 µs. - Allowed syscalls only. It uses only
openat,newfstatat,statx,faccessat,readlinkatandclose, all allowed for apps. A first version usedopenat2 RESOLVE_IN_ROOT, which the app policy blocks. - Equivalence check.
bench/equiv.pyruns stat, lstat, readlink, read, listdir and access over awkward paths, from several working directories and through a dirfd. Its output is byte-identical with and without the fast path, at TTL 0 and at TTL 2000. The paths include:- absolute and relative symlinks into binds,
..symlinks, dangling links and loops; - files inside binds, nested binds (
/dev/shminside/dev) and/proc; - missing paths behind symlinks.
- absolute and relative symlinks into binds,
Open items before shipping it
- Validate inside the app, not just from adb. Three things to check:
- The app domain allows an anonymous
PROT_EXECpage (execmem). If not, map a page of the library file itself at the fixed address instead, as proot's loader does for every program. - Nothing claims
0xffff00000first. - The stub's syscalls pass Android's filter. They should: all are allowed.
- The app domain allows an anonymous
- Integrate into
libblsession.sorather than a second preload.opens.candpathcache.calready defineopenandaccess, so their "real" call should become the fast path. That also removes the second library's load cost, about 0.2–0.4 ms per exec that is still unexplained (see the exec row above). - Wire the launcher. The app already knows the binds (
LinuxRuntime.binds):- set
PROOT_FASTPATH=1in proot's environment; - pass
PROOT_FP_ROOTandPROOT_FP_BINDSto the guest; - add a Performance toggle next to "Run proot without seccomp", with the fast path off whenever seccomp is off.
- set
- Cover the write family (mkdir, unlink, rename, symlink, chmod, utimens) and Unix-socket
connect/bindfor same-path binds. Then measure a real Steam start in the app, a game load, andPROOT_FP_STATShit rates for steam, steamwebhelper, wine and wineserver. - FEX, inside Proton as a DLL, translates x86 path syscalls through glibc's
syscall(), not the wrappers, so they still trap. Asyscall()hook could routeopenatandnewfstatatthere too.