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 | 1× |
| 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.