GujaLomsadze commented on issue #47133:
URL: https://github.com/apache/arrow/issues/47133#issuecomment-5050674452

   **TL;DR:** Not a memory leak. pyarrow infers `dict -> struct`, and a nested 
dict here has
   near-unique keys (**73,106 distinct / 74,270 rows**), so it builds a struct 
with **~73k
   fields**, which is O(rows x keys), about **5.4B cells / ~22GB**. The same 
data as a `map`
   does the full dataset in **0.03s / 231MB**. 
   
   **It's not pickle or pandas-specific.** (3-line repro
   below). Question at the end: guardrail, docs, or working-as-intended?
   
   ```python
   import pyarrow as pa
   data = [{f"key_{i}": "x"} for i in range(10_000)]  # 10k dicts, each a 
unique key
   pa.array(data)   # -> struct<10000 fields>; bump to 100k and it eats all 
your RAM
   ```
   
   `struct_fields == N` exactly: every distinct key becomes a field.
   
   
   <details>
   <summary>Full script to reproduce this (click to expand)</summary>
   
   ```python
   """
   Reproduce apache/arrow #47133: dict-with-unique-keys -> struct explosion.
   """
   import time
   import resource
   import pyarrow as pa
   import pandas as pd
   
   
   def peak_rss_mb():
       return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
   
   
   def make_dicts(n):
       # each row = a dict with a single UNIQUE key -> n distinct keys total
       return [{f"key_{i}": "x"} for i in range(n)]
   
   
   print("pyarrow", pa.__version__, "| pandas", pd.__version__)
   print("=" * 64)
   
   # --- 1. Minimal repro: plain pyarrow, NO pandas ---
   print("\n[1] no pandas: pa.array(list_of_dicts)")
   data = make_dicts(10_000)
   arr = pa.array(data)
   print("    struct fields :", len(arr.type))                 # 10000
   print("    first 3 names :", [arr.type.field(i).name for i in range(3)])
   print("    nbytes        :", round(arr.nbytes / 1e6, 1), "MB")
   
   # --- 2. Same thing via pandas from_pandas (how the report hit it) ---
   print("\n[2] pandas: pa.Table.from_pandas(df)")
   df = pd.DataFrame({"col": data})
   tbl = pa.Table.from_pandas(df)
   print("    struct fields :", len(tbl.schema.field("col").type))
   print("    nbytes        :", round(tbl.column("col").nbytes / 1e6, 1), "MB")
   
   # --- 3a. Scaling WITHOUT pandas (pa.array) ---
   print("\n[3a] scaling, no pandas (pa.array) -- doubling N ~= 4x nbytes")
   print(f"     {'N':>6} {'fields':>7} {'nbytes(MB)':>11} {'time(s)':>8}")
   for n in (2000, 4000, 8000, 16000):
       d = make_dicts(n)
       t = time.time()
       a = pa.array(d)
       dt = time.time() - t
       print(f"     {n:>6} {len(a.type):>7} {a.nbytes / 1e6:>11.1f} {dt:>8.2f}")
   
   # --- 3b. Scaling WITH pandas (from_pandas) ---
   print("\n[3b] scaling, pandas (from_pandas) -- same quadratic curve")
   print(f"     {'N':>6} {'fields':>7} {'nbytes(MB)':>11} {'time(s)':>8}")
   for n in (2000, 4000, 8000, 16000):
       d = make_dicts(n)
       df_n = pd.DataFrame({"col": d})
       t = time.time()
       t_n = pa.Table.from_pandas(df_n)
       dt = time.time() - t
       nb = t_n.column("col").nbytes / 1e6
       print(f"     {n:>6} {len(t_n.schema.field('col').type):>7} {nb:>11.1f} 
{dt:>8.2f}")
   
   # --- 4. The fix: it's really a map, not a struct (both paths) ---
   print("\n[4] correct type: map<string, string> (cheap, no explosion)")
   m_nopd = pa.array(data, type=pa.map_(pa.string(), pa.string()))
   m_pd = pa.Table.from_pandas(
       df, schema=pa.schema([("col", pa.map_(pa.string(), pa.string()))])
   )
   print("    no pandas nbytes :", round(m_nopd.nbytes / 1e6, 2), "MB")
   print("    pandas    nbytes :", round(m_pd.column("col").nbytes / 1e6, 2), 
"MB")
   print("    (vs struct's ~400 MB for the same 10k rows)")
   
   print("\n" + "=" * 64)
   print("peak RSS this process:", round(peak_rss_mb(), 1), "MB")
   ```
   
   </details>
   
   
   ---
   
   @Seminko got the pickle, thanks! Drive link still works lol. Reproduced and 
root-caused -it's not a leak, it's schema
   inference.
   
   The url_parts dicts contain a nested queries dict whose keys are effectively 
unique across
   rows (73,106 distinct keys in 74,270 rows). PyArrow infers dict -> struct, 
and a struct's
   fields are the union of all keys, so queries becomes a struct with ~73k 
fields. Materializing
   that over 74k rows is O(rows x fields), about 5.4B cells, roughly 22GB 
(almost all my RAM
   haha). Batching to 10k "works" only because each batch sees far fewer 
distinct keys.
   
   
   IMO the right Arrow type for open-ended keys is a **map**, not a struct. 
Converting the same
   column as `map<string, list<string>>` does the full 74,270 rows in **0.03s / 
231MB**.
   
   **It's also not pickle- or pandas-specific. Pickle was just how the report 
shipped the data. The
   real trigger is any dict column with lots of distinct keys (JSON/API 
payloads, scraped or
   parsed URLs, NoSQL dumps, and so on). The 3-line repro above uses plain 
pa.array, no pandas
   and no pickle. from_pandas just forwards to this same inference, so a 
guardrail at the
   inference layer would cover all of these paths.**
   
   So: not a leak!! inference just builds a pathological struct. A few ways 
this could go, happy
   to do whichever you prefer:
   
   1. Guardrail: when an inferred struct exceeds some field count, raise a 
clear error pointing
   at pa.map_() instead of silently consuming entire RAM. 
   2. Docs: a short note on dict -> struct vs map, and when to use each.
   3. Both.
   4. ... something else, haha
   
   Or if you'd rather treat this as working-as-intended, let me know the
   preferred direction and I'll pick it up.
   Way I look at it is that, at least it should drop some warning th
   
   cc @AlenkaF @jorisvandenbossche  - this is your area (`from_pandas` 
inference).


-- 
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