20 Allocators and fixed memory
What to know first
via lets a type argument decide allocation effectsLooking back
In chapter 18, what was rejected when building for a machine without an operating system, and what could still be used?
A. The heap effect, cap heap inputs and region … heap blocks were all rejected with E-HEAP-NOHOST. alloc, which carves from the fixed window that does not grow, could still be used. This chapter covers the allocators that hand out space on top of that fixed window, and on top of bytes someone else lent.
The need for this chapter, and its context
By the end of this chapter
use clause) and hand it over from the caller with use, and the default allocators that carve straight from a root, with the rule for capability fields. You will also see how the linker sets the size of the fixed window on machines without an operating system, and bit_cast, which keeps the bits and changes only how they are read.The questions this chapter answers
- Can a bump allocator take back one piece?
20.1 The three parts of an allocator#
| Layer | When it exists | What it is |
|---|---|---|
| Capability | only at translation | May it touch the root? — cap allocator · cap heap |
| Policy | at translation (a type) | By what rule does it carve? — a type satisfying the byte_allocator trait |
| State | at run time | The cursor and backing bytes — a value of that type (an actor) |
Table 20.1 — The three parts of an allocator
The byte_allocator trait of the standard library’s allocs requires three ops. reserve n cuts off n bytes and gives an option, grow enlarges the last piece in place, and used answers how much has been used. An actor (chapter 25) is an object that holds its own state and is spoken to only with send. Here it is enough to know that it is made with spawn actor and called with send a reserve 3.
20.2 Cutting borrowed bytes#
The simplest allocator is a bump allocator. It keeps one cursor, cuts off as much as requested, and pushes the cursor forward.
buf (8 bytes)
[ a a a │ b b │ · · · ]
└ the piece reserve 3 gave
└ the piece reserve 2 gave
▲ cursor (used = 5) --- the next reserve cuts from here
asking reserve 4: only 3 bytes are left → none (running short is a value too)examples/ch20/borrowed.low
module borrowed .
rem run: carve [0,0,0,0,0,0,0,0]
use allocs .
proc carve input buf mut slice u8 . output u64 . effects state .
do
var a allocs.bump_bytes spawn actor allocs.bump_bytes . .
let room usize send a. init buf. . .
let p option mut slice u8 send a. reserve 3 . .
guard is_some p. . else return 91 . .
let pv mut slice u8 some_value p. . .
set idx pv. 0 . 65 .
let q option mut slice u8 send a. reserve 99 . .
guard is_none q. . else return 92 . .
return cast u64 send a. used . . .
end .
Output
$ lowentc --run carve borrowed.low [0,0,0,0,0,0,0,0]
carve([65,0,0,0,0,0,0,0]) = 3
arg0 (written) = [65,0,0,0,0,0,0,0]
spawn actor allocs.bump_bytesmakes the allocator, andsend a init bufhands it the bytes to cut. This allocator cannot make memory by itself — the discipline of never allocating secretly.send a reserve 3cuts off 3 bytes. What comes back is not a copy but a slice pointing at part of the original, soset idx pv. 0 . 65 .changes the first byte of the caller’sbuf. The argument[65,0,…]the VM shows is the trace.send a reserve 99givesnonebecause there is not enough space. Not a trap. Running out of memory is a value, and the caller checks it.
This op’s head has neither cap allocator nor alloc; its effect is only state. The caller lent the bytes, and the allocator merely hands them out. The capability draws the line — code without an allocation capability can still allocate fully on bytes someone gave it.
Q. Can a bump allocator take back one piece?
A. It takes back only the last piece (release, trait freeing_allocator). For any other piece it answers false and changes nothing. Pretending to accept an unknown piece would let a double return erase someone else’s space. If the shape is taking and releasing over and over, pool with generational handles is the right tool (chapter 35).
20.3 Swapping allocators#
Code that uses an allocator need not know which implementation it is. It takes the allocator’s type as a translation-time parameter and its value with a use clause.
examples/ch20/generic.low
module generic .
rem run: with_bump [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
rem run: with_aligned [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
use allocs .
proc two_from
input comptime a type .
use al a .
output u64 .
effects state via a .
requires allocs.byte_allocator a. . .
do
let p option mut slice u8 send al. reserve 3 . .
guard is_some p. . else return 91 . .
let q option mut slice u8 send al. reserve 5 . .
guard is_some q. . else return 92 . .
return cast u64 send al. used . . .
end .
proc with_bump input buf mut slice u8 . output u64 . effects state .
do
var b allocs.bump_bytes spawn actor allocs.bump_bytes . .
let c usize send b. init buf. . .
let n use b. u64 two_from. .
return n. .
end .
proc with_aligned input buf mut slice u8 . output u64 . effects state .
do
var b allocs.bump_aligned spawn actor allocs.bump_aligned . .
let c usize send b. init buf. . .
let n use b. u64 two_from. .
return n. .
end .
Output
$ lowentc --run with_bump generic.low [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
with_bump([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]) = 8
arg0 (written) = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
$ lowentc --run with_aligned generic.low [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
with_aligned([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]) = 13
arg0 (written) = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
input comptime a type .is the allocator’s type, fixed to a concrete type at translation (chapter 22).use al a .receives the allocator value of that type under the nameal.useis not an input. The caller does not write it in an argument position but in the binding, aslet n use b. u64 two_from. ..requires allocs.byte_allocator a. . .is the condition thatasatisfies the trait (chapter 23).effects state via a .means the effects of the allocator’sreserveare this op’s effects.
Given bump_bytes, the same two_from uses 3 + 5 = 8; given bump_aligned, which aligns start positions to multiples of 8, the second piece starts at 8 and it uses 13. Because the type is fixed at translation, there is no virtual function table and no indirect call. Swapping costs nothing at run time.
The receiving side and the choosing side use the same word. In an op’s head, use al a . says “this op carves from al”; in a binding, let n use b. … says “this call, from b”. The use allocs . that imports a module is told apart by where it stands — an import stands outside declarations, this clause stands between the op’s name and its do. An op writes one use clause (two is E-USING-DUP). The clause in the head has no give-back / keep distinction. keep stands only in a binding (see “Given back when the block ends” below).
If no source is written, a default is chosen: the binding’s use or keep, the op’s own use name, and otherwise the input or binding of matching type if there is only one. With two or more it does not guess and asks you to write it with E-ALLOC-AMBIGUOUS. Defaults do not cross op boundaries — an allocator from the caller never flows in on its own, so there is no global allocator.
| Order | Source | Why here |
|---|---|---|
| 1 | use <name> or keep <name> written on the binding | what you write always wins |
| 2 | the only name of fitting type among this op’s use clauses | the allocator the op said it takes |
| 3 | the only input or binding of fitting type in this op | one visible value leaves nothing to confuse |
| none | E-ALLOC-NOSOURCE | no global allocator fills the gap |
| two or more | E-ALLOC-AMBIGUOUS | no guessing; every candidate is named |
Table 20.2 — The order that picks an allocator source — the first match from the top
Fields are not counted. If an allocator hidden inside a struct were used silently, reading the code would not tell you which buffer shrinks.
With no source at all, the call is refused.
examples/ch20/nosource.low
module nosource .
rem expect: E-ALLOC-NOSOURCE
use allocs .
proc one_from
input comptime a type .
use al a .
output u64 .
effects state via a .
requires allocs.byte_allocator a. . .
do
let p option mut slice u8 send al. reserve 3 . .
guard is_some p. . else return 91 . .
return send al. used . .
end .
proc caller output u64 . effects state .
do
rem ✘ there is no allocator at all in this op to draw from
let n u64 one_from. .
return n. .
end .
Output
$ lowentc --check nosource.low
21:13 E-ALLOC-NOSOURCE: this call draws from an allocator, but NO value of a fitting type is visible in this op — no input, no binding, no `use` clause. There is no global allocator: take one as an input, create one here, or declare `use <name> <type> .` on this op (RFC-0112 D8(4))
nosource.low:19:1 W-EFFECT-OVER: this op DECLARES `state` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
caller has no input, no spawned actor and no use clause. In another language a global heap would quietly step in here. Lowent asks you to take an allocator as an input, create one here, or write a use clause. Because the call is refused, caller never really uses state either, so W-EFFECT-OVER comes along. It goes away once the first error is fixed.
The other way round, writing use on the binding of a call that draws from no allocator is refused too.
examples/ch20/usingunused.low
module usingunused .
rem expect: E-ALLOC-USING-UNUSED
use allocs .
fn twice input x u64 . output u64 . do
return mul x. 2 . .
end .
proc caller input buf mut slice u8 . output u64 . effects state .
do
var b allocs.bump_bytes spawn actor allocs.bump_bytes . .
let c usize send b. init buf. . .
rem ✘ twice draws from no allocator, yet the binding picks one with use
let n use b. u64 twice 4 . .
return n. .
end .
Output
$ lowentc --check usingunused.low
15:13 E-ALLOC-USING-UNUSED: this binding says which allocator to use, but the call it initialises does not draw from one (no `use` clause on that op). An object that already carries its allocator — like `vecgen.append` on a vector — does not take the caller's choice (RFC-0112 D8(5))
twice only doubles a number and takes no allocator. A choice that will never be used misleads the reader into thinking twice uses memory. So whatever you write must be used.
20.4 Default allocators that carve straight from a root#
allocs also provides two allocators that carve straight from a root, under the same trait.
| Actor | State | Effect of reserve |
|---|---|---|
fixed_bytes | root cap allocator . · amount used | alloc state — anywhere |
heap_bytes | root cap heap . · amount used | heap state — only with an operating system |
Table 20.3 — Default allocators
examples/ch20/fixed.low
module fixed .
rem run: main
use allocs .
proc main input al cap allocator . output u8 . effects alloc state .
do
var fb allocs.fixed_bytes spawn actor allocs.fixed_bytes . .
let a option mut slice u8 send fb. reserve 64 . .
guard is_some a. . else return 1 . .
let b option mut slice u8 send fb. reserve 64 . .
guard is_some b. . else return 2 . .
return cast u8 send fb. used . . .
end .
Output
$ lowentc --run main fixed.low
main() = 128
fixed_bytes’s state has a capability field. A capability is not a run-time value, so the field has no size. Instead a rule comes with it: an actor with a capability field can be spawned only from an op that holds a capability of the same kind.
examples/ch20/forge.low
module forge .
rem expect: E-CAP-FORGE
use allocs .
proc sneaky output u64 . effects heap state .
do
var hb allocs.heap_bytes spawn actor allocs.heap_bytes . .
return send hb. used . .
end .
Output
$ lowentc --check forge.low
forge.low:8:0 E-CAP-FORGE: this spawns an actor whose state HOLDS a capability (`cap allocator` / `cap heap`), but the op spawning it holds no capability of that kind. A capability field means nothing at run time — it is only real because the place that creates the actor already had the right. Without that, one `spawn` would forge authority out of thin air (RFC-0112 D6). Take the capability as an input of this op
forge.low:7:1 W-EFFECT-OVER: this op DECLARES `heap` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
forge.low:9:0 E-TYPE-NOMINAL: these are NOMINALLY DISTINCT types with the same representation — a `newtype` (or usize/idiff) is not interchangeable with its base or with another newtype. Convert explicitly with `cast <type> <value>` (RFC-0002 §6.3.1)
sneaky tried to spawn heap_bytes without receiving cap heap. If that were allowed, a heap could be conjured in one line where there is no capability. Capabilities are handed over, not picked up.
Receive the capability and the same thing works.
examples/ch20/heapbytes.low
module heapbytes .
rem run: main
use allocs .
proc main input h cap heap . output u8 . effects heap state .
do
var hb allocs.heap_bytes spawn actor allocs.heap_bytes . .
rem the heap hands out 100000 bytes, more than the fixed window (64 KiB by default) holds
let a option mut slice u8 send hb. reserve 100000 . .
guard is_some a. . else return 1 . .
rem return 2 if the amount used is not 100000, and 0 if it is
guard eq send hb. used . 100000 . else return 2 . .
return 0 .
end .
Output
$ lowentc --run main heapbytes.low
main() = 0
main receives cap heap and writes heap in its effects line. So it may spawn heap_bytes, and it gets 100000 bytes, more than the fixed window holds. When the heap runs short it chains another chunk. Built for a machine without an operating system, this file is refused with E-HEAP-NOHOST (chapter 18).
20.5 Growing and returning a piece — grow and release#
A bump allocator only moves forward. Even so, the piece it handed out last is safe to take back, because nobody has received a place after it. grow enlarges that piece in place, and release (trait freeing_allocator) takes it back.
examples/ch20/growrelease.low
module growrelease .
rem run: regrow [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
use allocs .
proc regrow input buf mut slice u8 . output u64 . effects state .
do
var b allocs.bump_bytes spawn actor allocs.bump_bytes . .
let c usize send b. init buf. . .
let p option mut slice u8 send b. reserve 4 . .
guard is_some p. . else return 91 . .
let pv mut slice u8 some_value p. . .
rem grow the piece just received from 4 to 6 bytes in place
let g option mut slice u8 send b. grow pv. 6 . .
guard is_some g. . else return 92 . .
let gv mut slice u8 some_value g. . .
let q option mut slice u8 send b. reserve 2 . .
guard is_some q. . else return 93 . .
let qv mut slice u8 some_value q. . .
rem gv is not the last piece (qv comes after it), so it is not taken back
let back1 bool send b. release gv. . .
rem qv is the last piece, so it is taken back
let back2 bool send b. release qv. . .
let u usize send b. used . .
var d1 u64 0 .
if back1. do
set d1. 1 .
end .
var d2 u64 0 .
if back2. do
set d2. 1 .
end .
rem pack the amount used, the first answer and the second answer into one three-digit number
return add mul cast u64 u. . 100 . add mul d1. 10 . d2. . . .
end .
Output
$ lowentc --run regrow growrelease.low [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
regrow([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]) = 601
arg0 (written) = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
send b grow pv 6growspvfrom 4 bytes to 6. What it grows is the piece itself, not a size. The implementation checks with the core opsame_slice a. b. .(same start address and same length?) thatpvis exactly the bytes it just handed out. Pass someone else’s buffer of the same length and the answer isnone. Recognising a piece by size alone would let two containers overlap without a sound.- After
qvis handed out,gvis no longer the last piece. Sorelease gvisfalseand changes nothing. release qvistrue. The cursor goes back to 6, sousedis 6. The answer 601 reads “used 6 · first answer false · second answer true”.
Both ops are an optimisation, not a promise. If a piece cannot grow, the caller receives a new one and copies, and the answer must be the same. For fixed_bytes and heap_bytes, which carve straight from a root, grow is always none. A root does not know whose piece came last.
20.6 Taking a list from an allocator#
A list written out as values (chapter 9) normally sits in the op’s frame. When it is large, or must outlive the op, take its bytes from an allocator you choose: write use <allocator> right after the binding’s name, and say with else what happens when the bytes do not come.
examples/ch20/litalloc.low
module litalloc .
rem run: fill 9
use allocs .
rem take a list from the allocator you choose --- bind it if the bytes came, go to `else` if they ran out
export proc fill input a u64 . output u64 . effects state .
do
var back lit array 48 u8 _ . .
var bb allocs.bump_bytes spawn actor allocs.bump_bytes . .
let got usize send bb. init back. . .
var xs use bb. mut slice u64 lit array 4 u64 do 0 a. . _ 1 . end . else return 0 . .
set idx xs. 3 . 100 .
let big use bb. option mut slice u64 lit array 8 u64 _ . .
guard is_none big. . else return 1 . .
return add idx xs. 0 . idx xs. 3 . . .
end .
Output
$ lowentc --run fill litalloc.low 9
fill(9) = 109
var xs use bb mut slice u64 lit array 4 u64 … . else return 0 .asksbbfor 32 bytes and, if it gets them, fills them like any list and binds them toxs. If the allocator cannot give the bytes, control goes toelse, which must leave (bindingelseis covered in chapter 11).- To carry the failure along as an
option, write the whole type:let big use bb option mut slice u64 lit … .— then check it later withguardormatch. - Filling works as for a frame list: list the elements, or fill chosen cells with
do … end. - The bytes belong to the allocator, so their lifetime follows it. Bytes from the heap may be passed out of the block; bytes from a bump allocator backed by a frame array cannot leave that array’s block (
E-LIT-ESCAPE). - Writing
usewith neitherelsenoroption, or on alit vec, isE-LIT-USING.
20.6.1 Given back when the block ends#
When the allocator can take pieces back one by one (freeing_allocator), bytes taken with use are given back automatically when the block that declared the name ends. The same happens when the block is left by return, or by break or continue in a loop.
examples/ch20/autorel.low
module autorel .
rem run: rounds 10
use allocs .
rem bytes taken in a block are given back when the block ends --- 32 bytes ten times out of 64
export proc rounds input n u64 . output u64 . effects state .
do
var back lit array 64 u8 _ . .
var bb allocs.bump_bytes spawn actor allocs.bump_bytes . .
let got usize send bb. init back. . .
var s u64 0 .
var i u64 0 .
while lt i. n. . do
let t use bb. mut slice u64 lit array 4 u64 i. _ . else return 999 . .
set s. add s. idx t. 0 . . .
set i. add i. 1 . .
end .
return add s. mul cast u64 send bb. used . . 1000 . . .
end .
Output
$ lowentc --run rounds autorel.low 10
rounds(10) = 45
- Each time the loop body runs,
ttakes 32 bytes, and they are given back when the body ends. So the loop runs ten times on 64 backing bytes andusedis 0 afterwards. Without the give-back, the third round would run out of bytes and go toelse. The answer 45 is the sum of 0 to 9. - Giving back is
send <allocator> release <piece>. Whether the bytes are taken back is the allocator’s policy —bump_bytestakes back only the piece it gave last. So when one block takes two, the inner (later) one is given back first. - After they are given back, the bytes belong to something else. Carrying them out of the block (
return t, or storing them in a name further out) isE-LIT-ESCAPE. - Bytes from an allocator that cannot take pieces back (
fixed_bytes,heap_bytes) stay with the allocator. - To give the bytes back before the block ends, write
drop t. .. The block end then does not give them back again. Usingtafter thedropisE-OWN-MOVED, and adropin an inner block (one side of anif, say) isE-OWN-JOIN. - Nothing is given back when the program stops with
panic.
To use the bytes longer than the block, write keep <allocator> instead of use <allocator>. Then nothing is given back at the end of the block, and the bytes live as long as the allocator. A helper can hand the buffer it built to its caller.
examples/ch20/keepbuf.low
module keepbuf .
rem run: handoff
use allocs .
rem with `keep` in place of `use` nothing is given back at the end of the block --- the caller receives the buffer a helper built
proc make_buf input bb mut allocs.bump_bytes . input d mut slice u64 . output mut slice u64 . effects state .
do
let t keep bb. mut slice u64 lit array 2 u64 7 8 . else return d. . .
return t. .
end .
export proc handoff output u64 . effects state .
do
var back lit array 64 u8 _ . .
var d lit array 1 u64 _ . .
var bb allocs.bump_bytes spawn actor allocs.bump_bytes . .
let got usize send bb. init back. . .
let s mut slice u64 make_buf bb. d. . .
return add idx s. 1 . mul cast u64 send bb. used . . 100 . . .
end .
Output
$ lowentc --run handoff keepbuf.low
handoff() = 1608
- The
sthatmake_bufreturns isbb’s bytes. It can be used whilebblives; using it longer is refused at translation. - Giving the bytes back is up to you:
send bb release …gives a piece back, or everything goes back when the allocator ends.usedis 16, so the answer is 1608. keep <allocator>stands whereuse <allocator>does (right after the name), on a binding that takes a list or struct literal. Anywhere else it isE-USING-FORM.
20.6.2 Structs in an allocator too#
A struct value is built in an allocator’s bytes with the same spelling. The binding’s type is that struct.
examples/ch20/structalloc.low
module structalloc .
rem run: place 5
use allocs .
struct pt do
x u32 .
y u64 .
end .
rem a struct can be built in an allocator's bytes too --- only a struct with a byte layout
export proc place input a u64 . output u64 . effects state .
do
var back lit array 64 u8 _ . .
var bb allocs.bump_bytes spawn actor allocs.bump_bytes . .
let got usize send bb. init back. . .
var q use bb. pt lit pt do x 7 . y a. . end . else return 0 . .
set field q. y . add field q. y . 1 . .
return add widen u64 field q. x . . mul cast u64 send bb. used . . 100 . . .
end .
Output
$ lowentc --run place structalloc.low 5
place(5) = 1607
var q use bb. pt lit pt do … end . else return 0 . .asksbbforsize_of pt .bytes (16 here). If it gets them, it fills them with zeros, lays the struct’s layout over them asviewdoes, and writes the fields you gave.usedis 16 whileqlives, so the answer is 1607.- Fields are read and written as in any struct (
field q. y .,set field q. y . …). The bytes are given back when the block ends, as in the previous section. - Only a struct with a byte layout is accepted — every field must be a sized number. A struct with a slice,
ownedor array field has no representation in allocator bytes yet and isE-LIT-UNBUILT.
20.7 Where the three layers sit in the standard library#
| Module · name | Capability | Policy (type) | State (value) |
|---|---|---|---|
allocs.bump_bytes · bump_aligned | none — borrowed bytes | an actor with byte_allocator · freeing_allocator | backing bytes · cursor |
allocs.fixed_bytes · heap_bytes | capability field cap allocator · cap heap | byte_allocator | amount used |
vecgen.vec t a | none | takes the allocator type a as a parameter | element count · buffer — open takes the allocator with use |
growvec.gvec | none | a name fixed to vecgen.vec u8 allocs.bump_bytes | same as vecgen |
pool.block_pool b | none — borrowed bytes | not an allocator — a struct that takes blocks back one by one through generation handles | blocks · generation array |
Table 20.4 — Allocation-related modules — which of capability, policy and state each one carries
Only the two actors that reach a root hold a capability. Everything else works on bytes someone handed over. So library code that received no capability can still build and grow containers, and to find the code that brings new memory into the program you only look at the ops that received a capability.
20.8 Who sets the size of the fixed window?#
On a machine without an operating system, the fixed window is the memory between two symbols set by the linker. Its size is not baked into the executable. The compiler emits a linker-script fragment, and only the size in that fragment changes per board.
$ lowentc --emit-ldscript --fixed-bytes 4096 fixed.low
.lw_fixed (NOLOAD) : ALIGN(8)
{
__lw_fixed_start = .;
. = . + 4096;
__lw_fixed_end = .;
}To imitate another board on the host, give the VM a window size. fixed.low asks for 64 bytes twice, so shrinking the window to 100 bytes with lowentc --fixed-bytes 100 --run main fixed.low makes the second reserve return none and prints main() = 2. You can see on your development machine where a small machine runs out of memory.
A common misconception. Embedded code does not allocate dynamically, so it needs no allocator
none, and the heap effect is blocked at translation. The grammar keeps the rule.20.9 Same bits, different reading#
Reading the same bytes as a different type comes in two kinds. view, which lays a struct layout over a byte slice, was seen in chapter 13. Keeping the bit pattern between two scalars of the same width and changing only how it is read is bit_cast.
examples/ch20/bitcast.low
module bitcast .
rem run: reinterpret -1
rem run: float_bits 1.0
fn reinterpret input x i32 . output u32 .
do
return bit_cast u32 x. . .
end .
fn float_bits input f f64 . output u64 .
do
return bit_cast u64 f. . .
end .
Output
$ lowentc --run reinterpret bitcast.low -1
reinterpret(-1) = 4294967295
$ lowentc --run float_bits bitcast.low 1.0
float_bits(1.0) = 4607182418800017408
The −1 of an i32 has every bit set, so read as a u32 it is 4294967295. Reading the f64 1.0 as a u64 shows its IEEE 754 representation as is. Unlike cast, it does not move the value, so it never stops.
The target type must be one where every bit pattern is a valid value. bool and enum are not.
examples/ch20/bitcast_bool.low
module bitcast_bool .
rem expect: E-TYPE-BITCAST
fn truthy input a u8 . output bool .
do
return bit_cast bool a. . .
end .
Output
$ lowentc --check bitcast_bool.low
bitcast_bool.low:6:0 E-TYPE-BITCAST: bit_cast's target must be a `plain` scalar type — every bit pattern valid, no padding (SPEC-004 §190). bool/enum have trap representations and are NOT plain, so they are outside safe punning
Reading the u8 value 2 as a bool would give a value that is neither true nor false. The path that creates such values is closed.
20.10 Common mistakes#
Counter-example. Forgetting init on a bump allocator
examples/ch20/mistake_noinit.low
module mistake_noinit .
rem expect: E-ACTOR-UNINIT
use allocs .
proc carve input buf mut slice u8 . output u64 . effects state .
do
var a allocs.bump_bytes spawn actor allocs.bump_bytes . .
rem ✘ `send a init buf` is missing --- asks an allocator that has no bytes to hand out
let p option mut slice u8 send a. reserve 3 . .
guard is_some p. . else return 91 . .
return send a. used . .
end .
Output
$ lowentc --check mistake_noinit.low
mistake_noinit.low:10:0 E-ACTOR-UNINIT: this is the FIRST message sent to an actor that was just spawned, and the handler READS a state field that holds a slice without setting it. A fresh actor's state is all zeroes, and zero is not a slice: the VM stops (`len needs a slice`) while native quietly answers `none` — the two back ends disagree, which means one of them is lying. Send the message that sets it up first (the one whose handler `set`s that field, typically `init`)
mistake_noinit.low:12:0 E-TYPE-NOMINAL: these are NOMINALLY DISTINCT types with the same representation — a `newtype` (or usize/idiff) is not interchangeable with its base or with another newtype. Convert explicitly with `cast <type> <value>` (RFC-0002 §6.3.1)
bump_bytes does not create memory by itself. Until bytes are attached with send a init buf, it has nothing to hand out. A just-spawned actor’s state fields are all zeroes, and zero is not a slice — reading that field makes the VM and the native build run differently. So translation refuses it (E-ACTOR-UNINIT). Put the init on the line right after spawning the allocator.
Counter-example. Attaching the same bytes to two allocators
examples/ch20/mistake_sharedbuf.low
module mistake_sharedbuf .
rem expect: E-EXCL
use allocs .
proc twice input buf mut slice u8 . output u8 . effects state .
do
var a allocs.bump_bytes spawn actor allocs.bump_bytes . .
var b allocs.bump_bytes spawn actor allocs.bump_bytes . .
rem ✘ the same bytes are given to two allocators --- both hand out the same place
let c1 usize send a. init buf. . .
let c2 usize send b. init buf. . .
let p option mut slice u8 send a. reserve 3 . .
let q option mut slice u8 send b. reserve 3 . .
guard is_some p. . else return 91 . .
guard is_some q. . else return 92 . .
let pv mut slice u8 some_value p. . .
let qv mut slice u8 some_value q. . .
set idx pv. 0 . 65 .
set idx qv. 0 . 66 .
rem 65 was written, yet 66 comes back
return idx pv. 0 . .
end .
Output
$ lowentc --check mistake_sharedbuf.low
mistake_sharedbuf.low:12:0 E-EXCL: the same WRITABLE place was handed to a SECOND actor. A write borrow is exclusive (§8.4): two actors holding the same bytes both hand them out, so two containers silently overlap and a write through one is read through the other (measured: 65 written, 66 read back). Give each actor its own bytes — `subslice` the buffer into pieces that do not overlap
The two allocators know nothing of each other. Both cut from the front of buf, so pv and qv become the same place: write 65 and then 66, and reading pv gives 66. The rule that there is only one write borrow (chapter 12) must hold across the actor boundary too, so this is refused with E-EXCL (until 2026-09-16 it passed). Attach separate bytes to each allocator; if one buffer must be shared out, cut two non-overlapping pieces with subslice.
Counter-example. Leaving out use when two allocators fit
examples/ch20/mistake_ambiguous.low
module mistake_ambiguous .
rem expect: E-ALLOC-AMBIGUOUS
use allocs .
proc two_from
input comptime a type .
use al a .
output u64 .
effects state via a .
requires allocs.byte_allocator a. . .
do
let p option mut slice u8 send al. reserve 3 . .
guard is_some p. . else return 91 . .
return send al. used . .
end .
proc with_two input small mut slice u8 . input big mut slice u8 . output u64 . effects state .
do
var s allocs.bump_bytes spawn actor allocs.bump_bytes . .
var g allocs.bump_bytes spawn actor allocs.bump_bytes . .
let x usize send s. init small. . .
let y usize send g. init big. . .
rem ✘ two allocators of a fitting type are in scope, and the call does not say which
let n u64 two_from. .
return n. .
end .
Output
$ lowentc --check mistake_ambiguous.low
25:13 E-ALLOC-AMBIGUOUS: this call draws from an allocator, and MORE THAN ONE value of a fitting type is in scope — the tool will not guess which one you meant. Say it on the binding: `let <name> use <allocator> <type> …` (RFC-0112 D8(4))
s and g are both bump_bytes, so the tool cannot guess. A guess might carve from the big buffer what should come from the small one, or the other way round, and such a bug stays hidden on a development machine with plenty of memory. So it stops with E-ALLOC-AMBIGUOUS and asks you to say which.
examples/ch20/ambiguous_fixed.low
module ambiguous_fixed .
rem run: with_two [0,0,0,0] [0,0,0,0,0,0,0,0]
use allocs .
proc two_from
input comptime a type .
use al a .
output u64 .
effects state via a .
requires allocs.byte_allocator a. . .
do
let p option mut slice u8 send al. reserve 3 . .
guard is_some p. . else return 91 . .
return cast u64 send al. used . . .
end .
proc with_two input small mut slice u8 . input big mut slice u8 . output u64 . effects state .
do
var s allocs.bump_bytes spawn actor allocs.bump_bytes . .
var g allocs.bump_bytes spawn actor allocs.bump_bytes . .
let x usize send s. init small. . .
let y usize send g. init big. . .
rem name the source on the binding with `use`
let n use g. u64 two_from. .
let m use g. u64 two_from. .
return m. .
end .
Output
$ lowentc --run with_two ambiguous_fixed.low [0,0,0,0] [0,0,0,0,0,0,0,0]
with_two([0,0,0,0], [0,0,0,0,0,0,0,0]) = 6
arg0 (written) = [0,0,0,0]
arg1 (written) = [0,0,0,0,0,0,0,0]
The two calls to two_from each carved 3 bytes from the same g, so used is 6. With the source written on every call, you can read which buffer shrinks.
Counter-example. Writing the clause in an op’s head with the old spelling using
examples/ch20/mistake_usingold.low
module mistake_usingold .
rem expect: E-USING-OLD
use allocs .
proc one_from
input comptime a type .
rem ✘ the old spelling --- the clause in the head is `use` too
using al a .
output u64 .
effects state via a .
requires allocs.byte_allocator a. . .
do
let p option mut slice u8 send al. reserve 3 . .
guard is_some p. . else return 91 . .
return send al. used . .
end .
Output
$ lowentc --check mistake_usingold.low
9:3 E-USING-OLD: the allocator clause of an op is `use <name> <type> .` — the same word a binding picks its allocator with (`let b use al. …`). Write `use` where this `using` stands (RFC-0147)
Up to 1.9 the clause in the head was using al a . and only the binding said use. The receiving side and the choosing side used different words, so nothing in the text showed that the two are a pair. Now both are use. The old spelling is refused with E-USING-OLD, and the diagnostic names the letters to change.
Counter-example. Leaving out the root of alloc_bytes outside a region block
examples/ch20/mistake_noroot.low
module mistake_noroot .
rem expect: E-ALLOC-NOROOT
proc scratch input a cap allocator . output usize . effects alloc .
do
rem ✘ outside a region block, yet no root is named --- the capability `a` must still be written
let g option mut slice u8 alloc_bytes capacity 8 . .
if is_some g. . do
return len some_value g. . . .
end .
return 0 .
end .
Output
$ lowentc --check mistake_noroot.low
mistake_noroot.low:7:0 E-ALLOC-NOROOT: `alloc_bytes` needs the ROOT it carves from as its first operand, BY NAME — a `cap allocator` or `cap heap` input, a region parameter, an open `region` block, or a capability field of this actor. There is no ambient heap (RFC-0043 D1). The operand may be left out (`alloc_bytes capacity n`) only INSIDE a region block, where the innermost region fills it; a capability is never filled in for you (RFC-0112 D8(7))
The root may be left out, as in alloc_bytes capacity 8, only inside a region block; the innermost region is then the root (chapter 18). A root received as a capability cannot be left out. Write its name: alloc_bytes a. capacity 8. If a root could be reached without a name, reading the head would no longer tell you where the op gets its memory. The capacity in front of the size cannot be dropped either (E-ALLOC-CAPACITY-MARK). That word is what tells the shortened form from the named one.
Counter-example. Leaving out via a in a generic op
examples/ch20/mistake_novia.low
module mistake_novia .
rem expect: E-EFFECT
use allocs .
proc one_from
input comptime a type .
use al a .
output u64 .
rem ✘ no `via a` --- the `alloc` the allocator performs is not part of this op's declaration
effects state .
requires allocs.byte_allocator a. . .
do
let p option mut slice u8 send al. reserve 3 . .
guard is_some p. . else return 91 . .
return send al. used . .
end .
proc with_fixed input al cap allocator . output u64 . effects alloc state .
do
var fb allocs.fixed_bytes spawn actor allocs.fixed_bytes . .
let n use fb. u64 one_from. .
return n. .
end .
Output
$ lowentc --check mistake_novia.low
mistake_novia.low:13:1 E-EFFECT: this op performs `alloc`, which its `effects` clause does not declare — add it to `effects …`, or stop calling what needs it
mistake_novia.low:20:1 W-EFFECT-OVER: this op DECLARES `alloc` but never PERFORMS it. A declared effect is a cost the caller must budget for (a pure caller cannot call an `io` op). Drop it, or — if a future version will perform it — say so (RFC-0007)
mistake_novia.low:16:0 E-TYPE-NOMINAL: these are NOMINALLY DISTINCT types with the same representation — a `newtype` (or usize/idiff) is not interchangeable with its base or with another newtype. Convert explicitly with `cast <type> <value>` (RFC-0002 §6.3.1)
one_from declares only effects state ., but when instantiated with fixed_bytes, reserve performs alloc. via a is what makes “the allocation effects of type a are part of my declaration” true, so each instance gets exact effects. Without it you get E-EFFECT, and since the effect does not reach the caller, with_fixed oddly gets a W-EFFECT-OVER as well. Fixing the first error removes the second.
A common misconception. Spawning another allocator gives you another window
examples/ch20/shared_window.low
module shared_window .
rem run: main
use allocs .
proc main input al cap allocator . output u8 . effects alloc state .
do
rem two allocators, but only one fixed window
var f1 allocs.fixed_bytes spawn actor allocs.fixed_bytes . .
var f2 allocs.fixed_bytes spawn actor allocs.fixed_bytes . .
let a option mut slice u8 send f1. reserve 40000 . .
guard is_some a. . else return 1 . .
rem `f2` has used nothing yet, but the window has too little room left
let b option mut slice u8 send f2. reserve 40000 . .
guard is_some b. . else return 2 . .
return 0 .
end .
Output
$ lowentc --run main shared_window.low
main() = 2
fixed_bytes carves from the root. There is one root and one cursor (chapter 18). Once f1 takes 40000 bytes, f2 gets none even though it has used nothing yet, because the default window (65536 bytes) has too little room left. An allocator’s used is the amount that allocator has used, not what is left in the whole window. If you need separate budgets, take one large piece from the window and attach non-overlapping parts of it to several bump_bytes.
20.11 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
var a allocs.bump_bytes spawn actor allocs.bump_bytes . . | spawn an allocator (its state) | state is an actor value — there is no global allocator |
send a init buf | attach the bytes to hand out | an allocator never creates memory behind your back |
send a reserve 3 · send a used | request a piece (option) · amount used | shortage is a value, not a trap |
input comptime a type . | receive the allocator’s type (policy) at translation time | swapping costs nothing at run time |
use al a . | receive an allocator value of that type — not an input | it does not sit among the call’s arguments |
let n use g. u64 two_from. . | say which allocator this call carves from | with two or more, nothing is guessed |
effects state via a . · requires allocs.byte_allocator a. . . | inherit the type’s effects · trait condition | exact effects per instance |
allocs.fixed_bytes · allocs.heap_bytes | default allocators carving straight from a root | only an op holding that kind of capability may spawn one — E-CAP-FORGE |
send b grow pv 6 · send b release qv | grows the last piece · takes it back | checks identity with same_slice, not size |
no source · an unused use | E-ALLOC-NOSOURCE · E-ALLOC-USING-UNUSED | no global allocator, and no empty choice |
alloc_bytes a. capacity 8 · alloc_bytes capacity 8 inside a region block | take bytes from a root · from the innermost region | a root is written by name — E-ALLOC-NOROOT |
var xs use bb mut slice u64 lit array 4 u64 … . else … | takes a list from the allocator you choose | goes to else if it runs out — which must leave |
bit_cast u32 x. . | keep the bits, change only how they are read | never read as bool or enum |
Table 20.5 — Allocator syntax — shape · meaning · why it looks this way
Recap
none. Allocators are received with input comptime a type . and use al a ., swapped at no run-time cost, and there is no global allocator. fixed_bytes and heap_bytes are default allocators with capability fields and can be spawned only from ops holding the same kind of capability. The linker sets the size of the fixed window. bit_cast keeps the bits and never reads them as bool or enum.