Repository navigation
Tags
Tags
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>
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>
PreviousNext