Skip to content

Store numeric column min/max as primitives instead of boxed Comparables - #19479

Open
xiangfu0 wants to merge 2 commits into
xiangfu0/data-3221-7-slim-metadata-lazy-schemafrom
xiangfu0/data-3221-8-primitive-minmax
Open

Store numeric column min/max as primitives instead of boxed Comparables#19479
xiangfu0 wants to merge 2 commits into
xiangfu0/data-3221-7-slim-metadata-lazy-schemafrom
xiangfu0/data-3221-8-primitive-minmax

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

What

ColumnMetadataImpl held min and max as Comparable references, so a numeric column retained two boxes it never needed. They are now stored as raw bits in two long words and boxed only inside getMinValue()/getMaxValue(); columns with a variable-width stored type keep object references and reuse the same two words for their three element-length values, which is what makes the change shrink the object rather than grow it. FLOAT/DOUBLE go through floatToIntBits/doubleToLongBits so NaN compares equal and -0.0 stays distinct from 0.0, matching Float.equals/Double.equals; a value whose class does not match its stored type falls back to the reference so nothing can be mistranslated. bitsPerElement moves into the flags word, with values outside the encodable range falling back to the existing lazily allocated holder.

Measured with 500k instances sharing one field spec, used-heap delta after forced GC: a nullable INT column goes from 104 to 72 bytes, a STRING column stays at 72, and nothing regresses for multi-value or mixed-shape columns.

Worth knowing: getMinValue()/getMaxValue() on a fixed-width column now return a freshly boxed value per call rather than the same instance. Values stay equal() and of the same class; only reference identity across calls is no longer guaranteed, and no production code depended on it.

Tests

ColumnMetadataImplTest: min and max round-trip for INT, LONG, FLOAT, DOUBLE, BOOLEAN, TIMESTAMP, STRING, JSON, BYTES and BIG_DECIMAL including the min-max-invalid flag and null cases; NaN is canonicalized but stays equal; bitsPerElement round-trips across 11 values including both sides of the encodable boundary and both overflow signs, asserting the flags do not bleed into it.

Why

A server keeps one metadata object graph per (segment, column) for as long as the segment is loaded, so on wide tables the per-column footprint decides how many segments a server can hold. Measured end to end on a 1000-column segment, this series takes the heap retained at load from 4.08 MB to 0.175 MB per segment (4,080 to 174 bytes per column), with a fully compacting collector on both sides. No on-disk format change, the /tables/{table}/segments/{segment}/metadata JSON stays byte-identical, and every public and SPI signature keeps working.

Stack

Part 8 of 9, based on #19478. Review only this part's own commits; the earlier parts account for the rest of the diff.

  1. Allocate ColumnMetadataImpl index sizes lazily and skip the index_map lookup without an index dir #19480 lazy index-size storage
  2. Canonicalize the default null value and intern per-column strings at metadata parse time #19473 canonical default-null values and interned per-column strings
  3. Delegate immutable DataSourceMetadata to ColumnMetadata instead of snapshotting it #19474 delegating immutable DataSourceMetadata
  4. Fold PhysicalColumnIndexContainer's IndexTypeMap into a presence mask and a dense reader array #19475 presence-mask index container
  5. Share segment-derived FieldSpec instances across segments through a weak interner #19476 weak FieldSpec interner
  6. Materialize immutable-segment columns lazily behind an opt-in instance config (default off) #19477 opt-in lazy column materialization
  7. Slim ColumnMetadataImpl to 72 bytes and derive the per-segment Schema lazily #19478 slim ColumnMetadataImpl and lazy per-segment Schema
  8. Store numeric column min/max as primitives instead of boxed Comparables #19479 primitive numeric min/max
  9. Hold segment column metadata in sorted arrays and derive the map on demand #19481 sorted-array column metadata store

xiangfu0 and others added 2 commits September 8, 2026 18:49
…etadataImpl

A server retains one ColumnMetadataImpl per (segment, column) for the segment
lifetime, so a wide external table of 1000 Parquet-backed nullable INT columns
pays for it 1000 times. Two changes take the retained graph from ~104 to ~64
bytes per column, measured by allocating 500k instances with a shared FieldSpec
and diffing the used heap after a forced GC:

- Shared column shape. totalDocs, totalNumberOfEntries, maxNumberOfMultiValues
  and bitsPerElement are not column-distinguishing: build() pins the two MV ints
  to totalDocs and 0 for every SV column, and bitsPerElement is UNAVAILABLE for
  every raw column. They move into an immutable SharedShape behind one ref,
  interned through a weak interner keyed by its own equals/hashCode exactly like
  the existing FIELD_SPEC_INTERNER, so the SV columns of a segment share at most
  ~34 instances however wide it is (one for a raw external table) and segments
  with the same row count share across the JVM.

- Primitive min/max. The min/max of a fixed-width stored type is stored as raw
  bits and boxed only inside getMinValue()/getMaxValue(), which drops ~30 bytes
  and two surviving objects per numeric column. FLOAT/DOUBLE go through
  floatToIntBits/doubleToLongBits so NaN and -0.0 keep comparing exactly as
  Float.equals does. STRING, BYTES, BIG_DECIMAL and COMPLEX min/max stay object
  refs, as does a value whose class is not the box class of its stored type, so
  no Builder caller can lose a value.

The two longs the values need would have grown the object, so they are a union:
a fixed-width column derives its three element lengths from storedType.size()
and its multi-value count, and a var-width column packs those lengths into the
same two words instead of its (object) min/max. Net layout, verified by
measurement: 72 -> 64 bytes, and no column type regresses.

No on-disk format, public signature or REST payload changes: every getter keeps
its signature and value, the segment-metadata JSON is bean-serialized from the
same getter set, and _flags widening from byte to short is private.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… instead

Review of 8eab380 found the SharedShape indirection to be a net regression for
every column whose shape tuple is not shared with another, and to run its
interner on the query path. Both are fixed by dropping it; the primitive min/max
union, which is where the win actually is, stays.

Measured with 500k instances built from one shared FieldSpec, used heap diffed
after a forced GC (SerialGC, two allocation counts so the slope cancels the
constant offset), with the instance size read exactly off the field offsets:

  variant                        instance   raw INT SV    MV INT       SV STRING
                                            (one shape)   (distinct)   (dict)
  parent of 8eab380              72 B          104           101          72
  8eab380 (SharedShape)          64 B           64           140          64
  no shape, four ints inline     80 B           83            83          80
  this commit                    72 B           72            72          72

An unshared shape costs a 32-byte object plus its ~44-byte weak interner entry,
so a multi-value column paid ~36 bytes more than before 8eab380. Simply putting
the four ints back inline pushes the object from 72 to 80 bytes, which instead
regresses every var-width column by 8. Packing bitsPerElement into the flags
word - which widens from short to int and takes the field with it - frees the
int that keeps the object at 72, so no column type regresses against the parent
commit, while a numeric column still sheds its two min/max boxes (104 -> 72).
Against 8eab380 this costs 8 bytes per column on the wide raw-INT external table
it targeted (64 -> 72); that is the price of the multi-value case not costing 76.

bitsPerElement is written by the segment creator as getNumBitsPerValue(...) and
never exceeds Integer.SIZE, but it is read verbatim from metadata.properties and
the Builder is public, so a value outside the 23-bit encodable range falls back
to the Extras holder (whose int fits in the padding its four refs leave) rather
than being truncated.

Builder#build() is not a segment-load-only path: IndexSegment#getDataSource(
String, Schema) rebuilds a virtual column's metadata on every call, so the
interner ran per query per segment. It is gone, and the constraint is now
documented on Builder.

A FLOAT/DOUBLE NaN min/max comes back canonicalized, because floatToIntBits
collapses the payload and signalling bit. That is kept rather than switched to
the raw variants, so equals() on the words keeps agreeing with Float.equals, and
it is now documented on getMinValue() and pinned by a test.

No on-disk format, public signature or REST payload changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xiangfu0
xiangfu0 force-pushed the xiangfu0/data-3221-8-primitive-minmax branch from 2c01e87 to 42fc38a Compare September 9, 2026 01:56
@codecov-commenter

codecov-commenter commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.05556% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.76%. Comparing base (289f839) to head (42fc38a).

Files with missing lines Patch % Lines
...segment/spi/index/metadata/ColumnMetadataImpl.java 93.05% 2 Missing and 3 partials ⚠️
Additional details and impacted files
@@                                 Coverage Diff                                  @@
##             xiangfu0/data-3221-7-slim-metadata-lazy-schema   #19479      +/-   ##
====================================================================================
+ Coverage                                             67.74%   67.76%   +0.02%     
  Complexity                                             1430     1430              
====================================================================================
  Files                                                  3492     3492              
  Lines                                                224887   224935      +48     
  Branches                                              35514    35529      +15     
====================================================================================
+ Hits                                                 152344   152436      +92     
+ Misses                                                60513    60468      -45     
- Partials                                              12030    12031       +1     
Flag Coverage Δ
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-25 67.76% <93.05%> (+0.02%) ⬆️
lane-a 100.00% <ø> (ø)
lane-b 0.00% <ø> (ø)
temurin 67.76% <93.05%> (+0.02%) ⬆️
unittests 67.76% <93.05%> (+0.02%) ⬆️
unittests1 57.76% <93.05%> (+0.01%) ⬆️
unittests2 39.49% <0.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Labels

memory Related to memory usage or optimization performance Related to performance optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants