Parallel Fun with Monoids
Playing with monoids and discovering for oneself how frequently they turn up in various guises and how much is expressed by them is a delight. Not only it brightens the day of important but frankly boring data analytics. Not only it challenges to discover efficient (with the constant time and space operation) monoids, which demands ingenuity. In the words of Guy Steele, inventing efficient associative combining operators is a very big deal. An efficient monoid opens up parallel implementations -- not just parallel but embarrassingly parallel: The input sequence arbitrarily partitioned among workers, all running in parallel with no races, dependencies or even memory bank conflicts. This is the ideal for multi-core, GPU or distributed processing.
We show why monoids are a big deal, and also a big fun -- even more than expected. Horner rule and Boyer-Moore majority voting -- commonly regarded as prototypically inherently sequential -- turn out monoids, and hence embarrassingly parallelizable. As we generalize monoids to observable monoids, we discover a new, vertical way to combine them, enabling efficient iterated grouping and aggregation directly on raw deserialized (big) data, on multicore or multi-processor. Multicore OCaml and OpenMP examples bear it out.
- Why Monoids?
- Folds and Reduce
- Fold as map-reduce, trivially
- Fold as map-reduce, more interestingly
- Generalized Horner rule
- Boyer-Moore majority voting
- Nested grouping-aggregation: Vertical monoid composition
- Conclusions
Why Monoids?
When asked to sum an array, what first comes to mind?
For some readers, it may be
int sum = 0;
for(int i=0; i<N; i++) sum += arr[i];
For others, something like
fold_left (+) 0 arr
(which, despite very different notation, ends up, with a good compiler, in the same machine code.)
The ideal, advocated by Guy Steele in his ICFP 2009 keynote, is
Σaᵢ
This notation tells what without micromanaging how. Furthermore, there are many hows. For example,
Σⁿ⁻¹₀ aᵢ = (((0 + a₀) + a₁) + ... + aₙ₋₁)
which is the left accumulation above. One can also accumulate from the
right: fold_right in OCaml and similarly in other languages. One can
also do
Σⁿ⁻¹₀ aᵢ = (Σm-10 aᵢ) + (Σn-1m aᵢ) where 0 < m < n
In other words, one may split the array in two (or more, and not
necessarily equal) parts, sum them separately, and then tally up. The
partial sums may be computed by the left- or right- accumulation, or
by splitting again. Furthermore, they may be computed in parallel: not
just in parallel, but embarrassingly in parallel. If the partial
sum computations are assigned to separate cores, they can do their
work without any synchronization or locking, without snooping at their
neighbors, without even read memory conflicts, taking the full
advantage of the memory bandwidth. Embarrassing parallelism especially
suits GPU.
How would you compute the big sum depends on your circumstances (how big is the array, and how it is layed out in memory or disk or across nodes) and how many cores/nodes are available. All these decisions can be made later, to tuning -- or even to the run-time and or a compiler.
Summation is not unique in permitting such a variety of implementations, including the embarrassingly parallel ones. The same applies to any accumulation with an associative operation, and with a zero-like value to initialize the accumulator -- or, as we would say, to any monoid reduction.
Reduction is quite old (contemporaneous with the left and right
folds), appearing already in APL in early 1960s. In APL, the summation of an
array X is written, with characteristic brevity, as +/X:
one should see it as `wedging in' the plus operation between the
consecutive elements of the array. The array summation, contrasting
the accumulation in the imperative language of the day with reduction
has been a common example in APL tutorials since at least 1970, as far
as I can see. (Looking a bit ahead, we will write the summation, in
OCaml, as reduce (monoid Sum) -- not as brief as in APL, but more
pronounceable, and also extensible, to `richer' monoids.) Modern
array languages like Futhark or SaC indeed let the programmer write
reductions in a similar fashion, compiling it to the efficient GPU,
multicore etc. code, choosing the implementation strategy depending on
the problem and the hardware. Monoid reduction got quite a boost in
mid-2000s, thanks to Google's MapReduce. (We shall soon see why reduce is
often accompanied by map.)
Implementing/compiling monoid reductions is well-researched and supported, especially in modern array languages or MapReduce frameworks. The problem is designing/finding the efficient monoid -- which, as Guy Steele said, is non-trivial and requires creativity. That's where the real fun starts.
References
Guy Steele: Organizing Functional Code for Parallel execution
or, foldl and foldr considered slightly harmful
August 2009 (ICFP 2009, Keynote)
Olivier Danvy:
``Folding left and right matters: direct style, accumulators,
and continuations''
Journal of Functional Programming, vol 33, e2.
Functional Pearl, February 2023
Appendix A: A brief history of folding left and right over lists
According to Danvy,
the first instances of fold_left and fold_right were investigated by
Christopher Strachey in 1961(!). Reduce is part of APL (Iverson,
1962), where it is
called `/': hence +/x sums the array x. Goedel recursor R in
System T is a more general version of fold (called now para-fold).
Church numerals are folds.
Monoid reduce is sometimes called `big operator' (by analogy with the capital Σ and Π for summation and multiplication, resp). Another name for them is `Eindhoven quantifiers'.
One may think that foldMap from the Haskell Foldable class is also
related: it also talks about monoid reductions. However, foldMap is
specified as an explicitly right-associative operator, and hence lacks
the main property of map-reduce emphasized by Guy Steele: decoupling
of specification from implementation. One may argue the Foldable
type class itself is to blame: according to it, a collection has a
single foldMap, whereas in fact there may be many reduction
implementations for the same collection.
Folds and Reduce
Before getting to monoid reductions, lets review folds -- for ease of reference and because they will appear frequently, contrasted with monoids. Left fold over a sequence is particularly important as a notation for a state machine, a general pattern of a sequential, or streaming computation.
Folding, or sequential accumulation over a sequence (a list, for concreteness) is defined as
fold_left : ('z -> 'a -> 'z) -> 'z -> 'a list -> 'z
fold_right : ('a -> 'z -> 'z) -> 'a list -> 'z -> 'z
Actually, there are two operations: the left and the right fold. Their meaning should be clear from the following example:
fold_left (+) 0 [1;2;3;4] ≡ (((0 + 1) + 2) + 3) + 4
fold_right (+) [1;2;3;4] 0 ≡ 1 + (2 + (3 + (4 + 0)))
In this case, of the folding function being addition,
the results are identical: both expressions sum the
list. Generally, left and right folds produce different results: try,
for example, subtraction as the folding function.
The types of the list elements 'a and
of the accumulator 'z need not be the same: for example,
fold_left (fun z _ -> z + 1) 0 l
computes the length of any list,
fold_left (fun z x -> x :: z) [] l
reverses the list l and
fold_right (fun x z -> if p x then x::z else z) l []
filters the list: omits the elements for which the predicate p
returns false. Many other operations on lists (in fact, all of them)
can be expressed as folds. Fold is indeed the general pattern of sequential
stateful processing of a sequence.
The are alternative definitions of fold: sometimes the last two arguments of the right fold are swapped. If the arguments of the right folding function are likewise swapped, then the left and the right fold have the same signature. Their behavior, the association pattern, is still different. Olivier Danvy, see below, traces the history of list folds and the argument order.
For concreteness we showed folds over lists, but similar operations exist over arrays, streams, files, trees, dictionaries and any other collections.
In this article, by reduce we always mean the reduce over a monoid. A monoid is a set (called `carrier set') with an associative binary operation which has a neutral (also called unit, or zero) element. Concretely, in OCaml
module type monoid = sig
type t (* carrier set *)
val zero : t
val op : t -> t -> t
end
type 'a monoid = (module monoid with type t = 'a)
The operation op must be associative (and total: defined for all t values)
and
op zero x = op x zero = x
must hold for every element x of the monoid. In Google MapReduce, the
operation op is also taken to be commutative. We do not impose such
requirement. We will, however, emphasize efficiency: the monoids we
aim for should have a constant time and space operation op.
The obvious example is
module Sum = struct
type t = int
let zero = 0
let op = (+)
end
Reduce over a sequence (here, a list) is the operation
reduce : 'a monoid -> 'a list -> 'a
with the behavior that can be illustrated as
reduce monoid [] ≡ monoid.zero
reduce monoid [x] ≡ x (* for any x and monoid *)
reduce (module Sum) [1;2;3;4] ≡ 1 + 2 + 3 + 4
One may say that reduce `wedges in' the monoid operation between the
consecutive elements of the sequence. Since op
is associative, the parentheses are not necessary. Therefore, unlike
the left and the right fold, there is only one reduce.
Folding over a sequence is executing a finite state machine whose
input is the sequence: viz., fold_left f z l runs the finite state
machine with the initial state z and the transition function f on
the input l, and returns the final state. It is an inherently sequential
computation. As said already,
the left fold is just a different notation for an accumulating for-loop.
(The right fold is similar, but accessing the sequence right to left.)
Reduce, however, can be implemented in various ways: for example,
let reduce (module M) l = List.fold_left M.op M.zero l
let reduce (module M) l = List.fold_right M.op l M.zero
or
let reduce m l = split l |> map reduce' m |> reduce'' m
That is, split the sequence in two or more parts (using an
appropriately defined split : 'a list -> 'a list list), reduce each
part (using some implementation of the reduce, denoted here reduce')
and then tally up the results, using perhaps a different
implementation of reduce. (Using this code as is, with regular OCaml
lists, unlikely yields any benefits; however, there are better
sequences, e.g., chunk lists, arrays or seekable file handles;
intermediate lists can be eliminated by fusion. We shall see
examples later.) The attraction of this implementation (schema) is
that part reductions can all be done in parallel -- embarrassingly in
parallel. For example, reduce' above can be running in its
own thread (multicore OCaml Domain or an OpenMP task) as we
shall see later. To stress again, no locking or synchronization is needed.
Reduce thus offers quite a lot of flexibility:
whether to reduce sequentially or in parallel, how many parts to split
the sequence in, what implementation to use for part reduction and the
final tallying up. The decisions depend on the (estimated) size of the
sequence and the available hardware (cores), and can be left to later
tuning or the run-time.
Since the (left or right) fold commits us to sequential evaluation, Guy Steele called it `slightly harmful'. He urged using reduce, as far as possible, because it is flexible and decouples the algorithm from the execution strategy. The strategy (sequential, parallel, distributed) and data partitioning can be chosen later, depending on circumstances and available resources.
Thus the main question is: can we convert fold into reduce? The rest of the article answers it.
References
Guy Steele: Organizing Functional Code for Parallel execution
or, foldl and foldr considered slightly harmful
August 2009 (ICFP 2009, Keynote)
monoid_reduce.ml [42K]
Complete code for the article
How to zip folds
A complete library of fold-represented lists, demonstrating
that all list processing operations can be expressed as folds
Accumulating tree traversals, a better tree fold with applications to XML parsing
Monoids
Recall, monoid is a set with two operations (satisfying particular properties) -- in other words, an Algebra, of the following signature.
module type monoid = sig
type t
val zero : t
val op : t -> t -> t
end
Different monoids -- monoids algebras -- are implementations of the signature.
The operation op must be associative with zero as its left and
right unit. Furthermore, we place special emphasis on the efficient,
that is, constant space and time, operation op.
The obvious examples are
module Sum = struct
type t = int
let zero = 0
let op = (+)
end
and Prod -- and also Min (with min as the operation) and Max;
All (carrier bool, zero is true, op is conjunction) and Some
(carrier bool, zero is false, op is disjunction). Lists (or
sequences, in general) are prototypical monoids,
with empty list as zero and append (or, reverse append) as the
operation. This monoid is not efficient, however: concatenating two
non-empty sequences produces a bigger sequence; furthermore, for
lists, concatenation is not constant time.
One must also note the EndoF monoid: its carrier is the
set of t->t functions (set-theoretical maps in general) for some
type t; zero is the identity and op is the composition (for
related EndoF', op is the left-to-right composition). EndoF is of
great theoretical importance, giving rise to `monoid actions'.
Practically however (if we are talking about programming language
functions), it is wasteful; we see an example later. Although function
composition is usually constant time, it creates a new closure
(incorporating, by reference) its argument functions, and, therefore, not
constant-space.
Homomorphism
That monoids appeared in pairs in our list, e.g., All and Some,
hinted at a connection among them. The most important connection is
called homomorphism. Homomorphism h from monoid A to monoid B is
a map from the carrier of A to the carrier of B
(in programming language terms, A.t -> B.t function) that satisfies
h A.zero = B.zero h (A.op x y) = B.op (h x) (b y)
for any elements x and y of the monoid A. One says,
h `commutes with operations'. For example, boolean negation is a
homomorphism from Some to All or vice versa, justified by the de
Morgan laws. Exponentiation is the homomorphism from Sum to Prod.
Try to find interesting homomorphism, e.g., from Sum to Some.
A homomorphism from a monoid A to EndoF has a special name:
monoid action.
There is another important homomorphism. Consider integer lists, which
are monoid, as we saw already. A homomorphism h from integer lists to
Sum should satisfy
h [] = 0 h (x @ y) = h x + h y
List summation satisfies exactly these conditions: reduce is a
homomorphism. List.length satisfies too: the homomorphism conditions,
hence, do not define reduction uniquely. We need to extend the notion
of monoids, so to precisely specify reduce as a sequence
homomorphism.
Map-reduce and Collection Monoids
There are many hints that the monoid interface is too minimalistic. First, compare the types of the list fold
fold_left : ('z -> 'a -> 'z) -> 'z -> 'a list -> 'z
and reduce
reduce : 'a monoid -> 'a list -> 'a
which, after inlining 'a monoid, becomes
reduce : ('a -> 'a -> 'a) -> 'a -> 'a list -> 'a
The fold_left type is more general. This generality lets
fold_left express, for example, the length of a list of an arbitrary type:
let length 'a list -> int = List.fold_left (fun z _ -> z + 1) 0
Although list length can also be computed as a reduction, it is not a mere reduction:
let length : 'a list -> int = map (Fun.const 1) >> reduce (module Sum)
(where >> is the left-to-right function composition). That is, we
need map, to first convert element types (and values) to the monoid to
accumulate. That's why reduce is often preceded by map,
to do this adjustment.
If the type t in the monoid signature is taken to be abstract,
the only monoid elements we can get hold of
are zero, op zero zero, etc. -- all zeros. We need a way to obtain
other elements: we need generators.
In abstract Algebra, generators are often introduced as zero-arity operations, or constants -- perhaps infinitely many constants. In the programming language context, perhaps its best to think of generators as being indexed by some set, or, as a mapping (function) from the index set to monoid elements:
module type coll_monoid = sig
include monoid
type ix
val gen : ix -> t
end
type ('a,'e) coll_monoid = (module coll_monoid with type t='a and type ix='e)
The index set may be empty, unitary (e.g., unit), finite (e.g.,
bool) or infinite.
Whereas the type t of monoid elements may be abstract, the index type
ix should be concrete. Fegaras and Maier call (a simpler version of)
this interface a collection monoid.
The earlier Sum etc. monoids are trivially extended to collection
monoids:
module Sum = struct
type t = int
let zero = 0
let op = (+)
type ix = int
let gen = Fun.id
end
In other words, if the monoid type is concrete, we can always find/construct its values `out-of-band', so to speak, without using the monoid interface. A non-trivial example of a collection monoid is
let list_monoid : type a e. (a list, a) coll_monoid =
(module struct
type ix = a
type t = a list
let zero = []
let op = (@)
let gen x = [x]
end)
For this monoid, all of its elements can be obtained by via monoid
operations alone: as zero, gen i for some i, or applying op.
In other words, it is a generated monoid.
A homomorphism over collection monoids must also commute with the
operation gen: in other words, a homomorphism from collection monoid
A to collection monoid B is the function h : A.t -> B.t satisfying
h A.zero = B.zero h (A.op x y) = B.op (h x) (b y)
h (A.gen i) = B.gen i
In particular, the homomorphism h
from the int list collection monoid to Sum must satisfy
h [] = 0 h (x @ y) = (h x) + (b y) h [i] = i
which is exactly the list summation. The conditions now specify the
homomorphism uniquely. List length is also a homomorphism,
but to a different, Sum-like monoid
struct include Sum let gen = Fun.const 1 end
With collection monoids, list length is expressed directly as
reduction (homomorphism), without the need for map. One may say,
that with collection monoids, a homomorphism from a sequence is
map-reduce.
Thus, a collection monoid uniquely specifies the
homomorphism from a sequence. In other words, one may define
map_reduce : ('a,'e) coll_monoid -> 'e list -> 'a. Before we do
this, however, let's consider an alternative operation for generators:
val opgen : t -> ix -> t
It is equivalent, or inter-convertible, with gen:
opgen t x = op t (gen x)
gen x = opgen zero x
However, often opgen can be implemented more efficiently. There is
another reason.
If we are to implement the collection monoid reduction as left-accumulation, it should look like
let map_reduce : type t e. (t,e) coll_monoid -> e list -> t =
fun (module M) -> List.fold_left M.opgen M.zero
In other words, opgen is exactly the folding function.
Sequential and parallel map-reduce over arrays
As stressed before, monoid reduction permits many other implementations (all giving the same result). Here we show just two, using an array slice as a collection. (OCaml does not provide array slices in the standard library; therefore, we have to define it ourselves.)
type 'a array_slice = {arr:'a array; from:int; upe:int}
let whole_slice (arr:'a array) : 'a array_slice =
{arr; from=0; upe=Array.length arr}
The sequential, left-associative implementation:
let map_reduce_arr : type t e. (t,e) coll_monoid -> e array_slice -> t =
fun (module M) {arr;from;upe} ->
let acc = ref M.zero in
for i=from to upe-1 do
acc := M.opgen !acc arr.(i)
done;
!acc
and the parallel implementation, over ncores cores.
let map_reduce_par_n :
type t e. int -> (t,e) coll_monoid -> e array_slice -> t =
fun ncores (module M) {arr;from;upe} ->
let len = upe - from in
let chunk = len / ncores in
List.init ncores Fun.id |>
List.map (fun i ->
Domain.spawn (fun () ->
map_reduce_arr (module M)
{arr; from = i*chunk; upe = min len ((i+1)*chunk)}))
|>
map_reduce (module struct include M
type ix = M.t Domain.t
let gen = Domain.join
let opgen x y = op x (Domain.join y) end)
It is a bit naive: We should be checking first if the length is big
enough: if not, just do the sequential map_reduce_arr, since the
ever-present overhead of parallel evaluation would otherwise
dominate. We should also make chunk to be a multiple of the page
size. Also, we should use Domain 0 to do some work, too. See the
end of Nested grouping-aggregation: Vertical monoid composition for the real example.
Products
So far, we built monoids from scratch. The is another way: combining, or composing, existing monoids. The most obvious and straightforward composition is pairing, or a (collection) monoid product:
module Product(M1:coll_monoid)(M2:coll_monoid with type ix = M1.ix) :
(coll_monoid with type t = M1.t * M2.t and type ix = M1.ix)
It is literally just a pair of monoids, operated on elementwise. The two monoids should have the same index set, to generate elements of both monoids. An example is computing sequence average:
module Av =
Product(Sum)(struct include Sum let opgen x _ = x+1 end)
The map-reduce over Av yields both the sum and the length of the
sequence in one pass. (How would you modify Av to avoid overflows in
sum computation? After all, the average is well-defined for any
non-empty sequence, no matter how long and how big its elements are.)
Observable Monoids and the Conjugate Transform
If the type t in the (collection) monoid interface is indeed abstract,
the result of a monoid reduction is a value of the abstract
type t, which we cannot
use outside the monoid. We need one more operation: observing the
monoid, as a value of some concrete type obs.
module type obs_monoid = sig
include coll_monoid
type obs
val obs : t -> obs
end
The other motivation comes from computing the average of a sequence:
monoid reduction with the Av monoid above gives a pair of numbers:
the sum and the length. To get the desired average, we have to
`observe' the pair: do the final division. The operation obs looks
like the complement of gen, which is also gratifying.
Any collection monoid can be observed as is:
module AsIs(M:coll_monoid) :
(obs_monoid with type ix = M.ix and type obs = M.t) = struct
include M
type obs = M.t
let obs = Fun.id
end
A non-trivial observable monoid is
module Average = struct
include Av
type obs = float
let obs (s,l) = float_of_int s /. float_of_int l
end
Map-reduce with the observable monoid takes the form
let map_reduce_conj (type i) (type o)
(module M:obs_monoid with type ix = i and type obs = o) (l:i list) : o =
map_reduce (module M) l |> M.obs
performing map-reduction and then observing the result. The overall
process -- injecting sequence elements into the monoid using gen,
reducing, and observing the result -- is called
conjugate transform. It is the general monoid reduction.
References
monoids.ml [6K]
OCaml code for various monoids and monoid compositions
Leonidas Fegaras, David Maier: Towards an Effective Calculus for Object Query Languages. SIGMOD 1995, pp. 47-58.
Algebra
Introduction to the mathematical concept of Algebras for
computer scientists, specifically for tagless-final programmers
Fold as map-reduce, trivially
Returning to Guy Steele's exhortation of preferring reduce over
fold, one may wonder if one can always do that. In that section we see
that fold can indeed always be expressed in terms of reduce --
but in a way that is not useful or interesting. Indeed,
fold_right f [1;2;3;4] z
= f 1 (f 2 (f 3 (f 4 z)))
= (f 1 · f 2 · f 3 · f 4) z
= map_reduce (endof_monoid f) |> (fun g -> g z)
for any f, z and the sequence of appropriate types. The very similar
expression can be written for the left fold (left as an exercise to
the reader). The key idea is that function composition is associative.
Here:
let endof_monoid : type a e. (e -> a -> a) -> (a->a,e) coll_monoid =
fun f ->
(module struct
type t = a -> a
let zero = Fun.id
let op g h = fun x -> g (h x) (* may also consider >> *)
type ix = e
let gen = f
let opgen m i = fun x -> m (f i x)
end)
Since homomorphism to endof_monoid is called monoid action, fold is
hence called `iterative action' -- the construction that is quite
known in mathematics.
Unfortunately, this trivial answer is of little practical use. As we
see from the example, the
map-reduction creates
a closure (f 1 · f 2 · f 3 · f 4), which is finally applied to
z. The composed closure (f 1 · f 2 · f 3 · f 4) has the same
structure as the original list -- and hence the same size (actually, a
few times larger: not only we have to store the list elements
themselves, we have to store the function pointers f, plus the
overhead). In effect we have built an intermediate data structure of
unbounded size. Although composing the closures can be parallelized,
the useful work is done only when the big closure composition is
finally applied to the initial accumulator z -- at which point the
folding function f is applied step-by-step, sequentially, just like
in the original fold_right f l z. This trivial reduction of fold
only wastes time and space.
The problem hence is not merely to express fold as reduce, but do so efficiently, without undue and unbounded overhead. Only then we can profitably fold in parallel.
References
Jeremy Gibbons: Origami Programming for Fun and Profit
<https://www.cs.ox.ac.uk/publications/publication16637-abstract.html>
Exposition of a fold as endof_monoid
Fold as map-reduce, more interestingly
Representing a sequential algorithm (fold) as monoid reduce, opening up embarrassing parallelization, only makes sense if we can do so efficiently, without intermediary data of unbound size and without taking undue time. In other words, if folding works in constant time per sequence element and in constant (working) space, so should reduce.
The general approach, the conjugate transform introduced above, is a principle, or a schema. It does not tell how to actually find the efficient monoid, or if it even exists. In fact, it is often non-trivial and requires ingenuity. To appreciate this, here a few exercises for the reader. To stress, in all cases we are interested in the efficient monoid.
- Iterated subtraction
- Consider subtractive
folding, mentioned in passing earlier. Subtraction is not
associative and therefore
fold_left (-)is not per se an instance of reduce -- and neither isfold_right (-)(and they obviously give different results). Yet both are expressible as map-reduce over efficient monoids. Try to find them. Hint: one of the two cases is quite easier. - ArgMax
- Finding the largest element of a sequence is a reduction, over the
Maxmonoid. What if we want to find not just a largest element, but, at the same time, also its position in the sequence -- the left-most position, if there are several occurrences. What about the right-most position? In other words, computemaxandargmaxas an efficient monoid reduction, in one pass. - Parentheses matching
- Finding if in a string of open and close round parentheses, all parentheses match can be done as an efficient map-reduction. Try to find the corresponding monoid. As the next question, assume the string may contain other, non-parentheses characters. Can you modify the monoid that the reduction would tell not only if the parentheses match, but also, in the case of a mismatch, the position of the left-most unopened close parenthesis and the position of the right-most unclosed open parenthesis. What about the right-most unopened close parenthesis and the left-most unclosed open parenthesis?
Generalized Horner rule
Horner rule, or schema, is a widely-used sequential algorithm to efficiently evaluate a polynomial. A similar accumulating pattern also frequently occurs in parsing. Although the algorithm is inherently sequential, on the face of it, it turns out representable as a monoid reduction. Our reduction is more general and flexible than the other known ways to parallelize the Horner rule.
As an illustrating example, we take the conversion of a sequence of digits (most-significant first) to the corresponding number -- a simple parsing operation. It is an accumulating sequential operation and can be written as fold:
let digits_to_num =
List.fold_left (fun z x -> 10*z + (Char.code x - Char.code '0')) 0
For example, digits_to_num ['1'; '7'; '5'] gives 175. One can
easily factor it as a composition of a map and
List.fold_left (fun z x -> 10*z + x) 0
which is essentially the Horner rule: evaluating the polynomial
Σ di bi at b=10.
The folding function fun z x -> 10*z + x is not associative,
however.
Still, there is a way to build a monoid:
module Horner : (obs_monoid with type ix = int and type obs = int) =
struct
type t = int * int
let zero = (0,1)
let op (x,b1) (y,b2) = (x*b2+y, b1*b2)
type ix = int
let gen x = (x,10)
type obs = int
let obs = fst
end
and digits_to_num is a reduction
let digits_to_num =
map_reduce_conj (module struct
include Horner
type ix = char
let opgen m x = opgen m (Char.code x - Char.code '0') end)
Thus, the Horner rule can be expressed as map-reduce and can hence be evaluated embarrassingly in parallel.
The Horner rule has the feel of the parallel prefix problem and the famous parallel scan of Guy Blelloch (PPoPP 2009). Horner rule does not require reporting of intermediate results, however. Our monoid reduction differs from Blelloch's even-odd interleaving. It also differs from monoid-cached trees from Guy Steele's ICFP09 keynote. We do not rely on any special data structures: We can work with plain arrays, partitioning them across available cores.
Since Horner method is pervasive in polynomial evaluation, there are approaches to parallelize it. The most common is even/odd splitting of polynomial coefficients. Although it may be well suitable for SIMD processing, the even/odd splitting is bad for multicore since it creates read contention (bank conflicts) and wastes cache space. Our monoid reduce lets us assign different memory banks to different cores, for their exclusive, conflict-free access.
On the other hand, Estrin's scheme may be seen as a very particular instance of our monoid construction: binomial grouping. Our monoid reduction allows for arbitrary grouping, hierarchically if desired, and of not necessarily of the same size.
References
monoid_reduce.ml [42K]
Complete code for the article
Boyer-Moore majority voting
Boyer-Moore majority voting is the algorithm for finding the majority element in a sequence -- that is, the element that occurs more than half of the time. The algorithm requires one pass over the sequence and the constant amount of working space. It is important to keep in mind that the return value is the majority element if it exists. The algorithm in general cannot tell if the sequence has majority: if majority does not exist, the return value is an arbitrary element, not necessarily the most frequently occurring. In general, therefore, one needs the second pass over the sequence to count the occurrences of the returned element and verify it is indeed the majority. The second pass may be avoided or be unnecessary in some cases.
The Boyer-Moore algorithm is called a prototypical streaming algorithm and is inherently sequential. It may be surprising, therefore, that it may be presented as monoid reduction and hence performed just as efficiently in parallel -- in fact, embarrassingly in parallel. The monoid reduction implementation is actually simple. What is complex is convincing ourselves that it indeed works and does the right thing, in all cases.
The Boyer-Moore algorithm is a state machine processing, ingesting input element-by-element. It can hence be implemented as fold (assuming the input is given as a non-empty list):
let bm : 'a list -> 'a = function h::t ->
let fsm (c,m) x =
if c = 0 then (1,x)
else if m = x then (c+1,m)
else (c-1,m)
in List.fold_left fsm (1,h) t >> snd
The state is the counter c and the candidate majority m. At the
end of the algorithm, when the entire sequence is scanned, m is the
majority element (if the sequence has one). If the counter c at the
end is more than half of the sequence length, the sequence has
majority and m is the majority element. Otherwise, we have to make
another pass through the sequence to verify that m is indeed
the majority. The verification pass may be unnecessary if we have a prior
knowledge that majority exists -- or if we are satisfied with any
element if it does not.
Although the algorithm seems inherently sequential, it can in fact be represented as monoid reduction. The monoid is simple and efficient:
type 'a bm = int * 'a
type t = a bm
let zero = (0,z)
let op (c1,m1) (c2,m2) =
if c1 = 0 then (c2,m2) else
if c2 = 0 then (c1,m1) else
if m1 = m2 then (c1+c2,m1) else
if c1 > c2 then (c1-c2, m1) else
(c2-c1, m2)
type ix = a
let gen x = (1,x)
let opgen m x = op m (gen x)
Monoid elements (the carrier set) are pairs: of a natural number c and of a
sequence element m. When c is zero, m may be arbitrary; in the
above code we use z for such an arbitrary element. We could have
avoided specifying it by defining a more sophisticated data type for
monoid carrier, or using 'a option. The reasoning below would get
messier; therefore, we keep the above definition for the sake of
clarity.
One can see that op is commutative and that zero is indeed
the zero of op. However, is op associative? That is, is
bm_monoid really a monoid? It is easy to check:
let m1 = (2,10) and m2 = (3,9) and m3 = (2,8)
op (op m1 m2) m3 ⇝ (1, 8)
op m1 (op m2 m3) ⇝ (1, 10)
Unfortunately, bm_monoid is not a monoid.
To cut the suspense, it turns out that bm_monoid is `morally' a
monoid, and reduction with it indeed gives the right result, in all
cases. Proving it however is not that straightforward.
For the sake of proof, we extend
bm_monoid to:
type 'a ebm = int * 'a * ('a*'a) list
type t = a ebm
let zero = (0,z,[])
let op (c1,m1,l1) (c2,m2,l2) =
if c1 = 0 then (c2,m2,l1@l2) else
if c2 = 0 then (c1,m1,l1@l2) else
if m1 = m2 then (c1+c2,m1,l1@l2) else
if c1 > c2 then (c1-c2, m1, repeat c2 (m1,m2) @ l1 @ l2) else
(c2-c1, m2, repeat c1 (m1,m2) @ l1 @ l2)
type ix = a
let gen x = (1,x,[])
let opgen m x = op m (gen x)
Here, the infix operation @ is list concatenation and
repeat (n:int) (x:'a) : 'a list produces the list of length n
with elements all equal to x.
The carrier set of bm_monoid_ext is 'a ebm:
triples, of a natural number c, a sequence
element m, and a list of pairs with distinct components (that is,
fst of any pair is not equal to its snd). We note that op preserves
the invariant that all pairs in l have distinct components.
The operation op of thus extended bm_monoid_ext is not associative.
Not only bm_monoid_ext is not a monoid, it is also not efficient:
the list concatenation operations in op
are neither constant space nor constant time.
Let us introduce the relation ≈ on that set, defined as follows:
x ≈ y just in case flatten x is equal to flatten y, where
let flatten : 'a ebm -> 'a multiset = fun (c,m,l) ->
repeat c m @ List.map fst l @ List.map snd l
That is, flatten x collects all elements mentioned within x in a
multiset. Therefore, x ≈ y iff the multiset of elements of x
is equal to the multiset of elements of y. One easily sees that
the relation ≈ is reflexive, commutative and associative. That is,
it is an equivalence relation. It also has useful for us properties,
as follows.
Proposition: bm_monoid_ext is a monoid modulo ≈. That is,
op (0,z,[]) (c,m,l) ≈ op (c,m,l) (0,z,[]) ≈ (c,m,l)
op (c1,m1,l1) (op (c2,m2,l2) (c3,m3,l3)) ≈ op (op (c1,m1,l1) (c2,m2,l2)) (c3,m3,l3)
The associativity property follows from the fact op preserves
elements occurring in (c,m,l), including their multiplicities (and
the multiset equality is associative).
Moreover, ≈ is adequate for the task of finding the majority.
Proposition (Adequacy): if (c1,m1,l1) ≈ (c2,m2,l2) and
the multiset flatten (c1,m1,l1) has majority, then c1>0, c2>0,
and m1=m2. Informally, ≈ preserves the found majority (if the majority
exists). The proposition is the consequence of the fact that majority
is invariant to sequence permutation, and the following representation
lemma.
Lemma (Representation): if a sequence (multiset) flatten (c,m,l)
has majority, then c>0 and m is the majority element.
The key idea is that (c,m,l) is a representation of
a partition of the multiset flatten m: specifically, a
partition of the multiset into pairs of distinct elements and the
remainder (which, if exists, is comprised of one or more copies of a
particular element m). Such a partition is generally not unique:
consider the sequence [1; 2; 3; 3; 3] and its two partitions
(3,1,[(1,3);(2,3)]) and (3,3,[(1,2)]). It has however the property
specified in the lemma.
For the proof we note that if the list of distinct pairs l has n
elements, it has 2*n of values. Not all of them are
distinct. However, there is no value that occurs more than n times;
otherwise, by the pigeonhole principle, some pair in l would have
had equal components. Therefore, no value occurring in (c,m,l) that is
distinct from m (if c>0) can be the majority element. If the
majority exists, it has to be m, and c>0.
Our proofs are inspired by Boyer and Moore's original proofs. They however used induction (which is appropriate for the sequential algorithm). We, in contrast, used equational reasoning (and the pigeonhole principle) -- but no induction.
As an aside, it may seem that induction is the only proof method used in computer science -- many widely used textbooks present nothing but induction. In my opinion, induction is overused. Mathematics has many other proof methods.
The final step is to observe that the part of bm_monoid_ext that we
actually care about -- the element m and
the count c -- is computed with no regard to the list l.
The list is a `ghost' list, so to speak, needed only for proving
correctness but not for computing the majority. Therefore, we may omit it
and recover the bm_monoid.
In the upshot, the majority voting may hence be performed as
map-reduce, over the monoid bm_monoid. It is just as efficient as
the original Boyer-Moore algorithm: one pass over the sequence using
constant and small working space. However, the input may be split
arbitrarily among available workers, who would do the search in
parallel, without any interference or dependencies. Majority voting
can be done embarrassingly in parallel.
References
<https://en.wikipedia.org/wiki/Boyer%E2%80%93Moore_majority_vote_algorithm>
monoid_reduce.ml [42K]
Complete code for the article
Lambert Meertens: Reducing hopeful majority. The Squiggolist, 1(1):5, 1989
<https://www.kestrel.edu/people/meertens/publications/papers/Reducing_hopeful_majority.pdf>
The paper also noted the lack of associativity and informally argued
(invoking indeterminism) that it is not required for consistency.
We have shown how associativity can be formally regained without
invoking indeterminism, using the `ghost' list of distinct pairs.
This `ghost' list is the crucial ingredient to be able to define
the equivalence relation `≈'.
Nested grouping-aggregation: Vertical monoid composition
Another instructive example of the monoid reduction is nested grouping and aggregation -- a typical problem in data analysis. That grouping and aggregation per se are expressible as a monoid comprehension -- map-reduce -- was insightfully articulated by Fegaras and Maier in their pioneering paper on monoid comprehensions. In our case, however, the result of a grouping-aggregation is grouped-aggregated again -- and again. An earlier article described a sequential optimal solution to the problem. Curiously, it also featured monoids. However, their composition was not explicated. Here we show that `piling up' particular monoids still gives a monoid -- and hence the embarrassingly parallel processing.
The running example, as in the earlier article, is an Advent of Code 2022 problem, summarized as:
The input is a sequence of numbers separated by pipes into chunks where each chunk contains multiple comma-separated numbers. The task is to compute the sum of each chunk and find the maximum across all chunks.
A sample input is as follows:
100,200,300|400|500,600|700,800,900|1000
The earlier article showed an efficient -- single-pass with no buffering -- sequential solution. We now present an embarrassingly parallel one. Emphatically, the input can be split arbitrarily, for example
100,20 0,300|40 0|500,600 |700,800 ,900|1000
haphazardly cutting through the numbers and delimiters. We may mmap
the input file and assign a range of pages to a worker (a CPU core),
not worrying about how or if the data are aligned with page
boundaries. The workers may then all run in parallel, in constant memory,
with no
dependencies or races -- with the linear
speed-up. Such a processing is also very fitting for
GPU.
A more realistic variation of the problem is, say, finding the 10 most frequent words in a UTF-8--encoded text file.
Warm-up
To build intuition, let's first consider a simple version of the
problem.
The input is a sequence of items of type α
separated by delimiters. For example, in the case α is char, or digit,
to be precise:
let adv22_input =
['1'; '0'; '0'; ','; '2'; '0'; '0'; ','; '3'; '0'; '0'; '|'; '4'; '0'; '0';...]
We take the input here to be a list for simplicity; in the full version, the input is a byte array or a file. The task is to group the items between the delimiters (commas and bars).
Formally, the task is to convert (α+δ)* to (α*↑)* where α*↑ is a maximal subsequence not containing the delimiter. Here, δ is the type (set) of delimiters. We use `+' for a union (of two types or sequences) and multiplication for concatenation; the superscripted star is Kleene star. Observe:
(α+δ)* = (α*δ*)* = α* + α*δ(α*δ)*α* = α* + α*δ(α*↑δ)*α*
using the almost standard algebra (only the multiplication, being sequence concatenation, is not commutative). In words: A sequence (α+δ)* either:
- has no delimiters: α*
- has one: α*δα*
- has more than one:α*δ(α*↑δ)+α*
In the latter case, the α* sub-sequences between the delimiters are maximal -- by definition. This representation is closed under concatenation (`multiplication'):
(α+δ)* = (α+δ)* (α+δ)*
=
(α* + α*δ(α*↑δ)*α*)
(α* + α*δ(α*↑δ)*α*
=
α* + α*δ(α*↑δ)*α*
+ α*δ(α*↑δ)*α*
α*δ(α*↑δ)*α*
=
α* + α*δ(α*↑δ)*α*
The original (α+δ)* is a monoid: the operation is concatenation, zero is the empty sequence. As we have just shown, the representation α* + α*δ(α*↑δ)*α* is also a monoid; the monoid operation can be read directly from the above equations.
Concretely, in OCaml, α* + α*δ(α*↑δ)*α* is the sum data type:
type delim = char
type 'a chunk = A of 'a list
| C of 'a list * delim * (('a list) * delim) list * 'a list
The monoid is then
let wap_monoid =
{zero = A [];
op = fun x y -> match (x,y) with
| (A lx,A ly) -> A (lx @ ly)
| (A lx,C (l,d,c,r)) -> C (lx @ l, d,c,r)
| (C (l,d,c,r),A ly) -> C (l,d,c, r @ ly)
| (C (lx,dx,cx,rx),C (ly,dy,cy,ry)) ->
C (lx,dx, (cx @ [(rx@ly),dy] @ cy), ry)}
It is a complicated version of sequence concatenation, taking into account that α* subsequences flanked by delimiters must be maximal.
The monoid represents an
(α+δ)* subsequence: the empty subsequence is represented by
A [], the singleton subsequence by
A [c] or C ([],d,[],[]) (depending on if its element is a digit c
or a delimiter d). The monoid representing the whole input is found
by map-reduce:
adv22_input |>
map_reduce (function '0'..'9' as c -> A [c] | d -> C ([],d,[],[]))
wap_monoid
resulting in
C (['1'; '0'; '0'], ',',
[(['2'; '0'; '0'], ','); (['3'; '0'; '0'], '|'); ...],
['1'; '0'; '0'; '0'])
We may then extract the the maximal delimiter-free subsequences:
let of_chunk : 'a chunk -> ('a list) list = function
| A x -> [x]
| C (l,d,c,r) -> [l] @ (List.map fst c) @ [r]
The warm-up task is hence solved by map-reduce, followed by of_chunk
to extract the result.
The warm-up example converted the input list of digits and delimiters
to ((char list)) list, with two occurrences of list. We now
replace them with arbitrary monoids.
Vertical monoid composition
The just introduced algebraic background lets us easily generalize the warm-up example to solve arbitrary nested grouping-aggregation problems. The warm-up example, taken broadly, is converting a sequence (α+δ)* -- of α items interspersed with delimiters δ -- to a sequence (α*↑ + δ')*, that is, a sequence of maximal delimiter-free subsequences α*↑ also interspersed with, generally fewer delimiters δ'. (Group delimiters are preserved but the delimiters that only serve to separate items within a group disappear after aggregation.)
We now generalize from sequences to
arbitrary monoids. First, suppose there is an observable monoid M1 which
reduces a sequence α*↑ to some value ρ (e.g., a sequence of
digits to an integer). The first generalization is the reduction from
(α+δ)* to (ρ+δ')*. Now, suppose there is a monoid M2
that reduces (ρ+δ')*. This monoid should accept not just ρ items
but also delimiters. It is worth then introducing delimiter-accepting
monoids
module type obs_del_monoid = sig
include obs_monoid
type del (* type of delimiters *)
val del : del -> t
end
Given such M1 and M2 we can build an obs_del_monoid that reduces
(α+δ)* in one pass:
module ChunkMonoid(S:sig
module M1: obs_monoid
module M2: obs_del_monoid with type ix = M1.obs
val del_ign : M2.del -> bool
end) :
(obs_del_monoid with
type del = S.M2.del and type ix = S.M1.ix and type obs = S.M2.obs) = struct
open S
type t = A of M1.t | C of M1.t * M2.t * M1.t (* chunk *)
let zero = A M1.zero
let m1_to_m2 : M1.t -> M2.t = fun x -> M2.gen (M1.obs x)
let op x y = match (x,y) with
| (A lx,A ly) -> A (M1.op lx ly)
| (A lx,C (l,c,r)) -> C (M1.op lx l, c, r)
| (C (l,c,r),A ly) -> C (l,c, M1.op r ly)
| (C (lx,cx,rx),C (ly,cy,ry)) ->
C (lx, M2.op cx (M2.op (m1_to_m2 (M1.op rx ly)) cy), ry)
type ix = M1.ix
let gen x = A (M1.gen x)
type del = M2.del
let del c =
if del_ign c then A M1.zero
else C (M1.zero, M2.del c, M1.zero)
type obs = M2.obs
let obs = function
| A x -> m1_to_m2 x |> M2.obs
| C (l,c,r) -> M2.(op (m1_to_m2 l) (op c (m1_to_m2 r)) |> obs)
end
Here, del_ign is the predicate for the ignored
delimiters. This is the vertical monoid composition.
When the monoids M1 and M2 are efficient --
their operations take constant time and space -- so is the
ChunkMonoid.
ChunkMonoid produces an obs_del_monoid, which can be composed again,
and again. The full Advent of Code 2022 problem is a reduction with
the monoid
struct
include
ChunkMonoid (struct
module M1 = struct include Horner
type ix = char
let gen x = (Char.code x - Char.code '0') |> gen end
let del_ign = Fun.const false
module M2 = ChunkMonoid (struct
module M1 = AsIs(Sum)
let del_ign = ((=) ',')
module M2 = struct
include AsIs(Max)
type del = char
let del c = assert (c='|'); zero
end
end)
end)
let gen = function '0'..'9' as c -> gen c | c -> del c
end
We have thus demonstrated nested grouping-aggregation as a monoid reduction with an efficient monoid. Not only can it be done in one pass, in constant space and linear time (in the size of input). It can also be done embarrassingly in parallel, with linear speed-up.
The problem presented here is reminiscent of the one described by Steele in his ICFP talk. He also used the idea of `chunks' and segments. However, our problem here is the triple nesting of the chunked processing -- three times more complex -- and deriving the general vertical monoid composition.
References
Nested Grouping-Aggregation
Introducing the problem and its sequential solution.
Uncannily, it also used monoids and monoid composition, albeit
not explicitly realized.
talk_FP_Madrid.ml [16K]
Complete OCaml code with various monoids and monoid compositions
(including nested-aggregation composition)
par_nested_agg.ml [10K]
Complete multicore OCaml code for parallel nested-aggregation,
using OCaml domains. For benchmark we take a 4.6GB file (of the same
structure as the sample Advent of Code file). Each core gets a slice of the file
to reduce; the main thread then collects the results. The parallel code
needs no locking and is assuredly race-free.
Generating parallel map-reduce for nested grouping-aggregation
This section casts the monoid and monoid reductions (specifically, the earlier Vertical monoid composition) into an alternative, imperative form -- suitable for generating high-performance, low-level first-order imperative code for shared-memory multiprocessing. (The earlier functional interface is more suitable for message-passing, such as MPI.)
For illustration, we generate OCaml code, using MetaOCaml. The generated OCaml code, however, is deliberately low level: no algebraic data types, no pattern-matching, and certainly no first-class functions. It may then be offshored to C.
As repeatedly emphasized, monoid reductions may be done in several ways. One is sequential: left-fold, or the accumulating for-loop. It needs only a subset of the observable monoid interface:
module type state_machine = sig
type t
val zero : t
type ix
val opgen : t -> ix -> t
type obs
val obs : t -> obs
end
This is the interface for a state machine: with the state t;
initial state zero; input alphabet ix; state transition opgen
computing a new state from the current state and current input; and
the observation obs. This interface can be cast into an imperative form:
module type upd_state_machine = sig
type u
type unt
val newupd : (u -> unt) -> unt
type ix
val updgen : u -> ix -> unt
type obs
val obs : u -> (obs -> unt) -> unt
end
The type u is the type of the updateable state (e.g., reference cell);
newupd allocates/initializes the state, updgen updates it based on
the current input, and obs observes it. The type unt is the type
of `actions' so to speak; it is also a monoid, letting us compose actions.
For example, unt can be unit (the type of imperative statements) or
unit code (the code of imperative statements).
A monoid is more than just a state machine: monoid reductions may also
be done slice-wise, so to speak. Therefore, monoid
not only provides opgen : t -> ix -> t to update the state t
based on the current input ix, but also op : t -> t -> t to
concatenate, or merge the (partially accumulated) states. The
imperative version of the observable monoid therefore provides
operations to extract the current state from the updateable state
u and update u with the imported state t:
module type upd_monoid = sig
type u
type unt
val newupd : (u -> unt) -> unt
type ix
val updgen : u -> ix -> unt
type obs
val obs : u -> (obs -> unt) -> unt
type t (* exportable state *)
val from_upd : u -> t (* export u *)
val upd : u -> t -> unt (* update u *)
val set : u -> t -> unt
val reset : u -> unt
type us (* plural of u *)
type usix
val newupds : usix -> (us -> unt) -> unt
val us_set : us -> usix -> t -> unt
val us_get : us -> usix -> t
end
The slice-wise processing can be done independently, in parallel if
possible. Therefore, the interface provides us for creating
an independent updateable state for each of the workers (core). The
type us and its operations hence support parallel-for (which we
emulate in multicore OCaml using domains).
See below for the source code that implements
several staged upd_monoids, including the vertical monoid composition.
See also below for the generated OCaml code -- which is indeed
first-order, low-level and imperative -- essentially, C in OCaml
notation.
Overall, the code generation and update semantics consistently deliver 4x single-core speed-up, scaling to multiple cores.
Version
The current version is December 2025
References
spar_nested_agg.ml [25K]
The staged version of par_nested_agg.ml, also introducing the
update-oriented monoids.
pa_na_red.ml [23K]
The generated code for parallel nested aggregation. It takes the file
descriptor and the number of cores to use.
par_na_main.ml [2K]
The main function to run the generated code, also showing the
benchmark results.
Conclusions
In his ICFP'09 keynote, Guy Steele exhorted us to use reduce (or,
map_reduce) rather than fold, as far as possible. Unlike fold,
reduce does not commit us to a particular evaluation strategy. It can
be performed sequentially, embarrassingly parallel, or in a tree-like
fashion. Like the Σ notation in Math, it specifies what should be summed
up, but not how or in which sequence.
Guy Steele conclusions apply to the present article as well. Especially his final thoughts:
- Associative combining operators are a VERY BIG DEAL
- Inventing (and proving) new combining operators is a very, very big deal