Repository navigation
Speed up Array column reads with a Cython row builder - #1138
Open
sebastianbreguel wants to merge 1 commit into
Open
sebastianbreguel wants to merge 1 commit into
sebastianbreguel wants to merge 1 commit into
Conversation
sebastianbreguel
requested review from
joe-clickhouse and
peter-leonov-ch
as code owners
October 31, 2026 17:30
2 tasks done
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reading an
Arraycolumn with the Python codec ends in a Python loop that cuts the flat element list into one list per row:On 1M rows of
Array(UInt64)this loop was the top entry in cProfile forquery,query_npandquery_df, about 0.26 to 0.30 s of a 0.47 s query. #1008 reports the same cost from thequery_npside.This change moves the loop into a
build_array_rows(column, offsets)helper indataconv, with a Cython version indriverc/dataconv.pyx:CLICKHOUSE_CONNECT_USE_C=0nothing changes beyond one function call per Array nesting level.None, nested lists. Offsets past the end of the element list are clamped the same way Python slicing clamps them, so malformed offsets give the same rows with C on or off.Array.read_column_datanow passes the elements to the helper only when they are exactly alist(type(...) is listinstead ofisinstance). The Cython signature takeslist, so a list subclass is copied to a plain list first. No built-in reader returns a list subclass. The check only keeps a custom type from failing in the Cython path.I first tried a pure Python version,
list(map(column.__getitem__, map(slice, starts, ends))). It maderowsabout 26% faster on macOS with 3.12, but about 2% slower with 3.10, so I dropped it. I also tried preallocating the result list in Cython. It was as fast as appending, but on Linux with Python 3.10 its peak RSS moved by -9 to +4 MB depending on the query, while the append version stays within 2 MB ofmain. I kept the append version.Performance
Table:
perf.arr (u64 UInt64, arr Array(UInt64)), 1M rows,arr = range(number % 8)(0 to 7 elements, 3.5M elements in total). Queries:rows:query("SELECT u64, arr FROM perf.arr").result_rowsnp:query_npof the same querydf:query_dfof the same querynested:query(...).result_rowsofSELECT u64, arrayMap(x -> range(x % 4), arr) FROM perf.arr, anArray(Array(UInt64))Setup: ClickHouse 26.3 in Docker from the repo's
docker-compose.yml,compress=False, native codecpython. Each run is a fresh process that connects, runsSELECT 1, then times one query.mainand this PR alternate, 11 runs each, and the order flips every pair. Wall and CPU (time.process_time) cover the query call. Peak RSS isru_maxrss. The Python heap peak is one extratracemallocrun per side.OPENBLAS_NUM_THREADS=1. Host: Apple Silicon. Linux runs usepython:<ver>-bookwormcontainers (aarch64) on the ClickHouse container's Docker network, with the extensions built inside each container. The host load average was 4 to 7 during the runs because other work was running, so I compare medians of interleaved runs and show the range.C extensions on, wall p50 in seconds [min-max], main -> this PR:
Peak RSS p50 in MB, main / this PR, same runs:
The macOS RSS spread within one side is 5 to 15 MB, so the differences of about 1 MB in both directions are noise. On Linux the spread is tighter and the largest increase is 0.6 MB on
dfwith 3.13, with the same min-max range on both sides. The Python heap peak fromtracemallocis the same on both sides in every cell, for example 178.8 MB forrowsand 406.6 MB fornested.C extensions off (
CLICKHOUSE_CONNECT_USE_C=0), wall p50, main / this PR: every cell is within 1.3%, in both directions. For examplerowson macOS 3.12 is 0.372 / 0.377 s, and on Linux 3.13 it is 0.353 / 0.352 s. The pure Python loop is unchanged.Tests
tests/unit_tests/test_driver/test_c_parity.py:test_build_array_rows_parityruns both helpers on offsets given asarray.array("Q")and as a list, the two types the buffers return. It covers empty rows, an empty block, mixed element types, and malformed offsets[2**63, 0, 1].test_build_array_rows_parity_keeps_element_objectschecks that the rows hold the same numpy scalar objects as the input.tests/unit_tests/test_driver/test_native_read.py:test_array_native_round_trip_and_stream_alignmentwrites and readsArray(UInt8),Array(Nullable(String)),Array(LowCardinality(String)),Array(Array(UInt8))andArray(Array(Array(UInt8)))with both buffer classes, then reads a following column to check that the stream stays aligned.test_array_numpy_read_keeps_numpy_scalarschecks that a numpy-context read still returnsnumpy.uint64elements. These pass onmaintoo and pin the existing behavior.Suites, Python 3.12 on macOS, ClickHouse 26.3:
tests/unit_tests: 4933 passed, 957 skipped, withCLICKHOUSE_CONNECT_USE_C=1and=0.tests/integration_tests: 2132 passed, 35 skipped, withCLICKHOUSE_CONNECT_USE_C=1and=0.ruff format --check,ruff checkandmypy: clean.To reproduce, create the table with
INSERT INTO perf.arr SELECT number, range(number % 8) FROM numbers(1000000)and timeclient.query("SELECT u64, arr FROM perf.arr").result_rowsonmainand on this branch with the extensions rebuilt (python setup.py build_ext --inplace).Risk
The new code runs only with the C extensions, on every
Arrayread with the Python codec, including arrays insideTuple,Mapvalues,Nested,VariantandDynamic. The Rust codec is not affected. The main risk is a difference between the Cython and Python helpers. The parity tests cover the input types that the buffers produce. Reverting this commit restores the old loop.Related to #1008. This does not add the flat values plus offsets API discussed there. It only makes the existing list-of-lists result cheaper to build.
Checklist