Skip to content
About
Main content

Persistent tree: measure the cost of path copying

Measure your machineNeeds Python

Problem

A 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 , at the price of allocating a path's worth of nodes per write and leaving old nodes for the .

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

On one thread, how much slower is building the persistent tree than the mutable one?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    persistent-tree-measure-the-cost-of.py82 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--keys1,000,000 Keys inserted.
--seed0 Random seed.

Our run

One million inserts

One million inserts
TreeTimePeak memory (MB)
mutable4.29 s56
persistent27.6 s80.8
Reference runApple M2 · 8 cores · 8 GB
Output · uv run persistent-tree-measure-the-cost-of.py
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%)
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

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 and : readers never block, and the cost moves to allocation and to whatever reclaims the versions no one reads any more.