aiml
SearchMy learning
Menu

Lesson 6 of 20Module 2

Implement paged cache allocation and copy-on-write

Implement a paged cache allocator with reference counts and copy-on-write. Inspect ownership and fragmentation using concrete token markers.

Prerequisites

Python, basic causal attention, and the preceding inference lessons.

By the end, you can

  • Implement paged cache allocation and copy-on-write.
  • Explain the bottleneck, measurement boundary, and tradeoffs in this implementation.
  • Protect shared cache blocks with reference counts and copy-on-write.
Runtime
Python 3.12+
Dependencies
Standard library only

Lab code, tests and fixtures (ZIP, 105 KB)Quiz check your understanding

Progress is saved in this browser.

When you need this. A model server reserves a maximum-length buffer for every request. Short completions waste most of that reservation, so new requests are rejected while memory remains unused. Implement a block table that grows with the sequence.

What you run. Actual CPU block allocator with inspectable token markers. It teaches ownership and paging; it does not execute a GPU attention kernel.

This lesson builds on choose hardware from the workload bottleneck.

Follow block ownership

A block table maps logical sequence positions to physical blocks. A sequence can grow without one contiguous allocation. The last block may still be partly empty, so paging reduces fragmentation rather than eliminating it.

Shared prompt blocks use reference counts. Before modifying a shared tail block, copy-on-write allocates a private copy. Releasing one sequence decrements its references; storage becomes reusable only when the last reference goes away. These are ordinary allocator rules applied to inference state.

Logical sequenceBlock tableShared block poolpositionblock IDread stateLogical sequenceBlock tableShared block poolpositionblock IDread state
The return path represents state used by the next operation; the article distinguishes actual model state from the lab’s inspectable data structures.

PagedKvAllocator implements block allocation, sequence forks, appends, reads, and release. Its stored values are token markers used to inspect ownership, not GPU key/value tensors. The tests exercise actual mutation and verify that one branch cannot overwrite its sibling.

Exercise the block allocator

Download the lab from this page, extract it, and run these commands from its directory. The default command reads saved run records or runs the explicitly described local algorithm; it never downloads a model or calls a paid API.

Run the local analysis and checksTerminal · bash
python3 lab.py
python3 -m unittest -v test_lab.py
Read lab.py
lab.pySource · pythonDownload
"""Contiguous KV cache allocator vs PagedAttention block tables with copy-on-write."""

from __future__ import annotations

from dataclasses import dataclass, field
import json
import math


@dataclass
class ContiguousSlot:
    seq_id: str
    reserved_tokens: int
    used_tokens: int


class ContiguousKvAllocator:
    """Reserves a contiguous chunk of max_seq_len tokens per active request."""

    def __init__(self, total_token_capacity: int, max_seq_len: int) -> None:
        if total_token_capacity <= 0 or max_seq_len <= 0:
            raise ValueError("Capacity and max_seq_len must be positive integers")
        self.total_token_capacity = total_token_capacity
        self.max_seq_len = max_seq_len
        self.max_concurrent_sequences = total_token_capacity // max_seq_len
        self.active: dict[str, ContiguousSlot] = {}

    def allocate_sequence(self, seq_id: str, initial_tokens: int) -> None:
        if seq_id in self.active:
            raise ValueError(f"Duplicate sequence ID: {seq_id}")
        if initial_tokens <= 0 or initial_tokens > self.max_seq_len:
            raise ValueError(
                f"initial_tokens={initial_tokens} outside 1..{self.max_seq_len}"
            )
        if len(self.active) >= self.max_concurrent_sequences:
            raise MemoryError(
                f"Contiguous KV allocator out of slots: {len(self.active)}/{self.max_concurrent_sequences} in use"
            )
        self.active[seq_id] = ContiguousSlot(
            seq_id=seq_id,
            reserved_tokens=self.max_seq_len,
            used_tokens=initial_tokens,
        )

    def append_tokens(self, seq_id: str, count: int = 1) -> None:
        slot = self.active[seq_id]
        if slot.used_tokens + count > slot.reserved_tokens:
            raise MemoryError(
                f"Sequence {seq_id} exceeded max_seq_len={self.max_seq_len}"
            )
        slot.used_tokens += count

    def free_sequence(self, seq_id: str) -> None:
        self.active.pop(seq_id, None)

    def metrics(self) -> dict[str, object]:
        reserved = sum(s.reserved_tokens for s in self.active.values())
        used = sum(s.used_tokens for s in self.active.values())
        wasted = reserved - used
        waste_ratio = (wasted / reserved) if reserved else 0.0
        return {
            "active_sequences": len(self.active),
            "reserved_tokens": reserved,
            "used_tokens": used,
            "wasted_tokens": wasted,
            "waste_ratio": round(waste_ratio, 4),
            "utilization_of_total_pool": round(used / self.total_token_capacity, 4),
        }


@dataclass
class PhysicalKvBlock:
    block_id: int
    block_size: int
    ref_count: int = 0
    tokens: list[int] = field(default_factory=list)


@dataclass
class BlockTable:
    seq_id: str
    block_size: int
    logical_blocks: list[int] = field(default_factory=list)
    num_tokens: int = 0


class PagedKvAllocator:
    """Manages fixed-size physical KV blocks with reference counting and copy-on-write."""

    def __init__(self, total_token_capacity: int, block_size: int = 16) -> None:
        if total_token_capacity <= 0 or block_size <= 0:
            raise ValueError("total_token_capacity and block_size must be positive")
        if total_token_capacity % block_size != 0:
            raise ValueError("total_token_capacity must be a multiple of block_size")
        self.total_token_capacity = total_token_capacity
        self.block_size = block_size
        self.num_blocks = total_token_capacity // block_size
        self.blocks: dict[int, PhysicalKvBlock] = {
            i: PhysicalKvBlock(block_id=i, block_size=block_size)
            for i in range(self.num_blocks)
        }
        self.free_block_ids: list[int] = list(range(self.num_blocks))
        self.tables: dict[str, BlockTable] = {}
        self.cow_copies_performed: int = 0

    def _acquire_block(self) -> PhysicalKvBlock:
        if not self.free_block_ids:
            raise MemoryError("PagedKvAllocator out of physical KV blocks")
        block_id = self.free_block_ids.pop(0)
        block = self.blocks[block_id]
        block.ref_count = 1
        block.tokens = []
        return block

    def _release_block(self, block_id: int) -> None:
        block = self.blocks[block_id]
        if block.ref_count <= 0:
            raise RuntimeError(f"Block {block_id} already has ref_count=0")
        block.ref_count -= 1
        if block.ref_count == 0:
            block.tokens = []
            self.free_block_ids.append(block_id)

    def allocate_sequence(self, seq_id: str, token_ids: list[int]) -> BlockTable:
        if seq_id in self.tables:
            raise ValueError(f"Duplicate sequence ID: {seq_id}")
        if not token_ids:
            raise ValueError("token_ids must be non-empty")
        needed_blocks = math.ceil(len(token_ids) / self.block_size)
        if needed_blocks > len(self.free_block_ids):
            raise MemoryError(
                f"Need {needed_blocks} blocks for {seq_id}, only {len(self.free_block_ids)} free"
            )
        table = BlockTable(seq_id=seq_id, block_size=self.block_size)
        self.tables[seq_id] = table
        for token in token_ids:
            self.append_token(seq_id, token)
        return table

    def fork_sequence(self, parent_seq_id: str, child_seq_id: str) -> BlockTable:
        """Share physical blocks with ref_count += 1 for beam search / parallel sampling."""
        if parent_seq_id not in self.tables:
            raise KeyError(f"Unknown parent sequence: {parent_seq_id}")
        if child_seq_id in self.tables:
            raise ValueError(f"Duplicate child sequence: {child_seq_id}")
        parent = self.tables[parent_seq_id]
        for block_id in parent.logical_blocks:
            self.blocks[block_id].ref_count += 1
        child = BlockTable(
            seq_id=child_seq_id,
            block_size=self.block_size,
            logical_blocks=list(parent.logical_blocks),
            num_tokens=parent.num_tokens,
        )
        self.tables[child_seq_id] = child
        return child

    def append_token(
        self, seq_id: str, token_id: int, enforce_cow: bool = True
    ) -> None:
        table = self.tables[seq_id]
        if (
            not table.logical_blocks
            or len(self.blocks[table.logical_blocks[-1]].tokens) == self.block_size
        ):
            new_block = self._acquire_block()
            new_block.tokens.append(token_id)
            table.logical_blocks.append(new_block.block_id)
            table.num_tokens += 1
            return

        last_block_id = table.logical_blocks[-1]
        last_block = self.blocks[last_block_id]
        if last_block.ref_count > 1 and enforce_cow:
            # Copy-on-write: allocate a private physical block, copy existing tokens, decrement old ref_count
            private_block = self._acquire_block()
            private_block.tokens = list(last_block.tokens)
            private_block.tokens.append(token_id)
            last_block.ref_count -= 1
            table.logical_blocks[-1] = private_block.block_id
            table.num_tokens += 1
            self.cow_copies_performed += 1
        else:
            last_block.tokens.append(token_id)
            table.num_tokens += 1

    def read_sequence_tokens(self, seq_id: str) -> list[int]:
        table = self.tables[seq_id]
        out: list[int] = []
        for block_id in table.logical_blocks:
            out.extend(self.blocks[block_id].tokens)
        return out

    def free_sequence(self, seq_id: str) -> None:
        table = self.tables.pop(seq_id)
        for block_id in table.logical_blocks:
            self._release_block(block_id)

    def metrics(self) -> dict[str, object]:
        allocated_blocks = [b for b in self.blocks.values() if b.ref_count > 0]
        reserved_tokens = len(allocated_blocks) * self.block_size
        physical_stored_tokens = sum(len(b.tokens) for b in allocated_blocks)
        logical_tokens = sum(t.num_tokens for t in self.tables.values())
        internal_frag_tokens = reserved_tokens - physical_stored_tokens
        frag_ratio = (
            (internal_frag_tokens / reserved_tokens) if reserved_tokens else 0.0
        )
        return {
            "active_sequences": len(self.tables),
            "allocated_blocks": len(allocated_blocks),
            "free_blocks": len(self.free_block_ids),
            "reserved_tokens": reserved_tokens,
            "physical_stored_tokens": physical_stored_tokens,
            "logical_tokens_served": logical_tokens,
            "internal_fragmentation_tokens": internal_frag_tokens,
            "waste_ratio": round(frag_ratio, 4),
            "cow_copies_performed": self.cow_copies_performed,
        }


def compare_contiguous_vs_paged() -> dict[str, object]:
    total_capacity = 4096
    max_seq_len = 512
    block_size = 16
    request_lengths = [
        68,
        95,
        114,
        52,
        140,
        83,
        77,
        129,
        61,
        105,
        88,
        73,
        119,
        91,
        64,
        110,
        79,
        102,
        58,
        134,
        85,
        70,
        99,
        122,
        66,
        108,
        74,
        115,
        81,
        93,
        62,
        125,
    ]

    contiguous = ContiguousKvAllocator(
        total_token_capacity=total_capacity, max_seq_len=max_seq_len
    )
    contiguous_admitted = 0
    contiguous_rejected = 0
    for idx, length in enumerate(request_lengths):
        try:
            contiguous.allocate_sequence(f"req-{idx}", length)
            contiguous_admitted += 1
        except MemoryError:
            contiguous_rejected += 1

    paged = PagedKvAllocator(total_token_capacity=total_capacity, block_size=block_size)
    paged_admitted = 0
    paged_rejected = 0
    for idx, length in enumerate(request_lengths):
        try:
            paged.allocate_sequence(f"req-{idx}", list(range(length)))
            paged_admitted += 1
        except MemoryError:
            paged_rejected += 1

    # Demonstrate shared prefix + copy-on-write parallel sampling
    cow_allocator = PagedKvAllocator(total_token_capacity=512, block_size=16)
    shared_prompt = list(
        range(40)
    )  # 2 full blocks (32 tokens) + 1 partial block (8 tokens)
    cow_allocator.allocate_sequence("beam-0", shared_prompt)
    cow_allocator.fork_sequence("beam-0", "beam-1")
    cow_allocator.fork_sequence("beam-0", "beam-2")
    blocks_before_decode = len(
        [b for b in cow_allocator.blocks.values() if b.ref_count > 0]
    )
    cow_allocator.append_token("beam-1", 9001)
    cow_allocator.append_token("beam-2", 9002)
    cow_allocator.append_token("beam-0", 9000)

    return {
        "pool_config": {
            "total_token_capacity": total_capacity,
            "max_seq_len": max_seq_len,
            "block_size": block_size,
            "submitted_requests": len(request_lengths),
        },
        "contiguous": {
            "admitted_requests": contiguous_admitted,
            "rejected_requests": contiguous_rejected,
            **contiguous.metrics(),
        },
        "paged": {
            "admitted_requests": paged_admitted,
            "rejected_requests": paged_rejected,
            **paged.metrics(),
        },
        "concurrency_multiplier": round(paged_admitted / contiguous_admitted, 2),
        "copy_on_write_demo": {
            "prompt_tokens": len(shared_prompt),
            "shared_beams": 3,
            "blocks_before_decode_append": blocks_before_decode,
            **cow_allocator.metrics(),
            "beam_tails": {
                "beam_0_last_token": cow_allocator.read_sequence_tokens("beam-0")[-1],
                "beam_1_last_token": cow_allocator.read_sequence_tokens("beam-1")[-1],
                "beam_2_last_token": cow_allocator.read_sequence_tokens("beam-2")[-1],
            },
        },
    }


def main() -> None:
    print(
        json.dumps(
            {
                "execution_mode": "CPU algorithm with controlled inputs; any latency/capacity coefficients are assumptions",
                **compare_contiguous_vs_paged(),
            },
            indent=2,
        )
    )


if __name__ == "__main__":
    main()

The admission difference comes from allocating to actual sequence length rather than maximum-length reservation. It is specific to this fixture. Some space remains unused in each final block, and shared tails require copies when branches diverge.

QuantityRecorded value
Fixed-reservation admissions8
Paged admissions32
Paged tail slack (token slots)242
Copied shared blocks2
Inspect the complete local result
Recorded local resultRecorded output · jsonDownload
{
  "execution_mode": "CPU algorithm with controlled inputs; any latency/capacity coefficients are assumptions",
  "pool_config": {
    "total_token_capacity": 4096,
    "max_seq_len": 512,
    "block_size": 16,
    "submitted_requests": 32
  },
  "contiguous": {
    "admitted_requests": 8,
    "rejected_requests": 24,
    "active_sequences": 8,
    "reserved_tokens": 4096,
    "used_tokens": 758,
    "wasted_tokens": 3338,
    "waste_ratio": 0.8149,
    "utilization_of_total_pool": 0.1851
  },
  "paged": {
    "admitted_requests": 32,
    "rejected_requests": 0,
    "active_sequences": 32,
    "allocated_blocks": 199,
    "free_blocks": 57,
    "reserved_tokens": 3184,
    "physical_stored_tokens": 2942,
    "logical_tokens_served": 2942,
    "internal_fragmentation_tokens": 242,
    "waste_ratio": 0.076,
    "cow_copies_performed": 0
  },
  "concurrency_multiplier": 4.0,
  "copy_on_write_demo": {
    "prompt_tokens": 40,
    "shared_beams": 3,
    "blocks_before_decode_append": 3,
    "active_sequences": 3,
    "allocated_blocks": 5,
    "free_blocks": 27,
    "reserved_tokens": 80,
    "physical_stored_tokens": 59,
    "logical_tokens_served": 123,
    "internal_fragmentation_tokens": 21,
    "waste_ratio": 0.2625,
    "cow_copies_performed": 2,
    "beam_tails": {
      "beam_0_last_token": 9000,
      "beam_1_last_token": 9001,
      "beam_2_last_token": 9002
    }
  }
}
Saved output report for implement paged cache allocation and copy-on-write; execution mode and values come from the linked raw result.

This image is an application-generated report of the saved program result, not a new inference run. Open the full-resolution report or read the raw result.

Choose block size and sharing policy

Small blocks reduce tail slack but increase block-table and lookup overhead. Large blocks reduce metadata but waste more space for short tails. Prefix sharing saves memory until diverging branches need private blocks. A CPU allocator experiment does not measure GPU attention-kernel speed.

Unused reservation. The fixed-size allocator rejects another request because its reserved slots fill the pool, even though many reserved token positions are empty.

Shared-tail mutation. Appending without copy-on-write changes a sibling sequence. The safe path allocates a private tail and preserves the parent’s tokens.

Read the executable checks
Executable checksSource · pythonDownload
"""Unit and failure-boundary tests for contiguous vs PagedAttention KV allocators."""

from __future__ import annotations

import unittest

from lab import ContiguousKvAllocator, PagedKvAllocator, compare_contiguous_vs_paged


class PagedKvCachingTest(unittest.TestCase):
    def test_paged_allocator_eliminates_reserved_slack_and_admits_4x_more_requests(
        self,
    ) -> None:
        report = compare_contiguous_vs_paged()
        self.assertEqual(report["contiguous"]["admitted_requests"], 8)
        self.assertEqual(report["paged"]["admitted_requests"], 32)
        self.assertGreater(report["contiguous"]["waste_ratio"], 0.80)
        self.assertLess(report["paged"]["waste_ratio"], 0.10)

    def test_contiguous_allocator_ooms_on_reserved_slack(self) -> None:
        """Contiguous allocation rejects a 9th 64-token request even though 87.5% of the pool is unused."""
        allocator = ContiguousKvAllocator(total_token_capacity=4096, max_seq_len=512)
        for i in range(8):
            allocator.allocate_sequence(f"req-{i}", initial_tokens=64)
        metrics = allocator.metrics()
        self.assertEqual(metrics["used_tokens"], 512)
        self.assertEqual(metrics["wasted_tokens"], 3584)
        with self.assertRaises(MemoryError):
            allocator.allocate_sequence("req-8", initial_tokens=64)

    def test_mutating_shared_block_without_cow_corrupts_sibling(self) -> None:
        """Appending to a shared tail block with enforce_cow=False overwrites/pollutes the sibling sequence."""
        safe = PagedKvAllocator(total_token_capacity=256, block_size=16)
        safe.allocate_sequence(
            "parent", list(range(20))
        )  # 1 full block + 4 tokens in tail block
        safe.fork_sequence("parent", "child")
        safe.append_token("child", 999, enforce_cow=True)
        self.assertEqual(safe.read_sequence_tokens("parent"), list(range(20)))
        self.assertEqual(safe.read_sequence_tokens("child"), list(range(20)) + [999])

        unsafe = PagedKvAllocator(total_token_capacity=256, block_size=16)
        unsafe.allocate_sequence("parent", list(range(20)))
        unsafe.fork_sequence("parent", "child")
        unsafe.append_token("child", 999, enforce_cow=False)
        # Without copy-on-write, parent's tail block now unexpectedly contains child's token 999!
        self.assertIn(999, unsafe.read_sequence_tokens("parent"))

    def test_freeing_forked_sequence_preserves_shared_prefix_until_last_reference(
        self,
    ) -> None:
        allocator = PagedKvAllocator(total_token_capacity=128, block_size=16)
        allocator.allocate_sequence("seq-a", list(range(32)))
        allocator.fork_sequence("seq-a", "seq-b")
        self.assertEqual(allocator.metrics()["allocated_blocks"], 2)
        allocator.free_sequence("seq-a")
        # Both blocks remain allocated because seq-b still holds ref_count=1
        self.assertEqual(allocator.metrics()["allocated_blocks"], 2)
        self.assertEqual(allocator.read_sequence_tokens("seq-b"), list(range(32)))
        allocator.free_sequence("seq-b")
        self.assertEqual(allocator.metrics()["allocated_blocks"], 0)


if __name__ == "__main__":
    unittest.main()


class GuardBoundaryTests(unittest.TestCase):
    def test_rejects_invalid_boundary_inputs_with_specific_reason(self):
        import lab
        cases = [('ContiguousKvAllocator(0,1)', 'Capacity and'), ('a=ContiguousKvAllocator(4,4)\na.allocate_sequence("x",1)\na.allocate_sequence("x",1)', 'Duplicate sequence'), ('ContiguousKvAllocator(4,4).allocate_sequence("x",0)', 'initial_tokens'), ('a=ContiguousKvAllocator(4,4)\na.allocate_sequence("x",4)\na.append_tokens("x")', 'exceeded max'), ('PagedKvAllocator(0,1)', 'total_token_capacity and'), ('PagedKvAllocator(5,2)', 'multiple of'), ('a=PagedKvAllocator(2,2)\na._acquire_block()\na._acquire_block()', 'out of physical'), ('PagedKvAllocator(2,2)._release_block(0)', 'already has ref_count'), ('a=PagedKvAllocator(4,2)\na.allocate_sequence("x",[1])\na.allocate_sequence("x",[1])', 'Duplicate sequence'), ('PagedKvAllocator(4,2).allocate_sequence("x",[])', 'non-empty'), ('PagedKvAllocator(2,2).allocate_sequence("x",[1,2,3])', 'Need 2 blocks'), ('PagedKvAllocator(2,2).fork_sequence("missing","x")', 'Unknown parent'), ('a=PagedKvAllocator(4,2)\na.allocate_sequence("x",[1])\na.fork_sequence("x","x")', 'Duplicate child')]
        for code, reason in cases:
            with self.subTest(code=code):
                with self.assertRaisesRegex((ValueError, MemoryError, KeyError, RuntimeError), reason):
                    exec(code, vars(lab).copy())

Try a change and explain the result

Fork a sequence with a partially filled final block, append to the child, and read both sequences. Disable copy-on-write only in the controlled test and compare the corruption. Then free each sequence and inspect reference counts.

A child sequence appends to a shared partial block. What protects the parent?

Choose one answer, then check your reasoning.

Read the explanation

Copy-on-write gives the child a private tail while the parent keeps its original view.

Keep allocation lifetimes explicit

Keep allocation and release ownership explicit on cancellation and completion. Test pool exhaustion, shared tails, and repeated release. Use the serving engine’s real paged-attention implementation when running a model; do not swap this teaching allocator into its device memory manager.

References

Sources checked October 24, 2026. Runtime-specific commands and results apply to the recorded versions; linked rolling documentation can change. The application scenarios are teaching examples, not claims about an author’s production incident.

Written by Arjun Prabhulal.

Module 2

Fit the model and KV cache in GPU memory