Parallel Evaluation
HIGHLY EXPERIMENTAL. This feature is new, and its semantics may change without notice in a future release.
macOS/Linux only. The mechanism is a POSIX
fork(), so it is not available on Windows.par-*still works there — every call falls back to the ordinary sequential form, exactly as it does below the size threshold on a supported platform — it just never forks, so there is no parallelism speed-up, andEU_PP_TRACEhas no effect on Windows. See What is different frommapbelow: the value is identical either way.
Eucalypt can evaluate a map over independent elements in parallel worker processes. The vocabulary is small and deliberately boring: each combinator computes the same value as its sequential form, so adding one is a performance decision, never a semantic one.
| Parallel | Same value as |
|---|---|
xs par-map(f) | xs map(f) |
xs par-sum(f) | xs map(f) sum |
xs par-max(f) | xs map(f) max-of |
xs par-min(f) | xs map(f) min-of |
xs par-concat(f) | xs map(f) concat |
scores: candidates par-map(expensive-score)
best: candidates par-max(expensive-score)
Results come back in input order, always. The mechanism is deterministic: each worker is given a contiguous range of indices and the parent reassembles the ranges in order, so two runs of the same program produce byte-identical output.
When it helps
Parallel evaluation pays when f is genuinely expensive and there are many
elements. Below a threshold (1024 elements by default) eucalypt does not
bother: it runs the sequential map instead, because forking costs more than
it saves on a small list. It also stays sequential when only one core is
available, on platforms without fork, and inside hosts that have not
declared themselves safe to fork from — the language server, the WASM build,
and the test harness all run par-* sequentially. The answer is the same in
every case.
What is different from map
Three things. They are the whole contract.
par-* is strict. map is lazy: it builds a list of thunks and forces
only what the consumer looks at. par-* forces the spine of xs and every
result before returning. So:
- an infinite or unbounded list works with
mapand does not withpar-map; - an error in
fthatmapwould never have reached — because nothing looked at that element — is raised bypar-map; - taking the head of a parallel map costs the whole map.
g(x): if(x = 3, panic("boom"), x)
[1, 2, 3] map(g) head # 1 — the third element is never forced
[1, 2, 3] par-map(g) head # error: boom
f must be pure. Workers run in separate processes, so a side effect in
f happens in a child and is lost, and two workers sharing a file descriptor
would each advance the other's read position. Eucalypt catches the case it can
see — advancing a lazy impure producer, such as a streaming import, inside a
worker raises a boundary error rather than corrupting the stream — but it
cannot detect every impurity, so keep f pure.
Results must be data. Only the serialisable subset crosses between processes: numbers, strings, symbols, booleans, null, lists of those, and blocks of those. A function, an IO action or an opaque value (a set, a vector, a PRNG, a producer handle) is a boundary error naming the value's kind and the combinator, not a silent misinterpretation:
par-map: cannot serialise a function across the parallel boundary
That is a programmer error at the boundary and it is reported as one, on the sequential fallback path too, so a program cannot pass in testing and fail only when it grows large enough to fork.
Metadata travels with its value, so a :suppress or :doc annotation on a
result still applies after a par-map exactly as it does after a map. The
consequence of carrying it rather than discarding it is that metadata must be
data too: metadata holding a function raises the same boundary error. map
would have carried that function along unevaluated, so this is a real
difference — but the alternative is dropping the annotation silently, which
would let a par-map re-expose a key its author marked internal, with nothing
to show for it. Values compare equal with or without metadata, so nothing else
would notice.
Tuning and diagnosis
Because every parallel path has a sequential fallback that produces the
identical answer, a build that silently never forks looks exactly like one
that does. EU_PP_TRACE=1 says which path each call took:
par-map: 20000 elements — forked 13 workers
par-sum: 12 elements — sequential (below threshold)
EU_PP_WORKERS sets the worker count and EU_PP_THRESHOLD the element count
below which par-* stays sequential — chiefly useful for forcing one path or
the other while investigating. See
Debugging for the full list.