Skip to content

Code generation

write_module writes a function's C source and header to disk. The rendering functions return those artifacts in memory. See the code generation guide for an example that compiles and calls the result.

Normal numerical calls to a Function compile and cache it automatically. Use Function.compile() to prepare it before the first evaluation. The compilation and toolchain interfaces below support direct control of that process.

write_module accepts a Function or a Solver. Other rendering functions and workspace_size take a Function. Pass solve.function when exporting a solver.

Rendering

CModule dataclass

One rendered function: the header and the .c to write, plus what a consumer needs to compile and call them. body is the translation unit. program is the optimized Program IR that produced it. workspace_size is the entry's w[] length, and backends lists the solver plugins that the function calls. lang picks the header language ("c" or "cpp"), and casadi adds the CasADi-compatible symbols.

header, source and link_flags are rendered on first access. The JIT compiles body and asks for none of them. For a big sparse function the header alone is larger than the source.

Source code in src/scaly/codegen/aot.py
@dataclass(frozen=True)
class CModule:
  """One rendered function: the header and the ``.c`` to write, plus what a consumer needs to
  compile and call them. ``body`` is the translation unit. ``program`` is the optimized Program IR
  that produced it. ``workspace_size`` is the entry's ``w[]`` length, and ``backends`` lists the
  solver plugins that the function calls. ``lang`` picks the header language (``"c"`` or
  ``"cpp"``), and ``casadi`` adds the CasADi-compatible symbols.

  ``header``, ``source`` and ``link_flags`` are rendered on first access. The JIT compiles ``body``
  and asks for none of them. For a big sparse function the header alone is larger than the source.
  """

  fun: ConcreteFunction
  header_name: str
  source_name: str
  body: str
  program: ProgramNode
  workspace_size: int
  backends: tuple[str, ...]
  lang: str = "c"
  casadi: bool = False
  recipe: BuildRecipe = BuildRecipe()

  _libraries: tuple[CLibrary, ...] = ()

  @cached_property
  def _library_builds(self) -> tuple[LibraryBuild, ...]:
    if not self._libraries:
      return ()
    compiler = find_c_compiler()
    if compiler is None:
      raise RuntimeError("external libraries need a C compiler")
    return tuple(prepare_library(library, compiler.command) for library in self._libraries)

  @cached_property
  def frame_bytes(self) -> int:
    """Largest estimated procedure frame in bytes, including inlined locals and scalar spills.

    External C functions and solver implementations are outside this estimate.
    """
    return _frame_bytes(self.program)

  @cached_property
  def header(self) -> str:
    return self.recipe.comment(self.source_name) + _render_header(
      self.fun, self.backends, self.workspace_size, self.frame_bytes, lang=self.lang, casadi=self.casadi
    )

  @cached_property
  def source(self) -> str:
    """The ``.c`` as written: ``body`` behind an include of the paired C header. A C++ header
    cannot be included from C, so under ``lang="cpp"`` the kernel is ``body`` alone."""
    if self.lang == "cpp":
      return self.body
    include = self.header_name.replace("\\", "\\\\").replace('"', '\\"')
    recipe = self.recipe.comment(self.source_name)
    return recipe + f'#include "{include}"\n\n' + self.body.removeprefix(recipe)

  @cached_property
  def link_flags(self) -> tuple[str, ...]:
    """Resolved external-library and math linker flags for the current toolchain."""
    return (*self.recipe.link_flags, *(flag for build in self._library_builds for flag in build.link_flags))

frame_bytes cached property

frame_bytes: int

Largest estimated procedure frame in bytes, including inlined locals and scalar spills.

External C functions and solver implementations are outside this estimate.

source cached property

source: str

The .c as written: body behind an include of the paired C header. A C++ header cannot be included from C, so under lang="cpp" the kernel is body alone.

link_flags: tuple[str, ...]

Resolved external-library and math linker flags for the current toolchain.

render_c_module

render_c_module(fun: Function | ConcreteFunction, *, header_name: str | None = None, source_name: str | None = None, lang: str = 'c', casadi: bool = False, lanes: LaneCount = 'auto', dialect: CDialect = 'gnu', vector_libm: VectorLibm = 'none', reciprocal: bool = False, cpu: CpuLevel = 'generic', frame_budget: int = 65536) -> CModule

Render fun into its header / .c pair from a single lowering. The kernel is always C. lang picks the header a caller includes (f.h or f.hpp) and casadi adds the CasADi 3.8 compatible symbols to both.

Source code in src/scaly/codegen/aot.py
def render_c_module(
  fun: Function | ConcreteFunction,
  *,
  header_name: str | None = None,
  source_name: str | None = None,
  lang: str = "c",
  casadi: bool = False,
  lanes: LaneCount = "auto",
  dialect: CDialect = "gnu",
  vector_libm: VectorLibm = "none",
  reciprocal: bool = False,
  cpu: CpuLevel = "generic",
  frame_budget: int = 65536,
) -> CModule:
  """Render ``fun`` into its header / ``.c`` pair from a single lowering. The kernel is always C.
  ``lang`` picks the header a caller includes (``f.h`` or ``f.hpp``) and ``casadi`` adds the
  CasADi 3.8 compatible symbols to both."""
  fun = as_concrete(fun)
  _check_lang(lang)
  ctx, body = _render_observed(
    fun,
    casadi=casadi,
    recipe=BuildRecipe(cpu=cpu, lanes=lanes, dialect=dialect, vector_libm=vector_libm, reciprocal=reciprocal, frame_budget=frame_budget),
    source_name=source_name,
  )
  symbol = c_ident(fun.name)
  return CModule(
    fun=ctx.fun,
    header_name=f"{symbol}.{'hpp' if lang == 'cpp' else 'h'}" if header_name is None else header_name,
    source_name=f"{symbol}.c" if source_name is None else source_name,
    body=body,
    program=ctx.prog,
    workspace_size=ctx.workspace_size,
    backends=ctx.backends,
    lang=lang,
    casadi=casadi,
    recipe=ctx.recipe,
    _libraries=ctx.libraries,
  )

render_c_source

render_c_source(fun: Function | ConcreteFunction, *, casadi: bool = False, lanes: LaneCount = 'auto', dialect: CDialect = 'gnu', vector_libm: VectorLibm = 'none', reciprocal: bool = False, cpu: CpuLevel = 'generic', frame_budget: int = 65536) -> str

Render a standalone pointer-ABI C implementation of fun and its callees. casadi adds the CasADi 3.8 compatible symbols.

A LoweringError for a function that cannot be lowered propagates. There is no fallback.

Source code in src/scaly/codegen/aot.py
def render_c_source(
  fun: Function | ConcreteFunction,
  *,
  casadi: bool = False,
  lanes: LaneCount = "auto",
  dialect: CDialect = "gnu",
  vector_libm: VectorLibm = "none",
  reciprocal: bool = False,
  cpu: CpuLevel = "generic",
  frame_budget: int = 65536,
) -> str:
  """Render a standalone pointer-ABI C implementation of ``fun`` and its callees. ``casadi`` adds
  the CasADi 3.8 compatible symbols.

  A ``LoweringError`` for a function that cannot be lowered propagates. There is no fallback.
  """
  fun = as_concrete(fun)
  recipe = BuildRecipe(cpu=cpu, lanes=lanes, dialect=dialect, vector_libm=vector_libm, reciprocal=reciprocal, frame_budget=frame_budget)
  return _render_observed(fun, casadi=casadi, recipe=recipe)[1]

render_c_api_header

render_c_api_header(fun: Function | ConcreteFunction, *, lang: str = 'c', casadi: bool = False, lanes: LaneCount = 'auto', dialect: CDialect = 'gnu', vector_libm: VectorLibm = 'none', reciprocal: bool = False, cpu: CpuLevel = 'generic', frame_budget: int = 65536) -> str

Render the public header for fun: the ABI declarations, SZ_* constants, the typed buffers of the chosen lang, the sparse-output tables, and with casadi the CasADi query prototypes.

Source code in src/scaly/codegen/aot.py
def render_c_api_header(
  fun: Function | ConcreteFunction,
  *,
  lang: str = "c",
  casadi: bool = False,
  lanes: LaneCount = "auto",
  dialect: CDialect = "gnu",
  vector_libm: VectorLibm = "none",
  reciprocal: bool = False,
  cpu: CpuLevel = "generic",
  frame_budget: int = 65536,
) -> str:
  """Render the public header for ``fun``: the ABI declarations, ``SZ_*`` constants, the typed
  buffers of the chosen ``lang``, the
  sparse-output tables, and with ``casadi`` the CasADi query prototypes."""
  fun = as_concrete(fun)
  _check_lang(lang)
  if casadi:
    fun = casadi_layout(fun)
  ctx = _lower(
    fun, recipe=BuildRecipe(cpu=cpu, lanes=lanes, dialect=dialect, vector_libm=vector_libm, reciprocal=reciprocal, frame_budget=frame_budget)
  )
  return ctx.recipe.comment(f"{c_ident(fun.name)}.c") + _render_header(
    ctx.fun, ctx.backends, ctx.workspace_size, _frame_bytes(ctx.prog), lang=lang, casadi=casadi
  )

write_module

write_module(fun: Function | ConcreteFunction | Solver, out_dir: Path, *, lang: str = 'c', casadi: bool = False, lanes: LaneCount = 'auto', dialect: CDialect = 'gnu', vector_libm: VectorLibm = 'none', reciprocal: bool = False, cpu: CpuLevel = 'generic', frame_budget: int = 65536) -> CModule

Write fun's header / .c into out_dir and return the module. A Solver renders its function.

Source code in src/scaly/codegen/aot.py
def write_module(
  fun: Function | ConcreteFunction | Solver,
  out_dir: Path,
  *,
  lang: str = "c",
  casadi: bool = False,
  lanes: LaneCount = "auto",
  dialect: CDialect = "gnu",
  vector_libm: VectorLibm = "none",
  reciprocal: bool = False,
  cpu: CpuLevel = "generic",
  frame_budget: int = 65536,
) -> CModule:
  """Write ``fun``'s header / ``.c`` into ``out_dir`` and return the module. A ``Solver`` renders its ``function``."""
  if isinstance(fun, Solver):
    fun = fun.function
  module = render_c_module(
    fun, lang=lang, casadi=casadi, lanes=lanes, dialect=dialect, vector_libm=vector_libm, reciprocal=reciprocal, cpu=cpu, frame_budget=frame_budget
  )
  out_dir.mkdir(parents=True, exist_ok=True)
  (out_dir / module.header_name).write_text(module.header)
  (out_dir / module.source_name).write_text(module.source)
  if module._libraries:
    manifest = {
      "source": module.source_name,
      "compile_flags": ["-O2", *module.recipe.cpu_flags, "-fno-math-errno"],
      "link": ["m", *(["mvec"] if module.recipe.vector_libm == "glibc" else [])],
      "libraries": export_libraries(module._libraries, out_dir),
    }
    (out_dir / f"{c_ident(module.fun.name)}.build.json").write_text(json.dumps(manifest, indent=2) + "\n")
  return module

workspace_size

workspace_size(fun: Function | ConcreteFunction, *, casadi: bool = False, lanes: LaneCount = 'auto', dialect: CDialect = 'gnu', vector_libm: VectorLibm = 'none', reciprocal: bool = False, cpu: CpuLevel = 'generic', frame_budget: int = 65536) -> int

Entries of scratch fun needs in w[], the value its header's SZ_W quotes: doubles when every leaf is float64, floats when every leaf is float32, and bytes otherwise. CModule.workspace_size is the same number without a second lowering, so prefer it when the module is already in hand.

Source code in src/scaly/codegen/aot.py
def workspace_size(
  fun: Function | ConcreteFunction,
  *,
  casadi: bool = False,
  lanes: LaneCount = "auto",
  dialect: CDialect = "gnu",
  vector_libm: VectorLibm = "none",
  reciprocal: bool = False,
  cpu: CpuLevel = "generic",
  frame_budget: int = 65536,
) -> int:
  """Entries of scratch ``fun`` needs in ``w[]``, the value its header's ``SZ_W`` quotes: doubles
  when every leaf is float64, floats when every leaf is float32, and bytes otherwise.
  ``CModule.workspace_size`` is the same number without a second lowering, so prefer it when the
  module is already in hand."""
  fun = as_concrete(fun)
  if casadi:
    fun = casadi_layout(fun)
  return _lower(
    fun, recipe=BuildRecipe(cpu=cpu, lanes=lanes, dialect=dialect, vector_libm=vector_libm, reciprocal=reciprocal, frame_budget=frame_budget)
  ).workspace_size

Render options

These keyword options apply to render_c_module, render_c_source, render_c_api_header, write_module, and workspace_size.

Option Default Meaning
cpu "generic" CPU baseline recorded in the recipe. Accepted values are generic, native, x86-64-v3, x86-64-v4, and apple-m4.
lanes "auto" Target-selected lane width. An integer 1, 2, 4, or 8 requests a fixed width. 1 disables range widening.
dialect "gnu" GNU vector extensions. "c" emits scalar operations inside lane loops using C99 syntax.
vector_libm "none" Per-lane scalar math calls. "glibc" selects supported x86-64 libmvec functions and requires dialect="gnu".
reciprocal False Permits invariant x / y to become x * (1 / y). This can change rounding, overflow, and underflow.
frame_budget 65536 Estimated stack bytes for each generated C function. Zero moves all float64 temporary arrays to caller-provided workspace. Scalars still use the stack.

The lang option selects the header language. It does not select the kernel dialect. module.recipe.cpu_flags contains the target compiler flags. module.link_flags includes solver dependencies and the selected math library. Generated headers and sources carry a recipe comment with GCC and Clang build commands. The recipe's target flags must match the object build.

Lanes, dialects and math libraries

The code generation guide explains these options, and vector lanes shows the loops they produce. With lanes="auto", generated source selects SCALY_LANES from the compiler's target macros, and -DSCALY_LANES=1, 2, 4, or 8 overrides that selection. A fixed integer option emits a fixed width instead. The width is a request. A loop can use fewer lanes, and a loop that cannot be widened stays scalar. Input and output arrays keep the same layout at every width, and the header's workspace size does not depend on it.

dialect="gnu" uses GNU vector types and compiler builtins, as provided by GCC and Clang. dialect="c" uses scalar lane loops and is intended for C99 compilers without those extensions. Both give the same buffer layouts. Typed buffers use 16-byte alignment in C11 and C++, and natural alignment in C99.

With vector_libm="none", transcendentals call scalar libm once per lane. The glibc option emits guarded declarations for the supported sin, cos, tan, exp, log, pow, and tanh vector symbols. Guards check x86-64, glibc, and the target feature required by the vector width. tanh also requires glibc 2.35 or later. Incompatible builds fail at compile time. Cross builds whose C library lacks libmvec use vector_libm="none". The glibc recipe links -lmvec.

Vector math is a separate numerical policy. The glibc 2.39 manual documents a maximum error of four units in the last place for x86-64 libmvec functions. Results can differ from scalar libm. See glibc's math accuracy reference.

Widening keeps the order of the additions in a sum. With the same compiler flags and scalar math, a reduction gives the same bits as the scalar code. Reciprocal and vector-libm options have their own rounding behavior. General algebraic simplification has the limits described under arithmetic semantics.

CPU recipes and the command line

generic adds no CPU-specific flag. native uses -march=native on x86 and -mcpu=native on AArch64. The x86-64 levels use -march=x86-64-v3 and -march=x86-64-v4. apple-m4 uses -mcpu=apple-m4. native describes a build for the current host. Distributable binaries need a CPU baseline supported by every destination machine.

The command-line equivalents are --cpu, --lanes, --dialect, --vector-libm, --reciprocal, and --frame-budget:

uv run scaly_codegen my_model:kernel -o generated --cpu generic --lanes auto --vector-libm none
uv run scaly_codegen my_model:kernel -o generated --cpu x86-64-v3 --lanes 4 --dialect gnu

scaly_toolchain reports the compiler and its detected native recipe. The just-in-time compiler uses that native recipe with a fixed lane width. It enables libmvec on supported glibc x86-64 hosts after checking the host version. SCALY_VECTOR_LIBM=none forces scalar libm. SCALY_VECTOR_LIBM=glibc explicitly requests libmvec. The source recipe and compile and link flags participate in the JIT cache key. See environment variables.

Application binary interface

c_api_signature

c_api_signature(fun: Function | ConcreteFunction, symbol: str | None = None, *, solver_options: bool = False) -> str

The entry signature of fun, named symbol or after fun. arg, res and w point to double when every leaf is float64, to float when every leaf is float32, and to void otherwise, with SZ_W then counting bytes. mem follows CasADi 3.8: an int memory handle a stateful function would index a pool with. Every Scaly function is stateless and ignores it.

Source code in src/scaly/codegen/abi.py
def c_api_signature(fun: Function | ConcreteFunction, symbol: str | None = None, *, solver_options: bool = False) -> str:
  """The entry signature of ``fun``, named ``symbol`` or after ``fun``. ``arg``, ``res`` and ``w``
  point to ``double`` when every leaf is float64, to ``float`` when every leaf is float32, and to
  ``void`` otherwise, with ``SZ_W`` then counting bytes. ``mem`` follows CasADi 3.8: an ``int``
  memory handle a stateful function would index a pool with. Every Scaly function is stateless and
  ignores it."""
  fun = as_concrete(fun)
  t = ENTRY_C_TYPES[workspace_dtype(fun)]
  options = ", const scaly_solver_option* const* solver_options" if solver_options else ""
  return f"int {symbol or c_ident(fun.name)}(const {t}** arg, {t}** res, int* iw, {t}* w, int mem{options})"

The C++ header

render_cpp_header

render_cpp_header(fun: ConcreteFunction, backends: tuple[str, ...], sz_w: int, frame_bytes: int, *, casadi: bool) -> str

The .hpp for fun. The kernel symbols are declared extern "C" inside the function's namespace, since a namespace and a function cannot share the global name. C linkage keeps the symbol unmangled, so f::f is the same entry a C caller reaches as f.

The CasADi layer

check_casadi_layout

check_casadi_layout(fun: ConcreteFunction) -> None

Reject leaves CasADi cannot describe: other dtypes than float64, batch axes and non-CSC sparse orders.

casadi_sparsity(shape, layout)

casadi_sparsity

CasADi's compressed encoding: {nrow, ncol, 1} for dense, {nrow, ncol, colind..., row...} for sparse.

Compiling and caching

CompiledFunction

Handle around a JIT-compiled ConcreteFunction.

Holds the ctypes.CDLL for the cached shared object, the resolved entry point with argtypes/restype set up for the pointer ABI, and the workspace size the rendered module reported. That is the header's SZ_W, since the library exports no size query of its own.

run

run(args: list[Any]) -> list[Any]

Dispatch the compiled entry point with positional NumPy inputs.

Inputs are converted to contiguous buffers of their leaf dtypes with shapes validated against the declared input shapes. Outputs are freshly-allocated NumPy arrays reshaped to the ConcreteFunction's declared output shapes (the compact buffer shape for sparse outputs). Raises JitError if the C function returns a non-zero ABI status.

load_library

load_library(path: Path, *, isolated: bool, library_dirs: tuple[str, ...] = ()) -> ctypes.CDLL

Load a shared library, isolating incompatible Linux dependency builds from one another.

External kernels reuse a namespace when shared names identify the same path and content, including transitive dependencies. Loaded handles and namespaces live until process exit, so old compiled handles remain callable after a rebuild. Environment changes after the last load remain subject to the limitation tracked in #159.

Source code in src/scaly/codegen/jit.py
def load_library(path: Path, *, isolated: bool, library_dirs: tuple[str, ...] = ()) -> ctypes.CDLL:
  """Load a shared library, isolating incompatible Linux dependency builds from one another.

  External kernels reuse a namespace when shared names identify the same path and content,
  including transitive dependencies. Loaded handles and
  namespaces live until process exit, so old compiled handles remain callable after a rebuild.
  Environment changes after the last load remain subject to the limitation tracked in #159.
  """
  if isolated and sys.platform == "win32":
    with ExitStack() as stack:
      for directory in library_dirs:
        stack.enter_context(os.add_dll_directory(str(Path(directory).resolve())))
      return ctypes.CDLL(str(path))
  if not isolated or sys.platform != "linux":
    return ctypes.CDLL(str(path))
  libc = ctypes.CDLL(None)
  dlmopen = libc.dlmopen
  dlmopen.argtypes = [ctypes.c_long, ctypes.c_char_p, ctypes.c_int]
  dlmopen.restype = ctypes.c_void_p
  dlerror = libc.dlerror
  dlerror.argtypes = []
  dlerror.restype = ctypes.c_char_p

  def open_library(namespace_id: int, name: str) -> ctypes.CDLL:
    dlerror()
    handle = dlmopen(namespace_id, os.fsencode(name), os.RTLD_NOW | os.RTLD_LOCAL)
    if not handle:
      error = dlerror()
      reason = error.decode(errors="replace") if error else "unknown loader error"
      advice = (
        ". Use a fresh process with fewer incompatible dependency versions." if "no more namespaces" in reason or "static TLS" in reason else ""
      )
      raise OSError(f"dlmopen failed for {name}: {reason}{advice}")
    # Python 3.14 reopens a path even when CDLL receives a handle, so bind it directly.
    lib = ctypes.CDLL(None)
    lib._handle = handle
    lib._name = name
    return lib

  with _LINKER_NAMESPACE_LOCK:
    dependencies = _namespace_dependencies(path)
    previous = next(
      (
        candidate
        for candidate in _LINKER_NAMESPACES
        if all(name not in candidate.dependencies or candidate.dependencies[name] == identity for name, identity in dependencies.items())
      ),
      None,
    )
    if previous is None:
      # A libc-only anchor leaves other symbols in each kernel's local dependency scope.
      # DEEPBIND can split libc's allocator calls from a kernel's free; the extern buffer
      # contract does not prevent that internal mismatch. Keep ordinary LOCAL lookup.
      anchor = open_library(_LM_ID_NEWLM, _linux_loader()[1])
      dlinfo = libc.dlinfo
      dlinfo.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p]
      dlinfo.restype = ctypes.c_int
      resolved = ctypes.c_long()
      if dlinfo(anchor._handle, _RTLD_DI_LMID, ctypes.byref(resolved)) != 0:
        raise OSError(f"dlinfo failed for {path}")
      previous = _LinkerNamespace(resolved.value, [anchor], {}, ctypes.c_void_p.in_dll(anchor, "environ"))
      _LINKER_NAMESPACES.append(previous)
    # A namespace's libc retains the process environment array from its first load. Refresh it
    # before another initializer calls getenv, since Python may have replaced that array.
    previous.environ.value = ctypes.c_void_p.in_dll(libc, "environ").value
    lib = open_library(previous.id, str(path))
    previous.libraries.append(lib)
    previous.dependencies.update(dependencies)
  return lib

invalidate_cache

invalidate_cache(fun: ConcreteFunction) -> None

Make fun's next build in this process use another cache path.

Existing source files and libraries stay on disk for processes still building or loading them. Loaded handles remain callable, including on Windows, where loaded DLLs cannot be deleted or replaced. After recompile() in one process, a fresh process still reuses the original shared library instead of rebuilding it. Scaly writes each library under a temporary name and renames it into place only when complete, so no process finds a partial library.

Scaly does not prune old libraries after process exit. To reclaim the space, delete the cache manually while no process is using it. Safe to call before the first build.

Source code in src/scaly/codegen/jit.py
def invalidate_cache(fun: ConcreteFunction) -> None:
  """Make ``fun``'s next build in this process use another cache path.

  Existing source files and libraries stay on disk for processes still building or loading them.
  Loaded handles remain callable, including on Windows, where loaded DLLs cannot be deleted or
  replaced. After ``recompile()`` in one process, a fresh process still reuses the original shared
  library instead of rebuilding it. Scaly writes each library under a temporary name and renames
  it into place only when complete, so no process finds a partial library.

  Scaly does not prune old libraries after process exit. To reclaim the space, delete the cache
  manually while no process is using it. Safe to call before the first build.
  """
  with _artifact_lock:
    _cache_epochs[fun.name] = _cache_epochs.get(fun.name, 0) + 1

JitError

Bases: RuntimeError

Raised when a compiled function returns a non-zero ABI status code.

Source code in src/scaly/codegen/jit.py
class JitError(RuntimeError):
  """Raised when a compiled function returns a non-zero ABI status code."""

JitUnavailable

Bases: RuntimeError

Raised when JIT compilation cannot proceed and the caller should fall back.

Source code in src/scaly/codegen/jit.py
class JitUnavailable(RuntimeError):
  """Raised when JIT compilation cannot proceed and the caller should fall back."""

Toolchain

find_c_compiler

find_c_compiler() -> Compiler | None

Find the C compiler the JIT uses: SCALY_CC, else zig cc from the zig-bin package, else CC, else cc on PATH.

Returns the compiler command and which of those settings supplied it, or None if nothing is found.

Source code in src/scaly/codegen/toolchain.py
def find_c_compiler() -> Compiler | None:
  """Find the C compiler the JIT uses: ``SCALY_CC``, else ``zig cc`` from the ``zig-bin`` package, else ``CC``, else ``cc`` on ``PATH``.

  Returns the compiler command and which of those settings supplied it, or ``None`` if nothing is found.
  """
  if override := os.environ.get("SCALY_CC"):
    command = _command(override)
    return Compiler(command, "SCALY_CC") if command is not None else None
  if (zig := _zig_bin_executable()) is not None:
    return Compiler((zig, "cc"), "zig-bin")
  if override := os.environ.get("CC"):
    command = _command(override)
    return Compiler(command, "CC") if command is not None else None
  found = shutil.which("cc")
  return Compiler((found,), "PATH") if found is not None else None

cache_root

cache_root() -> Path

Return SCALY_CACHE_DIR, else $XDG_CACHE_HOME/scaly/jit, else ~/.cache/scaly/jit.

Windows returns an absolute extended path so deeply nested caches work beyond the legacy path limit.

Source code in src/scaly/codegen/toolchain.py
def cache_root() -> Path:
  """Return ``SCALY_CACHE_DIR``, else ``$XDG_CACHE_HOME/scaly/jit``, else ``~/.cache/scaly/jit``.

  Windows returns an absolute extended path so deeply nested caches work beyond the legacy path limit.
  """
  override = env_path("SCALY_CACHE_DIR")
  xdg = env_path("XDG_CACHE_HOME")
  base = xdg if xdg is not None else Path.home() / ".cache"
  root = override if override is not None else base / "scaly" / "jit"
  if sys.platform == "win32":
    # Repeated benchmark runs nest the cache deeply enough to exceed legacy MAX_PATH.
    path = str(root.resolve())
    if not path.startswith("\\\\?\\"):
      path = "\\\\?\\UNC\\" + path[2:] if path.startswith("\\\\") else "\\\\?\\" + path
    return Path(path)
  return root