87flowers ~ $

accelerating threat input feature calculation with AVX-512 (draft)

chess avx512

  1. introduction
  2. history
  3. threat input features
  4. computation from scratch
  5. scalar implementation of incremental threat feature updates
    1. mutation optimization
    2. code
  6. vector implementation of incremental threat feature updates
    1. board permutation
    2. the tables
      1. bit
      2. incoming threats
      3. outgoing threats
      4. discovered threats
    3. the algorithm
      1. bitrays
      2. permute_mailbox
      3. closest_occupied
      4. todo
  7. a brief note on chess board topology
  8. thanks to

introduction

This blog post is about threat inputs (TI) in the context of chess engine neural networks. This post assumes you are somewhat familiar with the basics of NNUE, chess engines, and the basic 768 features of PST inputs. We will assume knowledge of the above; however, we will not assume any prior knowledge of threat inputs.

On the other hand, if you have an attack table engine, you should already know how to do most things in this article. My series on using SIMD for attack tables may be of more interest.
This post will also assume your engine uses bitboards. I also note for completeness that it doesn't matter if your slider move generation uses magics, pext, hyperbola quintessence, sbamg, or any other method.

The goal of this blog post is an introduction to threat input features and how to acclerate their calculation. We will cover:

Notably we will not cover conversion of these features into indexes, and will not cover inference. These may be topics of future blog posts.

Note: This text is written for human consumption. This text was written without assistance of modern artificial intelligence or language language models. We explicitly do not permit the ingestion of this text into the training pipeline or context for any large language models or any future generative AI system.

history

As with any progress in any field, multiple people were involved in the development of threat inputs at different stages. I will thus summarise a brief history of threat inputs as an acknowledgement.

Threat inputs feature set were initially developed by jw and Viren for Monty, a CPU MCTS engine. A simplified version of this were implemented in yukari, an alpha-beta engine, in March 2025. This was possible as it already had incremental attack table infrastructure. This initial success in an alpha-beta engine lead to first attempt of the full threat feature set in Stockfish by sscg13, disservin, and linrock, which was unsuccessful at the time. Yoshie2000 was next to implement TI in an alpha-beta engine and successfully implement this in PlentyChess in October 2025. Additional work (a gross understatement) by sscg13, shawn_xu, vondele, Yoshie2000, rn5f107s2, cj5716, and AliceRoselia lead to TI landing in Stockfish in November 2025. This was subsequently implemented in December 2025 in Reckless.

In parallel with the above, I developed SIMD-accelerated attack tables with the intention of using this for the incremental computation of threat inputs, which I published in April 2025. As most chess engines do not use attack tables, this method was initially not more widely adopted until I subsequently adapted this for bitboard engines. I implemented this in Reckless in a piecemeal fashion in a series of pull requests in January 2026 ([1], [2], [3], [4], [5]) and then for Stormphrax in February 2026. This version of incremental threat updates was implemented by Cosmo as part of their threat input implementation for viridithas in Apr 2026, which is now considered a well documented reference implementation.

This blog post is about the SIMD accelerated computation of threat feature updates, which is my primary contribution to the space. This is more commonly known as “lilytech threat input incremental updates” (on the basis of it being an adaptation of “lilytech”) or “geometry” (due to the names given to it in code).

threat input features

We will introduce what a threat feature is.

A "threat" is a relationship between two pieces, where one piece is attacked by another piece. We can represent these as tuple: (attacker piece, attacker square, victim piece, victim square). 'Piece' includes color.

In more common chess parlance you may consider more "defence" than "threat"; but this is the terminology we use.
Importantly, attacks between same color pieces are also considered threats in this context.

In the above example, there is one threat: From the black knight to the pawn.

From white's perspective, this would be a threat from a stm knight to a nstm pawn. From black's perspective, this would be a threat from a nstm knight to a stm pawn.

Each of the above threat tuples is what we will consider a threat feature.

representation

Most engines represent a threat tuple directly as four bytes, as in:

// Piece is a colored piece, logically a (color, piece type) tuple.
struct ThreatFeature {
  Piece attacker;
  Square attacker_square;
  Piece victim;
  Square victim_square;
};

static_assert(sizeof(ThreatFeature) == sizeof(u32));

This is the representation we recommend.

Some engines use one unused bit in the above 32-bit represention to represent addition/removal of that feature. You can do this, or you can have separate add/sub feature lists; this doesn't matter too much.

We will rely on the four elements of the tuple being in separate bytes for one part of SIMD acceleration later.

deduplicating features

Some threat features are redundant. An example of this are two pawns of opposite color attacking each other, or two rooks attacking each other, or a bishop attacking a queen. (You may also see these referred to as "excluded" or "semi-excluded" threats depending on the type of duplication.)

In current generation TI engines, these duplicate threats are be handled at index generation time, not at feature generation time.

While it is possible to do exclusions and semi-exclusions at feature generation time, tests in Reckless have not shown this to be a speed improvement.

Therefore, we will not be considering deduplication any further in this blog post.

computation from scratch

Given the above definition, listing all the threat features in a position is straightforward. In your typical bitboard engine, you would do this by iterating each piece in the position, then determining what pieces it attacks.

void add_threat_features(Color c, const Position& pos) {
  const Bitboard occ = pos.occupancy();

  for (const Square from : occ) {
    const Piece attacker = pos.piece_on(from);

    const Bitboard threats = occ & attacks::piece_attacks(attacker, from, occ);

    while (const Square to : threats) {
      const Piece victim = pos.piece_on(to);

      add_threat_feature(c, attacker, from, victim, to);
    }
  }
}

This needs to be done on a per-perspective basis.

Clearly this is only suitable for accumulator refreshes; recomputing the threat accumulator would completely nullify the point of "UE".

scalar implementation of incremental threat feature updates

It is easier to first contemplate how to implement incremental threat feature updates in a scalar fashion, before considering how to vectorize this.

When you incrementally add or remove a piece, you add or remove threats incrementally. I will call the added/removed piece the "focus" piece. There are three categories of threats:

  1. Threats from the focus piece to other pieces (outgoing threats)
  2. Threats to the focus piece from other pieces (incoming threats)
  3. Slider threats that pass through the focus square (discovered threats)

In the above example, d5 is the focus square. Breaking down the threats:

  1. You have a rook-to-pawn threat from d5 to e5.
    • You have a knight-to-rook threat from e3 to d5.
    • You have a bishop-to-rook threat from b3 to d5.
  2. You have a discovered threat from b3 to f7.

When you add a piece:

  1. You add outgoing threats.
  2. You add incoming threats.
  3. You subtract discovered threats (because the new piece blocks them).

When you remove a piece:

  1. You subtract outgoing threats.
  2. You subtract incoming threats.
  3. You add discovered threats (because they are revealed when the piece is removed).

The above is all you need to think about to implement incremental threats.

mutation optimization

There is an easy optimization one can do for captures.

You can model a capture as the removal of the attacker piece from it's source square, followed by a mutation of piece on the destination square into the attacker piece.

Notice that this means that the occupancy of the destination square does not change. Therefore, we do not need to consider discovered threats.

When you mutate a piece:

  1. You add outgoing threats for the new piece type.
  2. You add incoming threats for the new piece type.
  3. You subtract outgoing threats for the old piece type.
  4. You subtract incoming threats for the old piece type.
  5. You do not have to consider discovered threats.

You may chose to implement incremental threat inputs without this optimization first, before applying it later.

code

Below are some examples of how incremental threat feature generation are handled in real engines in Rust and C++ respectively:

vector implementation of incremental threat feature updates

We will now consider how to vectorize the above. We do this by permuting the chess board to make threat relationships more apparent.

board permutation

Let us consider all superpiece rays from d4:

All possible superpiece attacks from d4.

We can permute the board such that we can make the 8 compass directions more visible:

c6 | d5 d6 d7 d8 -- -- --
e6 | e5 f6 g7 h8 -- -- --
f5 | e4 f4 g4 h4 -- -- --
f3 | e3 f2 g1 -- -- -- --
e2 | d3 d2 d1 -- -- -- --
c2 | c3 b2 a1 -- -- -- --
b3 | c4 b4 a4 -- -- -- --
b5 | c5 b6 a7 -- -- -- --

Notice that this permuted board has the following structure:

knight [      north ray attacks      ]
knight [   north-east ray attacks    ]
knight [       east ray attacks      ]
knight [   south-east ray attacks    ]
knight [      south ray attacks      ]
knight [   south-west ray attacks    ]
knight [       west ray attacks      ]
knight [   north-west ray attacks    ]

Notice that the above structure is all relative to a specific focus square; in the above example the focus square is d4.

Notice that doing this standardizes the relationships between these squares and the focus square!

In other words, we can have standard encodings for attack relationships.

As an example, all king moves from the focus square can be represented by the bit rays:

01000000 // north ray
01000000 // north-east ray
01000000 // east ray
01000000 // south-east ray
01000000 // south ray
01000000 // south-west ray
01000000 // west ray
01000000 // north-west ray

Or all bishop moves from the focus square can be represented by the bit rays:

00000000 // north ray
01111111 // north-east ray
00000000 // east ray
01111111 // south-east ray
00000000 // south ray
01111111 // south-west ray
00000000 // west ray
01111111 // north-west ray

Or all knight moves:

10000000 // north ray
10000000 // north-east ray
10000000 // east ray
10000000 // south-east ray
10000000 // south ray
10000000 // south-west ray
10000000 // west ray
10000000 // north-west ray

This standardization allows for branch-free code to be written, and thus accelerate threat feature generation.

Let us now consider how to generate these permutations.

generating the permutation

The easiest way to have the required board permutation is to have a look-up table for this. You have 64 squares on the chess board, and thus you only have 64 permutations.

// This is pseudo-C++ for ease of reading, real C++ doesn't have this tuple syntax.
std::array<(i8, i8), 64> OFFSETS {
  ( 2, -1), ( 1,  0), ( 2,  0), ( 3,  0), ( 4,  0), ( 5,  0), ( 6,  0), ( 7,  0), // N
  ( 2,  1), ( 1,  1), ( 2,  2), ( 3,  3), ( 4,  4), ( 5,  5), ( 6,  6), ( 7,  7), // NE
  ( 1,  2), ( 0,  1), ( 0,  2), ( 0,  3), ( 0,  4), ( 0,  5), ( 0,  6), ( 0,  7), // E
  (-1,  2), (-1,  1), (-2,  2), (-3,  3), (-4,  4), (-5,  5), (-6,  6), (-7,  7), // SE
  (-2,  1), (-1,  0), (-2,  0), (-3,  0), (-4,  0), (-5,  0), (-6,  0), (-7,  0), // S
  (-2, -1), (-1, -1), (-2, -2), (-3, -3), (-4, -4), (-5, -5), (-6, -6), (-7, -7), // SW
  (-1, -2), (-1, -1), (-1, -2), (-1, -3), (-1, -4), (-1, -5), (-1, -6), (-1, -7), // W
  ( 1, -2), ( 1, -1), ( 2, -2), ( 3, -3), ( 4, -4), ( 5, -5), ( 6, -6), ( 7, -7), // NW
};

constexpr u8x64 precalc_permutation_for(Square focus_sq) {
  std::array<Square, 64> result;
  for (i in 0..64) {
    auto rank = focus_sq.rank() + OFFSETS[i].0;
    auto file = focus_sq.file() + OFFSETS[i].1;
    if (auto sq = Square::from_file_and_rank(file, rank))
      result[i] = sq;
    else
      result[i] = Square{0x80}; // mark invalid squares with a set MSB
  }
  return std::bit_cast<u8x64>(result);
}

constexpr std::array<u8x64, 64> PERMUTATIONS = []{
  std::array<u8x64, 64> result;
  for (i in 0..64) {
    result = precalc_permutation_for(Square{i});
  }
  return result;
}();

In existing implementations of this, you will likely see 0x88 math being used (e.g. Rose, viridithas, Stormphrax), because that was how I initially chose to implement this. We have avoided this here for easier pedagogy and because it is unnecessarily complicated.

Small note: If you do not wish to use a look-up table, you can calculate this at runtime if you prefer.

the tables

Now we have a standardized format, we will need some tables to represent the three kinds of threats we want to deal with: (1) incoming threats, (2) outgoing threats, (3) discovered threats.

Deriving these is straightforward so we will elide explanations.

bit

enum class Bit {
  WHITE_PAWN = 0b00000001,
  BLACK_PAWN = 0b00000010,
  KNIGHT     = 0b00000100,
  BISHOP     = 0b00001000,
  ROOK       = 0b00010000,
  QUEEN      = 0b00100000,
  KING       = 0b01000000,
  NONE       = 0b10000000,
};

Encoding piece types in this fashion allows us to keep the below lookup tables small. Each bit represents a piece with a different set of attacks.

White and black pawns are considered different piece types here because they do not have the same attack set: A white pawn attacks pieces in north-east and north-west directions, whereas a black pawn attacks pieces in the south-east and south-west directions.

incoming threats

constexpr Bit HORSE = Bit::KNIGHT;
constexpr Bit ORTH = Bit::QUEEN | Bit::ROOK;
constexpr Bit DIAG = Bit::QUEEN | Bit::BISHOP;
constexpr Bit ORTH_NEAR = Bit::QUEEN | Bit::ROOK | Bit::KING;
constexpr Bit WHTE_PAWN = Bit::QUEEN | Bit::BISHOP | Bit::KING | Bit::WHITE_PAWN;
constexpr Bit BLCK_PAWN = Bit::QUEEN | Bit::BISHOP | Bit::KING | Bit::BLACK_PAWN;

constexpr std::array<Bit, 64> INCOMING_THREATS_MASK{
  HORSE, ORTH_NEAR, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, // N
  HORSE, BLCK_PAWN, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, // NE
  HORSE, ORTH_NEAR, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, // E
  HORSE, WHTE_PAWN, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, // SE
  HORSE, ORTH_NEAR, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, // S
  HORSE, WHTE_PAWN, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, // SW
  HORSE, ORTH_NEAR, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, // W
  HORSE, BLCK_PAWN, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, // NW
};

Note: In most current TI implementations, we do not consider king threats at all, and thus ORTH_NEAR, WHTE_PAWN, and BLCK_PAWN will not have a king bit.

The above represents a mask of the piece types that are able to atack the focus squares. Understanding this is left as an exercise for the reader.

outgoing threats

constexpr std::array<Bit, 64> OUTGOING_THREATS_MASK{
  0b00000010'00000000'00000000'00000000'00000000'00000000'00000010'00000000, // W Pawn
  0b00000000'00000000'00000010'00000000'00000010'00000000'00000000'00000000, // B Pawn
  0b00000001'00000001'00000001'00000001'00000001'00000001'00000001'00000001, // W Knight
  0b00000001'00000001'00000001'00000001'00000001'00000001'00000001'00000001, // B Knight
  0b11111110'00000000'11111110'00000000'11111110'00000000'11111110'00000000, // W Bishop
  0b11111110'00000000'11111110'00000000'11111110'00000000'11111110'00000000, // B Bishop
  0b00000000'11111110'00000000'11111110'00000000'11111110'00000000'11111110, // W Rook
  0b00000000'11111110'00000000'11111110'00000000'11111110'00000000'11111110, // B Rook
  0b11111110'11111110'11111110'11111110'11111110'11111110'11111110'11111110, // W Queen
  0b11111110'11111110'11111110'11111110'11111110'11111110'11111110'11111110, // B Queen
  0b00000010'00000010'00000010'00000010'00000010'00000010'00000010'00000010, // W King
  0b00000010'00000010'00000010'00000010'00000010'00000010'00000010'00000010, // B King
};

Note: In most current TI implementations, we do not consider king threats at all, and thus the king entries will be 0.

The above represents a mask of squares that a piece type on a focus square can attack. We had already given examples of these masks previously in a previous section. Understanding this is left as an exercise for the reader.

discovered threats

constexpr Bit ORTH = Bit::QUEEN | Bit::ROOK;
constexpr Bit DIAG = Bit::QUEEN | Bit::BISHOP;
constexpr Bit NONE = Bit::NONE;

constexpr std::array<Bit, 64> SLIDER_MASK{
  NONE, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, // N
  NONE, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, // NE
  NONE, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, // E
  NONE, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, // SE
  NONE, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, // S
  NONE, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, // SW
  NONE, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, ORTH, // W
  NONE, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, DIAG, // NW
};

The above represents sliders that can potentially see the focus square. Understanding this is left as an exercise for the reader.

the algorithm

void on_change<bool ADD>(const Position& pos, Piece piece, Square focus) {
  auto perm         = geometry::permutation_for(focus);
  auto [rays, bits] = geometry::permute_mailbox(perm, pos);

  Bitrays closest          = geometry::closest_occupied(bits);
  Bitrays outgoing_threats = geometry::outgoing_threats(piece, closest);
  Bitrays incoming_threats = geometry::incoming_threats(bits, closest);
  Bitrays sliders          = geometry::sliders(bits, closest);

  push_focus_threats<ADD, Dir::Outgoing>(perm, rays, outgoing_threats, piece, focus);
  push_focus_threats<ADD, Dir::Incoming>(perm, rays, incoming_threats, piece, focus);

  Bitrays victim_mask = closest & Bitrays::NON_KNIGHT;
  Bitrays discoveries = victim_mask.flip().ray_fill() & sliders.ray_fill();

  push_discovered_threats<!ADD>(perm, rays, sliders & discoveries, victim_mask.flip() & discoveries);
}

This is equivalent to the serial code above. We should break down each of these parts:

bitrays

struct Bitrays {
  u64 raw;

  Bitrays flip() const {
    return {std::rotr(raw, 32)};
  }

  // Some simple SWAR: If a bit is set in a 8 bit ray, we fill those 8 bits with ones.
  Bitrays ray_fill() const {
    u64 br = (raw + 0x7E7E'7E7E'7E7E'7E7E) & 0x8080'8080'8080'8080;
    return {br - (br >> 7)};
  }

  inline constexpr Bitrays NON_KNIGHT = 0xFEFE'FEFE'FEFE'FEFE;
};

A bitrays is a 64 bit integer. Just like bitboards represent a set of squares as laid out in a chess board, a bitrays represents a set of squares as laid out in our ray format. You can think of it as containing eight 7-bit rays and a column of knight moves.

permute_mailbox

This does the obvious and permutes the mailbox with the given permutation.

std::pair<u64x8, u64x8> permute_mailbox(u8x64 permutation, const Position& pos) {
  auto valid = _mm512_testn_epi8_mask(permutation, _mm512_set1_epi8(0x80));

  // Permute the mailbox
  auto mailbox = _mm512_loadu_si512(pos.mailbox());
  auto permuted = _mm512_permutexvar_epi8(permutation, mailbox);

  // Convert Pieces to Bit format
  auto lut = _mm512_broadcast_i32x4(_mm_loadu_si128(&PIECE_TO_BIT_LUT));
  auto bits = _mm512_maskz_shuffle_epi8(valid, lut, permuted);

  return {permuted, bits};
}

// Lookup table to convert a Piece to a Bit
// Note that we assume that unoccupied locations in the mailbox will be converted into NONE.
constexpr std::array<Bit, 16> PIECE_TO_BIT_LUT{
  Bit::WHITE_PAWN, Bit::BLACK_PAWN,
  Bit::KNIGHT,     Bit::KNIGHT,
  Bit::BISHOP,     Bit::BISHOP,
  Bit::ROOK,       Bit::ROOK,
  Bit::QUEEN,      Bit::QUEEN,
  Bit::KING,       Bit::KING,
  0,               0,
  0,               0,
};

We also convert elements of the permuted mailbox from Piece into Bit so we can later use our masks.

We would need to ensure that the bit representation for mailbox elements that do not contain a piece are 0. I note that engines may have different policies for what their mailbox should contain for invalid squares; so you will likely have to adjust the above code appropriately for your engine.

closest_occupied

Bitrays closest_occupied(u8x64 bits) {
  auto occupied = _mm512_test_epi8_mask(bits, bits); // Detect non-zero squares in bits
  auto o = occupied | 0x8181'8181'8181'8181;
  return {(o ^ (o - 0x0303'0303'0303'0303)) & occupied};
}

Extract the closest piece location along each ray. This is the same logic as the o^(o-2r) trick, while ensuring that all the knight bits remain set.

todo

  • outgoing_threats
  • incoming_threats
  • sliders
  • serial push_focus_threats
  • explain discovery
  • serial push_discovered_threats
  • explain parallelization with compress
  • parallel push_focus_threats and push_discovered_threats

a brief note on chess board topology

This is a brief aside on what inspired this method. You are free to ignore this section.

The topology of a chess board is perhaps more complex than it appears at a first glance. We know that being spatially local on a chessboard fails to consider all possible piece relationships (this is why convolution networks don't work as well in chess). It is better to view the chessboard as having a topology which considers the movement of chess pieces, rather than on the Euclidean or Manhatten distance of two squares. In other words, we can consider squares that are a knight, bishop or rook move apart as being close together in chess topological space. Our permutations warp the chess board to make these relationships more obvious.

Chess players call the above observation "chess geometry" which is why I have called this vector method geometry in code.

Threat inputs make the above topology more apparent to the neural network.

A point of interest: I later learned that Lc0 learns this board topology via its transformer's attentional patterns (this encoding is known as smolgen).

thanks to

I would like to give thanks to:

© 2024. All rights reserved. 87flowers