diff --git a/ast/term.go b/ast/term.go index 79e21ccae6..00f25f49b5 100644 --- a/ast/term.go +++ b/ast/term.go @@ -1840,17 +1840,22 @@ func ObjectTerm(o ...[2]*Term) *Term { } func LazyObject(blob map[string]interface{}) Object { - return &lazyObj{native: blob} + return &lazyObj{native: blob, cache: map[string]Value{}} } type lazyObj struct { strict Object + cache map[string]Value native map[string]interface{} } func (l *lazyObj) force() Object { if l.strict == nil { l.strict = MustInterfaceToValue(l.native).(Object) + // NOTE(jf): a possible performance improvement here would be to check how many + // entries have been realized to AST in the cache, and if some threshold compared to the + // total number of keys is exceeded, realize the remaining entries and set l.strict to l.cache. + l.cache = map[string]Value{} // We don't need the cache anymore; drop it to free up memory. } return l.strict } @@ -1924,13 +1929,20 @@ func (l *lazyObj) Get(k *Term) *Term { return l.strict.Get(k) } if s, ok := k.Value.(String); ok { + if v, ok := l.cache[string(s)]; ok { + return NewTerm(v) + } + if val, ok := l.native[string(s)]; ok { + var converted Value switch val := val.(type) { case map[string]interface{}: - return NewTerm(&lazyObj{native: val}) + converted = LazyObject(val) default: - return NewTerm(MustInterfaceToValue(val)) + converted = MustInterfaceToValue(val) } + l.cache[string(s)] = converted + return NewTerm(converted) } } return nil @@ -1985,13 +1997,20 @@ func (l *lazyObj) Find(path Ref) (Value, error) { return l, nil } if p0, ok := path[0].Value.(String); ok { + if v, ok := l.cache[string(p0)]; ok { + return v.Find(path[1:]) + } + if v, ok := l.native[string(p0)]; ok { + var converted Value switch v := v.(type) { case map[string]interface{}: - return (&lazyObj{native: v}).Find(path[1:]) + converted = LazyObject(v) default: - return MustInterfaceToValue(v).Find(path[1:]) + converted = MustInterfaceToValue(v) } + l.cache[string(p0)] = converted + return converted.Find(path[1:]) } } return nil, errFindNotFound diff --git a/ast/term_bench_test.go b/ast/term_bench_test.go index 30ec222462..0854357975 100644 --- a/ast/term_bench_test.go +++ b/ast/term_bench_test.go @@ -32,6 +32,35 @@ func BenchmarkObjectLookup(b *testing.B) { } } +func BenchmarkObjectFind(b *testing.B) { + sizes := []int{5, 50, 500, 5000} + for _, n := range sizes { + for _, m := range sizes { + b.Run(fmt.Sprintf("%d_%d", n, m), func(b *testing.B) { + obj := NewObject() + for i := 0; i < n; i++ { + arr := NewArray() + for j := 0; j < m; j++ { + arr = arr.Append(IntNumberTerm(j)) + } + obj.Insert(StringTerm(fmt.Sprint(i)), NewTerm(arr)) + } + key := Ref{StringTerm(fmt.Sprint(n - 1)), IntNumberTerm(m - 1)} + b.ResetTimer() + for i := 0; i < b.N; i++ { + value, err := obj.Find(key) + if err != nil { + b.Fatal(err) + } + if value == nil { + b.Fatal("expected hit") + } + } + }) + } + } +} + func BenchmarkObjectCreationAndLookup(b *testing.B) { sizes := []int{5, 50, 500, 5000, 50000, 500000} for _, n := range sizes { @@ -51,6 +80,57 @@ func BenchmarkObjectCreationAndLookup(b *testing.B) { } } +func BenchmarkLazyObjectLookup(b *testing.B) { + sizes := []int{5, 50, 500, 5000} + for _, n := range sizes { + b.Run(fmt.Sprint(n), func(b *testing.B) { + data := make(map[string]interface{}, n) + for i := 0; i < n; i++ { + data[fmt.Sprint(i)] = i + } + obj := LazyObject(data) + key := StringTerm(fmt.Sprint(n - 1)) + b.ResetTimer() + for i := 0; i < b.N; i++ { + value := obj.Get(key) + if value == nil { + b.Fatal("expected hit") + } + } + }) + } +} + +func BenchmarkLazyObjectFind(b *testing.B) { + sizes := []int{5, 50, 500, 5000} + for _, n := range sizes { + for _, m := range sizes { + b.Run(fmt.Sprintf("%d_%d", n, m), func(b *testing.B) { + data := make(map[string]interface{}, n) + for i := 0; i < n; i++ { + arr := make([]string, 0, m) + for j := 0; j < m; j++ { + arr = append(arr, fmt.Sprint(j)) + } + data[fmt.Sprint(i)] = arr + } + obj := LazyObject(data) + key := Ref{StringTerm(fmt.Sprint(n - 1)), IntNumberTerm(m - 1)} + b.ResetTimer() + for i := 0; i < b.N; i++ { + value, err := obj.Find(key) + if err != nil { + b.Fatal(err) + } + if value == nil { + b.Fatal("expected hit") + } + } + }) + } + } +} + func BenchmarkSetCreationAndLookup(b *testing.B) { sizes := []int{5, 50, 500, 5000, 50000, 500000} for _, n := range sizes { diff --git a/ast/term_test.go b/ast/term_test.go index 0e8f50478d..130f14c59d 100644 --- a/ast/term_test.go +++ b/ast/term_test.go @@ -1221,6 +1221,47 @@ func TestLazyObjectGet(t *testing.T) { assertForced(t, x, false) } +func TestLazyObjectGetCache(t *testing.T) { + x := LazyObject(map[string]interface{}{ + "a": true, + "b": false, + "d": map[string]interface{}{ + "e": "f", + "f": "g", + }, + }) + + // Assert that non-objects are cached + + y := x.Get(StringTerm("a")) + + if x.(*lazyObj).cache["a"].Compare(y.Value) != 0 { + t.Errorf("expected cache to be populated with retreived value") + } + + if x.(*lazyObj).cache["b"] != nil { + t.Errorf("expected cache to not be populated with non-retrieved value") + } + + // Assert that objects are cached as lazy objects + + y = x.Get(StringTerm("d")) + + expected := NewObject(Item(StringTerm("e"), StringTerm("f")), Item(StringTerm("f"), StringTerm("g"))) + if y.Value.Compare(expected) != 0 { + t.Errorf("expected returned value to be %v, got %v", expected, y) + } + + d := x.(*lazyObj).cache["d"] + ld, ok := d.(*lazyObj) + if !ok { + t.Errorf("expected cache to be populated with lazy object, got %v", d) + } + if ld.Compare(expected) != 0 { + t.Errorf("expected cached intermediate value to be %v, got %v", expected, y) + } +} + func TestLazyObjectFind(t *testing.T) { x := LazyObject(map[string]interface{}{ "a": map[string]interface{}{ @@ -1252,6 +1293,64 @@ func TestLazyObjectFind(t *testing.T) { } } +func TestLazyObjectFindCache(t *testing.T) { + x := LazyObject(map[string]interface{}{ + "a": []string{ + "b", "c", "d", + }, + "c": []string{ + "d", "e", "f", + }, + "d": map[string]interface{}{ + "e": "f", + "f": "g", + }, + }) + + // Assert that non-objects are cached + + y, err := x.Find(Ref{StringTerm("a"), IntNumberTerm(1)}) + if err != nil { + t.Fatal(err) + } + + if y.Compare(String("c")) != 0 { + t.Errorf("expected returned value to be 'c', got %v", y) + } + + expected := NewArray(StringTerm("b"), StringTerm("c"), StringTerm("d")) + if x.(*lazyObj).cache["a"].Compare(expected) != 0 { + t.Errorf("expected cache to be populated with type-converted intermediate value, got %v", + x.(*lazyObj).cache["a"]) + } + + if x.(*lazyObj).cache["b"] != nil { + t.Errorf("expected cache to not be populated non-retrieved intermediate value, got %v", + x.(*lazyObj).cache["b"]) + } + + // Assert that objects are cached as lazy objects + + y, err = x.Find(Ref{StringTerm("d"), StringTerm("e")}) + if err != nil { + t.Fatal(err) + } + + if y.Compare(String("f")) != 0 { + t.Errorf("expected returned value to be 'c', got %v", y) + } + + d := x.(*lazyObj).cache["d"] + ld, ok := d.(*lazyObj) + if !ok { + t.Errorf("expected cache to be populated with lazy object, got %v", d) + } + if ld.cache["e"].Compare(String("f")) != 0 { + t.Errorf("expected cache of intermediate lazyObj to be populated with type-converted intermediate value, got %v", + ld.cache["e"]) + } +} + func TestLazyObjectCopy(t *testing.T) { x := LazyObject(map[string]interface{}{ "a": map[string]interface{}{