Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

⚡ Improve position UCI command parsing (I) #410

Merged
merged 25 commits into from
Sep 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
82233d5
Use `MemoryExtensions.Split()` for fen 'sections' after board
eduherminio Sep 15, 2023
be12ac8
Use ' ' instead of " " in FENParser
eduherminio Sep 15, 2023
2b59403
Improve `Game` parsing with same .AsSpan().Split() techniche
eduherminio Sep 15, 2023
cc89fe0
Use more varied position commands for ParseGame benchmark
eduherminio Sep 15, 2023
8f06045
Add benchmark results
eduherminio Sep 15, 2023
b81c6a3
Rename variables and add benchmark results
eduherminio Sep 15, 2023
d3063f5
Readd support for capital 'W' and 'B' in fen, refactor fen parser
eduherminio Sep 15, 2023
afbaaa4
Make Move.TryParseFromUCIString accept a ReadOnlySpan<char> instead o…
eduherminio Sep 15, 2023
a447c02
Merge branch 'main' into perf/improve-fen-parser
eduherminio Sep 15, 2023
8280f52
Revert Span usage for parsed moves for now, since we don't really kno…
eduherminio Sep 15, 2023
9bf8999
Add test to cover this regression
eduherminio Sep 15, 2023
5f76c52
Revert constructor accessibility changes
eduherminio Sep 15, 2023
c2f9f61
Merge branch 'main' into perf/improve-fen-parser
eduherminio Sep 16, 2023
3fe6a01
Add more benchmarks
eduherminio Sep 16, 2023
0127939
Add more alternatives
eduherminio Sep 16, 2023
8716057
Use the best option and add benchmark results
eduherminio Sep 16, 2023
8ed7395
Bugfix and add another entry to benchmark
eduherminio Sep 16, 2023
9ddb5a5
Use last solution, make sure to throw exceptions in benchmarks when p…
eduherminio Sep 16, 2023
aef2d8f
Fix TB tests, add tests to prove position command with extra spaces a…
eduherminio Sep 16, 2023
ee858c8
Fix FEN() refactoring
eduherminio Sep 16, 2023
2f542e0
Update benchmark results
eduherminio Sep 16, 2023
f9824f0
Cleaning and consolidating new `ReadOnlySpan<char>` methods
eduherminio Sep 16, 2023
224fe7a
Add benchmark with improved ParseFEN
eduherminio Sep 16, 2023
1aee943
Use improved FENParser, add benchmark results
eduherminio Sep 16, 2023
3e71fd3
Merge branch 'main' into perf/improve-fen-parser-1
eduherminio Sep 17, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,15 @@ jobs:
run: dotnet build -c Release

- name: Run ${{ github.event.inputs.benchmark_name }} benchmark
if: matrix.os == 'windows-latest'
run: dotnet run -c Release --no-build --filter '${{ github.event.inputs.benchmark_name }}'
working-directory: ./src/Lynx.Benchmark

- name: Run ${{ github.event.inputs.benchmark_name }} benchmark with sudo
if: matrix.os != 'windows-latest'
run: sudo dotnet run -c Release --no-build --filter '${{ github.event.inputs.benchmark_name }}'
working-directory: ./src/Lynx.Benchmark

- name: 'Upload ${{ matrix.os }} artifacts'
continue-on-error: true
uses: actions/upload-artifact@v3
Expand Down
971 changes: 971 additions & 0 deletions src/Lynx.Benchmark/ParseFEN.cs

Large diffs are not rendered by default.

471 changes: 471 additions & 0 deletions src/Lynx.Benchmark/ParseGame.cs

Large diffs are not rendered by default.

120 changes: 61 additions & 59 deletions src/Lynx/FENParser.cs
Original file line number Diff line number Diff line change
@@ -1,29 +1,22 @@
using Lynx.Model;
using NLog;
using System.Text.RegularExpressions;
using System.Runtime.CompilerServices;

using ParseResult = (bool Success, ulong[] PieceBitBoards, ulong[] OccupancyBitBoards, Lynx.Model.Side Side, byte Castle, Lynx.Model.BoardSquare EnPassant,
int HalfMoveClock, int FullMoveCounter);

namespace Lynx;

public static partial class FENParser
public static class FENParser
{
[GeneratedRegex("(?<=^|\\/)[P|N|B|R|Q|K|p|n|b|r|q|k|\\d]{1,8}", RegexOptions.Compiled)]
private static partial Regex RanksRegex();

private static readonly Regex _ranksRegex = RanksRegex();

private static readonly Logger _logger = LogManager.GetCurrentClassLogger();

public static (bool Success, BitBoard[] PieceBitBoards, BitBoard[] OccupancyBitBoards, Side Side, byte Castle, BoardSquare EnPassant,
int HalfMoveClock, int FullMoveCounter) ParseFEN(string fen)
public static ParseResult ParseFEN(ReadOnlySpan<char> fen)
{
fen = fen.Trim();

var pieceBitBoards = new BitBoard[12] {
default, default, default, default,
default, default, default, default,
default, default, default, default};

var occupancyBitBoards = new BitBoard[3] { default, default, default };
var pieceBitBoards = new BitBoard[12];
var occupancyBitBoards = new BitBoard[3];

bool success;
Side side = Side.Both;
Expand All @@ -33,59 +26,72 @@ public static partial class FENParser

try
{
MatchCollection matches;
(matches, success) = ParseBoard(fen, pieceBitBoards, occupancyBitBoards);
success = ParseBoard(fen, pieceBitBoards, occupancyBitBoards);

var unparsedString = fen[(matches[^1].Index + matches[^1].Length)..];
var parts = unparsedString.Split(" ", StringSplitOptions.RemoveEmptyEntries);
var unparsedStringAsSpan = fen[fen.IndexOf(' ')..];
Span<Range> parts = stackalloc Range[5];
var partsLength = unparsedStringAsSpan.Split(parts, ' ', StringSplitOptions.RemoveEmptyEntries);

if (parts.Length < 3)
if (partsLength < 3)
{
throw new($"Error parsing second half (after board) of fen {fen}");
}

side = ParseSide(parts[0]);
side = ParseSide(unparsedStringAsSpan[parts[0]]);

castle = ParseCastlingRights(parts[1]);
castle = ParseCastlingRights(unparsedStringAsSpan[parts[1]]);

(enPassant, success) = ParseEnPassant(parts[2], pieceBitBoards, side);
(enPassant, success) = ParseEnPassant(unparsedStringAsSpan[parts[2]], pieceBitBoards, side);

if (parts.Length < 4 || !int.TryParse(parts[3], out halfMoveClock))
if (partsLength < 4 || !int.TryParse(unparsedStringAsSpan[parts[3]], out halfMoveClock))
{
_logger.Debug("No half move clock detected");
}

if (parts.Length < 5 || !int.TryParse(parts[4], out fullMoveCounter))
if (partsLength < 5 || !int.TryParse(unparsedStringAsSpan[parts[4]], out fullMoveCounter))
{
_logger.Debug("No full move counter detected");
}
}
catch (Exception e)
{
_logger.Error("Error parsing FEN string {0}", fen);
_logger.Error("Error parsing FEN string {0}", fen.ToString());
_logger.Error(e.Message);
success = false;
}

return (success, pieceBitBoards, occupancyBitBoards, side, castle, enPassant, halfMoveClock, fullMoveCounter);
}

private static (MatchCollection Matches, bool Success) ParseBoard(string fen, BitBoard[] pieceBitBoards, BitBoard[] occupancyBitBoards)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool ParseBoard(ReadOnlySpan<char> fen, BitBoard[] pieceBitBoards, BitBoard[] occupancyBitBoards)
{
bool success = true;

var rankIndex = 0;
var matches = _ranksRegex.Matches(fen);
var end = fen.IndexOf('/');

if (matches.Count != 8)
while (success && end != -1)
{
return (matches, false);
var match = fen[..end];

ParseBoardSection(pieceBitBoards, ref success, rankIndex, match);
PopulateOccupancies(pieceBitBoards, occupancyBitBoards);

fen = fen[(end + 1)..];
end = fen.IndexOf('/');
++rankIndex;
}

foreach (var match in matches)
ParseBoardSection(pieceBitBoards, ref success, rankIndex, fen[..fen.IndexOf(' ')]);
PopulateOccupancies(pieceBitBoards, occupancyBitBoards);

return success;

static void ParseBoardSection(ulong[] pieceBitBoards, ref bool success, int rankIndex, ReadOnlySpan<char> boardfenSection)
{
var fileIndex = 0;
foreach (var ch in ((Group)match).Value)
int fileIndex = 0;

foreach (var ch in boardfenSection)
{
if (Constants.PiecesByChar.TryGetValue(ch, out Piece piece))
{
Expand All @@ -98,19 +104,13 @@ private static (MatchCollection Matches, bool Success) ParseBoard(string fen, Bi
}
else
{
_logger.Error("Unrecognized character in FEN: {0} (within {1})", ch, ((Group)match).Value);
_logger.Error("Unrecognized character in FEN: {0} (within {1})", ch, boardfenSection.ToString());
success = false;
break;
}
}

PopulateOccupancies(pieceBitBoards, occupancyBitBoards);

++rankIndex;
}

return (matches, success);

static void PopulateOccupancies(BitBoard[] pieceBitBoards, BitBoard[] occupancyBitBoards)
{
var limit = (int)Piece.K;
Expand All @@ -129,51 +129,53 @@ static void PopulateOccupancies(BitBoard[] pieceBitBoards, BitBoard[] occupancyB
}
}

private static Side ParseSide(string sideString)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Side ParseSide(ReadOnlySpan<char> side)
{
#pragma warning disable S3358 // Ternary operators should not be nested
bool isWhite = sideString.Equals("w", StringComparison.OrdinalIgnoreCase);

return isWhite || sideString.Equals("b", StringComparison.OrdinalIgnoreCase)
? isWhite ? Side.White : Side.Black
: throw new($"Unrecognized side: {sideString}");
#pragma warning restore S3358 // Ternary operators should not be nested
return side[0] switch
{
'w' or 'W' => Side.White,
'b' or 'B' => Side.Black,
_ => throw new($"Unrecognized side: {side}")
};
}

private static byte ParseCastlingRights(string castleString)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static byte ParseCastlingRights(ReadOnlySpan<char> castling)
{
byte castle = 0;

foreach (var ch in castleString)
for (int i = 0; i < castling.Length; ++i)
{
castle |= ch switch
castle |= castling[i] switch
{
'K' => (byte)CastlingRights.WK,
'Q' => (byte)CastlingRights.WQ,
'k' => (byte)CastlingRights.BK,
'q' => (byte)CastlingRights.BQ,
'-' => castle,
_ => throw new($"Unrecognized castling char: {ch}")
_ => throw new($"Unrecognized castling char: {castling[i]}")
};
}

return castle;
}

private static (BoardSquare EnPassant, bool Success) ParseEnPassant(string enPassantString, BitBoard[] PieceBitBoards, Side side)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static (BoardSquare EnPassant, bool Success) ParseEnPassant(ReadOnlySpan<char> enPassantSpan, BitBoard[] PieceBitBoards, Side side)
{
bool success = true;
BoardSquare enPassant = BoardSquare.noSquare;

if (Enum.TryParse(enPassantString, ignoreCase: true, out BoardSquare result))
if (Enum.TryParse(enPassantSpan, ignoreCase: true, out BoardSquare result))
{
enPassant = result;

var rank = 1 + ((int)enPassant >> 3);
if (rank != 3 && rank != 6)
{
success = false;
_logger.Error("Invalid en passant square: {0}", enPassantString);
_logger.Error("Invalid en passant square: {0}", enPassantSpan.ToString());
}

// Check that there's an actual pawn to be captured
Expand All @@ -190,13 +192,13 @@ private static (BoardSquare EnPassant, bool Success) ParseEnPassant(string enPas
if (!pawnBitBoard.GetBit(pawnSquare))
{
success = false;
_logger.Error("Invalid board: en passant square {0}, but no {1} pawn located in {2}", enPassantString, side, pawnSquare);
_logger.Error("Invalid board: en passant square {0}, but no {1} pawn located in {2}", enPassantSpan.ToString(), side, pawnSquare);
}
}
else if (enPassantString != "-")
else if (enPassantSpan[0] != '-')
{
success = false;
_logger.Error("Invalid en passant square: {0}", enPassantString);
_logger.Error("Invalid en passant square: {0}", enPassantSpan.ToString());
}

return (enPassant, success);
Expand Down
60 changes: 57 additions & 3 deletions src/Lynx/Model/Game.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ public Game() : this(Constants.InitialPositionFEN)
{
}

public Game(string fen)
public Game(ReadOnlySpan<char> fen)
{
var parsedFen = FENParser.ParseFEN(fen);
CurrentPosition = new Position(parsedFen);
_gameInitialPosition = new Position(CurrentPosition);

if (!CurrentPosition.IsValid())
{
_logger.Warn($"Invalid position detected: {fen}");
_logger.Warn($"Invalid position detected: {fen.ToString()}");
}

MoveHistory = new(1024);
Expand All @@ -38,6 +38,10 @@ public Game(string fen)
HalfMovesWithoutCaptureOrPawnMove = parsedFen.HalfMoveClock;
}

/// <summary>
/// Intended to be used from tests only
/// </summary>
/// <param name="position"></param>
internal Game(Position position)
{
CurrentPosition = position;
Expand All @@ -47,7 +51,8 @@ internal Game(Position position)
PositionHashHistory = new(1024) { position.UniqueIdentifier };
}

public Game(string fen, List<string> movesUCIString) : this(fen)
[Obsolete("Just intended for testing purposes")]
internal Game(string fen, string[] movesUCIString) : this(fen)
{
foreach (var moveString in movesUCIString)
{
Expand All @@ -65,6 +70,55 @@ public Game(string fen, List<string> movesUCIString) : this(fen)
_gameInitialPosition = new Position(CurrentPosition);
}

[Obsolete("Just intended for testing purposes")]
internal Game(string fen, ReadOnlySpan<char> rawMoves, Span<Range> rangeSpan) : this(fen)
{
for (int i = 0; i < rangeSpan.Length; ++i)
{
var range = rangeSpan[i];
if (range.Start.Equals(range.End))
{
break;
}
var moveString = rawMoves[range];
var moveList = MoveGenerator.GenerateAllMoves(CurrentPosition, MovePool);

if (!MoveExtensions.TryParseFromUCIString(moveString, moveList, out var parsedMove))
{
_logger.Error("Error parsing game with fen {0} and moves {1}: error detected in {2}", fen, string.Join(' ', rawMoves.ToString()), moveString.ToString());
break;
}

MakeMove(parsedMove.Value);
}

_gameInitialPosition = new Position(CurrentPosition);
}

public Game(ReadOnlySpan<char> fen, ReadOnlySpan<char> rawMoves, Span<Range> rangeSpan) : this(fen)
{
for (int i = 0; i < rangeSpan.Length; ++i)
{
var range = rangeSpan[i];
if (range.Start.Equals(range.End))
{
break;
}
var moveString = rawMoves[range];
var moveList = MoveGenerator.GenerateAllMoves(CurrentPosition, MovePool);

if (!MoveExtensions.TryParseFromUCIString(moveString, moveList, out var parsedMove))
{
_logger.Error("Error parsing game with fen {0} and moves {1}: error detected in {2}", fen.ToString(), string.Join(' ', rawMoves.ToString()), moveString.ToString());
break;
}

MakeMove(parsedMove.Value);
}

_gameInitialPosition = new Position(CurrentPosition);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsThreefoldRepetition(Position position) => PositionHashHistory.Contains(position.UniqueIdentifier);

Expand Down
6 changes: 3 additions & 3 deletions src/Lynx/Model/Move.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public static class MoveExtensions
/// <exception cref="InvalidOperationException"></exception>
/// <exception cref="IndexOutOfRangeException"></exception>
/// <returns></returns>
public static bool TryParseFromUCIString(string UCIString, IEnumerable<Move> moveList, [NotNullWhen(true)] out Move? move)
public static bool TryParseFromUCIString(ReadOnlySpan<char> UCIString, IEnumerable<Move> moveList, [NotNullWhen(true)] out Move? move)
{
Utils.Assert(UCIString.Length == 4 || UCIString.Length == 5);

Expand All @@ -78,7 +78,7 @@ public static bool TryParseFromUCIString(string UCIString, IEnumerable<Move> mov

if (move.Equals(default(Move)))
{
_logger.Warn("Unable to link last move string {0} to a valid move in the current position. That move may have already been played", UCIString);
_logger.Warn("Unable to link last move string {0} to a valid move in the current position. That move may have already been played", UCIString.ToString());
move = null;
return false;
}
Expand All @@ -101,7 +101,7 @@ bool predicate(Move m)
move = candidateMoves.FirstOrDefault(predicate);
if (move.Equals(default(Move)))
{
_logger.Warn("Unable to link move {0} to a valid move in the current position. That move may have already been played", UCIString);
_logger.Warn("Unable to link move {0} to a valid move in the current position. That move may have already been played", UCIString.ToString());
move = null;
return false;
}
Expand Down