Skip to content

Commit 447ca81

Browse files
committed
lightway-server: Use i/o uring for all i/o, not just tun.
This does not consistently improve performance but reduces CPU overheads (by around 50%-100% i.e. half to one core) under heavy traffic, which adding perhaps a few hundred Mbps to a speedtest.net download test and making negligible difference to the upload test. It also removes about 1ms from the latency in the same tests. Finally the STDEV across multiple test runs appears to be lower. This appears to be due to a combination of avoiding async runtime overheads, as well as removing various channels/queues in favour of a more direct model of interaction between the ring and the connections. As well as those benefits we are now able to reach the same level of performance with far fewer slots used for the TUN rx path, here we use 64 slots (by default) and reach the same performance as using 1024 previously. The way uring handles blocking vs async for tun devices seems to be non-optimal. In blocking mode things are very slow. In async mode more and more time is spent on bookkeeping and polling, as the number of slots is increased, plus a high level of EAGAIN results (due to a request timing out after multiple failed polls[^0]) which waste time requeueing. This is related to axboe/liburing#886 and axboe/liburing#239. For UDP/TCP sockets io uring behaves well with the socket in blocking mode which avoids processing lots of EAGAIN results. Tuning the slots for each I/O path is a bit of an art (more is definitely not always better) and the sweet spot varies depending on the I/O device, so provide various tunables instead of just splitting the ring evenly. With this there's no real reason to have a very large ring, it's the number of inflight requests which matters. This is specific to the server since it relies on kernel features and correctness(/lack of bugs) which may not be upheld on an arbitrary client system (while it is assumed that server operators have more control over what they run). It is also not portable to non-Linux systems. It is known to work with Linux 6.1 (as found in Debian 12 AKA bookworm). Note that this kernel version contains a bug which causes the `iou-sqp-*` kernel thread to get stuck (unkillable) if the tun is in blocking mode, therefore an option is provided. Enabling that option on a kernel which contains [the fix][] allows equivalent performance with fewer slots on the ring. [^0]: When data becomes available _all_ requests are woken but only one will find data, the rest will see EAGAIN and after a certain number of such events I/O uring will propagate this back to userspace. [the fix]: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=438b406055cd21105aad77db7938ee4720b09bee
1 parent 1847932 commit 447ca81

File tree

17 files changed

+1674
-438
lines changed

17 files changed

+1674
-438
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,4 @@ tokio-util = "0.7.10"
5252
tracing = "0.1.37"
5353
tracing-subscriber = "0.3.17"
5454
twelf = { version = "0.15.0", default-features = false, features = ["env", "clap", "yaml"]}
55+
tun = { version = "0.7.1" }

lightway-app-utils/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ tokio-stream = { workspace = true, optional = true }
3838
tokio-util.workspace = true
3939
tracing.workspace = true
4040
tracing-subscriber = { workspace = true, features = ["json"] }
41-
tun = { version = "0.7", features = ["async"] }
41+
tun = { workspace = true, features = ["async"] }
4242

4343
[[example]]
4444
name = "udprelay"

lightway-server/Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ license = "GPL-2.0-only"
99
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
1010

1111
[features]
12-
default = ["io-uring"]
12+
default = []
1313
debug = ["lightway-core/debug"]
14-
io-uring = ["lightway-app-utils/io-uring"]
1514

1615
[lints]
1716
workspace = true
@@ -26,6 +25,7 @@ clap.workspace = true
2625
ctrlc.workspace = true
2726
delegate.workspace = true
2827
educe.workspace = true
28+
io-uring = "0.7.0"
2929
ipnet.workspace = true
3030
jsonwebtoken = "9.3.0"
3131
libc.workspace = true
@@ -48,6 +48,7 @@ tokio-stream = { workspace = true, features = ["time"] }
4848
tracing.workspace = true
4949
tracing-log = "0.2.0"
5050
tracing-subscriber = { workspace = true, features = ["json"] }
51+
tun.workspace = true
5152
twelf.workspace = true
5253

5354
[dev-dependencies]

lightway-server/src/args.rs

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,57 @@ pub struct Config {
7171
#[clap(long, default_value_t)]
7272
pub enable_pqc: bool,
7373

74-
/// Enable IO-uring interface for Tunnel
75-
#[clap(long, default_value_t)]
76-
pub enable_tun_iouring: bool,
77-
78-
/// IO-uring submission queue count. Only applicable when
79-
/// `enable_tun_iouring` is `true`
80-
// Any value more than 1024 negatively impact the throughput
74+
/// Total IO-uring submission queue count.
75+
///
76+
/// Must be larger than the total of:
77+
///
78+
/// UDP:
79+
///
80+
/// iouring_tun_rx_count + iouring_udp_rx_count +
81+
/// iouring_tx_count + 1 (cancellation request)
82+
///
83+
/// TCP:
84+
///
85+
/// iouring_tun_rx_count + iouring_tx_count + 1 (cancellation
86+
/// request) + 2 * maximum number of connections.
87+
///
88+
/// Each connection actually uses up to 3 slots, a persistent
89+
/// recv request and on demand slots for TX and cancellation
90+
/// (teardown).
91+
///
92+
/// There is no downside to setting this much larger.
8193
#[clap(long, default_value_t = 1024)]
8294
pub iouring_entry_count: usize,
8395

96+
/// Number of concurrent TUN device read requests to issue to
97+
/// IO-uring. Setting this too large may negatively impact
98+
/// performance.
99+
#[clap(long, default_value_t = 64)]
100+
pub iouring_tun_rx_count: u32,
101+
102+
/// Configure TUN device in blocking mode. This can allow
103+
/// equivalent performance with fewer `ìouring-tun-rx-count`
104+
/// entries but can significantly harm performance on some kernels
105+
/// where the kernel does not indicate that the tun device handles
106+
/// `FMODE_NOWAIT`.
107+
///
108+
/// If blocking mode is enabled then `iouring_tun_rx_count` may be
109+
/// set much lower.
110+
///
111+
/// This was fixed by <https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=438b406055cd21105aad77db7938ee4720b09bee>
112+
#[clap(long, default_value_t = false)]
113+
pub iouring_tun_blocking: bool,
114+
115+
/// Number of concurrent UDP socket recvmsg requests to issue to
116+
/// IO-uring.
117+
#[clap(long, default_value_t = 32)]
118+
pub iouring_udp_rx_count: u32,
119+
120+
/// Maximum number of concurrent UDP + TUN sendmsg/write requests
121+
/// to issue to IO-uring.
122+
#[clap(long, default_value_t = 512)]
123+
pub iouring_tx_count: u32,
124+
84125
/// Log format
85126
#[clap(long, value_enum, default_value_t = LogFormat::Full)]
86127
pub log_format: LogFormat,

lightway-server/src/io.rs

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,268 @@
11
pub(crate) mod inside;
22
pub(crate) mod outside;
3+
4+
mod ffi;
5+
mod tx;
6+
7+
use std::{
8+
os::fd::{AsRawFd, OwnedFd, RawFd},
9+
sync::{Arc, Mutex},
10+
};
11+
12+
use anyhow::{anyhow, Result};
13+
use io_uring::{
14+
cqueue::Entry as CEntry,
15+
opcode,
16+
squeue::Entry as SEntry,
17+
types::{Fd, Fixed},
18+
IoUring, SubmissionQueue, Submitter,
19+
};
20+
21+
use ffi::{iovec, msghdr};
22+
pub use tx::TxQueue;
23+
24+
const IOURING_SQPOLL_IDLE_TIME_MS: u32 = 100;
25+
26+
/// Convenience function to handle errors in a uring result codes
27+
/// (which are negative errno codes).
28+
fn io_uring_res(res: i32) -> std::io::Result<i32> {
29+
if res < 0 {
30+
Err(std::io::Error::from_raw_os_error(-res))
31+
} else {
32+
Ok(res)
33+
}
34+
}
35+
36+
/// An I/O source pushing requests to a uring instance
37+
pub(crate) trait UringIoSource: Send {
38+
/// Return the raw file descriptor. This will be registered as an
39+
/// fd with the ring, allowing the use of io_uring::types::Fixed.
40+
fn as_raw_fd(&self) -> RawFd;
41+
42+
/// Push the initial set of requests to `sq`.
43+
fn push_initial_ops(&mut self, sq: &mut io_uring::SubmissionQueue) -> Result<()>;
44+
45+
/// Complete an rx request
46+
fn complete_rx(
47+
&mut self,
48+
sq: &mut io_uring::SubmissionQueue,
49+
cqe: io_uring::cqueue::Entry,
50+
idx: u32,
51+
) -> Result<()>;
52+
53+
/// Complete a tx request
54+
fn complete_tx(
55+
&mut self,
56+
sq: &mut io_uring::SubmissionQueue,
57+
cqe: io_uring::cqueue::Entry,
58+
idx: u32,
59+
) -> Result<()>;
60+
}
61+
62+
pub(crate) enum OutsideIoSource {
63+
Udp(outside::udp::UdpServer),
64+
Tcp(outside::tcp::TcpServer),
65+
}
66+
67+
// Avoiding `dyn`amic dispatch is a small performance win.
68+
impl UringIoSource for OutsideIoSource {
69+
fn as_raw_fd(&self) -> RawFd {
70+
match self {
71+
OutsideIoSource::Udp(udp) => udp.as_raw_fd(),
72+
OutsideIoSource::Tcp(tcp) => tcp.as_raw_fd(),
73+
}
74+
}
75+
76+
fn push_initial_ops(&mut self, sq: &mut io_uring::SubmissionQueue) -> Result<()> {
77+
match self {
78+
OutsideIoSource::Udp(udp) => udp.push_initial_ops(sq),
79+
OutsideIoSource::Tcp(tcp) => tcp.push_initial_ops(sq),
80+
}
81+
}
82+
83+
fn complete_rx(
84+
&mut self,
85+
sq: &mut io_uring::SubmissionQueue,
86+
cqe: io_uring::cqueue::Entry,
87+
idx: u32,
88+
) -> Result<()> {
89+
match self {
90+
OutsideIoSource::Udp(udp) => udp.complete_rx(sq, cqe, idx),
91+
OutsideIoSource::Tcp(tcp) => tcp.complete_rx(sq, cqe, idx),
92+
}
93+
}
94+
95+
fn complete_tx(
96+
&mut self,
97+
sq: &mut io_uring::SubmissionQueue,
98+
cqe: io_uring::cqueue::Entry,
99+
idx: u32,
100+
) -> Result<()> {
101+
match self {
102+
OutsideIoSource::Udp(udp) => udp.complete_tx(sq, cqe, idx),
103+
OutsideIoSource::Tcp(tcp) => tcp.complete_tx(sq, cqe, idx),
104+
}
105+
}
106+
}
107+
108+
pub(crate) struct Loop {
109+
ring: IoUring,
110+
111+
tx: Arc<Mutex<TxQueue>>,
112+
113+
cancel_buf: u8,
114+
115+
outside: OutsideIoSource,
116+
inside: inside::tun::Tun,
117+
}
118+
119+
impl Loop {
120+
/// Use for outside IO requests, `self.outside.as_raw_fd` will be registered in this slot.
121+
const FIXED_OUTSIDE_FD: Fixed = Fixed(0);
122+
/// Use for inside IO requests, `self.inside.as_raw_fd` will be registered in this slot.
123+
const FIXED_INSIDE_FD: Fixed = Fixed(1);
124+
125+
/// Masks the bits used by `*_USER_DATA_BASE`
126+
const USER_DATA_TYPE_MASK: u64 = 0xe000_0000_0000_0000;
127+
128+
/// Indexes in this range will result in a call to `self.outside.complete_rx`
129+
const OUTSIDE_RX_USER_DATA_BASE: u64 = 0xc000_0000_0000_0000;
130+
/// Indexes in this range will result in a call to `self.outside.complete_tx`
131+
const OUTSIDE_TX_USER_DATA_BASE: u64 = 0x8000_0000_0000_0000;
132+
133+
/// Indexes in this range will result in a call to `self.inside.complete_rx`
134+
const INSIDE_RX_USER_DATA_BASE: u64 = 0x4000_0000_0000_0000;
135+
/// Indexes in this range will result in a call to `self.inside.complete_tx`
136+
const INSIDE_TX_USER_DATA_BASE: u64 = 0x2000_0000_0000_0000;
137+
138+
/// Indexes in this range are used by `Loop` itself.
139+
const CONTROL_USER_DATA_BASE: u64 = 0x0000_0000_0000_0000;
140+
141+
/// A read request on the cancellation fd (used to exit the io loop)
142+
const CANCEL_USER_DATA: u64 = Self::CONTROL_USER_DATA_BASE + 1;
143+
144+
/// Return user data for a particular outside rx index.
145+
fn outside_rx_user_data(idx: u32) -> u64 {
146+
Self::OUTSIDE_RX_USER_DATA_BASE + (idx as u64)
147+
}
148+
149+
/// Return user data for a particular inside rx index.
150+
fn inside_rx_user_data(idx: u32) -> u64 {
151+
Self::INSIDE_RX_USER_DATA_BASE + (idx as u64)
152+
}
153+
154+
/// Return user data for a particular outside tx index.
155+
fn inside_tx_user_data(idx: u32) -> u64 {
156+
Self::INSIDE_TX_USER_DATA_BASE + (idx as u64)
157+
}
158+
159+
/// Return user data for a particular inside tx index.
160+
fn outside_tx_user_data(idx: u32) -> u64 {
161+
Self::OUTSIDE_TX_USER_DATA_BASE + (idx as u64)
162+
}
163+
164+
pub(crate) fn new(
165+
ring_size: usize,
166+
tx: Arc<Mutex<TxQueue>>,
167+
outside: OutsideIoSource,
168+
inside: inside::tun::Tun,
169+
) -> Result<Self> {
170+
tracing::info!(ring_size, "creating IoUring");
171+
let ring: IoUring<SEntry, CEntry> = IoUring::builder()
172+
.dontfork()
173+
.setup_sqpoll(IOURING_SQPOLL_IDLE_TIME_MS) // Needs 5.13
174+
.build(ring_size as u32)?;
175+
176+
Ok(Self {
177+
ring,
178+
tx,
179+
cancel_buf: 0,
180+
outside,
181+
inside,
182+
})
183+
}
184+
185+
pub(crate) fn run(mut self, cancel: OwnedFd) -> Result<()> {
186+
let (submitter, mut sq, mut cq) = self.ring.split();
187+
188+
submitter.register_files(&[self.outside.as_raw_fd(), self.inside.as_raw_fd()])?;
189+
190+
let sqe = opcode::Read::new(
191+
Fd(cancel.as_raw_fd()),
192+
&mut self.cancel_buf as *mut _,
193+
std::mem::size_of_val(&self.cancel_buf) as _,
194+
)
195+
.build()
196+
.user_data(Self::CANCEL_USER_DATA);
197+
198+
#[allow(unsafe_code)]
199+
// SAFETY: The buffer is owned by `self.cancel_buf` and `self` is owned
200+
unsafe {
201+
sq.push(&sqe)?
202+
};
203+
204+
self.outside.push_initial_ops(&mut sq)?;
205+
self.inside.push_initial_ops(&mut sq)?;
206+
sq.sync();
207+
208+
loop {
209+
let _ = submitter.submit_and_wait(1)?;
210+
211+
cq.sync();
212+
213+
for cqe in &mut cq {
214+
let user_data = cqe.user_data();
215+
216+
match user_data & Self::USER_DATA_TYPE_MASK {
217+
Self::CONTROL_USER_DATA_BASE => {
218+
match user_data - Self::CONTROL_USER_DATA_BASE {
219+
Self::CANCEL_USER_DATA => {
220+
let res = cqe.result();
221+
tracing::debug!(?res, "Uring cancelled");
222+
return Ok(());
223+
}
224+
idx => {
225+
return Err(anyhow!(
226+
"Unknown control data {user_data:016x} => {idx:016x}"
227+
))
228+
}
229+
}
230+
}
231+
Self::OUTSIDE_RX_USER_DATA_BASE => {
232+
self.outside.complete_rx(
233+
&mut sq,
234+
cqe,
235+
(user_data - Self::OUTSIDE_RX_USER_DATA_BASE) as u32,
236+
)?;
237+
}
238+
Self::OUTSIDE_TX_USER_DATA_BASE => {
239+
self.outside.complete_tx(
240+
&mut sq,
241+
cqe,
242+
(user_data - Self::OUTSIDE_TX_USER_DATA_BASE) as u32,
243+
)?;
244+
}
245+
246+
Self::INSIDE_RX_USER_DATA_BASE => {
247+
self.inside.complete_rx(
248+
&mut sq,
249+
cqe,
250+
(user_data - Self::INSIDE_RX_USER_DATA_BASE) as u32,
251+
)?;
252+
}
253+
Self::INSIDE_TX_USER_DATA_BASE => {
254+
self.inside.complete_tx(
255+
&mut sq,
256+
cqe,
257+
(user_data - Self::INSIDE_TX_USER_DATA_BASE) as u32,
258+
)?;
259+
}
260+
261+
_ => unreachable!(),
262+
}
263+
264+
self.tx.lock().unwrap().drain(&submitter, &mut sq)?;
265+
}
266+
}
267+
}
268+
}

0 commit comments

Comments
 (0)