Skip to content

perf(processor): remove per-execute mallocs on prepared-statement fast path - #1286

Open
adsharma wants to merge 4 commits into
mainfrom
perf/per-execute-allocs
Open

adsharma wants to merge 4 commits into
mainfrom
perf/per-execute-allocs

Conversation

@adsharma

Copy link
Copy Markdown
Contributor

What

Three contained removals of per-execution allocations on the cached-plan path, found with an LD_PRELOAD allocation profiler (stack sampling) driving 120 cached-plan executions of LDBC Q1. Measured: 678 malloc+free pairs/execute before, 573 after (-15%); Q1 hot exec p50 224us to 210us.

  • Profiler metric registration (physical_operator.cpp): when profiling is disabled, bind thread-local dummy metrics instead of registering into the profiler unordered_map (map nodes + strings + unique_ptr per operator per task, plus mutex traffic). ~60 alloc pairs/execute removed.
  • RelTable::initScanState (rel_table.cpp): reuse a scan-state positions buffer across per-morsel inits, skip the bound-ID sort when the input is already ordered, replace the std::map node-group partition with a linear run scan over sorted offsets (common single-group case now allocates nothing).
  • ResultSetPool (processor.cpp): per-thread MRU cache of up to 8 descriptor slots instead of a single slot, which thrashed across pipelines (each pipeline sink has its own descriptor id) and reallocated all vectors every execution. Steady-state pool hit rate ~50% to ~100% (verified with temporary hit/miss instrumentation, since removed).

Validation

  • Alloc counts via counting shim; Q1 result verified identical; Q1-Q10 before/after sweep showed no result changes (Q6/Q10 differ only in unordered row order).
  • Optimizer suite: 4/4 pass. Broad e2e sweep (match/subquery/join/unwind/ldbc/optional/tck/filter/explain/intersect/path/order/agg/delete/transaction): only pre-existing AnonymousParquetDeleteReload failure, which fails identically on base main.
  • Requesting full e2e on CI via this PR (local full-suite runs exceed shell limits).

Notes

…t path

Three contained removals, measured with an LD_PRELOAD allocation profiler
on 120x cached-plan executions of LDBC Q1 (678 malloc+free pairs per
execute before, 573 after; Q1 hot exec p50 224us to 210us):

- PhysicalOperator::registerProfilingMetrics: bind thread-local dummy
  metrics when the profiler is disabled instead of registering into the
  profiler unordered_map (map nodes plus strings plus unique_ptr per
  operator per task, plus mutex traffic).
- RelTable::initScanState: reuse a scan-state positions buffer across
  per-morsel inits, skip the bound-ID sort when input is already ordered,
  and replace the stdmap node-group partition with a linear run scan
  over the sorted offsets.
- ResultSetPool: per-thread MRU cache of up to 8 descriptor slots instead
  of a single slot, which thrashed across pipelines and reallocated all
  vectors every execution. Steady-state pool hit rate 50pct to 100pct.
Checkout of the cached physical-plan template clones hundreds of small allocations per execution (operators, evaluators, print infos, schemas, descriptors). Keep up to 10 ready plans per thread, reset via prepareForReuse, and reuse them. Descriptors stay attached on pooled plans; nested same-statement execution falls back to fresh clones.
@adsharma

Copy link
Copy Markdown
Contributor Author

Update: added a second commit on this branch — per-thread physical-plan pooling.

perf(processor): pool cloned physical plans per thread on the fast path

Checkout of the cached physical-plan template clones hundreds of small allocations per execution (operators, evaluators, print infos, schemas, descriptors). The pool keeps up to 10 ready plans per thread, reset via the existing prepareForReuse(), and reuses them; nested same-statement execution falls back to fresh clones. Pooled plans keep their sink descriptors attached (skip re-attach when present).

Ground-truth A/B with uprobes (bpftrace malloc/free counts over 25s steady-state windows, same workload):

malloc / execute free / execute
base main 3,021 3,008
this branch (all fixes) 2,288 2,278
delta -733 (-24%) -730 (-24%)

Breakdown of the -733 (LD_PRELOAD stack attribution, directionally confirmed): profiler metric registration ~-60, ResultSet LRU ~-30, initScanState map/sort -15, plan pool covers the remainder (-600 clone spread).

Note: the LD_PRELOAD shims undercount ~3.6x vs uprobes (internal libc call paths bypass interposition) — absolute shim numbers in earlier comments should be read as relative only.

Validation so far: Q1 result identical (Thomas Brown); optimizer suite 4/4; targeted e2e subsets (match/subquery/join/transaction/delete/ldbc, 211 tests) green on the pre-pool state. Requesting full e2e on CI for the final state (local full-suite runs exceed this dev box).

…hunk functors

Reused-operator hardening plus allocator fixes. No behavior change for fresh operators (all cleared vectors start empty; all SFO lambdas evaluate identically): operator initLocalState fills now clear member vectors first so re-execution starts clean (hash join build and probe, intersect, cross product, order by and top-k, hash and simple aggregates, scan tables, hash-aggregate local state), and ColumnChunkData keeps the compression algorithm on the chunk with capture-this stdfunction members that fit small-function optimization instead of heap-allocating 24-byte functors per chunk.
@adsharma

Copy link
Copy Markdown
Contributor Author

Update: added a third commit (operator-init idempotency + SFO-bound chunk functors). Deliberately not included: per-task pipeline-replica pooling.

I prototyped pooling ProcessorTask sink-subtree clones (the biggest remaining per-execute clone source). It crashed intermittently (string corruption in results, native, non-deterministic across threads) — pooled task operators inherit too much construction-time state (table scan states, per-op vectors not covered by init) for a cheap fix, and auditing every operator's mutable state is a larger project with broad regression surface. The init-clear hardening in this commit is the safe prerequisite subset (behavior-preserving for fresh operators); the pool itself is reverted.

Current per-execute malloc tally (uprobe ground truth on Q1): main 3,021 -> 2,288 with plan pooling included. Remaining big-ticket items for a follow-up: per-task clone elimination (needs the state-lifecycle audit above), ValueVector pooling beyond ResultSet reuse, scan-state teardown allocs.

@adsharma

Copy link
Copy Markdown
Contributor Author

Validation update (perf-only tree, no lookup join): Q1-Q10 shell sweep vs base main, same DB, min-of-3 exec ms — Q1 3.05->3.14, Q2 7.46->7.87, Q4 2.68->2.65, Q5 19.29->17.49, Q6 2.82->3.09, Q7 25.79->26.44, Q8 18.07->17.52, Q9 2.24->2.15, Q10 5.44->5.34. All result sets identical (Q6/Q10 differ only in unordered row order). No regressions; deltas within run-to-run noise except Q5 (-9%, benefits from the initScanState sort-skip on large fanouts).

Note: I investigated exact single-hop fanout for INLJ root selection on top of this branch (probing secondary ART indexes + per-offset CSR degrees at plan time) and reverted it: getDegreeForOffset returns garbage/zero for valid offsets when called during planning (filed as #1292 with repro). The mechanism is preserved at branch experiment/q1-exact-fanout-blocked for whoever picks up the storage investigation. This PR stays allocation-focused; Q1's remaining gap is join-order/root-selection work tracked separately.

@zahariash

Copy link
Copy Markdown
Contributor

This turned up while testing an overlapping optimization on top of this branch: a CSR scan change (sparse CSR header decode in clusters plus a bounded neighbour-cache refill) built on the bound-node order this PR's initScanState keeps, which I plan to open as a draft PR soon so the two can be coordinated.

On ad3f67a merged onto main a7e923e (same tree as the PR head), a prepared statement over a node table of more than one node group loses rows after two executions with a selective parameter: the next one is still right, every later one returns only the last node group.

import tempfile

import ladybug as lb

d = tempfile.mkdtemp()
n = 140_000  # more than one node group (131,072 nodes)
open(f"{d}/n.csv", "w").writelines(f"{i}\n" for i in range(n))
conn = lb.Connection(lb.Database(f"{d}/db"), num_threads=1)
conn.execute("CREATE NODE TABLE N(id INT64, PRIMARY KEY(id))")
conn.execute(f'COPY N FROM "{d}/n.csv"')
query = "MATCH (a:N) WHERE a.id < $k RETURN count(*)"
stmt = conn.prepare(query)
print([conn.execute(stmt, {"k": k}).get_all()[0][0] for k in (3000, 3000, n, n)])

main prints [3000, 3000, 140000, 140000], this branch [3000, 3000, 140000, 8928]: the 8928 nodes of node group 1 (ids 131072–139999). Same at 4 threads and with one or two hops on top of the scan. One selective execution is not enough, a k already reaching node group 1 does not trigger it, k = n alone is fine, and unprepared conn.execute(query, params) is right throughout. A stress test of prepared rel-scan queries against a graph model found it; main passes, and the CSR change merged on top fails identically, so the bug is in this PR alone.

@adsharma

Copy link
Copy Markdown
Contributor Author

@zahariash bug likely caused by pooled plan leakage. Need a more comprehensive audit of all operators for properly resetting state when the pooled plan is reused.

…r resets

- Clear physicalPlanPool when the physical-plan cache is invalidated
  on rebind (parameter type change / catalog version bump). Pooled trees
  cloned from the previous template reference stale evaluators and sink
  descriptors; the second execution after a type change reused them and
  returned wrong results (ApiTest.PrepareWithoutParamsReusesPlan).
- Reset per-execution vectors missed by the idempotency pass so pooled
  operator objects start clean: ResultCollector payload vectors,
  QueryPrimaryKeyLookup outVectors, PackedFilteredCount multiplicity
  states/local counts, UnionAllScan vectors, GroupedReachableCount key
  vectors/scan states, CopyTo input vectors.
- RelTable::initScanState: on empty batches drop the stale multi-group
  plan and mark the scan exhausted instead of rescanning the previous
  batch.
- ColumnChunkData: call flush/metadata impls directly instead of via
  this-capturing std::functions (use-after-move when chunks relocate;
  also removes the per-chunk allocation entirely).
- ResultSetPool: revert MRU-8 to the single per-thread slot. With unique
  descriptor ids per uncached query the 8-slot cache accumulates garbage
  until the cap, so buffer-manager memory keeps growing across identical
  queries (BufferManagerTest.TestBMUsageForIdenticalQueries).
@adsharma
adsharma force-pushed the perf/per-execute-allocs branch from e8d018c to 3b181e6 Compare October 18, 2026 05:23

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants