Menu

Native buffers — design

Why SplFixedArray and Manticore\Ds\* sit on a malloc'd fixed-width block instead of a PHP array, and how the compiler inlines access to it. Layout lives in memory-abi.md §7c; user guide in ../ds.md; this doc is the reasoning and the wiring.

Status#

Shipped, ABI v18 (MemoryAbi::VERSION, BUF_* block, descriptor json_fn@56, kind 12 U64). In the v0.13.0 release. Tracking: #92 (epic), #101 (follow-ups). Open items are at the end.

Why#

A PHP array is a hash/vector of tagged cells: 8+ bytes per element plus bucket and tag traffic, for data that is homogeneous and numeric (offsets, token kinds, pixels, bit sets). Two costs: memory (an int32 costs a cell) and speed (every read decodes a tag; every store can COW or grow). The old SplFixedArray prelude class kept its elements in a PHP array property, so it paid all of that AND inherited every array ownership rule (offsetGet +1, element-borrowed property overwrite, array_slice in setSize — each leaked at some point; php-cs-fixer Tokens was the driver).

A native buffer stores elements at their declared width in one contiguous block, reached through an int. Superset feature: Zend has no such classes (docs/ds.md polyfill section).

Handle model#

Kinds#

BUF_KIND_*: I8 1, I16 2, I32 3, I64 4, U8 5, U16 6, U32 7, F32 8, F64 9, BIT 10, CELL 11, U64 12. Width per kind: __mir_nbuf_w / __mir_nbuf_bytes.

Three layers (and the bootstrap rule)#

Each op exists three times; the pairing is the AGENTS.md BOOTSTRAP RULE applied to a builtin family.

  1. PHP twins — src/Runtime/Stdlib/Buf.php: __mc_nbuf_* as plain PHP over one registry of PHP lists (__mc_nbuf_op($op,$h,$a,$b,$c,$v)). The compiler one generation behind has never heard of the builtins and links these; under Zend they ARE the storage of the manticorephp/ds polyfill (tools/ds_polyfill.php), so the polyfill is the oracle of the native classes by construction. Rules: pure PHP, no pack/unpack (a stdlib function must not call a prelude one) — the ByteBuffer float codecs are hand-written IEEE arithmetic.
  2. Codegen builtins — EmitLlvmBuiltins::biNbuf dispatches any __mc_nbuf_<op> whose arity matches RuntimeLibrary::nbufSig($op); the bodies are IR in RuntimeLibrary::nbuf() / nbufReduce() (per-kind generated sum/min/max/equals) and shadow the twins (emitCall asks emitBuiltin before definedFns). Adding an op = IR + nbufSig entry + twin in Buf.php; the wiring is generic by name.
  3. Inline access — NbufInline + EmitLlvmArrays::emitNbufGet / emitNbufSet / emitNbufAppend: $a[$i], $a[$i] = $v, $a[] = $v on a Manticore\Ds typed array are emitted in place (below). Everything else is a call into layer 2 from the PHP class body.

The name prefix is __mc_nbuf_*; __mc_buf_* collided with Io.php.

Inline access#

NbufInline is the single owner of the predicate; two passes must agree on it.

Shape (nbufProbe): load handle from the object slot, load len, idx <u len; fast arm loads at the kind's LLVM type from handle + BUF_DATA_OFFSET; slow arm calls the real offsetGet / offsetSet (which throws OutOfBoundsException or ValueError) and ends in llvm.trap; unreachable for raw kinds — the slow arm never returns normally, which lets LLVM LICM hoist handle and len out of a loop. Loads and stores carry !tbaa (!3 header = handle slot + len, !4 element; rt->needsNbufTbaa makes emitPreamble emit nodes !0..!4), so element stores do not force a handle/len reload. A null handle reads length 0.

Stores additionally require nbufValueFits (static) and nbufRangeTest (dynamic): a value outside the element range falls to the slow arm. emitNbufAppend inlines []= while len < cap (slack is zero, so it is a store + length bump) and otherwise calls push().

SplFixedArray is not covered by NbufInline: its reads/stores go through the PHP class (offsetGet / offsetSet) and the earlier in-place fast paths for __data/__mcbuf.

Narrowing = ValueError, never silent#

User rule (2026-10-07): a value that does not fit is an error. Enforced in the PHP layer (prelude/ds.php): IntTypedArray::coerce / inRange throw ValueError('Value N is out of range for X (min..max)'); a wrong type is TypeError; floats must be finite and integral; numeric strings are accepted when exact; out-of-i64 floats are a ValueError. The inline path range-tests and defers to the same PHP method on failure, so inline and slow arms agree. UInt64Array accepts 0..2^64-1. Index errors are OutOfBoundsException (SplFixedArray-faithful idx rules in idx()). The runtime ops themselves (set_i) truncate; they are not a user surface.

Classes on top#

#[TypeDef] element types#

UInt16Array<Kind>: elements are read as a #[TypeDef] class erased to its scalar (no allocation). Mechanics: typed arrays are generic (@template T, @extends, @return T); LowerTypes::resolveClassHint / bindsSameClass, LowerReify (keys a TypeDef by class, not carrier) and InferNodes ($o[$k] via genericReturnType) do the typing. A @var X<T> + new X pair reifies a class copy (X__of__t_Kind); hint/property/param bindings stay erased plus type args. Width check: LowerTypeDefs::checkNbufBinding (called from the generic branch of LowerTypes) refuses a binding unless every value of the TypeDef's repr fits the buffer kind — NbufInline::holds (signed never fits unsigned; unsigned fits a strictly wider signed; float only same-or-wider float). Analyzer case: ds_element_width.

JSON / serialize / dump#

Typed arrays are JsonSerializable, __serialize, __debugInfo — all via toArray(). json_encode of any class with jsonSerialize() (these included) calls through the class descriptor's json_fn@56 slot, which points at a synthesized __mc_jsonser_<id> helper (LowerPrelude::jsonSerSrc, RuntimeLibrary::jsonSerFn). The compiler-owned slot is hidden from every property view. UInt64Array shows raw bits in toArray (see open items).

Contributor invariants#

Open items#

Edit this page on GitHub