diff --git a/cpp/doxygen/developer_guide/DEVELOPER_GUIDE.md b/cpp/doxygen/developer_guide/DEVELOPER_GUIDE.md index efd79cd5cbda..afc6c9580cfb 100644 --- a/cpp/doxygen/developer_guide/DEVELOPER_GUIDE.md +++ b/cpp/doxygen/developer_guide/DEVELOPER_GUIDE.md @@ -38,8 +38,10 @@ A type representing a single element of a data type. ### Table -A table is a collection of columns with equal number of elements. A table is the C++ equivalent to -a cuDF Python [DataFrame](https://docs.rapids.ai/api/cudf/stable/api_docs/dataframe.html). +A table is a collection of columns that all have the same number of elements (rows). A table may +also have zero columns while still carrying a row count, mirroring an `(N, 0)` DataFrame. A table is +the C++ equivalent to a cuDF Python +[DataFrame](https://docs.rapids.ai/api/cudf/stable/api_docs/dataframe.html). ### View diff --git a/cpp/include/cudf/contiguous_split.hpp b/cpp/include/cudf/contiguous_split.hpp index 12f08f89a062..c9bb6df7cf49 100644 --- a/cpp/include/cudf/contiguous_split.hpp +++ b/cpp/include/cudf/contiguous_split.hpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -407,9 +407,6 @@ class packed_metadata_view { /** * @brief The number of rows in the table. * - * This is the row count of the first top-level column. - * Returns 0 if the table has no columns. - * * @return The row count */ [[nodiscard]] size_type num_rows() const; @@ -427,6 +424,8 @@ class packed_metadata_view { // Span from the first top-level column entry to the end of the metadata buffer. std::span _entries; size_type _num_columns{}; + // Table row count, read directly from the serialized table header. + size_type _num_rows{}; }; /** @} */ diff --git a/cpp/include/cudf/detail/contiguous_split.hpp b/cpp/include/cudf/detail/contiguous_split.hpp index ca94623a04cc..22d01417f048 100644 --- a/cpp/include/cudf/detail/contiguous_split.hpp +++ b/cpp/include/cudf/detail/contiguous_split.hpp @@ -13,6 +13,7 @@ #include #include +#include namespace cudf { namespace detail { @@ -48,8 +49,14 @@ class metadata_builder { * @brief Construct a new metadata_builder. * * @param num_root_columns is the number of top-level columns + * @param num_rows the table row count to record, or std::nullopt to not record + * one. A row count is only needed to preserve the rows of a zero-column + * table; for a table with one or more columns the row count is derived + * from the columns, so std::nullopt should be passed. If set, num_rows + * must match the size of the table's columns (if any). */ - explicit metadata_builder(size_type const num_root_columns); + explicit metadata_builder(size_type const num_root_columns, + std::optional const num_rows); /** * @brief Destructor that will be implemented as default, required because metadata_builder_impl @@ -70,6 +77,9 @@ class metadata_builder { * 3) add_column_info_to_meta(col_a_child_2) * 4) add_column_info_to_meta(col_b) * + * @throws std::invalid_argument if a num_rows was passed to the constructor + * and does not match col_size of the first (top-level) column added + * * @param col_type column data type * @param col_size column row count * @param col_null_count column null count @@ -114,7 +124,7 @@ std::vector pack_metadata(table_view const& table, /** * @brief Version of the packed metadata layout produced by `pack`/`pack_metadata`. */ -constexpr std::int32_t packed_metadata_version = 1; +constexpr std::int32_t packed_metadata_version = 2; } // namespace detail } // namespace cudf diff --git a/cpp/include/cudf/detail/copy_if.cuh b/cpp/include/cudf/detail/copy_if.cuh index 8ae2b7bb6ccb..7f2dfb0e4817 100644 --- a/cpp/include/cudf/detail/copy_if.cuh +++ b/cpp/include/cudf/detail/copy_if.cuh @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -48,7 +48,7 @@ std::unique_ptr copy_if(table_view const& input, { CUDF_FUNC_RANGE(); - if (0 == input.num_rows() || 0 == input.num_columns()) { return empty_like(input); } + if (0 == input.num_rows()) { return empty_like(input); } auto indices = rmm::device_uvector(input.num_rows(), stream); auto const begin = cuda::counting_iterator{0}; diff --git a/cpp/include/cudf/detail/gather.cuh b/cpp/include/cudf/detail/gather.cuh index afd382e7a645..e5bb1f9ff575 100644 --- a/cpp/include/cudf/detail/gather.cuh +++ b/cpp/include/cudf/detail/gather.cuh @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ #pragma once @@ -666,7 +666,9 @@ std::unique_ptr
gather(table_view const& source_table, } } - return std::make_unique
(std::move(destination_columns)); + // Pass the explicit row count (the gather-map size) so a zero-column input preserves its rows. + auto const num_rows = static_cast(cudf::distance(gather_map_begin, gather_map_end)); + return std::make_unique
(std::move(destination_columns), num_rows); } } // namespace detail diff --git a/cpp/include/cudf/detail/scatter.cuh b/cpp/include/cudf/detail/scatter.cuh index 2e6586e5be9c..b2610b99f966 100644 --- a/cpp/include/cudf/detail/scatter.cuh +++ b/cpp/include/cudf/detail/scatter.cuh @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -429,7 +429,7 @@ std::unique_ptr
scatter(table_view const& source, } }); } - return std::make_unique
(std::move(result)); + return std::make_unique
(std::move(result), target.num_rows()); } } // namespace detail } // namespace cudf diff --git a/cpp/include/cudf/partitioning.hpp b/cpp/include/cudf/partitioning.hpp index 8707b579f767..dd743d8531c2 100644 --- a/cpp/include/cudf/partitioning.hpp +++ b/cpp/include/cudf/partitioning.hpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -82,6 +82,9 @@ std::pair, std::vector> partition( * `[offsets[i], offsets[i+1])`. The last offset is always equal to the total * number of rows in the output table. * + * An empty `columns_to_hash` is treated as empty, producing an empty result even when `input` has a + * non-zero row count. + * * @throw std::out_of_range if index is `columns_to_hash` is invalid * * @param input The table to partition @@ -113,6 +116,9 @@ std::pair, std::vector> hash_partition( * `[offsets[i], offsets[i+1])`. The last offset is always equal to the total * number of rows in the output table. * + * A zero-column `keys` table is treated as empty, producing an empty result even when `input` has a + * non-zero row count. + * * @throw std::invalid_argument if `keys` is not empty and does not have the same number of rows as * `input`. * diff --git a/cpp/include/cudf/sorting.hpp b/cpp/include/cudf/sorting.hpp index 5b591989d4bb..8bccd319e2f7 100644 --- a/cpp/include/cudf/sorting.hpp +++ b/cpp/include/cudf/sorting.hpp @@ -254,6 +254,9 @@ std::unique_ptr rank( * result is { 0,1,2, 6,5,4,3, 7,8,9 } * @endcode * + * A zero-column `keys` table is treated as empty, producing an empty column even when `keys` has a + * non-zero row count. + * * @param keys The table that determines the ordering of elements in each segment * @param segment_offsets The column of `size_type` type containing start offset index for each * contiguous segment. @@ -266,6 +269,7 @@ std::unique_ptr rank( * `null_order::BEFORE`. * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource to allocate any returned objects + * * @return sorted order of the segment sorted table * */ diff --git a/cpp/include/cudf/stream_compaction.hpp b/cpp/include/cudf/stream_compaction.hpp index 1d2324104ad2..2c98863461fa 100644 --- a/cpp/include/cudf/stream_compaction.hpp +++ b/cpp/include/cudf/stream_compaction.hpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -267,6 +267,9 @@ enum class duplicate_keep_option { * Performance hint: if the input is pre-sorted, `cudf::unique` can produce an equivalent result * (i.e., same set of output rows) but with less running time than `cudf::distinct`. * + * A zero-column `input` is treated as empty, producing an empty table even when `input` has a + * non-zero row count. + * * @throws cudf::logic_error if the `keys` column indices are out of bounds in the `input` table. * * @param[in] input input table_view to copy only unique rows @@ -299,6 +302,9 @@ std::unique_ptr
unique( * Performance hint: if the input is pre-sorted, `cudf::unique` can produce an equivalent result * (i.e., same set of output rows) but with less running time than `cudf::distinct`. * + * A zero-column `input` is treated as empty, producing an empty table even when `input` has a + * non-zero row count. + * * @param input The input table * @param keys Vector of indices indicating key columns in the `input` table * @param keep Copy any, first, last, or none of the found duplicates @@ -306,6 +312,7 @@ std::unique_ptr
unique( * @param nans_equal Flag to specify whether NaN elements should be considered as equal * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned table + * * @return Table with distinct rows in an unspecified order */ std::unique_ptr
distinct( @@ -323,12 +330,16 @@ std::unique_ptr
distinct( * Given an `input` table_view, an output vector of all row indices of the distinct rows is * generated. If there are duplicate rows, which index is kept depends on the `keep` parameter. * + * A zero-column `input` is treated as empty, producing an empty column even when `input` has a + * non-zero row count. + * * @param input The input table * @param keep Get index of any, first, last, or none of the found duplicates * @param nulls_equal Flag to specify whether null elements should be considered as equal * @param nans_equal Flag to specify whether NaN elements should be considered as equal * @param stream CUDA stream used for device memory operations and kernel launches * @param mr Device memory resource used to allocate the returned vector + * * @return Column containing the result indices */ std::unique_ptr distinct_indices( @@ -353,6 +364,9 @@ std::unique_ptr distinct_indices( * with another values column `3, 4, 5`, the result could contain values `3, 4` or `4, 5` but not * `4, 3` or `5, 4`. * + * A zero-column `input` is treated as empty, producing an empty table even when `input` has a + * non-zero row count. + * * @param input The input table * @param keys Vector of indices indicating key columns in the `input` table * @param keep Copy any, first, last, or none of the found duplicates @@ -360,6 +374,7 @@ std::unique_ptr distinct_indices( * @param nans_equal Flag to specify whether NaN elements should be considered as equal * @param stream CUDA stream used for device memory operations and kernel launches. * @param mr Device memory resource used to allocate the returned table + * * @return Table with distinct rows, preserving input order */ std::unique_ptr
stable_distinct( diff --git a/cpp/include/cudf/table/table.hpp b/cpp/include/cudf/table/table.hpp index d62ef4f608c5..891562976e3f 100644 --- a/cpp/include/cudf/table/table.hpp +++ b/cpp/include/cudf/table/table.hpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ #pragma once @@ -24,6 +24,8 @@ namespace CUDF_EXPORT cudf { /** * @brief A set of cudf::column's of the same size. * + * If the set of columns is empty, the table's row count may still be non-zero. + * * @ingroup table_classes */ class table { @@ -56,6 +58,25 @@ class table { */ table(std::vector>&& columns); + /** + * @brief Moves the contents from a vector of `unique_ptr`s to columns to + * construct a new table with an explicit row count. + * + * This is primarily intended for zero-column tables, which cannot otherwise + * carry a non-zero row count (the row count is normally derived from the + * columns). It is used, for example, when converting a zero-column Arrow array + * that has a non-zero length. When `columns` is non-empty, `num_rows` must equal + * the size of every column. + * + * @throws std::invalid_argument if `columns` is non-empty and `num_rows` does not + * match the size of every column. + * + * @param columns The vector of `unique_ptr`s to columns whose contents will + * be moved into the new table. + * @param num_rows The number of rows in the table. + */ + table(std::vector>&& columns, size_type num_rows); + /** * @brief Copy the contents of a `table_view` to construct a new `table`. * @@ -146,7 +167,7 @@ class table { std::vector columns(std::distance(begin, end)); std::transform( begin, end, columns.begin(), [this](auto index) { return _columns.at(index)->view(); }); - return table_view{columns}; + return table_view{columns, num_rows()}; } /** diff --git a/cpp/include/cudf/table/table_view.hpp b/cpp/include/cudf/table/table_view.hpp index 5811e2d1b8ba..358cd2536eba 100644 --- a/cpp/include/cudf/table/table_view.hpp +++ b/cpp/include/cudf/table/table_view.hpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2024, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ #pragma once @@ -70,6 +70,21 @@ class table_view_base { */ explicit table_view_base(std::vector const& cols); + /** + * @brief Construct a table from a vector of column views with an explicit row count. + * + * This is primarily intended for zero-column tables, which cannot otherwise carry + * a non-zero row count (the row count is normally derived from the columns). When + * `cols` is non-empty, `num_rows` must equal the size of every column. + * + * @throws std::invalid_argument If `cols` is non-empty and any view's size does not equal + * `num_rows` + * + * @param cols The vector of columns to construct the table from + * @param num_rows The number of rows in the table + */ + table_view_base(std::vector const& cols, size_type num_rows); + /** * @brief Returns an iterator to the first view in the `table`. * @@ -181,6 +196,8 @@ bool has_nested_columns(table_view const& table); /** * @brief A set of cudf::column_view's of the same size. * + * If the set of columns is empty, the view's row count may still be non-zero. + * * @ingroup table_classes * * All public member functions and constructors are inherited from @@ -230,7 +247,7 @@ class table_view : public detail::table_view_base { { std::vector columns(std::distance(begin, end)); std::transform(begin, end, columns.begin(), [this](auto index) { return this->column(index); }); - return table_view{columns}; + return table_view{columns, num_rows()}; } /** diff --git a/cpp/src/copying/concatenate.cu b/cpp/src/copying/concatenate.cu index df9779f59c24..f946c0df7fe5 100644 --- a/cpp/src/copying/concatenate.cu +++ b/cpp/src/copying/concatenate.cu @@ -39,7 +39,9 @@ #include #include +#include #include +#include #include namespace cudf { @@ -544,6 +546,20 @@ std::unique_ptr
concatenate(std::span tables_to_concat, }), "Mismatch in table columns to concatenate."); + // Zero-column tables carry only a row count; concatenation sums their rows. + if (first_table.num_columns() == 0) { + auto const total_rows = std::accumulate( + tables_to_concat.begin(), + tables_to_concat.end(), + std::size_t{0}, + [](std::size_t acc, auto const& t) { return acc + static_cast(t.num_rows()); }); + CUDF_EXPECTS(total_rows <= static_cast(std::numeric_limits::max()), + "Total number of rows exceeds the column size limit", + std::overflow_error); + return std::make_unique
(std::vector>{}, + static_cast(total_rows)); + } + std::vector> concat_columns; for (size_type i = 0; i < first_table.num_columns(); ++i) { std::vector cols; diff --git a/cpp/src/copying/contiguous_split.cu b/cpp/src/copying/contiguous_split.cu index 2c3a9a191514..5c0bfdd6d7a8 100644 --- a/cpp/src/copying/contiguous_split.cu +++ b/cpp/src/copying/contiguous_split.cu @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -1785,21 +1785,21 @@ void copy_data(int num_batches_to_copy, */ bool check_inputs(cudf::table_view const& input, std::vector const& splits) { - if (input.num_columns() == 0) { return true; } + auto const num_rows = input.num_rows(); + if (input.num_columns() == 0 && num_rows == 0) { return true; } if (splits.size() > 0) { - CUDF_EXPECTS(splits.back() <= input.column(0).size(), - "splits can't exceed size of input columns", - std::out_of_range); + CUDF_EXPECTS( + splits.back() <= num_rows, "splits can't exceed size of input columns", std::out_of_range); } size_type begin = 0; for (auto end : splits) { CUDF_EXPECTS(begin >= 0, "Starting index cannot be negative.", std::out_of_range); CUDF_EXPECTS( end >= begin, "End index cannot be smaller than the starting index.", std::invalid_argument); - CUDF_EXPECTS(end <= input.column(0).size(), "Slice range out of bounds.", std::out_of_range); + CUDF_EXPECTS(end <= num_rows, "Slice range out of bounds.", std::out_of_range); begin = end; } - return input.column(0).size() == 0; + return num_rows == 0 || input.num_columns() == 0; } }; // anonymous namespace @@ -1926,7 +1926,12 @@ struct contiguous_split_state { { CUDF_EXPECTS(num_partitions == 1, "build_packed_column_metadata supported only without splits"); - if (input.num_columns() == 0) { return std::unique_ptr>(); } + if (input.num_columns() == 0) { + // A truly empty (0, 0) table has no metadata. + if (input.num_rows() == 0) { return std::unique_ptr>(); } + // A zero-column, N-row has metadata-only output recording its row count. + return std::make_unique>(cudf::pack_metadata(input, nullptr, 0)); + } if (is_empty) { // this is a bit ugly, but it was done to re-use make_empty_packed_table between the @@ -1937,7 +1942,7 @@ struct contiguous_split_state { auto& h_dst_buf_info = partition_buf_size_and_dst_buf_info->h_dst_buf_info; auto cur_dst_buf_info = h_dst_buf_info.data(); - detail::metadata_builder mb{input.num_columns()}; + detail::metadata_builder mb{input.num_columns(), std::nullopt}; populate_metadata(input.begin(), input.end(), cur_dst_buf_info, mb); @@ -1959,6 +1964,16 @@ struct contiguous_split_state { is_empty{check_inputs(input, splits)}, num_partitions{splits.size() + 1} { + // Per-partition row counts from the split boundaries (0, splits..., num_rows). + // check_inputs has already validated that the splits are monotonic and in range. + partition_row_counts.reserve(num_partitions); + size_type begin = 0; + for (auto const end : splits) { + partition_row_counts.push_back(end - begin); + begin = end; + } + partition_row_counts.push_back(input.num_rows() - begin); + // if the table we are about to contig split is empty, we have special // handling where metadata is produced and a 0-byte contiguous buffer // is the result. @@ -1993,7 +2008,27 @@ struct contiguous_split_state { std::vector make_packed_tables() { - if (input.num_columns() == 0) { return std::vector(); } + if (input.num_columns() == 0) { + // A truly empty (0, 0) table produces no output. + if (input.num_rows() == 0) { return std::vector(); } + + // A zero-column, N-row table contains no device data, so each partition is + // represented as a metadata-only packed table that records its row count. + std::vector result; + result.reserve(num_partitions); + std::transform( + partition_row_counts.begin(), + partition_row_counts.end(), + std::back_inserter(result), + [](size_type partition_rows) { + auto partition = cudf::table_view{std::vector{}, partition_rows}; + return packed_table{partition, + packed_columns{std::make_unique>( + cudf::pack_metadata(partition, nullptr, 0)), + std::make_unique()}}; + }); + return result; + } if (is_empty) { return make_empty_packed_table(); } std::vector result; result.reserve(num_partitions); @@ -2004,7 +2039,7 @@ struct contiguous_split_state { auto& h_dst_bufs = src_and_dst_pointers->h_dst_bufs; auto cur_dst_buf_info = h_dst_buf_info.data(); - detail::metadata_builder mb(input.num_columns()); + detail::metadata_builder mb(input.num_columns(), std::nullopt); for (std::size_t idx = 0; idx < num_partitions; idx++) { // traverse the buffers and build the columns. @@ -2072,6 +2107,9 @@ struct contiguous_split_state { // This can be 1 if `contiguous_split` is just packing and not splitting std::size_t const num_partitions; ///< The number of partitions to produce + // Per-partition row counts derived from `splits` and `input.num_rows()`. + std::vector partition_row_counts; + size_type num_src_bufs{}; ///< Number of source buffers including children std::size_t num_bufs{}; ///< Number of source buffers including children * number of splits diff --git a/cpp/src/copying/pack.cpp b/cpp/src/copying/pack.cpp index ebbda6089833..58785aeda761 100644 --- a/cpp/src/copying/pack.cpp +++ b/cpp/src/copying/pack.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -65,13 +66,22 @@ struct serialized_column { /** * @brief Table-level metadata stored before the serialized column entries. + * + * `num_rows` records the table's row count. For a zero-column table (which has no + * columns to derive it from) it is the only source of the count. For a table with + * columns it equals the columns' size and is validated against them on unpack. */ struct alignas(8) serialized_table_header { serialized_table_header() = default; - explicit serialized_table_header(size_type _num_columns) : num_columns(_num_columns) {} + serialized_table_header(size_type _num_columns, size_type _num_rows) + : num_columns(_num_columns), num_rows(_num_rows) + { + } int32_t version{packed_metadata_version}; size_type num_columns{}; + size_type num_rows{}; + int32_t pad{}; // Explicitly pad to avoid uninitialized padding bits }; // The header is serialized with memcpy, so it must not contain padding bytes @@ -98,6 +108,7 @@ serialized_table_header read_header(std::uint8_t const* ptr, CUDF_EXPECTS(header.version == packed_metadata_version, "packed metadata has an unsupported format version"); CUDF_EXPECTS(header.num_columns >= 0, "packed metadata header has negative column count"); + CUDF_EXPECTS(header.num_rows >= 0, "packed metadata header has negative row count"); return header; } @@ -224,6 +235,7 @@ table_view unpack(uint8_t const* metadata, uint8_t const* gpu_data) uint8_t const* base_ptr = gpu_data; auto const header = read_header(metadata); auto const num_columns = header.num_columns; + auto const num_rows = header.num_rows; // current_ptr tracks position in the metadata byte buffer auto const* current_ptr = metadata + sizeof(serialized_table_header); @@ -242,7 +254,16 @@ table_view unpack(uint8_t const* metadata, uint8_t const* gpu_data) return cols; }; - return table_view{get_columns(num_columns)}; + auto const cols = get_columns(num_columns); + if (num_columns == 0) { + // A zero-column table has no columns to derive its row count from; use the + // count recorded in the table header. + return table_view{std::vector{}, num_rows}; + } + // For a table with columns the row count is derived from the columns. + CUDF_EXPECTS(num_rows == cols.front().size(), + "packed metadata row count does not match the columns"); + return table_view{cols}; } } // anonymous namespace @@ -275,7 +296,8 @@ std::vector pack_metadata(table_view const& table, class metadata_builder_impl { public: - metadata_builder_impl(size_type const num_root_columns) : _num_root_columns(num_root_columns) + metadata_builder_impl(size_type const num_root_columns, std::optional const num_rows) + : _num_root_columns(num_root_columns), _num_rows(num_rows) { // Lower bound: exact for flat tables but nested children add more entries and grow the vector. _columns.reserve(num_root_columns); @@ -288,14 +310,22 @@ class metadata_builder_impl { int64_t const null_mask_offset, size_type const num_children) { + if (_num_rows.has_value() && _columns.empty()) { + CUDF_EXPECTS(col_size == _num_rows.value(), + "num_rows does not match the size of the table's columns", + std::invalid_argument); + } _columns.emplace_back( col_type, col_size, col_null_count, data_offset, null_mask_offset, num_children); } [[nodiscard]] std::vector build() const { - auto const header = serialized_table_header{_num_root_columns}; - auto output = std::vector(sizeof(serialized_table_header) + + // The header always records the table row count. Either the first top-level column's + // size for a table with columns, or the explicit count for a zero-column table. + auto const num_rows = _columns.empty() ? _num_rows.value_or(0) : _columns.front().size; + auto const header = serialized_table_header{_num_root_columns, num_rows}; + auto output = std::vector(sizeof(serialized_table_header) + _columns.size() * sizeof(serialized_column)); std::memcpy(output.data(), &header, sizeof(serialized_table_header)); if (!_columns.empty()) { @@ -311,12 +341,15 @@ class metadata_builder_impl { private: // Number of top-level columns (excludes nested children) stored in the header. size_type const _num_root_columns; + // Explicit table row count, recorded in the header only for a zero-column table. + std::optional const _num_rows; // Serialized column entries, depth-first with each column written before its children. std::vector _columns; }; -metadata_builder::metadata_builder(size_type const num_root_columns) - : impl(std::make_unique(num_root_columns)) +metadata_builder::metadata_builder(size_type const num_root_columns, + std::optional const num_rows) + : impl(std::make_unique(num_root_columns, num_rows)) { } @@ -379,21 +412,22 @@ packed_metadata_view::packed_metadata_view(std::span buffer) auto const* entries = buffer.data() + sizeof(detail::serialized_table_header); auto const header = detail::read_header(buffer.data(), end); _num_columns = header.num_columns; - // Validate that the column tree exactly fills the buffer. - auto const* past_last = detail::skip_subtrees(entries, _num_columns, end); - CUDF_EXPECTS(past_last == end, - "packed metadata buffer size does not match the encoded column tree"); + _num_rows = header.num_rows; + // Walk the top-level columns once to validate two things: every top-level column's size agrees + // with the recorded row count and the column tree exactly fills the buffer. + auto const* ptr = entries; + for (size_type i = 0; i < _num_columns; ++i) { + auto const entry = detail::read_entry(ptr, end); + CUDF_EXPECTS(entry.size == _num_rows, "packed metadata row count does not match the columns"); + ptr = detail::skip_subtrees(ptr, 1, end); + } + CUDF_EXPECTS(ptr == end, "packed metadata buffer size does not match the encoded column tree"); _entries = {entries, end}; } size_type packed_metadata_view::num_columns() const { return _num_columns; } -size_type packed_metadata_view::num_rows() const -{ - if (_num_columns == 0) { return 0; } - return detail::read_entry(_entries.data(), _entries.data() + sizeof(detail::serialized_column)) - .size; -} +size_type packed_metadata_view::num_rows() const { return _num_rows; } packed_metadata_view::column_view packed_metadata_view::column(size_type i) const { @@ -422,9 +456,16 @@ std::vector pack_metadata(table_view const& table, size_t buffer_size) { CUDF_FUNC_RANGE(); - if (table.is_empty()) { return std::vector{}; } - - auto builder = cudf::detail::metadata_builder(table.num_columns()); + // A truly empty table (no columns and no rows) serializes to an empty buffer. + // A zero-column table with a non-zero row count still emits a metadata buffer + // whose table header records the row count, so the count round-trips. + if (table.num_columns() == 0 && table.num_rows() == 0) { return std::vector{}; } + + // Only a zero-column table records a row count. For tables with columns the row count + // comes from the columns. + auto const num_rows = + table.num_columns() == 0 ? std::optional{table.num_rows()} : std::nullopt; + auto builder = cudf::detail::metadata_builder(table.num_columns(), num_rows); return detail::pack_metadata(table, contiguous_buffer, buffer_size, builder); } diff --git a/cpp/src/copying/scatter.cu b/cpp/src/copying/scatter.cu index 23396b48bc16..c0e31ea8778f 100644 --- a/cpp/src/copying/scatter.cu +++ b/cpp/src/copying/scatter.cu @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ #include @@ -375,7 +375,7 @@ std::unique_ptr
scatter(std::vector> mr); }); - return std::make_unique
(std::move(result)); + return std::make_unique
(std::move(result), target.num_rows()); } std::unique_ptr boolean_mask_scatter(column_view const& input, @@ -438,21 +438,25 @@ std::unique_ptr
boolean_mask_scatter(table_view const& input, "Type mismatch in input column and target column", cudf::data_type_error); - if (target.num_rows() != 0) { - std::vector> out_columns(target.num_columns()); - std::transform( - input.begin(), - input.end(), - target.begin(), - out_columns.begin(), - [&boolean_mask, mr, stream](auto const& input_column, auto const& target_column) { - return boolean_mask_scatter(input_column, target_column, boolean_mask, stream, mr); - }); - - return std::make_unique
(std::move(out_columns)); - } else { - return empty_like(target); - } + // Build a scatter map of the target row indices selected by the boolean mask, then delegate to + // detail::scatter. + auto indices = cudf::make_numeric_column(data_type{type_id::INT32}, + target.num_rows(), + mask_state::UNALLOCATED, + stream, + cudf::get_current_device_resource_ref()); + auto mutable_indices = indices->mutable_view(); + thrust::sequence(rmm::exec_policy_nosync(stream, cudf::get_current_device_resource_ref()), + mutable_indices.begin(), + mutable_indices.end(), + 0); + + auto scatter_map = detail::apply_mask(table_view{{indices->view()}}, + boolean_mask, + mask_type::RETENTION, + stream, + cudf::get_current_device_resource_ref()); + return detail::scatter(input, scatter_map->get_column(0).view(), target, stream, mr); } std::unique_ptr
boolean_mask_scatter( @@ -492,7 +496,7 @@ std::unique_ptr
boolean_mask_scatter( scalar.get(), target_column, boolean_mask, stream, mr); }); - return std::make_unique
(std::move(out_columns)); + return std::make_unique
(std::move(out_columns), target.num_rows()); } else { return empty_like(target); } diff --git a/cpp/src/copying/slice.cu b/cpp/src/copying/slice.cu index a04189e47492..9c63022cd307 100644 --- a/cpp/src/copying/slice.cu +++ b/cpp/src/copying/slice.cu @@ -117,7 +117,23 @@ std::vector slice(table_view const& input, for (size_type j = 0; j < input.num_columns(); j++) { table_columns.emplace_back(sliced_table[j][i]); } - result.emplace_back(table_view{table_columns}); + auto const begin = indices[2 * i]; + auto const end = indices[2 * i + 1]; + // For a zero-column input the per-column slice above never runs, so its bounds checks are + // skipped. Validate the range here before building the zero-column table_view. + if (input.num_columns() == 0) { + if (input.num_rows() == 0) { + // An empty table (no rows) historically returns empty slices regardless of the indices. + result.emplace_back(table_view{table_columns}); + continue; + } + CUDF_EXPECTS(begin >= 0, "Starting index cannot be negative.", std::out_of_range); + CUDF_EXPECTS(end >= begin, + "End index cannot be smaller than the starting index.", + std::invalid_argument); + CUDF_EXPECTS(end <= input.num_rows(), "Slice range out of bounds.", std::out_of_range); + } + result.emplace_back(table_view{table_columns, end - begin}); } return result; diff --git a/cpp/src/copying/split.cpp b/cpp/src/copying/split.cpp index 630560b439e0..74f9bf0d9bb5 100644 --- a/cpp/src/copying/split.cpp +++ b/cpp/src/copying/split.cpp @@ -50,8 +50,9 @@ std::vector split(cudf::table_view const& input, std::span splits, rmm::cuda_stream_view stream) { - if (input.num_columns() == 0) { return {}; } - return split(input, input.column(0).size(), splits, stream); + // A genuinely empty table (no columns and no rows) has nothing to split. + if (input.num_columns() == 0 && input.num_rows() == 0) { return {}; } + return split(input, input.num_rows(), splits, stream); } std::vector split(column_view const& input, diff --git a/cpp/src/hash/murmurhash3_x64_128.cu b/cpp/src/hash/murmurhash3_x64_128.cu index 1c7f38d0d93f..17af144fda06 100644 --- a/cpp/src/hash/murmurhash3_x64_128.cu +++ b/cpp/src/hash/murmurhash3_x64_128.cu @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ #include @@ -110,7 +110,7 @@ std::unique_ptr
murmurhash3_x64_128(table_view const& input, auto output2 = make_numeric_column( data_type(type_id::UINT64), input.num_rows(), mask_state::UNALLOCATED, stream, mr); - if (!input.is_empty()) { + if (input.num_rows() != 0) { bool const nullable = has_nulls(input); auto const input_view = table_device_view::create(input, stream); auto d_output1 = output1->mutable_view().data(); diff --git a/cpp/src/hash/murmurhash3_x86_32.cu b/cpp/src/hash/murmurhash3_x86_32.cu index 79c71d71b32c..3a28c1a15553 100644 --- a/cpp/src/hash/murmurhash3_x86_32.cu +++ b/cpp/src/hash/murmurhash3_x86_32.cu @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ #include @@ -28,8 +28,7 @@ std::unique_ptr murmurhash3_x86_32(table_view const& input, stream, mr); - // Return early if there's nothing to hash - if (input.num_columns() == 0 || input.num_rows() == 0) { return output; } + if (input.num_rows() == 0) { return output; } bool const nullable = has_nulls(input); auto const row_hasher = cudf::detail::row::hash::row_hasher(input, stream); diff --git a/cpp/src/hash/xxhash_32.cu b/cpp/src/hash/xxhash_32.cu index 11096b00d198..759a491b193b 100644 --- a/cpp/src/hash/xxhash_32.cu +++ b/cpp/src/hash/xxhash_32.cu @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ #include @@ -28,8 +28,7 @@ std::unique_ptr xxhash_32(table_view const& input, stream, mr); - // Return early if there's nothing to hash - if (input.num_columns() == 0 || input.num_rows() == 0) { return output; } + if (input.num_rows() == 0) { return output; } bool const nullable = has_nulls(input); auto const row_hasher = cudf::detail::row::hash::row_hasher(input, stream); diff --git a/cpp/src/hash/xxhash_64.cu b/cpp/src/hash/xxhash_64.cu index 885276c2c3a3..fcf7009bd128 100644 --- a/cpp/src/hash/xxhash_64.cu +++ b/cpp/src/hash/xxhash_64.cu @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ #include @@ -30,8 +30,7 @@ std::unique_ptr xxhash_64(table_view const& input, stream, mr); - // Return early if there's nothing to hash - if (input.num_columns() == 0 || input.num_rows() == 0) { return output; } + if (input.num_rows() == 0) { return output; } bool const nullable = has_nulls(input); auto const row_hasher = cudf::detail::row::hash::row_hasher(input, stream); diff --git a/cpp/src/interop/from_arrow_device.cu b/cpp/src/interop/from_arrow_device.cu index 42d7047eea75..aed25a2c5496 100644 --- a/cpp/src/interop/from_arrow_device.cu +++ b/cpp/src/interop/from_arrow_device.cu @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -365,7 +365,7 @@ dispatch_tuple_t get_column(ArrowSchemaView* schema, { CUDF_EXPECTS( input->length <= static_cast(std::numeric_limits::max()), - "Total number of rows in Arrow column exceeds the column size limit.", + "Number of rows exceeds cuDF's maximum supported row count (cudf::size_type).", std::overflow_error); return type.id() != type_id::EMPTY @@ -432,7 +432,19 @@ unique_table_view_t from_arrow_device(ArrowSchema const* schema, return out_view; }); - return unique_table_view_t{new table_view{columns}, + // A zero-column struct still has a length, preserve it as the table row count. + table_view* table_view_ptr = nullptr; + if (columns.empty()) { + CUDF_EXPECTS( + input->array.length <= static_cast(std::numeric_limits::max()), + "Number of rows exceeds cuDF's maximum supported row count (cudf::size_type).", + std::overflow_error); + table_view_ptr = + new table_view{std::vector{}, static_cast(input->array.length)}; + } else { + table_view_ptr = new table_view{columns}; + } + return unique_table_view_t{table_view_ptr, custom_view_deleter{std::move(owned_mem)}}; } diff --git a/cpp/src/interop/from_arrow_host.cu b/cpp/src/interop/from_arrow_host.cu index 74441b639ff8..8f6cdf4483e2 100644 --- a/cpp/src/interop/from_arrow_host.cu +++ b/cpp/src/interop/from_arrow_host.cu @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -217,7 +217,7 @@ std::unique_ptr dispatch_copy_from_arrow_host::operator()length + 1 <= static_cast(std::numeric_limits::max()), - "number of rows in Arrow column exceeds the column size limit", + "Number of rows exceeds cuDF's maximum supported row count (cudf::size_type).", std::overflow_error); if (input->length == 0) { return make_empty_column(type_id::STRING); } @@ -290,7 +290,7 @@ std::unique_ptr dispatch_copy_from_arrow_host::operator()length + 1 <= static_cast(std::numeric_limits::max()), - "number of rows in Arrow column exceeds the column size limit", + "Number of rows exceeds cuDF's maximum supported row count (cudf::size_type).", std::overflow_error); auto [offsets_column, offset, length] = get_offsets_column(schema, input, stream, mr); @@ -341,7 +341,7 @@ std::unique_ptr get_column_copy(ArrowSchemaView const* schema, { CUDF_EXPECTS( input->length <= static_cast(std::numeric_limits::max()), - "number of rows in Arrow column exceeds the column size limit", + "Number of rows exceeds cuDF's maximum supported row count (cudf::size_type).", std::overflow_error); if (type.id() == type_id::EMPTY) { @@ -481,6 +481,14 @@ std::unique_ptr
from_arrow_host(ArrowSchema const* schema, return get_column_copy(&view, child, type, false, stream, mr); }); + // A zero-column struct still has a length, preserve it as the table row count. + if (columns.empty()) { + CUDF_EXPECTS( + input->array.length <= static_cast(std::numeric_limits::max()), + "Number of rows exceeds cuDF's maximum supported row count (cudf::size_type).", + std::overflow_error); + return std::make_unique
(std::move(columns), static_cast(input->array.length)); + } return std::make_unique
(std::move(columns)); } diff --git a/cpp/src/partitioning/partitioning.cu b/cpp/src/partitioning/partitioning.cu index 99cc16e27bb5..d9bf0d99864f 100644 --- a/cpp/src/partitioning/partitioning.cu +++ b/cpp/src/partitioning/partitioning.cu @@ -725,7 +725,8 @@ std::pair, std::vector> hash_partition_table( } stream.synchronize(); // Async D2H copy must finish before returning host vec - return std::pair{std::make_unique
(std::move(output_cols)), std::move(partition_offsets)}; + return std::pair{std::make_unique
(std::move(output_cols), num_rows), + std::move(partition_offsets)}; } else { // Compute a scatter map from input to output such that the output rows are // sorted by partition number diff --git a/cpp/src/table/table.cpp b/cpp/src/table/table.cpp index 67ea34bf3567..1cfa1f41efdf 100644 --- a/cpp/src/table/table.cpp +++ b/cpp/src/table/table.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2025, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -41,6 +41,19 @@ table::table(std::vector>&& columns) : _columns{std::mov } } +table::table(std::vector>&& columns, size_type num_rows) + : _columns{std::move(columns)}, _num_rows{num_rows} +{ + CUDF_EXPECTS(num_rows >= 0, "Number of rows cannot be negative.", std::invalid_argument); + for (auto const& c : _columns) { + CUDF_EXPECTS(c, "Unexpected null column"); + CUDF_EXPECTS( + c->size() == num_rows, + "Column size mismatch: " + std::to_string(c->size()) + " != " + std::to_string(num_rows), + std::invalid_argument); + } +} + // Copy the contents of a `table_view` table::table(table_view view, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) : _num_rows{view.num_rows()} @@ -68,7 +81,7 @@ table_view table::view() const for (auto const& c : _columns) { views.push_back(c->view()); } - return table_view{views}; + return table_view{views, _num_rows}; } // Create mutable view @@ -79,7 +92,7 @@ mutable_table_view table::mutable_view() for (auto const& c : _columns) { views.push_back(c->mutable_view()); } - return mutable_table_view{views}; + return mutable_table_view{views, _num_rows}; } // Release ownership of columns diff --git a/cpp/src/table/table_view.cpp b/cpp/src/table/table_view.cpp index 453346cdde50..e0f79a928827 100644 --- a/cpp/src/table/table_view.cpp +++ b/cpp/src/table/table_view.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2018-2024, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2018-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -27,6 +27,18 @@ auto concatenate_column_views(std::vector const& views) return concat_cols; } +template +size_type concatenated_table_num_rows(std::vector const& views) +{ + if (views.empty()) { return 0; } + auto const num_rows = views.front().num_rows(); + CUDF_EXPECTS(std::all_of(views.begin(), + views.end(), + [num_rows](auto const& view) { return view.num_rows() == num_rows; }), + "Mismatch in number of rows"); + return num_rows; +} + } // namespace template @@ -42,6 +54,19 @@ table_view_base::table_view_base(std::vector const& cols } } +template +table_view_base::table_view_base(std::vector const& cols, + size_type num_rows) + : _columns{cols}, _num_rows{num_rows} +{ + CUDF_EXPECTS(num_rows >= 0, "Number of rows cannot be negative.", std::invalid_argument); + CUDF_EXPECTS(std::all_of(cols.begin(), + cols.end(), + [num_rows](ColumnView const& col) { return col.size() == num_rows; }), + "Column size mismatch", + std::invalid_argument); +} + // Explicit instantiation for a table of `column_view`s template class table_view_base; @@ -58,16 +83,17 @@ table_view table_view::select(std::vector const& column_indices) cons // Convert mutable view to immutable view mutable_table_view::operator table_view() { - return table_view{std::vector{begin(), end()}}; + return table_view{std::vector{begin(), end()}, num_rows()}; } table_view::table_view(std::vector const& views) - : table_view{detail::concatenate_column_views(views)} + : table_view{detail::concatenate_column_views(views), detail::concatenated_table_num_rows(views)} { } mutable_table_view::mutable_table_view(std::vector const& views) - : mutable_table_view{detail::concatenate_column_views(views)} + : mutable_table_view{detail::concatenate_column_views(views), + detail::concatenated_table_num_rows(views)} { } @@ -79,7 +105,7 @@ table_view scatter_columns(table_view const& source, // scatter(updated_table.begin(),updated_table.end(),indices.begin(),updated_columns.begin()); for (size_type idx = 0; idx < source.num_columns(); ++idx) updated_columns[map[idx]] = source.column(idx); - return table_view{updated_columns}; + return table_view{updated_columns, target.num_rows()}; } std::vector get_nullable_columns(table_view const& table) diff --git a/cpp/tests/copying/concatenate_tests.cpp b/cpp/tests/copying/concatenate_tests.cpp index 341294602f8f..8875add31856 100644 --- a/cpp/tests/copying/concatenate_tests.cpp +++ b/cpp/tests/copying/concatenate_tests.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -229,6 +229,15 @@ TEST_F(TableTest, ConcatenateTables) CUDF_TEST_EXPECT_TABLES_EQUAL(*concat_table, gold_table); } +TEST_F(TableTest, ConcatenateZeroColumnTables) +{ + TView t1{std::vector{}, 7}; + TView t2{std::vector{}, 5}; + auto concat_table = cudf::concatenate(std::vector({t1, t2})); + EXPECT_EQ(concat_table->num_columns(), 0); + EXPECT_EQ(concat_table->num_rows(), 12); +} + TEST_F(TableTest, ConcatenateTablesWithOffsets) { column_wrapper col1_1{{5, 4, 3, 5, 8, 5, 6}}; diff --git a/cpp/tests/copying/gather_tests.cpp b/cpp/tests/copying/gather_tests.cpp index 4c6c964fcb74..c5648c2ff586 100644 --- a/cpp/tests/copying/gather_tests.cpp +++ b/cpp/tests/copying/gather_tests.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -30,6 +30,17 @@ class GatherTest : public cudf::test::BaseFixture {}; TYPED_TEST_SUITE(GatherTest, cudf::test::NumericTypes); +struct GatherZeroColumnTest : public cudf::test::BaseFixture {}; + +TEST_F(GatherZeroColumnTest, PreservesRowCount) +{ + cudf::table_view source{std::vector{}, 5}; + cudf::test::fixed_width_column_wrapper gather_map{{0, 2, 4, 1}}; + auto result = cudf::gather(source, gather_map); + EXPECT_EQ(result->num_columns(), 0); + EXPECT_EQ(result->num_rows(), 4); +} + TYPED_TEST(GatherTest, IdentityTest) { constexpr cudf::size_type source_size{1000}; diff --git a/cpp/tests/copying/pack_tests.cpp b/cpp/tests/copying/pack_tests.cpp index 49ee4183f243..9f701ac1245d 100644 --- a/cpp/tests/copying/pack_tests.cpp +++ b/cpp/tests/copying/pack_tests.cpp @@ -13,6 +13,11 @@ #include #include +// Size of the serialized table header that precedes the column entries in the +// packed metadata buffer: version + num_columns + num_rows + pad, four 4-byte fields. +// Must match `serialized_table_header` in cpp/src/copying/pack.cpp. +auto constexpr metadata_header_size = 4 * sizeof(cudf::size_type); + struct PackUnpackTest : public cudf::test::BaseFixture { void verify_column_metadata(cudf::column_view const& col, cudf::packed_metadata_view::column_view const& meta) @@ -578,6 +583,21 @@ TEST_F(PackUnpackTest, EmptyTable) } } +TEST_F(PackUnpackTest, ZeroColumnsWithRows) +{ + // A zero-column table with rows survives a pack/unpack round-trip. + cudf::table_view t{std::vector{}, 7}; + auto unpacked = cudf::unpack(cudf::pack(t)); + EXPECT_EQ(unpacked.num_columns(), 0); + EXPECT_EQ(unpacked.num_rows(), 7); + + // A genuinely empty (0, 0) table round-trips to (0, 0). + cudf::table_view empty{std::vector{}, 0}; + auto unpacked_empty = cudf::unpack(cudf::pack(empty)); + EXPECT_EQ(unpacked_empty.num_columns(), 0); + EXPECT_EQ(unpacked_empty.num_rows(), 0); +} + TEST_F(PackUnpackTest, SlicedEmpty) { // empty sliced column. this is specifically testing the corner case: @@ -638,9 +658,8 @@ TEST_F(PackUnpackTest, MetadataViewRejectsTruncatedBuffer) // Metadata has a table header plus 3 column entries. Remove the last entry so // the header still says "3 columns" but only 2 column entries remain. - auto constexpr header_size = 2 * sizeof(cudf::size_type); - auto const entry_size = (packed.metadata->size() - header_size) / 3; - auto const truncated_size = packed.metadata->size() - entry_size; + auto const entry_size = (packed.metadata->size() - metadata_header_size) / 3; + auto const truncated_size = packed.metadata->size() - entry_size; auto truncated = std::span(packed.metadata->data(), truncated_size); EXPECT_THROW(cudf::packed_metadata_view{truncated}, cudf::logic_error); @@ -653,9 +672,8 @@ TEST_F(PackUnpackTest, MetadataViewRejectsTooLongBuffer) cudf::test::fixed_width_column_wrapper col{1, 2, 3}; auto packed = cudf::pack(cudf::table_view({col})); - auto constexpr header_size = 2 * sizeof(cudf::size_type); - auto const entry_size = packed.metadata->size() - header_size; // 1 column entry - auto extended = *packed.metadata; + auto const entry_size = packed.metadata->size() - metadata_header_size; // 1 column entry + auto extended = *packed.metadata; extended.resize(packed.metadata->size() + entry_size, 0); EXPECT_THROW(cudf::packed_metadata_view{extended}, cudf::logic_error); @@ -677,9 +695,8 @@ TEST_F(PackUnpackTest, MetadataViewRejectsCorruptedChildCount) // The num_children field is the // second-to-last 4-byte value in each entry (before the trailing pad). - auto constexpr header_size = 2 * sizeof(cudf::size_type); - auto const entry_size = (corrupted.size() - header_size) / 3; // 3 column entries - auto const num_children_offset = header_size // skip table header + auto const entry_size = (corrupted.size() - metadata_header_size) / 3; // 3 column entries + auto const num_children_offset = metadata_header_size // skip table header + entry_size - 2 * sizeof(int32_t); // num_children in struct cudf::size_type bad_children = 10; std::memcpy(corrupted.data() + num_children_offset, &bad_children, sizeof(bad_children)); @@ -704,6 +721,61 @@ TEST_F(PackUnpackTest, MetadataRejectsNegativeColumnCount) cudf::logic_error); } +// num_rows follows the leading version and num_columns fields in the header. +auto constexpr num_rows_offset = 2 * sizeof(std::int32_t); + +TEST_F(PackUnpackTest, MetadataRejectsNegativeRowCount) +{ + cudf::test::fixed_width_column_wrapper col{1, 2, 3}; + auto packed = cudf::pack(cudf::table_view({col})); + + auto corrupted = *packed.metadata; + cudf::size_type const negative = -1; + std::memcpy(corrupted.data() + num_rows_offset, &negative, sizeof(negative)); + + EXPECT_THROW(cudf::packed_metadata_view{corrupted}, cudf::logic_error); + EXPECT_THROW( + cudf::unpack(corrupted.data(), reinterpret_cast(packed.gpu_data->data())), + cudf::logic_error); +} + +TEST_F(PackUnpackTest, MetadataRejectsRowCountInconsistentWithColumns) +{ + cudf::test::fixed_width_column_wrapper col{1, 2, 3}; + auto packed = cudf::pack(cudf::table_view({col})); + + // The column has 3 rows; a header row count that disagrees must be rejected. + auto corrupted = *packed.metadata; + cudf::size_type const wrong = 99; + std::memcpy(corrupted.data() + num_rows_offset, &wrong, sizeof(wrong)); + + EXPECT_THROW(cudf::packed_metadata_view{corrupted}, cudf::logic_error); + EXPECT_THROW( + cudf::unpack(corrupted.data(), reinterpret_cast(packed.gpu_data->data())), + cudf::logic_error); +} + +TEST_F(PackUnpackTest, MetadataRejectsRowCountInconsistentAcrossColumns) +{ + cudf::test::fixed_width_column_wrapper col1{1, 2, 3}; + cudf::test::fixed_width_column_wrapper col2{4, 5, 6}; + auto packed = cudf::pack(cudf::table_view({col1, col2})); + + // Leave the header and column 0 at 3 rows but corrupt column 1's size to 4. Validating only + // the first column would miss this; every top-level column must match the recorded row count. + auto corrupted = *packed.metadata; + auto const entry_size = (corrupted.size() - metadata_header_size) / 2; // 2 column entries + // size is the first field after the 8-byte data_type (two int32s) in each entry. + auto const col1_size_offset = metadata_header_size + entry_size + 2 * sizeof(std::int32_t); + cudf::size_type const wrong = 4; + std::memcpy(corrupted.data() + col1_size_offset, &wrong, sizeof(wrong)); + + EXPECT_THROW(cudf::packed_metadata_view{corrupted}, cudf::logic_error); + EXPECT_THROW( + cudf::unpack(corrupted.data(), reinterpret_cast(packed.gpu_data->data())), + cudf::logic_error); +} + TEST_F(PackUnpackTest, MetadataRejectsUnsupportedVersion) { cudf::test::fixed_width_column_wrapper col{1, 2, 3}; @@ -727,10 +799,9 @@ TEST_F(PackUnpackTest, MetadataRejectsNegativeChildCount) auto struct_col = cudf::test::structs_column_wrapper({ints, floats}); auto packed = cudf::pack(cudf::table_view({struct_col})); - auto corrupted = *packed.metadata; - auto constexpr header_size = 2 * sizeof(cudf::size_type); - auto const entry_size = (corrupted.size() - header_size) / 3; // 3 column entries - auto const num_children_offset = header_size + entry_size - 2 * sizeof(int32_t); + auto corrupted = *packed.metadata; + auto const entry_size = (corrupted.size() - metadata_header_size) / 3; // 3 column entries + auto const num_children_offset = metadata_header_size + entry_size - 2 * sizeof(int32_t); cudf::size_type const negative = -1; std::memcpy(corrupted.data() + num_children_offset, &negative, sizeof(negative)); diff --git a/cpp/tests/copying/reverse_tests.cpp b/cpp/tests/copying/reverse_tests.cpp index d7273edca98f..f19292d956ad 100644 --- a/cpp/tests/copying/reverse_tests.cpp +++ b/cpp/tests/copying/reverse_tests.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -25,6 +25,16 @@ template class ReverseTypedTestFixture : public cudf::test::BaseFixture {}; TYPED_TEST_SUITE(ReverseTypedTestFixture, cudf::test::AllTypes); + +struct ReverseZeroColumnTest : public cudf::test::BaseFixture {}; + +TEST_F(ReverseZeroColumnTest, PreservesRowCount) +{ + cudf::table_view input{std::vector{}, 6}; + auto result = cudf::reverse(input); + EXPECT_EQ(result->num_columns(), 0); + EXPECT_EQ(result->num_rows(), 6); +} TYPED_TEST(ReverseTypedTestFixture, ReverseTable) { using T = TypeParam; diff --git a/cpp/tests/copying/scatter_tests.cpp b/cpp/tests/copying/scatter_tests.cpp index f791c357586c..b23ecdcee24f 100644 --- a/cpp/tests/copying/scatter_tests.cpp +++ b/cpp/tests/copying/scatter_tests.cpp @@ -10,14 +10,27 @@ #include #include +#include #include #include +#include #include +#include class ScatterUntypedTests : public cudf::test::BaseFixture {}; +TEST_F(ScatterUntypedTests, ZeroColumnsPreservesRowCount) +{ + cudf::table_view source{std::vector{}, 2}; + cudf::table_view target{std::vector{}, 5}; + cudf::test::fixed_width_column_wrapper scatter_map{{0, 3}}; + auto result = cudf::scatter(source, scatter_map, target); + EXPECT_EQ(result->num_columns(), 0); + EXPECT_EQ(result->num_rows(), 5); +} + // Throw logic error if scatter map is longer than source TEST_F(ScatterUntypedTests, ScatterMapTooLong) { @@ -492,6 +505,35 @@ class BooleanMaskScatter : public cudf::test::BaseFixture {}; TYPED_TEST_SUITE(BooleanMaskScatter, cudf::test::FixedWidthTypes); +struct BooleanMaskScatterZeroColumn : public cudf::test::BaseFixture {}; + +TEST_F(BooleanMaskScatterZeroColumn, PreservesRowCount) +{ + cudf::table_view target{std::vector{}, 4}; + cudf::test::fixed_width_column_wrapper mask{{true, false, true, false}}; + + // Table overload: a zero-column input scattered into a zero-column target. + cudf::table_view input{std::vector{}, 2}; + auto table_result = cudf::boolean_mask_scatter(input, target, mask); + EXPECT_EQ(table_result->num_columns(), 0); + EXPECT_EQ(table_result->num_rows(), 4); + + // Scalar overload: zero columns means no scalars to scatter. + std::vector> scalars{}; + auto scalar_result = cudf::boolean_mask_scatter(scalars, target, mask); + EXPECT_EQ(scalar_result->num_columns(), 0); + EXPECT_EQ(scalar_result->num_rows(), 4); +} + +TEST_F(BooleanMaskScatterZeroColumn, TooManyTrueValuesThrows) +{ + cudf::table_view input{std::vector{}, 1}; + cudf::table_view target{std::vector{}, 3}; + // 2 true values but only 1 input row. + cudf::test::fixed_width_column_wrapper mask{{true, true, false}}; + EXPECT_THROW(cudf::boolean_mask_scatter(input, target, mask), std::invalid_argument); +} + TYPED_TEST(BooleanMaskScatter, WithNoNullElementsInTarget) { using T = TypeParam; diff --git a/cpp/tests/copying/slice_tests.cpp b/cpp/tests/copying/slice_tests.cpp index 7c02ef89b499..e3ad84b14e21 100644 --- a/cpp/tests/copying/slice_tests.cpp +++ b/cpp/tests/copying/slice_tests.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -25,6 +25,46 @@ struct SliceTest : public cudf::test::BaseFixture {}; TYPED_TEST_SUITE(SliceTest, cudf::test::NumericTypes); +struct SliceZeroColumnTest : public cudf::test::BaseFixture {}; + +TEST_F(SliceZeroColumnTest, PreservesRowCount) +{ + cudf::table_view input{std::vector{}, 10}; + std::vector indices{1, 4, 5, 9}; + auto const result = cudf::slice(input, indices); + ASSERT_EQ(result.size(), 2); + EXPECT_EQ(result[0].num_columns(), 0); + EXPECT_EQ(result[0].num_rows(), 3); + EXPECT_EQ(result[1].num_columns(), 0); + EXPECT_EQ(result[1].num_rows(), 4); +} + +TEST_F(SliceZeroColumnTest, OutOfBoundsThrows) +{ + cudf::table_view input{std::vector{}, 10}; + // end exceeds the row count + EXPECT_THROW(cudf::slice(input, std::vector{8, 12}), std::out_of_range); + // end < begin + EXPECT_THROW(cudf::slice(input, std::vector{5, 3}), std::invalid_argument); + // negative begin + EXPECT_THROW(cudf::slice(input, std::vector{-1, 4}), std::out_of_range); +} + +TEST_F(SliceZeroColumnTest, EmptyTable) +{ + cudf::table_view input{std::vector{}, 0}; + + auto const result = cudf::slice(input, std::vector{1, 4, 5, 9}); + ASSERT_EQ(result.size(), 2); + for (auto const& t : result) { + EXPECT_EQ(t.num_columns(), 0); + EXPECT_EQ(t.num_rows(), 0); + } + + // Unlike a zero-column table with rows, an empty table does not reject out-of-range indices. + EXPECT_NO_THROW(cudf::slice(input, std::vector{8, 12})); +} + TYPED_TEST(SliceTest, NumericColumnsWithNulls) { using T = TypeParam; diff --git a/cpp/tests/copying/split_tests.cpp b/cpp/tests/copying/split_tests.cpp index f7ac698c7d7c..72faa23df0c2 100644 --- a/cpp/tests/copying/split_tests.cpp +++ b/cpp/tests/copying/split_tests.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -1410,6 +1410,56 @@ using FixedWidthTypesWithoutChrono = TYPED_TEST_SUITE(ContiguousSplitTest, FixedWidthTypesWithoutChrono); +struct ContiguousSplitZeroColumnTest : public cudf::test::BaseFixture {}; + +TEST_F(ContiguousSplitZeroColumnTest, PreservesRowCount) +{ + cudf::table_view input{std::vector{}, 7}; + + // With splits: each partition's row count comes from the split boundaries, and + // unpacking preserves it. + auto const split = cudf::contiguous_split(input, {3}); + ASSERT_EQ(split.size(), 2); + EXPECT_EQ(split[0].table.num_columns(), 0); + EXPECT_EQ(split[0].table.num_rows(), 3); + EXPECT_EQ(split[1].table.num_rows(), 4); + EXPECT_EQ(cudf::unpack(split[0].data).num_rows(), 3); + EXPECT_EQ(cudf::unpack(split[1].data).num_rows(), 4); + + // No splits: a single partition with all the rows. + auto const whole = cudf::contiguous_split(input, {}); + ASSERT_EQ(whole.size(), 1); + EXPECT_EQ(whole[0].table.num_rows(), 7); + EXPECT_EQ(cudf::unpack(whole[0].data).num_rows(), 7); + + // A genuinely empty (0 columns, 0 rows) table still produces no outputs. + cudf::table_view empty{std::vector{}, 0}; + EXPECT_TRUE(cudf::contiguous_split(empty, {}).empty()); +} + +TEST_F(ContiguousSplitZeroColumnTest, InvalidSplitsThrow) +{ + cudf::table_view input{std::vector{}, 7}; + EXPECT_THROW(cudf::contiguous_split(input, {8}), std::out_of_range); // beyond row count + EXPECT_THROW(cudf::contiguous_split(input, {5, 3}), std::invalid_argument); // non-monotonic +} + +TEST_F(ContiguousSplitZeroColumnTest, ChunkedPackPreservesRowCount) +{ + cudf::table_view input{std::vector{}, 7}; + auto mr = cudf::get_current_device_resource_ref(); + auto cp = cudf::chunked_pack::create(input, 1 * 1024 * 1024, cudf::get_default_stream(), mr); + // No device data to copy: zero total size and no chunks. + EXPECT_EQ(cp->get_total_contiguous_size(), 0u); + EXPECT_FALSE(cp->has_next()); + // Metadata is still emitted and unpacks to the original row count. + auto const metadata = cp->build_metadata(); + ASSERT_NE(metadata, nullptr); + auto const unpacked = cudf::unpack(metadata->data(), nullptr); + EXPECT_EQ(unpacked.num_columns(), 0); + EXPECT_EQ(unpacked.num_rows(), 7); +} + TYPED_TEST(ContiguousSplitTest, LongColumn) { split_custom_column( diff --git a/cpp/tests/hashing/murmurhash3_x64_128_test.cpp b/cpp/tests/hashing/murmurhash3_x64_128_test.cpp index ea913b54451b..e784fd0c36ac 100644 --- a/cpp/tests/hashing/murmurhash3_x64_128_test.cpp +++ b/cpp/tests/hashing/murmurhash3_x64_128_test.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2024, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -98,3 +98,15 @@ TEST_F(MurmurHash3_x64_128_Test, StringType) 11176802453047055260ul}); CUDF_TEST_EXPECT_COLUMNS_EQUAL(output->view().column(0), expected); } + +TEST_F(MurmurHash3_x64_128_Test, ZeroColumns) +{ + auto const input = cudf::table_view{std::vector{}, 5}; + auto const output = cudf::hashing::murmurhash3_x64_128(input, 42); + + // With no columns to hash, every row's 128-bit result is the initial {seed, 0}. + cudf::test::fixed_width_column_wrapper const expected1({42, 42, 42, 42, 42}); + cudf::test::fixed_width_column_wrapper const expected2({0, 0, 0, 0, 0}); + CUDF_TEST_EXPECT_COLUMNS_EQUAL(output->view().column(0), expected1); + CUDF_TEST_EXPECT_COLUMNS_EQUAL(output->view().column(1), expected2); +} diff --git a/cpp/tests/hashing/murmurhash3_x86_32_test.cpp b/cpp/tests/hashing/murmurhash3_x86_32_test.cpp index c306e14c08ad..bb0b5cc41efa 100644 --- a/cpp/tests/hashing/murmurhash3_x86_32_test.cpp +++ b/cpp/tests/hashing/murmurhash3_x86_32_test.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2025, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -463,4 +463,12 @@ TYPED_TEST(MurmurHashTestFloatTyped, TestExtremes) CUDF_TEST_EXPECT_COLUMNS_EQUAL(*hash_col, *hash_col_neg_nan, verbosity); } +TEST_F(MurmurHashTest, ZeroColumns) +{ + auto const input = cudf::table_view{std::vector{}, 5}; + auto const output = cudf::hashing::murmurhash3_x86_32(input, 42); + cudf::test::fixed_width_column_wrapper const expected({42, 42, 42, 42, 42}); + CUDF_TEST_EXPECT_COLUMNS_EQUAL(output->view(), expected); +} + CUDF_TEST_PROGRAM_MAIN() diff --git a/cpp/tests/hashing/xxhash_32_test.cpp b/cpp/tests/hashing/xxhash_32_test.cpp index 8d1c7844f565..3a0289c18673 100644 --- a/cpp/tests/hashing/xxhash_32_test.cpp +++ b/cpp/tests/hashing/xxhash_32_test.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2024-2025, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -54,3 +54,11 @@ TEST_F(XXHash_32_Test, StringType) CUDF_TEST_EXPECT_COLUMNS_EQUAL(output->view(), expected); } + +TEST_F(XXHash_32_Test, ZeroColumns) +{ + auto const input = cudf::table_view{std::vector{}, 5}; + auto const output = cudf::hashing::xxhash_32(input, 42); + cudf::test::fixed_width_column_wrapper const expected({42u, 42u, 42u, 42u, 42u}); + CUDF_TEST_EXPECT_COLUMNS_EQUAL(output->view(), expected); +} diff --git a/cpp/tests/hashing/xxhash_64_test.cpp b/cpp/tests/hashing/xxhash_64_test.cpp index f06bd1e46d5f..6efe16c3930e 100644 --- a/cpp/tests/hashing/xxhash_64_test.cpp +++ b/cpp/tests/hashing/xxhash_64_test.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2024, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -161,3 +161,11 @@ TEST_F(XXHash_64_Test, TestFixedPoint) 8009575895491381648ul}); CUDF_TEST_EXPECT_COLUMNS_EQUAL(output->view(), expected); } + +TEST_F(XXHash_64_Test, ZeroColumns) +{ + auto const input = cudf::table_view{std::vector{}, 5}; + auto const output = cudf::hashing::xxhash_64(input, 42); + cudf::test::fixed_width_column_wrapper const expected({42ul, 42ul, 42ul, 42ul, 42ul}); + CUDF_TEST_EXPECT_COLUMNS_EQUAL(output->view(), expected); +} diff --git a/cpp/tests/interop/from_arrow_device_test.cpp b/cpp/tests/interop/from_arrow_device_test.cpp index 18823a6deee0..5de19ba0a9ac 100644 --- a/cpp/tests/interop/from_arrow_device_test.cpp +++ b/cpp/tests/interop/from_arrow_device_test.cpp @@ -81,6 +81,32 @@ TEST_F(FromArrowDeviceTest, EmptyTable) CUDF_TEST_EXPECT_TABLES_EQUAL(*got_cudf_table, from_struct); } +TEST_F(FromArrowDeviceTest, ZeroColumnsWithRows) +{ + constexpr cudf::size_type num_rows = 5; + + nanoarrow::UniqueSchema input_schema; + ArrowSchemaInit(input_schema.get()); + NANOARROW_THROW_NOT_OK(ArrowSchemaSetTypeStruct(input_schema.get(), 0)); + + nanoarrow::UniqueArray input_array; + NANOARROW_THROW_NOT_OK(ArrowArrayInitFromSchema(input_array.get(), input_schema.get(), nullptr)); + input_array->length = num_rows; + input_array->null_count = 0; + NANOARROW_THROW_NOT_OK( + ArrowArrayFinishBuilding(input_array.get(), NANOARROW_VALIDATION_LEVEL_MINIMAL, nullptr)); + + ArrowDeviceArray input; + memcpy(&input.array, input_array.get(), sizeof(ArrowArray)); + input.device_id = rmm::get_current_cuda_device().value(); + input.device_type = ARROW_DEVICE_CUDA; + input.sync_event = nullptr; + + auto got_cudf_table = cudf::from_arrow_device(input_schema.get(), &input); + EXPECT_EQ(got_cudf_table->num_columns(), 0); + EXPECT_EQ(got_cudf_table->num_rows(), num_rows); +} + TEST_F(FromArrowDeviceTest, DateTimeTable) { auto data = std::vector{1, 2, 3, 4, 5, 6}; diff --git a/cpp/tests/interop/from_arrow_host_test.cpp b/cpp/tests/interop/from_arrow_host_test.cpp index 9db3a47073fe..3cb451165f79 100644 --- a/cpp/tests/interop/from_arrow_host_test.cpp +++ b/cpp/tests/interop/from_arrow_host_test.cpp @@ -106,6 +106,31 @@ TEST_F(FromArrowHostDeviceTest, EmptyTable) CUDF_TEST_EXPECT_TABLES_EQUAL(expected_cudf_table, got_cudf_table->view()); } +TEST_F(FromArrowHostDeviceTest, ZeroColumnsWithRows) +{ + constexpr cudf::size_type num_rows = 5; + + nanoarrow::UniqueSchema input_schema; + ArrowSchemaInit(input_schema.get()); + NANOARROW_THROW_NOT_OK(ArrowSchemaSetTypeStruct(input_schema.get(), 0)); + + nanoarrow::UniqueArray input_array; + NANOARROW_THROW_NOT_OK(ArrowArrayInitFromSchema(input_array.get(), input_schema.get(), nullptr)); + input_array->length = num_rows; + input_array->null_count = 0; + NANOARROW_THROW_NOT_OK( + ArrowArrayFinishBuilding(input_array.get(), NANOARROW_VALIDATION_LEVEL_MINIMAL, nullptr)); + + ArrowDeviceArray input; + memcpy(&input.array, input_array.get(), sizeof(ArrowArray)); + input.device_id = -1; + input.device_type = ARROW_DEVICE_CPU; + + auto got_cudf_table = cudf::from_arrow_host(input_schema.get(), &input); + EXPECT_EQ(got_cudf_table->num_columns(), 0); + EXPECT_EQ(got_cudf_table->num_rows(), num_rows); +} + TEST_F(FromArrowHostDeviceTest, DateTimeTable) { auto data = std::vector{1, 2, 3, 4, 5, 6}; diff --git a/cpp/tests/io/orc_test.cpp b/cpp/tests/io/orc_test.cpp index 0ecdd55d85a3..6159facde017 100644 --- a/cpp/tests/io/orc_test.cpp +++ b/cpp/tests/io/orc_test.cpp @@ -1141,6 +1141,28 @@ TEST_F(OrcWriterTest, SlicedValidMask) cudf::test::expect_metadata_equal(expected_metadata, result.metadata); } +TEST_F(OrcReaderTest, ZeroColumnsPreservesRowCount) +{ + GTEST_SKIP() << "Zero-column / N-row ORC reads are not yet supported. See " + "https://github.com/rapidsai/cudf/issues/22935)."; + + constexpr cudf::size_type num_rows = 8; + cudf::test::fixed_width_column_wrapper col{0, 1, 2, 3, 4, 5, 6, 7}; + cudf::table_view input{{col}}; + + auto filepath = temp_env->get_temp_filepath("OrcZeroColumns.orc"); + cudf::io::write_orc(cudf::io::orc_writer_options::builder(cudf::io::sink_info{filepath}, input)); + + // Project no columns: the result should be (num_rows, 0), not (0, 0). + auto in_opts = cudf::io::orc_reader_options::builder(cudf::io::source_info{filepath}) + .columns(std::vector{}) + .build(); + auto result = cudf::io::read_orc(in_opts); + + EXPECT_EQ(result.tbl->view().num_columns(), 0); + EXPECT_EQ(result.tbl->view().num_rows(), num_rows); +} + TEST_F(OrcReaderTest, SingleInputs) { srand(31533); diff --git a/cpp/tests/io/parquet_reader_test.cpp b/cpp/tests/io/parquet_reader_test.cpp index 46a8c4362971..3ad680861e12 100644 --- a/cpp/tests/io/parquet_reader_test.cpp +++ b/cpp/tests/io/parquet_reader_test.cpp @@ -125,6 +125,29 @@ TEST_F(ParquetReaderTest, UserBounds) } } +TEST_F(ParquetReaderTest, ZeroColumnsPreservesRowCount) +{ + GTEST_SKIP() << "Zero-column / N-row parquet reads are not yet supported. See " + "https://github.com/rapidsai/cudf/issues/22935)."; + + srand(31337); + auto const num_rows = 8; + auto expected = create_random_fixed_table(4, num_rows, false); + + auto filepath = temp_env->get_temp_filepath("ZeroColumns.parquet"); + cudf::io::write_parquet( + cudf::io::parquet_writer_options::builder(cudf::io::sink_info{filepath}, *expected)); + + // Project no columns: the result should be (num_rows, 0), not (0, 0). + auto read_opts = cudf::io::parquet_reader_options::builder(cudf::io::source_info{filepath}) + .column_names(std::vector{}) + .build(); + auto result = cudf::io::read_parquet(read_opts); + + EXPECT_EQ(result.tbl->view().num_columns(), 0); + EXPECT_EQ(result.tbl->view().num_rows(), num_rows); +} + TEST_F(ParquetReaderTest, UserBoundsWithNulls) { // clang-format off diff --git a/cpp/tests/partitioning/round_robin_test.cpp b/cpp/tests/partitioning/round_robin_test.cpp index 33e526ea9cb0..1fba2c0db002 100644 --- a/cpp/tests/partitioning/round_robin_test.cpp +++ b/cpp/tests/partitioning/round_robin_test.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -28,6 +28,19 @@ class RoundRobinTest : public cudf::test::BaseFixture {}; TYPED_TEST_SUITE(RoundRobinTest, cudf::test::FixedWidthTypes); +struct RoundRobinZeroColumnTest : public cudf::test::BaseFixture {}; + +TEST_F(RoundRobinZeroColumnTest, PreservesRowCount) +{ + cudf::table_view input{std::vector{}, 7}; + auto const [result, offsets] = cudf::round_robin_partition(input, 3); + EXPECT_EQ(result->num_columns(), 0); + EXPECT_EQ(result->num_rows(), 7); + // 7 rows over 3 partitions deals sizes {3, 2, 2}, so the round-robin boundaries must hold + // even with no columns. + EXPECT_EQ(offsets, (std::vector{0, 3, 5, 7})); +} + TYPED_TEST(RoundRobinTest, EmptyInput) { auto const empty_column = fixed_width_column_wrapper{}; diff --git a/cpp/tests/stream_compaction/apply_boolean_mask_tests.cpp b/cpp/tests/stream_compaction/apply_boolean_mask_tests.cpp index da8908e9bb21..81f68b99cad9 100644 --- a/cpp/tests/stream_compaction/apply_boolean_mask_tests.cpp +++ b/cpp/tests/stream_compaction/apply_boolean_mask_tests.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -24,6 +24,15 @@ struct ApplyBooleanMask : public cudf::test::BaseFixture {}; +TEST_F(ApplyBooleanMask, ZeroColumnsPreservesRowCount) +{ + cudf::table_view input{std::vector{}, 4}; + cudf::test::fixed_width_column_wrapper boolean_mask{{true, false, true, true}}; + auto got = cudf::apply_boolean_mask(input, boolean_mask); + EXPECT_EQ(got->num_columns(), 0); + EXPECT_EQ(got->num_rows(), 3); +} + TEST_F(ApplyBooleanMask, NonNullBooleanMask) { cudf::test::fixed_width_column_wrapper col1{{true, false, true, false, true, false}, diff --git a/cpp/tests/table/table_tests.cpp b/cpp/tests/table/table_tests.cpp index c7d37b943b65..71fda287d8e4 100644 --- a/cpp/tests/table/table_tests.cpp +++ b/cpp/tests/table/table_tests.cpp @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION. + * SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -45,6 +45,108 @@ TEST_F(TableTest, EmptyColumnedTable) EXPECT_EQ(input.num_columns(), expected); } +TEST_F(TableTest, ZeroColumnConstructors) +{ + // A zero-column table_view and owning table can carry an explicit row count. + TView view{std::vector{}, 42}; + EXPECT_EQ(view.num_columns(), 0); + EXPECT_EQ(view.num_rows(), 42); + + Table owning(CVector{}, 7); + EXPECT_EQ(owning.num_columns(), 0); + EXPECT_EQ(owning.num_rows(), 7); + + // With columns present, the explicit row count must match every column. + column_wrapper col{{1, 2, 3}}; + std::vector cols{col}; + EXPECT_NO_THROW((TView{cols, 3})); + EXPECT_THROW((TView{cols, 4}), std::invalid_argument); + { + CVector owning_cols; + owning_cols.push_back(column_wrapper{1, 2, 3}.release()); + EXPECT_NO_THROW(Table(std::move(owning_cols), 3)); + } + { + CVector owning_cols; + owning_cols.push_back(column_wrapper{1, 2, 3}.release()); + EXPECT_THROW(Table(std::move(owning_cols), 4), std::invalid_argument); + } + + // A negative explicit row count is rejected by both. + EXPECT_THROW((TView{std::vector{}, -1}), std::invalid_argument); + EXPECT_THROW(Table(CVector{}, -1), std::invalid_argument); + + // mutable_table_view exposes the same explicit row-count overload. + using MView = cudf::mutable_table_view; + MView mview{std::vector{}, 42}; + EXPECT_EQ(mview.num_columns(), 0); + EXPECT_EQ(mview.num_rows(), 42); + + auto owned = column_wrapper{1, 2, 3}.release(); + std::vector mcols{owned->mutable_view()}; + EXPECT_NO_THROW((MView{mcols, 3})); + EXPECT_THROW((MView{mcols, 4}), std::invalid_argument); + EXPECT_THROW((MView{std::vector{}, -1}), std::invalid_argument); +} + +TEST_F(TableTest, ZeroColumnRowCountPropagation) +{ + // An owning table built from a zero-column view keeps its rows, and view() reports them. + Table owning(TView{std::vector{}, 5}); + EXPECT_EQ(owning.num_columns(), 0); + EXPECT_EQ(owning.num_rows(), 5); + EXPECT_EQ(owning.view().num_rows(), 5); + + // Converting a zero-column mutable_table_view to a table_view keeps the rows. + cudf::table_view tv = owning.mutable_view(); // operator table_view() + EXPECT_EQ(tv.num_columns(), 0); + EXPECT_EQ(tv.num_rows(), 5); +} + +TEST_F(TableTest, SelectEmptyPreservesRowCount) +{ + // Selecting no columns from an N-row table yields an (N, 0) view, not (0, 0). + CVector cols; + cols.push_back(column_wrapper{1, 2, 3, 4, 5}.release()); + Table owning(std::move(cols)); + ASSERT_EQ(owning.num_rows(), 5); + + auto const owning_sel = owning.select(std::vector{}); + EXPECT_EQ(owning_sel.num_columns(), 0); + EXPECT_EQ(owning_sel.num_rows(), 5); + + auto const view_sel = owning.view().select(std::vector{}); + EXPECT_EQ(view_sel.num_columns(), 0); + EXPECT_EQ(view_sel.num_rows(), 5); +} + +TEST_F(TableTest, ZeroColumnViewConcatPreservesRowCount) +{ + // Horizontally concatenating zero-column views keeps the row count: (5, 0). + TView a{std::vector{}, 5}; + TView b{std::vector{}, 5}; + cudf::table_view combined{std::vector{a, b}}; + EXPECT_EQ(combined.num_columns(), 0); + EXPECT_EQ(combined.num_rows(), 5); +} + +TEST_F(TableTest, ZeroColumnViewConcatRowCountMismatchThrows) +{ + // All zero-column views, differing row counts. + { + TView a{std::vector{}, 5}; + TView b{std::vector{}, 3}; + EXPECT_THROW((cudf::table_view{std::vector{a, b}}), cudf::logic_error); + } + // Mixed: a zero-column view and a populated view with a different row count. + { + TView a{std::vector{}, 5}; + column_wrapper col{{1, 2, 3}}; // 3 rows + TView b{std::vector{col}}; + EXPECT_THROW((cudf::table_view{std::vector{a, b}}), cudf::logic_error); + } +} + TEST_F(TableTest, ValidateConstructorTableViewToTable) { column_wrapper col1{{1, 2, 3, 4}}; diff --git a/java/src/main/native/src/TableJni.cpp b/java/src/main/native/src/TableJni.cpp index 41adede55ae4..6fd64ebbb1ef 100644 --- a/java/src/main/native/src/TableJni.cpp +++ b/java/src/main/native/src/TableJni.cpp @@ -4755,7 +4755,7 @@ Java_ai_rapids_cudf_Table_contiguousSplitGroups(JNIEnv* env, 0); } - auto keys = input_table->select(key_indices); + auto keys = key_indices.empty() ? cudf::table_view{} : input_table->select(key_indices); auto null_handling = jignore_null_keys ? cudf::null_policy::EXCLUDE : cudf::null_policy::INCLUDE; auto keys_are_sorted = jkey_sorted ? cudf::sorted::YES : cudf::sorted::NO; diff --git a/python/pylibcudf/pylibcudf/libcudf/table/table_view.pxd b/python/pylibcudf/pylibcudf/libcudf/table/table_view.pxd index 6eae80639f5b..81f0ea4cdc98 100644 --- a/python/pylibcudf/pylibcudf/libcudf/table/table_view.pxd +++ b/python/pylibcudf/pylibcudf/libcudf/table/table_view.pxd @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2020-2024, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from libcpp.vector cimport vector from pylibcudf.exception_handler cimport libcudf_exception_handler @@ -13,6 +13,9 @@ cdef extern from "cudf/table/table_view.hpp" namespace "cudf" nogil: cdef cppclass table_view: table_view() except +libcudf_exception_handler table_view(const vector[column_view]) except +libcudf_exception_handler + table_view( + const vector[column_view], size_type num_rows + ) except +libcudf_exception_handler column_view column(size_type column_index) except +libcudf_exception_handler size_type num_columns() except +libcudf_exception_handler size_type num_rows() except +libcudf_exception_handler diff --git a/python/pylibcudf/pylibcudf/table.pxd b/python/pylibcudf/pylibcudf/table.pxd index 0cc711f5f567..dc459f9245d2 100644 --- a/python/pylibcudf/pylibcudf/table.pxd +++ b/python/pylibcudf/pylibcudf/table.pxd @@ -4,11 +4,13 @@ from libcpp.memory cimport unique_ptr from pylibcudf.libcudf.table.table cimport table from pylibcudf.libcudf.table.table_view cimport table_view +from pylibcudf.libcudf.types cimport size_type from rmm.pylibrmm.memory_resource cimport DeviceMemoryResource cdef class Table: # Tuple[pylibcudf.Column] cdef tuple _columns + cdef size_type _num_rows cdef table_view view(self) diff --git a/python/pylibcudf/pylibcudf/table.pyi b/python/pylibcudf/pylibcudf/table.pyi index fad9f9f4df7c..0d68cddf292b 100644 --- a/python/pylibcudf/pylibcudf/table.pyi +++ b/python/pylibcudf/pylibcudf/table.pyi @@ -12,7 +12,9 @@ from pylibcudf.types import DataType from pylibcudf.utils import CudaStreamLike class Table: - def __init__(self, columns: Sequence[Column]): ... + def __init__( + self, columns: Sequence[Column], num_rows: int | None = None + ): ... def num_columns(self) -> int: ... def num_rows(self) -> int: ... def shape(self) -> tuple[int, int]: ... diff --git a/python/pylibcudf/pylibcudf/table.pyx b/python/pylibcudf/pylibcudf/table.pyx index fa83fc00fc74..3bbf318b6834 100644 --- a/python/pylibcudf/pylibcudf/table.pyx +++ b/python/pylibcudf/pylibcudf/table.pyx @@ -29,6 +29,7 @@ from pylibcudf.libcudf.interop cimport ( ) from pylibcudf.libcudf.table.table cimport table from pylibcudf.libcudf.table.table_view cimport table_view +from pylibcudf.libcudf.types cimport size_type from .column cimport Column from .types cimport DataType @@ -62,18 +63,36 @@ cdef class _ArrowTableHolder: cdef class Table: """A list of columns of the same size. + If the list of columns is empty, the table's row count may still be non-zero. + Parameters ---------- columns : Sequence[Column] The columns in this table. + num_rows : int | None + Optional explicit row count. Only used to preserve the row count of a + table with zero columns. When `columns` is non-empty, `num_rows` must + equal the size of every column. """ __hash__ = None - def __init__(self, columns): + def __init__(self, columns, num_rows=None): columns = tuple(columns) if not all(isinstance(c, Column) for c in columns): raise ValueError("All columns must be pylibcudf Column objects") self._columns = columns + if num_rows is None: + self._num_rows = columns[0].size() if len(columns) else 0 + else: + if not isinstance(num_rows, int): + raise TypeError("num_rows must be an int or None") + if num_rows < 0: + raise ValueError("num_rows cannot be negative") + if any(c.size() != num_rows for c in columns): + raise ValueError( + "num_rows does not match the size of the provided columns" + ) + self._num_rows = num_rows def to_arrow( self, @@ -231,7 +250,7 @@ cdef class Table: for col in self._columns: c_columns.push_back(( col).view()) - return table_view(c_columns) + return table_view(c_columns, self.num_rows()) @staticmethod cdef Table from_libcudf( @@ -247,13 +266,17 @@ cdef class Table: """ assert stream is not None, "stream cannot be None" assert mr is not None, "mr cannot be None" + # Capture the row count before release() (which zeroes it) so a + # zero-column table preserves its rows. + cdef size_type nrows = dereference(libcudf_tbl).num_rows() cdef vector[unique_ptr[column]] c_columns = dereference(libcudf_tbl).release() cdef vector[unique_ptr[column]].size_type i - return Table([ + cols = [ Column.from_libcudf(move(c_columns[i]), stream, mr) for i in range(c_columns.size()) - ]) + ] + return Table(cols, num_rows=nrows) @staticmethod cdef Table from_table_view(const table_view& tv, Table owner): @@ -266,10 +289,13 @@ cdef class Table: (even direct pylibcudf Cython users). """ cdef int i - return Table([ - Column.from_column_view(tv.column(i), owner.columns()[i]) - for i in range(tv.num_columns()) - ]) + return Table( + [ + Column.from_column_view(tv.column(i), owner.columns()[i]) + for i in range(tv.num_columns()) + ], + num_rows=tv.num_rows(), + ) # Ideally this function would simply be handled via a fused type in # from_table_view, but this does not work due to @@ -296,10 +322,13 @@ cdef class Table: assert not isinstance(owner, Table) cdef int i cdef Stream _stream = stream - return Table([ - Column.from_column_view_of_arbitrary(tv.column(i), owner, _stream) - for i in range(tv.num_columns()) - ]) + return Table( + [ + Column.from_column_view_of_arbitrary(tv.column(i), owner, _stream) + for i in range(tv.num_columns()) + ], + num_rows=tv.num_rows(), + ) cpdef int num_columns(self): """The number of columns in this table.""" @@ -307,9 +336,7 @@ cdef class Table: cpdef int num_rows(self): """The number of rows in this table.""" - if self.num_columns() == 0: - return 0 - return self._columns[0].size() + return self._num_rows cpdef tuple columns(self): """The columns in this table.""" @@ -325,6 +352,7 @@ cdef class Table: """ cdef list columns = list(self._columns) self._columns = () + self._num_rows = 0 return columns cpdef tuple shape(self): @@ -348,7 +376,10 @@ cdef class Table: """ cdef Stream _stream = _get_stream(stream) mr = _get_memory_resource(mr) - return Table([col.copy(_stream, mr) for col in self._columns]) + return Table( + [col.copy(_stream, mr) for col in self._columns], + num_rows=self.num_rows(), + ) def _to_schema(self, metadata=None): """Create an Arrow schema from this table.""" diff --git a/python/pylibcudf/tests/test_contiguous_split.py b/python/pylibcudf/tests/test_contiguous_split.py index c24ebc1e2263..adebfb99258c 100644 --- a/python/pylibcudf/tests/test_contiguous_split.py +++ b/python/pylibcudf/tests/test_contiguous_split.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import string @@ -84,6 +84,27 @@ def test_chunked_pack(bufsize, stream): assert_table_eq(h_table, result) +def test_pack_and_unpack_zero_columns_with_rows(): + tbl = plc.Table([], num_rows=10) + packed = plc.contiguous_split.pack(tbl) + + res = plc.contiguous_split.unpack(packed) + assert res.num_columns() == 0 + assert res.num_rows() == 10 + + +def test_pack_and_unpack_from_memoryviews_zero_columns_with_rows(): + tbl = plc.Table([], num_rows=10) + packed = plc.contiguous_split.pack(tbl) + + metadata, gpudata = packed.release() + del packed + + res = plc.contiguous_split.unpack_from_memoryviews(metadata, gpudata) + assert res.num_columns() == 0 + assert res.num_rows() == 10 + + def test_unpack_from_memoryviews_empty_metadata_non_empty_data(): empty_metadata = memoryview(b"") non_empty_data = plc.gpumemoryview(rmm.DeviceBuffer(size=64)) diff --git a/python/pylibcudf/tests/test_copying.py b/python/pylibcudf/tests/test_copying.py index 6df21356523a..efe638b97e02 100644 --- a/python/pylibcudf/tests/test_copying.py +++ b/python/pylibcudf/tests/test_copying.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import pyarrow as pa @@ -1038,3 +1038,38 @@ def test_get_element_out_of_bounds(input_column): _, plc_input_column = input_column with cudf_raises(IndexError): plc.copying.get_element(plc_input_column, 100) + + +def test_gather_zero_columns_preserves_num_rows(): + source = plc.Table([], num_rows=5) + gather_map = plc.Column.from_arrow(pa.array([0, 2, 4, 1], type=pa.int32())) + result = plc.copying.gather( + source, gather_map, plc.copying.OutOfBoundsPolicy.DONT_CHECK + ) + assert result.num_columns() == 0 + assert result.num_rows() == 4 + + +def test_scatter_zero_columns_preserves_num_rows(): + source = plc.Table([], num_rows=2) + target = plc.Table([], num_rows=5) + scatter_map = plc.Column.from_arrow(pa.array([0, 3], type=pa.int32())) + result = plc.copying.scatter(source, scatter_map, target) + assert result.num_columns() == 0 + assert result.num_rows() == 5 + + +def test_slice_zero_columns_preserves_num_rows(): + result = plc.copying.slice(plc.Table([], num_rows=10), [1, 4, 5, 9]) + assert len(result) == 2 + assert result[0].num_columns() == 0 + assert result[0].num_rows() == 3 + assert result[1].num_rows() == 4 + + +def test_split_zero_columns_preserves_num_rows(): + result = plc.copying.split(plc.Table([], num_rows=10), [4]) + assert len(result) == 2 + assert result[0].num_columns() == 0 + assert result[0].num_rows() == 4 + assert result[1].num_rows() == 6 diff --git a/python/pylibcudf/tests/test_filling.py b/python/pylibcudf/tests/test_filling.py index b18b0271f0bf..bda822c10c87 100644 --- a/python/pylibcudf/tests/test_filling.py +++ b/python/pylibcudf/tests/test_filling.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 from datetime import datetime @@ -102,3 +102,9 @@ def test_calendrical_month_sequence(): ] expect = pa.array(expected_dates, type=pa.timestamp("ms")) assert_column_eq(result, expect) + + +def test_repeat_zero_columns_preserves_num_rows(): + result = plc.filling.repeat(plc.Table([], num_rows=3), 2) + assert result.num_columns() == 0 + assert result.num_rows() == 6 diff --git a/python/pylibcudf/tests/test_partitioning.py b/python/pylibcudf/tests/test_partitioning.py index 233121f7b070..2f75080b9353 100644 --- a/python/pylibcudf/tests/test_partitioning.py +++ b/python/pylibcudf/tests/test_partitioning.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import pyarrow as pa @@ -59,3 +59,13 @@ def test_round_robin_partition(partitioning_data): assert_table_eq(expect, got) # Should return num_partitions + 1 offsets: [0, 3] for 1 partition with 3 rows assert offsets == [0, 3] + + +def test_round_robin_partition_zero_columns_preserves_num_rows(): + result, offsets = plc.partitioning.round_robin_partition( + plc.Table([], num_rows=7), 3 + ) + assert result.num_columns() == 0 + assert result.num_rows() == 7 + # 7 rows over 3 partitions deals sizes {3, 2, 2}: boundaries hold with no columns. + assert offsets == [0, 3, 5, 7] diff --git a/python/pylibcudf/tests/test_reshape.py b/python/pylibcudf/tests/test_reshape.py index e0c50542de7b..4ec2db6864f1 100644 --- a/python/pylibcudf/tests/test_reshape.py +++ b/python/pylibcudf/tests/test_reshape.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import cupy as cp @@ -71,3 +71,9 @@ def test_table_to_array(dtype, type_id, patch_cupy_stream): with patch_cupy_stream: expect = cp.array([[1, 4], [2, 5], [3, 6]], dtype=dtype) cp.testing.assert_array_equal(expect, got) + + +def test_tile_zero_columns_preserves_num_rows(): + result = plc.reshape.tile(plc.Table([], num_rows=3), 4) + assert result.num_columns() == 0 + assert result.num_rows() == 12 diff --git a/python/pylibcudf/tests/test_stream_compaction.py b/python/pylibcudf/tests/test_stream_compaction.py index ccf21c2a6b31..539642ddc8bf 100644 --- a/python/pylibcudf/tests/test_stream_compaction.py +++ b/python/pylibcudf/tests/test_stream_compaction.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import pyarrow as pa @@ -67,3 +67,11 @@ def test_apply_deletion_mask(): ) expected = pa.table({"a": pa.array([2, 4], type=pa.int32())}) assert_table_eq(expected, result) + + +def test_apply_boolean_mask_zero_columns_preserves_num_rows(): + source = plc.Table([], num_rows=4) + mask = plc.Column.from_arrow(pa.array([True, False, True, True])) + result = plc.stream_compaction.apply_boolean_mask(source, mask) + assert result.num_columns() == 0 + assert result.num_rows() == 3 diff --git a/python/pylibcudf/tests/test_table.py b/python/pylibcudf/tests/test_table.py index a487c7fa8f83..91ff25055565 100644 --- a/python/pylibcudf/tests/test_table.py +++ b/python/pylibcudf/tests/test_table.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 import pyarrow as pa @@ -53,3 +53,61 @@ def test_table_copy(table_data): assert orig_col is not copy_col assert_table_eq(original.to_arrow(), copied) + + +@pytest.mark.parametrize( + "kwargs, expected_rows", [({}, 0), ({"num_rows": 5}, 5)] +) +def test_zero_column_table_num_rows(kwargs, expected_rows): + tbl = plc.Table([], **kwargs) + assert tbl.num_columns() == 0 + assert tbl.num_rows() == expected_rows + assert tbl.shape() == (expected_rows, 0) + # The row count is preserved by copy(). + assert tbl.copy().num_rows() == expected_rows + + +@pytest.mark.parametrize( + "num_rows, exc", + [(-1, ValueError), (2**31, OverflowError), (3.5, TypeError)], +) +def test_zero_column_table_invalid_num_rows_raises(num_rows, exc): + with pytest.raises(exc): + plc.Table([], num_rows=num_rows) + + +def test_table_num_rows_mismatch_raises(): + col3 = plc.Column.from_arrow(pa.array([1, 2, 3])) + col4 = plc.Column.from_arrow(pa.array([1, 2, 3, 4])) + # num_rows does not match the single column. + with pytest.raises(ValueError): + plc.Table([col3], num_rows=4) + # num_rows matches the first column but not the second; both must agree. + with pytest.raises(ValueError): + plc.Table([col3, col4], num_rows=3) + + +def test_zero_column_table_concatenate_sums_rows(): + result = plc.concatenate.concatenate( + [plc.Table([], num_rows=7), plc.Table([], num_rows=5)] + ) + assert result.num_columns() == 0 + assert result.num_rows() == 12 + + +def test_from_arrow_zero_column_preserves_num_rows(): + batch = pa.RecordBatch.from_struct_array( + pa.array([{}] * 5, type=pa.struct([])) + ) + arrow_tbl = pa.Table.from_batches([batch]) + assert arrow_tbl.shape == (5, 0) + + tbl = plc.Table.from_arrow(arrow_tbl) + assert tbl.num_columns() == 0 + assert tbl.num_rows() == 5 + + +def test_to_arrow_zero_column_preserves_num_rows(): + arrow = plc.Table([], num_rows=5).to_arrow() + assert arrow.num_columns == 0 + assert arrow.num_rows == 5