缓存替换:LRU

2 minute read

Published:

上次提到最常用的 PLRU(Pseudo Least Recently Used) 的实现理论,这里介绍一下其原型 LRU 的理论和软硬件实现方式。

理论

LRU 原理上,遵循了局部性原理,最近被访问的缓存行更有可能在不久的将来再次被访问。故当容量受限时,会将最近最少使用的缓存行数据换出,并换入新数据。

LRU 实现上,主要是保证不同缓存行的访问顺序,通常会使用一些数据结构来辅助实现。

软件实现

参考题目 146. LRU 缓存 - 力扣(LeetCode) 实现 O(1) 的读、写和替换,其中最重要的是 STL 的选择,一个满足不了 O(1)的话,就设计两个,思路如下:

  1. 要满足 O(1) 的读、写,直观想到哈希表 unordered_map
  2. 在替换上,需要维护访问的次序,直观解决方式是维护一个双向链表 list,插入和删除操作都是 O(1)。每次访问时,将该节点移动到链表对头,这样链表尾部的节点就是最久未使用的节点。但是为了不会出现遍历 list 的情况(O(n)),list 存储的对象应当是一个指针,这个指针由访问哈希表来获得。

参考代码:

class LRUCache {
public:
    LRUCache(int capacity) {
        size = capacity;
    }
    
    int get(int key) {
       auto e = cache.find(key);
       if(e != cache.end()){
        // update lru
        auto it = e->second;
        lru.splice(lru.begin(), lru, it);
        // return value
        return it->second;
       }else{
        return -1;
       }
    }
    
    void put(int key, int value) {
        auto e = cache.find(key);
        if(e != cache.end()){
            auto it = e->second;
            lru.splice(lru.begin(), lru, it);
            it->second = value;
        }else{
            // replace
            if(cache.size() == size){
                auto tmp = lru.back();
                cache.erase(tmp.first);
                lru.pop_back();
            }
            // allocate
            lru.push_front(make_pair(key,value));
            cache[key] = lru.begin();
        }
    }

private:
    list< pair<int, int> > lru;
    unordered_map<int, list< pair<int, int> >::iterator > cache;
    size_t size;
};
/**
 * Your LRUCache object will be instantiated and called as such:
 * LRUCache* obj = new LRUCache(capacity);
 * int param_1 = obj->get(key);
 * obj->put(key,value);
 */

硬件实现

硬件上的设计,其实和上述软件逻辑类似,cache 的 CAM(Content Addressable Memory)结构充当了哈希表的职责,用于数据的访问(读和写);而在替换上,会采用单独的结构来尽可能快地实现顺序的判断(利用矩阵的并发特性)。下文主要介绍替换的结构的硬件实现方式。

矩阵法

原理: 使用一个存储单元矩阵,每个单元代表一个缓存行,当一个缓存行被访问时,会将对应的行全置为1,对应的列全置为0,未访问的行对应的列全置为1。

实现:

  1. 构建一个 $n\times n$ 的存储单元矩阵,其中 n 是组相联的数量。常见的 m-set n-way 的缓存需要 $m \times n^2$ 比特。
  2. 初始化所有存储单元为0。
  3. 当缓存行被访问时,将对应行置为1,对应列置为0。
  4. 找到全零的行对应的缓存行,即为最近最少使用的行。

注意: $n\times n$ 的存储单元矩阵本质,是想维护 1…n 缓存行的访问顺序矩阵。故存储上,最少只需要矩阵上三角的数据即可,即最少需要 $\log n$ 比特。

module matrix_lru #(parameter SIZE=8)(
  input  clk,
  input  rstb,
  input  update,
  input  [2:0] update_index,
  output [2:0] lru_index
);
  reg  [(SIZE-1):0] matrix [0:(SIZE-1)];
  reg  [(SIZE-1):0] matrix_next [0:(SIZE-1)];
  reg  [2:0] lru_index;
  reg  [2:0] lru_index_next;

  generate
    genvar i;
    for(i=0; i<SIZE; i=i+1) begin
      always@(posedge clk or negedge rstb) begin
        if(!rstb) matrix[i] <= 0;
        else matrix[i] <= matrix_next[i];
      end
    end
  endgenerate

  generate
    genvar j, k;
    for(j=0; j<SIZE; j=j+1) begin
      for(k=0; k<SIZE; k=k+1) begin
        always@(*) begin
          matrix_next[j][k] = matrix[j][k];
          // 行置为1,列清为0
          if(update && (j == update_index) && (k!=update_index))
            matrix_next[j][k] = 1'b1;
          else if (update && (k==update_index))
            matrix_next[j][k] = 1'b0;
        end
      end
    end
  endgenerate

  always@(*) begin
    lru_index_next = lru_index;
      if(matrix[0] == 8'b0)
        lru_index_next = 0;
      else if(matrix[1] == 8'b0)
        lru_index_next = 1;
      else if(matrix[2] == 8'b0)
        lru_index_next = 2;
      else if(matrix[3] == 8'b0)
        lru_index_next = 3;
      else if(matrix[4] == 8'b0)
        lru_index_next = 4;
      else if(matrix[5] == 8'b0)
        lru_index_next = 5;
      else if(matrix[6] == 8'b0)
        lru_index_next = 6;
      else if(matrix[7] == 8'b0)
        lru_index_next = 7;
  end

  always@(*) begin
    if(!rstb) lru_index <= 0;
    else lru_index <= !lru_index_next
  end
endmodule  

移位寄存器

每个缓存行对应一个 m 位的移位寄存器。当缓存行被访问时,将寄存器的最高位置为1。每隔一定时间,将寄存器右移一位,低位溢出。数值最小的寄存器对应的缓存行是最近最少使用的。

值得注意的是,这种方法无法保证严格的 LRU。比如2, 3, 1, 4, 1, 1, 1, 1 一段时间后,2, 3, 4 的移位寄存器都右移至 0 后,无法选出严格 LRU 下的最近最少使用的缓存行。

参考文献

  1. 数字IC前端学习笔记:近期最少使用(LRU)算法_lru矩阵算法-CSDN博客
  2. 页面置换算法之 LRU算法_lru页面置换算法-CSDN博客