With the arrival of Generics to Go 1.18, a new programming model has arrived to Go: functional stream processing. This post evaluates some current libraries providing such functionality, and compares the achieved performance in single-thread streams.

Contenders

The first contender is the mariomac/gostream library, which was created by myself. Since there aren't popular or widely used stream libraries in Go, we had to Google for a while to find the other alternatives to compare:

mariomac / gostream primetalk / goio vladimirvivien / automi koss-null / lambda
Stars 58 52 774 114
Contributors 1 1 3 1
Generics
Parallelization

We found some other interesting libraries, but they are not compared here for diverse reasons:

Test scenario: anagram finder

To test the performance libraries, whe choose a simple example using a few subset of operations that is common to all the libraries:

The scenario receives a slice of words and returns a map with a set of anagrams. The pseudo-code would be something like:

function Anagrams(input: []string)
    Returns :Map<key: set of letters, value: list of anagrams to the key>
{
    return input.ConvertToStream()
                .Filter(MoreThanOneChar)
                .Map(ToLowerCase)
                .Map(SingleWordToMap)
                .Reduce(Accumulate)
}

The implementations rely on some provided functions:

Implementations

The different implementations for the anagrams finder can be completely seen in the following repository:

https://github.com/mariomac/go-stream-benchmarks

Baseline implementation

Functional processing might come at a cost, so we also provide an iterative implementation that would act as "theoretically fastest" implementation and would give us a better perspective of the performance of the functional approaches.

func Baseline(words []string) map[string]map[string]struct{} {
	var swa []Anagrams
	for _, w := range words {
		if !MoreThanOneChar(w) {
			continue
		}
		swa = append(swa, SingleWordToMap(strings.ToLower(w)))
	}
	if len(swa) == 0 {
		return nil
	}
	seed := swa[0]
	for i := range swa[1:] {
		seed = Accumulate(seed, swa[i])
	}
	return seed
}

mariomac/gostream

The mariomac/gostream library implements the above pseudo-code as following:

func Mariomac(words []string) map[string]map[string]struct{} {
	wordStream := stream.OfSlice(words).
		Filter(MoreThanOneChar).
		Map(strings.ToLower)
	groupedWords, _ := stream.Map(wordStream, SingleWordToMap).
		Reduce(Accumulate)
	return groupedWords
}

Observe that the above pseudo-code defines the whole stream pipeline in a fluent style (the result of a method invocation is immediately invoked without storing it into a variable). However, here we require an intermediate groupedWords variable. The reason is a limitation in the Go generics' implementation that prevents that a Map method returns an element with different type of the argument. So here we need to invoke it as a function.

primetalk/goio

primetalk/goio would implement anagram finder would be implemented as follows:

func Goio(words []string) map[string]map[string]struct{} {
	wordStr := stream.FromSlice(words)
	fWords := stream.Filter(wordStr, MoreThanOneChar)
	lfWords := stream.Map(fWords, strings.ToLower)
	swAgrs := stream.Map(lfWords, SingleWordToMap)
	swAgrsSl := stream.ToSlice(swAgrs)
	swArgsRs, err := io.ObtainResult(io.Continuation[[]Anagrams](swAgrsSl))
	if err != nil {
		panic(err)
	}
	return slice.Reduce(swArgsRs, Accumulate)
}

You will notice some subtle changes in the different API design decisions.

vladimirvivien/automi

vladimirvivien/automi does not provide yet Generics' support so you will notice that at some point we need to do a type cast. The API is clean but at the cost of potential runtime errors, since methods accept unchecked interface{} as argument.

func Automi(words []string) map[string]map[string]struct{} {
	var res Anagrams
	str := stream.New(words).
		Filter(MoreThanOneChar).
		Map(strings.ToLower).
		Map(SingleWordToMap).
		Reduce(Anagrams{}, Accumulate).
		Into(collectors.Func(func(i interface{}) error {
			res = i.(Anagrams)
			return nil
		}))
	if err := <-str.Open(); err != nil {
		panic(err)
	}
	return res
}

koss-null/lambda

And last, but not least, koss-null/lambda is providing an API that is very similar to mariomac/gostream.

func Lambda(words []string) map[string]map[string]struct{} {
	wordStream := pipe.Slice(words).Parallel(1).
		Filter(MoreThanOneChar).
		Map(strings.ToLower)
	groupedWords := pipe.Map(wordStream, SingleWordToMap).
		Reduce(Accumulate)
	return *groupedWords
}

Please notice that we limited the parallelization to 1 single worker, as not all the contenders implement parallelization, and this blog post compares the most common single-thread scenario (e.g. willing to keep the order of the resultant streams).

Benchmark results

We benchmarked the different implementations with Go 1.19.3 on an Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz running a Mac OS X

$ go test -benchmem -bench=. -benchtime 10s ./anafind/...
(...)
BenchmarkBaseline-12   62163    192633 ns/op  199315 B/op   2563 allocs/op
BenchmarkMariomac-12   59858    198622 ns/op  191516 B/op   2566 allocs/op
BenchmarkLambda-12     47329    257454 ns/op  243270 B/op   3812 allocs/op
BenchmarkGoio-12       17647    678838 ns/op  590703 B/op  14333 allocs/op
BenchmarkAutomi-12     28183    441176 ns/op  313544 B/op   4669 allocs/op

More graphically:

Conclusions

The tests demonstrated that mariomac/gostream is currently the most efficient functional library for functional-like in-memory stream composition. Both in terms of CPU and memory generation.

It was surprisingly near the baseline implementation. However, other dummy micro-benchmarks shown that a functional stream-based pipeline might be several times slower than a proper iterative implementation.

However, other libraries such as koss-null/lambda could beat mariomac/gostream if you enable parallelization, as this feature is not yet available in the later.

I could have missed some features improving the readability or performance of the rest of contenders, or there could be more stream-processing libraries around there that would beat any of the implementations of this blog post. In that case, feel free to leave a comment informing about them and I will do my best to update the post with the new alternatives and results.

Update (Dec 5th 2022)

A reader of this article kindly contributed with another library that seems to outperform by a smidgen the Gostream library, in terms of speed, with similar memory generation: github.com/kamstrup/fn.

The API looks slightly different:

func Fn(words []string) map[string]map[string]struct{} {
	wordsLower := fn.ArrayOf(words).
		Where(MoreThanOneChar).
		Shape(strings.ToLower)
	singleWords := fn.MapOf(wordsLower, SingleWordToMap)
	return fn.Into(Anagrams{}, Accumulate, singleWords)
}

In their own words:

I think the primary difference between our approaches is that you seem closer to Java Streams, and I am a bit closer to Clojure. Fx. my Seq API is designed to work on "immutable sequences", ie you get a head+tail back when you walk a Seq, and the underlying Seq is stateless. Whereas your stream lib keeps a stateful iterator.

Digging into its code, I also see another basic difference: while mariomac/gostream is designed for lazy evaluation of each stream's element, kamstrup/fn performs eager evaluation.