HDD vs SSD I/O Optimization: Schedulers, Mount Options, and LVM Caching
When server disk I/O won’t go any higher, don’t rush to replace hardware — software-level tuning of the scheduler, mount options, and write caching often delivers noticeable gains. But these techniques must not be mixed across hard disk drives (HDDs) and solid-state drives (SSDs): the two media have completely different bottlenecks. Cutting seek activity for HDDs and cutting pointless writes for SSDs is the medicine each one actually needs. This post walks through the main optimization points with real examples. The scheduler, TRIM, atime, and LVM volume creation were all tested in a Rocky 9 container, so you can adapt the commands and outputs directly.
Test Environment
- Rocky Linux 9.3 container (a throwaway
docker run --privileged, lvm2 2.03.33, e2fsprogs 1.46.5, sysstat 12.5.4); - No real spinning disk inside the container — block devices are loop devices standing in; command syntax matches bare metal;
- The original article was based on CentOS 7; differences between the two system generations are flagged where relevant;
hdparmwrite caching involves real ATA disks and can’t be simulated in a container, so it was not run.
First, Know What Kind of Disk You Have
The physical structure of the two media dictates completely different optimization directions:
- The HDD bottleneck is rotational latency and seek time. The head spends far more time jumping around than actually reading and writing; once random I/O piles up, throughput collapses. The optimization mindset is fewer head movements and merged writes.
- SSDs access data electronically, with no mechanical parts. They’re much faster, but they bring two new problems: writes have a limited lifespan (P/E cycles), and deleting data requires erasing a whole block before rewriting — which is where TRIM comes in.
Each example below notes which class of disk it serves.
Example 1: Give SSDs a Filesystem That Supports TRIM
TRIM is the mechanism by which the operating system tells the SSD “these blocks are dead.” The SSD uses that to reclaim invalidated blocks, slowing performance decay and extending its life. Both ext4 and XFS support TRIM; either is recommended for SSDs, and the format command has nothing special about it:
1 | # Format the SSD partition as ext4 |
The key is that reclamation actually happens. In the container, I walked the full flow on a loop device: create the disk, format it, mount it, write a 600M file, delete it, then use fstrim -v to have the filesystem hand back the dead blocks:

The output shows about 1.9 GiB reclaimed — far more than the 600M just deleted, because the metadata blocks from formatting and writing were handed back too. For daily use there are two paths: add the discard mount option (real-time TRIM on every delete), or run fstrim from a scheduled task — pick one, not both. Rocky 9 ships fstrim.timer, which runs weekly; after installing the system, confirm with systemctl status fstrim.timer that it’s enabled and you can forget about it. If you go with scheduled fstrim, do not also add discard — doing it twice buys nothing.
Example 2: Pick the I/O Scheduler by Disk Type
The scheduler decides in what order and with what concurrency I/O requests are sent to the disk. In the CentOS 7 era the rule of thumb was deadline for HDDs and noop for SSDs — but that rule is dead on Rocky 9, because the scheduler lineup changed. First, what Rocky 9 actually offers:

The square brackets around [mq-deadline] mark the currently active choice. The available list has neither noop nor cfq: cfq was removed as the kernel evolved, and noop’s semantics were folded into none. So on a new system, copying an old tutorial’s echo noop > /sys/block/sda/queue/scheduler doesn’t error out — it just doesn’t take effect. This is the easiest old-vs-new pitfall to trip on, summarized in a table:
| Command | CentOS 7 | Rocky 9, tested result |
|---|---|---|
echo noop > .../scheduler |
Works (noop is listed) | No effect; the original scheduler remains |
echo deadline > .../scheduler |
Works | No effect; deadline isn’t in the list |
echo none > .../scheduler |
Not an option | Works |
echo mq-deadline > .../scheduler |
Not an option | Works |
The right choice on Rocky 9: mq-deadline for HDDs and general workloads (it sets deadlines on requests, compressing average wait time), none for SSDs (no seek penalty, no need for request reordering, and multi-queue direct dispatch actually saves CPU). The kyber entry in the list targets low-latency NVMe scenarios; there’s usually no reason to switch to it proactively.
Changes take effect at runtime and vanish on reboot. For persistence, either path works: write a udev rule, or add elevator=mq-deadline to the kernel parameters.
1 | # Example udev rule: /etc/udev/rules.d/66-scheduler.rules |
CentOS 7 machines are unaffected — keep writing noop/deadline as in the original.
Example 3: Turn On the HDD Write Cache
The write cache takes incoming writes into the disk’s onboard cache first, then flushes them gradually — a direct boost to HDD write performance:
1 | hdparm -W 1 /dev/sda |
The price: on power loss, data sitting in the cache that hasn’t been flushed is gone. Database servers usually prefer to leave it off; ordinary file servers can turn it on with confidence.
Note: this command was not run (it requires a real ATA disk, which a container can’t simulate); usage comes from the original article and the hdparm(8) manual.
Example 4: Mount with noatime
Linux records every file’s access time (atime), which means reads are actually generating writes. Many distributions already mitigate this with relatime (atime updates only under certain conditions, such as when it’s older than mtime), but the “discounted” write still exists. Adding noatime at mount time turns it off entirely, and it applies to both HDDs and SSDs. In the container I ran both modes and observed atime with stat -c %x:

Under the default relatime, one read bumped the file’s atime from 9-12 to the current day; with noatime, the same operation left atime untouched — a read is a pure read. The fstab entry:
1 | # /etc/fstab example, adding noatime to a partition |
After the change, mount -o remount / takes effect immediately, no reboot needed. Note that a rare few applications genuinely depend on atime (some mail delivery tools clean up based on read time, for instance) — confirm you have no such dependency before shipping.
Going Further: RAID and LVM Caching
For applications with demanding read/write requirements, you can combine disks into RAID 10 to get reliability and performance together.
In a mixed environment (SSD + HDD coexisting), the cheaper option is LVM’s caching feature, using the SSD as an acceleration layer for the HDD: create the physical volumes and volume group first, then carve out the data volume and the cache volume, and finally bind the cache volume to the data volume. The first three steps were tested in the container (two loop devices standing in for the HDD and SSD, lvm2 2.03.33):

In lvs, both the data and cache logical volumes were created, with sizes matching the percentage parameters. The final binding step, lvconvert --type cache --cachevol cache vg0/data, depends on the kernel’s dm-cache module — the VM hosting the test container didn’t have it, so the binding didn’t complete, which conveniently leaves the real error for your reference:

Note: the cache binding command was not run; usage comes from the lvmcache(7) manual. Real servers (CentOS 7.5+ / Rocky 9) ship dm-cache in the kernel, so the command works as-is; if your kernel also reports dm_cache missing, first confirm with
modprobe dm_cache, then consider changing kernels.
Once binding succeeds, LVM automatically migrates hot data to the SSD layer: the HDD provides capacity, the SSD provides speed, and it’s completely transparent to the application.
After Changing Anything, Verify with iostat
Optimization isn’t done when the change lands — the parameters are for others to copy, but the gains are yours, and only a before/after comparison counts. iostat from the sysstat package is the handiest observation tool; in the container I sampled a loop device being written directly by dd:

During the write, tps approached 3000 and write throughput was about 1.5 GB/s — the numbers match the workload, confirming the observation path itself works. In practice, run the same command once before and once after the change (or track the trend with sar); the optimization counts as complete only when throughput or await genuinely improves.
Notes and Cautions
- Back up before touching disk configuration. A botched scheduler or cache setting ranges from worse performance to data loss.
- Tune optimization parameters to your own hardware and workload. Other people’s configs are only starting points.
- Runtime changes like the write cache and scheduler vanish on reboot. Confirm your persistence plan (udev rules or kernel parameters) before shipping.
- Commands don’t transfer across system generations: the CentOS 7 noop/deadline syntax does nothing on Rocky 9, and vice versa. Check the scheduler list before migrating scripts across machines.
- Keep observing after the change. Use iostat and sar for trends — don’t declare victory on vibes.
Summary
Back to the opening scenario: when I/O won’t climb, first identify whether you’re holding an HDD or an SSD. HDDs rely on mq-deadline and the write cache to reduce mechanical motion; SSDs rely on TRIM and noatime to reduce pointless writes; mixed environments use LVM caching to combine the strengths of both. All four example sets were verified with real commands and outputs on Rocky 9, and the one step that didn’t complete — the dm-cache binding — left its error intact above. Don’t forget two things after making changes: persist runtime parameters, and verify the effect with iostat. Only after squeezing the software layer dry is it time to talk about new hardware.
This article was rebuilt from the author’s CSDN blog posts published between 2020 and 2024, originally published on CSDN.