Skip to content

Follow the ideal exponent in Quo - #165

Open
mvdan wants to merge 4 commits into
cockroachdb:masterfrom
mvdan:quo-ideal-exponent
Open

mvdan wants to merge 4 commits into
cockroachdb:masterfrom
mvdan:quo-ideal-exponent

Conversation

@mvdan

@mvdan mvdan commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

(see commit messages - please do not squash)

cc @rafiss :)

mvdan added 4 commits October 22, 2026 10:34
Rounding an inexact quotient up can carry into an extra digit, so at
precision 9, 999999999.5/1 gives 1000000000 rather than the specified
1.00000000E+9. Add a test asserting the current results.
Rounding an inexact quotient up could carry into an extra digit, so at
precision 9, 999999999.5/1 gave 1000000000 rather than 1.00000000E+9.
Drop the new trailing zero and raise the exponent instead, as Round
does.

The GDA test suite compares finite results numerically, which hid this.
An inexact quotient always has Precision digits, so compare those
results exactly.
Context.Quo gives every exact quotient Precision digits, so that at
precision 34, 6.00/3 gives 2.000000000000000000000000000000000 rather
than 2.00, the ideal exponent of the specification. Add test cases
asserting the current results.

Also check that Exp, which sums a series through Quo, keeps Precision
digits for a small x, where the series is short and exact.
Quo computed every quotient with Precision digits, so at precision 34,
6.00/3 gave 2.000000000000000000000000000000000. The specification
instead gives an exact quotient the ideal exponent, the dividend's less
the divisor's, or the closest one within the precision, and only an
inexact quotient keeps Precision digits: 6.00/3 is 2.00, 1/4 is 0.25,
1000000/1E+4 is 100.0000, and at precision 9, 1000000000000/1 is
1.00000000E+12 with Rounded. Add, Sub and Mul already follow their ideal
exponents, and downstream users reduce exact quotients by hand today.

Until v3.1.0, Quo stripped every trailing zero, going past the ideal
exponent: 10.0000/4.0 gave 2.5 rather than 2.500. Pull request cockroachdb#115
removed that to keep the digits of the computation like PostgreSQL does.
Reducing only to the ideal exponent is the middle ground: the zeros
implied by the operands stay, an inexact quotient keeps Precision
digits, and a caller wanting a fixed scale quantizes the result, as it
must for inexact quotients already.

After the division, the exponent of an exact quotient is a known number
of places below the ideal one, so remove at most that many trailing
zeros. The bounded helper is shared with Decimal.Reduce, and divides by
10^k for decreasing powers of two k rather than by ten per zero, as an
exact quotient at high precision can end in thousands of zeros. An exact
quotient whose ideal form needs more than Precision digits now reports
Rounded, as Mul does. Sqrt and Exp pad their results, so they are
unchanged. Pow divides one by the power for a negative exponent, so
2**-3 is now 0.125 at any precision, as the specification says and issue
cockroachdb#161 reports.

The GDA suite compared divide results numerically, ignoring Rounded.
They now all match exactly, so compare them with CmpTotal and check
Rounded, except on a zero, as setExponent reports clamping its exponent
as Rounded.

@mw5h mw5h left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did an adversarial pass on this at eecfc2f, including a differential sweep of master vs. this branch vs. Python's decimal (~139k divisions across 9 precisions, all 8 rounding modes, 20k random quotients, subnormal grids) plus aliasing, Sqrt/Cbrt/Exp/Ln/Log10 regression checks, and a trial merge onto current master. Summary first, then the issues.

What checks out. No numeric value of any Quo result changes. All 49,022 exponent changes match Python exactly (master had 49,022 mismatches vs. Python, this branch has 0). Sqrt/Cbrt/Exp/Ln/Log10 are byte-identical to master, so the padDigits calls do their job. Aliased forms agree with the non-aliased result. The GDA divide suite passes with exponent-exact comparison. Reduce on big coefficients gets much faster (1000 trailing zeros: ~170µs → ~2.8µs). Merges cleanly onto e9c7a71 and the suite passes on the merged tree.

1. This changes CockroachDB's SQL output, and the commit message says otherwise

The commit message says downstream users reduce exact quotients by hand today. That is true of CUE but not of CockroachDB, which pins apd v3.2.3 and calls tree.DecimalCtx.Quo directly with no post-processing in pkg/sql/sem/eval/binary_op.go (EvalDivDecimalOp, EvalDivIntOp, EvalDivDecimalIntOp, EvalDivIntDecimalOp) and in the avg aggregate (aggregate_builtins.go). At CRDB's precision 20 this branch gives:

master:  30/6 = 5.0000000000000000000    1E-16/2 = 5.0000000000000000000E-17    6.1/2 = 3.0500000000000000000
PR:      30/6 = 5                        1E-16/2 = 5E-17                        6.1/2 = 3.05

CRDB logic tests encode the current form (logic_test/decimal lines 40-42 expect 5.0000000000000000000E-17; logic_test/aggregate 657-674 and 722-724 expect avg(k) = 5.0000000000000000000, avg(d) = 3.0500000000000000000). So bumping apd would change wire-visible results for every exact decimal division and exact avg(), and it reverses the stated rationale of #115 / 04d1483 (keep Precision digits for PG scale()/trim_scale() compatibility; PG gives 10::numeric/4 = 2.5000000000000000).

The code is spec-correct, so this is a policy call for the apd and CRDB owners rather than a bug: accept it and plan the CRDB side (CRDB cannot Reduce its way back to the old output; it would have to re-pad), gate it behind a Context option, or ship as a documented breaking change. Either way the commit message claim should be corrected and the behaviour change stated in the PR body.

2. Exact quotients are 2-4x slower

trimZeros is tuned for Reduce ("most coefficients have no trailing zero, one probe by 10 settles it"), but in Quo's exact branch the coefficient was just scaled by 10^(Precision-1) so it almost always ends in many zeros: the probe is wasted, then log2(n) big-int QuoRems follow, and above ~19 digits the uint64 fast path only kicks in late. ns/op, master → PR (-benchtime=20000x -count=3, stable):

case master PR
10/2 p=20 (CRDB-like) 72 153
1/4 p=34 103 243 (+1 alloc)
6.00/3 p=100 133 540
1/3 p=20 (inexact) 70 72
999999999.5/1 p=9 (inexact carry) 62 73

Exact division is the hot case for integer-typed SQL arithmetic and avg(). Options: try an exact division at the ideal exponent first (one QuoRem of the unscaled coefficients; if the remainder is zero and the quotient fits in Precision digits, done), or skip the by-10 probe from the Quo path and start at the largest k <= n. A BenchmarkQuo* in bench_test.go would keep this tracked. The +15% on the carry case is roundAddOne calling NumDigits twice.

3. Test gaps

  • trimZeros's big-int path (TrailingZeroBits bound, probe, power-of-two descent, fallthrough into the uint64 path, limit < zeros, limit > zeros) has no direct test; reduce.decTest runs at precision 9 so only the uint64 branch is exercised there. A 20k-case property test I ran passes, so it is correct today, just unprotected.
  • The subnormal flag fix is real but untested and unmentioned: MinExponent=-999, p=3, 1E-1000/1 goes from 1.0E-1000 subnormal, rounded (master) to 1E-1000 subnormal (Python agrees with the PR).
  • Pow(2, -3) -> 0.125 (issue #161) is advertised in the commit message but has no test.
  • TestQuoExponent has no negative operands, zero dividend, partial-trim case (25E+2/1E+3, 1/8 at p=2 → 0.13 inexact, rounded), or Precision == 1 (10/1 -> 1E+1 rounded).

4. Smaller things

  • Pow(1, -0.5) at p=5 moves from 1.0000 inexact, rounded (spec/Python) to 1 inexact. Root cause is pre-existing (Exp's early return for tiny |x| returns the bare constant 1 without padding; master's Pow(1, 0.5) already gives 1); the old Quo's padding was hiding it for the negative case.
  • Context.Quo's doc comment says nothing about the result exponent; since this changes a user-visible contract (exact → ideal exponent, inexact → Precision digits, Rounded when the ideal form does not fit) it should be documented there. The padDigits comment says operations computed through Quo pad their result, but only Sqrt and Exp do.
  • for k := int64(1) << bits.Len64(uint64(limit)) >> 1 overflows for limit >= 1<<62 (unreachable today because limit is clamped to TrailingZeroBits() first; shifting by bits.Len64(...) - 1 would be clearer). The uint64 loop for n+4 <= limit && i%10000 == 0 spins for i == 0 with a large limit; callers guarantee nonzero, so latent only.
  • New Rounded on exact-but-too-wide quotients (1000000000000/1 at p=9) is spec-correct and matches Mul/Add, but a caller trapping Rounded now gets an error where it did not before. Worth a line in the PR body.

@mw5h

mw5h commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Need @rafiss to weigh in here.

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.

2 participants