Key Takeaways

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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++.

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:

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:

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:

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:

// 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:

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.

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