linear-embedding-layer

LinearUVMEmbeddingLayer 深度分析

概述

LinearUVMEmbeddingLayer 是 NV Embedding Cache SDK 中第二层复杂度的嵌入层实现。它通过组合 GPU 集合关联缓存EmbedCacheSA)与 UVM(Unified Virtual Memory)线性后备表(UVM table),实现了”GPU 缓存命中则极速返回,缓存未命中则自动回退到 UVM 内存”的混合查找路径。查找过程完全在 GPU 上完成,无需回退到 CPU(与 HierarchicalEmbeddingLayer 不同)。

该层对应 README 中的第二种配置场景:

一些嵌入缓存在 GPU 内存中,所有嵌入在系统或 GPU 的线性内存中:使用带 LinearUVM 缓存类型的 NVEmbedding(Python)/ LinearUVMEmbeddingLayer(C++)。

文件位置:include/linear_embedding_layer.hpp(声明)和 src/linear_embedding_layer.cu(实现)。


与 GPUEmbeddingLayer 的核心差异

维度 GPUEmbeddingLayer LinearUVMEmbeddingLayer
数据存储 用户传入的 GPU 线性指针 GPU 集合关联缓存 + UVM 线性表
查找路径 cuEmbed 直读 EmbedCacheSA::lookup + UVM fallback
insert/erase warn-only(无效果) 真正支持:插入到 cache、从 cache 擦除
缺失处理 无缺失概念(全命中) cache miss → UVM table 透明回退
智能插入 AutoInsertHandler 自动提升冷门键
命中率报告 固定 1.0 真实命中率统计

核心设计目标:当完整嵌入表无法完全放入 GPU 显存(但可以放入 UVM 内存),通过一个小型 GPU 高速缓存 + UVM 大容量后端的组合,既保证热数据的高速访问,又保证全量数据的可用性。


配置结构体 LinearUVMEmbeddingLayer::Config

struct Config {
  std::string layer_name;                              // 层名称
  std::shared_ptr<InsertHeuristic> insert_heuristic;   // 插入策略(nullptr = 默认)
  int64_t min_insert_freq_gpu = 0;                     // 两次 GPU 插入之间的最小查找次数
  int64_t min_insert_size_gpu = 1 << 16;               // 触发 GPU 插入的最小键数
};
  • insert_heuristic:决定何时触发自动插入。默认为 DefaultInsertHeuristic,阈值为命中率低于 0.75。可用 NeverInsertHeuristic 完全禁用自动插入
  • min_insert_freq_gpu:GPU 插入频率控制。设为 0 表示每次都评估是否插入,设为正值表示跳过前 N 次查找
  • min_insert_size_gpu:批处理阈值。缺页键先收集,达到 65536 个时才批量插入到 GPU 缓存,避免频繁的小批量插入

底层架构:GpuTable + EmbedCacheSA

LinearUVMEmbeddingLayer 不直接操作 GPU 缓存——它委托给 GpuTable<KeyType> 对象。而 GpuTable 的核心是 EmbedCacheSA<KeyType, KeyType>——一个集合关联软件管理缓存

GpuTable 的构建

// src/gpu_table.cu: 构造函数
template <typename KeyType>
GpuTable<KeyType>::GpuTable(const GPUTableConfig& config, allocator_ptr_t allocator) {
  if (config.modify_on_gpu) {
    cache_ = std::make_shared<CacheSADeviceModify<KeyType, KeyType>>(...);
  } else {
    cache_ = std::make_shared<CacheSAHostModify<KeyType, KeyType>>(...);
  }
  cache_->init();
}

对于 LinearUVMEmbeddingLayer,关键在 GPUTableConfig 中的两个 UVM 相关字段:

  • uvm_table:指向 UVM 线性内存的指针,这是全量嵌入表
  • count_misses:必须为 true,因为需要收集 miss 计数来驱动 AutoInsertHandler

GPUTableConfig 完整字段

struct GPUTableConfig {
  int device_id{0};                    // GPU 设备编号
  size_t cache_size;                   // GPU 缓存总大小(字节)
  int64_t row_size_in_bytes;           // 每行嵌入向量字节数
  void* uvm_table{nullptr};            // UVM 线性后备表指针
  bool count_misses{true};             // 是否收集 miss 计数
  int64_t max_modify_size{1 << 20};    // 单次修改操作的最大条目数
  DataType_t value_dtype;              // 数据类型
  cudaStream_t private_stream{0};      // 私有 CUDA 流
  bool disable_uvm_update{false};      // 禁用 UVM 表更新
  bool uvm_cpu_accumulate{true};       // UVM 累积使用 CPU
  bool data_storage_on_host{false};    // 缓存数据存储在主机上
  bool modify_on_gpu{true};            // 修改操作在 GPU 上
  int64_t invalid_key{-1};             // 无效键值
};

查找路径的完整调用链

LinearUVMEmbeddingLayer::lookup()
  │
  ├── ① ScopedDevice: 切换 GPU 设备
  │
  ├── ② BufferWrapper 包装 keys / output / offsets
  │
  ├── ③ 决定是否收集 misses:
  │     if (hitrates 非空 || insert_heuristic 不是 NeverInsertHeuristic)
  │     → 需要收集 miss → gpu_table_->reset_lookup_counter()
  │
  ├── ④ 分流:
  │    ├── 有 pool_params → 按 value_dtype 模板化分发
  │    │    ├── Float32 → gpu_table_->find_and_combine_bw<KeyType, float>(...)
  │    │    └── Float16 → gpu_table_->find_and_combine_bw<KeyType, __half>(...)
  │    └── 无 pool_params → gpu_table_->find_bw(...)
  │
  ├── ⑤ 如果 output 在 Host → cudaMemcpyAsync 回传
  │
  └── ⑥ 需要收集 miss?
       ├── gpu_table_->get_lookup_counter() 获取 miss 数
       ├── cudaStreamSynchronize(lookup_stream) 同步
       ├── 计算命中率 hitrate = 1 - misses/num_keys
       ├── 写入 hitrates 指针(如果需要)
       └── AutoInsertHandler::auto_insert() 自动插入

GpuTable::find() 的 UVM 模式

config_.uvm_table 不为空时,GpuTable::find() 进入 UVM 模式:

if (config_.uvm_table) {
    std::unique_lock uvm_lock(uvm_table_mutex_, std::defer_lock);
    if (!config_.private_stream) {
      uvm_lock.lock();
    }
    run_find_uvm<KeyType, CacheType>(config_, cache_, ctx, num_keys, keys,
                                      values, value_stride, sc.queue_stream);
} else {
    // 普通 cache-only 模式
    cache_->lookup(lookup_ctx, keys, num_keys, values, hit_mask, ...);
}

run_find_uvm 的四种 kernel 模式

run_find_uvm 通过 get_kernel_mode(config, num_keys) 选择执行策略:

模式 名称 阈值 机制
LookupUVM 直接 UVM 查找 num_keys < 1M 一次 cache_->lookup 调用,UVM 硬件自动处理缺页
SortGather 排序-聚集 num_keys ≥ 1M 先对 keys 排序去重,再用大块连续读取 UVM,按原始顺序 gather
PipelineGather 流水线聚集 用户指定 自定义流水线参数,分 tile 处理
DynamicKernel 动态 暂未实现

LookupUVM 的内部机制:当 uvm_table 参数非空时,EmbedCacheSA::lookup 的内部 CUDA kernel 执行:

每个线程处理一个 key:
  1. 计算 hash(key) → set_id
  2. 在 set 内逐一比较 tag
  3. 如果 cache hit: 从 cache 数据区读取 → 写入 output
  4. 如果 cache miss: 
     a. 从 uvm_table + key * row_size 读取 → 写入 output
     b. 记录该 key 到 missing_keys 数组(用于计数和后续自动插入)

cache miss 时读取 UVM 内存,由于 UVM 是 Unified Virtual Memory,GPU 硬件会自动触发缺页(page fault)→ 从 CPU 内存迁移页面到 GPU。这是实现透明回退的关键机制。

find_and_combine_bw(带 pooling 的查找)

当有 pooling 参数时,LinearUVMEmbeddingLayer 调用 GpuTable::find_and_combine_bw(),使用 cuEmbed kernel 进行 GPU 端 pooling 聚合。类型组合的显式实例化:

// float 数据
template void GpuTable<int64_t>::find_and_combine_bw<int64_t, float, float, float>(...);
template void GpuTable<int32_t>::find_and_combine_bw<int32_t, float, float, float>(...);
// half 数据
template void GpuTable<int64_t>::find_and_combine_bw<int64_t, __half, __half, __half>(...);
template void GpuTable<int32_t>::find_and_combine_bw<int32_t, __half, __half, __half>(...);

AutoInsertHandler 自动插入机制

AutoInsertHandler(定义在 include/layer_utils.hpp)是 LinearUVMEmbeddingLayer 中最复杂的组件之一。

运行流程

auto_insert(layer_ctx, keys_bw, output_bw, hitrate, num_keys, output_stride)
  │
  ├── ① heuristic_->insert_needed(hitrate, 0) 评估是否需插入
  │      DefaultInsertHeuristic: hitrate < 0.75 → 需要
  │
  ├── ② 频率控制: min_insert_freq_gpu → insert_freq_cnt_++
  │      if (insert_freq_cnt_ < min_insert_freq_gpu) → 跳过
  │
  ├── ③ 收集缺页数据:
  │      collect_keys_and_data(keys_bw, output_bw, hitmask_bw, stream, num_keys)
  │      从 hitmask 中提取 miss 的 key 和对应的 output 数据
  │      → 存入 insert_keys_ / insert_data_ 缓冲区
  │      → collected_keys_ += 缺页数
  │
  ├── ④ 批量检查:
  │      if (collected_keys_ >= min_insert_size_gpu) → 执行实际插入
  │      else → 返回(继续收集)
  │
  └── ⑤ launch_insert(layer_ctx, output_stride)
         gpu_table_->insert() → EmbedCacheSA::insert() 通过 CUDA kernel 写入 cache

频率与批量的双重节流

  • 频率节流min_insert_freq_gpu 确保两次插入之间至少间隔若干次查找,避免 GPU cache 被频繁扰动
  • 批量节流min_insert_size_gpu(默认 65536)确保收集足够多的缺页键后才执行一次批量插入

InsertHeuristic 策略

class DefaultInsertHeuristic : public InsertHeuristic {
  static constexpr float DEFAULT_THRESHOLD = 0.75f;
  bool insert_needed(const float hitrate, const size_t table_id) override {
    return hitrate < thresholds_.at(table_id);
  }
};

当命中率低于 75% 时触发插入,表示当前 GPU 缓存的覆盖能力不足,需要补充热键。


insert() 操作的底层实现

LinearUVMEmbeddingLayer::insert() 委托给 GpuTable::insert()。根据 modify_on_gpu 标志选择 GPU 或 CPU 路径。

GPU 路径modify_on_gpu = true):

// 构建直方图(去重 + 聚合优先级)
DefaultGPUHistogram<KeyType> histogram(num_keys);
histogram.compute_histogram(keys, num_keys, values, value_stride,
                            d_hist_storage, mod_stream);

// 调用集合关联缓存的 insert kernel
cache_->insert(
    mod_ctx,
    histogram.get_keys(),        // 去重后的 key
    histogram.get_priority(),    // 优先级(基于出现频率)
    histogram.get_data(),        // 嵌入数据
    histogram.get_num_bins(),    // 去重后的条目数
    0, ec_event.get(), sc.queue_stream);

关键点:

  1. 直方图去重DefaultGPUHistogram 对输入 keys 进行 GPU 端直方图统计,按出现频率排序——高频键获得更高优先级,类似 LFU 策略
  2. 集合关联 insertEmbedCacheSA::insert 使用 LRU/LFU 淘汰策略,优先保留高频访问的嵌入向量
  3. modify_context:管理修改操作的临时缓冲区,大小由 max_modify_size(默认 1M)控制

update() 操作的底层实现

if (auto_insert_handler_) {
    auto_insert_handler_->lock_modify();   // 阻止自动插入在修改期间执行
}
gpu_table_->update_bw(...);
if (auto_insert_handler_) {
    auto_insert_handler_->unlock_modify();
}

GpuTable::update() 的双重更新

GpuTable::update() 执行两处更新

  1. 先更新 GPU 缓存:通过 EmbedCacheSA::update() kernel 覆盖缓存中已有键的值,分批处理,每批大小不超过 max_modify_size

  2. 再更新 UVM 表:如果 disable_uvm_update 为 false,使用 UpdateTable kernel 更新 UVM 内存中的全量表

if (config_.uvm_table && !config_.disable_uvm_update) {
    sync = contexts_->create_sync_event();
    sync->event_record();
    sync->event_wait_stream(update_stream);
    StreamCoordinator::create_stream_dependency(modify_stream, update_stream);
    UpdateTable<KeyType>(values, d_keys, config_.uvm_table, ...);
    cudaEventRecord(uvm_update_event, update_stream);
    cudaStreamWaitEvent(modify_stream, uvm_update_event);
}

这种”双重写入”确保了 GPU 缓存和 UVM 后备表的一致性:后续 cache miss 从 UVM 读取到的数据始终是最新的。


accumulate() 梯度累积的 CPU 路径

config_.uvm_cpu_accumulate = true 且需要同步 UVM 表时,GpuTable::update_accumulate() 采用 CPU 累积路径

Step 1: cudaMemcpyAsync 分批将梯度数据从 GPU 传回 Host
        └── 每批 512 个键,每批记录一个 cudaEvent
Step 2: 提交 meta task 到线程池
Step 3: meta task 等待 cudaEventSynchronize(每批的拷贝完成)
Step 4: 每批再细分成 tasks_per_copy 个子任务,提交到线程池
        └── 每个子任务处理 keys_per_task 个键
            └── 在 CPU 上进行循环累加:
                for (j = 0; j < elements_per_row; j++)
                    dst[key][j] += gradient[j]
Step 5: 等待所有子任务完成(std::promise + future 同步)
Step 6: 销毁拷贝事件

为什么不用 GPU kernel 做 UVM accumulate?

CPU 累积的优势在于避免 UVM page fault 的连锁代价。如果直接在 GPU 上以 atomicAdd 更新 UVM 内存,大量稀疏随机写入会导致严重的缺页抖动——每写一个不同的页面都可能触发 page fault。

小规模查找使用简单路径(直接 cudaMemcpyAsync + CPU 循环),大规模查找使用上述流水线多拷贝路径。


erase() 与 clear()

erase() 调用 GpuTable::erase(),最终通过 EmbedCacheSA::invalidate kernel 清除缓存中的 tag:

cache_->invalidate(mod_ctx, keys, num_keys, 0, ec_event.get(), sc.queue_stream);

clear() 调用 EmbedCacheSA::clear_cache

cache_->clear_cache(sc.queue_stream);

GPUTableExecutionContext 的缓存上下文管理

GPUTableExecutionContext 是 GpuTable 的执行上下文,它持有关键资源的句柄:

class GPUTableExecutionContext : public ExecutionContext {
    LookupContextHandle lookup_context_;  // 查找操作的 cache 上下文
    ModifyContextHandle modify_context_;  // 修改操作的 cache 上下文
    PerformanceMetric miss_metric_;       // miss 计数指标
    cache_ptr_type cache_;                // EmbedCacheSA 实例
};
  • lookup_context_:由 cache_->lookup_context_create() 创建,存储查找操作所需的临时状态
  • modify_context_:由 cache_->modify_context_create(max_modify_size) 创建,包含修改操作的批量缓冲区
  • miss_metric_:当 count_misses = true 时创建,在每次 lookup 后通过 get_lookup_counter() 读取 miss 计数
  • 构造时注册到 ContextRegistry,析构时注销(与 GPUEmbeddingLayer 的模式一致)

UVM 表与 GPU 缓存的互斥保护

UVM 表是一个共享资源——多个 GpuTable 实例可能共享同一个 UVM 表。uvm_table_mutex_ 确保 UVM 表更新的串行化:

std::unique_lock uvm_lock(uvm_table_mutex_, std::defer_lock);
if (!config_.private_stream) {
    uvm_lock.lock();
}

使用 private_stream 时,流本身提供了串行化保证;否则通过互斥锁保护 UVM 表的写入。


使用示例

C++ API 使用

// 1. 创建 UVM 后备表 (完整嵌入表)
size_t num_embeddings = 100000000;
size_t embed_width = 128;  // 128 字节 (32 个 float)
void* uvm_table;
cudaMallocManaged(&uvm_table, num_embeddings * embed_width);

// 2. 配置 GPU 缓存
GPUTableConfig cache_config;
cache_config.device_id = 0;
cache_config.cache_size = 2ULL * 1024 * 1024 * 1024;  // 2GB GPU 缓存
cache_config.row_size_in_bytes = embed_width;
cache_config.uvm_table = uvm_table;
cache_config.count_misses = true;
cache_config.value_dtype = DataType_t::Float32;

// 3. 创建 GpuTable 和 LinearUVMEmbeddingLayer
auto gpu_table = std::make_shared<GpuTable<int64_t>>(cache_config);

LinearUVMEmbeddingLayer<int64_t>::Config layer_config;
layer_config.layer_name = "my_embedding";

LinearUVMEmbeddingLayer<int64_t> layer(layer_config, gpu_table);

// 4. 创建执行上下文
auto ctx = layer.create_execution_context(
    lookup_stream, modify_stream, thread_pool, allocator);

// 5. 执行查找
int64_t num_keys = 4096;
int64_t keys[num_keys];
float output[num_keys * 32];

layer.lookup(ctx, num_keys, keys, output, embed_width,
             nullptr, nullptr, nullptr);

// 6. 更新
layer.update(ctx, num_keys, keys, embed_width, embed_width, new_values);

Python API 使用

import pynve

# 创建 LinearUVM 嵌入层
embedding = pynve.NVEmbedding(
    num_embeddings=100_000_000,
    embedding_dim=128,
    cache_type="LinearUVM",
    cache_size=2_000_000_000,  # 2GB GPU 缓存
)

# 前向查找
x = torch.tensor([1, 2, 3, 4, 5], device='cuda')
output = embedding(x)

性能特征与调优建议

查找延迟

场景 相对延迟 说明
Cache hit GPU cache 标签匹配 + 数据读取,纯 GPU kernel 延迟
Cache miss(小量) 中-高 UVM page fault,GPU 从 CPU 内存迁移页面
Cache miss(大量) 取决于 UVM 带宽 SortGather 模式通过排序提升 UVM 读取效率

关键调优参数

参数 推荐值 影响
cache_size GPU 显存的 10-50% 越大命中率越高,但留给其他操作的空间越少
min_insert_size_gpu 1<<16 ~ 1<<20 越小插入越频繁,越大 batch 效率越高
min_insert_freq_gpu 0~100 越大插入频率越低,缓存越稳定
kernel_mode_type 0(自动) 小 batch 用 LookupUVM,大 batch 自动切换 SortGather
uvm_cpu_accumulate true CPU 累积避免 GPU UVM 页错误

UVM 的陷阱与应对

  1. UVM page fault 叠加:大量随机读取 miss 可能导致大量并发缺页,严重降低性能。SortGather 模式通过排序来改善局部性
  2. GPU 缓存大小与命中率的折衷:cache 越大命中率越高,但占用显存也越多。建议通过基准测试找到最佳点
  3. double write 开销:update() 同时写 GPU cache 和 UVM 表,带来约 2 倍的写带宽消耗。disable_uvm_update 可禁用 UVM 写入

与 HierarchicalEmbeddingLayer 的对比

维度 LinearUVMEmbeddingLayer HierarchicalEmbeddingLayer
第二级存储 UVM 线性内存(虚拟统一内存) CPU 哈希表 + 远程表
二级访问方式 GPU 直接读取(硬件缺页) CPU 线程池并行查找
host 回退 无(全程 GPU) 有(host + remote)
数据持久化 否(UVM 内存不持久) 是(RocksDB/Redis)
适用嵌入大小 可超出显存但不超过系统内存 远超系统内存(数 TB)
配置复杂度 低(只需 UVM table pointer) 高(host table 配置 + 远程连接)

简而言之:LinearUVMEmbeddingLayer 是 GPUEmbeddingLayer 到 HierarchicalEmbeddingLayer 之间的中间方案——牺牲少量查找延迟(miss 时 UVM 缺页代价),换取更大的存储容量,同时保持全程 GPU 端执行、无需 CPU 回退的优势。