Skip to content

Divide exactly, or to a chosen precision - #69

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/library-optimization-9wvabg
Sep 13, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/library-optimization-9wvabg

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Follow-up to #68, covering the two things left open there.

1. Division no longer caps at what a double can hold

Divide computed its fractional part through a double, so it stopped at roughly sixteen significant digits regardless of what the operands carried. On a type whose purpose is arbitrary precision, that was the wrong ceiling.

A terminating quotient is now exact, however many digits that takes. A reduced fraction terminates in base ten when its denominator is a product of twos and fives — cheap to test, and usually cheap to reject:

1 / 8      →  0.125
1 / 2^64   →  0.0000000000000000000542101086242752217003726400434970855712890625

That second one is the exact value of 2⁻⁶⁴, all sixty-four decimal places of it, where before it stopped after seventeen.

A repeating quotient is taken to the precision of the wider operand, never fewer than MinimumDivisionPrecision (50) significant digits, with the last digit rounded half away from zero:

1 / 3      →  0.33333333333333333333333333333333333333333333333333
22 / 7     →  3.1428571428571428571428571428571428571428571428571

A new overload takes the precision explicitly:

PreciseNumber.Divide(one, three, 200);   // 200 significant digits
PreciseNumber.Divide(one, three, 3);     // 0.333

On the rounding: whatever the scaled division discards is worth less than one unit of the dropped digits, so it can never carry the decision across the halfway mark — at most it turns an exact tie into something above it, which rounds the same way. That is why the remainder can be dropped rather than tracked.

This changes results

Three existing tests pinned the old behaviour and are updated: two divisions that were correct only as far as a double reached, and TestExpWithNegativePower, which compared Exp(-1) against 1 / e back when both were equally imprecise. Exp still routes through a double, so that comparison is now made at the precision Exp actually delivers — which is itself worth knowing.

It can also produce fewer digits than before, in one case

Worth stating plainly rather than burying. The old implementation computed the integer part of a quotient exactly and appended a double-precision fraction, so a quotient with a long integer part came out with more significant digits than the new default:

924880000e19 / -4713100000e-19
  before:  54 significant digits
  after:   50 significant digits

That was a side effect rather than a contract, and an erratic one — the same code gave seventeen digits for a quotient near one and hundreds for a quotient with a large exponent. A uniform floor with an explicit override is the more predictable trade, and callers who want the extra digits can now ask for them. Across 1 000 random pairs measured against the exact rational value, the new result is closer 851 times and the old one 103.

Cost

From the committed benchmark suite:

Divide Before After
8 significant digits 795 ns 405 ns ~2× faster
30 significant digits 868 ns 622 ns ~1.4× faster
200 significant digits 966 ns 3,373 ns 3.5× slower

Faster at small sizes because the double round trip through formatting and parsing is gone. Slower at 200 digits because it now computes two hundred digits instead of discarding them — the previous number was cheap for the same reason it was wrong.

2. The benchmark coverage exemption moved out of the shared workflow

#68 added **/*.Benchmarks/**/* to sonar.coverage.exclusions in .github/workflows/dotnet.yml, and I flagged that it would be lost if that file were resynced. Looking into pushing it upstream: there is no upstream to push it to. The workflow is duplicated per repository rather than generated from a template — ktsu-dev/KtsuBuild carries a copy differing only in comment wording, and neither ktsu-dev/Sdk nor ktsu-dev/.github holds a canonical version. An exclusion added there would have to be repeated in every repository that gains a benchmark project, and re-applied on every resync.

So the exemption moved into the benchmark project instead:

<SonarQubeExclude>true</SonarQubeExclude>

It travels with the project, needs no workflow to know benchmarks exist, and dotnet.yml goes back to its canonical form — byte-identical on that line to KtsuBuild's copy. The trade is that benchmark C# is no longer analysed for code smells; it is tooling that handles no untrusted input, and the alternative was an edit that had to be maintained in ~40 repositories.

If you would rather keep analysis and only drop the coverage requirement, SonarQubeTestProject does that instead — say the word and I will switch it.

Testing

  • dotnet test — 228 passing, 8 new covering exact termination across eleven denominators, the full 2⁻⁶⁴ expansion round-tripping through multiplication, requested precision from 1 to 200 digits, half-away-from-zero rounding in both signs, precision never falling below the operands', rejection of non-positive precision, and zero dividends.
  • The differential fuzz harness now checks every division against the exact rational value rather than against the old implementation: 4 944 terminating quotients exact to the digit, 32 347 repeating ones within half a unit of their last significant digit, inside 871 454 checks with no failures. Everything else still matches the pre-optimization implementation exactly.
  • Release build clean across all four target frameworks.

Still open

Exp, and Pow with a non-integer power, still route through a double. They are genuine approximations rather than an implementation shortcut, so giving them arbitrary precision means choosing and implementing series expansions — a larger piece of work, and a separate one.

Separately: Round and ReduceSignificance round by adding a run of repeated fives, which rounds 123.45 to 124 rather than 123. TestReduceSignificance pins that behaviour deliberately, so I have left it alone, but the new division rounds correctly and the two are now inconsistent. Worth a decision.

🤖 Generated with Claude Code

https://claude.ai/code/session_018eTdSeGPQHGUKf9V3c2yXw


Generated by Claude Code

Divide computed its fractional part through a double, so it capped at
roughly sixteen significant digits regardless of what the operands
carried. On a type whose whole purpose is arbitrary precision that was
the wrong ceiling.

A quotient with a terminating decimal expansion is now produced exactly,
however many digits that takes. A reduced fraction terminates in base ten
when its denominator is a product of twos and fives, which is cheap to
test and usually cheap to reject: 1 / 8 is 0.125, and 1 / 2^64 keeps all
sixty-four decimal places rather than the seventeen a double allows.

A repeating quotient is taken to the precision of the wider operand, never
fewer than MinimumDivisionPrecision significant digits, with the last digit
rounded half away from zero. Whatever the scaled division discards is worth
less than one unit of the dropped digits, so it can never carry the rounding
decision across the halfway mark; at most it turns an exact tie into
something above it, which rounds the same way. A new overload takes the
precision explicitly.

This changes results. Three tests pinned the old behaviour and are updated:
two divisions that were correct only as far as a double reached, and one
that compared Exp against 1/e when both happened to be equally imprecise.
Exp still routes through a double, so that comparison is now made at the
precision Exp actually delivers.

It can also produce fewer significant digits than before in one case. The
old implementation computed the integer part of a quotient exactly and
appended a double-precision fraction, so a quotient with a long integer
part came out with more digits than the new default: 924880000e19 divided
by -4713100000e-19 gave fifty-four digits where it now gives fifty. That
was not a contract so much as a side effect, and an erratic one - the same
code gave seventeen digits for a quotient near one, and hundreds for a
quotient with a large exponent. A uniform floor with an explicit override
is the more predictable trade, and callers who want the extra digits can
ask for them.

Cost, from the benchmark suite: division is about twice as fast at eight
and thirty significant digits, because the double round trip through
formatting and parsing is gone, and about three and a half times slower at
two hundred, because it now computes two hundred digits instead of
discarding them.

Verified by checking every division in the differential fuzz harness
against the exact rational value rather than against the old
implementation: 4,944 terminating quotients exact to the digit and 32,347
repeating ones within half a unit of their last significant digit, inside
871,454 checks with no failures.

Also moves the benchmark project's coverage exemption out of the shared
.NET workflow and into the project itself. That workflow is duplicated
per repository rather than generated from one template - KtsuBuild carries
a near-identical copy - so an exclusion added there would have to be
repeated everywhere and re-applied on every resync. SonarQubeExclude in
the benchmark project travels with the project and needs no workflow to
know benchmarks exist, so the shared workflow goes back to its canonical
form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eTdSeGPQHGUKf9V3c2yXw
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 6a0eecd into main Sep 13, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/library-optimization-9wvabg branch September 13, 2026 06:32
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