Programs¶
A program goes through three steps. lower turns a JAX function into StableHLO,
compile_program turns that into an executable for the loaded plugin, and the
executable runs on host arrays.
Lowered programs¶
lower takes the function and the shapes of its arguments. It traces the function with
jax.export, so everything that JAX traces works, and it returns a
LoweredProgram with the portable bytecode and the module as
text.
import jax
import jax.numpy as jnp
import numpy as np
import cyxla as cx
def energy(positions, charges):
difference = positions[:, None] - positions[None, :]
distance = jnp.sqrt(jnp.sum(difference**2, axis=-1) + jnp.eye(len(charges)))
pairs = charges[:, None] * charges[None, :] / distance
return jnp.sum(jnp.triu(pairs, 1))[None]
shapes = [
jax.ShapeDtypeStruct((4, 2), jnp.float32),
jax.ShapeDtypeStruct((4,), jnp.float32),
]
program = cx.lower(energy, shapes)
print(program.text.splitlines()[0])
lower traces the function with the current setting of jax.enable_x64, as JAX does. A
float64 shape lowers to float32 while the setting is off, so 64-bit programs need it
on, either globally or as a context manager around the call. The input_types of the
program record the dtype and shape of each argument. The function may have unused
arguments. They stay in the signature, because callers pass every input of a program.
Lowering chooses the platform the way JAX chooses where to run. The arguments may be
arrays, or shapes with a sharding, and the platform of the device they are committed to
wins. Next comes the device of jax.default_device, then the loaded plugin, then the
first platform in jax_platforms that has a plugin, and last the plugin that cyxla
would load by default. A program runs on one device, and cyxla starts no JAX backend to
find out. The platform argument lowers for another platform, for instance to prepare
programs on a machine without a GPU. The program records its platform, and
compile_program loads the plugin for it. A function that calls custom calls needs
their names in custom_calls, as the custom calls page explains.
Compilation¶
compile_program returns the executable and the
CompileStatistics of the call. By default it keeps the
executable in the disk cache, so a second process loads it instead of
compiling again.
executable, statistics = cx.compile_program(program, disk_cache=False)
print(statistics.compiled, executable.num_outputs)
Execution from Python¶
Executable.run takes one array per argument and returns one array per output, with the
shape and dtype that the program gives. An argument converts to the type of the program
when that loses no more than precision. A float64 array passes for a float32
argument and integers pass for floats, but floats do not pass for integers.
rng = np.random.default_rng(0)
positions, charges = rng.normal(size=(4, 2)), rng.uniform(1.0, 2.0, size=4)
(value,) = executable.run([positions, charges])
assert np.allclose(value, energy(positions, charges))
A program takes at most 8 arguments and returns at most 4 outputs. run copies its
arguments into aligned memory first, so it measures more than an execution does. It is
meant for tests and interactive checks. The execute function of an extension
module reuses the buffers and takes no GIL.
Element types¶
Arguments and results can be bool, int8 to int64, uint8 to uint64, float16,
bfloat16, float32, float64, complex64 and complex128, and one program can mix
them.
shapes = [
jax.ShapeDtypeStruct((3,), jnp.int8),
jax.ShapeDtypeStruct((3,), jnp.bfloat16),
]
program = cx.lower(lambda counts, weights: (counts * 2, weights * 1j), shapes)
executable, _ = cx.compile_program(program, disk_cache=False)
doubled, rotated = executable.run([[1, 2, 3], [0.5, 1.5, 2.5]])
assert doubled.dtype == np.int8 and rotated.dtype == np.complex64
lower refuses a function with another dtype, such as the 8-bit floats, 4-bit integers
and PRNG keys, and the error names the argument or result. TPUs do not support
float64, so programs for them fail to compile.