flopscope-server 0.4.2__tar.gz → 0.5.0__tar.gz

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.
Files changed (23) hide show
  1. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/PKG-INFO +2 -2
  2. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/pyproject.toml +2 -2
  3. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/__init__.py +1 -1
  4. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_request_handler.py +60 -13
  5. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_server.py +2 -2
  6. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_server.py +41 -0
  7. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_session.py +18 -0
  8. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/.gitignore +0 -0
  9. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/README.md +0 -0
  10. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/__main__.py +0 -0
  11. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_array_store.py +0 -0
  12. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_comms_tracker.py +0 -0
  13. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_protocol.py +0 -0
  14. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_session.py +0 -0
  15. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_array_store.py +0 -0
  16. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_bugfixes_round2.py +0 -0
  17. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_bugfixes_round3.py +0 -0
  18. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_comms_tracker.py +0 -0
  19. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_new_types.py +0 -0
  20. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_protocol.py +0 -0
  21. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_request_handler.py +0 -0
  22. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_version_handshake.py +0 -0
  23. {flopscope_server-0.4.2 → flopscope_server-0.5.0}/uv.lock +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flopscope-server
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Backend server for flopscope client-server architecture
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
- Requires-Dist: flopscope==0.4.2
7
+ Requires-Dist: flopscope==0.5.0
8
8
  Requires-Dist: msgpack>=1.0.0
9
9
  Requires-Dist: numpy<2.5.0,>=2.0.0
10
10
  Requires-Dist: pyzmq>=26.0.0
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "flopscope-server"
7
- version = "0.4.2"
7
+ version = "0.5.0"
8
8
  description = "Backend server for flopscope client-server architecture"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
12
  dependencies = [
13
- "flopscope==0.4.2",
13
+ "flopscope==0.5.0",
14
14
  "numpy>=2.0.0,<2.5.0",
15
15
  "pyzmq>=26.0.0",
16
16
  "msgpack>=1.0.0",
@@ -1,3 +1,3 @@
1
1
  """Flopscope backend server — executes numpy operations on behalf of remote clients."""
2
2
 
3
- __version__ = "0.4.2"
3
+ __version__ = "0.5.0"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  import re
7
+ from time import perf_counter_ns
7
8
  from typing import Any
8
9
 
9
10
  import numpy as np
@@ -49,6 +50,28 @@ def _make_serializable(obj):
49
50
  return obj
50
51
 
51
52
 
53
+ _MSGPACK_SCALARS = (type(None), bool, int, float, str, bytes)
54
+
55
+
56
+ def _is_msgpack_native(obj) -> bool:
57
+ """True if *obj* is composed only of msgpack-encodable Python types.
58
+
59
+ ``_make_serializable`` flattens numpy types but passes unknown objects
60
+ through unchanged; this distinguishes a genuinely-encodable result from one
61
+ that would only fail later inside ``msgpack.packb``.
62
+ """
63
+ if isinstance(obj, _MSGPACK_SCALARS):
64
+ return True
65
+ if isinstance(obj, (list, tuple)):
66
+ return all(_is_msgpack_native(item) for item in obj)
67
+ if isinstance(obj, dict):
68
+ return all(
69
+ isinstance(k, (str, bytes, int)) and _is_msgpack_native(v)
70
+ for k, v in obj.items()
71
+ )
72
+ return False
73
+
74
+
52
75
  #: Maximum allowed array size in bytes (configurable via environment variable).
53
76
  MAX_ARRAY_BYTES = int(os.environ.get("FLOPSCOPE_MAX_ARRAY_BYTES", 100 * 1024 * 1024))
54
77
 
@@ -64,6 +87,20 @@ class RequestHandler:
64
87
 
65
88
  def __init__(self, session: Session) -> None:
66
89
  self._session = session
90
+ self._kernel_ns: int = 0
91
+
92
+ @property
93
+ def kernel_ns(self) -> int:
94
+ """Pure numpy-kernel nanoseconds accumulated during the last handle()."""
95
+ return self._kernel_ns
96
+
97
+ def _run_kernel(self, fn, *args, **kwargs):
98
+ """Invoke a numpy compute call, attributing only its wall to kernel time."""
99
+ t0 = perf_counter_ns()
100
+ try:
101
+ return fn(*args, **kwargs)
102
+ finally:
103
+ self._kernel_ns += perf_counter_ns() - t0
67
104
 
68
105
  # ------------------------------------------------------------------
69
106
  # Public entry point
@@ -74,6 +111,7 @@ class RequestHandler:
74
111
 
75
112
  The ``request["op"]`` field determines which handler is invoked.
76
113
  """
114
+ self._kernel_ns = 0
77
115
  try:
78
116
  op = request["op"]
79
117
 
@@ -113,6 +151,12 @@ class RequestHandler:
113
151
  "error_type": "UnsupportedFunctionError",
114
152
  "message": str(e),
115
153
  }
154
+ except flops.UnsupportedReturnType as e:
155
+ return {
156
+ "status": "error",
157
+ "error_type": "UnsupportedReturnType",
158
+ "message": str(e),
159
+ }
116
160
  except (ValueError, TypeError) as e:
117
161
  return {
118
162
  "status": "error",
@@ -221,7 +265,7 @@ class RequestHandler:
221
265
  raise ValueError("__getitem__ requires [handle, key]")
222
266
  arr = self._resolve_arg(args[0])
223
267
  key = self._decode_index_key(args[1])
224
- result = arr[key]
268
+ result = self._run_kernel(lambda: arr[key])
225
269
  return self._pack_result(result)
226
270
 
227
271
  # ------------------------------------------------------------------
@@ -239,7 +283,7 @@ class RequestHandler:
239
283
  dtype = raw_args[1] if len(raw_args) > 1 else kwargs.get("dtype")
240
284
  if isinstance(dtype, bytes):
241
285
  dtype = dtype.decode("utf-8")
242
- result = arr.astype(dtype)
286
+ result = self._run_kernel(arr.astype, dtype)
243
287
  return self._pack_result(result)
244
288
 
245
289
  # Generator method calls: op is "Generator.<method>" with the remote
@@ -257,14 +301,14 @@ class RequestHandler:
257
301
  gen = self._resolve_arg(raw_args[0])
258
302
  rest = [self._resolve_arg(a) for a in raw_args[1:]]
259
303
  resolved_kwargs = {k: self._resolve_arg(v) for k, v in kwargs.items()}
260
- result = getattr(gen, method)(*rest, **resolved_kwargs)
304
+ result = self._run_kernel(getattr(gen, method), *rest, **resolved_kwargs)
261
305
  return self._pack_result(result)
262
306
 
263
307
  func = _get_flopscope_func(op)
264
308
  resolved_args = [self._resolve_arg(a) for a in raw_args]
265
309
  resolved_kwargs = {k: self._resolve_arg(v) for k, v in kwargs.items()}
266
310
 
267
- result = func(*resolved_args, **resolved_kwargs)
311
+ result = self._run_kernel(func, *resolved_args, **resolved_kwargs)
268
312
 
269
313
  return self._pack_result(result)
270
314
 
@@ -420,16 +464,19 @@ class RequestHandler:
420
464
  handle = self._session.store_generator(result)
421
465
  return {"status": "ok", "result": {"gen_id": handle}, "budget": budget}
422
466
 
423
- # Fallback: try to make it serializable
424
- try:
425
- serializable = _make_serializable(result)
467
+ # Fallback: flatten nested numpy structures to JSON-safe values. If the
468
+ # result still isn't msgpack-native, fail loudly + attributably rather
469
+ # than silently str()-degrading (which previously surfaced downstream as
470
+ # an opaque "failed to serialize response" error). The registry-driven
471
+ # conformance test (tests/test_registry_conformance.py) catches any op
472
+ # whose return type lands here.
473
+ serializable = _make_serializable(result)
474
+ if _is_msgpack_native(serializable):
426
475
  return {"status": "ok", "result": {"value": serializable}, "budget": budget}
427
- except Exception:
428
- return {
429
- "status": "ok",
430
- "result": {"value": str(result), "dtype": "str"},
431
- "budget": budget,
432
- }
476
+ raise flops.UnsupportedReturnType(
477
+ f"{type(result).__name__} is not serializable across the "
478
+ f"client/server boundary"
479
+ )
433
480
 
434
481
 
435
482
  # ---------------------------------------------------------------------------
@@ -172,7 +172,7 @@ class FlopscopeServer:
172
172
 
173
173
  # --- Record comms overhead ---
174
174
  comms_ns = (t1 - t0) + (t3 - t2)
175
- compute_ns = t2 - t1
175
+ compute_ns = self._handler.kernel_ns
176
176
 
177
177
  self._session.comms_tracker.record_request(
178
178
  bytes_received=len(raw),
@@ -222,7 +222,7 @@ class FlopscopeServer:
222
222
  t3 = perf_counter_ns()
223
223
 
224
224
  comms_ns = (t1 - t0) + (t3 - t2)
225
- compute_ns = t2 - t1
225
+ compute_ns = 0 # session creation is not a numpy kernel
226
226
  self._session.comms_tracker.record_request(
227
227
  bytes_received=0,
228
228
  bytes_sent=len(response_bytes),
@@ -308,3 +308,44 @@ def test_normalize_msg_kwargs_handle_dict():
308
308
  assert isinstance(out_val, dict)
309
309
  assert "__handle__" in out_val
310
310
  assert out_val["__handle__"] == "a5"
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # kernel_ns timing tests (pure numpy kernel attribution)
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ def test_compute_time_is_kernel_only():
319
+ """A compute op records kernel time that does not exceed the full handle() wall."""
320
+ import time
321
+
322
+ import numpy as np
323
+ from flopscope_server._request_handler import RequestHandler
324
+ from flopscope_server._session import Session
325
+
326
+ session = Session(flop_budget=10**12)
327
+ handler = RequestHandler(session)
328
+ h = session.store_array(np.ones((256, 256)))
329
+
330
+ t0 = time.perf_counter_ns()
331
+ handler.handle({"op": "dot", "args": [h, h], "kwargs": None})
332
+ handle_ns = time.perf_counter_ns() - t0
333
+
334
+ assert handler.kernel_ns > 0
335
+ assert handler.kernel_ns <= handle_ns
336
+ session.close()
337
+
338
+
339
+ def test_fetch_contributes_no_kernel():
340
+ """A fetch op is data movement, not a numpy kernel — kernel_ns stays 0."""
341
+ import numpy as np
342
+ from flopscope_server._request_handler import RequestHandler
343
+ from flopscope_server._session import Session
344
+
345
+ session = Session(flop_budget=10**12)
346
+ handler = RequestHandler(session)
347
+ h = session.store_array(np.ones((8, 8)))
348
+
349
+ handler.handle({"op": "fetch", "id": h})
350
+ assert handler.kernel_ns == 0
351
+ session.close()
@@ -253,3 +253,21 @@ def test_budget_remaining_reflects_context():
253
253
  s = Session(flop_budget=100_000)
254
254
  assert s.budget_remaining == 100_000
255
255
  s.close()
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Server contract: close() surfaces recorded compute time
260
+ # ---------------------------------------------------------------------------
261
+
262
+
263
+ def test_close_surfaces_recorded_compute_time(session):
264
+ """close()'s comms_summary carries the compute time the client reads as backend."""
265
+ session.comms_tracker.record_request(
266
+ bytes_received=10,
267
+ bytes_sent=20,
268
+ comms_overhead_ns=100,
269
+ compute_time_ns=5000,
270
+ is_fetch=False,
271
+ )
272
+ summary = session.close()
273
+ assert summary["comms_summary"]["total_compute_time_ns"] == 5000