Skip to content

perf(libudx): make streams as fast as udx-native by porting C libudx's send path - #35

Closed
ColbySerpa wants to merge 13 commits into
Rightbracket:mainfrom
HORNET-Storage:perf/udx-send-path
Closed

ColbySerpa wants to merge 13 commits into
Rightbracket:mainfrom
HORNET-Storage:perf/udx-send-path

Conversation

@ColbySerpa

Copy link
Copy Markdown

Summary

  • A libudx stream is capped far below what any network carries. Between two programs on one machine it moves 0.1 MB/s on Windows and 0.9 MB/s on Linux. With this PR it moves 230 to 270 MB/s on Windows, which is what two Node.js programs on udx-native reach there, and about 900 MB/s on Linux under WSL2.
  • How: the sending side is now a port of C libudx, function by function: the write queue, pacing, BBR and its rate sampling, loss detection, the timers and MTU discovery, from udx.c, udx_bbr.c, udx_rate.c and win_filter.c at holepunchto/libudx@25b063b. Reading the two side by side turned up eight places where this crate had drifted from C. They are listed under Motivation.
  • Compatible: the wire format is unchanged, the Node.js interop tests pass, and no public signature changes. One method is added. This is the pacing matter I mentioned in fix(libudx): keep receiving after an undelivered datagram on Windows #34. It is built on that fix, so the commit of fix(libudx): keep receiving after an undelivered datagram on Windows #34 is the first one here.

50 MB between two programs on one machine, in MB/s (medians of eight to ten runs):

Before (main with #34) This PR Two Node.js programs on udx-native 1.19.2
Windows 11 0.1 230 written 64 KB at a time, 270 written in one call about 240
Linux (WSL2) 0.9 about 900 24 to 48 in most runs, about 630 in some
10 MB through a simulated path of 100 Mbit/s with a 40 ms round trip 0.4 9.9 8.6
The same path with 1% of packets lost not measured 9.3 9.0

Windows, 200 MB, with either end this crate or Node.js on udx-native, in MB/s (medians of eight rounds):

Sender to receiver Plain Encrypted with SecretStream
this crate to this crate 258 261
this crate to udx-native 260 297
udx-native to this crate 265 204
udx-native to udx-native 263 226

Motivation

On main, stream_write_read_10mb and throughput_sustained time out on Windows, as I noted in #34. At these rates 100 MB takes two minutes on Linux and over a quarter of an hour on Windows, whatever the network.

I read this crate's send path against C libudx line by line. Where it had drifted from C:

  1. Pacing. The pacer never held more than two packets, and a stream with more to send waited on a 1 ms timer, which Windows fires every 15.6 ms. C refills the pacer with the pacing rate times the time since the last refill, and sends whenever an acknowledgement, a write or a timer allows. (8813255)
  2. What BBR was fed. The send interval of a rate sample was always zero. The round-trip time BBR saw was the time since the flight began. The window grew without bound in Startup, and a pacing rate of zero switched pacing off. udx_bbr.c, udx_rate.c and win_filter.c are now ported function by function, in C's units. (8813255)
  3. Loss detection. Every acknowledgement with a gap marked the packets below it lost again, retransmitted ones included, and each copy went out at once, past the pacer and the window. C uses RFC 8985 (RACK-TLP) and sends lost packets through both. The retransmission timer resent everything in flight and doubled each time, where C resends what has timed out. (8813255)
  4. Selective acknowledgements from C peers were never read. C sends the ranges as the payload of a packet with data_offset zero. This crate only read ranges whose length was in data_offset, so against Node.js it learned of a loss only from its retransmission timer. (7b0fb25)
  5. Writes waited for acknowledgements. The AsyncWrite adapter waited for each write to be acknowledged before it took the next, so a stream written in pieces moved one piece per round trip. SecretStream wrote a frame's three-byte length and its body separately, so each message took two round trips. C has a write queue with a high-water mark (udx_stream_write and its drain callback). (8853e00, 98551a8)
  6. Every full packet travelled as two IP fragments. Sockets did not mark packets "don't fragment", so every MTU probe arrived, in pieces, and the search always ended at the largest size. The MTU was also counted as the size of the UDP datagram, which made a full packet an IP packet of 1528 bytes. C marks packets (udx__udp_set_dontfrag), counts the whole IP packet (udx__max_payload) and stops at the link's MTU (udx__get_link_mtu). Over a link with an MTU of 1500, 20 MB left in 27,492 fragments, and now leaves in none. (5310a35)
  7. Socket buffers. The system's default buffers hold about fifty packets on Windows, and what a burst sent beyond that was dropped. C asks for 212,992 bytes to send and 1 MiB to receive (udx_socket_bind). (2e2396f)
  8. The receive path. Every packet went through a channel from the socket's task to the stream's task before its data reached the reader, and nine times in ten that hand-over crossed threads. C's process_packet runs in the callback that reads the socket. On Windows the receiving end used 2.0 seconds of processor time for 200 MB, where udx-native uses 0.8. It now uses 1.1 to 1.3. (8fac6df, and f447e96 removes a second allocation for each packet sent)

Where it differs from C on purpose:

  • Rate samples are measured in microseconds. C counts whole milliseconds, and gets away with it because its loop sends a pacer's worth of packets with the clock standing still. Here acknowledgements are processed between batches of sends, so on a path that answers in under a millisecond a sample in whole milliseconds is zero or several times too low. With the estimate that low the pacer ran dry, and on Windows an empty pacer waits out a 15.6 ms tick.
  • The tail loss probe waits two round trips when the smoothed round-trip time rounds to 0 ms too. C takes zero to mean that none was measured, and waits a second.
  • An acknowledgement names up to 144 ranges, as many as fit a packet, where C names 51. A burst of a thousand losses, which the end of Startup produces on a path with a shallow queue, took some thirty round trips to repair and now takes seven. C reads a list of any length.
  • Acknowledgements wait one turn of the event loop on Windows only. C waits a turn on every system. On Windows a send costs 6 to 11 microseconds, so fewer acknowledgements carry a transfer further. On Linux the waiting measured 5% slower.

Two things are mirrored from C although they look wrong: the losses an acknowledgement reveals are not in its rate sample, and leaving recovery sets the window to the slow-start threshold, which is 0xffff until Drain. libudx/PROTOCOL.md records these, and what else was measured and left as C has it.

Changes

Crate Change
libudx 2e2396f asks for the socket buffers C asks for. 7b0fb25 reads selective acknowledgements in C's form.
libudx 8813255 ports the send path: pacing, BBR, rate sampling, the windowed filter, RACK-TLP and the timers. 8853e00 adds the write queue.
libudx 5310a35 marks packets "don't fragment", counts the MTU as C does and reads the link's MTU, in one new module, native::sockopt.
libudx e31f924 names up to 144 ranges in an acknowledgement. 8fac6df takes a packet in where it is received. f447e96 allocates a packet once.
libudx a94c1f0 adds examples/transfer.rs, which measured the figures here. PROTOCOL.md follows the code. 772421b puts the final figures in the changelog.
peeroxide-dht 98551a8 sends a frame's length and body in one write. 650e09e adds examples/secret_transfer.rs.
peeroxide One interop test blocked its runtime's thread while it waited for Node. It now awaits.
peeroxide-cli No code. Its changelog says what cp no longer waits for.
docs Two lines of dht-primitives.md that named MAX_PAYLOAD and fast retransmit.

Each commit message carries its reasoning and its measurements, so the PR is meant to be read commit by commit. CHANGELOG entries are in for all four crates, and no version is bumped.

Unit tests drive the new send engine without a socket: what would go on the wire is handed back, and every step is given the time it happens at.

Public API Changes

All changes below are: [x] Additive only (semver-compatible) / [ ] Breaking (requires maintainer approval)

Crate Symbol Change
libudx UdxAsyncStream::effective_mtu New method, the counterpart of UdxStream::effective_mtu. Documented.

No existing signature, public type or trait impl changes. I compared every pub item, struct and enum body and trait impl of the three library crates with main: the only other difference is a private field that was renamed. native::sockopt is a new module inside the private native module, so nothing in it is reachable from outside the crate.

Behaviour a caller can observe changes in these places, with the signatures as they were. Each is in the changelogs.

  • UdxAsyncStream no longer waits for acknowledgements. poll_write returns once the bytes are in the write queue, and poll_flush returns at once, as with a TCP socket. It asks its writer to wait only above C's high-water mark, 262,144 bytes on top of the send window. poll_shutdown still returns once the end of the stream is acknowledged, and is how a writer knows that everything arrived. UdxStream::write still returns when its data is acknowledged.
  • SecretStream::write returns once its message is handed to the transport. A program that exits after its last message should await shutdown first.
  • A stream whose peer never answers fails after 13 seconds, as in C. It took 61.
  • effective_mtu is the size of the IP packet, as udx-native's stream.mtu is. It was the size of the UDP datagram. It still starts at 1200 and ends at 1500.
  • A datagram larger than the path carries is dropped where it was fragmented, as with udx-native, and one sent with UdxSocket::send_to too. The largest DHT request is an IP packet of 1206 bytes.
  • A read that is waiting ends when its stream is closed or gives up on its remote. It waited for good.
  • A dropped UdxAsyncStream goes on delivering what was written until it is acknowledged, or until 30 seconds pass without progress.

Testing

  • cargo test --workspace
    • Linux: 1154 passed, 0 failed, 15 ignored.
    • macOS on Apple Silicon: 1153 passed, 0 failed, 15 ignored. The one test fewer is Linux-only.
    • Windows 11: 1143 passed, 7 failed, 12 ignored. stream_write_read_10mb and throughput_sustained pass there now. The seven are peeroxide-cli tests, and the same seven fail on the commit this branch starts from: test_chat_whoami, test_chat_nexus_publish_and_lookup, test_dd_local_roundtrip, test_dd_large_payload, test_dd_passphrase_roundtrip, test_dd_v2_multi_index and test_dd_v2_passphrase_roundtrip. I can raise those separately.
  • cargo test -p peeroxide-cli --test live_commands -- --ignored (Linux: 6 passed. Windows: 6 passed.)
  • cargo clippy --workspace --all-targets -- -D warnings (Linux, macOS and Windows)

cargo doc --workspace --no-deps, cargo check --workspace on Rust 1.85.0 and cargo package --workspace pass as well.

Linux and macOS ran on GitHub's runners in my fork: https://github.com/HORNET-Storage/peeroxide/actions/runs/43190896898. Its main is this branch plus two commits that only touch the workflows, to add the macOS jobs. Linux also ran on my machine under WSL2. Windows ran there only, since it is not in CI.

How the figures were measured. Both ends are separate processes on one machine, in release builds, over loopback. The rounds are interleaved, so every configuration meets the same state of the machine, and each figure is a median. The Node.js end runs udx-native 1.19.2 with the same stream ids as the examples, and @hyperswarm/secret-stream for the encrypted column. The simulated path is between Linux network namespaces, with netem. To repeat a row:

cargo run --release -p libudx --example transfer -- recv 127.0.0.1:9001 127.0.0.1:9002 50
cargo run --release -p libudx --example transfer -- send 127.0.0.1:9002 127.0.0.1:9001 50 64 async

The last two arguments write 64 KB at a time through the AsyncWrite adapter. Without them everything goes in one UdxStream::write. secret_transfer in peeroxide-dht does the same for an encrypted stream.

Notes

  • unsafe needs your call. libudx went from #![forbid(unsafe_code)] to #![deny(unsafe_code)], and one module, native::sockopt, is allowed two system calls: setsockopt and getsockopt, to mark packets "don't fragment" and to read a link's MTU. Neither the standard library, tokio nor socket2 offers them. Each call passes one integer that outlives it. If you would rather have this another way, I will change it.
  • Older releases of this crate. A list of more than 31 ranges goes out in C's form, which releases up to 1.7.4 do not read. A list of up to 31 is sent as before. So a 1.7.4 sender that loses more than 31 separate runs of packets at once learns of them from its timer, as it does today against every Node.js peer.
  • Where udx-native is still ahead. A udx-native sender into an encrypted stream of this crate reaches 204 MB/s on Windows, against 226 into udx-native. udx-native decrypts on the thread that reads the socket, so it acknowledges every tenth packet there, where this crate acknowledges every fourth. Once a transfer is under way, udx-native's sender also carries about 5% more, because it hands the system the caller's bytes without copying them. Going further means sending without that copy and handing the system several packets in one call. Neither is in this PR.
  • What the figures don't cover. They come from one machine, over loopback or a simulated path. The Linux figures are under WSL2, where Node's own vary widely from run to run. macOS ran the tests only, in CI, and nothing here was measured between two machines.
  • Size. 29 files, most of it in stream.rs and congestion/. The commits build on each other in order. If a smaller first step is easier to review, the first two stand on their own, and I can split wherever you prefer.
  • fix(libudx): keep receiving after an undelivered datagram on Windows #34. Its commit is the first one here, because the receive loop it fixes is rewritten later in the series. If fix(libudx): keep receiving after an undelivered datagram on Windows #34 merges first I will rebase, and this PR loses that commit.
  • Dependencies. libudx now names socket2, and libc or windows-sys for the two calls above. All three were already in the tree, through tokio and socket2.

On Windows, a UDP socket reports on its next receive that a datagram it
sent earlier was not delivered: WSAECONNRESET after an ICMP "port
unreachable", and WSAENETRESET after an ICMP "time to live exceeded".
The socket itself still works.

The receive loop of `UdxSocket` ended at the first receive error. So one
datagram to a peer that had gone, or one low-TTL holepunch probe, left the
socket deaf: it delivered no more datagrams, and no more packets to the
streams registered on it, until another stream was registered. A DHT node
shares one socket between its RPC and its streams, so on Windows a node
stopped hearing replies and its streams stalled.

The loop now carries on after those two errors, as libuv does
(`uv__process_udp_recv_req`), and still ends on any other error. Other
systems report no such error on an unconnected socket, so nothing changes
for them.

Tests: a socket, and a stream, still receive after a datagram from their
socket was refused by a closed local port. On Windows both fail without
this change, where the receive times out, and pass with it. Elsewhere they
pass either way. Unit tests cover which errors the loop carries on after.
A stream sends a burst of packets each time it is woken, and what the
receiving socket's buffer cannot hold until its task runs is dropped.
The system defaults hold some fifty packets on Windows and under two
hundred on Linux.

A socket now asks for 212,992 bytes to send and 1 MiB to receive,
stepping down through 512, 256 and 208 KiB to the largest the system
grants, as `udx_socket_bind` does. A refusal is not an error: the
socket works with what it gets.

New dependency: socket2, which tokio already brings in.
C libudx sends SACK ranges as the payload of a packet that carries
nothing else, with `data_offset` zero. This crate puts their length in
`data_offset`, and only read ranges sent that way. A stream therefore
ignored every selective acknowledgement of a C libudx or Node.js peer,
and learned of a lost packet only when its retransmission timer fired.

Both forms are read now. C libudx reads both already, so what this
crate sends stays as it is.

An acknowledgement also carried up to 50 ranges, 400 bytes, which the
one-byte `data_offset` cannot say: the length wrapped around, and a
peer read some of the ranges or none. It carries at most 31 now.
A stream sent far below what its path carries. Before this series, a
50 MB transfer between two programs on one machine ran at 0.1 MB/s on
Windows and 0.9 MB/s on Linux (WSL2), where two Node.js programs on
udx-native 1.19.2 reach about 240 MB/s and, in most runs, 24 to 48 MB/s.
Through a path of 100 Mbit/s with a 40 ms round trip, 10 MB ran at
0.4 MB/s, against Node's 8.5.

Congestion control, pacing, loss detection and the timers are now ports
of C libudx's udx_bbr.c, udx_rate.c, win_filter.c and the send path of
udx.c, as of holepunchto/libudx 25b063b. The same transfers, written in
one call, run at about 190 MB/s on Windows, 690 MB/s on Linux, and 8 to
9 MB/s through the 100 Mbit/s path. Written 64 KB at a time through
UdxAsyncStream, which at this commit still waits for each write to be
acknowledged, they run at 156 and 399 MB/s, and at 1.4 MB/s through the
path. No public API changes.

Sending

The pacer never held more than two packets, and a stream with more to
send waited on a 1 ms timer, which Windows fires every 15.6 ms: two
packets per tick. A stream now sends as soon as an acknowledgement, a
write or a timer allows: the acknowledgement it owes, then lost
packets, then new data, while the pacer has tokens and the window has
room. The pacer is C's: on each millisecond of the stream's clock it is
refilled with the pacing rate times the milliseconds since the last
refill.

Everything that has arrived is processed before anything is sent, so
one acknowledgement answers a whole batch of packets. Each packet drew
its own before, from a task spawned for it.

Loss detection

Loss was found from gaps below the highest selective acknowledgement,
on every acknowledgement. That marked a retransmitted packet lost again
each time an acknowledgement arrived before its own, and every copy
went out at once from a spawned task, past the pacer and the window.

It is now RFC 8985 (RACK-TLP), as in C: a packet is lost when one sent
after it has been delivered and the reordering window has passed. A
tail loss probe finds the loss of a flight's last packets. Lost packets
wait in a queue and go out through the pacer and the window.

The retransmission timer resent everything in flight and doubled each
time, so a stream whose peer had gone failed after 61 seconds. It now
resends the packets that have timed out and is set to twice the
timeout, and a packet may time out six times: 13 seconds, as in C.

Congestion control

BBR, its rate sampling and its windowed filter are ported function by
function, in C's units. Among what that corrects: the send interval of
a rate sample was always zero, the round-trip time BBR saw was the time
since the flight began, the window grew without bound in Startup, and a
pacing rate of zero switched pacing off.

Where this differs from C

Rate samples are measured in microseconds. C counts whole milliseconds
on its loop's clock, and gets away with it because its loop sends a
pacer's worth of packets in one go with the clock standing still, so a
sample spans the whole burst. Here acknowledgements are processed
between batches of sends, and when a round trip takes a fraction of a
millisecond a sample in whole milliseconds is either unusable (zero) or
several times too low (one). With the estimate that low the pacer ran
dry on every tick, and on Windows a stream whose pacer is empty waits
out a 15.6 ms timer tick.

The tail loss probe waits two round trips when the smoothed round-trip
time rounds to 0 ms as well. C takes zero to mean that none was
measured, and waits a second.

Mirrored from C on purpose: the losses an acknowledgement reveals are
not in its rate sample, and leaving recovery sets the window to the
slow-start threshold, which is 0xffff until Drain.

Tests

Unit tests drive the new engine without a socket: what goes on the
wire is handed back, and every step is given the time it happens at.
The sustained-throughput test now has 30 seconds for its 50 MiB, which
the code before needed 45 for on Linux and could not do in 120 on
Windows. The limit of the unreachable-peer test follows the new
13 seconds.
`transfer` sends a number of megabytes from one program to another over
one stream, and each end prints the rate it saw. The sending end can
write through `UdxStream::write` or the `AsyncWrite` adapter, in writes
of a given size.

The receiving end has stream id 2 and the sending end stream id 1, so
either end can be a Node.js program on udx-native that uses the same
ids. The figures of the previous commit were measured that way.
A write was cut into packets, and the packets numbered, when it was
written. The `AsyncWrite` adapter then waited for one write to be
acknowledged before it took the next, and `poll_flush` waited for the
last. A stream written in pieces moved one piece per round trip.

A write now goes into a write queue, as in C libudx, and packets are cut
from the queue as they are sent. The adapter takes a write into the
queue and returns. It asks its writer to wait only while more than
262,144 bytes on top of the send window are unacknowledged, which is C's
rule for `udx_stream_write` and its drain callback. `poll_flush` returns
at once. `poll_shutdown` returns, as before, once the end of the stream
has been acknowledged, and is now how a writer knows that everything
arrived. `UdxStream::write` still returns when its data is acknowledged.

Through a path of 100 Mbit/s with a 40 ms round trip, 10 MB written
64 KB at a time through the adapter went from 1.4 MB/s to between 7.8
and 8.3 MB/s, and written 1 KB at a time it reaches 7.3 to 7.6. A
Node.js sender on `udx-native` writing 64 KB at a time reaches 8.5 to
8.7 there.

What follows from cutting packets late:

- A packet is as large as the path's MTU allows when it goes out, so an
  MTU found larger during a long write applies to the rest of it.
- Writes smaller than a packet share one, and the end of the stream
  rides on the last packet of data.
- A packet is numbered when it is cut. Counting a write's packets
  before cutting them left a number without a packet when the MTU grew
  in between, and the stream then never completed another write.
- The congestion window is no longer scaled when the MTU grows. It
  counts packets, and C leaves it alone.

Since a write no longer says whether it arrived:

- A dropped adapter goes on delivering what was written until it is
  acknowledged, where it sent each queued packet once and stopped.
- A stream that gives up on its remote, or is closed, ends a waiting
  read, and the adapter's reads, writes, `flush` and `shutdown` report
  why.

A test of `peeroxide` wrote to a stream and then blocked its runtime's
thread while it waited for Node to receive the write. It now waits
without blocking.
`SecretStream::write` wrote the three bytes of a frame's length, then
the body, then flushed. Over a UDX stream the length went out in a
packet of its own, and until the previous commit each of the two writes
waited for the one before it to be acknowledged: two round trips for
every message.

The frame now goes to the transport in one write.
`secret_transfer` sends messages through a `SecretStream` on a UDX
stream, which is what a connection made through the DHT is, and prints
the rate. Either end can be a Node.js program that puts
`@hyperswarm/secret-stream` on a `udx-native` stream.

Through a path of 100 Mbit/s with a 40 ms round trip, 10 MB in 64 KB
messages go at 9.0 to 10.0 MB/s between two of it, and in 1 KB messages
at 7.8 to 8.5. Two Node.js programs reach 9.3 to 9.8 and 8.3 to 9.7.
Built on the commit before the write queue, it carries 64 KB messages
at 0.8 MB/s and takes 83 seconds for a megabyte of 1 KB messages.

Between two programs on one machine, under Linux, 50 MB in 64 KB
messages go at about 700 MB/s. Two Node.js programs reach 23 to
106 MB/s.
…dx does

A stream finds the largest packet its path carries by sending larger and
larger ones and seeing which arrive. That needs a packet that is too large
to be dropped. A socket's packets were not marked "don't fragment", so
every probe arrived, in pieces, and the search always ended at the largest
size. The stream also counted its MTU as the size of the UDP datagram, so
that size was an IP packet of 1528 bytes, and from then on every full
packet travelled as two fragments on any path with an MTU of 1500.

As in C libudx:

- A socket marks its packets when it is bound: IP_MTU_DISCOVER set to
  IP_PMTUDISC_PROBE on Linux, Android and Windows, IP_DONTFRAG on Apple's
  systems and FreeBSD (udx__udp_set_dontfrag).
- A stream's MTU is the size of the whole IP packet, so a full packet
  carries the MTU less 48 bytes over IPv4 and less 68 over IPv6
  (udx__max_payload), and a probe is an IP packet of the size tried.
- On connecting, a stream searches no further than the MTU of the link its
  remote is reached over, where the system tells it (udx__get_link_mtu).

Measured between network namespaces on Linux, 20 MB:

- Link MTU 1500: 13,746 packets left as 27,492 fragments. Now none, in
  packets of 1500 bytes, as from udx-native. 420 MB/s before, 585 after;
  udx-native 426.
- Link MTU 1500, then a hop of 1400: 27,592 fragments before. Now none: the
  router refuses the three probes of 1424 and the stream settles on 1392,
  as udx-native does. 427 MB/s before, 648 after; udx-native 407.
- Link MTU 1400: packets of 1400, as from udx-native.

On Windows the option is set and read back, and a datagram larger than the
link is refused with WSAEMSGSIZE.

Neither the standard library, tokio nor socket2 has these options, so one
module, native::sockopt, calls setsockopt and getsockopt. The crate denies
unsafe code where it forbade it, and only that module allows it. libc and
windows-sys become direct dependencies; both were in the tree already.

A datagram sent with UdxSocket::send_to that is larger than the path
carries is now dropped rather than fragmented, as with udx-native. The
largest DHT request is an IP packet of 1206 bytes.

effective_mtu now means what udx-native's stream.mtu means. It still
starts at 1200 and ends at 1500 on a path that carries that much.
UdxAsyncStream gets the same method, and the transfer example reports it.
A sender learns of a gap from the ranges around it, and an acknowledgement
lists the lowest ranges. So what a sender can repair in one round trip is
what one acknowledgement names. This crate named 31 ranges at most, C
libudx names 51, and a burst of a thousand losses, which the end of
Startup produces on a path with a shallow queue, took some thirty round
trips to repair.

An acknowledgement now names as many ranges as fit a packet of the starting
MTU: 144 over IPv4, 141 over IPv6. A list of more than 31 goes out in C
libudx's form, with data_offset at zero and the ranges as the whole
payload, which C libudx reads to any length. A list of up to 31 also says
its length in data_offset, as before, which is all that releases of this
crate up to 1.7.4 read.

10 MB through a path of 100 Mbit/s with a 40 ms round trip, medians of
twelve runs, in MB/s, without loss and with 1% of packets lost:

- between two ends of this crate: 7.8 -> 9.9, and 7.7 -> 9.4. The slowest
  run with the loss went from 4.5 to 9.1.
- a udx-native sender into this crate: 8.5 -> 9.6, and 8.8 -> 9.4. With
  the loss it sent 86 packets again where it had sent 741.
- two udx-native ends: 8.6 and 8.7.

The acknowledgements of a transfer take about as many bytes as before,
between 1.3 and 2.1 MB, since the repair they are needed for is over
sooner.

Before this crate limited itself to 31 ranges it sent up to 50 with a
length that does not fit data_offset. The changelog said that an
acknowledgement carries at most 31 ranges; it now says what was wrong.

PROTOCOL.md also records what was measured and left as C libudx has it:
the window after a loss in Startup, the one-second floor of the
retransmission timeout, and the missing zero-window probe.
A packet went from the socket's receive task to the stream's own task
through a channel, and what it carried went on to the reader through
another. On a runtime with several threads the first hand-over most often
ends on another thread: of the 144,000 packets of a 200 MB transfer,
130,000 did. On Windows the receiving end used about 2 seconds of
processor time for those 200 MB where udx-native uses 0.8, and a transfer
from udx-native into this crate ran at 204 MB/s where one into udx-native
ran at 264.

C libudx has no such hand-over: its process_packet runs in the callback
that reads the socket. As there, the task that receives a packet now takes
in what the packet carries for the reader:

- The socket's receive task hands the data to the stream's reader and
  sends the acknowledgement the stream owes: when the socket is empty, or
  after 256 packets. Where the socket has no room for it, the stream's own
  task sends it.
- The stream's own task still does all the sending, and nothing else
  touches the sending side. It is passed what a packet says to that side.
  An acknowledgement goes to it whole. From a packet with data it gets a
  copy of the header, and of the ranges if there are any, when the header
  acknowledges more, names ranges or advertises another window.
- The packets of a stream that is not connected yet, or that relays to
  another stream, and a packet that destroys the stream, go to the
  stream's own task whole, as before.

On Windows the acknowledgements wait one turn of the event loop, and the
socket is read once more before they go out, as in C libudx. An
acknowledgement costs 6 to 11 microseconds to send there and a turn about
2, so fewer acknowledgements carry a transfer further: 144,000 packets
draw about 30,000 where they drew about 50,000, and udx-native sends about
33,000 for the same transfer. A turn that takes longer than 100
microseconds switches the waiting off for the next 64 times. On Linux an
acknowledgement costs about 3 microseconds to send, the waiting measured
5% slower, and acknowledgements go out as soon as the socket is empty.

Between two programs on one machine, medians in MB/s:

Windows, 200 MB, eight rounds      plain          encrypted
  udx-native -> this crate         204 -> 259     173 -> 212
  this crate -> this crate         209 -> 250     210 -> 254
  this crate -> udx-native         252 -> 255     291 -> 288
  udx-native -> udx-native         264            234

Linux under WSL2, 500 MB plain, 300 MB encrypted
  udx-native -> this crate         805 -> 1065    475 -> 572
  this crate -> this crate         826 -> 895     787 -> 871
  this crate -> udx-native         834 -> 852     uneven, 110 to 520

The receiving end of a plain transfer on Windows used 1.1 to 1.3 seconds
of processor time where it used 2.0 to 2.1.

Through a path of 100 Mbit/s with a 40 ms round trip, 10 MB, eight runs,
nothing changes: 9.85 -> 9.9 MB/s between two ends of this crate and
9.35 -> 9.3 with 1% of packets lost; 9.7 -> 9.6 and 9.35 -> 9.3 from a
udx-native sender.

Left as it is: a udx-native sender into an encrypted stream of this crate
reaches 212 MB/s on Windows, and 234 into udx-native. udx-native decrypts
on the thread that reads the socket, so it acknowledges every tenth packet
there. This crate acknowledges every fourth, as both do without
encryption, and the udx-native sender is slower for each acknowledgement
it takes in.
A packet of data was put together in one allocation and copied into a
second: one went on the wire, the other was kept until the packet was
acknowledged. What is kept and what goes on the wire are now the same
bytes, held by both. A packet is put together in a buffer that the batch
reuses, and copied into them once. A packet that is sent again goes out
from the same bytes. A lost MTU probe gets a copy of its own without the
padding, since the padded one may still be on its way out.

The stream's own task, which sends, is the one a transfer waits for. On
Windows it has no time to spare: 82% of it goes to the system's send
call, 10 to 12% to picking and recording what to send, and 7% to the
acknowledgements that come back. Left without the second copy, the
picking took 0.11 microseconds less a packet, and the acknowledgements,
which free what was kept, 0.07 less.

Between two programs on one machine, medians in MB/s:

Windows, 200 MB, eight rounds      plain          encrypted
  this crate -> udx-native         252 -> 260     278 -> 297
  this crate -> this crate         249 -> 258     248 -> 261
  udx-native -> this crate         265            204
  udx-native -> udx-native         263            226

Linux under WSL2, seven rounds     500 MB plain   300 MB encrypted
  this crate -> this crate         896 -> 953     876 -> 909
  this crate -> udx-native         857 -> 911

With this, a plain transfer on Windows runs within 2% of the speed of two
udx-native ends whichever end this crate is at, which is less than one
run differs from the next.
The changelog's first entry gave the speed of a 50 MB transfer between
two programs as about 250 MB/s on Windows and 600 MB/s on Linux. Measured
again at the end of the series, medians of eight to ten runs:

- Windows: about 230 MB/s written 64 KB at a time through
  UdxAsyncStream, and about 270 written in one call. Two Node.js programs
  on udx-native: about 240.
- Linux under WSL2: about 900 MB/s either way.
- Through a path of 100 Mbit/s with a 40 ms round trip, 10 MB: 9.9 MB/s,
  where two udx-native ends reach 8.6.

Before the series: 0.1 MB/s on Windows, 0.9 on Linux and 0.4 through the
path, as the entry says.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant