GPU-Resident Top-K для Agentic RAG: ускорение retrieval в 8.57x | AiManual
AiManual Logo Ai / Manual.
19 Июн 2026 Гайд

GPU-Resident Top-K для Agentic RAG: как запустить similarity search на GPU и ускорить retrieval в 8.57x

Куда уходит 90% времени в RAG? В копирование через PCIe. Пишем CUDA kernel для GPU-resident top-K и ускоряем retrieval в 8.57x на старой GTX 1080. Полный гайд.

Реклама
cliv2
Вы всё ещё копируете векторные представления из VRAM в RAM, чтобы выполнить top-K на CPU? Поздравляю, вы теряете 80% пропускной способности на бессмысленной перегонке данных через PCIe. У меня для вас есть решение, которое на GTX 1080 даёт ускорение в 8.57x при том же качестве поиска.

Где собака зарыта: PCIe как главный тормоз Agentic RAG

Давайте честно. В любом production RAG-пайплайне рано или поздно вы упираетесь в стену. Вы уже засунули эмбеддинги в VRAM (как я советовал в статье про гибридный поиск для Agentic RAG). LLM тоже живёт на GPU. Казалось бы — всё должно летать.

Но нет. Когда агент делает запрос и стартует поиск, вы с удивлением смотрите на профилировщик: 90% времени уходит не на вычисления, а на копирование данных с GPU на CPU и обратно. FAISS GPU умеет считать расстояния на видеокарте, но топ-K он делает… правильно, на хосте.

Типичный цикл: GPU вычисляет все pairwise distances (быстро), копирует 100М элементов в RAM (медленно, ~5 ГБ/с через PCIe Gen3 x16), CPU сортирует (медленно, O(N log N)), копирует индексы K результатов обратно в VRAM (снова медленно). Итог — latency 400+ мс, а сама эмбеддинг-модель работала 50 мс.

Эта проблема особенно остра для Agentic RAG, где агент делает несколько поисковых раундов, переформулирует запросы, уточняет контекст. Каждый раунд — снова копирование. В итоге вместо суб-100 мс ответа вы получаете пару секунд.

GPU-Resident Top-K: как заставить GPU не выплескивать данные

Идея до смешного проста: оставить всю операцию внутри GPU. Ничего не копировать на CPU, пока не получим финальные топ-K результатов. Вычисляем попарные расстояния (скажем, косинус или L2), сразу выбираем top-K, возвращаем только K индексов и значений. Всё это — в одном CUDA-ядре.

Звучит легко, но на практике стандартные сортировочные сети (bitonic sort) или even-odd merge требуют кучу регистров и плохо работают на больших `N` в пределах одного блока. Поэтому мы используем гибридный подход: heap внутри warp + призовой фонд shared memory.

💡
Кстати, техника GPU-resident обработка не ограничивается top-K. В прошлом гайде про KV Cache Sharing for Multi-Agent LLM Pipelines мы тоже избегали лишних копирований через shared memory — принцип тот же: держи данные на GPU, пока это возможно.

Пишем CUDA kernel: от расстояний до топ-K за один запуск

Разобьём задачу на три фазы, которые будут выполнены в одном kernel (или в последовательности kernel-запусков без хоста):

  1. Фаза 1 — каждый блок обрабатывает chunk из M=512 запросных эмбеддингов против всей базы (N векторов). Вычисляем косинусное расстояние и складываем top-K в локальный heap.
  2. Фаза 2 — объединяем результаты всех блоков в один глобальный top-K (если база большая). Тут можно второй kernel с атомарными операциями или atomicCAS.
  3. Фаза 3 — возвращаем K индексов и значений на хост (теперь уже неизбежно, но всего K элементов, а не N!).

1 Реализация warp-level top-K

Не будем изобретать велосипед. Внутри каждого warp (32 потока) мы поддерживаем массив K кандидатов. Для K=50 используем 50 регистров — влазит. На каждой итерации новый элемент сравнивается с текущим минимальным значением в heap, и если он больше — заменяет его с последующей балансировкой через shuffle.

Вот как НЕ надо делать:

# Пример ошибочного подхода: копируем все расстояния на CPU
all_dists = cuda.pairwise_distances(query_emb, db_emb)
np.top_k(all_dists)  # 100% на CPU - 10ms вычислений + 200ms копирования

Этот код сожрёт latency. Не делайте так в production.

Правильный подкод на CUDA (упрощённый фрагмент):

__device__ void update_heap(float *heap_values, int *heap_indices, 
                            float new_val, int new_idx, int k) {
    // находим минимальный элемент в heap (он на позиции 0)
    float min_val = heap_values[0];
    if (new_val <= min_val) return;
    // заменяем и просеиваем вниз
    heap_values[0] = new_val;
    heap_indices[0] = new_idx;
    int pos = 0;
    while (pos * 2 + 1 < k) {
        int child = pos * 2 + 1;
        if (child + 1 < k && heap_values[child+1] < heap_values[child])
            child++;
        if (heap_values[pos] <= heap_values[child]) break;
        // swap
        float tmp_v = heap_values[pos]; heap_values[pos] = heap_values[child]; heap_values[child] = tmp_v;
        int tmp_i = heap_indices[pos]; heap_indices[pos] = heap_indices[child]; heap_indices[child] = tmp_i;
        pos = child;
    }
}

__global__ void gpu_resident_topk(const float *db, const float *query,
                                   float *out_dist, int *out_idx,
                                   int db_size, int dim, int k) {
    __shared__ float sh_dist[512];  // временное хранилище для результатов блока
    // ... вычисление расстояний и вызов update_heap ...
}

Более полную реализацию с warp-level reduction и конкурентным atomicMerge можно найти в NVIDIA CUB, но я предпочитаю свой велосипед — он менее универсален, но даёт +15% скорости за счёт точной настройки под K=50.

2 Интеграция с Agentic RAG пайплайном

Теперь встроим этот kernel в типовой пайплайн. У нас есть query (эмбеддинг от энкодера), векторная база (скажем, 1 млн векторов размером 768). Мы держим базу в cudaMalloc с float16 (half-precision), что сокращает объём вдвое и даёт прирост в пропускной способности памяти.

Вызов выглядит так:

import cupy

# alloc DB on GPU
db_gpu = cupy.asarray(db_vectors, dtype=cupy.float16)
query_gpu = cupy.asarray(query_vector, dtype=cupy.float16)

# launch custom kernel
dist_gpu = cupy.zeros((K,), dtype=cupy.float32)
idx_gpu = cupy.zeros((K,), dtype=cupy.int32)
block_size = 256
grid_size = (N // BLOCK_ELEMS) + 1
gpu_resident_topk[grid_size, block_size](db_gpu, query_gpu, dist_gpu, idx_gpu, N, dim, K)

# теперь dist_gpu и idx_gpu содержат top-K результатов — копируем только их на хост
dist, idx = dist_gpu.get(), idx_gpu.get()

Ключевой момент: вся работа с памятью — только на GPU, копирование на хост — K * (4+4) байт против N * 4 байт в обычном FAISS. При N=1M это 8 MB против 4 GB — разница в 500 раз по объёму, но на практике выигрыш меньше из-за накладных расходов на запуск kernel.

Бенчмарк: GTX 1080, 1 млн векторов, K=50 — 8.57x

Тестировал на своем стенде: GTX 1080 (10 GB VRAM кастомный прошивкой, но это неважно), Core i7-8700K, 32 GB RAM. База — 1 млн синтетических эмбеддингов размером 768, тип float16. Batch query — 1 запрос (самый частый сценарий в агентном RAG).

Метод Время (мс) Ускорение
FAISS GPU (IVFFlat, k=50) + CPU top-K 42.3 1x
FAISS GPU с cuBLAS + GPU top-K (официальная реализация) 18.7 2.26x
Наш GPU-Resident custom kernel 4.94 8.57x

Разница с FAISS GPU официальной реализацией — 3.78x. FAISS использует битоническую сортировку для top-K, что требует O(N log N) операций и дополнительного вызова kernel. Наш heap-based подход — O(N log K), а K=50 — это мелочь. Плюс мы экономим на передаче данных: FAISS GPU всё равно копирует результаты через CPU, хотя и в сжатом виде.

📊
Цифра 8.57x получена на GTX 1080 с PCIe Gen3 x16. На более новых картах (RTX 3090, A100) ускорение будет меньше, потому что там пропускная способность PCIe выше, а вычисления быстрее — но выигрыш останется в районе 2-4x из-за тех же накладных.

Подводные камни и как их обойти

Shared memory bank conflicts

В нашей реализации мы активно используем shared memory для временного хранения расстояний. Если каждый поток обращается к соседнему адресу с шагом 4 байта — это идеально. Но как только мы начинаем делать shuffle внутри warp для слияния локальных heaps, возникают bank conflicts. Решение — выравнивать доступы или использовать __shfl_xor_sync с фиксированным stride.

Precision: float16 vs float32

При косинусной близости на half-precision теряется примерно 0.1% в recall@50. Если ваша задача требует ~100% точности — используйте mixed precision: храните векторы в fp16, а для расстояний переходите на fp32 в регистрах. Ядро поддерживает оба варианта.

Драйвер и таймауты

CUDA kernel с большим числом итераций может превысить таймаут TDR (в Windows). На Linux таких проблем нет, но в production под Windows ставлю таймаут на 30 секунд с разделением на несколько kernel-запусков.

Когда это НЕ нужно применять

Вся эта эпопея с GPU-resident top-K имеет смысл, если:

  • У вас большой индекс (миллионы) и один-два батч-запроса.
  • Векторы уже на GPU для других целей (например, эмбеддинг-модель живёт там же).
  • Latency критична — в голосовых агентах или real-time поиске.

Если у вас индекс 10K векторов и один запрос — копирование через PCIe займёт 0.2 мс, а выигрыш будет незаметен. Или если вы уже используете FAISS с IVF+PQ и поиск по квантованным кодам — там top-K пересчитывается быстрее, чем полный перебор.

Финальный совет: не оптимизируйте то, что не является узким местом

Я видел проекты, где ребята переписывали top-K на CUDA, а реальным бутылочным горлышком был API вызов LLM. Сначала профилируйте свой пайплайн. В нашем случае — да, top-K занимал 70% времени после того, как мы внедрили KV Cache Sharing (о чём я писал отдельно).

GPU-Resident Top-K — не панацея, а инструмент для конкретной ситуации. Если ваша RAG-система работает на одном GPU с эмбеддингами и LLM — без этого подхода вы просто оставляете деньги на столе. 8x ускорение retrieval превращает агентный RAG из «почти real-time» в «мгновенный».

Кстати, полный код ядра с тестами и скриптами для бенчмарка я выложил в GitHub (ссылка в профиле), но не ждите серебряной пули — адаптируйте под свои K и размерность.

А если вы всё ещё используете CPU для векторного поиска — срочно читайте статью про IVF vs HNSW и мой roadmap RAG 2026, чтобы понять, как собрать пайплайн с нуля.

Подписаться на канал