docs / specification / lang / stream
Stream Parallelism — Design
Spec for the parallel-evaluation surface on cajeta.lang.stream.Stream<T>.
v1 targets Java-style fork/join parallel terminals over the existing
pull protocol — split the source once, run each split on its own fiber,
combine partials once. The pull contract doesn’t change; existing single-
threaded streams keep working unchanged.
Companion documents:
StreamParallelism.Examples.md— example/error catalogue and landed-feature errata.StreamParallelism.ErrorHandling.md— exception semantics (default fail-fast + user-side tolerance patterns).
Implementation status. The fork/join driver is wired and the chain-aware terminals (
reduce,fold<R>3-arg,collect<R>,anyMatch/allMatch/noneMatch,forEach,findFirst→findAny) fan out viascope { spawn worker<...> }over aSplittableroot. The scheduler today is single-carrier (cooperative), so workers are fibers that interleave rather than run on separate cores — the structured-concurrency shape is in place and yields wall-clock parallelism unchanged once a multi-carrier scheduler lands. Splittable roots:ArrayStream,ArrayList.stream(), and the three HashMap stream views. The oldercountParallelentry still walks sequentially. Driver:runtime/src/cajeta/lang/stream/ParallelDriver.cajeta.
Reactor-style reactive (push + async + backpressure + per-element scheduler hand-off) is explicitly out of scope here — that would parallel the pull protocol rather than reuse it, and the demand-signal model needs a different ownership story for in-flight elements. If there’s a use case later, it lands as a sibling
Flux<T>type, not a retrofit onStream<T>.
Goals
- Composes with the existing fluent surface.
xs.stream().parallel(). filter(p).map(f).reduce(0, +)works; calling.parallel()on any stream is legal even when the underlying source can’t split (it degenerates to sequential — the contract is “may parallelize,” not “must”). - Reuses the structured-concurrency machinery from Concurrency.md. Each
parallel terminal is itself a
scope { ... }body — workers arespawn’d fibers, the terminal returns only after every worker completes, exceptions propagate via the scope’s existing escalation. - No new ownership rules. A worker’s split is a disjoint subrange
of the source; one element is owned by at most one worker. Partial
results return to the orchestrator via
#transfer. The borrow checker already accepts borrows captured into scoped tasks (a worker reading fromint32[] xsborrowed by the source is sound because the scope joins before the borrow expires). - Composable with the cancellation model. A failing worker triggers
the scope’s cancellation; siblings observe at their next pull-loop
awaitcheckpoint and abandon early. - No GIL, no extra runtime. Lowers to the existing
__cajeta_task_run+ work-stealing scheduler + per-fiber drop chain.
Non-goals (v1)
- Custom executors. v1 always uses the built-in work-stealing pool.
- Ordered findFirst across splits. The encounter-order variant of
findFirstbecomesfindAnyunder parallel; explicitparallel().findFirstOrdered()is a v2 conversation if there’s demand. Java’sforEachOrderedis similarly v2. - Stateful intermediate combinators in parallel (
take,skip). v1 rejects them at the terminal site with a clear error;parallel().take(n).count()either falls back to sequential or errors with a remediation message. v2 can add an ordered-merge step. - Parallel sources that don’t implement
Splittable. No “buffered-arbitrary-stream” splitter — fast splitting is the only splitting. A user wanting parallel on a non-splittable source first collects to an ArrayList (which is splittable), then parallels. - Tunable split granularity. v1 picks
isqrt(estimateSize())splits per call, capped atMAX_SPLITS = 8and floored to sequential when the source has fewer thanMIN_PER_SPLIT * 2 = 64elements (both constants onParallelDriver). Manual threshold (parallel(minBatchSize)) is a v2 tuning knob.
Surface API
Stream<T>.parallel(): Stream<T>
Marks the stream as parallel-eligible by setting an isParallel flag
and returning this (no wrapper allocation, no # transfer). The bit
is read by the terminal, which decides whether to fork. Calling
.parallel() on an already-parallel stream is a no-op; .sequential()
clears the flag.
int32[] xs = …;
int32 total = xs.stream()
.parallel()
.filter((x) -> x > 0)
.map<int32>((x) -> x * 2)
.reduce(0, (a, b) -> a + b);
Internally parallel() returns the SAME stream with an isParallel
flag set on the head — no wrapper allocation. Stateless intermediate
combinators (filter, map, peek, flatMap) propagate the flag
through their construction. Terminals branch on it.
Splittable<T> extends Stream<T>
Sources that can be cheaply split implement this:
public interface Splittable<T> extends Stream<T> {
// Take half of the remaining elements into a fresh stream;
// this stream now covers the other half. Returns null when
// the source can't / shouldn't split further (one element
// left, no spatial locality, etc.). The returned stream
// transfers ownership (#) — the caller is the new owner.
public #Stream<T> trySplit();
// Best-effort element count for split sizing. Returns -1
// (UNKNOWN) when traversal-bound — the orchestrator falls
// back to a fixed split fan-out.
public int64 estimateSize();
}
Stdlib sources implementing Splittable<T> in v1:
ArrayStream<T>— splits by halving the index range. O(1).ArrayList<T>.stream()— same, since it’s an ArrayStream over the backing buffer.HashMap<K,V>.keys() / values() / entries()— splits by halving the slot-array range. O(1).
Wrapper streams (FilterStream, MapStream, PeekStream,
FlatMapStream, TakeStream, SkipStream) do not implement
Splittable. They wrap a source by reference and inherit splittability
through it — the orchestrator unwinds the wrapper chain at terminal
time, splits the underlying Splittable, then re-wraps each split
with copies of the intermediate ops (cheap — FilterStream etc. are
24-byte structs over a Stream+fn pair).
Terminal dispatch
Each terminal in Stream<T> checks isParallel and forks if it can:
public int32 count() {
if (this.isParallel) {
return ParallelDriver.count(this);
}
int32 n = 0;
Optional<T> o = this.next();
while (o.isPresent()) { n = n + 1; o = this.next(); }
return n;
}
ParallelDriver is the new internal stdlib helper that lives in
cajeta.lang.stream.parallel. It owns the fork-join machinery and
the per-terminal combine rules.
Worker model
Parallel terminals lower to roughly:
// Inside ParallelDriver — pseudo-cajeta, illustrating the shape.
R drive(Stream<T> head, R seed, (T -> X), (R, X -> R), (R, R -> R) combiner) {
// 1. Unwind wrapper chain to find the splittable root and the
// intermediate-op list. Reject stateful ops (take/skip) here
// with CAJETA_ERROR_STREAM_PARALLEL_REJECT_STATEFUL.
PipelineShape shape = unwind(head);
// 2. Decide split count. v1: ceil(sqrt(estimateSize)) capped by
// scheduler.availableCores(). Floor 1 (no parallelism if
// source is small).
int32 splits = pickSplitCount(shape.source.estimateSize());
// 3. Open structured-concurrency scope. Each worker writes its
// partial into a fresh slot transferred back at scope exit.
R[] partials = heap R[splits];
scope {
Splittable<T> remaining = shape.source;
for (int32 i = 0; i < splits - 1; i = i + 1) {
Splittable<T> share = (Splittable<T>) remaining.trySplit();
if (share == null) break;
int32 slot = i;
spawn workerBody(shape, share, slot, partials, seed, …);
}
// Tail piece runs on this fiber too — no point spawning to
// run the last split when the orchestrator is otherwise idle.
partials[splits - 1] = workerBody(shape, remaining, …);
} // scope joins — every spawned worker has finished here
// 4. Combine partials. Sequential O(N) for v1; tree-reduce later.
R acc = seed;
for (int32 i = 0; i < splits; i = i + 1) {
acc = combiner(acc, partials[i]);
}
return acc;
}
Each worker:
- Owns a
Stream<T>covering its split (transferred via#). - Re-wraps with copies of the pipeline’s intermediate combinators (each MapStream / FilterStream is reconstructed pointing at the worker’s split — cheap, no source data copied).
- Walks
next()to exhaustion on its own fiber. - Hands its partial back to the orchestrator’s slot.
The orchestrator never touches an element directly until the workers
have all transferred their partials at scope exit. The borrow
checker is satisfied because the source data (e.g. the int32[] xs
buffer) is borrowed by ArrayStream<int32> which is owned by the
orchestrator’s stream chain — the workers’ splits are sub-streams
over the same backing buffer, valid for the scope’s lifetime, and the
scope joins before the orchestrator can drop them.
Per-terminal rules
| Terminal | Parallel? | Combine | Notes |
|---|---|---|---|
count | yes | sum | trivial. |
forEach((T)→void) | yes | none | lambda must be side-effect-clean across threads. Order is unspecified under parallel — same as Java. |
anyMatch / allMatch / noneMatch | yes | OR / AND / AND | short-circuiting; first worker to find result triggers scope cancellation. |
findFirst | yes | ”any non-empty” | semantics shift to findAny under parallel — first encountered match by any worker wins. Document the change at the call site (compile-time lint: warn that ordering isn’t preserved). v2 may add findFirstOrdered. |
reduce(T seed, (T,T)→T fn) | yes | fn | requires fn associative AND seed to be its identity. Programmer responsibility (Java’s same contract). |
fold<R>(R seed, (R,T)→R fn) | needs combiner | n/a | v1 sequential-only when called on parallel stream; rejects with a remediation message pointing at the new fold<R>(seed, fn, combiner) overload below. |
fold<R>(R seed, (R,T)→R fn, (R,R)→R combiner) | yes | combiner | new overload; combiner merges partials. |
collect<R>(Collector<T, R>) | yes | combiner | Each worker calls the collector’s supplier() for its own partial R, accumulates, and the orchestrator merges partials with combiner. Both fields are required on Collector, so every collector is parallel-ready. |
take(n) / skip(n) (intermediate, not terminal) | rejected | n/a | stateful; would need ordered merge. The parallel driver’s chain-walk throws CAJETA_ERROR_STREAM_PARALLEL_REJECT_STATEFUL (runtime, not compile-time — the parallel flag is a runtime property) with a remediation suggesting .sequential().take(n). |
The shipped Collector<T, R> (runtime/src/cajeta/collection/Collector.cajeta):
public class Collector<T, R> {
public () -> #R supplier; // fresh accumulator instance, per worker
public (R, T) -> #R accumulator;
public (R, R) -> #R combiner; // merge two partials
public Collector(() -> #R supplier,
(R, T) -> #R accumulator,
(R, R) -> #R combiner); // all three required
}
A per-worker supplier() (rather than a single shared seed) is what
makes a mutable R like ArrayList safe under parallel — each worker
accumulates into its own instance with no aliasing. Collectors.toList<T>()
supplies () -> heap ArrayList<T>() with an appendAll-based combiner.
Ownership and the borrow checker
A parallel terminal opens a scope { } block. Each worker is a
spawn inside that scope, so:
- Captures from the orchestrator’s frame (the source array, the intermediate-op closures, the seed value) are valid borrows — the scope joins before they expire.
- The accumulator
Ris captured by-value (primitive) or by-borrow (class). For a class-typed accumulator used in a collector, each worker needs ITS OWN instance. The driver resolves this by callingCollector.supplier: () -> #Rper worker rather than sharing one instance.Collectors.toListpasses() -> heap ArrayList<T>()— one fresh list per worker. - Partials returning to the orchestrator travel via
#transfer. The combiner takes ownership; the worker’s partial is dropped from the worker’s drop chain at hand-off.
The per-fiber drop chain promotion that landed in R5 already gives
each worker its own chain, so a worker’s __cajeta_drop_push doesn’t
touch the orchestrator’s. The live-set is process-global with atomic
claim semantics — already correct under workers running in parallel.
Lint and diagnostics
Two new compile-time signals:
[parallel-stateful-op]— usingtake/skipAFTERparallel()in the same chain. Suggestion:xs.stream().parallel().filter(…) .sequential().take(n).[parallel-collector-no-combiner]—collect(c)on a parallel stream wherec.combiner == null. Suggestion: add a combiner to the collector OR drop the.parallel()call.
Runtime exception (only if the static check is bypassed by dynamic shape construction):
CAJETA_ERROR_STREAM_PARALLEL_REJECT_STATEFULCAJETA_ERROR_STREAM_PARALLEL_NO_COMBINER
Implementation phases
P1 — Splittable
cajeta.lang.stream.Splittable<T>interface.ArrayStream<T>.trySplit()/estimateSize().Stream<T>.parallel()/sequential()flag-flip methods (instance fieldisParallel; copy into wrapper combinators on construction).ParallelDriverclass incajeta.lang.stream.parallelwith the unwind + split + scope + combine skeleton. v1 supports onlycountto keep the surface narrow while the plumbing settles.- Tests: trivial count parallels, count on non-splittable falls back to sequential, parallel through filter/map composes.
P2 — Reduce-shaped terminals.
reduce(T, (T,T)→T)with associativity-required note.anyMatch / allMatch / noneMatchwith scope cancellation.forEachwith explicit “order unspecified” note.- Tests: each terminal with both splittable and non-splittable sources.
P3 — Cross-type combinators.
fold<R>(seed, fn, combiner)heap overload (sequential-friendly three-arg form stays single-threaded).Collector<T, R>combiner field +Collectors.toListcombiner.collectparallel path.- Tests: parallel sum-into-int64 via fold
, parallel toList, parallel Collectorschain.
P4 — Source coverage + diagnostics.
HashMapstream views become Splittable. Shipped.HashMapKeyStream/HashMapValueStream/HashMapEntryStreameachimplements Splittable<T>and overridetrySplit / estimateSize / splittableSize / trySplitRoot— halving the slot-array index range, same disjoint-share model asArrayStream. Verified end-to-end across count / reduce / fold- with-combiner / anyMatch / allMatch / noneMatch / forEach terminals intest/collections/HashMapStreamParallelTests.cpp.- Lint passes for
[parallel-stateful-op]/[parallel-collector-no-combiner]. - Runtime exceptions wired.
- Tests: HashMap parallel terminals; lint-as-error suites.
P5 — Tuning + cancellation.
pickSplitCountreads scheduler core count.findAnysemantics forfindFirstunder parallel; lint warns about ordering loss.- Scope-cancellation hookup for short-circuiting terminals (workers poll a flag inside their pull loop; first match flips it and the others bail at their next checkpoint).
Out-of-scope edge cases (documented decisions)
- Nested parallelism. A parallel stream that produces a parallel stream via flatMap. v1 sequentializes the inner — only the outer parallelizes. The scheduler’s work-stealing makes nested parallel flatten naturally if we ever lift this; the restriction is for the v1 driver’s bookkeeping simplicity.
- Reduction with non-associative ops. Programmer responsibility, same as Java’s contract. No runtime check.
Stream<T>whereTis a non-Sendtype. Cajeta’s existing#transfer rule already governs cross-thread handoff; the parallel driver inherits whatever the language enforces. A worker pulling a class-typedTfrom its split owns it for the worker’s fiber lifetime; transferring to the orchestrator only happens if the terminal accumulatesTinto the partial (reduce / collect of class-typed Ts), in which case the partial transfer must use#.- Source mutation during traversal. Same contract as sequential: undefined behavior. The HashMap stream views already specify snapshot semantics; that property carries over.
Locked decisions (2026-05-22)
- findFirst silently becomes findAny under parallel; lint
[parallel-findfirst-unordered]warns about ordering loss. Fluent surface stays intact. - OOM at spawn always raises
SystemResourceException— no silent fallback. Surfaces brittle workloads early. Collector<T, R>combiner is REQUIRED. The shipped ctor is 3-arg(supplier, accumulator, combiner)— notesupplier: () -> #Rreplaced the originally-plannedseedfield so each parallel worker gets its own accumulator instance (see Examples.md § 7.9 errata). Existing 2-arg call sites were swept in the P1 prep commit (Collectors.toList + CollectorTests sites).- Worker stack-trace frames named
<parallel worker N>using the split index.
Open design questions
(Out of scope for v1 — listed for future tracking.)
- Tree-reduce of partials instead of orchestrator-linear sweep. v1’s linear sweep is fine for the split counts the driver picks (4–32). Defer to v2 when measurements demand it.
- Custom executor surface.
parallel(Executor e)overload. Out of scope for v1; the Executor type itself isn’t designed yet.