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 +54 -0
- callserver/__main__.py +4 -0
- callserver/allocationmanager.py +176 -0
- callserver/allocationset.py +381 -0
- callserver/callserver.md +315 -0
- callserver/callserver.py +511 -0
- callserver-0.1.0.dist-info/METADATA +134 -0
- callserver-0.1.0.dist-info/RECORD +12 -0
- callserver-0.1.0.dist-info/WHEEL +5 -0
- callserver-0.1.0.dist-info/entry_points.txt +2 -0
- callserver-0.1.0.dist-info/licenses/LICENSE +21 -0
- callserver-0.1.0.dist-info/top_level.txt +1 -0
callserver/callserver.md
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# CallServer Usage Guide
|
|
2
|
+
|
|
3
|
+
`CallServer` runs Python functions in another process on the same machine. Numpy arrays above a size threshold in arguments and return values are transferred through shared memory, and all other data is sent through a pipe (child process) or socket (standalone server). CallServer can spawn a child server process or connect to an existing server process. It cannot be used for distributed execution across machines.
|
|
4
|
+
|
|
5
|
+
For runnable scripts, see the [examples](../examples) folder.
|
|
6
|
+
|
|
7
|
+
- [Key Features](#key-features)
|
|
8
|
+
- [Limitations](#limitations)
|
|
9
|
+
- [Usage](#usage)
|
|
10
|
+
- [Standalone Server](#standalone-server)
|
|
11
|
+
- [Notes](#notes)
|
|
12
|
+
- [How It Works](#how-it-works)
|
|
13
|
+
- [API Reference](#api-reference)
|
|
14
|
+
|
|
15
|
+
## Key Features
|
|
16
|
+
|
|
17
|
+
- **Zero-serialization transfer**: Large numpy arrays are automatically promoted to shared memory instead of being pickled.
|
|
18
|
+
- **Zero-copy transfer**: Arrays can be pre-allocated in shared memory, requiring no copy and no serialization.
|
|
19
|
+
- **Transparent buffer management**: Arrays are promoted automatically when passed directly as positional or keyword arguments, or returned directly or inside a tuple, list or dictionary. Shared memory is allocated on the first call and reused on subsequent calls.
|
|
20
|
+
- **Flexible call patterns**: Synchronous and asynchronous calls.
|
|
21
|
+
- **Double-buffering**: Front/back buffer pattern for overlapping server-side computation with client-side processing.
|
|
22
|
+
- **Connection modes**: Spawn a child process, or connect to a standalone server via TCP.
|
|
23
|
+
- **Server-side state**: Context objects can be passed to the server or created on the server.
|
|
24
|
+
|
|
25
|
+
## Limitations
|
|
26
|
+
|
|
27
|
+
- **Local machine only**: Shared memory cannot be used across machines.
|
|
28
|
+
- **Single async call**: Only one asynchronous call can be in progress at a time per `CallServer` instance.
|
|
29
|
+
- **Simple data structures**: Arrays nested inside argument containers are not promoted. Return values are promoted one level deep (tuple, list or dict).
|
|
30
|
+
- **Fixed shapes**: The shape and dtype of an array argument must not change between calls to the same function.
|
|
31
|
+
- **Picklable functions**: Server-side functions and context initializers are pickled by reference, so they must be defined at module level and importable by the server process.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
> On Windows and macOS, child processes re-import the main module, so client scripts need an `if __name__ == "__main__":` guard. It is omitted from the snippets below for brevity.
|
|
36
|
+
|
|
37
|
+
### 1. A simple example
|
|
38
|
+
```python
|
|
39
|
+
import numpy as np
|
|
40
|
+
from callserver import CallServer
|
|
41
|
+
|
|
42
|
+
server = CallServer('adder')
|
|
43
|
+
# start as child process
|
|
44
|
+
server.start()
|
|
45
|
+
# addition executes in the child process.
|
|
46
|
+
# the arguments and return value are exchanged through shared memory
|
|
47
|
+
result = server.call(np.add, np.ones((1024, 1024)), np.ones((1024, 1024)))
|
|
48
|
+
print(result[0, 0]) # 2.0
|
|
49
|
+
server.stop()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`CallServer` is also a context manager, which calls `start()` on entry and `stop()` on exit:
|
|
53
|
+
```python
|
|
54
|
+
with CallServer('adder') as server:
|
|
55
|
+
result = server.call(np.add, a, b)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 2. Server-Side State
|
|
59
|
+
Server-side state can be passed in via the CallServer constructor, or can be created by a server-side initializer.
|
|
60
|
+
If `context` is callable, it is called once inside the server process with the remaining constructor arguments, and its return value becomes the context. Otherwise, the object itself is pickled and sent to the server.
|
|
61
|
+
```python
|
|
62
|
+
import numpy as np
|
|
63
|
+
from callserver import CallServer
|
|
64
|
+
|
|
65
|
+
# The initializer can be a constructor or a factory function.
|
|
66
|
+
# It executes inside the server process.
|
|
67
|
+
def my_initializer(shape, value):
|
|
68
|
+
# Create and return a state object
|
|
69
|
+
return np.full(shape, value)
|
|
70
|
+
|
|
71
|
+
# arguments after the initializer are passed to it on the server
|
|
72
|
+
server = CallServer('my_server', my_initializer, (1024, 1024), 1000)
|
|
73
|
+
server.start()
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Errors raised by the initializer are re-raised on the client by the next `call()` or `get_result()`.
|
|
77
|
+
|
|
78
|
+
### 3. Server-Side Functions
|
|
79
|
+
Server-side functions receive the context object as their first argument. If no context was given, functions are called with the call arguments only.
|
|
80
|
+
```python
|
|
81
|
+
# this executes inside the server process
|
|
82
|
+
def increment(state, value):
|
|
83
|
+
return state + value
|
|
84
|
+
|
|
85
|
+
result = server.call(increment, 10) # returns [[1010, 1010, ...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A more realistic example captures and processes camera frames on the server, and returns them through shared memory:
|
|
89
|
+
```python
|
|
90
|
+
import cv2
|
|
91
|
+
from callserver import CallServer
|
|
92
|
+
|
|
93
|
+
# called on the server side once, at startup
|
|
94
|
+
def create_context(device_id):
|
|
95
|
+
return cv2.VideoCapture(device_id)
|
|
96
|
+
|
|
97
|
+
# executed on the server side on every call
|
|
98
|
+
def read_and_process_frame(cap):
|
|
99
|
+
ret, frame = cap.read()
|
|
100
|
+
# process frame as needed
|
|
101
|
+
# ...
|
|
102
|
+
return frame
|
|
103
|
+
|
|
104
|
+
server = CallServer('my_camera', create_context, 0)
|
|
105
|
+
server.start()
|
|
106
|
+
|
|
107
|
+
while True:
|
|
108
|
+
frame = server.call(read_and_process_frame) # this is the RPC call
|
|
109
|
+
cv2.imshow('frame', frame)
|
|
110
|
+
if cv2.waitKey(1) & 0xFF == ord('q'):
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
server.stop()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 4. Using Async Calls
|
|
117
|
+
Remote calls can execute asynchronously, allowing the client process to perform other work in parallel. Only one call can be in progress at a time: `call_async()` raises `RuntimeError` if the previous result has not been retrieved with `get_result()`.
|
|
118
|
+
```python
|
|
119
|
+
# start processing
|
|
120
|
+
server.call_async(read_and_process_frame)
|
|
121
|
+
|
|
122
|
+
# do local work while the server processes
|
|
123
|
+
do_local_computation()
|
|
124
|
+
|
|
125
|
+
# get the result (blocks until ready; raises TimeoutError if the timeout expires)
|
|
126
|
+
frame = server.get_result(timeout=1.0)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Use `has_result()` to poll without blocking, `wait_for_result(timeout)` to wait without retrieving, and `call_in_progress()` to check whether a call is outstanding.
|
|
130
|
+
|
|
131
|
+
### 5. Double-Buffering
|
|
132
|
+
Arrays returned by a call are views of shared memory, and that memory is reused by the next call to the same function. With a single allocation set, an async call therefore overwrites the previous result while the client may still be using it.
|
|
133
|
+
|
|
134
|
+
To avoid this, create two allocation sets for the function. `CallServer` rotates between them on every call, so the result of the previous call stays valid while the next call writes into the other set.
|
|
135
|
+
```python
|
|
136
|
+
# set up double-buffering
|
|
137
|
+
server.add_allocation_set(read_and_process_frame)
|
|
138
|
+
server.add_allocation_set(read_and_process_frame)
|
|
139
|
+
|
|
140
|
+
# prime the pipeline
|
|
141
|
+
server.call_async(read_and_process_frame)
|
|
142
|
+
|
|
143
|
+
while True:
|
|
144
|
+
# get the previous result
|
|
145
|
+
frame = server.get_result()
|
|
146
|
+
# start next frame processing on the server
|
|
147
|
+
server.call_async(read_and_process_frame)
|
|
148
|
+
# use the previous frame buffer locally
|
|
149
|
+
do_local_computation(frame)
|
|
150
|
+
cv2.imshow('frame', frame)
|
|
151
|
+
if cv2.waitKey(1) & 0xFF == ord('q'):
|
|
152
|
+
break
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
See [examples/async_calls.py](../examples/async_calls.py).
|
|
156
|
+
|
|
157
|
+
### 6. Pre-Allocation of shared memory
|
|
158
|
+
Buffers allocated with `CallServer.alloc()` live in shared memory, so passing or returning them involves no copy at all.
|
|
159
|
+
```python
|
|
160
|
+
buffer = CallServer.alloc(server, increment, (1024, 1024), float)
|
|
161
|
+
result = server.call(increment, 10, buffer)
|
|
162
|
+
|
|
163
|
+
# On server side, the buffer arrives as a shared memory numpy array
|
|
164
|
+
def increment(context, value, buffer):
|
|
165
|
+
buffer[:] = context # copy, no allocation
|
|
166
|
+
buffer += value # in-place addition
|
|
167
|
+
return buffer # buffer is already shared memory, so no allocation on return
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Buffers can be allocated on the server side as well. The server name argument must match the name provided to the CallServer on the client.
|
|
171
|
+
```python
|
|
172
|
+
def my_initializer(shape, value):
|
|
173
|
+
buffer = CallServer.alloc('my_server', increment, shape, float)
|
|
174
|
+
buffer[:] = value
|
|
175
|
+
return buffer
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
See [examples/preallocated_buffers.py](../examples/preallocated_buffers.py).
|
|
179
|
+
|
|
180
|
+
When pre-allocating buffers, follow these rules:
|
|
181
|
+
1. If the server has background threads pre-processing data directly in shared-memory buffers, you need to allocate an extra buffer (front- and back- buffers) and manage switching between them.
|
|
182
|
+
2. If the server-side function is only called synchronously, use a single allocation set.
|
|
183
|
+
3. If the server-side function is called async, use two allocation sets, with the same set of buffers in each (pass `alloc_set_id=0` and `alloc_set_id=1` to `CallServer.alloc()`). The CallServer will handle switching between the allocation sets between calls.
|
|
184
|
+
4. Mixing the back-buffer pattern and the double allocation-set pattern is not supported.
|
|
185
|
+
|
|
186
|
+
## Standalone Server
|
|
187
|
+
|
|
188
|
+
A server can also run as a separate, long-lived process. The port is required:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
callserver --port 5000
|
|
192
|
+
# or
|
|
193
|
+
python -m callserver --port 5000 [--address 127.0.0.1] [--authkey callserver]
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
or from Python:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from callserver import run, DEFAULT_AUTHKEY
|
|
200
|
+
run(("127.0.0.1", 5000, DEFAULT_AUTHKEY)) # blocks forever
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Clients connect by passing a `(name, endpoint)` tuple, where `endpoint` is a port or an `(address, port)` tuple. The `authkey` must match the server's `--authkey`.
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
with CallServer(('my_server', ('127.0.0.1', 5000)), authkey=b'callserver') as server:
|
|
207
|
+
result = server.call(np.add, a, b)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
- The server handles one client at a time. When a client disconnects, the server waits for the next one.
|
|
211
|
+
- If the next client uses the same name, context and context arguments, the existing server-side context is reused. Otherwise, it is re-created.
|
|
212
|
+
- Functions and context initializers sent to a standalone server must be importable by the server process, e.g. from installed packages, or from modules in the directory the server was started from with `python -m callserver`.
|
|
213
|
+
|
|
214
|
+
See [examples/standalone_client.py](../examples/standalone_client.py).
|
|
215
|
+
|
|
216
|
+
## Notes
|
|
217
|
+
|
|
218
|
+
- Always call `get_result()` after an asynchronous call before starting a new one.
|
|
219
|
+
- Returned arrays are views of shared memory that the next call to the same function reuses. Copy them (`result.copy()`) if you need to keep them beyond the next call, or use double-buffering.
|
|
220
|
+
- Arrays of 1024 bytes or less (`AllocationSet.MIN_SIZE`) are pickled and sent through the pipe or socket rather than promoted to shared memory.
|
|
221
|
+
- In child process mode, `stop()` waits for the server process to exit and releases all shared memory. In standalone mode, `stop()` only disconnects; the server keeps running.
|
|
222
|
+
|
|
223
|
+
## How It Works
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
┌─────────────────┐ ┌─────────────────┐
|
|
227
|
+
│ Client process │ │ Server process │
|
|
228
|
+
│ │ │ │
|
|
229
|
+
│ CallServer │ ── Pipe / TCP ─▶ │ run() loop │
|
|
230
|
+
│ │ ◀─ results ───── │ │
|
|
231
|
+
│ Allocation- │ │ Allocation- │
|
|
232
|
+
│ SetManager │ │ SetManager │
|
|
233
|
+
│ │ shared memory │ │
|
|
234
|
+
│ ┌───────────┐ │ │ ┌───────────┐ │
|
|
235
|
+
│ │ Buffer A │◀─┼──────────────────┼─▶│ Buffer A │ │
|
|
236
|
+
│ └───────────┘ │ │ └───────────┘ │
|
|
237
|
+
│ ┌───────────┐ │ │ ┌───────────┐ │
|
|
238
|
+
│ │ Buffer B │◀─┼──────────────────┼─▶│ Buffer B │ │
|
|
239
|
+
│ └───────────┘ │ │ └───────────┘ │
|
|
240
|
+
└─────────────────┘ └─────────────────┘
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
1. On each call, the client replaces large array arguments with `AllocationDescriptor`s (shared memory name, shape, dtype), copying the data into shared memory unless the array already lives there.
|
|
244
|
+
2. The function reference, descriptors and remaining arguments are pickled and sent through the pipe or socket.
|
|
245
|
+
3. The server maps the descriptors back to numpy arrays, calls the function, and replaces large arrays in the return value with descriptors.
|
|
246
|
+
4. The client maps the returned descriptors to numpy arrays.
|
|
247
|
+
|
|
248
|
+
Each process has one `AllocationSetManager` per server name, holding one or more `AllocationSet`s per function. Each `AllocationSet` tracks two kinds of shared memory buffers:
|
|
249
|
+
|
|
250
|
+
- **Linked buffers**: created by this process and owned by it (unlinked on close).
|
|
251
|
+
- **Mapped buffers**: created by the other process and only mapped for read/write access.
|
|
252
|
+
|
|
253
|
+
Buffers are created on the first call and reused on later calls, keyed by argument position or keyword name (`__arg0`, `__arg1`, ..., `__retval0`, ...).
|
|
254
|
+
|
|
255
|
+
## API Reference
|
|
256
|
+
|
|
257
|
+
### CallServer
|
|
258
|
+
|
|
259
|
+
The main client interface for making RPC calls.
|
|
260
|
+
|
|
261
|
+
**Constructor:**
|
|
262
|
+
```python
|
|
263
|
+
CallServer(name, context=None, *args, authkey=DEFAULT_AUTHKEY, **kwargs)
|
|
264
|
+
```
|
|
265
|
+
- `name`: Server identifier (string, spawns a child process), or `(name, endpoint)` tuple to connect to a standalone server, where `endpoint` is a port or an `(address, port)` tuple
|
|
266
|
+
- `context`: Server-side state object, or initializer callable that creates it on the server
|
|
267
|
+
- `*args`, `**kwargs`: Arguments passed to the context initializer
|
|
268
|
+
- `authkey`: Authentication key for connecting to a standalone server
|
|
269
|
+
|
|
270
|
+
**Methods:**
|
|
271
|
+
- `start()`: Start the server process or connect to a standalone server. Returns `self`.
|
|
272
|
+
- `call(fn, *args, **kwargs)`: Synchronous call. Returns the result.
|
|
273
|
+
- `call_async(fn, *args, **kwargs)`: Start an asynchronous call. Raises `RuntimeError` if a call is already in progress.
|
|
274
|
+
- `get_result(timeout=None)`: Retrieve the result of the async call, blocking until it is ready. Raises `TimeoutError` if the timeout expires. Returns the last result if no call is in progress.
|
|
275
|
+
- `wait_for_result(timeout=None)`: Wait for a result without retrieving it. Returns `True` if ready.
|
|
276
|
+
- `has_result()`: Check if a result is ready (non-blocking).
|
|
277
|
+
- `call_in_progress()`: Check if an async call is in progress.
|
|
278
|
+
- `add_allocation_set(fn)`: Add an allocation set for a function (call twice for double-buffering).
|
|
279
|
+
- `get_allocation_set(fn)`: Get the next allocation set for a function.
|
|
280
|
+
- `stop()`: Stop the server (child process mode) or disconnect (standalone mode), and clean up resources.
|
|
281
|
+
|
|
282
|
+
**Static Methods:**
|
|
283
|
+
- `CallServer.alloc(callserver, fn, shape, dtype=float, alloc_set_id=0)`: Pre-allocate a shared memory buffer for `fn`. `callserver` is a `CallServer` instance (client side) or the server name (server side). Returns a numpy array backed by shared memory.
|
|
284
|
+
|
|
285
|
+
### Functions
|
|
286
|
+
|
|
287
|
+
- `run(endpoint_or_connection)`: Run the server loop. Pass an `(address, port, authkey)` tuple to run a standalone TCP server.
|
|
288
|
+
- `run_console()`: Command-line entry point used by the `callserver` command and `python -m callserver`. Options: `--port` (required), `--address` (default `127.0.0.1`), `--authkey` (default `callserver`).
|
|
289
|
+
|
|
290
|
+
### Constants
|
|
291
|
+
|
|
292
|
+
- `DEFAULT_AUTHKEY`: Default authentication key (`b"callserver"`).
|
|
293
|
+
|
|
294
|
+
### AllocationSet
|
|
295
|
+
|
|
296
|
+
Manages shared memory buffers for a single function.
|
|
297
|
+
|
|
298
|
+
- `MIN_SIZE`: Arrays larger than this many bytes are promoted to shared memory (default 1024).
|
|
299
|
+
- `alloc(name, shape, dtype, init=None)`: Allocate a new buffer. Returns an `AllocationDescriptor`.
|
|
300
|
+
- `get(name, init=None)`: Get the descriptor of an existing buffer.
|
|
301
|
+
- `from_sharedmemory(descriptor)`: Map a descriptor to a numpy array.
|
|
302
|
+
- `close()`: Release all shared memory resources.
|
|
303
|
+
|
|
304
|
+
### AllocationSetManager
|
|
305
|
+
|
|
306
|
+
Manages the allocation sets of all functions for one server name. There is one instance per name in each process.
|
|
307
|
+
|
|
308
|
+
- `add_allocation_set(fn)`: Add a new set for a function.
|
|
309
|
+
- `get_allocation_set(fn, idx=None)`: Get the set at `idx`, creating it if needed. With `idx=None`, rotates to the next set.
|
|
310
|
+
- `get_active_id(fn)`: Index of the most recently used set.
|
|
311
|
+
- `close()`: Release all resources.
|
|
312
|
+
|
|
313
|
+
### AllocationDescriptor
|
|
314
|
+
|
|
315
|
+
Lightweight, picklable descriptor for a shared memory buffer (`name`, `shape`, `dtype`).
|