Repository: scikit-hep/uproot5

Issue:
Very slow reading of some (not all) vector<vector<int>> variables
Dear uproot,

One of my ROOT file contains `vector<vector<int>>` variables which are all produced in a very similar way.
Out of 4, 2 of them are extremely slow to read with uproot (5.0.3) : 
```python
t= uproot.open("test.root:gnnjet")
>>> timeit.timeit("a=t.arrays(['nnTruthReco' ])", globals=globals(), number=1)
6.898946930188686
>>> timeit.timeit("a=t.arrays(['nnRecoReco' ])", globals=globals(), number=1)
0.03529514092952013
>>> timeit.timeit("a=t.arrays(['nnRecoTruth' ])", globals=globals(), number=1)
5.870497648837045
>>> timeit.timeit("a=t.arrays(['nnTruthTruth' ])", globals=globals(), number=1)
0.06448978511616588
```

As you can see there seem to be a factor>100 in the read time for 2 of the variables. 
This is a  test file with 1000 entries which can be found at : 
https://cernbox.cern.ch/s/rvpBMjWLCZfhatG

My complete files have ~100k entries  so they are practically unreadable...
Is there something wrong with these files ?  Or is the slowness coming from uproot ?

Thanks for any hint !

P-A

```python
# python version is 3.10.9
>>> import uproot
>>> uproot.__version__
'5.0.3'
```



Discussion:
can you try 5.0.4?
```python
In [1]: import uproot

In [2]: t= uproot.open("test.root:gnnjet")

In [5]: %timeit t.arrays(['nnTruthReco' ])
508 µs ± 55.9 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [6]: %timeit t.arrays(['nnRecoReco' ])
13.3 ms ± 39.9 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

In [7]: uproot.__version__
Out[7]: '5.0.4'
```

the 2x slow down is expected, `nnRecoReco` has 2 baskets instead of 1
Hi,

Thanks for the fast feedback !
I don't have ipython but could update to 5.0.4 : 
```python

>>> import uproot
>>> uproot.__version__
'5.0.4'
>>> t=uproot.open("test.root:gnnjet")
>>> timeit.timeit("a=t.arrays(['nnTruthReco' ])", globals=globals(), number=1)
6.966970829991624
>>> timeit.timeit("a=t.arrays(['nnRecoReco' ])", globals=globals(), number=1)
0.05337693099863827
```  
I see the same behaviour. 

But I'm not sure I understand your tests : there are many runs (aren't you re-using the cache? ) and a different number of loops... 

you're right, I think cache is involved:
```python
In [8]: %timeit uproot.open("test.root:gnnjet").arrays(['nnRecoReco'])
39.5 ms ± 102 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [9]: %timeit uproot.open("test.root:gnnjet").arrays(['nnTruthReco'])
5.11 s ± 66.4 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
```

the different number of loops is just heuristics of `%timeit`, the final number is always correctly averaged.

btw I don't see it from a different package just for sanity check:
```julia
# the `[:]` materialize everything
julia> @btime LazyTree("/tmp/test.root", "gnnjet", "nnTruthReco")[:];
  38.639 ms (441377 allocations: 50.66 MiB)

julia> @btime LazyTree("/tmp/test.root", "gnnjet", "nnRecoReco")[:];
  24.760 ms (329552 allocations: 35.29 MiB)
```
The fact that the data types are `std::vector<std::vector<int>>` and the performance differences you're seeing are factors of ~100 makes me suspicious that the fast ones are using the new AwkwardForth optimization and the slow ones are not. I'm looking into it.

Resolving patch:
diff --git a/src/uproot/_awkward_forth.py b/src/uproot/_awkward_forth.py
--- a/src/uproot/_awkward_forth.py
+++ b/src/uproot/_awkward_forth.py
@@ -26,7 +26,7 @@ class ForthGenerator:
     This class is passed through the Forth code generation, collecting Forth snippets and concatenating them at the end.
     """
 
-    def __init__(self, aform=None, count_obj=0, var_set=False):
+    def __init__(self, aform=None, count_obj=0):
         self.dummy_form = False
         self.top_dummy = None
         self.dummy_aform = None
@@ -42,7 +42,6 @@ def __init__(self, aform=None, count_obj=0, var_set=False):
         self.final_header = []
         self.final_init = []
         self.form_keys = []
-        self.var_set = var_set
         self.count_obj = count_obj
 
     def traverse_aform(self):
@@ -82,7 +81,7 @@ def tree_walk(self, node):
                 init = node["init_code"] + init
                 header = node["header_code"] + header
                 return pre2 + post2, "", init, header
-        elif self.var_set:
+        else:
             return "", "", "", ""
 
     def should_add_form(self):
diff --git a/src/uproot/containers.py b/src/uproot/containers.py
--- a/src/uproot/containers.py
+++ b/src/uproot/containers.py
@@ -88,8 +88,6 @@ def _read_nested(
         values = numpy.empty(length, dtype=_stl_object_type)
         if isinstance(model, AsContainer):
             if forth_stash is not None:
-                if length == 0:
-                    forth_obj.var_set = True
                 temp_count = context["forth"].gen.count_obj
             for i in range(length):
                 if forth_stash is not None:
@@ -756,8 +754,6 @@ def read(self, chunk, cursor, context, file, selffile, parent, header=True):
                         {},
                     )
 
-                if cursor.index >= chunk.stop and forth_stash is not None:
-                    forth_obj.var_set = True
                 out = []
                 if forth_stash is not None:
                     temp_count = forth_obj.count_obj
@@ -828,8 +824,6 @@ def read(self, chunk, cursor, context, file, selffile, parent, header=True):
                         {},
                     )
 
-                if cursor.index >= chunk.stop and forth_stash is not None:
-                    forth_obj.var_set = True
                 out = []
                 if forth_stash is not None:
                     temp_count = forth_obj.count_obj
@@ -1147,9 +1141,6 @@ def read(self, chunk, cursor, context, file, selffile, parent, header=True):
                 )
                 context["temp_ref"] = temp
 
-            if length == 0 and forth_stash is not None:
-                forth_obj.var_set = True
-
             values = _read_nested(
                 self._values, length, chunk, cursor, context, file, selffile, parent
             )
@@ -1297,8 +1288,6 @@ def read(self, chunk, cursor, context, file, selffile, parent, header=True):
                 1,
                 {},
             )
-        if length == 0 and forth_stash is not None:
-            forth_obj.var_set = True
         keys = _read_nested(
             self._keys, length, chunk, cursor, context, file, selffile, parent
         )
diff --git a/src/uproot/interpretation/objects.py b/src/uproot/interpretation/objects.py
--- a/src/uproot/interpretation/objects.py
+++ b/src/uproot/interpretation/objects.py
@@ -270,9 +270,28 @@ def basket_array_forth(
 
         return output
 
+    def _any_NULL(self, form):
+        if form == "NULL":
+            return True
+        else:
+            content = form.get("content")
+            if content is not None:
+                return self._any_NULL(content)
+            contents = form.get("contents")
+            if isinstance(contents, list):
+                for content in contents:
+                    if self._any_NULL(content):
+                        return True
+            elif isinstance(contents, dict):
+                for content in contents.values():
+                    if self._any_NULL(content):
+                        return True
+            return False
+
     def _discover_forth(self, data, byte_offsets, branch, context, cursor_offset):
         output = numpy.empty(len(byte_offsets) - 1, dtype=numpy.dtype(object))
         context["cancel_forth"] = False
+
         for i in range(len(byte_offsets) - 1):
             byte_start = byte_offsets[i]
             byte_stop = byte_offsets[i + 1]
@@ -281,8 +300,10 @@ def _discover_forth(self, data, byte_offsets, branch, context, cursor_offset):
             cursor = uproot.source.cursor.Cursor(
                 0, origin=-(byte_start + cursor_offset)
             )
+
             if "forth" in context.keys():
-                context["forth"].gen.var_set = False
+                context["forth"].gen.count_obj = 0
+
             output[i] = self._model.read(
                 chunk,
                 cursor,
@@ -291,11 +312,15 @@ def _discover_forth(self, data, byte_offsets, branch, context, cursor_offset):
                 branch.file.detached,
                 branch,
             )
+
             if context["cancel_forth"] and "forth" in context.keys():
                 del context["forth"]
+
             if "forth" in context.keys():
                 context["forth"].gen.awkward_model = context["forth"].gen.top_node
-                if not context["forth"].gen.var_set:
+
+                discovered_form = context["forth"].gen.top_form
+                if not self._any_NULL(discovered_form):
                     context["forth"].prereaddone = True
                     self._assemble_forth(
                         context["forth"].gen, context["forth"].gen.top_node["content"]
@@ -312,8 +337,9 @@ def _discover_forth(self, data, byte_offsets, branch, context, cursor_offset):
     loop
     """
                     self._forth_form_keys = tuple(context["forth"].gen.form_keys)
-                    self._form = context["forth"].gen.top_form
+                    self._form = discovered_form
                     return None  # we should re-read all the data with Forth
+
         return output  # Forth-generation was unsuccessful: this is Python output
 
     def _assemble_forth(self, forth_obj, awkward_model):


Test patch: