8
submitted 3 months ago by Doods@infosec.pub to c/rust@programming.dev

Another crazy idea I share with this website.

I was developing a game and an engine in Rust, so I was reading many articles, most of which criticize the 'borrow checker'.

I know that Rust is a big agenda language, and the extreme 'borrow checker' shows that, but if it weren't for the checker, Rust would be a straight-up better C++ for Game development, so I thought: "Why not just use unsafe?", but the truth is: unsafe is not ergonomic, and so is Refcell<T> so after thinking for a bit, I came up with this pattern:

let mut enemies = if cfg!(debug_assertions) {
    // We use `expect()` in debug mode as a layer of safety in order
    // to detect any possibility of undefined bahavior.
    enemies.expect("*message*");
    } else {
    // SAFETY: The `if` statement (if self.body.overlaps...) must
    // run only once, and it is the only thing that can make
    // `self.enemies == None`.
    unsafe { enemies.unwrap_unchecked() }
};

You can also use the same pattern to create a RefCell<T> clone that only does its checks in 'debug' mode, but I didn't test that; it's too much of an investment until I get feedback for the idea.

This has several benefits:

1 - No performance drawbacks, the compiler optimizes away the if statement if opt-level is 1 or more. (source: Compiler Explorer)

2 - It's as safe as expect() for all practical use cases, since you'll run the game in debug mode 1000s of times, and you'll know it doesn't produce Undefined Behavior If it doesn't crash.

You can also wrap it in a "safe" API for convenience:

// The 'U' stands for 'unsafe'.
pub trait UnwrapUExt {
    type Target;

    fn unwrap_u(self) -> Self::Target;
}

impl<T> UnwrapUExt for Option<T> {
    type Target = T;

    fn unwrap_u(self) -> Self::Target {
        if cfg!(debug_assertions) {
            self.unwrap()
        } else {
            unsafe { self.unwrap_unchecked() }
        }
    }
}

I imagine you can do many cool things with these probably-safe APIs, an example of which is macroquad's possibly unsound usage of get_context() to acquire a static mut variable.

Game development is a risky business, and while borrow-checking by default is nice, just like immutability-by-default, we shouldn't feel bad about disabling it, as forcing it upon ourselves is like forcing immutability, just like Haskell does, and while it has 100% side-effect safety, you don't use much software that's written in Haskell, do you?

Conclusion: we shouldn't fear unsafe even when it's probably unsafe, and we must remember that we're programming a computer, a machine built upon chaotic mutable state, and that our languages are but an abstraction around assembly.

top 26 comments
sorted by: hot top controversial new old
[-] savvywolf@pawb.social 15 points 3 months ago

One thing I've noticed with Rust is that if you find yourself fighting with the borrow checker, that's a sign that your codebase isn't well structured.

So I'm curious; what problem have you been trying to solve where the borrow checker has been this much of an obstacle? There might be a cleaner design for it.

[-] FizzyOrange@programming.dev 10 points 3 months ago

I disagree. It's a sign your code isn't structured in a way that the borrow checker understands, but that is a subset of well-structured code.

In other words, if your code nicely fits with the borrow checker then it's likely well structured, but the inverse is not necessarily true.

One thing I always run into is using lambdas to reduce code duplication within a function. For example writing a RLE encoder:

fn encode(data: &[u8]) -> Vec<u8> {
  let mut out = Vec::new();

  let mut repeat_count = 0;

  let mut output_repeat = || {
     out.push(... repeat_count ...);
  };

  for d in data {
      output_repeat();
      ...
  }
  output_repeat();
  out
}

This is a pretty common pattern where you have a "pending" thing and need to resolve it in the loop and after the loop. In C++ you can easily use lambdas like this to avoid duplication.

Doesn't work in Rust though even though it's totally fine, because the borrow checker isn't smart enough. Instead I always end up defining inline functions and explicitly passing the parameters (&mut out) in. It's much less ergonomic.

(If anyone has any better ideas how to solve this problem btw I'm all ears - I've never heard anyone even mention this issue in Rust.)

[-] fil@hachyderm.io 2 points 3 months ago* (last edited 3 months ago)
[-] FizzyOrange@programming.dev 5 points 3 months ago

Sorry that example was a bit too limited to demonstrate the problem actually. Add a second lambda and you hit the issue:

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=eb99d3d670bdd9d92006f4672444d611

Still totally fine from a safety point of view, but the borrow checker can't figure that out.

[-] Jenztsch@discuss.tchncs.de 6 points 3 months ago* (last edited 3 months ago)

This is entering subjective taste, but in my opinion this also is a feature of Rust. Especially when the closures are more complicated, it may be not as obvious if and where they are changing state (the fact that Rust implicitely mutably borrows the variables to the closures doesn't help either).

So a solution of this issue for me would be to add the changed variables as parameters to the closures and explicitely mutably borrow them at the calls in the loop: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=78cc7947e2e0b07b54baf6e1a75a2632

I would agree with you that this increases code verbosity. However, this would be a price I'm willing to pay to help understand at quicker glances what happens inside this loop.

[-] zshift@hachyderm.io 2 points 3 months ago

@Jenztsch @FizzyOrange it would be nice if rust had a feature like inline macros for this kind of behavior just for reducing duplications when you don’t need to capture values as part of a closure.

[-] zshift@hachyderm.io 2 points 3 months ago* (last edited 3 months ago)

@Jenztsch @FizzyOrange

Eg

fn foo() -\> Vec\<i32\> {  
 let mut out = Vec::new();  
 macro! bar(i: i32) {  
 out.push(i);  
 }

 for i in 1..10 {  
 bar!(i);  
 }

 out  
}  
[-] Jenztsch@discuss.tchncs.de 3 points 3 months ago

I'm not sure how you intend to use this. When no variables are captured, the borrow checker will not have any issues with the closure method.

When you are still capturing, you could implement a macro like one answer suggests. However, IMO this highly depends on the complexity of the duplicated code and even then I don't immediately see what the benefits compared to extracting it as a closure/function are.

[-] FizzyOrange@programming.dev 1 points 3 months ago

The benefits are that you don't have to pass out and similar captured variables into the closures/functions.

[-] Jenztsch@discuss.tchncs.de 1 points 3 months ago

Then I think RefCell is exactly what you want to defer the mutable borrow to runtime instead of compile time: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=3170c01849dc577a501ecb11dd44c5ba (source for this method on StackOverflow).

Maybe there could be syntactic sugar to use captures implicitely as RefCells inside a closure. But I would not be a fan of implicitely allowing multiple mutable borrows without any clue for that in the code.

[-] FizzyOrange@programming.dev 1 points 3 months ago

Not really because RefCell has performance implications and also adds noise to the code.

But I would not be a fan of implicitely allowing multiple mutable borrows without any clue for that in the code.

Nobody is suggesting breaking Rust's multiple mutable borrow restriction. The macro solution simply doesn't do that, and the "make the borrow checker smarter" solution just releases the mutable borrows when they aren't being used so they don't overlap.

[-] FizzyOrange@programming.dev 1 points 3 months ago

I dunno, does it even need a new feature? Kind of feels like Rust should be able to figure it out as long as the lambdas aren't moved/stored/etc?

[-] BB_C@programming.dev 2 points 3 months ago* (last edited 3 months ago)

Is this crazy?

A general repeat macro that works on stable Rust would work too of course.

[-] FizzyOrange@programming.dev 2 points 3 months ago

Yeah that's pretty unreadable IMO. I think your second link isn't what you intended?

[-] BB_C@programming.dev 2 points 3 months ago

I think your second link isn’t what you intended?

You scared me for a moment there. I don't know why you thought that.

Needless to say, even with the first example, metavar expressions are not strictly needed here, as using a second pattern and recursing expansions would work.

But I wanted to showcase the power of ${ignore}, as it can be cleaner and/or more powerful in some cases where extra patterns and recursing expansions can get messy and hard to track.

[-] fil@hachyderm.io 1 points 3 months ago

@FizzyOrange I see what you mean

[-] BB_C@programming.dev 13 points 3 months ago* (last edited 3 months ago)

First of all, unsafe famously doesn't disable the borrow checker, which is something any Rustacean would know, so your intro is a bit weird in that regard.

And if you neither like the borrow checker, nor like unsafe rust as is, then why are you forcing yourself to use Rust at all. If you're bored with C++, there are other of languages out there, a couple of which are even primarily developed by game developers, for game developers.

The fact that you found a pattern that can be alternatively titled "A Generic Method For Introducing Heisenbugs In Rust", and you are somehow excited about it, indicates that you probably should stop this endeavor.

Generally speaking, I think the Rust community would benefit from making an announcement a long the lines of "If you're a game developer, then we strongly advise you to become a Rustacean outside the field of game development first, before considering doing game development in Rust".

[-] Giooschi@lemmy.world 12 points 3 months ago

Have you actually measured a performance impact from RefCell checks?

[-] sukhmel@programming.dev 9 points 3 months ago

I just wanted to advice you against thinking that if there's something in all cases you've tried, there's something every time. When you put something in an optional and then unwrap, it's okay because you can see that the value is there, but even then there are usually better ways to express that. When you expect that since you've run the code thousands of times and it didn't break [in a way that you would notice, e.g. panic in another thread will only affect that thread] means that everything is fine, you may get weird bugs seemingly out of nowhere and will also need to test much more than strictly necessary.

Regarding the borrow checker, it has limitations and there are improvements that I hope will some day find way into upstream, but most of the time it may be better to change the code flow to allow borrow checker to help with bugs, instead of throwing it away completely. The same goes for unsafe, as in most cases it's better to not uphold invariants manually.

[-] calcopiritus@lemmy.world 5 points 3 months ago* (last edited 3 months ago)

Instead of checking if debug assertions are disabled, you should use debug assertions, it would make the code much neater.

If you wanna eliminate the borrow checker this way, I guess you could use raw pointers instead of references, and have debug assertions to check if those pointers are null. At that point you'd have a mix of C and Rust. The memory would be completely unsafe (you'd have to allocate mostly on the heap, and drop it manually), but you'd have rusts's type system. You'd also lose a lot of ergonomics though.

EDIT: just to be clear. I think completely disabling the borrow checker this way is absolutely nuts. Maybe you could have some raw pointers in troublesome locations (where RC/RefCell would have too big of a performance impact for a 144fps game). But most of the code should be borrow checked, since the borrow checker only gets in your way sometimes. It's not like lifetimes go away with the borrow checker, you still have to think about lifetimes if you manually manage your memory.

[-] TehPers@beehaw.org 3 points 3 months ago

Two thoughts come to mind for me:

  1. I think people should feel free to use any language however they want for their own needs and projects, but it's also important to understand what exactly "unsound" and "undefined behavior" mean if you're going to dabble with them. If it's a risk you're willing to take, go for it, but don't be surprised if things break in ways that make no sense at all. Realistically a compiler won't delete your root directory if you trigger UB or anything, but subtle bugs can creep in and change behaviors in ways that still run but which make unrelated code break in difficult to debug ways.
  2. The borrow checker is one of Rust's biggest features, so looking for ways around it feels a bit counterproductive. Feature-wise, Rust has a lot of cool constructs around traits and enums and such, but the language and its libraries are built around the assumption that the guarantees the compiler enforces in safe code will always be true. These guarantees extend beyond the borrow checker to things like string representation and thread safety as well. As an alternative, some other languages (like C++, which you mentioned, or maybe even Zig) might be better suited for this approach to "dirty-but-works" development, and especially with C++, there are some excellent tools and libraries available for game development.
[-] Doods@infosec.pub 2 points 3 months ago

Hello Pers,

I made a mistake when writing the post, it reads like I am against the borrow checker, which I am not, I love the checker, and didn't encounter any - major - problems with it.

I meant that even if we used unsafe everywhere it would still be a good language, which is an attempt at arguing with those saying that Rust isn't fit for gamedev because the of the checker. Which I failed at due to lack of experience, as this is my first time making a game, and Rust is my first language*.

Regarding: "If it doesn't panic in Debug, it won't act weird on Release", even if I got reported a really weird bug related to UB, I should (I am not experienced enough to make a claim) be able to know it's UB since the game's gonna crash when I try to recreate the bug in Debug.

Some would say that shipping the game with runtime checks won't have an effect on performance, which is probably true, since it's so simple the list of entities is an array (not a vector), and the game state is - effectively - global (everything is impl CombatContext { fn x(&mut self) {} })**, and some (most? too early in development to tell) of the game is locked at 5fps (maybe I'll bump it up a bit)***.

I am so concerned about performance because I had to daily drive a computer that most people on this website - and especially on Reddit - would consider garbage E-waste, for 4 years, and was trying hard to play games on it, which was further amplified by my GPU not supporting Vulkan (nor Dx9 for some time), which meant I couldn't use Proton, which taught me some hacks that are... let's not talk about them.

So I find huge pain in leaving any possible performance optimizations, especially that some people I know are stuck on - arguably - worse machines****; accessibility is a big priority.

It also makes me angry to see pixel games come with 70Mib binaries and require Vulkan because:

1 - internet costs money

2 - they claim in the system requirements that their game "Should run on anything".

Memes like: "Oh my game could run on a potato" infuriate me (good thing I don't use social media), NO, your game can't run a potato, DooM can, it was actually optimized properly, your 2D pixels can't even render on a machine a 100x more powerful, you should feel ashamed*(5).

*: I was messing around with C# + Godot not super long ago, nothing serious.

**: I have been refactoring my code lately to limit the scope of most functions, in a way inspired by ECSs, but significantly more primitive.

***: the game has both a 3D and a 2D part, the 2D part has locked FPS, the 3D part can run at any framerate.

****: Macroquad supporting OpenGL only down to 2.0ES would be a problem, if I wasn't intending on forking it anyway to reduce the binary size (grim is an extremely bloated dependency, I managed to shove off 10 Mib in a few hours), and unless using 1.x is as hard people on the internet claim it is, which is probably false, as these people are mostly weak and say the same things about using a custom engine.

*(5): this might sound toxic, but that's how people get better.

[-] Jenztsch@discuss.tchncs.de 3 points 3 months ago

Do you have any sources about this "unfitness" of Rust for gamedev? From my experience many people have problems with the borrow checker due to habits they obtained coding in other languages. Due to the strict enforcement of ownership rules some things can't be done exactly like in e.g. C++, but the language offers the tools to design and implement the software in a way to work with (not around!) the borrow checker (an example would be the discussion in another comment thread).

So I'd be interested what special behavior occurs in gamedev that makes coding in Rust more difficult than for other software.

Also, I really like that you're considering users with lower spec machines for your game. However, have you run a profiler over your software to see where there is optimization potential? In cases like this, I often use the (maybe over-used) quote from Donald Knuth:

We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.

Many people think that with this quote, no optimization whatsoever should be considered. However, humans are often bad predictors on where the real bottlenecks are.

So yes, have some things in mind like e.g. algorithm performance for the expected input sizes. But in many cases, "optimization" done doesn't impact the runtime at all but makes the code much more difficult to understand and often more "unsafe" due to potentially increasing UB or other issues.

For this, a profiler helps to detect where your "critical 3%" are. And when you know them, then you can start optimization work to get big improvements. This also helps to direct your most likely limited effort to spend to the important parts of your game.

[-] Doods@infosec.pub 3 points 3 months ago* (last edited 3 months ago)

Do you have any sources about this “unfitness” of Rust for gamedev? From my experience many people have problems with the borrow checker due to habits they obtained coding in other languages.

I can't say anything for sure, but I read this article, in conjunction with this article, before I made this post, so you might consider looking at it, and how it influenced me.

Edit: wait, I'll extend this reply even more.

Edit 2: Done:

So I’d be interested what special behavior occurs in gamedev that makes coding in Rust more difficult than for other software.

Maybe it's because the gaming industry is historically among the slowest industries, they stuck with DOS until there was literally no drivers on it for the latest GPUs, only then did they upgrade. There's a video explaining how a recent AAA game could run on the steam deck, but not on Linux, it turns out the game was using a Windows XP library that's too old for wine to support, so how did it work on the deck? they effectively added this to their code:

if platform_name == "steamdeck" { use_modern_library() }

, which explains why it only ran on the deck, but notice how they still stuck to the ~2003 library as the default even though the modern one works, that's how much they hate change.

Considering the above, suggesting they change the particular way of their forefathers wouldn't be fruitful, unless extremely obvious B I G gains are to be found. Notice how Jonathan Blow's game development language is literally 'C++ but better', and how it mimics C++ in everything but the universally hated parts, and adds none but the universally wanted features. (as universal as an industry can agree on anything, that is)

That may be because games are a dangerous business, you pool all your resources in one project, and you get basically no income for up to four years, then you release and possibly succeed.

I also speculate that games aren't really maintained, most of the best games I know only received 3 patches at most (version 1.3). I think the priority isn't: "How am I gonna remember how this works in 3 months from now and deal with technical dept", it's more like: "How can I implement this in a way that the only thing faster than the implementation time is the feature itself?", so there is no fear of possibly breaking something that the checker can save you from down the road.

The last sentence kinda doesn't make sense since the first 3 years are more that enough technical dept for Rust to start doing its thing, but IDK how they think.

Bonus: look for Jonathan Blow's opinions on Rust on Youtube, he is an example of a studio owner fearing the risk of the possible "friction" that the Borrow checker could possibly cause.

[-] sukhmel@programming.dev 1 points 3 months ago

I see now, that you were misunderstood in some parts.

even if I got reported a really weird bug related to UB, I should (I am not experienced enough to make a claim) be able to know it's UB since the game's gonna crash when I try to recreate the bug in Debug.

This may be problematic for several reasons: it may be hard to reproduce, the more complicated the state, the harder; bug may rely on some race condition that may be much rarer in Debug because of speed difference; UB is notorious for causing things that should (seemingly) never happen, like returning from infinite loops, proving false statements true, and such, so it may be hard to understand what at all happened and why.

Regarding optimisations, it might still be better to try to profile the code (I will be honest, I don't do that until the moment when I can't go further without optimisation, and I haven't reached that with Rust) and see what are the real hot spots that require optimisations. I hope that someday you will be able to upgrade your machine, and hope that your game will be a good example of something that really runs anywhere

[-] Doods@infosec.pub 2 points 3 months ago

Oh no don't get me wrong, a year back I upgraded to an I5-7500 prebuilt, and it's a beast for all my tasks. (maybe compiling is quick because I split modules a little too much?)

Your advice is good for not knowing what I'm making. If I was making something multi threaded with much state I would fear UB more.

may be much rarer in Debug because of speed difference

Thanks, then I will remember to recreate bugs with opt-level = 3.

Wait no, this doesn't make sense if I don't have access to the user's machine, maybe I should send him a log-heavy version of some sort? How should I even what I am supposed to log? I should think about this some more before release.

this post was submitted on 17 Aug 2024
8 points (72.2% liked)

Rust

6029 readers
3 users here now

Welcome to the Rust community! This is a place to discuss about the Rust programming language.

Wormhole

!performance@programming.dev

Credits

  • The icon is a modified version of the official rust logo (changing the colors to a gradient and black background)

founded 1 year ago
MODERATORS