Skip to content

[python] Give the Python package Decimal128, and make its division the fastest of the three - #307

Merged
forfudan merged 1 commit into
mainfrom
py-decimal128
Aug 29, 2026
Merged

[python] Give the Python package Decimal128, and make its division the fastest of the three#307
forfudan merged 1 commit into
mainfrom
py-decimal128

Conversation

@forfudan

@forfudan forfudan commented Aug 29, 2026

Copy link
Copy Markdown
Owner

decimo.Decimal is arbitrary precision. This adds the other type to the Python package: Decimal128, 96 bits of coefficient and a scale from 0 to 28 in sixteen bytes that own nothing, with Dec128 as the shorter name the Mojo library uses.

+, -, *, /, the six comparisons and the hash are C slots rather than dictionary entries. The hash agrees with int, float, decimal.Decimal and Decimal, so the four are interchangeable as dictionary keys. quantize, round, normalize, adjusted, compare, copy_sign, copy_abs, copy_negate, to_integral_value, same_quantum, max, min, fma, as_tuple, as_integer_ratio, to_eng_string, from_float and the is_ predicates are there, along with sqrt, cbrt, root, exp, ln, log10, log, all six trigonometric functions, and the IEEE 754 interchange bytes from #305. It copies, pickles and formats like a value.

A mixed expression settles in the wider type: Decimal128 + Decimal is a Decimal either way round, since 29 digits and a scale of 28 both fit. Decimal's operand conversion recognises the fixed-width type and Decimal128's refuses it, which is what hands the expression to the reflected operator.

Division was the one operation slower than decimal's, so it was rewritten. The quotient was built a digit at a time, which cost 226 nanoseconds and could run out of digits before reaching the position the rounding needed: 504572829922.89957 / 525211.7899 came out one unit low, and 1 in 300 random pairs was wrong in the last place. It is now one wide division rounded from the remainder, which is what settles a tie.

That needed a divider. A divisor past 64 bits fell through to UInt256 // UInt256, a software shift-subtract loop of 261 nanoseconds, so udiv_u256_by_u128 takes Knuth's algorithm D over 64-bit limbs and does it in 22: every division inside it has a divisor that fits 64 bits, which __udivti3 does in 2.6 nanoseconds against 75 for a full 128-bit one. Decimal128 division is 70 nanoseconds against 226, and right on all 300 pairs.

Nanoseconds per operation, from Python:

Decimal128 Decimal decimal
a + b 45 65 73
a * b 57 90 80
a / b 108 220 131
from text 110 159 131
str(x) 114 439 63
comparison 29 24 22
an invoice 597 709 712

The invoice is three lines quantized to cents with tax on the subtotal. str is the one still slower: 60 nanoseconds of that is the Mojo side and the rest is the call, so beating 63 means making the text itself about four times cheaper.

Two bugs turned up in the operators. A failed conversion left CPython's error indicator set, and a slot returning NotImplemented with an error pending has it surface later attached to whatever ran next -- Decimal128(1) + Decimal(2) raised OverflowError: bad argument type for built-in operation. And __rpow__ and __rdivmod__ were missing, so 2 ** Decimal128(3) and divmod(7, Decimal128(2)) were type errors.

Three differences from Decimal are in the README. Division fills the type -- 29 significant digits and a scale of at most 28 -- rather than following the context precision. A value past 7.9E+28 raises instead of rounding into range. And a scale is never negative, so Decimal128("1.23E+5") is 123000: there is no cohort member with an exponent of 3 to print as 123E+3.

1.44 million random pairs check the divider against plain division, including both sides of the 64-bit boundary, a divisor larger than the dividend, and exact division. 2100 checks across every function in the decimal128 module against CPython's decimal and a 140-digit trigonometric reference: none wrong. The suite is at 1227 tests, and the Python tests cover the arithmetic, the method surface, hashing, money rounding, mixed expressions, the interchange bytes and what the type refuses.

…e fastest of the three

decimo.Decimal128 is the fixed-width type: 96 bits of coefficient and a scale
from 0 to 28, in sixteen bytes that own nothing. It arithmetics, compares,
hashes and rounds like Decimal, mixes with int, float and str on either side
of an operator, and carries quantize, round, as_tuple, sqrt, exp, ln, log10,
the trigonometry and the IEEE 754 interchange bytes. Its hash agrees with
int, float, decimal.Decimal and Decimal.

Its division was the one operation slower than decimal's, so it was rewritten.
The quotient was built a digit at a time, which cost 226 ns and could run out
of digits before the rounding position: 1 in 300 random pairs was wrong in the
last place. It is now one wide division rounded from the remainder, on top of
a 256-by-128 divider that takes Knuth's algorithm D over 64-bit limbs -- 22 ns
against the 261 the generic UInt256 division costs.

From Python: addition 46 ns against decimal's 73, multiplication 57 against
85, division 114 against 133, construction 116 against 136. An invoice of
three quantized lines with tax is 633 ns against 705.
@forfudan
forfudan merged commit 2ba7698 into main Aug 29, 2026
18 checks passed
@forfudan
forfudan deleted the py-decimal128 branch August 29, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant