joelniklaus HF Staff commited on
Commit
dec92cb
·
1 Parent(s): 26c62d3

made some more improvements to the inference throughput illustration

Browse files
app/src/content/chapters/3-experiments.mdx CHANGED
@@ -5,19 +5,9 @@ import Glossary from "../../components/Glossary.astro";
5
  import FigRef from "../../components/FigRef.astro";
6
  import ReadingTime from "../../components/ReadingTime.astro";
7
 
8
- {/* TODO: read through entire blog post and make improvements */}
9
  {/* TODO: Integrate decay experiment as another analysis for proxy */}
10
  {/* TODO: share on a bunch of discords/slacks/hackernews/locallama */}
11
  {/* TODO: run variance experiments with pretraining from scratch */}
12
- {/* TODO: go through the blog post and update the scale numbers for finephrase dataset */}
13
- {/* TODO: brainstorm better banner, be artsy */}
14
- {/* TODO: banner idea: 1T tokens = 8M books
15
- 5cm pro buech = 400km
16
-
17
- Denn chönntme die büecher ufenandstaple und d distanz zeige ufenere charte bspw. Oder mit öppis vergliiche.
18
- Oder für jedes buech en punkt mache
19
- */}
20
- {/* TODO: final configuration for finephrase at the end of infra section: visualization of how many pages (500 tokens) (use page emojis flying from left to right) we can generate (real time), user can configure with a slider the number of GPUs */}
21
  {/* TODO: baselines mixed with fw-edu-hq usually improve upon just baselines, but not sure if/how to present this */}
22
 
23
  {/*
 
5
  import FigRef from "../../components/FigRef.astro";
6
  import ReadingTime from "../../components/ReadingTime.astro";
7
 
 
8
  {/* TODO: Integrate decay experiment as another analysis for proxy */}
9
  {/* TODO: share on a bunch of discords/slacks/hackernews/locallama */}
10
  {/* TODO: run variance experiments with pretraining from scratch */}
 
 
 
 
 
 
 
 
 
11
  {/* TODO: baselines mixed with fw-edu-hq usually improve upon just baselines, but not sure if/how to present this */}
12
 
13
  {/*
app/src/content/chapters/5-infrastructure.mdx CHANGED
@@ -427,9 +427,7 @@ With a trillion-parameter model you won't be generating billions of tokens per h
427
 
428
  {/*
429
  Further improvement ideas:
430
- - add a second model below so we can compare. Suggest something cool for the numbers below.
431
- - Also add some animations (page turning, flapping books, bookshelfes books coming in and out)
432
- - Clean it up a bit to make it less cluttered
433
  */}
434
 
435
  To get a feel for what these throughput numbers actually mean, <FigRef target="inference-throughput" /> lets you pick a model and scale up the number of GPUs. Each page represents roughly 500 tokens of generated text. At high enough throughput, pages roll up into books (500 pages each), and books into shelves (500 books each).
 
427
 
428
  {/*
429
  Further improvement ideas:
430
+ - add a second model below so we can compare.
 
 
431
  */}
432
 
433
  To get a feel for what these throughput numbers actually mean, <FigRef target="inference-throughput" /> lets you pick a model and scale up the number of GPUs. Each page represents roughly 500 tokens of generated text. At high enough throughput, pages roll up into books (500 pages each), and books into shelves (500 books each).
app/src/content/embeds/inference-throughput.html CHANGED
@@ -78,7 +78,7 @@
78
  background-repeat: no-repeat;
79
  cursor: pointer;
80
  }
81
- .canvas-row { display: flex; gap: 8px; align-items: stretch; }
82
  .canvas-row .side-panel { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
83
  .canvas-wrap {
84
  position: relative;
@@ -95,13 +95,13 @@
95
  border: 0;
96
  background: transparent;
97
  }
98
- .slider-area { position: relative; width: 100%; margin-bottom: 8px; isolation: isolate; z-index: 20; }
99
  .slider-area input[type=range] { position: relative; z-index: 3; width: 100%; margin: 0; display: block; -webkit-appearance: none; appearance: none; height: 8px; border-radius: 4px; outline: none; cursor: pointer; }
100
  .slider-area input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--bg); border: 2px solid var(--brand); cursor: pointer; margin-top: -5px; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
101
  .slider-area input[type=range]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: var(--bg); border: 2px solid var(--brand); cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
102
 
103
- .landmark-row { position: relative; width: 100%; height: 16px; z-index: 1; }
104
- .landmark { position: absolute; transform: translateX(-50%); cursor: pointer; display: flex; flex-direction: column; align-items: center; z-index: 30; }
105
  .landmark-row.top .landmark { bottom: 0; }
106
  .landmark-row.bottom .landmark { top: 0; }
107
  .landmark .tick { width: 2px; height: 5px; flex-shrink: 0; }
@@ -112,8 +112,8 @@
112
  .landmark-row.top .name { color: var(--training); }
113
  .landmark-row.bottom .tick { background: var(--brand); }
114
  .landmark-row.bottom .name { color: var(--brand); }
115
- #row-a1, #row-b1 { z-index: 2; }
116
- #row-a2, #row-b2 { z-index: 1; }
117
  #row-a2 .landmark .tick,
118
  #row-b2 .landmark .tick { position: relative; }
119
  #row-a2 .landmark .tick::after {
@@ -142,22 +142,25 @@
142
  }
143
 
144
  .landmark .tooltip { display: none; position: absolute; left: 50%; transform: translateX(-50%); background: var(--tooltip-bg); color: var(--tooltip-text); font-size: 12px; padding: 10px 14px; border-radius: 8px; width: 300px; white-space: normal; z-index: 2147483647; pointer-events: none; line-height: 1.45; text-align: left; box-shadow: 0 12px 30px rgba(0,0,0,0.35); }
145
- .landmark-row.top .tooltip { bottom: calc(100% + 4px); }
146
  .landmark-row.bottom .tooltip { top: calc(100% + 4px); }
147
  .landmark .tooltip::after { content: ''; position: absolute; left: 50%; transform: translateX(-50%); border: 5px solid transparent; }
148
  .landmark .tooltip.tip-right::after { left: auto; right: 16px; transform: none; }
149
  .landmark .tooltip.tip-left::after { left: 16px; transform: none; }
150
- .landmark-row.top .tooltip::after { top: 100%; border-top-color: var(--tooltip-bg); }
151
  .landmark-row.bottom .tooltip::after { bottom: 100%; border-bottom-color: var(--tooltip-bg); }
 
152
  .landmark:hover .tooltip { display: block; }
153
 
154
  .throughput-strip {
 
 
 
155
  width: calc(32% - 10px);
156
- min-width: 190px;
157
- margin: 6px 0 10px 10px;
158
  padding: 0;
159
  text-align: center;
160
- font-size: 14px;
161
  color: #1f2937;
162
  font-weight: 700;
163
  font-variant-numeric: tabular-nums;
@@ -171,27 +174,27 @@
171
  .output-panel {
172
  position: absolute;
173
  right: 10px;
174
- top: 50%;
175
  transform: translateY(-50%);
176
  z-index: 4;
177
  pointer-events: none;
178
  display: flex;
179
  flex-direction: column;
180
- gap: 4px;
181
- padding: 10px 9px;
182
- border-radius: 10px;
183
  border: 1px solid var(--border);
184
  background: color-mix(in srgb, var(--surface) 88%, transparent);
185
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
186
  }
187
  .output-heading {
188
  text-align: left;
189
- font-size: 11px;
190
  color: var(--text-muted);
191
  text-transform: uppercase;
192
  letter-spacing: 0.6px;
193
  font-weight: 700;
194
- margin-bottom: 2px;
195
  }
196
  .output-stats {
197
  display: grid;
@@ -206,7 +209,7 @@
206
  }
207
  .output-num {
208
  text-align: right;
209
- font-size: 40px;
210
  line-height: 1;
211
  font-weight: 700;
212
  color: var(--brand);
@@ -223,22 +226,23 @@
223
  letter-spacing: 0.2px;
224
  }
225
  .output-unit-emoji {
226
- font-size: 20px;
227
  line-height: 1;
228
  }
229
 
230
- .datasets { margin-top: 12px; width: 100%; }
231
- .datasets-title { font-size: 12px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.6px; text-align: center; margin-bottom: 6px; }
232
- .dataset-bars { display: flex; flex-wrap: wrap; gap: 6px 16px; justify-content: center; }
233
- .dataset-item { display: flex; align-items: center; gap: 5px; font-size: 13px; min-width: 140px; position: relative; cursor: pointer; }
234
  .dataset-item .ds-check { font-size: 15px; width: 18px; text-align: center; }
235
  .dataset-item .ds-name { font-weight: 600; color: var(--text-strong); }
236
  .dataset-item .ds-time { color: var(--brand); font-weight: 700; font-variant-numeric: tabular-nums; }
237
  .dataset-item .ds-size { color: var(--text-faint); font-size: 11px; }
238
  .dataset-item.done .ds-name { color: var(--success); }
239
  .dataset-item.done .ds-time { color: var(--success); }
240
- .dataset-item .ds-tip { display: none; position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); background: var(--tooltip-bg); color: var(--tooltip-text); font-size: 12px; padding: 10px 14px; border-radius: 8px; width: 260px; white-space: normal; z-index: 2147483647; pointer-events: none; line-height: 1.45; text-align: left; box-shadow: 0 12px 30px rgba(0,0,0,0.35); }
241
- .dataset-item .ds-tip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: var(--tooltip-bg); }
 
242
  .dataset-item:hover .ds-tip { display: block; }
243
 
244
  @media (max-width: 980px) {
@@ -248,8 +252,8 @@
248
  .throughput-strip {
249
  width: auto;
250
  min-width: 0;
251
- margin: 6px 8px 8px;
252
- font-size: 13px;
253
  text-align: center;
254
  }
255
  .output-panel {
@@ -264,7 +268,9 @@
264
  .output-unit { font-size: 11px; min-width: 3ch; }
265
  .output-unit-emoji { font-size: 16px; }
266
  .output-heading { font-size: 10px; }
267
- .dataset-item { font-size: 12px; min-width: 170px; }
 
 
268
  }
269
  </style>
270
  </head>
@@ -283,28 +289,28 @@
283
  <div class="control-group">
284
  <label>Model</label>
285
  <select id="model">
286
- <option value="45540">SmolLM2-135M (45,540 tps/gpu)</option>
287
- <option value="8086">Qwen3-4B (8,086 tps/gpu)</option>
288
- <option value="6443">Qwen3-8B (6,443 tps/gpu)</option>
289
- <option value="6117">GPT-OSS-120B (6,117 tps/gpu)</option>
290
- <option value="1724">Gemma-3-27B (1,724 tps/gpu)</option>
291
  </select>
292
  </div>
293
  </div>
294
  <div class="canvas-wrap">
295
  <div class="canvas-stage">
296
  <canvas id="c"></canvas>
297
- <div class="output-panel">
298
- <div class="output-heading">Generated</div>
299
- <div class="output-stats">
300
- <div class="output-line">
301
- <span class="output-num" id="totalTokensNum">0</span>
302
- <span class="output-unit output-unit-text">toks</span>
303
- </div>
304
- <div class="output-line">
305
- <span class="output-num" id="totalItemNum">0</span>
306
- <span class="output-unit output-unit-emoji" id="totalItemUnit">📄</span>
307
- </div>
308
  </div>
309
  </div>
310
  </div>
@@ -313,17 +319,16 @@
313
  <span class="tps" id="tpsInline">(0 TPS)</span>
314
  </div>
315
  </div>
316
- </div>
317
-
318
- <div class="datasets">
319
- <div class="datasets-title">Time to generate dataset at current throughput</div>
320
- <div class="dataset-bars" id="datasetBars"></div>
321
  </div>
322
  </div>
323
 
324
  <script>
325
  const canvas = document.getElementById('c');
326
- const ctx = canvas.getContext('2d');
327
 
328
  function resizeCanvas() {
329
  const w = canvas.clientWidth;
@@ -349,14 +354,10 @@
349
  const DATASETS = [
350
  { name: 'BookCorpus', tokens: 1e9,
351
  desc: '<b>BookCorpus</b> (2015)<br>11K unpublished books scraped from smashwords.com. Used to train the original BERT and GPT-1.' },
352
- { name: 'Wikipedia', tokens: 4.3e9,
353
- desc: '<b>English Wikipedia</b><br>All articles from en.wikipedia.org. A staple ingredient in virtually every LLM pretraining mix.' },
354
- { name: 'C4', tokens: 156e9,
355
- desc: '<b>C4</b> (Colossal Clean Crawled Corpus, 2020)<br>Cleaned version of Common Crawl used to train T5. Aggressive filtering removed 99% of the raw crawl.' },
356
  { name: 'FinePhrase', tokens: 1e12,
357
  desc: '<b>FinePhrase</b> (Hugging Face, 2026)<br>1T tokens of LLM-rephrased web text. Synthetic data that teaches small models to punch above their weight.' },
358
- { name: 'FineWeb', tokens: 15e12,
359
- desc: '<b>FineWeb</b> (Hugging Face, 2024)<br>15T tokens of deduplicated, quality-filtered Common Crawl. The largest open pretraining dataset.' },
360
  { name: 'RedPajama', tokens: 100e12,
361
  desc: '<b>RedPajama v2</b> (Together AI, 2023)<br>100T raw tokens from 84 Common Crawl snapshots with quality signals. Covers 5 languages.' },
362
  { name: 'Common Crawl', tokens: 3e15,
@@ -507,8 +508,8 @@
507
  const dsEls = DATASETS.map(ds => {
508
  const el = document.createElement('div');
509
  el.className = 'dataset-item';
510
- if (ds.name === 'FineWeb') el.style.order = '99';
511
- el.innerHTML = '<span class="ds-check">\u23f3</span><span class="ds-name">' + ds.name + '</span><span class="ds-time">-</span><span class="ds-size">(' + formatNumInt(ds.tokens) + ' tok)</span><div class="ds-tip">' + ds.desc + '</div>';
512
  datasetBarsEl.appendChild(el);
513
  return el;
514
  });
@@ -560,7 +561,9 @@
560
  function getW() { return canvas.width; }
561
  function getH() { return canvas.height; }
562
 
563
- // Hardware display: GPU node rack superpod → cluster grid
 
 
564
  function getHardwareLevel(gpus) {
565
  if (gpus < GPUS_PER_NODE) return 'gpu';
566
  if (gpus < GPUS_PER_RACK) return 'node';
@@ -568,33 +571,55 @@
568
  return 'cluster';
569
  }
570
 
 
 
 
 
 
 
 
571
  function drawHardware() {
572
  const W = getW(), H = getH();
573
  const gpus = getGpuCount();
574
- const labelH = 40;
575
- const aw = W * 0.32, ah = H - 20 - labelH, ax = 10, ay = 10 + labelH;
576
- const level = getHardwareLevel(gpus);
577
-
578
- // GPU count label above hardware
579
- ctx.fillStyle = themeTokens.brand;
580
- ctx.font = 'bold 32px system-ui, sans-serif';
581
- ctx.textAlign = 'center';
582
- ctx.textBaseline = 'top';
583
- ctx.fillText(formatGpuLabel(gpus), ax + aw / 2, 8);
584
- ctx.textAlign = 'start'; ctx.textBaseline = 'alphabetic';
585
-
586
- if (level === 'gpu') {
587
- drawGpuGrid(gpus, ax, ay, aw, ah);
588
- } else if (level === 'node') {
589
- const nodeCount = Math.max(1, Math.round(gpus / GPUS_PER_NODE));
590
- drawNodeGrid(nodeCount, ax, ay, aw, ah);
591
- } else if (level === 'rack') {
592
- const rackCount = Math.max(1, Math.round(gpus / GPUS_PER_RACK));
593
- drawRackGrid(rackCount, ax, ay, aw, ah);
594
- } else {
595
- const pods = Math.round(gpus / GPUS_PER_SUPERPOD);
596
- drawPodGrid(pods, ax, ay, aw, ah);
597
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  }
599
 
600
  function drawGpuGrid(count, ax, ay, aw, ah) {
@@ -608,7 +633,7 @@
608
  const gh = gw / 0.6;
609
  const offX = ax + (aw - (cols * gw + (cols - 1) * gap)) / 2;
610
  const offY = ay + (ah - (rows * gh + (rows - 1) * gap)) / 2;
611
- const fanSpeed = (performance.now() / 200) * Math.min(getPps(), 100);
612
  for (let i = 0; i < count; i++) {
613
  const c = i % cols, r = Math.floor(i / cols);
614
  drawSingleGpu(offX + c * (gw + gap), offY + r * (gh + gap), gw, gh, fanSpeed + i * 0.5);
@@ -664,15 +689,18 @@
664
  ctx.beginPath(); ctx.roundRect(nx, ny, nw, nh, 4); ctx.stroke();
665
  const slotW = (nw - 20) / 8, slotH = nh * 0.6;
666
  const slotY = ny + (nh - slotH) / 2;
667
- const fanPhase = (performance.now() / 200) * Math.min(getPps(), 100);
668
  for (let i = 0; i < 8; i++) {
669
  const sx = nx + 10 + i * slotW;
670
- drawSingleGpu(sx + 1, slotY, slotW - 2, slotH, fanPhase + i * 0.5);
671
  }
 
 
672
  ctx.fillStyle = '#4ade80';
673
  ctx.beginPath(); ctx.arc(nx + 8, ny + 8, 3, 0, Math.PI * 2); ctx.fill();
 
674
  ctx.fillStyle = '#60a5fa';
675
  ctx.beginPath(); ctx.arc(nx + 18, ny + 8, 3, 0, Math.PI * 2); ctx.fill();
 
676
  }
677
 
678
  // Grid of racks
@@ -698,7 +726,6 @@
698
  const nodeCount = 4, pad = 4, gap = 3;
699
  const nodeH = (rh - 2 * pad - (nodeCount - 1) * gap) / nodeCount;
700
  const nodeW = rw - 2 * pad;
701
- const fanPhase = (performance.now() / 200) * Math.min(getPps(), 100);
702
  for (let n = 0; n < nodeCount; n++) {
703
  const ny = ry + pad + n * (nodeH + gap), nx = rx + pad;
704
  ctx.fillStyle = '#1a1a2e';
@@ -713,14 +740,17 @@
713
  const fr = Math.min((slotW - 1) * 0.35, (nodeH - 4) * 0.25);
714
  if (fr > 1) {
715
  ctx.beginPath(); ctx.arc(fcx, fcy, fr, 0, Math.PI * 2); ctx.fillStyle = '#444'; ctx.fill();
716
- ctx.save(); ctx.translate(fcx, fcy); ctx.rotate(fanPhase + g * 0.3 + n);
717
  for (let b = 0; b < 4; b++) { ctx.rotate(Math.PI / 2); ctx.beginPath(); ctx.moveTo(0, 0);
718
  ctx.lineTo(fr * 0.8, fr * 0.3); ctx.lineTo(fr * 0.8, -fr * 0.3); ctx.fillStyle = '#666'; ctx.fill(); }
719
  ctx.restore();
720
  }
721
  }
 
 
722
  ctx.fillStyle = '#4ade80';
723
  ctx.beginPath(); ctx.arc(nx + nodeW - 6, ny + nodeH / 2, Math.min(2, nodeH * 0.15), 0, Math.PI * 2); ctx.fill();
 
724
  }
725
  }
726
 
@@ -759,9 +789,23 @@
759
  ctx.beginPath(); ctx.roundRect(x, y, nw, nh, r); ctx.stroke();
760
  if (nw < 4) return;
761
  const lr = Math.max(0.8, Math.min(2, nw * 0.06)), ly = y + nh * 0.2;
762
- ctx.fillStyle = '#4ade80'; ctx.beginPath(); ctx.arc(x + nw * 0.2, ly, lr, 0, Math.PI * 2); ctx.fill();
763
- if (nw >= 8) { ctx.fillStyle = '#60a5fa'; ctx.beginPath(); ctx.arc(x + nw * 0.4, ly, lr, 0, Math.PI * 2); ctx.fill(); }
764
- if (nw >= 6) { ctx.fillStyle = '#c4a020'; ctx.beginPath(); ctx.arc(x + nw * 0.6, ly, lr, 0, Math.PI * 2); ctx.fill(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765
  if (nw >= 10) {
766
  const vy = y + nh * 0.45, vh = nh * 0.4, lc = Math.min(6, Math.floor(nw / 4));
767
  ctx.strokeStyle = '#333'; ctx.lineWidth = 0.5;
@@ -778,32 +822,59 @@
778
 
779
  const EMOJI = { page: '\u{1F4C4}', book: '\u{1F4D6}', shelf: '\u{1F4DA}' };
780
  const EMOJI_SIZE = { page: 28, book: 34, shelf: 40 };
781
-
782
- function drawItem(p) {
783
- const W = getW();
784
- const fadeStart = W - W * 0.1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
785
  const alpha = p.x > fadeStart ? 1 - (p.x - fadeStart) / (W - fadeStart) : 1;
 
 
 
786
  ctx.globalAlpha = alpha;
787
- const sz = EMOJI_SIZE[p.type];
788
- ctx.font = sz + 'px system-ui, sans-serif';
789
- ctx.textBaseline = 'top';
790
- ctx.fillText(EMOJI[p.type], p.x, p.y);
 
791
  ctx.globalAlpha = 1;
792
  }
793
 
794
  function spawnItem() {
795
  const H = getH(), W = getW();
796
  const spawnX = W * 0.34;
797
- const yMin = 30, yMax = H - 60, y = yMin + Math.random() * (yMax - yMin);
 
798
  const mode = getVisualMode();
799
  const pps = getPps();
800
  const pagesPerShelf = PAGES_PER_BOOK * BOOKS_PER_SHELF;
801
  const rate = mode === 'shelf' ? pps / pagesPerShelf : mode === 'book' ? pps / PAGES_PER_BOOK : pps;
802
  const bs = 0.8 + Math.log10(Math.max(1, rate)) * 0.8;
803
  const tokens = mode === 'shelf' ? TOKENS_PER_SHELF : mode === 'book' ? TOKENS_PER_BOOK : TOKENS_PER_PAGE;
 
 
 
 
 
 
804
  floatingItems.push({
805
- x: spawnX + Math.random() * 20, y, speed: bs + Math.random() * bs,
806
- type: mode, tokens
807
  });
808
  }
809
 
@@ -811,29 +882,41 @@
811
  const dt = Math.min((now - lastTime) / 1000, 0.1); lastTime = now;
812
  const W = getW(), H = getH();
813
 
 
 
 
 
 
 
814
  ctx.clearRect(0, 0, W, H);
815
  ctx.fillStyle = themeTokens.canvasBg; ctx.fillRect(0, 0, W, H);
816
 
817
  const spawnX = W * 0.34;
818
  ctx.strokeStyle = themeTokens.canvasGrid; ctx.lineWidth = 1; ctx.setLineDash([4, 8]);
819
- for (let y = 60; y < H - 30; y += 40) { ctx.beginPath(); ctx.moveTo(spawnX, y); ctx.lineTo(W - 20, y); ctx.stroke(); }
 
 
820
  ctx.setLineDash([]);
821
 
822
- const pps = getPps(), mode = getVisualMode();
823
  const pagesPerShelf = PAGES_PER_BOOK * BOOKS_PER_SHELF;
824
  const spawnRate = mode === 'shelf' ? Math.min(pps / pagesPerShelf, 60) : mode === 'book' ? Math.min(pps / PAGES_PER_BOOK, 120) : Math.min(pps, 400);
825
  spawnAccum += spawnRate * dt;
826
- while (spawnAccum >= 1) { spawnItem(); spawnAccum -= 1; }
827
-
828
- for (let i = floatingItems.length - 1; i >= 0; i--) {
829
- floatingItems[i].x += floatingItems[i].speed;
830
- if (floatingItems[i].x > W + 40) {
831
- totalTokens += floatingItems[i].tokens;
832
- floatingItems.splice(i, 1);
 
 
833
  continue;
834
  }
835
- drawItem(floatingItems[i]);
 
836
  }
 
837
 
838
  drawHardware();
839
 
 
78
  background-repeat: no-repeat;
79
  cursor: pointer;
80
  }
81
+ .canvas-row { display: flex; gap: 8px; align-items: stretch; position: relative; z-index: 1; }
82
  .canvas-row .side-panel { display: flex; flex-direction: column; gap: 6px; justify-content: center; }
83
  .canvas-wrap {
84
  position: relative;
 
95
  border: 0;
96
  background: transparent;
97
  }
98
+ .slider-area { position: relative; width: 100%; margin-bottom: 8px; z-index: 9999; }
99
  .slider-area input[type=range] { position: relative; z-index: 3; width: 100%; margin: 0; display: block; -webkit-appearance: none; appearance: none; height: 8px; border-radius: 4px; outline: none; cursor: pointer; }
100
  .slider-area input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--bg); border: 2px solid var(--brand); cursor: pointer; margin-top: -5px; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
101
  .slider-area input[type=range]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: var(--bg); border: 2px solid var(--brand); cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
102
 
103
+ .landmark-row { position: relative; width: 100%; height: 16px; }
104
+ .landmark { position: absolute; transform: translateX(-50%); cursor: pointer; display: flex; flex-direction: column; align-items: center; }
105
  .landmark-row.top .landmark { bottom: 0; }
106
  .landmark-row.bottom .landmark { top: 0; }
107
  .landmark .tick { width: 2px; height: 5px; flex-shrink: 0; }
 
112
  .landmark-row.top .name { color: var(--training); }
113
  .landmark-row.bottom .tick { background: var(--brand); }
114
  .landmark-row.bottom .name { color: var(--brand); }
115
+ #row-a1 .landmark, #row-b1 .landmark { z-index: 2; }
116
+ #row-a2 .landmark, #row-b2 .landmark { z-index: 1; }
117
  #row-a2 .landmark .tick,
118
  #row-b2 .landmark .tick { position: relative; }
119
  #row-a2 .landmark .tick::after {
 
142
  }
143
 
144
  .landmark .tooltip { display: none; position: absolute; left: 50%; transform: translateX(-50%); background: var(--tooltip-bg); color: var(--tooltip-text); font-size: 12px; padding: 10px 14px; border-radius: 8px; width: 300px; white-space: normal; z-index: 2147483647; pointer-events: none; line-height: 1.45; text-align: left; box-shadow: 0 12px 30px rgba(0,0,0,0.35); }
145
+ .landmark-row.top .tooltip { top: calc(100% + 4px); }
146
  .landmark-row.bottom .tooltip { top: calc(100% + 4px); }
147
  .landmark .tooltip::after { content: ''; position: absolute; left: 50%; transform: translateX(-50%); border: 5px solid transparent; }
148
  .landmark .tooltip.tip-right::after { left: auto; right: 16px; transform: none; }
149
  .landmark .tooltip.tip-left::after { left: 16px; transform: none; }
150
+ .landmark-row.top .tooltip::after { bottom: 100%; border-bottom-color: var(--tooltip-bg); }
151
  .landmark-row.bottom .tooltip::after { bottom: 100%; border-bottom-color: var(--tooltip-bg); }
152
+ .landmark-row:has(.landmark:hover) { z-index: 99999; position: relative; }
153
  .landmark:hover .tooltip { display: block; }
154
 
155
  .throughput-strip {
156
+ position: absolute;
157
+ bottom: 4px;
158
+ left: 10px;
159
  width: calc(32% - 10px);
160
+ min-width: 160px;
 
161
  padding: 0;
162
  text-align: center;
163
+ font-size: 11px;
164
  color: #1f2937;
165
  font-weight: 700;
166
  font-variant-numeric: tabular-nums;
 
174
  .output-panel {
175
  position: absolute;
176
  right: 10px;
177
+ top: calc(50%);
178
  transform: translateY(-50%);
179
  z-index: 4;
180
  pointer-events: none;
181
  display: flex;
182
  flex-direction: column;
183
+ gap: 2px;
184
+ padding: 7px 8px;
185
+ border-radius: 8px;
186
  border: 1px solid var(--border);
187
  background: color-mix(in srgb, var(--surface) 88%, transparent);
188
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
189
  }
190
  .output-heading {
191
  text-align: left;
192
+ font-size: 10px;
193
  color: var(--text-muted);
194
  text-transform: uppercase;
195
  letter-spacing: 0.6px;
196
  font-weight: 700;
197
+ margin-bottom: 1px;
198
  }
199
  .output-stats {
200
  display: grid;
 
209
  }
210
  .output-num {
211
  text-align: right;
212
+ font-size: 32px;
213
  line-height: 1;
214
  font-weight: 700;
215
  color: var(--brand);
 
226
  letter-spacing: 0.2px;
227
  }
228
  .output-unit-emoji {
229
+ font-size: 17px;
230
  line-height: 1;
231
  }
232
 
233
+ .datasets { min-width: 180px; display: flex; flex-direction: column; justify-content: center; }
234
+ .datasets-title { font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 6px; }
235
+ .dataset-bars { display: flex; flex-direction: column; gap: 4px; }
236
+ .dataset-item { display: flex; align-items: center; gap: 5px; font-size: 12px; position: relative; cursor: pointer; }
237
  .dataset-item .ds-check { font-size: 15px; width: 18px; text-align: center; }
238
  .dataset-item .ds-name { font-weight: 600; color: var(--text-strong); }
239
  .dataset-item .ds-time { color: var(--brand); font-weight: 700; font-variant-numeric: tabular-nums; }
240
  .dataset-item .ds-size { color: var(--text-faint); font-size: 11px; }
241
  .dataset-item.done .ds-name { color: var(--success); }
242
  .dataset-item.done .ds-time { color: var(--success); }
243
+ .dataset-item .ds-tip { display: none; position: absolute; top: calc(100% + 6px); left: 0; background: var(--tooltip-bg); color: var(--tooltip-text); font-size: 12px; padding: 10px 14px; border-radius: 8px; width: 260px; white-space: normal; z-index: 2147483647; pointer-events: none; line-height: 1.45; text-align: left; box-shadow: 0 12px 30px rgba(0,0,0,0.35); }
244
+ .dataset-item .ds-tip::after { content: ''; position: absolute; bottom: 100%; left: 20px; border: 5px solid transparent; border-bottom-color: var(--tooltip-bg); }
245
+ .dataset-item:hover { z-index: 99999; }
246
  .dataset-item:hover .ds-tip { display: block; }
247
 
248
  @media (max-width: 980px) {
 
252
  .throughput-strip {
253
  width: auto;
254
  min-width: 0;
255
+ left: 8px;
256
+ font-size: 10px;
257
  text-align: center;
258
  }
259
  .output-panel {
 
268
  .output-unit { font-size: 11px; min-width: 3ch; }
269
  .output-unit-emoji { font-size: 16px; }
270
  .output-heading { font-size: 10px; }
271
+ .datasets { max-width: 100%; min-width: 0; }
272
+ .dataset-bars { flex-direction: row; flex-wrap: wrap; gap: 4px 12px; }
273
+ .dataset-item { font-size: 12px; }
274
  }
275
  </style>
276
  </head>
 
289
  <div class="control-group">
290
  <label>Model</label>
291
  <select id="model">
292
+ <option value="45540">SmolLM2-135M</option>
293
+ <option value="8086">Qwen3-4B</option>
294
+ <option value="6443">Qwen3-8B</option>
295
+ <option value="6117">GPT-OSS-120B</option>
296
+ <option value="1724">Gemma-3-27B</option>
297
  </select>
298
  </div>
299
  </div>
300
  <div class="canvas-wrap">
301
  <div class="canvas-stage">
302
  <canvas id="c"></canvas>
303
+ </div>
304
+ <div class="output-panel">
305
+ <div class="output-heading">Generated</div>
306
+ <div class="output-stats">
307
+ <div class="output-line">
308
+ <span class="output-num" id="totalTokensNum">0</span>
309
+ <span class="output-unit output-unit-text">toks</span>
310
+ </div>
311
+ <div class="output-line">
312
+ <span class="output-num" id="totalItemNum">0</span>
313
+ <span class="output-unit output-unit-emoji" id="totalItemUnit">📄</span>
314
  </div>
315
  </div>
316
  </div>
 
319
  <span class="tps" id="tpsInline">(0 TPS)</span>
320
  </div>
321
  </div>
322
+ <div class="datasets">
323
+ <div class="datasets-title">Time to generate dataset<br>at current throughput</div>
324
+ <div class="dataset-bars" id="datasetBars"></div>
325
+ </div>
 
326
  </div>
327
  </div>
328
 
329
  <script>
330
  const canvas = document.getElementById('c');
331
+ let ctx = canvas.getContext('2d');
332
 
333
  function resizeCanvas() {
334
  const w = canvas.clientWidth;
 
354
  const DATASETS = [
355
  { name: 'BookCorpus', tokens: 1e9,
356
  desc: '<b>BookCorpus</b> (2015)<br>11K unpublished books scraped from smashwords.com. Used to train the original BERT and GPT-1.' },
357
+ { name: 'Wikipedia', tokens: 6e9,
358
+ desc: '<b>Multilingual Wikipedia</b><br>All articles across all 300+ language editions. ~4.7B words, ~6B tokens. A staple ingredient in virtually every LLM pretraining mix.' },
 
 
359
  { name: 'FinePhrase', tokens: 1e12,
360
  desc: '<b>FinePhrase</b> (Hugging Face, 2026)<br>1T tokens of LLM-rephrased web text. Synthetic data that teaches small models to punch above their weight.' },
 
 
361
  { name: 'RedPajama', tokens: 100e12,
362
  desc: '<b>RedPajama v2</b> (Together AI, 2023)<br>100T raw tokens from 84 Common Crawl snapshots with quality signals. Covers 5 languages.' },
363
  { name: 'Common Crawl', tokens: 3e15,
 
508
  const dsEls = DATASETS.map(ds => {
509
  const el = document.createElement('div');
510
  el.className = 'dataset-item';
511
+
512
+ el.innerHTML = '<span class="ds-check">\u23f3</span><span class="ds-name">' + ds.name + '</span><span class="ds-time">-</span><span class="ds-size">(' + formatNumInt(ds.tokens) + ' toks)</span><div class="ds-tip">' + ds.desc + '</div>';
513
  datasetBarsEl.appendChild(el);
514
  return el;
515
  });
 
561
  function getW() { return canvas.width; }
562
  function getH() { return canvas.height; }
563
 
564
+ // Per-frame cached state to avoid recomputing in nested draw calls
565
+ let _fNow = 0, _fPps = 0, _fFanPhase = 0, _fBlinkSpeed = 0;
566
+
567
  function getHardwareLevel(gpus) {
568
  if (gpus < GPUS_PER_NODE) return 'gpu';
569
  if (gpus < GPUS_PER_RACK) return 'node';
 
571
  return 'cluster';
572
  }
573
 
574
+ // Offscreen buffer for hardware rendering
575
+ const hwCanvas = document.createElement('canvas');
576
+ const hwCtx = hwCanvas.getContext('2d');
577
+ let hwDirty = true, hwLastGpus = -1, hwLastW = 0, hwLastH = 0;
578
+ gpuSlider.addEventListener('input', () => { hwDirty = true; });
579
+ modelSelect.addEventListener('change', () => { hwDirty = true; });
580
+
581
  function drawHardware() {
582
  const W = getW(), H = getH();
583
  const gpus = getGpuCount();
584
+
585
+ // Only re-render hardware if config or size changed, or animation needs update
586
+ const needsAnim = _fPps > 0;
587
+ if (hwCanvas.width !== W * 0.34 || hwCanvas.height !== H) {
588
+ hwCanvas.width = Math.ceil(W * 0.34);
589
+ hwCanvas.height = H;
590
+ hwDirty = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  }
592
+ // Animated hardware (fans/LEDs) redraws every frame when running
593
+ if (gpus !== hwLastGpus || hwDirty || needsAnim) {
594
+ hwLastGpus = gpus; hwDirty = false;
595
+ const prevCtx = ctx;
596
+ ctx = hwCtx;
597
+ ctx.clearRect(0, 0, hwCanvas.width, hwCanvas.height);
598
+
599
+ const pad = 10;
600
+ const aw = hwCanvas.width - 2 * pad, ax = pad;
601
+ const level = getHardwareLevel(gpus);
602
+
603
+ // GPU count label pinned to top
604
+ ctx.fillStyle = themeTokens.brand;
605
+ ctx.font = 'bold 22px system-ui, sans-serif';
606
+ ctx.textAlign = 'center'; ctx.textBaseline = 'top';
607
+ ctx.fillText(formatGpuLabel(gpus), ax + aw / 2, 6);
608
+
609
+ // Hardware illustration centered vertically in remaining space (shifted down)
610
+ const labelBottom = 32;
611
+ const ah = hwCanvas.height * 0.65;
612
+ const ay = labelBottom + (hwCanvas.height - labelBottom - ah) / 2 + hwCanvas.height * 0.04;
613
+ ctx.textAlign = 'start'; ctx.textBaseline = 'alphabetic';
614
+
615
+ if (level === 'gpu') drawGpuGrid(gpus, ax, ay, aw, ah);
616
+ else if (level === 'node') drawNodeGrid(Math.max(1, Math.round(gpus / GPUS_PER_NODE)), ax, ay, aw, ah);
617
+ else if (level === 'rack') drawRackGrid(Math.max(1, Math.round(gpus / GPUS_PER_RACK)), ax, ay, aw, ah);
618
+ else drawPodGrid(Math.round(gpus / GPUS_PER_SUPERPOD), ax, ay, aw, ah);
619
+
620
+ ctx = prevCtx;
621
+ }
622
+ ctx.drawImage(hwCanvas, 0, 0);
623
  }
624
 
625
  function drawGpuGrid(count, ax, ay, aw, ah) {
 
633
  const gh = gw / 0.6;
634
  const offX = ax + (aw - (cols * gw + (cols - 1) * gap)) / 2;
635
  const offY = ay + (ah - (rows * gh + (rows - 1) * gap)) / 2;
636
+ const fanSpeed = _fFanPhase;
637
  for (let i = 0; i < count; i++) {
638
  const c = i % cols, r = Math.floor(i / cols);
639
  drawSingleGpu(offX + c * (gw + gap), offY + r * (gh + gap), gw, gh, fanSpeed + i * 0.5);
 
689
  ctx.beginPath(); ctx.roundRect(nx, ny, nw, nh, 4); ctx.stroke();
690
  const slotW = (nw - 20) / 8, slotH = nh * 0.6;
691
  const slotY = ny + (nh - slotH) / 2;
 
692
  for (let i = 0; i < 8; i++) {
693
  const sx = nx + 10 + i * slotW;
694
+ drawSingleGpu(sx + 1, slotY, slotW - 2, slotH, _fFanPhase + i * 0.5);
695
  }
696
+ const ledAlpha = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(_fNow / (300 - _fBlinkSpeed * 1.2)));
697
+ ctx.globalAlpha = ledAlpha;
698
  ctx.fillStyle = '#4ade80';
699
  ctx.beginPath(); ctx.arc(nx + 8, ny + 8, 3, 0, Math.PI * 2); ctx.fill();
700
+ ctx.globalAlpha = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(_fNow / (400 - _fBlinkSpeed) + 1.5));
701
  ctx.fillStyle = '#60a5fa';
702
  ctx.beginPath(); ctx.arc(nx + 18, ny + 8, 3, 0, Math.PI * 2); ctx.fill();
703
+ ctx.globalAlpha = 1;
704
  }
705
 
706
  // Grid of racks
 
726
  const nodeCount = 4, pad = 4, gap = 3;
727
  const nodeH = (rh - 2 * pad - (nodeCount - 1) * gap) / nodeCount;
728
  const nodeW = rw - 2 * pad;
 
729
  for (let n = 0; n < nodeCount; n++) {
730
  const ny = ry + pad + n * (nodeH + gap), nx = rx + pad;
731
  ctx.fillStyle = '#1a1a2e';
 
740
  const fr = Math.min((slotW - 1) * 0.35, (nodeH - 4) * 0.25);
741
  if (fr > 1) {
742
  ctx.beginPath(); ctx.arc(fcx, fcy, fr, 0, Math.PI * 2); ctx.fillStyle = '#444'; ctx.fill();
743
+ ctx.save(); ctx.translate(fcx, fcy); ctx.rotate(_fFanPhase + g * 0.3 + n);
744
  for (let b = 0; b < 4; b++) { ctx.rotate(Math.PI / 2); ctx.beginPath(); ctx.moveTo(0, 0);
745
  ctx.lineTo(fr * 0.8, fr * 0.3); ctx.lineTo(fr * 0.8, -fr * 0.3); ctx.fillStyle = '#666'; ctx.fill(); }
746
  ctx.restore();
747
  }
748
  }
749
+ const rackLedAlpha = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(_fNow / (350 - _fBlinkSpeed) + n * 1.2));
750
+ ctx.globalAlpha = rackLedAlpha;
751
  ctx.fillStyle = '#4ade80';
752
  ctx.beginPath(); ctx.arc(nx + nodeW - 6, ny + nodeH / 2, Math.min(2, nodeH * 0.15), 0, Math.PI * 2); ctx.fill();
753
+ ctx.globalAlpha = 1;
754
  }
755
  }
756
 
 
789
  ctx.beginPath(); ctx.roundRect(x, y, nw, nh, r); ctx.stroke();
790
  if (nw < 4) return;
791
  const lr = Math.max(0.8, Math.min(2, nw * 0.06)), ly = y + nh * 0.2;
792
+ // Skip per-unit LED animation for large grids (> 64 pods) to save draw calls
793
+ if (total > 64) {
794
+ ctx.fillStyle = '#4ade80'; ctx.beginPath(); ctx.arc(x + nw * 0.2, ly, lr, 0, Math.PI * 2); ctx.fill();
795
+ } else {
796
+ const podHash = (x * 31 + y * 17) & 0xffff;
797
+ ctx.globalAlpha = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(_fNow / (400 - _fBlinkSpeed) + podHash));
798
+ ctx.fillStyle = '#4ade80'; ctx.beginPath(); ctx.arc(x + nw * 0.2, ly, lr, 0, Math.PI * 2); ctx.fill();
799
+ if (nw >= 8) {
800
+ ctx.globalAlpha = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(_fNow / (350 - _fBlinkSpeed) + podHash + 2));
801
+ ctx.fillStyle = '#60a5fa'; ctx.beginPath(); ctx.arc(x + nw * 0.4, ly, lr, 0, Math.PI * 2); ctx.fill();
802
+ }
803
+ if (nw >= 6) {
804
+ ctx.globalAlpha = 0.3 + 0.7 * (0.5 + 0.5 * Math.sin(_fNow / (300 - _fBlinkSpeed) + podHash + 4));
805
+ ctx.fillStyle = '#c4a020'; ctx.beginPath(); ctx.arc(x + nw * 0.6, ly, lr, 0, Math.PI * 2); ctx.fill();
806
+ }
807
+ ctx.globalAlpha = 1;
808
+ }
809
  if (nw >= 10) {
810
  const vy = y + nh * 0.45, vh = nh * 0.4, lc = Math.min(6, Math.floor(nw / 4));
811
  ctx.strokeStyle = '#333'; ctx.lineWidth = 0.5;
 
822
 
823
  const EMOJI = { page: '\u{1F4C4}', book: '\u{1F4D6}', shelf: '\u{1F4DA}' };
824
  const EMOJI_SIZE = { page: 28, book: 34, shelf: 40 };
825
+ const MAX_FLOATING = 150;
826
+
827
+ // Pre-render emojis at multiple scales to offscreen canvases
828
+ const emojiCache = {};
829
+ function getEmojiCanvas(type, scale) {
830
+ const sz = Math.round(EMOJI_SIZE[type] * scale);
831
+ const key = type + '_' + sz;
832
+ if (emojiCache[key]) return emojiCache[key];
833
+ const off = document.createElement('canvas');
834
+ off.width = sz + 4; off.height = sz + 4;
835
+ const octx = off.getContext('2d');
836
+ octx.font = sz + 'px system-ui, sans-serif';
837
+ octx.textBaseline = 'top';
838
+ octx.fillText(EMOJI[type], 2, 2);
839
+ emojiCache[key] = off;
840
+ return off;
841
+ }
842
+
843
+ function drawItem(p, now, W) {
844
+ const fadeStart = W * 0.9;
845
  const alpha = p.x > fadeStart ? 1 - (p.x - fadeStart) / (W - fadeStart) : 1;
846
+ const off = getEmojiCanvas(p.type, p.scale);
847
+ const bobY = p.y + Math.sin(now / 600 + p.bobPhase) * p.bobAmp;
848
+ const angle = Math.sin(now / 400 + p.wobblePhase) * p.wobbleAmp;
849
  ctx.globalAlpha = alpha;
850
+ ctx.save();
851
+ ctx.translate(p.x + off.width / 2, bobY + off.height / 2);
852
+ ctx.rotate(angle);
853
+ ctx.drawImage(off, -off.width / 2, -off.height / 2);
854
+ ctx.restore();
855
  ctx.globalAlpha = 1;
856
  }
857
 
858
  function spawnItem() {
859
  const H = getH(), W = getW();
860
  const spawnX = W * 0.34;
861
+ const margin = H * 0.12;
862
+ const yMin = margin + H * 0.04, yMax = H - margin + H * 0.04, y = yMin + Math.random() * (yMax - yMin);
863
  const mode = getVisualMode();
864
  const pps = getPps();
865
  const pagesPerShelf = PAGES_PER_BOOK * BOOKS_PER_SHELF;
866
  const rate = mode === 'shelf' ? pps / pagesPerShelf : mode === 'book' ? pps / PAGES_PER_BOOK : pps;
867
  const bs = 0.8 + Math.log10(Math.max(1, rate)) * 0.8;
868
  const tokens = mode === 'shelf' ? TOKENS_PER_SHELF : mode === 'book' ? TOKENS_PER_BOOK : TOKENS_PER_PAGE;
869
+ const scale = 0.85 + Math.random() * 0.3;
870
+ const bobAmp = 3 + Math.random() * 5;
871
+ const bobPhase = Math.random() * Math.PI * 2;
872
+ const wobbleAmp = mode === 'page' ? 0.08 + Math.random() * 0.12 : 0.04 + Math.random() * 0.06;
873
+ const wobblePhase = Math.random() * Math.PI * 2;
874
+ const depthSpeed = (bs + Math.random() * bs) * (0.7 + scale * 0.6);
875
  floatingItems.push({
876
+ x: spawnX + Math.random() * 20, y, speed: depthSpeed,
877
+ type: mode, tokens, scale, bobAmp, bobPhase, wobbleAmp, wobblePhase
878
  });
879
  }
880
 
 
882
  const dt = Math.min((now - lastTime) / 1000, 0.1); lastTime = now;
883
  const W = getW(), H = getH();
884
 
885
+ // Cache per-frame values for nested draw calls
886
+ _fNow = now;
887
+ _fPps = getPps();
888
+ _fFanPhase = (now / 200) * Math.min(_fPps, 100);
889
+ _fBlinkSpeed = Math.min(_fPps, 200);
890
+
891
  ctx.clearRect(0, 0, W, H);
892
  ctx.fillStyle = themeTokens.canvasBg; ctx.fillRect(0, 0, W, H);
893
 
894
  const spawnX = W * 0.34;
895
  ctx.strokeStyle = themeTokens.canvasGrid; ctx.lineWidth = 1; ctx.setLineDash([4, 8]);
896
+ const gridMargin = H * 0.12;
897
+ const gridOffset = H * 0.04;
898
+ for (let y = gridMargin + gridOffset; y < H - gridMargin + gridOffset; y += 40) { ctx.beginPath(); ctx.moveTo(spawnX, y); ctx.lineTo(W - 20, y); ctx.stroke(); }
899
  ctx.setLineDash([]);
900
 
901
+ const pps = _fPps, mode = getVisualMode();
902
  const pagesPerShelf = PAGES_PER_BOOK * BOOKS_PER_SHELF;
903
  const spawnRate = mode === 'shelf' ? Math.min(pps / pagesPerShelf, 60) : mode === 'book' ? Math.min(pps / PAGES_PER_BOOK, 120) : Math.min(pps, 400);
904
  spawnAccum += spawnRate * dt;
905
+ while (spawnAccum >= 1 && floatingItems.length < MAX_FLOATING) { spawnItem(); spawnAccum -= 1; }
906
+ if (floatingItems.length >= MAX_FLOATING) spawnAccum = 0;
907
+
908
+ let writeIdx = 0;
909
+ for (let i = 0; i < floatingItems.length; i++) {
910
+ const p = floatingItems[i];
911
+ p.x += p.speed;
912
+ if (p.x > W + 40) {
913
+ totalTokens += p.tokens;
914
  continue;
915
  }
916
+ drawItem(p, now, W);
917
+ floatingItems[writeIdx++] = p;
918
  }
919
+ floatingItems.length = writeIdx;
920
 
921
  drawHardware();
922