Skip to content

Commit b8a2395

Browse files
authored
Add simd-bitwise concept and its exercise (#458)
1 parent c15b729 commit b8a2395

20 files changed

Lines changed: 6125 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"blurb": "Packed bitwise operations and shifts in x86-64 assembly.",
3+
"authors": ["oxe-i"]
4+
}

concepts/simd-bitwise/about.md

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# About
2+
3+
There are packed variants for most scalar bitwise operations.
4+
They follow the same general syntax as [SIMD integer operations][simd-integers], but they have no size suffix:
5+
6+
| instruction | description |
7+
|-------------|-----------------------------------|
8+
| `pand` | bitwise AND of two 128-bit values |
9+
| `por` | bitwise OR of two 128-bit values |
10+
| `pxor` | bitwise XOR of two 128-bit values |
11+
12+
Note that there is no packed `NOT`.
13+
However, there is an ANDN operation, which negates the **destination** operand and then calculates the AND between both operands.
14+
It can be thought of as a NOT combined with an AND:
15+
16+
| instruction | description |
17+
|-------------|------------------------------------------------------------|
18+
| `pandn` | bitwise AND of the **negated** destination with the source |
19+
20+
Note that a bitwise operation combines bits at the same position in two different operands, so lane boundaries cannot change the result.
21+
This is why these instructions do not have a size suffix, the register behaves as a single 128-bit lane:
22+
23+
```x86asm
24+
pand xmm0, xmm1 ; xmm0 = xmm0 AND xmm1
25+
por xmm2, xmm3 ; xmm2 = xmm2 OR xmm3
26+
pxor xmm4, xmm5 ; xmm4 = xmm4 XOR xmm5
27+
pandn xmm6, xmm7 ; xmm6 = (NOT xmm6) AND xmm7
28+
```
29+
30+
~~~~exercism/note
31+
All instructions in this concept compute bits but set no flags.
32+
A later concept will address conditionals with packed values.
33+
~~~~
34+
35+
## Per-Lane Shifts
36+
37+
Shifts, unlike the pure bitwise operations, do move bits across positions, so they need a lane size to set a boundary.
38+
Within each lane they behave like their scalar counterparts, and bits never cross from one lane into a neighbor:
39+
40+
| instruction | description |
41+
|---------------------------|-------------------------------------|
42+
| `psllw`, `pslld`, `psllq` | shift left logical, per lane |
43+
| `psrlw`, `psrld`, `psrlq` | shift right logical, per lane |
44+
| `psraw`, `psrad` | shift right arithmetic, per lane |
45+
46+
Note that these instructions follow the syntax for packed integer operations, including a size suffix.
47+
There is no byte shift, only word, dword and, with the exception of shift right arithmetic, qword.
48+
49+
One difference between packed shifts and their scalar counterparts is in the operation name:
50+
51+
- Instead of `shl` and `shr`, we have `sll` and `srl`, for **s**hift **l**eft (**r**ight) **l**ogical.
52+
- Instead of `sar`, we have `sra`, for **s**hift **r**ight **a**rithmetic.
53+
54+
Although the name is different, the operation is the same:
55+
56+
- A logical right shift fills the uppermost bits with zero.
57+
It is equivalent to an unsigned division by a power of two.
58+
- An arithmetic right shift fills the uppermost bits with the value of the sign bit.
59+
It is equivalent to a signed division by a power of two, rounding toward negative infinity.
60+
- A shift to the left always fills with zero (this is why there is no need for a shift left arithmetic).
61+
62+
The shift count is the same for every lane.
63+
It can be an immediate, or the low 64 bits of an `xmm` register:
64+
65+
```x86asm
66+
pslld xmm0, 4 ; each 32-bit lane shifted left by 4
67+
psrlw xmm1, 1 ; each 16-bit lane shifted right by 1, zero-filled
68+
psrad xmm2, xmm3 ; each 32-bit lane shifted right by the count in xmm3, sign-filled
69+
```
70+
71+
~~~~exercism/note
72+
A shift count larger than the lane width does not wrap around like the scalar `shl`/`shr` family.
73+
It simply produces zero (or all sign bits, for shift right arithmetic).
74+
~~~~
75+
76+
## Floating-Point Bitwise Operations
77+
78+
Each bitwise instruction also has float-named twins, following the same syntax we have seen for packed floating-point operations:
79+
80+
| integer name | float names |
81+
|--------------|----------------------|
82+
| `pand` | `andps`, `andpd` |
83+
| `pandn` | `andnps`, `andnpd` |
84+
| `por` | `orps`, `orpd` |
85+
| `pxor` | `xorps`, `xorpd` |
86+
87+
Since bitwise operations act on bits, in practice integer and floating-point operations compute the same result.
88+
The different names exist for two reasons:
89+
90+
1. Documenting the intent, in the same way as we would prefer `movdqa`, `movaps` or `movapd` according to the nature of the bytes being copied.
91+
2. Integer and floating-point operations often have different execution domains.
92+
Operations within the same execution domain can usually be sequenced faster.
93+
94+
There is no shift operation for floating-point values.
95+
96+
## Floating-Point Bit Representation
97+
98+
An [IEEE 754][ieee-754] floating-point number has a very different bit representation from an integer.
99+
100+
In an unsigned integer, each bit has a uniform meaning: it represents a power of two corresponding to the bit index.
101+
The integer is the sum of the powers of two corresponding to the set bits.
102+
This is why shifting the bits can multiply or divide the number by a power of two: we are just changing the index of each set bit.
103+
104+
In a signed integer, most bits are interpreted in the same way as in an unsigned integer.
105+
However, the uppermost bit is special.
106+
If it is set, instead of being added to the others, we have to subtract the corresponding power of two, which results in a negative number.
107+
In practice, to change the sign of a number, we would conceptually need two steps: flipping all bits (a `not`) and then adding 1 to the result (an `inc`).
108+
109+
Bits in floating-point numbers do not have a uniform meaning and they need to be interpreted in very different ways according to their position:
110+
111+
| field | indexes (32-bit) | indexes (64-bit) |
112+
|----------|------------------|------------------|
113+
| sign | 31 | 63 |
114+
| exponent | 30-23 | 62-52 |
115+
| fraction | 22-0 | 51-0 |
116+
117+
The resulting number is calculated by `(-1)^sign * 1.fraction * 2^(exponent - bias)`.
118+
The bias depends on the precision:
119+
120+
| size | bias |
121+
|--------|------|
122+
| 32-bit | 127 |
123+
| 64-bit | 1023 |
124+
125+
In both formats, the sign of the number is exclusively determined by the sign bit.
126+
Changing the sign of a floating-point number is achieved by just flipping this bit (an XOR operation).
127+
Getting the absolute value is achieved by just clearing it (an AND where the bit is cleared):
128+
129+
```x86asm
130+
section .rodata
131+
align 16
132+
abs_mask: dd 0b0_11111111_11111111111111111111111, 0x7FFFFFFF, 0x7FFFFFFF, 0x7FFFFFFF ; same number in binary and hexadecimal
133+
sign_mask: dd 0b1_00000000_00000000000000000000000, 0x80000000, 0x80000000, 0x80000000 ; same number in binary and hexadecimal
134+
135+
section .text
136+
fn:
137+
andps xmm0, [rel abs_mask] ; absolute value: clear the sign bit of all 4 floats
138+
xorps xmm1, [rel sign_mask] ; negation: flip the sign bit of all 4 floats
139+
...
140+
```
141+
142+
Note that in this bit representation, shifting bits does not have a predictable and uniform result.
143+
The fraction can "spill" into the exponent and the exponent, into the sign.
144+
145+
However, it is sometimes convenient to isolate the different parts of the representation to operate on them.
146+
147+
For example, the bits in the exponent should first be interpreted as an unsigned number, before the bias is subtracted from it.
148+
This means we can produce a 32-bit or 64-bit integer whose underlying bit representation corresponds to some power of two in the corresponding floating-point format.
149+
Then this raw bit representation can be used to multiply or divide some other floating-point number.
150+
151+
For example:
152+
153+
```x86asm
154+
mov eax, 210 ; in the exponent position, this would be 2^(210 - 127) = 2^83 in a 32-bit floating-point number
155+
shl eax, 23 ; the bits are now in the correct position for the exponent of a 32-bit floating-point number
156+
movd xmm0, eax ; we move the raw bits to xmm0
157+
mulss xmm1, xmm0 ; we are multiplying a 32-bit floating-point number in xmm1 by 2^83
158+
```
159+
160+
Conversely, we could extract the exponents of packed floating-point numbers by performing an AND with a mask where the exponent bits are set and all others are cleared.
161+
Combined with a shift and a subtraction, this produces the unbiased exponent of every lane as a signed integer:
162+
163+
```x86asm
164+
section .rodata
165+
align 16
166+
exp_mask: dd 0b0_11111111_00000000000000000000000, 0x7F800000, 0x7F800000, 0x7F800000 ; same number in binary and hexadecimal
167+
bias: dd 127, 127, 127, 127
168+
169+
section .text
170+
fn:
171+
movdqa xmm0, [rel values] ; 4 packed 32-bit floating-point numbers
172+
pand xmm0, [rel exp_mask] ; keep bits 30-23
173+
psrld xmm0, 23 ; slide the exponent field to the bottom of each lane
174+
psubd xmm0, [rel bias] ; remove the bias
175+
...
176+
```
177+
178+
~~~~exercism/caution
179+
Operating on the raw bits of floating-point numbers, while useful in some situations, should be done with caution.
180+
The formula above only describes **normal** numbers: an exponent field of all zeros encodes zero and the denormal numbers, and a field of all ones encodes the infinities and NaN.
181+
Adding to, or subtracting from, the exponent field is only safe if there is no over or underflow and if the input number is normal.
182+
Also, adding a valid `x` to the exponent field of `0.0` results in `2^(x - bias)`, not in `0.0 * 2^x`.
183+
~~~~
184+
185+
~~~~exercism/note
186+
The [Float Toy page][toy] has a nice, graphical explanation for how a floating-point numbers' bits are converted to an actual floating-point value.
187+
188+
[toy]: https://evanw.github.io/float-toy
189+
~~~~
190+
191+
## AVX and Beyond
192+
193+
AVX gives every instruction here a `v`-prefixed three-operand form on wider registers, leaving the behavior unchanged:
194+
195+
```x86asm
196+
vpand ymm0, ymm1, ymm2 ; ymm0 = ymm1 AND ymm2, 256 bits
197+
vxorps ymm3, ymm4, ymm5 ; ymm3 = ymm4 XOR ymm5, 256 bits
198+
vpsrad ymm6, ymm7, 2 ; eight 32-bit lanes shifted right arithmetically
199+
```
200+
201+
AVX-512 closes both gaps left open by SSE:
202+
203+
- `vpsraq` finally provides the 64-bit arithmetic right shift.
204+
- `vpternlogd` and `vpternlogq` compute **any** three-input bitwise function in one instruction.
205+
The 8-bit immediate is the truth table itself: bit `i` of the immediate is the output for input combination `i`.
206+
Every chain of `pand`/`pandn`/`por`/`pxor` over three operands collapses into a single `vpternlog`.
207+
208+
These extensions are mentioned for completeness.
209+
The exercises in this track use the SSE forms.
210+
211+
~~~~exercism/note
212+
A full reference for every instruction mentioned here is available in the [x86 instruction reference][instruction-reference].
213+
214+
[instruction-reference]: https://www.felixcloutier.com/x86/
215+
~~~~
216+
217+
[simd-integers]: https://exercism.org/tracks/x86-64-assembly/concepts/simd-integers
218+
[ieee-754]: https://en.wikipedia.org/wiki/IEEE_754

0 commit comments

Comments
 (0)