Где собака зарыта: 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.
Пишем CUDA kernel: от расстояний до топ-K за один запуск
Разобьём задачу на три фазы, которые будут выполнены в одном kernel (или в последовательности kernel-запусков без хоста):
- Фаза 1 — каждый блок обрабатывает chunk из M=512 запросных эмбеддингов против всей базы (N векторов). Вычисляем косинусное расстояние и складываем top-K в локальный heap.
- Фаза 2 — объединяем результаты всех блоков в один глобальный top-K (если база большая). Тут можно второй kernel с атомарными операциями или atomicCAS.
- Фаза 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, хотя и в сжатом виде.
Подводные камни и как их обойти
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, чтобы понять, как собрать пайплайн с нуля.