Search Unity

  1. Megacity Metro Demo now available. Download now.
    Dismiss Notice
  2. Unity support for visionOS is now available. Learn more in our blog post.
    Dismiss Notice

How we replace most of our HPC# code with Rust code

Discussion in 'Entity Component System' started by IgreygooI, Dec 21, 2021.

  1. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    I think the opportunity is mature to post something about an alternative to writing performant code for Unity. The problem that current DOTS has for our use case is eco-system and memory safety, both significantly slowing down our development. In the end, we have migrated most of what we have written in HPC#/C# to Rust such as procedural mesh generation, procedural terrain generation, ray casting based on voxel marching, asynchronous file IO, and serialization(binary/JSON). More importantly, what we could migrate over in near future are the majority of the core game logic(both client-side and server-side), Networking, and Job system. This might sound impossible to do or too much effort to try, but we have trivialized it by a efficient workflow, to write performant code in Rust for Unity.

    Before I get started, let me clarify what is the goal of this thread, I will focus on technical things like how the development workflow looks, how the bindings look, how the `unsafe`s in both languages are dealt with and how can it be integrated with existing DOTS ECS framework. I assume you know what you want for your development, and this thread is here to only lower the marginal cost of using a little bit Rust for Unity development. You are welcome to ask any question and discuss this kind of workflow.

    There is a lot of details, and I will divide them into part and update regularly. And here is part one:

    1) Automatic binding generation:

    I am sure you might have check some existing blog talking about how to call a Rust function from C#, and
    a Rust function as the following can be compiled into a native plugin for Unity to use:

    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn hello_world() {
    3.     println!("hello world!");
    4. }
    On Unity's C# side, all you have to do is to write some bindings for your function in Rust:

    Code (CSharp):
    1. [DllImport("my_rust_code", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
    2. public static extern void hello_world();
    However, you probably do not want to rewrite the bindings every time you change your function signatures. To overcome this problem, we will introduce several code generators.

    Starting from something simple, two code generators could be used to generate binding from unsafe Rust to unsafe C# for now:
    cbindgen generates a C/C++ header from unsafe rust code that is ffi compatible,
    ClangSharp generates unmanaged unsafe binding C# code from C/C++ header, since it is unmanaged, they are also Burst compatible.

    both of tools are well documented, you could check their repository here:
    cbindgen: https://github.com/eqrion/cbindgen
    ClangSharp: https://github.com/dotnet/ClangSharp

    After writing a build script to chain them together as follows:

    Rust code --(cbindgen)-> C/C++ Header --(ClangSharp)-> C# bindings 

    Your code will firstly generate a C/C++ header file containing the binding:

    Code (C++):
    1. void hello_world();
    Then it will be fed into ClangSharp here to generate a binding file ready for use.
    Note: if your ide is complaining about missing NativeTypeNameAttribute: create a new file with the following content:

    Code (CSharp):
    1. using System;
    2. using System.Diagnostics;
    3.  
    4. [Conditional("DEBUG")]
    5. [AttributeUsage(
    6.     AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.ReturnValue,
    7.     AllowMultiple = false, Inherited = true)]
    8. internal sealed class NativeTypeNameAttribute : Attribute
    9. {
    10.     public NativeTypeNameAttribute(string name)
    11.     {
    12.         Name = name;
    13.     }
    14.  
    15.     public string Name { get; }
    16. }
    You could also found this file from the ClangSharp repository.

    Now, with the build script, once you edit some Rust code, both the binding and native plugin can be automatically deployed to Unity. However, Unity currently can not reload a native plugin. The best workaround that I found so far is compiling the library into a plugin, whose name is changed every time you make a new build(like incrementing a build version in the name). The generated binding will be pointed to the newer plugin, and a reload in Unity will force code to use the newer plugin. This is sufficient enough for development purposes since you won't have to restart Unity to reload native plugins, but it has limitations:

    1. since the older plugin will not be unloaded, Thus all of the things from the old plugin will be leaked without being used or freed and this is intended. But be careful with any global statics allocating a lot of resources.
    2. Burst compiled code will occasionally(not sure if it is consistent) not recompile to a binding change, thus the burst compiled code with dllimport will still point to the native plugin with the earlier version. (it might get fixed in a later version of Burst, but I have not checked).

    I hope what I wrote here is good enough for you to get started to explore. After this part, I will probably discuss a few thing regarding to FFI safety so you don't crash your Unity. Using this workflow, a crash could happen from those causes:
    1. Unity internal error
    2. FFI boundary between Rust and C#
    3. Rust code panics
    4. Rust code abort process
    We will be dealing with the second with a lot of code generation. Since the third will trigger a unwind which could be caught at FFI boundary, we will do so for every ffi function using code generation. The forth one will be unlikely to happen unless something "catastrophic happens". Thus stay tuned for part 2 if you are interested.
     
    Last edited: Dec 21, 2021
    defic, MNNoxMortem, NotaNaN and 11 others like this.
  2. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    2) FFI safe data types:

    During the last part of the thread, we establish how the unsafe rust is compiled and generated into a native plugin and C# bindings that can be used from Unity. I also give a classic hello world as an example. Unfortunately, the function will not work in a way that is expected, it will not print out anything in Unity Console, but you have to find it from the Unity Editor log.

    Note: println macro write the output to the stdout, which is not being displayed in Unity console.

    We will be talking about logging/tracing from the native plugin to Unity in a future part since before then, we have some baseline knowledge need to know about what is ok to pass between C# and Rust.

    C# and Rust are different languages, and the way they handle function call internal and data structure are different. However, the fundamental idea here we have to know is that both can have some features to allow interoperability with any language. The thing we need to look out for is the data structure. For a data structure to work in both languages, we have to make sure it has the same memory layout. Since the managed object in C# is a pointer to the actual data, which is managed by a garbage collector, it would be an extremely bad idea to pass that to anything outside of the C# runtime. The lifetime of that pointer is unpredictable(unless, rust has some mono API to operate on that data safely, which will not be the focus in this post).

    the only thing we could pass is those boring data structures like pointers, integers, floats, etc. In C# we call it blittable types.

    In this workflow, these types will be equivalent in both languages.

    - C# | Rust
    - byte | u8
    - sbyte | i8
    - short | i16
    - ushort | u16
    - int | i32
    - uint | u32
    - long | i64
    - ulong | u64
    - float | f32
    - double| f64
    - byte | bool (note here: bool is not blittable in C#, when ClangSharp generates the binding, it will be converted to byte)
    - T* | *mut T/ *const T (any pointer type)

    One more thing that you could pass around between C# and Rust is structures. However, for compatibility with HPC#, we will not be talking about passing structure or returning structure in this thread(except for a structure with only a pointer in the field, which is compatible in HPC# and I will talk about using it in future parts).
    (See:https://docs.unity3d.com/Packages/c...tIntrinsics.html#dllimport-and-internal-calls) Instead, we will need to use the pointer to that structure.

    So, let work on a function that prints out hello world in the console by passing that string from Rust to C#. I will do it in multiple different ways, and I will explain what works and what does not work.

    "How about just return a rust String back?"

    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn hello_world() -> String {
    3.     String::from("Hello World!")
    4. }
    This will not work since std::String does not have a consistent layout. For a rust struct to be FFI compatible, it must have the "#[repr(C)]" attribute.

    "OK, what if I just pass a pointer to the string back?"

    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn hello_world_by_ptr() -> *const u8 {
    3.     let string = String::from("Hello World!");
    4.     let bytes = string.as_bytes();
    5.     bytes.as_ptr()
    6. }
    This will not work, and a big no-no for this. There is two reasons for that:
    1. The pointer is pointing to a local variable that has a scope of the function, at the end of the function, the string will be dropped and the memory will be freed. the returned pointer will now become a dangling pointer.
    2. Even if the pointee is not freed, it will be dangerous to pass back since it is not a null-terminated string. Thus we need to use a CString instead of String for that.

    Note: In C#/C/C++, you have to remember that to free when something is out of scope.
    But in Rust, the freeing is guaranteed to happen at the end of the scope, sometime it will create unintended behaviors like here, we do not want to drop the pointee, since the content of the pointee is intentionally leaked to C#.

    "Well, just leak it intentionally then"

    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn hello_world_by_leaking_ptr() -> *const u8 {
    3.     let string = String::from("Hello World!");
    4.     let cstring = CString::new(string).unwrap();
    5.     let ptr = cstring.as_ptr();
    6.     std::mem::forget(cstring);
    7.     ptr as * const u8
    8. }
    This will work but it creates a potential memory leak if the memory is not freed from C#. What I mean by freeing from C# is that in C#, you have to call whatever is allocating the memory to free. However, that whatever-thing is rust internal allocator. Calling an allocator from C# might not redirect to the same allocator that Rust is using, and it will potentially lead to a Unity Crash. Thus, this method is not recommended.

    now from here, we can take two approaches, one is to keep using heap-allocated string or we can use something that stores strings in a location that does not take an indirection, something like FixedString32.

    for the first one, we need to include a wrapper to the rust internal allocator:

    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn rust_deallocate(ptr: *mut c_void, size: usize, align: usize) {
    3.     let layout = Layout::from_size_align(size, align).expect("");
    4.     std::alloc::dealloc(ptr as *mut u8, layout);
    5. }
    This will point to the Rust default Global allocator, which is the same allocator a String is using(unless you are using rust allocator API have a customize allocator for that String).

    We just need to we also pass the size and alignment of the string to the C# to allow C# to call the deallocating function. I will leave it to you to figure out how to do that.

    for the second approach, we need to have a FixedString32 implemented in Rust that has the same layout and behaves exactly the same as Unity ones. And I happened to have implemented one using macros to generate FixedString with different capacities. During your implementation, you have to make sure that the FixedString32 is also null-terminated as well.

    Code (Rust):
    1. use std::mem::MaybeUninit;
    2. use std::hash::{Hash, Hasher};
    3. macro_rules! impl_fixed_string {
    4.     ($name:ident, $array_len: expr, $cap: expr) => {
    5.         #[derive(Clone, Debug)]
    6.         #[repr(C)]
    7.         pub struct $name {
    8.             len: i16,
    9.             bytes: [u8; $array_len],
    10.         }
    11.  
    12.         impl Default for $name {
    13.             fn default() -> Self {
    14.                 Self::from_u8_slice(&[])
    15.             }
    16.         }
    17.  
    18.         impl $name {
    19.             const CAPACITY: usize = $cap;
    20.             pub fn capacity(&self) -> usize {
    21.                 Self::CAPACITY
    22.             }
    23.  
    24.             pub fn len(&self) -> usize {
    25.                 self.len as usize
    26.             }
    27.  
    28.             pub fn is_empty(&self) -> bool {
    29.                 self.len == 0
    30.             }
    31.  
    32.             pub fn clear(&mut self) {
    33.                 self.len = 0
    34.             }
    35.  
    36.             // this function is private since FixedString can only allow a valid utf-8 string
    37.             fn from_u8_slice<S: AsRef<[u8]>>(slice: S) -> Self {
    38.                 let slices = slice.as_ref();
    39.  
    40.                 let len = if slices.len() > ($cap) {
    41.                     $cap
    42.                 } else {
    43.                     slices.len() as i16
    44.                 };
    45.                 // Safety: this string will null-terminated, so it is fine to have it uninitiated
    46.                 unsafe {
    47.                     let bytes: MaybeUninit<[u8; ($array_len)]> = MaybeUninit::uninit();
    48.                     let mut bytes = bytes.assume_init();
    49.                     for i in 0..len as usize {
    50.                         bytes[i] = slices[i];
    51.                     }
    52.                     // must be null terminated
    53.                     bytes[len as usize] = b'\0';
    54.                     Self { len, bytes }
    55.                 }
    56.             }
    57.  
    58.             pub fn from_str<S: AsRef<str>>(string: S) -> Self {
    59.                 Self::from_u8_slice(string.as_ref())
    60.             }
    61.  
    62.             pub fn as_bytes(&self) -> &[u8] {
    63.                 // # Safety: we need to reconstruct a slice here since the bytes will contain uninitialized memory
    64.                 unsafe {
    65.                     std::slice::from_raw_parts(self.bytes.as_ptr() as *const u8, self.len as usize)
    66.                 }
    67.             }
    68.             pub fn as_str(&self) -> &str {
    69.                 // # Safety: we know that is no way to produce a FixedString being invalid utf-8 string
    70.                 unsafe { std::str::from_utf8_unchecked(self.as_bytes()) }
    71.             }
    72.         }
    73.  
    74.         impl From<Box<dyn std::any::Any + Send + 'static>> for $name {
    75.             fn from(e: Box<dyn std::any::Any + Send + 'static>) -> Self {
    76.                 // The documentation suggests that it will *usually* be a str or String.
    77.                 if let Some(s) = e.downcast_ref::<&'static str>() {
    78.                     $name::from_u8_slice(s)
    79.                 } else if let Some(s) = e.downcast_ref::<String>() {
    80.                     $name::from_u8_slice(s)
    81.                 } else {
    82.                     $name::from_u8_slice(&"Unknown panic!")
    83.                 }
    84.             }
    85.         }
    86.  
    87.         impl From<String> for $name {
    88.             fn from(string: String) -> Self {
    89.                 $name::from_u8_slice(string)
    90.             }
    91.         }
    92.  
    93.         impl From<&str> for $name {
    94.             fn from(from: &str) -> Self {
    95.                 Self::from_str(from)
    96.             }
    97.         }
    98.  
    99.         impl AsRef<str> for $name {
    100.             fn as_ref(&self) -> &str {
    101.                 self.as_str()
    102.             }
    103.         }
    104.  
    105.         impl PartialEq for $name {
    106.             #[inline]
    107.  
    108.             fn eq(&self, other: &Self) -> bool {
    109.                 self.as_str() == other.as_str()
    110.             }
    111.         }
    112.  
    113.         impl Eq for $name {}
    114.  
    115.         impl Hash for $name {
    116.             #[inline]
    117.             fn hash<H: Hasher>(&self, hasher: &mut H) {
    118.                 (*self.as_ref()).hash(hasher)
    119.             }
    120.         }
    121.     };
    122. }
    123.  
    124. impl_fixed_string!(FixedString32Ffi, 30, 29);
    125. impl_fixed_string!(FixedString64Ffi, 62, 61);
    126. impl_fixed_string!(FixedString128Ffi, 126, 125);
    127. impl_fixed_string!(FixedString512Ffi, 510, 509);
    128. impl_fixed_string!(FixedString4096Ffi, 4094, 4093);
    Note: However, if the FixedStringFfi takes a string that is longer than the capacity, it will truncate the string. You could panic it out due to your needs.
    we can also write a test to verify that the FixedString32Ffi is exactly 32 bytes long.

    Code (Rust):
    1. #[cfg(test)]
    2. mod test {
    3.     use std::mem::size_of;
    4.  
    5.     use super::*;
    6.  
    7.     #[test]
    8.     fn test_layout() {
    9.         assert_eq!(size_of::<FixedString32Ffi>(), 32);
    10.         assert_eq!(size_of::<FixedString64Ffi>(), 64);
    11.         assert_eq!(size_of::<FixedString128Ffi>(), 128);
    12.         assert_eq!(size_of::<FixedString512Ffi>(), 512);
    13.         assert_eq!(size_of::<FixedString4096Ffi>(), 4096);
    14.     }
    15. }
    After implemented the FixedStringFfi, we can use it in our code:

    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn hello_world_by_return_fixed_string() -> *const FixedString32Ffi {
    3.     Box::leak(Box::new(FixedString32Ffi::from(
    4.         "Hello World!",
    5.     )))
    6. }
    Note: I have mentioned that will not be return a structure directly, but instead return a pointer to the structure. Then, we must box it by allocate and place it on the heap, and leak it to C#. But this is less ideal than the following:

    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn hello_world_by_out_fixed_string(string: *mut FixedString128Ffi) -> bool {
    3.     *string = FixedString128Ffi::from("Hello World!");
    4.     true
    5. }
    this grammar is similar to C# where parameters are passed by our reference. It works better since there is no heap allocation, and HPC# can use this function as well. The boolean is there just to indicate whether the function is successful or not.

    After finishing the rust side, it is a good time to take a look at the C# side, and we already have those bindings generated to FixedStringFfi and the hello_world_by_out_fixed_string if you set up a bindgen chain from part 1:

    Code (CSharp):
    1. public unsafe partial struct FixedString32Ffi
    2. {
    3.     [NativeTypeName("int16_t")]
    4.     public short len;
    5.     [NativeTypeName("uint8_t [30]")]
    6.     public fixed byte bytes[30];
    7. }
    Here it uses a fixed byte array to store the string. The length of it is stored in the first 2 bytes, and the rest of the bytes are the string. Curiously, it is a more straightforward implementation than Unity.Collections.FixedString32.

    it is also good to write some helpers to these structures to make them easier to use:

    Code (CSharp):
    1. public partial struct FixedString32Ffi
    2. {
    3.     public static implicit operator FixedString32(FixedString32Ffi d)
    4.     {
    5.         unsafe
    6.         {
    7.             return *(FixedString32*) &d;
    8.         }
    9.     }
    10.    
    11.     public static implicit operator FixedString32Ffi(FixedString32 d)
    12.     {
    13.         unsafe
    14.         {
    15.             return *(FixedString32Ffi*) &d;
    16.         }
    17.     }
    18. }
    After which, we can call the rust function to get a "Hello World!" string from Rust in form of FixedString32. I hope you get a glimpse of how the data is passed between C# and Rust in this workflow. If you have some questions, feel free to ask, and we will be talking about how to deal with panic in the next part.
     
    Last edited: Dec 30, 2021
    defic, MNNoxMortem, NotaNaN and 5 others like this.
  3. l33t_P4j33t

    l33t_P4j33t

    Joined:
    Jul 29, 2019
    Posts:
    232
    ..why though?
    there's memory safety. you don't ever need to use pointers. not moreso than in rust.

    i would much rather use C++ 20.
    get some constexpr if action going.
     
  4. Arowx

    Arowx

    Joined:
    Nov 12, 2009
    Posts:
    8,194
    OK but show me the benchmarks what performance boost do I get from learning another programming language?
     
  5. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    We need to some data structure that is not inside Unity.Collections. The simplest example would be, how do you write a nested array without using UnsafeList or pointers? Then the memory problem comes. That is fine if you are more comfortable with C++, but I was not talking about replacing C++ with Rust, but I was talking about replacing HPC# with Rust.

    Not sure I understand what you mean.

    Thanks for your reply, and I will keep updating.
     
    Last edited: Dec 27, 2021
    Occuros likes this.
  6. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    Depends on what you want. If your are benchmark two same functionality in HPC# and Rust, I do not think there is any trick to push one ahead of the other one too much in most of cases. The low-level control is there for both languages. But the issue with HPC# was not the performance but the expressiveness limited by all sort of problems(I mentioned).

    If you only need the functionality that is provided by Unity which is written in C++ but interfaced to you via C#, I don't think there is a lot of performance boost that you could get out of from learning another programming language. But If you need to write, let's say a database allowing you stream your world state data consistently to the file system because your world is just too large to save at once, I will implement that in neither C# nor HPC#.
     
  7. Mortuus17

    Mortuus17

    Joined:
    Jan 6, 2020
    Posts:
    105
    Memory safety built into a language is just an abstraction layer. Somebody, at some point, would've had to go there and build the data structures with raw pointers, as is the case with Unity.Collections. It's not difficult if it stays in one class and is backed up by unit tests. There is no "unsafety" anywhere.
    I don't get why one would avoid doing that dogmatically where that is something every programmer should be capable of.
    I also don't get why then, at the same time, people basically say "we can do it better!" and give up on a substantial amount of the work that has already been done by the Unity team. Especially performance will suffer in the long run, as combining Burst code with an IL2CPP executable will be done without an interop layer.



    constexpr
    is a very powerful tool in C++ which lets you evaluate anything at compile time. Not only the if branches that would only ever be evaluated at compile time - never at runtime -, as the post suggested, but also stuff like functions which may contain loops where the compiler cannot say whether or not it is infinite (halting problem). Although Burst gives us the
    Unity.Burst.CompilerServices.Constant.IsConstantExpression<T>
    which is also very powerful but doesn't come close to the C++ feature, unfortunately.
     
  8. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    Yes, I agree with you on that. But, I choose to develop that data structure in Rust because I have the option. I am posting this thread so that other people who also enjoys Rust could also have this option.

    I need you to explain why you assume that what I have here will suffer more from interop layer than "combining Burst code with an IL2CPP executable" will in performance? Is it true that Burst code is statically linked to the final IL2CPP code?

    Thank you for explain constexpr as a keyword in C++, but care to explain "get some constexpr if action going" for him instead of the keyword?
    Anyways, I do not expect you do do it for him, but please stay on topic.
     
    Last edited: Dec 27, 2021
    Occuros likes this.
  9. Mortuus17

    Mortuus17

    Joined:
    Jan 6, 2020
    Posts:
    105
    No not yet - burst compiled code still results in a DLL, currently. But it is something they're working on - or at the very least it is on their to-do list. But that was an example for the long run which will be "huge" (200+ CPU cycles per function call of a difference). You're already suffering from performance issues, though, which could be avoided if you sticked to HPC#. Most of them are minor but they do add up and could even cripple your performance - but I do assume that you don't treat you DLLImport functions as normals functions but rather something like instantiating an IJob, so that's probably not a performance issue.

    In the end... If you're not willing to write your code in HPC# you'll pay for it by getting less perforjmance out of it.

    The dude probably - likely - wanted to say that constexpr in C++ alone would make him want to write all of his code in C++ rather than in Rust if he were to choose a high performance language to do interop with - but as you said: You specifically targeted this post towards people who enjoy Rust ( which is a minority :p ).
     
  10. snacktime

    snacktime

    Joined:
    Apr 15, 2013
    Posts:
    3,356
    Unity has a native logging api FYI.
     
  11. Neto_Kokku

    Neto_Kokku

    Joined:
    Feb 15, 2018
    Posts:
    1,751
    I think it's only on Windows/Xbox (not sure about Mac and Linux) where native plugins are dynamically linked. Everywhere else it becomes a static library and the PInvoke price goes away.

    Anyway, burst is nowhere near as expressive as languages as C++ and Rust, and needs quite more boilerplate than vanilla C. Writing really complex stuff with it can become a chore, so I appreciate efforts to document different pipelines for working with native code in Unity.
     
    xshadowmintx likes this.
  12. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    Not until 2021.2, but I will also talk about using Unity native plugin api in a few post later.
     
    Last edited: Dec 27, 2021
  13. Kamyker

    Kamyker

    Joined:
    May 14, 2013
    Posts:
    1,085
    I'd rather use C++ and Unreal than rust in Unity.
     
  14. Ashkan_gc

    Ashkan_gc

    Joined:
    Aug 12, 2009
    Posts:
    1,117
    What is the point of people here complaining about Rust. If you don't want to use Rust, don't read his post. It's not like he is forcing anyone to do it.
     
    JLabarca, NotaNaN, Orimay and 5 others like this.
  15. TieSKey

    TieSKey

    Joined:
    Apr 14, 2011
    Posts:
    223
    While I mostly agree with you. It would be nice to have right at the beginning of the thread a list of objective/measurable pros/cons and some benchmarks to know if there is a performance price to pay or an actual gain.

    "because we can" or "because we like it that way" are valid yet very dangerous reasons to act on software development (well, on anything productive I dare to say).
    Sharing the experience is nice and all but it would be a lot more enriching if some more data was added so others can make informed decisions.
     
  16. Kamyker

    Kamyker

    Joined:
    May 14, 2013
    Posts:
    1,085
    Exactly the same as your when complaining about people complaining about Rust.
     
    OndrejP and RaL like this.
  17. snacktime

    snacktime

    Joined:
    Apr 15, 2013
    Posts:
    3,356
    That and what the pain points are generally.

    A big one I've seen with Rust is the C/C++ library ecosystem is just so much better. Like out of the 5 C libraries we use there are either no Rust equivalents or they are substantially inferior. Especially true for things specific to games/high performance/concurrency.

    We use Rust in one place which is for some high throughput IPC we do server side. That's an easy case because it's all Rust, and limited platforms it will only ever be linux or windows.
     
    MNNoxMortem likes this.
  18. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    I agree with you. A list of pros and cons is needed with some statistics to back it up could make the thread more clear for people to make a choice. In my defense, it was not included from the beginning because it was not about the performance from the starting when we switch majority of code from HPC# to Rust:

    We had implemented some data structures in the HPC#, but it was extremely hard for one who comes from a .NET background and does not understand native memory. One mistake could easily lead to a Unity crash, which we have seen none so far after switching to the current workflow because:
    1. panics from Rust are handled gracefully at FFI boundary
    2. codegen eliminated most of the unsafe ffi boilerplates

    Regardless, I shall include a more detailed list of pros and cons and statistics.
     
    Last edited: Dec 30, 2021
  19. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    For anyone interested in overhead during PInvoke from C# and Burst. I make this benchmark to try to estimate the impact:

    I will be using the QueryPerformanceCounter from winapi(See: https://docs.microsoft.com/en-us/windows/win32/api/profileapi/nf-profileapi-queryperformancecounter), which has a precision of less than 1000ns.

    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn get_instant_time_in_nanoseconds() -> u64 {
    3.     let now = std::time::Instant::now();
    4.     match std::panic::catch_unwind(|| {
    5.         let mut counter: u64 = 0;
    6.         let mut freq: u64 = 0;
    7.         winapi::um::profileapi::QueryPerformanceCounter(
    8.             &mut counter as *mut u64 as *mut winapi::shared::ntdef::LARGE_INTEGER,
    9.         );
    10.         winapi::um::profileapi::QueryPerformanceFrequency(
    11.             &mut freq as *mut u64 as *mut winapi::shared::ntdef::LARGE_INTEGER,
    12.         );
    13.         let instant_nsec = mul_div_u64(counter, 1_000_000_000, freq);
    14.  
    15.         instant_nsec
    16.     }) {
    17.         Ok(instant_nsec) => instant_nsec,
    18.         Err(_) => 0,
    19.     }
    20. }
    21.  
    22. pub fn mul_div_u64(value: u64, numer: u64, denom: u64) -> u64 {
    23.     let q = value / denom;
    24.     let r = value % denom;
    25.     // Decompose value as (value/denom*denom + value%denom),
    26.     // substitute into (value*numer)/denom and simplify.
    27.     // r < denom, so (denom*numer) is the upper bound of (r*numer)
    28.     q * numer + r * numer / denom
    29. }
    Since the overhead of call into a Burst or Rust function is even smaller than 1000ns, I will benchmark many(250,000) iteration of calls into functions instead, and each time the benchmarked function will make a vectorized integer multiplication. I will also do the benchmark from both C# mono and Burst job.

    Code (CSharp):
    1. [BurstCompile(CompileSynchronously = true, DisableSafetyChecks = true)]
    2. public static unsafe void CallBurstNative(int4* aArrayPtr, int4* bArrayPtr, int4* cArrayPtr)
    3. {
    4.     for (int i = 0; i < TEST_ARRAY_SIZE; i++)
    5.     {
    6.         var aPtr = aArrayPtr + i;
    7.         var bPtr = bArrayPtr + i;
    8.         var cPtr = cArrayPtr + i;
    9.         *cPtr = *aPtr * *bPtr;
    10.     }
    11. }
    and the Rust counterpart:
    Code (CSharp):
    1. [BurstCompile(CompileSynchronously = true, DisableSafetyChecks = true)]
    2. public static unsafe void CallRust(int4* aArrayPtr, int4* bArrayPtr, int4* cArrayPtr)
    3. {
    4.     for (int i = 0; i < TEST_ARRAY_SIZE; i++)
    5.     {
    6.         var aPtr = aArrayPtr + i;
    7.         var bPtr = bArrayPtr + i;
    8.         var cPtr = cArrayPtr + i;
    9.         Extern.test_multiply((Point4Ffi<int>*)aPtr, (Point4Ffi<int>*)bPtr, (Point4Ffi<int>*)cPtr);
    10.     }
    11. }
    Code (Rust):
    1. #[inline]
    2. fn multiply_internal(a: &Point4Ffi<i32>, b: &Point4Ffi<i32>) -> Point4Ffi<i32> {
    3.     Point4Ffi {
    4.         x: a.x * b.x,
    5.         y: a.y * b.y,
    6.         z: a.z * b.z,
    7.         w: a.w * b.w,
    8.     }
    9. }
    10.  
    11. #[no_mangle]
    12. pub unsafe extern "C" fn test_multiply(
    13.     a: *const Point4Ffi<i32>,
    14.     b: *const Point4Ffi<i32>,
    15.     out_result: *mut Point4Ffi<i32>,
    16. ) -> bool {
    17.     *out_result = multiply_internal(&*a, &*b);
    18.     true
    19. }
    20.  
    21. #[derive(Copy, Clone, Eq, PartialOrd, PartialEq, Hash, Debug)]
    22. #[repr(simd)]
    23. pub struct Point4Ffi<I> {
    24.     pub x: I,
    25.     pub y: I,
    26.     pub z: I,
    27.     pub w: I,
    28. }
    29.  
    I will also include another version for Rust using catch_unwind which will catch any unwinding which is trigger by a panic. This is crucial for Rust FFI safety, and I will be including it as well.
    Code (CSharp):
    1. [BurstCompile(CompileSynchronously = true, DisableSafetyChecks = true)]
    2. public static unsafe void CallRustCatchUnwind(int4* aArrayPtr, int4* bArrayPtr, int4* cArrayPtr)
    3. {
    4.     for (int i = 0; i < TEST_ARRAY_SIZE; i++)
    5.     {
    6.         var aPtr = aArrayPtr + i;
    7.         var bPtr = bArrayPtr + i;
    8.         var cPtr = cArrayPtr + i;
    9.         Extern.test_multiply_catch_unwind((Point4Ffi<int>*)aPtr, (Point4Ffi<int>*)bPtr,
    10.             (Point4Ffi<int>*)cPtr);
    11.     }
    12. }
    Code (Rust):
    1. #[inline]
    2. fn multiply_internal_checked(a: &Point4Ffi<i32>, b: &Point4Ffi<i32>) -> Point4Ffi<i32> {
    3.     Point4Ffi {
    4.         x: a.x.checked_mul(b.x).unwrap(),
    5.         y: a.y.checked_mul(b.y).unwrap(),
    6.         z: a.z.checked_mul(b.z).unwrap(),
    7.         w: a.w.checked_mul(b.w).unwrap(),
    8.     }
    9. }
    10.  
    11. #[no_mangle]
    12. pub unsafe extern "C" fn test_multiply_catch_unwind(
    13.     a:  *const Point4Ffi<i32>,
    14.     b:  *const Point4Ffi<i32>,
    15.     out_result: *mut Point4Ffi<i32>,
    16. ) -> bool {
    17.     let error = std::panic::catch_unwind(|| multiply_internal_checked(&*a, &*b));
    18.  
    19.     match error {
    20.         Ok(result) => {
    21.             *out_result = result;
    22.             true
    23.         }
    24.         Err(_) => false,
    25.     }
    26. }
    And Here are the average result after doing the whole benchmark 10 times:

    Burst CallBurstNative: 923,910ns
    Burst CallRust: 1,007,560ns
    Burst CallRustCatchUnwind:1,635,790ns
    C# CallBurstNative: 3,106,310ns
    C# CallRust: 1,698,170ns
    C# CallRustCatchUnwind: 3,334,940ns

    From the data, we can see that calling into Rust function without catch_unwind has a close performance to the Burst implementation with the gap of only 0.3346 ns per call(~1 cpu cycle). Meanwhile the performance cost of catch_unwind in this case is around 2.51292 ns per call(~9 cpu cycle) on top of Rust function call from Burst.

    Calling Rust from C# directly has a better performance over calling burst compiled code from C# without catch_unwind, but when considering adding catch_unwind, it does bring the performance down.

    A few things to note here:
    1. All burst compiled code have DisableSafetyChecks set to true, while the test_multiply_catch_unwind checks for integer over flow(the reason for that is if the code does not panic, catch_unwind will be optimized away(the integer overflow for the implementation will intentionally trigger a panic in the function)). I did not do the checked multiplication for Burst because set DisableSafetyChecks to false will also allow burst to do some other checks, which mess up the performance of benchmarked Burst functions.
    2. Burst function code is inlined in the benchmark job, while Rust function is dynamic linked.
    3. The test is run under release mode inside the editor, with Burst safety checks disabled, leak detection disabled. I write everything with pointer operation to avoid ENABLE_UNITY_COLLECTIONS_CHECKS and any other safety checks.
    4. The Rust code is compiled under release profile with profile.release.opt-level set to 3, which will optimize the code as much as possible. (I also found out the profile.release.debug flag is true after doing every benchmarking, I assume this should not affect the performance)
    5. the benchmarked function is warmed up before the measurement while writing result to a native array allocated from Allocator.Temp.

    Here are the raw log and the rest of the codes:
    Code (log):
    1. TestPerformance (136.810s)
    2. ---
    3. Iteration 0
    4. TEST_ARRAY_SIZE = 250000
    5. WARMING_UP_ITERATIONS = 3
    6. get_instant_time_in_nanoseconds overhead called from C#: 2000ns
    7. get_instant_time_in_nanoseconds overhead from Burst: 200ns
    8. Burst CallBurstNative: 856000ns
    9. Burst CallRust: 964600 ns
    10. Burst CallRustCatchUnwind: 1557700 ns
    11. get_instant_time_in_nanoseconds overhead from Burst: 1800ns
    12. C# CallBurstNative: 3003900ns
    13. C# CallRust: 1756900 ns
    14. C# CallRustCatchUnwind: 3300200 ns
    15. Iteration 1
    16. TEST_ARRAY_SIZE = 250000
    17. WARMING_UP_ITERATIONS = 3
    18. get_instant_time_in_nanoseconds overhead called from C#: 200ns
    19. get_instant_time_in_nanoseconds overhead from Burst: 100ns
    20. Burst CallBurstNative: 836300ns
    21. Burst CallRust: 1028000 ns
    22. Burst CallRustCatchUnwind: 1567500 ns
    23. get_instant_time_in_nanoseconds overhead from Burst: 500ns
    24. C# CallBurstNative: 2918900ns
    25. C# CallRust: 1672700 ns
    26. C# CallRustCatchUnwind: 3116800 ns
    27. Iteration 2
    28. TEST_ARRAY_SIZE = 250000
    29. WARMING_UP_ITERATIONS = 3
    30. get_instant_time_in_nanoseconds overhead called from C#: 300ns
    31. get_instant_time_in_nanoseconds overhead from Burst: 200ns
    32. Burst CallBurstNative: 1092200ns
    33. Burst CallRust: 1171300 ns
    34. Burst CallRustCatchUnwind: 1681900 ns
    35. get_instant_time_in_nanoseconds overhead from Burst: 100ns
    36. C# CallBurstNative: 4519600ns
    37. C# CallRust: 1653300 ns
    38. C# CallRustCatchUnwind: 3028600 ns
    39. Iteration 3
    40. TEST_ARRAY_SIZE = 250000
    41. WARMING_UP_ITERATIONS = 3
    42. get_instant_time_in_nanoseconds overhead called from C#: 200ns
    43. get_instant_time_in_nanoseconds overhead from Burst: 100ns
    44. Burst CallBurstNative: 824200ns
    45. Burst CallRust: 901400 ns
    46. Burst CallRustCatchUnwind: 1740000 ns
    47. get_instant_time_in_nanoseconds overhead from Burst: 500ns
    48. C# CallBurstNative: 2899900ns
    49. C# CallRust: 1650900 ns
    50. C# CallRustCatchUnwind: 3011000 ns
    51. Iteration 4
    52. TEST_ARRAY_SIZE = 250000
    53. WARMING_UP_ITERATIONS = 3
    54. get_instant_time_in_nanoseconds overhead called from C#: 400ns
    55. get_instant_time_in_nanoseconds overhead from Burst: 300ns
    56. Burst CallBurstNative: 821900ns
    57. Burst CallRust: 885700 ns
    58. Burst CallRustCatchUnwind: 1536800 ns
    59. get_instant_time_in_nanoseconds overhead from Burst: 400ns
    60. C# CallBurstNative: 2899400ns
    61. C# CallRust: 1663700 ns
    62. C# CallRustCatchUnwind: 3029000 ns
    63. Iteration 5
    64. TEST_ARRAY_SIZE = 250000
    65. WARMING_UP_ITERATIONS = 3
    66. get_instant_time_in_nanoseconds overhead called from C#: 200ns
    67. get_instant_time_in_nanoseconds overhead from Burst: 100ns
    68. Burst CallBurstNative: 835000ns
    69. Burst CallRust: 882400 ns
    70. Burst CallRustCatchUnwind: 1621200 ns
    71. get_instant_time_in_nanoseconds overhead from Burst: 200ns
    72. C# CallBurstNative: 2897300ns
    73. C# CallRust: 1664900 ns
    74. C# CallRustCatchUnwind: 5548800 ns
    75. Iteration 6
    76. TEST_ARRAY_SIZE = 250000
    77. WARMING_UP_ITERATIONS = 3
    78. get_instant_time_in_nanoseconds overhead called from C#: 200ns
    79. get_instant_time_in_nanoseconds overhead from Burst: 100ns
    80. Burst CallBurstNative: 815000ns
    81. Burst CallRust: 949900 ns
    82. Burst CallRustCatchUnwind: 1597000 ns
    83. get_instant_time_in_nanoseconds overhead from Burst: 300ns
    84. C# CallBurstNative: 2910000ns
    85. C# CallRust: 1721000 ns
    86. C# CallRustCatchUnwind: 3013800 ns
    87. Iteration 7
    88. TEST_ARRAY_SIZE = 250000
    89. WARMING_UP_ITERATIONS = 3
    90. get_instant_time_in_nanoseconds overhead called from C#: 100ns
    91. get_instant_time_in_nanoseconds overhead from Burst: 200ns
    92. Burst CallBurstNative: 1265800ns
    93. Burst CallRust: 1214500 ns
    94. Burst CallRustCatchUnwind: 1754600 ns
    95. get_instant_time_in_nanoseconds overhead from Burst: 500ns
    96. C# CallBurstNative: 3153100ns
    97. C# CallRust: 1796400 ns
    98. C# CallRustCatchUnwind: 3145100 ns
    99. Iteration 8
    100. TEST_ARRAY_SIZE = 250000
    101. WARMING_UP_ITERATIONS = 3
    102. get_instant_time_in_nanoseconds overhead called from C#: 400ns
    103. get_instant_time_in_nanoseconds overhead from Burst: 400ns
    104. Burst CallBurstNative: 803300ns
    105. Burst CallRust: 879000 ns
    106. Burst CallRustCatchUnwind: 1592700 ns
    107. get_instant_time_in_nanoseconds overhead from Burst: 500ns
    108. C# CallBurstNative: 2899100ns
    109. C# CallRust: 1637400 ns
    110. C# CallRustCatchUnwind: 3032600 ns
    111. Iteration 9
    112. TEST_ARRAY_SIZE = 250000
    113. WARMING_UP_ITERATIONS = 3
    114. get_instant_time_in_nanoseconds overhead called from C#: 200ns
    115. get_instant_time_in_nanoseconds overhead from Burst: 200ns
    116. Burst CallBurstNative: 1089400ns
    117. Burst CallRust: 1198800 ns
    118. Burst CallRustCatchUnwind: 1708500 ns
    119. get_instant_time_in_nanoseconds overhead from Burst: 700ns
    120. C# CallBurstNative: 2961900ns
    121. C# CallRust: 1764500 ns
    122. C# CallRustCatchUnwind: 3123500 ns
    Code (CSharp):
    1. [Test]
    2.         public static void TestPerformance()
    3.         {
    4.             for (int k = 0; k < 10; k++)
    5.             {
    6.                 Debug.Log("Iteration " + k);
    7.                 Debug.Log($"TEST_ARRAY_SIZE = {TestJob.TEST_ARRAY_SIZE}");
    8.                 Debug.Log($"WARMING_UP_ITERATIONS = {TestJob.WARMING_UP_ITERATIONS}");
    9.  
    10.                 var aArray = new NativeArray<int4>(TestJob.TEST_ARRAY_SIZE, Allocator.Persistent);
    11.                 var bArray = new NativeArray<int4>(TestJob.TEST_ARRAY_SIZE, Allocator.Persistent);
    12.                 var cArray0 = new NativeArray<int4>(TestJob.TEST_ARRAY_SIZE, Allocator.Persistent);
    13.                 var cArray1 = new NativeArray<int4>(TestJob.TEST_ARRAY_SIZE, Allocator.Persistent);
    14.                 var cArray2 = new NativeArray<int4>(TestJob.TEST_ARRAY_SIZE, Allocator.Persistent);
    15.                 var cArray3 = new NativeArray<int4>(TestJob.TEST_ARRAY_SIZE, Allocator.Persistent);
    16.                 var cArray4 = new NativeArray<int4>(TestJob.TEST_ARRAY_SIZE, Allocator.Persistent);
    17.                 var cArray5 = new NativeArray<int4>(TestJob.TEST_ARRAY_SIZE, Allocator.Persistent);
    18.  
    19.  
    20.                 {
    21.                     var start = Extern.get_instant_time_in_nanoseconds();
    22.  
    23.                     var counter = Extern.get_instant_time_in_nanoseconds() - start;
    24.                     Debug.Log($"get_instant_time_in_nanoseconds overhead called from C#: {counter}ns");
    25.                 }
    26.  
    27.                 unsafe
    28.                 {
    29.                     var aArrayPtr = (Point4Ffi<int>*)aArray.GetUnsafePtr();
    30.                     var bArrayPtr = (Point4Ffi<int>*)bArray.GetUnsafePtr();
    31.                     var start = Extern.get_instant_time_in_nanoseconds();
    32.  
    33.                     for (int i = 0; i < TestJob.TEST_ARRAY_SIZE; i++)
    34.                     {
    35.                         var aPtr = aArrayPtr + i;
    36.                         var bPtr = bArrayPtr + i;
    37.                         Extern.init_random_number_for_test(aPtr, bPtr);
    38.                     }
    39.  
    40.                     var job = new TestJob
    41.                     {
    42.                         aArray = aArray,
    43.                         bArray = bArray,
    44.                         cArray0 = cArray0,
    45.                         cArray1 = cArray1,
    46.                         cArray2 = cArray2,
    47.                     };
    48.                     job.Run();
    49.                 }
    50.  
    51.                 {
    52.                     var start = Extern.get_instant_time_in_nanoseconds();
    53.                     var counter = Extern.get_instant_time_in_nanoseconds() - start;
    54.                     Debug.Log($"get_instant_time_in_nanoseconds overhead from Burst: {counter}ns");
    55.                 }
    56.  
    57.                 // warming up
    58.                 for (int j = 0; j < TestJob.WARMING_UP_ITERATIONS; j++)
    59.                 {
    60.                     var temp = new NativeArray<int4>(aArray.Length, Allocator.Temp);
    61.                     unsafe
    62.                     {
    63.                         var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    64.                         var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    65.                         var cArrayPtr = (int4*)temp.GetUnsafePtr();
    66.                         CallBurstNative(aArrayPtr, bArrayPtr, cArrayPtr);
    67.                     }
    68.  
    69.                     temp.Dispose();
    70.                 }
    71.  
    72.                 unsafe
    73.                 {
    74.                     var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    75.                     var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    76.                     var cArrayPtr = (int4*)cArray3.GetUnsafePtr();
    77.                     var start = Extern.get_instant_time_in_nanoseconds();
    78.                     CallBurstNative(aArrayPtr, bArrayPtr, cArrayPtr);
    79.                     var counter = Extern.get_instant_time_in_nanoseconds() - start;
    80.                     Debug.Log($"C# CallBurstNative: {counter}ns");
    81.                 }
    82.  
    83.                 // warming up
    84.                 for (int j = 0; j < TestJob.WARMING_UP_ITERATIONS; j++)
    85.                 {
    86.                     var temp = new NativeArray<int4>(aArray.Length, Allocator.Temp);
    87.                     unsafe
    88.                     {
    89.                         var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    90.                         var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    91.                         var cArrayPtr = (int4*)temp.GetUnsafePtr();
    92.                         CallRust(aArrayPtr, bArrayPtr, cArrayPtr);
    93.                     }
    94.  
    95.                     temp.Dispose();
    96.                 }
    97.  
    98.                 unsafe
    99.                 {
    100.                     var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    101.                     var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    102.                     var cArrayPtr = (int4*)cArray4.GetUnsafePtr();
    103.                     var start = Extern.get_instant_time_in_nanoseconds();
    104.                     CallRust(aArrayPtr, bArrayPtr, cArrayPtr);
    105.                     var counter = Extern.get_instant_time_in_nanoseconds() - start;
    106.                     Debug.Log($"C# CallRust: {counter} ns");
    107.                 }
    108.  
    109.                 for (int j = 0; j < TestJob.WARMING_UP_ITERATIONS; j++)
    110.                 {
    111.                     var temp = new NativeArray<int4>(aArray.Length, Allocator.Temp);
    112.                     unsafe
    113.                     {
    114.                         var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    115.                         var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    116.                         var cArrayPtr = (int4*)temp.GetUnsafePtr();
    117.                         CallRustCatchUnwind(aArrayPtr, bArrayPtr, cArrayPtr);
    118.                     }
    119.  
    120.                     temp.Dispose();
    121.                 }
    122.  
    123.                 unsafe
    124.                 {
    125.                     var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    126.                     var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    127.                     var cArrayPtr = (int4*)cArray5.GetUnsafePtr();
    128.                     var start = Extern.get_instant_time_in_nanoseconds();
    129.                     CallRustCatchUnwind(aArrayPtr, bArrayPtr, cArrayPtr);
    130.                     var counter = Extern.get_instant_time_in_nanoseconds() - start;
    131.                     Debug.Log($"C# CallRustCatchUnwind: {counter} ns");
    132.                 }
    133.  
    134.  
    135.                 for (int i = 0; i < TestJob.TEST_ARRAY_SIZE; i++)
    136.                 {
    137.                     Assert.AreEqual(aArray[i] * bArray[i], cArray0[i]);
    138.                     Assert.AreEqual(aArray[i] * bArray[i], cArray1[i]);
    139.                     Assert.AreEqual(aArray[i] * bArray[i], cArray2[i]);
    140.                     Assert.AreEqual(aArray[i] * bArray[i], cArray3[i]);
    141.                     Assert.AreEqual(aArray[i] * bArray[i], cArray4[i]);
    142.                     Assert.AreEqual(aArray[i] * bArray[i], cArray5[i]);
    143.                 }
    144.  
    145.                 aArray.Dispose();
    146.                 bArray.Dispose();
    147.                 cArray0.Dispose();
    148.                 cArray1.Dispose();
    149.                 cArray2.Dispose();
    150.                 cArray3.Dispose();
    151.                 cArray4.Dispose();
    152.                 cArray5.Dispose();
    153.             }
    154.         }
    155.  
    156.         public static unsafe void CallBurstNative(int4* aArrayPtr, int4* bArrayPtr, int4* cArrayPtr)
    157.         {
    158.             for (int i = 0; i < TestJob.TEST_ARRAY_SIZE; i++)
    159.             {
    160.                 var aPtr = aArrayPtr + i;
    161.                 var bPtr = bArrayPtr + i;
    162.                 var cPtr = cArrayPtr + i;
    163.                 *cPtr = *aPtr * *bPtr;
    164.             }
    165.         }
    166.  
    167.         public static unsafe void CallRust(int4* aArrayPtr, int4* bArrayPtr, int4* cArrayPtr)
    168.         {
    169.             for (int i = 0; i < TestJob.TEST_ARRAY_SIZE; i++)
    170.             {
    171.                 var aPtr = aArrayPtr + i;
    172.                 var bPtr = bArrayPtr + i;
    173.                 var cPtr = cArrayPtr + i;
    174.                 Extern.test_multiply((Point4Ffi<int>*)aPtr, (Point4Ffi<int>*)bPtr, (Point4Ffi<int>*)cPtr);
    175.             }
    176.         }
    177.  
    178.         public static unsafe void CallRustCatchUnwind(int4* aArrayPtr, int4* bArrayPtr, int4* cArrayPtr)
    179.         {
    180.             for (int i = 0; i < TestJob.TEST_ARRAY_SIZE; i++)
    181.             {
    182.                 var aPtr = aArrayPtr + i;
    183.                 var bPtr = bArrayPtr + i;
    184.                 var cPtr = cArrayPtr + i;
    185.                 Extern.test_multiply_catch_unwind((Point4Ffi<int>*)aPtr, (Point4Ffi<int>*)bPtr,
    186.                     (Point4Ffi<int>*)cPtr);
    187.             }
    188.         }
    189.  
    190.         [BurstCompile(CompileSynchronously = true, DisableSafetyChecks = true)]
    191.         public struct TestJob : IJob
    192.         {
    193.             public const int WARMING_UP_ITERATIONS = 3;
    194.             public const int TEST_ARRAY_SIZE = 250_000;
    195.  
    196.             public NativeArray<int4> aArray;
    197.             public NativeArray<int4> bArray;
    198.             public NativeArray<int4> cArray0;
    199.             public NativeArray<int4> cArray1;
    200.             public NativeArray<int4> cArray2;
    201.  
    202.             [BurstCompile(CompileSynchronously = true, DisableSafetyChecks = true)]
    203.             public void Execute()
    204.             {
    205.                 {
    206.                     var start = Extern.get_instant_time_in_nanoseconds();
    207.                     var counter = Extern.get_instant_time_in_nanoseconds() - start;
    208.                     Debug.Log($"get_instant_time_in_nanoseconds overhead from Burst: {counter}ns");
    209.                 }
    210.  
    211.                 // warming up
    212.                 for (int j = 0; j < WARMING_UP_ITERATIONS; j++)
    213.                 {
    214.                     var temp = new NativeArray<int4>(aArray.Length, Allocator.Temp);
    215.                     unsafe
    216.                     {
    217.                         var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    218.                         var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    219.                         var cArrayPtr = (int4*)temp.GetUnsafePtr();
    220.                         CallBurstNative(aArrayPtr, bArrayPtr, cArrayPtr);
    221.                     }
    222.  
    223.                     temp.Dispose();
    224.                 }
    225.  
    226.                 unsafe
    227.                 {
    228.                     var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    229.                     var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    230.                     var cArrayPtr = (int4*)cArray0.GetUnsafePtr();
    231.                     var start = Extern.get_instant_time_in_nanoseconds();
    232.                     CallBurstNative(aArrayPtr, bArrayPtr, cArrayPtr);
    233.                     var counter = Extern.get_instant_time_in_nanoseconds() - start;
    234.                     Debug.Log($"Burst CallBurstNative: {counter}ns");
    235.                 }
    236.  
    237.                 // warming up
    238.                 for (int j = 0; j < WARMING_UP_ITERATIONS; j++)
    239.                 {
    240.                     var temp = new NativeArray<int4>(aArray.Length, Allocator.Temp);
    241.                     unsafe
    242.                     {
    243.                         var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    244.                         var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    245.                         var cArrayPtr = (int4*)temp.GetUnsafePtr();
    246.                         CallRust(aArrayPtr, bArrayPtr, cArrayPtr);
    247.                     }
    248.  
    249.                     temp.Dispose();
    250.                 }
    251.  
    252.                 unsafe
    253.                 {
    254.                     var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    255.                     var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    256.                     var cArrayPtr = (int4*)cArray1.GetUnsafePtr();
    257.                     var start = Extern.get_instant_time_in_nanoseconds();
    258.                     CallRust(aArrayPtr, bArrayPtr, cArrayPtr);
    259.                     var counter = Extern.get_instant_time_in_nanoseconds() - start;
    260.                     Debug.Log($"Burst CallRust: {counter} ns");
    261.                 }
    262.  
    263.                 for (int j = 0; j < WARMING_UP_ITERATIONS; j++)
    264.                 {
    265.                     var temp = new NativeArray<int4>(aArray.Length, Allocator.Temp);
    266.                     unsafe
    267.                     {
    268.                         var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    269.                         var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    270.                         var cArrayPtr = (int4*)temp.GetUnsafePtr();
    271.                         CallRustCatchUnwind(aArrayPtr, bArrayPtr, cArrayPtr);
    272.                     }
    273.  
    274.                     temp.Dispose();
    275.                 }
    276.  
    277.                 unsafe
    278.                 {
    279.                     var aArrayPtr = (int4*)aArray.GetUnsafeReadOnlyPtr();
    280.                     var bArrayPtr = (int4*)bArray.GetUnsafeReadOnlyPtr();
    281.                     var cArrayPtr = (int4*)cArray2.GetUnsafePtr();
    282.                     var start = Extern.get_instant_time_in_nanoseconds();
    283.                     CallRustCatchUnwind(aArrayPtr, bArrayPtr, cArrayPtr);
    284.                     var counter = Extern.get_instant_time_in_nanoseconds() - start;
    285.                     Debug.Log($"Burst CallRustCatchUnwind: {counter} ns");
    286.                 }
    287.             }
    288.  
    289.             [BurstCompile(CompileSynchronously = true, DisableSafetyChecks = true)]
    290.             public static unsafe void CallBurstNative(int4* aArrayPtr, int4* bArrayPtr, int4* cArrayPtr)
    291.             {
    292.                 for (int i = 0; i < TEST_ARRAY_SIZE; i++)
    293.                 {
    294.                     var aPtr = aArrayPtr + i;
    295.                     var bPtr = bArrayPtr + i;
    296.                     var cPtr = cArrayPtr + i;
    297.                     *cPtr = *aPtr * *bPtr;
    298.                 }
    299.             }
    300.  
    301.             [BurstCompile(CompileSynchronously = true, DisableSafetyChecks = true)]
    302.             public static unsafe void CallRust(int4* aArrayPtr, int4* bArrayPtr, int4* cArrayPtr)
    303.             {
    304.                 for (int i = 0; i < TEST_ARRAY_SIZE; i++)
    305.                 {
    306.                     var aPtr = aArrayPtr + i;
    307.                     var bPtr = bArrayPtr + i;
    308.                     var cPtr = cArrayPtr + i;
    309.                     Extern.test_multiply((Point4Ffi<int>*)aPtr, (Point4Ffi<int>*)bPtr, (Point4Ffi<int>*)cPtr);
    310.                 }
    311.             }
    312.  
    313.             [BurstCompile(CompileSynchronously = true, DisableSafetyChecks = true)]
    314.             public static unsafe void CallRustCatchUnwind(int4* aArrayPtr, int4* bArrayPtr, int4* cArrayPtr)
    315.             {
    316.                 for (int i = 0; i < TEST_ARRAY_SIZE; i++)
    317.                 {
    318.                     var aPtr = aArrayPtr + i;
    319.                     var bPtr = bArrayPtr + i;
    320.                     var cPtr = cArrayPtr + i;
    321.                     Extern.test_multiply_catch_unwind((Point4Ffi<int>*)aPtr, (Point4Ffi<int>*)bPtr,
    322.                         (Point4Ffi<int>*)cPtr);
    323.                 }
    324.             }
    325.         }
    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn init_random_number_for_test(
    3.     a: *mut Point4Ffi<i32>,
    4.     b: *mut Point4Ffi<i32>,
    5. ) -> bool {
    6.     let error = std::panic::catch_unwind(move ||{
    7.         let mut rng = rand::thread_rng();
    8.         (*a).x = rng.gen_range(0..32768);
    9.         (*a).y = rng.gen_range(0..32768);
    10.         (*a).z = rng.gen_range(0..32768);
    11.         (*a).w = rng.gen_range(0..32768);
    12.  
    13.         (*b).x = rng.gen_range(0..32768);
    14.         (*b).y = rng.gen_range(0..32768);
    15.         (*b).z = rng.gen_range(0..32768);
    16.         (*b).w = rng.gen_range(0..32768);
    17.     });
    18.  
    19.     match error {
    20.         Ok(()) => {
    21.             true
    22.         }
    23.         Err(_) => false,
    24.     }
    25. }
     
    Last edited: Dec 31, 2021
    dsmiller95, JLabarca, Orimay and 6 others like this.
  20. mischa2k

    mischa2k

    Joined:
    Sep 4, 2015
    Posts:
    4,347
    Thanks for sharing.
    Which platforms did you try to build for so far, and what were the challenges?
     
  21. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    Our current pipeline are building for both x86_64-pc-windows-msvc and x86_64-unknown-linux-gnu.

    I did not face any serious challenges from those two targets, x86_64-pc-windows-msvc is built from my windows workstation, and x86_64-unknown-linux-gnu is built from a wsl on the workstation, and cross-compilation is also an reliable option.

    It is going to get your hands dirty for other platforms like mobile or console. I only did a little bit research, it seems that one need to setup correct cross-compilation to that platform and create the Rust bindings to their sdk(assuming the platform does not support rust standard library). Here are the list of the target that Rust supports: https://doc.rust-lang.org/nightly/rustc/platform-support.html

    But for our cases, we just have to compile to PC platforms: Windows, Linux, MacOS, which are all well established for Rust to compile to.
     
    Last edited: Dec 31, 2021
    mischa2k likes this.
  22. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    Part 3 Unwind Safety, Procedural Macro, and Opaque Pointers,

    This is the part that is the biggest contributor to the productivity of this workflow and the part I am intrigued by. In previous parts, we solved a lot of problems to make using Rust being possible from Unity, but it was neither safe nor productive:
    1. Panic in Rust will either unwind the stack or abort the process, which is catastrophic for Unity.
    2. The Rust unsafe binding code has many boilerplate codes, which are extremely unsafe and have to be explicitly written for each safe function being exported.
    To overcome those, the three things that I will talk about in this part: unwind safety, opaque pointers, and procedural macro, are key points to make using Rust in Unity safe and productive.

    For the following code:
    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn test_multiply(
    3.     a: *const i32,
    4.     b: *const i32,
    5.     out_result: *mut i32,
    6. ) {
    7.     *out_result = a.checked_mul(b).unwrap();
    8. }
    If an integer overflow happens, such as a and b are both 50,000, the Rust will panic because it is trying to unwrap that result into an i32 and trigger the unwinding in "normal" Rust. The unwinding process will try to walk the stack and drop all the variables that are on the stack. Unfortunately, this will also try to unwind the stack created from the caller of the function, which in our cases is Unity. In the end, your best bet is that you will get a Unity crash with a crash report without any useful information because the stack is most likely borked by Rust's unwinding. This needs to be solved since a random panic will trigger Unity to be crashed, and one won't even figure out the reason of the crash by looking at the crash report.

    Luckily, Rust have a function called catch_unwind(See: https://doc.rust-lang.org/std/panic/fn.catch_unwind.html). This can stop most of the unwinding process:
    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn test_multiply_catch_unwind(
    3.     a: *const i32,
    4.     b: *const i32,
    5.     out_result: *mut i32,
    6. ) -> bool
    7.     let error = std::panic::catch_unwind(|| *out_result = a.checked_mul(b).unwrap());
    8.  
    9.     // we could handle this gracefully now
    10.     match error {
    11.         Ok(result) => {
    12.             *out_result = result;
    13.             true
    14.         }
    15.         Err(_) => false,
    16.     }
    17. }
    It is a good idea to use catch_unwind coupled with a return type of bool. If this were to stop the unwinding process, it will return a boolean to indicate whether the function is unsuccessful. (Optionally, you could return an enum type to pass the reason of the failure).

    Now with catch_unwind, whenever testing a function that could panics due to a careless unwrap or a boundary overflow, the function will just return safely, and the Unity side won't be affected by the panic. However, a few things has to be made clear for this:
    1. If you are circumventing the UnwindSafe or RefUnwindSafe trait in your code(such as using a pointer), it is crucial that you understand what it means for your code. Although unwinding ensures memory safe and minimum resource leak, it is not a guarantee that a state passed into Rust will not be poisoned, since the normal execution flow is disrupted. UnwindSafe or RefUnwindSafe are the traits that provide such things, but it won't guarantee every single type
    2. catching an unwinding will have high performance cost on the unwinding process.

    For both of these reasons, it is a good idea to treat panic as the last resort, which means:
    1. no panic should happen in the release build.
    2. if a panic happens, it is better to stop to figure out what is going on and either fix it or handle it gracefully.
    Being the last resort, catch_unwind does the job perfectly, prevent one from crashing Unity Editor, and keep the development smooth. To make things more productive, we could also use Procedural Macro to generate the unsafe Rust bindings to avoid writing catch_unwind repetitively.

    Procedural macro is an extremely powerful code generation tool, and I have been using it in conjunction with catch_unwind and to regenerate unsafe bindings to the associated functions of any opaque struct(including Dynamic-sized types) by using opaque pointers. I am going to talk about procedural macro and uses of opaque pointer together next.

    Passing a concrete data type only containing strings, integers, floats is pretty trivial, but is it possible to pass a type representing a more complex data structure like a set, a tree, or a graph from Rust to Unity? The answer is yes.
    For example, we have this structure, RandomRegistry, and some associated methods:

    Code (Rust):
    1. pub struct RandomRegistry {
    2.     pub number: std::collections::HashMap<i32, i32>,
    3. }
    4.  
    5. impl RandomRegistry {
    6.     pub fn new() -> Self {
    7.         Self {
    8.             number: std::collections::HashMap::new(),
    9.         }
    10.     }
    11.     pub fn register(&mut self, key: i32, value: i32) -> bool {
    12.         self.number.insert(key, value);
    13.         true
    14.     }
    15.  
    16.     pub fn get_registered_value(&self, key: i32, out_value: &mut i32) -> bool {
    17.         *out_value = self.number[&key];
    18.         true
    19.     }
    20. }
    The end goal is that we could write a procedural macro to generate the unsafe Rust bindings for this, but before then, let's hand write some unsafe bindings to capture a pattern that we could program with procedural macro.

    so firstly, the RandomRegistry::new(). If we need to construct a RandomRegistry from C#, return a typed pointer is sufficient enough:

    Code (Rust):
    1. #[no_mangle]
    2. pub unsafe extern "C" fn RandomRegistry_new_handle() -> *mut RandomRegistry {
    3.     let result = std::panic::catch_unwind(|| {
    4.         Box::into_raw(Box::new(RandomRegistry::new()))
    5.     });
    6.  
    7.     match result {
    8.         Ok(result) => result,
    9.         Err(_) => null_mut(),
    10.     }
    11. }
    Here, we intentionally allocate this RandomRegistry onto the heap, leak the memory, and return the pointer to C#. C# only needs to remember this pointer without caring about the internal layout of RandomRegistry. However, we need to include a drop function to deallocate RandomRegistry as well to complete its lifecycle:

    Code (Rust):
    1. //drop RandomRegistry
    2. #[no_mangle]
    3. pub unsafe extern "C" fn RandomRegistry_drop(_this: *mut RandomRegistry) {
    4.     let result = std::panic::catch_unwind(|| {
    5.         // This is something great about Rust, that line drops the pointer without mentioning dropping.
    6.         let _ = Box::from_raw(_this);
    7.     });
    8.  
    9.     match result {
    10.         Ok(result) => result,
    11.         Err(_) => (),
    12.     }
    13. }
    and for the rest, we could also create unsafe wrappers to the other methods that takes the pointer as input:

    Code (Rust):
    1. // wrapper to RandomRegistry::register
    2. #[no_mangle]
    3. pub unsafe extern "C" fn RandomRegistry_register(
    4.     _this: *mut RandomRegistry,
    5.     key: i32,
    6.     value: i32,
    7. ) -> bool {
    8.     let result = std::panic::catch_unwind(|| {
    9.         let _this = &mut *_this;
    10.         RandomRegistry::register(_this, key, value)
    11.     });
    12.  
    13.     match result {
    14.         Ok(result) => result,
    15.         Err(_) => false,
    16.     }
    17. }
    18.  
    19. // wrapper to RandomRegistry::get_registered_value
    20. #[no_mangle]
    21. pub unsafe extern "C" fn RandomRegistry_get_registered_value(
    22.     _this: *mut RandomRegistry,
    23.     key: i32,
    24.     out_value: *mut i32,
    25. ) -> bool {
    26.     let result = std::panic::catch_unwind(|| {
    27.         let _this = &mut *_this;
    28.         RandomRegistry::get_registered_value(_this, key, &mut *out_value)
    29.     });
    30.  
    31.     match result {
    32.         Ok(result) => result,
    33.         Err(_) => false,
    34.     }
    35. }
    A few things to note here:
    1. all function calls and type conversions are wrapped inside a panic::catch_unwind.
    2. UFCS(Uniform Function Call Syntax) is used here to call the method, doing so, it is easier to write the logic in proc_macro since associated functions do not necessarily need a self argument(the associated function without the taking self as an argument is close to a static function of a class/struct in C#).
    3. the unsafe function call returned something that could be nullable. If the return value is null, it means the function is returned early. We could abstract that out into Nullable trait in Rust:

    Code (Rust):
    1. pub trait Nullable {
    2.     fn null() -> Self;
    3. }
    4.  
    5. impl<T> Nullable for *const T {
    6.     fn null() -> Self {
    7.         std::ptr::null()
    8.     }
    9. }
    10.  
    11. impl<T> Nullable for *mut T {
    12.     fn null() -> Self {
    13.         std::ptr::null_mut()
    14.     }
    15. }
    16. impl Nullable for () {
    17.     fn null() -> Self {
    18.         ()
    19.     }
    20. }
    21.  
    22. macro_rules! impl_nullable_for_a_type_with_default {
    23.     ($($t:ty),*) => {
    24.         $(
    25.             impl Nullable for $t {
    26.                 fn null() -> Self {
    27.                     Default::default()
    28.                 }
    29.             }
    30.         )*
    31.     };
    32.     () => {
    33.  
    34.     };
    35. }
    36.  
    37. impl_nullable_for_a_type_with_default! {
    38.     i8, u8, i16, u16, i32, u32, i64, u64, i128, u128, isize, usize, bool, char, f32, f64
    39. }
    After analyze the common things here, we should be able to proc_macro to take the following code straight to C#:
    Code (Rust):
    1. #[derive_ffi]
    2. impl RandomRegistry {
    3.     pub fn new() -> Self {
    4.         Self {
    5.             number: std::collections::HashMap::new(),
    6.         }
    7.     }
    8.     pub fn register(&mut self, key: i32, value: i32) -> bool {
    9.         self.number.insert(key, value);
    10.         true
    11.     }
    12.  
    13.     pub fn get_registered_value(&self, key: i32, out_value: &mut i32) -> bool {
    14.         *out_value = self.number[&key];
    15.         true
    16.     }
    17. }
    And it generates C# binding like the following:
    Code (CSharp):
    1.  
    2.     public partial struct RandomRegistry
    3.     {
    4.     }
    5.     public static unsafe partial class Extern
    6.     {
    7.         [DllImport("yourdll", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
    8.         public static extern RandomRegistry* RandomRegistry_new_handle();
    9.  
    10.         [DllImport("yourdll", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
    11.         [return: NativeTypeName("bool")]
    12.         public static extern byte RandomRegistry_register(RandomRegistry* _this, [NativeTypeName("int32_t")] int key, [NativeTypeName("int32_t")] int value);
    13.  
    14.         [DllImport("yourdll", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
    15.         [return: NativeTypeName("bool")]
    16.         public static extern byte RandomRegistry_get_registered_value(RandomRegistry* _this, [NativeTypeName("int32_t")] int key, [NativeTypeName("int32_t *")] int* out_value);
    17.  
    18.         [DllImport("yourdll", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)]
    19.         public static extern void RandomRegistry_drop(RandomRegistry* _this);
    20.     }
    A pointer like RandomRegistry* is called an opaque pointer because it is a pointer pointing to a struct that has no implementation detail(thus the opaqueness). This implies that the pointer is intended to pass into a function that knows the implementation detail of the pointee but not to be dereferenced directly.

    And from C# we could now use RandomRegistry like this:
    Code (CSharp):
    1.  
    2.         [Test]
    3.         public static void TestOpaquePointer()
    4.         {
    5.             unsafe
    6.             {
    7.                 var pointer = Extern.RandomRegistry_new_handle();
    8.  
    9.                 if (Extern.RandomRegistry_register(pointer, 100, 100) == 0)
    10.                 {
    11.                     Debug.LogError("something goes wrong");
    12.                     return;
    13.                 }
    14.  
    15.                 int out_value;
    16.                 if (Extern.RandomRegistry_get_registered_value(pointer, 100, &out_value) == 0)
    17.                 {
    18.                     Debug.LogError("something goes wrong");
    19.                     return;
    20.                 }
    21.  
    22.                 Assert.AreEqual(out_value, 100);
    23.  
    24.                 Extern.RandomRegistry_drop(pointer);
    25.             }
    26.         }
    27.  
    However, this opaque pointer has some limitations:
    1. it can only be held by one owner. It is not technically incorrect to share the pointer with another structure, but doing so will make the lifetime of this pointer extremely complex since freeing from one place also mean you need to invalidate all other copy of the pointer.
    2. it is not synchronized, which means it is not safe to send this pointer to another concurrent thread, and there is no check to make sure whether it is being accessed exclusively or not when you pass it into a worker thread.

    Hence, I have been using another opaque pointer more often, called ArcRwLockHandle, which is basically an opaque pointer that wraps the Arc<RwLock<T>>. In Rust, The Arc stands for asynchronous reference counting, and RwLock stands for a read-write lock. The advantages of using this are:
    1. it allows multiple ownership asynchronously using asynchronous reference counter
    2. it allows exclusive access to the T using a read-write lock.
    3. it supports dynamic-sized types like Arc<RwLock<dyn TraitObject>, unlike *mut dyn TraitObject which is a wide pointer(which is not FFI-safe).

    Although it does come with some overhead, it can be used on types that need to be accessed from multiple threads(especially something that needs to be accessed with zero-copy, but the timing of access is hard to express by the unity's Job System).
     
    Last edited: Jan 13, 2022
    SamOld and bb8_1 like this.
  23. hippocoder

    hippocoder

    Digital Ape

    Joined:
    Apr 11, 2010
    Posts:
    29,723
    One concern with rust vs Unity HPC# is that once you have a lot of interaction going between rendering (culling, mesh modification or submission)/audio and Unity Engine code you could get race conditions or bottlenecks, something Unity can engineer for.
     
  24. mischa2k

    mischa2k

    Joined:
    Sep 4, 2015
    Posts:
    4,347
    Check this out: https://doc.rust-lang.org/nomicon/races.html
    Part of why people are excited for Rust over say C# is that they don't need to worry about data races.
     
    bb8_1 likes this.
  25. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    Does this have advantages over what DOTS does currently, when it gives you errors whenever your code could potentially result in data races and/or race conditions? (With option to bypass restrictions if you want to)

    I can't really tell at a glance
     
    Last edited: Feb 17, 2022
  26. snacktime

    snacktime

    Joined:
    Apr 15, 2013
    Posts:
    3,356
    Rust you really just have to go see how it does what it does at the code level to get a handle on it and have any basis to compare from. It really is that different.

    Rust safety is at the compiler level, it gets there by add new language features like borrowing/ownership/moving. That and static analysis create a safe by default paradigm. It won't let you pass mutable objects to more then a single context/scope that could modify said objects. Trying to do so results in errors in the IDE, like you would see syntax errors in other languages.

    So you can't move a mutable object to a thread and then access it in another thread, the compiler just won't compile code that would do that.

    It's a fundamentally different approach then starting with an unsafe environment and then trying to design in safety on top of that for specific surface areas.
     
    Last edited: Feb 18, 2022
    IgreygooI and mischa2k like this.
  27. mischa2k

    mischa2k

    Joined:
    Sep 4, 2015
    Posts:
    4,347
    What snacktime said :)
    Rust is also way easier to use than DOTS.
    Not easier than C#, but for sure way easier than DOTS.

    I ported some of my netcode to Rust, and it's way less painful than in DOTS.
     
  28. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    So, the general idea how the interaction between Rust code and C#/HPC# would be that Rust code acts as another engine core parallel to the one in Unity.

    I can make sure that not data race happening in Rust code easily, and I just have to make sure that the C#/HPC# code using the object exported from Rust engine core does not have data race.

    So how do you make sure that never happens? Currently, most of the object exported to C# in our codebase is a handle which is either lock guarded or that do not give a mutable/exclusive reference when it pass into Rust.

    And again, our Rust engine core does not neither talk to the Unity core engine(with a few exceptions) nor talk to the mono runtime. Thus, most of the control flow is still written in C#.
     
    Last edited: Feb 18, 2022
    hippocoder likes this.
  29. IgreygooI

    IgreygooI

    Joined:
    Mar 13, 2021
    Posts:
    48
    I am wondering which part of the netcode that you have ported over, there is not any good networking crate in the Rust eco system, and we are currently exploring the option of porting steamworks api which contains a network transport layer, which has both Rust and C# bindings
     
  30. snacktime

    snacktime

    Joined:
    Apr 15, 2013
    Posts:
    3,356
    I open sourced the one I wrote for our game. There is also a full .Net integration layer I haven't gotten around to adding to the repo.

    https://github.com/gamemachine/tachyon-networking
     
    mischa2k and mgear like this.
  31. mischa2k

    mischa2k

    Joined:
    Sep 4, 2015
    Posts:
    4,347
    NetworkReader, NetworkWriter and delta compression.
    I noticed that there's already a kcp transport, so next step would be to fork that one apply our fixes from Mirror's kcp transport. Too busy with other stuff for now though.
     
    bb8_1 likes this.