Kiet Vu

KV-PIMple: PIM-aware KV Allocator for LLM Decode

Sep 23, 2026  

This is my hackathon submission for the Workshop on Chips and AI. My topic is KV-cache and attention inside processing-in-memory (PIM), and the result is KV-PIMple, a KV-cache allocator that knows which DRAM row its blocks land in.

This is a solo, simulation-only project. Besides the result, this writeup also covers what didn’t work and where my own model is wrong.

As for the name, KV is my initials, and it also means Key/Value. PIMple rhymes with simple, which is the point, since all I did was change the policy on top of an allocator that already exists. And it also literally means pimple, since I’m still an adolescent and still have many of those (bad joke bad joke).

Note: everything here is simulated. No GPU, no PIM hardware, no real inference. Every bandwidth number comes from a model, but dw I did document how wrong that model is.

the code
github.com/AndrewVu23/chip_hackathon_f26

TL;DR

1. Background

Quick context for readers who haven’t worked with LLM serving or PIM. If you already know all three, skip to section 2. If you have no background in neural networks at all, watch 3Blue1Brown’s neural networks series first. The chapters on transformers and attention cover everything this section builds on.

KV Cache

An LLM generates one token at a time. At each step, every attention layer makes a query (Q) for the new token, scores it against the keys (K) of every past token, turns the scores into weights with a softmax, and takes the weighted sum of the past values (V). The K and V of past tokens never change, so instead of recomputing them every step, you store them. That’s the KV cache, and besides the weights, it’s the biggest thing in GPU memory.

AttAcc figure of a GPT decoder. (a) The summarization stage processes the whole input sequence, then each generation stage processes one token and appends its K and V. (b) Inside one decoder: QKV generation, per-head score, softmax and context against 2049 cached keys and values, projection, then the feed-forward block.
A GPT-3 175B decoder, from AttAcc (Park et al., ASPLOS ’24) [1]. The summarization stage is prefill, the generation stages are decode, and the KT and V matrices in (b) are the KV cache

For the model shape I used (Llama-3-8B style GQA with 8 KV heads, head dim 128 and fp16), that’s 2 × 8 × 128 × 2 B = 4 KB per token per layer.

During decode, each step reads the whole cache once and does about 4 FLOPs per byte with it (with GQA). A GPU needs something like 100–300 FLOP/byte to be compute-bound. So decode attention is a memory bandwidth problem, and batching doesn’t help, because every request has its own cache.

Paging (vLLM)

The problem is that the cache grows by one token per step, and nobody knows how long the output will be. Reserve the max and most of it is wasted, and free memory turns into swiss cheese. PagedAttention (vLLM) [2] fixed this by borrowing a concept in OS: virtual memory. They cut the cache into fixed-size blocks (16 tokens by default), give each sequence a block table, and hand out physical blocks from a free list. Waste drops to under 4%, and prefix sharing comes for free.1

If you want to really understand PagedAttention, Hamza Elshafie’s “Paged Attention from First Principles” is one of the best explanations out there, if not the best. For vLLM in general (scheduler, engine, the whole thing), read the official vLLM blog’s “Anatomy of vLLM”.

The part that matters for this project is that the free list in vLLM v0.2.7’s BlockAllocator [3] is LIFO. It pops whatever block was freed last, with no idea where that block physically lives. On a GPU it doesn’t need to know, since the memory controller spreads every block across all its channels anyway and keeps thousands of reads in flight, so a scattered block costs about the same to read as a contiguous one.

PIM

PIM puts small compute units next to the DRAM banks. Decode attention is “read a huge array and barely touch it”, so you do the touching inside the memory, where the bandwidth is much higher than what comes out of the pins. Samsung’s HBM-PIM [4] is real hardware, and AttAcc and NeuPIMs (both ASPLOS ’24) [5] are the big designs for LLM attention.

A quick DRAM refresher first (if you’ve never seen how DRAM works inside, this video is a great visual intro). A bank holds rows (1 KB each here). To read, you activate a row into the bank’s row buffer (expensive), then pull 32 B bursts out of it (cheap). Reading from the row that’s already open is a row hit. Opening a different row costs a full row cycle, or tRC.

That’s also where the pin limit comes from. A normal read leaves the chip through the channel’s data pins, and those pins carry one bank’s 32 B burst at a time. So even with 16 banks per channel, only one of them is sending data out at any moment, and the other 15 open row buffers just wait their turn. An HBM3 stack has 1,024 data pins at 6.4 Gb/s each, which caps it at about 819 GB/s no matter how many banks are inside. PIM skips the pins. If every bank has its own compute unit, all 16 banks can read from their row buffers at the same time, and only the small results have to leave the chip. In the config below, that’s 3,810 GB/s of internal bandwidth, about 4.7× what the pins allow.

The trick that makes PIM fast is the all-bank operation. Open the same row index in all 16 banks of a channel, broadcast one command, and every bank computes on its own row buffer in lockstep. One command moves 16 × 32 B = 512 B every tCCD_ab. The catch is that it only works if the data sits at the same row index in every bank. And since all 16 banks move together, if any one of them needs a new row, they all pay the row cycle.

The DRAM I modeled (it matches AttAcc’s HBM3 config):

HBM3-PIMvalue
pseudo-channels32
banks per channel16
row size1 KB (32 bursts of 32 B)
all-bank command (tCCD_ab)4.3 ns, 512 B per channel
row cycle (tRC)45 ns, ~10.5× a command2
external bandwidth (pins)~819 GB/s
ideal all-bank bandwidth (inside)3,810 GB/s
  1. If two requests start with the same words (like the same system prompt), their K and V for that part are identical too. So instead of storing it twice, they share the same blocks. Once a request needs to change one of those shared blocks, it gets its own copy first. ↩
  2. The 10–11× ratio is from the RH+ [6] measurement on AttAcc + Ramulator 2, which also found that host-style address interleaving forces consecutive all-bank commands into different rows. Only the ratio matters for the results here, not the absolute nanoseconds. ↩

2. Deadly Assumptions

Every PIM paper I read places the KV cache statically. The architect picks the layout once, so it’s aligned by construction. Every serving paper assumes memory is flat, so where a block lands doesn’t matter. I couldn’t find anyone who measured what happens when a real paging allocator (16-token blocks, LIFO free list, requests arriving and leaving all the time) feeds a PIM unit.

I didn’t run vLLM for this. Installing it on my Mac would’ve eaten days, and I don’t need inference anyway, only where the blocks go. And since placement is decided entirely by the block manager, I ported vLLM v0.2.7’s BlockAllocator into Python and cited it down to the file, commit and line. Then a small simulator around it:

workload.py   steady | longctx | prefix | spec, Poisson arrivals, seeded
   v
   |
sim.py        continuous-batching decode loop: admission, block growth,
   |          release, optional vLLM preempt-by-recompute
   v
allocator.py  paged (vLLM port) | KV-PIMple | contiguous | random
   |
   v
addrmap.py    KV address -> (channel, bank group, bank, row, column)
   |
   v
pimmodel.py   group bursts into all-bank commands -> M1, M2, M3

The default run uses 1,000 requests with ShareGPT-like lengths (median ~200 tokens in and out), batches of up to 64, one representative layer, and a 64-burst reorder window in the controller. No GPU. Each run takes under a minute on a laptop, which is what makes the sweeps below possible.

Three metrics, fixed before the first run:

M3 = BWideal × M2 × tCCD_abM1 · tCCD_ab + (1 − M1) · tRC

Breaking it down:

Since one miss costs as much as ~10 hits, a few misses go a long way. At KV-PIMple’s M1 = 0.963, the average command takes 0.963 × 4.3 + 0.037 × 45 ≈ 5.8 ns, so it runs at 74% of ideal, and with M2 ≈ 0.99 that’s ~2,800 GB/s. At paged’s 0.77, the 23% misses make the average command more than 3× slower than ideal. The formula also assumes a miss can’t overlap with anything else, which turns out to be the main thing it gets wrong (section 8).

3. Kill Test

Before building anything, I set a stop rule and measured the paged baseline first. If its row-hit rate came back under ~0.3, the problem is real. If it came back over ~0.7, stop and say so, with no hunting for a workload that makes it look bad, so no cherry-picking here.

On Aug 28 it came back at 0.970. The rule fired.

Then I looked at the other two metrics:

address map (paged, 16-token blocks)row-hit (M1)banks (M2)bandwidth (M3)% ideal
host-centric (textbook)0.9700.124368 GB/s9.7%
host-cacheline (realistic)0.7590.9901,173 GB/s31%
pim-friendly (slab per channel)0.9691.0002,938 GB/s77%

The textbook host-centric map puts the column bits lowest, so a stream of addresses sweeps a whole row of one bank before moving on to the next bank. Rows stay open, so row-hit looks great. But only about 2 of 16 banks are busy per command, and the bandwidth is 9.7% of ideal. The 0.970 was real, it just measured the wrong thing. If I had trusted row-hit alone, I would’ve either killed the project, or worse, called the baseline fine.

On the realistic map, which interleaves cache lines across channels and banks like a real host does, the actual gap shows up, with row-hit at 0.759 and 1,173 GB/s. The pim-friendly map gives each channel its own slab, so a 16-token block covers 4 whole rows in one channel and scattering doesn’t matter at all. Real hosts don’t lay memory out like that though.

So the problem is narrower than I first framed it, but it’s there. From here on, every config is judged on all three metrics, never on M1 alone, and everything uses the realistic host-cacheline map.

4. Row Index

This is the core of the project, and it fits in one line.

The KV pool is one big tensor indexed by block ID. Under the host-cacheline map, the memory controller spreads consecutive bytes across all 32 channels and all 16 banks, and only moves to the next row index after it has filled one row in every bank of every channel: 1 KB × 16 × 32 = 512 KB. A block is 16 tokens × 4 KB = 64 KB. So 8 consecutive blocks share one row index:

row = ⌊block ID / 8⌋

Picking a block number is picking a row. vLLM’s free list picks numbers with no idea about that.

Here’s where the ÷ 8 comes from. The host-cacheline map slices a byte address into DRAM fields like this (bit ranges, high to low):

bits:   32 ... 19 | 18 .. 15 | 14 13 | 12 11 | 10 .. 6 |   5    | 4 .. 0
field:     row    |  col hi  |  bg   |  ba   | channel | col lo |  byte
        (16K rows)            (bank = bg:ba, 16/ch)  (32)  (64 B line) (32 B burst)

Read it from the right. The lowest 5 bits pick a byte inside one 32 B burst. The column (which burst inside a bank’s 1 KB row) is split in two.3 col lo is 1 bit, so a 64 B cache line stays together in one bank. Then come the channel and bank bits, so consecutive cache lines get dealt out to all 32 channels and all 16 banks. Only after that does col hi move on to the next cache line inside the same rows. When col hi wraps around, every bank’s 1 KB row is full and the row number goes up by one. That’s 32 × 16 × 1 KB = 512 KB per row, so row = address ÷ 512 KB. Block N starts at address N × 64 KB, which makes row = N ÷ 8. For comparison, the other two maps from section 3:

host-centric:   row | bg | ba | channel | col | byte   (col lowest: M2 collapses)
pim-friendly:   channel | row | col | bg | ba | byte   (channel on top: one slab each)

That also explains the 0.75. A 64 KB block spread over 32 channels is 2 KB per channel, which is 4 all-bank commands of 512 B. If each block of a sequence sits in a different row from the one before it, every block pays one activation per channel. That’s 1 miss in every 4 commands, so ~0.75. If a sequence’s blocks fill rows in order instead, you miss once per row. A row is 1 KB per bank = 32 bursts = 32 commands, so 1 miss in 32 gives 31/32 = 0.969. That’s the ceiling, and no allocator can beat it.

LIFO isn’t random tho. A fresh pool hands out consecutive IDs, so the first few sequences look fine. The scatter comes from churn. Requests finish and return blocks in whatever order, and the next sequence gets a mix. This doesn’t even need a DRAM model to see. Under churn, only 3–7% of a sequence’s consecutive blocks are physically next to each other, and a sequence is spread over 5–16× more regions than it needs. The code calls that last number frame_spread (regions touched ÷ regions needed, 1.00 is perfect).

  1. Here a “column” means one 32 B burst, not a datasheet column. A real HBM3 pseudo-channel is 32 bits wide, so a datasheet column is 4 B and a 1 KB row has 256 of them. But every read is a burst of 8 columns, so the controller only ever picks which burst, and the 3 bits that pick a word inside it are folded into the “byte” field. Simulators like Ramulator count columns the same way, and papers switch between the two meanings without saying so. Careful, I’ve been there. Some papers made no sense to me until I figured out which “column” they meant. ↩

5. Regions

Diagram of one channel's 16 banks, with rows grouped into regions, repeated across 32 channels. Each request's tokens fill whole rows across all banks before moving to the next region.
The pool split into regions, shown for one channel (× 32 for the whole stack)

KV-PIMple keeps vLLM’s 16-token blocks and changes how block IDs get picked:

  1. Split the pool into regions. A region is the set of blocks that fills one row index across every bank and every channel (8 blocks here).
  2. A sequence fills its current region at ascending offsets before claiming another one. A new region is the lowest empty one.
  3. Freed blocks go back to their own region, so alignment survives reuse.
  4. If no region is empty, fall back to the partial region with the most free slots. That’s just plain paging, so it degrades instead of failing.

The region size isn’t tuned. It comes straight from the hardware and the model:

region_size       = row x banks x channels       = 1 KB x 16 x 32       = 512 KB
token_size        = 2 (K, V) x kv_heads x head_dim x bytes = 2 x 8 x 128 x 2 = 4 KB
tokens_per_region = 512 KB / 4 KB    = 128 tokens
blocks_per_region = 128 / 16 tokens  = 8 blocks

config.derive_block_tokens() computes this at startup. Change the model or the DRAM and it follows. An MQA model at 512 B/token gets 64 blocks per region, and an MHA model at 16 KB/token gets 2.

That’s the whole fix. The allocator never touches an address, the hardware doesn’t change, and the PIM command set also doesn’t change.

The other lever is to make the block 128 tokens, so one block is one region. That’s roughly what prior PIM work does, and it gets the same row-hit rate. But it gives up the fine-grained packing that paging exists for in the first place.

6. Implementation

Allocator. Here’s the vLLM baseline, the whole policy, ported from v0.2.7’s vllm/core/block_manager.py:

class PagedFirstFit(KVAllocator):
    def __init__(self, num_blocks):
        self.free_blocks = list(range(num_blocks))   # ascending ids

    def _alloc_one(self, seq_id):
        return self.free_blocks.pop()                # LIFO: last freed comes out first

    def _free_one(self, block):
        self.free_blocks.append(block)

And KV-PIMple, trimmed (the code calls regions frames, and G is blocks per region, 8 here):

# one min-heap of free slots per region, one min-heap of empty regions
self.frame_free   = [list(range(G)) for _ in range(n_frames)]
self.empty_frames = list(range(n_frames))
self.affinity     = {}                               # sequence -> its current region

def _alloc_one(self, seq_id):
    f = self._pick_frame(seq_id)
    off = heapq.heappop(self.frame_free[f])          # lowest free slot in that region
    return f * G + off                               # block ID, so row = f

def _free_one(self, block):
    f, off = divmod(block, G)                        # the block goes back to its own region
    heapq.heappush(self.frame_free[f], off)
    if len(self.frame_free[f]) == G:                 # region is whole again
        heapq.heappush(self.empty_frames, f)

def _pick_frame(self, seq_id):
    f = self.affinity.get(seq_id)
    if f is not None and self.frame_free[f]:         # 1. keep filling my region
        return f
    while self.empty_frames:                         # 2. lowest empty region
        f = heapq.heappop(self.empty_frames)
        if len(self.frame_free[f]) == G:             # skip stale heap entries
            self.affinity[seq_id] = f
            return f
    f = max(range(n_frames), key=lambda f: len(self.frame_free[f]))
    self.affinity[seq_id] = f                        # 3. fallback: emptiest partial region
    return f

I left out the pool-exhausted check and the best-fit planning from section 9. Speculative draft blocks go through the same code under one shared key ("scratch") instead of the sequence’s ID, which is how they get their own regions.

The baseline is O(1) per operation. KV-PIMple is O(log G) for the heaps, plus a scan over all regions in the fallback. Section 10 measures what that costs.

How commands get counted. pimmodel.py walks each sequence’s KV bursts per channel, in token order, and groups them into all-bank commands:

Then M1 = hits ÷ commands and M2 = bursts ÷ (16 × commands). Two modeling choices here, and both work against my own claim:

Tests

  1. Cold start isn’t what keeps KV-PIMple at 0.963 instead of the 0.969 ceiling, since a sequence’s first row costs an activation either way. The gap is at the other end. Real sequences don’t end on a region boundary, so the last region is only partly filled but still costs a full activation. That’s why every allocator also lands at 0.963 with 128-token blocks. ↩

7. Results

Steady traffic, 16-token blocks, same workload, same pool, only the allocator changes:

allocatorrow-hit (M1)bandwidth (M3)
paged (vLLM port)0.76951,226 GB/s
KV-PIMple0.96332,805 GB/s
contiguous (knows every length up front)0.95792,712 GB/s
Row-hit rate and effective PIM bandwidth vs KV block size for paged, KV-PIMple and contiguous allocators
Row-hit rate (top) and effective PIM bandwidth (bottom) vs block size. Solid is steady traffic, dashed is speculative decoding

Across workloads (16-token blocks):

workloadpagedKV-PIMplegain
steady1,226 GB/s2,805 GB/s2.3×
speculative decoding1,153 GB/s2,795 GB/s2.4×
prefix sharing2,042 GB/s2,867 GB/s1.4×
long context2,823 GB/s2,938 GB/s1.04×

Long context barely cares about the allocator. A 20k-token prompt grabs hundreds of blocks in one prefill burst, which even a LIFO free list serves in long runs, and decode growth is a rounding error next to it. Prefix sharing helps paged too, since the shared prompt gets allocated once, early, and compactly. So the paged penalty is a churn problem, not a length problem, and it’s worst for short and medium contexts, which is most chat traffic.

It also gets worse as models shed KV heads. Fewer heads means fewer bytes per token, so a 16-token block is a smaller slice of a row and paging pays more activations for the same data:

Row-hit rate for MQA, GQA and MHA models under paged, KV-PIMple and contiguous allocators. Gains are 6.0x, 2.1x and 1.2x.
Row-hit by KV head count, 16-token blocks. This is a smaller run than the headline, so GQA shows 2.1×

Every KV-compression trend (fewer heads, FP8, INT4) shrinks the bytes per token, so they all widen the gap.

8. Cross-validation

M3 is a formula, not a measurement. To verify it, I ran it through something that actually simulates a DRAM controller cycle by cycle.

Ramulator 2

I captured real block tables from the simulator (3 sequences × 3 allocators), turned them into the exact bursts my model scores, and fed them to Ramulator 2 [7] as pre-decoded addresses, so it sees my address map bit for bit. Its controller does its own FR-FCFS reordering with an open-row policy.

Row-hit agrees to within 0.0023 on average (0.0065 at worst) over all 9 samples. So where the blocks land and which commands hit are not modeling artifacts.

The speedup was a different story. Ramulator saw paged as only 1.24× slower, while my model said 2.1×. That’s because stock Ramulator has no all-bank command. Its banks run independently, so a miss in one bank hides behind work in the others. It just measures the normal DRAM penalty, not the PIM one.

AttAcc

I first vendored AttAcc’s simulator [8] as reference only and assumed the bank-parallelism part couldn’t be checked. I couldn’t be more wrong. It ships a Ramulator 2 extension with exactly the missing piece, a real all-bank MAC command (PIM_MAC_AB) and a PIM controller, on the same geometry as mine.

Two tests:

Left: Ramulator 2 row-hit fraction vs model row-hit rate, all points on the diagonal. Right: claimed vs AttAcc-measured speedup over paged, 2.16x vs 1.50x for KV-PIMple.
Cross-validation against Ramulator 2 (left) and AttAcc (right)

Working backwards from AttAcc’s cycles, a miss effectively costs ~4.4× a hit, not the 10.5× I took from the raw timing parameters. The thing is that my model charges a full row cycle per miss, while a real PIM controller overlaps activations with useful work across banks and channels. So my M3 overstates the benefit by ~1.4×.

That gives the honest headline: 2.3× on published timing, ~1.6× on a cycle-level PIM controller. The direction, the ordering of the allocators, and the block-size story don’t change, except for the magnitude.

9. What Didn’t Work

Speculative Decoding

In speculative decoding, each round forks 4 draft branches (3 tokens each), keeps the accepted one, and frees the rest. That’s 110 allocations per step vs 6 on steady traffic. My first version took the draft blocks from the sequence’s own region. The churn punched holes in regions, so they never looked empty, everything fell to the fallback path, and row-hit dropped to about 0.87 at 16-token blocks. A sequence ended up touching more than 3× the regions it needed.

The obvious fixes, one at a time:

16-token blocks, spec decodingrow-hit
drafts in the sequence’s region (original)0.873
copy accepted tokens back, drafts still in the region0.871
drafts in a separate scratch area, keep the accepted block0.850
separate scratch area and copy back0.963

Copying back alone changes nothing, since the drafts still churn the sequence’s region. The separate scratch area alone makes it worse, because the accepted block lives in a scratch region forever, so committed blocks end up smeared across scratch space. Only both together work, and then spec decoding looks exactly like steady traffic. The cost is 8.5% more copied bytes (the accepted tokens), on top of the copy-on-write copies every spec engine already pays.

Preemption

The runs so far gave every request the memory it needed. Real vLLM doesn’t. It admits a request if its prompt fits, and when the pool runs out, it preempts the newest sequence, throws away its blocks, and recomputes it later. So I ported that too (preempt-by-recompute from v0.2.7) and shrank the pool.

Row-hit rate vs KV pool headroom. KV-PIMple falls from 0.963 to 0.860 as the pool shrinks to 0.6x of demand, while paged stays around 0.77 to 0.79.
Row-hit vs pool size. Spec decoding (dashed) survives, since its requests finish before they get evicted

At 0.6× headroom (202 preemptions), KV-PIMple falls from 0.963 to 0.860 and the gain goes from 2.13× to 1.38×. Paged doesn’t care, since its placement is already scattered. A recomputed sequence comes back with a longer prompt all at once, and in an oversubscribed pool there are no empty regions left, so it lands in whatever partial regions the churn left behind.

I tried two fixes:

Both are still in the code (off by default), so the negative results are reproducible. So KV-PIMple gets its full gain when the pool isn’t oversubscribed, and when it is, it degrades but never drops below paged.

10. Cost

vLLM’s allocator pops a block off a list, one operation. Mine pops the lowest free slot from the sequence’s region, and the lowest empty region when that fills, so it needs ordered structures instead of one list. Intuitively (or obviously), that’s more CPU time. But I wanted to know how much:

pagedKV-PIMple
CPU per block allocation1,960 ns3,167 ns
CPU per decode step (steady)+8 µs
memory time saved per decode step1.7 ms
bookkeeping memory113 KiB70 KiB

So it trades a bit of CPU time for a lot of memory time, and uses less bookkeeping memory than the free list. The benefit-to-cost ratio depends on the workload though. It’s 226× on steady, 125× on prefix sharing, 60× on spec decoding (all that churn), and only 5× on long context, where the allocator barely helps to begin with.

The worst case is a full pool with no empty region left. There, growing a sequence costs 1.5 µs vs 136 ns, and admitting a request costs 15.6 µs vs 215 ns, because the fallback scans every region. Most of that admission cost is the best-fit planning from section 9, which I measured as a no-op, so it can be dropped.

Four panels: allocator CPU per decode step by workload, worst-case nanoseconds per operation with a full pool, steady decode step time split into attention and allocator time, and memory time saved divided by CPU spent per workload (226x steady down to 5x long context).
The cost of KV-PIMple, measured in CPython. Both panels on the right are log scale

KV Tensor Layout. One simplification I made was storing K and V together, 4 KB per token in one tensor. vLLM actually keeps K and V in two separate tensors, and for a good reason. The GPU kernel reads them differently, so each gets its own layout. Keys are stored in 16 B chunks so a key vector loads in wide reads for the Q·K dot product, and values are stored with the tokens next to each other since the second step sums over tokens (cache_engine.py [3]). For PIM though, it means each tensor only sees 2 KB per token, so a block is 32 KB and 16 of them fill a row. Paged still pays one activation per block, but now each activation covers half as many reads.

Row-hit and bandwidth for combined K+V vs split K and V tensors. Paged drops from 0.769 to 0.551 with split tensors, KV-PIMple stays at 0.96.
Combined K+V (what I measured everywhere else) vs split K and V (what vLLM allocates)

Paged row-hit drops 0.77 → 0.55, KV-PIMple stays at 0.96, and the gain goes from 2.3× to 3.5×. This isn’t a case against splitting K and V, which is the right call for the GPU kernel. It just means a PIM-aware allocator matters even more with vLLM’s real layout, and my simplification understates the gain rather than inflating it. This one is a proxy though (one tensor at half the size), not a real two-stream simulation.

11. Related Work

The idea is not entirely new, and I want to be upfront about that.

I didn’t know about most of this work when I started. I leaned too much on Claude for the literature search this time, and only found other papers near the end of the build, while putting together the related-work slides.

That’s on me.

Here’s how I place the closest work (summarized from reading, I didn’t re-implement any of them):

What I think still stands:

And to be fair to them, they cover things I don’t, like the write path, K/V asymmetry, prefix caching, real engine integration, different hardware, and probably way more thorough testing than mine.

12. Concerns

  1. One more fidelity gap, in the baseline’s favor. Real vLLM frees a finished sequence’s blocks by iterating a set, so the order is arbitrary. I free them in block-table order to keep runs deterministic. That slightly tidies paged’s free list, so if anything it flatters the baseline. ↩

13. Conclusion

The fix itself is small. It’s one rule in the block manager, derived from a division, and the hardware never knows. Most of the time went into proving it honestly: the kill test, the three metrics, two external simulators, and measuring the cost of my own fix and the cases where it barely helps.

My lessons:

Next steps, in order of how much they would change the numbers:

If you want to try it, setup is Python 3.11+ with numpy, pandas, matplotlib and pyyaml:

make install        # venv + editable install (no uv? use: make install-pip)
make test           # 85 tests incl. the 5 validation gates, ~3-5 min

Then these two commands give the headline (~45 s each, same seed, same workload, only the allocator changes):

.venv/bin/python -m pimkv.run --workload steady --allocator paged \
  --addrmap host-cacheline --block-tokens 16 --requests 1000 --seed 0

.venv/bin/python -m pimkv.run --workload steady --allocator pim-aware \
  --addrmap host-cacheline --block-tokens 16 --requests 1000 --seed 0

You should get 0.7695 / 1226.3 GB/s, then 0.9633 / 2805.4 GB/s, with frame_spread 6.10 vs 1.00.

Update: I didn’t get into the finals, and honestly the finalists deserved it. AI agents for IC physical design, laser drone detection, neural networks built on a breadboard,... just to name a few. Their work is way more impressive than mine.

Still, it was worth it for me. In one month I learned how LLM serving works, from attention and the KV cache to vLLM’s paging, and how DRAM and PIM behave at the row and bank level. And I came out of it with a real project.

Bonus: My Notes

A few pages from my notebook while I was learning all this, in the order I went through it.

Handwritten notes on how K and V grow by one row per token, KV cache memory usage for a 30B model, prefill vs decode, and PagedAttention blocks.
Attention and the KV cache (after 3Blue1Brown), prefill vs decode, and PagedAttention
Handwritten notes on prefix sharing, copy-on-write, the vAttention critique of PagedAttention, and speculative decoding with draft and target models and the acceptance probability.
Prefix sharing and copy-on-write, vAttention’s critique of paging, and speculative decoding
Handwritten notes analyzing AttAcc: GEMV vs GEMM in batched decode, the HBM3 stack with DRAM dies, buffer die, pseudo-channels and bank groups, and where to place the GEMV units.
Analyzing AttAcc: where to place the GEMV units

See you in the next one!

References

  1. J. Park et al., “AttAcc! Unleashing the power of PIM for batched transformer-based generative model inference,” in Proc. 29th ACM Int. Conf. Archit. Support Program. Lang. Oper. Syst. (ASPLOS), vol. 2, 2024, pp. 103–119, doi: 10.1145/3620665.3640422.
  2. W. Kwon et al., “Efficient memory management for large language model serving with PagedAttention,” in Proc. 29th Symp. Oper. Syst. Princ. (SOSP), 2023, pp. 611–626, doi: 10.1145/3600006.3613165.
  3. vLLM Project, “vLLM v0.2.7,” GitHub. [Online]. Available: https://github.com/vllm-project/vllm/tree/v0.2.7
  4. S. Lee et al., “Hardware architecture and software stack for PIM based on commercial DRAM technology: Industrial product,” in Proc. ACM/IEEE 48th Annu. Int. Symp. Comput. Archit. (ISCA), 2021, pp. 43–56, doi: 10.1109/ISCA52012.2021.00013.
  5. G. Heo et al., “NeuPIMs: NPU-PIM heterogeneous acceleration for batched LLM inferencing,” in Proc. 29th ACM Int. Conf. Archit. Support Program. Lang. Oper. Syst. (ASPLOS), vol. 3, 2024, pp. 722–737, doi: 10.1145/3620666.3651380.
  6. Y. Jung, S. M. Anik, B. K. Lee, and J. Ryoo, “RH+: Row-hit-optimized scheduling for PIM-based LLM inference,” 2026, arXiv:2606.05511.
  7. H. Luo et al., “Ramulator 2.0: A modern, modular, and extensible DRAM simulator,” IEEE Comput. Archit. Lett., vol. 23, no. 1, pp. 112–116, 2024, doi: 10.1109/LCA.2023.3333759.
  8. SNU SCALE Lab, “AttAcc simulator,” GitHub. [Online]. Available: https://github.com/scale-snu/attacc_simulator
  9. H. Kwon et al., “PIMphony: Overcoming bandwidth and capacity inefficiency in PIM-based long-context LLM inference system,” 2024, arXiv:2412.20166.
  10. Z. Li, J. Zhou, X. Li, and N. Sun, “BlockPIM: Optimizing memory management for PIM-enabled long-context LLM inference,” in Proc. 62nd ACM/IEEE Design Autom. Conf. (DAC), 2025, pp. 1–7, doi: 10.1109/DAC63849.2025.11133193.
  11. H. Kim, S. Lee, T. Suh, and J.-Y. Kim, “PATTON: Enabling commodity PIM for production LLM serving,” 2026, arXiv:2609.11392.