Skip to content

Code generation

To evaluate a Function on concrete numerical values, scaly generates C code from the symbolic computational graph. This happens both in Python and at the user's request for deployment in a C or C++ application. This allows, for example, a controller in a ROS node to reuse the optimal controller written during prototyping without having to rewrite it. The model is written once, in Python, and both uses compile from it.

This page assumes familiarity with scaly functions and basic C concepts (such as pointers and how to compile C code).

Exported source and compiled functions

Consider a function representing \(E(x)=\sum_i x_i^2\):

import scaly as sc

@sc.function(sc.arg("x", 3), outputs=sc.arg("energy"))
def energy(x: sc.Expr) -> sc.Expr:
    return sc.sumsqr(x)

The decorated energy is an sc.Function. Constructing it records the expression graph without generating or compiling C. There are two ways to use that graph:

  • A numerical Python call automatically generates C, compiles a shared library, and evaluates it. Scaly caches the library for later calls. This is the just-in-time, or JIT, compilation path.
  • Export the Function to source and header files in a specific directory and integrate them with your application's build system. This is the ahead-of-time, or AOT, compilation path.

Both paths use the same code generator. Exporting source does not compile it, and exporting alone does not directly require numerical inputs or a C compiler.

from pathlib import Path
from scaly.codegen import write_module

module = write_module(energy, Path("generated"))
print(module.header_name, module.source_name)
# energy.h energy.c

write_module writes generated/energy.h and generated/energy.c and returns a CModule. This object holds the generated text, filenames, workspace size, the build recipe, and link flags. Existing files with those names are overwritten.

The command-line equivalent refers to the Python object by module and attribute name. If energy is defined in an importable module named model, the command is:

uv run scaly_codegen model:energy -o generated/

model:energy means import model and use its energy attribute. The module must be importable in the command's Python environment. A model.py in the current directory is one way to provide it.

The C header

The generated header declares one struct per input and output, a workspace struct, and an energy_call helper that evaluates the function. These are excerpts from energy.h:

typedef struct { SCALY_ALIGNAS(16) double data[3]; } energy_x_t;
typedef struct { SCALY_ALIGNAS(16) double data[1]; } energy_energy__t;
typedef struct { SCALY_ALIGNAS(16) double data[energy_SZ_W > 0 ? energy_SZ_W : 1]; } energy_workspace_t;

static inline int energy_call(const energy_x_t* x, energy_energy__t* energy_, energy_workspace_t* workspace);

Each struct holds one contiguous buffer of an input's or output's values, double for the default float64. Arrays use row-major order by default, as in a contiguous NumPy array, and a scalar occupies one entry. A leaf declared with sc.arg("A", (2, 3), layout="column-major") stores each matrix by column. For shape (4, 2, 3), the four two-by-three matrices follow one another, with each matrix stored by column. The C header marks such a leaf with <function>_<leaf>_COLUMN_MAJOR 1. Python calls keep ordinary NumPy indexing regardless of buffer order. Python tuple groups do not become extra buffers. The output name becomes energy_ inside the generated interface to avoid a collision with the function name, which accounts for the double underscore in energy_energy__t.

The structs own their storage. You can allocate them on the stack, statically, or on the heap. C11 and C++ buffers request 16-byte alignment, while C99 uses natural alignment. The workspace struct reserves at least one entry because standard C does not allow a zero-length array. The header reports the real requirement as energy_SZ_W, counted in doubles for this function. Float32 and integer functions describes the other data types.

For the energy model above, the following complete C caller supplies one three-element input and reads one scalar output. The compilation command below uses main.c as its filename:

#include <stdio.h>
#include "energy.h"

int main(void) {
    energy_x_t x = {{1.0, 2.0, 3.0}};
    energy_energy__t result;
    energy_workspace_t workspace;

    int status = energy_call(&x, &result, &workspace);
    if (status != 0) return status;
    printf("%g\n", result.data[0]);
    return 0;
}
cc -O2 -Igenerated main.c generated/energy.c -lm -o energy_demo
./energy_demo

The program prints 14. It links the C math library with -lm and needs no Python runtime. The interface reference lists the argument checks and return codes.

The exported files are ordinary C source, so the application compiles them with whatever compiler and build system it already uses, such as CMake, a ROS package or a microcontroller's toolchain. Scaly does not need to be installed where they are compiled. The commands on this page write cc for that compiler. The default GNU dialect needs GCC or Clang, and dialect="c" suits other C99 compilers.

zig cc is one way to compile them where no C compiler is installed. Scaly installs it with the core package on Windows, and the toolchain extra installs it on Linux and macOS, as Installation explains. With the zig-bin package, uv run zig cc takes the place of cc in these commands.

The pointer entry

energy_call is a header-only helper. The compiled symbol underneath it is a pointer-based entry point with the same five arguments for every generated function. For a function whose inputs and outputs are all float64, such as energy, it is:

int energy(const double** arg, double** res, int* iw, double* w, int mem);

arg contains one pointer per input and res one pointer per output, in declaration order. w is the workspace of energy_SZ_W doubles, or NULL when that size is zero. iw and mem are unused and take NULL and 0. The CasADi section explains why they exist. The helper only fills the two pointer arrays and forwards to this entry.

Call the entry directly in two situations. The first is memory that another library already owns, such as a message array in a ROS node or an Eigen matrix, which the typed structs would require copying. The second is loading the compiled library with dlopen and no header, where this signature is the whole contract. The C++ header and the CasADi layer are both built on the same symbol, so every generated module can be called this way.

Solver options in C

A module containing a solver exports an additional <name>_with_options entry. Its first five arguments match the pointer entry. The sixth is const scaly_solver_option* const* solver_options, with one option array per solver. The header's <name>_OPTIONS_<solver> macros give each array's index, and <name>_N_SOLVERS gives the count.

An option array ends with a null name. Each entry has name, kind, integer, number and text fields. Kind 0 uses the int64_t integer, kind 1 uses the double number, and kind 2 uses the string pointer. Names and string values must remain alive throughout the call.

Each <solver>_default_options() accessor returns the backend's complete, read-only default array. Copy this array, including its terminator, before changing entries. SQP's globalization and hessian entries use integers. Their value 1 selects "l1" and "exact", respectively. Value 0 selects the other choice listed in the backend guide.

The typed C helper <name>_call_with_options accepts the same option-pointer array after its workspace argument. The C++ call helper has a corresponding overload. Python supplies validated options automatically. A C or C++ caller must supply names and values valid for the backend and include every entry required by its default array.

The standard five-argument entry and the typed helpers without an option argument use backend tuning defaults. PIQP's sparse and SQP's qp select the compiled matrix interface and are absent from the runtime arrays. Other options supplied to sc.solver affect Python calls without changing the exported source. An ahead-of-time caller supplies its chosen tuning through _with_options.

Float32 and integer functions

A TensorType in place of a shape gives an input or output another data type: float32, int32, int64 or bool. Expressions built from such an input have its type, the generated C stores and computes them in the matching C type, and the entry, the typed buffers and a numerical Python call pass the values in that type too. In this function a Python 2.0 takes the type of x, so the product stays float32 and the C code calls sinf:

import numpy as np

f32 = sc.TensorType((3,), dtype=sc.dtypes.float32)

@sc.function(sc.arg("x", f32), outputs=sc.arg("y"))
def scaled(x: sc.Expr) -> sc.Expr:
    return 2.0 * x.sin()

y = scaled(np.array([0.0, 0.5, 1.0]))
print(y, y.dtype)
# [0.        0.9588511 1.6829419] float32

A numerical call converts each input array to the input's type when the conversion does not turn floats into integers or numbers into bool. A float64 or integer array is accepted for a float32 input, and a bool array for an integer one. A float array for an integer input raises TypeError naming the function and the input. Each output comes back in the type of the expression it returns, float32 for scaled.

The pointer types of the entry follow the types of the inputs and outputs:

Inputs and outputs Entry pointers SZ_W counts
all float64 const double** arg, double** res, double* w doubles
all float32 const float** arg, float** res, float* w floats
any other combination of types const void** arg, void** res, void* w bytes

iw and mem keep their types. A typed buffer holds double, float, int32_t, int64_t or bool, the C type of its input or output, and the C++ header spells the same types, as in Buffer<float, 3>. A bool value is one byte holding 0 or 1. With void pointers, each arg[i] and res[i] must point to values of that input's or output's C type, and w must be aligned to 8 bytes, which malloc guarantees.

This function mixes three types, so its entry takes void pointers:

steps = sc.TensorType((4,), dtype=sc.dtypes.int64)
mask = sc.TensorType((4,), dtype=sc.dtypes.bool_)

@sc.function(sc.arg("x", 4), sc.arg("k", steps), sc.arg("active", mask), outputs=sc.arg("y"))
def gated(x: sc.Expr, k: sc.Expr, active: sc.Expr) -> sc.Expr:
    return sc.where(active, x * sc.cast(k // 2, "float64"), 0.0)

print(gated(np.ones(4), np.array([1, 2, 3, 4]), np.array([True, False, True, True])))
# [0. 0. 1. 2.]

Its header declares:

int gated(const void** arg, void** res, int* iw, void* w, int mem);
typedef struct { SCALY_ALIGNAS(16) double data[4]; } gated_x_t;
typedef struct { SCALY_ALIGNAS(16) int64_t data[4]; } gated_k_t;
typedef struct { SCALY_ALIGNAS(16) bool data[4]; } gated_active_t;
typedef struct { SCALY_ALIGNAS(16) double data[4]; } gated_y_t;
typedef struct { SCALY_ALIGNAS(16) unsigned char data[gated_SZ_W > 0 ? gated_SZ_W : 1]; } gated_workspace_t;

The workspace of such a function is a block of bytes that may hold temporaries of several types. Allocate it with malloc, for example gated_workspace_t* workspace = malloc(sizeof *workspace). Memory from malloc has no declared type, so C's aliasing rules let the function store values of each type in it, which a stack or static unsigned char array does not allow. In C++ an array of unsigned char provides storage for other objects, so gated::workspace_t may live anywhere. A function whose inputs and outputs all have one floating type keeps its workspace in that type, and the stack and static structs shown earlier stay valid for it. Its temporaries of other types stay local arrays, so a large one takes stack space.

Solvers and CasADi stay float64

A module that calls a solver needs float64 inputs and outputs in every function it contains, and casadi=True needs float64 inputs and outputs. Both raise an error otherwise.

Generated text and export options

render_c_module returns a CModule without writing files:

from scaly.codegen import render_c_module

module = render_c_module(energy)
print(module.workspace_size)  # 0
print(module.link_flags)      # ()
header = module.header       # C declarations and buffer metadata
source = module.source       # C implementation

The zero workspace size means this function needs no caller-provided scratch buffer. Its local C variables still occupy memory. Empty link_flags means there are no additional solver or vector-math libraries to link beyond -lm.

The main export choices are:

Python keyword Command-line option Result
lang="c" --lang c C header with typed buffer structs, the default
lang="cpp" --lang cpp C++ header with buffer types and a namespace
casadi=True --casadi Metadata functions for compatible external-function loaders

C++ buffers

lang="cpp" generates a C++17 header. The implementation remains C, which lets the C and C++ interfaces use the same compiled numerical function:

cpp_module = write_module(energy, Path("generated_cpp"), lang="cpp")
print(cpp_module.header_name, cpp_module.source_name)
# energy.hpp energy.c

The header contains a Buffer template with inline storage and compile-time shape information. Its declaration begins as follows, with indexing methods omitted from this excerpt:

template <typename T, std::size_t... Ns>
struct alignas(16) Buffer {
  static constexpr std::size_t ndim = sizeof...(Ns);
  static constexpr std::array<std::size_t, ndim> shape = {Ns...};
  static constexpr std::size_t size = (Ns * ... * std::size_t{1});
  T data[size > 0 ? size : 1];
  T* ptr() { return data; }
  const T* ptr() const { return data; }
  // Indexing methods omitted.
};

Buffer<double, 2, 3> holds a two-by-three matrix in row-major order. buffer(i, j) accesses an element, with bounds assertions in debug builds. Column-major leaves use a separate ColumnMajorBuffer type. buffer(i, j) still selects row i, column j. Buffer<double> is a scalar, accessed as buffer(). Empty buffers reserve one physical element while reporting a logical size of zero.

Each function has its own namespace. For energy, the generated aliases are:

using x_t = Buffer<double, 3>;
using energy__t = Buffer<double>;
using workspace_t = Buffer<double, energy_SZ_W>;

These aliases live inside namespace energy. The output alias contains two underscores for the same name-collision reason as the typed C struct. A complete main.cpp using this header is:

#include <iostream>
#include "energy.hpp"

int main() {
    energy::x_t x = {{1.0, 2.0, 3.0}};
    energy::energy__t result{};
    energy::workspace_t workspace{};
    int status = energy::call(x, result, workspace);
    if (status != 0) return status;
    std::cout << result() << '\n';
}
cc -O2 -c generated_cpp/energy.c -o energy.o
c++ -std=c++17 -Igenerated_cpp main.cpp energy.o -lm -o energy_cpp_demo
./energy_cpp_demo

This prints 14. The energy::call helper constructs pointer arrays and calls the C entry point, declared with extern "C" linkage. Buffer owns its data. To use arrays already owned by another library without copying them into a Buffer, call the pointer interface directly.

Sparse output patterns

Sparse derivative functions write only the stored values of the matrix, in the output's declared layout. The generated header contains the fixed index tables of that layout, so the caller can identify the matrix entry of each value. For example:

@sc.function(sc.arg("x", 3), outputs=sc.arg("y"))
def measurements(x: sc.Expr) -> sc.Expr:
    return sc.stack([x[0] * x[2], x[1], x[0] + x[2]])

measurements_jac = sc.sparse_jacobian(measurements, "y", "x", name="measurements_jac")
sparse_module = write_module(measurements_jac, Path("generated_sparse"))

The Jacobian is

\[ J(x)=\begin{bmatrix}x_2 & 0 & x_0 \\ 0 & 1 & 0 \\ 1 & 0 & 1\end{bmatrix}. \]

Its output has five values instead of nine. The default layout is compressed sparse column, or CSC, and measurements_jac.h declares:

#define measurements_jac_spjac_y_x_NROW 3
#define measurements_jac_spjac_y_x_NCOL 3
#define measurements_jac_spjac_y_x_NNZ 5
#define measurements_jac_spjac_y_x_NDIM 2
#define measurements_jac_spjac_y_x_STORAGE_SIZE 5
static const int measurements_jac_spjac_y_x_shape[2] = {3, 3};
static const int measurements_jac_spjac_y_x_csc_col_ptr[4] = {0, 2, 3, 5};
static const int measurements_jac_spjac_y_x_csc_row_ind[5] = {0, 2, 1, 0, 2};

The prefix combines the function name and derivative output name. For input [2, 3, 4], the value array is [4, 1, 1, 2, 1]: column 0 holds \(J_{00}=4\) and \(J_{20}=1\), column 1 holds \(J_{11}=1\), and column 2 holds \(J_{02}=2\) and \(J_{22}=1\). With layout="csr" the header publishes csr_row_ptr and csr_col_ind and the values come row by row, and with a coordinate layout it publishes rows and cols in the declared order. These are the tables of any sparse output.

C++ headers expose the same tables as constexpr arrays inside a namespace named after the derivative output. The sparse derivatives guide explains the layouts and the triangle choices for Hessians.

CasADi-compatible exports

The pointer entry takes the same five arguments, in the same order, as a function generated by CasADi 3.8, including its integer-workspace argument iw and its memory-handle argument mem. Scaly functions are stateless, so both are ignored. The one difference in the signature is iw, which Scaly declares as int* where CasADi uses casadi_int*. Because the entry never reads it, CasADi and acados can pass their own pointer unchanged. The signature alone does not tell a loader how many inputs a function has or how large its buffers are. casadi=True adds the metadata queries of CasADi's external-function interface:

casadi_module = write_module(energy, Path("generated_casadi"), casadi=True)

For this model, the additional header declarations include:

casadi_int energy_n_in(void);
casadi_int energy_n_out(void);
const char* energy_name_in(casadi_int i);
const char* energy_name_out(casadi_int i);
const casadi_int* energy_sparsity_in(casadi_int i);
const casadi_int* energy_sparsity_out(casadi_int i);
int energy_work(casadi_int* sz_arg, casadi_int* sz_res, casadi_int* sz_iw, casadi_int* sz_w);
int energy_checkout(void);
void energy_release(int mem);

energy_n_in() and energy_n_out() both return one. The name queries return "x" and "energy" at index zero. The sparsity queries describe a dense three-by-one input and one-by-one output. energy_work returns the required pointer-array and workspace sizes. energy_checkout() returns zero and energy_release does nothing because scaly does not use memory handles.

For example, on Linux this command builds a shared library for CasADi:

cc -O2 -fPIC -shared generated_casadi/energy.c -lm -o generated_casadi/libenergy.so

With CasADi installed, it can then be loaded and called:

import casadi as ca

external_energy = ca.external("energy", "generated_casadi/libenergy.so")
print(float(external_energy([1.0, 2.0, 3.0])))  # 14.0

The generated casadi_int macro defaults to long long int, matching CasADi's loader. Consumers expecting another integer width need a matching build. An acados build must pass -Dcasadi_int=int through ocp.code_gen_options.ext_fun_compile_flags, as the acados race-car example does. The option works with either lang="c" or lang="cpp".

CasADi reads sparse values in CSC order, so this mode accepts sparse outputs, sparse derivatives included, only in the default CSC layout and refuses a CSR or coordinate layout.

With casadi=True, dense matrix buffers use column-major order. The original Function declaration and Python calls keep their behavior. Both header languages describe this order. The work query includes any scratch space needed to change buffer order.

Jacobian rows and columns still number matrix outputs and inputs in Scaly's row-major order. A CasADi consumer that numbers matrix entries by column must reorder the corresponding Jacobian rows and columns.

Buffer compatibility

Dense arrays of rank greater than two are rejected by casadi=True. Scalars, vectors, dense matrices and sparse matrices, sparse derivatives included, in CSC order without batch axes are supported, all in float64. Solvers require row-major declarations for dense variables and parameters. To export solver.function with matrix arguments or results for CasADi, call it inside an ordinary Function and export that wrapper. Direct export is refused. An input or output with no values accepts a null pointer. Other inputs and outputs require pointers, while CasADi treats a null input as zero and skips a null output. The arg and res pointer arrays must always be non-null.

The example uses this interface for discrete dynamics and external costs, including their sparse Jacobians and exact Hessians. Its build_scaly_solver helper takes functions that express the next state and stage cost in Scaly. It generates an acados solver with shaped zero CasADi placeholders and replaces the dynamics and cost sources with Scaly modules before compilation. acados reads the actual sparsity and workspace requirements from Scaly's queries, so the placeholders need only the correct shapes. The example solves race-car tracking with acados' sequential quadratic programming solver. The example's docstring gives the setup for acados-wheels 0.6.0, which requires access to its private repository.

Solver export and deployment dependencies

write_module and scaly_codegen accept an sc.Solver as well as a Function. They export the solver's underlying solve.function, including its objective, constraints, derivatives, and native solver wrapper. render_c_module and workspace_size take a Function, so those calls use solve.function explicitly.

The C interface always requires the initial variables, initial multipliers, and parameters. Defaults supplied by the Python Solver wrapper do not become defaults in C. Generated solver wrappers also keep static state, so the same wrapper cannot be called concurrently.

Ordinary generated model code has no dependency on Python, NumPy, or SciPy. Solver exports include a <name>.build.json manifest and wrapper sources under libraries/. Compile those sources separately, using each library record's include_dirs, defines and compile_flags, then link their objects with the main source. library_dirs gives the search directories for the names in link. The first include directory and all source paths are relative to the manifest. Other directories describe the exporting machine and may need changing for a deployment target.

Solver code also needs the corresponding native solver headers and libraries. The CModule.link_flags property reports the flags discovered for those dependencies and the selected math library in the current environment. Reading it requires the libraries to be available, even though rendering their wrapper source does not. For deployment on another machine, provide libraries and paths appropriate to that target. On Windows these flags contain no rpath, so an executable finds the solver DLLs at run time as described in Solver backends.

Prints in exported C

A function containing sc.print calls SCALY_PRINTF with a printf format and its values. Its source includes <stdio.h> and defines SCALY_PRINTF as printf, unless the macro is already defined, so a definition on the compiler command line replaces every print:

cc -c model.c '-DSCALY_PRINTF(...)='
cc -c model.c -DSCALY_PRINTF=uart_printf -include uart.h

The first command removes the prints, and the values they return are still computed. The second sends each print to uart_printf, a function taking the same arguments as printf, such as one writing to a serial port on a microcontroller. Its declaration has to come from somewhere, here a header given to -include, because the source includes <stdio.h> only for its own definition. A source without prints does not mention SCALY_PRINTF.

Embedded builds

An oracle Function, such as a dynamics model or its Jacobian, can be compiled for a bare-metal Cortex-M4F or M7. Its generated C needs no Python runtime or heap. Supply the target's math library for any libm calls it uses.

The embedded oracle example exports the nonlinear stages from the mapped-derivatives example and their sparse Jacobian in float32. The .block() hint forces a workspace for this check, and omitting it gives an object of 206 bytes with no workspace. From a repository checkout, generate it with a zero frame budget and compile it with Zig:

PYTHONPATH=. uv run scaly_codegen examples.embedded_oracle:oracle -o examples/generated/embedded \
    --dialect c --lanes 1 --frame-budget 0
uv run --extra toolchain zig cc -target thumb-freestanding-eabihf \
    -mcpu=cortex_m4+vfp4d16sp -ffreestanding -Os \
    -fno-unwind-tables -fno-asynchronous-unwind-tables \
    '-DSCALY_PRINTF(...)=' -Iexamples/embedded \
    -c examples/generated/embedded/embedded_oracle.c -o examples/generated/embedded/oracle.o
size examples/generated/embedded/oracle.o
nm --undefined-only examples/generated/embedded/oracle.o

Zig spells Clang's thumbv7em-none-eabihf target as thumb-freestanding-eabihf with the CPU selected separately. The vfp4d16sp feature enables the single-precision floating-point unit. Use -mcpu=cortex_m7+vfp4d16sp for the M7. Disabling unwind tables avoids an unwind-runtime dependency, and SCALY_PRINTF removes generated prints. Zig supplies no bare-metal math.h, so the example includes declarations of its two libm functions in examples/embedded/math.h. An application can use its board toolchain's math header instead. This object needs only sinf and cosf, which the final application must link from a hard-float target libm.

frame_budget=0 in Python, or --frame-budget 0 on the command line, sends every eligible temporary array to the caller's w[]. Allocate embedded_oracle_SZ_W floats for this example, plus eight input floats, eight output values and sixteen Jacobian values. These buffers may have static storage. The generated header describes the Jacobian's stored entries. This oracle has no integer or boolean temporary arrays. In a function whose inputs and outputs all have one floating type, temporary arrays of another type stay on the stack even at zero frame budget. Scalar locals, saved registers, return addresses and libm also need stack space.

The Linux size command reports the object sections. Flash usage is text + data, and static RAM is data + bss. Add the caller's input, output and workspace buffers to RAM. These object sizes exclude the target libm, startup code and application stack, and do not measure a complete firmware image. The continuous integration build records the object sizes and caller buffer requirements for both targets without executing on hardware.

CPU targets, vector lanes, and math libraries

A generated module carries a BuildRecipe in module.recipe. It records the CPU target, lane width, C dialect, and numerical options used to generate the source. The header and source begin with a comment showing matching GCC and Clang compilation commands and required math libraries.

A lane is one element processed alongside other elements in a vector operation. Scaly can group independent loop iterations into lanes without changing the function's input and output layout. For example:

vector_module = render_c_module(
    energy, cpu="x86-64-v3", lanes=4, dialect="gnu", vector_libm="glibc",
)
print(vector_module.recipe.cpu_flags)  # ('-march=x86-64-v3',)
print(vector_module.link_flags)        # ('-lmvec',)

These options request a target configuration. They do not guarantee that a particular small model uses vector instructions. The compiler widens eligible loops and can reduce their width to limit register pressure.

CPU baseline and lane width

Export defaults to cpu="generic", which adds no target-specific compiler flags, and lanes="auto". Automatic widths follow the target macros set by the C compiler:

Target features Requested lanes
AVX-512 8
AVX or fixed Arm SVE width of at least 256 bits 4
SSE2 or AArch64 2
Other targets 1

A fixed lanes=1, 2, 4, or 8 bypasses this detection. One lane disables widening. With automatic selection, -DSCALY_LANES=4 can select the width at C compilation time. Partial groups use guarded stores, and input and output arrays need no extra padding. Workspace capacity supports up to eight lanes, so changing the lane selection does not change the header's workspace size.

cpu="native" selects -march=native on x86 or -mcpu=native on AArch64. The other named targets are x86-64-v3, x86-64-v4, and apple-m4. module.recipe.cpu_flags gives their matching flags.

The target belongs to the application build

Export records the requested target without checking the deployment machine. Compile with the recipe's flags and choose a baseline supported by every destination. A native binary is intended for the build machine, rather than an arbitrary computer with the same operating system.

C dialect and vector math

dialect="gnu" is the default and uses GNU vector extensions supported by GCC and Clang. dialect="c" emits scalar lane loops in C99 syntax for compilers without those extensions. This option is separate from lang, which chooses the language of the caller's header.

Export defaults to vector_libm="none", so each lane uses scalar math calls. vector_libm="glibc" selects supported libmvec implementations of sin, cos, tan, exp, log, pow, and tanh. It requires the GNU dialect, x86-64 with compatible target features, and glibc. tanh additionally requires glibc 2.35 or newer. Generated guards reject incompatible builds. The recipe adds -lmvec, in addition to the usual -lm math-library link, and its Clang command adds -fveclib=libmvec, so math calls in loops that Clang vectorizes on its own also use libmvec. GCC has no such option.

Vector math can produce different rounded results from scalar math. Likewise, reciprocal=True allows invariant division x / y to become multiplication by a precomputed 1 / y, which changes rounding and can change overflow. Both are separate numerical choices. Neither enables general fast-math flags. See arithmetic semantics for simplifications that apply independently of these options.

A product becomes zero when the compiler proves that its two factors cannot both be nonzero at any entry:

import numpy as np

@sc.function(sc.arg("x", 2), outputs=sc.arg("y"))
def zero_product(x: sc.Expr) -> sc.Expr:
    return (x * sc.const([1, 0])) * sc.const([0, np.nan])

print(zero_product(np.ones(2)))  # [0. 0.]

NumPy evaluates (np.ones(2) * [1, 0]) * [0, np.nan] as [0, nan].

The same choices are available to scaly_codegen as --cpu, --lanes, --dialect, --vector-libm, and --reciprocal. The render-options reference lists their values.

Compilation and caching in Python

A numerical call compiles on first use unless a matching artifact already exists in the disk cache. Scaly then keeps the loaded library in memory. The Python compiler detects a native CPU recipe with a fixed lane width. On supported glibc x86-64 hosts it also selects vector math automatically. SCALY_VECTOR_LIBM=none forces scalar math calls, as described in environment settings. The cache key includes the generated source and its recipe, function name, interface version, the compiler command and its flags, and the compiler and CPU it runs on. New numerical inputs do not cause recompilation. Another compiler, an upgraded one at the same path, or another machine sharing the cache directory compiles a fresh library.

compile() prepares the library without evaluating the function:

energy.compile()

This is useful before a time-sensitive calculation. It also reuses a cached library when available. For a solver, solve.function.compile() prepares the compiled wrapper without running an optimization.

The default cache directory is $XDG_CACHE_HOME/scaly/jit, or ~/.cache/scaly/jit when XDG_CACHE_HOME is unset, which on Windows is .cache\scaly\jit in the user's profile directory. SCALY_CACHE_DIR overrides it. Deleting cached files does not invalidate an already loaded function, but future compilations rebuild missing artifacts.

energy.recompile()

Despite its name, recompile() drops the function's compiled handle without compiling. The next numerical call or explicit compile() in this process uses another path in the cache, and the old handle stays callable beside the new one. Nothing is deleted, so a process still building or loading the old library is unaffected. Other processes, including ones started later, keep reusing it. Scaly writes each library under a temporary name and renames it into place, so no process finds a partial one.

Because recompile() preserves existing libraries, it also works on Windows, which refuses to replace or delete a loaded DLL. Scaly does not prune the cache on any platform, so old libraries remain after the process exits. To reclaim the space, or make all processes build again, stop the processes using the cache and delete its directory before starting them again.

Scaly does not fall back to Python evaluation when compilation fails. uv run scaly_toolchain reports the compiler, cache directory, and resolved solver libraries when diagnosing such a failure.

Working memory

workspace_size obtains the caller-provided workspace requirement without rendering all the generated text:

from scaly.codegen import workspace_size

print(workspace_size(energy))  # 0

The number counts entries of w, doubles for a function whose inputs and outputs are all float64, floats when they are all float32, and bytes otherwise. It is not the total temporary memory. For an export with non-default options, use that module's workspace_size or its header's <name>_SZ_W constant. CasADi exports can require additional workspace to change dense matrix buffer order.

frame_budget limits the stack each generated C function is estimated to need, in bytes, and defaults to 65,536. Scaly adds up its temporary arrays, counting a called Function's arrays once for each call that can be inlined, plus 4,096 bytes for scalars the C compiler keeps on the stack. While the total is over the budget, Scaly moves the largest eligible array into w, so a smaller budget raises <name>_SZ_W. frame_budget=0, or --frame-budget 0, moves every temporary array eligible for its workspace dtype. The 4,096 bytes remain, so a budget below that behaves like zero.

module.frame_bytes and <name>_FRAME_BYTES give the largest estimate over the generated C functions. A function that is not inlined adds its own frame on top of its caller's, and that sum is not included. The estimate also excludes the stack used by external C functions and solver libraries. Check the compiled frames with -Wframe-larger-than=<budget> -Werror under the application's own compiler flags.