CPU improvements + step() API#162
Open
LucasJSch wants to merge 5 commits into
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
This PR modified the Python client and server such that it improves CPU usage when using python scripts and python server.
Step API
The step() API is useful for testing here because it turns the sim into a client-driven, synchronous workload you can drive, measure, and assert on, instead of fighting real-time clock jitter and pub/sub noise.
Client fix
1. Lazy recv thread on
client.pyBefore the fix, connect() always started a background thread that ran this loop:
When nothing was on the wire, recv(block=False) raised TryAgain and the loop restarted immediately. That is a busy-wait: one Python thread pegged a full CPU core (~100%+) even when the client was idle.
After these fixes, we only start the thread on the first
subscribe()call. The benefits are:a. Zero recv-thread CPU when you only use RPC
b. Pay the CPU cost only for pub/sub
c. The thread exists only when there is something to receive. That matches how the client is actually used.
2. Blocking recv + 100ms timeout
Before: non-blocking recv in a tight while self.state loop → spin when idle.
After: blocking recv with a 100ms socket timeout:
Benefits:
a. With no data, the thread blocks in the kernel instead of spinning. Idle wakeups drop from ~millions/sec to ~10/sec (100ms timeout).
b. Blocking recv returns as soon as a message is ready.
c.
disconnect()setsself.state = Falsethenjoin()s the thread. The 100ms timeout guarantees the loop re-checks self.state at least every 100ms, sojoin()finishes within ~100ms instead of hanging forever on a blocking recv with no data.d. Daemon threads avoid join hangs by being killed at process exit, but that skips cleanup. Non-daemon + 100ms timeout gives orderly shutdown and a bounded wait.
How these two work together:
flowchart TD A[connect] --> B{subscribe called?} B -->|No| C[No recv thread — 0% recv CPU] B -->|Yes| D[Start recv thread] D --> E{Data on socket?} E -->|Yes| F[Process message immediately] E -->|No| G[Block up to 100ms in kernel] G --> H{self.state still True?} H -->|Yes| E H -->|No| I[join completes within ~100ms]Server fix
What was wrong before: The old server recv loop looked like this:
When no message was waiting:
Unlike the Python client (which had no sleep and burned a full core at ~100%), the server already had a 1 ms backoff — so baseline system CPU was only 3–6%, not catastrophic. But it was still a pointless poll loop: wake, check socket, sleep, repeat, even when nothing was happening.
1. Set NNG_OPT_RECVTIMEO (100ms) at startup
Benefit: Tells the socket “block up to 100ms waiting for data.” The kernel handles the wait instead of userspace polling. Same 100ms choice as the Python client — consistent behavior on both ends of the pair socket.
Also enables: Bounded shutdown. RecvLoop runs in recv_thread_, which is join()ed on teardown. Without a recv timeout, a blocking nng_recv with no incoming data could hang join() indefinitely.
2. Remove NNG_FLAG_NONBLOCK from nng_recv()
Before: NNG_FLAG_ALLOC | NNG_FLAG_NONBLOCK
After: NNG_FLAG_ALLOC only (blocking recv, subject to the 100ms timeout)
Benefit: nng_recv now waits for data instead of instantly returning NNG_EAGAIN. That is what makes the socket timeout meaningful — nonblocking recv would ignore the blocking semantics and force you back into a manual poll loop.
3. 3. Remove sleep_for(milliseconds(1))
That sleep was only there because of nonblocking recv:
Once recv blocks with a timeout, the sleep is redundant — and harmful, because it added an extra 1 ms delay on every empty poll cycle.
Benefit: Eliminates ~1000 artificial wakeups/sec. Idle polling drops to ~10 wakeups/sec (one per 100ms timeout).
4. Handle NNG_ETIMEDOUT same as NNG_EAGAIN (continue)
Before: NNG_EAGAIN → continue (after 1ms sleep)
After: NNG_ETIMEDOUT → continue (no sleep)
Benefit: A timeout is not an error — it just means “no data yet.” The loop goes back to while (state_.load()), so:
This is the direct replacement for the old EAGAIN branch; the error code name changes because blocking recv with a timeout returns ETIMEDOUT instead of EAGAIN.
How this was tested
PYTHONPATH=client/python/projectairsim/src python3 -m pytest tests/test_cpu_usage.py -v -s.Results:
Credits
Co-authored-by: James Wiles (@Zaffer)