Skip to content

perf: use PyArrow fast path for JSONL reads - #2325

Open
praateekmahajan wants to merge 10 commits into
NVIDIA-NeMo:mainfrom
praateekmahajan:codex/pr2303-jsonl-arrow
Open

perf: use PyArrow fast path for JSONL reads#2325
praateekmahajan wants to merge 10 commits into
NVIDIA-NeMo:mainfrom
praateekmahajan:codex/pr2303-jsonl-arrow

Conversation

@praateekmahajan

@praateekmahajan praateekmahajan commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What changed

This PR makes direct PyArrow parsing the default for JSONL files and keeps the result as a pa.Table until a stage actually needs pandas.

Reader-side _curator_dedup_id generation and assignment now work with both Arrow tables and pandas DataFrames. As a result, LanceReader now supports _generate_ids and _assign_ids without converting its Arrow output to pandas first.

When pandas is needed, DocumentBatch.to_pandas() handles the conversion and preserves Arrow-backed string columns.

Choosing an engine

JSONL reads use direct PyArrow parsing by default. Callers can select engine="pandas" when they need pandas-specific parsing or type inference.

The two engines do not always infer identical types. For example, pandas may interpret an ISO created_at value as a timezone-aware datetime while PyArrow retains it as a string. Mixed-type columns are another situation where the pandas engine may be the better fit.

The PyArrow path continues to support remote files through fsspec. It normally reads with an 8 MiB block. If PyArrow reports that a single JSONL record straddles the block boundary, the reader retries with progressively larger blocks so that it can read the complete row. The 256 MiB maximum is a safety ceiling for unusually large individual records, such as rows containing a base64-encoded image or PDF; it is not the default amount read for every row.

Lance and reader IDs

Because ID handling lives in BaseReader, Arrow-backed Lance reads now support _generate_ids and _assign_ids too.

Lance tasks use a stable identity based on the dataset path, version, and fragment IDs. File readers retain their existing path-based registry keys for backward compatibility; NMCUR-315 tracks moving them to deterministic task IDs as well.

Compatibility

This work is based directly on main, including RAPIDS 26.08 and pandas 3 from #2303. PyArrow remains at 19.0.1 under the current dependency constraints.

Testing

The tests cover:

  • Direct PyArrow and explicit pandas JSONL reads
  • ID generation and assignment for Arrow and pandas
  • Large JSONL records and remote fsspec inputs
  • Expected pandas/PyArrow dtype differences
  • DocumentBatch Arrow-string conversion
  • Lance ID generation and assignment
  • Related reader, writer, and dedup-removal workflows

Benchmarks

The table compares the latest completed main nightly (nightly-2026_08_20__06_39_17_UTC) with this PR (pr-2325-2026_08_21__00_25_05_UTC-519ad6a9). Reader time is the mean jsonl_reader_process_time per task. Lower is better.

Benchmark Mean JSONL reader time, nightly → PR E2E runtime change
domain_classification_raydata 1.244s → 0.324s (74.0% faster) 0.8% slower
domain_classification_xenna 1.357s → 0.416s (69.4% faster) 7.5% faster
embedding_generation_raydata 0.183s → 0.048s (73.7% faster) 0.7% faster
embedding_generation_xenna 0.212s → 0.054s (74.4% faster) 0.2% faster
dedup_removal_raydata 49.02s → 9.05s (81.5% faster) 46.9% faster
dedup_removal_xenna 33.62s → 9.83s (70.8% faster) 68.0% faster
score_filter_raydata 0.333s → 0.079s (76.3% faster) 3.8% faster
score_filter_xenna 0.303s → 0.069s (77.3% faster) 2.0% slower
fasttext_filter_raydata 0.308s → 0.073s (76.4% faster) 0.8% faster
fasttext_filter_xenna 0.372s → 0.084s (77.3% faster) 5.8% faster
modifier_raydata 0.358s → 0.085s (76.1% faster) 3.2% faster
modifier_xenna 0.341s → 0.082s (75.9% faster) 4.7% faster
ndd_dynamo_dp8 0.175s → 0.046s (73.8% faster) 7.0% slower
ndd_ray_serve_dp8 0.168s → 0.046s (72.8% faster) <0.1% faster

Across all 14 entries, the median JSONL reader task is 75.2% faster and median E2E runtime improves by 2.0%. Looking only at the 12 entries where JSONL reading is not the dominant cost, median E2E runtime improves by 0.8%.

@copy-pr-bot

copy-pr-bot Bot commented Aug 20, 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 Aug 20, 2026

Copy link
Copy Markdown
Contributor

@praateekmahajan
praateekmahajan force-pushed the codex/pr2303-jsonl-arrow branch from 9f31d09 to b391189 Compare August 20, 2026 23:20
@praateekmahajan praateekmahajan changed the title perf: accelerate JSONL reading after pandas 3 bump perf: use PyArrow fast path for JSONL reads Aug 20, 2026
Comment thread nemo_curator/stages/text/io/reader/jsonl.py Outdated
Comment thread tests/stages/text/io/reader/test_jsonl.py
Comment thread nemo_curator/stages/text/io/reader/jsonl.py Outdated
@praateekmahajan
praateekmahajan marked this pull request as ready for review August 21, 2026 04:41
@praateekmahajan
praateekmahajan requested a review from a team as a code owner August 21, 2026 04:41
@praateekmahajan

Copy link
Copy Markdown
Contributor Author

@claude review

@ayushdg ayushdg left a comment

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.

Minor questions.

  1. Could you also add docstrings/update docs around the engine kwarg and the different options that exist

Comment thread nemo_curator/stages/text/io/reader/base.py Outdated
Comment thread nemo_curator/stages/text/io/reader/base.py Outdated
Comment on lines +34 to +35
DEFAULT_PYARROW_BLOCK_SIZE = 8 * 1024 * 1024
DEFAULT_PYARROW_MAX_BLOCK_SIZE = 256 * 1024 * 1024

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.

Can you explain what this do and how you chose them?

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.

TLDR is, pyarrow json reads one "block" (default of 1mb) of bytes at time. If a singular row is larger than that then it might error out. To my knowledge, pd.read_json follows a similar code path (with engine=pyarrow) but doesn't expose blocksize.
We do 8mb as default (assuming 8mb is enough for 1 row), however if we have pdf/image bytes in our jsonl row then it can explode to more, which is why we go upto 256 mb (to avoid the runtime error)

Comment on lines +72 to +75
except pa.ArrowInvalid as error:
if "straddling object" not in str(error) or block_size >= max_block_size:
raise
block_size = min(block_size * 2, max_block_size)

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.

In what case would we hit this? Could we end up in a loop where we try reading larger and larger block sizes when we run into this error?

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'm not sure I follow.

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.

Imagine a row, which is <8 mb, then we'll be able to read at first try. If not it'll double that, and goto 16mb and try reading again.. It'll stop at 256mb

read_options = paj.ReadOptions(block_size=block_size, use_threads=False)
if not is_remote_url(file_path) and not storage_options and compression == "infer":
return paj.read_json(file_path, read_options=read_options)
with fsspec.open(

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.

Pyarrow parquet supports remote IO, does jsonl as well?

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.

def _assign_ids_func(self, filepath: str | list[str], df: pd.DataFrame) -> pd.DataFrame:
@staticmethod
def _id_generator_key(task: ReaderTask) -> str | list[str]:
# TODO(NMCUR-315): Use the deterministic task ID for FileGroupTask as well.

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 can we not reference internal Linear issues as TODOs?

Comment thread nemo_curator/stages/text/io/reader/jsonl.py Outdated
Comment thread nemo_curator/stages/text/io/reader/jsonl.py Outdated
Comment thread nemo_curator/stages/text/io/reader/jsonl.py Outdated
Comment thread nemo_curator/stages/text/io/reader/jsonl.py Outdated
Comment thread nemo_curator/stages/text/io/reader/jsonl.py Outdated
Comment thread nemo_curator/stages/text/io/reader/jsonl.py Outdated
Comment thread fern/versions/main/pages/curate-text/load-data/read-existing.mdx Outdated
Comment thread fern/versions/main/pages/curate-text/load-data/read-existing.mdx Outdated
Comment thread fern/versions/main/pages/curate-text/load-data/read-existing.mdx Outdated
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>
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>
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