From be14cfd6538a6e72031ff82b47fe2352edc28a7d Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Mon, 13 Jul 2026 07:57:27 -0400 Subject: [PATCH 1/9] dev: local dev-setup This isn't a commit that will be submitting for review, it is purely for local developer tooling while developing this patch set. Ignore it. --- .clangd | 43 ++ .envrc | 10 + .gdbinit | 156 +++++++ flake.lock | 134 ++++++ flake.nix | 63 +++ glibc-no-fortify-warning.patch | 24 ++ pg-aliases.sh | 703 +++++++++++++++++++++++++++++++ shell.nix | 729 +++++++++++++++++++++++++++++++++ src/test/regress/pg_regress.c | 2 +- src/tools/pgindent/pgindent | 2 +- 10 files changed, 1864 insertions(+), 2 deletions(-) create mode 100644 .clangd create mode 100644 .envrc create mode 100644 .gdbinit create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 glibc-no-fortify-warning.patch create mode 100644 pg-aliases.sh create mode 100644 shell.nix diff --git a/.clangd b/.clangd new file mode 100644 index 0000000000000..490220f56a7c0 --- /dev/null +++ b/.clangd @@ -0,0 +1,43 @@ +Diagnostics: + MissingIncludes: None +InlayHints: + Enabled: true + ParameterNames: true + DeducedTypes: true +CompileFlags: + CompilationDatabase: build/ # Search build/ directory for compile_commands.json + Remove: [ -Werror ] + Add: + - -DDEBUG + - -DLOCAL + - -DPGDLLIMPORT= + - -DPIC + - -O2 + - -Wall + - -Wcast-function-type + - -Wconversion + - -Wdeclaration-after-statement + - -Wendif-labels + - -Werror=vla + - -Wextra + - -Wfloat-equal + - -Wformat-security + - -Wimplicit-fallthrough=3 + - -Wmissing-format-attribute + - -Wmissing-prototypes + - -Wno-format-truncation + - -Wno-sign-conversion + - -Wno-stringop-truncation + - -Wno-unused-const-variable + - -Wpointer-arith + - -Wshadow + - -Wshadow=compatible-local + - -fPIC + - -fexcess-precision=standard + - -fno-strict-aliasing + - -fvisibility=hidden + - -fwrapv + - -g + - -std=c11 + - -I. + - -I../../../../src/include diff --git a/.envrc b/.envrc new file mode 100644 index 0000000000000..cf781a39df685 --- /dev/null +++ b/.envrc @@ -0,0 +1,10 @@ +# Inherit shared CFLAGS from ~/ws/postgres/.envrc, load this worktree's +# flake, then layer the postgres agent context (helpers need flake's PATH + +# write into this worktree's dir). +source_up +watch_file flake.nix +use flake +pg_cflags +use project_steering postgresql aws c +use git_policy allow-force-push +use project_mcp postgresq diff --git a/.gdbinit b/.gdbinit new file mode 100644 index 0000000000000..854e5ecbaf69c --- /dev/null +++ b/.gdbinit @@ -0,0 +1,156 @@ +# HOT Indexed Updates — GDB breakpoints for code review +# +# Usage: gdb -x .gdbinit +# Or from gdb: source .gdbinit +# +# These breakpoints cover the major code paths introduced or modified by +# the HOT indexed updates patch series. They are organized by subsystem +# to make it easy to enable/disable groups during debugging. +# +# Tip: to skip to a specific subsystem, disable all then enable selectively: +# disable breakpoints +# enable 1 2 3 # just the update-decision group + +# ========================================================================= +# 1. UPDATE DECISION — heap_update() HOT/HOT-indexed/non-HOT choice +# src/backend/access/heap/heapam.c +# ========================================================================= + +# Main entry: heap_update +break heapam.c:3210 + +# HOT decision block: pure HOT vs HOT indexed vs non-HOT +# Line 4019: pure HOT (no indexed columns changed) +# Line 4024: HOT indexed path (non-catalog, some indexed columns changed) +# Line 4031: predict augmented tuple size +# Line 4033: size+space check before creating augmented tuple +break heapam.c:4019 +break heapam.c:4024 +break heapam.c:4033 + +# Set HEAP_INDEXED_UPDATED flag on new tuple before page insertion +break heapam.c:4101 + +# Restore HEAP_INDEXED_UPDATED on old tuple (only if it previously had it) +break heapam.c:4147 + +# ========================================================================= +# 2. TUPLE CREATION — building the augmented tuple with embedded bitmap +# src/backend/access/heap/heapam.c +# ========================================================================= + +# Predict augmented tuple size (returns 0 if t_hoff would overflow) +break heap_hot_indexed_tuple_size + +# Create augmented tuple with embedded modified-column bitmap +break heap_hot_indexed_create_tuple + +# Serialize Bitmapset into raw bytes in tuple header +break heap_hot_indexed_serialize_bitmap + +# ========================================================================= +# 3. BITMAP UTILITIES — raw bitmap operations for chain following +# src/backend/access/heap/heapam.c +# ========================================================================= + +# Compute raw bitmap byte size from natts +break heap_hot_indexed_bitmap_raw_size + +# Check if tuple header has room for bitmap between null bitmap and data +break heap_hot_indexed_has_bitmap_space + +# Read HOT indexed bitmap from tuple header (returns Bitmapset) +break heap_hot_indexed_read_bitmap + +# Fast overlap check: does tuple's raw bitmap overlap with indexed_attrs? +break heap_hot_indexed_bitmap_overlaps_raw + +# OR a tuple's raw bitmap into an accumulator buffer +break heap_hot_indexed_bitmap_or_raw + +# Check if accumulated raw bitmap overlaps with indexed_attrs +break heap_hot_indexed_accum_overlaps + +# Merge bitmaps from dead tuples into a target tuple on the page +break heap_hot_indexed_merge_bitmaps_raw + +# Deserialize raw bytes back to Bitmapset +break heap_hot_indexed_deserialize_bitmap + +# ========================================================================= +# 4. INDEX SCAN — HOT chain following with stale-entry detection +# src/backend/access/heap/heapam_indexscan.c +# ========================================================================= + +# Main HOT chain search with indexed update awareness +break heap_hot_search_buffer + +# Redirect-with-data: initialize bitmap accumulator from collapsed redirect +break heapam_indexscan.c:182 + +# Accumulate bitmap from INDEXED_UPDATED tuple in chain +break heapam_indexscan.c:250 + +# Stale entry detection: accumulated bitmap overlaps this index's attrs +break heapam_indexscan.c:297 + +# ========================================================================= +# 5. INDEX SCAN SETUP — indexed_attrs bitmap computation +# src/backend/access/index/indexam.c +# ========================================================================= + +# Compute indexed_attrs for HOT indexed update chain following +break indexam.c:299 + +# ========================================================================= +# 6. INDEX INSERTION — skip unchanged indexes for HOT indexed updates +# src/backend/executor/execIndexing.c +# ========================================================================= + +# Entry: insert/update index tuples +break ExecInsertIndexTuples + +# Index skip decision: skip indexes whose attrs don't overlap modified set +break execIndexing.c:370 + +# ========================================================================= +# 7. PRUNING — chain collapsing and redirect-with-data +# src/backend/access/heap/pruneheap.c +# ========================================================================= + +# Main prune function +break heap_page_prune_and_freeze + +# Per-chain pruning entry +break heap_prune_chain + +# Chain collapsing: collect bitmaps from dead INDEXED_UPDATED intermediates +break pruneheap.c:1802 + +# OR dead tuple bitmaps into combined bitmap +break pruneheap.c:1836 + +# Record redirect-with-data for execute phase +break pruneheap.c:1863 + +# Execute phase: apply redirect-with-data entries on the page +break pruneheap.c:1287 + +# ========================================================================= +# 8. WAL REPLAY — recovery of HOT indexed updates +# src/backend/access/heap/heapam_xlog.c +# ========================================================================= + +# WAL replay for XLOG_HEAP2_INDEXED_UPDATE +break heap_xlog_indexed_update + +# ========================================================================= +# 9. WAL LOGGING — writing HOT indexed update records +# src/backend/access/heap/heapam.c +# ========================================================================= + +# WAL logging for heap updates (handles indexed_update flag) +break log_heap_update + +# Serialize redirect-with-data into WAL record (pruneheap.c) +break pruneheap.c:2936 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000000000..0444ccd449e02 --- /dev/null +++ b/flake.lock @@ -0,0 +1,134 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "libumem": { + "inputs": { + "flake-utils": "flake-utils_2", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1790620607, + "narHash": "sha256-KJMbA7HNuXhvtgmaMuZSXLf8wqarWHrlUlsSb+FzOLw=", + "ref": "refs/tags/v4.1.0", + "rev": "5f6f9efe505e0efb0697464d3b22443f6b2832c8", + "revCount": 1006, + "type": "git", + "url": "https://codeberg.org/gregburd/libumem" + }, + "original": { + "ref": "refs/tags/v4.1.0", + "type": "git", + "url": "https://codeberg.org/gregburd/libumem" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1785141334, + "narHash": "sha256-kh35kIx7el4Jk8Ki3BH9/Pn1eZYSYLJ6LMALos0zOy0=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "38a4887411571457d700c51c64a6e49ead2ed5ab", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "libumem": "libumem", + "nixpkgs": "nixpkgs", + "nixpkgs-unstable": "nixpkgs-unstable" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000000..4a25f8ff6ee40 --- /dev/null +++ b/flake.nix @@ -0,0 +1,63 @@ +{ + description = "PostgreSQL development environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + + # libumem: LD_PRELOAD malloc, the umem/umemctl CLI, and GDB/LLDB helpers. + # Track the latest tagged release from the repo. Bump the ref to the new + # tag and run `nix flake update libumem` to pick up a newer release; do NOT + # point this at a branch, so the dev shell always builds a released + # version rather than an arbitrary master commit. + # + # Follow the stable nixpkgs so libumem links the same glibc the dev + # shell's base tools (bash, coreutils) use; following unstable produced a + # glibc 2.42-vs-2.40 GLIBC_ABI mismatch when LD_PRELOAD'ing into them. + libumem = { + url = "git+https://codeberg.org/gregburd/libumem?ref=refs/tags/v4.1.0"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { + self, + nixpkgs, + nixpkgs-unstable, + flake-utils, + libumem, + }: + flake-utils.lib.eachDefaultSystem ( + system: let + pkgs = import nixpkgs { + inherit system; + config.allowUnfree = true; + }; + pkgs-unstable = import nixpkgs-unstable { + inherit system; + config.allowUnfree = true; + }; + + libumemPkg = libumem.packages.${system}.libumem; + + shellConfig = import ./shell.nix { + inherit pkgs pkgs-unstable system libumemPkg; + }; + in { + formatter = pkgs.alejandra; + devShells = { + default = shellConfig.devShell; + gcc = shellConfig.devShell; + clang = shellConfig.clangDevShell; + }; + + packages = { + inherit (shellConfig) gdbConfig flameGraphScript pgbenchScript; + libumem = libumemPkg; + }; + + environment.localBinInPath = true; + } + ); +} diff --git a/glibc-no-fortify-warning.patch b/glibc-no-fortify-warning.patch new file mode 100644 index 0000000000000..4657a12adbcc5 --- /dev/null +++ b/glibc-no-fortify-warning.patch @@ -0,0 +1,24 @@ +From 130c231020f97e5eb878cc9fdb2bd9b186a5aa04 Mon Sep 17 00:00:00 2001 +From: Greg Burd +Date: Fri, 24 Oct 2025 11:58:24 -0400 +Subject: [PATCH] no warnings with -O0 and fortify source please + +--- + include/features.h | 1 - + 1 file changed, 1 deletion(-) + +diff --git a/include/features.h b/include/features.h +index 673c4036..a02c8a3f 100644 +--- a/include/features.h ++++ b/include/features.h +@@ -432,7 +432,6 @@ + + #if defined _FORTIFY_SOURCE && _FORTIFY_SOURCE > 0 + # if !defined __OPTIMIZE__ || __OPTIMIZE__ <= 0 +-# warning _FORTIFY_SOURCE requires compiling with optimization (-O) + # elif !__GNUC_PREREQ (4, 1) + # warning _FORTIFY_SOURCE requires GCC 4.1 or later + # elif _FORTIFY_SOURCE > 2 && (__glibc_clang_prereq (9, 0) \ +-- +2.50.1 + diff --git a/pg-aliases.sh b/pg-aliases.sh new file mode 100644 index 0000000000000..42bddff4c2730 --- /dev/null +++ b/pg-aliases.sh @@ -0,0 +1,703 @@ +# PostgreSQL Development Aliases + +# ============================================================ +# Build helpers shared by every variant. +# ============================================================ +pg_clean_for_compiler() { + local current_compiler="$(basename $CC)" + local build_dir="${1:-$PG_BUILD_DIR}" + + if [ -f "$build_dir/compile_commands.json" ]; then + local last_compiler=$(grep -o '/[^/]*/bin/[gc]cc\|/[^/]*/bin/clang' "$build_dir/compile_commands.json" | head -1 | xargs basename 2>/dev/null || echo "unknown") + + if [ "$last_compiler" != "$current_compiler" ] && [ "$last_compiler" != "unknown" ]; then + echo "Detected compiler change from $last_compiler to $current_compiler" + echo "Cleaning build directory..." + trash "$build_dir" 2>/dev/null || rm -rf "$build_dir" + mkdir -p "$build_dir" + fi + fi + + mkdir -p "$build_dir" + echo "$current_compiler" >"$build_dir/.compiler_used" +} + +# ============================================================ +# Core PostgreSQL commands (default/debug build) +# ============================================================ +alias pg-setup=' + if [ -z "$PERL_CORE_DIR" ]; then + echo "Error: Could not find perl CORE directory" >&2 + return 1 + fi + + pg_clean_for_compiler "$PG_BUILD_DIR" + + echo "=== PostgreSQL Build Configuration ===" + echo "Compiler: $CC" + echo "LLVM: $(llvm-config --version 2>/dev/null || echo disabled)" + echo "Source: $PG_SOURCE_DIR" + echo "Build: $PG_BUILD_DIR" + echo "Install: $PG_INSTALL_DIR" + echo "======================================" + + env CFLAGS="-I$PERL_CORE_DIR $CFLAGS" \ + LDFLAGS="-L$PERL_CORE_DIR -lperl $LDFLAGS" \ + meson setup $MESON_EXTRA_SETUP \ + --reconfigure \ + -Doptimization=g \ + -Ddebug=true \ + -Db_sanitize=none \ + -Db_lundef=false \ + -Dlz4=enabled \ + -Dzstd=enabled \ + -Dllvm=disabled \ + -Dplperl=enabled \ + -Dplpython=enabled \ + -Dpltcl=enabled \ + -Dlibxml=enabled \ + -Duuid=e2fs \ + -Dlibxslt=enabled \ + -Dssl=openssl \ + -Dldap=disabled \ + -Dcassert=true \ + -Dtap_tests=enabled \ + -Dinjection_points=true \ + -Ddocs_pdf=enabled \ + -Ddocs_html_style=website \ + --prefix="$PG_INSTALL_DIR" \ + "$PG_BUILD_DIR" \ + "$PG_SOURCE_DIR"' + +alias pg-compdb='compdb -p build/ list > compile_commands.json' +alias pg-build='meson compile -C "$PG_BUILD_DIR"' +alias pg-install='meson install -C "$PG_BUILD_DIR"' +alias pg-test='meson test -q --print-errorlogs -C "$PG_BUILD_DIR"' + +# Clean commands +alias pg-clean='ninja -C "$PG_BUILD_DIR" clean' +alias pg-full-clean='trash "$PG_BUILD_DIR" "$PG_INSTALL_DIR" 2>/dev/null || rm -rf "$PG_BUILD_DIR" "$PG_INSTALL_DIR"; echo "Build and install directories cleaned"' + +# Database management +alias pg-init='trash "$PG_DATA_DIR" 2>/dev/null || rm -rf "$PG_DATA_DIR"; "$PG_INSTALL_DIR/bin/initdb" --debug --no-clean "$PG_DATA_DIR"' + +alias pg-start='ulimit -c unlimited && "$PG_INSTALL_DIR/bin/postgres" -D "$PG_DATA_DIR" -k "$PG_DATA_DIR"' + +alias pg-stop='pkill -f "postgres.*-D.*$PG_DATA_DIR" || true' +alias pg-restart='pg-stop && sleep 2 && pg-start' +alias pg-status='pgrep -f "postgres.*-D.*$PG_DATA_DIR" && echo "PostgreSQL is running" || echo "PostgreSQL is not running"' + +# Client connections +alias pg-psql='"$PG_INSTALL_DIR/bin/psql" -h "$PG_DATA_DIR" postgres' +alias pg-createdb='"$PG_INSTALL_DIR/bin/createdb" -h "$PG_DATA_DIR"' +alias pg-dropdb='"$PG_INSTALL_DIR/bin/dropdb" -h "$PG_DATA_DIR"' + +# ============================================================ +# Debugger attachments +# ============================================================ +alias pg-debug-gdb='gdb -x "$GDBINIT" -x .gdbinit "$PG_INSTALL_DIR/bin/postgres"' +alias pg-debug-lldb='lldb "$PG_INSTALL_DIR/bin/postgres"' +alias pg-debug=' + if command -v gdb >/dev/null 2>&1; then + pg-debug-gdb + elif command -v lldb >/dev/null 2>&1; then + pg-debug-lldb + else + echo "No debugger available (gdb or lldb required)" + fi' + +alias pg-attach-gdb=' + PG_PID=$(pgrep -f "postgres.*-D.*$PG_DATA_DIR" | head -1) + if [ -n "$PG_PID" ]; then + echo "Attaching GDB to PostgreSQL process $PG_PID" + gdb -x "$GDBINIT" -x .gdbinit -p "$PG_PID" + else + echo "No PostgreSQL process found" + fi' + +alias pg-attach-lldb=' + PG_PID=$(pgrep -f "postgres.*-D.*$PG_DATA_DIR" | head -1) + if [ -n "$PG_PID" ]; then + echo "Attaching LLDB to PostgreSQL process $PG_PID" + lldb -p "$PG_PID" + else + echo "No PostgreSQL process found" + fi' + +alias pg-attach=' + if command -v gdb >/dev/null 2>&1; then + pg-attach-gdb + elif command -v lldb >/dev/null 2>&1; then + pg-attach-lldb + else + echo "No debugger available (gdb or lldb required)" + fi' + +# ============================================================ +# libumem (LD_PRELOAD malloc + umem/umemctl CLI + GDB/LLDB helpers) +# ============================================================ +# The nix devShell exports PG_UMEM_PRELOAD / PG_UMEM_GDB_HELPER / +# PG_UMEM_LLDB_HELPER and puts umem, umemctl and pg-umem-preload on PATH. +# These aliases wire libumem into the normal pg-start / debug flow. + +# Start the server with libumem's malloc LD_PRELOAD'ed (heap debugging). +alias pg-start-umem='ulimit -c unlimited && pg-umem-preload "$PG_INSTALL_DIR/bin/postgres" -D "$PG_DATA_DIR" -k "$PG_DATA_DIR"' + +# umemctl against the running postgres backend (default: the postmaster). +# Usage: pg-umem-ctl [args] (stats|caches|cache |whatis |leaks) +# pg-umem-ctl -p ... to target a specific backend +pg-umem-ctl() { + local pid + if [ "$1" = "-p" ]; then pid="$2"; shift 2; + else pid=$(pgrep -f "postgres.*-D.*$PG_DATA_DIR" | head -1); fi + if [ -z "$pid" ]; then echo "No PostgreSQL process found"; return 1; fi + umemctl "$pid" "$@" +} +alias pg-umem-stats='pg-umem-ctl stats' +alias pg-umem-leaks='pg-umem-ctl leaks' +alias pg-umem-caches='pg-umem-ctl caches' + +# Attach GDB with the libumem extension loaded (adds the `umem ...` commands). +# The postgres gdbinit already sources the helper, but this is explicit and +# also works when attaching to a non-postgres umem process. +alias pg-umem-gdb=' + PG_PID=$(pgrep -f "postgres.*-D.*$PG_DATA_DIR" | head -1) + if [ -n "$PG_PID" ]; then + gdb -x "$GDBINIT" -ex "source $PG_UMEM_GDB_HELPER" -p "$PG_PID" + else + echo "No PostgreSQL process found" + fi' + +# Attach LLDB with the libumem Python extension imported. +alias pg-umem-lldb=' + PG_PID=$(pgrep -f "postgres.*-D.*$PG_DATA_DIR" | head -1) + if [ -n "$PG_PID" ]; then + lldb -o "command script import $PG_UMEM_LLDB_HELPER" -p "$PG_PID" + else + echo "No PostgreSQL process found" + fi' + +# ============================================================ +# Valgrind-instrumented build and tests +# +# The valgrind build lives in a separate directory so the normal +# build stays warm. Runs use a wrapper dir that shadows `postgres` +# with a valgrind wrapper -- pg_regress finds it via PATH. +# ============================================================ +pg-build-valgrind() { + local bdir="$PG_BUILD_DIR_VALGRIND" + if [ -z "$PERL_CORE_DIR" ]; then + echo "Error: PERL_CORE_DIR is not set" >&2 + return 1 + fi + + pg_clean_for_compiler "$bdir" + + echo "=== Configuring Valgrind build in $bdir ===" + env CFLAGS="-Og -ggdb3 -fno-omit-frame-pointer -DUSE_VALGRIND -I$PERL_CORE_DIR $CFLAGS" \ + LDFLAGS="-L$PERL_CORE_DIR -lperl $LDFLAGS" \ + meson setup --reconfigure \ + -Doptimization=g \ + -Ddebug=true \ + -Dcassert=true \ + -Dtap_tests=enabled \ + -Dinjection_points=true \ + -Dllvm=disabled \ + -Dplperl=enabled -Dplpython=enabled -Dpltcl=enabled \ + -Dlz4=enabled -Dzstd=enabled \ + -Dlibxml=enabled -Dlibxslt=enabled -Dssl=openssl -Duuid=e2fs \ + -Dldap=disabled \ + --prefix="$PG_INSTALL_DIR-valgrind" \ + "$bdir" "$PG_SOURCE_DIR" || return 1 + + meson compile -C "$bdir" +} + +# Drop a wrapper directory that shadows the real binaries; `postgres` +# exec's into valgrind, everything else is a symlink. Writes to the +# supplied wrap dir and echoes its path. +_pg_make_valgrind_wrapper() { + local bindir="$1" + local wrapdir="$2" + + mkdir -p "$wrapdir" + cat >"$wrapdir/postgres" <&2 + return 1 + fi + + local tmpbin="$bdir/tmp_install$PG_INSTALL_DIR-valgrind/bin" + if [ ! -x "$tmpbin/postgres" ]; then + echo "Populating tmp_install..." + meson test -C "$bdir" tmp_install install_test_files initdb_cache >/dev/null || return 1 + fi + + local wrap + wrap=$(mktemp -d /tmp/pg-vg-wrap-XXXXXX) + _pg_make_valgrind_wrapper "$tmpbin" "$wrap" + + mkdir -p "$PG_BENCH_DIR" + echo "Valgrind logs: $PG_BENCH_DIR/valgrind-*.log" + echo "Wrapper dir: $wrap (will be removed on exit)" + echo "Expect the regress suite to take 15-45 minutes under valgrind." + + local rc=0 + (cd "$bdir" && PATH="$wrap:$PATH" meson test -t 60 --print-errorlogs regress/regress) || rc=$? + + trash "$wrap" 2>/dev/null || rm -rf "$wrap" + return "$rc" +} + +pg-valgrind-test() { + local bdir="$PG_BUILD_DIR_VALGRIND" + if [ ! -x "$bdir/src/backend/postgres" ]; then + echo "Valgrind build not found; run 'pg-build-valgrind' first." >&2 + return 1 + fi + + echo "This runs the FULL postgres test suite under valgrind." + echo "Expect many hours, and tens of GB of valgrind log output." + echo "Logs: $PG_BENCH_DIR/valgrind-*.log" + local yn + read -r -p "Continue? [y/N] " yn + case "$yn" in + y | Y | yes) ;; + *) echo "Aborted."; return 0 ;; + esac + + local tmpbin="$bdir/tmp_install$PG_INSTALL_DIR-valgrind/bin" + if [ ! -x "$tmpbin/postgres" ]; then + echo "Populating tmp_install..." + meson test -C "$bdir" tmp_install install_test_files initdb_cache >/dev/null || return 1 + fi + + local wrap + wrap=$(mktemp -d /tmp/pg-vg-wrap-XXXXXX) + _pg_make_valgrind_wrapper "$tmpbin" "$wrap" + mkdir -p "$PG_BENCH_DIR" + + local rc=0 + (cd "$bdir" && PATH="$wrap:$PATH" meson test -t 60 --print-errorlogs) || rc=$? + + trash "$wrap" 2>/dev/null || rm -rf "$wrap" + return "$rc" +} + +# ============================================================ +# AddressSanitizer / UndefinedBehaviorSanitizer build and tests +# ============================================================ +pg-build-asan() { + local bdir="$PG_BUILD_DIR_ASAN" + if [ -z "$PERL_CORE_DIR" ]; then + echo "Error: PERL_CORE_DIR is not set" >&2 + return 1 + fi + + pg_clean_for_compiler "$bdir" + + echo "=== Configuring ASan+UBSan build in $bdir ===" + env CFLAGS="-Og -ggdb3 -fno-omit-frame-pointer -fsanitize=address,undefined -fno-sanitize-recover=all -I$PERL_CORE_DIR $CFLAGS" \ + LDFLAGS="-fsanitize=address,undefined -L$PERL_CORE_DIR -lperl $LDFLAGS" \ + meson setup --reconfigure \ + -Doptimization=g \ + -Ddebug=true \ + -Dcassert=true \ + -Dtap_tests=enabled \ + -Dinjection_points=true \ + -Dllvm=disabled \ + -Dplperl=enabled -Dplpython=enabled -Dpltcl=enabled \ + -Dlz4=enabled -Dzstd=enabled \ + -Dlibxml=enabled -Dlibxslt=enabled -Dssl=openssl -Duuid=e2fs \ + -Dldap=disabled \ + --prefix="$PG_INSTALL_DIR-asan" \ + "$bdir" "$PG_SOURCE_DIR" || return 1 + + meson compile -C "$bdir" +} + +pg-asan-regress() { + local bdir="$PG_BUILD_DIR_ASAN" + if [ ! -x "$bdir/src/backend/postgres" ]; then + echo "ASan build not found; run 'pg-build-asan' first." >&2 + return 1 + fi + + # halt_on_error=0 lets regress continue past the first diagnostic so + # the whole suite runs; abort_on_error=1 makes each hit fail the test. + ASAN_OPTIONS="halt_on_error=0:abort_on_error=1:detect_leaks=0:print_summary=1:print_stacktrace=1" \ + UBSAN_OPTIONS="halt_on_error=1:abort_on_error=1:print_stacktrace=1:print_summary=1" \ + meson test -t 5 --print-errorlogs -C "$bdir" regress/regress +} + +# ============================================================ +# rr (deterministic record-and-replay) +# Requires kernel.perf_event_paranoid <= 1. rr is the single most +# effective tool for postgres bugs that reproduce intermittently. +# ============================================================ +pg-rr-check() { + if ! command -v rr >/dev/null; then + echo "rr is not installed (expected in the dev shell)." >&2 + return 1 + fi + local paranoid + paranoid=$(cat /proc/sys/kernel/perf_event_paranoid 2>/dev/null || echo 99) + if [ "$paranoid" -gt 1 ]; then + echo "rr requires kernel.perf_event_paranoid <= 1; currently $paranoid" + echo "To enable (root needed):" + echo " echo 1 | sudo tee /proc/sys/kernel/perf_event_paranoid" + return 1 + fi + echo "rr ready (perf_event_paranoid=$paranoid)" +} + +pg-rr-record() { + pg-rr-check >/dev/null || { + pg-rr-check + return 1 + } + ulimit -c unlimited + rr record -- "$PG_INSTALL_DIR/bin/postgres" -D "$PG_DATA_DIR" -k "$PG_DATA_DIR" +} + +pg-rr-replay() { + rr replay "$@" +} + +# ============================================================ +# perf wrappers (parallel to the flame-graph helper) +# ============================================================ +pg-perf-record() { + local pid + pid=$(pgrep -f "postgres.*-D.*$PG_DATA_DIR" | head -1) + if [ -z "$pid" ]; then + echo "No postgres running under $PG_DATA_DIR" >&2 + return 1 + fi + mkdir -p "$PG_BENCH_DIR" + local out="$PG_BENCH_DIR/perf-$(date +%Y%m%d_%H%M%S).data" + echo "Recording to $out (Ctrl-C to stop)" + perf record -F 997 --call-graph dwarf -p "$pid" -o "$out" "$@" + echo "Saved: $out" +} + +pg-perf-report() { + local data + data=$(ls -t "$PG_BENCH_DIR"/perf-*.data 2>/dev/null | head -1) + if [ -z "$data" ]; then + echo "No perf data in $PG_BENCH_DIR" >&2 + return 1 + fi + echo "Reading $data" + perf report -i "$data" "$@" +} + +pg-perf-annotate() { + local data + data=$(ls -t "$PG_BENCH_DIR"/perf-*.data 2>/dev/null | head -1) + if [ -z "$data" ]; then + echo "No perf data in $PG_BENCH_DIR" >&2 + return 1 + fi + perf annotate -i "$data" "$@" +} + +# ============================================================ +# Single regression test / group runner. +# Runs pg_regress directly against the existing build so you skip the +# full meson-driven suite wrapper. Usage: pg-test-one boolean [name ...] +# ============================================================ +pg-test-one() { + if [ $# -eq 0 ]; then + echo "usage: pg-test-one TESTNAME [TESTNAME ...]" + echo "example: pg-test-one boolean" + return 2 + fi + local bdir="${PG_BUILD_DIR_ONE:-$PG_BUILD_DIR}" + local tmpbin="$bdir/tmp_install$PG_INSTALL_DIR/bin" + if [ ! -x "$tmpbin/postgres" ]; then + echo "Populating tmp_install..." + meson test -C "$bdir" tmp_install install_test_files initdb_cache >/dev/null || return 1 + fi + local outdir + outdir=$(mktemp -d /tmp/pg-test-one-XXXXXX) + echo "Test output: $outdir" + "$bdir/src/test/regress/pg_regress" \ + --bindir="$tmpbin" \ + --inputdir="$PG_SOURCE_DIR/src/test/regress" \ + --expecteddir="$PG_SOURCE_DIR/src/test/regress" \ + --dlpath="$bdir/src/test/regress" \ + --outputdir="$outdir" \ + --temp-instance="$outdir/tmp" \ + --port=40099 \ + "$@" +} + +# Full flame graph / benchmark aliases +alias pg-flame='pg-flame-generate' +alias pg-flame-30='pg-flame-generate 30' +alias pg-flame-60='pg-flame-generate 60' +alias pg-flame-120='pg-flame-generate 120' + +pg-flame-custom() { + local duration=${1:-30} + local output_dir=${2:-$PG_FLAME_DIR} + echo "Generating flame graph for ${duration}s, output to: $output_dir" + pg-flame-generate "$duration" "$output_dir" +} + +alias pg-bench='pg-bench-run' +alias pg-bench-quick='pg-bench-run 5 1 100 1 30 select-only' +alias pg-bench-standard='pg-bench-run 10 2 1000 10 60 tpcb-like' +alias pg-bench-heavy='pg-bench-run 50 4 5000 100 300 tpcb-like' +alias pg-bench-readonly='pg-bench-run 20 4 2000 50 120 select-only' + +pg-bench-custom() { + local clients=${1:-10} + local threads=${2:-2} + local transactions=${3:-1000} + local scale=${4:-10} + local duration=${5:-60} + local test_type=${6:-tpcb-like} + + echo "Running custom benchmark:" + echo " Clients: $clients, Threads: $threads" + echo " Transactions: $transactions, Scale: $scale" + echo " Duration: ${duration}s, Type: $test_type" + + pg-bench-run "$clients" "$threads" "$transactions" "$scale" "$duration" "$test_type" +} + +pg-bench-flame() { + local duration=${1:-60} + local clients=${2:-10} + local scale=${3:-10} + + echo "Running benchmark with flame graph generation" + echo "Duration: ${duration}s, Clients: $clients, Scale: $scale" + + pg-bench-run "$clients" 2 1000 "$scale" "$duration" tpcb-like & + local bench_pid=$! + + sleep 5 + + local flame_duration=$((duration - 10)) + if [ $flame_duration -gt 10 ]; then + pg-flame-generate "$flame_duration" & + local flame_pid=$! + fi + + wait $bench_pid + if [ -n "${flame_pid:-}" ]; then + wait $flame_pid + fi + + echo "Benchmark and flame graph generation completed" +} + +# Live monitoring +alias pg-perf='perf top -p $(pgrep -f "postgres.*-D.*$PG_DATA_DIR" | head -1)' +alias pg-htop='htop -p $(pgrep -f "postgres.*-D.*$PG_DATA_DIR" | tr "\n" "," | sed "s/,$//")' + +pg-stats() { + local duration=${1:-30} + echo "Collecting system stats for ${duration}s..." + + iostat -x 1 "$duration" >"$PG_BENCH_DIR/iostat_$(date +%Y%m%d_%H%M%S).log" & + vmstat 1 "$duration" >"$PG_BENCH_DIR/vmstat_$(date +%Y%m%d_%H%M%S).log" & + + wait + echo "System stats saved to $PG_BENCH_DIR" +} + +# ============================================================ +# Code quality helpers +# ============================================================ +pg-format() { + local since=${1:-HEAD} + + if [ ! -f "$PG_SOURCE_DIR/src/tools/pgindent/pgindent" ]; then + echo "Error: pgindent not found at $PG_SOURCE_DIR/src/tools/pgindent/pgindent" + else + + modified_files=$(git diff --name-only "${since}" | grep -E "\.c$|\.h$") + + if [ -z "$modified_files" ]; then + echo "No modified .c or .h files found" + else + + echo "Formatting modified files with pgindent:" + for file in $modified_files; do + if [ -f "$file" ]; then + echo " Formatting: $file" + "$PG_SOURCE_DIR/src/tools/pgindent/pgindent" "$file" + else + echo " Warning: File not found: $file" + fi + done + + echo "Checking files for whitespace:" + git diff --check "${since}" + fi + fi +} + +pg-tidy() { + local since=${1:-HEAD} + local files + files=$(git diff --name-only "$since" | grep -E "\.(c|h)$") + if [ -z "$files" ]; then + echo "No modified .c or .h files." + return 0 + fi + for f in $files; do + [ -f "$f" ] || continue + echo "clang-tidy: $f" + clang-tidy -p "$PG_BUILD_DIR" "$f" 2>&1 | head -50 + done +} + +pg-spell() { + local since=${1:-HEAD} + local files=$(git diff --name-only "$since" | grep -E '\.(c|h|sgml|md)$') + if [ -z "$files" ]; then + echo "No .c/.h/.sgml/.md files changed since $since" + return 0 + fi + for f in $files; do + [ -f "$f" ] || continue + case "$f" in + *.c | *.h) + grep -nE '^\s*(/\*|\*|//)' "$f" | codespell --stdin-single-line - 2>/dev/null \ + && echo " $f: ok" || true + ;; + *.sgml | *.md) + codespell "$f" || true + ;; + esac + done +} + +# ============================================================ +# Core dump one-shots (one-time, requires root). kernel.core_pattern +# is a system-wide sysctl -- we don't touch it on every shell entry. +# ============================================================ +pg-cores-status() { + echo "ulimit -c: $(ulimit -c)" + echo "kernel.core_pattern: $(cat /proc/sys/kernel/core_pattern 2>/dev/null || echo unreadable)" + echo "cwd: $(pwd)" +} + +pg-enable-cores() { + ulimit -c unlimited + if ! [ -w /proc/sys/kernel/core_pattern ]; then + echo "Setting kernel.core_pattern (requires sudo)..." + echo "core.%p" | sudo tee /proc/sys/kernel/core_pattern >/dev/null || { + echo "Failed to write /proc/sys/kernel/core_pattern" >&2 + return 1 + } + else + echo "core.%p" >/proc/sys/kernel/core_pattern + fi + pg-cores-status +} + +pg-disable-cores() { + ulimit -c 0 + if ! [ -w /proc/sys/kernel/core_pattern ]; then + echo "Restoring kernel.core_pattern to 'core' (requires sudo)..." + echo "core" | sudo tee /proc/sys/kernel/core_pattern >/dev/null || { + echo "Failed to restore /proc/sys/kernel/core_pattern" >&2 + return 1 + } + else + echo "core" >/proc/sys/kernel/core_pattern + fi + pg-cores-status +} + +# ============================================================ +# Logs and results +# ============================================================ +alias pg-log='tail -f "$PG_DATA_DIR/log/postgresql-$(date +%Y-%m-%d).log" 2>/dev/null || echo "No log file found"' +alias pg-log-errors='grep -i error "$PG_DATA_DIR/log/"*.log 2>/dev/null || echo "No error logs found"' + +alias pg-build-log='cat "$PG_BUILD_DIR/meson-logs/meson-log.txt"' +alias pg-build-errors='grep -i error "$PG_BUILD_DIR/meson-logs/meson-log.txt" 2>/dev/null || echo "No build errors found"' + +alias pg-bench-results='ls -la "$PG_BENCH_DIR" && echo "Latest results:" && tail -20 "$PG_BENCH_DIR"/results_*.txt 2>/dev/null | tail -20' +alias pg-flame-results='ls -la "$PG_FLAME_DIR" && echo "Open flame graphs with: firefox $PG_FLAME_DIR/*.svg"' + +pg-clean-results() { + local days=${1:-7} + echo "Cleaning benchmark and flame graph results older than $days days..." + find "$PG_BENCH_DIR" -type f -mtime +$days -delete 2>/dev/null || true + find "$PG_FLAME_DIR" -type f -mtime +$days -delete 2>/dev/null || true + echo "Cleanup completed" +} + +# ============================================================ +# Info +# ============================================================ +alias pg-info=' + echo "=== PostgreSQL Development Environment ===" + echo "Source: $PG_SOURCE_DIR" + echo "Build (default): $PG_BUILD_DIR" + echo "Build (valgrind):$PG_BUILD_DIR_VALGRIND" + echo "Build (asan): $PG_BUILD_DIR_ASAN" + echo "Install: $PG_INSTALL_DIR" + echo "Data: $PG_DATA_DIR" + echo "Benchmarks: $PG_BENCH_DIR" + echo "Flame graphs: $PG_FLAME_DIR" + echo "Compiler: $CC" + echo "" + echo "Available commands:" + echo " Setup/build: pg-setup, pg-build, pg-install" + echo " Database: pg-init, pg-start, pg-stop, pg-psql" + echo " Tests: pg-test, pg-test-one NAME" + echo " Valgrind: pg-build-valgrind, pg-valgrind-regress, pg-valgrind-test" + echo " ASan/UBSan: pg-build-asan, pg-asan-regress" + echo " Debug: pg-debug, pg-attach" + echo " libumem: pg-start-umem, pg-umem-ctl, pg-umem-stats/leaks/caches, pg-umem-gdb/lldb" + echo " Record/replay: pg-rr-check, pg-rr-record, pg-rr-replay" + echo " Perf: pg-perf-record, pg-perf-report, pg-perf-annotate, pg-perf" + echo " Flame graphs: pg-flame, pg-flame-30, pg-flame-60, pg-flame-custom" + echo " Benchmarks: pg-bench-quick, pg-bench-standard, pg-bench-heavy" + echo " Combined: pg-bench-flame" + echo " Results: pg-bench-results, pg-flame-results" + echo " Logs: pg-log, pg-build-log" + echo " Clean: pg-clean, pg-full-clean, pg-clean-results" + echo " Code quality: pg-format, pg-tidy, pg-spell" + echo " Cores: pg-enable-cores, pg-disable-cores, pg-cores-status" + echo "=========================================="' + +echo "PostgreSQL aliases loaded. Run 'pg-info' for available commands." diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000000000..d1cd518c00382 --- /dev/null +++ b/shell.nix @@ -0,0 +1,729 @@ +{ + pkgs, + pkgs-unstable, + system, + libumemPkg, +}: let + # Create a patched glibc only for the dev shell. + # + # Glibc's features.h emits a `-Wcpp` diagnostic when _FORTIFY_SOURCE is + # defined without an optimization level. Meson's dependency probes + # (notably the libcurl thread-safety check) compile small snippets with + # `-O0 -Werror` under the nixpkgs hardening defaults, which turns that cpp + # warning into a hard error and breaks reconfigure. The patch drops the + # warning. It is scoped to this dev shell only and never leaks into system + # glibc or release builds. + # Patch glibc's features.h to drop a -Wcpp warning. + # + # Glibc's features.h emits a `-Wcpp` diagnostic when _FORTIFY_SOURCE is + # defined without an optimization level. Meson's dependency probes + # (notably the libcurl thread-safety check) compile small snippets with + # `-O0 -Werror` under the nixpkgs hardening defaults, which turns that cpp + # warning into a hard error and breaks reconfigure. The patch drops the + # warning. It is scoped to this dev shell only and never leaks into system + # glibc or release builds. Kept on the stable nixpkgs glibc so it matches + # the shell's base tools and the (stable-following) libumem input. + patchedGlibc = pkgs.glibc.overrideAttrs (oldAttrs: { + patches = + (oldAttrs.patches or []) + ++ [ + ./glibc-no-fortify-warning.patch + ]; + }); + + # Modern toolchain, pinned to nixpkgs-unstable. + gccPkg = pkgs-unstable.gcc16; + gdbPkg = pkgs-unstable.gdb; + llvmPkgs = pkgs-unstable.llvmPackages_22; + + # libumem debugger helpers (installed by the flake package under + # $out/share/umem/debugger/{gdb,lldb}). + umemGdbHelper = "${libumemPkg}/share/umem/debugger/gdb/umem_gdb.py"; + umemLldbHelper = "${libumemPkg}/share/umem/debugger/lldb/umem_lldb.py"; + umemPreload = "${libumemPkg}/lib/libumem_malloc.so"; + + # Configuration constants + config = { + pgSourceDir = "$PWD"; + pgBuildDir = "$PWD/build"; + pgBuildDirValgrind = "$PWD/build-valgrind"; + pgBuildDirAsan = "$PWD/build-asan"; + pgInstallDir = "$PWD/install"; + pgDataDir = "/tmp/test-db-$(basename $PWD)"; + pgBenchDir = "/tmp/pgbench-results-$(basename $PWD)"; + pgFlameDir = "/tmp/flame-graphs-$(basename $PWD)"; + }; + + # PostgreSQL build + development dependencies (glibc target). + postgreSQLDeps = with pkgs; + [ + # Build system (always use host tools) + pkgs-unstable.meson + pkgs-unstable.ninja + pkg-config + autoconf + git + which + binutils + gnumake + mold # fast linker, big wins on large postgres links + + # Parser/lexer tools + bison + flex + + gettext # msgfmt/xgettext for NLS (translation) builds + + # Perl with required packages + (perl.withPackages (ps: with ps; [IPCRun])) + + # Documentation + docbook_xml_dtd_45 + docbook-xsl-nons + libxslt + libxml2 + fop + + # Development tools (always use host tools) + coreutils + shellcheck + ripgrep + valgrind + curl + uv + pylint + black + lcov + strace + ltrace + perf-tools + linuxPackages.perf + flamegraph + bpftrace # kernel-level tracing (probes, uprobes) + rr # record-and-replay deterministic debugger + htop + iotop + sysstat + ccache + cppcheck + compdb + + # Spell checking + aspell + aspellDicts.en + codespell + + # GCC 16 + GDB (nixpkgs-unstable) + gccPkg + gdbPkg + + # LLVM 22 toolchain + llvmPkgs.llvm + llvmPkgs.llvm.dev + llvmPkgs.clang-tools + llvmPkgs.lldb + + # libumem: LD_PRELOAD malloc + umem/umemctl CLI + debugger helpers + libumemPkg + + # Language support + (python3.withPackages (ps: with ps; [requests browser-cookie3])) + tcl + ] + ++ [ + # Glibc target libraries + readline + zlib + openssl + icu + lz4 + zstd + libuuid + libkrb5 + linux-pam + libxcrypt + numactl + openldap + liburing + libselinux + patchedGlibc + patchedGlibc.dev + ]; + + # GDB configuration for PostgreSQL debugging + gdbConfig = pkgs.writeText "gdbinit-postgres" '' + # PostgreSQL-specific GDB configuration + + # Pretty-print PostgreSQL data structures + define print_node + if $arg0 + printf "Node type: %s\n", nodeTagNames[$arg0->type] + print *$arg0 + else + printf "NULL node\n" + end + end + document print_node + Print a PostgreSQL Node with type information + Usage: print_node + end + + define print_list + set $list = (List*)$arg0 + if $list + printf "List length: %d\n", $list->length + set $cell = $list->head + set $i = 0 + while $cell && $i < $list->length + printf " [%d]: ", $i + print_node $cell->data.ptr_value + set $cell = $cell->next + set $i = $i + 1 + end + else + printf "NULL list\n" + end + end + document print_list + Print a PostgreSQL List structure + Usage: print_list + end + + define print_query + set $query = (Query*)$arg0 + if $query + printf "Query type: %d, command type: %d\n", $query->querySource, $query->commandType + print *$query + else + printf "NULL query\n" + end + end + document print_query + Print a PostgreSQL Query structure + Usage: print_query + end + + define print_relcache + set $rel = (Relation)$arg0 + if $rel + printf "Relation: %s.%s (OID: %u)\n", $rel->rd_rel->relnamespace, $rel->rd_rel->relname.data, $rel->rd_id + printf " natts: %d, relkind: %c\n", $rel->rd_rel->relnatts, $rel->rd_rel->relkind + else + printf "NULL relation\n" + end + end + document print_relcache + Print relation cache entry information + Usage: print_relcache + end + + define print_tupdesc + set $desc = (TupleDesc)$arg0 + if $desc + printf "TupleDesc: %d attributes\n", $desc->natts + set $i = 0 + while $i < $desc->natts + set $attr = $desc->attrs[$i] + printf " [%d]: %s (type: %u, len: %d)\n", $i, $attr->attname.data, $attr->atttypid, $attr->attlen + set $i = $i + 1 + end + else + printf "NULL tuple descriptor\n" + end + end + document print_tupdesc + Print tuple descriptor information + Usage: print_tupdesc + end + + define print_slot + set $slot = (TupleTableSlot*)$arg0 + if $slot + printf "TupleTableSlot: %s\n", $slot->tts_ops->name + printf " empty: %d, shouldFree: %d\n", $slot->tts_empty, $slot->tts_shouldFree + if $slot->tts_tupleDescriptor + print_tupdesc $slot->tts_tupleDescriptor + end + else + printf "NULL slot\n" + end + end + document print_slot + Print tuple table slot information + Usage: print_slot + end + + # Memory context debugging + define print_mcxt + set $context = (MemoryContext)$arg0 + if $context + printf "MemoryContext: %s\n", $context->name + printf " type: %s, parent: %p\n", $context->methods->name, $context->parent + printf " total: %zu, free: %zu\n", $context->mem_allocated, $context->freep - $context->freeptr + else + printf "NULL memory context\n" + end + end + document print_mcxt + Print memory context information + Usage: print_mcxt + end + + # Process debugging + define print_proc + set $proc = (PGPROC*)$arg0 + if $proc + printf "PGPROC: pid=%d, database=%u\n", $proc->pid, $proc->databaseId + printf " waiting: %d, waitStatus: %d\n", $proc->waiting, $proc->waitStatus + else + printf "NULL process\n" + end + end + document print_proc + Print process information + Usage: print_proc + end + + # Set useful defaults + set print pretty on + set print object on + set print static-members off + set print vtbl on + set print demangle on + set demangle-style gnu-v3 + set print sevenbit-strings off + set history save on + set history size 1000 + set history filename ~/.gdb_history_postgres + + # Common breakpoints for PostgreSQL debugging + define pg_break_common + break elog + break errfinish + break ExceptionalCondition + break ProcessInterrupts + end + document pg_break_common + Set common PostgreSQL debugging breakpoints + end + + # libumem GDB helpers (umem heap/event inspection). Loaded if present; + # provides the 'umem' command family (umem break, umem events, ...). + python + try: + gdb.execute("source ${umemGdbHelper}") + except Exception as e: + pass + end + + printf "PostgreSQL GDB configuration loaded.\n" + printf "Available commands: print_node, print_list, print_query, print_relcache,\n" + printf " print_tupdesc, print_slot, print_mcxt, print_proc, pg_break_common\n" + ''; + + # Flame graph generation script + flameGraphScript = pkgs.writeScriptBin "pg-flame-generate" '' + #!${pkgs.bash}/bin/bash + set -euo pipefail + + DURATION=''${1:-30} + OUTPUT_DIR=''${2:-${config.pgFlameDir}} + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + + mkdir -p "$OUTPUT_DIR" + + echo "Generating flame graph for PostgreSQL (duration: ''${DURATION}s)" + + # Find PostgreSQL processes + PG_PIDS=$(pgrep -f "postgres.*-D.*${config.pgDataDir}" || true) + + if [ -z "$PG_PIDS" ]; then + echo "Error: No PostgreSQL processes found" + exit 1 + fi + + echo "Found PostgreSQL processes: $PG_PIDS" + + # Record perf data + PERF_DATA="$OUTPUT_DIR/perf_$TIMESTAMP.data" + echo "Recording perf data to $PERF_DATA" + + ${pkgs.linuxPackages.perf}/bin/perf record \ + -F 997 \ + -g \ + --call-graph dwarf \ + -p "$(echo $PG_PIDS | tr ' ' ',')" \ + -o "$PERF_DATA" \ + sleep "$DURATION" + + # Generate flame graph + FLAME_SVG="$OUTPUT_DIR/postgres_flame_$TIMESTAMP.svg" + echo "Generating flame graph: $FLAME_SVG" + + ${pkgs.linuxPackages.perf}/bin/perf script -i "$PERF_DATA" | \ + ${pkgs.flamegraph}/bin/stackcollapse-perf.pl | \ + ${pkgs.flamegraph}/bin/flamegraph.pl \ + --title "PostgreSQL Flame Graph ($TIMESTAMP)" \ + --width 1200 \ + --height 800 \ + > "$FLAME_SVG" + + echo "Flame graph generated: $FLAME_SVG" + echo "Perf data saved: $PERF_DATA" + + # Generate summary report + REPORT="$OUTPUT_DIR/report_$TIMESTAMP.txt" + echo "Generating performance report: $REPORT" + + { + echo "PostgreSQL Performance Analysis Report" + echo "Generated: $(date)" + echo "Duration: ''${DURATION}s" + echo "Processes: $PG_PIDS" + echo "" + echo "=== Top Functions ===" + ${pkgs.linuxPackages.perf}/bin/perf report -i "$PERF_DATA" --stdio --sort comm,dso,symbol | head -50 + echo "" + echo "=== Call Graph ===" + ${pkgs.linuxPackages.perf}/bin/perf report -i "$PERF_DATA" --stdio -g --sort comm,dso,symbol | head -100 + } > "$REPORT" + + echo "Report generated: $REPORT" + echo "" + echo "Files created:" + echo " Flame graph: $FLAME_SVG" + echo " Perf data: $PERF_DATA" + echo " Report: $REPORT" + ''; + + # pgbench wrapper script + pgbenchScript = pkgs.writeScriptBin "pg-bench-run" '' + #!${pkgs.bash}/bin/bash + set -euo pipefail + + # Default parameters + CLIENTS=''${1:-10} + THREADS=''${2:-2} + TRANSACTIONS=''${3:-1000} + SCALE=''${4:-10} + DURATION=''${5:-60} + TEST_TYPE=''${6:-tpcb-like} + + OUTPUT_DIR="${config.pgBenchDir}" + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + + mkdir -p "$OUTPUT_DIR" + + echo "=== PostgreSQL Benchmark Configuration ===" + echo "Clients: $CLIENTS" + echo "Threads: $THREADS" + echo "Transactions: $TRANSACTIONS" + echo "Scale factor: $SCALE" + echo "Duration: ''${DURATION}s" + echo "Test type: $TEST_TYPE" + echo "Output directory: $OUTPUT_DIR" + echo "============================================" + + # Check if PostgreSQL is running + if ! pgrep -f "postgres.*-D.*${config.pgDataDir}" >/dev/null; then + echo "Error: PostgreSQL is not running. Start it with 'pg-start'" + exit 1 + fi + + PGBENCH="${config.pgInstallDir}/bin/pgbench" + PSQL="${config.pgInstallDir}/bin/psql" + CREATEDB="${config.pgInstallDir}/bin/createdb" + DROPDB="${config.pgInstallDir}/bin/dropdb" + + DB_NAME="pgbench_test_$TIMESTAMP" + RESULTS_FILE="$OUTPUT_DIR/results_$TIMESTAMP.txt" + LOG_FILE="$OUTPUT_DIR/pgbench_$TIMESTAMP.log" + + echo "Creating test database: $DB_NAME" + "$CREATEDB" -h "${config.pgDataDir}" "$DB_NAME" || { + echo "Failed to create database" + exit 1 + } + + # Initialize pgbench tables + echo "Initializing pgbench tables (scale factor: $SCALE)" + "$PGBENCH" -h "${config.pgDataDir}" -i -s "$SCALE" "$DB_NAME" || { + echo "Failed to initialize pgbench tables" + "$DROPDB" -h "${config.pgDataDir}" "$DB_NAME" 2>/dev/null || true + exit 1 + } + + # Run benchmark based on test type + echo "Running benchmark..." + + case "$TEST_TYPE" in + "tpcb-like"|"default") + BENCH_ARGS="" + ;; + "select-only") + BENCH_ARGS="-S" + ;; + "simple-update") + BENCH_ARGS="-N" + ;; + "read-write") + BENCH_ARGS="-b select-only@70 -b tpcb-like@30" + ;; + *) + echo "Unknown test type: $TEST_TYPE" + echo "Available types: tpcb-like, select-only, simple-update, read-write" + "$DROPDB" -h "${config.pgDataDir}" "$DB_NAME" 2>/dev/null || true + exit 1 + ;; + esac + + { + echo "PostgreSQL Benchmark Results" + echo "Generated: $(date)" + echo "Test type: $TEST_TYPE" + echo "Clients: $CLIENTS, Threads: $THREADS" + echo "Transactions: $TRANSACTIONS, Duration: ''${DURATION}s" + echo "Scale factor: $SCALE" + echo "Database: $DB_NAME" + echo "" + echo "=== System Information ===" + echo "CPU: $(nproc) cores" + echo "Memory: $(free -h | grep '^Mem:' | awk '{print $2}')" + echo "Compiler: $CC" + echo "PostgreSQL version: $("$PSQL" --no-psqlrc -h "${config.pgDataDir}" -d "$DB_NAME" -t -c "SELECT version();" | head -1)" + echo "" + echo "=== Benchmark Results ===" + } > "$RESULTS_FILE" + + # Run the actual benchmark + "$PGBENCH" \ + -h "${config.pgDataDir}" \ + -c "$CLIENTS" \ + -j "$THREADS" \ + -T "$DURATION" \ + -P 5 \ + --log \ + --log-prefix="$OUTPUT_DIR/pgbench_$TIMESTAMP" \ + $BENCH_ARGS \ + "$DB_NAME" 2>&1 | tee -a "$RESULTS_FILE" + + # Collect additional statistics + { + echo "" + echo "=== Database Statistics ===" + "$PSQL" --no-psqlrc -h "${config.pgDataDir}" -d "$DB_NAME" -c " + SELECT + schemaname, + relname, + n_tup_ins as inserts, + n_tup_upd as updates, + n_tup_del as deletes, + n_live_tup as live_tuples, + n_dead_tup as dead_tuples + FROM pg_stat_user_tables; + " + + echo "" + echo "=== Index Statistics ===" + "$PSQL" --no-psqlrc -h "${config.pgDataDir}" -d "$DB_NAME" -c " + SELECT + schemaname, + relname, + indexrelname, + idx_scan, + idx_tup_read, + idx_tup_fetch + FROM pg_stat_user_indexes; + " + } >> "$RESULTS_FILE" + + # Clean up + echo "Cleaning up test database: $DB_NAME" + "$DROPDB" -h "${config.pgDataDir}" "$DB_NAME" 2>/dev/null || true + + echo "" + echo "Benchmark completed!" + echo "Results saved to: $RESULTS_FILE" + echo "Transaction logs: $OUTPUT_DIR/pgbench_$TIMESTAMP*" + + # Show summary + echo "" + echo "=== Quick Summary ===" + grep -E "(tps|latency)" "$RESULTS_FILE" | tail -5 + ''; + + # LD_PRELOAD libumem's malloc into a command: pg-umem-preload + # A script (not a shell function) so it survives subshells and non-login + # invocations. Preloads $out/lib/libumem_malloc.so ahead of any existing + # LD_PRELOAD. + umemPreloadScript = pkgs.writeScriptBin "pg-umem-preload" '' + #!${pkgs.bash}/bin/bash + if [ $# -eq 0 ]; then + echo "usage: pg-umem-preload [args...]" >&2 + echo " runs with libumem_malloc.so LD_PRELOAD'ed" >&2 + exit 2 + fi + export LD_PRELOAD="${umemPreload}''${LD_PRELOAD:+:$LD_PRELOAD}" + exec "$@" + ''; + + # Shared shellHook fragments. Each devShell prepends its own compiler/CFLAGS + # block, then appends the common tail via ${commonHookTail variant}. + commonHookHead = icon: '' + # History configuration + export HISTFILE=.history + export HISTSIZE=1000000 + export HISTFILESIZE=1000000 + + # Clean environment + unset LD_LIBRARY_PATH LD_PRELOAD LIBRARY_PATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH + + # Essential tools in PATH + export PATH="${pkgs.which}/bin:${pkgs.coreutils}/bin:$PATH" + export PS1="$(echo -e '\u${icon}') {\[$(tput sgr0)\]\[\033[38;5;228m\]\w\[$(tput sgr0)\]\[\033[38;5;15m\]} ($(git rev-parse --abbrev-ref HEAD)) \\$ \[$(tput sgr0)\]" + + # Ccache configuration + export PATH=${pkgs.ccache}/bin:$PATH + export CCACHE_COMPILERCHECK=content + # Loosen a few rules so ccache hits across rebuilds with touched headers. + export CCACHE_SLOPPINESS=pch_defines,time_macros,include_file_mtime,include_file_ctime + export CCACHE_DIR=$HOME/.ccache/pg/$(basename $PWD) + mkdir -p "$CCACHE_DIR" + + # Development tools in PATH + export PATH=${pkgs.clang-tools}/bin:$PATH + export PATH=${pkgs.cppcheck}/bin:$PATH + ''; + + # Tail shared by every devShell: PG env vars, GDB, tool PATH, per-process + # setup and alias load. Kernel core_pattern is NOT touched here -- + # run 'pg-enable-cores' explicitly if you need per-PID cores in CWD. + commonHookTail = label: '' + # PostgreSQL environment + export PG_SOURCE_DIR="${config.pgSourceDir}" + export PG_BUILD_DIR="${config.pgBuildDir}" + export PG_BUILD_DIR_VALGRIND="${config.pgBuildDirValgrind}" + export PG_BUILD_DIR_ASAN="${config.pgBuildDirAsan}" + export PG_INSTALL_DIR="${config.pgInstallDir}" + export PG_DATA_DIR="${config.pgDataDir}" + export PG_BENCH_DIR="${config.pgBenchDir}" + export PG_FLAME_DIR="${config.pgFlameDir}" + export PERL_CORE_DIR=$(find ${pkgs.perl} -maxdepth 5 -path "*/CORE" -type d) + + # GDB configuration + export GDBINIT="${gdbConfig}" + + # libumem: LD_PRELOAD malloc, the umem/umemctl CLI, and debugger helpers. + # The CLI and 'pg-umem-preload' are already on PATH (buildInputs). Export + # the preload object and helper paths; do NOT preload by default -- opt in + # per-command with 'pg-umem-preload ' or export + # LD_PRELOAD="$PG_UMEM_PRELOAD". + export PG_UMEM_PRELOAD="${umemPreload}" + export PG_UMEM_GDB_HELPER="${umemGdbHelper}" + export PG_UMEM_LLDB_HELPER="${umemLldbHelper}" + + # Performance tools in PATH + export PATH="${flameGraphScript}/bin:${pgbenchScript}/bin:${umemPreloadScript}/bin:$PATH" + + # Create output directories + mkdir -p "$PG_BENCH_DIR" "$PG_FLAME_DIR" + + # Per-process core dump size limit. Kernel core_pattern is NOT + # touched here -- run 'pg-enable-cores' explicitly when you need + # per-PID cores in CWD. + ulimit -c unlimited + + # Local git excludes + git config core.excludesFile .local-gitignore 2>/dev/null || true + + # Load PostgreSQL development aliases + if [ -f ./pg-aliases.sh ]; then + source ./pg-aliases.sh + else + echo "Warning: pg-aliases.sh not found in current directory" + fi + + echo "" + echo "PostgreSQL Development Environment Ready (${label})" + echo "Run 'pg-info' for available commands" + echo "libumem: umem/umemctl on PATH; 'pg-umem-preload ' to LD_PRELOAD libumem_malloc" + ''; + + # Development shell (GCC 16 + glibc) + devShell = pkgs.mkShell { + name = "postgresql-dev"; + buildInputs = + postgreSQLDeps + ++ [ + flameGraphScript + pgbenchScript + umemPreloadScript + ]; + + shellHook = + (commonHookHead "f121") + + '' + # LLVM configuration + export LLVM_CONFIG="${llvmPkgs.llvm}/bin/llvm-config" + export PATH="${llvmPkgs.llvm}/bin:$PATH" + export PKG_CONFIG_PATH="${llvmPkgs.llvm.dev}/lib/pkgconfig:$PKG_CONFIG_PATH" + export LLVM_DIR="${llvmPkgs.llvm.dev}/lib/cmake/llvm" + export LLVM_ROOT="${llvmPkgs.llvm}" + + # PostgreSQL Development CFLAGS + export CFLAGS="" + export CXXFLAGS="" + + # Python UV + UV_PYTHON_DOWNLOADS=never + + # GCC 16 (default compiler) + export CC="${gccPkg}/bin/gcc" + export CXX="${gccPkg}/bin/g++" + + echo "Environment configured:" + echo " Compiler: $CC ($($CC -dumpversion 2>/dev/null))" + echo " libc: glibc" + echo " LLVM: $(llvm-config --version 2>/dev/null || echo 'not available')" + '' + + (commonHookTail "GCC 16 + glibc"); + }; + + # Clang 22 + glibc variant + clangDevShell = pkgs.mkShell { + name = "postgresql-clang-glibc"; + buildInputs = + postgreSQLDeps + ++ [ + llvmPkgs.clang + llvmPkgs.lld + llvmPkgs.compiler-rt + flameGraphScript + pgbenchScript + umemPreloadScript + ]; + + shellHook = + (commonHookHead "f121") + + '' + # LLVM configuration + export LLVM_CONFIG="${llvmPkgs.llvm}/bin/llvm-config" + export PATH="${llvmPkgs.llvm}/bin:$PATH" + export PKG_CONFIG_PATH="${llvmPkgs.llvm.dev}/lib/pkgconfig:$PKG_CONFIG_PATH" + export LLVM_DIR="${llvmPkgs.llvm.dev}/lib/cmake/llvm" + export LLVM_ROOT="${llvmPkgs.llvm}" + + # Clang 22 + glibc + export CC="${llvmPkgs.clang}/bin/clang" + export CXX="${llvmPkgs.clang}/bin/clang++" + + echo "Environment configured:" + echo " Compiler: $CC ($($CC -dumpversion 2>/dev/null))" + echo " libc: glibc" + echo " LLVM: $(llvm-config --version 2>/dev/null || echo 'not available')" + '' + + (commonHookTail "Clang 22 + glibc"); + }; +in { + inherit devShell clangDevShell gdbConfig flameGraphScript pgbenchScript; +} diff --git a/src/test/regress/pg_regress.c b/src/test/regress/pg_regress.c index 13944701bc7b0..278cbfb735ce0 100644 --- a/src/test/regress/pg_regress.c +++ b/src/test/regress/pg_regress.c @@ -1243,7 +1243,7 @@ spawn_process(const char *cmdline) char *cmdline2; cmdline2 = psprintf("exec %s", cmdline); - execl(shellprog, shellprog, "-c", cmdline2, (char *) NULL); + execlp(shellprog, shellprog, "-c", cmdline2, (char *) NULL); /* Not using the normal bail() here as we want _exit */ bail_noatexit("could not exec \"%s\": %m", shellprog); } diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent index fa2041dc34b93..92526f6248581 100755 --- a/src/tools/pgindent/pgindent +++ b/src/tools/pgindent/pgindent @@ -1,4 +1,4 @@ -#!/usr/bin/perl +#!/usr/bin/env perl # Copyright (c) 2021-2026, PostgreSQL Global Development Group From 781bc6a0d220a7ca87e95782b06d4a72701d058e Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Thu, 1 Oct 2026 10:19:05 -0400 Subject: [PATCH 2/9] dev: drop patched glibc, fix pg-test-one/pg-format gcc16 (nixpkgs-unstable) links glibc 2.42 but the stable patchedGlibc in buildInputs put a 2.40 libc.so.6 first in RUNPATH, so every meson sanity-check binary failed with a GLIBC_PRIVATE symbol lookup error. The patched glibc only existed to silence the fortify-without-O warning; hardeningDisable does that without a second glibc. pg-test-one: set LD_LIBRARY_PATH to the tmp_install lib dir, as the meson test runner does, so initdb finds libpq. pg-format: put the in-tree pg_bsd_indent build on PATH. --- pg-aliases.sh | 3 +++ shell.nix | 34 +++++----------------------------- 2 files changed, 8 insertions(+), 29 deletions(-) diff --git a/pg-aliases.sh b/pg-aliases.sh index 42bddff4c2730..7979ec292620a 100644 --- a/pg-aliases.sh +++ b/pg-aliases.sh @@ -451,6 +451,7 @@ pg-test-one() { local outdir outdir=$(mktemp -d /tmp/pg-test-one-XXXXXX) echo "Test output: $outdir" + LD_LIBRARY_PATH="$bdir/tmp_install$PG_INSTALL_DIR/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ "$bdir/src/test/regress/pg_regress" \ --bindir="$tmpbin" \ --inputdir="$PG_SOURCE_DIR/src/test/regress" \ @@ -548,6 +549,8 @@ pg-format() { if [ ! -f "$PG_SOURCE_DIR/src/tools/pgindent/pgindent" ]; then echo "Error: pgindent not found at $PG_SOURCE_DIR/src/tools/pgindent/pgindent" else + # pgindent needs pg_bsd_indent; meson builds it in-tree. + export PATH="$PG_BUILD_DIR/src/tools/pg_bsd_indent:$PATH" modified_files=$(git diff --name-only "${since}" | grep -E "\.c$|\.h$") diff --git a/shell.nix b/shell.nix index d1cd518c00382..d62e76802e5a6 100644 --- a/shell.nix +++ b/shell.nix @@ -4,33 +4,6 @@ system, libumemPkg, }: let - # Create a patched glibc only for the dev shell. - # - # Glibc's features.h emits a `-Wcpp` diagnostic when _FORTIFY_SOURCE is - # defined without an optimization level. Meson's dependency probes - # (notably the libcurl thread-safety check) compile small snippets with - # `-O0 -Werror` under the nixpkgs hardening defaults, which turns that cpp - # warning into a hard error and breaks reconfigure. The patch drops the - # warning. It is scoped to this dev shell only and never leaks into system - # glibc or release builds. - # Patch glibc's features.h to drop a -Wcpp warning. - # - # Glibc's features.h emits a `-Wcpp` diagnostic when _FORTIFY_SOURCE is - # defined without an optimization level. Meson's dependency probes - # (notably the libcurl thread-safety check) compile small snippets with - # `-O0 -Werror` under the nixpkgs hardening defaults, which turns that cpp - # warning into a hard error and breaks reconfigure. The patch drops the - # warning. It is scoped to this dev shell only and never leaks into system - # glibc or release builds. Kept on the stable nixpkgs glibc so it matches - # the shell's base tools and the (stable-following) libumem input. - patchedGlibc = pkgs.glibc.overrideAttrs (oldAttrs: { - patches = - (oldAttrs.patches or []) - ++ [ - ./glibc-no-fortify-warning.patch - ]; - }); - # Modern toolchain, pinned to nixpkgs-unstable. gccPkg = pkgs-unstable.gcc16; gdbPkg = pkgs-unstable.gdb; @@ -146,8 +119,6 @@ openldap liburing libselinux - patchedGlibc - patchedGlibc.dev ]; # GDB configuration for PostgreSQL debugging @@ -652,6 +623,10 @@ # Development shell (GCC 16 + glibc) devShell = pkgs.mkShell { name = "postgresql-dev"; + # Meson probes compile with -O0 -Werror; glibc's fortify-without-O + # warning would fail them. Drop fortify rather than carry a patched + # glibc, which collided with the glibc gcc16 (unstable) links against. + hardeningDisable = ["fortify" "fortify3"]; buildInputs = postgreSQLDeps ++ [ @@ -692,6 +667,7 @@ # Clang 22 + glibc variant clangDevShell = pkgs.mkShell { name = "postgresql-clang-glibc"; + hardeningDisable = ["fortify" "fortify3"]; buildInputs = postgreSQLDeps ++ [ From 0ebadd3e69766f4bece1708921cf0cda54e7929d Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Thu, 1 Oct 2026 10:19:05 -0400 Subject: [PATCH 3/9] Let an ordering index scan hand its ORDER BY values to the target list An Index Scan that orders by an operator (ORDER BY col <-> const) computes each returned row's ORDER BY value inside the index AM and reports it in xs_orderbyvals. If the same expression also appears in the target list, the executor then evaluates it a second time in the scan's projection (a third time if the AM asked for a recheck). For an expensive distance operator that is wasted work. The AM contract only requires a non-rechecked distance to sort the same way as the operator's result, not to equal it, so the executor cannot substitute it blindly. Add an optional IndexAmRoutine callback, amcanreturnorderby(opfamily, opcintype, orderbyop): does the opclass report, for a non-rechecked leaf entry and that ordering operator, exactly the value the operator would return? It takes the operator because one opfamily can hold several ordering operators. The planner keeps the callback in IndexOptInfo, like amcostestimate, and create_indexscan_plan() asks it for each ORDER BY key, carrying one bool per key into the IndexScan. For each qualifying key, setrefs.c replaces a top-level target list expression that is equal() to that indexorderbyorig expression with Var(INNER_VAR, i), and nodeIndexscan.c stores the returned row's ORDER BY values in a virtual slot it installs as the projection's inner tuple (a scan node has no inner plan, so INNER_VAR is otherwise unused there). The values stored are the AM's for a row it reported exact, the recomputed ones for a row it asked to recheck, a reorder queue entry's copy for a queued row, and freshly recomputed ones for an EvalPlanQual test tuple. Only float4/float8 ORDER BY expressions qualify, as those are the only types index_store_float8_orderby_distances() can deliver. GiST answers yes for point_ops and SP-GiST for quad_point_ops and kd_point_ops, whose leaf distances call point_distance(), the function behind <->(point, point). box_ops doesn't qualify: its leaf distance is exact arithmetic on the box, while <->(box, point) treats a point within EPSILON of an edge's line as on it, so the two can differ in the last digits. The polygon and circle opclasses report lower bounds and are not affected, nor is any other AM or any out-of-core opclass, which all default to no. EXPLAIN VERBOSE deparses the INNER_VAR back to the ORDER BY expression and reports "Order By Values Used: N". Above such a scan, an upper node's reference to the value now prints with one more pair of parentheses, the same way any reference to a computed scan column does. Author: Greg Burd Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me --- contrib/btree_gist/expected/timestamp.out | 34 +++ contrib/btree_gist/sql/timestamp.sql | 18 ++ doc/src/sgml/indexam.sgml | 37 +++ src/backend/access/gist/gist.c | 1 + src/backend/access/gist/gistget.c | 23 ++ src/backend/access/spgist/spgscan.c | 21 ++ src/backend/access/spgist/spgutils.c | 1 + src/backend/commands/explain.c | 4 + src/backend/executor/nodeIndexscan.c | 87 +++++- src/backend/optimizer/plan/createplan.c | 20 ++ src/backend/optimizer/plan/setrefs.c | 60 ++++ src/backend/optimizer/util/plancat.c | 2 + src/backend/utils/adt/ruleutils.c | 18 ++ src/include/access/amapi.h | 9 + src/include/access/gist_private.h | 1 + src/include/access/spgist.h | 1 + src/include/nodes/execnodes.h | 8 + src/include/nodes/pathnodes.h | 2 + src/include/nodes/plannodes.h | 13 + .../isolation/expected/eval-plan-qual-knn.out | 40 +++ src/test/isolation/isolation_schedule | 1 + .../isolation/specs/eval-plan-qual-knn.spec | 44 +++ src/test/regress/expected/gist.out | 268 ++++++++++++++++++ src/test/regress/sql/gist.sql | 150 ++++++++++ src/tools/pgindent/typedefs.list | 1 + 25 files changed, 862 insertions(+), 2 deletions(-) create mode 100644 src/test/isolation/expected/eval-plan-qual-knn.out create mode 100644 src/test/isolation/specs/eval-plan-qual-knn.spec diff --git a/contrib/btree_gist/expected/timestamp.out b/contrib/btree_gist/expected/timestamp.out index 0d94f2f245cea..6589fc8125fc2 100644 --- a/contrib/btree_gist/expected/timestamp.out +++ b/contrib/btree_gist/expected/timestamp.out @@ -89,3 +89,37 @@ SELECT a, a <-> '2004-10-26 08:55:08' FROM timestamptmp ORDER BY a <-> '2004-10- Mon Nov 29 20:12:43 2004 | @ 34 days 11 hours 17 mins 35 secs (3 rows) +-- An ordering scan that hands an exact point distance to the target list +-- while another ORDER BY key, here an interval, is of a by-reference type. +CREATE TABLE timestamppt (p point, a timestamp); +INSERT INTO timestamppt + SELECT point(i % 7, i % 11), '2004-10-26 08:55:08'::timestamp + i * interval '37 min' + FROM generate_series(1, 600) i; +CREATE INDEX timestamppt_idx ON timestamppt USING gist (p, a); +SET enable_indexonlyscan=off; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT p <-> point(3, 4) AS d, a <-> '2004-10-26 08:55:08' AS da + FROM timestamppt + ORDER BY p <-> point(3, 4), a <-> '2004-10-26 08:55:08' LIMIT 3; + QUERY PLAN +---------------------------------------------------------------------------------------------------------------------------------------- + Limit + Output: ((p <-> '(3,4)'::point)), ((a <-> 'Tue Oct 26 08:55:08 2004'::timestamp without time zone)) + -> Index Scan using timestamppt_idx on public.timestamppt + Output: ((p <-> '(3,4)'::point)), (a <-> 'Tue Oct 26 08:55:08 2004'::timestamp without time zone) + Order By: ((timestamppt.p <-> '(3,4)'::point) AND (timestamppt.a <-> 'Tue Oct 26 08:55:08 2004'::timestamp without time zone)) + Order By Values Used: 1 +(6 rows) + +SELECT p <-> point(3, 4) AS d, a <-> '2004-10-26 08:55:08' AS da + FROM timestamppt + ORDER BY p <-> point(3, 4), a <-> '2004-10-26 08:55:08' LIMIT 3; + d | da +---+--------------------------- + 0 | @ 1 day 12 hours 23 mins + 0 | @ 3 days 11 hours 52 mins + 0 | @ 5 days 11 hours 21 mins +(3 rows) + +RESET enable_indexonlyscan; +DROP TABLE timestamppt; diff --git a/contrib/btree_gist/sql/timestamp.sql b/contrib/btree_gist/sql/timestamp.sql index 95effebfc47a7..216b0211a3611 100644 --- a/contrib/btree_gist/sql/timestamp.sql +++ b/contrib/btree_gist/sql/timestamp.sql @@ -35,3 +35,21 @@ SELECT count(*) FROM timestamptmp WHERE a > '2004-10-26 08:55:08'::timestamp; EXPLAIN (COSTS OFF) SELECT a, a <-> '2004-10-26 08:55:08' FROM timestamptmp ORDER BY a <-> '2004-10-26 08:55:08' LIMIT 3; SELECT a, a <-> '2004-10-26 08:55:08' FROM timestamptmp ORDER BY a <-> '2004-10-26 08:55:08' LIMIT 3; + +-- An ordering scan that hands an exact point distance to the target list +-- while another ORDER BY key, here an interval, is of a by-reference type. +CREATE TABLE timestamppt (p point, a timestamp); +INSERT INTO timestamppt + SELECT point(i % 7, i % 11), '2004-10-26 08:55:08'::timestamp + i * interval '37 min' + FROM generate_series(1, 600) i; +CREATE INDEX timestamppt_idx ON timestamppt USING gist (p, a); +SET enable_indexonlyscan=off; +EXPLAIN (VERBOSE, COSTS OFF) +SELECT p <-> point(3, 4) AS d, a <-> '2004-10-26 08:55:08' AS da + FROM timestamppt + ORDER BY p <-> point(3, 4), a <-> '2004-10-26 08:55:08' LIMIT 3; +SELECT p <-> point(3, 4) AS d, a <-> '2004-10-26 08:55:08' AS da + FROM timestamppt + ORDER BY p <-> point(3, 4), a <-> '2004-10-26 08:55:08' LIMIT 3; +RESET enable_indexonlyscan; +DROP TABLE timestamppt; diff --git a/doc/src/sgml/indexam.sgml b/doc/src/sgml/indexam.sgml index f48da3185307c..ee20c47750adb 100644 --- a/doc/src/sgml/indexam.sgml +++ b/doc/src/sgml/indexam.sgml @@ -157,6 +157,7 @@ typedef struct IndexAmRoutine ambulkdelete_function ambulkdelete; amvacuumcleanup_function amvacuumcleanup; amcanreturn_function amcanreturn; /* can be NULL */ + amcanreturnorderby_function amcanreturnorderby; /* can be NULL */ amcostestimate_function amcostestimate; amgettreeheight_function amgettreeheight; /* can be NULL */ amoptions_function amoptions; @@ -481,6 +482,32 @@ amcanreturn (Relation indexRelation, int attno); +bool +amcanreturnorderby (Oid opfamily, Oid opcintype, Oid orderbyop); + + Check whether, for an ordering scan using ordering operator + orderbyop on a column of the operator class given + by opfamily and opcintype, + the values the access method reports in + scan->xs_orderbyvals with + xs_recheckorderby false are exactly the values the + operator would return for the heap tuple. If so, the executor may return + them for a target list entry that is the same expression as the + ORDER BY key, instead of evaluating the operator again. + The answer is for that operator class and operator, not for the access + method as a whole: an operator class whose distances merely sort like the + operator's result, or differ from it in any digit, or depend on anything + other than the indexed value, must answer false. The planner calls this + while building the plan; it is only consulted for an + float8 or float4 ORDER BY + expression, and for float4 it is the float4 + rounding of the distance the access method reports that must equal the + operator's result. If the amcanreturnorderby + field is set to NULL, no substitution is made. + + + + void amcostestimate (PlannerInfo *root, IndexPath *path, @@ -989,6 +1016,16 @@ amtranslatecmptype (CompareType cmptype, Oid opfamily, Oid opcintype); of that form can be passed to amrescan as described previously. + + Such an access method returns each entry's ordering values in + scan->xs_orderbyvals, and sets + scan->xs_recheckorderby to true when those values are + only lower bounds that the executor must recompute from the heap + tuple. When xs_recheckorderby is false the values + need only sort in the same order as the ordering operator's results; + they need not equal them. See amcanreturnorderby + below for how an access method can promise that they do. + diff --git a/src/backend/access/gist/gist.c b/src/backend/access/gist/gist.c index 8565e225be7fd..f6c4592a1f5ff 100644 --- a/src/backend/access/gist/gist.c +++ b/src/backend/access/gist/gist.c @@ -93,6 +93,7 @@ gisthandler(PG_FUNCTION_ARGS) .ambulkdelete = gistbulkdelete, .amvacuumcleanup = gistvacuumcleanup, .amcanreturn = gistcanreturn, + .amcanreturnorderby = gistcanreturnorderby, .amcostestimate = gistcostestimate, .amgettreeheight = NULL, .amoptions = gistoptions, diff --git a/src/backend/access/gist/gistget.c b/src/backend/access/gist/gistget.c index 111c310f9df5b..9aa2f47485604 100644 --- a/src/backend/access/gist/gistget.c +++ b/src/backend/access/gist/gistget.c @@ -23,6 +23,8 @@ #include "pgstat.h" #include "storage/predicate.h" #include "utils/float.h" +#include "utils/fmgroids.h" +#include "utils/lsyscache.h" #include "utils/memutils.h" #include "utils/rel.h" @@ -813,3 +815,24 @@ gistcanreturn(Relation index, int attno) else return false; } + +/* + * Are the distances the opclass reports for 'orderbyop' on a leaf entry, + * when it does not set *recheck, the operator's own result? + * + * The GiST contract only requires a non-rechecked distance to *sort* like + * the operator's result, so we cannot assume this of an arbitrary opclass. + * point_ops computes a leaf's distance by calling point_distance(), the + * function behind <->(point, point), so the executor may return it in place + * of evaluating the operator. box_ops doesn't qualify: its leaf distance is + * exact arithmetic on the box, while <->(box, point) treats points within + * EPSILON of an edge's line as on it, so the two can differ in the last + * digits. Other opclasses do not qualify. + */ +bool +gistcanreturnorderby(Oid opfamily, Oid opcintype, Oid orderbyop) +{ + return (get_opfamily_proc(opfamily, opcintype, opcintype, + GIST_DISTANCE_PROC) == F_GIST_POINT_DISTANCE && + get_opcode(orderbyop) == F_POINT_DISTANCE); +} diff --git a/src/backend/access/spgist/spgscan.c b/src/backend/access/spgist/spgscan.c index 2cc5f06f5d779..c9b50be348403 100644 --- a/src/backend/access/spgist/spgscan.c +++ b/src/backend/access/spgist/spgscan.c @@ -24,6 +24,7 @@ #include "storage/bufmgr.h" #include "utils/datum.h" #include "utils/float.h" +#include "utils/fmgroids.h" #include "utils/lsyscache.h" #include "utils/memutils.h" #include "utils/rel.h" @@ -1088,3 +1089,23 @@ spgcanreturn(Relation index, int attno) return cache->config.canReturnData; } + +/* + * Are the distances the opclass reports for 'orderbyop' on a leaf tuple, + * when it does not set recheckDistances, the operator's own result? + * + * As for GiST, the contract only requires them to sort like the operator's + * result. quad_point_ops and kd_point_ops (whose leaf function is + * spg_quad_leaf_consistent()) compute a leaf's distance with + * point_distance(), the function behind <->(point, point). box_ops doesn't + * qualify, for the reason given in gistcanreturnorderby(); polygon sets + * recheckDistances. + */ +bool +spgcanreturnorderby(Oid opfamily, Oid opcintype, Oid orderbyop) +{ + return (get_opfamily_proc(opfamily, opcintype, opcintype, + SPGIST_LEAF_CONSISTENT_PROC) == + F_SPG_QUAD_LEAF_CONSISTENT && + get_opcode(orderbyop) == F_POINT_DISTANCE); +} diff --git a/src/backend/access/spgist/spgutils.c b/src/backend/access/spgist/spgutils.c index f2ee333f60d84..d07e197a594cf 100644 --- a/src/backend/access/spgist/spgutils.c +++ b/src/backend/access/spgist/spgutils.c @@ -78,6 +78,7 @@ spghandler(PG_FUNCTION_ARGS) .ambulkdelete = spgbulkdelete, .amvacuumcleanup = spgvacuumcleanup, .amcanreturn = spgcanreturn, + .amcanreturnorderby = spgcanreturnorderby, .amcostestimate = spgcostestimate, .amgettreeheight = NULL, .amoptions = spgoptions, diff --git a/src/backend/commands/explain.c b/src/backend/commands/explain.c index 4b1ad4227acb4..afc45c0969280 100644 --- a/src/backend/commands/explain.c +++ b/src/backend/commands/explain.c @@ -1974,6 +1974,10 @@ ExplainNode(PlanState *planstate, List *ancestors, planstate, es); show_scan_qual(((IndexScan *) plan)->indexorderbyorig, "Order By", planstate, ancestors, es); + if (es->verbose && ((IndexScan *) plan)->indexorderbynused > 0) + ExplainPropertyInteger("Order By Values Used", NULL, + ((IndexScan *) plan)->indexorderbynused, + es); show_scan_qual(plan->qual, "Filter", planstate, ancestors, es); if (plan->qual) show_instrumentation_count("Rows Removed by Filter", 1, diff --git a/src/backend/executor/nodeIndexscan.c b/src/backend/executor/nodeIndexscan.c index 129d005f187dd..6f11c1938fd3b 100644 --- a/src/backend/executor/nodeIndexscan.c +++ b/src/backend/executor/nodeIndexscan.c @@ -163,6 +163,46 @@ IndexNext(IndexScanState *node) return ExecClearTuple(slot); } +/* + * Hand the ORDER BY values of the row about to be returned to the projection, + * when the targetlist asks for them (indexorderbynused > 0, set by setrefs.c + * only for keys whose opclass passes amcanreturnorderby). vals and nulls + * are what the caller established as this row's ordering values: the AM's + * when it reported them exact, the recomputed ones when it asked for a + * recheck, or a reorder queue entry's copy. Never a lossy AM's lower bound. + * The targetlist reads only float8 and float4 keys (see setrefs.c's + * replace_orderby_tlist_refs()), which are passed by value, so the slot can + * keep them after the caller frees its copies. Another key may be of a + * by-reference type, such as btree_gist's interval-valued <->; nothing reads + * it here, so store a null rather than a pointer that is about to dangle. + */ +static void +IndexStoreOrderByValues(IndexScanState *node, const Datum *vals, + const bool *nulls) +{ + TupleTableSlot *oslot = node->iss_OrderBySlot; + int i; + + if (oslot == NULL) + return; + ExecClearTuple(oslot); + for (i = 0; i < node->iss_NumOrderByKeys; i++) + { + if (node->iss_OrderByTypByVals[i]) + { + oslot->tts_values[i] = vals[i]; + oslot->tts_isnull[i] = nulls[i]; + } + else + { + oslot->tts_values[i] = (Datum) 0; + oslot->tts_isnull[i] = true; + } + } + ExecStoreVirtualTuple(oslot); + node->ss.ps.ps_ExprContext->ecxt_innertuple = oslot; +} + /* ---------------------------------------------------------------- * IndexNextWithReorder * @@ -251,6 +291,9 @@ IndexNextWithReorder(IndexScanState *node) { HeapTuple tuple; + /* the values are by-value; see IndexStoreOrderByValues() */ + IndexStoreOrderByValues(node, topmost->orderbyvals, + topmost->orderbynulls); tuple = reorderqueue_pop(node); /* Pass 'true', as the tuple in the queue is a palloc'd copy */ @@ -352,6 +395,7 @@ IndexNextWithReorder(IndexScanState *node) else { /* Can return this tuple immediately. */ + IndexStoreOrderByValues(node, lastfetched_vals, lastfetched_nulls); return slot; } } @@ -404,7 +448,22 @@ IndexRecheck(IndexScanState *node, TupleTableSlot *slot) /* Does the tuple meet the indexqual condition? */ econtext->ecxt_scantuple = slot; - return ExecQualAndReset(node->indexqualorig, econtext); + if (!ExecQualAndReset(node->indexqualorig, econtext)) + return false; + + /* + * The EPQ test tuple never went through IndexNext, so the ORDER BY values + * the targetlist reads (if any) are not those of this row. Recompute + * them from the tuple, as we would for a rechecked row. + */ + if (node->iss_OrderBySlot != NULL) + { + EvalOrderByExpressions(node, econtext); + IndexStoreOrderByValues(node, node->iss_OrderByValues, + node->iss_OrderByNulls); + } + + return true; } @@ -954,7 +1013,21 @@ ExecInitIndexScan(IndexScan *node, EState *estate, int eflags) * Initialize result type and projection. */ ExecInitResultTypeTL(&indexstate->ss.ps); - ExecAssignScanProjectionInfo(&indexstate->ss); + + /* + * A targetlist that reads the ORDER BY values (INNER_VAR Vars, see + * setrefs.c) is never the scan tuple's physical layout, so it always + * projects; ExecConditionalAssignProjectionInfo would assert on the + * INNER_VAR while testing whether it can skip projection. + */ + if (node->indexorderbynused > 0) + { + ExecInitResultSlot(&indexstate->ss.ps, &TTSOpsVirtual); + ExecAssignProjectionInfo(&indexstate->ss.ps, + indexstate->ss.ss_ScanTupleSlot->tts_tupleDescriptor); + } + else + ExecAssignScanProjectionInfo(&indexstate->ss); /* * initialize child expressions @@ -1024,6 +1097,16 @@ ExecInitIndexScan(IndexScan *node, EState *estate, int eflags) NULL, /* no ArrayKeys */ NULL); + /* + * A targetlist that reads the ORDER BY values needs a slot to read them + * from, typed as the ORDER BY expressions (setrefs.c built it so). + */ + if (node->indexorderbynused > 0) + indexstate->iss_OrderBySlot = + ExecAllocTableSlot(&estate->es_tupleTable, + ExecTypeFromExprList(node->indexorderbyorig), + &TTSOpsVirtual, 0); + /* Initialize sort support, if we need to re-check ORDER BY exprs */ if (indexstate->iss_NumOrderByKeys > 0) { diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c index 5bc3a8e76d5d4..0d0037029cf03 100644 --- a/src/backend/optimizer/plan/createplan.c +++ b/src/backend/optimizer/plan/createplan.c @@ -3037,6 +3037,26 @@ create_indexscan_plan(PlannerInfo *root, indexorderbyops, best_path->indexscandir); + if (!indexonly && indexorderbys != NIL) + { + List *exact = NIL; + ListCell *lo, + *lc; + + forboth(lo, best_path->indexorderbys, lc, best_path->indexorderbycols) + { + int indexcol = lfirst_int(lc); + OpExpr *orderby = lfirst_node(OpExpr, lo); + + exact = lappend_int(exact, + indexinfo->amcanreturnorderby != NULL && + indexinfo->amcanreturnorderby(indexinfo->opfamily[indexcol], + indexinfo->opcintype[indexcol], + orderby->opno)); + } + ((IndexScan *) scan_plan)->indexorderbyexact = exact; + } + copy_generic_path_info(&scan_plan->plan, &best_path->path); return scan_plan; diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c index af78892fafc4e..0036bbb4d1739 100644 --- a/src/backend/optimizer/plan/setrefs.c +++ b/src/backend/optimizer/plan/setrefs.c @@ -134,6 +134,7 @@ static bool flatten_rtes_walker(Node *node, flatten_rtes_walker_context *cxt); static void add_rte_to_flat_rtable(PlannerGlobal *glob, List *rteperminfos, RangeTblEntry *rte); static Plan *set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset); +static void replace_orderby_tlist_refs(IndexScan *splan); static Plan *set_indexonlyscan_references(PlannerInfo *root, IndexOnlyScan *plan, int rtoffset); @@ -704,6 +705,8 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset) splan->indexorderbyorig = fix_scan_list(root, splan->indexorderbyorig, rtoffset, NUM_EXEC_QUAL(plan)); + /* after both are fixed, so equal() compares like with like */ + replace_orderby_tlist_refs(splan); } break; case T_IndexOnlyScan: @@ -1367,6 +1370,63 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset) return plan; } +/* + * replace_orderby_tlist_refs + * Let an ordering IndexScan hand its ORDER BY values to its targetlist. + * + * For each ORDER BY key whose index column's opclass promises that the + * distances it reports for an exact (non-rechecked) row are the ordering + * operator's own result (indexorderbyexact[i], from amcanreturnorderby), + * replace each top-level targetlist expression that is equal() to the i'th + * indexorderbyorig expression with Var(INNER_VAR, i + 1). nodeIndexscan.c + * then fills a virtual slot with the ORDER BY values of every returned row -- + * the AM's for an exact row, the recomputed ones for a rechecked row -- and + * installs it as the projection's inner tuple. A scan node has no inner + * plan, so INNER_VAR is otherwise unused here. + * + * Only float8/float4 expressions qualify: those are the only types + * index_store_float8_orderby_distances() can deliver. + */ +static void +replace_orderby_tlist_refs(IndexScan *splan) +{ + ListCell *lc; + Bitmapset *used = NULL; + + if (splan->indexorderbyexact == NIL) + return; + Assert(list_length(splan->indexorderbyexact) == + list_length(splan->indexorderbyorig)); + + foreach(lc, splan->scan.plan.targetlist) + { + TargetEntry *tle = lfirst_node(TargetEntry, lc); + ListCell *lo, + *le; + int i = 0; + + forboth(lo, splan->indexorderbyorig, le, splan->indexorderbyexact) + { + Expr *orig = (Expr *) lfirst(lo); + Oid typ = exprType((Node *) orig); + + i++; + if (lfirst_int(le) && + (typ == FLOAT8OID || typ == FLOAT4OID) && + equal(tle->expr, orig)) + { + tle->expr = (Expr *) makeVar(INNER_VAR, i, typ, + exprTypmod((Node *) orig), + exprCollation((Node *) orig), + 0); + used = bms_add_member(used, i); + break; + } + } + } + splan->indexorderbynused = bms_num_members(used); +} + /* * set_indexonlyscan_references * Do set_plan_references processing on an IndexOnlyScan diff --git a/src/backend/optimizer/util/plancat.c b/src/backend/optimizer/util/plancat.c index 22e0e8b11f8d5..bd496d71a2ca2 100644 --- a/src/backend/optimizer/util/plancat.c +++ b/src/backend/optimizer/util/plancat.c @@ -316,6 +316,7 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent, amroutine->amrestrpos != NULL); info->amcostestimate = amroutine->amcostestimate; Assert(info->amcostestimate != NULL); + info->amcanreturnorderby = amroutine->amcanreturnorderby; /* Fetch index opclass options */ info->opclassoptions = RelationGetIndexAttOptions(indexRelation, true); @@ -414,6 +415,7 @@ get_relation_info(PlannerInfo *root, Oid relationObjectId, bool inhparent, info->amhasgetbitmap = false; info->amcanmarkpos = false; info->amcostestimate = NULL; + info->amcanreturnorderby = NULL; info->sortopfamily = NULL; info->reverse_sort = NULL; diff --git a/src/backend/utils/adt/ruleutils.c b/src/backend/utils/adt/ruleutils.c index ccfd7f4728f32..173c6211c53b6 100644 --- a/src/backend/utils/adt/ruleutils.c +++ b/src/backend/utils/adt/ruleutils.c @@ -7999,6 +7999,24 @@ resolve_special_varno(Node *node, deparse_context *context, context->appendparents = save_appendparents; return; } + else if (var->varno == INNER_VAR && dpns->plan != NULL && + IsA(dpns->plan, IndexScan) && + ((IndexScan *) dpns->plan)->indexorderbynused > 0) + { + /* + * An IndexScan whose targetlist reads its own ORDER BY values (see + * replace_orderby_tlist_refs in setrefs.c) uses INNER_VAR Vars + * numbered by ORDER BY key; they deparse as the ORDER BY expressions. + */ + List *orderbys = ((IndexScan *) dpns->plan)->indexorderbyorig; + + if (var->varattno < 1 || var->varattno > list_length(orderbys)) + elog(ERROR, "bogus varattno for ORDER BY value var: %d", + var->varattno); + resolve_special_varno((Node *) list_nth(orderbys, var->varattno - 1), + context, callback, callback_arg); + return; + } else if (var->varno == INNER_VAR && dpns->inner_tlist) { TargetEntry *tle; diff --git a/src/include/access/amapi.h b/src/include/access/amapi.h index 7924033353031..f520ba06e6a77 100644 --- a/src/include/access/amapi.h +++ b/src/include/access/amapi.h @@ -144,6 +144,14 @@ typedef IndexBulkDeleteResult *(*amvacuumcleanup_function) (IndexVacuumInfo *inf /* can indexscan return IndexTuples? */ typedef bool (*amcanreturn_function) (Relation indexRelation, int attno); +/* + * Are the ORDER BY distances an opclass reports for ordering operator + * 'orderbyop' on an exact (xs_recheckorderby = false) leaf entry the + * operator's own result? + */ +typedef bool (*amcanreturnorderby_function) (Oid opfamily, Oid opcintype, + Oid orderbyop); + /* estimate cost of an indexscan */ typedef void (*amcostestimate_function) (PlannerInfo *root, IndexPath *path, @@ -300,6 +308,7 @@ typedef struct IndexAmRoutine ambulkdelete_function ambulkdelete; amvacuumcleanup_function amvacuumcleanup; amcanreturn_function amcanreturn; /* can be NULL */ + amcanreturnorderby_function amcanreturnorderby; /* can be NULL */ amcostestimate_function amcostestimate; amgettreeheight_function amgettreeheight; /* can be NULL */ amoptions_function amoptions; diff --git a/src/include/access/gist_private.h b/src/include/access/gist_private.h index 39fdd27f5b97c..d2b3b5d7ea36d 100644 --- a/src/include/access/gist_private.h +++ b/src/include/access/gist_private.h @@ -462,6 +462,7 @@ extern void gistkillitems(IndexScanDesc scan); extern bool gistgettuple(IndexScanDesc scan, ScanDirection dir); extern int64 gistgetbitmap(IndexScanDesc scan, TIDBitmap *tbm); extern bool gistcanreturn(Relation index, int attno); +extern bool gistcanreturnorderby(Oid opfamily, Oid opcintype, Oid orderbyop); /* gistvalidate.c */ extern bool gistvalidate(Oid opclassoid); diff --git a/src/include/access/spgist.h b/src/include/access/spgist.h index 083d93f8ffdc0..f293c14455b68 100644 --- a/src/include/access/spgist.h +++ b/src/include/access/spgist.h @@ -210,6 +210,7 @@ extern void spgrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys, extern int64 spggetbitmap(IndexScanDesc scan, TIDBitmap *tbm); extern bool spggettuple(IndexScanDesc scan, ScanDirection dir); extern bool spgcanreturn(Relation index, int attno); +extern bool spgcanreturnorderby(Oid opfamily, Oid opcintype, Oid orderbyop); /* spgvacuum.c */ extern IndexBulkDeleteResult *spgbulkdelete(IndexVacuumInfo *info, diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h index fe8681ba642e7..729a39761d94a 100644 --- a/src/include/nodes/execnodes.h +++ b/src/include/nodes/execnodes.h @@ -1722,6 +1722,7 @@ typedef struct * OrderByTypByVals is the datatype of order by expression pass-by-value? * OrderByTypLens typlens of the datatypes of order by expressions * PscanLen size of parallel index scan descriptor + * OrderBySlot returned row's ORDER BY values, for the targetlist * ---------------- */ typedef struct IndexScanState @@ -1751,6 +1752,13 @@ typedef struct IndexScanState bool *iss_OrderByTypByVals; int16 *iss_OrderByTypLens; Size iss_PscanLen; + + /* + * When the plan's targetlist reads the ORDER BY values (indexorderbynused + * > 0), the values of the row being returned, as a virtual slot installed + * as the projection's inner tuple. + */ + TupleTableSlot *iss_OrderBySlot; } IndexScanState; /* ---------------- diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h index 8615db98a551b..c4e0df4338e19 100644 --- a/src/include/nodes/pathnodes.h +++ b/src/include/nodes/pathnodes.h @@ -1447,6 +1447,8 @@ typedef struct IndexOptInfo /* AM's cost estimator */ /* Rather than include amapi.h here, we declare amcostestimate like this */ void (*amcostestimate) (struct PlannerInfo *, struct IndexPath *, double, Cost *, Cost *, Selectivity *, double *, double *) pg_node_attr(read_write_ignore); + /* AM's amcanreturnorderby, or NULL */ + bool (*amcanreturnorderby) (Oid, Oid, Oid) pg_node_attr(read_write_ignore); } IndexOptInfo; /* diff --git a/src/include/nodes/plannodes.h b/src/include/nodes/plannodes.h index 9e538f3c44a3c..d15967e614a8f 100644 --- a/src/include/nodes/plannodes.h +++ b/src/include/nodes/plannodes.h @@ -616,6 +616,19 @@ typedef struct IndexScan List *indexorderbyops; /* forward or backward or don't care */ ScanDirection indexorderdir; + + /* + * Per indexorderbyorig entry: may setrefs.c replace a target-list + * occurrence of that expression with an INNER_VAR reference to the scan's + * ORDER BY value? True when the index column's opclass reports exact + * distances that are the operator's result (amcanreturnorderby). Only + * setrefs.c reads it, so it isn't written out with the plan. + * indexorderbynused counts the ORDER BY values the targetlist reads (0 if + * none); the executor sets up the ORDER BY slot and projection when it's + * nonzero, and EXPLAIN shows it. + */ + List *indexorderbyexact pg_node_attr(read_write_ignore, read_as(NIL)); + int indexorderbynused; } IndexScan; /* ---------------- diff --git a/src/test/isolation/expected/eval-plan-qual-knn.out b/src/test/isolation/expected/eval-plan-qual-knn.out new file mode 100644 index 0000000000000..3c1c4794b64f4 --- /dev/null +++ b/src/test/isolation/expected/eval-plan-qual-knn.out @@ -0,0 +1,40 @@ +Parsed test spec with 2 sessions + +starting permutation: s2e s1b s1u s2s s1c +step s2e: + EXPLAIN (VERBOSE, COSTS OFF) + SELECT id, p <-> point(0, 0) AS d FROM epq_knn + WHERE p <@ box(point(0, 0), point(10, 10)) + ORDER BY p <-> point(0, 0) LIMIT 4 FOR UPDATE; + +QUERY PLAN +------------------------------------------------------------- +Limit + Output: id, ((p <-> '(0,0)'::point)), ctid + -> LockRows + Output: id, ((p <-> '(0,0)'::point)), ctid + -> Index Scan using epq_knn_p on public.epq_knn + Output: id, ((p <-> '(0,0)'::point)), ctid + Index Cond: (epq_knn.p <@ '(10,10),(0,0)'::box) + Order By: (epq_knn.p <-> '(0,0)'::point) + Order By Values Used: 1 +(9 rows) + +step s1b: BEGIN; +step s1u: UPDATE epq_knn SET p = point(3.5, 3.5) WHERE id = 3; +step s2s: + SELECT id, p <-> point(0, 0) AS d, (p <-> point(0, 0)) = d AS same + FROM (SELECT id, p, p <-> point(0, 0) AS d FROM epq_knn + WHERE p <@ box(point(0, 0), point(10, 10)) + ORDER BY p <-> point(0, 0) LIMIT 4 FOR UPDATE) s; + +step s1c: COMMIT; +step s2s: <... completed> +id| d|same +--+------------------+---- + 1|1.4142135623730951|t + 2|2.8284271247461903|t + 3| 4.949747468305833|t + 4| 5.656854249492381|t +(4 rows) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index bf9a2037c705a..1703a3ec50bd7 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -44,6 +44,7 @@ test: subxid-overflow test: eval-plan-qual test: eval-plan-qual-trigger test: eval-plan-qual-partition-prune +test: eval-plan-qual-knn test: inplace-inval test: intra-grant-inplace test: intra-grant-inplace-db diff --git a/src/test/isolation/specs/eval-plan-qual-knn.spec b/src/test/isolation/specs/eval-plan-qual-knn.spec new file mode 100644 index 0000000000000..b485e88dbc3c1 --- /dev/null +++ b/src/test/isolation/specs/eval-plan-qual-knn.spec @@ -0,0 +1,44 @@ +# An ordering index scan that returns its ORDER BY values (amcanreturnorderby) +# must not hand back the value it computed for the old row version when +# EvalPlanQual re-checks a concurrently updated row: the value is recomputed +# from the new version. + +setup +{ + CREATE TABLE epq_knn (id int, p point); + INSERT INTO epq_knn SELECT i, point(i, i) FROM generate_series(1, 20) i; + CREATE INDEX epq_knn_p ON epq_knn USING gist (p); +} + +teardown +{ + DROP TABLE epq_knn; +} + +session s1 +setup { SET enable_seqscan = off; SET enable_bitmapscan = off; } +step s1b { BEGIN; } +step s1u { UPDATE epq_knn SET p = point(3.5, 3.5) WHERE id = 3; } +step s1c { COMMIT; } + +session s2 +setup { SET enable_seqscan = off; SET enable_bitmapscan = off; } +step s2e +{ + EXPLAIN (VERBOSE, COSTS OFF) + SELECT id, p <-> point(0, 0) AS d FROM epq_knn + WHERE p <@ box(point(0, 0), point(10, 10)) + ORDER BY p <-> point(0, 0) LIMIT 4 FOR UPDATE; +} +step s2s +{ + SELECT id, p <-> point(0, 0) AS d, (p <-> point(0, 0)) = d AS same + FROM (SELECT id, p, p <-> point(0, 0) AS d FROM epq_knn + WHERE p <@ box(point(0, 0), point(10, 10)) + ORDER BY p <-> point(0, 0) LIMIT 4 FOR UPDATE) s; +} + +# s2's scan computes id 3's distance from (3,3), then waits on s1's lock; +# after s1 commits, EvalPlanQual re-checks id 3 with p = (3.5,3.5), and the +# value returned must be that row's distance. +permutation s2e s1b s1u s2s s1c diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out index ac79f94aa8028..355bf8164233c 100644 --- a/src/test/regress/expected/gist.out +++ b/src/test/regress/expected/gist.out @@ -387,6 +387,274 @@ select p from gist_tbl order by circle(p,1) <-> point(0,0) limit 1; select p from gist_tbl order by circle(p,1) <-> point(0,0) limit 1; ERROR: lossy distance functions are not supported in index-only scans +drop index gist_tbl_multi_index; +-- Test that an ordering Index Scan returns the AM's ORDER BY values to a +-- targetlist entry equal to the ORDER BY expression, instead of evaluating +-- the expression again, for an opclass that passes amcanreturnorderby. +set enable_indexonlyscan = off; +-- An expression index over a counting function proves the operator is not +-- re-evaluated: the count stays at zero for the rows returned. +create sequence gist_cnt_seq; +create function gist_cnt_pt(point) returns point language plpgsql immutable + as $$ begin perform nextval('public.gist_cnt_seq'); return $1; end $$; +create index gist_tbl_cnt_index on gist_tbl using gist (gist_cnt_pt(p)); +explain (verbose, costs off) +select p, gist_cnt_pt(p) <-> point(0.201, 0.201) as dist + from gist_tbl order by gist_cnt_pt(p) <-> point(0.201, 0.201) limit 3; + QUERY PLAN +------------------------------------------------------------------------ + Limit + Output: p, ((gist_cnt_pt(p) <-> '(0.201,0.201)'::point)) + -> Index Scan using gist_tbl_cnt_index on public.gist_tbl + Output: p, ((gist_cnt_pt(p) <-> '(0.201,0.201)'::point)) + Order By: (gist_cnt_pt(gist_tbl.p) <-> '(0.201,0.201)'::point) + Order By Values Used: 1 +(6 rows) + +select setval('gist_cnt_seq', 1, false); + setval +-------- + 1 +(1 row) + +-- the distance numbers are not exactly the same across platforms +set extra_float_digits = 0; +select p, gist_cnt_pt(p) <-> point(0.201, 0.201) as dist + from gist_tbl order by gist_cnt_pt(p) <-> point(0.201, 0.201) limit 3; + p | dist +-------------+-------------------- + (0.2,0.2) | 0.0014142135623731 + (0.25,0.25) | 0.0692964645562816 + (0.15,0.15) | 0.0721248916810279 +(3 rows) + +reset extra_float_digits; +select nextval('gist_cnt_seq') - 1 as calls_during_scan; + calls_during_scan +------------------- + 0 +(1 row) + +-- Every copy of the ORDER BY expression is served from the index; an +-- expression that merely contains it is not. +explain (verbose, costs off) +select gist_cnt_pt(p) <-> point(0,0) as d1, + (gist_cnt_pt(p) <-> point(0,0)) * 2 as d2, + gist_cnt_pt(p) <-> point(0,0) as d3 + from gist_tbl order by gist_cnt_pt(p) <-> point(0,0) limit 2; + QUERY PLAN +------------------------------------------------------------------------------------------------------------------------------------------------------------- + Limit + Output: ((gist_cnt_pt(p) <-> '(0,0)'::point)), (((gist_cnt_pt(p) <-> '(0,0)'::point) * '2'::double precision)), ((gist_cnt_pt(p) <-> '(0,0)'::point)) + -> Index Scan using gist_tbl_cnt_index on public.gist_tbl + Output: ((gist_cnt_pt(p) <-> '(0,0)'::point)), ((gist_cnt_pt(p) <-> '(0,0)'::point) * '2'::double precision), ((gist_cnt_pt(p) <-> '(0,0)'::point)) + Order By: (gist_cnt_pt(gist_tbl.p) <-> '(0,0)'::point) + Order By Values Used: 1 +(6 rows) + +drop index gist_tbl_cnt_index; +drop function gist_cnt_pt(point); +drop sequence gist_cnt_seq; +-- The values must equal the operator's, also for a row re-fetched under +-- row locking, and for a rescan with a new ORDER BY argument (LATERAL). +create index gist_tbl_point_index on gist_tbl using gist (p); +select count(*) filter (where dist = p <-> point(5.2, 5.91)) as same, + count(*) as total + from (select p, p <-> point(5.2, 5.91) as dist from gist_tbl + order by p <-> point(5.2, 5.91) limit 200) ss; + same | total +------+------- + 200 | 200 +(1 row) + +set extra_float_digits = 0; +select p, p <-> point(5.2, 5.91) as dist from gist_tbl + where p <@ box(point(5,5), point(6,6)) order by p <-> point(5.2, 5.91) + limit 3 for update; + p | dist +-------------+------------------- + (5.55,5.55) | 0.502095608425328 + (5.6,5.6) | 0.5060632371552 + (5.5,5.5) | 0.508035431835221 +(3 rows) + +reset extra_float_digits; +explain (verbose, costs off) +select v.q, n.dist + from (values (point(0.3, 0.3)), (point(7.1, 7.2)), (point(42, 42))) v(q), + lateral (select p <-> v.q as dist from gist_tbl + order by p <-> v.q limit 2) n; + QUERY PLAN +---------------------------------------------------------------------- + Nested Loop + Output: "*VALUES*".column1, ((gist_tbl.p <-> "*VALUES*".column1)) + -> Values Scan on "*VALUES*" + Output: "*VALUES*".column1 + -> Limit + Output: ((gist_tbl.p <-> "*VALUES*".column1)) + -> Index Scan using gist_tbl_point_index on public.gist_tbl + Output: ((gist_tbl.p <-> "*VALUES*".column1)) + Order By: (gist_tbl.p <-> "*VALUES*".column1) + Order By Values Used: 1 +(10 rows) + +select count(*) filter (where n.dist = n.p <-> v.q) as same, count(*) as total + from (values (point(0.3, 0.3)), (point(7.1, 7.2)), (point(42, 42))) v(q), + lateral (select p, p <-> v.q as dist from gist_tbl + order by p <-> v.q limit 50) n; + same | total +------+------- + 150 | 150 +(1 row) + +-- A multi-column index ordered by an exact key and a lossy one: the lossy +-- one's distances are rechecked, rows pass through the reorder queue, and +-- the exact key's value is still the operator's. +create index gist_tbl_pc_index on gist_tbl using gist (p, c); +explain (verbose, costs off) +select p <-> point(1, 1) as dp, c <-> point(2, 2) as dc from gist_tbl + order by p <-> point(1, 1), c <-> point(2, 2) limit 5; + QUERY PLAN +----------------------------------------------------------------------------------------- + Limit + Output: ((p <-> '(1,1)'::point)), ((c <-> '(2,2)'::point)) + -> Index Scan using gist_tbl_pc_index on public.gist_tbl + Output: ((p <-> '(1,1)'::point)), (c <-> '(2,2)'::point) + Order By: ((gist_tbl.p <-> '(1,1)'::point) AND (gist_tbl.c <-> '(2,2)'::point)) + Order By Values Used: 1 +(6 rows) + +select count(*) filter (where dp = p <-> point(1, 1)) as same_p, + count(*) filter (where dc = c <-> point(2, 2)) as same_c, + count(*) as total + from (select p, c, p <-> point(1, 1) as dp, c <-> point(2, 2) as dc + from gist_tbl + order by p <-> point(1, 1), c <-> point(2, 2) limit 200) ss; + same_p | same_c | total +--------+--------+------- + 200 | 200 | 200 +(1 row) + +drop index gist_tbl_pc_index; +drop index gist_tbl_point_index; +-- The box opclass computes a leaf's distance exactly, but the operator treats +-- a point within EPSILON of an edge's line as on it, so the two can differ in +-- the last digits; it doesn't qualify, and the operator is evaluated. +create index gist_tbl_box_index on gist_tbl using gist (b); +explain (verbose, costs off) +select b, b <-> point(5.2, 5.91) as dist from gist_tbl + where b <@ box(point(5,5), point(6,6)) order by b <-> point(5.2, 5.91); + QUERY PLAN +-------------------------------------------------------- + Index Scan using gist_tbl_box_index on public.gist_tbl + Output: b, (b <-> '(5.2,5.91)'::point) + Index Cond: (gist_tbl.b <@ '(6,6),(5,5)'::box) + Order By: (gist_tbl.b <-> '(5.2,5.91)'::point) +(4 rows) + +-- For this point, the index's distance is 1 and the operator's is +-- 1.00000000000008: dist is the operator's only if the scan doesn't hand +-- back its own. +create temp table gist_box_eps (b box); +insert into gist_box_eps values ('((0,0),(1,1))'); +create index on gist_box_eps using gist (b); +select count(*) filter (where dist = b <-> point(0.9999996, 2)) as same, + count(*) as total + from (select b, b <-> point(0.9999996, 2) as dist from gist_box_eps + order by b <-> point(0.9999996, 2) limit 1) ss; + same | total +------+------- + 1 | 1 +(1 row) + +drop table gist_box_eps; +drop index gist_tbl_box_index; +-- The circle opclass's distance is a lower bound that is always rechecked, +-- and its opclass does not pass amcanreturnorderby: the targetlist is left +-- alone and the operator is evaluated as before. +create index gist_tbl_circle_index on gist_tbl using gist (c); +explain (verbose, costs off) +select c <-> point(5.2, 5.91) as dist from gist_tbl + order by c <-> point(5.2, 5.91) limit 3; + QUERY PLAN +----------------------------------------------------------------- + Limit + Output: ((c <-> '(5.2,5.91)'::point)) + -> Index Scan using gist_tbl_circle_index on public.gist_tbl + Output: (c <-> '(5.2,5.91)'::point) + Order By: (gist_tbl.c <-> '(5.2,5.91)'::point) +(5 rows) + +select count(*) filter (where dist = c <-> point(5.2, 5.91)) as same, + count(*) as total + from (select c, c <-> point(5.2, 5.91) as dist from gist_tbl + order by c <-> point(5.2, 5.91) limit 200) ss; + same | total +------+------- + 200 | 200 +(1 row) + +drop index gist_tbl_circle_index; +reset enable_indexonlyscan; +-- SP-GiST's quad and k-d point opclasses qualify as well; its box opclass +-- doesn't, for the same reason as GiST's. +create temp table spg_orderby (p point, b box); +insert into spg_orderby + select point(i % 97 * 0.31, i / 97 * 0.29), + box(point(i % 97 * 0.31, i / 97 * 0.29), + point(i % 97 * 0.31 + 0.2, i / 97 * 0.29 + 0.1)) + from generate_series(1, 5000) i; +create index spg_orderby_quad on spg_orderby using spgist (p); +set enable_indexonlyscan = off; +explain (verbose, costs off) +select p <-> point(3, 4) as dist from spg_orderby + order by p <-> point(3, 4) limit 3; + QUERY PLAN +---------------------------------------------------------------- + Limit + Output: ((p <-> '(3,4)'::point)) + -> Index Scan using spg_orderby_quad on pg_temp.spg_orderby + Output: ((p <-> '(3,4)'::point)) + Order By: (spg_orderby.p <-> '(3,4)'::point) + Order By Values Used: 1 +(6 rows) + +select count(*) filter (where dist = p <-> point(3, 4)) as same, + count(*) as total + from (select p, p <-> point(3, 4) as dist from spg_orderby + order by p <-> point(3, 4) limit 300) ss; + same | total +------+------- + 300 | 300 +(1 row) + +drop index spg_orderby_quad; +create index spg_orderby_kd on spg_orderby using spgist (p kd_point_ops); +select count(*) filter (where dist = p <-> point(3, 4)) as same, + count(*) as total + from (select p, p <-> point(3, 4) as dist from spg_orderby + order by p <-> point(3, 4) limit 300) ss; + same | total +------+------- + 300 | 300 +(1 row) + +drop index spg_orderby_kd; +create index spg_orderby_box on spg_orderby using spgist (b); +explain (verbose, costs off) +select b <-> point(3, 4) as dist from spg_orderby + order by b <-> point(3, 4) limit 3; + QUERY PLAN +--------------------------------------------------------------- + Limit + Output: ((b <-> '(3,4)'::point)) + -> Index Scan using spg_orderby_box on pg_temp.spg_orderby + Output: (b <-> '(3,4)'::point) + Order By: (spg_orderby.b <-> '(3,4)'::point) +(5 rows) + +reset enable_indexonlyscan; +drop table spg_orderby; -- Test that an index-only scan deforms the tuple it reconstructs with the -- descriptor the AM formed it with, not the scan slot's descriptor. create temp table gist_ios_tupdesc (a inet, r numrange); diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql index 57dcc08245037..9a5e331b50d12 100644 --- a/src/test/regress/sql/gist.sql +++ b/src/test/regress/sql/gist.sql @@ -169,6 +169,156 @@ explain (verbose, costs off) select p from gist_tbl order by circle(p,1) <-> point(0,0) limit 1; select p from gist_tbl order by circle(p,1) <-> point(0,0) limit 1; +drop index gist_tbl_multi_index; + +-- Test that an ordering Index Scan returns the AM's ORDER BY values to a +-- targetlist entry equal to the ORDER BY expression, instead of evaluating +-- the expression again, for an opclass that passes amcanreturnorderby. +set enable_indexonlyscan = off; + +-- An expression index over a counting function proves the operator is not +-- re-evaluated: the count stays at zero for the rows returned. +create sequence gist_cnt_seq; +create function gist_cnt_pt(point) returns point language plpgsql immutable + as $$ begin perform nextval('public.gist_cnt_seq'); return $1; end $$; +create index gist_tbl_cnt_index on gist_tbl using gist (gist_cnt_pt(p)); + +explain (verbose, costs off) +select p, gist_cnt_pt(p) <-> point(0.201, 0.201) as dist + from gist_tbl order by gist_cnt_pt(p) <-> point(0.201, 0.201) limit 3; + +select setval('gist_cnt_seq', 1, false); +-- the distance numbers are not exactly the same across platforms +set extra_float_digits = 0; +select p, gist_cnt_pt(p) <-> point(0.201, 0.201) as dist + from gist_tbl order by gist_cnt_pt(p) <-> point(0.201, 0.201) limit 3; +reset extra_float_digits; +select nextval('gist_cnt_seq') - 1 as calls_during_scan; + +-- Every copy of the ORDER BY expression is served from the index; an +-- expression that merely contains it is not. +explain (verbose, costs off) +select gist_cnt_pt(p) <-> point(0,0) as d1, + (gist_cnt_pt(p) <-> point(0,0)) * 2 as d2, + gist_cnt_pt(p) <-> point(0,0) as d3 + from gist_tbl order by gist_cnt_pt(p) <-> point(0,0) limit 2; + +drop index gist_tbl_cnt_index; +drop function gist_cnt_pt(point); +drop sequence gist_cnt_seq; + +-- The values must equal the operator's, also for a row re-fetched under +-- row locking, and for a rescan with a new ORDER BY argument (LATERAL). +create index gist_tbl_point_index on gist_tbl using gist (p); + +select count(*) filter (where dist = p <-> point(5.2, 5.91)) as same, + count(*) as total + from (select p, p <-> point(5.2, 5.91) as dist from gist_tbl + order by p <-> point(5.2, 5.91) limit 200) ss; + +set extra_float_digits = 0; +select p, p <-> point(5.2, 5.91) as dist from gist_tbl + where p <@ box(point(5,5), point(6,6)) order by p <-> point(5.2, 5.91) + limit 3 for update; +reset extra_float_digits; + +explain (verbose, costs off) +select v.q, n.dist + from (values (point(0.3, 0.3)), (point(7.1, 7.2)), (point(42, 42))) v(q), + lateral (select p <-> v.q as dist from gist_tbl + order by p <-> v.q limit 2) n; +select count(*) filter (where n.dist = n.p <-> v.q) as same, count(*) as total + from (values (point(0.3, 0.3)), (point(7.1, 7.2)), (point(42, 42))) v(q), + lateral (select p, p <-> v.q as dist from gist_tbl + order by p <-> v.q limit 50) n; + +-- A multi-column index ordered by an exact key and a lossy one: the lossy +-- one's distances are rechecked, rows pass through the reorder queue, and +-- the exact key's value is still the operator's. +create index gist_tbl_pc_index on gist_tbl using gist (p, c); +explain (verbose, costs off) +select p <-> point(1, 1) as dp, c <-> point(2, 2) as dc from gist_tbl + order by p <-> point(1, 1), c <-> point(2, 2) limit 5; +select count(*) filter (where dp = p <-> point(1, 1)) as same_p, + count(*) filter (where dc = c <-> point(2, 2)) as same_c, + count(*) as total + from (select p, c, p <-> point(1, 1) as dp, c <-> point(2, 2) as dc + from gist_tbl + order by p <-> point(1, 1), c <-> point(2, 2) limit 200) ss; +drop index gist_tbl_pc_index; +drop index gist_tbl_point_index; + +-- The box opclass computes a leaf's distance exactly, but the operator treats +-- a point within EPSILON of an edge's line as on it, so the two can differ in +-- the last digits; it doesn't qualify, and the operator is evaluated. +create index gist_tbl_box_index on gist_tbl using gist (b); + +explain (verbose, costs off) +select b, b <-> point(5.2, 5.91) as dist from gist_tbl + where b <@ box(point(5,5), point(6,6)) order by b <-> point(5.2, 5.91); + +-- For this point, the index's distance is 1 and the operator's is +-- 1.00000000000008: dist is the operator's only if the scan doesn't hand +-- back its own. +create temp table gist_box_eps (b box); +insert into gist_box_eps values ('((0,0),(1,1))'); +create index on gist_box_eps using gist (b); +select count(*) filter (where dist = b <-> point(0.9999996, 2)) as same, + count(*) as total + from (select b, b <-> point(0.9999996, 2) as dist from gist_box_eps + order by b <-> point(0.9999996, 2) limit 1) ss; +drop table gist_box_eps; + +drop index gist_tbl_box_index; + +-- The circle opclass's distance is a lower bound that is always rechecked, +-- and its opclass does not pass amcanreturnorderby: the targetlist is left +-- alone and the operator is evaluated as before. +create index gist_tbl_circle_index on gist_tbl using gist (c); + +explain (verbose, costs off) +select c <-> point(5.2, 5.91) as dist from gist_tbl + order by c <-> point(5.2, 5.91) limit 3; + +select count(*) filter (where dist = c <-> point(5.2, 5.91)) as same, + count(*) as total + from (select c, c <-> point(5.2, 5.91) as dist from gist_tbl + order by c <-> point(5.2, 5.91) limit 200) ss; + +drop index gist_tbl_circle_index; +reset enable_indexonlyscan; + +-- SP-GiST's quad and k-d point opclasses qualify as well; its box opclass +-- doesn't, for the same reason as GiST's. +create temp table spg_orderby (p point, b box); +insert into spg_orderby + select point(i % 97 * 0.31, i / 97 * 0.29), + box(point(i % 97 * 0.31, i / 97 * 0.29), + point(i % 97 * 0.31 + 0.2, i / 97 * 0.29 + 0.1)) + from generate_series(1, 5000) i; +create index spg_orderby_quad on spg_orderby using spgist (p); +set enable_indexonlyscan = off; +explain (verbose, costs off) +select p <-> point(3, 4) as dist from spg_orderby + order by p <-> point(3, 4) limit 3; +select count(*) filter (where dist = p <-> point(3, 4)) as same, + count(*) as total + from (select p, p <-> point(3, 4) as dist from spg_orderby + order by p <-> point(3, 4) limit 300) ss; +drop index spg_orderby_quad; +create index spg_orderby_kd on spg_orderby using spgist (p kd_point_ops); +select count(*) filter (where dist = p <-> point(3, 4)) as same, + count(*) as total + from (select p, p <-> point(3, 4) as dist from spg_orderby + order by p <-> point(3, 4) limit 300) ss; +drop index spg_orderby_kd; +create index spg_orderby_box on spg_orderby using spgist (b); +explain (verbose, costs off) +select b <-> point(3, 4) as dist from spg_orderby + order by b <-> point(3, 4) limit 3; +reset enable_indexonlyscan; +drop table spg_orderby; + -- Test that an index-only scan deforms the tuple it reconstructs with the -- descriptor the AM formed it with, not the scan slot's descriptor. create temp table gist_ios_tupdesc (a inet, r numrange); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index a7a8dbdd23f79..1d9246b1ff8bd 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3567,6 +3567,7 @@ ambuildempty_function ambuildphasename_function ambulkdelete_function amcanreturn_function +amcanreturnorderby_function amcostestimate_function amendscan_function amestimateparallelscan_function From 8c64cce34436a2a25b86ee156a7f5e2cdd46da10 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Wed, 7 Oct 2026 01:50:04 +0000 Subject: [PATCH 4/9] Don't charge an ordering index scan for ORDER BY values it returns The previous commit lets a plain Index Scan return its ORDER BY values to a target list entry that matches the ORDER BY expression, instead of evaluating that expression again. The planner didn't know that. An ORDER BY expression is always part of the scan/join target (as a sort column, if nothing else), and apply_scanjoin_target_to_paths() charges every path rows * cost(expression) when it applies that target. For the Index Scan that cost is now never paid, while Seq Scan + Sort and Bitmap Heap Scan + Sort really do pay it, once per row. With a costly ORDER BY expression the planner therefore overestimated the kNN scan by up to ~32x and picked a Sort plan that was up to 13x slower. Teach create_projection_path() and apply_projection_to_path() to cost the target a path pays for with a new helper, path_target_cost(), which for a plain IndexScan leaves out top-level target expressions the scan will take from its ORDER BY values. The test for "will take" is index_orderby_returnable() plus orderby_tlist_match(); setrefs.c's rewrite and create_indexscan_plan() use the same two, so the cost model and the plan cannot drift apart. orderby_tlist_match() accepts the expression equal() to the ORDER BY key, and also its commuted form: match_clause_to_ordering_op() turns "const <-> col" into "col <-> const" for the index, so a target entry written the original way is the same value but not equal() to the key. An expression that only contains the ORDER BY expression, e.g. (expr) * 2, isn't rewritten and is still charged. Because this makes an IndexPath cheaper relative to its siblings, apply_scanjoin_target_to_paths() can no longer assume that applying the target preserves the cost ordering of rel->pathlist and rel->partial_pathlist, which add_path_precheck() and the linitial(partial_pathlist) callers rely on. Re-sort them with a new sort_pathlist_by_cost(), a stable insertion sort that is a single pass in the usual already-sorted case. Nothing changes for an index whose opclass does not pass amcanreturnorderby, for IndexOnlyScan, for bitmap scans, or for any query without a returnable ORDER BY expression in the target. Author: Greg Burd Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me --- src/backend/optimizer/plan/createplan.c | 13 +- src/backend/optimizer/plan/planner.c | 10 +- src/backend/optimizer/plan/setrefs.c | 16 ++- src/backend/optimizer/util/pathnode.c | 173 +++++++++++++++++++++++- src/include/optimizer/pathnode.h | 4 + src/test/regress/expected/gist.out | 93 +++++++++++++ src/test/regress/sql/gist.sql | 70 ++++++++++ 7 files changed, 354 insertions(+), 25 deletions(-) diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c index 0d0037029cf03..7f39ceecaed97 100644 --- a/src/backend/optimizer/plan/createplan.c +++ b/src/backend/optimizer/plan/createplan.c @@ -3043,17 +3043,12 @@ create_indexscan_plan(PlannerInfo *root, ListCell *lo, *lc; + /* must agree with path_target_cost() in pathnode.c */ forboth(lo, best_path->indexorderbys, lc, best_path->indexorderbycols) - { - int indexcol = lfirst_int(lc); - OpExpr *orderby = lfirst_node(OpExpr, lo); - exact = lappend_int(exact, - indexinfo->amcanreturnorderby != NULL && - indexinfo->amcanreturnorderby(indexinfo->opfamily[indexcol], - indexinfo->opcintype[indexcol], - orderby->opno)); - } + index_orderby_returnable(indexinfo, + lfirst_int(lc), + (Expr *) lfirst(lo))); ((IndexScan *) scan_plan)->indexorderbyexact = exact; } diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index edaa42d713af0..3b70b8a378feb 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -8259,8 +8259,10 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, * If the tlist exprs are the same, we can just inject the sortgroupref * information into the existing pathtargets. Otherwise, replace each * path with a projection path that generates the SRF-free scan/join - * target. This can't change the ordering of paths within rel->pathlist, - * so we just modify the list in place. + * target. We modify the lists in place. That usually adds the same cost + * to every path, but a plain IndexScan doesn't pay for target entries it + * takes from its ORDER BY values, so it can become cheaper relative to + * the others; restore the cost ordering afterwards. */ foreach(lc, rel->pathlist) { @@ -8281,6 +8283,8 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, lfirst(lc) = newpath; } } + if (!tlist_same_exprs) + sort_pathlist_by_cost(rel->pathlist); /* Likewise adjust the targets for any partial paths. */ foreach(lc, rel->partial_pathlist) @@ -8302,6 +8306,8 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, lfirst(lc) = newpath; } } + if (!tlist_same_exprs) + sort_pathlist_by_cost(rel->partial_pathlist); /* * Now, if final scan/join target contains SRFs, insert ProjectSetPath(s) diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c index 0036bbb4d1739..e6b6439ff2d01 100644 --- a/src/backend/optimizer/plan/setrefs.c +++ b/src/backend/optimizer/plan/setrefs.c @@ -1377,15 +1377,19 @@ set_plan_refs(PlannerInfo *root, Plan *plan, int rtoffset) * For each ORDER BY key whose index column's opclass promises that the * distances it reports for an exact (non-rechecked) row are the ordering * operator's own result (indexorderbyexact[i], from amcanreturnorderby), - * replace each top-level targetlist expression that is equal() to the i'th - * indexorderbyorig expression with Var(INNER_VAR, i + 1). nodeIndexscan.c + * replace each top-level targetlist expression that is the i'th + * indexorderbyorig expression (equal() to it, or its commuted form; see + * orderby_tlist_match()) with Var(INNER_VAR, i + 1). nodeIndexscan.c * then fills a virtual slot with the ORDER BY values of every returned row -- * the AM's for an exact row, the recomputed ones for a rechecked row -- and * installs it as the projection's inner tuple. A scan node has no inner * plan, so INNER_VAR is otherwise unused here. * - * Only float8/float4 expressions qualify: those are the only types - * index_store_float8_orderby_distances() can deliver. + * indexorderbyexact[i] comes from index_orderby_returnable(), which also + * limits this to float8/float4, the only types + * index_store_float8_orderby_distances() can deliver. The planner costs + * the scan with the same test (path_target_cost() in pathnode.c), so what + * is costed is what is built. */ static void replace_orderby_tlist_refs(IndexScan *splan) @@ -1411,9 +1415,7 @@ replace_orderby_tlist_refs(IndexScan *splan) Oid typ = exprType((Node *) orig); i++; - if (lfirst_int(le) && - (typ == FLOAT8OID || typ == FLOAT4OID) && - equal(tle->expr, orig)) + if (lfirst_int(le) && orderby_tlist_match(tle->expr, orig)) { tle->expr = (Expr *) makeVar(INNER_VAR, i, typ, exprTypmod((Node *) orig), diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c index 67c47fd7c6c48..0500234c8f65c 100644 --- a/src/backend/optimizer/util/pathnode.c +++ b/src/backend/optimizer/util/pathnode.c @@ -15,10 +15,12 @@ #include "postgres.h" #include "access/htup_details.h" +#include "catalog/pg_type.h" #include "executor/nodeSetOp.h" #include "foreign/fdwapi.h" #include "miscadmin.h" #include "nodes/extensible.h" +#include "nodes/nodeFuncs.h" #include "optimizer/appendinfo.h" #include "optimizer/clauses.h" #include "optimizer/cost.h" @@ -28,6 +30,7 @@ #include "optimizer/planmain.h" #include "optimizer/tlist.h" #include "parser/parsetree.h" +#include "utils/lsyscache.h" #include "utils/memutils.h" #include "utils/selfuncs.h" @@ -2577,6 +2580,157 @@ create_hashjoin_path(PlannerInfo *root, return pathnode; } +/* + * sort_pathlist_by_cost + * Restore the ordering add_path() and add_partial_path() maintain + * (fewest disabled nodes, then lowest total cost) after the costs of + * paths already in the list were changed in place. + * + * This is a stable insertion sort: it costs one pass when the list is still + * in order, which is the usual case, and keeps equal-cost paths in the order + * they were added. + */ +void +sort_pathlist_by_cost(List *pathlist) +{ + for (int i = 1; i < list_length(pathlist); i++) + { + Path *path = list_nth(pathlist, i); + int j; + + for (j = i; j > 0; j--) + { + Path *prev = list_nth(pathlist, j - 1); + + if (prev->disabled_nodes < path->disabled_nodes || + (prev->disabled_nodes == path->disabled_nodes && + prev->total_cost <= path->total_cost)) + break; + lfirst(list_nth_cell(pathlist, j)) = prev; + } + lfirst(list_nth_cell(pathlist, j)) = path; + } +} + +/* + * index_orderby_returnable + * Will an IndexScan on 'index' hand back the value of ORDER BY expression + * 'orderby', bound to index column 'indexcol', to its targetlist? + * + * create_indexscan_plan() records this per key for setrefs.c, and + * path_target_cost() uses it to cost the path, so that the cost we assign to + * a path is the cost of the plan we build from it. The opclass must promise + * exact values (amcanreturnorderby), and the expression must be float8 or + * float4: index_store_float8_orderby_distances() hands the executor float8 + * distances, rounding them for a float4 key, and can't deliver other types. + */ +bool +index_orderby_returnable(IndexOptInfo *index, int indexcol, Expr *orderby) +{ + Oid typ = exprType((Node *) orderby); + + if (typ != FLOAT8OID && typ != FLOAT4OID) + return false; + return (index->amcanreturnorderby != NULL && + index->amcanreturnorderby(index->opfamily[indexcol], + index->opcintype[indexcol], + castNode(OpExpr, orderby)->opno)); +} + +/* + * orderby_tlist_match + * Is targetlist expression 'expr' the value of ORDER BY expression + * 'orderby'? + * + * 'orderby' is an IndexPath's indexorderbys entry, which has the index key + * on the left; match_clause_to_ordering_op() commutes "const OP key" into + * that form. So a targetlist entry that the user wrote in the original + * "const OP key" form is the same value but not equal() to it. Accept it + * if it is the commuted operator applied to the swapped arguments; an + * operator's commutator yields the same result by definition. + * + * Shared by path_target_cost() and setrefs.c's replace_orderby_tlist_refs() + * so that what is costed is what is built. + */ +bool +orderby_tlist_match(Expr *expr, Expr *orderby) +{ + OpExpr *a, + *b; + + if (equal(expr, orderby)) + return true; + + if (!IsA(expr, OpExpr) || !IsA(orderby, OpExpr)) + return false; + a = (OpExpr *) expr; + b = (OpExpr *) orderby; + if (list_length(a->args) != 2 || list_length(b->args) != 2) + return false; + if (a->opresulttype != b->opresulttype || + a->opcollid != b->opcollid || + a->inputcollid != b->inputcollid || + get_commutator(a->opno) != b->opno) + return false; + + return equal(linitial(a->args), lsecond(b->args)) && + equal(lsecond(a->args), linitial(b->args)); +} + +/* + * path_target_cost + * Return the cost 'path' pays to evaluate 'target'. + * + * Normally that's just target->cost. But a plain IndexScan takes any + * top-level target expression that is a returnable ORDER BY expression + * (see orderby_tlist_match()) from its ORDER BY values instead of + * evaluating it (setrefs.c rewrites it into a reference to them), so it + * doesn't pay for those. Each target entry + * is counted at most once, as setrefs.c rewrites it once. + */ +static QualCost +path_target_cost(PlannerInfo *root, Path *path, PathTarget *target) +{ + QualCost cost = target->cost; + IndexPath *ipath; + ListCell *lc; + + if (!IsA(path, IndexPath) || path->pathtype != T_IndexScan) + return cost; + ipath = (IndexPath *) path; + if (ipath->indexorderbys == NIL) + return cost; + + foreach(lc, target->exprs) + { + Node *expr = (Node *) lfirst(lc); + ListCell *lo, + *lcol; + + if (IsA(expr, Var)) + continue; /* costs nothing anyway */ + + forboth(lo, ipath->indexorderbys, lcol, ipath->indexorderbycols) + { + Expr *orderby = (Expr *) lfirst(lo); + + if (index_orderby_returnable(ipath->indexinfo, lfirst_int(lcol), + orderby) && + orderby_tlist_match((Expr *) expr, orderby)) + { + QualCost ecost; + + cost_qual_eval_node(&ecost, expr, root); + cost.startup -= ecost.startup; + cost.per_tuple -= ecost.per_tuple; + break; + } + } + } + + return cost; +} + /* * create_projection_path * Creates a pathnode that represents performing a projection. @@ -2637,6 +2791,9 @@ create_projection_path(PlannerInfo *root, if (is_projection_capable_path(subpath) || equal(oldtarget->exprs, target->exprs)) { + QualCost oldcost = path_target_cost(root, subpath, oldtarget); + QualCost newcost = path_target_cost(root, subpath, target); + /* No separate Result node needed */ pathnode->dummypp = true; @@ -2646,10 +2803,10 @@ create_projection_path(PlannerInfo *root, pathnode->path.rows = subpath->rows; pathnode->path.disabled_nodes = subpath->disabled_nodes; pathnode->path.startup_cost = subpath->startup_cost + - (target->cost.startup - oldtarget->cost.startup); + (newcost.startup - oldcost.startup); pathnode->path.total_cost = subpath->total_cost + - (target->cost.startup - oldtarget->cost.startup) + - (target->cost.per_tuple - oldtarget->cost.per_tuple) * subpath->rows; + (newcost.startup - oldcost.startup) + + (newcost.per_tuple - oldcost.per_tuple) * subpath->rows; } else { @@ -2701,6 +2858,7 @@ apply_projection_to_path(PlannerInfo *root, PathTarget *target) { QualCost oldcost; + QualCost newcost; /* * If given path can't project, we might need a Result node, so make a @@ -2713,12 +2871,13 @@ apply_projection_to_path(PlannerInfo *root, * We can just jam the desired tlist into the existing path, being sure to * update its cost estimates appropriately. */ - oldcost = path->pathtarget->cost; + oldcost = path_target_cost(root, path, path->pathtarget); + newcost = path_target_cost(root, path, target); path->pathtarget = target; - path->startup_cost += target->cost.startup - oldcost.startup; - path->total_cost += target->cost.startup - oldcost.startup + - (target->cost.per_tuple - oldcost.per_tuple) * path->rows; + path->startup_cost += newcost.startup - oldcost.startup; + path->total_cost += newcost.startup - oldcost.startup + + (newcost.per_tuple - oldcost.per_tuple) * path->rows; /* * If the path happens to be a Gather or GatherMerge path, we'd like to diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h index da2d9b384b596..fad229234fdeb 100644 --- a/src/include/optimizer/pathnode.h +++ b/src/include/optimizer/pathnode.h @@ -225,6 +225,10 @@ extern HashPath *create_hashjoin_path(PlannerInfo *root, Relids required_outer, List *hashclauses); +extern void sort_pathlist_by_cost(List *pathlist); +extern bool index_orderby_returnable(IndexOptInfo *index, int indexcol, + Expr *orderby); +extern bool orderby_tlist_match(Expr *expr, Expr *orderby); extern ProjectionPath *create_projection_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath, diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out index 355bf8164233c..d29ce1d7af06f 100644 --- a/src/test/regress/expected/gist.out +++ b/src/test/regress/expected/gist.out @@ -655,6 +655,99 @@ select b <-> point(3, 4) as dist from spg_orderby reset enable_indexonlyscan; drop table spg_orderby; +-- The planner must not charge an ordering Index Scan for evaluating a +-- targetlist entry it takes from its ORDER BY values. With a costly ORDER BY +-- expression, the kNN scan should win over Seq Scan + Sort even when the +-- filter is selective, since the Sort plan really does evaluate it per row. +create function gist_costly_pt(point) returns point + language plpgsql immutable strict cost 10000 + as $$ begin return $1; end $$; +create temp table gist_costly (id int, p point, grp int); +insert into gist_costly + select g, point(g % 101, g % 103), g % 100 from generate_series(1, 10000) g; +create index on gist_costly using gist (gist_costly_pt(p)); +create index on gist_costly (grp); +vacuum analyze gist_costly; +set enable_bitmapscan = off; +-- (Seq Scan + Sort is a real choice here; the earlier tests turned it off.) +set enable_seqscan = on; +explain (costs off) +select id, gist_costly_pt(p) <-> point(0,0) as dist from gist_costly + where grp < 5 order by gist_costly_pt(p) <-> point(0,0); + QUERY PLAN +------------------------------------------------------------------ + Index Scan using gist_costly_gist_costly_pt_p_idx on gist_costly + Order By: (gist_costly_pt(p) <-> '(0,0)'::point) + Filter: (grp < 5) +(3 rows) + +set enable_seqscan = off; +-- The ORDER BY value is in every plan's target (as a sort column), so the +-- kNN scan used to be charged rows * cost(gist_costly_pt) for it, ~2.5e5 +-- here. It is now charged nothing for it; an expression that merely +-- contains it is still evaluated, and charged. +create function gist_costly_total(q text) returns float8 language plpgsql as +$$ declare j json; begin + execute 'explain (format json) ' || q into j; + return (j->0->'Plan'->>'Total Cost')::float8; + end $$; +set enable_sort = off; +select gist_costly_total('select id, gist_costly_pt(p) <-> point(0,0) + from gist_costly order by gist_costly_pt(p) <-> point(0,0)') < 10000 + as returned_is_free, + gist_costly_total('select id, (gist_costly_pt(p) <-> point(0,0)) * 2 + from gist_costly order by gist_costly_pt(p) <-> point(0,0)') > 100000 + as nested_is_charged; + returned_is_free | nested_is_charged +------------------+------------------- + t | t +(1 row) + +reset enable_sort; +drop function gist_costly_total(text); +reset enable_bitmapscan; +drop table gist_costly; +drop function gist_costly_pt(point); +-- The ORDER BY expression written as "const OP key" is commuted into +-- "key OP const" for the index; a targetlist entry in the original form is +-- still the same value, and is still taken from the scan. +create temp table gist_commute as + select g as id, point(g % 101, g % 103) as p from generate_series(1, 1000) g; +create index on gist_commute using gist (p); +vacuum analyze gist_commute; +set enable_seqscan = off; +set enable_bitmapscan = off; +set enable_indexonlyscan = off; +explain (verbose, costs off) +select point(5,5) <-> p as d from gist_commute order by point(5,5) <-> p limit 3; + QUERY PLAN +------------------------------------------------------------------- + Limit + Output: ((p <-> '(5,5)'::point)) + -> Index Scan using gist_commute_p_idx on pg_temp.gist_commute + Output: ((p <-> '(5,5)'::point)) + Order By: (gist_commute.p <-> '(5,5)'::point) + Order By Values Used: 1 +(6 rows) + +set extra_float_digits = 0; +select point(5,5) <-> p as d from gist_commute order by point(5,5) <-> p limit 3; + d +----------------- + 0 + 1.4142135623731 + 1.4142135623731 +(3 rows) + +reset extra_float_digits; +reset enable_seqscan; +reset enable_bitmapscan; +reset enable_indexonlyscan; +drop table gist_commute; +-- (The tests above reset these to their defaults; the rest of this file +-- wants them as set at the top.) +set enable_seqscan = off; +set enable_bitmapscan = off; -- Test that an index-only scan deforms the tuple it reconstructs with the -- descriptor the AM formed it with, not the scan slot's descriptor. create temp table gist_ios_tupdesc (a inet, r numrange); diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql index 9a5e331b50d12..3e59a4da4a665 100644 --- a/src/test/regress/sql/gist.sql +++ b/src/test/regress/sql/gist.sql @@ -319,6 +319,76 @@ select b <-> point(3, 4) as dist from spg_orderby reset enable_indexonlyscan; drop table spg_orderby; +-- The planner must not charge an ordering Index Scan for evaluating a +-- targetlist entry it takes from its ORDER BY values. With a costly ORDER BY +-- expression, the kNN scan should win over Seq Scan + Sort even when the +-- filter is selective, since the Sort plan really does evaluate it per row. +create function gist_costly_pt(point) returns point + language plpgsql immutable strict cost 10000 + as $$ begin return $1; end $$; +create temp table gist_costly (id int, p point, grp int); +insert into gist_costly + select g, point(g % 101, g % 103), g % 100 from generate_series(1, 10000) g; +create index on gist_costly using gist (gist_costly_pt(p)); +create index on gist_costly (grp); +vacuum analyze gist_costly; +set enable_bitmapscan = off; +-- (Seq Scan + Sort is a real choice here; the earlier tests turned it off.) +set enable_seqscan = on; + +explain (costs off) +select id, gist_costly_pt(p) <-> point(0,0) as dist from gist_costly + where grp < 5 order by gist_costly_pt(p) <-> point(0,0); +set enable_seqscan = off; + +-- The ORDER BY value is in every plan's target (as a sort column), so the +-- kNN scan used to be charged rows * cost(gist_costly_pt) for it, ~2.5e5 +-- here. It is now charged nothing for it; an expression that merely +-- contains it is still evaluated, and charged. +create function gist_costly_total(q text) returns float8 language plpgsql as +$$ declare j json; begin + execute 'explain (format json) ' || q into j; + return (j->0->'Plan'->>'Total Cost')::float8; + end $$; +set enable_sort = off; +select gist_costly_total('select id, gist_costly_pt(p) <-> point(0,0) + from gist_costly order by gist_costly_pt(p) <-> point(0,0)') < 10000 + as returned_is_free, + gist_costly_total('select id, (gist_costly_pt(p) <-> point(0,0)) * 2 + from gist_costly order by gist_costly_pt(p) <-> point(0,0)') > 100000 + as nested_is_charged; +reset enable_sort; +drop function gist_costly_total(text); + +reset enable_bitmapscan; +drop table gist_costly; +drop function gist_costly_pt(point); + +-- The ORDER BY expression written as "const OP key" is commuted into +-- "key OP const" for the index; a targetlist entry in the original form is +-- still the same value, and is still taken from the scan. +create temp table gist_commute as + select g as id, point(g % 101, g % 103) as p from generate_series(1, 1000) g; +create index on gist_commute using gist (p); +vacuum analyze gist_commute; +set enable_seqscan = off; +set enable_bitmapscan = off; +set enable_indexonlyscan = off; +explain (verbose, costs off) +select point(5,5) <-> p as d from gist_commute order by point(5,5) <-> p limit 3; +set extra_float_digits = 0; +select point(5,5) <-> p as d from gist_commute order by point(5,5) <-> p limit 3; +reset extra_float_digits; +reset enable_seqscan; +reset enable_bitmapscan; +reset enable_indexonlyscan; +drop table gist_commute; + +-- (The tests above reset these to their defaults; the rest of this file +-- wants them as set at the top.) +set enable_seqscan = off; +set enable_bitmapscan = off; + -- Test that an index-only scan deforms the tuple it reconstructs with the -- descriptor the AM formed it with, not the scan slot's descriptor. create temp table gist_ios_tupdesc (a inet, r numrange); From 9333ffd5a2d6a3a84daf5aea35e5e4047eca0918 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Wed, 7 Oct 2026 01:50:09 +0000 Subject: [PATCH 5/9] Let an ordering index scan below a join emit its ORDER BY values With the previous two commits, an ordering Index Scan returns its ORDER BY values to a target list entry that matches the ORDER BY expression, and isn't charged for it. But that only happens when the scan is the top of the scan/join tree. When the scan is below a join, its target is rel->reltarget, which holds only the Vars the rest of the query needs; the join computes the ORDER BY expression from those Vars, once per joined row, though the scan already had the value. Tom Lane pointed at this in the 2023 junk-column thread: a fix that only munges the final targetlist "leaves everything on the table as soon as there's more than one level of plan involved". Give such an IndexPath its own PathTarget: a copy of rel->reltarget with each returnable ORDER BY expression appended, but only if the query needs that expression above the scan (a top-level entry of the final targetlist, which includes sort columns). Most index paths keep sharing rel->reltarget as before. The appended expressions cost the scan nothing (cost_index() now uses path_target_cost()). Above the scan, setrefs.c's fix_join_expr() already matches whole non-Var expressions against the input targetlists before descending into them, so the join's copy becomes a reference to the scan's output with no setrefs change; and the join isn't charged for it either, since path_target_cost() leaves out of a join path's target the entries an input already emits (looking through Material and Memoize). use_physical_tlist() must not replace such a scan's targetlist with a physical one, which has only Vars; index_path_emits_orderby() tells it. Scope: plain IndexScan paths on plain base relations only. Not IndexOnlyScan (no ORDER BY value plumbing), not appendrel children (Append/MergeAppend build their own child targetlists, and the MergeAppend-over-kNN case already works through the scan/join target), and only ORDER BY expressions that pass index_orderby_returnable(). Because the join reads the value from the scan's output, an outer join nulls it correctly with the rest of that side's columns. Author: Greg Burd Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me Discussion: https://postgr.es/m/2935085.1703806977@sss.pgh.pa.us --- src/backend/optimizer/path/costsize.c | 12 +- src/backend/optimizer/plan/createplan.c | 9 ++ src/backend/optimizer/util/pathnode.c | 182 +++++++++++++++++++++++- src/include/optimizer/pathnode.h | 3 + src/test/regress/expected/gist.out | 93 ++++++++++++ src/test/regress/sql/gist.sql | 47 ++++++ 6 files changed, 340 insertions(+), 6 deletions(-) diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c index 7bbddb8bee4fa..a844701b1d11c 100644 --- a/src/backend/optimizer/path/costsize.c +++ b/src/backend/optimizer/path/costsize.c @@ -564,6 +564,7 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count, Cost min_IO_cost, max_IO_cost; QualCost qpqual_cost; + QualCost tlist_cost; Cost cpu_per_tuple; double tuples_fetched; double pages_fetched; @@ -799,9 +800,14 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count, cpu_run_cost += cpu_per_tuple * tuples_fetched; - /* tlist eval costs are paid per output row, not per tuple scanned */ - startup_cost += path->path.pathtarget->cost.startup; - cpu_run_cost += path->path.pathtarget->cost.per_tuple * path->path.rows; + /* + * tlist eval costs are paid per output row, not per tuple scanned. An + * ordering IndexScan doesn't pay for target entries it takes from its + * ORDER BY values. + */ + tlist_cost = path_target_cost(root, (Path *) path, path->path.pathtarget); + startup_cost += tlist_cost.startup; + cpu_run_cost += tlist_cost.per_tuple * path->path.rows; /* Adjust costing for parallelism, if used. */ if (path->path.parallel_workers > 0) diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c index 7f39ceecaed97..c74dacb221704 100644 --- a/src/backend/optimizer/plan/createplan.c +++ b/src/backend/optimizer/plan/createplan.c @@ -938,6 +938,15 @@ use_physical_tlist(PlannerInfo *root, Path *path, int flags) return false; } + /* + * Nor if a plain index scan path was asked to emit its ORDER BY values + * (see index_path_orderby_target()); a physical tlist has only Vars, so + * whatever needs them would compute them again. + */ + if (path->pathtype == T_IndexScan && + index_path_emits_orderby((IndexPath *) path)) + return false; + /* * For an index-only scan, the "physical tlist" is the index's indextlist. * We can only return that without a projection if all the index's columns diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c index 0500234c8f65c..7967bfe2ea7c4 100644 --- a/src/backend/optimizer/util/pathnode.c +++ b/src/backend/optimizer/util/pathnode.c @@ -1070,6 +1070,120 @@ create_samplescan_path(PlannerInfo *root, RelOptInfo *rel, Relids required_outer return pathnode; } +/* + * index_path_orderby_target + * Choose the PathTarget for an ordering plain IndexScan path. + * + * Normally an IndexPath emits rel->reltarget, which holds only the Vars (and + * PlaceHolderVars) the rest of the query needs. An ORDER BY expression is + * not in it: it is computed by whatever plan node first needs it. When the + * scan is the top of the scan/join tree that is the scan itself, and + * setrefs.c lets it take the value from its ORDER BY values. But when the + * scan is below a join, the join computes the expression from the Vars, + * once per joined row, though the scan already had the value. + * + * So if the scan can return any of its ORDER BY values (see + * index_orderby_returnable()), and the query needs that expression above + * the scan (it is in the final targetlist, which includes sort columns), + * give the path a copy of rel->reltarget with those expressions appended. + * They cost the scan nothing (path_target_cost() exempts them), and + * setrefs.c's fix_join_expr() matches them in the parent join's expressions, + * replacing its copy with a reference to the scan's output. + * + * Restricted to plain base relations: an appendrel child's paths are wrapped + * by Append/MergeAppend, which build their own targetlists, and the top-level + * scan/join target already handles the unjoined case. Returns + * rel->reltarget unchanged when there's nothing to add, which is the common + * case, so most paths share it as before. + */ +static PathTarget * +index_path_orderby_target(PlannerInfo *root, IndexOptInfo *index, + List *indexorderbys, List *indexorderbycols) +{ + RelOptInfo *rel = index->rel; + PathTarget *target = NULL; + ListCell *lo, + *lcol; + + if (indexorderbys == NIL || rel->reloptkind != RELOPT_BASEREL || + root->processed_tlist == NIL) + return rel->reltarget; + + forboth(lo, indexorderbys, lcol, indexorderbycols) + { + Expr *orderby = (Expr *) lfirst(lo); + bool needed = false; + ListCell *lt; + + if (!index_orderby_returnable(index, lfirst_int(lcol), orderby)) + continue; + + /* + * Only if some top-level targetlist entry is this expression; an + * expression that merely contains it would not be rewritten, and + * emitting the value would just widen the tuple. + */ + foreach(lt, root->processed_tlist) + { + TargetEntry *tle = lfirst_node(TargetEntry, lt); + + if (orderby_tlist_match(tle->expr, orderby)) + { + needed = true; + break; + } + } + if (!needed) + continue; + + if (target == NULL) + target = copy_pathtarget(rel->reltarget); + add_new_column_to_pathtarget(target, orderby); + } + + if (target == NULL) + return rel->reltarget; + + /* add_new_column_to_pathtarget doesn't maintain cost and width */ + return set_pathtarget_cost_width(root, target); +} + +/* + * index_path_emits_orderby + * Does this plain IndexScan path's target hold a value it takes from its + * ORDER BY values? + * + * use_physical_tlist() must not replace such a target with the scan's + * physical tlist, which has only Vars, or whatever needs the value would + * compute it again. + */ +bool +index_path_emits_orderby(IndexPath *ipath) +{ + ListCell *lc; + + if (ipath->path.pathtype != T_IndexScan || ipath->indexorderbys == NIL) + return false; + + foreach(lc, ipath->path.pathtarget->exprs) + { + Expr *expr = (Expr *) lfirst(lc); + ListCell *lo, + *lcol; + + if (IsA(expr, Var)) + continue; + forboth(lo, ipath->indexorderbys, lcol, ipath->indexorderbycols) + { + if (index_orderby_returnable(ipath->indexinfo, lfirst_int(lcol), + (Expr *) lfirst(lo)) && + orderby_tlist_match(expr, (Expr *) lfirst(lo))) + return true; + } + } + return false; +} + /* * create_index_path * Creates a path node for an index scan. @@ -1109,7 +1223,9 @@ create_index_path(PlannerInfo *root, pathnode->path.pathtype = indexonly ? T_IndexOnlyScan : T_IndexScan; pathnode->path.parent = rel; - pathnode->path.pathtarget = rel->reltarget; + pathnode->path.pathtarget = indexonly ? rel->reltarget : + index_path_orderby_target(root, index, indexorderbys, + indexorderbycols); pathnode->path.param_info = get_baserel_parampathinfo(root, rel, required_outer); pathnode->path.parallel_aware = false; @@ -2677,24 +2793,84 @@ orderby_tlist_match(Expr *expr, Expr *orderby) equal(lsecond(a->args), linitial(b->args)); } +/* + * join_target_cost + * path_target_cost() for a join path. + * + * A join takes any top-level target expression that one of its inputs + * already emits from that input's output instead of evaluating it again: + * setrefs.c's fix_join_expr() matches whole non-Var expressions against the + * input targetlists before descending into them. Join inputs ordinarily + * emit only Vars and PlaceHolderVars, so this changes nothing; but an + * ordering IndexScan input may emit the ORDER BY values it returns (see + * index_path_orderby_target()). Look through Material and Memoize, which + * pass their input's targetlist up unchanged. We look only at the join's + * immediate inputs; the value can't reach a higher join without passing + * through this one's targetlist, which is the joinrel's and has only Vars. + */ +static QualCost +join_target_cost(PlannerInfo *root, JoinPath *jpath, PathTarget *target) +{ + QualCost cost = target->cost; + Path *inputs[2] = {jpath->outerjoinpath, jpath->innerjoinpath}; + ListCell *lc; + + foreach(lc, target->exprs) + { + Node *expr = (Node *) lfirst(lc); + + if (IsA(expr, Var) || IsA(expr, PlaceHolderVar)) + continue; + + for (int i = 0; i < 2; i++) + { + Path *input = inputs[i]; + + while (IsA(input, MaterialPath) || IsA(input, MemoizePath)) + input = IsA(input, MaterialPath) ? + ((MaterialPath *) input)->subpath : + ((MemoizePath *) input)->subpath; + + if (IsA(input, IndexPath) && + index_path_emits_orderby((IndexPath *) input) && + list_member(input->pathtarget->exprs, expr)) + { + QualCost ecost; + + cost_qual_eval_node(&ecost, expr, root); + cost.startup -= ecost.startup; + cost.per_tuple -= ecost.per_tuple; + break; + } + } + } + + return cost; +} + /* * path_target_cost * Return the cost 'path' pays to evaluate 'target'. * - * Normally that's just target->cost. But a plain IndexScan takes any + * Normally that's just target->cost. A join path doesn't pay for entries + * an input already emits (see join_target_cost()). And a plain IndexScan + * takes any * top-level target expression that is a returnable ORDER BY expression * (see orderby_tlist_match()) from its ORDER BY values instead of * evaluating it (setrefs.c rewrites it into a reference to them), so it * doesn't pay for those. Each target entry * is counted at most once, as setrefs.c rewrites it once. */ -static QualCost +QualCost path_target_cost(PlannerInfo *root, Path *path, PathTarget *target) { QualCost cost = target->cost; IndexPath *ipath; ListCell *lc; + if (IsA(path, NestPath) || IsA(path, MergePath) || IsA(path, HashPath)) + return join_target_cost(root, (JoinPath *) path, target); + if (!IsA(path, IndexPath) || path->pathtype != T_IndexScan) return cost; ipath = (IndexPath *) path; diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h index fad229234fdeb..8fcfe60aba3b5 100644 --- a/src/include/optimizer/pathnode.h +++ b/src/include/optimizer/pathnode.h @@ -229,6 +229,9 @@ extern void sort_pathlist_by_cost(List *pathlist); extern bool index_orderby_returnable(IndexOptInfo *index, int indexcol, Expr *orderby); extern bool orderby_tlist_match(Expr *expr, Expr *orderby); +extern QualCost path_target_cost(PlannerInfo *root, Path *path, + PathTarget *target); +extern bool index_path_emits_orderby(IndexPath *ipath); extern ProjectionPath *create_projection_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath, diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out index d29ce1d7af06f..afc4d65fe42dd 100644 --- a/src/test/regress/expected/gist.out +++ b/src/test/regress/expected/gist.out @@ -744,6 +744,99 @@ reset enable_seqscan; reset enable_bitmapscan; reset enable_indexonlyscan; drop table gist_commute; +-- An ordering Index Scan below a join emits the ORDER BY value it returns, +-- so the join above reads it instead of evaluating the expression again. +-- The counting function shows the expression is evaluated zero times. +create sequence gist_join_cnt; +create function gist_join_pt(point) returns point + language plpgsql immutable strict cost 1000 + as $$ begin perform nextval('public.gist_join_cnt'); return $1; end $$; +create temp table gist_join_fact as + select g as id, point(g % 101, g % 103) as p from generate_series(1, 1000) g; +create index on gist_join_fact using gist (gist_join_pt(p)); +create temp table gist_join_dim as select g as id from generate_series(1, 1000, 2) g; +create index on gist_join_dim (id); +vacuum analyze gist_join_fact; +vacuum analyze gist_join_dim; +set enable_seqscan = off; +set enable_bitmapscan = off; +set enable_indexonlyscan = off; +set enable_hashjoin = off; +set enable_mergejoin = off; +set enable_sort = off; +explain (verbose, costs off) +select f.id, gist_join_pt(f.p) <-> point(5,5) as d + from gist_join_fact f join gist_join_dim j on j.id = f.id + order by gist_join_pt(f.p) <-> point(5,5) limit 3; + QUERY PLAN +-------------------------------------------------------------------------------------------- + Limit + Output: f.id, ((gist_join_pt(f.p) <-> '(5,5)'::point)) + -> Nested Loop + Output: f.id, ((gist_join_pt(f.p) <-> '(5,5)'::point)) + -> Index Scan using gist_join_fact_gist_join_pt_p_idx on pg_temp.gist_join_fact f + Output: f.id, f.p, ((gist_join_pt(f.p) <-> '(5,5)'::point)) + Order By: (gist_join_pt(f.p) <-> '(5,5)'::point) + Order By Values Used: 1 + -> Index Scan using gist_join_dim_id_idx on pg_temp.gist_join_dim j + Output: j.id + Index Cond: (j.id = f.id) +(11 rows) + +select setval('gist_join_cnt', 1, false); + setval +-------- + 1 +(1 row) + +set extra_float_digits = 0; +select f.id, gist_join_pt(f.p) <-> point(5,5) as d + from gist_join_fact f join gist_join_dim j on j.id = f.id + order by gist_join_pt(f.p) <-> point(5,5) limit 3; + id | d +-----+------------------ + 5 | 0 + 107 | 1.4142135623731 + 209 | 2.82842712474619 +(3 rows) + +reset extra_float_digits; +select nextval('gist_join_cnt') - 1 as calls_during_join; + calls_during_join +------------------- + 0 +(1 row) + +-- Nothing extra is emitted when the query doesn't need the value above the +-- scan. +explain (verbose, costs off) +select f.id from gist_join_fact f join gist_join_dim j on j.id = f.id + order by gist_join_pt(f.p) <-> point(5,5) limit 3; + QUERY PLAN +-------------------------------------------------------------------------------------------- + Limit + Output: f.id, ((gist_join_pt(f.p) <-> '(5,5)'::point)) + -> Nested Loop + Output: f.id, ((gist_join_pt(f.p) <-> '(5,5)'::point)) + -> Index Scan using gist_join_fact_gist_join_pt_p_idx on pg_temp.gist_join_fact f + Output: f.id, f.p, ((gist_join_pt(f.p) <-> '(5,5)'::point)) + Order By: (gist_join_pt(f.p) <-> '(5,5)'::point) + Order By Values Used: 1 + -> Index Scan using gist_join_dim_id_idx on pg_temp.gist_join_dim j + Output: j.id + Index Cond: (j.id = f.id) +(11 rows) + +reset enable_seqscan; +reset enable_bitmapscan; +reset enable_indexonlyscan; +reset enable_hashjoin; +reset enable_mergejoin; +reset enable_sort; +drop table gist_join_fact; +drop table gist_join_dim; +drop function gist_join_pt(point); +drop sequence gist_join_cnt; -- (The tests above reset these to their defaults; the rest of this file -- wants them as set at the top.) set enable_seqscan = off; diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql index 3e59a4da4a665..129bcaf984e51 100644 --- a/src/test/regress/sql/gist.sql +++ b/src/test/regress/sql/gist.sql @@ -384,6 +384,53 @@ reset enable_bitmapscan; reset enable_indexonlyscan; drop table gist_commute; +-- An ordering Index Scan below a join emits the ORDER BY value it returns, +-- so the join above reads it instead of evaluating the expression again. +-- The counting function shows the expression is evaluated zero times. +create sequence gist_join_cnt; +create function gist_join_pt(point) returns point + language plpgsql immutable strict cost 1000 + as $$ begin perform nextval('public.gist_join_cnt'); return $1; end $$; +create temp table gist_join_fact as + select g as id, point(g % 101, g % 103) as p from generate_series(1, 1000) g; +create index on gist_join_fact using gist (gist_join_pt(p)); +create temp table gist_join_dim as select g as id from generate_series(1, 1000, 2) g; +create index on gist_join_dim (id); +vacuum analyze gist_join_fact; +vacuum analyze gist_join_dim; +set enable_seqscan = off; +set enable_bitmapscan = off; +set enable_indexonlyscan = off; +set enable_hashjoin = off; +set enable_mergejoin = off; +set enable_sort = off; +explain (verbose, costs off) +select f.id, gist_join_pt(f.p) <-> point(5,5) as d + from gist_join_fact f join gist_join_dim j on j.id = f.id + order by gist_join_pt(f.p) <-> point(5,5) limit 3; +select setval('gist_join_cnt', 1, false); +set extra_float_digits = 0; +select f.id, gist_join_pt(f.p) <-> point(5,5) as d + from gist_join_fact f join gist_join_dim j on j.id = f.id + order by gist_join_pt(f.p) <-> point(5,5) limit 3; +reset extra_float_digits; +select nextval('gist_join_cnt') - 1 as calls_during_join; +-- Nothing extra is emitted when the query doesn't need the value above the +-- scan. +explain (verbose, costs off) +select f.id from gist_join_fact f join gist_join_dim j on j.id = f.id + order by gist_join_pt(f.p) <-> point(5,5) limit 3; +reset enable_seqscan; +reset enable_bitmapscan; +reset enable_indexonlyscan; +reset enable_hashjoin; +reset enable_mergejoin; +reset enable_sort; +drop table gist_join_fact; +drop table gist_join_dim; +drop function gist_join_pt(point); +drop sequence gist_join_cnt; + -- (The tests above reset these to their defaults; the rest of this file -- wants them as set at the top.) set enable_seqscan = off; From 706214f52d9f6dcb2fcb421976729d1d6e4c285a Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Wed, 7 Oct 2026 01:50:14 +0000 Subject: [PATCH 6/9] Don't charge an index-only scan for index expressions it returns An index-only scan on an index with an expression column reads that expression's value from the index: set_indexonlyscan_references() replaces a target list entry equal() to a returnable index expression with a reference to that column. But the planner still charges the scan the full cost of evaluating the expression for every row, as if it computed it. Heikki Linnakangas showed this in the 2023 junk-column thread with an index on (i, expensive(i)) and a function of COST 1000000: the index-only scan was estimated at 25 million, like the Seq Scan that really does evaluate expensive(i). path_target_cost() now also handles index-only scan paths: a top-level target expression equal() to a returnable index expression costs nothing. cost_index() already goes through path_target_cost(), so the index-only scan's estimate drops to what it actually does. This corrects the estimate of an index-only scan path that is already in the running, e.g. one kept for its pathkeys. It does not make such a path win the base relation's add_path() comparison when its only merit is the cheaper expression: at that point every path of the rel carries rel->reltarget, which holds only Vars, and the expression is charged to all of them alike afterwards. Doing that would mean giving index-only paths their own targets, as the previous commit does for ordering Index Scans; that's left for later. Author: Greg Burd Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me Discussion: https://postgr.es/m/356d5811-d8f0-4c29-a601-b61334337b11@iki.fi --- src/backend/optimizer/util/pathnode.c | 59 ++++++++++++++++++++++++++- src/test/regress/expected/gist.out | 33 +++++++++++++++ src/test/regress/sql/gist.sql | 23 +++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c index 7967bfe2ea7c4..681f81a90611c 100644 --- a/src/backend/optimizer/util/pathnode.c +++ b/src/backend/optimizer/util/pathnode.c @@ -2793,6 +2793,57 @@ orderby_tlist_match(Expr *expr, Expr *orderby) equal(lsecond(a->args), linitial(b->args)); } +/* + * indexonly_target_cost + * path_target_cost() for an index-only scan path. + * + * setrefs.c's set_indexonlyscan_references() matches target expressions + * against the index's returnable columns, expression columns included, and + * replaces a match with a reference to that column: the scan reads the + * value the index stored instead of computing it. So a top-level target + * expression equal() to a returnable index expression costs nothing. Only + * top-level entries are considered, as elsewhere; that's what's normally in + * a scan's target. + */ +static QualCost +indexonly_target_cost(PlannerInfo *root, IndexPath *ipath, PathTarget *target) +{ + QualCost cost = target->cost; + IndexOptInfo *index = ipath->indexinfo; + ListCell *lc; + + if (index->indexprs == NIL) + return cost; /* only Vars, which cost nothing anyway */ + + foreach(lc, target->exprs) + { + Node *expr = (Node *) lfirst(lc); + ListCell *lt; + int i = 0; + + if (IsA(expr, Var)) + continue; + + foreach(lt, index->indextlist) + { + TargetEntry *tle = lfirst_node(TargetEntry, lt); + + if (index->canreturn[i++] && !IsA(tle->expr, Var) && + equal(expr, tle->expr)) + { + QualCost ecost; + + cost_qual_eval_node(&ecost, expr, root); + cost.startup -= ecost.startup; + cost.per_tuple -= ecost.per_tuple; + break; + } + } + } + + return cost; +} + /* * join_target_cost * path_target_cost() for a join path. @@ -2853,8 +2904,9 @@ join_target_cost(PlannerInfo *root, JoinPath *jpath, PathTarget *target) * Return the cost 'path' pays to evaluate 'target'. * * Normally that's just target->cost. A join path doesn't pay for entries - * an input already emits (see join_target_cost()). And a plain IndexScan - * takes any + * an input already emits (see join_target_cost()), nor an index-only scan + * for entries it reads from the index (see indexonly_target_cost()). And a + * plain IndexScan takes any * top-level target expression that is a returnable ORDER BY expression * (see orderby_tlist_match()) from its ORDER BY values instead of * evaluating it (setrefs.c rewrites it into a reference to them), so it @@ -2871,6 +2923,9 @@ path_target_cost(PlannerInfo *root, Path *path, PathTarget *target) if (IsA(path, NestPath) || IsA(path, MergePath) || IsA(path, HashPath)) return join_target_cost(root, (JoinPath *) path, target); + if (IsA(path, IndexPath) && path->pathtype == T_IndexOnlyScan) + return indexonly_target_cost(root, (IndexPath *) path, target); + if (!IsA(path, IndexPath) || path->pathtype != T_IndexScan) return cost; ipath = (IndexPath *) path; diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out index afc4d65fe42dd..770a774b8bd40 100644 --- a/src/test/regress/expected/gist.out +++ b/src/test/regress/expected/gist.out @@ -837,6 +837,39 @@ drop table gist_join_fact; drop table gist_join_dim; drop function gist_join_pt(point); drop sequence gist_join_cnt; +-- An index-only scan isn't charged for evaluating a target expression it +-- reads from a returnable index expression column: it reads the stored +-- value instead. (Not GiST-specific; btree returns expression columns.) +create function gist_ios_costly(int) returns int + language plpgsql immutable strict cost 100000 + as $$ begin return $1; end $$; +create temp table gist_ios_cost (i int); +insert into gist_ios_cost select g from generate_series(1, 10000) g; +create index on gist_ios_cost (i, gist_ios_costly(i)); +vacuum analyze gist_ios_cost; +create function gist_ios_total(q text) returns float8 language plpgsql as +$$ declare j json; begin + execute 'explain (format json) ' || q into j; + return (j->0->'Plan'->>'Total Cost')::float8; + end $$; +explain (verbose, costs off) +select gist_ios_costly(i) from gist_ios_cost order by i; + QUERY PLAN +-------------------------------------------------------------------------------------- + Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost + Output: (gist_ios_costly(i)), i +(2 rows) + +select gist_ios_total('select gist_ios_costly(i) from gist_ios_cost order by i') + < 10000 as ios_expr_is_free; + ios_expr_is_free +------------------ + t +(1 row) + +drop function gist_ios_total(text); +drop table gist_ios_cost; +drop function gist_ios_costly(int); -- (The tests above reset these to their defaults; the rest of this file -- wants them as set at the top.) set enable_seqscan = off; diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql index 129bcaf984e51..68413b52d8176 100644 --- a/src/test/regress/sql/gist.sql +++ b/src/test/regress/sql/gist.sql @@ -431,6 +431,29 @@ drop table gist_join_dim; drop function gist_join_pt(point); drop sequence gist_join_cnt; +-- An index-only scan isn't charged for evaluating a target expression it +-- reads from a returnable index expression column: it reads the stored +-- value instead. (Not GiST-specific; btree returns expression columns.) +create function gist_ios_costly(int) returns int + language plpgsql immutable strict cost 100000 + as $$ begin return $1; end $$; +create temp table gist_ios_cost (i int); +insert into gist_ios_cost select g from generate_series(1, 10000) g; +create index on gist_ios_cost (i, gist_ios_costly(i)); +vacuum analyze gist_ios_cost; +create function gist_ios_total(q text) returns float8 language plpgsql as +$$ declare j json; begin + execute 'explain (format json) ' || q into j; + return (j->0->'Plan'->>'Total Cost')::float8; + end $$; +explain (verbose, costs off) +select gist_ios_costly(i) from gist_ios_cost order by i; +select gist_ios_total('select gist_ios_costly(i) from gist_ios_cost order by i') + < 10000 as ios_expr_is_free; +drop function gist_ios_total(text); +drop table gist_ios_cost; +drop function gist_ios_costly(int); + -- (The tests above reset these to their defaults; the rest of this file -- wants them as set at the top.) set enable_seqscan = off; From abb9280ec4d9255056fdea09a8e4357a6d57587d Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Wed, 7 Oct 2026 14:34:42 -0400 Subject: [PATCH 7/9] Let an index-only scan win on the index expressions it returns The previous commit stopped charging an index-only scan for target expressions it reads from the index, but that only changes the plan when the index-only scan path is already in the running for some other reason, such as useful pathkeys. Heikki Linnakangas's example, an index on (i, expensive(i)) and SELECT expensive(i) FROM atab ORDER BY expensive(i); still got a Seq Scan and evaluated expensive() for every row. When the rel's paths are compared, they all carry rel->reltarget, which holds only Vars. The index-only scan costs more than the seq scan, and the expression it would have read for free is charged to the winner later. Give an index-only scan path its own PathTarget: rel->reltarget plus each returnable index expression that is a top-level entry of the query's targetlist. This is the same thing the earlier commit does for an ordering Index Scan's ORDER BY values. Teach add_path() and add_partial_path() to treat base-rel index paths with different targets as incomparable, the way they treat different pathkeys, so neither dominates the other on cost alone. When the scan/join target is applied, the seq scan pays for evaluating the expression and the index-only scan doesn't, so the index-only scan wins on cost when it should. A path with its own target also needs the projection even when the scan/join target's exprs equal the reltarget's, since its target has extra columns. Heikki's query now plans as Sort over an Index Only Scan: estimated 930 instead of 25000809. With a function that really costs something, it ran about 20 times faster. This is limited to the query's sole base relation, where the value goes straight to the scan/join target. Below a join, the join would have to credit the input for the value and the joinrel's add_path() would have to keep it; that isn't attempted here. Author: Greg Burd Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me Discussion: https://postgr.es/m/356d5811-d8f0-4c29-a601-b61334337b11@iki.fi --- src/backend/optimizer/plan/planner.c | 28 ++++++--- src/backend/optimizer/util/pathnode.c | 91 ++++++++++++++++++++++++++- src/test/regress/expected/gist.out | 46 ++++++++++++++ src/test/regress/sql/gist.sql | 14 +++++ 4 files changed, 169 insertions(+), 10 deletions(-) diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index 3b70b8a378feb..179541f24547f 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -8190,6 +8190,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, bool tlist_same_exprs) { bool rel_is_partitioned = IS_PARTITIONED_REL(rel); + bool projected = false; PathTarget *scanjoin_target; ListCell *lc; @@ -8259,10 +8260,18 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, * If the tlist exprs are the same, we can just inject the sortgroupref * information into the existing pathtargets. Otherwise, replace each * path with a projection path that generates the SRF-free scan/join - * target. We modify the lists in place. That usually adds the same cost - * to every path, but a plain IndexScan doesn't pay for target entries it - * takes from its ORDER BY values, so it can become cheaper relative to - * the others; restore the cost ordering afterwards. + * target. We modify the lists in place. + * + * An index path may emit its own target, rel->reltarget plus a value it + * reads from the index (see indexonly_path_target() and + * index_path_orderby_target()). Such a path needs a projection even when + * the exprs are the same, since its target has more columns than + * scanjoin_target's sortgrouprefs describe. + * + * A projection usually adds the same cost to every path, but not always: + * an index path isn't charged for target entries it reads from the index, + * and a path that can't project gets a Result node. So when any path was + * replaced, restore the cost ordering afterwards. */ foreach(lc, rel->pathlist) { @@ -8271,7 +8280,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, /* Shouldn't have any parameterized paths anymore */ Assert(subpath->param_info == NULL); - if (tlist_same_exprs) + if (tlist_same_exprs && subpath->pathtarget == rel->reltarget) subpath->pathtarget->sortgrouprefs = scanjoin_target->sortgrouprefs; else @@ -8281,10 +8290,12 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, newpath = (Path *) create_projection_path(root, rel, subpath, scanjoin_target); lfirst(lc) = newpath; + projected = true; } } - if (!tlist_same_exprs) + if (projected) sort_pathlist_by_cost(rel->pathlist); + projected = false; /* Likewise adjust the targets for any partial paths. */ foreach(lc, rel->partial_pathlist) @@ -8294,7 +8305,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, /* Shouldn't have any parameterized paths anymore */ Assert(subpath->param_info == NULL); - if (tlist_same_exprs) + if (tlist_same_exprs && subpath->pathtarget == rel->reltarget) subpath->pathtarget->sortgrouprefs = scanjoin_target->sortgrouprefs; else @@ -8304,9 +8315,10 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, newpath = (Path *) create_projection_path(root, rel, subpath, scanjoin_target); lfirst(lc) = newpath; + projected = true; } } - if (!tlist_same_exprs) + if (projected) sort_pathlist_by_cost(rel->partial_pathlist); /* diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c index 681f81a90611c..bba27aaf28a3d 100644 --- a/src/backend/optimizer/util/pathnode.c +++ b/src/backend/optimizer/util/pathnode.c @@ -386,6 +386,27 @@ set_cheapest(RelOptInfo *parent_rel) parent_rel->cheapest_parameterized_paths = parameterized_paths; } +/* + * pathtargets_differ + * Do two paths of base relation 'rel' emit different targets? + * + * A base relation's paths normally all emit rel->reltarget, but an index + * path may also emit a value the query needs above the scan, one it has + * for free and other paths must compute (see indexonly_path_target() and + * index_path_orderby_target()). That cost isn't in either path's cost + * yet; it is charged when the expression is. So add_path() treats paths + * with different targets like paths with different pathkeys, and keeps + * both. + */ +static inline bool +pathtargets_differ(RelOptInfo *rel, Path *path1, Path *path2) +{ + return rel->reloptkind == RELOPT_BASEREL && + (IsA(path1, IndexPath) || IsA(path2, IndexPath)) && + path1->pathtarget != path2->pathtarget && + !equal(path1->pathtarget->exprs, path2->pathtarget->exprs); +} + /* * add_path * Consider a potential implementation path for the specified parent rel, @@ -513,6 +534,8 @@ add_path(RelOptInfo *parent_rel, Path *new_path) old_path_pathkeys = old_path->param_info ? NIL : old_path->pathkeys; keyscmp = compare_pathkeys(new_path_pathkeys, old_path_pathkeys); + if (pathtargets_differ(parent_rel, new_path, old_path)) + keyscmp = PATHKEYS_DIFFERENT; if (keyscmp != PATHKEYS_DIFFERENT) { switch (costcmp) @@ -818,8 +841,10 @@ add_partial_path(RelOptInfo *parent_rel, Path *new_path) bool remove_old = false; /* unless new proves superior */ PathKeysComparison keyscmp; - /* Compare pathkeys. */ + /* Compare pathkeys, and targets as in add_path(). */ keyscmp = compare_pathkeys(new_path->pathkeys, old_path->pathkeys); + if (pathtargets_differ(parent_rel, new_path, old_path)) + keyscmp = PATHKEYS_DIFFERENT; /* * Unless pathkeys are incompatible, see if one of the paths dominates @@ -1148,6 +1173,67 @@ index_path_orderby_target(PlannerInfo *root, IndexOptInfo *index, return set_pathtarget_cost_width(root, target); } +/* + * indexonly_path_target + * Choose the PathTarget for an index-only scan path. + * + * An index-only scan reads a returnable index expression column instead of + * computing it (set_indexonlyscan_references()), and isn't charged for it + * (indexonly_target_cost()). But that only helps if the path survives + * add_path(), and with rel->reltarget, which has only Vars, nothing + * distinguishes it from a seq scan that will have to compute the expression + * later; it loses on cost before the expression is charged to anyone. So + * if the query's targetlist has a top-level entry equal() to a returnable + * index expression, give the path a copy of rel->reltarget with that + * expression appended. add_path() keeps paths with different targets + * (see pathtargets_differ()), and once the scan/join target is applied, the + * paths that must compute the expression pay for it and this one doesn't. + * + * This is done only when the rel is the query's sole base relation, where + * the value goes straight to the scan/join target. Below a join, the join + * would have to credit it (as join_target_cost() does for ORDER BY values) + * and the joinrel's add_path() would have to keep it; not attempted yet. + */ +static PathTarget * +indexonly_path_target(PlannerInfo *root, IndexOptInfo *index) +{ + RelOptInfo *rel = index->rel; + PathTarget *target = NULL; + ListCell *lt; + int i = 0; + + if (index->indexprs == NIL || rel->reloptkind != RELOPT_BASEREL || + root->processed_tlist == NIL || + !bms_equal(rel->relids, root->all_query_rels)) + return rel->reltarget; + + foreach(lt, index->indextlist) + { + TargetEntry *itle = lfirst_node(TargetEntry, lt); + ListCell *lq; + + if (!index->canreturn[i++] || IsA(itle->expr, Var)) + continue; + + foreach(lq, root->processed_tlist) + { + if (equal(lfirst_node(TargetEntry, lq)->expr, itle->expr)) + { + if (target == NULL) + target = copy_pathtarget(rel->reltarget); + add_column_to_pathtarget(target, itle->expr, 0); + break; + } + } + } + + if (target == NULL) + return rel->reltarget; + + /* add_column_to_pathtarget doesn't maintain cost and width */ + return set_pathtarget_cost_width(root, target); +} + /* * index_path_emits_orderby * Does this plain IndexScan path's target hold a value it takes from its @@ -1223,7 +1309,8 @@ create_index_path(PlannerInfo *root, pathnode->path.pathtype = indexonly ? T_IndexOnlyScan : T_IndexScan; pathnode->path.parent = rel; - pathnode->path.pathtarget = indexonly ? rel->reltarget : + pathnode->path.pathtarget = indexonly ? + indexonly_path_target(root, index) : index_path_orderby_target(root, index, indexorderbys, indexorderbycols); pathnode->path.param_info = get_baserel_parampathinfo(root, rel, diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out index 770a774b8bd40..380624174bd25 100644 --- a/src/test/regress/expected/gist.out +++ b/src/test/regress/expected/gist.out @@ -867,6 +867,52 @@ select gist_ios_total('select gist_ios_costly(i) from gist_ios_cost order by i') t (1 row) +-- With nothing else to recommend it (no qual, no useful order), an +-- index-only scan that reads the expression still beats a seq scan that has +-- to compute it: add_path() keeps both, since their targets differ. +explain (verbose, costs off) +select gist_ios_costly(i) from gist_ios_cost order by gist_ios_costly(i); + QUERY PLAN +-------------------------------------------------------------------------------------------- + Sort + Output: (gist_ios_costly(i)) + Sort Key: (gist_ios_costly(gist_ios_cost.i)) + -> Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost + Output: (gist_ios_costly(i)) +(5 rows) + +explain (verbose, costs off) +select gist_ios_costly(i) from gist_ios_cost; + QUERY PLAN +-------------------------------------------------------------------------------------- + Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost + Output: (gist_ios_costly(i)) +(2 rows) + +-- Ordered by the index, the scan emits the final targetlist directly and +-- still reads the expression from the index. +explain (verbose, costs off) +select gist_ios_costly(i) from gist_ios_cost order by i desc limit 3; + QUERY PLAN +----------------------------------------------------------------------------------------------------- + Limit + Output: (gist_ios_costly(i)), i + -> Index Only Scan Backward using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost + Output: (gist_ios_costly(i)), i +(4 rows) + +-- Below a join it isn't considered. +explain (costs off) +select gist_ios_costly(a.i) from gist_ios_cost a join gist_ios_cost b using (i); + QUERY PLAN +----------------------------------------- + Hash Join + Hash Cond: (a.i = b.i) + -> Seq Scan on gist_ios_cost a + -> Hash + -> Seq Scan on gist_ios_cost b +(5 rows) + drop function gist_ios_total(text); drop table gist_ios_cost; drop function gist_ios_costly(int); diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql index 68413b52d8176..df8ec25b14ac4 100644 --- a/src/test/regress/sql/gist.sql +++ b/src/test/regress/sql/gist.sql @@ -450,6 +450,20 @@ explain (verbose, costs off) select gist_ios_costly(i) from gist_ios_cost order by i; select gist_ios_total('select gist_ios_costly(i) from gist_ios_cost order by i') < 10000 as ios_expr_is_free; +-- With nothing else to recommend it (no qual, no useful order), an +-- index-only scan that reads the expression still beats a seq scan that has +-- to compute it: add_path() keeps both, since their targets differ. +explain (verbose, costs off) +select gist_ios_costly(i) from gist_ios_cost order by gist_ios_costly(i); +explain (verbose, costs off) +select gist_ios_costly(i) from gist_ios_cost; +-- Ordered by the index, the scan emits the final targetlist directly and +-- still reads the expression from the index. +explain (verbose, costs off) +select gist_ios_costly(i) from gist_ios_cost order by i desc limit 3; +-- Below a join it isn't considered. +explain (costs off) +select gist_ios_costly(a.i) from gist_ios_cost a join gist_ios_cost b using (i); drop function gist_ios_total(text); drop table gist_ios_cost; drop function gist_ios_costly(int); From de0189b930b3529778c5f995d9e10d26d2ea493c Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Thu, 8 Oct 2026 07:55:02 -0400 Subject: [PATCH 8/9] Carry index-computed values up through joins and Appends The previous commit lets an index-only scan beat a seq scan when it reads an expression the query's targetlist needs, but only when the scan's rel is the only one in the query and the targetlist entry is that exact expression. Below a join the scan isn't chosen, and the expression is computed above it as before. Nor is it chosen when the expression is part of a larger one, such as expensive(i) + 1, or when the table is partitioned. Generalize the extra values an index path may emit, which 0003 added for an ordering IndexScan's ORDER BY values and the previous commit for an index-only scan's expression columns: * A join emits what its inputs emit, so the value reaches the scan/join target from any depth. fix_join_expr() already matches whole expressions in an input's targetlist, and path_target_cost() now credits them wherever they appear in an expression, as setrefs.c replaces them. Material and Memoize share their input's target. * An index-only scan emits a returnable index expression wherever it appears in the targetlist, including ORDER BY columns. Its qpquals aren't charged for one either, since set_indexonlyscan_references() rewrites them too. * For a partitioned table whose partitions all have the expression index, an unordered Append over child paths that all emit the value emits it too. create_append_child_plan() puts each child's values in the Append's order, translating them to the child, and drops any extra values a child emits for an Append that doesn't. * Hash and merge joins look only at each input's cheapest path, which rarely emits the value. Also try each input's cheapest path that does, and skip add_path_precheck() for paths over such an input, as the precheck can't see the advantage. add_path() and add_partial_path() compare the extra values like pathkeys: a path that emits a superset of another's is better, so it can dominate it, but not the other way round. The previous commit's rule kept both whenever the targets differed, which kept, for example, a seq scan that a cheaper, better-ordered kNN path dominates. The cost is planning time: every join path over an input with an extra value is kept alongside the one it would otherwise have replaced. So a value is emitted only if it costs more than 10 times cpu_operator_cost per evaluation, the same test make_sort_input_target() uses to decide an expression is worth postponing past a sort. That applies to an ordering IndexScan's ORDER BY values too, so a plain point distance is computed again above a join. Queries without such an expression plan as before. A value from the nullable side of an outer join is never used above it: the query's Vars carry varnullingrels the index's don't, so they don't match. Partial paths emit only parallel-safe values. An ordered or parallel Append still computes the expression above it. Author: Greg Burd Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me Discussion: https://postgr.es/m/356d5811-d8f0-4c29-a601-b61334337b11@iki.fi --- src/backend/optimizer/path/allpaths.c | 104 ++++ src/backend/optimizer/path/costsize.c | 47 +- src/backend/optimizer/path/joinpath.c | 129 ++++- src/backend/optimizer/plan/createplan.c | 84 ++- src/backend/optimizer/plan/planner.c | 11 +- src/backend/optimizer/util/clauses.c | 20 + src/backend/optimizer/util/pathnode.c | 679 +++++++++++++++++------- src/include/nodes/pathnodes.h | 5 + src/include/optimizer/clauses.h | 1 + src/include/optimizer/pathnode.h | 7 +- src/test/regress/expected/gist.out | 225 +++++++- src/test/regress/sql/gist.sql | 110 +++- src/tools/pgindent/typedefs.list | 1 + 13 files changed, 1194 insertions(+), 229 deletions(-) diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c index 295e3509c740b..a6b1b15208bb6 100644 --- a/src/backend/optimizer/path/allpaths.c +++ b/src/backend/optimizer/path/allpaths.c @@ -103,6 +103,8 @@ static void set_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, static void set_plain_rel_size(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *rte); static void create_plain_partial_paths(PlannerInfo *root, RelOptInfo *rel); +static void add_extras_append_path(PlannerInfo *root, RelOptInfo *rel, + List *live_childrels); static void set_rel_consider_parallel(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *rte); static void set_plain_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, @@ -1404,6 +1406,100 @@ set_grouped_rel_pathlist(PlannerInfo *root, RelOptInfo *rel) } +/* + * add_extras_append_path + * Build an unordered, unparameterized Append over child paths that all + * emit the same extra values, if there are any worth emitting. + * + * A partitioned table's index on an expression is an index on that + * expression in every partition, so each child may have an index-only scan + * that reads it (see indexonly_path_target()). The Append can pass such a + * value up, as a join does, but only if every child emits it: they must all + * return the Append's targetlist. The candidates are the parent's + * partitioned indexes' expressions that the query's targetlist uses; for + * each, translated to the child, we take the child's cheapest path that + * emits it. create_append_child_plan() puts each child's values in the + * Append's order. + * + * Only an unordered, non-partial Append is built this way; an ordered or + * parallel Append still computes the expression above it. + */ +static void +add_extras_append_path(PlannerInfo *root, RelOptInfo *rel, + List *live_childrels) +{ + List *extras = NIL; + List *tlist_exprs; + AppendPathInput input = {0}; + PathTarget *target; + AppendPath *apath; + ListCell *lc; + + if (rel->reloptkind != RELOPT_BASEREL || live_childrels == NIL || + root->processed_tlist == NIL) + return; + + tlist_exprs = query_tlist_exprs(root, rel); + foreach(lc, rel->indexlist) + { + IndexOptInfo *index = (IndexOptInfo *) lfirst(lc); + ListCell *le; + + foreach(le, index->indexprs) + { + Node *expr = (Node *) lfirst(le); + ListCell *lt; + + if (!expr_worth_emitting(root, expr)) + continue; + foreach(lt, tlist_exprs) + { + if (expr_contains(lfirst(lt), expr)) + { + extras = list_append_unique(extras, expr); + break; + } + } + } + } + if (extras == NIL) + return; + + foreach(lc, live_childrels) + { + RelOptInfo *childrel = (RelOptInfo *) lfirst(lc); + List *child_extras; + Path *best = NULL; + ListCell *lp; + + child_extras = (List *) + adjust_appendrel_attrs_multilevel(root, (Node *) extras, + childrel, childrel->top_parent); + foreach(lp, childrel->pathlist) + { + Path *path = (Path *) lfirst(lp); + + if (path->param_info == NULL && + list_difference(child_extras, path_extra_exprs(path)) == NIL) + { + best = path; /* pathlist is sorted by total cost */ + break; + } + } + if (best == NULL) + return; + accumulate_append_subpath(best, &input.subpaths, NULL, + &input.child_append_relid_sets); + } + + apath = create_append_path(root, rel, input, NIL, NULL, 0, false, -1); + target = copy_pathtarget(rel->reltarget); + foreach(lc, extras) + add_new_column_to_pathtarget(target, (Expr *) lfirst(lc)); + apath->path.pathtarget = set_pathtarget_cost_width(root, target); + add_path(rel, (Path *) apath); +} + /* * add_paths_to_append_rel * Generate paths for the given append relation given the set of non-dummy @@ -1628,6 +1724,14 @@ add_paths_to_append_rel(PlannerInfo *root, RelOptInfo *rel, NIL, NULL, 0, false, -1)); + /* + * Also consider an Append that emits extra values (see + * path_extra_exprs()) its children read from their indexes, so a join + * above it doesn't compute them. + */ + if (unparameterized_valid) + add_extras_append_path(root, rel, live_childrels); + /* build an AppendPath for the cheap startup paths, if valid */ if (startup_valid) add_path(rel, (Path *) create_append_path(root, rel, startup, diff --git a/src/backend/optimizer/path/costsize.c b/src/backend/optimizer/path/costsize.c index a844701b1d11c..4bd912d0811d6 100644 --- a/src/backend/optimizer/path/costsize.c +++ b/src/backend/optimizer/path/costsize.c @@ -795,6 +795,13 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count, */ cost_qual_eval(&qpqual_cost, qpquals, root); + /* + * An index-only scan reads returnable index expressions in its quals from + * the index, as it does in its targetlist; see path_target_cost(). + */ + if (indexonly) + qpqual_cost = indexonly_qual_cost(root, path, qpquals, qpqual_cost); + startup_cost += qpqual_cost.startup; cpu_per_tuple = cpu_tuple_cost + qpqual_cost.per_tuple; @@ -802,8 +809,8 @@ cost_index(IndexPath *path, PlannerInfo *root, double loop_count, /* * tlist eval costs are paid per output row, not per tuple scanned. An - * ordering IndexScan doesn't pay for target entries it takes from its - * ORDER BY values. + * index path doesn't pay for target entries it reads from the index; see + * path_target_cost(). */ tlist_cost = path_target_cost(root, (Path *) path, path->path.pathtarget); startup_cost += tlist_cost.startup; @@ -3652,9 +3659,15 @@ final_cost_nestloop(PlannerInfo *root, NestPath *path, cpu_per_tuple = cpu_tuple_cost + restrict_qual_cost.per_tuple; run_cost += cpu_per_tuple * ntuples; - /* tlist eval costs are paid per output row, not per tuple scanned */ - startup_cost += path->jpath.path.pathtarget->cost.startup; - run_cost += path->jpath.path.pathtarget->cost.per_tuple * path->jpath.path.rows; + /* + * tlist eval costs are paid per output row, not per tuple scanned. The + * join may also emit extra values its inputs emit (see + * join_path_target()), but it doesn't evaluate those, so charge only for + * the joinrel's reltarget. + */ + startup_cost += path->jpath.path.parent->reltarget->cost.startup; + run_cost += path->jpath.path.parent->reltarget->cost.per_tuple * + path->jpath.path.rows; path->jpath.path.startup_cost = startup_cost; path->jpath.path.total_cost = startup_cost + run_cost; @@ -4242,9 +4255,15 @@ final_cost_mergejoin(PlannerInfo *root, MergePath *path, cpu_per_tuple = cpu_tuple_cost + qp_qual_cost.per_tuple; run_cost += cpu_per_tuple * mergejointuples; - /* tlist eval costs are paid per output row, not per tuple scanned */ - startup_cost += path->jpath.path.pathtarget->cost.startup; - run_cost += path->jpath.path.pathtarget->cost.per_tuple * path->jpath.path.rows; + /* + * tlist eval costs are paid per output row, not per tuple scanned. The + * join may also emit extra values its inputs emit (see + * join_path_target()), but it doesn't evaluate those, so charge only for + * the joinrel's reltarget. + */ + startup_cost += path->jpath.path.parent->reltarget->cost.startup; + run_cost += path->jpath.path.parent->reltarget->cost.per_tuple * + path->jpath.path.rows; path->jpath.path.startup_cost = startup_cost; path->jpath.path.total_cost = startup_cost + run_cost; @@ -4761,9 +4780,15 @@ final_cost_hashjoin(PlannerInfo *root, HashPath *path, run_cost += cpu_per_tuple * hashjointuples; } - /* tlist eval costs are paid per output row, not per tuple scanned */ - startup_cost += path->jpath.path.pathtarget->cost.startup; - run_cost += path->jpath.path.pathtarget->cost.per_tuple * path->jpath.path.rows; + /* + * tlist eval costs are paid per output row, not per tuple scanned. The + * join may also emit extra values its inputs emit (see + * join_path_target()), but it doesn't evaluate those, so charge only for + * the joinrel's reltarget. + */ + startup_cost += path->jpath.path.parent->reltarget->cost.startup; + run_cost += path->jpath.path.parent->reltarget->cost.per_tuple * + path->jpath.path.rows; path->jpath.path.startup_cost = startup_cost; path->jpath.path.total_cost = startup_cost + run_cost; diff --git a/src/backend/optimizer/path/joinpath.c b/src/backend/optimizer/path/joinpath.c index dfd08e7aeb1b8..8ee38d0eb9afc 100644 --- a/src/backend/optimizer/path/joinpath.c +++ b/src/backend/optimizer/path/joinpath.c @@ -45,6 +45,7 @@ join_path_setup_hook_type join_path_setup_hook = NULL; #define PATH_PARAM_BY_REL(path, rel) \ (PATH_PARAM_BY_REL_SELF(path, rel) || PATH_PARAM_BY_PARENT(path, rel)) +static Path *cheapest_extras_path(RelOptInfo *rel); static void try_partial_mergejoin_path(PlannerInfo *root, RelOptInfo *joinrel, Path *outer_path, @@ -149,6 +150,8 @@ add_paths_to_joinrel(PlannerInfo *root, extra.restrictlist = restrictlist; extra.mergeclause_list = NIL; extra.sjinfo = sjinfo; + extra.extras_outer = cheapest_extras_path(outerrel); + extra.extras_inner = cheapest_extras_path(innerrel); extra.param_source_rels = NULL; extra.pgs_mask = joinrel->pgs_mask; @@ -872,6 +875,51 @@ get_memoize_path(PlannerInfo *root, RelOptInfo *innerrel, return NULL; } +/* + * inputs_emit_extras + * Does either input path emit values beyond its reltarget? + * + * A join over such an input emits them too (see join_path_target()), which + * add_path() counts in its favor like better pathkeys. add_path_precheck() + * can't know that before the path exists, so skip the precheck for it. + */ +static inline bool +inputs_emit_extras(Path *outer_path, Path *inner_path) +{ + /* cheap test first: most paths share their rel's reltarget */ + return (outer_path->pathtarget != outer_path->parent->reltarget && + path_emits_extras(outer_path)) || + (inner_path->pathtarget != inner_path->parent->reltarget && + path_emits_extras(inner_path)); +} + +/* + * cheapest_extras_path + * The cheapest unparameterized path of 'rel' that emits values beyond its + * reltarget (see path_extra_exprs()), or NULL. + * + * Such a path is kept by add_path() but is rarely the rel's cheapest, and + * hash and sort-merge joins look only at the cheapest. It is worth trying + * as well: a join over it doesn't pay for evaluating those values. + * pathlist is sorted by total cost, so the first one found is the cheapest. + */ +static Path * +cheapest_extras_path(RelOptInfo *rel) +{ + ListCell *lc; + + foreach(lc, rel->pathlist) + { + Path *path = (Path *) lfirst(lc); + + if (path->param_info == NULL && path != rel->cheapest_total_path && + path->pathtarget != rel->reltarget && + path_emits_extras(path)) + return path; + } + return NULL; +} + /* * try_nestloop_path * Consider a nestloop join path; if it appears useful, push it into @@ -970,7 +1018,8 @@ try_nestloop_path(PlannerInfo *root, nestloop_subtype | PGS_CONSIDER_NONPARTIAL, outer_path, inner_path, extra); - if (add_path_precheck(joinrel, workspace.disabled_nodes, + if (inputs_emit_extras(outer_path, inner_path) || + add_path_precheck(joinrel, workspace.disabled_nodes, workspace.startup_cost, workspace.total_cost, pathkeys, required_outer)) { @@ -1055,7 +1104,8 @@ try_partial_nestloop_path(PlannerInfo *root, */ initial_cost_nestloop(root, &workspace, jointype, nestloop_subtype, outer_path, inner_path, extra); - if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes, + if (!inputs_emit_extras(outer_path, inner_path) && + !add_partial_path_precheck(joinrel, workspace.disabled_nodes, workspace.startup_cost, workspace.total_cost, pathkeys)) return; @@ -1163,7 +1213,8 @@ try_mergejoin_path(PlannerInfo *root, outer_presorted_keys, extra); - if (add_path_precheck(joinrel, workspace.disabled_nodes, + if (inputs_emit_extras(outer_path, inner_path) || + add_path_precheck(joinrel, workspace.disabled_nodes, workspace.startup_cost, workspace.total_cost, pathkeys, required_outer)) { @@ -1245,7 +1296,8 @@ try_partial_mergejoin_path(PlannerInfo *root, outer_presorted_keys, extra); - if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes, + if (!inputs_emit_extras(outer_path, inner_path) && + !add_partial_path_precheck(joinrel, workspace.disabled_nodes, workspace.startup_cost, workspace.total_cost, pathkeys)) return; @@ -1317,7 +1369,8 @@ try_hashjoin_path(PlannerInfo *root, initial_cost_hashjoin(root, &workspace, jointype, hashclauses, outer_path, inner_path, extra, false); - if (add_path_precheck(joinrel, workspace.disabled_nodes, + if (inputs_emit_extras(outer_path, inner_path) || + add_path_precheck(joinrel, workspace.disabled_nodes, workspace.startup_cost, workspace.total_cost, NIL, required_outer)) { @@ -1378,7 +1431,8 @@ try_partial_hashjoin_path(PlannerInfo *root, */ initial_cost_hashjoin(root, &workspace, jointype, hashclauses, outer_path, inner_path, extra, parallel_hash); - if (!add_partial_path_precheck(joinrel, workspace.disabled_nodes, + if (!inputs_emit_extras(outer_path, inner_path) && + !add_partial_path_precheck(joinrel, workspace.disabled_nodes, workspace.startup_cost, workspace.total_cost, NIL)) return; @@ -1419,6 +1473,8 @@ sort_inner_and_outer(PlannerInfo *root, { Path *outer_path; Path *inner_path; + Path *extras_outer; + Path *extras_inner; Path *cheapest_partial_outer = NULL; Path *cheapest_safe_inner = NULL; List *all_pathkeys; @@ -1453,6 +1509,8 @@ sort_inner_and_outer(PlannerInfo *root, if (PATH_PARAM_BY_REL(outer_path, innerrel) || PATH_PARAM_BY_REL(inner_path, outerrel)) return; + extras_outer = extra->extras_outer; + extras_inner = extra->extras_inner; /* * If the joinrel is parallel-safe, we may be able to consider a partial @@ -1561,6 +1619,16 @@ sort_inner_and_outer(PlannerInfo *root, extra, false); + /* Likewise with inputs that emit extra values; see hash join */ + if (extras_outer != NULL) + try_mergejoin_path(root, joinrel, extras_outer, inner_path, + merge_pathkeys, cur_mergeclauses, + outerkeys, innerkeys, jointype, extra, false); + if (extras_inner != NULL) + try_mergejoin_path(root, joinrel, outer_path, extras_inner, + merge_pathkeys, cur_mergeclauses, + outerkeys, innerkeys, jointype, extra, false); + /* * If we have partial outer and parallel safe inner path then try * partial mergejoin path. @@ -1845,6 +1913,7 @@ match_unsorted_outer(PlannerInfo *root, bool useallclauses; Path *inner_cheapest_total = innerrel->cheapest_total_path; Path *matpath = NULL; + Path *extras_matpath = NULL; ListCell *lc1; /* @@ -1917,10 +1986,21 @@ match_unsorted_outer(PlannerInfo *root, if ((extra->pgs_mask & (PGS_NESTLOOP_MATERIALIZE | PGS_CONSIDER_NONPARTIAL)) == (PGS_NESTLOOP_MATERIALIZE | PGS_CONSIDER_NONPARTIAL) && - inner_cheapest_total != NULL && - !ExecMaterializesOutput(inner_cheapest_total->pathtype)) - matpath = (Path *) - create_material_path(innerrel, inner_cheapest_total, true); + inner_cheapest_total != NULL) + { + Path *extras_inner = extra->extras_inner; + + if (!ExecMaterializesOutput(inner_cheapest_total->pathtype)) + matpath = (Path *) + create_material_path(innerrel, inner_cheapest_total, true); + + /* likewise the cheapest inner path that emits extra values */ + if (extras_inner != NULL && + !PATH_PARAM_BY_REL(extras_inner, outerrel) && + !ExecMaterializesOutput(extras_inner->pathtype)) + extras_matpath = (Path *) + create_material_path(innerrel, extras_inner, true); + } } foreach(lc1, outerrel->pathlist) @@ -1994,6 +2074,17 @@ match_unsorted_outer(PlannerInfo *root, jointype, PGS_NESTLOOP_MATERIALIZE, extra); + + /* And of an inner path that emits extra values; see hash join */ + if (extras_matpath != NULL) + try_nestloop_path(root, + joinrel, + outerpath, + extras_matpath, + merge_pathkeys, + jointype, + PGS_NESTLOOP_MATERIALIZE, + extra); } /* Can't do anything else if inner rel is parameterized by outer */ @@ -2283,6 +2374,24 @@ hash_inner_and_outer(PlannerInfo *root, jointype, extra); + /* + * Also try an input that emits values the query needs above the join, + * on either side; see cheapest_extras_path(). + */ + { + Path *extras_outer = extra->extras_outer; + Path *extras_inner = extra->extras_inner; + + if (extras_outer != NULL) + try_hashjoin_path(root, joinrel, + extras_outer, cheapest_total_inner, + hashclauses, jointype, extra); + if (extras_inner != NULL) + try_hashjoin_path(root, joinrel, + cheapest_total_outer, extras_inner, + hashclauses, jointype, extra); + } + foreach(lc1, outerrel->cheapest_parameterized_paths) { Path *outerpath = (Path *) lfirst(lc1); diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c index c74dacb221704..1ce86e352d379 100644 --- a/src/backend/optimizer/plan/createplan.c +++ b/src/backend/optimizer/plan/createplan.c @@ -24,6 +24,7 @@ #include "nodes/extensible.h" #include "nodes/makefuncs.h" #include "nodes/nodeFuncs.h" +#include "optimizer/appendinfo.h" #include "optimizer/clauses.h" #include "optimizer/cost.h" #include "optimizer/optimizer.h" @@ -83,6 +84,8 @@ static Plan *create_gating_plan(PlannerInfo *root, Path *path, Plan *plan, List *gating_quals); static Plan *create_join_plan(PlannerInfo *root, JoinPath *best_path); static bool mark_async_capable_plan(Plan *plan, Path *path); +static Plan *create_append_child_plan(PlannerInfo *root, Path *apath, + Path *subpath); static Plan *create_append_plan(PlannerInfo *root, AppendPath *best_path, int flags); static Plan *create_merge_append_plan(PlannerInfo *root, MergeAppendPath *best_path, @@ -939,13 +942,25 @@ use_physical_tlist(PlannerInfo *root, Path *path, int flags) } /* - * Nor if a plain index scan path was asked to emit its ORDER BY values - * (see index_path_orderby_target()); a physical tlist has only Vars, so - * whatever needs them would compute them again. + * Nor if the path emits extra values (see path_extra_exprs()) that a + * physical tlist lacks, such as an index scan's ORDER BY values. An + * index-only scan's are its own expression columns (see + * indexonly_path_target()), which its physical tlist, the index's + * indextlist, already holds. */ - if (path->pathtype == T_IndexScan && - index_path_emits_orderby((IndexPath *) path)) - return false; + if (path_emits_extras(path)) + { + List *indextlist; + + if (path->pathtype != T_IndexOnlyScan) + return false; + indextlist = ((IndexPath *) path)->indexinfo->indextlist; + foreach(lc, path_extra_exprs(path)) + { + if (!tlist_member((Expr *) lfirst(lc), indextlist)) + return false; + } + } /* * For an index-only scan, the "physical tlist" is the index's indextlist. @@ -1217,6 +1232,59 @@ mark_async_capable_plan(Plan *plan, Path *path) return true; } +/* + * create_append_child_plan + * Build the plan for a child of an Append or MergeAppend. + * + * Every child must return the Append's tlist: its rel's reltarget, plus any + * extra values the Append emits (see add_extras_append_path()), translated + * to the child. A child path may emit extra values of its own beyond its + * reltarget (see path_extra_exprs()), in some order; keep those the Append + * emits, in the Append's order, and drop the rest. + */ +static Plan * +create_append_child_plan(PlannerInfo *root, Path *apath, Path *subpath) +{ + Plan *subplan = create_plan_recurse(root, subpath, CP_EXACT_TLIST); + List *append_extras = path_extra_exprs(apath); + List *child_extras; + List *tlist; + int nrel; + ListCell *lc; + + if (!path_emits_extras(subpath)) + { + Assert(append_extras == NIL); + return subplan; + } + + nrel = list_length(subpath->parent->reltarget->exprs); + tlist = list_copy_head(subplan->targetlist, nrel); + child_extras = (List *) + adjust_appendrel_attrs_multilevel(root, (Node *) append_extras, + subpath->parent, + apath->parent); + foreach(lc, child_extras) + { + TargetEntry *tle = tlist_member((Expr *) lfirst(lc), + subplan->targetlist); + + if (tle == NULL) + elog(ERROR, "child of Append doesn't emit one of its extra values"); + tlist = lappend(tlist, makeTargetEntry(tle->expr, + list_length(tlist) + 1, + NULL, false)); + } + if (tlist_same_exprs(tlist, subplan->targetlist)) + return subplan; + if (is_projection_capable_plan(subplan)) + { + subplan->targetlist = tlist; + return subplan; + } + return inject_projection_plan(subplan, tlist, subplan->parallel_safe); +} + /* * create_append_plan * Create an Append plan for 'best_path' and (recursively) plans @@ -1318,7 +1386,7 @@ create_append_plan(PlannerInfo *root, AppendPath *best_path, int flags) Plan *subplan; /* Must insist that all children return the same tlist */ - subplan = create_plan_recurse(root, subpath, CP_EXACT_TLIST); + subplan = create_append_child_plan(root, (Path *) best_path, subpath); /* * For ordered Appends, we must insert a Sort node if subplan isn't @@ -1534,7 +1602,7 @@ create_merge_append_plan(PlannerInfo *root, MergeAppendPath *best_path, /* Build the child plan */ /* Must insist that all children return the same tlist */ - subplan = create_plan_recurse(root, subpath, CP_EXACT_TLIST); + subplan = create_append_child_plan(root, (Path *) best_path, subpath); /* Compute sort column info, and adjust subplan's tlist as needed */ subplan = prepare_sort_from_pathkeys(root, subplan, pathkeys, diff --git a/src/backend/optimizer/plan/planner.c b/src/backend/optimizer/plan/planner.c index 179541f24547f..c2fe38d22114f 100644 --- a/src/backend/optimizer/plan/planner.c +++ b/src/backend/optimizer/plan/planner.c @@ -8262,10 +8262,9 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, * path with a projection path that generates the SRF-free scan/join * target. We modify the lists in place. * - * An index path may emit its own target, rel->reltarget plus a value it - * reads from the index (see indexonly_path_target() and - * index_path_orderby_target()). Such a path needs a projection even when - * the exprs are the same, since its target has more columns than + * A path may emit extra values beyond rel->reltarget (see + * path_extra_exprs()). Such a path needs a projection even when the + * exprs are the same, since its target has more columns than * scanjoin_target's sortgrouprefs describe. * * A projection usually adds the same cost to every path, but not always: @@ -8280,7 +8279,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, /* Shouldn't have any parameterized paths anymore */ Assert(subpath->param_info == NULL); - if (tlist_same_exprs && subpath->pathtarget == rel->reltarget) + if (tlist_same_exprs && !path_emits_extras(subpath)) subpath->pathtarget->sortgrouprefs = scanjoin_target->sortgrouprefs; else @@ -8305,7 +8304,7 @@ apply_scanjoin_target_to_paths(PlannerInfo *root, /* Shouldn't have any parameterized paths anymore */ Assert(subpath->param_info == NULL); - if (tlist_same_exprs && subpath->pathtarget == rel->reltarget) + if (tlist_same_exprs && !path_emits_extras(subpath)) subpath->pathtarget->sortgrouprefs = scanjoin_target->sortgrouprefs; else diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c index 95134bfeda3ee..5356beaf8a566 100644 --- a/src/backend/optimizer/util/clauses.c +++ b/src/backend/optimizer/util/clauses.c @@ -377,6 +377,26 @@ contain_subplans_walker(Node *node, void *context) } +/* + * expr_contains + * Is 'expr', or some subexpression of it, equal() to 'sub'? + */ +static bool +expr_contains_walker(Node *node, void *context) +{ + if (node == NULL) + return false; + if (equal(node, context)) + return true; + return expression_tree_walker(node, expr_contains_walker, context); +} + +bool +expr_contains(Node *expr, Node *sub) +{ + return expr_contains_walker(expr, sub); +} + /***************************************************************************** * Check clauses for mutable functions *****************************************************************************/ diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c index bba27aaf28a3d..06e9eea685f2d 100644 --- a/src/backend/optimizer/util/pathnode.c +++ b/src/backend/optimizer/util/pathnode.c @@ -387,24 +387,193 @@ set_cheapest(RelOptInfo *parent_rel) } /* - * pathtargets_differ - * Do two paths of base relation 'rel' emit different targets? + * path_extras_start + * Where the values a path emits beyond its rel's reltarget start in its + * pathtarget's exprs, or NULL if it emits none; *nextras is their number. * - * A base relation's paths normally all emit rel->reltarget, but an index - * path may also emit a value the query needs above the scan, one it has - * for free and other paths must compute (see indexonly_path_target() and - * index_path_orderby_target()). That cost isn't in either path's cost - * yet; it is charged when the expression is. So add_path() treats paths - * with different targets like paths with different pathkeys, and keeps - * both. + * A path normally emits rel->reltarget, the Vars and PlaceHolderVars the + * rest of the query needs. An index path may also emit a non-Var + * expression the query needs that it has for free: an index-only scan reads + * a returnable index expression column (see indexonly_path_target()), and an + * ordering IndexScan has its ORDER BY values (see + * index_path_orderby_target()). A join emits whatever its inputs emit (see + * join_path_target()), and so may an Append whose children all emit the + * same values (see add_extras_append_path()). Such a target is a copy of + * rel->reltarget with the extra expressions appended, so they're its tail. + * + * Sort, Material and Memoize don't project and share their input's target, + * so they emit what it emits. Nothing else emits extra values: other path + * types build their own targets, and an upper rel's targets differ from its + * reltarget for reasons of their own. + */ +static ListCell * +path_extras_start(Path *path, int *nextras) +{ + RelOptInfo *rel = path->parent; + PathTarget *reltarget = rel->reltarget; + int nrel; + + *nextras = 0; + if (path->pathtarget == reltarget) + return NULL; + + switch (nodeTag(path)) + { + case T_SortPath: + case T_IncrementalSortPath: + case T_MaterialPath: + case T_MemoizePath: + { + Path *subpath; + + if (IsA(path, MaterialPath)) + subpath = ((MaterialPath *) path)->subpath; + else if (IsA(path, MemoizePath)) + subpath = ((MemoizePath *) path)->subpath; + else + subpath = ((SortPath *) path)->subpath; + if (subpath->parent != rel || + subpath->pathtarget != path->pathtarget) + return NULL; + return path_extras_start(subpath, nextras); + } + case T_IndexPath: + if (!IS_SIMPLE_REL(rel)) + return NULL; + break; + case T_NestPath: + case T_MergePath: + case T_HashPath: + if (!IS_JOIN_REL(rel)) + return NULL; + break; + case T_AppendPath: + /* see add_extras_append_path() */ + if (rel->reloptkind != RELOPT_BASEREL) + return NULL; + break; + default: + return NULL; + } + + /* + * Our targets are a copy of reltarget with the values appended. Anything + * else (say, a final target that apply_projection_to_path() put into an + * index path) isn't ours. + */ + nrel = list_length(reltarget->exprs); + if (list_length(path->pathtarget->exprs) <= nrel) + return NULL; + for (int i = 0; i < nrel; i++) + { + if (list_nth(path->pathtarget->exprs, i) != + list_nth(reltarget->exprs, i)) + return NULL; + } + *nextras = list_length(path->pathtarget->exprs) - nrel; + return list_nth_cell(path->pathtarget->exprs, nrel); +} + +/* + * path_emits_extras + * Does the path emit any values beyond its rel's reltarget? */ -static inline bool -pathtargets_differ(RelOptInfo *rel, Path *path1, Path *path2) +bool +path_emits_extras(Path *path) { - return rel->reloptkind == RELOPT_BASEREL && - (IsA(path1, IndexPath) || IsA(path2, IndexPath)) && - path1->pathtarget != path2->pathtarget && - !equal(path1->pathtarget->exprs, path2->pathtarget->exprs); + int n; + + return path_extras_start(path, &n) != NULL; +} + +/* + * path_extra_exprs + * The values a path emits beyond its rel's reltarget (see + * path_extras_start()), as a new list. + */ +List * +path_extra_exprs(Path *path) +{ + int n; + ListCell *start = path_extras_start(path, &n); + + if (start == NULL) + return NIL; + return list_copy_tail(path->pathtarget->exprs, + list_length(path->pathtarget->exprs) - n); +} + +/* + * extras_subset + * Is each of the 'n1' values from 'c1' among the 'n2' from 'c2'? + */ +static bool +extras_subset(List *l1, ListCell *c1, int n1, List *l2, ListCell *c2, int n2) +{ + int i1 = list_cell_number(l1, c1); + int i2 = list_cell_number(l2, c2); + + for (int a = 0; a < n1; a++) + { + Node *x = list_nth(l1, i1 + a); + bool found = false; + + for (int b = 0; b < n2 && !found; b++) + found = equal(x, list_nth(l2, i2 + b)); + if (!found) + return false; + } + return true; +} + +/* + * compare_path_extras + * Compare the extra values two paths emit (see path_extra_exprs()). + * + * A path that emits a superset of what the other emits is "better" in the + * same sense as better-sorted: whatever needs the other path's values can + * use this one's instead. The result uses the PathKeysComparison codes so + * add_path() can combine it with the pathkeys comparison. + */ +static PathKeysComparison +compare_path_extras(Path *path1, Path *path2) +{ + int n1, + n2; + ListCell *c1 = path_extras_start(path1, &n1); + ListCell *c2 = path_extras_start(path2, &n2); + List *l1 = path1->pathtarget->exprs; + List *l2 = path2->pathtarget->exprs; + bool sub12, + sub21; + + if (n1 == 0 && n2 == 0) + return PATHKEYS_EQUAL; + sub12 = n1 == 0 || (n2 > 0 && extras_subset(l1, c1, n1, l2, c2, n2)); + sub21 = n2 == 0 || (n1 > 0 && extras_subset(l2, c2, n2, l1, c1, n1)); + if (sub12 && sub21) + return PATHKEYS_EQUAL; + if (sub21) + return PATHKEYS_BETTER1; + if (sub12) + return PATHKEYS_BETTER2; + return PATHKEYS_DIFFERENT; +} + +/* + * combine_comparisons + * Combine two PathKeysComparison results into one. + * + * One path is better overall only if it's better or equal on both counts. + */ +static PathKeysComparison +combine_comparisons(PathKeysComparison a, PathKeysComparison b) +{ + if (a == PATHKEYS_EQUAL) + return b; + if (b == PATHKEYS_EQUAL || a == b) + return a; + return PATHKEYS_DIFFERENT; } /* @@ -415,7 +584,11 @@ pathtargets_differ(RelOptInfo *rel, Path *path1, Path *path2) * A path is worthy if it has a better sort order (better pathkeys) or * cheaper cost (as defined below), or generates fewer rows, than any * existing path that has the same or superset parameterization rels. We - * also consider parallel-safe paths more worthy than others. + * also consider parallel-safe paths more worthy than others. And a path + * that emits extra values the query needs (see path_extra_exprs()) is + * better, like a better-sorted one, than a path that emits a subset of + * them: whatever would compute those values above the other path can + * read them from this one. * * Cheaper cost can mean either a cheaper total cost or a cheaper startup * cost; if one path is cheaper in one of these aspects and another is @@ -534,8 +707,8 @@ add_path(RelOptInfo *parent_rel, Path *new_path) old_path_pathkeys = old_path->param_info ? NIL : old_path->pathkeys; keyscmp = compare_pathkeys(new_path_pathkeys, old_path_pathkeys); - if (pathtargets_differ(parent_rel, new_path, old_path)) - keyscmp = PATHKEYS_DIFFERENT; + keyscmp = combine_comparisons(keyscmp, + compare_path_extras(new_path, old_path)); if (keyscmp != PATHKEYS_DIFFERENT) { switch (costcmp) @@ -841,10 +1014,10 @@ add_partial_path(RelOptInfo *parent_rel, Path *new_path) bool remove_old = false; /* unless new proves superior */ PathKeysComparison keyscmp; - /* Compare pathkeys, and targets as in add_path(). */ + /* Compare pathkeys, and extra values as in add_path(). */ keyscmp = compare_pathkeys(new_path->pathkeys, old_path->pathkeys); - if (pathtargets_differ(parent_rel, new_path, old_path)) - keyscmp = PATHKEYS_DIFFERENT; + keyscmp = combine_comparisons(keyscmp, + compare_path_extras(new_path, old_path)); /* * Unless pathkeys are incompatible, see if one of the paths dominates @@ -1113,13 +1286,14 @@ create_samplescan_path(PlannerInfo *root, RelOptInfo *rel, Relids required_outer * give the path a copy of rel->reltarget with those expressions appended. * They cost the scan nothing (path_target_cost() exempts them), and * setrefs.c's fix_join_expr() matches them in the parent join's expressions, - * replacing its copy with a reference to the scan's output. + * replacing its copy with a reference to the scan's output. A join passes + * them on up (see join_path_target()). * - * Restricted to plain base relations: an appendrel child's paths are wrapped - * by Append/MergeAppend, which build their own targetlists, and the top-level - * scan/join target already handles the unjoined case. Returns - * rel->reltarget unchanged when there's nothing to add, which is the common - * case, so most paths share it as before. + * Restricted to plain base relations: an appendrel child's ORDER BY values + * reach the scan/join target through the child's own projection (see + * apply_scanjoin_target_to_paths()). Returns rel->reltarget unchanged when + * there's nothing to add, which is the common case, so most paths share it + * as before. */ static PathTarget * index_path_orderby_target(PlannerInfo *root, IndexOptInfo *index, @@ -1143,6 +1317,14 @@ index_path_orderby_target(PlannerInfo *root, IndexOptInfo *index, if (!index_orderby_returnable(index, lfirst_int(lcol), orderby)) continue; + /* + * Not if it's cheap: a join would drop it (see join_path_target()), + * and emitting it would still keep extra paths at this level (see + * expr_worth_emitting()). + */ + if (!expr_worth_emitting(root, (Node *) orderby)) + continue; + /* * Only if some top-level targetlist entry is this expression; an * expression that merely contains it would not be rewritten, and @@ -1173,40 +1355,88 @@ index_path_orderby_target(PlannerInfo *root, IndexOptInfo *index, return set_pathtarget_cost_width(root, target); } +/* + * expr_worth_emitting + * Is 'expr' expensive enough to be worth emitting as an extra value? + * + * A path that emits an extra value makes add_path() keep it, and every join + * path built on it, alongside paths it would otherwise have discarded (see + * compare_path_extras()). That costs planning time at every join level + * above. For a cheap expression, computing it again costs less than that, + * so emit only values that cost more than 10 times cpu_operator_cost per + * evaluation, the same test make_sort_input_target() uses to decide that an + * expression is worth postponing past a sort. + */ +bool +expr_worth_emitting(PlannerInfo *root, Node *expr) +{ + QualCost cost; + + cost_qual_eval_node(&cost, expr, root); + return cost.per_tuple > 10 * cpu_operator_cost; +} + +/* + * query_tlist_exprs + * The query's final targetlist expressions, in terms of 'rel'. + * + * root->processed_tlist is in terms of the parent of an appendrel; translate + * it for a child, so it can be compared with the child's index expressions. + */ +List * +query_tlist_exprs(PlannerInfo *root, RelOptInfo *rel) +{ + List *exprs = get_tlist_exprs(root->processed_tlist, true); + + if (rel->reloptkind == RELOPT_OTHER_MEMBER_REL) + exprs = (List *) adjust_appendrel_attrs_multilevel(root, (Node *) exprs, + rel, + rel->top_parent); + return exprs; +} + /* * indexonly_path_target * Choose the PathTarget for an index-only scan path. * * An index-only scan reads a returnable index expression column instead of * computing it (set_indexonlyscan_references()), and isn't charged for it - * (indexonly_target_cost()). But that only helps if the path survives + * (path_target_cost()). But that only helps if the path survives * add_path(), and with rel->reltarget, which has only Vars, nothing * distinguishes it from a seq scan that will have to compute the expression * later; it loses on cost before the expression is charged to anyone. So - * if the query's targetlist has a top-level entry equal() to a returnable - * index expression, give the path a copy of rel->reltarget with that - * expression appended. add_path() keeps paths with different targets - * (see pathtargets_differ()), and once the scan/join target is applied, the - * paths that must compute the expression pay for it and this one doesn't. + * if the query's targetlist contains a returnable index expression, give + * the path a copy of rel->reltarget with that expression appended, making + * it an extra value the path emits (see path_extra_exprs()). add_path() + * then won't let a path that lacks it dominate this one on cost alone. + * The value travels up through joins (see join_path_target()), and + * setrefs.c's fix_join_expr() and fix_upper_expr() replace each occurrence, + * at any depth, with a reference to it; the paths that must compute it + * instead pay for it when the expression is charged. + * + * An expression is matched with equal(), so a Var of a rel on the nullable + * side of an outer join, which carries varnullingrels in the targetlist, + * never matches the index's own: the value read below the join isn't the + * one the query asks for above it. * - * This is done only when the rel is the query's sole base relation, where - * the value goes straight to the scan/join target. Below a join, the join - * would have to credit it (as join_target_cost() does for ORDER BY values) - * and the joinrel's add_path() would have to keep it; not attempted yet. + * A partial path gets the extra values only if they're parallel-safe. */ static PathTarget * -indexonly_path_target(PlannerInfo *root, IndexOptInfo *index) +indexonly_path_target(PlannerInfo *root, IndexOptInfo *index, + bool partial_path) { RelOptInfo *rel = index->rel; PathTarget *target = NULL; + List *tlist_exprs; ListCell *lt; int i = 0; - if (index->indexprs == NIL || rel->reloptkind != RELOPT_BASEREL || - root->processed_tlist == NIL || - !bms_equal(rel->relids, root->all_query_rels)) + if (index->indexprs == NIL || !IS_SIMPLE_REL(rel) || + root->processed_tlist == NIL) return rel->reltarget; + tlist_exprs = query_tlist_exprs(root, rel); + foreach(lt, index->indextlist) { TargetEntry *itle = lfirst_node(TargetEntry, lt); @@ -1214,14 +1444,18 @@ indexonly_path_target(PlannerInfo *root, IndexOptInfo *index) if (!index->canreturn[i++] || IsA(itle->expr, Var)) continue; + if (partial_path && !is_parallel_safe(root, (Node *) itle->expr)) + continue; + if (!expr_worth_emitting(root, (Node *) itle->expr)) + continue; - foreach(lq, root->processed_tlist) + foreach(lq, tlist_exprs) { - if (equal(lfirst_node(TargetEntry, lq)->expr, itle->expr)) + if (expr_contains(lfirst(lq), (Node *) itle->expr)) { if (target == NULL) target = copy_pathtarget(rel->reltarget); - add_column_to_pathtarget(target, itle->expr, 0); + add_new_column_to_pathtarget(target, itle->expr); break; } } @@ -1230,46 +1464,10 @@ indexonly_path_target(PlannerInfo *root, IndexOptInfo *index) if (target == NULL) return rel->reltarget; - /* add_column_to_pathtarget doesn't maintain cost and width */ + /* add_new_column_to_pathtarget doesn't maintain cost and width */ return set_pathtarget_cost_width(root, target); } -/* - * index_path_emits_orderby - * Does this plain IndexScan path's target hold a value it takes from its - * ORDER BY values? - * - * use_physical_tlist() must not replace such a target with the scan's - * physical tlist, which has only Vars, or whatever needs the value would - * compute it again. - */ -bool -index_path_emits_orderby(IndexPath *ipath) -{ - ListCell *lc; - - if (ipath->path.pathtype != T_IndexScan || ipath->indexorderbys == NIL) - return false; - - foreach(lc, ipath->path.pathtarget->exprs) - { - Expr *expr = (Expr *) lfirst(lc); - ListCell *lo, - *lcol; - - if (IsA(expr, Var)) - continue; - forboth(lo, ipath->indexorderbys, lcol, ipath->indexorderbycols) - { - if (index_orderby_returnable(ipath->indexinfo, lfirst_int(lcol), - (Expr *) lfirst(lo)) && - orderby_tlist_match(expr, (Expr *) lfirst(lo))) - return true; - } - } - return false; -} - /* * create_index_path * Creates a path node for an index scan. @@ -1310,7 +1508,7 @@ create_index_path(PlannerInfo *root, pathnode->path.pathtype = indexonly ? T_IndexOnlyScan : T_IndexScan; pathnode->path.parent = rel; pathnode->path.pathtarget = indexonly ? - indexonly_path_target(root, index) : + indexonly_path_target(root, index, partial_path) : index_path_orderby_target(root, index, indexorderbys, indexorderbycols); pathnode->path.param_info = get_baserel_parampathinfo(root, rel, @@ -1925,7 +2123,8 @@ create_material_path(RelOptInfo *rel, Path *subpath, bool enabled) pathnode->path.pathtype = T_Material; pathnode->path.parent = rel; - pathnode->path.pathtarget = rel->reltarget; + /* Material doesn't project, so use source path's pathtarget */ + pathnode->path.pathtarget = subpath->pathtarget; pathnode->path.param_info = subpath->param_info; pathnode->path.parallel_aware = false; pathnode->path.parallel_safe = rel->consider_parallel && @@ -1961,7 +2160,8 @@ create_memoize_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath, pathnode->path.pathtype = T_Memoize; pathnode->path.parent = rel; - pathnode->path.pathtarget = rel->reltarget; + /* Memoize doesn't project, so use source path's pathtarget */ + pathnode->path.pathtarget = subpath->pathtarget; pathnode->path.param_info = subpath->param_info; pathnode->path.parallel_aware = false; pathnode->path.parallel_safe = rel->consider_parallel && @@ -2543,6 +2743,90 @@ calc_non_nestloop_required_outer(Path *outer_path, Path *inner_path) return required_outer; } +/* + * join_path_target + * Choose the PathTarget for a join path. + * + * A join normally emits joinrel->reltarget. If an input emits extra values + * (see path_extra_exprs()), the join passes them up too, so that a higher + * join or the final scan/join target can use them instead of computing them + * (fix_join_expr() matches them in the inputs' targetlists, and + * join_free_exprs() credits them). Without that, an index-only scan or + * ordering IndexScan below the topmost join would read the value for + * nothing. + * + * Only values the query still needs above this join are passed up: a + * value that refers to a rel on the nullable side of an outer join this + * join forms would be wrong above it, but those never match the query's + * targetlist (its Vars carry varnullingrels the index's don't), so no input + * emits one; and every value we emit came from the final targetlist. + * + * Values must be parallel-safe if the join is, since a partial join path's + * target is computed in workers. + * + * Every value a scan emits passed expr_worth_emitting() there, so we don't + * look up its cost again for each join path. + */ +static PathTarget * +join_path_target(PlannerInfo *root, RelOptInfo *joinrel, + Path *outer_path, Path *inner_path) +{ + List *outer_extras = path_extra_exprs(outer_path); + List *inner_extras = path_extra_exprs(inner_path); + int nextras PG_USED_FOR_ASSERTS_ONLY; + PathTarget *target; + bool all_kept = true; + ListCell *lc; + + if (outer_extras == NIL && inner_extras == NIL) + return joinrel->reltarget; + + target = copy_pathtarget(joinrel->reltarget); + nextras = list_length(outer_extras) + list_length(inner_extras); + foreach(lc, list_concat(outer_extras, inner_extras)) + { + Node *expr = (Node *) lfirst(lc); + + if (!joinrel->consider_parallel || is_parallel_safe(root, expr)) + add_new_column_to_pathtarget(target, (Expr *) expr); + else + all_kept = false; + } + if (list_length(target->exprs) == list_length(joinrel->reltarget->exprs)) + return joinrel->reltarget; + + /* + * The values' cost and width are what they added to the inputs' targets, + * since the two inputs' values refer to different rels and can't overlap. + * Working that out is much cheaper than set_pathtarget_cost_width(), + * which looks up each function's cost again. + */ + if (all_kept) + { + Path *inputs[2] = {outer_path, inner_path}; + + /* + * Each one added a column: an input's values are distinct, the two + * inputs' refer to different rels, and a join rel's reltarget holds + * only Vars and PlaceHolderVars. + */ + Assert(list_length(target->exprs) == + list_length(joinrel->reltarget->exprs) + nextras); + + for (int i = 0; i < 2; i++) + { + PathTarget *pt = inputs[i]->pathtarget; + PathTarget *rt = inputs[i]->parent->reltarget; + + target->cost.startup += pt->cost.startup - rt->cost.startup; + target->cost.per_tuple += pt->cost.per_tuple - rt->cost.per_tuple; + target->width += pt->width - rt->width; + } + return target; + } + return set_pathtarget_cost_width(root, target); +} + /* * create_nestloop_path * Creates a pathnode corresponding to a nestloop join between two @@ -2611,7 +2895,9 @@ create_nestloop_path(PlannerInfo *root, pathnode->jpath.path.pathtype = T_NestLoop; pathnode->jpath.path.parent = joinrel; - pathnode->jpath.path.pathtarget = joinrel->reltarget; + pathnode->jpath.path.pathtarget = join_path_target(root, joinrel, + outer_path, + inner_path); pathnode->jpath.path.param_info = get_joinrel_parampathinfo(root, joinrel, @@ -2677,7 +2963,9 @@ create_mergejoin_path(PlannerInfo *root, pathnode->jpath.path.pathtype = T_MergeJoin; pathnode->jpath.path.parent = joinrel; - pathnode->jpath.path.pathtarget = joinrel->reltarget; + pathnode->jpath.path.pathtarget = join_path_target(root, joinrel, + outer_path, + inner_path); pathnode->jpath.path.param_info = get_joinrel_parampathinfo(root, joinrel, @@ -2742,7 +3030,9 @@ create_hashjoin_path(PlannerInfo *root, pathnode->jpath.path.pathtype = T_HashJoin; pathnode->jpath.path.parent = joinrel; - pathnode->jpath.path.pathtarget = joinrel->reltarget; + pathnode->jpath.path.pathtarget = join_path_target(root, joinrel, + outer_path, + inner_path); pathnode->jpath.path.param_info = get_joinrel_parampathinfo(root, joinrel, @@ -2880,125 +3170,154 @@ orderby_tlist_match(Expr *expr, Expr *orderby) equal(lsecond(a->args), linitial(b->args)); } +/* state for credit_free_exprs_walker() */ +typedef struct +{ + PlannerInfo *root; + List *free; + QualCost cost; +} credit_free_context; + /* - * indexonly_target_cost - * path_target_cost() for an index-only scan path. + * credit_free_exprs_walker + * Subtract from context->cost the cost of each outermost subexpression + * of 'node' equal() to one of context->free. * - * setrefs.c's set_indexonlyscan_references() matches target expressions - * against the index's returnable columns, expression columns included, and - * replaces a match with a reference to that column: the scan reads the - * value the index stored instead of computing it. So a top-level target - * expression equal() to a returnable index expression costs nothing. Only - * top-level entries are considered, as elsewhere; that's what's normally in - * a scan's target. + * setrefs.c's fix_upper_expr() and fix_join_expr() try to match every + * non-Var subexpression against the input's targetlist before descending + * into it, and replace a match with a reference to the input's column. So + * an expression costs nothing where an input emits it, wherever it appears + * in the expression, and its own subexpressions aren't evaluated either. + */ +static bool +credit_free_exprs_walker(Node *node, credit_free_context *context) +{ + if (node == NULL || IsA(node, Var) || IsA(node, Const)) + return false; + if (list_member(context->free, node)) + { + QualCost ecost; + + cost_qual_eval_node(&ecost, node, context->root); + context->cost.startup -= ecost.startup; + context->cost.per_tuple -= ecost.per_tuple; + return false; /* don't count its subexpressions twice */ + } + return expression_tree_walker(node, credit_free_exprs_walker, context); +} + +/* + * free_exprs_cost + * target->cost, less the cost of the values in 'free' that 'target' + * uses (see credit_free_exprs_walker()). */ static QualCost -indexonly_target_cost(PlannerInfo *root, IndexPath *ipath, PathTarget *target) +free_exprs_cost(PlannerInfo *root, PathTarget *target, List *free) +{ + credit_free_context context; + + context.root = root; + context.free = free; + context.cost = target->cost; + if (free != NIL) + (void) credit_free_exprs_walker((Node *) target->exprs, &context); + return context.cost; +} + +/* + * indexonly_free_exprs + * The returnable expression columns of an index-only scan's index. + * + * set_indexonlyscan_references() replaces each occurrence of one of these + * in the scan's targetlist and quals with a reference to the index column. + */ +static List * +indexonly_free_exprs(IndexPath *ipath) { - QualCost cost = target->cost; IndexOptInfo *index = ipath->indexinfo; - ListCell *lc; + List *result = NIL; + ListCell *lt; + int i = 0; if (index->indexprs == NIL) - return cost; /* only Vars, which cost nothing anyway */ - - foreach(lc, target->exprs) + return NIL; + foreach(lt, index->indextlist) { - Node *expr = (Node *) lfirst(lc); - ListCell *lt; - int i = 0; + TargetEntry *tle = lfirst_node(TargetEntry, lt); - if (IsA(expr, Var)) - continue; - - foreach(lt, index->indextlist) - { - TargetEntry *tle = lfirst_node(TargetEntry, lt); - - if (index->canreturn[i++] && !IsA(tle->expr, Var) && - equal(expr, tle->expr)) - { - QualCost ecost; - - cost_qual_eval_node(&ecost, expr, root); - cost.startup -= ecost.startup; - cost.per_tuple -= ecost.per_tuple; - break; - } - } + if (index->canreturn[i++] && !IsA(tle->expr, Var)) + result = lappend(result, tle->expr); } - - return cost; + return result; } /* - * join_target_cost - * path_target_cost() for a join path. + * indexonly_qual_cost + * 'cost', the cost of an index-only scan's qpquals, less the returnable + * index expressions they use. * - * A join takes any top-level target expression that one of its inputs - * already emits from that input's output instead of evaluating it again: - * setrefs.c's fix_join_expr() matches whole non-Var expressions against the - * input targetlists before descending into them. Join inputs ordinarily - * emit only Vars and PlaceHolderVars, so this changes nothing; but an - * ordering IndexScan input may emit the ORDER BY values it returns (see - * index_path_orderby_target()). Look through Material and Memoize, which - * pass their input's targetlist up unchanged. We look only at the join's - * immediate inputs; the value can't reach a higher join without passing - * through this one's targetlist, which is the joinrel's and has only Vars. + * set_indexonlyscan_references() rewrites the scan's quals as it does its + * targetlist. */ -static QualCost -join_target_cost(PlannerInfo *root, JoinPath *jpath, PathTarget *target) +QualCost +indexonly_qual_cost(PlannerInfo *root, IndexPath *ipath, List *qpquals, + QualCost cost) { - QualCost cost = target->cost; - Path *inputs[2] = {jpath->outerjoinpath, jpath->innerjoinpath}; + credit_free_context context; ListCell *lc; - foreach(lc, target->exprs) + context.root = root; + context.free = indexonly_free_exprs(ipath); + context.cost = cost; + if (context.free == NIL) + return cost; + foreach(lc, qpquals) { - Node *expr = (Node *) lfirst(lc); - - if (IsA(expr, Var) || IsA(expr, PlaceHolderVar)) - continue; - - for (int i = 0; i < 2; i++) - { - Path *input = inputs[i]; - - while (IsA(input, MaterialPath) || IsA(input, MemoizePath)) - input = IsA(input, MaterialPath) ? - ((MaterialPath *) input)->subpath : - ((MemoizePath *) input)->subpath; + Node *qual = (Node *) lfirst(lc); - if (IsA(input, IndexPath) && - index_path_emits_orderby((IndexPath *) input) && - list_member(input->pathtarget->exprs, expr)) - { - QualCost ecost; - - cost_qual_eval_node(&ecost, expr, root); - cost.startup -= ecost.startup; - cost.per_tuple -= ecost.per_tuple; - break; - } - } + if (IsA(qual, RestrictInfo)) + qual = (Node *) ((RestrictInfo *) qual)->clause; + (void) credit_free_exprs_walker(qual, &context); } + return context.cost; +} - return cost; +/* + * join_free_exprs + * The values a join's inputs emit beyond their reltargets. + * + * fix_join_expr() replaces each occurrence of one of these in the join's + * targetlist with a reference to the input's output. Material and Memoize + * pass their input's targetlist up unchanged; path_extra_exprs() already + * looks through them. + */ +static List * +join_free_exprs(JoinPath *jpath) +{ + return list_concat_unique(path_extra_exprs(jpath->outerjoinpath), + path_extra_exprs(jpath->innerjoinpath)); } /* * path_target_cost * Return the cost 'path' pays to evaluate 'target'. * - * Normally that's just target->cost. A join path doesn't pay for entries - * an input already emits (see join_target_cost()), nor an index-only scan - * for entries it reads from the index (see indexonly_target_cost()). And a - * plain IndexScan takes any - * top-level target expression that is a returnable ORDER BY expression - * (see orderby_tlist_match()) from its ORDER BY values instead of - * evaluating it (setrefs.c rewrites it into a reference to them), so it - * doesn't pay for those. Each target entry - * is counted at most once, as setrefs.c rewrites it once. + * Normally that's just target->cost. But a path doesn't pay for a value + * it has for free: + * + * - a join, for values one of its inputs emits (join_free_exprs()); + * - an index-only scan, for returnable index expression columns + * (indexonly_free_exprs()); + * + * in either case wherever the value appears in the target, since setrefs.c + * replaces every occurrence (see credit_free_exprs_walker()). And + * + * - a plain IndexScan, for a top-level target entry that is one of its + * returnable ORDER BY expressions (see orderby_tlist_match()), which + * setrefs.c's replace_orderby_tlist_refs() takes from its ORDER BY values. + * Only top-level entries of the scan's own targetlist are rewritten there, + * so only those count here; above the scan, a join or upper node that + * reads the value is credited for it at any depth, as above. */ QualCost path_target_cost(PlannerInfo *root, Path *path, PathTarget *target) @@ -3008,10 +3327,12 @@ path_target_cost(PlannerInfo *root, Path *path, PathTarget *target) ListCell *lc; if (IsA(path, NestPath) || IsA(path, MergePath) || IsA(path, HashPath)) - return join_target_cost(root, (JoinPath *) path, target); + return free_exprs_cost(root, target, + join_free_exprs((JoinPath *) path)); if (IsA(path, IndexPath) && path->pathtype == T_IndexOnlyScan) - return indexonly_target_cost(root, (IndexPath *) path, target); + return free_exprs_cost(root, target, + indexonly_free_exprs((IndexPath *) path)); if (!IsA(path, IndexPath) || path->pathtype != T_IndexScan) return cost; diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h index c4e0df4338e19..8f833c40f4e19 100644 --- a/src/include/nodes/pathnodes.h +++ b/src/include/nodes/pathnodes.h @@ -3619,6 +3619,9 @@ typedef struct SemiAntiJoinFactors * RIGHT_ANTI/inner_unique joins) * param_source_rels are OK targets for parameterization of result paths * pgs_mask is a bitmask of PGS_* constants to limit the join strategy + * extras_outer and extras_inner are each input rel's cheapest unparameterized + * path that emits extra values, if that isn't its cheapest path anyway + * (see cheapest_extras_path() in joinpath.c), else NULL */ typedef struct JoinPathExtraData { @@ -3629,6 +3632,8 @@ typedef struct JoinPathExtraData SemiAntiJoinFactors semifactors; Relids param_source_rels; uint64 pgs_mask; + Path *extras_outer; + Path *extras_inner; } JoinPathExtraData; /* diff --git a/src/include/optimizer/clauses.h b/src/include/optimizer/clauses.h index e929789762be9..e0196edf109bb 100644 --- a/src/include/optimizer/clauses.h +++ b/src/include/optimizer/clauses.h @@ -45,6 +45,7 @@ extern bool contain_subplans(Node *clause); extern char max_parallel_hazard(Query *parse); extern bool is_parallel_safe(PlannerInfo *root, Node *node); extern bool contain_nonstrict_functions(Node *clause); +extern bool expr_contains(Node *expr, Node *sub); extern bool contain_exec_param(Node *clause, List *param_ids); extern bool contain_leaked_vars(Node *clause); diff --git a/src/include/optimizer/pathnode.h b/src/include/optimizer/pathnode.h index 8fcfe60aba3b5..c1b3a22d3598f 100644 --- a/src/include/optimizer/pathnode.h +++ b/src/include/optimizer/pathnode.h @@ -231,7 +231,12 @@ extern bool index_orderby_returnable(IndexOptInfo *index, int indexcol, extern bool orderby_tlist_match(Expr *expr, Expr *orderby); extern QualCost path_target_cost(PlannerInfo *root, Path *path, PathTarget *target); -extern bool index_path_emits_orderby(IndexPath *ipath); +extern List *path_extra_exprs(Path *path); +extern bool path_emits_extras(Path *path); +extern bool expr_worth_emitting(PlannerInfo *root, Node *expr); +extern List *query_tlist_exprs(PlannerInfo *root, RelOptInfo *rel); +extern QualCost indexonly_qual_cost(PlannerInfo *root, IndexPath *ipath, + List *qpquals, QualCost cost); extern ProjectionPath *create_projection_path(PlannerInfo *root, RelOptInfo *rel, Path *subpath, diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out index 380624174bd25..40f00e435dcc7 100644 --- a/src/test/regress/expected/gist.out +++ b/src/test/regress/expected/gist.out @@ -827,6 +827,27 @@ select f.id from gist_join_fact f join gist_join_dim j on j.id = f.id Index Cond: (j.id = f.id) (11 rows) +-- Nor for a cheap ORDER BY expression: the plain point distance is computed +-- again above the join, which costs less than carrying it up. +create index on gist_join_fact using gist (p); +explain (verbose, costs off) +select f.id, f.p <-> point(5,5) as d + from gist_join_fact f join gist_join_dim j on j.id = f.id + order by f.p <-> point(5,5) limit 3; + QUERY PLAN +------------------------------------------------------------------------------- + Limit + Output: f.id, ((f.p <-> '(5,5)'::point)) + -> Nested Loop + Output: f.id, (f.p <-> '(5,5)'::point) + -> Index Scan using gist_join_fact_p_idx on pg_temp.gist_join_fact f + Output: f.id, f.p + Order By: (f.p <-> '(5,5)'::point) + -> Index Scan using gist_join_dim_id_idx on pg_temp.gist_join_dim j + Output: j.id + Index Cond: (j.id = f.id) +(10 rows) + reset enable_seqscan; reset enable_bitmapscan; reset enable_indexonlyscan; @@ -869,7 +890,7 @@ select gist_ios_total('select gist_ios_costly(i) from gist_ios_cost order by i') -- With nothing else to recommend it (no qual, no useful order), an -- index-only scan that reads the expression still beats a seq scan that has --- to compute it: add_path() keeps both, since their targets differ. +-- to compute it: add_path() keeps the path that emits the value. explain (verbose, costs off) select gist_ios_costly(i) from gist_ios_cost order by gist_ios_costly(i); QUERY PLAN @@ -901,18 +922,202 @@ select gist_ios_costly(i) from gist_ios_cost order by i desc limit 3; Output: (gist_ios_costly(i)), i (4 rows) --- Below a join it isn't considered. -explain (costs off) -select gist_ios_costly(a.i) from gist_ios_cost a join gist_ios_cost b using (i); - QUERY PLAN ------------------------------------------ +-- The expression may be part of a larger one, or appear in a qual. +explain (verbose, costs off) +select gist_ios_costly(i) + 1 from gist_ios_cost + where gist_ios_costly(i) % 3 = 0; + QUERY PLAN +-------------------------------------------------------------------------------------- + Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost + Output: ((gist_ios_costly(i)) + 1) + Filter: (((gist_ios_costly(gist_ios_cost.i)) % 3) = 0) +(3 rows) + +-- Below a join, the scan emits it and the join passes it up, through more +-- than one join level. +create temp table gist_ios_dim (i int, tag text); +insert into gist_ios_dim select g, 't' || g from generate_series(1, 10000, 7) g; +create index on gist_ios_dim (i); +create temp table gist_ios_dim2 (k int, i int); +insert into gist_ios_dim2 select g, g * 3 from generate_series(1, 3000) g; +vacuum analyze gist_ios_dim, gist_ios_dim2; +-- (Each join method is pinned; it's the value passing up that's tested.) +set enable_hashjoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_costly(a.i), d.tag from gist_ios_cost a + join gist_ios_dim d on d.i = a.i; + QUERY PLAN +---------------------------------------------------------------------------------------------- + Merge Join + Output: (gist_ios_costly(a.i)), d.tag + Merge Cond: (a.i = d.i) + -> Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost a + Output: a.i, (gist_ios_costly(a.i)) + -> Index Scan using gist_ios_dim_i_idx on pg_temp.gist_ios_dim d + Output: d.i, d.tag +(7 rows) + +reset enable_nestloop; +set enable_mergejoin = off; +explain (verbose, costs off) +select gist_ios_costly(a.i), d.tag, n.k from gist_ios_cost a + join gist_ios_dim d on d.i = a.i join gist_ios_dim2 n on n.i = a.i; + QUERY PLAN +---------------------------------------------------------------------------------------------- + Nested Loop + Output: (gist_ios_costly(a.i)), d.tag, n.k + Join Filter: (a.i = d.i) + -> Nested Loop + Output: d.tag, d.i, n.k, n.i + -> Seq Scan on pg_temp.gist_ios_dim2 n + Output: n.k, n.i + -> Index Scan using gist_ios_dim_i_idx on pg_temp.gist_ios_dim d + Output: d.i, d.tag + Index Cond: (d.i = n.i) + -> Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost a + Output: a.i, (gist_ios_costly(a.i)) + Index Cond: (a.i = n.i) +(13 rows) + +reset enable_hashjoin; +reset enable_mergejoin; +-- When everything the scan emits is an index column, it returns the +-- index's own tlist without projecting (here including k, which nothing +-- needs); the value is still read above the join. +create temp table gist_ios_wide (k int, i int); +insert into gist_ios_wide select g, g from generate_series(1, 10000) g; +create index on gist_ios_wide (k, gist_ios_costly(i)) include (i); +vacuum analyze gist_ios_wide; +set enable_mergejoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_costly(w.i), d.tag from gist_ios_wide w + join gist_ios_dim d on d.i = w.i; + QUERY PLAN +------------------------------------------------------------------------------------------------ Hash Join - Hash Cond: (a.i = b.i) - -> Seq Scan on gist_ios_cost a + Output: (gist_ios_costly(w.i)), d.tag + Hash Cond: (w.i = d.i) + -> Index Only Scan using gist_ios_wide_k_gist_ios_costly_i_i_idx on pg_temp.gist_ios_wide w + Output: w.k, (gist_ios_costly(w.i)), w.i -> Hash - -> Seq Scan on gist_ios_cost b -(5 rows) + Output: d.tag, d.i + -> Seq Scan on pg_temp.gist_ios_dim d + Output: d.tag, d.i +(9 rows) + +reset enable_mergejoin; +reset enable_nestloop; +drop table gist_ios_wide; +-- But not from the nullable side of an outer join: there the query's Var +-- differs from the index's (it may be null), so the value read below the +-- join isn't the one asked for. +explain (verbose, costs off) +select coalesce(gist_ios_costly(a.i), -1) from gist_ios_dim d + left join gist_ios_cost a on a.i = d.i * 2; + QUERY PLAN +--------------------------------------------------------- + Hash Left Join + Output: COALESCE(gist_ios_costly(a.i), '-1'::integer) + Hash Cond: ((d.i * 2) = a.i) + -> Seq Scan on pg_temp.gist_ios_dim d + Output: d.i, d.tag + -> Hash + Output: a.i + -> Seq Scan on pg_temp.gist_ios_cost a + Output: a.i +(9 rows) + +-- A partitioned table whose partitions all have the index: each child reads +-- the value and the Append passes it up, even when a partition's columns +-- are in a different order. +create temp table gist_ios_part (i int, pad text) partition by range (i); +create temp table gist_ios_part1 (pad text, i int); +alter table gist_ios_part attach partition gist_ios_part1 + for values from (1) to (5001); +create temp table gist_ios_part2 partition of gist_ios_part + for values from (5001) to (10001); +insert into gist_ios_part select g, 'p' from generate_series(1, 10000) g; +create index on gist_ios_part (i, gist_ios_costly(i)); +vacuum analyze gist_ios_part; +set enable_mergejoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_costly(p.i), d.tag from gist_ios_part p + join gist_ios_dim d on d.i = p.i; + QUERY PLAN +-------------------------------------------------------------------------------------------------------- + Hash Join + Output: (gist_ios_costly(p.i)), d.tag + Hash Cond: (p.i = d.i) + -> Append + -> Index Only Scan using gist_ios_part1_i_gist_ios_costly_i_idx on pg_temp.gist_ios_part1 p_1 + Output: p_1.i, (gist_ios_costly(p_1.i)) + -> Index Only Scan using gist_ios_part2_i_gist_ios_costly_i_idx on pg_temp.gist_ios_part2 p_2 + Output: p_2.i, (gist_ios_costly(p_2.i)) + -> Hash + Output: d.tag, d.i + -> Seq Scan on pg_temp.gist_ios_dim d + Output: d.tag, d.i +(12 rows) +reset enable_mergejoin; +reset enable_nestloop; +-- An index created on only one partition doesn't count: every child must +-- emit the value for the Append to. +create temp table gist_ios_part_x (i int) partition by range (i); +create temp table gist_ios_part_x1 partition of gist_ios_part_x + for values from (1) to (5001); +create temp table gist_ios_part_x2 partition of gist_ios_part_x + for values from (5001) to (10001); +insert into gist_ios_part_x select g from generate_series(1, 10000) g; +create index on gist_ios_part_x1 (i, gist_ios_costly(i)); +vacuum analyze gist_ios_part_x; +set enable_mergejoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_costly(p.i), d.tag from gist_ios_part_x p + join gist_ios_dim d on d.i = p.i; + QUERY PLAN +------------------------------------------------------ + Hash Join + Output: gist_ios_costly(p.i), d.tag + Hash Cond: (p.i = d.i) + -> Append + -> Seq Scan on pg_temp.gist_ios_part_x1 p_1 + Output: p_1.i + -> Seq Scan on pg_temp.gist_ios_part_x2 p_2 + Output: p_2.i + -> Hash + Output: d.tag, d.i + -> Seq Scan on pg_temp.gist_ios_dim d + Output: d.tag, d.i +(12 rows) + +reset enable_mergejoin; +reset enable_nestloop; +-- Each of these returns what the same query does without index-only scans. +create temp table gist_ios_check as +select gist_ios_costly(a.i) as e, d.tag from gist_ios_cost a + join gist_ios_dim d on d.i = a.i; +set enable_indexonlyscan = off; +select count(*) as mismatches from ( + (select gist_ios_costly(a.i) as e, d.tag from gist_ios_cost a + join gist_ios_dim d on d.i = a.i + except all select * from gist_ios_check) + union all + (select * from gist_ios_check + except all select gist_ios_costly(a.i), d.tag from gist_ios_cost a + join gist_ios_dim d on d.i = a.i)) s; + mismatches +------------ + 0 +(1 row) + +reset enable_indexonlyscan; +drop table gist_ios_check, gist_ios_part, gist_ios_part_x, gist_ios_dim, + gist_ios_dim2; drop function gist_ios_total(text); drop table gist_ios_cost; drop function gist_ios_costly(int); diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql index df8ec25b14ac4..ddd7001346edf 100644 --- a/src/test/regress/sql/gist.sql +++ b/src/test/regress/sql/gist.sql @@ -420,6 +420,13 @@ select nextval('gist_join_cnt') - 1 as calls_during_join; explain (verbose, costs off) select f.id from gist_join_fact f join gist_join_dim j on j.id = f.id order by gist_join_pt(f.p) <-> point(5,5) limit 3; +-- Nor for a cheap ORDER BY expression: the plain point distance is computed +-- again above the join, which costs less than carrying it up. +create index on gist_join_fact using gist (p); +explain (verbose, costs off) +select f.id, f.p <-> point(5,5) as d + from gist_join_fact f join gist_join_dim j on j.id = f.id + order by f.p <-> point(5,5) limit 3; reset enable_seqscan; reset enable_bitmapscan; reset enable_indexonlyscan; @@ -452,7 +459,7 @@ select gist_ios_total('select gist_ios_costly(i) from gist_ios_cost order by i') < 10000 as ios_expr_is_free; -- With nothing else to recommend it (no qual, no useful order), an -- index-only scan that reads the expression still beats a seq scan that has --- to compute it: add_path() keeps both, since their targets differ. +-- to compute it: add_path() keeps the path that emits the value. explain (verbose, costs off) select gist_ios_costly(i) from gist_ios_cost order by gist_ios_costly(i); explain (verbose, costs off) @@ -461,9 +468,104 @@ select gist_ios_costly(i) from gist_ios_cost; -- still reads the expression from the index. explain (verbose, costs off) select gist_ios_costly(i) from gist_ios_cost order by i desc limit 3; --- Below a join it isn't considered. -explain (costs off) -select gist_ios_costly(a.i) from gist_ios_cost a join gist_ios_cost b using (i); +-- The expression may be part of a larger one, or appear in a qual. +explain (verbose, costs off) +select gist_ios_costly(i) + 1 from gist_ios_cost + where gist_ios_costly(i) % 3 = 0; +-- Below a join, the scan emits it and the join passes it up, through more +-- than one join level. +create temp table gist_ios_dim (i int, tag text); +insert into gist_ios_dim select g, 't' || g from generate_series(1, 10000, 7) g; +create index on gist_ios_dim (i); +create temp table gist_ios_dim2 (k int, i int); +insert into gist_ios_dim2 select g, g * 3 from generate_series(1, 3000) g; +vacuum analyze gist_ios_dim, gist_ios_dim2; +-- (Each join method is pinned; it's the value passing up that's tested.) +set enable_hashjoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_costly(a.i), d.tag from gist_ios_cost a + join gist_ios_dim d on d.i = a.i; +reset enable_nestloop; +set enable_mergejoin = off; +explain (verbose, costs off) +select gist_ios_costly(a.i), d.tag, n.k from gist_ios_cost a + join gist_ios_dim d on d.i = a.i join gist_ios_dim2 n on n.i = a.i; +reset enable_hashjoin; +reset enable_mergejoin; +-- When everything the scan emits is an index column, it returns the +-- index's own tlist without projecting (here including k, which nothing +-- needs); the value is still read above the join. +create temp table gist_ios_wide (k int, i int); +insert into gist_ios_wide select g, g from generate_series(1, 10000) g; +create index on gist_ios_wide (k, gist_ios_costly(i)) include (i); +vacuum analyze gist_ios_wide; +set enable_mergejoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_costly(w.i), d.tag from gist_ios_wide w + join gist_ios_dim d on d.i = w.i; +reset enable_mergejoin; +reset enable_nestloop; +drop table gist_ios_wide; +-- But not from the nullable side of an outer join: there the query's Var +-- differs from the index's (it may be null), so the value read below the +-- join isn't the one asked for. +explain (verbose, costs off) +select coalesce(gist_ios_costly(a.i), -1) from gist_ios_dim d + left join gist_ios_cost a on a.i = d.i * 2; +-- A partitioned table whose partitions all have the index: each child reads +-- the value and the Append passes it up, even when a partition's columns +-- are in a different order. +create temp table gist_ios_part (i int, pad text) partition by range (i); +create temp table gist_ios_part1 (pad text, i int); +alter table gist_ios_part attach partition gist_ios_part1 + for values from (1) to (5001); +create temp table gist_ios_part2 partition of gist_ios_part + for values from (5001) to (10001); +insert into gist_ios_part select g, 'p' from generate_series(1, 10000) g; +create index on gist_ios_part (i, gist_ios_costly(i)); +vacuum analyze gist_ios_part; +set enable_mergejoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_costly(p.i), d.tag from gist_ios_part p + join gist_ios_dim d on d.i = p.i; +reset enable_mergejoin; +reset enable_nestloop; +-- An index created on only one partition doesn't count: every child must +-- emit the value for the Append to. +create temp table gist_ios_part_x (i int) partition by range (i); +create temp table gist_ios_part_x1 partition of gist_ios_part_x + for values from (1) to (5001); +create temp table gist_ios_part_x2 partition of gist_ios_part_x + for values from (5001) to (10001); +insert into gist_ios_part_x select g from generate_series(1, 10000) g; +create index on gist_ios_part_x1 (i, gist_ios_costly(i)); +vacuum analyze gist_ios_part_x; +set enable_mergejoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_costly(p.i), d.tag from gist_ios_part_x p + join gist_ios_dim d on d.i = p.i; +reset enable_mergejoin; +reset enable_nestloop; +-- Each of these returns what the same query does without index-only scans. +create temp table gist_ios_check as +select gist_ios_costly(a.i) as e, d.tag from gist_ios_cost a + join gist_ios_dim d on d.i = a.i; +set enable_indexonlyscan = off; +select count(*) as mismatches from ( + (select gist_ios_costly(a.i) as e, d.tag from gist_ios_cost a + join gist_ios_dim d on d.i = a.i + except all select * from gist_ios_check) + union all + (select * from gist_ios_check + except all select gist_ios_costly(a.i), d.tag from gist_ios_cost a + join gist_ios_dim d on d.i = a.i)) s; +reset enable_indexonlyscan; +drop table gist_ios_check, gist_ios_part, gist_ios_part_x, gist_ios_dim, + gist_ios_dim2; drop function gist_ios_total(text); drop table gist_ios_cost; drop function gist_ios_costly(int); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 1d9246b1ff8bd..ad69854ccd484 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -3689,6 +3689,7 @@ count_param_references_context cp_hash_func create_upper_paths_hook_type createdb_failure_params +credit_free_context crosstab_HashEnt crosstab_cat_desc curl_infotype From 289175dab0fd993a0d2f48838ceabff133a4a84c Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Tue, 20 Oct 2026 16:33:05 -0400 Subject: [PATCH 9/9] Carry index-computed values through outer joins, Gather and ordered Appends The previous commit passes an index's extra values (see path_extra_exprs()) up through inner joins and an unordered Append, but stops at three places, where the expression is then computed above them: * The nullable side of an outer join. Above the join, the query's copy of the expression has the join's bit in its Vars' nullingrels, and the scan's copy doesn't. The join null-extends the scan's value along with the Vars, so the two agree on every row exactly when the expression yields NULL for NULL inputs. So for a strict expression, join_path_target() now relabels the value with the joinrel's Vars (their nullingrels) as it passes it up, and setrefs.c's fix_join_expr() matches it against the input's copy at an outer join the way it already matches Vars there: equal but for nullingrels, with the upper copy's a superset of the input's (search_indexed_tlist_for_nulled_expr()); path_target_cost() credits it the same way. A non-strict expression is still computed above the join. An index-only scan matches the query's targetlist without nullingrels, so it emits the value in the first place; an ordering IndexScan's ORDER BY values still aren't emitted on the nullable side. * Partial paths, which emitted only parallel-safe values. A worker reads the stored value; nothing evaluates the expression, so a parallel-restricted one is fine. Gather and Gather Merge now pass a partial path's values up (gather_target()), and a Gather over the cheapest partial path that emits values is considered as well as one over the cheapest partial path. * An ordered Append or MergeAppend, and a parallel Append. add_extras_append_path() now also builds those, over child paths that emit the values and have the ordering, and over the children's partial paths, collecting the children as generate_orderedappend_paths() does. The expression also has to be compared without nullingrels when a scan decides what to emit. The new strip_nullingrels() in clauses.c does that; remove_nulling_relids() can't, as it errors out on the negative special varnos (ROWID_VAR in a MERGE). Author: Greg Burd Discussion: https://postgr.es/m/8s5lT8erXzBMugXJQ6Wginbp_gc2B4hmCYhF0Q0GpnuK87eCAOcKs5K31AWCwraCNFIQt42wwkow_MPPubHg485MWv-zDBY4NL_JX9yJEEg=@burd.me --- src/backend/optimizer/path/allpaths.c | 260 ++++++++++++++++++++++---- src/backend/optimizer/plan/setrefs.c | 128 ++++++++++++- src/backend/optimizer/util/clauses.c | 45 +++++ src/backend/optimizer/util/pathnode.c | 225 +++++++++++++++++++--- src/include/optimizer/clauses.h | 1 + src/test/regress/expected/gist.out | 214 ++++++++++++++++++++- src/test/regress/sql/gist.sql | 106 ++++++++++- src/tools/pgindent/typedefs.list | 1 + 8 files changed, 907 insertions(+), 73 deletions(-) diff --git a/src/backend/optimizer/path/allpaths.c b/src/backend/optimizer/path/allpaths.c index a6b1b15208bb6..89543166572cb 100644 --- a/src/backend/optimizer/path/allpaths.c +++ b/src/backend/optimizer/path/allpaths.c @@ -103,6 +103,7 @@ static void set_rel_pathlist(PlannerInfo *root, RelOptInfo *rel, static void set_plain_rel_size(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *rte); static void create_plain_partial_paths(PlannerInfo *root, RelOptInfo *rel); +static PathTarget *gather_target(RelOptInfo *rel, Path *subpath); static void add_extras_append_path(PlannerInfo *root, RelOptInfo *rel, List *live_childrels); static void set_rel_consider_parallel(PlannerInfo *root, RelOptInfo *rel, @@ -1406,10 +1407,39 @@ set_grouped_rel_pathlist(PlannerInfo *root, RelOptInfo *rel) } +/* + * extras_child_path + * The cheapest path of 'childrel' from 'paths' that emits every value + * in 'extras' (in the parent's terms) and is ordered by 'pathkeys' + * (NIL for no requirement), or NULL. Partial paths are sorted by total + * cost too, so the same search serves partial_pathlist. + */ +static Path * +extras_child_path(PlannerInfo *root, RelOptInfo *childrel, List *paths, + List *extras, List *pathkeys) +{ + List *child_extras; + ListCell *lp; + + child_extras = (List *) + adjust_appendrel_attrs_multilevel(root, (Node *) extras, + childrel, childrel->top_parent); + foreach(lp, paths) + { + Path *path = (Path *) lfirst(lp); + + if (path->param_info == NULL && + pathkeys_contained_in(pathkeys, path->pathkeys) && + list_difference(child_extras, path_extra_exprs(path)) == NIL) + return path; + } + return NULL; +} + /* * add_extras_append_path - * Build an unordered, unparameterized Append over child paths that all - * emit the same extra values, if there are any worth emitting. + * Build Appends over child paths that all emit the same extra values, + * if there are any worth emitting. * * A partitioned table's index on an expression is an index on that * expression in every partition, so each child may have an index-only scan @@ -1421,8 +1451,10 @@ set_grouped_rel_pathlist(PlannerInfo *root, RelOptInfo *rel) * emits it. create_append_child_plan() puts each child's values in the * Append's order. * - * Only an unordered, non-partial Append is built this way; an ordered or - * parallel Append still computes the expression above it. + * We build an unordered Append, a MergeAppend for each ordering some child + * provides (or an ordered Append if the partitions are ordered that way, as + * generate_orderedappend_paths() does), and a parallel Append over the + * children's partial paths. */ static void add_extras_append_path(PlannerInfo *root, RelOptInfo *rel, @@ -1430,9 +1462,10 @@ add_extras_append_path(PlannerInfo *root, RelOptInfo *rel, { List *extras = NIL; List *tlist_exprs; - AppendPathInput input = {0}; + List *orderings = NIL; + List *partition_pathkeys = NIL; + bool partition_pathkeys_partial = true; PathTarget *target; - AppendPath *apath; ListCell *lc; if (rel->reloptkind != RELOPT_BASEREL || live_childrels == NIL || @@ -1465,39 +1498,159 @@ add_extras_append_path(PlannerInfo *root, RelOptInfo *rel, if (extras == NIL) return; - foreach(lc, live_childrels) + target = copy_pathtarget(rel->reltarget); + foreach(lc, extras) + add_new_column_to_pathtarget(target, (Expr *) lfirst(lc)); + target = set_pathtarget_cost_width(root, target); + + /* + * Unordered Append; also collect the orderings children offer. If some + * child has no path that emits the values, we can't build this or an + * ordered one, but may still build the parallel Append below. + */ { - RelOptInfo *childrel = (RelOptInfo *) lfirst(lc); - List *child_extras; - Path *best = NULL; - ListCell *lp; - - child_extras = (List *) - adjust_appendrel_attrs_multilevel(root, (Node *) extras, - childrel, childrel->top_parent); - foreach(lp, childrel->pathlist) + AppendPathInput input = {0}; + AppendPath *apath; + bool ok = true; + + foreach(lc, live_childrels) { - Path *path = (Path *) lfirst(lp); + RelOptInfo *childrel = (RelOptInfo *) lfirst(lc); + Path *best = extras_child_path(root, childrel, + childrel->pathlist, + extras, NIL); + ListCell *lp; - if (path->param_info == NULL && - list_difference(child_extras, path_extra_exprs(path)) == NIL) + if (best == NULL) { - best = path; /* pathlist is sorted by total cost */ + ok = false; + orderings = NIL; break; } + accumulate_append_subpath(best, &input.subpaths, NULL, + &input.child_append_relid_sets); + foreach(lp, childrel->pathlist) + { + Path *path = (Path *) lfirst(lp); + + if (path->pathkeys != NIL && path_emits_extras(path) && + !list_member(orderings, path->pathkeys)) + orderings = lappend(orderings, path->pathkeys); + } + } + if (ok) + { + apath = create_append_path(root, rel, input, NIL, NULL, 0, false, + -1); + apath->path.pathtarget = target; + add_path(rel, (Path *) apath); } - if (best == NULL) - return; - accumulate_append_subpath(best, &input.subpaths, NULL, - &input.child_append_relid_sets); } - apath = create_append_path(root, rel, input, NIL, NULL, 0, false, -1); - target = copy_pathtarget(rel->reltarget); - foreach(lc, extras) - add_new_column_to_pathtarget(target, (Expr *) lfirst(lc)); - apath->path.pathtarget = set_pathtarget_cost_width(root, target); - add_path(rel, (Path *) apath); + /* Ordered: MergeAppend, or Append if the partitions are in that order */ + if (rel->part_scheme != NULL && + partitions_are_ordered(rel->boundinfo, rel->live_parts)) + partition_pathkeys = build_partition_pathkeys(root, rel, + ForwardScanDirection, + &partition_pathkeys_partial); + foreach(lc, orderings) + { + List *pathkeys = (List *) lfirst(lc); + AppendPathInput input = {0}; + bool match_partition_order; + bool ok = true; + ListCell *lcr; + + /* only orderings the parent's paths could be using */ + pathkeys = truncate_useless_pathkeys(root, rel, pathkeys); + if (pathkeys == NIL) + continue; + match_partition_order = + pathkeys_contained_in(pathkeys, partition_pathkeys) || + (!partition_pathkeys_partial && + pathkeys_contained_in(partition_pathkeys, pathkeys)); + + foreach(lcr, live_childrels) + { + RelOptInfo *childrel = (RelOptInfo *) lfirst(lcr); + Path *best = extras_child_path(root, childrel, + childrel->pathlist, + extras, pathkeys); + + if (best == NULL) + { + ok = false; + break; + } + + /* as generate_orderedappend_paths() collects them */ + if (match_partition_order) + input.subpaths = lappend(input.subpaths, + get_singleton_append_subpath(best, + &input.child_append_relid_sets)); + else + accumulate_append_subpath(best, &input.subpaths, NULL, + &input.child_append_relid_sets); + } + if (!ok) + continue; + + if (match_partition_order) + { + AppendPath *apath; + + apath = create_append_path(root, rel, input, pathkeys, NULL, 0, + false, -1); + apath->path.pathtarget = target; + add_path(rel, (Path *) apath); + } + else + { + MergeAppendPath *mapath; + + mapath = create_merge_append_path(root, rel, input.subpaths, + input.child_append_relid_sets, + pathkeys, NULL); + mapath->path.pathtarget = target; + add_path(rel, (Path *) mapath); + } + } + + /* Parallel Append over the children's partial paths */ + if (rel->consider_parallel) + { + AppendPathInput input = {0}; + AppendPath *apath; + int parallel_workers = 0; + + foreach(lc, live_childrels) + { + RelOptInfo *childrel = (RelOptInfo *) lfirst(lc); + Path *best = extras_child_path(root, childrel, + childrel->partial_pathlist, + extras, NIL); + + if (best == NULL) + return; + parallel_workers = Max(parallel_workers, best->parallel_workers); + accumulate_append_subpath(best, &input.partial_subpaths, NULL, + &input.child_append_relid_sets); + } + if (enable_parallel_append) + { + parallel_workers = Max(parallel_workers, + pg_leftmost_one_pos32(list_length(live_childrels)) + 1); + parallel_workers = Min(parallel_workers, + max_parallel_workers_per_gather); + } + if (parallel_workers <= 0) + return; + apath = create_append_path(root, rel, input, NIL, NULL, + parallel_workers, enable_parallel_append, + -1); + apath->path.pathtarget = target; + add_partial_path(rel, (Path *) apath); + } } /* @@ -3379,10 +3532,32 @@ generate_gather_paths(PlannerInfo *root, RelOptInfo *rel, bool override_rows) cheapest_partial_path = linitial(rel->partial_pathlist); rows = compute_gather_rows(cheapest_partial_path); simple_gather_path = (Path *) - create_gather_path(root, rel, cheapest_partial_path, rel->reltarget, + create_gather_path(root, rel, cheapest_partial_path, + gather_target(rel, cheapest_partial_path), NULL, rowsp); add_path(rel, simple_gather_path); + /* + * A partial path that emits extra values (see path_extra_exprs()) is + * rarely the cheapest; gather the cheapest such path too, passing its + * values up, so whatever is above can use them. + */ + foreach(lc, rel->partial_pathlist) + { + Path *subpath = (Path *) lfirst(lc); + + if (subpath == cheapest_partial_path) + continue; + if (path_emits_extras(subpath)) + { + rows = compute_gather_rows(subpath); + add_path(rel, (Path *) + create_gather_path(root, rel, subpath, + subpath->pathtarget, NULL, rowsp)); + break; + } + } + /* * For each useful ordering, we can consider an order-preserving Gather * Merge. @@ -3396,12 +3571,31 @@ generate_gather_paths(PlannerInfo *root, RelOptInfo *rel, bool override_rows) continue; rows = compute_gather_rows(subpath); - path = create_gather_merge_path(root, rel, subpath, rel->reltarget, + path = create_gather_merge_path(root, rel, subpath, + gather_target(rel, subpath), subpath->pathkeys, NULL, rowsp); add_path(rel, &path->path); } } +/* + * gather_target + * The target for a Gather or Gather Merge over 'subpath': the rel's + * reltarget, or the subpath's own if it emits extra values (see + * path_extra_exprs()), so they pass up through the Gather. + * + * A Gather over a projection to the final scan/join target, as built by + * apply_scanjoin_target_to_paths(), takes rel->reltarget, which by then is + * that target. + */ +static PathTarget * +gather_target(RelOptInfo *rel, Path *subpath) +{ + if (subpath->pathtarget != rel->reltarget && path_emits_extras(subpath)) + return subpath->pathtarget; + return rel->reltarget; +} + /* * get_useful_pathkeys_for_relation * Determine which orderings of a relation might be useful. @@ -3590,7 +3784,7 @@ generate_useful_gather_paths(PlannerInfo *root, RelOptInfo *rel, bool override_r rows = compute_gather_rows(subpath); path = create_gather_merge_path(root, rel, subpath, - rel->reltarget, + gather_target(rel, subpath), subpath->pathkeys, NULL, rowsp); diff --git a/src/backend/optimizer/plan/setrefs.c b/src/backend/optimizer/plan/setrefs.c index e6b6439ff2d01..86e28971707be 100644 --- a/src/backend/optimizer/plan/setrefs.c +++ b/src/backend/optimizer/plan/setrefs.c @@ -19,6 +19,7 @@ #include "catalog/pg_type.h" #include "nodes/makefuncs.h" #include "nodes/nodeFuncs.h" +#include "optimizer/clauses.h" #include "optimizer/optimizer.h" #include "optimizer/pathnode.h" #include "optimizer/planmain.h" @@ -51,6 +52,12 @@ typedef struct int num_vars; /* number of plain Var tlist entries */ bool has_ph_vars; /* are there PlaceHolderVar entries? */ bool has_non_vars; /* are there other entries? */ + + /* + * non-Var entries without nullingrels, built on demand by + * search_indexed_tlist_for_nulled_expr(); not freed with the struct + */ + List *nulled_tles; tlist_vinfo vars[FLEXIBLE_ARRAY_MEMBER]; /* has num_vars entries */ } indexed_tlist; @@ -180,6 +187,10 @@ static Var *search_indexed_tlist_for_phv(PlaceHolderVar *phv, static Var *search_indexed_tlist_for_non_var(Expr *node, indexed_tlist *itlist, int newvarno); +static Var *search_indexed_tlist_for_nulled_expr(PlannerInfo *root, + Expr *node, + indexed_tlist *itlist, + int newvarno); static Var *search_indexed_tlist_for_sortgroupref(Expr *node, Index sortgroupref, indexed_tlist *itlist, @@ -2923,7 +2934,8 @@ set_dummy_tlist_references(Plan *plan, int rtoffset) * * The result of this function is an indexed_tlist struct to pass to * search_indexed_tlist_for_var() and siblings. - * When done, the indexed_tlist may be freed with a single pfree(). + * When done, the indexed_tlist may be freed with a single pfree(); a + * nulled_tles list built for it later stays in the planner's context. */ static indexed_tlist * build_tlist_index(List *tlist) @@ -2940,6 +2952,7 @@ build_tlist_index(List *tlist) itlist->tlist = tlist; itlist->has_ph_vars = false; itlist->has_non_vars = false; + itlist->nulled_tles = NIL; /* Find the Vars and fill in the index array */ vinfo = itlist->vars; @@ -2991,6 +3004,7 @@ build_tlist_index_other_vars(List *tlist, int ignore_rel) itlist->tlist = tlist; itlist->has_ph_vars = false; itlist->has_non_vars = false; + itlist->nulled_tles = NIL; /* Find the desired Vars and fill in the index array */ vinfo = itlist->vars; @@ -3177,6 +3191,108 @@ search_indexed_tlist_for_non_var(Expr *node, return NULL; /* no match */ } +/* + * expr_nullingrels + * The union of the nullingrels of the Vars and PlaceHolderVars in 'node'. + */ +static Relids +expr_nullingrels(Node *node) +{ + Relids result = NULL; + ListCell *lc; + + foreach(lc, pull_var_clause(node, PVC_INCLUDE_PLACEHOLDERS | + PVC_RECURSE_AGGREGATES | + PVC_RECURSE_WINDOWFUNCS)) + { + Node *v = (Node *) lfirst(lc); + + if (IsA(v, Var)) + result = bms_add_members(result, ((Var *) v)->varnullingrels); + else + result = bms_add_members(result, + ((PlaceHolderVar *) v)->phnullingrels); + } + return result; +} + +/* + * search_indexed_tlist_for_nulled_expr + * Like search_indexed_tlist_for_non_var(), for an expression above an + * outer join whose Vars may carry nullingrels the input's don't. + * + * An input of an outer join may emit a non-Var expression it read from an + * index (see path_extra_exprs()). Above the join, the query's copy of that + * expression has the join's bit added to its Vars' nullingrels, so equal() + * won't match it; the join adds the bit because it null-extends those Vars. + * It null-extends the input's copy of the expression too, so the two agree + * on every row if the expression yields NULL when its Vars are NULL, that + * is if it is strict. Then match it as we match Vars here (NRM_SUPERSET): + * equal but for nullingrels, with the query's copy's nullingrels a superset + * of the input's. The planner relies on this when it carries such a value + * up through an outer join (see join_path_target()). + * + * The first call for an itlist saves its non-Var entries without + * nullingrels, each paired with the nullingrels it had, so that later calls + * don't strip them again. Callers only search an itlist that has_non_vars, + * so that list is never empty once built. + */ +static Var * +search_indexed_tlist_for_nulled_expr(PlannerInfo *root, Expr *node, + indexed_tlist *itlist, int newvarno) +{ + Node *stripped = NULL; + Relids nullingrels = NULL; + ListCell *lc; + + Assert(itlist->has_non_vars); + if (IsA(node, Const) || IsA(node, Param) || + bms_is_empty(root->outer_join_rels)) + return NULL; + + if (itlist->nulled_tles == NIL) + { + foreach(lc, itlist->tlist) + { + TargetEntry *tle = (TargetEntry *) lfirst(lc); + TargetEntry *ntle; + + if (IsA(tle->expr, Var) || IsA(tle->expr, PlaceHolderVar)) + continue; + ntle = flatCopyTargetEntry(tle); + ntle->expr = (Expr *) strip_nullingrels((Node *) tle->expr); + itlist->nulled_tles = lappend(itlist->nulled_tles, ntle); + itlist->nulled_tles = lappend(itlist->nulled_tles, + expr_nullingrels((Node *) tle->expr)); + } + Assert(itlist->nulled_tles != NIL); + } + + for (int i = 0; i < list_length(itlist->nulled_tles); i += 2) + { + TargetEntry *tle = list_nth_node(TargetEntry, itlist->nulled_tles, i); + + if (stripped == NULL) + { + if (contain_nonstrict_functions((Node *) node) || + !contain_var_clause((Node *) node)) + return NULL; + stripped = strip_nullingrels((Node *) node); + nullingrels = expr_nullingrels((Node *) node); + } + if (equal(stripped, tle->expr) && + bms_is_subset(list_nth(itlist->nulled_tles, i + 1), nullingrels)) + { + Var *newvar = makeVarFromTargetEntry(newvarno, tle); + + newvar->varnosyn = 0; /* wasn't ever a plain Var */ + newvar->varattnosyn = 0; + return newvar; + } + } + return NULL; +} + /* * search_indexed_tlist_for_sortgroupref --- find a sort/group expression * @@ -3384,6 +3500,11 @@ fix_join_expr_mutator(Node *node, fix_join_expr_context *context) newvar = search_indexed_tlist_for_non_var((Expr *) node, context->outer_itlist, OUTER_VAR); + if (newvar == NULL && context->nrm_match == NRM_SUPERSET) + newvar = search_indexed_tlist_for_nulled_expr(context->root, + (Expr *) node, + context->outer_itlist, + OUTER_VAR); if (newvar) return (Node *) newvar; } @@ -3392,6 +3513,11 @@ fix_join_expr_mutator(Node *node, fix_join_expr_context *context) newvar = search_indexed_tlist_for_non_var((Expr *) node, context->inner_itlist, INNER_VAR); + if (newvar == NULL && context->nrm_match == NRM_SUPERSET) + newvar = search_indexed_tlist_for_nulled_expr(context->root, + (Expr *) node, + context->inner_itlist, + INNER_VAR); if (newvar) return (Node *) newvar; } diff --git a/src/backend/optimizer/util/clauses.c b/src/backend/optimizer/util/clauses.c index 5356beaf8a566..86a979982d328 100644 --- a/src/backend/optimizer/util/clauses.c +++ b/src/backend/optimizer/util/clauses.c @@ -397,6 +397,51 @@ expr_contains(Node *expr, Node *sub) return expr_contains_walker(expr, sub); } +/* + * strip_nullingrels + * A copy of 'node' with every Var's and PlaceHolderVar's nullingrels + * cleared. + * + * Used to compare an expression read below an outer join with the query's + * copy above it (see query_tlist_exprs() in pathnode.c and + * search_indexed_tlist_for_nulled_expr() in setrefs.c). + * remove_nulling_relids() can't do this: it tests each Var's varno with + * bms_is_member(), which errors out on the negative special varnos, such as + * the ROWID_VAR a MERGE's targetlist contains. + */ +static Node * +strip_nullingrels_mutator(Node *node, void *context) +{ + if (node == NULL) + return NULL; + if (IsA(node, Var)) + { + Var *var = (Var *) node; + + if (bms_is_empty(var->varnullingrels)) + return node; + var = copyObject(var); + var->varnullingrels = NULL; + return (Node *) var; + } + if (IsA(node, PlaceHolderVar)) + { + PlaceHolderVar *phv; + + phv = (PlaceHolderVar *) + expression_tree_mutator(node, strip_nullingrels_mutator, context); + phv->phnullingrels = NULL; + return (Node *) phv; + } + return expression_tree_mutator(node, strip_nullingrels_mutator, context); +} + +Node * +strip_nullingrels(Node *node) +{ + return strip_nullingrels_mutator(node, NULL); +} + /***************************************************************************** * Check clauses for mutable functions *****************************************************************************/ diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c index 06e9eea685f2d..062c8d7476ea1 100644 --- a/src/backend/optimizer/util/pathnode.c +++ b/src/backend/optimizer/util/pathnode.c @@ -402,9 +402,10 @@ set_cheapest(RelOptInfo *parent_rel) * rel->reltarget with the extra expressions appended, so they're its tail. * * Sort, Material and Memoize don't project and share their input's target, - * so they emit what it emits. Nothing else emits extra values: other path - * types build their own targets, and an upper rel's targets differ from its - * reltarget for reasons of their own. + * so they emit what it emits; so may a Gather or Gather Merge given its + * input's target (see generate_gather_paths()). Nothing else emits extra + * values: other path types build their own targets, and an upper rel's + * targets differ from its reltarget for reasons of their own. */ static ListCell * path_extras_start(Path *path, int *nextras) @@ -423,6 +424,8 @@ path_extras_start(Path *path, int *nextras) case T_IncrementalSortPath: case T_MaterialPath: case T_MemoizePath: + case T_GatherPath: + case T_GatherMergePath: { Path *subpath; @@ -430,6 +433,10 @@ path_extras_start(Path *path, int *nextras) subpath = ((MaterialPath *) path)->subpath; else if (IsA(path, MemoizePath)) subpath = ((MemoizePath *) path)->subpath; + else if (IsA(path, GatherPath)) + subpath = ((GatherPath *) path)->subpath; + else if (IsA(path, GatherMergePath)) + subpath = ((GatherMergePath *) path)->subpath; else subpath = ((SortPath *) path)->subpath; if (subpath->parent != rel || @@ -448,6 +455,7 @@ path_extras_start(Path *path, int *nextras) return NULL; break; case T_AppendPath: + case T_MergeAppendPath: /* see add_extras_append_path() */ if (rel->reloptkind != RELOPT_BASEREL) return NULL; @@ -1392,6 +1400,14 @@ query_tlist_exprs(PlannerInfo *root, RelOptInfo *rel) exprs = (List *) adjust_appendrel_attrs_multilevel(root, (Node *) exprs, rel, rel->top_parent); + + /* + * Above an outer join that null-extends the rel, the query's Vars carry + * nullingrels that a scan's don't. The scan may still emit the value; + * whether it can be used above the join is join_path_target()'s call. + */ + if (!bms_is_empty(root->outer_join_rels)) + exprs = (List *) strip_nullingrels((Node *) exprs); return exprs; } @@ -1414,16 +1430,16 @@ query_tlist_exprs(PlannerInfo *root, RelOptInfo *rel) * at any depth, with a reference to it; the paths that must compute it * instead pay for it when the expression is charged. * - * An expression is matched with equal(), so a Var of a rel on the nullable - * side of an outer join, which carries varnullingrels in the targetlist, - * never matches the index's own: the value read below the join isn't the - * one the query asks for above it. + * The query's targetlist is compared without nullingrels (see + * query_tlist_exprs()); whether a value read below an outer join can be used + * above it is decided as it's passed up (see relabel_extra_for_join()). * - * A partial path gets the extra values only if they're parallel-safe. + * A partial path may emit a value that isn't parallel-safe: a worker reads + * the stored value, it never evaluates the expression, and nothing above + * the scan does either, since it references the scan's output column. */ static PathTarget * -indexonly_path_target(PlannerInfo *root, IndexOptInfo *index, - bool partial_path) +indexonly_path_target(PlannerInfo *root, IndexOptInfo *index) { RelOptInfo *rel = index->rel; PathTarget *target = NULL; @@ -1444,8 +1460,6 @@ indexonly_path_target(PlannerInfo *root, IndexOptInfo *index, if (!index->canreturn[i++] || IsA(itle->expr, Var)) continue; - if (partial_path && !is_parallel_safe(root, (Node *) itle->expr)) - continue; if (!expr_worth_emitting(root, (Node *) itle->expr)) continue; @@ -1508,7 +1522,7 @@ create_index_path(PlannerInfo *root, pathnode->path.pathtype = indexonly ? T_IndexOnlyScan : T_IndexScan; pathnode->path.parent = rel; pathnode->path.pathtarget = indexonly ? - indexonly_path_target(root, index, partial_path) : + indexonly_path_target(root, index) : index_path_orderby_target(root, index, indexorderbys, indexorderbycols); pathnode->path.param_info = get_baserel_parampathinfo(root, rel, @@ -2743,6 +2757,99 @@ calc_non_nestloop_required_outer(Path *outer_path, Path *inner_path) return required_outer; } +/* state for relabel_extra_for_join()'s walker and mutator */ +typedef struct +{ + List *joinvars; + bool changed; + bool missing; +} relabel_context; + +static Var * +relabel_find_joinvar(Var *var, List *joinvars) +{ + ListCell *lc; + + foreach(lc, joinvars) + { + Var *jv = (Var *) lfirst(lc); + + if (IsA(jv, Var) && jv->varno == var->varno && + jv->varattno == var->varattno && jv->varlevelsup == 0) + return jv; + } + return NULL; +} + +/* First pass: does anything need relabeling, or is a Var missing? */ +static bool +relabel_check_walker(Node *node, relabel_context *context) +{ + if (node == NULL) + return false; + if (IsA(node, Var) && ((Var *) node)->varlevelsup == 0) + { + Var *var = (Var *) node; + Var *jv = relabel_find_joinvar(var, context->joinvars); + + if (jv == NULL) + context->missing = true; + else if (!bms_equal(jv->varnullingrels, var->varnullingrels)) + context->changed = true; + return context->missing; + } + return expression_tree_walker(node, relabel_check_walker, context); +} + +static Node * +relabel_extra_mutator(Node *node, relabel_context *context) +{ + if (node == NULL) + return NULL; + if (IsA(node, Var) && ((Var *) node)->varlevelsup == 0) + { + Var *var = copyObject((Var *) node); + Var *jv = relabel_find_joinvar(var, context->joinvars); + + var->varnullingrels = bms_copy(jv->varnullingrels); + return (Node *) var; + } + return expression_tree_mutator(node, relabel_extra_mutator, context); +} + +/* + * relabel_extra_for_join + * Express an input's extra value in terms of the joinrel's Vars. + * + * Above an outer join, the joinrel's reltarget has each Var of the nullable + * side with the join's bit added to its nullingrels, and the query's copy of + * an expression over those Vars has it too. The input's copy doesn't. The + * join null-extends the input's value along with the Vars, which gives the + * expression's value for null-extended rows only if the expression is + * strict, so for a non-strict one we can't pass the value up (return NULL). + * Otherwise return the expression with each Var replaced by the joinrel's + * version of it, which setrefs.c matches with the input's + * (search_indexed_tlist_for_nulled_expr()). If some Var isn't in the + * joinrel's reltarget, the value isn't needed above, so return NULL too. + */ +static Node * +relabel_extra_for_join(RelOptInfo *joinrel, Node *expr) +{ + relabel_context context; + + context.joinvars = joinrel->reltarget->exprs; + context.changed = false; + context.missing = false; + (void) relabel_check_walker(expr, &context); + if (context.missing) + return NULL; + if (!context.changed) + return expr; + if (contain_nonstrict_functions(expr)) + return NULL; + return relabel_extra_mutator(expr, &context); +} + /* * join_path_target * Choose the PathTarget for a join path. @@ -2755,17 +2862,14 @@ calc_non_nestloop_required_outer(Path *outer_path, Path *inner_path) * ordering IndexScan below the topmost join would read the value for * nothing. * - * Only values the query still needs above this join are passed up: a - * value that refers to a rel on the nullable side of an outer join this - * join forms would be wrong above it, but those never match the query's - * targetlist (its Vars carry varnullingrels the index's don't), so no input - * emits one; and every value we emit came from the final targetlist. + * Above an outer join a value from the nullable side is relabeled with the + * join's nullingrels, and passed up only if that gives the right answer for + * null-extended rows; see relabel_extra_for_join(). * - * Values must be parallel-safe if the join is, since a partial join path's - * target is computed in workers. - * - * Every value a scan emits passed expr_worth_emitting() there, so we don't - * look up its cost again for each join path. + * A value needn't be parallel-safe for a partial join path: the join only + * references its input's output column. Every value a scan emits passed + * expr_worth_emitting() there, so we don't look up its cost again for each + * join path. */ static PathTarget * join_path_target(PlannerInfo *root, RelOptInfo *joinrel, @@ -2785,9 +2889,9 @@ join_path_target(PlannerInfo *root, RelOptInfo *joinrel, nextras = list_length(outer_extras) + list_length(inner_extras); foreach(lc, list_concat(outer_extras, inner_extras)) { - Node *expr = (Node *) lfirst(lc); + Node *expr = relabel_extra_for_join(joinrel, (Node *) lfirst(lc)); - if (!joinrel->consider_parallel || is_parallel_safe(root, expr)) + if (expr != NULL) add_new_column_to_pathtarget(target, (Expr *) expr); else all_kept = false; @@ -3175,9 +3279,39 @@ typedef struct { PlannerInfo *root; List *free; + bool nulled_ok; /* see free_expr_match() */ + List *free_stripped; /* 'free' without nullingrels, on demand */ QualCost cost; } credit_free_context; +/* + * free_expr_match + * Is 'node' one of context->free? + * + * At an outer join (context->nulled_ok), also accept a strict 'node' that is + * one of them but for nullingrels, as setrefs.c does. + */ +static bool +free_expr_match(Node *node, credit_free_context *context) +{ + Node *stripped; + ListCell *lc; + + if (list_member(context->free, node)) + return true; + if (!context->nulled_ok || bms_is_empty(context->root->outer_join_rels)) + return false; + if (context->free_stripped == NIL) + context->free_stripped = (List *) strip_nullingrels((Node *) context->free); + stripped = strip_nullingrels(node); + foreach(lc, context->free_stripped) + { + if (equal(stripped, lfirst(lc))) + return !contain_nonstrict_functions(node); + } + return false; +} + /* * credit_free_exprs_walker * Subtract from context->cost the cost of each outermost subexpression @@ -3194,7 +3328,7 @@ credit_free_exprs_walker(Node *node, credit_free_context *context) { if (node == NULL || IsA(node, Var) || IsA(node, Const)) return false; - if (list_member(context->free, node)) + if (free_expr_match(node, context)) { QualCost ecost; @@ -3212,12 +3346,15 @@ credit_free_exprs_walker(Node *node, credit_free_context *context) * uses (see credit_free_exprs_walker()). */ static QualCost -free_exprs_cost(PlannerInfo *root, PathTarget *target, List *free) +free_exprs_cost(PlannerInfo *root, PathTarget *target, List *free, + bool nulled_ok) { credit_free_context context; context.root = root; context.free = free; + context.nulled_ok = nulled_ok; + context.free_stripped = NIL; context.cost = target->cost; if (free != NIL) (void) credit_free_exprs_walker((Node *) target->exprs, &context); @@ -3268,6 +3405,8 @@ indexonly_qual_cost(PlannerInfo *root, IndexPath *ipath, List *qpquals, context.root = root; context.free = indexonly_free_exprs(ipath); + context.nulled_ok = false; + context.free_stripped = NIL; context.cost = cost; if (context.free == NIL) return cost; @@ -3308,6 +3447,7 @@ join_free_exprs(JoinPath *jpath) * - a join, for values one of its inputs emits (join_free_exprs()); * - an index-only scan, for returnable index expression columns * (indexonly_free_exprs()); + * - a Gather or Gather Merge, for values its input emits; * * in either case wherever the value appears in the target, since setrefs.c * replaces every occurrence (see credit_free_exprs_walker()). And @@ -3327,12 +3467,37 @@ path_target_cost(PlannerInfo *root, Path *path, PathTarget *target) ListCell *lc; if (IsA(path, NestPath) || IsA(path, MergePath) || IsA(path, HashPath)) - return free_exprs_cost(root, target, - join_free_exprs((JoinPath *) path)); + { + JoinPath *jpath = (JoinPath *) path; + bool forms_oj; + + /* + * A join that forms outer joins matches nulled copies too, as + * set_join_references() does when the Join's ojrelids isn't empty. + * The inputs' relids are disjoint and both within the joinrel's, so + * it forms one exactly when the joinrel has more relids than they do. + */ + forms_oj = bms_num_members(path->parent->relids) > + bms_num_members(jpath->outerjoinpath->parent->relids) + + bms_num_members(jpath->innerjoinpath->parent->relids); + return free_exprs_cost(root, target, join_free_exprs(jpath), + forms_oj); + } if (IsA(path, IndexPath) && path->pathtype == T_IndexOnlyScan) return free_exprs_cost(root, target, - indexonly_free_exprs((IndexPath *) path)); + indexonly_free_exprs((IndexPath *) path), + false); + + /* set_upper_references() matches them in a Gather's subplan's tlist */ + if (IsA(path, GatherPath)) + return free_exprs_cost(root, target, + path_extra_exprs(((GatherPath *) path)->subpath), + false); + if (IsA(path, GatherMergePath)) + return free_exprs_cost(root, target, + path_extra_exprs(((GatherMergePath *) path)->subpath), + false); if (!IsA(path, IndexPath) || path->pathtype != T_IndexScan) return cost; diff --git a/src/include/optimizer/clauses.h b/src/include/optimizer/clauses.h index e0196edf109bb..f88278f10d98f 100644 --- a/src/include/optimizer/clauses.h +++ b/src/include/optimizer/clauses.h @@ -46,6 +46,7 @@ extern char max_parallel_hazard(Query *parse); extern bool is_parallel_safe(PlannerInfo *root, Node *node); extern bool contain_nonstrict_functions(Node *clause); extern bool expr_contains(Node *expr, Node *sub); +extern Node *strip_nullingrels(Node *node); extern bool contain_exec_param(Node *clause, List *param_ids); extern bool contain_leaked_vars(Node *clause); diff --git a/src/test/regress/expected/gist.out b/src/test/regress/expected/gist.out index 40f00e435dcc7..be68194751ff7 100644 --- a/src/test/regress/expected/gist.out +++ b/src/test/regress/expected/gist.out @@ -1010,16 +1010,76 @@ select gist_ios_costly(w.i), d.tag from gist_ios_wide w reset enable_mergejoin; reset enable_nestloop; drop table gist_ios_wide; --- But not from the nullable side of an outer join: there the query's Var --- differs from the index's (it may be null), so the value read below the --- join isn't the one asked for. +-- Also from the nullable side of an outer join, because the function is +-- strict: a null-extended row gets NULL for the value, which is what the +-- function returns for a NULL argument. The value is used inside a larger, +-- non-strict expression above the join. +set enable_hashjoin = off; +set enable_nestloop = off; explain (verbose, costs off) select coalesce(gist_ios_costly(a.i), -1) from gist_ios_dim d left join gist_ios_cost a on a.i = d.i * 2; - QUERY PLAN ---------------------------------------------------------- + QUERY PLAN +---------------------------------------------------------------------------------------------- + Merge Left Join + Output: COALESCE((gist_ios_costly(a.i)), '-1'::integer) + Merge Cond: (((d.i * 2)) = a.i) + -> Sort + Output: d.i, ((d.i * 2)) + Sort Key: ((d.i * 2)) + -> Seq Scan on pg_temp.gist_ios_dim d + Output: d.i, (d.i * 2) + -> Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost a + Output: a.i, (gist_ios_costly(a.i)) +(10 rows) + +reset enable_hashjoin; +reset enable_nestloop; +-- And from both sides' nullable rows of a FULL JOIN. +set enable_hashjoin = off; +explain (verbose, costs off) +select gist_ios_costly(a.i), d.tag from gist_ios_dim d + full join gist_ios_cost a on a.i = d.i * 2; + QUERY PLAN +---------------------------------------------------------------------------------------------- + Merge Full Join + Output: (gist_ios_costly(a.i)), d.tag + Merge Cond: (((d.i * 2)) = a.i) + -> Sort + Output: d.tag, d.i, ((d.i * 2)) + Sort Key: ((d.i * 2)) + -> Seq Scan on pg_temp.gist_ios_dim d + Output: d.tag, d.i, (d.i * 2) + -> Index Only Scan using gist_ios_cost_i_gist_ios_costly_i_idx on pg_temp.gist_ios_cost a + Output: a.i, (gist_ios_costly(a.i)) +(10 rows) + +reset enable_hashjoin; +select count(*) as total, count(v) as with_value, + count(*) filter (where v = i) as same + from (select gist_ios_costly(a.i) as v, a.i from gist_ios_dim d + full join gist_ios_cost a on a.i = d.i * 2) s; + total | with_value | same +-------+------------+------- + 10714 | 10000 | 10000 +(1 row) + +-- A function that isn't strict may return something else for NULL, so it's +-- computed above the join. +create function gist_ios_nonstrict(int) returns int + language plpgsql immutable called on null input cost 100000 + as $$ begin return coalesce($1, -1); end $$; +create index on gist_ios_cost (i, gist_ios_nonstrict(i)); +vacuum analyze gist_ios_cost; +set enable_mergejoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_nonstrict(a.i) from gist_ios_dim d + left join gist_ios_cost a on a.i = d.i * 2; + QUERY PLAN +------------------------------------------------- Hash Left Join - Output: COALESCE(gist_ios_costly(a.i), '-1'::integer) + Output: gist_ios_nonstrict(a.i) Hash Cond: ((d.i * 2) = a.i) -> Seq Scan on pg_temp.gist_ios_dim d Output: d.i, d.tag @@ -1029,6 +1089,126 @@ select coalesce(gist_ios_costly(a.i), -1) from gist_ios_dim d Output: a.i (9 rows) +reset enable_mergejoin; +reset enable_nestloop; +select count(*) filter (where v = -1) as null_extended + from (select gist_ios_nonstrict(a.i) as v from gist_ios_dim d + left join gist_ios_cost a on a.i = d.i * 2) s; + null_extended +--------------- + 714 +(1 row) + +drop function gist_ios_nonstrict(int) cascade; +NOTICE: drop cascades to index gist_ios_cost_i_gist_ios_nonstrict_i_idx +-- A parallel-restricted function: the value is read from the index inside +-- the workers, not evaluated there, and passed up through the Gather. (Not +-- a temp table, which can't be scanned in parallel.) +create function gist_ios_restricted(int) returns int + language plpgsql immutable strict parallel restricted cost 100000 + as $$ begin return $1; end $$; +create table gist_ios_par (i int); +insert into gist_ios_par select g from generate_series(1, 10000) g; +create index on gist_ios_par (i, gist_ios_restricted(i)); +vacuum analyze gist_ios_par; +set parallel_setup_cost = 0; +set parallel_tuple_cost = 0; +set min_parallel_index_scan_size = 0; +set max_parallel_workers_per_gather = 2; +set parallel_leader_participation = off; +-- (Pin the scan type: these tables aren't temp, so VACUUM may not get to +-- mark them all-visible while other sessions hold snapshots.) +set enable_seqscan = off; +set enable_bitmapscan = off; +explain (verbose, costs off) +select gist_ios_restricted(i) from gist_ios_par; + QUERY PLAN +------------------------------------------------------------------------------------------------------ + Gather + Output: (gist_ios_restricted(i)) + Workers Planned: 2 + -> Parallel Index Only Scan using gist_ios_par_i_gist_ios_restricted_i_idx on public.gist_ios_par + Output: i, (gist_ios_restricted(i)) +(5 rows) + +select count(*), sum(v) from (select gist_ios_restricted(i) as v from gist_ios_par) s; + count | sum +-------+---------- + 10000 | 50005000 +(1 row) + +-- Also through a Gather Merge, which sorts the workers' rows by a column +-- the index doesn't lead with. +create table gist_ios_gm (k int, i int); +insert into gist_ios_gm select g % 97, g from generate_series(1, 20000) g; +create index on gist_ios_gm (i, gist_ios_restricted(i)) include (k); +vacuum analyze gist_ios_gm; +explain (verbose, costs off) +select k, gist_ios_restricted(i) from gist_ios_gm where i > 100 order by k; + QUERY PLAN +------------------------------------------------------------------------------------------------------------ + Gather Merge + Output: k, (gist_ios_restricted(i)) + Workers Planned: 2 + -> Sort + Output: k, i, (gist_ios_restricted(i)) + Sort Key: gist_ios_gm.k + -> Parallel Index Only Scan using gist_ios_gm_i_gist_ios_restricted_i_k_idx on public.gist_ios_gm + Output: k, i, (gist_ios_restricted(i)) + Index Cond: (gist_ios_gm.i > 100) +(9 rows) + +select count(*) filter (where v = i) as same, count(*) as total + from (select k, i, gist_ios_restricted(i) as v from gist_ios_gm + where i > 100 order by k) s; + same | total +-------+------- + 19900 | 19900 +(1 row) + +drop table gist_ios_gm; +-- And through a Parallel Append over partitions that all have the index. +-- (Different sizes, so that the children's cost order, which is the plan's +-- order, doesn't depend on how many pages VACUUM could mark all-visible +-- when other sessions hold snapshots.) +create table gist_ios_ppart (i int) partition by range (i); +create table gist_ios_ppart1 partition of gist_ios_ppart + for values from (1) to (2001); +create table gist_ios_ppart2 partition of gist_ios_ppart + for values from (2001) to (10001); +insert into gist_ios_ppart select g from generate_series(1, 10000) g; +create index on gist_ios_ppart (i, gist_ios_restricted(i)); +vacuum analyze gist_ios_ppart; +explain (verbose, costs off) +select gist_ios_restricted(i) from gist_ios_ppart; + QUERY PLAN +----------------------------------------------------------------------------------------------------------------------------------- + Gather + Output: (gist_ios_restricted(gist_ios_ppart.i)) + Workers Planned: 2 + -> Parallel Append + -> Parallel Index Only Scan using gist_ios_ppart2_i_gist_ios_restricted_i_idx on public.gist_ios_ppart2 gist_ios_ppart_2 + Output: gist_ios_ppart_2.i, (gist_ios_restricted(gist_ios_ppart_2.i)) + -> Parallel Index Only Scan using gist_ios_ppart1_i_gist_ios_restricted_i_idx on public.gist_ios_ppart1 gist_ios_ppart_1 + Output: gist_ios_ppart_1.i, (gist_ios_restricted(gist_ios_ppart_1.i)) +(8 rows) + +select count(*), sum(v) from (select gist_ios_restricted(i) as v from gist_ios_ppart) s; + count | sum +-------+---------- + 10000 | 50005000 +(1 row) + +drop table gist_ios_ppart; +reset enable_seqscan; +reset enable_bitmapscan; +reset parallel_setup_cost; +reset parallel_tuple_cost; +reset min_parallel_index_scan_size; +reset max_parallel_workers_per_gather; +reset parallel_leader_participation; +drop table gist_ios_par; +drop function gist_ios_restricted(int); -- A partitioned table whose partitions all have the index: each child reads -- the value and the Append passes it up, even when a partition's columns -- are in a different order. @@ -1064,6 +1244,28 @@ select gist_ios_costly(p.i), d.tag from gist_ios_part p reset enable_mergejoin; reset enable_nestloop; +-- An ordered Append passes the value up as well, here into a merge join. +set enable_hashjoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_costly(p.i), d.tag from gist_ios_part p + join gist_ios_dim d on d.i = p.i order by p.i; + QUERY PLAN +-------------------------------------------------------------------------------------------------------- + Merge Join + Output: (gist_ios_costly(p.i)), d.tag, p.i + Merge Cond: (p.i = d.i) + -> Append + -> Index Only Scan using gist_ios_part1_i_gist_ios_costly_i_idx on pg_temp.gist_ios_part1 p_1 + Output: p_1.i, (gist_ios_costly(p_1.i)) + -> Index Only Scan using gist_ios_part2_i_gist_ios_costly_i_idx on pg_temp.gist_ios_part2 p_2 + Output: p_2.i, (gist_ios_costly(p_2.i)) + -> Index Scan using gist_ios_dim_i_idx on pg_temp.gist_ios_dim d + Output: d.i, d.tag +(10 rows) + +reset enable_hashjoin; +reset enable_nestloop; -- An index created on only one partition doesn't count: every child must -- emit the value for the Append to. create temp table gist_ios_part_x (i int) partition by range (i); diff --git a/src/test/regress/sql/gist.sql b/src/test/regress/sql/gist.sql index ddd7001346edf..9c62f38ddce8c 100644 --- a/src/test/regress/sql/gist.sql +++ b/src/test/regress/sql/gist.sql @@ -508,12 +508,104 @@ select gist_ios_costly(w.i), d.tag from gist_ios_wide w reset enable_mergejoin; reset enable_nestloop; drop table gist_ios_wide; --- But not from the nullable side of an outer join: there the query's Var --- differs from the index's (it may be null), so the value read below the --- join isn't the one asked for. +-- Also from the nullable side of an outer join, because the function is +-- strict: a null-extended row gets NULL for the value, which is what the +-- function returns for a NULL argument. The value is used inside a larger, +-- non-strict expression above the join. +set enable_hashjoin = off; +set enable_nestloop = off; explain (verbose, costs off) select coalesce(gist_ios_costly(a.i), -1) from gist_ios_dim d left join gist_ios_cost a on a.i = d.i * 2; +reset enable_hashjoin; +reset enable_nestloop; +-- And from both sides' nullable rows of a FULL JOIN. +set enable_hashjoin = off; +explain (verbose, costs off) +select gist_ios_costly(a.i), d.tag from gist_ios_dim d + full join gist_ios_cost a on a.i = d.i * 2; +reset enable_hashjoin; +select count(*) as total, count(v) as with_value, + count(*) filter (where v = i) as same + from (select gist_ios_costly(a.i) as v, a.i from gist_ios_dim d + full join gist_ios_cost a on a.i = d.i * 2) s; +-- A function that isn't strict may return something else for NULL, so it's +-- computed above the join. +create function gist_ios_nonstrict(int) returns int + language plpgsql immutable called on null input cost 100000 + as $$ begin return coalesce($1, -1); end $$; +create index on gist_ios_cost (i, gist_ios_nonstrict(i)); +vacuum analyze gist_ios_cost; +set enable_mergejoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_nonstrict(a.i) from gist_ios_dim d + left join gist_ios_cost a on a.i = d.i * 2; +reset enable_mergejoin; +reset enable_nestloop; +select count(*) filter (where v = -1) as null_extended + from (select gist_ios_nonstrict(a.i) as v from gist_ios_dim d + left join gist_ios_cost a on a.i = d.i * 2) s; +drop function gist_ios_nonstrict(int) cascade; +-- A parallel-restricted function: the value is read from the index inside +-- the workers, not evaluated there, and passed up through the Gather. (Not +-- a temp table, which can't be scanned in parallel.) +create function gist_ios_restricted(int) returns int + language plpgsql immutable strict parallel restricted cost 100000 + as $$ begin return $1; end $$; +create table gist_ios_par (i int); +insert into gist_ios_par select g from generate_series(1, 10000) g; +create index on gist_ios_par (i, gist_ios_restricted(i)); +vacuum analyze gist_ios_par; +set parallel_setup_cost = 0; +set parallel_tuple_cost = 0; +set min_parallel_index_scan_size = 0; +set max_parallel_workers_per_gather = 2; +set parallel_leader_participation = off; +-- (Pin the scan type: these tables aren't temp, so VACUUM may not get to +-- mark them all-visible while other sessions hold snapshots.) +set enable_seqscan = off; +set enable_bitmapscan = off; +explain (verbose, costs off) +select gist_ios_restricted(i) from gist_ios_par; +select count(*), sum(v) from (select gist_ios_restricted(i) as v from gist_ios_par) s; +-- Also through a Gather Merge, which sorts the workers' rows by a column +-- the index doesn't lead with. +create table gist_ios_gm (k int, i int); +insert into gist_ios_gm select g % 97, g from generate_series(1, 20000) g; +create index on gist_ios_gm (i, gist_ios_restricted(i)) include (k); +vacuum analyze gist_ios_gm; +explain (verbose, costs off) +select k, gist_ios_restricted(i) from gist_ios_gm where i > 100 order by k; +select count(*) filter (where v = i) as same, count(*) as total + from (select k, i, gist_ios_restricted(i) as v from gist_ios_gm + where i > 100 order by k) s; +drop table gist_ios_gm; +-- And through a Parallel Append over partitions that all have the index. +-- (Different sizes, so that the children's cost order, which is the plan's +-- order, doesn't depend on how many pages VACUUM could mark all-visible +-- when other sessions hold snapshots.) +create table gist_ios_ppart (i int) partition by range (i); +create table gist_ios_ppart1 partition of gist_ios_ppart + for values from (1) to (2001); +create table gist_ios_ppart2 partition of gist_ios_ppart + for values from (2001) to (10001); +insert into gist_ios_ppart select g from generate_series(1, 10000) g; +create index on gist_ios_ppart (i, gist_ios_restricted(i)); +vacuum analyze gist_ios_ppart; +explain (verbose, costs off) +select gist_ios_restricted(i) from gist_ios_ppart; +select count(*), sum(v) from (select gist_ios_restricted(i) as v from gist_ios_ppart) s; +drop table gist_ios_ppart; +reset enable_seqscan; +reset enable_bitmapscan; +reset parallel_setup_cost; +reset parallel_tuple_cost; +reset min_parallel_index_scan_size; +reset max_parallel_workers_per_gather; +reset parallel_leader_participation; +drop table gist_ios_par; +drop function gist_ios_restricted(int); -- A partitioned table whose partitions all have the index: each child reads -- the value and the Append passes it up, even when a partition's columns -- are in a different order. @@ -533,6 +625,14 @@ select gist_ios_costly(p.i), d.tag from gist_ios_part p join gist_ios_dim d on d.i = p.i; reset enable_mergejoin; reset enable_nestloop; +-- An ordered Append passes the value up as well, here into a merge join. +set enable_hashjoin = off; +set enable_nestloop = off; +explain (verbose, costs off) +select gist_ios_costly(p.i), d.tag from gist_ios_part p + join gist_ios_dim d on d.i = p.i order by p.i; +reset enable_hashjoin; +reset enable_nestloop; -- An index created on only one partition doesn't count: every child must -- emit the value for the Append to. create temp table gist_ios_part_x (i int) partition by range (i); diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index ad69854ccd484..702b19b97be1f 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -4223,6 +4223,7 @@ regexp regexp_matches_ctx registered_buffer regproc +relabel_context relopt_bool relopt_enum relopt_enum_elt_def