Skip to content

Commit 7a803e1

Browse files
committed
perf: cut per-fire and per-verify allocations in the state engine
matchingTransitions returns the specific or wildcard slice directly when only one kind is present, so the steady-state bubble path no longer allocates a merged slice. activeParallelAncestor presizes its scratch map from the leaf count, and runActions builds the formatted effect label and probes the comm microstep only in full trace mode, keeping the lite hot path free of the Sprintf and effectLabel allocation. Verify rounds the machine through its public IR once and threads it into the topology, search-graph, and config-graph builders instead of re-serializing per builder. The config graph caches its inverted child index at build time so leavesUnder no longer rebuilds and re-sorts it on every call. A new CompileChecked binds a stored rich AST to its environment once for reuse across evaluations, with EvalCheckedAST now a one-shot wrapper over it. Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent 0ba99fa commit 7a803e1

8 files changed

Lines changed: 222 additions & 49 deletions

File tree

state/expr/coverage_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,61 @@ func TestEvalCheckedAST_RejectsBadBytes(t *testing.T) {
4545
}
4646
}
4747

48+
// TestCompileChecked_ReusesProgramAcrossEvals asserts a CompiledChecked built once
49+
// evaluates many context values and agrees with the one-shot EvalCheckedAST on each,
50+
// proving the cached program path is equivalent to the rebuild-every-call path.
51+
func TestCompileChecked_ReusesProgramAcrossEvals(t *testing.T) {
52+
reg := state.NewRegistry[order]()
53+
cat := expr.NewCatalog()
54+
if _, err := expr.Guard[string](reg, "big", `total > 100.0`, orderSchema(), expr.WithCatalog(cat)); err != nil {
55+
t.Fatalf("Guard: %v", err)
56+
}
57+
entry, ok := cat.Entry("big")
58+
if !ok {
59+
t.Fatal("catalog recorded no entry")
60+
}
61+
62+
compiled, err := expr.CompileChecked(entry.CheckedAST, orderSchema())
63+
if err != nil {
64+
t.Fatalf("CompileChecked: %v", err)
65+
}
66+
67+
for _, tc := range []struct {
68+
total float64
69+
want bool
70+
}{
71+
{total: 50, want: false},
72+
{total: 150, want: true},
73+
{total: 100, want: false},
74+
{total: 101, want: true},
75+
} {
76+
entity := order{Total: tc.total}
77+
got, err := compiled.Eval(entity)
78+
if err != nil {
79+
t.Fatalf("Eval(total=%v): %v", tc.total, err)
80+
}
81+
if got != tc.want {
82+
t.Fatalf("Eval(total=%v) = %v, want %v", tc.total, got, tc.want)
83+
}
84+
// The reusable program agrees with the rebuild-every-call helper.
85+
oneShot, err := expr.EvalCheckedAST(entry.CheckedAST, orderSchema(), entity)
86+
if err != nil {
87+
t.Fatalf("EvalCheckedAST(total=%v): %v", tc.total, err)
88+
}
89+
if oneShot != got {
90+
t.Fatalf("CompiledChecked.Eval=%v disagrees with EvalCheckedAST=%v at total=%v", got, oneShot, tc.total)
91+
}
92+
}
93+
}
94+
95+
// TestCompileChecked_RejectsBadBytes asserts malformed checked-AST bytes fail at
96+
// compile time rather than at evaluation.
97+
func TestCompileChecked_RejectsBadBytes(t *testing.T) {
98+
if _, err := expr.CompileChecked([]byte{0xff, 0xfe, 0xfd}, orderSchema()); err == nil {
99+
t.Fatal("malformed checked-AST bytes should fail to compile")
100+
}
101+
}
102+
48103
// TestLoadCatalog_RejectsMalformedSidecar asserts a sidecar that is not an object,
49104
// and one whose checked-AST is not valid base64, are both rejected.
50105
func TestLoadCatalog_RejectsMalformedSidecar(t *testing.T) {

state/expr/program.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,23 +96,55 @@ func checkedASTBytes(ast *cel.Ast) ([]byte, error) {
9696
// The AST is rebound against the schema-derived env so its variables resolve; an env
9797
// whose variables do not match the AST's declarations surfaces as an eval error.
9898
func EvalCheckedAST(checkedAST []byte, schema state.ContextSchema, entity any) (bool, error) {
99-
ast, err := astFromCheckedBytes(checkedAST)
99+
compiled, err := CompileChecked(checkedAST, schema)
100100
if err != nil {
101101
return false, err
102102
}
103+
return compiled.Eval(entity)
104+
}
105+
106+
// CompiledChecked is a rich AST that has been rebuilt and bound to its
107+
// schema-derived environment exactly once, ready to evaluate against many context
108+
// values. EvalCheckedAST rebuilds the env and program on every call, which is fine
109+
// for a one-shot tooling probe but wasteful when the same stored AST is replayed
110+
// repeatedly; CompileChecked pays that cost once and Eval reuses the program. The
111+
// type is immutable after construction and its Eval is safe for the same
112+
// synchronous, single-evaluator use as a celGuard.
113+
type CompiledChecked struct {
114+
program cel.Program
115+
schema state.ContextSchema
116+
}
117+
118+
// CompileChecked rebuilds a program from stored canonical cel.dev/expr CheckedExpr
119+
// bytes and binds it to the schema-derived environment once, returning a reusable
120+
// CompiledChecked. It performs the same env-rebuild and program-build EvalCheckedAST
121+
// does, so a malformed AST or an env whose variables do not match the AST's
122+
// declarations surfaces here rather than per evaluation.
123+
func CompileChecked(checkedAST []byte, schema state.ContextSchema) (*CompiledChecked, error) {
124+
ast, err := astFromCheckedBytes(checkedAST)
125+
if err != nil {
126+
return nil, err
127+
}
103128
env, err := newEnv(schema)
104129
if err != nil {
105-
return false, fmt.Errorf("rebuild env: %w", err)
130+
return nil, fmt.Errorf("rebuild env: %w", err)
106131
}
107132
program, err := env.Program(ast, cel.CostLimit(defaultCostLimit))
108133
if err != nil {
109-
return false, fmt.Errorf("rebuild program: %w", err)
134+
return nil, fmt.Errorf("rebuild program: %w", err)
110135
}
111-
activation, err := marshalActivation(entity, schema)
136+
return &CompiledChecked{program: program, schema: schema}, nil
137+
}
138+
139+
// Eval projects the entity into the bound environment and evaluates the compiled
140+
// program, returning its boolean verdict. It reuses the program built by
141+
// CompileChecked, so repeated evaluations skip the env/AST/program rebuild.
142+
func (c *CompiledChecked) Eval(entity any) (bool, error) {
143+
activation, err := marshalActivation(entity, c.schema)
112144
if err != nil {
113145
return false, err
114146
}
115-
out, _, err := program.Eval(activation)
147+
out, _, err := c.program.Eval(activation)
116148
if err != nil {
117149
return false, fmt.Errorf("eval: %w", err)
118150
}

state/fire.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,10 @@ func (i *Instance[S, E, C]) fireSpine(ctx context.Context, event E, tr Trace) Fi
422422
// events outrank the wildcard — and the wildcard outranks bubbling to an
423423
// ancestor.
424424
func matchingTransitions[S comparable, E comparable, C any](s *State[S, E, C], event E) []*Transition[S, E, C] {
425+
// The overwhelmingly common case is a state with one or more specific matches
426+
// and no wildcard. Collect specifics first; only allocate a second slice and
427+
// merge when a wildcard is actually present, so the steady-state path returns a
428+
// single slice with no merge allocation. A state with no matches returns nil.
425429
var specific, wild []*Transition[S, E, C]
426430
for ti := range s.Transitions {
427431
t := &s.Transitions[ti]
@@ -435,7 +439,15 @@ func matchingTransitions[S comparable, E comparable, C any](s *State[S, E, C], e
435439
specific = append(specific, t)
436440
}
437441
}
438-
return append(specific, wild...)
442+
if len(wild) == 0 {
443+
return specific
444+
}
445+
if len(specific) == 0 {
446+
return wild
447+
}
448+
out := make([]*Transition[S, E, C], 0, len(specific)+len(wild))
449+
out = append(out, specific...)
450+
return append(out, wild...)
439451
}
440452

441453
// forbids reports whether state s explicitly forbids event: a transition marked
@@ -756,9 +768,15 @@ func (i *Instance[S, E, C]) runActions(refs []Ref, entity C, tr *Trace) (effects
756768
return effects, a.Name, aerr
757769
}
758770
effects = append(effects, e)
759-
tr.recordEffect(fmt.Sprintf("%s:%s", a.Name, effectLabel(e)))
760-
if ms, ok := commMicrostep(e); ok {
761-
tr.note(ms)
771+
// recordEffect/note are no-ops in lite mode, so build the formatted effect
772+
// label and probe the comm microstep only when the full trace will keep them.
773+
// This keeps the default (lite) hot path free of the Sprintf and effectLabel
774+
// allocation on every action.
775+
if tr.full {
776+
tr.recordEffect(fmt.Sprintf("%s:%s", a.Name, effectLabel(e)))
777+
if ms, ok := commMicrostep(e); ok {
778+
tr.note(ms)
779+
}
762780
}
763781
}
764782
return effects, "", nil

state/parallel.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ func (i *Instance[S, E, C]) activeParallelAncestor() (S, bool) {
2020
return zero, false
2121
}
2222
// Candidate parallel states are the ancestors shared by the config leaves.
23-
seen := map[S]int{}
23+
// Presize from the leaf count so the map does not grow-and-rehash while the
24+
// orthogonal config is scanned. This runs only when already in a parallel
25+
// configuration (len(config) >= 2); flat machines return above without
26+
// allocating.
27+
seen := make(map[S]int, len(i.config))
2428
for _, leaf := range i.config {
2529
for _, anc := range m.ancestors(leaf) {
2630
n, ok := m.resolveNode(anc)

state/verify/invariant.go

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -118,33 +118,64 @@ type configGraph struct {
118118
// edges maps a source state to its path-advancing outgoing transitions in
119119
// declaration order, so exploration is deterministic.
120120
edges map[string][]searchEdge
121+
// children inverts parent into a sorted child list per state, computed once at
122+
// build time. leavesUnder walks it on the hot successors path, so caching it
123+
// avoids rebuilding and re-sorting the whole index on every call.
124+
children map[string][]string
121125
}
122126

123127
// buildConfigGraph flattens the machine's public IR into a configGraph. A machine
124128
// whose IR cannot be read yields the zero graph (hasInitial false) rather than
125129
// panicking, matching Verify's no-panic contract.
126130
func buildConfigGraph[S comparable, E comparable, C any](m *state.Machine[S, E, C]) configGraph {
127-
g := configGraph{
128-
parent: map[string]string{},
129-
leaf: map[string]bool{},
130-
compoundInitial: map[string]string{},
131-
regionInitials: map[string][]string{},
132-
edges: map[string][]searchEdge{},
133-
}
134131
ir, ok := loadIR(m)
135132
if !ok {
136-
return g
133+
return emptyConfigGraph()
137134
}
135+
return buildConfigGraphFromIR(ir)
136+
}
137+
138+
// buildConfigGraphFromIR builds the configGraph from an already-loaded IR, so a
139+
// caller that round-tripped the machine once can reuse it.
140+
func buildConfigGraphFromIR[S comparable, E comparable, C any](ir *state.IR[S, E, C]) configGraph {
141+
g := emptyConfigGraph()
138142
if ir.HasInitial {
139143
g.hasInitial = true
140144
g.initial = fmt.Sprint(ir.Initial)
141145
}
142146
for i := range ir.States {
143147
collectConfig(&ir.States[i], "", &g)
144148
}
149+
g.children = buildChildIndex(g.parent)
145150
return g
146151
}
147152

153+
// emptyConfigGraph returns a configGraph with initialized maps and no states.
154+
func emptyConfigGraph() configGraph {
155+
return configGraph{
156+
parent: map[string]string{},
157+
leaf: map[string]bool{},
158+
compoundInitial: map[string]string{},
159+
regionInitials: map[string][]string{},
160+
edges: map[string][]searchEdge{},
161+
}
162+
}
163+
164+
// buildChildIndex inverts the parent map into a sorted child list per state, so a
165+
// subtree walk is deterministic and does not range a map directly.
166+
func buildChildIndex(parent map[string]string) map[string][]string {
167+
idx := map[string][]string{}
168+
for name, p := range parent {
169+
if p != "" {
170+
idx[p] = append(idx[p], name)
171+
}
172+
}
173+
for p := range idx {
174+
sort.Strings(idx[p])
175+
}
176+
return idx
177+
}
178+
148179
// collectConfig records one state's structure — parent, leaf-ness, compound and
149180
// region initial children, and path-advancing edges — and recurses through its
150181
// children and region states in declaration order.
@@ -348,15 +379,14 @@ func (g configGraph) successors(leaves []string) []configStep {
348379
// it is every descendant leaf, found by walking the parent relation (which covers
349380
// both compound children and region states).
350381
func (g configGraph) leavesUnder(node string) map[string]bool {
351-
children := g.childIndex()
352382
out := map[string]bool{}
353383
var walk func(n string)
354384
walk = func(n string) {
355385
if g.leaf[n] {
356386
out[n] = true
357387
return
358388
}
359-
for _, c := range children[n] {
389+
for _, c := range g.children[n] {
360390
walk(c)
361391
}
362392
}
@@ -367,21 +397,6 @@ func (g configGraph) leavesUnder(node string) map[string]bool {
367397
return out
368398
}
369399

370-
// childIndex inverts the parent map into a sorted child list per state, so a
371-
// subtree walk is deterministic and does not range a map directly.
372-
func (g configGraph) childIndex() map[string][]string {
373-
idx := map[string][]string{}
374-
for name, p := range g.parent {
375-
if p != "" {
376-
idx[p] = append(idx[p], name)
377-
}
378-
}
379-
for p := range idx {
380-
sort.Strings(idx[p])
381-
}
382-
return idx
383-
}
384-
385400
// invariantFor decides a single invariant over an already-built exploration,
386401
// returning the finding. The exploration is walked in breadth-first discovery
387402
// order so the reported counterexample is the nearest violating configuration,

state/verify/ir.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,17 @@ type topology struct {
2525
// cannot be read yields the zero topology rather than panicking, matching
2626
// Verify's no-panic contract.
2727
func readTopology[S comparable, E comparable, C any](m *state.Machine[S, E, C]) topology {
28-
t := topology{parent: map[string]string{}}
2928
ir, ok := loadIR(m)
3029
if !ok {
31-
return t
30+
return topology{parent: map[string]string{}}
3231
}
32+
return topologyFromIR(ir)
33+
}
34+
35+
// topologyFromIR builds a topology from an already-loaded IR, so a caller that has
36+
// round-tripped the machine once can reuse it instead of re-serializing.
37+
func topologyFromIR[S comparable, E comparable, C any](ir *state.IR[S, E, C]) topology {
38+
t := topology{parent: map[string]string{}}
3339
for i := range ir.States {
3440
collectStates(&ir.States[i], "", &t)
3541
}
@@ -57,7 +63,15 @@ func collectStates[S comparable, E comparable, C any](s *state.State[S, E, C], p
5763
// declares no initial state.
5864
func initialName[S comparable, E comparable, C any](m *state.Machine[S, E, C]) string {
5965
ir, ok := loadIR(m)
60-
if !ok || !ir.HasInitial {
66+
if !ok {
67+
return ""
68+
}
69+
return initialNameFromIR(ir)
70+
}
71+
72+
// initialNameFromIR returns the initial state name from an already-loaded IR.
73+
func initialNameFromIR[S comparable, E comparable, C any](ir *state.IR[S, E, C]) string {
74+
if !ir.HasInitial {
6175
return ""
6276
}
6377
return fmt.Sprint(ir.Initial)

state/verify/reach.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,17 @@ type searchEdge struct {
6565
// whose IR cannot be read yields the zero graph (hasInitial false) rather than
6666
// panicking, matching Verify's no-panic contract.
6767
func buildSearchGraph[S comparable, E comparable, C any](m *state.Machine[S, E, C]) searchGraph {
68-
g := searchGraph{
69-
nodes: map[string]bool{},
70-
parent: map[string]string{},
71-
initialChildren: map[string][]string{},
72-
edges: map[string][]searchEdge{},
73-
}
7468
ir, ok := loadIR(m)
7569
if !ok {
76-
return g
70+
return emptySearchGraph()
7771
}
72+
return buildSearchGraphFromIR(ir)
73+
}
74+
75+
// buildSearchGraphFromIR builds the searchGraph from an already-loaded IR, so a
76+
// caller that round-tripped the machine once can reuse it.
77+
func buildSearchGraphFromIR[S comparable, E comparable, C any](ir *state.IR[S, E, C]) searchGraph {
78+
g := emptySearchGraph()
7879
if ir.HasInitial {
7980
g.hasInitial = true
8081
g.initial = fmt.Sprint(ir.Initial)
@@ -85,6 +86,16 @@ func buildSearchGraph[S comparable, E comparable, C any](m *state.Machine[S, E,
8586
return g
8687
}
8788

89+
// emptySearchGraph returns a searchGraph with initialized maps and no states.
90+
func emptySearchGraph() searchGraph {
91+
return searchGraph{
92+
nodes: map[string]bool{},
93+
parent: map[string]string{},
94+
initialChildren: map[string][]string{},
95+
edges: map[string][]searchEdge{},
96+
}
97+
}
98+
8899
// collectSearch records one state's structure — parent, initial-descent children,
89100
// and path-advancing edges — and recurses through its children and region states
90101
// in declaration order.

0 commit comments

Comments
 (0)