Skip to content

Commit 6c34e24

Browse files
Improve size calculation and memoization (#1431)
1 parent 1ad169d commit 6c34e24

9 files changed

Lines changed: 531 additions & 220 deletions

File tree

common/types/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ go_library(
2222
"format.go",
2323
"list.go",
2424
"map.go",
25+
"memory_tracker.go",
2526
"native.go",
2627
"null.go",
2728
"object.go",
@@ -72,6 +73,7 @@ go_test(
7273
"json_struct_test.go",
7374
"list_test.go",
7475
"map_test.go",
76+
"memory_tracker_test.go",
7577
"native_test.go",
7678
"null_test.go",
7779
"object_test.go",

common/types/list.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,17 @@ func (l *baseList) AggregateSize(sizer AggregateSizer) uint32 {
274274
if sz := atomic.LoadUint32(&l.aggSize); sz != 0 {
275275
return sz
276276
}
277-
total := uint32(1)
278-
for i := range l.size {
279-
total = safeAddUint32(total, sizer.AggregateSize(l.get(i)))
277+
var total uint32
278+
if l.value != nil {
279+
if t, ok := getSliceElementsAggregateSize(sizer, l.value); ok {
280+
total = t
281+
}
282+
}
283+
if total == 0 {
284+
total = uint32(1)
285+
for i := range l.size {
286+
total = safeAddUint32(total, sizer.AggregateSize(l.get(i)))
287+
}
280288
}
281289
if cacheableAggregateSize(sizer) {
282290
atomic.StoreUint32(&l.aggSize, total)
@@ -365,6 +373,7 @@ type concatList struct {
365373
prevList traits.Lister
366374
nextList traits.Lister
367375
cachedSize ref.Val
376+
aggSize uint32
368377
}
369378

370379
func newConcatList(adapter Adapter, prevList, nextList traits.Lister) ref.Val {
@@ -499,7 +508,14 @@ func (l *concatList) Size() ref.Val {
499508

500509
// AggregateSize implements the AggregateSizeVisitor interface method.
501510
func (l *concatList) AggregateSize(sizer AggregateSizer) uint32 {
502-
return safeAddUint32(sizer.AggregateSize(l.prevList), sizer.AggregateSize(l.nextList))
511+
if sz := atomic.LoadUint32(&l.aggSize); sz != 0 {
512+
return sz
513+
}
514+
total := safeAddUint32(sizer.AggregateSize(l.prevList), sizer.AggregateSize(l.nextList))
515+
if cacheableAggregateSize(sizer) {
516+
atomic.StoreUint32(&l.aggSize, total)
517+
}
518+
return total
503519
}
504520

505521
// String converts the concatenated list to a human-readable string.

common/types/map.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -314,12 +314,21 @@ func (m *baseMap) AggregateSize(sizer AggregateSizer) uint32 {
314314
if sz := atomic.LoadUint32(&m.aggSize); sz != 0 {
315315
return sz
316316
}
317-
f := foldableAggregateSizer{sizer: sizer, total: 1}
318-
m.Fold(&f)
317+
var total uint32
318+
if m.value != nil {
319+
if t, ok := getMapElementsAggregateSize(sizer, m.value); ok {
320+
total = t
321+
}
322+
}
323+
if total == 0 {
324+
f := foldableAggregateSizer{sizer: sizer, total: 1}
325+
m.Fold(&f)
326+
total = f.total
327+
}
319328
if cacheableAggregateSize(sizer) {
320-
atomic.StoreUint32(&m.aggSize, f.total)
329+
atomic.StoreUint32(&m.aggSize, total)
321330
}
322-
return f.total
331+
return total
323332
}
324333

325334
// String converts the map into a human-readable string.
@@ -331,7 +340,7 @@ func (m *baseMap) String() string {
331340
for it.HasNext() == True {
332341
k := it.Next()
333342
v, _ := m.Find(k)
334-
sb.WriteString(fmt.Sprintf("%v: %v", k, v))
343+
fmt.Fprintf(&sb, "%v: %v", k, v)
335344
if i != m.size-1 {
336345
sb.WriteString(", ")
337346
}
@@ -710,7 +719,8 @@ func (a *stringIfaceMapAccessor) Fold(f traits.Folder) {
710719
// accessing protoreflect.Map values.
711720
type protoMap struct {
712721
Adapter
713-
value *pb.Map
722+
value *pb.Map
723+
aggSize uint32
714724
}
715725

716726
// Contains returns whether the map contains the given key.
@@ -935,12 +945,18 @@ func (m *protoMap) AggregateSize(sizer AggregateSizer) uint32 {
935945
if m.value == nil {
936946
return 0
937947
}
948+
if sz := atomic.LoadUint32(&m.aggSize); sz != 0 {
949+
return sz
950+
}
938951
total := uint32(1)
939952
m.value.Range(func(k protoreflect.MapKey, v protoreflect.Value) bool {
940953
total = safeAddUint32(total, sizer.AggregateSize(k))
941954
total = safeAddUint32(total, sizer.AggregateSize(v))
942955
return true
943956
})
957+
if cacheableAggregateSize(sizer) {
958+
atomic.StoreUint32(&m.aggSize, total)
959+
}
944960
return total
945961
}
946962

common/types/memory_tracker.go

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414

1515
package types
1616

17+
import (
18+
"cel.dev/cel-go/common/types/ref"
19+
)
20+
1721
const (
1822
defaultMemoryTrackerSampleInterval = 1
1923
)
@@ -27,7 +31,8 @@ type MemoryTrackerOption func(*MemoryTracker)
2731
// tracking observations and terminate evaluation as appropriate.
2832
func MemoryTrackerLimit(limit uint32) MemoryTrackerOption {
2933
return func(t *MemoryTracker) {
30-
t.limit = &limit
34+
t.limit = limit
35+
t.hasLimit = true
3136
}
3237
}
3338

@@ -57,24 +62,23 @@ func MemoryTrackerSizeCalculator(calc *SizeCalculator) MemoryTrackerOption {
5762
//
5863
// Memory is measured in aggregate element counts as computed by a SizeCalculator, with all
5964
// arithmetic saturating at math.MaxUint32. The tracker is independent of any interpreter
60-
// implementation; evaluators feed it observations at points where values materialize:
65+
// implementation; evaluators feed it observations at the points where values materialize
66+
// during evaluation, such as resolved attributes, call results, constructed aggregates, and
67+
// values built up within comprehensions or bind initializers.
6168
//
62-
// - inputs and outputs of calls (e.g. the target and arguments of a.join(', '))
63-
// - resolved attribute values (e.g. the value of a.b.c)
64-
// - values built up within literal blocks, comprehensions, and bind initializers
65-
//
66-
// The peak is the largest single observation, where one Track call observes a set of
67-
// coexistent values as a single watermark.
69+
// The peak is the largest single observation, where one Track call observes a value
70+
// as a watermark.
6871
//
6972
// A MemoryTracker is stateful and intended for use by a single evaluation at a time; it is
7073
// not safe for concurrent use.
7174
type MemoryTracker struct {
7275
version int
7376
calc *SizeCalculator
74-
limit *uint32
77+
limit uint32
78+
hasLimit bool
7579
sampleInterval uint32
7680

77-
sampleCount uint32
81+
sampleCounts map[int64]uint32
7882
peak uint32
7983
calcLimitExceeded bool
8084
}
@@ -100,41 +104,60 @@ func (t *MemoryTracker) Version() int {
100104
return t.version
101105
}
102106

103-
// Track observes a set of coexistent values as a single watermark, returning their combined
104-
// aggregate size with saturation at math.MaxUint32.
105-
//
106-
// Call sites with multiple live values, such as the input arguments to a function call,
107-
// should be tracked in a single call so the watermark reflects their combined footprint.
108-
//
109-
// Within observers, consider whether to choose the aggregated argument sizes, the result size,
110-
// or both when working with allocating operations.
111-
func (t *MemoryTracker) Track(vals ...any) uint32 {
112-
total := uint32(0)
113-
for _, val := range vals {
114-
est := t.calc.EstimateAggregateSize(val)
115-
if est.LimitExceeded {
116-
t.calcLimitExceeded = true
107+
// Track observes a value as a watermark, returning its aggregate size with saturation at math.MaxUint32.
108+
func (t *MemoryTracker) Track(val ref.Val) uint32 {
109+
if val == nil {
110+
return 0
111+
}
112+
switch v := val.(type) {
113+
case Bool, Int, Uint, Double, Duration, Timestamp, Null, *Type, *Err, *Unknown:
114+
if 1 > t.peak {
115+
t.peak = 1
117116
}
118-
total = safeAddUint32(total, est.Size)
117+
return 1
118+
case String:
119+
sz := t.calc.stringSize(len(v))
120+
if sz > t.peak {
121+
t.peak = sz
122+
}
123+
return sz
124+
case Bytes:
125+
sz := t.calc.stringSize(len(v))
126+
if sz > t.peak {
127+
t.peak = sz
128+
}
129+
return sz
130+
}
131+
132+
est := t.calc.ApproximateAggregateSize(val)
133+
if est.LimitExceeded {
134+
t.calcLimitExceeded = true
119135
}
120-
if total > t.peak {
121-
t.peak = total
136+
if est.Size > t.peak {
137+
t.peak = est.Size
122138
}
123-
return total
139+
return est.Size
124140
}
125141

126-
// Sample observes a value subject to the tracker's sample interval, returning the value's
127-
// aggregate size when computed, or zero when the observation is skipped.
142+
// Sample observes a value for a specific node ID subject to the tracker's sample interval,
143+
// returning the value's aggregate size when computed, or zero when the observation is skipped.
144+
//
145+
// The first observation of any node ID is always tracked; subsequent observations are sampled
146+
// at multiples of the sample interval.
128147
//
129148
// Sample is intended for high-frequency observation points, such as accumulator values built
130149
// up by comprehension loops or bind initializers, where sizing every iteration would be
131150
// prohibitively expensive.
132-
func (t *MemoryTracker) Sample(val any) uint32 {
133-
t.sampleCount++
134-
if t.sampleInterval > 1 && t.sampleCount%t.sampleInterval != 0 {
135-
return 0
151+
func (t *MemoryTracker) Sample(id int64, val ref.Val) uint32 {
152+
if t.sampleCounts == nil {
153+
t.sampleCounts = make(map[int64]uint32)
154+
}
155+
t.sampleCounts[id]++
156+
count := t.sampleCounts[id]
157+
if count == 1 || t.sampleInterval <= 1 || count%t.sampleInterval == 0 {
158+
return t.Track(val)
136159
}
137-
return t.Track(val)
160+
return 0
138161
}
139162

140163
// Peak returns the largest single watermark observed, saturating at math.MaxUint32.
@@ -145,7 +168,7 @@ func (t *MemoryTracker) Peak() uint32 {
145168
// ExceedsLimit indicates whether the peak observed memory exceeds the configured limit.
146169
// When no limit is configured, ExceedsLimit always returns false.
147170
func (t *MemoryTracker) ExceedsLimit() bool {
148-
return t.limit != nil && t.peak > *t.limit
171+
return t.hasLimit && t.peak > t.limit
149172
}
150173

151174
// CalculationLimitExceeded indicates whether any tracked value was too expensive to size,

common/types/memory_tracker_test.go

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,18 @@ func TestMemoryTrackerTrack(t *testing.T) {
3737
}
3838
})
3939

40-
t.Run("call_inputs_combined_watermark", func(t *testing.T) {
41-
// Models the inputs to a.join(', ') where the list and separator coexist.
40+
t.Run("consecutive_watermarks", func(t *testing.T) {
4241
tracker := NewMemoryTracker()
43-
list := NewRefValList(adapter, []ref.Val{String("a"), String("b")})
44-
sep := String(", ")
45-
if got := tracker.Track(list, sep); got != 4 {
46-
t.Errorf("Track(list, sep) got %d, want 4 (3 list + 1 separator)", got)
42+
list := NewRefValList(adapter, []ref.Val{Int(1), Int(2)})
43+
if got := tracker.Track(list); got != 3 {
44+
t.Errorf("Track(list) got %d, want 3", got)
4745
}
48-
if got := tracker.Peak(); got != 4 {
49-
t.Errorf("Peak() got %d, want 4", got)
46+
arg := Int(0)
47+
if got := tracker.Track(arg); got != 1 {
48+
t.Errorf("Track(arg) got %d, want 1", got)
49+
}
50+
if got := tracker.Peak(); got != 3 {
51+
t.Errorf("Peak() got %d, want 3", got)
5052
}
5153
})
5254

@@ -63,18 +65,18 @@ func TestMemoryTrackerTrack(t *testing.T) {
6365
// Models the output of a + a, a string twice the size of its inputs.
6466
tracker := NewMemoryTracker()
6567
in := String(strings.Repeat("a", 50))
66-
tracker.Track(in, in)
68+
tracker.Track(in)
6769
out := String(strings.Repeat("a", 100))
6870
tracker.Track(out)
6971
if got := tracker.Peak(); got != 10 {
7072
t.Errorf("Peak() got %d, want 10 (100-char output at 10 chars per unit)", got)
7173
}
7274
})
7375

74-
t.Run("saturating_sum", func(t *testing.T) {
76+
t.Run("saturating_value", func(t *testing.T) {
7577
tracker := NewMemoryTracker()
7678
big := customSizerVal(math.MaxUint32)
77-
if got := tracker.Track(big, big); got != math.MaxUint32 {
79+
if got := tracker.Track(big); got != math.MaxUint32 {
7880
t.Errorf("Track() got %d, want MaxUint32", got)
7981
}
8082
if got := tracker.Peak(); got != math.MaxUint32 {
@@ -104,7 +106,7 @@ func TestMemoryTrackerSample(t *testing.T) {
104106
t.Run("default_interval_samples_every_value", func(t *testing.T) {
105107
tracker := NewMemoryTracker()
106108
for i := 0; i < 3; i++ {
107-
if got := tracker.Sample(Int(i)); got != 1 {
109+
if got := tracker.Sample(1, Int(i)); got != 1 {
108110
t.Errorf("Sample() got %d, want 1", got)
109111
}
110112
}
@@ -116,23 +118,48 @@ func TestMemoryTrackerSample(t *testing.T) {
116118
t.Run("interval_skips_intermediate_samples", func(t *testing.T) {
117119
tracker := NewMemoryTracker(MemoryTrackerSampleInterval(3))
118120
list := NewRefValList(adapter, []ref.Val{Int(1), Int(2)})
119-
if got := tracker.Sample(list); got != 0 {
120-
t.Errorf("Sample() #1 got %d, want 0 (skipped)", got)
121+
if got := tracker.Sample(1, list); got != 3 {
122+
t.Errorf("Sample() #1 got %d, want 3 (computed on first observation)", got)
121123
}
122-
if got := tracker.Sample(list); got != 0 {
124+
if got := tracker.Sample(1, list); got != 0 {
123125
t.Errorf("Sample() #2 got %d, want 0 (skipped)", got)
124126
}
125-
if got := tracker.Sample(list); got != 3 {
127+
if got := tracker.Sample(1, list); got != 3 {
126128
t.Errorf("Sample() #3 got %d, want 3 (computed)", got)
127129
}
128130
if got := tracker.Peak(); got != 3 {
129131
t.Errorf("Peak() got %d, want 3", got)
130132
}
131133
})
132134

135+
t.Run("per_id_tracking", func(t *testing.T) {
136+
tracker := NewMemoryTracker(MemoryTrackerSampleInterval(2))
137+
list := NewRefValList(adapter, []ref.Val{Int(1), Int(2)})
138+
// id 1: sample 1 (computed on first observation)
139+
if got := tracker.Sample(1, list); got != 3 {
140+
t.Errorf("Sample(1) #1 got %d, want 3", got)
141+
}
142+
// id 2: sample 1 (computed on first observation)
143+
if got := tracker.Sample(2, list); got != 3 {
144+
t.Errorf("Sample(2) #1 got %d, want 3", got)
145+
}
146+
// id 1: sample 2 (computed, multiple of 2)
147+
if got := tracker.Sample(1, list); got != 3 {
148+
t.Errorf("Sample(1) #2 got %d, want 3", got)
149+
}
150+
// id 2: sample 2 (computed, multiple of 2)
151+
if got := tracker.Sample(2, list); got != 3 {
152+
t.Errorf("Sample(2) #2 got %d, want 3", got)
153+
}
154+
// id 1: sample 3 (skipped)
155+
if got := tracker.Sample(1, list); got != 0 {
156+
t.Errorf("Sample(1) #3 got %d, want 0", got)
157+
}
158+
})
159+
133160
t.Run("zero_interval_clamped_to_one", func(t *testing.T) {
134161
tracker := NewMemoryTracker(MemoryTrackerSampleInterval(0))
135-
if got := tracker.Sample(Int(1)); got != 1 {
162+
if got := tracker.Sample(1, Int(1)); got != 1 {
136163
t.Errorf("Sample() got %d, want 1", got)
137164
}
138165
})
@@ -172,4 +199,5 @@ func TestMemoryTrackerVersion(t *testing.T) {
172199

173200
// Interface conformance check: the tracker's calculator remains usable as an AggregateSizer
174201
// by visitor implementations.
202+
var _ ref.Val = customSizerVal(0)
175203
var _ traits.Sizer = customSizerVal(0)

0 commit comments

Comments
 (0)