Perfect Squares in C, No Math Library Required
No floats, no libm, every u64 correct. Three attempts at a perfect square test, from four billion subtractions down to a branchless loop built on a fact you learned in school.
Here’s a puzzle. Write this function:
bool is_square(uint64_t n);The rules:
- No
math.h. Nosqrt, nopow, nothing from libm. - No floats. Not even a quick cast to
double“just to get close.” - It has to be right for every
uint64_t. All 18,446,744,073,709,551,616 of them.
Take a second and think about how you’d do it. I’ll wait.
Attempt 1: subtract odd numbers
My first instinct was a fact from grade school: the sum of the first k odd numbers is k².
1 = 1. 1 + 3 = 4. 1 + 3 + 5 = 9. 1 + 3 + 5 + 7 = 16.
There’s a nice picture behind it. To grow a k×k square into a (k+1)×(k+1) square, you add a strip along the right, a strip along the bottom, and one corner cell. That’s k + k + 1 cells, which is the next odd number.
So just run it backwards. Subtract 1, then 3, then 5, and keep going until there’s nothing left. Land on exactly zero and n was a square. Run out before you get there and it wasn’t.
bool is_square(uint64_t n){ uint64_t odd = 1; while (n >= odd) { n -= odd; odd += 2; } return n == 0;}It’s correct and it’s cute. It’s also √n steps. For UINT64_MAX that’s 4.3 billion subtractions, which took 0.8 seconds on my machine. One call. For one number.
Rules satisfied, dignity not.
Attempt 2: binary search
The root of a uint64_t fits in 32 bits, so guess a root, square it, and see if you’re high or low. Halve the range and repeat.
bool is_square(uint64_t n){ uint64_t lo = 0, hi = 0xFFFFFFFF; /* not n: mid * mid would overflow */ while (lo <= hi) { uint64_t mid = lo + (hi - lo) / 2; uint64_t sq = mid * mid; if (sq == n) return true; if (sq < n) lo = mid + 1; else if (mid == 0) break; else hi = mid - 1; } return false;}Watch that hi. Start it at n and the first mid * mid overflows, so you get a wrong answer that looks perfectly reasonable.
Four billion steps become 32. About 85–91 ns per call, depending on the compiler. Most people would stop here.
But every round, binary search throws away what it learned. It picks a brand new mid, squares it from scratch, and then hits a branch that goes either way with 50/50 odds, so the CPU guesses wrong about half the time. We can do a lot better.
Attempt 3: subtract odds in bulk
Go back to the odds. Attempt 1 wasn’t wrong, it was just slow. So what if we subtracted them in blocks instead of one at a time?
Say we’ve already eaten the first R odds, so what’s left is . The next d odds are . Each one is 2R plus one of the first d odds, so the whole block sums to:
So which d? Here’s the whole trick. That is the awkward part: squaring means multiplying, and the whole point was to stop squaring from scratch every round like binary search does. But if d is a power of two, , then , and powers of four are about the friendliest numbers a CPU will ever see. In binary, every one of them is a single 1 followed by an even number of zeros:
j d = 2ʲ d² = 4ʲ in binary 0 1 1 1 1 2 4 100 2 4 16 10000 3 8 64 1000000 4 16 256 100000000Each step down in j just slides that 1 two places to the right. The square costs nothing. It’s a shift.
The other term gets the same treatment: is just R shifted left. So the whole block, , is two shifts and an add. Geometrically, it’s the same picture as before, scaled up: two R×d strips plus a d×d corner, and the corner is itself a square (the first odds).
Now each step is a yes/no question. Walk j from high to low, and at each step ask whether the next odds fit in what’s left. If they do, subtract them and set bit j of the root. Picking bits greedily from the top lands on the largest R with , and whatever’s left over is . It’s zero exactly when n is a perfect square.
Try it. The dashed square is √n. Every block either fits inside it or doesn’t:
| j | odds | block | left before | R |
|---|
If that feels familiar, it should. It is a binary search on the root, one bit per round. The difference is it never starts over. It carries the remainder along, so each round is a compare and a subtract instead of a fresh multiply. And it only runs one round per pair of bits in n, so small numbers finish early.
data as table
| bits | one odd at a time | binary search | blocks of 2ʲ odds |
|---|---|---|---|
| 2 bits | 2 | 32 | 1 |
| 4 bits | 4 | 32 | 2 |
| 6 bits | 8 | 32 | 3 |
| 8 bits | 16 | 32 | 4 |
| 10 bits | 32 | 32 | 5 |
| 12 bits | 64 | 32 | 6 |
| 14 bits | 128 | 32 | 7 |
| 16 bits | 256 | 32 | 8 |
| 18 bits | 512 | 32 | 9 |
| 20 bits | 1,024 | 32 | 10 |
| 22 bits | 2,048 | 32 | 11 |
| 24 bits | 4,096 | 32 | 12 |
| 26 bits | 8,192 | 32 | 13 |
| 28 bits | 16,384 | 32 | 14 |
| 30 bits | 32,768 | 32 | 15 |
| 32 bits | 65,536 | 32 | 16 |
| 34 bits | 131,072 | 32 | 17 |
| 36 bits | 262,144 | 32 | 18 |
| 38 bits | 524,288 | 32 | 19 |
| 40 bits | 1,048,576 | 32 | 20 |
| 42 bits | 2,097,152 | 32 | 21 |
| 44 bits | 4,194,304 | 32 | 22 |
| 46 bits | 8,388,608 | 32 | 23 |
| 48 bits | 16,777,216 | 32 | 24 |
| 50 bits | 33,554,432 | 32 | 25 |
| 52 bits | 67,108,864 | 32 | 26 |
| 54 bits | 134,217,728 | 32 | 27 |
| 56 bits | 268,435,456 | 32 | 28 |
| 58 bits | 536,870,912 | 32 | 29 |
| 60 bits | 1,073,741,824 | 32 | 30 |
| 62 bits | 2,147,483,648 | 32 | 31 |
| 64 bits | 4,294,967,296 | 32 | 32 |
The code
One more trick gets rid of the multiply entirely. Don’t store R. Store it pre-shifted, as . Then the block sum is just r + bit, where bit : that lone 1 from the table. It starts at 1ULL << 62, which is , the biggest power of four a uint64_t can hold, and bit >>= 2 slides it to the next one. Moving to the next j halves r, and adds bit if that root bit was taken.
#include <stdint.h>#include <stdbool.h>
bool is_square(uint64_t n){ /* squares mod 16 are 0, 1, 4, 9: rejects 3/4 of inputs */ if (!((0x0213u >> (n & 15)) & 1)) return false;
uint64_t x = n; /* what's left: n - R^2 */ uint64_t r = 0; /* root so far: R * 2^(j+1) */ uint64_t bit = 1ULL << 62; /* 4^j */
while (bit > n) bit >>= 2;
while (bit) { uint64_t block = r + bit; /* sum of the next 2^j odds */ if (x >= block) { x -= block; r = (r >> 1) + bit; } else { r >>= 1; } bit >>= 2; } return x == 0; /* r now holds floor(sqrt(n)) */}Trace n = 100. The first block takes the first 8 odds (64), leaving 36. The next block of 4 odds (17 through 23, summing to 80) doesn’t fit. The one after that takes 17 + 19 = 36, leaving 0. Root is 10. Square.
No multiply, no divide, no floats, and at most 32 rounds. Zero doesn’t even need a special case: the loop never runs and x is already 0.
Here’s the part I didn’t expect. That if compiles to a pair of cmovs under both GCC and Clang, so the loop has no data-dependent branches at all. There’s nothing to mispredict. On squares, where every call runs the whole loop, it takes 13–15 ns against binary search’s 85–91, so 6× faster. On random inputs, where the filter below turns most of them away, it’s 15×. And attempt 1? That’s tens of millions of times slower.
The mod-16 line on top is a free bonus. Squares can only end in 0, 1, 4, or 9 in hex, so three out of four random inputs get turned away before the loop even starts.
Did I get it right?
“Every uint64_t” is a big promise, and I don’t trust fifteen lines of bit twiddling just because they look right, so I tested it against a reference. (The reference breaks the rules. You’ll see why at the end.)
Random 64-bit numbers are almost never squares, so random testing mostly exercises return false. The off-by-ones live right next to the squares, so that’s where I aimed:
for (int i = 0; i < 2000000; i++) { uint64_t k = rnd() >> 32; uint64_t cases[] = { k * k, k * k - 1, k * k + 1, rnd(), (uint64_t)i }; for (int j = 0; j < 5; j++) mismatches += is_square(cases[j]) != sqrt_fixed(cases[j]);}/* plus the edges: 0 through 4, (2^32 - 1)^2, and UINT64_MAX */10,000,007 inputs, 0 mismatches, under both GCC and Clang.
Okay, now the sane way
If you’re allowed floats, just use the hardware:
bool sqrt_fixed(uint64_t n){ uint64_t r = (uint64_t)sqrt((double)n); if (r > 0xFFFFFFFFu) r = 0xFFFFFFFFu; while (r * r > n) r--; while (r < 0xFFFFFFFFu && (r + 1) * (r + 1) <= n) r++; return r * r == n;}sqrtsd gets you within one or two of the true root in a single instruction, and the two while loops nudge it onto the exact integer. Don’t skip them. A double only holds 53 bits, so the version you’ll find pasted everywhere, rounding and squaring back in floating point, starts calling non-squares squares once n passes 253. This is the reference I tested against, too.
It runs in about 2 ns. Our loop takes 13–15 ns once inputs get past the filter. It isn’t close.
Every number in this post comes from one benchmark file: best of 7 passes over 4 million inputs, pinned to one core of a Ryzen 9 7950X. Here’s the whole session:
gcc -O2 -march=native perfect-squares-bench.c -lm -o bench-gcctaskset -c 2 ./bench-gcc > gcc.jsonjq -c '{checked, mismatches, naiveMaxSeconds}' gcc.json{"checked":10000007,"mismatches":0,"naiveMaxSeconds":0.798}jq -r '.bench[] | select(.mix == "squares") | "\(.fn) \(.ns)"' gcc.json | column -tsqrt_all_double 3.563sqrt_fixed 2.042bsearch_sq 85.394is_square 13.333is_square_nofilter 18.440clang -O2 -march=native perfect-squares-bench.c -lm -o bench-clangtaskset -c 2 ./bench-clang > clang.jsonjq -c '{checked, mismatches, naiveMaxSeconds}' clang.json{"checked":10000007,"mismatches":0,"naiveMaxSeconds":0.795}jq -r '.bench[] | select(.mix == "squares") | "\(.fn) \(.ns)"' clang.json | column -tsqrt_all_double 1.750sqrt_fixed 2.128bsearch_sq 90.516is_square 15.430is_square_nofilter 21.800
data as table
| implementation | Random · gcc | Random · clang | Squares · gcc | Squares · clang |
|---|---|---|---|---|
| sqrt, all double (wrong past 2⁵³) | 2.98 | 1.75 | 3.56 | 1.75 |
| sqrt + integer fix | 2.04 | 2.13 | 2.04 | 2.13 |
| binary search | 85.11 | 91.14 | 85.39 | 90.52 |
| is_square (this post) | 5.76 | 6.59 | 13.33 | 15.43 |
| is_square, no mod-16 filter | 17.29 | 20.69 | 18.44 | 21.80 |
-O2 -march=native, best of 7 runs over 4 million inputs. Benchmark source.And the hardware doesn’t care how big n is. Our loop does, one round per pair of bits:
data as table
| bits | is_square · GCC | is_square · Clang | sqrt + fix · GCC | sqrt + fix · Clang |
|---|---|---|---|---|
| 2 bits | 7.56 ns | 7.57 ns | 1.66 ns | 2.13 ns |
| 4 bits | 7.53 ns | 7.71 ns | 1.65 ns | 2.12 ns |
| 6 bits | 7.53 ns | 7.89 ns | 1.65 ns | 2.12 ns |
| 8 bits | 7.56 ns | 8.11 ns | 1.65 ns | 2.13 ns |
| 10 bits | 7.56 ns | 8.28 ns | 1.66 ns | 2.12 ns |
| 12 bits | 7.79 ns | 8.48 ns | 1.66 ns | 2.13 ns |
| 14 bits | 8.46 ns | 8.56 ns | 1.66 ns | 2.14 ns |
| 16 bits | 8.52 ns | 9.25 ns | 1.66 ns | 2.14 ns |
| 18 bits | 9.08 ns | 9.68 ns | 1.66 ns | 2.13 ns |
| 20 bits | 8.94 ns | 9.81 ns | 1.67 ns | 2.13 ns |
| 22 bits | 9.49 ns | 10.38 ns | 1.67 ns | 2.14 ns |
| 24 bits | 9.81 ns | 10.57 ns | 1.67 ns | 2.16 ns |
| 26 bits | 10.37 ns | 11.18 ns | 1.67 ns | 2.18 ns |
| 28 bits | 10.37 ns | 11.41 ns | 1.67 ns | 2.16 ns |
| 30 bits | 10.75 ns | 11.91 ns | 1.67 ns | 2.15 ns |
| 32 bits | 11.12 ns | 12.72 ns | 1.67 ns | 2.14 ns |
| 34 bits | 11.49 ns | 12.64 ns | 1.67 ns | 2.15 ns |
| 36 bits | 11.68 ns | 13.38 ns | 1.67 ns | 2.15 ns |
| 38 bits | 12.16 ns | 14.14 ns | 1.67 ns | 2.15 ns |
| 40 bits | 12.35 ns | 14.17 ns | 1.67 ns | 2.14 ns |
| 42 bits | 12.59 ns | 14.64 ns | 1.67 ns | 2.14 ns |
| 44 bits | 12.96 ns | 15.75 ns | 1.67 ns | 2.14 ns |
| 46 bits | 13.34 ns | 15.75 ns | 1.67 ns | 2.14 ns |
| 48 bits | 13.81 ns | 16.70 ns | 1.67 ns | 2.13 ns |
| 50 bits | 14.17 ns | 16.80 ns | 1.67 ns | 2.13 ns |
| 52 bits | 14.45 ns | 17.54 ns | 1.66 ns | 2.14 ns |
| 54 bits | 15.13 ns | 18.54 ns | 1.66 ns | 2.13 ns |
| 56 bits | 15.33 ns | 19.18 ns | 1.66 ns | 2.13 ns |
| 58 bits | 15.72 ns | 18.73 ns | 1.66 ns | 2.16 ns |
| 60 bits | 16.07 ns | 19.89 ns | 1.66 ns | 2.17 ns |
| 62 bits | 16.35 ns | 19.82 ns | 1.67 ns | 2.16 ns |
| 64 bits | 16.99 ns | 21.50 ns | 1.66 ns | 2.15 ns |
So if you have an FPU, use it. The block loop is for when you don’t: microcontrollers, freestanding code, kernels, anywhere you want an answer that’s exact by construction and not by careful reasoning about float rounding.
And honestly? It’s for the fun of watching a fact from grade school turn into fifteen lines that beat binary search by 6×.