Skip to content

numpy: return empty results for intersect1d and setxor1d at size 0 - #40231

Open
VaggelisGian wants to merge 4 commits into
jax-ml:mainfrom
VaggelisGian:fix-intersect-setxor-size0
Open

numpy: return empty results for intersect1d and setxor1d at size 0#40231
VaggelisGian wants to merge 4 commits into
jax-ml:mainfrom
VaggelisGian:fix-intersect-setxor-size0

Conversation

@VaggelisGian

Copy link
Copy Markdown

Description

jnp.intersect1d and jnp.setxor1d with a concrete size=0 raise ValueError: zero-size array to reduction operation max which has no identity, because the _intersect1d_size / _setxor1d_size helpers compute their default padding via vals.max() (and .min()) on a zero-size result:

jnp.intersect1d(jnp.arange(5), jnp.array([2]), size=0)   # ValueError
jnp.setxor1d(jnp.arange(5), jnp.array([2]), size=0)      # ValueError
jnp.union1d(jnp.arange(5), jnp.array([2]), size=0)       # fine: Array([], dtype=int32)

The fix guards the padding reductions on vals.size; an empty output has nothing to pad, so the arrays are returned as-is. With an explicit fill_value the size=0 path already worked and is unchanged; behavior at any nonzero size is bit-identical (vals always has shape (size,), so the guard fires only at size 0).

Scope note: setdiff1d has its own size=0 crash with a different mechanism, fixed separately (#40230); union1d is untouched.

AI assistance disclosure: this fix was developed with AI coding assistance under my direction; I verified the mechanisms against NumPy oracles, ran all tests shown below, and take responsibility for the change.

Test Plan

python -m pytest tests/lax_numpy_setops_test.py -k "intersect or setxor or union or setdiff" -q
  default flags: 90 passed.
JAX_ENABLE_X64=1 python -m pytest tests/lax_numpy_setops_test.py -k "intersect or setxor or union or setdiff" -q
  x64: 90 passed.
python -m pytest tests/lax_numpy_setops_test.py -n auto -q
  default flags: 172 passed; x64: 176 passed.

The new testIntersect1dSizeZero / testSetxor1dSizeZero fail on the unfixed tree exactly on the fill_value=None variants with the ValueError above (9 failed pre-fix), all pass after. A 576-case differential grid over sizes {0,1,2,3,5,10} x dtypes x assume_unique x fill_value x return_indices shows the only behavioral change is size=0 + default-fill going from raise to empty.

Recent neighboring fixes to the same file: #32335, #32402, #32340. Sibling PRs: #40229 (unique empty-axis shapes), #40230 (setdiff1d size=0).

jnp.intersect1d(a, b, size=0) and jnp.setxor1d(a, b, size=0) raised
"ValueError: zero-size array to reduction operation max which has no
identity".

Root cause in both helpers: with fill_value unspecified,
_intersect1d_size() and _setxor1d_size() pad unfilled slots toward
vals.max()/vals.min(), and vals.max() raises when size is zero because
vals itself is a zero-size array. Guard the reduction on vals.size:
when the output is empty there is nothing to pad, so both functions now
return an empty array of the natural result dtype, including under
assume_unique=True, return_indices=True, and explicitly given
fill_value (which was already handled).

Deliberate limits: jnp.union1d(size=0) already returns an empty array
and is untouched. jnp.setdiff1d has a related failure at size=0 that is
being fixed separately via an early return, so setdiff1d is not touched
here.

Test Plan:
python -m pytest tests/lax_numpy_setops_test.py -k "intersect or setxor or union or setdiff" -q
  90 passed, 82 deselected in 16.94s
JAX_ENABLE_X64=1 python -m pytest tests/lax_numpy_setops_test.py -k "intersect or setxor or union or setdiff" -q
  90 passed, 86 deselected in 15.45s
python -m pytest tests/lax_numpy_setops_test.py -n auto -q
  172 passed in 16.99s
JAX_ENABLE_X64=1 python -m pytest tests/lax_numpy_setops_test.py -n auto -q
  176 passed in 12.82s

Before this change the same tree failed the new size-zero tests:
  9 failed, 11 passed, 152 deselected
  FAILED testIntersect1dSizeZero6/7/8, testSetxor1dSizeZero0/2/4/5/7/9
  ValueError: zero-size array to reduction operation max which has no
  identity
Comment thread jax/_src/numpy/setops.py Outdated
if fill_value is None:
vals = where(arange(len(vals)) < num_results, vals, vals.max())
return where(arange(len(vals)) < num_results, vals, vals.min())
if vals.size: # max() and min() are undefined for zero-size arrays.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It seems to me if vals is empty we should skip the fill_value where logic altogether. Something like this:

if vals.size == 0:
  return vals
elif fill_value is None:
  return where(arange(len(vals)) < num_results, vals, vals.min())
else:
  return where(arange(len(vals)) < num_results, vals, fill_value)

Same below.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done: both helpers now return vals early when empty. Note I kept the existing max-then-min padding for non-empty vals: the first max() where is load-bearing, it replaces the zero-filled slots of the masked gather before the min() reduction, otherwise padded entries keep the 0 fill instead of the smallest real result (caught by testIntersect1d1). ba39fb2

Comment thread tests/lax_numpy_setops_test.py Outdated

@jtu.sample_product(
dtype=default_dtypes,
assume_unique=[False, True],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think it's important for this case to test all parameter combinations – just a single unparameterized test with size=0 is probably fine.

Same for the intersect1d test below.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done: replaced both with single unparameterized tests using default int arrays.

@jakevdp jakevdp self-assigned this Aug 26, 2026
Comment thread jax/_src/numpy/setops.py Outdated
else:
return vals
if vals.size == 0:
# min() and max() are undefined for zero-size arrays; skip the padding.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This comment is misleading in the current structure – we're not doing this to avoid min/max; rather we're doing this because fill values are irrelevant in an empty array.

Same below.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done: both comments now say the fill values are irrelevant in an empty array. cd65c85

Comment thread tests/lax_numpy_setops_test.py Outdated
def testSetxor1dSizeZero(self):
ar1 = np.array([1, 3, 5])
ar2 = np.array([2, 3, 4])
self.assertArraysEqual(jnp.setxor1d(ar1, ar2, size=0),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This will fail because assertArraysEqual defaults to asserting that dtypes match, and when X64 mode is disabled JAX will return int32 and NumPy will return int64.

Easier I think would be to just assert that the shape is as expected:

self.assertEqual(result.shape, (0,))

Same below.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done: both tests now assert the output shape is (0,). cd65c85

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.

2 participants