Parallel Loops
parallel_for, parallel_transform and parallel_reduce run a loop over an array on every core with deterministic results: the same output at every thread count, byte-identical to the serial loop. The engine checks the body when the statement is reached, before any element runs, and a body it cannot prove safe is refused with a catchable error that names the construct; it is never run serially in silence. The table below is that contract, and the rest of the chapter walks through each form.
What a parallel body may do
A text that starts with parallel_for: is raised when the statement is reached, before any element runs; most of them end with the position of the construct, at LINE:COL, left off here. parallel_transform and parallel_reduce raise the same texts under their own names. A text without the prefix is raised when the body runs the construct, because only then is the receiver known. The rules cover everything a body reaches: the functions it calls, the methods it runs and the constructors and destructors of the objects it makes.
| In a body | Rule | Refused with |
|---|---|---|
| Element writes | Allowed through the loop variables: auto& changes the element in place, a by-value loop variable is the body's own copy. A reference to a member of the element or of a local (auto& p = b.pos;), and a by-reference loop over a map the element or the body holds (for (auto& [k, v] : b.m), p.second += 1), write it as the serial loop does. Each element must be provably owned by one iteration: a primitive, a string, a value container, an instance of a flat value class (typed fields holding values, no handles; a bound C++ value such as a random counts when no other element holds the same object) or a handle to an object no other element names. | parallel_for: element 0 cannot be proven exclusive (only value-semantic shapes are: primitives, strings, value containers, flat value-class instances and distinct handles to them) · parallel_for: elements 1 and 2 share one 'random' in field 'brain' (an element owns the C++ values it holds: give each element its own, or capture one from outside the loop) · parallel_for: zipped array 2 element 0 cannot be proven exclusive · parallel_transform: element 0: value is not value-semantic (object) |
| Handle elements | An array of shared_ptr or weak_ptr elements runs when no object appears at two elements of the range and its zipped arrays (below). | parallel_for: elements 0 and 3 hold the same object (an object a body reaches through a handle element belongs to one element) |
| The range and zipped arrays | The range is an array, and every zipped array is a separate array with the range's length (below). | parallel_for: zipped array 2 has 1 elements, the range has 3 (zipped arrays must have the same length) · parallel_for: zipped array 2 is the range itself · parallel_for: zipped array 2 is not an array · parallel_for: can only iterate an array |
| Counted loops | parallel_for (int i = a; i < b; i++): one int (or auto) variable compared with a bound by <, <=, > or >= and stepped by i++, i--, i += k or i -= k toward it, k a constant or a name; the body may not write i (below). | parallel_for: a counted loop must step its variable by a fixed amount toward a bound (write i++, i--, i += k or i -= k) · parallel_for: a counted loop compares its variable with a bound (i < b, i <= b, i > b or i >= b) · parallel_for: a counted loop declares one int variable (int i = a or auto i = a) · parallel_for: a counted loop must step toward its bound (i < b and i <= b count up, i > b and i >= b count down) · parallel_for: a counted loop's step must not be zero · parallel_for: a counted loop's step of -2 never reaches its bound (the serial loop would not end) · parallel_for: a counted loop's bound is float, not int · parallel_for: the body writes the counted loop's variable 'i' (the loop steps it; copy it into a local to change it) · Integer overflow in '++' |
| Index writes | out[i] = v (and op=, ++, --) into an enclosing array, where i is a loop variable the body never writes and every element carries a different index; an index past the array raises the serial loop's error. The body touches out only as out[i] (below). | parallel_for: elements 2 and 5 write 'out' at the same index 4 · parallel_for: body touches 'out' beyond the slot it writes, out[i] · parallel_for: body writes enclosing state 'out' at an index the body changes · parallel_for: captured name 'out' is written at an index but is not an array |
| Reductions | parallel_reduce(xs, init, step, combine) folds each chunk into its own accumulator. step follows the body rules, takes its element by value and may take its accumulator by reference; combine is ordinary code, run on the calling thread after the chunks join; step and combine are script functions or lambdas of two parameters (below). The input may hold class instances, each its own object. | parallel_reduce: step must take two parameters (acc, x) (combine must take two parameters (a, b) alike) · parallel_reduce: step must be a script function or lambda · parallel_reduce: step takes its element by value (the elements are the input's, never written) · parallel_reduce: input elements 0 and 1 hold the same object (an object the step reaches through a handle element belongs to one element) |
parallel_transform's functions | fn is a script function or lambda that takes its element by value; the optional weight_fn takes one parameter by value and returns a finite, non-negative number. | parallel_transform: fn must be a script-defined function · parallel_transform: fn may not take its element by reference (elements are detached copies) · parallel_transform: weight_fn must take exactly one parameter by value · parallel_transform: weight_fn must return a finite non-negative number (element 0) |
| Captured reads | Allowed: the body reads enclosing scalars, strings, arrays and maps as they were when the statement was reached, the same at every worker count (below). The read-only builtins run on them: size, empty, front, back, index_of, has, contains, first, last, length, slice, join, keys, values, get, to_array, at, substr and the find family. A function's or method's const T& parameter takes enclosing state as a captured read. A subscript read of the range's own array would race the element writes, and so would a const T& of it or of an index-written array. | parallel_for: method 'push' may mutate captured state 'g' · parallel_for: captured name 'xs' is the container being mutated (reads through it would race the element writes) |
| Writes to enclosing state | Refused in every shape other than the index write: assignment, compound assignment, ++/--, a mutating method, a reference that aliases it, an argument to a non-const reference parameter and a by-reference loop over it. | parallel_for: body writes enclosing state 'total' · parallel_for: reference declaration would alias enclosing state 'g' (captured reads are read-only) · parallel_for: argument passes enclosing state 'grid' to reference parameter 'g' of 'peak' (captured reads are read-only; 'peak' only reads it: declare it const array<int>& g) (..., which writes it (captured reads are read-only; pass a copy, or take it as const T& where it is only read) when it writes) · parallel_for: argument passes enclosing state 'g' by reference (captured reads are read-only) (a C++ function's T&) · parallel_for: cannot iterate captured state 'g' by reference in a parallel body (captured reads are read-only) |
| Captured objects | Read only: a captured instance of a value class (fields of primitives, strings, containers of them and nested value-class instances), a shared_ptr to one, or a C++ class's instance reads through its fields and its methods. A script method may run when it writes no part of the object; a C++ method follows the host-call rules (its const methods run unless registered serial). A captured handle is used through its members only, and no element may reach a captured object (below). Every other object an iteration writes is its element or one it made; this exists only inside a method the body runs. | parallel_for: member store reaches captured state 'cam' (captured reads are read-only) · parallel_for: method 'bump' of 'C' writes its object, and captured 'c' is read-only (write a local copy, or the element) · parallel_for: method 'push' may mutate captured state 'c' · parallel_for: captured handle 'h' is read through its members and methods only (a worker reads its own view of the object) · parallel_for: element 1 reaches the object captured as 'h' (a captured object is read-only, and an element's lane may write it) · parallel_for: captured name 'bad' is not value-semantic (an object holding a handle, a function, a var field or a C++ object) (an object whose class runs a destructor, a script class over a C++ class alike) · parallel_for: member access reaches captured state 'm' (captured reads are read-only, and only a captured object has members) · parallel_for: captured name 'w' is not value-semantic (object/shared_ptr/weak_ptr) (a weak_ptr) · parallel_for: 'this' is not allowed in a parallel body |
| Constructing script classes | Allowed in every spelling: Hit(r, d), Hit h(r, d);, Hit h;, new Hit(r, d), and the converting constructor a typed slot runs (Box b = x;, an argument, a return, a store). The new object belongs to the worker that made it; every constructor, base constructor, field initializer and destructor the construction can run follows the body rules (below). | parallel_for: body writes enclosing state 'next_id' in the constructor of 'Tick' (in a field initializer of 'Tag' and in the destructor of 'Res' alike) · converting constructor of 'Box' was not admitted in this parallel body (the statement admits a conversion into a slot it can type) (a conversion the statement could not see is refused, never run unwalked) |
| Constructing C++ classes | Allowed, a script class over one included, unless the host bound it with serial_only_constructor() (below). | parallel_for: C++ class 'Sprite' is marked serial_only_constructor() and cannot be constructed in a parallel body · parallel_for: class 'Glowing' has a C++ base 'Sprite' marked serial_only_constructor() and cannot be constructed in a parallel body |
| Script class methods | A method runs when it is the only overload of its name on the element's class or the base it inherits it from, the class has no private members and the method body follows the body rules. Inside it a sibling method is called as this.name() or by its bare name; a C++ base method keeps the this. spelling. A method of an object reached through a field is not admitted. | parallel_for: method 'bump' on class 'W' is not admitted in a parallel body (only single-overload methods of the element's class or its bases) · parallel_for: class 'Pv' has non-public members — its methods are not admitted in a parallel body · parallel_for: method 'size' must be called as 'this.size()' in a parallel body (a bare call of a C++ base method) · method 'read' was not admitted in this parallel body (admitted: single-overload public methods of the element's class or its bases touching only element, local, or captured-read state) |
| Operators | A class's operators run on the worker: arithmetic, comparison and <=> (the reversed forms included), unary - + ~, compound assignment, [], operator=, and the conversions into a typed slot: to_string, to_bool, to_int, to_float and to_char (int k = n;). A script operator's overloads and what they construct follow the body rules; a C++ operator follows the host-call rules, so += and the other non-const ones run only on an object the body owns. An operator whose body breaks a rule is refused when it runs, with the text the rule gives a body, without the parallel_for: prefix. | body writes enclosing state 'calls' in operator '+' of 'V' at 2:68 · custom operator dispatch on class instances is not admitted in a parallel body (an operator the statement never walked, such as the global []) |
| Enums and class constants | Allowed: E::v reads a value of the enum and C::K a static const of a number, bool or char, wherever the body or a method it runs names them. Both read as constants on every worker. | parallel_for: 'Tune::K' is no longer a constant (a parallel body reads only const statics of a primitive value) |
| Accessors, class changes | A user getter or setter, and an assignment that changes an object's class, are refused when they run. | only direct field access on class instances is allowed in a parallel body · only direct field stores on class instances are allowed in a parallel body · an assignment that changes an object's class is not admitted in a parallel body |
| Script function calls | Allowed: a direct call to a named function, recursion included, and the builtin container and string methods on the body's own values. A function used as a value, or called through a local, is refused. | parallel_for: captured name 'fv' is a function value (only direct calls to statically-known functions are allowed in a parallel body) · parallel_for: call target 'h' is not statically resolvable in a parallel body |
| Host calls | Bound C++ functions, static methods and methods run on the worker unless the host registered them serial: serial_function, serial_static_method, serial_method on the binder (the binder). A serial function or static method refuses the statement before any element runs; a serial method is refused when it is called, since its receiver's class is known only then. A non-const method or a property store runs only on an object the body owns. The library's builtins are serial apart from pure math, to_string, the web:: text functions and crypto:: (all but crypto::sha256_file). A static method of a script class, and a namespaced call other than math::, web:: and crypto:: (reflect:: and the like), are refused (below). | parallel_for: host function 'print' is marked serial_function() and cannot be called in a parallel body · parallel_for: C++ static method 'Sound::play' is marked serial_static_method() and cannot be called in a parallel body · C++ method 'publish' of 'Meter' is marked serial_method() and cannot be called in a parallel body · C++ method 'add' of 'Meter' changes an object this parallel body did not make (only its const methods may run on it) · C++ property 'level' of 'Meter' is stored on an object this parallel body did not make (only an object it made takes stores) · parallel_for: static member calls are not allowed in a parallel body |
| Objects from host calls | An object a host call returns that the program already holds (a C++ object's wrapper or script layer) is shared: the body reads its fields, each read a copy of its own, an object a field holds is shared too, and its C++ class's const methods run. A store into it, and a script method on it, are refused when they run. ==, a conversion into a typed slot, a condition and its text read the worker's own copy of it, whose class's operators and conversions run on the copy through their pins; the copy never runs a destructor. Kept in the element, in out[i] or in the result, it is the object itself after the loop. | member 'extra' is stored on a shared object in a parallel body (a body writes its element, out[i] and the objects it made) · method 'grow' cannot run on a shared object in a parallel body (an object reached through a host call takes only its C++ class's const methods) · a host call in a parallel body returned an object one of its iterations owns (an element, or an object a chunk made) |
| Receivers | A signal's receiver list is state other objects' code reads and fires, so connect and disconnect on a Signal are serial methods. A C++ call that fires a signal runs its receivers on the worker that made the call, so a host marks such a method serial_method when its receivers reach shared state (Signals). | C++ method 'connect' of 'Signal' is marked serial_method() and cannot be called in a parallel body |
| Lambdas | A body writes no lambda. parallel_transform's fn and parallel_reduce's step may themselves be lambdas, their captures provisioned like captured reads; combine is ordinary code, a lambda as any. | parallel_for: lambdas are not allowed in a parallel body |
yield | Iterations do not suspend: a body neither yields nor calls a coroutine method. | parallel_for: yield is not allowed in a parallel body · parallel_for: coroutine method 'g' is not allowed in a parallel body |
time:: | The clock is engine-owned, and a read would depend on scheduling (Time and parallel loops). | parallel_for: time:: calls are not allowed in a parallel body (engine-owned clock) |
| Random numbers | Allowed: a captured random draws a stream per element that the statement seeds from one draw of it, so the numbers are the same at every worker count; crypto:: runs too, apart from crypto::sha256_file. Reseeding a captured random is refused (below). | parallel_for: random::seed cannot be called on captured 'loot' in a parallel body (seed before the loop: each element draws its own stream from it) · parallel_for: crypto::sha256_file cannot be called in a parallel body (it reads a file) |
| Nested parallelism | A body, and every function it calls, runs no parallel loop of its own. | parallel_for: statement is not allowed in a parallel body (a parallel_for) · parallel_for: host function 'parallel_transform' is marked serial_function() and cannot be called in a parallel body (parallel_reduce alike) |
| Declarations and modules | A body declares no function, class, namespace or enum, and includes or imports nothing. | parallel_for: function declarations are not allowed in a parallel body (namespace and enum declarations alike) · parallel_for: include/import is not allowed in a parallel body |
| Control flow | continue, and a return at the body's top level, move to the next element; try/catch works inside the body. A loop-level break is a parse error (below). | 'break' is not allowed in a parallel_for body: iterations are independent; use 'continue' for the next element |
| Reference casts | cast<T&> would bind a reference the proof cannot follow. | parallel_for: cast<T&> is not allowed in a parallel body |
| Errors and limits | A throwing element fails the whole call with the first error in iteration order, the serial loop's first error. Index writes are copied back up to the failing element; an element a body changes in place past it may or may not have been written. The execution budget and the memory cap hold inside a body (Safety Rails): a budget overrun is terminal as anywhere, and copies made for the workers count toward the memory cap. | the element's own error |
parallel_for and parallel_transform
parallel_for runs a loop body over the elements of an array on every core. The loop variable is the element, so auto& mutates it in place. parallel_transform(arr, fn) returns a new array with out[i] = fn(arr[i]); it is the same loop in a different spelling, as below shows. thread_count() reports the worker count: the count the host set, else the machine's performance cores, at most one fewer than its hardware threads. Results never depend on it.
var nums = [1, 2, 3, 4, 5, 6, 7, 8];
parallel_for (auto& x : nums) {
x = x * x; // each element mutated in place, across workers
}
print("squares: {}", to_json(nums));
int inc(int v) { return v + 1; }
print("mapped: {}", to_json(parallel_transform(nums, inc)));squares: [1,4,9,16,25,36,49,64]
mapped: [2,5,10,17,26,37,50,65]Counted loops
parallel_for (int i = a; i < b; i++) runs its body once for each value the serial for loop gives i, split across the workers exactly as the range form splits an array holding those values (no such array is made: each element's value is computed from its position, so the loop takes no memory per element), and under the same body rules: out[i] = v is an index write. a, b and the step are evaluated once, in that order, before any element runs; a loop the serial form would run zero times runs no element. The form is kept to what provably visits each index once: one int variable, compared with <, <=, > or >=, stepped toward the bound by i++, i--, i += k or i -= k with k a constant or a name, and never written by the body. Any other shape is refused when the script loads; a step that would never end the serial loop, or a last step that would overflow, is refused when the statement is reached.
int n = 8;
array<int> squares = [0, 0, 0, 0, 0, 0, 0, 0];
parallel_for (int i = 0; i < n; i++) { squares[i] = i * i; } // each index once, split across the workers
array<int> marks = [0, 0, 0, 0, 0, 0, 0, 0];
parallel_for (int i = n - 2; i >= 0; i -= 2) { marks[i] = 1; }
print(`${to_json(squares)} ${to_json(marks)}`);
int none = 0;
try { parallel_for (int i = 0; i < n; i += none) { } } catch (e) { print(e); }[0,1,4,9,16,25,36,49] [1,0,1,0,1,0,1,0]
parallel_for: a counted loop's step of 0 never reaches its bound (the serial loop would not end)The zip form
The zip form walks several arrays of the same length in lockstep, one loop variable per array. Worker n owns segment n of every array, so a pre-allocated array of output structs is written in place: no construction per element, no result marshalling. This is the emitter shape, one input array and one output array of the same length, and the preferred way to produce several values per element.
class Body { float x = 0.0; float vx = 0.0; Body() {} Body(float px, float v) { x = px; vx = v; } }
class Sample { float pos = 0.0; bool moving = false; }
array<Body> bodies = [];
array<Sample> samples = [];
for (int i = 0; i < 6; i++) { bodies.push(Body(i * 10.0, i % 3 - 1.0)); samples.push(Sample()); }
parallel_for (auto& b, auto& s : bodies, samples) { // one loop variable per array, walked in lockstep
b.x += b.vx * 0.5; // the input mutated in place
s.pos = b.x; // the pre-allocated output written in place
s.moving = b.vx != 0.0;
}
for (auto& s : samples) { print(`${s.pos:.1f} ${s.moving}`); }-0.5 true
10.0 false
20.5 true
29.5 true
40.0 false
50.5 trueArrays of different length, the range itself as a zipped array, or a non-array raise a catchable error when the statement is reached, before any element runs: parallel_for: zipped array 2 has 1 elements, the range has 3 (zipped arrays must have the same length), parallel_for: zipped array 2 is the range itself, parallel_for: zipped array 2 is not an array.
parallel_transform is that zip
parallel_transform(xs, fn) is parallel_for (auto& x, auto& o : xs, out) { o = fn(x); } over a fresh output of xs.size() slots, and it costs exactly that loop plus the output. When fn only reads its parameter, its body runs inlined in the loop with the parameter bound to the element in place, so there is no call and no copy per element. A fn that mutates its parameter works on a private copy, so the source array is never touched either way. Any result type works, objects included: the returned array holds copies that alias nothing.
class Body { float x = 0.0; float vx = 0.0; Body() {} Body(float px, float v) { x = px; vx = v; } }
array<Body> bodies = [];
for (int i = 0; i < 4; i++) { bodies.push(Body(i * 10.0, 1.0)); }
float next_x(Body b) { return b.x + b.vx; } // reads its parameter: the body runs inlined in the loop
Body stepped(Body b) { b.x += b.vx; return b; } // mutates its copy: the source is untouched
var xs = parallel_transform(bodies, next_x);
var moved = parallel_transform(bodies, stepped);
print(`${to_json(xs)} moved ${moved[3].x:.1f} source ${bodies[3].x:.1f}`);[1.0,11.0,21.0,31.0] moved 31.0 source 30.0An optional third argument, parallel_transform(arr, fn, weight_fn), balances chunks by per-element effort (e.g. weigh strings by length):
var texts = ["7", "42", "1999", "3", "250"];
int parse_one(string s) { // digits -> int, no host calls
var n = 0;
for (var i = 0; i < s.size(); i++) { n = n * 10 + (s[i] - '0'); }
return n;
}
float w(string s) { return s.size(); } // heavier strings, fatter chunks
var parsed = parallel_transform(texts, parse_one, w);
print("parsed: {}", to_json(parsed));parsed: [7,42,1999,3,250]Control flow in the body
The body is the loop's body: continue moves to the next element, and so does a return written at the body's top level (iterations are independent, there is nothing to return to). A loop-level break is a parse error, 'break' is not allowed in a parallel_for body: iterations are independent; use 'continue' for the next element. Inside a nested loop or switch, break and continue keep their usual meaning.
var words = ["keep", "", "skip", "keep", "", "keep"];
array<int> kept = [0, 0, 0, 0, 0, 0];
parallel_for (auto& w, auto& k : words, kept) {
if (w == "") { continue; } // next element
if (w == "skip") { return; } // also the next element: the body is the loop's body
k = w.size();
}
print("{}", to_json(kept));[4,0,0,4,0,4]A throwing element fails the whole call with the first error in iteration order, so the error is as deterministic as the results: it is the serial loop's first error. Index writes come back up to the failing element, exactly the serial loop's stores; an element the body changes in place past the failing one may or may not have been written, since other workers keep running their elements until the region joins.
Captured reads
The body may read enclosing globals: scalars, strings, and value-semantic containers. Every worker sees the content as of the call (the "barrier"), identical at every worker count. Provisioning is automatic and tiered: a container of primitives or strings touched only by subscript reads and size(), length() and empty() is a zero-copy borrow (workers read the shared storage in place; a string read copies its bytes into a worker-owned string); anything else (nested containers, iterated or whole-value reads) is copied once per worker at the barrier and charged to memory_cap. This is what makes the "shared read-only grid" and "shared palette" renderer shapes cheap:
var gw = 4;
var grid = []; // the shared input: three rows of gw cells
for (var i = 0; i < gw * 3; i++) { grid.push(i); }
int row_sum(int row) {
var total = 0;
for (var x = 0; x < gw; x++) { total += grid[row * gw + x]; } // captured reads
return total;
}
var row_indices = [0, 1, 2];
var sums = parallel_transform(row_indices, row_sum); // grid rides along for free
print("row sums: {}", to_json(sums));row sums: [6,22,38]A function or method the body calls may take enclosing state through a const T& parameter: that is a captured read, provisioned by the same tiers, so a helper that only subscripts its parameter (directly or by handing it on to another const T&) reads the shared storage in place. A lambda's captures given to parallel_transform or parallel_reduce follow the same tiers. A plain T& parameter could write the argument, so it is refused, and when the function never writes through it the error names the const T& to declare instead:
array<int> heights = [3, 9, 4, 7, 1, 8];
int peak(const array<int>& h, int i) { // reads its argument in place: no copy
int best = h[i];
if (i > 0 && h[i - 1] > best) { best = h[i - 1]; }
if (i + 1 < h.size() && h[i + 1] > best) { best = h[i + 1]; }
return best;
}
int tally(array<int>& h) { return h.size(); } // never writes h, but does not say so
array<int> spots = [0, 1, 2, 3, 4, 5];
array<int> peaks = [0, 0, 0, 0, 0, 0];
parallel_for (int i : spots) { peaks[i] = peak(heights, i); }
print(to_json(peaks));
try { parallel_for (int i : spots) { peaks[i] = tally(heights); } } catch (e) { print(e); }[9,9,9,7,8,8]
parallel_for: argument passes enclosing state 'heights' to reference parameter 'h' of 'tally' (captured reads are read-only; 'tally' only reads it: declare it const array<int>& h) at 13:55Captured objects
A body reads a captured object through its fields and its methods, with no write to it from any lane. That holds by construction for a value class, an instance whose fields hold values only (primitives, strings, containers of them, nested value-class instances), and for a shared_ptr to one: each worker reads its own view, a copy taken when the statement is reached. A script method called on it may run when it writes no part of the object, which the statement checks before any element runs. A C++ class's instance is captured too: every worker reaches the same C++ object, so its binding decides, as for any C++ call: methods the host left unmarked run (on a captured object only the const ones), and a method registered serial_method is refused when it is called (the binder). A captured handle is used through its members and methods only, since a worker's view is not the object itself, and the statement refuses a range whose elements reach a captured object, since an element's lane may write it while the others read their views. A captured object holding a handle, a function, a var field or a C++ object, one whose class runs a destructor, and a weak_ptr, stay refused.
class Vec3 {
float x = 0.0; float y = 0.0; float z = 0.0;
Vec3(float a, float b, float c) { x = a; y = b; z = c; }
float dot(Vec3 o) { return x * o.x + y * o.y + z * o.z; } // writes nothing of its object
void scale(float k) { x *= k; y *= k; z *= k; }
}
Vec3 sun = Vec3(0.0, 1.0, 0.0);
array<float> light = [0.0, 0.0, 0.0, 0.0];
parallel_for (int i = 0; i < 4; i++) { light[i] = sun.dot(Vec3(i * 1.0, i * 0.5, 0.0)); }
print(to_json(light));
try { parallel_for (int i = 0; i < 4; i++) { sun.scale(2.0); } } catch (e) { print(e); }[0.0,0.5,1.0,1.5]
parallel_for: method 'scale' of 'Vec3' writes its object, and captured 'sun' is read-only (write a local copy, or the element) at 11:50Writes to enclosing state
Writes to enclosing state are errors, with one proven exception, the index write below. The elements behind the loop variables are what a body mutates. Assigning to captured state in any other shape (g = ..., g[j] = ... with an index that is not a loop variable, compound stores, g.push(...) (any mutating or unrecognized method on a captured value), var& r = g aliases, passing captured state to a non-const reference parameter (a const T& one reads it, above), or for (auto& v : g)) raises an error like parallel_for: body writes enclosing state 'total' at 3:33. A captured object is read-only like any captured value: cam.x = 1.0 raises parallel_for: member store reaches captured state 'cam' (captured reads are read-only) at 4:45; per-element object state goes through the arrays. Calls to other script functions (including recursion) and builtin container/string methods are fine.
Index writes
out[i] = v (and op=, ++, --) is legal when out is an enclosing array and i a loop variable the body never writes (directly, or through a function's or method's T& parameter that writes it). The loop variable's values must name distinct slots, which the statement checks when it is reached: two elements carrying one index raise parallel_for: elements 2 and 5 write 'out' at the same index 4. The body may touch out only as out[i], since reading another slot would race the writes (parallel_for: body touches 'out' beyond the slot it writes, out[i] at 4:20). Each worker writes a private copy of out, and the statement copies every element's slot back in element order, up to the first failing element: exactly the stores the serial loop makes.
array<int> order = [3, 0, 2, 1];
array<int> placed = [0, 0, 0, 0];
parallel_for (int slot : order) {
placed[slot] = slot * 10; // each element writes the slot its value names
}
print("{}", to_json(placed));[0,10,20,30]Constructing objects
A body constructs objects in every spelling: Hit(r, d), Hit h(r, d);, Hit h; and new Hit(r, d), and through a converting constructor wherever a value converts into a slot of the class: a declaration (Box b = x;), an argument, a return value or a store. The new object belongs to the worker that made it, so its constructor writes its fields freely; everything else that construction runs follows the body's rules. When the statement is reached, before any element runs, it checks the constructor each call can reach (every one its argument count admits), the base constructors its initializer list names (a base it does not name is default-constructed), each level's field initializers, and the destructor of every class whose instance can die in the body: an object the body makes, an element it replaces, and whatever their fields hold. Class code may call script functions, methods on the object, and the same pure host functions a body may.
string PREFIX = "hit-";
class Vec { float x = 0.0; float y = 0.0; Vec() {} Vec(float a, float b) { x = a; y = b; } }
class Hit {
int ray = -1;
Vec at;
string tag = PREFIX;
Hit() {}
Hit(int r, float d) { ray = r; at = Vec(d, d * 2.0); tag = tag + to_string(r); }
}
array<int> rays = [0, 1, 2, 3];
array<Hit> hits = [Hit(), Hit(), Hit(), Hit()];
parallel_for (int r : rays) {
hits[r] = Hit(r, r * 0.5); // each worker constructs the hit its ray owns
}
print("{} {} {}", hits[3].tag, hits[3].at.y, hits[0].ray);hit-3 3.0 0Construction code that writes state the body does not own refuses the statement, and the error names where that code sits: a constructor, a field initializer or a destructor.
int next_id = 0;
int deaths = 0;
class Tick { int id = 0; Tick() { id = next_id; next_id++; } }
class Tag { int id = next_id++; }
class Res { int v = 0; ~Res() { deaths++; } }
array<int> xs = [1, 2, 3];
try { parallel_for (auto& x : xs) { Tick t; x = t.id; } } catch (e) { print(e); }
try { parallel_for (auto& x : xs) { Tag t; x = t.id; } } catch (e) { print(e); }
try { parallel_for (auto& x : xs) { Res r; x = r.v; } } catch (e) { print(e); }parallel_for: body writes enclosing state 'next_id' in the constructor of 'Tick' at 3:49
parallel_for: body writes enclosing state 'next_id' in a field initializer of 'Tag' at 4:22
parallel_for: body writes enclosing state 'deaths' in the destructor of 'Res' at 5:33C++ classes and hybrids
A class the host binds, and a script class over one, construct in a body too: the host's constructor and destructor are taken to be safe on any thread, while the script levels follow the rules above. A host class whose constructor or destructor touches state other objects share (a registry, a cache, a service) is bound with .serial_only_constructor() (see the binder); a body that constructs it, or a script class over it, is refused before any element runs, with parallel_for: C++ class 'Sprite' is marked serial_only_constructor() and cannot be constructed in a parallel body at 3:48 or parallel_for: class 'Glowing' has a C++ base 'Sprite' marked serial_only_constructor() and cannot be constructed in a parallel body at 4:49. An object of such a class that dies on a worker anyway is released when the statement's workers are done, on the calling thread, in element order.
C++ calls
Bound C++ functions, static methods and methods are parallel unless the host registered them serial (the binder). The library's own builtins are serial apart from pure math, to_string, the web:: text functions and crypto:: (all but crypto::sha256_file), so print in a body is refused before any element runs: parallel_for: host function 'print' is marked serial_function() and cannot be called in a parallel body at 3:5, and a serial static method reads parallel_for: C++ static method 'Sound::play' is marked serial_static_method() and cannot be called in a parallel body at 3:5. A method's mark covers its non-const overloads, so a const getter of the same name stays parallel. A method's receiver class is known only when it is called, so a serial method is refused then: C++ method 'publish' of 'Meter' is marked serial_method() and cannot be called in a parallel body. A non-const C++ method, or a store to a C++ property, runs only on an object the body owns: its element (a range or zipped element, which the statement proves the only element of its object, or a parallel_reduce accumulator) or one it made. On any other object, such as one a C++ function returns, only const methods run, and anything else is refused with C++ method 'add' of 'Meter' changes an object this parallel body did not make (only its const methods may run on it) or C++ property 'level' of 'Meter' is stored on an object this parallel body did not make (only an object it made takes stores).
Random numbers
A body draws from a random it captures, in the body and in the functions, methods and constructors it calls. The statement takes one draw from the captured generator to seed the streams, and each element (the chunk, for parallel_reduce) draws from its own stream, which depends only on that draw and the element's index. So the loop gives the same numbers on any number of cores, and the generator after it is the same too: it has moved one draw for each statement that captured it. The numbers are not the ones the serial loop would draw, since a serial loop takes its draws in the order its elements run and a parallel one has no such order. A random an element owns (an array<random> zipped beside the data, or a field of the element's class) or one the body makes draws as it would anywhere. The statement proves each element's generator its own object, as it does every C++ value an element holds, and refuses two elements holding one generator with parallel_for: elements 1 and 2 share one 'random' in field 'brain' (an element owns the C++ values it holds: give each element its own, or capture one from outside the loop). Reseeding a captured generator in a body is refused before any element runs; seed before the loop (Random Numbers & Crypto has the exact rule).
random loot(5);
array<int> drops = [0, 0, 0, 0, 0, 0, 0, 0];
parallel_for (auto& d : drops) { d = loot.next_int(1, 100); } // the same drops on any number of cores
print(drops.join(" "));
print(loot.next_int(1, 100)); // the generator moved one draw for the loop
array<random> brains = [random(1), random(2), random(3)]; // one stream per monster
array<int> hp = [10, 10, 10];
parallel_for (auto& h, auto& brain : hp, brains) { h -= brain.next_int(1, 6); }
print(hp.join(" "));
try { parallel_for (auto& d : drops) { loot.seed(d); } } catch (e) { print(e); }
class Monster { int hp = 10; random brain; } // or a field: each monster's own generator
array<Monster> pack = [];
for (int i = 1; i <= 3; ++i) { Monster m; m.brain.seed(i); pack.push(m); }
parallel_for (auto& m : pack) { m.hp -= m.brain.next_int(1, 6); }
print(to_string(pack[0].hp) + " " + pack[1].hp + " " + pack[2].hp);51 17 31 91 93 79 47 67
61
5 9 5
parallel_for: random::seed cannot be called on captured 'loot' in a parallel body (seed before the loop: each element draws its own stream from it) at 11:45
5 9 5Handles as elements
An array of shared_ptr<T> or weak_ptr<T> elements runs when they name distinct objects. The statement checks, once when it is reached, that no object appears at two elements of the range and its zipped arrays, and raises parallel_for: elements 0 and 3 hold the same object (an object a body reaches through a handle element belongs to one element) otherwise. The check proves that one worker reaches each object. It proves nothing about what the object holds: its class must still be a flat value class (fields that are values, no handles), as for an element held by value. Another holder of the same object elsewhere in the program is fine, since nothing else runs during the loop.
Reductions: parallel_reduce
parallel_reduce(xs, init, step, combine) folds an array on every core: xs splits into chunks whose size depends on xs.size() alone, each chunk folds its elements in order with step(acc, x) starting from its own copy of init, and the chunk results combine left to right with combine(a, b). The chunking never depends on the worker count, so a float sum is bit-identical on every machine. init should be combine's identity (0 for a sum), since every chunk starts from it; an empty array returns it. The accumulator may be a container or an object, and step is admitted like a parallel_for body: it may not write enclosing state, and a throw from it fails the call as from parallel_for. step may be a lambda, its captures provisioned as a parallel_for body's are. combine is ordinary code: it runs on the calling thread after the chunks join, left to right, so it may print, count its calls or write enclosing state. step's body runs grafted into the chunk loop, so no call is paid per element; a step that takes its accumulator by reference (void step(T& acc, x), or -> T& returning acc) folds the chunk's accumulator in place with no copy in or out, the form for a container or object accumulator, while the by-value form T step(T acc, x) suits a scalar. The element x is taken by value. The input may hold class instances: step reads their fields and runs their methods and operators under the body rules, and every element must be its own object, as a zipped array's are, so an input holding one shared_ptr twice is refused with parallel_reduce: input elements 0 and 1 hold the same object (an object the step reaches through a handle element belongs to one element).
array<float> prices = [4.5, 12.0, 7.25, 3.0, 9.5];
float add(float a, float b) { return a + b; }
class Span { float lo = 1000000.0; float hi = -1000000.0; }
void widen(Span& r, float x) { if (x < r.lo) { r.lo = x; } if (x > r.hi) { r.hi = x; } } // folds in place
Span join(Span a, Span b) { if (b.lo < a.lo) { a.lo = b.lo; } if (b.hi > a.hi) { a.hi = b.hi; } return a; }
print(`total ${parallel_reduce(prices, 0.0, add, add):.2f}`);
Span r = parallel_reduce(prices, Span(), widen, join); // an object accumulator
float big = parallel_reduce(prices, 0.0, [](float a, float x) { return x > 5.0 ? a + x : a; }, add);
print(`span ${r.lo:.2f}..${r.hi:.2f} big ${big:.2f}`);total 36.25
span 3.00..12.00 big 28.75The long form is the same loop written out: a zipped array of per-chunk accumulators and their chunk numbers, each chunk folding its slice in place, then a serial fold of the partial results. A body that adds into a captured total instead raises the enclosing-state error.
array<float> prices = [4.5, 12.0, 7.25, 3.0, 9.5];
int size = 2;
array<float> partial = [0.0, 0.0, 0.0]; // one accumulator per chunk of `size` elements
array<int> chunk_ids = [0, 1, 2];
parallel_for (auto& p, int c : partial, chunk_ids) {
for (int i = c * size; i < c * size + size && i < prices.size(); i++) { p += prices[i]; }
}
print(`total ${partial.reduce((s, p) => s + p, 0.0):.2f}`);total 36.25Choosing a shape
Use captured reads for shared inputs, a zipped output array for per-element results, and parallel_reduce for a total. Where the output shape is not known up front, the pair idiom serves: elements shaped [input..., tag] come back from parallel_transform as [tag, outputs...], collected from the result in iteration order.