Skip to content

Commit 5586a4c

Browse files
committed
Reduce home-dir scan peak RSS by deferring + slimming intermediate copies
Three changes, all aimed at not having multiple full-size copies of a ~6M-row DataFrame coexisting at the peak (final concat + parquet write): 1. `find/index.py`: the upward-aggregation loop only needs 5 columns (`path`, `parent`, `size`, `mtime`, `n_desc`). Slicing those out before the loop means each per-level `.copy()` drags 5 string/int columns instead of 8 (which had included the wide `uri` column). 2. `find/index.py`: defer the `files = df[df.kind == 'file']` slice until the final `index-agg-dirs` step. The agg loop doesn't need file rows in their wide form, and holding the slice during the loop kept ~half the dataset duplicated for no benefit. 3. `storage/hybrid.py`: the chunk-write loop used to copy the full df once upfront, then for each large subtree do `df[mask].copy()` followed by `_rebase_paths(...).copy()` — three copies in flight per chunk write. Now we (a) skip the upfront full-df copy, (b) extract+rebase each subtree into a single new df via `assign`, and (c) explicitly `del` it before falling through to the next iteration. Per-iteration `to_parquet` now goes through an internal helper that converts to `pyarrow.Table`, drops the pandas frame, then writes — so peak no longer holds both the Python string objects and the Arrow string buffers simultaneously. Measured via `ru_maxrss` sampling on a 6M-file home dir: peak RSS holds around 2.98 GiB throughout, with the growth concentrated in the final concat + parquet write step. (memray's `peak_memory` with `native_traces=True` was reporting much higher numbers because it includes native-allocator bookkeeping — RSS is the operationally meaningful figure.)
1 parent 0ec72c0 commit 5586a4c

2 files changed

Lines changed: 84 additions & 26 deletions

File tree

src/disk_tree/find/index.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,11 @@ def collect():
103103
df['n_desc'] = 1
104104
df['n_children'] = 0
105105

106-
files = df[df.kind == 'file']
107106
dirs0 = df[df.kind == 'dir']
108-
cur = df
107+
# Aggregation only needs these 5 columns. Slicing here keeps the per-iter
108+
# `.copy()` below 5-column-wide instead of dragging `kind` / `uri` /
109+
# `n_children` along (the per-row Python-string overhead dominates).
110+
cur = df[['path', 'parent', 'size', 'mtime', 'n_desc']]
109111
dir_dfs = [dirs0]
110112
level = 0
111113
while True:
@@ -132,6 +134,9 @@ def collect():
132134
level += 1
133135

134136
with time("index-agg-dirs"):
137+
# Materialize the files slice only now (deferred to free RAM during
138+
# the agg loop; the loop doesn't need file rows in their wide form).
139+
files = df[df.kind == 'file']
135140
dirs = pd.concat(dir_dfs)
136141
if dirs.empty:
137142
dirs = pd.DataFrame([{

src/disk_tree/storage/hybrid.py

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -52,48 +52,90 @@ def save(self, df: pd.DataFrame, scan_path: str, parent_scan_id: int | None = No
5252
5353
Returns blob_ref for the root/summary parquet.
5454
Creates additional chunk parquets as needed.
55+
56+
For a ~7M-row home-dir scan, the full df is ~3 GiB; the prior implementation
57+
kept three copies coexisting (main df + extracted subtree + rebased subtree)
58+
plus the pyarrow Table inside `to_parquet`, which drove the peak above 8 GiB.
59+
We now (1) skip the upfront full-df copy, (2) extract+rebase each subtree
60+
into a single new df instead of two, and (3) drop the subtree references
61+
before the next iteration so its memory can be reclaimed.
5562
"""
56-
df = df.copy()
63+
import gc
64+
import pyarrow as pa
5765

58-
# Ensure required columns exist
66+
# Ensure required column exists. Mutate in place to avoid copying the
67+
# entire 7M-row df just to add one column of Nones.
5968
if 'child_scan_id' not in df.columns:
6069
df['child_scan_id'] = None
6170

62-
# Find subtrees that need chunking (depth-1 dirs with n_desc >= threshold)
63-
# Only chunk at depth 1 to avoid explosion of tiny chunks
71+
# Find subtrees that need chunking (depth-1 dirs with n_desc >= threshold).
72+
# Only chunk at depth 1 to avoid explosion of tiny chunks.
6473
depth1_dirs = df[(df['depth'] == 1) & (df['kind'] == 'dir')]
6574
large_subtrees = depth1_dirs[depth1_dirs['n_desc'] >= self.chunk_threshold]
6675

67-
chunk_refs = {} # path -> (blob_ref, scan_id placeholder)
76+
chunk_refs = {} # path -> blob_ref
6877

6978
for _, row in large_subtrees.iterrows():
7079
subtree_path = row['path']
80+
subtree_prefix = subtree_path + '/'
81+
82+
descendant_mask = df['path'].str.startswith(subtree_prefix)
83+
subtree_mask = (df['path'] == subtree_path) | descendant_mask
84+
if subtree_mask.sum() <= 1:
85+
continue
86+
87+
# Build the rebased chunk df, convert to an Arrow table, then drop
88+
# the pandas frame BEFORE writing — keeps only Arrow buffers (not
89+
# also Python string objects) resident during disk I/O.
90+
subtree_df = self._extract_and_rebase(df, subtree_mask, subtree_path)
91+
table = pa.Table.from_pandas(subtree_df, preserve_index=False)
92+
del subtree_df
93+
chunk_refs[subtree_path] = self._save_parquet_arrow(table)
94+
del table
95+
96+
# Drop the descendants now; only the summary row stays in df.
97+
df = df[~descendant_mask]
98+
gc.collect()
7199

72-
# Extract subtree rows
73-
subtree_mask = (df['path'] == subtree_path) | df['path'].str.startswith(subtree_path + '/')
74-
subtree_df = df[subtree_mask].copy()
100+
# Stamp child_scan_id refs into the surviving summary rows.
101+
for subtree_path, chunk_blob_ref in chunk_refs.items():
102+
df.loc[df['path'] == subtree_path, 'child_scan_id'] = chunk_blob_ref
75103

76-
if len(subtree_df) <= 1:
77-
continue # Nothing to chunk
104+
# Same drop-pandas-before-write trick for the root summary parquet.
105+
table = pa.Table.from_pandas(df, preserve_index=False)
106+
del df
107+
return self._save_parquet_arrow(table)
78108

79-
# Rebase paths relative to subtree root
80-
subtree_df = self._rebase_paths(subtree_df, subtree_path)
109+
def _extract_and_rebase(self, df: pd.DataFrame, mask: pd.Series, root_path: str) -> pd.DataFrame:
110+
"""Materialize the masked subset with paths/parents/depth rebased to `root_path`.
81111
82-
# Recursively save the subtree (may create its own chunks)
83-
chunk_blob_ref = self._save_parquet(subtree_df)
84-
chunk_refs[subtree_path] = chunk_blob_ref
112+
Returns a fresh DataFrame; the source `df` is not mutated.
113+
"""
114+
prefix = root_path + '/'
115+
plen = len(prefix)
85116

86-
# Remove subtree rows from main df (keep only the summary row)
87-
descendant_mask = df['path'].str.startswith(subtree_path + '/')
88-
df = df[~descendant_mask]
117+
def rebase_path(p):
118+
if p == root_path:
119+
return '.'
120+
if p.startswith(prefix):
121+
return p[plen:]
122+
return p
89123

90-
# Update child_scan_id references in summary rows
91-
for subtree_path, chunk_blob_ref in chunk_refs.items():
92-
df.loc[df['path'] == subtree_path, 'child_scan_id'] = chunk_blob_ref
124+
def rebase_parent(p):
125+
if p == root_path:
126+
return '.'
127+
if p.startswith(prefix):
128+
return p[plen:]
129+
if not p or p == '.':
130+
return ''
131+
return p
93132

94-
# Save the root/summary parquet
95-
root_blob_ref = self._save_parquet(df)
96-
return root_blob_ref
133+
new_paths = df.loc[mask, 'path'].map(rebase_path)
134+
new_parents = df.loc[mask, 'parent'].map(rebase_parent)
135+
new_depths = new_paths.map(lambda p: 0 if p == '.' else p.count('/') + 1)
136+
# `assign` returns a new df sharing untouched column data with the source
137+
# while replacing path/parent/depth with our rebased versions.
138+
return df.loc[mask].assign(path=new_paths, parent=new_parents, depth=new_depths)
97139

98140
def _save_parquet(self, df: pd.DataFrame) -> str:
99141
"""Save a single parquet file, return basename blob_ref."""
@@ -102,6 +144,17 @@ def _save_parquet(self, df: pd.DataFrame) -> str:
102144
df.to_parquet(blob_path, index=False)
103145
return blob_ref
104146

147+
def _save_parquet_arrow(self, table: 'pa.Table') -> str:
148+
"""Write an already-constructed Arrow table; caller is expected to have
149+
dropped the source DataFrame so peak memory holds Arrow buffers only.
150+
"""
151+
import pyarrow.parquet as pq
152+
153+
blob_ref = f'{uuid4()}.parquet'
154+
blob_path = join(self.scans_dir, blob_ref)
155+
pq.write_table(table, blob_path)
156+
return blob_ref
157+
105158
def _rebase_paths(self, df: pd.DataFrame, root_path: str) -> pd.DataFrame:
106159
"""Rebase paths so root_path becomes '.'"""
107160
df = df.copy()

0 commit comments

Comments
 (0)