|
4 | 4 |
|
5 | 5 | import { preds } from '../engine/checks'; |
6 | 6 | import type { Sequence } from '../engine/types'; |
| 7 | +import { schematicBlock, type SchematicSpec } from '../ui/schematic'; |
| 8 | + |
| 9 | +/** |
| 10 | + * The one picture the whole series is built on: a window over a buffer, where |
| 11 | + * a slice is a second window over the SAME buffer (dashed) and a copy owns its |
| 12 | + * own separate buffer. |
| 13 | + */ |
| 14 | +const VIEW_VS_COPY: SchematicSpec = { |
| 15 | + id: 'numpy-view-vs-copy', |
| 16 | + title: 'A window over a buffer — view vs copy', |
| 17 | + nodes: [ |
| 18 | + { id: 'a', label: 'a', sub: 'ndarray · own' }, |
| 19 | + { id: 'v', label: 'v = a[:2]', sub: 'ndarray · view' }, |
| 20 | + { id: 'c', label: 'b = a.copy()', sub: 'ndarray · own' }, |
| 21 | + { id: 'bufA', label: 'buffer A', sub: 'float64[3]', type: 'buffer' }, |
| 22 | + { id: 'bufB', label: 'buffer B', sub: 'float64[2]', type: 'buffer' }, |
| 23 | + ], |
| 24 | + edges: [ |
| 25 | + { from: 'a', to: 'bufA', label: 'owns' }, |
| 26 | + { from: 'v', to: 'bufA', label: 'same buffer', dashed: true }, |
| 27 | + { from: 'c', to: 'bufB', label: 'owns (new)' }, |
| 28 | + ], |
| 29 | + legend: [ |
| 30 | + { color: 'amber', label: 'points at / owns' }, |
| 31 | + { color: 'coral', label: 'shares the buffer' }, |
| 32 | + ], |
| 33 | +}; |
7 | 34 |
|
8 | 35 | export const series: Sequence[] = [ |
9 | 36 | { |
10 | 37 | id: 'numpy', |
11 | 38 | title: 'NumPy: Buffers & Views', |
| 39 | + deepLesson: `## ELI5 |
| 40 | +
|
| 41 | +A numpy array is like a **frame** you slide over a row of beads. The beads (the |
| 42 | +numbers) sit in one long, unbroken row — that row is the **buffer**. The frame |
| 43 | +is the **array**: it just says "start here, show this many beads." When you take |
| 44 | +a slice, you don't copy any beads; you make a *smaller frame* over the **same** |
| 45 | +row. When you copy, you pour the beads into a brand-new row. |
| 46 | +
|
| 47 | +${schematicBlock(VIEW_VS_COPY)} |
| 48 | +
|
| 49 | +## ELI10 |
| 50 | +
|
| 51 | +Two objects, always. \`np.array([1, 2, 3])\` allocates a **buffer** (a flat, |
| 52 | +contiguous run of raw numbers) and an **ndarray** — a *window* (an offset plus a |
| 53 | +length) that says which part of that buffer to show. The name \`a\` points at the |
| 54 | +window; the window points at the buffer. |
| 55 | +
|
| 56 | +- \`a.copy()\` reads \`a\`'s buffer and mints a **second** buffer with the same |
| 57 | + numbers. Two windows, two buffers — edits to one are invisible to the other. |
| 58 | +- \`a[:2]\` makes a **view**: a second window over the *same* buffer. No data |
| 59 | + moves; only the offset and length change. |
| 60 | +- Because a view shares its buffer, \`v[0] = 9\` writes into the shared run, and |
| 61 | + \`a\` sees the change. A copy, never. |
| 62 | +
|
| 63 | +## ELI15 |
| 64 | +
|
| 65 | +The mental model that matters in real code: **a slice is a view, not a copy.** |
| 66 | +This is the single most common numpy foot-gun. |
| 67 | +
|
| 68 | +- A slice \`a[i:j]\` is O(1) — it allocates a new window, moves no data, and |
| 69 | + shares the buffer. The result's \`base\` attribute points back at the original. |
| 70 | +- Any operation that *builds new data* (an element-wise op like \`a + 1\`, a |
| 71 | + fancy index, \`a.copy()\`) allocates a **fresh** buffer. |
| 72 | +- So the question to always ask: *does this expression share my data, or make |
| 73 | + new data?* Chaining views is free; chaining real operations is where you pay |
| 74 | + memory. |
| 75 | +
|
| 76 | +## ELI20 |
| 77 | +
|
| 78 | +The memory mechanics, precisely. |
| 79 | +
|
| 80 | +- A buffer is one contiguous \`float64\` run. An ndarray stores \`{buffer, |
| 81 | + offset, shape, dtype, view}\`. Two ndarrays are "the same data" iff they |
| 82 | + point at the same buffer box (that is exactly what the RAM view's shared |
| 83 | + window strip shows). |
| 84 | +- Element-wise ops are vectorized: \`a + 1\` reads \`a\`'s window and writes a |
| 85 | + new buffer. Chained expressions allocate an intermediate buffer per step — |
| 86 | + the cost to watch in a hot loop. |
| 87 | +- \`a.sum()\` is a reduction over the window: one pass, a scalar out, no new |
| 88 | + buffer. |
| 89 | +- Watch the RAM tab while you do this: addresses are monotonic, the buffer row |
| 90 | + shows its raw byte cells, and each view paints its \`[offset..offset+shape)\` |
| 91 | + window as a strip aligned to those cells. |
| 92 | +
|
| 93 | +## ELI-PHD |
| 94 | +
|
| 95 | +Where this model pays off and where it breaks. |
| 96 | +
|
| 97 | +- **Strides & broadcast**: real ndarrays carry a stride, so a non-contiguous |
| 98 | + slice is still a view (a window with a non-1 stride). This visualizer keeps a |
| 99 | + flat 1-D window to stay honest about the buffer, and says so in the lessons. |
| 100 | +- **\`base\` / \`.copy()\` contract**: in production, \`arr.base\` is the canonical |
| 101 | + "does this view own its data" check; \`copy(arr)\` and \`arr.copy()\` are the |
| 102 | + escape hatch. A view outliving its buffer is the classic memory-safety trap. |
| 103 | +- **Performance**: prefer in-place ops and views when you don't need the |
| 104 | + independence, and \`np.ascontiguousarray\` before a call that wants |
| 105 | + contiguity. The identity picture — shared vs owned buffer — is the whole |
| 106 | + decision: copy only when you must own the data. |
| 107 | +`, |
12 | 108 | levels: [ |
13 | 109 | { |
14 | 110 | id: 'np-make-array', |
|
0 commit comments