diff --git a/selfdrive/ui/sunnypilot/ui_helpers.py b/selfdrive/ui/sunnypilot/ui_helpers.py new file mode 100644 index 0000000000..798c920950 --- /dev/null +++ b/selfdrive/ui/sunnypilot/ui_helpers.py @@ -0,0 +1,43 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" + + +@staticmethod +def update_item_from_param(item, key, params): + if not (action := getattr(item, 'action_item', None)): + return + + if hasattr(action, 'set_state'): + action.set_state(params.get_bool(key)) + elif hasattr(action, 'set_value'): + action.set_value(params.get(key, return_default=True)) + else: + try: + val = int(params.get(key, return_default=True)) + if hasattr(action, 'selected_button'): + action.selected_button = val + if hasattr(action, 'current_value'): + action.current_value = val + except (ValueError, TypeError): + pass + + +@staticmethod +def sync_layout_params(layout, param_name, params): + targets = [] + if toggles := getattr(layout, '_toggles', None): + targets.extend([(item, k) for k, item in toggles.items()]) + + items = getattr(layout, 'items', []) or getattr(getattr(layout, '_scroller', None), '_items', []) + for item in items: + action = getattr(item, 'action_item', None) + if key := getattr(action, 'param_key', None) or getattr(getattr(action, 'toggle', None), 'param_key', None): + targets.append((item, key)) + + for item, key in targets: + if param_name is None or key == param_name: + update_item_from_param(item, key, params) diff --git a/selfdrive/ui/sunnypilot/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py index a4ae23e8f3..b24ab8c62b 100644 --- a/selfdrive/ui/sunnypilot/ui_state.py +++ b/selfdrive/ui/sunnypilot/ui_state.py @@ -5,9 +5,11 @@ This file is part of sunnypilot and is licensed under the MIT License. See the LICENSE.md file in the root directory for more details. """ from cereal import messaging, custom -from openpilot.common.param_watcher import ParamWatcher, sync_layout_params from openpilot.common.swaglog import cloudlog + +from openpilot.sunnypilot.common.param_watcher import ParamWatcher from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState +from openpilot.selfdrive.ui.sunnypilot.ui_helpers import sync_layout_params class UIStateSP: diff --git a/sunnypilot/common/README.md b/sunnypilot/common/README.md new file mode 100644 index 0000000000..8fc9dd0371 --- /dev/null +++ b/sunnypilot/common/README.md @@ -0,0 +1,96 @@ + +# Comparative Analysis of Parameter Access Methods: `Params::get` vs. `ParamWatcher` + +## Inefficiencies in Standard Parameter Access +The standard `Params::get()` method executes a full file I/O lifecycle—opening, allocating, reading, and closing—for every function call. This approach results in significant CPU overhead and memory churn due to the frequency of these operations in the user interface loop. + +### System Overhead Analysis +* **System Call Overhead**: Every read operation requires context switches into kernel mode. The `Params::get` function calls `util::read_file` (sunnypilot, 2024), which subsequently invokes `std::ifstream` (sunnypilot, 2024). + * *Impact*: Frequent context switching degrades performance (Linux man-pages, n.d.-a; Linux man-pages, n.d.-b). +* **C++ Stream Overhead**: The use of `std::ifstream` introduces additional overhead for maintaining stream state and buffering compared to raw file descriptors (cppreference.com, n.d.-a; Codezup, n.d.). +* **Memory Churn**: The instantiation of `std::string result(size, '\0');` forces heap allocation and deallocation during every call (sunnypilot, 2024). This stresses the memory allocator and can lead to fragmentation (cppreference.com, n.d.). + +## The `ParamWatcher` Optimization +The `ParamWatcher` implementation utilizes OS-level file system events, such as `inotify` on Linux or `FSEvents` on macOS, to maintain a Random Access Memory (RAM) cache. This architecture eliminates the need for continuous polling. + +### Performance Comparison + +| Feature | Standard `Params::get` | Optimized `ParamWatcher` | +| :--- | :--- | :--- | +| **Workflow** | `open` → `malloc` → `read` → `close` | `dict.get()` (RAM lookup) | +| **Complexity** | **O(N * F)** (Linear to toggles & FPS) | **O(1)** (Constant time) | +| **Disk I/O** | ~1,000 reads/sec (50 toggles @ 20FPS) | **0 reads/sec** (Steady state) | +| **Memory** | New string object per call (High GC pressure) | Returns reference (Zero GC pressure) | + +## Architectural Mismatch of Standard Modules +Standard C++ modules like `std::ifstream` are optimized for **throughput**—reading large files sequentially—rather than **latency** required for polling small files frequently. + +* **The I/O Trap**: Even when a file resides in the OS page cache (RAM), invoking `open()` and `read()` forces a CPU mode switch (User → Kernel → User). Executing this sequence 1,000 times per second consumes CPU cycles merely to verify state constancy. +* **The Memory Trap**: The `std::string` class allocates memory on the heap. Repeated allocation creates short-lived objects, which in C++ fragments memory. In Python (which wraps this), it triggers the Garbage Collector, pausing the UI. +* **The Query Mismatch**: `Params::get` queries the current value every frame, whereas `ParamWatcher` waits for a notification of change, serving cached values in the interim. + +## Limitations and Trade-offs +While `ParamWatcher` offers superior performance for UI rendering, it presents specific trade-offs: + +* **Static RAM Usage**: `ParamWatcher` maintains a persistent dictionary cache of all accessed parameters (~50KB), whereas `Params::get` uses zero static RAM but incurs high dynamic memory access. +* **Event Latency**: In high-load scenarios, `inotify` events may experience slight delays or coalescing compared to direct reads. However, for user interface applications, this latency (<10ms) is imperceptible. +* **Complexity**: The solution requires managing a singleton background thread and OS-specific event loops, increasing code complexity compared to the synchronous `Params::get` function. + +## 5. Implementation Analysis: `param_watcher.py` + +The `ParamWatcher` class provides a cross-platform solution for monitoring file system changes, specifically targeting the parameter files used in Openpilot. The implementation leverages the `ctypes` library to interface directly with operating system kernels, bypassing higher-level abstractions for maximum performance. + +### Linux Implementation (`_run_linux`) +The Linux implementation interacts directly with the kernel's `inotify` subsystem (Linux man-pages, n.d.-c). + +* **Library Loading**: `libc = ctypes.CDLL('libc.so.6')` loads the standard C library to access system calls. +* **Initialization**: `inotify_init()` is called to create a new inotify instance, returning a file descriptor. +* **Watch Setup**: `inotify_add_watch(fd, path, mask)` registers the parameters directory. The mask includes `IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE` to capture all relevant file changes. +* **Event Loop**: + * **Polling**: `select.epoll()` is used to efficiently wait for activity on the file descriptor without busy-waiting. + * **Reading**: When events occur, `os.read(fd, 1024)` retrieves the raw binary event data. + * **Parsing**: The code uses Python's `struct` module (`struct.unpack_from("iIII", ...)`) to parse the C-style `inotify_event` structures directly from the buffer, avoiding the overhead of defining `ctypes` structures. + * **Handling**: Extracted filenames are passed to `_trigger_callbacks`, which invalidates the specific cache entry (`self._cache.pop(path, None)`), forcing a fresh read on the next access. + +### macOS Implementation (`_run_darwin`) +The macOS implementation uses the `FSEvents` API from the `CoreServices` framework (Apple Inc., n.d.-a), which is more efficient than `kqueue` for directory monitoring. + +* **Framework Loading**: `ctypes.cdll.LoadLibrary` loads `CoreServices` and `CoreFoundation`. +* **Callback Definition**: `CFUNCTYPE` is used to define a C-compatible callback function. This function is invoked by the OS whenever a change occurs in the watched directory. +* **Stream Creation**: `FSEventStreamCreate` creates a stream for the target directory. The `kFSEventStreamCreateFlagFileEvents` flag is used to request file-level granularity where available. +* **Scheduling**: `FSEventStreamScheduleWithRunLoop` attaches the stream to the current thread's run loop (Apple Inc., n.d.-b). +* **Execution**: `CFRunLoopRun()` starts the event loop. This passes control to the OS, which wakes the thread only when necessary. +* **Handling**: Inside the callback, the code iterates through the changed paths provided by the OS. It extracts the filename and calls `_trigger_callbacks` to invalidate the cache for that specific parameter. + +### Python `ctypes` Integration +The use of `ctypes` (Python Software Foundation, n.d.) is a strategic choice. It allows the Python interpreter to load shared libraries (`libc.so.6` on Linux, `CoreServices` on macOS) and call C functions directly. This approach avoids the overhead of spawning subprocesses or compiling external C extensions, keeping the codebase pure Python while achieving C-level system integration. + +### Memory Impact Analysis + With 232 defined parameters in `param_keys.h`, the maximum static RAM footprint of `ParamWatcher` is estimated to be **less than 250 KB**. Even if every single parameter were cached simultaneously, this static usage is negligible. Crucially, this stable footprint is likely more probable to maintain no trend of memory increase, whenc compared to the standard `Params::get` approach, which generates **megabytes** of short-lived "garbage" allocations per second, forcing the Python Garbage Collector to pause execution repeatedly. + +## Conclusion +Replacing polling mechanisms with event-driven caching shifts the computational load from kernel space (syscalls) to user space (RAM). This transition eliminates I/O overhead and UI stutters caused by garbage collection, resulting in a more responsive user experience. + +## References + +Apple Inc. (n.d.-a). *File System Events*. Retrieved from https://developer.apple.com/documentation/coreservices/file_system_events + +Apple Inc. (n.d.-b). *CFRunLoop*. Retrieved from https://developer.apple.com/documentation/corefoundation/cfrunloop-rhk + +Codezup. (n.d.). *Efficient File I/O in C++*. Retrieved from https://codezup.com/efficient-file-io-cpp-best-practices/ + +cppreference.com. (n.d.-a). *std::basic_ifstream*. Retrieved from https://en.cppreference.com/w/cpp/io/basic_ifstream + +cppreference.com. (n.d.-b). *std::basic_string*. Retrieved from https://en.cppreference.com/w/cpp/string/basic_string/basic_string + +Linux man-pages. (n.d.-a). *open(2)*. Retrieved from https://man7.org/linux/man-pages/man2/open.2.html + +Linux man-pages. (n.d.-b). *read(2)*. Retrieved from https://man7.org/linux/man-pages/man2/read.2.html + +Linux man-pages. (n.d.-c). *inotify(7)*. Retrieved from https://man7.org/linux/man-pages/man7/inotify.7.html + +Python Software Foundation. (n.d.). *ctypes — A foreign function library for Python*. Retrieved from https://docs.python.org/3/library/ctypes.html + +sunnypilot. (2025). *common/params.cc* [Source code]. GitHub. https://github.com/sunnypilot/sunnypilot/blob/master/common/params.cc + +sunnypilot. (2025). *common/util.cc* [Source code]. GitHub. https://github.com/sunnypilot/sunnypilot/blob/master/common/util.cc diff --git a/sunnypilot/common/__init__.py b/sunnypilot/common/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/param_watcher.py b/sunnypilot/common/param_watcher.py similarity index 72% rename from common/param_watcher.py rename to sunnypilot/common/param_watcher.py index 9cf9c8216e..8d7ce4189d 100644 --- a/common/param_watcher.py +++ b/sunnypilot/common/param_watcher.py @@ -1,3 +1,9 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" import os import platform import struct @@ -20,41 +26,6 @@ IN_MOVED_TO = 0x00000080 IN_CLOSE_WRITE = 0x00000008 -def update_item_from_param(item, key, params): - if not (action := getattr(item, 'action_item', None)): - return - - if hasattr(action, 'set_state'): - action.set_state(params.get_bool(key)) - elif hasattr(action, 'set_value'): - action.set_value(params.get(key, return_default=True)) - else: - try: - val = int(params.get(key, return_default=True)) - if hasattr(action, 'selected_button'): - action.selected_button = val - if hasattr(action, 'current_value'): - action.current_value = val - except (ValueError, TypeError): - pass - - -def sync_layout_params(layout, param_name, params): - targets = [] - if toggles := getattr(layout, '_toggles', None): - targets.extend([(item, k) for k, item in toggles.items()]) - - items = getattr(layout, 'items', []) or getattr(getattr(layout, '_scroller', None), '_items', []) - for item in items: - action = getattr(item, 'action_item', None) - if key := getattr(action, 'param_key', None) or getattr(getattr(action, 'toggle', None), 'param_key', None): - targets.append((item, key)) - - for item, key in targets: - if param_name is None or key == param_name: - update_item_from_param(item, key, params) - - class ParamWatcher(Params): def __init__(self): super().__init__() @@ -136,21 +107,10 @@ class ParamWatcher(Params): traceback.print_exc() def _run_linux(self): - # inotify constants: https://sites.uclouvain.be/SystInfo/usr/include/linux/inotify.h.html - # more docs: https://linux.die.net/man/7/inotify - # inotify init docs: https://www.man7.org/linux/man-pages/man2/inotify_init.2.html - # docs for add watch: https://www.man7.org/linux/man-pages/man2/inotify_add_watch.2.html path = Paths.params_root() - - if hasattr(os, "inotify_init"): - cloudlog.warning("taking the os.inotify path") - fd = os.inotify_init() - os.inotify_add_watch(fd, path, IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE) - else: - cloudlog.warning("fell back to libc from ctypes") - libc = ctypes.CDLL('libc.so.6') - fd = libc.inotify_init() - libc.inotify_add_watch(fd, path.encode(), IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE) + libc = ctypes.CDLL('libc.so.6') + fd = libc.inotify_init() + libc.inotify_add_watch(fd, path.encode(), IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_CLOSE_WRITE) try: poll = select.epoll() @@ -171,8 +131,6 @@ class ParamWatcher(Params): os.close(fd) def _run_darwin(self): - # FS documentation: https://developer.apple.com/documentation/coreservices/file_system_events - # More FS documentation: https://wiki.python.org/moin/MacPython/ctypes/CoreFoundation CS = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreServices")) CF = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation"))