# Peregrine engineering and performance study

The measured version is Peregrine 1.1.7, on the same CPython 3.14.4 and uvloop
0.23.0 as Skirr. We reviewed its exact released source, identified in
[peregrine-source.json](peregrine-source.json), rather than assuming the earlier
upstream design snapshot matched the installed wheel.

## What the profiles establish

All profiles use user-space cycles at 499 Hz, with sampling delayed three
seconds and identical diagnostic HTTP workloads. Each report records zero lost
samples. Percentages are shares of each profile's sampled user-space cycles;
they are neither elapsed-time shares nor directly comparable absolute costs.
Inlined Rust dispatch work appears under `State::pump`; Swift splits comparable
work across more symbols. Unknown symbols remain visible. Kernel work is not
captured by `cycles:u`.

| Workload | Peregrine observations | Skirr baseline observations |
|---|---|---|
| Tiny responses | Python evaluation 10.04%; parser 2.42%; dict copy 2.20% | Inlined pump/dispatch 16.40%; send wrapper 4.40%; PyO3 deferred-reference machinery 2.94% |
| 4 KiB uploads | memcpy/memmove 10.90%; Python evaluation 9.00%; parser 3.79% | Inlined pump 10.34%; memmove 5.76%; deferred references 3.92%; send wrapper 3.77% |
| FastAPI JSON | Python evaluation 48.63% | Python evaluation 47.85% |

Peregrine is not faster simply because it copies less upload data: its profile
actually spends a larger fraction in copying. Its compact CPython trampolines,
ready awaitables, header spans, and reusable connection storage are useful
engineering leads. Both run on Python's loop. Its exact-release spawn glue uses
ordinary `loop.create_task`; Skirr already uses eager startup while preserving
custom task factories and request context isolation.

Peregrine's scope prototype shares an immutable ASGI 2.3 mapping proxy. Skirr
keeps fresh mutable ASGI 2.5 scope dictionaries and lifespan-state copies; this
round does not exchange scope isolation or protocol validation for speed.
FastAPI execution dominates both JSON profiles, limiting how much server-only
optimizations can improve that workload. A proper remaining bottleneck is
Skirr's callable/ownership plumbing, not a conclusion that Swift beats Rust.

## Retained changes

- Stop reading after a short read on the level-triggered poller. Further data
  remains eligible for a later notification; body/read budgets and RDHUP handling
  stay active.
- Keep request-header ranges in one owned block, avoiding separate allocations
  for each native name and value. Python scope bytes remain owned and isolated.
- Validate and serialize response headers once; reuse at most 64 KiB of their
  allocation after task completion. Preserve header snapshots, duplicates,
  Content-Length checks and bodyless/trailer rules.
- Decode upload segments as borrowed slices and copy directly into owned Python
  bytes before releasing the socket-buffer borrow. No borrowed socket data is
  retained by Python or the receive queue.
- Use native ready-awaitable slots, including `am_send`, to return ready values
  without a PyO3 iterator exception path. Completed sends reuse a stateless
  instance. A separate instance still yields at the unchanged fairness quota;
  blocked I/O still uses real Futures.
- Populate scope-prototype keys up front and format response numbers directly
  into the output buffer. There are still three direct Rust dependencies.

The native awaitable is isolated in `src/completed.rs`. It stores only None or
server-created, initially inaccessible dictionaries of immutable values; it
removes a value before returning it. Consequently it cannot hide a cycle back
to itself. Its type cannot be constructed or subclassed by applications. It
contains no Rust request pointers, and its reference operations run under the
GIL. This is not support for free-threaded Python.

For 1,001 sequential tiny requests, strace counted:

| Server | Socket reads | EAGAIN reads | Socket writes |
|---|---:|---:|---:|
| Saved Skirr | 1,864 | 861 | 1,001 |
| Retained Skirr | 1,003 | 0 | 1,001 |
| Peregrine | 1,003 | 0 | 1,001 |

Final Skirr profiles put the tiny-response send wrapper at 3.06% and pump at
10.81%. The upload copy share is 4.53%, and message construction 0.95%.
FastAPI evaluation remains 47.63%. These normalized observations guide further
work; only the uninstrumented repeated comparisons measure throughput gains.

## Rejected experiment and qualification

A compact identity-only vectorcall channel used a thread-local weak registry,
without storing thread-bound Rust pointers in Python. The two-repeat trial
was slower than saved Skirr by about 1.6% tiny, 2.1% upload, 0.4% JSON and 1.8%
bulk. It added substantially more native integration code, so it was removed.
Intermediate short trials are retained as diagnostics, not final comparisons;
`copy-experiment` overlapped a compiler check and is unsuitable for conclusions.

The installed wheel passes 45 socket/ASGI cases under standard asyncio and
uvloop with Python's debug allocator, three native parser tests, and strict
Clippy. Added cases cover 100/101-header boundaries, empty/obs-text/trimmed
values, retained scopes across pipelining, response-header snapshots, and
iterator-return values. The saved baseline also passes the expanded tests:
these protect behavior during optimization rather than claim new bug fixes.
An additional 200-server lifetime check consumed/discarded ready values,
released discarded objects on another Python thread and observed all native
heap types collected after shutdown. See `lifetime-check.txt`. A separate
AddressSanitizer build also passes all 45 cases under both loops, with leak
detection disabled for the embedding host; scope and commands are in
`asan-check.txt`.

The final throughput matrix, baseline source and binary hashes, per-run errors,
P99 and memory results are linked in
[the repeated benchmark report](../results/peregrine-round2-report.txt).
This remains an HTTP/1 prototype, not a complete ASGI feature implementation or
production/conformance certification. Broader sanitizer/leak, fuzzing, platform and
Python-version qualification remain release gates.

During the initial full matrix, all engines abruptly gained over 2x throughput
as CPU 0 rose to roughly 4.3 GHz. The completed low-clock c64 matrix is retained
as `peregrine-round2-lowclock-c64`; the interrupted matrix is retained as
`peregrine-round2-mixedclock-c256`. Neither is combined with the repeated final
matrix. The harness now samples server CPU frequency every 200 ms, stores all
samples, and reports ranges alongside throughput. A second partial trial,
`peregrine-round2-variableclock-c64`, confirmed ongoing power-saver variation.
The final matrices use `powerprofilesctl launch --profile performance`, which
holds that profile only for the benchmark command's lifetime. Before/after
profiles are saved next to each matrix, and the active profile is recorded in
metadata. Profiling above predates the clock transition; its normalized samples
are not an absolute throughput comparison with the final performance-profile
runs. The production server never changes the power profile.

## Final uninstrumented results

The final 96 runs completed 54,257,980 requests with zero recorded HTTP,
connect/read/write, or timeout errors. Both matrices record a performance-profile
hold, one verified worker, matching source/binary hashes and automatically
restored `balanced` profiles. The following are medians of three five-second
runs; percentage differences do not establish statistical significance.

| Workload | Gain over saved Skirr, c64 / c256 | Versus Peregrine, c64 / c256 |
|---|---:|---:|
| Tiny response | +8.6% / +9.0% | +3.3% / +6.1% |
| 4 KiB upload/echo | +10.6% / +11.5% | -1.5% / +4.3% |
| FastAPI JSON | +0.8% / +1.4% | -6.2% / -6.7% |
| 64 KiB response | +2.3% / +2.7% | +12.1% / +12.1% |

Skirr has not achieved the goal of beating Peregrine across all target workloads.
FastAPI remains slower, with worse median per-run P99 at both concurrency
levels: 5.28 versus 3.74 ms at c64, and 17.41 versus 14.64 ms at c256. Tiny c64
P99 is also worse (1.03 versus 0.84 ms), despite higher throughput. Upload c64
throughput overlaps across runs; treat that small difference cautiously. Upload
c256 throughput and P99 improve over Peregrine in this matrix. Weft results,
stream timing, memory and all raw run ranges are in the linked full report.

The retained native source totals 2,054 Rust lines versus 1,917 in this round's
baseline, including native parser tests. There are no added runtime dependencies.
The rejected callback rewrite is not part of the measured wheel. Profiling
permission remains user-managed; no benchmark/profiling processes remain active.
