knitting 0.1.61 → 0.1.62
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 +21 -15
- package/src/common/task-source.d.ts +9 -0
- package/src/common/task-source.js +15 -0
- package/src/memory/lock.d.ts +12 -1
- package/src/memory/lock.js +51 -2
- 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 +15 -3
- package/src/runtime/pool.js +65 -22
- 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 +1 -1
- package/src/runtime/worker-common.js +2 -1
- package/src/types.d.ts +42 -9
- package/src/worker/loop.js +8 -1
package/src/api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getCallerFilePath, getCallerHref, setModuleUrl, } from "./common/task-source.js";
|
|
2
2
|
import { DEBUG_ENABLED, resolveDebugNamespaces } from "./debug/gate.js";
|
|
3
|
-
import { genTaskID } from "./common/task-source.js";
|
|
3
|
+
import { genTaskID, stableTaskID } from "./common/task-source.js";
|
|
4
4
|
import { toModuleUrl } from "./common/module-url.js";
|
|
5
5
|
import { endpointSymbol } from "./common/task-symbol.js";
|
|
6
6
|
import { createStealPoolBuffers, MAX_STEAL_CONSUMERS, spawnWorkerContext, } from "./runtime/pool.js";
|
|
@@ -9,7 +9,8 @@ import { createSharedArrayBuffer, RUNTIME } from "./common/runtime.js";
|
|
|
9
9
|
import { RUNTIME_IS_MAIN_THREAD, RUNTIME_POOL_DEPTH, RUNTIME_WORKER_DATA, } from "./common/worker-runtime.js";
|
|
10
10
|
import { classifyProcessPermissionCompatibility, enforceProcessPermissionCompatibility, resolvePermissionProtocol, toRuntimePermissionFlags, } from "./permission/index.js";
|
|
11
11
|
import { getNodeProcess } from "./common/node-compat.js";
|
|
12
|
-
import { readProcessWorkerNodeMajor, readProcessWorkerRuntime, } from "./runtime/process-worker.js";
|
|
12
|
+
import { readProcessSharedMemorySettings, readProcessWorkerNodeMajor, readProcessWorkerRuntime, } from "./runtime/process-worker.js";
|
|
13
|
+
import { TRANSPORT_SIGNAL_BYTES } from "./ipc/transport/shared-memory.js";
|
|
13
14
|
import { inspectCompiledWorkerArtifact } from "./runtime/compiled-artifact.js";
|
|
14
15
|
import { managerMethod } from "./runtime/balancer.js";
|
|
15
16
|
import { createInlineExecutor } from "./runtime/inline-executor.js";
|
|
@@ -370,17 +371,14 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
370
371
|
: stealEnvRaw === "0" || stealEnvRaw === "false"
|
|
371
372
|
? false
|
|
372
373
|
: undefined;
|
|
373
|
-
// Shared-submit stealing is the default for compatible multi-worker
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
// explicitly requests an unsupported combination.
|
|
374
|
+
// Shared-submit stealing is the default for compatible multi-worker pools.
|
|
375
|
+
// An explicit option wins over the environment; compiled and inline
|
|
376
|
+
// transports retain their existing topology.
|
|
377
377
|
const dispatcherExplicitlySelected = host?.dispatcher !== undefined ||
|
|
378
378
|
dispatcherEnv === "serial-channel" || dispatcherEnv === "per-thread";
|
|
379
379
|
const stealDefaultCompatible = balancer === undefined &&
|
|
380
380
|
!dispatcherExplicitlySelected && (threads ?? 1) <= MAX_STEAL_CONSUMERS;
|
|
381
381
|
const stealRequested = host?.steal ?? stealEnv ?? stealDefaultCompatible;
|
|
382
|
-
const stealExplicitlyEnabled = host?.steal === true ||
|
|
383
|
-
(host?.steal === undefined && stealEnv === true);
|
|
384
382
|
const usingCompiledWorker = resolvedWorker?.runtime === "compiled";
|
|
385
383
|
if (resolvedWorker?.compiled !== undefined && !usingCompiledWorker) {
|
|
386
384
|
throw new Error("worker.compiled requires worker.runtime to be compiled");
|
|
@@ -435,9 +433,6 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
435
433
|
if (usingInliner && resolvedWorker?.bootstrap !== undefined) {
|
|
436
434
|
throw new Error("worker.bootstrap cannot be used with the inliner");
|
|
437
435
|
}
|
|
438
|
-
if (stealExplicitlyEnabled && resolvedWorker?.runtime === "process") {
|
|
439
|
-
throw new Error("host.steal does not support process workers; use worker threads or disable stealing");
|
|
440
|
-
}
|
|
441
436
|
if (resolvedWorker?.runtime === "process") {
|
|
442
437
|
const processRuntime = readProcessWorkerRuntime(resolvedWorker);
|
|
443
438
|
enforceProcessPermissionCompatibility(classifyProcessPermissionCompatibility({
|
|
@@ -479,12 +474,11 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
479
474
|
})();
|
|
480
475
|
const dispatcher = explicitDispatcher ?? autoDispatcher;
|
|
481
476
|
// Work stealing: one shared submit region, private return lanes, one
|
|
482
|
-
// pool-global pending registry. It is the compatible multi-
|
|
477
|
+
// pool-global pending registry. It is the compatible multi-worker default;
|
|
483
478
|
// an explicit balancer/dispatcher keeps the private-lane topology unless
|
|
484
479
|
// stealing itself was explicitly requested.
|
|
485
480
|
const useSteal = stealRequested &&
|
|
486
481
|
!usingCompiledWorker &&
|
|
487
|
-
resolvedWorker?.runtime !== "process" &&
|
|
488
482
|
(threads ?? 1) > 1 &&
|
|
489
483
|
!usingInliner;
|
|
490
484
|
const stealBuffers = useSteal
|
|
@@ -494,12 +488,22 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
494
488
|
regionLanes: host?.stealRegionLanes,
|
|
495
489
|
abortSignalCapacity,
|
|
496
490
|
usesAbortSignal,
|
|
491
|
+
processWorker: resolvedWorker?.runtime === "process"
|
|
492
|
+
? {
|
|
493
|
+
signalBytes: TRANSPORT_SIGNAL_BYTES,
|
|
494
|
+
sharedMemory: readProcessSharedMemorySettings(resolvedWorker),
|
|
495
|
+
}
|
|
496
|
+
: undefined,
|
|
497
497
|
})
|
|
498
498
|
: undefined;
|
|
499
499
|
const serialChannel = !usingCompiledWorker && !useSteal &&
|
|
500
500
|
dispatcher === "serial-channel";
|
|
501
|
+
// One serial-channel hop drives every lane check in turn, and it depends on
|
|
502
|
+
// the channel's delivery to do that: swapping in the cheaper `setImmediate`
|
|
503
|
+
// pump cost 25% throughput and tripled p99 here, where every other topology
|
|
504
|
+
// gained. See ChannelHandler's note.
|
|
501
505
|
const serialDispatcherChannel = serialChannel
|
|
502
|
-
? new ChannelHandler()
|
|
506
|
+
? new ChannelHandler("channel")
|
|
503
507
|
: undefined;
|
|
504
508
|
let workers = Array.from({
|
|
505
509
|
length: threads ?? 1,
|
|
@@ -533,6 +537,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
533
537
|
regionLanes: stealBuffers.regionLanes,
|
|
534
538
|
abortSignalSAB: stealBuffers.abortSignalSAB,
|
|
535
539
|
abortSignalMax: stealBuffers.abortSignalMax,
|
|
540
|
+
processMemory: stealBuffers.processMemory,
|
|
536
541
|
},
|
|
537
542
|
}));
|
|
538
543
|
const stealChannel = useSteal ? new ChannelHandler() : undefined;
|
|
@@ -565,6 +570,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
565
570
|
channelHandler: channel,
|
|
566
571
|
dispatcherOptions: host,
|
|
567
572
|
notifySignal: wakeOne,
|
|
573
|
+
crossProcess: resolvedWorker?.runtime === "process",
|
|
568
574
|
});
|
|
569
575
|
channel.open(check);
|
|
570
576
|
workers.forEach((context) => {
|
|
@@ -843,7 +849,7 @@ const buildTaskDefinitionFromCaller = (input, callerHref, at, imported = false)
|
|
|
843
849
|
const importedFrom = new URL(callerHref).href;
|
|
844
850
|
const out = ({
|
|
845
851
|
...input,
|
|
846
|
-
id:
|
|
852
|
+
id: stableTaskID(importedFrom, at),
|
|
847
853
|
importedFrom,
|
|
848
854
|
at,
|
|
849
855
|
imported,
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
export declare const genTaskID: () => number;
|
|
2
|
+
/**
|
|
3
|
+
* Identity of a task, stable across processes.
|
|
4
|
+
*
|
|
5
|
+
* `genTaskID` numbers tasks by the order their module happened to be evaluated,
|
|
6
|
+
* which host and worker do not share: the host imports in its own source order
|
|
7
|
+
* and the worker imports in task-name order. `(href, at)` is the same pair in
|
|
8
|
+
* both, because `at` counts `task()` calls within one module.
|
|
9
|
+
*/
|
|
10
|
+
export declare const stableTaskID: (href: string, at: number) => number;
|
|
2
11
|
export declare const setModuleUrl: (url: string | undefined) => void;
|
|
3
12
|
export declare const getModuleUrlOverride: () => string | undefined;
|
|
4
13
|
export declare const getCallerHref: (offset?: number) => string;
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { toModuleUrl } from "./module-url.js";
|
|
2
2
|
export const genTaskID = ((counter) => () => counter++)(0);
|
|
3
|
+
/**
|
|
4
|
+
* Identity of a task, stable across processes.
|
|
5
|
+
*
|
|
6
|
+
* `genTaskID` numbers tasks by the order their module happened to be evaluated,
|
|
7
|
+
* which host and worker do not share: the host imports in its own source order
|
|
8
|
+
* and the worker imports in task-name order. `(href, at)` is the same pair in
|
|
9
|
+
* both, because `at` counts `task()` calls within one module.
|
|
10
|
+
*/
|
|
11
|
+
export const stableTaskID = (href, at) => {
|
|
12
|
+
let hash = 0x811c9dc5;
|
|
13
|
+
for (let index = 0; index < href.length; index++) {
|
|
14
|
+
hash = Math.imul(hash ^ href.charCodeAt(index), 0x01000193);
|
|
15
|
+
}
|
|
16
|
+
return (Math.imul(hash ^ at, 0x01000193) >>> 0);
|
|
17
|
+
};
|
|
3
18
|
const INTERNAL_CALLER_HINTS = [
|
|
4
19
|
"/src/common/task-source.ts",
|
|
5
20
|
"/src/common/task-source.js",
|
package/src/memory/lock.d.ts
CHANGED
|
@@ -173,6 +173,8 @@ export declare const STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32: number;
|
|
|
173
173
|
* clear here. This preserves single-writer ownership of every WANT word.
|
|
174
174
|
*/
|
|
175
175
|
export declare const STEAL_LIVE_SLOT_OFFSET_U32: number;
|
|
176
|
+
/** Host-owned arm word for the return-lock completion doorbell. */
|
|
177
|
+
export declare const DOORBELL_ARMED_SLOT_OFFSET_U32: number;
|
|
176
178
|
export declare const HEADER_U32_LENGTH: number;
|
|
177
179
|
export declare const HEADER_BYTE_LENGTH: number;
|
|
178
180
|
export declare const makeTask: () => Task;
|
|
@@ -191,7 +193,12 @@ type ResolveHostOptions = {
|
|
|
191
193
|
* - encode/decode are not re-entrant; payload codec uses a shared scratch buffer.
|
|
192
194
|
*/
|
|
193
195
|
export type Lock2 = ReturnType<typeof lock2>;
|
|
194
|
-
export
|
|
196
|
+
export type WaitAsyncState = "not-equal" | "ok" | "timed-out";
|
|
197
|
+
export type WaitAsyncResult = {
|
|
198
|
+
async: boolean;
|
|
199
|
+
value: WaitAsyncState | PromiseLike<WaitAsyncState>;
|
|
200
|
+
};
|
|
201
|
+
export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, notifyOnHostPublish, }: {
|
|
195
202
|
headers?: SharedBufferSource;
|
|
196
203
|
headerSlotStrideU32?: number;
|
|
197
204
|
LockBoundSector?: SharedBufferSource;
|
|
@@ -213,6 +220,8 @@ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, pa
|
|
|
213
220
|
consumerId?: number;
|
|
214
221
|
/** Lanes claimed per Dekker handshake. Paper rule: `slots / regionLanes >= consumers + 1`. */
|
|
215
222
|
regionLanes?: number;
|
|
223
|
+
/** Notify a host-side wait after this endpoint publishes a frame. */
|
|
224
|
+
notifyOnHostPublish?: boolean;
|
|
216
225
|
}) => {
|
|
217
226
|
enlist: (task: Task) => true;
|
|
218
227
|
encode: (task: Task, state?: number) => boolean;
|
|
@@ -227,6 +236,8 @@ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, pa
|
|
|
227
236
|
workerBits: Int32Array<import("../common/shared-buffer-region.js").SharedBuffer>;
|
|
228
237
|
recyclecList: RingQueue<Task>;
|
|
229
238
|
resolveHost: ({ queue, onResolved, shouldSettle, activeRejectPlaceholder, }: ResolveHostOptions) => () => number;
|
|
239
|
+
waitForHostChange: (timeoutMs?: number) => WaitAsyncResult | undefined;
|
|
240
|
+
setHostWaiterArmed: (armed: boolean) => void;
|
|
230
241
|
hasPendingFrames: () => boolean;
|
|
231
242
|
getPendingFrameCount: () => number;
|
|
232
243
|
getPendingPromiseCount: () => number;
|
package/src/memory/lock.js
CHANGED
|
@@ -247,6 +247,8 @@ export const STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32 = STEAL_ACK_SLOT_OFFSET_U32 + 2;
|
|
|
247
247
|
* clear here. This preserves single-writer ownership of every WANT word.
|
|
248
248
|
*/
|
|
249
249
|
export const STEAL_LIVE_SLOT_OFFSET_U32 = STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32 + 1;
|
|
250
|
+
/** Host-owned arm word for the return-lock completion doorbell. */
|
|
251
|
+
export const DOORBELL_ARMED_SLOT_OFFSET_U32 = STEAL_LIVE_SLOT_OFFSET_U32 + 1;
|
|
250
252
|
export const HEADER_U32_LENGTH = LockBound.header +
|
|
251
253
|
(HEADER_SLOT_STRIDE_U32 * LockBound.slots);
|
|
252
254
|
export const HEADER_BYTE_LENGTH = HEADER_U32_LENGTH *
|
|
@@ -297,7 +299,7 @@ const settleTask = (task) => {
|
|
|
297
299
|
task[TaskIndex["FlagsToHost"]] = 0;
|
|
298
300
|
}
|
|
299
301
|
};
|
|
300
|
-
export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, }) => {
|
|
302
|
+
export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, notifyOnHostPublish, }) => {
|
|
301
303
|
// Layout within `lockSectorRegion`:
|
|
302
304
|
// - hostBits starts at byte 0
|
|
303
305
|
// - workerBits starts at byte 64
|
|
@@ -316,6 +318,11 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
316
318
|
const headersRegion = toSharedBufferRegion(headers ?? createWasmSharedArrayBuffer(HEADER_BYTE_LENGTH));
|
|
317
319
|
const headersBuffer = new Uint32Array(headersRegion.sab, headersRegion.byteOffset, headersRegion.byteLength >>> 2);
|
|
318
320
|
const headersSlotStride = headerSlotStrideU32 ?? HEADER_SLOT_STRIDE_U32;
|
|
321
|
+
// The first task cache line has four unused control words after the task
|
|
322
|
+
// header. Keep the doorbell arm bit in one of those words so the host and
|
|
323
|
+
// worker lock instances can share it without allocating another SAB.
|
|
324
|
+
const doorbellArmed = new Int32Array(headersRegion.sab, headersRegion.byteOffset +
|
|
325
|
+
(DOORBELL_ARMED_SLOT_OFFSET_U32 * Uint32Array.BYTES_PER_ELEMENT), 1);
|
|
319
326
|
const resolvedPayloadConfig = resolvePayloadBufferOptions({
|
|
320
327
|
sab: payload,
|
|
321
328
|
options: payloadConfig,
|
|
@@ -428,6 +435,11 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
428
435
|
// Atomics aliases (hot path)
|
|
429
436
|
const a_load = Atomics.load;
|
|
430
437
|
const a_store = Atomics.store;
|
|
438
|
+
const a_notify = Atomics.notify;
|
|
439
|
+
const shouldNotifyHostPublish = notifyOnHostPublish === true;
|
|
440
|
+
const a_waitAsync = typeof Atomics.waitAsync === "function"
|
|
441
|
+
? Atomics.waitAsync.bind(Atomics)
|
|
442
|
+
: undefined;
|
|
431
443
|
// Sender-side cached shadow of the receiver-owned queue word. Under the XSC
|
|
432
444
|
// false-busy-only sender-side staleness property, this may hide newly freed
|
|
433
445
|
// lanes but cannot make a genuinely pending lane appear free. Refresh only
|
|
@@ -560,7 +572,12 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
560
572
|
deferredCount = 0 | 0;
|
|
561
573
|
return toBeSent.isEmpty;
|
|
562
574
|
};
|
|
563
|
-
const storeHost = (bit) =>
|
|
575
|
+
const storeHost = (bit) => {
|
|
576
|
+
a_store(hostBits, 0, LastLocal = (LastLocal ^ bit) | 0);
|
|
577
|
+
if (shouldNotifyHostPublish && a_load(doorbellArmed, 0) !== 0) {
|
|
578
|
+
a_notify(hostBits, 0, 1);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
564
581
|
const storeWorker = (bit) => a_store(workerBits, 0, LastWorker = (LastWorker ^ bit) | 0);
|
|
565
582
|
const encode = (task, state = (LastLocal ^ workerShadow) | 0) => {
|
|
566
583
|
state = ensureSenderStateHasFree(state);
|
|
@@ -878,6 +895,34 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
878
895
|
return modified;
|
|
879
896
|
};
|
|
880
897
|
};
|
|
898
|
+
/**
|
|
899
|
+
* HOST SIDE: wait until the producer changes hostBits.
|
|
900
|
+
*
|
|
901
|
+
* LastWorker is the host's acknowledgement shadow. Passing it as the
|
|
902
|
+
* expected value makes the arm race-free: a publication between the drain
|
|
903
|
+
* and this call returns `not-equal` synchronously instead of being lost.
|
|
904
|
+
*/
|
|
905
|
+
const waitForHostChange = (timeoutMs) => {
|
|
906
|
+
if (a_waitAsync === undefined) {
|
|
907
|
+
a_store(doorbellArmed, 0, 0);
|
|
908
|
+
return undefined;
|
|
909
|
+
}
|
|
910
|
+
a_store(doorbellArmed, 0, 1);
|
|
911
|
+
try {
|
|
912
|
+
const wait = a_waitAsync(hostBits, 0, LastWorker | 0, timeoutMs);
|
|
913
|
+
if (!wait.async)
|
|
914
|
+
a_store(doorbellArmed, 0, 0);
|
|
915
|
+
return wait;
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
a_store(doorbellArmed, 0, 0);
|
|
919
|
+
// Some runtimes reject particular SharedArrayBuffer implementations
|
|
920
|
+
// (for example a growable or native-backed buffer). Fall back to the
|
|
921
|
+
// existing dispatcher rather than turning a capability issue into a
|
|
922
|
+
// hung pool.
|
|
923
|
+
return undefined;
|
|
924
|
+
}
|
|
925
|
+
};
|
|
881
926
|
const decodeAt = (at) => {
|
|
882
927
|
const off = (at * headersSlotStride) + slotBaseU32;
|
|
883
928
|
const recycled = recycleShift();
|
|
@@ -951,6 +996,10 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
951
996
|
workerBits,
|
|
952
997
|
recyclecList,
|
|
953
998
|
resolveHost,
|
|
999
|
+
waitForHostChange,
|
|
1000
|
+
setHostWaiterArmed: (armed) => {
|
|
1001
|
+
a_store(doorbellArmed, 0, armed ? 1 : 0);
|
|
1002
|
+
},
|
|
954
1003
|
hasPendingFrames: () => toBeSent.size !== 0,
|
|
955
1004
|
getPendingFrameCount: () => toBeSent.size | 0,
|
|
956
1005
|
getPendingPromiseCount: () => pendingPromiseCount | 0,
|
|
@@ -2,12 +2,14 @@ import { type MultiQueue } from "./tx-queue.js";
|
|
|
2
2
|
import { type MainSignal } from "../ipc/transport/shared-memory.js";
|
|
3
3
|
import { type RuntimeMessageChannelLike, type RuntimeMessagePortLike } from "../common/worker-runtime.js";
|
|
4
4
|
import type { DispatcherSettings } from "../types.js";
|
|
5
|
-
export declare const hostDispatcherLoop: ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, }, channelHandler, dispatcherOptions, notifySignal, }: {
|
|
5
|
+
export declare const hostDispatcherLoop: ({ signalBox: { opView, txStatus, rxStatus, }, queue: { completeFrame, hasPendingFrames, flushToWorker, txIdle, waitForCompletion, setCompletionWaiterArmed, }, channelHandler, dispatcherOptions, notifySignal, crossProcess, }: {
|
|
6
6
|
queue: MultiQueue;
|
|
7
7
|
signalBox: MainSignal;
|
|
8
8
|
channelHandler: ChannelHandler;
|
|
9
9
|
dispatcherOptions?: DispatcherSettings;
|
|
10
10
|
notifySignal?: () => void;
|
|
11
|
+
/** Workers live in other processes; disables the doorbell. */
|
|
12
|
+
crossProcess?: boolean;
|
|
11
13
|
}) => {
|
|
12
14
|
check: {
|
|
13
15
|
(): void;
|
|
@@ -20,20 +22,21 @@ type CheckWithState = (() => void) & {
|
|
|
20
22
|
rerun: boolean;
|
|
21
23
|
};
|
|
22
24
|
export type DispatcherCheck = CheckWithState;
|
|
25
|
+
export type ChannelHandlerPump = "auto" | "channel";
|
|
23
26
|
export declare class ChannelHandler {
|
|
24
27
|
#private;
|
|
25
|
-
channel: RuntimeMessageChannelLike;
|
|
26
|
-
port1: RuntimeMessagePortLike;
|
|
27
|
-
port2: RuntimeMessagePortLike;
|
|
28
|
-
constructor();
|
|
28
|
+
channel: RuntimeMessageChannelLike | undefined;
|
|
29
|
+
port1: RuntimeMessagePortLike | undefined;
|
|
30
|
+
port2: RuntimeMessagePortLike | undefined;
|
|
31
|
+
constructor(pump?: ChannelHandlerPump);
|
|
29
32
|
notify(): void;
|
|
30
33
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
34
|
+
* Registers the handler the pump calls back into. On the channel pump this is
|
|
35
|
+
* also where the ports are opened, so `notify` can reach port 1.
|
|
33
36
|
*/
|
|
34
37
|
open(f: () => void): void;
|
|
35
38
|
/**
|
|
36
|
-
*
|
|
39
|
+
* Detaches the handler, and closes the channel if this pump has one.
|
|
37
40
|
*/
|
|
38
41
|
close(): void;
|
|
39
42
|
}
|
|
@@ -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
|