Repository navigation
Conversation
Signed-off-by: Praateek <praateekm@gmail.com>
|
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. |
|
🌿 Preview your docs: https://nvidia-preview-codex-semdedup-bounded-read-memory.docs.buildwithfern.com/nemo/curator Here are the markdown pages you've updated: |
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>
|
/claude review |
|
/ok to test d25112c |
Signed-off-by: Praateek <praateekm@gmail.com>
|
/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) |
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
Is it only because of Parquet vs JSONL?
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
prediction_only_info sounds good?
| 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() |
There was a problem hiding this comment.
I thought the read starts at first_fit_frame = ...? Shouldn't this timer be up there?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Sorry why don't we want normalize_embeddings_col_in_df here?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Why do we delete here? Should we do it for the JSONL path too?
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
Couldn't this fail if a real column actually has a dot in it?
There was a problem hiding this comment.
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": |
There was a problem hiding this comment.
Curious have you done an S3 benchmark?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Not too familiar with what is the difference between using column.meta_data.num_values here and column.num_values above?
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
Should we try to say which file(s) failed?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>
|
/claude 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" |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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": |
There was a problem hiding this comment.
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.
Summary
fit_data_fraction=None, select complete fit files within a budget derived from currently available GPU memory. Explicit fractions retain file-level sampling.KMeansMG.predict, including rows not selected for fitting.embedding_dimfrom the semantic-deduplication API, benchmarks, tutorials, and documentation.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
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
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