Skip to content

Generated code and compilation

This page opens up the C file that Scaly writes and follows it through the compiler and the Python cache. The code generation guide covers how to export, build and call a module, including the typed headers, the sparse coordinate tables and the CasADi queries. This page explains what is inside the files and why they are built the way they are.

An annotated module

Here is a stage function mapped over a ten-step horizon. The .block() hint keeps euler in loop form, so it survives lowering as its own procedure instead of being expanded into its caller (see lowering for the hints):

import scaly as sc
from scaly.codegen import render_c_module

@sc.function(sc.arg("z", 2), sc.arg("u", ()), outputs=sc.arg("znext"))
def euler(z: sc.Expr, u: sc.Expr) -> sc.Expr:
    return sc.stack([z[0] + 0.1 * z[1], z[1] + 0.1 * u.sin()]).block()

@sc.function(sc.arg("zs", 20), sc.arg("us", 10), outputs=sc.arg("zn"))
def rollout(zs: sc.Expr, us: sc.Expr) -> sc.Expr:
    return sc.vmap(euler, 10)(zs.reshape((10, 2)), us).vec()

print(render_c_module(rollout, lanes=1).source)

lanes=1 turns off vector widening so the listing stays short. This is the printed rollout.c:

/* Scaly build recipe
 * CPU baseline: generic
 * lanes=1, dialect=gnu, vector_libm=none, reciprocal=False
 * Math library: scalar libm
 * gcc -O3 -fno-math-errno -c rollout.c
 * clang -O3 -fno-math-errno -c rollout.c
 * Link with: -lm
 */
#include "rollout.h"

#include <math.h>
#include <stddef.h>
#include <stdint.h>
typedef double double2 __attribute__((vector_size(16), aligned(8), may_alias));

#define SCALY_SUCCESS 0
#define SCALY_ERR_NULL_ABI 1
#define SCALY_ERR_NULL_WORK 2
#define SCALY_ERR_NULL_RESULT 3
#define SCALY_ERR_NULL_INPUT 4

#ifdef __cplusplus
extern "C" {
#endif

static inline void euler_raw(const double* z, const double* u, double* znext, double* w) {
  (void)w;
  const double* t0 = z;
  const double* t2 = z + 1;
  double v0 = t2[0];
  *(double2*)(znext) = (double2){(t0[0] + (0.10000000000000001 * v0)), (v0 + (0.10000000000000001 * sin(u[0])))};
}

int rollout(const double** arg, double** res, int* iw, double* w, int mem) {
  (void)iw;
  (void)mem;
  if (!arg || !res) return SCALY_ERR_NULL_ABI;
  (void)w;
  if (!arg[0]) return SCALY_ERR_NULL_INPUT;
  if (!arg[1]) return SCALY_ERR_NULL_INPUT;
  if (!res[0]) return SCALY_ERR_NULL_RESULT;
  for (long long it_zn = 0; it_zn < 10; ++it_zn) {
    euler_raw((arg[0] + (2 * it_zn)), (arg[1] + it_zn), (res[0] + (it_zn * 2)), NULL);
  }
  return SCALY_SUCCESS;
}

#ifdef __cplusplus
}
#endif

The sections below take this file apart from top to bottom.

Build recipe

The comment at the top is the BuildRecipe the module was rendered for. It names the CPU baseline, the lane width, the C dialect, the math library and the reciprocal policy, and it gives a GCC and a Clang command that build the file the way Scaly expects. The same comment opens the header.

The recipe is not advice. Rendering made decisions that only hold under its flags. With lanes="auto", the lane width is chosen by the preprocessor from the target macros the compiler defines, so building without the recipe's -march flag silently gives narrower vectors. With glibc vector math the generated guards refuse to build at all. For a 64-element sin rendered with cpu="x86-64-v4", lanes=8, vector_libm="glibc":

$ cc -O3 -fno-math-errno -c waves.c
waves.c:45:2: error: #error "vector_libm=glibc width 8 requires __AVX512F__ and -lmvec"
$ cc -O3 -march=x86-64-v4 -fno-math-errno -c waves.c

The second command, with the recipe's flags, compiles. -fno-math-errno lets the compiler inline and vectorize calls such as sqrt, because they no longer have to set errno. Scaly never enables general fast-math flags. The guide lists the recipe options and their values.

Module layout

Every module has a main C source and one header. The source contains, in order:

  1. The recipe comment and, for a C header, #include of that header.
  2. System includes, vector typedefs and lane macros, and the status codes.
  3. One static inline void <callee>_raw(...) per retained callee.
  4. The exported entry, named after the root function.

Distinct functions may share a Python name. Scaly gives their internal C procedures distinct names and keeps each call attached to its own function. The exported entry keeps its C identifier, so a clash with a reserved name such as log raises ValueError before compilation. Buffer and loop names are renamed when needed, including C++ keywords in typed headers.

A _raw procedure takes one pointer per input, const-qualified, one pointer per output and a trailing workspace pointer. It has no null checks and no status code, so the entry pays for those once rather than at every stage. A callee that lowering expanded into its caller has no _raw at all. Without .block(), euler is expanded into the loop in rollout and euler_raw disappears, so function boundaries in Python do not promise C procedures.

euler_raw is called with w = NULL because it spills nothing to the workspace. When a callee does spill, the caller passes its own w advanced past its own spill window. The workspace pointer has the entry's type, double* here, and void* for a function whose inputs and outputs mix types.

A module that reaches a solver also exports a build manifest and external wrapper sources. Each wrapper compiles with its oracles as a separate C translation unit. It links to the plugin's native library and exports a solve function, an option setter and a statistics accessor. The host entry validates its arguments, supplies the option arrays, then calls its ordinary generated body. Solvers describes what each wrapper does.

The pointer ABI

The standard entry has the same five-argument signature for every function, and the guide explains each argument. A module containing a solver also exports a _with_options entry with a sixth argument for call-time option arrays. Python uses this entry. The standard entry calls it with backend defaults. The option interface describes the arrays and their lifetime. rollout has float64 inputs and outputs, so its pointers point to double. A function whose inputs and outputs are all float32 takes float pointers, and any other mix of float64, float32, int32, int64 and bool takes void pointers, which the entry casts once per input and output to its C type. The guide shows both. The body starts with null checks that return one of five codes:

Code Value Returned when
SCALY_SUCCESS 0 evaluation finished
SCALY_ERR_NULL_ABI 1 arg or res is null
SCALY_ERR_NULL_WORK 2 w is null and the header's SZ_W is not zero
SCALY_ERR_NULL_RESULT 3 an output that holds values has a null pointer
SCALY_ERR_NULL_INPUT 4 an input that holds values has a null pointer

An input or output with no stored values may have a null pointer. The arg and res pointer arrays must still be non-null.

rollout has no workspace, so its entry casts w away and the SCALY_ERR_NULL_WORK check is absent. The checks cannot tell whether a non-null pointer refers to a buffer that is large enough. A solver that fails to converge still returns SCALY_SUCCESS. Convergence is reported in the statistics described below.

Alignment

The pointer interface needs only the natural alignment of each input's and output's type, and a void* w needs 8 bytes, the alignment of its widest temporaries. Stores of several values at once go through vector types declared aligned(8) and may_alias, like the double2 store in euler_raw, so the compiler never assumes 16-byte alignment of a caller's buffer. The typed structs in the header request 16 bytes regardless. Every temporary that lowering moves to the workspace starts at a multiple of 64 bytes from w, with the padding counted in SZ_W, so a workspace aligned to 64 bytes gives every temporary a cache-line boundary.

C++ linkage

The source wraps everything in extern "C" when compiled as C++, so the file builds with either compiler and exports the same symbol. The C++ header declares the entry extern "C" inside a namespace of the same name, so rollout::rollout is the symbol a C caller reaches as rollout. With lang="cpp" the source does not include the header, because a C++ header cannot be included from C.

Sparse outputs

A sparse derivative output is a sparse matrix output like any other: its buffer holds the stored values in the output's declared layout, compressed-column by default, and the header publishes that layout's index tables. Inside the graph the values follow the row-major order of the pattern, and lowering writes them in the declared order at the boundary. When the computed order already matches the declared one, as for a solver oracle that declares the order its derivative is computed in, the values are written straight into the output. The guide shows the tables for a concrete Jacobian, and versioning says what a release promises about them.

The CasADi layer

CasADi expects sparse values in compressed-column order, the default layout, so casadi=True writes every sparse output directly into res and refuses a CSR, coordinate or batched sparse output. The header's sparsity tables and the sparsity_out query describe the same compressed-column order.

casadi.external passes a null pointer for an input or output with no values. The sparsity queries keep its declared dimensions, with a shape (0,) reported as a zero-by-one column vector.

Solver statistics

A module that reaches a solver defines a fixed-width statistics struct in its header and exports one <solver>_stats accessor per wrapper, which copies the latest statistics of that wrapper into the caller's struct. The struct as it appears in the header:

#define SCALY_SOLVER_STATS_VERSION 3
#define SCALY_SOLVE_OK 0
#define SCALY_SOLVE_ACCEPTABLE 1
#define SCALY_SOLVE_MAX_ITER 2
#define SCALY_SOLVE_PRIMAL_INFEASIBLE 3
#define SCALY_SOLVE_DUAL_INFEASIBLE 4
#define SCALY_SOLVE_NUMERICS 5
#define SCALY_SOLVE_USER_STOP 6
#define SCALY_SOLVE_ERROR 7
typedef struct {
  int32_t version;
  int32_t status;
  int32_t native_status;
  int32_t iter;
  double obj;
  double t_total;
  double t_fe;
  double t_solver;
  double t_qp;
  double t_globalization;
  double t_glue;
  int32_t n_eval_f;
  int32_t n_eval_grad_f;
  int32_t n_eval_g;
  int32_t n_eval_jac_g;
  int32_t n_eval_h;
  int32_t _pad0;
  double primal_viol;
  double step_inf;
  double alpha;
  double merit_penalty;
  int32_t backtracks;
  int32_t qp_iter;
} scaly_solver_stats;

The layout is 136 bytes, and new fields are only ever appended with a version bump. version is zero until the wrapper has run once. Fields a backend has no use for stay zero.

Times are in seconds from a monotonic clock. t_fe is function evaluation, and the wrapper sets t_glue so that t_total = t_fe + t_solver + t_qp + t_globalization + t_glue. PIQP puts its time in t_qp, IPOPT puts the time spent outside oracle calls in t_solver, and Scaly SQP splits t_qp from the line search in t_globalization.

The statistics live in a static variable next to the wrapper's native workspace, which is why a wrapper must not be called concurrently.

Compilation and caching

A numerical call such as energy(x) reaches the same render_c_module as an export, with a recipe for the host machine. The first call on a fresh cache compiles a shared library, and later processes reuse it. Timing energy.compile() for the energy function from the guide, with SCALY_CACHE_DIR=/tmp/scaly-cache:

import time

start = time.perf_counter()
energy.compile()
print(f"compile() took {1e3 * (time.perf_counter() - start):.0f} ms")

On a fresh cache the first process prints 42 ms. A second process loads the library the first one built, as What a new process reuses explains. Running it again with SCALY_CC_OPT=-O3 prints 41 ms, because the new flag gives a new cache key and a new compilation. The cache root then holds one directory per key, each with energy.c and libenergy.so.

The host recipe

The JIT probes the compiler once per process with cc -march=native -dM -E and reads the target macros. The widest supported vector width becomes a fixed lane count, 8 with AVX-512, and glibc vector math is selected on x86-64 with glibc 2.35 or newer. On such a machine the rendered recipe reads:

/* Scaly build recipe
 * CPU baseline: host-local native CPU
 * lanes=8, dialect=gnu, vector_libm=glibc, reciprocal=False
 * Math library: glibc x86-64; tanh requires glibc >= 2.35
 * gcc -O3 -march=native -fno-math-errno -c energy.c
 * clang -O3 -march=native -fno-math-errno -fveclib=libmvec -c energy.c
 * Link with: -lmvec -lm
 */

The compiler commands in the recipe are for building an exported module ahead of time. The JIT itself compiles with -O2 -march=native -fno-math-errno, or with SCALY_CC_OPT in place of -O2, then, with Clang or zig cc on x86-64, the flags that turn off vector gather loads where the compiler accepts them and -fveclib=libmvec when libmvec is selected, followed by -fPIC -shared, the solver include, library and rpath flags, -lmvec when selected, and -lm.

The cache key

The key is a SHA-256 hash over, in order:

  • a cache version string in src/scaly/codegen/jit.py, raised whenever generated code or the cache layout changes
  • the pointer ABI signature
  • the function's name
  • the rendered translation unit, which includes the recipe comment
  • a fingerprint of the compiler and host: the compiler command, the real path, size and modification time of its executable, its --version output, and the macros the host recipe probe prints, which name the CPU features and the compiler version
  • for external libraries, fingerprints of their sources, headers, compilation flags and resolved native dependencies
  • the full compile command apart from file paths: the optimization, CPU and -fno-math-errno flags, -fPIC -shared, the link flags (solver paths, -lmvec) and -lm

Changing the optimization level therefore gives a new key, as the -O3 run shows. So does pointing SCALY_CC at another compiler, replacing the compiler or a wrapper script at the same path, or running on a CPU with other features. Each build checks the compiler's path, size and modification time, and runs the compiler again for the fingerprint only when one of them has changed. Numerical inputs and runtime solver tuning never enter the key. Structural choices such as PIQP's sparse and SQP's qp change the generated interface and therefore the key.

Where artifacts live

Each key gets a directory under the cache root holding the exact source that was compiled, as <name>.c, and lib<name>.so (.dylib on macOS). Both are written to a file named after the process ID and then renamed into place, so parallel test workers building the same function on a cold cache never see a half-written library.

What a new process reuses

A new process runs the Python body again when the decorator executes, which traces the graph. It does not lower or render the graph when the cache already holds its library. An index under the cache root maps a second key to each library, computed from the traced graph instead of the rendered C. That key is a SHA-256 hash over:

  • a digest of the Function: its name, its input and output names, and every node's operation, type, name, lowering hint, constant values and attributes, with each called Function taken by its own digest
  • a digest of the installed source of Scaly and of its solver plugins, which are the code that renders the C, with the Scaly, Python and NumPy versions
  • the cache version, the ABI signature, the host recipe, the compiler fingerprint, external-library fingerprints and the compile command, as in the cache key above, and the glibc version when glibc vector math is selected

A process that finds an entry loads the library it names, without lowering, rendering or compiling the host graph or its solver oracles. Library fingerprints still inspect declared headers and linked dependencies. A miss renders the host C and solver bridges with the selected recipe, compiles them when the library is not there yet, and records the library in the index. An entry names its library by its path inside the cache root, so the cache can be moved, and a library deleted behind an entry is rendered and compiled again. Entries are written like the libraries, to a temporary file renamed into place.

A graph the digest cannot encode has no index key and is rendered as before. So is a function that a scaly.viz recording watches, and a function this process recompiled. A process whose Scaly or plugin source files changed after Scaly was imported, or less than two seconds before, renders every graph, because the files on disk may then not be the code that runs. Python writing bytecode into those directories does not count as a change. Within a process, a second Function with the same generated source finds the artifact in an in-memory table and does not touch the compiler either.

compile() does all of this and loads the library without evaluating. It opens the library with ctypes, resolves the entry and any _stats accessors, and keeps the handle on the Function. On Linux a library that reaches a solver is opened with dlmopen into a separate linker namespace, shared with other external kernels whose dependency names resolve to the same paths and contents. Conflicting versions use different namespaces. Each namespace starts with libc and loads kernels with ordinary local symbol lookup, so solver dependencies stay isolated from Python packages.

recompile() drops the handle and advances a process-local counter. It does not compile. The next call or compile() uses another cache path whose key includes the process ID and counter. That build renders the C, and leaves the index entry other processes use as it was. Existing directories remain available to processes still building or loading the library, and existing handles stay callable. Scaly does not prune these files. Delete them only when no process uses the cache.

The module map in the codebase points to the files behind each stage.