Skip to content

vdo: transparent compression under the dm-thin pool - #22

Merged
aljoscha merged 5 commits into
mainfrom
vdo-compression
Aug 18, 2026
Merged

vdo: transparent compression under the dm-thin pool#22
aljoscha merged 5 commits into
mainfrom
vdo-compression

Conversation

@aljoscha

Copy link
Copy Markdown
Owner

Adds an opt-in dm-vdo compression layer beneath the dm-thin pool's data
device, closing the gap against ZFS, which compresses by default while
dm-thin stores blocks verbatim.

ember init --storage dm-thin --vdo [--vdo-logical-size <SIZE>] [--vdo-dedup]

Off by default, permanent at pool creation, confined to dm-thin. ZFS and
APFS are untouched.

Measured

Same ubuntu-dev image, VM, and fork built in three independent
installations on a 60 GiB file-backed pool:

case on disk vs plain reported ratio
dm-thin 6.7 GiB (none)
+ VDO 2.8 GiB 2.39x 2.52x
+ VDO + dedup 2.8 GiB 2.42x 2.58x

Deduplication earned 60 MiB for 256 MiB of extra RAM, because dm-thin
already shares everything between a VM and its fork through
copy-on-write and there is no duplication left below the pool to find.
That is why it is off by default.

Write throughput is not established. The build step took 62s, 79s, and
67s across the three cases, but dedup came out faster than
compression-only, which cannot be true, so that is noise on a workload
dominated by docker and mkfs.ext4.

Sizing

VDO adds a third level of over-provisioning, so --size keeps meaning
the real disk budget and --vdo-logical-size defaults to it. The default
configuration does not deliberately over-promise: compression shows up as
a smaller footprint rather than as capacity that may not materialise.

It is still a small bet even at 1:1, because VDO's metadata reserve comes
off the physical side. That sets the 32 GiB floor, where the reserve is
about a tenth of the volume and break-even is ~1.1x; at 8 GiB it would be
nearer 1.6x.

Running out of space

Exhausting VDO's physical space under a thin pool is not graceful: the
pool sees I/O errors from its data device and drops to read-only, taking
every running VM with it, and its own accounting gives no warning because
it counts logical space. So VDO is checked before the pool on every
allocating operation, refused above 95% physical, warned above 85%, and
reported by storage usage. A read-only volume is refused on every
activation, not only when allocating, since disk_device_path is what
vm start uses. None of this stops a running VM filling the pool between
commands, and the spec says so rather than implying otherwise.

Reporting

PoolUsage gains addressable, the space a pool exposes when a
compressing layer lets it exceed capacity, which keeps free() meaning
real physical headroom. VDO's own metadata goes to reserved, read from
the volume's stats message because dmsetup status reports data and
metadata blocks pre-summed. That distinction is load-bearing: charged
together the ratio reads 1.00x, charged apart it reads 2.5x.

Per-volume figures stay pre-compression, since VDO cannot attribute a
physical block to a thin volume. The CLI prints a footnote when it
detects that shape, derived from the data rather than from a flag.

Also fixed

Three pre-existing problems surfaced while rewriting storage grow:

  • A --size smaller than the current one silently truncated the backing
    file and destroyed data.
  • The raw-device branch told the operator to grow the device and re-run,
    which hit the same error again.
  • ember init only refused when the backend differed, so re-running it
    against a live pool zeroed the thin metadata superblock and destroyed
    every VM and image before the config write reported the conflict.

Metadata is now sized from what the pool can address rather than from the
disk under it. Sized from the physical figure, a pool over-provisioned
4:1 would exhaust thin metadata at a quarter of its capacity, which is
the failure over-provisioning is a bet against.

Review

Two adversarial review passes plus a verification pass over the fixes.
The verification round found two bugs in the fix pass itself: --size was
validated then discarded on a raw device without VDO, and the re-init
guard was bypassable by an unparseable config.json. Both have
regression tests.

385 unit tests. Eight integration tests are #[ignore]d and need root,
dm-vdo, and vdoformat:

sudo cargo test --test vdo -- --ignored --test-threads=1

See docs/VDO-SPEC.md.

Creating, removing, suspending, and messaging a dm device works the
same whatever target is behind it, but all of that lived in dm_thin
and dm_thin::pool. Anything else we want to stack under the pool
would have to reach into the thin-pool module for suspend, which is
exactly the cross-boundary reach our conventions warn about.

Move the target-agnostic half to crate::dm, which only ever handles
opaque table strings, and leave dm_thin owning what is actually
specific to thin-pool and thin: the table layouts, PoolStatus,
MetadataSnap, thin ids, and name derivation.

Two small improvements come along with the move. dmsetup error labels
now name the device rather than the target type, which is what you
want when reading a failure. And the target-availability probe matches
the first field of each dmsetup targets line instead of substring
matching the whole listing, so a kernel offering thin can no longer be
read as offering thin-pool.
dm-thin stores blocks verbatim, while ZFS compresses by default and
delivers a little over 2x on real ember pools. `ember init --storage
dm-thin --vdo` closes that gap by putting a dm-vdo target between the
pool's data device and its backing store. The layer is opt-in, off by
default, permanent at pool creation, and confined to dm-thin.

Sizing is the part worth being careful about, because VDO adds a third
level of over-provisioning. --size keeps meaning the real disk budget,
and --vdo-logical-size defaults to it, so the default configuration
does not deliberately over-promise: compression shows up as a smaller
footprint on the host filesystem rather than as capacity that may not
materialise. It is still a small bet even at 1:1, because VDO's own
metadata reserve comes out of the physical side, which is what sets
the 32 GiB floor: there the reserve is about a tenth of the volume and
break-even is 1.1x, where at 8 GiB it would be nearer 1.6x.

Running out of physical space under a thin pool is not graceful. The
pool sees I/O errors from its data device and drops to read-only,
taking every running VM with it, and its own accounting gives no
warning because it is counting logical space. So VDO is checked before
the pool on every allocating operation, refused above 95% physical,
warned about above 85%, and reported by storage usage. A read-only
volume is refused on every activation rather than only when
allocating, since disk_device_path is what vm start uses and a guest
booted onto one just gets EIO. None of this stops a running VM from
filling the pool between commands, and the spec says so.

Reporting needed one new field. PoolUsage::addressable carries the
space a pool exposes when a compressing layer lets it exceed capacity,
which keeps free() meaning real physical headroom and
compression_ratio() meaning bytes stored over bytes on disk.

VDO's own metadata goes to PoolUsage::reserved, read from the volume's
`stats` message rather than from `dmsetup status`, which reports data
and metadata blocks pre-summed. That distinction turned out to matter
more than expected. The reserve is several gigabytes on any volume
worth creating and barely moves with the data stored, so charging it to
`allocated` alone divides the ratio by a wide constant: measured on a
60 GiB pool holding a 6.7 GiB Ubuntu image, 2.64 GiB of data alongside
4.05 GiB of metadata reported 1.00x where the real figure is 2.5x.
Per-volume figures stay pre-compression, since VDO has no idea which
thin volume a physical block belongs to, and the CLI says so with a
footnote derived from the data rather than from a flag.

storage grow gained a second dimension. --size preserves the
logical-to-physical ratio so doubling the disk doubles the pool, and
--logical-size overrides it. The whole request is planned and
validated before anything is touched, and the resolved sizes are
recorded between the VDO reload and the pool reload: the kernel
durably adopts VDO's new sizes on resume, so a config still describing
the old ones from that moment on is a pool that cannot be activated.

Three pre-existing problems fell out of the work and are fixed here.
A --size smaller than the current one silently truncated the backing
file and destroyed data. The raw-device branch of grow told the
operator to grow the device and re-run, which would have hit the same
error again. And ember init only refused when the *backend* differed,
so re-running it against a live pool zeroed the thin metadata
superblock and destroyed every VM and image before the config write
got around to reporting the conflict.

Metadata is now sized from the space the pool can address rather than
from the disk under it. Sized from the physical figure, a pool
over-provisioned 4:1 would exhaust thin metadata at a quarter of its
capacity and drop to read-only, which is the failure over-provisioning
is a bet against and would have happened even when compression
delivered.

Deduplication is off by default. It costs RAM proportional to pool
size on a host that is also running VMs, and it would fold two effects
into the single savings figure VDO reports, making the compression
number mean something else. --vdo-dedup turns it on, and measurement
says leaving it off is right for this workload: on the same pool it
took 2.64 GiB of data down to 2.58 GiB for 256 MiB more RAM, because
dm-thin already shares everything between a VM and its fork and there
was no duplication left below the pool to find.

Also tidies the extraction this builds on: dm gains a load-and-resume
table swap, because vdo documents that its volumes should not be
suspended and suspending one under a live thin pool risks blocking on
in-flight I/O; an unused helper and two pure passthrough wrappers are
dropped; and the comments no longer describe dm-thin from inside the
target-agnostic module.

See docs/VDO-SPEC.md.
aljoscha and others added 3 commits August 18, 2026 17:03
Two user-facing messages in the dm-thin backend sent the operator the
wrong way.

The size-mismatch check at init is reachable two ways, and they have
different fixes. A raw block device is whatever size it is, so --size has
to match it. A data.img left behind by a `deinit` without --purge is a
leftover pool, and telling that case to match --size only trades this
error for `vdoformat` refusing to overwrite a volume it already holds.
The message now names the backing kind and the fix that works for it.

The "cannot address less than it did" message in `plan_grow` was missing
a line continuation, so it printed an eighteen-space gap mid-sentence.

Also drops a dangling doc line above `resolve_pool_size`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`check_physical_size` rejected a physical size past the kernel's 8192
slabs at init, but `check_growth` did not, so a grow past 16 TiB reached
`dmsetup` and came back as EINVAL. `explain_einval` reads that as the
recorded sizes disagreeing with what the volume was formatted with, which
sends the operator to inspect a config.json that is fine.

The ceiling is out of reach for any pool ember is used for, but `grow`
discovers a raw device's size rather than being told it, so a larger
device gets there without anyone naming a number.

`check_physical_ceiling` gives the ceiling one owner so both paths report
it the same way. The spec's hazard said the ceiling was left to the
kernel, so it is updated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ember storage usage` and `ember info` printed the addressable line
whenever it differed from the physical capacity, but the two figures are
rounded differently. Capacity is a whole 4 KiB VDO block, while the
addressable figure is the pool's data capacity rounded down to a whole
pool block. On a raw device whose size is not a multiple of the pool
block, a 1:1 pool therefore announced an over-provision that sat below
its own capacity.

Testing for strictly greater is simpler and is exactly the condition both
comments already claimed to express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aljoscha
aljoscha merged commit 1e684ba into main Aug 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant