Skip to content

perf(semantic-dedup): bound parquet reads and memory - #2367

Merged
thomasdhc merged 14 commits into
NVIDIA-NeMo:mainfrom
praateekmahajan:codex/semdedup-bounded-read-memory
Sep 3, 2026
Merged

thomasdhc merged 14 commits into
NVIDIA-NeMo:mainfrom
praateekmahajan:codex/semdedup-bounded-read-memory

Conversation

@praateekmahajan

@praateekmahajan praateekmahajan commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Read Parquet footer metadata to group files by actual embedding element counts before loading them with cuDF.
  • Preallocate KMeans and pairwise embedding arrays from exact row and element counts.
  • When fit_data_fraction=None, select complete fit files within a budget derived from currently available GPU memory. Explicit fractions retain file-level sampling.
  • Predict labels for every input row with KMeansMG.predict, including rows not selected for fitting.
  • Remove embedding_dim from the semantic-deduplication API, benchmarks, tutorials, and documentation.
  • Keep Parquet writes synchronous; asynchronous writes are scoped to perf(semantic-dedup): overlap parquet writes #2361.

Behavior and constraints

Read groups stay below cuDF's embedding-column element limit. Files are kept intact, so each individual Parquet file must itself fit below that limit.

Benchmark results

EOS benchmark on 8× H100 GPUs. Baseline: main at 07920606; PR measurement: aa7529ea. The current head only simplifies the JSONL fallback and benchmark reporting; the measured Parquet path is unchanged.

20% dataset, full fit — 65.8M rows

Open the run comparison

Metric Main PR Change
End-to-end workflow 218.2 s 199.5 s 18.7 s faster (−8.6%)
KMeans stage 138.0 s 125.5 s 12.5 s faster (−9.1%)
KMeans actor processing 105.4 s 94.3 s 11.1 s faster (−10.5%)
KMeans read 33.9 s 24.2 s 9.7 s faster (−28.7%)
KMeans fit + predict 38.5 s 35.3 s 3.2 s faster (−8.2%)
KMeans output 33.0 s 22.6 s 10.4 s faster (−31.6%)
Pairwise stage 80.2 s 74.0 s 6.2 s faster (−7.8%)
Run-average GPU utilization 28.3% 31.3% +3.0 pp
Run-average GPU memory 27.8% 19.3% −8.5 pp (−30.6%)
Peak GPU memory 82.9% 82.4% Flat (−0.5 pp)

The steady KMeans fit plateau drops from about 66% to 32% GPU memory, showing that exact preallocation removes the previous duplicate full embedding buffer. The similar absolute peak occurs later in distance calculation and partitioned Parquet output.

Full dataset, 20% fit — 329.8M rows

Open the run comparison

Metric Main PR Change
End-to-end workflow 20m 54s 18m 37s 2m 17s faster (−11.0%)
KMeans stage 7m 43s 6m 13s 1m 30s faster (−19.4%)
KMeans actor processing 414.2 s 316.7 s 97.5 s faster (−23.5%)
KMeans read 201.3 s 156.0 s 45.3 s faster (−22.5%)
KMeans fit 40.2 s 35.0 s 5.2 s faster (−12.9%)
KMeans predict + output 172.6 s 110.1 s 62.5 s faster (−36.2%)
Pairwise stage 13m 11s 12m 23s 47.6 s faster (−6.0%)
Run-average GPU utilization 48.2% 55.9% +7.8 pp
Run-average GPU memory 33.1% 14.3% −18.8 pp (−56.8%)
Peak GPU memory 58.5% 78.0% +19.5 pp

The partial-fit run uses substantially less memory on average and keeps GPUs busier, but its instantaneous peak is higher during output. Reducing that output-path peak is intentionally scoped to the asynchronous/bounded writer work in #2361.

Validation

  • Semantic KMeans, pairwise, and utility GPU tests: 55 passed, 1 skipped
  • Pre-commit hooks

Signed-off-by: Praateek <praateekm@gmail.com>
@copy-pr-bot

copy-pr-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Signed-off-by: Praateek <praateekm@gmail.com>
Signed-off-by: Praateek <praateekm@gmail.com>
Signed-off-by: Praateek <praateekm@gmail.com>
Signed-off-by: Praateek <praateekm@gmail.com>
Signed-off-by: Praateek <praateekm@gmail.com>
Signed-off-by: Praateek <praateekm@gmail.com>
@praateekmahajan

Copy link
Copy Markdown
Contributor Author

/claude review
@greptileai review

@praateekmahajan

Copy link
Copy Markdown
Contributor Author

/ok to test d25112c

Signed-off-by: Praateek <praateekm@gmail.com>
@praateekmahajan

Copy link
Copy Markdown
Contributor Author

/ok to test 05fd74f

Signed-off-by: Praateek <praateekm@gmail.com>
return self._process_batch_two_pass(tasks, groups)
return self._process_batch_single_pass(tasks, groups)
return self._process_batch_two_pass(tasks, all_files)
return self._process_batch_single_pass(tasks, all_files)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why we are keeping both of _process_batch_two_pass and _process_batch_single_pass if the new behavior defaults to "however much fits in 60% of GPU memory" (if I am understanding correctly)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it only because of Parquet vs JSONL?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes - these are now exclusively the JSONL paths. None preserves JSONL’s full single-pass behavior (because we can't infer the number of rows without reading the files), while an explicit fraction requires two passes. I’ll rename them to make that distinction obvious.

gc.collect()
cp.get_default_memory_pool().free_all_blocks()

if remaining_info:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit but to me remaining_info wasn't clear that it is "the remaining data that we didn't fit with", could we rename and/or add comments about that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prediction_only_info sounds good?

Comment on lines +174 to +181
fit_frames = iter(self._iter_parquet_frames(fit_info, columns))
first_fit_frame = next(fit_frames)
embedding_width = get_array_from_df(first_fit_frame, self.embedding_field).shape[1]
fit_embeddings = cp.empty((fit_rows, embedding_width), dtype=cp.float32)
sampled_chunks: list[tuple[cudf.DataFrame, int, int]] = []
read_time = 0.0
offset = 0
read_start = time.perf_counter()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the read starts at first_fit_frame = ...? Shouldn't this timer be up there?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! It misses that one read.

stop = offset + len(df)
embeddings = fit_embeddings[offset:stop]
embeddings[...] = get_array_from_df(df, self.embedding_field)
embeddings /= cp.linalg.norm(embeddings, axis=1, keepdims=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry why don't we want normalize_embeddings_col_in_df here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That helper creates another full normalized Torch array and assigns it back into cuDF. Whereas the current proposed path copies into the exactly allocated CuPy fit buffer and normalizes each slice in place, which is what removes the ~2× fit-memory plateau.
I'll see if we can get rid of the normalize_embeddings_col_in_df or rather put this code there

read_start = time.perf_counter()
read_time += time.perf_counter() - read_start

del self.kmeans

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we delete here? Should we do it for the JSONL path too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually no need to remove it, because the actor pool anyway dies after this. So can remove it

# Default aggressive assumption of 1024 dimensional embedding
embedding_dim = 1024
def _root_column(path: str | list[str]) -> str:
return path[0] if isinstance(path, list) else path.split(".", maxsplit=1)[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't this fail if a real column actually has a dot in it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, also TBH i don't find this code super clear, so will update it to be clearer

max_files_per_subgroup = max(1, max_files_per_subgroup) # Ensure at least 1 file per subgroup
retained = set(retained_columns or [])
try:
if storage_options or get_protocol(files[0]) != "file":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious have you done an S3 benchmark?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope sadly not

column.meta_data.total_uncompressed_size
for row_group in footer.row_groups
for column in row_group.columns
if _root_column(column.meta_data.path_in_schema) in retained

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit but it does seem like we could reduce read_parquet_file_info's complexity here? Like maybe I am reading it wrong but the _root_column logic here and in the previous code block are both running the same logic right (in terms of nested for loops and if blocks)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, sadly we use fsspec for remote and pylibcudf for local which both differ in the metadata's object that they return. So math might be the same however attribute handling still remains different

if _root_column(column.meta_data.path_in_schema) in retained
)
embedding_elements = sum(
column.meta_data.num_values

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not too familiar with what is the difference between using column.meta_data.num_values here and column.num_values above?

@praateekmahajan praateekmahajan Sep 3, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, different metadata objects, PyArrow returns ColumnChunkMetaData directly; pylibcudf returns a ColumnChunk whose metadata is under .meta_data, can rename them though

result.append(ParquetFileInfo(path, footer.num_rows, metadata_bytes, embedding_elements))
return result # noqa: TRY300
except Exception as error:
msg = f"Failed to read Parquet footer metadata for one of {len(files)} files"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we try to say which file(s) failed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The batch might have 64 files which is too many to report, it might be an overkill to find which of the 64 failed. lmk what you think

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I guess not. I am just wondering about when this error is raised, what is a good way for the user to figure out how to fix their file(s) and/or file(s) to exclude in the next run?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today we have this issue even with parquet reads, a corrupt file doesn't share which file caused the failure. So I think we can let it be, added a TODO tho in case we run into it

Signed-off-by: Praateek <praateekm@gmail.com>
@praateekmahajan

Copy link
Copy Markdown
Contributor Author

/claude review
@greptileai review

result.append(ParquetFileInfo(path, footer.num_rows, metadata_bytes, embedding_elements))
return result # noqa: TRY300
except Exception as error:
msg = f"Failed to read Parquet footer metadata for one of {len(files)} files"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I guess not. I am just wondering about when this error is raised, what is a good way for the user to figure out how to fix their file(s) and/or file(s) to exclude in the next run?



@pytest.mark.gpu
def test_parquet_file_info_uses_fsspec_for_remote_uri_without_storage_options() -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering if we can remove the GPU coupling here yet or not? Even though it is used for semantic dedup only, it is still IO.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea we have a todo below for TODO : Remove this once we figure out how to import semantic on CPU

Maybe I should make it top-level..

The whole imports cause this issue, and would prefer not to resolve that in this PR

Signed-off-by: Praateek <praateekm@gmail.com>
Signed-off-by: Praateek <praateekm@gmail.com>
subgroups = [files[i : i + max_files_per_subgroup] for i in range(0, len(files), max_files_per_subgroup)]
retained = set(retained_columns or [])
try:
if storage_options or get_protocol(files[0]) != "file":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Mixed URI footers bypass fsspec

When a list-valued input combines local and remote Parquet locations without storage options and a local file comes first, this condition selects the pylibcudf backend for the entire actor batch. Later remote files are then passed to SourceInfo instead of fsspec, causing the mandatory footer scan to fail before KMeans runs.

This branch was previously deployed

3 inactive deployments
public — 953e235f Deployed Sep 3, 2026 by copy-pr-bot[bot] via release / finalize / notify #971
nemo-ci — 953e235f Deployed Sep 3, 2026 by copy-pr-bot[bot] via L0_Unit_Test_GPU-image #5415
test — 953e235f Deployed Sep 3, 2026 by copy-pr-bot[bot] via cicd-wait-in-queue #5415
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants