-
Notifications
You must be signed in to change notification settings - Fork 12
Report what the server spent, beside the answer it sends #725
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
base: client-latency-spans
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,9 @@ class InferenceSession: | |
| def __init__(self, websocket: Connection, infer_timeout: float = DEFAULT_INFER_TIMEOUT): | ||
| self._websocket = websocket | ||
| self._infer_timeout = infer_timeout | ||
| # What the server reported spending on the last inference, for the caller that times the round | ||
| # trip. Empty against a server that reports nothing, which leaves that round trip undivided. | ||
| self.served_timing: dict[str, float] = {} | ||
| self._metadata = self._handshake() | ||
|
|
||
| def _handshake(self, timeout_per_message: float = 30.0) -> dict[str, Any]: | ||
|
|
@@ -90,6 +93,7 @@ def infer(self, obs: dict[str, Any]) -> Any: | |
| f'No inference response within {self._infer_timeout}s — server stalled or connection half-open' | ||
| ) from None | ||
| response = deserialise(received) | ||
| self.served_timing = response.get(protocol.TIMING) or {} if isinstance(response, dict) else {} | ||
|
Comment on lines
95
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After one successful inference, a later timeout, send failure, or deserialization failure exits before this assignment, leaving Useful? React with 👍 / 👎. |
||
| logger.debug('Size of deserialised response: %1.f KiB', len(response) / 1024) | ||
|
|
||
| if isinstance(response, dict) and protocol.ERROR in response: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,8 @@ | |
| import os | ||
| import time | ||
| from collections import Counter | ||
| from collections.abc import Callable | ||
| from collections.abc import Callable, Iterator | ||
| from contextlib import contextmanager | ||
| from importlib.metadata import version as _pkg_version | ||
| from typing import Any | ||
|
|
||
|
|
@@ -179,6 +180,31 @@ def _declared_stack(local: Layer | None) -> dict[str, Any]: | |
| return local.to_spec() | ||
|
|
||
|
|
||
| class _ServedTiming: | ||
| """What one inference cost the server, in milliseconds on the server's own clock. | ||
|
|
||
| Every figure is a duration. ``served_ms`` opens when the observation arrives and brackets the | ||
| phases inside it. | ||
| """ | ||
|
|
||
| def __init__(self) -> None: | ||
| self._opened = time.perf_counter() | ||
| self._phases: dict[str, float] = {} | ||
|
|
||
| @contextmanager | ||
| def phase(self, name: str) -> Iterator[None]: | ||
| started = time.perf_counter() | ||
| try: | ||
| yield | ||
| finally: | ||
| self._phases[name] = (time.perf_counter() - started) * 1000.0 | ||
|
|
||
| def report(self) -> dict[str, float]: | ||
| """The phases closed so far, under the span bracketing them. FOOTGUN: read while encoding, so | ||
| it carries no encode of its own — the answer's serialisation is outside every figure here.""" | ||
| return {protocol.TIMING_SERVED: (time.perf_counter() - self._opened) * 1000.0, **self._phases} | ||
|
|
||
|
|
||
| class PolicyServer: | ||
| """Serves a policy pipeline: one layer chain with a ``remote`` marker, closed by a ``ModelSource`` | ||
| (see ``positronic.policy.spec``). | ||
|
|
@@ -352,13 +378,22 @@ async def _serve_session(self, websocket: WebSocket, model_id: str | None): | |
| message = await websocket.receive_bytes() | ||
| self._last_activity = time.monotonic() | ||
| try: | ||
| raw_obs = deserialise(message) | ||
| timing = _ServedTiming() | ||
| with timing.phase(protocol.TIMING_DECODE): | ||
| raw_obs = deserialise(message) | ||
| # Plain acquire, not the keepalive helper: the client is awaiting a ``result`` and | ||
| # would mis-parse a ``waiting`` message. Its ``infer_timeout`` bounds the wait. | ||
| async with self._infer_lock: | ||
| # The server's clock is not the rig's. | ||
| actions = await asyncio.to_thread(session, raw_obs, time.time_ns()) | ||
| await websocket.send_bytes(serialise({protocol.RESULT: actions})) | ||
| with timing.phase(protocol.TIMING_QUEUED): | ||
| await self._infer_lock.acquire() | ||
| try: | ||
| with timing.phase(protocol.TIMING_INFER): | ||
| # The server's clock is not the rig's. | ||
| actions = await asyncio.to_thread(session, raw_obs, time.time_ns()) | ||
| finally: | ||
| self._infer_lock.release() | ||
| with timing.phase(protocol.TIMING_ENCODE): | ||
| answer = serialise({protocol.RESULT: actions, protocol.TIMING: timing.report()}) | ||
|
Comment on lines
+395
to
+396
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Calling Useful? React with 👍 / 👎. |
||
| await websocket.send_bytes(answer) | ||
| except Exception as e: | ||
| logger.error(f'Error processing message: {e}', exc_info=True) | ||
| await websocket.send_bytes(serialise({protocol.ERROR: str(e)})) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rule diff-comments violated:
The comment on
served_timingexplains what its caller does with the value instead of stating the field's local meaning. Describe it as the timing block from the most recently decoded inference response, empty when absent.AGENTS.md reference: AGENTS.md:L14-L22
Useful? React with 👍 / 👎.