@driftengine/physics 4.2.0 → 4.3.0
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/dist/islandWorker.js +1 -1
- package/dist/workerPool.js +70 -2
- package/package.json +1 -1
- package/src/islandWorker.ts +1 -1
- package/src/workerPool.ts +69 -2
package/dist/islandWorker.js
CHANGED
|
@@ -44,7 +44,7 @@ function accept(message) {
|
|
|
44
44
|
state = SolveStage.adopt(message.buffer, message.plan).state;
|
|
45
45
|
/*
|
|
46
46
|
* **The epoch is read before the acknowledgement, and that order is the whole correctness of a
|
|
47
|
-
* re-plan.** The caller
|
|
47
|
+
* re-plan.** The caller parks on `CTL_ACK` and bumps the epoch the moment it completes. Read
|
|
48
48
|
* after acknowledging and this worker can capture the epoch it was meant to wake for, then wait
|
|
49
49
|
* for the one after it — sleeping through the tick, so the join never completes and the pool
|
|
50
50
|
* declares itself dead. Reading first bounds `seen` below the next bump by construction.
|
package/dist/workerPool.js
CHANGED
|
@@ -32,6 +32,26 @@ export const CTL_STOP = 6;
|
|
|
32
32
|
export const CTL_SOLVED = 7;
|
|
33
33
|
/** How long a join waits before it gives up on a worker and solves the tick itself. */
|
|
34
34
|
const JOIN_DEADLINE_MS = 250;
|
|
35
|
+
/**
|
|
36
|
+
* How long the *first* adoption may take, which is a different question from every later one.
|
|
37
|
+
*
|
|
38
|
+
* **A worker cannot acknowledge before it exists.** `createIslandPool` spawns and returns without
|
|
39
|
+
* a readiness handshake, so the first `reload` is also paying for the worker entry to load: a
|
|
40
|
+
* module graph, resolved and type-stripped, once per worker. Every later reload hands a buffer to
|
|
41
|
+
* a thread that is already parked, which is a different order of magnitude.
|
|
42
|
+
*
|
|
43
|
+
* Measured on an idle twenty-four core machine with a warm module cache: the first adoption of
|
|
44
|
+
* each pool took **45 to 77 ms** and every subsequent one **0.3 to 0.8 ms**. So the 250 ms that is
|
|
45
|
+
* generous for a running worker was already spending up to a third of itself on startup in the
|
|
46
|
+
* best conditions available, and a cold two-core build character went over it. Reported as the
|
|
47
|
+
* pool announcing that its workers never took the shared buffer, intermittently, on a machine
|
|
48
|
+
* nobody could reproduce it on.
|
|
49
|
+
*
|
|
50
|
+
* Five seconds, because a boot that slow is still a working pool and the alternative is serial
|
|
51
|
+
* physics for the life of the world. It is not a licence for a *running* worker to go quiet:
|
|
52
|
+
* `JOIN_DEADLINE_MS` still governs that, and it is the one that means "stopped answering".
|
|
53
|
+
*/
|
|
54
|
+
const START_DEADLINE_MS = 5_000;
|
|
35
55
|
function sharedAlloc(bytes) {
|
|
36
56
|
return new SharedArrayBuffer(bytes);
|
|
37
57
|
}
|
|
@@ -230,13 +250,61 @@ export class WorkerPoolExecutor extends StagedExecutor {
|
|
|
230
250
|
plan: stage.plan,
|
|
231
251
|
});
|
|
232
252
|
}
|
|
233
|
-
|
|
234
|
-
|
|
253
|
+
/*
|
|
254
|
+
* **Park on the acknowledgement rather than spinning for it, because the thing being waited
|
|
255
|
+
* for needs a core to happen.**
|
|
256
|
+
*
|
|
257
|
+
* A worker can only acknowledge from its *message handler*, which needs its event loop, which
|
|
258
|
+
* needs CPU. A caller that spins hot holds a core against exactly the worker it is waiting
|
|
259
|
+
* for, so on a machine with fewer cores than workers the acknowledgement cannot arrive and
|
|
260
|
+
* this deadline expires every time. The pool then reports, correctly, that its workers never
|
|
261
|
+
* took the buffer, and falls back to a serial solve — so the failure is a lost capability
|
|
262
|
+
* rather than a wrong world, which is why it survived: everything stays right, just slower.
|
|
263
|
+
*
|
|
264
|
+
* `islandWorker.ts` has always answered with `Atomics.notify` on this slot, and its comment
|
|
265
|
+
* says "the caller is spinning on `CTL_ACK`" — a notify with nobody waiting on it. `join`
|
|
266
|
+
* three methods up already parks this way, and for the same reason. This is that asymmetry
|
|
267
|
+
* closed.
|
|
268
|
+
*
|
|
269
|
+
* Found on a build character rather than here: four workers on a twenty-four core machine
|
|
270
|
+
* acknowledge in microseconds and this spin never bit, while the same four on a two core
|
|
271
|
+
* runner missed the 250 ms deadline. `workerPool.test.mjs` records two earlier attempts to
|
|
272
|
+
* tune the core count around it, which is treating the symptom.
|
|
273
|
+
*
|
|
274
|
+
* **What would make it wrong** is a caller that may not block, which is a browser's main
|
|
275
|
+
* thread: `blocking` is false there, JavaScript offers no synchronous yield, and the spin is
|
|
276
|
+
* all that is left. Such a host on a machine this contended still takes the serial fallback,
|
|
277
|
+
* and the honest cure there is to stop re-planning inside a tick rather than to wait harder.
|
|
278
|
+
*/
|
|
279
|
+
/*
|
|
280
|
+
* **The first adoption is allowed to be slow, and it parks rather than spins.**
|
|
281
|
+
*
|
|
282
|
+
* The two go together. A first reload waits for the worker entry to load, which is tens of
|
|
283
|
+
* milliseconds at best and can be seconds on a cold, contended machine, so it is measured
|
|
284
|
+
* against `START_DEADLINE_MS` rather than the deadline that means "a running worker stopped
|
|
285
|
+
* answering". And a wait that long must not be a hot spin: `islandWorker.ts` has always
|
|
286
|
+
* answered with `Atomics.notify` on this slot, so the caller can sleep on it and be woken the
|
|
287
|
+
* instant it lands, which is what `join` already does. Burning a core for five seconds would
|
|
288
|
+
* slow the very startup being waited for.
|
|
289
|
+
*
|
|
290
|
+
* **What would make it wrong** is a caller that may not block, which is a browser's main
|
|
291
|
+
* thread: `blocking` is false there, JavaScript offers no synchronous yield, and the spin is
|
|
292
|
+
* all that is left. That host still takes the serial fallback rather than a wrong world, and
|
|
293
|
+
* the honest cure for it is a readiness handshake at construction rather than a longer wait
|
|
294
|
+
* inside a tick.
|
|
295
|
+
*/
|
|
296
|
+
const deadline = performance.now() + (this.started ? JOIN_DEADLINE_MS : START_DEADLINE_MS);
|
|
297
|
+
for (;;) {
|
|
298
|
+
const acked = Atomics.load(ctl, CTL_ACK);
|
|
299
|
+
if (acked >= this.workers.length)
|
|
300
|
+
break;
|
|
235
301
|
if (performance.now() > deadline) {
|
|
236
302
|
this.failure =
|
|
237
303
|
'the workers did not take the shared buffer, so island solving stayed on this thread.';
|
|
238
304
|
return false;
|
|
239
305
|
}
|
|
306
|
+
if (this.blocking)
|
|
307
|
+
Atomics.wait(ctl, CTL_ACK, acked, 50);
|
|
240
308
|
}
|
|
241
309
|
Atomics.store(ctl, CTL_RELOAD, 0);
|
|
242
310
|
this.posted = stage.generation;
|
package/package.json
CHANGED
package/src/islandWorker.ts
CHANGED
|
@@ -58,7 +58,7 @@ function accept(message: StageMessage): void {
|
|
|
58
58
|
state = SolveStage.adopt(message.buffer, message.plan).state;
|
|
59
59
|
/*
|
|
60
60
|
* **The epoch is read before the acknowledgement, and that order is the whole correctness of a
|
|
61
|
-
* re-plan.** The caller
|
|
61
|
+
* re-plan.** The caller parks on `CTL_ACK` and bumps the epoch the moment it completes. Read
|
|
62
62
|
* after acknowledging and this worker can capture the epoch it was meant to wake for, then wait
|
|
63
63
|
* for the one after it — sleeping through the tick, so the join never completes and the pool
|
|
64
64
|
* declares itself dead. Reading first bounds `seen` below the next bump by construction.
|
package/src/workerPool.ts
CHANGED
|
@@ -48,6 +48,27 @@ export const CTL_SOLVED = 7;
|
|
|
48
48
|
/** How long a join waits before it gives up on a worker and solves the tick itself. */
|
|
49
49
|
const JOIN_DEADLINE_MS = 250;
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* How long the *first* adoption may take, which is a different question from every later one.
|
|
53
|
+
*
|
|
54
|
+
* **A worker cannot acknowledge before it exists.** `createIslandPool` spawns and returns without
|
|
55
|
+
* a readiness handshake, so the first `reload` is also paying for the worker entry to load: a
|
|
56
|
+
* module graph, resolved and type-stripped, once per worker. Every later reload hands a buffer to
|
|
57
|
+
* a thread that is already parked, which is a different order of magnitude.
|
|
58
|
+
*
|
|
59
|
+
* Measured on an idle twenty-four core machine with a warm module cache: the first adoption of
|
|
60
|
+
* each pool took **45 to 77 ms** and every subsequent one **0.3 to 0.8 ms**. So the 250 ms that is
|
|
61
|
+
* generous for a running worker was already spending up to a third of itself on startup in the
|
|
62
|
+
* best conditions available, and a cold two-core build character went over it. Reported as the
|
|
63
|
+
* pool announcing that its workers never took the shared buffer, intermittently, on a machine
|
|
64
|
+
* nobody could reproduce it on.
|
|
65
|
+
*
|
|
66
|
+
* Five seconds, because a boot that slow is still a working pool and the alternative is serial
|
|
67
|
+
* physics for the life of the world. It is not a licence for a *running* worker to go quiet:
|
|
68
|
+
* `JOIN_DEADLINE_MS` still governs that, and it is the one that means "stopped answering".
|
|
69
|
+
*/
|
|
70
|
+
const START_DEADLINE_MS = 5_000;
|
|
71
|
+
|
|
51
72
|
/** What a consumer asked for. */
|
|
52
73
|
export interface PoolRequest {
|
|
53
74
|
workers: number;
|
|
@@ -272,13 +293,59 @@ export class WorkerPoolExecutor extends StagedExecutor implements Executor {
|
|
|
272
293
|
});
|
|
273
294
|
}
|
|
274
295
|
|
|
275
|
-
|
|
276
|
-
|
|
296
|
+
/*
|
|
297
|
+
* **Park on the acknowledgement rather than spinning for it, because the thing being waited
|
|
298
|
+
* for needs a core to happen.**
|
|
299
|
+
*
|
|
300
|
+
* A worker can only acknowledge from its *message handler*, which needs its event loop, which
|
|
301
|
+
* needs CPU. A caller that spins hot holds a core against exactly the worker it is waiting
|
|
302
|
+
* for, so on a machine with fewer cores than workers the acknowledgement cannot arrive and
|
|
303
|
+
* this deadline expires every time. The pool then reports, correctly, that its workers never
|
|
304
|
+
* took the buffer, and falls back to a serial solve — so the failure is a lost capability
|
|
305
|
+
* rather than a wrong world, which is why it survived: everything stays right, just slower.
|
|
306
|
+
*
|
|
307
|
+
* `islandWorker.ts` has always answered with `Atomics.notify` on this slot, and its comment
|
|
308
|
+
* says "the caller is spinning on `CTL_ACK`" — a notify with nobody waiting on it. `join`
|
|
309
|
+
* three methods up already parks this way, and for the same reason. This is that asymmetry
|
|
310
|
+
* closed.
|
|
311
|
+
*
|
|
312
|
+
* Found on a build character rather than here: four workers on a twenty-four core machine
|
|
313
|
+
* acknowledge in microseconds and this spin never bit, while the same four on a two core
|
|
314
|
+
* runner missed the 250 ms deadline. `workerPool.test.mjs` records two earlier attempts to
|
|
315
|
+
* tune the core count around it, which is treating the symptom.
|
|
316
|
+
*
|
|
317
|
+
* **What would make it wrong** is a caller that may not block, which is a browser's main
|
|
318
|
+
* thread: `blocking` is false there, JavaScript offers no synchronous yield, and the spin is
|
|
319
|
+
* all that is left. Such a host on a machine this contended still takes the serial fallback,
|
|
320
|
+
* and the honest cure there is to stop re-planning inside a tick rather than to wait harder.
|
|
321
|
+
*/
|
|
322
|
+
/*
|
|
323
|
+
* **The first adoption is allowed to be slow, and it parks rather than spins.**
|
|
324
|
+
*
|
|
325
|
+
* The two go together. A first reload waits for the worker entry to load, which is tens of
|
|
326
|
+
* milliseconds at best and can be seconds on a cold, contended machine, so it is measured
|
|
327
|
+
* against `START_DEADLINE_MS` rather than the deadline that means "a running worker stopped
|
|
328
|
+
* answering". And a wait that long must not be a hot spin: `islandWorker.ts` has always
|
|
329
|
+
* answered with `Atomics.notify` on this slot, so the caller can sleep on it and be woken the
|
|
330
|
+
* instant it lands, which is what `join` already does. Burning a core for five seconds would
|
|
331
|
+
* slow the very startup being waited for.
|
|
332
|
+
*
|
|
333
|
+
* **What would make it wrong** is a caller that may not block, which is a browser's main
|
|
334
|
+
* thread: `blocking` is false there, JavaScript offers no synchronous yield, and the spin is
|
|
335
|
+
* all that is left. That host still takes the serial fallback rather than a wrong world, and
|
|
336
|
+
* the honest cure for it is a readiness handshake at construction rather than a longer wait
|
|
337
|
+
* inside a tick.
|
|
338
|
+
*/
|
|
339
|
+
const deadline = performance.now() + (this.started ? JOIN_DEADLINE_MS : START_DEADLINE_MS);
|
|
340
|
+
for (;;) {
|
|
341
|
+
const acked = Atomics.load(ctl, CTL_ACK);
|
|
342
|
+
if (acked >= this.workers.length) break;
|
|
277
343
|
if (performance.now() > deadline) {
|
|
278
344
|
this.failure =
|
|
279
345
|
'the workers did not take the shared buffer, so island solving stayed on this thread.';
|
|
280
346
|
return false;
|
|
281
347
|
}
|
|
348
|
+
if (this.blocking) Atomics.wait(ctl, CTL_ACK, acked, 50);
|
|
282
349
|
}
|
|
283
350
|
Atomics.store(ctl, CTL_RELOAD, 0);
|
|
284
351
|
this.posted = stage.generation;
|