Repository navigation
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/204288
Note: Links to docs will display an error until the docs builds have been completed.
|
This PR needs a
|
|
The following ciflow label(s) have been added but CI has not been triggered yet because the workflows are awaiting approval:
Once a maintainer approves the workflows (scroll to the bottom of the PR page), the corresponding CI jobs will be triggered automatically. Please ping one of the reviewers if you do not have access to approve and run workflows. |
1 similar comment
|
The following ciflow label(s) have been added but CI has not been triggered yet because the workflows are awaiting approval:
Once a maintainer approves the workflows (scroll to the bottom of the PR page), the corresponding CI jobs will be triggered automatically. Please ping one of the reviewers if you do not have access to approve and run workflows. |
Co-authored-by: Cursor <cursoragent@cursor.com>
06a582e to
9d0a06a
Compare
Co-authored-by: Cursor <cursoragent@cursor.com>
Linked issue or supporting maintainer
Part of #N
BC-breaking?
No
Summary (human written only)
The original FlyDSL top-k implementation supports fp32 when
largest=True. This PR extends the kernels to fp16, bfloat16, int32, int16, and int8, and adds support forlargest=False.This is done by introducing
TopKSpec, a compile-time specialization for each dtype and ordering. It selects the dtype-specific bit transformation used to encode values as order-preserving ordinals and decode them, and carries the element type and width used by the load path. A comprehensive proof of why this construction is sound is included in Why it works below.largestin the compilation cache keyslargest=True, andsorted=Trueuntil the policy windows are retunedAgent details (if applicable)
The proof below was outlined, written, and reviewed by the author. An AI assistant helped verify the bit-level reasoning, edit the presentation, and implement the corresponding refactor. The author reviewed and takes responsibility for both the code and the proof.
Testing
lintrunner --take RUFF,PYFMT -- torch/_native/ops/topk/flydsl_kernels.pyWhy it works
Each selected pair
(v, i)is packed into one signed int64 key, and the kernel keeps the greater key.ord(v)is the signed int32 in the high half, and the low 32 bits are~i:A narrow value occupies the top of
ord(v)and the bits underneath are zero. Those zeros are part oford(v), not a third field. fp32 and int32 fill the int32. fp16, bf16, and int16 use the top 16 bits. int8 uses the top 8.The current kernel already uses this key for fp32 with
largest=True: the high half is the fp32 ordinal and the low half is~i, with no pad. This note records the order that implementation assumes, and the same key is what extends it to the other dtypes and tolargest=False.Define
<on pairs as "loses to":largest=True:(v1, i1) < (v2, i2)whenv1 < v2, or the values tie andi2 < i1. This is lexicographic order on(v, -i).largest=False:(v1, i1) < (v2, i2)whenv2 < v1, or the values tie andi2 < i1. This is lexicographic order on(-v, -i).Here
-vdenotes the value order reversed, not a value computed by arithmetic negation. Likewise,-idenotes the index order reversed. The kernel does not negate either field.The value tie rule is the same in both: the smaller index wins. The
<on values agrees with ordinary comparison wherever two numbers compare unequal. It also places-0before+0, and every NaN above+inf, tied with every other NaN.Overview
We want to show that packaging
(v, i)pairs as[ ord(v) | ~i ]forlargest=True, and as[ ~ord(v) | ~i ]forlargest=False, then choosing the pairs with the largest keys, gives the correct semantics. We do so by showing three separate facts:ordpreserves the value order. This is dtype-specific. The signed int32ord(v)compares exactly asvdoes:v1 < v2if and only iford(v1) < ord(v2), and the ordinals are equal exactly when the values tie. The fp32 case is the ordinal the current kernel already builds. The same claim is what embeds fp16, bf16, int32, int16, and int8 into that int32. For this scheme to work,v1andv2have the same dtype.(v, -i). Representing a pair as[ ord(v) | ~i ]keeps that order. Sorting these keys sorts(v, -i).ord(v)reverses the value order. The current kernel never does this: it only builds thelargest=Truekey.largest=Falseis fact 2 applied to the key[ ~ord(v) | ~i ], with the same~i.From the above facts we can infer:
largest=Truesemantics are correct:(v, -i) -> (ord(v), -i)and(ord(v), -i) -> [ ord(v) | ~i ]are order preserving because of points 1 and 2 respectively.largest=Falsesemantics are correct:(-v, -i) -> (~ord(v), -i)and(~ord(v), -i) -> [ ~ord(v) | ~i ]are order preserving because of points 1 and 3, and point 2, respectively. Point 3 reverses the ordinal; point 1 is what ties that ordinal back tov.Side note. Before the reversible value transform, step 0 collapses every NaN of either sign to one sentinel. The current fp32 kernel already uses
0x7FFFFFFF; its ordinal sits above+infat0x7F800000. This PR keeps that collapse and uses0x7FFFfor fp16 and bf16. After the left shift, their stored sentinel is0x7FFF0000, above fp16+infat0x7C000000and bf16+infat0x7F800000.largest=Truetherefore returns NaNs first, tied with each other, and the index breaks the tie.largest=Falsecomplements after the collapse, so the maximum sentinel becomes the minimum code and a smallest-k returns NaNs last.The collapse is not injective, so deconversion cannot recover the original NaN payload. After undoing the complement, alignment, and value transform, the caller compares the recovered narrow bits with the narrow sentinel and reloads
row[i]on a match.The three facts
1.
ordpreserves the value order[ fp32 ]
This is the ordinal the current kernel already uses, unchanged. Bitcast the float to int32 and apply
For this width the mask equals
(bits >> 31) & 0x7FFFFFFF.As a background, positive fp32 is a clear sign bit, then an 8-bit exponent, then a 23-bit fraction.
+1is0x3F800000. A negative fp32 is that same pattern with bit 31 set, so-1is0xBF800000and the exponent and fraction are unchanged.On a non-negative pattern the formula is a no-op. The sign bit is clear, so
signis 0,0 - signis 0, and the XOR changes nothing. The ordinal is the raw bits. The exponent sits above the fraction, so a larger exponent is a larger integer, and the same exponent with a larger fraction differs only in the low bits.+0is the all-zero pattern, so its ordinal is0and every positive ordinal is greater than0.On a negative pattern the sign bit is set, so
signis 1 and0 - signis all ones. The mask keeps the low 31 of those ones, and the formula collapses tobits ^ 0x7FFFFFFF. The exponent and fraction are stored exactly as they are for a positive value, so a larger magnitude still has a larger field in the low 31 bits. A signed compare walks the bits from bit 31 downward, and the first bit that differs decides, with a1there making the larger integer. Both patterns have bit 31 set, so the walk falls through to the exponent. A larger exponent is therefore a larger int32, the same rule that ordered the positive side. Here it points the wrong way:-infhas a larger exponent than-1, so the raw pattern ranks-infabove-1. The XOR flips that field and leaves bit 31 set. The walk now meets a0first on the larger magnitude, so that value is the smaller int32, which is the numeric order, and the sign bit keeps the result negative.The sign bit keeps every negative ordinal below
ord(+0) = 0, and within that half a larger magnitude is a smaller ordinal. Every positive ordinal is aboveord(+0). That is the total order.Deconversion applies the same three lines:
The mask depends only on bit 31.
0x7FFFFFFFhas bit 31 clear, so the sign bit is the same before and after the original transform and the second application builds the same mask. For a non-NaN, xoring the same mask twice returns the original bits.NaN handling: before this transform, step 0 collapses every NaN to
0x7FFFFFFF. Its ordinal sits above+infat0x7F800000.[ fp16 and bf16 ]
This is the same three lines at 16 bits. The sign is bit 15, and the operations run on that 16-bit signed integer:
The 16-bit result is shifted left by 16 inside an int32. The shift keeps those 16 bits, moves bit 15 to bit 31, and writes zeros under the field, so the signed walk from the fp32 case applies. Bits above the field are shifted off the top, so it does not matter whether sign extension had filled them with ones.
Deconversion shifts the int32 right by 16 and truncates to 16 bits. An arithmetic shift fills ones above a negative ordinal, and the truncate keeps the low 16:
Bit 15 of that field is the bit the forward map left unchanged, so the mask built here is the mask applied to the original input. For a non-NaN, xoring it again returns the original bits.
NaN handling: before this transform, step 0 collapses every NaN to
0x7FFF. The stored ordinal after the shift is0x7FFF0000, which sits above fp16+infat0x7C000000and bf16+infat0x7F800000.int32, int16, and int8
This PR adds int32, int16, and int8. A signed integer needs no sign flip: its bits are already in signed order. The only step is the left shift of the next fact. int32 already fills the high half, so its shift is zero. int16 and int8 are shifted left so their sign lands on bit 31.
Deconversion is the arithmetic right shift by the same amount, then a truncate to
widthbits. int32 stays as it is. int16 shifts by 16 and int8 by 24, which sign-fills. The lowwidthbits are the original value, and the truncate reads them as that signed integer:NaN handling: these dtypes have no NaN.
Zero-padding preserves order
Zero-padding on the right is the shift that places a signed
width-bit value into the high bits of an int32:The shift keeps the low
widthbits and moves them to the top. Bits above the field are shifted off, whether sign extension had written ones there or not, and the bits under the field are zero.It preserves signed order when the shift does not overflow. Let
zbe a signedwidth-bit integer,width ≤ 32, so-2^(width-1) ≤ z ≤ 2^(width-1) - 1. The shift is multiplication by2^(32-width), which is positive, so the order is preserved as long as the product fits. The largestwidth-bit value is2^(width-1) - 1, so the largest product is2^31 - 2^(32-width). That is strictly smaller than2^31, hence at most2^31 - 1. The smallest value is-2^(width-1), and its product with2^(32-width)is-2^31, which isINT32_MIN. Both products fit in a signed int32. The bits below the field are zero. For fp32,width = 32, so the shift is zero and the current kernel has no pad.Deconversion is the arithmetic right shift by the same amount, then a truncate to
widthbits:A negative value sign-fills above the field. The truncate keeps the low
widthbits, and their top bit is the original sign. The filled ones sit above those bits, so the sign fill does not change the recovered value.Reference implementation
The value maps in point 1 are not proposed here for the first time. CUDA radix top-k already defines that order for float, half, bfloat16, int8, int16, and int32 in
TopKTypeConfig. A float keeps its order by an xor of the sign, with NaN sent to all-ones. A signed integer is biased by2^(width-1). Both are then compared as unsigned. This kernel uses the signed form of that same order, because the existing fp32 path already turns the value into a signed int32 and packs it into a signed int64. The new dtypes extend that path rather than switching the compare to the unsigned CUDA codes. For a float the two codes differ by one sign-bit xor:^ 0x80000000for fp32, and^ 0x8000on the 16-bit field for fp16 and bf16, which is^ 0x80000000after the left shift. That xor preserves order between the two spaces: a signed compare of this code and an unsigned compare of the reference code rank values the same way. It flips the sign bit, so the order is not the same inside either compare alone. For an integer the reference adds2^(width-1)and obtains a non-negative code. This kernel multiplies by2^(32-width), a left shift that puts the sign on bit 31, and a negative value stays negative. The signed order of that result is the unsigned order of the biased code.2. Packing preserves the order of
(v, -i)The current fp32 kernel already assumes this and implements it by sorting the int64 keys descending. Nothing below depends on the high half being an fp32 ordinal, so every new dtype inherits it once lemma 1 has produced an int32.
The packed key is
[ ord(v) | ~i ]. The high 32 bits areord(v)and the low 32 bits are the inverted index.A signed int64 compare walks from bit 63 downward. The key with bit 63 set is smaller. If that bit matches, the walk continues, and at the first later difference the key with the
1is larger. The first 32 steps are the same walk as a signed compare of the high half. When the ordinals differ, the keys therefore compare as the ordinals do, and the index bits are not reached.The walk reaches bit 31 only when the ordinals are equal, including two NaNs. From there it compares indexes. Take
i1 > i2 ≥ 0. Walking the raw indexes from bit 31 down, the first bit that differs is1ini1and0ini2, so the larger index would be the larger key. The low half stores~iinstead. Inversion flips that bit, soi2has the1there and the smaller index wins.The bitonic sort pads with
INT64_MIN, whose bits are[ INT32_MIN | 0 ]. Every real key is greater: either its ordinal is greater thanINT32_MIN, or its ordinal isINT32_MINand its low half is the nonzero~iof a valid index.3. Complementing
ord(v)reverses the value orderlargest=Falseis the new half of the scheme. The current kernel only builds thelargest=Truekey, so this reversal is not something it already does. The low half stays~i.ord(v)is the signed int32 the key already walks from bit 63. At bit 31, a1is the smaller ordinal. Below that, a1is the larger ordinal. Two different ordinals first differ in one of those bits. Complementing both swaps who has the1there, so the comparison reverses, and equal ordinals stay equal. Fact 2 applied to[ ~ord(v) | ~i ]is therefore the order of(-v, -i), which islargest=False.Complementing also turns the zero bits under a narrow field into ones. Those ones are the same for every value of the dtype, so the first difference is still in the field. The low half is still
~i, so a tie still goes to the smaller index.The NaN sentinel is the largest code of its field. For fp32 that sentinel is the
0x7FFFFFFFthe current kernel already writes; complementing it is the new step. Its complement is strictly smaller than every numeric ordinal. fp320x7FFFFFFFbecomes0x80000000, below-infat0x807FFFFF. A smallest-k therefore returns NaNs last, still tied with each other.cc @jeffdaily @sunway513 @jithunnair-amd @pruthvistony @ROCmSupport @jataylo @hongxiayang @naromero77amd @pragupta @jerrymannil @xinyazhang