From 2607537319d531b2290942d3752060399bbaa854 Mon Sep 17 00:00:00 2001 From: Lawrence Mitchell Date: Tue, 17 Sep 2024 01:17:05 +0100 Subject: [PATCH 01/15] Creation of CI artifacts for cudf-polars wheels (#16680) This is the changes that will be in the cudf-polars point release. --------- Co-authored-by: Thomas Li <47963215+lithomas1@users.noreply.github.com> Co-authored-by: David Wendt Co-authored-by: brandon-b-miller <53796099+brandon-b-miller@users.noreply.github.com> Co-authored-by: Vyas Ramasubramani Co-authored-by: brandon-b-miller Co-authored-by: Bradley Dice Co-authored-by: Manas Singh <122591937+singhmanas1@users.noreply.github.com> Co-authored-by: Manas Singh --- .github/workflows/pr.yaml | 12 + ci/run_cudf_polars_polars_tests.sh | 27 ++ ci/test_cudf_polars_polars_tests.sh | 69 +++ ci/test_wheel_cudf_polars.sh | 9 + cpp/include/cudf/detail/indexalator.cuh | 6 +- dependencies.yaml | 2 +- .../_static/Polars_GPU_speedup_80GB.png | Bin 0 -> 124695 bytes .../_static/compute_heavy_queries_polars.png | Bin 0 -> 108652 bytes .../source/_static/pds_benchmark_polars.png | Bin 0 -> 74310 bytes docs/cudf/source/cudf_polars/index.rst | 41 ++ docs/cudf/source/index.rst | 1 + .../api_docs/pylibcudf/strings/index.rst | 1 + .../api_docs/pylibcudf/strings/strip.rst | 6 + python/cudf/cudf/_lib/datetime.pyx | 42 +- python/cudf/cudf/_lib/pylibcudf/column.pyx | 9 +- python/cudf/cudf/_lib/pylibcudf/datetime.pyx | 49 ++ .../pylibcudf/libcudf/strings/CMakeLists.txt | 2 +- .../pylibcudf/libcudf/strings/side_type.pxd | 4 +- .../pylibcudf/libcudf/strings/side_type.pyx | 0 .../cudf/_lib/pylibcudf/libcudf/types.pxd | 2 + .../_lib/pylibcudf/strings/CMakeLists.txt | 4 +- .../cudf/_lib/pylibcudf/strings/__init__.pxd | 3 + .../cudf/_lib/pylibcudf/strings/__init__.py | 3 + .../pylibcudf/strings/convert/CMakeLists.txt | 22 + .../pylibcudf/strings/convert/__init__.pxd | 2 + .../pylibcudf/strings/convert/__init__.py | 2 + .../strings/convert/convert_datetime.pxd | 19 + .../strings/convert/convert_datetime.pyx | 57 +++ .../strings/convert/convert_durations.pxd | 18 + .../strings/convert/convert_durations.pyx | 42 ++ .../cudf/_lib/pylibcudf/strings/side_type.pxd | 3 + .../cudf/_lib/pylibcudf/strings/side_type.pyx | 4 + .../cudf/_lib/pylibcudf/strings/strip.pxd | 12 + .../cudf/_lib/pylibcudf/strings/strip.pyx | 61 +++ python/cudf/cudf/_lib/pylibcudf/types.pxd | 2 + python/cudf/cudf/_lib/pylibcudf/types.pyx | 16 +- python/cudf/cudf/_lib/string_casting.pyx | 86 ++-- python/cudf/cudf/_lib/strings/strip.pyx | 22 +- .../test_column_from_device.py | 39 +- .../cudf/pylibcudf_tests/test_datetime.py | 42 +- .../pylibcudf_tests/test_string_convert.py | 86 ++++ .../cudf/pylibcudf_tests/test_string_strip.py | 123 +++++ python/cudf_polars/cudf_polars/__init__.py | 30 +- python/cudf_polars/cudf_polars/callback.py | 139 +++++- .../cudf_polars/containers/column.py | 28 ++ .../cudf_polars/containers/dataframe.py | 14 +- python/cudf_polars/cudf_polars/dsl/expr.py | 459 +++++++++++++++--- python/cudf_polars/cudf_polars/dsl/ir.py | 212 +++++--- .../cudf_polars/cudf_polars/dsl/translate.py | 112 ++++- .../cudf_polars/testing/asserts.py | 114 ++++- .../cudf_polars/cudf_polars/testing/plugin.py | 156 ++++++ .../cudf_polars/typing/__init__.py | 4 + .../cudf_polars/cudf_polars/utils/dtypes.py | 24 +- .../cudf_polars/cudf_polars/utils/versions.py | 23 +- python/cudf_polars/docs/overview.md | 81 +++- python/cudf_polars/pyproject.toml | 5 +- .../tests/containers/test_dataframe.py | 11 + .../cudf_polars/tests/expressions/test_agg.py | 79 ++- .../tests/expressions/test_booleanfunction.py | 58 ++- .../tests/expressions/test_datetime_basic.py | 101 +++- .../tests/expressions/test_gather.py | 3 +- .../expressions/test_numeric_unaryops.py | 91 ++++ .../tests/expressions/test_stringfunction.py | 185 +++++++ python/cudf_polars/tests/test_config.py | 48 ++ python/cudf_polars/tests/test_groupby.py | 60 +-- .../cudf_polars/tests/test_groupby_dynamic.py | 29 ++ python/cudf_polars/tests/test_join.py | 2 +- python/cudf_polars/tests/test_mapfunction.py | 45 ++ python/cudf_polars/tests/test_python_scan.py | 4 +- python/cudf_polars/tests/test_scan.py | 90 +++- python/cudf_polars/tests/test_sort.py | 5 +- .../cudf_polars/tests/testing/test_asserts.py | 57 ++- 72 files changed, 2766 insertions(+), 453 deletions(-) create mode 100755 ci/run_cudf_polars_polars_tests.sh create mode 100755 ci/test_cudf_polars_polars_tests.sh create mode 100644 docs/cudf/source/_static/Polars_GPU_speedup_80GB.png create mode 100644 docs/cudf/source/_static/compute_heavy_queries_polars.png create mode 100644 docs/cudf/source/_static/pds_benchmark_polars.png create mode 100644 docs/cudf/source/cudf_polars/index.rst create mode 100644 docs/cudf/source/user_guide/api_docs/pylibcudf/strings/strip.rst create mode 100644 python/cudf/cudf/_lib/pylibcudf/libcudf/strings/side_type.pyx create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/convert/CMakeLists.txt create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/convert/__init__.pxd create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/convert/__init__.py create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_datetime.pxd create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_datetime.pyx create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_durations.pxd create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_durations.pyx create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/side_type.pxd create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/side_type.pyx create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/strip.pxd create mode 100644 python/cudf/cudf/_lib/pylibcudf/strings/strip.pyx create mode 100644 python/cudf/cudf/pylibcudf_tests/test_string_convert.py create mode 100644 python/cudf/cudf/pylibcudf_tests/test_string_strip.py create mode 100644 python/cudf_polars/cudf_polars/testing/plugin.py create mode 100644 python/cudf_polars/tests/expressions/test_numeric_unaryops.py create mode 100644 python/cudf_polars/tests/test_groupby_dynamic.py diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index d5dfc9e1ff5..25f11863b0d 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -27,6 +27,7 @@ jobs: - wheel-tests-cudf - wheel-build-cudf-polars - wheel-tests-cudf-polars + - cudf-polars-polars-tests - wheel-build-dask-cudf - wheel-tests-dask-cudf - devcontainer @@ -154,6 +155,17 @@ jobs: # This always runs, but only fails if this PR touches code in # pylibcudf or cudf_polars script: "ci/test_wheel_cudf_polars.sh" + cudf-polars-polars-tests: + needs: wheel-build-cudf-polars + secrets: inherit + uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@branch-24.08 + with: + # This selects "ARCH=amd64 + the latest supported Python + CUDA". + matrix_filter: map(select(.ARCH == "amd64")) | group_by(.CUDA_VER|split(".")|map(tonumber)|.[0]) | map(max_by([(.PY_VER|split(".")|map(tonumber)), (.CUDA_VER|split(".")|map(tonumber))])) + build_type: pull-request + # This always runs, but only fails if this PR touches code in + # pylibcudf or cudf_polars + script: "ci/test_cudf_polars_polars_tests.sh" wheel-build-dask-cudf: needs: wheel-build-cudf secrets: inherit diff --git a/ci/run_cudf_polars_polars_tests.sh b/ci/run_cudf_polars_polars_tests.sh new file mode 100755 index 00000000000..52a827af94c --- /dev/null +++ b/ci/run_cudf_polars_polars_tests.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Copyright (c) 2024, NVIDIA CORPORATION. + +set -euo pipefail + +# Support invoking run_cudf_polars_pytests.sh outside the script directory +# Assumption, polars has been cloned in the root of the repo. +cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")"/../polars/ + +DESELECTED_TESTS=( + "tests/unit/test_polars_import.py::test_polars_import" # relies on a polars built in place + "tests/unit/streaming/test_streaming_sort.py::test_streaming_sort[True]" # relies on polars built in debug mode + "tests/unit/test_cpu_check.py::test_check_cpu_flags_skipped_no_flags" # Mock library error + "tests/docs/test_user_guide.py" # No dot binary in CI image +) + +DESELECTED_TESTS=$(printf -- " --deselect %s" "${DESELECTED_TESTS[@]}") +python -m pytest \ + --import-mode=importlib \ + --cache-clear \ + -m "" \ + -p cudf_polars.testing.plugin \ + -v \ + --tb=short \ + ${DESELECTED_TESTS} \ + "$@" \ + py-polars/tests diff --git a/ci/test_cudf_polars_polars_tests.sh b/ci/test_cudf_polars_polars_tests.sh new file mode 100755 index 00000000000..924fc4ef28b --- /dev/null +++ b/ci/test_cudf_polars_polars_tests.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Copyright (c) 2024, NVIDIA CORPORATION. + +set -eou pipefail + +# We will only fail these tests if the PR touches code in pylibcudf +# or cudf_polars itself. +# Note, the three dots mean we are doing diff between the merge-base +# of upstream and HEAD. So this is asking, "does _this branch_ touch +# files in cudf_polars/pylibcudf", rather than "are there changes +# between upstream and this branch which touch cudf_polars/pylibcudf" +# TODO: is the target branch exposed anywhere in an environment variable? +if [ -n "$(git diff --name-only origin/branch-24.08...HEAD -- python/cudf_polars/ python/cudf/cudf/_lib/pylibcudf/)" ]; +then + HAS_CHANGES=1 + rapids-logger "PR has changes in cudf-polars/pylibcudf, test fails treated as failure" +else + HAS_CHANGES=0 + rapids-logger "PR does not have changes in cudf-polars/pylibcudf, test fails NOT treated as failure" +fi + +rapids-logger "Download wheels" + +RAPIDS_PY_CUDA_SUFFIX="$(rapids-wheel-ctk-name-gen ${RAPIDS_CUDA_VERSION})" +RAPIDS_PY_WHEEL_NAME="cudf_polars_${RAPIDS_PY_CUDA_SUFFIX}" RAPIDS_PY_WHEEL_PURE="1" rapids-download-wheels-from-s3 ./dist + +# Download the cudf built in the previous step +RAPIDS_PY_WHEEL_NAME="cudf_${RAPIDS_PY_CUDA_SUFFIX}" rapids-download-wheels-from-s3 ./local-cudf-dep + +rapids-logger "Install cudf" +python -m pip install ./local-cudf-dep/cudf*.whl + +rapids-logger "Install cudf_polars" +python -m pip install $(echo ./dist/cudf_polars*.whl) + +# TAG=$(python -c 'import polars; print(f"py-{polars.__version__}")') +TAG="py-1.7.0" +rapids-logger "Clone polars to ${TAG}" +git clone https://github.com/pola-rs/polars.git --branch ${TAG} --depth 1 + +# Install requirements for running polars tests +rapids-logger "Install polars test requirements" +python -m pip install -r polars/py-polars/requirements-dev.txt -r polars/py-polars/requirements-ci.txt + +function set_exitcode() +{ + EXITCODE=$? +} +EXITCODE=0 +trap set_exitcode ERR +set +e + +rapids-logger "Run polars tests" +./ci/run_cudf_polars_polars_tests.sh + +trap ERR +set -e + +if [ ${EXITCODE} != 0 ]; then + rapids-logger "Running polars test suite FAILED: exitcode ${EXITCODE}" +else + rapids-logger "Running polars test suite PASSED" +fi + +if [ ${HAS_CHANGES} == 1 ]; then + exit ${EXITCODE} +else + exit 0 +fi diff --git a/ci/test_wheel_cudf_polars.sh b/ci/test_wheel_cudf_polars.sh index 900acd5d473..d25601428a6 100755 --- a/ci/test_wheel_cudf_polars.sh +++ b/ci/test_wheel_cudf_polars.sh @@ -13,20 +13,29 @@ set -eou pipefail if [ -n "$(git diff --name-only origin/branch-24.08...HEAD -- python/cudf_polars/ python/cudf/cudf/_lib/pylibcudf/)" ]; then HAS_CHANGES=1 + rapids-logger "PR has changes in cudf-polars/pylibcudf, test fails treated as failure" else HAS_CHANGES=0 + rapids-logger "PR does not have changes in cudf-polars/pylibcudf, test fails NOT treated as failure" fi +rapids-logger "Download wheels" + RAPIDS_PY_CUDA_SUFFIX="$(rapids-wheel-ctk-name-gen ${RAPIDS_CUDA_VERSION})" RAPIDS_PY_WHEEL_NAME="cudf_polars_${RAPIDS_PY_CUDA_SUFFIX}" RAPIDS_PY_WHEEL_PURE="1" rapids-download-wheels-from-s3 ./dist # Download the cudf built in the previous step RAPIDS_PY_WHEEL_NAME="cudf_${RAPIDS_PY_CUDA_SUFFIX}" rapids-download-wheels-from-s3 ./local-cudf-dep + +rapids-logger "Install cudf" python -m pip install ./local-cudf-dep/cudf*.whl rapids-logger "Install cudf_polars" python -m pip install $(echo ./dist/cudf_polars*.whl)[test] +rapids-logger "Pin to 1.7.0 Temporarily" +python -m pip install polars==1.7.0 + rapids-logger "Run cudf_polars tests" function set_exitcode() diff --git a/cpp/include/cudf/detail/indexalator.cuh b/cpp/include/cudf/detail/indexalator.cuh index b5d57da6cd5..c264dff2181 100644 --- a/cpp/include/cudf/detail/indexalator.cuh +++ b/cpp/include/cudf/detail/indexalator.cuh @@ -93,7 +93,7 @@ struct input_indexalator : base_normalator { */ __device__ inline cudf::size_type operator[](size_type idx) const { - void const* tp = p_ + (idx * this->width_); + void const* tp = p_ + (static_cast(idx) * this->width_); return type_dispatcher(this->dtype_, normalize_type{}, tp); } @@ -109,7 +109,7 @@ struct input_indexalator : base_normalator { CUDF_HOST_DEVICE input_indexalator(void const* data, data_type dtype, cudf::size_type offset = 0) : base_normalator(dtype), p_{static_cast(data)} { - p_ += offset * this->width_; + p_ += static_cast(offset) * this->width_; } protected: @@ -165,7 +165,7 @@ struct output_indexalator : base_normalator __device__ inline output_indexalator const operator[](size_type idx) const { output_indexalator tmp{*this}; - tmp.p_ += (idx * this->width_); + tmp.p_ += static_cast(idx) * this->width_; return tmp; } diff --git a/dependencies.yaml b/dependencies.yaml index 4c93ef60dd3..9664c8e26f8 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -631,7 +631,7 @@ dependencies: common: - output_types: [conda, requirements, pyproject] packages: - - polars>=1.0,<1.3 + - polars>=1.6 run_dask_cudf: common: - output_types: [conda, requirements, pyproject] diff --git a/docs/cudf/source/_static/Polars_GPU_speedup_80GB.png b/docs/cudf/source/_static/Polars_GPU_speedup_80GB.png new file mode 100644 index 0000000000000000000000000000000000000000..e472cf66612e7d92681e220bcf42740bd89f50dd GIT binary patch literal 124695 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

7;>h%|N=2Wb@Tlr4bjT4wA}9e0y+B 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 literal 0 HcmV?d00001 diff --git a/docs/cudf/source/_static/compute_heavy_queries_polars.png b/docs/cudf/source/_static/compute_heavy_queries_polars.png new file mode 100644 index 0000000000000000000000000000000000000000..6854ed5a436d1f8cc89381e4a7a72b4c7ee759bf GIT binary patch literal 108652 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 Nad+`{u6IPI{tFLmBX0lz literal 0 HcmV?d00001 diff --git a/docs/cudf/source/_static/pds_benchmark_polars.png b/docs/cudf/source/_static/pds_benchmark_polars.png new file mode 100644 index 0000000000000000000000000000000000000000..d0b48ab2901a8648378a55ef7ab0374412623128 GIT binary patch literal 74310 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 benchmark to compare Polars GPU engine with the default CPU settings across several dataset sizes. Here are the results: + +.. figure:: ../_static/pds_benchmark_polars.png + :width: 600px + + + +You can see up to 13x speedup using the GPU backend on the compute-heavy PDS queries involving complex aggregation and join operations. Below are the speedups for the top performing queries: + + +.. figure:: ../_static/compute_heavy_queries_polars.png + :width: 1000px + +:emphasis:`PDS-H benchmark | GPU: NVIDIA H100 PCIe | CPU: Intel Xeon W9-3495X (Sapphire Rapids) | Storage: Local NVMe` + +You can reproduce the results by visiting the `Polars Decision Support (PDS) GitHub repository `__. + +Learn More +---------- + +The GPU backend for Polars is now available in Open Beta and the engine is undergoing rapid development. To learn more, visit the `GPU Support page `__ on the Polars website. + +Launch on Google Colab +---------------------- + +.. figure:: ../_static/colab.png + :width: 200px + :target: https://colab.research.google.com/github/rapidsai-community/showcase/blob/main/accelerated_data_processing_examples/polars_gpu_engine_demo.ipynb + + Take the cuDF backend for Polars for a test-drive in a free GPU-enabled notebook environment using your Google account by `launching on Colab `__. diff --git a/docs/cudf/source/index.rst b/docs/cudf/source/index.rst index 3b8dfa5fe01..1b86cafeb48 100644 --- a/docs/cudf/source/index.rst +++ b/docs/cudf/source/index.rst @@ -29,5 +29,6 @@ other operations. user_guide/index cudf_pandas/index + cudf_polars/index libcudf_docs/index developer_guide/index diff --git a/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/index.rst b/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/index.rst index cecf1ccc9bb..7affae6673f 100644 --- a/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/index.rst +++ b/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/index.rst @@ -7,3 +7,4 @@ strings contains replace slice + strip diff --git a/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/strip.rst b/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/strip.rst new file mode 100644 index 00000000000..32f87e013ad --- /dev/null +++ b/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/strip.rst @@ -0,0 +1,6 @@ +===== +strip +===== + +.. automodule:: cudf._lib.pylibcudf.strings.strip + :members: diff --git a/python/cudf/cudf/_lib/datetime.pyx b/python/cudf/cudf/_lib/datetime.pyx index b30ef875a7b..9a66d2527db 100644 --- a/python/cudf/cudf/_lib/datetime.pyx +++ b/python/cudf/cudf/_lib/datetime.pyx @@ -16,6 +16,8 @@ from cudf._lib.pylibcudf.libcudf.scalar.scalar cimport scalar from cudf._lib.pylibcudf.libcudf.types cimport size_type from cudf._lib.scalar cimport DeviceScalar +import cudf._lib.pylibcudf as plc + @acquire_spill_lock() def add_months(Column col, Column months): @@ -37,43 +39,9 @@ def add_months(Column col, Column months): @acquire_spill_lock() def extract_datetime_component(Column col, object field): - - cdef unique_ptr[column] c_result - cdef column_view col_view = col.view() - - with nogil: - if field == "year": - c_result = move(libcudf_datetime.extract_year(col_view)) - elif field == "month": - c_result = move(libcudf_datetime.extract_month(col_view)) - elif field == "day": - c_result = move(libcudf_datetime.extract_day(col_view)) - elif field == "weekday": - c_result = move(libcudf_datetime.extract_weekday(col_view)) - elif field == "hour": - c_result = move(libcudf_datetime.extract_hour(col_view)) - elif field == "minute": - c_result = move(libcudf_datetime.extract_minute(col_view)) - elif field == "second": - c_result = move(libcudf_datetime.extract_second(col_view)) - elif field == "millisecond": - c_result = move( - libcudf_datetime.extract_millisecond_fraction(col_view) - ) - elif field == "microsecond": - c_result = move( - libcudf_datetime.extract_microsecond_fraction(col_view) - ) - elif field == "nanosecond": - c_result = move( - libcudf_datetime.extract_nanosecond_fraction(col_view) - ) - elif field == "day_of_year": - c_result = move(libcudf_datetime.day_of_year(col_view)) - else: - raise ValueError(f"Invalid datetime field: '{field}'") - - result = Column.from_unique_ptr(move(c_result)) + result = Column.from_pylibcudf( + plc.datetime.extract_datetime_component(col.to_pylibcudf(mode="read"), field) + ) if field == "weekday": # Pandas counts Monday-Sunday as 0-6 diff --git a/python/cudf/cudf/_lib/pylibcudf/column.pyx b/python/cudf/cudf/_lib/pylibcudf/column.pyx index a61e0629292..1d9902b0374 100644 --- a/python/cudf/cudf/_lib/pylibcudf/column.pyx +++ b/python/cudf/cudf/_lib/pylibcudf/column.pyx @@ -15,13 +15,11 @@ from cudf._lib.pylibcudf.libcudf.types cimport size_type from .gpumemoryview cimport gpumemoryview from .scalar cimport Scalar -from .types cimport DataType, type_id +from .types cimport DataType, size_of, type_id from .utils cimport int_to_bitmask_ptr, int_to_void_ptr import functools -import numpy as np - cdef class Column: """A container of nullable device data as a column of elements. @@ -303,14 +301,15 @@ cdef class Column: raise ValueError("mask not yet supported.") typestr = iface['typestr'][1:] + data_type = _datatype_from_dtype_desc(typestr) + if not is_c_contiguous( iface['shape'], iface['strides'], - np.dtype(typestr).itemsize + size_of(data_type) ): raise ValueError("Data must be C-contiguous") - data_type = _datatype_from_dtype_desc(typestr) size = iface['shape'][0] return Column( data_type, diff --git a/python/cudf/cudf/_lib/pylibcudf/datetime.pyx b/python/cudf/cudf/_lib/pylibcudf/datetime.pyx index 82351327de6..87efcd495b9 100644 --- a/python/cudf/cudf/_lib/pylibcudf/datetime.pyx +++ b/python/cudf/cudf/_lib/pylibcudf/datetime.pyx @@ -4,6 +4,16 @@ from libcpp.utility cimport move from cudf._lib.pylibcudf.libcudf.column.column cimport column from cudf._lib.pylibcudf.libcudf.datetime cimport ( + day_of_year as cpp_day_of_year, + extract_day as cpp_extract_day, + extract_hour as cpp_extract_hour, + extract_microsecond_fraction as cpp_extract_microsecond_fraction, + extract_millisecond_fraction as cpp_extract_millisecond_fraction, + extract_minute as cpp_extract_minute, + extract_month as cpp_extract_month, + extract_nanosecond_fraction as cpp_extract_nanosecond_fraction, + extract_second as cpp_extract_second, + extract_weekday as cpp_extract_weekday, extract_year as cpp_extract_year, ) @@ -31,3 +41,42 @@ cpdef Column extract_year( with nogil: result = move(cpp_extract_year(values.view())) return Column.from_libcudf(move(result)) + + +def extract_datetime_component(Column col, str field): + + cdef unique_ptr[column] c_result + + with nogil: + if field == "year": + c_result = move(cpp_extract_year(col.view())) + elif field == "month": + c_result = move(cpp_extract_month(col.view())) + elif field == "day": + c_result = move(cpp_extract_day(col.view())) + elif field == "weekday": + c_result = move(cpp_extract_weekday(col.view())) + elif field == "hour": + c_result = move(cpp_extract_hour(col.view())) + elif field == "minute": + c_result = move(cpp_extract_minute(col.view())) + elif field == "second": + c_result = move(cpp_extract_second(col.view())) + elif field == "millisecond": + c_result = move( + cpp_extract_millisecond_fraction(col.view()) + ) + elif field == "microsecond": + c_result = move( + cpp_extract_microsecond_fraction(col.view()) + ) + elif field == "nanosecond": + c_result = move( + cpp_extract_nanosecond_fraction(col.view()) + ) + elif field == "day_of_year": + c_result = move(cpp_day_of_year(col.view())) + else: + raise ValueError(f"Invalid datetime field: '{field}'") + + return Column.from_libcudf(move(c_result)) diff --git a/python/cudf/cudf/_lib/pylibcudf/libcudf/strings/CMakeLists.txt b/python/cudf/cudf/_lib/pylibcudf/libcudf/strings/CMakeLists.txt index bd6e2e0af02..abf4357f862 100644 --- a/python/cudf/cudf/_lib/pylibcudf/libcudf/strings/CMakeLists.txt +++ b/python/cudf/cudf/_lib/pylibcudf/libcudf/strings/CMakeLists.txt @@ -12,7 +12,7 @@ # the License. # ============================================================================= -set(cython_sources char_types.pyx regex_flags.pyx) +set(cython_sources char_types.pyx regex_flags.pyx side_type.pyx) set(linked_libraries cudf::cudf) diff --git a/python/cudf/cudf/_lib/pylibcudf/libcudf/strings/side_type.pxd b/python/cudf/cudf/_lib/pylibcudf/libcudf/strings/side_type.pxd index 3a89299f11a..019ff3f17ba 100644 --- a/python/cudf/cudf/_lib/pylibcudf/libcudf/strings/side_type.pxd +++ b/python/cudf/cudf/_lib/pylibcudf/libcudf/strings/side_type.pxd @@ -1,10 +1,10 @@ -# Copyright (c) 2022, NVIDIA CORPORATION. +# Copyright (c) 2022-2024, NVIDIA CORPORATION. from libc.stdint cimport int32_t cdef extern from "cudf/strings/side_type.hpp" namespace "cudf::strings" nogil: - ctypedef enum side_type: + cpdef enum class side_type(int32_t): LEFT 'cudf::strings::side_type::LEFT' RIGHT 'cudf::strings::side_type::RIGHT' BOTH 'cudf::strings::side_type::BOTH' diff --git a/python/cudf/cudf/_lib/pylibcudf/libcudf/strings/side_type.pyx b/python/cudf/cudf/_lib/pylibcudf/libcudf/strings/side_type.pyx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/cudf/cudf/_lib/pylibcudf/libcudf/types.pxd b/python/cudf/cudf/_lib/pylibcudf/libcudf/types.pxd index 8e94ec296cf..eabae68bc90 100644 --- a/python/cudf/cudf/_lib/pylibcudf/libcudf/types.pxd +++ b/python/cudf/cudf/_lib/pylibcudf/libcudf/types.pxd @@ -98,3 +98,5 @@ cdef extern from "cudf/types.hpp" namespace "cudf" nogil: HIGHER MIDPOINT NEAREST + + cdef size_type size_of(data_type t) except + diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/CMakeLists.txt b/python/cudf/cudf/_lib/pylibcudf/strings/CMakeLists.txt index b499a127541..154ff70a75d 100644 --- a/python/cudf/cudf/_lib/pylibcudf/strings/CMakeLists.txt +++ b/python/cudf/cudf/_lib/pylibcudf/strings/CMakeLists.txt @@ -13,7 +13,7 @@ # ============================================================================= set(cython_sources capitalize.pyx case.pyx char_types.pyx contains.pyx find.pyx regex_flags.pyx - regex_program.pyx replace.pyx slice.pyx + regex_program.pyx replace.pyx side_type.pyx slice.pyx strip.pyx ) set(linked_libraries cudf::cudf) @@ -22,3 +22,5 @@ rapids_cython_create_modules( SOURCE_FILES "${cython_sources}" LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX pylibcudf_strings_ ASSOCIATED_TARGETS cudf ) + +add_subdirectory(convert) diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/__init__.pxd b/python/cudf/cudf/_lib/pylibcudf/strings/__init__.pxd index d1f632d6d8e..e76e6e68441 100644 --- a/python/cudf/cudf/_lib/pylibcudf/strings/__init__.pxd +++ b/python/cudf/cudf/_lib/pylibcudf/strings/__init__.pxd @@ -5,9 +5,12 @@ from . cimport ( case, char_types, contains, + convert, find, regex_flags, regex_program, replace, slice, + strip, ) +from .side_type cimport side_type diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/__init__.py b/python/cudf/cudf/_lib/pylibcudf/strings/__init__.py index ef102aff2af..63fa42f204c 100644 --- a/python/cudf/cudf/_lib/pylibcudf/strings/__init__.py +++ b/python/cudf/cudf/_lib/pylibcudf/strings/__init__.py @@ -5,9 +5,12 @@ case, char_types, contains, + convert, find, regex_flags, regex_program, replace, slice, + strip, ) +from .side_type import SideType diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/convert/CMakeLists.txt b/python/cudf/cudf/_lib/pylibcudf/strings/convert/CMakeLists.txt new file mode 100644 index 00000000000..175c9b3738e --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/convert/CMakeLists.txt @@ -0,0 +1,22 @@ +# ============================================================================= +# Copyright (c) 2024, NVIDIA CORPORATION. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# ============================================================================= + +set(cython_sources convert_durations.pyx convert_datetime.pyx) + +set(linked_libraries cudf::cudf) +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" MODULE_PREFIX pylibcudf_strings_ ASSOCIATED_TARGETS cudf +) diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/convert/__init__.pxd b/python/cudf/cudf/_lib/pylibcudf/strings/convert/__init__.pxd new file mode 100644 index 00000000000..05324cb49df --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/convert/__init__.pxd @@ -0,0 +1,2 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +from . cimport convert_datetime, convert_durations diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/convert/__init__.py b/python/cudf/cudf/_lib/pylibcudf/strings/convert/__init__.py new file mode 100644 index 00000000000..d803399d53c --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/convert/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. +from . import convert_datetime, convert_durations diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_datetime.pxd b/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_datetime.pxd new file mode 100644 index 00000000000..a6ad4dc1b3a --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_datetime.pxd @@ -0,0 +1,19 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. + +from libcpp.string cimport string + +from cudf._lib.pylibcudf.column cimport Column +from cudf._lib.pylibcudf.types cimport DataType + + +cpdef Column to_timestamps( + Column input, + DataType timestamp_type, + const string& format +) + +cpdef Column from_timestamps( + Column input, + const string& format, + Column input_strings_names +) diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_datetime.pyx b/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_datetime.pyx new file mode 100644 index 00000000000..a51b317e95a --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_datetime.pyx @@ -0,0 +1,57 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. + +from libcpp.memory cimport unique_ptr +from libcpp.string cimport string +from libcpp.utility cimport move + +from cudf._lib.pylibcudf.column cimport Column +from cudf._lib.pylibcudf.libcudf.column.column cimport column +from cudf._lib.pylibcudf.libcudf.strings.convert cimport ( + convert_datetime as cpp_convert_datetime, +) + +from cudf._lib.pylibcudf.types import DataType + + +cpdef Column to_timestamps( + Column input, + DataType timestamp_type, + const string& format +): + cdef unique_ptr[column] c_result + with nogil: + c_result = cpp_convert_datetime.to_timestamps( + input.view(), + timestamp_type.c_obj, + format + ) + + return Column.from_libcudf(move(c_result)) + +cpdef Column from_timestamps( + Column input, + const string& format, + Column input_strings_names +): + cdef unique_ptr[column] c_result + with nogil: + c_result = cpp_convert_datetime.from_timestamps( + input.view(), + format, + input_strings_names.view() + ) + + return Column.from_libcudf(move(c_result)) + +cpdef Column is_timestamp( + Column input, + const string& format +): + cdef unique_ptr[column] c_result + with nogil: + c_result = cpp_convert_datetime.is_timestamp( + input.view(), + format + ) + + return Column.from_libcudf(move(c_result)) diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_durations.pxd b/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_durations.pxd new file mode 100644 index 00000000000..74d31a4f7b6 --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_durations.pxd @@ -0,0 +1,18 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. + +from libcpp.string cimport string + +from cudf._lib.pylibcudf.column cimport Column +from cudf._lib.pylibcudf.types cimport DataType + + +cpdef Column to_durations( + Column input, + DataType duration_type, + const string& format +) + +cpdef Column from_durations( + Column input, + const string& format +) diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_durations.pyx b/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_durations.pyx new file mode 100644 index 00000000000..c94433fe215 --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/convert/convert_durations.pyx @@ -0,0 +1,42 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. + +from libcpp.memory cimport unique_ptr +from libcpp.string cimport string +from libcpp.utility cimport move + +from cudf._lib.pylibcudf.column cimport Column +from cudf._lib.pylibcudf.libcudf.column.column cimport column +from cudf._lib.pylibcudf.libcudf.strings.convert cimport ( + convert_durations as cpp_convert_durations, +) + +from cudf._lib.pylibcudf.types import DataType + + +cpdef Column to_durations( + Column input, + DataType duration_type, + const string& format +): + cdef unique_ptr[column] c_result + with nogil: + c_result = cpp_convert_durations.to_durations( + input.view(), + duration_type.c_obj, + format + ) + + return Column.from_libcudf(move(c_result)) + +cpdef Column from_durations( + Column input, + const string& format +): + cdef unique_ptr[column] c_result + with nogil: + c_result = cpp_convert_durations.from_durations( + input.view(), + format + ) + + return Column.from_libcudf(move(c_result)) diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/side_type.pxd b/python/cudf/cudf/_lib/pylibcudf/strings/side_type.pxd new file mode 100644 index 00000000000..95bf6fabb15 --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/side_type.pxd @@ -0,0 +1,3 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. + +from cudf._lib.pylibcudf.libcudf.strings.side_type cimport side_type diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/side_type.pyx b/python/cudf/cudf/_lib/pylibcudf/strings/side_type.pyx new file mode 100644 index 00000000000..dcbe8af7f6f --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/side_type.pyx @@ -0,0 +1,4 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. + +from cudf._lib.pylibcudf.libcudf.strings.side_type import \ + side_type as SideType # no-cython-lint diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/strip.pxd b/python/cudf/cudf/_lib/pylibcudf/strings/strip.pxd new file mode 100644 index 00000000000..f3bdbacbaf8 --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/strip.pxd @@ -0,0 +1,12 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. + +from cudf._lib.pylibcudf.column cimport Column +from cudf._lib.pylibcudf.scalar cimport Scalar +from cudf._lib.pylibcudf.strings.side_type cimport side_type + + +cpdef Column strip( + Column input, + side_type side=*, + Scalar to_strip=* +) diff --git a/python/cudf/cudf/_lib/pylibcudf/strings/strip.pyx b/python/cudf/cudf/_lib/pylibcudf/strings/strip.pyx new file mode 100644 index 00000000000..5179774f82d --- /dev/null +++ b/python/cudf/cudf/_lib/pylibcudf/strings/strip.pyx @@ -0,0 +1,61 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. + +from cython.operator cimport dereference +from libcpp.memory cimport unique_ptr +from libcpp.utility cimport move + +from cudf._lib.pylibcudf.column cimport Column +from cudf._lib.pylibcudf.libcudf.column.column cimport column +from cudf._lib.pylibcudf.libcudf.scalar.scalar cimport string_scalar +from cudf._lib.pylibcudf.libcudf.scalar.scalar_factories cimport ( + make_string_scalar as cpp_make_string_scalar, +) +from cudf._lib.pylibcudf.libcudf.strings cimport strip as cpp_strip +from cudf._lib.pylibcudf.scalar cimport Scalar +from cudf._lib.pylibcudf.strings.side_type cimport side_type + + +cpdef Column strip( + Column input, + side_type side=side_type.BOTH, + Scalar to_strip=None +): + """Removes the specified characters from the beginning + or end (or both) of each string. + + For details, see :cpp:func:`cudf::strings::strip`. + + Parameters + ---------- + input : Column + Strings column for this operation + side : SideType, default SideType.BOTH + Indicates characters are to be stripped from the beginning, + end, or both of each string; Default is both + to_strip : Scalar + UTF-8 encoded characters to strip from each string; + Default is empty string which indicates strip whitespace characters + + Returns + ------- + pylibcudf.Column + New strings column. + """ + + if to_strip is None: + to_strip = Scalar.from_libcudf( + cpp_make_string_scalar("".encode()) + ) + + cdef unique_ptr[column] c_result + cdef string_scalar* cpp_to_strip + cpp_to_strip = (to_strip.c_obj.get()) + + with nogil: + c_result = cpp_strip.strip( + input.view(), + side, + dereference(cpp_to_strip) + ) + + return Column.from_libcudf(move(c_result)) diff --git a/python/cudf/cudf/_lib/pylibcudf/types.pxd b/python/cudf/cudf/_lib/pylibcudf/types.pxd index 7d3ddca14a1..1f3e1aa2fbb 100644 --- a/python/cudf/cudf/_lib/pylibcudf/types.pxd +++ b/python/cudf/cudf/_lib/pylibcudf/types.pxd @@ -27,3 +27,5 @@ cdef class DataType: @staticmethod cdef DataType from_libcudf(data_type dt) + +cpdef size_type size_of(DataType t) diff --git a/python/cudf/cudf/_lib/pylibcudf/types.pyx b/python/cudf/cudf/_lib/pylibcudf/types.pyx index c45c6071bb3..311f9ce4046 100644 --- a/python/cudf/cudf/_lib/pylibcudf/types.pyx +++ b/python/cudf/cudf/_lib/pylibcudf/types.pyx @@ -2,7 +2,12 @@ from libc.stdint cimport int32_t -from cudf._lib.pylibcudf.libcudf.types cimport data_type, size_type, type_id +from cudf._lib.pylibcudf.libcudf.types cimport ( + data_type, + size_of as cpp_size_of, + size_type, + type_id, +) from cudf._lib.pylibcudf.libcudf.utilities.type_dispatcher cimport type_to_id from cudf._lib.pylibcudf.libcudf.types import type_id as TypeId # no-cython-lint, isort:skip @@ -69,6 +74,15 @@ cdef class DataType: ret.c_obj = dt return ret +cpdef size_type size_of(DataType t): + """Returns the size in bytes of elements of the specified data_type. + + Only fixed-width types are supported. + + For details, see :cpp:func:`size_of`. + """ + with nogil: + return cpp_size_of(t.c_obj) SIZE_TYPE = DataType(type_to_id[size_type]()) SIZE_TYPE_ID = SIZE_TYPE.id() diff --git a/python/cudf/cudf/_lib/string_casting.pyx b/python/cudf/cudf/_lib/string_casting.pyx index dfad7fd101c..0be2f7ce4a4 100644 --- a/python/cudf/cudf/_lib/string_casting.pyx +++ b/python/cudf/cudf/_lib/string_casting.pyx @@ -20,13 +20,7 @@ from cudf._lib.pylibcudf.libcudf.strings.convert.convert_booleans cimport ( to_booleans as cpp_to_booleans, ) from cudf._lib.pylibcudf.libcudf.strings.convert.convert_datetime cimport ( - from_timestamps as cpp_from_timestamps, is_timestamp as cpp_is_timestamp, - to_timestamps as cpp_to_timestamps, -) -from cudf._lib.pylibcudf.libcudf.strings.convert.convert_durations cimport ( - from_durations as cpp_from_durations, - to_durations as cpp_to_durations, ) from cudf._lib.pylibcudf.libcudf.strings.convert.convert_floats cimport ( from_floats as cpp_from_floats, @@ -48,6 +42,8 @@ from cudf._lib.pylibcudf.libcudf.types cimport data_type, type_id from cudf._lib.types cimport underlying_type_t_type_id import cudf +import cudf._lib.pylibcudf as plc +from cudf._lib.types cimport dtype_to_pylibcudf_type def floating_to_string(Column input_col): @@ -521,19 +517,14 @@ def int2timestamp( A Column with date-time represented in string format """ - cdef column_view input_column_view = input_col.view() cdef string c_timestamp_format = format.encode("UTF-8") - cdef column_view input_strings_names = names.view() - - cdef unique_ptr[column] c_result - with nogil: - c_result = move( - cpp_from_timestamps( - input_column_view, - c_timestamp_format, - input_strings_names)) - - return Column.from_unique_ptr(move(c_result)) + return Column.from_pylibcudf( + plc.strings.convert.convert_datetime.from_timestamps( + input_col.to_pylibcudf(mode="read"), + c_timestamp_format, + names.to_pylibcudf(mode="read") + ) + ) def timestamp2int(Column input_col, dtype, format): @@ -550,23 +541,15 @@ def timestamp2int(Column input_col, dtype, format): A Column with string represented in date-time format """ - cdef column_view input_column_view = input_col.view() - cdef type_id tid = ( - ( - SUPPORTED_NUMPY_TO_LIBCUDF_TYPES[dtype] + dtype = dtype_to_pylibcudf_type(dtype) + cdef string c_timestamp_format = format.encode('UTF-8') + return Column.from_pylibcudf( + plc.strings.convert.convert_datetime.to_timestamps( + input_col.to_pylibcudf(mode="read"), + dtype, + c_timestamp_format ) ) - cdef data_type out_type = data_type(tid) - cdef string c_timestamp_format = format.encode('UTF-8') - cdef unique_ptr[column] c_result - with nogil: - c_result = move( - cpp_to_timestamps( - input_column_view, - out_type, - c_timestamp_format)) - - return Column.from_unique_ptr(move(c_result)) def istimestamp(Column input_col, str format): @@ -612,23 +595,15 @@ def timedelta2int(Column input_col, dtype, format): A Column with string represented in TimeDelta format """ - cdef column_view input_column_view = input_col.view() - cdef type_id tid = ( - ( - SUPPORTED_NUMPY_TO_LIBCUDF_TYPES[dtype] + dtype = dtype_to_pylibcudf_type(dtype) + cdef string c_timestamp_format = format.encode('UTF-8') + return Column.from_pylibcudf( + plc.strings.convert.convert_durations.to_durations( + input_col.to_pylibcudf(mode="read"), + dtype, + c_timestamp_format ) ) - cdef data_type out_type = data_type(tid) - cdef string c_duration_format = format.encode('UTF-8') - cdef unique_ptr[column] c_result - with nogil: - c_result = move( - cpp_to_durations( - input_column_view, - out_type, - c_duration_format)) - - return Column.from_unique_ptr(move(c_result)) def int2timedelta(Column input_col, str format): @@ -646,16 +621,13 @@ def int2timedelta(Column input_col, str format): """ - cdef column_view input_column_view = input_col.view() cdef string c_duration_format = format.encode('UTF-8') - cdef unique_ptr[column] c_result - with nogil: - c_result = move( - cpp_from_durations( - input_column_view, - c_duration_format)) - - return Column.from_unique_ptr(move(c_result)) + return Column.from_pylibcudf( + plc.strings.convert.convert_durations.from_durations( + input_col.to_pylibcudf(mode="read"), + c_duration_format + ) + ) def int2ip(Column input_col): diff --git a/python/cudf/cudf/_lib/strings/strip.pyx b/python/cudf/cudf/_lib/strings/strip.pyx index 199fa5fc3b6..10545bd8077 100644 --- a/python/cudf/cudf/_lib/strings/strip.pyx +++ b/python/cudf/cudf/_lib/strings/strip.pyx @@ -12,6 +12,7 @@ from cudf._lib.pylibcudf.libcudf.scalar.scalar cimport string_scalar from cudf._lib.pylibcudf.libcudf.strings.side_type cimport side_type from cudf._lib.pylibcudf.libcudf.strings.strip cimport strip as cpp_strip from cudf._lib.scalar cimport DeviceScalar +import cudf._lib.pylibcudf as plc @acquire_spill_lock() @@ -24,23 +25,14 @@ def strip(Column source_strings, """ cdef DeviceScalar repl = py_repl.device_value - - cdef unique_ptr[column] c_result - cdef column_view source_view = source_strings.view() - - cdef const string_scalar* scalar_str = ( - repl.get_raw_ptr() + return Column.from_pylibcudf( + plc.strings.strip.strip( + source_strings.to_pylibcudf(mode="read"), + plc.strings.SideType.BOTH, + repl.c_value + ) ) - with nogil: - c_result = move(cpp_strip( - source_view, - side_type.BOTH, - scalar_str[0] - )) - - return Column.from_unique_ptr(move(c_result)) - @acquire_spill_lock() def lstrip(Column source_strings, diff --git a/python/cudf/cudf/pylibcudf_tests/test_column_from_device.py b/python/cudf/cudf/pylibcudf_tests/test_column_from_device.py index c4ff7bb43a5..78ee2cb100e 100644 --- a/python/cudf/cudf/pylibcudf_tests/test_column_from_device.py +++ b/python/cudf/cudf/pylibcudf_tests/test_column_from_device.py @@ -4,7 +4,8 @@ import pytest from utils import assert_column_eq -import cudf +import rmm + from cudf._lib import pylibcudf as plc VALID_TYPES = [ @@ -35,17 +36,39 @@ def valid_type(request): return request.param +class DataBuffer: + def __init__(self, obj, dtype): + self.obj = rmm.DeviceBuffer.to_device(obj) + self.dtype = dtype + self.shape = (int(len(self.obj) / self.dtype.itemsize),) + self.strides = (self.dtype.itemsize,) + self.typestr = self.dtype.str + + @property + def __cuda_array_interface__(self): + return { + "data": self.obj.__cuda_array_interface__["data"], + "shape": self.shape, + "strides": self.strides, + "typestr": self.typestr, + "version": 0, + } + + @pytest.fixture -def valid_column(valid_type): +def input_column(valid_type): if valid_type == pa.bool_(): return pa.array([True, False, True], type=valid_type) return pa.array([1, 2, 3], type=valid_type) -def test_from_cuda_array_interface(valid_column): - col = plc.column.Column.from_cuda_array_interface_obj( - cudf.Series(valid_column) - ) - expect = valid_column +@pytest.fixture +def iface_obj(input_column): + data = input_column.to_numpy(zero_copy_only=False) + return DataBuffer(data.view("uint8"), data.dtype) + + +def test_from_cuda_array_interface(input_column, iface_obj): + col = plc.column.Column.from_cuda_array_interface_obj(iface_obj) - assert_column_eq(expect, col) + assert_column_eq(input_column, col) diff --git a/python/cudf/cudf/pylibcudf_tests/test_datetime.py b/python/cudf/cudf/pylibcudf_tests/test_datetime.py index 75af0fa6ca1..777c234c192 100644 --- a/python/cudf/cudf/pylibcudf_tests/test_datetime.py +++ b/python/cudf/cudf/pylibcudf_tests/test_datetime.py @@ -1,8 +1,10 @@ # Copyright (c) 2024, NVIDIA CORPORATION. import datetime +import functools import pyarrow as pa +import pyarrow.compute as pc import pytest from utils import assert_column_eq @@ -10,7 +12,7 @@ @pytest.fixture -def column(has_nulls): +def date_column(has_nulls): values = [ datetime.date(1999, 1, 1), datetime.date(2024, 10, 12), @@ -22,9 +24,41 @@ def column(has_nulls): return plc.interop.from_arrow(pa.array(values, type=pa.date32())) -def test_extract_year(column): - got = plc.datetime.extract_year(column) +@pytest.fixture(scope="module", params=["s", "ms", "us", "ns"]) +def datetime_column(has_nulls, request): + values = [ + datetime.datetime(1999, 1, 1), + datetime.datetime(2024, 10, 12), + datetime.datetime(1970, 1, 1), + datetime.datetime(2260, 1, 1), + datetime.datetime(2024, 2, 29, 3, 14, 15), + datetime.datetime(2024, 2, 29, 3, 14, 15, 999), + ] + if has_nulls: + values[2] = None + return plc.interop.from_arrow( + pa.array(values, type=pa.timestamp(request.param)) + ) + + +@pytest.mark.parametrize( + "component, pc_fun", + [ + ("year", pc.year), + ("month", pc.month), + ("day", pc.day), + ("weekday", functools.partial(pc.day_of_week, count_from_zero=False)), + ("hour", pc.hour), + ("minute", pc.minute), + ("second", pc.second), + ("millisecond", pc.millisecond), + ("microsecond", pc.microsecond), + ("nanosecond", pc.nanosecond), + ], +) +def test_extraction(datetime_column, component, pc_fun): + got = plc.datetime.extract_datetime_component(datetime_column, component) # libcudf produces an int16, arrow produces an int64 - expect = pa.compute.year(plc.interop.to_arrow(column)).cast(pa.int16()) + expect = pc_fun(plc.interop.to_arrow(datetime_column)).cast(pa.int16()) assert_column_eq(expect, got) diff --git a/python/cudf/cudf/pylibcudf_tests/test_string_convert.py b/python/cudf/cudf/pylibcudf_tests/test_string_convert.py new file mode 100644 index 00000000000..3ea53685eaf --- /dev/null +++ b/python/cudf/cudf/pylibcudf_tests/test_string_convert.py @@ -0,0 +1,86 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. + +from datetime import datetime + +import pyarrow as pa +import pytest +from utils import assert_column_eq + +import cudf._lib.pylibcudf as plc + + +@pytest.fixture( + scope="module", + params=[ + pa.timestamp("ns"), + pa.timestamp("us"), + pa.timestamp("ms"), + pa.timestamp("s"), + ], +) +def timestamp_type(request): + return request.param + + +@pytest.fixture( + scope="module", + params=[ + pa.duration("ns"), + pa.duration("us"), + pa.duration("ms"), + pa.duration("s"), + ], +) +def duration_type(request): + return request.param + + +@pytest.fixture(scope="module") +def pa_timestamp_col(): + return pa.array(["2011-01-01", "2011-01-02", "2011-01-03"]) + + +@pytest.fixture(scope="module") +def pa_duration_col(): + return pa.array(["05:20:25"]) + + +@pytest.fixture(scope="module") +def plc_timestamp_col(pa_timestamp_col): + return plc.interop.from_arrow(pa_timestamp_col) + + +@pytest.fixture(scope="module") +def plc_duration_col(pa_duration_col): + return plc.interop.from_arrow(pa_duration_col) + + +@pytest.mark.parametrize("format", ["%Y-%m-%d"]) +def test_to_datetime( + pa_timestamp_col, plc_timestamp_col, timestamp_type, format +): + expect = pa.compute.strptime(pa_timestamp_col, format, timestamp_type.unit) + got = plc.strings.convert.convert_datetime.to_timestamps( + plc_timestamp_col, + plc.interop.from_arrow(timestamp_type), + format.encode(), + ) + assert_column_eq(expect, got) + + +@pytest.mark.parametrize("format", ["%H:%M:%S"]) +def test_to_duration(pa_duration_col, plc_duration_col, duration_type, format): + def to_timedelta(duration_str): + date = datetime.strptime(duration_str, format) + return date - datetime(1900, 1, 1) # "%H:%M:%S" zero date + + expect = pa.array([to_timedelta(d.as_py()) for d in pa_duration_col]).cast( + duration_type + ) + + got = plc.strings.convert.convert_durations.to_durations( + plc_duration_col, + plc.interop.from_arrow(duration_type), + format.encode(), + ) + assert_column_eq(expect, got) diff --git a/python/cudf/cudf/pylibcudf_tests/test_string_strip.py b/python/cudf/cudf/pylibcudf_tests/test_string_strip.py new file mode 100644 index 00000000000..e2567785a70 --- /dev/null +++ b/python/cudf/cudf/pylibcudf_tests/test_string_strip.py @@ -0,0 +1,123 @@ +# Copyright (c) 2024, NVIDIA CORPORATION. + +import pyarrow as pa +import pytest +from utils import assert_column_eq + +import cudf._lib.pylibcudf as plc + +data_strings = [ + "AbC", + "123abc", + "", + " ", + None, + "aAaaaAAaa", + " ab c ", + "abc123", + " ", + "\tabc\t", + "\nabc\n", + "\r\nabc\r\n", + "\t\n abc \n\t", + "!@#$%^&*()", + " abc!!! ", + " abc\t\n!!! ", + "__abc__", + "abc\n\n", + "123abc456", + "abcxyzabc", +] + +strip_chars = [ + "a", + "", + " ", + "\t", + "\n", + "\r\n", + "!", + "@#", + "123", + "xyz", + "abc", + "__", + " \t\n", + "abc123", +] + + +@pytest.fixture +def pa_col(): + return pa.array(data_strings, type=pa.string()) + + +@pytest.fixture +def plc_col(pa_col): + return plc.interop.from_arrow(pa_col) + + +@pytest.fixture(params=strip_chars) +def pa_char(request): + return pa.scalar(request.param, type=pa.string()) + + +@pytest.fixture +def plc_char(pa_char): + return plc.interop.from_arrow(pa_char) + + +def test_strip(pa_col, plc_col, pa_char, plc_char): + def strip_string(st, char): + if st is None: + return None + + elif char == "": + return st.strip() + return st.strip(char) + + expected = pa.array( + [strip_string(x, pa_char.as_py()) for x in pa_col.to_pylist()], + type=pa.string(), + ) + + got = plc.strings.strip.strip(plc_col, plc.strings.SideType.BOTH, plc_char) + assert_column_eq(expected, got) + + +def test_strip_right(pa_col, plc_col, pa_char, plc_char): + def strip_string(st, char): + if st is None: + return None + + elif char == "": + return st.rstrip() + return st.rstrip(char) + + expected = pa.array( + [strip_string(x, pa_char.as_py()) for x in pa_col.to_pylist()], + type=pa.string(), + ) + + got = plc.strings.strip.strip( + plc_col, plc.strings.SideType.RIGHT, plc_char + ) + assert_column_eq(expected, got) + + +def test_strip_left(pa_col, plc_col, pa_char, plc_char): + def strip_string(st, char): + if st is None: + return None + + elif char == "": + return st.lstrip() + return st.lstrip(char) + + expected = pa.array( + [strip_string(x, pa_char.as_py()) for x in pa_col.to_pylist()], + type=pa.string(), + ) + + got = plc.strings.strip.strip(plc_col, plc.strings.SideType.LEFT, plc_char) + assert_column_eq(expected, got) diff --git a/python/cudf_polars/cudf_polars/__init__.py b/python/cudf_polars/cudf_polars/__init__.py index 41d06f8631b..bada971756a 100644 --- a/python/cudf_polars/cudf_polars/__init__.py +++ b/python/cudf_polars/cudf_polars/__init__.py @@ -10,9 +10,33 @@ from __future__ import annotations -from cudf_polars._version import __git_commit__, __version__ -from cudf_polars.callback import execute_with_cudf -from cudf_polars.dsl.translate import translate_ir +import os +import warnings + +# We want to avoid initialising the GPU on import. Unfortunately, +# while we still depend on cudf, the default mode is to check things. +# If we set RAPIDS_NO_INITIALIZE, then cudf doesn't do import-time +# validation, good. +# We additionally must set the ptxcompiler environment variable, so +# that we don't check if a numba patch is needed. But if this is done, +# then the patching mechanism warns, and we want to squash that +# warning too. +# TODO: Remove this when we only depend on a pylibcudf package. +os.environ["RAPIDS_NO_INITIALIZE"] = "1" +os.environ["PTXCOMPILER_CHECK_NUMBA_CODEGEN_PATCH_NEEDED"] = "0" +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + import cudf + + del cudf + +# Check we have a supported polars version +import cudf_polars.utils.versions as v # noqa: E402 +from cudf_polars._version import __git_commit__, __version__ # noqa: E402 +from cudf_polars.callback import execute_with_cudf # noqa: E402 +from cudf_polars.dsl.translate import translate_ir # noqa: E402 + +del v __all__: list[str] = [ "execute_with_cudf", diff --git a/python/cudf_polars/cudf_polars/callback.py b/python/cudf_polars/cudf_polars/callback.py index f31193aa938..76816ee0a61 100644 --- a/python/cudf_polars/cudf_polars/callback.py +++ b/python/cudf_polars/cudf_polars/callback.py @@ -5,19 +5,26 @@ from __future__ import annotations +import contextlib import os import warnings -from functools import partial +from functools import cache, partial from typing import TYPE_CHECKING import nvtx -from polars.exceptions import PerformanceWarning +from polars.exceptions import ComputeError, PerformanceWarning + +import rmm +from rmm._cuda import gpu from cudf_polars.dsl.translate import translate_ir if TYPE_CHECKING: + from collections.abc import Generator + import polars as pl + from polars import GPUEngine from cudf_polars.dsl.ir import IR from cudf_polars.typing import NodeTraverser @@ -25,23 +32,126 @@ __all__: list[str] = ["execute_with_cudf"] +@cache +def default_memory_resource(device: int) -> rmm.mr.DeviceMemoryResource: + """ + Return the default memory resource for cudf-polars. + + Parameters + ---------- + device + Disambiguating device id when selecting the device. Must be + the active device when this function is called. + + Returns + ------- + rmm.mr.DeviceMemoryResource + The default memory resource that cudf-polars uses. Currently + an async pool resource. + """ + try: + return rmm.mr.CudaAsyncMemoryResource() + except RuntimeError as e: # pragma: no cover + msg, *_ = e.args + if ( + msg.startswith("RMM failure") + and msg.find("not supported with this CUDA driver/runtime version") > -1 + ): + raise ComputeError( + "GPU engine requested, but incorrect cudf-polars package installed. " + "If your system has a CUDA 11 driver, please uninstall `cudf-polars-cu12` " + "and install `cudf-polars-cu11`" + ) from None + else: + raise + + +@contextlib.contextmanager +def set_memory_resource( + mr: rmm.mr.DeviceMemoryResource | None, +) -> Generator[rmm.mr.DeviceMemoryResource, None, None]: + """ + Set the current memory resource for an execution block. + + Parameters + ---------- + mr + Memory resource to use. If `None`, calls :func:`default_memory_resource` + to obtain an mr on the currently active device. + + Returns + ------- + Memory resource used. + + Notes + ----- + At exit, the memory resource is restored to whatever was current + at entry. If a memory resource is provided, it must be valid to + use with the currently active device. + """ + if mr is None: + device: int = gpu.getDevice() + mr = default_memory_resource(device) + previous = rmm.mr.get_current_device_resource() + rmm.mr.set_current_device_resource(mr) + try: + yield mr + finally: + rmm.mr.set_current_device_resource(previous) + + +@contextlib.contextmanager +def set_device(device: int | None) -> Generator[int, None, None]: + """ + Set the device the query is executed on. + + Parameters + ---------- + device + Device to use. If `None`, uses the current device. + + Returns + ------- + Device active for the execution of the block. + + Notes + ----- + At exit, the device is restored to whatever was current at entry. + """ + previous: int = gpu.getDevice() + if device is not None: + gpu.setDevice(device) + try: + yield previous + finally: + gpu.setDevice(previous) + + def _callback( ir: IR, with_columns: list[str] | None, pyarrow_predicate: str | None, n_rows: int | None, + *, + device: int | None, + memory_resource: int | None, ) -> pl.DataFrame: assert with_columns is None assert pyarrow_predicate is None assert n_rows is None - with nvtx.annotate(message="ExecuteIR", domain="cudf_polars"): + with ( + nvtx.annotate(message="ExecuteIR", domain="cudf_polars"), + # Device must be set before memory resource is obtained. + set_device(device), + set_memory_resource(memory_resource), + ): return ir.evaluate(cache={}).to_polars() def execute_with_cudf( nt: NodeTraverser, *, - raise_on_fail: bool = False, + config: GPUEngine, exception: type[Exception] | tuple[type[Exception], ...] = Exception, ) -> None: """ @@ -52,9 +162,8 @@ def execute_with_cudf( nt NodeTraverser - raise_on_fail - Should conversion raise an exception rather than continuing - without setting a callback. + config + GPUEngine configuration object exception Optional exception, or tuple of exceptions, to catch during @@ -62,9 +171,23 @@ def execute_with_cudf( The NodeTraverser is mutated if the libcudf executor can handle the plan. """ + device = config.device + memory_resource = config.memory_resource + raise_on_fail = config.config.get("raise_on_fail", False) + if unsupported := (config.config.keys() - {"raise_on_fail"}): + raise ValueError( + f"Engine configuration contains unsupported settings {unsupported}" + ) try: with nvtx.annotate(message="ConvertIR", domain="cudf_polars"): - nt.set_udf(partial(_callback, translate_ir(nt))) + nt.set_udf( + partial( + _callback, + translate_ir(nt), + device=device, + memory_resource=memory_resource, + ) + ) except exception as e: if bool(int(os.environ.get("POLARS_VERBOSE", 0))): warnings.warn( diff --git a/python/cudf_polars/cudf_polars/containers/column.py b/python/cudf_polars/cudf_polars/containers/column.py index 02018548b2c..b6275cf4a4a 100644 --- a/python/cudf_polars/cudf_polars/containers/column.py +++ b/python/cudf_polars/cudf_polars/containers/column.py @@ -84,6 +84,34 @@ def sorted_like(self, like: Column, /) -> Self: is_sorted=like.is_sorted, order=like.order, null_order=like.null_order ) + # TODO: Return Column once #16272 is fixed. + def astype(self, dtype: plc.DataType) -> plc.Column: + """ + Return the backing column as the requested dtype. + + Parameters + ---------- + dtype + Datatype to cast to. + + Returns + ------- + Column of requested type. + + Raises + ------ + RuntimeError + If the cast is unsupported. + + Notes + ----- + This only produces a copy if the requested dtype doesn't match + the current one. + """ + if self.obj.type() != dtype: + return plc.unary.cast(self.obj, dtype) + return self.obj + def copy_metadata(self, from_: pl.Series, /) -> Self: """ Copy metadata from a host series onto self. diff --git a/python/cudf_polars/cudf_polars/containers/dataframe.py b/python/cudf_polars/cudf_polars/containers/dataframe.py index dba76855329..401886e0ccc 100644 --- a/python/cudf_polars/cudf_polars/containers/dataframe.py +++ b/python/cudf_polars/cudf_polars/containers/dataframe.py @@ -7,7 +7,7 @@ import itertools from functools import cached_property -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING import pyarrow as pa @@ -46,11 +46,19 @@ def copy(self) -> Self: def to_polars(self) -> pl.DataFrame: """Convert to a polars DataFrame.""" + # If the arrow table has empty names, from_arrow produces + # column_$i. But here we know there is only one such column + # (by construction) and it should have an empty name. + # https://github.com/pola-rs/polars/issues/11632 + # To guarantee we produce correct names, we therefore + # serialise with names we control and rename with that map. + name_map = {f"column_{i}": c.name for i, c in enumerate(self.columns)} table: pa.Table = plc.interop.to_arrow( self.table, - [plc.interop.ColumnMetadata(name=c.name) for c in self.columns], + [plc.interop.ColumnMetadata(name=name) for name in name_map], ) - return cast(pl.DataFrame, pl.from_arrow(table)).with_columns( + df: pl.DataFrame = pl.from_arrow(table) + return df.rename(name_map).with_columns( *( pl.col(c.name).set_sorted( descending=c.order == plc.types.Order.DESCENDING diff --git a/python/cudf_polars/cudf_polars/dsl/expr.py b/python/cudf_polars/cudf_polars/dsl/expr.py index 9e0fca3f52f..d6f44621406 100644 --- a/python/cudf_polars/cudf_polars/dsl/expr.py +++ b/python/cudf_polars/cudf_polars/dsl/expr.py @@ -21,7 +21,9 @@ from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple import pyarrow as pa +import pyarrow.compute as pc +from polars.exceptions import InvalidOperationError from polars.polars import _expr_nodes as pl_expr import cudf._lib.pylibcudf as plc @@ -478,12 +480,6 @@ def __init__( self.options = options self.name = name self.children = children - if ( - self.name in (pl_expr.BooleanFunction.Any, pl_expr.BooleanFunction.All) - and not self.options[0] - ): - # With ignore_nulls == False, polars uses Kleene logic - raise NotImplementedError(f"Kleene logic for {self.name}") if self.name == pl_expr.BooleanFunction.IsIn and not all( c.dtype == self.children[0].dtype for c in self.children ): @@ -578,20 +574,31 @@ def do_evaluate( child.evaluate(df, context=context, mapping=mapping) for child in self.children ] - if self.name == pl_expr.BooleanFunction.Any: + # Kleene logic for Any (OR) and All (AND) if ignore_nulls is + # False + if self.name in (pl_expr.BooleanFunction.Any, pl_expr.BooleanFunction.All): + (ignore_nulls,) = self.options (column,) = columns - return Column( - plc.Column.from_scalar( - plc.reduce.reduce(column.obj, plc.aggregation.any(), self.dtype), 1 - ) - ) - elif self.name == pl_expr.BooleanFunction.All: - (column,) = columns - return Column( - plc.Column.from_scalar( - plc.reduce.reduce(column.obj, plc.aggregation.all(), self.dtype), 1 - ) - ) + is_any = self.name == pl_expr.BooleanFunction.Any + agg = plc.aggregation.any() if is_any else plc.aggregation.all() + result = plc.reduce.reduce(column.obj, agg, self.dtype) + if not ignore_nulls and column.obj.null_count() > 0: + # Truth tables + # Any All + # | F U T | F U T + # --+------ --+------ + # F | F U T F | F F F + # U | U U T U | F U U + # T | T T T T | F U T + # + # If the input null count was non-zero, we must + # post-process the result to insert the correct value. + h_result = plc.interop.to_arrow(result).as_py() + if is_any and not h_result or not is_any and h_result: + # Any All + # False || Null => Null True && Null => Null + return Column(plc.Column.all_null_like(column.obj, 1)) + return Column(plc.Column.from_scalar(result, 1)) if self.name == pl_expr.BooleanFunction.IsNull: (column,) = columns return Column(plc.unary.is_null(column.obj)) @@ -599,13 +606,19 @@ def do_evaluate( (column,) = columns return Column(plc.unary.is_valid(column.obj)) elif self.name == pl_expr.BooleanFunction.IsNan: - # TODO: copy over null mask since is_nan(null) => null in polars (column,) = columns - return Column(plc.unary.is_nan(column.obj)) + return Column( + plc.unary.is_nan(column.obj).with_mask( + column.obj.null_mask(), column.obj.null_count() + ) + ) elif self.name == pl_expr.BooleanFunction.IsNotNan: - # TODO: copy over null mask since is_not_nan(null) => null in polars (column,) = columns - return Column(plc.unary.is_not_nan(column.obj)) + return Column( + plc.unary.is_not_nan(column.obj).with_mask( + column.obj.null_mask(), column.obj.null_count() + ) + ) elif self.name == pl_expr.BooleanFunction.IsFirstDistinct: (column,) = columns return self._distinct( @@ -655,26 +668,22 @@ def do_evaluate( ), ) elif self.name == pl_expr.BooleanFunction.AllHorizontal: - if any(c.obj.null_count() > 0 for c in columns): - raise NotImplementedError("Kleene logic for all_horizontal") return Column( reduce( partial( plc.binaryop.binary_operation, - op=plc.binaryop.BinaryOperator.BITWISE_AND, + op=plc.binaryop.BinaryOperator.NULL_LOGICAL_AND, output_type=self.dtype, ), (c.obj for c in columns), ) ) elif self.name == pl_expr.BooleanFunction.AnyHorizontal: - if any(c.obj.null_count() > 0 for c in columns): - raise NotImplementedError("Kleene logic for any_horizontal") return Column( reduce( partial( plc.binaryop.binary_operation, - op=plc.binaryop.BinaryOperator.BITWISE_OR, + op=plc.binaryop.BinaryOperator.NULL_LOGICAL_OR, output_type=self.dtype, ), (c.obj for c in columns), @@ -695,7 +704,7 @@ def do_evaluate( class StringFunction(Expr): - __slots__ = ("name", "options", "children") + __slots__ = ("name", "options", "children", "_regex_program") _non_child = ("dtype", "name", "options") children: tuple[Expr, ...] @@ -714,12 +723,18 @@ def __init__( def _validate_input(self): if self.name not in ( - pl_expr.StringFunction.Lowercase, - pl_expr.StringFunction.Uppercase, - pl_expr.StringFunction.EndsWith, - pl_expr.StringFunction.StartsWith, pl_expr.StringFunction.Contains, + pl_expr.StringFunction.EndsWith, + pl_expr.StringFunction.Lowercase, + pl_expr.StringFunction.Replace, + pl_expr.StringFunction.ReplaceMany, pl_expr.StringFunction.Slice, + pl_expr.StringFunction.Strptime, + pl_expr.StringFunction.StartsWith, + pl_expr.StringFunction.StripChars, + pl_expr.StringFunction.StripCharsStart, + pl_expr.StringFunction.StripCharsEnd, + pl_expr.StringFunction.Uppercase, ): raise NotImplementedError(f"String function {self.name}") if self.name == pl_expr.StringFunction.Contains: @@ -733,11 +748,65 @@ def _validate_input(self): raise NotImplementedError( "Regex contains only supports a scalar pattern" ) + pattern = self.children[1].value.as_py() + try: + self._regex_program = plc.strings.regex_program.RegexProgram.create( + pattern, + flags=plc.strings.regex_flags.RegexFlags.DEFAULT, + ) + except RuntimeError as e: + raise NotImplementedError( + f"Unsupported regex {pattern} for GPU engine." + ) from e + elif self.name == pl_expr.StringFunction.Replace: + _, literal = self.options + if not literal: + raise NotImplementedError("literal=False is not supported for replace") + if not all(isinstance(expr, Literal) for expr in self.children[1:]): + raise NotImplementedError("replace only supports scalar target") + target = self.children[1] + if target.value == pa.scalar("", type=pa.string()): + raise NotImplementedError( + "libcudf replace does not support empty strings" + ) + elif self.name == pl_expr.StringFunction.ReplaceMany: + (ascii_case_insensitive,) = self.options + if ascii_case_insensitive: + raise NotImplementedError( + "ascii_case_insensitive not implemented for replace_many" + ) + if not all( + isinstance(expr, (LiteralColumn, Literal)) for expr in self.children[1:] + ): + raise NotImplementedError("replace_many only supports literal inputs") + target = self.children[1] + if pc.any(pc.equal(target.value, "")).as_py(): + raise NotImplementedError( + "libcudf replace_many is implemented differently from polars " + "for empty strings" + ) elif self.name == pl_expr.StringFunction.Slice: if not all(isinstance(child, Literal) for child in self.children[1:]): raise NotImplementedError( "Slice only supports literal start and stop values" ) + elif self.name == pl_expr.StringFunction.Strptime: + format, _, exact, cache = self.options + if cache: + raise NotImplementedError("Strptime cache is a CPU feature") + if format is None: + raise NotImplementedError("Strptime format is required") + if not exact: + raise NotImplementedError("Strptime does not support exact=False") + elif self.name in { + pl_expr.StringFunction.StripChars, + pl_expr.StringFunction.StripCharsStart, + pl_expr.StringFunction.StripCharsEnd, + }: + if not isinstance(self.children[1], Literal): + raise NotImplementedError( + "strip operations only support scalar patterns" + ) def do_evaluate( self, @@ -760,12 +829,10 @@ def do_evaluate( else pat.obj ) return Column(plc.strings.find.contains(column.obj, pattern)) - assert isinstance(arg, Literal) - prog = plc.strings.regex_program.RegexProgram.create( - arg.value.as_py(), - flags=plc.strings.regex_flags.RegexFlags.DEFAULT, - ) - return Column(plc.strings.contains.contains_re(column.obj, prog)) + else: + return Column( + plc.strings.contains.contains_re(column.obj, self._regex_program) + ) elif self.name == pl_expr.StringFunction.Slice: child, expr_offset, expr_length = self.children assert isinstance(expr_offset, Literal) @@ -796,6 +863,22 @@ def do_evaluate( plc.interop.from_arrow(pa.scalar(stop, type=pa.int32())), ) ) + elif self.name in { + pl_expr.StringFunction.StripChars, + pl_expr.StringFunction.StripCharsStart, + pl_expr.StringFunction.StripCharsEnd, + }: + column, chars = ( + c.evaluate(df, context=context, mapping=mapping) for c in self.children + ) + if self.name == pl_expr.StringFunction.StripCharsStart: + side = plc.strings.SideType.LEFT + elif self.name == pl_expr.StringFunction.StripCharsEnd: + side = plc.strings.SideType.RIGHT + else: + side = plc.strings.SideType.BOTH + return Column(plc.strings.strip.strip(column.obj, side, chars.obj_scalar)) + columns = [ child.evaluate(df, context=context, mapping=mapping) for child in self.children @@ -826,6 +909,51 @@ def do_evaluate( else prefix.obj, ) ) + elif self.name == pl_expr.StringFunction.Strptime: + # TODO: ignores ambiguous + format, strict, exact, cache = self.options + col = self.children[0].evaluate(df, context=context, mapping=mapping) + + is_timestamps = plc.strings.convert.convert_datetime.is_timestamp( + col.obj, format.encode() + ) + + if strict: + if not plc.interop.to_arrow( + plc.reduce.reduce( + is_timestamps, + plc.aggregation.all(), + plc.DataType(plc.TypeId.BOOL8), + ) + ).as_py(): + raise InvalidOperationError("conversion from `str` failed.") + else: + not_timestamps = plc.unary.unary_operation( + is_timestamps, plc.unary.UnaryOperator.NOT + ) + + null = plc.interop.from_arrow(pa.scalar(None, type=pa.string())) + res = plc.copying.boolean_mask_scatter( + [null], plc.Table([col.obj]), not_timestamps + ) + return Column( + plc.strings.convert.convert_datetime.to_timestamps( + res.columns()[0], self.dtype, format.encode() + ) + ) + elif self.name == pl_expr.StringFunction.Replace: + column, target, repl = columns + n, _ = self.options + return Column( + plc.strings.replace.replace( + column.obj, target.obj_scalar, repl.obj_scalar, maxrepl=n + ) + ) + elif self.name == pl_expr.StringFunction.ReplaceMany: + column, target, repl = columns + return Column( + plc.strings.replace.replace_multiple(column.obj, target.obj, repl.obj) + ) raise NotImplementedError( f"StringFunction {self.name}" ) # pragma: no cover; handled by init raising @@ -833,6 +961,18 @@ def do_evaluate( class TemporalFunction(Expr): __slots__ = ("name", "options", "children") + _COMPONENT_MAP: ClassVar[dict[pl_expr.TemporalFunction, str]] = { + pl_expr.TemporalFunction.Year: "year", + pl_expr.TemporalFunction.Month: "month", + pl_expr.TemporalFunction.Day: "day", + pl_expr.TemporalFunction.WeekDay: "weekday", + pl_expr.TemporalFunction.Hour: "hour", + pl_expr.TemporalFunction.Minute: "minute", + pl_expr.TemporalFunction.Second: "second", + pl_expr.TemporalFunction.Millisecond: "millisecond", + pl_expr.TemporalFunction.Microsecond: "microsecond", + pl_expr.TemporalFunction.Nanosecond: "nanosecond", + } _non_child = ("dtype", "name", "options") children: tuple[Expr, ...] @@ -847,8 +987,8 @@ def __init__( self.options = options self.name = name self.children = children - if self.name != pl_expr.TemporalFunction.Year: - raise NotImplementedError(f"String function {self.name}") + if self.name not in self._COMPONENT_MAP: + raise NotImplementedError(f"Temporal function {self.name}") def do_evaluate( self, @@ -862,12 +1002,59 @@ def do_evaluate( child.evaluate(df, context=context, mapping=mapping) for child in self.children ] - if self.name == pl_expr.TemporalFunction.Year: - (column,) = columns - return Column(plc.datetime.extract_year(column.obj)) - raise NotImplementedError( - f"TemporalFunction {self.name}" - ) # pragma: no cover; init trips first + (column,) = columns + if self.name == pl_expr.TemporalFunction.Microsecond: + millis = plc.datetime.extract_datetime_component(column.obj, "millisecond") + micros = plc.datetime.extract_datetime_component(column.obj, "microsecond") + millis_as_micros = plc.binaryop.binary_operation( + millis, + plc.interop.from_arrow(pa.scalar(1_000, type=pa.int32())), + plc.binaryop.BinaryOperator.MUL, + plc.DataType(plc.TypeId.INT32), + ) + total_micros = plc.binaryop.binary_operation( + micros, + millis_as_micros, + plc.binaryop.BinaryOperator.ADD, + plc.types.DataType(plc.types.TypeId.INT32), + ) + return Column(total_micros) + elif self.name == pl_expr.TemporalFunction.Nanosecond: + millis = plc.datetime.extract_datetime_component(column.obj, "millisecond") + micros = plc.datetime.extract_datetime_component(column.obj, "microsecond") + nanos = plc.datetime.extract_datetime_component(column.obj, "nanosecond") + millis_as_nanos = plc.binaryop.binary_operation( + millis, + plc.interop.from_arrow(pa.scalar(1_000_000, type=pa.int32())), + plc.binaryop.BinaryOperator.MUL, + plc.types.DataType(plc.types.TypeId.INT32), + ) + micros_as_nanos = plc.binaryop.binary_operation( + micros, + plc.interop.from_arrow(pa.scalar(1_000, type=pa.int32())), + plc.binaryop.BinaryOperator.MUL, + plc.types.DataType(plc.types.TypeId.INT32), + ) + total_nanos = plc.binaryop.binary_operation( + nanos, + millis_as_nanos, + plc.binaryop.BinaryOperator.ADD, + plc.types.DataType(plc.types.TypeId.INT32), + ) + total_nanos = plc.binaryop.binary_operation( + total_nanos, + micros_as_nanos, + plc.binaryop.BinaryOperator.ADD, + plc.types.DataType(plc.types.TypeId.INT32), + ) + return Column(total_nanos) + + return Column( + plc.datetime.extract_datetime_component( + column.obj, + self._COMPONENT_MAP[self.name], + ) + ) class UnaryFunction(Expr): @@ -875,6 +1062,51 @@ class UnaryFunction(Expr): _non_child = ("dtype", "name", "options") children: tuple[Expr, ...] + # Note: log, and pow are handled via translation to binops + _OP_MAPPING: ClassVar[dict[str, plc.unary.UnaryOperator]] = { + "sin": plc.unary.UnaryOperator.SIN, + "cos": plc.unary.UnaryOperator.COS, + "tan": plc.unary.UnaryOperator.TAN, + "arcsin": plc.unary.UnaryOperator.ARCSIN, + "arccos": plc.unary.UnaryOperator.ARCCOS, + "arctan": plc.unary.UnaryOperator.ARCTAN, + "sinh": plc.unary.UnaryOperator.SINH, + "cosh": plc.unary.UnaryOperator.COSH, + "tanh": plc.unary.UnaryOperator.TANH, + "arcsinh": plc.unary.UnaryOperator.ARCSINH, + "arccosh": plc.unary.UnaryOperator.ARCCOSH, + "arctanh": plc.unary.UnaryOperator.ARCTANH, + "exp": plc.unary.UnaryOperator.EXP, + "sqrt": plc.unary.UnaryOperator.SQRT, + "cbrt": plc.unary.UnaryOperator.CBRT, + "ceil": plc.unary.UnaryOperator.CEIL, + "floor": plc.unary.UnaryOperator.FLOOR, + "abs": plc.unary.UnaryOperator.ABS, + "bit_invert": plc.unary.UnaryOperator.BIT_INVERT, + "not": plc.unary.UnaryOperator.NOT, + } + _supported_misc_fns = frozenset( + { + "drop_nulls", + "fill_null", + "mask_nans", + "round", + "set_sorted", + "unique", + } + ) + _supported_cum_aggs = frozenset( + { + "cum_min", + "cum_max", + "cum_prod", + "cum_sum", + } + ) + _supported_fns = frozenset().union( + _supported_misc_fns, _supported_cum_aggs, _OP_MAPPING.keys() + ) + def __init__( self, dtype: plc.DataType, name: str, options: tuple[Any, ...], *children: Expr ) -> None: @@ -882,15 +1114,15 @@ def __init__( self.name = name self.options = options self.children = children - if self.name not in ( - "mask_nans", - "round", - "setsorted", - "unique", - "dropnull", - "fill_null", - ): + + if self.name not in UnaryFunction._supported_fns: raise NotImplementedError(f"Unary function {name=}") + if self.name in UnaryFunction._supported_cum_aggs: + (reverse,) = self.options + if reverse: + raise NotImplementedError( + "reverse=True is not supported for cumulative aggregations" + ) def do_evaluate( self, @@ -948,7 +1180,7 @@ def do_evaluate( if maintain_order: return Column(column).sorted_like(values) return Column(column) - elif self.name == "setsorted": + elif self.name == "set_sorted": (column,) = ( child.evaluate(df, context=context, mapping=mapping) for child in self.children @@ -975,7 +1207,7 @@ def do_evaluate( order=order, null_order=null_order, ) - elif self.name == "dropnull": + elif self.name == "drop_nulls": (column,) = ( child.evaluate(df, context=context, mapping=mapping) for child in self.children @@ -995,13 +1227,65 @@ def do_evaluate( ) arg = evaluated.obj_scalar if evaluated.is_scalar else evaluated.obj return Column(plc.replace.replace_nulls(column.obj, arg)) - + elif self.name in self._OP_MAPPING: + column = self.children[0].evaluate(df, context=context, mapping=mapping) + if column.obj.type().id() != self.dtype.id(): + arg = plc.unary.cast(column.obj, self.dtype) + else: + arg = column.obj + return Column(plc.unary.unary_operation(arg, self._OP_MAPPING[self.name])) + elif self.name in UnaryFunction._supported_cum_aggs: + column = self.children[0].evaluate(df, context=context, mapping=mapping) + plc_col = column.obj + col_type = column.obj.type() + # cum_sum casts + # Int8, UInt8, Int16, UInt16 -> Int64 for overflow prevention + # Bool -> UInt32 + # cum_prod casts integer dtypes < int64 and bool to int64 + # See: + # https://github.com/pola-rs/polars/blob/main/crates/polars-ops/src/series/ops/cum_agg.rs + if ( + self.name == "cum_sum" + and col_type.id() + in { + plc.types.TypeId.INT8, + plc.types.TypeId.UINT8, + plc.types.TypeId.INT16, + plc.types.TypeId.UINT16, + } + ) or ( + self.name == "cum_prod" + and plc.traits.is_integral(col_type) + and plc.types.size_of(col_type) <= 4 + ): + plc_col = plc.unary.cast( + plc_col, plc.types.DataType(plc.types.TypeId.INT64) + ) + elif ( + self.name == "cum_sum" + and column.obj.type().id() == plc.types.TypeId.BOOL8 + ): + plc_col = plc.unary.cast( + plc_col, plc.types.DataType(plc.types.TypeId.UINT32) + ) + if self.name == "cum_sum": + agg = plc.aggregation.sum() + elif self.name == "cum_prod": + agg = plc.aggregation.product() + elif self.name == "cum_min": + agg = plc.aggregation.min() + elif self.name == "cum_max": + agg = plc.aggregation.max() + + return Column(plc.reduce.scan(plc_col, agg, plc.reduce.ScanType.INCLUSIVE)) raise NotImplementedError( f"Unimplemented unary function {self.name=}" ) # pragma: no cover; init trips first def collect_agg(self, *, depth: int) -> AggInfo: """Collect information about aggregations in groupbys.""" + if self.name in {"unique", "drop_nulls"} | self._supported_cum_aggs: + raise NotImplementedError(f"{self.name} in groupby") if depth == 1: # inside aggregation, need to pre-evaluate, groupby # construction has checked that we don't have nested aggs, @@ -1188,11 +1472,7 @@ class Cast(Expr): def __init__(self, dtype: plc.DataType, value: Expr) -> None: super().__init__(dtype) self.children = (value,) - if not ( - plc.traits.is_fixed_width(self.dtype) - and plc.traits.is_fixed_width(value.dtype) - and plc.unary.is_supported_cast(value.dtype, self.dtype) - ): + if not dtypes.can_cast(value.dtype, self.dtype): raise NotImplementedError( f"Can't cast {self.dtype.id().name} to {value.dtype.id().name}" ) @@ -1256,6 +1536,13 @@ def __init__( req = plc.aggregation.variance(ddof=options) elif name == "count": req = plc.aggregation.count(null_handling=plc.types.NullPolicy.EXCLUDE) + elif name == "quantile": + _, quantile = self.children + if not isinstance(quantile, Literal): + raise NotImplementedError("Only support literal quantile values") + req = plc.aggregation.quantile( + quantiles=[quantile.value.as_py()], interp=Agg.interp_mapping[options] + ) else: raise NotImplementedError( f"Unreachable, {name=} is incorrectly listed in _SUPPORTED" @@ -1287,9 +1574,18 @@ def __init__( "count", "std", "var", + "quantile", ] ) + interp_mapping: ClassVar[dict[str, plc.types.Interpolation]] = { + "nearest": plc.types.Interpolation.NEAREST, + "higher": plc.types.Interpolation.HIGHER, + "lower": plc.types.Interpolation.LOWER, + "midpoint": plc.types.Interpolation.MIDPOINT, + "linear": plc.types.Interpolation.LINEAR, + } + def collect_agg(self, *, depth: int) -> AggInfo: """Collect information about aggregations in groupbys.""" if depth >= 1: @@ -1300,7 +1596,19 @@ def collect_agg(self, *, depth: int) -> AggInfo: raise NotImplementedError("Nan propagation in groupby for min/max") (child,) = self.children ((expr, _, _),) = child.collect_agg(depth=depth + 1).requests - if self.request is None: + request = self.request + # These are handled specially here because we don't set up the + # request for the whole-frame agg because we can avoid a + # reduce for these. + if self.name == "first": + request = plc.aggregation.nth_element( + 0, null_handling=plc.types.NullPolicy.INCLUDE + ) + elif self.name == "last": + request = plc.aggregation.nth_element( + -1, null_handling=plc.types.NullPolicy.INCLUDE + ) + if request is None: raise NotImplementedError( f"Aggregation {self.name} in groupby" ) # pragma: no cover; __init__ trips first @@ -1309,7 +1617,7 @@ def collect_agg(self, *, depth: int) -> AggInfo: # Ignore nans in these groupby aggs, do this by masking # nans in the input expr = UnaryFunction(self.dtype, "mask_nans", (), expr) - return AggInfo([(expr, self.request, self)]) + return AggInfo([(expr, request, self)]) def _reduce( self, column: Column, *, request: plc.aggregation.Aggregation @@ -1381,7 +1689,10 @@ def do_evaluate( raise NotImplementedError( f"Agg in context {context}" ) # pragma: no cover; unreachable - (child,) = self.children + + # Aggregations like quantiles may have additional children that were + # preprocessed into pylibcudf requests. + child = self.children[0] return self.op(child.evaluate(df, context=context, mapping=mapping)) @@ -1426,6 +1737,11 @@ def __init__( right: Expr, ) -> None: super().__init__(dtype) + if plc.traits.is_boolean(self.dtype): + # For boolean output types, bitand and bitor implement + # boolean logic, so translate. bitxor also does, but the + # default behaviour is correct. + op = BinOp._BOOL_KLEENE_MAPPING.get(op, op) self.op = op self.children = (left, right) if not plc.binaryop.is_supported_operation( @@ -1437,6 +1753,15 @@ def __init__( f"with output type {self.dtype.id().name}" ) + _BOOL_KLEENE_MAPPING: ClassVar[ + dict[plc.binaryop.BinaryOperator, plc.binaryop.BinaryOperator] + ] = { + plc.binaryop.BinaryOperator.BITWISE_AND: plc.binaryop.BinaryOperator.NULL_LOGICAL_AND, + plc.binaryop.BinaryOperator.BITWISE_OR: plc.binaryop.BinaryOperator.NULL_LOGICAL_OR, + plc.binaryop.BinaryOperator.LOGICAL_AND: plc.binaryop.BinaryOperator.NULL_LOGICAL_AND, + plc.binaryop.BinaryOperator.LOGICAL_OR: plc.binaryop.BinaryOperator.NULL_LOGICAL_OR, + } + _MAPPING: ClassVar[dict[pl_expr.Operator, plc.binaryop.BinaryOperator]] = { pl_expr.Operator.Eq: plc.binaryop.BinaryOperator.EQUAL, pl_expr.Operator.EqValidity: plc.binaryop.BinaryOperator.NULL_EQUALS, diff --git a/python/cudf_polars/cudf_polars/dsl/ir.py b/python/cudf_polars/cudf_polars/dsl/ir.py index 7f62dff4389..e27c7827e9a 100644 --- a/python/cudf_polars/cudf_polars/dsl/ir.py +++ b/python/cudf_polars/cudf_polars/dsl/ir.py @@ -15,7 +15,6 @@ import dataclasses import itertools -import types from functools import cache from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, ClassVar @@ -29,7 +28,7 @@ import cudf_polars.dsl.expr as expr from cudf_polars.containers import DataFrame, NamedColumn -from cudf_polars.utils import sorting +from cudf_polars.utils import dtypes, sorting if TYPE_CHECKING: from collections.abc import MutableMapping @@ -134,8 +133,7 @@ class IR: def __post_init__(self): """Validate preconditions.""" - if any(dtype.id() == plc.TypeId.EMPTY for dtype in self.schema.values()): - raise NotImplementedError("Cannot make empty columns.") + pass # noqa: PIE790 def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: """ @@ -190,32 +188,42 @@ class Scan(IR): """Cloud-related authentication options, currently ignored.""" paths: list[str] """List of paths to read from.""" - file_options: Any - """Options for reading the file. - - Attributes are: - - ``with_columns: list[str]`` of projected columns to return. - - ``n_rows: int``: Number of rows to read. - - ``row_index: tuple[name, offset] | None``: Add an integer index - column with given name. - """ + with_columns: list[str] + """Projected columns to return.""" + skip_rows: int + """Rows to skip at the start when reading.""" + n_rows: int + """Number of rows to read after skipping.""" + row_index: tuple[str, int] | None + """If not None add an integer index column of the given name.""" predicate: expr.NamedExpr | None """Mask to apply to the read dataframe.""" def __post_init__(self) -> None: """Validate preconditions.""" + super().__post_init__() if self.typ not in ("csv", "parquet", "ndjson"): # pragma: no cover # This line is unhittable ATM since IPC/Anonymous scan raise # on the polars side raise NotImplementedError(f"Unhandled scan type: {self.typ}") - if self.typ == "ndjson" and self.file_options.n_rows is not None: - raise NotImplementedError("row limit in scan") + if self.typ == "ndjson" and (self.n_rows != -1 or self.skip_rows != 0): + raise NotImplementedError("row limit in scan for json reader") + if self.skip_rows < 0: + # TODO: polars has this implemented for parquet, + # maybe we can do this too? + raise NotImplementedError("slice pushdown for negative slices") + if self.typ == "csv" and self.skip_rows != 0: # pragma: no cover + # This comes from slice pushdown, but that + # optimization doesn't happen right now + raise NotImplementedError("skipping rows in CSV reader") if self.cloud_options is not None and any( self.cloud_options.get(k) is not None for k in ("aws", "azure", "gcp") ): raise NotImplementedError( "Read from cloud storage" ) # pragma: no cover; no test yet + if any(p.startswith("https://") for p in self.paths): + raise NotImplementedError("Read from https") if self.typ == "csv": if self.reader_options["skip_rows_after_header"] != 0: raise NotImplementedError("Skipping rows after header in CSV reader") @@ -243,13 +251,21 @@ def __post_init__(self) -> None: raise NotImplementedError( "ignore_errors is not supported in the JSON reader" ) + elif ( + self.typ == "parquet" + and self.row_index is not None + and self.with_columns is not None + and len(self.with_columns) == 0 + ): + raise NotImplementedError( + "Reading only parquet metadata to produce row index." + ) def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: """Evaluate and return a dataframe.""" - options = self.file_options - with_columns = options.with_columns - row_index = options.row_index - nrows = self.file_options.n_rows if self.file_options.n_rows is not None else -1 + with_columns = self.with_columns + row_index = self.row_index + n_rows = self.n_rows if self.typ == "csv": parse_options = self.reader_options["parse_options"] sep = chr(parse_options["separator"]) @@ -257,7 +273,7 @@ def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: eol = chr(parse_options["eol_char"]) if self.reader_options["schema"] is not None: # Reader schema provides names - column_names = list(self.reader_options["schema"]["inner"].keys()) + column_names = list(self.reader_options["schema"]["fields"].keys()) else: # file provides column names column_names = None @@ -283,6 +299,7 @@ def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: # polars skips blank lines at the beginning of the file pieces = [] + read_partial = n_rows != -1 for p in self.paths: skiprows = self.reader_options["skip_rows"] path = Path(p) @@ -304,9 +321,13 @@ def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: comment=comment, decimal=decimal, dtypes=self.schema, - nrows=nrows, + nrows=n_rows, ) pieces.append(tbl_w_meta) + if read_partial: + n_rows -= tbl_w_meta.tbl.num_rows() + if n_rows <= 0: + break tables, colnames = zip( *( (piece.tbl, piece.column_names(include_children=False)) @@ -321,7 +342,8 @@ def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: tbl_w_meta = plc.io.parquet.read_parquet( plc.io.SourceInfo(self.paths), columns=with_columns, - num_rows=nrows, + num_rows=n_rows, + skip_rows=self.skip_rows, ) df = DataFrame.from_table( tbl_w_meta.tbl, @@ -354,12 +376,7 @@ def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: raise NotImplementedError( f"Unhandled scan type: {self.typ}" ) # pragma: no cover; post init trips first - if ( - row_index is not None - # TODO: remove condition when dropping support for polars 1.0 - # https://github.com/pola-rs/polars/pull/17363 - and row_index[0] in self.schema - ): + if row_index is not None: name, offset = row_index dtype = self.schema[name] step = plc.interop.from_arrow( @@ -480,36 +497,6 @@ def evaluate( return DataFrame(columns) -def placeholder_column(n: int) -> plc.Column: - """ - Produce a placeholder pylibcudf column with NO BACKING DATA. - - Parameters - ---------- - n - Number of rows the column will advertise - - Returns - ------- - pylibcudf Column that is almost unusable. DO NOT ACCESS THE DATA BUFFER. - - Notes - ----- - This is used to avoid allocating data for count aggregations. - """ - return plc.Column( - plc.DataType(plc.TypeId.INT8), - n, - plc.gpumemoryview( - types.SimpleNamespace(__cuda_array_interface__={"data": (1, True)}) - ), - None, - 0, - 0, - [], - ) - - @dataclasses.dataclass class GroupBy(IR): """Perform a groupby.""" @@ -556,8 +543,7 @@ def check_agg(agg: expr.Expr) -> int: def __post_init__(self) -> None: """Check whether all the aggregations are implemented.""" - if self.options.rolling is None and self.maintain_order: - raise NotImplementedError("Maintaining order in groupby") + super().__post_init__() if self.options.rolling: raise NotImplementedError( "rolling window/groupby" @@ -565,6 +551,8 @@ def __post_init__(self) -> None: if any(GroupBy.check_agg(a.value) > 1 for a in self.agg_requests): raise NotImplementedError("Nested aggregations in groupby") self.agg_infos = [req.collect_agg(depth=0) for req in self.agg_requests] + if len(self.keys) == 0: + raise NotImplementedError("dynamic groupby") def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: """Evaluate and return a dataframe.""" @@ -590,7 +578,10 @@ def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: for info in self.agg_infos: for pre_eval, req, rep in info.requests: if pre_eval is None: - col = placeholder_column(df.num_rows) + # A count aggregation, doesn't touch the column, + # but we need to have one. Rather than evaluating + # one, just use one of the key columns. + col = keys[0].obj else: col = pre_eval.evaluate(df).obj requests.append(plc.groupby.GroupByRequest(col, [req])) @@ -609,7 +600,32 @@ def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: results = [ req.evaluate(result_subs, mapping=mapping) for req in self.agg_requests ] - return DataFrame(broadcast(*result_keys, *results)).slice(self.options.slice) + broadcasted = broadcast(*result_keys, *results) + result_keys = broadcasted[: len(result_keys)] + results = broadcasted[len(result_keys) :] + # Handle order preservation of groups + # like cudf classic does + # https://github.com/rapidsai/cudf/blob/5780c4d8fb5afac2e04988a2ff5531f94c22d3a3/python/cudf/cudf/core/groupby/groupby.py#L723-L743 + if self.maintain_order and not sorted: + left = plc.stream_compaction.stable_distinct( + plc.Table([k.obj for k in keys]), + list(range(group_keys.num_columns())), + plc.stream_compaction.DuplicateKeepOption.KEEP_FIRST, + plc.types.NullEquality.EQUAL, + plc.types.NanEquality.ALL_EQUAL, + ) + right = plc.Table([key.obj for key in result_keys]) + _, indices = plc.join.left_join(left, right, plc.types.NullEquality.EQUAL) + ordered_table = plc.copying.gather( + plc.Table([col.obj for col in broadcasted]), + indices, + plc.copying.OutOfBoundsPolicy.DONT_CHECK, + ) + broadcasted = [ + NamedColumn(reordered, b.name) + for reordered, b in zip(ordered_table.columns(), broadcasted) + ] + return DataFrame(broadcasted).slice(self.options.slice) @dataclasses.dataclass @@ -625,7 +641,7 @@ class Join(IR): right_on: list[expr.NamedExpr] """List of expressions used as keys in the right frame.""" options: tuple[ - Literal["inner", "left", "full", "leftsemi", "leftanti", "cross"], + Literal["inner", "left", "right", "full", "leftsemi", "leftanti", "cross"], bool, tuple[int, int] | None, str | None, @@ -642,6 +658,7 @@ class Join(IR): def __post_init__(self) -> None: """Validate preconditions.""" + super().__post_init__() if any( isinstance(e.value, expr.Literal) for e in itertools.chain(self.left_on, self.right_on) @@ -651,7 +668,7 @@ def __post_init__(self) -> None: @staticmethod @cache def _joiners( - how: Literal["inner", "left", "full", "leftsemi", "leftanti"], + how: Literal["inner", "left", "right", "full", "leftsemi", "leftanti"], ) -> tuple[ Callable, plc.copying.OutOfBoundsPolicy, plc.copying.OutOfBoundsPolicy | None ]: @@ -661,7 +678,7 @@ def _joiners( plc.copying.OutOfBoundsPolicy.DONT_CHECK, plc.copying.OutOfBoundsPolicy.DONT_CHECK, ) - elif how == "left": + elif how == "left" or how == "right": return ( plc.join.left_join, plc.copying.OutOfBoundsPolicy.DONT_CHECK, @@ -685,8 +702,7 @@ def _joiners( plc.copying.OutOfBoundsPolicy.DONT_CHECK, None, ) - else: - assert_never(how) + assert_never(how) def _reorder_maps( self, @@ -780,8 +796,12 @@ def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: table = plc.copying.gather(left.table, lg, left_policy) result = DataFrame.from_table(table, left.column_names) else: + if how == "right": + # Right join is a left join with the tables swapped + left, right = right, left + left_on, right_on = right_on, left_on lg, rg = join_fn(left_on.table, right_on.table, null_equality) - if how == "left": + if how == "left" or how == "right": # Order of left table is preserved lg, rg = self._reorder_maps( left.num_rows, lg, left_policy, right.num_rows, rg, right_policy @@ -808,6 +828,9 @@ def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: ) ) right = right.discard_columns(right_on.column_names_set) + if how == "right": + # Undo the swap for right join before gluing together. + left, right = right, left right = right.rename_columns( { name: f"{name}{suffix}" @@ -1057,11 +1080,13 @@ class MapFunction(IR): # "merge_sorted", "rename", "explode", + "unpivot", ] ) def __post_init__(self) -> None: """Validate preconditions.""" + super().__post_init__() if self.name not in MapFunction._NAMES: raise NotImplementedError(f"Unhandled map function {self.name}") if self.name == "explode": @@ -1078,6 +1103,22 @@ def __post_init__(self) -> None: set(new) & (set(self.df.schema.keys() - set(old))) ): raise NotImplementedError("Duplicate new names in rename.") + elif self.name == "unpivot": + indices, pivotees, variable_name, value_name = self.options + value_name = "value" if value_name is None else value_name + variable_name = "variable" if variable_name is None else variable_name + if len(pivotees) == 0: + index = frozenset(indices) + pivotees = [name for name in self.df.schema if name not in index] + if not all( + dtypes.can_cast(self.df.schema[p], self.schema[value_name]) + for p in pivotees + ): + raise NotImplementedError( + "Unpivot cannot cast all input columns to " + f"{self.schema[value_name].id()}" + ) + self.options = (indices, pivotees, variable_name, value_name) def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: """Evaluate and return a dataframe.""" @@ -1099,6 +1140,40 @@ def evaluate(self, *, cache: MutableMapping[int, DataFrame]) -> DataFrame: return DataFrame.from_table( plc.lists.explode_outer(df.table, index), df.column_names ).sorted_like(df, subset=subset) + elif self.name == "unpivot": + indices, pivotees, variable_name, value_name = self.options + npiv = len(pivotees) + df = self.df.evaluate(cache=cache) + index_columns = [ + NamedColumn(col, name) + for col, name in zip( + plc.reshape.tile(df.select(indices).table, npiv).columns(), + indices, + ) + ] + (variable_column,) = plc.filling.repeat( + plc.Table( + [ + plc.interop.from_arrow( + pa.array( + pivotees, + type=plc.interop.to_arrow(self.schema[variable_name]), + ), + ) + ] + ), + df.num_rows, + ).columns() + value_column = plc.concatenate.concatenate( + [c.astype(self.schema[value_name]) for c in df.select(pivotees).columns] + ) + return DataFrame( + [ + *index_columns, + NamedColumn(variable_column, variable_name), + NamedColumn(value_column, value_name), + ] + ) else: raise AssertionError("Should never be reached") # pragma: no cover @@ -1114,6 +1189,7 @@ class Union(IR): def __post_init__(self) -> None: """Validate preconditions.""" + super().__post_init__() schema = self.dfs[0].schema if not all(s.schema == schema for s in self.dfs[1:]): raise NotImplementedError("Schema mismatch") diff --git a/python/cudf_polars/cudf_polars/dsl/translate.py b/python/cudf_polars/cudf_polars/dsl/translate.py index dec45679c75..2886f1c684f 100644 --- a/python/cudf_polars/cudf_polars/dsl/translate.py +++ b/python/cudf_polars/cudf_polars/dsl/translate.py @@ -76,13 +76,12 @@ def _translate_ir( def _( node: pl_ir.PythonScan, visitor: NodeTraverser, schema: dict[str, plc.DataType] ) -> ir.IR: - return ir.PythonScan( - schema, - node.options, - translate_named_expr(visitor, n=node.predicate) - if node.predicate is not None - else None, + scan_fn, with_columns, source_type, predicate, nrows = node.options + options = (scan_fn, with_columns, source_type, nrows) + predicate = ( + translate_named_expr(visitor, n=predicate) if predicate is not None else None ) + return ir.PythonScan(schema, options, predicate) @_translate_ir.register @@ -95,13 +94,34 @@ def _( cloud_options = None else: reader_options, cloud_options = map(json.loads, options) + if ( + typ == "csv" + and visitor.version()[0] == 1 + and reader_options["schema"] is not None + ): + # Polars 1.7 renames the inner slot from "inner" to "fields". + reader_options["schema"] = {"fields": reader_options["schema"]["inner"]} + file_options = node.file_options + with_columns = file_options.with_columns + n_rows = file_options.n_rows + if n_rows is None: + n_rows = -1 # All rows + skip_rows = 0 # Don't skip + else: + # TODO: with versioning, rename on the rust side + skip_rows, n_rows = n_rows + + row_index = file_options.row_index return ir.Scan( schema, typ, reader_options, cloud_options, node.paths, - node.file_options, + with_columns, + skip_rows, + n_rows, + row_index, translate_named_expr(visitor, n=node.predicate) if node.predicate is not None else None, @@ -294,10 +314,28 @@ def translate_ir(visitor: NodeTraverser, *, n: int | None = None) -> ir.IR: ctx: AbstractContextManager[None] = ( set_node(visitor, n) if n is not None else noop_context ) + # IR is versioned with major.minor, minor is bumped for backwards + # compatible changes (e.g. adding new nodes), major is bumped for + # incompatible changes (e.g. renaming nodes). + # Polars 1.7 changes definition of the CSV reader options schema name. + if (version := visitor.version()) >= (3, 0): + raise NotImplementedError( + f"No support for polars IR {version=}" + ) # pragma: no cover; no such version for now. + with ctx: + polars_schema = visitor.get_schema() node = visitor.view_current_node() - schema = {k: dtypes.from_polars(v) for k, v in visitor.get_schema().items()} - return _translate_ir(node, visitor, schema) + schema = {k: dtypes.from_polars(v) for k, v in polars_schema.items()} + result = _translate_ir(node, visitor, schema) + if any( + isinstance(dtype, pl.Null) + for dtype in pl.datatypes.unpack_dtypes(*polars_schema.values()) + ): + raise NotImplementedError( + f"No GPU support for {result} with Null column dtype." + ) + return result def translate_named_expr( @@ -346,6 +384,24 @@ def _(node: pl_expr.Function, visitor: NodeTraverser, dtype: plc.DataType) -> ex name, *options = node.function_data options = tuple(options) if isinstance(name, pl_expr.StringFunction): + if name in { + pl_expr.StringFunction.StripChars, + pl_expr.StringFunction.StripCharsStart, + pl_expr.StringFunction.StripCharsEnd, + }: + column, chars = (translate_expr(visitor, n=n) for n in node.input) + if isinstance(chars, expr.Literal): + if chars.value == pa.scalar(""): + # No-op in polars, but libcudf uses empty string + # as signifier to remove whitespace. + return column + elif chars.value == pa.scalar(None): + # Polars uses None to mean "strip all whitespace" + chars = expr.Literal( + column.dtype, + pa.scalar("", type=plc.interop.to_arrow(column.dtype)), + ) + return expr.StringFunction(dtype, name, options, column, chars) return expr.StringFunction( dtype, name, @@ -370,19 +426,43 @@ def _(node: pl_expr.Function, visitor: NodeTraverser, dtype: plc.DataType) -> ex *(translate_expr(visitor, n=n) for n in node.input), ) elif isinstance(name, pl_expr.TemporalFunction): - return expr.TemporalFunction( + # functions for which evaluation of the expression may not return + # the same dtype as polars, either due to libcudf returning a different + # dtype, or due to our internal processing affecting what libcudf returns + needs_cast = { + pl_expr.TemporalFunction.Year, + pl_expr.TemporalFunction.Month, + pl_expr.TemporalFunction.Day, + pl_expr.TemporalFunction.WeekDay, + pl_expr.TemporalFunction.Hour, + pl_expr.TemporalFunction.Minute, + pl_expr.TemporalFunction.Second, + pl_expr.TemporalFunction.Millisecond, + } + result_expr = expr.TemporalFunction( dtype, name, options, *(translate_expr(visitor, n=n) for n in node.input), ) + if name in needs_cast: + return expr.Cast(dtype, result_expr) + return result_expr + elif isinstance(name, str): - return expr.UnaryFunction( - dtype, - name, - options, - *(translate_expr(visitor, n=n) for n in node.input), - ) + children = (translate_expr(visitor, n=n) for n in node.input) + if name == "log": + (base,) = options + (child,) = children + return expr.BinOp( + dtype, + plc.binaryop.BinaryOperator.LOG_BASE, + child, + expr.Literal(dtype, pa.scalar(base, type=plc.interop.to_arrow(dtype))), + ) + elif name == "pow": + return expr.BinOp(dtype, plc.binaryop.BinaryOperator.POW, *children) + return expr.UnaryFunction(dtype, name, options, *children) raise NotImplementedError( f"No handler for Expr function node with {name=}" ) # pragma: no cover; polars raises on the rust side for now diff --git a/python/cudf_polars/cudf_polars/testing/asserts.py b/python/cudf_polars/cudf_polars/testing/asserts.py index d37c96a15de..a79d45899cd 100644 --- a/python/cudf_polars/cudf_polars/testing/asserts.py +++ b/python/cudf_polars/cudf_polars/testing/asserts.py @@ -5,12 +5,11 @@ from __future__ import annotations -from functools import partial from typing import TYPE_CHECKING +from polars import GPUEngine from polars.testing.asserts import assert_frame_equal -from cudf_polars.callback import execute_with_cudf from cudf_polars.dsl.translate import translate_ir if TYPE_CHECKING: @@ -77,21 +76,13 @@ def assert_gpu_result_equal( NotImplementedError If GPU collection failed in some way. """ - if collect_kwargs is None: - collect_kwargs = {} - final_polars_collect_kwargs = collect_kwargs.copy() - final_cudf_collect_kwargs = collect_kwargs.copy() - if polars_collect_kwargs is not None: - final_polars_collect_kwargs.update(polars_collect_kwargs) - if cudf_collect_kwargs is not None: # pragma: no cover - # exclude from coverage since not used ATM - # but this is probably still useful - final_cudf_collect_kwargs.update(cudf_collect_kwargs) - expect = lazydf.collect(**final_polars_collect_kwargs) - got = lazydf.collect( - **final_cudf_collect_kwargs, - post_opt_callback=partial(execute_with_cudf, raise_on_fail=True), + final_polars_collect_kwargs, final_cudf_collect_kwargs = _process_kwargs( + collect_kwargs, polars_collect_kwargs, cudf_collect_kwargs ) + + expect = lazydf.collect(**final_polars_collect_kwargs) + engine = GPUEngine(raise_on_fail=True) + got = lazydf.collect(**final_cudf_collect_kwargs, engine=engine) assert_frame_equal( expect, got, @@ -134,3 +125,94 @@ def assert_ir_translation_raises(q: pl.LazyFrame, *exceptions: type[Exception]) raise AssertionError(f"Translation DID NOT RAISE {exceptions}") from e else: raise AssertionError(f"Translation DID NOT RAISE {exceptions}") + + +def _process_kwargs( + collect_kwargs: dict[OptimizationArgs, bool] | None, + polars_collect_kwargs: dict[OptimizationArgs, bool] | None, + cudf_collect_kwargs: dict[OptimizationArgs, bool] | None, +) -> tuple[dict[OptimizationArgs, bool], dict[OptimizationArgs, bool]]: + if collect_kwargs is None: + collect_kwargs = {} + final_polars_collect_kwargs = collect_kwargs.copy() + final_cudf_collect_kwargs = collect_kwargs.copy() + if polars_collect_kwargs is not None: # pragma: no cover; not currently used + final_polars_collect_kwargs.update(polars_collect_kwargs) + if cudf_collect_kwargs is not None: # pragma: no cover; not currently used + final_cudf_collect_kwargs.update(cudf_collect_kwargs) + return final_polars_collect_kwargs, final_cudf_collect_kwargs + + +def assert_collect_raises( + lazydf: pl.LazyFrame, + *, + polars_except: type[Exception] | tuple[type[Exception], ...], + cudf_except: type[Exception] | tuple[type[Exception], ...], + collect_kwargs: dict[OptimizationArgs, bool] | None = None, + polars_collect_kwargs: dict[OptimizationArgs, bool] | None = None, + cudf_collect_kwargs: dict[OptimizationArgs, bool] | None = None, +): + """ + Assert that collecting the result of a query raises the expected exceptions. + + Parameters + ---------- + lazydf + frame to collect. + collect_kwargs + Common keyword arguments to pass to collect for both polars CPU and + cudf-polars. + Useful for controlling optimization settings. + polars_except + Exception or exceptions polars CPU is expected to raise. + cudf_except + Exception or exceptions polars GPU is expected to raise. + collect_kwargs + Common keyword arguments to pass to collect for both polars CPU and + cudf-polars. + Useful for controlling optimization settings. + polars_collect_kwargs + Keyword arguments to pass to collect for execution on polars CPU. + Overrides kwargs in collect_kwargs. + Useful for controlling optimization settings. + cudf_collect_kwargs + Keyword arguments to pass to collect for execution on cudf-polars. + Overrides kwargs in collect_kwargs. + Useful for controlling optimization settings. + + Returns + ------- + None + If both sides raise the expected exceptions. + + Raises + ------ + AssertionError + If either side did not raise the expected exceptions. + """ + final_polars_collect_kwargs, final_cudf_collect_kwargs = _process_kwargs( + collect_kwargs, polars_collect_kwargs, cudf_collect_kwargs + ) + + try: + lazydf.collect(**final_polars_collect_kwargs) + except polars_except: + pass + except Exception as e: + raise AssertionError( + f"CPU execution RAISED {type(e)}, EXPECTED {polars_except}" + ) from e + else: + raise AssertionError(f"CPU execution DID NOT RAISE {polars_except}") + + engine = GPUEngine(raise_on_fail=True) + try: + lazydf.collect(**final_cudf_collect_kwargs, engine=engine) + except cudf_except: + pass + except Exception as e: + raise AssertionError( + f"GPU execution RAISED {type(e)}, EXPECTED {polars_except}" + ) from e + else: + raise AssertionError(f"GPU execution DID NOT RAISE {polars_except}") diff --git a/python/cudf_polars/cudf_polars/testing/plugin.py b/python/cudf_polars/cudf_polars/testing/plugin.py new file mode 100644 index 00000000000..7be40f6f762 --- /dev/null +++ b/python/cudf_polars/cudf_polars/testing/plugin.py @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. +# SPDX-License-Identifier: Apache-2.0 + +"""Plugin for running polars test suite setting GPU engine as default.""" + +from __future__ import annotations + +from functools import partialmethod +from typing import TYPE_CHECKING + +import pytest + +import polars + +if TYPE_CHECKING: + from collections.abc import Mapping + + +def pytest_addoption(parser: pytest.Parser): + """Add plugin-specific options.""" + group = parser.getgroup( + "cudf-polars", "Plugin to set GPU as default engine for polars tests" + ) + group.addoption( + "--cudf-polars-no-fallback", + action="store_true", + help="Turn off fallback to CPU when running tests (default use fallback)", + ) + + +def pytest_configure(config: pytest.Config): + """Enable use of this module as a pytest plugin to enable GPU collection.""" + no_fallback = config.getoption("--cudf-polars-no-fallback") + collect = polars.LazyFrame.collect + engine = polars.GPUEngine(raise_on_fail=no_fallback) + polars.LazyFrame.collect = partialmethod(collect, engine=engine) + config.addinivalue_line( + "filterwarnings", + "ignore:.*GPU engine does not support streaming or background collection", + ) + config.addinivalue_line( + "filterwarnings", + "ignore:.*Query execution with GPU not supported", + ) + + +EXPECTED_FAILURES: Mapping[str, str] = { + "tests/unit/io/test_csv.py::test_compressed_csv": "Need to determine if file is compressed", + "tests/unit/io/test_csv.py::test_read_csv_only_loads_selected_columns": "Memory usage won't be correct due to GPU", + "tests/unit/io/test_lazy_count_star.py::test_count_compressed_csv_18057": "Need to determine if file is compressed", + "tests/unit/io/test_lazy_csv.py::test_scan_csv_slice_offset_zero": "Integer overflow in sliced read", + "tests/unit/io/test_lazy_parquet.py::test_parquet_is_in_statistics": "Debug output on stderr doesn't match", + "tests/unit/io/test_lazy_parquet.py::test_parquet_statistics": "Debug output on stderr doesn't match", + "tests/unit/io/test_lazy_parquet.py::test_parquet_different_schema[False]": "Needs cudf#16394", + "tests/unit/io/test_lazy_parquet.py::test_parquet_schema_mismatch_panic_17067[False]": "Needs cudf#16394", + "tests/unit/io/test_lazy_parquet.py::test_parquet_slice_pushdown_non_zero_offset[True]": "Unknown error: invalid parquet?", + "tests/unit/io/test_lazy_parquet.py::test_parquet_slice_pushdown_non_zero_offset[False]": "Unknown error: invalid parquet?", + "tests/unit/io/test_parquet.py::test_read_parquet_only_loads_selected_columns_15098": "Memory usage won't be correct due to GPU", + "tests/unit/io/test_scan.py::test_scan[single-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_limit[single-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_filter[single-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_filter_and_limit[single-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_limit_and_filter[single-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_and_limit[single-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_and_filter[single-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_limit_and_filter[single-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan[glob-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_limit[glob-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_filter[glob-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_filter_and_limit[glob-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_limit_and_filter[glob-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_and_limit[glob-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_and_filter[glob-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_limit_and_filter[glob-csv-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan[glob-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_limit[glob-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_filter[glob-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_filter_and_limit[glob-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_limit_and_filter[glob-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_and_limit[glob-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_and_filter[glob-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_limit_and_filter[glob-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_projected_out[glob-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_filter_and_limit[glob-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan[single-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_limit[single-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_filter[single-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_filter_and_limit[single-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_limit_and_filter[single-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_and_limit[single-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_and_filter[single-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_limit_and_filter[single-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_projected_out[single-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_with_row_index_filter_and_limit[single-parquet-async]": "Debug output on stderr doesn't match", + "tests/unit/io/test_scan.py::test_scan_include_file_name[False-scan_parquet-write_parquet]": "Need to add include_file_path to IR", + "tests/unit/io/test_scan.py::test_scan_include_file_name[False-scan_csv-write_csv]": "Need to add include_file_path to IR", + "tests/unit/io/test_scan.py::test_scan_include_file_name[False-scan_ndjson-write_ndjson]": "Need to add include_file_path to IR", + "tests/unit/lazyframe/test_engine_selection.py::test_engine_import_error_raises[gpu]": "Expect this to pass because cudf-polars is installed", + "tests/unit/lazyframe/test_engine_selection.py::test_engine_import_error_raises[engine1]": "Expect this to pass because cudf-polars is installed", + "tests/unit/lazyframe/test_lazyframe.py::test_round[dtype1-123.55-1-123.6]": "Rounding midpoints is handled incorrectly", + "tests/unit/lazyframe/test_lazyframe.py::test_cast_frame": "Casting that raises not supported on GPU", + "tests/unit/lazyframe/test_lazyframe.py::test_lazy_cache_hit": "Debug output on stderr doesn't match", + "tests/unit/operations/aggregation/test_aggregations.py::test_duration_function_literal": "Broadcasting inside groupby-agg not supported", + "tests/unit/operations/aggregation/test_aggregations.py::test_sum_empty_and_null_set": "libcudf sums column of all nulls to null, not zero", + "tests/unit/operations/aggregation/test_aggregations.py::test_binary_op_agg_context_no_simplify_expr_12423": "groupby-agg of just literals should not produce collect_list", + "tests/unit/operations/aggregation/test_aggregations.py::test_nan_inf_aggregation": "treatment of nans and nulls together is different in libcudf and polars in groupby-agg context", + "tests/unit/operations/test_abs.py::test_abs_duration": "Need to raise for unsupported uops on timelike values", + "tests/unit/operations/test_group_by.py::test_group_by_mean_by_dtype[input7-expected7-Float32-Float32]": "Mismatching dtypes, needs cudf#15852", + "tests/unit/operations/test_group_by.py::test_group_by_mean_by_dtype[input10-expected10-Date-output_dtype10]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_mean_by_dtype[input11-expected11-input_dtype11-output_dtype11]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_mean_by_dtype[input12-expected12-input_dtype12-output_dtype12]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_mean_by_dtype[input13-expected13-input_dtype13-output_dtype13]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_median_by_dtype[input7-expected7-Float32-Float32]": "Mismatching dtypes, needs cudf#15852", + "tests/unit/operations/test_group_by.py::test_group_by_median_by_dtype[input10-expected10-Date-output_dtype10]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_median_by_dtype[input11-expected11-input_dtype11-output_dtype11]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_median_by_dtype[input12-expected12-input_dtype12-output_dtype12]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_median_by_dtype[input13-expected13-input_dtype13-output_dtype13]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_median_by_dtype[input14-expected14-input_dtype14-output_dtype14]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_median_by_dtype[input15-expected15-input_dtype15-output_dtype15]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_median_by_dtype[input16-expected16-input_dtype16-output_dtype16]": "Unsupported groupby-agg for a particular dtype", + "tests/unit/operations/test_group_by.py::test_group_by_binary_agg_with_literal": "Incorrect broadcasting of literals in groupby-agg", + "tests/unit/operations/test_group_by.py::test_group_by_apply_first_input_is_literal": "Polars advertises incorrect schema names polars#18524", + "tests/unit/operations/test_group_by.py::test_aggregated_scalar_elementwise_15602": "Unsupported boolean function/dtype combination in groupby-agg", + "tests/unit/operations/test_group_by.py::test_schemas[data1-expr1-expected_select1-expected_gb1]": "Mismatching dtypes, needs cudf#15852", + "tests/unit/operations/test_group_by_dynamic.py::test_group_by_dynamic_by_monday_and_offset_5444": "IR needs to expose groupby-dynamic information", + "tests/unit/operations/test_group_by_dynamic.py::test_group_by_dynamic_label[left-expected0]": "IR needs to expose groupby-dynamic information", + "tests/unit/operations/test_group_by_dynamic.py::test_group_by_dynamic_label[right-expected1]": "IR needs to expose groupby-dynamic information", + "tests/unit/operations/test_group_by_dynamic.py::test_group_by_dynamic_label[datapoint-expected2]": "IR needs to expose groupby-dynamic information", + "tests/unit/operations/test_group_by_dynamic.py::test_rolling_dynamic_sortedness_check": "IR needs to expose groupby-dynamic information", + "tests/unit/operations/test_group_by_dynamic.py::test_group_by_dynamic_validation": "IR needs to expose groupby-dynamic information", + "tests/unit/operations/test_group_by_dynamic.py::test_group_by_dynamic_15225": "IR needs to expose groupby-dynamic information", + "tests/unit/operations/test_join.py::test_cross_join_slice_pushdown": "Need to implement slice pushdown for cross joins", + "tests/unit/sql/test_cast.py::test_cast_errors[values0-values::uint8-conversion from `f64` to `u64` failed]": "Casting that raises not supported on GPU", + "tests/unit/sql/test_cast.py::test_cast_errors[values1-values::uint4-conversion from `i64` to `u32` failed]": "Casting that raises not supported on GPU", + "tests/unit/sql/test_cast.py::test_cast_errors[values2-values::int1-conversion from `i64` to `i8` failed]": "Casting that raises not supported on GPU", + "tests/unit/sql/test_miscellaneous.py::test_read_csv": "Incorrect handling of missing_is_null in read_csv", + "tests/unit/sql/test_wildcard_opts.py::test_select_wildcard_errors": "Raises correctly but with different exception", + "tests/unit/streaming/test_streaming_io.py::test_parquet_eq_statistics": "Debug output on stderr doesn't match", + "tests/unit/test_cse.py::test_cse_predicate_self_join": "Debug output on stderr doesn't match", + "tests/unit/test_empty.py::test_empty_9137": "Mismatching dtypes, needs cudf#15852", + # Maybe flaky, order-dependent? + "tests/unit/test_projections.py::test_schema_full_outer_join_projection_pd_13287": "Order-specific result check, query is correct but in different order", + "tests/unit/test_queries.py::test_group_by_agg_equals_zero_3535": "libcudf sums all nulls to null, not zero", +} + + +def pytest_collection_modifyitems( + session: pytest.Session, config: pytest.Config, items: list[pytest.Item] +): + """Mark known failing tests.""" + if config.getoption("--cudf-polars-no-fallback"): + # Don't xfail tests if running without fallback + return + for item in items: + if item.nodeid in EXPECTED_FAILURES: + item.add_marker(pytest.mark.xfail(reason=EXPECTED_FAILURES[item.nodeid])) diff --git a/python/cudf_polars/cudf_polars/typing/__init__.py b/python/cudf_polars/cudf_polars/typing/__init__.py index c04eac41bb7..fa6b23ca7ec 100644 --- a/python/cudf_polars/cudf_polars/typing/__init__.py +++ b/python/cudf_polars/cudf_polars/typing/__init__.py @@ -85,6 +85,10 @@ def view_expression(self, n: int) -> Expr: """Convert the given expression to python rep.""" ... + def version(self) -> tuple[int, int]: + """The IR version as `(major, minor)`.""" + ... + def set_udf( self, callback: Callable[[list[str] | None, str | None, int | None], pl.DataFrame], diff --git a/python/cudf_polars/cudf_polars/utils/dtypes.py b/python/cudf_polars/cudf_polars/utils/dtypes.py index cd68d021286..6c8a161b64d 100644 --- a/python/cudf_polars/cudf_polars/utils/dtypes.py +++ b/python/cudf_polars/cudf_polars/utils/dtypes.py @@ -14,7 +14,7 @@ import cudf._lib.pylibcudf as plc -__all__ = ["from_polars", "downcast_arrow_lists"] +__all__ = ["from_polars", "downcast_arrow_lists", "can_cast"] def downcast_arrow_lists(typ: pa.DataType) -> pa.DataType: @@ -46,6 +46,28 @@ def downcast_arrow_lists(typ: pa.DataType) -> pa.DataType: return typ +def can_cast(from_: plc.DataType, to: plc.DataType) -> bool: + """ + Can we cast (via :func:`~.pylibcudf.unary.cast`) between two datatypes. + + Parameters + ---------- + from_ + Source datatype + to + Target datatype + + Returns + ------- + True if casting is supported, False otherwise + """ + return ( + plc.traits.is_fixed_width(to) + and plc.traits.is_fixed_width(from_) + and plc.unary.is_supported_cast(from_, to) + ) + + @cache def from_polars(dtype: pl.DataType) -> plc.DataType: """ diff --git a/python/cudf_polars/cudf_polars/utils/versions.py b/python/cudf_polars/cudf_polars/utils/versions.py index 9807cffb384..2e6efde968c 100644 --- a/python/cudf_polars/cudf_polars/utils/versions.py +++ b/python/cudf_polars/cudf_polars/utils/versions.py @@ -12,18 +12,11 @@ POLARS_VERSION = parse(__version__) -POLARS_VERSION_GE_10 = POLARS_VERSION >= parse("1.0") -POLARS_VERSION_GE_11 = POLARS_VERSION >= parse("1.1") -POLARS_VERSION_GE_12 = POLARS_VERSION >= parse("1.2") -POLARS_VERSION_GE_121 = POLARS_VERSION >= parse("1.2.1") -POLARS_VERSION_GT_10 = POLARS_VERSION > parse("1.0") -POLARS_VERSION_GT_11 = POLARS_VERSION > parse("1.1") -POLARS_VERSION_GT_12 = POLARS_VERSION > parse("1.2") - -POLARS_VERSION_LE_12 = POLARS_VERSION <= parse("1.2") -POLARS_VERSION_LE_11 = POLARS_VERSION <= parse("1.1") -POLARS_VERSION_LT_12 = POLARS_VERSION < parse("1.2") -POLARS_VERSION_LT_11 = POLARS_VERSION < parse("1.1") - -if POLARS_VERSION < parse("1.0"): # pragma: no cover - raise ImportError("cudf_polars requires py-polars v1.0 or greater.") +POLARS_VERSION_GE_16 = POLARS_VERSION >= parse("1.6") +POLARS_VERSION_GT_16 = POLARS_VERSION > parse("1.6") +POLARS_VERSION_LT_16 = POLARS_VERSION < parse("1.6") + +if POLARS_VERSION_LT_16: + raise ImportError( + "cudf_polars requires py-polars v1.6 or greater." + ) # pragma: no cover diff --git a/python/cudf_polars/docs/overview.md b/python/cudf_polars/docs/overview.md index 874bb849747..331e8f179e7 100644 --- a/python/cudf_polars/docs/overview.md +++ b/python/cudf_polars/docs/overview.md @@ -15,8 +15,10 @@ You will need: ## Installing polars -We will need to build polars from source. Until things settle down, -live at `HEAD`. +`cudf-polars` works with polars >= 1.3, as long as the internal IR +version doesn't get a major version bump. So `pip install polars>=1.3` +should work. For development, if we're adding things to the polars +side of things, we will need to build polars from source: ```sh git clone https://github.com/pola-rs/polars @@ -59,7 +61,7 @@ The executor for the polars logical plan lives in the cudf repo, in ```sh cd cudf/python/cudf_polars -uv pip install --no-build-isolation --no-deps -e . +pip install --no-build-isolation --no-deps -e . ``` You should now be able to run the tests in the `cudf_polars` package: @@ -69,16 +71,18 @@ pytest -v tests # Executor design -The polars `LazyFrame.collect` functionality offers a -"post-optimization" callback that may be used by a third party library -to replace a node (or more, though we only replace a single node) in the -optimized logical plan with a Python callback that is to deliver the -result of evaluating the plan. This splits the execution of the plan -into two phases. First, a symbolic phase which translates to our -internal representation (IR). Second, an execution phase which executes -using our IR. - -The translation phase receives the a low-level Rust `NodeTraverse` +The polars `LazyFrame.collect` functionality offers configuration of +the engine to use for collection through the `engine` argument. At a +low level, this provides for configuration of a "post-optimization" +callback that may be used by a third party library to replace a node +(or more, though we only replace a single node) in the optimized +logical plan with a Python callback that is to deliver the result of +evaluating the plan. This splits the execution of the plan into two +phases. First, a symbolic phase which translates to our internal +representation (IR). Second, an execution phase which executes using +our IR. + +The translation phase receives the a low-level Rust `NodeTraverser` object which delivers Python representations of the plan nodes (and expressions) one at a time. During translation, we endeavour to raise `NotImplementedError` for any unsupported functionality. This way, if @@ -86,33 +90,60 @@ we can't execute something, we just don't modify the logical plan at all: if we can translate the IR, it is assumed that evaluation will later succeed. -The usage of the cudf-based executor is therefore, at present: +The usage of the cudf-based executor is therefore selected with the +gpu engine: ```python -from cudf_polars.callback import execute_with_cudf +import polars as pl -result = q.collect(post_opt_callback=execute_with_cudf) +result = q.collect(engine="gpu") ``` This should either transparently run on the GPU and deliver a polars dataframe, or else fail (but be handled) and just run the normal CPU -execution. +execution. If `POLARS_VERBOSE` is true, then fallback is logged with a +`PerformanceWarning`. -If you want to fail during translation, set the keyword argument -`raise_on_fail` to `True`: +As well as a string argument, the engine can also be specified with a +polars `GPUEngine` object. This allows passing more configuration in. +Currently, the public properties are `device`, to select the device, +and `memory_resource`, to select the RMM memory resource used for +allocations during the collection phase. +For example: ```python -from functools import partial -from cudf_polars.callback import execute_with_cudf +import polars as pl -result = q.collect( - post_opt_callback=partial(execute_with_cudf, raise_on_fail=True) -) +result = q.collect(engine=pl.GPUEngine(device=1, memory_resource=mr)) +``` + +Uses device-1, and the given memory resource. Note that the memory +resource provided _must_ be valid for allocations on the specified +device, no checking is performed. + +For debugging purposes, we can also pass undocumented keyword +arguments, at the moment, `raise_on_fail` is also supported, which +raises, rather than falling back, during translation: + +```python + +result = q.collect(engine=pl.GPUEngine(raise_on_fail=True)) ``` This is mostly useful when writing tests, since in that case we want any failures to propagate, rather than falling back to the CPU mode. +## IR versioning + +On the polars side, the `NodeTraverser` object advertises an internal +version (via `NodeTraverser.version()` as a `(major, minor)` tuple). +`minor` version bumps are for backwards compatible changes (e.g. +exposing new nodes), whereas `major` bumps are for incompatible +changes. We can therefore attempt to detect the IR version +(independently of the polars version) and dispatch, or error +appropriately. This should be done during IR translation in +`translate.py`. + ## Adding a handler for a new plan node Plan node definitions live in `cudf_polars/dsl/ir.py`, these are @@ -175,7 +206,7 @@ around their pylibcudf counterparts. We have four (in 1. `Scalar` (a wrapper around a pylibcudf `Scalar`) 2. `Column` (a wrapper around a pylibcudf `Column`) -3. `NamedColumn` a `Column` with an additional name +3. `NamedColumn` (a `Column` with an additional name) 4. `DataFrame` (a wrapper around a pylibcudf `Table`) The interfaces offered by these are somewhat in flux, but broadly diff --git a/python/cudf_polars/pyproject.toml b/python/cudf_polars/pyproject.toml index 7b29ad3373d..06c0e217403 100644 --- a/python/cudf_polars/pyproject.toml +++ b/python/cudf_polars/pyproject.toml @@ -20,7 +20,7 @@ license = { text = "Apache 2.0" } requires-python = ">=3.9" dependencies = [ "cudf==24.8.*,>=0.0.0a0", - "polars>=1.0,<1.3", + "polars>=1.6", ] # This list was generated by `rapids-dependency-file-generator`. To make changes, edit ../../dependencies.yaml and run `rapids-dependency-file-generator`. classifiers = [ "Intended Audience :: Developers", @@ -58,6 +58,9 @@ exclude_also = [ "class .*\\bProtocol\\):", "assert_never\\(" ] +# The cudf_polars test suite doesn't exercise the plugin, so we omit +# it from coverage checks. +omit = ["cudf_polars/testing/plugin.py"] [tool.ruff] line-length = 88 diff --git a/python/cudf_polars/tests/containers/test_dataframe.py b/python/cudf_polars/tests/containers/test_dataframe.py index 87508e17407..1634b593a09 100644 --- a/python/cudf_polars/tests/containers/test_dataframe.py +++ b/python/cudf_polars/tests/containers/test_dataframe.py @@ -10,6 +10,7 @@ import cudf._lib.pylibcudf as plc from cudf_polars.containers import DataFrame, NamedColumn +from cudf_polars.testing.asserts import assert_gpu_result_equal def test_select_missing_raises(): @@ -141,3 +142,13 @@ def test_sorted_flags_preserved(with_nulls, nulls_last): assert b.null_order == b_null_order assert c.is_sorted == plc.types.Sorted.NO assert df.flags == gf.to_polars().flags + + +def test_empty_name_roundtrips_overlap(): + df = pl.LazyFrame({"": [1, 2, 3], "column_0": [4, 5, 6]}) + assert_gpu_result_equal(df) + + +def test_empty_name_roundtrips_no_overlap(): + df = pl.LazyFrame({"": [1, 2, 3], "b": [4, 5, 6]}) + assert_gpu_result_equal(df) diff --git a/python/cudf_polars/tests/expressions/test_agg.py b/python/cudf_polars/tests/expressions/test_agg.py index 245bde3acab..56055f4c6c2 100644 --- a/python/cudf_polars/tests/expressions/test_agg.py +++ b/python/cudf_polars/tests/expressions/test_agg.py @@ -7,15 +7,38 @@ import polars as pl from cudf_polars.dsl import expr -from cudf_polars.testing.asserts import assert_gpu_result_equal +from cudf_polars.testing.asserts import ( + assert_gpu_result_equal, + assert_ir_translation_raises, +) -@pytest.fixture(params=sorted(expr.Agg._SUPPORTED)) +@pytest.fixture( + params=[ + # regular aggs from Agg + "min", + "max", + "median", + "n_unique", + "first", + "last", + "mean", + "sum", + "count", + "std", + "var", + # scan aggs from UnaryFunction + "cum_min", + "cum_max", + "cum_prod", + "cum_sum", + ] +) def agg(request): return request.param -@pytest.fixture(params=[pl.Int32, pl.Float32, pl.Int16]) +@pytest.fixture(params=[pl.Int32, pl.Float32, pl.Int16, pl.Int8, pl.UInt16]) def dtype(request): return request.param @@ -34,6 +57,11 @@ def df(dtype, with_nulls, is_sorted): if is_sorted: values = sorted(values, key=lambda x: -1000 if x is None else x) + if dtype.is_unsigned_integer(): + values = pl.Series(values).abs() + if is_sorted: + values = values.sort() + df = pl.LazyFrame({"a": values}, schema={"a": dtype}) if is_sorted: return df.set_sorted("a") @@ -52,6 +80,51 @@ def test_agg(df, agg): assert_gpu_result_equal(q, check_dtypes=check_dtypes, check_exact=False) +def test_bool_agg(agg, request): + if agg == "cum_min" or agg == "cum_max": + pytest.skip("Does not apply") + request.applymarker( + pytest.mark.xfail( + condition=agg == "n_unique", + reason="Wrong dtype we get Int32, polars gets UInt32", + ) + ) + df = pl.LazyFrame({"a": [True, False, None, True]}) + expr = getattr(pl.col("a"), agg)() + q = df.select(expr) + + assert_gpu_result_equal(q) + + +@pytest.mark.parametrize("cum_agg", expr.UnaryFunction._supported_cum_aggs) +def test_cum_agg_reverse_unsupported(cum_agg): + df = pl.LazyFrame({"a": [1, 2, 3]}) + expr = getattr(pl.col("a"), cum_agg)(reverse=True) + q = df.select(expr) + + assert_ir_translation_raises(q, NotImplementedError) + + +@pytest.mark.parametrize("q", [0.5, pl.lit(0.5)]) +@pytest.mark.parametrize("interp", ["nearest", "higher", "lower", "midpoint", "linear"]) +def test_quantile(df, q, interp): + expr = pl.col("a").quantile(q, interp) + q = df.select(expr) + + # https://github.com/rapidsai/cudf/issues/15852 + check_dtypes = q.collect_schema()["a"] == pl.Float64 + if not check_dtypes: + with pytest.raises(AssertionError): + assert_gpu_result_equal(q) + assert_gpu_result_equal(q, check_dtypes=check_dtypes, check_exact=False) + + +def test_quantile_invalid_q(df): + expr = pl.col("a").quantile(pl.col("a")) + q = df.select(expr) + assert_ir_translation_raises(q, NotImplementedError) + + @pytest.mark.parametrize( "op", [pl.Expr.min, pl.Expr.nan_min, pl.Expr.max, pl.Expr.nan_max] ) diff --git a/python/cudf_polars/tests/expressions/test_booleanfunction.py b/python/cudf_polars/tests/expressions/test_booleanfunction.py index 97421008669..2347021c40e 100644 --- a/python/cudf_polars/tests/expressions/test_booleanfunction.py +++ b/python/cudf_polars/tests/expressions/test_booleanfunction.py @@ -17,15 +17,11 @@ def has_nulls(request): return request.param -@pytest.mark.parametrize( - "ignore_nulls", - [ - pytest.param( - False, marks=pytest.mark.xfail(reason="No support for Kleene logic") - ), - True, - ], -) +@pytest.fixture(params=[False, True], ids=["include_nulls", "ignore_nulls"]) +def ignore_nulls(request): + return request.param + + def test_booleanfunction_reduction(ignore_nulls): ldf = pl.LazyFrame( { @@ -43,6 +39,25 @@ def test_booleanfunction_reduction(ignore_nulls): assert_gpu_result_equal(query) +@pytest.mark.parametrize("expr", [pl.Expr.any, pl.Expr.all]) +def test_booleanfunction_all_any_kleene(expr, ignore_nulls): + ldf = pl.LazyFrame( + { + "a": [False, None], + "b": [False, False], + "c": [False, True], + "d": [None, False], + "e": pl.Series([None, None], dtype=pl.Boolean()), + "f": [None, True], + "g": [True, False], + "h": [True, None], + "i": [True, True], + } + ) + q = ldf.select(expr(pl.col("*"), ignore_nulls=ignore_nulls)) + assert_gpu_result_equal(q) + + @pytest.mark.parametrize( "expr", [ @@ -54,14 +69,7 @@ def test_booleanfunction_reduction(ignore_nulls): ids=lambda f: f"{f.__name__}()", ) @pytest.mark.parametrize("has_nans", [False, True], ids=["no_nans", "nans"]) -def test_boolean_function_unary(request, expr, has_nans, has_nulls): - if has_nulls and expr in (pl.Expr.is_nan, pl.Expr.is_not_nan): - request.applymarker( - pytest.mark.xfail( - reason="Need to copy null mask since is_{not_}nan(null) => null" - ) - ) - +def test_boolean_function_unary(expr, has_nans, has_nulls): values: list[float | None] = [1, 2, 3, 4, 5] if has_nans: values[3] = float("nan") @@ -119,9 +127,7 @@ def test_boolean_isbetween(closed, bounds): "expr", [pl.any_horizontal("*"), pl.all_horizontal("*")], ids=["any", "all"] ) @pytest.mark.parametrize("wide", [False, True], ids=["narrow", "wide"]) -def test_boolean_horizontal(request, expr, has_nulls, wide): - if has_nulls: - request.applymarker(pytest.mark.xfail(reason="No support for Kleene logic")) +def test_boolean_horizontal(expr, has_nulls, wide): ldf = pl.LazyFrame( { "a": [False, False, False, False, False, True], @@ -164,6 +170,18 @@ def test_boolean_is_in(expr): assert_gpu_result_equal(q) +@pytest.mark.parametrize("expr", [pl.Expr.and_, pl.Expr.or_, pl.Expr.xor]) +def test_boolean_kleene_logic(expr): + ldf = pl.LazyFrame( + { + "a": [False, False, False, None, None, None, True, True, True], + "b": [False, None, True, False, None, True, False, None, True], + } + ) + q = ldf.select(expr(pl.col("a"), pl.col("b"))) + assert_gpu_result_equal(q) + + def test_boolean_is_in_raises_unsupported(): ldf = pl.LazyFrame({"a": pl.Series([1, 2, 3], dtype=pl.Int64)}) q = ldf.select(pl.col("a").is_in(pl.lit(1, dtype=pl.Int32()))) diff --git a/python/cudf_polars/tests/expressions/test_datetime_basic.py b/python/cudf_polars/tests/expressions/test_datetime_basic.py index 218101bf87c..c6ea29ddd38 100644 --- a/python/cudf_polars/tests/expressions/test_datetime_basic.py +++ b/python/cudf_polars/tests/expressions/test_datetime_basic.py @@ -9,7 +9,11 @@ import polars as pl -from cudf_polars.testing.asserts import assert_gpu_result_equal +from cudf_polars.dsl.expr import TemporalFunction +from cudf_polars.testing.asserts import ( + assert_gpu_result_equal, + assert_ir_translation_raises, +) @pytest.mark.parametrize( @@ -37,26 +41,97 @@ def test_datetime_dataframe_scan(dtype): assert_gpu_result_equal(query) +datetime_extract_fields = [ + "year", + "month", + "day", + "weekday", + "hour", + "minute", + "second", + "millisecond", + "microsecond", + "nanosecond", +] + + +@pytest.fixture( + ids=datetime_extract_fields, + params=[methodcaller(f) for f in datetime_extract_fields], +) +def field(request): + return request.param + + +def test_datetime_extract(field): + ldf = pl.LazyFrame( + { + "datetimes": pl.datetime_range( + datetime.datetime(2020, 1, 1), + datetime.datetime(2021, 12, 30), + "3mo14h15s11ms33us999ns", + eager=True, + ) + } + ) + + q = ldf.select(field(pl.col("datetimes").dt)) + + assert_gpu_result_equal(q) + + +def test_datetime_extra_unsupported(monkeypatch): + ldf = pl.LazyFrame( + { + "datetimes": pl.datetime_range( + datetime.datetime(2020, 1, 1), + datetime.datetime(2021, 12, 30), + "3mo14h15s11ms33us999ns", + eager=True, + ) + } + ) + + def unsupported_name_setter(self, value): + pass + + def unsupported_name_getter(self): + return "unsupported" + + monkeypatch.setattr( + TemporalFunction, + "name", + property(unsupported_name_getter, unsupported_name_setter), + ) + + q = ldf.select(pl.col("datetimes").dt.nanosecond()) + + assert_ir_translation_raises(q, NotImplementedError) + + @pytest.mark.parametrize( "field", [ methodcaller("year"), - pytest.param( - methodcaller("day"), - marks=pytest.mark.xfail(reason="day extraction not implemented"), - ), + methodcaller("month"), + methodcaller("day"), + methodcaller("weekday"), ], ) -def test_datetime_extract(field): +def test_date_extract(field): + ldf = pl.LazyFrame( + { + "dates": [ + datetime.date(2024, 1, 1), + datetime.date(2024, 10, 11), + ] + } + ) + ldf = pl.LazyFrame( {"dates": [datetime.date(2024, 1, 1), datetime.date(2024, 10, 11)]} ) - q = ldf.select(field(pl.col("dates").dt)) - with pytest.raises(AssertionError): - # polars produces int32, libcudf produces int16 for the year extraction - # libcudf can lose data here. - # https://github.com/rapidsai/cudf/issues/16196 - assert_gpu_result_equal(q) + q = ldf.select(field(pl.col("dates").dt)) - assert_gpu_result_equal(q, check_dtypes=False) + assert_gpu_result_equal(q) diff --git a/python/cudf_polars/tests/expressions/test_gather.py b/python/cudf_polars/tests/expressions/test_gather.py index 6bffa3e252c..f7c5d1bf2cd 100644 --- a/python/cudf_polars/tests/expressions/test_gather.py +++ b/python/cudf_polars/tests/expressions/test_gather.py @@ -6,7 +6,6 @@ import polars as pl -from cudf_polars import execute_with_cudf from cudf_polars.testing.asserts import assert_gpu_result_equal @@ -47,4 +46,4 @@ def test_gather_out_of_bounds(negative): query = ldf.select(pl.col("a").gather(pl.col("b"))) with pytest.raises(pl.exceptions.ComputeError): - query.collect(post_opt_callback=execute_with_cudf) + query.collect(engine="gpu") diff --git a/python/cudf_polars/tests/expressions/test_numeric_unaryops.py b/python/cudf_polars/tests/expressions/test_numeric_unaryops.py new file mode 100644 index 00000000000..ac3aecf88e6 --- /dev/null +++ b/python/cudf_polars/tests/expressions/test_numeric_unaryops.py @@ -0,0 +1,91 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import numpy as np +import pytest + +import polars as pl + +from cudf_polars.testing.asserts import assert_gpu_result_equal + + +@pytest.fixture( + params=[ + "sin", + "cos", + "tan", + "arcsin", + "arccos", + "arctan", + "sinh", + "cosh", + "tanh", + "arcsinh", + "arccosh", + "arctanh", + "exp", + "sqrt", + "cbrt", + "ceil", + "floor", + "abs", + ] +) +def op(request): + return request.param + + +@pytest.fixture(params=[pl.Int32, pl.Float32]) +def dtype(request): + return request.param + + +@pytest.fixture +def ldf(with_nulls, dtype): + values = [1, 2, 4, 5, -2, -4, 0] + if with_nulls: + values.append(None) + if dtype == pl.Float32: + values.append(-float("inf")) + values.append(float("nan")) + values.append(float("inf")) + elif dtype == pl.Int32: + iinfo = np.iinfo("int32") + values.append(iinfo.min) + values.append(iinfo.max) + return pl.LazyFrame( + { + "a": pl.Series(values, dtype=dtype), + "b": pl.Series([i - 4 for i in range(len(values))], dtype=pl.Float32), + } + ) + + +def test_unary(ldf, op): + expr = getattr(pl.col("a"), op)() + q = ldf.select(expr) + assert_gpu_result_equal(q, check_exact=False) + + +@pytest.mark.parametrize("base_literal", [False, True]) +@pytest.mark.parametrize("exponent_literal", [False, True]) +def test_pow(ldf, base_literal, exponent_literal): + base = pl.lit(2) if base_literal else pl.col("a") + exponent = pl.lit(-3, dtype=pl.Float32) if exponent_literal else pl.col("b") + + q = ldf.select(base.pow(exponent)) + + assert_gpu_result_equal(q, check_exact=False) + + +@pytest.mark.parametrize("natural", [True, False]) +def test_log(ldf, natural): + if natural: + expr = pl.col("a").log() + else: + expr = pl.col("a").log(10) + + q = ldf.select(expr) + + assert_gpu_result_equal(q, check_exact=False) diff --git a/python/cudf_polars/tests/expressions/test_stringfunction.py b/python/cudf_polars/tests/expressions/test_stringfunction.py index df08e15baa4..4f6850ac977 100644 --- a/python/cudf_polars/tests/expressions/test_stringfunction.py +++ b/python/cudf_polars/tests/expressions/test_stringfunction.py @@ -10,6 +10,7 @@ from cudf_polars import execute_with_cudf from cudf_polars.testing.asserts import ( + assert_collect_raises, assert_gpu_result_equal, assert_ir_translation_raises, ) @@ -152,3 +153,187 @@ def test_slice_column(slice_column_data): else: query = slice_column_data.select(pl.col("a").str.slice(pl.col("start"))) assert_ir_translation_raises(query, NotImplementedError) + + +@pytest.fixture +def to_datetime_data(): + return pl.LazyFrame( + { + "a": [ + "2021-01-01", + "2021-01-02", + "abcd", + ] + } + ) + + +@pytest.mark.parametrize("cache", [True, False], ids=lambda cache: f"{cache=}") +@pytest.mark.parametrize("strict", [True, False], ids=lambda strict: f"{strict=}") +@pytest.mark.parametrize("exact", [True, False], ids=lambda exact: f"{exact=}") +@pytest.mark.parametrize("format", ["%Y-%m-%d", None], ids=lambda format: f"{format=}") +def test_to_datetime(to_datetime_data, cache, strict, format, exact): + query = to_datetime_data.select( + pl.col("a").str.strptime( + pl.Datetime("ns"), format=format, cache=cache, strict=strict, exact=exact + ) + ) + if cache or format is None or not exact: + assert_ir_translation_raises(query, NotImplementedError) + elif strict: + assert_collect_raises( + query, + polars_except=pl.exceptions.InvalidOperationError, + cudf_except=pl.exceptions.ComputeError, + ) + else: + assert_gpu_result_equal(query) + + +@pytest.mark.parametrize( + "target, repl", + [("a", "a"), ("Wı", "☺"), ("FG", ""), ("doesnotexist", "blahblah")], # noqa: RUF001 +) +@pytest.mark.parametrize("n", [0, 3, -1]) +def test_replace_literal(ldf, target, repl, n): + query = ldf.select(pl.col("a").str.replace(target, repl, literal=True, n=n)) + assert_gpu_result_equal(query) + + +@pytest.mark.parametrize("target, repl", [("", ""), ("a", pl.col("a"))]) +def test_replace_literal_unsupported(ldf, target, repl): + query = ldf.select(pl.col("a").str.replace(target, repl, literal=True)) + assert_ir_translation_raises(query, NotImplementedError) + + +def test_replace_re(ldf): + query = ldf.select(pl.col("a").str.replace("A", "a", literal=False)) + assert_ir_translation_raises(query, NotImplementedError) + + +@pytest.mark.parametrize( + "target,repl", + [ + (["A", "de", "kLm", "awef"], "a"), + (["A", "de", "kLm", "awef"], ""), + (["A", "de", "kLm", "awef"], ["a", "b", "c", "d"]), + (["A", "de", "kLm", "awef"], ["a", "b", "c", ""]), + ( + pl.lit(pl.Series(["A", "de", "kLm", "awef"])), + pl.lit(pl.Series(["a", "b", "c", "d"])), + ), + ], +) +def test_replace_many(ldf, target, repl): + query = ldf.select(pl.col("a").str.replace_many(target, repl)) + + assert_gpu_result_equal(query) + + +@pytest.mark.parametrize( + "target,repl", + [(["A", ""], ["a", "b"]), (pl.col("a").drop_nulls(), pl.col("a").drop_nulls())], +) +def test_replace_many_notimplemented(ldf, target, repl): + query = ldf.select(pl.col("a").str.replace_many(target, repl)) + assert_ir_translation_raises(query, NotImplementedError) + + +def test_replace_many_ascii_case(ldf): + query = ldf.select( + pl.col("a").str.replace_many(["a", "b", "c"], "a", ascii_case_insensitive=True) + ) + + assert_ir_translation_raises(query, NotImplementedError) + + +_strip_data = [ + "AbC", + "123abc", + "", + " ", + None, + "aAaaaAAaa", + " ab c ", + "abc123", + " ", + "\tabc\t", + "\nabc\n", + "\r\nabc\r\n", + "\t\n abc \n\t", + "!@#$%^&*()", + " abc!!! ", + " abc\t\n!!! ", + "__abc__", + "abc\n\n", + "123abc456", + "abcxyzabc", +] + +strip_chars = [ + "a", + "", + " ", + "\t", + "\n", + "\r\n", + "!", + "@#", + "123", + "xyz", + "abc", + "__", + " \t\n", + "abc123", + None, +] + + +@pytest.fixture +def strip_ldf(): + return pl.DataFrame({"a": _strip_data}).lazy() + + +@pytest.fixture(params=strip_chars) +def to_strip(request): + return request.param + + +def test_strip_chars(strip_ldf, to_strip): + q = strip_ldf.select(pl.col("a").str.strip_chars(to_strip)) + assert_gpu_result_equal(q) + + +def test_strip_chars_start(strip_ldf, to_strip): + q = strip_ldf.select(pl.col("a").str.strip_chars_start(to_strip)) + assert_gpu_result_equal(q) + + +def test_strip_chars_end(strip_ldf, to_strip): + q = strip_ldf.select(pl.col("a").str.strip_chars_end(to_strip)) + assert_gpu_result_equal(q) + + +def test_strip_chars_column(strip_ldf): + q = strip_ldf.select(pl.col("a").str.strip_chars(pl.col("a"))) + assert_ir_translation_raises(q, NotImplementedError) + + +def test_invalid_regex_raises(): + df = pl.LazyFrame({"a": ["abc"]}) + + q = df.select(pl.col("a").str.contains(r"ab)", strict=True)) + + assert_collect_raises( + q, + polars_except=pl.exceptions.ComputeError, + cudf_except=pl.exceptions.ComputeError, + ) + + +@pytest.mark.parametrize("pattern", ["a{1000}", "a(?i:B)"]) +def test_unsupported_regex_raises(pattern): + df = pl.LazyFrame({"a": ["abc"]}) + + q = df.select(pl.col("a").str.contains(pattern, strict=True)) + assert_ir_translation_raises(q, NotImplementedError) diff --git a/python/cudf_polars/tests/test_config.py b/python/cudf_polars/tests/test_config.py index 5b4bba55552..3c3986be19b 100644 --- a/python/cudf_polars/tests/test_config.py +++ b/python/cudf_polars/tests/test_config.py @@ -6,6 +6,9 @@ import pytest import polars as pl +from polars.testing.asserts import assert_frame_equal + +import rmm from cudf_polars.dsl.ir import IR from cudf_polars.testing.asserts import ( @@ -32,3 +35,48 @@ def raise_unimplemented(self): ): # And ensure that collecting issues the correct warning. assert_gpu_result_equal(q) + + +def test_unsupported_config_raises(): + q = pl.LazyFrame({}) + + with pytest.raises(pl.exceptions.ComputeError): + q.collect(engine=pl.GPUEngine(unknown_key=True)) + + +@pytest.mark.parametrize("device", [-1, "foo"]) +def test_invalid_device_raises(device): + q = pl.LazyFrame({}) + with pytest.raises(pl.exceptions.ComputeError): + q.collect(engine=pl.GPUEngine(device=device)) + + +@pytest.mark.parametrize("mr", [1, object()]) +def test_invalid_memory_resource_raises(mr): + q = pl.LazyFrame({}) + with pytest.raises(pl.exceptions.ComputeError): + q.collect(engine=pl.GPUEngine(memory_resource=mr)) + + +def test_explicit_device_zero(): + q = pl.LazyFrame({"a": [1, 2, 3]}) + + result = q.collect(engine=pl.GPUEngine(device=0)) + assert_frame_equal(q.collect(), result) + + +def test_explicit_memory_resource(): + upstream = rmm.mr.CudaMemoryResource() + n_allocations = 0 + + def allocate(bytes, stream): + nonlocal n_allocations + n_allocations += 1 + return upstream.allocate(bytes, stream) + + mr = rmm.mr.CallbackMemoryResource(allocate, upstream.deallocate) + + q = pl.LazyFrame({"a": [1, 2, 3]}) + result = q.collect(engine=pl.GPUEngine(memory_resource=mr)) + assert_frame_equal(q.collect(), result) + assert n_allocations > 0 diff --git a/python/cudf_polars/tests/test_groupby.py b/python/cudf_polars/tests/test_groupby.py index a75825ef3d3..6f996e0e0ec 100644 --- a/python/cudf_polars/tests/test_groupby.py +++ b/python/cudf_polars/tests/test_groupby.py @@ -12,7 +12,6 @@ assert_gpu_result_equal, assert_ir_translation_raises, ) -from cudf_polars.utils import versions @pytest.fixture @@ -31,6 +30,7 @@ def df(): params=[ [pl.col("key1")], [pl.col("key2")], + [pl.col("key1"), pl.lit(1)], [pl.col("key1") * pl.col("key2")], [pl.col("key1"), pl.col("key2")], [pl.col("key1") == pl.col("key2")], @@ -52,6 +52,7 @@ def keys(request): [(pl.col("float") - pl.lit(2)).max()], [pl.col("float").sum().round(decimals=1)], [pl.col("float").round(decimals=1).sum()], + [pl.col("int").first(), pl.col("float").last()], ], ids=lambda aggs: "-".join(map(str, aggs)), ) @@ -60,15 +61,7 @@ def exprs(request): @pytest.fixture( - params=[ - False, - pytest.param( - True, - marks=pytest.mark.xfail( - reason="Maintaining order in groupby not implemented" - ), - ), - ], + params=[False, True], ids=["no_maintain_order", "maintain_order"], ) def maintain_order(request): @@ -98,15 +91,10 @@ def test_groupby_sorted_keys(df: pl.LazyFrame, keys, exprs): # Multiple keys don't do sorting qsorted = q.sort(*sort_keys) if len(keys) > 1: - with pytest.raises(AssertionError): - # https://github.com/pola-rs/polars/issues/17556 - assert_gpu_result_equal(q, check_exact=False) - if versions.POLARS_VERSION_LT_12 and schema[sort_keys[1]] == pl.Boolean(): - # https://github.com/pola-rs/polars/issues/17557 - with pytest.raises(AssertionError): - assert_gpu_result_equal(qsorted, check_exact=False) - else: - assert_gpu_result_equal(qsorted, check_exact=False) + # https://github.com/pola-rs/polars/issues/17556 + # Can't assert that the query without post-sorting fails, + # since it _might_ pass. + assert_gpu_result_equal(qsorted, check_exact=False) elif schema[sort_keys[0]] == pl.Boolean(): # Boolean keys don't do sorting, so we get random order assert_gpu_result_equal(qsorted, check_exact=False) @@ -133,6 +121,21 @@ def test_groupby_unsupported(df, expr): assert_ir_translation_raises(q, NotImplementedError) +def test_groupby_null_keys(maintain_order): + df = pl.LazyFrame( + { + "key": pl.Series([1, float("nan"), 2, None, 2, None], dtype=pl.Float64()), + "value": [-1, 2, 1, 2, 3, 4], + } + ) + + q = df.group_by("key", maintain_order=maintain_order).agg(pl.col("value").min()) + if not maintain_order: + q = q.sort("key") + + assert_gpu_result_equal(q) + + @pytest.mark.xfail(reason="https://github.com/pola-rs/polars/issues/17513") def test_groupby_minmax_with_nan(): df = pl.LazyFrame( @@ -159,15 +162,7 @@ def test_groupby_nan_minmax_raises(op): @pytest.mark.parametrize( "key", - [ - pytest.param( - 1, - marks=pytest.mark.xfail( - versions.POLARS_VERSION_GE_121, reason="polars 1.2.1 disallows this" - ), - ), - pl.col("key1"), - ], + [1, pl.col("key1")], ) @pytest.mark.parametrize( "expr", @@ -183,3 +178,12 @@ def test_groupby_literal_in_agg(df, key, expr): # so just sort by the group key q = df.group_by(key).agg(expr).sort(key, maintain_order=True) assert_gpu_result_equal(q) + + +@pytest.mark.parametrize( + "expr", + [pl.col("int").unique(), pl.col("int").drop_nulls(), pl.col("int").cum_max()], +) +def test_groupby_unary_non_pointwise_raises(df, expr): + q = df.group_by("key1").agg(expr) + assert_ir_translation_raises(q, NotImplementedError) diff --git a/python/cudf_polars/tests/test_groupby_dynamic.py b/python/cudf_polars/tests/test_groupby_dynamic.py new file mode 100644 index 00000000000..38b3ce74ac5 --- /dev/null +++ b/python/cudf_polars/tests/test_groupby_dynamic.py @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +from datetime import datetime + +import polars as pl + +from cudf_polars.testing.asserts import assert_ir_translation_raises + + +def test_groupby_dynamic_raises(): + df = pl.LazyFrame( + { + "dt": [ + datetime(2021, 12, 31, 0, 0, 0), + datetime(2022, 1, 1, 0, 0, 1), + datetime(2022, 3, 31, 0, 0, 1), + datetime(2022, 4, 1, 0, 0, 1), + ] + } + ) + + q = ( + df.sort("dt") + .group_by_dynamic("dt", every="1q") + .agg(pl.col("dt").count().alias("num_values")) + ) + assert_ir_translation_raises(q, NotImplementedError) diff --git a/python/cudf_polars/tests/test_join.py b/python/cudf_polars/tests/test_join.py index 1e880cdc6de..7d9ec98db97 100644 --- a/python/cudf_polars/tests/test_join.py +++ b/python/cudf_polars/tests/test_join.py @@ -17,7 +17,7 @@ def join_nulls(request): return request.param -@pytest.fixture(params=["inner", "left", "semi", "anti", "full"]) +@pytest.fixture(params=["inner", "left", "right", "semi", "anti", "full"]) def how(request): return request.param diff --git a/python/cudf_polars/tests/test_mapfunction.py b/python/cudf_polars/tests/test_mapfunction.py index 77032108e6f..e895f27f637 100644 --- a/python/cudf_polars/tests/test_mapfunction.py +++ b/python/cudf_polars/tests/test_mapfunction.py @@ -61,3 +61,48 @@ def test_rename_columns(mapping): q = df.rename(mapping) assert_gpu_result_equal(q) + + +@pytest.mark.parametrize("index", [None, ["a"], ["d", "a"]]) +@pytest.mark.parametrize("variable_name", [None, "names"]) +@pytest.mark.parametrize("value_name", [None, "unpivoted"]) +def test_unpivot(index, variable_name, value_name): + df = pl.LazyFrame( + { + "a": ["x", "y", "z"], + "b": pl.Series([1, 3, 5], dtype=pl.Int16), + "c": pl.Series([2, 4, 6], dtype=pl.Float32), + "d": ["a", "b", "c"], + } + ) + q = df.unpivot( + ["c", "b"], index=index, variable_name=variable_name, value_name=value_name + ) + + assert_gpu_result_equal(q) + + +def test_unpivot_defaults(): + df = pl.LazyFrame( + { + "a": pl.Series([11, 12, 13], dtype=pl.UInt16), + "b": pl.Series([1, 3, 5], dtype=pl.Int16), + "c": pl.Series([2, 4, 6], dtype=pl.Float32), + "d": ["a", "b", "c"], + } + ) + q = df.unpivot(index="d") + assert_gpu_result_equal(q) + + +def test_unpivot_unsupported_cast_raises(): + df = pl.LazyFrame( + { + "a": ["x", "y", "z"], + "b": pl.Series([1, 3, 5], dtype=pl.Int16), + } + ) + + q = df.unpivot(["a", "b"]) + + assert_ir_translation_raises(q, NotImplementedError) diff --git a/python/cudf_polars/tests/test_python_scan.py b/python/cudf_polars/tests/test_python_scan.py index fd8453b77c4..0cda89474a8 100644 --- a/python/cudf_polars/tests/test_python_scan.py +++ b/python/cudf_polars/tests/test_python_scan.py @@ -8,7 +8,9 @@ def test_python_scan(): - def source(with_columns, predicate, nrows): + def source(with_columns, predicate, nrows, *batch_size): + # PythonScan interface changes between 1.3 and 1.4 to add an + # extra batch_size argument return pl.DataFrame({"a": pl.Series([1, 2, 3], dtype=pl.Int8())}) q = pl.LazyFrame._scan_python_function({"a": pl.Int8}, source, pyarrow=False) diff --git a/python/cudf_polars/tests/test_scan.py b/python/cudf_polars/tests/test_scan.py index 64acbb076ed..792b136acd8 100644 --- a/python/cudf_polars/tests/test_scan.py +++ b/python/cudf_polars/tests/test_scan.py @@ -12,7 +12,6 @@ assert_gpu_result_equal, assert_ir_translation_raises, ) -from cudf_polars.utils import versions @pytest.fixture( @@ -58,6 +57,22 @@ def mask(request): return request.param +@pytest.fixture( + params=[ + None, + (1, 1), + ], + ids=[ + "no-slice", + "slice-second", + ], +) +def slice(request): + # For use in testing that we handle + # polars slice pushdown correctly + return request.param + + def make_source(df, path, format): """ Writes the passed polars df to a file of @@ -79,7 +94,9 @@ def make_source(df, path, format): ("parquet", pl.scan_parquet), ], ) -def test_scan(tmp_path, df, format, scan_fn, row_index, n_rows, columns, mask, request): +def test_scan( + tmp_path, df, format, scan_fn, row_index, n_rows, columns, mask, slice, request +): name, offset = row_index make_source(df, tmp_path / "file", format) request.applymarker( @@ -94,21 +111,23 @@ def test_scan(tmp_path, df, format, scan_fn, row_index, n_rows, columns, mask, r row_index_offset=offset, n_rows=n_rows, ) + if slice is not None: + q = q.slice(*slice) if mask is not None: q = q.filter(mask) if columns is not None: q = q.select(*columns) - polars_collect_kwargs = {} - if versions.POLARS_VERSION_LT_12: - # https://github.com/pola-rs/polars/issues/17553 - polars_collect_kwargs = {"projection_pushdown": False} - assert_gpu_result_equal( - q, - polars_collect_kwargs=polars_collect_kwargs, - # This doesn't work in polars < 1.2 since the row-index - # is in the wrong order in previous polars releases - check_column_order=versions.POLARS_VERSION_LT_12, - ) + assert_gpu_result_equal(q) + + +def test_negative_slice_pushdown_raises(tmp_path): + df = pl.DataFrame({"a": [1, 2, 3]}) + + df.write_parquet(tmp_path / "df.parquet") + q = pl.scan_parquet(tmp_path / "df.parquet") + # Take the last row + q = q.slice(-1, 1) + assert_ir_translation_raises(q, NotImplementedError) def test_scan_unsupported_raises(tmp_path): @@ -127,10 +146,6 @@ def test_scan_ndjson_nrows_notimplemented(tmp_path, df): assert_ir_translation_raises(q, NotImplementedError) -@pytest.mark.xfail( - versions.POLARS_VERSION_LT_11, - reason="https://github.com/pola-rs/polars/issues/15730", -) def test_scan_row_index_projected_out(tmp_path): df = pl.DataFrame({"a": [1, 2, 3]}) @@ -169,15 +184,25 @@ def test_scan_csv_column_renames_projection_schema(tmp_path): ("test*.csv", False), ], ) -def test_scan_csv_multi(tmp_path, filename, glob): +@pytest.mark.parametrize( + "nrows_skiprows", + [ + (None, 0), + (1, 1), + (3, 0), + (4, 2), + ], +) +def test_scan_csv_multi(tmp_path, filename, glob, nrows_skiprows): + n_rows, skiprows = nrows_skiprows with (tmp_path / "test1.csv").open("w") as f: - f.write("""foo,bar,baz\n1,2\n3,4,5""") + f.write("""foo,bar,baz\n1,2,3\n3,4,5""") with (tmp_path / "test2.csv").open("w") as f: - f.write("""foo,bar,baz\n1,2\n3,4,5""") + f.write("""foo,bar,baz\n1,2,3\n3,4,5""") with (tmp_path / "test*.csv").open("w") as f: - f.write("""foo,bar,baz\n1,2\n3,4,5""") + f.write("""foo,bar,baz\n1,2,3\n3,4,5""") os.chdir(tmp_path) - q = pl.scan_csv(filename, glob=glob) + q = pl.scan_csv(filename, glob=glob, n_rows=n_rows, skip_rows=skiprows) assert_gpu_result_equal(q) @@ -280,3 +305,24 @@ def test_scan_ndjson_unsupported(df, tmp_path): make_source(df, tmp_path / "file", "ndjson") q = pl.scan_ndjson(tmp_path / "file", ignore_errors=True) assert_ir_translation_raises(q, NotImplementedError) + + +def test_scan_parquet_nested_null_raises(tmp_path): + df = pl.DataFrame({"a": pl.Series([None], dtype=pl.List(pl.Null))}) + + df.write_parquet(tmp_path / "file.pq") + + q = pl.scan_parquet(tmp_path / "file.pq") + + assert_ir_translation_raises(q, NotImplementedError) + + +def test_scan_parquet_only_row_index_raises(df, tmp_path): + make_source(df, tmp_path / "file", "parquet") + q = pl.scan_parquet(tmp_path / "file", row_index_name="index").select("index") + assert_ir_translation_raises(q, NotImplementedError) + + +def test_scan_hf_url_raises(): + q = pl.scan_csv("hf://datasets/scikit-learn/iris/Iris.csv") + assert_ir_translation_raises(q, NotImplementedError) diff --git a/python/cudf_polars/tests/test_sort.py b/python/cudf_polars/tests/test_sort.py index ecc02efd967..cfa8e5ff9b9 100644 --- a/python/cudf_polars/tests/test_sort.py +++ b/python/cudf_polars/tests/test_sort.py @@ -13,10 +13,7 @@ "sort_keys", [ (pl.col("a"),), - pytest.param( - (pl.col("d").abs(),), - marks=pytest.mark.xfail(reason="abs not yet implemented"), - ), + (pl.col("d").abs(),), (pl.col("a"), pl.col("d")), (pl.col("b"),), ], diff --git a/python/cudf_polars/tests/testing/test_asserts.py b/python/cudf_polars/tests/testing/test_asserts.py index 5bc2fe1efb7..8e7f1a09d9b 100644 --- a/python/cudf_polars/tests/testing/test_asserts.py +++ b/python/cudf_polars/tests/testing/test_asserts.py @@ -7,7 +7,10 @@ import polars as pl +from cudf_polars.containers import DataFrame +from cudf_polars.dsl.ir import Select from cudf_polars.testing.asserts import ( + assert_collect_raises, assert_gpu_result_equal, assert_ir_translation_raises, ) @@ -26,10 +29,62 @@ def test_translation_assert_raises(): class E(Exception): pass - unsupported = df.group_by("a").agg(pl.col("a").cum_max().alias("b")) + unsupported = df.group_by("a").agg(pl.col("a").upper_bound().alias("b")) # Unsupported query should raise NotImplementedError assert_ir_translation_raises(unsupported, NotImplementedError) with pytest.raises(AssertionError): # This should fail, because we can't translate this query, but it doesn't raise E. assert_ir_translation_raises(unsupported, E) + + +def test_collect_assert_raises(monkeypatch): + df = pl.LazyFrame({"a": [1, 2, 3], "b": ["a", "b", "c"]}) + + with pytest.raises(AssertionError): + # This should raise, because polars CPU can run this query + assert_collect_raises( + df, + polars_except=pl.exceptions.InvalidOperationError, + cudf_except=pl.exceptions.InvalidOperationError, + ) + + # Here's an invalid query that gets caught at IR optimisation time. + q = df.select(pl.col("a") * pl.col("b")) + + # This exception is raised in preprocessing, so is the same for + # both CPU and GPU engines. + assert_collect_raises( + q, + polars_except=pl.exceptions.InvalidOperationError, + cudf_except=pl.exceptions.InvalidOperationError, + ) + + with pytest.raises(AssertionError): + # This should raise because the expected GPU error is wrong + assert_collect_raises( + q, + polars_except=pl.exceptions.InvalidOperationError, + cudf_except=NotImplementedError, + ) + + with pytest.raises(AssertionError): + # This should raise because the expected CPU error is wrong + assert_collect_raises( + q, + polars_except=NotImplementedError, + cudf_except=pl.exceptions.InvalidOperationError, + ) + + with monkeypatch.context() as m: + m.setattr(Select, "evaluate", lambda self, cache: DataFrame([])) + # This query should fail, but we monkeypatch a bad + # implementation of Select which "succeeds" to check that our + # assertion notices this case. + q = df.select(pl.col("a") + pl.Series([1, 2])) + with pytest.raises(AssertionError): + assert_collect_raises( + q, + polars_except=pl.exceptions.ComputeError, + cudf_except=pl.exceptions.ComputeError, + ) From 250a73ab64c036cb82dfda1542a12b98603fab95 Mon Sep 17 00:00:00 2001 From: Bradley Dice Date: Tue, 17 Sep 2024 01:13:43 -0500 Subject: [PATCH 02/15] Fix pylibcudf imports, branches, and more. --- .github/workflows/pr.yaml | 2 +- ci/test_cudf_polars_polars_tests.sh | 2 +- .../api_docs/pylibcudf/strings/strip.rst | 2 +- python/cudf/cudf/_lib/datetime.pyx | 2 +- python/cudf/cudf/_lib/string_casting.pyx | 4 +++- python/cudf/cudf/_lib/strings/strip.pyx | 2 +- .../strings/convert/convert_datetime.pxd | 5 ++--- .../strings/convert/convert_datetime.pyx | 9 ++++----- .../strings/convert/convert_durations.pxd | 5 ++--- .../strings/convert/convert_durations.pyx | 9 ++++----- python/pylibcudf/pylibcudf/strings/side_type.pxd | 2 +- python/pylibcudf/pylibcudf/strings/side_type.pyx | 2 +- python/pylibcudf/pylibcudf/strings/strip.pxd | 6 +++--- python/pylibcudf/pylibcudf/strings/strip.pyx | 15 +++++++-------- .../pylibcudf/tests/test_string_convert.py | 3 +-- .../pylibcudf/tests/test_string_strip.py | 3 +-- 16 files changed, 34 insertions(+), 39 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 68b1a38737e..2c76e50eedd 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -248,7 +248,7 @@ jobs: cudf-polars-polars-tests: needs: wheel-build-cudf-polars secrets: inherit - uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@branch-24.08 + uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@python-3.12 with: # This selects "ARCH=amd64 + the latest supported Python + CUDA". matrix_filter: map(select(.ARCH == "amd64")) | group_by(.CUDA_VER|split(".")|map(tonumber)|.[0]) | map(max_by([(.PY_VER|split(".")|map(tonumber)), (.CUDA_VER|split(".")|map(tonumber))])) diff --git a/ci/test_cudf_polars_polars_tests.sh b/ci/test_cudf_polars_polars_tests.sh index 924fc4ef28b..25ed44df316 100755 --- a/ci/test_cudf_polars_polars_tests.sh +++ b/ci/test_cudf_polars_polars_tests.sh @@ -10,7 +10,7 @@ set -eou pipefail # files in cudf_polars/pylibcudf", rather than "are there changes # between upstream and this branch which touch cudf_polars/pylibcudf" # TODO: is the target branch exposed anywhere in an environment variable? -if [ -n "$(git diff --name-only origin/branch-24.08...HEAD -- python/cudf_polars/ python/cudf/cudf/_lib/pylibcudf/)" ]; +if [ -n "$(git diff --name-only origin/branch-24.10...HEAD -- python/cudf_polars/ python/cudf/cudf/_lib/pylibcudf/)" ]; then HAS_CHANGES=1 rapids-logger "PR has changes in cudf-polars/pylibcudf, test fails treated as failure" diff --git a/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/strip.rst b/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/strip.rst index 32f87e013ad..a79774b8e67 100644 --- a/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/strip.rst +++ b/docs/cudf/source/user_guide/api_docs/pylibcudf/strings/strip.rst @@ -2,5 +2,5 @@ strip ===== -.. automodule:: cudf._lib.pylibcudf.strings.strip +.. automodule:: pylibcudf.strings.strip :members: diff --git a/python/cudf/cudf/_lib/datetime.pyx b/python/cudf/cudf/_lib/datetime.pyx index 3eb24d14441..bc5e085ec39 100644 --- a/python/cudf/cudf/_lib/datetime.pyx +++ b/python/cudf/cudf/_lib/datetime.pyx @@ -17,7 +17,7 @@ from pylibcudf.libcudf.types cimport size_type from cudf._lib.column cimport Column from cudf._lib.scalar cimport DeviceScalar -import cudf._lib.pylibcudf as plc +import pylibcudf as plc @acquire_spill_lock() diff --git a/python/cudf/cudf/_lib/string_casting.pyx b/python/cudf/cudf/_lib/string_casting.pyx index 6d2734d552d..60a6795a402 100644 --- a/python/cudf/cudf/_lib/string_casting.pyx +++ b/python/cudf/cudf/_lib/string_casting.pyx @@ -42,8 +42,10 @@ from pylibcudf.libcudf.types cimport data_type, type_id from cudf._lib.types cimport underlying_type_t_type_id +import pylibcudf as plc + import cudf -import cudf._lib.pylibcudf as plc + from cudf._lib.types cimport dtype_to_pylibcudf_type diff --git a/python/cudf/cudf/_lib/strings/strip.pyx b/python/cudf/cudf/_lib/strings/strip.pyx index 22102eb2a32..38ecb21a94c 100644 --- a/python/cudf/cudf/_lib/strings/strip.pyx +++ b/python/cudf/cudf/_lib/strings/strip.pyx @@ -13,7 +13,7 @@ from pylibcudf.libcudf.strings.strip cimport strip as cpp_strip from cudf._lib.column cimport Column from cudf._lib.scalar cimport DeviceScalar -import cudf._lib.pylibcudf as plc +import pylibcudf as plc @acquire_spill_lock() diff --git a/python/pylibcudf/pylibcudf/strings/convert/convert_datetime.pxd b/python/pylibcudf/pylibcudf/strings/convert/convert_datetime.pxd index a6ad4dc1b3a..07c84d263d6 100644 --- a/python/pylibcudf/pylibcudf/strings/convert/convert_datetime.pxd +++ b/python/pylibcudf/pylibcudf/strings/convert/convert_datetime.pxd @@ -1,9 +1,8 @@ # Copyright (c) 2024, NVIDIA CORPORATION. from libcpp.string cimport string - -from cudf._lib.pylibcudf.column cimport Column -from cudf._lib.pylibcudf.types cimport DataType +from pylibcudf.column cimport Column +from pylibcudf.types cimport DataType cpdef Column to_timestamps( diff --git a/python/pylibcudf/pylibcudf/strings/convert/convert_datetime.pyx b/python/pylibcudf/pylibcudf/strings/convert/convert_datetime.pyx index a51b317e95a..fcacb096f87 100644 --- a/python/pylibcudf/pylibcudf/strings/convert/convert_datetime.pyx +++ b/python/pylibcudf/pylibcudf/strings/convert/convert_datetime.pyx @@ -3,14 +3,13 @@ from libcpp.memory cimport unique_ptr from libcpp.string cimport string from libcpp.utility cimport move - -from cudf._lib.pylibcudf.column cimport Column -from cudf._lib.pylibcudf.libcudf.column.column cimport column -from cudf._lib.pylibcudf.libcudf.strings.convert cimport ( +from pylibcudf.column cimport Column +from pylibcudf.libcudf.column.column cimport column +from pylibcudf.libcudf.strings.convert cimport ( convert_datetime as cpp_convert_datetime, ) -from cudf._lib.pylibcudf.types import DataType +from pylibcudf.types import DataType cpdef Column to_timestamps( diff --git a/python/pylibcudf/pylibcudf/strings/convert/convert_durations.pxd b/python/pylibcudf/pylibcudf/strings/convert/convert_durations.pxd index 74d31a4f7b6..ac11b8959ed 100644 --- a/python/pylibcudf/pylibcudf/strings/convert/convert_durations.pxd +++ b/python/pylibcudf/pylibcudf/strings/convert/convert_durations.pxd @@ -1,9 +1,8 @@ # Copyright (c) 2024, NVIDIA CORPORATION. from libcpp.string cimport string - -from cudf._lib.pylibcudf.column cimport Column -from cudf._lib.pylibcudf.types cimport DataType +from pylibcudf.column cimport Column +from pylibcudf.types cimport DataType cpdef Column to_durations( diff --git a/python/pylibcudf/pylibcudf/strings/convert/convert_durations.pyx b/python/pylibcudf/pylibcudf/strings/convert/convert_durations.pyx index c94433fe215..f3e0b7c9c8e 100644 --- a/python/pylibcudf/pylibcudf/strings/convert/convert_durations.pyx +++ b/python/pylibcudf/pylibcudf/strings/convert/convert_durations.pyx @@ -3,14 +3,13 @@ from libcpp.memory cimport unique_ptr from libcpp.string cimport string from libcpp.utility cimport move - -from cudf._lib.pylibcudf.column cimport Column -from cudf._lib.pylibcudf.libcudf.column.column cimport column -from cudf._lib.pylibcudf.libcudf.strings.convert cimport ( +from pylibcudf.column cimport Column +from pylibcudf.libcudf.column.column cimport column +from pylibcudf.libcudf.strings.convert cimport ( convert_durations as cpp_convert_durations, ) -from cudf._lib.pylibcudf.types import DataType +from pylibcudf.types import DataType cpdef Column to_durations( diff --git a/python/pylibcudf/pylibcudf/strings/side_type.pxd b/python/pylibcudf/pylibcudf/strings/side_type.pxd index 95bf6fabb15..34b7a580380 100644 --- a/python/pylibcudf/pylibcudf/strings/side_type.pxd +++ b/python/pylibcudf/pylibcudf/strings/side_type.pxd @@ -1,3 +1,3 @@ # Copyright (c) 2024, NVIDIA CORPORATION. -from cudf._lib.pylibcudf.libcudf.strings.side_type cimport side_type +from pylibcudf.libcudf.strings.side_type cimport side_type diff --git a/python/pylibcudf/pylibcudf/strings/side_type.pyx b/python/pylibcudf/pylibcudf/strings/side_type.pyx index dcbe8af7f6f..acdc7d6ff1f 100644 --- a/python/pylibcudf/pylibcudf/strings/side_type.pyx +++ b/python/pylibcudf/pylibcudf/strings/side_type.pyx @@ -1,4 +1,4 @@ # Copyright (c) 2024, NVIDIA CORPORATION. -from cudf._lib.pylibcudf.libcudf.strings.side_type import \ +from pylibcudf.libcudf.strings.side_type import \ side_type as SideType # no-cython-lint diff --git a/python/pylibcudf/pylibcudf/strings/strip.pxd b/python/pylibcudf/pylibcudf/strings/strip.pxd index f3bdbacbaf8..8bbe4753edd 100644 --- a/python/pylibcudf/pylibcudf/strings/strip.pxd +++ b/python/pylibcudf/pylibcudf/strings/strip.pxd @@ -1,8 +1,8 @@ # Copyright (c) 2024, NVIDIA CORPORATION. -from cudf._lib.pylibcudf.column cimport Column -from cudf._lib.pylibcudf.scalar cimport Scalar -from cudf._lib.pylibcudf.strings.side_type cimport side_type +from pylibcudf.column cimport Column +from pylibcudf.scalar cimport Scalar +from pylibcudf.strings.side_type cimport side_type cpdef Column strip( diff --git a/python/pylibcudf/pylibcudf/strings/strip.pyx b/python/pylibcudf/pylibcudf/strings/strip.pyx index 5179774f82d..429a23c3cdf 100644 --- a/python/pylibcudf/pylibcudf/strings/strip.pyx +++ b/python/pylibcudf/pylibcudf/strings/strip.pyx @@ -3,16 +3,15 @@ from cython.operator cimport dereference from libcpp.memory cimport unique_ptr from libcpp.utility cimport move - -from cudf._lib.pylibcudf.column cimport Column -from cudf._lib.pylibcudf.libcudf.column.column cimport column -from cudf._lib.pylibcudf.libcudf.scalar.scalar cimport string_scalar -from cudf._lib.pylibcudf.libcudf.scalar.scalar_factories cimport ( +from pylibcudf.column cimport Column +from pylibcudf.libcudf.column.column cimport column +from pylibcudf.libcudf.scalar.scalar cimport string_scalar +from pylibcudf.libcudf.scalar.scalar_factories cimport ( make_string_scalar as cpp_make_string_scalar, ) -from cudf._lib.pylibcudf.libcudf.strings cimport strip as cpp_strip -from cudf._lib.pylibcudf.scalar cimport Scalar -from cudf._lib.pylibcudf.strings.side_type cimport side_type +from pylibcudf.libcudf.strings cimport strip as cpp_strip +from pylibcudf.scalar cimport Scalar +from pylibcudf.strings.side_type cimport side_type cpdef Column strip( diff --git a/python/pylibcudf/pylibcudf/tests/test_string_convert.py b/python/pylibcudf/pylibcudf/tests/test_string_convert.py index 3ea53685eaf..e9e95459d0e 100644 --- a/python/pylibcudf/pylibcudf/tests/test_string_convert.py +++ b/python/pylibcudf/pylibcudf/tests/test_string_convert.py @@ -3,11 +3,10 @@ from datetime import datetime import pyarrow as pa +import pylibcudf as plc import pytest from utils import assert_column_eq -import cudf._lib.pylibcudf as plc - @pytest.fixture( scope="module", diff --git a/python/pylibcudf/pylibcudf/tests/test_string_strip.py b/python/pylibcudf/pylibcudf/tests/test_string_strip.py index e2567785a70..005e5e4a405 100644 --- a/python/pylibcudf/pylibcudf/tests/test_string_strip.py +++ b/python/pylibcudf/pylibcudf/tests/test_string_strip.py @@ -1,11 +1,10 @@ # Copyright (c) 2024, NVIDIA CORPORATION. import pyarrow as pa +import pylibcudf as plc import pytest from utils import assert_column_eq -import cudf._lib.pylibcudf as plc - data_strings = [ "AbC", "123abc", From 4291f26377a9846c653b135e16e757426014ff53 Mon Sep 17 00:00:00 2001 From: Bradley Dice Date: Tue, 17 Sep 2024 11:40:46 -0700 Subject: [PATCH 03/15] Clean up cudf dependency in cudf_polars.__init__. --- python/cudf_polars/cudf_polars/__init__.py | 28 ++++------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/python/cudf_polars/cudf_polars/__init__.py b/python/cudf_polars/cudf_polars/__init__.py index bada971756a..c1317e8f467 100644 --- a/python/cudf_polars/cudf_polars/__init__.py +++ b/python/cudf_polars/cudf_polars/__init__.py @@ -10,31 +10,11 @@ from __future__ import annotations -import os -import warnings - -# We want to avoid initialising the GPU on import. Unfortunately, -# while we still depend on cudf, the default mode is to check things. -# If we set RAPIDS_NO_INITIALIZE, then cudf doesn't do import-time -# validation, good. -# We additionally must set the ptxcompiler environment variable, so -# that we don't check if a numba patch is needed. But if this is done, -# then the patching mechanism warns, and we want to squash that -# warning too. -# TODO: Remove this when we only depend on a pylibcudf package. -os.environ["RAPIDS_NO_INITIALIZE"] = "1" -os.environ["PTXCOMPILER_CHECK_NUMBA_CODEGEN_PATCH_NEEDED"] = "0" -with warnings.catch_warnings(): - warnings.simplefilter("ignore") - import cudf - - del cudf - # Check we have a supported polars version -import cudf_polars.utils.versions as v # noqa: E402 -from cudf_polars._version import __git_commit__, __version__ # noqa: E402 -from cudf_polars.callback import execute_with_cudf # noqa: E402 -from cudf_polars.dsl.translate import translate_ir # noqa: E402 +import cudf_polars.utils.versions as v +from cudf_polars._version import __git_commit__, __version__ +from cudf_polars.callback import execute_with_cudf +from cudf_polars.dsl.translate import translate_ir del v From 3886c7ca2b28b723586f583ba218d3f913ed9508 Mon Sep 17 00:00:00 2001 From: Lawrence Mitchell Date: Thu, 19 Sep 2024 18:17:30 +0100 Subject: [PATCH 04/15] Download pylibcudf wheel when testing polars itself --- ci/test_cudf_polars_polars_tests.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/test_cudf_polars_polars_tests.sh b/ci/test_cudf_polars_polars_tests.sh index 25ed44df316..8ec87c7bd62 100755 --- a/ci/test_cudf_polars_polars_tests.sh +++ b/ci/test_cudf_polars_polars_tests.sh @@ -25,10 +25,10 @@ RAPIDS_PY_CUDA_SUFFIX="$(rapids-wheel-ctk-name-gen ${RAPIDS_CUDA_VERSION})" RAPIDS_PY_WHEEL_NAME="cudf_polars_${RAPIDS_PY_CUDA_SUFFIX}" RAPIDS_PY_WHEEL_PURE="1" rapids-download-wheels-from-s3 ./dist # Download the cudf built in the previous step -RAPIDS_PY_WHEEL_NAME="cudf_${RAPIDS_PY_CUDA_SUFFIX}" rapids-download-wheels-from-s3 ./local-cudf-dep +RAPIDS_PY_WHEEL_NAME="pylibcudf_${RAPIDS_PY_CUDA_SUFFIX}" rapids-download-wheels-from-s3 ./local-pylibcudf-dep -rapids-logger "Install cudf" -python -m pip install ./local-cudf-dep/cudf*.whl +rapids-logger "Install pylibcudf" +python -m pip install ./local-pylibcudf-dep/pylibcudf*.whl rapids-logger "Install cudf_polars" python -m pip install $(echo ./dist/cudf_polars*.whl) From 9df13d1094a559a6b123c74393344057be6f2ecf Mon Sep 17 00:00:00 2001 From: Lawrence Mitchell Date: Thu, 19 Sep 2024 18:18:06 +0100 Subject: [PATCH 05/15] No cover for 1.6 IR changes CI only tests 1.7 --- python/cudf_polars/cudf_polars/dsl/translate.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/cudf_polars/cudf_polars/dsl/translate.py b/python/cudf_polars/cudf_polars/dsl/translate.py index a5e17c25e4d..2fa96a59bb7 100644 --- a/python/cudf_polars/cudf_polars/dsl/translate.py +++ b/python/cudf_polars/cudf_polars/dsl/translate.py @@ -98,8 +98,9 @@ def _( and visitor.version()[0] == 1 and reader_options["schema"] is not None ): - # Polars 1.7 renames the inner slot from "inner" to "fields". - reader_options["schema"] = {"fields": reader_options["schema"]["inner"]} + reader_options["schema"] = { + "fields": reader_options["schema"]["inner"] + } # pragma: no cover; CI tests 1.7 file_options = node.file_options with_columns = file_options.with_columns n_rows = file_options.n_rows From 944312d9020417d3dcf76f46cfea081d90944d43 Mon Sep 17 00:00:00 2001 From: Bradley Dice Date: Thu, 19 Sep 2024 16:58:04 -0500 Subject: [PATCH 06/15] Fix package name. --- ci/test_cudf_polars_polars_tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/test_cudf_polars_polars_tests.sh b/ci/test_cudf_polars_polars_tests.sh index 8ec87c7bd62..6c728a9537f 100755 --- a/ci/test_cudf_polars_polars_tests.sh +++ b/ci/test_cudf_polars_polars_tests.sh @@ -24,7 +24,7 @@ rapids-logger "Download wheels" RAPIDS_PY_CUDA_SUFFIX="$(rapids-wheel-ctk-name-gen ${RAPIDS_CUDA_VERSION})" RAPIDS_PY_WHEEL_NAME="cudf_polars_${RAPIDS_PY_CUDA_SUFFIX}" RAPIDS_PY_WHEEL_PURE="1" rapids-download-wheels-from-s3 ./dist -# Download the cudf built in the previous step +# Download the pylibcudf built in the previous step RAPIDS_PY_WHEEL_NAME="pylibcudf_${RAPIDS_PY_CUDA_SUFFIX}" rapids-download-wheels-from-s3 ./local-pylibcudf-dep rapids-logger "Install pylibcudf" From 8a1d652118d352b1fd9eb646be09352a673beb76 Mon Sep 17 00:00:00 2001 From: Lawrence Mitchell Date: Fri, 20 Sep 2024 14:23:45 +0100 Subject: [PATCH 07/15] Fix branch in shared-workflow pointer --- .github/workflows/pr.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 93c75ead4cd..af1538ad0c1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -248,7 +248,7 @@ jobs: cudf-polars-polars-tests: needs: wheel-build-cudf-polars secrets: inherit - uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@python-3.12 + uses: rapidsai/shared-workflows/.github/workflows/wheels-test.yaml@branch-24.10 with: # This selects "ARCH=amd64 + the latest supported Python + CUDA". matrix_filter: map(select(.ARCH == "amd64")) | group_by(.CUDA_VER|split(".")|map(tonumber)|.[0]) | map(max_by([(.PY_VER|split(".")|map(tonumber)), (.CUDA_VER|split(".")|map(tonumber))])) From e278018363814df3c939f01df2ed0646a1ab3d24 Mon Sep 17 00:00:00 2001 From: Lawrence Mitchell Date: Fri, 20 Sep 2024 13:59:49 +0000 Subject: [PATCH 08/15] cmake-format --- python/pylibcudf/pylibcudf/strings/CMakeLists.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/pylibcudf/pylibcudf/strings/CMakeLists.txt b/python/pylibcudf/pylibcudf/strings/CMakeLists.txt index fc8ec35bf9c..8b4fbb1932f 100644 --- a/python/pylibcudf/pylibcudf/strings/CMakeLists.txt +++ b/python/pylibcudf/pylibcudf/strings/CMakeLists.txt @@ -12,9 +12,9 @@ # the License. # ============================================================================= -set(cython_sources capitalize.pyx case.pyx char_types.pyx contains.pyx extract.pyx find.pyx - regex_flags.pyx regex_program.pyx repeat.pyx replace.pyx side_type.pyx - slice.pyx strip.pyx +set(cython_sources + capitalize.pyx case.pyx char_types.pyx contains.pyx extract.pyx find.pyx regex_flags.pyx + regex_program.pyx repeat.pyx replace.pyx side_type.pyx slice.pyx strip.pyx ) set(linked_libraries cudf::cudf) From 434f99b1b3842d1a2344725c4cb6e91d2be5b13b Mon Sep 17 00:00:00 2001 From: Lawrence Mitchell Date: Fri, 20 Sep 2024 14:01:08 +0000 Subject: [PATCH 09/15] Pacify ruff --- python/cudf_polars/cudf_polars/dsl/translate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cudf_polars/cudf_polars/dsl/translate.py b/python/cudf_polars/cudf_polars/dsl/translate.py index 2fa96a59bb7..45881afe0c8 100644 --- a/python/cudf_polars/cudf_polars/dsl/translate.py +++ b/python/cudf_polars/cudf_polars/dsl/translate.py @@ -100,7 +100,7 @@ def _( ): reader_options["schema"] = { "fields": reader_options["schema"]["inner"] - } # pragma: no cover; CI tests 1.7 + } # pragma: no cover; CI tests 1.7 file_options = node.file_options with_columns = file_options.with_columns n_rows = file_options.n_rows From 9834a3ab2b4554e0abd2c2eb1ee76f0462661144 Mon Sep 17 00:00:00 2001 From: Lawrence Mitchell Date: Fri, 20 Sep 2024 16:57:28 +0000 Subject: [PATCH 10/15] Update xfailing tests in polars test suite --- python/cudf_polars/cudf_polars/testing/plugin.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/cudf_polars/cudf_polars/testing/plugin.py b/python/cudf_polars/cudf_polars/testing/plugin.py index 7be40f6f762..c40d59e6d33 100644 --- a/python/cudf_polars/cudf_polars/testing/plugin.py +++ b/python/cudf_polars/cudf_polars/testing/plugin.py @@ -53,8 +53,7 @@ def pytest_configure(config: pytest.Config): "tests/unit/io/test_lazy_parquet.py::test_parquet_statistics": "Debug output on stderr doesn't match", "tests/unit/io/test_lazy_parquet.py::test_parquet_different_schema[False]": "Needs cudf#16394", "tests/unit/io/test_lazy_parquet.py::test_parquet_schema_mismatch_panic_17067[False]": "Needs cudf#16394", - "tests/unit/io/test_lazy_parquet.py::test_parquet_slice_pushdown_non_zero_offset[True]": "Unknown error: invalid parquet?", - "tests/unit/io/test_lazy_parquet.py::test_parquet_slice_pushdown_non_zero_offset[False]": "Unknown error: invalid parquet?", + "tests/unit/io/test_lazy_parquet.py::test_parquet_slice_pushdown_non_zero_offset[False]": "Thrift data not handled correctly/slice pushdown wrong?", "tests/unit/io/test_parquet.py::test_read_parquet_only_loads_selected_columns_15098": "Memory usage won't be correct due to GPU", "tests/unit/io/test_scan.py::test_scan[single-csv-async]": "Debug output on stderr doesn't match", "tests/unit/io/test_scan.py::test_scan_with_limit[single-csv-async]": "Debug output on stderr doesn't match", @@ -119,7 +118,6 @@ def pytest_configure(config: pytest.Config): "tests/unit/operations/test_group_by.py::test_group_by_median_by_dtype[input15-expected15-input_dtype15-output_dtype15]": "Unsupported groupby-agg for a particular dtype", "tests/unit/operations/test_group_by.py::test_group_by_median_by_dtype[input16-expected16-input_dtype16-output_dtype16]": "Unsupported groupby-agg for a particular dtype", "tests/unit/operations/test_group_by.py::test_group_by_binary_agg_with_literal": "Incorrect broadcasting of literals in groupby-agg", - "tests/unit/operations/test_group_by.py::test_group_by_apply_first_input_is_literal": "Polars advertises incorrect schema names polars#18524", "tests/unit/operations/test_group_by.py::test_aggregated_scalar_elementwise_15602": "Unsupported boolean function/dtype combination in groupby-agg", "tests/unit/operations/test_group_by.py::test_schemas[data1-expr1-expected_select1-expected_gb1]": "Mismatching dtypes, needs cudf#15852", "tests/unit/operations/test_group_by_dynamic.py::test_group_by_dynamic_by_monday_and_offset_5444": "IR needs to expose groupby-dynamic information", From 69ab9880eec438d106f6769cc8323e13fe2098b0 Mon Sep 17 00:00:00 2001 From: Basit Ayantunde Date: Fri, 20 Sep 2024 22:44:01 +0100 Subject: [PATCH 11/15] Exposed stream-ordering to join API (#16793) Adds stream ordering to the public join APIs: - `inner_join` - `left_join` - `full_join` - `left_semi_join` - `left_anti_join` - `cross_join` - `conditional_inner_join` - `conditional_left_join` - `conditional_full_join` - `conditional_left_semi_join` - `conditional_left_anti_join` - `mixed_inner_join` - `mixed_left_join` - `mixed_full_join` - `mixed_left_semi_join` - `mixed_left_anti_join` - `mixed_inner_join_size` - `mixed_left_join_size` - `conditional_inner_join_size` - `conditional_left_join_size` - `conditional_left_semi_join_size` - `conditional_left_anti_join_size` closes #16792 follows up https://github.com/rapidsai/cudf/issues/13744 Authors: - Basit Ayantunde (https://github.com/lamarrr) Approvers: - Paul Mattione (https://github.com/pmattione-nvidia) - Nghia Truong (https://github.com/ttnghia) - David Wendt (https://github.com/davidwendt) URL: https://github.com/rapidsai/cudf/pull/16793 --- .../ndsh_data_generator/table_helpers.cpp | 2 +- cpp/benchmarks/ndsh/utilities.cpp | 15 +- cpp/examples/parquet_io/parquet_io.cpp | 9 +- cpp/include/cudf/join.hpp | 44 ++++ cpp/src/join/conditional_join.cu | 75 ++---- cpp/src/join/conditional_join.hpp | 1 - cpp/src/join/cross_join.cu | 4 +- cpp/src/join/join.cu | 10 +- cpp/src/join/mixed_join.cu | 16 +- cpp/src/join/mixed_join_semi.cu | 7 +- cpp/src/join/semi_join.cu | 7 +- cpp/tests/CMakeLists.txt | 1 + cpp/tests/join/join_tests.cpp | 12 +- cpp/tests/join/semi_anti_join_tests.cpp | 7 +- cpp/tests/streams/join_test.cpp | 219 ++++++++++++++++++ 15 files changed, 349 insertions(+), 80 deletions(-) create mode 100644 cpp/tests/streams/join_test.cpp diff --git a/cpp/benchmarks/common/ndsh_data_generator/table_helpers.cpp b/cpp/benchmarks/common/ndsh_data_generator/table_helpers.cpp index d4368906702..54d177df401 100644 --- a/cpp/benchmarks/common/ndsh_data_generator/table_helpers.cpp +++ b/cpp/benchmarks/common/ndsh_data_generator/table_helpers.cpp @@ -85,7 +85,7 @@ std::unique_ptr perform_left_join(cudf::table_view const& left_inpu auto const left_selected = left_input.select(left_on); auto const right_selected = right_input.select(right_on); auto const [left_join_indices, right_join_indices] = - cudf::left_join(left_selected, right_selected, cudf::null_equality::EQUAL, mr); + cudf::left_join(left_selected, right_selected, cudf::null_equality::EQUAL, stream, mr); auto const left_indices_span = cudf::device_span{*left_join_indices}; auto const right_indices_span = cudf::device_span{*right_join_indices}; diff --git a/cpp/benchmarks/ndsh/utilities.cpp b/cpp/benchmarks/ndsh/utilities.cpp index 2d514764fc2..62116ddf661 100644 --- a/cpp/benchmarks/ndsh/utilities.cpp +++ b/cpp/benchmarks/ndsh/utilities.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include @@ -146,11 +147,15 @@ std::unique_ptr join_and_gather(cudf::table_view const& left_input, cudf::null_equality compare_nulls) { CUDF_FUNC_RANGE(); - constexpr auto oob_policy = cudf::out_of_bounds_policy::DONT_CHECK; - auto const left_selected = left_input.select(left_on); - auto const right_selected = right_input.select(right_on); - auto const [left_join_indices, right_join_indices] = cudf::inner_join( - left_selected, right_selected, compare_nulls, cudf::get_current_device_resource_ref()); + constexpr auto oob_policy = cudf::out_of_bounds_policy::DONT_CHECK; + auto const left_selected = left_input.select(left_on); + auto const right_selected = right_input.select(right_on); + auto const [left_join_indices, right_join_indices] = + cudf::inner_join(left_selected, + right_selected, + compare_nulls, + cudf::get_default_stream(), + cudf::get_current_device_resource_ref()); auto const left_indices_span = cudf::device_span{*left_join_indices}; auto const right_indices_span = cudf::device_span{*right_join_indices}; diff --git a/cpp/examples/parquet_io/parquet_io.cpp b/cpp/examples/parquet_io/parquet_io.cpp index 442731694fa..9cda22d0695 100644 --- a/cpp/examples/parquet_io/parquet_io.cpp +++ b/cpp/examples/parquet_io/parquet_io.cpp @@ -18,6 +18,8 @@ #include "../utilities/timer.hpp" +#include + /** * @file parquet_io.cpp * @brief Demonstrates usage of the libcudf APIs to read and write @@ -159,8 +161,11 @@ int main(int argc, char const** argv) // Left anti-join the original and transcoded tables // identical tables should not throw an exception and // return an empty indices vector - auto const indices = cudf::left_anti_join( - input->view(), transcoded_input->view(), cudf::null_equality::EQUAL, resource.get()); + auto const indices = cudf::left_anti_join(input->view(), + transcoded_input->view(), + cudf::null_equality::EQUAL, + cudf::get_default_stream(), + resource.get()); // No exception thrown, check indices auto const valid = indices->size() == 0; diff --git a/cpp/include/cudf/join.hpp b/cpp/include/cudf/join.hpp index cc8912cb022..a590eb27511 100644 --- a/cpp/include/cudf/join.hpp +++ b/cpp/include/cudf/join.hpp @@ -97,6 +97,7 @@ class distinct_hash_join; * @param[in] right_keys The right table * @param[in] compare_nulls controls whether null join-key values * should match or not. + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -108,6 +109,7 @@ std::pair>, inner_join(cudf::table_view const& left_keys, cudf::table_view const& right_keys, null_equality compare_nulls = null_equality::EQUAL, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -137,6 +139,7 @@ inner_join(cudf::table_view const& left_keys, * @param[in] right_keys The right table * @param[in] compare_nulls controls whether null join-key values * should match or not. + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -148,6 +151,7 @@ std::pair>, left_join(cudf::table_view const& left_keys, cudf::table_view const& right_keys, null_equality compare_nulls = null_equality::EQUAL, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -176,6 +180,7 @@ left_join(cudf::table_view const& left_keys, * @param[in] right_keys The right table * @param[in] compare_nulls controls whether null join-key values * should match or not. + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -187,6 +192,7 @@ std::pair>, full_join(cudf::table_view const& left_keys, cudf::table_view const& right_keys, null_equality compare_nulls = null_equality::EQUAL, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -205,6 +211,7 @@ full_join(cudf::table_view const& left_keys, * @param left_keys The left table * @param right_keys The right table * @param compare_nulls Controls whether null join-key values should match or not + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A vector `left_indices` that can be used to construct @@ -215,6 +222,7 @@ std::unique_ptr> left_semi_join( cudf::table_view const& left_keys, cudf::table_view const& right_keys, null_equality compare_nulls = null_equality::EQUAL, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -236,6 +244,7 @@ std::unique_ptr> left_semi_join( * @param[in] right_keys The right table * @param[in] compare_nulls controls whether null join-key values * should match or not. + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A column `left_indices` that can be used to construct @@ -246,6 +255,7 @@ std::unique_ptr> left_anti_join( cudf::table_view const& left_keys, cudf::table_view const& right_keys, null_equality compare_nulls = null_equality::EQUAL, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -266,6 +276,7 @@ std::unique_ptr> left_anti_join( * * @param left The left table * @param right The right table + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table's device memory * * @return Result of cross joining `left` and `right` tables @@ -273,6 +284,7 @@ std::unique_ptr> left_anti_join( std::unique_ptr cross_join( cudf::table_view const& left, cudf::table_view const& right, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -567,6 +579,7 @@ class distinct_hash_join { * @param right The right table * @param binary_predicate The condition on which to join * @param output_size Optional value which allows users to specify the exact output size + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -578,6 +591,7 @@ conditional_inner_join(table_view const& left, table_view const& right, ast::expression const& binary_predicate, std::optional output_size = {}, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -612,6 +626,7 @@ conditional_inner_join(table_view const& left, * @param right The right table * @param binary_predicate The condition on which to join * @param output_size Optional value which allows users to specify the exact output size + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -623,6 +638,7 @@ conditional_left_join(table_view const& left, table_view const& right, ast::expression const& binary_predicate, std::optional output_size = {}, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -655,6 +671,7 @@ conditional_left_join(table_view const& left, * @param left The left table * @param right The right table * @param binary_predicate The condition on which to join + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -665,6 +682,7 @@ std::pair>, conditional_full_join(table_view const& left, table_view const& right, ast::expression const& binary_predicate, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -693,6 +711,7 @@ conditional_full_join(table_view const& left, * @param right The right table * @param binary_predicate The condition on which to join * @param output_size Optional value which allows users to specify the exact output size + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A vector `left_indices` that can be used to construct the result of @@ -704,6 +723,7 @@ std::unique_ptr> conditional_left_semi_join( table_view const& right, ast::expression const& binary_predicate, std::optional output_size = {}, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -732,6 +752,7 @@ std::unique_ptr> conditional_left_semi_join( * @param right The right table * @param binary_predicate The condition on which to join * @param output_size Optional value which allows users to specify the exact output size + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A vector `left_indices` that can be used to construct the result of @@ -743,6 +764,7 @@ std::unique_ptr> conditional_left_anti_join( table_view const& right, ast::expression const& binary_predicate, std::optional output_size = {}, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -786,6 +808,7 @@ std::unique_ptr> conditional_left_anti_join( * @param output_size_data An optional pair of values indicating the exact output size and the * number of matches for each row in the larger of the two input tables, left or right (may be * precomputed using the corresponding mixed_inner_join_size API). + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -801,6 +824,7 @@ mixed_inner_join( ast::expression const& binary_predicate, null_equality compare_nulls = null_equality::EQUAL, std::optional>> output_size_data = {}, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -846,6 +870,7 @@ mixed_inner_join( * @param output_size_data An optional pair of values indicating the exact output size and the * number of matches for each row in the larger of the two input tables, left or right (may be * precomputed using the corresponding mixed_left_join_size API). + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -861,6 +886,7 @@ mixed_left_join( ast::expression const& binary_predicate, null_equality compare_nulls = null_equality::EQUAL, std::optional>> output_size_data = {}, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -906,6 +932,7 @@ mixed_left_join( * @param output_size_data An optional pair of values indicating the exact output size and the * number of matches for each row in the larger of the two input tables, left or right (may be * precomputed using the corresponding mixed_full_join_size API). + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -921,6 +948,7 @@ mixed_full_join( ast::expression const& binary_predicate, null_equality compare_nulls = null_equality::EQUAL, std::optional>> output_size_data = {}, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -956,6 +984,7 @@ mixed_full_join( * @param right_conditional The right table used for the conditional join * @param binary_predicate The condition on which to join * @param compare_nulls Whether or not null values join to each other or not + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -968,6 +997,7 @@ std::unique_ptr> mixed_left_semi_join( table_view const& right_conditional, ast::expression const& binary_predicate, null_equality compare_nulls = null_equality::EQUAL, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -1004,6 +1034,7 @@ std::unique_ptr> mixed_left_semi_join( * @param right_conditional The right table used for the conditional join * @param binary_predicate The condition on which to join * @param compare_nulls Whether or not null values join to each other or not + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair of vectors [`left_indices`, `right_indices`] that can be used to construct @@ -1016,6 +1047,7 @@ std::unique_ptr> mixed_left_anti_join( table_view const& right_conditional, ast::expression const& binary_predicate, null_equality compare_nulls = null_equality::EQUAL, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -1041,6 +1073,7 @@ std::unique_ptr> mixed_left_anti_join( * @param right_conditional The right table used for the conditional join * @param binary_predicate The condition on which to join * @param compare_nulls Whether or not null values join to each other or not + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair containing the size that would result from performing the @@ -1056,6 +1089,7 @@ std::pair>> mixed_in table_view const& right_conditional, ast::expression const& binary_predicate, null_equality compare_nulls = null_equality::EQUAL, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -1081,6 +1115,7 @@ std::pair>> mixed_in * @param right_conditional The right table used for the conditional join * @param binary_predicate The condition on which to join * @param compare_nulls Whether or not null values join to each other or not + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return A pair containing the size that would result from performing the @@ -1096,6 +1131,7 @@ std::pair>> mixed_le table_view const& right_conditional, ast::expression const& binary_predicate, null_equality compare_nulls = null_equality::EQUAL, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -1111,6 +1147,7 @@ std::pair>> mixed_le * @param left The left table * @param right The right table * @param binary_predicate The condition on which to join + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return The size that would result from performing the requested join @@ -1119,6 +1156,7 @@ std::size_t conditional_inner_join_size( table_view const& left, table_view const& right, ast::expression const& binary_predicate, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -1134,6 +1172,7 @@ std::size_t conditional_inner_join_size( * @param left The left table * @param right The right table * @param binary_predicate The condition on which to join + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return The size that would result from performing the requested join @@ -1142,6 +1181,7 @@ std::size_t conditional_left_join_size( table_view const& left, table_view const& right, ast::expression const& binary_predicate, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -1157,6 +1197,7 @@ std::size_t conditional_left_join_size( * @param left The left table * @param right The right table * @param binary_predicate The condition on which to join + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return The size that would result from performing the requested join @@ -1165,6 +1206,7 @@ std::size_t conditional_left_semi_join_size( table_view const& left, table_view const& right, ast::expression const& binary_predicate, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -1180,6 +1222,7 @@ std::size_t conditional_left_semi_join_size( * @param left The left table * @param right The right table * @param binary_predicate The condition on which to join + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table and columns' device memory * * @return The size that would result from performing the requested join @@ -1188,6 +1231,7 @@ std::size_t conditional_left_anti_join_size( table_view const& left, table_view const& right, ast::expression const& binary_predicate, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @} */ // end of group } // namespace CUDF_EXPORT cudf diff --git a/cpp/src/join/conditional_join.cu b/cpp/src/join/conditional_join.cu index 748691fb7d1..2ec23e0dc6d 100644 --- a/cpp/src/join/conditional_join.cu +++ b/cpp/src/join/conditional_join.cu @@ -27,7 +27,6 @@ #include #include #include -#include #include #include @@ -377,16 +376,12 @@ conditional_inner_join(table_view const& left, table_view const& right, ast::expression const& binary_predicate, std::optional output_size, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::conditional_join(left, - right, - binary_predicate, - detail::join_kind::INNER_JOIN, - output_size, - cudf::get_default_stream(), - mr); + return detail::conditional_join( + left, right, binary_predicate, detail::join_kind::INNER_JOIN, output_size, stream, mr); } std::pair>, @@ -395,16 +390,12 @@ conditional_left_join(table_view const& left, table_view const& right, ast::expression const& binary_predicate, std::optional output_size, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::conditional_join(left, - right, - binary_predicate, - detail::join_kind::LEFT_JOIN, - output_size, - cudf::get_default_stream(), - mr); + return detail::conditional_join( + left, right, binary_predicate, detail::join_kind::LEFT_JOIN, output_size, stream, mr); } std::pair>, @@ -412,16 +403,12 @@ std::pair>, conditional_full_join(table_view const& left, table_view const& right, ast::expression const& binary_predicate, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::conditional_join(left, - right, - binary_predicate, - detail::join_kind::FULL_JOIN, - {}, - cudf::get_default_stream(), - mr); + return detail::conditional_join( + left, right, binary_predicate, detail::join_kind::FULL_JOIN, {}, stream, mr); } std::unique_ptr> conditional_left_semi_join( @@ -429,16 +416,12 @@ std::unique_ptr> conditional_left_semi_join( table_view const& right, ast::expression const& binary_predicate, std::optional output_size, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::conditional_join_anti_semi(left, - right, - binary_predicate, - detail::join_kind::LEFT_SEMI_JOIN, - output_size, - cudf::get_default_stream(), - mr); + return detail::conditional_join_anti_semi( + left, right, binary_predicate, detail::join_kind::LEFT_SEMI_JOIN, output_size, stream, mr); } std::unique_ptr> conditional_left_anti_join( @@ -446,64 +429,56 @@ std::unique_ptr> conditional_left_anti_join( table_view const& right, ast::expression const& binary_predicate, std::optional output_size, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::conditional_join_anti_semi(left, - right, - binary_predicate, - detail::join_kind::LEFT_ANTI_JOIN, - output_size, - cudf::get_default_stream(), - mr); + return detail::conditional_join_anti_semi( + left, right, binary_predicate, detail::join_kind::LEFT_ANTI_JOIN, output_size, stream, mr); } std::size_t conditional_inner_join_size(table_view const& left, table_view const& right, ast::expression const& binary_predicate, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); return detail::compute_conditional_join_output_size( - left, right, binary_predicate, detail::join_kind::INNER_JOIN, cudf::get_default_stream(), mr); + left, right, binary_predicate, detail::join_kind::INNER_JOIN, stream, mr); } std::size_t conditional_left_join_size(table_view const& left, table_view const& right, ast::expression const& binary_predicate, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); return detail::compute_conditional_join_output_size( - left, right, binary_predicate, detail::join_kind::LEFT_JOIN, cudf::get_default_stream(), mr); + left, right, binary_predicate, detail::join_kind::LEFT_JOIN, stream, mr); } std::size_t conditional_left_semi_join_size(table_view const& left, table_view const& right, ast::expression const& binary_predicate, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::compute_conditional_join_output_size(left, - right, - binary_predicate, - detail::join_kind::LEFT_SEMI_JOIN, - cudf::get_default_stream(), - mr); + return detail::compute_conditional_join_output_size( + left, right, binary_predicate, detail::join_kind::LEFT_SEMI_JOIN, stream, mr); } std::size_t conditional_left_anti_join_size(table_view const& left, table_view const& right, ast::expression const& binary_predicate, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::compute_conditional_join_output_size(left, - right, - binary_predicate, - detail::join_kind::LEFT_ANTI_JOIN, - cudf::get_default_stream(), - mr); + return detail::compute_conditional_join_output_size( + left, right, binary_predicate, detail::join_kind::LEFT_ANTI_JOIN, stream, mr); } } // namespace cudf diff --git a/cpp/src/join/conditional_join.hpp b/cpp/src/join/conditional_join.hpp index 4f6a9484e8c..303442e79ef 100644 --- a/cpp/src/join/conditional_join.hpp +++ b/cpp/src/join/conditional_join.hpp @@ -19,7 +19,6 @@ #include #include -#include #include #include diff --git a/cpp/src/join/cross_join.cu b/cpp/src/join/cross_join.cu index eeb49736bac..15594fb60e3 100644 --- a/cpp/src/join/cross_join.cu +++ b/cpp/src/join/cross_join.cu @@ -25,7 +25,6 @@ #include #include #include -#include #include #include @@ -75,10 +74,11 @@ std::unique_ptr cross_join(cudf::table_view const& left, std::unique_ptr cross_join(cudf::table_view const& left, cudf::table_view const& right, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::cross_join(left, right, cudf::get_default_stream(), mr); + return detail::cross_join(left, right, stream, mr); } } // namespace cudf diff --git a/cpp/src/join/join.cu b/cpp/src/join/join.cu index 0abff27667b..7b13c260364 100644 --- a/cpp/src/join/join.cu +++ b/cpp/src/join/join.cu @@ -20,7 +20,6 @@ #include #include #include -#include #include #include @@ -120,10 +119,11 @@ std::pair>, inner_join(table_view const& left, table_view const& right, null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::inner_join(left, right, compare_nulls, cudf::get_default_stream(), mr); + return detail::inner_join(left, right, compare_nulls, stream, mr); } std::pair>, @@ -131,10 +131,11 @@ std::pair>, left_join(table_view const& left, table_view const& right, null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::left_join(left, right, compare_nulls, cudf::get_default_stream(), mr); + return detail::left_join(left, right, compare_nulls, stream, mr); } std::pair>, @@ -142,10 +143,11 @@ std::pair>, full_join(table_view const& left, table_view const& right, null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::full_join(left, right, compare_nulls, cudf::get_default_stream(), mr); + return detail::full_join(left, right, compare_nulls, stream, mr); } } // namespace cudf diff --git a/cpp/src/join/mixed_join.cu b/cpp/src/join/mixed_join.cu index 8ff78dd47f4..820b81ee309 100644 --- a/cpp/src/join/mixed_join.cu +++ b/cpp/src/join/mixed_join.cu @@ -28,7 +28,6 @@ #include #include #include -#include #include #include @@ -484,6 +483,7 @@ mixed_inner_join( ast::expression const& binary_predicate, null_equality compare_nulls, std::optional>> const output_size_data, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); @@ -495,7 +495,7 @@ mixed_inner_join( compare_nulls, detail::join_kind::INNER_JOIN, output_size_data, - cudf::get_default_stream(), + stream, mr); } @@ -506,6 +506,7 @@ std::pair>> mixed_in table_view const& right_conditional, ast::expression const& binary_predicate, null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); @@ -516,7 +517,7 @@ std::pair>> mixed_in binary_predicate, compare_nulls, detail::join_kind::INNER_JOIN, - cudf::get_default_stream(), + stream, mr); } @@ -530,6 +531,7 @@ mixed_left_join( ast::expression const& binary_predicate, null_equality compare_nulls, std::optional>> const output_size_data, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); @@ -541,7 +543,7 @@ mixed_left_join( compare_nulls, detail::join_kind::LEFT_JOIN, output_size_data, - cudf::get_default_stream(), + stream, mr); } @@ -552,6 +554,7 @@ std::pair>> mixed_le table_view const& right_conditional, ast::expression const& binary_predicate, null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); @@ -562,7 +565,7 @@ std::pair>> mixed_le binary_predicate, compare_nulls, detail::join_kind::LEFT_JOIN, - cudf::get_default_stream(), + stream, mr); } @@ -576,6 +579,7 @@ mixed_full_join( ast::expression const& binary_predicate, null_equality compare_nulls, std::optional>> const output_size_data, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); @@ -587,7 +591,7 @@ mixed_full_join( compare_nulls, detail::join_kind::FULL_JOIN, output_size_data, - cudf::get_default_stream(), + stream, mr); } diff --git a/cpp/src/join/mixed_join_semi.cu b/cpp/src/join/mixed_join_semi.cu index cfb785e242c..aa4fa281159 100644 --- a/cpp/src/join/mixed_join_semi.cu +++ b/cpp/src/join/mixed_join_semi.cu @@ -29,7 +29,6 @@ #include #include #include -#include #include #include @@ -267,6 +266,7 @@ std::unique_ptr> mixed_left_semi_join( table_view const& right_conditional, ast::expression const& binary_predicate, null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); @@ -277,7 +277,7 @@ std::unique_ptr> mixed_left_semi_join( binary_predicate, compare_nulls, detail::join_kind::LEFT_SEMI_JOIN, - cudf::get_default_stream(), + stream, mr); } @@ -288,6 +288,7 @@ std::unique_ptr> mixed_left_anti_join( table_view const& right_conditional, ast::expression const& binary_predicate, null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); @@ -298,7 +299,7 @@ std::unique_ptr> mixed_left_anti_join( binary_predicate, compare_nulls, detail::join_kind::LEFT_ANTI_JOIN, - cudf::get_default_stream(), + stream, mr); } diff --git a/cpp/src/join/semi_join.cu b/cpp/src/join/semi_join.cu index f69ded73e8d..d2ab2122c75 100644 --- a/cpp/src/join/semi_join.cu +++ b/cpp/src/join/semi_join.cu @@ -23,7 +23,6 @@ #include #include #include -#include #include #include @@ -98,22 +97,24 @@ std::unique_ptr> left_semi_join( cudf::table_view const& left, cudf::table_view const& right, null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); return detail::left_semi_anti_join( - detail::join_kind::LEFT_SEMI_JOIN, left, right, compare_nulls, cudf::get_default_stream(), mr); + detail::join_kind::LEFT_SEMI_JOIN, left, right, compare_nulls, stream, mr); } std::unique_ptr> left_anti_join( cudf::table_view const& left, cudf::table_view const& right, null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); return detail::left_semi_anti_join( - detail::join_kind::LEFT_ANTI_JOIN, left, right, compare_nulls, cudf::get_default_stream(), mr); + detail::join_kind::LEFT_ANTI_JOIN, left, right, compare_nulls, stream, mr); } } // namespace cudf diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 1bedb344a01..586bac97570 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -691,6 +691,7 @@ ConfigureTest(STREAM_DICTIONARY_TEST streams/dictionary_test.cpp STREAM_MODE tes ConfigureTest(STREAM_FILLING_TEST streams/filling_test.cpp STREAM_MODE testing) ConfigureTest(STREAM_GROUPBY_TEST streams/groupby_test.cpp STREAM_MODE testing) ConfigureTest(STREAM_HASHING_TEST streams/hash_test.cpp STREAM_MODE testing) +ConfigureTest(STREAM_JOIN_TEST streams/join_test.cpp STREAM_MODE testing) ConfigureTest(STREAM_JSONIO_TEST streams/io/json_test.cpp STREAM_MODE testing) ConfigureTest(STREAM_LABELING_BINS_TEST streams/labeling_bins_test.cpp STREAM_MODE testing) ConfigureTest(STREAM_LISTS_TEST streams/lists_test.cpp STREAM_MODE testing) diff --git a/cpp/tests/join/join_tests.cpp b/cpp/tests/join/join_tests.cpp index ab387a5c7f5..3431e941359 100644 --- a/cpp/tests/join/join_tests.cpp +++ b/cpp/tests/join/join_tests.cpp @@ -39,6 +39,8 @@ #include #include +#include + #include template @@ -60,6 +62,7 @@ template >, cudf::table_view const& left_keys, cudf::table_view const& right_keys, cudf::null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr), cudf::out_of_bounds_policy oob_policy = cudf::out_of_bounds_policy::DONT_CHECK> std::unique_ptr join_and_gather( @@ -68,12 +71,13 @@ std::unique_ptr join_and_gather( std::vector const& left_on, std::vector const& right_on, cudf::null_equality compare_nulls, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()) { auto left_selected = left_input.select(left_on); auto right_selected = right_input.select(right_on); auto const [left_join_indices, right_join_indices] = - join_impl(left_selected, right_selected, compare_nulls, mr); + join_impl(left_selected, right_selected, compare_nulls, stream, mr); auto left_indices_span = cudf::device_span{*left_join_indices}; auto right_indices_span = cudf::device_span{*right_join_indices}; @@ -2027,7 +2031,11 @@ struct JoinTestLists : public cudf::test::BaseFixture { auto const probe_tv = cudf::table_view{{probe}}; auto const [left_result_map, right_result_map] = - join_func(build_tv, probe_tv, nulls_equal, cudf::get_current_device_resource_ref()); + join_func(build_tv, + probe_tv, + nulls_equal, + cudf::get_default_stream(), + cudf::get_current_device_resource_ref()); auto const left_result_table = sort_and_gather(build_tv, column_view_from_device_uvector(*left_result_map), oob_policy); diff --git a/cpp/tests/join/semi_anti_join_tests.cpp b/cpp/tests/join/semi_anti_join_tests.cpp index 3e279260b99..554d5754e39 100644 --- a/cpp/tests/join/semi_anti_join_tests.cpp +++ b/cpp/tests/join/semi_anti_join_tests.cpp @@ -28,8 +28,11 @@ #include #include #include +#include #include +#include + #include template @@ -51,6 +54,7 @@ template > (*join_impl)( cudf::table_view const& left_keys, cudf::table_view const& right_keys, cudf::null_equality compare_nulls, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr)> std::unique_ptr join_and_gather( cudf::table_view const& left_input, @@ -58,11 +62,12 @@ std::unique_ptr join_and_gather( std::vector const& left_on, std::vector const& right_on, cudf::null_equality compare_nulls, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()) { auto left_selected = left_input.select(left_on); auto right_selected = right_input.select(right_on); - auto const join_indices = join_impl(left_selected, right_selected, compare_nulls, mr); + auto const join_indices = join_impl(left_selected, right_selected, compare_nulls, stream, mr); auto left_indices_span = cudf::device_span{*join_indices}; auto left_indices_col = cudf::column_view{left_indices_span}; diff --git a/cpp/tests/streams/join_test.cpp b/cpp/tests/streams/join_test.cpp new file mode 100644 index 00000000000..2811bb676fa --- /dev/null +++ b/cpp/tests/streams/join_test.cpp @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2024, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +class JoinTest : public cudf::test::BaseFixture { + static inline cudf::table make_table() + { + cudf::test::fixed_width_column_wrapper col0{{3, 1, 2, 0, 3}}; + cudf::test::strings_column_wrapper col1{{"s0", "s1", "s2", "s4", "s1"}}; + cudf::test::fixed_width_column_wrapper col2{{0, 1, 2, 4, 1}}; + + std::vector> columns; + columns.push_back(col0.release()); + columns.push_back(col1.release()); + columns.push_back(col2.release()); + + return cudf::table{std::move(columns)}; + } + + public: + cudf::table table0{make_table()}; + cudf::table table1{make_table()}; + cudf::table conditional0{make_table()}; + cudf::table conditional1{make_table()}; + cudf::ast::column_reference col_ref_left_0{0}; + cudf::ast::column_reference col_ref_right_0{0, cudf::ast::table_reference::RIGHT}; + cudf::ast::operation left_zero_eq_right_zero{ + cudf::ast::ast_operator::EQUAL, col_ref_left_0, col_ref_right_0}; +}; + +TEST_F(JoinTest, InnerJoin) +{ + cudf::inner_join(table0, table1, cudf::null_equality::EQUAL, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, LeftJoin) +{ + cudf::left_join(table0, table1, cudf::null_equality::EQUAL, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, FullJoin) +{ + cudf::full_join(table0, table1, cudf::null_equality::EQUAL, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, LeftSemiJoin) +{ + cudf::left_semi_join( + table0, table1, cudf::null_equality::EQUAL, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, LeftAntiJoin) +{ + cudf::left_anti_join( + table0, table1, cudf::null_equality::EQUAL, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, CrossJoin) { cudf::cross_join(table0, table1, cudf::test::get_default_stream()); } + +TEST_F(JoinTest, ConditionalInnerJoin) +{ + cudf::conditional_inner_join( + table0, table1, left_zero_eq_right_zero, std::nullopt, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, ConditionalLeftJoin) +{ + cudf::conditional_left_join( + table0, table1, left_zero_eq_right_zero, std::nullopt, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, ConditionalFullJoin) +{ + cudf::conditional_full_join( + table0, table1, left_zero_eq_right_zero, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, ConditionalLeftSemiJoin) +{ + cudf::conditional_left_semi_join( + table0, table1, left_zero_eq_right_zero, std::nullopt, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, ConditionalLeftAntiJoin) +{ + cudf::conditional_left_anti_join( + table0, table1, left_zero_eq_right_zero, std::nullopt, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, MixedInnerJoin) +{ + cudf::mixed_inner_join(table0, + table1, + conditional0, + conditional1, + left_zero_eq_right_zero, + cudf::null_equality::EQUAL, + std::nullopt, + cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, MixedLeftJoin) +{ + cudf::mixed_left_join(table0, + table1, + conditional0, + conditional1, + left_zero_eq_right_zero, + cudf::null_equality::EQUAL, + std::nullopt, + cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, MixedFullJoin) +{ + cudf::mixed_full_join(table0, + table1, + conditional0, + conditional1, + left_zero_eq_right_zero, + cudf::null_equality::EQUAL, + std::nullopt, + cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, MixedLeftSemiJoin) +{ + cudf::mixed_left_semi_join(table0, + table1, + conditional0, + conditional1, + left_zero_eq_right_zero, + cudf::null_equality::EQUAL, + cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, MixedLeftAntiJoin) +{ + cudf::mixed_left_anti_join(table0, + table1, + conditional0, + conditional1, + left_zero_eq_right_zero, + cudf::null_equality::EQUAL, + cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, MixedInnerJoinSize) +{ + cudf::mixed_inner_join_size(table0, + table1, + conditional0, + conditional1, + left_zero_eq_right_zero, + cudf::null_equality::EQUAL, + cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, MixedLeftJoinSize) +{ + cudf::mixed_left_join_size(table0, + table1, + conditional0, + conditional1, + left_zero_eq_right_zero, + cudf::null_equality::EQUAL, + cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, ConditionalInnerJoinSize) +{ + cudf::conditional_inner_join_size( + table0, table1, left_zero_eq_right_zero, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, ConditionalLeftJoinSize) +{ + cudf::conditional_left_join_size( + table0, table1, left_zero_eq_right_zero, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, ConditionalLeftSemiJoinSize) +{ + cudf::conditional_left_semi_join_size( + table0, table1, left_zero_eq_right_zero, cudf::test::get_default_stream()); +} + +TEST_F(JoinTest, ConditionalLeftAntiJoinSize) +{ + cudf::conditional_left_anti_join_size( + table0, table1, left_zero_eq_right_zero, cudf::test::get_default_stream()); +} From b165210c706337fbda3284d115983b86a86b445f Mon Sep 17 00:00:00 2001 From: "Richard (Rick) Zamora" Date: Fri, 20 Sep 2024 17:11:40 -0500 Subject: [PATCH 12/15] Add best practices page to Dask cuDF docs (#16821) Adds a much-needed "best practices" page to the Dask cuDF documentation. Authors: - Richard (Rick) Zamora (https://github.com/rjzamora) Approvers: - Peter Andreas Entschev (https://github.com/pentschev) - Lawrence Mitchell (https://github.com/wence-) URL: https://github.com/rapidsai/cudf/pull/16821 --- docs/dask_cudf/source/best_practices.rst | 320 +++++++++++++++++++++++ docs/dask_cudf/source/index.rst | 26 +- python/dask_cudf/README.md | 1 + 3 files changed, 336 insertions(+), 11 deletions(-) create mode 100644 docs/dask_cudf/source/best_practices.rst diff --git a/docs/dask_cudf/source/best_practices.rst b/docs/dask_cudf/source/best_practices.rst new file mode 100644 index 00000000000..142124163af --- /dev/null +++ b/docs/dask_cudf/source/best_practices.rst @@ -0,0 +1,320 @@ +.. _best-practices: + +Dask cuDF Best Practices +======================== + +This page outlines several important guidelines for using `Dask cuDF +`__ effectively. + +.. note:: + Since Dask cuDF is a backend extension for + `Dask DataFrame `__, + the guidelines discussed in the `Dask DataFrames Best Practices + `__ + documentation also apply to Dask cuDF (excluding any pandas-specific + details). + + +Deployment and Configuration +---------------------------- + +Use Dask-CUDA +~~~~~~~~~~~~~ + +To execute a Dask workflow on multiple GPUs, a Dask cluster must +be deployed with `Dask-CUDA `__ +and `Dask.distributed `__. + +When running on a single machine, the `LocalCUDACluster `__ +convenience function is strongly recommended. No matter how many GPUs are +available on the machine (even one!), using `Dask-CUDA has many advantages +`__ +over default (threaded) execution. Just to list a few: + +* Dask-CUDA makes it easy to pin workers to specific devices. +* Dask-CUDA makes it easy to configure memory-spilling options. +* The distributed scheduler collects useful diagnostic information that can be viewed on a dashboard in real time. + +Please see `Dask-CUDA's API `__ +and `Best Practices `__ +documentation for detailed information. Typical ``LocalCUDACluster`` usage +is also illustrated within the multi-GPU section of `Dask cuDF's +`__ documentation. + +.. note:: + When running on cloud infrastructure or HPC systems, it is usually best to + leverage system-specific deployment libraries like `Dask Operator + `__ and `Dask-Jobqueue + `__. + + Please see `the RAPIDS deployment documentation `__ + for further details and examples. + + +Use diagnostic tools +~~~~~~~~~~~~~~~~~~~~ + +The Dask ecosystem includes several diagnostic tools that you should absolutely use. +These tools include an intuitive `browser dashboard +`__ as well as a dedicated +`API for collecting performance profiles +`__. + +No matter the workflow, using the dashboard is strongly recommended. +It provides a visual representation of the worker resources and compute +progress. It also shows basic GPU memory and utilization metrics (under +the ``GPU`` tab). To visualize more detailed GPU metrics in JupyterLab, +use `NVDashboard `__. + + +Enable cuDF spilling +~~~~~~~~~~~~~~~~~~~~ + +When using Dask cuDF for classic ETL workloads, it is usually best +to enable `native spilling support in cuDF +`__. +When using :func:`LocalCUDACluster`, this is easily accomplished by +setting ``enable_cudf_spill=True``. + +When a Dask cuDF workflow includes conversion between DataFrame and Array +representations, native cuDF spilling may be insufficient. For these cases, +`JIT-unspill `__ +is likely to produce better protection from out-of-memory (OOM) errors. +Please see `Dask-CUDA's spilling documentation +`__ for further details +and guidance. + +Use RMM +~~~~~~~ + +Memory allocations in cuDF are significantly faster and more efficient when +the `RAPIDS Memory Manager (RMM) `__ +library is configured appropriately on worker processes. In most cases, the best way to manage +memory is by initializing an RMM pool on each worker before executing a +workflow. When using :func:`LocalCUDACluster`, this is easily accomplished +by setting ``rmm_pool_size`` to a large fraction (e.g. ``0.9``). + +See the `Dask-CUDA memory-management documentation +`__ +for more details. + +Use the Dask DataFrame API +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Although Dask cuDF provides a public ``dask_cudf`` Python module, we +strongly recommended that you use the CPU/GPU portable ``dask.dataframe`` +API instead. Simply `use the Dask configuration system +`__ +to set the ``"dataframe.backend"`` option to ``"cudf"``, and the +``dask_cudf`` module will be imported and used implicitly. + +Be sure to use the :func:`to_backend` method if you need to convert +between the different DataFrame backends. For example:: + + df = df.to_backend("pandas") # This gives us a pandas-backed collection + +.. note:: + Although :func:`to_backend` makes it easy to move data between pandas + and cuDF, repetitive CPU-GPU data movement can degrade performance + significantly. For optimal results, keep your data on the GPU as much + as possible. + +Avoid eager execution +~~~~~~~~~~~~~~~~~~~~~ + +Although Dask DataFrame collections are lazy by default, there are several +notable methods that will result in the immediate execution of the +underlying task graph: + +:func:`compute`: Calling ``ddf.compute()`` will materialize the result of +``ddf`` and return a single cuDF object. This is done by executing the entire +task graph associated with ``ddf`` and concatenating its partitions in +local memory on the client process. + +.. note:: + Never call :func:`compute` on a large collection that cannot fit comfortably + in the memory of a single GPU! + +:func:`persist`: Like :func:`compute`, calling ``ddf.persist()`` will +execute the entire task graph associated with ``ddf``. The most important +difference is that the computed partitions will remain in distributed +worker memory instead of being concatenated together on the client process. +Another difference is that :func:`persist` will return immediately when +executing on a distributed cluster. If you need a blocking synchronization +point in your workflow, simply use the :func:`wait` function:: + + ddf = ddf.persist() + wait(ddf) + +.. note:: + Avoid calling :func:`persist` on a large collection that cannot fit comfortably + in global worker memory. If the total sum of the partition sizes is larger + than the sum of all GPU memory, calling persist will result in significant + spilling from device memory. If the individual partition sizes are large, this + is likely to produce an OOM error. + +:func:`len` / :func:`head` / :func:`tail`: Although these operations are used +often within pandas/cuDF code to quickly inspect data, it is best to avoid +them in Dask DataFrame. In most cases, these operations will execute some or all +of the underlying task graph to materialize the collection. + +:func:`sort_values` / :func:`set_index` : These operations both require Dask to +eagerly collect quantile information about the column(s) being targeted by the +global sort operation. See `Avoid Sorting`__ for notes on sorting considerations. + +.. note:: + When using :func:`set_index`, be sure to pass in ``sort=False`` whenever the + global collection does not **need** to be sorted by the new index. + +Avoid Sorting +~~~~~~~~~~~~~ + +`The design of Dask DataFrame `__ +makes it advantageous to work with data that is already sorted along its index at +creation time. For most other cases, it is best to avoid sorting unless the logic +of the workflow makes global ordering absolutely necessary. + +If the purpose of a :func:`sort_values` operation is to ensure that all unique +values in ``by`` will be moved to the same output partition, then `shuffle +`__ +is often the better option. + + +Reading Data +------------ + +Tune the partition size +~~~~~~~~~~~~~~~~~~~~~~~ + +The ideal partition size is usually between 1/32 and 1/8 the memory +capacity of a single GPU. Increasing the partition size will typically +reduce the number of tasks in your workflow and improve the GPU utilization +for each task. However, if the partitions are too large, the risk of OOM +errors can become significant. + +.. note:: + As a general rule of thumb, start with 1/32-1/16 for shuffle-intensive workflows + (e.g. large-scale sorting and joining), and 1/16-1/8 otherwise. For pathologically + skewed data distributions, it may be necessary to target 1/64 or smaller. + This rule of thumb comes from anecdotal optimization and OOM-debugging + experience. Since every workflow is different, choosing the best partition + size is both an art and a science. + +The easiest way to tune the partition size is when the DataFrame collection +is first created by a function like :func:`read_parquet`, :func:`read_csv`, +or :func:`from_map`. For example, both :func:`read_parquet` and :func:`read_csv` +expose a ``blocksize`` argument for adjusting the maximum partition size. + +If the partition size cannot be tuned effectively at creation time, the +`repartition `__ +method can be used as a last resort. + + +Use Parquet +~~~~~~~~~~~ + +`Parquet `__ is the recommended +file format for Dask cuDF. It provides efficient columnar storage and enables +Dask to perform valuable query optimizations like column projection and +predicate pushdown. + +The most important arguments to :func:`read_parquet` are ``blocksize`` and +``aggregate_files``: + +``blocksize``: Use this argument to specify the maximum partition size. +The default is `"256 MiB"`, but larger values are usually more performant +on GPUs with more than 8 GiB of memory. Dask will use the ``blocksize`` +value to map a discrete number of Parquet row-groups (or files) to each +output partition. This mapping will only account for the uncompressed +storage size of each row group, which is usually smaller than the +correspondng ``cudf.DataFrame``. + +``aggregate_files``: Use this argument to specify whether Dask should +map multiple files to the same DataFrame partition. The default is +``False``, but ``aggregate_files=True`` is usually more performant when +the dataset contains many files that are smaller than half of ``blocksize``. + +If you know that your files correspond to a reasonable partition size +before splitting or aggregation, set ``blocksize=None`` to disallow +file splitting. In the absence of column-projection pushdown, this will +result in a simple 1-to-1 mapping between files and output partitions. + +.. note:: + If your workflow requires a strict 1-to-1 mapping between files and + partitions, use :func:`from_map` to manually construct your partitions + with ``cudf.read_parquet``. When :func:`dd.read_parquet` is used, + query-planning optimizations may automatically aggregate distinct files + into the same partition (even when ``aggregate_files=False``). + +.. note:: + Metadata collection can be extremely slow when reading from remote + storage (e.g. S3 and GCS). When reading many remote files that all + correspond to a reasonable partition size, use ``blocksize=None`` + to avoid unnecessary metadata collection. + + +Use :func:`from_map` +~~~~~~~~~~~~~~~~~~~~ + +To implement custom DataFrame-creation logic that is not covered by +existing APIs (like :func:`read_parquet`), use :func:`dask.dataframe.from_map` +whenever possible. The :func:`from_map` API has several advantages +over :func:`from_delayed`: + +* It allows proper lazy execution of your custom logic +* It enables column projection (as long as the mapped function supports a ``columns`` key-word argument) + +See the `from_map API documentation `__ +for more details. + +.. note:: + Whenever possible, be sure to specify the ``meta`` argument to + :func:`from_map`. If this argument is excluded, Dask will need to + materialize the first partition eagerly. If a large RMM pool is in + use on the first visible device, this eager execution on the client + may lead to an OOM error. + + +Sorting, Joining, and Grouping +------------------------------ + +Sorting, joining, and grouping operations all have the potential to +require the global shuffling of data between distinct partitions. +When the initial data fits comfortably in global GPU memory, these +"all-to-all" operations are typically bound by worker-to-worker +communication. When the data is larger than global GPU memory, the +bottleneck is typically device-to-host memory spilling. + +Although every workflow is different, the following guidelines +are often recommended: + +* `Use a distributed cluster with Dask-CUDA workers `_ +* `Use native cuDF spilling whenever possible `_ +* Avoid shuffling whenever possible + * Use ``split_out=1`` for low-cardinality groupby aggregations + * Use ``broadcast=True`` for joins when at least one collection comprises a small number of partitions (e.g. ``<=5``) +* `Use UCX `__ if communication is a bottleneck. + +.. note:: + UCX enables Dask-CUDA workers to communicate using high-performance + tansport technologies like `NVLink `__ + and Infiniband. Without UCX, inter-process communication will rely + on TCP sockets. + + +User-defined functions +---------------------- + +Most real-world Dask DataFrame workflows use `map_partitions +`__ +to map user-defined functions across every partition of the underlying data. +This API is a fantastic way to apply custom operations in an intuitive and +scalable way. With that said, the :func:`map_partitions` method will produce +an opaque DataFrame expression that blocks the query-planning `optimizer +`__ from performing +useful optimizations (like projection and filter pushdown). + +Since column-projection pushdown is often the most effective optimization, +it is important to select the necessary columns both before and after calling +:func:`map_partitions`. You can also add explicit filter operations to further +mitigate the loss of filter pushdown. diff --git a/docs/dask_cudf/source/index.rst b/docs/dask_cudf/source/index.rst index 7fe6cbd45fa..23ca7e49753 100644 --- a/docs/dask_cudf/source/index.rst +++ b/docs/dask_cudf/source/index.rst @@ -15,7 +15,7 @@ as the ``"cudf"`` dataframe backend for .. note:: Neither Dask cuDF nor Dask DataFrame provide support for multi-GPU or multi-node execution on their own. You must also deploy a - `dask.distributed ` cluster + `dask.distributed `__ cluster to leverage multiple GPUs. We strongly recommend using `Dask-CUDA `__ to simplify the setup of the cluster, taking advantage of all features of the GPU @@ -29,6 +29,10 @@ minutes to Dask by `10 minutes to cuDF and Dask cuDF `__. +After reviewing the sections below, please see the +:ref:`Best Practices ` page for further guidance on +using Dask cuDF effectively. + Using Dask cuDF --------------- @@ -36,7 +40,7 @@ Using Dask cuDF The Dask DataFrame API (Recommended) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Simply use the `Dask configuration ` system to +Simply use the `Dask configuration `__ system to set the ``"dataframe.backend"`` option to ``"cudf"``. From Python, this can be achieved like so:: @@ -50,14 +54,14 @@ environment before running your code. Once this is done, the public Dask DataFrame API will leverage ``cudf`` automatically when a new DataFrame collection is created from an on-disk format using any of the following ``dask.dataframe`` -functions:: +functions: -* :func:`dask.dataframe.read_parquet` -* :func:`dask.dataframe.read_json` -* :func:`dask.dataframe.read_csv` -* :func:`dask.dataframe.read_orc` -* :func:`dask.dataframe.read_hdf` -* :func:`dask.dataframe.from_dict` +* :func:`read_parquet` +* :func:`read_json` +* :func:`read_csv` +* :func:`read_orc` +* :func:`read_hdf` +* :func:`from_dict` For example:: @@ -112,8 +116,8 @@ performance benefit over the CPU/GPU-portable ``dask.dataframe`` API. Also, using some parts of the explicit API are incompatible with automatic query planning (see the next section). -The explicit Dask cuDF API -~~~~~~~~~~~~~~~~~~~~~~~~~~ +Query Planning +~~~~~~~~~~~~~~ Dask cuDF now provides automatic query planning by default (RAPIDS 24.06+). As long as the ``"dataframe.query-planning"`` configuration is set to diff --git a/python/dask_cudf/README.md b/python/dask_cudf/README.md index 4655d2165f0..69e1524be39 100644 --- a/python/dask_cudf/README.md +++ b/python/dask_cudf/README.md @@ -16,6 +16,7 @@ See the [RAPIDS install page](https://docs.rapids.ai/install) for the most up-to ## Resources - [Dask cuDF documentation](https://docs.rapids.ai/api/dask-cudf/stable/) +- [Best practices](https://docs.rapids.ai/api/dask-cudf/stable/best_practices/) - [cuDF documentation](https://docs.rapids.ai/api/cudf/stable/) - [10 Minutes to cuDF and Dask cuDF](https://docs.rapids.ai/api/cudf/stable/user_guide/10min/) - [Dask-CUDA documentation](https://docs.rapids.ai/api/dask-cuda/stable/) From ed2f9f6d000d28e67169c3636423047fed57844c Mon Sep 17 00:00:00 2001 From: Matthew Roeschke <10647082+mroeschke@users.noreply.github.com> Date: Fri, 20 Sep 2024 14:22:26 -1000 Subject: [PATCH 13/15] Add transform APIs to pylibcudf (#16760) Contributes to https://github.com/rapidsai/cudf/issues/15162 One question is that I notice that the libcudf `compute_column` takes an expression computed by a routine in https://github.com/rapidsai/cudf/blob/branch-24.10/python/cudf/cudf/core/_internals/expressions.py. Does this need to be moved to pylibcudf too? Authors: - Matthew Roeschke (https://github.com/mroeschke) - GALI PREM SAGAR (https://github.com/galipremsagar) Approvers: - GALI PREM SAGAR (https://github.com/galipremsagar) URL: https://github.com/rapidsai/cudf/pull/16760 --- python/cudf/cudf/_lib/column.pyx | 11 +- python/cudf/cudf/_lib/transform.pyx | 134 ++++------------ .../pylibcudf/tests/test_transform.py | 51 ++++++ python/pylibcudf/pylibcudf/transform.pxd | 14 ++ python/pylibcudf/pylibcudf/transform.pyx | 146 +++++++++++++++++- 5 files changed, 250 insertions(+), 106 deletions(-) diff --git a/python/cudf/cudf/_lib/column.pyx b/python/cudf/cudf/_lib/column.pyx index e27c595edda..99e4c21df8a 100644 --- a/python/cudf/cudf/_lib/column.pyx +++ b/python/cudf/cudf/_lib/column.pyx @@ -599,7 +599,6 @@ cdef class Column: children=tuple(children) ) - # TODO: Actually support exposed data pointers. @staticmethod def from_pylibcudf( col, bint data_ptr_exposed=False @@ -616,7 +615,7 @@ cdef class Column: col : pylibcudf.Column The object to copy. data_ptr_exposed : bool - This parameter is not yet supported + Whether the data buffer is exposed. Returns ------- @@ -639,16 +638,18 @@ cdef class Column: dtype = dtype_from_pylibcudf_column(col) return cudf.core.column.build_column( - data=as_buffer(col.data().obj) if col.data() is not None else None, + data=as_buffer( + col.data().obj, exposed=data_ptr_exposed + ) if col.data() is not None else None, dtype=dtype, size=col.size(), mask=as_buffer( - col.null_mask().obj + col.null_mask().obj, exposed=data_ptr_exposed ) if col.null_mask() is not None else None, offset=col.offset(), null_count=col.null_count(), children=tuple([ - Column.from_pylibcudf(child) + Column.from_pylibcudf(child, data_ptr_exposed=data_ptr_exposed) for child in col.children() ]) ) diff --git a/python/cudf/cudf/_lib/transform.pyx b/python/cudf/cudf/_lib/transform.pyx index baa08a545ec..40d0c9eac3a 100644 --- a/python/cudf/cudf/_lib/transform.pyx +++ b/python/cudf/cudf/_lib/transform.pyx @@ -3,41 +3,26 @@ from numba.np import numpy_support import cudf -from cudf._lib.types import SUPPORTED_NUMPY_TO_LIBCUDF_TYPES from cudf.core._internals.expressions import parse_expression from cudf.core.buffer import acquire_spill_lock, as_buffer from cudf.utils import cudautils from cython.operator cimport dereference -from libc.stdint cimport uintptr_t from libcpp.memory cimport unique_ptr -from libcpp.pair cimport pair -from libcpp.string cimport string from libcpp.utility cimport move cimport pylibcudf.libcudf.transform as libcudf_transform from pylibcudf cimport transform as plc_transform from pylibcudf.expressions cimport Expression from pylibcudf.libcudf.column.column cimport column -from pylibcudf.libcudf.column.column_view cimport column_view from pylibcudf.libcudf.expressions cimport expression -from pylibcudf.libcudf.table.table cimport table from pylibcudf.libcudf.table.table_view cimport table_view -from pylibcudf.libcudf.types cimport ( - bitmask_type, - data_type, - size_type, - type_id, -) -from rmm._lib.device_buffer cimport DeviceBuffer, device_buffer +from pylibcudf.libcudf.types cimport size_type from cudf._lib.column cimport Column -from cudf._lib.types cimport underlying_type_t_type_id -from cudf._lib.utils cimport ( - columns_from_unique_ptr, - data_from_table_view, - table_view_from_columns, -) +from cudf._lib.utils cimport table_view_from_columns + +import pylibcudf as plc @acquire_spill_lock() @@ -46,17 +31,8 @@ def bools_to_mask(Column col): Given an int8 (boolean) column, compress the data from booleans to bits and return a Buffer """ - cdef column_view col_view = col.view() - cdef pair[unique_ptr[device_buffer], size_type] cpp_out - cdef unique_ptr[device_buffer] up_db - - with nogil: - cpp_out = move(libcudf_transform.bools_to_mask(col_view)) - up_db = move(cpp_out.first) - - rmm_db = DeviceBuffer.c_from_unique_ptr(move(up_db)) - buf = as_buffer(rmm_db) - return buf + mask, _ = plc_transform.bools_to_mask(col.to_pylibcudf(mode="read")) + return as_buffer(mask) @acquire_spill_lock() @@ -68,22 +44,15 @@ def mask_to_bools(object mask_buffer, size_type begin_bit, size_type end_bit): if not isinstance(mask_buffer, cudf.core.buffer.Buffer): raise TypeError("mask_buffer is not an instance of " "cudf.core.buffer.Buffer") - cdef bitmask_type* bit_mask = ( - mask_buffer.get_ptr(mode="read") + plc_column = plc_transform.mask_to_bools( + mask_buffer.get_ptr(mode="read"), begin_bit, end_bit ) - - cdef unique_ptr[column] result - with nogil: - result = move( - libcudf_transform.mask_to_bools(bit_mask, begin_bit, end_bit) - ) - - return Column.from_unique_ptr(move(result)) + return Column.from_pylibcudf(plc_column) @acquire_spill_lock() def nans_to_nulls(Column input): - (mask, _) = plc_transform.nans_to_nulls( + mask, _ = plc_transform.nans_to_nulls( input.to_pylibcudf(mode="read") ) return as_buffer(mask) @@ -91,80 +60,45 @@ def nans_to_nulls(Column input): @acquire_spill_lock() def transform(Column input, op): - cdef column_view c_input = input.view() - cdef string c_str - cdef type_id c_tid - cdef data_type c_dtype - nb_type = numpy_support.from_dtype(input.dtype) nb_signature = (nb_type,) compiled_op = cudautils.compile_udf(op, nb_signature) - c_str = compiled_op[0].encode('UTF-8') np_dtype = cudf.dtype(compiled_op[1]) - try: - c_tid = ( - SUPPORTED_NUMPY_TO_LIBCUDF_TYPES[ - np_dtype - ] - ) - c_dtype = data_type(c_tid) - - except KeyError: - raise TypeError( - "Result of window function has unsupported dtype {}" - .format(np_dtype) - ) - - with nogil: - c_output = move(libcudf_transform.transform( - c_input, - c_str, - c_dtype, - True - )) - - return Column.from_unique_ptr(move(c_output)) + plc_column = plc_transform.transform( + input.to_pylibcudf(mode="read"), + compiled_op[0], + plc.column._datatype_from_dtype_desc(np_dtype.str[1:]), + True + ) + return Column.from_pylibcudf(plc_column) def table_encode(list source_columns): - cdef table_view c_input = table_view_from_columns(source_columns) - cdef pair[unique_ptr[table], unique_ptr[column]] c_result - - with nogil: - c_result = move(libcudf_transform.encode(c_input)) + plc_table, plc_column = plc_transform.encode( + plc.Table([col.to_pylibcudf(mode="read") for col in source_columns]) + ) return ( - columns_from_unique_ptr(move(c_result.first)), - Column.from_unique_ptr(move(c_result.second)) + [Column.from_pylibcudf(col) for col in plc_table.columns()], + Column.from_pylibcudf(plc_column) ) def one_hot_encode(Column input_column, Column categories): - cdef column_view c_view_input = input_column.view() - cdef column_view c_view_categories = categories.view() - cdef pair[unique_ptr[column], table_view] c_result - - with nogil: - c_result = move( - libcudf_transform.one_hot_encode(c_view_input, c_view_categories) - ) - - # Notice, the data pointer of `owner` has been exposed - # through `c_result.second` at this point. - owner = Column.from_unique_ptr( - move(c_result.first), data_ptr_exposed=True - ) - - pylist_categories = categories.to_arrow().to_pylist() - encodings, _ = data_from_table_view( - move(c_result.second), - owner=owner, - column_names=[ - x if x is not None else '' for x in pylist_categories - ] + plc_table = plc_transform.one_hot_encode( + input_column.to_pylibcudf(mode="read"), + categories.to_pylibcudf(mode="read"), ) - return encodings + result_columns = [ + Column.from_pylibcudf(col, data_ptr_exposed=True) + for col in plc_table.columns() + ] + result_labels = [ + x if x is not None else '' + for x in categories.to_arrow().to_pylist() + ] + return dict(zip(result_labels, result_columns)) @acquire_spill_lock() diff --git a/python/pylibcudf/pylibcudf/tests/test_transform.py b/python/pylibcudf/pylibcudf/tests/test_transform.py index 06fc35d8835..d5c618f07e4 100644 --- a/python/pylibcudf/pylibcudf/tests/test_transform.py +++ b/python/pylibcudf/pylibcudf/tests/test_transform.py @@ -29,3 +29,54 @@ def test_nans_to_nulls(has_nans): got = input.with_mask(mask, null_count) assert_column_eq(expect, got) + + +def test_bools_to_mask_roundtrip(): + pa_array = pa.array([True, None, False]) + plc_input = plc.interop.from_arrow(pa_array) + mask, result_null_count = plc.transform.bools_to_mask(plc_input) + + assert result_null_count == 2 + result = plc_input.with_mask(mask, result_null_count) + assert_column_eq(pa.array([True, None, None]), result) + + plc_output = plc.transform.mask_to_bools(mask.ptr, 0, len(pa_array)) + result_pa = plc.interop.to_arrow(plc_output) + expected_pa = pa.chunked_array([[True, False, False]]) + assert result_pa.equals(expected_pa) + + +def test_encode(): + pa_table = pa.table({"a": [1, 3, 4], "b": [1, 2, 4]}) + plc_input = plc.interop.from_arrow(pa_table) + result_table, result_column = plc.transform.encode(plc_input) + pa_table_result = plc.interop.to_arrow(result_table) + pa_column_result = plc.interop.to_arrow(result_column) + + pa_table_expected = pa.table( + [[1, 3, 4], [1, 2, 4]], + schema=pa.schema( + [ + pa.field("", pa.int64(), nullable=False), + pa.field("", pa.int64(), nullable=False), + ] + ), + ) + assert pa_table_result.equals(pa_table_expected) + + pa_column_expected = pa.chunked_array([[0, 1, 2]], type=pa.int32()) + assert pa_column_result.equals(pa_column_expected) + + +def test_one_hot_encode(): + pa_column = pa.array([1, 2, 3]) + pa_categories = pa.array([0, 0, 0]) + plc_input = plc.interop.from_arrow(pa_column) + plc_categories = plc.interop.from_arrow(pa_categories) + plc_table = plc.transform.one_hot_encode(plc_input, plc_categories) + result = plc.interop.to_arrow(plc_table) + expected = pa.table( + [[False] * 3] * 3, + schema=pa.schema([pa.field("", pa.bool_(), nullable=False)] * 3), + ) + assert result.equals(expected) diff --git a/python/pylibcudf/pylibcudf/transform.pxd b/python/pylibcudf/pylibcudf/transform.pxd index 4b21feffe25..b530f433c97 100644 --- a/python/pylibcudf/pylibcudf/transform.pxd +++ b/python/pylibcudf/pylibcudf/transform.pxd @@ -1,7 +1,21 @@ # Copyright (c) 2024, NVIDIA CORPORATION. +from libcpp cimport bool +from pylibcudf.libcudf.types cimport bitmask_type, data_type from .column cimport Column from .gpumemoryview cimport gpumemoryview +from .table cimport Table +from .types cimport DataType cpdef tuple[gpumemoryview, int] nans_to_nulls(Column input) + +cpdef tuple[gpumemoryview, int] bools_to_mask(Column input) + +cpdef Column mask_to_bools(Py_ssize_t bitmask, int begin_bit, int end_bit) + +cpdef Column transform(Column input, str unary_udf, DataType output_type, bool is_ptx) + +cpdef tuple[Table, Column] encode(Table input) + +cpdef Table one_hot_encode(Column input_column, Column categories) diff --git a/python/pylibcudf/pylibcudf/transform.pyx b/python/pylibcudf/pylibcudf/transform.pyx index 100ccb580ce..bcd6185521a 100644 --- a/python/pylibcudf/pylibcudf/transform.pyx +++ b/python/pylibcudf/pylibcudf/transform.pyx @@ -1,14 +1,20 @@ # Copyright (c) 2024, NVIDIA CORPORATION. from libcpp.memory cimport unique_ptr +from libcpp.string cimport string from libcpp.utility cimport move, pair from pylibcudf.libcudf cimport transform as cpp_transform -from pylibcudf.libcudf.types cimport size_type +from pylibcudf.libcudf.column.column cimport column +from pylibcudf.libcudf.table.table cimport table +from pylibcudf.libcudf.table.table_view cimport table_view +from pylibcudf.libcudf.types cimport bitmask_type, size_type from rmm._lib.device_buffer cimport DeviceBuffer, device_buffer from .column cimport Column from .gpumemoryview cimport gpumemoryview +from .types cimport DataType +from .utils cimport int_to_bitmask_ptr cpdef tuple[gpumemoryview, int] nans_to_nulls(Column input): @@ -32,3 +38,141 @@ cpdef tuple[gpumemoryview, int] nans_to_nulls(Column input): gpumemoryview(DeviceBuffer.c_from_unique_ptr(move(c_result.first))), c_result.second ) + + +cpdef tuple[gpumemoryview, int] bools_to_mask(Column input): + """Create a bitmask from a column of boolean elements + + Parameters + ---------- + input : Column + Column to produce new mask from. + + Returns + ------- + tuple[gpumemoryview, int] + Two-tuple of a gpumemoryview wrapping the bitmask and the null count. + """ + cdef pair[unique_ptr[device_buffer], size_type] c_result + + with nogil: + c_result = move(cpp_transform.bools_to_mask(input.view())) + + return ( + gpumemoryview(DeviceBuffer.c_from_unique_ptr(move(c_result.first))), + c_result.second + ) + + +cpdef Column mask_to_bools(Py_ssize_t bitmask, int begin_bit, int end_bit): + """Creates a boolean column from given bitmask. + + Parameters + ---------- + bitmask : int + Pointer to the bitmask which needs to be converted + begin_bit : int + Position of the bit from which the conversion should start + end_bit : int + Position of the bit before which the conversion should stop + + Returns + ------- + Column + Boolean column of the bitmask from [begin_bit, end_bit] + """ + cdef unique_ptr[column] c_result + cdef bitmask_type * bitmask_ptr = int_to_bitmask_ptr(bitmask) + + with nogil: + c_result = move(cpp_transform.mask_to_bools(bitmask_ptr, begin_bit, end_bit)) + + return Column.from_libcudf(move(c_result)) + + +cpdef Column transform(Column input, str unary_udf, DataType output_type, bool is_ptx): + """Create a new column by applying a unary function against every + element of an input column. + + Parameters + ---------- + input : Column + Column to transform. + unary_udf : str + The PTX/CUDA string of the unary function to apply. + output_type : DataType + The output type that is compatible with the output type in the unary_udf. + is_ptx : bool + If `True`, the UDF is treated as PTX code. + If `False`, the UDF is treated as CUDA code. + + Returns + ------- + Column + The transformed column having the UDF applied to each element. + """ + cdef unique_ptr[column] c_result + cdef string c_unary_udf = unary_udf.encode() + cdef bool c_is_ptx = is_ptx + + with nogil: + c_result = move( + cpp_transform.transform( + input.view(), c_unary_udf, output_type.c_obj, c_is_ptx + ) + ) + + return Column.from_libcudf(move(c_result)) + +cpdef tuple[Table, Column] encode(Table input): + """Encode the rows of the given table as integers. + + Parameters + ---------- + input : Table + Table containing values to be encoded + + Returns + ------- + tuple[Table, Column] + The distinct row of the input table in sorted order, + and a column of integer indices representing the encoded rows. + """ + cdef pair[unique_ptr[table], unique_ptr[column]] c_result + + with nogil: + c_result = move(cpp_transform.encode(input.view())) + + return ( + Table.from_libcudf(move(c_result.first)), + Column.from_libcudf(move(c_result.second)) + ) + +cpdef Table one_hot_encode(Column input, Column categories): + """Encodes `input` by generating a new column + for each value in `categories` indicating the presence + of that value in `input`. + + Parameters + ---------- + input : Column + Column containing values to be encoded. + categories : Column + Column containing categories + + Returns + ------- + Column + A table of the encoded values. + """ + cdef pair[unique_ptr[column], table_view] c_result + cdef Table owner_table + + with nogil: + c_result = move(cpp_transform.one_hot_encode(input.view(), categories.view())) + + owner_table = Table( + [Column.from_libcudf(move(c_result.first))] * c_result.second.num_columns() + ) + + return Table.from_table_view(c_result.second, owner_table) From 96d2f814ab60bd22667c22d82d4b9b1755c1e028 Mon Sep 17 00:00:00 2001 From: Vyas Ramasubramani Date: Fri, 20 Sep 2024 18:24:47 -0700 Subject: [PATCH 14/15] Update labeler for pylibcudf (#16868) The labeler was not updated for the move of pylibcudf to a separate package. Authors: - Vyas Ramasubramani (https://github.com/vyasr) Approvers: - Bradley Dice (https://github.com/bdice) URL: https://github.com/rapidsai/cudf/pull/16868 --- .github/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 90cdda4d3ca..8506d38a048 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -12,7 +12,7 @@ cudf.polars: - 'python/cudf_polars/**' pylibcudf: - - 'python/cudf/pylibcudf/**' + - 'python/pylibcudf/**' libcudf: - 'cpp/**' From 9b4c4c721c399bae9e88733da79daa1a10644481 Mon Sep 17 00:00:00 2001 From: Basit Ayantunde Date: Sat, 21 Sep 2024 14:19:57 +0100 Subject: [PATCH 15/15] Exposed stream-ordering to datetime API (#16774) This merge request exposes stream-ordering to the public-facing datetime APIs. - `extract_year` - `extract_month` - `extract_day` - `extract_weekday` - `extract_hour` - `extract_minute` - `extract_second` - `extract_millisecond_fraction` - `extract_microsecond_fraction` - `extract_nanosecond_fraction` - `last_day_of_month` - `day_of_year` - `add_calendrical_months` - `is_leap_year` - `days_in_month` - `extract_quarter` - `ceil_datetimes` - `floor_datetimes` - `round_datetimes` Follows-up https://github.com/rapidsai/cudf/issues/13744 Closes https://github.com/rapidsai/cudf/issues/16775 Authors: - Basit Ayantunde (https://github.com/lamarrr) Approvers: - Karthikeyan (https://github.com/karthikeyann) - Yunsong Wang (https://github.com/PointKernel) URL: https://github.com/rapidsai/cudf/pull/16774 --- cpp/include/cudf/datetime.hpp | 43 +++++++++ cpp/include/cudf/detail/datetime.hpp | 55 +++++------ cpp/include/cudf/detail/timezone.hpp | 6 +- cpp/include/cudf/timezone.hpp | 5 + cpp/src/datetime/datetime_ops.cu | 91 +++++++++++------- cpp/src/datetime/timezone.cpp | 4 +- cpp/tests/CMakeLists.txt | 1 + cpp/tests/streams/datetime_test.cpp | 139 +++++++++++++++++++++++++++ 8 files changed, 276 insertions(+), 68 deletions(-) create mode 100644 cpp/tests/streams/datetime_test.cpp diff --git a/cpp/include/cudf/datetime.hpp b/cpp/include/cudf/datetime.hpp index c7523c80b2b..7359a0d5fde 100644 --- a/cpp/include/cudf/datetime.hpp +++ b/cpp/include/cudf/datetime.hpp @@ -17,9 +17,12 @@ #pragma once #include +#include #include #include +#include + #include /** @@ -40,6 +43,7 @@ namespace datetime { * cudf::column. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of the extracted int16_t years @@ -47,6 +51,7 @@ namespace datetime { */ std::unique_ptr extract_year( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -54,6 +59,7 @@ std::unique_ptr extract_year( * cudf::column. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of the extracted int16_t months @@ -61,6 +67,7 @@ std::unique_ptr extract_year( */ std::unique_ptr extract_month( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -68,6 +75,7 @@ std::unique_ptr extract_month( * cudf::column. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of the extracted int16_t days @@ -75,6 +83,7 @@ std::unique_ptr extract_month( */ std::unique_ptr extract_day( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -82,6 +91,7 @@ std::unique_ptr extract_day( * cudf::column. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of the extracted int16_t days @@ -89,6 +99,7 @@ std::unique_ptr extract_day( */ std::unique_ptr extract_weekday( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -96,6 +107,7 @@ std::unique_ptr extract_weekday( * cudf::column. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of the extracted int16_t hours @@ -103,6 +115,7 @@ std::unique_ptr extract_weekday( */ std::unique_ptr extract_hour( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -110,6 +123,7 @@ std::unique_ptr extract_hour( * cudf::column. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of the extracted int16_t minutes @@ -117,6 +131,7 @@ std::unique_ptr extract_hour( */ std::unique_ptr extract_minute( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -124,6 +139,7 @@ std::unique_ptr extract_minute( * cudf::column. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of the extracted int16_t seconds @@ -131,6 +147,7 @@ std::unique_ptr extract_minute( */ std::unique_ptr extract_second( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -141,6 +158,7 @@ std::unique_ptr extract_second( * For example, the millisecond fraction of 1.234567890 seconds is 234. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of the extracted int16_t milliseconds @@ -148,6 +166,7 @@ std::unique_ptr extract_second( */ std::unique_ptr extract_millisecond_fraction( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -158,6 +177,7 @@ std::unique_ptr extract_millisecond_fraction( * For example, the microsecond fraction of 1.234567890 seconds is 567. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of the extracted int16_t microseconds @@ -165,6 +185,7 @@ std::unique_ptr extract_millisecond_fraction( */ std::unique_ptr extract_microsecond_fraction( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -175,6 +196,7 @@ std::unique_ptr extract_microsecond_fraction( * For example, the nanosecond fraction of 1.234567890 seconds is 890. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of the extracted int16_t nanoseconds @@ -182,6 +204,7 @@ std::unique_ptr extract_microsecond_fraction( */ std::unique_ptr extract_nanosecond_fraction( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @} */ // end of group @@ -196,6 +219,7 @@ std::unique_ptr extract_nanosecond_fraction( * cudf::column. * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column containing last day of the month as TIMESTAMP_DAYS @@ -203,6 +227,7 @@ std::unique_ptr extract_nanosecond_fraction( */ std::unique_ptr last_day_of_month( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -210,6 +235,7 @@ std::unique_ptr last_day_of_month( * returns an int16_t cudf::column. The value is between [1, {365-366}] * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of datatype INT16 containing the day number since the start of the year @@ -217,6 +243,7 @@ std::unique_ptr last_day_of_month( */ std::unique_ptr day_of_year( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -245,6 +272,7 @@ std::unique_ptr day_of_year( * * @param timestamps cudf::column_view of timestamp type * @param months cudf::column_view of integer type containing the number of months to add + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of timestamp type containing the computed timestamps @@ -252,6 +280,7 @@ std::unique_ptr day_of_year( std::unique_ptr add_calendrical_months( cudf::column_view const& timestamps, cudf::column_view const& months, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -280,6 +309,7 @@ std::unique_ptr add_calendrical_months( * * @param timestamps cudf::column_view of timestamp type * @param months cudf::scalar of integer type containing the number of months to add + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @return cudf::column of timestamp type containing the computed timestamps @@ -287,6 +317,7 @@ std::unique_ptr add_calendrical_months( std::unique_ptr add_calendrical_months( cudf::column_view const& timestamps, cudf::scalar const& months, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -297,6 +328,7 @@ std::unique_ptr add_calendrical_months( * `output[i] is null` if `column[i]` is null * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @returns cudf::column of datatype BOOL8 truth value of the corresponding date @@ -304,6 +336,7 @@ std::unique_ptr add_calendrical_months( */ std::unique_ptr is_leap_year( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -315,11 +348,13 @@ std::unique_ptr is_leap_year( * @throw cudf::logic_error if input column datatype is not a TIMESTAMP * * @param column cudf::column_view of the input datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * @return cudf::column of datatype INT16 of days in month of the corresponding date */ std::unique_ptr days_in_month( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -331,11 +366,13 @@ std::unique_ptr days_in_month( * @throw cudf::logic_error if input column datatype is not a TIMESTAMP * * @param column The input column containing datetime values + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * @return A column of INT16 type indicating which quarter the date is in */ std::unique_ptr extract_quarter( cudf::column_view const& column, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -357,6 +394,7 @@ enum class rounding_frequency : int32_t { * * @param column cudf::column_view of the input datetime values * @param freq rounding_frequency indicating the frequency to round up to + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @throw cudf::logic_error if input column datatype is not TIMESTAMP. @@ -365,6 +403,7 @@ enum class rounding_frequency : int32_t { std::unique_ptr ceil_datetimes( cudf::column_view const& column, rounding_frequency freq, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -372,6 +411,7 @@ std::unique_ptr ceil_datetimes( * * @param column cudf::column_view of the input datetime values * @param freq rounding_frequency indicating the frequency to round down to + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @throw cudf::logic_error if input column datatype is not TIMESTAMP. @@ -380,6 +420,7 @@ std::unique_ptr ceil_datetimes( std::unique_ptr floor_datetimes( cudf::column_view const& column, rounding_frequency freq, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @@ -387,6 +428,7 @@ std::unique_ptr floor_datetimes( * * @param column cudf::column_view of the input datetime values * @param freq rounding_frequency indicating the frequency to round to + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate device memory of the returned column * * @throw cudf::logic_error if input column datatype is not TIMESTAMP. @@ -395,6 +437,7 @@ std::unique_ptr floor_datetimes( std::unique_ptr round_datetimes( cudf::column_view const& column, rounding_frequency freq, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); /** @} */ // end of group diff --git a/cpp/include/cudf/detail/datetime.hpp b/cpp/include/cudf/detail/datetime.hpp index 31782cbaf8a..9db7e48498f 100644 --- a/cpp/include/cudf/detail/datetime.hpp +++ b/cpp/include/cudf/detail/datetime.hpp @@ -26,111 +26,108 @@ namespace CUDF_EXPORT cudf { namespace datetime { namespace detail { /** - * @copydoc cudf::extract_year(cudf::column_view const&, rmm::device_async_resource_ref) + * @copydoc cudf::extract_year(cudf::column_view const&, rmm::cuda_stream_view, + * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr extract_year(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::extract_month(cudf::column_view const&, rmm::device_async_resource_ref) + * @copydoc cudf::extract_month(cudf::column_view const&, rmm::cuda_stream_view, + * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr extract_month(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::extract_day(cudf::column_view const&, rmm::device_async_resource_ref) + * @copydoc cudf::extract_day(cudf::column_view const&, rmm::cuda_stream_view, + * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr extract_day(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::extract_weekday(cudf::column_view const&, rmm::device_async_resource_ref) + * @copydoc cudf::extract_weekday(cudf::column_view const&, rmm::cuda_stream_view, + * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr extract_weekday(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::extract_hour(cudf::column_view const&, rmm::device_async_resource_ref) + * @copydoc cudf::extract_hour(cudf::column_view const&, rmm::cuda_stream_view, + * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr extract_hour(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::extract_minute(cudf::column_view const&, rmm::device_async_resource_ref) + * @copydoc cudf::extract_minute(cudf::column_view const&, rmm::cuda_stream_view, + * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr extract_minute(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::extract_second(cudf::column_view const&, rmm::device_async_resource_ref) + * @copydoc cudf::extract_second(cudf::column_view const&, rmm::cuda_stream_view, + * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr extract_second(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::extract_millisecond_fraction(cudf::column_view const&, + * @copydoc cudf::extract_millisecond_fraction(cudf::column_view const&, rmm::cuda_stream_view, * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr extract_millisecond_fraction(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::extract_microsecond_fraction(cudf::column_view const&, + * @copydoc cudf::extract_microsecond_fraction(cudf::column_view const&, rmm::cuda_stream_view, * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr extract_microsecond_fraction(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::extract_nanosecond_fraction(cudf::column_view const&, + * @copydoc cudf::extract_nanosecond_fraction(cudf::column_view const&, rmm::cuda_stream_view, * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr extract_nanosecond_fraction(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::last_day_of_month(cudf::column_view const&, rmm::device_async_resource_ref) + * @copydoc cudf::last_day_of_month(cudf::column_view const&, rmm::cuda_stream_view, + * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr last_day_of_month(cudf::column_view const& column, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr); /** - * @copydoc cudf::day_of_year(cudf::column_view const&, rmm::device_async_resource_ref) + * @copydoc cudf::day_of_year(cudf::column_view const&, rmm::cuda_stream_view, + * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr day_of_year(cudf::column_view const& column, rmm::cuda_stream_view stream, @@ -138,9 +135,8 @@ std::unique_ptr day_of_year(cudf::column_view const& column, /** * @copydoc cudf::add_calendrical_months(cudf::column_view const&, cudf::column_view const&, - * rmm::device_async_resource_ref) + * rmm::cuda_stream_view, rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr add_calendrical_months(cudf::column_view const& timestamps, cudf::column_view const& months, @@ -149,9 +145,8 @@ std::unique_ptr add_calendrical_months(cudf::column_view const& ti /** * @copydoc cudf::add_calendrical_months(cudf::column_view const&, cudf::scalar const&, - * rmm::device_async_resource_ref) + * rmm::cuda_stream_view, rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr add_calendrical_months(cudf::column_view const& timestamps, cudf::scalar const& months, @@ -159,9 +154,9 @@ std::unique_ptr add_calendrical_months(cudf::column_view const& ti rmm::device_async_resource_ref mr); /** - * @copydoc cudf::is_leap_year(cudf::column_view const&, rmm::device_async_resource_ref) + * @copydoc cudf::is_leap_year(cudf::column_view const&, rmm::cuda_stream_view, + * rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr is_leap_year(cudf::column_view const& column, rmm::cuda_stream_view stream, diff --git a/cpp/include/cudf/detail/timezone.hpp b/cpp/include/cudf/detail/timezone.hpp index 5738f9ec8e9..f51d1ba42b2 100644 --- a/cpp/include/cudf/detail/timezone.hpp +++ b/cpp/include/cudf/detail/timezone.hpp @@ -16,6 +16,7 @@ #pragma once #include +#include #include #include @@ -26,14 +27,13 @@ namespace detail { /** * @copydoc cudf::make_timezone_transition_table(std::optional, std::string_view, - * rmm::device_async_resource_ref) + * rmm::cuda_stream_view, rmm::device_async_resource_ref) * - * @param stream CUDA stream used for device memory operations and kernel launches. */ std::unique_ptr make_timezone_transition_table( std::optional tzif_dir, std::string_view timezone_name, - rmm::cuda_stream_view stream, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); } // namespace detail diff --git a/cpp/include/cudf/timezone.hpp b/cpp/include/cudf/timezone.hpp index aa903770e26..f6de1056c24 100644 --- a/cpp/include/cudf/timezone.hpp +++ b/cpp/include/cudf/timezone.hpp @@ -15,9 +15,12 @@ */ #pragma once +#include #include #include +#include + #include #include #include @@ -43,6 +46,7 @@ static constexpr uint32_t solar_cycle_entry_count = 2 * solar_cycle_years; * * @param tzif_dir The directory where the TZif files are located * @param timezone_name standard timezone name (for example, "America/Los_Angeles") + * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table's device memory. * * @return The transition table for the given timezone @@ -50,6 +54,7 @@ static constexpr uint32_t solar_cycle_entry_count = 2 * solar_cycle_years; std::unique_ptr
make_timezone_transition_table( std::optional tzif_dir, std::string_view timezone_name, + rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); } // namespace CUDF_EXPORT cudf diff --git a/cpp/src/datetime/datetime_ops.cu b/cpp/src/datetime/datetime_ops.cu index fd9a6b8f5fe..ddb0dbcd96d 100644 --- a/cpp/src/datetime/datetime_ops.cu +++ b/cpp/src/datetime/datetime_ops.cu @@ -580,142 +580,167 @@ std::unique_ptr extract_quarter(column_view const& column, std::unique_ptr ceil_datetimes(column_view const& column, rounding_frequency freq, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::round_general( - detail::rounding_function::CEIL, freq, column, cudf::get_default_stream(), mr); + return detail::round_general(detail::rounding_function::CEIL, freq, column, stream, mr); } std::unique_ptr floor_datetimes(column_view const& column, rounding_frequency freq, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::round_general( - detail::rounding_function::FLOOR, freq, column, cudf::get_default_stream(), mr); + return detail::round_general(detail::rounding_function::FLOOR, freq, column, stream, mr); } std::unique_ptr round_datetimes(column_view const& column, rounding_frequency freq, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::round_general( - detail::rounding_function::ROUND, freq, column, cudf::get_default_stream(), mr); + return detail::round_general(detail::rounding_function::ROUND, freq, column, stream, mr); } -std::unique_ptr extract_year(column_view const& column, rmm::device_async_resource_ref mr) +std::unique_ptr extract_year(column_view const& column, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_year(column, cudf::get_default_stream(), mr); + return detail::extract_year(column, stream, mr); } -std::unique_ptr extract_month(column_view const& column, rmm::device_async_resource_ref mr) +std::unique_ptr extract_month(column_view const& column, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_month(column, cudf::get_default_stream(), mr); + return detail::extract_month(column, stream, mr); } -std::unique_ptr extract_day(column_view const& column, rmm::device_async_resource_ref mr) +std::unique_ptr extract_day(column_view const& column, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_day(column, cudf::get_default_stream(), mr); + return detail::extract_day(column, stream, mr); } std::unique_ptr extract_weekday(column_view const& column, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_weekday(column, cudf::get_default_stream(), mr); + return detail::extract_weekday(column, stream, mr); } -std::unique_ptr extract_hour(column_view const& column, rmm::device_async_resource_ref mr) +std::unique_ptr extract_hour(column_view const& column, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_hour(column, cudf::get_default_stream(), mr); + return detail::extract_hour(column, stream, mr); } -std::unique_ptr extract_minute(column_view const& column, rmm::device_async_resource_ref mr) +std::unique_ptr extract_minute(column_view const& column, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_minute(column, cudf::get_default_stream(), mr); + return detail::extract_minute(column, stream, mr); } -std::unique_ptr extract_second(column_view const& column, rmm::device_async_resource_ref mr) +std::unique_ptr extract_second(column_view const& column, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_second(column, cudf::get_default_stream(), mr); + return detail::extract_second(column, stream, mr); } std::unique_ptr extract_millisecond_fraction(column_view const& column, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_millisecond_fraction(column, cudf::get_default_stream(), mr); + return detail::extract_millisecond_fraction(column, stream, mr); } std::unique_ptr extract_microsecond_fraction(column_view const& column, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_microsecond_fraction(column, cudf::get_default_stream(), mr); + return detail::extract_microsecond_fraction(column, stream, mr); } std::unique_ptr extract_nanosecond_fraction(column_view const& column, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_nanosecond_fraction(column, cudf::get_default_stream(), mr); + return detail::extract_nanosecond_fraction(column, stream, mr); } std::unique_ptr last_day_of_month(column_view const& column, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::last_day_of_month(column, cudf::get_default_stream(), mr); + return detail::last_day_of_month(column, stream, mr); } -std::unique_ptr day_of_year(column_view const& column, rmm::device_async_resource_ref mr) +std::unique_ptr day_of_year(column_view const& column, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::day_of_year(column, cudf::get_default_stream(), mr); + return detail::day_of_year(column, stream, mr); } std::unique_ptr add_calendrical_months(cudf::column_view const& timestamp_column, cudf::column_view const& months_column, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::add_calendrical_months( - timestamp_column, months_column, cudf::get_default_stream(), mr); + return detail::add_calendrical_months(timestamp_column, months_column, stream, mr); } std::unique_ptr add_calendrical_months(cudf::column_view const& timestamp_column, cudf::scalar const& months, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::add_calendrical_months(timestamp_column, months, cudf::get_default_stream(), mr); + return detail::add_calendrical_months(timestamp_column, months, stream, mr); } -std::unique_ptr is_leap_year(column_view const& column, rmm::device_async_resource_ref mr) +std::unique_ptr is_leap_year(column_view const& column, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::is_leap_year(column, cudf::get_default_stream(), mr); + return detail::is_leap_year(column, stream, mr); } -std::unique_ptr days_in_month(column_view const& column, rmm::device_async_resource_ref mr) +std::unique_ptr days_in_month(column_view const& column, + rmm::cuda_stream_view stream, + rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::days_in_month(column, cudf::get_default_stream(), mr); + return detail::days_in_month(column, stream, mr); } std::unique_ptr extract_quarter(column_view const& column, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::extract_quarter(column, cudf::get_default_stream(), mr); + return detail::extract_quarter(column, stream, mr); } } // namespace datetime diff --git a/cpp/src/datetime/timezone.cpp b/cpp/src/datetime/timezone.cpp index 6498a5e6c55..cf239297255 100644 --- a/cpp/src/datetime/timezone.cpp +++ b/cpp/src/datetime/timezone.cpp @@ -380,11 +380,11 @@ static int64_t get_transition_time(dst_transition_s const& trans, int year) std::unique_ptr
make_timezone_transition_table(std::optional tzif_dir, std::string_view timezone_name, + rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { CUDF_FUNC_RANGE(); - return detail::make_timezone_transition_table( - tzif_dir, timezone_name, cudf::get_default_stream(), mr); + return detail::make_timezone_transition_table(tzif_dir, timezone_name, stream, mr); } namespace detail { diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 586bac97570..288fa84a73d 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -687,6 +687,7 @@ ConfigureTest(STREAM_BINARYOP_TEST streams/binaryop_test.cpp STREAM_MODE testing ConfigureTest(STREAM_CONCATENATE_TEST streams/concatenate_test.cpp STREAM_MODE testing) ConfigureTest(STREAM_COPYING_TEST streams/copying_test.cpp STREAM_MODE testing) ConfigureTest(STREAM_CSVIO_TEST streams/io/csv_test.cpp STREAM_MODE testing) +ConfigureTest(STREAM_DATETIME_TEST streams/datetime_test.cpp STREAM_MODE testing) ConfigureTest(STREAM_DICTIONARY_TEST streams/dictionary_test.cpp STREAM_MODE testing) ConfigureTest(STREAM_FILLING_TEST streams/filling_test.cpp STREAM_MODE testing) ConfigureTest(STREAM_GROUPBY_TEST streams/groupby_test.cpp STREAM_MODE testing) diff --git a/cpp/tests/streams/datetime_test.cpp b/cpp/tests/streams/datetime_test.cpp new file mode 100644 index 00000000000..82629156fa6 --- /dev/null +++ b/cpp/tests/streams/datetime_test.cpp @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2024, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include + +#include +#include + +#include + +class DatetimeTest : public cudf::test::BaseFixture { + public: + cudf::test::fixed_width_column_wrapper timestamps{ + -23324234, // 1969-12-31 23:59:59.976675766 GMT + 23432424, // 1970-01-01 00:00:00.023432424 GMT + 987234623 // 1970-01-01 00:00:00.987234623 GMT + }; + cudf::test::fixed_width_column_wrapper months{{1, -1, 3}}; +}; + +TEST_F(DatetimeTest, ExtractYear) +{ + cudf::datetime::extract_year(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, ExtractMonth) +{ + cudf::datetime::extract_month(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, ExtractDay) +{ + cudf::datetime::extract_day(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, ExtractWeekday) +{ + cudf::datetime::extract_weekday(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, ExtractHour) +{ + cudf::datetime::extract_hour(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, ExtractMinute) +{ + cudf::datetime::extract_minute(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, ExtractSecond) +{ + cudf::datetime::extract_second(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, ExtractMillisecondFraction) +{ + cudf::datetime::extract_millisecond_fraction(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, ExtractMicrosecondFraction) +{ + cudf::datetime::extract_microsecond_fraction(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, ExtractNanosecondFraction) +{ + cudf::datetime::extract_nanosecond_fraction(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, LastDayOfMonth) +{ + cudf::datetime::last_day_of_month(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, DayOfYear) +{ + cudf::datetime::day_of_year(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, AddCalendricalMonths) +{ + cudf::datetime::add_calendrical_months(timestamps, months, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, AddCalendricalMonthsScalar) +{ + auto scalar = cudf::make_fixed_width_scalar(1, cudf::test::get_default_stream()); + + cudf::datetime::add_calendrical_months(timestamps, *scalar, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, IsLeapYear) +{ + cudf::datetime::is_leap_year(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, DaysInMonth) +{ + cudf::datetime::days_in_month(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, ExtractQuarter) +{ + cudf::datetime::extract_quarter(timestamps, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, CeilDatetimes) +{ + cudf::datetime::ceil_datetimes( + timestamps, cudf::datetime::rounding_frequency::HOUR, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, FloorDatetimes) +{ + cudf::datetime::floor_datetimes( + timestamps, cudf::datetime::rounding_frequency::HOUR, cudf::test::get_default_stream()); +} + +TEST_F(DatetimeTest, RoundDatetimes) +{ + cudf::datetime::round_datetimes( + timestamps, cudf::datetime::rounding_frequency::HOUR, cudf::test::get_default_stream()); +}