Bitwise operations

CS 301 Lecture, Dr. Lawlor, 2005/09/09

Last class, we looked at binary and hexadecimal numbers.

Binary Operations--Thinking in SIMD

The simplest bitwise operation is the NOT operation: NOT 0 is 1; NOT 1 is 0.  Bitwise NOT is written as "~" in C/C++/Java/C#.   The cool thing about the NOT (and other bitwise) operations is that they operate on *all* the bits of an integer at *once*.  That means a 32-bit integer NOT does thirty-two separate things at the same time!  This "single-instruction, multiple data" (SIMD) approach is a really common way to speed up computations; it's the basis of the MMX, SSE, and AltiVec instruction set extensions. But you can do lots interesting SIMD work without using any special instructions--plain old C will do.  This use of bitwise operations is often called "SIMD within a register (SWAR)" or "word-SIMD"; see Sean Anderson's "Bit Twiddling Hacks" for a variety of amazing examples.

Here's the "truth table" for the 3 main binary bitwise operations: AND, OR, and XOR.  These are accessible directly from C-like languages using '&' (ampersand or just "and"), '|' (pipe or just "or"), and '^' (caret or just "ex-oar").
A
B
A&B (AND)
A|B (OR)
A^B (XOR)
0
0
0
0
0
0
1
0
1
1
1
0
0
1
1
1
1
1
1
0
Interpretations:

Both-1 Either-1 Different


Spreads-0
Spreads-1
Flips

Say you're Google.  For each possible word, you store a big list of documents containing one 32-bit integer per document.  Each bit of that integer corresponds to a section of the document where the word might occur (e.g., bit 0 is set if the word occurs in the first 3% of the document, bit 1 represents the next 3%, and so on).  If you've got two search terms, for a given document you can look up two integers, A and B, that represent where those terms appear in the document.  If both terms appear near the same place in the document, that's good--and we can find a list of all those places (where both bits are set to 1) in just one clock cycle using just "A&B"!  The same idea shows up in the "region codes" of Cohen-Sutherland clipping, used in computer graphics.

But what if I want to compare two documents for similarity?  Note that C-like languages don't provide a "bitwise-compare" operator, that sets equal bits to 1 and unequal bits to 0 (because == compares whole integers, not individual bits).  So how can you compute this?

Well, all the bitwise operations do interesting and useful things when inverted:
A
B
~(A&B) (NAND)
~(A|B) (NOR)
~(A^B) (XNOR)
0
0
1
1
1
0
1
1
0
0
1
0
1
0
0
1
1
0
0
1
Interpretations:

Either-0
Both-0 Equality

Note that "~(A^B)" is 1 if both input bits are identical, and 0 if they're different--so it's perfect for computing document similarity!

The final major bitwise operation is bit shifting, represented in C-like languages using ">>" and "<<".  Left shift ("<<", pointing left), shifts bits into higher positions, filling the now-empty lower positions with zeros.  For unsigned numbers, right shifting (">>", pointing right) shifts bits into lower positions and fills the empties with zeros.  If you wants to search for both-term matches in B that are one bit off of those in A, you can use "A&(B<<1)" or "A&(B>>1)", depending on the direction you want to check.  To check both directions, use "(A&(B<<1))|(A&(B>>1))".

One warning: bitwise operations have strange precedence.  Specifically, "A&B==C" is interpreted by default like "A&(B==C)", not "(A&B)==C"; and "A>>B+C" is interpreted as "A>>(B+C)".  This can cause horrible, hard-to-understand errors, so I just stick in parenthesis around everything when I'm using bitwise operators.

The genome uses just 4 digits (ACGT, for the acids Adenine, Cytosine, Guanine, and Thymine), and so can be represented in as few as 2 bits per nucleotide (genetic digit).  Former UAF CS undergraduate James Long, now up the hill at ARSC, developed a very fast sofware implementation of the Smith-Waterman string (or gene) matching algorithm called CBL that uses this SIMD-within-a-register approach.

Bit-field Interpretation of Bitwise Operations

So we've seen what interesting things can be done with bitwise operations on a pile of bits.  Another major use of bitwise operations is for "bit fields", where you've divided up the parts of an integer into smaller pieces. 

If you've got 2 possible values, you only need one bit.  To chop something down to n bits, you can "mask" it by ANDing with a value where the bits less than n are set to 1, and bits n and higher are cleared to 0.  For example, 0xFF has bits 8 and higher clear, so you can chop a value down to 8 bits by ANDing by 0xff.  Note that this removes all powers of two at 2n and beyond, so after masking the value will run from 0 to 2n-1.  Beware: normal machines only keep a small number of bits, like 32 or 64, in each integer, and the *hardware* masks off arithmetic results (by throwing away bits) to fit in this space!  This is called "wraparound" or "overflow", and it can cause lots of suprising errors.

Number of Values
Number of bits
Mask
2
1
0x1
4
2
0x3
8
3
0x7
16
4
0xF
32
5
0x1F
64
6
0x3F
128
7
0x7F
256
8
0xFF
65,536
16
0xFFFF
4,294,967,296
32
0xFFFFFFFF
2n n
2n-1

For example, let's say you're trying to decode data written by a satellite radar sensor, which uses 4 bits each for its "I" and "Q" values (which have 16 possible values each), and stores them together in one integer "A" like this:
Name
I
Q
Size
4 bits
4 bits
Starting bit in A
Bit 4
Bit 0
So "I" is stored in the high 4 bits of A, and "Q" is in the low 4 bits of A.  For example, if A==0xA7, the I field would be 0xA and the Q field would be 0x7.

To extract out just the Q field, we've just got to get rid of the bits that store I somehow.  The standard way to do this is to use the AND operation to set all the bits of I to zero--zero AND anything is zero, so "Q=A&0x0F".  That is, we've used 0xF to mask off the bits above Q.

To extract out the I field, we've got to get rid of Q (as before), but we've also got to shift I down so it starts at bit zero.  We can do this using "I=(A&0xF0)>>4".  Or, because right-shift throws away bits that fall off the right end, we can also just use "I=A>>4".

To stick A back together from separate I and Q values, we just shift I and Q into the right places, then OR them together, like "A=(I<<4)|Q".   Or if you really want to be persnickety, "A=(I<<4)|(Q<<0)", which is redundant because the ">>0" doesn't do anything. If you're really paranoid, you'll mask off any high bits, like "A=(0xF0&(I<<4))|(0x0F&(Q<<0))", but this isn't needed if I and Q don't have any high bits set.

This "keep a bunch of bitfields in one integer" technique is in found all over the place in machine code.  In x86 machine code, the "SIB" byte stores a scale, index, and base (that we'll learn the details of later) as:
Name
Scale
Index
Base
Size
2 bits
3 bits
3 bits
Starting bit
Bit 6
Bit 3
Bit 0

We can reassemble a SIB byte like "SIB=(Scale<<6)|(Index<<3)|Base"; and extract out the fields like "Scale=0x3&(SIB>>6)", "Index=0x7&(SIB>>3)", and "Base=0x7&SIB".

Arithmetic Interpretation of Bitwise Operations

If A and B are random integers, "A&B", "A|B", and "A^B" don't have any obvious numerical relationship to A and B.  But there are lots of useful relationships for special cases:

0=A&0     (AND by 0's creates 0's--used for masking)
0=A^A  (XOR by yourself creates 0's--used for cryptography)
~0=A|~0  (OR by 1's creates 1's)
A=A&~0 (AND by 1's has no effect)
A=A|0      (OR by 0's has no effect)
A=A&A=A|A (AND or OR by yourself has no effect)