[PATCH v2] nvme-tcp: pin io_cpu to submitter cpu

Sagi Grimberg sagi at grimberg.me
Sat Aug 22 14:20:21 PDT 2026


Hey Saravanan,

On 20/08/2026 11:36, Saravanan D wrote:
> nvme_tcp_set_queue_io_cpu() picks each queue's io_cpu at connect time,
> before any I/O exists, as the least loaded CPU in the queue's blk-mq
> map group, and all socket work then runs there for the connection's
> lifetime.

That is true. It effectively takes a sane heuristics given that there are
multiple factors it is not aware of.

>   On hosts that partition CPUs between pinned workloads a map
> group can straddle a partition boundary, so the pick can land one
> workload's socket processing on CPUs owned by another. On a 384 cpu
> multi tenant host with one VM driving ~1.1 GB/s of writes, 9% of
> nvme_tcp_io_work executions ran outside the submitting VM's cpuset,
> all on io_cpus of boundary straddling map groups, observed by the
> neighbor as steal time it did not cause.

Can you share a concrete example?
What is the output of:
1. lscpu
2. grep -r ''  /sys/block/nvmeXnY/mq/*/cpu_list

Also, what would be the VMs cpusets on such a server?

In an ideal setting, the nvme controller would support $NR_CPUS worth of
io queues, and both nvme_tcp_set_queue_io_cpu() and blk_mq_map_queues() 
would
map cpu-queue with perfect alignment.

FWIW, I don't think this is an nvme-tcp specific problem, other than the 
fact that TCP
is more CPU consuming than other transports (doing stuff that is not 
offloaded by
the HW...)

>
> Adopt the submitting CPU as io_cpu for every command except the
> fabrics Connect. The submitter is a member of the map group by
> construction, and the nvme_tcp_cpu_queues accounting moves with each
> adoption. Connect is the only command on an I/O queue that does not
> represent the data path, since it is injected on an arbitrary CPU by
> blk_mq_alloc_request_hctx(), so it is skipped and the first real read
> or write decides. User passthrough is submitted from a real task on
> the submitting CPU and adopts like any other command.
>
> Queues outlive the workloads that submit through them, so adoption
> re-arms after 30 seconds of queue quiet. An idle queue is reclaimed by
> its next submitter, while a busy queue keeps a stable io_cpu and
> cannot ping pong between two live submitters. Concurrent writers on
> different CPUs serialize on a cmpxchg on io_cpu.
>
> The behavior is opt in per controller via the io_cpu_adopt fabrics
> option at connect time. wq_unbound takes precedence when set.

I think that for controllers with low queue count this approach would not
work very well. Plus the cpu<->queue mapping by the block layer is not
guaranteed to align with how workloads/VMs are scheduled on the host...

To me the problem statement sounds to me like you are looking to get a 
"private"
controller (set of IO queues) for each VM. Wouldn't a better approach 
would be to pass
a "private controller" connect-param which would for TCP mean that the 
driver would
create a dedicated UNBOUND workqueue which allows setting its cpumask?

Something like: nvme connect -t tcp -a <traddr> --queues_scope=private ?
Then you'd set its IO queues to something like:
echo [cpumask] > /sys/devices/virtual/workqueue/nvmeX-unb-wq/cpumask

Even explicit cpu-groups would be a better approach IMO. This would make
nvme-tcp to override blk_mq_map_queues() and adhere to the passed groups and
try to divide queues between them.

Something like the below example:
nvme connect -t tcp -a <traddr> --cpu-groups=0-63,64-127,128-191,192-255

In this case, nvme-tcp would try to both assign queue->io_cpu within these
ranges, and also assign the cpu<->mapping according to these groups.



More information about the Linux-nvme mailing list