flopscope-server 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,375 @@
1
+ """FlopscopeServer -- ZMQ REP loop that dispatches requests to a Session + RequestHandler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from time import monotonic, perf_counter_ns
7
+
8
+ import msgpack
9
+ import zmq
10
+
11
+ import flopscope
12
+ from flopscope_server._protocol import (
13
+ InvalidRequestError,
14
+ decode_request,
15
+ encode_error_response,
16
+ encode_response,
17
+ validate_request,
18
+ )
19
+ from flopscope_server._request_handler import RequestHandler
20
+ from flopscope_server._session import Session
21
+
22
+
23
+ class FlopscopeServer:
24
+ """ZMQ REP server for the flopscope budget-controlled compute service.
25
+
26
+ Parameters
27
+ ----------
28
+ url:
29
+ ZMQ endpoint to bind (e.g. ``"ipc:///tmp/flopscope.sock"`` or
30
+ ``"tcp://127.0.0.1:15555"``).
31
+ session_timeout_s:
32
+ Seconds of inactivity after which an open session is reaped.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ url: str = "ipc:///tmp/flopscope.sock",
38
+ session_timeout_s: float = 60.0,
39
+ ) -> None:
40
+ self._url = url
41
+ self._session_timeout_s = session_timeout_s
42
+ self._running = False
43
+
44
+ # Session state (at most one session at a time)
45
+ self._session: Session | None = None
46
+ self._handler: RequestHandler | None = None
47
+ self._last_activity: float = 0.0
48
+
49
+ # ------------------------------------------------------------------
50
+ # Public API
51
+ # ------------------------------------------------------------------
52
+
53
+ def run(self) -> None:
54
+ """Start the blocking ZMQ REP loop.
55
+
56
+ Creates a ZMQ Context and REP socket, binds to ``self._url``, and
57
+ loops until :meth:`stop` is called.
58
+ """
59
+ ctx = zmq.Context()
60
+ sock = ctx.socket(zmq.REP)
61
+ sock.setsockopt(zmq.RCVTIMEO, 1000) # 1 s poll interval
62
+ sock.bind(self._url)
63
+ self._running = True
64
+
65
+ try:
66
+ while self._running:
67
+ try:
68
+ raw = sock.recv()
69
+ except zmq.Again:
70
+ self._reap_session_if_stale()
71
+ continue
72
+
73
+ response = self._process_request(raw)
74
+ sock.send(response)
75
+ finally:
76
+ sock.close(linger=0)
77
+ ctx.term()
78
+
79
+ def stop(self) -> None:
80
+ """Signal the run loop to exit."""
81
+ self._running = False
82
+
83
+ # ------------------------------------------------------------------
84
+ # Request processing
85
+ # ------------------------------------------------------------------
86
+
87
+ def _process_request(self, raw: bytes) -> bytes:
88
+ """Decode, dispatch, encode a single request.
89
+
90
+ Returns msgpack bytes ready to send back to the client.
91
+ """
92
+ t0 = perf_counter_ns()
93
+
94
+ # --- Decode ---
95
+ try:
96
+ msg = decode_request(raw)
97
+ except InvalidRequestError as exc:
98
+ return encode_error_response("InvalidRequestError", str(exc))
99
+
100
+ # Normalise remaining bytes→str fields that decode_request leaves as
101
+ # raw bytes (it only converts _STRING_FIELDS: op, dtype, request_id).
102
+ _normalize_msg(msg)
103
+
104
+ t1 = perf_counter_ns()
105
+ op = msg["op"]
106
+
107
+ # --- Pre-session ops (no active session required) ---
108
+ if op == "hello":
109
+ return self._handle_hello(msg)
110
+
111
+ # --- Session lifecycle ops ---
112
+ if op == "budget_open":
113
+ return self._handle_budget_open(msg, t0, t1)
114
+
115
+ if op == "budget_close":
116
+ return self._handle_budget_close(t0, t1)
117
+
118
+ # --- Require active session for everything else ---
119
+ if self._session is None:
120
+ return encode_error_response(
121
+ "NoBudgetContextError",
122
+ "no active session -- send budget_open first",
123
+ )
124
+
125
+ # --- Validate (whitelist check) ---
126
+ try:
127
+ validate_request(msg)
128
+ except InvalidRequestError as exc:
129
+ return encode_error_response("InvalidRequestError", str(exc))
130
+
131
+ # --- Dispatch ---
132
+ assert self._handler is not None
133
+ result = self._handler.handle(msg)
134
+
135
+ t2 = perf_counter_ns()
136
+
137
+ # --- Encode response ---
138
+ is_fetch = op in ("fetch", "fetch_slice")
139
+ response_bytes: bytes
140
+
141
+ try:
142
+ if result.get("status") == "error":
143
+ response_bytes = encode_error_response(
144
+ result["error_type"], result["message"]
145
+ )
146
+ elif "data" in result:
147
+ # fetch / fetch_slice -- use raw response packing
148
+ payload = {
149
+ "status": "ok",
150
+ "data": result["data"],
151
+ "shape": result["shape"],
152
+ "dtype": result["dtype"],
153
+ "comms_overhead_ns": 0, # placeholder, updated below
154
+ }
155
+ response_bytes = msgpack.packb(payload, use_bin_type=True)
156
+ else:
157
+ response_bytes = encode_response(
158
+ result.get("result"),
159
+ result.get(
160
+ "budget", self._session.budget_remaining if self._session else 0
161
+ ),
162
+ comms_overhead_ns=0, # placeholder
163
+ )
164
+ except Exception as enc_err:
165
+ # Response serialization failed -- return error instead of crashing
166
+ response_bytes = encode_error_response(
167
+ "FlopscopeServerError",
168
+ f"failed to serialize response: {type(enc_err).__name__}",
169
+ )
170
+
171
+ t3 = perf_counter_ns()
172
+
173
+ # --- Record comms overhead ---
174
+ comms_ns = (t1 - t0) + (t3 - t2)
175
+ compute_ns = t2 - t1
176
+
177
+ self._session.comms_tracker.record_request(
178
+ bytes_received=len(raw),
179
+ bytes_sent=len(response_bytes),
180
+ comms_overhead_ns=comms_ns,
181
+ compute_time_ns=compute_ns,
182
+ is_fetch=is_fetch,
183
+ )
184
+
185
+ self._last_activity = monotonic()
186
+ return response_bytes
187
+
188
+ # ------------------------------------------------------------------
189
+ # Budget lifecycle handlers
190
+ # ------------------------------------------------------------------
191
+
192
+ def _handle_budget_open(self, msg: dict, t0: int, t1: int) -> bytes:
193
+ """Open a new session; error if one is already open."""
194
+ if self._session is not None and self._session.is_open:
195
+ return encode_error_response(
196
+ "RuntimeError",
197
+ "session already open -- send budget_close first",
198
+ )
199
+
200
+ # Support both top-level and kwargs-based flop_budget
201
+ flop_budget = msg.get("flop_budget")
202
+ flop_multiplier = msg.get("flop_multiplier")
203
+ if flop_budget is None:
204
+ kwargs = msg.get("kwargs") or {}
205
+ flop_budget = kwargs.get("flop_budget", 1_000_000)
206
+ if flop_multiplier is None:
207
+ flop_multiplier = kwargs.get("flop_multiplier", 1.0)
208
+ if flop_multiplier is None:
209
+ flop_multiplier = 1.0
210
+ self._session = Session(
211
+ flop_budget=flop_budget, flop_multiplier=flop_multiplier
212
+ )
213
+ self._handler = RequestHandler(self._session)
214
+ self._last_activity = monotonic()
215
+
216
+ t2 = perf_counter_ns()
217
+ response_bytes = encode_response(
218
+ {"session": "opened", "flop_budget": flop_budget},
219
+ budget=flop_budget,
220
+ comms_overhead_ns=0,
221
+ )
222
+ t3 = perf_counter_ns()
223
+
224
+ comms_ns = (t1 - t0) + (t3 - t2)
225
+ compute_ns = t2 - t1
226
+ self._session.comms_tracker.record_request(
227
+ bytes_received=0,
228
+ bytes_sent=len(response_bytes),
229
+ comms_overhead_ns=comms_ns,
230
+ compute_time_ns=compute_ns,
231
+ is_fetch=False,
232
+ )
233
+ return response_bytes
234
+
235
+ def _handle_budget_close(self, t0: int, t1: int) -> bytes:
236
+ """Close the active session and return a summary."""
237
+ if self._session is None or not self._session.is_open:
238
+ return encode_error_response(
239
+ "NoBudgetContextError",
240
+ "no active session to close",
241
+ )
242
+
243
+ summary = self._session.close()
244
+ self._session = None
245
+ self._handler = None
246
+
247
+ t2 = perf_counter_ns()
248
+ response_bytes = encode_response(summary, budget=0, comms_overhead_ns=0)
249
+ t3 = perf_counter_ns()
250
+ # No session to record to (already closed); that's fine.
251
+ return response_bytes
252
+
253
+ # ------------------------------------------------------------------
254
+ # Handshake
255
+ # ------------------------------------------------------------------
256
+
257
+ def _handle_hello(self, msg: dict) -> bytes:
258
+ """Compare client and server flopscope versions.
259
+
260
+ Returns an ok response carrying the server's leading X.Y.Z when
261
+ the client reports a matching version, or a ``VersionMismatch``
262
+ error otherwise. The handshake is intentionally session-free so
263
+ clients can detect mismatch before opening a budget.
264
+ """
265
+ kwargs = msg.get("kwargs") or {}
266
+ client_version = (
267
+ kwargs.get("client_version") if isinstance(kwargs, dict) else None
268
+ )
269
+ server_xyz = flopscope.__version__.split("+", 1)[0]
270
+
271
+ if not isinstance(client_version, str) or not client_version:
272
+ return encode_error_response(
273
+ "VersionMismatch",
274
+ f"client did not report a flopscope version; server is {server_xyz}",
275
+ )
276
+
277
+ if client_version != server_xyz:
278
+ return encode_error_response(
279
+ "VersionMismatch",
280
+ (
281
+ f"flopscope-client {client_version} cannot talk to "
282
+ f"flopscope-server {server_xyz}: versions must match. "
283
+ "Install matched versions: see "
284
+ "https://pypi.org/project/flopscope/ for the latest."
285
+ ),
286
+ )
287
+
288
+ return msgpack.packb(
289
+ {"status": "ok", "server_version": server_xyz},
290
+ use_bin_type=True,
291
+ )
292
+
293
+ # ------------------------------------------------------------------
294
+ # Session reaping
295
+ # ------------------------------------------------------------------
296
+
297
+ def _reap_session_if_stale(self) -> None:
298
+ """Close and discard the session if it has been idle too long."""
299
+ if self._session is None or not self._session.is_open:
300
+ return
301
+ if monotonic() - self._last_activity > self._session_timeout_s:
302
+ print(
303
+ "[flopscope-server] session timed out -- reaping",
304
+ file=sys.stderr,
305
+ )
306
+ self._session.close()
307
+ self._session = None
308
+ self._handler = None
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Message normalisation helper
313
+ # ---------------------------------------------------------------------------
314
+
315
+
316
+ def _decode_if_bytes(v: object) -> object:
317
+ """Decode bytes to str if possible, otherwise return as-is."""
318
+ if isinstance(v, bytes):
319
+ try:
320
+ return v.decode("utf-8")
321
+ except UnicodeDecodeError:
322
+ return v
323
+ return v
324
+
325
+
326
+ def _normalize_arg(a: object) -> object:
327
+ """Normalize a single arg: decode short bytes to str, normalize dict keys/values.
328
+
329
+ Binary data payloads (array bytes) must stay as bytes. Heuristic: only
330
+ decode if short AND contains no null bytes (handles, dtype strings, etc.
331
+ are always short ASCII).
332
+ """
333
+ if isinstance(a, bytes):
334
+ # Only decode bytes that are short AND contain only ASCII printable
335
+ # characters (no bytes > 127). This catches handle IDs and dtype
336
+ # strings but never touches binary array data (which often contains
337
+ # high bytes even if it happens to be valid UTF-8).
338
+ if len(a) > 0 and len(a) <= 32 and all(32 <= b < 128 for b in a):
339
+ return a.decode("ascii")
340
+ return a # keep as raw bytes (likely binary payload)
341
+ if isinstance(a, dict):
342
+ return {_decode_if_bytes(k): _normalize_arg(v) for k, v in a.items()}
343
+ if isinstance(a, list):
344
+ return [_normalize_arg(x) for x in a]
345
+ return a
346
+
347
+
348
+ def _normalize_msg(msg: dict) -> None:
349
+ """In-place normalise a decoded request dict.
350
+
351
+ ``decode_request`` only converts a small set of known string fields
352
+ (op, dtype, request_id) from bytes to str. Other string-valued
353
+ fields -- ``id``, ``ids``, and items inside ``args`` -- may still be
354
+ bytes after msgpack decoding with ``raw=True``. This helper converts
355
+ them so the downstream RequestHandler receives plain strings.
356
+ """
357
+ # id (used by fetch, fetch_slice, free)
358
+ if "id" in msg:
359
+ msg["id"] = _decode_if_bytes(msg["id"])
360
+
361
+ # ids (used by free)
362
+ if "ids" in msg and isinstance(msg["ids"], list):
363
+ msg["ids"] = [_decode_if_bytes(x) for x in msg["ids"]]
364
+
365
+ # args -- handle IDs are short ASCII strings, may also contain dicts
366
+ if "args" in msg and isinstance(msg["args"], list):
367
+ msg["args"] = [_normalize_arg(a) for a in msg["args"]]
368
+
369
+ # kwargs keys are already str (decode_request normalises all keys).
370
+ # kwargs *values* may contain handles, dicts, or lists that need full
371
+ # normalization (not just _decode_if_bytes).
372
+ if "kwargs" in msg and isinstance(msg["kwargs"], dict):
373
+ msg["kwargs"] = {
374
+ _decode_if_bytes(k): _normalize_arg(v) for k, v in msg["kwargs"].items()
375
+ }
@@ -0,0 +1,171 @@
1
+ """Session — ties together ArrayStore, BudgetContext, and CommsTracker for a single participant session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ import flopscope as flops
10
+ from flopscope_server._array_store import ArrayStore
11
+ from flopscope_server._comms_tracker import CommsTracker
12
+
13
+
14
+ class Session:
15
+ """A single participant session combining ArrayStore, BudgetContext, and CommsTracker.
16
+
17
+ Parameters
18
+ ----------
19
+ flop_budget : int
20
+ Maximum number of FLOPs allowed for this session.
21
+ flop_multiplier : float
22
+ Multiplier applied to each operation's raw FLOP cost.
23
+ """
24
+
25
+ def __init__(self, flop_budget: int, flop_multiplier: float = 1.0) -> None:
26
+ self._store = ArrayStore()
27
+ self._comms_tracker = CommsTracker()
28
+ self._budget_ctx = flops.BudgetContext(
29
+ flop_budget=flop_budget,
30
+ flop_multiplier=flop_multiplier,
31
+ quiet=True,
32
+ )
33
+ self._budget_ctx.__enter__()
34
+ self._is_open = True
35
+
36
+ # ------------------------------------------------------------------
37
+ # Properties
38
+ # ------------------------------------------------------------------
39
+
40
+ @property
41
+ def is_open(self) -> bool:
42
+ """True if this session is still active (not yet closed)."""
43
+ return self._is_open
44
+
45
+ @property
46
+ def budget_remaining(self) -> int:
47
+ """FLOPs remaining in the current budget."""
48
+ return self._budget_ctx.flops_remaining
49
+
50
+ @property
51
+ def budget_context(self) -> flops.BudgetContext:
52
+ """The active BudgetContext for this session.
53
+
54
+ Raises
55
+ ------
56
+ RuntimeError
57
+ If the session is already closed.
58
+ """
59
+ if not self._is_open:
60
+ raise RuntimeError(
61
+ "Session is closed; BudgetContext is no longer available."
62
+ )
63
+ return self._budget_ctx
64
+
65
+ @property
66
+ def comms_tracker(self) -> CommsTracker:
67
+ """The CommsTracker for this session."""
68
+ return self._comms_tracker
69
+
70
+ # ------------------------------------------------------------------
71
+ # Array operations (delegate to ArrayStore)
72
+ # ------------------------------------------------------------------
73
+
74
+ def store_array(self, arr: Any) -> str:
75
+ """Store *arr* and return its handle ID.
76
+
77
+ Delegates to :meth:`ArrayStore.put`.
78
+ """
79
+ return self._store.put(arr)
80
+
81
+ def get_array(self, handle: str) -> Any:
82
+ """Return the array for *handle*.
83
+
84
+ Delegates to :meth:`ArrayStore.get`.
85
+
86
+ Raises
87
+ ------
88
+ KeyError
89
+ If *handle* is not in the store.
90
+ """
91
+ return self._store.get(handle)
92
+
93
+ def array_metadata(self, handle: str) -> dict:
94
+ """Return metadata dict for *handle*.
95
+
96
+ Delegates to :meth:`ArrayStore.metadata`.
97
+
98
+ Raises
99
+ ------
100
+ KeyError
101
+ If *handle* is not in the store.
102
+ """
103
+ return self._store.metadata(handle)
104
+
105
+ def free_arrays(self, handles: list) -> None:
106
+ """Remove arrays by handle; silently ignore unknown handles.
107
+
108
+ Delegates to :meth:`ArrayStore.free`.
109
+ """
110
+ self._store.free(handles)
111
+
112
+ # ------------------------------------------------------------------
113
+ # Budget
114
+ # ------------------------------------------------------------------
115
+
116
+ def budget_status(self) -> dict:
117
+ """Return the current FLOP budget status.
118
+
119
+ Returns
120
+ -------
121
+ dict with keys:
122
+ flop_budget: total budget
123
+ flops_used: FLOPs consumed so far
124
+ flops_remaining: budget minus used
125
+ """
126
+ return {
127
+ "flop_budget": self._budget_ctx.flop_budget,
128
+ "flops_used": self._budget_ctx.flops_used,
129
+ "flops_remaining": self._budget_ctx.flops_remaining,
130
+ }
131
+
132
+ # ------------------------------------------------------------------
133
+ # Session lifecycle
134
+ # ------------------------------------------------------------------
135
+
136
+ def close(self) -> dict:
137
+ """Close the session, exiting the BudgetContext and clearing the ArrayStore.
138
+
139
+ Returns
140
+ -------
141
+ dict with keys:
142
+ budget_summary: str — human-readable FLOP budget summary,
143
+ including a namespace section when labeled ops were recorded
144
+ budget_breakdown: dict — machine-readable summary data with
145
+ ``by_namespace`` buckets for direct ingestion
146
+ comms_summary: dict — CommsTracker summary
147
+
148
+ Raises
149
+ ------
150
+ RuntimeError
151
+ If the session is already closed.
152
+ """
153
+ if not self._is_open:
154
+ raise RuntimeError("Session is already closed.")
155
+
156
+ self._budget_ctx.__exit__(None, None, None)
157
+ budget_breakdown = self._budget_ctx.summary_dict(by_namespace=True)
158
+ show_namespaces = any(
159
+ namespace is not None
160
+ for namespace in budget_breakdown.get("by_namespace", {})
161
+ )
162
+ budget_summary = self._budget_ctx.summary(by_namespace=show_namespaces)
163
+ comms_summary = self._comms_tracker.summary()
164
+ self._store.clear()
165
+ self._is_open = False
166
+
167
+ return {
168
+ "budget_summary": budget_summary,
169
+ "budget_breakdown": budget_breakdown,
170
+ "comms_summary": comms_summary,
171
+ }
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: flopscope-server
3
+ Version: 0.3.0
4
+ Summary: Backend server for flopscope client-server architecture
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: flopscope==0.3.0
7
+ Requires-Dist: msgpack>=1.0.0
8
+ Requires-Dist: numpy<2.5.0,>=2.0.0
9
+ Requires-Dist: pyzmq>=26.0.0
@@ -0,0 +1,12 @@
1
+ flopscope_server/__init__.py,sha256=AVnTf0x1li_DqBv19AszabChlmxAN-bqCQqJx7H5uIY,113
2
+ flopscope_server/__main__.py,sha256=3dhGzDNLgrni3LsTjYvA5aeNz_Ny2bVXpZ2Wrj-ABr4,1056
3
+ flopscope_server/_array_store.py,sha256=iIoDuBQcdnk3UPUdKBee8mczOWhbo5yuO77k4fPzrj4,2947
4
+ flopscope_server/_comms_tracker.py,sha256=T8HC00xbMbFOhQ_gK9mLyCCJhJJpZMTbPUSmKfgT4uc,2833
5
+ flopscope_server/_protocol.py,sha256=jiwx-lJd9xik0HBR72h_vuUPWkWmgs9m-4xp4SBuL6g,6588
6
+ flopscope_server/_request_handler.py,sha256=UXBwrxvB1zM3hfCGDk4nlaXALA8Rs8Jm6nsvDK5RE0M,17025
7
+ flopscope_server/_server.py,sha256=WZ9bYiJjYFavyaHLfYG9h1BQrFHJTbDDxK46u6RcQME,13466
8
+ flopscope_server/_session.py,sha256=23wf19c9uJs_Kiq9mgBrdHR2W48_3jpWLoQE5pa7YJE,5401
9
+ flopscope_server-0.3.0.dist-info/METADATA,sha256=zya9tXmhH1ie2HlzQrh_KBA3uJbqPTXUpNFwaNmR4uM,275
10
+ flopscope_server-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ flopscope_server-0.3.0.dist-info/entry_points.txt,sha256=_fVtS1QpIrRT22oEsh9PEuucsyUfH8v2FHctWdYAzU4,68
12
+ flopscope_server-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flopscope-server = flopscope_server.__main__:main