8000 Tags · dotabuff/manta · GitHub
[go: up one dir, main page]

Skip to content

Tags

Tags

v1.5.2

Toggle v1.5.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Parser performance improvements, round 3 (#183)

* P6.1: CDemoFullPacket fast envelope

Every CDemoFullPacket went through the reflective protobuf unmarshal,
which decoded its embedded CDemoStringTables dump in full: a message,
string pointer and bytes copy per string-table item, on every full
packet. The internal handler ignores that dump (onCDemoStringTables is a
no-op), so this was ~20% of all allocations for nothing.

When only the internal handler is registered, fastDemoFullPacket now
scans the envelope with protowire, skips string_table without decoding
it, and passes the nested packet data (aliasing the outer buffer, like
CDemoPacket) to processDemoPacket. Repeated packet fields keep
protobuf's merge semantics. A user OnCDemoFullPacket callback reverts to
the full path. TestFullPacketData checks the scan against proto.Unmarshal.

benchstat vs Phase 5 end (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   577.1m +/- 1%  ->  567.2m +/- 0%   -1.72%  (p=0.000)
  B/op     193.8Mi +/- 0% ->  169.1Mi +/- 0%  -12.74% (p=0.000)
  allocs   2.492M +/- 0%  ->  2.080M +/- 0%   -16.53% (p=0.000)

go test ./... PASS (identical goldens)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.2: zero-copy outer messages for NewParser

NewParser wrapped its buffer in a bytes.Reader, so every outer message
was copied through io.ReadFull into the stream's scratch buffer (which
also grew to the largest message seen). NewParser now builds the stream
over the buffer itself: readBytes returns cap-limited subslices and
readByte indexes directly. The parser only reads these slices (proto
unmarshal and the demo-packet arena copy anything retained), and the
io.ReadFull error contract (io.EOF at a clean end, ErrUnexpectedEOF on a
short read) is reproduced exactly; TestBufferStreamMatchesReader checks
the two streams agree on random read sequences. NewStreamParser is
unchanged.

The CPU profile's bytes.Reader memmove (~7%) was mostly the cost of
first touching the cold replay, which now moves to the next reader of
those bytes, so sec/op is flat. The scratch-buffer growth is gone.

benchstat vs P6.1 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   567.2m +/- 0%  ->  568.3m +/- 1%   ~       (p=0.353)
  B/op     169.1Mi +/- 0% ->  166.1Mi +/- 0%  -1.78%  (p=0.000)
  allocs   2.080M +/- 0%  ->  2.080M +/- 0%   -0.00%  (p=0.000)

go test ./... PASS (identical goldens); full-corpus output digest
(entity events, periodic state snapshots, string tables, game events,
via both NewParser and NewStreamParser) identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.3: inlinable bit-reader fast paths

readBits, peekBits and skipBits carried their refill loops (and, for
skipBits, a variadic _panicf) inline, which put them far over the
inliner's budget, so the field-path op lookup paid two full calls
(peekBits + skipBits) per op on top of readBits calls everywhere.

The refills now live in out-of-line fill/fillPeek and the skipBits
underflow panic in a noinline skipUnderflow, leaving small fast paths:
peekBits (cost 75) and skipBits (73) now inline into readFieldPaths.
readBits and readBoolean still contain a call (cost 57 alone) and stay
out of line, but with straight-line bodies. Bit semantics are
unchanged: the same word/byte refill logic, zero padding for peekBits,
and panics on truncated input.

benchstat vs P6.2 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   568.3m +/- 1%  ->  536.6m +/- 0%   -5.58%  (p=0.000)
  B/op     166.1Mi flat, allocs 2.080M flat (CPU-only change)

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.4: switch-dispatch field-path ops (stack accumulator, no pool)

readFieldPaths called each op through fieldPathTable's function values.
The indirect call forced the accumulator to escape, so every call
borrowed it from a sync.Pool (Get + Put per entity update), and every op
paid an indirect closure call even for a one-line PlusOne.

The ops are now named functions, keyed into fieldPathTable by op-id
constants, and readFieldPaths dispatches them through a switch of
direct calls. The simple ops inline, the rest are direct calls, and
escape analysis keeps the accumulator on the stack (no pool). The table
(names, weights, functions) is unchanged and remains the canonical
definition: TestReadFieldPathsMatchesTable decodes 2000 streams built
from random sequences of every op with random operands and checks
readFieldPaths against a tree-walk decoder applying fieldPathTable's
functions (paths and bits consumed); swapping a single case fails it.

benchstat vs P6.3 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   536.6m +/- 0%  ->  503.0m +/- 0%   -6.25%  (p=0.000)
  B/op     166.1Mi flat, allocs 2.080M flat (CPU-only change)

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.5: don't drop demo-packet messages buffered in the accumulator

processDemoPacket looped while r.remBytes() > 0. That matched the old
byte-at-a-time reader, which never held more than the rest of the
current byte, but the word-at-a-time refill (P1.10) can pull up to 8
bytes past the current message into the accumulator. When the trailing
messages of a packet fit in those bytes, r.pos reaches the end of the
buffer while whole messages are still buffered, and the loop silently
dropped them.

The loop now tests the logically unread bits (r.remBits() >= 8), which
is exactly the old byte-reader condition. remBits itself was wrong
(it added remBytes to bitCount) and had no callers; it now returns
remBytes*8 + bitCount. TestProcessDemoPacketReadsBufferedTail builds
packets with a shifted body followed by empty messages and failed for
several body lengths before the fix.

The corpus never hits this (instrumented: 0 of 1.97M packets end with
whole bytes buffered), so goldens and the digest are unchanged.

benchstat vs P6.4 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   503.0m +/- 0%  ->  501.9m +/- 0%   -0.22%  (p=0.023)
  B/op     166.1Mi flat, allocs 2.080M flat

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.6: decode varints and ubitvars from one accumulator peek

readVarUint32 took every byte of an unaligned varint through readByte
-> readBits(8), and readUBitVarFP / readUBitVar tested each flag or
group with its own readBoolean/readBits call: up to five calls per value
on the hottest decode paths (signed/unsigned decoders, PlusN and the
Push ops, entity index deltas).

Each now peeks the longest form at once (40, 35 and 34 bits) and
consumes only what the value used, which reads the same bits in the same
order. The byte-aligned varint path is untouched, so it still reads
straight from the buffer and leaves pos exact (the reader contract the
existing reader tests assert). Truncated input still panics, via
skipBits instead of nextByte.

TestReaderVarLengthMatchesReference compares all three against the
previous implementations on 20000 random buffers each, at random bit
offsets including reads past the end: same value, same bits consumed,
same follow-up read, same panic, and exact pos when aligned. Off-by-one
mutations of either fast path fail it.

benchstat vs P6.5 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   501.9m +/- 0%  ->  465.0m +/- 1%   -7.36%  (p=0.000)
  B/op     166.1Mi flat, allocs 2.080M flat (CPU-only change)

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.7: shift-merge unaligned readBytesInto

Every embedded message body in a CDemoPacket sits at a bit offset (after
the 6-bit ubitvar type), so processDemoPacket's arena copy went through
readBytesInto's unaligned path: a readBits(32) call (plus refills) per
four bytes, for essentially the whole replay.

The unaligned path now copies straight from the buffer: the bits at the
logical position are still in buf, so each output byte is buf[i]>>shift
| buf[i+1]<<(8-shift), done eight bytes per step with 64-bit loads and a
byte loop for the tail. The reader then resumes with the remainder of
the final byte in the accumulator, exactly as a byte-at-a-time reader
holds it. A copy past the end still panics, now with an explicit bounds
check up front.

TestReaderReadBytesIntoMatchesReference compares against the previous
implementation on 20000 random buffers at random bit offsets and lengths
(including overruns): same bytes, same bits consumed, same follow-up
reads, same panic. Mutating the merge shift or the resume state fails it.

benchstat, interleaved A/B vs P6.6 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   473.1m +/- 0%  ->  423.0m +/- 1%   -10.59% (p=0.000)
  B/op     166.1Mi flat, allocs 2.080M flat (CPU-only change)

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.8: alloc smalls (sort boxing, entity state, string-table pointers)

Three per-message/per-entity allocations with nothing to show for them:

- processDemoPacket's sort.Stable(ms) boxed the pending-message slice
  into a sort.Interface on every packet. slices.SortStableFunc with an
  equivalent tick-then-priority comparator sorts without boxing; both
  are stable, so the order is identical.
- newEntity allocated a fresh fieldState (struct + 8-cell array) that
  processPacketEntities immediately replaced with the baseline clone.
  newEntity now takes the state.
- parseStringTable built a []*stringTableItem alongside its item slab.
  It now returns the []stringTableItem slab itself; callers that retain
  an item keep a pointer into it, exactly as before.

benchstat, interleaved A/B vs P6.7 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   422.0m +/- 0%  ->  419.2m +/- 0%   -0.66%  (p=0.005)
  B/op     166.1Mi +/- 0% ->  160.5Mi +/- 0%  -3.38%  (p=0.000)
  allocs   2.080M +/- 0%  ->  1.980M +/- 0%   -4.79%  (p=0.000)

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.9: 16-byte cell (pointer + length instead of an interface)

cell was {ref interface{}; num uint32; kind} = 24 bytes. The interface's
type word is redundant with kind, and storing a string, []float32 or
[]byte in it costs a box (convTstring/convTslice) on top of the data on
every decode.

cell is now {ptr unsafe.Pointer; num uint32; kind} = 16 bytes. Strings,
vectors and blobs keep their data pointer in ptr and their length in
num (built and rebuilt with unsafe.StringData/SliceData and
unsafe.String/Slice); 64-bit integers are held inline when they fit in
32 bits and otherwise point at a heap copy; nested tables point at their
*fieldState. kind gives the exact original type, so iface() returns the
same dynamic types and values as before. Every entity state array, and
so every baseline clone and state growth, is a third smaller, and qangle
/ vector / string decodes drop their interface box.

TestDecoderRepresentations now also covers both sides of the inline vs
boxed boundary for uint64/int64, and string/blob/vector round trips;
TestCellSize locks 16 bytes.

Trade-off: Entity.Get on string, vector, blob and >32-bit fields now
boxes on read, like scalars have since P3.1 (scratch micro-benchmark:
string 8.1 -> 17.6 ns, vector 7.3 -> 19.4 ns, steam ID 8.6 -> 14.4 ns,
each +1 small alloc; float Get unchanged at 1 alloc). The parse saves
far more than a typical consumer's reads cost; the typed getters are
made alloc-free in the next commit.

benchstat, interleaved A/B vs P6.8 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   419.6m +/- 0%  ->  411.7m +/- 0%   -1.88%  (p=0.000)
  B/op     160.5Mi +/- 0% ->  121.4Mi +/- 0%  -24.33% (p=0.000)
  allocs   1.980M +/- 0%  ->  1.747M +/- 0%   -11.79% (p=0.000)

go test ./... PASS (identical goldens); full-corpus output digest
(including %T of every Map value) identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.10: alloc-free typed entity getters

GetInt32/GetUint32/GetUint64/GetFloat32/GetString/GetBool were defined
as a type assertion on Get, so each boxed the stored value first (a
float32 Get allocated, and after P6.9 so would a string or steam ID).

A shared getCell (the existing name -> field-path cache plus a cell
lookup) now backs Get, Exists and the typed getters; the getters read
the cell's kind and value directly and never box. Results are defined
exactly as before: TestEntityTypedGettersMatchGet checks every getter
and Exists against the Get-plus-assertion definition for every field of
every entity at the end of a replay, plus missing names.

Scratch micro-benchmark vs P6.8 (before the 16-byte cell), count=6:
  GetString   8.12 -> 7.40 ns   GetFloat32 12.49 -> 6.25 ns (1 -> 0 allocs)
  GetUint64   8.54 -> 7.96 ns   GetInt32    7.84 -> 6.07 ns
Plain Get keeps the P6.9 trade-off for reference values.

benchstat, interleaved A/B vs P6.9 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   412.6m +/- 3%  ->  410.4m +/- 4%   ~       (p=0.218)
  B/op     121.4Mi flat, allocs 1.747M flat (the bench reads no fields)

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.11: 32-byte fieldPath (int32 components, no done flag)

readFieldPaths snapshots the accumulator by value into the reused buffer
once per decoded field, and readFields then walks that buffer; with
[7]int path components, an int last and a bool done, each snapshot was
72 bytes.

Path components and last are now int32 (field and element indices are
small; every component comes from <= 31-bit reads), and the done flag
is gone: since P6.4 readFieldPaths stops on the FieldPathEncodeFinish op
id, so nothing reads it (the table's finish op is now a no-op and the
reference decoder in the tests stops on the op id too). fieldPath is
exactly 32 bytes, locked by TestFieldPathSize. The pos parameters of the
serializer/field path walks follow last to int32.

benchstat, interleaved A/B vs P6.10 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   414.9m +/- 0%  ->  406.3m +/- 1%   -2.08%  (p=0.000)
  B/op     121.4Mi +/- 0% ->  120.1Mi +/- 0%  -1.13%  (p=0.000)
  allocs   1.747M flat

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.12: readBitsAsBytes copies whole bytes with readBytesInto

String-table values are read with readBitsAsBytes, which filled its
result with a readBits(32) call per four bytes plus readByte per
remaining byte. The whole bytes are now copied with readBytesInto (the
P6.7 shift-merge copy when unaligned, a plain copy when aligned): bits
are LSB-first, so these are exactly the bytes readBits(8) yields. The
trailing partial byte is still read with readBits.

TestReaderReadBitsAsBytesMatchesReference compares against the previous
implementation on 20000 random buffers at random bit offsets and bit
lengths, including overruns (same bytes, bits consumed, follow-up read
and panic).

benchstat, interleaved A/B vs P6.11 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   407.9m +/- 0%  ->  402.3m +/- 0%   -1.37%  (p=0.001)
  B/op     120.1Mi flat, allocs 1.747M flat (CPU-only change)

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.13: direct closure for unflagged quantized floats

Quantized floats are ~29% of all field decodes (2.8M per replay on the
bench). Each went through the factory closure into
quantizedFloatDecoder.decode, a second non-inlined call that tests the
round-down/round-up/encode-zero flags before the scaled read.

When no such flag survives validation, quantizedFactory now returns a
closure that does the scaled read directly from captured values, using
decode's exact expression so results are bit-identical; flagged
decoders still go through decode. TestQuantizedFactoryMatchesDecode
compares the factory's decoders with decode over every bit count 1-31,
all flag combinations and several ranges on random input (cell bits and
bits consumed).

benchstat, interleaved A/B vs P6.12 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   400.4m +/- 1%  ->  392.9m +/- 2%   -1.87%  (p=0.002)
  B/op     120.1Mi flat, allocs 1.747M flat (CPU-only change)

go test ./... PASS (identical goldens, incl. exact hero mana floats);
full-corpus output digest identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.14: share baseline vector/blob leaves, copy on read

clone() deep-copied every vector ([]float32) and blob ([]byte) leaf of
the class baseline into each new entity (P4.1), so a caller mutating a
slice returned from Entity.Get/Map could not corrupt the template or
sibling entities. That is one allocation per baseline vector per entity
created, whether or not the value is ever read or is soon overwritten.

The 16-byte cell has a spare byte, so clones now share those leaves and
mark them shared; iface() hands out a copy of a shared value instead of
its backing array. The guarantee is the same (nothing returned from a
clone aliases template or sibling storage), the cost moves to reads of
never-updated baseline vectors, and decoding a new value replaces the
cell and its mark. TestCloneIsolatesMutableLeaves (new; P4.1 had no
test) mutates everything a clone hands out, including in nested states,
and checks the template and a sibling are untouched.

benchstat, interleaved A/B vs P6.13 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   391.3m +/- 2%  ->  393.2m +/- 1%   ~       (p=0.481)
  B/op     120.1Mi +/- 0% ->  119.5Mi +/- 0%  -0.43%  (p=0.000)
  allocs   1.747M +/- 0%  ->  1.713M +/- 0%   -1.95%  (p=0.000)

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.15: copy-on-write nested baseline states

Baseline clones still deep-copied every nested state of the class
template into each new entity (two slabs sized by a counting pre-pass),
whether or not the entity ever wrote into them; nested states a
10BC8
re about
a quarter of all cloned cells on the bench.

A decoded baseline is now marked once as a template (share: every
vector/blob leaf and nested state flagged shared), and clone copies only
the root cell array. set copies a shared nested state the first time it
descends into one (its own children stay shared until written), and
iface already hands out copies of shared vectors/blobs, so nothing a
clone exposes or modifies aliases template storage. The P5.7 clone slab
machinery (cloneSize/cloneInto/cloneArena) is gone. The template must
not change after share; templates are dropped, not edited, on an
instancebaseline update, and entities keep the old shared states alive.

TestCloneIsolatesMutableLeaves now also writes two levels deep through a
shared nested state; disabling the copy-on-write fails it.

benchstat, interleaved A/B vs P6.14 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   404.3m +/- 4%  ->  406.2m +/- 4%   ~       (p=0.684)
  B/op     119.5Mi +/- 0% ->  111.7Mi +/- 0%  -6.60%  (p=0.000)
  allocs   1.713M +/- 0%  ->  1.730M +/- 0%   +0.97%  (p=0.000)
(allocs: the lazy copies cost two small allocations each; the next
commit removes one allocation per entity created.)

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.16: hold the entity's root state inline

With copy-on-write clones (P6.15) creating an entity allocated the
Entity, a root fieldState, and the root cell array. The root fieldState
is now a value field of Entity, and newEntity clones the baseline
template straight into it (cloneTo), leaving one allocation for the
state: its root cell array.

benchstat, interleaved A/B vs P6.15 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   408.2m +/- 1%  ->  406.7m +/- 1%   ~       (p=0.353)
  B/op     111.6Mi +/- 0% ->  111.5Mi +/- 0%  -0.12%  (p=0.000)
  allocs   1.730M +/- 0%  ->  1.712M +/- 0%   -1.03%  (p=0.000)

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.17: keep Entity comparable (review fix)

P6.16 made Entity's root fieldState a value field. fieldState holds a
slice, so Entity stopped being a comparable type: code comparing Entity
values with ==, keying a map by Entity, or passing it as a comparable
type argument no longer compiled against this branch. Comparability is
part of the exported type's API.

Entity holds a *fieldState again, and newEntity co-allocates the Entity
and its root state in one entityAlloc block, so creating an entity still
costs one allocation for both (the P6.16 saving is kept).
TestEntityIsComparable is a compile-time guard (requireComparable[Entity])
and fails to build against P6.16.

benchstat, interleaved A/B vs P6.16 (BenchmarkMatch8552595443, 10x, count=10;
measured together with P6.18, which only widens arithmetic):
  sec/op   386.3m +/- 2%  ->  386.0m +/- 2%   ~       (p=0.529)
  B/op     111.5Mi +/- 0% ->  111.8Mi +/- 0%  +0.24%  (64-byte size class)
  allocs   1.712M flat

go test ./... PASS (identical goldens); full-corpus output digest
identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* P6.18: 64-bit bit positions in the reader (review fix)

remBits (P6.5) and the unaligned readBytesInto (P6.7) computed bit
positions as r.pos*8 in uint32, which overflows once a buffer passes
512 MiB. A decompressed packet that large (compressed input can expand
past the outer-message limit) would make readBytesInto copy from near
the start of the buffer, and remBits wrap, so processDemoPacket could
stop early.

Bit positions and remaining bit counts are now computed in 64 bits:
new bitPos(), remBits() returns uint64, and readBytesInto uses both
(the debug position() too). TestReaderLargeBufferOffsets reads past the
512 MiB mark in a just-over-512 MiB buffer; without this change it fails (remBits 1024
instead of 4294968320, first copied byte 00 instead of 09).

Perf: measured with P6.17 (flat). go test ./... PASS (identical goldens);
full-corpus output digest identical to pre-Phase-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

v1.5.1

Toggle v1.5.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Parser performance improvements, round 2 (#181)

* P5.0: Phase 5 plan + baseline (profile-driven, post-#180)

Fresh CPU/alloc profiles on master show a new profile shape after the
Phase 1-4 work: unaligned readBytes in onCDemoPacket (13.5% of allocs),
readBitsAsBytes growth (11.6%), protobuf envelope decode (~30% of allocs,
32.7% of alloc space in consumeBytes), fieldState.clone (19% of space),
and the p.entities map (~9% CPU).

Baseline (BenchmarkMatch8552595443, benchtime=10x, count=10, M4 Pro):
  sec/op  876.7m +/- 8%
  B/op    389.4Mi +/- 0%
  allocs  4.478M +/- 0%

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* P5.1: readBitsAsBytes exact prealloc + word fill

Allocate the exact (n+7)/8 result up front instead of growing a cap-0
slice byte-at-a-time, and fill via readBits(32) words with a byte/bit
tail. String-table values retain the slice, so one fresh allocation per
value is the floor; this removes the append-growth allocations on top.

benchstat vs P5.0 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   876.7m +/- 8%  ->  756.3m +/- 1%   -13.74% (p=0.000)*
  B/op     389.4Mi +/- 0% ->  384.4Mi +/- 0%  -1.29%  (p=0.000)
  allocs   4.478M +/- 0%  ->  4.181M +/- 0%   -6.64%  (p=0.000)

* P5.0 sec/op was thermally inflated (+/-8%); allocs/B are the reliable
signal for this change.

go test ./... PASS (identical goldens)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* P5.2: demo-packet arena + word-copy unaligned readBytes

The onCDemoPacket inner stream is bit-shifted after the leading 6-bit
readUBitVar, so nearly every embedded message body hit the unaligned
readBytes slow path: a fresh zeroed make([]byte, n) filled one
readBits(8) at a time (422K allocs, 49.5MB, plus most of the 8%
memclrNoHeapPointers CPU).

New reader.readBytesInto(dst) copies unaligned data a 32-bit word at a
time (readBytes' slow path routes through it), and onCDemoPacket carves
message buffers from a single parser-level arena sized to the packet and
reused across packets. Buffer lifetime matches pendingMsgBuf: dispatch
only, and the protobuf unmarshal copies what it keeps.

benchstat vs P5.1 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   756.3m +/- 1%  ->  676.7m +/- 1%   -10.52% (p=0.000)
  B/op     384.4Mi +/- 0% ->  336.3Mi +/- 0%  -12.52% (p=0.000)
  allocs   4.181M +/- 0%  ->  3.757M +/- 0%   -10.13% (p=0.000)

go test ./... PASS (identical goldens)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* P5.3: entities map -> dense slice

Entity indices are 14-bit by the handle encoding, and the PacketEntities
update loop did 1-3 map lookups per entity update (~9% CPU in
mapaccess1_fast32). Replace map[int32]*Entity with a dense []*Entity of
1<<indexBits slots (128 KB per parser).

FindEntity bounds-checks and keeps its signature. FilterEntity skips nil
slots, which also fixes a latent hazard: deletion stored nil into the
map, so FilterEntity could previously pass nil entities to callbacks.
Iteration is now deterministic by index instead of map-random.

benchstat vs P5.2 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   676.7m +/- 1%  ->  624.8m +/- 2%   -7.66% (p=0.000)
  B/op     336.3Mi flat, allocs 3.757M flat (CPU-only change)

go test ./... PASS (identical goldens)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* P5.4: string-table item slab + prealloc + ring history + single lookup

parseStringTable allocated a &stringTableItem per item plus cap-0 items
append growth, shifted the whole 32-entry key-history window per item
once full, and the UpdateStringTable apply loop did up to four map
lookups per item.

Preallocate items and back them with a single []stringTableItem slab
(cap bounded at 4096 so a corrupt numUpdates cannot OOM; pointer
identity survives slab regrowth because items are only touched through
the taken pointers). The key history becomes a fixed ring buffer with
histCount-based indexing that reproduces the shift-window semantics
exactly. The apply loop hoists to a single map lookup.

benchstat vs P5.3 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   624.8m +/- 2%  ->  618.7m +/- 4%   -0.98% (p=0.043)
  B/op     336.3Mi +/- 0% ->  335.0Mi +/- 0%  -0.38% (p=0.000)
  allocs   3.757M +/- 0%  ->  3.597M +/- 0%   -4.26% (p=0.000)

go test ./... PASS (identical goldens)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* P5.5: reader smalls: readLeUintX via accumulator, readString prealloc

Unaligned readLeUint32/64 allocated through the readBytes slow path (24K
objects via fixed64Decoder and friends). Read them straight from the bit
accumulator instead: bits are consumed LSB-first from little-endian
bytes, so readBits(32) equals the LE decode of the next four stream
bytes; readLeUint64 uses two accumulator words (readBits is capped at
32 bits per call). Aligned paths keep the zero-copy readBytes fast path
and the exact pos contract the reader tests assert.

readString starts at cap 32 instead of growing a cap-0 buffer per byte.

benchstat vs P5.4 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   618.7m +/- 4%  ->  618.8m +/- 2%   ~ (p=0.481)
  B/op     335.0Mi +/- 0% ->  334.5Mi +/- 0%  -0.16% (p=0.000)
  allocs   3.597M +/- 0%  ->  3.540M +/- 0%   -1.59% (p=0.000)

go test ./... PASS (identical goldens)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* P5.6: hand-rolled envelope decode for hot internal messages

Every CDemoPacket / CSVCMsg_PacketEntities / CSVCMsg_UpdateStringTable /
CNETMsg_Tick went through the reflective protobuf unmarshal: reflect.New
per message, a pointer alloc per scalar field, and - 32.7% of all
allocated bytes - a fresh copy of every bytes payload. The replay's data
was effectively copied twice through the proto layer.

envelope_fast.go decodes these four envelopes by hand with protowire,
aliasing the payload instead of copying, and calls scalar-arg core
methods split out of the internal handlers. Gating: NewParser registers
exactly one internal handler per list before returning, so
len(list) == 1 means no user callbacks are registered; any user
registration reverts that type to the full protobuf path, where the
user-visible message owns its own copies as before.

Aliasing lifetimes: entity_data/string_data alias the packet arena
(stable through the dispatch loop, consumed synchronously; string-table
values are copied out by readBitsAsBytes). CDemoPacket.data aliases the
outer-message buffer (stable until the next readOuterMessage;
processDemoPacket copies bodies into the arena before dispatch).

benchstat vs P5.5 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   618.8m +/- 2%  ->  580.5m +/- 0%   -6.18%  (p=0.000)
  B/op     334.5Mi +/- 0% ->  192.7Mi +/- 0%  -42.39% (p=0.000)
  allocs   3.540M +/- 0%  ->  2.615M +/- 0%   -26.13% (p=0.000)

go test ./... PASS (identical goldens; the goldens exercise the fast
path since tests register OnEntity/OnGameEvent, not raw message
callbacks)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* P5.7: slab-allocated baseline clone

clone() allocated a *fieldState plus a []cell array per nested state on
every entity create (210K objects on the profile). A counting pre-pass
now sizes two slabs ([]fieldState, []cell) and the copy carves from
them; vector/blob leaves keep their individual deep copies (P4.1
semantics). Cell slices are cap-limited so a later set() grow allocates
a fresh array instead of stomping a neighbouring state's arena region;
each entity's slabs are private and die with the entity.

benchstat vs P5.6 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   580.5m +/- 0%  ->  574.5m +/- 1%   -1.03% (p=0.001)
  B/op     192.7Mi +/- 0% ->  194.4Mi +/- 0%  +0.92% (p=0.000)
  allocs   2.615M +/- 0%  ->  2.508M +/- 0%   -4.10% (p=0.000)

The small B/op cost is slab retention overhead, traded for object count
(which drives GC scan work).

go test ./... PASS (identical goldens)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* P5.8: game-event eventid wire-peek

Every CMsgSource1LegacyGameEvent was fully unmarshaled (message plus a
reflect.New per key) before the internal handler checked whether any
handler is registered for the event name and usually returned nil.

skipGameEvent peeks the eventid varint (field 2) with protowire; a known
event with no registered handlers skips the unmarshal entirely. Unknown
ids and absent fields fall back to the full path, preserving its error
behaviour exactly.

benchstat vs P5.7 (BenchmarkMatch8552595443, 10x, count=10):
  sec/op   574.5m +/- 1%  ->  572.3m +/- 1%   ~ (p=0.052)
  B/op     194.4Mi +/- 0% ->  193.8Mi +/- 0%  -0.32% (p=0.000)
  allocs   2.508M +/- 0%  ->  2.492M +/- 0%   -0.63% (p=0.000)

Modest on this bench because it registers a combat-log handler and
combat-log events dominate the event stream; the skip is worth more to
consumers that register fewer (or no) game-event handlers.

go test ./... PASS (identical goldens)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* P5.9/P5.10: document skipped and rejected experiments; Phase 5 summary

P5.9 (prefix cache for the set/decoder walk across sorted field paths)
measured statistically flat on sec/op (p=0.481 vs a verified-clean P5.8
baseline) with identical allocs/B - most field paths in this workload
are depth-1, where the cache does nothing. Reverted per the
keep-only-if-confirmed rule.

P5.10 (widen cell to 32 bytes with inline vec2/vec3 float lanes) cut
allocs -17.9% but cost +12.0% B/op and +1.87% sec/op (p=0.000): the
wider cell inflates every state array and clone slab, and the extra
copy bytes outweigh the GC win, consistent with P3.1's earlier
32-byte-cell result. Reverted.

Phase 5 final (verified-clean re-measure at P5.8):
  sec/op  573.9m +/- 1%   (vs ~750-756m credible pre-phase level: ~-24%)
  B/op    193.8Mi         (-50.2% vs P5.0)
  allocs  2.492M          (-44.3% vs P5.0)

Cumulative vs the original pre-Phase-1 baseline: sec -62%, B/op -75%,
allocs -88%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove IMPROVEMENTS.md from the repository

The Phase 5 plan and results are preserved in the commit messages and
kept locally; the tracking document does not belong in the repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

v1.5.0

Toggle v1.5.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Parser performance & correctness improvements (#180)

v1.4.7

Toggle v1.4.7's commit message
re-enable string table test

v1.4.6

Toggle v1.4.6's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Add missing VectorWS type in new replays (#173)

v1.4.5

Toggle v1.4.5's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Update protos (#166)

* update protos

* make generate

v1.4.4

Toggle v1.4.4's commit message
update protos

v1.4.3

Toggle v1.4.3's commit message
update protos

v1.4.2

Toggle v1.4.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
Dota 7.33 support (#149)

v1.4.1

Toggle v1.4.1's commit message
add new type mapping with test

0