Three Thousand Brains
The measured creature phase
Three thousand creatures each run one forward pass inside half of a 100 ms tick. The design has treated that number as affordable; the sensor row allocates nothing, held actions skip the network, and the output row stays bare.
Count what the work is, measure where the time goes, and change nothing until those two answers disagree. The forward passes are about a million multiply-adds; the profile puts the time somewhere else.
A forward pass through this controller is 360 multiplications, 378 additions and twelve tanh calls. Three thousand passes make 1,080,000 multiplications. The fan can spend 432 ray steps per creature, or 1,296,000 cell questions for the same population.
The bench counts first, times second, then changes the cell index and the scratch buffers before splitting the phase across goroutines. The split keeps replay intact by letting only the looking go wide; the body updates still land in roster order.
// cmd/crowd, -mode sums: the arithmetic before anything is measured.
// Every number on it is a count and none of them is a time.
func sums(herd, hidden, cols, rows int) {
n := mind.New(beast.Inputs, hidden, beast.Acts)
muls := n.In*n.Hid + n.Hid*n.Out
steps := int(beast.Fauna[0].Sight / beast.Stride)
fmt.Printf("crowd: what one creature asks of the machine in one tick\n\n")
fmt.Printf(" %-38s %10d\n", "weights in the network", len(n.W))
fmt.Printf(" %-38s %10d\n", "multiplies in one forward pass", muls)
fmt.Printf(" %-38s %10d\n", "adds in one forward pass", muls+n.Hid+n.Out)
fmt.Printf(" %-38s %10d\n", "tanh calls in one forward pass", n.Hid)
// ... the sensor row, the fan, the sight cap, the bite, the table ...
fmt.Printf("\n and what %d of them come to, once:\n\n", herd)
fmt.Printf(" %-38s %10d\n", "multiply-adds in the forward passes", herd*muls)
fmt.Printf(" %-38s %10d\n", "ray steps at the sight cap", herd*beast.Fan*steps)
fmt.Printf(" %-38s %10d\n", "cells of ground under them", cols*rows)
}
$ go run ./cmd/crowd -mode sums
crowd: what one creature asks of the machine in one tick
weights in the network 378
multiplies in one forward pass 360
adds in one forward pass 378
tanh calls in one forward pass 12
numbers in the sensor row 24
rays in the fan 9
steps a ray takes at the sight cap 48
steps a whole fan takes at that cap 432
cells a bite looks over 9
entries the ranking can walk 6
and what 3000 of them come to, once:
multiply-adds in the forward passes 1080000
ray steps at the sight cap 1296000
cells of ground under them 15000
a million multiply-adds is the small number on this page
Nothing in that table is a measurement. It is arithmetic off the sizes the volume already fixed, and it comes out the same on every machine that runs it, which is the property that makes it useful. A count is a fact about the world you built. A time is a fact about the machine you happened to be sitting at. Confusing the two is how a person spends an afternoon making the network faster and finds the tick exactly as slow as it was.
Where the fifty milliseconds went
Three thousand creatures need somewhere to stand. The valley the rest of this volume runs in is twelve cells across and eight down, and the herd on it settles at nineteen animals, which is one creature to every five cells of ground. Keeping that crowding and scaling the population to three thousand asks for fifteen thousand cells, so the bench founds a valley a hundred and fifty cells across and a hundred down. Same seed, same terrain generator, same plants, same weather. Only bigger.
The population is scattered on stream 12 exactly as before, drawing until three thousand
different cells have been taken, and every creature gets a brain of its own off stream 14.
Then the roster is hung off terra.Valley.Phase the way the wiring chapter left
it, with the second reading of the world built for the minds, and the whole thing is run
for a hundred and twenty ticks.
// cmd/crowd: the valley this chapter measures itself in, and the crowd
// standing on it. The book's valley is twelve cells by eight with
// nineteen creatures on it, which is one creature to five cells of
// ground; a hundred and fifty by a hundred is that same crowd at three
// thousand.
const (
Cols = 150
Rows = 100
Herd = 3000
)
// people scatters a population and gives every one of them a brain off
// stream 14, in roster order.
func people(v *terra.Valley, seed uint64, n, hidden int) *beast.Roster {
r := beast.NewRoster()
rng := mind.Brains(seed)
for _, b := range scatter(v, n, seed) {
na := mind.New(beast.Inputs, hidden, beast.Acts)
na.Squash = mind.Tanh
na.Draw(rng)
beast.NewWits(na, b)
r.Add(b)
}
return r
}
$ go run ./cmd/crowd -mode spend -ticks 120 -was
crowd: 150x100 valley, 15000 cells, 591 plants standing, 3000 creatures
one goroutine, indexed into maps, a new index every tick, built twice
ticks run 120
creature-ticks handed out 352595
creatures struck off 269
creatures still walking 2731
fans cast on the closing tick 2731
ray steps those fans took 349694
of those rays, ones that met something 23304
digest of valley and roster e7098b55b7ce0cef
Read the counts before reading anything else, because two of them are surprising and one of them is the chapter. Three thousand creatures over a hundred and twenty ticks handed out 352,595 creature-ticks, which is a little short of the 360,000 a population that never died would have had: 269 animals ran their stores down and were struck off, and every one of them stopped being stepped. The closing tick cast 2,731 fans, one per survivor.
Now the second count. Those 2,731 fans are 24,579 rays, and 23,304 of them met something, which is nineteen rays in every twenty. The whole fan ran 349,694 steps, so the average ray got a little over fourteen steps down its line before it stopped. The count from the first page said 432 steps for a creature that sees nothing; the real figure is 128. A valley with a creature on every fifth cell is a valley where a creature is almost never looking at empty ground. The crowd blocks its own sight, and it does so more the bigger it gets.
So the arithmetic says a million multiplications and a third of a million ray steps. Both of those are small numbers for a modern processor. Here is what the phase actually took.
$ go run ./cmd/crowd -mode spend -ticks 120 -was -clock | tail -5 (measured on an eight-core Ryzen 7 3700X, Go 1.26, Linux; yours will differ) measured here, and yours will differ: the whole tick 16.765ms a tick the creature phase inside it 15.185ms a tick the phase's share of the tick 91% the phase against a 50 ms budget 30%
Fifteen milliseconds, on this machine, for work the count said was about one. The promise is met, in the narrow sense that thirty per cent of fifty milliseconds is inside fifty milliseconds. It is met by a phase spending fifteen times as long as its own arithmetic accounts for, and a valley ten times this size would not fit at all. Fourteen of those fifteen milliseconds are being spent on something nobody wrote down.
Go ships a sampling profiler in the standard library. Wrap a run in
pprof.StartCPUProfile and pprof.StopCPUProfile, and while the
program runs the runtime interrupts it a hundred times a second and writes down which
function was executing. Feed the resulting file to go tool pprof and it
adds those samples up per function: flat is time spent inside the function
itself, cum is time spent inside it and everything it called. It is sampling,
so small differences between runs mean nothing and a line at a third of the total means
a great deal. go doc runtime/pprof and go tool pprof -help are
the whole interface.
// cmd/crowd, in -mode spend: five lines and a filename.
if cpu != "" {
f, err := os.Create(cpu)
if err != nil {
die(err)
}
defer f.Close()
if err := pprof.StartCPUProfile(f); err != nil {
die(err)
}
defer pprof.StopCPUProfile()
}
$ go run ./cmd/crowd -mode spend -ticks 120 -was -cpuprofile was.prof $ go tool pprof -top -nodecount=10 was.prof (a profile of one run on one machine; yours will name the same functions in a different order) Showing top 10 nodes out of 70 flat flat% sum% cum cum% 520ms 25.12% 25.12% 520ms 25.12% aeshashbody 210ms 10.14% 35.27% 210ms 10.14% internal/runtime/maps.ctrlGroup.matchH2 (inline) 150ms 7.25% 42.51% 930ms 44.93% runtime.mapaccess1 130ms 6.28% 48.79% 130ms 6.28% theworld/internal/mind.Layer 90ms 4.35% 53.14% 1510ms 72.95% theworld/internal/beast.(*Beast).Look 80ms 3.86% 57.00% 80ms 3.86% internal/runtime/maps.(*Map).directoryAt (inline) 80ms 3.86% 60.87% 1290ms 62.32% theworld/internal/beast.(*View).atHashed 70ms 3.38% 64.25% 250ms 12.08% runtime.mapaccess2 60ms 2.90% 67.15% 60ms 2.90% math.cos 60ms 2.90% 70.05% 60ms 2.90% runtime.memclrNoHeapPointers
The top line is a hash function. aeshashbody is the routine the Go runtime
uses to turn a map key into a bucket number, and it is a quarter of the phase on its own.
The line under it is the routine that scans a bucket for a matching key. Under that,
mapaccess1, which is a map lookup, carrying 45 per cent of the phase in its
cumulative column. Add the map lines together and something close to half of every
creature phase in this valley is the Go runtime looking things up in a hash table.
mind.Layer is the forward pass, the arithmetic six chapters of this volume
were spent building. It is 6.28 per cent. The network was never the problem, and a
profile is how you find that out in ten seconds instead of finding it out in a week of
rewriting the network.
The flat cell index
Here is where the hashing comes from. The vision chapter built View as an
index of the valley: one map from a cell to the plant standing on it, one map from a cell
to the creatures standing on it, both rebuilt at the top of every phase. Every one of the
349,694 ray steps asks that index a question, and every question hashes a
sim.Coord, which is two integers in a struct. The map was the right call when
the alternative was walking the whole stand list once per sample. It stopped being the
right call the moment the population got big enough to notice.
A cell already has a number. The frame buffer has been addressing pixels as y times the width plus x since the drawing volume, the terrain grid stores its cells that way, and nothing about a valley makes its ground harder to lay out flat than a picture. Two slices of fifteen thousand entries, one for the plant and one for the bucket of creatures, and a lookup is a multiply, an add and a load off memory that is very likely already in cache.
// internal/beast/look.go
//
// The index is two flat rows, one entry a cell, addressed the way the
// frame buffer addresses a pixel: the cell at x,y is entry y times the
// width plus x. A row of ground is a row of memory, so a lookup is an
// add, a multiply and a load, and no part of a coordinate is ever
// hashed.
type View struct {
Valley *terra.Valley
w, h int
stand []*terra.Stand // one slot a cell, nil where nothing is standing
herd [][]*Beast // one bucket a cell, kept and refilled
hot []int // the buckets this tick put a creature in
}
// slot is where one cell sits in the flat rows, and -1 for a cell that
// is not on this grid at all. Both halves of the bounds test are here
// and nowhere else, because y times the width plus x turns a cell one
// step west of the left edge into a perfectly valid cell at the end of
// the row above.
func (v *View) slot(c sim.Coord) int {
if c.X < 0 || c.Y < 0 || c.X >= v.w || c.Y >= v.h {
return -1
}
return c.Y*v.w + c.X
}
// Stand is the plant on one cell, and whether there is one.
func (v *View) Stand(c sim.Coord) (*terra.Stand, bool) {
i := v.slot(c)
if i < 0 || v.stand[i] == nil {
return nil, false
}
return v.stand[i], true
}
// Mass is the grams standing on one cell, and nothing at all where
// nothing is standing.
func (v *View) Mass(c sim.Coord) float64 {
if st, ok := v.Stand(c); ok {
return st.Plant.Mass
}
return 0
}
// At is what a ray meets on one cell, with one creature left out of the
// answer because a creature cannot see itself. A cell off the grid is
// the rock the valley is cut out of.
//
// This is the most-called function in the whole tick: nine rays a
// creature, up to forty-eight steps a ray, three thousand creatures.
// Everything in it is a load off a row already in cache, and the one
// multiply that finds the row entry is the same arithmetic the frame
// buffer does for a pixel.
func (v *View) At(c sim.Coord, self *Beast) Hit {
if !v.Valley.Bed.In(c) {
return Rock
}
i := c.Y*v.w + c.X
for _, b := range v.herd[i] {
if b != self && !b.Dead {
return Creature
}
}
switch v.Valley.Bed.Kind(c) {
case sim.Water:
return Water
case sim.Rock:
return Rock
}
if v.stand[i] != nil {
return Plant
}
return Nothing
}
The bounds test is the part to be careful about, and it is the reason
slot exists as its own function instead of being written out at each of the
three places that index the rows. A map answered "no such key" for a cell off the grid
for free. A flat row does not: on a valley a hundred and fifty cells wide, the cell at
-1,7 works out to entry 1,049, which is a real cell at the far end of row
six, and a creature standing at the western edge would find a plant it cannot see and
cannot reach. Testing x and y separately is the whole of the fix: four comparisons
on a path that used to hash a struct.
That is the speed change. The next one is the allocation change, and they are not the same change: an index made out of slices instead of maps still gets thrown away and built again every tick unless somebody stops it. Fifteen thousand pointers and fifteen thousand slice headers, every tick, forever, and then the buckets on top. The roster is the natural owner of a reading of the world it is the only thing reading, so let it keep one.
// internal/beast/roster.go
//
// One View belongs to the roster and is pointed at the valley again
// every tick. One fan belongs to each place on the roster, made the
// first time that place is filled and reused by whoever stands in it
// afterwards, because a fan is written from end to end before anything
// reads it and nothing in it survives a tick. Between them, a phase of
// three thousand creatures allocates nothing whatever.
func (r *Roster) open(v *terra.Valley) *View {
switch {
case r.Maps:
r.view = NewHashed(v, r.Live)
case r.view == nil || r.Fresh:
r.view = NewView(v, r.Live)
default:
r.view.Refill(v, r.Live)
}
for len(r.eyes) < len(r.Live) {
r.eyes = append(r.eyes, Eyes())
}
return r.view
}
// internal/beast/look.go, continued
// Refill points a View at a valley and a roster again. The two rows
// are only ever allocated when the ground under them changes size;
// after that, blanking the stand row is one sweep of memory and
// blanking the creature buckets is one pass over the handful of cells
// that had a creature in them, each cut back to no length and keeping
// the array it had grown.
func (v *View) Refill(val *terra.Valley, herd []*Beast) {
v.Valley = val
if v.w != val.Grid.W || v.h != val.Grid.H {
v.w, v.h = val.Grid.W, val.Grid.H
v.stand = make([]*terra.Stand, v.w*v.h)
v.herd = make([][]*Beast, v.w*v.h)
v.hot = nil
}
clear(v.stand)
for _, i := range v.hot {
v.herd[i] = v.herd[i][:0]
}
v.hot = v.hot[:0]
for _, st := range val.Stands {
if i := v.slot(st.At); i >= 0 {
v.stand[i] = st
}
}
for _, b := range herd {
i := v.slot(b.Cell())
if i < 0 {
continue
}
if len(v.herd[i]) == 0 {
v.hot = append(v.hot, i)
}
v.herd[i] = append(v.herd[i], b)
}
}
hot is the small idea in there. Blanking the plant row is one call to
clear, which the compiler turns into a single sweep of memory over fifteen
thousand pointers and costs almost nothing. Blanking fifteen thousand creature buckets
the same way would throw away every array they had grown, and the point of keeping the
index was to keep those arrays. So the refill writes down which buckets it put anything
in, and the next one cuts exactly those back to no length. In a valley of three thousand
creatures that is at most three thousand cells touched instead of fifteen thousand, and
every array survives.
The two flags in the switch are the reason this chapter can measure anything.
Maps is the index as the last chapter left it and Fresh is a
new one built every tick, and both are false in every roster this book ships. The
-was run at the top of the chapter has those two on, along with the other
two this chapter is about to retire, because every design retired here is kept the same
way: behind a flag, so the ledger a few pages on is a run and not a memory.
One index, then, where the last chapter had two. That chapter said so on its own page: the
phase built a reading of the world for the rays, and the wiring built a second, identical
one so that Wits.Pick could put its ranking to the check. Two indexes over the
same valley and the same roster, one of them pure waste. The fix is a seam rather than a
special case. The phase already has the reading it opened on, so let it hand that reading
to anything that asks.
// internal/beast/roster.go, on Roster
//
// Seen is handed the reading of the world the phase opened on,
// once a tick, before the first creature is stepped. It is the
// seam anything that needs the same world the eyes got hangs off,
// and it exists so that nothing outside this package ever has a
// reason to build a second index of a valley that is already
// indexed.
Seen func(*View)
// what wiring a roster of network-driven creatures into the valley's
// tick now comes to, wherever it is done: the join in one line, and
// nothing built twice.
r.Seen = func(view *beast.View) { beast.Watch(r.Live, view) }
v.Phase = r.Hook(v, scale(v), buried, no)
The last removal is smaller and more embarrassing. Wits.Pick walks the ranking
from the top down and asks the check about each action until one comes back allowed. The
check it was asking, Legal, answers with a *Refusal: who was
refused, which rule stopped them, which cell the rule was about, and the two numbers it
compared. That is exactly what the event log wants. The ranking walk wants none of it. It
compares the answer with nil and drops the sentence on the floor, and building
a sentence to drop is one allocation for every crossed-out score, every tick, for three
thousand animals.
// internal/beast/act.go
//
// Rule is the check with nothing to say: which of the three rules
// stops this action here this tick, or nil for one the creature may
// take. The store first, because it is one subtraction and every one
// of the six is subject to it; then the entry's own test, which is the
// only part that has to look at the world.
//
// The upkeep is not in this check. A body is charged for standing there
// whatever it does with the tick, so a store that cannot cover the
// upkeep is not something choosing a cheaper action can mend.
//
// Nothing here allocates. The three rules are values made once when the
// package was loaded, so asking the check is a comparison and a return
// and leaves no rubbish behind whatever the answer is.
func (b *Beast) Rule(a Act, v *View) error {
m := Table[a]
if m.Price(*b.Kind) > b.Store {
return ErrBroke
}
return m.Allow(b, v)
}
// Can is the same question as a yes or a no, for the callers that were
// only ever going to compare the answer with nil.
func (b *Beast) Can(a Act, v *View) bool { return b.Rule(a, v) == nil }
// Legal is the check with a sentence attached: nil when the creature
// may take the action, and a *Refusal naming who, what, which rule and
// which cell when it may not. It is what the log wants and what the
// ranking walk does not, and the split between this and Rule is the
// whole of the difference.
func (b *Beast) Legal(a Act, v *View) error {
why := b.Rule(a, v)
if why == nil {
return nil
}
r := &Refusal{Who: b.ID, Act: a, Why: why, At: b.Cell()}
switch why {
case ErrBroke:
r.Want, r.Got = Table[a].Price(*b.Kind), b.Store
case ErrNoGround:
r.At = b.Ahead()
case ErrNoReach:
_, r.Got = b.Meal(v)
r.Want = b.Kind.Bite
}
return r
}
The three tests the table hangs off Allow lose a parameter, and two of
them lose the & that was the allocation. An entry's own test used to
build the *Refusal itself, so it had to be told which action it was
refusing; now it hands back the bare rule and Legal works the rest out
from the action it was called with.
// internal/beast/act.go — the field inside Move, and the three tests it holds
Allow func(b *Beast, v *View) error
// always is the test for an action nothing about the ground can forbid.
func always(b *Beast, v *View) error { return nil }
// ground refuses a step that would put weight on open water or over the
// rim. It tests the cell ahead and not the cell underfoot, because the
// cell underfoot has already been walked on.
func ground(b *Beast, v *View) error {
c := b.Ahead()
if !v.Valley.Bed.In(c) || v.Valley.Bed.Kind(c) == sim.Water {
return ErrNoGround
}
return nil
}
// mouth refuses a bite with nothing to bite.
func mouth(b *Beast, v *View) error {
if st, _ := b.Meal(v); st == nil {
return ErrNoReach
}
return nil
}
Which leaves the caller. The ranking walk in Wits.Pick asked
b.Legal(a, w.Here) == nil, and that is the line that has to change, so the
question goes behind a method of its own with the earlier wiring kept beside it under a
flag, exactly as Blunt was kept.
// internal/beast/wits.go — the field, the line inside Pick, and what it now asks
// Wordy is the ranking walk as it was first written: the check
// asked for a written refusal at every step and the sentence
// thrown away unread. Kept behind a flag for the same reason, and
// false in every creature this book ships.
Wordy bool
a := Act(best)
if w.allows(b, a) {
// allows is the one question the walk asks, in whichever of the two
// ways this brain has been set to ask it. Can hands back a yes or a
// no; Legal hands back a sentence naming who was refused and by which
// rule, and building that sentence is work nothing here reads.
func (w *Wits) allows(b *Beast, a Act) bool {
switch {
case w.Blunt || w.Here == nil:
return true
case w.Wordy:
return b.Legal(a, w.Here) == nil
}
return b.Can(a, w.Here)
}
Four changes, and the bench can take them one at a time. Every one of the earlier designs is still in the module behind a flag, the way this book keeps every design it has retired, so the ledger below is run instead of remembered. It counts allocations around the creature phase and nowhere else, with the collector stopped for each row, so what it reports is the phase's own rubbish and not the terrarium's.
$ go run ./cmd/crowd -mode share -ticks 120
crowd: what one creature phase allocates, 3000 creatures, 120 ticks
how the phase is built a tick each
as the wiring chapter left it 6251 2.29
the index in rows of cells, not maps 6245 2.29
one index refilled, not a new one a tick 3437 1.26
one index, not two 488 0.18
the ranking walk asking for a yes, not a sentence 168 0.06
each is that count over the creatures still walking at the end
The second row is the interesting one, because it barely moves. Swapping two maps for two flat rows changed 6,251 allocations a tick into 6,245, which is nothing. That change was never about allocation. It was about what happens on each of a third of a million lookups, and this ledger cannot see that at all. The rows that do move are the ones that stop making something: keeping one index instead of building a new one halves the figure, dropping the second index takes it to a couple of hundred, and the yes-or-no check takes off the last three hundred and twenty. What is left is 168 a tick, one allocation for every sixteen creatures, and most of it is a cell nothing had ever stood on growing its bucket for the first time.
Now the run itself, with all four in place.
$ go run ./cmd/crowd -mode spend -ticks 120
crowd: 150x100 valley, 15000 cells, 591 plants standing, 3000 creatures
one goroutine, indexed into rows of cells, one index refilled
ticks run 120
creature-ticks handed out 352595
creatures struck off 269
creatures still walking 2731
fans cast on the closing tick 2731
ray steps those fans took 349694
of those rays, ones that met something 23304
digest of valley and roster e7098b55b7ce0cef
Every count is the count from before. The same 352,595 creature-ticks, the same 269 deaths, the same 349,694 ray steps, and the same sixteen characters of digest over the ground, the plants and every creature's place, heading, store and grams eaten. That last line is not decoration. A performance change that moves a result is not a performance change, it is a bug with a stopwatch next to it, and the only way to know which one you have made is to fold the whole run into a number and compare it.
$ go run ./cmd/crowd -mode spend -ticks 120 -clock -cpuprofile now.prof | tail -5 (measured on an eight-core Ryzen 7 3700X, Go 1.26, Linux; yours will differ) measured here, and yours will differ: the whole tick 8.019ms a tick the creature phase inside it 6.35ms a tick the phase's share of the tick 79% the phase against a 50 ms budget 13%
Fifteen milliseconds down to six, for the identical run. And the profile, taken again, says something that stops the edit.
$ go tool pprof -top -nodecount=8 now.prof (the same machine, the same caveat) Showing nodes accounting for 640ms, 65.98% of 970ms total Showing top 8 nodes out of 77 flat flat% sum% cum cum% 210ms 21.65% 21.65% 270ms 27.84% theworld/internal/beast.(*View).At 120ms 12.37% 34.02% 490ms 50.52% theworld/internal/beast.(*Beast).Look 110ms 11.34% 45.36% 110ms 11.34% theworld/internal/mind.Layer 100ms 10.31% 55.67% 100ms 10.31% math.cos 30ms 3.09% 58.76% 30ms 3.09% runtime.(*spanInlineMarkBits).init 30ms 3.09% 61.86% 40ms 4.12% theworld/internal/sim.(*Grid).At 20ms 2.06% 63.92% 20ms 2.06% math.archMax 20ms 2.06% 65.98% 20ms 2.06% math.sin
No hash function anywhere on it. mind.Layer has climbed from 6 per cent to 11,
and it did not get slower by so much as an instruction: it is the same code doing the same
1,080,000 multiplications, and it is a larger share of a smaller total. That is the honest
reading of every profile taken after a change. A line going up in percentage terms usually
means something else went down.
The parallel ray pass
Six milliseconds of the phase, and half of it is still the fans. That half is doing real work now, so the next question is not how to make it cheaper but whether it can be done on more than one processor at once. The machine this was measured on has eight cores sitting idle while one of them walks three thousand fans in a row.
Splitting a simulation across goroutines is where most simulations quietly stop being reproducible, so the rule has to come before the code:
A phase may be split exactly where the split cannot be noticed, and the only way to claim it cannot be noticed is to fold the whole run into a digest and watch the digest not move.
The volume's phase order says every creature senses the world as it stood when the phase opened, writes only its own body and its own action, and is stepped in roster order, with a death applied at the end of the phase and never in the middle. That is a promise about what a creature reads and writes, and it is what makes any of this possible. But the promise is not equally true of the four things a creature does with its tick, and reading them one at a time is the whole design.
The fan reads the ground, which does not move during a tick, and the index, which was
built before anybody was stepped. It writes nine Ray values into a slice that
belongs to that creature and to nothing else. Two creatures casting their fans at the same
moment cannot touch the same byte.
The sensor row is different, and the difference is one entry. The food gradient samples the standing tissue on the four cells around the creature, and a creature stepped earlier in this same phase may have taken a quarter of a gram off one of those plants. Read the row for everybody first and the eleventh animal sees grass the tenth one has already eaten.
The decision has the same problem for the same reason: the ranking walk asks whether a bite is legal, and that question is about the grams standing inside reach right now. And the action itself writes the plant's mass, the litter on the bed and the creature's own body. Three of the four are tangled up in the order they happen in. One is not.
// internal/beast/roster.go
//
// look fills every creature's fan against the world the phase opened
// on. It is the one part of a creature's tick that reads nothing any
// other creature is about to write: the ground does not move, the
// index was built before anybody was stepped, and each creature writes
// into a fan nobody else touches. So it may be handed to as many
// goroutines as there are, and the answer does not depend on how many
// there were.
//
// The stripes are interleaved rather than cut into blocks. A fan that
// meets a rock on its first step costs a fortieth of one that runs to
// the sight cap, and creatures near each other tend to be looking at
// the same kind of thing; taking every Crew'th creature spreads the
// cheap and the dear across all of them.
func (r *Roster) look(w *View) {
if r.Crew <= 1 || len(r.Live) < 2*r.Crew {
for i, b := range r.Live {
b.Look(w, r.eyes[i])
}
return
}
done := make(chan struct{}, r.Crew)
for c := 0; c < r.Crew; c++ {
go func(c int) {
for i := c; i < len(r.Live); i += r.Crew {
r.Live[i].Look(w, r.eyes[i])
}
done <- struct{}{}
}(c)
}
for c := 0; c < r.Crew; c++ {
<-done
}
}
Crew is new, and it is one number on the roster: how many goroutines the
fans are cast across. At nothing or one the phase stays on the goroutine it was called
on, and so does any roster holding fewer than two creatures a goroutine, because
starting eight of them to share twelve animals costs more than it saves.
The channel is doing one job and it is not carrying data. Each goroutine sends an empty
value when its stripe is finished, and the phase receives exactly as many as it started,
which is a barrier: past that last receive, every fan on the roster has been written and
every write is visible to the goroutine that is about to read it. That last clause is
the part people skip. Receiving from a channel a goroutine sent on is what makes
everything that goroutine did beforehand visible here, and without something like it a
loop over r.eyes would be reading memory another processor may still be
holding in a register.
Run that, compare the digest with the narrow phase, and it does not match. Not sometimes: every time, by the same amount, on one goroutine and on sixteen.
The phase now casts every fan before it steps anybody, and the digest it closes on is not the digest the phase closed on when the looking happened inside the loop.
$ go run ./cmd/crowd -mode crew -ticks 120 -loose
crowd: 150x100 valley, 3000 creatures, 120 ticks, on one crew after another
the phase run narrow, in roster order, closes on e7098b55b7ce0cef
the fans below are cast wide and never looked at again
crew digest the same run
1 cc56c8cd4ae975f9 NO
2 cc56c8cd4ae975f9 NO
4 cc56c8cd4ae975f9 NO
8 cc56c8cd4ae975f9 NO
16 cc56c8cd4ae975f9 NO
the digest covers the ground, every standing plant, and every
creature's place, heading, store and grams eaten, in roster order
Start from the column that surprises. All five runs agree with each other. If goroutines were the problem, sixteen of them racing over shared memory would give sixteen different answers on sixteen different afternoons. These five give one answer, and the run on a single goroutine gives it too. Whatever is wrong here has nothing to do with running things at the same time. It is wrong in the narrow version as well, which means the mistake was made when the looking was lifted out of the loop, before the first goroutine was ever started.
So ask what a fan reads that changes between the top of the phase and the creature's own
turn. The ground does not change. The index does not change. But View.At
skips a creature that is marked dead, and a creature gets marked dead in the middle of
the phase, the instant its store closes the tick at nothing. Its body is left standing
where it is until the burial at the end, and the roster is not edited, but its
Dead flag is set immediately, and every creature stepped after it stops
seeing it. Cast all three thousand fans before the first animal is stepped and all three
thousand of them see a valley in which nobody has died yet.
Two hundred and sixty-nine creatures die over these hundred and twenty ticks. Each one of those deaths changes what somebody behind it in the order sees, one ray turns from a creature into open ground, that creature walks somewhere else, and a hundred and twenty ticks later the valley has drifted somewhere the roster order never took it.
The repair is not to give up the split. A death is a rare thing at this scale, two a tick out of three thousand animals, and it can only change a fan that could have reached it. A ray leaves the eye and runs at most twelve cells, so a creature standing more than twelve cells away in x or in y from the cell a body stopped on never asked about that cell at all. The phase writes down the cells the dead were standing on as it goes, and any creature stepped afterwards that is near enough to one of them casts its fan again before it reads its row.
// internal/beast/roster.go
//
// The looking is lifted out of the loop and done first, because it is
// the only part that can be. What a fan reports depends on the ground,
// on the index and on one thing a creature ahead of it in the order
// can change: whether a body is still standing. So a death goes on a
// list of cells, and any creature stepped after one that is near
// enough to have seen it looks again before it reads its row. In a
// valley of three thousand that is a handful of fans a tick, and it is
// what makes the wide version and the narrow one the same run.
func (r *Roster) Phase(v *terra.Valley, sc Scale) (died []Carcass, refused []error) {
w := r.open(v)
if r.Seen != nil {
r.Seen(w)
}
r.look(w)
r.gone = r.gone[:0]
for i, b := range r.Live {
home := b.Cell()
if len(r.gone) > 0 && !r.Loose && b.sees(r.gone) {
b.Look(w, r.eyes[i])
}
b.Read(&r.row, r.eyes[i], w, sc)
a := Rest
if b.Mind != nil {
a = b.Mind.Pick(&r.row)
}
t, err := b.Do(a, w)
if err != nil {
refused = append(refused, err)
}
r.Ate += t.Grams
r.Steps++
if t.Left <= 0 {
b.Dead = true
c := Carcass{Who: b.ID, Name: b.Name, At: b.Cell(),
Grams: b.Kind.Bulk, Tick: v.Now}
died = append(died, c)
r.gone = append(r.gone, home)
if r.Hasty {
r.bury(v, died[len(died)-1:])
}
}
}
if !r.Hasty && len(died) > 0 {
r.bury(v, died)
}
return died, refused
}
// sees reports whether any of these cells is near enough this creature
// for one of its rays to have asked about it. A ray leaves the eye and
// runs at most Sight cells, and the cell a step lands on is the one
// its position floors into, so a cell more than Sight plus two away in
// x or in y is a cell no ray of this fan ever reached.
func (b *Beast) sees(cells []sim.Coord) bool {
c := b.Cell()
n := int(b.Kind.Sight) + 2
for _, g := range cells {
if abs(g.X-c.X) <= n && abs(g.Y-c.Y) <= n {
return true
}
}
return false
}
home is the cell the index has this creature standing on, taken before its
action moves it, because the index was built at the top of the phase and has not been
touched since. The margin of two cells is deliberate slack. A ray's last step lands
somewhere inside twelve cells of the eye, the eye sits somewhere inside the creature's
own cell, and flooring a position to a cell can lose most of one on each of those. Two
cells of margin costs a few extra fans a tick and buys an argument that does not depend
on getting a rounding rule exactly right. Casting a fan again when it did not need it
produces the same nine rays, so being generous here is free and being stingy is not.
Two flags run through that listing and both belong to failures already worked in this
book. Loose is the split as it was first written, on the page above:
every fan cast up front and none of them ever looked at again. Hasty is
the burial the death chapter tried first, applied in the middle of the phase instead
of at the end of it. Both are false in every roster this book ships, and both are in
the file so the two failures can be run rather than remembered.
That claim deserves a test rather than a paragraph, and it gets one that runs the same valley narrow and then on two, three, eight, twenty and sixty-four goroutines, the last two of them more goroutines than a valley of forty creatures has any use for.
$ go test -count=1 -v -run TestASplitPhaseIsTheSameRun ./internal/beast/
=== RUN TestASplitPhaseIsTheSameRun
--- PASS: TestASplitPhaseIsTheSameRun (0.12s)
PASS
ok theworld/internal/beast 0.124s
And the bench says the same thing at the size the chapter is about, against the same fixed reference the failure above was measured against.
$ go run ./cmd/crowd -mode crew -ticks 120
crowd: 150x100 valley, 3000 creatures, 120 ticks, on one crew after another
the phase run narrow, in roster order, closes on e7098b55b7ce0cef
crew digest the same run
1 e7098b55b7ce0cef yes
2 e7098b55b7ce0cef yes
4 e7098b55b7ce0cef yes
8 e7098b55b7ce0cef yes
16 e7098b55b7ce0cef yes
the digest covers the ground, every standing plant, and every
creature's place, heading, store and grams eaten, in roster order
On this machine those five runs took 6.38, 4.44, 4.11, 3.71 and 3.60 milliseconds of phase a tick, measured here and different on yours. Eight goroutines are not eight times faster than one, and nothing is wrong: half the phase is the fans and half of it is the roster walked in order, and the half that cannot be split sets the floor no matter how many cores are thrown at the half that can. Doubling from one to two buys most of what there is to buy. Going from eight to sixteen buys almost nothing, which is what an eight-core machine has left to give.
The capacity calculation
The volume's promise is a question about capacity, so answer it as one. The phase has fifty milliseconds of the tick. A creature costs whatever it costs. Divide.
Numbers first. Eight goroutines carried 2,731 creatures through a phase in 3.71 milliseconds on the machine this was written on. Three point seven one milliseconds is 3,710 microseconds, and 3,710 divided by 2,731 is 1.36 microseconds a creature. The budget is fifty milliseconds, which is 50,000 microseconds. Fifty thousand divided by one point three six is a little over 36,000.
So on this machine, at this crowding, with eight cores, the fifty-millisecond phase has room for something like thirty-six thousand animals. Three thousand of them use under a thirteenth of it.
That division only holds if the cost of a creature stays roughly the same as the population grows, and there is one real reason it might not. Most of a creature's phase is its fan, and how far a ray runs depends on how far away the nearest thing is. Put ten times as many animals in ten times as much ground and the crowding is unchanged, so the rays run the same distance. Put ten times as many animals in the same ground and every ray stops sooner, so each creature gets cheaper while the population gets dearer. The run below holds the crowding fixed, which is the case where the division above is allowed to be a straight line.
In those terms: c is t over N, the capacity is B over c, and the whole estimate is trustworthy exactly as long as s stays put. B is a decision somebody made about the tick. N and s are counts off the run. Only t was measured, and t is the one number on this page that belongs to a machine and not to a valley.
A prediction is cheap. The bench founds the valley at one, two, five and ten times over, holding the crowding at one creature to five cells, and runs each of them, so the closing arithmetic of this volume is a run instead of a multiplication.
$ go run ./cmd/crowd -mode fit -ticks 30
crowd: the valley at one, two, five and ten times over, 30 ticks each
across down cells creatures ray steps multiply-adds
150 100 15000 3000 366925 1080000
150 200 30000 6000 740366 2160000
150 500 75000 15000 1891008 5400000
150 1000 150000 30000 3804210 10800000
the counts are facts about the valley: the same four rows on any machine
Read the ray steps against the creatures. Three thousand animals take 366,925 steps, which is 122 apiece. Thirty thousand animals in ten times the ground take 3,804,210, which is 127 apiece. Four per cent more, over a tenfold change in size, which is the crowding holding still exactly as the interlude said it would. The multiply-adds column is a straight multiple by construction: a creature is a creature and its network has 360 multiplications in it whatever else is happening.
$ go run ./cmd/crowd -mode fit -ticks 30 -clock | tail -8 (measured on an eight-core Ryzen 7 3700X, Go 1.26, Linux; yours will differ) across down cells creatures ray steps multiply-adds a tick 150 100 15000 3000 366925 1080000 3.689ms 150 200 30000 6000 740366 2160000 7.953ms 150 500 75000 15000 1891008 5400000 21.114ms 150 1000 150000 30000 3804210 10800000 43.379ms the counts are facts about the valley: the same four rows on any machine the times were measured here on eight goroutines and yours will differ
Ten times the valley, thirty thousand creatures, 43.4 milliseconds. Inside a fifty millisecond budget with about an eighth of it left, on this machine. The straight-line estimate from the interlude said 36,000 animals would fill the budget and the run says something a little under that, which is the right sort of disagreement: a prediction off one measurement that lands within a few per cent of a run ten times the size is a prediction doing its job.
Which leaves the promise this volume was built on, and state it in counts, with the stopwatch beside them and not in front of them. Three thousand creatures. 2,731 of them still walking after a hundred and twenty ticks. 352,595 creature-ticks handed out. 349,694 ray steps on the closing tick. 1,080,000 multiplications in the forward passes, one pass per creature per tick that creature was free to choose. Every one of those numbers is the same whether the phase runs on one goroutine or sixteen, and the digest says so to the bit. What that cost here, on an eight-core Ryzen 7 3700X running Go 1.26 on Linux, was 3.71 milliseconds of the fifty the phase is allowed, and yours will differ. Three thousand brains inside half a tick, with forty-six milliseconds to spare.
The measured budget
Four changes went into the phase and each of them is an instance of something general. Addressing a grid by hashing a pair of integers is paying for a lookup that the geometry already gave you for free. Building an index every tick and dropping it is paying for memory you are about to ask for again. Building a second copy of something because two callers need it is a missing seam. Asking a function for a sentence when you wanted a yes is paying to write something nobody reads. None of the four is about creatures, and all four turned up in a profile and not in anybody's judgement, which is the point of taking one.
The parallel split is the one that generalises the least comfortably, so state its condition plainly. A pass may go wide when every one of its workers reads only things that nothing in the pass writes, and writes only into memory no other worker touches. Casting the fans passes that test on every clause but one, and the one exception, a body that stops standing part-way through, is handled by naming the cells where it happened and doing the few affected fans again in order. That repair is not a nicety. Without it, the run is repeatable, arrives at the same digest every time on every crew size, and is not this valley's run: the worst kind of wrong, because everything about it looks like it is working.
And the digest is what makes any of that checkable. A hundred and twenty ticks of a three-thousand-creature valley is far too much to read. Folded into sixteen characters, it is one line, and the question "is this the same run" becomes a string comparison that a test can make on every commit.
- Handed the count of multiply-adds in a tick and a profile of that tick, say which of the two the phase is actually spending itself on, and name the runtime function a coordinate-keyed map shows up as.
- Say what moving the index from maps to flat rows bought and what it did not, and why an allocation ledger cannot see that particular change at all.
- Name the bounds test a flat cell index needs that a map handed over for nothing, and describe the cell a creature at the western edge would find without it.
- Say which one part of a creature's tick may be handed to another goroutine, and name the one thing a fan reads that a creature ahead of it in the roster can change.
- Shown a split phase whose digest differs from the narrow one by the same amount on one goroutine and on sixteen, say what that rules out and where to look instead.
- Given one measured phase, work out how many creatures a stated budget holds, and state the assumption about crowding that makes the division a straight line rather than a guess.
Exercise 1 — the ten-times valley on paper. Before running
anything, work out how many multiply-adds and how many ray steps thirty thousand
creatures come to at the sight cap, then check with
go run ./cmd/crowd -mode sums -herd 30000 -rows 1000.
Both are straight multiples, because both are counts about one creature repeated: 360 multiplications each is 10,800,000, and 432 ray steps each is 12,960,000. The interesting part is that the second figure never happens. The measured run at that size took 3,804,210 steps, under a third of the cap, because a valley crowded at one creature to five cells is a valley where a ray finds something after about fourteen steps. The cap is what one creature can cost. The crowd is what it does cost.
$ go run ./cmd/crowd -mode sums -herd 30000 -rows 1000 | tail -6
multiply-adds in the forward passes 10800000
ray steps at the sight cap 12960000
cells of ground under them 150000
a million multiply-adds is the small number on this page
Exercise 2 — take the deaths away. The worked failure
blamed the loose split on creatures dying mid-phase. Test that claim by running a
stretch short enough that nobody dies:
go run ./cmd/crowd -mode crew -ticks 20 -loose. Predict the column
first.
Every row says yes. Twenty ticks is not long enough for any of the three thousand stores to reach nothing, so no creature is ever marked dead in the middle of a phase, so nothing a fan reads changes between the top of the phase and a creature's turn, so casting every fan up front is exactly the same run as casting them one at a time. The loose phase is not wrong in general: it is wrong in every valley where something dies, which is every valley anybody would actually run.
$ go run ./cmd/crowd -mode crew -ticks 20 -loose
crowd: 150x100 valley, 3000 creatures, 20 ticks, on one crew after another
the phase run narrow, in roster order, closes on d2c88fdd1181ddee
the fans below are cast wide and never looked at again
crew digest the same run
1 d2c88fdd1181ddee yes
2 d2c88fdd1181ddee yes
4 d2c88fdd1181ddee yes
8 d2c88fdd1181ddee yes
16 d2c88fdd1181ddee yes
the digest covers the ground, every standing plant, and every
creature's place, heading, store and grams eaten, in roster order
Exercise 3 — a proof small enough to run on every commit.
The bench proves the repair is needed with three thousand creatures and takes several
seconds. Write the same proof as a test in internal/beast, and decide what
it has to assert before it earns a place.
Forty creatures over six hundred ticks in the small valley is enough, and it runs in a tenth of a second. The assertion people get wrong is the shape of it. Checking that the loose run differs from the narrow one is not enough on its own, because a valley where nobody dies would pass that check by producing no difference at all and the test would quietly stop testing anything. So it asserts twice: that the loose run does differ, and that two loose runs on different crew sizes agree with each other, which is what says the difference came from lifting the looking out of the loop rather than from a race.
$ go test -count=1 -v -run TestFansCastWideNeedLookingAtAgain ./internal/beast/
=== RUN TestFansCastWideNeedLookingAtAgain
--- PASS: TestFansCastWideNeedLookingAtAgain (0.10s)
PASS
ok theworld/internal/beast 0.100s
The Hollow now holds three thousand animals, each carrying a body with a mass and a store, nine lines of sight, a row of twenty-four numbers and three hundred and seventy-eight weights of its own, and the whole population thinks and acts inside a fraction of the time it was given. That is the volume finished. It is also, read honestly, a valley of three thousand creatures that are not any good and cannot become good. A brain is drawn from a stream on the day its animal is founded and is the brain that animal dies with. Nothing compares one creature's afternoon with another's. Nothing is born: the roster only ever gets shorter, 269 of these three thousand went into the litter in a hundred and twenty ticks, and the ones still walking are not better animals than the ones that stopped, only luckier ones. The machine can now afford thirty thousand creatures. It cannot yet afford a single one that improves.