Last updated: 2026-11-08

U
Undergraduate level
FDN
Foundational โ€” Knowledge that endures for decades โ€” core principles

Blocks, Ownership and Memory: The Architecture Under PatLang's Runtime

For new readers

Every language runtime has to answer two questions about each piece of data a program holds: who else can see it, and when can its memory be reused. Many languages split those answers between two mechanisms, a type system or borrow checker for the first and a garbage collector for the second. PatLang answers both with one: a reference count on every heap value, read by a compiler that knows where every reference comes from. This page explains that design, which the project calls the Block Ownership Model, and the memory design review that rebuilt the compiler and runtime around it. Journey Chapter 13 tells the story of building the first version; this page covers the architecture itself.cf. Chapter 13: the story of building this model

Three bugs started it. On the native x64 backend, list_push changed a list in place, while the interpreter treated the same call as copy-on-write, so the two engines disagreed about whether a change was visible to another holder of the list. Native == on two identical list literals returned false. And the native heap was a bump allocator that never freed anything, so any long-running PatLang program, a dispatcher or a discovery daemon, would run out of memory eventually.

Each could have been patched separately. Looked at together, they share a cause: nothing in the runtime recorded who owned a piece of data at any moment. The Block Ownership Model is the answer to that question, built once, in PatLang's own compiler and runtime.

Blocks and jumps in place of calls FoundationalKnowledge that endures for decades โ€” core principles

A block is a straight run of instructions that ends in a jump. PatLang's lowered code always had these boundaries implicitly, wherever a Jump or JumpIfFalse landed. The model makes them the unit of execution. Control passes from one block to the next by jumping with a pointer in hand: no stack frame, no return address, and so no tail-call problem to solve, since nothing is a call in the first place.

The pointer is the whole of what a block can see. Its declared parameters are filled from that pointer's fields by name; anything else it computes is a private local, gone when it jumps on. A block's inputs and outputs are therefore a structural fact about the code, readable by a tool, where in most languages they are a convention a reader has to infer.

flowchart TD accTitle: How a block runs accDescr: A block receives one pointer to the data it may see, fills its declared parameters from that pointer by name, and runs its body. A mutation the static flight check could not prove safe is checked at run time against the reference count; a count of one allows an in-place change and anything higher forces a copy first. The block ends by jumping to the next block with a fresh pointer, and none of its own locals survive the jump. IN["Incoming pointer
(scoped data, globals view included)"] --> POP["Fill declared parameters by name"] POP --> BODY["Run body against parameters
and private locals"] BODY --> MUT{"Mutation the flight check
could not prove safe?"} MUT -->|"count == 1"| INPLACE["Change in place"] MUT -->|"count > 1"| CLONE["Copy, then change"] MUT -->|"no, or already proven"| PASS["No runtime check"] INPLACE --> NEXT CLONE --> NEXT PASS --> NEXT["Jump to next block
with a fresh pointer"]

Nesting turned out to be three separate questions. An if or match forks forward once and rejoins, so it gets no boundary of its own: block-ifying every branch would only produce tiny blocks re-receiving data the enclosing one already had. A while loop is different. Its lowering ends in a jump back to a point captured before the condition, a back edge and a cycle in the control-flow graph, which is the model's core move applied to the case where the next block is the current one. Each loop body therefore becomes its own block, entered afresh each iteration with its accumulator threaded forward. Closures are the third case, and the most demanding one, taken up below.like SSA form in compilers

No ambient state FoundationalKnowledge that endures for decades โ€” core principles

Ambient global state, reachable from anywhere, defeats any analysis of who holds a reference, because for a global the only safe answer is "possibly everyone." The design first proposed keeping PatLang's set_var/get globals as an explicit escape hatch. The decision went the other way: fold them into the pointer every block already receives, as a globals-view section the lowerer threads through every jump. Because set_var and get were host functions reached only through CallHost, that was a change to lowering alone; the source a programmer wrote stayed the same.

Named objects were the other ambient mechanism. new("Dict", name) registered an object in one process-wide table, and two calls with the same name aliased the same object. Dozens of call sites minted names from a counter, "prefix_" + pr_next_id(), purely to avoid that aliasing. Those became ordinary reference values with no name at all. The minority that wanted lookup by name on purpose, such as a schema registry, keep it through an explicit handler value that can be passed around and scoped, where previously every named object sat in one undifferentiated bucket.explicit context avoids hidden aliasing

The later rewrite went further and removed set_global, get_global, set_var and get("__vars", ...) from the language, replacing them with explicit contexts. About 225 files in the self-hosted toolchain moved from the old test library to a runner whose state is one context value passed to every step, and the language suite stayed green, 184 of 184, throughout the migration.

One count, two jobs FoundationalKnowledge that endures for decades โ€” core principles

mut already stopped a let name from being rebound. The model extends it to parameters and to the data behind them: a parameter without mut grants read-only access to what it points at. Enforcing that needs to know whether anyone else holds the same data, and a reference count answers that question. If the count is 1, this is the only live reference, so change it in place. Above 1, copy first and leave the other holders' view untouched. When a count reaches 0, nothing refers to the object and its memory can be reused.copy-on-write: share until mutation

That is one header field doing ownership checking and memory reclamation together, and the second job closes the bump-allocator bug as a side effect of the first. The technique has close relatives elsewhere. Perceus, the reference-counting scheme behind the Koka language, uses precise counts to reuse a uniquely referenced cell in place, so that functional-looking code updates memory directly1. PatLang's version is written in PatLang, in its own code generator and runtime.

Static where provable, runtime where not

Checking the count at run time tells you a violation happened when it happens. Proving before the program runs that no path can violate exclusivity is the stronger guarantee, and it carries costs that the design records alongside the decision:

  • A sound checker has to reject programs it cannot prove safe, including some that would have run correctly.
  • Rejecting valid programs creates pressure for an unsafe-style override, and wherever that is used the guarantee is gone.
  • Explaining why a checker thinks two references may overlap, across several blocks, is hard to make readable.
  • Objects created in an unbounded loop are a hard case for points-to analysis in any language.

The decision is a hybrid. A pre-run flight check proves exclusivity wherever it can, and the runtime count check handles whatever it cannot decide. Because PatLang can compile and run fragments during a program's own execution (the REPL and the browser playground both do), the flight check runs again for every such fragment, ahead of either the interpreter or native code. Feeding both engines from one verified program is what keeps them equivalent.

What a closure keeps alive

A closure captures values from the scope that created it. The cheap option was to capture everything in scope, which the old lowering already did. The chosen option was a free-variable analysis that captures only what the closure body uses, and four comparisons settled it:

QuestionCapture everything in scopeCapture only free variables
Cost to buildAlmost none: the existing capture list becomes a named section.A new compiler pass, recursive through nested closures and match arms.
Memory heldEverything in scope lives as long as the closure.Only what the closure reads.
Effect on mutationEvery captured value's count rises above 1, so the enclosing block falls back to copying even values the closure never touches.Values the closure ignores stay uniquely owned and change in place.
As a contract"Might depend on anything in scope."An inspectable list of what the closure depends on.

Captured integers and booleans cost nothing either way; the difference falls entirely on heap values such as lists. The same analysis serves loop bodies, which also need to see outer values computed before the loop started.

The memory design review: from fixes to rules Applied / MethodologicalKnowledge with a 5โ€“10 year half-life โ€” stable practice

Once native memory was reference-counted, the fixes kept arriving one at a time: a free list that kept 64 exact sizes and dropped every other freed block, a BigInt division returning its own argument as the remainder, a trimmed BigInt freed into the wrong size class. Each fix was right locally. Read together, most were one instance of a general rule the code had broken in its own way, and the compiler held seven hand-maintained name lists (23 runtime producers, 23 functions that borrow their arguments, and so on) that restated facts the runtime's own source already contained.

The review asked whether this was one design: a short set of rules the compiler applies and both engines follow, with every exception named. It judged each rule at two levels.

  • The language. Everything is an object and every value carries its type. No program frees memory, and none can observe when memory is freed, only that it does not run out.
  • The runtime. Underneath, the representation may take shortcuts: an unboxed integer, a raw float in a register. Each shortcut is valid only under a stated condition, in the way Ruby keeps one object model while its virtual machine stores small integers as immediates.

The ownership rules came out as five, and the specification says there are no others:

  1. A call borrows its arguments and returns a value its caller owns.
  2. A local owns the value a call or a literal gave it; a copy of a local takes its own reference.
  3. A structure owns each value it holds, and releases each when it is freed or a slot is overwritten.
  4. A heap block is freed when its last reference is released, and freeing it releases what it holds.
  5. A value with no heap block (an int, float, bool or unit) has nothing to count, and its type word says so.

The decision with the widest consequences was to write the compiler and runtime again as a new version, keeping the old compiler as a behavioural reference. Several design choices follow from that.

The compiler knows nothing about the runtime by name

The runtime is PatLang, compiled by the same stages as any user program. Anything the compiler needs to know about a runtime function's ownership is derived from that function's source and recorded as a per-function summary, and the summary is stated as the function's contract with require and ensure. A broken ownership rule then stops the program at the call that broke it, where before it showed up later and elsewhere as a leak or a double release. Contract checks run in every build, controlled by a single setting read in one place.contracts catch errors at the call site

Two words per value

The old representation packed a value's type into its top 16 bits. That limited immediate integers to 48 bits, split floats into raw and boxed forms, and forced an address-range test to tell a string literal from a heap string. The interpreter and native code even disagreed about whether 247ย +ย 9 was an int or a bigint. In the new version a value is two words: a type word (class id, a bit saying whether the data word points to a counted heap block, and a bit reserved for metadata) and a full 64-bit data word. Integers and floats sit inline at full range with no allocation. Lists and arguments double in size, and the review accepted that cost in exchange for removing a whole family of special cases.

Operators are messages, and messages raise events

With a type always present, every value answers methods through its class: 3 + 5 sends + to 3 with argument 5. A method can declare events it raises, and int's + raises added, plus added_to once for each operand, so a program can observe "3 was added to 5" as an ordinary PatLang event handled with when. Handlers register on a type or an operation, never on an individual value, so an integer needs no per-value state. With no handler registered, the compiled fast path is a flag test and a machine add.

Writing the scenarios for this found a rule the design had missed. A handler for added that itself adds two integers would raise added again and never return. The specification now says that while a handler for an event runs, nothing it does on the same thread raises that event again. The runtime's own source files go further and declare quiet_operators(), so the runtime's internal arithmetic never raises a program's events.

Values and identities

Numbers, strings, lists and closures have value semantics: an operation that "changes" one produces a new value, and other holders keep the old one. Class instances, string builders, vecs and bool arrays have identity: a change through one holder is seen by all. The interpreter's objects, which had been maps copied by value, changed to match native code. Value semantics still runs fast, because of the count: let xs = list_push(xs, e) on a list nothing else holds takes the same time however long the list is. One bug found under this rule shows the stakes: when the pushed value itself read the list, the lowerer stopped handing the list over and copied it every time, so 16,000 pushes took 2,574ย ms. After the fix they take 3ย ms, and a scaling check now fails if doubling the pushes more than roughly triples the time.

Allocation without a shared lock

The old allocator took one ticket lock on every allocation and free. In the new runtime each thread has its own free lists and bumps within its own chunk, taken from the shared reservation with a single atomic add, so ordinary allocation takes no lock at all. A block freed by another thread goes onto the freeing thread's lists, since within a size class any block will do. When one thread mostly frees what another allocates, the imbalance is traded in batches through a shared pool, one lock per batch: about 14 lock acquisitions for 2,000 blocks in the runtime's own test. Hoard pioneered this general shape, per-processor heaps backed by a global heap that absorbs and redistributes surplus memory in large units2. PatLang's variant is constrained by its only atomic primitive being fetch-and-add, which rules out the compare-and-swap queues other allocators use to send a block back to the thread that allocated it.

Where it stands Ephemeral / ToolingKnowledge that evolves in months to a year โ€” check for updates

As of early November 2026 the new compiler, v2, passes its specification's language part (89 of 89 scenarios) and runtime part (83 of 83), the engines-agree suite (62 of 62), the 242-scenario language suite both compiled and interpreted, and the match and block-model suites. It compiles itself to a fixed point: the compiler, its build of itself, and that build's build are byte-identical. The interpreter has been rewritten in PatLang too, built by v2 on the v2 runtime, so the two engines now agree by construction, sharing one runtime. The swarm compiler page covers how v2 is structured as a set of independent jobs, which is the other half of the same rewrite.

See also

Journey Chapter 13 follows the first implementation of the model through its twenty-three phases. The compiler pipeline describes the shared front end the earlier engines used. The swarm compiler applies the same ownership vocabulary to the compiler's own work, and Packages, resolution and trust builds on the deterministic output it produces.


  1. Reinking, A., Xie, N., de Moura, L., & Leijen, D. (2021). Perceus: Garbage free reference counting with reuse. In Proceedings of the 42nd ACM SIGPLAN International Conference on Programming Language Design and Implementation (PLDI '21) (pp. 96โ€“111). ACM. https://doi.org/10.1145/3453483.3454032 โ†ฉ

  2. Berger, E. D., McKinley, K. S., Blumofe, R. D., & Wilson, P. R. (2000). Hoard: A scalable memory allocator for multithreaded applications. In Proceedings of the Ninth International Conference on Architectural Support for Programming Languages and Operating Systems (ASPLOS IX) (pp. 117โ€“128). ACM. https://doi.org/10.1145/378993.379232 โ†ฉ

Graduate Attributes

Modelled on this page

  • Subject Specialist: Technical distinctions between reference counting, bump allocation and tail-call elimination are applied to resolve specific runtime inconsistencies.
  • Critical Thinker: Three separate bugs are traced back to a single underlying cause regarding data ownership before a unified solution is proposed.
  • Reflective: Design choices are evaluated against their costs and limitations, with explicit records of why certain approaches were rejected.

University of Reading Graduate Attributes framework.