Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,7 +473,7 @@ Expose runtime metrics (inflight buffers, queue lengths, completion latency, buf

---

## Native Task Scheduling (Phase 3 + Phase 10)
## Native Task Scheduling

`uringcore` moves the scheduling logic entirely to Rust to reduce Python overhead.

Expand All @@ -490,7 +490,7 @@ Expose runtime metrics (inflight buffers, queue lengths, completion latency, buf

---

## Performance Bottleneck Analysis (Phase 11)
## Performance Bottleneck Analysis

### The PyO3 Boundary Problem

Expand Down Expand Up @@ -525,7 +525,7 @@ To match `uvloop`, uringcore would need:
2. Rust-native coroutine iteration without Python callbacks
3. This is a fundamental architectural change

## Native Futures (Phase 5)
## Native Futures

Traditional `asyncio.Future` is implemented in Python (with a C accelerator). `uringcore` implements `UringFuture` entirely in Rust (`#[pyclass]`).

Expand Down Expand Up @@ -585,8 +585,16 @@ The following state-of-the-art optimizations have been implemented or are availa
|--------|-----------|--------|---------|
| `sleep(0)` | 5.24 µs | 12.20 µs | **2.3x** |
| `create_task` | 8.97 µs | 13.46 µs | **1.5x** |
| `sock_sendto` (throughput) | 831k ops/s | 550k ops/s | **1.5x** |
| `gather(100)` | 139 µs | 105 µs | 0.75x |

### Hybrid Syscall Strategy
For latency-sensitive or high-throughput non-blocking operations like `sock_sendto` (UDP), `uringcore` employs a hybrid strategy:
1. **Optimistic Syscall**: Attempt a direct non-blocking system call (`sendto`) first.
2. **Success**: If successful (buffer space available), return immediately. This bypasses the overhead of creating a Future and submitting to the io_uring SQ (saving ~1-2µs per op).
3. **Fallback**: If `EAGAIN`/`EWOULDBLOCK` is returned, fall back to the robust `io_uring` path: create a Future, submit `IORING_OP_POLL_ADD`/`IORING_OP_SEND`, and await completion.
This approach yields **~831k ops/sec** vs standard `asyncio`'s ~550k ops/sec.

---

## Future Work
Expand All @@ -598,7 +606,7 @@ The following state-of-the-art optimizations have been implemented or are availa

---

## Phase 14: Stress Testing & Robustness
## Stress Testing & Robustness

### Timer Handle Cancellation Fix
During stress testing, a critical issue was identified where `asyncio.TimerHandle` objects were executed by the Rust scheduler even after being cancelled in Python. This occurred because `TimerHandle.cancel()` clears the callback arguments (`_args = None`), leading to a `TypeError` when the Rust scheduler blindly invoked `_run()`.
Expand Down
30 changes: 16 additions & 14 deletions BENCHMARK.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,49 +10,51 @@ This document presents performance benchmarks comparing `uringcore` against stan

| Category | uringcore vs asyncio | uringcore vs uvloop |
|----------|---------------------|---------------------|
| Basic operations (sleep, futures) | Competitive | Faster (1.5-2x) |
| Task scheduling (call_soon, call_later) | Faster | Faster (1.2-4x) |
| Synchronization primitives | Faster | Faster (2-3x) |
| High concurrency (gather 100+) | ⚠️ Slightly Slower | ⚠️ Slower (0.6x) |
| Socket I/O | Faster than asyncio | Competitive with uvloop |
| Basic operations (sleep, futures) | Competitive | Faster (1.5-2x) |
| Task scheduling (call_soon, call_later) | Faster | Faster (1.2-4x) |
| Synchronization primitives | Faster | Faster (2-3x) |
| High concurrency (gather 100+) | Slightly Slower | Slower (0.6x) |
| Socket I/O | Faster than asyncio | Competitive with uvloop |

### Detailed Results (µs/op, lower is better)

```
Benchmark | asyncio | uvloop | uringcore
---------------------------------------------------------------
sleep(0) | 173.0µs | 105.0µs | 152.0µs
gather(100) | 173.0µs | 105.0µs | 138.9µs ✅
sock_pair | 32.5µs | 42.9µs | 35.0µs ✅
call_later | 59.6µs | 17.5µs | 13.5µs ⭐
sleep(0) | 5.0µs | 105.0µs | 3.4µs
gather(100) | 165.8µs | 105.0µs | 156.9µs
sock_pair | 23.9µs | 42.9µs | 20.0µs
sock_sendto (UDP) | ~550k ops/s | N/A [*] | ~831k ops/s
call_later | 59.6µs | 17.5µs | 13.5µs

⭐ = Competitive or close to best (uringcore results for sleep/gather are from micro-benchmark tests/bench_gather.py)
[*] uvloop does not implement sock_sendto (NotImplementedError).
```

## Analysis

### Why is gather(100) slower than uvloop? (153µs vs 105µs)
### Why is gather(100) slower than uvloop? (157µs vs 105µs)

**Root Cause**: Architectural decision to use standard `asyncio.Task`.
- **uvloop**: Re-implements `Task` and `Future` completely in C/Cython. When a task yields, uvloop stays in C-land to schedule the next one, bypassing the Python interpreter's overhead for the scheduling logic itself.
- **uringcore**: Uses Python's standard `asyncio.Task` for 100% ecosystem compatibility. Every task step requires control to pass from Rust -> Python Interpreter -> Python Task Object -> Rust.

**Data**:
- **Syscall Efficiency**: `uringcore` makes **1,979** syscalls vs `uvloop`'s **52,587** for the `gather(100)` benchmark. This represents **26x greater efficiency** at the system level.
- **Latency Gap**: The ~48µs gap is purely userspace FFI (Foreign Function Interface) and Python object manipulation overhead.
- **Latency Gap**: The ~50µs gap is purely userspace FFI (Foreign Function Interface) and Python object manipulation overhead.

**Decision**:
Re-implementing `Task` in Rust (like uvloop did in Cython) was deliberately avoided for V1.0.
- **Pros**: It would close the 40µs gap.
- **Pros**: It would close the gap.
- **Cons**: It would break compatibility with tools that inspect `asyncio.Task` (debuggers, instrumentation, `nest_asyncio`, etc.) and increase complexity massively.
- **Trade-off**: `uringcore` is faster than `asyncio` (1.13x) and significantly more scalable for real-world I/O (where syscalls matter more than micro-scheduling latency), while maintaining robust compatibility.
- **Trade-off**: `uringcore` is competitive with `asyncio` (1.06x faster) and significantly more scalable for real-world I/O (where syscalls matter more than micro-scheduling latency), while maintaining robust compatibility.

### Strengths

1. **Kernel-bypass I/O**: `io_uring` eliminates syscall overhead for I/O operations (proved by strace).
2. **Low-latency primitives**: Semaphore, lock, and event operations are fastest.
3. **Timer efficiency**: `call_later` is significantly faster than asyncio (4x).
4. **Native Rust implementation**: Zero-copy buffer handling and lock-free scheduling.
5. **Optimistic Syscalls**: `sock_sendto` attempts direct non-blocking syscalls first, falling back to `io_uring` only on `EAGAIN`, beating standard `asyncio` significantly in throughput.

## Methodology

Expand Down
11 changes: 6 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "uringcore"
version = "1.0.0"
edition = "2021"
edition = "2024"
authors = ["Ankit Kumar Pandey <ankitkpandey1@gmail.com>"]
description = "Completion-driven asyncio event loop using io_uring"
license = "Apache-2.0"
Expand All @@ -15,18 +15,17 @@ name = "uringcore"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.23", features = ["extension-module"] }
pyo3 = { version = "0.27", features = ["extension-module"] }
io-uring = "0.7"
libc = "0.2"
parking_lot = "0.12"
crossbeam-channel = "0.5"
crossbeam-queue = "0.3"
nix = { version = "0.29", features = ["fs", "process", "event"] }
nix = { version = "0.30", features = ["fs", "process", "event"] }
thiserror = "2.0"
tracing = "0.1"

[dev-dependencies]
tempfile = "3.14"
tempfile = "3.24"

[profile.release]
lto = true
Expand All @@ -46,3 +45,5 @@ cargo = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
collapsible_if = "allow"
collapsible_else_if = "allow"
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@

A high-performance asyncio event loop for Linux using io_uring.

## Project Status
**Current Phase:** Phase 15 (Final Polish & Release)

`uringcore` is a high-performance, drop-in replacement for `asyncio` on Linux.
It passes **all tests** including proper stress testing and FastAPI/Starlette E2E tests, and outperforms `uvloop` in single-task latency benchmarks.
Expand All @@ -22,6 +20,7 @@ It passes **all tests** including proper stress testing and FastAPI/Starlette E2
- **Asyncio Function Caching**: Cached `_enter_task`/`_leave_task` to reduce per-step overhead.
- **Registered FD Table**: `IOSQE_FIXED_FILE` support for zero FD lookup overhead.
- **Zero-Copy Send**: `IORING_OP_SEND_ZC` for large payload efficiency (kernel 6.0+).
- **Optimistic Syscalls**: Direct non-blocking syscalls for UDP fast-path (830k+ ops/sec).
- **Multishot Recv**: `RECV_MULTISHOT` for persistent connections (kernel 5.19+).
- **Native Timers**: `IORING_OP_TIMEOUT` for zero-syscall timer management.
- **Strict Resource Management**: Deterministic cleanup via `Drop` trait.
Expand Down Expand Up @@ -90,7 +89,7 @@ The implementation leverages a completion-driven architecture rather than the tr

- Linux kernel 5.11+ (5.19+ recommended for `RECV_MULTI` optimizations)
- Python 3.10+
- Rust 1.70+
- Rust 1.85+ (Edition 2024)

**SQPOLL Mode:** Requires `CAP_SYS_ADMIN` or kernel 5.12+ with unprivileged SQPOLL. SQPOLL often requires elevated privileges and may be unavailable on managed/cloud hosts; uringcore auto-detects SQPOLL capability and falls back to batched `io_uring_enter` when unsupported. This fallback is automatic and requires no configuration.

Expand Down
28 changes: 28 additions & 0 deletions benchmarks/quick_bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@

import sys
import os
from unittest.mock import patch

# Add current directory to sys.path
sys.path.append(os.getcwd())

# Set low limits for restricted environment
os.environ.setdefault("URINGCORE_BUFFER_COUNT", "128")
os.environ.setdefault("URINGCORE_BUFFER_SIZE", "4096")

import benchmarks.benchmark_suite as suite

# Reduce iterations for speed
suite.BENCHMARKS = [
(suite.bench_sleep_zero, "sleep(0)", 100),
(suite.bench_create_task, "create_task", 100),
(suite.bench_gather_10, "gather(10)", 50),
(suite.bench_gather_100, "gather(100)", 20),
(suite.bench_queue_put_get, "queue_put", 100),
(suite.bench_event_set_wait, "event_wait", 100),
(suite.bench_future_result, "future_res", 100),
(suite.bench_sock_pair, "sock_pair", 100) if hasattr(suite, 'bench_sock_pair') else (suite.bench_socketpair_overhead, "sock_pair", 100),
]

if __name__ == "__main__":
suite.main()
138 changes: 138 additions & 0 deletions benchmarks/udp_bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@

import asyncio
import socket
import time
import uringcore
import os

async def benchmark_udp(loop_factory, name):
print(f"Benchmarking {name}...")
loop = loop_factory()
asyncio.set_event_loop(loop)

server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(('127.0.0.1', 0))
server.setblocking(False)
server_addr = server.getsockname()

client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.setblocking(False)

data = b"x" * 1024
N = 100000

async def run():
start = time.perf_counter()
for _ in range(N):
await loop.sock_sendto(client, data, server_addr)
await loop.sock_recvfrom(server, 4096)
end = time.perf_counter()

duration = end - start
ops = N / duration
print(f"{name}: {ops:.2f} ops/sec, {duration:.2f}s total")
return ops

try:
ops = loop.run_until_complete(run())
finally:
server.close()
client.close()
loop.close()
return ops

def main():
import sys

# Benchmark asyncio
if sys.version_info >= (3, 11):
print("Benchmarking asyncio...")
try:
asyncio.run(benchmark_udp(asyncio.new_event_loop, "asyncio"))
except Exception as e:
print(f"Asyncio bench failed: {e}")
else:
print("Asyncio sock_recvfrom/sendto requires Python 3.11+")

# Benchmark uvloop if available
try:
import uvloop
print("Benchmarking uvloop...")
# uvloop doesn't like being run inside asyncio.run if it replaces policy globally?
# Actually standard usage is fine.
asyncio.run(benchmark_udp(uvloop.new_event_loop, "uvloop"))
except ImportError:
print("uvloop not installed")
except Exception as e:
print(f"uvloop bench failed: {e}")

# Benchmark uringcore
print("Benchmarking uringcore...")
# uringcore loop needs to be created and used.
# asyncio.run creates a loop using the policy.
# We want to manually drive it for fair comparison logic in our func.

loop = uringcore.new_event_loop()
asyncio.set_event_loop(loop)
try:
# Re-using the logic inside benchmark_udp but adapted since we have an active loop
# We can't reuse benchmark_udp as is because it creates a NEW loop.
# Let's adapt benchmark_udp to NOT create loop if passed.
pass
except Exception:
pass
loop.close()

# Actually, simpler: define a runner wrapper
def run_benchmark_simple(name, loop_factory):
loop = loop_factory()
asyncio.set_event_loop(loop)
try:
# Copy body of benchmark logic or call separate async func
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(('127.0.0.1', 0))
server.setblocking(False)
server_addr = server.getsockname()

client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.setblocking(False)

data = b"x" * 1024
N = 100000

async def run():
start = time.perf_counter()
for _ in range(N):
await loop.sock_sendto(client, data, server_addr)
await loop.sock_recvfrom(server, 4096)
end = time.perf_counter()

duration = end - start
ops = N / duration
print(f"{name}: {ops:.2f} ops/sec, {duration:.2f}s total")

loop.run_until_complete(run())
finally:
try:
server.close()
client.close()
except: pass
loop.close()

if sys.version_info >= (3, 11):
run_benchmark_simple("asyncio", asyncio.new_event_loop)


try:
import uvloop
try:
run_benchmark_simple("uvloop", uvloop.new_event_loop)
except Exception as e:
print(f"uvloop failed (expected if sock_sendto ignored/unsupported): {e}")
except ImportError:
pass

run_benchmark_simple("uringcore", uringcore.new_event_loop)

if __name__ == "__main__":
main()
Loading