|
1 | 1 | # bridge_example |
2 | 2 |
|
3 | | -Minimal Flutter app exercising [`PythonBridge`](../../lib/bridge.dart) — |
4 | | -the in-process byte transport between Dart and the embedded CPython |
5 | | -runtime. Sends bytes from Dart to Python via `bridge.send()`, and Python |
6 | | -echoes them back through `dart_bridge.send_bytes()`. No Flet, no msgpack, |
7 | | -no protocol layer — the smallest possible end-to-end demonstration of the |
8 | | -transport. |
| 3 | +Direct exercise of [`PythonBridge`](../../lib/bridge.dart) — the in-process byte transport between Dart and the embedded CPython runtime. No Flet, no MsgPack, no protocol layer beyond what this example draws itself. Used as the CI gate for the `serious_python` repo and as the perf / leak baseline for every change to `libdart_bridge`. |
9 | 4 |
|
10 | | -## Build the Python app bundle |
| 5 | +## What it does |
| 6 | + |
| 7 | +Two independent `PythonBridge` channels open at startup; Python registers a handler per channel: |
11 | 8 |
|
12 | | -Before running, package the Python source into `app/app.zip`: |
| 9 | +| Channel | Env var carrying the Dart-side native port to Python | Wire format | Purpose | |
| 10 | +|---------|------------------------------------------------------|----------------------------------------------|------------------------------------------| |
| 11 | +| Control | `BRIDGE_EXAMPLE_CONTROL_PORT` | UTF-8 JSON, `{"op": …}` ↔ `{"event": …}` | Interactivity (counter, version), memory snapshots | |
| 12 | +| Echo | `BRIDGE_EXAMPLE_ECHO_PORT` | Raw bytes — Python echoes the frame verbatim | Throughput timing, memory hammer loop | |
| 13 | + |
| 14 | +Separating channels means the throughput / memory hot path is just `bridge.send` → `dart_bridge.send_bytes` echo back, with zero framing tax on either side. The JSON dispatcher only runs for tiny control messages where the encoding cost is irrelevant. |
| 15 | + |
| 16 | +## Build the Python app bundle |
13 | 17 |
|
14 | 18 | ```sh |
15 | 19 | # From this directory: |
16 | 20 | dart run serious_python:main package app/src --platform Darwin \ |
17 | 21 | --python-version 3.14 |
18 | 22 | ``` |
19 | 23 |
|
20 | | -Substitute `Darwin` with `Linux`, `Windows`, or `Android` for those |
21 | | -targets (each platform plugin's CMake/Gradle pipeline downloads the |
22 | | -prebuilt `dart_bridge` native binary from |
23 | | -[flet-dev/dart-bridge](https://github.com/flet-dev/dart-bridge) at build |
24 | | -time — no `--bridge` flag, no PyPI wheel). |
| 24 | +Substitute `Darwin` with `Linux`, `Windows`, `iOS`, or `Android`. Each platform plugin's CMake / Gradle pipeline downloads the prebuilt `dart_bridge` native binary from [flet-dev/dart-bridge](https://github.com/flet-dev/dart-bridge) at build time — no `--bridge` flag, no PyPI wheel. |
25 | 25 |
|
26 | 26 | ## Run |
27 | 27 |
|
28 | 28 | ```sh |
29 | 29 | flutter run -d macos # or -d linux / windows / <device id> |
30 | 30 | ``` |
31 | 31 |
|
32 | | -How it works: |
| 32 | +Tap `+` / `−` to send `{"op": "inc"}` / `{"op": "dec"}` on the control channel; the displayed counter updates from the Python-side `{"event": "count", "value": …}` response. The version banner is populated by an analogous `{"op": "version"}` round-trip on `initState`. |
| 33 | + |
| 34 | +## Integration tests |
| 35 | + |
| 36 | +Three tests under [`integration_test/`](integration_test/): |
| 37 | + |
| 38 | +| Test | What it covers | |
| 39 | +|----------------------------|------------------------------------------------------------------------------------------------------| |
| 40 | +| `interactivity_test.dart` | Counter +/-, version banner. Asserts UI text via Flutter widget keys; matches `EXPECTED_PYTHON_VERSION` if supplied via `--dart-define`. | |
| 41 | +| `throughput_test.dart` | Size sweep 1 KB → 16 MB, 100 round-trips each. Logs min/p50/p95/mean + MB/s. Floor assertion at ≥ 1 MB. | |
| 42 | +| `memory_test.dart` | 1 000 × 1 MB echo round-trips (~2 GB total). Snapshots Python `tracemalloc` + RSS before/after. Asserts `traced_delta < 5 MB`. | |
| 43 | + |
| 44 | +```sh |
| 45 | +# After `dart run serious_python:main package …`: |
| 46 | +flutter test integration_test/throughput_test.dart -d macos |
| 47 | +flutter test integration_test/memory_test.dart -d macos |
| 48 | +flutter test integration_test/interactivity_test.dart -d macos \ |
| 49 | + --dart-define=EXPECTED_PYTHON_VERSION=3.14 |
| 50 | +``` |
| 51 | + |
| 52 | +--- |
| 53 | + |
| 54 | +# Performance & memory baseline |
| 55 | + |
| 56 | +> Measured 2026-06-12 on the bridge_example integration tests. Re-running these is the canonical way to refresh the numbers after any `libdart_bridge` / `serious-python` bump. |
| 57 | +
|
| 58 | +## Test environment |
| 59 | + |
| 60 | +| | | |
| 61 | +|----------------|----------------------------------------------------------| |
| 62 | +| Hardware | MacBook Pro M2 Pro, 32 GB | |
| 63 | +| OS | macOS 26.5 | |
| 64 | +| Python | CPython 3.14.6 (embedded via `libdart_bridge`) | |
| 65 | +| Flutter | 3.41.7, Debug build | |
| 66 | +| Test harness | `bridge_example/integration_test/` (`flutter test integration_test`) | |
| 67 | + |
| 68 | +## Throughput |
| 69 | + |
| 70 | +Methodology — round-trip = `Dart.send(N bytes) → Python echo handler → Dart receives N bytes back`. Throughput counted as `(2 × N) / mean_seconds` since both directions cross the bridge. Payload is `Random()`-seeded so each iteration is unique. 100 iterations per size. |
| 71 | + |
| 72 | +| Payload | min | p50 | p95 | mean | Throughput | |
| 73 | +|--------:|--------:|--------:|--------:|--------:|---------------:| |
| 74 | +| 1 KB | 0.08 ms | 0.08 ms | 0.11 ms | 0.08 ms | 23 MB/s | |
| 75 | +| 64 KB | 0.07 ms | 0.09 ms | 0.12 ms | 0.09 ms | 1.3 GB/s | |
| 76 | +| 256 KB | 0.11 ms | 0.16 ms | 0.33 ms | 0.21 ms | 2.4 GB/s | |
| 77 | +| 1 MB | 0.24 ms | 0.31 ms | 0.84 ms | 0.45 ms | **4.5 GB/s** | |
| 78 | +| 4 MB | 0.82 ms | 1.01 ms | 3.83 ms | 1.55 ms | 5.2 GB/s | |
| 79 | +| 16 MB | 2.45 ms | 3.32 ms | 9.12 ms | 4.45 ms | **7.2 GB/s** | |
| 80 | + |
| 81 | +### What the curve says |
| 82 | + |
| 83 | +- **Below ~64 KB the transport is call-overhead-bound.** Every round-trip pays a fixed ~80 µs floor (Dart isolate scheduling + Python GIL acquisition + two `bridge.send_bytes` calls). At 1 KB this overhead swamps the byte work — 23 MB/s is the *call rate*, not the *memory rate*. |
| 84 | +- **At 64 KB → 16 MB it scales linearly with payload size.** Throughput goes from 1.3 GB/s to 7.2 GB/s as the per-byte cost (memory copy + Dart Native API marshalling) dominates the fixed per-call cost. |
| 85 | +- **7.2 GB/s at 16 MB is within an order of magnitude of M2 Pro's main-memory bandwidth ceiling** (~200 GB/s theoretical, ~50 GB/s achievable for non-tuned `memcpy`). Practically: the bridge is memory-copy-bound, which is the best you can do without a true zero-copy shared-buffer scheme. |
| 86 | + |
| 87 | +For comparison: a Unix-domain socket transport on the same hardware tops out near 1 GB/s for similar-sized payloads, because every byte traverses the kernel. |
| 88 | + |
| 89 | +## Memory |
| 90 | + |
| 91 | +Methodology — 1 000 × 1 MB echo round-trips (~2 GB of bytes crossing the bridge total). Python heap measured via `tracemalloc.get_traced_memory()` (load-bearing leak signal — unit-stable, byte-accurate). RSS measured via `resource.getrusage(RUSAGE_SELF).ru_maxrss` on the Python side and `ProcessInfo.currentRss` on the Dart side (informational only — page residency, not retention). |
| 92 | + |
| 93 | +| Metric | Before | After | Delta | |
| 94 | +|-------------------------------------|----------:|----------:|--------------------------------------------:| |
| 95 | +| Python heap (`tracemalloc`) | 10 179 B | 10 179 B | **0 B** | |
| 96 | +| Python RSS (`ru_maxrss`) | 350 MB | 457 MB | +112 MB | |
| 97 | +| Dart RSS (`ProcessInfo.currentRss`) | 1 232 MB | 1 239 MB | +7 MB | |
| 98 | +| Python `tracemalloc` peak | 11 446 B | 1.0 MB | +~1 MB (per-iteration buffer; reclaimed) | |
| 99 | + |
| 100 | +### What the deltas say |
| 101 | + |
| 102 | +- **`traced_delta = 0 B` after 2 GB of throughput.** This is the only number that matters for "does the bridge leak?" — Python's heap accounting is exact down to the byte. Zero growth means **the bridge does not retain a single byte** of the data it transports. |
| 103 | +- **`traced_peak` rose to ~1 MB** — exactly the size of one in-flight payload, then reclaimed. The bridge holds at most one frame's worth of bytes per direction. |
| 104 | +- **RSS growth (Python +112 MB, Dart +7 MB) is OS-level page residency**, not retention. macOS keeps recently-faulted-in pages mapped for performance; the kernel will release them under pressure. This is normal behaviour for any process that has briefly touched a lot of memory, and is decoupled from actual ownership of bytes. |
| 105 | + |
| 106 | +## Bottom line |
33 | 107 |
|
34 | | -1. Dart creates a `PythonBridge`; its `port` (a `ReceivePort.sendPort.nativePort`) |
35 | | - is exported to Python via the `BRIDGE_EXAMPLE_PORT` env var passed to |
36 | | - `SeriousPython.run()`. |
37 | | -2. Python reads the env var, registers a handler keyed on that port via |
38 | | - `dart_bridge.set_enqueue_handler_func(port, handler)`, and echoes any |
39 | | - incoming frame with a `b"echo: "` prefix using `dart_bridge.send_bytes(port, ...)`. |
40 | | -3. Dart's `bridge.send()` returns `false` until the Python-side handler |
41 | | - is registered; the example retries briefly to cover that startup race. |
| 108 | +| Concern | Result | |
| 109 | +|---------------------------------------------|-----------------------------------------------------------------| |
| 110 | +| Speed at typical Flet message size (~KB) | ~80 µs per round-trip (~12 000 messages/sec round-trip rate) | |
| 111 | +| Speed at "moving files / images" sizes (MB) | **4.5 GB/s at 1 MB, 7.2 GB/s at 16 MB** — memory-bandwidth class | |
| 112 | +| Leaks? | **None. 2 GB moved, Python heap unchanged.** | |
| 113 | +| Bytes retained per round-trip | Zero (peak ≤ 1 MB during one frame, then reclaimed) | |
0 commit comments