Add decimal division functionality with scale preservation - #19861
Add decimal division functionality with scale preservation#19861a-hirota wants to merge 2 commits into
Conversation
ab27895 to
60fce03
Compare
There was a problem hiding this comment.
Pull Request Overview
This PR implements decimal division functionality with scale preservation, addressing issue #17448. The implementation follows Java's BigDecimal.divide() behavior by maintaining the dividend's scale rather than using standard division which would change the scale.
Key changes:
- Adds C++ decimal division implementation with HALF_UP and HALF_EVEN rounding modes
- Implements Python bindings through pylibcudf with proper type checking and error handling
- Extends DecimalBaseColumn with divide_decimal method supporting column-column, column-scalar, and scalar-column operations
Reviewed Changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| cpp/include/cudf/decimal/decimal_ops.hpp | New header defining public API for decimal division functions |
| cpp/include/cudf/fixed_point/fixed_point.hpp | Adds divide_decimal function with rounding modes and scale preservation logic |
| cpp/src/binaryop/decimal_ops.cu | Core C++ implementation handling different decimal types and null values |
| python/pylibcudf/pylibcudf/libcudf/decimal/decimal_ops.pxd | Cython declarations for C++ decimal operations |
| python/pylibcudf/pylibcudf/decimal_division.pyx | Python bindings with type validation and rounding mode conversion |
| python/cudf/cudf/core/column/decimal.py | High-level divide_decimal method for DecimalBaseColumn |
| python/cudf/cudf/tests/test_decimal_division.py | Comprehensive test suite covering various scenarios and edge cases |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
| /** | ||
| * @brief Helper function to perform division with scale preservation | ||
| * | ||
| * NOTE: This function was added for SQL-compatible division but is currently unused. | ||
| * It preserves the dividend's scale (lhs.scale()) rather than using standard behavior. | ||
| * Kept for potential future use or removal. | ||
| * | ||
| * @tparam Rep1 Representation type of input operands | ||
| * @tparam Rad1 Radix (base) type | ||
| * @tparam ReturnRep Representation type for return value (defaults to Rep1) | ||
| * @param lhs Left hand side operand | ||
| * @param rhs Right hand side operand | ||
| * @return Division result with dividend's scale preserved | ||
| */ | ||
| template <typename Rep1, Radix Rad1, typename ReturnRep = Rep1> | ||
| CUDF_HOST_DEVICE inline fixed_point<ReturnRep, Rad1> perform_decimal_division( | ||
| fixed_point<Rep1, Rad1> const& lhs, fixed_point<Rep1, Rad1> const& rhs) | ||
| { | ||
| #if defined(__CUDACC_DEBUG__) | ||
| assert(!division_overflow<Rep1>(lhs.value(), rhs.value()) && "fixed_point overflow"); | ||
| #endif | ||
|
|
||
| // Calculate adjustment factor based on right operand's scale | ||
| auto const scale_factor = detail::ipow<Rep1, Rad1>(static_cast<int32_t>(rhs.scale())); | ||
|
|
||
| // Check for potential overflow when scaling left operand | ||
| bool overflow = multiplication_overflow<Rep1>(lhs.value(), scale_factor); | ||
|
|
||
| // Normal processing (no overflow) | ||
| if (!overflow) { | ||
| ReturnRep result_value = detail::left_shift<ReturnRep, Rad1>( | ||
| lhs.value(), scale_type{static_cast<int32_t>(rhs.scale())}) / | ||
| rhs.value(); | ||
|
|
||
| #if defined(__CUDACC_DEBUG__) | ||
| printf("No overflow in division calculation.\n"); | ||
| #endif | ||
|
|
There was a problem hiding this comment.
This function is marked as unused and "kept for potential future use or removal." Since it's not being used in this implementation, consider removing it to avoid code bloat and confusion about which division function to use.
| /** | |
| * @brief Helper function to perform division with scale preservation | |
| * | |
| * NOTE: This function was added for SQL-compatible division but is currently unused. | |
| * It preserves the dividend's scale (lhs.scale()) rather than using standard behavior. | |
| * Kept for potential future use or removal. | |
| * | |
| * @tparam Rep1 Representation type of input operands | |
| * @tparam Rad1 Radix (base) type | |
| * @tparam ReturnRep Representation type for return value (defaults to Rep1) | |
| * @param lhs Left hand side operand | |
| * @param rhs Right hand side operand | |
| * @return Division result with dividend's scale preserved | |
| */ | |
| template <typename Rep1, Radix Rad1, typename ReturnRep = Rep1> | |
| CUDF_HOST_DEVICE inline fixed_point<ReturnRep, Rad1> perform_decimal_division( | |
| fixed_point<Rep1, Rad1> const& lhs, fixed_point<Rep1, Rad1> const& rhs) | |
| { | |
| #if defined(__CUDACC_DEBUG__) | |
| assert(!division_overflow<Rep1>(lhs.value(), rhs.value()) && "fixed_point overflow"); | |
| #endif | |
| // Calculate adjustment factor based on right operand's scale | |
| auto const scale_factor = detail::ipow<Rep1, Rad1>(static_cast<int32_t>(rhs.scale())); | |
| // Check for potential overflow when scaling left operand | |
| bool overflow = multiplication_overflow<Rep1>(lhs.value(), scale_factor); | |
| // Normal processing (no overflow) | |
| if (!overflow) { | |
| ReturnRep result_value = detail::left_shift<ReturnRep, Rad1>( | |
| lhs.value(), scale_type{static_cast<int32_t>(rhs.scale())}) / | |
| rhs.value(); | |
| #if defined(__CUDACC_DEBUG__) | |
| printf("No overflow in division calculation.\n"); | |
| #endif |
| cpp_mode = <cpp_decimal_rounding_mode>0 # HALF_UP = 0 | ||
| elif rounding_mode == DecimalRoundingMode.HALF_EVEN: | ||
| cpp_mode = <cpp_decimal_rounding_mode>1 # HALF_EVEN = 1 |
There was a problem hiding this comment.
The magic numbers 0 and 1 are hardcoded here and repeated in multiple functions. Consider using the enum values directly (e.g., cpp_decimal_rounding_mode.HALF_UP) to avoid magic numbers and improve maintainability.
| cpp_mode = <cpp_decimal_rounding_mode>0 # HALF_UP = 0 | |
| elif rounding_mode == DecimalRoundingMode.HALF_EVEN: | |
| cpp_mode = <cpp_decimal_rounding_mode>1 # HALF_EVEN = 1 | |
| cpp_mode = cpp_decimal_rounding_mode.HALF_UP | |
| elif rounding_mode == DecimalRoundingMode.HALF_EVEN: | |
| cpp_mode = cpp_decimal_rounding_mode.HALF_EVEN |
60fce03 to
fd3a8a4
Compare
Implements divide_decimal for fixed-point decimal columns that preserves the dividend's scale, similar to Java BigDecimal.divide() with rounding mode. - Add divide_decimal C++ implementation with HALF_UP and HALF_EVEN rounding - Add Python bindings via pylibcudf - Add divide_decimal method to DecimalBaseColumn - Add comprehensive tests for various scales and rounding modes - Fix critical bug in C++ implementation for different scales - Add Doxygen documentation for new functions Addresses issue NVIDIA#17448 Co-authored-by: Akihiro Hirota <akihiro-hirota@gmobiz.com>
fd3a8a4 to
969285a
Compare
19e60bc to
969285a
Compare
|
@davidwendt @bdice |
|
Hi! Acknowledging the request for review. Optimistically I will try to review tomorrow but I might need a couple days before I can give this a deep dive. |
bdice
left a comment
There was a problem hiding this comment.
Thanks for the PR! Overall, this seems like it's on the right track. I have some high level guidance on naming, where code should live, what APIs should be public (or not), test cases that we should cover, and so forth. I think a few iterations will get this into the right place!
There was a problem hiding this comment.
We should probably rename "decimal" to "fixed point" for consistency with the rest of libcudf. The code paths we have don't force the base to be 10 (could be binary).
That would put this in cudf/fixed_point/fixed_point_operators.hpp or similar.
| * @param rounding_mode The rounding mode to use (default: HALF_UP) | ||
| * @return A fixed-point number with the same scale as the dividend | ||
| */ | ||
| template <typename Rep1, Radix Rad1> |
There was a problem hiding this comment.
If this is specialized for decimals, we might need to require that the radix is 10. But if this logic is generic, we should call it "fixed point" rather than "decimal."
| } | ||
| } else if (rounding_mode == decimal_rounding_mode::HALF_EVEN) { | ||
| // Banker's rounding | ||
| // Avoid abs() ambiguity for __int128 by using conditional |
There was a problem hiding this comment.
Let's copy this pattern. Maybe write it into a helper function if it's repeated.
| WidestRep remainder = scaled_dividend % wide_divisor; | ||
|
|
||
| // Apply rounding | ||
| if (rounding_mode == decimal_rounding_mode::HALF_UP) { |
There was a problem hiding this comment.
Can we pull out some of this logic into a function templated on the rep? It looks like it's repeated several times.
| rmm::cuda_stream_view stream, | ||
| rmm::device_async_resource_ref mr) | ||
| { | ||
| using namespace numeric; |
There was a problem hiding this comment.
I'd like to avoid this using namespace if possible. It is easier to trace when namespaces are explicitly written.
| s2 = cudf.Series([Decimal("2.0"), Decimal("3.0"), Decimal("4.0")]) | ||
|
|
||
| # divide_decimal (scale preserved) | ||
| decimal_result = s1._column.divide_decimal(s2._column) |
There was a problem hiding this comment.
Continuing on some comments from above: users should not need to invoke private APIs for this functionality. It should be done by converting cuDF Series to pylibcudf columns (with public APIs) and calling a free function from pylibcudf.
| >>> lhs = pylibcudf.column_from_decimal_values([1.23, 4.56], scale=-2) | ||
| >>> rhs = pylibcudf.column_from_decimal_values([2.0, 3.0], scale=-1) | ||
| >>> # Divide preserving lhs scale | ||
| >>> result = divide_decimal(lhs, rhs, DecimalRoundingMode.HALF_UP) |
There was a problem hiding this comment.
Yes, this is the public API we want to recommend for users.
| else: | ||
| raise ValueError(f"Invalid rounding mode: {rounding_mode}") | ||
|
|
||
| # Check that both columns are decimal types |
There was a problem hiding this comment.
Is this check being performed at the C++ layer? I think we might want to defer this and let C++ handle the type validation.
| return Column.from_libcudf(move(result)) | ||
|
|
||
|
|
||
| cpdef Column divide_decimal_column_scalar( |
There was a problem hiding this comment.
There may be some Cython fused-type magic we need to invoke here to make overloads work properly. Something like this: https://github.com/rapidsai/cudf/blob/7362ce261962c62a406402d7c7b965d3bcbaf7a5/python/pylibcudf/pylibcudf/lists.pxd#L16-L18
Please give that a try.
| * Specifies how to round the result when performing decimal division | ||
| * with scale preservation. | ||
| */ | ||
| enum class decimal_rounding_mode : int32_t { |
There was a problem hiding this comment.
revans2
left a comment
There was a problem hiding this comment.
I am not sure what the requester of this feature really wants, but this does not fit with what we want for Spark. Spark deals with decimal divide in inconsistent and configurable ways. The adjustments to the precision and scale changed in 3.3.0. The divide in an average is treated differently from regular divide. There is also the config https://github.com/apache/spark/blob/e08c15b62712303942284cb90d6a1b004f69652c/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala#L4115-L4124 which can change how the precision and scale change when the precision would need to go above 38 to get the final result. To make this work in the same way as Spark we have multiple different implementations of DecimalDivide. In general we cast each LHS and RHS decimal value to an intermediate type that would give us enough output to match the desired output precision+1 and scale+1 so that we can do the HALF_UP rounding being done here. Then we do the rounding by casting the result to the final precision and scale. But that only works if the true intermediate (output precision +1 and scale +1) would fit in a DECIMAL_128. If not, then we have to call into a custom kernel that we wrote which does a 256 by 256 bit decimal divide. We also have to check to see if the result is too large to actually fit in the specified output and handle those overflow cases. If anyone wants details about all of this I am happy to provide them.
|
@a-hirota It might be good to do some comparisons across libraries and enumerate all of the engines that you expect to match this set of conventions, and list them in the comments. @revans2 Could you point to the decimal division test cases you use in Spark? It would be good to expand the tests for this feature to include all of those cases. I am guessing you've run across far more edge cases than I can think of. |
|
I'm afraid I haven't had the chance to review this yet myself, apologies. Given the discussions above, though, it seems like we still need to have higher-level discussions, so I've bumped this to 25.12. |
|
Sorry I missed the comment from @bdice about test cases. Most of our test cases are a direct comparison to Spark. Tests general division with ANSI disabled so we don't get exceptions, we get nulls on overflow. We focus on some large ranges of scale and precision explicitly as a part of this, and mixing different scales and precisions along with int like inputs. |
|
Thanks! @a-hirota hopefully that pointer gives you some idea of the different behaviors we need to support here. |
|
@simoneves I understand you were examining this implementation to see if it helps with Velox as well. Does this method for division meet your needs? |
|
@a-hirota there is renewed interest here at NVIDIA in this PR. Please let us know if you would be interested in collaborating on completing it, or if you are OK with us taking it over from you. I tried emailing you at the address associated with your GitHub account, but it bounced. If you prefer you can reply to me at seves@nvidia.com |
|
Closing as replaced by #22261. @a-hirota thank you for your hard work on this! Please feel free to coordinate with @simoneves on his PR as much as you would like, hopefully we can develop something that meets everyone's needs there. |
Description
This PR addresses issue #17448 by implementing decimal division operations that preserve the scale of the dividend, similar to Java's BigDecimal.divide() behavior.
Key changes:
The implementation supports:
Future Work:
Checklist