Repository navigation
Native automatic erasure progressively slows repeated wrapped-list allocation; explicit traversal stays fast #1307
Description
Summary
On darwin arm64, native Bend 2.0.35 repeatedly building and automatically erasing the same affine list of wrapped one-word arrays progressively slows down. No bend-kit libraries, JSON, sockets, parallel calls, unsafe code, or background input are needed. This also reproduced on 2.0.34.
A structural traversal that explicitly matches the same wrappers before erasing their buffers stays roughly constant. The following is a performance regression within one process, not a correctness assertion or an established explanation of cache/allocator internals.
Reproduction
Save as repro.bend:
# Base-only allocation/disposal control, with no bend-kit imports.
import Base
type Blob is Type:
Blob{size: U32, buffer: Array<U32>}
type Item is Type:
Item{blob: Blob}
def build(count: Nat, acc: List<&1, Item>) -> List<&1, Item>:
match count:
case 0n:
acc
case 1n+rest:
build(rest, Item{Blob{1, Array.new(U32, 0n, 48)}} <> acc)
def elapsed(label: String, start: Nat) -> IO(Unit):
do IO<Unit>:
end : Nat <- IO.now()
IO.print(label ++ " " ++ Nat.show((end - start : Nat)))
def erased(value: List<&1, Item>, start: Nat) -> IO(Unit):
elapsed("DISPOSE_MS", start)
def retained(value: List<&1, Item>, +output: Chan(List<&1, Item>), start: Nat) -> IO(Unit):
do IO<Unit>:
sent : Bool <- Chan.send(List<&1, Item>, output, value)
elapsed("BUILD_MS", start)
disposal : Nat <- IO.now()
received : List<&1, Item> <- IO.join(List<&1, Item>, output)
erased(received, disposal)
def measure(nodes: Nat) -> IO(Unit):
do IO<Unit>:
output0 : Chan(List<&1, Item>) <- Chan.new(List<&1, Item>, 1)
+output : Chan(List<&1, Item>) = output0
start : Nat <- IO.now()
retained(build(nodes, Nil{}), output, start)
def repeat(count: Nat, +nodes: Nat) -> IO(Unit):
match count:
case 0n:
IO.print("DONE")
case 1n+rest:
do IO<Unit>:
measure(nodes)
repeat(rest, nodes)
def main() -> IO(Unit):
repeat(24n, U32.to_nat(524283))
bend version # bend 2.0.35
bend repro.bend -o repro
./repro --threads 1 --gpu off
Environment: macOS 26.6.2 (25G83), arm64. The program repeats 24 times and builds 524,283 elements per iteration. IO.now stage durations are milliseconds. Channel send/receive prevents erasure before the build timestamp.
Expected
Identical build/drop cycles should not progressively become tens of times slower while the previous list is reclaimed and no payload remains retained. I would expect comparable steady-state costs, allowing normal timing variation.
Observed
On 2.0.35, BUILD_MS starts at 6, 4, 14 ms and later reaches 152, 178, 265, 245, 312 ms. DISPOSE_MS starts at 10 ms and later includes 296, 212, 515 ms. The full sequence is retained in the kit attribution dataset.
Controlled comparison
Replace erased with:
def erase(value: List<&1, Item>) -> Unit:
match value:
case Nil{}:
Unit{}
case Con{Item{Blob{size, buffer}}, rest}:
erase(rest)
def after_erase(value: Unit, start: Nat) -> IO(Unit):
elapsed("DISPOSE_MS", start)
def erased(value: List<&1, Item>, start: Nat) -> IO(Unit):
after_erase(erase(value), start)
On the same compiler and machine, BUILD_MS stays 3–5 ms after the initial 5 ms; DISPOSE_MS stays 3–4 ms except one 7 ms sample, over all 24 repeats. The program still constructs, retains, receives, and disposes the same list. It adds a structural traversal rather than changing the payload shape.
A simpler List<Array<U32>> without wrappers also stays fast. Holding 126 MiB of independent Base arrays is not necessary for the degradation. Removing List.reverse does not remove it (this minimal reproduction has no reversal).
The JSON investigation which led here showed a native profile with 1880 of 4247 sampled main-thread stacks in term_drop; that profile is from the larger isolated JSON probe, not the minimal file. Generated native heap_free pushes nodes on a size-class free list, and automatic term_drop walks/reclaims children before their containers; allocation/reclamation order and locality are a hypothesis, not yet proven.
Application consequence
Our JSON result has many nested wrappers and exact-number byte arrays. On one 2.0.35 binary with selectable disposal modes, a 126-body live batch with automatic disposal took 46.6/76.4/78.9 seconds for the ordinary request; explicit disposal took 4.65 seconds in a fresh process immediately afterward (and 3.18/3.70/4.06 seconds in three preceding trials). Automatic cost is unstable, so this is not a stable speedup claim or a claimed compiler-release regression. The intervention is not a general decoder fix or a scheduler fix. We are retaining the reproduction instead of introducing a special-case production workaround.
Activity