I explained in previous post how you can build simple functions and convert them into IL code.
Now what is interesting is you can easily optimize those by using simple assumptions, which are things that you would easily forget while writing code.
You can consider 2 things:
Now what is interesting is you can easily optimize those by using simple assumptions, which are things that you would easily forget while writing code.
You can consider 2 things:
- Immediate optimizations : For elements that you know at compile time
- Code Path : You need to define them at runtime depending on inputs size.
Let's see a few Immediate optimizations:
Code Snippet
- [SlicewiseMethod(Name = "SimpleLerp", Category = "Value")]
- public static void SimpleLerp(
- [Input("Input 1")] double d1,
- [Input("Input 2")] double d2,
- [Input("Amount")] double amt,
- [Output("Output")] out double result)
- {
- result = VMath.Lerp(d1, d2, amt);
- }
Here we just process a simple lerp.
Since in slicewise operations, our output size is always SpreadMax, Generator will automaticaly select a pointer output,and miss the mod operator.
This is a no brainer, easy as that, outputs never use mod and do direct storage.
Second test:
Here we have one input and one output. The compiler can detect that, and automatically set the input read to safely ignore the mod operator too.
Now let's look at our lerp above, and let's say that we want only one lerp value for all elements:
Here we did set the IsSingle Attribute to our variable. Compiler will detect that, read the variable into a local once before the for loop.
Now let's say we want to multiply a spread by a constant value:
Here as before, our Amount variable will be stored into a local.
But now we also know that our Input Variable is alone (same as the Sine case).
So compiler will remove the mod operator on input value as well!
Now let's see a little bit more complex case:
Here we basically build a node that Has one input, one output , and and enum to select function.
Equivalent code:
Since in slicewise operations, our output size is always SpreadMax, Generator will automaticaly select a pointer output,and miss the mod operator.
This is a no brainer, easy as that, outputs never use mod and do direct storage.
Second test:
Code Snippet
- [SlicewiseMethod(Name = "Sin", Category = "Value")]
- public static void Sin(
- [Input("Input")] double d,
- [Output("Output")] out double dout)
- {
- dout = Math.Sin(d);
- }
Here we have one input and one output. The compiler can detect that, and automatically set the input read to safely ignore the mod operator too.
Now let's look at our lerp above, and let's say that we want only one lerp value for all elements:
Code Snippet
- [SlicewiseMethod(Name = "SimpleLerp2", Category = "Value")]
- public static void SimpleLerp(
- [Input("Input 1")] double d1,
- [Input("Input 2")] double d2,
- [Input("Amount", IsSingle=true)] double amt,
- [Output("Output")] out double result)
- {
- result = VMath.Lerp(d1, d2, amt);
- }
Here we did set the IsSingle Attribute to our variable. Compiler will detect that, read the variable into a local once before the for loop.
Now let's say we want to multiply a spread by a constant value:
Code Snippet
- [SlicewiseMethod(Name = "MultiplyFixed", Category = "Value")]
- public static void SimpleLerp(
- [Input("Input")] double d,
- [Input("Amount", IsSingle = true)] double amt,
- [Output("Output")] out double result)
- {
- result = d * amt;
- }
Here as before, our Amount variable will be stored into a local.
But now we also know that our Input Variable is alone (same as the Sine case).
So compiler will remove the mod operator on input value as well!
Now let's see a little bit more complex case:
Code Snippet
- [Selector(Name = "Function", Category = "Value Inverse")]
- public static Dictionary<string, Func<double, double>> TrigonometryInv
- {
- get
- {
- var result = new Dictionary<string, Func<double, double>>();
- result.Add("Asin", (x) => Math.Asin(x));
- result.Add("Acos", (x) => Math.Acos(x));
- result.Add("Atan", (x) => Math.Atan(x));
- return result;
- }
- }
Here we basically build a node that Has one input, one output , and and enum to select function.
Equivalent code:
Code Snippet
- [PluginInfo(Name = "Function", Category = "Value", Version = "Simple")]
- public unsafe class DummyFunc : IPluginEvaluate
- {
- public enum eFunc { Sin,Cos, Tan}
- public delegate double FuncDelegate (double input);
- [Input("Input")]
- private ValueInput input;
- [Input("Function", IsSingle = true)]
- private ISpread<eFunc> function;
- [Output("Output")]
- private ValueOutput output;
- private Dictionary<eFunc, FuncDelegate> functable = new Dictionary<eFunc, FuncDelegate>();
- public DummyFunc()
- {
- functable.Add(eFunc.Sin, (x) => Math.Sin(x));
- functable.Add(eFunc.Cos, (x) => Math.Cos(x));
- functable.Add(eFunc.Tan, (x) => Math.Tan(x));
- }
- public void Evaluate(int SpreadMax)
- {
- FuncDelegate f = functable[function[0]];
- output.Length = SpreadMax;
- double* iptr = input.Data;
- double* optr = output.Data;
- for (int i = 0; i < SpreadMax; i++)
- {
- optr[i] = f(iptr[i]);
- }
- }
- }
Please note that we could create a loop delegate, like this:
This doesn't give a very good gain tho, so first option is easier to build.
Now let's look at code path.
We can eventually decide of our loop at runtime, let's take our Vector2 join again:
We have 5 cases we want to consider:
Now in our evaluate method:
Now here are a few benchmarks (considering different cases):
X = 50000, Y = 20
Native: 0.35 ms
Zip : 0.28ms
Mutable : 0.2 ms
This is one of the 2 worst cases, (when X != Y), but we saved a mod on the biggest spread, which technically reflects.
X = 25000, Y = 25000
In that case the node will use : FullSpeed
Native: 0.17ms
Zip : 0.12ms
Mutable : 0.06 ms
In that case we simply ignore all the mod operators, and Mutable clearly wins.
X = 50000, Y = 1
Native : 0.36 ms
Zip : 0.25ms
Mutable : 0.16ms
We can also see that pushing a local and setting other at full speed gives a decent gain. So setting path at runtime is significant.
So technically, that gives us this information:
let function (p1,p2,p3,p4) = dosomething
Now what we have, to generalize our vector, is the following (for each parameter)
if length == 1 -> set local before loop and use that local
if length == SpreadMax -> read without mod
if length < SpreadMax -> read modded
Of course we can clearly see that we have a combinatorial explosion, a function with 8 parameters has a LOT of different cases.
Few options here:
generate the worst case scenario (eg: mod everything).
When you enter Evaluate:
build a mini hash (length == 1) -> 0, (length == SpreadMax) -> 1, (length < SpreadMax) -> 2
Ask the runtime if we already have the function, if not compile it and give it back.
Run the function.
Yes, clearly as you've seen, we have nodes that self compile ;)
So this is of course to use with caution, if your node input length vary a lot, you might have a lot of recompile.
If you have reasonably steady spread counts, you'll have a little overhead on startup. Please note that something like:
let function (a,b) => a+b
a = 2000, b = 1
next frame , a = 1000, b= 1
will not cause a recompile, since a is is still at SpreadMax.
On Some specific notes, node compiler is about 0.5 millisecond, which is pretty fast, but if you have 1000 nodes rebuilding on the fly, that's not too good ;)
What is good is that our node is now a basic container, so we save some il code. Second very cool thing, our logic becomes a delegate, which is much easier to wrap into a Task, but more on that later.
Code Snippet
- [PluginInfo(Name = "Function", Category = "Value", Version = "Loopable")]
- public unsafe class DummyFunc2 : IPluginEvaluate
- {
- public enum eFunc { Sin, Cos, Tan }
- public delegate void FuncLoopDelegate(int cnt, double* input, double* output);
- [Input("Input")]
- private ValueInput input;
- [Input("Function", IsSingle = true)]
- private ISpread<eFunc> function;
- [Output("Output")]
- private ValueOutput output;
- private Dictionary<eFunc, FuncLoopDelegate> functable = new Dictionary<eFunc, FuncLoopDelegate>();
- public DummyFunc2()
- {
- functable.Add(eFunc.Sin, (cnt, input, output) => { for (int i = 0; i < cnt; i++) { output[i] = Math.Sin(input[i]); } });
- functable.Add(eFunc.Cos, (cnt, input, output) => { for (int i = 0; i < cnt; i++) { output[i] = Math.Cos(input[i]); } });
- functable.Add(eFunc.Tan, (cnt, input, output) => { for (int i = 0; i < cnt; i++) { output[i] = Math.Tan(input[i]); } });
- }
- public void Evaluate(int SpreadMax)
- {
- FuncLoopDelegate f = functable[function[0]];
- output.Length = SpreadMax;
- double* iptr = input.Data;
- double* optr = output.Data;
- f(SpreadMax,iptr,optr);
- }
- }
This doesn't give a very good gain tho, so first option is easier to build.
Now let's look at code path.
We can eventually decide of our loop at runtime, let's take our Vector2 join again:
We have 5 cases we want to consider:
- Length (x) == Length(y) : We can ignore mod on each vector.
- Length (x) == 1 : We store x before the loop and sample y at full speed
- Length (y) == 1 : Same as above, just the other way round.
- Length (x) > Length(y) : Sample x at full speed, mod on y
- Length (y) < Length(y) : Opposite
Expressed as code:
Code Snippet
- public delegate void VectorLoop(int cnt, double* d1, int i1, double* d2,int i2, Vector2D* output);
- private Dictionary<eFuncSelector, VectorLoop> functable = new Dictionary<eFuncSelector, VectorLoop>();
- public DummyVectorDynamic()
- {
- functable.Add(eFuncSelector.XMax, (cnt, x, i1,y, i2, o) => { for (int i = 0; i < cnt; i++) { o[i].x = x[i]; o[i].y = y[i % i2]; } });
- functable.Add(eFuncSelector.YMax, (cnt, x, i1, y, i2, o) => { for (int i = 0; i < cnt; i++) { o[i].x = x[i]; o[i%i1].y = y[i]; } });
- functable.Add(eFuncSelector.FullSpeed, (cnt, x, i1, y, i2, o) => { for (int i = 0; i < cnt; i++) { o[i].x = x[i]; o[i].y = y[i]; } });
- functable.Add(eFuncSelector.SingleX, (cnt, x, i1, y, i2, o) => { double xval = x[0]; for (int i = 0; i < cnt; i++) { o[i].x = xval; o[i].y = y[i]; } });
- functable.Add(eFuncSelector.SingleY, (cnt, x, i1, y, i2, o) => { double yval = y[0]; for (int i = 0; i < cnt; i++) { o[i].x = x[i]; o[i].y = yval; } });
- }
Now in our evaluate method:
Code Snippet
- public void Evaluate(int SpreadMax)
- {
- dout.Length = SpreadMax * 2;
- Vector2D* d = (Vector2D*)dout.Data;
- double* pt1 = d1.Data;
- double* pt2 = d2.Data;
- int l1 = d1.Length;
- int l2 = d2.Length;
- if (l1 == l2) {
- functable[eFuncSelector.FullSpeed](SpreadMax, pt1, l1, pt2, l2,d);
- } else if (l1 == 1) {
- functable[eFuncSelector.SingleX](SpreadMax, pt1, l1, pt2, l2, d);
- } else if (l2 == 1) {
- functable[eFuncSelector.SingleY](SpreadMax, pt1, l1, pt2, l2, d);
- } else if (l1 > l2) {
- functable[eFuncSelector.XMax](SpreadMax, pt1, l1, pt2, l2, d);
- } else {
- functable[eFuncSelector.YMax](SpreadMax, pt1, l1, pt2, l2, d);
- }
- }
Now here are a few benchmarks (considering different cases):
X = 50000, Y = 20
Native: 0.35 ms
Zip : 0.28ms
Mutable : 0.2 ms
This is one of the 2 worst cases, (when X != Y), but we saved a mod on the biggest spread, which technically reflects.
X = 25000, Y = 25000
In that case the node will use : FullSpeed
Native: 0.17ms
Zip : 0.12ms
Mutable : 0.06 ms
In that case we simply ignore all the mod operators, and Mutable clearly wins.
X = 50000, Y = 1
Native : 0.36 ms
Zip : 0.25ms
Mutable : 0.16ms
We can also see that pushing a local and setting other at full speed gives a decent gain. So setting path at runtime is significant.
So technically, that gives us this information:
let function (p1,p2,p3,p4) = dosomething
Now what we have, to generalize our vector, is the following (for each parameter)
if length == 1 -> set local before loop and use that local
if length == SpreadMax -> read without mod
if length < SpreadMax -> read modded
Of course we can clearly see that we have a combinatorial explosion, a function with 8 parameters has a LOT of different cases.
Few options here:
generate the worst case scenario (eg: mod everything).
When you enter Evaluate:
build a mini hash (length == 1) -> 0, (length == SpreadMax) -> 1, (length < SpreadMax) -> 2
Ask the runtime if we already have the function, if not compile it and give it back.
Run the function.
Yes, clearly as you've seen, we have nodes that self compile ;)
So this is of course to use with caution, if your node input length vary a lot, you might have a lot of recompile.
If you have reasonably steady spread counts, you'll have a little overhead on startup. Please note that something like:
let function (a,b) => a+b
a = 2000, b = 1
next frame , a = 1000, b= 1
will not cause a recompile, since a is is still at SpreadMax.
On Some specific notes, node compiler is about 0.5 millisecond, which is pretty fast, but if you have 1000 nodes rebuilding on the fly, that's not too good ;)
What is good is that our node is now a basic container, so we save some il code. Second very cool thing, our logic becomes a delegate, which is much easier to wrap into a Task, but more on that later.