From bdc7a6947d6d175b65d23f45712ce55bcf4e2052 Mon Sep 17 00:00:00 2001 From: JP Kobryn Date: Wed, 8 Apr 2026 18:52:01 -0700 Subject: [PATCH] vmm: add per-zone mergeable option to --memory-zone Add a `mergeable` field to `MemoryZoneConfig` so that KSM page merging can be enabled selectively per memory zone rather than globally for all guest RAM. Previously, `MADV_MERGEABLE` was only controllable via the top-level `--memory mergeable=on` flag, which applied uniformly to all regions. With this change, users can leave boot memory unmerged while enabling KSM only on hotplug zones: --memory size=0,hotplug_method=virtio-mem --memory-zone id=boot,size=512M,shared=on,mergeable=off --memory-zone id=hotplug,size=256M,hotplug_size=1G,shared=off,mergeable=on The `MemoryZone` runtime struct now carries the `mergeable` flag so that both `allocate_address_space` and `add_ram_region` can apply per-zone `MADV_MERGEABLE` instead of the global `self.mergeable`. The top-level `--memory mergeable=on` path continues to work unchanged: the default zone is synthesised from `MemoryConfig` and inherits its `mergeable` value. AI/LLM disclosure: this patch was co-authored with GitHub Copilot and Claude Code (Opus 4.6). Signed-off-by: JP Kobryn --- docs/memory.md | 31 +++++++++++++- vmm/src/config.rs | 90 ++++++++++++++++++++++++++++++++++++++- vmm/src/memory_manager.rs | 31 ++++++++++---- vmm/src/vm_config.rs | 4 +- 4 files changed, 145 insertions(+), 11 deletions(-) diff --git a/docs/memory.md b/docs/memory.md index 75179e0e0..fb42e8937 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -214,11 +214,12 @@ struct MemoryZoneConfig { hotplug_size: Option, hotplugged_size: Option, prefault: bool, + mergeable: bool, } ``` ``` ---memory-zone User defined memory zone parameters "size=,file=,shared=on|off,hugepages=on|off,hugepage_size=,host_numa_node=,id=,hotplug_size=,hotplugged_size=,prefault=on|off" +--memory-zone User defined memory zone parameters "size=,file=,shared=on|off,hugepages=on|off,hugepage_size=,host_numa_node=,id=,hotplug_size=,hotplugged_size=,prefault=on|off,mergeable=on|off" ``` This parameter expects one or more occurrences, allowing for a list of memory @@ -422,6 +423,34 @@ _Example_ --memory-zone id=mem0,size=1G,prefault=on ``` +### `mergeable` + +Specifies if the pages from this memory zone must be marked as _mergeable_, +enabling Kernel Same-page Merging (KSM) for this zone. + +This is the per-zone equivalent of the top-level `--memory mergeable=on` option. +It allows KSM to be enabled selectively — for example, enabling it only on a +hotplug zone while leaving boot memory unaffected: + +``` +--memory size=2G,mergeable=off +--memory-zone id=hotplug,size=0,hotplug_size=8G,mergeable=on +``` + +For KSM to have any effect, the host kernel must have KSM enabled: +``` +echo 1 > /sys/kernel/mm/ksm/run +``` + +By default this option is turned off. + +_Example_ + +``` +--memory size=0 +--memory-zone id=mem0,size=1G,mergeable=on +``` + ## NUMA settings `NumaConfig` or what is known as `--numa` from the CLI perspective has been diff --git a/vmm/src/config.rs b/vmm/src/config.rs index 405cb1da7..b2ae29a84 100644 --- a/vmm/src/config.rs +++ b/vmm/src/config.rs @@ -1006,7 +1006,8 @@ impl MemoryConfig { .add("host_numa_node") .add("hotplug_size") .add("hotplugged_size") - .add("prefault"); + .add("prefault") + .add("mergeable"); parser.parse(memory_zone).map_err(Error::ParseMemoryZone)?; let id = parser.get("id").ok_or(Error::ParseMemoryZoneIdMissing)?; @@ -1047,6 +1048,11 @@ impl MemoryConfig { .map_err(Error::ParseMemoryZone)? .unwrap_or(Toggle(false)) .0; + let mergeable = parser + .convert::("mergeable") + .map_err(Error::ParseMemoryZone)? + .unwrap_or(Toggle(mergeable)) + .0; zones.push(MemoryZoneConfig { id, @@ -1059,6 +1065,7 @@ impl MemoryConfig { hotplug_size, hotplugged_size, prefault, + mergeable, }); } Some(zones) @@ -3750,6 +3757,87 @@ mod unit_tests { Ok(()) } + #[test] + fn test_mem_zone_parsing() -> Result<()> { + // mergeable defaults to false + assert_eq!( + MemoryConfig::parse("size=0", Some(vec!["id=mem0,size=1G"]))?, + MemoryConfig { + size: 0, + zones: Some(vec![MemoryZoneConfig { + id: "mem0".to_string(), + size: 1 << 30, + ..Default::default() + }]), + ..Default::default() + } + ); + // mergeable=on + assert_eq!( + MemoryConfig::parse("size=0", Some(vec!["id=mem0,size=1G,mergeable=on"]))?, + MemoryConfig { + size: 0, + zones: Some(vec![MemoryZoneConfig { + id: "mem0".to_string(), + size: 1 << 30, + mergeable: true, + ..Default::default() + }]), + ..Default::default() + } + ); + // mergeable=off is explicit false + assert_eq!( + MemoryConfig::parse("size=0", Some(vec!["id=mem0,size=1G,mergeable=off"]))?, + MemoryConfig { + size: 0, + zones: Some(vec![MemoryZoneConfig { + id: "mem0".to_string(), + size: 1 << 30, + mergeable: false, + ..Default::default() + }]), + ..Default::default() + } + ); + // per-zone mergeable independent of global mergeable + assert_eq!( + MemoryConfig::parse( + "size=1G,mergeable=off", + Some(vec!["id=hotplug,size=0,hotplug_size=4G,mergeable=on"]) + )?, + MemoryConfig { + size: 1 << 30, + mergeable: false, + hotplug_method: HotplugMethod::Acpi, + zones: Some(vec![MemoryZoneConfig { + id: "hotplug".to_string(), + size: 0, + hotplug_size: Some(4 << 30), + mergeable: true, + ..Default::default() + }]), + ..Default::default() + } + ); + // global mergeable=on inherited by zone with no explicit mergeable + assert_eq!( + MemoryConfig::parse("size=0,mergeable=on", Some(vec!["id=mem0,size=1G"]))?, + MemoryConfig { + size: 0, + mergeable: true, + zones: Some(vec![MemoryZoneConfig { + id: "mem0".to_string(), + size: 1 << 30, + mergeable: true, + ..Default::default() + }]), + ..Default::default() + } + ); + Ok(()) + } + #[test] fn test_mem_parsing() -> Result<()> { assert_eq!(MemoryConfig::parse("", None)?, MemoryConfig::default()); diff --git a/vmm/src/memory_manager.rs b/vmm/src/memory_manager.rs index 3a57ea60a..a01949b0f 100644 --- a/vmm/src/memory_manager.rs +++ b/vmm/src/memory_manager.rs @@ -138,16 +138,18 @@ pub struct MemoryZone { shared: bool, hugepages: bool, backing_page_size: u64, + mergeable: bool, } impl MemoryZone { - fn new(shared: bool, hugepages: bool, backing_page_size: u64) -> Self { + fn new(shared: bool, hugepages: bool, backing_page_size: u64, mergeable: bool) -> Self { Self { regions: Vec::new(), virtio_mem_zone: None, shared, hugepages, backing_page_size, + mergeable, } } @@ -607,7 +609,7 @@ impl MemoryManager { // Add zone id to the list of memory zones. memory_zones.insert( zone.id.clone(), - MemoryZone::new(zone.shared, zone.hugepages, zone_align_size), + MemoryZone::new(zone.shared, zone.hugepages, zone_align_size, zone.mergeable), ); for ram_region in ram_regions.iter() { @@ -701,7 +703,12 @@ impl MemoryManager { } memory_zones.insert( zone.id.clone(), - MemoryZone::new(zone.shared, zone.hugepages, zone_align_size), + MemoryZone::new( + zone.shared, + zone.hugepages, + zone_align_size, + zone.mergeable, + ), ); } @@ -733,7 +740,12 @@ impl MemoryManager { let zone_page_size = memory_zone_get_align_size(zone_config)?; memory_zones.insert( zone_config.id.clone(), - MemoryZone::new(zone_config.shared, zone_config.hugepages, zone_page_size), + MemoryZone::new( + zone_config.shared, + zone_config.hugepages, + zone_page_size, + zone_config.mergeable, + ), ); } @@ -1295,6 +1307,7 @@ impl MemoryManager { hotplug_size: config.hotplug_size, hotplugged_size: config.hotplugged_size, prefault: config.prefault, + mergeable: config.mergeable, }]; Ok((config.size, zones, allow_mem_hotplug)) @@ -1316,10 +1329,10 @@ impl MemoryManager { regions.push((virtio_mem_zone.region().clone(), true)); } - list.push((zone_id.clone(), regions)); + list.push((zone_id.clone(), regions, memory_zone.mergeable)); } - for (zone_id, regions) in list { + for (zone_id, regions, zone_mergeable) in list { for (region, virtio_mem) in regions { // SAFETY: guaranteed by GuestRegionMmap invariants let slot = unsafe { @@ -1327,7 +1340,7 @@ impl MemoryManager { region.start_addr().raw_value(), region.len().try_into().unwrap(), region.as_ptr(), - self.mergeable, + zone_mergeable, false, self.log_dirty, ) @@ -2087,7 +2100,9 @@ impl MemoryManager { region.start_addr().0, region.len().try_into().unwrap(), region.as_ptr(), - self.mergeable, + self.memory_zones + .get(DEFAULT_MEMORY_ZONE) + .map_or(self.mergeable, |z| z.mergeable), false, self.log_dirty, ) diff --git a/vmm/src/vm_config.rs b/vmm/src/vm_config.rs index 94b1c011c..541f6f21b 100644 --- a/vmm/src/vm_config.rs +++ b/vmm/src/vm_config.rs @@ -159,7 +159,7 @@ pub struct PciSegmentConfig { pub mmio64_aperture_weight: u32, } -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] pub struct MemoryZoneConfig { pub id: String, pub size: u64, @@ -179,6 +179,8 @@ pub struct MemoryZoneConfig { pub hotplugged_size: Option, #[serde(default)] pub prefault: bool, + #[serde(default)] + pub mergeable: bool, } impl ApplyLandlock for MemoryZoneConfig {