The World Vol 3 · Forces of Nature
ch 32 / 105
Chapter 32

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.

∑ Interlude — one walker, three neighbours, three answers

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.

radiushow far a body can see; a body inside it is a neighbour and a body outside it does not exist, 16 world pixels here
offsetthe neighbour's position subtracted from the body's: the direction from the neighbour to the body, which is the direction away from it
add up one term for every neighbour; with three neighbours it is three vectors added
offset ÷ distance ÷ distanceone separation vote: the first division makes it a direction, the second makes a near neighbour count for more
meanadded up and divided by how many there were
unit(v) × maxSpeedthat direction, asked for at the fastest this body travels
▣ Build · stage 1 — three rules, and one limit on their sum
// 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.

▣ Build · stage 2 — the tick, and the plainest query there is
// 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.

▣ Build · stage 3 — the same crowd, five times, one rule set each
$ 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.

▣ Build · stage 4 — one tick, timed, at four crowd sizes
// 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.

⚙ Tool · go tool pprof

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.

Why nine buckets hold every neighbour A grid of seven columns by four rows of cells. The nine cells in the middle-left of the grid, three by three, are highlighted, and a dot near the top-left corner of the middle one is the walker asking. A circle of one cell's radius around that dot spills into the cells above and to the left and still lies wholly inside the nine highlighted cells. Two other dots sit in plain cells far from the highlighted block, marked as never measured. Text to the right explains that a neighbour is at most 16 away and a cell is 16 across, so a neighbour's cell differs by at most one in each direction, and that with 2,048 bodies at two to a cell, nine buckets hold about eighteen and the other 2,029 are never measured. WHY NINE BUCKETS ARE ENOUGH a neighbour is at most 16 away, and a cell is 16 across, so a neighbour's cell differs from yours by at most one, each way 2,048 bodies, two to a cell: nine buckets hold about 18, and 2,029 are never measured gold: the asking walker, standing off centre, and how far it can see red: bodies this tick does not measure to at all

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.

▣ Build · stage 5 — one list per cell, refilled every tick
// 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.

⚠ Worked failure — the same neighbours in a different order

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.

▣ Build · stage 6 — three queries, one scene, four hundred ticks
$ 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.

▣ Build · stage 7 — the identity, written down as a test
// 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

✓ Checkpoint — what you can now do
  • 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 -list output 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.
⚡ Exercises — try first, then reveal
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.