Performance
Optimal performance is one of the key design principles of CSnakes. That said, there are some important differences between Python and .NET which impact performance when embedding.
This page documents some performance considerations when using CSnakes.
Important Concepts
- Marshalling refers to the transfer of data and types between platforms
(Python to .NET or .NET to Python). For example converting a Python
int
into a C#.NETlong
.
3 Things to avoid when performance matters
1. "Crossing the bridge" too frequently
Python and .NET functions have very different calling conventions. Whilst every effort has been made to make calling Python functions fast from .NET in CSnakes, function calls are relatively slow compared with regular .NET to .NET calls. Function calling in Python is generally slow (compared to C#, C++, Rust, C, etc.) so when writing performance Python code you should avoid writing tiny functions and making too many calls. Python doesn't have function inlining (unlike C# with JIT or AOT compilation) and a lot of CPU time is spent creating call frames.
Calling Python functions from CSnakes is slower than calling Python functions from Python, because CSnakes has to marshal the input values from .NET into Python objects and vice versa. Therefore, you want to avoid "crossing the bridge" (going between .NET and Python) too frequently.
For example, take this code:
import numpy as np
def make_square_2d_array(n: int) -> np.ndarray:
return np.zeros((n, n))
def set_random(arr, i: int, j: int) -> None:
arr[i, j] = np.random.random()
From C#, you fill or set the values in the array like this:
var names = env.Make2dArray(1000);
for (int i = 0; i < 1000; i++)
{
for (int j = 0; j < 1000; j++)
{
env.SetRandom(names, i, j);
}
}
This is a very inefficient way to set the values in the array. The
SetRandom
function is called 1,000,000 times and each time it crosses the
bridge between .NET and Python. For all 1,000,000 calls, the C# integers i
and
j
are converted to Python integers, the function is called, and then the
result is converted back.
Instead, you should design your code with a wrapper function to minimize the number of interop calls. For example, you could create a function that sets all the values in the array at once:
def set_random(arr: np.ndarray) -> None:
for i in range(arr.shape[0]):
for j in range(arr.shape[1]):
arr[i, j] = np.random.random()
2. Marshalling return values unnecessarily
Unlike .NET which has value types and reference types, Python has only names
which are references to objects (everything is a pointer to PyObject
). If you're a C# developer, just think of Python
as having only reference types.
To marshall value types from .NET to Python, CSnakes has to convert the value
type into a reference type. This is done by creating a new object in Python and
copying the value into it. This is a relatively expensive operation and should
be avoided when possible. There are some performance tricks in CSnakes to intern
certain values like 0
, 1
, True
, False
, and None
to avoid this
overhead, but you should still be careful when passing value types to Python
functions.
If you don't need to read the return value of a Python function, you can hint
the function as Any
. This will tell the source generator to return a
PyObject
, which is a SafeHandle
to the Python object. This will avoid the
overhead of marshalling the return value into a .NET type.
This is particularly useful when passing data between functions.
For example, if the Python function a
returns an object and you need that to
pass to another function b
, you can do this:
Instead of marshalling the return value of a
into a .NET type, you can use
Any
to avoid the overhead:
Then from C#, you use the PyObject
type to pass the value to the next
function:
Tuples are value types
In .NET, Tuple types are value types; tuple elements are public fields. Unlike
Python, where tuples are immutable and returned as a reference (pointer to PyObject
). This means that if
a Python object returns a tuple, each of the elements in the tuple is eagerly
marshalled into the corresponding .NET type.
That is different to dictionaries and lists, which are lazily marshalled (see below).
Lazy dictionaries
If a Python function returns a dictionary, CSnakes will return
IReadOnlyDictionary
with an implementation to lazily convert the values to
.NET types. The conversion is completed the first time the key value is accessed. Lazy Conversion is done to avoid the overhead of converting all the values in
the dictionary to .NET types when the dictionary is created.
IReadOnlyDictionary<string, int> dict = env.ExampleDict();
// Don't do this to get the value of a key
foreach (var kvp in dict)
{
if (kvp.Key == "key")
{
// Do something with the value
int value = kvp.Value;
}
}
// Instead, check the existence of a key
if (dict.ContainsKey("key"))
{
// Get the value of the key
int value = dict["key"];
}
Lazy lists
Similar to dictionaries, if a Python function returns a list, CSnakes will
return IReadOnlyList
with an implementation to lazily convert the values to
.NET types as each index is accessed.
Where possible, you should try and avoid iterating over the list to get a single
or a few values. Instead, you should use the IReadOnlyList
interface to index
into the specific values you need.
If you do, the marshalled value is cached in the IReadOnlyList
implementation.
This means that if you call the same index multiple times, the value is only
marshalled once.
3. Sending large amounts of data to Python
Whilst Python functions which return lists and dictionaries are lazily marshalled, functions which take lists and dictionaries as arguments are not; they are copied instead. This means that if you pass a large list or dictionary to a Python function, CSnakes has to eagerly marshal the entire list or dictionary into .NET types.
Take this example:
From C#, you can call this function like this:
When calling the Python function, CSnakes has to create a Python list object and create a Python integer object for each element in the list. If there are thousands or millions of elements in the list, this can be a very expensive operation.
There are some alternatives:
Using bytes
If you are passing a large amount of data to Python, you can use a bytes
instead of a list. This will avoid the overhead of creating a Python list and
converting each element to a Python object.
from array import array
def example_function(data: bytes) -> None:
# Do something with the data
arr = array('B', data) # unsigned char
# Do something with the array
From C#, you can call this function like this:
byte[] data = new byte[1_000_000];
for (int i = 0; i < data.Length; i++) // Fill the byte array with data (example)
{
data[i] = (byte)i;
}
env.ExampleFunction(data);
CSnakes only creates 1 Python object and copies the byte array into the bytes object. This is more efficient than creating an array or tuple of values, because each of the elements in the array needs to be created as a Python object and allocated. You can use the array module to convert the bytes into a list of an underlying C type.
Using numpy arrays
If you're sending large byte data, or numerical data from Python to .NET, you should use the Buffer protocol to pass the data.
If you need to copy lots of numerical data from .NET to Python, we recommend
creating a numpy array in Python and using the Buffer
type to fill
it from .NET. This is the fastest way to pass large amounts of data to Python.
You can combine generators with the Buffer
type to yield
the empty Numpy
array, fill it from .NET, the continue with execution in Python:
import numpy as np
from typing import Generator
from collections.abc import Buffer
def sum_of_2d_array(n: int) -> Generator[Buffer, None, int]:
arr = np.zeros((n, n), dtype=np.int32)
yield arr
return np.sum(arr).item()
From C#, you can call the generator, wait for the first yield, and then fill the array with data from .NET:
// whatever your data looks like, e.g. a list of Int32
List<Int32> list = new() { 1, 2, 3, 4, 5 };
var bufferGenerator = testModule.SumOf2dArray(5);
// Move to the first yield
bufferGenerator.MoveNext();
// Get the buffer object
var bufferObject = bufferGenerator.Current;
// Get the buffer as a 2D span of Int32
var bufferAsSpan = bufferObject.AsInt32Span2D();
// Copy the list to the buffer
for (int i = 0; i < list.Count; i++)
{
for (int j = 0; j < list.Count; j++)
{
bufferAsSpan[i, j] = list[i];
}
}
// Continue execution in Python
bufferGenerator.MoveNext();
// Get return value
long result = bufferGenerator.Return;
Streaming data from Python into .NET
If you need to send lots of data from Python to .NET, you can either use the buffer protocol or use a generator to stream the data from Python to .NET.