We ran into a pathological case of field lookup recently. A common pattern we follow is something along the lines of:
std.foldl(function (o, acc) { return acc + { [o.key]: o.val } }, arr, {})
We run into an issue here because internally this effectively creates a linked list:
|
// extendedObject represents an object created through inheritance (left + right). |
|
// We represent it as the pair of objects. This results in a tree-like structure. |
|
// Example: |
|
// (A + B) + C |
|
// |
|
// + |
|
// / \ |
|
// + C |
|
// / \ |
|
// A B |
|
// |
|
// It is possible to create an arbitrary binary tree. |
|
// Note however, that because + is associative the only thing that matters |
|
// is the order of leafs. |
|
// |
|
// This represenation allows us to implement "+" in O(1), |
|
// but requires going through the tree and trying subsequent leafs for field access. |
Now, O(1) object lookups become O(n). If we do this in a loop, O(n) can become O(n^2) or worse. We can re-flatten the resulting extendedObject into a simpleObject by using object comprehensions:
flattened = { [k]: obj[k] for k in std.objectFields(obj) }
I'm not sure if there are additional bugs contributing to this behaviour (e.g., why do the caches not absorb this). But ultimately it does raise the question if optimizing for O(n) object merging is the right move. It heavily penalizes reads.
Would it make sense to instead spend more cycles during the object merging? The hacky patch I've attached eliminates the performance issues we faced.
diff --git a/builtins.go b/builtins.go
index 251ca40..7b13205 100644
--- a/builtins.go
+++ b/builtins.go
@@ -60,6 +60,27 @@ func builtinPlus(i *interpreter, x, y value) (value, error) {
case *valueObject:
switch right := y.(type) {
case *valueObject:
+ if left, ok := left.uncached.(*simpleObject); ok {
+ if right, ok2 := right.uncached.(*simpleObject); ok2 {
+ frame := make(bindingFrame)
+ for k, v := range left.upValues {
+ frame[k] = v
+ }
+ for k, v := range right.upValues {
+ frame[k] = v
+ }
+
+ fields := make(simpleObjectFieldMap)
+ for k, v := range left.fields {
+ fields[k] = v
+ }
+ for k, v := range right.fields {
+ fields[k] = v
+ }
+
+ return makeValueSimpleObject(frame, fields, append(left.asserts, right.asserts...), append(left.locals, right.locals...)), nil
+ }
+ }
return makeValueExtendedObject(left, right), nil
default:
return nil, i.typeErrorSpecific(y, &valueObject{})
A hybrid approach could also be possible. Perhaps we can establish some sort of depth threshold at which we compact extendedObjects down into a simpleObject.
Thanks to @suprememoocow for tracking down the original issue.
We ran into a pathological case of field lookup recently. A common pattern we follow is something along the lines of:
We run into an issue here because internally this effectively creates a linked list:
go-jsonnet/value.go
Lines 618 to 634 in 923f51b
Now, O(1) object lookups become O(n). If we do this in a loop, O(n) can become O(n^2) or worse. We can re-flatten the resulting
extendedObjectinto asimpleObjectby using object comprehensions:I'm not sure if there are additional bugs contributing to this behaviour (e.g., why do the caches not absorb this). But ultimately it does raise the question if optimizing for O(n) object merging is the right move. It heavily penalizes reads.
Would it make sense to instead spend more cycles during the object merging? The hacky patch I've attached eliminates the performance issues we faced.
A hybrid approach could also be possible. Perhaps we can establish some sort of depth threshold at which we compact
extendedObjects down into asimpleObject.Thanks to @suprememoocow for tracking down the original issue.