File size: 13,471 Bytes
5ae37d4
 
54513db
9b65d3b
5ae37d4
 
 
 
 
 
 
 
 
 
54513db
 
5ae37d4
 
9b65d3b
 
54513db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ae37d4
 
 
 
 
 
 
9b65d3b
 
 
 
 
 
5ae37d4
 
 
 
 
 
9b65d3b
 
5ae37d4
 
 
 
 
 
 
 
54513db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b65d3b
54513db
 
 
 
 
 
5ae37d4
 
 
 
 
 
54513db
5ae37d4
 
 
 
 
 
 
 
 
 
 
 
54513db
5ae37d4
 
 
 
 
 
 
 
 
9b65d3b
 
 
 
 
 
5ae37d4
 
 
 
 
 
 
 
 
 
 
9b65d3b
 
 
 
 
 
5ae37d4
 
 
9b65d3b
5ae37d4
9b65d3b
 
 
 
 
 
 
 
5ae37d4
 
9b65d3b
5ae37d4
 
 
 
9b65d3b
 
5ae37d4
 
 
 
9b65d3b
 
 
 
5ae37d4
 
 
9b65d3b
 
 
 
 
 
 
 
 
 
 
 
 
5ae37d4
9b65d3b
5ae37d4
 
 
 
 
 
9b65d3b
 
 
5ae37d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b65d3b
5ae37d4
 
 
 
 
 
 
 
9b65d3b
5ae37d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b65d3b
 
 
 
 
 
 
 
 
 
3a22edb
9b65d3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ae37d4
 
 
 
 
 
 
 
 
 
 
 
 
9b65d3b
 
 
 
 
 
 
 
 
 
 
5ae37d4
 
9b65d3b
 
 
 
 
 
 
54513db
9b65d3b
 
 
 
 
 
 
 
5ae37d4
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
"use client";

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";

import {
  Model,
  Gpu,
  PRECISIONS,
  PRECISION_ACCENT,
  overheadGB,
  round1,
  weightsGB,
} from "@/lib/data";

/** Width before the container has been measured. */
const DEFAULT_W = 1180;
const BAR_H = 55;
const R = 5.5;
/** Pointer band around each tick, in viewBox units. */
const HIT_W = 44;
/** MethodReceipt's fixed width. */
const CARD_W = 466;

/**
 * Where the hover card's left edge goes, in px from the chart's left edge, so
 * that it reads as hanging from the tick at `anchorX` without running off a
 * chart `width` px wide. The chart is as narrow as the docs iframe (~730px),
 * so a card can fail to fit on either side of a tick in the middle.
 */
function cardLeft(anchorX: number, width: number, cardW: number): number {
  // Hang from the tick: rightward if the card fits, else leftward.
  const hung = anchorX + cardW <= width ? anchorX : anchorX - cardW;
  // Neither side fits, so slide it into frame. When the chart is narrower than
  // the card, the left edge wins: overflow to the right can still be scrolled.
  return Math.max(0, Math.min(hung, width - cardW));
}

interface Props {
  /** null until a model resolves — the bar renders empty, but to scale */
  model: Model | null;
  gpu: Gpu;
  contextTokens: number;
  selectedBits: number;
  /** fires with the precision under the cursor, or null on leave */
  onHoverBits?: (bits: number | null) => void;
  /** which tick is under the cursor, so the bar can anchor the card to it */
  hoverBits?: number | null;
  /** rendered under the hovered tick, positioned by the bar */
  hoverCard?: React.ReactNode;
}

interface Tick {
  bits: number;
  total: number;
  x: number;
  /** which label row this tick's caption sits on, 0 = closest to the bar */
  row: number;
}

/** Round a scale ceiling up to something a human would pick. */
function niceCeil(v: number): number {
  const step = v > 400 ? 100 : v > 200 ? 50 : v > 60 ? 10 : 5;
  return Math.ceil(v / step) * step;
}

/**
 * The chart draws in real pixels: the viewBox is as wide as the box it sits
 * in. A fixed viewBox would scale the 11px captions down with the page, and in
 * the docs iframe they shrink to about 6px.
 */
function useWidth() {
  const ref = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(DEFAULT_W);
  // Layout effect, so the first paint is already at the measured width.
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    setWidth(Math.max(1, Math.round(el.clientWidth)));
    const ro = new ResizeObserver(([entry]) => {
      setWidth(Math.max(1, Math.round(entry.contentRect.width)));
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return [ref, width] as const;
}

export default function MemoryBar(props: Props) {
  const [ref, W] = useWidth();
  return (
    <div ref={ref}>
      {props.model ? <FilledBar {...props} model={props.model} W={W} /> : <EmptyBar W={W} />}
    </div>
  );
}

/**
 * The card, waiting. Just the empty track, holding the same space the filled
 * bar will take, so nothing moves when a model arrives.
 */
function EmptyBar({ W }: { W: number }) {
  const barTop = 84;
  const barBottom = barTop + BAR_H;

  return (
    <svg viewBox={`0 0 ${W} ${barBottom + 66}`} className="h-auto w-full" role="img"
      aria-label="No model chosen yet">
      <rect x="0.5" y={barTop + 0.5} width={W - 1} height={BAR_H} rx={R} fill="#ffffff"
        stroke="#ebebeb" />
    </svg>
  );
}

function FilledBar({ model, gpu, contextTokens, selectedBits, onHoverBits, hoverBits, hoverCard, W }: Props & { model: Model; W: number }) {
  const overhead = overheadGB(model, contextTokens);
  const vram = gpu.vram;

  // The solid bar is the precision you picked. fp16 trails behind it as a
  // ghost, so you can see what quantizing bought you.
  const selWeights = weightsGB(model, selectedBits);
  const selTotal = selWeights + overhead;
  const ghostTotal = weightsGB(model, 16) + overhead;

  // The scale follows the model, not the card: a 2B model on an 80GB card would
  // otherwise crowd all four precisions into a few pixels. It spans the widest
  // precision on offer, so every tick stays readable. fp16 and — when the model
  // fits comfortably — the capacity mark are free to run off the end.
  const widestTick = Math.max(...PRECISIONS.map((b) => weightsGB(model, b) + overhead));
  const scaleMax = niceCeil(Math.max(selTotal, widestTick) * 1.02);
  const x = (gb: number) => (gb / scaleMax) * W;

  const overflows = selTotal > vram;
  // When it overflows, the card is full: weights fill whatever the reservation leaves.
  const weightsThatFit = Math.max(0, vram - overhead);
  const kvStart = overflows ? x(weightsThatFit) : x(selWeights);
  const kvEnd = overflows ? x(vram) : x(selTotal);
  const barEnd = x(selTotal);
  const ghostEnd = x(ghostTotal);
  const ghostClipped = ghostEnd > W;
  const ghostRight = Math.min(ghostEnd, W - 0.5);
  // The track's corners are rounded, so a clipped ghost must stop before the
  // curve begins or its horizontals cut across the corner.
  const ghostRunEnd = ghostClipped ? W - R - 0.5 : ghostRight;
  // The card only needs showing while it is in play.
  const capX = x(vram);
  const capOff = capX > W;
  // The callout needs ~215px; right-align it on the block when it would run off.
  const kvLabelFlips = kvStart + 215 > W;
  // Two totals this close would overprint.
  const showGhostLabel = ghostRunEnd - barEnd >= 64;

  // Precision labels are ~56px wide; anything closer than that overprints. Each
  // tick takes the first row with room, so a small model — whose four
  // precisions can land within a few pixels of each other — fans out upward
  // instead of stacking into one illegible blur. The lines stay at their true
  // x: only the captions move.
  const MIN_GAP = 56;
  const ROW_H = 42;
  const lastX: number[] = [];
  const ticks: Tick[] = PRECISIONS.map((bits) => {
    const total = weightsGB(model, bits) + overhead;
    return { bits, total, x: x(total), row: 0 };
  })
    .filter((t) => t.total <= scaleMax)
    .sort((a, b) => a.x - b.x)
    .map((t) => {
      let row = 0;
      while (lastX[row] !== undefined && t.x - lastX[row] < MIN_GAP) row++;
      lastX[row] = t.x;
      return { ...t, row };
    });

  // Row 0 is the topmost caption and has the longest drop line; each extra row
  // sits 42px lower, so more rows push the bar down.
  const rows = Math.max(1, lastX.length);
  const barTop = Math.max(84, 40 + (rows - 1) * ROW_H + 18);
  const barBottom = barTop + BAR_H;
  const height = barBottom + 66;

  // The card hangs below the chart, outside this container's box, so pointing
  // at it would otherwise read as leaving. Closing waits a beat, and anything
  // the pointer lands on in the meantime cancels it.
  const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const cancelClose = useCallback(() => clearTimeout(closeTimer.current), []);
  const scheduleClose = useCallback(() => {
    clearTimeout(closeTimer.current);
    closeTimer.current = setTimeout(() => onHoverBits?.(null), 160);
  }, [onHoverBits]);
  useEffect(() => () => clearTimeout(closeTimer.current), []);

  const anchor = ticks.find((t) => t.bits === hoverBits);

  return (
    <div className="relative">
    <svg viewBox={`0 0 ${W} ${height}`} className="h-auto w-full" role="img"
      aria-label={`${model.label} at ${selectedBits}-bit needs ${round1(selTotal)} GB of the ${vram} GB on ${gpu.label}`}>
      {/* precision labels + drop lines above the bar */}
      {ticks.map((t) => {
        const accent = PRECISION_ACCENT[t.bits];
        const isSel = t.bits === selectedBits;
        const nameY = 11 + t.row * ROW_H;
        const valY = 29 + t.row * ROW_H;
        const lineTop = 40 + t.row * ROW_H;
        return (
          <g key={t.bits}>
            <text x={t.x} y={nameY} textAnchor="middle" fontFamily="var(--font-geist-mono)"
              fontSize="11" letterSpacing="0.79" fill={accent}>
              {t.bits}-BIT
            </text>
            <text x={t.x} y={valY} textAnchor="middle" fontFamily="var(--font-geist-mono)"
              fontSize="13" fontWeight={isSel ? 500 : 400} fill={isSel ? accent : "#171717"}>
              {round1(t.total)}
            </text>
            <line x1={t.x} y1={lineTop} x2={t.x} y2={barTop} stroke={accent}
              strokeWidth={isSel ? 2.5 : 1.75} strokeLinecap="round"
              strokeDasharray={isSel ? "0.01 4" : "0.01 3.5"}
              opacity={isSel ? 1 : 0.9} />
          </g>
        );
      })}

      {/* KV reservation callout — under the bar, anchored on the block it describes */}
      <text
        x={kvLabelFlips ? kvEnd : kvStart}
        y={barBottom + 52}
        textAnchor={kvLabelFlips ? "end" : "start"}
        fontFamily="var(--font-geist-mono)"
        fontSize="11"
        letterSpacing="0.79"
        fill="#8f8f8f"
      >
        <tspan fontSize="12" letterSpacing="0" fill="#666666">{round1(overhead)}</tspan>
        {"  KV CACHE + ACTIVATIONS"}
      </text>

      {/* track */}
      <rect x="0.5" y={barTop + 0.5} width={W - 1} height={BAR_H} rx={R} fill="#ffffff"
        stroke="#ebebeb" />

      {/* fp16, unquantized — the ghost the solid bar is measured against. It is
          left open on the right when it runs past the edge of the scale. */}
      <path
        d={
          ghostClipped
            ? `M${ghostRunEnd} ${barTop + 0.5} H${R} A${R} ${R} 0 0 0 0.5 ${barTop + R} V${barBottom - R} A${R} ${R} 0 0 0 ${R} ${barBottom + 0.5} H${ghostRunEnd}`
            : `M${ghostRight} ${barTop + 0.5} H${R} A${R} ${R} 0 0 0 0.5 ${barTop + R} V${barBottom - R} A${R} ${R} 0 0 0 ${R} ${barBottom + 0.5} H${ghostRight} Z`
        }
        fill="none"
        stroke="#d4d4d4"
        strokeWidth="1"
        strokeDasharray="2 3"
      />
      {showGhostLabel && (
        <text x={ghostRunEnd - 6} y={barTop - 8} textAnchor="end"
          fontFamily="var(--font-geist-mono)" fontSize="11" letterSpacing="0.79" fill="#a8a8a8">
          {round1(ghostTotal)} AT FP16{ghostClipped ? " ›" : ""}
        </text>
      )}

      {/* weights that fit — rounded on the left only */}
      <path
        d={`M${R} ${barTop} H${kvStart} V${barBottom} H${R} A${R} ${R} 0 0 1 0 ${barBottom - R} V${barTop + R} A${R} ${R} 0 0 1 ${R} ${barTop} Z`}
        fill="#171717"
      />
      {/* the fixed reservation */}
      <rect x={kvStart} y={barTop} width={Math.max(0, kvEnd - kvStart)} height={BAR_H} fill="#8f8f8f" />
      {/* what will not load */}
      {overflows && (
        <rect x={x(vram)} y={barTop} width={barEnd - x(vram)} height={BAR_H} fill="#ebebeb" />
      )}

      {/* drop lines continued through the bar */}
      {ticks.map((t) => {
        const accent = PRECISION_ACCENT[t.bits];
        const isSel = t.bits === selectedBits;
        return (
          <line key={t.bits} x1={t.x} y1={barTop + 1} x2={t.x} y2={barBottom - 1} stroke={accent}
            strokeWidth={isSel ? 2.5 : 1.75} strokeLinecap="round"
            strokeDasharray={isSel ? "0.01 4" : "0.01 3.5"}
            opacity={isSel ? 1 : 0.9} />
        );
      })}

      {/* hit targets — a 2px line is not a pointer target, so give each tick a
          band running the full height of its label and line */}
      {ticks.map((t) => (
        <rect
          key={`hit-${t.bits}`}
          x={t.x - HIT_W / 2}
          y={0}
          width={HIT_W}
          height={barBottom}
          fill="transparent"
          className="cursor-pointer outline-none"
          onMouseEnter={() => {
            cancelClose();
            onHoverBits?.(t.bits);
          }}
          onMouseLeave={scheduleClose}
          onFocus={() => {
            cancelClose();
            onHoverBits?.(t.bits);
          }}
          tabIndex={0}
          role="button"
          aria-label={`${t.bits}-bit, ${round1(t.total)} GB`}
        />
      ))}

      {/* in-bar figures */}
      {kvStart >= 52 && (
        <text x="16" y={barTop + 32} fontFamily="var(--font-geist-mono)" fontSize="13" fill="#fafafa">
          {round1(overflows ? weightsThatFit : selWeights)}
        </text>
      )}
      {overflows && barEnd - x(vram) >= 52 && (
        <text x={(x(vram) + barEnd) / 2} y={barTop + 32} textAnchor="middle"
          fontFamily="var(--font-geist-mono)" fontSize="13" fill="#171717">
          {round1(selTotal - vram)}
        </text>
      )}

      {/* capacity line — shown only while the card is in frame; past the edge
          it would just be a number with nothing to point at */}
      {!capOff && (
        <>
          <line x1={capX} y1={barBottom} x2={capX} y2={barBottom + 12} stroke="#171717" strokeWidth="1.5" />
          <text x={capX} y={barBottom + 30} textAnchor="middle" fontFamily="var(--font-geist-mono)"
            fontSize="13" fill="#171717">
            {vram}
          </text>
        </>
      )}

    </svg>

      {anchor && hoverCard && (
        <div
          className="absolute z-10"
          onMouseEnter={cancelClose}
          onMouseLeave={scheduleClose}
          style={{
            left: cardLeft(anchor.x, W, CARD_W),
            // Below the whole chart, so it never covers the KV cache caption.
            top: "calc(100% + 6px)",
          }}
        >
          {hoverCard}
        </div>
      )}
    </div>
  );
}