LOADING

加载过慢请开启缓存 浏览器默认开启

Okabe's LAB

C++ 知识查漏补缺

2025/5/25

使用 std::tie 读取 std::pair

std::pair<int, double> p{1, 2.0};
int i;
double d;
std::tie(i, d) = p;

std::span

std::span 就是一个连续对象存储的观察者。类似std::string_viewstd::string 的观察者。

int printSize(std::span<int> s) { std::cout << s.size() << std::endl; }

int main() {
  std::vector<int> v{1, 2, 3};
  int a[10];
  std::array<int, 30> arr;
  printSize(v); // 3
  printSize(a); // 10
  printSize(arr); // 30
}

std::vector 释放内存

std::vector::clear 并不会释放 capacity,需要使用 std::vector::shrink_to_fit 或:

std::vector<int> v{1, 2, 3};
std::vector<int>().swap(v);
std::cout << v.capacity() << std::endl; // 0

虚函数表占的空间

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};

手写一个 std::function

#include <iostream>

template <typename R, typename... Args> struct ICallable {
  virtual R invoke(Args &&...args) = 0;
  virtual ~ICallable() {}
};

template <typename T, typename R, typename... Args>
class CallableImpl : public ICallable<R, Args...> {
private:
  T callable;

public:
  explicit CallableImpl(T callable) : callable(callable) {}

  R invoke(Args &&...args) override {
    return callable(std::forward<Args>(args)...);
  }
};

template <typename Signature> class Function;

template <typename R, typename... Args> class Function<R(Args...)> {
private:
  std::unique_ptr<ICallable<R, Args...>> callable;

public:
  template <typename T>
  Function<R(Args...)>(T &&callable)
      : callable(std::make_unique<CallableImpl<T, R, Args...>>(
            std::forward<T>(callable))) {}

  Function(const Function<R(Args...)> &other) = delete;
  Function &operator=(const Function<R(Args...)> &other) = delete;

  R operator()(Args &&...args) {
    return callable->invoke(std::forward<Args>(args)...);
  }
};

int mmax(int a, int b) { return a > b ? a : b; }

int main() {
  Function<int(int, int)> f1 = mmax;
  std::cout << f1(3, 4) << std::endl;

  Function<int(int, int)> f2 = std::function<int(int, int)>(mmax);
  std::cout << f2(3, 4) << std::endl;

  Function<int(int, int)> f3([](int a, int b) { return a > b ? a : b; });
  std::cout << f3(3, 4) << std::endl;

  class Callable {
  public:
    int operator()(int x, int y) { return x > y ? x : y; }
  } c;
  Function<int(int, int)> f4(c);
  std::cout << f4(3, 4) << std::endl;
}

智能指针

std::make_shared 优于 std::shared_ptr<T>(new T) 的原因是:

  1. 更简洁
  2. std::make_shared 会分配一块连续的空间(控制块 + 数据块),locality 更好。而后者的内存空间是分散的。

手写 std::shared_ptr:

template <typename T> class ReferenceCount {
private:
  std::atomic<size_t> mRefCount;
  T *data;

public:
  ReferenceCount(T *data) : data(data), mRefCount(1) {}
  void increase() { mRefCount.fetch_add(1); }
  void decrease() {
    if (mRefCount.fetch_sub(1) == 1) {
      delete this;
    }
  }
  size_t count() const { return mRefCount.load(); }
};

template <typename T> class SharedPtr {
private:
  T *data;
  ReferenceCount<T> *mRefCount;

public:
  SharedPtr(T *data) : data(data), mRefCount(new ReferenceCount<T>(data)) {}

  SharedPtr(const SharedPtr<T> &other)
      : data(other.data), mRefCount(other.mRefCount) {
    mRefCount->increase();
  }

  SharedPtr<T> &operator=(const SharedPtr<T> &other) {
    if (mRefCount != other.mRefCount) {
      mRefCount->decrease();
      mRefCount = other.mRefCount;
      data = other.data;
    }
    mRefCount->increase();
    return *this;
  }

  size_t useCount() const { return mRefCount->count(); }

  T *operator->() { return data; }
};

template <typename T, typename... Args> SharedPtr<T> makeShared(Args... args) {
  return SharedPtr<T>(new T(std::forward<Args>(args)...));
}

int main() {
  SharedPtr<int> a = makeShared<int>(4);
  SharedPtr<int> e = makeShared<int>(5);
  std::cout << a.useCount() << std::endl;
  SharedPtr<int> b = a;
  std::cout << a.useCount() << std::endl;
  SharedPtr<int> c(a);
  std::cout << a.useCount() << std::endl;

  b = e;
  std::cout << a.useCount() << std::endl;
  std::cout << e.useCount() << std::endl;

  struct Person {
    Person(std::string name, int age) : name(name), age(age) {}
    std::string name;
    int age;
  };

  SharedPtr<Person> p = makeShared<Person>("Alice", 32);
  std::cout << p->name << ", " << p->age << std::endl;
}

输出:

1
2
3
2
2
Alice, 32

手写 std::any

老样子,使用类型擦除技术(基于特化)。这里的思路我总结下来是这样:
std::any 他是不需要指定类型的,比如:

std::any a = 123;

这里实际上只有在构造的时候才会用到类型,对应一个特化的构造函数:

template<typename T>
Any(T val);

例如:

std::any a = 123;
std::any a = std::string("Hello");

将对应两个特化的构造函数。

既然 std::any 不需要指明其存储的类型 T,那么他的所有成员变量一定是和类型 T 无关的,但是我们总要有个地方存这个 T 类型的 value。

所以可以想到,我们定义一个接口,该接口和 T 类型无关,其实现类可以与 T 有关,该实现类是真正存储 T 类型 value 的地方。而 std::any 则可以存储这个接口。

struct IValueHolder {
  virtual const std::type_info &type() const = 0;
};

template <typename T> class ValueHolder : public IValueHolder {
private:
  T val;

public:
  explicit ValueHolder(T val) : val(val) {}

  const std::type_info &type() const override { return typeid(val); }

  inline const T &value() const { return val; }
};

class Any {
private:
  std::unique_ptr<IValueHolder> holder;

public:
  template <typename T>
  Any(T val) : holder(std::make_unique<ValueHolder<T>>(val)) {}

  template <typename T> T AnyCast() {
    if (typeid(T) != holder->type()) {
      throw std::bad_cast();
    }
    return static_cast<ValueHolder<T> *>(holder.get())->value();
  }
};

int main() {
  Any a = 123;
  std::cout << a.AnyCast<int>() << endl;
  std::cout << a.AnyCast<double>() << endl;
}

输出:

123
libc++abi: terminating due to uncaught exception of type std::bad_cast: std::bad_cast
[1]    25963 abort      /Users/lucas/projects/c++/bin/my_test

enum class

  1. 避免命名空间冲突。
  2. 可以指定底层存储的类型,int or long long
  3. 可以前置声明。

std::atomic

std::atomic 背后的缓存行锁定(Cache Line Locking)是实现原子操作的关键机制,它依赖于现代 CPU 的缓存一致性协议(如 MESI)和总线锁定(Bus Locking)。当线程对 std::atomic 变量执行原子操作时,CPU 首先尝试通过缓存行锁定实现高效并发:如果该变量所在的缓存行被当前 CPU 独占(Modified 状态),则直接修改并标记为脏;若被其他 CPU 共享(Shared 状态),则通过总线发送 invalidate 信号,迫使其他 CPU 放弃缓存行的所有权,从而获取独占权后执行操作。若缓存行锁定失败(如跨缓存行的操作),CPU 会退化为总线锁定,临时阻塞整个内存总线,确保操作的原子性。这种分级机制平衡了性能与正确性,使 std::atomic 在大多数场景下仅需短暂的缓存行竞争,避免了昂贵的总线锁定开销。

std::atomic<int> flag{0};
int data = 0;

// 线程 A(写端)
data = 42;                          // 非原子写入
flag.store(1, std::memory_order_release); // release 保证 data 的写入不会重排到之后

// 线程 B(读端)
if (flag.load(std::memory_order_acquire) == 1) { // acquire 保证读到 release 的写入
    assert(data == 42); // 一定成功!data 的写入对线程 B 可见
}

std::memory_order_release 保证它之前的所有读写都不会重排到它后面,而 std::memory_order_acquire 又保证了它之后的所有读写不会重排到它前面,这样就形成了 release-acquire 模式,前者保证 flag.Store 之前 data = 42 已经写入;后者保证 flag.load 之后,data = 42 才被读取。

noexcept

std::vector 扩缩容的时候,如果元素的移动构造函数被标记为 noexcept,那它就会大胆地调用移动构造函数,否则就会使用相对更安全的拷贝构造函数。std::sort 的优化类似。

class A {
public:
  A() = default;
  A(const A &) { std::cout << "copy" << std::endl; }

  A(A &&) { std::cout << "move" << std::endl; }
};

int main() {
  std::vector<A> aVec;
  aVec.emplace_back();
  aVec.emplace_back();
}

输出:

copy

将移动构造函数标记为 noexcept,输出:

move

std::enable_shared_from_this

有些时候,我们需要在一个 std::shared_ptr 托管的类的成员函数中,创建它的一个 shared_ptr 新实例,传给某些函数,例如:

class Manager;
class Component {
private:
  int *value = new int(3);
  Manager *mgr;

public:
  Component(Manager *mgr) : mgr(mgr) {}
  ~Component() {
    std::cout << "Deconstruct" << std::endl;
    delete value;
  }
  void DoSomething();
  friend class Manager;
};

class Manager {
public:
  void Modify(std::shared_ptr<Component> c) { *c->value = 3; }
};

void Component::DoSomething() { mgr->Modify(std::shared_ptr<Component>(this)); }

int main() {
  Manager mgr;
  auto c = std::make_shared<Component>(&mgr);
  c->DoSomething();
}

上述的代码会导致 double free 的问题,正确做法如下:

class Component : public std::enable_shared_from_this<Component> {
  ...
}
void Component::DoSomething() { mgr->Modify(shared_from_this()); }

其原理是:构造 Componentshared_ptr 的时候,会同时定义一个 weak_ptr 指向 shared_ptr。在调用 shared_from_this 的时候,会由该 weak_ptr 创建对应的 shared_ptr

注意,此处的 std::enable_shared_from_this<Component> 必须是 public 继承,不然创建 std::shared_ptr 的时候无法正确访问 enable_shared_from_this 中的 weak_ptr

std::deque 实现原理

std::deque 通常采用 分块存储(Chunked Storage) 的方式实现,即:
由多个固定大小的 “块(Chunks)” 组成,每个块是一个连续的内存数组(称为 “缓冲区” 或 “页”)。

使用一个中央控制结构(如指针数组或动态数组)管理这些块,记录每个块的地址。
这种设计使得:
头尾插入/删除高效(不需要像 vector 那样整体移动元素)。
随机访问(operator[])比 list 快(计算块索引 + 块内偏移)。

典型的 std::deque 实现(如 GCC 的 libstdc++)采用 “Map + Chunks” 结构:

+-------------------+     +-------------------+     +-------------------+
| Chunk 0 (front)   | --> | Chunk 1           | --> | Chunk N (back)    |
+-------------------+     +-------------------+     +-------------------+
| [0] | [1] | ...   |     | [0] | [1] | ...   |     | [0] | [1] | ...   |
+-------------------+     +-------------------+     +-------------------+
  • Map(控制中心):一个动态数组(如 T**),存储指向各个块的指针。
  • Chunk(块):每个块是一个固定大小的数组(如 512B 或 4KB),存储实际数据。
阅读全文

Kubernetes CSI

2025/5/20
  1. PVC(Persistent Volume Claim,持久卷声明):是用户对存储资源的请求,用于向 Kubernetes 申请存储,抽象化存储的使用细节,方便应用获取和使用持久化存储。
  2. PV(Persistent Volume,持久卷):是 Kubernetes 中底层存储的抽象,代表具体的存储资源(如 NFS、云硬盘等),通过定义存储的类型、容量、访问模式等参数,供 PVC 动态绑定调用。
  3. StorageClass:用于定义存储资源的配置模板,可动态创建 PV,支持按需提供存储服务,通过参数配置存储的类型(如 SSD、HDD)、备份策略等,实现存储资源的自动化管理和动态供给。

静态绑定

使用NAS静态存储卷为例:

  • PV

    静态绑定的方式需要手动配置 PV,并设定 labels

    apiVersion: v1
    kind: PersistentVolume
    metadata:
    name: pv-nas
    labels:
        alicloud-pvname: pv-nas
    spec:
    capacity:
        storage: 5Gi
    accessModes:
        - ReadWriteMany
    csi:
        driver: nasplugin.csi.alibabacloud.com
        volumeHandle: pv-nas   # 必须与PV Name保持一致。
        volumeAttributes:
        server: "0c47****-mpk25.cn-shenzhen.nas.aliyuncs.com"  # NAS挂载点,与集群VPC一致。
        path: "/csi"  # 挂载子目录。
    mountOptions:
    - nolock,tcp,noresvport
    - vers=3
    
  • PVC

    PVC 通过 label selector 绑定上面手动配置的 PV

动态绑定

  1. 用户创建 PVC:用户通过 PVC 声明存储需求(如容量、访问模式、存储类型等),但此时无对应的 PV 绑定。
  2. StorageClass 匹配与创建:PVC 会根据指定的 StorageClass (或默认类),触发存储插件(如云厂商存储插件、NFS 插件等)动态创建符合要求的 PV,并将两者绑定。
  3. 应用使用存储:绑定完成后,Pod 可通过 PVC 挂载自动创建的 PV,实现按需获取存储资源,无需手动预先创建 PV。

PVCPVstorageClassName 为桥梁,以 StorageClass 为中介动态绑定。若 storageClassName 为空,则 PVC 将会和默认的 PV 绑定。

StorageClassallowVolumeExpansion 属性用于表明该存储是否允许扩容卷,以阿里云 NAS为例,若该属性为 true,则可以通过 patch PVC,修改其声明的卷大小来动态扩缩容。

CSI Driver

Understanding CSI architecture and communication

  • Daemonset Pod 应用可在所有节点上运行。
  • StatefulSet / Deployment Pod 应用作为 Kubernetes controller 运行。
  • 在 Daemonset Pod中,CSI Driver 通过 gRPC 和 socket 与 Kubelet 通信。
  • node-driver-registrar 将 CSI Driver 作为插件注册到 Kubelet。
    https://speakerdeck.com/bells17/kubernetes-and-csi?slide=44
  • 在 StatefulSet / Deployment Pod 中,CSI Driver 通过 gRPC 和 socket 与 CSI sidecar 应用通信。
  • CSI sidecar 应用作为 Kubernetes controller 运行。
  • 例如,创建 PVC 资源时,external-provisioner 会监听该事件,并调用 CSI Driver 的 CreateVolume rpc。
    https://speakerdeck.com/bells17/kubernetes-and-csi?slide=47
阅读全文

几种模型量化的方式

AI 2025/5/14

标量量化(Scalar Quantization)

FP32 -> FP16,FP16 -> INT8

以 FP16 -> INT8 为例,将 FP16 的数值映射为 INT8 的范围。如果 tensor 中存在离群值(远大于平均的值),在 de-Quantized 之后可能导致其他值消失(接近 0)。解决方法是把这部分离群值拎出来,不量化,按照原有精度处理。

乘积量化(Production Quantization)

将原向量拆分成若干子向量,找到子向量距离最近的 cluster(聚类)对应的标识。由这些标识构成量化后的向量。

以上图为例,假设绿色为 0,蓝色为 1,红色为 2,则量化后的向量为:
[0, 1, 1, 2]。

二进制量化与 RaBitQ

https://zhuanlan.zhihu.com/p/7193968541

二进制量化直接将向量的每一维度映射到一个 bit(0 或 1),常见的做法就是将大于 0 的维度设置为 1,否则则为 0。然而这种做法虽然极大地压缩了空间,但是也会显著影响召回率。

RabitQ 通过将向量进行归一化,将其放入一个单位高维球中,这个单位高维球中有一个 D 维的超正方体(D 为向量维度)。靠近哪个顶点,就被划定为这个顶点,以这个顶点对应的向量作为量化结果。

阅读全文

RocksDB 源码阅读——SkipList

Database 2025/5/13

References

SkipList::Node 的实现

RocksDB 分配了一块连续的空间来存储节点在不同 level 的 next 指针。同时,使用 std::atomic 保证可以在无锁的情况下更新链表。其伪代码如下所示,可以说是把内存空间利用得严严实实。

#include <atomic>
#include <cstring>
#include <memory>

// https://github.com/facebook/rocksdb/blob/ef67339175c1aebe039c0671560b608e22656612/memtable/inlineskiplist.h
class SkipList {
public:
  struct Node {
  private:
    std::atomic<Node *> next_[1];

  public:
    void StashHeight(size_t h) { memcpy(&next_[0], &h, sizeof(h)); }

    size_t UnstashHeight() {
      size_t rv;
      memcpy(&rv, &next_[0], sizeof(rv));
      return rv;
    }

    Node *Next(size_t level) {
      return (&next_[0] - level)->load(std::memory_order_acquire);
    }
  };

  Node *AllocateNode(size_t height, size_t key_size) {
    size_t prefix = sizeof(std::atomic<Node *>) * height - 1;
    char *raw = new char[prefix + sizeof(Node) + key_size];
    Node *node = reinterpret_cast<Node *>(raw + prefix);

    node->StashHeight(height);

    return node;
  }
};

此处有个很有意思的点,在 AllocateNode 生成一个随机高度的节点的时候,会把他的高度通过 StashHeight 的方式暂时存储在 next_[0] 中。在后续的操作中,可以通过 UnstashHeight 读取存储的高度,并进行后续的 next 指针的更新(毕竟这些更新需要知道高度),可以说是不浪费任何内存空间。

其内存布局大概如下图所示(此处我做了简化,&next_[0] - n 其实就是相当于 next_[-n],但是数组不支持负数):

阅读全文

Vector DB

AI 2025/5/10

References

常用的向量检索算法

  1. 量化(以一定的模型性能换取查询效率),RabitQ

  2. Locality Sensitive Hashing (LSH):传统的 hash 函数一般都希望减少碰撞,将一个 key 映射到尽可能分散的空间,而 LSH 则恰恰相反,通过将比较近的 embedding 映射到尽可能相同的 bucket 中,这样就能快速找到相邻的节点。

  3. NSW(Navigate Small World)

    选择若干个 entry,然后维护一个有固定大小上限的队列,以类似 BFS 的方式,将距离目标向量最近的向量入队。需要多个 entry 的原因是,如果只采用一个,很容易陷入局部最优解。

    为了加速查询,我们会限制图中点之间的边的数量为 max_m。当我们插入一个节点的时候,其邻居节点的边可能会超过 max_m,所以我们需要重新计算这些节点的最近邻。

  4. HNSW(Hierarchical Navigate Small World)

    将图以类似跳表的形式组织,但是无论是 NSW 还是 HNSW 都会涉及大量磁盘随机 I/O,性能受限。

  5. IVF(Inverted File)

    使用 Kmeans 将向量分为多个 cluster。并根据 cluster 的标识建立对向量的倒排索引。由于索引存储在一起,可以被顺序读写。

    在查询的时候,除了判断距离查询向量最近的质心对应的集群,还需要判断临近的集群。因为集群的质心可能距离查询向量比较远,但是边缘的点可能比较近。

阅读全文

vllm 学习笔记

AI 2025/4/28

References

  1. 大模型推理加速与KV Cache(一):什么是KV Cache
  2. [FIXME][EP02][直播版] vllm源码讲解,分布式推理
  3. [FIXME][EP05][直播版] vllm从开源到部署,Prefix Caching和开源答疑
  4. CacheBlend-高效提高KVCache复用性的方法

Distributed Inference

具体的通信代码可以参考:vllm/model_executor/models/llama.py

分布式通信手段

  1. nvlink
  2. InfiniBand
  3. RDMA

并行方式

  1. TP(Tensor Parallel):

    将 tensor 拆分运算,然后通过 all-reduce / all-gather 汇总。例如 LLM 的 Decoder 采用多头注意力(multihead-attention),假设有 个头部, 张卡,则可以每张卡 个头部并行计算。

    # llama前向传播的代码
    def forward(
        self,
        positions: torch.Tensor,
        hidden_states: torch.Tensor,
    ) -> torch.Tensor:
        qkv, _ = self.qkv_proj(hidden_states)
        q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
        q, k = self.rotary_emb(positions, q, k)
        attn_output = self.attn(q, k, v)
        output, _ = self.o_proj(attn_output)
        return output
    
  2. PP(Pipeline Parallel):

    一个 request 由多个 node 流水线式地执行。可以提高吞吐,但是无法降低 TTFT

    每个worker负责一个layers的子集

    • vllm/model_executor/models/llama.py 中 self.start_layer –> self.end_layer
    • 在 worker 之间: communicate IntermediateTensor
    • vllm/worker/model_runner.py: 搜索 get_pp_group()
  3. EP(Expert Parallel):

    用于 MoE(Mixture of Expert)架构。在处理一个 request 的时候,只有一个很小的专家子集会被激活(通过门控控制)。router 会将 request 转发到相应的 expert 节点(MoE 的前置计算可能不是在相应的 expert 节点上进行的)。这里多个 expert 是并行的,同时计算不同的 batch。

    • Shuffle (DeepEP communication kernel)
    • Forward
    • Shuffle back

  1. DP(Data Parallel)

    因为大型分布式生产环境中,EP >> TP(attention head 可能就十几个,而 experts 可能有几百个),这时候就需要 data parallel。

    • TP * DP == EP(通过请求并行的方式去拉满计算资源)
    • 在实践中难以应用。
    • 对请求进行padding避免造成死锁。

Prefix Caching

预备知识:

  1. LLM 的 Prefill 阶段:当用户输入一个 prompt(例如 “Hello, how are”),模型会一次性处理整个输入文本,计算所有输入 token 的隐藏状态。这个阶段通常是并行计算的,效率较高;LLM 的 Decode 阶段:模型从第一个生成的 token 开始,逐个预测后续的 token(例如 “you”、”today” 等)。每个新 token 的生成都依赖于前一个 token。
  2. 在多轮对话中,需要传递上下文,这些上下文总是共享一个前缀,如上图所示。显然,我们可以缓存这些前缀对应的 KV tensor,这也就是 Prefix Caching,缓存的对象便是 KV tensor。
  3. Prefix Caching 只节省了 Prefill 阶段的耗时(也就是降低了 TTFTTime To First Token),并不能节省解码阶段的耗时(也就是 TPOTTime Per Output Token)。

vllm 中的 Prefix Caching 主要思路:

  1. 将若干个 tokens(默认 16 个)组成一个 block,对于每个 block,计算其 hash 值并更新 prefix hash
  2. prefix hash -> kv cache 的映射存储到 kv store 中(例如 redis,注意后者的 kv 指的是键值存储)。
  3. 若无法 allocate 缓存空间,以一定的策略(如 LFU,LRU)evict 一些缓存。

伪代码:

prefix_hash = ""
for chunk in chunked_token:
    chunk_hash = hash(prefix_hash + chunk)
    prefix_hash = chunk_hash

for chunk_hash, chunk_kv in zip(...):
    kv_store.put(chunk_hash, chunk_kv)

Prefix Caching 的扩展——CacheBalend

  • Prefix Caching 只利用了前缀,缓存利用率有限;
  • Full KV Cache 使用所有的 Cache,会忽视 cross attention,产生低质量结果;
  • CacheBlend 通过重新计算一部分 KV,进行折中。

以下是忽视 cross attention 的结果:

Alt text Alt text

PD 分离

为什么需要 PD 分离?

在大模型推理中,常用以下两项指标评估性能:
TTFT(Time-To-First-Token):首 token 的生成时间,主要衡量 Prefill 阶段性能。
TPOT(Time-Per-Output-Token):生成每个 token 的时间,主要衡量 Decode 阶段性能。
当 Prefill 和 Decode 在同一块 GPU 上运行时,由于两阶段的计算特性差异(Prefill 是计算密集型,而 Decode 是存储密集型),资源争抢会导致 TTFT 和 TPOT 之间的权衡。例如:

若优先处理 Prefill 阶段以降低 TTFT,Decode 阶段的性能(TPOT)可能下降。
若尽量提升 TPOT,则会增加 Prefill 请求的等待时间,导致 TTFT 上升。
PD 分离式架构的提出正是为了打破这一矛盾。通过将 Prefill 和 Decode 分离运行,可以针对不同阶段的特性独立优化资源分配,从而在降低首 token 延迟的同时提高整体吞吐量。

Prefill 和 Decode 阶段分别受限于什么?

Prefill 阶段:吞吐量随 batch size 增加逐渐趋于平稳。这是因为 Prefill 的计算受限特性(compute-bound),当 batch 中的总 token 数超过某个阈值时,计算资源成为瓶颈。

Decode 阶段:吞吐量随 batch size 增加显著提升。由于 Decode 阶段的存储受限特性(memory-bound),增大 batch size 可提高计算效率,从而显著增加吞吐量。

摘自 LLM推理优化 - Prefill-Decode分离式推理架构

  1. Chunked Prefill
  2. xP yD 问题,Prefill Instance 和 Decode Instance 的数量如何调整?
  3. 先 D 后 P 的模式:D node收到后先检查是否有 KVCache,没有的话再转给 P node去做,这个思路主要考虑的是 TTFT

传递 KV Cache

  • 两种模式:pooling 模式,P2P 模式,LMCache都支持上面两种模式,Mooncake(pooling),NIXL(p2p)。

  • 怎么从 vllm 提取(注入)KVCache

    • connector API in vllm/worker/model_runner.py
    • 在模型 forward 前:尝试接收 KVCache 并注入到到 vllm 的 pages memory 中。
    • 在模型 forward 后,将 KVCache 从 pages memory 中并将它发送出去。

  • vllm/distributed/kv_transfer/kv_transfer_agent.py

    # 本质上是根据 model input 计算出 KVCache 放在 page memory中的什么地方
    def recv_kv_caches_and_hidden_states(
        self, model_executable: torch.nn.Module,
        model_input: "ModelInputForGPUWithSamplingMetadata",
        kv_caches: List[torch.Tensor]
    ) -> Tuple[Union[torch.Tensor, IntermediateTensors], bool,
            "ModelInputForGPUWithSamplingMetadata"]:
                
        return self.connector.recv_kv_caches_and_hidden_states(
            model_executable, model_input, kv_caches)
    

    可以先看 vllm/distributed/kv_transfer/kv_connector/simple_connector.py

Speculative Decoding

前文提到 Prefill 是 gpu-bound,计算密集型(把 request 的 tokens 全部输入 llm,生成第一个 token,并构建 KVCache);而 Decode 是 memory-bound,依赖 Prefill 阶段生成的 KVCache,访存的时间往往大于计算的时间。那么有没有一种方法可以在不怎么增加 memory access 的前提下,提升计算的吞吐呢?有的,兄弟,有的Speculative Decoding

Speculative Decoding 干了什么?其实就是去根据输入猜接下来的若干个 tokens 是什么,然后并行地进行验证。假如猜测 3 个 tokens,我们并行地验证这 3 个 tokens 是否正确的,由于是并行的,我们差不多只花费了原来只生成 1 个 token 的时间,最终获得了 3 个 tokens,也就是将吞吐提升了 3 倍,而访存只增加了(3 - 1)* token size。

那么如何猜呢?其实用一个古老的方法,n-gram 就行了。

An n-gram is a sequence of n adjacent symbols in particular order. The symbols may be n adjacent letters (including punctuation marks and blanks), syllables, or rarely whole words found in a language dataset; or adjacent phonemes extracted from a speech-recording dataset, or adjacent base pairs extracted from a genome. They are collected from a text corpus or speech corpus.

也就是说,我们根据一定的前缀,就能大致猜出后续的 token 搭配。Speculative Decoding 会根据输入构建 ngram,然后猜测后续的 tokens。

为什么这是有效的呢?下面几个 workload 就能说明这个问题:

  1. 全文搜索,在用户给定的内容中寻找内容,或者给出一定答案。此处的回答一定是和用户内容强相关的,本质上会复读一部分;
  2. 代码生成场景,变量名、函数名等都很容易被 ngram 预测。

Tree Verification

Model-based(draft model)Speculative Decoding

  1. Parallel guessing(并行猜测)

    • 优点:快,在不知道第一个 token 情况下直接猜第二个。
    • 缺点:在猜测第二个 token 的时候不知道第一个token是什么,容易胡言乱语
  2. Autoregression guessing(自回归猜测)

    • 优点:在猜测第二个 token 的时候知道第一个 token,准确率较高
    • 缺点:慢
阅读全文

RocksDB 学习笔记

Database 2025/4/26

RocksDB 架构

先写入 WAL,再写入 MemTableMemTable 达到一定的大小就会变为 Immutable MemTable,后台异步以 SSTable 的形式 flush 到磁盘中。

MemTable 中还包含了一个 Bloom Filter,用于快速判断一个元素是否不存在

Column Family

RocksDB 中列族是一个逻辑上的分组,列族之间使用不同的 LSM Tree,但是共享 WAL。

Version

RocksDB Version管理概述

Version 会指向不同的 SST,并且 VersionSSTable 都是以引用计数的方式维护,一旦引用计数变为 0,就会进行回收。

Write Batch

参考文献:

  1. RocksDB 源码分析 – Write Batch
  2. RocksDB 剖析 | WriteThread 如何控制并发写入流程

上面这篇文章讲的挺清楚的,RocksDB 默认是不开启并行写的,因为跳表结构并不适合并发写入,WAL 如果要并发写也需要一定同步机制维护其写入顺序。所以,RocksDB 采用的是分批写入的方式,以保证其吞吐量。Write Batch Group 会有一个 leader(当前的第一个 writer),然后后面的 writer 将成为 follower,构成一条双向链表(通过 link_olderlink_newer)相连。这个 group 会有个大小上限,超过之后就会截断。截断后的新 writer 将会变为新的 leader,构成新的 group

RocksDB 使用 atomic::<Writer *> 的形式组织链表,可以通过 CASlatch-free 的方式添加 writer

Writer* writers = newest_writer->load(std::memory_order_relaxed);
while (true) {
  w->link_older = writers;
  if (newest_writer->compare_exchange_weak(writers, w)) {
    return (writers == nullptr);
  }
}

这边正好学习了一下 std::atomic 的用法,比起其他语言,c++ 的原子对象的 load 操作还可以选择不同的参数:

  1. std::memory_order_relaxed:仅保证原子性,不强制内存顺序。
  2. std::memory_order_acquire:确保后续读操作不会重排到此操作之前。
  3. std::memory_order_seq_cst(默认):顺序一致性,提供最强的内存屏障。

尝试写了一个链表插入练习:

#include <atomic>
#include <iostream>
#include <thread>

struct ListNode {
  int val;

  std::atomic<ListNode *> next;
};

void insertListNode(std::atomic<ListNode *> &tail, ListNode **root, int val) {
  ListNode *newNode = new ListNode;
  newNode->val = val;
  newNode->next = nullptr;
  ListNode *oldTail = tail.load(std::memory_order_relaxed);
  while (true) {
    if (tail.compare_exchange_weak(oldTail, newNode)) {
      break;
    }
  }
  if (oldTail == nullptr) {
    *root = newNode;
  } else {
    oldTail->next = newNode;
  }
}

void printList(ListNode *root) {
  if (root == nullptr) {
    std::cout << "List is empty" << std::endl;
    return;
  }
  ListNode *p = root;
  while (p) {
    std::cout << p->val << " ";
    p = p->next.load(std::memory_order_relaxed);
  }
  std::cout << std::endl;
}

int main() {
  std::atomic<ListNode *> tail = nullptr;
  ListNode *root = nullptr;
  for (int i = 0; i < 3; i++) {
    std::thread t([&, i]() {
      while (1) {
        insertListNode(tail, &root, i);
        std::this_thread::sleep_for(std::chrono::milliseconds(300));
      }
    });
    t.detach();
  }

  while (1) {
    printList(root);
    std::this_thread::sleep_for(std::chrono::milliseconds(300));
  }
}

一种 Lazy Initialize 的方式:

std::optional<std::mutex> mtx;
mtx.emplace();
阅读全文

Foundation DB 学习笔记

2025/4/20

在线讲解:[Paper Reading] FoundationDB:开源分布式 KV 存储的内部实现原理

最近看 DeepSeek 的 3FS 的时候发现他们会用 FoundationDB 做 chunk metadata 的存储(3FS 有点像 GFS,也是以 chunk 作为存储单位)。正好不久之后要去阿里云开始一段存储相关的实习,学习一下相关知识。

FoundationDB 是一个分布式 KV 存储数据库,适用于读多写少的场景,支持 SSI(Serializable Snapshot Isolation)级别的事务。

FoundationDB 如何实现 SSI

SSI 这一级别是通过 OCC + MVCC 实现的。

首先,让我们复习一下事务隔离级别,可以从这篇文章切入:事务隔离级别备忘。通过 MVCC 的快照,我们可以很容易地实现 Snapshot Isolation,虽然这解决了脏读和幻读的问题,但是无法解决两种问题(也就是上面链接中的 P4 和 A5B 问题)。

  • P4 Lost Update

    T2 修改 x 为 120,然而 T2 提交后被 T1 提交的修改覆盖,最终 x = 130。对外界而言,x = 120 这一修改不可见,被覆盖。

  • A5B Write Skew

    x + y 应该满足约束 x + y <= 100,由于 T1 和 T2 的 write set 没有相交,所以两个事务都能提交成功,然而约束已经被破坏。

为了解决上述问题,FoundationDB 会维护一个 read set,如果 read set 中的某个 key range 对应的最新的一次 commit version 大于事务的 read version,就会 abort 当前事务。这种乐观的方式有若干好处:

  • 无需锁,高效;
  • 读多写少,abort 是小概率事件。

在 FoundationDB 中,resolvers 会保留最近一段时间(如 5 秒,MVCC window)内已提交的写入操作在内存中,这个时间范围就可以看作是一个 “窗口”。当一个新的事务进行冲突检测时,会将其读取的数据与这个窗口内的写入操作进行比较,以确定是否存在数据冲突。如果在这个窗口内,事务读取的数据被其他事务修改了,那么就会检测到冲突,该事务可能会被中止或重试。

FoundationDB 的架构

整个系统是自上而下启动的。FoundationDB 的控制面是通过 Paxos 算法选举出 Cluster Controller(即 leader),如下图所示。

下图为数据面,主要由 TS + LS + SS 组成。

TS

  • Sequencer:
    • 负责获取 read/commit version。
  • Proxy:
    • 一个中介,负责获取 read version 和事务提交。
  • Resolver:
    • 分布式地解决读写冲突。
    • 多节点,按照 key range 分片(shard)。

LS

  • 按照 key range 分片(shard)。

SS

  • 单机存储系统,从 LS 的 WAL 异步复制数据。
  • 多版本数据只存储在内存中
  • 5s 的 MVCC window(太长了会产生大量假阳性)

Log System

  • Log Server 下面绑了若干 Storage Server。
  • 按照 key range 映射到若干个 replica。
  • 之所以需要向未被映射到的 Log Server 传递空消息,是因为 Log Server 是按照顺序执行的,不这样做会产生“空洞”。

Commit Path

  1. Client:
    1. Buffer Modifications
    2. Send Read/Write Set To Proxy
  2. Proxy: Get Commit Version from Sequencer
  3. Proxy: Send User Data KeyRange To Resolvers
  4. Resolver: Check RW Conflict
  5. Proxy: Commit Phase When no Conflict
    1. Send Data to LS
    2. Committed When All LS return Success
    3. Send Commit Version to Sequencer for Read
    4. Proxy return OK to Client
  6. SS pull WAL from LS

Read: 1 roundtrip
Write: 4 roundtrip

Recovery && Replication

k = f + 1

这里 k 表示需要复制的日志份数,f 表示系统可容忍的故障节点数。即需要复制的日志份数比可容忍的故障节点数多 1,通过这种方式来保证即使部分节点故障,日志信息也不会丢失,能维持系统的正常运行和数据一致性。

阅读全文

SFINAE —— Substitution Failure Is Not An Error

Language 2025/3/24

IsConvertible

#include <iostream>
#include <string>

template <typename FROM, typename TO> struct IsConvetible {
  // FROM should be converted to TO implicitly
  static void aux(TO);

  // Should use another typename F, otherwise it will cause an compilation error
  // immediately
  template <typename F, typename = decltype(aux(std::declval<F>()))>
  static std::true_type test(void *);

  // Will failed with: No viable conversion from 'double' to 'std::string'
  //   template <typename = decltype(aux(std::declval<FROM>()))>
  //   static std::true_type test(void *);

  // If the above function failed, it will fallback here
  template <typename> static std::false_type test(...);

  static constexpr bool value = decltype(test<FROM>(nullptr))::value;
};

int main() {
  std::cout << std::boolalpha;
  std::cout << IsConvetible<double, std::string>::value << std::endl;
  std::cout << IsConvetible<double, int>::value << std::endl;
  std::cout << IsConvetible<const char *, std::string>::value << std::endl;
  std::cout << IsConvetible<const char[10], std::string>::value << std::endl;
  std::cout << IsConvetible<char[10], std::string>::value << std::endl;
}

输出:

false
true
true
true
true

IsDefaultConstructible

#include <iostream>
#include <string>

template <typename T> struct IsDefaultConstructibleHelper {

  // Should use another typename U, otherwise it will cause an compilation error
  // immediately
  template <typename U, typename = decltype(U())>
  static std::true_type test(void *);

  // If the above function failed, it will fallback here.
  template <typename> static std::false_type test(...);

  static constexpr bool value = decltype(test<T>(nullptr))::value;
};

template <typename T>
constexpr bool IsDefaultConstructible = IsDefaultConstructibleHelper<T>::value;

class WithDefaultConstructor {};

class WithoutDefaultConstructor {
  WithoutDefaultConstructor() = delete;
};

int main() {
  std::cout << std::boolalpha;
  std::cout << IsDefaultConstructible<int> << std::endl;
  std::cout << IsDefaultConstructible<WithDefaultConstructor> << std::endl;
  std::cout << IsDefaultConstructible<WithoutDefaultConstructor> << std::endl;
}

输出:

true
true
false
阅读全文

C++ Template 阅读笔记(2)

Language 2025/3/21

接上文,继续记录学习 C++ Template 这本书的心得。

变参模板

以下是一个例子:

void print() {}

template <typename T, typename... Types>
void print(T firstArg, Types... args) {
    std::cout << firstArg << std::endl;
    print(args...);
}

上述的代码必须要定义 print() 这个重载函数,否则编译会不通过。因为 Types... args 的参数数量最后会变成 0,这样就找不到对应的函数了,也可以采用如下的版本。

template <typename T>
void print(T arg) {
    std::cout << arg << std::endl;
}

template <typename T, typename... Types>
void print(T firstArg, Types... args) {
    print(firstArg);
    print(args...);
}

当传入的参数为 1 个的时候,会优先匹配第一个函数。

折叠表达式

template <typename... T> 
auto foldSum(T... args) { return (... + args); }

变参下标(Variadic Indices)

template <typename T, typename... Idx>
void printElements(const T &v, Idx... idx) {
  print(v[idx]...);
}
const char *names[]{"Alice", "Bob", "Peter"};
// print "Alice" and "Bob"
printElements(names, 0, 1);

template <size_t... Idx, typename C> void printElements(const C &v) {
  print(v[Idx]...);
}
const char *names[]{"Alice", "Bob", "Peter"};
// print "Bob" and "Peter"
printElements<1, 2>(names);

模板成员变量

template<typename T>
class MyClass {
public:
  static constexpr int size = sizeof(T);
};

template<typename T>
constexpr int Size = MyClass<T>::size;

std::cout << Size<char> << std::endl; // 1
std::cout << Size<double> << std::endl; // 8

各种 type trait,如 std::is_const_v 就是基于此。

从 static_assert 到 std::enable_if_t 再到 concept

static_assert

template <typename T> void foo(T t) {
  static_assert(sizeof(T) > 4,
                "size of given type should be greater than 4 bytes");
  std::cout << t << std::endl;
}
foo(1);

上述代码会报错:

Static assertion failed due to requirement ‘sizeof(int) > 4’: size of given type should be greater than 4 bytesclang(static_assert_requirement_failed)

std::enable_if_t

template <typename T> 
std::enable_if_t<(sizeof(T) > 4)> foo(T t) {
  std::cout << t << std::endl;
}

std::enable_if_t 等价于 std::enable_if::type。其中第一个模板类型是 bool 编译期常量,若为 true,则函数返回第二个模板类型(若没有第二个模板类型,则为 void),否则函数未定义。

上述代码会报未定义错误:

No matching function for call to ‘foo’

concept

template <typename T>
  requires(sizeof(T) > 4)
void foo(T t) {
  std::cout << t << std::endl;
}

类似的报错:

No matching function for call to 'foo'clang(ovl_no_viable_function_in_call)
main.cpp(18, 6): Candidate template ignored: constraints not satisfied [with T = int]
main.cpp(17, 12): Because 'sizeof(int) > 4' (4 > 4) evaluated to false

模板中按值传递 & 按引用传递

引用传递会保留类型的修饰,而值传递则会对类型进行退化。

template<typename T>
void foo(T &t) {
  if constexpr(std::is_array_v<T>) {
    std::cout << "is array" << std::endl;
  }
  if constexpr(std::is_const_v<T>) {
    std::cout << "is const" << std::endl;
  }
}

const char a[] = "Hello, World";
foo(a);
// 输出
// is array
// is const

而将上述代码修改为值传递:

template <typename T> void foo(T t) {
  if constexpr (std::is_array_v<T>) {
    std::cout << "is array" << std::endl;
  }
  if constexpr (std::is_const_v<T>) {
    std::cout << "is const" << std::endl;
  }
}

则不会产生任何输出,因为 const char[13] 被退化为了 char *
既不是 array 也不是 const

下面的函数有时候会产生问题:

template<typename T>
T foo(T t) {
  return t;
}

虽然会对类型进行退化,但是仍然可以通过显式的方式将 T 声明为引用。

int i;
foo<int &>(i);

这样可能会导致垂悬引用(dangling reference),所以此处可以通过三种方式保证返回值是退化的。

// 使用 auto 自动退化
template<typename T>
auto foo(T t) {
  return t;
}

// 使用 std::decay 显式退化
template<typename T>
std::decay_t<T> foo(T t) {
  return t;
}

// 去除引用
template<typename T>
std::remove_reference_t<T> foo(T t) {
  return t;
}

std::is_convertible

std::is_convertible 可以用于限制一个使用万能引用的构造函数的模板类型,例如:

class MyClass {
public:
  template <typename T>
  MyClass(T &&data)
    requires(std::is_convertible_v<T, std::string>)
      : data(data) {}

private:
  std::string data;
};

MyClass a(1); // error

报错信息:

No matching constructor for initialization of 'MyClass'clang(ovl_no_viable_function_in_init)
main.cpp(19, 7): Candidate constructor (the implicit copy constructor) not viable: no known conversion from 'int' to 'const MyClass' for 1st argument
main.cpp(19, 7): Candidate constructor (the implicit move constructor) not viable: no known conversion from 'int' to 'MyClass' for 1st argument
main.cpp(22, 3): Candidate template ignored: constraints not satisfied [with T = int]
main.cpp(23, 14): Because 'std::is_convertible_v<int, std::string>' evaluated to false

std::reference_wrapper

std::string str = "123";
auto r1 = std::ref(str); // std::reference_wrapper<string>
auto r2 = std::cref(str); // std::reference_wrapper<const string>

std::reference_wrapper 相当于在引用外包了一层,这样就可以在保证性能的前提下进行值传递(有些模板采用值传递)。std::reference_wrapper 可以隐式地转换成包裹的引用本身。

std::string &s2 = r1;
const std::string &s3 = r2;
阅读全文
1 2 3 ... 4
avatar
Zihong Lin

What I can’t create, I don’t understand.