Skip to content

Repository files navigation

Findprimes

This is a hobby research project of mine. I've been working on it off and on as my copious free time permits. It started off as a dig to the Quantum Computing Industry's claims about Prime Factorization - which is currently still stuck at factoring 21. As in 3 * 7. As a reference point, my Core i9-14900HX laptop can factorize ULLONG_MAX in less than 3 seconds using trial division.

The second - corresponding - part of this project can be found here in my Github repo: primefactors.

GMP-ECPP with CUDA, POSIX threads and MPI parallelization can be found here in my Github repo as well: GMP_ECPP_CUDA.

Five prime finders over the same range scanner. Each enumerates every prime in a decimal range and prints them in sorted order; they differ in how a candidate is decided and where the work runs.

program primality method where it runs
findprimesmp trial division to sqrt(N) CPU, pthreads
findprimesmpecm N-1 Pocklington, N+1 Morrison, and their combined CRT bound, factored with GMP-ECM CPU, pthreads
findprimesmpecmcgbn the same three certificates ECM stage 1 on the GPU via CGBN, curve space split across MPI ranks
findprimesmpecmcgbnmpi the same three certificates the same GPU kernels, with MPI splitting the range across ranks and the curve space again inside each slice
findprimesmpecmcgbngmpecppmpi ECPP (Atkin–Morain) via GMP-ECPP, every certificate re-verified in-process the same two MPI axes; ECPP's own ECM stage on the GPU through CGBN

The fifth is the one that lifts the ceiling. The first four all rest on the same theorem family and need N±1 to factor far enough, which for an arbitrary N stops happening a little under 1,024 bits. ECPP does not factor N±1 at all — it manufactures a curve whose order it can factor and recurses — so its cost tracks the width of N and nothing else. Measured here on one candidate: nextprime(2^1024) is proven in 32 s by the ECPP build and not at all in 900 s by findprimesmpecmcgbn.

Your Linux distro should have GMP-ECM available. Recent Fedora and Ubuntu definitely do.

This was tested on Fedora 41 and 44 with CUDA 12.9 (Ada) and 13.2 (Blackwell). Hardware I used for testing:

  • Laptop with an RTX 4080 Ada (sm_89) and a Core(TM) i9-14900HX.
  • Intel NUC 13 Extreme with an RTX PRO 4500 Blackwell (sm_120) and a Core(TM) i9-13900K.

No probable-prime tests.

mpz_probab_prime_p() is called nowhere and Miller-Rabin appears nowhere. Every answer is a proof: primality by certificate, compositeness by an exhibited divisor or a failed Fermat or Lucas congruence. That constraint is what the whole design serves — it is why ECM is here at all, since a certificate needs N±1 factored and ECM is what factors it.

The rule survives the library boundary too. Of GMP-ECPP's eight status codes exactly one — GMPECPP_PROVEN_PRIME, a completed certificate chain — is read as "prime"; GMPECPP_PROBABLY_PRIME is treated as undecided, and the one_step mode that produces it is never enabled. Every chain the library returns is then re-checked here from the numbers alone, so a proof is held rather than borrowed.

The five must agree candidate for candidate. Unrelated machinery over the same input is the strongest correctness signal available here, and verify.py cross-checks all five. The ECPP build makes that check considerably sharper: Pocklington/Morrison and Atkin–Morain share no theorem, no factoring target and no code path, only the candidate list.

Building

%>> make            # all five
%>> make clean      # cleanup

Requirements, with the versions this was verified against:

for verified with
GMP all five system libgmp
GMP-ECM (gmp-ecm-devel) the four ECM builds 7.0.6
GMP-ECPP (/usr/local/include/gmpecpp.h, /usr/local/lib64/libgmpecpp.so) the ECPP build 1.0.0, built with CUDA/CGBN
CUDA toolkit the three CGBN builds 12.9
CUDA toolkit the three CGBN builds 13.2
CGBN headers in /usr/local/include/cgbn the three CGBN builds
Open MPI the three CGBN builds 5.0.5

libgmpecpp.so lives in /usr/local/lib64, which is not on the default runtime search path, so the link carries an rpath for it. Without that the program builds and then dies at exec with libgmpecpp.so.1: cannot open shared object file.

The CPU pair builds warning-free under both clang++ and g++ (make CXX=g++), at C++17, C++20 or C++23. The CGBN trio is built by nvcc; GPU_ARCH defaults to sm_89, so change it for a different card.

To run any MPI build under mpirun, put the Open MPI bin directory on PATH:

%>> export PATH=/usr/lib64/openmpi/bin:$PATH
%>> mpirun --oversubscribe -np 4 ./findprimesmpecmcgbn            -s 1000000 -e 1000200 -T 8
%>> mpirun --oversubscribe -np 4 ./findprimesmpecmcgbnmpi         -s 1000000 -e 1000200 -T 8
%>> mpirun --oversubscribe -np 4 ./findprimesmpecmcgbngmpecppmpi  -s 1000000 -e 1000200 -T 8

Open MPI 5 launches through PRRTE and finds prterun by PATH lookup, not by its own install prefix. Run without mpirun, any of them is a single-rank job.

Usage

Usage: findprimesmpecmcgbnmpi [ -s <range-start> (default 18446744073709551615)]
                    -e <range-end> (required)
       [ -b <number-of-bits> (default 128)]
       [ -T <number-of-threads> (default 4)]
       [ -f <output-file> (default stdout)]
       [ -p (print header at the top)]
       [ -t (print prime discovery time)]
       [ -B <ecm-stage-1-bound> (default 2000, escalates)]
       [ -C <cpu-ecm-curves-per-split> (default 100)]
       [ -G <gpu-ecm-curves-per-launch> (default 4096)]
       [ -N <mpi-ranks> (default: as launched by mpirun)]
       [ -c <ranks-sharing-each-range-slice> (default 1)]
  run under mpirun to spread the range over several hosts:
    mpirun -np 8 --host a,b,c,d ./findprimesmpecmcgbnmpi \
        -e <end> -N 8 -c 2   # 4 range slices, 2 ranks each

That is findprimesmpecmcgbnmpi -h; the others print the same flags minus the ones they do not have. -B/-C are on all four ECM builds, -G on all three CGBN builds, -N/-c on the two two-axis builds, and findprimesmp has none of them.

-T is on all five, but it means slightly more on the ECPP build: as well as sizing the Phase A worker pool, it sizes libgmpecpp's ECM worker threads inside each proof. That is safe because the two phases never hold threads at the same time — Phase A's pool is joined before Phase B starts.

findprimesmpecmcgbngmpecppmpi adds two flags of its own:

flag meaning
-P ECPP floating-point working precision in bits (default: the library's)
-S ECPP discriminant-sweep threads (default 1)

-S selects which of libgmpecpp's two internal thread axes a proof uses. At the default 1, -T drives the ECM axis; above 1, the sweep axis takes -S threads and ECM drops to one. They are mutually exclusive because the library says so — "setting both oversubscribes, since sweep workers each run their own ECM". See the ECPP section for which to want.

-P is GMP-ECPP's most consequential knob — the class-polynomial layer is arbitrary-precision floating point and its cost grows steeply with it. It cannot trade correctness for speed: too low a precision makes a class polynomial fail its own consistency check, the discriminant is abandoned and the sweep moves on, so the failure mode is lost time rather than a wrong answer.

Note -h exits 1 like an argument error rather than 0. Primes go to stdout (or -f); progress and the summary line go to stderr.

%>> ./findprimesmpecm -s 1000000 -e 1000200 -T 4 2>/dev/null
1000003
1000033
1000037
...
1000193
1000199

The default range start is 2^64 - 1: these are big-number benchmarks, not fast prime sieves.

How primality is decided

findprimesmp divides by every odd number up to sqrt(N). Correct, and completely impractical past 2^64 — one prime candidate there costs ~37 s.

The ECM builds resolve a candidate in five stages, cheapest first:

  1. Cheap compositeness proofs. A small divisor, then one Fermat congruence to base 2. Between them these settle nearly every composite for the price of one modular exponentiation.
  2. N-1 (Pocklington). Factor a divisor F of N-1 with F > sqrt(N) — trial division for the small part, ECM for the rest — then for each prime q | F exhibit a base a with a^(N-1) ≡ 1 (mod N) and gcd(a^((N-1)/q) - 1, N) = 1. Every prime factor of N is then 1 mod F and so exceeds sqrt(N); two of those will not fit inside N.
  3. N+1 (Morrison). When N-1 will not come apart far enough. Factor a divisor F of N+1 with (F-1)² > N, pick D with Jacobi symbol (D/N) = -1, and exhibit Lucas parameters giving U_{N+1} ≡ 0 (mod N) and gcd(U_{(N+1)/q}, N) = 1. N-1 and N+1 are unrelated numbers, so this is an independent attempt rather than a retry.
  4. Combined bound. When neither side reaches sqrt(N) alone, both congruences still hold at once, and the Chinese remainder theorem turns the pair into a lower bound on the smallest prime factor. Each side then needs only about N^(1/4).
  5. Trial division, only if no certificate could be produced.

Only half of N±1 ever has to be factored. Insisting on the complete factorisation is the obvious reading of the theorems and it stalls a couple of hundred bits in.

findprimesmpecmcgbngmpecppmpi keeps stage 1 byte for byte — same digit screen, same small divisors, same single Fermat congruence — and replaces stages 2–4 with ECPP. See its own section below.

GPU and MPI

ECM stage 1 is a scalar multiplication [s]P on a Montgomery curve mod M, and every curve is an independent walk — one CGBN instance per curve, thousands per launch. Curves use the inversion-free parameterisation (a24 = d, start (2:1)), which matters for more than speed: building a curve needs no modular inverse, so d is just a counter, and a counter is what lets MPI ranks take provably disjoint slices of curve space without exchanging anything.

All three CGBN builds run that identical kernel. What separates the first two is what MPI divides; the third is covered in its own section below.

findprimesmpecmcgbn — curves

Every rank scans the whole range and the ranks divide ECM's curve space. Rank r on attempt a takes slot a*nranks + r; an MPI_Allreduce(MIN) elects the lowest-ranked finder and MPI_Bcast hands its factor to everyone, so all ranks leave with the same factor. Scanning the range on every rank is duplicated work by design — it is the cheap half, and replicating it keeps the ranks in lockstep with no communication.

k hosts try k times the curves per attempt. That is a throughput win on ECM's probabilistic curve search, and it is the only thing here that makes a single hard candidate finish sooner.

findprimesmpecmcgbnmpi — the range, then curves inside it

The same kernel and the same certificates, laid out on two axes:

-N 8 -c 2      slice 0        slice 1        slice 2        slice 3
               rank 0 rank 1  rank 2 rank 3  rank 4 rank 5  rank 6 rank 7
               \____________/ \____________/ \____________/ \____________/
                curve split    curve split    curve split    curve split

-N says how many of the ranks mpirun launched take part; -c says how many of them share each range slice. The active ranks are cut into ceil(N/c) groups of c consecutive ranks. Group g owns range slice g — a contiguous, disjoint run of odd candidates — and the ranks inside a group split curve space between themselves exactly as findprimesmpecmcgbn does.

So the two programs are the ends of one dial. -c 1 (the default) is a pure range split; -c N is the pure curve split findprimesmpecmcgbn already was; anything between is a rectangle — ceil(N/c) candidates in flight, c GPUs on each.

The axes are not interchangeable.

range split (-c 1) curve split (-c N)
makes one hard candidate finish sooner no yes
turns k nodes into candidates/second yes no
communication during the scan none a collective per ECM attempt
Phase B full thread pool one thread, strictly in order

That last row is the real cost of the inner axis. Ranks in a group must reach every collective in the same order, so within a group Phase B walks a sorted list on a single thread, one rank decides every CPU-side ECM split for the group, and the GPU-or-CPU choice has to be unanimous (a rank without a GPU would return a different factor and desynchronise everything after it). A group of one has none of those constraints, because it has nobody to agree with.

Groups, on the other hand, never have to agree about anything. They share no candidate, so a cluster with GPUs on some nodes and not others simply runs its groups at different speeds and still prints the same primes.

Reporting. Each group's root packs its primes as length-prefixed records — a 32-bit word count, then that many little-endian limbs — and one MPI_Gatherv on the whole job hands them to rank 0, which merges them into the sorted set its own slice filled and prints the union. Nothing on the wire is fixed-width, so 128-bit primes and 16,000-bit primes travel through the same code. Non-root ranks of a group send nothing (a group replicates its slice, so the root already speaks for them), and rank 0 sends nothing (its primes are already in the set).

Because the slices are disjoint and a std::set re-sorts the union, the printed list is byte-identical at every -N, -c and -T — verified against findprimesmpecm across -np/-N 1–8 and -c 1–4 at 20, 64, 96, 128, 256 and 384 bits, including the shapes that do not divide evenly (-np 3 -c 2, -np 6 -c 4) and the degenerate ones: -c above the rank count, -N above the launched world, and a range with fewer candidates than groups. The 384-bit sweep is the one that exercises everything at once — it is where N-1 starts failing and the N+1 Morrison certificate has to carry a candidate, and where the GPU does real work (14 splits in 36 launches at -c 1).

Both flags only select from what mpirun launched. A job's world size is fixed at launch, so -N splits MPI_COMM_WORLD down and says so when asked for more than it has; it cannot conjure ranks up. (Growing the job would mean MPI_Comm_spawn, which places children on the local host unless separately handed a host list — so it would not reach other nodes, which is the point.) Reaching several nodes is mpirun's job:

%>> mpirun -np 8 --host a,b,c,d ./findprimesmpecmcgbnmpi -e <end> -N 8 -c 2

On a single GPU, more ranks is slower on either axis for findprimesmpecmcgbn and findprimesmpecmcgbnmpi -c > 1 — ranks queue on the one device and replicate the scan. Both designs target one rank per GPU across hosts; that configuration is not measured here, only that the distribution is correct. The -c 1 range split is the exception and does speed up even on one box, because the ranks are then doing disjoint work rather than the same work twice.

findprimesmpecmcgbngmpecppmpi — primality by ECPP

Same range scanner, same candidate enumeration, same output, same two MPI axes. What changes is the one thing in the middle: where the other three certificate builds prove a survivor with Pocklington, Morrison, or their combined CRT bound, this one hands it to GMP-ECPP (libgmpecpp) — an Atkin–Morain elliptic curve primality prover, itself built against CUDA/CGBN.

Why

All three N±1 certificates need a factored divisor of N-1 or N+1 reaching sqrt(N) (or N^(1/4) each for the combined bound). For an arbitrary N those are random numbers and about half their bits have to come apart, which ECM stops managing a little under 1,024 bits. That is a property of the particular N, not of the code, and no amount of GPU throughput moves it.

ECPP sidesteps the problem rather than attacking it. It searches discriminants D until the order m of a CM curve over Z/NZ is one it can split as m = k·q with q prime and large, then exhibits a point P whose order is divisible by q. That reduces primality of N to primality of the strictly smaller q, and the prover recurses. It never factors N±1 at all, so its cost tracks the width of N and nothing else.

The proof is checked, not borrowed

The library returns the chain, and VerifyCertificate() re-derives every link of it here, from the numbers alone. For each step it checks that the curve is non-singular mod n, that P really lies on it, that q | m, that q clears the Goldwasser–Kilian bound (n^(1/4)+1)², that [m/q]P is not the point at infinity, that [q]([m/q]P) is, and that the next step's n is this step's q. The terminal q is then settled outright by the small-prime sieve. A chain that fails any of those is discarded and reported — never silently downgraded to a "probably".

The curve arithmetic for that is affine and inverts with mpz_invert, which is the right choice precisely because n is not yet known to be prime: a failed inversion has exhibited a non-trivial gcd with n, so the slow representation is also the one that cannot quietly produce nonsense on a composite modulus.

The check is live and it works, established by injection rather than by inspection. Perturbing py before each VerifyStep() call makes all 40 certificates in a 10^12 window get rejected (rejected certificates: 40, trial-division fallbacks: 10). Breaking the chain linkage instead rejects 32 of the 40 and spares exactly two — precisely the two candidates whose chains are a single step, which that injection cannot reach. In both cases the printed prime list is still correct, because the fallback catches what the corrupted verifier threw away.

Threads, ranks, and what each one buys

Two facts about the library have to be kept apart, because conflating them builds the wrong program.

gmpecpp_prove() still serialises against itself. Its header says so:

This call is thread safe in the sense that concurrent calls will not corrupt each other, but they are serialised internally: the underlying prover keeps global state. For parallelism, run separate processes.

So Phase B here is a serial loop over the survivors at every -c. A thread pool wrapped around it would be a queue with extra steps.

But a single proof is now threaded internally, since libgmpecpp was parallelised, and that is where -T goes. Two axes are exposed, and they are mutually exclusive:

drives scales deterministic
-T ECM curves inside the proof yes — 12.59 s → 3.89 s on nextprime(2^512), 1 → 32 threads no: changes which factor is found
-S the discriminant sweep weakly — 12.59 s → 10.33 s, then flat; no effect at all on inputs that succeed on an early D yes — any -S > 1 reproduces any other

-T is the default axis. -S is there for when a reproducible certificate matters more than the clock. Neither can change the prime list — verified: -T 4, -T 8, -T 32, -S 2 and -S 8 produce byte-identical output, matching findprimesmpecmcgbnmpi.

-N still scales, and it composes with -T — but on one box they compete for the same cores, so it is the product that matters and the split between them barely does (see Performance). -N remains the only axis that reaches another host.

-c is the one that did not improve. It still splits the search — rank r on attempt a takes seed slot a·c + r, elected by the same Allreduce(MIN)/Bcast pair — but a gmpecpp_prove() call cannot be interrupted, so the group is as slow as its slowest member and a wider group does not make a hard candidate finish sooner. What it buys is c shots per attempt at avoiding a give-up, since OUT_OF_DISCRIMINANTS, FACTORING_BOUND and a certificate that fails verification are all seed-dependent. That is a robustness win on a rare path.

findprimesmpecmcgbnmpi findprimesmpecmcgbngmpecppmpi
makes one hard candidate finish sooner -c > 1 -T
-c > 1 improves the odds of settling a candidate at all no yes
-T accelerates Phase B yes (-c 1) yes, inside each proof
Phase B threading pool if c = 1 serial loop; threads live in the library
recommended shape for a range scan -c 1 -c 1, more firmly

Where CGBN is used

On both sides of the boundary. libgmpecpp is itself built against the CGBN fork and runs the ECM stage of its downrun on the GPU when asked, which -G sizes. The program also keeps its own CGBN ECM stage-1 kernels — byte for byte findprimesmpecmcgbnmpi's — because they are the only ECM whose curve space this program can slice across ranks itself, and because they have a real job: corroborating a GMPECPP_COMPOSITE verdict that arrived without an exhibited divisor. A verdict is not a divisor, and this program will not treat one as though it were.

That path is rare but not dead code. It fires on base-2 Fermat pseudoprimes, which is exactly what a Phase A survivor that is not prime must be — one turns up in the 1000-wide window at 2^64, and one in the 400-wide window at 2^512 (where the GPU took the split).

Performance

10^18 + 0..1000 — 23 primes, identical output from both:

findprimesmp findprimesmpecm
wall, -T 8 70.5 s 0.04 s
wall, -T 32 33.5 s 0.04 s

Scaling, same 1000-wide window at -T 8:

10^12 10^15 10^18
findprimesmp 0.07 s 1.83 s 70.47 s
findprimesmpecm 0.00 s 0.01 s 0.03 s

Trial division tracks sqrt(N) as advertised. The certificates are flat in N because their cost follows how hard N±1 is to factor.

N±1 against ECPP

Wall clock, -T 8, single rank, RTX 4080 Laptop / i9-14900HX. Identical output from all three at every row.

window findprimesmpecm findprimesmpecmcgbnmpi findprimesmpecmcgbngmpecppmpi
10^18 + 0..1000 0.03 s 2.03 s 2.34 s
2^128 + 0..1000 0.10 s 2.09 s 2.54 s
2^256 + 0..400 36.06 s 28.23 s 3.01 s
2^384 + 0..400 126.71 s 323.22 s 3.60 s
2^512 + 0..400 253.12 s 361.42 s 17.79 s

The ECPP column is with the parallel libgmpecpp. On the single-threaded one the same program measured 2.25 / 3.27 / 5.09 / 5.74 / 34.41 s, so threading inside each proof is worth about 1.7× at 256 and 384 bits and 1.9× at 512.

Three things to read off that.

The crossover sits between 128 and 256 bits. Below it the N±1 certificates are unbeatable — a Pocklington proof at 128 bits is a handful of modular exponentiations, while ECPP has to run a discriminant sweep whatever the size. Above it the positions reverse and keep reversing: against findprimesmpecmcgbnmpi on the same window, 9.4× at 2^256, 90× at 2^384 and 20× at 2^512.

About 2 s of the CGBN builds' floor is CUDA context creation, paid once per rank whether or not a kernel ever launches. At 10^18 that is the entire measurement, which is why findprimesmpecm wins those rows and why neither number there means anything about the certificates.

2^384 is where findprimesmpecmcgbnmpi is at its worst (323 s, slower than the CPU build's 127 s): it is the width at which N-1 starts failing outright, so candidates fall through to the N+1 Morrison certificate and to GPU ECM on cofactors that are hard by construction. ECPP does not care, and finishes the same window in 3.60 s.

Rank scaling on one GPU

Range 2^256 + 0..4000 — 2000 odd candidates — -T 8, wall clock, one RTX 4080 shared by every rank:

shape findprimesmpecmcgbnmpi findprimesmpecmcgbngmpecppmpi
-np 1 -N 1 -c 1 155.85 s 15.54 s
-np 2 -N 2 -c 1 157.42 s 11.55 s
-np 4 -N 4 -c 1 158.61 s 10.95 s
-np 8 -N 8 -c 1 161.41 s 12.26 s
-np 2 -N 2 -c 2 278.58 s 22.85 s
-np 4 -N 4 -c 4 173.41 s 37.26 s

findprimesmpecmcgbnmpi is flat on the range axis — 155.85 s to 161.41 s from one rank to eight, a slight loss rather than a gain. Its time is GPU ECM, and the ranks queue on one device however disjoint their slices are. That program is built for one rank per GPU across hosts.

The ECPP build does scale on the range axis, because most of its time is host-side class-polynomial and modular arithmetic rather than kernels — the ranks compete for cores, of which there are 32, not for the one device. It stops improving at eight, where the per-rank CUDA context and the uneven distribution of primes across slices catch up.

Note what in-proof threading did to that axis: the one-rank figure halved (31.63 → 15.54 s) while the four-rank figure improved much less (15.41 → 10.95 s), so ranks now buy 1.42× where they used to buy 2.05×. The threads got there first, and the cores are the same 32.

Both are slower on the pure curve axis, for the same reason: ranks in a group replicate the slice, and no candidate in this range is pathological enough to want the extra curves or seeds.

Threads against ranks

On one box the two compete for the same cores, so it is the product that matters and the split barely does. Range 2^512 + 0..400 at a constant 32:

shape wall
-np 1 -T 32 15.13 s
-np 2 -T 16 13.91 s
-np 4 -T 8 15.15 s
-np 8 -T 4 18.80 s

Nearly flat, with a shallow optimum around two to four ranks and a real loss at eight — where eight CUDA contexts and a static range split that strands ranks behind the slowest slice both begin to bite. Anything in the middle is within noise of the best, so this is not worth tuning hard.

The thread axis on its own, single rank:

window -T 4 -T 8 -T 16 -T 32
2^384 + 0..400 4.32 s 3.94 s 3.73 s 3.96 s
2^512 + 0..400 24.62 s 22.13 s 16.05 s 15.18 s

Narrower ranges flatten sooner, because per-proof ECM is a smaller share of a scan that is mostly Phase A and CUDA startup.

The two thread axes, measured

A range scan dilutes the effect with Phase A and CUDA startup, so these are measured against the library directly on one candidate at a time, CUDA backend. This is the evidence for -T being the default axis.

ECM curves (-T) — independent trials, one success ends the search:

prime 1 4 8 16 32 best
nextprime(2^384) 4.47 s 2.47 s 2.22 s 2.02 s 1.87 s 2.4×
nextprime(2^512) 12.59 s 5.79 s 5.26 s 4.47 s 3.89 s 3.2×
nextprime(2^1024) 55.81 s 34.93 s 27.89 s 25.24 s 24.87 s 2.2×

Discriminant sweep (-S) — speculative, and the smallest successful discriminant wins, so the losing workers' output is discarded:

prime 1 2 4 8 16 best
nextprime(2^384) 4.47 s 4.40 s 4.44 s 4.42 s 4.44 s 1.0×
nextprime(2^512) 12.59 s 11.31 s 10.33 s 10.36 s 10.85 s 1.2×
nextprime(2^1024) 55.81 s 53.85 s 53.98 s 1.0×

The sweep axis is essentially flat. Most inputs succeed on one of the first few discriminants, so there is little sweep to divide, and what the extra workers do find is thrown away when a smaller D succeeds. It earns its place only for being deterministic — per-D seeding makes each attempt a pure function of (D, N, seed), so any -S > 1 reproduces any other.

What the parallel library bought

Same program, same inputs, before and after libgmpecpp gained threads. The "after" column is -T 8, which is not even the best -T:

measurement serial library parallel, -T 8
2^256 + 0..400 5.09 s 3.01 s 1.7×
2^384 + 0..400 5.74 s 3.60 s 1.6×
2^512 + 0..400 34.41 s 17.79 s 1.9×
nextprime(2^512) 13.6 s 7.2 s 1.9×
nextprime(2^768) 10.3 s 6.4 s 1.6×
nextprime(2^1024) 56.6 s 31.9 s 1.8×
1125*2^1024+1 (Proth) 345.6 s 219.4 s 1.6×
2^256 + 0..4000, 1 rank 31.63 s 15.54 s 2.0×
2^256 + 0..4000, 4 ranks 15.41 s 10.95 s 1.4×

The last two rows are the interesting pair: threading nearly halved the one-rank figure but improved the four-rank figure much less, because the ranks were already using those cores.

Bit Width

There is no width cap in any of the builds. What bounds the N±1 ones is the certificate needing a factored divisor of N±1 to reach sqrt(N) — so their ceiling depends on how N±1 factors, not on how big N is:

N-1 prime width findprimesmpecmcgbn
smooth by construction 6223*2^16384+1 16,397 bits 4.1 s
arbitrary nextprime(2^768) 769 bits 280 s
arbitrary nextprime(2^1024) 1,025 bits not certified in 900 s

For Proth-form primes k*2^n+1, N-1 = k*2^n is factored by inspection and width barely registers. For an arbitrary prime, roughly half of N-1 has to come apart, and that wall arrives just under 1,024 bits. A range scan costs what its unluckiest candidate costs.

ECPP has the opposite shape, and the two ceilings are close to complementary:

prime width N±1 builds findprimesmpecmcgbngmpecppmpi
nextprime(2^512) 513 bits ~9 s 7.2 s
nextprime(2^768) 769 bits 280 s 6.4 s
nextprime(2^1024) 1,025 bits not in 900 s 31.9 s
1125*2^1024+1 (Proth) 1,035 bits 2.0 s 219.4 s
6223*2^16384+1 (Proth) 16,397 bits 4.1 s out of reach

At -T 8; on the single-threaded library these were 13.6 / 10.3 / 56.6 / 345.6 s. Note that 513 bits has now moved below the N±1 figure too, so the crossover on single arbitrary candidates has come down.

ECPP's cost tracks the width of N and is indifferent to its form, so it walks through the 1,024-bit wall the N±1 builds cannot pass — and gives up the one case where they are unbeatable, the Proth primes whose N-1 is factored by inspection. Note also that ECPP is not monotonic in width: 768 bits came out faster than 512 here, because what actually costs time is how long the discriminant sweep runs before it finds a curve order it can split, and that is luck rather than size.

For a range scan at an arbitrary magnitude, which is what these programs actually do, ECPP is the one that does not have a wall.

Testing

%>> pip3 install sympy
%>> python3 verify.py          # needs sympy
%>> ./verify-mpi.sh            # the rank shapes verify.py cannot reach

For each binary verify.py runs 8 ranges × 10 thread counts against a computed reference, asserting both that the output matches and that every -T agrees — then a cross-check between the binaries. The ECPP build also gets a thread-axis check at 2^256, where -T 8, -T 32, -S 2 and -S 8 must all reproduce the reference: that is the assertion that libgmpecpp's internal threading cannot reach the prime list. The ECM builds additionally get ranges at 2^64, 2^96, 2^256 and 2^384, and the ECPP build one more at 2^512, which is past where the others can follow.

verify.py drives all three MPI builds as a single rank, which exercises none of their distribution. verify-mpi.sh covers that: it sweeps every -np, and for the two two-axis programs every -N/-c rectangle, and requires every shape to produce output identical to findprimesmpecm's — 26 shapes in all, including the degenerate ones (-c larger than the rank count, clamped to one slice; -N larger than the world, diagnosed and run with what it has; -N selecting down, so the ranks left out must leave before the first collective; and a range with fewer candidates than groups, whose empty slices must retire quietly rather than error).

That is the check that earns its keep: a group whose ranks fall out of step pairs an Allreduce with the wrong cofactor, and a Gatherv with a miscomputed displacement drops a slice — both show up at once as a wrong or missing prime.

Two intentional quirks the reference model encodes: only odd candidates are tested, so 2 is never reported, and 1 is reported as prime. Both are preserved deliberately — all five programs agree, and the cross-check between them is worth more than the tidier answer.

License

MIT License - see LICENSE, which is the verbatim MIT text. Copyright (C) 2019-2026 Stefan Teleman.

AI

I used Claude Opus 5.0 for some of the very difficult parts of the primality proofs and to test / verify / find bugs.