jonasdedden commented on PR #51611:
URL: https://github.com/apache/arrow/pull/51611#issuecomment-6186182816

   @zanmato1984 thanks so much for the investigation! There indeed was a lot of 
performance optimization left to do, and I think now things are way faster and 
don't have extreme memory overhead in edge cases.
   
   **1. List array + scalar query:** batched `equal` + bitmap scan was the 
simplest way to reuse `equal`'s type promotion; the only early exit was in the 
bitmap scan. Each list is now searched directly. Short lists (with < 32 values) 
are compared in full without data-dependent branches, which keeps them stable 
when matches are frequent (early-exit loop lead to mispredicts in benchmarks). 
Long lists stop after the first 16-value chunk holding a match.
   
   **2. List array + query array:** the `take` was also a way to reuse `equal`. 
The same per-list search now reads `queries[i]` directly, so the repeated 
values (and the indices to build them) are gone.
   
   **3. Scalar list + query array:** now uses `is_in` instead of broadcasting. 
It aligns with `equal` once both sides are cast to `equal`'s common type, with 
one difference: `is_in` tells -0.0 from 0.0, so floats get 0.0 added first.
   
   Promotion still follows `equal`: values and list values are cast to the 
types `equal` would compare them as, and a length-1 `equal` call on nulls 
raises `equal`'s exact type and time zone errors.
   
   Some benchmarks with 50M `int64` values in one `list<int64>` array:
   
   | List length | Match position | Scalar query: before -> after | Query 
array: before -> after |
   |---:|---|---|---|
   | 3 | first | 49 ms, 10 MB -> 34 ms, 4 MB | 218 ms, 804 MB -> 34 ms, 4 MB |
   | 3 | last | 50 ms, 10 MB -> 33 ms, 4 MB | 218 ms, 804 MB -> 35 ms, 4 MB |
   | 3 | none | 49 ms, 10 MB -> 34 ms, 4 MB | 219 ms, 804 MB -> 35 ms, 4 MB |
   | 3 | random (30%) | 49 ms, 10 MB -> 36 ms, 4 MB | 217 ms, 804 MB -> 33 ms, 
4 MB |
   | 10 | none | 28 ms, 8 MB -> 19 ms, 1 MB | 196 ms, 801 MB -> 21 ms, 1 MB |
   | 10 | random (30%) | 28 ms, 8 MB -> 20 ms, 1 MB | 198 ms, 801 MB -> 21 ms, 
1 MB |
   | 100 | first | 26 ms, 6 MB -> 9 ms, 0 MB | 202 ms, 800 MB -> 9 ms, 0 MB |
   | 100 | none | 34 ms, 6 MB -> 19 ms, 0 MB | 210 ms, 800 MB -> 20 ms, 0 MB |
   | 1,000 | first | 20 ms, 6 MB -> 1 ms, 0 MB | 184 ms, 800 MB -> 1 ms, 0 MB |
   | 1,000 | middle | 20 ms, 6 MB -> 15 ms, 0 MB | 183 ms, 800 MB -> 15 ms, 0 
MB |
   | 1,000 | none | 21 ms, 6 MB -> 17 ms, 0 MB | 186 ms, 800 MB -> 18 ms, 0 MB |
   | 1,000 | random (30%) | 20 ms, 6 MB -> 1 ms, 0 MB | 184 ms, 800 MB -> 1 ms, 
0 MB |
   
   With 10% null child values:
   
   | List length | Match position | Scalar query: before -> after | Query 
array: before -> after |
   |---:|---|---|---|
   | 3 | none | 53 ms -> 51 ms | 219 ms -> 51 ms |
   | 3 | random (30%) | 52 ms -> 50 ms | 219 ms -> 50 ms |
   | 1,000 | none | 22 ms -> 17 ms | 185 ms -> 18 ms |
   | 1,000 | random (30%) | 20 ms -> 3 ms | 184 ms -> 3 ms |
   
   Scalar list + query array (about half the queries match):
   
   | List length | Queries | Before (broadcast) | After (`is_in`) |
   |---:|---:|---|---|
   | 10 | 1,000,000 | 156 ms, 245 MB | 9.2 ms, 1 MB |
   | 1,000 | 1,000 | 4.1 ms, 24 MB | <0.1 ms, 0 MB |
   | 1,000 | 100,000 | 428 ms, 2,400 MB | 1.4 ms, 0 MB |
   | 100,000 | 1,000 | 427 ms, 2,400 MB | 0.9 ms, 6 MB |
   
   The lookup table paid off even at the smallest size I tried; broadcasting 
copies the list once per query, so its memory grows with list length times 
queries.
   
   <details>
   <summary>Benchmark scripts</summary>
   
   List array, scalar query vs query array, by match position (`python 
bench_positions.py`):
   
   ```python
   import json
   import subprocess
   import sys
   import time
   
   import numpy as np
   import pyarrow as pa
   import pyarrow.compute as pc
   
   N_VALUES = 50_000_000
   
   
   def make_input(length, where):
       rng = np.random.default_rng(42)
       values = rng.integers(2, 1 << 40, N_VALUES)
       starts = np.arange(0, N_VALUES // length * length, length)
       if where == "first":
           values[starts] = 1
       elif where == "middle":
           values[starts + length // 2] = 1
       elif where == "last":
           values[starts + length - 1] = 1
       elif where == "random":
           values[rng.random(N_VALUES) < 0.3] = 1
       offsets = np.arange(0, N_VALUES + 1, length, dtype=np.int32)
       return pa.ListArray.from_arrays(offsets, pa.array(values))
   
   
   def run_one(length, where, mode):
       lists = make_input(length, where)
       query = (pa.scalar(1, pa.int64()) if mode == "scalar"
                else pa.array(np.full(len(lists), 1, dtype=np.int64)))
       pool = pa.default_memory_pool()
       baseline = pool.bytes_allocated()
       times = []
       for _ in range(5):
           start = time.perf_counter()
           pc.list_contains(lists, query)
           times.append(time.perf_counter() - start)
       return {"ms": min(times) * 1e3, "mb": (pool.max_memory() - baseline) / 
1e6}
   
   
   if __name__ == "__main__":
       if len(sys.argv) == 4:
           print(json.dumps(run_one(int(sys.argv[1]), sys.argv[2], 
sys.argv[3])))
           sys.exit()
       for length in (3, 10, 100, 1000):
           for where in ("first", "middle", "last", "none", "random"):
               cells = []
               for mode in ("scalar", "array"):
                   out = json.loads(subprocess.run(
                       [sys.executable, __file__, str(length), where, mode],
                       capture_output=True, text=True, check=True).stdout)
                   cells.append(f"{out['ms']:.0f} ms, {out['mb']:.0f} MB")
               print(f"| {length} | {where} | " + " | ".join(cells) + " |", 
flush=True)
   ```
   
   With 10% null child values (uses `make_input` from above):
   
   ```python
   import time
   
   import numpy as np
   import pyarrow as pa
   import pyarrow.compute as pc
   
   from bench_positions import make_input
   
   for length in (3, 1000):
       for where in ("none", "random"):
           clean = make_input(length, where)
           rng = np.random.default_rng(7)
           values = clean.values.to_numpy()
           values = pa.array(values, mask=rng.random(len(values)) < 0.1)
           lists = pa.ListArray.from_arrays(clean.offsets, values)
           queries = pa.array(np.full(len(lists), 1, dtype=np.int64))
           cells = []
           for q in (pa.scalar(1, pa.int64()), queries):
               ts = []
               for _ in range(5):
                   t = time.perf_counter()
                   pc.list_contains(lists, q)
                   ts.append(time.perf_counter() - t)
               cells.append(f"{min(ts) * 1e3:.0f} ms")
           print(f"| {length} | {where} | " + " | ".join(cells) + " |", 
flush=True)
   ```
   
   Scalar list + query array:
   
   ```python
   import json
   import subprocess
   import sys
   import time
   
   import numpy as np
   import pyarrow as pa
   import pyarrow.compute as pc
   
   SIZES = [(10, 1_000_000), (1_000, 1_000), (1_000, 100_000), (100_000, 1_000)]
   
   
   def run_one(list_length, n_queries):
       rng = np.random.default_rng(42)
       values = rng.permutation(2 * list_length)[:list_length].astype(np.int64)
       lst = pa.scalar(values, pa.list_(pa.int64()))
       queries = pa.array(rng.integers(0, 2 * list_length, n_queries))
       pool = pa.default_memory_pool()
       baseline = pool.bytes_allocated()
       times = []
       for _ in range(5):
           start = time.perf_counter()
           pc.list_contains(lst, queries)
           times.append(time.perf_counter() - start)
       return {"ms": min(times) * 1e3, "mb": (pool.max_memory() - baseline) / 
1e6}
   
   
   if __name__ == "__main__":
       if len(sys.argv) == 3:
           print(json.dumps(run_one(int(sys.argv[1]), int(sys.argv[2]))))
           sys.exit()
       for list_length, n_queries in SIZES:
           out = json.loads(subprocess.run(
               [sys.executable, __file__, str(list_length), str(n_queries)],
               capture_output=True, text=True, check=True).stdout)
           print(f"| {list_length:,} | {n_queries:,} | {out['ms']:,.1f} ms, 
{out['mb']:,.0f} MB |")
   ```
   
   </details>


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to