S3 over RDMA Object Store#
The s3 backend turns a rocm-ernic instance into an S3
server that moves object payloads over RDMA instead of over
the HTTP body. The object store, the HTTP endpoint and the
RDMA data mover all live inside the server process, so a
stock guest can run
curl http://192.168.200.1:9000/ernic/
and get a bucket listing back over the emulated NIC, with no second virtual machine, no second server instance, and no RDMA hardware anywhere in the picture.
It is the NVMe-oF backend’s argument applied one layer up.
Every other RDMA path in this project needs two endpoints to
prove anything; here one guest and one server are a complete
object fabric, which makes an object PUT and GET a
day-to-day smoke test of the memory keys and the RDMA
READ/WRITE engine in a single VM.
The protocol is the one cuObject v1.2.0 introduced and
hipObject speaks on
the client side: an ordinary HTTP/1.1 request carrying an
x-amz-rdma-token header that describes the client’s
registered buffer, and a response whose payload was placed
into that buffer by RDMA before the headers came back. The
S3 surface itself – path addressing, the XML listing
documents, the error codes, the multipart flow – follows
versitygw, which
is the reference this implementation was read against. No
part of versitygw is vendored or submoduled: the store here
is a few hundred lines of in-memory objects, because its job
is to exercise the data path rather than to be a gateway.
Starting the Server#
# 256 MiB store in bucket "ernic" on 192.168.200.1:9000
./build/rocm-ernic \
--socket /tmp/vfio-user-rocm-ernic.sock \
--backend s3 --tap ernic0
# 1 GiB, a different bucket name
./build/rocm-ernic \
--socket /tmp/vfio-user-rocm-ernic.sock \
--backend s3:size=1G,bucket=bench --tap ernic0
# Somewhere else on the guest's segment
./build/rocm-ernic \
--socket /tmp/vfio-user-rocm-ernic.sock \
--backend s3:ip=10.0.0.5,port=8080 --tap ernic0
The backend string is s3 optionally followed by : and
a comma-separated option list:
Option |
Default |
Meaning |
|---|---|---|
|
|
The single bucket the store serves. A DNS label: 3..63 characters of lowercase, digits, dot and dash, not starting or ending with dot or dash. |
|
|
Total object capacity. Accepts |
|
|
Object slots, 1..65536. Independent of |
|
|
Address the HTTP endpoint answers on. It has to be on
the guest’s |
|
|
TCP port the endpoint listens on. |
|
|
Largest single transfer, and so the largest multipart part, accepted in one request. |
A malformed option is refused before the device is presented, rather than surfacing later as a failed request:
$ ./build/rocm-ernic --backend s3:size=bogus
Error: s3 backend: bad size 'bogus'
A successful start logs the store, its endpoint and the MAC
the endpoint will answer ARP with, at info:
ionic_datapath: s3 bucket 'ernic' 268435456 bytes at \
http://192.168.200.1:9000/ mac 02:53:c0:a8:c8:01
Reaching It from the Guest#
Nothing has to be configured in the guest. Unlike the NVMe-oF controller, this endpoint answers ARP for itself, so it is simply a host on the segment:
ping -c 3 192.168.200.1
curl -s http://192.168.200.1:9000/ernic/
curl -s -X PUT --data-binary @file http://192.168.200.1:9000/ernic/obj
curl -s http://192.168.200.1:9000/ernic/obj
Those last two are the body path: with no
x-amz-rdma-token header the endpoint behaves like an
ordinary S3 server and carries the payload in the HTTP body.
That path exists so that curl is a usable debugging tool
and so the control plane can be checked independently of
RDMA. The RDMA path needs a client that registers a buffer
and mints a token; tests/s3_rdma_client.c is that
client, and is described under Testing below.
How It Works#
Like nvmeof, this is not a transport for guest RDMA
traffic – it is a peer that speaks S3 back at the guest.
It hooks into the data path that serves the guest
ionic_rdma driver (src/ionic/ionic_datapath_s3.c),
rather than into the QEMU-derived backend vtable.
The control plane, in band#
S3 needs TCP, and the guest’s TCP goes out of the emulated
ionic NIC. Rather than open a host socket and ask the guest
to route out to it – which would make the backend depend on
--tap and on the host’s network –
src/s3/s3_tcp.c terminates TCP on the emulated
wire. Guest TX frames are offered to a filter registered
with src/ionic/ionic_eth_emu.c before they go
anywhere else, and replies are injected back into the
guest’s RX queue.
What is implemented is the smallest TCP a well-behaved local peer on a lossless link needs: ARP, ICMP echo, the three-way handshake, in-order data with cumulative ACKs, MSS-sized segmentation (1460 bytes, honouring a smaller peer MSS down to 536), a 64 KiB window, one 200 ms retransmission timer per connection, and an orderly FIN. Eight connections at a time. There is no congestion control, no SACK, no out-of-order reassembly and no fragmentation, because every one of those exists to cope with a real network and there is not one between these two endpoints.
The endpoint’s MAC is derived from its address –
02:53: followed by the four address bytes, so
192.168.200.1 is 02:53:c0:a8:c8:01. It is locally
administered and stable across restarts, and because the
stack answers ARP the guest never needs to be told it.
Frames in, frames out through a callback: nothing in
src/s3/s3_tcp.c knows about vfio-user or queues, which
is why the whole stack runs under CTest against a loopback
that hands frames straight back.
The RDMA token#
A client that wants the payload moved by RDMA sends
x-amz-rdma-token: <88 hex characters>
which decodes to a 44-byte little-endian structure
(src/s3/s3_token.h):
Offset |
Size |
Field |
|---|---|---|
0 |
1 |
Transport: |
1 |
4 |
Queue-pair number. |
5 |
16 |
GID. |
21 |
4 |
|
25 |
8 |
Remote address of that buffer. |
33 |
8 |
Length of the window the server may touch. |
41 |
1 |
Port number. |
42 |
2 |
LID. |
hipObject’s formatRdmaHeaderValue() sends a three-field
form, <token-hex>:<addr-hex>:<len-hex>, where the
trailing pair overrides the address and length inside the
token and a zero in either means “no override”. Both forms
are accepted.
The response carries the outcome in x-amz-rdma-reply and
the byte count actually moved in x-amz-rdma-bytes. The
reply value has three forms in the wild: the legacy cuObject
ok/err tags, a bare decimal HTTP status, and
<code>:<token-hex> carrying the server’s own token so an
RC client can point a queue pair at the peer that just
served it. This server sends the last form; all three parse.
Moving the bytes#
On a GET the store resolves the token’s rkey and
RDMA-WRITEs the object into the client’s buffer; on a PUT
it RDMA-READs the payload out of it. Either way the transfer
is capped by the token’s length field, by maxpart=, and
by the object’s own size – a token describing a window
smaller than the object gets a short transfer and an
x-amz-rdma-bytes that says so, rather than a write past
the end of the client’s registration.
The interesting part is what is not here: no queue pair is
ever connected. One guest owns every registration in the
emulator, so an rkey arriving in a token resolves in the
same memory-region table as any local lkey, and the
target DMAs against it directly, with the same guest-memory
DMA the rest of the ionic data path uses.
The client still creates a real RC queue pair and puts its
QPN in the token, because that is what it would have to do on
hardware, but the store doesn’t check it: nothing on this
path depends on the connection existing.
An rkey of 0 is refused. To the ionic data path, key 0 is
the driver’s reserved DMA key, which names a raw guest bus
address rather than a registration, and any process in the
guest can put it in a token. A request naming it moves
nothing and gets a 500, as one naming an unknown key
does.
What the store supports#
Path-style addressing (/bucket/key) against one bucket:
GET /– list buckets.GET /bucket– list objects, withprefixandmax-keys, returning aListBucketResult.HEAD /bucket– bucket existence.PUT,GET,HEAD,DELETEon an object, with MD5ETags and rangedGET(Range: bytes=a-banswered with a 206).Multipart upload:
POST ?uploadsto initiate,PUT ?partNumber=N&uploadId=...for parts,POST ?uploadId=...to complete. The completedETagis the digest of the part digests suffixed with the part count, as clients expect.S3 XML errors throughout:
NoSuchKey,NoSuchBucket,InvalidRange,MethodNotAllowed,EntityTooLargeand friends, each with the offending resource named.
Deletes are idempotent, as S3 requires: a missing key is still a 204.
Testing#
Server-side coverage runs without a VM and is part of
ctest:
ctest --test-dir build -R '^s3-'
s3-token-unit– the token and reply wire formats, pinned against a hand-assembled reference image so the layout cannot drift.s3-http-unit– the HTTP/1.1 subset: request parsing, query strings, headers, chunked andContent-Lengthbodies, response assembly.s3-target-unit– the store: the full method set, the listing documents, ranged reads, multipart, the error paths, and the RDMA transfers against a fake set of DMA ops.s3-tcp-unit– the in-band stack end to end over a loopback: ARP, ICMP, handshake, segmentation, retransmission, FIN.s3-ci– starts the real server binary once per documented option spelling, checks the store and endpoint it reports, checks the derived MAC, and checks that malformed options are refused before the device appears.
The end-to-end RDMA transfer needs a guest, because it needs
a real ibv_reg_mr for the token to describe.
tests/s3_rdma_client.c is a libibverbs client,
built inside the guest and run there with nothing else from
the tree but the header-only src/parse_int.h. It
opens the RDMA device, registers one buffer, splits it in
half – low half the PUT source, high half the GET
destination – and mints a token per request. In functional
mode (-F) it checks:
a 4 KiB and a 1 MiB round trip, comparing byte for byte;
a ranged
GET(206 forbytes=1024-1535);a token whose window is shorter than the object, asserting that the write stopped at the window;
the body path, with no token at all;
listing and deletion, then a 404 for the deleted key;
the error paths: missing key, malformed token, wrong bucket.
It prints all checks passed and exits 0, or names what
failed and exits 1.
The Ansible play is the reusable definition, and needs a
single guest already provisioned against an instance running
the store. Run it through ci-site.yml, which first
registers the running guest in the ernic_vms group; run
alone, the play finds no hosts and does nothing:
cd ansible
ansible-playbook ci-site.yml --tags s3 \
-e ernic_project_root="$PWD/.."
It installs a toolchain and libibverbs-dev in the guest,
copies the client in, builds it, runs it, and checks the
store came back empty.
The hosted lane runs the whole thing from scratch –
build, unit tests, boot a guest, provision it, transfer –
in .github/workflows/s3-lane.yml. Like the NVMe-oF
lane it is a reusable workflow with a single publish
input, called by .github/workflows/system-tests.yml:
false on a pull request, true on a scheduled or dispatched
run against develop. The steps are the same either way, so a
green pull request and a scheduled run mean the same thing.
The endpoint’s address, port and bucket are Ansible
variables (ernic_s3_addr, ernic_s3_port,
ernic_s3_bucket in
ansible/group_vars/all.yml); they have to match the
--backend options the instance was started with.
Performance#
The scheduled run of
.github/workflows/system-tests.yml sweeps the client
across three object sizes and publishes what it measured, on
the same terms and through the same code path as the RDMA,
TCP/IP and NVMe-oF figures. The numbers land on the
trend page
as the S3-over-RDMA GET bandwidth over time chart, and the
1 MiB figure becomes the S3 1M GET shield on the README.
The sweep is GETs – the store RDMA-WRITEs into guest
memory, which is the direction worth a headline number.
Correctness in the other direction is covered by the round
trips above, which are checks rather than rates.
The headline is the largest tracked size, unlike the NVMe-oF shield’s 4 KiB. Every object transfer here carries an HTTP request and response of its own over the in-band TCP stack, so a 4 KiB figure measures that control plane rather than the data plane the badge is about.
The same caveats apply as everywhere else on that page:
The numbers come from a GitHub-hosted runner – shared vCPUs, noisy neighbours, no pinning. Treat them as a trend, not a benchmark. They stay in their own
s3series: an HTTP-framed object transfer and a perftest sweep share no scale.The store is RAM, so nothing here measures storage.
Only a green run on
developpublishes.
To take the same measurements locally, set
ernic_s3_perf_csv to a path and the play writes one
bandwidth CSV there:
cd ansible
ansible-playbook ci-site.yml --tags s3 \
-e ernic_project_root="$PWD/.." \
-e ernic_s3_perf_csv=/tmp/s3-bw.csv
The object sizes are not free-form: they have to stay equal
to TRACKED_SIZES in ci/report/publish-perf.py or
the chart quietly drops a series. Sweep only a guest running
under KVM: numbers measured under TCG are one to two orders of
magnitude off, and neither the play nor the hosted lane checks
the accelerator.
Limitations#
One bucket per server instance, held entirely in memory. Nothing survives a restart.
No authentication: no SigV4, no ACLs, no policies. The endpoint is on a private emulated segment and trusts everything on it.
No versioning, no lifecycle, no tagging, no CORS, no
DeleteObjectsbatch, no copy.Multipart parts are buffered whole and concatenated at completion, so an upload is bounded by the store’s capacity twice over.
The in-band TCP stack does not fragment, reassemble out of order, or do congestion control; it is correct for the one link it serves and would not survive a real network.