Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Velo

Velo is a distributed messaging library for Rust. It gives active messages, typed streams, distributed events, large-payload transfer, and work queues over pluggable transports. It has peer discovery and Prometheus metrics built in.

Velo is experimental. The APIs are not stable yet. Do not use Velo in production.

What a Velo instance holds

A Velo instance wraps three managers behind one API:

  • Messenger: active messages with four patterns (fire-and-forget, sync, unary, typed unary) and named handlers.
  • AnchorManager: typed streams. One producer attaches to one anchor and sends items to the consumer that owns the anchor.
  • RendezvousManager: large payloads. The owner stages the bytes, and a peer pulls them by handle. Over UCX, the pull is a one-sided RDMA read.

You inject transports, discovery, and metrics when you build the instance.

graph TD
    App[Application] --> V[Velo]
    V --> M[Messenger]
    V --> A[AnchorManager]
    V --> R[RendezvousManager]
    M --> T["Transports<br>TCP, UDS, QUIC, NATS, gRPC, ZMQ, UCX"]
    A --> FT["Frame transport or<br>batched streaming (mux)"]
    R --> M
    R --> RDMA["RDMA GET (UCX)"]
    M --> D["Discovery<br>filesystem, NATS, etcd"]
    V --> O["VeloMetrics<br>(Prometheus)"]

Crates

CrateForContents
veloApplication authorsThe runtime: messaging, streaming, rendezvous, events, queues, discovery, all in-tree transports, metrics
velo-extAuthors of out-of-tree pluginsThe stable trait surface: Transport, FrameTransport, PeerDiscovery, ServiceDiscovery, TransportObservability, and the value types these traits use
velo-hubAuthors of a hub serviceA hub: peer discovery, registration, and liveness for a velo application, plus hosted extensions. See The hub.

Application authors depend on velo only. See Workspace crates for why the runtime is one crate, and why velo-hub is a crate of its own.

How to read this book

  • Concepts explains each subsystem and the contract that it keeps.
  • Guides gives procedures for common tasks.
  • Operations covers metrics, saturation, benchmarks, and the measured performance.
  • Development records the workspace rules and the design decisions, with the reasons and the alternatives that were rejected.

Getting started

Add the dependency

cargo add velo
cargo add serde --features derive
cargo add tokio --features macros,rt-multi-thread
cargo add anyhow

The default features are http, nats-transport, grpc, and services. Add other features as you need them:

FeatureWhat it adds
services (default)Distributed events, work queues, and filesystem discovery. See Services and mux-only mode.
nats-transport (default)NATS messenger transport
grpc (default)gRPC messenger transport and gRPC frame transport
http (default)The axum dependency. The HTTP messenger transport is not built at this time.
zmqZeroMQ transport. The build compiles libzmq and needs cmake.
ucxUCX transport and RDMA rendezvous. Linux only. See Run rendezvous over RDMA.
quicQUIC transport (quinn, TLS 1.3)
nats-discoveryNATS peer and service discovery
etcdetcd peer and service discovery
nats-queueNATS JetStream work-queue backend
queue-messengerWork-queue backend on active messages
distributed-tracingOpenTelemetry trace context in message headers
simulationDiscrete-event simulation transport
test-helpersPrometheus snapshot helpers for tests

Platforms

velo builds on Linux and macOS. On macOS every feature builds except ucx. There the ucx feature compiles but adds no transport, and the ucx-rs crate builds as an empty stub, so cargo build --workspace --all-features still works. Linux sockets also set TCP_USER_TIMEOUT, which macOS does not have.

TCP is always available. UDS is always available on Unix. Filesystem discovery needs services. Discovery and queue backend features enable services too.

nats-transport, nats-discovery, and nats-queue are independent. They share the async-nats dependency, but one does not enable the others.

A first program

This program connects two instances over TCP and makes one typed request.

use std::sync::Arc;
use serde::{Deserialize, Serialize};
use velo::transports::tcp::TcpTransportBuilder;
use velo::{Handler, TypedContext, Velo};

#[derive(Serialize, Deserialize)]
struct AddRequest { a: i64, b: i64 }

#[derive(Serialize, Deserialize)]
struct AddResponse { sum: i64 }

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let node_a = Velo::builder()
        .add_transport(Arc::new(TcpTransportBuilder::new().build()?))
        .build()
        .await?;
    let node_b = Velo::builder()
        .add_transport(Arc::new(TcpTransportBuilder::new().build()?))
        .build()
        .await?;

    // Register a handler on node B.
    let handler = Handler::typed_unary_async("add", |ctx: TypedContext<AddRequest>| async move {
        Ok(AddResponse { sum: ctx.input.a + ctx.input.b })
    })
    .build();
    node_b.register_handler(handler)?;

    // Without a discovery backend, register the peer by hand.
    node_a.register_peer(node_b.peer_info())?;

    let resp: AddResponse = node_a
        .typed_unary::<AddResponse>("add")?
        .payload(&AddRequest { a: 3, b: 4 })?
        .instance(node_b.instance_id())
        .send()
        .await?;
    assert_eq!(resp.sum, 7);
    Ok(())
}

Next steps

  • Read Messaging for the four request patterns and the handler options.
  • Read Streaming to move a sequence of items between workers.
  • Read Run the examples to run the examples in the repository.

Messaging

The messenger sends active messages. An active message names a handler on a remote instance. That handler runs when the message arrives.

Four patterns

The patterns differ in what the caller waits for.

PatternBuilderThe caller waits forReturns
Fire-and-forgetam_sendThe transport to take the message()
Syncam_syncThe remote handler to finish (ack or nack)() or the handler error
UnaryunaryThe response bytes from the remote handlerBytes
Typed unarytyped_unary::<T>The response from the remote handler, deserializedT

All four builders work the same way. Get a builder from the Velo instance, attach a payload, name the destination instance, then call .send().await.

// Fire-and-forget. Delivery is best effort.
node_a.am_send("notify")?.payload(&event)?.instance(b).send().await?;

// Sync. Returns when the handler finishes.
node_a.am_sync("process")?.payload(&job)?.instance(b).send().await?;

// Unary with raw bytes.
let reply: Bytes = node_a.unary("ping")?.raw_payload(Bytes::new()).instance(b).send().await?;

// Typed unary. Payloads are JSON (serde_json).
let reply: MyResponse = node_a.typed_unary::<MyResponse>("rpc")?.payload(&req)?.instance(b).send().await?;

A payload that is larger than the large-payload threshold does not travel in the message. The messenger stages it with rendezvous and sends a handle. The receiver pulls the bytes before the handler runs.

Timeouts

velo puts no deadline on a request. A sync or unary call waits until the remote answers or the send fails. To bound the wait, set .timeout(d) on an am_sync, unary, or typed_unary builder.

use std::time::Duration;
use velo::RequestTimeout;

match node_a.unary("ping")?.timeout(Duration::from_secs(2)).instance(b).send().await {
    Ok(reply) => { /* the answer */ }
    Err(e) if e.is::<RequestTimeout>() => { /* no answer in time */ }
    Err(e) => return Err(e),
}

The clock starts at send(). It covers the wait for a response slot, discovery, the handshake, the send, and the answer. When the time runs out, the call fails with RequestTimeout. Every other error is a failed send or an error from the remote. Only awaiting the result is bounded, not admitted().

At expiry, the call gives back its response slot, also when the caller keeps the result. A reply that arrives later is discarded. The remote can still run the handler. A call without .timeout makes no timer.

When the request's payload is staged, the timeout also bounds the receiver's fetch of it. At expiry the receiver gives up the fetch and its response slot, the handler does not run, and the sender frees the payload. See Transparent staging.

A remote that forgot the caller never answers. The remote sends a reply through its own peer table, not back over the connection of the request. So a call to a remote that called unregister_peer on the caller waits until its timeout, and without a timeout it waits for ever.

Handlers

You register a handler by name. Each handler kind has a sync form and an async form.

use velo::{Context, Handler, TypedContext};

// Sync unary handler that returns raw bytes.
node.register_handler(Handler::unary_handler("ping", |_ctx: Context| Ok(Some(Bytes::from("pong")))).build())?;

// Async typed handler. The input is deserialized and the output is serialized.
node.register_handler(
    Handler::typed_unary_async("add", |ctx: TypedContext<AddRequest>| async move {
        Ok(AddResponse { sum: ctx.input.a + ctx.input.b })
    })
    .build(),
)?;

// Async fire-and-forget handler.
node.register_handler(Handler::am_handler_async("notify", |ctx: Context| async move { Ok(()) }).build())?;

The handler context holds the payload, the message headers, and the messenger (ctx.msg). A handler can use the messenger to send messages, register handlers, or wait for events. ctx.sender_worker_id() is always available. ctx.sender_instance_id() returns the InstanceId of the sender after its handshake is complete.

Handler names that start with _ are reserved for system handlers.

Dispatch modes

ModeBehavior
.spawn() (default)One task for each message. Two messages from one peer can run in either order.
.ordered()One lane for each sending instance. A lane handles its messages in arrival order. Different senders run in parallel.
.ordered_global()One lane for all senders. This gives total order and no parallelism.
.ordered_with(OrderedConfig)Ordered dispatch with an explicit configuration

.max_concurrent(n) limits how many lanes run the handler at the same time. A lane takes the permit for each message inside the lane, so the limit does not change the order in a lane. The builder rejects 0 because it would stop every lane.

let h = Handler::typed_unary_async("bulk", bulk)
    .ordered_with(
        OrderedConfig::by_sender()
            .with_idle_lane_ttl(Some(Duration::from_secs(300)))
            .with_max_queue_depth(Some(100_000))
            .with_overflow(OverflowPolicy::Reject),
    )
    .build();

Ordered dispatch has these limits:

  • It keeps order. It does not create order. If a peer uses more than one transport, or a connection drops and reconnects, the arrival order was already lost before the messenger saw the messages.
  • Large payloads are not ordered. A rendezvous-staged message resolves before dispatch. The dispatcher logs a warning once when it sees one.
  • Lane queues have no hard bound. max_queue_depth is a soft limit for each lane. It drives OverflowPolicy. It does not bound the channel. One slow peer cannot cause the messages of other peers to be shed.
  • A unary handler serves one request at a time for each sender. A client that makes 100 concurrent calls gets them one after the other.
  • Idle lanes stop. A lane with no messages for idle_lane_ttl (30 s by default) stops, so short-lived peers do not leak tasks.

Watch velo_messenger_ordered_lane_depth and velo_messenger_ordered_lane_wait_seconds to find a lane that falls behind. Under OverflowPolicy::Reject, velo_messenger_dispatch_failures_total{reason="ordered_lane_shed"} counts the shed messages.

Events

The event APIs require the default services feature. Messenger ACK/NACK messages are part of core and do not require it.

Velo has a generational event system to coordinate async tasks. An event has a compact u128 handle. You can share the handle between threads, or send it to another instance.

Local events

use velo::EventManager;

let manager = EventManager::local();
let event = manager.new_event()?;
let awaiter = manager.awaiter(event.handle())?;

// trigger() consumes the event, so an event cannot complete twice.
event.trigger()?;
awaiter.await?;

If you drop an Event without trigger() or poison(), the drop poisons it. A waiter never waits for an event that nobody can complete.

merge_events makes an AND gate. The merged event completes after all its inputs complete.

let weights = manager.new_event()?;
let tokenizer = manager.new_event()?;
let ready = manager.merge_events(vec![weights.handle(), tokenizer.handle()])?;
weights.trigger()?;
tokenizer.trigger()?;
manager.awaiter(ready)?.await?;

When an input is poisoned, the poison reason goes to all its waiters, and to every merged event that uses the input.

Distributed events

In a Velo instance, the event manager is distributed. The handle encodes the instance that owns the event. When you wait on a remote handle, Velo subscribes to the owner with active messages.

// Node A creates the event and sends the handle to node B.
let event = node_a.event_manager().new_event()?;
let handle = event.handle();

// Node B waits on the remote event.
let awaiter = node_b.event_manager().awaiter(handle)?;

// Node A triggers the event, and the waiter on node B wakes.
event.trigger()?;
awaiter.await?;

A remote wait looks in three places, in this order:

  1. A cache of completed events. A wait on an event that is already complete returns immediately, with no network round trip.
  2. An existing local subscription to the same event.
  3. A new subscription to the owner, over the network.

Streaming

A stream moves a sequence of typed items from a producer to a consumer. The consumer owns a StreamAnchor<T>. A producer attaches to the anchor and gets a StreamSender<T>. The anchor has a compact u128 handle, so the consumer can send the handle to a producer on a different worker.

The handle is all that a peer needs to attach to the anchor, cancel it, or open its slot on the mux, and velo does not authenticate peers. So the local ID in the handle is random: 63 bits, and never 0. With a counter, any host that could reach a node could attach to the next handle and take over the stream of another request. Send the handle only to the producer that needs it.

Single producer (SPSC)

A default anchor accepts one producer at a time.

use futures::StreamExt;
use velo::StreamFrame;

// Consumer: create an anchor and send its handle to the producer.
let mut anchor = node_b.create_anchor::<String>();
let handle = anchor.handle();

// Producer, on the same node or another node.
let sender = node_a.attach_anchor::<String>(handle).await?;
sender.send("hello".into()).await?;
sender.finalize()?;

// Consumer.
while let Some(frame) = anchor.next().await {
    match frame? {
        StreamFrame::Item(s) => println!("{s}"),
        StreamFrame::Finalized => break,
        _ => {}
    }
}
StreamFrame<T> variantMeaning
Item(T)A data item
FinalizedThe producer finished normally
DetachedThe producer detached and did not finalize
DroppedThe producer was dropped
SenderError(String)The producer could not serialize an item
TransportError(String)The network failed during delivery

The consumer can request graceful stop with anchor.controller().request_stop(). The producer observes sender.stop_token() and can send its final output, then call finalize(). Stop is idempotent and does not discard buffered output.

The consumer can cancel the stream with anchor.cancel() or a cloned StreamController. Cancel triggers both producer tokens and ends delivery. Dropping the consumer also cancels. Stop and cancel made before attach or ticket open are retained; the producer observes them when it opens, even if it sends no data. Transport failure remains a stream error.

Know when the output has left

finalize returns before the stream's output leaves the producer's node. Under the messenger mux, records wait on the producer for the consumer's credit, and a slow consumer can hold them there for as long as it does not read. A producer that shuts its node down in that time cuts the stream short.

sender.retired() returns a future. The future resolves once velo on the producer's node has handed on all of the stream's output; the table below says what that means on each path. It resolves after finalize, detach, or drop. Get the future before any of the three, because each one consumes the sender.

let retired = sender.retired();
sender.finalize()?;
match retired.await {
    Retirement::Sent => {}      // Handed on; see the table and the staged-item exception below.
    Retirement::Discarded => {} // Output was thrown away before it left this node.
    _ => {}                     // `Retirement` is not exhaustive.
}

Retirement::Sent means that velo handed the terminal (Finalized, Detached, or Dropped) on behind every record before it. It is a statement about the producer's node, not about the consumer: it does not say that the consumer received anything, and what "handed on" means depends on the path of the stream:

PathSent means
Messenger muxThe transport admitted the batch that carries the terminal: it is in the connection writer's queue, or on its way there. At graceful shutdown, TCP and UDS write the frames that they admitted, until their teardown grace has passed (1 second unless the builder set it); QUIC fails the frames that are still queued (known limitation); what another messenger transport does with them at teardown is its own (see Shutdown). One exception: an item larger than the eager budget is staged on the producer until the consumer pulls it, so Sent can resolve while the producer still holds it (known limitation).
Same workerThe terminal is in the anchor's channel.
Per-stream transport (TCP, gRPC)The stream's own pump wrote the terminal to the socket (TCP), or the server took the whole request stream (gRPC). Discarded if the write failed, the connection ended first, or the gRPC server did not take the whole stream, including after 20 seconds without an answer from it. On both, an item whose encoded frame is larger than 16 MiB (MAX_ITEM_BYTES, the TCP streaming codec's frame limit) fails at send with SendError::ItemTooLarge and nothing leaves the producer; the gRPC server decodes with the same limit, so a frame the producer lets through is never one the server refuses. The same holds for an MpscStreamSender on these transports. A gRPC server from before velo 0.21 ends the call cleanly even when its consumer left, so against one a producer reads Sent for frames nobody took (known limitation). This holds when the Velo builder installed the transport; a frame transport from outside velo, or an AnchorManager built by hand, reports as the terminal enters the egress channel (known limitation).

Retirement::Discarded means that the output was thrown away before velo handed it on. Over the messenger mux: the consumer cancelled or dropped its anchor, the consumer's process went away, the transport refused the batch, or the producer's node shut down while it held the output. On the same worker: the consumer dropped its anchor, so the terminal had no channel to enter; a cancel through a StreamController while the anchor lives leaves the terminal in the anchor's channel, which is Sent. On a per-stream transport: the pump's write failed, the connection ended first, or the gRPC server did not take the stream; a per-stream TCP pump cannot see a consumer that left before it writes (known limitation). A task or batch that dies with the terminal also gives Discarded, so the future does not wait forever.

The future shares the state that the sender already keeps for the stream. A sender can hand out any number of them, and each one wakes. A stream that nobody asks about pays one compare-exchange when it retires. Batched streaming shows how the mux reports it, and what it costs.

Byte items

Some streams carry items that the application has already serialized, such as response chunks. For these, use Bytes as the item type, send with send_bytes, and read with next_bytes. Inside a hand-written Stream, use poll_next_bytes.

use bytes::Bytes;
use velo::StreamFrame;

// Producer.
let sender = node_a.open_anchor_stream::<Bytes>(handle, ticket).await?;
sender.send_bytes(&chunk).await?;
sender.finalize()?;

// Consumer of `node_b.create_anchor::<Bytes>()`.
while let Some(frame) = anchor.next_bytes().await {
    match frame? {
        StreamFrame::Item(chunk) => write(chunk),
        StreamFrame::Finalized => break,
        _ => {}
    }
}

send_bytes writes the frame by hand, and the frame is the same as the one send writes for a Bytes item. So each end can use either form. next_bytes handles every frame other than an item in the same code as next, so heartbeats, errors, Detached and the end of the stream behave the same.

The difference is the copies of each payload. Over the mux:

FormProducerConsumer
send and nextserde writes the frame into a buffer that grows as it writes (1 copy), then the batcher copies it into the batch (1 copy)the ingress copies the record out of the batch (1 copy), then the Deserialize of Bytes copies the payload into a new buffer (1 copy)
send_bytes and next_bytesone allocation of the exact size (1 copy), then the batch (1 copy)the ingress copy (1 copy); the item is a slice of that buffer, with no allocation

These counts are from the source, not measured. On the same worker, there is no batch and no ingress, so send_bytes and next_bytes make one copy in total.

A kept item keeps its frame buffer in memory. The frame is the item plus at most 11 bytes.

Write an item in place

send_bytes copies a payload that the application encoded into its own buffer. To skip that copy, encode straight into the frame with ByteItemBuilder, and send the result with send_frame. The builder needs no sender, so the encode can run before the producer holds one.

use velo::streaming::ByteItemBuilder;

let mut item = ByteItemBuilder::with_capacity(size_hint);
rmp_serde::encode::write(item.buffer(), &token)?;
sender.send_frame(item.finish()?).await?;

buffer is a Vec<u8> that already holds the 11-byte item header. Append the payload to it. finish writes the payload's length into the header, and refuses a frame whose header was changed. send_frame has the rules of send_bytes for a cancel, a full channel, and the item limit.

A payload that fits the capacity costs one allocation for the whole frame, and no copy before the batch. A larger payload grows the buffer as a Vec grows.

The frame decodes as the send_bytes frame does, through next, next_bytes, and a typed consumer. The bytes differ in one way: the length is always a 5-byte bin32, because it is written after the payload. So an item under 64 KiB is up to 3 bytes longer on the wire. On the same worker, a frame with spare capacity costs the consumer one small allocation when it takes the buffer as Bytes. Over the mux, the batch copy removes that cost.

Why separate methods: next cannot skip serde for Bytes alone. Rust has no specialization, and a type check at run time needs T: 'static on the Stream impl of StreamAnchor, which is a breaking change. A marker item type would still go through serde on the producer. Why the mux still copies each record out of its batch is in Batched streaming design.

Many producers (MPSC)

create_mpsc_anchor makes an anchor that accepts many producers. Each producer gets a SenderId when it attaches, and each frame carries the SenderId of its producer. Detached and Dropped from one producer do not end the stream. The consumer ends the stream: it cancels, drops the anchor, or lets its unattached timeout (MpscAnchorConfig::unattached_timeout, none by default) fire after all producers leave. A producer cannot finalize an MPSC anchor.

How frames travel

Frames travel on one of two paths:

  • A frame transport. The builder sets it with stream_config. StreamConfig::Tcp is the default. StreamConfig::Grpc needs the grpc feature. Each stream sends its own frames. This path carries a stream when the peer does not offer the mux, or when the mux is off.
  • Batched streaming (the mux). The builder installs it by default. messenger_mux(MuxConfig) changes its settings or turns it off. Records from many streams to the same peer share one messenger frame, with credit-based flow control for each stream. See Batched streaming.
let node = Velo::builder()
    .add_transport(tcp)
    .stream_config(StreamConfig::Tcp(None))? // TCP streams on a port that the OS assigns
    .build()
    .await?;

Zero-RTT setup

attach_anchor makes one _anchor_attach round trip before the first item can move. Sometimes the consumer knows which worker will produce. For example, the consumer is about to send that worker a request. Then the consumer can make a ticket and put it in its own request, and the worker opens the stream with no round trip.

// Consumer: make a ticket.
let mut anchor = node_b.create_anchor::<String>();
let handle = anchor.handle();
let Some(ticket) = node_b.prebind_anchor(handle) else {
    // No ticket. Attach the usual way.
    let sender = node_a.attach_anchor::<String>(handle).await?;
    /* ... */
    return Ok(());
};

// The ticket travels inside the request of the application.

// Worker: open the stream on the ticket.
let sender = node_a.open_anchor_stream::<String>(handle, ticket).await?;
sender.send("hello".into()).await?;
sender.finalize()?;

prebind_anchor returns None when it has nothing to make. The caller then uses attach_anchor as usual:

  • If no mux is installed, None is the normal result, and Velo records nothing.
  • For all other causes, Velo logs at debug level and counts outcome="error" under velo_streaming_anchor_operations_total{operation="prebind"}. These causes are an MPSC anchor, the handle of another node, an anchor that is already attached or pre-bound, and an anchor that was removed. Call prebind_anchor only on SPSC anchors that this node created.

Call prebind_anchor from a runtime context, because it starts tasks. A ticket that the worker never opens does not stay forever. After the configured accept window (180 seconds by default), the bind is reclaimed and the anchor sees SenderDropped. velo_streaming_unclaimed_bind_reaped_total counts these tickets.

Ticket senders support stop_token() and cancellation_token() through messenger mux version 2. The existing slot-open exchange carries the stream session identity; no extra stream registration is needed. A peer epoch and slot generation reject stale lifecycle signals. Applications that require this lifecycle must require messenger-mux-v2; legacy attach fallback does not establish that capability.

Reservation renewal

VeloBuilder::reservation_config sets the default unattached lifetime (120 seconds) and the accept window of the messenger mux (180 seconds). The accept window applies to every mux bind: a prepared slot and an ordinary attach. The per-stream TCP transport keeps its own fixed 60-second accept window. Per-anchor AnchorConfig::unattached_timeout overrides the ordinary lifetime. The unattached default applies to SPSC anchors only. An MPSC anchor has an unattached timeout only when MpscAnchorConfig::unattached_timeout sets one, and renewal does not extend it. To make anchors wait for a sender with no limit, set unattached_timeout to Duration::MAX. An AnchorManager that drops cancels the SPSC and MPSC anchors still in its registries. Unlike stop, a drop does not ask attached senders to cancel.

ReservationConfig is #[non_exhaustive], so start from the default and change its fields:

let mut reservations = ReservationConfig::default();
reservations.prebind_timeout = Duration::from_secs(600);
let velo = Velo::builder().reservation_config(reservations).build().await?;

Capture AnchorReservation::new(handle, ticket.as_ref()) when a request takes responsibility for a reply. Keep the reservation while that request waits or executes before attachment. Periodically pass held reservations to AnchorManager::renew_reservations. The call groups them by owner and sends at most 256 entries per remote batch. It does not attach a producer. The application owns the collection, renewal cadence, and call deadline. Velo does not schedule renewal. Choose a cadence shorter than the reservation lifetime.

Renewal extends only a live, unattached reservation. Attached, expired, cancelled, and replaced reservations are successful no-ops, including attachment races. Renewal cannot revive an expired stream. A transport failure returns an error. A peer that runs an older Velo has no _anchor_renew handler, so renewal to it returns an error too. Successful delivery does not prove that a reservation still exists.

Release the held reservation after downstream acceptance, attachment, cancellation, or failure. The accept window is the only limit on an unclaimed bind. A renewal loop that outlives its request holds the bind, its buffer, and its count on a mux lane until the loop stops. Renewal does not extend an application request deadline or replace producer heartbeats. A prepared ticket still identifies one slot generation; renewal does not make that ticket reusable after detach. After a detach, renewal with that ticket extends the unattached lifetime of the anchor.

Batched streaming

The messenger mux carries every stream to one peer over the Messenger connection that already exists to that peer. It packs the records for that peer into _stream_batch active messages. Its transport key is messenger-mux-v2. The mux is on by default and is negotiated per attach. Senders do not change: StreamSender::send stages a record, and the layer below it decides when to write.

This chapter describes how the mux works. The Tune batched streaming guide tells you how to configure it. The Batched streaming design chapter records why it works this way and which alternatives were rejected.

Why one connection per stream does not scale

Without the mux, a velo stream owns a connection. The consumer creates a StreamAnchor, the producer attaches to its StreamAnchorHandle, and FrameTransport::connect returns a channel over a dedicated TCP connection. This design is correct for a few long-lived bulk streams. Each stream gets its own socket, its own kernel buffers and its own failure domain.

LLM serving has a different shape. A decode engine holds X requests in flight and emits one token per request on each forward pass. The X anchors belong to a small set of Y frontends. In a typical disaggregated deployment X is 256 to 1,024 and Y is 4 to 16. Each forward pass therefore makes X writes and X TCP segments, where Y writes carry the same payload.

Each remote stream on the per-stream path costs the following:

ResourceCost per stream
Sockets1 socket, 2 file descriptors (one per side)
Socket buffers1 MiB send and 1 MiB receive requested at each end (Linux doubles the request)
Channel slots4,096 on the connect side, 4,096 on the bind side
Tokio tasks4: heartbeat, egress pump, accept pump, reader pump
Setup latency1 active-message round trip plus 1 TCP dial round trip

Per token, the stream pays one rmp_serde allocation, one channel hop, one encode_frame and, because TCP_NODELAY is set, one syscall and one TCP segment.

This cost is a ceiling, not a slope. Each remote stream holds one socket, with one file descriptor in each of the two processes. Across both ends, 1,024 concurrent remote streams need 2,048 descriptors, about 4 GiB of requested socket buffer and about 4,096 tasks. Each process holds one descriptor per stream. At the default ulimit -n of 1,024, a process stops below 1,024 concurrent remote streams, less the descriptors that it uses for other things.

The rate of new streams is a limit too. On a cluster (2026-09-23, two Grace nodes on 200G Ethernet, 512 mock workers in 8 processes, concurrency 8,192), a response plane that opened about 2,500 streams per second on the per-stream path failed 80% to 92% of its requests. The workers could not get a local port (Cannot assign requested address). The mux carried the same load with no errors. For this reason the mux is the default.

Per-stream coalescing cannot reach the forward-pass shape

The per-stream egress pump packs every frame already queued on its stream into one write. The test lib/velo/tests/streaming/tcp_batching.rs measures this on TCP loopback. The ratio is velo_streaming_frames_written_total divided by velo_streaming_egress_flushes_total.

WorkloadFramesEgress flushesRatio
One stream, 20,000 frames back to back20,00121952 : 1
32 streams, 1 frame per stream per pass3,2323,2321.00 : 1

A forward pass puts one frame on each of X different streams. Each pump wakes with exactly one frame, so per-stream coalescing does nothing. The frames are on the wrong axis. Only bucketing by destination worker reaches them. The test forward_pass_shape_does_not_coalesce_per_stream asserts this limitation, so it fails if per-stream coalescing is ever mistaken for a solution.

Design overview

The mux has four mechanisms:

  1. Multiplexing. Every stream to a peer becomes a slot on the Messenger connection to that peer. Streaming owns no sockets.
  2. Batching. Many records go into one active message and one write_all.
  3. Flow control. Per-slot credit keeps one slow consumer from stalling the shared ordering lane of its peer.
  4. Flush policy. A policy decides when the batcher writes.

Bucketing by destination costs nothing. StreamAnchorHandle packs a WorkerId into its upper 64 bits, and WorkerId is a 1:1 hash of InstanceId. handle.unpack().0 is the batching key, with no map lookup and no discovery hop.

flowchart LR
    subgraph Producer node
        S1[StreamSender] --> I1[slot inlet C+1]
        S2[StreamSender] --> I2[slot inlet C+1]
        I1 --> B[PeerBatcher, one per peer]
        I2 --> B
    end
    B -->|_stream_batch active message| M[Messenger transport]
    subgraph Consumer node
        M --> L[ordered lane for this sender]
        L --> H[ingress handle_batch]
        H --> Q1[slot buffer C+1]
        H --> Q2[slot buffer C+1]
        Q1 -->|read directly| C1[StreamAnchor]
        Q2 -->|read directly| C2[StreamAnchor]
        H -.->|CreditUpdate replies| RB[PeerBatcher back to producer]
    end

Protocol

Riding the Messenger

MessengerMuxTransport implements the streaming FrameTransport contract. It has no dial, no listener, no acceptor and no connection manager. The sender's identity arrives in the active-message envelope, so credit has a return route without a handshake.

Egress is one PeerBatcher per remote instance and lane. The batcher is created on the first send to that peer on that lane, and evicted when it is idle with no live slots. A node that talks to Y peers on one lane holds Y batchers, whatever its stream count. The per-stream design is O(X) in tasks and sockets. With the mux, sockets and batchers are O(Y). Each stream still has its own stream watchdog on the consumer, so consumer tasks stay O(X). On the producer, one task per AnchorManager sends the heartbeats of all streams. The watchdog wakes once per heartbeat window, not once per record.

The cost of this design is that streaming no longer owns its wire. It shares queues, framing and backpressure with control traffic. Stream order is no longer a TCP guarantee. It is a protocol obligation, and every record carries a per-slot sequence number for this reason.

Frame envelope

A batch is the payload of one _stream_batch active message. The Messenger frames the active message, so the batch carries only its own header:

_stream_batch payload:
  [16 B batch header][record_count x record]

batch header:
  [u8 mux_version = 2][u8 flags][u16 record_count][u64 peer_epoch][u32 batch_seq]

record:
  [u8 record_type][u32 slot][u32 frame_seq][u32 len][len bytes body]

record_type: 0 = Data, 1 = OpenSlot, 2 = CloseSlot, 3 = CreditUpdate, 4 = SlotHeartbeat, 5 = LifecycleSlot

Every multi-byte field is big-endian, in the header and in each record. The low four bits of flags carry the lane of the batch. The high four bits are reserved and are zero. A lane-0 batch writes flags as zero, as senders did before lanes, and a receiver from before lanes ignores the byte.

The sender bumps peer_epoch each time it re-establishes its view of the peer. batch_seq advances within an epoch and is compared modulo 2^32. Together they let ingress discard a stale epoch's batches by header inspection, and they meter gaps. frame_seq is per slot and is the authority on stream order.

Each record header is 13 bytes. On a 40-byte token that is about 33% overhead, against 9 bytes on a dedicated connection. The two sequence numbers pay for ordering that a private TCP connection gave for free.

Record bodies:

  • Data carries the rmp_serde-encoded StreamFrame bytes, identical to the per-stream path. is_terminal_sentinel() works unchanged on a record body, so terminal detection has one code path.
  • OpenSlot carries [u64 anchor_id][u64 session_id]. This is the 16-byte attach handshake, moved into a record. The anchor id is random, so a peer cannot guess the pair. See Streaming.
  • CloseSlot carries [u8 reason]: 0 terminal sent, 1 peer gone, 2 unknown slot, 3 protocol error.
  • CreditUpdate carries [u32 delta] from receiver to sender.
  • LifecycleSlot carries the 64-bit session identity from OpenSlot and a one-byte action (0 = stop, 1 = cancel). Stop leaves the slot open for remaining output. Cancel takes precedence for the same session. Both the queued signal and the live producer check session identity, so a delayed signal cannot target a reused slot even if its compact generation has wrapped. Early stop is retained by the pre-bind until OpenSlot claims it.
  • SlotHeartbeat has no body. The decoder accepts it, but no sender emits it. See Heartbeats.

CloseSlot travels in both directions and has no direction bit. The reason carries the direction. TerminalSent and PeerGone travel from slot owner to receiver. UnknownSlot and ProtocolError travel from receiver to slot owner. Both sides can hold a slot at the same dense index, and the reason tells them apart.

Lanes

A lane is one ordered channel of batches from a sender to a peer. Order holds per (peer, lane), not per peer. Each slot stays on one lane for its whole life, so the order of each stream holds.

The mux keeps up to 16 lanes to a peer, and mux lane k rides transport lane k. The count follows the transport: Transport::lanes(peer), capped at 16. A QUIC or TCP transport built with lanes(8) gives 8 mux lanes. The default of one lane is the behavior from before lanes.

Each lane has its own batch handler, ordered per sender. Lane 0 keeps the name _stream_batch, so a peer from before lanes sends and receives on it. Lanes 1 to 15 use _stream_batch.1 to _stream_batch.15. Every node registers all 16 handlers when it is built. At that time the lane counts of its peers are not known, and they can be different for each peer and each transport.

Everything that depends on order is kept per (peer, lane): the batcher on the sender, and the epoch, batch_seq and slot table on the receiver. Slot ids are unique only within one batcher, so a table shared by two lanes would let one lane retire the slots of the other. Replies (credit, closes, stops) go back on the lane that the batch arrived on.

The receiver of a stream names its lane, in the attach response or in the StreamOpenTicket. The sender uses that lane modulo the lanes its own transport keeps to the receiver. A sender with one lane always uses lane 0. Any lane is correct for any stream, because every node takes batches on every lane. Only the spread changes.

The receiver chooses the lane once, when it binds the slot:

  • With a key. Velo::attach_anchor_keyed(handle, key), Velo::attach_mpsc_anchor_keyed(handle, key) and Velo::prebind_anchor_keyed(handle, key) take a u64 key. The receiver puts the stream on lane hash(key) % lanes. The hash is splitmix64, fixed in the code, so one key gives one lane index in every build, on every receiver with the same lane count. The key is a placement hint, not an ordering guarantee. Streams with one key to one receiver share a lane, but the mux never orders the records of one stream against the records of another.
  • Without a key, on attach. attach_anchor and attach_mpsc_anchor put the stream on the lane with the least load from that sender. The load of a lane is its live slots plus the binds that the receiver answered and no OpenSlot has claimed yet. The unclaimed binds count because an OpenSlot arrives only with the first batch of the sender. Without them, attaches answered at the same time all go to lane 0.
  • Without a key, on pre-bind. prebind_anchor does not know the sender. It puts the stream on the lane with the least load on this node. The load of a lane is its live slots from every peer, plus the pre-binds on it that are not yet claimed, released or expired. The live slots count because a frontend's tickets are claimed within milliseconds and the streams then live for seconds. If only unclaimed pre-binds counted, almost every choice would see all lanes empty, and almost every stream would go to lane 0.

Ties go to the lowest lane. lanes is the mux lane count of the transport to the sender. A pre-bind has no sender, so it uses the most lanes that any installed transport keeps. This is correct while Transport::lanes() gives one count for all peers, as every transport in velo does.

A stream counts on the lane that the receiver chose, from the bind until its slot closes. That is not always the lane that its batches arrive on. A sender with fewer lanes sends lane k on lane k modulo its own count. If the load counted on the arrival lane, the lanes above the count of the sender would never get load, and every unkeyed stream would go to the first of them.

A receiver whose transport keeps one lane names lane 0 for every stream, with a key or without one. A default deployment therefore sends the same bytes as before lanes.

The new fields are last in each message, default to zero when absent, and are not sent when zero. A lane-0 ticket or attach message is therefore the same bytes as before lanes, in JSON and in MessagePack. A ticket that names another lane has one more field, which a worker from before lanes refuses under positional MessagePack. Upgrade the workers before the node that mints the tickets.

The batch header repeats the lane. Ingress drops a batch whose header lane is not the lane of its handler, and counts its records as lane_mismatch in velo_streaming_mux_records_dropped_total. The slot ids of such a batch belong to another batcher, so applying them could feed or retire the wrong streams.

Across two nodes over QUIC, 64 streams of 16 KiB items moved about 750 MiB/s on 1 lane and 5,300 MiB/s on 8 lanes. The setup and the full table are in QUIC performance.

Each (peer, lane) table takes the whole range of 65,536 slot indices. The sender picks the lane that a stream rides, so one table can hold every stream of a peer: a sender with one lane puts all its streams on lane 0. All the tables of one peer share one byte budget, peer_byte_budget. The bound for each peer is therefore the same at any lane count on either side, and it does not depend on when each table was made.

The batch size cap

Three numbers clamp a batch, and the smallest one binds:

  • MuxConfig::max_batch_bytes, 60 KiB by default.
  • The effective eager budget. This is the smaller of Transport::max_message_size(target) (where the transport reports one) and the rendezvous staging threshold, less the envelope overhead. It is the largest payload the Messenger carries inline to that peer.
  • COALESCE_THRESHOLD, 64 KiB. The coalescing writer packs a frame into its buffered write_all only if the frame fits under this threshold. A larger frame is written in segments, which gives back the syscall saving.

A batch over the eager budget does not fail. It becomes a rendezvous transfer and pays a round trip for every slot packed into it. The cap exists to keep batches eager.

A single record larger than the eager budget goes through rendezvous alone, in its own batch. Other slots continue in eager batches while that transfer is in flight. Ordering describes how the slot stays in order.

Slots

SlotId = (u24 index, u8 generation), packed into a u32, scoped by the sender's peer epoch

  bits 31..8 : index        bits 7..0 : generation

The index sits in the high bits so that the raw u32 sorts by index. A dense index makes demux a Vec lookup instead of a hash. A 60 KiB batch holds about 1,100 records, so this lookup is on the hot path.

The generation is a correctness requirement. Without it, a stale record for a reused index goes to the stream that now holds that index. Request A's tokens then appear in request B's response, silently. Ingress drops a record whose generation does not match the current occupant and meters it in velo_streaming_mux_generation_mismatch_total.

The epoch scopes the whole table above the generation. A generation survives slot reuse within one sender's lifetime. The epoch survives the sender itself. Ingress therefore discards batches from an old epoch as a whole, and a u8 generation is enough.

Ordering is per slot

The mux registers each lane's batch handler (_stream_batch, _stream_batch.1, ...) with ordered per-sender dispatch. One task handles the batches from one peer on one lane, in arrival order. The general reordering problem does not arise, and no reorder window is necessary.

One exception exists. A rendezvous payload resolves in a detached task before dispatch, so an oversized record is not ordered against the eager batches around it. Two mechanisms bound this:

  1. The egress fence. A slot has at most one fenced singleton outstanding. While the fence is up, the batcher withholds the later records of that slot until the staged send is admitted. This includes the slot's CloseSlot, because a close must not overtake the record in front of it. Only the resolution of that singleton lifts its own fence. A rendezvous record always fences its slot, even when the transport admits it synchronously, because the receiver resolves it outside the ordered lane.
  2. The ingress hold. Ingress keeps a record that arrives ahead of its frame_seq in a per-slot hold, and applies it when the gap closes. Credit admission and the slot and peer byte budgets bound the hold. An overflow closes that slot with ProtocolError and meters velo_streaming_mux_hold_overflow_total. The consumer sees Dropped. Other slots and the lane continue.

The fence lifts when the rendezvous send is admitted, not when the receiver has the record. The slot's terminal and CloseSlot then go in an eager batch, and the sender frees the slot index. Its free list is last in, first out, so the next stream it opens on that lane takes that index at once. That OpenSlot can reach ingress while the old slot still waits for its rendezvous record, with its terminal in the hold and its close deferred behind it. Ingress has the close, so the sender is done with the old slot. Ingress moves it aside, kept by its full slot id, and opens the new slot at the index. The old slot then ends as it would have in the table.

An OpenSlot over a slot whose close has not arrived is still a protocol error. Ingress rejects the new slot with ProtocolError, leaves the old one alone, and counts slot_collision in velo_streaming_mux_records_dropped_total. One legitimate case stays a collision: a terminal larger than the eager limit (a TransportError with a very large message) goes through rendezvous together with its CloseSlot, so ingress has no close to see.

The generation in a slot id has 8 bits. After 256 streams on one index, the sender can open a slot with the id of an old slot that still waits aside. A record names its slot by id alone, so ingress cannot tell the records of the two streams apart. It rejects the new slot as a collision and fails the old one, so that neither stream gets the other's records.

Two other designs were rejected. The sender could keep an index until the receiver acknowledges the close, but that adds a record to every stream. The lane could wait for each rendezvous record before it handles later batches, but then every stream from the peer on that lane waits behind one large transfer.

A record refused for its size

A receiver built with VeloBuilder::staged_payload_max refuses a staged batch that states more bytes than that, before it allocates them (Rendezvous). It never reads the batch, so it cannot learn from the batch which slot the record was for. Each singleton batch that the messenger stages therefore names its stream in a header, _mux_slot: the sender's epoch, the slot's full id, and the routing session the slot was opened with, as <epoch>:<slot>:<session>. An inline singleton cannot be refused, so it carries no header: it may already be at the transport's frame limit. On a refusal, ingress finds that slot in the table of the (peer, lane) the batch came from, gives its consumer a TransportError that names both sizes as the terminal, retires the slot, and tells the sender to cancel that stream with LifecycleSlot, which names the session. A CloseSlot would name only the slot id, and a late one could end a later stream that reuses the id. The producer's next send fails. Other slots are not touched.

The slot ends at once. It does not wait for records ahead of the refused one that are still on their way, because the stream has already failed. A terminal that overtook the refused record waits in the hold and is dropped with the slot, so the error is delivered even when that terminal has already spent the slot's terminal reserve: the consumer always ends with the error, never with nothing. All three are checked under the table's lock. A resolve can outlive its stream, and a slot id alone does not name one lifetime: a replaced batcher reuses ids, and within one epoch a cancelled slot's index is reused and its eight-bit generation wraps. The session is the receiver's own u64, unique on its node and never reused, so a stale refusal finds no slot and does nothing. Without the header, the slot would keep a gap that never closes, and its consumer would wait for the stream watchdog. The id is the sender's word, but it is looked up only among that sender's own slots, so a sender can end only its own streams.

The refusal comes from the resolver, not along the ordered lane, so it can arrive while the stream's OpenSlot still waits on that lane. The slot is not open yet, but the stream's bind is waiting for it, so the refusal waits on the bind, and the OpenSlot that claims the bind ends the stream at once. It must match the (peer, lane), the epoch and the slot id that the refusal named. A session is not secret, so the bind keeps one waiting refusal for each (peer, lane): another peer that names the same session can neither replace nor block it. Both happen under the table's lock, so the claim cannot fall between the check and the mark. Without this, the refusal found no slot, the OpenSlot opened one, and its consumer waited for the stream watchdog.

Batches from one peer on one lane must not be reordered. The worker writes each OpenSlot in its own batch. A Data batch that overtakes it finds no slot and is dropped as closed_slot. The slot then opens with its sequence past the lost record, which silently truncates the head of the stream.

Opening a slot

The sender writes OpenSlot when the stream attaches, in a flush of its own. It does not wait for the first data record. bind() starts an accept window of ReservationConfig::prebind_timeout (180 seconds by default). The window measures the time until a batch that carries the OpenSlot arrives. A lazy OpenSlot makes the window measure the time until the first token, and a queued request with a long prefill then expires.

An OpenSlot for an (anchor_id, session_id) pair that was never registered does not fail the peer. The receiver replies CloseSlot{UnknownSlot} and discards the records of that slot. When the accept window closes on an unclaimed bind, the stream watchdog (or the consumer, if it sees the close first) reaps the registry entry, injects Dropped and increments velo_streaming_unclaimed_bind_reaped_total.

By default, connect returns after the transport admits the OpenSlot. MuxConfig::async_open_ack changes this. The OpenSlot still goes out in a batch of its own before connect returns, but the acknowledgement does not wait for admission. If the transport admits the frame synchronously, per-target FIFO already orders the slot's later records behind it, and no fence goes up. Otherwise the slot is fenced until the admission resolves. A failed admission is epoch death in both modes. Measured at load, this option did not improve first-token latency. See Response plane performance.

Zero-RTT stream setup

The receiver chooses every field of the attach response without input from the sender. It can therefore bind a slot before any sender asks. AnchorManager::prebind_anchor does the work of the attach handler at request registration. It binds the slot, allocates the routing session, takes the drain signal, installs the direct feed and spawns the stream watchdog. It returns a StreamOpenTicket with the six values an attach response carries. The application puts the ticket in the request envelope that it already sends to the worker.

The worker calls AnchorManager::open_anchor_stream with the ticket. Its first batch carries an OpenSlot, which claims the pre-bound slot the same way an attached sender's does. No _anchor_attach crosses the wire. The wire format does not change: StreamOpenTicket is a separate type in the application's envelope. When no mux is installed, prebind_anchor returns None and the stream attaches the ordinary way.

The pre-bind has these rules:

  • An attach adopts an unclaimed pre-bind. A sender that attaches the ordinary way receives the waiting slot and the ticket's session id. An attach against a pre-bind whose OpenSlot already arrived is refused. A sender that does not offer the pre-bound key is refused, and the pre-bind is released.
  • A same-worker attach releases an unclaimed pre-bind. A same-worker sender writes into the anchor channel directly and never claims the slot.
  • A dead request gives the bind back. PreBind's Drop releases an unclaimed bind. Every path that kills an anchor removes its registry entry, so this needs no extra call site.
  • Cancellation posts a close. A zero-RTT anchor never learns a StreamCancelHandle. When a claimed pre-bind drops, it posts CloseSlot{UnknownSlot} to the producer through close_claimed_slot. An idle producer learns of the cancel without sending another record.
  • Detach gives the slot back. Both places that handle Detached release the pre-bind and resume the unattached timer.

The ticket can wait in an envelope for the full configured accept window (180 seconds by default). Heartbeat detection starts only when the OpenSlot that claims the bind arrives. Before that, the stream watchdog does not count a silent window as a miss.

Credit is exact by construction. prebind sizes its buffer from the same NegotiatedLimits that the ticket quotes. open_slot emits no CreditUpdate on the claim, so the sender never holds 2C credit against a C+1 buffer.

The rollback is not symmetric. Disable the mux on the minting side first, or on both sides together. A producer that disables the mux alone still advertises its default transport key. A consumer that still pre-binds refuses that attach, because the key does not match the pre-bind.

The rollout has the same asymmetry in reverse. The mux is on by default, so a consumer mints tickets as soon as it runs a version with the mux. A producer without the mux cannot open them. Upgrade the producers first, or keep the mux off on the consumers that mint tickets until every producer has it.

Peer loss

While a batcher has live slots, it checks the health of its peer every 5 s. A failed health check, peer eviction and batcher eviction end in epoch death. A failed admission of a batch is also epoch death. The transport refused the batch, so it never reached the wire and left a frame_seq gap in every slot that it carried. The mux does not retransmit, so those slots cannot make progress again.

At epoch death the batcher closes every live slot of its (peer, lane) and starts a new epoch. The receiver retires the slots of the old epoch when the first batch of the new epoch arrives on that lane. Every retired slot that has not seen a terminal receives an injected StreamFrame::Dropped. The consumer sees StreamError::SenderDropped, the same as on the per-stream path. TransportError stays reserved for protocol violations. If no batch of the new epoch arrives, for example because the lane still refuses, the stream watchdog ends each slot.

A connection that closes after the transport admitted a batch is not epoch death. The mux does not wait for a response to a batch: the send drops its response awaiter at admission. A later failure of the send goes only to the process-wide error handler of the Messenger, which logs it. QUIC and TCP dial the lane again on the next send, and the batcher keeps its epoch. A batch that still waits for admission when the connection closes is refused (ChannelClosed or ConnectionReplaced), and that refusal is epoch death. The receiver sees the lost batches as a gap in batch_seq, and as a gap in frame_seq in each slot that they carried.

A slot with a gap cannot make progress. Its stream watchdog ends it after DETECTION_MULTIPLIER heartbeat windows, 15 to 20 s at the defaults. A slot whose OpenSlot was lost never opens. If the slot was pre-bound, its consumer waits until the configured accept window (180 seconds by default) closes the bind; an attached slot is ended by its stream watchdog, like a slot with a gap. A lost reply batch loses the credit and the cancels that it carried. The consumer's account then shows that credit as out, so its watchdog ends a slot whose grant was lost. Slots that lost nothing continue.

Each lane has its own batcher, connection and epoch. A failure on one lane therefore touches only the slots of that lane. Streams to the same peer on other lanes continue, unless the peer fails its health check.

Flow control

The shared resource is the ordering lane of one (peer, lane). A batch handler that awaits holds that lane, and every slot from the peer on that lane stalls behind it. Lane channels are unbounded, so a blocking handler turns backpressure into unbounded memory growth. With a blocking handler, one saturated anchor stalls every stream from that peer on its lane and fires all of their heartbeat watchdogs at once. For inference, one slow HTTP client then throttles the GPU. Ingress is therefore bounded and nonblocking, on per-slot credit.

Credit against a mux-owned buffer

Credit is issued against a mux-owned per-slot buffer, never against the anchor's frame_tx. frame_tx has other writers: the same-worker attach path, the detach and finalize handlers, the watchdog's Dropped injection and M concurrent MPSC senders. Any proof of "C credits against a C-deep channel" fails when a second writer exists. bind returns a receiver of depth C+1. The StreamAnchor reads that buffer itself, before its own frame_tx, and counts each record it takes. No task sits between the buffer and the consumer.

The mux publishes the buffer to the anchor through a replaceable feed. Retiring the bind (detach, a released pre-bind, a same-worker attach over a pre-bind) or removing the anchor withdraws the feed. The consumer drops a withdrawn feed on its next poll, before it reads that buffer again, so records still unread there are discarded. While a feed is installed, nothing on the anchor channel can jump ahead of a record the consumer can still read.

Invariant: a slot never has more than C data records outstanding against its C+1 buffer. The ingress handler only calls try_send into space that credit already reserved, and it never blocks the lane. velo_streaming_mux_reader_stall_total > 0 is a bug, not a tuning signal.

The initial credit C is negotiated. The receiver advertises initial_credit and slot_byte_budget in AnchorAttachResponse::Ok and in the MPSC equivalent. A slot opens already holding that window, so no round trip occurs before the first token. Continuing credit returns ride CreditUpdate records.

Each slot reserves one credit that only a terminal sentinel can spend. Data spends only C. One reserved credit is enough because sent_terminal allows at most one terminal per slot. SenderError is not a terminal and spends data credit. Control records (OpenSlot, CloseSlot, CreditUpdate) do not occupy the buffer, so data exhaustion never blocks them.

Byte budgets

Frame credit alone bounds memory at slots × C × the maximum frame size, which is not a useful bound. The per-stream socket enforced about 1 MiB per stream for free. The mux shares one connection, so it enforces its own limits:

  • MuxConfig::slot_byte_budget, 1 MiB per slot by default.
  • MuxConfig::peer_byte_budget, 8 MiB per peer by default. All the lanes of a peer share it.

Frame credit proves that no head-of-line blocking occurs. Byte credit bounds memory. The two grants can disagree, for example C records of 1 MiB each against a 1 MiB slot cap. The byte side wins by withholding the next grant while the slot is over its byte watermark. It does not refuse a record whose frame credit was already granted. The ingress hold is the one place where a byte reservation refuses a record, because the alternative is unbounded growth behind a gap.

Egress: the withheld queue

The batcher pulls every slot's inlet, whether or not the slot has credit, up to the slot byte budget. A slot with no credit keeps its records in a per-slot withheld queue. The queue is FIFO, so a terminal in it still waits for the records in front of it. finalize, detach and Drop reach the inlet from synchronous code. When the inlet is full, the terminal waits in a task, so these calls do not block.

A producer that runs a slot's byte cap ahead of its consumer waits. The batcher stops pulling from that slot's inlet, the inlet fills, and StreamSender::send waits until credit returns. Other slots continue. Stream saturation describes this backpressure from the operator side.

The batcher's own control inlet is coalesced state, not a queue. Credit returns accumulate into a u32 per slot. A close supersedes the credit of its slot, and a failed singleton supersedes a successful one. The batcher is woken, never fed. A queue is unbounded exactly when a flush parks on admission, which is when the peer is busiest returning credit.

Each control map is bounded by what the batcher allocated, not by a size cap. One map holds the control that a peer sends about this side's slots. It accepts a key only for an index that this batcher opened, at its live generation. A key for an index never allocated is refused and counted in velo_streaming_mux_control_refused_total. A key for a retired generation is dropped silently, because that is an ordinary close-then-reopen race. Replies that this side's ingress writes for slots it admitted are never refused. Rejections of an OpenSlot that ingress never admitted go on a separate reject lane, capped at 8,192 entries. A dropped rejection costs no credit, which is why a cap is safe there and nowhere else.

Admission

Transports expose an ordered admission gate, per target and lane, as SendOutcome::{Admitted, Pending(SendAdmission)}. A batcher learns at the send site, in order, that its peer is congested, and parks itself instead of a runtime worker. A batcher parked on admission stops draining its inlets. The producer then waits on a full inlet. This wait is bounded by the progress of the transport, which is the same position a socket was always in.

Credit return

Credit comes back from three paths. Two of them visit only slots that something named. The third walks the whole table as a backstop.

  • Drain signal. When the consumer takes a record out of the slot buffer, it increments an exact count on the slot's DrainSignal. It then sets the slot's bit in the peer's dirty set, a lock-free bitmap. Only the drain that sets the bit posts the peer; a drain that finds the bit already set changes nothing shared. The consumer takes no lock.
  • Arrival path. On every inbound batch, handle_batch reconciles the slots that the batch delivered into and the slots in the dirty set. The credit that a stream's tail waits for rides the peer's next batch, which arrives in tens of microseconds.
  • Doorbell. The sweep task answers a peer wake by reconciling the slots in its dirty set. MuxConfig::drain_visit_floor (2 ms by default) limits it to one visit per (peer, lane) per floor. Each lane has its own table and lock, so a peer with N lanes can take up to N visits per floor, one on each lane. This path covers a peer that sends no further batches.
  • Periodic tick. Every MuxConfig::credit_sweep_interval (200 ms by default), the sweep walks every slot of every ingress peer. This covers a slot that nothing names: parked, with nothing arriving and nothing being taken out. The same tick evicts idle batchers. Each walk is skipped when it has nothing to visit: the slot walk while no slot is live, the eviction check while no batcher exists. So an idle node's tick allocates nothing.

The dirty set carries an index and no quantity. The quantity is the count on the slot's own DrainSignal, and IngressSlot::reconcile swaps it to zero. A redundant visit therefore finds a count of zero and grants nothing. The three paths can run concurrently without double-counting. A stale listing costs a visit, never credit.

sequenceDiagram
    participant P as Producer batcher
    participant I as Consumer ingress
    participant R as Consumer (StreamAnchor)
    participant S as Sweep task
    participant B as Consumer batcher
    P->>I: _stream_batch (Data records)
    I->>I: deliver into slot buffer C+1
    R->>R: take record from slot buffer
    R->>R: DrainSignal count += 1, list slot in dirty set
    R-->>S: post peer wake
    P->>I: next _stream_batch
    I->>I: reconcile touched slots and dirty-set slots
    I->>B: CreditUpdate replies
    S->>I: doorbell visit (at most once per drain_visit_floor)
    S->>I: periodic whole-table walk (every credit_sweep_interval)
    B->>P: _stream_batch (CreditUpdate records, after reply_linger)

Credit for a record returns when the consumer takes it, not when it enters the mux buffer. A single-sender consumer that stops polling therefore holds its sender to the credit window C: no task moves records onward and returns credit for them. An MPSC anchor still has a pump that moves records into the anchor channel, so its senders can run ahead by that channel's depth as well.

A producer that ran out of credit sends no batches, so the arrival path does not run for its slots. It waits for the doorbell floor and then for the reply linger. Per record this costs (drain_visit_floor + reply_linger) / initial_credit. At the defaults (2 ms, 1 ms and 32) that is about 94 µs per record.

The periodic walk does not reclaim credit for a slot whose consumer is gone, because a consumer that is gone counts no drains. The slot is closed instead: removing a single-sender anchor closes its slot and tells the sender, whichever way the stream ended. An MPSC anchor closes the slot of each sender when that sender's pump ends without a terminal, for example when the anchor is dropped. A sender parked at its byte cap then wakes with an error.

Reply linger

A CreditUpdate does not write a batch of its own. MuxConfig::reply_linger (1 ms by default) starts when a reply with no window already running joins the batch. The window belongs to that reply. A record that joins later does not cancel it.

What ends the wait early depends on the flush policy:

  • Under Auto { on_admission: true }, any record that is not a credit reply ends the wait. A batch that holds only replies waits out the window, or until a close, a terminal or an application flush.
  • Under Manual and Auto { on_admission: false }, ordinary staging does not end the wait. Data that joins a pending reply is written with it when the window ends.
  • Under every policy, opening a slot on the peer ends the wait, because OpenSlot has its own flush.

No policy holds a reply past its window. A reply held for good starves the peer's sender, and no application on this side knows that it owes the peer anything. Duration::ZERO makes each reply urgent again. CloseSlot replies are always urgent.

Terminal sentinels

On the per-stream path, the egress pump writes a terminal, discards anything queued behind it and closes the socket. A frame sent after a terminal races the consumer's cleanup. The mux keeps this rule, scoped to one slot:

  1. Egress sees is_terminal_sentinel(body) for slot S, appends the record to the current batch, marks S draining and drops the inlet receiver of S. Frames queued behind the terminal for S are discarded. Other slots continue.
  2. Egress appends CloseSlot{TerminalSent} in the same batch, right after the terminal. Terminal and close are atomic.
  3. The batcher frees the slot, bumps its generation and releases its credit and byte budget. The peer batcher and the peer's ordering lane continue.
  4. On the receive side, the terminal spends the reserved credit. Then CloseSlot drops the mux-side sender. The consumer reads the terminal and then sees the buffer close, the same as a receiver does when a socket closes, and the stream watchdog exits.
  5. A CloseSlot with any reason other than TerminalSent, and every epoch death, injects Dropped for a slot that has not delivered a terminal.

finalize, detach and Drop hand the terminal to the inlet without blocking a runtime worker. They call try_send first. On a full channel, they hand the record to a task that awaits space. The task holds a clone of the sender, so the channel stays open until the sentinel is in it. The task runs on the caller's runtime, or on the sender's own runtime when the caller has none, so no caller thread blocks. detach clears the attachment flag only after the sentinel is in the channel, so a re-attach cannot put records ahead of the Detached frame.

velo_streaming_mux_live_slots must return to zero at teardown. A leaked slot holds credit and byte budget for the rest of the epoch, and unlike a leaked socket it does not show in lsof.

Retirement

StreamSender::retired() resolves when the producer's node has handed on all of the stream's output (see Streaming). Under the mux, the batcher reports it:

  • When egress writes a terminal into the open batch, it moves a guard for the stream into the batch. When the batch is admitted, each guard reports Sent. A terminal that goes alone through rendezvous gives its guard to the task that waits for its admission.
  • A guard that is dropped reports Discarded. A refused batch, an epoch death, a teardown, and a batcher task that is aborted all drop the guards that they hold.
  • An item larger than the eager budget goes alone, as a handle the consumer pulls. Its guard reports Sent when that handle's batch is admitted, while the item is still staged on the producer (known limitation).
  • A slot that closes while it still holds its producer's lifecycle reports Discarded. The terminal path takes the lifecycle out of the slot first. So every other close reports the loss: the CloseSlot{UnknownSlot} that a consumer sends when it cancels or drops its anchor, an epoch death when the consumer's process goes away, and the teardown of the producer's node.

The state is three bits in the per-stream allocation that the sender and its slot already share (the outcome, and whether anyone waits), and a list of wakers that stays empty until a Retired future waits. No message crosses the wire for it. The first outcome stands. A stream that nobody watches pays one compare-exchange when it retires, and no wake.

The alternative is an acknowledgement from the consumer in the application's own protocol. It costs one more active message for each stream, a table of waiters on the producer, and a round trip before the producer knows.

Measured with the stream_lifecycle example (Benchmarking): two nodes in one process over loopback TCP, 200,000 streams of four 16-byte items, 64 at once, on the example's 4 worker threads. Provisional: an Apple M5 laptop (10 cores, macOS), shared with other builds and test runs (load average 2.3 to 3.3), release build, /usr/bin/time -l target/release/examples/stream_lifecycle --mode <mode>. The modes ran interleaved, so contention hit each alike, and the figure is the process's instructions divided by the streams, median of nine runs (three for ack), with the spread of the runs. These costs are to be confirmed on dedicated hardware, and only instruction counts are compared, not wall time or tail latency.

ModeInstructions per streamSpreadProducer holds the stream after finalize, p50 / p99
plain: retired() not used123,874123,638 to 124,229—
retired: retired() awaited124,470124,443 to 124,70276 µs / 195 µs
ack: consumer acknowledgement161,890161,753 to 161,928276 µs / 500 µs

Awaiting retired() costs about 600 instructions per stream here, under 0.5 percent; the acknowledgement costs about 38,000, 30 percent, and a round trip. A stream that nobody watches pays one compare-exchange when it retires; this run has no build without retirement, so it does not measure that.

A second run compares a build without retirement. It ran on an idle Linux host (20 Arm cores: Cortex-X925 and Cortex-A725), release build, with the same harness and defaults. Hardware counters are not readable there, so the figure is the process's CPU time (user and system) divided by the streams, median of fifteen runs, with the builds and modes interleaved. The build without retirement is the same harness on the commit before retirement, with the retired mode left out.

Path and modeWithout retirementWith retirementProducer holds the stream after finalize, p50 / p99
Mux, plain40.6 µs (38.4 to 42.9)39.7 µs (38.0 to 42.7)—
Mux, retired awaited—43.0 µs (42.1 to 43.7)277 µs / 654 µs
Per-stream TCP, plain269.0 µs (254.5 to 270.9)269.1 µs (250.0 to 272.5)—

A stream that nobody watches costs the same with and without retirement, on both paths: the medians differ by less than the spread of the runs. Throughput is the same too (86,000 and 87,500 streams per second on the mux; 15,160 and 15,146 per stream). Awaiting retired() costs about 3 µs of CPU per stream on this host.

Heartbeats

One task per AnchorManager sends the heartbeats of its remote senders. Once per heartbeat interval that the consumer advertised, it calls try_send with a StreamFrame::Heartbeat into the channel of each sender. On a full channel the heartbeat is dropped. A sender on the anchor's own worker gets no heartbeats, because no watchdog reads them there. Under the mux, a heartbeat is an ordinary Data record. It spends data credit and shares the peer's queues with data.

On a single-sender mux stream, the stream watchdog detects silence. It is one task per stream, and it holds no data. It wakes once per heartbeat window on its own timer, or when the mux closes the bind. A window is live if the ingress delivered anything to the slot during it, or if the sender holds no data credit and nothing waits behind a sequence gap in the reorder hold. A consumer that is behind leaves its sender without credit, by leaving its window unread or by holding the byte budget so that credit is withheld, and a sender without credit cannot send a heartbeat, so that silence is not the sender's. After DETECTION_MULTIPLIER dead windows (3 × 5 s at the manager default), the watchdog injects Dropped, removes the anchor and increments velo_streaming_heartbeat_watchdog_firings_total. The windows run on the watchdog's own clock, so detection lands between DETECTION_MULTIPLIER and DETECTION_MULTIPLIER + 1 windows after the last arrival. Before a pre-bound slot has a sender, windows do not count. An MPSC anchor over the mux keeps its reader pump and that pump's watchdog. On any transport, an MPSC pump neither logs a firing nor counts it in velo_streaming_heartbeat_watchdog_firings_total.

A sender that still holds credit can heartbeat, so its silence counts even with records unread: a worker that dies behind a slow reader is detected on time. A sender that died while holding no credit is detected once its consumer reads enough to return credit to it.

On the per-stream path, the reader pump detects silence. It arms one pinned timer per stream. On each received frame, it stamps the time after the forward completes. It moves the deadline only when the deadline is within half a window. Under steady traffic the timer never fires and moves at most twice per deadline. When the timer fires with no frame inside the window, the pump counts a miss. After DETECTION_MULTIPLIER misses, exactly that many windows after the last frame, it injects Dropped and increments the same counter.

A per-stream heartbeat does not detect a hung producer, because it runs on a separate task. It detects process or host death, connection death and sustained saturation. Saturation shows because a full channel drops heartbeats. Under the mux, the health check of the batcher detects a dead peer on the sending side, and the mux learns of it through epoch death. The health check runs only while a batcher has live slots to send on. On a node that only receives, a dead producer is still the watchdog's job: its slots retire only when a batch of a new epoch arrives, or when the watchdog fires. A connection that closes after admission is different: nothing reports it to the mux, and the stream watchdog ends the slots that lost records (see Peer loss). The other signal that a stream heartbeat carries is per-slot saturation upstream of the consumer, such as a backlog on the producer's egress. For this reason heartbeats are not in the reserved control class. A consumer that has fallen behind is not this signal: it leaves its sender without credit, and the watchdog exempts a sender that holds none.

The wire reserves SlotHeartbeat (record type 4) for a cheaper heartbeat. In that design, the batcher emits it only for idle slots, on one peer-level tick. Ingress decodes and applies it, but no sender emits it yet. The Batched streaming design chapter records that design.

Flush policy

Senders stage records, and the peer batcher decides when to write. The policies differ only in who decides.

PolicyTriggerAdded latencyDefault
Auto { on_admission: true }End of every wake, after the batcher takes everything already queuedNone (up to reply_linger for a batch of only credit replies)Yes
Auto { on_admission: false, max_linger: Some(w) }Up to w after the oldest staged recordUp to wNo
... with on_complete: trueAlso when every live slot on the lane has a record in the batch, but not sooner than w after the last write that carried dataNone for a lone stream whose records are more than w apartNo
... with at_bytes: Some(n)Also when the batch reaches n bytesNone for a batch that reaches nNo
ManualVelo::flush_batch()Set by the caller. A pending credit reply takes the batch out after reply_linger.No

AutoFlush is a struct, not more enum variants, because its conditions compose. A batcher can hold several. on_admission is named for its mechanism. A flush parks until the transport admits it, so "at the end of every wake" means "as soon as the peer took the last batch". The default cannot make latency worse. The batcher never waits for work that has not arrived. It only takes everything that is already queued.

Ending the window early

A linger window runs from the oldest record in the batch. A record that is alone on its lane therefore waits the whole window for records that will not come. Dynamo's response plane runs with a 2 ms window. With one request in flight, the time from the worker's first chunk to the frontend's first token was 3.13 ms longer ([3.12, 3.14] ms, over 278 requests) than over Dynamo's per-request TCP path: the window, plus about 1 ms of timer rounding. That was Qwen3.8 on SGLang, with the frontend and the worker on one VR200 node. The same 3 ms showed at every single-request point measured, on TRT-LLM, vLLM and SGLang. Two optional conditions end the wait when more waiting cannot help:

  • on_complete. Every live slot on the lane already has a data record in the batch. More waiting could only add a second record from one of them, and for a token stream that is a forward pass away. The batch goes at the later of its oldest record and one window after the last write that carried data. The second bound keeps a stream that sends faster than the window at one write per window. A write without data does not count: an empty flush (such as flush_batch() with nothing staged), an OpenSlot, or a batch of credit replies. Counting every write was rejected. flush_batch() kicks every batcher on the node, so one empty call held the next lone record on every idle lane for up to a window, and the OpenSlot held a stream's first record until a window after its open. On a busy lane, some slots are always idle (in prefill, or between turns), so the batch is never complete and the window applies as before. Credit replies and closes do not make a batch complete.
  • at_bytes. The batch has reached about one transport segment. A longer wait then saves at most one partly filled packet per write, and adds latency. This is not max_batch_bytes: that cap is also the eager limit, and a record larger than it goes through rendezvous. at_bytes only ends the wait.

The rejected alternative wrote a batch at once whenever the lane had been quiet for a window. A forward pass reaches the batcher over a fraction of a millisecond, so that rule writes the first records of a pass alone and the rest a window later: two writes per pass where the window made one. on_complete writes early only when the batch holds a record from every live slot, so it never writes the start of a pass alone. A pass can still be split at a window edge, as under the plain window: a complete batch that waits one window from the last write that carried data can take the first records of the next pass.

In the same deployment with a 2 ms window, worker batches averaged 1.4 to 3.1 records and 120 to 410 bytes, with a 99th percentile of 7 to 16 records. That was at 96 concurrent requests on TRT-LLM (about 38 live slots per worker) and at 1,882 on SGLang with 4 workers (135 to 720 live slots per worker). Batch sizes in bytes were measured only as averages. At 85 to 200 bytes per record, a 99th-percentile batch is about 1.4 to 2 KB, so an at_bytes of one 1,448-byte segment would bind at most on that tail, and one of 8,948 bytes not at all.

Two conditions write a batch under every policy:

  • A batch at a clamp goes. The byte cap, the record cap and the eager budget each cut a batch where they bind. A full batch gains nothing by waiting.
  • Records that carry liveness go. OpenSlot has its own flush on the awaited path. A CloseSlot or a terminal makes its batch urgent. A CreditUpdate goes within reply_linger.

A slot with no credit also cuts a batch: it adds nothing, and its records wait in the withheld queue.

The flush API

#![allow(unused)]
fn main() {
for (sender, token) in outputs {
    sender.send(token).await?;   // stage
}
velo.flush_batch();              // one write per peer
}

flush_batch() is synchronous and does not block. It kicks each batcher and returns. It is not a backpressure point: per-slot credit and transport admission slow a producer, the same as when nobody calls it. It takes no argument, because a producer holds StreamSenders and cannot know which batcher each one feeds. It is valid under every policy and never returns an error. Under Manual it is the write. Under Auto it forces a write before the batcher's own conditions. With no mux installed it does nothing.

A burst between two calls is a hint, not a frame boundary. A clamp or a liveness record can cut a wire batch inside the burst. A caller must not assume that the records it bracketed arrive in one _stream_batch.

The kick survives a clamp. A wake that stages 100 records can hit a clamp at record 60 and flush inline. The kick stays set, and the batcher consumes it at the end of the wake, after it writes the other 40.

Why a call and not a guard

An earlier design used an RAII gate: start_batch() and end_batch() around a StreamBatch guard, reference-counted for nesting, flushing on Drop. The call replaces it:

  • No open state. A gate can be held across an .await, which made a deadlock possible and required two rules to prevent it. A flush holds nothing and nests nothing.
  • No guard type. One method replaces a guard, wrapper functions and a reference count.
  • It fits the loop. A forward pass is imperative: stage the sends, flush, start the next pass.

The deadlock that the gate rules existed for cannot occur. A producer that sends C+1 frames to one slot has frames 1 to C staged and frame C+1 in the withheld queue. The flush that returns credit is one the producer makes itself, at the end of the pass.

The failure mode of Manual

Under Manual, the application's own records that are staged after the last flush_batch wait for the next call. No timer moves them. This is what makes "one write per pass, carrying that pass" a property of the code and not of the scheduler. The usual cause is a producer that stopped calling flush_batch. A slot starved of credit is a second case: a grant that arrives after the flush releases withheld records into the next pass's batch. A terminal and an inlet close move on their own, so the end of the stream bounds this case.

The cost is latency, not memory, because the same clamps bound staged records. velo_streaming_mux_staged_records shows a plateau. A deployment that wants a safety net can configure Auto { on_admission: false, max_linger: Some(w) }, which is the same batching with a window.

When an explicit flush helps

A forward pass that sends to X streams with no .await between the sends puts them all in the shared egress queue. The default policy then sees all of them. A producer that awaits between sends (a tokenizer, a sampling callback, anything that yields) delivers each send to the batcher alone, and the ratio falls toward 1.0. Only an explicit flush groups sends that the runtime scheduled apart.

The second reason is determinism. How many records share a batch under Auto depends on how the runtime scheduled the batcher against the producer. The batched_streaming example (three anchor hosts, two engines, loopback TCP, 20-core arm64 machine, credit window 256) shows the difference in tokens per wire write:

ConfigurationLegacy per-streamAuto (5 runs)Manual (5 runs)
24 requests, max batch 8, 1 ms between passes0.99 to 1.002.18 to 2.192.19 every run
96 requests, max batch 32, 1 ms between passesnot run4.67 to 5.145.38 every run
24 requests, max batch 8, no gap between passesnot run6.61 to 7.443.47 to 4.41

At serving depth, Manual is both higher and repeatable. A batcher that writes at every wake sometimes wakes mid-pass and writes half a pass. Auto packs harder only when the producer outruns the batcher with no gap between passes. That surplus is throughput bought with per-token latency, which is the wrong trade for a decode engine. Use Auto to let the batcher do its best with what it finds. Use Manual to know what it will do.

Negotiation and compatibility

The attach selects the mux. No wire magic exists. AnchorAttachRequest and MpscAnchorAttachRequest carry supported_transport_keys (with #[serde(default)], so an older sender deserializes as advertising nothing). The attach handler intersects the sender's keys with its installed transports. It picks messenger-mux-v2 only when both sides name it. Otherwise it selects the per-stream default if the sender offers that key, or sends an attach error. An empty offer from an older sender retains the legacy default selection.

The sender reads the answer:

  • A key other than messenger-mux-v2 is the per-stream path, and the credit fields do not apply. Every older receiver answers this way.
  • messenger-mux-v2 with a window opens a slot that already holds that window.
  • messenger-mux-v2 with no window (initial_credit zero) is refused. No shipped receiver answers this, and a node with a mux cannot advertise a zero window. A fallback to another transport reaches nothing that listens, and the stream hangs until the watchdog fires.

By default, a node with the mux enabled registers both messenger-mux-v2 and its configured per-stream transport, so it still serves older peers. A node built with .mux_only() installs no per-stream listener and refuses peers that need one. resolve_transport fails on an unknown key in a non-empty registry, so a receiver that answers the mux on its own breaks every older sender. SPSC and MPSC anchors both negotiate the mux.

StreamSender::negotiated_transport() returns the key that the attach settled on. It returns None for a same-worker attach, which uses no transport. Compare it with the public constant MESSENGER_MUX_KEY.

For a node with a per-stream listener, MuxConfig::enabled = false, or VELO_MESSENGER_MUX_DISABLE=1 at startup, is the rollback. The node stops advertising messenger-mux-v2, and the next attach negotiates the per-stream path with no code or wire change. For mux-only nodes, either setting makes startup fail. Remove .mux_only() to restore the listener before using this rollback. See Zero-RTT stream setup for the order when tickets are in use.

Observability

The mux series all start with velo_streaming_mux_. The most important ones:

  • velo_streaming_mux_records_per_batch{direction} and velo_streaming_mux_batches_total{direction} show how much batching occurs.
  • velo_streaming_mux_live_slots returns to zero at teardown.
  • velo_streaming_mux_reader_stall_total and velo_streaming_mux_credit_lost_total must stay at zero.
  • velo_streaming_slot_credit_exhausted_total counts credit starvation on the sender.
  • velo_messenger_ordered_lane_wait_seconds{handler="_stream_batch"} shows the ingress lane wait, one sample per batch. Lanes other than 0 report under their own handler names, _stream_batch.1 to _stream_batch.15.

The Metrics reference lists every series. Stream saturation explains how to read them under load.

Peer loss

A batcher with live producer slots checks its selected message transport every five seconds. A failed check closes the producer slots and triggers their cancellation and stop tokens, including idle producers. The check is per peer, not per token or stream. It does not change the selected transport or permit a fallback.

Transports

A transport moves messenger frames between instances. You add transports when you build the instance. More than one transport can be active at the same time.

let node = Velo::builder()
    .add_transport(Arc::new(TcpTransportBuilder::new().build()?))
    .build()
    .await?;

Available transports

TransportFeatureProtocolNotes
TCPalwaysRaw TCPDefault. Coalescing writer.
UDSalways, Unix onlyUnix domain socketLocal only. Coalescing writer.
NATSnats-transport (default)NATS subjectsSubject scheme velo.{id}.{type}
gRPCgrpc (default)HTTP/2 bidirectional streamReconnects with exponential backoff
ZMQzmqZMQ DEALER/ROUTERReconnects and queues messages
UCXucx, Linux onlyUCX active messages over RDMA, TCP, or shared memoryAlso carries RDMA rendezvous. See Rendezvous and RDMA.
QUICquicQUIC over UDP, TLS 1.3Pinned self-signed certificate. Coalescing writer. See QUIC.

The http feature exists, but the HTTP messenger transport is not built at this time.

Peer routing

Velo registers each peer with every transport that the peer and the local instance both support. For each peer, Velo selects a primary transport: the compatible transport with the highest priority. Messages to that peer go on the primary transport.

Velo::unregister_peer undoes this. It removes the peer's routes, and then each transport drops the peer's address and closes its connections. See Forget a peer. Dropping a connection's map entry does not close it: the writer task holds a sender of its own channel, so it would wait for frames forever. So TCP, UDS, QUIC, and gRPC give each connection its own cancel token, a child of the transport's token. UCX closes the endpoint on its progress thread, at the next point in the loop where closing one is safe. ZMQ closes the DEALER socket on its sender thread. NATS keeps no connection per peer.

A WorkerAddress is a MessagePack map from TransportKey to endpoint bytes. Each transport adds its own entry, and a peer reads the entries for the transports that it has.

The transport contract

Every transport implements the Transport trait from velo-ext. The runtime relies on these rules:

  • Sends do not wait for the wire. send_message takes the frame and reports when it reached the send channel for the target. Failures after that point go to a TransportErrorHandler callback.
  • Order holds per lane. lanes(target) tells how many ordered channels the transport keeps to a peer. The default is 1. send_message_on_lane keeps order within one lane only, and send_message sends on lane 0. A lane never fails over to another lane's connection, because that would reorder it. The messenger sends its own traffic on lane 0, because ordered handlers need the messages of one peer on one lane. QUIC and TCP implement lanes. The other transports keep one lane.
  • Inbound frames go to four streams: message, response, event, and shutdown. The TransportAdapter routes each frame to its stream.
  • Admission owns the in-flight count. Each inbound MessageType::Message goes through TransportAdapter::admit_message. See Shutdown and drain for why a transport must not check is_draining() itself.
  • Unregister forgets a peer. unregister(instance_id) drops what the transport keeps for the peer and closes its connections. The runtime calls it on every transport, also on one that never registered the peer, so it must be cheap and idempotent. The default does nothing, which is right only for a transport with no per-peer state.
  • Metrics use one handle. The runtime gives each transport an observability handle through set_observability. In-tree and out-of-tree transports write the same velo_transport_* series.

Coalescing writer

TCP, UDS, and QUIC use a coalescing writer. Small frames that wait in the send channel go out together in one write.

A frame above 64 KB (COALESCE_THRESHOLD) goes out directly, as two writes. The first write is the preamble and the header, staged in a 256 B stack buffer. The second write is the payload, with no copy. Three writes cost two extra syscalls on a TCP_NODELAY socket, and they put small segments on the wire before each large payload. The writer does not use write_vectored, because TCP can return a short write for it past about 128 KB.

These costs are for TCP and UDS. On QUIC, quinn copies each write into its own send buffer, so the payload write is not free of copies there, and the three writes cost no extra syscalls.

The reader reserves the full frame length as soon as it has parsed the preamble. Without this, the read buffer grows by doubling from 8 KB, and each step copies all the bytes received so far. On loopback, the two changes together cut the 8 MB round trip from 6.46 ms to 4.70 ms.

The writer publishes velo_transport_frames_written_total, velo_transport_write_duration_seconds, and velo_transport_egress_queue_wait_seconds. gRPC, NATS, ZMQ, and UCX have no such writer and do not publish these series. See Observability.

Socket buffers are set before data flows

TCP sets SO_RCVBUF and SO_SNDBUF on the listening socket, so each accepted socket inherits the sizes at handshake time. The dial side sets them before its first write. The size is 2 MiB by default.

An explicit size turns off the kernel's autotuning, and Linux clamps it to net.core.rmem_max and wmem_max. With the common value of 212,992, 2 MiB becomes a locked buffer that reports 425,984 bytes (the kernel doubles the value for its own use). This caps the TCP window at about 208 KiB. TcpTransportBuilder::socket_buffers(None) sets no size, so the kernel autotunes the buffers up to net.ipv4.tcp_rmem and tcp_wmem. The choice is a trade-off.

The table below was measured on 2026-09-30 with the throughput example in its two-host mode, over one connection. The nodes were of the same type as in the two-node table: Grace aarch64, 200G Ethernet at MTU 1500, rmem_max at 212,992. Each process had a whole node and was not pinned. Each cell sent 20,000 messages. Three reps, with the two settings interleaved.

TrafficFixed 2 MiB (MiB/s)Autotuned (MiB/s)
64 KiB, pipelined one way2,411–2,5062,618–2,660
256 KiB, pipelined one way2,232–2,3553,359–3,387
64 KiB, request and reply, 64 in flight1,614–1,9031,549–1,612
256 KiB, request and reply, 64 in flight1,892–1,920, p50 8.0 ms1,639–1,674, p50 9.6 ms

The NUMA node that the processes run on also moves these numbers. The two-node table gives 3,920 MiB/s for one TCP connection, 64 KiB pipelined. In a separate run with both processes pinned to the NUMA node of the NIC, that case moved 3,220–3,910 MiB/s with the fixed size and 4,440–4,450 MiB/s with autotuning. Pinned to the other NUMA node, it moved 2,370–2,400 MiB/s with the fixed size.

On loopback, autotuning lost 10% for 64 KiB pipelined, gained 10 to 30% for 256 KiB pipelined, and doubled the p99 for 256 KiB with 64 in flight. Autotuning suits one-way bulk traffic across nodes, and 256 KiB messages on loopback. The default suits request and reply. The option applies to the TCP messenger transport only: the per-stream TCP transport keeps a fixed 1 MiB. Every example that builds its transport with new_transport reads VELO_TCP_SOCKET_BUFFERS: auto for autotuning, or a size in bytes.

The old code set the sizes on the accepted socket, one task spawn after accept. By then the peer was already sending. On Linux, SO_RCVBUF at that point turns off receive autotuning and clamps the buffer to net.core.rmem_max. The advertised window then collapses for the life of the connection. Throughput fell about 100 times, to 22.9 MB/s, and the fault was racy, so it showed up in some runs only. The tx_budget example measures this path and guards against the fault.

TCP lanes

With TcpTransportBuilder::lanes(n), the dialer keeps up to n connections to each peer, one for each lane that it sends on, dialed on the first send on that lane. The default is 1. send_message uses lane 0, so ordinary traffic keeps one ordered channel for each peer. Each lane has its own send channel and admission gate. If the connection of a lane closes, the next send on that lane dials a new one. A lane never uses the connection of another lane, because that can reorder its messages.

  • One connection is limited by its receiver. On the receiving side, one reader task does the whole receive copy for a connection, and by default the fixed socket buffers cap the TCP window (see Socket buffers). Lanes spread that work over more tasks and cores.
  • The listener needs no change. The kernel gives each accepted connection its own socket, and the listener reads each connection on its own task. A peer can open as many lanes as it needs. Only the count on the dialing side matters.
  • The health check counts any lane. A caller can send on any lane, so a peer can have live connections with none on lane 0. check_health reports such a peer as healthy.

A prototype with one connection per lane sent 64 KiB messages, pipelined, between two nodes. It used the throughput example on nodes of the same type as the two-node table, and sent each message on the next lane in turn. The prototype was a patched build, not the code in the tree. The throughput example in the tree sends through the messenger, which uses lane 0 only, so it cannot repeat this measurement. Each cell has two reps. The NUMA node that the two processes run on changes the result by 1.3 to 1.6 times:

LanesPinned to the NUMA node of the NIC (MiB/s)Pinned to the other NUMA node (MiB/s)Not pinned (MiB/s)
13,220–3,9102,370–2,4002,410–2,430
24,370–4,500
410,510–10,6907,760–7,8907,710–7,790
816,830 (one rep)12,420–12,520

The 3,920 MiB/s for one TCP connection in the two-node table is at the top of the range for processes on the NUMA node of the NIC.

QUIC

The QUIC transport uses quinn. It has the same shape as TCP: one connection for each peer, lane and direction, dialed on the first send on that lane. With the default of one lane, that is one connection for each peer and direction.

graph LR
    subgraph Dialer
        W[Coalescing writer] --> S["Bidirectional stream<br>(send half)"]
        R[Dialed reader] --> A1[Shutdown stream]
    end
    subgraph Listener
        E["Server endpoints<br>(one port each)"] --> F[Stream reader]
        F --> AD[admit_message and route_frame]
        F -. "ShuttingDown echo" .-> R
    end
    S --> E
  • One stream for each connection. The dialer opens one bidirectional stream and writes velo frames on it with the TCP frame codec. One stream keeps the order of the messages on one connection, that is on one lane. Ordered handlers and batched streaming rely on that order. Several streams would remove head-of-line blocking after a packet loss, but they would also reorder the messages of one peer without the caller choosing it. Lanes (below) reorder only across lanes, and the caller picks the lane for each ordered flow.
  • Shutdown waits for acknowledgement. When a writer stops, even at teardown, it finishes its stream and waits up to 1 second for the peer to acknowledge what it wrote, and then closes its connection. A writer that is blocked because the peer stopped reading is closed by force at 2 seconds instead. The dial sockets close when every writer has finished, or at the latest after 2 seconds. A QUIC close discards unacknowledged data, which TCP would still deliver after a close. Transport::closed() waits for this, and graceful_shutdown waits for closed(), so a process can exit when graceful_shutdown returns.
  • The reverse direction carries only drain echoes. The listener writes a ShuttingDown frame back on the same stream when it refuses a request during drain. The dialer reads it with the same code as TCP.
  • The certificate is pinned. Each transport makes a self-signed certificate and puts its SHA-256 fingerprint in its WorkerAddress entry. A dialer accepts only that certificate and checks the TLS 1.3 handshake signature. A different listener on a reused port fails the handshake.
  • Lanes are separate connections. With lanes(n), the dialer keeps up to n connections to each peer, one for each lane that it sends on, each from its own UDP socket. One QUIC connection does its packet and crypto work on one task, so it is bound to about one core. Lanes spread that work over more cores. send_message uses lane 0, so ordinary traffic keeps one ordered channel for each peer.
  • Each server socket has its own port. server_endpoints(n) binds n UDP sockets, each on its own port, and the WorkerAddress entry lists the ports. A dialer sends lane k to socket (offset + k) % n, where offset comes from the dialer's own certificate. So the lanes of one dialer land on different sockets, and the peers are spread over the sockets too. Each socket has its own quinn endpoint driver, receive queue and buffer ceiling. The lanes of a dialer are spread evenly, so a socket can carry two lanes: 8 lanes into 4 sockets moved as much as into 8. The default is 4. Each socket costs a port, a quinn endpoint and its buffers. A peer that does not list ports is dialed on its one advertised port for every lane.
  • Dial sockets are separate. Dials use their own sockets on ephemeral ports, one for each lane, so each lane has its own endpoint driver for its replies.
  • UDP buffers are checked. The transport requests 8 MiB receive and 4 MiB send buffers on each socket, reads back what the kernel granted, and logs a warning when net.core.rmem_max or net.core.wmem_max clamped the request. A clamped UDP buffer shows up later as dropped datagrams, not as an error.
  • Packets are at most 6550 bytes. quinn sends up to 10 packets in one GSO batch, and a larger packet makes the batch exceed the UDP datagram limit. The batch is then lost with no error. max_mtu is lowered to 6550.
  • quinn 0.11.12 is the minimum. It pulls quinn-proto 0.11.18. Older quinn-proto can fail an ordered, lossless stream of many small chunks with too many gaps in stream buffer.

For the tuning settings and the measured cost against TCP, see QUIC performance.

Frame transports for streams

Streams without the mux use a FrameTransport. TCP is the default. gRPC is available with the grpc feature. See Streaming.

Add a transport

  • For an in-tree transport, follow the procedure in the project CLAUDE.md under "Adding a new in-tree transport".
  • For a transport in another crate, see Write an out-of-tree transport.

Discovery

Discovery finds the addresses of peers. A backend implements the PeerDiscovery trait. It resolves an InstanceId or a WorkerId to a PeerInfo, which holds the transport addresses of the peer.

use velo::discovery::FilesystemPeerDiscovery;

let discovery = Arc::new(FilesystemPeerDiscovery::new("/tmp/peers.json")?);
let node = Velo::builder()
    .add_transport(tcp)
    .discovery(discovery)
    .build()
    .await?;

// Find a peer and register it.
node.discover_and_register_peer(peer_instance_id).await?;

The discovery traits and custom implementations are available without services. Built-in backends require it; nats-discovery and etcd enable it automatically.

ServiceDiscovery is a second trait. It maps a service name to the instances that provide it.

BackendFeatureImplementsUse
FilesystemPeerDiscoveryservicesPeerDiscoveryDevelopment, tests, one host
FilesystemServiceDiscoveryservicesServiceDiscoveryDevelopment, tests, one host
NatsPeerDiscoverynats-discoveryPeerDiscoveryMore than one host, with NATS
EtcdServiceDiscoveryetcdServiceDiscoveryMore than one host, with etcd

A registration returns a guard. When the guard drops, the backend removes the registration.

A hub from the velo-hub crate is a discovery service for a whole application, with registration, liveness, and hosted extensions. A program uses it as its peer discovery through HubClient::velo_builder(), and as its service discovery through HubClient::services(). See The hub.

Without a discovery backend, register each peer by hand:

node_a.register_peer(node_b.peer_info())?;

Forget a peer

Velo::unregister_peer(instance_id) undoes register_peer. It drops the peer from every transport, closes the connections to it, and forgets its handler list. A send to the peer then fails as a send to a peer never registered does. A later register_peer, with the same id or a new one, starts fresh. Other peers are not affected. The call is synchronous, cheap, and idempotent.

node_a.unregister_peer(node_b.instance_id());

A restarted peer has a new instance id. So a long-lived node whose peers restart, such as a worker that serves many frontends, keeps the state of every peer it ever saw unless it calls this when a peer leaves.

Unregister only an idle peer. A send, stream, or handshake in flight to it may fail, and one that finishes after the call can register the peer again. A _hello handshake from the peer registers it again too. The messenger mux keeps small per-peer receive tables, and this call does not drop them.

The etcd backend uses leases. If a test process stops and does not revoke its lease, its keys stay until the lease expires.

The hub

The velo-hub crate builds a hub: one service that is the peer discovery for a velo application and also hosts extensions. A program builds the hub in main, adds extensions or bundles of extensions, picks a store, and serves it. velo does not depend on velo-hub. An application that does not use a hub picks any velo discovery backend, as before.

The crate has three cargo features. server builds the hub. client builds the hub client, velo_hub::client, without the server. test-support adds the store conformance suite and FaultStore, a store that fails on request. velo_hub::protocol (paths, request and response types, ids) builds under either feature, and with no feature. It is the velo-hub-ext crate, re-exported. velo-hub-ext depends on velo-ext and not on velo, so a program that only speaks the hub's wire protocol can depend on it and skip the velo runtime.

use velo_hub::{Hub, HubConfig};

let config: HubConfig = serde_json::from_str(&std::fs::read_to_string("hub.json")?)?;
let hub = Hub::builder(config)
    .velo(velo::Velo::builder().add_transport(tcp))
    .bundle(MyBundle::new())
    .serve()
    .await?;
hub.run_until_signal().await?;

The library reads no files and parses no flags. The program builds the HubConfig. examples/examples/hub.rs is a complete hub program: two bundles, one requiring the other, configured by examples/hub.json; --demo serves on free ports, runs a client against it, and stops. Without .store(...), the hub uses an InMemoryStore. Without .velo(...), the hub only does discovery: it does not probe registrants, and liveness is the store's TTL.

Listeners

A hub serves two HTTP listeners:

ListenerDefault portRoutes
public1337GET /v1/health, GET /v1/hub, peer lookups
control8337the core routes of the public listener, plus registration, unregistration, heartbeat, and probe

Extension routes are under /v1/ext/{name}/. An extension's public_routes go on the public listener and its control_routes on the control listener. The hub checks that each extension name is one URL segment of [a-z0-9_-], so an extension cannot add a route to a core path.

PathPurpose
POST /v1/instancesregister: { peer, extensions: { name: payload } }
GET /v1/instanceslist the registrations
DELETE /v1/instances/{id}unregister, with the credential
POST /v1/instances/{id}/heartbeatrefresh liveness, with the credential
POST /v1/instances/{id}/probethe hub sends a heartbeat to the instance over velo
GET /v1/peers/instance/{id}, GET /v1/peers/worker/{id}peer discovery

A successful registration returns a credential and an epoch. The credential goes in the x-velo-hub-credential header of every mutation that the registration owns. Each registration gets a new credential and a new epoch, and the previous credential stops working. Every error that a core route answers is an ErrorBody with a stable code, also for a body or a path that the hub cannot parse, and for a route that does not exist. A client acts on the code: busy means retry, and register_again means register again without a credential. Extension routes get the same behavior when they use the HubJson, HubPath, and Authorized extractors and return HubError.

Store

The hub keeps registrations in a HubStore. The hub's routes, its registration transaction, and its own velo instance all use the same store. The hub's velo instance uses the store as its PeerDiscovery. InMemoryStore is the store that ships. It expires an entry that nobody touched for the TTL (30 s by default).

The store contract:

  • Each write of an instance gets a new incarnation, greater than every earlier one of that instance. The hub and the extensions compare incarnations to tell a stale removal from a current one.
  • Writes, removals, and touches are conditional on the current incarnation. Work that runs later carries the incarnation that it saw, so it cannot change a later registration of the same instance.
  • The removal hook and its snapshot share one point in time. Each entry present at that point is in the snapshot, and the hook hears each later removal once.
  • The store reports its continuity. When the store is not Live, the hub refuses every credential with 503 and refuses new registrations. Peer lookups continue from the store's local view. A Fenced store (another hub holds it) stops the hub.
  • Every removal that a call causes or races reaches the removal hook before the call returns. The hub reads the hook's record right after a write, a read-back, or a remove.
  • A write can answer Indeterminate: a remote write can apply even when its answer is lost. The hub then asks the store which write produced the current record, by the write's op id.

The record holds the peer, the epoch, the extension payloads, a pinned flag for the hub's own entry, and SHA-256 digests of the credentials. The store never holds a credential, so a copy of the store does not give authority.

Each backend runs the conformance suite in velo_hub::store::conformance (feature test-support). FaultStore in velo_hub::store::testing is an in-memory store that fails, stops at a gate, or answers Indeterminate on request. It passes the same suite when no fault is set.

The etcd store

EtcdStore (feature etcd) keeps the registrations in etcd, under a prefix (/velo-hub/default by default), so they survive a hub restart.

  • An entry's incarnation is the etcd revision of its key. etcd revisions only grow, so incarnations do too.
  • Every write is one etcd transaction, conditional on the incarnation. When a write's answer is lost, the hub's read-back first writes a tombstone for the write's op id, and every write requires that tombstone to be absent. So a read-back that finds nothing is final: the write will not apply later.
  • The store watches its prefix and keeps a local copy, which serves the sync reads and the peer lookups. A call waits until the watch has applied its own transaction and run the removal hook for it, so the hook rule above holds.
  • One hub per prefix. The store takes a lock key on a short lease (10 s) when it connects, and every transaction checks that the lock is still its own. If the lock is lost, for example after a partition, the store is Fenced as soon as etcd tells it so, and the hub stops. A hub that cannot reach etcd at all learns this only when it reaches etcd again. It notices that it is cut off when its watch breaks: at once if connections are refused, and after up to about 9 s with the defaults if packets are dropped (the 5 s connection keep-alive plus the request deadline). From then on its store is Degraded: it refuses every credential, but it still answers peer lookups from its local view, which may be stale. Its lock can expire before that, two thirds to one lock TTL after the last renewal, so for a few seconds a second hub can take the prefix while the first one still accepts its credentials. A deployment with a standby hub must allow for that window. A clean stop frees the lock. After a crash, the next hub waits for the lock with wait_for_prefix.
  • Liveness does not use a lease per entry. A touch updates the local copy, and the store's reaper removes an entry that nobody touched for the registrant TTL (30 s). Every key of a hub is on one custody lease (5 min), kept alive by the store. So the entries of a hub that crashed stay for the custody TTL, which is how long a restarted hub can recover them. A lease per entry was rejected: it needs one etcd keep-alive stream per touch (about 1,000 a second for 10,000 registrants at the default interval), and a healthy registrant could expire after one late probe.
  • A watch that falls behind etcd's compaction loses history. The store is then Lost, reloads the prefix, reports every entry that went away in the gap as a removal, and is Live again. The hub needs nothing more: the store is fenced to it, so no other writer added an entry in the gap.

Measured against a local etcd 3.5 in Docker (release build, one call at a time): a put takes 2.8 ms and a remove 2.5 ms, against 2.2 ms for a plain etcd put; a touch takes 0.17 µs, because it does not reach etcd; a reaper pass that expires 10,000 entries takes 165 to 187 ms.

Recovery after a restart

A hub that starts on a store with registrations in it reserves each of them: nobody can claim the instance without a credential, and peer lookups keep working. It also tells its velo instance about each peer, so its probes reach them.

The registrant gets back in with its credential. The hub's heartbeat carries the hub's id, and the client notices that the id changed (HubClient::recovered()); a route the registrant calls with its credential answers recovered. The client then registers again presenting its credential. The hub checks the credential's digest against the stored record (the credential the registrant presented, or the one that registration minted, whose answer may have been lost), and the registration proceeds as a re-registration of the stored one, with a new credential and a new epoch. Extension state is not replayed: extensions see the owner as new. The registration carries the client's services, so they come back with it. A recovery that fails before its write (an extension refuses it) leaves the instance reserved, so the credential can try again. The same holds for a registration that presented a credential and failed after its write, when the hub could not remove the entry it wrote: the hub answers recovered, not register_again, so the client keeps the one credential that can recover the entry. The credential the registrant presented stays able to recover until the registrant registers once more, because the record keeps it for the case where the answer to that registration was lost.

Authority

Only the hub holds credentials. Authority::authorize(owner, credential) returns a Lease with the incarnation and the epoch of the registration that the credential belongs to. While a registration of that owner is in progress, authorize answers Busy.

A lease alone does not fence a mutation. A registration can start after authorize returns and before the mutation applies. So an extension keeps the epoch that it committed for each owner. It applies an owner's mutation only if the lease's epoch is equal to that epoch, and it does this check under its own lock, in the same critical section as the write.

authorize takes a read lock for one hash lookup and a 16-byte compare in constant time, plus one atomic load for continuity. The Authorized axum extractor does this for a route with an {instance_id} path parameter.

Extensions

An extension implements Extension. It has a wire name, a payload type, a staged type, the extensions it requires, and these hooks:

HookWhenRule
admitbefore the registration lockreject a payload that does not fit the hub's config
prepareunder the lock, before the store writecan fail; can only hide or revoke, and the staged value undoes that when it drops
commitafter the writecannot fail
on_registeredafter the credential is liveruns under step_timeout_ms and is dropped at the deadline; spawn work that awaits
on_removedwhen an incarnation leaves the storedrop the owner's state if it belongs to that incarnation or an older one

Each extension sees each registration. The payload is None when the registrant did not opt in. Then commit drops what the owner had before. commit, on_registered, and on_removed all run under one hub-wide registration lock, so nothing changes extension state between prepare and commit.

requires sets the attach order and the start order. At attach, AttachContext::extension::<T>() returns an extension that is already attached. It fails if T is not in requires, so a missing declaration fails at once.

A bundle (Bundle) adds a set of extensions and can require other bundles. A bundle never builds another bundle: the program adds each bundle once, and two bundles that require a third share one instance of it. A bundle reads its own section, bundles.{name}, of HubConfig.

The hub checks the graph before it binds a socket. These are errors: two bundles or two extensions with one name, a required bundle or extension that is not added, a cycle, a name that is not one URL segment, and a config section that no bundle owns.

Registration

handler   spawn the transaction task, and wait for it
task      decode payloads; check registration_requires; admit           400
          take the lock (503 after lock_timeout_ms); begin               401, 409 busy
          prepare each extension, each under step_timeout_ms
            failure: abort; the prior registration is not changed
          write to the store, under step_timeout_ms
            Indeterminate or timeout: resolve the write by its op id
          record the write; velo register_peer; commit each extension;
          commit the credential; on_registered each extension; unlock     200
            any failure after the write: remove the new entry,
            on_removed on each extension, forget the velo peer,
            refuse the credential                                        409 register_again

The transaction is a task of the hub. hyper drops a request handler when its client closes the connection. If the handler ran the transaction, a disconnect could leave the owner busy for ever. Decoding and admit run in the task as well, so a panic in any extension hook answers 500.

Each store call that the hub awaits (under the lock, in a request such as the HTTP heartbeat, or in the heartbeat task), and each on_registered, runs under step_timeout_ms. A store or an extension that never answers cannot hold the lock. A remove that times out counts as indeterminate: the hub reads the store to learn whether it applied.

A failure before the store write leaves the prior registration exactly as it was. A failure after the write removes the registration, including the prior one, and the client registers again. The alternative is to write the prior registration back. kvbm-hub did that, but the restore is a second write that can fail too, and it gives the registration a new incarnation.

If an extension panics, the hub settles the owner. Before the write, it aborts. After the write, it refuses the credential and queues the removal of the entry.

When the hub runs velo, it decodes the peer's address before it takes the lock and answers 400 if the address is not valid. Without velo, the hub stores the address and does not read it.

Removal

The store calls the hub's removal hook when an entry leaves: an unregistration, a TTL expiry, or a failed heartbeat. The hook revokes the owner's authority at once and puts the removal on a queue. It never waits for the registration lock. A lifecycle task takes the lock and delivers the removal: on_removed on each extension in reverse start order, then it forgets the velo peer, then the owner can register again. Until then, the owner's requests answer busy.

The lifecycle task forgets the velo peer only if no later incarnation replaced it. So a late delivery does not disconnect a replacement.

DELETE /v1/instances/{id} takes the lock and delivers the removal itself. When it answers, each extension has dropped the owner. The hook also queued a delivery, and that second delivery changes nothing. Like a registration, the unregistration runs as a task of the hub, so a client that disconnects cannot leave the owner busy.

Heartbeat

When the hub runs velo, it sends a heartbeat to each registration every heartbeat_interval_ms (10 s by default). The registrant answers on the velo_hub_heartbeat handler. Each probe times out after one interval. After heartbeat_max_failures (3 by default) failures in a row, the hub removes that incarnation. A success touches that incarnation. An answer with ok: false counts as a failure: a registrant answers that while it holds no registration with this hub, for example after the response to its registration was lost. The hub committed that registration, but nobody holds its credential, so the registrant could never register again while its probes kept the entry alive. The hub does not probe an instance while its registration transaction runs: the registrant has no answer yet, so it cannot tell its new registration from one it never got, and a slow but conforming on_registered would let the probes remove the entry before the answer arrives. Probes run in parallel, so a stopped peer does not delay the others. The hub does not probe its own pinned entry. Without velo, liveness is the store's TTL.

The heartbeat task holds a WeakVelo. Extension handlers and tasks that use the hub's Velo must also hold a WeakVelo, and upgrade it on each call. A handler that holds a Velo is in that instance's own handler registry, so the instance holds itself and is never dropped.

Client

A program that uses a hub builds a HubClient (feature client), builds its Velo from the client, and registers:

use velo_hub::client::HubClient;

let hub = HubClient::builder().host("hub.example").build()?;
let velo = hub.velo_builder().add_transport(tcp).build().await?;
hub.register(&velo, Default::default()).await?;
// velo now finds every other registered peer through the hub.
hub.unregister().await?;

velo_builder() sets the hub as the velo discovery. velo resolves a worker target through it, and Velo::discover_and_register_peer resolves an instance id through it.

register(&velo, extensions) installs the heartbeat handler on velo first, so the hub's probes get an answer as soon as the hub answers the registration. After the registration, register registers the hub's velo peer with velo (velo does not ask discovery for a call to an instance id). It does this again for a new hub after a restart, and if this step fails, it logs a warning and keeps the registration. register_peer(peer, extensions) registers a PeerInfo without a handler, for a hub without velo. A client holds one registration. A second register of the same peer presents the credential, and the hub replaces the registration: a new credential, a new epoch, and the old credential stops working. If the hub answers unauthorized or register_again, the client drops the registration it holds, and the next register starts again without a credential.

The client's heartbeat handler answers ok: true only while the client holds a registration or is getting one. So when the response to a registration is lost, the hub removes the registration that nobody can use after heartbeat_max_failures probes, and the client can register again. A response is lost when the network drops it, and also when the caller drops the register future (a timeout around it): the hub runs the registration as its own task and commits it anyway. A guard clears "getting one" on every exit from register, also when the future is dropped, so the handler never answers ok for a registration the client does not hold. A re-registration that gets no answer from the hub fails closed. No answer means anything but the hub's own response or a request that never left: its future is dropped, its request times out or breaks after it was sent, or a proxy in front of the hub answers 502 or 504. Then: the hub may have committed it and rotated the credential, so the client drops the registration it held, and the hub frees whatever it holds after heartbeat_max_failures probes. A register without a credential that the hub refuses with unauthorized means the hub holds a registration of this instance that the client never got. Until a register succeeds, the handler then answers not ok even during a call, so a caller that retries in a loop cannot keep that registration alive. The retry gets in after about heartbeat_interval_ms times heartbeat_max_failures (30 s by default).

An answer that says the hub does not accept the credential (unauthorized, register_again, on any call, also an extension call) drops the registration the client holds, and so does not_found for a heartbeat or an unregister. So is_registered() tells the truth again, and the next register starts without a credential.

The client is a cheap handle; its clones share the one registration. Dropping the last clone sends DELETE in the background on the current runtime, as a best effort: without a runtime, or when the runtime shuts down first, the request is not sent, and the hub's probes or the TTL remove the entry. A program that can await calls unregister. If DELETE fails, the client keeps the registration, so a retry or the drop sends it again.

velo keeps its discovery for as long as it lives, so the discovery that velo_builder() installs is a separate handle, HubDiscovery, that holds no registration. If velo held the client itself, the registration would live as long as velo does, and dropping the client would not unregister. The heartbeat handler and ExtClient hold the registration weakly for the same reason.

client.ext(name) makes JSON calls under /v1/ext/{name}. get and post use the public listener. control_get, control_post, and control_delete use the control listener and send the registration's credential; once every HubClient clone is dropped, they answer NotRegistered. A bundle crate builds its typed client on it. Every error that the hub answers is a ClientError::Hub with the hub's ErrorCode, so a caller acts on the code.

The default HTTP client has a 5 s connect timeout and a 60 s request timeout. A registration on a hub with default settings and up to two extensions of its own takes at most 50 s: 5 s to wait for the lock, and 5 s for each step under it (the built-in service directory counts as an extension). A caller that passes its own reqwest::Client sets its own timeouts.

An extension can also serve velo handlers on the hub's velo instance, and its client calls them with velo requests, not over HTTP. velo puts no deadline on a velo request, and the hub sends each reply through its own peer table. When the hub removes a registration, it forgets the velo peer. After that, a velo request from that instance gets no reply.

So a client that makes velo calls to the hub sets .timeout on each call. It reads RequestTimeout as "the hub can have forgotten this instance", and it calls register again. That call presents the credential that the client holds. If the hub answers register_again or unauthorized, the client drops the registration, and the next register starts without a credential. is_registered() does not show an eviction, because only an answer from the hub changes it. recovered() shows only a hub restart.

Services

A registered instance can serve named services. Another program asks the hub which instances serve a name, or watches the name. A service name is 1 to 128 of A-Z a-z 0-9 . _ -, and not . or .., so it is always one path segment and a URL resolver cannot turn it into another route.

RouteListenerUse
GET /v1/servicesbothnames that have at least one instance
GET /v1/services/{name}boththe instances of one name
GET /v1/services/{name}/watchbothserver-sent events: initial, then added and removed
PUT, DELETE /v1/services/{name}/instances/{id}controladd or remove an instance, with that instance's credential

The services are a built-in extension, named services, first in the start order. So a service entry follows the same transaction and removal rules as an extension's state: it goes when its registration goes (unregister, expiry, or heartbeat eviction), and there is no second cleanup path that could leave an orphan. A registration may carry the names the instance serves, as the services payload: the hub then sets the instance's services to exactly those names. Without the payload, a re-registration with the credential keeps the instance's services, because the peer still serves them, and a registration without a credential starts with none. A PUT or DELETE applies only if its lease's epoch is the one the directory committed, under the directory's own lock. The hub does not list the built-in in GET /v1/hub, and no other extension can take its name.

A watch takes its snapshot and subscribes in one step, under the directory's lock, so a watcher never misses a change or sees one twice. A watcher that falls more than 128 changes behind gets disconnected, and its stream ends: its set is stale, so it watches again. At shutdown the directory's shutdown hook ends every watch stream, before the hub drains its listeners, so an open watch does not hold the drain. The hub sends a keep-alive comment every 10 s. A watch of a name that nobody serves costs the hub a small entry and a 128-slot event ring until the watch ends.

The client implements velo's ServiceDiscovery with client.services(). client.register_service(name) returns a guard; the client holds one guard per name and refuses a second. The client keeps the names it serves, and every registration carries them, so after any registration (fresh, re-registered, or on a hub that restarted) the hub's set is the client's, with no request per name. Between registrations, guards change only the client's set, and one writer, sync_services, sends the changes one request at a time while no registration runs. So two requests from one client never race. A request the hub refused is answered; one with no answer from the hub (a dropped future, a timeout, a proxy's 504) leaves its name unsettled, and the next pass sends it again as the client's set says. So a guard never removes another guard's entry, and the hub does not keep an entry nobody serves. register answers Ok once the registration is held. A failure to send a change made during the call is left for sync_services, which the caller can also run to retry. Dropping a guard stops serving the name, in the background.

Services are kept in the hub's memory, like extension state. A hub that restarts with a persistent store restores peer discovery only; the client's next registration carries its services back.

Startup and shutdown

HubBuilder::serve does these steps in this order:

  1. Check the bundle and extension graph and the config. This step does no I/O.
  2. Bind the two listeners.
  3. Build the hub's velo instance, with the store as its discovery.
  4. Write the hub's own pinned entry.
  5. Attach each extension, in dependency order.
  6. Start each extension, in dependency order. Then build both routers, which runs each extension's route code.
  7. Install the store's removal hook, and reserve each entry that the store already holds.
  8. Start the reaper, the heartbeat, the lifecycle task, and the HTTP servers, then accept registrations.

If a step fails or panics, the hub undoes each earlier step in reverse order. Step 1 also refuses a config with a zero interval or timeout. The removal hook cannot be removed, so the hub installs it last. A store given to a hub that failed to start can go to another hub.

Hub::shutdown stops new registrations, waits for the registration that holds the lock, stops extension background work, delivers the queued removals, shuts each extension down in reverse order, and stops the heartbeat and the reaper. Then it releases the lock, so a request that waits for it answers 503 at once. It closes both listeners and waits up to shutdown_timeout_ms for the requests in flight. Then each request still in flight answers 503 and its handler is dropped, and the hub waits up to shutdown_timeout_ms again for the connections to close. So the listener drain can take two times shutdown_timeout_ms. The wait for the lifecycle task, each extension's shutdown, each hub task, and the velo drain also get shutdown_timeout_ms each. The wait for the lock gets at least as long as a registration can hold it: a prepare and an on_registered for each extension, and a write, a read-back, and a remove, each step_timeout_ms. So only a sync hook that blocks its thread, against the extension rules, can make the stop go on without the lock. Then it delivers no queued removal, because without the lock on_removed could run while that hook still runs. Last, it removes the pinned entry, closes the store (an etcd store frees its prefix, so the next hub can take it over), and shuts the hub's velo instance down. When the drain finishes within its budgets, the listener ports are free when it returns. If a connection outlives both budgets, the hub aborts its server task, and the port is freed when that abort takes effect.

The stop runs as its own task. A caller that stops waiting does not stop it, and a second call waits for the same stop. Dropping the last Hub clone runs the same stop in the background. serve also runs as its own task: if its caller stops waiting, startup finishes and the new hub stops in order.

Cost of the request paths

Two reads are on the request path: Authority::authorize, for each owner-scoped mutation, and the peer lookup. Both read a sharded copy of the state, and the writers update that copy while they hold the one lock that orders all writes. A design with the readers on the writers' RwLock was measured first and rejected: every read writes the lock's cache line, so more threads gave fewer calls in total.

Setup: release build, 10,000 owners or peers, 2,000,000 calls spread over the owners, a 20-core host. The tests are authorize_cost and lookup_cost, run with --ignored --release --test-threads=1. The "one RwLock" column is the same two tests run on the code before the sharded copy existed.

Read1 thread8 threads8 threads, one RwLock
authorize37 ns per call153 ns per call, 52 M calls/s in total847 ns per call, 9.4 M calls/s in total
discover_by_instance_id58 ns per call207 ns per call, 39 M calls/s in total995 ns per call, 8.0 M calls/s in total

get and list copy each record with its payloads. With a 2 KiB payload, get took 248 ns, and list took 14 to 19 ms for 10,000 entries. The heartbeat uses peers(), which does not copy payloads: 0.3 ms for 10,000 entries.

A write holds the store's write lock and a shard of the copy, so its cost must not grow with the number of entries. With 10,000 entries (write_cost), a put takes 1.0 µs and a remove 0.6 µs, and a reaper pass that expires all 10,000 takes 7.2 ms. An index update that scanned every entry was measured first and rejected: 21 µs for each put and each remove, and 109 ms for that reaper pass, all of it with the write lock held.

Versioning

velo-hub and velo-hub-ext take the workspace version and depend on each other and on velo with a caret on that version. See Versioning.

Work queues

Work queues require the default services feature. The queue-messenger and nats-queue backend features enable it automatically.

A work queue is a named queue of typed work items. You create or connect to a queue by name, and then you get a typed sender and a typed receiver for it.

use velo::queue::{backends::memory::InMemoryBackend, receiver, sender};

let backend = InMemoryBackend::new(1024);
let tx = sender::<Job>(&backend, "my-jobs").await?;
let rx = receiver::<Job>(&backend, "my-jobs").await?;

tx.enqueue(&Job { id: 1 }).await?;
let job = rx.next().await?.unwrap();
BackendFeatureDescription
InMemoryBackendservicesDashMap and flume channels. For tests.
MessengerQueueBackendqueue-messengerAn actor on a Velo instance, reached with active messages
NatsQueueBackendnats-queueNATS JetStream with WorkQueue retention

Shutdown and drain

Velo::graceful_shutdown(policy) stops an instance in four phases. With ShutdownPolicy::WaitForever, it does not lose a request that it already accepted. With ShutdownPolicy::Timeout(d), teardown drops any accepted request that is still queued when d expires.

sequenceDiagram
    participant App
    participant Velo
    participant Adapter as TransportAdapter
    participant Peer
    App->>Velo: graceful_shutdown(policy)
    Velo->>Adapter: 1. Gate: begin_drain()
    Peer->>Adapter: new request
    Adapter-->>Peer: ShuttingDown (request header echoed)
    Velo->>Velo: 2. Drain: wait until in-flight = 0 (or the timeout)
    Velo->>Velo: 3. Teardown: cancel tokens, stop transports
    Velo->>Velo: 4. Close: wait until each transport's close is on the wire
  1. Gate. The drain flag goes up. New inbound requests are refused. Responses, acks, events, and the messages of open streams continue to flow, so in-flight work can finish.
  2. Drain. Velo waits until no admitted request is in flight. ShutdownPolicy::WaitForever waits with no limit. ShutdownPolicy::Timeout(d) bounds the drain at d. The drain counts requests in flight, not the payloads that the messenger staged for messages over the eager budget: a consumer that has not pulled one yet loses it at teardown (known limitation). Joining the mux's batchers adds up to 0.5 seconds, the time a batcher stalled on a slow peer may take to send what it holds, and the close step adds the close bound of each transport (QUIC: 2.5 seconds; TCP and UDS: their teardown grace, 1 second unless the builder set it, plus 1.5 seconds). Teardown adds the time that the shutdown hooks of the transports take. A hook may block, so d does not bound it. The ZMQ hook takes at most about 6 seconds: up to 5 seconds for a send already under way to a peer that has gone, and up to 1 second for the frames that were queued before the stop. ZMQ reports each frame that it cannot send in that second as failed. Unless a hook blocks, the whole call takes at most d plus these bounds.
  3. Teardown. Velo cancels the tokens and stops the transports. Each transport's shutdown hook runs once, on a dedicated thread, and the call waits for all of them. (A hook runs on the caller instead if the thread cannot be created, and on the building task if the build fails or is cancelled while transports start.) If a hook panics, the other hooks still run, and the call then panics: it cannot report the instance, or its RDMA memory, as released. A later shutdown call panics at once, without draining again.
  4. Close. Velo waits for Transport::closed() on each transport. TCP and UDS first wait for their connection writers, for at most their teardown grace plus 0.5 seconds. At teardown, each writer finishes the write in progress and writes the frames already in its channel, until the grace has passed, and then fails the rest. The grace is 1 second unless the transport's builder set it (teardown_flush_grace on TcpTransportBuilder and UdsTransportBuilder). It covers only what is already queued when teardown starts (known limitation). A writer that is still dialing its connection keeps dialing until the same deadline, so the frames queued behind the dial are written too. The transport admitted those frames, and a sender can act on that at once: the messenger mux reports a stream sent when the batch that ends it is admitted. TCP and UDS do not wait for what they wrote, because the kernel delivers it after the process exits. Then they wait until their accept loop has closed its listening socket, for at most 1 second, so that no peer can connect after graceful_shutdown returns. The loop stops at its next poll, so the wait runs out only when nothing drives the runtime that runs it. In that case the transport logs a warning, and the socket can stay open until the loop runs. QUIC keeps written data in user space until the peer acknowledges it, so it waits up to 2 seconds for its writers to finish their streams and its connections to close. Then it closes the rest by force and waits up to 0.5 seconds more, so that each frame a writer still held is reported as failed. A frame still queued at teardown is failed (known limitation). A frame that quinn accepted but the peer did not acknowledge is lost, as a frame in the kernel send buffer is lost when a TCP peer stops reading. The writer logs a warning when that can happen. The exception is an application close from the peer: a peer that closes has gone, as a TCP peer that closes has, so that end is not a warning. A process can exit when graceful_shutdown returns.

Velo is Clone. If two clones call graceful_shutdown at the same time, the first runs the sequence and the second waits for it.

Shutdown does not remove handlers. A handler that holds a Velo clone of its own instance keeps that instance alive, so its final drop never runs. A handler that needs its own instance holds a WeakVelo from Velo::downgrade and calls upgrade on each request.

Work that was accepted before the drain keeps flowing through it. The messenger mux sends its records and its credit as active messages, so the gate lets through the handlers that serve accepted work:

  • The mux batch handlers (_stream_batch, and _stream_batch.1 to _stream_batch.15, one for each lane), which carry the records and credit of open mux streams.
  • _stream_cancel and _stream_stop, the detach, finalize and cancel handlers of SPSC anchors, and the detach and cancel handlers of MPSC anchors.
  • _anchor_renew, which extends a reservation that this node already made. It opens no stream.
  • The rendezvous handlers that pull a staged payload and end its lease (_rv_acquire, _rv_pull, _rv_detach, _rv_release, _rv_lease_renew). A record or response too large for one message is staged, and the receiver pulls it with these handlers. A draining owner answers the pull chunked, never by RDMA. _rv_metadata and _rv_ref start a new consumer, so the gate refuses them. A handle that an application staged itself can still be pulled with get during the drain.
  • The event handlers _event_trigger, _event_trigger_request and _event_subscribe. _event_trigger completes an awaiter of work that this node already accepted. _event_trigger_request completes an event that this node already created, and it acknowledges the requester. The requester sends it fire-and-forget, so a refusal would never reach the requester's wait. _event_subscribe answers at once for an event that is already complete, or records one subscriber for a pending event. None of the three starts new work.

Each handler declares its exemption in the code where it is registered, so the gate cannot disagree with the code. The list above is kept by hand. An attach opens a new stream, so the gate refuses it. A detached SPSC anchor therefore waits out its unattached timeout during a drain, because no new sender can attach. A zero-RTT stream is not an attach: its first record opens the slot that prebind_anchor bound, during the drain as well. prebind_anchor does not check the drain, so a node that keeps making pre-binds while it drains keeps accepting streams.

The drain counts an exempt message while its handler runs. It does not count the stream that the message serves. This has two results:

  • The drain finishes at the first moment that no message is in flight. A stream message is in flight only for microseconds, and consecutive batches are at least a credit round trip apart. An open stream, busy or quiet, therefore does not hold graceful_shutdown open.
  • Velo then stops and joins mux sends before transport teardown. Ingress slots stay in place until full shutdown detaches their readers, so orderly shutdown does not report a false sender-drop error. The shutdown timeout bounds the drain; joining mux tasks comes after it, takes up to 0.5 seconds more for a batcher stalled on a slow peer, and needs the owning Tokio runtime to keep running.

To let open streams finish, call begin_drain, wait until your streams end, and then call shutdown. The application must wait for its streams: the messenger drain does not count their full lifetime.

Close an instance while Tokio keeps running

Use Velo::shutdown(policy) when an application removes a Velo instance but keeps its Tokio runtime. It first runs graceful_shutdown, then cancels live anchors and senders, stops the builder-owned per-stream listener, and joins the messenger receive loops and streaming tasks. Pending remote event waits fail at teardown, and their subscription tasks stop. Local event completion remains available. Custom frame transports remain the caller's responsibility.

If the sender of a stream is on the same instance, shutdown cancels that sender. The cancellation_token of the sender fires, and later sends fail. The reader of the stream ends when the application drops or finalizes the sender. Shutdown does not end the reader itself. That needs a check on every read.

Call shutdown explicitly to drain and join. Final Velo drop cancels its streams and stops builder-owned streaming services. It also tells each remote producer that its stream ended, while the transports are still up: an attached producer through _stream_cancel, and a zero-RTT producer through its mux slot close. This is best effort. It needs the Tokio runtime to keep running, a stalled peer gets the slot close only if it admits it within 0.5 seconds, and without a retained Messenger the transport can close before the notice is written. A producer that is told nothing waits on its next send. When remote producers must be told, call Velo::shutdown and end their streams first. A retained Arc<Messenger> keeps active messaging available, but does not keep those streams alive. Final Messenger drop starts transport teardown on an owned thread. Explicit shutdown waits for this cleanup, including native transport joins, before it waits for transport close. Neither Drop path waits for work to finish or reports RDMA memory as released. See Ownership changes in 0.19.

graceful_shutdown keeps its existing behavior: it drains and closes the messenger and RDMA services, but does not close the per-stream TCP or gRPC transport. shutdown is the complete instance shutdown operation. Application handlers that exceed a timeout can still be running after it returns.

The public task tracker includes receive loops, ordering lanes, and tasks added by the application. close() allows wait() to finish when all tracked tasks exit; it does not cancel them. Ensure application tasks and idle ordering lanes can exit before waiting. An ordered handler with with_idle_lane_ttl(None) can leave a lane waiting forever while its router remains owned. Internal shutdown joins its own receive loops separately, so an application task cannot extend its timeout.

A handler panic fails its waiting caller in every dispatch mode, provided the program unwinds panics. The error reply remains counted work until the transport accepts it. An ordered lane continues with the next message.

Teardown does not interrupt a blocked TCP or UDS write, or a dial in progress, at once. The writer finishes it and writes the frames already in its channel, until the transport's teardown grace has passed (1 second unless its builder set it). At that deadline it fails the write and the frames that are left. A connection that closes alone, because its peer was unregistered or closed the socket, fails its frames at once. The connection is discarded if a write may be partial. A reported write failure does not prove that the peer received no bytes; applications must not treat it as permission to retry a non-idempotent request.

A refused request fails fast

A transport that has a return path answers a refused request with a MessageType::ShuttingDown frame. The frame echoes the header of the rejected request, so the sender can find the waiting caller and fail it at once. Without the echo, the caller waits for its own timeout. The echo uses the request header, not a response header, so ShuttingDown frames have their own inbound stream.

gRPC has no return path on its client-side read half. There, the transport records the rejection and drops the frame.

Admission owns the in-flight count

The rule is: a message on the inbound queue is counted work. TransportAdapter::admit_message is the only way onto the inbound queue. It takes the in-flight guard first, and then it reads the drain flag. The queued message carries the guard, and the guard is not optional.

This count starts at server admission. It does not include requests still in a client or network queue. To settle a known set of client calls before shutdown, call begin_drain, keep the server alive until those calls finish or reach their deadlines, then call shutdown.

Each caller must bound its response wait, for example with tokio::time::timeout. A peer disconnect does not complete every outstanding response slot. A request admitted before peer failure can therefore wait until its caller's deadline. Dropping its response awaiter releases the slot.

Two faults made this the rule:

  • The consumer took the guard after it dequeued the message. A message in the queue was then invisible to the drain. The fast path of graceful_shutdown does not yield, so it could finish inside the gap between enqueue and dequeue. The handler then ran after the instance had declared itself stopped.
  • A transport that checked is_draining() before it enqueued had a check-then-act race. A request could pass the check, the drain could start and finish, and then the request was enqueued.

is_draining() is for reports only. Never use it to decide admission.

The admission check is a store-buffer pattern. The producer increments in_flight and then loads the flag. Shutdown stores the flag and then loads in_flight. All four accesses must be SeqCst. With Acquire and Release, both sides can miss the store of the other side.

A consumer that stops with messages still in its queue must drain the queue and drop the messages. flume keeps the buffer alive while any sender clone is alive. Guards left in the buffer hold in_flight above zero, and every later drain waits forever.

These tests pin the contract:

BehaviorTest
A queued message holds the drain openqueued_message_holds_drain (velo-ext)
Admission refuses during drain and returns the frameadmit_message_rejects_during_drain
The drain waiter does not lose a wakeup when a guard drops at the checkwait_for_drain_survives_guard_dropped_at_the_check
The ShuttingDown echo reaches the sender over TCP, UDS and QUICtransport_shutdown_tests! in lib/velo/tests/transports/common/mod.rs
TCP and UDS write the frames admitted before teardown, until the teardown deadlineadmitted_frames_are_written_across_teardown in lib/velo/tests/transports/common/shutdown_scenarios.rs
A frame admitted while its connection was still dialing is writtena_frame_admitted_while_dialing_survives_teardown in the same file
The teardown write stops at its deadline when the peer does not reada_stop_that_keeps_the_queue_ends_at_its_deadline
closed() waits for a writer blocked on its peer, until the teardown deadlineclosed_waits_for_a_writer_blocked_on_its_peer
The builder's teardown grace is what the writers get, longer or shorter than the defaultthe_teardown_grace_is_the_builders
A per-stream TCP pump reports Sent once it wrote the terminal, Discarded when the socket goes firstthe_pump_retires_the_stream_once_it_wrote_the_terminal
A writer stopped by an unregister, or by a peer that closed the socket, gives up its queued frames at once; only a stop at transport teardown writes thema_stop_is_teardown_only_once_the_transport_tears_down
The echo completes the waiting callerdrain_rejection_echo_completes_awaiter

RDMA registrations go first

When the RDMA registration layer is installed, shutdown has four steps:

  1. The gate closes. No new request can start. The pull of a payload staged before the drain still passes the gate, but a draining owner answers it chunked, never by RDMA.
  2. The registry sweep runs. New registrations are refused, in-flight transfers drain, and each region and arena is unmapped. Anything staged in registered memory first moves to the heap, so an admitted chunked transfer can finish.
  3. The messenger gate, drain, teardown, and close run as usual.
  4. Each registration that survived step 2 is declared released, but only if the backend reports that nothing is still registered.

The order of steps 1 and 2 matters. An RDMA GET is issued by the NIC of the peer, so it never shows in the in-flight count of this instance. If the transport stopped first, Velo would unmap memory that a peer is still reading.

Step 4 makes RegionGuard::deregistered safe to wait on. After an abnormal teardown, such as a progress thread that panicked, step 4 declares nothing. That memory leaks on purpose, because Velo does not tell a caller to free pages that it cannot prove are released.

Under ShutdownPolicy::Timeout, the sweep and the messenger drain share one deadline. Under WaitForever, the sweep still uses RdmaConfig::shutdown_timeout, so a peer that crashed during a transfer cannot stop shutdown forever. See Rendezvous and RDMA.

Rendezvous and RDMA

Rendezvous moves a large payload by handle. One worker (the owner) stages the bytes and gets a compact DataHandle. The owner passes the handle to consumers by any means: a message field, an event, or a typed response. Each consumer then pulls the bytes from the owner. The consumer drives every transfer. The owner only answers.

The bytes move on one of two paths. The chunked path works everywhere and pulls the payload in 512 KiB messages. The RDMA path lets the consumer's NIC read the owner's memory with one UCX ucp_get_nbx call. The owner decides the path for each transfer, and a caller writes the same code for both.

The RDMA path needs the ucx feature on Linux. See Run rendezvous over RDMA for the build and the runtime setup. See RDMA performance for measured numbers, and RDMA design for why the design has this shape.

The handle API

CallSideEffect
register_data(bytes)ownerStage bytes in plain memory. Refcount 1.
register_data_pinned(&[u8])ownerCopy bytes into RDMA-registered pool memory. Never fails.
register_data_in_region(&guard, range)ownerStage a range of caller-registered memory with no copy.
metadata(handle)consumerSize, refcount, and pinned. Takes no lock.
get(handle)consumerTake a read lock, move the bytes, return (Bytes, lease_id).
get_pinned(handle)consumerSame, but return a PinnedBuf in registered memory with no copy out.
get_into(handle, &mut dest)consumerSame, into a caller buffer. A PinnedWriter destination gets a zero-copy GET.
ref_handle(handle)consumerAdd one reference, for one more consumer.
detach(handle, lease)consumerDrop the read lock. The handle stays usable.
release(handle, lease)consumerDrop the read lock and one reference. The owner frees the slot at zero.

A handle is a u128: the owner's WorkerId in the upper 64 bits and a local slot id in the lower 64. Serde writes it as {hi, lo} because MessagePack has no clean u128 encoding.

The owner registers seven handlers: _rv_metadata, _rv_acquire, _rv_pull, _rv_ref, _rv_detach, _rv_release, and _rv_lease_renew. The control plane uses JSON over ordinary messenger calls, so any transport carries it.

One transfer, both paths

_rv_acquire takes the read lock and chooses the path in the same round trip.

sequenceDiagram
    participant C as Consumer
    participant O as Owner
    participant NIC as Consumer NIC
    C->>O: _rv_acquire {handle, rdma: offer}
    alt owner answers RDMA
        O-->>C: Rdma {lease_id, descriptor, lease_timeout_ms}
        C->>NIC: ucp_get_nbx(addr, len, packed rkey)
        NIC->>O: one-sided read of owner memory
        loop every lease_timeout / 2 while the GET runs
            C--)O: _rv_lease_renew {handle, lease_id}
        end
        alt GET or decode fails
            C->>O: _rv_detach {lease_id}
            C->>O: _rv_acquire {handle, rdma: none}
            O-->>C: Ready {...}
            C->>O: _rv_pull (chunked)
        end
    else owner answers chunked
        O-->>C: Ready {lease_id, transfer_id, chunk_size, chunk_count}
        loop chunk_count times
            C->>O: _rv_pull {transfer_id, index}
            O-->>C: chunk bytes
        end
    end
    C->>O: _rv_detach or _rv_release {lease_id}

Both answers end in the same detach or release. The owner checks the registration again at every acquire, so no cross-node invalidation protocol exists.

When the owner answers RDMA

The owner answers AcquireResponse::Rdma only if every check below passes. The checks run in this order. Each refusal increments velo_rendezvous_rdma_path_total{path="chunked", reason=...} on the owner.

#CheckReason label on refusal
1The consumer sent an RDMA offer.no_offer
2This instance has an RDMA registry (UCX was added with add_ucx_transport).not_configured
3The RDMA path is enabled on this owner (the kill switch is off).kill_switch
4The slot is staged in registered memory.not_pinned
5The payload is at least rdma_min_bytes (64 KiB by default).below_min
6The offer names the backend this owner serves ("ucx").no_offer
7The owner is not draining. The shutdown sweep that follows a drain frees pinned memory.draining
8The staging still exists and the descriptor encodes.not_pinned

Check 7 comes after the facts about the configuration and the slot, so draining counts only the acquires that the drain changed. A heap-staged slot on a draining owner still counts as not_pinned. Check 8 catches an external region that was deregistered under the slot.

A lease answered with a descriptor always gets a deadline. A chunked lease gets one only when its consumer asks for it (see What a fetch that gives up leaves behind).

What the consumer offers

The consumer builds its offer from three local facts. It asks the owner nothing.

  • This instance has an RDMA registry.
  • The kill switch on this instance is off.
  • The owner is registered on a transport whose key matches the registry backend.

The GET uses this consumer's own UCX endpoint to the owner. So "UCX is registered for that peer" is the whole condition. UCX does not have to be the primary transport. The expected deployment is a TCP control plane with UCX beside it.

The rule has a cold-start effect. A peer that is not registered yet translates to nothing. The first get to an unknown owner therefore offers nothing and pulls chunks. Later gets take the RDMA path.

Fallback is a routing decision

After the owner sends a descriptor, four failures can occur on the consumer. They map to three reason labels:

FailureReason label
The descriptor does not decode, or it names a backend this consumer does not have.decode_error
The consumer cannot get a registered destination buffer.pool_exhausted
The GET fails.get_failed

For each failure, the consumer detaches the lease, acquires again with no offer, and pulls chunks. This always works, because a pinned slot also serves the chunked path.

The fallback happens exactly once. The second acquire carries no offer, so an owner that answers it with another descriptor is broken. The consumer returns an error in that case. A retry loop turns one bad owner into an unbounded storm of round trips.

A pinned slot is never RDMA-only. Old consumers, consumers without UCX, consumers below the threshold, and consumers whose GET failed all read the same slot by chunks. The owner does not need to know in advance which kind of consumer it talks to.

Staging on the owner

register_data_pinned

This call copies the bytes into a pool buffer that the NIC can read. It returns a DataHandle, not a Result. If the pool cannot give memory, the call stages the bytes in plain memory and records why:

  • The registered-bytes budget is spent: budget.
  • The pool cannot allocate for another reason: pool_exhausted.
  • The kill switch is on: kill_switch.
  • The instance has no UCX transport: the data goes to plain memory.

Zero-length data always goes to plain memory, because a GET has nothing to transfer. The call always costs one copy. The zero-copy form is register_data_in_region over memory that the caller registered.

register_data_in_region

This call stages a range of memory that the caller already registered with register_external_memory or register_owned. The range is relative to the registered pointer, not to the page-rounded effective range. A caller therefore cannot name a byte that is inside the registration but outside its own allocation.

The call returns a Result. A fallback to plain memory copies bytes that the caller asked velo not to copy. The kill switch does not affect staging here. It only decides whether _rv_acquire answers with a descriptor.

The slot holds an in-flight guard on the region. RegionGuard::unregister therefore waits for the anchors inside the region before it unmaps. The slot takes the guard first and then reads the region state. The reverse order leaves a gap in which the whole gate, drain, and unmap sequence can run.

Chunked reads of an external slot never form a Rust reference into the region. A peer holding the key can write the region at any time. The read copies with ptr::copy_nonoverlapping into a fresh buffer, under the region's copy gate, 512 KiB at a time. The copy gate orders each read against the deregistration latch. A copy in progress delays the latch. A copy that starts after the latch sees the flag and refuses.

Transparent staging

The messenger stages any payload above 256 KiB through rendezvous and replaces it in the frame with a handle. The receiver resolves the handle before the handler runs, so handler code never sees the difference.

The transparent stager runs inside the synchronous send_message. It uses only arenas that the pool has already mapped. It never maps a new arena, because mapping is an ibv_reg_mr whose cost is linear in size. A send must not stall on that.

The result: a process whose only staging is transparent never maps an arena and never takes the RDMA path. One explicit register_data_pinned call maps the first arena. After that, transparent payloads use it. Growing the pool from a send on a background task was rejected. A caller cannot reason about a message send that causes a 64 MiB pin.

The fetch has a deadline

The receiver fetches the payload with its own calls to the sender (_rv_acquire, then _rv_pull). Each call holds one of the receiver's response slots, and the request keeps its drain guard, until the fetch ends. A fetch with no deadline ends only when the sender answers. So a sender that stops answering took one slot from the receiver for each staged request, after its own caller had given up.

The fetch now ends at the first of two bounds:

  • The caller's. A sync or unary call with .timeout sends what is left of it with the handle, in the _rv_budget_ms header, rounded up from nanoseconds to whole milliseconds. The receiver starts the bound when it takes the message off its queue, so it does not end before the caller's own timer. How much later it ends depends on transit, queueing and scheduling, which nothing bounds. When it runs out, the receiver drops the fetch and sends no reply: the caller has its RequestTimeout already, and a reply that raced the caller's timer would replace that with a different error.
  • The receiver's. VeloBuilder::staged_fetch_timeout bounds every fetch on this instance. It covers what carries no bound: fire-and-forget sends, calls without a timeout, and senders from before the header. When it runs out, a sync or unary caller gets an error reply that says the fetch did not finish in time. It does not apply to the items of a stream. A stream item over the threshold is a fire-and-forget batch, so a dropped fetch lost the item with no word to the stream. Its consumer got SenderDropped from the stream watchdog about 15 s later, while the producer was still attached.

The bound is a duration and not an instant, because the two nodes do not share a clock. It is its own header because a receiver from before it reads the _rv value as a bare number. Such a receiver ignores the new header.

The staging headers belong to the send that staged

The receiver takes _rv and _rv_budget_ms out of a staged message before its handler runs, so a handler never sees them. A handler that passes its headers on in a send of its own therefore passes neither. A forwarded handle made the next receiver fetch a payload that was not its message's. A forwarded budget made it give up at another call's deadline.

A staged send writes both headers itself, after the tracing propagator, so the ones on the wire are its own. A budget without a handle means nothing to a receiver, so an eager send needs no such care. An encoder that owned the budget key was rejected: it compared every header key of every send with _rv_budget_ms, and it left a forwarded _rv in place.

What a fetch that gives up leaves behind

The receiver tells the sender nothing when it gives up a fetch. A slow sender then grants a lease that nobody pulls or releases, and a payload whose fetch never got a lease stays staged. Two things end both on the sender:

  • A time-to-live. A staged payload whose call has a .timeout gets that deadline as its RegisterOptions::ttl. Past it, the slot is freed with every lease on it, whether or not a consumer is still reading. The caller has given up by then.
  • A chunked lease deadline. A fetch with a bound sends an _rv_lease_ms header with its _rv_acquire. The owner ends the lease when no _rv_pull renews it within that time. An owner from before the header ignores it. An acquire without the header, from an older consumer or from a public get, gets a lease with no deadline, as before. A get caller can still hold its lease after the last pull.

A sweep in the store enforces both, every 100 ms. It starts when a deadline or a time-to-live is set, and it exits when none is left, so an instance that stages nothing with a deadline arms no timer. A UCX instance stops it at shutdown, together with the RDMA reaper.

The lease deadline is a header, not a field of RvAcquireRequest, because a new public field breaks code that builds the request. A message from the receiver that abandons the fetch was rejected. Before _rv_acquire returns, the receiver has no lease to name, and a lost message still leaves the payload staged. What still stays staged is in the limitation.

The receiver's limit

A staged payload's size is its owner's word: the acquire answer states total_len, or a descriptor states its length. With no limit, the consumer allocates that size before it pulls a byte, so one peer can make another allocate any amount for one message. VeloBuilder::staged_payload_max sets the largest payload a node takes this way. The transparent resolver checks the stated size against it before it allocates anything, on the chunked path and on the RDMA path.

A payload over the limit is refused:

  • The consumer releases the handle, so the owner frees the staged copy. Transparent handles are single-use, and a detached one would stay on the owner with nobody left to read it.
  • A request that expects a reply fails with an error that names both sizes. Its handler never runs.
  • A stream item on the messenger mux ends its own stream with a TransportError that names both sizes, and the producer is told to stop (Batched streaming). Other streams from the same peer go on.
  • Any other fire-and-forget message is lost, and the refusal is logged.

The limit does not bound a payload under the staging threshold. Such a payload travels inline, in one transport frame, and the frame limit bounds it.

The size is checked after the acquire, from its answer, not by a _rv_metadata query first: that query would add a round trip to every staged payload to catch the rare one that is too large. The check is not after the allocation, because by then the memory is spent.

Reading on the consumer

  • get GETs into a pool buffer on the consumer, then copies into Bytes so the pool space returns at once.
  • get_pinned returns the pool buffer itself. On the chunked path it copies the chunks into a pool buffer, so the return type is the same on both paths.
  • get_into with a PinnedWriter (from alloc_pinned_writer) GETs straight into the caller buffer. Other RendezvousWrite destinations get a GET into a pool buffer and one copy.

The GET runs on a spawned task that owns the destination reservation (a TransferHold). If the caller drops the get future, the task keeps running and the granules stay reserved until the backend reports completion. Without this, a dropped future returns the granules to the free list while the NIC still writes into them.

Leases and deadlines

The owner sees every chunk request, so it sees an abandoned chunked transfer stop. It does not see an RDMA GET, because the consumer's NIC issues it. Without a deadline, a consumer that crashes mid-GET holds the read lock and the refcount forever. So RDMA leases carry a deadline.

  • lease_timeout defaults to 30 s. The owner sends it to the consumer as lease_timeout_ms.
  • The owner reaper scans every lease_timeout / 2, with a 10 ms floor. A silent lease is force-released between one and one and a half timeouts after its last renewal.
  • While a GET runs, the consumer sends _rv_lease_renew every lease_timeout / 2, with a 5 ms floor. A lost renewal is harmless, because the next one is half a deadline away.
  • Renewal stops when the transfer ends. Holding an RDMA lease idle past its deadline is not supported. A caller that holds data across a long pause releases it and acquires again.

On the wire, 0 means "no deadline". A consumer that gets 0 starts no renewal ticker. A sub-millisecond lease_timeout therefore clamps to 1 ms at build time. Without the clamp, the owner arms a deadline that the consumer was told does not exist, and the lease never renews. The clamp makes small values defined, not useful. Renewals go out at most every 5 ms and the reaper scans at most every 10 ms, so values below about 100 ms reap live transfers.

The descriptor

The RDMA answer carries a velo-owned binary descriptor, not JSON:

backend:    u8      1 = ucx
version:    u8      1
flags:      u8      0
generation: u64le   owner's registration generation
addr:       u64le   owner-authored absolute address
len:        u64le   bytes to read
rkey_len:   u16le   length of the packed key that follows
rkey:       [u8; rkey_len]

The header is 29 bytes, then exactly rkey_len key bytes, then nothing.

The format is binary because the packed rkey goes to ucp_ep_rkey_unpack, which has no length parameter. A truncated or corrupt blob is an out-of-bounds read inside UCX, and no Rust wrapper can make that safe after the fact. The framing must therefore be exact and owned by velo.

Decoding refuses anything that it cannot account for byte for byte:

  • an unknown backend or version
  • a non-zero flags
  • a zero len
  • an rkey_len that disagrees with the bytes that follow
  • any trailing byte

A refusal is not an error for the caller. It costs one extra round trip on the chunked path.

The key is bounded at 4096 bytes by the descriptor and at 1024 bytes by the UCX backend. The backend checks the bound at map time and again before unpack. Before any pointer reaches ucp_ep_rkey_unpack, the UCX backend also walks the two stages of the UCX 1.22 packed-rkey format. It refuses a blob unless UCX's own parse ends inside the blob, and it refuses an out-of-range mem_type. This pre-parse checks framing only. A stale key with perfect framing passes it. See RDMA design for why that matters.

generation is in the descriptor for diagnostics. The consumer does not send it back in detach or release.

Version skew

Two #[serde(default)] fields make a mixed-version deployment fall back to chunked instead of failing:

  • RvAcquireRequest::rdma (the offer). An old owner ignores it and answers Ready. An old consumer omits it, and an owner that sees no offer never answers Rdma.
  • AcquireResponse::Rdma::lease_timeout_ms. If a future owner omits it, the default 0 means no deadline, which is the old behavior.

The rule for new fields: every field is #[serde(default)], and every default means what the protocol did before the field existed. A default that means "the new behavior" makes an old peer's silence look like consent.

Memory registration

Velo registers memory in two ways. Both end in the same lifecycle.

The arena pool

Velo sets UCX_RCACHE_ENABLE=n and UCX_MEM_EVENTS=n by default, so UCX installs no malloc hooks in the process. With those settings, every ucp_mem_map is a fresh ibv_reg_mr whose cost is linear in size, and UCX caches nothing. Pre-registered arenas are therefore required, not an optimization.

  • The pool registers a few large arenas. Each arena is one page-aligned allocation, one ucp_mem_map, and one packed rkey.
  • The first arena is 64 MiB (initial_arena_bytes). Later arenas grow geometrically up to 1 GiB (max_arena_bytes).
  • Inside an arena, offset-allocator hands out 4 KiB granules in O(1). Its float bins waste at most about 12.5%.
  • A request of 64 MiB or more (dedicated_arena_min) gets its own arena. Without this, the 12.5% round-up costs 128 MiB on a 1 GiB object.
  • The suballocator node pool is sized to the granule count, about 28 bytes per granule (0.7% of the arena). A smaller node pool makes a fragmented arena report "full" while it still has room. The pool then maps an arena that it does not need.
  • The free token (the private Allocation inside a PinnedBuf) and the wire descriptor are different values. A peer with a descriptor can read the range. It cannot free it, and the descriptor does not keep it alive.

registered_bytes_budget (1 GiB by default) caps mapped bytes across the pool and external regions together. It counts what the kernel pins: whole pages, reconciled to the effective range that UCX reports. A mostly empty arena costs its full size. Over the budget, pinned staging falls back to plain memory. Budget exhaustion is never a hard error.

Pages outlive their registration. Arena memory is freed only after the backend confirms the unmap. A registry torn down without its shutdown sweep leaks its arenas and logs at error. A leak is the safer failure, because freeing pages that UCX still pins is the hazard this layer exists to prevent.

External registration

Velo::register_external_memory(ptr, len) registers memory that the caller owns and returns a RegionGuard. The function is unsafe. Until RegionGuard::deregistered() resolves, the caller guarantees all of these:

  • ptr is valid for reads and writes of len bytes. Registering a read-only mapping is undefined behavior.
  • ptr + len does not wrap.
  • The allocation is not freed, moved, remapped, or reallocated.
  • No Rust reference into the range exists. A peer can write at any time.

Velo::register_owned(Box<[u8]>) is the safe form. Velo holds the buffer until an unmap is confirmed. On failure, the buffer comes back inside RegisterOwnedError, because BudgetExceeded is a routine refusal and must not consume the allocation. RegionGuard::unregister_owned gives the buffer back after release.

The guard does not borrow. Its lifecycle:

  • deregistered().await is the single release signal. It resolves on a confirmed unmap, or at the end of graceful_shutdown, whichever comes first. Only then can the caller free the memory.
  • unregister(timeout) gates new use, drains in-flight work, and unmaps. It returns Deregistered::Drained or Deregistered::DrainTimedOut. Both mean the memory is released. DrainTimedOut means velo did not wait for in-flight work. An earlier Err(Timeout) shape mixed up "still mapped" with "unmapped early", and only the first means "do not free".
  • Drop without unregister starts a background deregistration, logs at warn, and returns at once. It never blocks, because blocking in Drop inside a tokio runtime deadlocks the worker. The memory stays pinned until the background task finishes. An early drop is a liveness bug for the caller, not a soundness bug for anyone else.
  • watch() returns a RegionWatch. It carries the same observers but does not own the release, because unregister consumes the guard.
  • If the progress thread dies while the backend still reports registrations, deregistered() never resolves. Velo cannot prove the memory was released, so it leaks the memory on purpose.

Registered means remotely writable

UCP has no enforced protection field. Any holder of a region's key can write the region, not only read it. The GET-only shape of the rendezvous protocol is a convention, not an enforcement. Registration pins whole pages, so bytes next to the allocation share the pinning and the remote writability. RegionGuard::effective_range reports what was pinned.

Registering memory is therefore a trust decision about the peers that this instance talks to. Key material only goes to peers that this instance already talks to. A &mut [u8]-shaped API is an aliasing lie, so velo does not offer one.

Lifecycle pressure

Registrations are evicted by a byte budget, never by an inactivity timer. A timer that evicts a registration while a peer still holds its descriptor creates a stale key. On InfiniBand, a stale key aborts the process. No surveyed system (UCX rcache, libfabric, MPICH, NCCL) evicts registrations on a timer.

Two timers exist anyway, on objects that no peer can hold a reference to.

Arena reclamation

One periodic sweep runs on the lease reaper's task. A second timer needs its own ordering against the first at shutdown.

  • Empty pooled arenas, only when arena_reclaim_after is set (off by default). An arena with no suballocation has no live descriptor into it, so an unmap is safe. retain_arena_bytes (64 MiB by default) keeps a warm floor mapped, so an idle-then-busy workload does not pay a fresh registration. The sweep considers the newest arenas first, so the small first arena survives as the floor.
  • Empty dedicated arenas, always, with no timer and no floor. A dedicated arena serves one request and is never offered again, so after its buffer drops it only consumes budget. Without this, a workload that stages 64 MiB objects repeatedly exhausts a 1 GiB budget after 16 of them and falls back to chunked for good.

The sweep period is the smaller of lease_timeout / 2 and arena_reclaim_after / 2, with a 10 ms floor. An arena is unmapped between one and two arena_reclaim_after intervals after it becomes empty.

Endpoint idle reaper

UcxTransportBuilder::ep_idle_timeout(Some(d)) closes UCX endpoints that nothing used for d. It is off by default and experimental. Values below 500 ms rise to 500 ms, which is about 35 times the 14 ms that the first RDMA GET on a fresh two-process pair over CX-7 InfiniBand took, wireup included.

  • "Used" means both directions. Our sends, GETs, pings, and eager wireup stamp the endpoint. Every frame the peer sends us stamps it too, of any kind. Each frame header starts with the sender's 8-byte incarnation, and the receiver stamps the endpoint to the peer with that incarnation. The receive callback passes senders to the main loop through 8 slots, one per sender. If more than 8 different peers send frames between two drains of those slots, the main loop stamps every endpoint instead, so no sender's stamp is lost. On a worker that hears from many peers at once, idle endpoints can therefore stay open longer.
  • Why a tag and not UCX's reply endpoint: UCX tells the receive callback which endpoint a frame came from only when the sender sets the REPLY flag. Velo sets that flag on Messages and pings only. A stamp that used it missed Responses, Events, Acks, Pongs, and ShuttingDown echoes, so a peer that streamed Responses back to us had our endpoint reaped under the stream. Setting the flag on every frame was rejected: after a reap, the peer's REPLY-flagged frames are lost (see below), so with the flag the next Response to a new request was lost too (3 of 3 in a probe). Without the flag, Responses kept arriving.
  • An endpoint is idle only when no send posted on it is in flight and none has completed for the timeout. The reaper counts the sends in flight on each endpoint, and an asynchronous completion of the last one restarts the idle clock. A send that completes inline, while it is posted, records no completion time; the stamp taken when it was posted stands in for it. Replies on the endpoint that UCX hands the receive callback (Pongs and ShuttingDown echoes) are not counted. The inbound frame that caused each reply refreshes the endpoint in the same loop pass, before the reaper scans.
  • The reaper never closes an endpoint while an RDMA operation to that peer is outstanding. When the operation completes, the idle clock starts again, as it does when a send completes.
  • The scan runs every half timeout, and at least once per second. On an idle worker a due scan runs at most 100 ms late (the progress thread's park timeout). An endpoint closes between one timeout and one timeout plus one scan period plus 100 ms after its last use. A long pass on a busy worker delays the close further.
  • The progress thread reads the reaper's clock a few times per pass, not once per use: at the start of the pass, after ucp_worker_progress returns, and after each ucp_ep_create. An inbound frame is stamped after it arrives, which keeps the endpoint open a little longer. A send can be stamped up to one command drain before it is posted, which can shorten the timeout by that much. A send whose asynchronous completion empties its endpoint's count also reads the clock once, in stamp_ep_drained, to record when it completed. With the reaper off, the progress thread does not read the reaper's clock per pass, and a send pays only two None checks, plus one clock read per ucp_ep_create.
  • Idle time starts when ucp_ep_create returns, not before the call. In the in-process test harness over TCP on a GB200 node, with several UCX workers per process, one ucp_ep_create call took 110 to 150 ms. Across 31 creates in one process during the parallel UCX tests, the median was 630 ms, which is longer than the floor. The cause is not isolated. An endpoint with no send on it, such as an eager one, has only this stamp to keep it open.
  • A first send between fresh workers also waits while the peer sets up its own endpoint back to us. With the CPUs oversubscribed, a fresh pair's first frame took 360 to 420 ms. With many UCX workers in one process, the peer's progress loop that contained that step took 526 to 573 ms. The count of sends in flight keeps the endpoint open for that time. The count costs no measurable time per send: on a GB200 node, in a release build with 6 alternating runs, the reaper-on send cost moved +1% to +3%, inside a noise floor of at least ±6%.
  • The next use wires up a new endpoint with no error. The peer stays registered.

Closing an endpoint costs the peer. UCX pairs endpoints by remote worker. The peer's own endpoint back to us rides the same connection. After a reap, the peer's next Message to us is admitted and silently lost, with no error at either end (measured). By the mechanism inferred below, its next ping gets no Pong (not measured), so its check_health returns Timeout (or ConnectionFailed if keepalive fails the endpoint while the probe is waiting). Every Message the peer sends after that is lost too, until keepalive fails its endpoint: retrying does not help, and neither does a fresh endpoint on our side (measured by hand; the in-tree test asserts only the first loss). Pings are expected to be lost the same way (inferred, not measured). Its Responses and Events still arrive (measured: 8 of 8 arrived in every recorded run). Acks carry no REPLY flag either, so the same is expected, but it was not measured. The likely reason, inferred from the UCX source and a measurement but not proven: a REPLY-flagged frame carries an endpoint id for the reply path, and UCX drops a frame whose endpoint id no longer resolves. UCX keepalive (about 20 s) then declares the peer's endpoint failed, and the frame after that arrives. The cost per reaped endpoint is that the peer's Messages and pings to us are lost, silently, until keepalive (about 20 s) fails its endpoint. The first loss is a Message with no error, or a ping that times out. The disruption self-heals.

The reaper is meant for peers idle in both directions. That case still carries the cost above, whichever side speaks first, when the peer keeps its endpoint to us: if we speak first, our send arrives with no error, and the peer still loses its next Messages to us, and, by the same inferred mechanism, its pings. If the peer also runs the reaper and has closed its own endpoint, its next send creates a fresh one; that case is not measured. Send-only fan-out costs nothing, because no peer uses a path back. The reaper is unsafe for peers that this instance only health-probes. Each probe creates an endpoint, and each reap costs the peer its next Messages and pings to us, if it sends any before keepalive fails its endpoint.

The sender tag changes the wire format. The UCX blob version is 2. A version-1 node and a version-2 node refuse each other at register(), so all UCX peers must be upgraded together. See Version compatibility.

Eager endpoint wireup

UcxTransportBuilder::eager_endpoints(true) creates the endpoint at register() instead of at first use. It is off by default. The first GET on a fresh peer pair costs about 14 ms of UCX wireup, against about 108 µs for a warm GET (measured on 2026-08-29, see RDMA performance). Eager wireup moves that cost off the first transfer. The hint is fire-and-forget: register() does not wait, and a dropped hint falls back to lazy wireup.

With both knobs on, a peer that is registered but never used is wired up once and closed about one timeout (see the close window above) after ucp_ep_create returns. The UCX wireup itself finishes later, in the background.

Shutdown ordering

An RDMA GET is invisible to the owner's in-flight counters. An unmap after transport teardown can remove memory under a transfer that nobody on the owner can see. graceful_shutdown therefore runs the RDMA sweep before messenger teardown, under one shared deadline.

sequenceDiagram
    participant App
    participant Velo
    participant RV as RendezvousManager
    participant Reg as RdmaRegistry
    participant UCX as UCX progress thread
    App->>Velo: graceful_shutdown(policy)
    Velo->>Velo: begin_drain (messenger gate)
    Velo->>RV: stop lease reaper, demote pinned slots to heap
    Velo->>Reg: 1. Gate: refuse new registrations and allocations
    Velo->>Reg: 2. Drain: wait for in-progress registrations and region in-flight counts
    Velo->>Reg: 3. Deregister: unmap every region and arena
    Reg->>UCX: unmap (regions before endpoints)
    Velo->>Velo: messenger graceful_shutdown (remaining budget)
    UCX->>UCX: teardown force-unmaps anything left
    Velo->>Reg: latch deregistered() for every region
  • Demotion copies, it does not drop. Each pinned slot is copied to the heap. A chunked pull admitted before the gate still finishes. A peer mid-GET holds a descriptor for the old address. That GET fails at its own end on InfiniBand and is silently lost over UCX_TLS=tcp.
  • The gate is an ordering, not a flag. Admission takes an in-flight guard first and then reads the gate with SeqCst, as TransportAdapter::admit_message does. A plain token check followed by a map is a check-then-act race. A registration can land after step 3 and leave pinned memory with no tracking entry.
  • The drain is bounded. When the budget runs out, velo warns and force-unmaps. The same straggler rule applies.
  • RdmaConfig::shutdown_timeout (30 s) bounds the sweep when the policy is WaitForever. RdmaConfig::drop_dereg_timeout (30 s) bounds a background deregistration from a dropped guard.

See Shutdown and drain for the messenger half.

Configuration

KnobSet throughDefaultEffect
DEFAULT_THRESHOLDconstant (the Velo builder uses it)256 KiBMessenger payloads above this go through rendezvous.
staged_fetch_timeoutVeloBuilderNoneThe longest the receiver fetches a staged payload. A call's .timeout bounds it too. Stream items are exempt.
DEFAULT_CHUNK_SIZEconstant512 KiBChunk size of the chunked path.
staged_payload_maxVeloBuilderNoneThe largest payload this node takes from a peer by rendezvous. Checked before anything of its size is allocated.
rdma_min_bytesRdmaRendezvousConfig64 KiBPinned slots below this answer chunked.
enabledRdmaRendezvousConfigtrueThe kill switch.
lease_timeoutRdmaRendezvousConfig30 sRDMA lease deadline. Clamped to at least 1 ms.
initial_arena_bytesRdmaPoolConfig64 MiBFirst pooled arena.
max_arena_bytesRdmaPoolConfig1 GiBLargest pooled arena.
dedicated_arena_minRdmaPoolConfig64 MiBRequests at or above this get their own arena.
registered_bytes_budgetRdmaPoolConfig1 GiBCap on mapped bytes, pool and external.
arena_reclaim_afterRdmaPoolConfigNoneUnmap empty pooled arenas after this long.
retain_arena_bytesRdmaPoolConfig64 MiBWarm floor that reclamation keeps.
shutdown_timeoutRdmaConfig30 sSweep bound under WaitForever.
drop_dereg_timeoutRdmaConfig30 sBound on a dropped guard's background deregistration.
eager_maxUcxTransportBuilder1 MiBLargest UCX Active Message. Not related to the RDMA path.
ep_idle_timeoutUcxTransportBuilderNoneEndpoint idle reaper. Floor 500 ms.
eager_endpointsUcxTransportBuilderfalseWire up endpoints at register().

Pass RdmaConfig with VeloBuilder::rdma_config. It has no effect without VeloBuilder::add_ucx_transport. A UCX transport added with add_transport gives full messaging and no RDMA registry.

The defaults keep the order rdma_min_bytes <= DEFAULT_THRESHOLD < DEFAULT_CHUNK_SIZE (64 KiB, 256 KiB, 512 KiB). A transparently staged payload is then large enough for the RDMA path from its first byte over the threshold. If rdma_min_bytes is above the transparent threshold, a band of payloads sits in pinned memory and never uses it.

The kill switch

VELO_RDMA_RENDEZVOUS_DISABLE=1 forces RdmaRendezvousConfig::enabled off. Velo reads it once, in VeloBuilder::build, so one process cannot answer half its acquires one way and half the other. Only 1, true, yes, and on (any case) count. Any other value leaves the path on. A switch that fires on a typo silently costs performance, but a missed typo shows in the metric.

The switch acts on both roles. An owner with it on never answers Rdma and stages new pooled slots in plain memory. A consumer with it on never sends an offer. Either side alone turns the path off, so a rollback works one node at a time. Pinned slots keep answering the chunked path, so no staged data becomes unreachable.

Metrics

SeriesTypeMeaning
velo_rendezvous_rdma_path_total{path, reason}counterPath decisions, with the reason. reason="ok" is the only path="rdma" label.
velo_rendezvous_rdma_get_duration_secondshistogramGET time, without the acquire round trip.
velo_rendezvous_rdma_leases_reaped_totalcounterRDMA leases the owner reaper force-released.
velo_rdma_registered_bytesgaugeMapped bytes, pool and external.
velo_rdma_registrations_total{kind}counterRegistrations, kind = arena or external.
velo_rdma_live_regionsgaugeRegions the backend holds registered.

Both sides record into path_total, at different points:

  • The owner records at staging time, when register_data_pinned falls back (kill_switch, budget, pool_exhausted). It also records at each acquire (no_offer, not_configured, kill_switch, not_pinned, below_min, draining, ok).
  • The consumer records when it builds its offer (not_configured, kill_switch, no_offer), after a failure (decode_error, pool_exhausted, get_failed), and when a GET completes (ok).

The series counts decisions, not transfers. With the owner's kill switch on, one slot adds kill_switch once at staging and again at each acquire. A fallback acquire carries no offer, so the owner also counts no_offer for a consumer whose GET failed. The owner counts ok when it sends a descriptor, before the GET runs. The consumer's ok is the proof that a GET completed.

This series is the only honest answer to "did the RDMA path run", because the chunked path also returns correct bytes. See Metrics reference.

Not yet built

  • PUT in the other direction. The owner writes into a consumer-supplied buffer, flushes, and sends a completion message. The current decoder refuses any non-zero flags, and RdmaOffer carries backend names but no version. A direction flag is therefore not additive: every current peer refuses it and falls back to chunked during a mixed-version window. Either a version field in the offer ships one release ahead, or the rollout accepts chunked fallback everywhere in that window.
  • A goodbye message before an idle close. It removes the Messages and pings that a peer loses after the endpoint reaper closes our endpoint to it.
  • A deadline for every chunked lease. A chunked lease has a deadline only when its consumer asks for one. A consumer's LeaseGuard releases a chunked lease on every error path except one. That path is a release that cannot spawn because the runtime is shutting down. Nothing on the owner reclaims that lease, unless its slot has a time-to-live.
  • A consumer-side unpacked-rkey cache. It removes both halves of the stale-key safety argument at once. An rkey outlives its operation, and the owner no longer revalidates each transfer. It is a safety change, not a profile-driven optimization.
  • Register-in-place for large owned buffers. It needs an explicit API. Registering arbitrary caller Bytes by default is unsound: provenance is unknown and UCX pins past the caller's range.
  • Metrics: an arena-utilization gauge, and a Prometheus series for endpoints closed by the idle reaper (the count exists only as an internal atomic).
  • GPU memory, scatter-gather anchors, and an RDMA surface in velo-ext for out-of-tree transports.

Migrate ownership, dispatch, and cancellation to 0.19

Velo 0.19 makes runtime ownership explicit and removes duplicate dispatch and cancellation APIs. Stream wire formats and velo-ext traits do not change.

Runtime ownership

Keep a Velo owner for as long as its streams are needed. Velo is a handle, and clones share one instance. Dropping one clone does not stop streams. Dropping the final Velo owner cancels its streams and stops the builder-owned streaming listener and tasks, even if an Arc<Messenger> remains alive.

Messenger values no longer implement Clone. Clone Arc<Messenger> to share the instance:

let messages = Arc::clone(velo.messenger());
let streams = velo.clone(); // Keep this owner while streams are active.

The handlers that AnchorManager::register_handlers installs no longer keep their manager alive, so keep your own Arc<AnchorManager> for as long as you use it. The manager still keeps its Messenger alive: drop the manager too before you expect final Messenger drop to start teardown. RendezvousManager::register_handlers no longer keeps the Messenger alive. The public handler factories streaming::control::create_anchor_{attach,detach,finalize,cancel}_handler and streaming::mpsc::create_mpsc_anchor_{attach,detach,cancel}_handler are removed; call AnchorManager::register_handlers instead. A manager whose handlers owned it formed a cycle with its Messenger, so neither was ever dropped.

A retained Messenger supports active messages after the final Velo owner is dropped; it does not keep Velo streaming services alive. Stream handles and handler contexts can also hold Messenger references. Final Messenger drop starts immediate transport teardown and wakes remote event waits. It does not complete a response wait made through that Messenger: such a wait ends at the caller's own deadline. Velo's internal tasks that wait on a response end at teardown, except the handshake task for a peer that is not yet known, which ends at its 30-second handshake timeout. Local event completion remains available through retained event handles.

Drop requests cancellation and starts transport cleanup on an owned thread. It does not drain accepted work or wait for tasks or native threads to exit. A failed VeloBuilder::build also cleans up, in one of two ways. If anything fails while the transports start, the build stops the transports that already started on the building task, and waits for them to close before it returns the error, as step 3 of Shutdown and drain says. It skips that wait if a hook panicked, because a hook that panicked may never set up what its close waits on. If the build fails after its transports started, dropping what it built cleans up as Drop does, so a listener port can stay bound for a short time after the error returns. Explicit shutdown runs the same transport cleanup, and waits for it before it waits for transport close. Continue to call Velo::shutdown(policy).await when those guarantees are needed. RDMA users must use explicit shutdown before relying on deregistration; Drop does not report registered memory as released. Discovery registration guards and custom frame transports remain the caller's responsibility.

Handler dispatch

Replace .inline() with .spawn(), or omit the selector to use the default. The DispatchMode enum is removed. Select behavior with the builder methods: .spawn(), .ordered(), .ordered_global(), or .ordered_with(config).

Both old modes ran one task for each message. Neither guaranteed message order. Use .ordered() or .ordered_with(...) when handlers need to run in arrival order.

Accepted handler calls still count toward graceful shutdown until the handler and its response send finish. Messenger::tracker() remains available.

Stream cancellation

SenderEntry::rx_closer is removed, and SenderEntry has a new closed field. Code that constructs SenderEntry must omit rx_closer and set closed: Default::default(). Code that dropped its receiver to cancel a sender must call entry.cancel() instead. If you remove an entry from SenderRegistry to cancel its sender, call cancel() on it before you drop it.

The normal StreamController::cancel() and MpscStreamController::cancel() APIs need no changes. SPSC and MPSC senders check a cancelled flag before they send an item. SenderEntry::cancel sets the flag first, so the next send fails. Checking the token itself takes a lock on every item: 8.7 ns, against 0.3 ns for the flag. The token that cancellation_token() returns only observes the stream: it fires when the stream is cancelled, and cancelling it does not stop the sender. To end a stream from the producer, finalize, detach, or drop the sender. A cancelled stream also wakes a send that waits for channel space. Item and error sends return SendError::ChannelClosed after cancellation.

Graceful stop remains separate. For SPSC streams, request_stop() signals stop_token() and lets the producer send buffered output and call finalize(). Hard cancellation also signals the stop token, but rejects further item and error sends. Do not replace a graceful stop with cancel_token.cancel().

Migrate to the Velo handle in 0.21

Velo is now a cheap handle. VeloBuilder::build() returns Velo. It used to return Arc<Velo>.

Velo is Clone. A clone adds one reference count and does not copy any state. Every clone is the same instance.

How to migrate

  • Replace Arc<Velo> with Velo in your types and signatures.
  • Replace Weak<Velo> with WeakVelo in your types and signatures.
  • Remove velo.as_ref() and &*velo. Pass &velo instead. Replace velo.as_ref().clone() with velo.clone().
  • Keep .clone() as it is. It now clones the handle.
  • Change Arc::clone(&velo) to velo.clone().
  • Change Arc::downgrade(&velo) to velo.downgrade(). It returns a WeakVelo, and WeakVelo::upgrade() returns Option<Velo>. A handler that reaches its own instance must hold a WeakVelo. A Velo clone in the handler keeps the instance alive, so its final drop never runs.
  • Change Weak::new() to WeakVelo::default(). It points at no instance, and upgrade() returns None.
  • Change Arc::ptr_eq(&a, &b) to a.instance_id() == b.instance_id(). Clones of one instance share an InstanceId, and separate instances never do. Velo does not expose reference counts.
  • The other Arc and Weak methods have no counterpart: try_unwrap, into_inner, get_mut, make_mut, strong_count, weak_count, and Weak::ptr_eq. For identity, compare instance_id(). To share, clone.
  • Remove Arc::new(velo) if you wrapped a built Velo. Pass velo or velo.clone() instead.
// 0.20: `velo` is an `Arc<Velo>`
let velo = Velo::builder().add_transport(transport).build().await?;
let worker = Arc::clone(&velo);

// 0.21: `velo` is a Velo
let velo = Velo::builder().add_transport(transport).build().await?;
let worker = velo.clone();

Arc<Messenger> and the other Arc values that Velo hands out do not change.

What does not change

  • Clones share one instance and one set of handlers, streams, and rendezvous state.
  • The final drop of the last clone cancels the streaming services. Dropping any other clone does not.
  • graceful_shutdown runs its teardown once, even when you call it through different clones. A later call waits for the first and then returns, unless the first teardown failed. Then a later call panics at once.
  • shutdown calls graceful_shutdown, then closes the streaming services. It does the streaming step again on each call.
  • Wire formats and velo-ext traits do not change.

Services and mux-only mode

Velo 0.19 groups distributed events, named work queues, and built-in discovery backends under one default feature, services. The default build keeps these APIs and the current stream listener behavior.

Select services at build time

For an application that registers peers itself:

[dependencies]
velo = { version = "0.19", default-features = false }

This build retains TCP and Unix sockets, typed handlers, ACK/NACK messages, SPSC and MPSC streams, per-stream TCP, the messenger mux, rendezvous, metrics, and discovery traits. Custom discovery implementations still work. Add ucx for UCX messaging and RDMA registration; those APIs do not need services.

The following APIs need services:

  • velo::events, its root re-exports, VeloEvents, and event methods on Velo and Messenger.
  • velo::queue, including the in-memory queue backend.
  • Filesystem peer and service discovery.

nats-discovery, etcd, nats-queue, and queue-messenger enable services automatically. They still select their own backends. nats-transport does not enable services. This is a deliberate coarse boundary: selecting a discovery or queue backend also includes the other service APIs and their shared dependencies. It keeps the supported feature combinations small. Simulation remains independent of this feature.

If an existing dependency disables defaults and uses these APIs, add the feature:

velo = { version = "0.19", default-features = false, features = ["services"] }

Cargo combines features requested by all dependents. Another crate that enables Velo's default features also enables services for the shared Velo package. Without services, the messenger does not create an event manager or register the _event_* handlers. The fs4 and lru dependencies are omitted. Core request acknowledgements and errors keep their current wire format.

Select mux-only streams at runtime

The default builder creates a TCP stream listener beside the messenger mux. That listener allows streams from peers that do not offer the mux. If every peer supports the mux, use:

let node = velo::Velo::builder()
    .add_transport(transport)
    .mux_only()
    .build()
    .await?;

This starts no extra TCP or gRPC stream listener and advertises no stream endpoint. Ordinary SPSC attach, prebound tickets, MPSC attach, and large-payload rendezvous remain available. All remote streams use the messenger transport.

Do not combine mux_only() with stream_config() or stream_bind_addr(). build() returns an error for that combination, a disabled MuxConfig, or an active VELO_MESSENGER_MUX_DISABLE switch. It validates these choices before starting transports. Peers that need a per-stream transport receive an attach error. There is no silent fallback. To restore that fallback, remove mux_only() and restart the instance. The fleet-wide mux kill switch is therefore not a fallback for mux-only nodes.

The compile-time services choice and the runtime stream choice are independent. A node can keep all services and use mux-only streams, or omit services and keep the default stream listener. Use Velo::shutdown(policy) to close either kind of instance. See Shutdown and drain.

If a peer accepts an attach but the producer cannot connect, the producer waits up to one second for rollback before it returns the original connect error. SPSC rollback checks the producer identity so a late cleanup cannot detach a replacement. MPSC uses its sender ID. Rollback does not emit a Detached frame.

Older peers may not support the SPSC rollback handler. Cleanup failure is logged; there is no unqualified detach fallback. Existing receiver watchdogs and accept timeouts remain the backstop. A legacy transport's pending bind can remain until its accept timeout even after anchor rollback succeeds. Dropping the attach future, or losing the attach reply, is outside this cleanup guarantee.

Write an out-of-tree transport

A transport, frame transport, or discovery backend in another crate depends on velo-ext only. It does not depend on velo. velo-ext has no Prometheus, no Tonic, no NATS, and no dependency on velo.

[dependencies]
velo-ext = "0.5"

Pick the trait

TraitWhat you provide
velo_ext::TransportA messenger transport
velo_ext::FrameTransportA frame transport for streams
velo_ext::PeerDiscoveryA peer discovery backend
velo_ext::ServiceDiscoveryA service discovery backend
velo_ext::TransportObservabilityRarely needed. The runtime gives you one of these.

Procedure for a messenger transport

  1. Implement Transport for your type.
  2. Send each inbound MessageType::Message frame through TransportAdapter::admit_message. Do not check ShutdownState::is_draining() first. See Shutdown and drain.
  3. If admit_message returns AdmitOutcome::Draining and your transport has a return path, send a MessageType::ShuttingDown frame with the header of the rejected request. If there is no return path, record the rejection and drop the frame.
  4. If admit_message returns AdmitOutcome::Disconnected, the runtime has stopped. Record TransportRejection::RouteFailed through the observability handle and drop the frame.
  5. Route the other inbound types to their streams on the adapter: Response to the response stream, Ack and Event to the event stream, and ShuttingDown to the shutdown stream.
  6. Store the handle that set_observability gives you. Record each inbound frame as you route it.
  7. On the Admitted arm, and only there, record the frame as inbound message.
  8. In shutdown, stop your tasks and threads. The runtime calls it at most once, with its Tokio runtime handle entered, on a dedicated thread, so you may block there, for example to join a thread. Two cases run it on the caller instead. Any failure while the transports start (another transport fails to start, a duplicate key, an address that cannot be merged) or a build that is cancelled then runs it on the building task. A thread that cannot be created runs it on whatever dropped or shut down the instance. After the final owner is dropped, the runtime may already be gone, so a task that shutdown spawns may never run. If shutdown panics, the runtime still calls the other transports' hooks, and then the shutdown call panics.
  9. If your transport keeps state for each peer, override unregister to drop it and close the peer's connections. It must be cheap and idempotent. See Peer routing.

Step 7 matters because the inbound queue depth is a difference between two counters. If your transport admits a frame and does not record it, the depth goes negative. See Observability.

Step 6 matters because the runtime creates a series for each direction and message type in advance. If only message moves, the dashboard reads "no responses arrived", not "responses are not instrumented".

use std::sync::{Arc, OnceLock};
use velo_ext::{Transport, TransportObservability};

struct MyTransport {
    obs: OnceLock<Arc<dyn TransportObservability>>,
    /* ... */
}

impl Transport for MyTransport {
    // ... the required methods ...
    fn set_observability(&self, obs: Arc<dyn TransportObservability>) {
        let _ = self.obs.set(obs);
    }
}

Stability of velo-ext

New trait methods in velo-ext always have a default implementation, so a new release does not break your implementation. A change to an existing signature is a breaking release. See Versioning.

A transport with one ordered channel to each peer keeps the defaults of lanes and send_message_on_lane. Override both only if your transport keeps several ordered channels to one peer. The two methods need velo-ext = "0.5.4" or later. unregister needs velo-ext = "0.5.6" or later.

Tune batched streaming

Use this guide to change the settings of the messenger mux, choose a flush policy, or turn the mux off. Read Batched streaming first for the mechanisms that each setting controls.

The mux is on by default, and most deployments change nothing. Keep the defaults until a measurement shows a reason to change one.

The mux is on by default

A Velo builder installs the mux with MuxConfig::default(). The per-stream transport stays configured beside it, because negotiation needs it to serve peers that do not offer the mux. An attach uses the mux only when both sides advertise messenger-mux-v2. Every other pair uses the per-stream path.

To turn the mux off without a code change, set VELO_MESSENGER_MUX_DISABLE=1 and restart the process. Only 1, true, yes and on (any case) count. Velo reads the variable once, when it builds the node. The variable wins over enabled: true set in code, so a benchmark that must measure the mux must not inherit it.

A node with the mux runs one sweep task, which wakes five times a second, and registers 16 batch handlers (_stream_batch for lane 0 and _stream_batch.1 to _stream_batch.15), even if it never streams. A handler costs a map entry until its first batch.

Call messenger_mux to change a setting, or to turn the mux off:

#![allow(unused)]
fn main() {
use velo::streaming::MuxConfig;
use velo::{StreamConfig, TcpConfig, Velo};

let velo = Velo::builder()
    .add_transport(transport)
    // The per-stream path stays configured. Negotiation picks per attach.
    .stream_config(StreamConfig::Tcp(Some(TcpConfig::new(bind_addr))))?
    .messenger_mux(MuxConfig {
        enabled: false, // Turn the mux off. Omit this line to keep it on.
        ..Default::default()
    })?
    .build()
    .await?;
}

Call messenger_mux once per Velo instance. A second call returns an error.

Make sure that the mux carries your streams

  1. On the sender, read StreamSender::negotiated_transport() after the attach.
  2. Compare the result with velo::streaming::MESSENGER_MUX_KEY.
#![allow(unused)]
fn main() {
let sender = velo.attach_anchor::<Token>(handle).await?;
match sender.negotiated_transport() {
    Some(key) if key.as_str() == velo::streaming::MESSENGER_MUX_KEY => { /* multiplexed */ }
    Some(_) => { /* one connection per stream */ }
    None => { /* same worker, no transport */ }
}
}
  1. On either node, read velo_streaming_mux_batches_total{direction="sent"}. The value increases only when the mux carries data.
  2. Read velo_streaming_mux_records_per_batch{direction="sent"} to see how many records each batch carries.

negotiated_transport() exists on the SPSC StreamSender only. For an MPSC sender, use the metrics in steps 3 and 4.

If a benchmark must measure the mux, fail each request whose negotiated key is not MESSENGER_MUX_KEY. A silent fallback measures the per-stream path under a mux label.

Select a flush policy

If you do not own the send loop, use the default FlushPolicy::Auto. The batcher writes when the peer admits the previous batch.

If you own a loop that produces one record per stream per pass, use FlushPolicy::Manual. A decode engine is this kind of loop. Call flush_batch() once at the end of each pass.

#![allow(unused)]
fn main() {
let velo = Velo::builder()
    .messenger_mux(MuxConfig {
        flush_policy: FlushPolicy::Manual,
        ..Default::default()
    })?
    // ...
    .build()
    .await?;

loop {
    for (sender, token) in forward_pass() {
        sender.send(token).await?;   // stage
    }
    velo.flush_batch();              // one write per peer
}
}

CAUTION: Under Manual, call flush_batch() after every pass. Without the call, the last records of each stream stay staged and do not arrive.

flush_batch() is synchronous and returns at once. It flushes every peer, because a producer cannot know which peer each sender feeds. It is valid under Auto too, where it forces an early write. It does nothing when no mux is installed.

If you want batching with a time limit, use FlushPolicy::Auto(AutoFlush { on_admission: false, max_linger: Some(window), ..Default::default() }). The batcher then holds a batch for up to window after its oldest record. Two optional conditions end the wait early:

  • Set on_complete: true when a lane often carries a single stream, such as a serving worker at low concurrency. The batch then goes once every live slot on the lane has a record in it, but not sooner than window after the last write that carried data. An empty flush_batch() and the write that opens a stream do not count. Without it, each record of a lone stream waits the whole window.
  • Set at_bytes: Some(n) to write a batch once it reaches n bytes. Choose n near one transport segment of payload (1,448 bytes at MTU 1500, 8,948 at MTU 9000). Past that, a longer wait saves at most one partly filled packet per write, and adds latency. Unlike max_batch_bytes, it does not send larger records through rendezvous.

See Ending the window early.

Settings reference

The settings below are fields of MuxConfig. Always build it with ..Default::default() so that a new field does not break your code. The accept window of each bind is not a MuxConfig field: ReservationConfig::prebind_timeout sets it (180 seconds by default).

FieldDefaultWhat it controls
enabledtrueInstalls the mux and advertises messenger-mux-v2. Setting it to false is the rollback.
max_batch_bytes60 KiBThe configured cap on one batch. The eager budget and the 64 KiB coalescing threshold also clamp it.
initial_credit32Data credit C per slot. Each slot buffer holds C+1 records. Zero is refused at build time.
slot_byte_budget1 MiBBytes one slot can hold in flight, and the cap on its withheld queue. Zero means the default.
peer_byte_budget8 MiBBytes all slots of one peer, on all its lanes, can hold in flight on the receive side.
credit_sweep_interval200 msPeriod of the whole-table credit walk, the batcher eviction check, and the check that closes expired accept windows. An unclaimed bind is reclaimed up to one interval after its accept window closes. Zero is refused at build time.
drain_visit_floor2 msShortest gap between two doorbell visits to the same (peer, lane). Zero turns the floor off. Values above 1 hour are clamped.
batcher_idle_ttl60 sHow long a batcher with no slots stays alive before eviction.
flush_policyAuto with on_admission: trueWhen a batcher writes. See Select a flush policy.
async_open_ackfalseWhether a slot open returns before the transport admits its OpenSlot.
reply_linger1 msHow long a credit reply waits for other records to share its batch. Zero writes each reply at once.

Change the credit window

initial_credit sets how many records a sender can send on one slot before it needs a grant. The consumer node's value is the one that counts: it advertises its window when a stream attaches, and the sender uses that.

  • Keep the default of 32 for token streams. A larger window lets every stream fill the shared path between a producer and a saturated consumer node, and a new stream's first record then waits behind all of it. See The credit window and a saturated frontend.
  • A stream longer than the window needs grants. The consumer node returns credit as its consumer drains, usually when the next batch from the producer arrives. A live consumer therefore rarely stalls its producer.
  • The window is also how far a sender can run ahead of a consumer that stops polling. On a single-sender stream, credit returns only when the consumer takes a record from the slot buffer. An MPSC anchor's pump moves records into the anchor channel, so its senders can run further ahead.
  • Do not set a small window to save memory. A small window raises the credit-return latency per record. For a producer that ran out of credit, it is (drain_visit_floor + reply_linger) / initial_credit.
  • Raise the window for a few high-rate streams to an otherwise idle consumer. With no other traffic from the producer, a starved stream gets its credit back only through the doorbell and the reply linger, so a small window caps its rate.
  • Do not set zero. Zero on the wire means "not offering the mux", and the build refuses it.

Each slot buffer holds initial_credit + 1 records. The byte budgets, not the record count, bound the memory.

Change the byte budgets

slot_byte_budget sets how far a producer can run ahead of its consumer before its send waits. A larger cap absorbs longer bursts, and costs that much memory per slot on the producer's node. A smaller cap makes the producer wait sooner.

If one peer carries many slots with large records, increase peer_byte_budget. It bounds only the receive side.

Read Stream saturation before you change either budget.

Leave the credit timers at their defaults

credit_sweep_interval, drain_visit_floor and reply_linger interact. Keep the defaults unless a measurement shows a reason.

  • The arrival path returns most credit on the next inbound batch. The periodic sweep is a backstop. A shorter sweep interval costs CPU in proportion to peers × slots and gains little.
  • drain_visit_floor limits contention on the ingress lock of each (peer, lane). Setting it to zero lets a fast consumer drive back-to-back walks under that lock.
  • reply_linger lets credit replies share batches. The measurement used a 512-worker rig at 8,192-way concurrency. The 1 ms default cut the frontend's outbound batches by 4.3 to 5.9 times against zero. It also cut the frontend's total write time from 13.5–14.3 s to 2.8–3.9 s per run.

If you set reply_linger to zero, each credit reply writes its own batch again. Do this only to compare against the earlier behavior.

Do not enable async_open_ack without a measurement

async_open_ack returns from a slot open before the transport admits the OpenSlot. At 512 workers and 8,192-way concurrency, it made first-token p95 worse in every rep and did not improve p50.

It also parks producers behind a congested peer. On such a peer, a producer can fill the slot byte cap before its own OpenSlot is admitted, and then waits in send although its consumer is healthy. Nothing caps how many slots can wait this way against one congested peer, so egress memory grows with the number of opens.

Do not enable it unless both of these conditions are true:

  • The queue depth of a congested peer is bounded.
  • You measured the open time that it removes, at your own concurrency.

Use zero-RTT stream setup

Zero-RTT setup removes the attach round trip from each stream. If the consumer already sends each request to the producer in an envelope that you control, use it.

  1. On the consumer, create the anchor.
  2. Call velo.prebind_anchor(handle) from a runtime context.
  3. If the call returns Some(ticket), put the ticket in the request envelope. StreamOpenTicket implements Serialize and Deserialize.
  4. If the producer is a different worker and the envelope has a ticket, call velo.open_anchor_stream::<T>(handle, ticket) on the producer.
  5. If the producer is the worker that called prebind_anchor, call velo.attach_anchor::<T>(handle). open_anchor_stream on the minting worker fails at once.
  6. If the envelope has no ticket, call velo.attach_anchor::<T>(handle).
#![allow(unused)]
fn main() {
// Consumer, at request registration.
let anchor = velo.create_anchor::<Token>();
let ticket = velo.prebind_anchor(anchor.handle());
send_request(RequestEnvelope { handle: anchor.handle(), ticket }).await?;

// Producer, on receipt.
let minted_here = envelope.handle.unpack().0 == velo.instance_id().worker_id();
let sender = match envelope.ticket {
    Some(ticket) if !minted_here => velo.open_anchor_stream::<Token>(envelope.handle, ticket).await?,
    _ => velo.attach_anchor::<Token>(envelope.handle).await?,
};
}

prebind_anchor returns a ticket whenever the consumer has the mux and the anchor can be pre-bound, and the mux is on by default. A producer without the mux cannot open that ticket: open_anchor_stream fails, and the first attach_anchor is refused. The refusal releases the pre-bind, so a retry attaches on the per-stream path. To avoid these failures when you roll the mux out, upgrade the producers before the consumers that mint tickets, or keep the mux off on those consumers until every producer has it.

A ticket sender observes stop_token() and cancellation_token() through the mux's slot lifecycle. Stop leaves the stream open for final output. Cancel or consumer drop wakes an idle producer and ends delivery; it does not need a later send.

The ticket stays valid for the configured accept window (180 seconds by default). After the window, the consumer reaps the bind and sees SenderDropped.

Roll back

  1. Set enabled: false, or set VELO_MESSENGER_MUX_DISABLE=1, on the nodes that mint tickets.
  2. Restart those nodes.
  3. Do the same on the producers.
  4. Restart the producers.

CAUTION: Do not roll back a producer alone while its consumer still mints tickets. The consumer refuses the producer's attach, because the producer no longer offers the pre-bound key.

Rolling the mux out needs the reverse order: producers first, then the consumers that mint tickets. See Use zero-RTT stream setup.

Without zero-RTT setup, the order does not matter. Each new attach negotiates the per-stream path.

Settings that are not levers

These changes were measured and did not help. Do not try them again without a new reason:

  • A grant threshold of half a window, measured at a window of 256. It cut credit traffic 19-fold, did not change CPU, and made inter-token p99 worse.
  • A 500 µs data linger on the producers (Auto { max_linger }). It saved about 1 ms of frontend CPU per request and added about 10 ms to the request path.
  • A shorter credit_sweep_interval for faster credit. The arrival path already returns credit on the next batch.
  • async_open_ack, as described above.

Response plane performance has the measurements.

For work that exceeds the initial window, renew held tickets with AnchorManager::renew_reservations. Configure lifetimes through VeloBuilder::reservation_config; see reservation renewal.

Run rendezvous over RDMA

This guide builds velo with the ucx feature and wires the UCX transport for the rendezvous RDMA path. It then verifies that the path runs on the NIC you expect. For how the path works, see Rendezvous and RDMA.

What you need

  • Linux on x86_64 or aarch64. On other platforms the ucx feature builds but adds no transport.
  • A C compiler and GNU make. The vendored UCX tarball ships a generated configure, so autotools and cmake are not necessary for this crate.
  • The rdma-core development headers. The build looks for three files: infiniband/verbs.h, infiniband/mlx5dv.h, and rdma/rdma_cma.h. On Debian and Ubuntu, libibverbs-dev and librdmacm-dev supply them.
  • For real RDMA: an InfiniBand or RoCE port in state PORT_ACTIVE, and a memlock limit large enough for the registered-bytes budget.

The headers are necessary even for a build that only runs over UCX_TLS=tcp. The UCX tests and CI run that way with no RDMA hardware.

Build

  1. Install the headers:

    sudo apt-get install -y libibverbs-dev librdmacm-dev
    
  2. Turn on the feature in your Cargo.toml:

    velo = { version = "...", features = ["ucx"] }
    
  3. Build:

    cargo build --features ucx
    

The first build compiles UCX 1.22.0 from the tarball inside crates/ucx-rs. This takes about 25 s on a 20-core machine (about 73 CPU-seconds). Later builds reuse it in about 0.3 s. A stamp file records the exact configure arguments and toolchain variables, so a change to either rebuilds UCX.

The result links UCX statically. At run time, no UCX shared objects, plugin directories, or LD_LIBRARY_PATH settings exist. The only dynamic dependencies are libibverbs.so.1, libmlx5.so.1, and librdmacm.so.1.

Why the build refuses a TCP-only UCX

If the verbs headers are missing, UCX's own configure prints a warning and builds a UCX with only TCP and shared memory. That build passes CI and then runs production traffic over TCP. ucx-rs refuses instead, with a panic that names the missing header.

For the same reason, ucx-rs passes --with-devx. Upstream defaults DEVX to check, which silently drops the accelerated mlx5 memory domain.

Build variables

VariableEffect
CPATH, C_INCLUDE_PATHExtra header directories for the header check.
UCX_RS_IGNORE_MISSING_HEADERSSkip the header check, for a non-standard prefix that configure still finds.
UCX_DIR=/opt/ucxLink a preinstalled UCX 1.17 or later instead of the tarball. It needs a shared libucp.so. The transport modules are then UCX's own plugins.
UCX_EXTRA_CONFIGUREExtra configure arguments, appended last. The build refuses --without-devx, --with-devx=no, and --with-devx=check, because the last option wins.
CC_<target> or CCThe cross compiler. For a cross build with no cross compiler, the build stops. Without this check, configure silently builds for the host.

The velo feature ucx turns on the ucx-rs default features: ib, rdmacm, and mt. You cannot turn off ib through velo.

Wire the transport

Add UCX with add_ucx_transport, not add_transport. Only add_ucx_transport creates the RDMA registry. Keep a TCP transport beside it for the control plane.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use velo::transports::tcp::TcpTransportBuilder;
use velo::transports::ucx::UcxTransportBuilder;
use velo::{RdmaConfig, Velo};

let tcp = Arc::new(
    TcpTransportBuilder::new()
        .from_listener(std::net::TcpListener::bind("0.0.0.0:0")?)?
        .build()?,
);
// Eager wireup moves the ~14 ms first-GET endpoint cost to register().
let ucx = Arc::new(UcxTransportBuilder::new().eager_endpoints(true).build()?);

let velo = Velo::builder()
    .add_transport(tcp)
    .add_ucx_transport(ucx)
    .rdma_config(RdmaConfig::default())
    .build()
    .await?;

// Owner: stage in registered memory. This call also maps the first arena.
let handle = velo.register_data_pinned(&payload).await;
}

The consumer calls get, get_pinned, or get_into as usual. Register the peers before the first get. A get to an owner that is not registered yet takes the chunked path.

If the only staging in a process is transparent (large messenger payloads), call register_data_pinned once at startup. The transparent stager never maps an arena, so without that call it never uses the RDMA path.

Set the runtime environment

Velo reads the UCX variables from the environment. The environment wins over the builder. UcxTransportBuilder::tls and net_devices apply only when UCX_TLS or UCX_NET_DEVICES is not set.

VariableValueWhy
UCX_NET_DEVICESthe port, for example mlx5_2:1Pins the NIC and port. Do not let UCX choose.
UCX_TLSrc_mlx5,ud_mlx5,self or rc_verbs,ud_verbs,selfPins the transport. An RC-only list cannot wire up. See the note below this table.
UCX_PROTO_INFOyPrints the protocol table, with the device for each range.
UCX_LOG_LEVELdebugShows which memory domain opened.
UCX_IB_MLX5_DEVXyForces the DEVX memory domain open. See the failures table.
VELO_RDMA_RENDEZVOUS_DISABLE1Turns the RDMA path off with no rebuild.

NOTE: No hardware run has used velo's build on rc_mlx5 yet. All two-node results so far are on rc_verbs (see RDMA performance). On rc_mlx5, do all steps in Verify the lane.

Velo sets UCX_RCACHE_ENABLE=n and UCX_MEM_EVENTS=n unless you set them. With these values, UCX patches no libc functions in the process. Keep them. Velo registers memory explicitly and never uses the UCX registration cache.

Do not set UCX_PROTO_ENABLE=n. It is a common workaround for protocol-v2 faults, and it silently disables all RMA. A multi-rail GET needs UCX_MAX_RMA_RAILS of 2 or more. The default is 1.

Prepare each node

  1. Find the port:

    ibv_devinfo
    
  2. Pick a port with state: PORT_ACTIVE and link_layer: InfiniBand. An active Ethernet port on the same card carries RoCE and looks like a success.

  3. Set UCX_NET_DEVICES to that port, for example mlx5_2:1.

  4. Set UCX_TLS. Include a ud transport beside the rc transport.

  5. Raise the memlock limit:

    ulimit -l unlimited
    
  6. If the IB partition gives this node partial pkey membership (0x7fff), set UCX_IB_PKEY explicitly. UCX_IB_PKEY=auto needs full membership, and raw verbs works where UCX fails.

Some systems ship a memlock limit of 8 MiB. With that limit, ibv_reg_mr fails at 32 MiB, and the first 64 MiB arena cannot map.

Verify the lane

A correct transfer does not prove that RDMA ran. The chunked path also returns correct bytes. Do all three steps.

  1. Run with UCX_LOG_LEVEL=debug and find the memory domain line:

    UCX_LOG_LEVEL=debug ./your-binary 2>&1 | grep 'md open by'
    

    On a mlx5 NIC, the line must say uct_ib_mlx5_devx_md_ops. If it says uct_ib_verbs_md_ops, the accelerated transports have no devices.

  2. Run with UCX_PROTO_INFO=y. Find the row rendezvous zero-copy read from remote. It must name the transport and device that you set, for example rc_mlx5/mlx5_2:1.

  3. Read velo_rendezvous_rdma_path_total on the consumer. The series {path="rdma", reason="ok"} must increase with each transfer. Any other reason names why the path was not used. See Rendezvous and RDMA.

Do not use the system ucx_info to verify velo's build. The system UCX loads its transports as plugins and is a different library. On the same NIC, the system UCX can report rc_mlx5 while velo's static build does not.

Do not trust a configuration header that a program prints. The environment overrides the builder, so a header that says UCX_TLS=tcp can describe a run on InfiniBand. The UCX_PROTO_INFO table is the truth.

Version compatibility

Two velo nodes can talk over UCX only if they use the same UCX wire version. Each node publishes that version in its UCX address, and register() refuses a peer on a different one.

The current version is 2, from velo 0.15.0. Version 2 starts every frame header with the sender's 8-byte incarnation, so that the endpoint idle reaper can tell which peer sent each frame. A version-1 node would misread those 8 bytes, so the two versions refuse each other instead.

Upgrade all UCX peers together. During a rolling upgrade, an old node and a new node cannot reach each other over UCX. register() returns InvalidEndpoint, and the refusing node logs a warning: ucx: rejecting peer blob: unsupported ucx blob version 1 (expected 2).

Run the two-process example

The example stages 8 MiB on an owner and pulls it on a consumer, with a TCP control plane and UCX beside it. The consumer exits non-zero if the transfer did not take the RDMA path.

On one host, over TCP:

UCX_TLS=tcp cargo run --manifest-path examples/Cargo.toml --release --features ucx --example rendezvous_rdma_two_proc

With no --role, the example starts both roles as child processes.

On two nodes:

  1. Build on both nodes:

    cargo build --manifest-path examples/Cargo.toml --release --features ucx --example rendezvous_rdma_two_proc
    
  2. Pick a directory that both nodes can read and write, for example /shared/rv.

  3. Start the owner on node A:

    UCX_NET_DEVICES=mlx5_2:1 UCX_TLS=rc_mlx5,ud_mlx5,self \
      ./examples/target/release/examples/rendezvous_rdma_two_proc --role owner --dir /shared/rv
    
  4. Start the consumer on node B, with the same variables and --role consumer.

  5. Read the velo_rendezvous_rdma_path_total lines that both sides print at the end.

Use --size <bytes> to change the payload. The two processes find each other through JSON files in --dir. On NFS, negative-dentry caching can delay that exchange by about 30 s. That delay is not a velo latency.

CI runs the single-host form with UCX_TLS=tcp on every pull request.

Run the tests

The UCX suites set tls("tcp") in code. The environment wins, so the same suites run on InfiniBand when UCX_TLS and UCX_NET_DEVICES are set.

# Rendezvous RDMA path, end to end
timeout 300 cargo test -p velo --features ucx,test-helpers --test rendezvous_rdma

# UCX transport and RMA plumbing
timeout 300 cargo test -p velo --features ucx,test-helpers --lib transports::ucx

# Registration layer
timeout 300 cargo test -p velo --features ucx,test-helpers --lib rendezvous::rdma

# The mlx5 memory domain sits ahead of the verbs one
timeout 300 cargo test -p ucx-rs --test ctor_order

# Registration and GET cost (prints numbers, asserts nothing)
timeout 300 cargo test -p velo --features ucx,test-helpers --lib bench_rma -- --ignored --nocapture

CAUTION: Do not run unusable_rkey_is_refused_by_ucx_over_tcp on InfiniBand. It aborts the process with SIGABRT there. Over TCP it passes.

Skip it on an InfiniBand run:

timeout 300 cargo test -p velo --features ucx,test-helpers --lib transports::ucx -- --skip unusable_rkey_is_refused_by_ucx_over_tcp

Common failures

SymptomCauseFix
UCX WARN transports 'rc_mlx5','ud_mlx5' are not available, and transfers report chunkedThe mlx5 memory domain did not open. The verbs domain opened in its place.Rebuild with the current ucx-rs. On an old binary, set UCX_IB_MLX5_DEVX=y.
md open by 'uct_ib_verbs_md_ops' on a mlx5 NICSame as above.Same as above. Run cargo test -p ucx-rs --test ctor_order.
no auxiliary transport ... Destination is unreachableUCX_TLS names an RC transport with no ud transport.Add ud_mlx5 or ud_verbs.
ucp_init: ... InvalidParam with no UCX log outputA UCX constructor did not run. The logging subsystem registers from a constructor, so nothing logs.Make sure that the binary references ucx_rs (use ucx_rs as _; in a crate that links it directly).
Build panic: feature \ib` is enabled but <infiniband/verbs.h> was not found`The rdma-core headers are missing.Install libibverbs-dev and librdmacm-dev.
configure fails: devx requested but not foundThe rdma-core on the build host has no DEVX support.Install a newer rdma-core.
undefined symbol: __aarch64_ldclr4_sync (aarch64)The linker did not find the static libgcc.a.Install the libgcc-<version>-dev package.
relocation R_AARCH64_ADR_PREL_PG_HI21 ... recompile with -fPICA UCX built without --with-pic went into a shared object.Use the vendored build, or rebuild the system UCX with --with-pic.
anonymous version tag cannot be combined with other version tagsA cdylib added its own --version-script. rustc already supplies one.Remove the extra script. rustc's script already hides every UCX symbol.
Consumer reports no_offerThe owner was not registered on the consumer yet (first get), or UCX is not registered for the owner.Register the peer before the first get. Make sure that the owner advertises a UCX address.
Owner reports no_offerThe consumer sent no offer: it has no UCX, its kill switch is on, or this was the fallback acquire after a failed GET.Read the consumer's own path_total reasons.
Either side reports kill_switchVELO_RDMA_RENDEZVOUS_DISABLE is set to an affirmative value, or RdmaRendezvousConfig::enabled is false.Unset the variable and restart.
not_configuredUCX was added with add_transport.Use add_ucx_transport.
Owner reports drainingThe owner called begin_drain. It serves pulls of staged payloads chunked until it shuts down.Nothing to do. This is expected during a shutdown.
pool_exhausted on transparent sendsNo arena is mapped.Call register_data_pinned once at startup.
budgetThe registered-bytes budget is spent.Raise RdmaPoolConfig::registered_bytes_budget, or set arena_reclaim_after.
ibv_reg_mr fails at 32 MiBThe memlock limit is 8 MiB.Set ulimit -l unlimited, or grant IPC_LOCK in a container.
Process abort: rc_verbs_impl.h:104 Fatal: receive completion ... with errorA GET used an unusable or stale rkey on InfiniBand.Report it. The single-use rkey rule was broken, or the TCP-only test above ran on InfiniBand.
ucp_ep.c:2222 UCX ERROR ep ... has already been closed at teardown on InfiniBandA double close during teardown. Assertions still pass.None. This is known log noise.
register() returns InvalidEndpoint, with the warning ucx: rejecting peer blob: unsupported ucx blob versionThe peer runs a velo with a different UCX wire version.Upgrade all UCX peers together. See Version compatibility.
tcp_ep ... recv(-1) failed: Input/output error on a peerThe endpoint idle reaper closed an endpoint.None, if ep_idle_timeout is on. The node that logged this loses its Messages to the node that closed the endpoint, and, inferred, its pings, until keepalive fails its endpoint (about 20 s). See Endpoint idle reaper.

Run the examples

The examples are in the examples/ crate. This crate is not a workspace member. Run the commands from examples/, or add --manifest-path examples/Cargo.toml from the repository root.

ExampleWhat it showsFeatures
ping_pongRound-trip time for unary messages between two instanceszmq, grpc, quic or ucx for those transports
throughputMessages per second and bytes per second for sequential, concurrent, and pipelined sends, and for streams (--modes stream), and p50/p95/p99 for sequential and concurrent sendszmq, grpc, quic or ucx for those transports
tx_budgetThe latency budget of the send path, one layer at a timenone
mpsc_faninMany producers into one MPSC anchornone
batched_streamingBatched streaming over the mux, in the shape of LLM servingnone
response_plane_benchLoad harness for the streaming response planenone
stream_lifecycleWhat a producer pays to learn that a stream's output left its nodenone
anchor_setup_benchWhat an SPSC anchor costs to create, attach and finish, and what an unattached anchor holdsnone
rendezvous_rdma_two_procTwo processes that move 8 MiB by RDMA GETucx
soakStress and correctness driver for messaging, streams, and rendezvousgrpc

Messaging examples

cargo run --example ping_pong --all-features -- --rounds 1000
cargo run --example throughput --all-features -- --count 10000
cargo run --example mpsc_fanin --all-features -- --producers 4 --items 40

ping_pong and throughput take --transport {tcp,uds,zmq,nats,grpc,quic,ucx}. The default is tcp. The nats transport needs a nats-server on 127.0.0.1:4222.

The latency budget (tx_budget)

tx_budget adds one layer on each rung. The difference between two rungs is the cost of the layer that was added.

RungWhat it adds
l0Blocking write and read on OS threads: the syscall and the kernel round trip
l1The same bytes over tokio: the reactor, the task wake, and the scheduler
l2TcpFrameCodec encode and decode
l3The full Transport::send_message path, up to the consumer

Each rung is an echo ping-pong of the same frame size in both directions, so the one-way time is half of the round trip. Use tx_budget to find which layer caused a change in latency.

Soak

soak messenger|stream|rendezvous|all --transport tcp|grpc --tier ci|nightly|long [--faults]

CI runs the ci tier on each PR. A nightly workflow runs the full set.

Benchmarks

For batched_streaming, response_plane_bench, stream_lifecycle and anchor_setup_bench, see Benchmarking. For rendezvous_rdma_two_proc, see Run rendezvous over RDMA.

Observability

velo::observability gives Prometheus metrics for all subsystems. You create a Registry and register VeloMetrics into it. Velo does not run an exporter. Serve or scrape the registry with your own code.

use prometheus::Registry;
use velo::{Velo, VeloMetrics};

let registry = Registry::new();
let metrics = Arc::new(VeloMetrics::register(&registry)?);
let node = Velo::builder()
    .add_transport(tcp)
    .metrics(metrics)
    .build()
    .await?;

For the full list of families, see the Metrics reference.

Families

AreaWhat it covers
TransportFrames, bytes, rejections, registered peers, active connections, send backpressure. TCP, UDS, and QUIC also give egress queue wait, frames written, and write duration.
MessengerHandler requests, durations, payload bytes, in-flight handlers, dispatch failures, departures from the inbound queue, ordered lanes
StreamingAnchor operations and durations, attach round-trip time, active anchors, backpressure, and the batched streaming (mux) families
Rendezvous and RDMAStage, get, and release operations, durations, bytes, active slots, registered bytes, RDMA path decisions

With the distributed-tracing feature, Velo puts OpenTelemetry trace context in the message headers.

Read some metrics as differences

Some quantities have no gauge. A sampled channel length reads the wrong number under the load that makes the depth worth knowing. Velo gives two counters instead, and you subtract them.

Before you subtract, aggregate both sides to the same labels. The two sides often have different labels, so a bare a - b matches nothing.

Inbound queue depth

sum by (job, instance) (velo_transport_frames_total{direction="inbound",message_type="message",outcome="accepted"})
- sum by (job, instance) (velo_messenger_inbound_dequeued_total)
  • The result can go negative for a short time. A transport counts the frame after admit_message has already put it on the queue, so the consumer can count the departure first. Clamp at zero.
  • After a Timeout shutdown, the result stays high by the number of messages that were abandoned. Those messages never count as departures.
  • The difference is correct only for transports that record what they admit. All in-tree messenger transports do. The simulation transport does not.

Egress queue depth, for each transport

sum by (job, instance, transport) (velo_transport_frames_total{direction="outbound",outcome="accepted"})
- sum by (job, instance, transport) (velo_transport_frames_written_total)

This is the number of frames in front of the socket: in the bounded send channel, or staged in the writer. It has these limits:

  • It does not include frames held in the admission gate. velo_transport_send_backpressure_total counts those, and velo_transport_egress_queue_wait_seconds covers both the gate and the channel.
  • It stops at the kernel. By default the TCP sockets request a 2 MiB send buffer (see Socket buffers). To see bytes below the counter, read tx_queue for the socket. On QUIC it stops at quinn's send buffer instead, in user space, and a frame counted as written can still wait there for the peer's acknowledgement.
  • It can go negative for a short time. Clamp at zero.
  • It stays high after a frame fails: a replaced connection, a socket error, a failed connect, or a stop of the writer. Those frames were accepted and never written.
  • Only the coalescing writer (TCP, UDS, and QUIC) publishes frames_written. gRPC, NATS, ZMQ, and UCX have no such series, so the query returns no rows for them. Do not add or vector(0), because zero reads as "queue empty", not "not measured".
  • The transport label is the TransportKey of the transport, not a fixed name. Select on the series, not on a name pattern.

Queueing on the receiver during attach

  sum(rate(velo_streaming_anchor_attach_rtt_seconds_sum{outcome="success"}[5m]))
/ sum(rate(velo_streaming_anchor_attach_rtt_seconds_count{outcome="success"}[5m]))
-
  sum(rate(velo_streaming_anchor_operation_duration_seconds_sum{operation="attach",outcome="success"}[5m]))
/ sum(rate(velo_streaming_anchor_operation_duration_seconds_count{operation="attach",outcome="success"}[5m]))

Both families are histograms, so the query compares mean durations from _sum and _count. The sum removes the labels that differ between sender and receiver.

The sender records the round trip. The receiver records the time inside the handler. The difference is an upper bound on the queueing at the receiver: it also includes the send path, both wire legs, the handler spawn, and the wake of the sender task.

  • The query uses outcome="success" on both sides, because the error populations do not compare.
  • The query sums away transport_scheme and instance. The two series come from different nodes, and the sender can record "unknown" for a scheme that the receiver named.
  • SPSC and MPSC attaches share one RTT series, so the result is an average over both.

Batched streaming

For the mux families and how to find a stream that cannot keep up, see Stream saturation.

Stream saturation

If streaming traffic shows sender errors, missed deadlines, or a Dropped frame with no producer crash, use this runbook. The usual cause is saturation. The producer generates frames faster than the consumer drains them. Velo reports saturation through Prometheus counters and one log line. This page tells you which signals to read and what to do.

The signals differ between the per-stream path and the messenger mux. Read The per-stream cascade for streams with one TCP or gRPC connection each. Read Saturation under the mux for streams that negotiated messenger-mux-v2.

The per-stream cascade

A per-stream stream uses a chain of bounded flume channels around a TCP or gRPC byte stream:

flowchart TD
    P[producer: StreamSender::send] --> C1[connect-side channel, 4096]
    C1 --> T[TCP socket buffers and network]
    T --> C2[bind-side channel, 4096]
    C2 --> R[reader pump]
    R --> A[anchor frame_tx, 256: smallest, fills first]
    A --> Q[consumer: StreamAnchor::next]

When the consumer falls behind, the 256-deep anchor channel fills first. Pressure then moves back up the chain:

  1. The anchor channel (256) fills.
  2. The reader pump's try_send returns Full. velo_streaming_reader_pump_backpressure_total increments.
  3. The bind-side channel (4,096) fills.
  4. The server pump's try_send returns Full. velo_streaming_server_pump_backpressure_total increments.
  5. The TCP receive buffer fills, and the kernel sends a zero-window ACK.
  6. The producer's TCP write stalls.
  7. The connect-side channel (4,096) fills.
  8. StreamSender::send sees Full. velo_streaming_producer_send_backpressure_total increments, and the producer waits in send_async.
  9. The sender's heartbeats use try_send, so a full channel drops them. After DETECTION_MULTIPLIER × heartbeat_interval of silence (3 × 5 s by default), the reader pump's watchdog fires. velo_streaming_heartbeat_watchdog_firings_total increments, and the session ends.

The consumer sees one of two results when the watchdog fires:

  • The anchor channel had room. This is the common case, usually a producer that died. The consumer receives a Dropped frame, which StreamAnchor::next() returns as StreamError::SenderDropped.
  • The anchor channel was full. The watchdog uses a non-blocking try_send, so that registry and cancel cleanup cannot deadlock. The Dropped frame is lost. The consumer drains the queued frames and then receives None (a clean end of stream). A tracing::warn! line with the local_id records the loss.

In both cases velo_streaming_heartbeat_watchdog_firings_total is the authoritative signal. Do not detect watchdog kills from StreamError::SenderDropped alone.

Counters

All four counters have no labels. They register in the Prometheus registry that you pass to Velo::builder().metrics(...).

MetricMeaningHow to read it
velo_streaming_reader_pump_backpressure_totalThe 256-deep anchor channel was full, and the reader pump fell through to an awaited send.Leading indicator. A sustained non-zero rate means that the consumer is at or near saturation. Per-stream path only: a mux stream has no reader pump, so this counter stays at zero there.
velo_streaming_server_pump_backpressure_totalThe 4,096-deep bind-side channel was full.The cascade moved past the anchor channel. With reader-pump backpressure, this shows saturation.
velo_streaming_producer_send_backpressure_totalStreamSender::send found its channel full.The producer now waits in send_async.
velo_streaming_heartbeat_watchdog_firings_totalA session was silent for DETECTION_MULTIPLIER × heartbeat_interval, and the reader pump ended it. Under the mux, the stream watchdog increments it too.Lagging indicator. Any increase means that a session died.

A backpressure counter measures events, not latency, throughput or bytes. A high rate means that the system is at its capacity. A zero rate means that there is headroom.

Dashboard

Build at least three panels:

  1. rate(velo_streaming_reader_pump_backpressure_total[1m]). A rising rate is the earliest warning on the per-stream path. For mux streams, use rate(velo_streaming_slot_credit_exhausted_total[1m]) instead, which the producer's node counts.
  2. rate(velo_streaming_server_pump_backpressure_total[1m]) and rate(velo_streaming_producer_send_backpressure_total[1m]) beside the first panel. When these rise in sequence, the cascade is moving up.
  3. increase(velo_streaming_heartbeat_watchdog_firings_total[5m]). Any non-zero value is a dead session. If the rate panels rose first, the cause was saturation. If they stayed flat, the producer crashed or the network failed.

The watchdog log line

When the watchdog fires on the per-stream path, the reader pump writes one warn line:

reader_pump: heartbeat watchdog fired, injecting Dropped (saturation indicator: see velo_streaming_*_backpressure_total)
  local_id=...
  anchor_frame_tx_len=256 anchor_frame_tx_cap=256
  transport_rx_len=4096 transport_rx_cap=4096
  heartbeat_deadline_ms=5000
  detection_multiplier=3

Read the channel depths:

  • If anchor_frame_tx_len equals anchor_frame_tx_cap, the consumer side was saturated.
  • If both depths are near zero, the silence came from upstream of the consumer. The cause is a producer crash, a network partition, or a backlog on the producer's egress.

On a single-sender mux stream, the stream watchdog writes a different line. An MPSC anchor's reader pump, on any transport, neither logs a firing nor counts it:

stream_watchdog: nothing arrived from a sender holding credit for the detection window, injecting Dropped
  local_id=...
  slot_buffer_len=...
  arrivals=...
  heartbeat_deadline_ms=5000
  detection_multiplier=3

This watchdog fires only when nothing was delivered to the slot for the whole detection window while its sender still held data credit or had records waiting behind a sequence gap. Heartbeats spend data credit, so a sender that held credit could have sent one. A consumer that falls behind leaves its sender without credit, and the watchdog exempts such a sender, so a firing on a mux stream is never a consumer that fell behind. Records still in the slot buffer when it fires are not delivered: the consumer reads SenderDropped next. The silence came from upstream: a producer crash, a network partition, or a backlog on the producer's egress or the peer link. On a mux peer link that carries thousands of streams, heartbeats wait in the same queue as data. A deep enough backlog there silences a live sender. In one measured UCX run, 1,722 streams on one congested peer were killed this way while their worker was healthy.

Mitigations

Apply these in order, from the smallest change to the largest:

  1. Slow the producer. A tokio::time::sleep of 100 µs between sends, or tokio::task::yield_now().await, often ends saturation. The producer does not know the consumer's rate. Back off voluntarily, or use the producer-side backpressure counter as feedback.
  2. Speed up the consumer. Move work out of the anchor.next().await loop into a separate task. The anchor consumer must only take each frame and hand it on.
  3. Resize the MPSC anchor channel. MpscAnchorConfig::channel_capacity sets the depth of an MPSC anchor channel (256 by default). A larger channel absorbs bigger bursts at a memory cost per anchor. The SPSC anchor channel is fixed at 256.
  4. Reduce the number of concurrent anchors on the per-stream path. Each anchor adds channel memory and a reader pump. If your application creates one anchor per work item, batch the work items into one anchor. For many streams to few peers, use the mux, which is on by default. See Tune batched streaming.

Saturation under the mux

A muxed stream has no socket of its own. It has credit. A consumer that stops draining stops returning credit, and the stream's egress parks. The producer then waits, as it waited on a full socket buffer.

The batcher pulls each slot's records from the slot inlet into a per-slot withheld queue, whether or not the slot has credit. The slot byte budget (1 MiB by default) bounds that queue. When the queue reaches the budget, the batcher stops pulling from that slot's inlet. The inlet (C+1 records deep) then fills, and StreamSender::send waits. When credit returns and the queue drops below the budget, the batcher pulls again.

flowchart TD
    P[producer: StreamSender::send] --> I[slot inlet, C+1]
    I -->|until the byte budget| W[batcher: withheld queue, slot byte budget]
    W -->|credit available| B[_stream_batch on the peer connection]
    B --> S[consumer slot buffer, C+1]
    S -->|read directly| Q[consumer: StreamAnchor::next]
    W -.->|budget reached: inlet paused| I

Producer backpressure

A producer faster than its consumer waits in send. The stream stays open, and nothing is dropped. This holds for a consumer that drains more slowly than the producer sends, and for a consumer that stopped draining.

finalize, detach and Drop are synchronous. When the inlet is full, the terminal waits in a task, so these calls do not block. The terminal then goes out after the records ahead of it.

An earlier design closed the slot when the queue passed the budget. That design also closed streams whose consumer was draining, because any producer faster than one credit round trip reached the budget within milliseconds.

The consumer reads the slot buffer itself, so credit returns only when it takes a record. A consumer that stops polling holds its sender to the credit window C, and then to the byte budget. The stream watchdog exempts a sender that holds no credit, so it never ends a stream whose consumer stopped polling with its window full. Such a stream stays open until the application drops the anchor. If the producer dies while it still holds credit, the watchdog ends the stream on time, even with records unread. If it dies holding no credit, the watchdog ends the stream once the consumer reads enough to return credit to it. velo_streaming_reader_pump_backpressure_total does not move for mux streams. Watch velo_streaming_slot_credit_exhausted_total, which the producer's node counts.

If the slot is fenced behind an unresolved OpenSlot or rendezvous admission, its records wait for that admission, and the producer waits at the byte budget. If a producer leaves while its slot is fenced, the consumer's Dropped waits for the admission too. If the admission fails, the failure is epoch death for the (peer, lane) of the slot. The slot is retired without the deferred Dropped, and the consumer falls back on the heartbeat watchdog.

The knob is MuxConfig::slot_byte_budget. A larger budget lets a producer run further ahead of its consumer, and costs that much memory per slot on the producer's node.

Mux counters

MetricMeaning
velo_streaming_slot_credit_exhausted_totalA slot on the sender ran out of credit. This is the mux equivalent of consumer backpressure.
velo_streaming_mux_withheld_recordsGauge of records waiting in withheld queues on this node.
velo_streaming_producer_send_backpressure_totalThe slot inlet (C+1 deep) was full. Under the mux, a full inlet means that the slot's withheld queue reached the byte budget, or that the batcher is parked on transport admission.
velo_transport_send_backpressure_totalThe transport's admission gate returned Pending. The peer connection is congested.
velo_streaming_mux_reader_stall_totalMust be zero. A non-zero value is a bug in the credit invariant.
velo_streaming_mux_live_slotsGauge of open slots. It must return to zero at teardown.

velo_streaming_producer_send_backpressure_total changes meaning under the mux. On the per-stream path it means that the 4,096-deep connect-side channel was full. Dashboards built on that meaning shift once streams negotiate the mux, which they do by default. Watch velo_streaming_slot_credit_exhausted_total for consumer-driven saturation.

The async_open_ack exposure

With MuxConfig::async_open_ack enabled, the OpenSlot fence withholds a slot's records from the first record, with or without credit. A producer that starts generating into a peer whose send queue is congested fills the byte budget before its own OpenSlot is admitted, and then waits in send. The cause is sender-side congestion, not the consumer.

peer_byte_budget bounds the receive side only. N concurrent opens into a stalled peer can hold N times the slot byte budget in egress memory. The default awaited open serializes new opens behind the same admission and bounds this to one wait at a time.

Write coalescing ratio

Two counters describe the write side on the per-stream path:

MetricMeaning
velo_streaming_frames_written_totalStream frames written to the wire.
velo_streaming_egress_flushes_totalBatches the egress pump handed to the socket.

Their ratio is the coalescing ratio. The egress pump packs everything already queued on one stream into one flush. A stream whose producer runs ahead reports hundreds of frames per flush. A stream that emits one frame at a time reports about 1.0.

A flush is a unit of coalescing, not a syscall. write_all can loop over several writes. An oversized frame is written in segments and still counts as one flush. The kernel decides the TCP segmentation.

A ratio near 1.0 is not a fault. It means that one frame was queued each time the pump woke. It matters when many anchors stream to the same peer. Coalescing is per stream, so it cannot pack frames spread across streams. That case is what the mux solves. See Batched streaming. Under the mux, read velo_streaming_mux_records_per_batch{direction="sent"} instead.

Benchmarking

Use this chapter to measure the streaming response plane. It describes the four in-repo harnesses, the external serving rig, and the measurement rules that earlier mistakes made necessary. Response plane performance has the results. To compare two commits on one machine, see Review for performance.

Select the harness

HarnessUse it toWhat it reports
batched_streamingShow that the mux batches, and compare flush policies.Tokens per wire write, per engine and in total.
response_plane_benchCompare two configurations of velo under load, in one process.TTFT and inter-token latency percentiles, requests per second, tokens per write, process CPU.
stream_lifecycleMeasure what a producer pays to learn that a stream's output left its node.Streams per second, active messages per stream from each side, how long the producer holds each stream after finalize.
anchor_setup_benchMeasure the anchor path alone: create, attach on the same worker, finish.Create time, tasks and RSS held by unattached anchors; streams per second and latency percentiles of a full anchor lifecycle.
External serving rigCompare velo with another response plane inside a real serving stack.Client TTFT, ITL and end-to-end latency, throughput, frontend CPU per request, errors.

The first two in-repo harnesses model the same serving shape. Anchor hosts stand in for frontends and own one response stream per request. Engines run continuous batching: each forward pass puts exactly one token on each active request's stream. Request dispatch is an in-process channel. The response path is real: anchors live on different workers from the engines, so every attach and every token crosses a loopback TCP socket.

Run batched_streaming

Run all commands from the examples/ directory:

cargo run --release --all-features --example batched_streaming -- --engines 2 --requests 24 --max-batch 8 --tokens 40
FlagDefaultMeaning
--engines2Token producers. Each keeps its own active batch.
--requests24Requests to serve. Each request is one response stream.
--max-batch8Requests one engine holds in its active batch at once.
--tokens40Longest response, in tokens. Each request's budget comes from its index, so every run has the same composition.
--pass-delay-ms1Gap between forward passes, standing in for GPU time.
--legacyoffRun the same workload on the per-stream path.
--flush-policymanualmanual calls flush_batch() once per pass. auto lets the batcher write at every wake. Ignored with --legacy.
--initial-credit32, velo's defaultPer-stream credit window, printed in the run header. The figures in this book that say "at a credit window of 256" need --initial-credit 256.

The example has three anchor hosts. It fails the run if any request misses a token, sees an unexpected frame, or if the batcher's velo_streaming_mux_records_per_batch{direction="sent"} does not account for every token.

Keep --pass-delay-ms above zero for a per-stream comparison. With no gap, the engine runs ahead, several tokens queue on each stream, and per-stream coalescing packs them. That flatters the per-stream path and hides the effect under test.

To reproduce the flush-policy comparison in Batched streaming, run each configuration five times with --flush-policy auto and --flush-policy manual:

cargo run --release --all-features --example batched_streaming -- --engines 2 --requests 96 --max-batch 32 --tokens 40 --flush-policy auto --initial-credit 256
cargo run --release --all-features --example batched_streaming -- --engines 2 --requests 96 --max-batch 32 --tokens 40 --flush-policy manual --initial-credit 256
cargo run --release --all-features --example batched_streaming -- --engines 2 --requests 24 --max-batch 8 --tokens 40 --pass-delay-ms 0 --flush-policy auto --initial-credit 256

CI runs batched_streaming at the defaults with both flush policies.

Run response_plane_bench

Run all commands from the examples/ directory:

cargo run --release --all-features --example response_plane_bench -- --anchor-hosts 2 --engines 128 --requests 2000 --credit-sweep-interval-ms 200
FlagDefaultMeaning
--anchor-hosts3Frontends. Sets each engine's ingress peer count.
--engines2Token producers. Also each anchor host's ingress peer count.
--requests500Requests to serve.
--max-batch32Requests one engine holds in its active batch at once.
--tokens64Longest response, in tokens. The maximum is 4,096.
--pass-delay-ms1Gap between forward passes.
--credit-sweep-interval-ms2MuxConfig::credit_sweep_interval.
--legacyoffRun on the per-stream path.
--flush-policymanualmanual or auto, as in batched_streaming.
--initial-credit32, velo's defaultPer-stream credit window, as in batched_streaming.
--warmup-requests0Leave the first N requests out of the latency histograms.
--jsonoffPrint one line of JSON for scripts.

CAUTION: Set --credit-sweep-interval-ms 200 to match velo's default. The harness default of 2 ms reproduces an earlier default and exists for the sweep A/B.

The harness reports TTFT and ITL as HDR histograms (p50, p95, p99). It also reports requests per second, tokens per wire write and process CPU. CPU is split into user and system time from /proc/self/stat. The consumer asserts the position of every token, so a run fails if sharing a batch ever reorders a stream.

Sweep --engines, not --anchor-hosts. The per-peer costs on a frontend scale with its ingress peers, and an anchor host's ingress peers are the engines that stream to it. --anchor-hosts moves the smaller side of the same product.

The harness agrees with batched_streaming. At --anchor-hosts 3 --engines 2 --requests 96 --max-batch 32 --tokens 40 --initial-credit 256, it reports 5.41 tokens per write where batched_streaming reports 5.38, both at a credit window of 256.

Limits of the in-process harness

  • One process. The reported CPU belongs to the whole topology, not to one frontend. An A/B that changes one frontend-side setting is valid. A comparison with another system's per-frontend CPU is not.
  • Loopback. It removes wire time, which exaggerates the syscall term in favor of the mux.
  • Size. It runs out of memory above about 256 nodes in one process on a 374 GiB host. A 512-worker shape needs the external rig.
  • Queueing. With --max-batch well below --requests, most of TTFT is the wait for a batch slot. Read TTFT and requests per second only as comparisons between arms.
  • Raw percentiles. The harness reports percentiles over every recorded request after --warmup-requests. It does not separate steady state from the opening burst. See Steady-state and raw percentiles.

Run stream_lifecycle

Run all commands from the examples/ directory. The harness runs two nodes in one process over loopback TCP, on the messenger mux, or on one TCP connection per stream with --path per-stream. The producer opens short streams, finalizes each one, and then does what --mode says:

--modeAfter finalize, the producer
plain (default)Moves on at once.
retiredAwaits StreamSender::retired().
ackWaits for an active message that the consumer sends after it reads Finalized.
FlagDefaultMeaning
--pathmuxmux: the streams ride the messenger mux. per-stream: one TCP connection per stream.
--streams200,000Streams in the measured run.
--concurrency64Streams open at once.
--items4Items of 16 bytes in each stream.
--warmup2,000Streams run before the measured run. Their messages are not counted.

Wall time moves with the load on the machine. Compare builds and modes by the instructions of the process, divided by --streams, and take the median of several runs. On macOS:

cargo build --release --example stream_lifecycle
/usr/bin/time -l target/release/examples/stream_lifecycle --mode plain

On Linux, use perf stat -e instructions instead of /usr/bin/time -l. Batched streaming has the results for the mux.

On the per-stream path (--path per-stream), keep --streams to a few thousand and leave time between runs: each stream opens a connection, and the ephemeral ports run out. Provisional, on the same laptop and under the same conditions as the mux table, with --streams 4000 --warmup 400, the two modes interleaved, median of five: plain 525,586 instructions per stream (520,910 to 526,380) and retired 527,360 (526,161 to 529,770). The spreads overlap, so awaiting retired() costs nothing this run can resolve; the pump reports the retirement in both modes, and most of each figure is the connection each stream opens. The producer holds a stream for about 130 µs p50 and 500 µs p99 after finalize while it waits for the write. To be confirmed on dedicated hardware.

Run anchor_setup_bench

Run all commands from the examples/ directory. The harness has no network and no mux, so its numbers show the cost of the anchor path only. It runs two phases, each on its own runtime and manager:

  1. Create --held SPSC anchors and hold them unattached. The harness reports the create time of each anchor, and the tokio tasks and the RSS that they add.
  2. Run --tasks concurrent tasks. Each task runs --streams lifecycles: create an anchor, attach a sender on the same worker, finalize, and read the terminal. The harness reports streams per second and the p50, p99 and p99.9 of one lifecycle.
FlagDefaultMeaning
--threads8Tokio worker threads.
--tasks64Concurrent lifecycle tasks.
--streams20,000Lifecycles in each task.
--held200,000Unattached anchors in the first phase.
cargo run --release --example anchor_setup_bench

To compare two builds, run each build three times or more, interleave the runs, and take the median. The tail of the lifecycle depends on the thread count: a lock that every create takes shows at 8 threads and not at 1 or 2. Response plane performance has the results for the unattached timeout.

The external serving rig

The external rig runs velo as the response plane inside Dynamo's serving stack. It uses Dynamo's own HTTP frontend, its mocker engine and the aiperf load generator. The same rig runs Dynamo's own response planes, so only the response plane changes between arms. The rig scripts and the Dynamo adapter live outside this repository. The table below is the shape of the main comparison; other measurements state their own shape.

ItemValue
Nodes2 GB200 nodes, aarch64, 144 cores each, exclusive allocation
Node AFrontend on cores 0–71. aiperf, etcd and nats-server on cores 72–143.
Node B8 mocker processes, 64 workers each (512 workers)
LoadConcurrency 8,192. 250,000 requests per rep after 8,192 warm-up requests.
ShapeInput length 1,024, output length 256 (Qwen3-0.6B tokenizer), mocker speedup 5
Reps3 per arm, arms interleaved, fresh processes for each rep
CPU per request1 Hz samples of /proc/<frontend_pid>/stat inside aiperf's measurement window, divided by completed requests

Steady-state and raw percentiles

A closed-loop load generator at concurrency N starts its measured phase with N requests at once. aiperf 0.10.0 ramps request rate, not concurrency. On the external rig, each rep therefore opens with a burst of 8,192 simultaneous requests. The 8,192 warm-up requests warm caches, but the measured phase still opens with its own burst.

The burst sets the raw p99. In six reps on the external rig, every request at or above the raw TTFT p99 started within the first 0.2 s of the measured phase. That tail was 760 to 930 ms for both velo and the comparison plane. Its excess over the median came mostly from the request plane and the mocker processes absorbing 1,024 simultaneous requests each, not from the response plane.

The rig's summary therefore reports steady-state percentiles beside the raw ones. Steady state is the set of requests that started 10 s or later into the measured phase, about 200,000 of 250,000 per rep. Read verdicts from the steady-state columns. Report the raw columns too, so that a reader sees the burst.

Measurement rules

Each rule below exists because a measurement without it was wrong.

  1. Run on an exclusive node. A CPU A/B ran on a shared login node with about 11 other users. Its spread was up to 5.5 µs per token, against an effect of about 2 µs per token. The result was retracted. On an exclusive 144-core node, the total spread over 42 runs was 2.43 µs per token.
  2. Build both arms from one tree on the node, and interleave them. Run the arms alternately, rep by rep, so that drift hits both. Normalize CPU per token or per request over identical work.
  3. Pin the load generator away from the system under test. With aiperf unpinned on the frontend node, node A ran at 95% utilization. Its load average was 159 on 144 cores. velo's TTFT p50 read 992 ms. With the frontend and aiperf on disjoint cores, the same configuration read 98 ms. Pin etcd and nats-server to the load generator's cores too. Unpinned, they took 12% to 38% of the frontend's cores in profiled reps.
  4. Read timestamps in the right zone. aiperf 0.10.0 writes naive local timestamps. Reading them as UTC put the CPU window after the run, on the idle tail. Every frontend CPU figure measured that way was about three times too low.
  5. Compare reps at a matched backlog draw. On the external rig, one or more mocker processes fall behind during the opening burst and keep that backlog. The number of processes that hold the backlog (the "holders") sets throughput, ITL and end-to-end latency for every arm. Compare two arms only at the same holder count.
  6. Do not fix the arm order. A matrix that always ran the same arm second put that arm in a degraded band in every rep. Its request-level numbers measured run position, not the setting.
  7. Change one setting per arm. A pair that differed in two settings attributed a result to the wrong one.
  8. Assert the transport per request. Fail any request whose negotiated key is not messenger-mux-v2. A silent fallback measures the per-stream path under a mux label.
  9. Record the build. Write the velo commit and its dirty state into each rep's metadata. If the installed build and the checkout differ, fail the rep.
  10. Report latency beside throughput. A change that adds latency can raise throughput by letting the consumer catch up. Watch TTFT in particular, because any windowed flush policy can make it worse.
  11. Prove a regression test before you trust it. Revert the fix and make sure that the test fails. A test that passes with the fix reverted proves nothing.
  12. Check that every process was scraped. The rig gave worker process p the metrics port base + p. With 16 processes the range covered port 9100, which node_exporter holds on the compute nodes. Process 10 served traffic with no /metrics, and every summed worker metric missed a sixteenth of the fleet. An empty scrape file must fail the rep.
  13. Time the first record at both ends before you blame a stage for TTFT. Dynamo's worker time_to_first_response stops when the prologue is sent, not at the first token, so it cannot separate engine queueing from the response path. The rig adds histograms for the engine's first item and for its send on the worker, and for the prologue and the first data item on the frontend. With them, a 200 ms TTFT gap turned out to be the response path (the first token left the worker 10 ms after the request arrived) and not the mocker. See The credit window and a saturated frontend.
  14. Sort reps by the environment before you compare arms. On the 2-node rig, reps fall into two states that move every arm, the comparison plane included. In the bad state, frontend CPU per request rose from about 11 to 16–20 ms and irq and softirq time on node A rose from 5–9 to 13–16 cores. Network softirq runs on the frontend's own pinned cores: with the frontend on 24 cores, it took 5 to 7 of them, in both planes. Three reps that mix the states average two regimes and settle nothing.

Response plane performance

This chapter reports how velo performs as the response plane of an LLM serving stack. It gives the result, the changes that produced it, the changes that did not help, and the mechanisms found on the way. Benchmarking describes the rig and the measurement rules. Read it before you compare any number here with a new one.

The response plane is the path that carries generated tokens from a worker back to the frontend. In these measurements velo runs the messenger mux over TCP inside Dynamo's serving stack. The comparison plane is Dynamo's multiplexed TCP response plane (Dynamo PR 11918, ported onto the same Dynamo base so that both planes share one stack). Both planes carry many streams over a few shared connections and batch their frames.

Result

Measured on 2026-09-10 on the external rig that Benchmarking describes: 512 mocker workers, concurrency 8,192 and 250,000 requests per rep. The frontend ran on one tokio runtime with 72 workers. Each of two matrices ran three reps per plane.

The velo configuration: mux over TCP, zero-RTT stream setup, write on admission (FlushPolicy::Auto with on_admission), reply_linger 1 ms, initial_credit 256 (the default when these were measured; now 32, see The credit window and a saturated frontend), async_open_ack off.

A holder is a mocker process that keeps part of the 8,192-request backlog after the opening burst. The holder count sets throughput and the tails for every plane, so reps compare only at a matched holder count. Steady state is the requests that started 10 s or later into the measured phase. Where two reps share a draw, both values are shown.

HoldersPlanereq/sTTFT p50 msSteady p90 msSteady p99 msFrontend CPU ms/req
1velo2,44039.513221611.9
1comparison2,274 / 2,32346.4 / 46.2137 / 134226 / 21710.1 / 10.0
2velo2,824 / 2,81839.3 / 43.583 / 84116 / 11113.2 / 13.2
2comparison2,83545.78010710.3
3velo3,447 / 3,13345.4 / 44.587 / 96181 / 17113.0 / 13.0
3comparison3,21148.310322911.4
5 to 6velo3,14255.510126513.4
5 to 6comparison3,33055.111630812.2
  • At 1 to 3 holders, velo's first-token p50 is 2 to 7 ms ahead. At 5 to 6 holders, the two are level (55.5 against 55.1 ms).
  • Steady-state p90 and p99 are level or ahead.
  • Throughput is equal within the draw.
  • Every rep in the two matrices had zero errors.
  • Frontend CPU per request is 1.2 to 2.9 ms higher on velo. This is recorded, not a requirement.

Against the shipping per-request plane

Dynamo's shipping plane opens one TCP connection per response. Measured on 2026-09-05 with core pinning, before zero-RTT setup. The velo arm here is the mux with an attach round trip per stream. CPU is the corrected figure.

Planereq/s (mean of 3)TTFT p50 msTTFT p99 ms (raw)Frontend CPU ms/reqErrors
Per-request TCP3,306581,70910.480
velo mux, attach per stream3,210718338.090
Comparison mux3,372477537.610

The per-request plane's accept loop is bistable. In the jammed state about 3,900 sockets sit connected but not accepted, and TTFT is the backlog divided by the accept rate. Its raw p99 of 1.7 s comes from that loop.

Dynamo's QUIC response plane (PR 11996) did not complete a clean run at this scale on this cluster. Six reps, measured before core pinning, had 4,947 to 84,525 errors each: HTTP 500s and streams with no content. Its low TTFT medians in those reps were survivorship, because dropped requests do not queue. A likely factor is net.core.rmem_max at 212,992 bytes, which clamps QUIC's UDP sockets about 30 times below what quinn requests. This is a result for this cluster, not a general verdict on QUIC.

What produced the result

The changes are listed in order of effect. Each one landed with a test that fails without it.

  1. One tokio runtime in the frontend. This is the largest lever, and it is outside velo. The Dynamo frontend ran two 72-worker runtimes on 72 cores. The Python bridge built its own runtime lazily, because its initialization code sat in a branch that never ran. The velo node ran on the first runtime. The HTTP handlers and the per-stream reader pumps ran on the second. Every response record crossed between them through the injection-queue mutex of the second runtime. That lock took 2.4% of the cores plus 6.8% in lock contention. A worker blocked on that mutex does not service the time driver. Linger timers then stopped for seconds, and credit stopped. The 8,192 streams fell into a 6 to 8 s limit cycle. The fix initializes the bridge with Dynamo's runtime, a 24-line change. Thread count fell from 242 to 154.
  2. Zero-RTT stream setup. The worker sends without waiting for the attach round trip. The worker-ingress-to-first-token segment fell from 78 to 48 ms at p50.
  3. Credit on the next batch for every drained slot. Each drain names the slot it drained, and the next inbound batch reconciles it. Before this change, each inbound batch walked every slot of its peer. At about 1,000 slots per peer and 11 slots touched per batch, flume::Shared::len alone took 2.1% of the frontend's 72 cores, about 0.5 ms per request.
  4. One timer per stream in the reader pump. The pump used to build a tokio::time::timeout per record. Each one took the time driver's lock twice. The Sleep subtree was 7.3% of the frontend's cores, plus 0.8% for the cancellation future. With one pinned timer, the timer subtree fell to 0.15%. The mux has since dropped the reader pump; its stream watchdog wakes once per window and does no timer work per record. See The reader pump leaves the mux data path.
  5. Reply linger. Credit replies wait up to 1 ms to share a batch. With zero-RTT setup, the frontend's egress had carried one batch per credit reply: 1,027,872 outbound batches against 97,042 without zero-RTT. The linger cut frontend batches four to six times and saved 0.4 ms of CPU per request.
  6. Control maps bounded by allocation. A 4,096-entry control cap was sized for about 1,024 slots per peer. One mocker process held 4,000 to 6,700 live slots. The cap refused credit grants and closes, and with async_open_ack it refused the answer that lifts a slot's fence. A fenced slot then waited for the 15 s watchdog, and the client saw an HTTP 500.
  7. Drain-driven credit return with a visit floor. Measured on 2026-09-01 on an exclusive 144-core node with the in-process harness at 256 ingress peers, 42 interleaved runs, at a credit window of 256 with streams of 64 tokens, so no stream ran out of credit:
ChangeCPU per token
Credit sweep at 2 ms against 200 ms or 500 ms, no drain hook1.075 times
Drain hook alone+0.30 µs per token (about 2%), worse in 34 of 42 paired runs
Before (2 ms sweep) against after (200 ms sweep with drain hook)13.34 against 12.73 µs per token (−4.5%)

The hook costs about 2%. It is what makes the 200 ms sweep safe, because the sweep was the only path that returned credit to a quiet peer. The net gain is 4.5%, and the case for the change is correctness. Without a floor on doorbell visits, a stream drained through an 8-record window rang the doorbell 1,000 times in about 270 ms. The 2 ms floor spread the same 1,000 walks over about 3 s.

The Dynamo adapter also polls the anchor inline from the connection task instead of through a consumer task with a 64-deep mailbox. This removed about 0.2 ms of adapter CPU per request.

What is not a lever

These changes were measured or traced and did not help. They are recorded so that nobody tries them again without a new reason.

ChangeWhat was measuredVerdict
Grant credit once per half windowMeasured at a credit window of 256. Credit updates per rep fell from 54–59 million to 2.8–3.1 million. Frontend batches fell from 307,000–484,000 to 7,800–10,200. CPU per request did not change. Inter-token p99 rose to 68 and 76 ms against 40 and 42 ms on the same nodes, and sender credit exhaustion rose to 1,431 per rep.Rejected. A threshold makes a slow reader exhaust the sender sooner.
An urgent grant for a starved slotMeasured at a credit window of 256, on the 8-process rig with output length 256. A sender runs out of credit about 300 times per rep and waits under 2 ms. Long gaps rise slightly in the last four token positions (76 to 106 gaps of 63.5 million). Only 21% to 30% of credit batches wait out the reply window.Not built at 256. It has under 1 ms to win on about 300 of 250,000 streams, and an urgent reply per wake returns one batch per wake. On the 16-process rig with output length 900, in the first repetition of the allocation that ran 32 against 256 on 24 frontend cores, senders ran out of credit about 240,000 times at 256 and about 1.2 million times at 32, so a starved slot is common at 32.
A 32-worker frontend runtimeCPU per request fell 1.4 to 1.8 ms. TTFT p95 at one holder rose from about 206 ms to 317–366 ms, and the p50 lead disappeared.Rejected. velo's larger task count needs the workers.
Coalesce SSE flusheshyper drains the body until Pending and flushes once per poll, in both planes. velo flushes about 0.9 times per record, the comparison plane about 0.54, because its body task runs further behind.No lever. More records per flush need a linger.
A 500 µs data linger on the workersFrontend inbound batches fell from 7.5–8.6 million to 1.0 million per rep, and CPU fell to 9.10 ms per request. The request path grew: client-to-frontend 8.4–9.1 ms against 5.6, frontend-to-worker 12–13 ms against 5.5. TTFT p50 72 to 76 ms.Not a ship setting.
async_open_ack aloneTTFT p95 was worse in every rep (209, 176, 252 ms against 142, 119, 136). p50 did not improve. With zero-RTT setup it cut the response segment from 48 to 39 ms at p50, but p95 stayed worse.Off by default.
Reply linger as a first-token leverThe per-request segments did not move at equal load: 3.7 against 3.9–4.0 ms, 3.3 against 3.3–3.5 ms, 47.7 against 47.8–47.9 ms.A batch and CPU fix only. The batch inflation was a symptom of contention, not its cause.
Shard the frontend ingest laneThe lane's cost was the per-batch slot walk, which item 3 removed. The profile shows no core-bound lane stage.Not built.
Merge the reader pump into the anchor channelTraced. The anchor channel has other writers, and credit is issued against the mux buffer's sole writer.Forbidden by the credit invariant. The pump was later removed another way: the consumer reads the mux buffer itself. See The reader pump leaves the mux data path.
SO_REUSEPORT on the TCP pathTraced. The kernel demuxes TCP per connection, so the frontend already has one socket, queue and reader per peer.Not applicable. One QUIC endpoint is one UDP socket, so the QUIC transport binds several server sockets, one port each. See QUIC performance.
A 60 KiB against 64 KiB batch capTraced. Both planes write one writev per batch.Equivalent.
Flatten the MessagePack envelopeProfiled. Decode costs the same in both planes.No lever.
A connection pool per peer, a different record headerTraced. Neither is on the measured path.Not built.
Tune UCXSee UCX transport instability.No knob reaches the mechanism.

Where velo's extra CPU goes

A per-subtree partition of a perf profile of both frontends, with one runtime (one rep each, measured on 2026-09-06). Each stack line goes to exactly one bucket, and each plane sums to 100%. The values are milliseconds per request.

BucketveloComparisonDifference
HTTP and SSE connection task, socket writes7.515.03+2.48
Reader pump (per-stream relay task)1.110+1.11
Anchor (frame decode, gauge)0.790+0.79
Adapter consumer task0.660+0.66
Ingress lane, handle_batch0.650+0.65
TCP transport, dispatch, batcher and credit0.500+0.50
Tokio scheduler residual0.780.33+0.46
Unattributed2.372.00+0.37
Shared request path (SSE, router, preprocessor)3.773.62+0.15
Comparison plane's reader and receiver01.24−1.24
axum graceful-shutdown watch1.362.21−0.84
Idle4.044.66−0.62

velo's anchor and adapter consumer (1.46) cost about what the comparison plane's reader and receiver cost (1.24). The excess is task hops. The reader pump is a channel-to-channel relay with no counterpart, and the lane, dispatch and transport are three stages where the comparison plane has one task. Each record also reaches the HTTP connection task as its own wake.

After the inline receiver, a second profile put the velo-only buckets at 3.20 ms per request. The buckets were: reader pump 1.01, anchor 0.85, ingress 0.51, adapter 0.49, TCP 0.18, dispatch 0.14, batcher 0.01. The reader pump and the anchor channel are the remaining addressable surplus. The pump cannot merge into the anchor channel (see the table above). Each other hop-chain change is optional and needs a same-matrix tail check. These two profiles predate the direct feed, which takes the reader pump off the mux data path. See The reader pump leaves the mux data path.

Before the fixes above, these symbols appeared only in velo's profile, as a share of the 72 frontend cores:

SymbolShareSourceState
flume::Shared<T>::len2.09%Per-batch walk of every slot, reading each channel length under its lockRemoved
parking_lot lock slow paths1.58%A timeout per received record in the reader pumpRemoved
flume::Sender<T>::try_send0.93%Delivery of each record into its anchor channelRemoved for the mux: the consumer reads the slot buffer directly
Active-anchor gauge updates0.49%The gauge recounted the anchor registry on each create and retireRemoved: the gauge is computed when scraped
CancellationToken::is_cancelled0.43%Per-record checks on the delivery pathRemoved for the mux with the reader pump; the producer's per-send check remains

Removed, measured on the rig rather than in a profile: each bind used to spawn one task that slept for the 60-second accept window, and a claim did not cancel it. A frontend at about 2,000 requests per second held 146,000 live tokio tasks against about 25,000 on the QUIC plane. The accept window is now a deadline in one queue that the credit sweep drains, so a bind owns no task and a window closes up to one sweep interval late. The test a_finished_zero_rtt_stream_leaves_no_task_behind holds the task count to its baseline after streams end.

Removed, measured with anchor_setup_bench: each SPSC anchor used to spawn one task for its unattached timeout. Attach cancelled the task, but each stream still paid one spawn and one exit, and each unattached anchor held one task. An unattached deadline is now an entry in one queue per manager, and one task sweeps that queue. The sweep wakes at the earliest entry, so a timeout fires at its deadline, as before. The host is one aarch64 machine with 20 cores (10 Cortex-X925, 10 Cortex-A725). The unattached rows are the median of three interleaved runs at 8 tokio threads. The lifecycle rows are the median of five interleaved runs of 16M lifecycles (3.84M at 2 threads), 64 tasks. Such a run lasts about 10 s, so the sweep pops entries for all but its first second. CPU is the user and system time of the process, divided by the lifecycles.

MeasureTask per anchorDeadline queue
Tasks held by 200,000 unattached anchors200,0001
RSS added by 200,000 unattached anchors276.6 MiB168.5 MiB
Create time of an unattached anchor1,217 ns708 ns
Lifecycles per second, 2 / 8 / 16 threads0.74M / 1.45M / 1.56M0.87M / 1.62M / 2.15M
CPU per lifecycle, 8 threads9.2 µs7.5 µs
Lifecycle p50 / p99 / p99.9, 8 threads4.37 / 12.05 / 22.62 µs3.97 / 10.42 / 18.14 µs
Lifecycle p99 / p99.9, 16 threads39.9 / 261 µs25.9 / 167 µs

At 2 threads the p99.99 ranges overlap: 127 to 251 µs with a task per anchor, and 133 to 216 µs with the queue. A run of 1.28M lifecycles ends before any entry comes due, so it measures only the push; the 16M run includes the sweep.

The queue does not remove an entry when its anchor attaches or ends. The sweep drops the entry when it comes due. A new entry comes due within 1 s, and a requeued entry within 60 s, so the queue holds about stream rate × 1 s of stale entries, at 24 bytes each. An entry queued at the deadline of its anchor stayed for the full timeout instead: 120 s by default, and 30 years for Duration::MAX.

Rejected: one lock for the whole queue. Every create takes a lock on the queue. With one lock, the lifecycle p99.9 at 8 threads was 110 µs, against 22 µs with a task per anchor. No entry came due inside that run, so the sweep did not cause the tail: creates waited on each other for the one lock. With 1 or 2 threads, the single lock was faster than the task at every percentile, so the cost was contention. The queue now has 16 parts, each with its own lock, and the id of an anchor selects its part.

The tests unattached_anchors_share_one_sweep_task and ended_anchors_leave_the_queue_within_the_first_look hold the task count and the queue length. MPSC anchors use a second queue of the same kind, and mpsc_anchors_share_one_sweep_task holds its task count.

Removed, measured with anchor_setup_bench and stream_lifecycle: each SPSC and MPSC sender spawned one heartbeat task that lived as long as its stream. Now one task per AnchorManager beats every remote sender, from the same kind of queue. A sender on the anchor's own worker does not beat at all, because no watchdog reads its beats. Same host and settings as above, median of three interleaved runs, against the deadline queue alone:

The two rows measure two different parts of the change. A same-worker sender now does not beat at all, so the same-worker row shows the cost of a heartbeat task per stream, not the cost of the sweep. Only the mux row exercises the sweep. Both arms of each row have the deadline queue. The same-worker runs are 1.28M lifecycles, which end before the anchor sweep pops anything, in both arms.

PathMeasureHeartbeat task per senderThis change
Same worker (anchor_setup_bench): no beatLifecycles per second1.49M2.19M
Same worker: no beatLifecycle p50 / p99 / p99.94.30 / 11.26 / 22.11 µs3.06 / 5.22 / 7.15 µs
Mux, 4 items per stream (stream_lifecycle): one heartbeat sweepStreams per second134,546137,856

On the mux path the difference is inside the spread of the runs. The test senders_share_one_heartbeat_task holds the task count, and a_silent_remote_stream_outlives_the_watchdog_window holds an idle remote stream open for 10 heartbeat intervals.

These costs are counted from source, not measured:

  • The per-record copy. Ingress copies each record body into a Vec (one allocation and one memcpy, estimated at 35 to 55 ns). Removing it needs Bytes from the slot buffer through to the consumer, and it is not done (see Batched streaming design). On the microbenchmark below, memcpy was 0.5% of frontend CPU. A typed Bytes consumer copies each payload a second time, in serde. Byte items remove that second copy.
  • Wakes. flume fires a waker only for a parked receiver, so k records for one slot in one batch already cost one wake. With the direct feed, a record makes one hop, from the slot buffer to the consumer.

The reader pump leaves the mux data path

Measured on 2026-09-26 with a two-process microbenchmark shaped like response_plane_bench, on one Grace node: frontend 32 threads, 2,048 streams, 918 records of 160 B, jemalloc. The figure is frontend CPU per record, median of 3 runs. Each pair of rows is its own A/B. The direct-feed baseline is a separate run of the dirty-set build, so the rows do not chain.

ChangePeersBefore, µs per recordAfter, µs per recordChange
Dirty set: a lock-free per-peer bitmap replaces the per-peer dirty lane and per-slot flag126.665.82−12.5%
Dirty set488.608.11−5.6%
Direct feed: the consumer reads the slot buffer, and no reader pump runs125.875.33−9.2%
Direct feed487.717.38−4.3%

In the profiles before these changes, the reader pump was 28% of frontend CPU at 12 peers. In the dirty set, only the drain that newly lists a slot touches the peer's shared wake flag. The direct feed removes a task wake, a channel send and receive, and a hook allocation per record. These are microbenchmark numbers, not rig numbers.

First-token mechanisms

A per-request join splits client TTFT into three segments. The join matches aiperf's request id, the frontend's log and the mocker's log, with clock skew below 1 ms. The three segments sum to the client's TTFT.

  • A: client send to the frontend's "request received" log line (HTTP ingress).
  • B: the frontend to the worker handler's start (the request plane).
  • C: worker ingress to the first token at the client (mostly the response plane).

TTFT equals the wait for the first HTTP byte to within 0.3 ms at every percentile, in every plane. No plane writes a byte before the first token.

These mechanisms were found:

  • A saturated load-generator node looked like a slow plane. Before core pinning, velo's TTFT p50 read about 1.1 s. The queues that velo owns held a small share of it: 26 to 36 ms in the node-global inbound queue, about 110 ms in the ordered lanes and 79 ms in the worker's egress queue. The rest was the time between a wake and a run on a node at 95% utilization. Pinning moved velo's p50 to 98 ms at the same throughput.
  • The attach round trip was the largest term. Before zero-RTT setup, the worker waited for _anchor_attach before it started generating. Under load before pinning, that round trip averaged 390 to 524 ms. With pinning it averaged 13 to 22 ms. Zero-RTT setup removes it.
  • The comparison plane has nothing on the first-record path that grows with load. The frontend mints the stream id at request registration. The prologue goes on an urgent lane that the writer drains before ordered data. The only shared queue that a first frame crosses is bounded by a 256 KiB byte budget per connection.
  • Zero-RTT setup first moved the cost into the request path. With two frontend runtimes, segments A and B each grew by about 8 to 9 ms under zero-RTT setup, while C fell. The frontend sent ten times more batches, one per credit reply. The reply linger removed the extra batches but not the A and B growth, so the batches were a symptom of frontend contention. After the one-runtime fix, velo's p50 moved ahead of the comparison plane, and segment B contributes nothing to the steady-state tail.
  • TTFT p50 followed the backlog draw. Before the one-runtime fix, velo's p50 was 54 to 55 ms at one holder, 61 to 62 at two, 73 to 76 at three and 83 at five. The comparison plane stayed at 48 to 49 ms. After the fix, velo's p50 is 39 to 45 ms at one to three holders.
  • The steady-state tail is the hot mocker process and HTTP ingress, in both planes. In steady state, segment B contributes nothing to p99. Segment C adds 125 to 209 ms, and 59% to 89% of the p90-to-p99 band sits on one mocker process with 3,000 to 6,400 requests in flight. Segment A adds 39 to 112 ms. The raw p99 is the opening burst, described in Benchmarking.
  • End-to-end and ITL tails measure one mocker process. Arrivals are equal across the 8 mocker processes. In-flight counts are not: seven sit at 150 to 200 and one at 3,000 to 7,000 for the whole run, in every plane. By Little's law, a request on the hot process stays about 15 s against 0.43 s elsewhere. End-to-end p99 and ITL p99 therefore measure which process holds the backlog, not the plane.
  • A handler wait cost a hello round trip per request. The Dynamo adapter calls Velo::wait_for_handler before each request to make sure the worker can reach the frontend's _stream_stop handler. wait_for_handler refreshed the peer's handler list with a full _hello round trip every time, and under a saturated frontend that round trip waited in the frontend's messenger queues. It now returns at once when the handler list already names the handler. The test test_wait_for_handler_reuses_cache_and_refreshes_missing_handlers counts one handshake for repeated waits on a known handler, and a new one for a missing handler or a new instance. On the rig its effect on TTFT was within the rep-to-rep spread. A Dynamo adapter change also prepares each peer once instead of per request.
  • velo's tail discipline is conditional. Before pinning, velo appeared to keep a much shorter end-to-end tail. That was a starved frontend limiting how many streams ran at once. With the frontend on 48 cores and the load generator on 96, velo held 8.1 s end-to-end p99 at 3,008 req/s against the comparison plane's 25.2 s at 2,460. On a 72/72 split, both planes posted about 11.5 s.

The credit window and a saturated frontend

Measured on 2026-09-26 and 2026-09-27. When the frontend is the bottleneck, every stream runs at its credit window, and the windows fill the shared path between a worker and the frontend: the worker's egress queue, both socket buffers and the frontend's reader. A new stream's first record queues behind all of it. The comparison plane has no per-request credit, and its worker-side send blocks, so its backlog stays in the producer.

Setup: the Dynamo mocker rig with 16 processes of 32 workers, concurrency 8,192, input length 1,024, output length 900, speedup 10 and 250,000 requests per repetition. The frontend was pinned to 24 or 32 cores. Each allocation ran one window against a window of 256, three repetitions per arm with the arms interleaved; ranges cover all three. The last column is a stream's first record (its prologue), from the worker handing it to the plane to the frontend receiving it, as a mean over requests.

FrontendWindowreq/sTTFT p50 (ms)ITL p50 (ms)ITL p99 (ms)First record (ms)
24 cores2561,940 to 2,088139 to 3551.65 to 1.885.3 to 11.696 to 268
24 cores322,022 to 2,10288 to 1021.74 to 2.304.7 to 4.916 to 31
24 corescomparison plane1,712 to 2,05984 to 970.75 to 1.619.9 to 33.9-
24 cores2561,975 to 2,135135 to 2101.62 to 1.694.7 to 7.983 to 162
24 cores162,052 to 2,05577 to 781.79 to 1.874.9 to 5.39 to 11
24 cores2561,451 to 2,147156 to 8301.73 to 3.425.0 to 26.9135 to 672
24 cores641,817 to 2,15898 to 2071.68 to 3.134.5 to 8.631 to 146
32 cores2562,117 to 2,16490 to 1071.31 to 1.804.2 to 4.821 to 50
32 cores322,138 to 2,17793 to 951.37 to 1.404.0 to 4.210 to 11
32 corescomparison plane2,115 to 2,38972 to 1161.25 to 1.335.2 to 12.4-

Rows are grouped by allocation. In the 64 allocation, two of the three 256 repetitions and one of the 64 repetitions fell into the degraded environment state that Measurement rules describes. In their good repetitions, 64 read 98 ms of TTFT against 156 ms at 256, and its first record took 31 ms, the top of the range of 32. The data does not separate 64 from 32 beyond that.

The default window is 32. At 24 cores it brings velo's TTFT to the comparison plane's, with throughput within noise of 256 in the same allocation and ITL p99 no higher; ITL p50 is 0.1 to 0.4 ms higher, because streams need more grants. At 32 cores, 32 and 256 read the same TTFT p50, throughput and ITL, and the first record still waits less at 32. A window of 16 had the lowest TTFT at 24 cores, but it doubles the per-record credit-return cost ((drain_visit_floor + reply_linger) / initial_credit, about 190 µs at 16 against 94 µs at 32), and it was not measured at 32 cores.

At 32 the surplus waits in each stream's own withheld queue on the producer rather than in the shared path. In the first repetition of the 24-core allocation that ran 32 against 256, summed over the 16 worker processes, sender credit exhaustion rose from about 240,000 to about 1.2 million events; the worker's mean egress queue wait per frame fell from 9.5 to 1.2 ms, and the frontend's mean lane wait per batch from 1.8 to 0.7 ms. The per-stream backlog itself does not go away while the frontend is the bottleneck; it moves to where it delays only its own stream.

UCX transport instability

Measured on 2026-09-04, before core pinning: the velo mux over velo's UCX transport on InfiniBand (2 mlx5 adapters per node), against the same mux over TCP on 200G Ethernet. Three reps each, 512 workers, concurrency 8,192.

Transportreq/s per repTTFT p99 ms per repErrors
TCP3,017 / 2,847 / 2,6842,124 / 2,107 / 2,5310
UCX2,526 / 2,875 / 2,3559,152 / 2,016 / 7,6781,722 in rep 1

UCX did not beat TCP, and it did not hold its tail. The failure chain:

  1. The KV router put about 5,760 concurrent streams on one of the eight mocker processes. This imbalance occurs with every transport.
  2. All 64 workers in a process share one velo node and one mux peer link. All 1,722 failures were in that one process, spread evenly over its 64 workers.
  3. Over UCX, that one link fell behind. On the hot process, the delay from worker completion to client completion grew to a median of 18.87 s. Over TCP, at the same or higher concurrency, it peaked at 1.0 to 2.75 s.
  4. Heartbeats share the per-peer queue with data, so streams behind more than 15 s of backlog went silent at the frontend.
  5. The reader-pump watchdog (3 × 5 s) killed 1,722 streams. Every log line showed anchor_frame_tx_len=0 and transport_rx_len=0. closed_slot drops were 1,722 × 255 exactly, so no record had arrived before each kill.
  6. The worker was healthy the whole time. It completed all 27,702 of its requests.

The measured revision had no UCX backpressure edge beyond its shared ring:

  • ucp_am_send_nbx never refuses. An exhausted endpoint returns a request pointer, not an error.
  • inflight_ops counts posted operations but does not gate admission.
  • The per-peer admission gate queues without a bound.
  • All peers share one 1,024-entry ring into the progress thread. TCP gives each connection a 256-entry channel and a blocking write.

Version 0.14 bounds queued and outstanding sends per peer. A send permit is released on UCX completion or failure, and peer retirement cancels pending admission. The progress thread can still process control and endpoint cleanup when a data peer is full. This change requires a new saturation measurement; it does not change the historical results below.

Because admission never blocks, the on-admission flush policy never parks, and batches collapse. UCX carried 6.97 to 8.65 records per batch against 18 to 29 over TCP with the same settings. That is 2.6 to 4.1 times the message rate for the same records.

These alternatives were checked and refuted:

  • Send-backpressure counters do not follow the failure. Their totals ran opposite to the instability across reps 2 and 3.
  • The peer byte budget (8 MiB over 5,760 slots) does not depend on the transport. The TCP control carried 5,326 streams on the same budget and held p99 at 2.5 s.
  • Batch collapse amplifies the failure but does not start it. The clean rep had fully collapsed batches too.
  • UCX scheduling is not unfair. The failed peer carried eight times the load of the others.
  • Spinning progress threads do not starve the CPU. There were 9 progress threads on 288 cores.

No tuning setting reaches this mechanism. The mux's stream watchdog, which replaced the reader pump's watchdog on single-sender streams, exempts a sender that holds no data credit, but here the senders held credit and their records were stuck upstream, so it would still have fired. The fix has two parts. The UCX transport needs a backpressure edge (gate admission on in-flight operations or on a per-peer ring share). Liveness needs a heartbeat path that data cannot block, or a watchdog that can tell a starved stream from a dead one. The UCX inbound path records frames like every other transport. Version 0.14 adds the bounded admission edge. Saturation measurements must still check whether data backlog delays stream heartbeats beyond the watchdog. RDMA performance covers the UCX transport outside the response plane.

Instrumentation cost

These instruments are on the hot path. Their costs are accepted without an A/B measurement:

  • Egress writer instruments (velo_transport_egress_queue_wait_seconds, velo_transport_frames_written_total, velo_transport_write_duration_seconds, TCP, UDS and QUIC only). Per frame: one Instant::now() at send, then one elapsed() and one histogram observe at dequeue. Per write: a second observe and up to five counter compare-and-swap loops. If this cost shows in a measurement, observe once per write on the oldest frame instead of once per frame.
  • Ordered-lane metrics for _stream_batch. Per batch: four Arc clones, one elapsed() with one histogram observe, and one gauge increment and decrement. This cost is the measurement of the lane wait itself, so no A/B applies.
  • Batcher counters (velo_streaming_mux_records_sent_total, velo_streaming_mux_batcher_wakes_total). The collectors are pre-bound into arrays. Per record: one u16 increment. Per wake: one AtomicF64 compare-and-swap. velo_streaming_mux_staged_records already paid one gauge compare-and-swap per staged record.

The rule for new instruments on these paths: no label lookup per record. Pre-bind the collectors, and observe per batch where possible.

The instruments did not perturb the rig. Three instrumented reps and one control rep stayed inside the historical band. One outlier rep was a hot mocker process with 1,887 live slots, not the instruments.

Findings outside velo

  • axum's graceful-shutdown watch costs 1.4 to 2.2 ms per request in both planes. It is one process-wide lock, polled again on every connection wake. It tracks the backlog draw, not the plane.
  • Two tokio runtimes in the frontend. See item 1 of What produced the result. The fix belongs in Dynamo's Python bindings and helps every response plane.
  • aiperf 0.10.0 can wait forever when a record references a dataset entry that fails to decode. It then writes no export. The rig patches it to finish after 15 report intervals with no progress, once all credits are complete and at least 95% of records are processed. The patch fired on runs that missed 2 to 397 of 250,000 records.
  • Mixed model cards stop a mocker fleet silently. Packed workers with DYN_SYSTEM_PORT set publish a mix of self-hosted and fallback model cards. Dynamo's discovery controller parks them in a Conflict state with no log line, and the fleet never serves. Set DYN_SELF_HOST_METADATA=0 and leave DYN_SYSTEM_PORT unset.
  • The socket buffer clamp. By default, velo requests 2 MiB socket buffers (see Socket buffers). This cluster's net.core.rmem_max and wmem_max are 212,992 bytes and cannot be raised, which clamps each buffer to about 208 KiB and disables autotuning. At 0.5 ms round-trip time, one connection carries at most about 400 MB/s. The rig's worst-case mux traffic is about 200 MB/s in total, so the clamp does not bind with 4 or more worker processes per node.

RDMA performance

This chapter records measured numbers for the rendezvous RDMA path, with the setup behind each number. For how the path works, see Rendezvous and RDMA. For the setup procedure, see Run rendezvous over RDMA.

Every two-node number here is an unaccelerated floor. The runs used rc_verbs on a plain verbs memory domain. At the time, velo's vendored UCX opened no mlx5 memory domain (see RDMA design). They are lower bounds on what the hardware does, not measurements of it. No run has used the accelerated rc_mlx5 lane yet. Re-derive rdma_min_bytes on that lane before you trust the 64 KiB default.

Two-node setup

ItemValue
Measured on2026-08-29
NodesTwo nodes, --exclusive, 144 cores and 478 GB each
CPU and OSaarch64 (Grace class), Ubuntu 22.04.5
NICConnectX-7 (vendor_part_id 4129, firmware 28.43.2566), three mlx5 devices per node
Portmlx5_2:1, InfiniBand, PORT_ACTIVE, 400 Gb/s (4X NDR), MTU 4096. Both nodes report the same subnet manager LID.
Excluded portsmlx5_0:1 Ethernet 25 Gb/s (active, RoCE-capable) and mlx5_1:1 Ethernet (down). Excluded so that no result is a silent RoCE measurement.
Memlockulimit -l unlimited
UCXVendored 1.22.0, static
EnvironmentUCX_NET_DEVICES=mlx5_2:1 UCX_TLS=rc_verbs,ud_verbs,self

UCX_PROTO_INFO=y on the live run named the device for every protocol range:

| 1476303..inf | (?) rendezvous zero-copy read from remote | rc_verbs/mlx5_2:1 50% on path0 and 50% on path1 |
|       0..107 | short                                     | rc_verbs/mlx5_2:1/path0                         |

Cold pair: one transfer per process pair

Each rep started a fresh owner process and a fresh consumer process, one on each node, with the rendezvous_rdma_two_proc example. There were 5 reps per cell. The time is the example's own velo.get() measurement: the acquire round trip, the transfer, and the copy out of registered memory. release is not included. The chunked baseline ran with VELO_RDMA_RENDEZVOUS_DISABLE=1.

PayloadRDMA get() medianChunked get() median
64 KiB14.21 ms0.346 ms
1 MiB14.38 ms1.271 ms
1 MiB + 4097 B (not granule-aligned)14.55 ms1.433 ms
16 MiB16.11 ms14.597 ms
256 MiB57.50 ms197.25 ms
  • All 50 transfers matched byte for byte, including the size that is not granule-aligned. No corruption, truncation, or hang occurred.
  • All 25 RDMA reps reported rdma/ok on both sides.
  • Every RDMA number includes the one-time endpoint wireup. The flat 14 ms floor from 64 KiB to 1 MiB is that wireup, not the transfer.
  • The raw results show a wall time near 30 s per rep. That was NFS negative-dentry caching on the shared home directory, which delayed the file exchange between the two processes. It is not a velo latency.

Warm pair: many transfers per process pair

The sweep harness (see How the sweep works) ran 10 transfers per size inside one process pair on the same two nodes. The table gives medians of reps 1 to 9. Rep 0 is excluded because it carries the one-time wireup.

PayloadRDMA warm medianChunked warm medianRDMA advantage
4 KiB108 µs151 µs1.40x
8 KiB113 µs156 µs1.38x
16 KiB117 µs161 µs1.38x
32 KiB114 µs183 µs1.61x
64 KiB123 µs194 µs1.58x
128 KiB129 µs227 µs1.76x
256 KiB131 µs280 µs2.14x
512 KiB159 µs401 µs2.52x
1 MiB229 µs717 µs3.13x
4 MiB566 µs2816 µs4.98x
  • The first RDMA get() on a fresh peer pair took 14,145 µs. Later gets took about 108 µs.
  • The first chunked transfer took 216 µs. It pays no comparable cost, because it uses the TCP control connection that already exists and never creates a UCX endpoint.
  • All 100 sweep transfers took rdma/ok. All 100 transfers in the chunked run took chunked/kill_switch. Every payload matched.
  • Warm RDMA is a fixed cost of about 105 µs plus a size term. The 1 MiB and 4 MiB points give about 9 GB/s marginal rate. Two points show the shape only and are not a bandwidth measurement.
  • The rate is far below the 400 Gb/s link, because get() copies the payload out of registered memory into Bytes. get_pinned() and get_into() skip that copy. Neither was measured.

The fixed ~105 µs is the acquire round trip. Registration (next section) is not the bottleneck.

Threshold verdict for rdma_min_bytes

The default stays at 64 KiB. The threshold turned out not to be the lever.

  1. Warm pair. RDMA beats chunked at every measured size, down to 4 KiB (1.4x). There is no crossover above 4 KiB on this fabric. The 64 KiB default is therefore conservative. It costs about 40 to 70 µs per transfer in the 4 to 64 KiB band. That cost is smaller than the extra pinned memory and lease traffic that small pinned slots add.
  2. Cold pair. The 14 ms wireup reverses the result. Chunked wins by 41x at 64 KiB, 11x at 1 MiB, and 1.1x at 16 MiB. RDMA wins only at 256 MiB (3.4x). The cold crossover lies between 16 MiB and 256 MiB, near the low end. The two cells are 16x apart, so the data supports no tighter number.
  3. The lever is the wireup. A lower threshold makes the cold case worse. A threshold inside the cold band discards the warm win, which is the common case for any pair that transfers more than once. UcxTransportBuilder::eager_endpoints exists because of this result. It moves the wireup to register(). It is off by default.

Two caveats limit the comparison:

  • The chunked arm used the kill switch, and the kill switch also makes the owner stage in plain memory (pinned=false in every chunked line, pinned=true in every RDMA line). Staging mode therefore changes together with the path. The table answers "enable RDMA or not". It is not a clean sweep of rdma_min_bytes with pinned staging on both arms. The 4 KiB gap is about 40 µs either way, so the verdict likely holds.
  • All numbers are from the rc_verbs floor. The warm latency and the 14 ms wireup can change on rc_mlx5.

Registration cost and raw GET latency

bench_rma ran two UCX workers in one process on one node. These are HCA-loopback numbers, not two-node numbers, and they do not compare with the tables above. The test header printed UCX_TLS=tcp, but the environment overrode it. The run used the same rc_verbs lane and port. Measured on 2026-08-29.

Region sizeucp_mem_mapucp_mem_unmapPacked rkey
4 KiB53.98 µs35.52 µs20 B
1 MiB43.07 µs31.84 µs20 B
64 MiB265.97 µs132.22 µs20 B
GET sizeLatency, three timed passesThroughput
64 KiB24.93 / 22.43 / 19.71 µs2.5 to 3.2 GiB/s
1 MiB50.27 / 49.82 / 49.92 µsabout 19.6 GiB/s
16 MiB443.2 / 441.9 / 442.1 µsabout 35.3 GiB/s

Registration costs 43 to 266 µs. It is not the bottleneck of a warm transfer, because the pool registers an arena once and many transfers use it. A registration per transfer adds this cost to every transfer, so pre-registered arenas stay the right design.

An earlier probe measured ucp_mem_map much higher on a different host: 161 to 805 µs for 4 KiB to 16 MiB, and 3.2 ms at 100 MiB. That probe ran on x86_64 (DGX B200, ConnectX-7 InfiniBand, MLNX OFED 25.07) with UCX 1.22.0 built on the node. The two results use different hosts, CPU architectures, driver stacks, and memory domains. They are not reconciled.

Packed rkey size

LaneMemory domainUCXHostPacked rkey
UCX_TLS=tcptcp (registers nothing)1.22.0any9 B (header only)
rc_verbs, CX-7verbs1.22.0 vendoredaarch6420 B
CX-7 IB probeDEVX (expected)1.22.0x86_64 B20019 B
CX-7 IB probeDEVX (expected)1.19.0x86_64 B20018 B

A packed rkey carries key material for each memory domain, and different domain types pack different amounts. The 20 B and 19 B results can therefore both be right. No run has measured a packed rkey under a DEVX domain in velo's own build. From the UCX source, each extra IB device adds 9 B.

The rkey_pack_canary test asserts at least 9 B, and the bound stays there. The 20 B value came from the verbs domain that the link-order defect forced. The value changes once the mlx5 domain opens.

Correctness on InfiniBand

On the same two nodes and lane:

  • All 23 rendezvous_rdma integration tests passed, including a_malformed_descriptor_falls_back_to_chunked and a_failed_get_falls_back_chunked_exactly_once.
  • get_cancelled_by_endpoint_replacement passed in 0.16 s. The caller got an answer, the region was released, and teardown balanced.
  • These RMA tests also passed: rkey_pack_canary, preparse_accepts_real_packed_rkeys, map_get_roundtrip, get_zero_length, get_out_of_range, unmap_waits_for_inflight, shutdown_with_inflight_get, get_cancel_still_releases_the_region, peer_shutdown_during_get_answers_caller, and truncated_rkey_is_refused_before_ucx.
  • Four teardown tests (map_get_roundtrip, unmap_waits_for_inflight, get_cancel_still_releases_the_region, peer_shutdown_during_get_answers_caller) log ucp_ep.c:2222 UCX ERROR ep ... has already been closed. Every assertion holds. This double close does not occur over TCP.
  • unusable_rkey_is_refused_by_ucx_over_tcp aborted the process. See RDMA design.

Not measured: the abandon_rma_ops teardown path under a stopped owner, get_pinned() and get_into() throughput, concurrent transfers, and any soak on hardware.

Probe measurements that shaped the design

These numbers come from probes before the rendezvous code existed. They ran on x86_64 (DGX B200, ConnectX-7 InfiniBand, MLNX OFED 25.07) with UCX 1.22.0 and 1.19.0, pinned to mlx5_0:1. Measured on 2026-08-20.

  • One-sided GET. A 1 MiB GET completed in 826 µs while the owner worker never ran its progress loop. The data matched. This held with UCP_ERR_HANDLING_MODE_PEER on rc_mlx5 with DEVX on, with UCX_IB_MLX5_DEVX=n, and on rc_verbs.
  • Eager Active Messages. With UCP_AM_SEND_FLAG_EAGER on every send, the receiver saw eager data at every size up to 8 MiB and never a rendezvous receive. Without the flag, sends switched to rendezvous at 1 MiB and above. Eager 8 MiB reached 1.28 GB/s against 1.21 GB/s for rendezvous. The single-port loopback path capped at 1.43 GB/s, and UCX reached 83 to 98% of the raw-verbs ceiling on that path.
  • Progress thread wakeup. ucp_worker_signal against a spinning poller took 0.83 µs at p50 and 1.28 µs at p99. Against a blocking poll() it took 7.3 µs at p50, with a 115 to 165 µs tail from CPU idle states. A bare eventfd shows the same tail, so the platform causes it, not UCX. One earlier aarch64 sample of 3.09 µs was a lucky blocking draw.
  • UCX protocol boundaries for Active Messages on rc_mlx5, host memory, CX-7: short up to 2038 B (2030 B with the reply flag), bcopy 2039 to 8118 B, zero-copy 8119 to 8246 B, multi-fragment zero-copy up to 311,293 B, rendezvous from 311,294 B.

The spin-then-park loop of the UCX progress thread (UcxConfig::spin_us, 20 µs by default) follows from the wakeup numbers. A loaded submitter pushes onto the ring in about 100 ns and skips the signal. Only an idle worker pays the wakeup.

How the sweep works

The warm-pair table came from a measurement harness, rv_sweep. It is not a shipped example. It compiles against the current API as an example with required-features = ["ucx"], using only dependencies that examples/Cargo.toml already has. This section describes it so that it can be rebuilt.

Shape. It has the same two-process shape as rendezvous_rdma_two_proc. Each role builds a Velo with a TCP transport, a UCX transport added through add_ucx_transport, a Prometheus registry, and RdmaConfig { rendezvous: RdmaRendezvousConfig { rdma_min_bytes, .. }, .. }.

Flags. --role owner|consumer, --dir <shared directory>, --sizes <comma-separated bytes> (default 4096 to 1048576 in powers of two), --reps <n> (default 10), and --min-bytes <n> (default 65536, applied to both roles). The 4 MiB row needs a --sizes list longer than the default.

Owner.

  1. For each size, stage --reps separate slots with register_data_pinned. A slot is single-use, because release drops its only reference. One slot per rep lets the pair measure a warm endpoint instead of a new wireup each time.
  2. Record metadata(handle).pinned for each slot, and print rdma_registered_bytes().
  3. Write owner.json: the owner's PeerInfo and a list of (size, handle as u128 decimal, pinned).
  4. Wait for consumer.json, register the consumer as a peer, and wait for a done file.
  5. Call graceful_shutdown(ShutdownPolicy::Timeout(30 s)).

Consumer.

  1. Read owner.json, build the instance, register the owner, and write consumer.json.
  2. Wait up to 120 s for the owner's _rv_acquire handler with wait_for_handler.
  3. For each slot: read velo_rendezvous_rdma_path_total{reason="ok"}, time velo.get(handle), call release, and read the counter again. An increase means the RDMA path ran.
  4. Verify the length and every byte against the pattern.
  5. Print SWEEP size=<n> rep=<n> pinned=<bool> path=<rdma|chunked> get_us=<n>.
  6. Print a summary of every non-zero path_total label, write done, and shut down.

Payload. Byte i is (i * 31 + (i >> 8)) as u8. The pattern does not repeat at the chunk size, so a chunk written at the wrong offset fails the comparison.

File exchange. Each card is written to a temporary name and renamed, which is atomic in one directory. The reader polls every 25 ms. Before each read, it calls read_dir on the parent directory. On NFS, a plain read of a name that the client cached as absent does not revalidate, and read_dir forces it. The rendezvous_rdma_two_proc example does not have this step.

Arms. The RDMA arm runs as is. The chunked arm in the table ran the same binary with VELO_RDMA_RENDEZVOUS_DISABLE=1.

Two changes make the next run cleaner:

  • For a clean threshold sweep, run the chunked arm with --min-bytes above the largest size instead of the kill switch. Both arms then stage in pinned memory, and the owner answers below_min. Only the path differs.
  • Add a get_pinned arm. get() includes a copy out of registered memory, and that copy dominates at large sizes.

Run it.

  1. Build: cargo build --manifest-path examples/Cargo.toml --release --features ucx --example rv_sweep.
  2. Allocate two --exclusive nodes on one subnet manager, each with an active InfiniBand port. Verify the port with ibv_devinfo.
  3. Start the owner, then the consumer, with the same --dir and the same UCX_NET_DEVICES and UCX_TLS on both.
  4. Verify the lane with UCX_PROTO_INFO=y, as in Verify the lane.
  5. Take medians over reps 1 and later.

QUIC performance

This chapter records what the QUIC transport costs against TCP, the settings that change it, and one quinn defect that the transport works around. For the design, see Transports.

Settings

Builder methodDefaultEffect
lanes(n)1Up to n connections to each peer, one for each lane used, each from its own UDP socket. Order holds within a lane only. See Lanes.
server_endpoints(n)4Server sockets, each on its own port. A dialer spreads its lanes evenly over them. See Lanes.
udp_buffer_sizes(recv, send)8 MiB, 4 MiBRequested socket buffers. The kernel clamps them to net.core.rmem_max and net.core.wmem_max, and the transport logs the clamp.
max_mtu(bytes)quinn's (1452)Upper bound for path MTU discovery. Values above 6550 are lowered to 6550.
stream_receive_window(bytes)quinn'sFlow-control window for the stream.
keep_alive_interval(d)5 sQUIC keep-alive.
idle_timeout(d)15 sA connection that receives nothing for this long is closed. This is how a peer that died without closing is found. quinn's own default is 30 s.
shrink_threshold(bytes)The TCP transport'sRead-buffer size above which a reader gives memory back after a frame.

The examples read VELO_QUIC_LANES, VELO_QUIC_MAX_MTU, VELO_QUIC_STREAM_WINDOW, and VELO_QUIC_SERVER_ENDPOINTS, so a sweep can change them without new flags.

Packets above 6550 bytes are lost

quinn gives up to 10 packets to one GSO send, and it does not limit the size of the batch. A UDP datagram holds at most 65507 bytes. When a packet is larger than 6550 bytes, a full batch is too large, and the kernel returns EMSGSIZE. quinn-udp 0.5 treats EMSGSIZE as success, because it expects that error only from MTU probes. So the whole batch is lost with no error, and each flight waits for a loss-probe timeout.

Measured on loopback with 64 KiB messages, one at a time:

max_mtuMessages per second
65502,666
6560115 (p95 latency 28 ms)

The transport lowers max_mtu to 6550 and logs a warning. The test large_frames_round_trip_at_a_jumbo_mtu requests 8952 and fails if 200 round trips of 64 KiB take 2 s or more. Without the limit they take about 9 s.

Loopback measurements

Measured on 2026-09-23 on one aarch64 workstation (20 cores, shared, load average near 8), over loopback, with the throughput example and 5,000 messages for each cell. Two reps for each configuration. These numbers show direction only. For a network between two nodes, see Two nodes. For the response plane on a cluster, see Response plane on a cluster.

CaseTCPQUIC, defaultQUIC, max_mtu 6550
64 B, one at a time (msg/s)21,100–23,50012,000–12,20010,200–12,500
64 B, one at a time, p50 (µs)37–4174–7777–92
64 B, 64 in flight (msg/s)294,000–297,000191,000–195,000199,000–200,000
64 B, pipelined (msg/s)482,000–486,000465,000–574,000486,000–562,000
64 KiB, one at a time (msg/s)9,600–11,9002,500–2,6003,200–3,300
64 KiB, 64 in flight (msg/s)16,700–19,9008,400–8,80010,900–11,200
64 KiB, pipelined (MiB/s)2,800–4,400580–620770–790
  • Small messages cost about twice the latency of TCP. Each direction passes through quinn's connection and endpoint driver tasks, so one message takes more task hops than on TCP.
  • Large messages reach about 600 to 800 MiB/s on one connection. One quinn task does the packet work and the encryption for a connection. TCP moves 64 KiB segments on loopback, and the kernel does that work.
  • max_mtu 6550 gives 25% to 30% more for large messages when the path carries it. It does not help small messages.

Settings that did not help

  • A larger initial congestion window. 1 MiB instead of quinn's 14,720 bytes made no difference, at the default MTU or at 6550.
  • Larger flow-control windows. A 16 MiB stream window with a 64 MiB send window made no difference to one-at-a-time traffic, and was slower with 64 in flight (9,300 to 9,600 against 10,900 to 11,200 msg/s).
  • Charging Tokio's task budget per batch of frames. Dynamo's QUIC plane needed this because it polled quinn once per frame header and once per payload. The velo reader uses FramedRead, which decodes all buffered frames before it polls quinn again, and routes frames through flume, which does not charge the budget.
  • LSE atomics. Building with -C target-feature=+lse changed neither transport by more than 5%, on loopback on a 144-core Grace node.

Two nodes

Measured on 2026-09-29 with the throughput example in its two-host mode. The server ran on one node and the client on another. Each node has 144 Grace aarch64 cores, and the process had 16 of them. The nodes connect through 200G Ethernet at MTU 1500, with net.core.rmem_max at 212,992. Each cell sent 20,000 messages. Three reps, with TCP and QUIC interleaved.

CaseTCPQUICQUIC / TCP
64 B, one at a time (msg/s)19,600–21,40012,800–13,0000.62
64 B, one at a time, p50 (µs)5176
64 B, 64 in flight (msg/s)261,000–339,000210,000–270,0000.80
64 B, pipelined (msg/s)433,000–483,000449,000–502,0001.02
64 KiB, one at a time (msg/s)6,400–8,1002,300–2,6000.33
64 KiB, 64 in flight (MiB/s, best rep)2,9507500.27
64 KiB, pipelined (MiB/s, best rep)3,9208400.23
  • Small messages cost about 25 µs more per round trip than on TCP, as on loopback. The network does not change this cost: it comes from the task hops inside quinn. With many messages in flight the gap closes, and pipelined QUIC matches TCP.
  • One QUIC connection tops out at about 0.8 GB/s, the same ceiling as on loopback on the same node type. So the limit is the CPU of the connection, not the network. A profile of the 64 KiB case put about 15% of the samples in AES-GCM. The rest was quinn's packet work, the kernel's UDP path, and task wakeups. TCP moves about 3.9 GB/s, because the kernel and the NIC offloads share that work.
  • A peer that needs more than about 0.8 GB/s needs more than one connection; see Lanes. The Dynamo response plane on the cluster carries far less than that per connection (see below).

Lanes

With lanes(n), each peer gets up to n connections, one for each lane used, each dialed from its own UDP socket. Each lane has its own quinn endpoint driver, shared by every peer dialed on that lane, and each connection has its own connection driver. The work runs on up to n cores.

The messenger sends its own traffic on lane 0, so lanes(n) does not change the throughput of ordinary messages. The batched-streaming mux places streams over the lanes (see Lanes), so streams do get faster. A caller can also send on a lane with send_message_on_lane.

Measured on 2026-09-29 across the same two nodes as above, 64 KiB messages pipelined, 20,000 messages per cell, two reps. A prototype, which is not in the tree, spread the messages round robin over the lanes, with one connection per lane, each from its own UDP socket. The receiving node had 32 server sockets in one SO_REUSEPORT group.

LanesMiB/s
1788–789
21,552–1,558
42,994–3,020
85,773–5,850

The UDP receive-buffer error count stayed below 50 in each run, so the gain comes from more cores, not from more socket buffers. 64 B pipelined messages do not change with the lane count.

Lanes on one server socket share its endpoint driver. So each server socket has its own port, and a dialer sends lane k to socket (offset + k) % n, which spreads its lanes evenly. The prototype instead put all server sockets on one port in a SO_REUSEPORT group, where the kernel hashes each connection to a socket at random, so some sockets carried three or four lanes and others none. The table below shows what that cost with 8 lanes. It ran in a separate job on another pair of nodes, so its 32-socket row differs from the table above by run-to-run spread.

Server sockets in the reuse-port groupMiB/s
43,326–4,014
82,628 or 6,579, as the hash fell
326,463–6,526

With one port for each socket, the lanes of one dialer are placed evenly, so fewer sockets do. Measured on 2026-09-29 across two nodes, 64 KiB messages pipelined, 20,000 messages per cell, two reps, with messages spread round robin over the lanes by a measurement build:

LanesServer sockets, one port eachMiB/s
14781–788
442,984–3,055
845,788–5,800
885,542–5,878

With 8 lanes, 4 sockets carry two lanes each and reach the same rate as 8 sockets. One lane is unchanged.

A fixed bind_addr port P binds ports P to P + n - 1, one for each server socket. Peers spread over all of them, ordinary traffic included, so open all of them in a firewall. Transports on one host need fixed ports at least n apart.

During a rolling upgrade, an old peer can dial a new node, and a new peer can dial an old node. A peer without per-socket ports dials only the first port of a new node, so until it upgrades it shares that socket with the other old peers.

A TCP prototype with the same layout, one connection per lane, scaled too: 2.5, 4.6, 8.1 and 13.1 GB/s (10^9 bytes per second) with 1, 2, 4 and 8 lanes. One TCP connection is limited by its receiver: one reader task does the recvmsg copy, and by default the transport's fixed socket buffers cap the TCP window (see Socket buffers). TcpTransportBuilder::lanes(n) builds the same layout. See TCP lanes. These TCP numbers came from the prototype, not from that code.

Streams over lanes

Measured on 2026-09-30 across two nodes of the same type as above, with the throughput example in its stream mode (--modes stream). The client creates the anchors, and the server attaches to them and streams to them, so the client is the consumer and places the streams on lanes. Both sides set VELO_QUIC_LANES to the lane count and keep the default of 4 server sockets. Each cell sends 200,000 items in total, split evenly over the streams, after a warm-up round. Two reps. The TCP row is one connection in the same job, for reference.

16 KiB items, MiB/s:

Transport16 streams64 streams256 streams
QUIC, 1 lane730–776732–756493–677
QUIC, 2 lanes1,499–1,5051,440–1,4411,472–1,506
QUIC, 4 lanes2,561–2,8742,445–2,8292,784–2,814
QUIC, 8 lanes3,660–3,7045,290–5,3005,282–5,365
TCP, 1 connection1,722–1,7762,098–2,1251,916–2,056

64 B items, items per second:

Transport16 streams256 streams
QUIC, 1 lane174,000–178,000736,000–927,000
QUIC, 8 lanes159,000–161,0001,755,000–1,933,000
TCP, 1 connection183,000–186,000851,000–856,000
  • Streams scale with lanes as messages do. One lane stays at the one-connection limit, 500 to 780 MiB/s. Each doubling of lanes nearly doubles the rate, up to about 5,300 MiB/s at 8 lanes, which is 2.5 times TCP on one connection.
  • 16 streams do not fill 8 lanes. With two streams on each lane, 8 lanes reach about 3,700 MiB/s. Each stream waits for credit every 32 records, which limits the rate of one stream. This was not profiled.
  • Small items lose a little at few streams. 16 streams of 64 B are about 9% slower at 8 lanes than at 1. The likely cause is that each lane's batch then carries the records of only two streams. This was not profiled. At 256 streams, 8 lanes move twice as many items as 1.
  • The streams used the lanes. One lane carries at most about 790 MiB/s, so 5,300 MiB/s at 8 lanes needs data on at least 7 lanes. The lane tests check the placement directly.

A second job on another pair of nodes gave 533 to 735 MiB/s at 1 lane and 3,721 to 5,150 MiB/s at 8 lanes, so the spread between node pairs is larger than between reps.

Later runs on three more pairs of nodes gave 3,237 to 5,402 MiB/s at 8 lanes and 64 streams (median 5,198, 11 reps), and 3,655 to 5,326 MiB/s at 256 streams (median 4,948). The first rep of a job is often the low one. A diagnostic build counted the bytes that each lane's batch handler received: over 8 lanes, the busiest lane carried at most 1.09 times the mean, at 64 and at 256 streams.

The same stream mode over TCP lanes (VELO_TCP_LANES), on one pair of nodes, 2 reps, 16 KiB items, MiB/s:

TCP lanes16 streams64 streams256 streams
11,712–1,7732,083–2,0902,005–2,087
22,986–3,3143,809–3,8473,796–3,894
43,917–4,0466,583–6,7206,882–6,943
83,046–3,0989,921–10,73410,448–10,709

With 64 B items at 256 streams, TCP moved 783,000 to 857,000 items/s at 1 lane and 2,120,000 to 2,219,000 at 8 lanes. At 16 streams, 8 lanes were slower than 4, for both item sizes.

Batched streaming over QUIC

Measured on the same workstation with response_plane_bench (2 anchor hosts, 64 engines, 2,000 requests, 200 warm-up requests, --flush-policy auto), three reps.

TransportRequests/sTTFT p50 (ms)ITL p50 (ms)CPU (ms/request)
TCP8,200–11,60031–722.20.39–0.48
QUIC, default6,800–11,30031–1232.20.50–0.61
QUIC, max_mtu 65507,500–9,60054–1022.1–2.30.58–0.69

In the two quieter reps, QUIC matched TCP on throughput and first-token latency (11,300 and 11,000 against 11,600 and 11,000 requests/s). It used 15% to 30% more CPU for each request. The mux batches many token records into each frame, so the per-message cost of QUIC matters less here than in the one-at-a-time tests. max_mtu 6550 was slower in all three reps with these small frames. Use it only for traffic with large messages.

Response plane on a cluster

Measured on 2026-09-23 with Dynamo's response plane on two nodes. Each node has 144 Grace aarch64 cores. The nodes connect through 200G Ethernet at MTU 1500. One node runs the frontend, etcd, NATS, and the load generator. The frontend has cores 0-71 and the load generator has cores 72-143. The other node runs 512 mock workers in 8 processes. Each run sends 250,000 requests at concurrency 8,192, with 1,024 input tokens and 256 output tokens. Each configuration ran three times, and the runs of the four configurations alternated.

The hosts have net.core.rmem_max and net.core.wmem_max at 212,992. The transport requests 8 MiB and 4 MiB for each socket, and the kernel gives 425,984 bytes. The transport logs one receive-buffer and one send-buffer clamp warning for each socket. The frontend used 32 server endpoints. The workers used the default of 4.

The configurations:

  • Velo, TCP. The messenger mux over the TCP transport. The frontend mints the stream terms at registration (zero-RTT attach), and batches write when the transport admits them.
  • Velo, QUIC. The same configuration over the QUIC transport.
  • Multiplexed TCP. A multiplexed TCP response plane that does not use velo, for reference.
  • Dynamo QUIC. Dynamo's own QUIC response plane on quinn 0.11.12, with 32 server endpoints and no batch interval.

A mock-worker process that holds a share of the 8,192 open requests answers late. Throughput and first-token latency change with the number of these processes in a run. The table therefore compares runs with the same number, shown as "Holders". Steady state is the requests that started 10 s or more after the first request. ITL is the mean inter-token latency of each request. CPU is frontend CPU time for each request. Drops is the RcvbufErrors count of the frontend host for the run.

ConfigurationHoldersRunsRequests/sTTFT p50 (ms)TTFT p99 (ms)ITL p99 (ms)CPU (ms/request)Drops
Velo, TCP112,28241.922710512.00
Velo, QUIC122,212–2,24640.7–42.7232–236107–11012.2–12.63,898–5,818
Multiplexed TCP112,37145.82161019.90
Dynamo QUIC112,10942.424811513.8160,179
Velo, TCP212,76041.11154913.10
Velo, QUIC212,74238.91124513.15,132
Multiplexed TCP222,825–2,91244.7–45.5108–11147–4910.00
Dynamo QUIC222,328–2,49638.9–39.1123–14851–6814.0–14.482,184–94,359

TTFT p50 and p99 are steady-state values. One run of Velo over TCP had 7 holders and is not in the table. No run had client errors.

  • QUIC shows no cost for velo here. At the same number of holders, velo over QUIC and velo over TCP agree on throughput, latency, and CPU. The differences are 1% to 5%, and one or two runs for each cell cannot resolve them. On loopback, QUIC used 15% to 30% more CPU for each request. On the cluster, most of the frontend CPU is outside the transport, and the mux puts many records in each frame.
  • The clamped buffer loses about 0.1% of datagrams. With 32 endpoints, the velo frontend received 4.7 to 5.3 million datagrams in each run and dropped about 0.1% of them. The drops did not show in the tail latency.
  • Velo over QUIC is ahead of Dynamo QUIC. With 2 holders, velo moved 2,742 requests/s against 2,328 to 2,496. It also had a lower first-token p99, a lower ITL p99, and 6% to 9% less CPU. The Dynamo frontend dropped 16 to 18 times as many datagrams with 2 holders, and 27 to 41 times as many with 1 holder. It received about 0.7 datagrams for each output token, against 0.07 to 0.08 for velo. With the batch interval at 0, Dynamo sends close to one packet for each token record. The velo mux puts many records in each frame. Dynamo's default interval of 5 ms was not measured.
  • max_mtu 6550 was not measured. The path MTU of the cluster is 1500, so a larger max_mtu has no effect.

Workspace crates

The workspace has two runtime crates, two hub crates, and one FFI crate.

PathCratePublishedContents
lib/veloveloyesThe runtime. All transports, discovery backends, streaming, the messenger, queues, rendezvous, and metrics are modules here.
lib/velo-extvelo-extyesThe trait surface for out-of-tree plugins, and the value types these traits use
lib/velo-hubvelo-hubnot yet; versions with the workspaceA hub: peer discovery for a velo application, plus hosted extensions. See The hub.
lib/velo-hub-extvelo-hub-extnot yet; versions with the workspaceThe wire protocol of the hub, on velo-ext alone. velo-hub re-exports it as velo_hub::protocol. For programs that speak the protocol without the velo runtime.
crates/ucx-rsucx-rsyes, versioned aloneRust bindings and a vendored static build of UCX, for the ucx feature
examplesvelo-examplesnoExamples. Not a workspace member.

Why only two crates

There used to be nine crates: velo-messenger, velo-transports, velo-streaming, and more. Each had its own version. A 0.1.1 patch of one crate changed its dependency on another internal crate from 0.1 to 0.2. Cargo took 0.1.1 as compatible with 0.1.0, so downstream lockfiles got two copies of the messenger types. The result was a flood of E0277 and E0308 errors in downstream builds.

The fix was structural. All runtime code moved into lib/velo/src/. Do not add a new crate under lib/.

ucx-rs is one exception. It is a leaf FFI crate. It exports no types that velo or velo-ext share, so it cannot cause two copies of a shared type. It must be on crates.io before a velo release with the ucx feature can ship.

velo-hub and velo-hub-ext are the other exceptions. They do share types with velo and with each other, so they take the workspace version and require velo and each other with a caret on that version. See Versioning. A separate trait crate for hub extensions was rejected: an extension gets a Velo and returns axum routes, so that crate would still depend on velo and axum, and it would be one more crate that can cause two copies of a type.

What goes in velo-ext

  • The traits: Transport, FrameTransport, PeerDiscovery, ServiceDiscovery, TransportObservability.
  • The types in their signatures: WorkerId, InstanceId, PeerInfo, WorkerAddress, TransportKey, MessageType, TransportError, SendOutcome, ShutdownState, Direction, and similar.
  • The channel types in their signatures: TransportAdapter, DataStreams, make_channels, AdmissionGate.

These stay out of velo-ext:

  • Concrete transports and discovery backends.
  • prometheus, tonic, axum, and other heavy dependencies.
  • The runtime types: Messenger, Velo, AnchorManager, RendezvousManager.

The test for the boundary: cargo tree -p velo-ext | grep -c prometheus must print 0.

Module map of velo

ModuleContents
velo::messengerActive messages, handlers, dispatch, distributed events
velo::eventsThe generational event system
velo::streamingAnchors, senders, frame transports, and the mux
velo::rendezvousLarge-payload staging and the RDMA GET path
velo::transportsTCP, UDS, QUIC, NATS, gRPC, ZMQ, UCX, and the shared ingress and writer code
velo::discoveryFilesystem, NATS, and etcd backends
velo::queueWork queues
velo::observabilityVeloMetrics and the Prometheus families
velo::simulationThe discrete-event simulation transport

Versioning

velo and velo-ext are published. Out-of-tree code compiles against velo-ext, so every change to it reaches every downstream implementation. The rules below are the structural fix for the bug class in Workspace crates. They are not optional.

Rules

  1. Only velo and velo-ext publish. A new workspace crate gets publish = false. ucx-rs, velo-hub, and velo-hub-ext are the documented exceptions. velo-hub has publish = false until a real port of kvbm's hub passes kvbm's lifecycle tests against it, and velo-hub-ext publishes together with it.
  2. velo-ext has an exact pin. [workspace.dependencies] has velo-ext = { path = "lib/velo-ext", version = "=X.Y.Z" }. A caret requirement lets a later "compatible" patch change downstream lockfiles without notice. Do not change = to a caret.
  3. When velo-ext moves past its latest published version, velo must move past its own. Update the pin to match.
  4. A new trait method in velo-ext needs a default implementation. A method with no default breaks every external implementation. A defaulted addition is a patch release (0.5.0 to 0.5.1). Before 1.0, Cargo reads 0.y as the breaking position.
  5. A change to a signature, bound, or parameter of a trait method is breaking. Before 1.0, that is a minor bump (0.5 to 0.6), and velo must carry a bump over its latest published version too.
  6. Removing a public item from velo-ext is breaking.
  7. A bump is measured against the latest published version. Only versions on crates.io take part in Cargo's resolution. So main must carry a large enough bump over the latest published version for everything merged since that publish. Several breaking changes between two publishes share one bump. For example, if crates.io has 0.17.0 and main is at 0.18.0, a breaking change stays at 0.18.0. After the next publish, the first breaking change bumps again.

velo-hub

velo-hub-ext is the wire protocol of the hub, on velo-ext alone. velo-hub re-exports it as velo_hub::protocol, so its types are part of the velo-hub API. The rules below cover both crates.

The API of velo-hub uses velo types (Velo, PeerInfo, axum Router). A move of velo-hub from velo 0.21 to velo 0.22 changes these types, so it is a breaking change of velo-hub, but its own source does not change. cargo semver-checks cannot see that. These rules make it safe:

  1. velo-hub and velo-hub-ext use version.workspace = true. A velo minor bump is always a velo-hub and velo-hub-ext minor bump, and velo-hub X.Y works with velo X.Y.
  2. [workspace.dependencies] requires velo with a caret on the workspace version: velo = { path = "lib/velo", version = "0.22.0", default-features = false }. Before 1.0, a caret on 0.22.0 means >=0.22.0, <0.23.0, so it never admits a second minor. An exact = pin would also stop a velo patch from reaching users of velo-hub. velo-hub-ext has the same kind of line: velo-hub-ext = { path = "lib/velo-hub-ext", version = "0.22.0" }.
  3. scripts/check-semver.sh fails if the major.minor of either requirement is not the workspace version. This check runs before the semver:skip escape and before the script stops for a change with no crate changes.
  4. velo, velo-hub, and velo-hub-ext publish together, from one commit.
  5. velo-hub re-exports velo, axum, and velo-hub-ext (as velo_hub::protocol). Hub crates downstream use these re-exports, and require velo-hub with a caret. CI also checks that velo-hub-ext depends on no velo runtime and no prometheus.

The CI gate

The Semver Check job runs scripts/check-semver.sh. For each crate that the change touches, the script reads the latest version of the crate from the crates.io index and runs cargo semver-checks check-release against it. It fails when a breaking change is not covered by the version bump over that published version. It skips a crate that was never published (the index answers 404). Any other answer from the index fails the gate: no connection, an error status, a page that is not an index entry, or a crate whose every version is yanked or a pre-release. The gate also fails a version below the latest published one, and a version with a pre-release suffix, even without a breaking change. Build metadata, such as the +ucx.1.22.0 of ucx-rs, is ignored. A change to the root Cargo.toml also selects every crate that takes its version from it. The check is cumulative, so a breaking change that reached main without a bump fails the next change that touches that crate. The semver:skip PR label exists for emergencies. Use it only when a reviewer agrees.

To run the gate locally:

bash scripts/check-semver.sh

Inside the workspace

While all callers of an item are in the workspace, the compiler finds them all. Change the interface, change the callers, and delete the old form. Do not keep a second entry point, a forwarding wrapper, or an alias for an old name.

Testing

Commands

cargo build --all-features
cargo test --all-features --all-targets
cargo clippy --all-features --no-deps --all-targets -- -D warnings
cargo fmt --check
cargo machete
bash scripts/check-semver.sh

Always use --all-features. Without it, the code behind zmq, grpc, quic, nats-*, etcd, ucx, and simulation does not compile, and its tests do not run.

Also test the core configuration. An all-features build can hide a missing dependency feature in a smaller build:

timeout 900 cargo test --locked -p velo --no-default-features --features test-helpers --all-targets
UCX_TLS=tcp timeout 900 cargo test --locked -p velo --no-default-features --features ucx,test-helpers --all-targets

The bytes serde feature is enabled explicitly for tests. Tests must not rely on an unrelated transport to enable it.

Run one integration test by its name. The name is <module>_<file>. For example, tests/transports/zmq_integration.rs is transports_zmq:

cargo test --features zmq --test transports_zmq

Put a time limit on each run

Run each suite under timeout: timeout 900 for the full workspace, and timeout 300 for one target. A test that hangs stops the whole runner, and it does not fail. The tests most likely to hang are those that change timeout and deadline code.

If the run exits with 124, suspect the newest test or the newest change. Run that test binary again with --test-threads=1 --nocapture and a short timeout to find the test that hangs.

Services and system packages

NeedFor
NATS server with JetStream on localhost:4222NATS transport, discovery, and queue tests
etcd on localhost:2379etcd discovery tests
cmakezmq. The build compiles libzmq from source.
libibverbs-dev and librdmacm-dev (headers only)ucx. See below.
protocgRPC code generation

The ucx feature builds UCX with InfiniBand support. UCX configure only warns when the rdma-core headers are missing, and then it builds a TCP-only UCX. ucx-rs stops the build instead, so a TCP-only build can never ship by mistake. No RDMA hardware is needed for the tests. They run with UCX_TLS=tcp.

CI links with mold (RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"). With all features and the bundled libzmq, the default ld can run out of memory on CI runners.

CI jobs

JobWhat it runs
Formattingcargo fmt --check
Clippyclippy with -D warnings, then cargo machete
Cargo Denylicense and ban checks
Testscargo test --locked --all-features --all-targets, with NATS and etcd services
Core configurationTCP and UCX tests with default features disabled
Coveragecargo llvm-cov over the same set
Examplesbuilds all examples and runs the short ones, including rendezvous_rdma_two_proc with UCX_TLS=tcp
Soak smokescripts/soak-smoke.sh
Semver Checkscripts/check-semver.sh
Bookbuilds this book and checks its links

Rules for tests

  • Write the test first. A claim about a fault is proved by a test that fails for that fault. The same test then proves the fix.
  • Prove the precondition. Do not wait for it. A test that sleeps and hopes that a state is reached fails under load and under coverage instrumentation. Wait on an observable signal, such as a counter or a hook, that shows the state.
  • Put the hard lessons in tests. A comment does not stop a regression. A test does. The doc comment of the test can be long, because it carries the reason forward.
  • Count a cost, not only a result. A performance fault passes every functional test. Count the allocations, timer arms, tasks or writes of a hot path, and fail the test when the count grows. See Review for performance.
  • Keep an ignored test honest. If a failing test must land before its fix, mark it #[ignore = "<finding>: <why it fails>"]. An ignored test enforces nothing. Removing the ignore is the first step of the fix.

Known flaky tests

peer_batcher::tests::flush_policy::kicks_during_an_admission_park_neither_double_send_nor_reorder sometimes fails with left: 3.0, right: 2.0. The test assumes the timing of a full admission gate. It fails more often under coverage instrumentation. If a PR changes no file under lib/velo/src/streaming/ and this is the only failure, it is this flake. CI runs only on a new commit, so push a new commit to run it again.

Review for performance

A functional test cannot see a performance fault. The message still arrives, only later or at more CPU. This chapter says where velo pays its costs, which costs have shipped before, how to hold a cost in a test, and how to compare two commits on one machine.

The perf-review skill in .claude/skills/perf-review/ runs this review with agents. Run it before a PR that changes code on a hot path. thermonuclear-review runs the same regression check when a change touches a hot path.

Where the costs are paid

Rank a cost by how often it is paid, not by where the code sits.

TierThe cost is paidExamples
T1per message, frame, record or byte; or per batch when the work grows with streams, slots or peersthe send path, the coalescing writer, frame decode, route_frame, dispatch, the mux batcher and ingress
T2per connection, stream, slot or peer, or per batch of constant workdialing, opening a slot, the credit sweep
T3at setup, shutdown, on errors and on first usebuilders, discovery, handshakes

The code site can mislead. Socket buffers are set once per connection, but when they were set after the first data, the TCP window collapsed for the life of the connection, and throughput fell 100 to 350 times. That is a per-byte cost. See Socket buffers sized after data was in flight.

For scale:

So 100 ns per message is about 1% of either budget.

Costs that shipped

Each of these costs reached velo, and each fix came with a test. A change that brings one back fails its test, unless the change goes around the place that the test counts.

CostFixTest
A cancellation future built per frame on the node-wide teardown token. Every build and drop takes a lock that all connections share.Build one future per connection, on a child token.a_connection_arms_its_teardown_once
A tokio::time::timeout per record. Each one takes the time driver's lock twice.One timer per stream, moved forward only inside half a window.a_thousand_records_arm_the_heartbeat_timer_a_handful_of_times
A full decode of each stream item, only to see whether it ends the stream.Decide from the first bytes.classifying_an_item_does_not_allocate
A task per stream bind, to time out its accept window.One deadline queue that the credit sweep drains.a_finished_zero_rtt_stream_leaves_no_task_behind
A DashMap::iter() walk of two maps on every credit sweep tick, idle or not. The iterator allocates one Arc per shard: 1,024 allocations per tick on 72 CPUs. A new doorbell receive future per tick added one more.Skip each walk when no slot is live or no batcher exists. Keep one doorbell stream across ticks.an_idle_tick_allocates_nothing_before_and_after_traffic, an_idle_wait_allocates_nothing_per_tick
Three writes per large frame.Stage the preamble and header in a stack buffer, so a frame is two writes.test_direct_path_stages_prefix_into_two_writes
A read buffer that doubled for a large frame, and copied at each step.Reserve the whole frame once the preamble is read.test_decode_reserves_capacity_for_announced_frame
Socket buffers set on the accepted socket after data could flow.Set them on the listener, before listen.socket_buffers_sizes_the_listener_or_leaves_it_to_the_kernel
One write per frame.The coalescing writer.burst_coalesces_writes
A tokio::time::timeout per item on an ordered lane.One idle timer per lane.lane_arms_its_idle_timer_a_handful_of_times_not_once_per_item
QUIC packets above 6550 bytes, which GSO loses.Clamp max_mtu to 6550.large_frames_round_trip_at_a_jumbo_mtu

Some changes were measured and did not help. Read these lists before you propose an optimization:

Hold a cost in a test

A comment does not stop a regression. Count the cost in a test, and make the count fail when the cost comes back.

  • Allocations. crate::test_alloc::allocations_in(|| ...) counts the allocations of a closure. It works only in lib/velo unit tests, because it is that test binary's global allocator, and it counts only the calling thread.

  • Builds of a future or a timer. Route every build through one function that bumps a counter in tests and compiles to nothing in release:

    #![allow(unused)]
    fn main() {
    #[cfg(test)]
    fn note_teardown_arm() {
        let _ = TEARDOWN_ARMS.try_with(|n| n.fetch_add(1, Ordering::Relaxed));
    }
    #[cfg(not(test))]
    #[inline(always)]
    fn note_teardown_arm() {}
    }

    The test counts only builds that go through the seam. New code that builds the same future somewhere else escapes the test, so a review must check for that. To prove a bypass, add the seam call back at the new site in a scratch copy and run the test: if it fails there, the change went around it.

  • Tasks. Compare Handle::current().metrics().num_alive_tasks() with a baseline after the work ends.

  • Writes and batches. Use the recording writer in transports/coalesce/tests.rs, or the ratios from observability::test_helpers::MetricSnapshot end to end.

Prove each test before you trust it: revert the fix, and check that the test fails. See rule 11 in Measurement rules.

Compare two commits on one machine

  1. Build each arm in its own worktree and target directory. Use the same features for both. Do not share one CARGO_TARGET_DIR between two worktrees of this repo: cargo gives velo's build the same name in both and decides freshness by file times, so one arm can run the other arm's code with no warning. Check that each binary names its own worktree's source paths (strings <binary> | grep <worktree>).
  2. Count before you time. A count of allocations, timer arms, tasks or writes is a fact on any machine. Next best is perf stat -e instructions:u divided by the work done. Wall time comes last.
  3. Run a harness that reaches the changed code. tx_budget attributes the TCP and UDS send path layer by layer. throughput and ping_pong cover the messenger. response_plane_bench, batched_streaming and stream_lifecycle cover the mux, and they are the only harnesses that install metrics. See Benchmarking.
  4. Interleave the arms. Pin both to the same cores, run at least five reps of each, and change which arm goes first.
  5. Call a difference real only when the ranges do not overlap. Report latency beside throughput.
  6. Check the memory kept per operation. Run each arm at N, 4N and 16N operations, at the same concurrency. Divide the RSS difference between N and 4N by 3N, and check that 4N to 16N grows at the same rate. Growth is not yet a leak. Tasks of streams that ended can pile up in a loop that never yields, and glibc keeps freed pages. Read num_alive_tasks() and RSS after malloc_trim(0) before you call it a leak. In anchor_setup_bench, two tasks per stream grew RSS to 24 GB at 16M lifecycles (a measured peak). In a run of 512k lifecycles, malloc_trim(0) after the run took RSS from 777 MiB to 6 MiB.
  7. Label the result. A number from a shared or dev machine is directional. A number for this book needs an exclusive node and the Measurement rules.

Documentation

This book is the documentation of Velo. The source is in docs/src/. A GitHub Actions workflow builds it and publishes it to GitHub Pages on each push to main.

Where text goes

TextPlace
How a subsystem works, and whyA chapter in this book
Measured numbers, with their setupA chapter under Operations
Rejected designs and negative resultsA chapter under Development
The reason for a line of codeA short code comment
A hard lesson ("X breaks when Y")A test. Its doc comment can be long.
Plans, work in progress, session notesagent-docs/ on a work branch. Never merged to main.

Rules

  • agent-docs/ never merges to main. Use it for notes while work is in progress. Before the PR merges, move the lasting parts into this book and delete the directory from the branch.
  • Keep only what lasts. Write how it works, why, the numbers with their setup, and the results that did not work. Do not copy plans, phase or workstream labels, PR numbers, or handoff notes. History is in git.
  • Write in simple English. Use the simple-english skill for the book, README files, and comments.
  • Keep comments short. A comment says why, or which fault the code avoids. It does not repeat the code.
  • Update the book in the same PR. Each PR that changes behavior updates the chapters that describe it and the top-level README.md.

Build the book locally

Install the tools at the versions that CI uses:

cargo install --locked mdbook --version 0.4.52
cargo install --locked mdbook-mermaid --version 0.16.2
cargo install --locked mdbook-linkcheck --version 0.7.7

Build the book and check its links. This is the command that CI runs:

bash scripts/build-book.sh

A broken internal link fails the build. To preview while you edit, serve the book:

mdbook serve docs --open

Batched streaming design

This chapter is the design record for the messenger mux. It lists the rulings that constrain future changes, each with the alternative that was rejected and what that alternative costs. It also records the validation method, the failures found and their mechanisms, and the hazards that remain open. Batched streaming describes how the mux works. Read that chapter first.

Validation methodology

At two nodes and ten streams on loopback, the mux is the same speed as the per-stream path or slightly slower. The gain is proportional to X/Y (streams per peer) and appears only at scale. The method therefore uses independent lines of evidence, ordered by how few assumptions each needs.

V1: resource-ceiling arithmetic

The per-stream path uses one socket per remote stream: one file descriptor and about 2 MiB of requested socket buffer in each process, and 4 tasks across the two ends. At the default ulimit -n of 1,024, a process stops below 1,024 concurrent remote streams, less its other descriptors. This is a hard wall, not a slope. A small-scale null result cannot refute it, which is why it comes first. The intended test opens N streams and counts /proc/self/fd. That test is not built.

V2: analytical cost model

Let s be the syscall and TCP cost per write_all, e the encode cost, f the receive-side decode and handoff, w a channel-handoff wake and p the per-record framing cost:

per-stream:  X · (e + s + w_connect + w_recv + f)
mux:         X · (e + f + p) + Y · (s + w)

The model makes three predictions that a measurement can refute:

  • Syscalls and memory improve for any X/Y above 1.
  • CPU crosses over where X/Y exceeds (s + w) / (s + w_connect + w_recv - p). Because p (about 20 ns) is small next to s (1 to 3 µs), that ratio is near 1. The prediction is that the mux costs less CPU for X/Y above 2, and saves more than 20% of streaming CPU above X/Y of 8.
  • Latency gets slightly worse, by one extra channel hop of 1 to 5 µs. Report this term beside every throughput number.

V3: microbenchmarks

The planned Criterion targets measure the model constants directly:

  • encode_frame for one payload against one batch of N, for e, p and the 64 KiB coalescing cliff.
  • Decode of N frames at once against N calls, for f.
  • A channel round trip, for w.
  • Loopback N writes against one batched write, for s.

These benchmarks are not built.

V4: deterministic simulation

SimFabric charges base_latency per transfer, so it captures the packing gain deterministically. Streaming over it needs a SimFrameTransport and a StreamConfig::Sim, which do not exist. The simulation models only the network term, and it favors the mux there. It models no syscalls, no scheduling, no socket memory and no descriptor exhaustion, which is where most of the real gain is. Its larger value is as a deterministic correctness harness for credit accounting, slot generations and connection loss under adversarial interleavings.

V5: loopback scale sweep

The plan sweeps X over {1, 8, 64, 256, 1024, 4096} and Y over {1, 2, 8, 32} at about 64-byte frames, with the same binary on both paths. It records wall time, CPU, peak RSS, peak descriptor count, the batching ratio and p50 and p99 per-frame latency. Loopback removes wire time, which favors the mux, so the sweep needs a two-host repeat. A variant pinned to 2 cores shows the task and syscall gain under CPU scarcity, which is the normal state of an inference server. response_plane_bench covers part of this sweep. See Benchmarking.

V6: falsification criteria

If one of these criteria holds, the design stops or changes:

  • The batching ratio does not improve at X/Y of 64. Then sends are not clustered in time and the premise is wrong. This is the cheapest and most decisive test. The per-stream path measures 1.00 for the forward-pass shape, and the mux measures 2.19 to 5.38 in batched_streaming at a credit window of 256.
  • p99 per-frame latency at X/Y of 1 is more than twice the per-stream figure. Then a deployment with that shape sets enabled: false. This criterion does not decide the default (see below).
  • CPU does not fall at X/Y of 64. Then encode dominates, and the fix is a cheaper codec. On the external serving rig, the mux cost 8.09 ms of frontend CPU per request against 10.48 for one connection per request.
  • Measurable head-of-line blocking under mixed fast and slow consumers. Then the credit design failed.
  • velo_streaming_heartbeat_watchdog_firings_total rises after heartbeat consolidation. Then the consolidation lost real failure detection.
  • Throughput improves because added latency lets the consumer catch up. This is the trap. Always report latency beside throughput, and watch time to first token. Any windowed or hinted flush policy can make it worse, which is why the default flush does not wait.

The mux is on by default. The latency criterion above does not decide the default, and it has not been measured since the default changed. What decides it is a failure of the per-stream path: on a cluster, it failed 80% to 92% of requests at about 2,500 new streams per second, because the workers could not get a local port. See Batched streaming. A deployment with one stream per peer that measures a latency cost sets enabled: false.

The change of default has these effects that an operator can see:

  • A consumer with the mux mints zero-RTT tickets, and a producer without the mux cannot open them. Upgrade producers before the consumers that mint tickets.
  • velo_streaming_producer_send_backpressure_total changes meaning. See Saturation.
  • A producer that runs the slot byte budget ahead of its consumer waits in send. The stream is not killed. See Saturation.

Rulings

Ride the Messenger, not a connection per peer

The mux packs records into active messages on the Messenger's existing connections. A dedicated mux transport with its own connections needs four more parts:

  • A handshake magic value to learn the sender's identity.
  • A fan of connections per peer for encode parallelism.
  • A four-task connection lifecycle.
  • A solution for acceptor identity.

The Messenger envelope already carries the sender, and one batcher per peer spreads encode work.

The price is that streaming shares queues, framing and backpressure with control traffic. Order is an explicit protocol obligation, so every record carries a per-slot frame_seq. The 13-byte record header pays for this. Measure before you shrink it.

Ordered dispatch, not a reorder window

_stream_batch uses ordered per-sender dispatch. The deprecated VeloFrameTransport put a 4,096-deep reorder buffer over a dispatcher that spawned a task per inbound message. Under cross-stream contention the window overflowed and deadlocked the consumer. That transport is deleted.

Do not shard the lane or reorder batches from one peer. OpenSlot travels in its own batch, and a Data batch that overtakes it truncates the head of the stream silently. Per-slot frame_seq is enough for Data only after OpenSlot lands.

Credit against the mux buffer, never against the anchor channel

The anchor's frame_tx has writers other than the mux: the same-worker attach path, detach and finalize, the watchdog's injection and MPSC senders. A proof of "C credits against a C-deep channel" fails with a second writer. The mux therefore owns a C+1 buffer and a reader pump moves records into frame_tx.

This ruling keeps the reader pump. Merging the pump into the anchor channel removes one task hop per record (about 1 ms of frontend CPU per request on the serving rig), but it breaks the credit proof. Removing the pump for mux streams also removes the heartbeat watchdog and velo_streaming_reader_pump_backpressure_total.

Addendum, 2026-09-26: the pump is off the mux data path, by another route. The ruling against the merge stands, and so does its reason. What changed is who reads the mux buffer. The StreamAnchor now polls the slot buffer itself, ahead of its own frame_tx, and counts each record it takes on the slot's DrainSignal (lib/velo/src/streaming/control/feed.rs). The mux is still the buffer's only writer, so the credit proof is unchanged. The anchor channel carries only sentinels and the heartbeat that tells a parked consumer a new feed is installed.

The watchdog is not lost. A stream watchdog, one task per mux stream that holds no data, keeps the pump's other duties: it reaps an unclaimed bind, injects Dropped after DETECTION_MULTIPLIER dead windows, and keeps the pre-bind exemption. It wakes once per window or when the mux closes the bind. A window is live if the ingress delivered to the slot, or if the sender holds no data credit with nothing waiting behind a sequence gap. The backpressure counter is lost for mux streams: nothing forwards into the anchor channel, so it no longer moves. velo_streaming_slot_credit_exhausted_total, counted on the producer's node, is the consumer-saturation signal there.

What changes in behavior:

  • A consumer that stops polling holds its sender to the credit window C. The pump counted each move into the 256-deep anchor channel as a drain, so the sender could run C + 1 + 256 records ahead.
  • Detection lands between DETECTION_MULTIPLIER windows and one window more after the last arrival, because windows run on the watchdog's own clock. The pump detected exactly DETECTION_MULTIPLIER windows after the last forwarded frame.
  • A window is exempt only while the sender holds no data credit, as the consumer node's credit account sees it, and nothing waits in the reorder hold: credit spent into the hold behind a gap that never closes would otherwise exempt a stuck stream for good. Arrivals count deliveries, not records parked in the hold. An earlier version exempted any non-empty buffer; a dead sender behind a consumer that was connected but slow then kept its anchor, slot and peer batcher until the consumer drained. A sender that still holds credit can heartbeat, so its silence is its own whether or not records wait unread. The account also covers a sender starved by the byte budget, whose credit the consumer node withholds while the buffer holds that budget with fewer than C records in it.
  • Retiring a bind or removing the anchor withdraws the feed. The consumer drops a withdrawn feed before it reads that buffer again, so unread records in it are discarded.

Measured on one Grace node with a two-process microbenchmark (frontend 32 threads, 2,048 streams, 918 records of 160 B, jemalloc, median of 3). Frontend CPU per record fell from 5.87 to 5.33 µs at 12 peers (−9.2%) and from 7.71 to 7.38 µs at 48 peers (−4.3%). In the profiles before these changes, the reader pump was 28% of frontend CPU at 12 peers. Response plane performance has the setup and the dirty-set numbers.

Generations and epochs

Dense slot reuse without a generation delivers a stale record to the stream that now holds its index. A u8 generation is enough because the epoch scopes the whole table: batches from an old epoch are discarded as a whole.

The byte side throttles grants and does not refuse records

When frame credit and the byte cap disagree, the receiver withholds the next grant. Refusing a record whose frame credit was already granted breaks a stream for a peer that obeyed every rule. The ingress hold is the one exception, because the alternative is unbounded growth behind a gap that can stay open.

Drain every inlet up to the byte budget, and do not split control from data

The first design split the egress inlet into an unbounded control lane and a bounded data lane. FrameTransport::connect returns one flume::Sender<Vec<u8>>, and in that byte channel a terminal and a token look the same. A split needs a typed sink in the velo-ext trait, which is a breaking change to a published crate.

The batcher instead drains every inlet, with or without credit, into a per-slot withheld queue that the slot byte budget bounds. At the budget, the batcher stops pulling from that inlet, and the producer's send waits. A synchronous terminal send on a full inlet waits in a task, so it does not block its caller.

The first version killed the slot at the budget instead of pausing. That killed every producer faster than one credit round trip, including producers whose consumer was draining. A 10,000-record stream of 1 KiB records over loopback died after 1,128 records.

Ingress copies each record out of its batch

The ingress gets each batch as one Bytes, and each record knows its range in it. It still copies every record body into its own Vec for the slot buffer. Handing the consumer payload.slice(range) instead would remove that copy, and with byte items the consumer would then copy nothing. It is not done, for two reasons:

  • The byte budgets would stop bounding memory. A slice keeps its whole batch in memory. A batch holds records of many streams, so a consumer that keeps one 100 B token keeps up to the batch size, and many slow consumers keep many batches. The slot and peer budgets count record bytes, so they would no longer bound what the node holds. Slicing only a record that fills most of its batch would bound this, but it is a second policy to keep correct.
  • velo-ext fixes the slot buffer type. The mux implements FrameTransport::bind, which returns flume::Receiver<Vec<u8>>. A slot buffer of Bytes needs the trait path to keep its own Vec channel, and it ripples through the attach and pre-bind paths, the direct feed, the MPSC pump and the anchor.

The producer side has the same limit. Its frame goes through the slot inlet, the flume::Sender<Vec<u8>> that FrameTransport::connect returns, so it is built once and copied into the batch once. Writing it straight into the batch needs another inlet type for the mux and a StreamSender that knows which transport it has.

The cost of the ingress copy is estimated at 35 to 55 ns per record, in Response plane performance.

Control is coalesced state, bounded by allocation

Credit returns, closes and singleton resolutions reach the batcher as per-slot state, not as messages. A queue is unbounded exactly when a flush parks on admission, which is when the peer returns the most credit.

A size cap on these maps was the wrong bound. A 4,096-entry cap was sized for about 1,024 slots per peer. On the serving rig one peer held 4,000 to 6,700 slots, and the cap refused grants, closes and the answer that lifts a fenced slot. A refused grant is lost for good, because the receiver zeroes its ungranted count when it mints the CreditUpdate. Each map is now bounded by what distinguishes a real key from a bogus one:

  • Control that a peer sends about this side's slots is accepted only for an index this batcher allocated, at its live generation. A retired generation is dropped silently, because a close-then-reopen race is ordinary.
  • Replies for slots that this side's ingress admitted are never refused.
  • Rejections of an OpenSlot that ingress never admitted travel on a reject lane capped at 8,192. A dropped rejection loses no credit, so only this lane can have a cap.

Admission is the backpressure, and a failed admission is epoch death

The batcher awaits each flush's admission, so a congested peer parks the batcher in FIFO order and not a runtime worker. FireResult erases the admission error to a string, so the batcher cannot match on a subset of errors. Treating every failure as epoch death is correct anyway. A batch that never reached the wire leaves a frame_seq gap in every slot it carried, and the mux does not retransmit.

Eager OpenSlot, and credit from the attach response

OpenSlot goes out when the stream attaches, so the accept window (ReservationConfig::prebind_timeout, 180 seconds by default) measures "time until the OpenSlot arrives" and not "time until the first token". Otherwise a long prefill expires the window and kills a healthy request.

The slot opens already holding the window from the attach response. Before negotiation, the receiver sent a CreditUpdate on OpenSlot, which cost a round trip per stream. Both together grant 2C against a C+1 buffer, so the change was a swap.

flush_batch is a call, and Manual has no timer

The rejected design was an RAII gate with a 5 ms watchdog that forced the gate open. A gate can be held across an .await, which made a deadlock possible. The watchdog turns a forgotten flush into the windowed policy. Manual is then deterministic only until something is slow, which is the worse failure. A deployment that wants a net configures Auto { max_linger }.

The one exception is a pending credit reply. Nothing on the replying side knows that a peer is owed credit, so Manual cannot leave the reply to the application. reply_linger carries the reply, and the batch around it, out after a bounded wait. A pass that contains a credit reply can therefore split across two writes under Manual.

The price of Manual is also known. A per-pass flush caps a batch at that pass's fan-out, where Auto can pack the next pass too. Measured, Manual is lower only when the producer outruns the batcher, and that surplus costs per-token latency.

Negotiate per attach and register both transports

The receiver answers messenger-mux-v2 only when the sender named it. resolve_transport fails on an unknown key, so a receiver that chooses the mux on its own breaks every older sender. A node with the mux registers both keys. The wire version lives in the key, so incompatible versions never pair. A zero window with the mux key is refused, because a fallback connects to a transport where nothing listens.

Credit return: the consumer posts, the reconcile decides

The first design had the reader pump call credit.release(1) after each handoff. The landed design has the drainer count on the slot's DrainSignal, list the slot in a per-peer dirty set and post the peer. A reconcile then swaps the count and releases it. For a mux stream the drainer is the consumer itself, which reads the slot buffer directly; for an MPSC anchor it is the MPSC pump.

  • Why the consumer does not release. Releasing needs the peer mutex that the inbound batch path takes. Taking it per record trades a periodic cost for a worse per-record one. Two paths that each release an amount for one record also double-count, and the periodic sweep still exists. With the quantity on the slot, every visit is idempotent.
  • Why wakes coalesce per peer. Posting per record replaces a periodic cost with a per-record one. A per-slot record threshold withholds credit for the first T records of every slot. It also still posts once per slot per threshold, so it is worse on latency and on volume.
  • Why the wake flag drops before the walk. A drain that lands during a walk must post a fresh wake. The same property lets a fast consumer re-arm the flag at once, so the task walks back to back under the peer mutex. drain_visit_floor (2 ms, the old sweep interval) caps the visit rate per (peer, lane). A wake inside the floor is scheduled for when the peer comes due, and later drains coalesce into that visit.
  • Why the arrival path reads the dirty set. Narrowing the per-batch reconcile to the slots a batch delivered into left every other slot to the doorbell and the sweep. Every stream on the serving rig sends about 4 records more than its then-default 256-record window, so each stream's tail needs one grant. On the rig, sender credit exhaustion rose from 13 to about 20,500 per process. Doorbell visits fell to about one per peer per 12 ms. The frontend's lane wait rose from 0.36 ms to 1.4 s per batch. Throughput halved to 1,516 req/s and TTFT p50 rose from 55 to 331 ms. The grant must ride the peer's next inbound batch.
  • Why the count replaces the occupancy estimate. Reading frame_tx.len() takes the channel's lock. At about 1,000 slots per peer and 6 million batches per rep, that read was the largest velo-only cost on the frontend.
  • Why the order is clear, then swap. A drain that lands between the two steps finds the listing down and lists the slot again. The next pass then finds either a zero count or the new drain, never a count with nothing to fetch it.
  • Why a bitmap, not a lane. The dirty set was a bounded flume lane per peer plus a listed flag per slot. With about one record per stream per batch, nearly every drain found its slot unlisted and took the lane's mutex, which every consumer of the peer shares. It is now one lock-free bitmap per peer (lib/velo/src/streaming/messenger_mux/ingress/dirty.rs). Only the drain that newly lists a slot touches the peer's shared pending flag, and the drain wake lane is unbounded, so a wake is never refused. On the two-process microbenchmark, frontend CPU per record fell from 6.66 to 5.82 µs at 12 peers (−12.5%) and from 8.60 to 8.11 µs at 48 peers (−5.6%).

The doorbell's deferral queue holds at most one entry per peer. An earlier version queued a second entry when a periodic tick cleared a peer's wake and a drain re-armed it inside the floor. The queue then grew by one entry per tick, permanently. The floor is also clamped to one hour, because last + floor overflows for an absurd Duration.

The periodic sweep defaults to 200 ms and must be non-zero. tokio::time::interval panics on a zero period inside the spawned sweep task. The build returned Ok and the mux then ran with no credit backstop, no eviction and nobody reading the doorbell. The build now refuses a zero interval.

A grant threshold (grant only when half the window has drained) was built and measured. It cut credit updates 19-fold and did not change CPU. It made the inter-token p99 worse, because a slow reader exhausts the sender sooner when drained credit is held back. Do not build it again.

Zero-RTT: pre-bind is the synchronous twin of bind

MessengerMuxTransport::bind_on_lane calls the same open_bind body as FrameTransport::bind. bind is async only because the trait is. One body keeps the two paths from drifting.

  • Rejected: bind on OpenSlot. Minting a ticket and binding when the OpenSlot arrives has a simpler lifecycle. It inverts a layer (ingress resolves the anchor's channel from the registry) and it loses the meaning of the accept window.
  • No protocol version bump. The ticket rides the application's envelope as an optional field. A version bump breaks an old worker outright. An absent field makes the worker attach the ordinary way.
  • The watchdog exemption is gated on WatchdogContext::prebound, not on the drain claim alone. The mux parks a DrainSignal for every bind. The signal reads as unclaimed until the peer's OpenSlot arrives, which is after the attach response returned. prebound tells a real pre-bind (no sender yet) from an ordinary attach whose OpenSlot is still in flight. It is an Arc<AtomicBool> so that PreBind::adopt can clear it at once when a sender attaches the long way.
  • The exemption lets the accept window be the true backstop. Counting silence before a sender exists caps the wait at DETECTION_MULTIPLIER × heartbeat_interval (15 s at the default), whatever the application configured.
  • Adoption does not restart the accept window. An adopted attach inherits what is left of the accept window. Whichever deadline comes first catches a sender that dies before its first record: the watchdog, measured from adoption, or the remaining accept window. The watchdog fires DETECTION_MULTIPLIER (3) heartbeat intervals after adoption. So with the 180-second default, a heartbeat interval of 60 s or more lets the accept window win. The window closes up to one credit_sweep_interval late, so at exactly a third of the window the two can land together.

async_open_ack fences only when admission is pending

The first version fenced every detached open. On an uncongested peer the admission is already Admitted synchronously, and per-target FIFO already orders the slot's later records behind the OpenSlot. The fence then only made the first record wait for a spawn, a control insert, a wake and a release pass. The open now fences only when admission_state() is not Admitted.

This skip applies to OpenSlot only. A rendezvous record always fences its slot, even when admitted synchronously. The receiver resolves rendezvous bytes in a detached task before dispatch, so the sender's admission order does not order the receiver's apply.

The batch gap meter keeps a high-water mark

A detached open and a later flush to the same peer are separate tasks, so their batch_seq values can reach the wire out of order. Per-slot order does not depend on batch_seq, so this inverts a counter, not a stream. The gap meter used wrapping_sub, and one inverted pair added 4,294,967,295 to velo_streaming_mux_batch_seq_gaps_total. The meter now keeps a high-water mark under RFC 1982 comparison. A batch behind the mark is not metered, so an inverted pair costs exactly one. A batch_seq per registration class removes that one. It is not built.

One timer per stream in the reader pump

This applies to reader_pump, which now serves only per-stream transports. The mux's stream watchdog has no per-record work at all: it wakes once per window on its own timer and reads the slot's arrival count.

The pump arms one pinned Sleep for its stream. A received frame pushes the deadline only when the deadline is within half a window. When the timer fires, the pump re-arms from the last frame and counts a miss only if the last frame is older than the deadline.

The first version re-armed only from the fired arm. Under traffic every stream fired once per window, and 8,192 streams opened in the same second fired in phase. The receive-arm push is what keeps a live stream's timer from firing.

The pump stamps the frame time after the forward to the anchor channel completes. A forward blocked on a full anchor channel is the pump working, not the sender going silent. Stamping on arrival charges that block to the sender and fires the watchdog one window early.

The select! is biased toward the receive arm. tokio::time::timeout, which this replaced, always polled the receive first. An unbiased select can let a ready timer win over a ready frame, and report a real terminal as a watchdog Dropped. The messenger's ordered-lane router uses the same one-timer shape and the same bias.

Heartbeats: the consolidation that is partly built

A per-stream heartbeat task does not detect a hung producer, because it runs on its own task. Under the mux, the health check of the batcher detects a dead peer on the sending side, and the mux learns of it through epoch death. On a node that only receives, a dead producer is still the stream watchdog's job. A connection that closes after admission is not reported to the mux, and the stream watchdog ends the slots that lost records with it. The other signal that a stream heartbeat carries is per-slot saturation. The design keeps the heartbeat per slot and makes it cheap:

  • Suppression. A per-slot "last send" tick (one relaxed AtomicU64 store per frame). A slot that sent anything in the interval skips its heartbeat.
  • Phase alignment. Each sender follows one peer-level tick (a tokio::sync::watch driven by one timer task) instead of its own interval. Heartbeats then coalesce into one batch instead of scattering across the interval.
  • No per-sender task. The batcher walks its live slots on each tick and emits SlotHeartbeat for the idle ones.

N scattered heartbeats per interval become at most one batch, and N tasks become one. The receiver still sees a per-slot beat inside its per-slot deadline, so the stream watchdog and DETECTION_MULTIPLIER do not change. SlotHeartbeat must not join the reserved control class: a heartbeat dropped under saturation is the saturation signal. The third point is built without the batcher: one task per AnchorManager beats every remote sender, so N senders use one task. Suppression and phase alignment are not built, and heartbeats still travel as Data records. A sender on the anchor's own worker does not beat, because no watchdog reads its beats.

Why the mux surface is not in velo-ext

The mux is a user of the Messenger send path, not something a transport implements. An out-of-tree Transport gets multiplexing without knowing that the mux exists. Exposing a mux to out-of-tree FrameTransport implementors (for example open_mux and MuxChannel) is deferred for two reasons:

  1. Coupled defaulted methods fail silently. The velo-ext rules require default implementations. A transport can then report supports_mux() == true and leave the acceptor a no-op, so senders open slots into nothing. If this surface lands, it must be one method, as_mux() -> Option<&dyn MuxTransport>, returning a separate trait with no defaults. The seam must carry a typed sink (Frame::{Data, Terminal, ...}), not a byte channel. A transport can then reserve capacity for records that must never queue behind data.
  2. A byte pipe can be the wrong abstraction. An RDMA transport already multiplexes per queue pair. Forcing it to serialize into a byte stream so that velo can split it again is slower. Removing a published velo-ext item is a major version bump, so the wrong shape is expensive.

The additions this work made to velo-ext were shaped to last. Transport::max_message_size(target) -> Option<usize> has a default, and None means unknown, which costs the caller a conservative budget. SendOutcome::Pending(SendAdmission) changed a published enum, so it shipped once, as a coordinated velo-ext and velo release. The egress recorders on TransportObservability have no-op defaults.

The cost of deferral is bounded. Out-of-tree FrameTransports get no multiplexing, and nothing else breaks. A receiver offers messenger-mux-v2 only when the mux is installed and enabled, so a deployment without the mux degrades correctly.

Methods that the mux needs outside the trait (advertised_limits, connect_negotiated, take_drain_signal, prebind, release_bind, close_claimed_slot) are inherent on the concrete MessengerMuxTransport. AnchorManager already holds the concrete type, so none of them needs a trait change.

Failures and their mechanisms

A terminal send wedged the runtime

A test with six streams to one peer, 100 frames each and initial_credit 8 never finished. The same six streams passed with a window of 512, so credit exhaustion was the variable. The process had three threads, all in futex_wait, and used no CPU.

The first hypothesis was half right. It named the right call site (finalize, detach and Drop reach the inlet through a synchronous flume::Sender::send) and the right resource (the C+1 inlet). It was wrong in two ways:

  • It assumed that a starved slot's channel never drains. The batcher had drained every inlet into its withheld queues and was idle, waiting for credit.
  • It assumed that the terminal reserve was too small. The inlet was full of data records. The +1 in the slot depth is a credit reservation on the receive side, not egress channel capacity. A reserved terminal seat fixes this test and leaves the defect.

The real mechanism: std::thread::available_parallelism() returned 1 on that host (while nproc reported 128), so the test runtime had one worker. The synchronous send blocked that worker. The batcher, the only task that can make room, did not run again, and runtime shutdown waited on the blocked worker. With W workers, W concurrent blocking terminal sends wedge the runtime. A decode engine that finalizes many starved streams at once is that case.

The fix is send_terminal. It calls try_send. On a full channel it hands the record to a task that awaits space. The task holds a sender clone, so the receiver cannot see end-of-stream before the sentinel. With no runtime on the thread, the task runs on the runtime the sender was made on. The invariant is structural: no terminal send blocks a runtime-owned thread, at any credit, stream count or runtime size. tokio::task::block_in_place was rejected because it panics on a current_thread runtime.

detach clears the attachment flag only after the sentinel is in the channel. Otherwise a sender that re-attaches in the gap puts its records ahead of the Detached frame.

The test a_terminal_lands_even_when_its_inlet_is_full runs eight streams at initial_credit 1, the smallest window a peer can advertise. With the blocking send restored, it hangs. With the fix, it passes in 0.07 s.

The MPSC sender has the same class of defect on its same-worker path. MpscStreamSender's Drop for a local sender still calls a blocking tx.send, and test_mpsc_local_drop_preserved_under_backpressure asserts that the drop blocks while the channel is full. Its remote path uses try_send and does not block.

A credit reply was lost on epoch death

A CreditUpdate zeroes the slot's ungranted credit when it is minted, not when its batch is admitted. From then on, the batch holds the only copy of that credit.

  • Loss path 1. The batcher applies replies before its own control. A reply staged from the first set can still be in the open batch when a failed singleton in the second set calls epoch death. Epoch death discards the batch. reply_linger widens this window from one control drain to up to the linger. The batcher now keeps its own copy of the credit in the open batch. On epoch death it hands the copy back, and the next batch advertises it again. This is correct and not a double grant: the credit belongs to ingress slots, which epoch death does not close. If the batcher is retiring and its inbox is closed, the credit goes to the batcher that took over the peer. velo_streaming_mux_credit_reposted_total counts the hand-backs. velo_streaming_mux_credit_lost_total counts credit that reached neither destination and must stay at zero.
  • Loss path 2. A batch that fails to write loses its credit replies. This path is open on purpose. Batcher::flush clears the copy before the write. A transport that refused this batch refuses the rebuilt one too. The re-post also wakes the task. Closing this path therefore turns a failing transport into an unbounded retry paced by reply_linger. The idle reaper never reaches a task that wakes. Losing the credit of a peer whose writes fail costs nothing that the failing epoch did not already cost. No counter records this loss. If the decision is revisited, bound the retry.

Two alternatives were considered for path 1. The chosen one re-posts on discard. The other defers the zeroing until admission. That needs write confirmation per slot from the batcher back into ingress, across a boundary that the flush gate keeps.

A prompt close was lost to batcher eviction

A consumer that dropped a claimed pre-bind posted a close to the producer. The close was lost when the sweep retired the peer's batcher between the removal of the ingress slot and the post. The idle producer's sender then stayed open. The batcher inbox now closes on its last drain. A writer that finds it closed gets its record back and posts through a fresh batcher. A duplicate close is idempotent at the peer. A cancelled batcher also unregisters before its inbox closes, so a refused writer cannot resolve the same batcher again.

Tests that timed their preconditions were blind

Two batcher tests set up their state by timing and flaked on CI. velo_streaming_mux_batches_total{direction="sent"} counts batches offered, not landed. The increment happens before the send, so a test that sampled it raced the batcher's wake. The fixed tests observe the state that the code keeps: one waits for SendOutcome::Pending on the test transport, the other holds the batcher at a test barrier.

Mutation testing showed that both original tests were blind: under a mutation that broke the property under test, 0 of 30 runs failed. The fixed tests were red in 30 of 30. In one test, the current_thread runtime flavor is the detection mechanism: with the flavor reverted, the mutation went undetected.

Open hazards

These properties of the current code are known and not yet changed:

  • The sweep does not reclaim credit for a consumer that is gone. A consumer that is gone counts no drains, so its slot is closed instead. A consumer that ends its own stream, a watchdog firing and a cancel each close the slot and tell the sender. Every removal of a single-sender anchor goes through the entry's Drop, which closes the slot. An MPSC anchor closes each sender's slot when that sender's pump ends without a terminal, including when the anchor is cancelled while the pump waits on a full anchor channel.
  • The per-peer drain flag map never shrinks. A claimed slot's DrainSignal holds its peer's flag as an Arc for the life of its stream. Removing the map entry while such a stream lives leaves its consumer setting a flag that nothing reads. That peer's credit then falls back to the periodic sweep. Removal must happen under the same visibility that retires slots and binds.
  • A fence lift can queue behind opens. Under async_open_ack, the batcher polls the opens channel ahead of coalesced control. While opens for a peer are queued, the resolution that lifts a fence waits.
  • velo_streaming_mux_live_slots counts a slot whose producer left while it was fenced. Its registry entry survives until the admission resolves, which can be the rest of the epoch.
  • MPSC local Drop blocks. See A terminal send wedged the runtime.

RDMA design

This chapter records why the RDMA stack has its current shape. It covers the decision for UCX, the rejected alternatives, the invariants that the code depends on, and the faults found on the way. For how the path works, see Rendezvous and RDMA. For numbers, see RDMA performance.

The decision: vendored UCX, not raw ibverbs

Velo reaches RDMA through UCX, built from a vendored release tarball and linked statically by the ucx-rs crate. A pure-ibverbs messaging transport was designed in full and kept as the fallback (see The raw ibverbs design).

Round one of the research favored raw ibverbs. Two facts changed the result:

  • The requirement grew. Rendezvous needed an RDMA GET path. The messenger stages every payload above 256 KiB through rendezvous, so one RMA substrate serves both subsystems.
  • The UCX unknowns resolved in its favor. The vendored static build worked as a prototype. Two UCX copies in one process measured safe. The wrapper that velo owns priced out smaller than the like-for-like ibverbs scope.
AxisRaw ibverbsVendored UCX
New code, with testsabout 6,000 to 8,500 lines (messaging 4,000 to 6,000, plus RMA 1,800 to 2,700)about 4,900 lines (plus or minus 30%)
What velo ownsThe wire protocol: credits, SRQ sizing, RNR and timeout tuning, the QP state machine, a bootstrap listener, multi-NIC, GID, and pkey selection, restart racesA safe FFI wrapper: callback soundness, thread affinity, the vendored C build, protocol observability
CI with no RDMA hardwareSoftRoCE (rxe) on self-hosted runners, fidelity unprovenUCX_TLS=tcp on stock runners, the same code path, shown to work
Connection setupTCP bootstrap listener plus a per-connection QP exchangeucp_ep_create from the peer's address blob. No bootstrap subsystem.
Performance ceilingFull control, plain verbs onlyrc_mlx5 accelerated transports with no extra work, no WQE-level control
Flow controlVelo-owned credits, observableUCX-internal, under velo's gate and the mux: three layers, opaque

The UCX path costs less because the expensive half (worker, op state, admission, health, rendezvous protocol) is common to both paths. What UCX deletes is the wire-protocol half that ibverbs keeps.

The probe that decided it

UCX can drop RMA lanes under UCP_ERR_HANDLING_MODE_PEER, the mode that gives velo peer-death callbacks. Locally, PEER mode made self, posix, and sysv endpoints fail and silently dropped the rma_bw lane on cma. If RC lost rma_bw too, velo had to choose between peer-death detection and zero-copy RMA.

On a live ConnectX-7 InfiniBand host, with UCX 1.22.0 and 1.19.0, RC kept rma_bw in PEER mode. The mechanism is in UCX's lane selection. Under PEER mode with rendezvous, it adds UCT_MD_FLAG_INVALIDATE_RMA as a requirement. It then removes that requirement again for connect-to-endpoint lanes, because both sides close the connection on error. Every RC transport connects to an endpoint. The lane survived on rc_mlx5 with DEVX on, with UCX_IB_MLX5_DEVX=n, and on rc_verbs. A 1 MiB GET completed in 826 µs while the owner never ran its progress loop.

The invalidation requirement still applies to connect-to-interface transports. Shared-memory RMA inside one host under PEER mode therefore falls back to software emulation. For the same reason, the in-process UCX tests run over tcp: in PEER mode, the shared-memory lanes are not eligible.

Two more probe facts shaped the wrapper:

  • UCX_TLS=rc_mlx5 alone cannot wire up (no auxiliary transport ... Destination is unreachable). A ud transport must be in the list. In PEER mode, the ud lane does keepalive and wireup only.
  • Which exit ucp_am_send_nbx takes (see Completion-owned operations) changes between UCX versions. UCX 1.19 completes inline at sizes where 1.22 returns a request.

Rejected alternatives

Each entry below was tried or studied and failed. They are here so that nobody tries them again without new evidence.

NIXL for messaging

  • genNotif gives no ordering guarantee. That alone breaks velo's per-target ordered admission contract.
  • The Active Message header is removed (hdr = nullptr, and the receiver asserts header_length == 0).
  • Sends are pinned eager. There is no rendezvous.
  • A notification payload is copied about five times. The C shim passes clean spans, but the C++ API takes const std::string& and const std::vector<int>&, which force the copies. Only an upstream C++ change removes them.
  • Delivery is poll-only, under an exclusive lock, with no file descriptor or callback. The default Rust Agent::new() spins a progress thread at poll(fds, n, 0), one core at 100%.

Notifications arrived byte-exact up to 8 MiB, so NIXL works. It has the wrong shape for messaging. A claim that NIXL "validates every address" and needs an unsafe bypass was false. nixlMemSection::populate() resolves addresses to rkeys, which is necessary work. prepXferDlist() amortizes it once per buffer set. One idea was kept: loading a peer's opaque metadata blob creates endpoints eagerly, which matches address() and register(PeerInfo).

A NIXL-based rendezvous prototype

An earlier rendezvous prototype on NIXL had these faults. The current design avoids each one:

  • A 64 MiB arena that returned a hard error when full. Now, pool exhaustion falls back to chunked.
  • Three slot fields (a mode flag, an optional descriptor, an optional buffer) for one fact. Every combination was representable, and one was meaningful. Now, SlotBody is an enum and StageMode derives from it.
  • Pinned slots refused non-RDMA readers. Now, every pinned slot serves the chunked path.
  • velo_nixl::MemType on the wire. Now, no backend type crosses the wire. The descriptor carries a backend discriminator and opaque key bytes.
  • No lease deadline, so a consumer crash leaked the read lock and the pinned memory.

The two ideas kept from it: the owner decides the path at acquire time, and the two-process launcher became the rendezvous_rdma_two_proc example.

async-ucx

The published crate is unmaintained at 0.1.1. Its Drop calls ucp_request_free without ucp_request_cancel, a cancellation soundness hole that was never fixed. Its workers and endpoints are Rc-based and !Send. Velo's wrapper closes the hole by structure (see Completion-owned operations). From its unmerged modernization branch, three pieces were worth mining: the owned-Bytes worker address, the zeroed request-parameter builder, and the bindgen allowlist.

UCX's own rendezvous for large Active Messages

Letting UCX move large Active Messages with its internal rendezvous speeds up push messaging. It does not help pull-by-handle, it has no story for external memory registration, and it gives descriptor ownership back to user code across shutdown. Velo sets UCP_AM_SEND_FLAG_EAGER on every send, caps Active Messages at eager_max (1 MiB), and refuses a rendezvous-mode receive. Large payloads go through velo's rendezvous GET.

UCP_AM_FLAG_PERSISTENT_DATA does not prevent rendezvous receives, although ucp.h says so. ucp_am_rndv_process_rts sets FLAG_RNDV without a condition. An 8 MiB message arrived in rendezvous mode with PERSISTENT_DATA set, and in eager mode with the eager flag set.

UCX API traps

  • ucp_memh_pack without UCP_MEMH_PACK_FLAG_EXPORT calls ucs_fatal and aborts the process. The working packer is ucp_rkey_pack, marked deprecated, in ucp_compat.h. ucx-rs binds it and rkey_pack_canary asserts it packs.
  • ucp_init is a static inline in ucp.h and does not exist in the archives. Call ucp_init_version.
  • ucp_get_nbx and ucp_put_nbx need protocol v2. UCX_PROTO_ENABLE=n, a common workaround for protocol-v2 faults, silently disables all RMA.
  • A multi-rail GET needs UCX_MAX_RMA_RAILS of 2 or more. The default is 1.
  • Requesting UCP_FEATURE_RMA at ucp_init changes Active Message lane selection. Messaging behavior can move when RMA is compiled in.
  • A zero-padded copy of a packed rkey does not contain ucp_ep_rkey_unpack. Its second parse stage sets buffer_end = UINTPTR_MAX and stops only on a 0xFF byte, so zero padding feeds the walk. Containment must come from a pre-parse of the blob itself.

Other rejected choices

  • rdma_cm for connection setup (ibverbs design). It needs IPoIB or ibacm for address resolution on native InfiniBand, caps private data at 56 B, and brings its own event state machine. A 50-line exchange over the TCP infrastructure that velo already has is simpler.
  • Inactivity timers on memory registrations. A timer that evicts a registration while a peer holds its descriptor creates a stale key. UCX rcache, libfabric, MPICH, and NCCL all evict by byte or count ceiling. Timers apply only to empty arenas and to endpoints.
  • moka for a future registration cache. Since 0.12 it has no background threads, so time-to-idle eviction and eviction listeners fire only when something touches the cache. An idle process never evicts. quick_cache with a byte weigher and a pinned-while-in-flight lifecycle fits.
  • Per-slot registration by default. Registering arbitrary caller Bytes in place is unsound as a default. The provenance is unknown, UCX pins outward past what the caller owns, and from_static and sub-slices exist.
  • --disable-logging in the UCX build. It does not remove logging, it only lowers the maximum level. UCX_LOG_LEVEL=debug is the only field diagnostic for a half-initialized link.
  • A consumer-side unpacked-rkey cache, as an optimization. See A stale rkey aborts the process on InfiniBand.

The UCX transport

flowchart LR
    S1[send_message] --> G1[per-peer AdmissionGate]
    S2[send_message] --> G2[per-peer AdmissionGate]
    R[RdmaEndpoint map / unmap / get] --> Ring
    G1 --> Ring[(bounded flume ring)]
    G2 --> Ring
    Ring --> PT[UCX progress thread<br/>one ucp_worker, THREAD_MODE_SINGLE]
    PT -->|oneshot / flume| T[tokio tasks]
    PT <-->|ucp_am_send_nbx / ucp_get_nbx| NIC[(NIC)]
  • No listener. The WorkerAddress blob carries the packed ucp_worker address (about 200 to 250 bytes, 239 B measured) and the negotiation fields. register() stores it. ucp_ep_create runs at first use, or at register() with eager_endpoints. A restarted peer has a new address blob, so most restart races do not exist.
  • One progress thread owns the ucp_context and one worker in UCS_THREAD_MODE_SINGLE. UCX is built with --enable-mt and mt_workers_shared=1. In that build a SINGLE worker takes no lock: the lock macro reduces to a debug-only owner assert. Raw handles never leave the thread's main function. Stock UCX has --enable-mt off, and its MULTI mode is one recursive lock around the whole worker.
  • Per-peer admission gates feed one bounded ring. The single consumer cannot reorder what the gates admitted, so per-target ordering holds. RMA commands bypass the gates, because gate semantics (eager caps, drain rejection, SendOutcome) apply to Active Message frames. The bounded ring is the RMA backpressure.
  • Spin, then park. After the last activity, the loop spins for spin_us (20 µs) and then arms the wakeup fd and parks in poll(2). Submitters ring the doorbell only when the loop is parked. A ring push costs about 100 ns and ucp_worker_signal costs about 1 to 3 µs. The park has a bounded timeout as a backstop for a lost wakeup.
  • The maximum Active Message header measured 8101 B. The Transport trait cannot express this second cap, so the transport rejects an oversized header before the wire.

Completion-owned operations

Every posted operation owns its buffers until UCX completes it. One Arc<OpState> rides ucp_request_param_t.user_data. ucp_am_send_nbx has three exits, and exactly one of them drops the Arc:

  1. NULL: completed inline. UCX ignores the callback even if one is set, so the poster drops the Arc.
  2. A request pointer: the callback fires once, drops the Arc, and frees the request.
  3. An error pointer: no callback. The poster drops the Arc and reports.

Which exit a send takes is not monotonic in size. Over TCP: 64 B returned a request, 64 KiB and 1 MiB returned NULL, and 8 MiB returned a request. It also changes between UCX versions. All three exits are always live code. A dropped future only abandons the notification, and on_error still fires. Every extern "C" trampoline wraps its body in catch_unwind.

GETs post with UCP_OP_ATTR_FLAG_NO_IMM_CMPL, which removes the inline exit and leaves one completion path.

RMA invariants

GET, not PUT

ucp_get_nbx completion is final at both ends: the data has landed, and no flush is necessary. ucp_put_nbx completion frees only the local buffer. To tell the peer, the sender must first complete a ucp_ep_flush_nbx. Active Messages and RMA use different lanes with no ordering between them. Over UCX_TLS=tcp, a PUT flush also needs the peer to run its progress loop, which deadlocks shutdown paths. A put that is visible at completion over CMA is an accident of that transport, not a contract.

One-sidedness depends on the lane

With a native RMA lane, a GET completes with the owner idle (100 MiB in 19.9 ms over CMA, owner never progressed). Without one, UCX silently substitutes rma_am software emulation over the Active Message lane. The GET, the flush, and PUT visibility then hang forever on an owner that does not progress (measured: 1 s, 5.9 million polls, no completion). CI does not see this, because the owner that answered _rv_acquire is progressing. The owner's progress loop therefore stays live for the whole lease, and no separate unprogressed RMA worker exists.

Single-use rkeys

The packed rkey travels in the descriptor as plain bytes for each transfer. The consumer unpacks it on the progress thread just before the GET and destroys it in the completion callback. ucp_rkey_h never leaves the progress thread. No rkey cache and no invalidation protocol exist.

Destroying the rkey in the callback is safe, from the UCX 1.22 source:

  • ucp_rkey_destroy touches no endpoint. It releases each transport key through its component, a context-level object, and returns the descriptor to the worker's memory pool. What must outlive the rkey is the worker.
  • On the close path the endpoint is still alive. A FORCE close takes a discard reference on the endpoint, and the endpoint is freed only after the purge that drives the callbacks with UCS_ERR_CANCELED.
  • Inside ucp_worker_destroy, the purges run before the memory pools are destroyed.

Progress thread rules

  • No completion callback enqueues onto the ring. The progress thread is the only consumer of a bounded ring. A callback that blocks on a full ring deadlocks the process. RMA completions resolve a oneshot and hand follow-up work to the main loop through a side queue.
  • A parked unmap gates its region. An unmap of a region with GETs in flight parks its reply. From then on, new GETs on that region are refused. A repeat unmap attaches as another waiter. A caller that cancels and retries therefore never hears that a live, DMA-active region does not exist.
  • Every RMA reply resolves, including at teardown. ucp_worker_destroy does not run user callbacks for operations still outstanding when the bounded drain expires. The worker keeps an Arc of every posted operation, and teardown answers each survivor with ShuttingDown. The reply sits in a take-once slot, so a late callback cannot answer twice.
  • Offsets are relative to the mapped pointer. ucp_mem_map rounds the pinned range out to page boundaries. A GET destination offset is measured from the caller's pointer and checked against the caller's length. A caller cannot name a byte inside the registration but outside its allocation.
  • A cancelled map rolls back. The region id is minted before the command is pushed. A dropped map_region future pushes an unmap for that id, and the progress thread also rolls back when it finds the reply channel closed.
  • Endpoints close only after the ring is observed empty. Reply commands carry raw endpoint pointers. The parked-endpoint close, the failed-endpoint reaper, the revalidation pass, and the idle reaper all run after the post-progress drain sees an empty ring. An earlier order let a FORCE close free an endpoint that a queued reply still named.

What teardown cannot promise

Regions unmap before endpoints close for every region idle at teardown, and for every region whose GETs finish during the flush close. A GET posted to a peer that stopped progressing is the exception. The flush close never completes, and the later FORCE close is a no-op: ucp_ep_close_nbx returns UCS_ERR_NOT_CONNECTED because the flush close already set the closed flag. That GET completes only inside ucp_worker_destroy, after the forced unmap. Over TCP this is silent. On InfiniBand the straggler completes with an access error. The caller still gets an answer. The abandon_rma_ops path that covers this has no test.

A stale rkey aborts the process on InfiniBand

Over UCX_TLS=tcp, a well-formed but unusable rkey fails inside ucp_ep_rkey_unpack and returns an ordinary error. On InfiniBand, the unpack succeeds against a real local memory domain, the GET posts, and uct_rc_verbs turns the HCA completion error into ucs_fatal:

rc_verbs_impl.h:104  Fatal: receive completion[0] with error on mlx5_2/...: general error, vendor_err 0x0
(signal: 6, SIGABRT: process abort signal)

The expected cost of a stale rkey on RC was a dead queue pair and every transfer on it. The measured cost is a dead process, with no Rust error to fall back from. A TCP-only CI can never see this class.

Two things keep it out of reach, and the syntactic rkey pre-parse is not one of them:

  1. Single-use rkeys: an rkey cannot outlive its operation.
  2. Revalidation at every acquire: the owner checks the registration for each transfer.

The pre-parse checks framing. A stale rkey with perfect framing passes it. Consequences:

  • The shutdown straggler, the reaper force-release, and shutdown demotion all release memory under a peer that can still hold a descriptor. On InfiniBand that peer's GET can abort the peer's process, not only fail.
  • A consumer-side rkey cache removes both safety legs at once. It makes an rkey outlive its operation, and it skips revalidation. Arena reclamation is safe only because an empty arena has no live descriptor, and a cached rkey breaks that too. An rkey cache is a safety change, not a profile-gated optimization.
  • The mlx5 completion path differs from rc_verbs. This finding needs a rerun on the accelerated lane.

Symptom

On ConnectX-7, UCX_TLS=rc_mlx5,ud_mlx5 made UCX print:

UCX  WARN  transports 'rc_mlx5','ud_mlx5' are not available, please use one or more of:
ib, mm, posix, rc, rc_v, rc_verbs, self, shm, sm, sysv, tcp, ud, ud_v, ud_verbs

UCX then started with self only. It did not reach the peer, and every transfer fell back to chunked. The path_total metric showed it. Without that metric, the deployment looks like RDMA.

Evidence that ruled out the hardware

  • configure recorded HAVE_MLX5_DV 1, HAVE_DEVX 1, HAVE_MLX5_HW_UD 1, and HAVE_INFINIBAND_MLX5DV_H 1.
  • libuct_ib_mlx5.a was built and linked. uct_rc_mlx5_iface_tl_ops, uct_ud_mlx5_iface_tl_ops, uct_dc_mlx5_iface_tl_ops, and uct_mlx5_init were in the binary.
  • The system UCX 1.18 on the same node and NIC reported rc_mlx5, ud_mlx5, and dc_mlx5.

Root cause

uct_ib_init and uct_mlx5_init are ELF constructors. Both add their memory domains to the head of uct_ib_ops with ucs_list_add_head, so whichever runs later owns the head. uct_ib_component_md_open takes the first entry that opens, and uct_ib_verbs_md_open opens for any device unless DEVX is forced. It also continues past an entry only on UCS_ERR_UNSUPPORTED, so a verbs I/O error stops the open instead of falling through to mlx5.

For static= archives, constructor order is .init_array order, and that is the order in which build.rs emits cargo:rustc-link-lib=static=.... The build emitted uct_ib_mlx5 first. So uct_mlx5_init ran first, uct_ib_init ran second and put verbs at the head, and every mlx5 NIC opened a plain verbs domain. rc_mlx5, dc_mlx5, and ud_mlx5 found zero devices.

A shared UCX cannot hit this. uct_ib_init loads the mlx5 module with UCS_MODULE_FRAMEWORK_LOAD after verbs has registered.

The fix

build.rs emits static=uct_ib before static=uct_ib_mlx5. With four mlx5 devices at UCX_LOG_LEVEL=debug, md open by 'uct_ib_verbs_md_ops' became md open by 'uct_ib_mlx5_devx_md_ops'. This held under GNU ld 2.42 and under mold, the CI linker. The fix was measured on aarch64 with every port down. It proves which domain opens. It does not prove that rc_mlx5 carries traffic, and x86_64 is not measured.

crates/ucx-rs/tests/ctor_order.rs walks uct_ib_ops directly and fails if the verbs domain is at the head. It needs no binutils and no ELF parsing.

On a binary built before the fix, UCX_IB_MLX5_DEVX=y is a runtime workaround. It makes the verbs open refuse, so the DEVX domain opens.

What does not work

  • Repeating the archive (uct_ib, uct_ib_mlx5, uct_ib). static= bundles archive members into the rlib, and the final link carries no -l for them. A repeated archive collapses to its last position and reproduces the fault exactly. This advice is correct for a C linker and wrong for rustc.
  • +whole-archive or +verbatim. They change inclusion and name resolution, not member position.
  • "Static archives are never loaded by the module loader." Disproven. The archive was in the binary and the transports registered. The fault was which domain won the open.
  • "Version skew between the system UCX 1.18 and the vendored 1.22." Disproven. Swapping the archive order on the same 1.22 archives fixes it.

Building UCX from source inside cargo

The first recipe for the vendored build was wrong in four ways. Each was measured:

  1. --with-pic is required. Static-only libtool emits non-PIC objects. Linking them into any shared object fails on aarch64 with relocation R_AARCH64_ADR_PREL_PG_HI21 ... recompile with -fPIC. Python extension modules are shared objects. A Rust cdylib link against non-PIC archives sometimes succeeded and produced a broken library.
  2. The constructor set must be complete. In a static link, the linker pulls an archive member only if a symbol references it. Without ucs_init, UCX links, loads, and fails ucp_init with a bare UCS_ERR_INVALID_PARAM and no log output, because logging registers from that constructor. The full set is ucs_init, uct_init, ucp_global_init, uct_ib_init, uct_mlx5_init, uct_rdmacm_init, and optionally uct_cma_init. It mirrors the Libs.private markers in UCX's own pkg-config files.
  3. cargo:rustc-link-arg does not cross a crate boundary. A downstream cdylib linked with no -lucp and failed with undefined symbol: ucs_status_string. -Wl,--undefined= cannot live in a build script. ucx-rs references the constructors from a #[used] static in lib.rs instead, and emits archives with cargo:rustc-link-lib=static=. A crate that never names ucx_rs drops it and every native library from the link, so use ucx_rs as _; is required in that case.
  4. rustc's -nodefaultlibs and one-pass archive resolution leave two symbols unresolved. On aarch64, __aarch64_ldclr4_sync lives only in the static libgcc.a. On all targets, pthread_atfork lives in libc_nonshared.a. build.rs appends static=gcc (aarch64) and dylib=c after the UCX archives.

Other build facts:

  • The top-level make fails under --disable-shared, because it links the tools against shared libraries that do not exist. build.rs builds src/ucm, src/ucs, src/uct, and src/ucp one at a time.
  • The tarball SHA-256 is pinned and enforced before anything runs from it.
  • links = "ucx-rs", because lamellar-ucx-sys already owns links = "ucx" on crates.io. The name ucx-sys belongs to an unmaintained crate.
  • ucx-rs is a publishable leaf crate outside velo's type graph. It exports no types that velo or velo-ext share, so it cannot cause the duplicate-type semver fault that the two-crate rule prevents. See Versioning.
  • With UCX_DIR, the constructor references are compiled out. The transport modules of a shared UCX are plugins, and their init symbols are not in the core libraries.
  • Bindings are checked in. --all-features turns on the bindgen feature, but regeneration also needs UCX_RS_REGEN_BINDINGS=1, so CI never rewrites tracked files.

Symbols and a second UCX in one process

NIXL loads its own libucp in Dynamo, so two UCX copies in one process was the top operational risk. It measured safe:

  • A Rust cdylib that links static UCX exports zero UCX symbols with no extra flags. rustc emits a version script that makes all UCX symbols (about 3,700) local, and the constructors still run. A second version script from the consumer is a link error: anonymous version tag cannot be combined with other version tags.
  • Eight of eight coexistence cells passed: a static 1.22 cdylib and a dlopened system 1.20, both load orders, RTLD_LOCAL and RTLD_GLOBAL, UCM hooks on and off. Both ucp_init calls succeeded, with no cross-binding and no crash.
  • With UCX_MEM_EVENTS=n and UCX_RCACHE_ENABLE=n, UCX patches zero libc functions. This was verified by comparing libc entry-point bytes, not by reading logs. Velo sets both by default.
  • The claim "hooks off means a clean dlclose unload" did not hold. UCM pins itself with RTLD_NODELETE in a --with-pic build. Velo never unloads UCX, so this does not matter to velo.

The raw ibverbs design

This design is the fallback if UCX hits a wall that velo cannot work around. It is also the reference for what UCX must beat. Only its core is recorded here.

Shape. An in-tree ibverbs transport on the jonhoo/rust-ibverbs binding (MIT or Apache-2.0, maintained, with extended-verbs batching and completion-channel file descriptors). RC queue pairs, one shared receive queue (SRQ) per device context, and per-peer credits. Eager messages only. No velo-ext change.

Connection setup. The WorkerAddress blob carries a small TCP bootstrap endpoint, device hints, and an incarnation number. register() stores the peer and connects nothing. The first send dials the bootstrap listener and exchanges {qpn, psn, gid, gid_index, mtu, credit window, eager ceiling, incarnation} once in each direction. A static address blob cannot carry RC state, because the QPN exists only after ibv_create_qp for each connection. NCCL and UCX also publish a listener and exchange this data per connection.

Wire. One RC SEND per message. The scatter list is an 8-byte fixed header (version, message type, flags, credits granted, header length), then the user header, then the payload. Messages up to about 828 B post inline, so the buffer is free on return. Medium messages copy into a registered slab. RC carries up to 1 GiB in one message on CX-7, so no fragmentation exists.

Flow control. A credit is one SRQ slab that the peer can consume. The window is 64 by default. Credits return in the header byte, or in a pure-grant message when the receiver is idle. With credits, RNR cannot occur in steady state.

Completions. One completion queue per worker, its completion channel in tokio::io::unix::AsyncFd. Arm first with ibv_req_notify_cq, then drain, then wait. This order closes the arm race.

RMA additions. A second RC queue pair per peer for bulk reads. RC processes work requests in order, so bulk reads on a shared queue pair block Active Messages. Also, one remote access error takes the whole queue pair to ERROR. The bootstrap struct must reserve the second QPN from the start, because a later addition breaks the wire format. max_rd_atomic must be set: the binding defaults it to 1, which serializes reads, and CX-7 allows 16. Estimated 1,800 to 2,700 extra lines.

Seven constraints from the research

  1. Never poll payload memory for arrival. Last-byte polling breaks InfiniBand spec rule o9-20, relaxed ordering, adaptive routing, and out-of-order placement. Arrival signals are SEND and RECV completions, or RDMA_WRITE_WITH_IMM.
  2. RNR is not backpressure. min_rnr_timer = 0 encodes 655.36 ms, and rnr_retry = 7 retries forever. A stalled receiver wedges the sender with no error. Credits make RNR rare. min_rnr_timer of 1 to 6 (10 to 80 µs) makes the rare race cheap.
  3. Verbs completions are not a liveness signal. CX-5 and later firmware enforce a minimum ack timeout of 16 (about 268 ms per try). With retry_cnt = 7, a dead peer shows after 3 to 4 s. Health checks must be application-level.
  4. A static address blob cannot carry RC connection state (see above).
  5. SRQ size is fixed at creation on CX-7. SRQ_RESIZE is absent. max_srq_wr is 32,767. The limit event works.
  6. The inline limit on CX-7 is 64·k − 4 bytes: at most 828 B for RC and 956 B for UD. Requests of 912 B or more fail with EINVAL. Inline cut latency 35% at 64 B (2.95 to 1.92 µs) and did nothing at 512 B.
  7. Platform tuning is part of the transport. On aarch64 with default idle states, event-mode p50 at low rates rose from about 10 µs to 88 to 97 µs. A completion IRQ on a busy core put p99.9 at 726 to 1082 µs, against 11 to 15 µs with the IRQ isolated.

Measurements behind it

Measured on 2026-08-20. "Spark" is an aarch64 DGX-Spark-class host with four ConnectX-7 RoCE ports, all link-down, rdma-core 50, Linux 6.14: verbs objects are real, but no wire traffic occurred. "B200" is an x86_64 DGX B200 node, ConnectX-7 InfiniBand, MLNX OFED 25.07, over a two-HCA path inside the node (mlx5_0:1 to mlx5_2:1, MTU 512). The B200 path hit an unexplained ceiling near 11.6 Gb/s, so rows of 8 KiB and more are lower bounds.

Completion delivery on Spark, 100,000 messages per second, idle states limited and the IRQ isolated:

Strategyp50p99Cores
Busy poll2.06 µs2.64 µs1.000
Raw epoll8.02 µs8.96 µs0.409
tokio AsyncFd8.16 µs9.10 µs0.463
Spin 5 µs, then park7.07 µs9.07 µs0.672

tokio adds 0.14 µs over raw epoll. The interrupt path is the cost. A shared busy-poll core delivers about 5.4 µs to workers, not 2 µs, because the dispatch hop adds 3.34 µs.

ib_send_lat, RC one-way, B200, depth 1:

SizeNo inlineInline (828 B cap)
64 B2.95 µs1.92 µs
512 B3.61 µs3.62 µs
1 KiB3.95 µsnot applicable
8 KiB9.99 µsnot applicable
64 KiB52.5 µsnot applicable
1 MiB785 µsnot applicable

Registration against memcpy, B200, median of 101, memory touched first:

SizeRegister, 4 KiB pagesRegister, 2 MiB THPDeregister, THPmemcpy
4 KiB13.7 µs13.7 µs12.4 µs0.05 µs
256 KiB18.7 µs14.5 µs12.4 µs4.6 µs
1 MiB36.3 µs18.7 µs12.5 µs27.5 to 35.8 µs
8 MiB250.9 µs36.5 µs12.9 µs486 to 494 µs

Copying into a pre-registered pool beats register-then-transfer below about 0.5 MiB for a cold source and about 2 MiB for a cache-resident source. The memcpy cache cliff (76 to 17 GB/s) sets that crossover, not the registration cost. With 4 KiB pages, deregistration grows to 561 µs at 64 MiB, so pools use huge pages. UCX's cost model (16 µs plus 0.06 ns per byte) is about 2x pessimistic on this hardware.

Other measured facts: one queue pair costs 8.2 KB of host memory at depth 4 or less, and 16.2 KB at depth 16 (12.2 KB with an SRQ). Implicit on-demand paging (one key for the whole address space) works on CX-7 with an 11 ms one-time cost. It is a capability probe only, never the default.

Operational traps found during the probes: perftest -I prints the requested inline size, not the granted one. A single-host UCX benchmark silently selects shared memory and reports impossible numbers. A partial pkey membership breaks UCX while raw verbs works.

TCP faults found on the way

The baseline work for this design measured velo's existing transports and found two TCP faults. Both are fixed. A harness, examples/examples/tx_budget.rs, adds one layer per rung and attributes the cost of each. It found the faults and now guards against them. It asserts nothing, so a regression shows only when someone runs it.

Socket buffers sized after data was in flight

Symptom. At 16 KiB and more with 64 messages in flight, TCP throughput collapsed by 100 to 350 times. At 256 KiB x 64, TCP moved 22.9 MB/s against 2,648 MB/s for the same code over UDS. The p50 round trip was 1.45 s.

Mechanism. Velo's messenger connections are one-directional. The dialing peer writes as soon as connect() returns. The accepting side set SO_RCVBUF and SO_SNDBUF one task spawn later, after the first burst was in flight. On Linux, setting SO_RCVBUF then locks the buffer, turns off receive autotuning, and clamps it to net.core.rmem_max. With the common rmem_max of 212,992, the 2 MiB request becomes about 416 KiB. By then the window has already grown past that size, and the kernel collapses the advertised window for the life of the connection. ss showed snd_wnd:32640 with rwnd_limited:100.0%. The fault was per connection and racy, so results were bimodal across runs.

Correction to the first analysis. The first analysis applied the same options to an idle socket, saw an 18% cost, and called the buffer hypothesis refuted. That test removed the race, and the race is the mechanism.

Fix. The listening socket sets the buffer sizes, and accepted sockets inherit them at handshake time. The dialing side still sets its own before the first write, which cannot race. The streaming TCP transport had the same fault and the same fix. On aarch64 loopback, 256 KiB x 64 went from 8.8 to 27.9 MB/s (8 of 8 runs collapsed) to 2,470 to 4,173 MB/s (8 of 8 healthy). For request and reply, inherited sizes are also faster than no sizes at all, because autotuning starts cold. For one-way bulk traffic across nodes, autotuning is faster, and the TCP messenger transport has an option for it. See Transports.

Three writes per large frame

Above the 64 KiB coalesce threshold, the direct write path called write_all three times per frame (an 11 B preamble, the header, the payload) on a TCP_NODELAY socket. This added 10.2 µs one-way at 256 KiB. The preamble and header now go into a 256 B stack buffer and leave in one write, so a frame takes two writes. writev was rejected, because TCP write_vectored() can short-write past about 128 KiB. The decoder also reserves the whole announced frame after it parses the preamble. Before, Framed doubled its buffer from 8 KiB and copied everything received at each step. With both changes, the codec rung at 8 MiB went from 3.73 ms to 2.94 ms, and strace -c over 1,100 frames showed sendto fall from 6,644 to 4,476.

Where the per-message time goes

On aarch64 loopback, 64 B header plus 1 KB payload, depth 1, a four-worker tokio runtime on performance cores:

RungLayersOne-way p50
L0Blocking socket calls, OS threads5,996 ns
L1Plus tokio5,652 ns
L2Plus TcpFrameCodec5,668 ns
L3Full Transport and DataStreams11,368 to 11,599 ns

The codec adds 16 ns, and AdmissionGate::send adds 16 ns over a raw flume::try_send. Of the 5,700 ns the full transport adds, 84% is two cross-thread wakes of a parked task (2,393 ns each, against 216 ns on a pinned current_thread runtime). About 3.7 µs of that is removable with no RDMA, by removing cross-core wakes. UDS at L3 is 5,235 ns. So inside one node, an RDMA messaging transport gains little over UDS. Between nodes, it gains 4 to 11 times: about 2 µs of wire plus 2.9 µs of software, against 15 to 50 µs of TCP wire plus 5.7 µs.

Open questions

  • Three layers of flow control. The mux budgets, the admission gate, and UCX's internal window and arbiter all sit on one path. UCX's layer is not observable. Nobody has analyzed whether they deadlock or double-buffer. This is the largest open design question on the chosen path.
  • Fabric prerequisites. RoCEv2 without PFC or ECN degrades under congestion through go-back-N. Kubernetes memlock budgets, device plugins, and the GID index inside a network namespace are not researched.
  • Zero-copy receive. It depends on an audit that no downstream path turns inbound Bytes into BytesMut, which copies owner-backed bytes.
  • libfabric, UCCL, and Mooncake were not scored.
  • The accelerated lane. The threshold, the packed rkey size, the stale-rkey abort, and the abandon_rma_ops path all need a run on rc_mlx5.

Glossary

TermMeaning
Active messageA message that names a handler on the remote instance. The handler runs when the message arrives.
AdmissionThe step where a transport puts an inbound request on the queue with TransportAdapter::admit_message. Admission takes the in-flight guard.
Admission gateThe queue for one target (and lane) that holds sends while the bounded send channel is full
AnchorThe consumer end of a stream. It has a u128 handle that a producer uses to attach.
AttachThe _anchor_attach round trip that connects a producer to an anchor
BatchOne messenger frame that carries records from many streams to one peer
Coalescing writerThe TCP, UDS, and QUIC writer task. It writes small queued frames together, and a large frame as two writes.
CreditThe number of records that a sender can send on a slot before the receiver grants more
DrainThe second phase of graceful shutdown. Velo waits until no admitted request is in flight.
Frame transportA transport for stream frames when the mux is not used (TCP or gRPC)
GateThe first phase of graceful shutdown. New inbound requests are refused.
InstanceIdThe identity of one Velo instance
MuxBatched streaming. Records from many streams share messenger frames to the same peer.
Ordered laneFor an ordered handler, the queue and task for one sender (or for all senders with ordered_global). Not the same as a transport lane.
Pre-bindprebind_anchor. The consumer makes a ticket, so the producer can open the stream with no attach round trip.
Primary transportThe compatible transport with the highest priority for a peer
RendezvousTransfer of a large payload by handle. The owner stages the bytes, and the peer pulls them.
RDMA GETA one-sided read by the NIC of the peer from registered memory on the owner
SlotThe identity of one stream inside the mux, with its own order and credit
TeardownThe third phase of graceful shutdown. Velo cancels tokens and stops the transports.
CloseThe fourth phase of graceful shutdown. Velo waits for Transport::closed() on each transport, so that what it wrote reaches the peer.
TicketA StreamOpenTicket. It carries the terms of a pre-bound stream.
Transport laneOne ordered channel from a transport to a peer. Order holds within a lane only. QUIC and TCP can keep several lanes to a peer, each on its own connection. Not the same as an ordered lane.
TransportKeyThe name of a transport in a WorkerAddress, for example tcp
WorkerAddressA MessagePack map from TransportKey to endpoint bytes
WorkerIdThe identity of a worker. A StreamAnchorHandle encodes it.

Metrics reference

This page lists every Prometheus metric family that VeloMetrics registers. The description column is the help text from lib/velo/src/observability.rs. A histogram exposes _bucket, _sum, and _count series. For how to read the families together, see Observability. For the batched streaming families, see Stream saturation.

Transport

MetricTypeLabelsDescription
velo_transport_frames_totalcountertransport, direction, message_type, outcomeTransport frames observed by Velo.
velo_transport_frame_bytes_totalcountertransport, direction, message_typeLogical frame bytes observed by Velo transports.
velo_transport_frames_written_totalcountertransport, message_typeFrames a transport's per-connection writer handed to its send buffer (the kernel socket buffer for TCP and UDS, quinn's user-space buffer for QUIC), counted once the write returned. Subtract this from velo_transport_frames_total{direction="outbound",outcome="accepted"} on the same transport to get that transport's egress queue depth. Published only by the coalescing writer of the TCP, UDS and QUIC transports — the transport label is whatever TransportKey the transport was built with, not a fixed name, so select on this series' presence rather than on a transport-name pattern; see the book's Observability chapter for the identity's limits.
velo_transport_egress_queue_wait_secondshistogramtransportTime one outbound frame spent between the transport's send attempt and its connection's writer taking it off the send queue. Stamped before admission, so it covers the admission gate's pending queue as well as the bounded per-connection channel behind it. Observed once per frame, by the writer, at the dequeue — including a frame whose write then failed, so this count is at least velo_transport_frames_written_total and equals it only on a connection that never faulted. Published only by the coalescing writer of the TCP, UDS and QUIC transports — select on this series' presence, not on the transport label's value.
velo_transport_write_duration_secondshistogramtransportWall time of one write on a connection's writer. One observation per write, and a write carries as many frames as the writer had queued — so this count is at most velo_transport_frames_written_total and equals it only when nothing ever coalesced. Large values mean the socket's send buffer is full and the wire or the receiver is the constraint; small values beside a large velo_transport_egress_queue_wait_seconds mean the writer is starved or the queue is simply long. Published only by the coalescing writer of the TCP, UDS and QUIC transports — select on this series' presence, not on the transport label's value.
velo_transport_rejections_totalcountertransport, reasonTransport-level rejections and drops observed by Velo.
velo_transport_send_backpressure_totalcountertransportSends that found the bounded channel for a target (and lane) full and were queued in its admission gate.
velo_transport_registered_peersgaugetransportRegistered peer count per transport.
velo_transport_active_connectionsgaugetransportActive connection count per transport.

Messenger

MetricTypeLabelsDescription
velo_messenger_handler_requests_totalcounterhandler, response_type, outcomeMessenger handler requests observed by Velo.
velo_messenger_handler_duration_secondshistogramhandler, response_type, outcomeMessenger handler execution time.
velo_messenger_handler_request_bytes_totalcounterhandler, response_typeMessenger handler request payload bytes.
velo_messenger_handler_response_bytes_totalcounterhandler, response_type, outcomeMessenger handler response payload bytes.
velo_messenger_handler_in_flightgaugehandlerMessenger handlers currently executing.
velo_messenger_ordered_lanesgaugehandlerLive ordering lanes held by ordered-dispatch handlers.
velo_messenger_ordered_lane_depthgaugehandlerMessages enqueued on ordering lanes but not yet handled.
velo_messenger_ordered_lane_wait_secondshistogramhandlerTime a message waited on an ordering lane before its handler started.
velo_messenger_ordered_lanes_created_totalcounterhandlerOrdering lanes created. Subtracting the live lane gauge yields lanes reaped.
velo_messenger_dispatch_failures_totalcounterstage, reasonMessenger dispatch and response path failures.
velo_messenger_client_resolution_totalcounterpath, outcomeMessenger client routing path outcomes.
velo_messenger_pending_responsesgauge—Pending messenger response awaiters.
velo_messenger_response_slot_exhausted_totalcounter—Response slot acquisitions that hit the per-worker capacity ceiling.
velo_messenger_inbound_dequeued_totalcounter—Messages the dispatch loop took off the messenger's inbound queue. Subtract this from velo_transport_frames_total{direction="inbound",message_type="message",outcome="accepted"} to get that queue's depth — there is deliberately no gauge for it, because a sampled channel length reads the wrong number under exactly the load that makes the depth interesting. Sum the frame counter across transports before subtracting: it is per transport and this one is per instance. The identity holds while the instance is live — messages abandoned when a Timeout shutdown tears the dispatch loop down are never counted here, so from teardown onward the derived depth reads high by the abandoned count.

Streaming

MetricTypeLabelsDescription
velo_streaming_anchor_operations_totalcounteroperation, outcome, transport_schemeStreaming control-plane operations observed by Velo.
velo_streaming_anchor_operation_duration_secondshistogramoperation, outcome, transport_schemeStreaming control-plane operation duration.
velo_streaming_anchor_attach_rtt_secondshistogramoutcome, transport_schemeWall time a remote anchor attach spent in flight, measured by the sender across the round trip to either the _anchor_attach or _mpsc_anchor_attach handler — the two attach kinds share this one series, so a subtraction against it is a population average, not a per-kind reading. Its excess over velo_streaming_anchor_operation_duration_seconds{operation="attach"} on the receiver, which both handlers record into as well, bounds that receiver's ingest queueing from above; the sender's own send path, both wire legs, the receiver's handler-spawn delay and the sender task's wake latency are inside the bracket too. The two are not directly comparable on their labels: this series' transport_scheme is the sender's own advertised-vs-answered view, which folds to "unknown" on an ordinary mixed-deployment negotiation rather than naming the key the receiver actually used, and the two histograms live in different registries on different nodes, so a comparison must sum both labels away rather than match on them, and covers only the outcome="success" population.
velo_streaming_active_anchorsgauge—Anchors currently present in the streaming registry.
velo_streaming_backpressure_totalcountertransport_schemeStreaming backpressure events observed by Velo.
velo_streaming_reader_pump_backpressure_totalcounter—Reader-pump forwards that hit the per-anchor frame channel's Full branch and fell through to send_async. Leading indicator of consumer-side saturation: this channel (bounded(256)) is the first to fill in the saturation cascade. Per-stream transports only: a messenger-mux stream has no reader pump (its consumer reads the slot buffer directly), so this never moves for it; watch velo_streaming_slot_credit_exhausted_total there, which the producer's node counts.
velo_streaming_server_pump_backpressure_totalcounter—Server-side stream pump forwards that hit the bind-side frame channel's Full branch (bounded(4096)) and fell through to send_async. Ticks once the per-anchor channel is saturated and the transport-level channel begins to fill.
velo_streaming_producer_send_backpressure_totalcounter—StreamSender::send calls that found the connect-side channel full and waited in send_async. The channel is 4096 deep on the per-stream path and C+1 deep under the mux, where it fills when the slot reaches its byte cap or the batcher waits on admission.
velo_streaming_heartbeat_watchdog_firings_totalcounter—Heartbeat-watchdog firings: a session went DETECTION_MULTIPLIER × heartbeat_deadline with no data or heartbeat frames and was force-cleaned with a Dropped sentinel. Fired by the reader pump on a per-stream transport, where it is typically the lagging indicator of the cascade surfaced by the *_backpressure_total counters above. Fired by the stream watchdog on a single-sender messenger-mux stream, and only when nothing was delivered to the slot while its sender held data credit or had records waiting behind a sequence gap, so there it means silence upstream of the consumer (a dead or stalled producer, or a backlog on its egress or the peer link), never a consumer that fell behind. An MPSC anchor's reader pump, on any transport, does not record this counter.
velo_streaming_unclaimed_bind_reaped_totalcounter—Reap of a mux bind, by its stream watchdog or its consumer (a zero-RTT pre-bind, an ordinary attach whose peer never sent its OpenSlot, or an attach that adopted a pre-bind) whose accept window closed with no sender having claimed it. Distinct from the heartbeat-watchdog firing above: this is the accept window catching what the watchdog either cannot see yet (no sender exists) or would catch too late (the watchdog's threshold exceeds the accept window's remaining span, at heartbeat_interval >= 20s, give or take one credit_sweep_interval, since the accept window closes on the sweep tick).
velo_streaming_egress_flushes_totalcounter—Batches handed to the socket by the per-stream egress pump. Divide velo_streaming_frames_written_total by this for the coalescing ratio: 1.0 means every frame went out on its own, higher means frames are being packed together. This counts batches, not syscalls or TCP segments — write_all may loop over several underlying writes, a frame too large to pack is written segmented and still counts as one, and segmentation is the kernel's decision. Only batches that reached the wire are counted.
velo_streaming_frames_written_totalcounter—Logical stream frames written to the wire. See velo_streaming_egress_flushes_total for the coalescing ratio.
velo_streaming_slot_credit_exhausted_totalcounter—Times a slot ran out of data credit and its egress parked. Per-slot starvation, not peer-wide: other slots on the same batcher keep flowing, which is the property multiplexing has to buy back after giving up one socket per stream.

Batched streaming (mux)

MetricTypeLabelsDescription
velo_streaming_mux_live_slotsgauge—Slots the messenger mux currently holds state for, counting both sides: an egress slot allocated by connect() and an ingress slot created by an OpenSlot record each add one. Must return to zero once every stream has torn down — a leaked slot leaks credit and byte budget for the life of the epoch, and unlike a leaked socket it is invisible to lsof.
velo_streaming_mux_reader_stall_totalcounter—Records the applier could not try_send into a slot buffer that credit had already reserved space in. Should always be zero: a slot never has more than C frames outstanding against a C+1-deep buffer, so a non-zero value means the credit invariant is broken. A bug, not a tuning signal.
velo_streaming_mux_generation_mismatch_totalcounter—Records dropped because the slot index was live but at a different generation. This is dense slot reuse being caught: without the generation tag these records would have been delivered into whichever stream now occupies the index.
velo_streaming_mux_records_dropped_totalcounterreasonMux records discarded before delivery, by reason. stale_epoch and generation are the protocol working as designed across a reconnect or a slot reuse; unknown_slot and closed_slot mean a sender is talking about a stream this node does not have; lane_mismatch counts a batch whose header lane is not the lane of the handler it arrived on, which is a sender fault; slot_collision counts an OpenSlot at an index whose slot has not been closed, which is a sender fault too, or one whose slot id came round while the old slot with that id still waits for a rendezvous record.
velo_streaming_mux_batches_totalcounterdirection_stream_batch active messages packed (sent) or decoded (received) by the mux.
velo_streaming_mux_records_per_batchhistogramdirectionRecords carried by one _stream_batch, by direction. This is the multiplexing win expressed directly: 1.0 means the mux is paying protocol overhead for nothing. Every node both sends and receives batches — credit rides back on them — so an unlabelled sum here would mix a node's packing with its peers'.
velo_streaming_mux_rendezvous_singletons_totalcounter—Records larger than the effective eager budget, sent alone in their batch so the messenger stages them through rendezvous. Each one pays a round trip and fences its own slot until the admission resolves; a rising rate means frames are outgrowing the target's eager budget.
velo_streaming_mux_held_recordsgauge—Records sitting in per-slot ahead-of-sequence holds, waiting for a frame_seq gap to close. Non-zero only while a rendezvous singleton is outstanding, since that is the one path that can overtake the ordered lane.
velo_streaming_mux_withheld_recordsgauge—Records the egress batcher has pulled from producer inlets but may not send yet — a slot out of credit, or fencing a singleton (a rendezvous transfer, or an OpenSlot under MuxConfig::async_open_ack) whose admission has not resolved. Bounded per slot by the slot byte cap: at the cap the slot stops pulling from its inlet, and the producer's send waits.
velo_streaming_mux_staged_recordsgauge—Records packed into batches the egress batchers have open but have not written. Transient under FlushPolicy::Auto with on_admission set, where every wake that stages anything besides credit replies ends in a write and a batch holding only credit replies is held for up to MuxConfig::reply_linger. Under FlushPolicy::Manual, or Auto { on_admission: false, max_linger: None } — the two policies with no timer of their own — a pending credit reply still holds the whole batch it is in for that same bound, data included, so a plateau beyond it is a producer that stopped calling flush_batch (under Auto { max_linger: Some(_) } instead, the same plateau means that window's own timer stalled). Bounded by the batch clamps, so it costs latency rather than memory.
velo_streaming_mux_control_refused_totalcounter—Coalesced control entries a peer batcher refused, for either of two reasons: a grant or close named a slot index this side never allocated, or a burst of OpenSlots this side never admitted filled the bounded lane that carries their rejections. Either way the peer's own accounting is not at risk: a real held-slot credit or close reply is never refused. Not counted here: an ordinary stale-generation race, where a grant or close names an index this side allocated but at a generation that is not the one currently live there — that is dropped silently rather than signalled, since it is expected traffic rather than a hostile peer.
velo_streaming_mux_hold_overflow_totalcounter—Slots closed because their ahead-of-sequence hold exceeded the credit or byte budget backing it. Scoped to the one slot: the peer's ordering lane and its other slots are untouched.
velo_streaming_mux_epoch_deaths_totalcounter—Peer epochs that died, each failing every live slot beneath it exactly once. Driven by a batch send that was never admitted, which is the only ordered per-target congestion and liveness signal the mux has.
velo_streaming_mux_credit_reposted_totalcounter—Ingress credit that was minted into a batch the epoch then threw away, and handed back to the control state so a later batch re-advertises it. take_pending_grant zeroes the slot's ungranted at mint time, so without this the sender's window would shrink by the delta for the life of the slot.
velo_streaming_mux_credit_lost_totalcounter—Ingress credit from a discarded batch that reached neither the batcher's own control state nor the batcher that took the peer over. The sender's window for that slot is short by this much until the slot closes. Expected to stay at zero.
velo_streaming_mux_batch_seq_gaps_totalcounter—Batches missing between the expected and received batch_seq within one epoch. Reported and moved past — the mux does not retransmit — so this is the count of batches whose records the per-slot frame_seq machinery had to recover from. A batch that arrives after its successor was already counted as missing is not counted again: the meter keeps a high-water mark, so a reordered pair costs one, never a wrapped u32.
velo_streaming_mux_drain_visits_totalcounter—Credit reconciles the sweep task ran on one (peer, lane) because a consumer drained, counted once per walk actually performed. Wakes deferred by MuxConfig::drain_visit_floor are not counted until the walk they coalesced into runs, and the periodic sweep's own walks are not counted at all, so this divided by elapsed time is the doorbell's real visit rate and is bounded above by 1/drain_visit_floor per (peer, lane).
velo_streaming_mux_records_sent_totalcounterrecord_typeMux records a batcher packed for its peer, by record type — this series only ever describes what a node sends, so it carries no direction label of its own. Compared with velo_streaming_mux_batches_total{direction="sent"} it says what those outbound batches are made of, which is the question a batch count alone cannot answer: a rise in small batches is either data arriving one record at a time or control flushed as it comes.
velo_streaming_mux_batcher_wakes_totalcountersourceWakes of the per-peer batcher tasks, by what woke them: a producer's open, coalesced control, a queued record, a departed producer, or the linger timer. This attributes what woke the task, not what it wrote: one wake can still write more than one batch (a size clamp or an oversized record flushes inline), and a batch held open only by MuxConfig::reply_linger writes on the first end-of-wake check after the window elapses — a linger wake only when the batcher was otherwise idle at the deadline, a control or frame wake when one arrives first (measured 21-30% linger on the tier-3 frontend).

Rendezvous and RDMA

MetricTypeLabelsDescription
velo_rendezvous_operations_totalcounteroperation, outcomeRendezvous operations observed by Velo.
velo_rendezvous_operation_duration_secondshistogramoperation, outcomeDuration of rendezvous operations.
velo_rendezvous_bytes_totalcounteroperationTotal bytes transferred via rendezvous.
velo_rendezvous_active_slotsgauge—Number of data slots currently staged in the rendezvous registry.
velo_rdma_registered_bytesgauge—Bytes currently registered for RDMA (arena pool plus external regions).
velo_rdma_registrations_totalcounterkindRDMA memory registrations performed by Velo.
velo_rdma_live_regionsgauge—Memory regions the RDMA backend currently holds registered.
velo_rendezvous_rdma_path_totalcounterpath, reasonRendezvous path decisions, by the path chosen and the reason. Owner and consumer both record, so one transfer can count more than once.
velo_rendezvous_rdma_get_duration_secondshistogram—Duration of the RDMA GET inside a rendezvous get, excluding the acquire round trip.
velo_rendezvous_rdma_leases_reaped_totalcounter—RDMA leases force-released by the owner-side reaper after their deadline passed.

Known limitations

What velo does not do yet, stated so that nobody has to find out from a test. Each section says what happens, when, why, what to do about it, and what would lift it. The pages of the book that touch a limitation say it in one line and link here, so the two do not drift.

Sent while a staged item is still held

What happens. StreamSender::retired() resolves Retirement::Sent while the producer's node still holds an item of the stream. A graceful shutdown right after Sent can cut that item, and the consumer reads a broken stream instead of it.

When. Over the messenger mux, for an item larger than the eager budget: the smaller of the transport's frame limit and the stager's threshold (RendezvousStager::with_threshold, 256 KiB by default), less the message envelope.

Why. Such an item leaves the producer as a handle. Its bytes stay staged in the producer's rendezvous store until the consumer pulls them. Sent is reported when the batch that carries the handle is admitted by the transport, and the drain of graceful_shutdown counts requests in flight, not staged payloads, so neither waits for the pull.

Workaround. Keep stream items under the eager budget, or wait for the consumer's own acknowledgement before shutting down.

Lifted by. Not scheduled. The test sent_resolves_while_a_staged_item_is_still_held in lib/velo/tests/streaming/retired.rs pins the behaviour; a fix changes what that test asserts.

QUIC discards queued frames at teardown

What happens. A frame the QUIC transport admitted but had not yet written when the transport tears down is reported as failed. A stream whose Sent came just before the shutdown can lose its tail over QUIC.

When. At the teardown step of graceful_shutdown, over the quic transport.

Why. TCP and UDS keep writing admitted frames for the teardown grace. QUIC keeps written data in user space until the peer acknowledges it, and its close waits for that acknowledgement, but it does not first write what is still queued.

Workaround. Use TCP or UDS where the tail of a stream must survive a shutdown that follows Sent.

Lifted by. Not scheduled.

A hand-built AnchorManager reports a per-stream Sent at enqueue

What happens. Over the per-stream TCP and gRPC transports of this crate, Sent means that the stream's pump wrote the terminal (TCP) or that the server took the whole stream (gRPC), but only when the Velo builder installed the transport. An AnchorManager built by hand, with AnchorManagerBuilder or AnchorManager::new, reports Sent as the terminal enters the egress channel, while the pump still has to write it. The same holds for a frame transport from outside velo.

When. A producer that builds its AnchorManager itself, or a FrameTransport implementation from another crate.

Why. FrameTransport is published in velo-ext, so the retirement does not go through it. The Velo builder names the in-crate transports to the anchor manager by key; nothing else can.

Workaround. Build the node with Velo::builder().

Lifted by. #142: a defaulted method on FrameTransport at the next velo-ext release, which also deletes the second map.

Per-stream TCP cannot see a consumer that dropped its anchor

What happens. Over per-stream TCP, a consumer that dropped its anchor before the producer's last write still leaves the producer with Retirement::Sent. Over the mux and on the same worker the same case gives Discarded.

When. A stream that rides per-stream TCP (the mux off, or a peer without it), and a consumer that leaves while the producer still writes.

Why. The consumer's pump learns of the drop only from the next frame it tries to forward, so the producer's write succeeds, and a successful write of the terminal is what Sent means on this path. Telling the producer earlier would take a message on the wire.

Workaround. Watch StreamSender::cancellation_token(), which the consumer's drop fires, when the difference matters.

Lifted by. Not scheduled.

A gRPC server from before velo 0.21 answers Sent for frames nobody took

What happens. Over per-stream gRPC, a producer on this velo reads Retirement::Sent although the consumer never got the stream.

When. The consumer's node runs a velo from before 0.21, and its consumer left before the stream ended, or the server could not receive a frame.

Why. Sent over gRPC means that the server ended the call cleanly, which it does only once it has taken the whole request stream. A server from before 0.21 ended the call cleanly in every case. A server on this velo ends it with an error when its consumer left, when the stream ended without its terminal, or when the server pump was dropped with the transport.

Workaround. Run the same velo on both nodes.

Lifted by. Upgrading the consumer's node.

MpscStreamSender has no retired()

What happens. A producer on an MPSC stream cannot wait until its output has left the node.

When. Any MpscStreamSender.

Why. The retirement is recorded for an MPSC stream too, over the mux and the per-stream transports, but the sender exposes no future for it.

Workaround. None inside velo; an acknowledgement in the application's protocol.

Lifted by. Not scheduled.

The teardown grace covers only what is already queued

What happens. At teardown, a TCP or UDS writer keeps writing for the transport's teardown grace (teardown_flush_grace on the builders, 1 s by default), but only the frames already in its channel: at most channel_capacity per connection. A frame that waits in the connection's admission gate when teardown starts is refused, and a send after teardown fails at once. A send that races the start of teardown can still be admitted and then fail; closing that needs a terminal state on the admission gate in velo-ext. Over the messenger mux, a refused batch retires its streams as Discarded.

When. The close step of graceful_shutdown.

Why. The grace starts after the drain. The time the work behind the frames takes belongs to the drain, which ShutdownPolicy::Timeout bounds; the policy cannot reach the writer without a new velo-ext trait method.

Workaround. Size the grace by the bytes that can be queued per connection and the slowest peer's read rate, and give the drain its own time through ShutdownPolicy::Timeout.

Lifted by. Not scheduled.

A staged payload without a deadline can stay staged

What happens. The sender keeps a staged payload until the sender shuts down when nobody fetches and releases it, and nothing gives it a deadline.

When. The message of a send without a .timeout never reaches a receiver, or the receiver stops before it releases the payload. Also a fetch that gives up against a sender from before the _rv_lease_ms header.

Why. Only the receiver's release frees a transparently staged payload. A time-to-live comes only from a call's .timeout, and a chunked lease gets a deadline only when a fetch with a bound asks for one.

Workaround. Give large calls a .timeout. It gives their payloads a time-to-live.

Lifted by. Not scheduled. A default time-to-live for transparently staged payloads would lift it, but it would also fail a fetch that is slower than that time.