knitting 0.1.60 → 0.1.61
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/BROWSER.md +134 -0
- package/README.md +93 -6
- package/knitting.browser.d.ts +6 -0
- package/knitting.browser.js +1 -0
- package/package.json +11 -2
- package/prebuilds/darwin-arm64-node-127/knitting_buffer_pointer.node +0 -0
- package/prebuilds/darwin-arm64-node-127/knitting_shared_memory.node +0 -0
- package/prebuilds/darwin-arm64-node-127/knitting_shm.node +0 -0
- package/prebuilds/darwin-arm64-node-137/knitting_buffer_pointer.node +0 -0
- package/prebuilds/darwin-arm64-node-137/knitting_shared_memory.node +0 -0
- package/prebuilds/darwin-arm64-node-137/knitting_shm.node +0 -0
- package/prebuilds/win32-x64/knitting_windows_shared_memory.dll +0 -0
- package/prebuilds/win32-x64-node-127/knitting_buffer_pointer.node +0 -0
- package/prebuilds/win32-x64-node-127/knitting_shared_memory.node +0 -0
- package/prebuilds/win32-x64-node-127/knitting_shm.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_buffer_pointer.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_shared_memory.node +0 -0
- package/prebuilds/win32-x64-node-137/knitting_shm.node +0 -0
- package/src/api.js +127 -22
- package/src/common/runtime.d.ts +2 -1
- package/src/common/runtime.js +8 -1
- package/src/memory/lock.d.ts +26 -1
- package/src/memory/lock.js +212 -4
- package/src/memory/payloadCodec.js +2 -6
- package/src/memory/regionRegistry.d.ts +1 -3
- package/src/memory/regionRegistry.js +16 -14
- package/src/permission/protocol.js +5 -1
- package/src/runtime/dispatcher.js +9 -0
- package/src/runtime/pool.d.ts +114 -3
- package/src/runtime/pool.js +209 -63
- package/src/runtime/process-worker.d.ts +2 -12
- package/src/runtime/process-worker.js +69 -113
- package/src/runtime/tx-queue.d.ts +15 -1
- package/src/runtime/tx-queue.js +52 -15
- package/src/runtime/worker-common.d.ts +13 -0
- package/src/runtime/worker-common.js +102 -0
- package/src/types.d.ts +41 -3
- package/src/worker/loop.js +27 -5
- package/src/worker/rx-queue.d.ts +11 -1
- package/src/worker/rx-queue.js +4 -1
- package/src/worker/timers.d.ts +8 -1
- package/src/worker/timers.js +20 -17
package/src/api.js
CHANGED
|
@@ -3,13 +3,13 @@ import { DEBUG_ENABLED, resolveDebugNamespaces } from "./debug/gate.js";
|
|
|
3
3
|
import { genTaskID } from "./common/task-source.js";
|
|
4
4
|
import { toModuleUrl } from "./common/module-url.js";
|
|
5
5
|
import { endpointSymbol } from "./common/task-symbol.js";
|
|
6
|
-
import { spawnWorkerContext } from "./runtime/pool.js";
|
|
7
|
-
import { ChannelHandler } from "./runtime/dispatcher.js";
|
|
8
|
-
import { RUNTIME } from "./common/runtime.js";
|
|
6
|
+
import { createStealPoolBuffers, MAX_STEAL_CONSUMERS, spawnWorkerContext, } from "./runtime/pool.js";
|
|
7
|
+
import { ChannelHandler, hostDispatcherLoop } from "./runtime/dispatcher.js";
|
|
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 {
|
|
12
|
+
import { readProcessWorkerNodeMajor, readProcessWorkerRuntime, } from "./runtime/process-worker.js";
|
|
13
13
|
import { inspectCompiledWorkerArtifact } from "./runtime/compiled-artifact.js";
|
|
14
14
|
import { managerMethod } from "./runtime/balancer.js";
|
|
15
15
|
import { createInlineExecutor } from "./runtime/inline-executor.js";
|
|
@@ -46,18 +46,6 @@ const readHostCwd = () => {
|
|
|
46
46
|
}
|
|
47
47
|
return undefined;
|
|
48
48
|
};
|
|
49
|
-
const readKnownProcessWorkerNodeMajor = (worker) => {
|
|
50
|
-
if (RUNTIME !== "node")
|
|
51
|
-
return undefined;
|
|
52
|
-
if (readProcessWorkerCommandPrefix(worker) !== undefined)
|
|
53
|
-
return undefined;
|
|
54
|
-
const nodeProcess = getNodeProcess();
|
|
55
|
-
if (nodeProcess?.env?.NODE_BINARY !== undefined)
|
|
56
|
-
return undefined;
|
|
57
|
-
const raw = nodeProcess?.versions?.node;
|
|
58
|
-
const major = Number.parseInt(String(raw).split(".", 1)[0] ?? "", 10);
|
|
59
|
-
return Number.isInteger(major) && major > 0 ? major : undefined;
|
|
60
|
-
};
|
|
61
49
|
const formatDebugList = (values, empty = "(none)") => values && values.length > 0 ? values.join(",") : empty;
|
|
62
50
|
const MAX_FUNCTION_ID = 0xFFFF;
|
|
63
51
|
const MAX_FUNCTION_COUNT = MAX_FUNCTION_ID + 1;
|
|
@@ -375,6 +363,24 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
375
363
|
hostDebug?.log(`permission=${permissionProtocol?.mode ?? "off"} execArgv=${formatDebugList(execArgv)}`);
|
|
376
364
|
const usesAbortSignal = listOfFunctions.some((fn) => fn.abortSignal !== undefined);
|
|
377
365
|
const resolvedWorker = resolveWorkerSettings(worker, callerHref);
|
|
366
|
+
const dispatcherEnv = nodeProcess?.env?.KNITTING_DISPATCHER;
|
|
367
|
+
const stealEnvRaw = nodeProcess?.env?.KNITTING_STEAL?.trim().toLowerCase();
|
|
368
|
+
const stealEnv = stealEnvRaw === "1" || stealEnvRaw === "true"
|
|
369
|
+
? true
|
|
370
|
+
: stealEnvRaw === "0" || stealEnvRaw === "false"
|
|
371
|
+
? false
|
|
372
|
+
: undefined;
|
|
373
|
+
// Shared-submit stealing is the default for compatible multi-worker thread
|
|
374
|
+
// pools. An explicit option wins over the environment; process/compiled/
|
|
375
|
+
// inline transports retain their existing topology unless the caller
|
|
376
|
+
// explicitly requests an unsupported combination.
|
|
377
|
+
const dispatcherExplicitlySelected = host?.dispatcher !== undefined ||
|
|
378
|
+
dispatcherEnv === "serial-channel" || dispatcherEnv === "per-thread";
|
|
379
|
+
const stealDefaultCompatible = balancer === undefined &&
|
|
380
|
+
!dispatcherExplicitlySelected && (threads ?? 1) <= MAX_STEAL_CONSUMERS;
|
|
381
|
+
const stealRequested = host?.steal ?? stealEnv ?? stealDefaultCompatible;
|
|
382
|
+
const stealExplicitlyEnabled = host?.steal === true ||
|
|
383
|
+
(host?.steal === undefined && stealEnv === true);
|
|
378
384
|
const usingCompiledWorker = resolvedWorker?.runtime === "compiled";
|
|
379
385
|
if (resolvedWorker?.compiled !== undefined && !usingCompiledWorker) {
|
|
380
386
|
throw new Error("worker.compiled requires worker.runtime to be compiled");
|
|
@@ -429,6 +435,9 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
429
435
|
if (usingInliner && resolvedWorker?.bootstrap !== undefined) {
|
|
430
436
|
throw new Error("worker.bootstrap cannot be used with the inliner");
|
|
431
437
|
}
|
|
438
|
+
if (stealExplicitlyEnabled && resolvedWorker?.runtime === "process") {
|
|
439
|
+
throw new Error("host.steal does not support process workers; use worker threads or disable stealing");
|
|
440
|
+
}
|
|
432
441
|
if (resolvedWorker?.runtime === "process") {
|
|
433
442
|
const processRuntime = readProcessWorkerRuntime(resolvedWorker);
|
|
434
443
|
enforceProcessPermissionCompatibility(classifyProcessPermissionCompatibility({
|
|
@@ -437,7 +446,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
437
446
|
target: {
|
|
438
447
|
runtime: processRuntime,
|
|
439
448
|
nodeMajor: processRuntime === "node"
|
|
440
|
-
?
|
|
449
|
+
? readProcessWorkerNodeMajor(resolvedWorker)
|
|
441
450
|
: undefined,
|
|
442
451
|
},
|
|
443
452
|
}));
|
|
@@ -454,7 +463,6 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
454
463
|
`(import { isMain } from "knitting") so only the main program starts ` +
|
|
455
464
|
`the pool.`);
|
|
456
465
|
}
|
|
457
|
-
const dispatcherEnv = nodeProcess?.env?.KNITTING_DISPATCHER;
|
|
458
466
|
const explicitDispatcher = host?.dispatcher ??
|
|
459
467
|
(dispatcherEnv === "serial-channel" || dispatcherEnv === "per-thread"
|
|
460
468
|
? dispatcherEnv
|
|
@@ -470,7 +478,25 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
470
478
|
return "serial-channel";
|
|
471
479
|
})();
|
|
472
480
|
const dispatcher = explicitDispatcher ?? autoDispatcher;
|
|
473
|
-
|
|
481
|
+
// Work stealing: one shared submit region, private return lanes, one
|
|
482
|
+
// pool-global pending registry. It is the compatible multi-thread default;
|
|
483
|
+
// an explicit balancer/dispatcher keeps the private-lane topology unless
|
|
484
|
+
// stealing itself was explicitly requested.
|
|
485
|
+
const useSteal = stealRequested &&
|
|
486
|
+
!usingCompiledWorker &&
|
|
487
|
+
resolvedWorker?.runtime !== "process" &&
|
|
488
|
+
(threads ?? 1) > 1 &&
|
|
489
|
+
!usingInliner;
|
|
490
|
+
const stealBuffers = useSteal
|
|
491
|
+
? createStealPoolBuffers({
|
|
492
|
+
threads: threads ?? 1,
|
|
493
|
+
payload,
|
|
494
|
+
regionLanes: host?.stealRegionLanes,
|
|
495
|
+
abortSignalCapacity,
|
|
496
|
+
usesAbortSignal,
|
|
497
|
+
})
|
|
498
|
+
: undefined;
|
|
499
|
+
const serialChannel = !usingCompiledWorker && !useSteal &&
|
|
474
500
|
dispatcher === "serial-channel";
|
|
475
501
|
const serialDispatcherChannel = serialChannel
|
|
476
502
|
? new ChannelHandler()
|
|
@@ -496,14 +522,83 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
496
522
|
usesAbortSignal,
|
|
497
523
|
permission: permissionProtocol,
|
|
498
524
|
sharedChannelHandler: serialDispatcherChannel,
|
|
525
|
+
stealPool: stealBuffers === undefined ? undefined : {
|
|
526
|
+
submitBuffers: stealBuffers.submitBuffers,
|
|
527
|
+
returnBuffers: stealBuffers.returnBuffers[thread],
|
|
528
|
+
hostSubmitLock: stealBuffers.hostSubmitLock,
|
|
529
|
+
hostReturnLock: stealBuffers.hostReturnLocks[thread],
|
|
530
|
+
sharedQueue: stealBuffers.sharedQueue,
|
|
531
|
+
consumers: threads ?? 1,
|
|
532
|
+
consumerId: thread,
|
|
533
|
+
regionLanes: stealBuffers.regionLanes,
|
|
534
|
+
abortSignalSAB: stealBuffers.abortSignalSAB,
|
|
535
|
+
abortSignalMax: stealBuffers.abortSignalMax,
|
|
536
|
+
},
|
|
499
537
|
}));
|
|
538
|
+
const stealChannel = useSteal ? new ChannelHandler() : undefined;
|
|
539
|
+
if (useSteal) {
|
|
540
|
+
const channel = stealChannel;
|
|
541
|
+
const queue = stealBuffers.sharedQueue;
|
|
542
|
+
// Wake exactly one worker per drain, round-robin. Stealing decouples the
|
|
543
|
+
// wake target from the assignment: if the woken worker is busy, any other
|
|
544
|
+
// idle endpoint claims the region instead. So this costs one notify per
|
|
545
|
+
// publish regardless of pool size, rather than one per lane.
|
|
546
|
+
let wakeCursor = 0;
|
|
547
|
+
const wakeOne = () => {
|
|
548
|
+
const lanes = workers.length;
|
|
549
|
+
if (lanes === 0)
|
|
550
|
+
return;
|
|
551
|
+
const lane = wakeCursor;
|
|
552
|
+
wakeCursor = wakeCursor + 1 < lanes ? wakeCursor + 1 : 0;
|
|
553
|
+
workers[lane].laneWake?.();
|
|
554
|
+
};
|
|
555
|
+
// The shared dispatcher drives one queue, so it needs signal words of its
|
|
556
|
+
// own rather than any single lane's; waking is delegated to `wakeOne`.
|
|
557
|
+
const signalWords = new Int32Array(createSharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT));
|
|
558
|
+
const { check } = hostDispatcherLoop({
|
|
559
|
+
signalBox: {
|
|
560
|
+
opView: signalWords.subarray(0, 1),
|
|
561
|
+
txStatus: signalWords.subarray(1, 2),
|
|
562
|
+
rxStatus: signalWords.subarray(2, 3),
|
|
563
|
+
},
|
|
564
|
+
queue,
|
|
565
|
+
channelHandler: channel,
|
|
566
|
+
dispatcherOptions: host,
|
|
567
|
+
notifySignal: wakeOne,
|
|
568
|
+
});
|
|
569
|
+
channel.open(check);
|
|
570
|
+
workers.forEach((context) => {
|
|
571
|
+
context.bindSend(() => {
|
|
572
|
+
if (check.isRunning !== true) {
|
|
573
|
+
check.isRunning = true;
|
|
574
|
+
Promise.resolve().then(check);
|
|
575
|
+
}
|
|
576
|
+
wakeOne();
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
hostDebug?.log(`dispatcher=steal lanes=${workers.length} g=${stealBuffers.regionLanes}`);
|
|
580
|
+
}
|
|
500
581
|
const sharedDispatcherChannel = serialDispatcherChannel;
|
|
501
582
|
if (serialChannel) {
|
|
502
583
|
const channel = serialDispatcherChannel;
|
|
503
584
|
const checks = workers.map((context) => context.dispatcherCheck);
|
|
585
|
+
const laneIdle = workers.map((context) => context.txIdle);
|
|
504
586
|
let serialScheduled = false;
|
|
505
587
|
let serialInFlight = false;
|
|
506
588
|
let serialRerun = false;
|
|
589
|
+
// Busy-lane set: a tick walks only the lanes that have work, not all of
|
|
590
|
+
// them. `active` is a dense list of lane indices; `isActive` keeps the
|
|
591
|
+
// membership test O(1). A lane joins on send() and leaves once its queue
|
|
592
|
+
// reports idle, so an N-worker pool with one busy lane costs one check per
|
|
593
|
+
// tick instead of N.
|
|
594
|
+
const active = [];
|
|
595
|
+
const isActive = new Uint8Array(checks.length);
|
|
596
|
+
const markActive = (lane) => {
|
|
597
|
+
if (isActive[lane] === 1)
|
|
598
|
+
return;
|
|
599
|
+
isActive[lane] = 1;
|
|
600
|
+
active.push(lane);
|
|
601
|
+
};
|
|
507
602
|
const runSerialChecks = () => {
|
|
508
603
|
if (serialInFlight) {
|
|
509
604
|
serialRerun = true;
|
|
@@ -513,11 +608,19 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
513
608
|
do {
|
|
514
609
|
serialRerun = false;
|
|
515
610
|
serialScheduled = false;
|
|
516
|
-
|
|
517
|
-
|
|
611
|
+
// Walk backwards so dropping an idle lane is a swap-with-last pop.
|
|
612
|
+
for (let index = active.length - 1; index >= 0; index--) {
|
|
613
|
+
const lane = active[index];
|
|
614
|
+
const check = checks[lane];
|
|
518
615
|
if (check.isRunning !== true)
|
|
519
616
|
check.isRunning = true;
|
|
520
617
|
check();
|
|
618
|
+
if (laneIdle[lane]()) {
|
|
619
|
+
isActive[lane] = 0;
|
|
620
|
+
const last = active.pop();
|
|
621
|
+
if (index < active.length)
|
|
622
|
+
active[index] = last;
|
|
623
|
+
}
|
|
521
624
|
}
|
|
522
625
|
} while (serialRerun);
|
|
523
626
|
serialInFlight = false;
|
|
@@ -533,9 +636,10 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
533
636
|
Promise.resolve().then(runSerialChecks);
|
|
534
637
|
};
|
|
535
638
|
channel.open(runSerialChecks);
|
|
536
|
-
workers.forEach((context) => {
|
|
639
|
+
workers.forEach((context, lane) => {
|
|
537
640
|
const wake = context.laneWake;
|
|
538
641
|
context.bindSend(() => {
|
|
642
|
+
markActive(lane);
|
|
539
643
|
scheduleSerialCheck();
|
|
540
644
|
wake();
|
|
541
645
|
});
|
|
@@ -580,6 +684,7 @@ export const createPool = ({ threads, debug, inliner, balancer, payload, unsafe,
|
|
|
580
684
|
closePromise = Promise.allSettled(workers.map((context) => context.kills()))
|
|
581
685
|
.then(() => {
|
|
582
686
|
sharedDispatcherChannel?.close();
|
|
687
|
+
stealChannel?.close();
|
|
583
688
|
});
|
|
584
689
|
return closePromise;
|
|
585
690
|
};
|
package/src/common/runtime.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
type RuntimeName = "deno" | "bun" | "node" | "andromeda" | "unknown";
|
|
1
|
+
type RuntimeName = "deno" | "bun" | "node" | "andromeda" | "browser" | "unknown";
|
|
2
2
|
export declare const IS_DENO: boolean;
|
|
3
3
|
export declare const IS_BUN: boolean;
|
|
4
4
|
export declare const IS_NODE: boolean;
|
|
5
5
|
export declare const IS_ANDROMEDA: boolean;
|
|
6
|
+
export declare const IS_BROWSER: boolean;
|
|
6
7
|
export declare const RUNTIME: RuntimeName;
|
|
7
8
|
export declare const SET_IMMEDIATE: typeof setImmediate & ((cb: () => void) => void);
|
|
8
9
|
export declare const HAS_SHARED_WASM_MEMORY: boolean;
|
package/src/common/runtime.js
CHANGED
|
@@ -7,6 +7,11 @@ export const IS_NODE = typeof nodeProcess?.versions?.node === "string";
|
|
|
7
7
|
// Andromeda (Nova engine): no `node:` builtins or `WebAssembly`; detect via
|
|
8
8
|
// its private bootstrap global.
|
|
9
9
|
export const IS_ANDROMEDA = typeof globals.__andromeda__ !== "undefined";
|
|
10
|
+
// A page or a web worker: no `node:` builtins, but `Worker`, `MessageChannel`,
|
|
11
|
+
// and a cross-origin isolated `SharedArrayBuffer` are all the pool needs.
|
|
12
|
+
export const IS_BROWSER = !IS_DENO && !IS_BUN && !IS_NODE && !IS_ANDROMEDA &&
|
|
13
|
+
(typeof globals.document !== "undefined" ||
|
|
14
|
+
typeof globals.WorkerGlobalScope === "function");
|
|
10
15
|
export const RUNTIME = (IS_DENO
|
|
11
16
|
? "deno"
|
|
12
17
|
: IS_BUN
|
|
@@ -15,7 +20,9 @@ export const RUNTIME = (IS_DENO
|
|
|
15
20
|
? "node"
|
|
16
21
|
: IS_ANDROMEDA
|
|
17
22
|
? "andromeda"
|
|
18
|
-
:
|
|
23
|
+
: IS_BROWSER
|
|
24
|
+
? "browser"
|
|
25
|
+
: "unknown");
|
|
19
26
|
// `WebAssembly` may be undeclared (not just undefined) on Andromeda, where a
|
|
20
27
|
// bare reference throws; capture it through `globalThis` so access is safe.
|
|
21
28
|
const WebAssemblyRef = globals.WebAssembly;
|
package/src/memory/lock.d.ts
CHANGED
|
@@ -159,6 +159,20 @@ export declare const HEADER_SLOT_STRIDE_BYTES: number;
|
|
|
159
159
|
export declare const HEADER_TASK_LINE_U32: number;
|
|
160
160
|
export declare const HEADER_STATIC_PAYLOAD_U32: number;
|
|
161
161
|
export declare const HEADER_TASK_OFFSET_IN_SLOT_U32: number;
|
|
162
|
+
export declare const STEAL_ACK_SLOT_OFFSET_U32: number;
|
|
163
|
+
export declare const STEAL_WANT_SLOT_OFFSET_U32: number;
|
|
164
|
+
/**
|
|
165
|
+
* The payload allocator needs its own participant-owned ACK per consumer, since
|
|
166
|
+
* several workers can free payload slots from one shared allocator. No intent
|
|
167
|
+
* word: freeing needs no arbitration, a consumer only frees what it drained.
|
|
168
|
+
*/
|
|
169
|
+
export declare const STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32: number;
|
|
170
|
+
/**
|
|
171
|
+
* Host-owned liveness mask for stealing consumers. A dead claimant may leave
|
|
172
|
+
* its WANT word set forever; survivors ignore WANT from consumers whose bit is
|
|
173
|
+
* clear here. This preserves single-writer ownership of every WANT word.
|
|
174
|
+
*/
|
|
175
|
+
export declare const STEAL_LIVE_SLOT_OFFSET_U32: number;
|
|
162
176
|
export declare const HEADER_U32_LENGTH: number;
|
|
163
177
|
export declare const HEADER_BYTE_LENGTH: number;
|
|
164
178
|
export declare const makeTask: () => Task;
|
|
@@ -177,7 +191,7 @@ type ResolveHostOptions = {
|
|
|
177
191
|
* - encode/decode are not re-entrant; payload codec uses a shared scratch buffer.
|
|
178
192
|
*/
|
|
179
193
|
export type Lock2 = ReturnType<typeof lock2>;
|
|
180
|
-
export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, }: {
|
|
194
|
+
export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, }: {
|
|
181
195
|
headers?: SharedBufferSource;
|
|
182
196
|
headerSlotStrideU32?: number;
|
|
183
197
|
LockBoundSector?: SharedBufferSource;
|
|
@@ -189,6 +203,16 @@ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, pa
|
|
|
189
203
|
resultList?: RingQueue<Task>;
|
|
190
204
|
recycleList?: RingQueue<Task>;
|
|
191
205
|
processBoundary?: boolean;
|
|
206
|
+
/**
|
|
207
|
+
* Number of consumer endpoints sharing this lock. `1` (default) keeps the
|
|
208
|
+
* classic single-consumer path. `> 1` enables region-Dekker work stealing and
|
|
209
|
+
* is only valid on a host->worker submit lock, never on a return lock.
|
|
210
|
+
*/
|
|
211
|
+
consumers?: number;
|
|
212
|
+
/** This endpoint's id, `0..consumers-1`. Priority is fixed by id. */
|
|
213
|
+
consumerId?: number;
|
|
214
|
+
/** Lanes claimed per Dekker handshake. Paper rule: `slots / regionLanes >= consumers + 1`. */
|
|
215
|
+
regionLanes?: number;
|
|
192
216
|
}) => {
|
|
193
217
|
enlist: (task: Task) => true;
|
|
194
218
|
encode: (task: Task, state?: number) => boolean;
|
|
@@ -207,6 +231,7 @@ export declare const lock2: ({ headers, headerSlotStrideU32, LockBoundSector, pa
|
|
|
207
231
|
getPendingFrameCount: () => number;
|
|
208
232
|
getPendingPromiseCount: () => number;
|
|
209
233
|
resetPendingState: () => void;
|
|
234
|
+
deactivateStealConsumer: (id: number) => boolean;
|
|
210
235
|
takeDeferredCount: () => number;
|
|
211
236
|
setPromiseHandler: (handler?: PromisePayloadHandler) => void;
|
|
212
237
|
};
|
package/src/memory/lock.js
CHANGED
|
@@ -226,6 +226,27 @@ export const HEADER_TASK_LINE_U32 = LOCK_CACHE_LINE_BYTES /
|
|
|
226
226
|
export const HEADER_STATIC_PAYLOAD_U32 = TaskIndex.TotalBuff -
|
|
227
227
|
HEADER_TASK_LINE_U32;
|
|
228
228
|
export const HEADER_TASK_OFFSET_IN_SLOT_U32 = HEADER_STATIC_PAYLOAD_U32;
|
|
229
|
+
// Work-stealing control words live in the unused tail of each slot's header
|
|
230
|
+
// cache line. Task words occupy 0..7 (`TASK_LOCAL_FLAGS_INDEX = 7`) of the
|
|
231
|
+
// 16-word line, leaving words 8..15 free. Consumer `c` owns slot `c`'s pair, and
|
|
232
|
+
// because the slot stride is exactly 9 cache lines those pairs land on distinct
|
|
233
|
+
// lines. `ACK[c]` and `WANT[c]` share one line on purpose: same single writer,
|
|
234
|
+
// and a peer reading one pulls the other for free.
|
|
235
|
+
export const STEAL_ACK_SLOT_OFFSET_U32 = HEADER_TASK_OFFSET_IN_SLOT_U32 +
|
|
236
|
+
TaskIndex.Size;
|
|
237
|
+
export const STEAL_WANT_SLOT_OFFSET_U32 = STEAL_ACK_SLOT_OFFSET_U32 + 1;
|
|
238
|
+
/**
|
|
239
|
+
* The payload allocator needs its own participant-owned ACK per consumer, since
|
|
240
|
+
* several workers can free payload slots from one shared allocator. No intent
|
|
241
|
+
* word: freeing needs no arbitration, a consumer only frees what it drained.
|
|
242
|
+
*/
|
|
243
|
+
export const STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32 = STEAL_ACK_SLOT_OFFSET_U32 + 2;
|
|
244
|
+
/**
|
|
245
|
+
* Host-owned liveness mask for stealing consumers. A dead claimant may leave
|
|
246
|
+
* its WANT word set forever; survivors ignore WANT from consumers whose bit is
|
|
247
|
+
* clear here. This preserves single-writer ownership of every WANT word.
|
|
248
|
+
*/
|
|
249
|
+
export const STEAL_LIVE_SLOT_OFFSET_U32 = STEAL_PAYLOAD_ACK_SLOT_OFFSET_U32 + 1;
|
|
229
250
|
export const HEADER_U32_LENGTH = LockBound.header +
|
|
230
251
|
(HEADER_SLOT_STRIDE_U32 * LockBound.slots);
|
|
231
252
|
export const HEADER_BYTE_LENGTH = HEADER_U32_LENGTH *
|
|
@@ -276,7 +297,7 @@ const settleTask = (task) => {
|
|
|
276
297
|
task[TaskIndex["FlagsToHost"]] = 0;
|
|
277
298
|
}
|
|
278
299
|
};
|
|
279
|
-
export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, }) => {
|
|
300
|
+
export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload, payloadConfig, payloadSector, textCompat, resultList, toSentList, recycleList, processBoundary, consumers, consumerId, regionLanes, }) => {
|
|
280
301
|
// Layout within `lockSectorRegion`:
|
|
281
302
|
// - hostBits starts at byte 0
|
|
282
303
|
// - workerBits starts at byte 64
|
|
@@ -308,6 +329,55 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
308
329
|
headers: headersRegion,
|
|
309
330
|
payload: payloadSAB,
|
|
310
331
|
});
|
|
332
|
+
// ---- work stealing (multi-consumer) state ----
|
|
333
|
+
// With N consumers the pending set is A ^ ACK[0] ^ ... ^ ACK[N-1]; every word
|
|
334
|
+
// still has exactly one writer, so no shared writable word is introduced.
|
|
335
|
+
const stealConsumers = Math.max(1, (consumers ?? 1) | 0);
|
|
336
|
+
const stealEnabled = stealConsumers > 1;
|
|
337
|
+
// A lock2 built without `consumerId` is the producer endpoint: it encodes but
|
|
338
|
+
// never claims. It still frees payload slots, so it needs an acknowledgement
|
|
339
|
+
// word of its own rather than sharing consumer 0's.
|
|
340
|
+
const stealIsProducer = consumerId === undefined;
|
|
341
|
+
const stealId = (consumerId ?? 0) | 0;
|
|
342
|
+
const stealRegionLanes = (regionLanes ?? 8) | 0;
|
|
343
|
+
const stealRegions = (LockBound.slots / stealRegionLanes) | 0;
|
|
344
|
+
if (stealEnabled) {
|
|
345
|
+
if (stealRegionLanes < 1 || (stealRegionLanes & (stealRegionLanes - 1)) !== 0) {
|
|
346
|
+
throw new RangeError("regionLanes must be a power of two");
|
|
347
|
+
}
|
|
348
|
+
if (stealRegions < stealConsumers) {
|
|
349
|
+
throw new RangeError(`regionLanes=${stealRegionLanes} yields ${stealRegions} regions, ` +
|
|
350
|
+
`too few for ${stealConsumers} consumers`);
|
|
351
|
+
}
|
|
352
|
+
if (stealId < 0 || stealId >= stealConsumers) {
|
|
353
|
+
throw new RangeError(`consumerId ${stealId} out of range`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Int32 alias over the headers SAB so the control words get signed bit math
|
|
357
|
+
// consistent with the rest of the protocol.
|
|
358
|
+
const stealView = new Int32Array(headersRegion.sab, headersRegion.byteOffset, headersRegion.byteLength >>> 2);
|
|
359
|
+
const stealAckIndex = new Int32Array(stealConsumers);
|
|
360
|
+
const stealWantIndex = new Int32Array(stealConsumers);
|
|
361
|
+
for (let c = 0; c < stealConsumers; c++) {
|
|
362
|
+
const slotBase = (c * headersSlotStride) + LockBound.header;
|
|
363
|
+
stealAckIndex[c] = slotBase + STEAL_ACK_SLOT_OFFSET_U32;
|
|
364
|
+
stealWantIndex[c] = slotBase + STEAL_WANT_SLOT_OFFSET_U32;
|
|
365
|
+
}
|
|
366
|
+
const stealLiveIndex = LockBound.header + STEAL_LIVE_SLOT_OFFSET_U32;
|
|
367
|
+
const stealAllLiveMask = stealConsumers === 32
|
|
368
|
+
? -1
|
|
369
|
+
: ((1 << stealConsumers) - 1) | 0;
|
|
370
|
+
if (stealEnabled && stealIsProducer) {
|
|
371
|
+
Atomics.store(stealView, stealLiveIndex, stealAllLiveMask);
|
|
372
|
+
}
|
|
373
|
+
const stealIsLive = (mask, consumer) => (mask & (1 << consumer)) !== 0;
|
|
374
|
+
const stealAckXorAll = () => {
|
|
375
|
+
let x = 0 | 0;
|
|
376
|
+
for (let c = 0; c < stealConsumers; c++) {
|
|
377
|
+
x = (x ^ a_load(stealView, stealAckIndex[c])) | 0;
|
|
378
|
+
}
|
|
379
|
+
return x;
|
|
380
|
+
};
|
|
311
381
|
let promiseHandler;
|
|
312
382
|
if (registeredEncodePayload === undefined ||
|
|
313
383
|
registeredDecodePayload === undefined) {
|
|
@@ -362,8 +432,11 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
362
432
|
// false-busy-only sender-side staleness property, this may hide newly freed
|
|
363
433
|
// lanes but cannot make a genuinely pending lane appear free. Refresh only
|
|
364
434
|
// when the cached free set is exhausted.
|
|
365
|
-
let workerShadow =
|
|
366
|
-
const refreshWorkerShadow =
|
|
435
|
+
let workerShadow = 0 | 0;
|
|
436
|
+
const refreshWorkerShadow = stealEnabled
|
|
437
|
+
? () => workerShadow = stealAckXorAll()
|
|
438
|
+
: () => workerShadow = a_load(workerBits, 0) | 0;
|
|
439
|
+
refreshWorkerShadow();
|
|
367
440
|
const ensureSenderStateHasFree = (state) => (~state) !== 0 ? state : (LastLocal ^ refreshWorkerShadow()) | 0;
|
|
368
441
|
// RingQueue method aliases (hot path)
|
|
369
442
|
const toBeSentPush = (task) => toBeSent.push(task);
|
|
@@ -561,6 +634,122 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
561
634
|
lastTake = last;
|
|
562
635
|
return true;
|
|
563
636
|
};
|
|
637
|
+
/**
|
|
638
|
+
* WORKER SIDE: decode, region-Dekker stealing variant.
|
|
639
|
+
*
|
|
640
|
+
* Claims one whole region of `stealRegionLanes` lanes per handshake, so the
|
|
641
|
+
* StoreLoad barrier is amortised over the region rather than paid per task.
|
|
642
|
+
* Priority is fixed by endpoint id: a junior withdraws for a senior, so at
|
|
643
|
+
* most one consumer ever acknowledges a published generation.
|
|
644
|
+
*
|
|
645
|
+
* The C reference needs an explicit mfence between the intent store and the
|
|
646
|
+
* peer loads. `Atomics.store` is sequentially consistent in JS, so that
|
|
647
|
+
* barrier is implicit here and holds on x86 and ARM alike.
|
|
648
|
+
*/
|
|
649
|
+
const stealLaneMask = (region) => stealRegionLanes === 32
|
|
650
|
+
? -1
|
|
651
|
+
: ((((1 << stealRegionLanes) - 1) << (region * stealRegionLanes)) | 0);
|
|
652
|
+
const stealPeerAcks = () => {
|
|
653
|
+
let x = 0 | 0;
|
|
654
|
+
for (let c = 0; c < stealConsumers; c++) {
|
|
655
|
+
if (c !== stealId)
|
|
656
|
+
x = (x ^ a_load(stealView, stealAckIndex[c])) | 0;
|
|
657
|
+
}
|
|
658
|
+
return x;
|
|
659
|
+
};
|
|
660
|
+
const stealJuniorWants = (intent) => {
|
|
661
|
+
const live = a_load(stealView, stealLiveIndex) | 0;
|
|
662
|
+
for (let c = stealId + 1; c < stealConsumers; c++) {
|
|
663
|
+
if (!stealIsLive(live, c))
|
|
664
|
+
continue;
|
|
665
|
+
if ((a_load(stealView, stealWantIndex[c]) & intent) !== 0)
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
return false;
|
|
669
|
+
};
|
|
670
|
+
let stealCursor = ((stealRegions * stealId) / stealConsumers) | 0;
|
|
671
|
+
const decodeSteal = () => {
|
|
672
|
+
const pending = (a_load(hostBits, 0) ^ LastWorker ^ stealPeerAcks()) | 0;
|
|
673
|
+
if (pending === 0)
|
|
674
|
+
return false;
|
|
675
|
+
let pendingRegions = 0 | 0;
|
|
676
|
+
if (stealRegions === 1)
|
|
677
|
+
pendingRegions = 1;
|
|
678
|
+
else {
|
|
679
|
+
for (let r = 0; r < stealRegions; r++) {
|
|
680
|
+
if ((pending & stealLaneMask(r)) !== 0)
|
|
681
|
+
pendingRegions |= 1 << r;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const liveBeforeClaim = a_load(stealView, stealLiveIndex) | 0;
|
|
685
|
+
let peerIntent = 0 | 0;
|
|
686
|
+
let seniorIntent = 0 | 0;
|
|
687
|
+
for (let c = 0; c < stealConsumers; c++) {
|
|
688
|
+
if (c === stealId || !stealIsLive(liveBeforeClaim, c))
|
|
689
|
+
continue;
|
|
690
|
+
const value = a_load(stealView, stealWantIndex[c]) | 0;
|
|
691
|
+
peerIntent |= value;
|
|
692
|
+
if (c < stealId)
|
|
693
|
+
seniorIntent |= value;
|
|
694
|
+
}
|
|
695
|
+
const notSenior = pendingRegions & ~seniorIntent;
|
|
696
|
+
if (notSenior === 0)
|
|
697
|
+
return false;
|
|
698
|
+
const clean = pendingRegions & ~peerIntent;
|
|
699
|
+
const candidates = clean !== 0 ? clean : notSenior;
|
|
700
|
+
let region = -1;
|
|
701
|
+
for (let step = 0; step < stealRegions; step++) {
|
|
702
|
+
const candidate = (stealCursor + step) % stealRegions;
|
|
703
|
+
if ((candidates & (1 << candidate)) !== 0) {
|
|
704
|
+
region = candidate;
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (region < 0)
|
|
709
|
+
return false;
|
|
710
|
+
const intent = (1 << region) | 0;
|
|
711
|
+
a_store(stealView, stealWantIndex[stealId], intent);
|
|
712
|
+
// Dekker StoreLoad: implicit, `Atomics.store` above is seq-cst.
|
|
713
|
+
let seniorConflict = false;
|
|
714
|
+
let juniorConflict = false;
|
|
715
|
+
for (let c = 0; c < stealConsumers; c++) {
|
|
716
|
+
if (c === stealId || !stealIsLive(liveBeforeClaim, c))
|
|
717
|
+
continue;
|
|
718
|
+
if ((a_load(stealView, stealWantIndex[c]) & intent) === 0)
|
|
719
|
+
continue;
|
|
720
|
+
if (c < stealId)
|
|
721
|
+
seniorConflict = true;
|
|
722
|
+
else
|
|
723
|
+
juniorConflict = true;
|
|
724
|
+
}
|
|
725
|
+
if (seniorConflict) {
|
|
726
|
+
a_store(stealView, stealWantIndex[stealId], 0);
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
if (juniorConflict) {
|
|
730
|
+
while (stealJuniorWants(intent)) { /* junior withdraws */ }
|
|
731
|
+
}
|
|
732
|
+
// Last control loads before ownership. After this no control word is read
|
|
733
|
+
// until every slot in `take` has been decoded.
|
|
734
|
+
const take = ((a_load(hostBits, 0) ^ LastWorker ^ stealPeerAcks()) &
|
|
735
|
+
stealLaneMask(region)) | 0;
|
|
736
|
+
if (take === 0) {
|
|
737
|
+
a_store(stealView, stealWantIndex[stealId], 0);
|
|
738
|
+
return false;
|
|
739
|
+
}
|
|
740
|
+
let lanes = take;
|
|
741
|
+
while (lanes !== 0) {
|
|
742
|
+
decodeAt(31 - clz32((lanes & -lanes) >>> 0));
|
|
743
|
+
lanes = (lanes & (lanes - 1)) | 0;
|
|
744
|
+
}
|
|
745
|
+
// Retire the whole claimed mask with one ACK store, then release the region.
|
|
746
|
+
// ACK before intent-clear (paper Assumption 2(ii)).
|
|
747
|
+
LastWorker = (LastWorker ^ take) | 0;
|
|
748
|
+
a_store(stealView, stealAckIndex[stealId], LastWorker);
|
|
749
|
+
a_store(stealView, stealWantIndex[stealId], 0);
|
|
750
|
+
stealCursor = (region + 1) % stealRegions;
|
|
751
|
+
return true;
|
|
752
|
+
};
|
|
564
753
|
/**
|
|
565
754
|
* HOST SIDE: decode version
|
|
566
755
|
*/
|
|
@@ -730,6 +919,24 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
730
919
|
deferredCount = 0 | 0;
|
|
731
920
|
pendingPromiseCount = 0 | 0;
|
|
732
921
|
};
|
|
922
|
+
/**
|
|
923
|
+
* HOST SIDE: remove a terminated consumer from stealing arbitration.
|
|
924
|
+
*
|
|
925
|
+
* Its ACK remains part of the generation XOR forever, because it records
|
|
926
|
+
* lanes that consumer retired while alive. Only its stale WANT becomes
|
|
927
|
+
* ineligible. The caller must invoke this only after the endpoint can no
|
|
928
|
+
* longer execute; there is deliberately no matching reactivation operation.
|
|
929
|
+
*/
|
|
930
|
+
const deactivateStealConsumer = (id) => {
|
|
931
|
+
if (!stealEnabled || !stealIsProducer)
|
|
932
|
+
return false;
|
|
933
|
+
if (!Number.isInteger(id) || id < 0 || id >= stealConsumers) {
|
|
934
|
+
throw new RangeError(`consumerId ${id} out of range`);
|
|
935
|
+
}
|
|
936
|
+
const bit = 1 << id;
|
|
937
|
+
const previous = Atomics.and(stealView, stealLiveIndex, ~bit);
|
|
938
|
+
return (previous & bit) !== 0;
|
|
939
|
+
};
|
|
733
940
|
return {
|
|
734
941
|
enlist,
|
|
735
942
|
encode,
|
|
@@ -737,7 +944,7 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
737
944
|
encodeAll,
|
|
738
945
|
publish,
|
|
739
946
|
flushPending,
|
|
740
|
-
decode,
|
|
947
|
+
decode: stealEnabled ? decodeSteal : decode,
|
|
741
948
|
hasSpace,
|
|
742
949
|
resolved,
|
|
743
950
|
hostBits,
|
|
@@ -748,6 +955,7 @@ export const lock2 = ({ headers, headerSlotStrideU32, LockBoundSector, payload,
|
|
|
748
955
|
getPendingFrameCount: () => toBeSent.size | 0,
|
|
749
956
|
getPendingPromiseCount: () => pendingPromiseCount | 0,
|
|
750
957
|
resetPendingState,
|
|
958
|
+
deactivateStealConsumer,
|
|
751
959
|
takeDeferredCount: () => {
|
|
752
960
|
const count = deferredCount | 0;
|
|
753
961
|
deferredCount = 0 | 0;
|
|
@@ -311,9 +311,7 @@ export const encodePayload = ({ lockSector, payload, sab, payloadConfig, headers
|
|
|
311
311
|
options: payload?.config ?? payloadConfig,
|
|
312
312
|
});
|
|
313
313
|
const maxPayloadBytes = resolvedPayloadConfig.maxPayloadBytes;
|
|
314
|
-
const { allocTask, setSlotLength, free } = register({
|
|
315
|
-
lockSector,
|
|
316
|
-
});
|
|
314
|
+
const { allocTask, setSlotLength, free } = register({ lockSector });
|
|
317
315
|
const { writeBinary: writeDynamicBinary, writeBuffer: writeDynamicBuffer, writeArrayBuffer: writeDynamicArrayBuffer, write8Binary: writeDynamic8Binary, writeUtf8: writeDynamicUtf8, } = createSharedDynamicBufferIO({
|
|
318
316
|
sab: payloadSab,
|
|
319
317
|
payloadConfig: resolvedPayloadConfig,
|
|
@@ -1219,9 +1217,7 @@ export const decodePayload = ({ lockSector, payload, sab, payloadConfig, headers
|
|
|
1219
1217
|
sab: payloadSab,
|
|
1220
1218
|
options: payload?.config ?? payloadConfig,
|
|
1221
1219
|
});
|
|
1222
|
-
const { free } = register({
|
|
1223
|
-
lockSector,
|
|
1224
|
-
});
|
|
1220
|
+
const { free } = register({ lockSector });
|
|
1225
1221
|
const freeTaskSlot = (task) => free(getTaskSlotIndex(task));
|
|
1226
1222
|
const { readUtf8: readDynamicUtf8, readBytesCopy: readDynamicBytesCopy, readBytesBufferCopy: readDynamicBufferCopy, readBufferCopy: readDynamicBuffer, readBytesArrayBufferCopy: readDynamicArrayBufferCopy, readArrayBufferCopy: readDynamicArrayBuffer, read8BytesFloatCopy: readDynamic8BytesFloatCopy, read8BytesFloatView: readDynamic8BytesFloatView, } = createSharedDynamicBufferIO({
|
|
1227
1223
|
sab: payloadSab,
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import type { Task } from "./lock.js";
|
|
2
2
|
import { type SharedBufferSource } from "../common/shared-buffer-region.js";
|
|
3
3
|
export type RegisterMalloc = ReturnType<typeof register>;
|
|
4
|
-
export
|
|
5
|
-
export declare const register: ({ lockSector, publishMode, }: {
|
|
4
|
+
export declare const register: ({ lockSector, }: {
|
|
6
5
|
lockSector?: SharedBufferSource;
|
|
7
|
-
publishMode?: RegionRegistryPublishMode;
|
|
8
6
|
}) => {
|
|
9
7
|
allocTask: (task: Task) => number;
|
|
10
8
|
setSlotLength: (slotIndex: number, payloadLen: number) => boolean;
|