Parallel Collections
March can run a transformation over a list across multiple CPU cores with a one-character change: map → pmap. The parallel operations live in the List module, produce exactly the same results (same values, same order) as their sequential counterparts, and ride the same green-thread scheduler that powers actors and tasks.
let squares = List.map(xs, fn x -> x * x) -- sequential
let squares2 = List.pmap(xs, fn x -> x * x) -- parallel, identical result
The functions are ordinary stdlib code over task_spawn: a list above a cutoff is split into chunks, one task per chunk, awaited in order. Below the cutoff they are their sequential twins. The interpreter runs tasks eagerly, so results match there too but only a compiled build gets faster.
This page covers the four functions, how they chunk and schedule, the --pmap-threshold flag, when to reach for them, and the editor hint that suggests them.
On this page
Start here
- When to use them explicitly
- How it fits with the rest of March
- The functions:
pmap,pmap_n,pfilter,preduce
How it works
- How it works under the hood: chunking, the scheduler, OS threads, the preemption signal, why parallel FBIP needs no locks, interpreter vs compiled
- The threshold and the
--pmap-thresholdflag
Tooling and guarantees
- Automatic detection: the “magic,” explained
- Correctness guarantees, in one place
- Bulk parallel work: RRB.Vec and Parallel
- See also
When to use them explicitly
| Situation | Reach for | Why |
|---|---|---|
| Large list, cheap-to-moderate pure work per element | List.pmap / List.pfilter |
The threshold handles the “is it worth it?” decision for you |
| Few elements, expensive work each (HTTP, heavy compute) | List.pmap_n |
Bound concurrency explicitly; the size heuristic doesn’t apply |
| Aggregating a large list with an associative combiner | List.preduce |
Tree reduction parallelizes sum/max/concat/union |
| Small list, or trivial per-element work | plain List.map / fold_left |
Spawning overhead would make parallel slower |
| The function does I/O ordering, mutation, or must run in sequence | plain List.map |
Parallel execution would change observable behavior |
Rules of thumb:
- Prefer the sequential version by default. Reach for the parallel one when you have evidence (a large dataset, a profiler, a slow loop) that it’ll pay off.
- The function must be safe to run concurrently. Pure functions (ones that only compute from their arguments) always are. A function that prints, sends actor messages, writes a file, or depends on shared mutable state is not, and the runtime won’t stop you from misusing it. For those, keep the sequential version (or restructure so the parallel part is pure). A captured native array (
NativeIntArrand friends) is fine to read from parallel code, and even to write: it is a copy-on-write value, so each worker’s write lands in its own copy. ARingBufcannot be captured by the lambda at all; it is linear. - Measure in a compiled build. The interpreter won’t show a speedup (see above).
How it fits with the rest of March
| Concept | Where parallel collections meet it | Chapter |
|---|---|---|
| Actors and tasks | pmap is task_spawn plus task_await_unwrap over chunks, on the same M:N scheduler; Task.async is the hand-wired form. |
Actors, choosing a primitive |
| Memory model | Disjoint subtrees of a uniquely owned structure are rewritten in place on different cores with no lock. | Memory Model |
| Linear types | A RingBuf cannot be captured by the mapped function; native arrays are copy-on-write and may be. |
Linear Types |
| Flow | When a producer can outrun the consumer, a Flow pipeline bounds in-flight work instead of a list. |
Flow & Backpressure |
| Editor | The language server suggests pmap for a provably pure map and offers a one-click rewrite. |
LSP & Editors |
| Observing a running node | SCHED reports per-thread utilisation, the live view of the scheduler described here. |
Observe |
The functions
All of these are order-preserving: element n of the output corresponds to element n of the input, regardless of which core finished first.
List.pmap(xs, f)
Parallel map. Drop-in replacement for List.map when f is safe to run concurrently.
List.pmap([1, 2, 3], fn x -> x * 2) -- [2, 4, 6]
List.pmap(rows, fn r -> render_row(r)) -- each row rendered on a worker
List.pmap_n(xs, f, max_concurrency)
Like pmap, but caps the number of tasks running at once. Use it when each element is expensive (a network call, a heavy computation) and you want to bound how many run simultaneously (rate-limiting, connection pools, memory pressure).
-- Fetch up to 8 URLs at a time, no matter how long the list is
List.pmap_n(urls, fn u -> http_get(u), 8)
List.pfilter(xs, pred)
Parallel filter. Same semantics as List.filter, kept in input order.
List.pfilter(candidates, fn c -> passes_expensive_check(c))
List.preduce(xs, identity, combine)
Parallel reduction (a tree reduce). Each chunk is reduced independently, then the partial results are combined.
List.preduce([1, 2, 3, 4], 0, fn (a, b) -> a + b) -- 10
List.preduce(words, "", fn (a, b) -> a ++ b) -- concatenation
Contract:
combinemust be associative (combine(combine(x, y), z)must equalcombine(x, combine(y, z))) andidentitymust be its unit. Sum, product, max, min, string/list concatenation, and set union all qualify. Subtraction, division, and average do not (you’d get a different answer depending on how the work was split). The compiler cannot verify associativity, so this is your responsibility.
How it works under the hood
Chunking, not element-per-task
A naïve parallel map would spawn one task per element: a million elements would mean a million tasks, and the scheduling overhead would dwarf the actual work. Instead, pmap splits the list into chunks, spawns one task per chunk, runs each chunk’s elements sequentially inside its task, awaits the tasks in order, and concatenates the results:
xs = [................................................] (say 4000 elements)
│ │ │ │
chunk 0 chunk 1 chunk 2 chunk 3 (threshold-sized chunks)
│ │ │ │
task task task task (one green thread each)
└───────────┴─────┬─────┴───────────┘
concat in order
Because the number of tasks tracks the chunk count (not the element count), the overhead stays proportional to the number of cores, not the size of the data.
The scheduler
In compiled code, tasks run on March’s M:N green-thread scheduler: several OS threads (one per CPU by default), each running many lightweight green threads, with work-stealing to keep cores busy. This is the same runtime described in Actors. Reference counting is atomic and each actor/task owns a private arena heap, so there is no stop-the-world GC pause; parallel work scales cleanly.
How many OS threads
One per online CPU, by default. You override it with the MARCH_NUM_SCHEDULERS environment variable at run time:
MARCH_NUM_SCHEDULERS=4 ./my_program # exactly 4 scheduler threads
MARCH_NUM_SCHEDULERS=auto ./my_program # one per online CPU (the default)
MARCH_NUM_SCHEDULERS=1 ./my_program # fully serialize green threads
The default was a flat 4 on every machine until 2026-09-04 — four threads on a four-core laptop and four on a 96-core server — so 4 was the effective parallelism ceiling of any March program that had not been told otherwise, and nothing said so. A pmap_n over CPU-bound work would plateau at roughly 4x and stay there no matter how many tasks you created. If you have a scaling table taken before that change, it is a table of one data point repeated.
The upper bound is MARCH_MAX_SCHEDULERS, a compile-time constant (64) that sizes the runtime’s scheduler table. A larger request is clamped, and the runtime says so on stderr rather than silently running fewer threads:
march: MARCH_NUM_SCHEDULERS=200 exceeds this build's maximum of 64; using 64
scheduler threads (rebuild the runtime with -DMARCH_MAX_SCHEDULERS=200 to raise it)
A value that is not a positive integer or auto is likewise reported and the default used.
The preemption signal
Compiled programs preempt long-running green threads by signalling each scheduler thread about once a millisecond. The signal is SIGUSR1 by default; MARCH_PREEMPT_SIGNAL moves it at run time:
MARCH_PREEMPT_SIGNAL=USR2 ./my_program # use SIGUSR2
MARCH_PREEMPT_SIGNAL=RTMIN+1 ./my_program # Linux: a real-time signal
Accepted values are USR1, USR2, and on Linux RTMIN or RTMIN+<n> (a SIG prefix or the signal number also works); anything else is reported on stderr and SIGUSR1 is used. A C host embedding March can choose with march_sched_set_preempt_signal(signo) before the scheduler first runs.
The runtime coexists with a handler the host process already installed for that signal. The handler is called for every delivery that is not one of March’s own ticks (in particular every signal sent by another process), and it is put back when the scheduler stops. This matters when March runs inside a process that uses SIGUSR1 itself: the Erlang VM, for example, writes a crash dump on SIGUSR1. If the signal was at its default action before, deliveries from elsewhere are ignored while March runs, as they always were.
Two notes on choosing one. The preemption signal cannot be watched with Signal.watch in compiled programs (so moving it to USR2 frees Signal.Usr1). And real-time signals queue instead of merging: while a scheduler thread has the signal masked around a slow system call, each tick adds a queued delivery that counts against the user’s pending-signal limit. That is why the default stays SIGUSR1 on Linux too.
Why parallel FBIP needs no locks
The reason preduce over a tree (or any divide-and-conquer over a uniquely-owned structure) scales without a single mutex comes down to ownership. March’s memory model tracks each value’s reference count; a structure with root at RC == 1 is uniquely owned. When you split a uniquely-owned tree at its root, the left and right subtrees are themselves uniquely owned and disjoint: no node is reachable from both subtrees.
That disjointness is the key. Hand each subtree to a different task and the two tasks rewrite their nodes in place (functional-but-in-place, FBIP) on separate cores. Because no node is shared, there is no data to race over: no lock, no atomic fence on the data, no cache-line ping-pong. The only synchronization is the join at the end, when the parent task collects two already-finished results and combines them. Reference counting is needed only where sharing is possible; unique ownership proves it isn’t, so the fast path is lock-free by construction.
This is exactly what the depth-24 parallel tree-sum benchmark exercises: a ~16-million-node tree is split top-down, each task sums its disjoint subtree in place, and partial sums combine on the way up. The speedup tracks core count almost linearly exactly because there is zero contention between tasks; the structure’s own shape guarantees they never touch the same memory.
Interpreter vs. compiled
| Result correctness | Real CPU parallelism | |
|---|---|---|
Interpreter (march run, REPL) |
✅ identical to sequential | ❌ tasks run eagerly on one thread |
Compiled (march --compile, forge build) |
✅ identical to sequential | ✅ runs across cores |
The interpreter executes spawned tasks eagerly, so pmap there is correct but sequential. You get the speedup when you compile. This means you can develop and test against the interpreter and trust that the compiled binary produces the same answers.
The threshold and the --pmap-threshold flag
Parallelism isn’t free: spawning and joining tasks costs something. For a short list, sequential map wins decisively. So pmap, pfilter, and preduce consult a cutoff before deciding:
- At or below the cutoff → they delegate to the plain sequential version. No tasks, no overhead.
- Above the cutoff → they chunk and parallelize as described above.
What actually happens when you call pmap
pmap isn’t a special compiler intrinsic; it’s ordinary March code in the stdlib,
built on the exact same task_spawn/task_await_unwrap
primitives you could call yourself. Stripped down, List.pmap(xs, f) does this:
fn pmap(xs : List(a), f : a -> b) : List(b) do
let t = pmap_threshold()
if length(xs) <= t do
map(xs, f) -- at/below cutoff: plain sequential map
else do
let cs = chunks(xs, t) -- split into t-sized chunks
let tasks = map(cs, fn c -> task_spawn(fn _ -> map(c, f))) -- one task per chunk
concat(map(tasks, fn tk -> task_await_unwrap(tk))) -- await in order, join results
end
end
end
(pfilter and preduce follow the same shape, swapping map for filter /
fold_left.) Walking through it:
- The check is one length count and comparison, done fresh on every call.
length(xs)is a runtime value (the compiler has no way to know it ahead of time), so this comparison against the threshold has to happen live, each timepmapruns. Counting a list is a single O(n) walk with no calls tof, so it is cheap next to the work being parallelized. - At or below the cutoff, it’s literally
map. No task, no scheduler involvement, no overhead beyond the length check you just paid for. - Above it, the chunk size is the threshold.
chunks(xs, t)splits the list into pieces oftelements each; this is why the earlier diagram shows a 4000-element list splitting into 4 chunks at the default threshold of 1024 (⌈4000 / 1024⌉ = 4). Each chunk becomes onetask_spawn, so the number of tasks trackslength(xs) / t, notlength(xs). - The tasks are awaited in order, which is what keeps the result order-preserving.
task_await_unwrapruns on chunk 0’s task, then chunk 1’s, and so on. But all the tasks were already spawned before any awaiting starts, so this doesn’t serialize the work, only the order results are collected in. - What “spawn” means depends on the backend, exactly as in The
scheduler above. Compiled,
task_spawnstarts a real green thread that the M:N scheduler can run on any OS thread. Interpreted,task_spawnevaluates its function immediately, synchronously, right there, so even the “parallel” branch above the threshold runs chunk 0 to completion, then chunk 1, then chunk 2, in plain left-to-right order, on one thread. That’s why the interpreter is guaranteed to produce the same result as the sequential version: it isn’t a special case, just the same code evaluated by a backend that has no concurrency to offer. And it’s why the interpreter never gets faster no matter how large the list is.
The threshold value itself
The cutoff is returned by the builtin pmap_threshold() and defaults to 1024 elements. You can change it at compile time:
march --compile --pmap-threshold=256 -o app app.march
# via forge:
MARCH_PMAP_THRESHOLD=256 forge build
The value is baked into the binary as a compile-time constant; there’s no per-call runtime configuration to read.
The same --pmap-threshold flag also works when running interpreted
(march --pmap-threshold=256 app.march, no --compile); there it just sets the value
pmap_threshold() returns for that one run, which is occasionally useful for testing how
your code behaves at a different cutoff without a full compile, even though it won’t
affect wall-clock time either way in the interpreter.
Why a runtime cutoff instead of the type system?
A natural question: can’t the compiler decide statically whether a list is “big enough”? In practice, no, and not because the type system is too weak:
- List length is almost always a runtime property. The lists worth parallelizing come from files, sockets, databases, user input. Their size simply isn’t known at compile time.
- Profitability is
length × cost-per-element, not length by itself. Eight elements that each make a 200 ms HTTP call are very worth parallelizing; a million elements that each dox + 1are not. The type system can’t see how expensive a closure body is.
So March chooses at runtime, on the actual data, with a single cheap length check, the same approach used by Rust’s Rayon, Java parallel streams, and .NET PLINQ. The default of 1024 is a safe floor for cheap per-element work; pmap_n is the precise override when per-element cost is what matters.
Automatic detection: the “magic,” explained
March will tell you when a map or filter is a parallelization candidate. Open a file in an editor with the March language server and a pure List.map / List.filter gets a Hint:
This
List.mapover a pure function could beList.pmapto run in parallel.
…together with a one-click “Convert to List.pmap“ quick-fix that rewrites just the call.
What is, and is not, happening:
- It’s detection, not silent rewriting. The compiler does not secretly turn your
mapinto apmapbehind your back. Your code runs exactly as written; the suggestion is advisory, and you opt in by accepting the fix. Performance stays predictable and your stack traces stay truthful. - It only fires when it can prove safety. The analysis checks that the mapped function is pure (no I/O, no message sends, no mutation), reusing the same purity oracle the optimizer uses internally. If it can’t prove purity, it stays quiet. False positives (suggesting an unsafe parallelization) are designed out; the cost is the occasional false negative (staying silent on something that was safe).
fold/reduceare never suggested. Purity is enough to parallelize amap, but a parallel reduce also needs an associative combiner, and associativity isn’t something the compiler can check. So the detector never nudges you towardpreduce; that one is always an intentional, you-vouched-for-the-contract choice.
Why advisory instead of fully automatic?
Whether to parallelize is a judgment call that depends on data size and per-element cost, exactly the things a static analysis can’t know (see the threshold discussion above). Silently rewriting every pure map into a pmap would make small-list code slower and scatter task-scheduling through programs that never wanted it. Surfacing the opportunity and letting you decide keeps the speedups where they matter and the behavior easy to reason about.
Roadmap: a fully-automatic, opt-in rewrite mode (a
--auto-parallelcompiler flag, off by default) has been designed to reuse the same purity analysis and runtime threshold. It is not yet available; today, parallelization is always either explicit (you callpmap) or accepted from a suggestion (you click the quick-fix).
Correctness guarantees, in one place
pmap,pmap_n, andpfilterreturn results identical tomap/filter(same elements, same order) for any function safe to run concurrently.preducereturns a result identical tofold_leftiffcombineis associative withidentityas its unit.- Compiled and interpreted runs produce the same output; only wall-clock time differs.
- Below
pmap_threshold(), every parallel function is exactly its sequential equivalent.
Conformance status (widening slice 9): this guarantee is a mechanically
tested part of the language reference: core-march.md §4.14 and
core-march-types.md §2.11 (data parallelism adds no new typing rules).
Golden g43_parallel_determinism witnesses List.pmap == List.map plus the
RRB Parallel.psum/pcount/pany/pall/preduce matching to the exact
byte interpreted and compiled (stress-verified 0/15 crashes), the first compiled
witness for the Parallel module. One documented exception: psum_float
is not backend-portable: IEEE-754 +. is not associative, and the two
backends pick different worker/chunk counts, so results can differ in the
last bit (finding P1, closed 2026-09-09 by a doc-comment warning on psum_float;
see specs/progress/2026-07-10-p2-compiler-parallelism-clustering-found-during-core-march.md). Prefer psum/integer accumulation,
or pin psum_float inputs that are exact in binary, when portability matters.
Bulk parallel work: RRB.Vec and Parallel
For large datasets where you want full map-reduce control, use the RRB and Parallel modules
instead of List.pmap.
| Feature | List.pmap |
Parallel.pmap |
|---|---|---|
| Input type | List |
RRB.Vec |
| Custom worker count | pmap_n(xs, f, n) |
pmap_n(v, f, n) |
| Reduce with identity | preduce |
Parallel.preduce |
| Built-in psum / pcount | No | Yes |
| Zero-copy slices | No | Yes (RRB.slice) |
Quick example
let nums = RRB.range(1, 1000001)
let total = Parallel.psum(nums) -- 500_000_500_000
let evens = Parallel.pcount(nums, fn n -> n % 2 == 0) -- 500_000
let sq = Parallel.pmap(nums, fn n -> n * n) -- Vec of squares
See the Parallel Data cookbook for full examples including word frequency, image processing, and statistics.
See also
- Actors: the scheduler,
Task.async/Task.await, and message-passing concurrency these functions build on. - Parallel Data cookbook:
RRB.VecandParallelwith copy-and-run examples. - Standard Library → List: the full
ListAPI reference. - LSP & Editors: set up the language server to get the parallelization hints and quick-fix.
- Memory Model: why disjoint subtrees need no locks.
- Flow & Backpressure: bounded pipelines when a producer outruns the consumer.