Skip to content

Native automatic erasure progressively slows repeated wrapped-list allocation; explicit traversal stays fast #1307

Closed

Description

@paymog

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions