If you are running XOA, not XO from sources, then we could take a look remotely using the support tunnel.
Otherwise, make sure that you have patched and rebooted each pool member.
Operators from our Vates Pro Support
If you are running XOA, not XO from sources, then we could take a look remotely using the support tunnel.
Otherwise, make sure that you have patched and rebooted each pool member.
Follow-up on the questions left open in this thread. Three things came out differently from what I said above, so corrections first, and my earlier headline numbers need revising upward.
Everything I posted before ran NVMe to NVMe, where the destination disk saturates around 250 to 300 MB/s and degrades as it fills. That capped the faster arms, arm C worst of all.
Rebuilt with RAM-backed SRs on both hosts, no storage ceiling, full four-arm matrix on one rig, 3 runs per arm, interleaved.
| arm | build | MiB/s | range | vs stock | stalls | B/seg |
|---|---|---|---|---|---|---|
| A stock | control | 71.3 | 71.2-71.4 | 1.00x | 78.3% | 1441.6 |
| B TCP_NODELAY only | +socket | 303.8 | 303.1-305.0 | 4.26x | 1.5% | 1426.6 |
| C NODELAY + pipelining | both | 526.6 | 522.8-528.5 | 7.39x | 1.1% | 1425.7 |
| D pipelining only | +pipeline | 528.3 | 523.0-531.9 | 7.41x | 16.1% | 1442.0 |

Per-arm spread is 0.3% to 1.7%.
The full stack is worth 7.4x, not 5.51x, and TCP_NODELAY alone is worth 4.26x, not 2.80x.
The two changes are substitutes, not complements:
So:

What the socket option still does after pipelining is remove the stall, 16.1% of samples down to 1.1%, for 1.14% more segments. On this rig that buys no throughput. See the RTT caveat before writing it off.
xe vdi-copy between two host-local SRs, 100 GiB, interleaved A/B/A/B. @TeddyAstie was right.
| arm | transfer | data_segs_out | B/seg | stalls |
|---|---|---|---|---|
| A control | 442.1 s | 74,746,948 | 1438.5 | 0.0% |
| A2 control | 439.4 s | 74,677,058 | 1439.9 | 0.0% |
| B NODELAY | 449.5 s | 75,205,400 | 1430.1 | 0.0% |
| B2 NODELAY | 455.5 s | 75,260,858 | 1428.7 | 0.0% |

Both controls beat both patched runs with no overlap, so this is not drift. TCP_NODELAY costs +2.7% wall clock and +0.70% segments here, and buys nothing.
Nothing, because the stall it fixes does not occur on this path:
notsent about 3.3 MB, rwnd_limited 99.7%).Where the extra packets come from, and it is not mainly the headers:
expand_copy (f.ml:2745) splits at its 2 MiB cap: 51,200 chunks.channels.ml:129), so each 16 KiB TLS record is its own 16,413 byte write, 11.33x MSS, leaving a sub-MSS remainder.Data is correct either way: the patched copy md5s identical to the source, ce647d9436b48401cd4b489c955ef0f7.
Protocol confirmed two ways, for anyone reproducing:
transfer-encoding: nbd yields [Chunked; NoProtocol] (impl.ml:1084), and the code takes List.hd.vhd-tool serve --source-format raw --source-protocol chunked --source-fd 8.I said that with 8 requests in flight there is almost always an MSS queued, so the sub-MSS condition rarely arises. Not what happens. Arm D is pipelining with the socket patch removed, and it still stalls on 16.1% of samples against 1.1% with it. Depth 8 does not keep the queue above MSS.
The conclusion survives, the reasoning does not: NODELAY after pipelining gives no throughput gain because the stall stops being the limiting factor, not because the stall goes away.
stream_raw (impl.ml:335) : export_raw_vdi.ml:56 passes "none" as the destination protocol. No headers, no replies, so no write-write-read pattern.--destination-fd becomes fd://N, then File_descr, then Channels.of_raw_fd (impl.ml:991), never touching the socket helper.http_svr.ml:624 already sets TCP_NODELAY on the listening socket and Linux passes it to accepted sockets. Verified on the 4.19 kernel these hosts run, with a listener without the option as a control returning 0.xapi_stunnel_server.ml:66-68).| path | Nagle on | TCP_NODELAY | cost |
|---|---|---|---|
| NBD 107 GiB, NVMe | 1441.7 (0.996x MSS) | 1425.2 (0.984x) | +1.16% segments |
| NBD pipelined, RAM | 1442.0 (0.996x) | 1425.7 (0.984x) | +1.14% segments |
| NBD 6-pair repeat, RAM | 1441.9 (0.996x) | 1424.6 (0.984x) | +1.20% segments |
| Chunked 100 GiB | 1438.5-1439.9 (0.993x) | 1428.7-1430.1 (0.988x) | +0.70% segments |

About 1.2% more segments, reproducible to three digits across four independent experiments. Small, and it is the real permanent cost of the option.
At 7.4x we are at about 525 MiB/s, and it is not the obvious things:
sparse_dd at mean 57%, peak 64% of one core.tapdisk is roughly 72% of all busy CPU (python3 9%, xapi 6%, stunnel 6%).That sample spans more than one leg and sums several tapdisk processes, so read it as dominance rather than a precise figure. It is where we would look next.
Both changes are worth having, they are independent, and they are not additive, so the order is a real decision.
TCP_NODELAY
nbd/client-connection.c:143) and forwards it through TLS. blktap treats failing to set it as fatal (drivers/block-nbd.c:793). xapi and stunnel both set it.vdi-copy between two host-local SRs where it gains nothing.Pipelining
nbd/lib/client.ml:78), so this is not the NBD redesign it first appears to be.expand_copy hands out slices of one shared 2 MiB buffer, so a naive pipeline silently corrupts data while reporting success.expand_copy in f.ml, a shared library with other consumers.We will follow whichever route the XAPI team prefers and are happy to do the work either way. Our own order would be the socket option first, then pipelining done properly rather than our prototype. That is a sequencing preference, not a claim that the gains compose. If you would rather go straight to pipelining and skip the socket change, our data supports that: it reaches 7.41x on its own.
One caveat that cuts in favour of the socket option, and our rig cannot measure it:
vdi-copy acceptable, or should the option be scoped to NBD? Unconditional is simpler and matches every other component. Scoping avoids a measured regression on a path that cannot benefit.expand_copy own a buffer pool? Any pipelining implementation needs it. Fixing it in f.ml fixes it for every consumer, but that is a wider blast radius than we wanted to take unilaterally.tapdisk the next real ceiling? At 7.4x the sender and the network both still have headroom and tapdisk dominates destination CPU. We have not dug into why.setsockopt where relevant, same tree and toolchain.brd ramdisk. tmpfs does not work: no O_DIRECT, so VDIs can be written onto such an SR but never migrated back off.I have no issue using Claude, but I would prefer your own conclusions/recommendations in the end and less text 
I'm doing more tests right now, ideally with RAM drives to make sure we measure the right bottleneck 
Hi!
That said, since XO 6.5 there is an opt-in encryption at rest. In your xo-server config:
[redis]
encryptCredentialDatabase = true
On the next start, every record is encrypted with AES-256-GCM (values get an enc: prefix) and index keys become HMAC-SHA256 blind indexes, so hostnames and emails are not readable either. The key is split in two halves, one in XenStore (vm-data/xo-encryption-key) and one in /var/lib/xo-server/data/xo-encryption-key.
Caveats for a source install:
Full doc: https://docs.xen-orchestra.com/credential-encryption
About the PoolAlreadyConnected part, that is a separate issue. It means two server entries resolve to the same pool, typically a slave host registered next to the master, or the pool re-added after a master change. Removing the entry with DEL xo:server:<id> is not the right fix: it drops the hash but leaves the id in xo:server_ids and in the host index. Remove the server from the UI instead. If the error persists, it is the other entry pointing to that same pool that needs to go.
@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 md5 of the 107 GiB disk both ce647d9436b48401cd4b489c955ef0f7. That mattered more than the stopwatch here, for reasons below.
No NBD redesign is needed. nbd/lib/client.ml:78 is already module Rpc = Mux.Make (NbdRpc), and that multiplexer:
get_handle)id_to_wakeup keyed by that handleoutgoing_mutex, then returns a promiseSo concurrent Client.write calls already interleave correctly. The whole request/reply machinery is there and unused. The serialisation is one fold_left in stream_nbd that 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 serialise |
| tapdisk NBD server | NBD_SERVER_NUM_REQS 8, per-client request pool |
| destination storage | fio at 2 MiB blocks: 399 MiB/s at qd=1, ~1700 MiB/s at qd=2..8, collapses at qd=16 |
Depth 8 matches tapdisk's pool. The fio sweep says deeper is not better.
This is the part worth writing down, because it is invisible from the protocol level and it bites silently.
Vhd_format.F.expand_copy allocates 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_read overwrites 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 depth buffers in stream_nbd, one memcpy per 2 MiB block, buffer returned only once its write completes. The proper fix is a buffer pool inside expand_copy itself, but f.ml is a shared library with other consumers, so that is a wider change than I wanted for a measurement.
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:
--- 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)
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_NODELAY matters 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.
|
22,481
Posts
3,398
Reputation
|
|
390
Posts
65
Reputation
|