@driftengine/physics 4.2.0 → 4.2.1

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.
@@ -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 is spinning on `CTL_ACK` and bumps the epoch the moment it completes. Read
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.
@@ -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
- const deadline = performance.now() + JOIN_DEADLINE_MS;
234
- while (Atomics.load(ctl, CTL_ACK) < this.workers.length) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/physics",
3
- "version": "4.2.0",
3
+ "version": "4.2.1",
4
4
  "description": "Collision shapes, spatial queries and swept kinematic collision",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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 is spinning on `CTL_ACK` and bumps the epoch the moment it completes. Read
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
- const deadline = performance.now() + JOIN_DEADLINE_MS;
276
- while (Atomics.load(ctl, CTL_ACK) < this.workers.length) {
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;