When using the library recently, I came across the code that removes expired cache entries from the file storage. And I saw that it calls is_file and stat on each of them, both of which result in a system call. We can use os.scandir to save the syscalls to is_file.
This is demonstrated by this script here:
#!/usr/bin/env bash
DIR=/home/gorgor/tmp/hishel_iterdir_test
cat >scan_current.py <<EOF
#!/usr/bin/env python3
import os, time
from pathlib import Path
for f in Path("$DIR").iterdir():
if f.is_file():
age = time.time() - f.stat().st_mtime
EOF
cat >scan_new.py <<EOF
#!/usr/bin/env python3
import os, time
with os.scandir("$DIR") as entries:
for entry in entries:
if entry.is_file():
age = time.time() - entry.stat().st_mtime
EOF
trace() {
local script="$1"
mkdir -p "$DIR"
rm -rf "$DIR"/*
touch "$DIR"/{1,2,3}
# Call with syscall tracing and discard all the noise at the beginning.
strace python3 "$script" "$DIR" 2>&1 | sed -n '/hishel_iterdir_test/,$p'
}
echo "3 files with current version:"
echo
trace scan_current.py
echo
echo "============================================================"
echo
echo "3 files with os.scandir:"
echo
trace scan_new.py
From the output, we can see that the current version makes two syscalls per file, while a version with os.scandir only makes one:
So, I also checked what the impact of that is with this script:
#!/usr/bin/env python3
import os
from pathlib import Path
import time
from time import perf_counter
def create_dir(d, n_files):
os.makedirs(d, exist_ok=True)
for i in range(n_files):
open(os.path.join(d, str(i)), "w")
def iter_dir_old(d):
for file in Path(d).iterdir():
if file.is_file():
age = time.time() - file.stat().st_mtime
def iter_dir_new(d):
with os.scandir(d) as entries:
for entry in entries:
if entry.is_file():
age = time.time() - entry.stat().st_mtime
def main():
topdir = "/home/gorgor/tmp/hishel_iterdir_test/"
n_files = 10000
d = os.path.join(topdir, "new")
create_dir(d, n_files)
for _ in range(3):
start = perf_counter()
iter_dir_new(d)
delta = perf_counter() - start
print(f"{n_files} files with os.scandir: {delta:.4f}")
print()
d = os.path.join(topdir, "old")
create_dir(d, n_files)
for _ in range(3):
start = perf_counter()
iter_dir_old(d)
delta = perf_counter() - start
print(f"{n_files} files current version: {delta:.4f}")
if __name__ == "__main__":
main()
The following output shows that the os.scandir version takes less than half the time:
10000 files with os.scandir: 0.0191
10000 files with os.scandir: 0.0227
10000 files with os.scandir: 0.0274
10000 files current version: 0.0540
10000 files current version: 0.0607
10000 files current version: 0.0621
It's definitely not a big thing and spending time with it was more a bit of play for me, but I'd say it can be improved a bit with minimal effort. So I'm preparing a PR for it.
When using the library recently, I came across the code that removes expired cache entries from the file storage. And I saw that it calls
is_file
andstat
on each of them, both of which result in a system call. We can useos.scandir
to save the syscalls tois_file
.This is demonstrated by this script here:
From the output, we can see that the current version makes two syscalls per file, while a version with
os.scandir
only makes one:So, I also checked what the impact of that is with this script:
The following output shows that the
os.scandir
version takes less than half the time:It's definitely not a big thing and spending time with it was more a bit of play for me, but I'd say it can be improved a bit with minimal effort. So I'm preparing a PR for it.