| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
|
Yeah, I think this is good. Do you have any benchmark on x86 comparing inline assembly versus this short 128-bit arithmetic? As Fredrik mentioned here, it was slower on older GCC, but GCC has improved on this area then I'd like to remove the inline assembly. |
Sorry, something went wrong.
|
I did not have x86 numbers, so I measured it (with my local infrastucture) -- on every x86 box I could get my hands on: Zen 2 (Ryzen 3800XT), Raptor Lake (i7-13700KF), Haswell (i5-4590S) and Sandy Bridge-EP (Xeon E5-2650, over proxmox), across gcc 12.2 / 14.2 / 15.2 / 16.2. Method: default ./configure, plus an #undef umul_ppmm / #undef smul_ppmm right before the block this PR adds, so exactly these two macros resolve to the __int128 versions while everything else in longlong.h (add_ssaaaa and friends) stays inline asm. Every forced-__int128 build passes make check MOD=test, which includes t-umul_ppmm and t-smul_ppmm. Timings are best-of-5 loops per process, 4 interleaved process runs per side, pinned to one core. Codegen first: for these two macros gcc already emits exactly what the asm does -- a single mulq for umul_ppmm, a single imulq for smul_ppmm (checked on gcc 12/14/16), and with -mbmi2 it switches to mulx on its own, matching the BMI2 asm variant. End to end, nmod_mat_mul 200x200 (modulus near 2^60), range over the 4 runs (fastest - slowest):
nmod_poly_gcd is a wash everywhere. nmod_poly_mul is a wash on the modern cores but favors the asm by 1-3% on Haswell and Sandy Bridge (0.0073 vs 0.0075 s on the i5). So: on 2012-2014 cores the two forms trade blows within ~3% either way; on anything recent the __int128 version is never slower and nmod_mat_mul is consistently 3-5% faster -- plausibly because the multiply is visible to the optimizer instead of being an opaque asm pinned to rax/rdx. If you do end up dropping the x86 inline asm for these two macros, the block this PR adds is exactly what gcc builds would fall back to, and per the above that fallback costs nothing on current hardware. Caveats: the Raptor Lake numbers are from a Linux environment under WSL2 and the Xeon is a live virtualization host (its first warm-up run was discarded) -- but every comparison is A/B inside the same environment, so the deltas hold. And this only covers umul_ppmm/smul_ppmm: I did not touch add_ssaaaa and the other carry-chain macros, which is where I would guess the regression Fredrik measured in #1636 came from. Sorry for the 6 hours waiting it took me a while to run and configure the environments. |
Sorry, something went wrong.
|
Sounds great! Can you remove the inline assembly definitions of umul_ppmm and smul_ppmm, so they use the int128 version? |
Sorry, something went wrong.
|
For the generic fallback code this is certainly fine, but there are indeed several places where I've measured a slowdown on x86-64. A recently commented example is ulong_extras/ll_is_prime.c: /* GCC 11/Zen 3: the following functions are faster implemented with umul_ppmm/add_ssaaaa inline assembly than with __uint128_t. */ We'd have to recheck whether this applies when the umul_ppmm alone uses __uint128 while keeping add_ssaaaa inline assembly. Other modules that may need some microbenchmarks are nfloat and mpn_mod. Though if the difference is <5%, it may not be worth the hassle to maintain inline assembly versions; one can maybe accept a small slowdown and bet on compilers improving their codegen... |
Sorry, something went wrong.
|
I would guess that umul_ppmm using int128 is pretty safe since the expansion is very simple. However, the add_s...a... expansion is trickier if the compiler cannot handle CPU flags well or knows how to convert to a carry chain properly -- this one I'm not so sure about for GCC and I would say we keep until experiments show something else. |
Sorry, something went wrong.
Sorry, something went wrong.
|
OK, my up to date view is that we should not drop the inline assembly umul_ppmm for x86-64. I've had Claude check both variants for a bunch of dot product-type kernels routines and the __uint128 version often performs 1.5x worse. Here is Claude's precise analysis of why: disassembly shows identical multiply and adc counts but increased register-register moves: "GCC's u128 lowering doesn't pin the product halves where the subsequent add_ssaaaa/add_sssaaaaaa asm blocks want them, and the shuffling lands on the critical carry path of the inner loop". |
Sorry, something went wrong.
|
Great job looking into this. I did not expect GCC to behave like this (really, removing register-register moves is a simple compiler routine). |
Sorry, something went wrong.
|
Sorry for the delay. Numbers as promised, on the split we discussed: only umul_ppmm/smul_ppmm on __int128, add_ssaaaa and the rest left as asm. Zen 2 (3800XT), gcc 16.2.1, --disable-shared, -march=znver2 picked by configure so __BMI2__ is on in both builds. Measured at this PR's commit f3a861e, i.e. before the Sep 3 mpn_mod overhaul (da421e8), so the mpn_mod rows describe the kernels as they were then. 8 to 12 processes a side in a counter-balanced interleave, pinned to one core; the box was not idle, so every ratio is taken on minima.
Rows above 1.05 have disjoint ranges over 12 runs a side and reproduce in a second pass under higher load, where three of the six nfloat cells touch at the edge; on the mpn_mod rows the minima reproduce to within 1.6%. Null control: mpn_mod matmul at nlimbs 3, where at that commit the dot went to flint_mpn_mul_n and the macro cannot matter, measures 0.995. So your mechanism reproduces, with a lower ceiling here (1.10 rather than 1.5), and the disassembly says why. Under __BMI2__ the FLINT asm macro emits mulx with "=r","=r", both destinations free. Correction to my Aug 31 note: gcc does emit mulx for the macro in isolation, but inside these kernels its __int128 lowering mostly does not -- 64 of the 72 wide-multiply sites in n_ll_is_prime get the legacy one-operand mul pinned to rdx:rax. Wide-multiply, imul and adc counts come out identical between the two builds; reg-reg moves go 44 -> 139. The __int128 form pins the halves harder than the hand-written asm does. And _mpn_mod_vec_dot, the pure umul_ppmm-into-add_sssaaaaaa shape, gains no extra reg-reg moves at all and is a wash -- so the cost tracks how long a carry chain the products feed, not just whether the macro is hot. Agreed on keeping the x86-64 asm. The aarch64 asm stays as well, so the A/B I mentioned is moot. On the PR itself: the added block is the generic fallback behind #if !defined(umul_ppmm), so it cannot fire where an asm header ran first. Checked by preprocessing: on x86-64 gcc, with and without BMI2, umul_ppmm still expands to the mulq/mulx asm; on clang it comes from longlong_asm_clang.h, which is itself a __uint128_t multiply. It expands only where no asm header defined it first (riscv64, ppc64, s390x, loongarch64, mips64 and any other gcc target without an asm block) and on every gcc target under --disable-assembly, where the alternative is the 4-multiplication generic path, never the asm. The riscv64 numbers in the description are from exactly that path. Nothing else pending on my side for this one. |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
The generic fallback for umul_ppmm/smul_ppmm in src/longlong.h builds a 64x64 -> 128 product out of four half-word multiplications. Where the compiler has a 128-bit integer type, one widening multiply does the same job. Platforms that already have inline asm for umul_ppmm are untouched: the new block sits behind !defined(umul_ppmm), like the generic one it precedes.
This is not a rare path, at least under gcc. Even with assembly enabled, gcc has inline asm for umul_ppmm only on amd64/i386 (longlong_asm_gcc.h:21) and arm/aarch64 (:247), so riscv64, ppc64, s390x, loongarch64 and mips64 always get the generic block; and ./configure --disable-assembly puts every architecture on it, x86-64 included. None of this applies to clang, which gets longlong_asm_clang.h unconditionally (src/longlong.h:47-48), outside the FLINT_WANT_ASSEMBLY gate.
That last one makes the change easy to see without RISC-V hardware. On x86-64 with gcc, configure with --disable-assembly and preprocess a file that uses the macro, say src/ulong_extras/mulmod_preinv.c: before the patch umul_ppmm expands to the four-multiplication version (grep -c __x0 gives 8, __rx 0), after it to a single __uint128_t product (__rx 8, __x0 0). Both configurations build and pass make check for ulong_extras, nmod, nmod_mat, nmod_vec, fmpz and mpn_extras: 355 tests, same count either way.
FLINT already uses the same widening multiply on the generic GNU path elsewhere: n_mulhi (src/ulong_extras.h:47-48), ull_t (src/nmod.h:296-299) and _mul (src/crt_helpers.h:309-315). The guard here is copied from crt_helpers.h:309.
For gcc this also completes the umul_ppmm and smul_ppmm items ticked off in #1636, which until now only took effect for clang through longlong_asm_clang.h:194-195.
Codegen
gcc 16, -O2, counting the final ret:
smul_ppmm on riscv64 goes from 32 instructions and 4 mul to mul + mulh. Both mulhu and mulh belong to the base M extension, so nothing beyond a stock rv64gc build is needed.
Benchmark
SiFive U74 (VisionFive 2, rv64gc), gcc 16 cross build, governor pinned to performance at 1.5 GHz, master and patched runs interleaved, 4 runs each, spread <= 0.4%. Modulus a 60-bit prime:
The gain is not uniform. It is large where nmod_mul dominates the inner loop and almost nothing for the polynomial routines, which spend their time elsewhere.
Both libraries were built from 00e660a with ./configure --host=riscv64-linux-gnu --disable-shared --disable-pthread, with only src/longlong.h differing between them.
Division is deliberately left alone: the same treatment applied to udiv_qrnnd makes gcc emit calls to __udivti3 and __umodti3 instead of the hardware divide, so it would be a pessimisation.
Testing
Test suites cross-compiled for riscv64 and run natively on the U74 against the patched library: test (which contains t-umul_ppmm and t-smul_ppmm), nmod_mat, nmod, nmod_vec, ulong_extras, fmpz, mpn_extras. All exit 0, 371 PASS in total, no failures.
Worth flagging: no CI job compiles the new branch. On every platform CI covers, umul_ppmm is already defined before this point, by inline asm on x86-64/aarch64 with autotools, by longlong_asm_clang.h for clang, by longlong_msc_*.h for MSVC, while the 32-bit Alpine job has no __int128 at all. The ways to exercise it are --disable-assembly or an architecture without inline asm.
One regression I found
On sparc64, gcc compiles the 128-bit product into a call __multi3 rather than inlining it (gcc 15.2 at -O2, also with -mcpu=niagara4). Every other target I checked inlines it: riscv64, ppc64, s390x, loongarch64, mips64el, aarch64. Since FLINT has no sparc CI and no sparc-specific code I left the guard as it is, but I am happy to add && !defined(__sparc__) if you prefer.