Visitar URL original
scalars/mappings performance improvement. by ollz272 · Pull Request #13415 · sqlalchemy/sqlalchemy · GitHub
Skip to content

scalars/mappings performance improvement. - #13415

Open
ollz272 wants to merge 6 commits into
sqlalchemy:mainfrom
ollz272:perf/core-scalars-mappings-row-elision
Open

ollz272 wants to merge 6 commits into
sqlalchemy:mainfrom
ollz272:perf/core-scalars-mappings-row-elision

Conversation

@ollz272

@ollz272 ollz272 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Description

What changed
Core .scalars() built a one-column Row per row only to unwrap it with itemgetter(0); .mappings() built a Row plus a second BaseRow (RowMapping) via attrgetter("_mapping").

New class-level _fetch_shape hint (_SHAPE_ROW / _SHAPE_SCALAR / _SHAPE_MAPPING) on BaseResultInternal; _row_getter emits direct getters: raw_row[index] with the column's result processor hoisted (scalar shape), or RowMapping constructed directly (mapping shape).
_configure_scalar_row_shape / _configure_mapping_row_shape helpers in result.py, called from init and unique() — the only pre-fetch mutation points. Mirrored in AsyncScalarResult / AsyncMappingResult.
Fast path gates OFF (legacy Row path preserved verbatim) when: _row_logging_fn present (echo="debug" must log real Rows); caller-supplied unique(strategy); metadata _create_unique_filters (scalars); any uniquing at all (mappings — RowMapping has Mapping equality, not Row-data equality). Default .scalars().unique() keeps the fast path: Row hash/eq delegate to the data tuple, so deduping raw values is equivalent.
_raw_all_tuples() now also asserts the default shape, keeping the ORM position-based path strict.

Performance (10k rows, in-memory SQLite, min-of-30, -O3 verified)

case main branch Δ
.scalars() int col 2.645 ms 2.096 ms −20.8%
.scalars() w/ processor 3.461 ms 2.778 ms −19.7%
.scalars(2) of 3 cols 3.779 ms 3.200 ms −15.3%
.scalars().unique() 5.664 ms 2.134 ms −62.3%
.mappings() 5 cols 9.193 ms 5.426 ms −41.0%
plain .all() (control) 5.475 ms 5.379 ms −1.8% (noise)

The .unique() win comes from hashing raw values instead of Row objects.

Testing

33 new unit tests (test/base/test_result.py::RowShapeElisionTest, incl.
asyncio twins) + 10 cursor-level integration tests
(test/sql/test_resultset_elision.py) covering processors, echo="debug"
fallback, uniquing fallbacks, partitions, freeze/replay. Suite green
compiled AND uncompiled (pure-python _result_cy).

Benchmark script: bench_core_result_filters.py
"""Micro-benchmark: Core .scalars().all() / .mappings().all() vs
plain .all(), 10k rows, in-memory SQLite, min-of-rounds.

Run identically on `main` and on the branch (rebuild the extensions
at -O3 after each checkout!). Compare the `min ms` column only —
the machine is noisy.
"""

import gc
import statistics
import time

from sqlalchemy import Column
from sqlalchemy import create_engine
from sqlalchemy import Integer
from sqlalchemy import MetaData
from sqlalchemy import select
from sqlalchemy import String
from sqlalchemy import Table
from sqlalchemy.types import TypeDecorator


class UpperString(TypeDecorator):
    impl = String(64)
    cache_ok = True

    def process_result_value(self, value, dialect):
        return value.upper() if value is not None else None


N_ROWS = 10_000

engine = create_engine("sqlite://")
metadata = MetaData()
t = Table(
    "bench",
    metadata,
    Column("id", Integer, primary_key=True, autoincrement=False),
    Column("a", Integer),
    Column("b", Integer),
    Column("name", String(64)),
    Column("deco", UpperString()),
)
metadata.create_all(engine)
with engine.begin() as conn:
    conn.execute(
        t.insert(),
        [
            {
                "id": i,
                "a": i % 7,
                "b": i * 2,
                "name": "name-%d" % i,
                "deco": "deco-%d" % i,
            }
            for i in range(N_ROWS)
        ],
    )

CASES = {}


def case(fn):
    CASES[fn.__name__] = fn
    return fn


@case
def scalars_int(conn):
    return conn.execute(select(t.c.a)).scalars().all()


@case
def scalars_processor(conn):
    return conn.execute(select(t.c.deco)).scalars().all()


@case
def scalars_index_2(conn):
    return conn.execute(select(t.c.id, t.c.a, t.c.name)).scalars(2).all()


@case
def scalars_unique(conn):
    return conn.execute(select(t.c.a)).scalars().unique().all()


@case
def mappings_5col(conn):
    return conn.execute(select(t)).mappings().all()


@case
def rows_5col_baseline(conn):
    return conn.execute(select(t)).all()


def run(repeats=30, warmup=3):
    import sqlalchemy
    from sqlalchemy.engine import _result_cy

    print(
        "sqlalchemy %s  compiled=%s"
        % (sqlalchemy.__version__, _result_cy._is_compiled())
    )
    assert _result_cy._is_compiled(), (
        "extensions not compiled - rebuild before benchmarking"
    )
    results = {}
    with engine.connect() as conn:
        for name, fn in CASES.items():
            for _ in range(warmup):
                fn(conn)
            timings = []
            for _ in range(repeats):
                gc.collect()
                t0 = time.perf_counter()
                fn(conn)
                timings.append(time.perf_counter() - t0)
            results[name] = timings
    print("%-24s%10s%12s" % ("case", "min ms", "median ms"))
    for name, timings in results.items():
        print(
            "%-24s%10.3f%12.3f"
            % (
                name,
                min(timings) * 1000,
                statistics.median(timings) * 1000,
            )
        )


if __name__ == "__main__":
    run()

Checklist

This pull request is:

  • A documentation / typographical / small typing error fix
    • Good to go, no issue or tests are needed
  • A short code fix
    • please include the issue number, and create an issue if none exists, which
      must include a complete example of the issue. one line code fixes without an
      issue and demonstration will not be accepted.
    • Please include: Fixes: #<issue number> in the commit message
    • please include tests. one line code fixes without tests will not be accepted.
  • A new feature implementation
    • please include the issue number, and create an issue if none exists, which must
      include a complete example of how the feature would look.
    • Please include: Fixes: #<issue number> in the commit message
    • please include tests.

Have a nice day!

ollz272 added 6 commits July 6, 2026 15:15
- annotate the intentional Row -> RowMapping class swap with
  type: ignore[assignment]; RowMapping shares BaseRow's constructor
  signature so the swap is runtime-safe
- update test_fetch_by_key_mappings sqlite call counts to the values
  observed on x86_64_linux CI (mappings rows no longer construct an
  intermediate Row + second BaseRow, so counts drop); other-dialect
  entries for this test remain stale and need --write-profiles on
  their platforms
@zzzeek
zzzeek requested a review from CaselIT July 7, 2026 18:58
@zzzeek

zzzeek commented Jul 19, 2026

Copy link
Copy Markdown
Member

@CaselIT any time to look at this one?

@CaselIT

CaselIT commented Jul 20, 2026

Copy link
Copy Markdown
Member

it's on my list sure

"""
self._unique_filter_state = (set(), strategy)
if not self._real_result._source_supports_scalars:
_configure_scalar_row_shape(self)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_row_getter may already be memoized here if the caller consumed a row before calling unique(). In that case the new shape is ignored: a scalar custom strategy still receives an int (and can raise TypeError), while the mapping path still receives RowMapping; either invalidate the memoized getter when changing shape or keep the existing shape once fetching has started.

@zzzeek zzzeek added the performance where performance can be improved. add "bug" only if it's a performance degradation label Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance where performance can be improved. add "bug" only if it's a performance degradation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants