From aba55031c331cd0991c3d36788fb903a3ca30e78 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:08:06 -0500 Subject: [PATCH] perf: answer chown and bound thermal paths through the proot fast path (#2203) --- .../thor-sd-storage-investigation.md | 74 ++++++++++++++ tools/proot/bench/chown.py | 98 +++++++++++++++++++ tools/proot/bench/thermal.py | 12 +++ tools/proot/fastpath/fastpath.c | 30 ++++++ tools/proot/fastpath/svc-cache.h | 2 +- tools/proot/fastpath/svc-dispatch.h | 3 +- tools/tests/test_fastpath_bind_names.py | 51 +++++++++- 7 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 tools/proot/bench/chown.py create mode 100644 tools/proot/bench/thermal.py diff --git a/docs/development/thor-sd-storage-investigation.md b/docs/development/thor-sd-storage-investigation.md index b2cfbce55..e2cee625a 100644 --- a/docs/development/thor-sd-storage-investigation.md +++ b/docs/development/thor-sd-storage-investigation.md @@ -377,3 +377,77 @@ diagnostics remained enabled. Temporary probe files, executables and obsolete partial staging were removed after validation. Original effective mount options were restored; no app-specific Magisk root policy remained. The primary checkout's unrelated `SessionService.kt` modification was preserved. + +## Install phases on current main (2026-11-07) + +A fresh install of 1000xRESIST (app 1675830, 2,216 files, 7.45 GB) to the same Thor SD +card, on the build from main at the time (`0652b0d`), with the fast path and storage +shim from the earlier work. Phase times come from Steam's `content_log.txt`. + +| Run | Reserving space | Download and stage | Commit | Total | +|---|---|---|---|---| +| Clean | 78 s | 25 min 01 s | 205 s | 29 min 46 s | +| Profiled | 79 s | 18 min 43 s | 146 s | 22 min 28 s | + +Reserving space was 404 s on 2026-10-04 and is now about 36 ms per file, which is the +file-creation floor measured above (25-36 ms). The two runs differ by 7 minutes because +the download phase follows the network: Steam logged 12 to 83 Mbps for the whole phase. +Raw sequential write to the card with `dd conv=fsync` was 15 MB/s (120 Mbps), so the +card is not the limit during most of the download. The run-to-run spread is larger than +any change a path-translation fix could make, so install totals are not used to judge +the fast path changes below. + +Kernel stacks of Steam's blocked threads, sampled every 0.2 s from `/proc//task/*`: + +- **Commit:** every sampled thread was inside `renameat`, waiting on synchronous + directory entry writes (`exfat_rename`, `exfat_init_ext_entry`, `exfat_remove_entries`, + `exfat_free_dentry_set`, all through `__sync_dirty_buffer`). That is the `dirsync` + mount option, at about 65 to 92 ms per file. PRoot is not involved in that time. +- **Download:** blocked threads were in `pwrite64`, in `exfat_get_block`, + `exfat_alloc_cluster` and `cont_write_begin`, which allocate clusters as the unreserved + files grow. + +PRoot's own handler time during the reserving phase (profiler, 1 ms slow threshold) was +7.3 s of the 79 s, of which 6.3 s was `openat` handler visits. Almost all of those came +from the HUD reading the three bound thermal files (22,326 fast path misses logged +against `/sys/class/thermal/thermal_zone{22,26,28}/temp`), not from Steam. `fchownat` +accounted for 2,795 visits at about 70 us. Steam's own calls were answered by the fast +path. + +### Fast path changes + +- `fchownat`, `chown` and `lchown` are answered in the fast path, through the libc + wrappers and the patched `svc` sites. The site cache name moves to `svc-v3` so cached + site lists from older builds are rebuilt. +- A path that names a bound file exactly, spelled through a symlinked directory + (`/sys/class/thermal/thermal_zone28/temp`, where `thermal_zone28` is a symlink), now + resolves to the bind's host path. Before, any path through a symlink with a binding + beneath it fell back to PRoot. Paths with `..`, a trailing slash or any other ending + still fall back. + +Same workloads on the Thor, stock library against the new one (session started with +`droiddeckctl override libblfastpath.so`, PRoot profiler on): + +| Workload | Stock | New | +|---|---|---| +| `chown` of 2,000 files, internal storage | 40-50 us each | 6 us each | +| `chown` of 2,000 files, SD card | 61-62 us each | 9 us each | +| Open of the three bound thermal files, 6,000 opens | 128-133 us each | 24-25 us each | +| PRoot `openat` handler visits for both workloads | 15,496 (1,205 ms) | 7,458 (366 ms) | +| PRoot `fchownat` handler visits | 6,873 (288 ms) | 0 | + +`bench/chown.py` compares 87 `chown` results (paths, links, dangling and looping links, +descriptors, directory descriptors, foreign owners, trailing slashes) with the fast path +on and with `PROOT_FP_OFF=1`; they were identical. The thermal paths were compared the +same way for every spelling the HUD can use (absolute, doubled slashes, `.` and `..` +components, relative to a working directory or descriptor on the parent directory) and +matched. They differ only when the working directory or descriptor is inside the +symlinked directory, or the path uses its canonical `/sys/devices/virtual/...` name: PRoot +returns the bound file there and the fast path returns the real node. That difference +existed before these changes, and the HUD does not read those spellings. + +What this does not change: the commit and the file creation costs above are the card's +synchronous directory updates, so SD installs stay bound by them (about 4 to 5 minutes per +2,000 files of reserving plus commit on this card). Removing those costs needs fewer +directory operations per file, not faster path translation. + diff --git a/tools/proot/bench/chown.py b/tools/proot/bench/chown.py new file mode 100644 index 000000000..b774e79af --- /dev/null +++ b/tools/proot/bench/chown.py @@ -0,0 +1,98 @@ +import errno +import os +import shutil +import sys +import time + +me = (os.getuid(), os.getgid()) + + +def call(fn): + try: + return repr(fn()) + except OSError as e: + return "E" + errno.errorcode.get(e.errno, str(e.errno)) + + +def run(base, links): + shutil.rmtree(base, ignore_errors=True) + os.makedirs(base + "/d") + open(base + "/f", "w").close() + open(base + "/d/g", "w").close() + if links: + os.symlink("f", base + "/lf") + os.symlink("nowhere", base + "/dl") + os.symlink("d", base + "/ld") + os.symlink("loop2", base + "/loop1") + os.symlink("loop1", base + "/loop2") + uid = lambda p, follow=True: os.stat(p, follow_symlinks=follow).st_uid + cases = [ + ("path", lambda: os.chown(base + "/f", *me)), + ("keep", lambda: os.chown(base + "/f", -1, -1)), + ("uid only", lambda: os.chown(base + "/f", me[0], -1)), + ("gid only", lambda: os.chown(base + "/f", -1, me[1])), + ("foreign uid", lambda: os.chown(base + "/f", me[0] + 1, -1)), + ("foreign gid", lambda: os.chown(base + "/f", -1, me[1] + 12345)), + ("dir", lambda: os.chown(base + "/d", *me)), + ("dir trailing slash", lambda: os.chown(base + "/d/", *me)), + ("file trailing slash", lambda: os.chown(base + "/f/", *me)), + ("missing", lambda: os.chown(base + "/nope", *me)), + ("missing parent", lambda: os.chown(base + "/nope/x", *me)), + ("under file", lambda: os.chown(base + "/f/x", *me)), + ("empty", lambda: os.chown("", *me)), + ("dotdot", lambda: os.chown(base + "/d/../f", *me)), + ("fd", lambda: (lambda fd: (os.chown(fd, *me), os.close(fd))[1])(os.open(base + "/f", os.O_RDONLY))), + ("dirfd rel", lambda: (lambda fd: (os.chown("g", *me, dir_fd=fd), os.close(fd))[1])(os.open(base + "/d", os.O_RDONLY))), + ("dirfd abs", lambda: (lambda fd: (os.chown(base + "/f", *me, dir_fd=fd), os.close(fd))[1])(os.open(base + "/d", os.O_RDONLY))), + ("bad dirfd", lambda: os.chown("g", *me, dir_fd=9999)), + ("file dirfd", lambda: (lambda fd: (os.chown("g", *me, dir_fd=fd), os.close(fd))[1])(os.open(base + "/f", os.O_RDONLY))), + ("cwd rel", lambda: (os.chdir(base + "/d"), os.chown("g", *me))[1]), + ("cwd dotdot", lambda: (os.chdir(base + "/d"), os.chown("../f", *me))[1]), + ] + if links: + cases += [ + ("link follow", lambda: os.chown(base + "/lf", *me)), + ("link nofollow", lambda: os.chown(base + "/lf", *me, follow_symlinks=False)), + ("dangling follow", lambda: os.chown(base + "/dl", *me)), + ("dangling nofollow", lambda: os.chown(base + "/dl", *me, follow_symlinks=False)), + ("dirlink", lambda: os.chown(base + "/ld/g", *me)), + ("dirlink slash", lambda: os.chown(base + "/ld/", *me)), + ("loop", lambda: os.chown(base + "/loop1", *me)), + ("loop nofollow", lambda: os.chown(base + "/loop1", *me, follow_symlinks=False)), + ("link foreign", lambda: os.chown(base + "/lf", me[0] + 1, -1)), + ] + out = [] + for label, fn in cases: + os.chdir("/") + out.append(f" {label}: {call(fn)}") + os.chdir("/") + out.append(" owner after: " + call(lambda: (uid(base + "/f"), os.stat(base + "/f").st_gid))) + shutil.rmtree(base, ignore_errors=True) + return out + + +def bench(base, count): + shutil.rmtree(base, ignore_errors=True) + os.makedirs(base) + names = [f"{base}/f{i}" for i in range(count)] + for n in names: + open(n, "w").close() + start = time.perf_counter() + for n in names: + try: + os.chown(n, *me) + except OSError: + pass + elapsed = time.perf_counter() - start + shutil.rmtree(base, ignore_errors=True) + return elapsed + + +for base, links in (("/root/chown-eq", True), ("/tmp/chown-eq", True), ("/mnt/droiddeck-sd/chown-eq", False)): + print("==", base) + print("\n".join(run(base, links))) + +if len(sys.argv) > 1: + for base in ("/root/chown-bench", "/mnt/droiddeck-sd/chown-bench"): + t = bench(base, int(sys.argv[1])) + print(f"BENCH {base} {int(sys.argv[1])} calls {t:.3f}s {t / int(sys.argv[1]) * 1e6:.0f}us each", file=sys.stderr) diff --git a/tools/proot/bench/thermal.py b/tools/proot/bench/thermal.py new file mode 100644 index 000000000..d7a50503a --- /dev/null +++ b/tools/proot/bench/thermal.py @@ -0,0 +1,12 @@ +import sys +import time + +count = int(sys.argv[1]) +paths = ["/sys/class/thermal/thermal_zone%d/temp" % n for n in (22, 26, 28)] +start = time.perf_counter() +for _ in range(count): + for p in paths: + with open(p) as f: + f.read() +elapsed = time.perf_counter() - start +print(f"{count * len(paths)} opens {elapsed:.3f}s {elapsed / (count * len(paths)) * 1e6:.0f}us each") diff --git a/tools/proot/fastpath/fastpath.c b/tools/proot/fastpath/fastpath.c index 0a9d3f22b..5bad6e04e 100644 --- a/tools/proot/fastpath/fastpath.c +++ b/tools/proot/fastpath/fastpath.c @@ -672,6 +672,18 @@ static int base_of(int dirfd, char *h) { return gone(h, n) ? -1 : 0; } +static long bound_literal(const char *literal, struct res *r) { + struct bind *bd; + if (reserved(literal) || bind_of(literal, &bd) < 0 || !bd || bd->glen != strlen(literal) || bd->glen < 2) return FP_SLOW; + if (snprintf(r->host, PATH_MAX, "%s", bd->host) >= PATH_MAX) return FP_SLOW; + if (sc(SYS_newfstatat, AT_FDCWD, (long)r->host, (long)&r->st, AT_SYMLINK_NOFOLLOW, 0) < 0 || S_ISLNK(r->st.st_mode)) return FP_SLOW; + snprintf(r->guest, PATH_MAX, "%s", literal); + r->exists = 1; + r->moving = 0; + r->magic = 0; + return 0; +} + static long resolve_in(const char *base, const char *path, int follow, struct res *r) { char norm[PATH_MAX]; size_t o = 0; @@ -707,6 +719,10 @@ static long resolve_in(const char *base, const char *path, int follow, struct re unsigned h = hash_of(norm); if (!cached(norm, h, r->guest)) { w = walk(r, norm, 1, 0); + if (w == FP_SLOW && !trailing) { + *slash = '/'; + return bound_literal(norm, r); + } if (w < 0) return w; if (w != GLUE && !S_ISDIR(r->st.st_mode)) return -ENOTDIR; if (!r->moving) remember(norm, h, r->guest); @@ -1204,6 +1220,20 @@ int fchmodat(int dirfd, const char *path, mode_t mode, int flags) { } int chmod(const char *path, mode_t mode) { return fchmodat(AT_FDCWD, path, mode, 0); } +static long fp_chown(int dirfd, const char *path, uid_t uid, gid_t gid, int flags) { + struct res r; + long rs = flags & ~AT_SYMLINK_NOFOLLOW ? FP_SLOW : resolve(dirfd, path, (flags & AT_SYMLINK_NOFOLLOW ? 0 : 1) | MAY_BE_UNSEEN, &r); + if (rs == 1) rs = -ENOENT; + if (rs == 0 || rs == UNSEEN) rs = sc(SYS_fchownat, AT_FDCWD, (long)r.host, (long)uid, (long)gid, flags); + return rs; +} +int fchownat(int dirfd, const char *path, uid_t uid, gid_t gid, int flags) { + REAL(fchownat, int (*)(int, const char *, uid_t, gid_t, int)); + ANSWER(fp_chown(dirfd, path, uid, gid, flags), dirfd, path, real_fchownat(dirfd, path, uid, gid, flags)); +} +int chown(const char *path, uid_t uid, gid_t gid) { return fchownat(AT_FDCWD, path, uid, gid, 0); } +int lchown(const char *path, uid_t uid, gid_t gid) { return fchownat(AT_FDCWD, path, uid, gid, AT_SYMLINK_NOFOLLOW); } + static long fp_utimens(int dirfd, const char *path, const struct timespec times[2], int flags) { struct res r; long rs; diff --git a/tools/proot/fastpath/svc-cache.h b/tools/proot/fastpath/svc-cache.h index 57c1be4ba..c346c3d21 100644 --- a/tools/proot/fastpath/svc-cache.h +++ b/tools/proot/fastpath/svc-cache.h @@ -39,7 +39,7 @@ static int svc_cache_name(const struct dl_phdr_info *info, char *path, size_t si id[2 * j + 1] = hex[b & 15]; } id[2 * h.n_descsz] = 0; - int n = snprintf(path, size, "/tmp/.droiddeck-svc-v2-%d-%s", svc_android_api, id); + int n = snprintf(path, size, "/tmp/.droiddeck-svc-v3-%d-%s", svc_android_api, id); return n > 0 && (size_t)n < size; } at += name + desc; diff --git a/tools/proot/fastpath/svc-dispatch.h b/tools/proot/fastpath/svc-dispatch.h index b8e87475f..a5e88cdb6 100644 --- a/tools/proot/fastpath/svc-dispatch.h +++ b/tools/proot/fastpath/svc-dispatch.h @@ -29,7 +29,7 @@ static int svc_supported(int nr) { switch (nr) { case SYS_openat: case SYS_newfstatat: case SYS_statx: case SYS_readlinkat: case SYS_faccessat: case SYS_mkdirat: case SYS_unlinkat: case SYS_renameat: - case SYS_renameat2: case SYS_symlinkat: case SYS_fchmodat: case SYS_utimensat: + case SYS_renameat2: case SYS_symlinkat: case SYS_fchmodat: case SYS_fchownat: case SYS_utimensat: case SYS_truncate: case SYS_statfs: case SYS_getxattr: case SYS_lgetxattr: case SYS_listxattr: case SYS_llistxattr: case SYS_inotify_add_watch: case SYS_getcwd: return 1; @@ -112,6 +112,7 @@ static long svc_path_call(const long *r) { return fp_rename((int)r[0], path, (int)r[2], other, r[8] == SYS_renameat2 ? (unsigned)r[4] : 0); case SYS_symlinkat: return fp_symlink(path, (int)r[1], other); case SYS_fchmodat: return fp_chmod((int)r[0], path, (mode_t)r[2], 0); + case SYS_fchownat: return fp_chown((int)r[0], path, (uid_t)r[2], (gid_t)r[3], (int)r[4]); case SYS_utimensat: return fp_utimens((int)r[0], path, when, (int)r[3]); case SYS_truncate: return fp_truncate(path, (off_t)r[1]); case SYS_statfs: { diff --git a/tools/tests/test_fastpath_bind_names.py b/tools/tests/test_fastpath_bind_names.py index 2e8a8b11f..08f73dab9 100644 --- a/tools/tests/test_fastpath_bind_names.py +++ b/tools/tests/test_fastpath_bind_names.py @@ -43,6 +43,24 @@ int main(int argc, char **argv) { if (detranslated(text, argv[i + 1]) == FP_SLOW) strcpy(text, "SLOW"); i += 2; puts(text); + } else if (!strcmp(argv[i], "read")) { + long fd = fp_open(AT_FDCWD, argv[++i], O_RDONLY, 0); + if (fd == FP_SLOW) puts("SLOW"); + else if (fd < 0) printf("E%ld\n", -fd); + else { + char data[64] = ""; + ssize_t n = read((int)fd, data, sizeof data - 1); + close((int)fd); + data[n > 0 ? n : 0] = 0; + puts(data); + } + } else if (!strcmp(argv[i], "chown")) { + int flags = !strcmp(argv[i + 1], "nofollow") ? AT_SYMLINK_NOFOLLOW : 0; + long r = fp_chown(AT_FDCWD, argv[i + 2], getuid(), getgid(), flags); + i += 2; + if (r == FP_SLOW) puts("SLOW"); + else if (r < 0) printf("E%ld\n", -r); + else puts("ok"); } else if (!strcmp(argv[i], "lstat")) { struct stat st; long r = fstatat(AT_FDCWD, argv[++i], &st, AT_SYMLINK_NOFOLLOW); @@ -65,13 +83,17 @@ class FastPathBindNamesTest(unittest.TestCase): cls.program = cls.work / "names" subprocess.run(["gcc", "-O1", "-w", "-pthread", "-o", str(cls.program), str(source), "-ldl"], check=True) t = cls.work / "tree" - for d in ["root/etc", "root/mnt", "root/opt", "root/d/real", "lib/common/game", "fonts", "a", "b"]: + for d in ["root/etc", "root/mnt", "root/opt", "root/d/real", "lib/common/game", "fonts", "a", "b", + "root/sys/class/thermal", "root/sys/devices/zone28", "bound"]: (t / d).mkdir(parents=True, exist_ok=True) for f in ["root/etc/x", "lib/f", "lib/common/game/f", "fonts/f", "a/f", "b/f"]: (t / f).touch() os.symlink(t / "lib/f", t / "lib/l2") os.symlink(t / "lib/common", t / "lib/l") os.symlink("real", t / "root/d/link") + os.symlink("../../devices/zone28", t / "root/sys/class/thermal/zone28") + (t / "bound/temp").write_text("bound") + (t / "root/sys/devices/zone28/other").write_text("other") cls.tree = t cls.binds = [ (t / "lib", "/mnt/droiddeck-sd"), @@ -82,6 +104,8 @@ class FastPathBindNamesTest(unittest.TestCase): (t / "a", "/opt/g"), (t / "b", "/opt/g"), (t / "fonts", "/mnt/x"), + (t / "bound/temp", "/sys/class/thermal/zone28/temp"), + (t / "bound/temp", "/dev/stdin/x"), ] @classmethod @@ -134,6 +158,31 @@ class FastPathBindNamesTest(unittest.TestCase): ("lstat", "/d/link/"), ), ["link", "dir", "dir", "dir"]) + def test_a_bound_file_under_a_link_is_found_by_its_spelled_name(self): + self.assertEqual(self.ask( + ("read", "/sys/class/thermal/zone28/temp"), + ("read", "//sys/class/thermal/./zone28//temp"), + ("read", "/sys/class/thermal/zone28/other"), + ("read", "/sys/class/thermal/zone28/temp/"), + ("read", "/sys/class/thermal/zone28/../zone28/temp"), + ("read", "/sys/class/thermal/zone28/missing"), + ("read", "/dev/stdin/x"), + ), ["bound", "bound", "SLOW", "SLOW", "SLOW", "SLOW", "SLOW"]) + + def test_chown_answers_like_the_kernel(self): + t = self.tree + self.assertEqual(self.ask( + ("chown", "follow", "/etc/x"), + ("chown", "follow", "/d/link"), + ("chown", "nofollow", "/d/link"), + ("chown", "follow", "/nope"), + ("chown", "follow", "/etc/x/y"), + ("chown", "follow", "/mnt/droiddeck-sd/f"), + ("chown", "follow", "/sys/class/thermal/zone28/temp"), + ("chown", "nofollow", "/sys/class/thermal/zone28/temp"), + ("chown", "follow", "/sys/class/thermal/zone28/other"), + ), ["ok", "ok", "ok", "E2", "E20", "ok", "ok", "ok", "SLOW"]) + if __name__ == "__main__": unittest.main()