Repository navigation
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
Closed
ColbySerpa wants to merge 13 commits into
ColbySerpa wants to merge 13 commits into
Conversation
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.
3 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
libudxstream 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 onudx-nativereach there, and about 900 MB/s on Linux under WSL2.udx.c,udx_bbr.c,udx_rate.candwin_filter.cat 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.50 MB between two programs on one machine, in MB/s (medians of eight to ten runs):
mainwith #34)udx-native1.19.2Windows, 200 MB, with either end this crate or Node.js on
udx-native, in MB/s (medians of eight rounds):SecretStreamudx-nativeudx-nativeto this crateudx-nativetoudx-nativeMotivation
On
main,stream_write_read_10mbandthroughput_sustainedtime 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:
udx_bbr.c,udx_rate.candwin_filter.care now ported function by function, in C's units. (8813255)data_offsetzero. This crate only read ranges whose length was indata_offset, so against Node.js it learned of a loss only from its retransmission timer. (7b0fb25)AsyncWriteadapter waited for each write to be acknowledged before it took the next, so a stream written in pieces moved one piece per round trip.SecretStreamwrote 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_writeand its drain callback). (8853e00, 98551a8)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)udx_socket_bind). (2e2396f)process_packetruns in the callback that reads the socket. On Windows the receiving end used 2.0 seconds of processor time for 200 MB, whereudx-nativeuses 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:
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.mdrecords these, and what else was measured and left as C has it.Changes
libudxlibudxlibudxnative::sockopt.libudxlibudxexamples/transfer.rs, which measured the figures here.PROTOCOL.mdfollows the code. 772421b puts the final figures in the changelog.peeroxide-dhtexamples/secret_transfer.rs.peeroxidepeeroxide-clicpno longer waits for.docsdht-primitives.mdthat namedMAX_PAYLOADand 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)
libudxUdxAsyncStream::effective_mtuUdxStream::effective_mtu. Documented.No existing signature, public type or trait impl changes. I compared every
pubitem, struct and enum body and trait impl of the three library crates withmain: the only other difference is a private field that was renamed.native::sockoptis a new module inside the privatenativemodule, 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.
UdxAsyncStreamno longer waits for acknowledgements.poll_writereturns once the bytes are in the write queue, andpoll_flushreturns 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_shutdownstill returns once the end of the stream is acknowledged, and is how a writer knows that everything arrived.UdxStream::writestill returns when its data is acknowledged.SecretStream::writereturns once its message is handed to the transport. A program that exits after its last message should awaitshutdownfirst.effective_mtuis the size of the IP packet, asudx-native'sstream.mtuis. It was the size of the UDP datagram. It still starts at 1200 and ends at 1500.udx-native, and one sent withUdxSocket::send_totoo. The largest DHT request is an IP packet of 1206 bytes.UdxAsyncStreamgoes on delivering what was written until it is acknowledged, or until 30 seconds pass without progress.Testing
cargo test --workspacestream_write_read_10mbandthroughput_sustainedpass there now. The seven arepeeroxide-clitests, 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_indexandtest_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 --workspaceon Rust 1.85.0 andcargo package --workspacepass as well.Linux and macOS ran on GitHub's runners in my fork: https://github.com/HORNET-Storage/peeroxide/actions/runs/43190896898. Its
mainis 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-native1.19.2 with the same stream ids as the examples, and@hyperswarm/secret-streamfor the encrypted column. The simulated path is between Linux network namespaces, withnetem. To repeat a row:The last two arguments write 64 KB at a time through the
AsyncWriteadapter. Without them everything goes in oneUdxStream::write.secret_transferinpeeroxide-dhtdoes the same for an encrypted stream.Notes
unsafeneeds your call.libudxwent from#![forbid(unsafe_code)]to#![deny(unsafe_code)], and one module,native::sockopt, is allowed two system calls:setsockoptandgetsockopt, 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.udx-nativeis still ahead. Audx-nativesender into an encrypted stream of this crate reaches 204 MB/s on Windows, against 226 intoudx-native.udx-nativedecrypts 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.stream.rsandcongestion/. 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.libudxnow namessocket2, andlibcorwindows-sysfor the two calls above. All three were already in the tree, through tokio and socket2.