# Fast poker hand evaluation via hardware synthesis and bitslicing

> Source: <https://roderickgreen.com/posts/fast-poker-hand-eval/>
> Published: 2026-08-28 15:31:52+00:00

# Fast poker hand evaluation via hardware synthesis and bitslicing

## Introduction [#](#introduction)

This is a post about writing fast software in a roundabout way. Starting from a function definition, we define and synthesize a hardware circuit that implements it. Then we optimize the circuit using hardware minimizers, and finally generate code from the minimized circuit. I started initial work on this project years ago while computing some random poker hand statistics. I got interested in what made hand evaluators fast and how to make them faster. I put it back down before making much progress. With the arrival of Claude I can pick a few of these projects back up and bring one or two to life.

If you are interested in the techniques of synthesising function implementations but not the domain of poker, you can skip to [Introduction to code generation](#intro-code-gen). If you are instead looking for C code you can link into your poker application and start experimenting with, head to [my github](https://github.com/roderickgreen/circuit-eval). All code is MIT licensed and provided for free.

## Scope [#](#scope)

The scope of this project is batch poker hand evaluation functions for Texas Holdem and Omaha, including Omaha low-hand. The Omaha evaluator is invariant to hole card count and exhaustively validated from two to six hole cards. There is also a live [hand equity demo](https://roderickgreen.com/demos/circuit-equity). (Spoiler: the demo runs WebGPU shader kernels generated from the same circuits).

The generated computational kernels are bitsliced for parallel hand evaluation and branch-free. The Omaha evaluator in particular is up to 100x faster than table-based evaluators. All 130+ million 7-card Holdem hands can be trivially evaluated by any fast evaluator. The same isn’t currently true of Omaha. As part of this project, I exhaustively validated the Omaha evaluators out to 6 hole cards requiring 65 trillion hand evaluations and a hundred core-hours. There is a set of benchmarks [below](#benchmarks) and more in the repo.

## Poker variants [#](#poker-variants)

Many people are familiar with Texas Holdem. Players are dealt two cards face down, then five community cards are dealt face up for everyone to share. Five-card hands are made with any combination of the face down hole cards and the face up community cards.

Omaha is a variant that is a bit more complex. Players are dealt four hole cards instead of two, and they must play exactly two hole cards and three community cards to make the best five-card hand. The added complexity makes hands more difficult to evaluate. There are variants of Omaha played with five or six hole cards as well.

## Determining the best five card poker hand [#](#determining-the-best-five-card-poker-hand)

Five card poker hands are ranked as follows:

- Straight flush: 5 cards in rank-order, all the same suit. Aces can count as high or low. AKQJT all the same suit is a royal flush.
- Four of a kind: 4 cards of one rank, one card of a different rank: AAAAK
- Full house: 3 cards of one rank, 2 cards of a different rank: AAAKK
- Flush: 5 cards of a single suit: AT832 same suit
- Straight: 5 cards in rank-order: AKQJT or 87654. Aces can count as high or low: A2345 is a straight commonly called ‘The Wheel’
- Three of a kind: 3 cards of one rank, two of different ranks: AAAKJ
- Two pair: 2 cards of one rank, 2 cards of another rank, and one card of a third rank: AAKKQ
- One pair: 2 cards of one rank, 3 cards of different ranks: AAKQJ
- High card: everything else

Ties on hand ranks are broken using high cards: Three aces with a K and T beats three aces with a K and 9, and both beat 3 kings. A 9-high straight beats an 8-high straight. Ace high beats king high. And nothing beats a royal flush. Absolute ties are also possible.

Every poker application has code that implements this function, and experienced players can evaluate and rank hands without much thought. Once we have a function `evaluateHand`

, we compare the result of the function applied to each players’ hand to determine the winner. It’s not challenging to implement this function correctly, but creating a fast implementation takes some work.

## Previous approaches to fast evaluation functions [#](#previous-approaches-to-fast-evaluation-functions)

There is a lot of structure and symmetry in the evaluation functions that can be exploited, and that is the basis of fast implementations. For example, in Holdem where there are 7 cards playable per player, it’s impossible for a single player to simultaneously make both a flush and a full house or four of a kind, or for a player to simultaneously make flushes of two different suits. These can be exploited by factoring the problem into flush- and rank- evaluations, and by simplifying the flush evaluation logic to evaluate a single merged suit instead of all four suits. Fast computational evaluators exploit all these tricks to achieve strong performance.

Another common approach is the use of lookup tables and perfect hashing. You precompute lookup tables and look up solutions during evaluation. This works well in general and is the basis of most modern evaluators, but in more complex variants like Omaha, the lookup tables get large and you pay a performance penalty.

## Introduction to code generation via hardware synthesis [#](#intro-code-gen)

We’ll take a break from poker to introduce a different technique. This is a technique that is far from universal but in certain cases can provide great results. We’ll start with a trivial example function:

`uint8_t isOdd(uint8_t input); // returns 1 iff input is odd`

Instead of writing out the body of the function, we’ll enumerate the inputs and outputs in binary and emit a truth table.

| input | output | |
|---|---|---|
| 0 | 0000 0000 | 0 |
| 1 | 0000 0001 | 1 |
| 2 | 0000 0010 | 0 |
| 3 | 0000 0011 | 1 |
| … | … | …. |
| 255 | 1111 1111 | 1 |

This is interpretable as a two-layer circuit built out of AND, OR, and NOT gates and defined by the on-set (rows outputting `1`

) of the function:

```
(~b7 & ~b6 & ~b5 & ~b4 & ~b3 & ~b2 & ~b1 & b0) |
(~b7 & ~b6 & ~b5 & ~b4 & ~b3 & ~b2 & b1 & b0) |
...
(b7 & b6 & b5 & b4 & b3 & b2 & b1 & b0)
```

This is a correct implementation, but it requires hundreds of gates to implement.

Enter hardware minimizers: We can pass this circuit to [espresso](https://en.wikipedia.org/wiki/Espresso_heuristic_logic_minimizer) and have it minimize it for us. The return is a compact two entry table. The `-`

in the input mean “don’t care”, ie the function output isn’t dependent on those bits from the input.

| input | output |
|---|---|
| —- —0 | 0 |
| —- —1 | 1 |

The minimized circuit is:

```
(b0)
```

The last step is to generate code:

```
uint8_t isOdd(uint8_t input) {
  return input & 1; // return 1 iff bit 0 of input is set
}
```

## Defining the Holdem hand evaluation function [#](#defining-the-holdem-hand-evaluation-function)

Now we apply the same technique to poker. We get to choose how inputs and outputs are encoded as long as the function correctly encodes the rules. We can encode inputs in a 52-bit bitset where bit `j`

is `1`

if and only if card `j`

is in play. Likewise for outputs, we output a bitset where output bit `k`

is `1`

if and only if the input evaluates to rank `k`

. We will adopt a more compact output encoding later.

As an example, we’ll encode the 7-card hand `[Ah, Kh, Qh, Jh, Th, 9h, 8h]`

. (Ah is shorthand for the ace of hearts and T is 10). This hand is a royal flush since Ah-Th are all in play. We’ll encode the input from left to right in suit-major order (left most is Ah), and a royal flush as output rank = 1.

| input | output |
|---|---|
| 1111 1110 0000 …. 0000 | 0000 0000 … 0001 |

For this function to be correct, any input with the left-most 5 bits set must also emit `1`

since they all score as a royal flush playing Ah-Th:

| input | output |
|---|---|
| 1111 1000 0000 …. 0011 | 0000 0000 … 0001 |
| 1111 1000 0000 …. 0101 | 0000 0000 … 0001 |
| … | … |
| 1111 1101 0000 …. 0000 | 0000 0000 … 0001 |
| 1111 1110 0000 …. 0000 | 0000 0000 … 0001 |

After minimization, this “heart royal flush” slice of the circuit looks like this with don’t-cares in most of the input bits:

| input | output |
|---|---|
| 1111 1— —- …. —- | 0000 0000 … 0001 |

We proceed as we did for `isOdd`

, except now the truth table is thousands of columns wide and over 100 million lines long. Still, we define a circuit that computes the function and pass it to a hardware minimizer. If we’re lucky, it will discover a compact form that we can translate into fast code.

## Describing circuits from function definitions [#](#describing-circuits-from-function-definitions)

For the `isOdd`

example function we used a truth table to define the circuit and `espresso`

to minimize it. That workflow fails for more complicated functions. Instead, we’ll define the logic in `verilog`

, use `yosys`

to synthesize to a netlist, and use [Berkeley abc](https://github.com/berkeley-abc/abc) for hardware minimization. The good news is the circuits we’re building are tiny from a hardware perspective so we won’t have any problem working with them in modern tooling.

If you haven’t encountered `verilog`

, it’s a hardware description language that reads like imperative code but synthesizes to hardware. Here’s a helper from this project that counts bits set in a 4-bit input and returns a 3-bit output:

```
function [2:0] popcnt4;
  input [3:0] x;
  integer pk;
  begin
    popcnt4 = 0;
    for (pk = 0; pk < 4; pk = pk + 1) 
      popcnt4 = popcnt4 + x[pk];
  end
endfunction
```

Although this looks like a naive imperative implementation of `popcount`

, it synthesizes to a combinational circuit: [
](/posts/fast-poker-hand-eval/popcnt4.svg)

## Mapping circuits to code [#](#mapping-circuits-to-code)

We’ll continue with `popcnt4`

and translate the circuit to code. Every wire becomes a variable and every gate a bitwise expression. We have some freedom in the order in which expressions are emitted as long as a value is not used before it is computed:

``` js
void popcnt4(const uint64_t *in, uint64_t *out) {
    uint64_t x0 = in[0];
    uint64_t x1 = in[1];
    uint64_t x2 = in[2];
    uint64_t x3 = in[3];
 
    uint64_t t0 = (~x0 & x1) | (x0 & ~x1);
    uint64_t t1 = (~t0 & x2) | (t0 & ~x2);
    uint64_t y0 = (~t1 & x3) | (t1 & ~x3);
    uint64_t t2 = x0 & x1;
    uint64_t t3 = t0 & x2;
    uint64_t t4 = ~t3 & ~t2;
    uint64_t t5 = t1 & x3;
    uint64_t y1 = (~t5 & ~t4) | (t5 & t4);
    uint64_t y2 = t5 & t2;
 
    out[0] = y0;
    out[1] = y1;
    out[2] = y2;
}
```

Something immediately stands out. The generated code doesn’t have the expected signature `int popcnt4(int x)`

. Instead, it takes an integer for every bit in the input and emits an integer for every bit in the output. This is a result of the circuit operating on bits, not words. The bad news is we need to bit-transpose the input and output to make use of this function in most cases, and those transposes are expensive. The good news, though, is that we can pack 64 bits into a uint64_t, so this function computes 64 popcnt4 operations in parallel. And we can just as easily emit 256- or 512-bit words for a higher degree of parallelism. This form of computation where each bit in a register carries a separate lane of data is called bitslicing:

The inherent cost of transposing I/O for bitslicing is conceptually similar to the overhead of moving a computation out of process and paying for IPC, or of moving a computation into a GPU and paying for PCIe bandwidth. Strictly from a computational efficiency standpoint, it only makes sense to adopt any of these if the achieved speedup exceeds the additional overhead and justifies the added complexity.

As an aside, `popcount`

provides a canonical example of a similar concept called [SIMD-within-a-register (SWAR)](https://en.wikipedia.org/wiki/SWAR). Instead of each bit carrying a separate lane of data, the data is iteratively reduced within a register.

## Choice of encodings [#](#choice-of-encodings)

A lot of creativity can go into encoding design when the problem allows you the freedom to choose. For this problem, I spent very little time on the input encoding. An n-hot 52-bit encoding of the cards in play is both very natural for the problem and easy to work with. I did iterate some on the output encoding.

In an example above, we used a maximally-sparse 7kbit one-hot encoding for the output. On the other end of the spectrum is a maximally-dense encoding. There are 7462 unique five card poker hands. Simply number them from 1-7462 and store the result in 13 bits. This is the encoding used by some evaluators. The downside is that it is expensive to compute in hardware. Initial work showed that it required nearly as many gates to encode the output as was required to evaluate a hand.

As a middle ground, we’ll adopt a 24 bit encoding where the hand category (straight flush=9 -> high card=1) is stored in the most significant nibble and the remaining 5 nibbles encode enough information to break ties. Re-synthesising with this encoding showed it to be significantly easier to compute. There are even more efficient encodings available if anyone wants to really dig in to this problem.

## Poker circuits [#](#poker-circuits)

We finally have all the tools we need to build the evaluator circuits. We still have a ton of work to do of course, but we have the benefit on this project of having known-good implementations we can validate against. We let Claude get to work writing verilog and the thousands of lines of python, shell scripts, makefiles, test harnesses, and benchmarks that glue everything together. The circuits can be validated at every stage in the pipeline so logic errors don’t make it far. Most of the verilog is approachable even without direct experience. Each evaluation circuit is only a few hundred lines. The Holdem circuit exploits the same factoring that fast software evaluators exploit:

The 52 bit input flows into two blocks: the rank side and the flush side. The rank side counts cards per rank, detects pairs, trips, and quads, checks straights, and computes the rank-side category and output encoding. The flush side counts cards per suit, detects flushes and straight flushes, and computes the flush-side category and output encoding. The two sides are muxed together and if a flush is present, the flush side is emitted, otherwise the rank side. We pass the circuit through the pipeline and end up with ~1200 lines of C that computes it.

I’ll also note, the first holdem circuit we built was hundreds of thousands of gates whereas the final was only 1200. Effectively all of that gain came from re-specifying the hardware. Synthesis tools are invaluable for removing redundancy, but they are limited to behavior-preserving transforms. Wherever we had latitude in the design, we iterated to find alternatives that were cheaper to compute.

## AVX512 backend [#](#avx512-backend)

With a working code generation pipeline, we can now generate code for AVX512. In addition to a larger word size, AVX512 includes an instruction `vpternlog`

that executes arbitrary ternary functions. This is appealing because it requires fewer 3-input gates than 2-input gates to implement the same circuit, so that theoretically translates into fewer instructions and faster code.

The initial approach to AVX512 was to map the hardware to 3-input logic and emit `vpternlog`

manually. After running initial tests, we backed off from this because GCC was so efficient at automatically fusing binary ops to `vpternlog`

. After finding a large regression in gcc-15 (relative to this code), we re-introduced emission of `vpternlog`

via a macro in the general CPU codegen backend.

## WebGPU backend [#](#webgpu-backend)

After the CPU backend was working well and I thought the project was close to complete, I provided a spark of “what-if” to Claude and very shortly after we had incredibly high throughput WebGPU compute shaders and the need to build out a front-end demo.

We spent a lot of time iterating towards a design for the demo that was fast enough to be compelling without being overwhelmingly complex. We ended up enumerating hands in Javascript and using three shaders per variant to support fast computation while enumerating along different axes. There is still a lot here to explore for someone who wants to build a really fast WebGPU equity calculator: fusing in hardware two evaluators and a comparator to let the synthesis tools optimize away the output encoders, enumeration of hands within the shaders, and unfusing the Omaha board and hand partial evaluations to cut repeated work. I now want to do a deep dive in WebGPU to create something that challenges me to learn all the nitty details.

## Micro-optimization [#](#micro-optimization)

More than any other technical area of this project, I lacked the expertise to get exceptional results out of Claude with reasonable effort when it was time to optimize this code for different architectures. I don’t get to do this type of work nearly often enough to have an innate sense of what is real and what’s a quirk of the environment, so we spent some time chasing ghosts instead of just rejecting a result and moving on. That said, I had a lot of fun here, and I think someone who is really talented at micro-optimization could also have a lot of fun with this code.

It’s very large straight-line vector code that is 10x+ oversubscribed. Omaha is *right* on the edge of a lot of hardware cache sizes. We could measure 10-20% performance swings based only on the layout of the binary on Zen 5 which we attributed to an artifact of where the code landed in the uop cache. We hit something that felt like LRU cache thrashing where one run would stream 99% from the uop cache and the next was only 60%. The approach to these was to hammer on the code size and instruction count.

Every architecture and compiler version brought new surprises. Xeon 6 was slower overall but more forgiving in that we didn’t experience any performance cliffs. And the Apple M5 is much more forgiving for table-based methods. We were still finding general optimizations right up until the end so I’m pretty sure we left some performance on the table. And I want to say, AMD’s Zen 5 is an absolute *beast* of a machine.

## Benchmarks [#](#benchmarks)

These are measured on an EC2 Zen 5 c8a.2xlarge instance, AVX512 backend, single threaded. There are AVX2 and Apple M5 benchmarks available in the repo.

For the first two benchmarks, every evaluator starts from the same input, a list of seven card ids per hand, and builds whatever it needs on the clock with no state maintained across hands. The aim of these is to measure the raw per-hand evaluation performance without the benefit of caching partial evaluation.

### Holdem, 7 cards (bench/holdem.cc) [#](#holdem-7-cards-benchholdemcc)

| Order | Evaluator | ns/hand | vs CircuitEval | vs Seq |
|---|---|---|---|---|
| sequential | CircuitEval | 0.619 | – | – |
| sequential | OMPEval | 1.40 | 2.3x | – |
| sequential | TwoPlusTwo | *2.42 | 3.9x | – |
| sequential | PHE | 8.86 | 14.3x | – |
| random | CircuitEval | 0.620 | – | 1.0x |
| random | OMPEval | 1.67 | 2.7x | 1.2x |
| random | TwoPlusTwo | 11.8 | 19.1x | 4.9x |
| random | PHE | 16.4 | 26.4x | 1.8x |

*This benchmark really undersells how fast the TwoPlusTwo evaluator is at sequential enumeration when it is allowed to maintain state. See below for a fairer comparison. Also note that CircuitEval’s performance is independent of enumeration order.

### Omaha (bench/omaha.cc) [#](#omaha-benchomahacc)

| Variant | Order | Evaluator | ns/hand | vs CircuitEval | vs Seq |
|---|---|---|---|---|---|
| PLO4 | sequential | CircuitEval | 1.044 | – | – |
| PLO5 | sequential | CircuitEval | 1.057 | – | – |
| PLO6 | sequential | CircuitEval | 1.082 | – | – |
| PLO4 hi-lo | sequential | CircuitEval | 1.127 | – | – |
| PLO4 | sequential | PHE | 11.91 | 11.4x | – |
| PLO5 | sequential | PHE | 12.41 | 11.7x | – |
| PLO6 | sequential | PHE | 13.57 | 12.5x | – |
| PLO4 | random | CircuitEval | 1.026 | – | 1.0x |
| PLO5 | random | CircuitEval | 1.059 | – | 1.0x |
| PLO6 | random | CircuitEval | 1.081 | – | 1.0x |
| PLO4 hi-lo | random | CircuitEval | 1.108 | – | 1.0x |
| PLO4 | random | PHE | 38.74 | 37.7x | 3.3x |
| PLO5 | random | PHE | 81.18 | 76.6x | 6.5x |
| PLO6 | random | PHE | 124.4 | 115x | 9.2x |

PHE is the only other library I measured with dedicated PLO4/5/6 evaluators, and it has no Omaha hi-lo evaluator.

### Holdem, enumeration order (bench/holdem_enum.cc) [#](#holdem-enumeration-order-benchholdem_enumcc)

In this benchmark, each evaluator is written to maintain state across evaluations when applicable. The aim of this is to measure the maximum per-hand throughput that is achievable by each evaluator. TwoPlusTwo hoists prefix lookups per loop level, OMPEval keeps one partial Hand per level, CircuitEval carries the prefix mask per level and ORs in the last two cards from a table of all two-card masks. PHE keeps no per-prefix state a caller could reuse, so it runs the plain loop. This is the exact problem TwoPlusTwo was built to solve and it solves it exceptionally fast.

| Evaluator | ns/hand | vs CircuitEval |
|---|---|---|
| CircuitEval | 0.488 | – |
| TwoPlusTwo | 0.247 | 0.51x |
| OMPEval | 0.746 | 1.53x |
| PHE | 9.85 | 20.2x |

### Binary Sizes [#](#binary-sizes)

| Evaluator | Code (KiB) | Data (KiB) | Total (KiB) | *vs CircuitEval |
|---|---|---|---|---|
| CircuitEval holdem | 19.9 | 1.1 | 21.0 | – |
| CircuitEval omaha | 30.1 | 0.9 | 31.1 | – |
| CircuitEval omaha hi-lo | 31.4 | 1.0 | 32.4 | – |
| TwoPlusTwo | ~0 | 126,906 | 126,906 | 6,041x |
| OMPEval | 14.0 | 219 | 232.6 | 11.1x |
| PHE holdem | 0.9 | 122 | 122.6 | 5.8x |
| PHE PLO4 | 1.1 | 29,956 | 29,957 | 964x |
| PHE PLO5 | 1.1 | 111,738 | 111,740 | 3,594x |
| PHE PLO6 | 1.0 | 352,926 | 352,927 | 11,351x |

*By variant: TwoPlusTwo, OMPEval, PHE holdem compare to CircuitEval holdem. PHE PLO4/5/6 compare to CircuitEval omaha

## Acknowledgements [#](#acknowledgements)

This project is a collaboration between Claude Fable 5 and me.
