NaN is not one bit pattern: sorting floats by integer keys

Floats · sorting · NaN

float("nan") and 0/0 both print as nan, but they are not always the same bits. A sort that never compares the floats, and instead ranks an integer key made from those bits, can give those NaNs a rank against each other. That breaks a stable rule: equal values keep the earlier column. Fold every NaN to one bit pattern before the key is built.

bits  -->  key  -->  radix / sort  -->  top-k
             |
             +-- more than one NaN pattern
                   --> more than one key
                   --> NaNs get a rank
                   --> a stable tie breaks
                 fix: one pattern for every NaN, then the key

The listing below is the output of one toy, run with CPython 3.12.4 and NumPy 2.0.1. NumPy is the only dependency.

python3 001-nan-sort-key-toy.py

1. The bits of a float32, and what a NaN is

fp32 = [ sign 1 ][ exponent 8 ][ mantissa 23 ]

exponent == 11111111, mantissa == 0       -> ±inf
exponent == 11111111, mantissa != 0       -> NaN
                      first mantissa bit 1 -> quiet NaN (qNaN)
                      first mantissa bit 0 -> signaling NaN (sNaN)
                      the other mantissa bits -> payload
=== 1. fp32 = sign | exponent (8) | mantissa (23)
name             | hex        | sign | exponent | mantissa                | isnan
1.0              | 0x3f800000 | 0    | 01111111 | 00000000000000000000000 | False
+inf             | 0x7f800000 | 0    | 11111111 | 00000000000000000000000 | False
+qNaN            | 0x7fc00000 | 0    | 11111111 | 10000000000000000000000 | True
-qNaN            | 0xffc00000 | 1    | 11111111 | 10000000000000000000000 | True
+NaN payload 1   | 0x7fc00001 | 0    | 11111111 | 10000000000000000000001 | True
+sNaN            | 0x7f800001 | 0    | 11111111 | 00000000000000000000001 | True

isnan is true for all four NaNs. A NaN is not one value. It is a set of about 224 bit patterns.

2. Which operation writes which NaN

=== 2. which bits an operation produces (this CPU)
operation        | hex        | printed
float('nan')     | 0x7fc00000 | nan
0 / 0            | 0x7fc00000 | nan
inf - inf        | 0x7fc00000 | nan
0 * inf          | 0x7fc00000 | nan
sqrt(-1)         | 0x7fc00000 | nan
-float('nan')    | 0xffc00000 | nan

On this run, invalid operations wrote the same quiet NaN as float("nan"). Negation flipped only the sign bit, so -float("nan") is 0xffc00000. The same script on another CPU printed 0xffc00000 for 0/0, inf-inf, 0*inf, and sqrt(-1). Both results are allowed. The hardware default NaN is not portable, and every line above still prints as nan.

SourceBits you should expectNote
float("nan"), np.nan+qNaN 0x7fc00000A NaN written as a constant
0/0, inf-inf, 0*infThe hardware default NaNThis run: 0x7fc00000. Another CPU: 0xffc00000
-xThe sign bit flipsTrue for a NaN as well as for a number
A NaN with another payloadUncommonBuilt by writing the bits, or passed through by some operations

A row can hold a constant NaN and a computed NaN at once. Printed, they look the same.

3. Comparisons do not rank a NaN

=== 3. NaN compares false to everything, itself included
+qNaN == +qNaN : False
+qNaN == -qNaN : False
+qNaN >  1.0   : False
+qNaN <  1.0   : False

The sort has to choose a rule. A common one is: every NaN ranks above +inf, and every NaN ties with every other NaN. Under a stable sort, the column order then decides among NaNs.

4. An unsigned key whose order matches the float

A radix sort does not compare values. It builds a key from the bits and buckets by the key's digits. A usual conversion:

InputConversionWhy
sign = 0 (positive)Set the sign bitPositives sort above negatives
sign = 1 (negative)Flip every bitA larger magnitude becomes a smaller key
=== 4. a float as an unsigned key whose order is the float order
value            | bits       | raw key    | canonical key
-qNaN            | 0xffc00000 | 0x003fffff | 0xffc00000
-inf             | 0xff800000 | 0x007fffff | 0x007fffff
-1.0             | 0xbf800000 | 0x407fffff | 0x407fffff
-0.0             | 0x80000000 | 0x7fffffff | 0x7fffffff
+0.0             | 0x00000000 | 0x80000000 | 0x80000000
1.0              | 0x3f800000 | 0xbf800000 | 0xbf800000
+inf             | 0x7f800000 | 0xff800000 | 0xff800000
+qNaN            | 0x7fc00000 | 0xffc00000 | 0xffc00000
+NaN payload 1   | 0x7fc00001 | 0xffc00001 | 0xffc00000
where the NaNs sit in raw-key order

0x003fffff   0x007fffff  ...  0xff800000  0xffc00000  0xffc00001
  -qNaN        -inf      ...    +inf        +qNaN     +NaN payload 1
  ^ below -inf                              ^ above +inf, and one key per payload

For every non-NaN, key order matches float order. For NaNs it does not. -qNaN sorts below -inf, +qNaN sorts above +inf, and a different payload is a different key. The canonical key column is what you get after every NaN has been rewritten to +qNaN first. Those NaNs share one key.

5. What that does to a stable top-k

Width 8, k = 2, and the rule from section 3: NaNs sit above +inf, and NaNs tie with each other.

KeyNaNs versus +infNaNs versus each other
rawAbove +inf, but the raw key is keptEach bit pattern has its own rank
canonicalOne key, above +infA tie, so the column decides
=== 5. stable top-k: largest key first, equal keys -> smaller column first
column  |       0 |       1 |       2 |       3 |       4 |       5 |       6 |       7
row A   | +qNaN   | +qNaN   | -qNaN   | -qNaN   | 3.0     | inf     | 1.0     | -2.0
row B   | +qNaN   | +qNaN   | +NaN p1 | +NaN p1 | 3.0     | inf     | 1.0     | -2.0
row C   | -qNaN   | -qNaN   | +qNaN   | +qNaN   | 3.0     | inf     | 1.0     | -2.0
want    | NaN above +inf, all NaNs equal -> top-2 = columns [0, 1] in every row
row  | key       | top-2   | verdict
A    | raw       | [0, 1]  | right
A    | canonical | [0, 1]  | right
B    | raw       | [2, 3]  | WRONG: the later NaNs outrank the earlier ones
B    | canonical | [0, 1]  | right
C    | raw       | [2, 3]  | WRONG: the later NaNs outrank the earlier ones
C    | canonical | [0, 1]  | right

Row A happens to be right: the earlier columns are the NaNs with the higher raw key. Rows B and C are wrong: a later NaN outranks an earlier one. After canonicalizing, all four NaNs tie, so columns 0 and 1 win on every row.

The bug shows up only whenWhy it stays hidden otherwise
The sort promises stability (an earlier column wins a tie)An unstable sort may return any of the NaNs
There are more NaNs than kWith k or fewer, every NaN is selected, so their order never appears
The NaNs mix signs or payloadsOne pattern means one key, so they tie

The last row is why a test that "includes some NaNs" misses this. A handful of float("nan") values, all the same pattern, pass.

6. Canonicalize before the key

bits -- is it a NaN?   (bits & 0x7fffffff) > 0x7f800000
        yes -> bits = 0x7fc00000      every NaN, one pattern
        no  -> keep the bits
      -> then the key conversion from section 4
  • Each element gains one compare and one select. When the sort is limited by reading memory, that cost is often invisible. Do not claim that until you time it.
  • If more than one place builds a key (a select stage and an output stage, for example), every place must canonicalize the same way. One side left raw will disagree with the other about order.
  • Which pattern you pick does not matter. What matters is that every NaN becomes the same key.
  • The test is the condition in section 5: stable, more NaNs than k, mixed signs and payloads, and a layout where the higher raw key sits in a later column (rows B and C).

Terms

TermMeaning
NaNA float whose exponent is all ones and whose mantissa is not zero. Every comparison is false
qNaN / sNaNQuiet if the first mantissa bit is 1, signaling if it is 0. Arithmetic almost always produces a qNaN
payloadThe remaining mantissa bits. They are not a numeric value
default NaNThe NaN an invalid operation such as 0/0 returns. Its bits depend on the hardware
sort keyThe bits rewritten so that an integer compare reproduces float order
canonicalizeFolding several representations of one meaning into one. Here, every NaN becomes 0x7fc00000
stableEqual keys keep their original order. Here, that order is the column order
top-kThe operation that selects the positions of the k largest values

Sections 1 and 3–5 were run with CPython 3.12.4 and NumPy 2.0.1 and match the listing. Section 2 is that same run; another CPU printed a negative default NaN for the invalid operations.