Hello,

I was playing around with rust and wondered if I could use const generics for toggling debug code on and off to avoid any runtime cost while still being able to toggle the DEBUG flag during runtime. I came up with a nifty solution that requires a single dynamic dispatch which many programs have anyways. It works by rewriting the vtable. It’s a zero cost bool!

Is this technique worth it?

Probably not.

It’s funny though.

Repo: https://github.com/raldone01/runtime_const_generics_rs/tree/v1.0.0

Full source code below:

use std::mem::transmute;
use std::sync::atomic::AtomicU32;
use std::sync::atomic::Ordering;

use replace_with::replace_with_or_abort;

trait GameObject {
  fn run(&mut self);
  fn set_debug(&mut self, flag: bool) -> &mut dyn GameObject;
}

trait GameObjectBoxExt {
  fn set_debug(self: Box<Self>, flag: bool) -> Box<dyn GameObject>;
}

impl GameObjectBoxExt for dyn GameObject {
  fn set_debug(self: Box<Self>, flag: bool) -> Box<dyn GameObject> {
    unsafe {
      let selv = Box::into_raw(self);
      let selv = (&mut *selv).set_debug(flag);
      return Box::from_raw(selv);
    }
  }
}

static ID_CNT: AtomicU32 = AtomicU32::new(0);

struct Node3D<const DEBUG: bool = false> {
  id: u32,
  cnt: u32,
}

impl Node3D {
  const TYPE_NAME: &str = "Node3D";
  fn new() -> Self {
    let id = ID_CNT.fetch_add(1, Ordering::Relaxed);
    let selv = Self { id, cnt: 0 };
    return selv;
  }
}

impl<const DEBUG: bool> GameObject for Node3D<DEBUG> {
  fn run(&mut self) {
    println!("Hello {} from {}@{}!", self.cnt, Node3D::TYPE_NAME, self.id);
    if DEBUG {
      println!("Debug {} from {}@{}!", self.cnt, Node3D::TYPE_NAME, self.id);
    }
    self.cnt += 1;
  }

  fn set_debug(&mut self, flag: bool) -> &mut dyn GameObject {
    unsafe {
      match flag {
        true => transmute::<_, &mut Node3D<true>>(self) as &mut dyn GameObject,
        false => transmute::<_, &mut Node3D<false>>(self) as &mut dyn GameObject,
      }
    }
  }
}

struct Node2D<const DEBUG: bool = false> {
  id: u32,
  cnt: u32,
}

impl Node2D {
  const TYPE_NAME: &str = "Node2D";
  fn new() -> Self {
    let id = ID_CNT.fetch_add(1, Ordering::Relaxed);
    let selv = Self { id, cnt: 0 };
    return selv;
  }
}

impl<const DEBUG: bool> GameObject for Node2D<DEBUG> {
  fn run(&mut self) {
    println!("Hello {} from {}@{}!", self.cnt, Node2D::TYPE_NAME, self.id);
    if DEBUG {
      println!("Debug {} from {}@{}!", self.cnt, Node2D::TYPE_NAME, self.id);
    }
    self.cnt += 1;
  }

  fn set_debug(&mut self, flag: bool) -> &mut dyn GameObject {
    unsafe {
      match flag {
        true => transmute::<_, &mut Node2D<true>>(self) as &mut dyn GameObject,
        false => transmute::<_, &mut Node2D<false>>(self) as &mut dyn GameObject,
      }
    }
  }
}

fn main() {
  let mut objects = Vec::new();
  for _ in 0..10 {
    objects.push(Box::new(Node3D::new()) as Box<dyn GameObject>);
    objects.push(Box::new(Node2D::new()) as Box<dyn GameObject>);
  }

  for o in 0..3 {
    for (i, object) in objects.iter_mut().enumerate() {
      let debug = (o + i) % 2 == 0;
      replace_with_or_abort(object, |object| object.set_debug(debug));
      object.run();
    }
  }
}

Note:

If anyone gets the following to work without unsafe, maybe by using the replace_with crate I would be very happy:

impl GameObjectBoxExt for dyn GameObject {
  fn set_debug(self: Box<Self>, flag: bool) -> Box<dyn GameObject> {
    unsafe {
      let selv = Box::into_raw(self);
      let selv = (&mut *selv).set_debug(flag);
      return Box::from_raw(selv);
    }
  }

I am curious to hear your thoughts.

  • nous@programming.dev
    link
    fedilink
    English
    arrow-up
    9
    ·
    1 month ago

    It’s a zero cost bool!

    It really is not. Dynamic dispatch has a cost on every access due to it being a pointer. Instead if doing a bool check you are needing to read a pointer to figure out which function to call. I doubt this is any faster than just having the bool check and I doubt that the bool check is slowing anything down by a noticeable amount. All of this is probably not worth the complexity, especially without benchmark results as I can see this actually slowing down the code overall.

    There is also a increased binary size due to there needing to be two versions of the code block - if you have that in every function that would be a dramatic increase in the binary size. This means more data needs to be loaded into memory at startup and I don’t know if the branch prediction in your CPU worth with vtables?

    Maybe if you already need dynamic dispatch for something it might be worth it. But most of the time I would avoid it if possible which means this pattern has a very limited usecase that you cannot use over the whole program? What is wrong with the standard logging crates in rust? I would expect them to be good enough even for game dev.

    • raldone01@lemmy.worldOP
      link
      fedilink
      English
      arrow-up
      1
      ·
      edit-2
      28 days ago

      I thought about all these points too. I agree. It’s really not worth doing.

      It is only benefitial at all, if dynamic dispatch is already in use. Then this “feature” is free besides the binary size.

      I mostly thought about the code not slowing down the usual path and still retaining the option to change it at runtime.

      Thanks for the detailed outline though.

      Nothing is wrong with the logging crates. :)

      I thought more about putting debug visualizations in the debug blocks - not log statements.

  • BB_C@programming.dev
    link
    fedilink
    arrow-up
    2
    ·
    edit-2
    1 month ago

    I’m stating the obvious here, but if you have ownership at the time of conversion, then you can just change the type, you don’t have to use dyn (a la impl Foo<false> { fn into_debug(self) -> Foo<true> {} }). That may require some code restructuring of course.

    • raldone01@lemmy.worldOP
      link
      fedilink
      English
      arrow-up
      1
      ·
      edit-2
      28 days ago

      Well I did not think of that. So in the into_debug I would move all struct members from Foo<false> into a “new” Foo<true> and return that?

      • BB_C@programming.dev
        link
        fedilink
        arrow-up
        2
        ·
        1 month ago

        I would move all struct members from Foo<false> into a “new” Foo<true> and return that?

        Yes… if you don’t define Drop for Foo.

        If you do, then you will have to either use mem::take()/mem::replace() with each field, or if you don’t mind unsafe {}, you can just do a trivial transmute. Justunsafe { transmute(self) } should work.