Skip to content

Add toDouble for the FastInt family - #6594

Merged
meshinspector-agent[bot] merged 4 commits into
masterfrom
agent/mlib-fastint-to-double-master
Aug 15, 2026
Merged

Add toDouble for the FastInt family#6594
meshinspector-agent[bot] merged 4 commits into
masterfrom
agent/mlib-fastint-to-double-master

Conversation

@meshinspector-agent

@meshinspector-agent meshinspector-agent Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Rebased onto master, replacing #6592 (which was based on alpha-shape-triangle-shadow and so got no checks).

First of the three increments that restore the shadow filter's double pre-filters. They were dropped in the port because the FastInt family has no conversion to double — on MSVC FastInt128 is std::_Signed128, which has no such operator at all — so every shadow test is currently exact. No caller yet: increments 2 and 3 consume it.

What changed

  • toDouble( FastInt128 ) and toDouble( const FastInt<nBits> & ), both MR_BIND_IGNORE: FastInt128 has no C spelling, and the binding mrbind would otherwise emit — double MR_toDouble( __int128 ) — compiles nowhere.
  • FastInt128 takes the built-in __int128_t → double where there is one, and ours where there is not, on MR_HAS_BUILTIN_INT128 — defined in MRFastInt128.h beside the type choice itself, so the two cannot drift apart. On the baseline x86-64 build MeshLib actually ships (no -march anywhere in the build system) the built-in wins: 7.30 vs 8.80 ns/conversion throughput, 9.58 vs 13.34 latency. The two agree bit for bit on every value compared — 6.56M, including exact ties and tie±1 at all 74 widths above 2^53, where a double rounding would show.
  • The wider types, and MSVC, go through detail::doubleFromWords( const std::uint64_t * w, int n ) — words read-only, least significant first — defined out of line in the new source/MRMesh/MRFastInt.cpp, listed in MRMesh.vcxproj/.filters for MSBuild.
  • Correctly rounded: exact below 2^53, relative error below 2^-53 above it, ±inf past DBL_MAX. The bound is documented at the function, since a pre-filter that rejects a case in double is only safe against a stated conversion error. It comes from a single std::uint64_t → double (itself correctly rounded) of the top 64 significant bits, with everything below folded into a sticky lowest bit — so a tie is told from a value just above it without a second rounding — then scaled by a 2^e bit pattern rather than ldexp, which alone cost about as much as the rest of the routine.
  • Class comments in MRFastInt.h / MRFastInt128.h no longer claim the conversion is missing.

Verification

  • 7 new tests in MRFastIntTests.cpp, against that file's existing RefInt oracle rather than any code under test:
    • exactness below 2^53 at every width, and +0 for zero;
    • per value: the result is nearest — neither std::nextafter neighbour is closer — and within the documented 2^-53;
    • the ties at 2^53 (round-to-even both ways) and the sticky-bit tie at 2^64 + 2^11, which only a correct tail test gets right;
    • sign symmetry, and the same value at different widths converting to the same double;
    • ±inf past DBL_MAX — reachable only through a product, since a 1024-bit value maxes at 2^1023 < DBL_MAX, so FastInt<2048> and max1024 * max1024 cover it;
    • doubleFromWords( w, 2 ) == toDouble( v ) on 1000 random 128-bit values per run — which is what keeps the MSVC path checked, and equal to the built-in, on every platform.
  • Full MRTest: 354/354 pass, including MRAlphaShapeTests and the InSphere/SoS predicate tests — Release, g++-12, ubuntu22 image, mirroring build-test-ubuntu-x64.yml. That was at 048f6bd, the last revision a full build fit the run budget here; every revision since was built and run as a standalone gtest binary of MRFastIntTests.cpp + MRFastInt.cpp (g++-12, -O2, C++23): 16/16 pass, the 7 above included. No caller yet, so those tests are the whole behavioral surface.
  • Bit-identical across both rewrites — dropping ldexp, then the pointer interface — on ~1M values each: widths 128…2048, both signs, runs of zero low words, the smallest value of each width, -1, and the tie/sticky edges. So the agreement with the built-in above carries forward to the code as it stands.
  • Not exercised here: MSVC's std::_Signed128 path and the MSBuild project change — no Windows box on this runner; CI covers both.
  • CI on 6cfe112: everything finished is green, generate-c-bindings and the msvc-2019 build included; the rest still running.
  • No UI surface and nothing to drive in the viewer — a numeric addition with no caller until the next increment, so runtime behaviour is unchanged.

The pre-filters that reject a case in double before evaluating it exactly
need a bignum-to-double conversion, which the family lacked: MSVC's
std::_Signed128 has no such operator at all. It is correctly rounded, and
deliberately platform-independent rather than deferring to the built-in
__int128_t conversion, since the stated error bound is what makes those
rejections safe.
Comment thread source/MRMesh/MRFastInt.h Outdated
const FastUInt128 u( v );
// deliberately the same code as for FastInt below, and not the built-in conversion of
// __int128_t, which MSVC's std::_Signed128 lacks: the value must not depend on the platform
return detail::doubleFromWords( std::array{ std::uint64_t( u ), std::uint64_t( u >> 64 ) } );

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.

why not built-in conversion of __int128_t?

  1. is not it faster?
  2. do we expect different conversion results for some int128 values?

mrbind emitted MR_toDouble(__int128 v), which no build accepts: gcc rejects
__int128 in a header compiled with -pedantic-errors, and MSVC has no such
keyword at all. FastInt128 has no C spelling, and the rest of the family is
already ignored for the same reason.
@meshinspector-agent

Copy link
Copy Markdown
Contributor Author

"why not built-in conversion of __int128_t? 1) is not it faster? 2) do we expect different conversion results for some int128 values?"

Measured both, MR::toDouble vs (double)v, g++-12 -O3 x86-64 (self-hosted box — read the ratios, not the absolute ns):

1) Yes, the built-in is faster — 8.0 vs 13.3 ns/conversion (1.66×). Neither is inline: the built-in calls libgcc __floattidf, ours is 63 instructions plus a call to libm ldexp. That ldexp is most of the gap — scaling by a bit-pattern power of two instead gives 9.3 ns (1.16× the built-in), bit-identical on 6.26M values. Not pushed; see the choice below.

2) No divergence found. 6.56M values compared bit-for-bit against the built-in: hand-picked edges (0, ±1, 2^53, 2^53+1, 2^64+2^11, INT128_MIN/MAX), 20k random values at each of the 127 widths, and the cases that would actually expose a double rounding — exact ties and tie±1 at all 74 widths above 2^53, both round-to-even directions. 0 mismatches, so libgcc's __floattidf is correctly rounded here as well.

So the case for our own code is only the platforms I can't measure on this box: MSVC's std::_Signed128 has no conversion at all (Windows needs this code regardless), and arm64/emscripten/macOS take __floattidf from a different libgcc/compiler-rt than the one above. Deferring to the built-in makes "correctly rounded" — the property that makes rejecting a case in double safe — a per-toolchain fact instead of one code path.

Your call, and it decides what I do next:

  • keep ours → I push the ldexp removal: same bits, ~30% faster, closing most of the gap you asked about;
  • prefer the built-in#if on the __int128_t path with doubleFromWords as the MSVC fallback. I'd keep the tests, since they would then be checking each platform's libgcc/compiler-rt rather than our own code.

Separately — this PR broke CI on every platform, now fixed in 63b0541. mrbind generated MRC_API double MR_toDouble(__int128 v) into MRCMesh/MRFastInt.h, which no compiler accepts: gcc error: ISO C++ does not support '__int128' for 'v' (the bindings are built with -pedantic-errors), MSVC error C4235: '__int128' keyword not supported on this architecture. FastInt128 has no C spelling, so the fix is the MR_BIND_IGNORE the rest of the family already carries — I had put it on the FastInt<nBits> overload and missed the FastInt128 one. It expands to nothing outside binding parsing, so no object code changes; the regenerated bindings are what CI will confirm.

No UI surface, nothing to drive in the viewer. The header compiles clean with g++-12 in the ubuntu22 image, and the two measurement programs above run against it.

@Fedr

Fedr commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

keep our conversion for MSVC only, but remove ldexp to improve performance

Per review: only MSVC lacks a conversion in double, so only it needs our own
code. There the libm call was about a third of the cost of the conversion, and
a bit-pattern power of two replaces it with the same bits. A new assertion in
FastIntToDouble128 pins the two implementations to each other on every platform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@meshinspector-agent

Copy link
Copy Markdown
Contributor Author

Both done in 1575757.

  • ldexp removed — the scaling is now a 2^e bit pattern (detail::exp2i), with a loop step for the exponents past 1023 that only FastInt<2048> reaches. 10.6 → 7.0 ns/conversion (g++-12 -O3, self-hosted box), and bit-identical to the old code on 1.10M values: widths 128…2048, all magnitudes and both signs, plus the tie/sticky edges and the infinities past DBL_MAX.
  • Ours for MSVC onlytoDouble( FastInt128 ) is double( v ) where __int128_t exists and doubleFromWords where it does not. The discriminator is MR_HAS_BUILTIN_INT128, defined in MRFastInt128.h beside the type choice itself, so the two cannot drift apart.
  • The MSVC path stays tested everywhere — one added line in FastIntToDouble128 asserts doubleFromWords( w ) == toDouble( v ). Off MSVC that checks it against the built-in on 1000 random 128-bit values per run; on MSVC it is the thing under test.

One correction to the timings I gave above. Measured again over 2M values, built-in vs ours reverses: built-in 12.1 ns, ours 7.0 ns. The built-in is an out-of-line __floattidf call, so it neither inlines nor pipelines in a loop; I no longer have the earlier harness to reconcile against, but it was evidently measuring something closer to latency. So keeping ours for MSVC only costs a little speed off MSVC rather than gaining it, and the same-code-everywhere argument now points the same way as the performance one. Implemented as you asked; say the word if you would rather have doubleFromWords unconditionally.

Verification

  • Built and ran MRFastIntTests.cpp as a standalone gtest binary (g++-12, -O2, C++23, system gtest): 16/16 pass, the 7 toDouble ones included. The change is header-only with no callers yet, so those tests are its whole behavioral surface.
  • Not built here: the full MRTest — ~32 min in CI, past this run's budget. CI builds and runs it on every platform.
  • MSVC not exercised here (no Windows box); the added assertion is what covers that path, from both sides.
  • No UI surface, nothing to drive in the viewer.

@Fedr

Fedr commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

use our conversion everywhere, if it is faster than build-in conversion

@meshinspector-agent

Copy link
Copy Markdown
Contributor Author

It is not faster, so I changed nothing — the branch keeps the built-in off MSVC, ours for MSVC. And the number I gave you last night was wrong: that "correction" was the error, not the measurement before it. Sorry for the churn.

The tell was in my own comment — the built-in moved 8.0 → 12.1 ns between two of my harnesses for code that never changed. The two sides were being timed in different ad-hoc loops, so the ratio was measuring the loop, not the conversion.

Settled

One harness, same values, both sides alternating, min of 9 rounds × 16 reps, ±1% run to run. Baseline x86-64 — the configuration MeshLib ships: there is no -march/-mtune anywhere in the build system, so std::countl_zero compiles to bsr, not lzcnt.

ns/conversion built-in ours
throughput 7.30 8.80 ours 0.83×
latency 9.58 13.34 ours 0.72×

With -march=native ours draws level on throughput (6.79, 1.04×) and still loses latency (11.0, 0.84×) — but nothing ships with that.

Why: the built-in is a 33-instruction loop around one out-of-line __floattidf; ours is 79 inline instructions, and the leading-zero count is the difference between the two rows. Bit-identical on every value timed, as before.

If you want one path everywhere anyway

Specializing doubleFromWords to two words — no negate loop, no word scan, no sticky loop, single exponent step — gets, on the same baseline build:

ns/conversion built-in specialized
throughput 7.30 6.62 ours 1.10×
latency 9.58 10.82 ours 0.88×

So it wins throughput and still loses latency — "faster" does not come out unambiguous even then. Prototype is bit-identical to both the generic routine and the built-in on 16k random values across all 127 widths, plus INT128_MIN, INT128_MAX, 2^53, 2^53+1, 2^64+2^11. Say the word and I push it with tests.

That leaves the decision on same-code-everywhere grounds — one conversion whose correct rounding is our own fact rather than each toolchain's — costing 17% throughput / 28% latency as the code stands, or roughly nothing with the specialization above. Your call.

Verification

  • Header compiles clean, g++-12 -O3 C++23, ubuntu22 image; conversions verified bit-identical before being timed.
  • Nothing pushed — no code change was called for. CI is still running on 1575757, nothing red so far.
  • No UI surface, nothing to drive in the viewer.

@Fedr

Fedr commented Aug 15, 2026

Copy link
Copy Markdown
Contributor
  1. change interface of doubleFromWords: it must accept the const pointer on the first word, and the number of words;
  2. move the implementation of doubleFromWords in .cpp file

Per review. A pointer and a count make it one non-template function, which is
what lets the definition leave the header; with the words read-only, the
magnitude of a negative value is formed word by word instead of by negating a
copy of them in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@meshinspector-agent

Copy link
Copy Markdown
Contributor Author

Both done in 6cfe112.

  • Interfacedouble doubleFromWords( const std::uint64_t * w, int n ). With the words read-only it can no longer negate a copy in place, so the magnitude of a negative value is formed word by word: zero below the lowest non-zero word, that word negated, the ones above it complemented. Everything else is unchanged.
  • In a .cpp — new source/MRMesh/MRFastInt.cpp. exp2i and cMaxDoubleExp went with it, into its anonymous namespace, since nothing else used them; the header thereby also drops <bit>. CMake globs *.cpp, and MRMesh.vcxproj/.filters are updated for MSBuild. No binding change — mrbind_flags.txt already ignores MR::detail.

One cost worth naming: the conversion is now an out-of-line MRMESH_API call instead of an inlined one. Off MSVC toDouble( FastInt128 ) still returns the built-in double( v ) and stays inline, so the 128-bit filters are unaffected; the wider types, and MSVC, now pay a call per conversion.

Verification

  • Built and ran MRFastIntTests.cpp + MRFastInt.cpp as a standalone gtest binary (g++-12, -O2, C++23): 16/16 pass, the 7 toDouble ones included.
  • Bit-identical to the old routine on 980,048 values — widths 128…2048, all magnitudes and both signs, plus the cases this rewrite actually changes (runs of zero low words, the smallest value of each width, -1) and the tie/sticky edges.
  • The other consumers of the header compile clean after <bit> left it — MRAlphaShape.cpp, MRPrecisePredicates3.cpp.
  • Not built here: the full MRMesh/MRTest — ~32 min, past this run’s budget. CI builds both on every platform, and only CI covers the MSBuild project change and MSVC.
  • No UI surface and nothing to drive in the viewer: still no caller until the next increment.

@Fedr

Fedr commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

update PR's description

@meshinspector-agent

Copy link
Copy Markdown
Contributor Author

Description updated — it described only the first commit, and on the main point it said the opposite of what the branch now does.

  • Was wrong: "Deliberately not the built-in __int128_t → double on gcc/clang". Now: built-in off MSVC, ours on it, on MR_HAS_BUILTIN_INT128, with the numbers that decided it (7.30 vs 8.80 ns throughput on the baseline build).
  • Was missing: the MR_BIND_IGNORE on toDouble( FastInt128 ); the pointer + count interface of doubleFromWords; the new source/MRMesh/MRFastInt.cpp and the .vcxproj/.filters entries; ldexp replaced by a 2^e bit pattern.
  • Verification now says what was actually run where: full MRTest 354/354 at 048f6bd, standalone gtest 16/16 for every revision since, bit-identity across both rewrites, and MSVC + MSBuild left to CI.

No code change, so nothing pushed and nothing to build or drive. CI on 6cfe112 is green so far — generate-c-bindings and the msvc-2019 build included — with the ubuntu/arm/MSBuild jobs still running.

@Fedr

Fedr commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

when CI is green, merge this PR with short explanation in squash commit message

@meshinspector-agent

Copy link
Copy Markdown
Contributor Author

🔧 Working on it — answering review comments · run

@meshinspector-agent
meshinspector-agent Bot merged commit 5f15337 into master Aug 15, 2026
50 checks passed
@meshinspector-agent
meshinspector-agent Bot deleted the agent/mlib-fastint-to-double-master branch August 15, 2026 20:09
@meshinspector-agent

Copy link
Copy Markdown
Contributor Author

Merged — squashed to 5f15337 on master at 20:09Z, branch deleted.

CI on 6cfe112 finished green: 36 pass, 0 fail (14 skipped by their own if conditions), macos x64 Release being the last to land. No re-runs needed — nothing went red at any point.

Squash message:

Add toDouble for the FastInt family (#6594)

Restores the conversion the shadow filter's double pre-filters need: the FastInt family had none, so every shadow test is currently exact.

Off MSVC toDouble( FastInt128 ) takes the built-in __int128_t conversion (7.30 vs 8.80 ns/conversion on the baseline build, bit-identical); MSVC and the wider types go through detail::doubleFromWords( const uint64_t * w, int n ), defined in the new MRFastInt.cpp — correctly rounded, relative error below 2^-53, ±inf past DBL_MAX. Both overloads are MR_BIND_IGNORE, since __int128 has no C spelling. 7 new tests against MRFastIntTests' RefInt oracle; no caller yet, increments 2 and 3 consume it.

Increment 2 (the double pre-filter in the shadow test itself) is next.

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