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:
Part III 15.2 and Part III’s code table (NK2506).
List, String and Map stand on allocator-api2/hashbrown, and every allocation can fail.pub function’s parameter, a value read out of a shared value), the hand-over is refused with NK2506 and the program writes .clone(). A copy is written or refused, never inserted (ADR-282 D7). The better the following, the fewer .clone()s are asked for. A Vec/String that Rust hands back is taken over without a copy.update whose value copies (a number, bool, char) works on a copy and puts it in place at its end, so a panic in it poisons nothing.update_all copies only where both values copy, and puts both back together while it still holds both locks. Where one does not copy, neither is copied and a panic poisons both, because values changed in one door must agree.access/access_all cannot change a value, so a panic in them poisons nothing.What exists today:
SharedMut::supervised) and ADR-326 D4 (a panic ends only its task) are built.update_all (Part II 12.2). A panic there never poisons.Vec/String, there is no allocator of Nikaia’s own, and every other door poisons on a panic, access included (held in lock.rs and std’s mutex).| 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).
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.
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%.crates/nikaia-std/src/lock.rs, and the emitter’s lowering of the doors):
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;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.kasse.update fn(mut v) { v += i64::MAX } in a task panics; a second task reads the old value through get, without a panic;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;access whose block panics leaves the value readable.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);try_reserve. A full block panics in the task, outside the allocator (D6).copy_nonoverlapping. allocator-api2’s extend_from_slice copies one element at a time, which doubled the tasks’ count in step 1.#[cold] function. Rust has no #[unlikely] attribute.SharedMut door and in Shared’s construction, allocations go to the value’s block; a full one poisons the value.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.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.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();Vec/String handed back is wrapped as it is and counted;NK2506. Each case the walk does not yet follow is a gap to close, not a reason to copy.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).String panics, and another task runs on;supervised cache that outgrows its block is rebuilt;&[T] passed to a Rust function is not copied (pointer equality, in a test crate);Vec arrives at the address it was built at;pub function that hands its parameter to such a Rust function is NK2506, and compiles with .clone();recv, and the sender runs on.Nothing in ADR-327 is left open except D10’s measurement (step 1).
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:
malloc: 41% fewer instructions and half the mispredicted branches. The saving is malloc’s and free’s own work, about 7 200 instructions per task.malloc costs 2.3%.Checked before quoting:
global declares System as its global allocator. Without a declaration, std’s default shims (__rdl_*) are not inlined and add about 420 instructions per task. That is a difference of the declaration, not of the strategy.malloc’s count depends on where the heap starts. The same binary called by another path moves _int_malloc/_int_free by about 177 instructions per task. The script always calls the binaries the same way.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
d3a6eac)benches/fallible/ writes two workloads twice:
Vec, String and std’s HashMap;allocator-api2’s Vec<T, A> and hashbrown’s map, with an allocator that counts every byte against a budget and refuses past it. Growth goes through try_reserve, and a refusal ends the task.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:
allocator-api2’s extend_from_slice is extend(iter().cloned()) on stable Rust, a loop of one byte at a time. Used as it is, it doubled the tasks’ count (35 136 per task). The bench copies with one copy_nonoverlapping, as std’s Vec<u8> does. A std built on the crate would have to do the same.List<u8>, handed out as a str without checking its UTF-8 again, as String does. Checking it cost about 230 instructions per task more.Not measured:
nikaia-std feature, with an emitted program against them. The emitter writes Vec/String today, so that is a change to the emitter, not to a bench;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
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:
malloc (saving ~7,200 instructions per task).The measured gains far outweigh the checks. We are moving forward to Step 4: Implementation.
next headers. $O(1)$ bulk free when a task ends.SharedMut door.try_reserve-style semantics.panic (fault isolation, other tasks run on).TaskStrategy: API for defining initial block sizes, growth factors, and hard budget ceilings.#[unlikely] for cold-path separation (BTB protection).&[T], &mut [T]): 100% Zero-Copy. Rust borrows directly from the task slab.Vec / String):
try_realloc), strictly accounted against the task’s budget, to avoid arena corruption while enabling zero-copy moves.copy_nonoverlapping for memory primitives and bypass UTF-8 checks (from_utf8_unchecked) on previously validated slices.&[T] across the C/Rust boundary via pointer comparison tests.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:
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.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.#[unlikely] is not a Rust attribute. The panic path is a #[cold] function.Rejected on the way (ADR-327 §3):
#[global_allocator]. A crate that keeps a Vec past its task would point into an emptied block, and a refused allocation ends the process.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