Repository navigation
Description
What you did
bend fork_drop.bend -o fork_drop.c
What happened
Everything below uses the published bend-2.0.36-linux-x64.tar.gz (sha256 02089dc0eed0fd5fd6d73c74cc9cffcb2c6638dd3b02ae83f7b8cc57fbe381ba) on WSL2, Ubuntu 24.04. fork_drop.bend is the file at the end of this report. mode 0 passes one tree as a + parameter to a def whose two fork tasks only read it (a b = walk.a(t) walk.a(t)). mode 1 builds the tree twice and gives each task its own copy. Both modes print the same number.
tar -xzf bend-2.0.36-linux-x64.tar.gz
bend version # bend 2.0.36
bend fork_drop.bend -o fork_drop.c
CUDA_HOME=/usr bend fork_drop.bend -o fork_drop # Ubuntu's CUDA packages put nvrtc.h in /usr/include
./fork_drop 0 20 1 --gpu off # CPU
./fork_drop 0 20 1 --gpu 4GB
bend: CUDA device lacks concurrent managed access (WSL2 lacks it)
WSL2 has no concurrent managed access, so the binary that the CLI builds refuses the GPU. To reach the CUDA path I changed one host line of the emitted C, if (managed == 0) { in gpu_probe, to if (0 && managed == 0) {, and built the copy with the same two steps that bend -o runs (the clang line and --gpu-build). The device code is unchanged. On a Linux machine with concurrent managed access, bend fork_drop.bend -o fork_drop and ./fork_drop <mode> <depth> <rounds> --gpu 4GB should be enough.
sed 's/^ if (managed == 0) {$/ if (0 && managed == 0) {/' fork_drop.c > fork_drop_wsl.c
clang-19 -DBEND_CUDA=1 -I/usr/include -L/usr/lib64 -L/usr/lib -std=c11 -O3 fork_drop_wsl.c
-lpthread -lm -o fork_drop_wsl -lcuda -lnvrtc
./fork_drop_wsl --gpu-build
To check the cause, I also built a copy without the term_sink line of the mode 0 join. It leaks the tree and prints the same result.
sed '/WL_CASE(FID_ROUND_J43)/,/^ }}/{/term_sink(e, _h_3);/d}' fork_drop_wsl.c > fork_drop_nosink.c
then the same clang line and --gpu-build
Wall time, one run per line, with fork_drop for --gpu off and fork_drop_wsl for --gpu 4GB:
for depth in 18 20; do for gpu in off 4GB; do for mode in 0 1; do for rounds in 1 9; do
bin=./fork_drop; [ $gpu = off ] || bin=./fork_drop_wsl
t0=$(date +%s%N); out=$($bin $mode $depth $rounds --gpu
echo "depth=$depth gpu=$gpu mode=$mode rounds=$rounds result=$out wall_ms=$(( (t1 - t0) / 1000000 ))"
done; done; done; done
Device time, with Nsight Systems 2026.3.2, 3 rounds per run and 2 runs of each binary and mode:
nsys profile --trace=cuda --cuda-um-cpu-page-faults=true --cuda-um-gpu-page-faults=true
-o m0_d20 ./fork_drop_wsl 0 20 3 --gpu 4GB
nsys stats --report cuda_gpu_trace,cuda_gpu_mem_time_sum,um_total_sum m0_d20.nsys-rep
Every round runs 8 bend_dev launches. The seventh holds the join. Removing the term_sink line takes it from hundreds or thousands of milliseconds to tens, and in mode 0 every other launch stays under 210 ms, with or without the line. The table gives the device time from cuda_gpu_trace as the range over 6 rounds (2 runs of 3 rounds):
| CUDA | launch with the join | device time per round |
|---|---|---|
depth 18, mode 0 (+t read by both tasks) |
429 to 496 ms | 468 to 551 ms |
| depth 18, mode 1 (two copies) | 71 to 86 ms | 182 to 398 ms |
depth 18, mode 0 without the term_sink |
33 to 42 ms | 113 to 264 ms |
| depth 20, mode 0 | 1,804 to 7,173 ms | 1,853 to 7,386 ms |
| depth 20, mode 1 | 71 to 300 ms | 166 to 824 ms |
depth 20, mode 0 without the term_sink |
21 to 24 ms | 86 to 116 ms |
At depth 18 the free costs about 0.4 s of device time in every round. At depth 20 the tree has 2^21 - 1 nodes, and the free takes 1.8 to 7.2 s per round, varying between runs. Without the term_sink, both tasks still read every node of the same tree, and the launch with the join takes 21 to 42 ms. Mode 1 builds twice as many nodes and still uses less device time per round than mode 0.
On WSL2, Nsight recorded no Unified Memory migrations or page faults in any run. um_total_sum finds no transfer data, and the only memory operation is one 143 MB [CUDA memset] on managed memory at startup. The numbers above are kernel durations. Whatever WSL2 does to move managed pages happens outside the launches or is not visible to Nsight, and the control without the term_sink reads the same pages in the same launches.
Wall time from the second run, on an idle machine. The time per round is (9 rounds - 1 round) / 8, which removes startup and the NVRTC load:
| 1 round | 9 rounds | per round | |
|---|---|---|---|
| depth 18, CUDA, mode 0 | 3,737 ms | 9,500 ms | 720 ms |
| depth 18, CUDA, mode 1 | 2,238 ms | 4,239 ms | 250 ms |
| depth 18, CPU, mode 0 | 9 ms | 37 ms | 3.5 ms |
| depth 18, CPU, mode 1 | 8 ms | 18 ms | 1.3 ms |
| depth 20, CUDA, mode 0 | 6,360 ms | 32,783 ms | 3,303 ms |
| depth 20, CUDA, mode 1 | 2,410 ms | 7,904 ms | 687 ms |
| depth 20, CPU, mode 0 | 18 ms | 100 ms | 10.3 ms |
| depth 20, CPU, mode 1 | 13 ms | 58 ms | 5.6 ms |
CUDA wall times on WSL2 vary a lot between runs. In the first run, with another job loading the CPU, depth 20 took 4,740 ms per round in mode 0 and 170 ms in mode 1. On the CPU, mode 0 is also about twice as slow as mode 1.
The emitted C shows the cause. shared is inlined into round, and only the join of mode 0 frees the tree:
WL_CASE(FID_ROUND_J43) // mode 0: a b = walk.a(t) walk.a(t)
{
Term _h_3 = r0;
u32 _a_2 = r1;
u32 _b_1 = r2;
WL_OPEN
term_sink(e, _h_3);
r0 = U32_BIN(_a_2, +, _b_1);
WL_RETN(1);
}}
WL_CASE(FID_ROUND_J47) // mode 1: p q = walk.b(a) walk.b(b)
{
u32 _p_2 = r0;
u32 _q_1 = r1;
WL_OPEN
r0 = U32_BIN(_p_2, +, _q_1);
WL_RETN(1);
}}
In mode 0, walk.a only matches its parameter, so it borrows, and both tasks read the tree without a count. The def that owns the tree frees it after the join with term_sink, which walks every node in the one lane that runs the join while the other lanes are idle. In mode 1 each copy has one reader, the reader owns it, and each task frees its nodes as it walks them, in parallel.
I found this in a larger program with the same shape. There the free at the join took about 2.8 ms of a 5 ms device step, and giving each task its own copy brought the step down to 1.8 ms.
Expected: when the last readers of a borrowed value are the tasks of a fork, the free after the join should not run in one lane. For example, the free could be split into tasks like other recursive work, or the compiler could give ownership to one of the two readers.
Related issues:
- Guide: what a shared +Data costs inside a parallel call is only documented in a demo's comments #836. The behavior here follows the borrowing rule in
guide/SHADERS.md(Ownership): "The compiler borrows a boxed parameter (not anArray) that the def only matches or passes to a borrower".walk.aborrows, and the owner frees the tree at the join. This report is not about the rule. It is about the cost of that free when it runs serially after a fork. - Native automatic erasure progressively slows repeated wrapped-list allocation; explicit traversal stays fast #1307 reports that the automatic drop gets slow over time while an explicit walk stays fast.
The file
import Base
# A tree passed as a `+` parameter to a def that only forwards it to both tasks of a fork,
# where each task only reads it. The def keeps the tree until the join and then frees all
# of it in the one thread that runs the join. With two copies, each read once, the readers
# own them and free each node as they walk, in parallel.
#
# Usage: ./fork_drop <mode> <depth> <rounds>
# mode 0: shared(+t): `a b = walk.a(t) walk.a(t)`, both fork tasks read the same tree
# mode 1: split(x): the tree is built twice and each fork task reads its own copy
# Each round builds a full tree of 2^depth leaves inside a `!` call, runs the fork and
# returns the sum of the leaves read by both tasks. Prints the total of all rounds, which
# is the same in both modes.
type Tree is Data:
Leaf{v: U32}
Node{l: Tree, r: Tree}
type Two is Data:
Two{a: Tree, b: Tree}
def gen(+d: Nat, +i: U32) -> Tree:
match d:
case 0n:
Leaf{i}
case 1n+p:
a b = gen(p, i) gen(p, (i + U32.shln(1, p) : U32))
Node{a, b}
def two.node(x: Two, y: Two) -> Two:
match x:
case Two{xa, xb}:
match y:
case Two{ya, yb}:
Two{Node{xa, ya}, Node{xb, yb}}
def two.leaf(+i: U32) -> Two:
Two{Leaf{i}, Leaf{i}}
def gen2(+d: Nat, +i: U32) -> Two:
match d:
case 0n:
two.leaf(i)
case 1n+p:
a b = gen2(p, i) gen2(p, (i + U32.shln(1, p) : U32))
two.node(a, b)
# Two copies of the same reader: it only matches the tree. The compiler decides per def
# whether a parameter is borrowed, so each variant below gets its own copy.
def walk.a(t: Tree) -> U32:
match t:
case Leaf{v}:
v
case Node{l, r}:
a b = walk.a(l) walk.a(r)
(a + b : U32)
def walk.b(t: Tree) -> U32:
match t:
case Leaf{v}:
v
case Node{l, r}:
a b = walk.b(l) walk.b(r)
(a + b : U32)
# The tree is a `+` parameter that the def only forwards to the two fork tasks.
def shared(+t: Tree) -> U32:
a b = walk.a(t) walk.a(t)
(a + b : U32)
# Each fork task gets its own copy, and nothing is shared.
def split(x: Two) -> U32:
match x:
case Two{a, b}:
p q = walk.b(a) walk.b(b)
(p + q : U32)
def round.go(c: Bool, +d: Nat) -> U32:
match c:
case True{}:
shared(gen(d, 0))
case False{}:
split(gen2(d, 0))
def round(+m: U32, +d: Nat) -> U32:
round.go(U32.is_eq(m, 0), d)
def rounds(k: Nat, +m: U32, +d: Nat, +acc: U32) -> U32:
match k:
case 0n:
acc
case 1n+p:
rounds(p, m, d, (acc + round!(m, d) : U32))
def rd(s: String) -> U32:
Maybe.default(&2, U32, U32.read(s), 0)
def run.go(a: String, b: String, c: String) -> String:
U32.show(rounds(U32.to_nat(rd(c)), rd(a), U32.to_nat(rd(b)), 0)) ++ "\n"
# Takes the last three arguments, whether or not IO.args starts with the program name.
def run.args3(a: String, b: String, t: List<String>) -> String:
match t:
case Nil{}:
""
case Con{c, u}:
match u:
case Nil{}:
run.go(a, b, c)
case Con{d, _}:
run.go(b, c, d)
def run.args2(a: String, t: List<String>) -> String:
match t:
case Nil{}:
""
case Con{b, u}:
run.args3(a, b, u)
def run.args(xs: List<String>) -> String:
match xs:
case Nil{}:
""
case Con{a, t}:
run.args2(a, t)
def main() -> IO(Unit):
do IO<Unit>:
args : List<String> <- IO.args()
IO.print(run.args(args))bend --version
The 2.0.36 CLI has no --version flag (bend: unknown option --version). bend version prints bend 2.0.36, from the published bend-2.0.36-linux-x64.tar.gz.
uname -sm
Linux x86_64 (WSL2, Ubuntu 24.04, on Windows 11; NVIDIA GeForce RTX 4090, driver 616.92; libnvrtc 12.0.140)
clang --version (the first line)
Ubuntu clang version 19.1.1 (1ubuntu1~24.04.2). This is clang-19, which bend -o picks for a GPU program because clang on this machine is 18.1.3.
Activity
NicolasCaous commentedon Oct 12, 2026
Some context on why this matters to me.
I'm building a game engine where Bend runs inside the graphics pipeline. One Bend program runs the game logic 60 times per second. Another Bend program runs every frame with the camera, to decide what to draw. The renderer draws what Bend writes straight from the GPU, without the CPU reading it back. I don't use the CLI runner. My engine loads the C that bend generates and runs it with its own runtime, tuned to be as fast as possible.
At 60 frames per second, each frame has 16.7 ms, and Bend has to share that time with rendering and everything else. So every millisecond matters. In my game logic program, Bend takes about 5 ms with the free at the join, around 30% of the frame. Without that free, and with my runtime tuned, it takes 0.87 ms, around 5%.
This one free is the difference between Bend eating a third of the frame and Bend being a small part of it. That's why I reduced it to the small file above.
It's possible this is on purpose. Freeing the whole tree in one place, right after the join, keeps ownership simple and makes sure nothing leaks, and maybe that's a safety choice of the language. If so, I'd ask for a way out for programs where performance comes first. Something like an unsafe mode, with separate switches for specific behaviors, so people like me who squeeze every bit can turn off one guarantee at a time and take responsibility for it. For this case, a switch could let me give the tree to one of the two readers, or free it in parallel instead of in one lane. Bend already has @unsafe for shared arrays, so a similar opt-in for this would fit.
oxura commentedon Oct 12, 2026
I have a candidate using the existing ownership-fact gate: all last fork readers of a locally owned boxed value take counted ownership together, while owners used after the fork remain retained. This avoids the unsafe one-owner/one-borrower handoff. The original CPU mode-0 repro has identical output and improved from 447 ms to 246 ms median in seven interleaved pairs (depth 20, 25 rounds, 8 workers); 28 serial/parallel native lifetime cases and a 40-round ASan/UBSan run passed. Finishing the permanent compiler-size gate and rechecking the compact final implementation. No CUDA/Metal execution or performance claim; no GPU guard bypass.
AbarnaaSree commentedon Oct 14, 2026
Hi @NicolasCaous and @oxura, I'd like to contribute to this issue. I noticed PR #1521 is already exploring the ownership fix. Is there a separate part that would be useful for me to investigate, such as CUDA-specific validation or additional regression tests? I'd be happy to work on a non-overlapping task.
Lorenzobattistela commentedon Oct 14, 2026
This is a real cost, so the issue stays open. Here's a workaround that already works under the current ownership rules.
The slow part is the serial free after the join: the owner releases the shared tree with one
term_sink, which walks the whole tree on one lane. You can do that free in parallel in your own Bend code. After the join, free the tree with a forked walk and a forked drop:a b = walk(t) walk(t), thendrop(t), wheredropmatches on the node and forks on the children.The readers keep borrowing, so nothing gets refcounted. On Metal at depth 22 this took 0.19 s, against 34 s before. You load the generated C into your own runtime, so this should work for you right away.
We won't add an unsafe opt-out. If the compiler should do this on its own, the fix would be a parallel sink at the join, which changes the CPU, CUDA and Metal runtimes. That's up to the maintainers. #1521 tried making the last readers owners instead; it was closed because it slowed other code by up to 3.2x (details there).