Lazy obj performance (#6031)

Adding entry cache to `lazyObj` for storing already type converted entries for subsequent reuse.

Before this change, `.Find()` invocations on `lazyObj` instances where the searched for ref contains multiple variables would cause multiple type conversions for the same object entry; which can be very costly for large objects.

Fixes: #6009
Signed-off-by: Johan Fylling <johan.dev@fylling.se>
This commit is contained in:
Johan Fylling
2023-06-21 10:54:13 +02:00
committed by GitHub
parent 72fcd514f2
commit e40d2e6b03
3 changed files with 203 additions and 5 deletions
+24 -5
View File
@@ -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
+80
View File
@@ -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 {
+99
View File
@@ -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{}{