Runtime Marshalling

Interfacing between languages efficiently.

In Plugify, runtime marshalling is the process of converting data types between managed and unmanaged code, enabling seamless communication between plugins written in different programming languages. This guide provides tips and recommendations for language module developers on how to handle marshalling efficiently and how to use Plugify's utilities to simplify the process.

Basic Type Mapping

The following table lists how types are exposed to the C++ API:

C++ TypePlugify AliasRef Support ?
voidvoid
boolbool
charchar8
char16_tchar16
int8_tint8
int16_tint16
int32_tint32
int64_tint64
uint8_tuint8
uint16_tuint16
uint32_tuint32
uint64_tuint64
uintptr_tptr64
uintptr_tptr32
floatfloat
doubledouble
void*function
plg::stringstring
plg::anyany
plg::vector<bool>bool
plg::vector<char>char8
plg::vector<char16_t>char16
plg::vector<int8_t>int8
plg::vector<int16_t>int16
plg::vector<int32_t>int32
plg::vector<int64_t>int64
plg::vector<uint8_t>uint8
plg::vector<uint16_t>uint16
plg::vector<uint32_t>uint32
plg::vector<uint64_t>uint64
plg::vector<uintptr_t>ptr64
plg::vector<uintptr_t>ptr32
plg::vector<float>float
plg::vector<double>double
plg::vector<plg::string>string
plg::vector<plg::any>any
plg::vector<plg::vec2>vec2
plg::vector<plg::vec3>vec3
plg::vector<plg::vec4>vec4
plg::vector<plg::mat4x4>mat4x4
plg::vec2vec2
plg::vec3vec3
plg::vec4vec4
plg::mat4x4mat4x4

Handling Plugify Types

Plugify requires two types of marshalling:

  1. Language to C++: Marshalling data from a language (e.g., Go, Python) into C++ types.
  2. C++ to Language: Generate and Export C++ functions so they can be called from other languages.

Marshalling from Language to C++

This is the process of converting data from a language's native types into Plugify's C++ types (e.g., plg::string, plg::vector). This is typically done when a plugin calls a function exposed by another plugin.

Example: Working with plg::string in C

Here’s how you can work with plg::string from another language in multiple ways. Simple approach is to use language's native system, however in this example we review alternative approach for language which not have that feature.

At the machine level, Plugify's C++ objects are essentially C structures. The only difference is that they required execution of constructors and destructors. By treating these objects as plain C structures, you can avoid runtime wrapper generation and instead use compile-time marshalling to interact with them.

C++ Code (Language Module)

language-module.cpp
// C++
extern "C" plg::string ConstructString(_GoString_ source) {
    if (source.p == nullptr || source.n == 0)
        return {};
    else
        return { source.p, source.n };
}
extern "C" void DestroyString(plg::string* string) {
    string->~basic_string();
}
extern "C" ptrdiff_t GetStringLength(plg::string* string) {
    return static_cast<ptrdiff_t>(string->length());
}
extern "C" _GoString_ GetStringData(plg::string* string) {
    return _GoString_{ string->c_str(), string->length() };
}
extern "C" void AssignString(plg::string* string, _GoString_ source) {
    if (source == nullptr)
        string->clear();
    else
        string->assign(source.p, source.n);
}

C Code (CGo)

plugify.c
typedef struct { char* data; size_t size; size_t cap; } String; // always 24 bytes
typedef struct { void* begin; void* end; void* capacity; } Vector; // always 24 bytes

String Plugify_ConstructString(_GoString_ source) {
    return constructString(source);
}
void Plugify_DestroyString(String* string) {
    destroyString(string);
}
_GoString_ Plugify_GetStringData(String* string) {
    return getStringData(string);
}
ptrdiff_t Plugify_GetStringLength(String* string) {
    return getStringLength(string);
}
void Plugify_AssignString(String* string, _GoString_ source) {
    assignString(string, source);
}

typedef String (*ParamCallbackFn)(int32_t, float, double, Vector4*, int64, char, String*);
static String ParamCallback(int32_t a, float b, double c, Vector4* d, int64 e, char f, String* g) {
    static Param7CallbackFn __func = NULL;
    if (__func == NULL) Plugify_GetMethodPtr2("other_plugin.ParamCallback", (void**)&__func); // get address of original function
    return __func(a, b, c, d, e, f, g);
}

Go Usage

plugin.go
func ParamCallback(a int32, b float32, c float64, d Vector4, e int64, f int8, g string) string {
    C_a := C.int32_t(a)
    C_b := C.float(b)
    C_c := C.double(c)
    C_e := C.int64_t(c)
    C_d := *(*C.Vector4)(unsafe.Pointer(&d))
    C_f := C.char(f)
    C_g := C.Plugify_ConstructString(g) // [!] call plg::string ctor on stack-allocated struct
    
    // Call the C++ function by address 
    // plg::string ParamCallback(int32_t a, float b, double c, const plg::vec4& d, int64_t e, int8_t f, const plg::string& g)
    C_output := C.ParamCallback(C_a, C_b, C_c, &C_d, &C_e, C_f, &C_g)
 
    // Get pointer to cstring and convert to go equivalent
    P_output := C.Plugify_GetStringData(&C_output)
    output := C.GoString(P_output)
 
    // Clean up
    C.Plugify_DestroyString(&C_g)
    C.Plugify_DestroyString(&C_r)
    
    return output;
}

Advantages of This Approach

  1. No Runtime Overhead: Avoids the need for runtime function generation, reducing performance overhead.
  2. Language Compatibility: Works with languages that cannot be controlled from unmanaged code at runtime.
  3. Explicit Memory Management: Provides full control over object lifetime and memory allocation.

Considerations

  • Ensure that the memory layout and alignment of Plugify types match the C structures used in the target language.
  • Manually manage object construction and destruction to avoid memory leaks or undefined behavior.

Exporting Functions from Language to C++

Plugify requires plugins to export functions written in their native language (e.g., Python, Go) so they can be called by other plugins. This process involves creating wrappers that convert Plugify's C++ types into the target language's types and vice versa. These wrappers are generated during plugin load and stored in the LoadResult for later export to other language modules.

How It Works

  1. Plugin Load: When a plugin is loaded, the language module initializes the plugin's script and retrieves its exported functions.
  2. Wrapper Creation: For each exported function, the language module creates a wrapper using JitCallback or a similar mechanism. This wrapper handles type conversion between Plugify's C++ types and the target language's types.
  3. Export to Other Modules: The wrappers are stored in the LoadResult and exported to other language modules during the OnMethodExport phase.

Example: Python Language Module

Here’s a simplified example of how the Python language module exports a function (add_numbers) to C++:

Python Plugin Code

plugin.py
def add_numbers(a: int, b: int) -> int:
    """
    Adds two 32-bit integers.
    :param a: First integer
    :param b: Second integer
    :return: Sum of a and b
    """
    a_32 = c_int32(a).value
    b_32 = c_int32(b).value
    result = c_int32(a_32 + b_32).value
    return result

C++ Code (Python Language Module)

The Python language module creates a wrapper for the add_numbers function during plugin load. This wrapper converts C++ types to Python types, calls the Python function, and converts the result back to a C++ type.

language-module.cpp
// InternalCall: Handles the actual function call and type conversion
void InternalCall(MethodHandle method, MemAddr data, const Parameters* params, uint8_t count, const Return* ret) {
    // Convert C++ types to Python types
    int32_t a = params->GetArgument<int32_t>(0);
    int32_t b = params->GetArgument<int32_t>(1);
    
    // Create arguments for the Python function
    PyObject* pArgs = PyTuple_Pack(2, PyLong_FromLong(a), PyLong_FromLong(b));
    
    // Extract the Python function from data
    PyObject* pFunc = reinterpret_cast<PyObject*>(data);
    
    // Call the Python function
    PyObject* pValue = PyObject_CallObject(pFunc, pArgs);
    int32_t result = -1;  // Default error value
    
    if (pValue) {
        result = PyLong_AsLong(pValue);
        Py_DECREF(pValue);
    } else {
        PyErr_Print();
        std::cerr << "Function call failed" << std::endl;
    }
    
    // Cleanup
    Py_DECREF(pArgs);
    Py_DECREF(pFunc);
    
    // Set the return value
    ret->SetReturn(result);
}

// OnPluginLoad: Creates wrappers for exported functions
LoadResult PythonLanguageModule::OnPluginLoad(PluginHandle plugin) {
    // ...
    
    std::span<const MethodHandle> exportedMethods = plugin.GetDescriptor().GetExportedMethods();
    std::vector<MethodData> methods;
    methods.reserve(exportedMethods.size());

    for (const auto& method : exportedMethods) {
        // Retrieve the Python function by name
        if (PyObject* pFunc = script->GetFunctionByName(method.GetFunctionName())) {
            // Create a JIT wrapper for the function
            JitCallback callback(_rt);
            MemAddr func = callback.GetJitFunc(method, &InternalCall, pFunc);

            if (!func) {
                // Handle errors
                funcErrors.emplace_back(method.GetName());
                continue;
            }

            // Store the wrapper and method data
            _functions.emplace_back(std::move(callback));
            methods.emplace_back(method, func);
        } else {
            // Handle missing functions
            funcErrors.emplace_back(method.GetName());
        }
    }

    // Return errors if any functions were not found
    if (!funcErrors.empty()) {
        std::string funcs(funcErrors[0]);
        for (auto it = std::next(funcErrors.begin()); it != funcErrors.end(); ++it) {
            std::format_to(std::back_inserter(funcs), ", {}", *it);
        }
        return ErrorData{ std::format("Not found {} method function(s)", funcs) };
    }

    // Return the list of wrapped methods
    return LoadResultData{ std::move(methods) };
}

Key Steps in the Process

  1. Function Retrieval: The language module retrieves the plugin's exported functions (e.g., add_numbers in Python).
  2. Wrapper Creation: For each function, a wrapper is created using JitCallback. This wrapper handles type conversion and function invocation.
  3. Error Handling: If a function cannot be found or a wrapper cannot be created, the language module logs an error and skips the function.
  4. Export to Other Modules: The wrapped functions are stored in the LoadResult and exported to other language modules during the OnMethodExport phase.

Advantages of This Approach

  1. Language Independence: Functions written in any language can be exported and called by other plugins.
  2. Type Safety: Wrappers ensure that types are correctly converted between languages.
  3. Performance: By generating wrappers at load time, runtime overhead is minimized.

Considerations

  1. Memory Management: Ensure proper cleanup of resources (e.g., Python objects) to avoid memory leaks.
  2. Error Handling: Handle errors gracefully, especially when functions are missing or type conversion fails.
  3. Performance: Optimize wrappers for performance-critical applications.

Using the Jit Library

The plugify-jit library is a powerful tool for runtime function generation and dynamic function calls. It provides two key classes, JitCallback and JitCall, which are essential for marshalling functions between managed and unmanaged code.

JitCallback

The JitCallback class allows you to create callback objects that can be passed to functions as callback function pointers. These objects enable dynamic iteration over arguments when the callback is invoked.

Key Methods

  • GetJitFunc: Generates a dynamically created function based on a method reference.
    MemAddr GetJitFunc(MethodHandle method, CallbackHandler callback, MemAddr data = nullptr, HiddenParam hidden = &ValueUtils::IsHiddenParam);
    
  • CallbackHandler: A function type that handles the callback logic.
    using CallbackHandler = void(*)(MethodHandle method, MemAddr data, const Parameters* params, uint8_t count, const Return* ret);
    

Step-by-Step Guide

Initialize the Object:

Create an instance of the JitCallback class. It requires an asmjit runtime object for executable memory allocation.

JitCallback callback(jitRuntime);
Generate the Function:

Use the GetJitFunc method to generate a function pointer.

void* methodAddr = callback.GetJitFunc(method, &Callback, funcAddress);
Implement the Callback Function:

Define a callback function to handle type conversion and call the original function.

void Callback(MethodHandle method, MemAddr data, const Parameters* params, uint8_t count, const Return* ret) {
    // Convert types and call the original function
}

Example

void Callback(plugify::MethodHandle method, plugify::MemAddr data, const plugify::JitCallback::Parameters* params, uint8_t count, const plugify::JitCallback::Return* ret) {
// Implementation of the callback function
}

int main() {
    // Initialize the JitCallback object
    plugify::JitCallback callback(jitRuntime);

    // Define the method and function pointers
    plugify::MethodHandle method;  // Assume this is properly initialized
    void* func = /* function pointer to be used or any other data */;

    // Generate the JIT function (C Calling Convention)
    void* methodAddr = callback.GetJitFunc(method, &Callback, func);
}

JitCall

The JitCall class encapsulates function call semantics, allowing you to dynamically push function parameters and issue calls. This is particularly useful for calling C functions in a dynamic manner.

Key Methods

  • GetJitFunc: Generates a dynamically created function based on a method reference.
    MemAddr GetJitFunc(MethodHandle method, MemAddr target, WaitType waitType = WaitType::None, HiddenParam hidden = &ValueUtils::IsHiddenParam);
    

Step-by-Step Guide

Initialize the Object:

Create an instance of the JitCall class.

JitCall call(jitRuntime);
Generate the Function:

Use the GetJitFunc method to generate a function pointer.

void* methodAddr = call.GetJitFunc(method, funcAddress);

Example

int main() {
    // Initialize the JitCall object
    plugify::JitCall call(jitRuntime);

    // Define the method and function pointers
    plugify::MethodHandle method;  // Assume this is properly initialized
    void* func = /* function pointer to be used */;

    // Generate the JIT function (C Calling Convention)
    MemAddr methodAddr = call.GetJitFunc(method, func);

    // Generate parameters holder
    plugify::JitCall::Parameters params(2);

    // Pass parameters
    params.AddArgument(2);
    params.AddArgument(2.0f);
    params.AddArgument(2.0);

    // Pass a string by reference
    plg::string str("Some string");
    params.AddArgument(&str);

    // Call the function
    plugify::JitCall::Return ret{};
    methodAddr.Cast<CallingFunc>()(params.GetDataPtr(), &ret);

    // Validate the return value
    assert(ret.GetReturn<int>() == 1);
}

Benefits of Using Jit Library

  1. Dynamic Function Generation: Create and call functions at runtime, enabling flexibility and adaptability.
  2. Interoperability: Facilitates communication between different programming languages within Plugify.
  3. Simplified Integration: Provides a straightforward API for generating and using dynamic functions, reducing the complexity of marshalling.

Linking the Library

To link the plugify-jit library with your language module, add plugify::plugify-jit to your CMake target:

target_link_libraries(${PROJECT_NAME} PRIVATE plugify::plugify plugify::plugify-jit)

This ensures that the plugify-jit library is linked during the build process, making its functionality available to your code.

Troubleshooting

Common Issues

  1. Memory Leaks:
    • Ensure that dynamically allocated memory (e.g., for JitCallback or JitCall objects) is properly managed.
    • Use smart pointers or RAII patterns to avoid leaks.
  2. Invalid Method References:
    • Verify that the method object passed to GetJitFunc is valid and correctly represents the target method.
  3. Debugging Tips:
    • Use verbose logging to trace function calls and parameter values.
    • Enable debugging symbols in your build configuration to simplify debugging.

Performance Tips

  1. Minimize Memory Allocations:
    • Reuse JitCallback and JitCall objects where possible to avoid frequent memory allocations.
    • Use stack-allocated buffers for small data structures.
  2. Avoid Unnecessary Type Conversions:
    • Use native types whenever possible to reduce overhead.
    • Cache converted values if they are reused frequently.
  3. Optimize Callback Functions:
    • Keep callback functions lightweight and avoid blocking operations.
    • Use asynchronous processing for time-consuming tasks.