Sean Macdonald primes is a command-line interface for finding prime numbers, using the ancient and elegant Sieve of Eratosthenes. It’s a simple example of a Rust program and how darned fast Rust can be. I use it often in various config files and cronjobs, in order to mitigate the Thundering Herd problem.

When I come across values like 1009 or 3593 in config files, it serves as a quick clue that I’ve been there, surveyed the land, and made my mark. As the building blocks of all the integers, primes are holy. Working with them is a joy. It’s a communion with eternal mysteries. This is my journey in creating primes.

Planning & Analysis

What are my goals? They are this:

  1. I want to have fun learning Rust, specifically Edition 2024
  2. I want to see how performant my solution is (ie: include benchmarks)
  3. I want to implement the algorithm myself
  4. A CLI that finds prime numbers, aside from being fun to build, could be useful

Requirements

The basic API will look like this:

Primes Near

primes near takes one number as input and returns the nearest prime lower than that number, and the nearest prime higher. If the number you give it is itself prime, it returns 3 numbers.

primes near 25          # returns 23,27
primes near 13          # returns 11,13,17
primes near banana      # panics, because banana is not a number

Primes Between

Takes two numbers (unsigned integers) and returns all the primes in that range, inclusive.

primes between 17 29        # returns 17,23,29
primes between 65000 65100	# returns 65003,65011...65089,65099

Primes Beneath

Return all primes below a certain number.

primes beneath 17       # returns 2,3,5,7,11,13,17

Primes Is

Your basic primality test.

primes is 17            # returns yes

Design

A simple crate which can be easily installed using cargo, producing either a binary executable or Rust library, depending on what you want.

├── benches
│   └── example.rs        <-- benchmarks
├── Cargo.toml
├── README.md
├── src
│   ├── lib.rs            <-- core logic
│   ├── main.rs           <-- CLI
│   └── test.rs           <-- unit tests
└── target
    ├── criterion         <-- benchmark data
    ├── debug
    └── release           <-- binaries

Development

Let’s set up our IDE and get the nightly version of rust:

rustup toolchain install nightly
rustup default nightly

All the interesting logic is in ./src/lib.rs. Let’s start with beneath() which finds all prime numbers beneath a certain number. This will be the workhorse that powers all other functionality and where all our optimisations hinge.

// ./src/lib.rs

//  return all primes beneath (and maybe including) the value provided
pub fn beneath(upper: usize) -> Vec<usize> {
    let mut primes: Vec<usize> = Vec::with_capacity(n_primes_beneath(upper));
    for i in 2..=upper {
        if !is_multiple_of(i, &primes) {
            primes.push(i);
        }
    }
    return primes;
}

There is some insight to grok here. First off, our sieve is an iterative algorithm. You find primes by process of elimination, which in the most naive sense means going through all the numbers from 0 to n and checking if they are prime. This implies a linear time operation, but another feature of factorization is commutativity, which allows us to stop iterating at the square root of a number. That implies logarithmic time. Thirdly, our final value (a list of primes), as we build it, is useful as an intermediary data structure precisely because a prime number can be defined as a number that is not divisible by a smaller prime number. How do you know every even number must be eliminated? Because 2 is a prime, so any multiple of 2 is not a prime. Extrapolating this further, we say that any number that is a multiple of a prime is not a prime. This is the core insight of the Sieve of Eratosthenes.

The helper function n_primes_beneath() allows us to pre-allocate our vector capactity efficiently, giving us good space complexity. It’s possible to estimate the number of primes beneath a certain value using this consequence of prime number theorem:

$$\pi(n) \sim \frac{n}{\log n} \ \text{as} \ n \to +\infty$$

// ./src/lib.rs

//  find upper limit of number of primes beneath n
fn n_primes_beneath(n: usize) -> usize {
    if n < 2 {
        return 0;
    }
    if n == 3 {
        return 1;
    }
    if n == 4 {
        return 2;
    }
    let n_float = n as f64;
    let approx_primes: f64 = n_float / n_float.ln() + 1 as f64;
    approx_primes.ceil() as usize
}

Those are the basic primitives all other functions build on. For example between() just calls beneath() and lobs off the first bit:

// ./src/lib.rs

//  call beneath() and then slice off the early bits
pub fn between(a: usize, b: usize) -> Vec<usize> {
    let all_primes = beneath(b);
    let mut i = 0;
    for p in &all_primes {
        if p < &a {
            i += 1;
        }
    }
    let slice = &all_primes[i..];
    return slice.to_vec();
}

So how does primes perform? In terms of time complexity, it appears to have slightly worse than linear growth characteristics. This makes it infeasable (or at least less fun) on operations involving integers in the 1,000,000 to ∞ range. Primes benchmark

Obviously at this stage, it’s still just a toy. It should not be used for finding really big prime numbers, as there are much more advanced algorithms and bigger computers for that, but I am dedicated to only implementing algorithms I can understand, and improving this project in ways that make sense. In the future I will look into more suitable data structures, such as BTreeSet, and run benchmarks to determine how it affects performance. My goal is to get under linear growth.

Getting started

To install, you must first have cargo. Then do:

cargo install codemonk-primes-cli

Now you have a binary called primes. You can do all the shiz.

primes near 25          # returns 23,27
primes near banana      # panics, because banana is not a number
primes between 17 29    # returns 17,23,29
primes beneath 17       # returns 2,3,5,7,11,13,17
primes is 17            # returns yes

Including unit tests and benchmarks.

cargo test              # runs all tests
cargo bench             # runs all benchmarks

Contributions welcome 😎