Persistent tree: measure the cost of path copying
Measure your machineNeeds Python
Problem
A persistent data structure never changes a node in place: an insert copies the path from the root to the new leaf and returns a new root, while every untouched subtree is shared with the old version. Every old root is therefore a consistent snapshot that readers can keep using without locks, at the price of allocating a path's worth of nodes per write and leaving old nodes for the collector.
Implement the persistent insert from section 3 of Concurrency-friendly structures, and a normal mutable binary tree. Insert 1 million random keys into each. Measure time and memory with tracemalloc. Then hold the root after 500,000 inserts as "snapshot A", insert the remaining 500,000, and check that a lookup on snapshot A still sees only the first half. Finally, count how many nodes the final tree shares with snapshot A.
The question. How much slower and larger is the persistent tree, does the snapshot stay frozen, and how much of it does the final tree share?
Predict
Expect the persistent version to be 2 to 10 times slower and larger on one thread.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
persistent-tree-measure-the-cost-of.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Persistent tree: measure the cost of immutability. 1 million random keys into a persistent (path-copying) binary search tree and into a mutable one: time and memory. A snapshot held after 500,000 inserts still sees only the first half; then the nodes the final tree shares with that snapshot. """ import argparse, json, random, sys, time, tracemalloc p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--keys", type=int, default=1_000_000, help="Keys inserted.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) sys.setrecursionlimit(10_000) keys = random.sample(range(10 * args.keys), args.keys) class Node: __slots__ = ("k", "l", "r") def __init__(self, k, l=None, r=None): self.k, self.l, self.r = k, l, r def p_insert(n, k): # persistent: copy the path, share the rest if n is None: return Node(k) if k < n.k: return Node(n.k, p_insert(n.l, k), n.r) if k > n.k: return Node(n.k, n.l, p_insert(n.r, k)) return n def m_insert(root, k): # mutable: change one pointer in place if root is None: return Node(k) n = root while True: if k < n.k: if n.l is None: n.l = Node(k); return root n = n.l elif k > n.k: if n.r is None: n.r = Node(k); return root n = n.r else: return root def contains(n, k): while n: if k == n.k: return True n = n.l if k < n.k else n.r return False rows = [] for name, ins in (("mutable", m_insert), ("persistent", p_insert)): tracemalloc.start(); t = time.perf_counter(); root = None; snapshot = None for i, k in enumerate(keys): root = ins(root, k) if i == args.keys // 2 - 1: snapshot = root dt = time.perf_counter() - t; peak = tracemalloc.get_traced_memory()[1]; tracemalloc.stop() rows.append({"tree": name, "seconds": dt, "peak_mb": peak / 1e6}) if name == "persistent": p_root, p_snap = root, snapshot print(f"{name:<11} {dt:6.2f} s peak memory {peak / 1e6:7.1f} MB") first, second = keys[: args.keys // 2], keys[args.keys // 2:] sees_first = all(contains(p_snap, k) for k in first[:10_000]); sees_second = any(contains(p_snap, k) for k in second[:10_000]) def ids(n, acc): stack = [n] while stack: x = stack.pop() if x: acc.add(id(x)); stack += [x.l, x.r] return acc final_nodes, snap_nodes = ids(p_root, set()), ids(p_snap, set()) shared = len(final_nodes & snap_nodes) print(f"snapshot after {args.keys // 2:,} inserts sees the first half: {sees_first}; sees any of the second: {sees_second}") print(f"the final tree shares {shared:,} of the snapshot's {len(snap_nodes):,} nodes ({shared / len(snap_nodes):.0%})") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"trees": rows, "summary": [{"slowdown": rows[1]["seconds"] / rows[0]["seconds"], "memory_x": rows[1]["peak_mb"] / rows[0]["peak_mb"], "sees_first": "yes" if sees_first else "no", "sees_second": "yes" if sees_second else "no", "shared": shared, "snapshot_nodes": len(snap_nodes), "shared_pct": shared / len(snap_nodes) * 100}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run persistent-tree-measure-the-cost-of.py uv run persistent-tree-measure-the-cost-of.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --keys1,000,000 | Keys inserted. |
| --seed0 | Random seed. |
Our run
One million inserts
| Tree | Time | Peak memory (MB) |
|---|---|---|
| mutable | 4.29 s | 56 |
| persistent | 27.6 s | 80.8 |
mutable 4.29 s peak memory 56.0 MB
persistent 27.60 s peak memory 80.8 MB
snapshot after 500,000 inserts sees the first half: True; sees any of the second: False
the final tree shares 56,993 of the snapshot's 500,000 nodes (11%)What we found
The persistent tree took 27.6 s against 4.29 s, 6.43× slower, inside the expected range; its peak memory was 1.44× the mutable tree's, less than expected, because the copied paths of past versions become garbage once nothing holds their roots. Snapshot A saw the first half (yes) and none of the second (no). The final tree shared 57K of snapshot A's 500K nodes (11.4%): 500,000 random inserts touched paths through most of the old tree.
Read it
A snapshot for free at read time is paid for at write time, with a path copy per insert. The sharing that makes it cheap depends on the write pattern: random keys touch paths everywhere, so after many writes little of an old version is still shared, and holding an old snapshot keeps nearly a whole tree's worth of nodes alive.
This is the same trade as MVCC and copy-on-write B-trees: readers never block, and the cost moves to allocation and to whatever reclaims the versions no one reads any more.