Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
17794d7
[PERF]: Avoid deep copies with GIL in pylibcudf read_parquet_footers
TomAugspurger Aug 5, 2026
ecfd5b6
Attempt to reduce GIL contention in Scan nodes.
TomAugspurger Aug 6, 2026
b3775f5
Option to skip page index materialization in parquet metadata
TomAugspurger Aug 6, 2026
f5525b5
Enable prefetching by default
TomAugspurger Aug 6, 2026
0053fb4
test style
TomAugspurger Aug 6, 2026
fd8ce79
Merge branch 'main' into tom/parquet-metadata-gil-perf
TomAugspurger Aug 7, 2026
3491475
prefetch only parquet types
TomAugspurger Aug 7, 2026
c076a1e
Executor-dependent default
TomAugspurger Aug 7, 2026
a8bb6fb
user option fixes
TomAugspurger Aug 7, 2026
5b6717f
fix
TomAugspurger Aug 7, 2026
14f7258
disable for use_rapidsmpf_native
TomAugspurger Aug 7, 2026
77f3793
merge conflict
Matt711 Aug 10, 2026
fe11cd9
Merge branch 'main' into tom/parquet-metadata-gil-perf
Matt711 Aug 11, 2026
aebf8a0
revert commit 3: dont read page indices
Matt711 Aug 11, 2026
f9490ac
move helper function from C++ to Cython (verbatim)
Matt711 Aug 11, 2026
5e864ac
properly handle unspecified prefetch meta in parquet options
Matt711 Aug 11, 2026
3c1d78b
turn on for cloud-only
Matt711 Aug 12, 2026
98fbdd8
Merge branch 'main' into tom/parquet-metadata-gil-perf
Matt711 Aug 12, 2026
93bccd7
pre-commit
Matt711 Aug 12, 2026
df0b4d4
remove copyright change
Matt711 Aug 12, 2026
8b7563a
Merge branch 'main' into tom/parquet-metadata-gil-perf
Matt711 Aug 12, 2026
ad20e44
default is Unspecified for prefetching with streaming engine
Matt711 Aug 13, 2026
74cda45
add a test for ParquetOptions w/o setting prefetching default
Matt711 Aug 13, 2026
0af3e32
fix tests because we fallback now if metadata is not prefetched
Matt711 Aug 13, 2026
4c55108
test remote_only=True
Matt711 Aug 13, 2026
071bd04
docs, small fixes
Matt711 Aug 13, 2026
ea351a6
revert unnecessary changes
Matt711 Aug 13, 2026
d84b230
remove copy_parquet_metadatas
Matt711 Aug 13, 2026
420469a
more doc strings fixes
Matt711 Aug 13, 2026
813740f
trigger github update
Matt711 Aug 13, 2026
b545203
Merge branch 'main' into tom/parquet-metadata-gil-perf
Matt711 Aug 13, 2026
ccb3154
Merge branch 'main' into tom/parquet-metadata-gil-perf
Matt711 Aug 13, 2026
cdb65fe
Merge branch 'main' into tom/parquet-metadata-gil-perf
Matt711 Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 2 additions & 11 deletions python/cudf_polars/cudf_polars/dsl/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -902,12 +902,7 @@ def _get_parquet_row_count_from_metadata(
) -> int:
# Zero-width parquet files lose their row count when read through
# pylibcudf. See https://github.com/rapidsai/cudf/issues/21428
if parquet_options.prefetch_file_metadata:
if cached_parquet_info is None:
raise AssertionError(
Comment thread
Matt711 marked this conversation as resolved.
"Cached parquet info is required when prefetching file metadata is enabled"
)

if cached_parquet_info is not None:
Scan._validate_cached_parquet_info(paths, cached_parquet_info)
parquet_metadatas = [
info.file_metadata for info in cached_parquet_info
Expand Down Expand Up @@ -1071,11 +1066,7 @@ def read_csv_header(
df,
)
elif typ == "parquet":
if parquet_options.prefetch_file_metadata:
if cached_parquet_info is None:
raise AssertionError(
"Cached parquet info is required when prefetching file metadata is enabled"
)
if cached_parquet_info is not None:
Scan._validate_cached_parquet_info(paths, cached_parquet_info)
filepath_sources = []
parquet_metadatas = []
Expand Down
15 changes: 13 additions & 2 deletions python/cudf_polars/cudf_polars/dsl/utils/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ def prefetch_parquet_file_metadata_for_ir(
root: IR,
py_executor: concurrent.futures.Executor | None,
stats: StatsCollector | None = None,
*,
remote_only: bool = False,
) -> dict[str, CachedParquetInfo]:
"""
Prefetch parquet metadata for all parquet scans in an IR graph.
Expand All @@ -125,6 +127,9 @@ def prefetch_parquet_file_metadata_for_ir(
prefetched during statistics collection, when the number of files
sampled equals the total number of files. Providing ``stats`` here will
skip rereading metadata for those files.
remote_only
If ``True``, only prefetch metadata for remote URIs (e.g. ``s3://``),
skipping local paths.

Returns
-------
Expand All @@ -135,7 +140,7 @@ def prefetch_parquet_file_metadata_for_ir(
all_paths: set[str] = set()

for node in traversal([root]):
if isinstance(node, StreamingScan):
if isinstance(node, StreamingScan) and node.base_scan.typ == "parquet":
for scan in node.scans:
for path in scan.paths:
all_paths.add(path)
Expand All @@ -155,6 +160,10 @@ def prefetch_parquet_file_metadata_for_ir(
cached_parquet_info[info.path] = info

missing_paths = all_paths - set(cached_parquet_info.keys())
if remote_only:
missing_paths = {
p for p in missing_paths if plc.io.SourceInfo._is_remote_uri(p)
}
cm: contextlib.AbstractContextManager[concurrent.futures.Executor | None]

if py_executor is None:
Expand Down Expand Up @@ -194,8 +203,10 @@ def attach_cached_parquet_metadata(
Mapping from file paths to cached parquet metadata.
"""
for node in traversal([root]):
if isinstance(node, StreamingScan):
if isinstance(node, StreamingScan) and node.base_scan.typ == "parquet":
for scan in node.scans:
if not all(path in cached_parquet_info_map for path in scan.paths):
continue
Comment thread
Matt711 marked this conversation as resolved.
cached = [cached_parquet_info_map[path] for path in scan.paths]
Scan._validate_cached_parquet_info(scan.paths, cached)
scan.cached_parquet_info = cached
Expand Down
6 changes: 4 additions & 2 deletions python/cudf_polars/cudf_polars/engine/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from cudf_polars.streaming.parallel import lower_ir_graph_with_node_map
from cudf_polars.streaming.statistics import collect_statistics
from cudf_polars.streaming.utils import _concat
from cudf_polars.utils.config import get_total_device_memory
from cudf_polars.utils.config import Unspecified, get_total_device_memory

if TYPE_CHECKING:
from collections.abc import Callable, MutableMapping
Expand Down Expand Up @@ -778,11 +778,13 @@ def evaluate_on_rank(
py_executor, get_cuda_stream=ctx.br().stream_pool.get_stream, query_id=query_id
)

if config_options.parquet_options.prefetch_file_metadata:
prefetch_file_metadata = config_options.parquet_options.prefetch_file_metadata
if prefetch_file_metadata is not False:
Comment thread
Matt711 marked this conversation as resolved.
cached_parquet_info_map = prefetch_parquet_file_metadata_for_ir(
ir,
ir_context.py_executor,
stats=stats,
remote_only=isinstance(prefetch_file_metadata, Unspecified),
Comment thread
Matt711 marked this conversation as resolved.
Comment thread
Matt711 marked this conversation as resolved.
)
attach_cached_parquet_metadata(ir, cached_parquet_info_map)

Expand Down
34 changes: 1 addition & 33 deletions python/cudf_polars/cudf_polars/engine/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from cudf_polars.engine.hardware_binding import (
HardwareBindingPolicy,
)
from cudf_polars.utils.config import MemoryResourceConfig
from cudf_polars.utils.config import UNSPECIFIED, MemoryResourceConfig, Unspecified

if TYPE_CHECKING:
from collections.abc import Callable
Expand All @@ -37,38 +37,6 @@
]


class Unspecified:
"""
Sentinel value meaning "fall back to environment variable, then built-in default".

The singleton instance :data:`UNSPECIFIED` is used as the default for every
:class:`StreamingOptions` field. When a field is still ``UNSPECIFIED`` after
construction (i.e. neither an explicit value nor an environment variable was provided),
the underlying library applies its own built-in default.
"""

_instance: Unspecified | None = None

def __new__(cls) -> Unspecified:
"""Return the singleton instance."""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

def __repr__(self) -> str:
"""Return ``"UNSPECIFIED"``."""
return "UNSPECIFIED"


UNSPECIFIED = Unspecified()
"""Singleton sentinel for all :class:`StreamingOptions` fields.

A field set to ``UNSPECIFIED`` after construction means no explicit value and no
matching environment variable was found; the underlying library will apply its own
built-in default.
"""


def _opt(
category: str,
env_var: str | None = None,
Expand Down
74 changes: 68 additions & 6 deletions python/cudf_polars/cudf_polars/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@


__all__ = [
"UNSPECIFIED",
"Cluster",
"ConfigOptions",
"DaskContext",
Expand All @@ -60,9 +61,45 @@
"SPMDContext",
"StreamingExecutor",
"StreamingFallbackMode",
"Unspecified",
]


class Unspecified:
"""
Sentinel value meaning "no value was explicitly provided".

The singleton instance :data:`UNSPECIFIED` is used as the default for every
:class:`StreamingOptions` field, as well as for
:attr:`ParquetOptions.prefetch_file_metadata`. When a field is still
``UNSPECIFIED`` after construction (i.e. neither an explicit value nor a
matching environment variable was provided), the consuming component decides
on the semantics.
"""

_instance: Unspecified | None = None

def __new__(cls) -> Unspecified:
"""Return the singleton instance."""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

def __repr__(self) -> str:
"""Return ``"UNSPECIFIED"``."""
return "UNSPECIFIED"


UNSPECIFIED = Unspecified()
"""Singleton sentinel for all :class:`StreamingOptions` fields, as well as for
:attr:`ParquetOptions.prefetch_file_metadata`.

A field set to ``UNSPECIFIED`` after construction means no explicit value and no
matching environment variable was found; the consuming component decides on the
semantics.
"""


def _env_get_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, default))
Expand Down Expand Up @@ -221,7 +258,10 @@ class ParquetOptions:
will also be skipped if ``max_footer_samples`` is 0.
prefetch_file_metadata
Whether to prefetch parquet file metadata and pass it through
`parquet_metadatas` to avoid rereading file footers.
`parquet_metadatas` to avoid rereading file footers. Not supported
by the in-memory executor, where it defaults to disabled. For the
streaming executor, it defaults to being enabled for remote URIs
(e.g. ``s3://``) only; pass ``True`` to also prefetch local files.
use_jit_filter
Whether to use JIT compilation for post-read filtering in Parquet scans.
When enabled, filter predicates are JIT-compiled to CUDA kernels for
Expand Down Expand Up @@ -261,11 +301,11 @@ class ParquetOptions:
f"{_env_prefix}__MAX_ROW_GROUP_SAMPLES", int, default=1
)
)
prefetch_file_metadata: bool = dataclasses.field(
prefetch_file_metadata: bool | Unspecified = dataclasses.field(
default_factory=_make_default_factory(
f"{_env_prefix}__PREFETCH_FILE_METADATA",
_bool_converter,
default=False,
default=UNSPECIFIED,
)
)
use_jit_filter: bool = dataclasses.field(
Expand All @@ -289,8 +329,8 @@ def __post_init__(self) -> None: # noqa: D105
raise TypeError("max_footer_samples must be an int")
if not isinstance(self.max_row_group_samples, int):
raise TypeError("max_row_group_samples must be an int")
if not isinstance(self.prefetch_file_metadata, bool):
raise TypeError("prefetch_file_metadata must be a bool")
if not isinstance(self.prefetch_file_metadata, (bool, Unspecified)):
raise TypeError("prefetch_file_metadata must be a bool when specified")
if not isinstance(self.use_jit_filter, bool):
raise TypeError("use_jit_filter must be a bool")

Expand Down Expand Up @@ -960,9 +1000,31 @@ def from_polars_engine(
if user_parquet_options is None:
user_parquet_options = {}

# Engine-dependent default: only prefetch for the streaming executor.
# Skipped if the user or the environment has already set a value.
prefetch_default = UNSPECIFIED if user_executor == "streaming" else False
prefetch_env_set = (
os.environ.get(f"{ParquetOptions._env_prefix}__PREFETCH_FILE_METADATA")
is not None
)

if isinstance(user_parquet_options, dict):
user_parquet_options = dict(user_parquet_options)
if (
"prefetch_file_metadata" not in user_parquet_options
and not prefetch_env_set
):
user_parquet_options["prefetch_file_metadata"] = prefetch_default
parquet_options = ParquetOptions(**user_parquet_options)
else:
if (
isinstance(user_parquet_options.prefetch_file_metadata, Unspecified)
and not prefetch_env_set
):
user_parquet_options = dataclasses.replace(
user_parquet_options,
prefetch_file_metadata=prefetch_default,
)
parquet_options = user_parquet_options
# This is set in polars, and so can't be overridden by the environment
user_raise_on_fail = engine.config.get("raise_on_fail", False)
Expand Down Expand Up @@ -991,7 +1053,7 @@ def from_polars_engine(
match user_executor:
case "in-memory":
executor = InMemoryExecutor(**user_executor_options)
if parquet_options.prefetch_file_metadata:
if parquet_options.prefetch_file_metadata is True:
raise NotImplementedError(
"Prefetching is not supported for the in-memory executor."
)
Expand Down
43 changes: 22 additions & 21 deletions python/cudf_polars/tests/streaming/test_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,27 @@ def test_prefetch_parquet_file_metadata_no_parquet_scans() -> None:
assert result == {}


def test_prefetch_parquet_file_metadata_remote_only(tmp_path, df) -> None:
make_partitioned_source(df, tmp_path, "parquet", n_files=1)
local_path = str(next(tmp_path.glob("*.parquet")))

scan = _make_parquet_scan([local_path])
fused = FusedScan(scan.schema, scan, scan.paths, scan.parquet_options, [])
streaming_scan = StreamingScan([fused], scan, "fused")

# Local paths are skipped entirely when remote_only=True.
result = prefetch_parquet_file_metadata_for_ir(
streaming_scan, py_executor=None, stats=None, remote_only=True
)
assert result == {}

# The same local path is prefetched when remote_only=False (the default).
result = prefetch_parquet_file_metadata_for_ir(
streaming_scan, py_executor=None, stats=None
)
assert set(result) == {local_path}


def test_prefetch_file_metadata_select_fast_count(
df: pl.DataFrame,
streaming_engine_factory: Callable[..., StreamingEngine],
Expand Down Expand Up @@ -349,33 +370,13 @@ def test_streaming_scan_raises() -> None:
StreamingScan.do_evaluate([fused], scan, context=ctx)


def test_scan_missing_prefetch_metadata_raises() -> None:
def test_scan_path_mismatch_raises() -> None:
# This isn't reachable by polars' public API, so we test it directly.
scan = _make_parquet_scan(
["file.parquet"], parquet_options=ParquetOptions(prefetch_file_metadata=True)
)
ctx = IRExecutionContext()

with pytest.raises(
AssertionError,
match=r"Cached parquet info is required",
):
Scan.do_evaluate(
scan.schema,
scan.typ,
scan.reader_options,
scan.paths,
scan.with_columns,
scan.skip_rows,
scan.n_rows,
scan.row_index,
scan.include_file_paths,
scan.predicate,
scan.parquet_options,
None,
context=ctx,
)

with pytest.raises(
AssertionError,
match=r"Paths do not match cached parquet info",
Expand Down
Loading
Loading