Embedded C target#
hawk can emit a kernel as freestanding C for a safety-critical embedded build:
a <name>.c and a <name>.h that include nothing from hawk, aether or eagle,
only <math.h>, <stdint.h>, <stdbool.h>, <stddef.h> and <float.h>.
The text follows MISRA C:2012 (with its amendments), is serial, and under the
native host profile it is bit-identical to the host build of the same kernel:
the C is built with -ffp-contract=off, every reduction and product follows
aether’s evaluation order, and the scalar math is libm.
The target id is c. It sits beside host and cuda; their emitted text is
unchanged. hawk.embedded is the engine; hawk.build(..., targets=("c",))
is the same engine behind the build door.
Quickstart#
One kernel, straight to a directory:
import hawk
from hawk import Mutable, Param, Vector
from hawk.math import norm
@hawk.kernel
def drag(state: Vector[3], rho: Param, out: Mutable[Vector[3]]):
out = -rho * state * norm(state)
art = hawk.embedded.export(drag, "out/")
art.c_path, art.h_path, art.report_path # drag.c, drag.h, drag.embedded.json
For a runnable walk-through (export, the header and report, a bit-for-bit comparison with the host build, and the refusals), see the embedded C example.
or through the build door, alone or beside the native targets:
hawk.build([drag], "out/", targets=("c",))
hawk.build([drag], "out/", targets=("host", "c"))
export writes three files: <name>.c, <name>.h and
<name>.embedded.json (the report). targets=("c",) writes the same
three plus the build stamp, and neither a manifest nor a sidecar: the c
target never enters the eagle manifest (exec_targets lists the native
targets only), so hawk.load (given the directory) and hawk.artifact.plugins (given the
build’s Bundle) refuse a c-only build and say it is source for an embedded build. A
profile reaches the door as embedded_profile=; it is also a term of the
build’s cache key.
Compile the output with any C99 compiler. Keep floating-point contraction off, or the result is not the host build’s:
cc -std=c99 -ffp-contract=off -O2 -c out/drag.c -o drag.o
The entry is <name>_c(args, base, count, n_samples). It returns a status
instead of trusting its inputs:
status |
value |
meaning |
|---|---|---|
|
0 |
the partition ran |
|
1 |
the args pointer or a plane pointer is null |
|
2 |
|
On an error the entry returns before it touches any plane. It is re-entrant and allocation-free, with one exit point.
The ABI contract#
drag.h, exactly as hawk.embedded.render(drag).header returns it (export writes it to drag.h) for the drag kernel
above (float64, default profile):
/* drag.h -- generated by hawk 0.5.0; do not edit.
* TRACEABILITY:
* kernel drag
* hawk version 0.5.0
* walk digest bcc450195db6caa95fb54687b8b395c13f1bcffbf63d1c0ef69191cda2624a84
* kernel sha256 dff29da0d5195f0021bb40af3d7f8744baa15a824f4b2e59a2cccd565a50bed5
* scalar mode float64
* target c, MISRA C:2012+AMD, C99 dialect, serial
* profile EmbeddedProfile(max_trips=None, max_ops=None, max_stack_bytes=None, fp_domain='record', index_policy='clamp', require_cppcheck=False, c_std='c99')
* aether core aether-c/1
* BUDGETS (per sample, worst case):
* loop trips 0
* ops arith32 0, arith64 14, special32 0, special64 0, loads 3, gathers 0, chain32 0, chain64 3, chainSpecial32 0, chainSpecial64 0, stores 3
* fp64 ops 14
* static bytes tapes 0, carries 0, temporaries 72, total 72
* libm sqrt
* INTERFACE CONTRACT: planes are SoA; rank-1 extent N stores component c of sample i at
* base[(c * n_samples) + i]; rank-2 (R,C) at base[(((r*C)+c) * n_samples) + i]; rank-0 at
* base[i]. The pitch is ALWAYS n_samples, also for a partition [base, base+count).
* A runtime-length plane carries its length in the int64_t field after its pointer.
* field role dir dtype shape init-by-caller
* mut_out mutable out f64 (3,) no
* vin_state vec_in in f64 (3,) yes
* uni_rho uniform in f64 () yes (by value)
* drag_c() validates args and every pointer, 0 <= base <= n_samples and
* 0 <= count <= n_samples - base, returning HAWK_E_NULL / HAWK_E_RANGE untouched otherwise.
* Re-entrant, allocation-free. */
#ifndef HAWK_DRAG_H
#define HAWK_DRAG_H
#include <stdint.h>
#include <stddef.h>
#ifndef HAWK_EMBEDDED_TYPES
#define HAWK_EMBEDDED_TYPES 64
typedef double hawk_real_t;
typedef int64_t hawk_int_t;
typedef enum { HAWK_OK = 0, HAWK_E_NULL = 1, HAWK_E_RANGE = 2 } hawk_status_t;
#elif HAWK_EMBEDDED_TYPES != 64
#error "drag.h: float64 unit; another hawk header in this build defined hawk_real_t at a different width"
#endif
typedef struct {
hawk_real_t* mut_out; /* mutable out Vector[3] SoA, pitch n_samples */
const hawk_real_t* vin_state; /* vec_in in Vector[3] SoA, pitch n_samples */
hawk_real_t uni_rho; /* uniform in Scalar by value */
} drag_args_t;
hawk_status_t drag_c(const drag_args_t* args, int64_t base, int64_t count, int64_t n_samples);
#endif
The TRACEABILITY digests shown are those of the test suite’s drag kernel
(tests/_kernels.py); a kernel you define carries its own walk and source
digests.
The contract the comment states is part of the interface.
Layout. Every plane is structure-of-arrays. A rank-1 extent N stores
component c of sample i at base[(c * n_samples) + i]; a rank-2 (R, C)
plane stores component (r, c) at base[(((r*C)+c) * n_samples) + i]; a
rank-0 plane at base[i]. The pitch is always n_samples, also when the call
covers only the partition [base, base + count). This is the layout of a
NumPy array of shape (prod(shape), n) in C order.
Runtime lengths. A plane whose length is a runtime value (a lookup table, a
wide input or output) carries it in the int64_t field after its pointer,
<field>_len. The entry requires it to be at least 1 (zero is refused too, with
HAWK_E_RANGE); indices into it are
clamped to it (see the verify pass).
Directions. The header’s interface table and the report’s interface
rows give each field a direction, and the caller must initialise every field
marked init-by-caller:
role |
direction |
init by caller |
|---|---|---|
|
in |
yes |
|
out; inout when the kernel reads it before writing |
no; yes when inout |
|
inout (a masked sample keeps the prior value; the reason names the guard’s masks) |
yes |
|
inout (one row is written per launch, the rest kept) |
yes |
|
out |
no |
|
inout (one partial per sample; a masked sample keeps its own) |
yes (zeroed) |
|
inout (the rows written are replaced, the rest kept) |
yes |
|
inout ( |
yes (zeroed) |
|
in; inout when it is the finish mask |
yes |
|
inout |
yes (zeroed) |
A store is under a mask guard when the kernel binds, in the terminated role,
a mask its kind’s guard names (the default guard names terminated; a
finishing kernel binds its finish mask there too). The generated loop then
skips a masked sample entirely, exactly as the host build does, so the plane
keeps whatever the caller put there. Every interface row carries the bound
mask fields as guard (empty for an unguarded kernel), and the header’s
contract prints a line for each writable mutable or wide_out field of a
guarded kernel naming them, for example
* mut_y: masked samples keep the prior value (guard: trm_terminated); the caller initialises it.
Partials. An accum_out written on the sample’s own column holds one
partial per sample, length n_samples. Nothing in a standalone build folds
them; the caller reduces the plane. The interface row says layout: "partials" and gives that reason. Under a mask guard a masked sample keeps
its partial, so the caller zeroes the plane first, then reduces it.
The mix plane. A dispatch kernel counts how often each branch ran in a
uint32_t plane of K cells (one per branch), incremented by the generated
code; the caller zeroes it before the first call.
Types. hawk_real_t is double or float by the scalar mode,
hawk_int_t is int64_t; an Index input plane is an int32_t* (an Index[int64] one is int64_t*),
widened where the body reads it. The three typedefs and the status enum sit behind
HAWK_EMBEDDED_TYPES, so several hawk headers of one width coexist in a build;
a header of another width fails with #error. The .c also checks the sizes
with _Static_assert, but only under C11 (__STDC_VERSION__ >= 201112L):
a strict -std=c99 build compiles the asserts out and relies on the
HAWK_EMBEDDED_TYPES guard alone.
The verify pass#
Before anything is emitted, hawk.embedded.verify reads the IR. It refuses
what the C target does not build, then computes (without refusing) the report’s
numbers, and a profile turns chosen numbers into refusals.
What it checks.
Bounds and budgets. Worst-case loop trips per sample (nested loops multiplied, a runtime-count loop at its static trip, which the C clamps to), the per-sample operation counts from
kernel_cost, and the static bytes: tapes, loop carries and the named temporaries the renderer declares.Index safety. Every runtime index is classified
provenorclamped. A table read’s length is a runtime value, so a gather index is clamped (hawk_clamp_index); a parameter cell, a loop index with a static range inside the extent, and a tape ordinal are proven and emit the raw index. Clamping changes nothing in an execution the C++ build defines, so the bit identity holds.Floating-point domain. Each divisor,
sqrt,rsqrt,log*,acos,asin,atanh,acoshandpowbase is reported with whether its operand is provably in the domain (safe, with the reason, orunknown). The proof reads signs off the operand, for examplepositive(a positive literal,1 + x*x,x*x + cornorm(v) + cwithcpositive),nonneg(exp, which underflows to +0,abs,norm,dot(v, v),x*x, a zero literal,sqrtof a nonneg value) orunknown(anything else,sqrtof an unknown value among it). A divisor,log*andrsqrtneedpositive,sqrtneedsnonneg. A sign claim holds for non-NaN operands: a NaN propagates identically on both sides (clippasses it through), so the report speaks for every value that is a number.Interface. One row per slot: field, role, direction, dtype, shape, layout, runtime length, init by caller, guard (the bound mask fields that skip a sample, the same on every row; empty when unguarded), reason.
The profile. EmbeddedProfile carries the opt-in caps and policies. The
default refuses only what the target cannot build.
field |
default |
effect |
|---|---|---|
|
|
refuse above this many worst-case loop trips per sample ( |
|
|
refuse above this many arithmetic and special operations per sample ( |
|
|
refuse above this many static bytes (tapes, carries, temporaries; |
|
|
|
|
|
|
|
|
an absent cppcheck raises instead of being recorded as skipped |
|
|
the dialect the consumer compiles under, |
The refusal shape. Every refusal is a HawkError naming the kernel, the
node, the rule, why, and what to do:
'<kernel>': the C target refuses node <pos> (<kind>) under <rule>: <why>; <fix>
<pos> is the node’s index in the walk, or loop<i>.<k> for a node in a
loop body; a refusal about the whole kernel (active_set, compensated,
two_path, budget.ops, budget.stack) uses the position kernel. An audit
refusal has a different shape and no fix clause:
'<kernel>': the C target refuses line <N> under audit.<kind>: <text>. Rules are unsupported.<feature>, budget.trips, budget.ops,
budget.stack, index.unproven, fp.domain and audit.<kind>.
Refused features. Checked first, and always:
feature |
why |
fix |
|---|---|---|
|
a random draw lowers to aether’s counter-based generator |
draw in the caller and pass the values in as a plane |
|
a |
spell the block with |
|
a reduction over lanes ( |
commit the own column and reduce in the caller |
|
|
pass the values the kernel needs as a |
|
an index map (an active-set guard or a lane set) is a plane the C entry does not take |
run without the index map (the plain mask guard) |
|
a segmented dispatch is a per-unit split |
export each branch as its own kernel |
|
the compensated sink policy commits a correction plane |
use the plain sink policy |
|
a fused lane group is composed for a launching runtime |
export each lane as its own kernel |
|
an automatic kernel branches once per launch on a runtime step word |
export |
|
a libm function on an |
use |
|
a scattered accumulate of a rank>=1 value is not lowered (hawk’s tracer refuses it first) |
declare one scalar accumulate plane per component |
|
a loop that never runs is dead code (MISRA C:2012 Rule 14.3) |
give the loop at least one trip |
|
a select on a rank>=1 condition picks per component |
select per component with a rank-0 condition |
After rendering, a text audit reads the .c and .h (rule audit.<kind>):
no allocation, goto, stdio, union, volatile, #pragma, C++ casts or
namespaces, no launch-geometry token; only the whitelisted includes; at most
one break; per for body; every switch with a default: and a break;
in each clause; no unreferenced helper; distinct external identifiers in 31
characters; block comments only; no brace initialiser. A hit refuses the
export before any file is written.
The report file#
<name>.embedded.json is indented JSON, always in this key order
(hawk.embedded.REPORT_KEYS):
key |
holds |
|---|---|
|
the kernel name |
|
|
|
|
|
the digest of the traced walk |
|
sha256 of the kernel’s source ( |
|
|
|
|
|
one row per slot, as above |
|
|
|
every runtime index site with its |
|
every domain-sensitive site and its verdict (a sign claim holds for non-NaN operands) |
|
walk positions that compare reals with |
|
positions of integer divisions |
|
positions of every |
|
|
|
every libm function the unit calls, sorted |
|
the hawk-owned helper functions emitted |
|
the aether core functions inlined |
|
the aether core’s version, |
|
the deviations table below, plus one |
|
the refused features that were checked |
|
|
|
the compiler and cppcheck runs |
There are no timestamps and no temporary paths: exporting the same kernel again writes the same bytes, and the header carries the same walk and source digests.
Toolchain and checks#
export runs the checks (check=True), and hawk.embedded.check runs them
on a rendered source. The C compiler runs twice, -std=c99 and -std=c11,
with -pedantic -Wall -Wextra -Wconversion -Wsign-conversion -Wdouble-promotion -Werror -fsyntax-only; then cppcheck runs with the MISRA
addon (--addon=misra --std=c99 --language=c --enable=warning,style). Any
compiler diagnostic or cppcheck finding raises a HawkError with the command
and output. The .c and .h are written first, so a failing check leaves
them for inspection; the report is written only after the checks pass.
The C compiler is the first of: $HAWK_CC (must resolve); the C sibling of
$HAWK_CXX (g++-13 becomes gcc-13, clang++ becomes clang), so the C
and C++ checks agree on one toolchain; the first word of $CC; cc, clang,
gcc on PATH; a zig cc wrapper from the ziglang wheel. cppcheck is
$HAWK_CPPCHECK, else cppcheck on PATH. If it is absent the report records
checks.cppcheck as skipped, and EmbeddedProfile(require_cppcheck=True)
raises instead. The repository’s own test rows fail rather than skip without
cppcheck: a skipped check certifies nothing.
Deviations. Deviations are applied as cppcheck --suppress flags and
recorded in the report; they are never written as comments in the C.
rule |
category |
reason |
|---|---|---|
|
advisory |
cppcheck analyses one translation unit; the entry function and its args struct are the unit’s API, referenced from the consumer’s unit, so the single-unit heuristic cannot see the reference |
|
required |
a checker false positive: the addon reads a function-call initialiser of a struct with an array member as a partial brace initialiser. The emitted C has no brace initialiser, and the text audit ( |
|
advisory |
the |
Beyond the cppcheck table, a reviewer should know the unit does the following
by design. The kernel’s own == and != on reals are kept (they mirror the
host build bit for bit) and every site is listed in fp_equality_sites with a
float-equality note in deviations. NAN and INFINITY appear as
literals when the kernel writes them and are listed in literal_specials.
The integer helpers wrap in the unsigned twin type and convert back to the
signed type; the conversion of an out-of-range unsigned value is
implementation-defined in C, which Directive 1.1 asks a project to document.
Int32 and UInt32 arithmetic and every Index //, % and shift go
through the same helpers in the host build, so the two agree everywhere. The
C++ build spells an Index +, -, *, negation and true division /
infix, where signed overflow, a zero divisor and MIN / -1 are undefined
(the C’s hawk_i64_div returns 0 and MIN there), so for those the two
builds agree wherever the C++ is defined; the defined_where_cpp_is_ub note in deviations lists every such
site (int_ub_sites). The size asserts are guarded by the C11 version test, as
above.
Bit identity#
The C unit is checked against the host build of the same kernel, in the same
scalar mode, built under the native host profile, on the same machine. The
comparison is on bytes (tobytes()), never a tolerance, over the corpus of
emitted kernels, the loop forms (head, middle and tail breaks, runtime counts,
hawk.steps with a finish, vjp, tapes), dispatch with the mix plane, mask
guards (a masked sample keeps the caller’s value on both sides) and the
integer kernels on their boundary grids. An Index +, -, *, negation
and true division / are compared only where the C++ is defined (see
int_ub_sites); at the overflow points the C alone is checked against NumPy’s
int64 wrapping, and at a zero divisor and MIN / -1 against hawk_i64_div’s
defined results. The
conditions: -ffp-contract=off on both sides, the C compiler of the same vendor
as the host compiler (hence the $HAWK_CXX sibling rule), and libm scalar math.
abs, minimum and maximum on an Index use integer helpers with NumPy
semantics in the C; the host build does not compile them (aether’s
abs/fmin/fmax take only reals), so they have no host twin and sit
outside the comparison. Without the native extension (for example on a
Mac), a weaker row compares the C against the interpreter within a band.
What is deferred#
Not built, and refused by name: random draws (they need aether’s Philox
generator in C), reductions over lanes (hawk.sum), cross-lane
reads (.at), active-set guards, segmented units, the
compensated sink policy, fused lanes and automatic (two-path) kernels.
Nothing is threaded: scattered accumulates are plain +=. The scalar
reference functions (every fold, product and component-wise operation) come
from aether_dsc.c_core, version aether-c/1, which hawk inlines verbatim
into the unit; hawk requires aether-dsc>=0.2.7 for it.