Fallible memory, parallel execution, and SIMD
Status: Partly implemented; retained for B13 allocator design. Originally researched after
b690afeon 2026-10-05 against Cranelift0.131.1; that backend evidence is historical, not the current dependency. The design sketches below are not current API documentation.
- Recoverable allocation: implemented. Allocating operations expose allocation errors through
Result, and?propagates; see allocation and traps. Public allocator selection, budgets, and bounded containers remain B13.- Parallel execution: implemented. See concurrency. Automatic parallelization is B24.
- SIMD: initial subsets implemented. Explicit
std.simdsupplies four numeric vector shapes, masks, scalar fallback and bounded native lowering. Integer-column filter/count has automatic SIMD. Broader shapes, packed views and transformations remain B23.This page remains the B13 design record. The newer portable
std.simdproposal refines B23 with an API contract, automatic pipeline SIMD and measured backend evidence. The completed allocation audit and native concurrency contract record the delivered subsets. Checked-index arenas solve node identity; they are distinct from the allocator-lifetime proposal here.Shipped differently:
AllocErrorhas two variants,OutOfMemoryandCapacityOverflow. Class, tuple, enum, generator, and range storage is inline and needs no allocation; component expressions and fallible class initializers retain their own errors..unwrap()exists;.expect(...)does not. Only object headers record an allocator; container buffers use the global one. Threads start through a scopedwithmanager, and a pool isThreadPoolExecutor(max_workers, queue_capacity)withmap,map_result, andreduce_tree.
Current delivery and remaining design
The original recoverable-allocation foundation and explicit native concurrency have shipped. B23’s delivery record describes the initial explicit and automatic SIMD subsets. Generic collections are not packed numeric arrays, and ordinary loops remain serial.
Public allocator switching, per-container allocator handles, allocator lifetimes, and buffer provenance remain unimplemented. Objects and container buffers use the global allocator; objects no longer carry an allocator prefix (issue #814), so allocator selection must record provenance without enlarging global objects. Fixed inline arrays and constant value parameters are separately accepted but unimplemented under B83; bounded containers build on that design. These remaining contracts keep this proposal partly implemented. Scope and priorities belong in B13/B23/B24/B83.
The allocation, concurrency and SIMD sections preserve the original rationale and alternatives. Where they describe a shipped area, the linked references and completed audits take precedence over their historical sketches.
Original recommendation
Make recoverable allocation failure a language contract, with Result and ?
as the normal way to handle it. This requires more than making collection growth
fallible: error values, metadata, cleanup, and runtime helpers must also work
without an allocation succeeding. Establish this foundation before advertising
OOM recovery, custom allocators, or embedded suitability.
Keep the public model simple: one str, ordinary collections using a configured
default allocator, and explicit constructors accepting an allocator when needed.
Start parallelism with owned tasks, bounded channels, and scoped borrowing; add
explicit parallel collection operations after those are sound. Start SIMD with
a small set of explicit fixed-width vectors. Neither automatic parallelization
nor a general auto-vectorizer should be necessary for the basic language.
Historical implementation audit
This snapshot predates fallible allocation, inline sums/ranges/generators, static type metadata, and atomic ownership counts. It explains the original proposal’s motivation, not current behavior. See the implementation status and native concurrency audit for the current contracts.
| Area | Original behavior and implication |
|---|---|
| Raw object allocation | plenty-runtime/src/memory.rs::allocate calls alloc_zeroed, then handle_alloc_error on null; layout overflow exits through fail. |
| Collection growth | aggregates.rs::insert uses try_reserve for entries, but turns failure into process termination. Hash-table rebuilding uses an infallible vec! allocation. |
| Type metadata | Descriptors are parsed into String, Vec, and Rc<Type> at runtime. Even constructing a small collection can allocate metadata. |
| Classes and enum values | Records have owned heap allocations. Standard Result and Option use enum machinery; constructing an error is not an allocation-free escape hatch. |
| Text and I/O | Dynamic text, input buffering, formatting, and collection rendering allocate. String literals are immortal compiler data. |
| Indexing and ranges | String indexing/iteration creates a new managed one-scalar string; constructing a range allocates metadata and a managed range object. |
| Structural equality | aggregates.rs::equal memoizes aggregate pairs in a Rust HashSet, avoiding exponential work on shared graphs whose floating-point contents prevent identity shortcuts. Equality and membership can therefore allocate internally. |
| Generators | Creating the frame allocates; suspended values own resources. Returning Option from iteration also uses enum machinery. |
| Destruction | An intrusive thread-local queue avoids allocating a separate worklist while traversing owned object graphs. User __del__ code can still allocate. |
| Sharing | Object reference counts are non-atomic u64; metadata uses non-atomic Rc. Moving a collection does not prove its reachable immutable strings are unshared. |
Rust does have fallible APIs: Vec::try_reserve preserves existing contents on
failure. The familiar infallible APIs choose a different policy, and the standard
allocation-error handler normally aborts with std. Plenty can expose a
different policy while using Rust underneath, but must select or implement
fallible operations consistently. Rust Vec,
allocation error handling.
Recoverable allocation contract
This is the original design rationale. The
completed allocation audit records the delivered
per-path behavior, including inline classes, sums, ranges and generator frames.
The third error category, fallible generator-frame construction and heap-backed
class construction discussed here were not adopted; .expect(...) remains
unimplemented. None is an additional implementation commitment.
Allocation-free error transport comes first
Introduce a small, non-owning AllocError value distinguishing exhaustion,
capacity overflow, and an unsupported allocation layout. Its data should be
fixed-size integers/tags, not an allocated message or backtrace. It can be
formatted later if resources permit; an emergency reporting path writes a
static message directly without growing a buffer.
Represent ordinary finite Result[T, E] and Option[T] values without a new heap
allocation for their wrapper: use an inline tag/payload layout, register tuples,
or a caller-provided result slot according to a private Plenty ABI. Moving an
already-created payload into Ok or Err must not allocate. This is a concrete
reason to generalize the current single-word managed-value representation.
Recursive values still need explicit indirection; neither inline errors nor
stack placement imply arbitrary recursive types have a finite layout.
Implementing ? before this representation change is useful for normal errors.
It must not be described as OOM-safe while its generated error path still creates
heap-backed enum objects. Compiler-emitted immutable type metadata is the next
useful change: replacing runtime descriptor parsing removes hidden allocation
and a future cross-thread Rc hazard at the same time.
Which operations return Result?
Recommend fallible public signatures for operations whose contract permits allocation, even on calls that happen to fit existing capacity. Their type must not change with optimization level, input size, or whether the compiler managed to place a particular object on the stack.
For example, a future items.append(value)? propagates AllocError, and a future
copy(value)? can fail when duplicating owned data. Explicit .expect(...)-style
termination may be offered for applications choosing that policy; it is not a
silent default selected by release builds. A terminal failure is distinct from a
recoverable allocation error and does not promise user cleanup.
There is a syntax decision to make before changing existing programs: allocating
literals, comprehensions, string concatenation, heap-backed class construction,
and generator creation have no explicit fallible method today. A consistent
candidate is for these expressions to produce a Result, with explicit ? at
the use site, including ([f(x) for x in source])?. This makes allocation visible
but changes many familiar expressions and needs learner examples before adoption.
An alternative is a narrowly specified allocation effect with an explicit
handler boundary; it adds more language machinery and should not be quietly
introduced as implicit ? insertion. The Result with explicit ? form was
chosen.
The required behavior is clearer than the syntax: a program must be able to
handle every language-runtime allocation failure without an unadvertised abort.
Audit these paths together, not just list.append:
| Allocation site | Required recovery behavior |
|---|---|
| Lists, dicts, sets | Reserve entries and hash storage before committing a mutation; report checked-size overflow separately from exhaustion. |
| Comprehensions | Destroy the partial result and pending owned temporaries on failure. Already-completed I/O or other external side effects are not rolled back. |
| Strings | Concatenation, formatting, conversion, slicing that copies, and input buffering expose failure; literals and merely retaining existing immutable storage need no allocation. |
| String indexing and ranges | Remove their internal allocation through a suitable representation, or expose failure even though these operations currently look like simple value expressions. |
| Equality and membership | Replace allocating memoization with an allocation-free strategy, or make its failure explicit. A bool result must not conceal an abort when temporary comparison storage is exhausted. |
| Classes and enum payloads | Clean up only successfully initialized fields if construction fails. No call to a whole-object custom destructor on a partially initialized object. |
copy | Destroy a partially built deep copy; retain the original unchanged. Resource objects with custom cleanup keep their existing copy restrictions. |
| Generators | Frame creation is fallible. Allocation errors in the body need an explicit yielded Result or a designed fallible-iteration contract, not an indistinguishable end-of-stream. |
| Metadata and dispatch | Prefer emitted static tables. Any remaining lazy caches or adapter allocation must return errors or be preallocated explicitly. |
| Printing and diagnostics | Stream through a nonallocating writer or propagate allocation/I/O errors. Formatting an error must not recursively allocate another error. |
| Scheduling and channels | Pool creation, task records, queue storage, thread stacks, and message construction have distinct fallible creation/submission contracts. |
| FFI | An external allocator/library can still abort; an imported declaration must not claim recoverable failure unless its implementation provides it. |
Mutation, moved inputs, and cleanup on failure
Guarantee the strong property for allocator-aware collection mutation: if reserve, rehash, or allocation fails, the existing collection’s logical contents remain unchanged. Capacity may have increased during preparatory work. A replace operation must not destroy the old value until all required allocation succeeds. Allocator failure must leave the original allocation valid on failed resize.
Recommend a simple initial ownership policy: an ordinary owning append consumes
its argument on the call, including failure, and destroys it if it was not stored.
The caller cannot conditionally reuse the moved binding after observing Err.
Document this prominently. A later recovering variant can return the uninserted
item together with AllocError, but that composite error must itself have an
allocation-free representation. Another explicit workflow is reserve first, then
move the value into already available capacity. Do not hide input cloning in the
error path.
Destruction itself must not allocate. The existing intrusive queue is a useful
starting point, not a complete proof: custom destructors, allocator callbacks,
formatting used by cleanup, and cleanup of partially initialized values all need
auditing. A destructor has no caller to receive Result; keep __del__ and scope
exit hooks infallible and make fallible close/flush an explicit operation before
drop. OOM-safe cleanup must handle any fallible work locally and continue releasing
owned resources. A later compiler-checked noalloc effect could verify critical
cleanup and request-rejection paths; initially keep this a documented runtime
contract with tests, not an unimplemented guarantee. See the companion
context-manager proposal.
The runtime needs a failure-injection allocator: fail allocation number N for every N in a test, then verify unchanged source values, balanced resources, partially initialized cleanup, and no secondary allocation on the failure path. Include failures during hash growth, nested copies, generators, output, and user cleanup. Success-only accounting and sanitizer tests cannot establish these failure guarantees.
What recovery can and cannot guarantee
An allocator reports only failures it can observe. On an overcommitting OS,
successful reservation can be followed by process termination when pages are
touched; a Result cannot intercept the kernel killing the process. Bounded
allocators and request budgets make admission control more useful; reserved and
committed-memory policy remains platform-specific. Linux explicitly documents
multiple overcommit policies. Linux overcommit accounting.
Compiler OOM, process startup, stack overflow, and third-party C libraries are separate limits. Do not advertise survival of arbitrary system-wide exhaustion. For services, the practical guarantee is narrower and useful: reject work when a controlled allocator cannot satisfy a request, clean up, and continue using previously reserved service resources. An application needs a preallocated path for that rejection response too.
Custom allocators without multiplying everyday types
The user’s proposed separation is viable. Prefer one ordinary constructor with
an optional allocator argument over unrelated list_custom/dict_custom names.
Exact syntax depends on generics, but conceptually list[T](allocator=pool) uses
the same operations as list[T](); literals and comprehensions use the configured
default. An explicit collection builder can later direct a comprehension into a
particular allocator without introducing ambient thread-local allocation scope.
Use a small runtime allocator handle containing a context pointer and operations for allocate, grow/resize, and deallocate. The contract includes alignment, checked sizes, thread eligibility, failure behavior, and ownership of the handle. This is compatible with statically known adapters for user classes; it does not require every ordinary collection type to expose an allocator generic parameter. The tradeoff is a stored handle and indirect allocation calls. Specialized allocator-generic collections can remain a later optimization.
Rust’s per-object Allocator contract is useful prior art, including matching
deallocation and allocator lifetime, but the inspected stable documentation still
marks it an unstable API. Plenty can implement its own stable runtime interface
without exposing Rust’s allocator ABI. Rust’s GlobalAlloc also permits an
implementation that aborts instead of returning null: a Plenty allocator
advertised as recoverable must impose the stronger return-failure rule.
Rust Allocator,
Rust GlobalAlloc.
The difficult part is lifetime and provenance, not constructor spelling:
- Each object and internal buffer remembers the allocator that created it, and frees through that allocator even if the default later changes. Growth stays with that allocator. Start with default selection fixed at startup to avoid unnecessary ambient state and initialization races.
- The allocator’s context must outlive every live allocation and destructor using it. Start with process-lifetime or explicitly retained allocator handles. Handle creation is fallible and its own allocation must not form an ownership cycle.
- A scoped arena is a borrow-checking feature: forbid reset/destruction while any arena-backed value or reference can survive. Deferring resettable arenas is better than using a runtime pointer that silently dangles after a scope ends.
- An arena does not make resource cleanup optional. Destructors of files and other resources still run; bulk byte reclamation cannot replace them.
- Inserting an existing string or nested list into a pool-backed list does not
migrate its storage. The outer buffer uses the pool; each child retains its own
allocator. A future explicit
copy_into(pool)must define which reachable storage it duplicates and may fail partway through. copyshould default to the source allocator; changing allocators is explicit. No pointer allocated in a DLL may be passed to another DLL’s unrelated free function. FFI ownership contracts need the matching release operation and its library lifetime; Plenty’s allocator handle remains a private representation.- Thread-confined allocators make their values thread-confined. Merely making a reference count atomic cannot make an allocator or its destructor thread-safe.
Supporting arbitrary allocator callbacks is itself a trusted low-level extension; the compiler cannot prove that foreign callbacks honor alignment and lifetime. Built-in budget/pool allocators should be the first supported implementations.
Fixed capacity and inline storage
The ideas in heapless and smallvec solve different problems. heapless
containers store fixed-capacity backing storage inline and report capacity
exhaustion; SmallVec stores a small prefix inline and spills to the heap when
needed. Inline means inside the containing value, not necessarily on the stack.
heapless,
SmallVec.
The original sketches were Array[T, N], BoundedList[T, N], and
SmallList[T, N], with constant generic capacities. The accepted
value-generics design now specifies array[T, N], but is
not implemented; the bounded and small-list names here remain illustrative.
A bounded container returns a capacity error without allocating; a small list
still needs fallible spill/growth. Element construction can allocate even when
the container does not. A borrowed slice/view should let APIs accept their
contents without copying into the ordinary heap-backed list. Defer this family
until basic constant parameters exist; inline aggregates have shipped. It should
not complicate the first custom-allocator interface.
Threads, channels, and parallel collections
This section preserves the original concurrency design. Scoped threads, bounded channels, executors, ordered mapping and fixed-tree reduction are implemented; the concurrency reference owns current APIs, worker eligibility, ordering and shutdown contracts. The suggestions below are not additional pending concurrency work.
Runtime prerequisites
The native concurrency audit records the current runtime and boundary contract. Type metadata is static and immutable, and object counts now use atomic ownership handoff, including immutable strings: a list move may leave a string alias in the originating thread. Atomic ownership alone does not make a mutable payload safe to share or grant foreign thread permissions.
Retain static immutable type metadata and use atomic reference counts for storage
that can be shared between threads, including the single public str type.
Benchmark the cost before choosing whether all headers should use the same atomic
implementation or whether internal unshared objects can keep a cheaper path.
Either choice preserves one user-visible str type. Revisit the destructor queue
algorithm when changing the count field: zero-to-destruction handoff, memory
ordering, and recycling the dead count as a link need a coherent atomic design.
Define compiler-known structural properties for transferable ownership and shareable immutable borrows. These are safety facts, not protocols users can satisfy merely by writing a method with the right name. Ordinary methods remain explicitly resolved; importing a module must not enable extra thread eligibility. Check all captured values recursively, including allocator handles, generator frames, destructor requirements, and FFI handles with thread affinity.
Initial user model
- Owned tasks move captures and results. Thread creation returns
Result, and join is explicit; no silent detached thread holding resources beyondmain. - Scoped tasks may borrow until the scope joins every task on every exit path,
including
?. Shared borrows need thread-shareable values; mutable borrows require disjoint storage proven by the checker. Scoped threads are established prior art in Rust’sthread::scope. - Bounded channels own preallocated queue storage. Sending transfers ownership; failed or disconnected send returns the undelivered value. Creation and payload construction are fallible; preallocating the queue does not eliminate allocations inside payloads. Specify closure, blocking, and shutdown behavior before adding unbounded channels.
- A configurable thread pool supports explicit parallel map/collect/reduce. Keep the executor API replaceable: the language need not promise Rayon as a dependency, and any chosen executor must meet the allocation contract.
Rayon demonstrates explicit parallel iterators and configurable pools, including
num_threads; its parallel fallible traversal may observe multiple errors and
does not specify which one is returned. These are useful comparisons, not a
reason to inherit unspecified language behavior accidentally.
Rayon pool configuration,
Rayon fallible traversal.
For an initial Plenty parallel collection operation, preserve input order in the successful result. If multiple items fail, returning the failure at the lowest input index is easier to reproduce; it requires completing earlier in-flight work before reporting it. Cleanup drops all partial outputs and joins started tasks. It cannot undo I/O already performed by workers. A simpler first-completed error mode is an alternative, but must be explicitly documented if selected. Dictionary/set construction must still resolve duplicates using source order. The order of destructor side effects across threads is not the serial iteration order and must not be promised accidentally.
Cancellation is cooperative and happens at defined boundaries, not by killing
threads during mutation or drop. Restrict break/continue in higher-level
parallel loop forms until their semantics are specified. Floating-point
reductions need an explicit order policy: reassociation changes results; a
parallel sum cannot silently claim ordinary left-to-right arithmetic semantics.
Automatic parallelization is deferred. Borrow independence alone does not prove equivalence: allocation failures, I/O, externally visible destructor order, floating-point reductions, and first-error ordering are observable. Start with explicit opt-in operations, measure workloads, then consider an effect analysis for a small deterministic subset. A runtime thread-count setting is reasonable for these operations, but should not silently change ordinary loop semantics.
SIMD that fits Cranelift
This is the original backend assessment, not an outstanding initial SIMD plan. The explicit implementation record describes the shipped API and representation limits; the portable SIMD proposal separates delivered subsets from broader transformations. Current values use copyable inline enums, with bounded local promotion to vector registers, rather than a public packed-vector layout or vector C ABI. The initial automatic integer-column filter/count slice is separate from general auto-vectorization.
Cranelift represents lane-typed vectors and provides vector arithmetic, but an IR type’s existence does not promise every instruction or width works on every backend. The upstream IR guide explicitly discusses target-dependent support. Cranelift IR, instruction builder.
The installed cranelift-codegen 0.131.1 source was checked directly:
src/ir/types.rs tests cover I32X4 and F64X2; src/isa/x64/lower.isle includes
fadd rules for F32X4/F64X2; src/isa/aarch64/lower.isle lowers vector fadd.
This is evidence for a small 128-bit starting set, not a blanket claim about
256/512-bit vectors, scalable vectors, arbitrary lane counts, or all operations.
The docs.rs pages for that exact crate version were unavailable to the research
tool, so the source inspection, rather than latest-version docs, establishes the
version-specific finding.
Propose explicitly imported vector types/functions first: 128-bit shapes such as
four f32 lanes or two f64 lanes, with splat, checked load/store, arithmetic,
comparisons, masks, and lane extraction. Names such as simd.f32x4 are sketches.
Represent vectors inline in SSA/registers or stack slots; do not heap-box each
vector as general managed objects were boxed at the time of this assessment.
Efficient vectorized kernels
also need contiguous typed buffers/views: the current generic collection entry
storage is not a guarantee of a contiguous C array of lane values.
Specify exact integer overflow behavior, mask representation, lane order, alignment, bounds, and floating-point behavior. Scalar fallback must preserve those semantics, not merely produce approximately similar answers. Unaligned loads are a useful safe default where supported; never read past a slice end to fill a vector, even if discarded lanes appear unused. Keep scalar tails explicit at first. SIMD reduction order and NaNs must follow the documented operation; relaxed reassociation and fused arithmetic require opt-in semantics.
Compile a documented baseline target with scalar fallback for unsupported operations. Offer explicit native-target compilation separately, recording the required CPU features; shipping native-target binaries to an older CPU can fail. Runtime feature dispatch and multiple compiled variants are later library/compiler features with code-size and compile-time costs. SIMD vectors should not cross the initial public C ABI by value: use pointers and lengths until each target’s ABI and alignment rules are deliberately supported.
Before adopting a vector operation, test native and scalar implementations on boundaries, NaNs/infinities, overflow, short/unaligned buffers, and each supported target. Inspect generated code and measure throughput and compilation time. Explicit vector lowering is compatible with Plenty’s fast-compilation goal; global loop dependence analysis and speculative vectorization are not required.
Original implementation sequence
This sequence records the original dependencies, not the current work queue. Delivery status is reconciled below it; remaining scope lives in the backlog.
- Add ordinary
?and clarify cleanup on propagated errors. Explicitly document that current heap-backedResultis not yet an OOM-recovery mechanism. - Design allocation-free finite sum/aggregate layouts and emitted static type metadata; measure compile time and ABI complexity before wider runtime changes.
- Introduce a fallible allocation ABI and failure-injection tests, then migrate every runtime allocation path. Settle allocating-expression syntax with small tutorial examples before changing all public APIs.
- Add a configured default allocator and explicit per-container allocator handles, starting with a bounded budget allocator. Keep scoped resettable arenas deferred until lifetimes can be enforced.
- Add contiguous buffers/views and bounded inline containers as generic/value support permits. These also establish a practical foundation for SIMD.
- Audit thread safety and add transfer/share checking, atomic shared ownership, owned/scoped tasks, and bounded channels. Then explicit pool-based parallel collection operations with documented ordering and failure behavior.
- Add the narrow tested SIMD surface. Automatic parallelization, auto-vectorization, allocator specialization, and runtime CPU multiversioning remain optional later work rather than prerequisites for useful programs.
Steps 1-3 and 6 are complete; their descriptions of the then-current runtime are historical. Step 4 remains B13. Step 5 is mixed: inline aggregates, list/byte slice views and packed columnar storage have shipped, while fixed inline arrays and value parameters remain B83 and bounded containers remain B13. Step 7’s initial explicit vectors and bounded automatic integer-column SIMD have shipped; B23 tracks extensions, not an unimplemented SIMD foundation.
Allocating-expression syntax, the private sum-value lowering contract, and task error ordering are settled. Existing collection operations also define ownership on failure; see the collection reference. Any future recovering-insertion API would be a separate design, not an unresolved contract for today’s operations. Allocator handle lifetimes and buffer routing remain open B13 design questions. Published SIMD operations already have scalar contracts; broader operations require separate decisions in the portable SIMD design.
The private lowering contract is not a frozen ABI or a public C representation; it must leave room for future FFI, vector values, target-specific calling conventions, and further representation improvements.