lithander wrote: ↑Mon Jun 14, 2021 8:44 pm
That's exactly the thing. There are some basic design decisions like having a copy/make design instead of a make/unmake one that only make sense if the design goal is simplicity.
Transposition tables are all about gaining speed and playing strength not simplicity. So is it about speed now and climbing the ELO ladder? If I change my goal retroactively I should reconsider the design from the ground up. I should also stop writing idiomatic C# code because it's leaving a trail of millions of objects per second on the heap for the garbage collector to clean up. It's how the language is meant to be used but it's not exactly fast.
If it comes right down to it, even Rustic isn't completely idiomatic Rust. Even though Rust promises zero-cost abstractions, using the language everywhere in the engine as it was supposed to be used (like using enums for pieces and squares, not using "for" but an iterator to iterate over a list) is just cumbersome.
Sometimes, you need to know the exact piece, like "knight". An enum makes this super safe and convenient in Rust, because you can't mix up "knight" with "king" for example. (In a C enum you can, because they are just ints.) However, often you also need the numerical value of the enum, such as "1" for Queen, so you can use it to index an an array. In Rust, I'd need to write something that does this:
Code: Select all
array_here[Queen::value() as usize];
That gets tiresome very fast. So I just have an empty struct with a list of constants, which creates something akin to a C-enum, but namespaced, so I can do:
From a "good programming practice" point of view, this is bad, because you can swap the queen with a king, and the program doesn't complain and probably crash. (I can even swap pieces with squares... and the program won't complain and then crash.) Doing it the "proper" way though is so long-winded and cumbersome that it becomes unpractical (and needs conversions from enum to int and back, so it's probably slow as well).
Same with iterating through an array. You should be doing:
Code: Select all
for (i, x) in array_here.iter().enumerate() {
...
}
But if you then hit a certain condition and want to stop the iteration, you'd have to break. I find "breaking" loops really ugly. (Back when I started programming, it wasn't even possible in Pascal, because it was considered ugly.) I prefer to write this:
Code: Select all
let i = 0
let condition = false;
while i < array_here.length() && !condition {
...
condition = ... (something that evaluates to a boolean here, obviously)
i++;
}
Rust considers this "ugly", because you could forget to increase i, you need two extra variables, and you could forget to set the condition, so it's not idiomatic Rust. The correct way would be to use higher order functions such as "filter" in combination with iterators and closures, but often I find that to become completely unreadable. And, you need an object over which you can iterate. My move list is an array wrapped in a struct, backed by a counter, and it has push() and pop() methods. (Same for the history list, etc.) I could have used a vector, but the counter-backed array-in-a-struct is just over 10% faster, so I use that. I _could_ still use iterators, filter, and closures, but then I'd have to implement an iterator in the struct. That's extra work, an extra layer of abstraction, and because it emits references to the values in the array, it needs an extra indirection, which is probably slower than just looping over the array directly.
So yes... Rustic is more C-like in many places than I'd like, but it is just _faster_. In many places, I use Rust as a "better C". Maybe, at some point, I'll take a look into "idiomizing" the engine as much as possible, but only if it doesn't take a huge amount of extra code, and it doesn't cost any speed.
But is that fun to you? I don't think that's fun for me. Currently I'm trying to add late move reductions because that is a really simple idea and it can be done without adding much extra code. But there are so many variants that I could consider... what is considered a late move? how much do you reduce? And so on... And in every scenario you will pay a price for the additional search depth: Sometimes you'll miss a few good moves because the path to them didn't look too promising and got pruned. And so it remains a trade off even if it gains you a few ELO.
That was of course said in jest. Getting 5 more Elo is not fun for me. My goal is to get as much Elo out of each feature as I can before I move to the next feature. I want to create the strongest engine I can with the least amount of features. And, with good documentation and not in C.
I think I should just skip late move reductions and prepare version 0.5 for a release... or maybe just call it 1.0 directly and declare MinimalChess done!
If you want to have a good, idiomatic, around 2000 Elo chess engine in C#, then that would be a very good idea. Make a few more youtube video's, and call it a day on MinimalChess. It's a good contribution to the chess engine community, other people can use it as a base if they want to, they can learn some stuff from your video's, and you learned a lot as well.
Then redesign a new engine purely for speed and power, and reconsider if you want to write that in C#; you could write it in C++, C, or Rust... or even Ada, Pascal, or D if you want to go to niche languages just to avoid the mainstream.
Because by making these optimizations now that come at a price and skipping optimizations that have no downside other than the complexity they add to the code base it feels like I'm riding a dead horse! Like I missed the point where any reasonable person would say: "Based on the original vision of the project I'm done. This is minimal and simple and anything I add now will water down the purity of the original vision."
That's a completely understandable viewpoint. If your goal was a simple, idiomatic C# engine that plays decently, you've more than achieved that goal with a 2000+ rating. You're done with _this_ engine, but hopefully not with chess programming.