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.
%>> 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: 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.
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:
- 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.
- N-1 (Pocklington). Factor a divisor
FofN-1withF > sqrt(N)— trial division for the small part, ECM for the rest — then for each primeq | Fexhibit a baseawitha^(N-1) ≡ 1 (mod N)andgcd(a^((N-1)/q) - 1, N) = 1. Every prime factor ofNis then1 mod Fand so exceedssqrt(N); two of those will not fit insideN. - N+1 (Morrison). When
N-1will not come apart far enough. Factor a divisorFofN+1with(F-1)² > N, pickDwith Jacobi symbol(D/N) = -1, and exhibit Lucas parameters givingU_{N+1} ≡ 0 (mod N)andgcd(U_{(N+1)/q}, N) = 1.N-1andN+1are unrelated numbers, so this is an independent attempt rather than a retry. - 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 aboutN^(1/4). - 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.
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.
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.
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 k× 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.
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.
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 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.
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 |
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).
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.
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.
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.
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.
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.
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.
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.
%>> 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.
MIT License - see LICENSE, which is the verbatim MIT text. Copyright (C) 2019-2026 Stefan Teleman.
I used Claude Opus 5.0 for some of the very difficult parts of the primality proofs and to test / verify / find bugs.