Skip to content

Commit 07d7160

Browse files
authored
Use different max_concurrent_io_tasks defaults for local and remote paths (#23847)
Add path-aware automatic defaults for `max_concurrent_io_tasks`. By default, Scan actors use lower IO concurrency for local paths and higher concurrency for scans with remote URIs. Users can override this with an integer for all scans, or a `{"local": ..., "remote": ...}` dict. The environment variable also accepts `auto` or a JSON dict. Closes #23552. Once this PR merges we won't be trying to set the value based on any heuristic, but we will at least be defining it based on observed benchmark results. Authors: - Richard (Rick) Zamora (https://github.com/rjzamora) Approvers: - Peter Andreas Entschev (https://github.com/pentschev) - Tom Augspurger (https://github.com/TomAugspurger) URL: #23847
1 parent 6f902ce commit 07d7160

10 files changed

Lines changed: 347 additions & 39 deletions

File tree

docs/cudf/source/cudf_polars/memory_errors.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ enters the pipeline at once. For formats that do not support partial reads, such
5959
the engine must load an entire file before it can begin processing, which may produce
6060
chunks much larger than `target_partition_size`.
6161

62+
### Concurrent file reads
63+
64+
Each scan node may read more than one input chunk at a time. At least two IO
65+
producer tasks help overlap IO with GPU compute. Larger
66+
`max_concurrent_io_tasks` values may improve high-latency IO throughput but
67+
increase memory use per Scan actor. See
68+
{class}`~cudf_polars.engine.options.StreamingOptions` for current defaults.
69+
6270
## Spilling to host memory
6371

6472
When GPU memory pressure rises above a configurable threshold
@@ -95,10 +103,11 @@ constructing the GPU engine for queries.
95103
| Option | Default | Effect |
96104
|---|---|---|
97105
| `target_partition_size` (executor option or `CUDF_POLARS__EXECUTOR__TARGET_PARTITION_SIZE`) | 1.5 GB or 2.5% of smallest GPU | Target chunk size in bytes. Smaller values reduce peak memory at some cost to compute efficiency. |
106+
| `max_concurrent_io_tasks` (executor option or `CUDF_POLARS__EXECUTOR__MAX_CONCURRENT_IO_TASKS`) | auto | Number of concurrent IO producer tasks for each scan node. Larger values may improve high-latency IO throughput but increase memory pressure. |
98107
| `RAPIDSMPF_SPILL_DEVICE_LIMIT` | `80%` | GPU memory fraction at which spilling begins. Lower values give more headroom for peaks. |
99108
| `RAPIDSMPF_PINNED_MEMORY` | disabled | Set to `true` to enable pinned host memory for spill buffers. |
100109
| `RAPIDSMPF_PINNED_INITIAL_POOL_SIZE` | (none) | Size of the pinned memory pool to pre-allocate (e.g. `32GB`). |
101110

102-
For the full list of engine configuration options, including `target_partition_size`,
103-
see {doc}`options`. For the full list of memory and spill configuration options see the
104-
[RapidsMPF configuration reference](https://docs.rapids.ai/api/rapidsmpf/stable/configuration/#general).
111+
For the full list of engine configuration options, including `target_partition_size`
112+
and `max_concurrent_io_tasks`, see {doc}`options`. For the full list of memory
113+
and spill configuration options see the [RapidsMPF configuration reference](https://docs.rapids.ai/api/rapidsmpf/stable/configuration/#general).

docs/cudf/source/cudf_polars/options.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ Environment variables follow these patterns:
107107
| `max_rows_per_partition` | Maximum number of rows per partition. Only used for in-memory `DataFrame` sources, never for disk IO or dynamic planning. | `1_000_000` |
108108
| `broadcast_limit` | Maximum number of bytes for broadcast joins. | auto |
109109
| `target_partition_size` | Target partition size in bytes. Used for IO and dynamic planning. `0` means auto. | auto |
110+
| `max_concurrent_io_tasks` | Number of concurrent IO producer tasks for each scan node. Tune with an integer or a `{"local": ..., "remote": ...}` dict. | auto |
110111
| `dynamic_planning` | Dynamic planning configuration, dict or {class}`~cudf_polars.utils.config.DynamicPlanningOptions`. `None` disables. | enabled |
111112
| `join_filter_pushdown` | Configuration for join filter pushdown plan rewrites, dict or {class}`~cudf_polars.utils.config.JoinFilterPushdownOptions`. `None` disables. | enabled |
112113
| `sink_to_directory` | Whether `.sink_*()` writes its output as a directory. The `spmd`, `ray`, and `dask` engines always use `True`; passing `False` raises `ValueError`. | `True` |

python/cudf_polars/cudf_polars/engine/options.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from cudf_polars.utils.config import (
2222
UNSPECIFIED,
2323
DynamicPlanningOptions,
24+
MaxConcurrentIOTasks,
2425
MemoryResourceConfig,
2526
Unspecified,
2627
)
@@ -210,7 +211,10 @@ class StreamingOptions:
210211
max_concurrent_io_tasks
211212
Maximum concurrent IO tasks for each scan node.
212213
Env: ``CUDF_POLARS__EXECUTOR__MAX_CONCURRENT_IO_TASKS``.
213-
Default: ``2``.
214+
Default: automatic, resolved separately for each scan based on its paths.
215+
Python and config values may be an ``int``, a dict with ``local``
216+
and/or ``remote`` keys, or omitted/``None`` for the default policy.
217+
The environment variable accepts an int or a JSON dict.
214218
Category: executor.
215219
fallback_mode
216220
Fallback behavior (``"warn"``, ``"raise"``, ``"silent"``).
@@ -338,8 +342,10 @@ class StreamingOptions:
338342
kvikio_statistics: bool | Unspecified = _opt(
339343
"executor", "CUDF_POLARS__EXECUTOR__KVIKIO_STATISTICS", parse_boolean
340344
)
341-
max_concurrent_io_tasks: int | Unspecified = _opt(
342-
"executor", "CUDF_POLARS__EXECUTOR__MAX_CONCURRENT_IO_TASKS", int
345+
max_concurrent_io_tasks: int | dict[str, int] | Unspecified | None = _opt(
346+
"executor",
347+
"CUDF_POLARS__EXECUTOR__MAX_CONCURRENT_IO_TASKS",
348+
MaxConcurrentIOTasks.parse_env,
343349
)
344350
fallback_mode: str | Unspecified = _opt(
345351
"executor", "CUDF_POLARS__EXECUTOR__FALLBACK_MODE"
@@ -723,7 +729,7 @@ def _add_cli_args(parser: argparse.ArgumentParser) -> None:
723729
help=textwrap.dedent("""\
724730
Maximum concurrent IO tasks for each scan node.
725731
Env: CUDF_POLARS__EXECUTOR__MAX_CONCURRENT_IO_TASKS.
726-
Built-in default: 2."""),
732+
Omit to use the path-dependent default."""),
727733
)
728734
g.add_argument(
729735
"--raise-on-fail",

python/cudf_polars/cudf_polars/streaming/actor_graph/dispatch.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
PartitionInfo,
2222
StatsCollector,
2323
)
24-
from cudf_polars.utils.config import ConfigOptions, StreamingExecutor
24+
from cudf_polars.utils.config import (
25+
ConfigOptions,
26+
MaxConcurrentIOTasks,
27+
StreamingExecutor,
28+
)
2529

2630

2731
class FanoutInfo(NamedTuple):
@@ -52,7 +56,7 @@ class GenState(TypedDict):
5256
ir_context
5357
The execution context for the IR node.
5458
max_concurrent_io_tasks
55-
The maximum number of concurrent IO tasks to use for a single IO node.
59+
The local and remote IO task limits to use for scan nodes.
5660
stats
5761
Statistics collector.
5862
collective_id_map
@@ -65,7 +69,7 @@ class GenState(TypedDict):
6569
partition_info: MutableMapping[IR, PartitionInfo]
6670
fanout_nodes: dict[IR, FanoutInfo]
6771
ir_context: IRExecutionContext
68-
max_concurrent_io_tasks: int
72+
max_concurrent_io_tasks: MaxConcurrentIOTasks
6973
stats: StatsCollector
7074
collective_id_map: dict[IR, list[int]]
7175

python/cudf_polars/cudf_polars/streaming/actor_graph/io.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import polars as pl
1515

16+
import pylibcudf as plc
1617
from cudf_streaming.channel_metadata import ChannelMetadata
1718
from cudf_streaming.table_chunk import TableChunk
1819
from rapidsmpf.memory.memory_reservation import opaque_memory_usage
@@ -43,7 +44,7 @@
4344
from cudf_polars.streaming.rank_aware_source import RankAwareSource
4445

4546
if TYPE_CHECKING:
46-
from collections.abc import Callable, Sequence
47+
from collections.abc import Callable, Iterable, Sequence
4748

4849
from rapidsmpf.communicator.communicator import Communicator
4950
from rapidsmpf.streaming.core.channel import Channel
@@ -57,6 +58,17 @@
5758
PartitionInfo,
5859
)
5960
from cudf_polars.streaming.io import FusedScan, SplitScan
61+
from cudf_polars.utils.config import MaxConcurrentIOTasks
62+
63+
64+
def resolve_max_concurrent_io_tasks(
65+
max_concurrent_io_tasks: MaxConcurrentIOTasks,
66+
paths: Iterable[str],
67+
) -> int:
68+
"""Resolve the scan-local IO producer count."""
69+
if any(plc.io.SourceInfo._is_remote_uri(path) for path in paths):
70+
return max_concurrent_io_tasks.remote
71+
return max_concurrent_io_tasks.local
6072

6173

6274
class Lineariser:
@@ -280,7 +292,9 @@ def _(
280292
) -> tuple[dict[IR, list[Any]], dict[IR, ChannelManager]]:
281293
config_options = rec.state["config_options"]
282294
rows_per_partition = config_options.executor.max_rows_per_partition
283-
num_producers = rec.state["max_concurrent_io_tasks"]
295+
num_producers = resolve_max_concurrent_io_tasks(
296+
rec.state["max_concurrent_io_tasks"], ()
297+
)
284298
# Use target_partition_size as the estimated chunk size
285299
estimated_chunk_bytes = config_options.executor.target_partition_size
286300

@@ -674,7 +688,10 @@ def _(
674688
config_options = rec.state["config_options"]
675689
executor = config_options.executor
676690
partition_info = rec.state["partition_info"][ir]
677-
num_producers = rec.state["max_concurrent_io_tasks"]
691+
num_producers = resolve_max_concurrent_io_tasks(
692+
rec.state["max_concurrent_io_tasks"],
693+
ir.base_scan.paths,
694+
)
678695
channels: dict[IR, ChannelManager] = {ir: ChannelManager(rec.state["context"])}
679696

680697
assert partition_info.io_plan is not None, "Scan node must have a partition plan"

python/cudf_polars/cudf_polars/utils/config.py

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"DynamicPlanningOptions",
6262
"InMemoryExecutor",
6363
"JoinFilterPushdownOptions",
64+
"MaxConcurrentIOTasks",
6465
"ParquetOptions",
6566
"RayContext",
6667
"SPMDContext",
@@ -105,6 +106,47 @@ def __repr__(self) -> str:
105106
"""
106107

107108

109+
@dataclasses.dataclass(frozen=True)
110+
class MaxConcurrentIOTasks:
111+
"""Concurrent IO task defaults for local and remote scan paths."""
112+
113+
local: int = 2
114+
remote: int = 8
115+
116+
@staticmethod
117+
def parse_env(raw: str) -> int | dict[str, int] | None:
118+
"""Parse an environment-variable value."""
119+
raw = raw.strip()
120+
try:
121+
return int(raw)
122+
except ValueError:
123+
value = json.loads(raw)
124+
MaxConcurrentIOTasks.from_config(value)
125+
return value
126+
127+
@classmethod
128+
def from_config(
129+
cls, value: int | dict[str, int] | MaxConcurrentIOTasks | None
130+
) -> MaxConcurrentIOTasks:
131+
"""Construct from the supported configuration shapes."""
132+
if value is None:
133+
return cls()
134+
if isinstance(value, int):
135+
return cls(local=value, remote=value)
136+
if isinstance(value, MaxConcurrentIOTasks):
137+
return value
138+
if not isinstance(value, dict):
139+
raise TypeError("max_concurrent_io_tasks must be an int, dict, or None")
140+
return cls(**value)
141+
142+
def __post_init__(self) -> None:
143+
"""Validate local and remote values."""
144+
if type(self.local) is not int or type(self.remote) is not int:
145+
raise TypeError("max_concurrent_io_tasks values must be ints")
146+
if self.local < 1 or self.remote < 1:
147+
raise ValueError("max_concurrent_io_tasks values must be positive")
148+
149+
108150
def _env_get_int(name: str, default: int) -> int:
109151
try:
110152
return int(os.getenv(name, default))
@@ -790,11 +832,16 @@ class StreamingExecutor:
790832
Enable through environment variables with
791833
``CUDF_POLARS__EXECUTOR__JOIN_FILTER_PUSHDOWN=1``.
792834
max_concurrent_io_tasks
793-
Maximum number of concurrent IO tasks for each scan node. Default is 2.
794-
This can be set via
835+
Maximum number of concurrent IO tasks for each scan node. The default
836+
uses ``2`` for local paths and ``8`` for scans with remote URIs.
837+
Passing an ``int`` uses the same value for all scans. Passing a dict
838+
with ``local`` and/or ``remote`` keys tunes local and remote paths
839+
separately. Omit the option, or pass ``None``, to use the default
840+
policy. This can be set via
795841
796842
- ``executor_options`` passed to ``polars.GPUEngine``
797-
- the ``CUDF_POLARS__EXECUTOR__MAX_CONCURRENT_IO_TASKS`` environment variable
843+
- the ``CUDF_POLARS__EXECUTOR__MAX_CONCURRENT_IO_TASKS`` environment
844+
variable, as an int or JSON dict
798845
num_py_executors
799846
Maximum number of workers for the Python ThreadPoolExecutor.
800847
Default is 8.
@@ -881,9 +928,13 @@ class StreamingExecutor:
881928
join_filter_pushdown: JoinFilterPushdownOptions | None = dataclasses.field(
882929
default_factory=JoinFilterPushdownOptions
883930
)
884-
max_concurrent_io_tasks: int = dataclasses.field(
931+
max_concurrent_io_tasks: MaxConcurrentIOTasks = dataclasses.field(
885932
default_factory=_make_default_factory(
886-
f"{_env_prefix}__MAX_CONCURRENT_IO_TASKS", int, default=2
933+
f"{_env_prefix}__MAX_CONCURRENT_IO_TASKS",
934+
lambda raw: MaxConcurrentIOTasks.from_config(
935+
MaxConcurrentIOTasks.parse_env(raw)
936+
),
937+
default=MaxConcurrentIOTasks(),
887938
)
888939
)
889940
num_py_executors: int = dataclasses.field(
@@ -967,7 +1018,6 @@ def __post_init__(self) -> None: # noqa: D105
9671018
object.__setattr__(self, "sink_to_directory", True)
9681019
elif self.sink_to_directory is None:
9691020
object.__setattr__(self, "sink_to_directory", False)
970-
9711021
# Type / value check everything else
9721022
if not isinstance(self.max_rows_per_partition, int):
9731023
raise TypeError("max_rows_per_partition must be an int")
@@ -979,8 +1029,6 @@ def __post_init__(self) -> None: # noqa: D105
9791029
raise TypeError("sink_to_directory must be bool")
9801030
if not isinstance(self.client_device_threshold, float):
9811031
raise TypeError("client_device_threshold must be a float")
982-
if not isinstance(self.max_concurrent_io_tasks, int):
983-
raise TypeError("max_concurrent_io_tasks must be an int")
9841032
if not isinstance(self.num_py_executors, int):
9851033
raise TypeError("num_py_executors must be an int")
9861034
if not isinstance(self.kvikio_nthreads, int):
@@ -994,6 +1042,9 @@ def __hash__(self) -> int: # noqa: D105
9941042
d = dataclasses.asdict(self)
9951043
d["dynamic_planning"] = json.dumps(d["dynamic_planning"])
9961044
d["join_filter_pushdown"] = json.dumps(d["join_filter_pushdown"])
1045+
d["max_concurrent_io_tasks"] = json.dumps(
1046+
d["max_concurrent_io_tasks"], sort_keys=True
1047+
)
9971048

9981049
# Hash the quent context UUIDs as ints
9991050
quent_context = d["quent_context"]
@@ -1189,6 +1240,12 @@ def from_polars_engine(
11891240
user_executor_options = user_executor_options.copy()
11901241
if "min_device_size" not in user_executor_options:
11911242
user_executor_options["min_device_size"] = get_total_device_memory()
1243+
if "max_concurrent_io_tasks" in user_executor_options:
1244+
user_executor_options["max_concurrent_io_tasks"] = (
1245+
MaxConcurrentIOTasks.from_config(
1246+
user_executor_options["max_concurrent_io_tasks"]
1247+
)
1248+
)
11921249

11931250
# Handle dynamic_planning: check user config, then env var
11941251
user_dynamic_planning = user_executor_options.get(

python/cudf_polars/docs/overview.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -410,19 +410,24 @@ engine = pl.GPUEngine(
410410
)
411411
```
412412

413-
Each scan node may run up to `max_concurrent_io_tasks` reads concurrently. The
414-
limit applies independently to each scan node, each corresponding to a
415-
single `pl.scan_parquet` call in the query. Configure it through
416-
`executor_options` or
413+
Each scan node may run up to `max_concurrent_io_tasks` reads concurrently. By
414+
default, the streaming executor chooses this limit automatically based on the
415+
scan's paths. The limit applies independently to each scan node, each
416+
corresponding to a single `pl.scan_parquet` call in the query. Configure it
417+
explicitly through `executor_options` or
417418
`CUDF_POLARS__EXECUTOR__MAX_CONCURRENT_IO_TASKS`:
418419

419420
```python
420421
engine = pl.GPUEngine(
421422
executor="streaming",
422-
executor_options={"max_concurrent_io_tasks": 8},
423+
executor_options={"max_concurrent_io_tasks": 4},
423424
)
424425
```
425426

427+
Passing an integer uses the same limit for all scans. Pass a
428+
`{"local": ..., "remote": ...}` dict, or set the environment variable to a
429+
JSON value like `{"remote": 16}`, to tune local and remote scans separately.
430+
426431
Before each read is submitted, it waits for a device-memory reservation.
427432
This makes aggregate read concurrency respond to memory pressure across all
428433
scan nodes on the rank.

0 commit comments

Comments
 (0)