Threads: why pthread_create cannot be the primitive, and what can be
Status: §2’s single-leaf slice is built.
docs/reach.md§3.3 anddocs/function-values.md§5 both point at the same wall and stop there: a thread needs “more than a pointer”, and neither document says what more. This document finishes that sentence. The answer is not “add function values and declarepthread_create” — §1 shows exactly why that specific shape cannot work in this language, for a reason no amount of FFI cleverness fixes. The way through is a compiler-providedspawn/joinpair, the same kind of primitivefork,boxandsplitalready are, built on an insight the existing checker did not need to be extended to state: the aliasing rule that makes aborrowblock sound was never a single-threaded rule. It forbids a second writer while a reference is live, full stop, and a spawned thread joined before its region closes is just a second writer the checker already refuses to admit exists. (That was not true when this was written: see the correction in §3. It is true now, becausespawnlends the&!it is given until the handle is joined –aliasing.md§6.1.)What is built, and what is deliberately narrower than §2’s own illustrative signature:
spawn’spayloadandbody’s return type are each restricted to one pointer-width leaf —int,bool,c_ptr, a captureless function value, a single non-slice reference, or an owned zero-or-one-leaf capability (Io,File,Ffi,Fs,Args,Heap) — checked byRule::ThreadPayloadType(crates/cancho-ir/src/lower/conc.rs).body’s own compiled entry point becomespthread_create’s start routine directly; the reason a general struct payload is still refused is not a missing feature but a missing prerequisite feature — this language has no compiler-synthesised trampoline function yet (nothing builds a hand-craftedFuncoutside a parsed source body), which is what a multi-field struct payload would need. §5 step 3’s owned-capability case turned out to need none of that:Fileis already one leaf at the ABI level (the fd) and every zero-field capability (Ioand the rest) is already zero, so both cross through the exact same paths this slice already built forintand()— the fix was widening the checker’s allowlist, not new codegen, confirmed by running real capability-moving programs on both backends before writing it down.res Thread[T, R]— two type parameters, not the one sketched below, becauseTrides along purely so its region is tracked, getting §3’s entire escape-check argument for free fromType::Named’s already-genericmentions/regions_intowalk into its type arguments rather than needing any new checker code. Real threads (std::thread-adjacent: a genuinepthread_create/pthread_joinpair on both backends), not a simulation — verified under §5 step 2, distinct thread IDs and real wall-clock parallelism, and under step 3, a real file read and a real console write each performed by a second OS thread holding the only reference to the capability that authorised it, all checked directly rather than trusted from the type checker alone. Step 4 is also built, folded into steps 2 and 3’s own fixtures rather than needing a separate one.§5 steps 3’s struct-shaped case and 5, checked and closed as “not yet,” not left open. A genuinely multi-field payload needs the same compiler-synthesised trampoline (§4 below) whether or not it happens to be a capability, and
Rc[h] Tis not an implemented type in this compiler at all, so no fixture can even construct what step 5 would refuse. Rather than build the trampoline speculatively, this project’s own rule (AGENTS.md§7: a feature earns its way in when a program asks) was applied and checked directly: nothing acrosscancho,lex-os,lex-langorlex-gpuasks for a multi-field spawn payload today —lex-os-guest’s own reasoning loop is single-threaded by design with no expressed want for concurrency, andlex-lang’s ownconc.spawn_thread(a parallel, independent design for the same problem in a different language) sidesteps the question entirely with a zero-argument closure rather than an explicit payload, which is a mild signal that real background-task askers in this shape of problem tend to route around a fat payload rather than demand one. Decided, the same wayself-hosting.mddecided not yet: revisit when a concrete asker exists, not on a schedule.
1. Why pthread_create cannot be an extern fn
function-values.md §5 already worked out the three conditions a
callback into C needs, checking pthread_create’s real signature
against them:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);
- The row must be
[]. C callsstart_routinewith exactly onevoid *and nothing else. There is no capability parameter for an authorised call to be checked against, and no context pointerreach.md§3.1’s rule would let carry one anyway. - Its parameters must be scalars.
start_routine’svoid *argisreach.md§3.1’s wall by another name: a pointer from C carries no region, and there is no cast, nofrom_raw, no transmute anywhere in this language to manufacture one (§3.1.1).c_ptr(opaque-pointers.md) does not help either — it is opaque by design, equal to itself and nothing else, never dereferenced. Smuggling a real capability inside one and reading it back out the other side is exactly the “smuggling one does not help” argumentreach.md§3.1.1 already made aboutmalloc, applied to a thread’s argument instead of a foreign result. - The calling convention matches. True, and irrelevant, because (1) and (2) already stop the declaration.
So the honest reading of function-values.md §5’s own list is not
“threads need function values plus one more thing.” It is: the
void *arg C hands a thread’s start routine is the same
un-crossable pointer reach.md §3.1 refuses everywhere else, and no
amount of function-value design closes that gap, because the gap is
not about calling a function — it is about the one argument C’s own
ABI allows across, which is exactly the shape c_ptr was built to be
safe about by refusing to be anything else. A c_ptr-typed
start_routine argument would type-check and be useless: nothing can
turn it back into the capability or reference the spawning side meant
to send, for the same reason nothing can turn a smuggled int back
into a reference (reach.md §3.1.1).
The conclusion function-values.md §5 already states is the right
one: pthread_create is unreachable through the FFI mechanism this
language has, permanently, not provisionally. A design for threads
that starts from “declare pthread_create as an extern fn” is
solving the wrong problem.
2. The primitive is spawn, not a declaration
fork (reach.md §3.3), box (heap.md), and split (§8.2 of
several documents) all share a shape: each is a builtin the compiler
implements directly in Rust, not a foreign function a program
declares and calls through Ffi. None of them is bound by the C ABI
a real extern fn call has to cross, because none of them makes a
C call in the way an extern fn does — the compiler is free to
implement each however it needs to, the same freedom that lets box
hand back a real, checker-tracked pointer while malloc (declared by
hand) cannot (reach.md §3.1).
A thread should be exactly this kind of primitive. Call it spawn:
fn spawn[T: res](payload: T, body: fn(T) -> [row] R) -> [] res Thread[R];
fn join[R](handle: res Thread[R]) -> [row] R;
(Illustrative signatures — spawn/join are builtins with call-site
checked types, the same way split/release/box are, not written
functions; [T: res]/[R] here mean “however the real type-checker
already spells an unbounded generic parameter”, mode-polymorphism.md
§2.)
Because spawn never goes through libc, none of §1’s three
conditions apply to it. What replaces them:
bodyis a captureless function value (function-values.md§4.2, unchanged) — this is the asker §6 of that document said would justify building them.payloadis its one argument, and unlike a C callback’svoid *, it is an ordinary cancho value of any type the checker already understands, because the compiler runtime callsbody’s own compiled entry point directly — the samelexs_<name>symbol a normal call already targets — rather than going through a C function pointer with a fixed, foreign signature.payloadis moved, not borrowed.spawnconsumes it exactly the way any function call consumes an argument whose type is notval(linearity-and-effects.md’s existing rule, nothing new). After the call, the spawning code cannot readpayloadagain — which is the whole soundness argument, in §3.body’s row is whatever it needs to be, not[]. Ifpayloadcarries a capability,body’s row can perform whatever that capability authorises, checked exactly as a normal call’s row is checked against what its arguments carry (reach.md§8.4’s authorisation check, unmodified).joinis mandatory.Thread[R]isres: obliged to be consumed, exactly like aFilehandle or any other capability (linearity-and-effects.md§4). A program that spawns and never joins does not compile, for the reasonfile_handle_unclosed.choalready exists.
3. Why this needs no new soundness rule, only a new obligation
Corrected twice. First (
atomics.md§2, measured): the argument below was true of a moved unique reference and false of&!in general, and the claim that the checker “already refuses to admit” a second writer was wrong. Then closed (aliasing.md§6.1): a&!given tospawnis now lent to that thread until its handle is joined, and a second spawn of a copy of it, or any use of it by the spawning thread meanwhile, is refused (tests/reject/spawn_two_copies_of_unique.cho,spawn_same_unique_twice.cho,spawn_unique_used_while_lent.cho;tests/accept/spawn_unique_join_first.chois the control that stays accepted). The paragraph below is what was measured before that change.&!r Tisval(aliasing.md§1):let a = r; let b = r;is two copies of one pointer, andspawn(a, ..)andspawn(b, ..)each take one. That compiles on both backends and loses updates: two threads adding 1 to one counter 200,000,000 times each ended short of 400,000,000 on every run, on arm64 macOS and x86-64 Linux (benches/atomics/race.cho; the control that joins the first thread before spawning the second is exact). So a data race on an ordinary value is expressible today; what is sound is a shared&(nothing writes through it) and a&!that is the only copy. The one-reference-per-thread tests in §5 do not exercise the copy.
The question function-values.md §4.1 raised about closures — “a
captured capability is authority no parameter names” — does not apply
here, because payload is a parameter, of spawn itself, checked
by the ordinary rule that already governs every parameter. The real
question is different, and it is the one this document’s status line
opens with: can two OS threads touch the same region without the
checker knowing anything about “threads” at all?
The existing rule is linearity-and-effects.md §5’s aliasing
discipline: while a unique reference &!r T is live, no other
reference to the same value exists; while a shared reference &r T
is live, every other reference to it is also shared, and nothing
through any of them writes. This rule is stated over lifetimes,
not over control flow — it never says “and no other call frame
touches it,” because in a single-threaded language every other call
frame is either an ancestor (blocked on this one returning) or has
already returned. A second OS thread is the first call frame that is
neither: it can run while the spawning frame is also running.
That sounds like a gap. It is not one, provided exactly one thing
holds: payload’s references cannot escape join. If a
reference &r T inside payload is only valid because a borrow r
block is open in the spawning function, and join on that thread’s
handle must run before that borrow r block ends, then the two
“threads” of execution are, from the aliasing rule’s point of view,
just two readers of r whose lifetimes are both nested inside the
same open block — indistinguishable from two ordinary calls each
taking &r T, which the rule already allows and already forbids a
concurrent unique reference to. The rule was never about time; it
was about which reference exists when. Enforcing “the handle cannot
outlive the region its payload mentions” is exactly
linearity-and-effects.md §5 rule 4, the escape check
Type::mentions already performs (crates/cancho-types/src/lib.rs)
— applied to Thread[R]’s own type the same way it is applied to a
borrow block’s result today. No new check; the existing one, asked
about one more kind of value.
What this buys, precisely:
- A shared reference (
&r T) may cross intopayload. Two readers, one on each OS thread, is what the shared-reference rule already permits — it is unsound only if a writer could run concurrently, and the rule already refuses to admit a unique reference exists while a shared one is live, regardless of which thread holds either. - A unique reference (
&!r T) may cross intopayload, and then the spawning function holds no reference to that value at all untiljoinreturns it back (ifRcarries it out) — this is not a new rule either; it is what “moved” already means for a unique borrow’s own referent once the borrow ends, applied to the borrow itself. - A capability may cross into
payload, checked by the existing authorisation rule (reach.md§8.4): the spawnedbody’s row is whatever the capability insidepayloadauthorises, and the spawning function no longer holds that capability (moved), so it cannot also use it — the same “authority is a resource, spent once” rulelinearity-and-effects.md§4 already states forrelease.
4. What this does not solve
Rc[h] Tmust not cross — moot today, checked rather than assumed.linearity-and-effects.md§9 names it “non-atomic reference count” as a deliberate, single-threaded simplicity trade. Two threads each holding anRcto the same value could race its increment/decrement, corrupting the count — the exact hazard the “non-atomic” word warns about, latent until something lets anRcreach a second thread. The exclusion needs no new check at all right now:Rcis not an implemented type in this compiler (sharing.md), socrosses_to_a_thread’s allowlist already excludes anything shaped like it, the same way it excludes every other multi-field type. Worth a real, narrow check and a fixture the dayRc(or an atomic variant of it) exists — not before, and not a hatch this document opens in the meantime.- A wider payload is
thread-payloads.md, which also records why a unique slice cannot simply be allowed (§3 there). - No shared mutable state. (Designed for one word at a time in
atomics.md.)&!r Tcrossing means the unique reference moved, not that two threads can now both mutate the same memory – and, per the correction in §3, a copied&!is refused at the secondspawn, so it cannot do exactly that either. AMutex-shaped capability — lock, get a unique reference scoped to the lock, unlock — is a real, separate design, parallel to howFs(prefix)turned “files” into a capability rather than a raw handle (filesystem.md). Not proposed here. - No cancellation, no thread-local state, no thread pool.
joinblocks until the thread returns; there is no way to ask it to stop early, and nothing about a pool of reusable threads is addressed. A pool isspawn/joincalled in a loop today, at whatever cost real OS thread creation has — measuring that cost is part of §5’s verification, not assumed here. - Does not make
pthread_createreachable. A program that specifically wants to call realpthread_create(to interoperate with a C library that spawns its own threads, say) still cannot, for exactly §1’s reason.spawnis this language’s own primitive, not a wrapper around the libc one — the runtime backing it may well callstd::thread::spawn(which itself callspthread_create) in Rust, but that call is the compiler’s to make, never the program’s.
5. Verification plan
function-values.md §2’s bar — two real askers — is not yet met by
counting, the way it was for a decision that already answered
no. The verification here has to be earned by building the
primitive and using it, not by counting existing programs against a
feature that does not exist yet for any of them to ask for.
- Function values, minimal slice. Built (#127).
Type::Fn,Expr::FnValue/Expr::CallIndirect, on both backends —tests/accept/function_value.cho. spawn/join, no shared data. Built.tests/accept/ spawn_join.cho: a thread that receives an ownedint, computes on it with no capability, and returns anintthroughjoin. Verified by checking two things directly rather than trusting the type checker alone (tests/accept/spawn_thread_ids.cho,tests/accept/spawn_parallel_sleep.cho, checked on both backends incrates/cancho/tests/conformance/backends.rs): distinct OS thread IDs (pthread_self, declared the ordinaryextern fnway — nogettidneeded, since a thread ID here is only ever compared, never printed as the kernel’s own number) and wall-clock evidence of real parallelism (four independent 200ms sleeps, joined, finish in about one sleep’s time, asserted well under two, not four) — the same “measured, not argued” standardreach.mditself is held to. The same fixture doubles as §3’s “shared reference crosses into more than one spawn, and the spawning side still reads it after everyjoin” case: one borrowedFfi("libc")capability ispayloadfor two threads and is read a third time bymainonce both are joined.tests/reject/spawn_handle_escapes_borrow.chochecks §3’s soundness argument itself: aThread[&r int, int]handle returned out of theborrow rblock that openedr, withoutjoining first, is refused by the existingreference-escapes-regioncheck — no new rule, exactly as §3 predicted.tests/reject/ spawn_payload_type_not_supported.chochecks this slice’s own wall: a multi-field struct payload is refused byRule::ThreadPayloadTypebefore it ever reaches codegen.spawn/joinwith a moved capability. Built — and this step’s own original text was wrong. It assumed an ownedFile/Iois a multi-field struct under the hood needing the same trampoline the struct-payload case does; checked againstabi::leaves_intoon both backends before writing any code,Fileturns out to be exactly one leaf (the fd,PRELUDE_FILE’s own dedicated arm) and every zero-field capability (Io,Ffi,Fs,Args,Heap) is exactly zero — the same shapes this slice’s codegen already handles forintand(), so the fix was a widercrosses_to_a_threadallowlist, not a trampoline.tests/accept/spawn_owned_io.chomoves an ownedIointo a thread that writes to the real console (a zero-leaf capability,worker’s own row correctly[]since owningIooutright dischargesio_write,defs.rs’sdischarged_by);tests/accept/spawn_owned_file.chomoves an owned, already-open_readFileinto a thread that reads it and closes it there (one real leaf, the fd). Both checked on both backends (the_two_backends_agree_on_spawn_owned_io/_file,crates/cancho/tests/conformance/backends.rs). Left genuinely unbuilt: a capability shaped like a real multi-field struct — none of today’s capabilities are, so this step’s trampoline-free answer may not generalise pastFile/Io’s own accidentally-simple shape.spawn/joinwith a shared reference across the join. The shared-reference half is built, folded into step 2 above. Its companion — atests/reject/fixture proving a unique value cannot be read from the spawning side beforejoin— is built as the owned-capability case’s own mirror rather than a separate reference fixture:tests/reject/spawn_owned_capability_reused.chomoves an ownedIointospawnand thenreleases it again from the spawning function, refused by the pre-existinglinear-use-after-moverule with no thread-specific rule added, confirming §3’s “the existing rule, not a new one” claim by running it rather than only stating it.- The
Rcexclusion — measured, decided not yet, not left open. Cannot be built today:Rc[h] Tis not an implemented type in this compiler at all, so no fixture can construct the payload this step needs to refuse, andcrosses_to_a_threadalready excludes anything of that shape structurally with no code written for it specifically. The step this document actually owes an answer to is broader — does a multi-field spawn payload (Rc-shaped or otherwise) have a real asker anywhere today — and that was checked directly rather than left as a standing TODO: a repo-wide search across all four reachable repositories (cancho,lex-os,lex-lang,lex-gpu) found none.lex-os-guest’s reasoning loop (the concrete production target §2’s own status note names) is single-threaded by design, with no comment or TODO anywhere in it wanting concurrency.lex-lang’s own, independently-designedconc.spawn_threadanswers the same “how does a background task get its state” question with a captured, zero-argument closure rather than an explicit payload — evidence, not proof, that this problem shape does not actually want a fat payload even where a language’s design is free to offer one.AGENTS.md§7’s rule (a feature earns its way in when a program asks) applies as written: not built, and not because of missing infrastructure alone.
Corrected (
parallelism.md§3.4): a multi-field payload does not need the trampoline when it is passed by reference. A unique reference to a struct, including one that owns aBox[[int]], is one pointer leaf and crosses today on both backends (tests/accept/spawn_struct_ref.cho); the trampoline is needed only for an owned struct. Two further findings from the same probes: each spawn needs its own function value (oneworktaken once is instantiated at the first borrow’s region, and the second reference “does not outlive” it); andNet(..)andClockwere refused as payloads althoughcrosses_to_a_thread’s own comment listsNetamong the zero-field capabilities, which was an allowlist omission, not a design position: both are admitted now (tests/accept/spawn_owned_net.cho,spawn_owned_clock.cho).
Steps 1 through 4 are built. Step 3’s struct-shaped-capability half and
step 5 are measured and decided not yet, the same verdict
self-hosting.md reached the same way: nothing found is a hard
blocker (the trampoline is buildable, Rc is buildable), but nothing
asks for either today. Revisit both together — the trampoline is one
piece of infrastructure whether the payload that needs it is Rc-shaped
or any other multi-field struct — the day a concrete asker exists,
rather than on a schedule.
6. aarch64 Linux: the thread counter has to be aligned
spawn and join move a counter of unjoined threads up and down
atomically (docs/signals.md section 3: a claim is refused while one
runs). It is the second word of lexs_signal_state, and it is the
only atomic in the compiler’s output. On aarch64 an atomic is an
exclusive pair (ldaxr/stlxr), and an exclusive access to an address
that is not a multiple of its size faults with SIGBUS regardless of
the alignment-check bit that lets ordinary loads through.
Measured on Debian trixie aarch64 (colima, kernel 6.8, rustc
1.98.1, clang 19.1.7), on origin/main at 906ad24:
tests/accept/spawn_join.chobuilt with--backend craneliftexits 135 (SIGBUS) and prints nothing; with--backend llvmit prints42and exits 0. The fault is at theldaxrofcount_thread, on address0x…60049.nm -non the Cranelift binary: glibc’scrtstuff.oputs a one-bytecompleted.0at the start of.bss(0x20030), and the compiler’s four zeroed objects follow it with no padding:lexs_argcat0x20031,lexs_argvat0x20039,lexs_fd_epochat0x20041,lexs_signal_stateat0x60041..bssitself is aligned2**0. Cranelift’sDataDescriptiondefaults to an alignment of one, andcancho-codegennever set one.- The same compiler on macOS arm64 emits the same
ldaxr/stlxrloop, and the program prints42:ld64starts__bsson a page and puts nothing of its own ahead of these objects, so they land on0x100008000,…8008,…8010and…48010. Aligned by luck, not by request. - The LLVM backend was never affected: it declares the same object as
internal global [2 x i64], which hasi64’s alignment by type. - x86-64 Linux has the same
completed.0in front, so the counter is misaligned there too (inferred from the samecrtstuffand the same unaligned.bss; not re-measured on an x86 host).lock xaddaccepts an unaligned operand, so the bug is only latent there.
So this was not the LLVM backend’s thread entry, the ABI, clang 19 or
pthread linking. Fourteen of the fifteen conformance failures reported
on aarch64 Linux (backends::…spawn_*, …fork_*,
…a_join_as_an_operand, spawn_and_join_run_concurrently_not_sequentially,
and the two signals:: thread tests) are this one fault: each runs a
Cranelift binary that spawns, and assert_backends_agree puts LLVM’s
42 on the left and the crashed Cranelift run’s empty output on the
right. All fourteen fail before the change and pass after it, on the
same container.
The fifteenth, differential::the_folder_agrees_with_the_backend, is a
different thing and passes on the unchanged compiler. The reproduction
ran under sh -c 'ulimit -c 0 && …', and dash’s ulimit lowers the
hard limit too (ulimit -Hc reads 0). The test’s pre_exec then asks
for RLIMIT_CORE = 1 (differential.md §3.2), which needs raising the
hard limit, and gets EPERM, so spawn fails with “Operation not
permitted” before the program runs. It passes with the hard limit left
alone or with ulimit -Sc 0. The harness now leaves a hard limit of
zero as it is, because skipping the dump is only a saving.
The rule: every data object the Cranelift backend defines states
its alignment. Word data (the zeroed globals, and a static whose
elements are 8-byte leaves) is aligned to 8, which is the size of a
leaf (layout.md §1) and what LLVM already gives the same objects.
Byte data (string literals, a static of byte) stays at 1, so
nothing grows. cancho-codegen’s
every_word_global_is_aligned_to_a_word reads the alignment back out
of both the ELF and the Mach-O object, so the check runs on every host
CI has, including the two where the misalignment never faults.
7. A region per unit of work does not scale with threads on macOS
Measured on an Apple M4 Max (16 cores), macOS 26, from a table reader
(cancho-table, docs/numbers.md) that opens one region per cell: on a
1M-row file 1 thread took 1.09 s and 16 took 2.15 s, where Linux scaled.
Cause: the system malloc on a request over 32 KiB, not the region
code. A region is one malloc(ARENA_CHUNK) and one free
(region_stmt: “one malloc in, one free out”), and ARENA_CHUNK is
64 KiB. libmalloc serves requests up to 32 KiB from per-thread magazines;
above that (measured: 32,768 bytes is fast, 33,000 is not) each
malloc/free goes to the kernel’s VM allocator, which is cheap on one
thread (about 0.5 µs a pair) and, with the same loop on every thread,
serialises: the time is almost all system time. That a lock inside the
VM allocator is what they queue on is an inference from the sys time and
from mmap/munmap behaving the same, not something profiled below
that.
| what | 1 thread | 8 threads | 16 threads |
|---|---|---|---|
C, malloc(64 KiB); free, 100,000 per thread, wall | 0.05 s | 1.28 s (sys 6.9 s) | |
| the same, 200,000 per thread | 0.11 s | 5.74 s (user 16 s, sys 62 s) | |
| the same at 32,768 bytes | 0.00 s | 0.00 s | |
| the same at 33,000 bytes | 0.05 s | 1.34 s (sys 3.8 s) | |
C, mmap/munmap 64 KiB, 100,000 per thread | 0.15 s | 2.56 s (sys 13 s) | |
| C, one chunk per thread kept and reused | 0.00 s | ||
cancho build --backend cranelift, 300,000 regions, 80 ints each, per thread | 0.40 s | 4.69 s (sys 20.5 s) | 8.59 s (user 20.7 s, sys 41.0 s) |
Same work per thread, so a flat row would be linear scaling: it is 21
times slower at 16 threads. The cliff sits between 32,768 and 33,000
bytes and the size does not matter above it (49 KiB, 64 KiB, 128 KiB, 1
MiB are alike), so mmap is not a way out either. With
--backend llvm (the default) a toy loop whose arena never escapes does
not show it, because LLVM deletes a malloc/free pair it can see
through (0.22 s at 1 and at 16 threads); a region whose chunk is passed
to a call it cannot see through, which is any real reader, pays in full.
Not fixed here, and why. Both ways to fix it change what a program
means or need machinery this repository does not have: a chunk of 32 KiB
or less makes every program that allocates between 32 and 64 KiB in one
region trap (allocating_past_an_arenas_chunk_traps_with_sigill is the trap), and a
per-thread chunk that a region takes and gives back needs thread-local
storage in both backends, a thread-exit hook to free it, and a rule for
nested regions, which is a design and not a patch. What a program can do
today is open one region per batch and not one per item (a row’s
cells, a block of 1,000 rows), which turns the cost into one lock
acquisition per batch; on macOS --threads with a region per cell should
be left off until a chunk cache exists. The reproduction is in
scripts/region_scaling.c.
Fixed (region-chunk-cache.md, #375): each thread keeps the chunks its regions give back (at most 16). The same shape of
program, 300,000 regions a thread, Cranelift, on this M4: 0.03 s at 1 thread, 0.03 s at 8 and 0.04 s at 16, where it was
0.18, 3.82 and 8.76 s (§8.4 there). One region per batch is no longer needed for speed.