While working on a cusom intepreter of our DSL written purely in c# I am trying to understand how to correctly take advantage of the built in call site caching feature. Here is my little demonstration program that works only until the types change.

using System.Collections.ObjectModel;
using System.Linq.Expressions;
using System.Runtime.CompilerServices;

public class Program2
{
    public static void Main()
    {
        // Create a CallSite
        var callSite = CallSite<Func<CallSite, object, object, object>>.Create(new AdditionBinder());

        // Perform dynamic addition
        var add = callSite.Target;
        var result1 = add(callSite, 5, 10);             // Bind for int,int gets called for the first time and compiled fn(int,int) is created
        var result2 = add(callSite, 10, 10);            // Bind not called, the compiled fn(int,int) is reused. Nice!
        var result3 = add(callSite, "strA", "str1");    // before Bind was even called I got exception "Unable to cast object of type 'System.String' to type 'System.Int32" :-(
        var result4 = add(callSite, "strB", "str2");
    }
}

Here is how the custom binder is implemented. The logic how to switch over the types is simplistic and will be better after I make sure that this code gets called correctly just once per each combination of types. There is nothing else in the whole csproj.


public class AdditionBinder : CallSiteBinder
{
    public override Expression Bind(object[] args, ReadOnlyCollection<ParameterExpression> parameters, LabelTarget returnLabel)
    {
        // Get the two arguments that we're adding
        var left = parameters[0];
        var right = parameters[1];

        // Here comes later logic how to prepare for tailormade type specific compiled variants
        Expression addition = null;
        if (args[0] is int && args[1] is int)
        {
            // Perform int addition
            addition = Expression.Convert( Expression.Add(
                    Expression.Convert(left, typeof(int)),
                    Expression.Convert(right, typeof(int))
                ), typeof(object)
            );
        }
        else if (args[0] is string && args[1] is string)
        {
            // Perform string addition
            addition = Expression.Convert(
                Expression.Add(
                    Expression.Convert(left, typeof(string)),
                    Expression.Convert(right, typeof(string)),
                    typeof(string).GetMethod("Concat", new[] { typeof(string), typeof(string) })
                ), typeof(object)
            );
        }
        else
            throw new InvalidOperationException("No add for T + T");

        // Return the result of the addition
        return Expression.Return(returnLabel, addition);
    }

}

I have debugged into the CallSiteBinder.cs but I was not clear I undersand it and that I have corretly downloaded all necessary parts.

Do I use the Binder correctly? Do I use the target or call site correctly? I feel like I am really close. I want to post this here, because I have never found a complete example online. I hope we figure it out and this page helps others. Thanks for your kind advice.

Update

I tried two distinct instaces of CallSite that share binder and got this:

        var sharedBinder = new AdditionBinder();
        var callSite = CallSite<Func<CallSite, object, object, object>>.Create(sharedBinder);
        var callSite2 = CallSite<Func<CallSite, object, object, object>>.Create(sharedBinder);

        _ = callSite2.Target(callSite2, "str", "str");    // works
        _ = callSite.Target(callSite, 5, 10);             // throws as if the callsite instances shared the cached Target

Example of the interpreted DSL

# I assume one instance of CallSiteBinder has cache with all specialized 
# specific typed compiled delegates, for myAdd definition
myAdd = x,y => x+y  
 
# adds 42 to all integers
# I assume one instance of 'CallSiteCache' for myAdd call here, that typically ends up with L1 cached myAdd(int,int)
# but does not thow in possible string and uses the shared CallSiteBinder with some L2 cache of delegates
exampleAlgoInts = intArr => intArr.Select(i => myAdd(i,42)) 
 
# adds 'Hi ' to all names
# I assume another instance of 'CallSiteCache' for myAdd call here, that typically ends up with L1 cached myAdd(string,string)
# but does not thow in possible double, int and uses the shared CallSiteBinder with some L2 cache of delegates
exampleAlgoStrings = strArr => strArr.Select(name => myAdd("Hi ",name)) 

I know that I am using internals that may not be intended to be used by developers outside of the core team. But I still think it is interesting to know how this works and might help performance for many other interpreter-writers.

2

There are 2 answers

1
brett_0267 On

Handling the dynamic expressions inside the overridden Bind method is causing the problem, and here is a solution that will allow you to use the same CallSite for both calls dynamically:

public static void Main()
{
    var binder = new AdditionBinder();
    var dynamicObject = binder.GetDynamicObject();

    // create a CallSite
    var callSite = CallSite<Func<CallSite, object, object, object, object>>.Create(
        Binder.InvokeMember(CSharpBinderFlags.None, "Invoke", null, null,
        new CSharpArgumentInfo[] {
                CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null),
                CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null),
                CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null)
        }));

    // Perform dynamic addition
    var add = callSite.Target;
    var result1 = add(callSite, dynamicObject.AddInts, 5, 10);
    var result2 = add(callSite, dynamicObject.AddInts, 10, 10);
    var result3 = add(callSite, dynamicObject.ConcatStrings, "strA", "str1");
    var result4 = add(callSite, dynamicObject.ConcatStrings, "strB", "str2");
}


public class AdditionBinder
{
    private dynamic dynamicObject;

    public AdditionBinder()
    {
        dynamicObject = new ExpandoObject();
        dynamicObject.AddInts = new Func<int, int, int>((a, b) => a + b);
        dynamicObject.ConcatStrings = new Func<string, string, string>((a, b) => a + b);
    }

    public dynamic GetDynamicObject()
    {
        return dynamicObject;
    }
}

The only difference is that here we are using a dynamic object and defining two different delegates on that object that will handle the functionality for both operations for our shared binder (AddInts and ConcatStrings). We are also declaring and passing aBinder using InvokeMember and not overriding to create our own CallSiteBinder. We can do this, but the Bind method is responsible for generating the expression of the passed operation. We would want to handle the logic for different parameters/return type in separate expression definitions.

The binder has a set of restrictions, that will evaluate to true or false, and this determines if the cached result can be reused. If there are different parameter types, then the cached result cannot be reused, and a new expression will be generated by the CallSiteBinder.

More on restrictions here: https://www.red-gate.com/simple-talk/blogs/inside-the-dlr-invoking-methods/

And another useful post on DLR and how the binder handles restrictions: https://stackoverflow.com/a/4653954/17290059

0
Richard Vondracek On

I finally found the solution after a few weeks. The real solution was in creating a custom AdditionInvokeBinder as a specialization of InvokeBinder class. It does exactly what I need. The FallbackInvoke method is called exactly once for each new encountered combination of parameter types and the compiled expressions remains cached at the call site.

When I create the call site like this

        // Create a CallSite
        var callSite = CallSite<Func<CallSite, object?, object, object, object>>.Create(
            new AdditionInvokeBinder(new CallInfo(2,"a","b"))
        );

        // Perform dynamic addition
        var add = callSite.Target;
        var result1 = add(callSite, null,5, 10);              // FallbackInvoke for int,int gets called for the first time and compiled fn(int,int) is created
        var result2 = add(callSite, null, 10, 10);            // FallbackInvoke not called, the compiled fn(int,int) is reused. Nice!
        var result3 = add(callSite, null, "strA", "str1");    // FallbackInvoke for string, string is called again
        var result4 = add(callSite, null, "strB", "str2");    // and not called, because it is cached!! BINGO !! Happy me!!

And the AdditionInvokeBinder is defined like this. I can even define my custom BindingRestrictions that say the rules that determine whether the next call takes the cached function or a new call to FallbackInvoke is needed. Nice!

public class AdditionInvokeBinder : InvokeBinder
{
    public AdditionInvokeBinder(CallInfo callInfo) : base(callInfo)
    {
    }
    public override DynamicMetaObject FallbackInvoke(DynamicMetaObject target, DynamicMetaObject[] args, DynamicMetaObject? errorSuggestion)
    {
        var left = args[0].Expression;
        var right = args[1].Expression;

        if (args[0].Value is int && args[1].Value is int)
        {
            // Perform int addition
            var addition = Expression.Convert(Expression.Add(
                    Expression.Convert(left, typeof(int)),
                    Expression.Convert(right, typeof(int))
                ), typeof(object)
            );

            var restrictions = 
                BindingRestrictions.GetTypeRestriction(args[0].Expression, typeof(int)).Merge(
                    BindingRestrictions.GetTypeRestriction(args[1].Expression, typeof(int)));

            return new DynamicMetaObject(addition, restrictions);
        }
        else if (args[0].Value is string && args[1].Value is string)
        {
            // Perform string addition
            var addition = Expression.Convert(
                Expression.Add(
                    Expression.Convert(left, typeof(string)),
                    Expression.Convert(right, typeof(string)),
                    typeof(string).GetMethod("Concat", new[] { typeof(string), typeof(string) })
                ), typeof(object)
            );

            var restrictions =
                BindingRestrictions.GetTypeRestriction(args[0].Expression, typeof(string)).Merge(
                    BindingRestrictions.GetTypeRestriction(args[1].Expression, typeof(string)));

            return new DynamicMetaObject(addition, restrictions);
        }

        throw new NotImplementedException("Custom expression builder not supported for these types");
    }
}

I hope it helps someone.