This is an automated email from the ASF dual-hosted git repository.
jrgemignani pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/age.git
The following commit(s) were added to refs/heads/master by this push:
new 795a881d perf(agtype): arena passthrough + binary-direct id extraction
(#2424)
795a881d is described below
commit 795a881d20d6b7cf74439a8be06289ac3e390dc4
Author: John Gemignani <[email protected]>
AuthorDate: Mon Jun 1 11:52:20 2026 -0700
perf(agtype): arena passthrough + binary-direct id extraction (#2424)
Targets the agtype value-tree allocation cost that dominates LDBC IC4 and
other sort/comparator-bound queries. Two complementary optimizations
(A1 arena, A2 fast-id) plus the sanitizer issues uncovered while
verifying the change.
A1 — per-tuple agtype arena (Pattern B applied):
Add agt_arena_begin / AGT_ARENA_BEGIN_SHARED / agt_arena_reset /
agt_arena_end helpers (agtype_util.c, agtype.h). Two usage patterns:
disposable per-call arenas for one-shot tree builds, and a long-lived
shared arena (file-static, parented under CacheMemoryContext) for hot
inner loops that would otherwise pay AllocSetCreate cost per call.
Apply to compare_agtype_scalar_containers: when the comparator
deserializes VERTEX/EDGE/PATH composites via fill_agtype_value_no_copy,
route those allocations into a shared arena and reset (O(1)) at the
end of the comparison instead of recursively pfree'ing the tree (O(N)).
Master IC4 spent 17.64% of total CPU in pfree_agtype_value_content from
this comparator; the arena collapses that walk to one MemoryContextReset.
A2 — binary-direct id extraction:
When compare_agtype_scalar_containers compares two VERTEX or two EDGE
values, only the graphid 'id' determines order
(compare_agtype_scalar_values
ignores all other fields). Master IC4 spent 73.48% inclusive in
ag_deserialize_composite called from this comparator, building the entire
agtype_value tree (label, properties, nested values) just to read one
int64.
Add extract_composite_id_fast: walks the binary VERTEX/EDGE container
directly and reads the int64 id at field index 0 (always 'id' due to
length-sorted key ordering established by uniqueify_agtype_object — the
same invariant PR #2302 leveraged for direct array indexing).
Wired into compare_agtype_scalar_containers as a fast path before the
existing arena+slow path. Falls through on cross-type comparisons
(VERTEX vs EDGE), PATH, and malformed extended-type entries.
ASAN/UBSan fixes (10 issues found while verifying A1+A2 under
-fsanitize=address -fsanitize=undefined; 1 introduced by A2, 9
pre-existing).
The 11th finding (uninitialized parent_cpstate fields in cypher_analyze.c)
was independently fixed upstream by PR #2423; this branch is rebased on
top of that fix and no longer carries a duplicate change.
- HIGH: heap-buffer-overflow in agtype_raw.c:write_container — VARSIZE
included the 4-byte varlena header but copy started past it, reading
VARHDRSZ bytes past source allocation. Subtract VARHDRSZ. ASan flagged
4 times in installcheck.
- 7 unaligned int64/float8 reads/writes (AGT_HEADER is 4 bytes, leaving
payload 4-byte- but not 8-byte-aligned). UB under strict-alignment
rules. Replaced typed loads/stores with memcpy in agtype_ext.c (4
sites),
agtype_util.c (3 sites incl. extract_composite_id_fast), agtype_raw.c
(write_graphid).
- agtype.c:agtype_hash_cmp: reading r->val.array.raw_scalar at
WAGT_END_ARRAY where the iterator does not populate *val. Replaced
with explicit raw_scalar bool stack tracked across BEGIN/END pairs.
Hash output unchanged.
- agtype_util.c:copy_to_buffer: memcpy(NULL, NULL, 0) is UB. Guard with
if (len > 0).
Performance (SF3 LDBC, paired same-session vs master):
IC sum: 201,930 -> 157,129 ms = -22.19%
IC4: 50,570 -> 7,615 ms = -84.94%
IC5: 21,779 -> 20,646 ms = -5.20%
IS, IU: within noise (<= +/-3% on sub-second queries)
Tests:
34/34 installcheck on production PG18.3
34/34 installcheck on ASAN+UBSan PG18.3 (zero unique runtime errors,
zero heap-buffer-overflows; the original audit found 10 unique
runtime issues plus 1 HBO with 4 instances, all addressed here
or upstream)
Co-authored-by: Claude [email protected]
modified: src/backend/utils/adt/agtype.c
modified: src/backend/utils/adt/agtype_ext.c
modified: src/backend/utils/adt/agtype_raw.c
modified: src/backend/utils/adt/agtype_util.c
modified: src/include/utils/agtype.h
---
src/backend/utils/adt/agtype.c | 57 +++++++-
src/backend/utils/adt/agtype_ext.c | 21 ++-
src/backend/utils/adt/agtype_raw.c | 28 +++-
src/backend/utils/adt/agtype_util.c | 282 ++++++++++++++++++++++++++++++++++--
src/include/utils/agtype.h | 59 ++++++++
5 files changed, 422 insertions(+), 25 deletions(-)
diff --git a/src/backend/utils/adt/agtype.c b/src/backend/utils/adt/agtype.c
index 624d6a88..cfff8138 100644
--- a/src/backend/utils/adt/agtype.c
+++ b/src/backend/utils/adt/agtype.c
@@ -5261,6 +5261,22 @@ Datum agtype_hash_cmp(PG_FUNCTION_ARGS)
agtype_iterator_token tok;
agtype_value *r;
uint64 seed = 0xF0F0F0F0;
+ /*
+ * Stack of "is the current open array a raw_scalar pseudo-array?" so
+ * that the WAGT_END_ARRAY case can match the choice we made at
+ * WAGT_BEGIN_ARRAY. The iterator does not populate *val on END tokens
+ * (see comment above agtype_iterator_next), so r->val.array.raw_scalar
+ * is uninitialized at that point and reading it as a bool is undefined
+ * behavior (UBSan flagged values like 127 here).
+ *
+ * Arrays in agtype are bounded by AGTYPE_CONTAINER_SIZE which fits in
+ * AGT_CMASK; a fixed-size stack of 64 levels is far more than any real
+ * agtype graph value will ever produce. Bail out with an error if we
+ * exceed it rather than silently corrupting the hash.
+ */
+#define HASH_RAW_SCALAR_STACK_DEPTH 64
+ bool raw_scalar_stack[HASH_RAW_SCALAR_STACK_DEPTH];
+ int raw_scalar_top = -1;
/* this function returns INTEGER which is 32 bits */
if (PG_ARGISNULL(0))
@@ -5277,18 +5293,51 @@ Datum agtype_hash_cmp(PG_FUNCTION_ARGS)
{
if (IS_A_AGTYPE_SCALAR(r) && AGTYPE_ITERATOR_TOKEN_IS_HASHABLE(tok))
agtype_hash_scalar_value_extended(r, &hash, seed);
- else if (tok == WAGT_BEGIN_ARRAY && !r->val.array.raw_scalar)
- seed = LEFT_ROTATE(seed, 4);
+ else if (tok == WAGT_BEGIN_ARRAY)
+ {
+ /* push raw_scalar state for the matching END token */
+ raw_scalar_top++;
+ if (raw_scalar_top >= HASH_RAW_SCALAR_STACK_DEPTH)
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED),
+ errmsg("agtype_hash_cmp: array nesting depth exceeds
%d",
+ HASH_RAW_SCALAR_STACK_DEPTH)));
+ }
+ raw_scalar_stack[raw_scalar_top] = r->val.array.raw_scalar;
+ if (!r->val.array.raw_scalar)
+ {
+ seed = LEFT_ROTATE(seed, 4);
+ }
+ }
else if (tok == WAGT_BEGIN_OBJECT)
seed = LEFT_ROTATE(seed, 6);
- else if (tok == WAGT_END_ARRAY && !r->val.array.raw_scalar)
- seed = RIGHT_ROTATE(seed, 4);
+ else if (tok == WAGT_END_ARRAY)
+ {
+ bool was_raw_scalar;
+
+ /* pop matching BEGIN's raw_scalar state */
+ if (raw_scalar_top < 0)
+ {
+ ereport(ERROR,
+ (errcode(ERRCODE_DATA_CORRUPTED),
+ errmsg("agtype_hash_cmp: WAGT_END_ARRAY without
matching WAGT_BEGIN_ARRAY")));
+ }
+ was_raw_scalar = raw_scalar_stack[raw_scalar_top];
+ raw_scalar_top--;
+ if (!was_raw_scalar)
+ {
+ seed = RIGHT_ROTATE(seed, 4);
+ }
+ }
else if (tok == WAGT_END_OBJECT)
seed = RIGHT_ROTATE(seed, 4);
seed = LEFT_ROTATE(seed, 1);
}
+#undef HASH_RAW_SCALAR_STACK_DEPTH
+
pfree_if_not_null(r);
PG_FREE_IF_COPY(agt, 0);
diff --git a/src/backend/utils/adt/agtype_ext.c
b/src/backend/utils/adt/agtype_ext.c
index 7a0ea991..e4a38e3d 100644
--- a/src/backend/utils/adt/agtype_ext.c
+++ b/src/backend/utils/adt/agtype_ext.c
@@ -57,7 +57,14 @@ bool ag_serialize_extended_type(StringInfo buffer, agtentry
*agtentry,
/* copy in the int_value data */
numlen = sizeof(int64);
offset = reserve_from_buffer(buffer, numlen);
- *((int64 *)(buffer->data + offset)) = scalar_val->val.int_value;
+ /*
+ * Use memcpy because the AGT_HEADER (4 bytes) leaves the buffer
+ * write position 4-byte-aligned but not necessarily 8-byte-aligned.
+ * Direct *((int64 *) ...) = ... is undefined behavior under strict
+ * alignment rules (UBSan flags it; works in practice on x86_64).
+ */
+ memcpy(buffer->data + offset, &scalar_val->val.int_value,
+ sizeof(int64));
*agtentry = AGTENTRY_IS_AGTYPE | (padlen + numlen + AGT_HEADER_SIZE);
break;
@@ -68,7 +75,8 @@ bool ag_serialize_extended_type(StringInfo buffer, agtentry
*agtentry,
/* copy in the float_value data */
numlen = sizeof(scalar_val->val.float_value);
offset = reserve_from_buffer(buffer, numlen);
- *((float8 *)(buffer->data + offset)) = scalar_val->val.float_value;
+ memcpy(buffer->data + offset, &scalar_val->val.float_value,
+ sizeof(float8));
*agtentry = AGTENTRY_IS_AGTYPE | (padlen + numlen + AGT_HEADER_SIZE);
break;
@@ -154,12 +162,17 @@ void ag_deserialize_extended_type(char *base_addr, uint32
offset,
{
case AGT_HEADER_INTEGER:
result->type = AGTV_INTEGER;
- result->val.int_value = *((int64 *)(base + AGT_HEADER_SIZE));
+ /* See comment in ag_serialize_extended_type. The 4-byte AGT_HEADER
+ * leaves the int64 at a 4-byte-aligned but not 8-byte-aligned
+ * address, so a direct typed load is undefined behavior. */
+ memcpy(&result->val.int_value, base + AGT_HEADER_SIZE,
+ sizeof(int64));
break;
case AGT_HEADER_FLOAT:
result->type = AGTV_FLOAT;
- result->val.float_value = *((float8 *)(base + AGT_HEADER_SIZE));
+ memcpy(&result->val.float_value, base + AGT_HEADER_SIZE,
+ sizeof(float8));
break;
case AGT_HEADER_VERTEX:
diff --git a/src/backend/utils/adt/agtype_raw.c
b/src/backend/utils/adt/agtype_raw.c
index d8bad3d2..3d56d94d 100644
--- a/src/backend/utils/adt/agtype_raw.c
+++ b/src/backend/utils/adt/agtype_raw.c
@@ -197,9 +197,20 @@ void write_graphid(agtype_build_state *bstate, graphid
graphid)
write_const(AGT_HEADER_INTEGER, AGT_HEADER_TYPE);
length += AGT_HEADER_SIZE;
- /* graphid value */
- write_const(graphid, int64);
- length += sizeof(int64);
+ /*
+ * graphid value (int64). The 4-byte AGT_HEADER above leaves the buffer
+ * write position 4-byte-aligned but not 8-byte-aligned, so the typed
+ * write done by write_const(graphid, int64) is undefined behavior under
+ * strict alignment rules. Use memcpy. (UBSan flags the typed write as
+ * "store to misaligned address ... requires 8 byte alignment".)
+ */
+ {
+ int numlen = sizeof(int64);
+ int g_offset = BUFFER_RESERVE(numlen);
+
+ memcpy(bstate->buffer->data + g_offset, &graphid, sizeof(int64));
+ length += numlen;
+ }
/* agtentry */
write_agt(AGTENTRY_IS_AGTYPE | length);
@@ -214,8 +225,15 @@ void write_container(agtype_build_state *bstate, agtype
*agtype)
/* padding */
length += BUFFER_WRITE_PAD();
- /* varlen data */
- length += write_ptr((char *) &agtype->root, VARSIZE(agtype));
+ /*
+ * Copy the inner agtype_container only, NOT the outer varlena header.
+ * VARSIZE(agtype) reports the total varlena size (including the 4-byte
+ * vl_len_ header), but we are starting our copy at &agtype->root, which
+ * is already past that header. Subtracting VARHDRSZ avoids reading
+ * VARHDRSZ bytes past the source allocation (caught by ASan as
+ * heap-buffer-overflow in __interceptor_memcpy from write_pointer).
+ */
+ length += write_ptr((char *) &agtype->root, VARSIZE(agtype) - VARHDRSZ);
/* agtentry */
write_agt(AGTENTRY_IS_CONTAINER | length);
diff --git a/src/backend/utils/adt/agtype_util.c
b/src/backend/utils/adt/agtype_util.c
index b3972341..90e54070 100644
--- a/src/backend/utils/adt/agtype_util.c
+++ b/src/backend/utils/adt/agtype_util.c
@@ -103,6 +103,66 @@ static int get_type_sort_priority(enum agtype_value_type
type);
static void pfree_iterator_agtype_value_token(agtype_iterator_token token,
agtype_value *agtv);
+/*
+ * Per-call agtype build arena.
+ *
+ * Hot Datum functions in agtype.c build an agtype_value tree, serialize it
+ * via agtype_value_to_agtype(), then recursively free it with
+ * pfree_agtype_value_content(). The recursive free is O(N) in the number of
+ * tree nodes; the arena helpers below let callers replace it with an O(K)
+ * block reset (K = number of allocated AllocSet blocks; typically 1 for
+ * small trees) by allocating the entire tree inside a short-lived
+ * MemoryContext.
+ *
+ * The arena is sized for typical agtype_value trees; it grows on demand.
+ * Cost of creating and deleting the context is amortized against the
+ * eliminated per-node pfree walk and per-node AllocSetFree calls.
+ *
+ * agt_arena_end() is a no-op on NULL, allowing simple cleanup paths.
+ *
+ * The AGT_ARENA_BEGIN_SHARED() macro (declared in agtype.h) returns a
+ * context parented under CacheMemoryContext (process lifetime) so the
+ * caller may stash it in a file-static variable for reuse across many
+ * invocations. The first caller pays AllocSetContextCreate cost;
+ * subsequent callers reuse the context and pay only MemoryContextReset
+ * (cheap; ~100 ns when nothing was allocated since last reset). Use this
+ * for hot inner loops (sort comparators, per-tuple deep-copy workspaces).
+ */
+MemoryContext agt_arena_begin(void)
+{
+ /*
+ * ALLOCSET_SMALL_SIZES (initial=1KB, max=8KB). Most agtype value trees
+ * fit in a single 1KB block.
+ */
+ return AllocSetContextCreate(CurrentMemoryContext,
+ "agtype build arena",
+ ALLOCSET_SMALL_SIZES);
+}
+
+/*
+ * Pattern B (shared, long-lived arena) is exposed as the macro
+ * AGT_ARENA_BEGIN_SHARED in agtype.h, because PG's AllocSetContextCreate
+ * enforces a compile-time-constant name via StaticAssertStmt. The macro
+ * inlines the AllocSet creation at the call site so the name literal is
+ * visible to the StaticAssert.
+ */
+
+void agt_arena_reset(MemoryContext arena)
+{
+ if (arena != NULL)
+ {
+ MemoryContextReset(arena);
+ }
+}
+
+void agt_arena_end(MemoryContext arena)
+{
+ if (arena != NULL)
+ {
+ MemoryContextDelete(arena);
+ }
+}
+
/*
* Turn an in-memory agtype_value into an agtype for on-disk storage.
*
@@ -834,12 +894,18 @@ static void fill_agtype_value_no_copy(agtype_container
*container, int index,
{
case AGT_HEADER_INTEGER:
result->type = AGTV_INTEGER;
- result->val.int_value = *((int64 *)(base + AGT_HEADER_SIZE));
+ /* See ag_serialize_extended_type: the 4-byte AGT_HEADER leaves
+ * the int64 at a 4-byte-aligned but not 8-byte-aligned address.
+ * Use memcpy to avoid undefined behavior on strict alignment
+ * platforms (caught by UBSan). */
+ memcpy(&result->val.int_value, base + AGT_HEADER_SIZE,
+ sizeof(int64));
break;
case AGT_HEADER_FLOAT:
result->type = AGTV_FLOAT;
- result->val.float_value = *((float8 *)(base + AGT_HEADER_SIZE));
+ memcpy(&result->val.float_value, base + AGT_HEADER_SIZE,
+ sizeof(float8));
break;
default:
@@ -880,8 +946,122 @@ static void fill_agtype_value_no_copy(agtype_container
*container, int index,
* of the full iterator machinery. It extracts the scalar values using no-copy
* fill and compares them directly.
*
+ * For composite types (VERTEX, EDGE, PATH) the no-copy fill still
+ * deserializes into newly-allocated memory. To avoid the per-call
+ * pfree_agtype_value_content recursive walk on every sort comparator
+ * invocation, those allocations are routed through a long-lived shared
+ * arena that is reset (O(1)) at the end of each call. The arena is
+ * created lazily on the first call that needs it (i.e. the first
+ * VERTEX/EDGE/PATH comparison) and reused across all subsequent calls in
+ * the process.
+ *
+ * Additional fast path (A2): when both containers are VERTEX or EDGE of
+ * the same type, the comparator only needs the graphid `id` field
+ * (compare_agtype_scalar_values does this; all other fields are ignored).
+ * Skip the full ag_deserialize_composite agtype_value tree build entirely
+ * and walk the binary representation directly to read just the int64 id.
+ * This is the dominant cost of sort-bound queries (notably IC4) since the
+ * tree build is O(properties + label) per row when only one int64 is
+ * needed for ordering.
+ *
* Returns: negative if a < b, 0 if a == b, positive if a > b
*/
+static MemoryContext compare_scalar_arena = NULL;
+
+/*
+ * Fast int64-id extraction from a binary VERTEX or EDGE container.
+ *
+ * Layout of an extended VERTEX/EDGE entry (set up by
ag_serialize_extended_type
+ * with convert_extended_object): a 4-byte AGT_HEADER followed by a standard
+ * agtype_container holding an object whose first field (sorted by key length
+ * ascending) is "id". The id is itself stored as an extended AGTV_INTEGER:
+ * a 4-byte AGT_HEADER_INTEGER followed by an int64.
+ *
+ * Pair layout in an object container: children[0..N-1] are the keys, then
+ * children[N..2N-1] are the values; data follows. The "id" key is at index 0
+ * because "id" (length 2) is the shortest key in both VERTEX (id, label,
+ * properties) and EDGE (id, label, end_id, start_id, properties), and the
+ * agtype object representation sorts pairs by key length ascending.
+ *
+ * On success, writes the extracted graphid through *out and returns true.
+ * Returns false (leaving *out untouched) if the id field is not a well-formed
+ * extended AGTV_INTEGER, signaling the caller to fall back to the slow path.
+ * A bool result is used instead of a sentinel return value because a valid
+ * graphid can legitimately take any int64 value (e.g. label_id 0x8000 with
+ * entry_id 0 equals PG_INT64_MIN), so no in-band sentinel is safe.
+ *
+ * The caller is responsible for ensuring the input container actually holds a
+ * VERTEX or EDGE (i.e. this is only called after AGTE_IS_AGTYPE indicates an
+ * extended type and the AGT_HEADER matches AGT_HEADER_VERTEX or
+ * AGT_HEADER_EDGE).
+ */
+static bool extract_composite_id_fast(const agtype_container *outer_scalar,
+ graphid *out)
+{
+ const char *base_addr;
+ const agtype_container *obj;
+ int n_pairs;
+ const agtentry *children;
+ uint32 id_value_offset;
+ char *id_value_base;
+ char *id_extended_base;
+ AGT_HEADER_TYPE id_header;
+ int id_value_index;
+
+ /* outer_scalar is a single-element pseudo-array; element 0 is our
extended type */
+ base_addr = (const char *)&outer_scalar->children[1];
+
+ /*
+ * Element 0 is an extended type. Its data starts at base_addr + 0 and
+ * begins with AGT_HEADER_VERTEX or AGT_HEADER_EDGE; the inner
+ * agtype_container follows immediately.
+ */
+ obj = (const agtype_container *)((const char *)base_addr +
AGT_HEADER_SIZE);
+ n_pairs = AGTYPE_CONTAINER_SIZE(obj);
+ children = obj->children;
+
+ /*
+ * Pair value 0 ("id") lives at child index n_pairs (values come after
+ * keys). Compute its offset within the object's data area.
+ */
+ id_value_index = n_pairs;
+ id_value_offset = get_agtype_offset(obj, id_value_index);
+
+ /* Data area for this object starts after children[2*n_pairs] */
+ id_value_base = (char *)&children[2 * n_pairs];
+
+ /*
+ * The id value is an extended AGTV_INTEGER: AGT_HEADER (aligned) followed
+ * by the int64. INTALIGN matches what ag_deserialize_extended_type does.
+ */
+ id_extended_base = id_value_base + INTALIGN(id_value_offset);
+ /*
+ * The header is uint32 (4-byte) and lives at a 4-byte-aligned address,
+ * so a typed load is fine. The int64 that follows starts at offset
+ * AGT_HEADER_SIZE = 4 from id_extended_base, so it is 4-byte- but not
+ * necessarily 8-byte-aligned; use memcpy to avoid alignment UB.
+ */
+ memcpy(&id_header, id_extended_base, sizeof(AGT_HEADER_TYPE));
+
+ /*
+ * Defensive: if the value isn't actually an extended integer, fall back
+ * to the slow deserialize path by signaling. AGT_HEADER_INTEGER is the
+ * only valid header for the id field of a well-formed VERTEX/EDGE.
+ */
+ if (id_header != AGT_HEADER_INTEGER)
+ {
+ return false;
+ }
+
+ {
+ graphid id;
+
+ memcpy(&id, id_extended_base + AGT_HEADER_SIZE, sizeof(int64));
+ *out = id;
+ return true;
+ }
+}
+
static int compare_agtype_scalar_containers(agtype_container *a,
agtype_container *b)
{
@@ -892,6 +1072,7 @@ static int
compare_agtype_scalar_containers(agtype_container *a,
int result;
bool need_free_a = false;
bool need_free_b = false;
+ MemoryContext saved_ctx = NULL;
Assert(AGTYPE_CONTAINER_IS_SCALAR(a));
Assert(AGTYPE_CONTAINER_IS_SCALAR(b));
@@ -900,13 +1081,77 @@ static int
compare_agtype_scalar_containers(agtype_container *a,
base_addr_a = (char *)&a->children[1];
base_addr_b = (char *)&b->children[1];
+ /*
+ * A2 fast path: when both sides are extended-type scalars holding the
+ * same composite kind (VERTEX or EDGE), we only need the int64 id field
+ * to determine ordering (see compare_agtype_scalar_values' AGTV_VERTEX
+ * and AGTV_EDGE cases). Skip the full agtype_value tree build entirely.
+ */
+ if (AGTE_IS_AGTYPE(a->children[0]) && AGTE_IS_AGTYPE(b->children[0]))
+ {
+ AGT_HEADER_TYPE ha;
+ AGT_HEADER_TYPE hb;
+
+ /*
+ * The header is uint32 at an INTALIGN'd (4-byte) address, so a typed
+ * load would be safe, but use memcpy for consistency with the rest of
+ * this comparator (extract_composite_id_fast reads the same header via
+ * memcpy) and to stay robust if AGT_HEADER_TYPE is ever widened.
+ */
+ memcpy(&ha,
+ base_addr_a + INTALIGN(get_agtype_offset(a, 0)),
+ sizeof(AGT_HEADER_TYPE));
+ memcpy(&hb,
+ base_addr_b + INTALIGN(get_agtype_offset(b, 0)),
+ sizeof(AGT_HEADER_TYPE));
+
+ if (ha == hb &&
+ (ha == AGT_HEADER_VERTEX || ha == AGT_HEADER_EDGE))
+ {
+ graphid ida;
+ graphid idb;
+
+ /*
+ * extract_composite_id_fast returns false on a malformed id
+ * field; fall through to the slow path in that rare case rather
+ * than producing a wrong comparison.
+ */
+ if (extract_composite_id_fast(a, &ida) &&
+ extract_composite_id_fast(b, &idb))
+ {
+ if (ida == idb)
+ {
+ return 0;
+ }
+ return (ida > idb) ? 1 : -1;
+ }
+ }
+ }
+
+ /*
+ * Peek at the entry types without filling, so we can route any composite
+ * allocations into the shared arena instead of CurrentMemoryContext.
+ * This avoids the recursive pfree walk after every comparison.
+ */
+ if (AGTE_IS_AGTYPE(a->children[0]) || AGTE_IS_AGTYPE(b->children[0]))
+ {
+ if (compare_scalar_arena == NULL)
+ {
+ compare_scalar_arena =
+ AGT_ARENA_BEGIN_SHARED("agtype scalar comparator");
+ }
+ saved_ctx = MemoryContextSwitchTo(compare_scalar_arena);
+ }
+
/* Use no-copy fill to avoid allocations for simple types */
fill_agtype_value_no_copy(a, 0, base_addr_a, 0, &va);
fill_agtype_value_no_copy(b, 0, base_addr_b, 0, &vb);
/*
- * Check if we need to free the values after comparison.
- * Only VERTEX, EDGE, and PATH types allocate memory in no-copy mode.
+ * Track which sides allocated composite content (VERTEX/EDGE/PATH).
+ * For the arena-backed allocations we still need this so we know to
+ * reset the arena at the end; for the (rare) caller-context-backed
+ * fallback path we still need to recursively free.
*/
if (va.type == AGTV_VERTEX || va.type == AGTV_EDGE || va.type == AGTV_PATH)
{
@@ -936,14 +1181,17 @@ static int
compare_agtype_scalar_containers(agtype_container *a,
get_type_sort_priority(vb.type)) ? -1 : 1;
}
- /* Free any allocated memory from composite types */
- if (need_free_a)
- {
- pfree_agtype_value_content(&va);
- }
- if (need_free_b)
+ /*
+ * Cleanup. If we allocated into the shared arena, reset it (O(1));
+ * otherwise the no-copy fill made no allocations to free.
+ */
+ if (saved_ctx != NULL)
{
- pfree_agtype_value_content(&vb);
+ MemoryContextSwitchTo(saved_ctx);
+ if (need_free_a || need_free_b)
+ {
+ agt_arena_reset(compare_scalar_arena);
+ }
}
return result;
@@ -2127,7 +2375,17 @@ int reserve_from_buffer(StringInfo buffer, int len)
static void copy_to_buffer(StringInfo buffer, int offset, const char *data,
int len)
{
- memcpy(buffer->data + offset, data, len);
+ /*
+ * Guard against memcpy(dst, NULL, 0). Some callers (notably empty PATH
+ * scalars with no element bytes) reach here with data == NULL and len
+ * == 0; the C standard says memcpy with a NULL pointer is undefined even
+ * when len == 0. UBSan flags this as "null pointer passed as argument 2,
+ * which is declared to never be null".
+ */
+ if (len > 0)
+ {
+ memcpy(buffer->data + offset, data, len);
+ }
}
/*
diff --git a/src/include/utils/agtype.h b/src/include/utils/agtype.h
index b10474ce..4bddbe4f 100644
--- a/src/include/utils/agtype.h
+++ b/src/include/utils/agtype.h
@@ -32,6 +32,7 @@
#define AG_AGTYPE_H
#include "utils/array.h"
+#include "utils/memutils.h"
#include "utils/numeric.h"
#include "utils/graphid.h"
@@ -581,6 +582,64 @@ agtype_iterator_token agtype_iterator_next(agtype_iterator
**it,
agtype *agtype_value_to_agtype(agtype_value *val);
bool agtype_deep_contains(agtype_iterator **val,
agtype_iterator **m_contained, bool skip_nested);
+
+/*
+ * Per-call agtype build arena.
+ *
+ * Many top-level Datum functions in agtype.c follow the pattern:
+ * 1. Allocate / build an agtype_value tree
+ * 2. Serialize it via agtype_value_to_agtype()
+ * 3. Recursively pfree the tree
+ *
+ * Step 3 (pfree_agtype_value_content) is an O(N) walk that frees each tree
+ * node individually. By building the tree inside a dedicated AllocSet
+ * MemoryContext and resetting the context after serialization, we trade
+ * the O(N) walk for an O(K) block reset (K << N) and eliminate per-node
+ * AllocSetFree overhead. The AllocSetAlloc cost is also reduced because
+ * the arena uses a small initial block size and grows on demand.
+ *
+ * Two usage patterns:
+ *
+ * Pattern A — disposable per-call arena (one-shot):
+ * MemoryContext arena = agt_arena_begin();
+ * MemoryContext caller = MemoryContextSwitchTo(arena);
+ * ... build tree ...
+ * MemoryContextSwitchTo(caller);
+ * result = agtype_value_to_agtype(val); // copies into caller
+ * agt_arena_end(arena); // resets / discards
+ *
+ * Pattern B — long-lived shared arena (sort comparator, hot inner loop):
+ * static MemoryContext shared_arena = NULL;
+ * if (shared_arena == NULL)
+ * {
+ * shared_arena = AGT_ARENA_BEGIN_SHARED("comparator");
+ * }
+ * MemoryContext caller = MemoryContextSwitchTo(shared_arena);
+ * ... allocate / use ...
+ * MemoryContextSwitchTo(caller);
+ * agt_arena_reset(shared_arena); // O(1) cheap reset
+ *
+ * Pattern A creates a fresh context per call (microseconds). Pattern B
+ * amortizes the create cost across many calls (single AllocSetCreate at
+ * the very first call), and pays only a MemoryContextReset (~100 ns) per
+ * subsequent call. Use Pattern B when the function is invoked many times
+ * per query (e.g. a sort comparator called O(N log N) times).
+ */
+MemoryContext agt_arena_begin(void);
+/*
+ * Pattern-B shared-arena creator. Implemented as a macro because PG's
+ * AllocSetContextCreate enforces memory-context names be compile-time
+ * constants via a StaticAssert. Use with a string literal:
+ * static MemoryContext my_arena = NULL;
+ * if (my_arena == NULL)
+ * {
+ * my_arena = AGT_ARENA_BEGIN_SHARED("my comparator workspace");
+ * }
+ */
+#define AGT_ARENA_BEGIN_SHARED(_name) \
+ AllocSetContextCreate(CacheMemoryContext, (_name), ALLOCSET_SMALL_SIZES)
+void agt_arena_reset(MemoryContext arena);
+void agt_arena_end(MemoryContext arena);
void agtype_hash_scalar_value(const agtype_value *scalar_val, uint32 *hash);
void agtype_hash_scalar_value_extended(const agtype_value *scalar_val,
uint64 *hash, uint64 seed);