Skip to content

Bound per-row memory in writable clauses - #2517

Open
crdv7 wants to merge 1 commit into
apache:masterfrom
crdv7:fix-write-executor-memory-lifetime
Open

Bound per-row memory in writable clauses#2517
crdv7 wants to merge 1 commit into
apache:masterfrom
crdv7:fix-write-executor-memory-lifetime

Conversation

@crdv7

@crdv7 crdv7 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

SET and REMOVE currently create EState-owned tuple slots for every updated
entity and allocate transient update state in an executor-lifetime memory
context. These resources remain until executor shutdown, so a large writable
statement becomes progressively more expensive and retains memory in
proportion to the number of processed rows. The UPDATE and DELETE RLS paths
have the same per-row slot ownership problem, and MERGE ... SET shares the
property-update path.

This PR gives row-local slots and scratch a row-local lifetime while preserving
the executor- and statement-lifetime state that must survive across rows.

It does not change Cypher semantics, agtype serialization, entity lookup, or
the on-disk representation.

Root cause

apply_update_list() creates a label-table write slot with
ExecInitExtraTupleSlot() for every updated entity. That API appends the slot
to EState.es_tupleTable and pins its TupleDesc resource until executor
shutdown. Temporary RLS slots in the UPDATE and DELETE paths use the same API.

Instrumentation shows that the tuple table grows once per updated row:

10,000 rows:  es_tupleTable 7 -> 10,007
100,000 rows: es_tupleTable 7 -> 100,007

apply_update_list() and process_delete_list() also run their direct
allocations and downstream agtype builders while the current allocation
context is executor-lifetime. Row-local arrays, hash tables, parse state,
temporary strings, entity wrappers, and serialized values therefore
accumulate until the statement ends.

CPU profile

The following profile is Apache AGE master at 80141740, running SET over
100,000 vertices with two properties. It was sampled with:

perf record -F 199 -e cycles:u -g --call-graph dwarf,16384

ResourceOwnerAddToHash() accounts for 66.74% of sampled CPU and
ResourceOwnerForget() for 27.98%. Both stacks originate from the per-row
EState-owned slots described above.

write-memory-baseline-annotated-static

The same workload on the final patched commit has no
ResourceOwnerAddToHash() or ResourceOwnerForget() samples. CPU time is
distributed across the actual property rebuild and PostgreSQL tuple/index
update paths; reclaiming the preceding row's memory context accounts for
1.08% of the patched profile.

write-memory-patched-annotated-static

Implementation

  • Use MakeSingleTupleTableSlot() for the per-entity SET/REMOVE write slot
    and the per-row UPDATE/DELETE RLS slots, then release each with
    ExecDropSingleTupleTableSlot() in the same row.
  • Run update/delete scratch allocation in the writable CustomScan's
    ecxt_per_tuple_memory, and restore the caller's memory context before
    returning.
  • Reset that ExprContext before processing the next SET, REMOVE, DELETE,
    or eager MERGE input row.
  • Keep MERGE's insert slot EState-owned because it is initialized once and
    reused for the node lifetime.
  • Keep MERGE's created_paths_list as statement-lifetime de-duplication state.
    Properties for a newly retained path are copied to es_query_cxt before the
    row context is reset. Duplicate paths do not incur that copy.

The per-row bound applies to expression and writable-clause scratch. MERGE's
created_paths_list still retains one entry per unique path created by the
statement, and its eager result buffer retains one tuple per result row. Both
are required across input rows and are released when the MERGE node ends.

An audit of all ExecInitExtraTupleSlot() call sites found three per-row
uses that require this change. MERGE insert slots, initialization helpers, and
executor-shutdown scans are created once for their intended lifetime and stay
unchanged.

Performance

Environment and method

  • Baseline AGE: 801417404978823bd8732452c3f7959017584785
    (Apache master)
  • Patched AGE: 1ebaf28617d669b25dcfb9b3a1c5545f879210b9
  • PostgreSQL 18.4, --disable-debug --disable-cassert, CFLAGS=-O2 -g
  • Intel Xeon 6982P-C, 8 cores / 16 threads, 29 GiB RAM
  • Local Unix-domain socket
  • shared_buffers=1GB, fsync=off, synchronous_commit=off,
    full_page_writes=off, autovacuum=off, jit=off

The two variants use separate databases bound to their exact age.so files.
Each writable workload shown below has five measured runs in AB/BA-interleaved
variant order. Fixture reset, VACUUM ANALYZE, and library selection are
outside the timed interval. Elapsed values are medians; CV is the sample
coefficient of variation. Memory is the maximum private mapping total sampled
from the backend's /proc/<pid>/smaps_rollup during each statement.

The workloads exercise different parts of the writable executor:

  • SET new key rebuilds each entity's property map while inserting a property.
  • SET existing key replaces a value already present in the property map.
  • REMOVE rebuilds the property map while omitting one existing key.
  • SET += map exercises whole-map merge rather than a single-property update.
  • DELETE does not rebuild or serialize properties, so it isolates the
    per-row slot and scratch-lifetime cost more directly.

100,000 vertices, two properties

Workload Master median (CV) Patched median (CV) Change Speedup
SET new key 12.876 s (0.11%) 635.564 ms (1.46%) -95.06% 20.26x
SET existing key 12.880 s (0.30%) 635.340 ms (0.21%) -95.07% 20.27x
REMOVE 12.869 s (0.20%) 612.503 ms (1.72%) -95.24% 21.01x
SET += map 12.673 s (1.04%) 635.912 ms (1.50%) -94.98% 19.93x
DELETE 483.930 ms (0.13%) 387.853 ms (0.14%) -19.85% 1.25x

Across the four property-update workloads, the median maximum private memory is
1.00-1.12 GiB on master and 23-26 MiB with the patch.
For DELETE, it falls from approximately 271 MiB to 22 MiB.

100,000 vertices, 100 properties

Workload Master median (CV) Patched median (CV) Change Speedup
SET new key 17.542 s (0.53%) 2.756 s (1.22%) -84.29% 6.36x
SET existing key 17.514 s (0.20%) 2.733 s (0.70%) -84.40% 6.41x
REMOVE 17.586 s (0.50%) 2.705 s (0.97%) -84.62% 6.50x
SET += map 16.392 s (0.16%) 2.250 s (0.75%) -86.27% 7.29x

Across the wide-property workloads, the median maximum private memory is
4.90-5.62 GiB on master and 0.33-0.34 GiB with the patch.

The narrow workload demonstrates the slot-lifetime cost directly. The
100-property workload also magnifies scratch retained in the wrong memory
context. Remaining width-dependent agtype serialization work is intentionally
outside this PR.

Reproduction

For a quick timing comparison, run the attached SQL once against Apache master
and once against this branch:

psql -X -d <database> -v row_count=100000 \
  -f repro_write_memory_lifetime.sql

The script builds the fixture outside the measured statements and uses a
transaction rollback after every workload so that each starts from the same
data.

For elapsed time plus backend RSS/private-memory sampling on Linux, use the
attached one-command runner for each installed build:

PGDATABASE=<database> TAG=master ROWS=100000 KEYS=2 \
  ./run_write_memory_benchmark.sh

PGDATABASE=<database> TAG=patched ROWS=100000 KEYS=2 \
  ./run_write_memory_benchmark.sh

Set KEYS=100 for the wide-property case. The runner emits local results under
write-memory-results/ and also covers MERGE ... ON MATCH SET; generated
result files do not need to be attached to the PR.

repro_write_memory_lifetime.sql
run_write_memory_benchmark.sh

Testing

  • PostgreSQL 18.4 release build with COPT=-Werror: 43/43 regression tests
    passed.
  • PostgreSQL 18.4 ASan + cassert + MEMORY_CONTEXT_CHECKING: 43/43 regression
    tests passed, including the new multi-row and repeated-path MERGE coverage.

Suggested review order

  1. src/backend/executor/cypher_set.c: standalone per-row slots and the
    per-tuple allocation boundary.
  2. src/backend/executor/cypher_delete.c: DELETE scratch and RLS slot
    ownership.
  3. src/backend/executor/cypher_merge.c: per-row reset points and preservation
    of cross-row de-duplication properties.
  4. regress/sql/cypher_merge.sql and expected output.

SET and REMOVE create EState-owned tuple slots for every updated entity and
run transient builders in an executor-lifetime memory context. DELETE RLS
slots have the same ownership problem, while MERGE SET shares the update
path. Large writable statements therefore retain slots and scratch until
executor shutdown.

Use standalone slots for per-row writes and RLS checks, release them
explicitly, and allocate update/delete scratch in the writable CustomScan's
per-tuple context. Reset that context before each input row while retaining
node-lifetime slots in the EState. Copy properties for paths retained by
MERGE's cross-row de-duplication state into the query-lifetime context.

Add eager multi-row MERGE coverage for ON CREATE SET and ON MATCH SET, plus
terminal and non-terminal repeated-path coverage across row-context resets.
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.

1 participant