The Herd That Bunches
Forty walkers without a herd
One walker with a gait, a tuft of grass and a fright in it looks alive. Release forty of them on the same ground and something goes wrong that no single walker could have shown you. They pass through each other. Two stand on the same pixel and neither notices. One drifts off toward the eastern rim alone while the rest are away west, and it never turns round, because nothing in it has any opinion about where the others are. Put forty on a field and what you have is forty animals who have never met.
No herd is stored anywhere. A herd is three more questions each body asks the bodies inside a radius of it: am I too close to anyone, which way is everyone going, and where is the middle of everyone? What a viewer wants from a crowd of one kind of creature is a short list. They keep a little room around themselves. They travel roughly the way their neighbours are travelling. They stay together instead of dissolving across the map. Get those three and the rest comes free: the crowd bunches when it slows, strings out when it runs, splits round a rock and closes behind it, and turns as though somebody had given an order.
The tempting way to build that is a herd object: a leader, a formation, a list of members, and each walker reading its slot out of the herd every tick. It works right up until the herd meets a rock and half of it goes left. Now the object has to notice it has become two objects, invent a leader for the new one, and decide which is the real herd. The same question returns for every walker that gets lost, every pair of herds that mingle, every animal born and every animal eaten. None of that is about animals. It is bookkeeping produced by a noun the world does not contain.
A bill comes attached, and it arrives fast. A body that asks about the bodies near it has to find them, and the obvious way is to look at all of them. Forty bodies each asking about thirty-nine others is 1,560 questions a tick. A hundred and twenty is 14,280. Two thousand is over four million, every tenth of a second, forever. That curve is the second half of this chapter, and the fix for it carries a condition that matters more than the speed does.
Three local questions
Each rule takes the same input, the list of bodies inside the radius, and produces the same kind of output, one desired velocity. Nothing else about them is shared. Work all three by hand for one walker with three neighbours before writing any of it, because the arithmetic is where their differences live.
A walker stands at (96, 64) travelling at (1.800, 0.600). Its gait is the one from the last chapter: 2.4 world pixels a tick flat out, a steering force of at most 0.3. It can see 16 pixels, and three walkers are inside that.
Too close to anyone? For each neighbour, take the offset from the neighbour to the walker, which is the direction "away". The first neighbour is at (92, 67), so the offset is (4, −3) and the distance is 5. Now weight it: divide by the distance once to throw the length away, and once more so a close neighbour shouts louder than a far one. Dividing (4, −3) by 25 gives (0.160, −0.120), a vote 0.2 long. Dividing an offset by its own length twice always leaves a vector one over the distance long, so the neighbour at (80, 64), exactly 16 away and included because the test is "no further than 16", votes only 0.0625. The third, at (104, 70), votes (−0.080, −0.060). Add the three: (0.1425, −0.180), which the run prints rounded. That direction at full speed is what separation wants.
Which way is everyone going? Add the three velocities, (0.600, −2.000) and (2.400, 0.000) and (−0.300, −1.600), giving (2.700, −3.600), and divide by three: (0.900, −1.200), a vector of length 1.500. Throw that length away and ask for the same direction at 2.4, giving (1.440, −1.920). A body copies where its neighbours are going, not how fast they are going.
Where is the middle of everyone? Add the three positions and divide by three: (92.000, 67.000). Nobody is standing there. From the walker at (96, 64) that point lies at (−4, 3), a distance of 5, and seeking it at full speed asks for (−1.920, 1.440).
separation = unit(∑ (offset ÷ distance ÷ distance)) × maxSpeed
alignment = unit(mean of the neighbours' velocities) × maxSpeed
cohesion = seek(mean of the neighbours' positions)
Three desired velocities pointing three different ways, and every one of them goes through the same door: subtract the walker's current velocity, cut the result to 0.3, hand it to the accumulator. That door was built in the last chapter and none of it needs changing.
// internal/field/flock.go
// Separate is the push-apart rule: a direction away from every
// neighbour, each one weighted by how close it is, so the body nearest
// gets the loudest vote. Dividing the offset by the distance twice is
// the whole of that weighting: once to throw the length away, once to
// make the vote fall off with range.
func (g Gait) Separate(b Body, near []Body) Vec2 {
var away Vec2
n := 0
for _, o := range near {
d := b.Pos.Sub(o.Pos)
r := d.Len()
if r == 0 {
continue // two bodies on the same pixel name no direction
}
away = away.Add(d.Scale(1 / (r * r)))
n++
}
if n == 0 {
return Vec2{}
}
return g.Steer(b, away.Unit().Scale(g.MaxSpeed))
}
// Align is the match-heading rule: the average of the neighbours'
// velocities, taken as a direction and asked for at full speed. The
// average's own length is thrown away, so a body surrounded by slow
// neighbours still wants to travel; what it copies is where they are
// going, not how fast.
func (g Gait) Align(b Body, near []Body) Vec2 {
if len(near) == 0 {
return Vec2{}
}
var sum Vec2
for _, o := range near {
sum = sum.Add(o.Vel)
}
avg := sum.Scale(1 / float64(len(near)))
if avg.Len() == 0 {
return Vec2{}
}
return g.Steer(b, avg.Unit().Scale(g.MaxSpeed))
}
// Cohere is the pull-toward-the-middle rule: average the neighbours'
// positions and seek the point that comes out. Nobody stores that point
// and no two bodies compute the same one, because each body averages a
// different set of neighbours and leaves itself out.
func (g Gait) Cohere(b Body, near []Body) Vec2 {
if len(near) == 0 {
return Vec2{}
}
var sum Vec2
for _, o := range near {
sum = sum.Add(o.Pos)
}
return g.Seek(b, sum.Scale(1/float64(len(near))))
}
// Cut is the limit a gait puts on a force, and it is the same limit
// Steer already applies to one behavior: keep the direction, and if the
// length is more than this body can push with, set it to what it can.
// Adding several behaviors together produces a vector no single
// behavior could have produced, so a combination has to be cut too.
func (g Gait) Cut(f Vec2) Vec2 {
if f.Len() > g.MaxForce {
return f.Unit().Scale(g.MaxForce)
}
return f
}
$ go run ./cmd/herd -mode hand
herd: one body at 96.000,64.000 doing 1.800,0.600, max speed 2.4, max force 0.3
neighbour radius 16
neighbour position velocity offset distance vote
0 92.000,67.000 0.600,-2.000 4.000,-3.000 5.000 0.160,-0.120
1 80.000,64.000 2.400,0.000 16.000,0.000 16.000 0.062,0.000
2 104.000,70.000 -0.300,-1.600 -8.000,-6.000 10.000 -0.080,-0.060
rule what the three neighbours make desired steering |steer| force
separate votes add to 0.143,-0.180 1.490,-1.882 -0.310,-2.482 2.501 -0.037,-0.298
align mean velocity 0.900,-1.200 1.440,-1.920 -0.360,-2.520 2.546 -0.042,-0.297
cohere middle at 92.000,67.000 -1.920,1.440 -3.720,0.840 3.814 -0.293,0.066
weighted 1.6, 1.0, 0.9: -0.365,-0.714, length 0.802
cut to the gait's 0.3: -0.137,-0.267
one tick later it is at 97.663,64.333 doing 1.663,0.333
Every column of the hand calculation is there, and one pair of rows repays a second look. Separation and alignment produce almost the same force, (−0.037, −0.298) against (−0.042, −0.297), from completely unrelated inputs: one is built from where three bodies are, the other from how three bodies are moving. They agree here by accident. Cohesion, meanwhile, points the other way entirely, because the middle of the crowd is behind and to the left while the crowd itself is drifting north.
That disagreement is normal, and it is the reason for the last two lines. Three forces
that each obey a limit of 0.3, added with weights of 1.6, 1.0 and 0.9, come to 0.802,
and a walker that can push at 0.802 is a different animal from the one that grazed in
the last chapter. The weights say who is heard and the gait says how hard the body can
push, and those are separate decisions. Cut keeps them apart, and it is the
arithmetic Steer was already doing, lifted out so a sum can use it too.
Rule rows at 120 walkers
Now the tick. Every force is worked out from the state at the start of the tick, so that
a body updated early does not change the answer a body updated late gets: the loop applies
forces first and steps afterwards. The neighbour list comes from a Query,
which is handed the bodies once a tick and then answers, one body at a time, with the
indices of the bodies inside a radius of it.
// internal/field/near.go
// Query is how a body finds its neighbours. Build is given every body
// once a tick, before any body asks; Near answers for one of them with
// the indices of the bodies inside r of it, itself excluded, in the
// order those indices run. Count reports the distance comparisons made
// since the last Reset, which is the number that tells two
// implementations apart on any machine.
//
// The order in that contract is not decoration. Every rule in flock.go
// adds its neighbours up, floating-point addition gives a different
// answer for a different order, and a world that answers differently is
// a different world however fast it answered.
type Query interface {
Build(bodies []Body)
Near(bodies []Body, i int, r float64, dst []int) []int
Count() int
Reset()
}
// Everybody is the neighbour query written the obvious way: to find one
// body's neighbours, measure to every other body. It is correct, it is
// four lines, and the count it runs up is the reason the rest of this
// file exists.
type Everybody struct{ Tests int }
// Build has nothing to prepare: this query looks at the slice it is
// handed, every time it is asked.
func (e *Everybody) Build([]Body) {}
// Near appends the index of every body inside r of body i to dst and
// returns it. The loop runs in index order, so the answer comes back in
// index order without anybody having to arrange it.
func (e *Everybody) Near(bodies []Body, i int, r float64, dst []int) []int {
dst = dst[:0]
for j := range bodies {
if j == i {
continue
}
e.Tests++
if bodies[i].Pos.Sub(bodies[j].Pos).Len() <= r {
dst = append(dst, j)
}
}
return dst
}
// cmd/herd/main.go — the tick
func (h *herd) tick() {
h.q.Build(h.bodies)
for i := range h.bodies {
b := h.bodies[i]
h.idx = h.q.Near(h.bodies, i, Radius, h.idx)
near := field.Gather(h.bodies, h.idx, h.scratch)
h.scratch = near
f := walk.Separate(b, near).Scale(wSep).
Add(walk.Align(b, near).Scale(wAli)).
Add(walk.Cohere(b, near).Scale(wCoh))
f = f.Add(h.wanders[i].Force(walk, b, h.rng).Scale(wWan))
f = f.Add(h.rim(b).Scale(wRim))
h.bodies[i].ApplyForce(walk.Cut(f))
}
for i := range h.bodies {
h.bodies[i].Step()
}
}
Two forces in that tick are not herd rules. The wander is the drifting heading from the
last chapter, kept so a walker with nobody near it still moves like an animal. The rim
is the edge of the field: inside fourteen pixels of it a walker wants to be travelling
away. Both are ordinary steering forces and neither knows a herd exists. The program
wraps the three-line sum in a switch on its -rule flag, so the next few
runs can hear one rule at a time; every other line above is what it always runs.
A hundred and twenty walkers, four hundred ticks, the same seed and the same starting positions every time, and the only difference between the runs is which rules are heard. Four numbers describe what came out: how many neighbours the average walker ended up with, how far its nearest neighbour was, how closely its heading agreed with the mean heading of the walkers it could see on a scale where 1 is perfect and 0 is none, and how many separate groups the crowd fell into, two walkers counting as grouped if either can see the other or see somebody who can.
$ go run ./cmd/herd -mode alone -n 120 -ticks 400 -shot yes
herd: 120 walkers on the seed 5 valley, 400 ticks, neighbour radius 16
rules heard neighbours nearest together groups frame
none 4.02 6.739 0.059 11 f42d95f4dcf1
push apart 3.58 9.008 0.031 2 c92dba1ae064
match heading 59.67 0.613 0.999 2 0c1cb97d16fd
pull to middle 27.57 0.915 -0.139 6 298dcde7069c
all three 5.95 8.411 0.951 3 b88eb16bed9e
wrote adrift.png
wrote separate.png
wrote align.png
wrote cohere.png
wrote herd.png
Read one row at a time and each rule confesses to exactly one thing. Push apart raises
the distance to the nearest walker from the aimless run's 6.739 to 9.008 and does
nothing else: agreement stays at 0.031, the number a crowd of strangers produces.
separate.png is the valley evenly stippled with walkers, almost a lattice,
no two of them near each other.
Match heading wins its own column outright at 0.999, which is 120 walkers travelling
one way to within a tenth of a degree, and pays with the other three columns: nearly
sixty neighbours each, and a nearest neighbour six tenths of a pixel off. Nothing in
alignment has an opinion about space, so the crowd converges on one heading and then on
one spot. align.png is an empty valley with two dense knots in it.
Pull to the middle fails most picturesquely. Agreement goes slightly negative, meaning a walker on average heads a little against its neighbours, because everyone is falling inward past everyone else. The groups column settles at 6 and stays there for two hundred ticks: six knots, frozen, with wide empty ground between them. At a radius of 16 cohesion can only pull a walker toward the crowd it has already found, so it never makes one herd out of six.
The last row has no column of its own and that is the point. 8.411 apart, which is better spacing than the aimless run manages. 0.951 agreement, within five hundredths of what pure alignment achieves. Three groups instead of eleven. Every one of those numbers comes out of a sum of three forces, none of which was asked for a herd, and none of which could have produced that row on its own.
Watch it arrive rather than reading the end of it, and the last claim gets harder to argue with.
$ go run ./cmd/herd -mode run -rule herd -n 120 -ticks 400
herd: 120 walkers on the seed 5 valley, all three rules, weighted
neighbour radius 16, every body asks every body
tick neighbours nearest together groups tests frame
0 5.67 6.000 0.063 2 0 edd70571b572
40 5.32 8.565 0.877 10 14280 39ffcc25d74b
80 8.93 6.488 0.906 4 14280 737752fc4480
120 11.10 6.647 0.942 7 14280 027b773e8926
160 5.63 8.742 0.951 2 14280 4185b5ba16f4
200 12.83 6.242 0.732 2 14280 64bbf42c23ab
240 10.63 6.519 0.889 4 14280 1a0ccf5c1b7b
280 6.18 8.008 0.952 2 14280 407c313a8606
320 12.40 7.016 0.826 4 14280 c8c2d3adbd65
360 13.28 6.523 0.971 9 14280 50a57b8210ce
400 5.95 8.411 0.951 3 14280 b88eb16bed9e
Agreement is 0.063 at tick 0 and 0.877 four seconds later, and after that it never falls below 0.73. The neighbour count breathes: 5.32, then 11.10, then 5.63, then 12.83, up and down all run while the spacing moves the opposite way. That is the crowd gathering into a band, running, spreading as it turns, and gathering again. The groups column does the same thing more coarsely, splitting to nine and closing back to two as a band meets the rim and the halves find each other a few seconds later. Nothing in the program noticed.
It is fair to ask whether a herd is really there or whether four columns have been picked
to flatter three functions, so say plainly what the code holds. No list of members, no
leader, no formation, no group identifier, no line anywhere that mentions a herd. One loop
over bodies, three sums over whoever was within 16 pixels, a weighted addition. Everything
in herd.png past that was supplied by the arithmetic and by whoever is
looking at it.
4,192,256 distance tests
The tests column has been sitting in those runs unremarked. It counts the
distance comparisons the query made in one tick, and at 120 walkers it reads 14,280 on
every line, which is 120 multiplied by 119. Every walker measures to every other walker,
whether that walker is a neighbour or is standing on the far side of the valley. Doubling
the crowd very nearly quadruples the count.
Counting is not measuring, though, and a number that looks alarming on paper is sometimes irrelevant on a machine. The benchmark ticks a crowd of the three rules and nothing else, on a field sized to hold two bodies per cell, so the walkers stand as thick at 2,048 as they do at 32 and only the count changes.
// internal/field/flock_test.go
// BenchmarkNeighbours times one tick of the same crowd at four sizes
// through both queries, and reports the distance tests each one made.
// The nanoseconds belong to whatever machine ran it; the tests do not.
func BenchmarkNeighbours(b *testing.B) {
for _, n := range []int{32, 128, 512, 2048} {
for _, q := range []struct {
name string
buckets bool
}{{"everybody", false}, {"buckets", true}} {
b.Run(fmt.Sprintf("%s/%d", q.name, n), func(b *testing.B) {
c := newBenchCrowd(n, q.buckets)
c.tick()
c.q.Reset()
b.ResetTimer()
for i := 0; i < b.N; i++ {
c.tick()
}
b.StopTimer()
b.ReportMetric(float64(c.q.Count())/float64(b.N), "tests/tick")
})
}
}
}
$ go test -run '^$' -bench 'BenchmarkNeighbours/everybody' -benchtime 200x ./internal/field/
goos: linux
goarch: amd64
pkg: theworld/internal/field
cpu: AMD Ryzen 7 3700X 8-Core Processor
BenchmarkNeighbours/everybody/32-16 200 8568 ns/op 992.0 tests/tick
BenchmarkNeighbours/everybody/128-16 200 77756 ns/op 16256 tests/tick
BenchmarkNeighbours/everybody/512-16 200 759489 ns/op 261632 tests/tick
BenchmarkNeighbours/everybody/2048-16 200 10237126 ns/op 4192256 tests/tick
PASS
ok theworld/internal/field 2.257s
These times were taken on the machine this chapter was written on and yours will read differently; the ratios travel, and the tests column is the same everywhere. The benchmark has a slot for a second query and the rest of the chapter fills it, so only the double loop is run here. The tests go 992, 16,256, 261,632, 4,192,256, which is 32×31, 128×127, 512×511 and 2048×2047: every quadrupling of the crowd multiplies the questions by sixteen exactly, with no measurement error in it because it is not a measurement.
The nanoseconds follow at a distance: 8,568 to 77,756 to 759,489 to 10,237,126, which is 9.1 then 9.8 then 13.5 times per quadrupling instead of sixteen. The gap is the fixed cost per body, three rules and an integration step, which is real work that does not grow with the crowd and which the quadratic term slowly buries. At 2,048 bodies the tick takes 10.2 milliseconds. This world runs at ten ticks a second, so that is a tenth of the budget spent on one flock, and 4,096 bodies would take about 41 milliseconds and 8,192 about 163, which is more than a whole tick.
The arithmetic said where the time was going, but the arithmetic has been wrong before. Before rewriting anything, ask the profiler, which has no theory about the program and only knows where it actually was.
Adding -cpuprofile to a test or benchmark run writes a file of stack
samples taken about a hundred times a second. go tool pprof -top ranks the
functions by how often they were on top of the stack, and -list spreads one
function's samples over its own source lines. Nothing needs adding to the program.
The listings below drop pprof's four header lines, which name the binary, its build
id, the profile type and the wall-clock time, and the absolute file path it prints
beside a routine. Full documentation is at
go.dev/blog/pprof.
$ go test -run '^$' -bench 'BenchmarkNeighbours/everybody/2048' -benchtime 300x -cpuprofile cpu.out ./internal/field/
BenchmarkNeighbours/everybody/2048-16 300 9867464 ns/op 4192256 tests/tick
PASS
ok theworld/internal/field 2.997s
$ go tool pprof -top -nodecount=8 cpu.out
Duration: 2.99s, Total samples = 2.99s (99.87%)
Showing nodes accounting for 2.95s, 98.66% of 2.99s total
Dropped 7 nodes (cum <= 0.01s)
Showing top 8 nodes out of 9
flat flat% sum% cum cum%
2.67s 89.30% 89.30% 2.91s 97.32% theworld/internal/field.(*Everybody).Near
0.24s 8.03% 97.32% 0.25s 8.36% theworld/internal/vec.Vec2.Len (inline)
0.04s 1.34% 98.66% 0.04s 1.34% theworld/internal/field.Gather (inline)
0 0% 98.66% 2.97s 99.33% testing.(*B).launch
0 0% 98.66% 0.02s 0.67% testing.(*B).run1.func1
0 0% 98.66% 2.99s 100% testing.(*B).runN
0 0% 98.66% 2.99s 100% theworld/internal/field.(*benchCrowd).tick
0 0% 98.66% 2.99s 100% theworld/internal/field.BenchmarkNeighbours.func1
The herd rules are not in the list, and neither is Step nor
Steer. The whole profile has nine nodes in it: five are the benchmark
harness, and the three that belong to this world are the neighbour search, the length
function inside it, and the copy that hands the answer to the rules. Ninety-seven percent
of three seconds went into finding out who the neighbours were. Ask for the lines.
$ go tool pprof -list 'Everybody.*Near' cpu.out
Total: 2.99s
ROUTINE ======================== theworld/internal/field.(*Everybody).Near
2.67s 2.91s (flat, cum) 97.32% of Total
. . 47:func (e *Everybody) Near(bodies []Body, i int, r float64, dst []int) []int {
. . 48: dst = dst[:0]
10ms 10ms 49: for j := range bodies {
10ms 10ms 50: if j == i {
. . 51: continue
. . 52: }
. . 53: e.Tests++
2.02s 2.26s 54: if bodies[i].Pos.Sub(bodies[j].Pos).Len() <= r {
630ms 630ms 55: dst = append(dst, j)
. . 56: }
. . 57: }
. . 58: return dst
. . 59:}
. . 60:
One line, 2.26 of 2.99 seconds, and there are two ways to read that. The tempting reading is that the line is expensive, and it does contain a square root, so compare squared distances and skip the root. That is a real improvement, it buys a constant factor once, and then the curve carries on doing what the curve does. The other reading is that the line is cheap and is being run 4,192,256 times a tick. Making a cheap thing cheaper is arithmetic; running it four million times fewer is a different question, and the profile has just said which question this is.
Nine buckets
Most of those four million measurements answer a question nobody needed asked. A walker at (32, 27) can see 16 pixels, so a walker at (150, 90) is not a neighbour, is not going to become one this tick, and the program worked that out by measuring the distance between them. The measurement is how it found out. What is needed is a way to know it without measuring, and the valley has been carrying one all along: a grid of cells, sixteen world pixels to a cell.
Give every cell a list of the walkers standing in it, refilled once a tick. Then a walker's candidates are the walkers in its own cell and in the eight around it, and nothing else on the field gets measured at all. One piece of integer arithmetic is what makes that safe: a body 16 or fewer pixels away has an X within 16 of yours, and two X values within 16 of each other, divided by a cell width of 16 and rounded down, differ by at most one. Same for Y. Every neighbour is in your cell or in one of the eight touching it, and the nine together hold everybody who could qualify.
The equality between radius and cell width is a condition, not a coincidence. Ask for a radius of 24 on a grid of 16 and the argument collapses: two X values 24 apart can land two cells apart, and a nine-cell search will quietly miss neighbours. A cell must be at least as wide as the radius asked of it, and the code says so instead of hoping.
Figure 32.1 — the reach of one walker fits inside its own cell and the eight touching it, so nine lists answer the question the whole field was being asked.
// internal/field/near.go
// Buckets is the neighbour query on a grid. Every cell of the world's
// own grid holds the indices of the bodies standing in it, rebuilt once
// a tick, and a body's neighbours are looked for in its cell and the
// eight around it and nowhere else. That is only correct while a cell is
// at least as wide as the radius being asked for, and Near refuses to
// answer quietly when it is not.
type Buckets struct {
Cols, Rows int
T float64 // cell size, in world pixels
cell [][]int // one list of body indices per cell, in row order
cand []int // this query's candidates, reused so nothing allocates
Tests int
}
// Build empties every bucket and refills it from the bodies, in index
// order, so each bucket's list is sorted before anybody reads it. The
// slices keep their capacity, so after the first tick this allocates
// nothing.
func (bk *Buckets) Build(bodies []Body) {
for c := range bk.cell {
bk.cell[c] = bk.cell[c][:0]
}
for i := range bodies {
cx, cy := bk.at(bodies[i].Pos)
bk.cell[cy*bk.Cols+cx] = append(bk.cell[cy*bk.Cols+cx], i)
}
}
// at is the cell a position stands in. Off the grid reads as the nearest
// edge cell, which can only ever bring distant bodies into an edge
// bucket, never take a near one out of it.
func (bk *Buckets) at(p Vec2) (int, int) {
return clamp(int(p.X/bk.T), 0, bk.Cols-1), clamp(int(p.Y/bk.T), 0, bk.Rows-1)
}
// Near gathers the candidates out of nine buckets and measures to each.
// Everything the double loop did is still here; what has gone is the
// bodies that were never candidates.
func (bk *Buckets) Near(bodies []Body, i int, r float64, dst []int) []int {
if r > bk.T {
panic("field: a radius wider than a bucket needs more than nine buckets")
}
dst = dst[:0]
cx, cy := bk.at(bodies[i].Pos)
bk.cand = bk.cand[:0]
for gy := cy - 1; gy <= cy+1; gy++ {
if gy < 0 || gy >= bk.Rows {
continue
}
for gx := cx - 1; gx <= cx+1; gx++ {
if gx < 0 || gx >= bk.Cols {
continue
}
bk.cand = append(bk.cand, bk.cell[gy*bk.Cols+gx]...)
}
}
for _, j := range bk.cand {
if j == i {
continue
}
bk.Tests++
if bodies[i].Pos.Sub(bodies[j].Pos).Len() <= r {
dst = append(dst, j)
}
}
return dst
}
Nothing about the rules changed and nothing about the tick changed. The same three
functions receive the same kind of list from a different Query, and that
list holds precisely the same walkers: the nine buckets provably contain every body
inside the radius, and the distance test at the bottom throws out the candidates that
turned out to be too far.
The same walkers ought to mean the same world. They do not.
Run the herd through both queries side by side and the frames match for a good while and then stop matching. There is no crash, no walker in the wrong place, no rule misfiring. The program has a mode that hunts the first disagreement down and takes it apart.
$ go run ./cmd/herd -mode why -rule herd -n 120 -ticks 400
herd: 120 walkers on the seed 5 valley, all three rules, weighted
every body against nine buckets left in the order the cells were walked
the numbers first differed on tick 1
the picture first differed on tick 159, and differs on 239 of 400 ticks
entering tick 1, walker 0 stands at 32.884,27.633
every body hands it [48 74 84 114]
the buckets hand it [84 74 114 48]
the same walkers, sorted: [48 74 84 114] and [48 74 84 114]
every body, in the order it answers:
walker 48 at 10.234 running total -0.02855247636275680,-0.09344664914710148
walker 74 at 12.602 running total -0.10581558501612852,-0.07534854029682950
walker 84 at 3.719 running total 0.05428190394970313,-0.29139154830618696
walker 114 at 8.930 running total 0.11651509742619502,-0.38448694397170102
the buckets, in the order it answers:
walker 84 at 3.719 running total 0.16009748896583165,-0.21604300800935744
walker 74 at 12.602 running total 0.08283438031245993,-0.19794489915908545
walker 114 at 8.930 running total 0.14506757378895183,-0.29104029482459948
walker 48 at 10.234 running total 0.11651509742619502,-0.38448694397170097
the separation force out of every body: 0.04217294281749551,-0.29702094689451153
the separation force out of the buckets: 0.042172942817495514,-0.29702094689451153
Walker 0 has four neighbours and both queries found all four of them. Sorted, the two lists are identical, so nothing has been missed and nothing spurious has been added. The lists arrive in different orders because the double loop walks bodies by index while the buckets walk cells by position, and walker 84 happens to be standing in an earlier cell than walker 48.
Follow the running totals down. Both columns end at 0.11651509742619502 for X. For Y one ends at −0.38448694397170102 and the other at −0.38448694397170097, a difference of five in the seventeenth decimal place. That is one step in the last bit of a float64, and it is there because adding four numbers in a different order rounds differently at each step. Three lines on, printed to as many digits as it takes to tell them apart, the separation force reads 0.04217294281749551 one way and 0.042172942817495514 the other.
On tick 1 that is a difference no screen could hold. The difficulty is what a tick does with it. The force goes into the accumulator, the accumulator sets the velocity, the velocity sets the position, and the position decides who is a neighbour on tick 2. A bounded rounding error is being fed through a loop that answers a threshold question, and once one walker on one tick lands on the far side of its radius from a neighbour the two runs are computing different worlds. Whole pixels hold the picture together for 158 ticks. Then it differs, on 239 of the 400.
The lesson is not about floats. Every replay in this book rests on a promise that the same inputs give the same outputs to the bit. A neighbour query that answers correctly in a different order breaks that promise while passing every test that asks whether the answer is right. The order has to be part of the contract, and now it is: the candidates go back into index order before any distance is measured.
// internal/field/near.go
// Ordered puts the candidates back in index order before they are
// measured, which is what makes this query answer exactly what Everybody
// answers. It is a field rather than a fact so the two can be run
// against each other.
Ordered bool
// ... and at the bottom of the gather, before any distance is taken:
if bk.Ordered {
slices.Sort(bk.cand)
}
With the sort in, the claim the grid makes can be stated exactly and checked exactly. The crowd steered from nine buckets is the crowd steered from every body, at every walker, on every tick, in the picture and in the numbers behind it. The double loop is the definition of the answer and the grid has to reproduce it, which is the same move the culling chapter in Volume 2 made: the frame drawn from the visible cells had to be the frame drawn from the whole map, pixel for pixel, or the saving did not count.
$ go run ./cmd/herd -mode sweep -rule herd -n 120 -ticks 400
herd: 120 walkers on the seed 5 valley, all three rules, weighted
the same scene through every body, nine buckets, and nine buckets unordered
the two same? columns are the frame and then the numbers behind it
tick every body: frame numbers buckets same? unordered same? tests each
0 edd70571b572 efa4abd607952d18 yes yes yes yes 0 0
40 39ffcc25d74b c3efc83c716bbe67 yes yes yes NO 14280 1448
80 737752fc4480 16611ceb4f5f9c5f yes yes yes NO 14280 2614
120 027b773e8926 4d694be4caa8484e yes yes yes NO 14280 2594
160 4185b5ba16f4 11b627fd32ce7e52 yes yes yes NO 14280 1762
200 64bbf42c23ab 8b91972a8701cde8 yes yes NO NO 14280 3066
240 1a0ccf5c1b7b 9413aff316d25737 yes yes NO NO 14280 3234
280 407c313a8606 bd73c504678050b9 yes yes NO NO 14280 2032
320 c8c2d3adbd65 64f455525f23da44 yes yes NO NO 14280 2584
360 50a57b8210ce 2e8487d294b274a9 yes yes NO NO 14280 3876
400 b88eb16bed9e af85abbb3b768594 yes yes NO NO 14280 1880
over 400 ticks the ordered buckets disagreed on 0 of them
the unordered buckets disagreed on 400
Zero disagreements in 401 frames and 401 state hashes, against 400 for the version that skipped the sort. The unordered run's frame column is instructive on its own: at tick 160 it still reads yes, and sampling every fortieth tick would have let it through. The state hash catches it at tick 1, which is the argument for hashing the numbers and not only the picture.
The tests columns say what the trade bought. Every body measures 14,280 distances on every tick, unchanging, because that count does not depend on where anybody is standing. The buckets measure between 1,448 and 3,876, and the variation is the crowd itself: a bunched herd shares cells and gives more to check, a strung-out one less. The work now follows the crowd instead of the roster.
The claim belongs in a test, since it is the sort of thing a later optimisation will break silently.
// internal/field/flock_test.go
// TestQueriesAgree is the claim the grid makes, written as a test: the
// crowd steered from nine buckets is the crowd steered from every body,
// bit for bit, at every body, after three hundred ticks. A query that
// answers faster and differently has not made the world faster; it has
// made a different world.
func TestQueriesAgree(t *testing.T) {
slow := newBenchCrowd(512, false)
fast := newBenchCrowd(512, true)
for i := 0; i < 300; i++ {
slow.tick()
fast.tick()
}
for i := range slow.bodies {
s, f := slow.bodies[i], fast.bodies[i]
if s.Pos != f.Pos || s.Vel != f.Vel {
t.Fatalf("body %d after 300 ticks: every body has it at %v doing %v, the buckets at %v doing %v",
i, s.Pos, s.Vel, f.Pos, f.Vel)
}
}
if slow.q.Count() <= fast.q.Count() {
t.Errorf("the buckets measured %d distances and every body %d", fast.q.Count(), slow.q.Count())
}
t.Logf("300 ticks, 512 bodies: every body measured %d distances, the buckets %d",
slow.q.Count(), fast.q.Count())
}
$ go test -count=1 -v -run TestQueriesAgree ./internal/field/
=== RUN TestQueriesAgree
flock_test.go:134: 300 ticks, 512 bodies: every body measured 78489600 distances, the buckets 3436228
--- PASS: TestQueriesAgree (0.37s)
PASS
ok theworld/internal/field 0.369s
Seventy-eight million distance measurements against three and a half million, and 512 walkers standing on identical coordinates at the end of it. Now run the benchmark with both queries in it.
$ go test -run '^$' -bench BenchmarkNeighbours -benchtime 200x ./internal/field/
goos: linux
goarch: amd64
pkg: theworld/internal/field
cpu: AMD Ryzen 7 3700X 8-Core Processor
BenchmarkNeighbours/everybody/32-16 200 8589 ns/op 992.0 tests/tick
BenchmarkNeighbours/buckets/32-16 200 15472 ns/op 446.4 tests/tick
BenchmarkNeighbours/everybody/128-16 200 77300 ns/op 16256 tests/tick
BenchmarkNeighbours/buckets/128-16 200 92141 ns/op 2584 tests/tick
BenchmarkNeighbours/everybody/512-16 200 760794 ns/op 261632 tests/tick
BenchmarkNeighbours/buckets/512-16 200 406764 ns/op 10252 tests/tick
BenchmarkNeighbours/everybody/2048-16 200 10216898 ns/op 4192256 tests/tick
BenchmarkNeighbours/buckets/2048-16 200 1749183 ns/op 42367 tests/tick
PASS
ok theworld/internal/field 2.714s
760,794 nanoseconds a tick against 406,764 at 512 bodies, and 10,216,898 against 1,749,183 at 2,048: a factor of 1.9 where the crowd is middling and 5.8 where it is largest, which is where the tick budget had started to look uncomfortable.
The 32-body rows are the honest part of that table. The buckets take 15,472 nanoseconds where the double loop takes 8,589, so on a small crowd the fix costs 80% more than what it replaced. Emptying sixteen cell lists, filling them, and putting a short candidate list in order once per body all cost something, and at 32 bodies there was never enough waste to pay for it. The curves cross somewhere around 250 walkers, and past that the grid pulls away and does not come back.
One last look through the profiler, at the fixed version, because the answer is not what the first profile would have predicted.
$ go tool pprof -top -nodecount=8 bk.out
Duration: 524.43ms, Total samples = 520ms (99.15%)
Showing nodes accounting for 440ms, 84.62% of 520ms total
Showing top 8 nodes out of 26
flat flat% sum% cum cum%
140ms 26.92% 26.92% 410ms 78.85% theworld/internal/field.(*Buckets).Near
120ms 23.08% 50.00% 120ms 23.08% slices.insertionSortOrdered[go.shape.int]
70ms 13.46% 63.46% 70ms 13.46% slices.partitionOrdered[go.shape.int]
30ms 5.77% 69.23% 230ms 44.23% slices.pdqsortOrdered[go.shape.int]
30ms 5.77% 75.00% 30ms 5.77% theworld/internal/vec.Vec2.Unit (inline)
20ms 3.85% 78.85% 20ms 3.85% runtime.memmove
20ms 3.85% 82.69% 40ms 7.69% theworld/internal/field.Gait.Separate
10ms 1.92% 84.62% 10ms 1.92% math.Sqrt (inline)
Sorting is 44% of what is left, reading the pdqsort line's cumulative column. Three lines added to keep two runs identical now account for nearly half of a job that got almost six times cheaper overall. That is what the guarantee costs, it is visible in the profile, and it is payable. The version without those lines would be quicker and would not be this world.
Same answers, faster
Three things met in this chapter and it helps to keep them apart, because each one turns up again on its own.
The first is that a group behaviour can be a per-body behaviour. Nothing in the herd knows about the herd. Each walker answers three local questions about whoever is within 16 pixels, and the pattern a viewer names as a herd is those answers added up across a hundred and twenty bodies. The awkward cases are the test of it: a crowd splitting round a rock costs nothing, because no object had to be told it became two, and two crowds meeting costs nothing either. Everything a membership list would have had to maintain does not exist.
The second is that "who is near me" is a question about storage, not about behaviour. The
rules in flock.go never learned that the grid was built, and if this world
ever grows a tree of cells they will not learn about that either. Bucketing is the general
move: put the things into containers keyed by the quantity you are about to filter on, and
open only the containers that could hold an answer. It works the same way for anything
with a range attached, a smell that carries thirty pixels, a sound that carries eighty, a
plant that shades its neighbours. One rule travels with it: a container must be at least
as wide as the reach it answers for, or the search widens and the widening costs more than
the coarser containers saved.
The third is a habit and it is the one to carry furthest. Every optimisation in a simulated world is a claim of the form "this computes the same thing faster", and that claim is checkable. Run both, hash both, compare. The culling chapter did it for a camera and this one did it for a neighbour search, and both times the identity check caught what a correctness test could not see: culling that dropped a column of pixels, and a search that found the right neighbours in the wrong order. An optimisation stays honest by shipping with the slow version still in the tree and a test that runs the two against each other.
One last note on measuring before cutting. The arithmetic predicted the quadratic, the benchmark confirmed it, and the profile said which line to look at. They agreed here. When they disagree the profile is the one holding evidence. But a profile only tells you where the time went. Whether that was a cheap thing done often or an expensive thing done once is still your call, and the two want opposite fixes.
A crowd with a grid under it
- Given a body, its velocity and three neighbours, I can produce all three rules'
desired velocities by hand and check them against
-mode hand, including the two divisions inside a separation vote and what each one does. - Shown the four-column table from
-mode alone, I can name which rule produced which extreme and say what each one fails at when it runs on its own. - I can state why several capped behaviors added together need capping again, and which number in the hand run proves it.
- From a crowd size I can compute the double loop's distance tests exactly, and say how many buckets a radius equal to the cell width needs and why.
- I can read a
pprof -listoutput and tell a line that is expensive from a line that is merely run often. - Given two neighbour queries that return identical sets in different orders, I can explain how identical frames on tick 1 become different frames by tick 159.
Exercise 1 — ask only your own cell. Change the gather to look in one bucket instead of nine, and predict two things before running it: what fraction of the real neighbours survives, and whether the frames still match.
A radius of 16 reaches over about 804 square pixels and one cell covers 256, so a walker standing anywhere but dead centre loses most of what it should see. The loss is worst near a cell boundary, and most walkers are near one.
// internal/field/near.go — the gather, crippled
for gy := cy; gy <= cy; gy++ {
...
for gx := cx; gx <= cx; gx++ {
// cmd/exp/main.go — the counting part, and nothing else
e := &field.Everybody{}
bk := field.NewBuckets(cols, rows, tile, true)
e.Build(bodies)
bk.Build(bodies)
real, found, blind := 0, 0, 0
for i := range bodies {
want := len(e.Near(bodies, i, radius, nil))
got := len(bk.Near(bodies, i, radius, nil))
real += want
found += got
if want > 0 && got == 0 {
blind++
}
}
$ go run ./cmd/exp
one cell instead of nine, 120 walkers, radius 16
neighbours that exist: 680
neighbours found: 198 (29.1%)
walkers that saw nobody but do have neighbours: 16
$ go test -count=1 -run TestQueriesAgree ./internal/field/
--- FAIL: TestQueriesAgree (0.26s)
flock_test.go:127: body 0 after 300 ticks: every body has it at {106.1550639463447 0} doing {1.1734338263929165 0.06597239875668207}, the buckets at {64.46239478743344 50.40937569963538} doing {1.5975100368684267 -1.0461518334788904}
FAIL
FAIL theworld/internal/field 0.265s
FAIL
Twenty-nine percent found, and sixteen walkers with neighbours who believe they are alone. After 300 ticks body 0 is at two coordinates 65 pixels apart, one of them pinned against the top edge. The tests count drops to a few hundred a tick, which is what every fast wrong answer looks like from outside: fast because it is not doing the work.
Exercise 2 — find the crossing point. The grid loses at 32 walkers and wins at 512. Add 64 and 256 to the benchmark's list and find where the two curves cross on your machine.
Guessing from the tests column alone puts it too early: at 128 the grid already measures six times fewer distances and is still the slower of the two. Building the buckets and ordering the candidates is a cost per body that has to be paid off by distances not taken, and at 128 there are not enough of those.
$ go test -run '^$' -bench BenchmarkNeighbours -benchtime 400x ./internal/field/
BenchmarkNeighbours/everybody/32-16 400 8511 ns/op 992.0 tests/tick
BenchmarkNeighbours/buckets/32-16 400 15340 ns/op 445.0 tests/tick
BenchmarkNeighbours/everybody/64-16 400 25027 ns/op 4032 tests/tick
BenchmarkNeighbours/buckets/64-16 400 36365 ns/op 1034 tests/tick
BenchmarkNeighbours/everybody/128-16 400 76563 ns/op 16256 tests/tick
BenchmarkNeighbours/buckets/128-16 400 89068 ns/op 2527 tests/tick
BenchmarkNeighbours/everybody/256-16 400 244395 ns/op 65280 tests/tick
BenchmarkNeighbours/buckets/256-16 400 227993 ns/op 6335 tests/tick
BenchmarkNeighbours/everybody/512-16 400 767668 ns/op 261632 tests/tick
BenchmarkNeighbours/buckets/512-16 400 430939 ns/op 11132 tests/tick
256 is the first size where the grid wins, and it wins by 7%. At 128 it is 16% behind and at 64 it is 45% behind. A world that will never put more than a hundred creatures on one map would be right to skip this whole section, and would want the benchmark in the tree anyway for the day it changes its mind.
Exercise 3 — pay for the order more cheaply. Every bucket is filled in index order, so nine buckets are nine sorted runs. Merge them instead of sorting the pile, and measure what that saves.
Merging two ordered lists is one pass over both, so eight merges into an alternating pair of scratch slices replaces one comparison sort. The profile put sorting at nearly half the job, so the expectation should be large.
// internal/field/near.go
// mergeRuns merges two lists that are each already in index order into
// dst, which is emptied first.
func mergeRuns(a, b, dst []int) []int {
i, j := 0, 0
for i < len(a) && j < len(b) {
if a[i] <= b[j] {
dst = append(dst, a[i])
i++
} else {
dst = append(dst, b[j])
j++
}
}
dst = append(dst, a[i:]...)
return append(dst, b[j:]...)
}
// internal/field/near.go — inside the gather, in place of the append
run := bk.cell[gy*bk.Cols+gx]
if !bk.Ordered {
bk.cand = append(bk.cand, run...)
continue
}
bk.tmp = mergeRuns(bk.cand, run, bk.tmp[:0])
bk.cand, bk.tmp = bk.tmp, bk.cand
$ go test -run '^$' -bench 'BenchmarkNeighbours/buckets/2048' -benchtime 200x -count=5 ./internal/field/ # sorting
BenchmarkNeighbours/buckets/2048-16 200 1760011 ns/op 42367 tests/tick
BenchmarkNeighbours/buckets/2048-16 200 1756648 ns/op 42367 tests/tick
BenchmarkNeighbours/buckets/2048-16 200 1746470 ns/op 42367 tests/tick
BenchmarkNeighbours/buckets/2048-16 200 1745385 ns/op 42367 tests/tick
BenchmarkNeighbours/buckets/2048-16 200 1753181 ns/op 42367 tests/tick
$ go test -run '^$' -bench 'BenchmarkNeighbours/buckets/2048' -benchtime 200x -count=5 ./internal/field/ # merging
BenchmarkNeighbours/buckets/2048-16 200 1726153 ns/op 42367 tests/tick
BenchmarkNeighbours/buckets/2048-16 200 1722924 ns/op 42367 tests/tick
BenchmarkNeighbours/buckets/2048-16 200 1717978 ns/op 42367 tests/tick
BenchmarkNeighbours/buckets/2048-16 200 1700595 ns/op 42367 tests/tick
BenchmarkNeighbours/buckets/2048-16 200 1747981 ns/op 42367 tests/tick
Under two percent, from five runs of each varying by about one, and
TestQueriesAgree still passes, which is the part that had to hold.
Profile the merged version and mergeRuns accounts for 260 of 520
milliseconds where the sort accounted for 230 of 520: the merge is not the cheaper
routine, it is the one that allocates and branches less around the edges. Putting
eighteen numbers in order costs about what it costs whichever way it is done. The
sorting algorithm was never the expensive thing; four million distance measurements
were, and they are already gone.