Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
048d252
docs: parser improvement plan
jcoene Jun 19, 2026
e3870da
perf(bench): in-memory benchmark rig + recent canonical replay
jcoene Jun 19, 2026
f968107
perf(packet): reuse pendingMessage value-slice, stable sort
jcoene Jun 19, 2026
638122a
perf(parser): return outerMessage by value
jcoene Jun 19, 2026
1b00c34
perf(parser): reuse snappy scratch buffer for outer messages
jcoene Jun 19, 2026
3116d5b
perf(modifier): skip emission entirely when no handler registered
jcoene Jun 19, 2026
a29739f
perf(entity): reuse per-packet tuple buffer and entity-data reader
jcoene Jun 19, 2026
729d63c
perf(entity): share field-path name caches on the class
jcoene Jun 19, 2026
16bc0aa
perf(reader): hoist debug verbosity check out of the field loop
jcoene Jun 19, 2026
a354e5f
perf(stream): buffer non-byte-reader inputs (e.g. *os.File)
jcoene Jun 19, 2026
769aad1
perf(fieldpath): reuse a value-type field-path buffer (eliminate 56%-…
jcoene Jun 19, 2026
54c074f
perf(reader): refill the bit accumulator a word at a time
jcoene Jun 19, 2026
142f22a
docs: skip P1.11 (varints-from-accumulator breaks reader pos contract)
jcoene Jun 19, 2026
59f33d6
perf(fieldpath): flatten the field-path huffman tree into int arrays
jcoene Jun 19, 2026
b11b3c2
perf(fieldpath): resolve field-path ops via an 8-bit lookup table
jcoene Jun 19, 2026
0673269
perf(entity): decode each class baseline once and clone per entity
jcoene Jun 19, 2026
a0eeecf
test(reader): guard zero-copy readBytes invariant
jcoene Jun 19, 2026
5d02c35
fix(stringtable): additive non-increment index (was absolute)
jcoene Jun 19, 2026
cd44c63
fix(stringtable): surface parse errors instead of swallowing them
jcoene Jun 19, 2026
e5e3881
fix(gameevent): correct off-by-one bound in getEventKey
jcoene Jun 19, 2026
66d7cd4
fix(modifier): skip empty/deleted ActiveModifiers entries
jcoene Jun 19, 2026
eaf131f
fix(gameevent): resolve combat-log type via descriptor, not key[0]
jcoene Jun 19, 2026
ca3fade
feat(decoder): forward-compat decoders + 64-bit int64
jcoene Jun 19, 2026
ec1cb0f
fix(decoder): align HSequence and HeroID_t with clarity
jcoene Jun 19, 2026
fa4170d
fix(decoder): decode BloodType as a fixed 8-bit value
jcoene Jun 19, 2026
647a5ba
feat(decoder): handle QAngle precise/noscale/pitch-yaw raw-float cases
jcoene Jun 19, 2026
d4e8f57
fix(patch): guard mana/runetime patches on the sentinel bounds
jcoene Jun 19, 2026
8bf31ce
fix(parser): bound outer-message size before allocating
jcoene Jun 19, 2026
6eb5c2c
docs(fieldpath): name the max field-path depth invariant
jcoene Jun 19, 2026
cfcc6a8
docs: phase 2 (correctness) complete
jcoene Jun 19, 2026
fbca7ed
perf(entity): store field values in a typed cell, box lazily on read
jcoene Jun 20, 2026
40da8cc
fix(entity): deep-copy mutable baseline leaves in clone
jcoene Jun 22, 2026
ff0e2a1
fix(parser): clear reused tuple/pending buffers after dispatch
jcoene Jun 22, 2026
69b3aaa
fix(reader): guard skipBits against underflow on truncated streams
jcoene Jun 22, 2026
4e9a173
fix(reader): correct debug position() after word-at-a-time refill
jcoene Jun 22, 2026
1b3d8ab
test(decoder): lock value-changing decoder representations (Decision A)
jcoene Jun 22, 2026
9d530d5
docs: phase 4 cumulative cost (cooled re-measure)
jcoene Jun 22, 2026
4fbc1fe
fix(parser): clear reused buffers on error paths too
jcoene Jun 22, 2026
7f3f403
chore: remove IMPROVEMENTS.md planning doc
jcoene Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@ testnew:
go test -cover -run=TestMatchNew -v

bench:
go test -run=XXX -bench=BenchmarkMatch -benchtime=1m -v
go test -run=XXX -bench=BenchmarkMatch8552595443 -benchmem -benchtime=10x -count=10 -v

cover:
go test -cover -coverpkg github.com/dotabuff/manta,github.com/dotabuff/manta/vbkv -coverprofile /tmp/manta.cov -v
go tool cover -html=/tmp/manta.cov

cpuprofile:
go test -v -run=TestMatch2159568145 -test.cpuprofile=/tmp/manta.cpuprof
go test -v -run=TestMatchNew8552595443 -test.cpuprofile=/tmp/manta.cpuprof
go tool pprof -svg -output=/tmp/manta.cpuprof.svg manta.test /tmp/manta.cpuprof
open /tmp/manta.cpuprof.svg

memprofile:
go test -v -run=TestMatch2159568145 -test.memprofile=/tmp/manta.memprof -test.memprofilerate=1
go test -v -run=TestMatchNew8552595443 -test.memprofile=/tmp/manta.memprof -test.memprofilerate=1
go tool pprof --alloc_space manta.test /tmp/manta.memprof

update: update-protobufs generate
Expand Down
12 changes: 12 additions & 0 deletions class.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ type class struct {
classId int32
name string
serializer *serializer

// fpCache and fpNoop memoize name -> field-path resolution, which is
// class-invariant. They are shared by every entity of the class so a name
// is resolved once per class instead of once per entity.
fpCache map[string]*fieldPath
fpNoop map[string]bool
}

func (c *class) getNameForFieldPath(fp *fieldPath) string {
Expand Down Expand Up @@ -67,6 +73,8 @@ func (p *Parser) onCDemoClassInfo(m *dota.CDemoClassInfo) error {
classId: classId,
name: networkName,
serializer: p.serializers[networkName],
fpCache: make(map[string]*fieldPath),
fpNoop: make(map[string]bool),
}
p.classesById[class.classId] = class
p.classesByName[class.name] = class
Expand Down Expand Up @@ -101,4 +109,8 @@ func (p *Parser) updateInstanceBaseline() {
}
p.classBaselines[classId] = item.Value
}

// Decoded baseline templates may now be stale; drop them so they are
// re-decoded lazily from the updated bytes on the next entity creation.
clear(p.classBaselineStates)
}
39 changes: 25 additions & 14 deletions demo_packet.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type pendingMessage struct {
}

// Calculates the priority of the message. Lower is more important.
func (m *pendingMessage) priority() int {
func (m pendingMessage) priority() int {
switch m.t {
case
// These messages provide context needed for the rest of the tick
Expand Down Expand Up @@ -42,7 +42,7 @@ func (m *pendingMessage) priority() int {
}

// Provides a sortable structure for storing messages in the same packet.
type pendingMessages []*pendingMessage
type pendingMessages []pendingMessage

func (ms pendingMessages) Len() int { return len(ms) }
func (ms pendingMessages) Swap(i, j int) { ms[i], ms[j] = ms[j], ms[i] }
Expand All @@ -60,9 +60,12 @@ func (ms pendingMessages) Less(i, j int) bool {
// multiple inner packets from a single CDemoPacket. This is the main structure
// that contains all other data types in the demo file.
func (p *Parser) onCDemoPacket(m *dota.CDemoPacket) error {
// Create a slice to store pending mesages. Messages are read first as
// pending messages then sorted before dispatch.
ms := make(pendingMessages, 0, 2)
// Reuse a parser-level buffer to store pending messages. Messages are read
// first as pending messages then sorted before dispatch. onCDemoPacket is
// never re-entrant (it is dispatched only via callByDemoType, never nested
// within a callByPacketType call), so a single reused backing array is safe
// and avoids a heap allocation per embedded message.
ms := p.pendingMsgBuf[:0]

// Read all messages from the buffer. Messages are packed serially as
// {type, size, data}. We keep reading until until less than a byte remains.
Expand All @@ -71,21 +74,29 @@ func (p *Parser) onCDemoPacket(m *dota.CDemoPacket) error {
t := int32(r.readUBitVar())
size := r.readVarUint32()
buf := r.readBytes(size)
ms = append(ms, &pendingMessage{p.Tick, t, buf})
ms = append(ms, pendingMessage{p.Tick, t, buf})
}

// Sort messages to ensure dependencies are met. For example, we need to
// process string tables before game events that may reference them.
sort.Sort(ms)

// Dispatch messages in order, returning on handler error.
for _, m := range ms {
if err := p.Callbacks.callByPacketType(m.t, m.buf); err != nil {
return err
// process string tables before game events that may reference them. A
// stable sort keeps equal-priority messages in their original file order
// and avoids the reflection allocations of sort.Sort's interface path.
sort.Stable(ms)

// Dispatch messages in order, stopping on handler error.
var err error
for i := range ms {
if err = p.Callbacks.callByPacketType(ms[i].t, ms[i].buf); err != nil {
break
}
}

return nil
// Release the inner-packet buffer references and keep the slice at length
// zero so the reused backing array does not retain packet data. This runs on
// every path (success or error), before the single return.
clear(ms)
p.pendingMsgBuf = ms[:0]
return err
}

// Internal parser for callback OnCDemoFullPacket.
Expand Down
85 changes: 51 additions & 34 deletions entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,25 +49,21 @@ type EntityHandler func(*Entity, EntityOp) error

// Entity represents a single game entity in the replay
type Entity struct {
index int32
serial int32
class *class
active bool
state *fieldState
fpCache map[string]*fieldPath
fpNoop map[string]bool
index int32
serial int32
class *class
active bool
state *fieldState
}

// newEntity returns a new entity for the given index, serial and class
func newEntity(index, serial int32, class *class) *Entity {
return &Entity{
index: index,
serial: serial,
class: class,
active: true,
state: newFieldState(),
fpCache: make(map[string]*fieldPath),
fpNoop: make(map[string]bool),
index: index,
serial: serial,
class: class,
active: true,
state: newFieldState(),
}
}

Expand All @@ -92,20 +88,20 @@ func (e *Entity) Dump() {

// Get returns the current value of the Entity state for the given key
func (e *Entity) Get(name string) interface{} {
if fp, ok := e.fpCache[name]; ok {
if fp, ok := e.class.fpCache[name]; ok {
return e.state.get(fp)
}
if e.fpNoop[name] {
if e.class.fpNoop[name] {
return nil
}

fp := newFieldPath()
if !e.class.getFieldPathForName(fp, name) {
e.fpNoop[name] = true
e.class.fpNoop[name] = true
fp.release()
return nil
}
e.fpCache[name] = fp
e.class.fpCache[name] = fp

return e.state.get(fp)
}
Expand Down Expand Up @@ -218,9 +214,17 @@ func (p *Parser) FilterEntity(fb func(*Entity) bool) []*Entity {
return entities
}

// entityOpTuple pairs an entity with the operation performed on it. Updates are
// buffered during a PacketEntities message, then dispatched to handlers.
type entityOpTuple struct {
e *Entity
op EntityOp
}

// Internal Callback for OnCSVCMsg_PacketEntities.
func (p *Parser) onCSVCMsg_PacketEntities(m *dota.CSVCMsg_PacketEntities) error {
r := newReader(m.GetEntityData())
r := &p.entityReader
r.reset(m.GetEntityData())

var index = int32(-1)
var updates = int(m.GetUpdatedEntries())
Expand All @@ -237,11 +241,7 @@ func (p *Parser) onCSVCMsg_PacketEntities(m *dota.CSVCMsg_PacketEntities) error
p.entityFullPackets++
}

type tuple struct {
e *Entity
op EntityOp
}
tuples := make([]tuple, 0, updates)
tuples := p.entityTuples[:0]

for ; updates > 0; updates-- {
index += int32(r.readUBitVar()) + 1
Expand All @@ -259,15 +259,24 @@ func (p *Parser) onCSVCMsg_PacketEntities(m *dota.CSVCMsg_PacketEntities) error
_panicf("unable to find new class %d", classId)
}

baseline := p.classBaselines[classId]
// Decode the class baseline once into a reusable template and
// clone it for each new entity, instead of re-decoding the raw
// baseline bytes on every creation.
baseline := p.classBaselineStates[classId]
if baseline == nil {
_panicf("unable to find new baseline %d", classId)
raw := p.classBaselines[classId]
if raw == nil {
_panicf("unable to find new baseline %d", classId)
}
baseline = newFieldState()
p.fpBuf = readFields(newReader(raw), class.serializer, baseline, p.fpBuf)
p.classBaselineStates[classId] = baseline
}

e = newEntity(index, serial, class)
e.state = baseline.clone()
p.entities[index] = e
readFields(newReader(baseline), class.serializer, e.state)
readFields(r, class.serializer, e.state)
p.fpBuf = readFields(r, class.serializer, e.state, p.fpBuf)
op = EntityOpCreated | EntityOpEntered

} else {
Expand All @@ -281,7 +290,7 @@ func (p *Parser) onCSVCMsg_PacketEntities(m *dota.CSVCMsg_PacketEntities) error
op |= EntityOpEntered
}

readFields(r, e.class.serializer, e.state)
p.fpBuf = readFields(r, e.class.serializer, e.state, p.fpBuf)
}

} else {
Expand All @@ -300,18 +309,26 @@ func (p *Parser) onCSVCMsg_PacketEntities(m *dota.CSVCMsg_PacketEntities) error
}
}

tuples = append(tuples, tuple{e, op})
tuples = append(tuples, entityOpTuple{e, op})
}

var err error
dispatch:
for _, h := range p.entityHandlers {
for _, t := range tuples {
if err := h(t.e, t.op); err != nil {
return err
for i := range tuples {
if err = h(tuples[i].e, tuples[i].op); err != nil {
break dispatch
}
}
}

return nil
// Release the entity references and keep the buffer at length zero so a
// reused backing array does not retain (possibly deleted) entities and their
// state. This runs on every path (success or error), before the single
// return.
clear(tuples)
p.entityTuples = tuples[:0]
return err
}

// OnEntity registers an EntityHandler that will be called when an entity
Expand Down
6 changes: 3 additions & 3 deletions field.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ func (f *field) getFieldPaths(fp *fieldPath, state *fieldState) []*fieldPath {
if sub, ok := state.get(fp).(*fieldState); ok {
fp.last++
for i, v := range sub.state {
if v != nil {
if !v.isEmpty() {
fp.path[fp.last] = i
x = append(x, fp.copy())
}
Expand All @@ -271,7 +271,7 @@ func (f *field) getFieldPaths(fp *fieldPath, state *fieldState) []*fieldPath {
if sub, ok := state.get(fp).(*fieldState); ok {
fp.last++
for i, v := range sub.state {
if v != nil {
if !v.isEmpty() {
fp.path[fp.last] = i
x = append(x, fp.copy())
}
Expand All @@ -283,7 +283,7 @@ func (f *field) getFieldPaths(fp *fieldPath, state *fieldState) []*fieldPath {
if sub, ok := state.get(fp).(*fieldState); ok {
fp.last += 2
for i, v := range sub.state {
if vv, ok := v.(*fieldState); ok {
if vv, ok := v.sub(); ok {
fp.path[fp.last-1] = i
x = append(x, f.serializer.getFieldPaths(fp, vv)...)
}
Expand Down
Loading
Loading