Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 23 additions & 36 deletions docs/design-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -784,42 +784,29 @@ outside this capability because they require their own complete element/projecti

## The lambda philosophy

Lambdas (`fn x { ... }`) are not a separate paradigm bolted onto the data-processing core — they
**are** how you pass behavior to `map` / `where` / `reduce` / `par_map`. A lambda and a named
function are the same thing; the lambda just spares you a top-level declaration for a one-off.

The load-bearing decision is **how capture works**. A lambda that captures an enclosing variable
does *not* allocate a hidden closure environment: it is lifted to an ordinary function whose
captured values become extra parameters, passed at the call site. So a captured pipeline lambda
fuses into the same counted loop as a named function and carries zero allocation — the capture is
just a loop-invariant argument. The compiler snapshots each stage capture once when that written
stage is formed, evaluates later terminal arguments, then snapshots terminal/reducer captures; the
fused loop reuses those SSA operands. It never delegates capture timing to an optimizer by emitting
an enclosing-local reload per iteration. A view snapshot keeps its owner dependency across the
intervening arguments, so an ownership-changing `init` is rejected rather than leaving a dangling
callback argument. This keeps lambdas inside the existing
guarantees rather than introducing a new cost class: **Nothing hidden** (no silent heap
environment), **Predictable performance** (a lambda is never secretly slower than the named
equivalent), and **Compiler-friendly** (the optimizer sees a direct call, not an indirect one
through a closure object).

**Escape decides the representation.** A lambda that *escapes* — stored in a variable, returned,
or handed to `task_group`'s `spawn` to run later — outlives the locals it captures, so it cannot
borrow them; it needs a **closure environment** holding the captured values. The compiler's escape
analysis (the same one that governs views and arenas) chooses: a non-escaping lambda (every
pipeline `map`/`where`/`reduce`/`par_map`) is inlined with captures-as-parameters — zero
allocation, SIMD/GPU-friendly; an escaping lambda gets an environment. That environment is not a
new hidden cost class: it is **owned by the enclosing region** — the `task_group {}` / `arena {}`
scope it escapes into — and freed with that region, exactly like every other region allocation.
The task-group region is reserved for spawned environments and result slots; it does not silently
turn unrelated allocations in the block into arena allocations. So
it stays inside the one region-based allocation model, and the *visible* act of escaping (and the
*visible* enclosing scope) is the allocation boundary — consistent with **Nothing hidden** (no
silent free-floating `malloc`). This is the load-bearing design point: it lets first-class function
values and `task_group` exist *without* eroding the inlined, offload-ready pipeline path, and the
two paths are distinguished by escape, not by two different lambda syntaxes. (The allocation model
for a closure that escapes *every* region — e.g. one returned to an unbounded caller — is part of
the deferred first-class-closure design; the `task_group` consumer is scope-bounded and clean.)
Lambdas (`fn x { ... }`) pass behavior to pipelines and other functions without
requiring a top-level declaration for each callback.

An inline pipeline lambda is lifted to a function with extra parameters for its Copy
captures. The compiler snapshots each stage's captures at its written position, then
evaluates terminal arguments and snapshots terminal captures. The fused loop reuses those
values. A captured view retains its owner dependency across intervening arguments, so an
ownership-changing `init` cannot leave the callback with a dangling view. No separate
closure environment is needed on this path.

A stored or passed capturing function value instead uses a frame-local environment.
It may be called while that frame and its borrowed captures remain live. Binding a lambda
therefore does not imply heap allocation or permission to return it. Returning function
values and carrying a capturing environment beyond its frame remain deferred; wrapping
the binding in an `arena` does not extend that environment's lifetime.

A spawned literal needs a fresh environment for each task. A frame-local slot reused by
a loop iteration would let concurrent tasks observe overwritten captures. The visible
`task_group` scope owns these environments and result slots until every task has joined.
Other values allocated in its body retain their ordinary ownership, and arena-only
operations still need an explicit `arena`. The same lambda syntax thus supports direct
pipeline calls, frame-local function values and structured tasks with distinct storage
lifetimes. Plan259 adds owned inputs only to the task's single invocation, as described below.

The **Side Effect Rule** completes the picture: a `par_map` lambda must be Pure (it may read
captured values but not mutate external state), and every worker capture must independently be
Expand Down
9 changes: 8 additions & 1 deletion docs/guide/07-strings-and-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ fn main() -> i32 {
}
```

The default in signatures is `str`: take views, return views when the data already exists, and return `string` only when the function genuinely creates new text.
Use `str` when a function reads text or returns a view whose source remains alive. Return `string` when the caller needs ownership, whether the function creates the text or transfers an existing owned value.

## Literals, escapes, bytes

Expand Down Expand Up @@ -119,6 +119,13 @@ fn main() -> i32 {

The builder uses one growable buffer and produces a final `string`. Buffer growth is amortized across appends. `write` takes a `str` (or an owned `string`); `write_int` formats an integer directly into the buffer without a temporary string. The compiler can combine adjacent writes of literals and integers into one runtime call.

`b.len()` reports the initialized byte count without finishing the builder.
`b.bytes()` borrows those same bytes as a read-only `slice<u8>`, without a copy
or allocation. This lets a byte-oriented consumer read the current text without
first calling `to_string()`. Finish using that view before any `write*` call,
including an empty write, or before finishing, moving or dropping the builder.
Obtain a fresh view after a write; reserved capacity is never part of the view.

## Template strings

For one-shot formatting, `template` interpolates full expressions:
Expand Down
10 changes: 7 additions & 3 deletions docs/guide/10-closures-and-parallelism.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ ys := [1, 2].par_map(fn x { show(x) })
// for an accumulation
```

Sequential `map` may call `show`: it prints in input order. Changing that call to `par_map` is not valid, because the observable order would depend on worker scheduling. Parallel callables must satisfy both purity and capture restrictions. Move captures are rejected, and a `region` allocation capability cannot be sent to a worker even though its type is Copy. These checks are inferred; no `Send` or `Sync` annotation is written.
Sequential `map` may call `show`: it prints in input order. Changing that call to `par_map` is not valid, because the observable order would depend on worker scheduling. `par_map` callables must satisfy both purity and capture restrictions. They cannot capture Move values, and a `region` allocation capability cannot be sent to a worker even though its type is Copy. These checks are inferred; no `Send` or `Sync` annotation is written.

## `par_map` — data parallelism

Expand Down Expand Up @@ -103,7 +103,7 @@ fn main() -> Result<(), Error> {
The task is eligible to run before `spawn` returns, even when it is the only task. A busy worker
pool can delay its start. A captured view shares its original bytes: keep that backing unchanged
and alive until `wait`. Dropping or reassigning the task handle does not end this protection.
Tasks may read shared views and modify storage they create themselves.
Tasks may read shared views and modify storage they create or receive by ownership transfer, as shown below.

Unlike `par_map`, spawned tasks may perform I/O and other impure work. `wait()` also helps execute its group's pending tasks, allowing nested groups to progress even when every worker is occupied. It cannot resolve a blocking cycle created by the program. Task results may be self-contained primitive values, strings, buffers, files, ordinary dynamic arrays, records, tuples or sums, optionally through `Result<R, Error>`. They may contain owned data but cannot contain views, pointers, callables or native/resource handles other than `file`. After a successful wait, Copy `get()` may repeat; owned `get()` consumes the handle and transfers the result once. Results can outlive the group. Group exit joins all tasks and drops untaken results, including after a failed wait. Shared captures remain read-only. For contiguous output, create one owned band per task and collect the named handles in band order.

Expand All @@ -119,7 +119,7 @@ An already-open `file` can move into a task without reopening its path:
import std.fs

fn read_window(pack: file) -> Result<buffer, Error> {
mut window := buffer(4096)
mut window := buffer.try_new(4096)?
pack.pread_into(window, 0, 4096, 0)?
Ok(window)
}
Expand All @@ -136,6 +136,10 @@ fn main() -> Result<(), Error> {
}
```

`buffer.try_new(4096)?` reserves the requested window or propagates an allocation
error before the read. `pread_into` fills at most that window; a short read
produces a shorter result, so this example reads one window rather than an entire file.

The same file owner may be returned directly or in a record alongside a filled buffer,
using the ordinary consuming `get()`. This permits bounded window reuse while retaining
file identity. GPU resources and signal subscriptions remain confined to their owning thread;
Expand Down
15 changes: 10 additions & 5 deletions docs/guide/16-toolchain.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ alignc explain-opt file.align --verbose
alignc size file.align --profile tiny
```

`emit-mir` is the semantic lens. Raw LLVM IR shows lowering before optimization; optimized IR shows the code LLVM actually shaped. `explain-opt` reports the compiler's current storage and explicit-parallel execution choices, then translates vectorization and other LLVM optimization remarks back to source lines. `size` builds the same artifact as `build` under the selected profile and reports where its bytes went. For standalone objects or IR, repeat `--export name` to keep selected entry-unit functions externally visible.
`emit-mir` prints the compiler's mid-level representation. Raw LLVM IR shows lowering before optimization; optimized IR shows the result of LLVM's optimization passes. `explain-opt` reports the compiler's current storage and explicit-parallel execution choices, then translates vectorization and other LLVM optimization remarks back to source lines. `size` builds the same artifact as `build` under the selected profile and reports where its bytes went. For standalone objects or IR, repeat `--export name` to keep selected entry-unit functions externally visible.

Roots for linking an executable and roots for inspecting a unit are different questions. A build keeps `main` plus whatever `--export` names, and `emit-obj` keeps the same set because its output is linked, not read. The inspection verbs report the unit you pointed them at: when that file defines no `main` and you pass no `--export`, every `pub` function it defines is a root, so `emit-llvm --stage optimized` emits the unit's own bodies and `explain-opt` reports its own optimizer decisions instead of an empty module. The seeded set is noted on stderr, so a redirected IR or report stream is unchanged. `--export` is accepted by `emit-obj`, `emit-llvm` and `explain-opt`, and narrows that set exactly as before. `emit-mir` needs no rule: it prints every lowered function.

Expand All @@ -123,11 +123,16 @@ runtime LTO off; it does not describe a linked ThinLTO executable.
--thin-lto # cross-unit ThinLTO
```

On an Apple target the deployment target is part of the resolved target identity, exactly as the CPU is: one resolution point, one stated precedence, and one string that the objects, the module IR, the link and the cache key all derive from. It resolves to the first of `--deployment-target`, the platform's `MACOSX_DEPLOYMENT_TARGET` (or `IPHONEOS_`/`WATCHOS_`/`TVOS_`/`XROS_`) environment variable, the host's `sw_vers -productVersion`, and a documented per-platform floor. Every layer is read at `major.minor` precision, and a malformed value at any layer is an error naming that layer rather than a fall-through to the next: an unset environment variable moves on, but a variable that is set to an empty or unusable value is a supplied value and is rejected. `--deployment-target` on a non-Apple host is an error rather than a silently ignored flag. Because the version is part of the identity and the OS patch level is not, a patch-level OS update does not invalidate the build cache, and changing the deployment target does.
On an Apple target, the deployment target specifies the minimum OS version for generated artifacts and participates in the build cache identity. It resolves to the first of `--deployment-target`, the platform's `MACOSX_DEPLOYMENT_TARGET` (or `IPHONEOS_`/`WATCHOS_`/`TVOS_`/`XROS_`) environment variable, the host's `sw_vers -productVersion`, and a documented per-platform floor. Every layer is read at `major.minor` precision, and a malformed value at any layer is an error naming that layer rather than a fall-through to the next: an unset environment variable moves on, but a variable that is set to an empty or unusable value is a supplied value and is rejected. `--deployment-target` on a non-Apple host is an error rather than a silently ignored flag. Because the version is part of the identity and the OS patch level is not, a patch-level OS update does not invalidate the build cache, and changing the deployment target does.

`--sdk-version` separately records explicit SDK provenance in Apple objects' `LC_BUILD_VERSION`. It accepts `major`, `major.minor`, or `major.minor.patch`; omission leaves the object SDK unspecified (`sdk n/a`) exactly as before. It does not select an SDK or sysroot and has no ambient `SDKROOT`/`xcrun` fallback. The selected canonical version participates in codegen and ThinLTO cache identities but not the frontend cache. An invalid value, or any value on a non-Apple target, is an error before source or artifact work.

The default is portable `baseline` plus `release`. `native` is for the current machine; a named LLVM CPU such as `x86-64-v3` is useful for a known deployment fleet. Runtime LTO is **on by default** under the optimizing `release`/`fast` profiles (measured 2-3× on string-predicate pipelines, non-regressing elsewhere, +1-2ms compile) and off under `dev`/`small`/`tiny`; `--no-rt-lto` / `--rt-lto` force either direction. `--thin-lto` stays explicit because it changes compile cost and optimization scope; it requires `release` or `fast`, applies to linked `build`/`run`/`size` operations, is parallel and cached, and composes with runtime LTO.
The default is portable `baseline` plus `release`. `native` is for the current machine; a named LLVM CPU such as `x86-64-v3` is useful for a known deployment fleet. Runtime LTO is **on by default** under the optimizing `release`/`fast` profiles and off under `dev`/`small`/`tiny`; `--no-rt-lto` / `--rt-lto` force either direction. `--thin-lto` stays explicit because it changes compile cost and optimization scope; it requires `release` or `fast`, applies to linked `build`/`run`/`size` operations, is parallel and cached, and composes with runtime LTO.

Runtime LTO lets the optimizer see runtime helper bodies, which can remove calls from
string predicates. The [recorded benchmark](../../bench/rt_lto/README.md) shows
one workload and target; measure your program with both settings before drawing
a performance conclusion.

For a representative production workload, instrumented PGO is available:

Expand Down Expand Up @@ -245,8 +250,8 @@ spliced in, the whole program is recompiled through the same driver calls `align
the resulting native binary is executed. Behavior is therefore identical to a production compile —
the same profile, the same `rt-lto` default, the same object.

Re-execution is the model, not an implementation detail. Because the whole program runs again every
entry, output you have already seen is elided and only the new lines are printed:
The whole program runs again after every entry, including earlier I/O and other side
effects. The REPL hides unchanged output and prints only the new lines:

```text
align> x := 5
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/17-the-align-way.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Consider `soa<T>` when a hot path scans a few fields across many records. `to_so

## Choose between data parallelism and tasks

Use `par_map` when the per-element function is expensive; measure first, because a vectorized sequential pipeline may be faster. Its callable must be Pure and cannot mutate captured backing. Use `task_group`/`spawn` for independent jobs, including I/O: tasks may be Impure, but captured views remain read-only until the group joins. Use `wait()` when no task returns `Result`, and `wait()?` to propagate fallible task results. After a successful wait, `.get()` reads a Copy result or transfers an owned result once. Both forms enforce capture and lifetime rules without user-written purity annotations.
Use `par_map` when the per-element function is expensive; measure first, because a vectorized sequential pipeline may be faster. Its callable must be Pure and cannot mutate captured backing. Use `task_group`/`spawn` for independent jobs, including I/O: tasks may be Impure, but captured views remain read-only until the group joins. Use `wait()` when no task returns `Result`, and `wait()?` to propagate fallible task results. A literal `spawn(fn { ... })` can move self-contained inputs, including buffers and files, into a task; ordinary stored closures and pipeline lambdas still capture Copy values only. After a successful wait, `.get()` reads a Copy result or transfers an owned result once. Both forms enforce capture and lifetime rules without user-written purity annotations.

## Let the compiler see the shape

Expand Down
Loading
Loading