Skip to main content
LadeStack LogoLadeStack
Virtual Machines 11 min read October 13, 2024

Optimizing VM Performance for Production Workloads: A Sysadmin's Guide to Hypervisor Tuning

Master vCPU scheduling, NUMA pinning, CPU steal time diagnosis, virtio multiqueue I/O, hugepages, and guest Linux kernel tuning for virtual instances.

Virtual Machine Performance Optimization Architecture Diagram

1. Introduction: The Hypervisor Virtualization Tax

In enterprise infrastructure, running production databases, high-throughput message brokers, and low-latency API tiers on virtualized hardware introduces latency profiles absent on bare metal. A virtual machine does not execute in isolation; it shares hardware timers, CPU cache hierarchies (L1/L2/L3), memory buses, and PCI express switches with the hypervisor host and potentially other virtual machines.

When an application inside a VM experiences unexplained tail latency spikes or degraded IOPS, engineers often reflexively upscale the instance tier. However, throwing additional virtual CPUs (vCPUs) and RAM at a sub-optimally tuned VM often exacerbates performance bottlenecks due to increased hypervisor scheduling contention and cross-NUMA memory hops.

Hardware Layer to Virtual Guest Abstraction
===========================================
Physical Hardware:
[ Socket 0: 32 Physical Cores | L3 Cache ] ──(QPI/UPI Bus)── [ Socket 1: 32 Cores | L3 Cache ]
[ NUMA Node 0: 128GB DDR5 Local Memory   ]                   [ NUMA Node 1: 128GB DDR5 Memory  ]
                         ▲                                                  ▲
═════════════════════════╪══════════════════════════════════════════════════╪══════════════════
Hypervisor Layer (KVM / Nitro / ESXi Scheduler):
   ├── Pins vCPU threads to specific physical CPU cores (Affinity)
   ├── Arbitrates memory pages and ballooning drivers
   └── Dispatches block I/O through Virtio rings to physical NVMe
═════════════════════════╪══════════════════════════════════════════════════╪══════════════════
Guest Operating System:
[ Guest Linux Kernel ] ──► [ Applications: PostgreSQL / Kafka / Redis / Java JVM ]

Optimizing virtual machine performance requires a systematic methodology spanning four distinct layers:

  1. CPU & Scheduling: vCPU allocation, overcommit ratios, and CPU steal time diagnosis.
  2. Memory Subsystem: NUMA node alignment, memory ballooning hazards, and HugePages configuration.
  3. Storage & Disk I/O: Virtio-SCSI multiqueue, IOPS queue depths, and filesystem TRIM discards.
  4. Guest Kernel Tuning: Sysctl network buffers, scheduler granularity, and telemetry profiling.

Assumptions: The techniques in this guide focus on modern Linux guests (kernels 5.15+) hosted on Linux KVM/QEMU hypervisors or cloud equivalents (AWS Nitro System, Google Cloud Compute Engine, and VMware ESXi).


2. vCPU Allocation, Scheduling, and CPU Steal Time

Understanding vCPUs vs Physical Cores

A virtual CPU (vCPU) is not a dedicated CPU processor. In hypervisor architectures, a vCPU is simply a POSIX execution thread scheduled on the host operating system’s process table. When your guest VM has 8 vCPUs, the hypervisor runs 8 thread workers (e.g., QEMU thread tasks) that compete with all other processes on the host for execution time slices on physical CPU threads (Hyper-Threading / SMT logical cores).

CPU Overcommit Ratios

The ratio of total allocated vCPUs to physical CPU cores defines host overcommit:

$$\text{Overcommit Ratio} = \frac{\sum \text{Allocated Guest vCPUs}}{\text{Physical Host Cores (or SMT Threads)}}$$

  • 1:1 Ratio (No Overcommit): Mandatory for latency-critical, high-load production workloads (e.g., PostgreSQL databases, trading engines, Kafka brokers). Guarantees a dedicated physical thread for every guest vCPU.
  • 2:1 to 3:1 Ratio: Acceptable for general-purpose application servers and CI/CD runners where workloads exhibit bursty, asynchronous CPU consumption.
  • > 4:1 Ratio: Severe overcommit. High risk of CPU Starvation, context-switching thrashing, and high latency jitter.

Diagnosing CPU Steal Time (%st)

CPU Steal Time represents the percentage of time that a virtual CPU was ready to execute instructions in the guest OS, but was forced to wait because the hypervisor was servicing another virtual machine or host workload on that physical processor.

# Monitor CPU steal time inside the guest using mpstat
mpstat -P ALL 1 5
Example mpstat output indicating hypervisor contention:
11:42:01 AM  CPU    %usr   %nice    %sys %iowait   %irq   %soft  %steal  %guest  %idle
11:42:02 AM    0   14.20    0.00    3.10    0.50   0.00    0.20   12.80    0.00  69.20
11:42:02 AM    1   12.10    0.00    2.90    0.40   0.00    0.10   15.40    0.00  69.10

Diagnostic Thresholds:

  • %steal < 1%: Healthy baseline. The hypervisor is scheduling guest threads with negligible delay.
  • %steal between 3% and 8%: Noticeable latency degradation. The host is overcommitted or a noisy neighbor VM on the same physical socket is monopolizing CPU resources.
  • %steal > 10%: Critical condition. Production database queries and API response times will exhibit severe tail latency outliers (p99/p99.9). Immediate host rebalancing, instance migration, or noisy neighbor remediation is required.

3. NUMA Topology: Node Pinning and Memory Latency

Non-Uniform Memory Access (NUMA) is the architectural standard in multi-socket server motherboards. In a dual-socket server, each physical CPU socket owns dedicated local RAM channels (a NUMA Node). Accessing local memory takes approximately 60ns to 80ns, whereas accessing memory attached to the remote CPU socket over the interconnect bus (Intel Ultra Path Interconnect / AMD Infinity Fabric) takes 120ns to 160ns—a 100% latency penalty.

NUMA Node Memory Access Topology
--------------------------------
[Socket 0 / NUMA Node 0] ══════════(UPI Bus)══════════ [Socket 1 / NUMA Node 1]
     │ (Local: ~70ns)                                       │ (Local: ~70ns)
     ▼                                                      ▼
[Local RAM Bank 0]                                     [Local RAM Bank 1]
     ▲                                                      ▲
     └──────────────── Remote Access (~140ns) ──────────────┘

The “Split-Socket” Anti-Pattern

If you provision a 16-vCPU virtual machine on a host where each NUMA node contains 8 physical cores, and fail to expose the NUMA topology to the guest, the hypervisor scheduler will randomly dispatch vCPU threads across both sockets while spreading memory allocations across both banks. The guest operating system kernel treats the instance as a single uniform memory pool (UMA), causing constant remote memory bus traversal and unpredictable L3 cache invalidations.

NUMA Guest Configuration (libvirt / KVM Example)

For large VM instances exceeding the core count of a single NUMA node, configure virtual NUMA (vNUMA) in the domain XML to mirror the physical hardware topology:

<domain type='kvm'>
  <vcpu placement='static'>16</vcpu>
  <cputune>
    <!-- Pin vCPUs 0-7 to physical cores 0-7 (Socket 0 / NUMA 0) -->
    <vcpupin vcpu='0' cpuset='0'/>
    <vcpupin vcpu='1' cpuset='1'/>
    <!-- Pin vCPUs 8-15 to physical cores 16-23 (Socket 1 / NUMA 1) -->
    <vcpupin vcpu='8' cpuset='16'/>
    <vcpupin vcpu='9' cpuset='17'/>
  </cputune>
  <numatune>
    <!-- Bind memory allocations strictly to local NUMA nodes -->
    <memory mode='strict' nodeset='0-1'/>
  </numatune>
  <cpu mode='host-passthrough'>
    <topology sockets='2' dies='1' cores='8' threads='1'/>
    <numa>
      <cell id='0' cpus='0-7' memory='33554432' unit='KiB'/>
      <cell id='1' cpus='8-15' memory='33554432' unit='KiB'/>
    </numa>
  </cpu>
</domain>

Inside the guest, verify that the Linux kernel recognizes both NUMA nodes:

# Check NUMA node memory distribution and hit ratios inside the guest
numactl --hardware
numastat

Ensure numastat indicates high numa_hit counts and near-zero numa_miss or foreign_alloc values.


4. Memory Subsystem: Ballooning Hazards, Swap, and HugePages

The Danger of Memory Ballooning in High-Throughput Workloads

Memory ballooning allows a hypervisor to dynamically reclaim unused RAM from a guest VM without restarting it. The balloon driver (virtio_balloon) inflates inside the guest, forcing the guest OS kernel to evict filesystem page caches or swap out anonymous memory to satisfy the balloon’s allocation.

Production Rule: Disable memory ballooning for production databases, caches, and Elasticsearch clusters. When a database relies on the Linux page cache for read performance, an inflating balloon will silently destroy cache hit ratios and trigger unexpected disk swapping, degrading throughput by an order of magnitude.

HugePages: Reducing TLB Misses

By default, the x86-64 architecture manages memory in 4 KB pages. For a virtual machine with 64 GB of RAM, the operating system must track over 16 million page table entries. The CPU’s Translation Lookaside Buffer (TLB)—a tiny hardware cache that translates virtual memory addresses to physical addresses—cannot hold this many mappings, leading to constant TLB misses and hardware page table walks.

In virtualized environments, this cost is doubled because of Two-Dimensional Page Walks (Guest Virtual Address -> Guest Physical Address -> Host Physical Address).

Two-Dimensional Paging (EPT / NPT)
----------------------------------
[Guest Virtual Address]
         │ (Walks Guest Page Table)
         ▼
[Guest Physical Address]
         │ (Walks Hypervisor Extended Page Table / EPT)
         ▼
[Host Physical Address in Hardware RAM]

Configuring Transparent HugePages (THP)

For most general-purpose workloads, set THP to madvise to allow applications that explicitly request huge pages (such as jemalloc, Redis, or PostgreSQL) to utilize them while preventing system-wide memory fragmentation:

# Set Transparent HugePages to madvise in the guest
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/defrag

Allocating Static 2MB HugePages for Heavy Databases

For dedicated PostgreSQL or Oracle instances, pre-allocate explicit 2MB HugePages directly in the guest kernel:

# Pre-allocate 16,384 2MB HugePages (32 GB total)
echo 16384 | sudo tee /proc/sys/vm/nr_hugepages

# Verify available hugepages
grep -i hugepage /proc/meminfo

Swappiness Tuning

A guest VM configured with the default Linux swappiness (vm.swappiness=60) will aggressively page anonymous memory to disk even when abundant page cache exists. For production servers, reduce swappiness to prevent unexpected latency spikes:

# Add to /etc/sysctl.d/99-performance.conf
vm.swappiness = 10
vm.dirty_background_ratio = 5
vm.dirty_ratio = 10

5. Storage I/O Optimization: Virtio-SCSI, Queues, and Latency

Virtual disk performance is governed by queue depth, block size, and driver emulation overhead.

Guest Block I/O Path
--------------------
[Guest Application (Write IOPS)]
         │
         ▼
[Guest Filesystem (ext4 / XFS)]
         │
         ▼
[Virtio-SCSI Driver with Multi-Queue (mq)]
         │ Shared Memory Ring Buffer
         ▼
[QEMU / Hypervisor vhost-user Engine]
         │
         ▼
[Physical Host NVMe Storage Controller]

1. Virtio-SCSI vs Virtio-Blk

Always prefer Virtio-SCSI over legacy IDE or Virtio-Blk for production VMs. Virtio-SCSI supports hundreds of virtual disks per controller, handles full SCSI pass-through commands, and crucially supports Multi-Queue (mq).

2. Multi-Queue virtio-scsi Configuration

Standard virtual disk drivers route all I/O interrupts through a single virtual CPU. On multi-core systems processing tens of thousands of IOPS, that single vCPU becomes completely saturated handling I/O interrupts, bottlenecking disk throughput. Multi-queue allows the virtual disk controller to dedicate an independent I/O submission/completion queue pair per vCPU core:

<!-- libvirt disk definition with multi-queue enabled -->
<disk type='file' device='disk'>
  <driver name='qemu' type='qcow2' cache='none' io='native' queues='8' discard='unmap'/>
  <source file='/var/lib/libvirt/images/db-data.qcow2'/>
  <target dev='sda' bus='scsi'/>
</disk>
  • io='native': Bypasses QEMU thread-pool emulation by leveraging Linux kernel Asynchronous I/O (io_submit). Requires cache='none'.
  • discard='unmap': Permits the guest operating system to send TRIM commands to release unused disk space back to the underlying storage pool.

3. Periodic Filesystem TRIM

To prevent virtual disk images from expanding continuously and to inform the hypervisor of deleted blocks, enable the systemd fstrim timer:

sudo systemctl enable --now fstrim.timer

6. Network Performance: Multiqueue Virtio-Net and MTU Tuning

Multi-Queue virtio-net

Similar to storage, a single-queue virtual network interface processes all packet transmission and reception interrupts on a single vCPU thread. When ingress traffic exceeds 100,000 packets per second, that vCPU core reaches 100% software interrupt (%softirq) usage, dropping packets even if other CPU cores are completely idle.

Inside the guest, inspect and adjust multi-queue network channels:

# Inspect current and maximum supported interface channels
sudo ethtool -l eth0

# Enable maximum multi-queue channels (e.g., matching the vCPU count up to 8)
sudo ethtool -L eth0 combined 8

Jumbo Frames (MTU 9000) for Inter-Host Traffic

Within private cloud datacenters or VPC networks (AWS, GCP, Azure), standard 1500-byte MTU frames incur high per-packet processing overhead. Switching to Jumbo Frames (MTU 9000) reduces packet processing counts by a factor of six for high-volume replication traffic:

# Verify MTU support and set MTU to 9000 on private interfaces
sudo ip link set dev eth1 mtu 9000

7. Guest Linux Kernel Tuning & Profiling

Consolidate high-performance sysctl parameters in /etc/sysctl.d/99-vm-performance.conf:

# Maximum open files and file handles
fs.file-max = 2097152

# Network socket buffer sizes for high-throughput WAN/LAN
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

# Connection backlog queue depths
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 8192

# Enable BBR TCP Congestion Control
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr

Apply immediately:

sudo sysctl --system

Profiling Latency Outliers with bpftrace

When investigating unexpected scheduling delays inside a VM, use eBPF to trace how long runnable threads spend waiting in the CPU runqueue before being dispatched by the hypervisor:

# Measure CPU runqueue scheduling latency inside the guest
# Requires bpftrace and Linux kernel headers
sudo bpftrace -e '
tracepoint:sched:sched_wakeup {
    @start[args->pid] = nsecs;
}
tracepoint:sched:sched_switch {
    if (@start[args->next_pid]) {
        $delta = (nsecs - @start[args->next_pid]) / 1000;
        @runqueue_latency_us = hist($delta);
        delete(@start[args->next_pid]);
    }
}'

If @runqueue_latency_us shows significant distribution above 1,000 microseconds (1ms), the guest is experiencing scheduling delay either from host CPU overcommit or cross-NUMA dispatch jitter.


8. Summary Checklist for Production VM Tuning

Follow this checklist when auditing any mission-critical virtual instance:

Optimization Layer Target Setting Validation Command
CPU Contention %steal < 1%; 1:1 vCPU-to-core mapping for tier-1 databases mpstat -P ALL 1 5
NUMA Topology vNUMA topology matches physical host socket layout; zero misses numastat, numactl --hardware
Memory Isolation Disable ballooning; configure madvise or static HugePages cat /sys/kernel/mm/transparent_hugepage/enabled
Swappiness Set vm.swappiness = 10 to protect disk I/O channels cat /proc/sys/vm/swappiness
Block I/O Virtio-SCSI multi-queue enabled; io='native', cache='none' lsblk -d -o NAME,RQ-SIZE,MIN-IO
Storage Maintenance Systemd fstrim.timer active for block deallocation systemctl is-active fstrim.timer
Network Queues Multi-queue channels matched to vCPU count; BBR congestion control ethtool -l eth0, sysctl net.ipv4.tcp_congestion_control

More Engineering Articles