guard/docs/research/rust-performance.md
loki5512344 cf9608ce5d
Initial commit: Rampart v0.2.0
Multi-layer DDoS protection for Minecraft servers.

- rampart-core: Edge node with XDP/eBPF + Rust L7 filtering
- rampart-manager: REST API with JWT auth, Redis sync
- rampart-cli: CLI tool for operators
- velocity-plugin: Domain check, HMAC verify, server registry, load balancer
- paper-plugin: Auto-registration, heartbeat, HMAC verify
- dashboard: React + Vite web UI for management
2026-07-20 20:53:32 +02:00

9.1 KiB
Raw Blame History

Rust Performance - Zero-Copy, SO_REUSEPORT, NUMA

Актуально: v0.3+


Zero-Copy проксирование

Обычный proxy (2 копии):
  NIC → kernel buf → copy → userspace buf → copy → kernel buf → NIC

splice(2) zero-copy (0 копий в userspace):
  NIC → kernel pipe → NIC
  Данные никогда не покидают kernel

Когда применять

Handshake фаза  → обычный read() (нужно видеть байты, парсить, ставить HMAC)
После handshake → zero-copy splice (просто проксируем стрим)
// src/proxy/tunnel.rs
use tokio_splice::zero_copy_bidirectional;

pub async fn tunnel(mut client: TcpStream, mut backend: TcpStream) {
    // После того как handshake прочитан и HMAC добавлен -
    // всё остальное идёт через splice(2) без копий в userspace
    let _ = zero_copy_bidirectional(&mut client, &mut backend).await;
}

SO_REUSEPORT - линейный scale по CPU

// main.rs - N воркеров, каждый слушает тот же порт
// Ядро само балансирует входящие SYN между воркерами

use socket2::{Domain, Socket, Type};

fn build_listener(addr: SocketAddr) -> TcpListener {
    let socket = Socket::new(Domain::IPV4, Type::STREAM, None).unwrap();
    socket.set_reuse_port(true).unwrap();   // SO_REUSEPORT
    socket.set_reuse_address(true).unwrap();
    socket.set_nonblocking(true).unwrap();
    socket.bind(&addr.into()).unwrap();
    socket.listen(65535).unwrap();
    TcpListener::from_std(socket.into()).unwrap()
}

#[tokio::main]
async fn main() {
    let addr: SocketAddr = "0.0.0.0:25565".parse().unwrap();
    let cpus = num_cpus::get();

    let handles: Vec<_> = (0..cpus)
        .map(|_| tokio::spawn(accept_loop(build_listener(addr))))
        .collect();

    futures::future::join_all(handles).await;
}

Ожидаемый прирост

Ядра Без SO_REUSEPORT С SO_REUSEPORT
1 20k conn/s 20k conn/s
4 22k conn/s 78k conn/s
8 23k conn/s 155k conn/s

Buffer Pool - без heap allocation на каждый пакет

// src/pool.rs - пул буферов, переиспользуем вместо Vec::new()
// ⚠ ВАЖНО: tokio::sync::Mutex блокирует async runtime в hot path.
// Используем crossbeam::ArrayQueue - lock-free, не блокирует.

use crossbeam::queue::ArrayQueue;
use std::sync::Arc;

pub struct BufferPool {
    pool: Arc<ArrayQueue<Vec<u8>>>,
    buf_size: usize,
}

impl BufferPool {
    pub fn new(capacity: usize, buf_size: usize) -> Self {
        let pool = ArrayQueue::new(capacity);
        for _ in 0..capacity {
            pool.push(vec![0u8; buf_size]).ok();
        }
        Self { pool: Arc::new(pool), buf_size }
    }

    // Не async! Не блокирует runtime.
    pub fn acquire(&self) -> Vec<u8> {
        self.pool.pop().unwrap_or_else(|| vec![0u8; self.buf_size])
    }

    // Не async! Не блокирует runtime.
    pub fn release(&self, mut buf: Vec<u8>) {
        buf.clear();
        let _ = self.pool.push(buf); // игнорируем если полон
    }
}

DashMap - lock-free concurrent HashMap

// Блэклист и rate limit - читаются на каждый пакет
// RwLock<HashMap> создаёт contention под нагрузкой
// DashMap решает это через шарды

use dashmap::DashMap;

pub struct Blacklist {
    // 64 шарда, каждый со своим RwLock
    // Разные IP попадают в разные шарды → нет contention
    ips: DashMap<Ipv4Addr, BanEntry>,
}

impl Blacklist {
    pub fn is_blocked(&self, ip: Ipv4Addr) -> bool {
        if let Some(entry) = self.ips.get(&ip) {
            if entry.expires > Instant::now() {
                return true;
            }
            drop(entry);
            self.ips.remove(&ip); // expired
        }
        false
    }
}

io_uring - async I/O нового поколения (v0.4+, future optimization)

Текущий код на tokio (epoll). io_uring - future optimization для edge нод.

epoll vs io_uring

epoll (tokio сейчас):
  read() → syscall → копирование в userspace buf → возврат
  На каждую операцию: минимум 1 syscall + 1 копия

io_uring:
  Кладём запросы в submission queue (shared memory)
  Ядро обрабатывает батчем, результаты в completion queue
  Нет syscall per operation (только sq_enter раз в батч)
  Нет копирования (registered buffers)

Когда разница заметна

10k соединений:  epoll ≈ io_uring (разница < 5%)
100k соединений: io_uring +15-20%
1M соединений:   io_uring +35-40%

Рантаймы сравнение

Рантайм Базируется на Когда использовать
tokio (текущий) epoll v0.1-v0.3, универсально
tokio-uring io_uring v0.4+, Linux only
glommio io_uring, thread-per-core v0.5+, высокая изоляция
monoio io_uring, Tencent v0.6+, максимальная пропускная способность

Реализация через feature flag

# Cargo.toml
[features]
default = []
io-uring = ["dep:tokio-uring"]

[dependencies]
tokio        = { version = "1", features = ["full"] }
tokio-uring  = { version = "0.5", optional = true }
// src/runtime.rs
pub fn run(config: Config) {
    #[cfg(feature = "io-uring")]
    {
        tracing::info!("Запуск с io_uring runtime");
        tokio_uring::start(async { crate::edge::run(config).await });
    }

    #[cfg(not(feature = "io-uring"))]
    {
        tracing::info!("Запуск с epoll (tokio)");
        tokio::runtime::Builder::new_multi_thread()
            .worker_threads(num_cpus::get())
            .enable_all()
            .build()
            .unwrap()
            .block_on(crate::edge::run(config));
    }
}
# Обычная сборка (epoll, работает везде)
cargo build --release

# С io_uring (Linux 5.10+)
cargo build --release --features io-uring

Registered Buffers

// Регистрируем буферы один раз в ядре
// Потом read/write используют эти буферы без копирования
use tokio_uring::buf::IoBuf;

let buffers: Vec<Vec<u8>> = (0..1024)
    .map(|_| vec![0u8; 4096])
    .collect();

// io_uring читает прямо в зарегистрированный буфер
// Нет copy_to_user, нет дополнительной аллокации
let (result, buf) = stream.read(buf).await;

Ограничения io_uring

✗ Только Linux (macOS/Windows → epoll fallback)
✗ Требует kernel 5.10+ (stable features)
✗ Некоторые VDS провайдеры блокируют io_uring
  (проверь: cat /proc/sys/kernel/io_uring_disabled)

NUMA-aware allocation (для 2-сокетных серверов)

Актуально для bare metal с 2 физическими CPU (NUMA topology)

// Привязываем воркеры к NUMA нодам
// Память аллоцируется близко к CPU который её использует

use nix::sched::{sched_setaffinity, CpuSet};

fn pin_to_numa_node(worker_id: usize, numa_node: usize) {
    let mut cpuset = CpuSet::new();
    // NUMA node 0: CPU 0-7, NUMA node 1: CPU 8-15 (пример)
    let cpu_start = numa_node * 8;
    let cpu_for_worker = cpu_start + (worker_id % 8);
    cpuset.set(cpu_for_worker).unwrap();
    sched_setaffinity(Pid::from_raw(0), &cpuset).unwrap();
}

Для обычных VDS (1 NUMA нода) - не нужно.


Profiling в production

# tokio-console - live view async tasks
# Запускаем edge с поддержкой tokio-console
TOKIO_CONSOLE_BIND=10.0.100.1:6669 ./rampart-edge

# На своей машине
tokio-console http://10.0.100.1:6669

# Parca - continuous profiling (CPU flame graphs)
docker run -p 7070:7070 ghcr.io/parca-dev/parca:latest
# Смотрим в браузере: http://localhost:7070

# perf (Linux)
perf record -g -p $(pgrep rampart-edge) -- sleep 30
perf report --stdio | head -50

# Flamegraph
cargo flamegraph --bin rampart-edge

Сводная таблица оптимизаций

Техника Прирост Версия Сложность
SO_REUSEPORT 4x на 4 ядрах v0.1 Низкая
DashMap вместо RwLock 2x при contention v0.1 Низкая
Buffer pool -30% alloc v0.2 Средняя
Zero-copy splice -50% CPU на трафик v0.2 Средняя
io_uring +30-40% conn/s v0.4 Высокая
XDP 10x дроп rate v0.4 Высокая
NUMA pinning +10-20% на 2P сервере v0.6 Высокая