knitting 0.1.61 → 0.1.63
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.
- package/README.md +10 -9
- package/knitting.browser.js +1 -1
- package/package.json +1 -1
- package/src/api.js +26 -16
- package/src/common/task-source.d.ts +9 -0
- package/src/common/task-source.js +15 -0
- package/src/ipc/transport/shared-memory.d.ts +9 -1
- package/src/ipc/transport/shared-memory.js +13 -1
- package/src/memory/lock.d.ts +12 -1
- package/src/memory/lock.js +82 -41
- package/src/runtime/dispatcher.d.ts +11 -8
- package/src/runtime/dispatcher.js +159 -26
- package/src/runtime/inline-executor.js +6 -1
- package/src/runtime/pool.d.ts +22 -4
- package/src/runtime/pool.js +104 -27
- package/src/runtime/process-worker.d.ts +22 -0
- package/src/runtime/process-worker.js +116 -2
- package/src/runtime/tx-queue.d.ts +2 -0
- package/src/runtime/tx-queue.js +68 -0
- package/src/runtime/worker-common.d.ts +2 -1
- package/src/runtime/worker-common.js +20 -5
- package/src/types.d.ts +44 -9
- package/src/worker/loop.js +24 -6
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
import { createRuntimeMessageChannel, } from "../common/worker-runtime.js";
|
|
2
|
-
|
|
2
|
+
import { RUNTIME, SET_IMMEDIATE } from "../common/runtime.js";
|
|
3
|
+
/**
|
|
4
|
+
* Macrotask primitive for the host pump, picked per runtime: a round trip costs
|
|
5
|
+
* 757ns on Bun via MessageChannel but 4662ns on Deno, where `setImmediate` is
|
|
6
|
+
* 1110ns. Bun keeps the channel; browser and Andromeda have no choice.
|
|
7
|
+
*
|
|
8
|
+
* `serial-channel` opts back into the channel explicitly (see `src/api.ts`): one
|
|
9
|
+
* hop drives every lane's check in turn and relies on the channel's delivery to
|
|
10
|
+
* do it, so a merely cheaper pump starves it -- it cost 25% throughput there.
|
|
11
|
+
*/
|
|
12
|
+
const IMMEDIATE_PUMP = RUNTIME === "deno" || RUNTIME === "node"
|
|
13
|
+
? SET_IMMEDIATE
|
|
14
|
+
: undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Free drain hops before `scheduleNotify` stops re-arming the pump for free.
|
|
17
|
+
*
|
|
18
|
+
* The window has to match what the dispatcher escalates *to*, so these are
|
|
19
|
+
* chosen together and `canUseDoorbell` picks between them. Polling escalates to
|
|
20
|
+
* the `setTimeout` ladder, whose finest rung is ~1.1ms on every runtime, so it
|
|
21
|
+
* wants a wide window; that sleep is also load-bearing, since it batches
|
|
22
|
+
* completions. A doorbell escalates to `Atomics.waitAsync` at about the price of
|
|
23
|
+
* one hop, so it wants a narrow one.
|
|
24
|
+
*
|
|
25
|
+
* Mixing them is the trap: a doorbell behind the wide window keeps every poll
|
|
26
|
+
* hop *and* adds the arm, and loses to plain polling at every thread count.
|
|
27
|
+
* Measurements behind both values: `docs/host-doorbell-proposal.md`.
|
|
28
|
+
*/
|
|
29
|
+
const POLL_STALL_FREE_LOOPS = 128;
|
|
30
|
+
const DOORBELL_STALL_FREE_LOOPS = 1;
|
|
31
|
+
export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, waitForCompletion, setCompletionWaiterArmed, }, channelHandler, dispatcherOptions, notifySignal, crossProcess, }) => {
|
|
3
32
|
const a_load = Atomics.load;
|
|
4
33
|
const a_store = Atomics.store;
|
|
5
34
|
const a_notify = Atomics.notify;
|
|
@@ -10,13 +39,81 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
|
|
|
10
39
|
a_notify(opView, 0, 1);
|
|
11
40
|
});
|
|
12
41
|
const notify = () => channelHandler.notify();
|
|
42
|
+
// `crossProcess` is a capability, not a preference, so it overrides an
|
|
43
|
+
// explicit `doorbell: true`: V8's Atomics waiter list is per isolate, so a
|
|
44
|
+
// worker in another process can never ring the host's waiter, and an armed
|
|
45
|
+
// doorbell would just sleep to the watchdog.
|
|
46
|
+
const canUseDoorbell = crossProcess !== true &&
|
|
47
|
+
(dispatcherOptions?.doorbell ?? true) &&
|
|
48
|
+
(RUNTIME === "bun" || RUNTIME === "node") &&
|
|
49
|
+
typeof Atomics.waitAsync === "function";
|
|
50
|
+
let doorbellEnabled = canUseDoorbell;
|
|
51
|
+
let doorbellArmed = false;
|
|
52
|
+
let doorbellEpoch = 0 | 0;
|
|
53
|
+
const DOORBELL_WATCHDOG_MS = 1000;
|
|
13
54
|
let stallCount = 0 | 0;
|
|
14
|
-
const
|
|
55
|
+
const requestedStallFreeLoops = dispatcherOptions?.stallFreeLoops;
|
|
56
|
+
let stallFreeLoops = requestedStallFreeLoops !== undefined
|
|
57
|
+
? Math.max(0, requestedStallFreeLoops | 0)
|
|
58
|
+
: canUseDoorbell
|
|
59
|
+
? DOORBELL_STALL_FREE_LOOPS
|
|
60
|
+
: POLL_STALL_FREE_LOOPS;
|
|
15
61
|
const MAX_BACKOFF_MS = Math.max(0, (dispatcherOptions?.maxBackoffMs ?? 10) | 0);
|
|
16
62
|
let backoffTimer;
|
|
17
63
|
// inFlight prevents re-entrancy when pool.ts fires check() concurrently
|
|
18
64
|
// from both send() and the channel callback. Cheaper than try/finally.
|
|
19
65
|
let inFlight = false;
|
|
66
|
+
const cancelDoorbell = () => {
|
|
67
|
+
if (!doorbellArmed)
|
|
68
|
+
return;
|
|
69
|
+
doorbellEpoch = (doorbellEpoch + 1) | 0;
|
|
70
|
+
doorbellArmed = false;
|
|
71
|
+
setCompletionWaiterArmed(false);
|
|
72
|
+
};
|
|
73
|
+
const armDoorbell = () => {
|
|
74
|
+
if (!doorbellEnabled || doorbellArmed === true) {
|
|
75
|
+
if (!doorbellEnabled)
|
|
76
|
+
notify();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const token = (doorbellEpoch + 1) | 0;
|
|
80
|
+
doorbellEpoch = token;
|
|
81
|
+
doorbellArmed = true;
|
|
82
|
+
let woke = false;
|
|
83
|
+
const wake = () => {
|
|
84
|
+
if (!doorbellArmed || doorbellEpoch !== token || woke)
|
|
85
|
+
return;
|
|
86
|
+
woke = true;
|
|
87
|
+
doorbellArmed = false;
|
|
88
|
+
doorbellEpoch = (doorbellEpoch + 1) | 0;
|
|
89
|
+
setCompletionWaiterArmed(false);
|
|
90
|
+
// Keep the existing macrotask boundary. Calling check directly from a
|
|
91
|
+
// resolved waitAsync promise can chain microtasks under a hot workload
|
|
92
|
+
// and starve host I/O.
|
|
93
|
+
notify();
|
|
94
|
+
};
|
|
95
|
+
let supported = false;
|
|
96
|
+
try {
|
|
97
|
+
supported = waitForCompletion(wake, DOORBELL_WATCHDOG_MS);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
supported = false;
|
|
101
|
+
}
|
|
102
|
+
// Some runtimes reject waitAsync on their SharedArrayBuffer variants; fall
|
|
103
|
+
// back to polling rather than hanging the pool.
|
|
104
|
+
if (!supported) {
|
|
105
|
+
doorbellEnabled = false;
|
|
106
|
+
// The window was narrowed for a doorbell that no longer exists; widen it
|
|
107
|
+
// back or the ladder fires after a single fruitless drain.
|
|
108
|
+
if (requestedStallFreeLoops === undefined) {
|
|
109
|
+
stallFreeLoops = POLL_STALL_FREE_LOOPS;
|
|
110
|
+
}
|
|
111
|
+
doorbellArmed = false;
|
|
112
|
+
doorbellEpoch = (doorbellEpoch + 1) | 0;
|
|
113
|
+
setCompletionWaiterArmed(false);
|
|
114
|
+
notify();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
20
117
|
const check = () => {
|
|
21
118
|
if (inFlight) {
|
|
22
119
|
// Another check() is already mid-drain; mark that a re-run is needed
|
|
@@ -24,11 +121,12 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
|
|
|
24
121
|
check.rerun = true;
|
|
25
122
|
return;
|
|
26
123
|
}
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
//
|
|
124
|
+
// A waitAsync cannot be cancelled, so bumping its epoch makes the eventual
|
|
125
|
+
// callback inert instead.
|
|
126
|
+
cancelDoorbell();
|
|
127
|
+
// Idle lane: skip the drain. Otherwise it pays a txStatus write, an rxStatus
|
|
128
|
+
// load, and an Atomics.notify that wakes a parked worker for nothing — the
|
|
129
|
+
// waste that makes serial-channel latency grow with thread count.
|
|
32
130
|
if (txIdle()) {
|
|
33
131
|
check.isRunning = false;
|
|
34
132
|
return;
|
|
@@ -46,25 +144,27 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
|
|
|
46
144
|
a_store(opView, 0, 1);
|
|
47
145
|
wakeSignal();
|
|
48
146
|
}
|
|
49
|
-
//
|
|
50
|
-
|
|
147
|
+
// Local vars so V8 keeps them as unboxed int32. Only a reaped completion
|
|
148
|
+
// counts as progress for `stallCount`: the pump exists to notice work a
|
|
149
|
+
// worker cannot announce, and letting a flush reset the counter would
|
|
150
|
+
// leave the escalation unreachable for a pool that never runs dry.
|
|
151
|
+
let completed = false;
|
|
51
152
|
let progressed = true;
|
|
52
153
|
while (progressed) {
|
|
53
154
|
progressed = false;
|
|
54
155
|
if (completeFrame() > 0) {
|
|
55
156
|
progressed = true;
|
|
56
|
-
|
|
157
|
+
completed = true;
|
|
57
158
|
}
|
|
58
159
|
while (hasPendingFrames()) {
|
|
59
160
|
if (!flushToWorker())
|
|
60
161
|
break;
|
|
61
162
|
progressed = true;
|
|
62
|
-
anyProgressed = true;
|
|
63
163
|
}
|
|
64
164
|
}
|
|
65
165
|
txStatus[0] = 0;
|
|
66
166
|
if (!txIdle()) {
|
|
67
|
-
if (
|
|
167
|
+
if (completed || hasPendingFrames()) {
|
|
68
168
|
stallCount = 0 | 0;
|
|
69
169
|
}
|
|
70
170
|
else {
|
|
@@ -83,14 +183,22 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
|
|
|
83
183
|
check.isRunning = false;
|
|
84
184
|
check.rerun = false;
|
|
85
185
|
const scheduleNotify = () => {
|
|
86
|
-
if (stallCount <=
|
|
186
|
+
if (stallCount <= stallFreeLoops) {
|
|
87
187
|
notify();
|
|
88
188
|
return;
|
|
89
189
|
}
|
|
190
|
+
if (doorbellEnabled) {
|
|
191
|
+
// Release isRunning during the wait so a fresh send() can restart the
|
|
192
|
+
// dispatcher immediately. The epoch in cancelDoorbell() invalidates the
|
|
193
|
+
// old waiter in that case.
|
|
194
|
+
check.isRunning = false;
|
|
195
|
+
armDoorbell();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
90
198
|
// One delayed wakeup at a time; fresh send() calls preempt via check directly.
|
|
91
199
|
if (backoffTimer !== undefined)
|
|
92
200
|
return;
|
|
93
|
-
let delay = (stallCount -
|
|
201
|
+
let delay = (stallCount - stallFreeLoops - 1) | 0;
|
|
94
202
|
if (delay < 0)
|
|
95
203
|
delay = 0;
|
|
96
204
|
else if (delay > MAX_BACKOFF_MS)
|
|
@@ -108,25 +216,47 @@ export const hostDispatcherLoop = ({ signalBox: { opView, txStatus, rxStatus, },
|
|
|
108
216
|
return { check };
|
|
109
217
|
};
|
|
110
218
|
export class ChannelHandler {
|
|
219
|
+
// Set only when the pump is a MessageChannel. The `setImmediate` pump has no
|
|
220
|
+
// ports, and nothing outside this class reads them.
|
|
111
221
|
channel;
|
|
112
222
|
port1;
|
|
113
223
|
port2;
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
224
|
+
#handler;
|
|
225
|
+
#notify;
|
|
226
|
+
constructor(pump = "auto") {
|
|
227
|
+
if (pump === "auto" && IMMEDIATE_PUMP !== undefined) {
|
|
228
|
+
const immediate = IMMEDIATE_PUMP;
|
|
229
|
+
// Allocated once; the pump fires hundreds of thousands of times a second.
|
|
230
|
+
// Routing via `#handler` also makes a callback outliving `close()` inert.
|
|
231
|
+
const run = () => {
|
|
232
|
+
this.#handler?.();
|
|
233
|
+
};
|
|
234
|
+
this.#notify = () => {
|
|
235
|
+
immediate(run);
|
|
236
|
+
};
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const channel = createRuntimeMessageChannel();
|
|
240
|
+
const port2 = channel.port2;
|
|
241
|
+
this.channel = channel;
|
|
242
|
+
this.port1 = channel.port1;
|
|
243
|
+
this.port2 = port2;
|
|
244
|
+
this.#notify = () => {
|
|
245
|
+
port2.postMessage(null);
|
|
246
|
+
};
|
|
120
247
|
}
|
|
121
248
|
notify() {
|
|
122
|
-
this.#
|
|
249
|
+
this.#notify();
|
|
123
250
|
}
|
|
124
251
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
252
|
+
* Registers the handler the pump calls back into. On the channel pump this is
|
|
253
|
+
* also where the ports are opened, so `notify` can reach port 1.
|
|
127
254
|
*/
|
|
128
255
|
open(f) {
|
|
256
|
+
this.#handler = f;
|
|
129
257
|
const port1 = this.port1;
|
|
258
|
+
if (port1 === undefined)
|
|
259
|
+
return;
|
|
130
260
|
if (typeof port1.on === "function") {
|
|
131
261
|
port1.on("message", f);
|
|
132
262
|
}
|
|
@@ -134,13 +264,16 @@ export class ChannelHandler {
|
|
|
134
264
|
// @ts-ignore
|
|
135
265
|
port1.onmessage = f;
|
|
136
266
|
}
|
|
137
|
-
this.port1
|
|
138
|
-
this.port2
|
|
267
|
+
this.port1?.start?.();
|
|
268
|
+
this.port2?.start?.();
|
|
139
269
|
}
|
|
140
270
|
/**
|
|
141
|
-
*
|
|
271
|
+
* Detaches the handler, and closes the channel if this pump has one.
|
|
142
272
|
*/
|
|
143
273
|
close() {
|
|
274
|
+
this.#handler = undefined;
|
|
275
|
+
if (this.port1 === undefined || this.port2 === undefined)
|
|
276
|
+
return;
|
|
144
277
|
//@ts-ignore
|
|
145
278
|
this.port1.onmessage = null;
|
|
146
279
|
//@ts-ignore
|
|
@@ -81,9 +81,14 @@ const composeInlineCallable = (fn, timeout, useAbortToolkit = false) => {
|
|
|
81
81
|
};
|
|
82
82
|
};
|
|
83
83
|
export const createInlineExecutor = ({ tasks, genTaskID, batchSize, }) => {
|
|
84
|
+
// `createPool` passes an already name-sorted array, which is the order
|
|
85
|
+
// `fnNumber` indexes into. The object form has to reach the same order on its
|
|
86
|
+
// own: task ids identify a task, they do not rank it, so sorting by id would
|
|
87
|
+
// hand callers a different lane than the one they addressed.
|
|
84
88
|
const entries = Array.isArray(tasks)
|
|
85
89
|
? tasks
|
|
86
|
-
: Object.values(tasks)
|
|
90
|
+
: Object.values(tasks)
|
|
91
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
87
92
|
const runners = entries.map((entry) => {
|
|
88
93
|
// Imported tasks must never execute on the host inline lane: their module
|
|
89
94
|
// import is meant to stay inside the worker so worker permission policies
|
package/src/runtime/pool.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHostTxQueue } from "./tx-queue.js";
|
|
2
|
-
import { type ProcessSharedMemoryBacking } from "./process-worker.js";
|
|
2
|
+
import { type ProcessSharedMemoryBacking, type ProcessStealMemoryLayout, type ResolvedProcessSharedMemorySettings } from "./process-worker.js";
|
|
3
3
|
import { type Sab } from "../ipc/transport/shared-memory.js";
|
|
4
4
|
import { ChannelHandler, type DispatcherCheck } from "./dispatcher.js";
|
|
5
5
|
import { lock2, type Task } from "../memory/lock.js";
|
|
@@ -20,12 +20,16 @@ import { type PayloadBufferOptions } from "../memory/payload-config.js";
|
|
|
20
20
|
export declare const resolveStealRegionLanes: (consumers: number) => number;
|
|
21
21
|
/** Maximum claimants that leave the protocol's required spare region. */
|
|
22
22
|
export declare const MAX_STEAL_CONSUMERS: number;
|
|
23
|
-
export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, abortSignalCapacity, usesAbortSignal, }: {
|
|
23
|
+
export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, abortSignalCapacity, usesAbortSignal, processWorker, }: {
|
|
24
24
|
threads: number;
|
|
25
25
|
payload?: PayloadBufferOptions;
|
|
26
26
|
regionLanes?: number;
|
|
27
27
|
abortSignalCapacity?: number;
|
|
28
28
|
usesAbortSignal?: boolean;
|
|
29
|
+
processWorker?: {
|
|
30
|
+
signalBytes: number;
|
|
31
|
+
sharedMemory: ResolvedProcessSharedMemorySettings;
|
|
32
|
+
};
|
|
29
33
|
}) => {
|
|
30
34
|
submitBuffers: LockBuffers;
|
|
31
35
|
returnBuffers: LockBuffers[];
|
|
@@ -48,6 +52,8 @@ export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, a
|
|
|
48
52
|
shouldSettle?: (task: Task) => boolean;
|
|
49
53
|
activeRejectPlaceholder?: Task["reject"];
|
|
50
54
|
}) => () => number;
|
|
55
|
+
waitForHostChange: (timeoutMs?: number) => import("../memory/lock.js").WaitAsyncResult | undefined;
|
|
56
|
+
setHostWaiterArmed: (armed: boolean) => void;
|
|
51
57
|
hasPendingFrames: () => boolean;
|
|
52
58
|
getPendingFrameCount: () => number;
|
|
53
59
|
getPendingPromiseCount: () => number;
|
|
@@ -75,6 +81,8 @@ export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, a
|
|
|
75
81
|
shouldSettle?: (task: Task) => boolean;
|
|
76
82
|
activeRejectPlaceholder?: Task["reject"];
|
|
77
83
|
}) => () => number;
|
|
84
|
+
waitForHostChange: (timeoutMs?: number) => import("../memory/lock.js").WaitAsyncResult | undefined;
|
|
85
|
+
setHostWaiterArmed: (armed: boolean) => void;
|
|
78
86
|
hasPendingFrames: () => boolean;
|
|
79
87
|
getPendingFrameCount: () => number;
|
|
80
88
|
getPendingPromiseCount: () => number;
|
|
@@ -88,6 +96,8 @@ export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, a
|
|
|
88
96
|
hasPendingFrames: () => boolean;
|
|
89
97
|
txIdle: () => boolean;
|
|
90
98
|
completeFrame: () => number;
|
|
99
|
+
waitForCompletion: (onWake: () => void, timeoutMs?: number) => boolean;
|
|
100
|
+
setCompletionWaiterArmed: (armed: boolean) => void;
|
|
91
101
|
setReturnHooks: (lane: number, hooks: (import("../connections/buffer-reference.js").BufferReferenceReturnHooks | ((token: bigint) => void)) | undefined) => void;
|
|
92
102
|
enqueue: (functionID: number, timeout?: import("../types.js").TaskTimeout, abortSignal?: import("../types.js").AbortSignalOption) => (rawArgs: unknown) => Promise<never> | import("../common/with-resolvers.js").PromiseWithMaybeReject<unknown>;
|
|
93
103
|
flushToWorker: () => boolean;
|
|
@@ -95,10 +105,11 @@ export declare const createStealPoolBuffers: ({ threads, payload, regionLanes, a
|
|
|
95
105
|
settlePromisePayload: (task: Task, isRejected: boolean, value: unknown) => boolean;
|
|
96
106
|
};
|
|
97
107
|
regionLanes: number;
|
|
98
|
-
|
|
108
|
+
processMemory: ProcessStealMemoryLayout;
|
|
109
|
+
abortSignalSAB: SharedArrayBuffer | import("../types.js").SharedBufferRegion;
|
|
99
110
|
abortSignalMax: number;
|
|
100
111
|
};
|
|
101
|
-
export declare const spawnWorkerContext: ({ list, ids, names, sab, thread, debug, hostDebug, totalNumberOfThread, source, at, workerOptions, workerExecArgv, permission, host, payload, bufferReferenceReturn, abortSignalCapacity, usesAbortSignal, sharedChannelHandler, stealPool, }: {
|
|
112
|
+
export declare const spawnWorkerContext: ({ list, ids, names, sab, thread, debug, hostDebug, totalNumberOfThread, workerCount, source, at, workerOptions, workerExecArgv, permission, host, payload, bufferReferenceReturn, abortSignalCapacity, usesAbortSignal, sharedChannelHandler, stealPool, }: {
|
|
102
113
|
list: string[];
|
|
103
114
|
ids: number[];
|
|
104
115
|
names: string[];
|
|
@@ -108,6 +119,12 @@ export declare const spawnWorkerContext: ({ list, ids, names, sab, thread, debug
|
|
|
108
119
|
debug?: DebugOptions;
|
|
109
120
|
hostDebug?: (message: string) => void;
|
|
110
121
|
totalNumberOfThread: number;
|
|
122
|
+
/**
|
|
123
|
+
* Workers only, excluding any host inline lane. Drives the spin policy:
|
|
124
|
+
* the inliner runs on the host thread and never spins, so counting it
|
|
125
|
+
* would strip a one-worker pool of the budget it should keep.
|
|
126
|
+
*/
|
|
127
|
+
workerCount?: number;
|
|
111
128
|
source?: string;
|
|
112
129
|
workerOptions?: WorkerSettings;
|
|
113
130
|
workerExecArgv?: string[];
|
|
@@ -140,6 +157,7 @@ export declare const spawnWorkerContext: ({ list, ids, names, sab, thread, debug
|
|
|
140
157
|
regionLanes: number;
|
|
141
158
|
abortSignalSAB?: LockBuffers["headers"];
|
|
142
159
|
abortSignalMax?: number;
|
|
160
|
+
processMemory?: ProcessStealMemoryLayout;
|
|
143
161
|
};
|
|
144
162
|
}) => (WorkerContext & {
|
|
145
163
|
dispatcherCheck?: never;
|
package/src/runtime/pool.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { createHostTxQueue } from "./tx-queue.js";
|
|
2
|
-
import { cleanupProcessWorkerMemoryQuietly, createProcessSharedMemoryAllocator, createProcessWorkerMemoryLayout, createProcessWorkerNativeSignalNotifier, readProcessSharedMemorySettings, readProcessWorkerCommandPrefix, readProcessWorkerRuntime, spawnProcessWorker, toProcessWorkerBootPayload, } from "./process-worker.js";
|
|
2
|
+
import { cleanupProcessWorkerMemoryQuietly, createProcessSharedMemoryAllocator, createProcessStealMemoryLayout, createProcessWorkerMemoryLayout, createProcessWorkerNativeSignalNotifier, readProcessSharedMemorySettings, readProcessWorkerCommandPrefix, readProcessWorkerRuntime, spawnProcessWorker, toProcessWorkerBootPayload, } from "./process-worker.js";
|
|
3
3
|
import { serializeWorkerBootstrapData, terminateWorkerQuietly, toWorkerCompatExecArgv, toWorkerSafeExecArgv, } from "./worker-common.js";
|
|
4
|
-
import { createSharedMemoryTransport, TRANSPORT_SIGNAL_BYTES, } from "../ipc/transport/shared-memory.js";
|
|
4
|
+
import { createSharedMemoryTransport, TRANSPORT_SIGNAL_BYTES, WORKER_STOP, } from "../ipc/transport/shared-memory.js";
|
|
5
5
|
import { ChannelHandler, hostDispatcherLoop, } from "./dispatcher.js";
|
|
6
6
|
import { HEADER_SLOT_STRIDE_U32, lock2, LOCK_SECTOR_BYTE_LENGTH, LockBound, } from "../memory/lock.js";
|
|
7
7
|
// Side-effect import: registers the payload codec (cycle break for Andromeda;
|
|
8
8
|
// see lock.ts). Must run before any lock2() call.
|
|
9
9
|
import "../memory/payloadCodec.js";
|
|
10
10
|
import "../worker/loop.js";
|
|
11
|
-
import { createSharedArrayBuffer, createWasmSharedArrayBuffer, IS_BROWSER, } from "../common/runtime.js";
|
|
11
|
+
import { createSharedArrayBuffer, createWasmSharedArrayBuffer, IS_BROWSER, RUNTIME, } from "../common/runtime.js";
|
|
12
12
|
import { HAS_NODE_WORKER_THREADS, RUNTIME_WORKER, } from "../common/worker-runtime.js";
|
|
13
13
|
import { probeLockBufferTextCompat } from "../common/shared-buffer-text.js";
|
|
14
14
|
import { signalAbortFactory } from "../shared/abortSignal.js";
|
|
@@ -20,8 +20,12 @@ const WORKER_FATAL_MESSAGE_KEY = "__knittingWorkerFatal";
|
|
|
20
20
|
const isWorkerFatalMessage = (value) => !!value &&
|
|
21
21
|
typeof value === "object" &&
|
|
22
22
|
typeof value[WORKER_FATAL_MESSAGE_KEY] === "string";
|
|
23
|
+
// Bound the wait for a worker to acknowledge loop exit.
|
|
24
|
+
const WORKER_STOP_ACK_TIMEOUT_MS = 50;
|
|
25
|
+
const WORKER_STOP_NEEDS_ACK = RUNTIME === "deno";
|
|
23
26
|
// Keep idle workers self-healing if an Atomics.notify wake is missed.
|
|
24
27
|
const DEFAULT_WORKER_PARK_MS = 1;
|
|
28
|
+
const SINGLE_WORKER_SPIN_US = 50;
|
|
25
29
|
const DEFAULT_ABORT_SIGNAL_CAPACITY = 258;
|
|
26
30
|
const sanitizePositiveInteger = (value) => {
|
|
27
31
|
if (!Number.isFinite(value))
|
|
@@ -31,15 +35,20 @@ const sanitizePositiveInteger = (value) => {
|
|
|
31
35
|
};
|
|
32
36
|
const resolveAbortSignalCapacity = (value) => sanitizePositiveInteger(value) ?? DEFAULT_ABORT_SIGNAL_CAPACITY;
|
|
33
37
|
const abortSignalByteLength = (capacity) => Math.max(1, Math.ceil(capacity / 32)) * Uint32Array.BYTES_PER_ELEMENT;
|
|
34
|
-
const withDefaultWorkerTimers = (options) => {
|
|
38
|
+
const withDefaultWorkerTimers = (options, workerCount) => {
|
|
35
39
|
const parkMs = options?.timers?.parkMs ?? DEFAULT_WORKER_PARK_MS;
|
|
40
|
+
// `??` and not `||`: an explicit `spinMicroseconds: 0` is the whole point of
|
|
41
|
+
// the multi-worker default and must not fall through to it.
|
|
42
|
+
const spinMicroseconds = options?.timers?.spinMicroseconds ??
|
|
43
|
+
(workerCount <= 1 ? SINGLE_WORKER_SPIN_US : 0);
|
|
36
44
|
if (options === undefined)
|
|
37
|
-
return { timers: { parkMs } };
|
|
45
|
+
return { timers: { parkMs, spinMicroseconds } };
|
|
38
46
|
return {
|
|
39
47
|
...options,
|
|
40
48
|
timers: {
|
|
41
49
|
...options.timers,
|
|
42
50
|
parkMs,
|
|
51
|
+
spinMicroseconds,
|
|
43
52
|
},
|
|
44
53
|
};
|
|
45
54
|
};
|
|
@@ -67,8 +76,11 @@ export const resolveStealRegionLanes = (consumers) => {
|
|
|
67
76
|
};
|
|
68
77
|
/** Maximum claimants that leave the protocol's required spare region. */
|
|
69
78
|
export const MAX_STEAL_CONSUMERS = LockBound.slots - 1;
|
|
70
|
-
export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSignalCapacity, usesAbortSignal, }) => {
|
|
71
|
-
const
|
|
79
|
+
export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSignalCapacity, usesAbortSignal, processWorker, }) => {
|
|
80
|
+
const basePayloadConfig = resolvePayloadBufferOptions({ options: payload });
|
|
81
|
+
const payloadConfig = processWorker === undefined
|
|
82
|
+
? basePayloadConfig
|
|
83
|
+
: withFixedPayloadConfig(basePayloadConfig);
|
|
72
84
|
const makePayload = () => payloadConfig.mode === "growable"
|
|
73
85
|
? createSharedArrayBuffer(payloadConfig.payloadInitialBytes, payloadConfig.payloadMaxByteLength)
|
|
74
86
|
: createSharedArrayBuffer(payloadConfig.payloadInitialBytes);
|
|
@@ -92,8 +104,25 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
|
|
|
92
104
|
const lanes = regionLanes === undefined
|
|
93
105
|
? maxLanes
|
|
94
106
|
: Math.min(Math.max(1, regionLanes | 0), maxLanes);
|
|
95
|
-
const
|
|
96
|
-
const
|
|
107
|
+
const resolvedAbortSignalCapacity = resolveAbortSignalCapacity(abortSignalCapacity);
|
|
108
|
+
const processMemory = processWorker === undefined ? undefined : createProcessStealMemoryLayout({
|
|
109
|
+
threads,
|
|
110
|
+
signalBytes: processWorker.signalBytes,
|
|
111
|
+
abortBytes: usesAbortSignal === true
|
|
112
|
+
? abortSignalByteLength(resolvedAbortSignalCapacity)
|
|
113
|
+
: 0,
|
|
114
|
+
abortSignalMax: usesAbortSignal === true
|
|
115
|
+
? resolvedAbortSignalCapacity
|
|
116
|
+
: undefined,
|
|
117
|
+
payloadBytes: payloadConfig.payloadMaxByteLength,
|
|
118
|
+
sharedMemory: processWorker.sharedMemory,
|
|
119
|
+
});
|
|
120
|
+
const submitBuffers = processMemory === undefined
|
|
121
|
+
? (() => {
|
|
122
|
+
const submitCarpet = carpet();
|
|
123
|
+
return toBuffers(submitCarpet.lock, makePayload());
|
|
124
|
+
})()
|
|
125
|
+
: toBuffers(processMemory.workers[0].controlLayout.lock, processMemory.workers[0].lockPayload);
|
|
97
126
|
const hostSubmitLock = lock2({
|
|
98
127
|
headers: submitBuffers.headers,
|
|
99
128
|
headerSlotStrideU32: submitBuffers.headerSlotStrideU32,
|
|
@@ -104,11 +133,14 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
|
|
|
104
133
|
textCompat: submitBuffers.textCompat,
|
|
105
134
|
consumers: threads,
|
|
106
135
|
regionLanes: lanes,
|
|
136
|
+
processBoundary: processMemory !== undefined,
|
|
107
137
|
});
|
|
108
138
|
const returnBuffers = [];
|
|
109
139
|
const hostReturnLocks = [];
|
|
110
140
|
for (let i = 0; i < threads; i++) {
|
|
111
|
-
const buffers =
|
|
141
|
+
const buffers = processMemory === undefined
|
|
142
|
+
? toBuffers(carpet().returnLock, makePayload())
|
|
143
|
+
: toBuffers(processMemory.workers[i].controlLayout.returnLock, processMemory.workers[i].returnPayload);
|
|
112
144
|
returnBuffers.push(buffers);
|
|
113
145
|
hostReturnLocks.push(lock2({
|
|
114
146
|
headers: buffers.headers,
|
|
@@ -118,14 +150,15 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
|
|
|
118
150
|
payloadSector: buffers.payloadSector,
|
|
119
151
|
payloadConfig,
|
|
120
152
|
textCompat: buffers.textCompat,
|
|
153
|
+
processBoundary: processMemory !== undefined,
|
|
121
154
|
}));
|
|
122
155
|
}
|
|
123
156
|
// Abort-aware tasks can execute on any claimant, so all workers and the
|
|
124
157
|
// pool-global queue must observe one bitmap. Per-lane abort buffers would
|
|
125
158
|
// encode an id from one allocator and test it against another lane's bits.
|
|
126
|
-
const resolvedAbortSignalCapacity = resolveAbortSignalCapacity(abortSignalCapacity);
|
|
127
159
|
const abortSignalSAB = usesAbortSignal === true
|
|
128
|
-
?
|
|
160
|
+
? processMemory?.workers[0]?.controlLayout.abortSignals ??
|
|
161
|
+
createSharedArrayBuffer(abortSignalByteLength(resolvedAbortSignalCapacity))
|
|
129
162
|
: undefined;
|
|
130
163
|
const abortSignals = abortSignalSAB === undefined
|
|
131
164
|
? undefined
|
|
@@ -146,13 +179,13 @@ export const createStealPoolBuffers = ({ threads, payload, regionLanes, abortSig
|
|
|
146
179
|
hostReturnLocks,
|
|
147
180
|
sharedQueue,
|
|
148
181
|
regionLanes: lanes,
|
|
182
|
+
processMemory,
|
|
149
183
|
abortSignalSAB,
|
|
150
|
-
abortSignalMax:
|
|
151
|
-
? undefined
|
|
152
|
-
: resolvedAbortSignalCapacity,
|
|
184
|
+
abortSignalMax: processMemory?.abortSignalMax ??
|
|
185
|
+
(abortSignalSAB === undefined ? undefined : resolvedAbortSignalCapacity),
|
|
153
186
|
};
|
|
154
187
|
};
|
|
155
|
-
export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostDebug, totalNumberOfThread, source, at, workerOptions, workerExecArgv, permission, host, payload, bufferReferenceReturn, abortSignalCapacity, usesAbortSignal, sharedChannelHandler, stealPool, }) => {
|
|
188
|
+
export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostDebug, totalNumberOfThread, workerCount, source, at, workerOptions, workerExecArgv, permission, host, payload, bufferReferenceReturn, abortSignalCapacity, usesAbortSignal, sharedChannelHandler, stealPool, }) => {
|
|
156
189
|
if (workerOptions?.runtime === "compiled") {
|
|
157
190
|
return spawnCompiledWorkerContext({
|
|
158
191
|
list,
|
|
@@ -165,7 +198,7 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
|
|
|
165
198
|
}
|
|
166
199
|
const tsFileUrl = new URL(import.meta.url);
|
|
167
200
|
const poliWorker = RUNTIME_WORKER;
|
|
168
|
-
const resolvedWorkerOptions = serializeWorkerBootstrapData(withDefaultWorkerTimers(workerOptions));
|
|
201
|
+
const resolvedWorkerOptions = serializeWorkerBootstrapData(withDefaultWorkerTimers(workerOptions, workerCount ?? totalNumberOfThread));
|
|
169
202
|
const useProcessWorkerRuntime = resolvedWorkerOptions.runtime === "process";
|
|
170
203
|
const processWorkerRuntime = useProcessWorkerRuntime
|
|
171
204
|
? readProcessWorkerRuntime(resolvedWorkerOptions)
|
|
@@ -205,15 +238,25 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
|
|
|
205
238
|
const abortBytes = stealPool === undefined && usesAbortSignal === true
|
|
206
239
|
? abortSignalByteLength(resolvedAbortSignalCapacity)
|
|
207
240
|
: 0;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
241
|
+
// A stealing process pool carves every lane out of one mapping, so lane
|
|
242
|
+
// `thread` must find its slice there. Falling back to a private layout would
|
|
243
|
+
// silently give this lane its own submit region — it would boot and then
|
|
244
|
+
// never see a task the host published to the shared one.
|
|
245
|
+
const stealProcessMemory = stealPool?.processMemory;
|
|
246
|
+
const processWorkerMemory = !useProcessWorkerRuntime
|
|
247
|
+
? undefined
|
|
248
|
+
: stealProcessMemory === undefined
|
|
249
|
+
? createProcessWorkerMemoryLayout({
|
|
250
|
+
signalBytes,
|
|
251
|
+
abortBytes,
|
|
252
|
+
payloadBytes: resolvedPayloadConfig.payloadMaxByteLength,
|
|
253
|
+
thread,
|
|
254
|
+
sharedMemory: processSharedMemorySettings,
|
|
255
|
+
})
|
|
256
|
+
: stealProcessMemory.workers[thread] ??
|
|
257
|
+
(() => {
|
|
258
|
+
throw new RangeError(`stealing process pool has no shared-memory slice for worker ${thread}`);
|
|
259
|
+
})();
|
|
217
260
|
const processSharedMemory = processWorkerMemory === undefined
|
|
218
261
|
? createProcessSharedMemoryAllocator(debug)
|
|
219
262
|
: undefined;
|
|
@@ -401,6 +444,7 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
|
|
|
401
444
|
channelHandler: ownChannel,
|
|
402
445
|
dispatcherOptions: host,
|
|
403
446
|
notifySignal: nativeNotifySignal,
|
|
447
|
+
crossProcess: useProcessWorkerRuntime,
|
|
404
448
|
});
|
|
405
449
|
if (ownsChannel && dispatcherCheck !== undefined) {
|
|
406
450
|
ownChannel.open(dispatcherCheck);
|
|
@@ -442,6 +486,12 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
|
|
|
442
486
|
payloadConfig: resolvedPayloadConfig,
|
|
443
487
|
bufferReferenceReturn,
|
|
444
488
|
permission,
|
|
489
|
+
// Mirrors the dispatcher's `canUseDoorbell`: a cross-process worker cannot
|
|
490
|
+
// ring the host's waiter, so do not make it pay the atomic op.
|
|
491
|
+
notifyOnHostPublish: !useProcessWorkerRuntime &&
|
|
492
|
+
host?.doorbell !== false &&
|
|
493
|
+
(RUNTIME === "bun" || RUNTIME === "node") &&
|
|
494
|
+
typeof Atomics.waitAsync === "function",
|
|
445
495
|
steal: stealPool === undefined ? undefined : {
|
|
446
496
|
consumers: stealPool.consumers,
|
|
447
497
|
consumerId: stealPool.consumerId,
|
|
@@ -596,12 +646,39 @@ export const spawnWorkerContext = ({ list, ids, names, sab, thread, debug, hostD
|
|
|
596
646
|
return pending;
|
|
597
647
|
};
|
|
598
648
|
};
|
|
649
|
+
// Ask the worker to leave its loop before termination.
|
|
650
|
+
const requestWorkerStop = async () => {
|
|
651
|
+
const stopView = signalBox.stopView;
|
|
652
|
+
if (stopView === undefined)
|
|
653
|
+
return;
|
|
654
|
+
try {
|
|
655
|
+
Atomics.store(stopView, 0, WORKER_STOP.requested);
|
|
656
|
+
laneWake();
|
|
657
|
+
notifySignal?.();
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (!WORKER_STOP_NEEDS_ACK)
|
|
663
|
+
return;
|
|
664
|
+
const deadline = Date.now() + WORKER_STOP_ACK_TIMEOUT_MS;
|
|
665
|
+
while (Atomics.load(stopView, 0) !== WORKER_STOP.acknowledged) {
|
|
666
|
+
if (Date.now() >= deadline)
|
|
667
|
+
return;
|
|
668
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
669
|
+
}
|
|
670
|
+
};
|
|
599
671
|
const context = {
|
|
600
672
|
txIdle,
|
|
601
673
|
call,
|
|
674
|
+
requestStop: requestWorkerStop,
|
|
602
675
|
kills: async () => {
|
|
603
676
|
markWorkerClosed("Thread closed", true);
|
|
604
|
-
|
|
677
|
+
// Process workers must exit before their shared memory is unmapped.
|
|
678
|
+
const awaitExit = processWorkerMemory !== undefined;
|
|
679
|
+
const termination = terminateWorkerQuietly(worker, awaitExit);
|
|
680
|
+
if (awaitExit)
|
|
681
|
+
await termination;
|
|
605
682
|
cleanupProcessWorkerMemoryQuietly(processWorkerMemory);
|
|
606
683
|
},
|
|
607
684
|
lock,
|
|
@@ -29,6 +29,20 @@ export type ProcessWorkerMemoryLayout = {
|
|
|
29
29
|
returnPayload: SharedBufferSource;
|
|
30
30
|
cleanup: () => void;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Process-worker memory for a stealing pool.
|
|
34
|
+
*
|
|
35
|
+
* The request side is one shared region all child processes may claim from;
|
|
36
|
+
* each worker keeps its own signals and return region. Every per-worker view
|
|
37
|
+
* points into the same OS mapping so the existing fd/name boot protocol can
|
|
38
|
+
* carry all of the slices without adding a second descriptor to WorkerData.
|
|
39
|
+
*/
|
|
40
|
+
export type ProcessStealMemoryLayout = {
|
|
41
|
+
mapping: SharedMemoryMapping<SharedMemoryBuffer>;
|
|
42
|
+
descriptor: FileDescriptor;
|
|
43
|
+
workers: ProcessWorkerMemoryLayout[];
|
|
44
|
+
abortSignalMax?: number;
|
|
45
|
+
};
|
|
32
46
|
export type ProcessWorkerRuntime = "bun" | "deno" | "node";
|
|
33
47
|
export type ProcessWorkerCommandPrefix = NonNullable<WorkerSettings["processCommandPrefix"]>;
|
|
34
48
|
export type ResolvedProcessSharedMemorySettings = {
|
|
@@ -61,6 +75,14 @@ export declare const createProcessWorkerMemoryLayout: ({ signalBytes, abortBytes
|
|
|
61
75
|
thread: number;
|
|
62
76
|
sharedMemory: ResolvedProcessSharedMemorySettings;
|
|
63
77
|
}) => ProcessWorkerMemoryLayout;
|
|
78
|
+
export declare const createProcessStealMemoryLayout: ({ threads, signalBytes, abortBytes, abortSignalMax, payloadBytes, sharedMemory, }: {
|
|
79
|
+
threads: number;
|
|
80
|
+
signalBytes: number;
|
|
81
|
+
abortBytes: number;
|
|
82
|
+
abortSignalMax?: number;
|
|
83
|
+
payloadBytes: number;
|
|
84
|
+
sharedMemory: ResolvedProcessSharedMemorySettings;
|
|
85
|
+
}) => ProcessStealMemoryLayout;
|
|
64
86
|
export declare const toProcessWorkerBootPayload: (workerData: WorkerData, memory: ProcessWorkerMemoryLayout) => ProcessWorkerBootPayload;
|
|
65
87
|
export declare const readProcessWorkerRuntime: (options: WorkerSettings | undefined) => ProcessWorkerRuntime;
|
|
66
88
|
export declare const readProcessWorkerCommandPrefix: (options: WorkerSettings | undefined) => ProcessWorkerCommandPrefix | undefined;
|