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
Functionto 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:
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;
}
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:
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
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:
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:
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:
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:
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.
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:
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.