c9ebbb7
commit c9ebbb7
2 files changed
+223 -0
Showing 2 changed files+223-0
▼skills/benchmark-harness/SKILL.md
+125-0
| @@ -0,0 +1,125 @@ | ||
1 | +--- | |
2 | +name: benchmark-harness | |
3 | +description: > | |
4 | + Use when running or interpreting a benchmark, adding a new workload, or | |
5 | + comparing justapi against Uvicorn/Granian/Hypercorn. Covers the exact | |
6 | + invocation, hardware assumptions, and how to distinguish real improvements | |
7 | + from noise. | |
8 | +--- | |
9 | + | |
10 | +# Benchmark Harness | |
11 | + | |
12 | +## When to use | |
13 | + | |
14 | +- Before and after every phase to record numbers in `BENCHMARKS.md`. | |
15 | +- When deciding whether a p99 regression is real (>5% threshold). | |
16 | +- Adding a new workload type (e.g., DB-round-trip-simulated, file serving). | |
17 | +- Comparing against a competing server (Uvicorn, Granian, Hypercorn). | |
18 | +- Validating a performance claim before writing it in docs or PLAN.md. | |
19 | + | |
20 | +## Setup | |
21 | + | |
22 | +### Required tools | |
23 | + | |
24 | +```bash | |
25 | +# Primary HTTP load generator (outputs p50/p95/p99 natively) | |
26 | +cargo install oha | |
27 | + | |
28 | +# Allocation profiling (pick one) | |
29 | +# dhat: integrated via cargo, no external install needed | |
30 | +# heaptrack: `sudo pacman -S heaptrack` (Arch) or `apt install heaptrack` | |
31 | + | |
32 | +# CPU profiling | |
33 | +cargo install flamegraph | |
34 | +``` | |
35 | + | |
36 | +### Hardware fixture | |
37 | + | |
38 | +Record once in `BENCHMARKS.md` and reuse for all runs: | |
39 | +- CPU model + core count | |
40 | +- RAM size | |
41 | +- Kernel version | |
42 | +- Whether virtualized (bare metal vs VM vs container) | |
43 | +- OS distribution | |
44 | + | |
45 | +## Protocol | |
46 | + | |
47 | +### 1. Warm up | |
48 | + | |
49 | +Minimum 5 seconds of traffic before measuring. This ensures: | |
50 | +- JIT compilation (Python side) is complete | |
51 | +- Connection pools are established | |
52 | +- OS caches are warm | |
53 | + | |
54 | +### 2. Run parameters | |
55 | + | |
56 | +```bash | |
57 | +# Standard benchmark: 30s duration, 100 concurrent connections | |
58 | +oha -z 30s -c 100 --latency-correction --disable-keepalive http://127.0.0.1:8080/hello | |
59 | + | |
60 | +# JSON echo: POST with nested payload | |
61 | +oha -z 30s -c 100 -m POST \ | |
62 | + -H "Content-Type: application/json" \ | |
63 | + -d '{"user":{"name":"test","id":42},"items":[1,2,3],"meta":{"version":"1.0"}}' \ | |
64 | + http://127.0.0.1:8080/echo | |
65 | +``` | |
66 | + | |
67 | +### 3. Minimum runs | |
68 | + | |
69 | +**3 runs per workload**, report the distribution: | |
70 | +- p50, p95, p99 latency | |
71 | +- Requests/sec (throughput) | |
72 | +- Peak RSS (via `/proc/<pid>/status` → VmHWM, or `time -v`) | |
73 | + | |
74 | +### 4. Record in BENCHMARKS.md | |
75 | + | |
76 | +Append a new section — **never overwrite** old numbers. Include: | |
77 | +- Date | |
78 | +- Tool version and exact command line | |
79 | +- All three runs' numbers | |
80 | +- The median or best-of-three, clearly labeled | |
81 | + | |
82 | +### 5. Regression check | |
83 | + | |
84 | +A p99 regression >5% vs. the previous phase's recorded numbers **fails the | |
85 | +benchmark gate** unless `DECISIONS.md` documents an accepted trade-off with | |
86 | +the rationale. | |
87 | + | |
88 | +## Workloads | |
89 | + | |
90 | +| Name | Route | Method | Description | | |
91 | +|---|---|---|---| | |
92 | +| hello-world | `/hello` | GET | Static JSON response — routing + serialization floor | | |
93 | +| json-echo | `/echo` | POST | Nested JSON payload echoed back — parser + serializer | | |
94 | +| db-sim | `/db` | GET | `tokio::time::sleep(5ms)` in handler — simulates DB I/O | | |
95 | + | |
96 | +## Measuring peak RSS | |
97 | + | |
98 | +```bash | |
99 | +# Option 1: /proc (during run) | |
100 | +cat /proc/<pid>/status | grep VmHWM | |
101 | + | |
102 | +# Option 2: GNU time wrapper | |
103 | +/usr/bin/time -v ./target/release/justapi serve 2>&1 | grep "Maximum resident" | |
104 | +``` | |
105 | + | |
106 | +## Gotchas | |
107 | + | |
108 | +- **Single-run noise:** One run proves nothing. Three+ runs establish a | |
109 | + distribution. If variance is >10%, investigate before drawing conclusions. | |
110 | +- **Comparing across hardware:** Absolute numbers are hardware-dependent. | |
111 | + Only compare numbers from the same fixture. | |
112 | +- **Tool version drift:** Record the exact tool version in `BENCHMARKS.md`. | |
113 | +- **Background load:** Ensure no competing CPU/memory load. CI runners | |
114 | + are noisy — prefer dedicated hardware for release benchmarks. | |
115 | +- **Keep-alive vs no-keepalive:** Document which mode was used. Keep-alive | |
116 | + measures steady-state; no-keepalive includes connection setup cost. | |
117 | +- **Python server workers:** Match worker count to justapi's thread count | |
118 | + for fair comparison (e.g., `uvicorn --workers 4`). | |
119 | + | |
120 | +## References | |
121 | + | |
122 | +- oha: https://github.com/hatoo/oha | |
123 | +- wrk: https://github.com/wg/wrk | |
124 | +- `BENCHMARKS.md` in project root | |
125 | +- `DECISIONS.md` — ADR-006 for tooling choice rationale | |
0 | 126 | |
▼skills/rust-ffi-safety/SKILL.md
+98-0
| @@ -0,0 +1,98 @@ | ||
1 | +--- | |
2 | +name: rust-ffi-safety | |
3 | +description: > | |
4 | + Use when writing or reviewing any PyO3 code that crosses the GIL boundary, | |
5 | + holds a buffer pointer across an await point, calls into Python from a | |
6 | + Rust thread not holding the GIL, or implements the buffer protocol for | |
7 | + zero-copy request views. | |
8 | +--- | |
9 | + | |
10 | +# Rust FFI Safety | |
11 | + | |
12 | +## When to use | |
13 | + | |
14 | +- Writing `#[pyfunction]` or `#[pymethods]` that take or return raw pointers. | |
15 | +- Implementing `PyBuffer` protocol to share Rust-owned memory with Python. | |
16 | +- Spawning a `tokio` task that calls back into Python (GIL reacquisition). | |
17 | +- Adding `unsafe` blocks in `justapi-py/src/`. | |
18 | +- Reviewing any code path where a Rust `&[u8]` or `&str` is handed to | |
19 | + Python without copying. | |
20 | +- Any `Send`/`Sync` boundary involving Python objects. | |
21 | + | |
22 | +## Rules | |
23 | + | |
24 | +### 1. GIL acquisition discipline | |
25 | + | |
26 | +- Use `Python::with_gil(|py| { ... })` for short callbacks. | |
27 | +- For long-running Rust I/O, release the GIL before the `.await` point | |
28 | + and reacquire it afterward. | |
29 | +- **Never** hold the GIL across an `.await` — doing so blocks the entire | |
30 | + tokio worker thread and can deadlock the runtime. | |
31 | + | |
32 | +### 2. Lifetime rules for zero-copy | |
33 | + | |
34 | +- A `&[u8]` handed to Python via the buffer protocol must remain valid | |
35 | + for the buffer's lifetime. | |
36 | +- Pin the backing allocation (arena, refcounted pool) and never move it | |
37 | + while Python holds a view. | |
38 | +- Always Python-bind the buffer's lifecycle to a Python-owned wrapper | |
39 | + (`#[pyclass]`) so Python's GC can track it. | |
40 | + | |
41 | +### 3. `// SAFETY:` comments | |
42 | + | |
43 | +Every `unsafe` block must explain: | |
44 | +- (a) The invariant being upheld | |
45 | +- (b) Why the invariant holds at this specific call site | |
46 | +- (c) What test would fail if the invariant were violated | |
47 | + | |
48 | +### 4. Thread safety | |
49 | + | |
50 | +- Python objects are `!Send`. Use `pyo3::PyAny::extract()` to copy data | |
51 | + out before sending across threads. | |
52 | +- Never hold a Rust `Mutex` (or `RwLock`) across a GIL acquisition — | |
53 | + this inverts the lock ordering and can deadlock. | |
54 | + | |
55 | +### 5. Free-threaded CPython (PEP 703) path | |
56 | + | |
57 | +- Feature-gate free-threading code behind `#[cfg(feature = "free-threaded")]`. | |
58 | +- When the GIL is absent, Python objects become `Send` but you must use | |
59 | + `pyo3`'s atomic reference counting APIs. | |
60 | +- Test both paths in CI when PyO3 free-threading support matures. | |
61 | + | |
62 | +## Gotchas / known failure modes | |
63 | + | |
64 | +| Failure | Root cause | Mitigation | | |
65 | +|---|---|---| | |
66 | +| Use-after-free in buffer view | Request body buffer freed while Python holds `memoryview` | Bind buffer lifetime to a `#[pyclass]` wrapper | | |
67 | +| GIL deadlock | Two Rust threads each waiting for the other to release GIL while holding a tokio lock | Never hold Rust Mutex across GIL acquisition | | |
68 | +| `!Send` violation | Passing Python object across tokio task boundary | Extract data into Rust types before `tokio::spawn` | | |
69 | +| Silent data corruption | Aliased `&mut` through FFI boundary | Use `PyCell` / `PyRefMut` for mutable access | | |
70 | + | |
71 | +## Example: correct GIL handling in async context | |
72 | + | |
73 | +```rust | |
74 | +use pyo3::prelude::*; | |
75 | + | |
76 | +/// Call a Python handler from an async Rust context. | |
77 | +/// GIL is held only during the Python call, not during I/O. | |
78 | +async fn call_python_handler(body: Vec<u8>) -> anyhow::Result<Vec<u8>> { | |
79 | + // Do async I/O without the GIL | |
80 | + let processed = do_rust_io(&body).await?; | |
81 | + | |
82 | + // Acquire GIL only for the Python call | |
83 | + let result = Python::with_gil(|py| -> PyResult<Vec<u8>> { | |
84 | + let handler = py.import("app")?.getattr("handle")?; | |
85 | + let py_bytes = pyo3::types::PyBytes::new(py, &processed); | |
86 | + let result = handler.call1((py_bytes,))?; | |
87 | + result.extract::<Vec<u8>>() | |
88 | + })?; | |
89 | + | |
90 | + Ok(result) | |
91 | +} | |
92 | +``` | |
93 | + | |
94 | +## References | |
95 | + | |
96 | +- PyO3 user guide: https://pyo3.rs/latest/ | |
97 | +- PEP 703 (free-threaded CPython): https://peps.python.org/pep-0703/ | |
98 | +- `DECISIONS.md` — GIL/concurrency strategy (to be written in Phase 4) | |
0 | 99 | |