crdv7 opened a new pull request, #2517:
URL: https://github.com/apache/age/pull/2517

   ## 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:
   
   ```text
   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:
   
   ```text
   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.
   
   <img width="1600" height="718" alt="write-memory-baseline-annotated-static" 
src="https://github.com/user-attachments/assets/deb4d06d-53de-4e28-ba9e-dbd47e4afab1";
 />
   
   
   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.
   
   <img width="1600" height="734" alt="write-memory-patched-annotated-static" 
src="https://github.com/user-attachments/assets/f67a8140-b1c7-405b-93e5-cdc532a78848";
 />
   
   
   ## 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:
   
   ```bash
   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:
   
   ```bash
   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](https://github.com/user-attachments/files/31015151/repro_write_memory_lifetime.sql)
   
[run_write_memory_benchmark.sh](https://github.com/user-attachments/files/31015152/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.
   


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