Skip to content

Store Normalized validity on the array instead of in its children - #9202

Draft
connortsui20 wants to merge 1 commit into
developfrom
ct/fix-normalized
Draft

Store Normalized validity on the array instead of in its children#9202
connortsui20 wants to merge 1 commit into
developfrom
ct/fix-normalized

Conversation

@connortsui20

@connortsui20 connortsui20 commented Aug 5, 2026

Copy link
Copy Markdown
Member

Rationale for this change

Normalized derived its validity by anding its two children's, which made the array's nullability a function of two independently nullable children. Every dtype-widening defect in #9200 follows from that shape: the decode paths and the read-through operators each had to get from a child's nullability to the parent's, and each got it wrong in a different case.

What changes are included in this PR?

Nulls move onto the array. Both children become non-nullable, normalize zeroes them at null positions, and a validity slot is what makes the array's dtype nullable, so nothing widens a child's dtype any more. That removes the panic on doubly-nullable children and lets L2Norm's read-through reattach the array's null map rather than assert on a mismatched dtype. The other three fixes are independent: the zero-norm rule is checked in both directions, NormalizedScheme::matches requires a float element ptype so an i32 tensor falls through instead of aborting compression, and the constant-norms identity path requires a norm of exactly 1.0 so scalar_at cannot disagree with a bulk decode. Two consequences worth review: validate_normalized_rows no longer consults validity, so try_new rejects raw children paired with a mask unless their null positions are already zeroed, and the validity slot is passed through uncompressed like FixedSizeListArray validity, which is a small size regression for nullable columns.

What APIs are changed? Are there any user-facing changes?

All of this sits behind unstable_encodings, and vortex.tensor.normalized belongs to the unstable 2026-04 edition, so the serialized layout change carries no compatibility obligation. try_new and new_unchecked take a Validity and require non-nullable children, NormalizedMetadata is removed because the parent dtype and child count now carry everything it held, and validate_l2_normalized_rows_against_norms is renamed to validate_normalized_rows.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EC8wGdby4GdyxR5J2YrnVi

Closes #9200

`Normalized` derived its validity by `and`ing its two children's, which
made the parent's nullability a function of two independently nullable
children. That is what every dtype-widening bug in the encoding came
from: the decode paths and the read-through operators each had to get
from a child's nullability to the parent's, and each got it wrong in a
different case.

Nulls now live on the array. Both children are non-nullable, `normalize`
zeroes them at null positions, and the array carries a `validity` slot
whose presence is what makes its dtype nullable. Neither the decode path
nor a read-through operator has to widen a child's dtype any more, and
`validity()` is a match on a slot rather than a `Binary(And)` array that
has to be built and optimized on every call.

## Fixes

- Both children nullable plus a non-unit constant norm panicked with
  `Tried to create an `ExtensionArray` with an incompatible storage
  array`: the dtype guard on the constant path caught a nullable norms
  child paired with a non-nullable normalized child, but not the case
  where both were nullable, and the multiply then widened the FSL
  elements. The guard is gone along with the state it guarded against.
- `L2Norm`'s read-through asserted exact dtype equality against a
  `norm_dtype` it took from the parent, so it failed on any column whose
  nullability came from the `normalized` child. It now reattaches the
  array's null map to the non-nullable norms child.
- `try_new` accepted an all-zero row paired with a non-zero stored norm.
  That decodes to zeros while `L2Norm` reads the stored norm straight
  back, so the split it promises is lossless was not. The zero-norm rule
  is now checked in both directions.
- `NormalizedScheme::matches` claimed any `AnyTensor` extension, but
  `compress` gates on a float element ptype. Since the scheme reports
  `AlwaysUse`, an `i32` tensor column was claimed and then aborted the
  whole column's compression instead of falling through to another
  scheme. `matches` now requires a float element ptype.

## Also

- `NormalizedMetadata` is removed. It existed only because the parent's
  unioned nullability could not say which child was nullable; with
  non-nullable children, the parent dtype and the child count carry
  everything, so the array serializes no metadata.
- The constant-norms identity path now requires a norm of exactly `1.0`.
  Skipping the multiply for a merely near-unit norm left `scalar_at` --
  which routes every row through that path -- answering differently than
  a bulk decode of the same column.
- `validate_l2_normalized_rows_against_norms` is renamed to
  `validate_normalized_rows`, and no longer takes validity into account:
  a zeroed null row satisfies both directions of the zero-norm rule on
  its own.
- Fixes "An `Normalized`" in the eight places the rename left it, and
  restores the alphabetical order of the 2026-04 edition's `added` list.

## Checks

| Check | Result |
| --- | --- |
| `cargo test -p vortex-tensor` | 180 passed |
| `cargo test -p vortex --features unstable_encodings` | 26 passed |
| `cargo test -p vortex-file --features unstable_encodings` | 133 passed |
| `cargo test -p vortex-btrblocks -p vortex-compressor` | 93 passed, 1 skipped |
| `cargo clippy -p vortex-tensor -p vortex -p vortex-bench --all-targets --features vortex/unstable_encodings` | clean |
| `cargo clippy -p vortex-tensor --all-targets --all-features` | clean |
| `cargo +nightly fmt --all` | clean |
| `cargo test --doc -p vortex-tensor -p vortex` | clean |

Each of the four functional fixes was confirmed to reproduce before the
change, and each regression test was confirmed to fail when its fix
alone is reverted.

Not run: workspace-wide `cargo clippy --all-features` (the `vortex-cuda`
build script needs to download nvCOMP, which this sandbox cannot reach),
Python/Java bindings, docs.

Signed-off-by: Connor Tsui <connor@spiraldb.com>
@connortsui20
connortsui20 marked this pull request as draft August 5, 2026 16:17
@connortsui20 connortsui20 added the changelog/fix A bug fix label Aug 5, 2026
@codspeed-hq

codspeed-hq Bot commented Aug 5, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 19.92%

⚡ 1 improved benchmark
✅ 1829 untouched benchmarks
⏩ 43 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation fsl_large 253.2 µs 211.2 µs +19.92%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing ct/fix-normalized (1e038aa) with develop (3d0c124)

Open in CodSpeed

Footnotes

  1. 43 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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

Labels

changelog/fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix Normalized encoding existing issues

2 participants