Concurrent kXR_readv.
For: Andy Hanushevsky
From: Matevž Tadel (with analysis by Claude, working in the xrootd tree)
Date: 2026-09-02
Depends on: Step 1 (xcache-step1-small-reads.md), which stands as written.
Step 1 makes small-block caching efficient. It does not make it fast enough to use. This document covers what is needed for that, and adds a second request to the one Step 1 made.
kXR_pgreadv: N (offset, length) pairs like kXR_readv, crc32c per 4 kB page
like kXR_pgread, because pfc.cschk net currently forces XCache into one
origin request per cache block and there is no vector form to batch them into.
That ask is unchanged, and Step 1's implementation notes still hold — it is built and
working on branch pfc-pgreadv.
Step 1's numbers came from a UCSD origin at 8 ms RTT, where per-request cost is small
enough that request count looks like an efficiency question. We have since run
the IRIS-HEP AGC ttbar analysis (ROOT RDataFrame, TTreeCache, CMS open data
NanoAODv9) against FNAL dCache at 57.8 ms RTT, with tcpdump on the cache's
outbound leg and nanosecond instrumentation inside XrdPfc.
Identical physics, identical client, same host and link:
| client-visible | ||
|---|---|---|
| ROOT direct to FNAL | 28.5 s | 12 connections, to 12 dCache pool nodes |
| through XCache, warm | 11.1 s | faster than direct — the cache is good at its job |
| through XCache, cold | 178–245 s | 6–8x slower than direct |
The cold number is the problem, and it is not what we assumed.
From the packet capture, per origin response:
| XCache -> FNAL | ROOT direct -> FNAL | |
|---|---|---|
| think time (request -> first response byte) | 56.9 ms | 56.0 ms |
| throughput while bytes are actually flowing | 91.1 MB/s | 74.2 MB/s |
| dead air (inter-arrival gaps > 5 ms) | 166.5 s | 44.5 s |
Think time is one RTT and is identical. dCache is not slow. The wire is slightly faster for the cache than for ROOT. The entire difference was idle time.
We found a real cause for part of it — the cache's pool connections idle ~2.1 s between requests, past the minimum RTO, so the origin's congestion window resets to IW10 (measured first round: 14 kB, exactly 10 x 1428 MSS) and each ~1 MB response spends ~10 RTTs ramping. Raising client concurrency warmed the connections and fixed it completely: transfer time per response fell from 336 ms to 60.8 ms, versus ROOT direct's 58.9 ms — parity.
The run got slower. 245 s instead of 229 s.
The benchmark runs 8 RDataFrame threads over 9 input files, and the cache
confirms it has them all: 9 distinct IO objects for 9 distinct files, 6-7 of
them being fetched concurrently, peak 9. There is no shortage of independent
work to overlap.
Instrumenting every remote request with wall-clock issue/completion timestamps:
MEAN remote requests in flight : 1.00 peak 8
MEAN client reads in flight : 0.99 peak 8
Exactly 1.00, across three configurations with 787 / 787 / 1347 requests at
median latencies of 393 / 286 / 168 ms. The run is n_requests x latency / 1,
which is why halving latency bought nothing at all.
The client-side counter is the telling one. In the cold run there were 787 client vector reads and 787 origin requests — 1:1, and the cache logged one login. ROOT opens a single session to the cache, all nine files ride it, and vector reads on it do not overlap. Going direct, the same ROOT process gets twelve links to twelve pool nodes and overlaps freely.
The arithmetic then closes on every measurement we have:
| client reads | per read | predicted | observed | |
|---|---|---|---|---|
| warm cache | 787 | 14.1 ms | 11.8 s | 11.1 s |
| cold cache | 787 | 282.8 ms | 222 s | 178–245 s |
| ROOT direct | 787 | ~56 ms over 12 links | ~28 s | 28.5 s |
The rule, stated plainly and confirmed by the split-process test below: a client's outstanding-request concurrency equals the number of server sessions it holds, and nothing else. Going direct to dCache, ROOT gets a session per pool node — twelve of them — and overlaps across them. An XCache is a single endpoint, so the same client gets one session and one outstanding request.
This is not a tuning problem, it is structural: interposing any single-endpoint cache in front of a distributed origin collapses exactly the parallelism the origin's own architecture was providing. Warm, this is invisible (14 ms x 787 = 11 s, still beating direct). Cold, over a WAN, it is the whole story.
If round trips are serialized, their count is the only thing that matters,
and that is precisely what pgReadV controls. On this workload, at
pfc.blocksize 4k and pfc.iosize 1m:
| origin round trips | |
|---|---|
pfc.cschk off — one kXR_readv per client vector read |
587 |
pfc.cschk net — forced to one kXR_pgread per run |
13,405 |
23x more round trips. At the measured 282 ms per serialized round trip that is
not "somewhat slower", it is roughly an hour instead of three minutes. Without
kXR_pgreadv, end-to-end checksums are not merely expensive on a WAN cache at
small block size — they are unusable. Step 1 argued this on efficiency grounds; the
WAN data turns it into a feasibility argument.
do_ReadVThe cause is in the server. Making vector reads asynchronous unconditionally would be wrong, for the reason that presumably kept them synchronous. The request is narrower: make the async path reachable, and let the layer that knows the storage decide whether to take it.
ClientReadVRequest in XProtocol.hh already carries a pathid byte:
struct ClientReadVRequest {
kXR_char streamid[2];
kXR_unt16 requestid;
kXR_char reserved[15];
kXR_char pathid; // on the wire, referenced nowhere in the tree
kXR_int32 dlen;
};
Nothing in the tree reads it -- not server, not client. Meanwhile:
| request | async mechanisms |
|---|---|
do_Read |
aio on the same link (pathID==0, XrdXrootdXeq.cc:2597-2616, return 0) and do_Offload for pathID!=0 (:2624) |
do_PgRead |
do_Offload(&do_PgRIO, pathID) (XrdXrootdXeqPgrw.cc:199), pathID from ClientPgReadReqArgs |
do_Write |
do_Offload (:3352, :3361) |
do_ReadV |
neither |
do_ReadV therefore completes synchronously on the link's thread, so one link
carries one vector read at a time. Three measurements pin it there:
MEAN 0.98, peak 1. The cache never saw two client vector
reads at once while eight were outstanding.This also explains why SubStreamsPerChannel does nothing for a readv workload
(we measured 2 -> 16 connections, no change). Substreams are parallel paths;
read, pgread and write all use them, and readv discards the field.
Note for pgReadV: because do_PgRead already honours pathID and offloads, a
do_PgReadV modelled on it inherits the right behaviour for free. The problem is
specific to plain kXR_readv.
An XCache is a guaranteed instance of this, not the only one. The condition is just one client, one server endpoint, several concurrent vector reads, and that is easy to meet with no cache in sight:
TFile — but XrdCl
keys channels on (user, host, port), so every one of those opens lands on
the same channel. Measured directly: eight XrdCl::File handles on the same
path from one process share two sockets and gain nothing from concurrency
(5.13 s); forcing them onto separate channels gives sixteen sockets and
3.82 s.The ?xrdcl.intent= workaround applies unchanged in every one of these: it is a
property of the URL, not of the cache.
Andy pointed out that a server can hand a connecting client a different username, which is enough to make XrdCl open a new socket. Traced through the code, it works, and it is better than anything client-side:
SFS_REDIRECT with the ErrInfo code as the port
and the message as the host (XrdSfsInterface.hh:115).kXR_redirect, message passed through
(XrdXrootdXeq.cc:3936).URL from it, and URL::ParseHostInfo accepts
user[:pass]@host.URL::ComputeHostId() puts user@ into pHostId, and GetChannelId() is
protocol://pHostId/ -- so the redirect lands on a new channel.So a cache can redirect each open to itself under a synthetic username and get
one channel per open, with no client change, no protocol change, and nothing
for the user to do. That is strictly better than asking analysers to rewrite
their filesets with ?xrdcl.intent=.
Note that XrdCl inherits the client's original username only when the redirect omits one, so a redirect-supplied name wins.
Tested on 2026-09-03 with a bare redirector on one port bouncing read opens to
the cache on another, and the client fileset left completely unmodified.
The static xrootd.redirect directive cannot express a username -- xred_php()
validates the target with XrdNetAddr::Set(), so u1@localhost is rejected --
so the rotation was done with a throwaway patch at the redirect send.
| logins | mean in flight | execute | |
|---|---|---|---|
| stock redirect, client's own username | 1 | 1.00 | 275.9 / 309.1 s |
| redirect rotating 8 synthetic usernames | 8 | 2.83 | 31.4 / 29.5 s |
787 remote requests either way, so the work is identical. ROOT direct to FNAL
is 28.5 s: the redirect brings a cold cache to parity with not caching at
all, and asks nothing of the client. That is a ~9x improvement from a server
change alone, and it beats both URL-level workarounds (xrdcl.intent gave
47.8 s, a distinct username per file 26-37 s).
In flight settles at 2.83 rather than 8 because per-response latency falls once requests overlap -- the connections stay warm, so the congestion window does not collapse between requests. 787 requests at ~113 ms over 2.83 concurrent is the 31 s.
The plumbing to raise this inside the cache -- rather than from a separate
redirector -- already exists, and needs no protocol change:
XrdPssFile::Open puts a full URL into the open env and returns
-EDESTADDRREQ; XrdOfs.cc:788 turns that into SFS_REDIRECT with
ecode = -1; the protocol layer sends the URL as the redirect body; XrdCl
parses user@host out of it, and URL::GetChannelId() includes the user.
XrdPssFile::Open already has Env.secEnv() in hand, so the loop guard is a
comparison -- though it has to look at tident, not name: under
sec.protocol host there is no authenticated name.
But a self-redirect to the same host:port differing only in username loops
on a stock client. XRootDMsgHandler::RetryAtServer gates its "new server"
branch on URL::GetLocation(), which is protocol://host:port/path and
deliberately omits the user. The redirect therefore looks like the same
server, the request is retried on the existing channel, the server redirects
again, and the client hits its redirect limit. Comparing GetChannelId() as
well is a one-line fix, and with a patched client the open succeeds and the
channels appear.
Avoiding that client change is possible: redirect to a second port of the
same instance (xrd.protocol xroot:<p> +port lets one process listen on two
ports, so GetLocation() differs and a stock client takes the new-server
branch). Measured with a native XrdCl client, this works well. Six distinct
paths, 36 vector reads, concurrency 6:
| logins | conns to target | wall | |
|---|---|---|---|
| no redirect | 1 | 0 | 15.18 s (1.9 MB/s) |
| redirect, 8-user pool | 7 (xc0..xc5) |
8 | 1.79 s (15.8 MB/s) |
8.5x, entirely server-side. So the mechanism is sound. Two limits found:
| client | redirect raised in | topology | result |
|---|---|---|---|
| ROOT | protocol layer | two processes | 31 s |
| ROOT | pss layer | two processes | 34 s |
| ROOT | pss layer | one instance, 2nd port | 230 s (vs 215 baseline) |
| ROOT | pss layer | one instance, loopback alias | 216 s (vs 254 baseline) |
| native XrdCl | pss layer | one instance, 2nd port | 15.18 s -> 1.79 s |
So the pss-layer hook is the right place and the mechanism is sound: two processes work with either mechanism. What fails is ROOT against a self-redirecting single instance. The client cannot be the cause on its own, since it sees byte-identical URLs in both topologies, and a native XrdCl client does benefit even in the single-instance case. That points at the one process serving both the initial and the redirected links, and it is the remaining question.
Not ROOT's redirect handling, incidentally: TNetXNGFile::Open only inspects
errRedirect on failure (storing fNewUrl for TFile to retry), so a
redirect that XrdCl follows successfully is invisible to ROOT.
So the measured 276 s -> 31 s above stands, but it was obtained by redirecting to a genuinely different endpoint. Whether the same-endpoint form can be made to work without a client change is open.
Still worth measuring before production: the extra round trip per open;
authentication running again per channel, free under sec.protocol host but
not under GSI or tokens; and monitoring, mapping and quota all seeing synthetic
usernames. A real implementation belongs in the OSS/OFS layer returning
SFS_REDIRECT, where it can vary per open and be switched off, rather than in
the protocol layer's static route table.
Andy pointed at XrdSsi, and it is the same trick, done in the right place.
XrdSsiServReal::GenURL builds the endpoint URL with a numeric synthetic
username:
if (uEnt == 0) xUsr = xAt = "";
else {snprintf(uBuff, sizeof(uBuff), "%d", uEnt);
xUsr = uBuff; xAt = "@";
}
giving root://4@host:port/path, purely so that sessions spread across
channels. XrdSsiScale manages the spread and answers the "how many?"
question that a fixed setting cannot: defSprd = 4, maxSprd = 1024, with
auto-tuning driven by per-channel pending counts (minTune, midTune,
maxTune, quadratic then linear growth).
The important part is where: SSI decorates the URL client-side, at open. That is why it works, and it is what our fileset rewrite was doing by hand. Measured in SSI's exact form against a single cache on a single port, with an unmodified ROOT:
| fileset | logins | mean in flight | execute |
|---|---|---|---|
| plain URLs | 1 | 1.00 | 284.4 s |
root://<n>@host:port/... |
6 | 1.37 | 76.1 s |
No client change of any kind: the server log shows zero redirects, and ROOT
loads /usr/lib64/libXrdCl.so.3, so none of our XrdCl work is even in the
process. Whether our RetryAtServer change is present is therefore irrelevant
on this path -- it is only reached on a redirect, and there is none.
Only six distinct users appeared because the index assignment collided across
JSON entries, which incidentally shows the gain scales with the number of
distinct channels: nine users gave 26-37 s. That is precisely what
XrdSsiScale exists to tune, and an argument for adopting its logic rather
than a fixed setting.
Our redirect experiments were trying to achieve server-side what SSI achieves client-side, which is the wrong end.
So the shape worth building is a small URL decorator, either in the
RDF/RNTuple data source or inside XrdCl so that nothing above it needs to
care -- with XrdSsiScale as the model for the tuning rather than a fixed N.
Worth recording because it constrains any server-side variant. The login name
from kXR_login is sanitised and then handed only to
Link->setID(uname, pid) (XrdXrootdXeq.cc:1100). Nothing assigns it to
XrdSecEntity::name, which is set by authentication protocols and is null
under sec.protocol host. XrdSecEntity::tident is documented as
//!< Trace identifier always preset -- it merely happens to begin with the
login name, because that is what the link ID is built from.
So an OSS/pss-level hook cannot read the client's login name through any
documented field. Our first loop guard read tident and worked by accident;
a real one has to key on something explicit, such as a CGI marker placed in
the redirect URL, which does reach the storage layer -- XrdOfs builds the
open env as XrdOucEnv Open_Env(info, 0, client) from the client's opaque
info.
An XCache in production serves O(1k) clients, each with its own session and its own outstanding vector read. In aggregate the server is not concurrency-starved, so the serialisation costs little there. It is a known limitation; what is new here is the size of it in the opposite regime.
It bites when a small number of clients each want high throughput from a cold cache. That is the interactive analysis case, and it is exactly the case small block sizes exist for. The quantity that matters here is per-client latency, not server aggregate throughput.
XrdCl keys channels on (user, host, port). A client reading N files from one
XCache gets one channel, hence one outstanding vector read. The same client
going direct to dCache has its N files spread over N pool hosts, so it gets N
channels and N-way concurrency. Interposing a single-endpoint cache removes
exactly the parallelism the distributed origin was supplying.
Full AGC cold run, FNAL dCache at 57.8 ms RTT:
| logins | in-flight | execute | |
|---|---|---|---|
| normal URLs | 1 | 1.00 | 249.6 / 205.6 s |
| distinct username per file | 9 | 2.64 | 36.7 / 36.5 / 26.4 s |
?xrdcl.intent=chanN |
10 | 1.85 | 47.8 s |
| ROOT direct | -- | -- | 28.5 s |
Physics bit-identical throughout. So a 7-9x recovery from nothing but defeating channel pooling -- the cold cache reaches parity with direct.
We assume readv was left synchronous deliberately. Throwing N concurrent vector reads at a classic xrootd data server is a bad idea, especially several on the same file, when that file lives on one spinning disk -- the seek pattern is exactly what a vectored read is supposed to avoid. That reasoning is sound and we are not asking you to discard it.
But it is a property of the storage, not of the request. For a cache in front of remote multi-point storage it does not apply at all. And the cache cannot answer it once and for all either: the same XCache serves a cold file from a WAN origin (concurrency is free and worth ~8x) and a fully-cached file from a local spinning disk (concurrency is harmful) -- sometimes within one request. So the protocol layer cannot know, and a static server config cannot know either.
Hence the shape we would find most useful, in rough order of preference:
do_ReadV an aio path like do_Read's, and let the
storage layer beneath decide whether to use it -- the OSS/cache knows whether
it is about to serve RAM, local disk, or a remote origin.as_maxperlnk / as_maxpersrv, set from
xrootd.async limit and maxtot (XrdXrootdConfig.cc:983-987).XrdXrootdAioFob is already per-file, which is the natural place to express
"one outstanding vectored read per LFN".XrdXrootdAioTask::Read(long long offs, int dlen) is single-extent, so a
vector form needs either a new XrdXrootdAioTask subclass or aggregation of
per-element completions. This looks like the real work, and is presumably part
of why it was never done.The client-side workaround makes this non-urgent for us. It is raised because a 7-9x factor sits behind an unused protocol field, and because rewriting filesets is not something every analyser can be asked to do.
Dmytro Kovalskyi's uCache (github.com/drkovalskyi/xrd-ucache,
README references arXiv:2609.00400) is a per-user read cache for ROOT data over
XRootD, and it is reported to have essentially no cold-pass penalty -- its README
puts it as "the first cold run has minimal overhead, so there's little to lose in
trying it."
The reason is structural: uCache is an XrdCl client plugin
(libXrdClUCache.so), so it sits above the protocol layer. The client keeps
its own direct connections to the origin -- a channel per dCache pool host, and
therefore its full N-way concurrency -- while the cache is local disk beneath it.
No kXR_readv ever crosses an interposed server link, so the serialisation
described above simply cannot arise.
That is the point we want to make: the penalty is not inherent to caching, it is inherent to interposing a single-endpoint server in front of a distributed origin. Same workload, same origin, same client library; put the cache in the client and the concurrency survives, put it behind an xrootd endpoint and it collapses to one outstanding vector read.
We are not proposing XCache become a client plugin -- a site-level shared cache
serving many users is a different and necessary thing, and it is what XCache is
for. But it does mean the cost is a property of our deployment model rather than
of caching, and that is precisely what an async path for do_ReadV would buy
back.
(We have not read uCache's internals, so we make no claim about its chunk sizes, readv handling or fetch concurrency -- only about where it sits in the stack, which is what matters here.)
BlockRun — separating "unit of cache state" (Block) from "unit of transfer"
(BlockRun), so a client vector read becomes one origin request instead of
thousands. Built and measured; it is what makes the 587 number possible.pfc.iosize default raised to 1 MB, pfc.iogap for coalescing across gaps.NETTIME / CLIREAD instrumentation above, which we would like to keep in
some form; it made a problem we had misdiagnosed three times immediately
obvious.Diffs against upstream master, on github.com/osschar/xrootd:
xrdcl-channels-per-host — the ChannelsPerHost prototype. Works,
but it is the wrong answer for deployment: it only helps clients rebuilt
against this XrdCl, and it changes the size of XrdCl::URL, so it breaks ABI.
Published because it demonstrates the mechanism, not as a proposal.pfc-optimize-for-small-blocks — the cache side, including the
NETTIME/CLIREAD instrumentation these measurements came from.Everything above is measured on black -> FNAL dCache (57.8 ms RTT) on
2026-09-01, with tcpdump on the cache's outbound leg and wall-clock
instrumentation inside XrdPfc. Capture, analysis scripts and raw logs are kept
alongside this note and can be shared.