Issue #490: ADR-327 D4-D11: memory budgets per task and per shared value — measured, decided, to build

Open and answer this issue on GitHub.

performance, ready · opened 2026-10-06 · updated 2026-10-12 · 4 comments

The direction is decided in ADR-327 D4-D11 (history in #121 and in the comments below). The specification states it in:

What exists today:

Measured (comments below)

  instructions mispredicted branches
a 64 KiB bump block per task, against malloc (benches/taskblock, 0.0.602) −41% about −50%
fallible collections on allocator-api2/hashbrown, against std (benches/fallible, 0.0.603) +0.2% +5% (1BRC), +7% (tasks)

The owner approved the direction on these numbers (step 3, done).

To build

Measured first only where a step can be slower than what it replaces. A step that can only be faster is built without a measurement.

  1. Measure D10. The bump block measured above never reuses freed memory, so it cannot serve a long-running task (a connection loop, main). Measure a block that gives freed memory back to its task (size classes with free lists inside the block, or a chain of blocks) against the bump block, on benches/taskblock. Add a second workload: one long task that frees and allocates in a loop. If reuse makes short tasks slower, a task may start as a bump block and switch to reuse at its first full block. The measurement decides; the owner is asked only if neither form keeps the 41%.
  2. D11, independent of the rest, can go first (crates/nikaia-std/src/lock.rs, and the emitter’s lowering of the doors):
    • an update of a value that copies runs on a local copy, written back at the block’s end, with no poison mark while it runs;
    • an update_all where both values copy does the same with both, written back together before either lock is released; otherwise it stays as today;
    • access/access_all set no poison mark. On Crossing, take the guard so that a panic in a reading block does not poison std’s mutex.
    • Tests:
      • kasse.update fn(mut v) { v += i64::MAX } in a task panics; a second task reads the old value through get, without a panic;
      • an update of a SharedMut[Vec[i64]] that panics after a push still poisons;
      • update_all(a, b) over two i64s whose block panics after changing the first leaves both old values readable;
      • update_all(a, b) over an i64 and a Vec[i64] that panics poisons both;
      • an access whose block panics leaves the value readable.
  3. The allocator (D4, D5, D10):
    • a task-local allocator in nikaia-std;
    • List/String/Map on allocator-api2/hashbrown, with that allocator as a handle that is either the task’s block or outside (D8, D9);
    • growth through try_reserve. A full block panics in the task, outside the allocator (D6).
    • Copy byte runs with one copy_nonoverlapping. allocator-api2’s extend_from_slice copies one element at a time, which doubled the tasks’ count in step 1.
    • Text that is already valid UTF-8 is not checked again.
    • The panic path is a #[cold] function. Rust has no #[unlikely] attribute.
  4. The shared value’s block (D5, D6): inside a SharedMut door and in Shared’s construction, allocations go to the value’s block; a full one poisons the value.
  5. Where a value lives (D8, D9): the emitter decides per allocation site whether the value stays in the block. It is built outside when the compiler sees it can outlive the task (send, a store into a shared value outside its door, a move into a Rust owner, the task’s result), and also when it cannot tell. Then the block is given back whole when the task ends.
  6. Receiver budgets (D9): at recv, at a join, and at a store into a shared value, the value’s bytes move from the sender’s count to the receiver’s. A value of one allocation (a list of numbers, a text) is one step; a nested value is a walk over its allocations. A receiver it does not fit panics there, or a shared value is poisoned.
  7. The Rust boundary (D8):
    • the walk that follows a value back to where it was built, so that it is built outside the block;
    • such a value crosses as a std::vec::Vec/String without a copy (into_raw_parts → from_raw_parts; allocator-api2’s Global is std’s global allocator);
    • NK2506 where the walk cannot follow the value, with the help naming the .clone();
    • a Vec/String handed back is wrapped as it is and counted;
    • the goal is few NK2506. Each case the walk does not yet follow is a gap to close, not a reason to copy.
  8. The strategy (D7): std’s default derives block sizes from the memory there is and the tasks running, with the same exchange point as admission (ADR-326 D2).
  9. Tests:
    • a task that reads more than its budget into a String panics, and another task runs on;
    • a supervised cache that outgrows its block is rebuilt;
    • a &[T] passed to a Rust function is not copied (pointer equality, in a test crate);
    • a list built in the package and handed to a Rust function that owns its Vec arrives at the address it was built at;
    • a pub function that hands its parameter to such a Rust function is NK2506, and compiles with .clone();
    • a long task that allocates and frees a megabyte a thousand times under a 64 KiB budget does not panic (D10);
    • a value sent from a task that has ended is still read correctly by its receiver, and counts against the receiver (D9);
    • a receiver whose budget a received value does not fit panics at recv, and the sender runs on.

Nothing in ADR-327 is left open except D10’s measurement (step 1).

Comments

keywan-ghadami, 2026-10-06

Step 2 measured: a block per task (0.0.602, 7fb1c22)

benches/taskblock/ runs one workload: 100 000 short tasks, each reading a request and writing a response, with 22 allocations and 7 reallocations per task. It is built as three binaries that differ only in their #[global_allocator]. All three print the same bytes. Counts are from callgrind with --branch-sim=yes, release build (opt-level = 3, no LTO), minus a run of no task.

allocator instructions / task mispredicted / task
global: the system’s malloc 17410 180.3
budget: malloc, each task’s bytes counted against 64 KiB (D5’s check without a block) 17810 197.5
block: a 64 KiB block per task, a bump pointer, emptied whole when the task ends 10188 94.8

What the numbers say:

Checked before quoting:

Not measured here:

Step 1 (fallible collections on allocator_api2/hashbrown, counted on benches/brc) is still owed. The owner’s decision (step 3) waits for both tables.


Generated by Claude Code

keywan-ghadami, 2026-10-06

Step 1 measured: fallible collections (0.0.603, d3a6eac)

benches/fallible/ writes two workloads twice:

The budget is set so that nothing is refused, so what is counted is the check and the collections, not a refusal. Both sides of each workload print the same bytes. Counts are from callgrind with --branch-sim=yes, release build (opt-level = 3, no LTO), minus a run over nothing.

program instructions mispredicted
1BRC’s aggregation, std (per row, 200 000 rows) 393.8 3.51
1BRC’s aggregation, fallible 394.4 3.67
taskblock’s tasks, std (per task) 17872.4 181.12
taskblock’s tasks, fallible 17882.3 193.21

What the numbers say: instructions are the same within 0.2%. Mispredicted branches rise by 5% (1BRC) and 7% (tasks).

Checked before quoting:

Not measured:

Both tables are now here (step 2 in the comment above: a block per task, −41% against malloc). Step 3 is the owner’s: does the direction stand as measured?


Generated by Claude Code

keywan-ghadami, 2026-10-09

Step 3 Owner Decision: APPROVED (Proceed to Step 4)

Based on the measurement results from Step 1 (0.0.603) and Step 2 (0.0.602), the direction set in ADR-327 is fully confirmed:

The measured gains far outweigh the checks. We are moving forward to Step 4: Implementation.


Consolidated Scope for Step 4 (To Build)

1. Allocation Engine

2. Policy & Configuration

3. Bulk Memory & Rust FFI Boundary (Zero-Copy First)

4. Integration Tests

keywan-ghadami, 2026-10-12

Decided with the owner: D8 rewritten, D9 and D10 added; the body above is the plan now (0.0.856 52e702c, 0.0.857 6807871)

How it came up. An outside proposal on “pipeline fusion” assumed this arena was already built (it is not). Checking it against this issue showed that the step-3 comment above, written with Gemini, went beyond ADR-327 or against it in five places. The body now says what holds:

  1. “O(1) bulk free when a task ends” needs every value to have left the block. ADR-327 §6 had left that open. It is now D9: a value that can outlive its task is never in the task’s block (sent, stored into a shared value outside its door, handed to Rust to own, the task’s result). So the block can be given back whole. From the hand-over such a value counts against its receiver: the task that takes it or joins its producer, or the shared value it is stored into. That was the owner’s answer to the old §6 question of whose budget it is.
  2. “Dual-path allocation” for values that go to Rust became D8. The compiler follows a value handed to a Rust owner back to where it was built, builds it outside the block, and hands it over as a std Vec/String without a copy. Where it cannot follow the value, the hand-over is NK2506 and the program writes .clone(). A first version (0.0.856) let the compiler copy unseen there. The owner rejected that the same day: it breaks ADR-282 D7 (a copy is written or refused, never inserted), which is also a promise the language makes to its users. So the work goes into following values with few gaps, not into hidden copies. A Vec that Rust hands back is taken over without a copy.
  3. “Pluggable TaskStrategy with hard budget ceilings”: D7 says no number is configured. A strategy with a fixed ceiling is one a package may offer, not the default.
  4. #[unlikely] is not a Rust attribute. The panic path is a #[cold] function.
  5. “Chained slabs” and the 41% are two different allocators. The bump block that was measured never reuses freed memory, so a long-running task would be ended for memory it no longer holds. That is now D10 (a budget counts what is held), and it is the one step measured before building, because reuse can cost some of the 41%.

Rejected on the way (ADR-327 §3):

Measuring: the owner’s rule is to measure where a step can be slower than what it replaces, not to weigh a gain that can only be positive. Only D10’s allocator is measured first (To build, step 1).

Specification: Part II 12 (“A task has a memory budget”), Part III 15.2 and NK2506 in Part III’s code table say it; ADR-327 says why. The pipeline side of the same proposal is #595 (ADR-342).


Generated by Claude Code

Back to Open Issues