mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
407 lines
27 KiB
Markdown
407 lines
27 KiB
Markdown
# 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](thor-sd-storage-investigation.md). 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`, the `set*id` family, `futex_waitv`, ...).
|
||
- Stock Arch glibc without a tracer is killed by `SIGSYS` on its first `pthread_create`. That
|
||
stays true even with an in-process `SIGSYS` handler, 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/exe` emulator for
|
||
Chromium, path redirects for `/tmp`, `/dev/shm`, `/mnt/*` and the fake `/proc` and `/sys`
|
||
files, 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.
|
||
- **During gameplay, proot already costs almost nothing**, thanks to patches 0004–0013. GPU submits
|
||
(`ioctl`), `futex`, `read`/`write`, `mmap`, `ppoll`/`epoll`, `sendmsg`/`recvmsg` and
|
||
`clock_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/` and `patches/0014`.
|
||
- Speed: `stat` 25 µs → 0.6 µs, `open` 25 µs → 0.9 µs, missing-file lookups 23 µs → 1.6 µs.
|
||
Eight threads doing `stat` went 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.
|
||
|
||
## 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's `setgid`/`setuid`), and about 25 binds: `/dev`, `/proc`, `/sys`, fake
|
||
`/proc` and `/sys` files, the GPU node, `/dev/shm`, storage and the game libraries.
|
||
- `/etc/ld.so.preload` loads `libblsession.so` (the `tools/linuxfs/preload/*.c` shims) 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:
|
||
|
||
1. The kernel stops the tracee and wakes the tracer: a cross-core IPI, and possibly an exit from a
|
||
deep idle state.
|
||
2. The tracer reads the registers (`PTRACE_GETREGSET`) and reads the path (`process_vm_readv`).
|
||
3. It canonicalises the path against the rootfs and bindings: an `lstat` per component, or one
|
||
`O_PATH` open plus a `/proc/self/fd` readlink with patch 0002.
|
||
4. It writes the host path into a scratch area in the tracee (`process_vm_writev`) and updates the
|
||
registers.
|
||
5. 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`, resolving `RESOLVE_IN_ROOT` paths component by component inside the root) |
|
||
| `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
|
||
|
||
1. **Pure glibc, no proot.** Rejected as the main path, for the reasons in the short answer.
|
||
- Closest prior art: huntergdavis/steamclienttermux, which runs `steamrtarm64` on a patched
|
||
Termux glibc 2.44 with an exec shim and a `/tmp` and `/dev/shm` redirect 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.
|
||
2. **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 hits `PT_INTERP`. Its server
|
||
directory is hard-coded under `/tmp`, and Proton's esync and fsync use `shm_open` (`/dev/shm`)
|
||
and `futex_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.
|
||
3. **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.
|
||
4. **In-process `SIGSYS` for everything** (0.8 µs). The kernel resets a blocked `SIGSYS` to 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 trapping `rt_sigprocmask`. No.
|
||
5. **proot plus an in-process fast path (this branch).** Recommended. Details below.
|
||
6. **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.json` does.
|
||
|
||
This removes the remaining library-loading traps and all the `SIGSYS` round trips. The rootfs is
|
||
versioned (`linuxfs-rN`), so glibc rebuilds stay under control.
|
||
7. **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. `BwrapSpawner` already 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=16777216` in the session
|
||
environment.
|
||
|
||
## 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
|
||
holds `mov x8,x0 … svc #0; ret`. With `PROOT_FASTPATH`, proot's filter checks the caller's address
|
||
just before each `RET_TRACE` and 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_PATH` and requires `/proc/self/fd` to 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.
|
||
- **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 `ENOENT` is exact.
|
||
- **What always goes to proot:**
|
||
- `..`, a trailing `/`, `/proc`, `/dev/fd` and `/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-off `preload/pathcache.c` already makes for the Steam client. A parent directory turned
|
||
into a symlink inside that window would be missed; `PROOT_FP_TTL_MS=0` verifies every call, at
|
||
2.0 µs instead of 0.6 µs.
|
||
- **Allowed syscalls only.** It uses only `openat`, `newfstatat`, `statx`, `faccessat`, `readlinkat`
|
||
and `close`, all allowed for apps. A first version used `openat2 RESOLVE_IN_ROOT`, which the app
|
||
policy blocks.
|
||
- **Equivalence check.** `bench/equiv.py` runs 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/shm` inside `/dev`) and `/proc`;
|
||
- missing paths behind symlinks.
|
||
|
||
### Open items before shipping it
|
||
|
||
- **Validate inside the app**, not just from adb. Three things to check:
|
||
- The app domain allows an anonymous `PROT_EXEC` page (`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 `0xffff00000` first.
|
||
- The stub's syscalls pass Android's filter. They should: all are allowed.
|
||
- **Integrate into `libblsession.so`** rather than a second preload. `opens.c` and `pathcache.c`
|
||
already define `open` and `access`, 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=1` in proot's environment;
|
||
- pass `PROOT_FP_ROOT` and `PROOT_FP_BINDS` to the guest;
|
||
- add a Performance toggle next to "Run proot without seccomp", with the fast path off whenever
|
||
seccomp is off.
|
||
- **Cover the write family** (mkdir, unlink, rename, symlink, chmod, utimens) and Unix-socket
|
||
`connect`/`bind` for same-path binds. Then measure a real Steam start in the app, a game load,
|
||
and `PROOT_FP_STATS` hit 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. A `syscall()` hook could route `openat` and `newfstatat` there too.
|
||
|
||
## Finding the next fast path
|
||
|
||
The prototype notes above describe the original experiments. Main now includes the fast path,
|
||
expanded wrappers, FEX raw-open routing ([#1283](https://github.com/Droid-Deck/DroidDeck/pull/1283)),
|
||
and SD-library bind-name resolution ([#1331](https://github.com/Droid-Deck/DroidDeck/pull/1331)).
|
||
Those fixes show why wrapper hit rates alone are insufficient: a caller can bypass the wrappers,
|
||
or repeatedly hit a path the wrappers cannot resolve.
|
||
|
||
### Opt-in tracer profiling
|
||
|
||
With **Setup > Diagnostics > Debugging tools** enabled, queue profiling for one session:
|
||
|
||
```sh
|
||
tools/droiddeckctl proot-profile enable --slow-us 1000
|
||
tools/droiddeckctl start steam --wait
|
||
# Reproduce the startup, game load, or streaming workload.
|
||
tools/droiddeckctl stop
|
||
tools/droiddeckctl logs latest ./profile-run
|
||
tools/droiddeckctl proot-profile report ./profile-run/session.log --sort wall
|
||
# Also inspect --sort cpu and --sort calls.
|
||
```
|
||
|
||
`PROOT_PROFILE=1` is forwarded from the session environment to the host tracer. Patch 0018 records
|
||
entry/exit **handler visits**, including PRoot's chained calls, by portable syscall name. It emits
|
||
`proot-profile` JSON lines to the existing `session.log`: cumulative totals every five seconds while
|
||
handling calls, totals at normal exit, and up to 32 slow-handler samples per five-second window exceeding `PROOT_PROFILE_SLOW_US`
|
||
(default 1,000 microseconds). The report uses each counter's last snapshot rather than adding
|
||
snapshots together and retains the most recent 128 slow samples. Logs can be pulled and reported while the session is running; final totals can
|
||
be lost if the tracer is killed. An empty report (`hasCounters: false`) is not evidence of zero
|
||
PRoot cost: check for the `start` record and that the installed PRoot pack contains patch 0018.
|
||
|
||
**What the clocks mean:** handler wall time includes PRoot's path translation, filesystem waits
|
||
inside the tracer, and time the tracer is descheduled inside the handler. Thread CPU time measures
|
||
CPU used by that handler. Neither clock includes the tracee's subsequent kernel syscall, the
|
||
wake-up/queue delay before handler entry, or work outside `translate_syscall` (such as standalone
|
||
SIGSYS emulation, signal delivery, and process-event handling). Fast-path calls that avoid PRoot
|
||
are absent. Counts are handler visits, not a count of all guest syscalls. No extra ptrace stops or
|
||
syscall-exit interception are enabled for profiling. Profiling still costs clock reads, counters,
|
||
and log output; turn it off for final performance measurements.
|
||
|
||
Rank cumulative time and frequency before chasing individual outliers. High handler CPU suggests
|
||
translation work; high handler wall with low CPU suggests filesystem or scheduling waits. Neither
|
||
proves how much a new fast path would save. Reproduce with the same app/PRoot/library build,
|
||
workload, storage, CPU policy, and cache state, then alternate A/B runs and measure the real load
|
||
milestone without profiling too. Keep `PROOT_FP_OFF=1` runs for correctness comparisons.
|
||
|
||
For path context, the existing `PROOT_FP_LOG=/tmp/fastpath-misses.log` logs wrapper fallbacks and
|
||
`PROOT_FP_STATS=1` prints per-process hit/miss counts at normal exit. These are separate opt-ins:
|
||
miss logging writes on every fallback, may contain personal paths, and cannot see callers that
|
||
bypass the wrappers. Capture it separately from the session artifact folder. The new profiler
|
||
records syscall names, numeric IDs, and timings without paths or arguments.
|
||
|
||
### Test a replacement library without rebuilding the APK
|
||
|
||
Build a **glibc ARM64** library using the same toolchain as the shipped fast path:
|
||
|
||
```sh
|
||
docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" \
|
||
-v "$PWD:/src" -w /src droiddeck-local-cross:24.04-v3 \
|
||
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
|
||
-o /src/libblfastpath.so tools/proot/fastpath/fastpath.c -ldl
|
||
|
||
tools/droiddeckctl stop
|
||
tools/droiddeckctl override libblfastpath.so ./libblfastpath.so
|
||
tools/droiddeckctl proot-profile enable
|
||
tools/droiddeckctl start steam --wait
|
||
```
|
||
|
||
The existing override bridge now recognizes `libblfastpath.so`: it checks ELF class, endianness,
|
||
type, and machine, reports SHA-256, and selects that file in `/etc/ld.so.preload` for one session.
|
||
It rejects installation while a session is active. This is an architecture check, not validation
|
||
of libc compatibility or the library's behavior. The fast path still requires its normal enabled
|
||
setting and the patched PRoot pack. Its basename stays `libblfastpath.so`, so `libblsession`'s FEX
|
||
routing finds it. Guest-command restaging retains the active selection. After stopping, the next
|
||
session stages the bundled preload again; `env clear all` cancels queued overrides.
|
||
|
||
This replaces a library **for a fresh session**. It does not rebind already-resolved symbols in
|
||
running Wine/FEX processes, unload old code, or load arbitrary plug-ins through a new ABI. True
|
||
in-process patching would need an explicit dispatch API and synchronization across every process.
|
||
[The dynamic loader](https://man7.org/linux/man-pages/man3/dlopen.3.html) resolves symbols within each process.
|
||
For now, restarting the session avoids those lifetime and binding problems while removing APK
|
||
build/install time from a fast-path iteration. Changes to the trampoline/filter contract still
|
||
require a matching PRoot build, not just a library swap. The profiler itself also needs a new
|
||
PRoot pack; CI builds and pins that pack alongside the APK.
|
||
|
||
The next useful extensions are bounded path sampling tied to handler time, separate process/signal
|
||
stop counters, and scenario reports that compare the same game-load milestone across builds.
|
||
Keep candidate fast paths behind equivalence tests: a slow symlink, `/proc` lookup, unsupported
|
||
flag, or ambiguous bind must retain PRoot's semantics.
|