-
-
Notifications
You must be signed in to change notification settings - Fork 9
fix(taskworker): Measure child busy and wait time in shared memory #788
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
a4ef397
Measure child busy and wait time as counters
enochtangg ac73185
comment
enochtangg a9716c2
Stop double billing child busy and wait time
enochtangg b64314d
move segment tracking to own class
enochtangg bbac43b
comments
enochtangg abccb48
Move child busy/wait accounting into shared memory
enochtangg ce505e0
Emit queue_wait, and fix the timestamp it is built on
enochtangg eb8c975
Trim comments to one line
enochtangg ccc6326
Add observability for occupancy under-reporting
enochtangg 0715691
Divide occupancy by eligible time, not headcount
enochtangg 8dee8d4
Cut the diagnostic metrics back to the two that matter
enochtangg 8559519
clock read per child inside loop
enochtangg 5d1007e
Derive free timing slots from tracked children
enochtangg bcaa254
Give each child its own timing slot
enochtangg b18a20f
Merge remote-tracking branch 'origin/main' into track-children-busy-d…
enochtangg 7afcd9e
Defer backwards totals, and check busy + wait as a pair
enochtangg c8621be
Stop test_run_once_current_task_state enqueueing a second task
enochtangg 03c5dca
Make the accounted baseline a high-water mark
enochtangg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
232 changes: 232 additions & 0 deletions
232
clients/python/src/taskbroker_client/worker/childtiming.py
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| """Shared-memory busy/wait accounting for worker children. | ||
|
|
||
| Each child owns its own small shared array and writes its cumulative totals | ||
| into it. The parent reads every child's array once per flush and diffs against | ||
| its previous reading, so the cost is O(children) per second rather than | ||
| O(tasks) per second. | ||
|
|
||
| The parent reads rather than having children emit their own metrics because a | ||
| child only knows a segment's length once it ends: a child sitting in a 30s task | ||
| would report nothing for 30 flushes and then 30s at once. The parent folds the | ||
| open segment forward at read time instead, so that child contributes to every | ||
| interval it spans. | ||
|
|
||
| Slot layout, five doubles:: | ||
|
|
||
| 0 version seqlock; odd means a write is in progress | ||
| 1 busy_total cumulative seconds closed into busy | ||
| 2 wait_total cumulative seconds closed into wait | ||
| 3 segment_start time.monotonic() when the open segment began | ||
| 4 segment_kind KIND_NONE, KIND_WAIT or KIND_BUSY | ||
|
|
||
| Every value is absolute, so a torn read costs one transient sample that the next | ||
| flush re-derives from the totals rather than accumulating drift. | ||
| `time.monotonic()` is CLOCK_MONOTONIC, which is system-wide, so a child's | ||
| timestamps are directly comparable in the parent. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import ctypes | ||
| from dataclasses import dataclass | ||
| from multiprocessing.context import ForkContext, ForkServerContext, SpawnContext | ||
|
|
||
| SLOT_VERSION = 0 | ||
| SLOT_BUSY_TOTAL = 1 | ||
| SLOT_WAIT_TOTAL = 2 | ||
| SLOT_SEGMENT_START = 3 | ||
| SLOT_SEGMENT_KIND = 4 | ||
| SLOT_WIDTH = 5 | ||
|
|
||
| # NONE must be 0.0 so a freshly zeroed slot reads as "nothing open". Any other | ||
| # value and the parent would fold `now - 0.0` forward as elapsed time. | ||
| KIND_NONE = 0.0 | ||
| KIND_WAIT = 1.0 | ||
| KIND_BUSY = 2.0 | ||
|
|
||
| SEQLOCK_READ_ATTEMPTS = 3 | ||
|
|
||
|
|
||
| def new_slot( | ||
| mp_context: ForkContext | SpawnContext | ForkServerContext, | ||
| ) -> ctypes.Array[ctypes.c_double]: | ||
| """One child's slot, allocated at spawn and dropped when the child is reaped. | ||
|
|
||
| Giving each child its own array instead of an index into a pool means there | ||
| is no free list to hand a slot back to, so no exit path can lose one. | ||
| `RawArray` zeroes what it returns, so a recycled block never carries the | ||
| previous child's totals into its replacement. | ||
| """ | ||
| return mp_context.RawArray("d", SLOT_WIDTH) | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class SampleResult: | ||
| """One child's busy and wait, and the window they were measured over. | ||
|
|
||
| `busy + wait` should equal `eligible`. The pool sums all three and compares | ||
| them to catch time that was double-counted or dropped. | ||
| """ | ||
|
|
||
| busy: float = 0.0 | ||
| wait: float = 0.0 | ||
| eligible: float = 0.0 | ||
|
|
||
|
|
||
| class ChildTimeWriter: | ||
| """Child-side writer for its slot. | ||
|
|
||
| The child is the only writer, so it keeps authoritative totals as plain | ||
| floats and republishes the whole slot on each transition. | ||
| """ | ||
|
|
||
| __slots__ = ("_shm", "_busy_total", "_wait_total", "_start", "_kind") | ||
|
|
||
| def __init__(self, shm: ctypes.Array[ctypes.c_double]) -> None: | ||
| self._shm = shm | ||
| self._busy_total = 0.0 | ||
| self._wait_total = 0.0 | ||
| self._start = 0.0 | ||
| self._kind = KIND_NONE | ||
|
|
||
| def _publish(self) -> None: | ||
| """Write the slot behind an odd version, so a reader can tell it raced. | ||
|
|
||
| The five stores are not atomic together, and a reader that caught a new | ||
| `busy_total` beside a stale `segment_start` would count the same span | ||
| twice. Bracketing them makes that detectable. | ||
| """ | ||
| shm = self._shm | ||
| version = shm[SLOT_VERSION] | ||
| shm[SLOT_VERSION] = version + 1.0 | ||
| shm[SLOT_BUSY_TOTAL] = self._busy_total | ||
| shm[SLOT_WAIT_TOTAL] = self._wait_total | ||
| shm[SLOT_SEGMENT_START] = self._start | ||
| shm[SLOT_SEGMENT_KIND] = self._kind | ||
| shm[SLOT_VERSION] = version + 2.0 | ||
|
|
||
| def _close_open(self, now: float) -> None: | ||
| if self._kind == KIND_BUSY: | ||
| self._busy_total += max(0.0, now - self._start) | ||
| elif self._kind == KIND_WAIT: | ||
| self._wait_total += max(0.0, now - self._start) | ||
| self._kind = KIND_NONE | ||
|
|
||
| def mark_running(self, now: float) -> None: | ||
| """Open the wait clock. A warmed-up child with no task yet is waiting.""" | ||
| if self._kind != KIND_NONE: | ||
| return | ||
|
|
||
| self._start = now | ||
| self._kind = KIND_WAIT | ||
| self._publish() | ||
|
|
||
| def mark_busy(self, now: float) -> None: | ||
| """Close the open wait segment and open a busy one.""" | ||
| self._close_open(now) | ||
| self._start = now | ||
| self._kind = KIND_BUSY | ||
| self._publish() | ||
|
|
||
| def mark_idle(self, now: float) -> None: | ||
| """Close the open busy segment and open a wait one.""" | ||
| self._close_open(now) | ||
| self._start = now | ||
| self._kind = KIND_WAIT | ||
| self._publish() | ||
|
|
||
| def close(self, now: float) -> None: | ||
| """Close whatever is open so a departing child stops folding time forward.""" | ||
| self._close_open(now) | ||
| self._publish() | ||
|
|
||
|
|
||
| @dataclass | ||
| class ChildTimeAccounting: | ||
| """Parent-side reader for one child's slot.""" | ||
|
|
||
| shm: ctypes.Array[ctypes.c_double] | ||
| _prev_busy: float = 0.0 | ||
| _prev_wait: float = 0.0 | ||
| _accounted: bool = False | ||
| _measured_from: float = 0.0 | ||
|
|
||
| def mark_running(self, now: float) -> None: | ||
| """Start counting this child, baselining against the slot as it stands. | ||
|
|
||
| Baselining rather than zeroing discards whatever the child banked before | ||
| the parent saw its `running` message, which is the same window over | ||
| which it is absent from the pool's running count. | ||
| """ | ||
| reading = self._read(now) | ||
| if reading is None: | ||
| # A torn read at baseline time. Zero is the right guess: the child | ||
| # has only just started, so its totals are near enough to zero. | ||
| self._prev_busy = 0.0 | ||
| self._prev_wait = 0.0 | ||
| else: | ||
| self._prev_busy, self._prev_wait = reading | ||
|
|
||
| self._accounted = True | ||
| self._measured_from = now | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| def mark_stopped(self) -> None: | ||
| """Stop counting, so an exiting child's tail misses the live pool.""" | ||
| self._accounted = False | ||
|
enochtangg marked this conversation as resolved.
|
||
|
|
||
| def sample(self, now: float) -> SampleResult: | ||
| """Return this child's busy and wait since the previous sample.""" | ||
| if not self._accounted: | ||
| return SampleResult() | ||
|
|
||
| reading = self._read(now) | ||
| if reading is None: | ||
| # Advance nothing. The next sample then covers both windows, and its | ||
| # busy and eligible grow together instead of one outrunning the other. | ||
| return SampleResult() | ||
|
|
||
| busy_now, wait_now = reading | ||
| busy = max(0.0, busy_now - self._prev_busy) | ||
| wait = max(0.0, wait_now - self._prev_wait) | ||
| eligible = max(0.0, now - self._measured_from) | ||
|
|
||
| # High-water, not the last value read. The fold above can overshoot the | ||
| # timestamp the child publishes for that segment, so a total can land | ||
| # under the baseline. Moving it down re-bills the span next sample. | ||
| self._prev_busy = max(self._prev_busy, busy_now) | ||
| self._prev_wait = max(self._prev_wait, wait_now) | ||
| self._measured_from = now | ||
|
enochtangg marked this conversation as resolved.
|
||
| return SampleResult(busy, wait, eligible) | ||
|
|
||
| def _read(self, now: float) -> tuple[float, float] | None: | ||
| """Seqlock read of absolute busy/wait, including the segment still open. | ||
|
|
||
| Retries on an odd version (a write is in progress) or a changed one (a | ||
| write started and finished mid-read). Returns None if it never got a | ||
| clean pass, which the caller treats as "defer", not "zero". | ||
| """ | ||
| shm = self.shm | ||
| for _ in range(SEQLOCK_READ_ATTEMPTS): | ||
| version = shm[SLOT_VERSION] | ||
| if version % 2.0: | ||
| continue | ||
|
|
||
| busy = shm[SLOT_BUSY_TOTAL] | ||
| wait = shm[SLOT_WAIT_TOTAL] | ||
| start = shm[SLOT_SEGMENT_START] | ||
| kind = shm[SLOT_SEGMENT_KIND] | ||
|
|
||
| if shm[SLOT_VERSION] != version: | ||
| continue | ||
|
|
||
| # Fold in the segment the child is in right now, so a long task | ||
| # contributes to every interval it spans instead of landing all at | ||
| # once when it finally ends. | ||
| if kind == KIND_BUSY: | ||
| busy += max(0.0, now - start) | ||
| elif kind == KIND_WAIT: | ||
| wait += max(0.0, now - start) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| return (busy, wait) | ||
|
|
||
| return None | ||
Oops, something went wrong.
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.