Hi hackers,

I'd like early feedback on a small, self-contained addition to the
TupleTableSlotOps vtable before investing further time in it. Patch
attached (against master @ 086f6f17601).

Problem statement:
------------------

TupleTableSlot's deforming convention is "everything up to the highest
referenced attribute is valid" (tts_nvalid as a dense prefix,
slot_getsomeattrs(slot, natts)). That's the right default for row
stores, but it's a poor fit for any slot type backed by
column-oriented storage, for ex, a simple demonstration query:
```
SELECT col_1, col_2, ..., col_50 FROM wide_table WHERE col_50 = 0;
```
actually needs only col_50 to test at every row (assuming indexes are
not used for this particular example, so seq scan is working), but
current approach forces the slot to also touch attributes 1..49 (which
means for column-oriented storage opening and reading files for each
column), even though the underlying storage could fetch column 50 on
its own with no dependency on the others, and fetch the others only
when the predicate is true.

This is not a new observation - Andres flagged the same convention as
a bottleneck for column-oriented storage back in 2016 ("Rethinking
TupleTableSlot deforming"), and it came up again when
TupleTableSlotOps itself was introduced in 2018, and again in Heikki's
Zedstore work, and again in Soumyadeep Chakraborty's 2020-2021 TableAM
column-projection proposal. Full list of prior threads below - I don't
think any of them shipped a general mechanism, so I'm not assuming
this is uncontroversial, just that the problem is a known one.

What the patch does:
--------------------

Two new optional callbacks on TupleTableSlotOps:

    bool (*gettargetattr) (TupleTableSlot *slot, Bitmapset *attrs);
    bool (*is_attr_valid) (TupleTableSlot *slot, int attnum);

gettargetattr() asks the slot to fetch exactly the (possibly
non-contiguous) attribute set given, instead of a dense prefix. It
returns false if the slot type declines to service this particular
call (e.g. the slot isn't yet bound to live storage), in which case
the caller falls back to the existing slot_getsomeattrs() path.
Therefore, gettargetattr is an optional optimization, and should not
break existing flows if not used. is_attr_valid() lets code that only
knows tts_nvalid as "the valid prefix length" also ask about a
specific attribute that a sparse fetch may have already populated
outside that prefix.

Both are NULL for every existing slot type (TTSOpsVirtual,
TTSOpsHeapTuple, TTSOpsMinimalTuple, TTSOpsBufferHeapTuple). That's
deliberate: this patch changes no observable behavior for any slot
type in core. slot_gettargetattr() returns false when the callback is
NULL, and slot_is_attr_valid() degenerates to exactly the existing
tts_nvalid > attnum check when is_attr_valid is NULL.

On the wiring side: ExprSetupInfo (execExpr.c) gains an all_scan_attrs
Bitmapset, populated during expr_setup_walker() from every plain
scan-level Var (same walk that already computes last_scan, just also
recording each individual attnum rather than only the maximum). It's
passed through to EEOP_SCAN_FETCHSOME via a new ExprEvalStep.d.fetch
.all_vars field. EEOP_SCAN_FETCHSOME tries slot_gettargetattr() first
and falls back to slot_getsomeattrs() exactly as before if it returns
false. EEOP_SCAN_VAR/EEOP_ASSIGN_SCAN_VAR's validity Assert()s switch
from a raw "attnum < tts_nvalid" to slot_is_attr_valid(), since a
sparse fetch can validate an attribute outside the dense prefix.

Note on the Bitmapset convention: all_scan_attrs is 0-based (member =
attnum - 1, matching tts_values[]/tts_isnull[] array indexing) rather
than offset by FirstLowInvalidHeapAttributeNumber the way
attribute-number Bitmapsets elsewhere in the tree usually are (e.g.
pull_varattnos()). That's safe here because FETCHSOME steps never
carry negative/system attribute numbers - those go through the
separate *_SYSVAR opcodes - but I'm flagging the inconsistency up
front rather than leaving it for a reviewer to notice.

What's deliberately left out of this round:
-------------------------------------------

To keep this first round reviewable, three related pieces are *not* in
this patch, on purpose:

  1. JIT. The interpreter's EEOP_SCAN_FETCHSOME picks up the new
     fallback; the JIT-compiled equivalent does not, so a JIT-compiled
     plan will silently keep using the dense path for
     this opcode even after this patch.

  2. Two single-Var interpreter fast paths, ExecJustScanVar() and
     ExecJustAssignScanVar(), which bypass the general opcode
     interpreter entirely (and so bypass gettargetattr()) whenever an
     expression collapses to exactly one bare column reference. This
     may be a real gap - it means "SELECT single_col FROM wide_table"
     doesn't benefit from this mechanism as it stands - but our
     initial performance testing of a fix for it didn't show a benefit
     (as our underlying column-storage can handle such cases on its
     own), so it's held back pending better data rather than included
     speculatively.

  3. The equivalent change for
     EEOP_INNER_FETCHSOME/EEOP_OUTER_FETCHSOME and the inner/outer
     VAR/ASSIGN_VAR opcodes. Same reasoning as (2): the measured
     benefit wasn't clean enough yet to bring here.

Happy to bring (2) and (3) back as follow-ups if the core shape here
is acceptable and once we have a cleaner performance case for them.

Motivation:
-----------

This is infrastructure with no consumer in core - I want to be upfront
about that rather than have it surface as a "why would we want this"
question partway through review. The motivating use case is any
external columnar table access method, where the win is avoiding I/O
for unreferenced columns entirely, not just avoiding in-memory deform
work for them.

We've tested the change on the Greengage DB, and together with the
related changes for the specific tuple table slot for column-oriented
storage, the benefit could be very good (especially for queries with
low selectivity that have filtering on a small subset of projected
columns, allowing to throw away most part of tuples without full
reading them from the file system).


Similar existing threads:
-------------------------

- "Rethinking TupleTableSlot deforming" (Andres Freund, 2016) -
https://www.postgresql.org/message-id/flat/20160722015605.hpthk7axm6sx2mur%40alap3.anarazel.de

- "TupleTableSlot abstraction" (Andres Freund, 2018) -
https://www.postgresql.org/message-id/flat/20180220224318.gw4oe5jadhpmcdnm%40alap3.anarazel.de

- Zedstore (Heikki Linnakangas et al., 2019) -
https://www.postgresql.org/message-id/flat/CALfoeiuF-m5jg51mJUPm5GN8u396o5sA2AF5N97vTRAEDYac7w%40mail.gmail.com

- "Table AM modifications to accept column projection lists"
  (Soumyadeep Chakraborty, 2020-2021) -
https://www.postgresql.org/message-id/flat/CAE-ML%2B9RmTNzKCNTZPQf8O3b-UjHWGFbSoXpQa3Wvuc8YBbEQw%40mail.gmail.com

- "More speedups for tuple deformation" (David Rowley, Jan-Apr 2026) -
    the part committed in PG19 speeds up the existing dense deform
    loop (still walks every attribute, just faster); a subset-column
    variant was floated in the same thread but never implemented,
    which is closer to this patch, but at the heap/offset layer rather
    than the slot vtable layer.
https://www.postgresql.org/message-id/flat/CAEG8a3KeKcZxJsH9nL%2BD1JzC4Ekx51ps7-1ZGWkwdXbPS5jTXw%40mail.gmail.com#63ab266a1f1ffff3463abdc071993ca8


Thanks for reading this far. Patch attached.
Looking forward to receiving feedback.

Best regards,
Roman Eskin
diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index cfea7e160c2..f06c7367892 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -64,6 +64,15 @@ typedef struct ExprSetupInfo
        AttrNumber      last_scan;
        AttrNumber      last_old;
        AttrNumber      last_new;
+       /*
+        * All attribute numbers fetched from scan tuple slots, for use by
+        * slot_gettargetattr().  Unlike most Bitmapsets of attribute numbers
+        * elsewhere in the codebase (e.g. pull_varattnos()), this is *not*
+        * offset by FirstLowInvalidHeapAttributeNumber: members are plain
+        * zero-based tts_values/tts_isnull array indexes (attnum - 1).  System
+        * columns never appear here, since FETCHSOME steps never fetch them.
+        */
+       Bitmapset  *all_scan_attrs;
        /* MULTIEXPR SubPlan nodes appearing in the expression: */
        List       *multiexpr_subplans;
 } ExprSetupInfo;
@@ -557,7 +566,7 @@ ExecBuildUpdateProjection(List *targetList,
        int                     nAssignableCols;
        bool            sawJunk;
        Bitmapset  *assignedCols;
-       ExprSetupInfo deform = {0, 0, 0, 0, 0, NIL};
+       ExprSetupInfo deform = {0, 0, 0, 0, 0, NULL, NIL};
        ExprEvalStep scratch = {0};
        int                     outerattnum;
        ListCell   *lc,
@@ -2875,7 +2884,7 @@ ExecInitSubPlanExpr(SubPlan *subplan,
 static void
 ExecCreateExprSetupSteps(ExprState *state, Node *node)
 {
-       ExprSetupInfo info = {0, 0, 0, 0, 0, NIL};
+       ExprSetupInfo info = {0, 0, 0, 0, 0, NULL, NIL};
 
        /* Prescan to find out what we need. */
        expr_setup_walker(node, &info);
@@ -2905,6 +2914,7 @@ ExecPushExprSetupSteps(ExprState *state, ExprSetupInfo 
*info)
        {
                scratch.opcode = EEOP_INNER_FETCHSOME;
                scratch.d.fetch.last_var = info->last_inner;
+               scratch.d.fetch.all_vars = NULL;
                scratch.d.fetch.fixed = false;
                scratch.d.fetch.kind = NULL;
                scratch.d.fetch.known_desc = NULL;
@@ -2915,6 +2925,7 @@ ExecPushExprSetupSteps(ExprState *state, ExprSetupInfo 
*info)
        {
                scratch.opcode = EEOP_OUTER_FETCHSOME;
                scratch.d.fetch.last_var = info->last_outer;
+               scratch.d.fetch.all_vars = NULL;
                scratch.d.fetch.fixed = false;
                scratch.d.fetch.kind = NULL;
                scratch.d.fetch.known_desc = NULL;
@@ -2925,6 +2936,7 @@ ExecPushExprSetupSteps(ExprState *state, ExprSetupInfo 
*info)
        {
                scratch.opcode = EEOP_SCAN_FETCHSOME;
                scratch.d.fetch.last_var = info->last_scan;
+               scratch.d.fetch.all_vars = info->all_scan_attrs;
                scratch.d.fetch.fixed = false;
                scratch.d.fetch.kind = NULL;
                scratch.d.fetch.known_desc = NULL;
@@ -2935,6 +2947,7 @@ ExecPushExprSetupSteps(ExprState *state, ExprSetupInfo 
*info)
        {
                scratch.opcode = EEOP_OLD_FETCHSOME;
                scratch.d.fetch.last_var = info->last_old;
+               scratch.d.fetch.all_vars = NULL;
                scratch.d.fetch.fixed = false;
                scratch.d.fetch.kind = NULL;
                scratch.d.fetch.known_desc = NULL;
@@ -2945,6 +2958,7 @@ ExecPushExprSetupSteps(ExprState *state, ExprSetupInfo 
*info)
        {
                scratch.opcode = EEOP_NEW_FETCHSOME;
                scratch.d.fetch.last_var = info->last_new;
+               scratch.d.fetch.all_vars = NULL;
                scratch.d.fetch.fixed = false;
                scratch.d.fetch.kind = NULL;
                scratch.d.fetch.known_desc = NULL;
@@ -3001,6 +3015,8 @@ expr_setup_walker(Node *node, ExprSetupInfo *info)
                                {
                                        case VAR_RETURNING_DEFAULT:
                                                info->last_scan = 
Max(info->last_scan, attnum);
+                                               if (attnum > 0)
+                                                       info->all_scan_attrs = 
bms_add_member(info->all_scan_attrs, attnum - 1);
                                                break;
                                        case VAR_RETURNING_OLD:
                                                info->last_old = 
Max(info->last_old, attnum);
@@ -3675,7 +3691,7 @@ ExecBuildAggTrans(AggState *aggstate, AggStatePerPhase 
phase,
        PlanState  *parent = &aggstate->ss.ps;
        ExprEvalStep scratch = {0};
        bool            isCombine = DO_AGGSPLIT_COMBINE(aggstate->aggsplit);
-       ExprSetupInfo deform = {0, 0, 0, 0, 0, NIL};
+       ExprSetupInfo deform = {0, 0, 0, 0, 0, NULL, NIL};
 
        state->expr = (Expr *) aggstate;
        state->parent = parent;
diff --git a/src/backend/executor/execExprInterp.c 
b/src/backend/executor/execExprInterp.c
index 9bc23cb16fa..da973bf62e0 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -663,7 +663,8 @@ ExecInterpExpr(ExprState *state, ExprContext *econtext, 
bool *isnull)
                {
                        CheckOpSlotCompatibility(op, scanslot);
 
-                       slot_getsomeattrs(scanslot, op->d.fetch.last_var);
+                       if (!slot_gettargetattr(scanslot, op->d.fetch.all_vars))
+                               slot_getsomeattrs(scanslot, 
op->d.fetch.last_var);
 
                        EEO_NEXT();
                }
@@ -722,7 +723,7 @@ ExecInterpExpr(ExprState *state, ExprContext *econtext, 
bool *isnull)
 
                        /* See EEOP_INNER_VAR comments */
 
-                       Assert(attnum >= 0 && attnum < scanslot->tts_nvalid);
+                       Assert(attnum >= 0 && slot_is_attr_valid(scanslot, 
attnum));
                        *op->resvalue = scanslot->tts_values[attnum];
                        *op->resnull = scanslot->tts_isnull[attnum];
 
@@ -836,7 +837,7 @@ ExecInterpExpr(ExprState *state, ExprContext *econtext, 
bool *isnull)
                         * We do not need CheckVarSlotCompatibility here; that 
was taken
                         * care of at compilation time.  But see EEOP_INNER_VAR 
comments.
                         */
-                       Assert(attnum >= 0 && attnum < scanslot->tts_nvalid);
+                       Assert(attnum >= 0 && slot_is_attr_valid(scanslot, 
attnum));
                        Assert(resultnum >= 0 && resultnum < 
resultslot->tts_tupleDescriptor->natts);
                        resultslot->tts_values[resultnum] = 
scanslot->tts_values[attnum];
                        resultslot->tts_isnull[resultnum] = 
scanslot->tts_isnull[attnum];
diff --git a/src/backend/executor/execTuples.c 
b/src/backend/executor/execTuples.c
index 3ad983c7fa5..cab0253c991 100644
--- a/src/backend/executor/execTuples.c
+++ b/src/backend/executor/execTuples.c
@@ -1291,7 +1291,10 @@ const TupleTableSlotOps TTSOpsVirtual = {
        .get_heap_tuple = NULL,
        .get_minimal_tuple = NULL,
        .copy_heap_tuple = tts_virtual_copy_heap_tuple,
-       .copy_minimal_tuple = tts_virtual_copy_minimal_tuple
+       .copy_minimal_tuple = tts_virtual_copy_minimal_tuple,
+
+       .gettargetattr = NULL,
+       .is_attr_valid = NULL
 };
 
 const TupleTableSlotOps TTSOpsHeapTuple = {
@@ -1309,7 +1312,10 @@ const TupleTableSlotOps TTSOpsHeapTuple = {
        /* A heap tuple table slot can not "own" a minimal tuple. */
        .get_minimal_tuple = NULL,
        .copy_heap_tuple = tts_heap_copy_heap_tuple,
-       .copy_minimal_tuple = tts_heap_copy_minimal_tuple
+       .copy_minimal_tuple = tts_heap_copy_minimal_tuple,
+
+       .gettargetattr = NULL,
+       .is_attr_valid = NULL
 };
 
 const TupleTableSlotOps TTSOpsMinimalTuple = {
@@ -1327,7 +1333,10 @@ const TupleTableSlotOps TTSOpsMinimalTuple = {
        .get_heap_tuple = NULL,
        .get_minimal_tuple = tts_minimal_get_minimal_tuple,
        .copy_heap_tuple = tts_minimal_copy_heap_tuple,
-       .copy_minimal_tuple = tts_minimal_copy_minimal_tuple
+       .copy_minimal_tuple = tts_minimal_copy_minimal_tuple,
+
+       .gettargetattr = NULL,
+       .is_attr_valid = NULL
 };
 
 const TupleTableSlotOps TTSOpsBufferHeapTuple = {
@@ -1345,7 +1354,10 @@ const TupleTableSlotOps TTSOpsBufferHeapTuple = {
        /* A buffer heap tuple table slot can not "own" a minimal tuple. */
        .get_minimal_tuple = NULL,
        .copy_heap_tuple = tts_buffer_heap_copy_heap_tuple,
-       .copy_minimal_tuple = tts_buffer_heap_copy_minimal_tuple
+       .copy_minimal_tuple = tts_buffer_heap_copy_minimal_tuple,
+
+       .gettargetattr = NULL,
+       .is_attr_valid = NULL
 };
 
 
@@ -2199,6 +2211,22 @@ slot_getsomeattrs_int(TupleTableSlot *slot, int attnum)
         */
 }
 
+/*
+ * slot_gettargetattr - fetch exactly the given (possibly sparse) set of
+ * attributes, for slot types that support it (see TupleTableSlotOps).
+ *
+ * Returns false, doing nothing, if the slot type has no gettargetattr
+ * callback, so the caller can fall back to slot_getsomeattrs().
+ */
+bool
+slot_gettargetattr(TupleTableSlot *slot, Bitmapset *attrs)
+{
+       if (slot->tts_ops->gettargetattr == NULL)
+               return false;
+
+       return slot->tts_ops->gettargetattr(slot, attrs);
+}
+
 /* ----------------------------------------------------------------
  *             ExecTypeFromTL
  *
diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h
index c61b3d624d5..ca4657f1282 100644
--- a/src/include/executor/execExpr.h
+++ b/src/include/executor/execExpr.h
@@ -329,6 +329,8 @@ typedef struct ExprEvalStep
                        TupleDesc       known_desc;
                        /* type of slot, can only be relied upon if fixed is 
set */
                        const TupleTableSlotOps *kind;
+                       /* all att numbers to fetch (if NULL, use `last_var`) */
+                       Bitmapset  *all_vars;
                }                       fetch;
 
                /* for EEOP_INNER/OUTER/SCAN/OLD/NEW_[SYS]VAR */
diff --git a/src/include/executor/tuptable.h b/src/include/executor/tuptable.h
index 3db6c9c9bd0..f062620c475 100644
--- a/src/include/executor/tuptable.h
+++ b/src/include/executor/tuptable.h
@@ -17,6 +17,7 @@
 #include "access/htup.h"
 #include "access/sysattr.h"
 #include "access/tupdesc.h"
+#include "nodes/bitmapset.h"
 #include "storage/buf.h"
 
 /*----------
@@ -123,7 +124,13 @@ typedef struct TupleTableSlot
 #define FIELDNO_TUPLETABLESLOT_FLAGS 1
        uint16          tts_flags;              /* Boolean states */
 #define FIELDNO_TUPLETABLESLOT_NVALID 2
-       AttrNumber      tts_nvalid;             /* # of valid values in 
tts_values */
+       /*
+        * # of valid values in tts_values. Entry in tts_values with index
+        * below tts_nvalid is guaranteed to be valid. But other entries in
+        * tts_values *may* be valid (if fetched via slot_gettargetattr()) and
+        * their validity can be checked via slot_is_attr_valid().
+        */
+       AttrNumber      tts_nvalid;
        const TupleTableSlotOps *const tts_ops; /* implementation of slot */
 #define FIELDNO_TUPLETABLESLOT_TUPLEDESCRIPTOR 4
        TupleDesc       tts_tupleDescriptor;    /* slot's tuple descriptor */
@@ -239,6 +246,20 @@ struct TupleTableSlotOps
         * with the minimal tuple without the need for an additional allocation.
         */
        MinimalTuple (*copy_minimal_tuple) (TupleTableSlot *slot, Size extra);
+
+       /*
+        * Fill up target entries of tts_values and tts_isnull arrays with
+        * values from the tuple contained in the slot. Returns false if the
+        * callback declines to service this particular call (e.g. because the 
slot
+        * isn't yet associated with live storage); caller falls back to
+        * slot_getsomeattrs().
+        */
+       bool            (*gettargetattr) (TupleTableSlot *slot, Bitmapset 
*attrs);
+
+       /*
+        * Check if value for attnum in tts_values and tts_isnull arrays is 
valid.
+        */
+       bool            (*is_attr_valid) (TupleTableSlot *slot, int attnum);
 };
 
 /*
@@ -364,6 +385,7 @@ extern Datum ExecFetchSlotHeapTupleDatum(TupleTableSlot 
*slot);
 extern void slot_getmissingattrs(TupleTableSlot *slot, int startAttNum,
                                                                 int 
lastAttNum);
 extern void slot_getsomeattrs_int(TupleTableSlot *slot, int attnum);
+extern bool slot_gettargetattr(TupleTableSlot *slot, Bitmapset *attrs);
 
 
 #ifndef FRONTEND
@@ -392,6 +414,20 @@ slot_getallattrs(TupleTableSlot *slot)
        slot_getsomeattrs(slot, slot->tts_tupleDescriptor->natts);
 }
 
+/*
+ * This function checks if Datum/isnull array value for attnum is valid.
+ */
+static inline bool
+slot_is_attr_valid(TupleTableSlot *slot, int attnum)
+{
+       if (slot->tts_nvalid > attnum)
+               return true;
+
+       if (slot->tts_ops->is_attr_valid)
+               return slot->tts_ops->is_attr_valid(slot, attnum);
+
+       return false;
+}
 
 /*
  * slot_attisnull

Reply via email to