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.
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/PKG-INFO +2 -2
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/pyproject.toml +2 -2
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/__init__.py +1 -1
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_request_handler.py +60 -13
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_server.py +2 -2
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_server.py +41 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_session.py +18 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/.gitignore +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/README.md +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/__main__.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_array_store.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_comms_tracker.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_protocol.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/src/flopscope_server/_session.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_array_store.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_bugfixes_round2.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_bugfixes_round3.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_comms_tracker.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_new_types.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_protocol.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_request_handler.py +0 -0
- {flopscope_server-0.4.2 → flopscope_server-0.5.0}/tests/test_version_handshake.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -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
|
|
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)
|
|
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
|
|
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:
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|