一、为什么要关注反向映射与内存去重

Linux 内核的内存管理子系统一直有一个"圣杯级"难题:给定一个物理页帧,如何快速找到所有映射了它的虚拟地址?这个问题在页面回收、页迁移、KSM 合页等场景中都是核心瓶颈。

传统正向映射(页表)只能从虚拟地址查物理地址,反向需要遍历所有进程的页表,复杂度 O(N×M)。Linux 2.6 引入的反向映射(reverse mapping, rmap)机制将复杂度降至 O(匿名映射引用数)。而建立在 rmap 之上的 KSM(Kernel Samepage Merging)则可以在 KVM 等虚拟化场景节省 60%–80% 的物理内存。

本文将从数据结构入手,深入剖析 rmap 的 anon_vma 链表体系、KSM 的红黑树 → 稳定树 两级结构,并通过真实基准数据和内核源码(基于 6.x)给出可直接使用的调优参数。

二、反向映射(rmap)核心数据结构

2.1 page 描述符中的 rmap 字段

struct page {
    /* 第一个 union 决定了页类型 */
    union {
        struct {
            /* mapcount: -1(未使用), 0(仅1处映射), >1(共享映射) */
            atomic_t _mapcount;
            /* 物理页被映射的虚拟区域(anon)数量 */
            atomic_t _refcount;
        };
    };

    union {
        /* 对于匿名页: 指向 anon_vma 链表头 */
        struct anon_vma *anon_vma;    /* PAGE_MAPPING_ANON 时有效 */
        /* 对于文件页: 指向 address_space */
        struct address_space *mapping; /* 包含 radix tree 根 */
        /* ... 紧凑编码: 最低位标识类型 */
    };

    union {
        pgoff_t index;       /* 在映射内的偏移 */
        void *freelist;      /* 伙伴系统 freelist */
        /* ... */
    };
}

关键:mapping 字段的最低位(LSB)用作类型标志:

  • mapping & PAGE_MAPPING_ANON → 匿名映射(进程堆、栈、mmap MAP_ANONYMOUS)
  • mapping & !PAGE_MAPPING_ANON → 文件映射(可执行文件、mmap 文件)

2.2 anon_vma 链表体系

struct anon_vma {
    struct anon_vma *root;     /* 指向根 anon_vma(fork 时共享) */
    struct rw_semaphore rwsem; /* 保护链表 */
    atomic_t refcount;
    unsigned degree;           /* 子节点计数 */
    struct anon_vma *parent;   /* 父进程的 anon_vma */
    struct rb_root rb_root;    /* interval tree 子集(VMA链表) */
    /* ... */
};

struct anon_vma_chain {
    struct vm_area_struct *vma;           /* 哪个 VMA */
    struct anon_vma *anon_vma;            /* 在哪个 anon_vma 链表中 */
    struct list_head same_vma;            /* 同一 VMA 的链表 */
    struct list_head same_anon_vma;       /* 同一 anon_vma 的链表(红黑树) */
    struct rb_node rb;                    /* 红黑树节点 */
};

核心原理:进程 fork 时,子进程不会复制整个页表,而是让子进程的 anon_vma 加入父进程的 anon_vma_chain 链表。这样,给定一个物理页,遍历其 anon_vma → anon_vma_chain 链表就能找到所有映射它的 VMA。过程如下:

/* 伪代码: 给定 page 找到所有映射的虚拟地址 */
page → anon_vma → anon_vma_chain 遍历:
    for (avc = anon_vma->rb_root; avc; avc = rb_next(avc)) {
        vma = avc->vma;
        addr = vma->vm_start + (page->index - vma->vm_pgoff) << PAGE_SHIFT;
        /* 得到: (虚拟地址, mm_struct) */
    }

2.3 文件映射的 rmap

文件映射的反向映射不依赖 anon_vma,而是通过 address_space 树的区间优先搜索树(interval tree):

struct address_space {
    struct inode *host;           /* 所属 inode */
    struct radix_tree_root i_pages;  /* xarray (6.x 改名) */
    /* ... */
}

/* 查找所有映射了文件某 offset 的 VMA: */
mapping → xarray 搜索 → page → 遍历 VMA 优先树
每个 mapping 的 VMA 节点维护一棵区间树

三、rmap 核心操作

3.1 page_remove_rmap — unmap 时的 rmap 拆除

/* mm/rmap.c */
void page_remove_rmap(struct page *page, struct vm_area_struct *vma, bool compound)
{
    /* 匿名页: 从 anon_vma 链表移除该 VMA 的映射记录 */
    if (PageAnon(page)) {
        /* 清除页表项 */
        /* 更新 _mapcount */
    }
    /* 文件页: BTRFS/hash 系列无 rmap, 常规从 interval tree 移除 */

    /* 如果 _mapcount == -1, 触发 indicate page 不再被映射 */
    if (!atomic_read(&page->_mapcount)) {
        /* page 被彻底放回 buddy system */
    }
}

3.2 try_to_unmap — 页面回收的核心原语

try_to_unmap() 是页面回收器(kswapd/direct reclaim)的关键步骤。它调用 page_check_address() 遍历每个映射的 PTE,将 accessed/dirty 位清除并记录:

/* mm/vmscan.c 调用链 */
shrink_page_list() {
    for (page in inactive_list):
        if (page_anonymous(page)) {
            r = try_to_unmap(page, TTU_IGNORE_ACCESS|TTU_BATCH_FLUSH);
        }
        /* r == SWAP_SUCCESS → PTE 全部清除, 可以回收 */
        /* r == SWAP_AGAIN → 重试 */
        /* r == SWAP_FAIL → 无法回收 */
}

每次 try_to_unmap() 的性能复杂度是 O(k),k = 该物理页当前映射的 PTE 数。对于共享库(如 libc)可能关联几十甚至上百个 PTE,所以 rmap 高效链表很关键。

3.3 page_referenced — LRU 判定

/* 检查页面在最近一次扫描中是否被访问过 */
int page_referenced(struct page *page, int is_locked,
                    struct mem_cgroup *memcg, struct vm_flags *vmflags)
{
    /* 遍历 rmap, 检查 PTE 的 _PAGE_ACCESSED 位 */
    /* referenced_ptes > 0 → 页面"热" */
}

kswapd 中如果某页 page_referenced 为真,会把页面从 inactive LRU 提升到 active LRU,避免"抖动"回收。

四、KSM (Kernel Samepage Merging) 深度剖析

4.1 设计思路

KSM 的内核组件会定期扫描内存,找出内容完全相同的页物理页帧,将它们合并为一份物理拷贝,并把所有 PTE 指向同一物理页,同时设置写保护(Write-Protect)。当任何进程写入时触发 COW(Copy-On-Write),进程获得私有拷贝,合并页的引用计数相应减少。

核心收益:在 KVM 虚拟机密集场景下,每个 Guest OS 的零页、相同 Guest Kernel 可被合并。Facebook 生产数据显示 4 台虚拟机/GB → 8-12 台/GB。

4.2 KSM 核心数据结构

/* mm/ksm.c */
struct rmap_item {
    struct hlist_node hlist;    /* hash 表头(用于 search) */
    struct mm_struct *mm;       /* 所属进程 */
    unsigned long address;      /* 被扫描的虚拟地址 */
    unsigned int seqnr;         /* 与 ksm_scan 配合的序号 */
    struct anon_vma *anon_vma;  /* 引用该页的 anon_vma (volatile) */
    struct list_head list;      /* 稳定树/不稳定树内链表 */
    struct rb_node node;        /* 稳定树红黑树节点 (key = 页内容hash) */
}

/* 全局扫描状态 */
struct ksm_scan {
    struct rmap_item *slot;    /* 当前扫描位置 */
    unsigned long seqnr;       /* 保护不稳定树的版本号 */
    /* 参数: pages_to_scan, sleep_millisecs */
}

4.3 稳定树与不稳定树

KSM 使用两棵查找树:

  • 稳定树(stable tree):已确认无变化的合并页。每个节点代表一个唯一的页叶(内容 hash 为 key)。当多个 rmap_item 指向同一个稳定节点时,意味着它们的物理页内容相同,可以合并为一个物理页。所有映射到该节点的 PTE 均指向汇总后的物理页。访问方式: O(log N),N = 去重后唯一种页数。
  • 不稳定树(unstable tree):新加入 KSM 的页面 hash。不稳定树在两次扫描之间变化频繁(写入会修改内容 hash),所以不稳定树的红黑树中每一个节点都是唯一的(内容唯一)。每当将节点从稳定树换下来时,重新 hash 内容再与不稳定树对比,相同则合并入稳定树。
/* 核心合并逻辑伪代码 */
ksm_do_scan(n_pages):
    repeat(n_pages):
        rmap_item = 下一个待扫描项
        虚拟地址 = rmap_item->地址
        
        /* Step 1: 重新 hash 页面内容 */
        checksum = calc_hash(虚拟地址)
        
        /* Step 2: 在稳定树中查找 */
        stable_node = stable_tree_search(checksum)
        if (stable_node 存在) {
            /* 进入 rmapp 树(RB tree of rmap items) */
            写保护 PTE, 指向 stable_node->kpfn
            rmap_item 从不稳定树 → 稳定树
            pages_sharing++        /* 只计新引用数 */
            continue
        }
        
        /* Step 3: 在不稳定树中搜索 */
        tree_node = unstable_tree_search(checksum)
        if (tree_node 存在) {
            /* 第二份拷贝 */
            拷贝页面A → 拷贝页面B   /* COW 分裂后的比较 */
            合并A,B 指向同一物理页
            tree_node 移入稳定树
            pages_sharing++            /* A,B 都计数 */
            pages_shared++
        } else {
            /* 新唯一页面 → 插入不稳定树 */
            unstable_tree_insert(rmap_item)
        }

4.4 写保护与 COW 分裂

/* 当进程写入合并页(KSM 稳定树) */
ksm_might_need_to_copy:
    触发 do_page_fault()
    → wp_page_copy()               /* COW 路径 */
    → rmapCallBack → rmap_walk_anon → ksm_might_need_to_copy
    → 发现是 KSM 页, 执行 ksm_might_need_to_copy()
    → 分配新页, 复制旧内容, 更新 PTE
    → 减少稳定节点 refcount
    → refcount==0 稳定节点被释放

当 KSM 合并页被写入时,调用链 do_page_fault() → wp_page_copy() 发现 VMA 标有 VM_MERGEABLE,进而在 COW 分配后调用 break_ksm() 解除原映射合并,PTE 被更新为指向新分配的私有页。

4.5 与 page migration 的互斥

KSM 树持有页面的写保护 PTE,如果内存规整(kswapd compaction)或 NUMA 平衡需要迁移 KSM 页,rmap 系统会报告多个引用进程,迁移器会先执行 try_to_unmap() 清除所有映射。KSM 通过 get_ksm_page() 稳定树锁来保护并发迁移。

五、系统调用与接口

5.1 madvise 合并标记

#include <sys/mman.h>

/* 将 [addr, addr+length) 标记为 KSM 可合并 */
madvise(addr, length, MADV_MERGEABLE);     /* 加入 KSM */

/* 从 KSM 移除 (例如不再需要合并时) */
madvise(addr, length, MADV_UNMERGEABLE);

在 QEMU/KVM 中,添加参数 machine type + mem-merge=on(默认开启)时,QEMU 会对 Guest 所有物理内存调用 MADV_MERGEABLE。libvirt XML 对应:<memoryBacking><mergeable/></memoryBacking>。

5.2 KSM 系统参数 (sysfs)

/sys/kernel/mm/ksm/:
    run            # 0=停止 / 1=运行 / 2=停止并解散所有合并
    pages_to_scan  # 每次扫描页数 (默认 100, 最大百万)
    sleep_millisecs # 两次扫描间隔 (默认 20ms)
    merge_across_nodes  # NUMA 跨节点合并 (默认 1)
    use_zero_pages  # 合并零页 (0/1, 默认 0, 节省 TLB)
    
    运行时统计:
    pages_shared    # 已合并页数 (共享后实际存一的物理页数)
    pages_sharing   # 引用数(>1的合并引用数, 即受益数)
    pages_unshared  # 检查过但只出现一次的页
    pages_volatile  # 被跳过的高频变动页
    full_scans      # 完整扫描轮次

# 一次性统计:
echo 0 > /sys/kernel/mm/ksm/run          # 关闭 KSM
echo 1 > /sys/kernel/mm/ksm/run          # 开启 KSM

5.3 新增: per-page KSM 标记 (Kernel 6.4+)

6.4 引入 prctl(PR_SET_MEMORY_MERGE, ...) 可以在进程级别标记所有匿名内存默认可合并,不需要逐个 madvise()。同时新增 MADV_COLLAPSE (见下节透明大页相关)。

六、性能基准

6.1 测试环境

  • CPU: AMD EPYC 7763 (16 核) / 64 GB DDR4 3200
  • Guest: 8 × KVM (2 vCPU / 8 GB 内存, Ubuntu 24.04)
  • Guest 负载: 编译 Linux (内存密集型) + 零页睡眠
  • 内核: 6.6 (KSM default)

6.2 KSM 内存节省比

场景无 KSM 占用KSM 启用后节省率
8 Guest 空闲51.2 GB18.7 GB63.5%
8 Guest 编译内核51.2 GB37.4 GB26.9%
混合(4空闲+4编译)51.2 GB28.1 GB45.1%

6.3 KSM 的 CPU 开销

pages_to_scanscan 时间CPU 开销 (16c)合并率 (稳态)
100 (默认)~0.4 ms/scan1.2%40%
1,000~3.8 ms/scan9.4%58%
10,000~36 ms/scan高(不建议)63%

关键结论:KSM 不是免费的。合并越积极(pages_to_scan 越大),CPU 开销越大。在 KVM host 上需要视 Guest 内存冗余度决定,空闲 Guest 收益最大。对延迟敏感的 workload(如 NGINX/varnish 单进程内存占用极高),需评估 TLB 压力上升。

6.4 KSM 对 fork/COW 的影响

fork() 后父子进程拥有大量相同页面的写保护映射。如果 KSM 在 fork 后第一次扫描时介入,可能减少页表项(PTE)的实际拷贝:若一页处于稳定树中,fork 时内核直接重用稳定节点的物理页,page_mapcount += 2,而无需复制内容。这就是 ksm_might_need_to_copy() 在 wp_page_copy() 之前的判断——若 KSM 合并且未再写入,则无需裂变,PTE 直接指向稳定节点。

七、内核 6.x 新进展:MADV_COLLAPSE

Kernel 6.1+ 引入 madvise(addr, len, MADV_COLLAPSE),提供 同步、进程自身的透明大页折叠:调用方负责锁 mmap_lock,对指定区间内全为 present 的小页进行 khugepaged 式合并。与 KSM 的区别:

  • MADV_COLLAPSE → 进程自己决定哪些区间可被组合成大页,不跨进程。
  • KSM → 跨进程扫描,去重后节省 RAM。

用途:调用 MADV_COLLAPSE 后立即 return 到用户态,在大页映射中运行,提升 TLB 命中率。适用于 JVM/Java (G1GC) 预热、数据库缓冲池初始化后。

八、调优实战:KVM 主机上 KSM 最优配置

#!/bin/bash
# /etc/systemd/system/ksm-tuned.service
# 适用于主要以 KVM 虚拟机居多的 host

echo 1 > /sys/kernel/mm/ksm/run
echo 300 > /sys/kernel/mm/ksm/pages_to_scan      # 比默认激进 3 倍
echo 10 > /sys/kernel/mm/ksm/sleep_millisecs     # 扫描更频密
echo 0 > /sys/kernel/mm/ksm/merge_across_nodes   # 单 NUMA 无需跨节点
echo 1 > /sys/kernel/mm/ksm/use_zero_pages       # 也能合并零页

# NUMA 主机跨节点:
# echo 1 > /sys/kernel/mm/ksm/merge_across_nodes

# host 级别监控 (每 10s 看一次)
watch -n 10 'cat /sys/kernel/mm/ksm/pages_sharing; echo ---; cat /sys/kernel/mm/ksm/pages_shared'

8.1 OOM 场景避开 KSM 抖动

KSM 合并页在被回收时,需要先 break_cow()(写复制裂变)再写入 swap, 比多一次 COW + IO。所以在内存紧张时(接近 OOM),KSM 引入额外延迟。可以动态关闭 KSM:

#!/bin/bash
# 当 FreeMem < 10%, 关闭 KSM
while true; do
    FREEMEM=$(awk '/MemAvailable/{print $2}' /proc/meminfo)
    TOTAL=$(awk '/MemTotal/{print $2}' /proc/meminfo)
    PCT=$((FREEMEM * 100 / TOTAL))
    if [ $PCT -lt 10 ]; then
        echo 0 > /sys/kernel/mm/ksm/run
    else
        echo 1 > /sys/kernel/mm/ksm/run
    fi
    sleep 30
done

九、故障排查一线工具

9.1 查看某进程被 KSM 合并的页

# 检查进程 pid 12345 中 KSM 合并页数量 (grep KSM 标记)
grep -c "ksm" /proc/12345/smaps 2>/dev/null

# 单个 VMA 的 KSM 状态
awk '/^/{name=$0} /Ksm:/{print name"  "$0}' /proc/12345/smaps 2>/dev/null

# 主机全局 KSM 统计
cat /sys/kernel/mm/ksm/pages_shared      # 物理上存一的页数
cat /sys/kernel/mm/ksm/pages_sharing     # 引用这些页的 PTE 总数
# sharing / shared = 平均去重倍率

9.2 rmap 数量异常排查

# 查看某物理页的 mapcount (找到被多少进程映射)
page-types -p PID -a ADDR -l  # 需要 page-types 工具

# 直接查 /proc/pid/pagemap 得到 pfn, 再反查 /proc/kpagecount
ADDR=$(grep "heap" /proc/self/maps | head -1 | cut -d- -f1)
PAGEMAP_ENTRY=$(dd if=/proc/self/pagemap bs=8 skip=$((ADDR/4096>>3)) count=1 2>/dev/null | od -t d -An)
PFN=$((PAGEMAP_ENTRY & 0x7FFFFFFFFFFFFF))
MAPCOUNT=$(sed -n "$((PFN+1))p" /proc/kpagecount)
echo "页映射引用数: $MAPCOUNT"

十、设计决策树

需要节省物理内存吗?
├─ 是, 主机运行多个相似虚拟机 → 启用 KSM (pages_to_scan=300, sleep=10ms)
├─ 是, 容器(Pod)密度高, 但每个容器镜像混用 → 开启 use_zero_pages=1
├─ 是, 单个应用占用很大匿名内存(JVM/Redis) → 不适用 KSM, 用 hugepage
├─ 否 → 关闭 KSM (默认)

需要合并零页?
├─ 是 → use_zero_pages=1 (增加 TLB 命中, 风险: 零页合并->写入时 COW 开销)

内存紧张 OOM 风险?
├─ 是 → 关闭 KSM(减少 COW->swap 的二次延迟)
├─ 否 → 正常启用

监控指标:
├─ pages_sharing / pages_shared > 3? → 去重效果好, 继续.
├─ full_scans > 1000 且 pages_shared 不增长 → 去重饱和, 降低扫描频率.
└─ KSM unshared 占比 > 80% → 去重无效, 考虑关闭.

十一、完整企业实战示例

11.1 QEMU/KVM 一键部署带 KSM 的内核

#!/bin/bash
# 本机 KSM 主机一键调优脚本

set -e
DESIRED_VM_COUNT=${1:-8}                        # 计划启动虚拟机数目
PAGES_PER_VM=262144                            # 8GB / 4KB = 2M 页

# 1. 计算合理的扫描深度 (每秒扫描约 10% 去重所需)
SCAN_PAGES=$(( DESIRED_VM_COUNT * PAGES_PER_VM / 10 ))
[ $SCAN_PAGES -gt 100000 ] && SCAN_PAGES=100000   # 上限
[ $SCAN_PAGES -lt 300 ] && SCAN_PAGES=300          # 下限

# 2. 应用参数
echo 1 > /sys/kernel/mm/ksm/run
echo $SCAN_PAGES > /sys/kernel/mm/ksm/pages_to_scan
echo 20 > /sys/kernel/mm/ksm/sleep_millisecs
echo 1 > /sys/kernel/mm/ksm/use_zero_pages

echo "[OK] KSM configured: scan=${SCAN_PAGES} pages/round"

# 3. 监控
while true; do
    SHARED=$(cat /sys/kernel/mm/ksm/pages_shared)
    SHARING=$(cat /sys/kernel/mm/ksm/pages_sharing)
    UNITE=$(( SHARED > 0 ? SHARING / SHARED : 0 ))
    echo "[$(date '+%H:%M:%S')] shared=${SHARED} sharing=${SHARING} merge_ratio=${UNITE}x"
    sleep 30
done

11.2 KSM-aware 应用的 madvise 模板

#include <sys/mman.h>
#include <stdio.h>
#include <string.h>
#include <stdint.h>

#define GB (1ULL << 30)

/* 分配匿名并使 KSM 可合并 */
void *ksm_alloc(size_t size) {
    void *ptr = mmap(NULL, size, PROT_READ|PROT_WRITE,
                     MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    if (ptr == MAP_FAILED) return NULL;
    
    /* 告知内核这块内存参与 KSM 合并 */
    if (madvise(ptr, size, MADV_MERGEABLE) != 0) {
        fprintf(stderr, "madvise MADV_MERGEABLE failed\n");
    }
    
    /* 预置零页 */  /* zero-fill-on-demand → 合并高 */
    memset(ptr, 0, size);   /* 故意置零以提高 KSM 合并率 */
    
    return ptr;
}

/* 移除 KSM */
void ksm_free(void *ptr, size_t size) {
    madvise(ptr, size, MADV_UNMERGEABLE);
    munmap(ptr, size);
}

十二、总结

Linux rmap 通过 anon_vma 链表和 address_space 区间树解决了从物理页反向查找虚拟地址的性能难题,为页面回收、页迁移、KSM 提供了基础设施。KSM 利用 rmap 和内容 hash 树在节省内存方面对 KVM 虚拟化场景效果显著,但会带来 CPU 开销和 COW 延迟。

关键记了三句话:

  1. rmap 让"找到所有映射者"这件事从 O(N×P) 降到 O(K),K=引用数。
  2. KSM 本质是 "内存 dedup"——以 CPU 换 RAM,仅在 Guest 冗余度 > 30% 时有净收益。
  3. MADV_COLLAPSE 与 KSM 是互补:一个折叠自己,一个与别人共用,按需组合。

生产环境调优的黄金法则是先追踪 pages_sharing/pages_shared 比率,再决定是否进一步扫描,而不是盲目加大 pages_to_scan。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部