3

I have the following code to perform the Sieve of Eratosthenes in F#:

let sieveOutPrime p numbers =
   numbers
   |> Seq.filter (fun n -> n % p <> 0)

let primesLessThan n =
    let removeFirstPrime = function
       | s when Seq.isEmpty s -> None
       | s -> Some(Seq.head s, sieveOutPrime (Seq.head s) (Seq.tail s))

    let remainingPrimes =
       seq {3..2..n}
       |> Seq.unfold removeFirstPrime

    seq { yield 2; yield! remainingPrimes }

This is excruciatingly slow when the input to primesLessThan is remotely large: primes 1000000 |> Seq.skip 1000;; takes nearly a minute for me, though primes 1000000 itself is naturally very fast because it's just a Sequence.

I did some playing around, and I think that the culprit has to be that Seq.tail (in my removeFirstPrime) is doing something intensive. According to the docs, it's generating a completely new sequence, which I could imagine being slow.

If this were Python and the sequence object were a generator, it would be trivial to ensure that nothing expensive happens at this point: just yield from the sequence, and we've cheaply dropped its first element.

LazyList in F# doesn't seem to have the unfold method (or, for that matter, the filter method); otherwise I think LazyList would be the thing I wanted.

How can I make this implementation fast by preventing unnecessary duplications/recomputations? Ideally primesLessThan n |> Seq.skip 1000 would take the same amount of time regardless of how large n was.

Will Ness
  • 70,110
  • 9
  • 98
  • 181
Patrick Stevens
  • 569
  • 3
  • 17
  • [it is](https://ideone.com/MAzTKO). I tried your code with n=10000, 20000 or 100000, observed the same run time. – Will Ness Mar 11 '18 at 14:10
  • but changing the `m` in `primesLessThan n |> Seq.skip m |> Seq.take 10` indeed changes the time and reveals it runs at about *~m^3* [***empirically***](http://en.wikipedia.org/wiki/Analysis_of_algorithms#Empirical_orders_of_growth), instead of the theoretical *~m^2* for your algorithm (producing *m* primes). cubic time is no picnic, for sure. :) (quadratic, too) – Will Ness Mar 11 '18 at 14:17
  • for *a* solution, see this [RosettaCode entry](http://rosettacode.org/wiki/Sieve_of_Eratosthenes#Functional) which [runs](https://ideone.com/VOIJ9i) at *m^1.4* empirically, at the tested range of producing *m = 100K..200K* primes. It uses different algorithm as well as a different, custom-made, type. (disclaimer: I'm not the author). – Will Ness Mar 11 '18 at 14:36
  • @scrwtp has already linked to an answer that says this, but I'll repeat it here for anyone who finds this question: **don't use Seq.tail in recursive code**. You probably think that because List.tail is O(1), Seq.tail is also O(1). It's not. Seq.tail is O(N), and will cause an O(N) algorithm to become O(N^2), or an O(N^2) algorithm to become O(N^3) and so on. (I see that you've already figured that out, but I want to repeat it because this is the second most common mistake I've seen with seqs. First most common mistake is forgetting that they are lazy). – rmunn Mar 12 '18 at 01:00
  • @rmunn In fact the reason I thought Seq.tail was O(1) was because the analogous concept in Python is O(1) - but the point is taken, thanks. – Patrick Stevens Mar 12 '18 at 09:03

2 Answers2

3

Recursive solutions and sequences don't go well together (compare the answers here, it's very much the same pattern you are using). You might want to inspect the generated code, but I'd just consider this a rule of thumb.

LazyList (as defined in FSharpX) does of course come with unfold and filter defined, it would have been quite bizarre if it didn't. Typically in F# code this sort of functionality is provided in separate modules rather than as instance members on the type itself, a convention that does seem to confuse most of those documentation systems.

scrwtp
  • 13,437
  • 2
  • 26
  • 30
1

As you probably know Seq is a lazily evaluated collection. Sieve algorithm is about filtering out non-primes from a sequence so that you don't have to consider them again.

However, when you combine Sieve with a lazily evaluated collection you end up do the filtering of the same non-primes over and over again.

You see much better performance if you switch from Seq to Array or List because of the non-lazy aspect of those collections means that you only filter non-primes once.

One way to improve performance in your code is to introduce caching.

let removeFirstPrime s =
   let s = s |> Seq.cache 
   match s with
   | s when Seq.isEmpty s -> None
   | s -> Some(Seq.head s, sieveOutPrime (Seq.head s) (Seq.tail s))

I implemented a LazyList that works alot like Seq that allows me to count the number of evaluations:

For all primes up to 2000.

  • Without caching: 14753706 evaluations
  • With caching: 97260 evaluations

Of course if you really need performance you use a mutable array implementation.

PS. Performance metrics

Running 'seq' ...
  it took 271 ms with cc (16, 4, 0), the result is: 1013507
Running 'list' ...
  it took 14 ms with cc (16, 0, 0), the result is: 1013507
Running 'array' ...
  it took 14 ms with cc (10, 0, 0), the result is: 1013507
Running 'mutable' ...
  it took 0 ms with cc (0, 0, 0), the result is: 1013507

This is Seq with caching. Seq in F# has rather high overhead, there are interesting lazy alternatives to Seq like Nessos.

List and Array run roughly similar but because of the more compact memory layout the GC metrics are better for Array (10 cc0 collections for Array vs 16 cc0 collections for List). Seq has worse GC metrics in that it forced 4 cc1 collections.

The mutable implementation of sieve algorithm has better memory and performance metrics by a large margin.