Repository: ibis-project/ibis

Issue:
perf: "large compile" test failing with some recent commit
I noticed while working on https://github.com/ibis-project/ibis/pull/4006 that after some recent sync with master that the "large compile" test in the BigQuery backend started failing.

https://github.com/ibis-project/ibis-bigquery/blob/d3acf50de9d8e9015819efe300b4b5f9615c027a/tests/unit/test_compiler.py#L425

I was hoping this was just a misconfiguration with my local machine, but I see now that it is failing / infinitely looping on the GitHub Actions setup as well: https://github.com/ibis-project/ibis-bigquery/runs/6711618312?check_suite_focus=true


Discussion:
I used `git bisect` while running this test and found the following:

55a0b356176d8e086c3c3592869bf9c477516a8b is the first bad commit

```
commit 55a0b356176d8e086c3c3592869bf9c477516a8b
Author: Phillip Cloud <417981+cpcloud@users.noreply.github.com>
Date:   Tue May 31 07:11:24 2022 -0400

    refactor: ignore predicates and sort_keys when handling Selection traversal in `_find_root_table`

 ibis/expr/analysis.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)
```
It looks like this is a performance issue because the test did eventually finish. I can confirm locally that https://github.com/ibis-project/ibis/commit/55a0b356176d8e086c3c3592869bf9c477516a8b is in fact the commit that introduced the performance issue.
Wow, it did! 51 minutes versus a few seconds before this commit. 😳 

Resolving patch:
diff --git a/ibis/expr/analysis.py b/ibis/expr/analysis.py
--- a/ibis/expr/analysis.py
+++ b/ibis/expr/analysis.py
@@ -574,7 +574,6 @@ def try_fusion(self, root):
         root_table = root.table
         roots = root_table.op().root_tables()
         fused_exprs = []
-        can_fuse = False
         clean_exprs = self.clean_exprs
 
         if not isinstance(root_table.op(), ops.Join):
@@ -600,9 +599,6 @@ def try_fusion(self, root):
             # which to attempt fusion
             resolved = clean_exprs
 
-        if not resolved:
-            return None
-
         root_selections = root.selections
         parent_op = self.parent.op()
         for val in resolved:
@@ -614,7 +610,6 @@ def try_fusion(self, root):
                 or len(roots) == 1
                 and val.op().root_tables()[0] is roots[0]
             ):
-                can_fuse = True
                 have_root = False
                 for root_sel in root_selections:
                     # Don't add the * projection twice
@@ -626,24 +621,18 @@ def try_fusion(self, root):
 
                 # This was a filter, so implicitly a select *
                 if not have_root and not root_selections:
-                    fused_exprs = [root_table] + fused_exprs
+                    fused_exprs = [root_table, *fused_exprs]
             elif shares_all_roots(val, root_table):
-                can_fuse = True
                 fused_exprs.append(val)
-            elif not shares_all_roots(val, root_table):
-                can_fuse = False
-                break
             else:
-                fused_exprs.append(val)
+                return None
 
-        if can_fuse:
-            return ops.Selection(
-                root_table,
-                fused_exprs,
-                predicates=root.predicates,
-                sort_keys=root.sort_keys,
-            )
-        return None
+        return ops.Selection(
+            root_table,
+            fused_exprs,
+            predicates=root.predicates,
+            sort_keys=root.sort_keys,
+        )
 
 
 def find_first_base_table(expr):
@@ -666,10 +655,7 @@ def _find_root_table(expr):
     if isinstance(op, ops.Selection):
         # remove predicates and sort_keys, so that child tables are considered
         # equivalent even if their predicates and sort_keys are not
-        return lin.proceed, op.__class__(
-            table=op.table,
-            selections=op.selections,
-        )
+        return lin.proceed, op._projection
     elif op.blocks():
         return lin.halt, op
     else:
diff --git a/ibis/expr/lineage.py b/ibis/expr/lineage.py
--- a/ibis/expr/lineage.py
+++ b/ibis/expr/lineage.py
@@ -4,17 +4,16 @@
 import itertools
 from typing import Any, Callable, Iterable, Iterator
 
-from toolz import compose, identity
-
 import ibis.expr.operations as ops
 import ibis.expr.types as ir
 
 
 class Container:
 
-    __slots__ = ('data',)
+    __slots__ = "data", "visitor"
 
-    def __init__(self, data):
+    def __init__(self, data, visitor: Callable | None = None) -> None:
+        self.visitor = (lambda val: val) if visitor is None else visitor
         self.data = collections.deque(self.visitor(data))
 
     def append(self, item):
@@ -26,10 +25,6 @@ def __len__(self):
     def get(self):
         raise NotImplementedError('Child classes must implement get')
 
-    @property
-    def visitor(self):
-        raise NotImplementedError('Child classes must implement visitor')
-
     def extend(self, items):
         return self.data.extend(items)
 
@@ -40,15 +35,14 @@ class Stack(Container):
     Implements the `Container` API for depth-first graph traversal.
     """
 
-    __slots__ = ('data',)
+    __slots__ = ()
+
+    def __init__(self, data):
+        super().__init__(data, visitor=lambda data: reversed(list(data)))
 
     def get(self):
         return self.data.pop()
 
-    @property
-    def visitor(self):
-        return compose(reversed, list)
-
 
 class Queue(Container):
     """Wrapper around `collections.deque`.
@@ -56,15 +50,11 @@ class Queue(Container):
     Implements the `Container` API for breadth-first graph traversal.
     """
 
-    __slots__ = ('data',)
+    __slots__ = ()
 
     def get(self):
         return self.data.popleft()
 
-    @property
-    def visitor(self):
-        return identity
-
 
 def _get_args(op, name):
     """Hack to get relevant arguments for lineage computation.
diff --git a/ibis/expr/operations/relations.py b/ibis/expr/operations/relations.py
--- a/ibis/expr/operations/relations.py
+++ b/ibis/expr/operations/relations.py
@@ -404,6 +404,10 @@ def __init__(self, table, selections, predicates, sort_keys):
         # Validate no overlapping columns in schema
         assert self.schema
 
+    @cached_property
+    def _projection(self):
+        return self.__class__(table=self.table, selections=self.selections)
+
     @cached_property
     def schema(self):
         # Resolve schema and initialize


Test patch:
diff --git a/ibis/tests/benchmarks/test_benchmarks.py b/ibis/tests/benchmarks/test_benchmarks.py
--- a/ibis/tests/benchmarks/test_benchmarks.py
+++ b/ibis/tests/benchmarks/test_benchmarks.py
@@ -598,3 +598,19 @@ def eq(a, b):
         assert a == b
 
     benchmark(eq, dtypes, copy.deepcopy(dtypes))
+
+
+def multiple_joins(table, num_joins):
+    for _ in range(num_joins):
+        table = table.mutate(dummy=ibis.literal(""))
+        table = table.left_join(table, ["dummy"])[[table]]
+
+
+@pytest.mark.parametrize("num_joins", [1, 10, 100])
+@pytest.mark.parametrize("num_columns", [1, 10, 100])
+def test_multiple_joins(benchmark, num_joins, num_columns):
+    table = ibis.table(
+        {f"col_{i:d}": "string" for i in range(num_columns)},
+        name="t",
+    )
+    benchmark(multiple_joins, table, num_joins)
diff --git a/ibis/tests/expr/test_analysis.py b/ibis/tests/expr/test_analysis.py
--- a/ibis/tests/expr/test_analysis.py
+++ b/ibis/tests/expr/test_analysis.py
@@ -1,3 +1,5 @@
+import time
+
 import pytest
 
 import ibis
@@ -308,3 +310,21 @@ def test_agg_selection_does_not_share_roots():
 
     with pytest.raises(com.RelationError, match="Selection expressions"):
         gb.aggregate(n=n)
+
+
+@pytest.mark.parametrize("num_joins", [1, 10])
+def test_large_compile(num_joins):
+    num_columns = 20
+    table = ibis.table(
+        {f"col_{i:d}": "string" for i in range(num_columns)}, name="t"
+    )
+    for _ in range(num_joins):
+        start = time.time()
+        table = table.mutate(dummy=ibis.literal(""))
+        stop = time.time()
+        assert stop - start < 1.0
+
+        start = time.time()
+        table = table.left_join(table, ["dummy"])[[table]]
+        stop = time.time()
+        assert stop - start < 1.0