Skip to content

fix(compiler): hand last fork readers counted ownership together - #1521

Closed
oxura wants to merge 1 commit into
bendlang:mainfrom
oxura:fix/issue-1512-parallel-tree-drop-20261012
Closed

oxura wants to merge 1 commit into
bendlang:mainfrom
oxura:fix/issue-1512-parallel-tree-drop-20261012

Conversation

@oxura

@oxura oxura commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #1512.

The fork emitter retained a locally owned tree until its last borrowed readers joined, then erased the entire tree in one lane. Last fork readers now receive counted ownership together through the existing val_own fact gate. Argument materialization accounts for the still-pending calls and continuation, so earlier readers obtain their references before the last argument consumes the original. The obsolete fork-specific argument-counting mode is removed. A retained continuation owner remains live; an outside borrow follows the existing loan propagation. No one-owner/one-borrower handoff, leak, unsafe mode, GPU guard bypass, or new runtime scheduler.

Permanent lifetime coverage uses two different recursively forked readers, a returned tree consumed after the join, and a bang. Existing mixed consumer/dead-reader/array/fork cases remain. Obsolete comments promising a whole-value join sink were updated; hand-written GUIDE.md, CHANGELOG.md, protected bend.ts and all gates/caps are unchanged.

Real behavior proof

Linux x86_64, Bun CLI 1.3.13, Node v22.23.1, clang 22.1.8. Refreshed onto main 381dc877, including the merged #1509 budget reduction and #1506 number seam.

  • Original issue program, mode 0, depth 20, 25 rounds, 8 CPU workers. Seven interleaved before/after pairs, alternating order: current-main median 552.711 ms, fixed median 278.720 ms. Every run prints 4268752896. These are local CPU whole-process measurements, not GPU/kernel timings; all samples retained, including two slower early fixed runs.
  • The generated mode-0 join previously captured the tree and executed term_sink(e, _h_3); the fixed join captures only the two U32 results. The readers own and free their references instead; the sink was not merely deleted.
  • Fourteen lifetime/fork programs were built to native C and each executed with 1 and 8 workers: 28/28 expected outputs. The new program also ran through the real CLI and standalone Node, printing 6144, 11264, 6144.
  • Final current-main code compiled with clang -std=c11 -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer ... -lpthread -lm; original shared-tree workload at depth 16, 40 rounds, 8 workers prints 4292345856, with no sanitizer findings observed. The custom heap pool means ASan alone does not prove zero pool leaks.
  • bun gates/repo.ts: 54/54. Existing 64,000-token compiler cap unchanged.

Exact workload command: ./fork_drop 0 20 25 --gpu off --threads 8. Safety commands: bend tests/reg/fork_last_readers_tree.bend -o safety, then ./safety --gpu off --threads 1 and --threads 8.

Limits and tradeoff

No CUDA/Metal execution or GPU performance claim; an actual --gpu on attempt reports that this Linux binary has no CUDA or Metal support. The 48-mini test/perf gates and graphical shader benchmarks were not run. Borrow/own calling convention is inferred per definition, so ownership facts can affect other calls to a reader; retained-owner and mixed-reader paths are covered locally, but device performance still requires upstream hardware qualification. No flaky permanent wall-clock threshold was added: interleaved benchmark evidence covers the performance change; permanent regressions cover consumer-visible lifetime correctness.

@Lorenzobattistela Lorenzobattistela self-assigned this Oct 13, 2026
@Lorenzobattistela

Copy link
Copy Markdown
Collaborator

Thanks for the careful work here. The change is memory-safe in everything we tried, and it does remove the join sink from #1512 (on Metal at depth 20, 8.6 s down to 0.11 s).

We're closing it anyway, because it goes against a deliberate rule of the borrow inference (9015fe1): "a fork's join keeps a rooted read only when the body uses it". Readers of a fork borrow, and the owner lets go after the join. Making the last readers owners changes that for the whole program. Ownership is decided once per def and counting once per type, so every call of those readers and every node of that type pays for it. On the cluster (M4, median of 7 interleaved runs):

None of this shows up in the perf gate, because no bench's C changes.

The fast path already works under the current rule: free the tree after the join with a forked walk (a b = walk(t) walk(t), then drop(t), where drop matches and forks). The readers keep borrowing and nothing gets counted. At depth 22 that takes 0.19 s on Metal, against 0.34 s with this PR and 34 s before. If the compiler should do this on its own, the way to go is a parallel sink at the join, not owned readers.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A tree borrowed by both tasks of a fork is freed by one lane at the join, and on CUDA that serial term_sink takes most of the device time

2 participants