Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
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.
|
| 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;
|
| 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,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 {
|
| 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 {
|
| 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:
|
| 157 |
-
margin: 6px 0 10px 10px;
|
| 158 |
padding: 0;
|
| 159 |
text-align: center;
|
| 160 |
-
font-size:
|
| 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:
|
| 181 |
-
padding:
|
| 182 |
-
border-radius:
|
| 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:
|
| 190 |
color: var(--text-muted);
|
| 191 |
text-transform: uppercase;
|
| 192 |
letter-spacing: 0.6px;
|
| 193 |
font-weight: 700;
|
| 194 |
-
margin-bottom:
|
| 195 |
}
|
| 196 |
.output-stats {
|
| 197 |
display: grid;
|
|
@@ -206,7 +209,7 @@
|
|
| 206 |
}
|
| 207 |
.output-num {
|
| 208 |
text-align: right;
|
| 209 |
-
font-size:
|
| 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:
|
| 227 |
line-height: 1;
|
| 228 |
}
|
| 229 |
|
| 230 |
-
.datasets {
|
| 231 |
-
.datasets-title { font-size:
|
| 232 |
-
.dataset-bars { display: flex; flex-
|
| 233 |
-
.dataset-item { display: flex; align-items: center; gap: 5px; font-size:
|
| 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;
|
| 241 |
-
.dataset-item .ds-tip::after { content: ''; position: absolute;
|
|
|
|
| 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 |
-
|
| 252 |
-
font-size:
|
| 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 |
-
.
|
|
|
|
|
|
|
| 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
|
| 287 |
-
<option value="8086">Qwen3-4B
|
| 288 |
-
<option value="6443">Qwen3-8B
|
| 289 |
-
<option value="6117">GPT-OSS-120B
|
| 290 |
-
<option value="1724">Gemma-3-27B
|
| 291 |
</select>
|
| 292 |
</div>
|
| 293 |
</div>
|
| 294 |
<div class="canvas-wrap">
|
| 295 |
<div class="canvas-stage">
|
| 296 |
<canvas id="c"></canvas>
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
</
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
</
|
| 308 |
</div>
|
| 309 |
</div>
|
| 310 |
</div>
|
|
@@ -313,17 +319,16 @@
|
|
| 313 |
<span class="tps" id="tpsInline">(0 TPS)</span>
|
| 314 |
</div>
|
| 315 |
</div>
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
<
|
| 320 |
-
<div class="dataset-bars" id="datasetBars"></div>
|
| 321 |
</div>
|
| 322 |
</div>
|
| 323 |
|
| 324 |
<script>
|
| 325 |
const canvas = document.getElementById('c');
|
| 326 |
-
|
| 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:
|
| 353 |
-
desc: '<b>
|
| 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 |
-
|
| 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) + '
|
| 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 |
-
//
|
|
|
|
|
|
|
| 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 |
-
|
| 575 |
-
|
| 576 |
-
const
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 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 =
|
| 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,
|
| 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(
|
| 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 |
-
|
| 763 |
-
if (
|
| 764 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 783 |
-
|
| 784 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
const alpha = p.x > fadeStart ? 1 - (p.x - fadeStart) / (W - fadeStart) : 1;
|
|
|
|
|
|
|
|
|
|
| 786 |
ctx.globalAlpha = alpha;
|
| 787 |
-
|
| 788 |
-
ctx.
|
| 789 |
-
ctx.
|
| 790 |
-
ctx.
|
|
|
|
| 791 |
ctx.globalAlpha = 1;
|
| 792 |
}
|
| 793 |
|
| 794 |
function spawnItem() {
|
| 795 |
const H = getH(), W = getW();
|
| 796 |
const spawnX = W * 0.34;
|
| 797 |
-
const
|
|
|
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
| 820 |
ctx.setLineDash([]);
|
| 821 |
|
| 822 |
-
const pps =
|
| 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 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
|
|
|
|
|
|
| 833 |
continue;
|
| 834 |
}
|
| 835 |
-
drawItem(
|
|
|
|
| 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 |
|