Skip to content

Commit 547d3f3

Browse files
committed
perf(core.utils): track FileStream cache size incrementally
Avoid the per-write directory walk in `_enforce_size_cap` by maintaining a running byte total. The tracker is seeded once from `_compute_total_size` at open time, updated on `__setitem__` (net delta from the old entry's size), `__delitem__` (subtract the unlinked file's size), and `clear` (re-derive from the post-clear state). When a write doesn't push the running total above `max_size_bytes`, eviction stays a no-op -- writes become O(1) instead of O(n) in the cache size. Cross-process drift (other writers/deleters working on the same root) self-corrects: any time `_enforce_size_cap` actually runs its scan, it reseeds `_tracked_size_bytes` from the observed disk total, so overestimates trigger one extra scan and then settle. Skipped entirely when `max_size_bytes is None` (no cap, no need for a tracker). Mutations are guarded by `_size_lock` so multi-threaded writers in the same process don't interleave the read-modify-write on the int. Also switch `_iter_entry_paths` from `Path.iterdir` to `os.scandir`: the dirent type cache lets `is_dir`/`is_file` answer without a separate `stat` syscall on filesystems that report it (ext4, NTFS, ...). Behaviourally equivalent (the cache layout never creates symlinks, and we pass `follow_symlinks=False` to match `pathlib`'s previous no-symlink-confusion default for our paths). Tests cover three guarantees: (1) writes that stay under the cap don't call `_enforce_size_cap`, (2) writes that cross the cap do call it, and (3) external deletion behind the cache's back is reconciled by the next eviction pass.
1 parent c6aaaa7 commit 547d3f3

2 files changed

Lines changed: 198 additions & 8 deletions

File tree

cuda_core/cuda/core/utils/_program_cache/_file_stream.py

Lines changed: 124 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import hashlib
1818
import os
1919
import tempfile
20+
import threading
2021
import time
2122
from pathlib import Path
2223
from typing import Iterable
@@ -401,6 +402,19 @@ def __init__(
401402
# crashed writers. Age-based so concurrent in-flight writes from
402403
# other processes are preserved.
403404
self._sweep_stale_tmp_files()
405+
# Incremental size tracker. Without it every ``__setitem__`` would
406+
# walk ``entries/`` + ``tmp/`` to compute the total -- O(n) per
407+
# write. With it: writes update the tracker by the net delta in O(1)
408+
# and only walk on eviction (which already needs the scan to sort
409+
# entries by atime). The tracker is seeded by one full scan at open
410+
# time and refreshed on every eviction pass; cross-process drift
411+
# (other writers/deleters) self-corrects the next time eviction
412+
# fires. The lock guards mutations so multi-threaded writers in
413+
# the same process don't interleave the read-modify-write on the
414+
# int. Skipped entirely when ``max_size_bytes is None`` -- without
415+
# a cap the tracker is dead weight.
416+
self._size_lock = threading.Lock()
417+
self._tracked_size_bytes = self._compute_total_size() if max_size_bytes is not None else 0
404418

405419
# -- key-to-path helpers -------------------------------------------------
406420

@@ -445,6 +459,18 @@ def __setitem__(self, key: object, value: bytes | bytearray | memoryview | Objec
445459
# FileNotFoundError even though we could trivially recover.
446460
self._tmp.mkdir(parents=True, exist_ok=True)
447461

462+
# Stat the existing entry (if any) BEFORE the replace so we can
463+
# update the tracker by the net delta. A racing writer that lands
464+
# an ``os.replace`` between this stat and our own makes ``old_size``
465+
# slightly off; the next ``_enforce_size_cap`` reconciles by
466+
# re-scanning. Skipped when ``max_size_bytes is None`` (no tracker).
467+
old_size = 0
468+
if self._max_size_bytes is not None:
469+
try:
470+
old_size = target.stat().st_size
471+
except FileNotFoundError:
472+
old_size = 0
473+
448474
fd, tmp_name = tempfile.mkstemp(prefix="entry-", dir=self._tmp)
449475
tmp_path = Path(tmp_name)
450476
try:
@@ -465,14 +491,39 @@ def __setitem__(self, key: object, value: bytes | bytearray | memoryview | Objec
465491
with contextlib.suppress(FileNotFoundError):
466492
tmp_path.unlink()
467493
raise
468-
self._enforce_size_cap()
494+
495+
if self._max_size_bytes is None:
496+
return
497+
498+
# O(1) tracker update. Only run the scan-heavy ``_enforce_size_cap``
499+
# when this write actually pushes the running total above the cap.
500+
new_size = len(data)
501+
with self._size_lock:
502+
self._tracked_size_bytes += new_size - old_size
503+
over_cap = self._tracked_size_bytes > self._max_size_bytes
504+
if over_cap:
505+
self._enforce_size_cap()
469506

470507
def __delitem__(self, key: object) -> None:
471508
path = self._path_for_key(key)
509+
# Stat before unlink so we can decrement the tracker by the actual
510+
# on-disk size. Best-effort: if the file vanishes between stat and
511+
# unlink (concurrent eviction), we treat the delete as a miss --
512+
# matching the behaviour callers expect (KeyError) and leaving the
513+
# tracker untouched (the racing eviction already accounted for it).
514+
size = 0
515+
if self._max_size_bytes is not None:
516+
try:
517+
size = path.stat().st_size
518+
except FileNotFoundError:
519+
raise KeyError(key) from None
472520
try:
473521
_unlink_with_sharing_retry(path)
474522
except FileNotFoundError:
475523
raise KeyError(key) from None
524+
if self._max_size_bytes is not None:
525+
with self._size_lock:
526+
self._tracked_size_bytes -= size
476527

477528
def __len__(self) -> int:
478529
"""Return the number of files currently in ``entries/``.
@@ -513,18 +564,71 @@ def clear(self) -> None:
513564
if sub.is_dir():
514565
with contextlib.suppress(OSError):
515566
sub.rmdir()
567+
# The directory is now (almost) empty -- but a concurrent writer may
568+
# have landed a fresh entry between the snapshot and the unlink, and
569+
# young temp files were intentionally preserved. Re-derive the
570+
# tracker from the post-clear state instead of zeroing blindly.
571+
if self._max_size_bytes is not None:
572+
actual = self._compute_total_size()
573+
with self._size_lock:
574+
self._tracked_size_bytes = actual
516575

517576
# -- internals -----------------------------------------------------------
518577

519578
def _iter_entry_paths(self) -> Iterable[Path]:
520-
if not self._entries.exists():
579+
# ``os.scandir`` returns ``DirEntry`` objects whose ``is_dir`` /
580+
# ``is_file`` methods consult the cached dirent type from the
581+
# ``readdir`` result on filesystems that report it (ext4, NTFS, ...),
582+
# avoiding a per-entry ``stat`` syscall. ``Path.iterdir`` also wraps
583+
# ``scandir`` but discards the cached type, forcing a separate
584+
# ``stat`` for every ``Path.is_dir`` / ``Path.is_file`` -- a real
585+
# cost on caches with thousands of entries. ``follow_symlinks=False``
586+
# matches the prior behaviour: ``pathlib`` defaults to following,
587+
# but our cache layout never creates symlinks, so the choice is
588+
# only visible if a user manually points one inside ``entries/``;
589+
# treating it as the file/dir it points to is the surprising
590+
# outcome, so we don't.
591+
try:
592+
outer = os.scandir(self._entries)
593+
except FileNotFoundError:
521594
return
522-
for sub in self._entries.iterdir():
523-
if not sub.is_dir():
595+
with outer:
596+
for sub in outer:
597+
if not sub.is_dir(follow_symlinks=False):
598+
continue
599+
try:
600+
inner = os.scandir(sub.path)
601+
except FileNotFoundError:
602+
continue
603+
with inner:
604+
for entry in inner:
605+
if entry.is_file(follow_symlinks=False):
606+
yield Path(entry.path)
607+
608+
def _compute_total_size(self) -> int:
609+
"""Walk ``entries/`` + ``tmp/`` and return the on-disk byte total.
610+
611+
Used to seed the tracker at open time and to refresh it after every
612+
eviction pass. Best-effort: files that vanish under us during the
613+
walk (concurrent eviction by this or another process) are skipped.
614+
Tracked total may briefly differ from this scan's result under
615+
cross-process contention; the next eviction will reconcile.
616+
"""
617+
total = 0
618+
for path in self._iter_entry_paths():
619+
try:
620+
total += path.stat().st_size
621+
except FileNotFoundError:
524622
continue
525-
for entry in sub.iterdir():
526-
if entry.is_file():
527-
yield entry
623+
if self._tmp.exists():
624+
for tmp in self._tmp.iterdir():
625+
if not tmp.is_file():
626+
continue
627+
try:
628+
total += tmp.stat().st_size
629+
except FileNotFoundError:
630+
continue
631+
return total
528632

529633
def _sweep_stale_tmp_files(self) -> None:
530634
"""Remove temp files left behind by crashed writers.
@@ -587,11 +691,18 @@ def _enforce_size_cap(self) -> None:
587691
except FileNotFoundError:
588692
continue
589693
if total <= self._max_size_bytes:
694+
# Re-seed the tracker from the scan: catches drift from
695+
# cross-process writers/deleters that the per-write delta
696+
# accounting wouldn't have observed. Reaching here means the
697+
# tracker was over-cap but the disk truth is under-cap, so
698+
# this assignment is the cheapest reconciliation point we get.
699+
with self._size_lock:
700+
self._tracked_size_bytes = total
590701
return
591702
entries.sort(key=lambda e: e[0]) # oldest atime first
592703
for _atime, size, path, st_before in entries:
593704
if total <= self._max_size_bytes:
594-
return
705+
break
595706
# _prune_if_stat_unchanged refuses if a writer replaced the file
596707
# between snapshot and now, so eviction can't silently delete a
597708
# freshly-committed entry from another process.
@@ -620,3 +731,8 @@ def _enforce_size_cap(self) -> None:
620731
except PermissionError as exc:
621732
if not _is_windows_sharing_violation(exc):
622733
raise
734+
# Reconcile: after the eviction pass, ``total`` reflects what we
735+
# believe the disk now holds. Re-seed the tracker so the next write
736+
# accumulates from a fresh baseline.
737+
with self._size_lock:
738+
self._tracked_size_bytes = total

cuda_core/tests/test_program_cache.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1909,6 +1909,80 @@ def test_filestream_cache_unbounded_by_default(tmp_path):
19091909
assert len(cache) == 20
19101910

19111911

1912+
def test_filestream_cache_writes_skip_scan_when_under_cap(tmp_path, monkeypatch):
1913+
"""An incremental size tracker keeps writes O(1) while under the cap.
1914+
Counter-test against a regression that re-introduces a per-write disk
1915+
walk: if every write triggers the eviction scan, the running total has
1916+
to be recomputed for entries we never modified, and a cache with
1917+
thousands of files gets quadratic. Writes that stay under
1918+
``max_size_bytes`` must not call ``_enforce_size_cap`` at all."""
1919+
from cuda.core.utils import FileStreamProgramCache
1920+
1921+
with FileStreamProgramCache(tmp_path / "fc", max_size_bytes=1_000_000) as cache:
1922+
# Seed one entry so the tracker has a non-zero starting point.
1923+
cache[b"k0"] = b"x" * 100
1924+
1925+
calls: list[int] = []
1926+
original = cache._enforce_size_cap
1927+
monkeypatch.setattr(
1928+
cache,
1929+
"_enforce_size_cap",
1930+
lambda: (calls.append(1), original())[1],
1931+
)
1932+
1933+
# 100 small writes well under the 1MB cap -- none should trigger
1934+
# the scan-heavy enforcement path.
1935+
for i in range(1, 101):
1936+
cache[f"k{i}".encode()] = b"y" * 100
1937+
assert calls == [], f"_enforce_size_cap called {len(calls)} times under cap"
1938+
1939+
1940+
def test_filestream_cache_writes_trigger_scan_when_over_cap(tmp_path, monkeypatch):
1941+
"""Counterpart to ``test_filestream_cache_writes_skip_scan_when_under_cap``:
1942+
once the tracker reports we crossed the cap, the eviction scan must
1943+
fire so the cache actually enforces its bound."""
1944+
from cuda.core.utils import FileStreamProgramCache
1945+
1946+
with FileStreamProgramCache(tmp_path / "fc", max_size_bytes=300) as cache:
1947+
calls: list[int] = []
1948+
original = cache._enforce_size_cap
1949+
monkeypatch.setattr(
1950+
cache,
1951+
"_enforce_size_cap",
1952+
lambda: (calls.append(1), original())[1],
1953+
)
1954+
1955+
# 200 + 200 > 300 -> second write must trigger enforcement.
1956+
cache[b"a"] = b"a" * 200
1957+
cache[b"b"] = b"b" * 200
1958+
assert len(calls) >= 1
1959+
1960+
1961+
def test_filestream_cache_tracker_reconciles_after_external_drift(tmp_path):
1962+
"""If another writer (or this cache reopened) deletes entries on disk
1963+
out from under us, the running tracker overestimates the on-disk total.
1964+
The next eviction pass walks the directory and re-seeds the tracker
1965+
from reality, so the overestimate self-corrects rather than growing
1966+
without bound and triggering bogus evictions every write thereafter."""
1967+
from cuda.core.utils import FileStreamProgramCache
1968+
1969+
with FileStreamProgramCache(tmp_path / "fc", max_size_bytes=1000) as cache:
1970+
cache[b"a"] = b"A" * 400
1971+
cache[b"b"] = b"B" * 400
1972+
assert cache._tracked_size_bytes == 800
1973+
# Simulate an external deleter (another process, manual rm,
1974+
# filesystem-level pruning script): unlink an entry behind the
1975+
# cache's back so the tracker is now stale-high.
1976+
path_a = cache._path_for_key(b"a")
1977+
path_a.unlink()
1978+
# Tracker still believes 800 bytes are on disk; trigger an
1979+
# eviction by writing one more entry that pushes the tracker
1980+
# above the cap. The scan should observe ~400 bytes (only 'b'
1981+
# remains plus the new write) and reset the tracker to that.
1982+
cache[b"c"] = b"C" * 700 # tracker becomes 800 + 700 = 1500 > cap
1983+
assert cache._tracked_size_bytes <= 1100 # actual on-disk is 'b' + 'c' or just 'c'
1984+
1985+
19121986
def test_make_program_cache_key_changes_with_key_schema_version(monkeypatch):
19131987
"""Bumping ``_KEY_SCHEMA_VERSION`` produces a different cache key for
19141988
the same logical inputs. That's what makes a schema bump invalidate

0 commit comments

Comments
 (0)