Key Takeaways
- Hybrid architectures that combine .NET (C#) and native C++ enable both rapid development and high performance: using C# for general application logic and UI, and C++ for compute-intensive tasks yields optimal results in demanding engineering and scientific systems.
- P/Invoke offers a simple but limited approach for calling native code from C#, and is best suited for straightforward, stable APIs. Its use quickly becomes cumbersome and error-prone when working with complex or evolving native interfaces due to manual synchronization and marshalling requirements.
- C++/CLI provides a robust, type-safe, and maintainable bridge between managed and native code, significantly simplifying parameter conversions and minimizing performance overhead in both directions, enabling large-scale, performant, and reliable interoperation.
- Performance benchmarks demonstrate that C++/CLI wrappers introduce negligible overhead compared to direct native calls, whereas mixed-mode assemblies do not deliver native-level performance and should only be used when convenience outweighs speed requirements.
- Careful separation of managed and native components, leveraging C++/CLI as an integration layer, yields architectures that are easier to develop, maintain, and evolve, while allowing each side to be independently optimized and unit tested. Always profile your actual workloads to validate the benefits in your context.
Introduction
The modern development of high-performance engineering systems, particularly in domains such as scientific computing, financial modelling, or big data processing, faces a fundamental dilemma. On one hand, rapid development, ease of maintenance, intuitive user interfaces, and efficient debugging are essential. On the other hand, performance-critical components demand extreme speed and minimal overhead. Attempting to implement an entire project in a single programming language often leads to significant compromises in one of these areas.
The common misconception that a whole project must be written on a single platform can result in suboptimal architecture. This overlooks the inherent tension between the development productivity offered by managed environments like C#/.NET and the raw computational power of native C++. Building everything, from the UI to high-performance kernels, in one language typically means sacrificing either development efficiency or peak performance. This is not merely a matter of preference but a fundamental architectural trade-off in complex, performance-critical domains.
This article explores how C# and native C++ can work in tandem to build optimal systems that leverage each language’s strengths. It provides practical guidance on integrating the two, project setup, and effective interaction, supported by analysis of real-world code examples and benchmarks.
The thesis is that an optimal balance between development speed, maintainability, and high performance is achieved by using C# for general application logic and user interfaces, and native C++ for performance-critical computational blocks, with C++/CLI as an efficient bridge between them. We will examine why this hybrid approach is beneficial, demonstrate methods for calling native C++ code from C# (and vice versa), discuss specifics and limitations of C++/CLI “mixed mode” assemblies, and present detailed performance benchmarks.
Why Combine C# and C++ in One System?
Choosing a single language for a project has its appeal, but there are compelling reasons to split work between C# and C++.
- Developer Productivity: High-level application logic, UI development, and general business code are typically faster to write and debug in C#. The .NET provides a vast class library and tools that speed up development. Writing complex GUI code is simpler with C#, and the language’s memory safety and garbage collection eliminate certain classes of bugs. As a result, large systems can often be built more quickly and with fewer errors using .NET for the bulk of the code.
- Maintainability: C#’s concise syntax and managed environment lead to cleaner, more maintainable code for many tasks. It’s generally easier to write correct application-level code in C#, and teams benefit from quicker iteration and easier onboarding when a significant portion of the codebase is managed. This means faster development cycles and simplified debugging and testing.
- Performance-Critical Components: Conversely, certain parts of a system require extreme optimization, for example, heavy mathematical computations, algorithms running in tight loops, real-time processing, or low-level hardware interactions. Native C++ can outperform C# by orders of magnitude in such scenarios. C++ is compiled to machine code with advanced optimizations, giving developers fine-grained control over memory and CPU instructions (e.g., using SIMD instructions or custom memory layouts) for significant speedups. Additionally, C++ can directly leverage GPUs (CUDA/OpenCL) and other hardware-specific features. In contrast, C# runs on the CLR with JIT compilation and garbage collection; for many tasks the performance is close to C++, but in the most demanding cases an expert-tuned C++ implementation can be much faster (avoiding GC overhead, manual vectorization, etc.).
- Existing Libraries and APIs: You may need to use native C/C++ libraries (with no .NET equivalent) or reuse legacy C++ code. The C++ ecosystem offers decades worth of highly optimized libraries (e.g., BLAS, LAPACK, Eigen for math; signal processing libraries; graphics and physics engines). These provide performance that can be reused without reimplementing. Likewise, a legacy C++ application might want to call into newer .NET libraries or services. Interoperability lets you combine these without rewriting everything. C++ can also access low-level OS functions or specialized CPU instructions not exposed in .NET.
In short, a hybrid C# + C++ architecture lets you implement most of the code in a productive managed environment, while offloading critical “hot paths” to high-performance native code. For example, one could build the UI, data management, and orchestration in C#, but perform heavy number crunching or real-time signal processing in C++. This yields a system that is both easier to develop and maintain and fast where it counts. The trade-off is the added complexity of bridging between the managed and unmanaged worlds, which is what we address next.
P/Invoke: Simplicity with Significant Caveats
When considering interoperability between .NET (C#) and native C++ code, a common first approach is Platform Invocation Services (P/Invoke). P/Invoke allows managed C# code to call functions implemented in unmanaged DLLs by using the [DllImport]
attribute to declare external method signatures within your C# code.
Using P/Invoke is especially useful if you have very limited access to or knowledge of a third-party native component. In a scenario where the native library’s source code or headers are unavailable, you can declare a C# method prototype (with DllImport
) that matches the expected native function signature based on documentation or some guesswork.
For instance, Example 1 shows how to declare and call an external function using P/Invoke in C#:
using System.Runtime.InteropServices;
public class NativeMethods
{
[DllImport("ThirdPartyLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern int PerformCalculation(int value);
}
// Usage:
int result = NativeMethods.PerformCalculation(42);
Example 1: Declaring and calling a native function via P/Invoke in C#
In Example 1 above, the exact prototype of PerformCalculation
had to be guessed from external documentation or trial-and-error. Any mistakes, such as incorrect parameter types or calling conventions, won’t be caught at compile time. They will only surface at runtime, potentially causing memory corruption or application crashes.
However, if you do have control over (or detailed knowledge of) the native component, this P/Invoke approach quickly becomes cumbersome and error-prone as the project grows.
Major Drawbacks of Using P/Invoke
Fragile Synchronization:
With P/Invoke, the C# method declaration is completely disconnected from the actual native function definition. Every change to the native code, such as renaming a function, changing parameters, or refactoring an implementation, requires a corresponding manual update to the managed declaration. Over time, if these declarations fall out of sync, you inevitably get runtime errors due to mismatches.
For example, suppose we start with a native function and a matching P/Invoke declaration:
// Original C++ function
double CalculateArea(double radius);
Example 2: Original C++ function
[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern double CalculateArea(double radius);
Example 3: Corresponding P/Invoke declaration in C#
If the native function is later modified (e.g. its signature changes), as shown in Example 4, the managed declaration becomes incorrect:
// Modified C++ function
double CalculateCircleArea(double radius, double precision);
Example 4: Modified C++ function signature
In this scenario, the P/Invoke declaration in C# no longer matches the native function, leading to unexpected behaviour or crashes at runtime until the managed signature is updated to match.
Limited Type Support and Extensive Marshalling:
P/Invoke is largely limited to primitive data types (integers, floats, pointers, simple structs, strings, etc.). Complex object models in a managed application cannot be directly passed; they must first be converted or marshalled into simpler unmanaged representations. This manual marshalling adds significant complexity, overhead, and potential for errors.
For example, consider a complex managed data type in C#:
public class ComplexData
{
public string Name { get; set; }
public List<double> Values { get; set; }
}
Example 5: A complex managed type in C
To call a native function with this data, you would need to manually marshal it into a simpler struct that P/Invoke can handle:
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct SimpleData
{
[MarshalAs(UnmanagedType.LPStr)]
public string Name;
public IntPtr Values;
public int ValueCount;
}
[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern int ProcessData(ref SimpleData data);
Example 6: Unmanaged struct and P/Invoke declaration for ProcessData
Now, the managed code must translate a ComplexData instance into this SimpleData struct and manage memory manually:
ComplexData managedData = new ComplexData
{
Name = "Test",
Values = new List<double> { 1.1, 2.2, 3.3 }
};
// Manual marshaling:
SimpleData simpleData = new SimpleData
{
Name = managedData.Name,
ValueCount = managedData.Values.Count,
Values = Marshal.AllocHGlobal(sizeof(double) * managedData.Values.Count)
};
Marshal.Copy(managedData.Values.ToArray(), 0, simpleData.Values, managedData.Values.Count);
// Calling native function:
ProcessData(ref simpleData);
// Cleaning up:
Marshal.FreeHGlobal(simpleData.Values);
Example 7: Marshalling a ComplexData instance to SimpleData and invoking the native function
All this marshalling code is harder to read, maintain, and debug. It’s clear that using P/Invoke for complex interactions can quickly become unwieldy.
When is P/Invoke Appropriate?
Use P/Invoke sparingly, and primarily for simple, stable APIs (for example, calling OS-level functions or straightforward third-party C libraries) where the API signatures are unlikely to change frequently. If your project involves extensive or complex interactions between managed and unmanaged code, P/Invoke should not be your default solution.
In summary, while P/Invoke can be straightforward for trivial cases, it becomes inadequate for larger, evolving, or complex interop scenarios. The issues described above can all be mitigated by introducing a C++/CLI project as an intermediary between native C++ and C#.
C++/CLI as an Interoperability Layer Between Native C++ and .NET (C#)
C++/CLI is a .NET project type that enables seamless interaction between native C++ code and managed .NET code. A DLL built in C++/CLI can be referenced by a .NET project like any managed assembly and simultaneously loaded by a native C++ program, like a regular native DLL. This dual capability makes C++/CLI a powerful and convenient bridge for efficient interoperation, without sacrificing performance or maintainability.
Why Use C++/CLI?
One primary advantage of using C++/CLI as an intermediary is that it abstracts away most of the marshalling and type conversion complexities. Managed code calling a native function via a C++/CLI assembly can pass and receive regular .NET objects seamlessly, without writing explicit marshalling or pointer-manipulation code. Similarly, native code can directly invoke managed logic through clearly defined exported functions or interfaces. This greatly reduces errors from data marshalling or type mismatches, making integration and refactoring safer and easier.
Additionally, a C++/CLI project can include native C++ header files directly, creating a strong compile-time link with the native code. Changes in the native interface (e.g., function signatures) will produce compile-time errors in the C++/CLI wrapper, alerting you to update the managed side accordingly. This tight coupling at compile time significantly reduces refactoring errors and simplifies maintenance.
Exposing Native C++ to .NET via C++/CLI
For example, suppose we have a native C++ function performing some complex numerical computation:
// NativeComponent.h (Native C++)
#pragma once
class NativeComponent {
public:
static double ComputeSine(double x, int terms);
};
Example 8: Native C++ class with a computation function
And its implementation:
// NativeComponent.cpp (Native C++)
#include "NativeComponent.h"
#include <cmath>
double NativeComponent::ComputeSine(double x, int terms) {
double result = 0.0;
double term = x;
int sign = 1;
long long factorial = 1;
for (int i = 1; i <= terms; ++i) {
result += sign * term / factorial;
term *= x * x;
factorial *= (2LL * i) * (2LL * i + 1LL);
sign *= -1;
}
return result;
}
Example 9: Native C++ implementation of the function
To expose this native functionality to .NET, we can create a C++/CLI wrapper DLL that provides a managed interface:
// ManagedWrapper.h (C++/CLI)
#pragma once
#include "NativeComponent.h"
using namespace System;
namespace ManagedWrapper {
public ref class ComputationWrapper {
public:
static double ComputeSine(double x, int terms);
};
}
Example 10: C++/CLI wrapper class header (ManagedWrapper.h)
// ManagedWrapper.cpp (C++/CLI)
#include "ManagedWrapper.h"
double ManagedWrapper::ComputationWrapper::ComputeSine(double x, int terms) {
return NativeComponent::ComputeSine(x, terms);
}
Example 11: C++/CLI wrapper implementation (ManagedWrapper.cpp)
Now a .NET application can reference this managed DLL and call the function naturally, as if it were a pure C# method:
// .NET Client (C#)
using ManagedWrapper;
class Program {
static void Main(string[] args) {
double result = ComputationWrapper.ComputeSine(Math.PI / 2, 10);
Console.WriteLine($"Result: {result}");
}
}
Example 12: C# code calling the native function via the C++/CLI wrapper
Calling .NET Code from Native C++ via C++/CLI
Conversely, you may have a scenario where a native C++ application needs to leverage some managed .NET logic. This is less common, but it can be easily achieved with C++/CLI as well.
For instance, say we have some managed functionality that we want to call from C++:
// ManagedLogic.cs (.NET)
namespace ManagedLogic {
public static class Logic {
public static int Add(int a, int b) => a + b;
}
}
Example 13: A simple managed class in C#
We can create a C++/CLI bridge that exposes this logic to native code:
// ManagedBridge.h (C++/CLI)
#pragma once
using namespace System;
namespace ManagedBridge {
public ref class LogicBridge {
public:
static int Add(int a, int b);
};
}
Example 14: C++/CLI header for bridging to managed logic (ManagedBridge.h)
// ManagedBridge.cpp (C++/CLI)
#include "ManagedBridge.h"
int ManagedBridge::LogicBridge::Add(int a, int b) {
return ManagedLogic::Logic::Add(a, b);
}
Example 15: C++/CLI implementation bridging to the C# logic (ManagedBridge.cpp)
Now, a native C++ program can link against this C++/CLI DLL and use the managed functionality as if it were a normal native library:
// NativeClient.cpp (Native C++)
#include "ManagedBridge.h.h"
#include <iostream>
int main() {
int sum = ManagedBridge::LogicBridge::Add(3, 4);
std::cout << "Sum is: " << sum << std::endl;
return 0;
}
Example 16 Native C++ code calling the managed logic via the C++/CLI bridge
Benefits of Using C++/CLI
Using C++/CLI as an interop layer offers several practical advantages:
- Separation of Concerns: C++/CLI allows clear separation of responsibilities. Performance-critical native logic remains isolated from the managed code, preserving a clean architecture and making the system easier to maintain.
- Refactoring Safety: Because a C++/CLI project can directly include and call native headers, there is a compile-time contract between the native and managed worlds. If a native interface changes, the C++/CLI wrapper will fail to compile until you update the code, significantly reducing the chance of runtime mismatches.
- Performance Efficiency: Compared to manual marshalling via P/Invoke, C++/CLI introduces minimal overhead. This is especially beneficial for frequent or performance-critical calls across the boundary, C++/CLI can often use blittable types and avoid excessive copying.
- Managed Abstraction: On the managed side, callers remain oblivious to the fact that they’re invoking native code. They work with familiar .NET classes and methods, which simplifies development and reduces bugs. The interop layer handles all the gritty details of talking to C++.
By structuring applications with a C++/CLI interop layer, developers can build high-performance systems that are still easy to develop, maintain, and evolve, effectively leveraging the strengths of both .NET and native C++.
Managed vs. Native Code Performance Comparison
Let’s now turn to performance. We often assert that computational logic runs faster in native C++ than in equivalent C# code. But how much faster, and under what conditions? To answer these questions, we conducted a simple benchmarking experiment. We implemented two computationally intensive tasks in both C# and C++ to measure performance:
- Integer-based calculations: Calculating Fibonacci numbers recursively. We used a naïve recursive implementation of
Fibonacci(n)
(exponential time complexity) and summed up the results over many calls. This is a CPU-bound, branch-heavy integer workload. In our test, we chose n = 20 (soFibonacci(20)
is computed repeatedly). Both the C# and C++ versions call this function in a loop for a large number of iterations (100,000 iterations in our case). - Floating-point calculations: Calculating sine via a Taylor series expansion. Specifically, computing sin(π/2) using 20 terms of the series, in a loop for many iterations (10 million iterations). This stresses floating-point arithmetic and memory access (even though it’s a predictable loop, it involves a lot of calculation). Again, the code for the series summation is identical in C# and C++.
For fairness, both algorithms were implemented in a virtually identical way in C# and in native C++ to allow a direct comparison.
C# Benchmark Code
// Managed C# Benchmark.cs
namespace OutWit.Examples.ManagedComponent
{
public static class Benchmark
{
public static double RunDouble(int iterations)
{
double totalResult = 0.0;
double sineX = Math.PI / 2.0;
int numTerms = 20;
for (long i = 0; i < iterations; i++)
{
totalResult += CalculateSine(sineX, numTerms);
}
return totalResult;
}
public static int RunInt(int iterations)
{
int totalResult = 0;
int fibN = 20;
for (int i = 0; i < iterations; i++)
{
totalResult += CalculateFibonacci(fibN);
}
return totalResult;
}
private static int CalculateFibonacci(int n)
{
if (n <= 1)
return n;
return CalculateFibonacci(n - 1) + CalculateFibonacci(n - 2);
}
private static double CalculateSine(double x, int numTerms)
{
double result = 0.0;
double term = x;
int sign = 1;
long factorial = 1;
for (int i = 1; i <= numTerms; i++)
{
result += sign * term / factorial;
term *= x * x;
factorial *= (2L * i) * (2L * i + 1L);
sign *= -1;
}
return result;
}
}
}
Example 17: C# benchmarking code for Fibonacci and sine computations
Native C++ Benchmark Code
// Benchmark.h
#pragma once
namespace OutWit::Examples::NativeComponent
{
class __declspec(dllexport) Benchmark
{
public:
static long RunInt(long iterations);
static double RunDouble(long iterations);
private:
static long CalculateFibonacci(long n);
static double CalculateSine(double x, int numTerms);
};
}
Example 18: Native C++ benchmarking code class definition
// Benchmark.cpp
#include "pch.h"
#include "Benchmark.h"
#include <cmath>
using namespace OutWit::Examples::NativeComponent;
long Benchmark::RunInt(long iterations)
{
long totalResult = 0;
int fibN = 20;
for (long long i = 0; i < iterations; ++i) {
totalResult += CalculateFibonacci(fibN);
}
return totalResult;
}
double Benchmark::RunDouble(long iterations)
{
double totalResult = 0.0;
const double PI = 3.14159265358979323846;
double sineX = PI / 2.0;
int numTerms = 20;
for (long long i = 0; i < iterations; ++i) {
totalResult += CalculateSine(sineX, numTerms);
}
return totalResult;
}
long Benchmark::CalculateFibonacci(long n)
{
if (n <= 1)
return n;
return CalculateFibonacci(n - 1) + CalculateFibonacci(n - 2);
}
double Benchmark::CalculateSine(double x, int numTerms)
{
double result = 0.0;
double term = x;
int sign = 1;
long long factorial = 1;
for (int i = 1; i <= numTerms; ++i) {
result += sign * term / factorial;
term *= x * x;
factorial *= (2LL * i) * (2LL * i + 1LL);
sign *= -1;
}
return result;
}
Example 19: Native C++ benchmarking code class implementation
Benchmark Results
Below are the summarized results of these benchmarks, showing the median execution times on both 64-bit (x64) and 32-bit (x86) builds:
Scenario |
x64 Int (ms) |
x64 Double (ms) |
x86 Int (ms) |
x86 Double (ms) |
---|---|---|---|---|
Managed C# |
1457.2 |
202.6 |
1430.1 |
1297.9 |
Native C++ |
0.023 |
6.398 |
0.064 |
24.818 |
Table 1: Benchmark execution times (median) for equivalent algorithms implemented in C# vs. C++
The results clearly show an enormous performance disparity. The native C++ implementation outperforms the managed C# code by orders of magnitude in both test cases. The recursive Fibonacci calculation (integer workload) executed in a fraction of a millisecond in native C++, compared to over one second in managed C#. Similarly, the floating-point sine calculations took mere milliseconds in C++ versus hundreds of milliseconds in C#.
This practical benchmark underscores a crucial point: for performance-critical, compute-intensive operations, native C++ remains unparalleled. While C# offers significant advantages in development speed, rapid iteration, and maintainability, true performance bottlenecks are best addressed by offloading those parts to native C++ via an efficient interop mechanism (such as C++/CLI).
Given these potential performance gains, the next question is: can we achieve that native C++ speed inside a C# application without sacrificing too much ease-of-use? The discussion above hints that C++/CLI wrappers let us get near-native performance while keeping the integration simple. We will validate that by measuring the overhead of crossing the managed/unmanaged boundary in the following section.
Calling Native C++ from Managed C# via C++/CLI Wrapper
One of the most common integration patterns is a C# application that requires executing native C++ code (for example, a high-performance computation engine or reusing an existing C++ library). We’ll use a C++/CLI wrapper to accomplish this, as outlined earlier. Let’s walk through how to set it up properly and then examine the performance.
First, we create a C++/CLI wrapper for the native benchmark code described above, exposing the native methods to managed code:
// BenchmarkWrapper.h
#pragma once
namespace OutWit::Examples::NativeWrapper
{
public ref class BenchmarkWrapper
{
public:
static double RunInt(long iterations);
static double RunDouble(long iterations);
};
}
Example 20: C++/CLI wrapper class for native benchmark functions (BenchmarkWrapper.h)
// BenchmarkWrapper.cpp
#include "pch.h"
#include "BenchmarkWrapper.h"
#include "Benchmark.h"
using namespace OutWit::Examples::NativeWrapper;
double BenchmarkWrapper::RunInt(long iterations)
{
return NativeComponent::Benchmark::RunInt(iterations);
}
double BenchmarkWrapper::RunDouble(long iterations)
{
return NativeComponent::Benchmark::RunDouble(iterations);
}
Example 21: C++/CLI wrapper implementation (BenchmarkWrapper.cpp)
After building this C++/CLI wrapper DLL, we can reference it in the C# project and call the native functions as if they were managed:
// Managed C# code
int result = BenchmarkWrapper.RunInt(20);
Console.WriteLine($"RunInt(20) = {result}");
Example 22: Calling a native function via the C++/CLI wrapper in C#
This C# call goes into a C++/CLI method, which in turn immediately invokes a C++ function. What’s the overhead? Essentially, it’s just the transition into unmanaged code, there is no significant marshalling cost here since we’re only passing a simple integer and getting back a primitive result.
Deployment Considerations
When deploying your application, note that the native C++ DLL will not be copied automatically to the output directory by the C# project. If you run the application without the native DLL present, you’ll get a runtime error (DLL not found). To avoid this, you must ensure the native DLL is deployed alongside your application, for example, by manually copying it or using a post-build event in Visual Studio.
For example, you could add a post-build step to copy the native DLL to the output folder:
xcopy /y "$(SolutionDir)\NativeBenchmark\x64\Release\NativeBenchmark.dll" "$(TargetDir)"
Example 23: Post-build command to copy the native DLL to the output directory (Visual Studio)
After building, your output (deployment) directory should contain the following:
- The managed application executable (.exe)
- The C++/CLI wrapper DLL (.dll – this is copied automatically by the build when referenced)
- The native C++ DLL (.dll – manually copied or included via a post-build step)
Performance Comparison
We next measured the overhead of calling native code through the C++/CLI wrapper, compared to calling the native code directly. The table below illustrates the performance of a direct native call versus calling the same function via the wrapper:
Scenario |
x64 Int (ms) |
x64 Double (ms) |
x86 Int (ms) |
x86 Double (ms) |
---|---|---|---|---|
Direct Native Call |
0.023 |
6.398 |
0.064 |
24.818 |
Native via C++/CLI Wrapper |
0.0247 |
6.3329 |
0.0548 |
24.8496 |
Table 2: Performance of direct native calls vs. calls through a C++/CLI wrapper
Calling Managed C# from Native C++ via C++/CLI Wrapper
So far, we’ve focused on a C# application calling into C++ for performance-critical work. What about the reverse situation? Although less common, you might have a predominantly native C++ application that wants to use some high-level C# logic or a .NET library. For example, a native backend might wish to invoke a C# library implementing complex business rules, or even create a .NET UI component. How can C++ call into C#?
In the classic .NET Framework era, one approach was COM interop: you could mark a C# class library as COM-visible, register it with regasm, and then use COM from C++ to instantiate objects and call methods. This does work, but it comes with all the hassles of COM: registering DLLs, dealing with VARIANT/BSTR types, reference counting, etc. It’s also a dated approach and doesn’t translate well to .NET Core (which lacks the old Windows Registry-based component registration). Unless you already have a COM-based infrastructure, a more straightforward way is preferred: once again, C++/CLI to the rescue.
Using C++/CLI, we can write a DLL that hosts the .NET runtime and calls into C# code, while exposing a native C++-callable interface. Let’s create a C++/CLI wrapper for our managed C# benchmark functionality so that a native C++ application can call it:
// BenchmarkWrapper.h
#pragma once
namespace OutWit::Examples::ManagedWrapper
{
class __declspec(dllexport) BenchmarkWrapper
{
public:
static long RunInt(long iterations);
static double RunDouble(long iterations);
};
}
Example 24: C++/CLI wrapper class to expose managed code to native (BenchmarkWrapper.h)
// BenchmarkWrapper.cpp
#include "pch.h"
#include "BenchmarkWrapper.h"
using namespace OutWit::Examples::ManagedWrapper;
using namespace OutWit::Examples::ManagedComponent;
long BenchmarkWrapper::RunInt(long iterations)
{
return Benchmark::RunInt(iterations);
}
double BenchmarkWrapper::RunDouble(long iterations)
{
return Benchmark::RunDouble(iterations);
}
Example 25: C++/CLI wrapper implementation calling into C# (BenchmarkWrapper.cpp)
In this wrapper, BenchmarkWrapper
is a native C++ class (not a managed ref class and marked with __declspec(dllexport)
). This means the C++/CLI DLL will expose real native exported functions RunInt and RunDouble, that any C++ code can call, just like exports from a normal native DLL. Inside those functions, we seamlessly transition into the CLR (the C++/CLI runtime initializes and loads the .NET runtime when the DLL is loaded) and invoke the managed Benchmark class methods.
After compiling this C++/CLI ManagedWrapper.dll
, we have a bridging component. A native C++ application can link against the import library and call the managed logic as if it were a normal C++ library. For example:
// Native C++ usage of the managed wrapper
#include "ManagedWrapper\BenchmarkWrapper.h"
#pragma comment(lib, "ManagedWrapper.lib") // link against the lib
...
// Now call the managed logic via the wrapper:
double result =
OutWit::Examples::ManagedWrapper::BenchmarkWrapper::RunDouble(10000000);
Example 26: Native C++ code using the C++/CLI wrapper to call managed code
When the native code calls BenchmarkWrapper::RunDouble(...)
(Example 26), under the hood it executes the C# implementation of that logic and returns the result to C++ as a normal double. The native application doesn’t need to know anything about .NET types, it just sees a function that takes and returns primitive types. The C++/CLI DLL takes care of starting up the .NET runtime (on first use, incurring a one-time startup cost to initialize the CLR) and marshalling data types as needed.
Performance Comparison
We measured the overhead of this wrapper by timing our benchmarks in a native C++ program that calls the C# code through the wrapper, and comparing it to a pure C# program calling the same code directly:
Scenario |
x64 Int (ms) |
x64 Double (ms) |
x86 Int (ms) |
x86 Double (ms) |
---|---|---|---|---|
Direct Managed Call |
1457.2 |
202.6 |
1430.1 |
1297.9 |
Managed via C++/CLI Wrapper |
1488.3 |
202.9 |
1434.7 |
1293.3 |
Table 3: Execution time calling managed code directly vs. via a C++/CLI wrapper (from native C++)
As shown, the overhead of transitioning from unmanaged to managed code via C++/CLI is minimal. In these tests, the actual work (hundreds of milliseconds of computation) dominates the timings; the wrapper’s overhead is lost in the noise. As expected, calling managed code from native C++ through a C++/CLI bridge is about as fast as calling that managed code from C# itself.
In summary, C++/CLI provides a convenient two-way street: it allows managed code to be exposed to native code and vice versa, all within one bridging layer. By using it, a C++ application can call into rich C# libraries as if they were just another native DLL, and a C# app can call into high-speed C++ routines as if they were just another .NET assembly. In both directions, as we’ve seen, the performance cost of the interop is negligible when done correctly.
Mixed Code Inside C++/CLI Wrapper
Another approach to consider is putting both the managed and native code together in one C++/CLI project. C++/CLI allows the creation of mixed-mode assemblies that contain both unmanaged C++ code and managed .NET types in the same binary. For example, you could write a single C++/CLI DLL that directly implements the high-performance code in a native C++ class and also provides a managed class interface - all in one project, with no separate pure-native DLL.
Let’s say we create a C++/CLI assembly NativeMixed.dll
with the following structure:
- A native C++ class
BenchmarkNative
with static methods implementing the actual computations (Fibonacci and sine series), very similar to our earlier pure native code. It is marked with__declspec(dllexport)
so it can also be called from native code if needed (i.e., the class’s methods are true native exports). Example 27 shows this native class definition. - A managed
ref class Benchmark
with static methods exposed to .NET. These methods will internally call the native implementations. Example 28 shows this managed class interface.
// BenchmarkNative.h
#pragma once
namespace OutWit::Examples::NativeMixed
{
class __declspec(dllexport) BenchmarkNative
{
public:
static long RunInt(long iterations);
static double RunDouble(long iterations);
private:
static long CalculateFibonacci(long n);
static double CalculateSine(double x, int numTerms);
};
}
Example 27: Native class inside a mixed C++/CLI assembly (BenchmarkNative.h)
// Benchmark.h
#pragma once
namespace OutWit::Examples::NativeMixed
{
public ref class Benchmark
{
public:
static double RunInt(long iterations);
static double RunDouble(long iterations);
};
}
Example 28: Managed class inside the mixed assembly (also in BenchmarkNative.h)
In this design, the managed Benchmark
methods simply call the corresponding native BenchmarkNative
methods internally (for both RunInt
and RunDouble
). For example:
// BenchmarkNative.cpp
#include "pch.h"
#include "Benchmark.h"
#include "BenchmarkNative.h"
using namespace OutWit::Examples::NativeMixed;
double Benchmark::RunInt(long iterations)
{
return BenchmarkNative::RunInt(iterations);
}
double Benchmark::RunDouble(long iterations)
{
return BenchmarkNative::RunDouble(iterations);
}
Example 29: Implementation of managed methods calling native methods (BenchmarkNative.cpp)
In theory, this single mixed-mode DLL can act as the entire bridge: C# code could call NativeMixed.Benchmark.RunInt
, which goes to the native implementation in the same module, and a C++ program could link against NativeMixed.lib
and call the BenchmarkNative::RunInt
export to do the same. It sounds ideal: one component to deploy that serves both managed and unmanaged sides. And indeed, functionally it works.
Performance Comparison
The catch is performance. You might assume that since the heavy lifting is in a “native” C++ function (BenchmarkNative::RunInt
), it would run at full native speed. However, our measurements (and other analyses) show that computations inside a mixed-mode assembly do not reach the performance of a pure native binary. In our case, the mixed assembly’s native functions ran significantly slower than the identical functions in a pure native DLL. In fact, the timings of the mixed assembly’s operations were in the same ballpark as the pure C# version; no significant speedup was gained by putting the code in C++ within a mixed assembly.
To illustrate, here are the performance results comparing a pure C# run, a pure native C++ run, and the mixed assembly in both managed and native contexts:
Scenario |
x64 Int (ms) |
x64 Double (ms) |
x86 Int (ms) |
x86 Double (ms) |
---|---|---|---|---|
Managed C# |
1457.2 |
202.6 |
1430.1 |
1297.9 |
Native C++ |
0.023 |
6.398 |
0.064 |
24.818 |
Managed via Mixed C++/CLI dll |
1672.9 |
190.8 |
1440.9 |
2102.4 |
Native via Mixed C++/CLI dll |
1463.2 |
191.1 |
1423.2 |
2114.1 |
Table 4: Performance of mixed-mode C++/CLI assembly vs. pure C# and pure C++ implementations
Why did this happen? The primary reason is that when you compile with /clr
(even in a mixed assembly), the compiler’s optimization and code generation for native functions can be constrained. In some cases, code might be emitted as managed IL if it doesn’t use any explicitly unmanaged features. Even when compiled to native code, certain optimizations that a pure native compiler would do (e.g. Whole Program Optimization or very low-level CPU-specific optimizations) might be disabled or limited. In our tests, the mixed assembly’s native functions likely did not benefit from the full optimizations, resulting in performance closer to the C# JIT output. In short, native code compiled as part of a C++/CLI assembly can run a great deal slower than truly independent native code.
The solution, as we’ve already demonstrated, is to keep the heavy code in a pure native module and just wrap it via C++/CLI. This approach yields the same performance as a pure native build while giving you more flexibility in interop.
Generally, the mixed-code approach isn’t worth it if your goal is performance: you won’t get meaningful speedups relative to just using C#. If you’re going to the trouble of writing C++ for performance, you should compile it as a pure native library to reap the benefits. However, there are cases where a mixed assembly is convenient: for example, if performance requirements are modest and you want the simplicity of a single deployable component with a little bit of C++ logic inside. Another scenario is when writing a custom tool or plugin, where dropping in one assembly is easier, and “good enough” performance is acceptable. In those cases, using mixed C++/CLI is fine; just be aware that it won’t magically make compute-heavy code run faster than C# in practice.
In summary, mixed-mode C++/CLI assemblies are a powerful feature (showcasing the flexibility of C++/CLI), but for maximum performance, you should keep computational code in true native modules. Use the mixed approach sparingly, only when convenience outweighs the need for absolute speed.
Conclusion
Bridging .NET and native C++ unlocks a hybrid approach that can yield both high developer productivity and high performance. In this article, we explored a range of interoperability techniques to achieve this balance:
- P/Invoke – The simplest way for C# to call into existing native libraries, suitable for quick integrations or when you don’t control the native code. However, it comes with challenges in keeping function signatures synchronized and in marshalling complex data types. It’s best used for calling well-defined C APIs or OS functions, rather than as a long-term solution for large-scale integration.
- C++/CLI Wrappers – A robust tool for building a two-way bridge between C# and C++. By writing a C++/CLI wrapper DLL, we can expose native functionality to .NET in a type-safe way and likewise allow native code to invoke managed functionality. This technique simplifies integration (no manual DllImport marshalling in C#) and minimizes runtime errors due to mismatched definitions. Our examples showed that C++/CLI wrappers introduce minimal performance overhead, effectively giving us near full native speed inside a C# application (and vice versa).
- Architecture Separation – Separating the native and managed components (with C++/CLI as the glue) provides clarity and reuse. The managed side handles high-level application logic and UI, leveraging rich .NET libraries, while the native side handles compute-intensive tasks or direct hardware interaction. The C++/CLI layer enforces a clean interface between them. This separation also means each side can be developed and tested somewhat independently (for example, you can unit-test the C++ library on its own, in isolation from the UI).
- Mixed-Mode Assemblies (C++/CLI) – We cautioned against expecting miracles just by putting code into a mixed-mode C++/CLI assembly. If performance is the goal, that code should remain in a pure native module to get the full benefit of native optimization. Mixed assemblies are fine when convenience is more important than absolute speed (e.g., packaging a small amount of C++ logic with some managed glue for simplicity). Just be aware that a mixed assembly won’t magically run CPU-intensive code faster than C# in practice.
- Leveraging Existing Libraries – If you have an existing C++ library and need to call it from C#, consider writing a C++/CLI wrapper DLL for it. This approach saves you from P/Invoke headaches, especially if the native API is large or complex, by providing a managed interface to that library.
- Offloading Hotspots – If you have a C# application that needs a performance boost, identify the hotspots (critical sections of code) and move them to a native C++ library accessed via a C++/CLI wrapper. Conversely, if your C++ application could benefit from .NET functionality (say you want to use a C# machine learning library or some .NET API), you can wrap that managed code in a C++/CLI layer so your C++ code can call it as if it were a plain DLL function.
- Measure and Profile – Finally, always measure and verify the performance when mixing C# and C++. As we saw, C++ can be significantly faster for certain tasks, and in our cases the integration overhead was negligible. However, this might vary with different workloads or calling patterns. Use profiling tools to ensure that your cross-boundary calls aren’t becoming a bottleneck. In many cases, the largest performance gains come from architectural decisions, e.g., doing 100,000 computations in C++ instead of C#, rather than micro-optimizing the interop call itself.
By leveraging the techniques discussed, you can create systems that use C# and C++ collaboratively, combining a responsive, feature-rich UI and high-level logic in C# with the raw computing power of C++ where it matters most. With modern tool support and the data we’ve seen, there’s little penalty for mixing these two worlds, and in return, you get a highly capable, high-performance system that takes the best of both platforms.
Links
Examples GitHub Repository. . Contains the complete source code for the examples and benchmarks discussed in this article (C# managed component, C++ native component, C++/CLI wrappers, and test applications): https://github.com/dmitrat/Examples
Performance Considerations for Interop (C++). Microsoft documentation discussing the cost of managed/unmanaged transitions and comparing P/Invoke with C++ interop. Notably explains that C++/CLI can use faster, blittable marshalling by default, whereas P/Invoke often performs more extensive marshalling for complex types: https://learn.microsoft.com/en-us/cpp/dotnet/performance-considerations-for-interop-cpp?view=msvc-170
.NET Programming with C++/CLI. Microsoft documentation on what C++/CLI is and how to enable it in modern Visual Studio versions. Useful for getting started with C++/CLI projects (installation, project settings, etc): https://learn.microsoft.com/en-us/cpp/dotnet/dotnet-programming-with-cpp-cli-visual-cpp?view=msvc-170