Skip to content

feat(array): min/max for fixed-size lists, so UUID columns get zone-map stats - #10457

Open
XiangpengHao wants to merge 7 commits into
vortex-data:developfrom
XiangpengHao:xiangpeng/fsl-min-max
Open

XiangpengHao wants to merge 7 commits into
vortex-data:developfrom
XiangpengHao:xiangpeng/fsl-min-max

Conversation

@XiangpengHao

Copy link
Copy Markdown
Contributor

Summary

vortex.uuid is stored as FixedSizeList(U8, 16), and the min/max aggregate skips fixed-size lists as a stop-gap (#8243, for #8189). UUID zone maps and file footers therefore keep only null counts, and uuid_col = x prunes nothing, which hurts sorted UUIDv7 keys most. This implements min/max for fixed-size lists, so UUID columns get the same zone-map and footer statistics, and the same pruning, as Parquet's UUID logical type.

Changes

  • min/max for FixedSizeList (aggregate_fn/fns/min_max/fixed_size_list.rs): lists of fixed-width elements (bool, primitive, decimal), ordered lexicographically by the compare kernel's own row comparator (compare/nested.rs), so statistics and comparisons always agree. Null lists are skipped, null elements order first, floats use total order. Non-nullable u8 lists (the UUID case) compare as byte slices, the same order as Postgres, DuckDB and Parquet.
  • Size cap: lists wider than MAX_FIXED_SIZE_LIST_MIN_MAX_BYTES (64 bytes per value) get no min/max, so embedding-style columns don't grow zone maps for pruning they can't use. The result is the same for every encoding.
  • vortex.uuid gets min/max through its storage; no UUID-specific code.
  • DuckDB COPY … RETURN_STATS converts UUID bounds to UUID and fixed-size-list bounds to ARRAY instead of failing on them; the JNI writer converts them to java.util.UUID and List.
  • Tests: vortex/tests/uuid_pruning.rs (a UUID column over four zones; uuid = x keeps only the zone that can hold it, footer min/max, can_prune), fixed_size_list::tests (ordering vs Scalar::partial_cmp and compare, nulls, floats, sliced and dictionary inputs, the cap at its boundary), vortex-uuid min/max order, DuckDB conversion tests and copy_return_stats.slt. JNIWriterTest.testUuidAndFixedSizeListBounds is added but was not run locally (no JDK).

API Changes

Files written with this change carry min/max for UUID and narrow fixed-size-list columns. Engines that receive file statistics (DuckDB RETURN_STATS, the JNI writer's column statistics) now see bounds for those columns.

…ap stats

`vortex.uuid` is stored as `FixedSizeList(U8, 16)`, and the min/max
aggregate skipped fixed-size lists as a stop-gap (vortex-data#8243), so UUID zone
maps and footers kept only null counts and `uuid = x` pruned nothing.

Compute min/max for fixed-size lists of fixed-width elements (bool,
primitive, decimal), ordered by the compare kernel's own row comparator
so statistics and comparisons always agree. Non-nullable byte lists such
as UUIDs compare as byte slices. Lists wider than 64 bytes per value get
none, so embedding-style columns do not grow zone maps.

DuckDB's COPY RETURN_STATS and the JNI writer now convert UUID and
fixed-size-list bounds instead of failing on them.

Signed-off-by: XiangpengHao <xiangpeng@spiraldb.com>
@XiangpengHao XiangpengHao added the changelog/feature A new feature label Oct 12, 2026
Signed-off-by: XiangpengHao <xiangpeng@spiraldb.com>
@codspeed

codspeed Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Merging this PR will degrade performance by 9.13%

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

⚠️ 2 benchmarks measured no execution time

Nothing ran under measurement, usually because the compiler removed the code under test. These results are not comparable, so they count as unchanged.

Preventing compiler optimizations

⚠️ 14 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 3 improved benchmarks
❌ 9 regressed benchmarks
✅ 2247 untouched benchmarks
🗄️ 31 archived benchmarks run1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ WallTime threshold_neon[8] 1.4 µs 2 µs -26.01%
❌ WallTime filter_neon[8] 869 ns 1,140 ns -23.77%
❌ WallTime threshold_neon[80] 4.2 µs 5.2 µs -20.08%
❌ WallTime threshold_neon[64] 2.6 µs 3.2 µs -16.86%
❌ Simulation test_scan_filter_to_arrow_array[int_float] 3.5 ms 4.1 ms -13.75%
❌ Simulation test_repeated_scan_filter[int_float] 3.4 ms 3.9 ms -11.11%
❌ WallTime decode_chunked_neon[i64, 524288] 13.2 µs 14.7 µs -10.3%
❌ WallTime filter_neon[64] 1.7 µs 1.9 µs -10.3%
❌ Simulation test_repeated_scan_filter[int] 2.5 ms 2.7 ms -10.22%
⚡ Simulation test_repeated_scan_scalar_at[int_float] 486.7 µs 400.4 µs +21.58%
⚡ ⚠️ Simulation random_i128[0.01] 5.9 µs 5.2 µs +13.31%
⚡ WallTime words_gather_dispatch_avx2[65536] 1.4 µs 1.3 µs +10.87%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing XiangpengHao:xiangpeng/fsl-min-max (dd0aa74) with develop (18aebea)

Open in CodSpeed

Footnotes

  1. 31 benchmarks were run, but are now archived. If they were deleted in another branch, consider rebasing to remove them from the report. Instead if they were added back, click here to restore them. ↩

Comment thread vortex-array/src/aggregate_fn/fns/min_max/mod.rs
… own

Min/max of an extension array is computed on its storage, which is only
meaningful when the type orders like its storage. Add
`ExtVTable::orders_by_storage` (default `false`) and opt in timestamp,
date, time and UUID. Other extension types, such as JSON, geometries,
vectors and unknown foreign types, get no extrema in any encoding.

Signed-off-by: XiangpengHao <xiangpeng@spiraldb.com>
Comment thread vortex-array/src/scalar_fn/fns/binary/compare/nested.rs
/// Statistics such as zone maps and file footers store the extrema of every chunk they cover, so
/// the extrema of wide lists such as embedding vectors would grow them without enabling useful
/// pruning. UUIDs (16 bytes) and small fixed-size vectors fit.
const MAX_FIXED_SIZE_LIST_MIN_MAX_BYTES: usize = 64;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we need to make sure this is respected everywhere - if we execute to Columnar Constant might not obey it thus depending on encoding you end up with different stats

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we want to use BoundedMax/Min here too?

@robert3005 robert3005 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

My clankers are telling me that now we can publish min max stats for degenerate cases where list size is 0. There's a small follow up to publish uuid stats in duckdb.

MinMax capped fixed-size lists at 64 bytes so that wide lists such as
embeddings would not grow stored statistics. That made a general
aggregate refuse to compute some extrema, and the cap had to be kept
consistent across encodings by hand. MinMax now computes any
fixed-size list of fixed-width elements, and the size limit is applied
where statistics are stored, with the byte budget those writers already
use for strings.

`lower_bound`/`upper_bound` now take a `Scalar` and dispatch on its
dtype: strings and binary are truncated as before, and a fixed-size
list, which cannot be shortened without changing its dtype, is kept
whole if it fits and has no bound otherwise. `can_bound_extrema` makes
the same decision from a dtype, following extensions to their storage.

- BoundedMax/BoundedMin reject fixed-size lists wider than `max_bytes`.
- Zone maps record fixed-size lists, and extensions stored as one, with
  BoundedMax/BoundedMin.
- File statistics skip min/max for dtypes that cannot be bounded.
- The flat and CUDA writers share `StatsSetRef::bound_min_max` instead
  of each truncating Utf8/Binary stats.

Signed-off-by: XiangpengHao <xiangpeng@spiraldb.com>
@XiangpengHao

Copy link
Copy Markdown
Contributor Author

Thank you @robert3005 @joseph-isaacs, moved the max cap out, and the length is enforced where the stats are written. the bound applies to min_max’s result, every encoding should get the same stats.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants