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.
Four 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 |
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 four must agree candidate for candidate. Unrelated machinery over the same
input is the strongest correctness signal available here, and verify.py
cross-checks all four.
%>> make # all four
%>> make clean # cleanup
Requirements, with the versions this was verified against:
| for | verified with | |
|---|---|---|
| GMP | all four | system libgmp |
GMP-ECM (gmp-ecm-devel) |
the three ECM builds | 7.0.6 |
| CUDA toolkit | the two CGBN builds | 12.9 |
| CUDA toolkit | the two CGBN builds | 13.2 |
CGBN headers in /usr/local/include/cgbn |
the two CGBN builds | — |
| Open MPI | the two CGBN builds | 5.0.5 |
The CPU pair builds warning-free under both clang++ and g++
(make CXX=g++), at C++17, C++20 or C++23. The CGBN pair is built by nvcc;
GPU_ARCH defaults to sm_89, so change it for a different card.
To run either 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
Open MPI 5 launches through PRRTE and finds prterun by PATH lookup, not by
its own install prefix. Run without mpirun, either program 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 other three print the same flags minus
the ones they do not have. -B/-C are on all three ECM builds, -G on both
CGBN builds, -N/-c only on findprimesmpecmcgbnmpi, and findprimesmp has
none of them. 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.
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.
Both CGBN builds run that identical kernel. What separates them is what MPI divides.
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 — ranks queue on the
one device, and with -c > 1 they also replicate the scan. Both designs target
one rank per GPU across hosts; that configuration is not measured here, only
that the distribution is correct.
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.
There is no width cap in any of the ECM builds. What bounds the answer is the certificate needing a factored divisor of N±1 to reach sqrt(N) — so the ceiling depends on how N±1 factors, not on how big N is:
N-1 |
prime | width | wall |
|---|---|---|---|
| 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.
Going further needs ECPP, which GMP-ECM does not provide.
%>> pip3 install sympy
%>> python3 verify.py # needs sympy
For each binary: 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 ECM builds additionally get ranges at
2^64, 2^96, 2^256 and 2^384.
verify.py drives both CGBN builds as a single MPI rank, which exercises
neither program's distribution. That is covered separately, and every shape
must produce byte-identical output:
#!/bin/bash
export PATH=/usr/lib64/openmpi/bin:$PATH
for np in 1 2 4; do
mpirun --oversubscribe -np $np ./findprimesmpecmcgbn -s ... -e ... -T 8
done
for np in 1 2 4 8; do
for c in 1 2 4; do
mpirun --oversubscribe -np $np ./findprimesmpecmcgbnmpi \
-s ... -e ... -T 8 -N $np -c $c
done
done
For findprimesmpecmcgbnmpi that is the check that earns its keep: a group
whose ranks fall out of step pair 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. Worth including in the sweep: -c larger than the
rank count (clamped to one slice), -N larger than the world (diagnosed, runs
with what it has), and a range with fewer candidates than groups (empty slices
must retire quietly, not error).
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 four 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.