Skip to content

perf(predicate): evaluate IN and NOT IN with arrow::compute::is_in - #265

Open
lucasfang wants to merge 4 commits into
apache:mainfrom
lucasfang:dev10
Open

perf(predicate): evaluate IN and NOT IN with arrow::compute::is_in#265
lucasfang wants to merge 4 commits into
apache:mainfrom
lucasfang:dev10

Conversation

@lucasfang

@lucasfang lucasfang commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Purpose

Linked issue: close #262

MultiLiteralsLeafFunction::Test evaluates an IN / NOT IN predicate by materializing the whole column into Literal objects, one heap allocation per row, and then comparing every row against every literal. That is O(rows × literals) comparisons plus O(rows) allocations per batch, and with the large IN lists that get pushed down from SQL it becomes a measurable part of scan time even though membership testing only needs a set lookup.

This PR builds the literals into an arrow array once per batch and probes the whole column with arrow::compute::is_in, which turns the evaluation into O(literals) of setup plus an O(rows) hash probe with no per-row allocation. Every LeafFunction is a shared stateless singleton, so the value set cannot be cached on the function; building it per batch is what buys the vectorized probe.

Changes:

  • LiteralConverter::ConvertLiteralsToArray is added as the reverse of the existing ConvertLiteralsFromArray. It writes BOOLEAN, TINYINT, SMALLINT, INT, BIGINT, FLOAT, DOUBLE, DATE, STRING, BINARY, DECIMAL and TIMESTAMP, keeps a null literal as an arrow null so the result has exactly one entry per literal, and returns Status::Invalid for any other field type. For DECIMAL the arrow type carries a precision and a scale that a FieldType alone does not settle, so the array is written with the pair the literals carry themselves: at least one literal has to be non null to settle it, and every non null one has to carry the same pair, because rescaling a value to another scale loses digits or overflows. The type is built with arrow::Decimal128Type::Make, since arrow::decimal128 checks the precision fatally. For TIMESTAMP the array is written with the finest time unit that keeps every value (nanosecond if any nanos-of-millisecond is not a whole microsecond, microsecond if any is non zero, millisecond otherwise), which again needs one non null literal to settle.
  • MultiLiteralsLeafFunction::Test moves out of the header into a new multi_literals_leaf_function.cpp. It takes the value set path for the field types listed above; FLOAT and DOUBLE join it except for a NaN literal, because arrow hashes the raw bits of a float while FieldsComparator::CompareFloatingPoint makes every NaN equal. DECIMAL probes only a column carrying the scale of the literals, because is_in compares a value set against a column of another scale by casting one side to the other, and that cast fails on a value that does not fit the other scale where Literal::CompareTo rescales one value at a time and merely finds no match. TIMESTAMP probes only a column of the very unit the values need and without a time zone, because casting to a coarser unit fails on a value the unit does not keep, casting to a finer one can overflow int64, and is_in refuses to compare a zoned timestamp against an unzoned one while a literal has no zone to speak of.
  • SetLookupOptions::EMIT_NULL gives the exact null semantics the predicate already has: the nulls of the value set are ignored, which is what IN does with a null literal, and a null input row becomes a null output row, which both IN and NOT IN report as false.
  • The row-by-row path is kept for everything the probe does not cover: the field types above when the column parameter does not match, literals of mixed types, which must keep reporting the Literal::CompareTo error rather than being built into one typed value set, and NOT IN holding a null literal, which is false for every row anyway. The fallback compares by value, so a decimal literal of another scale or a timestamp literal of another unit still matches the same number or instant.

Beyond the performance change this also fixes an evaluation failure: ConvertLiteralsFromArray rejects several dictionary layouts, for example int32 indices over an int64 dictionary or int8 indices over a utf8 dictionary, and an IN predicate over such a column used to fail the whole evaluation. is_in decodes the dictionary itself, so those columns now evaluate normally. It also promotes both sides to their common type when the column is read as a wider or narrower arrow type than the value set, and only reports a mismatch when the field type genuinely disagrees with the column.

Tests

New unit tests in src/paimon/common/predicate/multi_literals_leaf_function_test.cpp, each asserting both IN and NOT IN results:

  • Value set path per field type: TestInt, TestBigInt, TestInt64Boundaries, TestTinyIntAndSmallInt, TestDate, TestBoolean, TestString, TestStringWithoutEmptyLiteral, TestBinary, TestFloatAndDouble, TestNegativeZeroDoesNotMatchPositiveZero
  • Decimal and timestamp value sets: TestDecimal including a column of the same scale but another precision and values beyond 64 bits, TestTimestamp for the millisecond and the nanosecond unit, TestDecimalNullLiterals and TestTimestampNullLiterals for null literals in the set and for nothing but null literals
  • Fallback parity with the row-by-row path: TestDecimalOffTheValueSetPath for another scale, mixed precision and scale, and the scale pair where is_in would have failed the cast, TestTimestampOffTheValueSetPath for a coarser unit, a finer unit and a column with a time zone, TestNanLiteralStaysOffTheValueSetPath
  • Dictionary columns: TestDictionaryString, TestLargeStringDictionary, TestDictionaryWithNullValue
  • Null and empty handling: TestNullLiteralIgnoredForIn, TestOnlyNullLiterals, TestEmptyLiterals
  • Error parity: TestMixedLiteralTypesReportTheError
  • Type resolution: TestProbeResolvesArrowTypeOnItsOwn, which also pins the dictionary layouts that ConvertLiteralsFromArray rejects, and TestProbeFailsOnUnrelatedArrowType
  • TestSlicedArray for sliced primitive and string arrays

New unit tests in src/paimon/common/predicate/literal_converter_test.cpp:

  • TestLiteralsToArray: every writable field type, BOOLEAN, TINYINT, SMALLINT, INT, BIGINT, FLOAT, DOUBLE, DATE, STRING, BINARY, DECIMAL and TIMESTAMP with one case per millisecond, microsecond and nanosecond unit, each with a null literal in the middle, asserting the arrow array and that it reads back through ConvertLiteralsFromArray as the literals it was built from
  • TestLiteralsToArrayWithoutValue: empty input, and input that is all nulls
  • TestLiteralsToArrayUnsupportedType: TIMESTAMP and DECIMAL without a non null literal, decimal literals that do not share one precision and scale, a decimal precision arrow rejects instead of aborting, and BLOB, ARRAY, MAP, STRUCT and UNKNOWN all report the field type in the error

New end-to-end tests in src/paimon/common/predicate/predicate_test.cpp:

  • TestLargeStringIn: a 1000-literal STRING IN / NOT IN over an array that includes the empty string and a null
  • TestInAfterRebind: rebinding by field name and by field index keeps results identical
  • TestInt64BoundaryIn: IN / NOT IN with INT64_MIN and INT64_MAX through PredicateBuilder

Verified with the full paimon-common-test suite: 1537 tests from 178 test suites, all passed, plus the wider regression paimon-parquet-format-test (202 tests), paimon-orc-format-test (119 tests) and paimon-core-test (1863 tests), whose existing decimal and timestamp IN / NOT IN cases now run through the new value set path.

API and Format

No. No header under include/ is touched, and neither the storage format nor the protocol changes. LiteralConverter gains one static method, which is purely additive.

Documentation

No. This is a performance optimization, plus the dictionary-column fix described above, with no user-visible API or configuration change.

Generative AI tooling

Generated-by: Qoder

@lucasfang
lucasfang marked this pull request as draft August 31, 2026 09:16
@lucasfang
lucasfang force-pushed the dev10 branch 2 times, most recently from 4ec1e48 to d78d924 Compare August 31, 2026 10:12
@lucasfang lucasfang changed the title Dev10 perf(predicate): evaluate IN and NOT IN with arrow::compute::is_in Sep 1, 2026
@lucasfang
lucasfang marked this pull request as ready for review September 1, 2026 06:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Optimize IN and NOT IN predicate evaluation with a dedicated literal lookup set

2 participants