|
| 1 | +# Optimization Guide — Object-Oriented Parent-Child GUI Systems |
| 2 | + |
| 3 | +Performance principles for Python GUI libraries where a parent widget manages a collection of interactive child objects that receive events at display refresh rate (~60 Hz). |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## 1. Know Your Hot Path |
| 8 | + |
| 9 | +The **hot path** is any code that executes on every user interaction during a drag or resize — typically 60 times per second. Every microsecond wasted there is perceptible. |
| 10 | + |
| 11 | +Identify the hot path by asking: |
| 12 | +- Is this method called on every mouse-move event? |
| 13 | +- Is this method called inside a loop over all managed objects? |
| 14 | +- Is this method called by a tkinter binding that fires continuously? |
| 15 | + |
| 16 | +If yes to any: treat it as hot-path. Everything else is cold. |
| 17 | + |
| 18 | +**Profile before optimizing.** Use `cProfile` or `py-spy` to confirm where time is actually spent before making changes. Premature optimization is the root of maintenance debt. |
| 19 | + |
| 20 | +```python |
| 21 | +import cProfile |
| 22 | +cProfile.run("root.mainloop()", sort="cumulative") |
| 23 | +``` |
| 24 | + |
| 25 | +--- |
| 26 | + |
| 27 | +## 2. Attribute Access on the Hot Path |
| 28 | + |
| 29 | +Python attribute lookup follows the MRO chain on every access. On a 60 Hz loop, redundant lookups accumulate. |
| 30 | + |
| 31 | +### Cache repeated attribute access in local variables |
| 32 | + |
| 33 | +```python |
| 34 | +# Slow — three global lookups per call |
| 35 | +def on_drag(self, event): |
| 36 | + self.canvas.move(self.rect, event.x - self.canvas.last_x, ...) |
| 37 | + self.canvas.move(self.resize_handle, ...) |
| 38 | + |
| 39 | +# Fast — one lookup, local binding |
| 40 | +def on_drag(self, event): |
| 41 | + canvas = self.canvas |
| 42 | + canvas.move(self.rect, event.x - canvas.last_x, ...) |
| 43 | + canvas.move(self.resize_handle, ...) |
| 44 | +``` |
| 45 | + |
| 46 | +### Replace `hasattr` / `getattr` with cached booleans |
| 47 | + |
| 48 | +`hasattr` and `getattr` are expensive on the hot path. Pre-compute capability flags once in `__init__`: |
| 49 | + |
| 50 | +```python |
| 51 | +# In __init__ (cold path — runs once) |
| 52 | +self._has_dispatch: bool = hasattr(canvas, "_dispatch_rect") |
| 53 | +self._has_move_attached: bool = hasattr(canvas, "move_attached_items") |
| 54 | + |
| 55 | +# In on_drag (hot path — runs at 60 Hz) |
| 56 | +if self._has_move_attached: |
| 57 | + canvas.move_attached_items(self, dx, dy) |
| 58 | +``` |
| 59 | + |
| 60 | +This pattern is especially valuable when checking for optional parent capabilities — capabilities that may or may not exist depending on how the parent was configured. |
| 61 | + |
| 62 | +--- |
| 63 | + |
| 64 | +## 3. Data Structure Choice for O(1) Lookups |
| 65 | + |
| 66 | +### Membership testing: use `set`, not `list` |
| 67 | + |
| 68 | +```python |
| 69 | +# O(n) — scans all items |
| 70 | +if rect in my_list: |
| 71 | + |
| 72 | +# O(1) — hash lookup |
| 73 | +if id(rect) in my_set: |
| 74 | +``` |
| 75 | + |
| 76 | +### Reverse maps eliminate scanning |
| 77 | + |
| 78 | +Maintain a reverse dict alongside the forward dict: |
| 79 | + |
| 80 | +```python |
| 81 | +self.objects: Dict[int, ChildObject] = {} # item_id → object |
| 82 | +self._rect_to_id: Dict[int, int] = {} # id(object) → item_id |
| 83 | +self._registered: set[int] = set() # id(object) presence check |
| 84 | +``` |
| 85 | + |
| 86 | +When you need `item_id` given an object, use `self._rect_to_id.get(id(obj))` — O(1) instead of scanning `self.objects.values()`. |
| 87 | + |
| 88 | +**Keep reverse maps in sync.** Every insertion or deletion must update all related structures atomically. |
| 89 | + |
| 90 | +--- |
| 91 | + |
| 92 | +## 4. The Python–C Boundary Cost (tkinter) |
| 93 | + |
| 94 | +Every call to a tkinter method (`canvas.coords()`, `canvas.move()`, `canvas.itemconfig()`) crosses the Python–C boundary. This cost is fixed per call, regardless of payload size. |
| 95 | + |
| 96 | +**Minimize round-trips:** |
| 97 | +- Batch reads: collect all coordinates in one pass before beginning writes. |
| 98 | +- Avoid calling `canvas.coords(item)` inside a tight loop when you already know the coordinates from a previous step. |
| 99 | +- Use `canvas.move(item, dx, dy)` instead of `canvas.coords(item, x1, y1, x2, y2)` when only translating — `move` is implemented as a single C call while `coords` requires computing and passing four new values. |
| 100 | + |
| 101 | +--- |
| 102 | + |
| 103 | +## 5. The Mutable Hash Key Pitfall |
| 104 | + |
| 105 | +**Never use an object with a coordinate-based `__hash__` as a dict key when its coordinates will change during iteration.** |
| 106 | + |
| 107 | +Python dicts store items by hash at insertion time. If the hash changes after insertion (because the underlying data changed), lookups will silently fail with `KeyError` even though the object is in the dict: |
| 108 | + |
| 109 | +```python |
| 110 | +# BROKEN — rect.__hash__ is coordinate-based |
| 111 | +cache = {r: r.canvas.coords(r.rect) for r in rectangles} |
| 112 | +for rect in rectangles: |
| 113 | + rc = cache[rect] # KeyError after set_topleft_pos changes hash |
| 114 | + rect.set_topleft_pos(...) # mutates hash |
| 115 | +``` |
| 116 | + |
| 117 | +**Fix: use `id(obj)` as the key.** `id()` is the memory address — it never changes for a live object: |
| 118 | + |
| 119 | +```python |
| 120 | +# CORRECT |
| 121 | +cache = {id(r): r.canvas.coords(r.rect) for r in rectangles} |
| 122 | +for rect in rectangles: |
| 123 | + rc = cache[id(rect)] # always stable |
| 124 | + rect.set_topleft_pos(...) |
| 125 | +``` |
| 126 | + |
| 127 | +This applies to sets as well: never put a mutable-hash object in a set if its hash can change while it's a member. |
| 128 | + |
| 129 | +The general rule: `__hash__` must return a stable value for the lifetime of the object, or the object must be declared unhashable (`__hash__ = None`). |
| 130 | + |
| 131 | +--- |
| 132 | + |
| 133 | +## 6. Reconciliation vs. Destroy-and-Rebuild |
| 134 | + |
| 135 | +When restoring state (undo/redo, reload), prefer **in-place reconciliation** over destroying all objects and rebuilding from scratch. |
| 136 | + |
| 137 | +Destroy-and-rebuild: |
| 138 | +- Invalidates every external reference to child objects. |
| 139 | +- Forces all caller code to re-fetch references after every state change. |
| 140 | +- Triggers unnecessary canvas item creation/destruction overhead. |
| 141 | + |
| 142 | +Reconciliation (React-style diffing): |
| 143 | +1. Update items that exist in both old and new state — in-place mutations preserve Python object identity. |
| 144 | +2. Delete items present in current state but absent from the target state. |
| 145 | +3. Create items present in the target state but absent from current state. |
| 146 | + |
| 147 | +```python |
| 148 | +current_ids = set(self.objects.keys()) |
| 149 | +target_ids = set(state.keys()) |
| 150 | + |
| 151 | +for item_id in current_ids & target_ids: |
| 152 | + self._update_in_place(self.objects[item_id], state[item_id]) |
| 153 | + |
| 154 | +for item_id in current_ids - target_ids: |
| 155 | + self._delete(item_id) |
| 156 | + |
| 157 | +for item_id in target_ids - current_ids: |
| 158 | + self._create_from_state(item_id, state[item_id]) |
| 159 | +``` |
| 160 | + |
| 161 | +This is the same pattern used by game-engine entity managers and virtual DOM renderers — it is the correct pattern for any system where object identity matters to external holders. |
| 162 | + |
| 163 | +--- |
| 164 | + |
| 165 | +## 7. Coordinate Arithmetic Optimization |
| 166 | + |
| 167 | +Division is slower than multiplication. When you repeatedly divide by the same value (e.g. a zoom scale factor), pre-compute the inverse and multiply: |
| 168 | + |
| 169 | +```python |
| 170 | +# In a setup or property setter (cold) |
| 171 | +self._zoom_inv = 1.0 / self.zoom_scale |
| 172 | + |
| 173 | +# In coordinate conversion (hot path) |
| 174 | +logical_x = canvas_x * self._zoom_inv # multiply — faster than divide |
| 175 | +``` |
| 176 | + |
| 177 | +For identity transforms (zoom == 1.0, no pan offset), add an early-return guard: |
| 178 | + |
| 179 | +```python |
| 180 | +def canvas_to_logical(self, x: float, y: float) -> tuple[float, float]: |
| 181 | + if self._offset_x == 0.0 and self._offset_y == 0.0 and self._zoom_inv == 1.0: |
| 182 | + return x, y |
| 183 | + return (x - self._offset_x) * self._zoom_inv, (y - self._offset_y) * self._zoom_inv |
| 184 | +``` |
| 185 | + |
| 186 | +--- |
| 187 | + |
| 188 | +## 8. Lazy vs. Eager Initialization |
| 189 | + |
| 190 | +| Pattern | When to use | |
| 191 | +|---|---| |
| 192 | +| Eager (in `__init__`) | Structures always needed; cheap to create; eliminates `getattr` guards | |
| 193 | +| Lazy (on first use) | Structures rarely needed; expensive to create (e.g. image thumbnails) | |
| 194 | + |
| 195 | +**Default to eager** for anything that guards a hot-path conditional. The tiny startup cost is paid once; the eliminated `getattr` overhead is paid every frame. |
| 196 | + |
| 197 | +```python |
| 198 | +# Eager — no runtime guards needed |
| 199 | +self._thumbnail_cache: Dict[str, Image] = {} |
| 200 | + |
| 201 | +# Lazy — only compute on first access |
| 202 | +@property |
| 203 | +def thumbnail(self) -> Image: |
| 204 | + if self._thumbnail is None: |
| 205 | + self._thumbnail = self._compute_thumbnail() |
| 206 | + return self._thumbnail |
| 207 | +``` |
| 208 | + |
| 209 | +--- |
| 210 | + |
| 211 | +## 9. Selection Area Optimization |
| 212 | + |
| 213 | +When hit-testing a rectangular selection region against a large set of objects, use the underlying engine's spatial query instead of looping in Python: |
| 214 | + |
| 215 | +```python |
| 216 | +# Slow — O(n) Python loop |
| 217 | +selected = [r for r in all_rects if overlaps(r, selection_bbox)] |
| 218 | + |
| 219 | +# Fast — O(log n) or hardware-accelerated via tkinter's spatial index |
| 220 | +enclosed_ids = set(canvas.find_enclosed(x1, y1, x2, y2)) |
| 221 | +``` |
| 222 | + |
| 223 | +Convert the result to a `set` immediately if you need repeated membership tests. |
| 224 | + |
| 225 | +--- |
| 226 | + |
| 227 | +## 10. Memory Layout and Object Count |
| 228 | + |
| 229 | +- Minimize the number of canvas items per logical object. Each canvas item has overhead in tkinter's internal C structures. |
| 230 | +- Delete canvas items explicitly when a logical object is deleted (`canvas.delete(item_id)`). Tkinter does not garbage-collect canvas items when the Python object goes out of scope. |
| 231 | +- Use `canvas.delete("all")` only on full resets — never inside per-frame or per-object loops. |
| 232 | + |
| 233 | +--- |
| 234 | + |
| 235 | +## 11. Profiling Checklist |
| 236 | + |
| 237 | +Before declaring an optimization complete: |
| 238 | + |
| 239 | +- [ ] Measured baseline FPS or latency with `cProfile` or `time.perf_counter`. |
| 240 | +- [ ] Confirmed the optimization targets code that is actually on the measured hot path. |
| 241 | +- [ ] Re-measured after the change to confirm improvement. |
| 242 | +- [ ] No regression in correctness (all tests pass). |
| 243 | +- [ ] No increase in code complexity that outweighs the gain. |
| 244 | + |
| 245 | +**Do not optimize until you have measured. Do not stop optimizing until you have re-measured.** |
0 commit comments