Spaces:
Running
Running
File size: 13,711 Bytes
7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 17694b3 7c11b65 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 | # Marionette Startup Optimization β Current State & Ideas
*February 2026*
## What We're Measuring
From the moment the daemon spawns `python -u -m marionette.main` to the moment the robot's head starts moving (the user's first sign the app is alive). The sound starts 0.4s after the head moves β that 0.4s is fine and not something we want to optimize.
**Current time to first head movement: ~7.3 seconds on the CM4.**
---
## The Two Blocks of Waiting
### Block 1: Python Imports (3.48s)
When Python starts the Marionette app, it reads the source file and every `import` statement at the top of `main.py`. Each import loads a library into memory. Some libraries are big and slow to load.
| Import | Time | What it is |
|--------|------|------------|
| `reachy_mini` | 1.91s | The robot SDK. This is the biggest one because `reachy_mini.py` itself imports `cv2` (camera library, 0.3s), `scipy` (math library, 0.5s), `zenoh` (communication protocol, 0.2s), and more β even though Marionette doesn't use the camera or the math functions that need scipy. |
| `fastapi` | 1.22s | The web framework that serves the UI. It also pulls in Starlette (HTTP server), anyio (async), etc. |
| `numpy` | 0.38s | Math/array library. Required by everything. |
| `huggingface_hub` | 0.31s | For uploading/downloading datasets. |
| Everything else | ~0.27s | pydantic, soundfile, etc. |
**Nothing runs during this time.** Python is just reading library code into memory. No robot communication, no audio, nothing. The app can't do anything until all imports finish because the code that *does* things (creating the Marionette class, connecting to the robot) is defined using types and functions from these imports.
### Block 2: Creating the ReachyMini SDK Instance (3.42s)
After imports, Marionette creates its own object (`__init__`, 0.37s β fast), and then `wrapped_run()` is called. This is a method from the base class `ReachyMiniApp` (in the reachy_mini SDK). Here's what happens inside, step by step:
**Step 1: Start the web server (~0.4s)** β Uvicorn (the HTTP server) starts in a background thread so your browser can reach `http://robot:8042`. This happens in parallel with the rest, so it doesn't add to the total.
**Step 2: Create the ReachyMini object (~3s)** β This is the SDK's main class that lets you control the robot. Creating it does several things in sequence:
```
ReachyMini.__init__():
β
ββ daemon_check() ~0.1s
β Scans all running processes to verify the daemon is running.
β
ββ ZenohClient() ~0.3s
β Opens a Zenoh session (network connection to the daemon).
β Creates subscribers for joint positions, head pose, status, etc.
β
ββ wait_for_connection() ~0.5β2.0s β biggest variable
β BLOCKS until the daemon has sent at least one joint position
β update AND one head pose update over Zenoh. This proves the
β daemon is alive and the robot is responding.
β Polls by sleeping 1 second, checking, sleeping 1 second, etc.
β
ββ get_status() ~0.1s
β Reads the daemon status (is it wireless? simulation? what's the IP?).
β Usually instant because the status arrived during wait_for_connection.
β
ββ MediaManager() ~0.5β1.0s
Initializes the audio (and optionally camera) system.
ββ Determines which backend to use (GStreamer on wireless)
ββ Gst.init() ~0.1β0.5s Starts the GStreamer runtime
ββ DeviceMonitor ~0.1β0.3s Scans for audio hardware (mic, speaker)
ββ Pipeline build ~0.05s Creates record + playback pipelines
ββ GLib MainLoop Spawns a background thread for GStreamer events
```
**Only after all of this completes** does `run()` get called, which starts the animation (head up + sound).
### Why Can't We Just Play the Sound Right Away?
Because the sound has to go through the robot's audio system, which is managed by the ReachyMini SDK. Here's the chain:
```
Marionette wants to play a sound
β calls reachy_mini.media.push_audio_sample()
β which pushes data into a GStreamer playback pipeline
β which outputs to the robot's speaker via ALSA
```
You can't call `push_audio_sample()` until the MediaManager exists, and the MediaManager can't be created until we know whether we're on a Wireless (GStreamer) or Lite (SoundDevice) robot, and we can't know that until we've connected to the daemon via Zenoh.
Similarly, moving the head requires `reachy_mini.goto_target()`, which sends commands through the Zenoh client, which must be connected first.
**So the answer is: yes, it's the ReachyMini SDK instance creation that blocks everything, and we need it because both audio and motor control go through it.**
### Could We Pre-Load It?
The daemon launches apps as subprocesses on demand. There's no "pre-warm" mechanism β each time you click "Start Marionette" in the dashboard, it runs `python -u -m marionette.main` from scratch.
To pre-load, the daemon would need to either:
- Keep a Python process with all imports already done, waiting for a "go" signal (complex, high memory)
- Or cache compiled bytecode (Python already does this with `.pyc` files, but the actual import time is dominated by *executing* module-level code, not reading files)
This is why the realistic approach is to **make each step faster or run steps in parallel**, rather than trying to skip them.
---
## Optimization Ideas β Explained
### Idea 1: Lazy Imports in reachy_mini.py
**What "lazy import" means:** Instead of loading a library at the top of the file (which happens immediately when the file is imported), you load it inside the function that actually uses it:
```python
# BEFORE β imported at the top of reachy_mini.py, always loaded:
import cv2
from scipy.spatial.transform import Rotation as R
class ReachyMini:
def look_at_image(self, u, v, ...):
points = np.array([[[u, v]]], dtype=np.float32)
x_n, y_n = cv2.undistortPoints(points, ...) # uses cv2
...
def wake_up(self):
pose[:3, :3] = R.from_euler("xyz", [20, 0, 0], degrees=True).as_matrix() # uses scipy
...
# AFTER β imported only when needed:
class ReachyMini:
def look_at_image(self, u, v, ...):
import cv2 # loaded here, only if this method is called
points = np.array([[[u, v]]], dtype=np.float32)
x_n, y_n = cv2.undistortPoints(points, ...)
...
def wake_up(self):
from scipy.spatial.transform import Rotation as R # loaded here
pose[:3, :3] = R.from_euler("xyz", [20, 0, 0], degrees=True).as_matrix()
...
```
**"Isn't it annoying to make sure we didn't miss anything?"**
Not really, because:
1. Python has a simple rule: if you use a name that isn't imported, you get a `NameError` immediately. So if you miss a usage, it crashes on the first call β it doesn't silently break.
2. The scope is small. In `reachy_mini.py`, `cv2` is only used in `look_at_image()` (one method). `scipy.spatial.transform.Rotation` is used in `look_at_image()`, `look_at_world()`, `wake_up()`, and `goto_sleep()` β four methods. You grep for `cv2` and `R.from_`, move the import into each method, done.
3. Python caches imports. The first call to `import cv2` inside a function takes 0.3s. Every subsequent call in the same process takes ~0 β Python just returns the cached module. So there's no performance penalty for having the `import` line in multiple methods.
**Why it helps Marionette:** Marionette never calls `look_at_image()` or `look_at_world()`. It does call `goto_sleep()` indirectly (via `_goto_sleep_and_release`), but only *after* the app is already running. So the 0.3s (cv2) + 0.5s (scipy) would not be paid at startup at all.
**Estimated savings: 0.8s** from the 1.91s reachy_mini import, bringing it down to ~1.1s.
---
### Idea 2: Reduce Zenoh Poll Interval
**What Zenoh is:** Zenoh is a communication protocol (like MQTT or ROS topics). The daemon publishes robot state (joint positions, head pose) on Zenoh topics. The app subscribes to those topics to receive updates.
**What happens during `wait_for_connection()`:** After opening a Zenoh session and subscribing, the app needs to verify the daemon is alive and sending data. It does this by waiting for two events:
- "I received at least one joint position update"
- "I received at least one head pose update"
The current code checks these events in a loop:
```python
while time.time() - start < timeout: # timeout = 5 seconds
if joint_received.is_set() and head_received.is_set():
break
time.sleep(1.0) # β sleeps for 1 FULL SECOND between checks
```
**The problem:** The daemon might respond in 0.1 seconds, but the app won't notice until it wakes up from its 1-second sleep. In the worst case, this adds almost 1 full second of pure waiting-for-nothing.
**The fix:** Change `time.sleep(1.0)` to `time.sleep(0.1)` β check 10 times per second instead of once. This way, as soon as the daemon responds, the app notices within 0.1s instead of up to 1.0s.
**Why is the current code sleeping 1 second?** Probably just a conservative default β it's not doing anything useful during that sleep, and checking more frequently costs essentially nothing (it's just checking if a boolean flag is set).
**Estimated savings: 0β0.9s** (depends on how quickly the daemon responds relative to the sleep cycle; on average ~0.45s).
---
### Idea 3: Parallelize Zenoh Connection + MediaManager Init
**The problem:** Currently, ReachyMini.__init__() does everything one step at a time:
```
Step 1: Connect to daemon via Zenoh (0.5β2.0s) ββββ sequential ββββ
Step 2: Wait for daemon status (0.1s) ββββ sequential ββββ
Step 3: Initialize MediaManager/GStreamer (0.5β1.0s) ββββ sequential ββββ
Total: 1.1β3.1s
```
But steps 1 and 3 don't fully depend on each other:
- The **Zenoh connection** is about talking to the daemon to control motors and read sensors.
- The **GStreamer init** is about setting up the local audio hardware (microphone, speaker, pipelines).
The only dependency is that MediaManager needs to know *which* backend to use (GStreamer vs SoundDevice vs WebRTC), which comes from the daemon status. But the actual heavy work (calling `Gst.init()`, scanning for audio devices, building pipelines) is purely local.
**The idea:** Split MediaManager init into two parts:
1. **Backend selection** (needs daemon status β must wait for Zenoh): "We're on a Wireless robot, use GStreamer"
2. **Actual initialization** (local work β can run in parallel): "Initialize GStreamer, find devices, build pipelines"
Start part 2 in a background thread as soon as part 1 is decided, while the Zenoh connection continues. The MediaManager becomes usable once both the Zenoh connection and the background init are done β whichever finishes last.
```
Step 1: Connect to daemon via Zenoh (0.5β2.0s) ββ
Step 2: Daemon status arrives β decide backend βββ parallel
Step 3: Initialize GStreamer (background) (0.5β1.0s) ββ
Total: max(step1, step3) β 0.5β2.0s
Savings: 0.5β1.0s
```
**Estimated savings: 0.5β1.0s** because the GStreamer init would happen *during* the Zenoh wait instead of *after* it.
---
### Idea 4: Lazy-Import FastAPI
Same concept as idea 1 but for FastAPI (1.22s import time). Currently imported at the top of `main.py`:
```python
from fastapi import HTTPException, UploadFile, File # 1.22s
```
This can't be trivially lazy-imported in Marionette's `main.py` because the `BaseModel` classes, route decorators, etc. reference FastAPI types at class definition time. However, it *could* be lazy-imported in the reachy_mini SDK's `app.py`, where FastAPI is first used:
```python
# Currently at top of app.py:
from fastapi import FastAPI
# Could become:
def __init__(self):
if self.custom_app_url and not self.dont_start_webserver:
from fastapi import FastAPI # only imported here
self.settings_app = FastAPI()
```
This doesn't save wall-clock time for Marionette specifically (because Marionette's own `main.py` also imports from FastAPI), but it would help *other* apps that don't use FastAPI and it shows the general pattern.
**Estimated savings: 0s for Marionette specifically**, but up to 1.2s for simpler apps.
---
## Summary: What We Can Actually Do
| What | Where | Savings | Effort |
|------|-------|---------|--------|
| Lazy cv2/scipy in `reachy_mini.py` | reachy_mini repo | ~0.8s | Small (grep + move imports to 4 methods) |
| Faster Zenoh poll (1.0s β 0.1s) | reachy_mini repo | ~0.5s avg | Trivial (one line change) |
| Parallelize Zenoh + GStreamer | reachy_mini repo | ~0.5β1.0s | Medium (threading in SDK init) |
| **Total realistic savings** | | **~1.8β2.3s** | |
| **New time to first movement** | | **~5.0β5.5s** | |
The remaining ~5s floor is: Python startup + irreducible imports (numpy, zenoh, fastapi, huggingface_hub) + actual Zenoh network round-trip + bare minimum GStreamer init. These can't be optimized without fundamental architecture changes (pre-launched processes, compiled extensions, etc.).
---
## How to Verify
After any change, deploy to the CM4 and check the `[BOOT]` lines:
```bash
./deploy_wireless.sh
ssh reachy journalctl -u reachy-mini-daemon -f | grep BOOT
```
The `[BOOT]` instrumentation is already in `main.py` and will show timing for each phase.
|