Repository navigation
Expand file tree
/
Copy pathallocgate_test.go
More file actions
343 lines (315 loc) · 13.8 KB
/
Copy pathallocgate_test.go
File metadata and controls
343 lines (315 loc) · 13.8 KB
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
//go:build !race
package figure_test
import (
"testing"
"github.com/timzifer/figure"
"github.com/timzifer/figure/geom"
"github.com/timzifer/figure/internal/irtest"
"github.com/timzifer/figure/three"
)
// The allocation gate.
//
// It is built out under the race detector, which allocates on figure's
// behalf: counting allocations while something else is adding them measures
// that instead. The race job and this one are separate CI jobs, so the gate is
// still checked on every commit.
// allocsPerFrame is the average number of allocations one steady-state render
// makes, into a backend that does nothing — so what is measured is figure's
// own work rather than an emitter's.
//
// [testing.AllocsPerRun] measures on one processor, which is what keeps the
// count reproducible: a goroutine that migrated between processors would leave
// its pooled scratch in the old one's private slot and be charged for refilling
// it. The benchmark half of the gate pins itself the same way — see onOnePGate
// in alloc_test.go, which is a note about why this half never needed one.
func allocsPerFrame(t *testing.T, p *figure.Plot) float64 {
t.Helper()
target := irtest.NullTarget()
// One render first: the scratch pools are empty on the first frame by
// definition, and a gate on the first frame would be a gate on start-up.
if err := p.Render(target); err != nil {
t.Fatalf("Render: %v", err)
}
return testing.AllocsPerRun(20, func() {
if err := p.Render(target); err != nil {
t.Fatalf("Render: %v", err)
}
})
}
// The allocation gate.
//
// The claim figure makes is not that a render allocates nothing — a frame
// still builds tick labels, a legend and a layout, and those are real. The
// claim is that none of it is *per point*: a thousand rows and a million rows
// cost the same, because everything sized by the data comes from a pool and is
// handed back. That is what makes a chart redrawn every frame over a live
// series affordable, and it is what this test exists to stop anyone undoing.
//
// If this fails, something on the data path started allocating per row again.
// Find it with:
//
// go test -run=XXX -bench=Frame -memprofile=mem.out .
// go tool pprof -sample_index=alloc_objects -top mem.out
func TestARenderDoesNotAllocatePerPoint(t *testing.T) {
small := allocsPerFrame(t, signal(1_000))
large := allocsPerFrame(t, signal(1_000_000))
// A thousand times the data, and the same handful of allocations. The
// slack absorbs a pool miss, not a per-row cost: a per-row cost would show
// up here as hundreds of thousands.
const slack = 8
if large > small+slack {
t.Errorf("1M rows allocate %.0f times per frame against %.0f for 1k rows: "+
"something on the data path is allocating per row", large, small)
}
}
// The absolute budget. It is generous on purpose — the number that matters is
// the one above — but a frame that suddenly needs ten times as many
// allocations has grown something worth looking at.
func TestAFrameStaysWithinItsAllocationBudget(t *testing.T) {
const budget = 128
if got := allocsPerFrame(t, signal(250_000)); got > budget {
t.Errorf("a steady-state frame allocates %.0f times, budget %d", got, budget)
}
}
// A curve family that solves for its tangents is the one reduction with a
// workspace of its own, so it gets its own gate: the slopes and the two swept
// tridiagonal rows have to come out of the scratch pool like everything else.
func TestACurvedLineDoesNotAllocatePerPoint(t *testing.T) {
for _, k := range []geom.CurveKind{geom.CurveNatural, geom.CurveMonotone, geom.CurveBasis} {
small := allocsPerFrame(t, curvedSignal(1_000, k))
large := allocsPerFrame(t, curvedSignal(200_000, k))
const slack = 8
if large > small+slack {
t.Errorf("curve %v: 200k rows allocate %.0f times per frame against %.0f for 1k: "+
"the fit is allocating per row", k, large, small)
}
}
}
// Faceting copies rows into per-panel sources, so it allocates once per panel
// per column — but still nothing per row after the first frame.
func TestAFacetedRenderDoesNotAllocatePerPoint(t *testing.T) {
small := allocsPerFrame(t, facetedSignal(3, 500))
large := allocsPerFrame(t, facetedSignal(3, 200_000))
const slack = 16
if large > small+slack {
t.Errorf("200k rows per panel allocate %.0f times per frame against %.0f for 500: "+
"faceting is allocating per row", large, small)
}
}
// A stacked layer is the third data path, and the one with the most
// bookkeeping: the groups are indexed and the adjustment is derived on every
// Train, because the axis has to describe the totals. All of it works out of
// buffers the layer keeps, so a long table costs the same handful of
// allocations as a short one.
func TestAStackedLayerDoesNotAllocatePerPoint(t *testing.T) {
small := allocsPerFrame(t, stackedSeries(1_000))
large := allocsPerFrame(t, stackedSeries(200_000))
const slack = 8
if large > small+slack {
t.Errorf("200k stacked rows allocate %.0f times per frame against %.0f for 1k: "+
"the adjustment is allocating per row", large, small)
}
}
// A hatched stack is the stacked path wearing a pattern. The hatch costs a
// clip and a stroke per series per frame, and must cost nothing per row: the
// geometry is generated into a pooled path and the pattern is measured in
// device units, so a long table and a short one emit the same lines.
func TestAHatchedLayerDoesNotAllocatePerPoint(t *testing.T) {
small := allocsPerFrame(t, hatchedStack(1_000))
large := allocsPerFrame(t, hatchedStack(200_000))
const slack = 8
if large > small+slack {
t.Errorf("200k hatched rows allocate %.0f times per frame against %.0f for 1k: "+
"the hatch is allocating per row", large, small)
}
}
// A polar layer is the fourth data path, and the one v0.8 added: every mark
// goes through the coord on its way to a device point.
//
// It is measured because that call is where a per-row interface method would
// reappear — the shape that once cost a million allocations on a million-row
// column. coord.Coord.Points is the batch form that stops it, and this is the
// assertion that it is the form the geoms actually use. A polar coord does not
// decimate either, so this draws every row rather than a reduction of them.
func TestAPolarRenderDoesNotAllocatePerPoint(t *testing.T) {
small := allocsPerFrame(t, polarSignal(1_000))
large := allocsPerFrame(t, polarSignal(200_000))
const slack = 8
if large > small+slack {
t.Errorf("200k polar marks allocate %.0f times per frame against %.0f for 1k: "+
"the coord is allocating per point", large, small)
}
}
// A broken-out layer collects a displacement per mark and carries it through
// the colour and group batching. All of that comes out of the scratch pool, so
// a ring of two hundred thousand slices costs what a ring of a thousand does.
func TestABrokenOutLayerDoesNotAllocatePerPoint(t *testing.T) {
small := allocsPerFrame(t, brokenRing(1_000))
large := allocsPerFrame(t, brokenRing(200_000))
const slack = 8
if large > small+slack {
t.Errorf("200k broken-out slices allocate %.0f times per frame against %.0f for 1k: "+
"the break-out is allocating per mark", large, small)
}
}
// A sized layer is the v0.9 data path, and the only one on which a frame
// *sorts*: the bubbles are ordered largest first so that a small one inside a
// large one is still visible. The ordering runs in place over the scratch's own
// index list, and the circles are appended into a path reserved once rather
// than grown per mark — so two hundred thousand bubbles cost what a thousand
// cost. A sort that allocated its own permutation, or a path grown a rung at a
// time, would show up here and nowhere else.
func TestASizedLayerDoesNotAllocatePerPoint(t *testing.T) {
small := allocsPerFrame(t, bubbleCloud(1_000))
large := allocsPerFrame(t, bubbleCloud(200_000))
const slack = 8
if large > small+slack {
t.Errorf("200k bubbles allocate %.0f times per frame against %.0f for 1k: "+
"the size channel is allocating per mark", large, small)
}
}
// A layer coloured from a column is the other data path: one colour per mark,
// batched into one drawing call per distinct colour. Its buffers are pooled
// too, and this is the assertion that they stay that way.
func TestAColouredLayerDoesNotAllocatePerPoint(t *testing.T) {
small := allocsPerFrame(t, colouredCloud(1_000))
large := allocsPerFrame(t, colouredCloud(200_000))
const slack = 8
if large > small+slack {
t.Errorf("200k coloured marks allocate %.0f times per frame against %.0f for 1k", large, small)
}
}
// A label is measured against its box on every frame, and a label that does
// not fit is cut — which builds a string. So the text path is the one place a
// per-row allocation could hide behind work that has to happen anyway, and the
// cut is remembered per row for exactly that reason: a chart redrawn at the
// same size cuts every label where it cut it last frame.
//
// This is the gate on that. Ten times the rows, the same handful of
// allocations. If it fails, the elision cache has stopped hitting — look at
// textGeom.remember before looking anywhere else.
func TestALabelledRenderDoesNotAllocatePerPoint(t *testing.T) {
small := allocsPerFrame(t, labelled(1_000))
large := allocsPerFrame(t, labelled(10_000))
const slack = 8
if large > small+slack {
t.Errorf("10k labelled rows allocate %.0f times per frame against %.0f for 1k: "+
"the text path is allocating per row", large, small)
}
}
// A layout mark builds a structure sized by the data on every Train — a node
// per name, a depth and a total per node — and none of it may be allocated per
// frame. The interning map is the one to watch: it is cleared rather than
// replaced, so its buckets survive, and a frame that made a new one would show
// up here as a hundred thousand allocations rather than as anything visibly
// wrong on screen.
func TestARelationalRenderDoesNotAllocatePerPoint(t *testing.T) {
for _, tc := range []struct {
name string
plot func(int) *figure.Plot
}{
{"sankey", flowOf},
{"treemap", treeOf},
} {
t.Run(tc.name, func(t *testing.T) {
small := allocsPerFrame(t, tc.plot(1_000))
large := allocsPerFrame(t, tc.plot(100_000))
const slack = 8
if large > small+slack {
t.Errorf("100k rows allocate %.0f times per frame against %.0f for 1k rows: "+
"something on the layout path is allocating per row", large, small)
}
})
}
}
// allocsPerFrame's counterpart for a projected scene.
//
// The same discipline and for the same reason: a chart the reader is dragging
// redraws on every pointer move, so what a frame costs must not grow with the
// data it draws. ADR 0057 committed figure/three to this before any of it was
// written — pooled points, one sort over a pooled key slice, and a traversal
// that allocates nothing — and this is what holds it to it.
func allocsPerScene(t *testing.T, p *three.Plot) float64 {
t.Helper()
target := irtest.NullTarget()
if err := p.Render(target); err != nil {
t.Fatalf("Render: %v", err)
}
return testing.AllocsPerRun(20, func() {
if err := p.Render(target); err != nil {
t.Fatalf("Render: %v", err)
}
})
}
// A surface is not decimated — a reduction defined over pixel columns measures
// nothing in a projected scene, where one column of screen mixes values from
// everywhere along the view direction — so every quad of the grid is projected
// and ordered on every frame. Sixteen times the quads must cost the same
// handful of allocations.
func TestASurfaceDoesNotAllocatePerQuad(t *testing.T) {
small := allocsPerScene(t, ripple(32))
large := allocsPerScene(t, ripple(128))
const slack = 8
if large > small+slack {
t.Errorf("a 128x128 surface allocates %.0f times per frame against %.0f for 32x32: "+
"something in the projection or the ordering is allocating per quad", large, small)
}
}
// A trajectory emits one primitive per segment, which is what lets it
// interleave with a surface — and a hundred times the segments must still cost
// a constant.
func TestATrajectoryDoesNotAllocatePerSegment(t *testing.T) {
small := allocsPerScene(t, spiral(1_000))
large := allocsPerScene(t, spiral(100_000))
const slack = 8
if large > small+slack {
t.Errorf("100k segments allocate %.0f times per frame against %.0f for 1k: "+
"the trajectory is allocating per segment", large, small)
}
}
// A projected scatter emits a marker and a dropline per row, and the markers
// batch into calls by run — so a hundred times the points must cost the same
// handful of allocations, which is what the single-vertex append in
// three.Sink.Marker is for.
func TestAScatterDoesNotAllocatePerPoint(t *testing.T) {
small := allocsPerScene(t, cloud(1_000))
large := allocsPerScene(t, cloud(100_000))
const slack = 8
if large > small+slack {
t.Errorf("100k points allocate %.0f times per frame against %.0f for 1k: "+
"the scatter is allocating per point", large, small)
}
}
// Turning a scene is the case the whole discipline exists for: a camera is two
// floats and a frame is the same frame from another angle, so an orbit that
// allocated per frame would be a leak with a chart attached.
func TestAnOrbitDoesNotAllocatePerQuad(t *testing.T) {
turn := func(n int) float64 {
live, err := ripple(n).Live(irtest.NullTarget())
if err != nil {
t.Fatal(err)
}
defer live.Close()
// Two frames first: the arenas behind a recording are empty on the
// first by definition, and a gate on the first frame would be a gate
// on start-up.
for i := 0; i < 2; i++ {
live.Camera(three.Orbit(live.CameraValue(), 0.01, 0))
if err := live.Draw(); err != nil {
t.Fatal(err)
}
}
return testing.AllocsPerRun(20, func() {
live.Camera(three.Orbit(live.CameraValue(), 0.01, 0))
if err := live.Draw(); err != nil {
t.Fatal(err)
}
})
}
small, large := turn(32), turn(96)
const slack = 8
if large > small+slack {
t.Errorf("orbiting a 96x96 surface allocates %.0f times per frame against %.0f for 32x32: "+
"a turn is allocating per quad", large, small)
}
}