Introduction
While watching the series Prime Target, which turns the unpredictability of prime numbers into a plot device, I got curious: what are the nice ways to generate primes in Elixir?
Here are four, from the obvious to the parallel, each with the small detail that makes it correct.
1. The naive primality test
The simplest check: a number is prime if nothing between 2 and its square root divides it.
defmodule PrimeChecker do
def is_prime?(n) when n < 2, do: false
def is_prime?(2), do: true
def is_prime?(n) do
2..trunc(:math.sqrt(n))//1 |> Enum.all?(fn i -> rem(n, i) != 0 end)
end
end
PrimeChecker.is_prime?(11) # => true
PrimeChecker.is_prime?(15) # => false
Two details matter here. :math.sqrt/1 returns a float, and Elixir ranges
need integers, so it has to be wrapped in trunc/1. And the explicit //1
step is what makes the range empty rather than descending when the square
root is below 2 — without it, 2..1 would iterate [2, 1], and dividing by 1
would wrongly reject small primes like 3.
Simple, but slow for large numbers.
2. A recursive sieve
We can generate every prime up to a limit by repeatedly keeping a number and filtering out its multiples from the rest.
defmodule PrimeGenerator do
def sieve(limit) when limit < 2, do: []
def sieve(limit) do
2..limit |> Enum.to_list() |> remove_multiples()
end
defp remove_multiples([]), do: []
defp remove_multiples([head | tail]) do
[head | remove_multiples(Enum.filter(tail, &(rem(&1, head) != 0)))]
end
end
PrimeGenerator.sieve(30) # => [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
Much faster than checking each number in isolation, at the cost of holding the whole list in memory.
3. An infinite stream
Elixir’s Stream module lets us produce primes lazily, computing only as many
as we ask for.
defmodule PrimeStream do
def stream_primes do
Stream.iterate(2, &next_prime/1)
end
defp next_prime(n) do
next = n + 1
if PrimeChecker.is_prime?(next), do: next, else: next_prime(next)
end
end
PrimeStream.stream_primes() |> Enum.take(10)
# => [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
Stream.iterate/2 emits its seed and then feeds each value to the next call,
so this yields primes forever without ever building a full list. It’s still
sequential, though.
4. Parallel checks with Task.async_stream
For a range of candidates, Task.async_stream/3 checks several at once across
cores.
defmodule ParallelPrimes do
def generate(n) do
2..100_000
|> Task.async_stream(fn x -> {x, PrimeChecker.is_prime?(x)} end, max_concurrency: 4)
|> Stream.filter(fn {:ok, {_x, prime}} -> prime end)
|> Stream.map(fn {:ok, {x, _}} -> x end)
|> Enum.take(n)
end
end
ParallelPrimes.generate(10)
# => [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
The trick is what each task returns. Task.async_stream wraps results as
{:ok, value}, and value is whatever the function returned. If the function
returns just the boolean, you get back a list of {:ok, true} tuples, not the
primes. Returning the pair {x, is_prime?(x)} keeps the number attached, so
after filtering you can map back to the actual primes.
Which one to use
| Method | Good for | Trade-off |
|---|---|---|
| Naive check | Testing a single number | Slow at scale |
| Recursive sieve | All primes up to a limit | Holds the list in memory |
| Stream | An open-ended supply of primes | Sequential |
Task.async_stream |
Scanning a big range | Overhead hurts for small counts |
For generating a batch, the sieve is the pragmatic default. For an unbounded
source, the stream. And when the range is large and the check is the
bottleneck, Task.async_stream puts your cores to work — as long as you
remember to carry the number through the pipeline, not just the boolean.
Have comments or want to discuss this topic?
Send an email to ~bounga/public-inbox@lists.sr.ht