Skip to content

feat(tcmu): support multi-vCPU backends with Photon v0.9 - #458

Open
liulanzheng wants to merge 3 commits into
containerd:mainfrom
liulanzheng:codex/tcmu-multivcpu
Open

liulanzheng wants to merge 3 commits into
containerd:mainfrom
liulanzheng:codex/tcmu-multivcpu

Conversation

@liulanzheng

@liulanzheng liulanzheng commented Sep 9, 2026

Copy link
Copy Markdown
Member

What this PR does / why we need it:

A TCMU device is pinned to a single Photon vCPU today, so one core caps its IOPS however deep the queue gets. This fans each device's commands out over a shared Photon WorkPool and lets any vCPU publish completions in place, under a per-device spinlock with the uio notification coalesced by a counter. enableThread is replaced by workpoolSize (vCPUs in the shared pool, default 8); compression batch state is now request-local and the file cache works in multi-vCPU mode.

tcmu-performance.yml re-runs that comparison against the single-vCPU baseline on every push (Release, DSA/ISA-L off in both builds, 4 KiB random reads over libaio + O_DIRECT, prewarmed file cache with the network blocked, one fio job so all concurrency is iodepth, backend on 3 of the runner's 4 vCPUs) and gates the ratio at >=95% through qd32, then 2x/4x/8x. Latest run:

https://github.com/containerd/overlaybd/actions/runs/34945509588

Which issue(s) this PR fixes: none

Please check the following list:

  • Does the affected code have corresponding tests, e.g. unit test, E2E test?
  • Does this change require a documentation update?
  • Does this introduce breaking changes that would require an announcement or bumping the major version?
  • Do all new files have an appropriate license header?

@liulanzheng
liulanzheng force-pushed the codex/tcmu-multivcpu branch 30 times, most recently from 00d97b7 to 1065a3a Compare September 10, 2026 14:06
@liulanzheng
liulanzheng force-pushed the codex/tcmu-multivcpu branch 4 times, most recently from 0735c67 to 788c40b Compare September 10, 2026 17:43
@liulanzheng
liulanzheng marked this pull request as draft September 11, 2026 03:28
@liulanzheng
liulanzheng force-pushed the codex/tcmu-multivcpu branch 19 times, most recently from a6ddfc7 to 04b30d2 Compare September 14, 2026 15:06
@liulanzheng
liulanzheng marked this pull request as ready for review September 14, 2026 15:47
@liulanzheng
liulanzheng requested a review from BigVan September 14, 2026 15:47
The read-path statistics belong to the file, not to the vCPU that reads it, so
with a multi-vCPU backend every worker serving the same image updates
lsmt_io_cnt, lsmt_io_size and the allocated-block counter of the writable index
at the same time, and their plain read-modify-writes lose updates. Make all
three atomic and update them with relaxed ordering.

The writable index itself is worse than a lost counter: it is a std::set that
insert() mutates in place, and while pwrite() serialises the writers on
m_rw_mtx, the readers never took it. On a single vCPU that was safe only
because photon does not preempt and neither lookup() nor insert() yields;
across vCPUs a reader walking the tree while another vCPU erases and reinserts
a mapping resolves a hole or a foreign mapping, and one that catches a
rebalance in progress follows nodes that are no longer on its search path.

Take a photon::rwlock on every access to the index of a file: for reading in
pread() and seek_data() and around the index snapshots of commit(),
close_seal() and flatten(), for writing in index_insert(), and across the whole
of restack(), which also reshuffles m_files. m_rw_mtx still comes first and the
rwlock second, and readers never take m_rw_mtx, so the two cannot deadlock. The
write lock covers the index mutation only, not the data I/O of a write, so
appending does not stall the readers; a hybrid writer still rewrites the data
of an existing mapping in place outside of it, so a read overlapping a write of
the same LBA may return a mix of both, which is what a same-LBA read/write race
is. A read-only file has an immutable index, so m_index_mutable stays false
there and pread() skips the lock: that is the hot path of an image service, and
a lock that is only ever shared still puts every vCPU on one cache line. For
the same reason pread() delegates to do_pread(), which also lets the
MAX_IO_SIZE splitting take the lock once instead of recursively -- photon's
rwlock is not reentrant.

Also close the writers that mutated the index with no lock at all: discard()
inserted before taking m_rw_mtx, and the sparse and the warp files inserted and
appended to the index log without it.

Add two tests. multi_vcpu_io_counters reads a sealed layer from four vCPUs and
checks the counters against the number of reads. multi_vcpu_concurrent_rw maps
a whole 1MB layer, then has two vCPUs rewrite random blocks while two others
read them, requiring every block to come back as one consistent version of its
own pattern; with the index lock disabled it reads 5 corrupted blocks out of
40000 and fails.

Signed-off-by: Lanzheng Liu <liulanzheng@gmail.com>
BaseCompressor held the per-block source and destination pointers of a batch in
two member vectors, so two vCPUs compressing or decompressing through the same
compressor overwrote each other's batch. Build them in std::arrays on the
caller's stack and pass them down to do_compress() and do_decompress() instead,
which also lets the QAT path drop the resize it needed once nbatch() started
returning the real batch size.

Bound the batch at MAX_BATCH (256) so those arrays cannot be overrun, and
reject n == 0, which divided dst_buffer_capacity by zero.

Signed-off-by: Lanzheng Liu <liulanzheng@gmail.com>
Dispatch TCMU commands across a shared Photon WorkPool and let each vCPU
publish its own responses, allow the file cache in multi-vCPU mode, and add a
benchmark that compares the backend against the single-vCPU implementation on
the same runner.

Commands beyond the ones the dispatcher starts locally go onto the pool's own
task ring, so the device dispatcher never leaves its vCPU and keeps draining
its mailbox: WorkPool spreads the overflow over whichever vCPUs are free, and
its RingChannel pays for a wakeup only when a worker is parked. Each task just
spawns the handler thread and returns, because the pool runs tasks inline on
its own loop. Completions are published in place under a per-device spinlock
with the uio notification coalesced by a counter, instead of being forwarded to
the device's home vCPU one wakeup per command. libtcmu keeps the reader's
cursor (dev->cmd_tail) separate from the response cursor (mb->cmd_tail) and the
kernel matches responses by cmd_id, so completions only have to be mutually
exclusive and must never run ahead of the reader; the in-flight count is
released last because device teardown waits on it before freeing the device.

Fan-out is load adaptive: each batch starts up to 8 of its commands on the
current vCPU, limited further by that vCPU's idle handler slots, and sends only
the rest through async_call for pool-wide distribution. This keeps available
local capacity busy without holding overflow on a saturated vCPU.

enableThread, which turned on one extra thread and refused the file cache,
becomes workpoolSize: the number of vCPUs in the pool, 8 by default.

main() now releases everything through DEFER, so the image service, the work
pool, the tcmulib context and the main loop are also torn down on the error
paths, and in the reverse order of their construction: the device loops stop,
tcmulib_close() removes the devices and with them the image files and their
photon threads, the workers are joined, the image service goes away, and
photon::fini() runs last -- it waits for every photon thread of the main vCPU,
so nothing may outlive it.

The Release benchmark disables DSA and ISA-L in both builds to isolate the
TCMU implementation, and is sized to the hosted runner's 4 vCPUs: the backend's
overlaybd.json sets workpoolSize to 3, while fio runs a single job that takes
all of its concurrency from iodepth, leaving the fourth CPU to fio and the
kernel's LIO loopback path instead of oversubscribing it. It reads a range
whose allocated file extents cover at least 95% of it, and gates the IOPS ratio
against the single-vCPU baseline: at least 95% through a queue depth of 32,
then 2x at 64, 4x at 128 and 8x at 256. The comparison table is published as a
check-run annotation because job logs and artifacts require repository
authentication.

Use signed elapsed time in the TCMU and ublk read retry loops so a
backward timestamp does not underflow into a spurious seven-day timeout.

Signed-off-by: Lanzheng Liu <liulanzheng@gmail.com>
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