<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Block-Manager on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/block-manager/</link><description>Recent content in Block-Manager on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Mon, 08 Jun 2026 05:20:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/block-manager/index.xml" rel="self" type="application/rss+xml"/><item><title>La despensa por casilleros: PagedAttention y el block manager de vLLM</title><link>https://blog.lo0.es/posts/pagedattention-deep-dive/</link><pubDate>Mon, 08 Jun 2026 05:20:00 +0200</pubDate><guid>https://blog.lo0.es/posts/pagedattention-deep-dive/</guid><description>&lt;blockquote>
&lt;p>Sigue la serie &lt;em>por debajo del motor&lt;/em>. El &lt;a href="https://blog.lo0.es/posts/scheduler-step-vllm/">post del scheduler&lt;/a> terminó con un cabo suelto: el scheduler tiene un segundo presupuesto, los &lt;strong>bloques de KV&lt;/strong>, y cuando se agotan, preempta. Este post abre ese presupuesto. Es la pieza que el &lt;a href="https://blog.lo0.es/posts/kv-cache-fundamentos/">post de KV cache&lt;/a> daba por buena —&lt;em>qué&lt;/em> se guarda— para explicar &lt;em>cómo se gestiona en memoria&lt;/em>. Y es el que el &lt;a href="https://blog.lo0.es/posts/flashattention-fundamentos/">post de FlashAttention&lt;/a> llevaba meses prometiendo.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>El &lt;a href="https://blog.lo0.es/posts/kv-cache-fundamentos/">KV cache&lt;/a> crece un poco con cada token generado, y el problema nunca fue su &lt;strong>tamaño total&lt;/strong> sino la &lt;strong>forma de reservarlo&lt;/strong>. Los primeros servidores pedían, por petición, un trozo &lt;strong>contiguo&lt;/strong> de HBM del tamaño del contexto máximo posible. Como casi ninguna petición llega a ese máximo, el resultado era catastrófico: &lt;strong>60-80% de la HBM desperdiciada&lt;/strong> en fragmentación. PagedAttention aplica al KV la idea más vieja y probada de los sistemas operativos —la &lt;strong>paginación&lt;/strong>—: partir el KV en &lt;strong>bloques de tamaño fijo&lt;/strong> (16 tokens por defecto), guardarlos en HBM &lt;strong>no contigua&lt;/strong> donde haya hueco, y mantener una &lt;strong>block table&lt;/strong> que traduce el bloque &lt;em>lógico&lt;/em> de cada secuencia a su bloque &lt;em>físico&lt;/em>. El desperdicio cae a &lt;strong>~4%&lt;/strong> (solo el último bloque, a medio llenar). Y como cada bloque se puede identificar por el &lt;strong>hash de su contenido&lt;/strong>, dos peticiones que comparten un prefijo apuntan al &lt;strong>mismo bloque físico&lt;/strong> y comparten memoria —con &lt;strong>copy-on-write&lt;/strong> cuando una diverge—: ese es el motor del &lt;a href="https://blog.lo0.es/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a>. Este post explica la fragmentación con números, el block manager, el block table, el COW, el compromiso del tamaño de bloque, los 10 knobs y la trampa de confundir &lt;em>&amp;ldquo;fragmentación resuelta&amp;rdquo;&lt;/em> con &lt;em>&amp;ldquo;cero desperdicio&amp;rdquo;&lt;/em>. Sobre el cluster genérico 4×H100 SXM.&lt;/p>
&lt;h2 id="dónde-estás-la-despensa-debajo-del-scheduler">Dónde estás: la despensa, debajo del scheduler&lt;/h2>
&lt;p>Vuelve a la cocina del &lt;a href="https://blog.lo0.es/posts/scheduler-step-vllm/">post anterior&lt;/a>. El jefe de sala arma bandejas, pero detrás hay una &lt;strong>despensa&lt;/strong> donde se guardan los ingredientes que cada mesa va acumulando a lo largo de su comida —su KV cache—. La pregunta de este post es cómo está organizada esa despensa.&lt;/p>
&lt;p>La forma ingenua: a cada mesa se le asigna &lt;strong>una estantería entera y contigua&lt;/strong>, dimensionada para el cliente más glotón imaginable. El problema salta a la vista: una mesa que pide poco deja casi toda su estantería vacía, pero esa estantería &lt;strong>ya está reservada&lt;/strong> y nadie más puede usarla. Con muchas mesas, la despensa se llena de estanterías medio vacías y no caben mesas nuevas, aunque sumando huecos sobre sitio de sobra.&lt;/p>
&lt;p>La forma de PagedAttention: la despensa se divide en &lt;strong>casilleros pequeños e idénticos&lt;/strong>. A cada mesa se le dan los casilleros que va necesitando, &lt;strong>uno a uno, donde haya hueco&lt;/strong> —no tienen que estar juntos—. Un &lt;strong>libro de mapas&lt;/strong> anota qué casilleros físicos tiene cada mesa y en qué orden. Cuando una mesa se va, sus casilleros vuelven al montón. No hay estanterías medio vacías: solo se desperdicia el último casillero de cada mesa, el que está a medio llenar. Eso es, casi literalmente, la memoria virtual de un sistema operativo aplicada al KV cache.&lt;/p>
&lt;h2 id="por-qué-la-memoria-contigua-fragmentaba">Por qué la memoria contigua fragmentaba&lt;/h2>
&lt;p>Reservar contiguo y por adelantado produce &lt;strong>tres&lt;/strong> desperdicios distintos:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Fragmentación de reserva.&lt;/strong> Apartas espacio para &lt;code>max_model_len&lt;/code> (p. ej. 8192 tokens) aunque la petición vaya a usar 800. Reservado y nunca usado.&lt;/li>
&lt;li>&lt;strong>Fragmentación interna.&lt;/strong> Dentro de lo reservado, lo que queda por encima de lo que de verdad usas en cada momento.&lt;/li>
&lt;li>&lt;strong>Fragmentación externa.&lt;/strong> Huecos entre reservas contiguas demasiado pequeños para una petición nueva, aunque sumados sobren.&lt;/li>
&lt;/ol>
&lt;p>El paper original de vLLM medía que los sistemas previos &lt;strong>desperdiciaban del 60% al 80%&lt;/strong> de la memoria de KV por estas tres vías (&lt;a href="https://arxiv.org/pdf/2309.06180">Kwon et al., SOSP 2023&lt;/a>). Es decir: en una GPU con sitio para 100 peticiones reales, solo cabían 20-40. La paginación ataca las tres a la vez —elimina la reserva (asignación on-demand) y la externa (los bloques no necesitan ser contiguos), y deja solo un resto de la interna: el último bloque parcial.&lt;/p>
&lt;h2 id="el-mecanismo-bloques-block-table-y-el-gather-del-kernel">El mecanismo: bloques, block table y el gather del kernel&lt;/h2>
&lt;p>El KV de una secuencia se trocea en &lt;strong>bloques lógicos&lt;/strong> de $b$ tokens (por defecto $b = 16$). Cada bloque lógico se mapea, vía la &lt;strong>block table&lt;/strong>, a un &lt;strong>bloque físico&lt;/strong> en algún punto de la HBM. La block table es el &amp;ldquo;libro de mapas&amp;rdquo;: una lista, por petición, de qué físico corresponde a cada lógico (&lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">implementación vLLM&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 250" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Bloques lógicos, block table y bloques físicos no contiguos">
&lt;text x="20" y="20" fill="currentColor" font-size="13">Petición A — bloques lógicos (orden de la secuencia)&lt;/text>
&lt;rect x="20" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="45" y="52" text-anchor="middle" fill="#2563eb">L0&lt;/text>
&lt;rect x="75" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="100" y="52" text-anchor="middle" fill="#2563eb">L1&lt;/text>
&lt;rect x="130" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="155" y="52" text-anchor="middle" fill="#2563eb">L2&lt;/text>
&lt;rect x="185" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5" stroke-dasharray="4 3"/>&lt;text x="210" y="52" text-anchor="middle" fill="#2563eb">L3*&lt;/text>
&lt;text x="20" y="100" fill="currentColor" font-size="13">block table: L0→F7 · L1→F2 · L2→F9 · L3→F4 (último, a medio llenar)&lt;/text>
&lt;text x="20" y="135" fill="currentColor" font-size="13">HBM física — casilleros donde haya hueco (no contiguos)&lt;/text>
&lt;rect x="20" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="40" y="171" text-anchor="middle" fill="currentColor" font-size="10">F0&lt;/text>
&lt;rect x="65" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="85" y="171" text-anchor="middle" fill="currentColor" font-size="10">F2·L1&lt;/text>
&lt;rect x="110" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="130" y="171" text-anchor="middle" fill="currentColor" font-size="10">F3&lt;/text>
&lt;rect x="155" y="150" width="40" height="34" fill="#f59e0b" opacity="0.7"/>&lt;text x="175" y="171" text-anchor="middle" fill="currentColor" font-size="10">F4·L3&lt;/text>
&lt;rect x="200" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="220" y="171" text-anchor="middle" fill="currentColor" font-size="10">F5&lt;/text>
&lt;rect x="290" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="310" y="171" text-anchor="middle" fill="currentColor" font-size="10">F7·L0&lt;/text>
&lt;rect x="380" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="400" y="171" text-anchor="middle" fill="currentColor" font-size="10">F9·L2&lt;/text>
&lt;text x="20" y="220" fill="currentColor" font-size="11">El kernel de atención hace un gather: recorre la block table y lee F7,F2,F9,F4 como si fueran contiguos.&lt;/text>
&lt;text x="20" y="238" fill="#f59e0b" font-size="11">* Solo F4 está a medio llenar: ese es el único desperdicio (≈ medio bloque por secuencia).&lt;/text>
&lt;/svg>
&lt;p>La clave es que &lt;strong>el kernel de atención sabe leer así&lt;/strong>. En lugar de asumir un tensor de KV contiguo, el kernel de PagedAttention recibe la block table y hace un &lt;strong>gather&lt;/strong>: para cada secuencia, recorre sus bloques físicos en el orden lógico y lee K y V como si estuvieran juntos. Por eso PagedAttention no es solo una estructura de datos: es un &lt;strong>kernel&lt;/strong> que sabe atender sobre memoria paginada. Y por eso el &lt;a href="https://blog.lo0.es/posts/flashattention-fundamentos/">backend de atención&lt;/a> y el block manager están atados —el segundo decide dónde vive el KV, el primero sabe leerlo de ahí.&lt;/p>
&lt;h2 id="el-block-manager-el-bibliotecario-de-la-despensa">El block manager: el bibliotecario de la despensa&lt;/h2>
&lt;p>El &lt;strong>block manager&lt;/strong> (en V1, el &lt;code>KVCacheManager&lt;/code>) es quien lleva el libro de mapas. Sus responsabilidades:&lt;/p>
&lt;ul>
&lt;li>Mantener un &lt;strong>pool de bloques físicos libres&lt;/strong> (una cola de bloques disponibles).&lt;/li>
&lt;li>&lt;strong>Asignar&lt;/strong> bloques a una secuencia cuando crece (un bloque nuevo cada $b$ tokens).&lt;/li>
&lt;li>&lt;strong>Liberar&lt;/strong> los bloques cuando la secuencia termina o es preemptada.&lt;/li>
&lt;li>Mantener las &lt;strong>block tables&lt;/strong> (logical→physical) de cada petición.&lt;/li>
&lt;li>Gestionar el &lt;strong>prefix caching&lt;/strong>: detectar bloques con contenido idéntico y compartirlos.&lt;/li>
&lt;li>Cuando se acaban los bloques libres, avisar al scheduler para que &lt;strong>preempte&lt;/strong> (ver &lt;a href="https://blog.lo0.es/posts/scheduler-step-vllm/">el post del scheduler&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>Cuando el block manager dice &amp;ldquo;no quedan bloques&amp;rdquo;, el scheduler tiene que bajar a alguien del tren. Por eso los dos presupuestos —tokens y bloques— son las dos manos del mismo motor.&lt;/p>
&lt;h2 id="prefix-caching-compartir-casilleros-con-copy-on-write">Prefix caching: compartir casilleros con copy-on-write&lt;/h2>
&lt;p>Aquí está la parte elegante. Si dos peticiones empiezan con el &lt;strong>mismo prefijo&lt;/strong> —el mismo system prompt, el mismo documento de contexto—, los primeros bloques de KV de ambas son &lt;strong>idénticos byte a byte&lt;/strong>. ¿Por qué calcularlos y guardarlos dos veces?&lt;/p>
&lt;p>vLLM le pone a cada bloque un &lt;strong>hash&lt;/strong> que resume su contenido (los tokens que lo formaron, más el hash del bloque anterior, para que el hash capture la posición). Mantiene una tabla global de bloques por hash. Cuando una petición nueva produce un bloque cuyo hash ya existe, &lt;strong>no asigna memoria nueva&lt;/strong>: apunta su block table al bloque físico que ya estaba (&lt;a href="https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html">automatic prefix caching, vLLM&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 200" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Prefix caching con copy-on-write">
&lt;text x="20" y="20" fill="currentColor" font-size="13">Prefijo común (system prompt) compartido entre A y B&lt;/text>
&lt;rect x="20" y="35" width="55" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="47" y="56" text-anchor="middle" fill="currentColor" font-size="10">F1&lt;/text>
&lt;rect x="80" y="35" width="55" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="107" y="56" text-anchor="middle" fill="currentColor" font-size="10">F2&lt;/text>
&lt;text x="200" y="56" fill="currentColor">← A y B apuntan los dos aquí (memoria compartida)&lt;/text>
&lt;path d="M47 90 V70" stroke="#2563eb" stroke-width="1.5" marker-end="url(#b)"/>&lt;text x="47" y="105" text-anchor="middle" fill="#2563eb" font-size="10">A&lt;/text>
&lt;path d="M107 90 V70" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#b)"/>&lt;text x="120" y="105" text-anchor="middle" fill="#7c3aed" font-size="10">B&lt;/text>
&lt;text x="20" y="140" fill="currentColor" font-size="13">A y B divergen → copy-on-write: B copia el bloque antes de escribir&lt;/text>
&lt;rect x="20" y="150" width="55" height="30" fill="#2563eb" opacity="0.7"/>&lt;text x="47" y="169" text-anchor="middle" fill="currentColor" font-size="10">F3·A&lt;/text>
&lt;rect x="90" y="150" width="55" height="30" fill="#7c3aed" opacity="0.7"/>&lt;text x="117" y="169" text-anchor="middle" fill="currentColor" font-size="10">F8·B (copia)&lt;/text>
&lt;defs>&lt;marker id="b" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">&lt;path d="M0 0 L8 4 L0 8 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;p>El &lt;strong>copy-on-write&lt;/strong> es la salvaguarda: mientras A y B comparten un bloque, ninguna lo puede modificar. En el momento en que una de las dos necesita escribir algo distinto en ese bloque (porque sus secuencias divergen, o en &lt;em>parallel sampling&lt;/em> / beam search donde varias ramas comparten prefijo), el block manager &lt;strong>copia&lt;/strong> el bloque para esa rama y solo entonces escribe (&lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">details, vLLM&lt;/a>). Es el mismo COW que usa &lt;code>fork()&lt;/code> en un SO: compartir hasta que alguien escriba.&lt;/p>
&lt;p>El ahorro es directo: si 50 peticiones comparten un system prompt de 1000 tokens, en lugar de 50 copias del KV de ese prefijo hay &lt;strong>una&lt;/strong>. Cómo maximizar ese ahorro en la práctica es el tema del &lt;a href="https://blog.lo0.es/posts/prefix-cache-hit-rate-engineering/">post de prefix cache hit rate&lt;/a>.&lt;/p>
&lt;h2 id="las-matemáticas-que-importan-cuánto-kv-cuántos-bloques">Las matemáticas que importan: cuánto KV, cuántos bloques&lt;/h2>
&lt;p>&lt;strong>Bytes de KV por token.&lt;/strong> Para un bloque transformer con $L$ capas, $h_{kv}$ cabezas de KV (GQA), dimensión por cabeza $d$ y $s$ bytes por elemento (2 en FP16):&lt;/p>
&lt;p>$$\text{KV/token} = 2 \cdot L \cdot h_{kv} \cdot d \cdot s$$&lt;/p>
&lt;p>Para un Llama-70B ($L=80$, $h_{kv}=8$, $d=128$, FP16):&lt;/p>
&lt;p>$$\text{KV/token} = 2 \cdot 80 \cdot 8 \cdot 128 \cdot 2 = 327680 \text{ bytes} \approx 320 \text{ KB}$$&lt;/p>
&lt;p>Un &lt;strong>bloque de 16 tokens&lt;/strong> ocupa $16 \times 320,\text{KB} = 5,12$ MB.&lt;/p>
&lt;p>&lt;strong>Cuántas peticiones caben.&lt;/strong> Si tras cargar los pesos quedan ~120 GB de los 320 del nodo para KV:&lt;/p>
&lt;p>$$\text{tokens de KV} = \frac{120 \cdot 10^9}{327680} \approx 366000 \text{ tokens} \approx 22900 \text{ bloques}$$&lt;/p>
&lt;p>Con contextos medios de 4000 tokens (250 bloques cada uno), eso son &lt;strong>~90 peticiones concurrentes&lt;/strong>. Ese número —no &lt;code>max_num_seqs&lt;/code>— es el techo real de concurrencia, y es exactamente el &amp;ldquo;presupuesto de bloques&amp;rdquo; del &lt;a href="https://blog.lo0.es/posts/scheduler-step-vllm/">scheduler&lt;/a>.&lt;/p>
&lt;p>&lt;strong>El desperdicio que queda.&lt;/strong> PagedAttention no llega a cero: cada secuencia desperdicia, de media, &lt;strong>medio bloque&lt;/strong> (el último, a medio llenar). Con bloques de 16 tokens y secuencias de 4000, eso es $8 / 4000 = 0,2%$ por secuencia —el famoso &amp;ldquo;~4%&amp;rdquo; agregado del paper incluye otros overheads—. La lección: el desperdicio no desaparece, &lt;strong>se acota&lt;/strong> al tamaño de un bloque.&lt;/p>
&lt;h2 id="el-compromiso-del-tamaño-de-bloque">El compromiso del tamaño de bloque&lt;/h2>
&lt;p>El &lt;code>block_size&lt;/code> (16 por defecto) es un compromiso, no una constante mágica:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Bloque&lt;/th>
&lt;th>Ventaja&lt;/th>
&lt;th>Inconveniente&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Pequeño (8)&lt;/td>
&lt;td>menos desperdicio interno; sharing de prefijo más fino&lt;/td>
&lt;td>más entradas de block table; más overhead de gestión y de gather&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Grande (32)&lt;/td>
&lt;td>menos metadatos; gather más eficiente&lt;/td>
&lt;td>más desperdicio en el último bloque; el prefix caching comparte con grano más grueso (menos hits)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Un bloque grande comparte peor: el prefix caching solo puede reutilizar bloques &lt;strong>completos e idénticos&lt;/strong>, así que con bloques de 32 dos prompts que coinciden en 20 tokens &lt;strong>no comparten nada&lt;/strong> (no llenan un bloque común), mientras que con bloques de 8 comparten dos bloques. El 16 por defecto es el punto que vLLM encontró razonable para la mayoría de cargas; merece la pena probarlo si tu carga tiene prefijos cortos muy repetidos.&lt;/p>
&lt;h2 id="los-10-knobs">Los 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Qué controla&lt;/th>
&lt;th>Coste si te pasas&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>block_size&lt;/code>&lt;/td>
&lt;td>tokens por bloque&lt;/td>
&lt;td>desperdicio / overhead (ver tabla)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>enable_prefix_caching&lt;/code>&lt;/td>
&lt;td>compartir bloques por hash&lt;/td>
&lt;td>casi ninguno; suele ir on&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>gpu_memory_utilization&lt;/code>&lt;/td>
&lt;td>cuántos bloques físicos hay&lt;/td>
&lt;td>OOM si demasiado alto&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;code>kv_cache_dtype&lt;/code> (FP8)&lt;/td>
&lt;td>bytes por elemento de KV&lt;/td>
&lt;td>calidad (medir, no asumir)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>swap_space&lt;/code>&lt;/td>
&lt;td>bloques que caben en host (SWAP)&lt;/td>
&lt;td>tráfico PCIe en preemption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>max_model_len&lt;/code>&lt;/td>
&lt;td>longitud máxima por petición&lt;/td>
&lt;td>menos peticiones si muy alto&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>política de evicción&lt;/td>
&lt;td>a quién se le quitan bloques&lt;/td>
&lt;td>hit rate de prefix cache&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>sliding window&lt;/td>
&lt;td>descartar KV viejo&lt;/td>
&lt;td>calidad en contextos largos&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>TP / sharding del KV&lt;/td>
&lt;td>reparto del KV entre GPUs&lt;/td>
&lt;td>tráfico NVLink&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>num_gpu_blocks (override)&lt;/td>
&lt;td>forzar el conteo de bloques&lt;/td>
&lt;td>OOM o infrautilización&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="cómo-se-conecta-con-el-resto-del-stack">Cómo se conecta con el resto del stack&lt;/h2>
&lt;p>&lt;strong>Con el scheduler.&lt;/strong> El &amp;ldquo;presupuesto de bloques&amp;rdquo; del &lt;a href="https://blog.lo0.es/posts/scheduler-step-vllm/">scheduler&lt;/a> lo administra este block manager. Cuando dice que no hay bloques, el scheduler preempta (RECOMPUTE por defecto).&lt;/p>
&lt;p>&lt;strong>Con el KV cache.&lt;/strong> El &lt;a href="https://blog.lo0.es/posts/kv-cache-fundamentos/">post de KV cache&lt;/a> explica &lt;em>qué&lt;/em> guarda cada token; este, &lt;em>cómo&lt;/em> se coloca en memoria sin fragmentar.&lt;/p>
&lt;p>&lt;strong>Con el prefix caching.&lt;/strong> El COW y los hashes de bloque son el mecanismo; el &lt;a href="https://blog.lo0.es/posts/prefix-cache-hit-rate-engineering/">hit rate engineering&lt;/a> es cómo exprimirlo (estructura de prompts, routing prefix-aware).&lt;/p>
&lt;p>&lt;strong>Con la cuantización del KV.&lt;/strong> Pasar el KV a &lt;a href="https://blog.lo0.es/posts/fp8-end-to-end-pesos-kv-calidad/">FP8&lt;/a> parte por la mitad los bytes/token: el mismo nodo cabe el doble de tokens. Es la palanca más directa sobre la concurrencia.&lt;/p>
&lt;p>&lt;strong>Con el backend de atención.&lt;/strong> El kernel de &lt;a href="https://blog.lo0.es/posts/flashattention-fundamentos/">FlashAttention/FlashInfer&lt;/a> tiene que saber atender sobre bloques paginados; el block manager decide dónde viven, el kernel sabe leerlos.&lt;/p>
&lt;p>&lt;strong>Con el disaggregated serving.&lt;/strong> Mover una petición de un pool de prefill a uno de decode en &lt;a href="https://blog.lo0.es/posts/disaggregated-serving-prefill-decode/">serving desagregado&lt;/a> es, en el fondo, &lt;strong>transferir sus bloques de KV&lt;/strong> entre motores —por NVLink o red—.&lt;/p>
&lt;p>&lt;strong>Con multi-LoRA.&lt;/strong> En &lt;a href="https://blog.lo0.es/posts/multi-lora-serving-fundamentos/">multi-LoRA serving&lt;/a>, la base comparte KV de prefijo entre peticiones de distintos adapters siempre que el prefijo sea idéntico.&lt;/p>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;PagedAttention elimina el desperdicio.&amp;rdquo;&lt;/strong> Lo &lt;strong>acota&lt;/strong>, no lo elimina. Queda el último bloque parcial por secuencia (~medio bloque) más los metadatos del block table. Es ~4% en vez de 60-80%, pero no es cero. Dimensionar como si fuera cero te deja sin colchón.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Bloques más grandes siempre rinden mejor.&amp;rdquo;&lt;/strong> El gather es algo más eficiente, sí, pero pierdes granularidad de &lt;em>sharing&lt;/em>: el prefix caching comparte peor y el desperdicio del último bloque crece. En cargas con muchos prefijos cortos repetidos, bloques pequeños pueden ganar.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;El prefix caching comparte KV entre usuarios, eso es un problema de privacidad.&amp;rdquo;&lt;/strong> Comparte solo bloques &lt;strong>idénticos token a token&lt;/strong> (mismo system prompt, mismo documento). No expone el contenido de un usuario a otro: si los tokens no coinciden, no hay bloque común. Lo que sí conviene vigilar es la &lt;strong>información por canales laterales de tiempo&lt;/strong> (un hit es más rápido que un miss), relevante solo en escenarios multi-tenant muy adversariales.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;FP8 en el KV es gratis: el doble de concurrencia.&amp;rdquo;&lt;/strong> Dobla los tokens que caben, sí, pero el KV en FP8 &lt;strong>degrada la calidad&lt;/strong> de forma medible en contextos largos. Es una palanca real, no un almuerzo gratis: hay que medir la calidad (&lt;a href="https://blog.lo0.es/posts/fp8-end-to-end-pesos-kv-calidad/">FP8 end-to-end&lt;/a>), no asumirla.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Volver a memoria contigua sería más simple y casi igual de bueno.&amp;rdquo;&lt;/strong> Es la nostalgia del tensor contiguo. Lo &amp;ldquo;simple&amp;rdquo; reintroduce el 60-80% de fragmentación: en una GPU, eso es la diferencia entre 30 y 90 peticiones concurrentes. La complejidad del block table se paga con creces.&lt;/p>
&lt;p>&lt;strong>SWAP frente a RECOMPUTE al preemptar.&lt;/strong> Configurar mucho &lt;code>swap_space&lt;/code> &amp;ldquo;para no perder KV&amp;rdquo; mete transferencias de gigabytes por el &lt;a href="https://blog.lo0.es/posts/pcie-topology-gpudirect-p2p-acs/">PCIe&lt;/a> en el camino crítico. En V1, RECOMPUTE suele ser mejor; el swap es para casos concretos.&lt;/p>
&lt;h2 id="conclusión">Conclusión&lt;/h2>
&lt;p>El cuello de botella de servir un LLM nunca fue solo cuánta memoria tienes, sino &lt;strong>cómo la repartes&lt;/strong>. Los primeros servidores trataban el KV cache como una estantería contigua por cliente y tiraban dos tercios de la HBM a la basura sin que apareciera en ningún dashboard. PagedAttention le robó al sistema operativo su mejor idea de hace cincuenta años —paginar— y la aplicó al sitio exacto donde dolía: casilleros pequeños, un libro de mapas, asignación bajo demanda y, de regalo, la posibilidad de que dos peticiones que empiezan igual compartan los mismos casilleros hasta que dejen de parecerse. El resultado no es magia: el desperdicio sigue ahí, pero acotado al tamaño de un bloque en vez de al tamaño del peor caso imaginable. Y esa diferencia —del 70% al 4%— es la que convirtió una GPU que servía a treinta clientes en una que sirve a noventa, sin tocar el hardware. La despensa no se hizo más grande; se organizó mejor.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/scheduler-step-vllm/">El pase: el scheduler step de vLLM&lt;/a> — el presupuesto de bloques que este block manager administra; cuando se agota, preemption.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/kv-cache-fundamentos/">KV cache: la memoria de trabajo&lt;/a> — &lt;em>qué&lt;/em> guarda cada token, el dato que aquí se pagina.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — cómo exprimir el sharing de bloques que el COW hace posible.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/flashattention-fundamentos/">FlashAttention v1/v2/v3/v4&lt;/a> — el kernel que sabe atender sobre KV paginado.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/fp8-end-to-end-pesos-kv-calidad/">FP8 end-to-end: pesos y KV&lt;/a> — partir por la mitad los bytes/token y doblar la concurrencia, midiendo la calidad.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/disaggregated-serving-prefill-decode/">Disaggregated serving: prefill y decode separados&lt;/a> — mover una petición entre pools es transferir sus bloques de KV.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/pcie-topology-gpudirect-p2p-acs/">PCIe, GPUDirect P2P y ACS&lt;/a> — por dónde viajan los bloques cuando se hace SWAP o se mueve KV entre GPUs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/multi-lora-serving-fundamentos/">Multi-LoRA serving&lt;/a> — compartir prefijo entre peticiones de distintos adapters.&lt;/li>
&lt;/ul>
&lt;h2 id="referencias">Referencias&lt;/h2>
&lt;ul>
&lt;li>W. Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023): &lt;a href="https://arxiv.org/pdf/2309.06180">https://arxiv.org/pdf/2309.06180&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching&lt;/em> (diseño, hashing de bloques): &lt;a href="https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html">https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching — Implementation&lt;/em> (block table, COW): &lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html&lt;/a>.&lt;/li>
&lt;li>H. Elshafie, &lt;em>Paged Attention from First Principles: A View Inside vLLM&lt;/em>: &lt;a href="https://hamzaelshafie.bearblog.dev/paged-attention-from-first-principles-a-view-inside-vllm/">https://hamzaelshafie.bearblog.dev/paged-attention-from-first-principles-a-view-inside-vllm/&lt;/a>.&lt;/li>
&lt;li>&lt;em>vAttention: Dynamic Memory Management for Serving LLMs without PagedAttention&lt;/em> (alternativa, contexto crítico): &lt;a href="https://arxiv.org/pdf/2405.04437">https://arxiv.org/pdf/2405.04437&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>