Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. Dismiss Notice

Question Burst + Generic delegate + Generic interface + FunctionPointer Array + Lambda + Headache

Discussion in 'Burst' started by MonolithBR, Jun 14, 2022.

  1. MonolithBR

    MonolithBR

    Joined:
    Apr 11, 2022
    Posts:
    8
    I am working on a Wave Function Collapse Burst Job with the following selling points:
    • Values can be any unmanaged IEquatable
    • Wave can be any struct to that implements IWave (so, it works for 1D, 2D, 3D, and for 4D+ just need to make a new Wave struct)
    • Conditions as generic as can be: Get Wave and position, return changes to be made in that position (eliminate impossible states, change probabilities of states, rescale probabilities so they add to 1). Any number of Conditions.
    • And the part that is giving me headaches: Some Condition Templates to which you may pass certain parameters and it returns to you a delegate that can be used in BurstCompiler.CompileFunctionPointer, so you only need to pass your delegates to a MakeCollapseConditions and it gives you a NativeArray of FunctionPointers
    It goes as follows:

    Code (CSharp):
    1. [BurstDiscard()]
    2. public static CollapseConditionFunc<T, TWave> Count<T, TWave>(Predicate<T> predicate, int2 sizeAndStateCount, uint times = 1)
    3.     where T : unmanaged, IEquatable<T>
    4.     where TWave : IWave<T>
    5. {
    6.     return (TWave a, int index) => Count<T, TWave, IPredicate<T>>(a, index, new BurstPredicate<T>(predicate), sizeAndStateCount, times);
    7. }
    8. [BurstCompile()]
    9. private static NativeArray<State<T>> Count<T, TWave, TPredicate>(TWave a, int index, TPredicate predicate, int2 sizeAndStateCount, uint times)
    10.     where T : unmanaged, IEquatable<T>
    11.     where TWave : IWave<T>
    12.     where TPredicate : IPredicate<T>
    13. {
    14.     // Assume this function body works with Burst
    15. }
    That is all inside a Burst static class. As you can see, there is generic delegate, generic interface, and lambda. All stuff that Burst isn't exactly fond of. Lambda specially.

    That is one of the templates, some other templates only work with 2D or 3D Waves specifically (RookConnect, to check if states of neighbooring positions satisfy a Connect() Func, will either only make sense in 2D or 3D, as I cannot implement Neighborhood generically for a N-Dimensional Wave).

    Now, I understand that if someone passes a Predicate<T> screws with the code safety, it won't work. Consider the predicate to be of a function that works with Burst in the first place. (No ref types, has [BurstCompile], etc.)

    So, the questions:
    1- Is this even possible in the first place? Passing these made-on-the-fly functions to Burst, I mean.
    2- Is a lambda of a BurstCompile also Burstable? If not, any other way to do this?
    3- What do I actually need to do to make sure what I passing into the Predicate<T> will work with Burst?
    4- The bottleneck of the Collapse is on the conditon-checks, I'm pretty sure. Should the whole Collapse be a Job, or should only the conditions be parallel jobs?
    5- Anything else I may have grossly overlooked? I cannot even compile the code right now because there are still too many things not done.
     
  2. MarcoPersson

    MarcoPersson

    Unity Technologies

    Joined:
    Jul 21, 2021
    Posts:
    43
    Hello MonolithBR,

    Lambdas are not supported in Burst, because they're compiled into managed classes under the hood by the C# compiler. The following code:
    Code (CSharp):
    1. public static void M()
    2. {
    3.     Func<int, int> f = (int x) => x + 1;
    4. }
    Gets compiled to the equivalent of:

    Code (CSharp):
    1.  
    2. [Serializable]
    3. [CompilerGenerated]
    4. private sealed class <>c
    5. {
    6.     public static readonly <>c <>9 = new <>c();
    7.  
    8.     public static Func<int, int> <>9__0_0;
    9.  
    10.     internal int <M>b__0_0(int x)
    11.     {
    12.         return x + 1;
    13.     }
    14. }
    15.  
    16. public static void M()
    17. {
    18.     Func<int, int> func = <>c.<>9__0_0 ?? (<>c.<>9__0_0 = new Func<int, int>(<>c.<>9.<M>b__0_0));
    19. }
    Where you both have an instance of a managed class <>c and the use of delegate object Func which is also managed.


    Another problem is that your lambda here is effectively not a normal function, but a closure. I.e. it captures
    (and holds on to) values from its outer scope (predicate, sizeAndStateCount and times in this case). It has to store those values somewhere, and in C# they're fields of the generated lambda object, which again is a managed object. In Burst, you can use function pointers, but as they are they can only point to functions, and not carry any other data around with them.

    If you want to play around with how lambda's and other C# language constructs are lowered by the C# compiler (to see what Burst sees) then I can recommend checking out https://sharplab.io/.
     
    Last edited: Jun 14, 2022
    MonolithBR likes this.
  3. MonolithBR

    MonolithBR

    Joined:
    Apr 11, 2022
    Posts:
    8
    Seeing how the Lambda function captures the variables in sharplab, it'd be better to just make a struct to hold all the function pointers and captured variables.

    I took a deeper look into the FunctionPointer documentation and it seems it doesn't support passing structs by value, it needs pointers. I also realized making the entire Collapse into a Job is not the way to go, as the clear chokepoint (running every condition for every position in the wave) can be made into a Parallel Batch Job instead, and pass it the NativeArray of FunctionPointers (does this even work? can I actually put FunctionPointers into NativeArrays?).

    And now there is a new problem: NativeHashMap<int2, State<T>> as pointer how?

    State by the way is a struct with 2 fields ( T value, double probability ) and a bunch of methods and properties (checked sharplab, none of the properties are lowered with underlying fields) , and T is unmanaged, IEquatable<T>.


    On a different approach, if each condition is going to be an IJobParallelForBatch anyway, is it possible to pass jobs with some variables already assigned as arguments to a function? That would spare some serious boilerplate, performance issues and unsafeties.

    Another thing is I probably need to separate global and local conditions (since global conditions require reading the entire wave, and local conditions require reading only part of the wave (usually a fixed number of elements) for every element, I would like to not read the whole wave for every element. The complexity was already at O(n) when it was just using async, increasing the complexity would undermine the whole purpose of using a Job)
     
  4. Deleted User

    Deleted User

    Guest

    Functions are just the simplest case of an interface. Why not make an interface for your functions, then implement them with structs? You can also make them a tagged union, if the size of the structs do not vary much between the smallest possible function and the largest one.

    See this as an example: https://github.com/CareBoo/Burst.Delegates

    Also maybe use C# 9 function pointers?
     
  5. MonolithBR

    MonolithBR

    Joined:
    Apr 11, 2022
    Posts:
    8
    So, a condition can have a function defined by whoever decides to use the package, so the maximum diference in size of the smallest possible condition struct and the largest possible condition struct is, well, infinite. Passing a huge function with a ton of parameters shouldn't prevent the collapse from working, just slow it down a lot. Or so it would be, but I just realized I can precalculate the conditions. So instead of passing a whole function, i just need to pass a NativeHashMap that maps the arguments to bool, which makes it run way faster.

    Current approach is making the conditions structs that implement IParallelJobFor and ICondition (ICondition is just 1 method and a property: void Setup(TWave input) and bool Impossible { get; }
    And as i have previously stated: I cannot compile the code to test due to the humoungous amount of compiler errors that arise form the implementation being incomplete.

    Problem I then ran into: the Conditions are different structs, using an interface or a generic struct to make an array of them results in not being able to .Schedule()

    The (probably unsafe) solution: Have ICondition implement DoSchedule() and DoComplete() to Schedule and Complete the Job from a method inside the Job struct that isn't the Execute() method. I don't know if this works. Probably doesn't.

    And this also leads into the problem of can i Schedule the same instance of a job again after it has completed or do I have to create a new Instance?
     
  6. jasonboukheir

    jasonboukheir

    Joined:
    May 3, 2017
    Posts:
    80
    A Job is just a data struct, you should just be able to schedule another one. What exactly is your problem here? Having trouble exactly what you're trying to accomplish.
     
  7. Zuntatos

    Zuntatos

    Joined:
    Nov 18, 2012
    Posts:
    612
    I've got some absolutely disgusting code for what seems like a related use case; I'm passing managed callbacks to filter some data inside of a burst job.

    - a Filter wrapper class that holds an interface instance that does the actual work, it's kept alive for at least the time the job runs to prevent GC of the wrapper instance
    - an internal method on the wrapper class calls the actual work method on said filter instance
    - the wrapper class has an internal instance method which is cast to a delegate, a ref to the delegate is stored in the wrapper as well to prevent GC of the delegate instance
    - the delegate instance is cast to a functionpointer with Marshal.GetFunctionPointerForDelegate(..), which is then wrapped in a Unity.Burst.FunctionPointer<T>
    - this burst functionpointer is passed to the job and used as a delegate

    this all seems to work fine; the downside is that (besides the ugly way it's done) is that the managed callback runs, well, managed. So you're getting the performance associated with that. But you can also use reference types and such in there, use globals, etc.

    It doesn't allocate any GC beyond the initial wrapper allocation which can be reused, and you can do basically anything you want in the managed callback.
     
  8. MonolithBR

    MonolithBR

    Joined:
    Apr 11, 2022
    Posts:
    8
    I see. A question: I have a struct which implements IParallelJob, and a method called DoSchedule(). Can I use DoSchedule to schedule the Job the struct represents, or do I have to call Schedule() directly?

    Also, I am quite shaky with how Native Container works, so another question (cause google did not give me answers, and I'm still in the cannot-compile situation.) Does the operator = with NativeArray just copy the pointer or does it actually behave as a value-type and copy every value?

    Thanks for all the help, btw.
     
  9. MonolithBR

    MonolithBR

    Joined:
    Apr 11, 2022
    Posts:
    8
    My trashy workaround the generic-delegates-in-burst problem was to, well, cache every possible argument combination and respective output in the constructor.
    I literally created a NativeHashMap of ValueTuple to bool for every argument that would be passed into the delegate. "Since I can expect every combinantion passed multiple times throughout the job, might as well cache all of them right off the bat." was my reasoning.
    My frames are happy, because it means the complexity of the conditions with respect to the passed delegate is O(1), in that the delegate doesn't actually matter after the cache is made.
    My RAM is not. If I have N states, Connect's NativeHashMap will take N^2 bytes of memory in the values alone. Then another N^2*constant for the keys. Plus NHM's overhead.
     
  10. MonolithBR

    MonolithBR

    Joined:
    Apr 11, 2022
    Posts:
    8
    And now I can finally compile. And now my dumb decision of having made things outside the Jobs with native arrays has come back to bite me as native arrays, much like C arrays, will promptly deallocate upon leaving their scope. So returning a NativeArray (or any NativeContainer for that matter) from a normal function is the same as returning an unitialized pointer. So my idea of using a function to generate a cache of the function as a NativeHashMap to then pass said NativeHashMap into the Job needs some tweaking.

    Also: a little problem I ran into: Why the C is burst complaining about a == operator?
    Here's the code:

    Code (CSharp):
    1. public void Execute(int index)
    2. {
    3.     // Determine borders (1 - not border, 0 - border)
    4.     key.x = index % columnCount;
    5.     key.y = index / columnCount;
    6.     left = key.x != 0;
    7.     right = key.x != columnCount - 1;
    8.     down = key.y != 0;
    9.     up = key.y != columnCount - 1;
    10.     near.FromBools(right, left, up, down);
    11.     // If state, for any neighboor, cannot connect to neighboor's superposition, set state Impossible.
    12.     _change = new NativeArray<State<T>>(stateCount, Allocator.Temp);
    13.     for (stateIndex = 0; stateIndex < stateCount; stateIndex++)
    14.     {
    15.         state = wave[key, stateIndex];
    16.         _change[stateIndex] = new State<T>(state.value, -1);
    17.         if (state.Impossible) continue;
    18.         for (d = 1; d <= 128; d <<= 1)
    19.         {
    20.             if ((near & (Direction)d) != 0)
    21.             {
    22.                 offset = key + ((Direction)d).AsInt2();
    23.                 for (otherStateIndex = 0; otherStateIndex < stateCount; otherStateIndex++)
    24.                 {
    25.                     otherState = wave[offset, otherStateIndex];
    26.                     if (otherState.Impossible) continue;
    27.                     if ((conditionant[(state.value, otherState.value)] & d) == 0) // <--- Burst TraceStack points here.
    28.                     {
    29.                         _change[stateIndex] *= 0;
    30.                         break;
    31.                     }
    32.                 }
    33.             }
    34.             else
    35.             {
    36.                 if ((conditionant[(state.value, null)] & d) == 0)
    37.                     _change[stateIndex] *= 0;
    38.             }
    39.             if (_change[stateIndex].probability == 0)
    40.                 break;
    41.         }
    42.     }
    43.  
    44.     for (int i = 0; i < _change.Length; i++)
    45.         if (_change[i].probability == 0)
    46.         {
    47.             changeWriter.Add(index, _change[i]);
    48.         }
    49. }
    And yes, this is still that same Wave Function Collapse project.
    conditionant is a
    NativeHashMap<(int, int?), byte>
     
  11. DreamingImLatios

    DreamingImLatios

    Joined:
    Jun 3, 2017
    Posts:
    3,983
    Not true. There is likely something else at play here.

    What is the error?