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
23 changes: 1 addition & 22 deletions benches/compare_event_loops.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,20 +130,6 @@ def parse_args() -> argparse.Namespace:
default=1024,
help="Payload size in bytes for the tcp_streams workload",
)
stream_mode = parser.add_mutually_exclusive_group()
stream_mode.add_argument(
"--rsloop-fast-streams",
dest="rsloop_fast_streams",
action="store_true",
help="Use rsloop's native stream wrappers for tcp_streams (default).",
)
stream_mode.add_argument(
"--no-rsloop-fast-streams",
dest="rsloop_fast_streams",
action="store_false",
help="Use the stdlib asyncio streams layer for tcp_streams on rsloop.",
)
parser.set_defaults(rsloop_fast_streams=True)
parser.add_argument(
"--json-output",
type=str,
Expand Down Expand Up @@ -544,8 +530,6 @@ def run_child(
env["PYTHONPATH"] = os.pathsep.join(
filter(None, (os.path.dirname(script_path), env.get("PYTHONPATH")))
)
if loop_name == "rsloop":
env["RSLOOP_USE_FAST_STREAMS"] = "1" if args.rsloop_fast_streams else "0"
proc = subprocess.run(
cmd,
check=False,
Expand Down Expand Up @@ -687,12 +671,7 @@ def parent_main(args: argparse.Namespace) -> int:
for workload in selected_workloads:
workload_runs: dict[str, list[ChildResult]] = {}
if workload == "tcp_streams":
stream_mode = (
"rsloop native fast streams"
if args.rsloop_fast_streams
else "stdlib asyncio streams"
)
print(f"tcp_streams mode: {stream_mode}")
print("tcp_streams mode: rsloop native fast streams")
for loop_name in available_loops:
print(f"Running {workload} on {loop_name}...")
if profile_rsloop_dir and loop_name == "rsloop":
Expand Down
2 changes: 0 additions & 2 deletions benches/workload_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -1176,8 +1176,6 @@ def run_child_batch(
env["PYTHONPATH"] = os.pathsep.join(
filter(None, (str(Path(__file__).resolve().parent), env.get("PYTHONPATH")))
)
if loop_name == "rsloop":
env["RSLOOP_USE_FAST_STREAMS"] = "1"
cmd = child_command(args, loop_name, scenario, child_runs)
if profile_output is not None:
cmd = sampling_profiler_command(cmd, profile_output)
Expand Down
5 changes: 3 additions & 2 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ Times are milliseconds; lower is better.
| 5,000 TCP roundtrips | 151.77 | 127.87 | 104.26 | **83.30** |

The TCP workload uses 1,024-byte payloads and rsloop's native fast streams;
the other loops use stdlib asyncio streams. Use `--no-rsloop-fast-streams`
to compare all loops through the stdlib streams layer. Zuvloop led callbacks
the other loops use stdlib asyncio streams. Current rsloop always uses native
streams, and the former stream-mode benchmark switches have been removed.
Zuvloop led callbacks
and tasks in this run, while rsloop led TCP roundtrips. These are local
microbenchmarks, not isolated-lab measurements or general application performance
claims; do not compare them directly with the historical macOS results below.
Expand Down
35 changes: 17 additions & 18 deletions docs/fast-streams.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
# Fast Streams

Importing `rsloop` patches `asyncio.open_connection()` and
`asyncio.start_server()` by default.

That import-time behavior is controlled by `RSLOOP_USE_FAST_STREAMS` and can be
disabled with:

```bash
export RSLOOP_USE_FAST_STREAMS=0
```

The native fast-stream path is used only when:

- the running loop is an `rsloop.Loop`
- `ssl` is unset or `None`

Otherwise `rsloop` falls back to the standard-library `asyncio.streams`
helpers.

On that path the reader handed to your code is the native
`asyncio.start_server()`, including their `asyncio.streams` entry points.
On an `rsloop.Loop`, these helpers always use native fast streams for both
plaintext TCP and TLS. There is no stream-mode option or automatic stdlib
fallback on rsloop; the former `RSLOOP_USE_FAST_STREAMS` environment variable
has no effect. Connection and TLS errors propagate to the caller.

The helpers remain coroutine functions and select the running loop when
awaited, so they can be created before `run()` and passed to `create_task()`.
Calls running on other event loops continue to use those loops' standard
asyncio stream helpers.

The reader handed to your rsloop code is the native
`PyFastStreamReader` rather than `asyncio.StreamReader`. It implements the
reading surface protocols actually use:

Expand All @@ -34,6 +28,11 @@ left in the buffer afterwards. `tests/test_stream_reader.py` pins that behavior
by driving the same feed scripts through both readers and comparing the
results.

Native writers support `write`, `writelines`, `drain`, `close`, `wait_closed`,
`is_closing`, `get_extra_info`, `write_eof`, and `can_write_eof`. TLS transports
do not support half-close; TLS peer EOF closes the stream instead of leaving
its write side open. TLS negotiation continues to use rsloop's TLS transport.

The implementation lives in `src/transport/stream/fast.rs` and is backed by
the lower-level transport code in `src/transport/stream/mod.rs`.

Expand Down
12 changes: 4 additions & 8 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,11 @@ Importing `rsloop` does a little setup work:

- it boots the native extension
- it patches `asyncio.set_event_loop(...)` for compatibility, especially on older Python versions
- it can patch `asyncio.open_connection(...)` and `asyncio.start_server(...)` to use `rsloop`'s fast stream path
- it patches `asyncio.open_connection(...)` and `asyncio.start_server(...)` to use native fast streams on rsloop, including TLS

That fast stream behavior is controlled by `RSLOOP_USE_FAST_STREAMS`.

Disable it like this:

```bash
export RSLOOP_USE_FAST_STREAMS=0
```
Native fast streams are automatic on rsloop, with no mode switch or stdlib
fallback. Other event loops retain their standard stream helpers. See
[Fast Streams](fast-streams.md) for the supported stream interface.

## Useful examples

Expand Down
2 changes: 1 addition & 1 deletion docs/hotpath-lab.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ This harness does not apply model output or create commits automatically.
emitted. SHA-256 inventories detect changed source/package/compiler outputs.
- Every benchmark child imports the selected artifact first and checks the
actual native extension path and hash. The development installation is not
used. `PYTHONHASHSEED=0`, fast streams enabled, and GC disabled during both
used. `PYTHONHASHSEED=0`, native streams, and GC disabled during both
measured and warmup workloads are explicit harness choices.
- PGO builds use an explicit native Cargo `--target` so build scripts and proc
macros are not instrumented. Training sets `LLVM_PROFILE_FILE` only in workload
Expand Down
7 changes: 3 additions & 4 deletions docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,9 @@ These gaps are visible in the current implementation:

- TLS uses a `rustls` backend with a narrower compatibility surface than
CPython's OpenSSL-backed `ssl` module. In particular, encrypted private keys
are not supported yet, and the fast-stream monkeypatch still falls back to
standard-library helpers whenever `ssl` is enabled. TLS transport internals
also still use helper-thread paths instead of the runtime-thread `vibeio`
socket path.
are not supported yet. TLS uses native fast streams, but its transport
internals still use helper-thread paths instead of the runtime-thread
`vibeio` socket path.
- Subprocess support still has one notable gap: `preexec_fn` remains unsupported
because running arbitrary Python between `fork()` and `exec()` is unsafe in
this runtime model.
Expand Down
132 changes: 132 additions & 0 deletions docs/pypi-0.1.54-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# PyPI rsloop 0.1.54 benchmark

On October 4, 2026, the live [PyPI JSON API](https://pypi.org/pypi/rsloop/json)
reported [0.1.54](https://pypi.org/project/rsloop/0.1.54/) as the latest release.
The CPython 3.14 Linux wheel was uploaded at `2026-10-04T09:19:49.632695Z`.

Downloaded and benchmarked:

```text
rsloop-0.1.54-cp314-cp314-manylinux_2_39_x86_64.whl
SHA-256: aabab6f48b949810ae82716e342398484b111ac314172722751ac061575cc443
```

The checksum matches PyPI metadata. The wheel was installed with `uv pip install
--target` under `target/pypi-speed-check/pypi/package`, and its import path and
distribution version were verified. The development installation was preserved.
The wheel's `Handle` and `TimerHandle` object sizes are 80 and 56 bytes,
respectively, matching the optimized layouts on the local branch. Matching
layouts do not establish identical source or build settings.

## Standard loop comparison

Linux x86-64, Intel Core i9-9900K, GIL-enabled CPython 3.14.7, uvloop 0.22.1.
The repository's `benches/compare_event_loops.py` ran seven measured fresh
processes after two warmups per loop/workload. Times are medians; lower is better.

| Workload | asyncio | uvloop | PyPI rsloop 0.1.54 |
| --- | ---: | ---: | ---: |
| 200,000 callbacks | 106.30 ms | 51.06 ms | 43.87 ms |
| 50,000 tasks, batches of 5,000 | 141.76 ms | 87.40 ms | 85.42 ms |
| 5,000 TCP roundtrips, 1 KiB | 142.74 ms | 117.73 ms | 77.31 ms |

The default TCP comparison uses rsloop's native fast streams and stdlib streams
for the other loops. These are short, sequential microbenchmarks, not paired
application-level comparisons. In particular, the small task difference from
uvloop should not be generalized into an overall performance advantage.

A separate run disabled rsloop's native fast streams, using the stdlib streams
layer for all loops (same 5,000 roundtrips, 1 KiB, seven runs/two warmups):

| asyncio | uvloop | PyPI rsloop 0.1.54 |
| ---: | ---: | ---: |
| 147.30 ms | 117.68 ms | 125.93 ms |

Here uvloop had the lowest median; rsloop took 7.0% longer. The default native
stream result therefore should not be presented as a win with every streams
configuration. These separate short runs remain descriptive measurements.

These commands used the benchmark scripts at `a570b9f`. The stream-mode switches
were subsequently removed when native streams became mandatory on rsloop.
Use that revision or the archived harness to reproduce this historical check.

```bash
PYTHONPATH="$PWD/target/pypi-speed-check/pypi/package" \
.venv/bin/python benches/compare_event_loops.py \
--loops asyncio,uvloop,rsloop --workloads callbacks,tasks,tcp_streams \
--warmups 2 --repeat 7 \
--json-output target/pypi-speed-check/event-loops.json
PYTHONPATH="$PWD/target/pypi-speed-check/pypi/package" \
.venv/bin/python benches/compare_event_loops.py \
--loops asyncio,uvloop,rsloop --workloads tcp_streams \
--no-rsloop-fast-streams --warmups 2 --repeat 7 \
--json-output target/pypi-speed-check/stdlib-tcp.json
```

## Published wheel versus local branch

The local binary is a normal `maturin develop --release` build of `a570b9f`
on `perf/hotpath-rs-profiling` (project version 0.1.53). It was copied into a
second isolated package directory. Both ELF `.comment` sections report Rust
1.100.0-nightly (`f7575a9da`, September 24), GCC 13.3.0, and LLD 23.1.1. All four
packaged Python `.py` files have identical hashes. The complete published build
settings and source revision are unavailable, so this compares two distributable
binaries rather than isolating the effect of source edits.

The comparison reuses `scripts/hotpath_lab.py` workload configurations,
`run_sample`, balanced AB/BA orders, and paired bootstrap statistics. An archived
driver runs 12 shuffled paired blocks across eight workloads, with one full
warmup per fresh process. Every child verifies the selected native extension's
path and package inventory. Holdout workload sizes keep measured runs longer
than the short standard comparison above. GC is disabled during workloads.

The ordinary lab comparison intentionally requires matching compiler metadata
and flags. The published manifest records build provenance as unknown; embedded
ELF compiler labels are preserved separately. This distribution comparison uses
a separate driver, without applying the source-change promotion gate.
Both manifests and all benchmark sources are archived. No compilation or tests
run concurrently with timings; CPU frequency and unrelated host load are not
controlled.

All 12 blocks completed. The table shows median elapsed time and the paired
change from the published wheel to the local branch; negative means the branch
is faster. The paired change uses log-time ratios, not the ratio of medians.
Bootstrap intervals adjust across eight workloads and two metrics with a 95%
family confidence target. Every measured sample lasted at least 0.38 seconds.

| Workload | PyPI 0.1.54 | Branch `a570b9f` | Branch timing change (adjusted interval) |
| --- | ---: | ---: | ---: |
| 3,000,000 callbacks | 655.43 ms | 650.93 ms | -0.7% [-2.4%, +0.8%] |
| 350,000 tasks, batches of 777 | 584.30 ms | 583.41 ms | -1.0% [-2.8%, +0.3%] |
| 25,000 native TCP roundtrips, 8 KiB | 485.43 ms | 490.18 ms | +0.2% [-1.1%, +1.6%] |
| HTTP, 7 connections, 4,000 requests each | 495.83 ms | 498.93 ms | +1.1% [-1.4%, +4.0%] |
| 1,200,000 retained timers | 414.53 ms | 418.46 ms | +1.0% [-0.05%, +2.3%] |
| 1,200,000 discarded-handle timers | 388.67 ms | 386.12 ms | -1.2% [-3.2%, +0.05%] |
| 1,200,000 cancelled timers | 404.27 ms | 405.51 ms | +0.3% [-1.1%, +1.8%] |
| 4,000 generic TCP connections, 8 KiB | 693.48 ms | 691.55 ms | +0.1% [-2.0%, +2.0%] |

Timer batch size is 777. All timing intervals include zero: this experiment
does not establish a speed difference between the published wheel and the
local branch. This is not proof of exact equivalence or of identical source.

The published wheel's median peak RSS was about 0.6–0.9 MiB lower in most
workloads. For example, tasks used 33.42 versus 34.30 MiB and callbacks used
515.84 versus 516.50 MiB. Connection-churn peak RSS was essentially unchanged.
RSS includes the warmup and reflects the GC-disabled benchmark configuration;
these are whole-process measurements, not Python object-size measurements.

## Local artifacts

`target/pypi-speed-check/` contains the original wheel, full PyPI metadata,
selected-wheel metadata and digest, isolated packages, standard benchmark JSON
and log, comparison driver, plans, raw samples, and archived harness sources.
The driver is `target/pypi-speed-check/compare_distributions.py`; it refuses to
overwrite its `paired/` output directory.

Install the verified wheel into its isolated directory with:

```bash
uv pip install --python .venv/bin/python --no-deps \
--target target/pypi-speed-check/pypi/package \
target/pypi-speed-check/rsloop-0.1.54-cp314-cp314-manylinux_2_39_x86_64.whl
```
13 changes: 1 addition & 12 deletions examples/fastapi_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import argparse
import asyncio
import importlib
import os
import signal
import sys
import time
Expand All @@ -31,11 +30,6 @@ def normalize_event_loop(value: str) -> str:
return value


def configure_rsloop_fast_streams(loop_name: str) -> None:
if normalize_event_loop(loop_name) == "rsloop":
os.environ["RSLOOP_USE_FAST_STREAMS"] = "1"


def loop_factory_for(loop_name: str) -> EventLoopFactory:
normalized = normalize_event_loop(loop_name)
if normalized == "asyncio":
Expand Down Expand Up @@ -103,11 +97,7 @@ def current_loop_payload() -> dict[str, str]:
loop = asyncio.get_running_loop()
stream_mode = "stdlib asyncio streams"
if app.state.selected_event_loop == "rsloop":
stream_mode = (
"rsloop fast streams"
if os.environ.get("RSLOOP_USE_FAST_STREAMS", "1") != "0"
else "stdlib asyncio streams"
)
stream_mode = "rsloop fast streams"
return {
"selected": app.state.selected_event_loop,
"module": type(loop).__module__,
Expand Down Expand Up @@ -267,7 +257,6 @@ def parse_args() -> argparse.Namespace:

def main() -> int:
args = parse_args()
configure_rsloop_fast_streams(args.event_loop)
app.state.selected_event_loop = normalize_event_loop(args.event_loop)
previous_sigint_handler = signal.getsignal(signal.SIGINT)

Expand Down
30 changes: 25 additions & 5 deletions python/rsloop/_loop_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
from ._loop import PyLoop as Loop
from ._loop import __version__ as __version__
from ._loop import build_info as build_info
from ._loop import open_connection as __open_connection
from ._loop import open_connection as __native_open_connection
from ._loop import reset_transport_stats as reset_transport_stats
from ._loop import start_server as __start_server
from ._loop import start_server as __native_start_server
from ._loop import transport_stats as transport_stats

_T = __typing.TypeVar("_T")
Expand Down Expand Up @@ -66,14 +66,34 @@ def __set_event_loop(loop: Loop | None) -> None:
__ORIG_SHUTDOWN_ASYNCGENS = Loop.shutdown_asyncgens
__ORIG_CLOSE: __typing.Any = Loop.close
__ORIG_CREATE_TASK = Loop.create_task
__USE_FAST_STREAMS = __os.environ.get("RSLOOP_USE_FAST_STREAMS", "1") != "0"
__ASYNCGEN_STATE: dict[Loop, dict[str, __typing.Any]] = {}
__LOOP_CONFIG: dict[Loop, dict[str, __typing.Any]] = {}

if __USE_FAST_STREAMS and __asyncio.open_connection is __ORIG_OPEN_CONNECTION:

async def __open_connection(host=None, port=None, *, limit=2**16, **kwargs):
# Select the implementation when awaited, so calls created before run()
# still use the implementation belonging to the running event loop.
if isinstance(__asyncio.get_running_loop(), Loop):
return await __native_open_connection(host, port, limit=limit, **kwargs)
return await __ORIG_OPEN_CONNECTION(host, port, limit=limit, **kwargs)


async def __start_server(client_connected_cb, host=None, port=None, *, limit=2**16, **kwargs):
if isinstance(__asyncio.get_running_loop(), Loop):
return await __native_start_server(
client_connected_cb, host, port, limit=limit, **kwargs
)
return await __ORIG_START_SERVER(client_connected_cb, host, port, limit=limit, **kwargs)


if __asyncio.open_connection is __ORIG_OPEN_CONNECTION:
__asyncio.open_connection = __open_connection
if __USE_FAST_STREAMS and __asyncio.start_server is __ORIG_START_SERVER:
if __asyncio.streams.open_connection is __ORIG_OPEN_CONNECTION:
__asyncio.streams.open_connection = __open_connection
if __asyncio.start_server is __ORIG_START_SERVER:
__asyncio.start_server = __start_server
if __asyncio.streams.start_server is __ORIG_START_SERVER:
__asyncio.streams.start_server = __start_server

_asyncio = __asyncio
_io = __io
Expand Down
1 change: 0 additions & 1 deletion scripts/hotpath_lab.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,6 @@ def run_sample(
profile_pattern: Path | None = None,
) -> dict:
env = clean_environment()
env["RSLOOP_USE_FAST_STREAMS"] = "1"
if profile_pattern:
env["LLVM_PROFILE_FILE"] = str(profile_pattern)
command = [
Expand Down
Loading
Loading