Let callers reserve partitioning memory - #23833
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe change adds cost-estimation APIs and caller-provided memory reservations for partitioning, splitting, and unpacking in C++ and Python. It updates stream types, reservation handling, bindings, public declarations, exports, and regression tests. ChangesPartition memory APIs
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🔴 Critical · up to The change adds caller-managed memory reservations, but the current implementation still has binding declarations inconsistent with the C++ APIs, which can block builds, and failed reservations may consume caller-owned partition data so the operation cannot be retried safely. These issues should be fixed before merging. Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 12.90% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 31 functions across 6 files. (1 skipped: 1 unsupported.) Full details: Title checkExplanation The title accurately identifies the central change: callers can provide reservations for partitioning memory. It does not mention splitting, unpacking, or cost-estimation APIs, but those are supporting aspects of the changeset.
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
…e-partitioning-memory
…e-partitioning-memory
| std::unordered_map<rapidsmpf::shuffler::PartID, rapidsmpf::PackedData> split_and_pack_impl( | ||
| cudf::table_view const& table, | ||
| std::vector<cudf::size_type> const& splits, | ||
| rmm::cuda_stream_view stream, | ||
| rapidsmpf::BufferResource* br, | ||
| rapidsmpf::AllowOverbooking allow_overbooking, | ||
| rapidsmpf::MemoryReservation* reservation); |
There was a problem hiding this comment.
I think we can request a mandatory reservation in every *_impl methods.
| std::unordered_map<rapidsmpf::shuffler::PartID, rapidsmpf::PackedData> split_and_pack_impl( | |
| cudf::table_view const& table, | |
| std::vector<cudf::size_type> const& splits, | |
| rmm::cuda_stream_view stream, | |
| rapidsmpf::BufferResource* br, | |
| rapidsmpf::AllowOverbooking allow_overbooking, | |
| rapidsmpf::MemoryReservation* reservation); | |
| std::unordered_map<rapidsmpf::shuffler::PartID, rapidsmpf::PackedData> split_and_pack_impl( | |
| cudf::table_view const& table, | |
| std::vector<cudf::size_type> const& splits, | |
| rmm::cuda_stream_view stream, | |
| rapidsmpf::MemoryReservation& reservation); |
There was a problem hiding this comment.
The signature is nicer, br and allow_overbooking both fall out since MemoryReservation carries br(). But it moves the reserving up into the public non-reservation overload, and that costs the callers who use it today, which is everything: the benchmarks, the tests and cudf-polars. They would pay an extra packed_size() sync, and hold twice the peak claim, since partition_and_pack() currently reserves the reorder and the pack in sequence rather than together.
There was a problem hiding this comment.
No I meant in the, *_impl methods only. We have,
auto partition_and_pack_impl(..., res); // anon ns
auto partition_and_pack(..., br, ...){ // original API
// create res
// call impl
}
auto partition_and_pack(..., res, ...) { // new API
// call impl
}Maybe that way, we dont really need an impl as well?
There was a problem hiding this comment.
Ah, I see what you mean. I was hesitant because it would worsen the repeated cudf::packed_size() calls @wence- mentions. But both are handled now: it does what you suggest, and an optional packed_bytes argument lets the size be computed once and passed down rather than recomputed in each phase.
`MemoryReservation::split(size)` reduces a reservation by `size` and returns a new reservation of that size on the same buffer resource and memory type. The total reserved by the buffer resource is unchanged, the bytes only move between the two reservations. This lets a caller scope part of a reservation to the allocation it covers. The returned reservation releases its bytes when it goes out of scope, so the reservation is never counted on top of memory that has already been allocated, and there is no size argument at the release point to disagree with what was allocated. The alternative is `BufferResource::release(reservation, size)`, which works but leaves the caller to pick the release point and to repeat the size. `split()` also composes with a caller-provided reservation: a function can carve off what it needs without caring whether the caller supplied the reservation or it made one itself. Adopted by cudf's `partition_and_pack()` and friends, which take a caller-provided reservation and split off one sub-reservation per allocation phase: NVIDIA/cudf#23833 Authors: - Mads R. B. Kristensen (https://github.com/madsbk) Approvers: - Niranda Perera (https://github.com/nirandaperera) URL: #1177
…e-partitioning-memory
…e-partitioning-memory
nirandaperera
left a comment
There was a problem hiding this comment.
@madsbk Last change requests! Promise :inno
| #include <cudf/partitioning.hpp> | ||
| #include <cudf/table/table.hpp> | ||
|
|
||
| #include <cuda/stream_ref> |
There was a problem hiding this comment.
stream_ref header is deprecated 😞
| #include <cuda/stream_ref> | |
| #include <cuda/stream> |
| if (!packed_bytes.has_value()) { | ||
| packed_bytes = table.num_rows() == 0 ? 0 : cudf::packed_size(table, stream, temp_mr); | ||
| } |
There was a problem hiding this comment.
nit. empty tables are handled by packed_size.
https://github.com/NVIDIA/cudf/blob/main/cpp/src/copying/contiguous_split.cu#L2216
| if (!packed_bytes.has_value()) { | |
| packed_bytes = table.num_rows() == 0 ? 0 : cudf::packed_size(table, stream, temp_mr); | |
| } | |
| if (!packed_bytes.has_value()) { | |
| packed_bytes = cudf::packed_size(table, stream, temp_mr); | |
| } |
| rapidsmpf::BufferResource* br, | ||
| rapidsmpf::MemoryReservation& reservation, | ||
| std::optional<std::size_t> packed_bytes) | ||
| { | ||
| RAPIDSMPF_EXPECTS(reservation.mem_type() == rapidsmpf::MemoryType::DEVICE, | ||
| "reservation must be for device memory", | ||
| std::invalid_argument); | ||
| RAPIDSMPF_EXPECTS(reservation.br() == br, | ||
| "reservation must belong to the given buffer resource", | ||
| std::invalid_argument); |
There was a problem hiding this comment.
| rapidsmpf::BufferResource* br, | |
| rapidsmpf::MemoryReservation& reservation, | |
| std::optional<std::size_t> packed_bytes) | |
| { | |
| RAPIDSMPF_EXPECTS(reservation.mem_type() == rapidsmpf::MemoryType::DEVICE, | |
| "reservation must be for device memory", | |
| std::invalid_argument); | |
| RAPIDSMPF_EXPECTS(reservation.br() == br, | |
| "reservation must belong to the given buffer resource", | |
| std::invalid_argument); | |
| rapidsmpf::MemoryReservation& reservation, | |
| std::optional<std::size_t> packed_bytes) | |
| { | |
| auto br = reservation.br(); | |
| RAPIDSMPF_EXPECTS(reservation.mem_type() == rapidsmpf::MemoryType::DEVICE, | |
| "reservation must be for device memory", | |
| std::invalid_argument); |
| rapidsmpf::BufferResource* br, | ||
| rapidsmpf::MemoryReservation& reservation, | ||
| std::optional<std::size_t> packed_bytes) | ||
| { | ||
| RAPIDSMPF_EXPECTS(reservation.mem_type() == rapidsmpf::MemoryType::DEVICE, | ||
| "reservation must be for device memory", | ||
| std::invalid_argument); | ||
| RAPIDSMPF_EXPECTS(reservation.br() == br, | ||
| "reservation must belong to the given buffer resource", | ||
| std::invalid_argument); |
There was a problem hiding this comment.
| rapidsmpf::BufferResource* br, | |
| rapidsmpf::MemoryReservation& reservation, | |
| std::optional<std::size_t> packed_bytes) | |
| { | |
| RAPIDSMPF_EXPECTS(reservation.mem_type() == rapidsmpf::MemoryType::DEVICE, | |
| "reservation must be for device memory", | |
| std::invalid_argument); | |
| RAPIDSMPF_EXPECTS(reservation.br() == br, | |
| "reservation must belong to the given buffer resource", | |
| std::invalid_argument); | |
| rapidsmpf::MemoryReservation& reservation, | |
| std::optional<std::size_t> packed_bytes) | |
| { | |
| auto br = reservation.br(); | |
| RAPIDSMPF_EXPECTS(reservation.mem_type() == rapidsmpf::MemoryType::DEVICE, | |
| "reservation must be for device memory", | |
| std::invalid_argument); |
| rapidsmpf::BufferResource* br, | ||
| rapidsmpf::MemoryReservation& reservation) |
| // Checked up front so an undersized reservation is caught before the first split | ||
| // mutates it, leaving the caller free to reserve more and retry. | ||
| check_reservation(reservation, total_size + non_device_size); | ||
|
|
||
| // The unspill consumes its reservation as it moves each partition, the concatenation | ||
| // only needs its bytes accounted for until it has allocated them. | ||
| auto unspill_res = reservation.split(non_device_size); | ||
| auto concat_res = reservation.split(total_size); |
There was a problem hiding this comment.
I think these are redundant. Splitting the reservation for total_size + non_device_size should cover all these
| // Checked up front so an undersized reservation is caught before the first split | |
| // mutates it, leaving the caller free to reserve more and retry. | |
| check_reservation(reservation, total_size + non_device_size); | |
| // The unspill consumes its reservation as it moves each partition, the concatenation | |
| // only needs its bytes accounted for until it has allocated them. | |
| auto unspill_res = reservation.split(non_device_size); | |
| auto concat_res = reservation.split(total_size); | |
| // Checked up front so an undersized reservation is caught before the first split | |
| // mutates it, leaving the caller free to reserve more and retry. | |
| auto scoped_res = reservation.split(total_size + non_device_size); |
| * Computed here otherwise, which syncs the stream. | ||
| * @return A pair of the packed size and the total cost, both in bytes. | ||
| */ | ||
| [[nodiscard]] std::pair<std::size_t, std::size_t> packed_and_total_size( |
There was a problem hiding this comment.
Nit. wondering if we really need this 😇
partition_and_pack(),split_and_pack()andunpack_and_concat()can now take a caller-providedMemoryReservation&instead of reserving and spilling internally. That lets a caller reserve before it starts, so the point where it might block is explicit rather than buried inside the call.partition_and_pack_cost(),split_and_pack_cost()andunpack_and_concat_cost()return the peak device memory each function needs. All six are bound incudf_streaming.partition_utils, where the reservation is an optional trailing argument, so existing callers are unaffected.Nothing calls the new overloads yet beyond the tests. cudf-polars picks them up in the follow-up #23834, which gives the shuffle memory backpressure on both the insert and the extract side.
Notes
Only the unspill share of
unpack_and_concat_cost()is exact, since the buffer resource consumes it while moving each partition. The rest is an estimate, because libcudf allocates againstBufferResource::device_mr()and never sees the reservation.split_and_pack_cost()in particular under-reports for more than one partition, sincecontiguous_split()aligns every column buffer of every partition. That was true of the internal reservations before this change too.unpack_and_concat_cost()has an overload taking a vector of pointers, so Cython can compute the cost without moving the partitions out of their Python owners.