callserver 0.1.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.
callserver/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """
2
+ Shared-memory RPC module for high-performance inter-process communication.
3
+
4
+ This module provides an efficient Remote Procedure Call (RPC) mechanism that uses
5
+ shared memory for transferring large numpy arrays between processes on the same machine.
6
+ It is designed for use cases where low-latency, high-throughput data exchange is required,
7
+ such as real-time robot control, vision processing pipelines, or any compute-intensive
8
+ workload that benefits from zero-copy data sharing.
9
+
10
+ Key Features:
11
+ - Automatic promotion of large numpy arrays to shared memory for zero-copy transfer
12
+ - Support for both synchronous and asynchronous calls
13
+ - Front/back buffer pattern for overlapping computation and communication
14
+ - Connection to child processes or existing server processes via TCP
15
+
16
+ Limitations:
17
+ - Only supports inter-process communication on the same machine (shared memory is local)
18
+ - Cannot be used for distributed or remote execution across machines
19
+
20
+ Classes:
21
+ CallServer: Main client interface for making RPC calls to a server process.
22
+ AllocationSet: Manages a set of shared memory allocations for a single function.
23
+ AllocationDescriptor: Descriptor for a shared memory buffer (name, shape, dtype).
24
+ AllocationSetManager: Manages multiple AllocationSet instances for resource allocation.
25
+
26
+ Functions:
27
+ run: Run the server loop, either on a child-process connection or as a
28
+ standalone TCP server on a given (address, port, authkey) endpoint.
29
+ run_console: Command-line entry point for running a standalone server.
30
+
31
+ Example:
32
+ >>> import numpy as np
33
+ >>> from callserver import CallServer
34
+ >>> with CallServer('adder') as server:
35
+ ... result = server.call(np.add, np.ones((1024, 1024)), np.ones((1024, 1024)))
36
+ >>> print(result[0, 0])
37
+ 2.0
38
+
39
+ For more details, see the callserver.md documentation file.
40
+ """
41
+
42
+ from .allocationmanager import AllocationSetManager
43
+ from .allocationset import AllocationDescriptor, AllocationSet
44
+ from .callserver import DEFAULT_AUTHKEY, CallServer, run, run_console
45
+
46
+ __all__ = [
47
+ "CallServer",
48
+ "run",
49
+ "run_console",
50
+ "DEFAULT_AUTHKEY",
51
+ "AllocationSet",
52
+ "AllocationDescriptor",
53
+ "AllocationSetManager",
54
+ ]
callserver/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .callserver import run_console
2
+
3
+ if __name__ == "__main__":
4
+ run_console()
@@ -0,0 +1,176 @@
1
+ """
2
+ AllocationSetManager for managing multiple function allocation sets.
3
+
4
+ This module provides the AllocationSetManager class which coordinates multiple
5
+ AllocationSet instances for different functions in an RPC workflow.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Callable, Dict, List, Optional, Union
11
+
12
+ from .allocationset import AllocationSet
13
+
14
+
15
+ class AllocationSetManager:
16
+ """
17
+ Manages multiple AllocationSet instances for resource allocation in RPC workflows.
18
+
19
+ This class implements a singleton pattern per name, ensuring that all references
20
+ to the same named manager share state. It supports front- and back-buffering
21
+ for function arguments, tracks active sets, and handles cleanup.
22
+
23
+ The manager provides multiple allocation sets per function to support asynchronous
24
+ call patterns where one set is being used for a call in progress while another
25
+ is being prepared for the next call.
26
+
27
+ Attributes:
28
+ _managers: Class-level dict of all manager instances by name.
29
+
30
+ Example:
31
+ >>> manager = AllocationSetManager("my_server")
32
+ >>> alloc_set = manager.add_allocation_set("my_function")
33
+ >>> # Add a second set for async double-buffering
34
+ >>> alloc_set2 = manager.add_allocation_set("my_function")
35
+ """
36
+
37
+ _managers: Dict[str, "AllocationSetManager"] = {}
38
+
39
+ def __new__(cls, name: Optional[str] = None) -> "AllocationSetManager":
40
+ """
41
+ Return existing manager for the given name, or create a new one.
42
+
43
+ Args:
44
+ name: The unique name for this manager.
45
+
46
+ Returns:
47
+ An AllocationSetManager instance (existing or new).
48
+ """
49
+ if name is not None and name in cls._managers:
50
+ return cls._managers[name]
51
+ return super().__new__(cls)
52
+
53
+ def __init__(self, name: Optional[str] = None) -> None:
54
+ """
55
+ Initialize the AllocationSetManager.
56
+
57
+ If a manager with the given name already exists, this is a no-op.
58
+
59
+ Args:
60
+ name: The unique name for this manager.
61
+ """
62
+ if name is not None and name in AllocationSetManager._managers:
63
+ return
64
+ self._name = name
65
+ self._arg_sets: Dict[str, List[AllocationSet]] = {}
66
+ self._active_set_ids: Dict[str, int] = {}
67
+ if self._name is not None:
68
+ assert (
69
+ self._name not in AllocationSetManager._managers
70
+ ), f"An AllocationSetManager with name {self._name} already exists."
71
+ AllocationSetManager._managers[self._name] = self
72
+
73
+ def _make_name(self, fn: Union[Callable, str]) -> str:
74
+ """
75
+ Convert a function or string to a string name.
76
+
77
+ Args:
78
+ fn: A callable or string identifier.
79
+
80
+ Returns:
81
+ The function's __name__ attribute, or the string itself.
82
+ """
83
+ return fn.__name__ if callable(fn) else fn
84
+
85
+ def add_allocation_set(self, fn: Union[Callable, str]) -> AllocationSet:
86
+ """
87
+ Add a new AllocationSet for the given function.
88
+
89
+ Creates a new allocation set and adds it to the list of sets for the
90
+ specified function. Multiple sets can be created for double-buffering.
91
+
92
+ Args:
93
+ fn: A callable or string identifier for the function.
94
+
95
+ Returns:
96
+ The newly created AllocationSet.
97
+ """
98
+ fn_name = self._make_name(fn)
99
+ if fn_name not in self._arg_sets:
100
+ self._arg_sets[fn_name] = []
101
+ self._active_set_ids[fn_name] = -1
102
+
103
+ arg_set = AllocationSet(
104
+ len(self._arg_sets[fn_name]), f"{self._name}.{fn_name}"
105
+ )
106
+ self._arg_sets[fn_name].append(arg_set)
107
+ return arg_set
108
+
109
+ def get_allocation_set(
110
+ self, fn: Union[Callable, str], idx: Optional[int] = None
111
+ ) -> AllocationSet:
112
+ """
113
+ Get an AllocationSet for the given function.
114
+
115
+ If idx is None, rotates to the next allocation set (for double-buffering).
116
+ If idx is specified, returns or creates the set at that index.
117
+
118
+ Args:
119
+ fn: A callable or string identifier for the function.
120
+ idx: Optional specific index to retrieve.
121
+
122
+ Returns:
123
+ The requested AllocationSet.
124
+ """
125
+ fn_name = self._make_name(fn)
126
+ if fn_name not in self._arg_sets:
127
+ self.add_allocation_set(fn_name)
128
+ if idx is None:
129
+ idx = self._active_set_ids[fn_name]
130
+ idx = (1 + idx) % len(self._arg_sets[fn_name])
131
+ self._active_set_ids[fn_name] = idx
132
+ else:
133
+ for i in range(len(self._arg_sets[fn_name]), idx + 1):
134
+ self.add_allocation_set(fn_name)
135
+ active_set = self._arg_sets[fn_name][idx]
136
+ return active_set
137
+
138
+ def get_active_id(self, fn: Union[Callable, str]) -> int:
139
+ """
140
+ Get the currently active allocation set index for a function.
141
+
142
+ Args:
143
+ fn: A callable or string identifier for the function.
144
+
145
+ Returns:
146
+ The index of the currently active AllocationSet.
147
+ """
148
+ fn_name = self._make_name(fn)
149
+ return self._active_set_ids[fn_name]
150
+
151
+ def close(self) -> None:
152
+ """
153
+ Release all resources held by all allocation sets.
154
+
155
+ Closes all allocation sets for all functions managed by this manager.
156
+ After calling this method, the manager should not be used.
157
+ """
158
+ for arg_set_group in self._arg_sets.values():
159
+ for arg_set in arg_set_group:
160
+ arg_set.close()
161
+ self._arg_sets = {}
162
+
163
+ def __del__(self) -> None:
164
+ """Clean up resources when the manager is garbage collected."""
165
+ self.close()
166
+
167
+
168
+ if __name__ == "__main__":
169
+ manager = AllocationSetManager("test")
170
+ manager.add_allocation_set("func1")
171
+ manager2 = AllocationSetManager("test")
172
+ assert manager is manager2
173
+ assert manager2.get_allocation_set("func1") != None
174
+
175
+ print("Test passed.")
176
+
@@ -0,0 +1,381 @@
1
+ """
2
+ Shared memory allocation management for inter-process numpy array transfer.
3
+
4
+ This module provides classes for managing shared memory buffers used in RPC
5
+ communication. It handles the low-level details of creating, mapping, and
6
+ converting numpy arrays to/from shared memory segments.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from multiprocessing import shared_memory
13
+ from typing import Any, Dict, List, Optional, Tuple, Union
14
+
15
+ import numpy as np
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class AllocationDescriptor:
21
+ """
22
+ Descriptor for a shared memory allocation.
23
+
24
+ This lightweight class stores metadata about a shared memory buffer,
25
+ allowing it to be reconstructed in another process. It is serializable
26
+ and can be sent through pipes or sockets.
27
+
28
+ Attributes:
29
+ name: The system-level name of the shared memory segment.
30
+ shape: The shape of the numpy array stored in the buffer.
31
+ dtype: The data type of the numpy array.
32
+ """
33
+
34
+ __slots__ = ("name", "shape", "dtype")
35
+
36
+ def __init__(
37
+ self,
38
+ name: str,
39
+ shape: Tuple[int, ...],
40
+ dtype: np.dtype,
41
+ ) -> None:
42
+ """
43
+ Initialize an AllocationDescriptor.
44
+
45
+ Args:
46
+ name: The system-level name of the shared memory segment.
47
+ shape: The shape of the numpy array.
48
+ dtype: The data type of the numpy array.
49
+ """
50
+ self.name = name
51
+ self.shape = shape
52
+ self.dtype = dtype
53
+
54
+ def __repr__(self) -> str:
55
+ return f"AllocationDescriptor(name={self.name!r}, shape={self.shape}, dtype={self.dtype})"
56
+
57
+
58
+ class AllocationSet:
59
+ """
60
+ Manages a set of shared memory allocations for inter-process communication.
61
+
62
+ An AllocationSet represents a collection of shared memory buffers that can be
63
+ used to transfer numpy arrays between processes. It handles the lifecycle of
64
+ shared memory segments, including creation, mapping, and cleanup.
65
+
66
+ The class supports two types of memory ownership:
67
+ - Linked buffers: Created by this process and owned (will be unlinked on close)
68
+ - Mapped buffers: Created by another process, only mapped for access
69
+
70
+ Attributes:
71
+ MIN_SIZE: Minimum array size (in bytes) to use shared memory instead of pipes.
72
+ ix: Index identifier for this allocation set.
73
+
74
+ Example:
75
+ >>> alloc_set = AllocationSet(0, "my_function")
76
+ >>> desc = alloc_set.alloc("buffer", (1024, 1024), np.float64)
77
+ >>> array = alloc_set.from_sharedmemory(desc)
78
+ >>> array[:] = 42.0
79
+ """
80
+
81
+ MIN_SIZE = 1024
82
+
83
+ def __init__(self, ix: int, name: Optional[str] = None) -> None:
84
+ """
85
+ Initialize an AllocationSet.
86
+
87
+ Args:
88
+ ix: Index identifier for this allocation set.
89
+ name: Optional name for logging and debugging purposes.
90
+ """
91
+ self.ix = ix
92
+ self._name = name
93
+ self._linked_buffers: List[shared_memory.SharedMemory] = []
94
+ self._mapped_buffers: List[shared_memory.SharedMemory] = []
95
+ self._arrays: Dict[str, np.ndarray] = {}
96
+ self._descriptors: Dict[str, AllocationDescriptor] = {}
97
+
98
+ def __repr__(self) -> str:
99
+ """Return a string representation of the AllocationSet."""
100
+ return (
101
+ f"AllocationSet(ix={self.ix}, name={self._name!r}, "
102
+ f"buffers={len(self._linked_buffers)}, mapped={len(self._mapped_buffers)})"
103
+ )
104
+
105
+ def alloc(
106
+ self,
107
+ name: str,
108
+ shape: Tuple[int, ...],
109
+ dtype: np.dtype,
110
+ init: Optional[np.ndarray] = None,
111
+ ) -> AllocationDescriptor:
112
+ """
113
+ Allocate a new shared memory buffer.
114
+
115
+ Creates a new shared memory segment and returns a descriptor that can be
116
+ used to access the buffer from another process.
117
+
118
+ Args:
119
+ name: Logical name for the allocation (used for lookup).
120
+ shape: Shape of the numpy array to allocate.
121
+ dtype: Data type of the numpy array.
122
+ init: Optional array to copy into the allocated buffer.
123
+
124
+ Returns:
125
+ An AllocationDescriptor containing the buffer metadata.
126
+ """
127
+ logger.info(f"alloc {self._name}({self.ix}): {name}")
128
+ shm = shared_memory.SharedMemory(
129
+ create=True, size=int(np.prod(shape) * np.dtype(dtype).itemsize)
130
+ )
131
+ array = np.ndarray(shape, dtype=dtype, buffer=shm.buf)
132
+ if init is not None:
133
+ array[:] = init
134
+ self._linked_buffers.append(shm)
135
+ self._arrays[shm.name] = array
136
+ desc = AllocationDescriptor(shm.name, shape, dtype)
137
+ self._descriptors[name] = desc
138
+ return desc
139
+
140
+ def get(
141
+ self, name: str, init: Optional[np.ndarray] = None
142
+ ) -> AllocationDescriptor:
143
+ """
144
+ Get an existing allocation descriptor by logical name.
145
+
146
+ Args:
147
+ name: Logical name of the allocation.
148
+ init: Optional array to copy into the buffer.
149
+
150
+ Returns:
151
+ The AllocationDescriptor for the named buffer.
152
+
153
+ Raises:
154
+ KeyError: If no allocation with the given name exists.
155
+ """
156
+ desc = self._descriptors[name]
157
+ if init is not None:
158
+ array = self._arrays[desc.name]
159
+ array[:] = init
160
+ return desc
161
+
162
+ def is_mappable(self, arg: Any) -> bool:
163
+ """
164
+ Check if an argument should be transferred via shared memory.
165
+
166
+ Arguments are mapped to shared memory if they are numpy arrays larger
167
+ than MIN_SIZE bytes. None values are also considered mappable if a
168
+ corresponding allocation already exists.
169
+
170
+ Args:
171
+ arg: The argument to check.
172
+
173
+ Returns:
174
+ True if the argument should use shared memory, False otherwise.
175
+ """
176
+ return arg is None or (
177
+ isinstance(arg, np.ndarray) and arg.nbytes > AllocationSet.MIN_SIZE
178
+ )
179
+
180
+ def to_sharedmemory(
181
+ self, name: str, array: Optional[np.ndarray]
182
+ ) -> Optional[AllocationDescriptor]:
183
+ """
184
+ Convert a numpy array to a shared memory allocation.
185
+
186
+ If the array is already in shared memory (managed by this set), returns
187
+ its existing descriptor. Otherwise, either reuses an existing allocation
188
+ (if shape/dtype match) or creates a new one.
189
+
190
+ Args:
191
+ name: Logical name for the allocation.
192
+ array: The numpy array to convert, or None.
193
+
194
+ Returns:
195
+ An AllocationDescriptor for the shared memory buffer, or None if
196
+ array is None and no existing allocation exists.
197
+
198
+ Raises:
199
+ AssertionError: If array is not a numpy array (when not None).
200
+ AssertionError: If shape or dtype changed for an existing allocation.
201
+ """
202
+ if array is None:
203
+ return self.get(name) if name in self._descriptors else None
204
+ assert isinstance(array, np.ndarray)
205
+ for id, existing in self._arrays.items():
206
+ if array is existing:
207
+ return AllocationDescriptor(id, array.shape, array.dtype)
208
+ if name in self._descriptors:
209
+ assert (
210
+ array.shape == self._descriptors[name].shape
211
+ and array.dtype == self._descriptors[name].dtype
212
+ ), f"Unexpected shape or dtype for {name}. Changing argument shape or dtype between calls is not supported."
213
+ return self.get(name, array)
214
+
215
+ return self.alloc(name, array.shape, array.dtype, array)
216
+
217
+ def from_sharedmemory(self, descriptor: AllocationDescriptor) -> np.ndarray:
218
+ """
219
+ Map a shared memory buffer to a numpy array.
220
+
221
+ If the buffer is already mapped in this set, returns the existing array.
222
+ Otherwise, maps the shared memory segment and creates a new numpy array
223
+ view of it.
224
+
225
+ Args:
226
+ descriptor: The AllocationDescriptor identifying the buffer.
227
+
228
+ Returns:
229
+ A numpy array backed by the shared memory buffer.
230
+
231
+ Raises:
232
+ AssertionError: If descriptor is not an AllocationDescriptor.
233
+ """
234
+ assert isinstance(descriptor, AllocationDescriptor)
235
+ if descriptor.name in self._arrays:
236
+ return self._arrays[descriptor.name]
237
+ logger.info(f"from_sharedmemory {self._name}({self.ix}): {descriptor.name}")
238
+ shm = shared_memory.SharedMemory(name=descriptor.name)
239
+ self._mapped_buffers.append(shm)
240
+ array = np.ndarray(descriptor.shape, dtype=descriptor.dtype, buffer=shm.buf)
241
+ self._arrays[descriptor.name] = array
242
+ return array
243
+
244
+ def args_to_sharedmemory(
245
+ self, *args: Any, **kwargs: Any
246
+ ) -> Tuple[List[Any], Dict[str, Any]]:
247
+ """
248
+ Convert function arguments to shared memory representations.
249
+
250
+ Large numpy arrays in the arguments are converted to AllocationDescriptors,
251
+ while other arguments are passed through unchanged.
252
+
253
+ Args:
254
+ *args: Positional arguments to convert.
255
+ **kwargs: Keyword arguments to convert.
256
+
257
+ Returns:
258
+ A tuple of (positional_args, keyword_args) with arrays replaced by
259
+ their AllocationDescriptors.
260
+ """
261
+ positional_args = [
262
+ self.to_sharedmemory(f"__arg{idx}", arg) if self.is_mappable(arg) else arg
263
+ for idx, arg in enumerate(args)
264
+ ]
265
+ keyword_args = {
266
+ k: self.to_sharedmemory(k, v) if self.is_mappable(v) else v
267
+ for k, v in kwargs.items()
268
+ }
269
+ return positional_args, keyword_args
270
+
271
+ def args_from_sharedmemory(
272
+ self, *args: Any, **kwargs: Any
273
+ ) -> Tuple[List[Any], Dict[str, Any]]:
274
+ """
275
+ Convert function arguments from shared memory representations.
276
+
277
+ AllocationDescriptors in the arguments are mapped to numpy arrays,
278
+ while other arguments are passed through unchanged.
279
+
280
+ Args:
281
+ *args: Positional arguments to convert.
282
+ **kwargs: Keyword arguments to convert.
283
+
284
+ Returns:
285
+ A tuple of (positional_args, keyword_args) with descriptors replaced
286
+ by their numpy array views.
287
+ """
288
+ positional_args = [
289
+ self.from_sharedmemory(arg) if isinstance(arg, AllocationDescriptor) else arg
290
+ for arg in args
291
+ ]
292
+ keyword_args = {
293
+ k: self.from_sharedmemory(v) if isinstance(v, AllocationDescriptor) else v
294
+ for k, v in kwargs.items()
295
+ }
296
+ return positional_args, keyword_args
297
+
298
+ def retval_to_sharedmemory(self, retval: Any) -> Any:
299
+ """
300
+ Convert a function's return value to shared memory representation.
301
+
302
+ Supports return values that are tuples, lists, dicts, or single values.
303
+ Large numpy arrays are converted to AllocationDescriptors.
304
+
305
+ Args:
306
+ retval: The return value to convert.
307
+
308
+ Returns:
309
+ The return value with large arrays replaced by AllocationDescriptors.
310
+ """
311
+ if isinstance(retval, tuple):
312
+ return tuple(
313
+ self.to_sharedmemory(f"__retval{idx}", v) if self.is_mappable(v) else v
314
+ for idx, v in enumerate(retval)
315
+ )
316
+ if isinstance(retval, list):
317
+ return [
318
+ self.to_sharedmemory(f"__retval{idx}", v) if self.is_mappable(v) else v
319
+ for idx, v in enumerate(retval)
320
+ ]
321
+ if isinstance(retval, dict):
322
+ return {
323
+ k: self.to_sharedmemory(k, v) if self.is_mappable(v) else v
324
+ for k, v in retval.items()
325
+ }
326
+ return (
327
+ self.to_sharedmemory("__retval0", retval)
328
+ if self.is_mappable(retval)
329
+ else retval
330
+ )
331
+
332
+ def retval_from_sharedmemory(self, retval: Any) -> Any:
333
+ """
334
+ Convert a function's return value from shared memory representation.
335
+
336
+ Supports return values that are tuples, lists, dicts, or single values.
337
+ AllocationDescriptors are mapped to numpy arrays.
338
+
339
+ Args:
340
+ retval: The return value to convert.
341
+
342
+ Returns:
343
+ The return value with descriptors replaced by numpy array views.
344
+ """
345
+ if isinstance(retval, tuple):
346
+ return tuple(
347
+ self.from_sharedmemory(v) if isinstance(v, AllocationDescriptor) else v
348
+ for v in retval
349
+ )
350
+ if isinstance(retval, list):
351
+ return [
352
+ self.from_sharedmemory(v) if isinstance(v, AllocationDescriptor) else v
353
+ for v in retval
354
+ ]
355
+ if isinstance(retval, dict):
356
+ return {
357
+ k: self.from_sharedmemory(v) if isinstance(v, AllocationDescriptor) else v
358
+ for k, v in retval.items()
359
+ }
360
+ return (
361
+ self.from_sharedmemory(retval)
362
+ if isinstance(retval, AllocationDescriptor)
363
+ else retval
364
+ )
365
+
366
+ def close(self) -> None:
367
+ """
368
+ Release all shared memory resources.
369
+
370
+ Closes and unlinks all linked buffers (owned by this process), and
371
+ closes all mapped buffers (owned by other processes). After calling
372
+ this method, the AllocationSet should not be used.
373
+ """
374
+ for buf in self._linked_buffers:
375
+ buf.close()
376
+ buf.unlink()
377
+ for buf in self._mapped_buffers:
378
+ buf.close()
379
+ self._linked_buffers = []
380
+ self._mapped_buffers = []
381
+ self._arrays = {}