😮 Epicor Function Invocation Performance Tests

Fair warning, I came up with the technique, but I had AI help me test it.

Epicor Function Invocation Performance

I recently ran a microbenchmark comparing different ways of invoking the same helper method inside Epicor.

The helper itself, NormalizePath, performs a small amount of string manipulation, making it a good candidate for measuring invocation overhead rather than implementation cost.


Technique Tested

The goal was to avoid repeatedly crossing the Epicor Function boundary for small, frequently-used helper methods.

Instead of exposing each helper as an individual Epicor Function, a framework function constructs a reusable object graph containing delegates, constants, settings, and other shared resources. These objects are stored in dictionaries and returned inside a DataSet, allowing them to be transferred through the normal Epicor Function infrastructure.

A simplified example:

var rootPath = "/";

Func<string, string> NormalizePath = path =>
{
    if (String.IsNullOrWhiteSpace(path)) return rootPath;

    path = path.Replace("\\", "/").Trim();

    while (path.Contains("//")) path = path.Replace("//", "/");

    if (!path.StartsWith("/")) path = "/" + path;

    path = path.TrimEnd('/');

    return String.IsNullOrWhiteSpace(path) ? rootPath : path;
};

var api = new Dictionary<string, object>();

api["NormalizePath"] = NormalizePath;

framework.Rows.Add("API", api);

A consumer retrieves the framework once:

var fw = ThisLib.GetFramework();

extracts the API dictionary:

var API = (Dictionary<string, object>)GetFrameworkObject("API");

retrieves the helper delegate:

var NormalizePath = (Func<string, string>)API["NormalizePath"];

and then invokes it like any normal C# delegate:

var result = NormalizePath(path);

The important distinction is that the Epicor Function boundary is crossed once to retrieve the framework. Every subsequent helper invocation executes entirely in-process through a cached delegate rather than invoking another Epicor Function.


Results

Invocation Method Time / Call Relative
Framework Delegate (API["NormalizePath"]) 0.213 µs
Other Library Direct 5494.674 µs ~25,830× slower
Other Library Dynamic 5501.441 µs ~25,860× slower
Framework → Epicor Function Delegate 5539.087 µs ~26,040× slower
This Library Dynamic 5584.613 µs ~26,260× slower
This Library Epicor Function Delegate 5637.817 µs ~26,510× slower
Other Library Epicor Function Delegate 5639.974 µs ~26,520× slower
This Library Direct 5760.700 µs ~27,080× slower

Observations

Framework delegates are dramatically faster

Calling a cached framework delegate is essentially a normal in-process delegate invocation.

Measured performance:

~0.21 µs per invocation

This was approximately 26,000× faster than invoking the same helper through an Epicor Function.


Epicor Function invocation has a nearly fixed cost

Every Epicor Function invocation method—regardless of whether it was called directly, through a delegate, across libraries, or using dynamic—clustered around:

  • 5.5 ms per call

This indicates that almost all execution time is spent crossing the Epicor Function boundary rather than executing the helper itself.

For small utility methods, the invocation overhead completely dominates the runtime.


Delegate overhead is negligible

Caching and invoking a delegate:

var helper = (Func<string, string>)...

introduced no measurable overhead.

The differences between direct calls and delegate calls were only a few percent, well within normal benchmark variation.


Dynamic dispatch was not the bottleneck

One unexpected result was that dynamic invocation performed almost identically to every other Epicor Function invocation.

For example:

Method Time
Direct 5494 µs
Dynamic 5501 µs

The difference was approximately 0.13%.

Once the Epicor Function boundary is crossed, the C# dispatch mechanism contributes very little to the total execution time.


Practical Takeaway

For lightweight helper methods that may execute thousands of times:

  • Path utilities
  • String helpers
  • Formatting
  • Dataset builders
  • Small reusable algorithms

a shared delegate API provides centralized code reuse while avoiding repeated Epicor Function invocation overhead.

Epicor Functions remain an excellent choice for coarse-grained operations where several milliseconds of invocation overhead are insignificant compared to the work being performed.


Summary

The speedup came from changing how code was reused.

Instead of reusing helper methods by repeatedly invoking another Epicor Function, a single framework function returns a reusable object graph containing delegates to those helpers.

The caller retrieves the framework once, caches the delegates it needs, and then executes those helpers entirely in-process.

The result is a centralized, reusable API with the convenience of shared code and the performance of normal delegate invocation.

Nice work @klincecum .