Atención paginada en modelos de lenguaje grandes LLM

Cuando se ejecutan LLM a escala, la verdadera limitación es la memoria de GPU en lugar de la computación, principalmente porque cada solicitud requiere una caché KV para almacenar datos a nivel de token. En las configuraciones tradicionales, se reserva un gran bloque de memoria fija por solicitud en función de la longitud máxima de la secuencia, lo que genera un espacio no utilizado significativo y limita la simultaneidad. Paged Attention mejora esto al dividir el caché KV en fragmentos más pequeños y flexibles que se asignan solo cuando es necesario, de manera similar a cómo funciona la memoria virtual. También permite que múltiples solicitudes con el mismo mensaje inicial compartan memoria y solo la dupliquen cuando sus resultados comiencen a diferir. Este enfoque mejora enormemente la eficiencia de la memoria, permitiendo un rendimiento significativamente mayor con muy poca sobrecarga.

En este artículo, simulamos el ingenuo asignador de caché KV, creamos una implementación funcional de Atención paginada con una tabla de bloques y uso compartido de prefijos de copia en escritura, y medimos la brecha de utilización en tamaños de lotes de 10 a 200 solicitudes simultáneas.

importar matemáticas importar aleatorio importar numpy como np importar matplotlib.pyplot como plt importar matplotlib.patches como mpatches de colecciones importar defaultdict random.seed(42) np.random.seed(42)

Antes de simular algo, necesitamos saber cuánta memoria de GPU cuesta realmente un solo token. Esto depende completamente de la arquitectura del modelo. Usamos una configuración estilo GPT: 32 capas, 32 cabezas de atención, 128 dimensiones por cabeza, almacenadas en fp16. El factor de 2 al principio representa las proyecciones de clave y valor (no hay caché Q; las consultas se recalculan en cada paso). Multiplicarlos nos da 524.288 bytes, o 512 KB, por token. Esta es la unidad fundamental sobre la que se construye todo lo demás: los tamaños de preasignación, el recuento de páginas y la memoria desperdiciada se escalan directamente a partir de este número.

NUM_LAYERS = 32 NUM_HEADS = 32 HEAD_DIM = 128 BYTES_FP16 = 2 PAGE_SIZE = 16 # tokens por página (valor predeterminado de vLLM) MAX_SEQ_LEN = 2048 KV_BYTES_PER_TOKEN = 2 * NUM_LAYERS * NUM_HEADS * HEAD_DIM * BYTES_FP16 KV_MB_PER_TOKEN = KV_BYTES_PER_TOKEN / 1024/1024

El enfoque ingenuo es simple: cuando llega una solicitud, se asigna un bloque contiguo de memoria de GPU con el tamaño de la longitud máxima de secuencia (2048 tokens en este caso). Esto sucede porque la duración de la respuesta se desconoce desde el principio, por lo que se reserva el peor de los casos.

AVG_RESPONSE está establecido en 500, que es un promedio realista para un chatbot de producción. Multiplicar por KV_MB_PER_TOKEN da lo que realmente está escrito versus lo que estaba bloqueado. La brecha es el desperdicio.

Los números concretan el problema. Cada solicitud preasigna 1024 MB, pero utiliza solo 250 MB (24,4 % de utilización). Los 774 MB restantes permanecen reservados durante toda la duración de la solicitud y no están disponibles para ninguna otra solicitud. Entre 100 usuarios simultáneos, son 75 GB de memoria GPU que no hacen nada. Este no es un caso extremo: es el comportamiento predeterminado de todo sistema que no implementa la asignación paginada, y es exactamente la razón por la que los sistemas de servicio ingenuos chocan contra un muro OOM mucho antes de que la GPU se sature computacionalmente.

print("=" * 60) print("SECCIÓN 1 – Caché KV ingenuo: el problema del desperdicio") print("=" * 60) AVG_RESPONSE = 500 # tokens promedio realistas generados pre_allocated_mb = MAX_SEQ_LEN * KV_MB_PER_TOKEN Actually_used_mb = AVG_RESPONSE * KV_MB_PER_TOKEN print(f"nCaché de KV por token: {KV_BYTES_PER_TOKEN:,} bytes") print(f"Preasignado/solicitud: {pre_allocated_mb:.2f} MB ({MAX_SEQ_LEN} tokens)") print(f"Realmente utilizado/solicitud: {actually_used_mb:.2f} MB ({AVG_RESPONSE} tokens)") print(f"Utilización: {actually_used_mb / pre_allocated_mb * 100:.1f}%") print(f"Desperdicio por solicitud: {pre_allocated_mb – Actually_used_mb:.2f} MB") NUM_USERS = 100 Wasted_gb = (pre_allocated_mb – Actually_used_mb) * NUM_USERS / 1024 print(f"nEn {NUM_USERS} usuarios simultáneos → {wasted_gb:.2f} GB desperdiciados") print("n→ Los sistemas ingenuos utilizan solo entre el 20 y el 38% de la memoria caché KV asignada") print(" (fuente: documento original Paged Attention / vLLM)")

Aquí se presentan dos clases para simular cómo funciona realmente la atención paginada en el nivel de administración de memoria.

PagePool representa el grupo de memoria física de la GPU: una matriz plana de páginas del mismo tamaño, cada una con 16 tokens. Mantiene una lista gratuita y un recuento de referencias por página. Cuando el recuento de referencias de una página cae a cero, se devuelve inmediatamente a la lista gratuita y queda disponible para cualquier nueva solicitud. Esta es la diferencia clave con la asignación ingenua: no hay agujeros reservados, ni fragmentación, ni memoria vinculada a una solicitud finalizada.

PagedRequest representa una única solicitud de inferencia. Contiene un block_table, una lista que asigna índices de páginas lógicas a identificadores de páginas físicas en el grupo. Cada vez que se llama a generate_token() y el recuento de tokens cruza el límite de una página, se reclama una nueva página física del grupo. Ningún recuerdo se toca antes de que sea necesario.

Se ejecutan cinco solicitudes con recuentos de tokens de 320, 48, 160, 96 y 272. El resultado muestra páginas asignadas proporcionalmente al uso real: req-1 con 48 tokens obtiene 3 páginas, req-0 con 320 tokens obtiene 20. Cuando se libera req-1, sus 3 páginas regresan directamente al grupo y se pueden reutilizar inmediatamente. La utilización del grupo del 10,9% parece baja solo porque se aprovisionaron 512 páginas para 5 solicitudes pequeñas; en un grupo de producción completamente cargado, se ubicaría cerca del rango del 98% visto en la Sección 4. "0 tokens desperdiciados" en la columna de la última página es un artefacto inicial: los cinco recuentos de tokens resultan ser múltiplos exactos de 16. En la práctica, el desperdicio promedio de la última página es PAGE_SIZE / 2 = 8 tokens por solicitud.

print("n" + "=" * 60) print("SECCIÓN 2 – Atención paginada: Páginas + Tabla de bloques") print("=" * 60) """ En lugar de un gran bloque contiguo por solicitud: – La caché KV se divide en páginas de tamaño fijo (tokens PAGE_SIZE cada una) – Las páginas se asignan según demanda, pueden vivir en cualquier lugar de la memoria de la GPU – Cada solicitud mantiene una block_table: índice lógico → ID de página física """ clase PagePool: def __init__(self, total_pages): self.free = list(range(total_pages)) self.total = total_pages self.ref_count = defaultdict(int) def allocate(self): si no self.free: rise MemoryError("OOM – no hay páginas libres") pid = self.free.pop(0) self.ref_count[pid] = 1 return pid def release(self, pid): self.ref_count[pid] -= 1 if self.ref_count[pid] <= 0: self.free.append(pid) del self.ref_count[pid] def share(self, pid): """Incrementar el recuento de referencias: otra solicitud está compartiendo esta página.""" self.ref_count[pid] += 1 def cow_copy(self, pid): """CoW: asignar una nueva página, disminuir la referencia en la anterior one.""" new_pid = self.allocate() self.release(pid) return new_pid @property def utilisation(self): return (self.total – len(self.free)) / self.total * 100 class PagedRequest: def __init__(self, req_id, pool: PagePool): self.id = req_id self.pool = pool self.block_table =[]# índice lógico → ID de página física self.tokens = 0 def generate_token(self): if self.tokens % PAGE_SIZE == 0: # límite de página → asignar nueva página self.block_table.append(self.pool.allocate()) self.tokens += 1 def free(self): para pid en self.block_table: self.pool.release(pid) self.block_table.clear() pool = PagePool(total_pages=512) solicitudes = [PaggedRequest(f"req-{i}", pool) para i en rango(5)] token_counts = [320, 48, 160, 96, 272] para solicitud, n en zip(solicitudes, token_counts): para _ en rango(n): req.generate_token() print("nEstado de solicitud después de generación:") print(f" {'ID':<10} {'Tokens':>8} {'Pages':>7} {'Last-page Waste':>16}") para solicitud en solicitudes: desperdicio = req.tokens % PAGE_SIZE desperdicio = PAGE_SIZE – desperdicio si el desperdicio es 0 print(f" {req.id:<10} {req.tokens:>8} {len(req.block_table):>7} {residuos:>16} tokens") print(f"nUtilización del grupo: {pool.utilisation:.1f}%") solicitudes[1].free() print(f"Después de liberar req-1 → utilización: {pool.utilisation:.1f}% (páginas inmediatamente reutilizables)")

En producción, casi todas las solicitudes a un LLM implementado llevan el mismo mensaje del sistema: las instrucciones que definen el comportamiento del modelo. Bajo una asignación ingenua, cada una de esas solicitudes almacena su propia copia completa del caché KV del indicador del sistema. Con 10 solicitudes simultáneas y un aviso del sistema de 200 tokens, es decir, 10 copias idénticas de los mismos datos que ocupan regiones de memoria separadas.

Aquí se reutiliza el mismo PagePool de la Sección 2, ampliado con dos métodos: share() incrementa el recuento de referencias de una página sin asignar nada nuevo, y cow_copy() asigna una página nueva y disminuye el recuento de referencias en la original. Se crea una instancia de un nuevo grupo y el mensaje del sistema se codifica en 13 páginas: math.ceil(200/16). Cada una de las 10 solicitudes de usuario llama a share() en las 13 páginas, apuntando sus tablas de bloques a la misma memoria física. No se asignan páginas nuevas. El recuento de árbitros en cada página compartida simplemente aumenta a 11.

Los ahorros son inmediatos: la asignación ingenua requeriría 130 páginas en 10 solicitudes. Con CoW, sólo existen 13 páginas físicas. Son 936 MB guardados desde un único prefijo compartido.

Cuando req-3 genera su primer token único, se llama a cow_copy() en su última página compartida: la página 12. Se asigna una nueva página 13 como copia privada de req-3 y el recuento de referencias en la página 12 se reduce en uno. Las otras 9 solicitudes continúan apuntando a la página 12, sin verse afectadas en absoluto. Este es el contrato CoW: compartido hasta la divergencia, privado sólo cuando sea necesario.

print("n" + "=" * 60) print("SECCIÓN 3 – Copia en escritura: Avisos del sistema compartidos") print("=" * 60) """ Si N solicitudes comparten un aviso del sistema, la asignación ingenua almacena N copias. Con CoW, todas las solicitudes apuntan a las MISMAS páginas físicas. Se realiza una copia privada solo cuando una solicitud escribe un token divergente. """ cow_pool = PagePool(total_pages=512) SYSTEM_TOKENS = 200 system_pages = math.ceil(SYSTEM_TOKENS / PAGE_SIZE) share_pids = [cow_pool.allocate() for _ in range(system_pages)] print(f"nIndicador del sistema → {system_pages} páginas compartidas: {shared_pids}") N = 10 user_tables =[]para i en rango(N): tabla = lista(shared_pids) para pid en compartido_pids: cow_pool.share(pid) # recuento de referencias arriba – sin copia física user_tables.append(table) save_mb = (system_pages * N – system_pages) * PAGE_SIZE * KV_MB_PER_TOKEN print(f"nAlmacenamiento del mensaje del sistema para {N} solicitudes:") print(f" Naive: {system_pages * N} páginas ({system_pages * N * PAGE_SIZE * KV_MB_PER_TOKEN:.1f} MB)") print(f" CoW: {system_pages} páginas ({system_pages * PAGE_SIZE * KV_MB_PER_TOKEN:.1f} MB)") print(f" Guardado: {saved_mb:.1f} MB") old_pid = user_tables[3][-1] new_pid = cow_pool.cow_copy(old_pid) tablas_usuario[3][-1] = new_pid print(f"nReq-3 diverge → CoW: página antigua {old_pid} → nueva página {new_pid}") print(f"Todas las demás solicitudes {N-1} aún comparten la página {old_pid} no afectada")

Se definen dos funciones para medir la utilización bajo cada enfoque en diferentes tamaños de lotes.

naive_utilisation extrae recuentos de tokens de una distribución normal con avg=500 y std=200, recortados a [200, 2048]. Esto refleja una distribución de producción realista: la mayoría de las respuestas oscilan entre 200 y 800 tokens, aunque ocasionalmente hay respuestas largas. Para cada solicitud, el bloque completo de 2048 ranuras se preasigna independientemente. La utilización es entonces actual_tokens_sum / (2048 × n): la proporción entre lo que se escribió y lo que se reservó.

paged_utilisation toma el mismo recuento de tokens real pero calcula cuántas páginas necesitaría cada solicitud: ceil(tokens/16). El único desperdicio es la cola sin completar de la última página de cada solicitud, que tiene un promedio de 8 tokens. La utilización es suma_tokens_actual / (páginas_asignadas × 16).

Los resultados se analizan en tamaños de lote de 10, 25, 50, 100 y 200. La utilización ingenua ronda el 24 % en todos los tamaños de lote, con cierta variación en lotes más pequeños debido al ruido del muestreo, que es exactamente avg/max_seq = 500/2048. No mejora con la escala porque el desperdicio es estructural, no estadístico.

La utilización paginada se mantiene estable en ~98,5 % independientemente del tamaño del lote, porque el desperdicio por solicitud está limitado por una única página parcial y no escala con max_seq_len en absoluto. La brecha entre los dos números (aproximadamente 74 puntos porcentuales) es directamente lo que permite a vLLM acomodar entre 2 y 4 veces más solicitudes simultáneas en la misma memoria de GPU.

print("n" + "=" * 60) print("SECCIÓN 4 – Utilización: Naive vs Paged") print("=" * 60) def naive_utilisation(n, max_seq=2048, avg=500, std=200): actual = np.clip(np.random.normal(avg, std, n).astype(int), 200, max_seq) devuelve actual.sum() / (max_seq * n) * 100, def real paged_utilisation(actual_tokens, page_size=PAGE_SIZE): páginas = np.ceil(actual_tokens / page_size).astype(int) devuelve actual_tokens.sum() / (pages * page_size).sum() * 100 lotes_tamaños = [10, 25, 50, 100, 200] ingenuo_u, paginado_u =[],[]print(f"n {'Batch':>6} {'Naive':>8} {'Pagged':>8}") para bs en tamaños de lote: nu, actual = naive_utilisation(bs) pu = paged_utilisation(actual) naive_u.append(nu) paged_u.append(pu) print(f" {bs:>6} {nu:>7.1f}% {pu:>7.1f}%")

Consulta el cuaderno completo aquí. Además, no dude en seguirnos en Twitter y no olvide unirse a nuestro SubReddit de más de 120.000 ML y suscribirse a nuestro boletín. ¡Esperar! estas en telegrama? Ahora también puedes unirte a nosotros en Telegram.

Soy graduado en ingeniería civil (2022) de Jamia Millia Islamia, Nueva Delhi, y tengo un gran interés en la ciencia de datos, especialmente las redes neuronales y su aplicación en diversas áreas.