Migrating an offline VM disk between two local SRs is slow
-
I can't really tell, gut feeling is the sending host, but I have no numbers to confirm.
-
@Davidj-0 in my case there CPU activity is minimal. I think something is wrong with the software raid 10 setup. On an identical setup warm migration between to the raid 10 array between hosts is showing horrible iowait similar to the sr to sr transfer on the other host

-
Maybe the IO scheduler is not the right one?
-
@olivierlambert
I think I found the root cause of the slow Storage Migration performance I reported above.After tracing
sparse_dd, the issue appears to be an interaction between Nagle's algorithm and TCP delayed ACKs on the NBD connection.With
strace, I found thatsparse_ddsends the NBD request header and payload using separatewrite()calls:write(fd, <NBD header>, 28) = 28 write(fd, <data>, 2097152) = 2097152 read(fd, <NBD reply>, 16) = 16Normally this is fast, but there are also periodic 512-byte requests:
write(fd, <NBD header>, 28) = 28 write(fd, <data>, 512) = 512 ~40 ms delay read(fd, <NBD reply>, 16) = 16During these stalls,
ss -tinpshowed:ato:40 unacked:1 notsent:512This seems to produce the following sequence:
- The 28-byte NBD header is sent.
- It remains unacknowledged.
- The following 512-byte payload is queued.
- Nagle's algorithm prevents that small payload from being sent while the previous data is unacknowledged.
- The peer's delayed ACK timer expires after approximately 40 ms.
- The ACK arrives and the 512-byte payload is finally transmitted.
I also checked a packet capture. The destination iSCSI write only occurred after this delay, so the storage itself was not causing the 40 ms stall.
To verify this before modifying the package, I attached GDB to the running
sparse_ddprocess and enabledTCP_NODELAYwithsetsockopt()on its existing TCP socket.The effect was immediate: the
notsent:512stalls disappeared and migration throughput increased substantially.I then patched
ocaml/vhd-tool/src/impl.ml.The current code is:
let socket sockaddr = let family = match sockaddr with | Lwt_unix.ADDR_INET (addr, port) -> Unix.domain_of_sockaddr (Lwt_unix.ADDR_INET (addr, port)) | Lwt_unix.ADDR_UNIX _ -> Unix.PF_UNIX in Lwt_unix.socket family Unix.SOCK_STREAM 0I changed it to enable
TCP_NODELAYfor TCP sockets:--- a/ocaml/vhd-tool/src/impl.ml +++ b/ocaml/vhd-tool/src/impl.ml @@ - Lwt_unix.socket family Unix.SOCK_STREAM 0 + let sock = Lwt_unix.socket family Unix.SOCK_STREAM 0 in + ( match sockaddr with + | Lwt_unix.ADDR_INET _ -> + Lwt_unix.setsockopt sock Unix.TCP_NODELAY true + | Lwt_unix.ADDR_UNIX _ -> + () + ) ; + sockI rebuilt
vhd-toolfor XCP-ng 8.3 and tested Storage Migration again.Before the patch, I was consistently seeing only around:
30-40 MB/sAfter enabling
TCP_NODELAY, I am seeing roughly:150-300 MB/sdepending on storage activity.
For example, during one test:
eth2: ~174 MB/s eth3: ~174 MB/s lo: ~320 MB/sand there were also physical-interface peaks around 300 MB/s.
After the patch,
ssstill showsato:40, which is expected because delayed ACK is still enabled on the peer:rtt:0.059/0.017 ato:40 ... unacked:1but the important difference is that the persistent:
notsent:512is gone, so the delayed ACK timer no longer stalls the NBD payload.
I also checked the upstream
xen-apiv26.1.16 source, and the socket creation code still does not enableTCP_NODELAY.So I believe this explains the ~30-40 MB/s limitation I was seeing with
sparse_ddNBD Storage Migration.Would it make sense to enable
TCP_NODELAYfor theADDR_INETsocket invhd-toolupstream? -
Worth mentioning @Team-Storage
-
I'm not sure disabling Nagle is a good idea (even though it can improve things here). Fundamentally, we're doing bulk transfer of disk content, which Nagles tries to optimize by coalescing packets, so you're not flooding the network with small TCP packets.
The main problem here is that the progress is gated by NBD replies, which is going to be bad regardless of TCP configuration. TCP_NODELAY will workaround this problem, but with significant tradeoffs (and perhaps will perform worse in some other cases).
What should be done instead is that writes should be streamed (or pipelined) while reading replies in parralel, so that NBD reply delays doesn't bottleneck the whole transfer. But that actually requires a redesign of the whole NBD implementation which is not going to be a easy thing AFAICT. -
@TeddyAstie You're right on the important part, and I went and measured the rest.
First, the boring argument:
TCP_NODELAYis already the default everywhere else in this stack.Where Call QEMU, used for the qcow2 path nbd/client-connection.c:143,qio_channel_set_delay(..., false), unconditional on every connection, and deliberately forwarded through TLS inio/channel-tls.cblktap's own NBD client drivers/block-nbd.c:793, where failing to set it is treated as fatalxapi generally Unixext.set_tcp_nodelay, and stunnel withr:/a:/l:TCP_NODELAY=1vhd-tool is the only NBD client in the toolstack that leaves Nagle on. If it were a bad default for bulk NBD, QEMU wouldn't do it unconditionally. So this patch is less "new tuning" and more "stop being the exception".
On "bulk transfers benefit from Nagle's coalescing": correct, and it costs 1.16%.
Two-host 8.3 pool, 10G, live SXM, two RPMs from the same tree differing only by that one
setsockopt(control build, so the A/B isn't a build-environment artifact).bytes / data segment vs MSS (1448) segments / GiB Nagle on 1441.7 0.996x 744,763 TCP_NODELAY 1425.2 0.984x 753,391 Nagle really does pack 99.6% of MSS. But NODELAY still packs 98.4%, because vhd-tool's payload writes are 2 MiB and were never sub-MSS in the first place. The only thing being coalesced is the 28-byte NBD header, and it's paid for with a delayed-ACK round trip. No small-packet flood.
What Nagle is actually holding (107 GiB migration,
ss -tinpevery 200 ms):Nagle on TCP_NODELAY Samples with a sub-MSS payload stuck in the send queue 62.2% 0.6% Median bytes held 541 B 49,239 B Segments in flight while held 4.1 284.9 62% of the run sitting on a 541-byte header with 4 segments in flight, on a 0.2 ms RTT link. The 0.6% left in the patched arm have 285 segments in flight, so that's a full pipe, not a stall.
Result: transfer phase 2016.9s to 679.9s (~3x), end-to-end 2080.8s to 743.8s. Same stall signature on iSCSI and on local NVMe, so it's the protocol pattern rather than the storage.
On the redesign: I agree, and I don't think it's either/or.
The reply-gating is the real ceiling. Even patched we only reach ~210 MiB/s on a 10G link, because it's still one request at a time. Pipelining is the bigger win and it's the correct fix.
But once writes are pipelined there's almost always at least an MSS queued, so the sub-MSS condition rarely arises and
TCP_NODELAYbecomes close to a no-op. It isn't something anyone would have to unwind afterwards. 12 lines now, redesign later, no conflict.Where you might still be right: the regression case for NODELAY is many small writes with no application-level batching. vhd-tool's NBD path is header + 2 MiB so there's nothing to merge, but the Chunked path (12-byte header, no per-request reply) is a different shape, and that's what
vdi-copynegotiates between two host-local SRs. I haven't tested that one. If anyone expects a regression, that's where I'd look. -
Here is the exact patch used for the benchmarks above, so the XAPI team can pick it up directly.
Target:
xapi-project/xen-api, fileocaml/vhd-tool/src/impl.mlNote the standalone
xapi-project/vhd-toolrepo is not the right target. It has been dead since 2021-05-21 (vendored into xen-api on 2021-09-20) and has diverged:socketsits at line 778 there versus 795 in what actually ships.The patch
diff --git a/ocaml/vhd-tool/src/impl.ml b/ocaml/vhd-tool/src/impl.ml --- a/ocaml/vhd-tool/src/impl.ml +++ b/ocaml/vhd-tool/src/impl.ml @@ -800,7 +800,20 @@ let socket sockaddr = | Lwt_unix.ADDR_UNIX _ -> Unix.PF_UNIX in - Lwt_unix.socket family Unix.SOCK_STREAM 0 + let sock = Lwt_unix.socket family Unix.SOCK_STREAM 0 in + (* Disable Nagle's algorithm on TCP sockets. The stream protocols used here + (NBD and Chunked) write a small header and its payload with separate + write(2) calls, and NBD then waits for a per-request reply. Combined with + the peer's delayed ACKs this is the classic write-write-read stall: the + header sits in the send queue for up to 40ms waiting for an ACK that the + peer is itself delaying. Not applicable to Unix domain sockets. *) + ( match sockaddr with + | Lwt_unix.ADDR_INET _ -> + Lwt_unix.setsockopt sock Unix.TCP_NODELAY true + | Lwt_unix.ADDR_UNIX _ -> + () + ) ; + sock let split ~limit ~sep str = Xapi_stdext_std.Xstringext.String.split ~limit sep strWhy this spot
socketis the onlyLwt_unix.socketcall site in the whole of vhd-tool, and bothLwt_unix.connectcall sites use it:impl.ml:1030, thetcp:endpointimpl.ml:1057, thehttp/httpsdestination thatsparse_dduses for storage motion
So one change covers all of vhd-tool's outbound TCP.
Two things a reviewer will probably ask
Does it survive TLS? Yes. The option is set on the fd before
Channels.of_ssl_fdwraps it, sohttps://destinations are covered. QEMU does the same thing deliberately inio/channel-tls.c, forwardingset_delaydown to the underlying socket.Why the
ADDR_UNIXguard?unix:is a real endpoint scheme (impl.ml:764), andsetsockopt(TCP_NODELAY)on an AF_UNIX socket fails withEOPNOTSUPP. Verified on the built binary: the AF_UNIX path issues nosetsockoptat all and produces noEOPNOTSUPP.The same helper is also used for the listening socket in
serve(impl.ml:1301). That is harmless, since Linux inheritsTCP_NODELAYonto accepted sockets, so it is a small bonus rather than a bug.Build and verification
Built against XCP-ng 8.3 from
xcp-ng-rpms/xapibranch 8.3 (1502e68), sourcexen-api-26.1.16.tar.gz, inghcr.io/xcp-ng/xcp-ng-build-env:8.3. Applies cleanly on top of the 20 existing XCP-ng patches, including the qcow2hybridqcowone, which touches a different region of the same file.vhd-tool ships as a subpackage of the xapi SRPM. It has no package dependencies beyond shared libraries, and
xapi-core'sRequires: vhd-toolis unversioned, so it can be installed standalone for testing:rpm -Uvh vhd-tool-<version>.x86_64.rpm # add --oldpackage to downgrade yum downgrade vhd-tool # to roll backNo daemon restart needed, since
sparse_ddis forked per migration.Confirmed on the resulting binary:
socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 6 setsockopt(6, SOL_TCP, TCP_NODELAY, [1], 4) = 0 connect(6, {AF_INET, ...})The control build (same tree, patch removed) shows zero
TCP_NODELAYcalls, which is what makes the A/B in the previous post attributable to this change alone.I have deliberately left the DCO
Signed-off-byline off so whoever opens the PR can add their own. Original report and diagnosis credit goes to @tosh. -
Also adding @Team-XAPI-Network
-
@TeddyAstie you were right, and it's the bigger win. I prototyped the pipelining you described and measured it. Same rig, same 107 GiB disk, same direction as the earlier runs.
Arm Wall clock vs control Peak rate Stalls A : stock behaviour 2080.8 s 1.00x 63 MiB/s 62.2% B : TCP_NODELAY only 743.8 s 2.80x 210 MiB/s 0.6% C : + pipelined writes, depth 8 377.4 s 5.51x 432 MiB/s 1.6% Pipelining is worth a further 1.97x on top of the Nagle fix. Your diagnosis was correct: the per-request reply gating, not TCP, is the dominant limit.
Verified byte for byte, source and destination
md5of the 107 GiB disk bothce647d9436b48401cd4b489c955ef0f7. That mattered more than the stopwatch here, for reasons below.It's cheaper than you thought: the multiplexer already exists
No NBD redesign is needed.
nbd/lib/client.ml:78is alreadymodule Rpc = Mux.Make (NbdRpc), and that multiplexer:- assigns every request a unique handle (
get_handle) - registers a waiter in
id_to_wakeupkeyed by that handle - serialises only the send under
outgoing_mutex, then returns a promise - runs a background dispatcher thread that reads replies and wakes the matching waiter
So concurrent
Client.writecalls already interleave correctly. The whole request/reply machinery is there and unused. The serialisation is onefold_leftinstream_nbdthat awaits each write before pulling the next element.The rest of the chain was already fine too:
Layer Verdict xapi nbdproxy Unixext.proxy, raw bidirectional byte copy, never parses NBD, cannot serialisetapdisk NBD server NBD_SERVER_NUM_REQS 8, per-client request pooldestination storage fioat 2 MiB blocks: 399 MiB/s at qd=1, ~1700 MiB/s at qd=2..8, collapses at qd=16Depth 8 matches tapdisk's pool. The fio sweep says deeper is not better.
The actual trap: buffer ownership
This is the part worth writing down, because it is invisible from the protocol level and it bites silently.
Vhd_format.F.expand_copyallocates one 2 MiB buffer and hands out slices of it:let buffer = Memory.alloc twomib_bytes in ... let data = Cstruct.sub buffer 0 (this * 512) in really_read h (sector_start ** 512L) data >>= fun () -> return (Cons (`Sectors data, next))It refills that same buffer on every step. The current sequential code is safe only as a side effect of awaiting each write before pulling the next element.
Pipeline it naively and you get: launch write N, pull element N+1,
really_readoverwrites the buffer, write N puts block N+1's bytes at block N's offset. The migration completes, reports success, and the disk is corrupt. Nothing in the stack flags it.So any implementation of this needs buffer ownership solved alongside the concurrency. My prototype takes the cheap local route: a pool of
depthbuffers instream_nbd, one memcpy per 2 MiB block, buffer returned only once its write completes. The proper fix is a buffer pool insideexpand_copyitself, butf.mlis a shared library with other consumers, so that is a wider change than I wanted for a measurement.Prototype patch
Against
xapi-project/xen-api,ocaml/vhd-tool/src/impl.ml, on top of the TCP_NODELAY patch from the previous post.This is a measurement prototype, not mergeable as-is. Known gaps:
- progress reporting counts issued rather than completed work
- a failed write leaves its siblings unawaited rather than cancelled
- the per-block memcpy is a workaround for the shared buffer, not the right fix
--- a/ocaml/vhd-tool/src/impl.ml +++ b/ocaml/vhd-tool/src/impl.ml @@ stream_nbd (if not prezeroed then expand_empty s else return s) >>= fun s -> expand_copy s >>= fun s -> + (* Pipelined writes. The NBD client already multiplexes: every request gets a + unique handle and a background dispatcher matches replies back to waiters, + so several writes may be outstanding at once. Issuing them one at a time + makes every request pay a full round trip. + + Depth 8 matches NBD_SERVER_NUM_REQS in tapdisk's NBD server. Deeper just + queues. + + Buffer ownership matters here. [expand_copy] hands out slices of a single + shared 2MiB buffer that it refills on every step, so an in-flight write + cannot keep pointing at it: pulling the next element would overwrite the + bytes before they reach the wire. Each outstanding write therefore gets a + private buffer from a pool sized to the pipeline depth, returned only once + the write has completed. *) + let depth = 8 in + let twomib = 2 * 1024 * 1024 in + let free = ref (List.init depth (fun _ -> IO.alloc twomib)) in + let inflight = ref [] in + let reap () = + match !inflight with + | [] -> + return () + | l -> + Lwt.nchoose_split (List.map fst l) >>= fun (_, pending) -> + let still, done_ = + List.partition (fun (t, _) -> List.memq t pending) l + in + inflight := still ; + free := List.map snd done_ @ !free ; + return () + in + let rec drain () = + if !inflight = [] then return () else reap () >>= fun () -> drain () + in fold_left (fun (sector, work_done) x -> ( match x with - | `Sectors data -> ( - Client.write server (Int64.mul sector 512L) [data] >>= function - | Ok () -> - return Int64.(of_int (Cstruct.length data)) - | Error _e -> - fail (Failure "Got error from NBD library") - ) + | `Sectors data -> + (* Block only when the pipeline is full. *) + (if !free = [] then reap () else return ()) >>= fun () -> + let buf = List.hd !free in + free := List.tl !free ; + let len = Cstruct.length data in + let mine = Cstruct.sub buf 0 len in + Cstruct.blit data 0 mine 0 len ; + let t = + Client.write server (Int64.mul sector 512L) [mine] >>= function + | Ok () -> + return () + | Error _e -> + fail (Failure "Got error from NBD library") + in + inflight := (t, buf) :: !inflight ; + return Int64.(of_int len) | `Empty _n -> (* must be prezeroed *) assert prezeroed ; return 0L @@ (0L, 0L) s.elements >>= fun _ -> + (* Every write must land before the stream is declared complete. *) + drain () >>= fun () -> p total_work ; return (Some total_work)Caveats on the numbers
The destination SSD degrades partway through a large transfer (DRAM-less Lexar NM790, host with 3.9 GB RAM), which is why arm C starts at ~432 MiB/s and settles around 275. Arm C runs closest to that ceiling so it feels it most. On better destination storage the gap should widen, not narrow.
Also worth saying: after pipelining,
TCP_NODELAYmatters much less, since with 8 requests in flight there is almost always at least an MSS queued. It is still correct to set it, and it is what every other NBD client in the stack does, but the 2.8x from the previous post should be read as "what you get today with a 12 line change", not as something that stacks cleanly onto the 5.5x. - assigns every request a unique handle (
Hello! It looks like you're interested in this conversation, but you don't have an account yet.
Getting fed up of having to scroll through the same posts each visit? When you register for an account, you'll always come back to exactly where you were before, and choose to be notified of new replies (either via email, or push notification). You'll also be able to save bookmarks and upvote posts to show your appreciation to other community members.
With your input, this post could be even better 💗
Register Login