Single-pass MemoryFileSystem.find() to avoid quadratic listing - #2055
Conversation
cd585c6 to
38d9992
Compare
|
Your implementation is probably fine (I haven't looked in detail yet), but is there really a usecase for >>1000 files in a memoryFS? |
| dirs[parent] = {"name": parent, "size": 0, "type": "directory"} | ||
| idx = parent.rfind("/") | ||
|
|
||
| for name, filelike in self.store.items(): |
There was a problem hiding this comment.
Could this snapshot self.store.items() before iterating? store is global across all MemoryFileSystem instances, so a concurrent create/delete now raises RuntimeError: dictionary changed size during iteration; ls() avoids that by iterating over tuple(self.store).
My impression was just that if you use memory:// as the backend for something chunked, like a zarr array where every chunk is its own key, a 200x200 grid is already 40k keys, and then glob() and du() pay the same cost since they go through find(). below ~1000 paths it is sub-millisecond and nobody would notice An assumption about how people could use memory:// though, I can close it tbh. |
MemoryFileSystem inherited the generic AbstractFileSystem.find(), which walks the tree calling ls() once per directory. Each ls() scans the whole global store, so listing a tree is O(n_dirs * n_entries). Override find() with a single pass over the flat store, producing output identical to the generic implementation across roots, maxdepth, withdirs and detail. On a 10k-file / 200-directory tree this is ~20x faster and the gain grows with the tree size; find(), du(), expand_path() and glob() all benefit since they delegate to find(). Add a differential test against AbstractFileSystem.find() and a regression test asserting find() no longer calls ls() per directory.
`store` is a class attribute shared by every MemoryFileSystem instance, so a concurrent create or delete lands mid-iteration and raises "dictionary changed size during iteration". `ls()` already guards against this by iterating over `tuple(self.store)`; `find()` now does the same.
38d9992 to
6560a0a
Compare
zarr should never need to list a chunk directory, the chunk names are deterministic. Maybe you have a point, though, if listing the top-level directories, which would be the array containing group level. |
What
Give
MemoryFileSystema single-passfind()so that listing a tree is linear in the number of stored paths instead of quadratic.The problem
MemoryFileSystemkeeps every path in one flat dict (self.store). The inheritedAbstractFileSystem.find()walks the tree by callingls()once per directory, andMemoryFileSystem.ls()scans the entire store on every call:So
find()over a tree withDdirectories andNstored paths doesO(D * N)work. For a store with many directories this dominates anything built onfind().The fix
Override
find()onMemoryFileSystemwith one pass over the flat store (the store already contains every path, so no per-directory recursion is needed). The override reproduces the base semantics exactly:maxdepth,withdirs(including implied ancestor directories and explicitly-created emptypseudo_dirs),detail, the file-as-path case, and inclusion of the search root itself whenwithdirsis set. It is additive and touches no existing code path.This speeds up everything that delegates to
find():find,du,glob, andexpand_path.walk()is left unchanged (it still callsls()per directory and remainsO(D * N)); it could get the same treatment in a follow-up if wanted.Correctness
A new test,
test_find_does_not_scan_per_directory, asserts the override never callsls()(so the quadratic factor is gone), andtest_find_matches_genericchecks the override against the genericAbstractFileSystem.find()for equality across the full matrix of roots (including"", a file path, and a missing path),maxdepth in {None, 1, 2, 3},withdirs, anddetail. Comparing against the base implementation directly means this test also guards against future drift: if the genericfind()semantics change, the test fails.The original development run also diff-tested the override against the base across 13,560 randomized configurations with zero mismatches.
Performance
MemoryFileSystem.find("/data"), old genericls()-per-directory vs the single-pass override, 50 files per directory, outputs verified identical:Old time grows quadratically, new time grows linearly, so the gap widens with the number of directories.
Notes
docs/source/changelog.rsthas an entry under a newDevsection; the#XXXXPR reference will be filled in once this PR has a number.