diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11400c1..2450d2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,7 +141,7 @@ jobs: raptor/pyproject.toml eagle/python/pyproject.toml - - run: sudo apt-get update && sudo apt-get install -y g++-13 ccache + - run: sudo apt-get update && sudo apt-get install -y g++-13 ccache cppcheck # compiled objects persist across runs; one cache per job and architecture - uses: actions/cache@v6 @@ -276,7 +276,7 @@ jobs: raptor/pyproject.toml eagle/python/pyproject.toml - - run: sudo apt-get update && sudo apt-get install -y g++-13 g++-${{ matrix.gcc }} ccache + - run: sudo apt-get update && sudo apt-get install -y g++-13 g++-${{ matrix.gcc }} ccache cppcheck # compiled objects persist across runs; one cache per job and architecture - uses: actions/cache@v6 @@ -509,7 +509,7 @@ jobs: raptor/pyproject.toml eagle/python/pyproject.toml - - run: sudo apt-get update && sudo apt-get install -y g++-13 ccache + - run: sudo apt-get update && sudo apt-get install -y g++-13 ccache cppcheck # compiled objects persist across runs; one cache per job and architecture - uses: actions/cache@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb4d825..794b6b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -238,7 +238,7 @@ jobs: raptor/pyproject.toml eagle/python/pyproject.toml - - run: sudo apt-get update && sudo apt-get install -y g++-13 + - run: sudo apt-get update && sudo apt-get install -y g++-13 cppcheck - uses: ./hawk/.github/actions/cuda-toolchain diff --git a/CHANGELOG.md b/CHANGELOG.md index 64bb24a..031d9de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.6.5 (2026-10-23) + +A new `c` target emits a kernel as standalone MISRA C:2012: `.c`, `.h` and a +`.embedded.json` report, serial, with a status-returning entry and a plain +structure-of-arrays ABI, bit-identical to the `native` host build and compiled with +`-ffp-contract=off`. `hawk.embedded.export(kernel, directory)` is the engine and +`hawk.build(..., targets=("c",))` the build door, alone or beside `host` and `cuda`, with the +new `embedded_profile=` keyword taking an `EmbeddedProfile`. The `c` target never enters the +eagle manifest, and `hawk.load` and `hawk.artifact.plugins` refuse a build that holds only a `c` unit. A verify pass +runs before emission: it refuses by name what the target does not build (thirteen features, among them random +draws, raw device blocks, reductions over samples and cross-sample reads (`hawk.all_samples`), active-set guards, segmented units, the +compensated sink policy, fused lanes, automatic kernels and libm functions on `Int` operands), records +the worst-case trips, operations and static bytes, classifies every runtime index as proven or +clamped, reports the floating-point domain of each division and root, and applies the +profile's opt-in caps. A text audit and the C compiler (`-std=c99` and `-std=c11`) and +cppcheck's MISRA addon then check the output; the report records the deviations. The +embedded rows need cppcheck and fail rather than skip without it. + +`Kernel.source_digest` holds the sha256 of a kernel's source (`None` when it is unavailable) and +reaches the header and the report. `hawk.emit` gains a `Dialect` seam (`Dialect`, `AETHER`, +`render_body(..., dialect=)`): the traversal is unchanged and the CUDA and host output is +byte-identical except for one change: the renderer no longer emits a dead load at the sample +index (`lut__i = lut_[i]...`) for a lookup or wide-input plane (`Table`, `Wide`) read +twice, nor for a rank>=1 lookup plane (a `Table[Vector[3]]`) read at all. + +hawk now requires `aether-dsc>=0.2.7`, which brings `aether_dsc.c_core`, the generator whose +scalar reference functions the `c` target inlines into the unit. + ## 0.6.4 (2026-10-22) **Wheels for more platforms.** Linux aarch64 and macOS arm64 wheels (on macOS, kernels compile with Homebrew's LLVM clang; see the installation page), and Python 3.15 and 3.15t wheels for every platform (the other versions are CPython 3.9-3.14 and free-threaded 3.13t and 3.14t). diff --git a/CITATION.cff b/CITATION.cff index f8a2287..4cf566b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -6,8 +6,8 @@ abstract: "Kernels written once in Python and compiled into the captured graph a authors: - family-names: "Masat" given-names: "Alessandro" -version: "0.6.4" -date-released: "2026-10-22" +version: "0.6.5" +date-released: "2026-10-23" license: Apache-2.0 repository-code: "https://github.com/amasat01/hawk" doi: "10.5281/zenodo.23250242" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee5c011..26b44a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,11 @@ python -m pytest tests -q -m "not gpu" # the gate (.github/workflows/ci.yml) Most tests also need `eagle` installed as a Python package to run traced kernels. Without it, host artifacts still run through `hawk.load` and `hawk.run`. The free-threading rows run with `python -m pytest tests -m ft -W error::RuntimeWarning`. +The embedded-C rows (`tests/test_embedded_*.py`) compile the emitted C and run cppcheck's MISRA addon over it, so they need a C +compiler and cppcheck (`brew install cppcheck`, or `apt-get install cppcheck`). Without them the rows fail rather than skip: a +skipped check certifies nothing. `$HAWK_CC` names the C compiler and `$HAWK_CPPCHECK` the cppcheck binary when they are not on +`PATH`. + Docs (`cd docs`): `pip install -r requirements.txt` (it pulls the shared `raptor-docs` package; offline, `pip install ./site` from a checkout of the landing site). `make strict` (zero warnings), `make linkcheck`, and for notebooks `make nbexec` then `make nbcheck`. Keep a new notebook under a minute with small committed outputs. `make nbcheck-hosted` runs them as a Colab user would; the comments diff --git a/docs/conf.py b/docs/conf.py index 4192c25..800a439 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -114,7 +114,7 @@ # Templates and exclusions templates_path = ["_templates"] -exclude_patterns = ["_templates", "_build", "Thumbs.db", ".DS_Store", "README.md"] +exclude_patterns = ["_templates", "_build", "Thumbs.db", ".DS_Store", "README.md", "superpowers"] # Two narrow, tooling-internal rough edges, not content bugs: # - "autodoc": sphinx_autodoc_typehints' autodoc-process-signature hook diff --git a/docs/content/api/index.md b/docs/content/api/index.md index 50459d7..7d87577 100644 --- a/docs/content/api/index.md +++ b/docs/content/api/index.md @@ -26,4 +26,5 @@ them may change between releases. hawk.compile hawk.runtime hawk.artifact + hawk.embedded ``` diff --git a/docs/content/compiling.md b/docs/content/compiling.md index 848c313..40ac694 100644 --- a/docs/content/compiling.md +++ b/docs/content/compiling.md @@ -5,6 +5,9 @@ How hawk turns a kernel into something that runs. - [The compile cache](compile_cache): why building the same kernel twice compiles once. - [Compile options](compile_options): the flags and profiles a build takes. +The `c` target emits standalone MISRA-C for an embedded build instead of a +native library; see [Embedded C target](embedded.md). + ## The compile guard Two guards keep a compile from running away unseen: a very large source is refused before the diff --git a/docs/content/embedded.md b/docs/content/embedded.md new file mode 100644 index 0000000..4ccb847 --- /dev/null +++ b/docs/content/embedded.md @@ -0,0 +1,394 @@ +# Embedded C target + +hawk can emit a kernel as freestanding C for a safety-critical embedded build: +a `.c` and a `.h` that include nothing from hawk, aether or eagle, +only ``, ``, ``, `` and ``. +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: + +```python +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](examples/05_embedded_c_target). + +or through the build door, alone or beside the native targets: + +```python +hawk.build([drag], "out/", targets=("c",)) +hawk.build([drag], "out/", targets=("host", "c")) +``` + +`export` writes three files: `.c`, `.h` and +`.embedded.json` (the [report](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: + +```bash +cc -std=c99 -ffp-contract=off -O2 -c out/drag.c -o drag.o +``` + +The entry is `_c(args, base, count, n_samples)`. It returns a status +instead of trusting its inputs: + +| status | value | meaning | +|---|---|---| +| `HAWK_OK` | 0 | the partition ran | +| `HAWK_E_NULL` | 1 | the args pointer or a plane pointer is null | +| `HAWK_E_RANGE` | 2 | `0 <= base <= n_samples` or `0 <= count <= n_samples - base` failed, or a runtime length is below 1 | + +On an error the entry returns before it touches any plane. It is re-entrant +and allocation-free, with one exit point. + +(abi)= +## 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): + +```c +/* 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 +#include +#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, +`_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](verify)). + +**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 | +|---|---|---| +| `per_sample`, `vec_in`, `mat_in`, `lookup`, `wide_in`, `uniform` | in | yes | +| `mutable` | out; inout when the kernel reads it before writing | no; yes when inout | +| `mutable`, `wide_out` on the sample's own column, under a mask guard | inout (a masked sample keeps the prior value; the reason names the guard's masks) | yes | +| `out` (a tape write's plane) | inout (one row is written per launch, the rest kept) | yes | +| `wide_out`, `accum_out` on the sample's own column | out | no | +| `accum_out` on the sample's own column, under a mask guard | inout (one partial per sample; a masked sample keeps its own) | yes (zeroed) | +| `wide_out` written at a computed index | inout (the rows written are replaced, the rest kept) | yes | +| `accum_out` written at a computed index | inout (`+=` into the prior value) | yes (zeroed) | +| `terminated` | in; inout when it is the finish mask | yes | +| `finished_count`, `dispatchN_mix` | 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 + +```text + * 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`. 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. + +(verify)= +## 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 `proven` or `clamped`. 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`, `acosh` and `pow` base is reported with whether its operand + is provably in the domain (`safe`, with the reason, or `unknown`). The proof + reads signs off the operand, for example `positive` (a positive literal, + `1 + x*x`, `x*x + c` or `norm(v) + c` with `c` positive), `nonneg` (`exp`, + which underflows to +0, `abs`, `norm`, `dot(v, v)`, `x*x`, a zero literal, + `sqrt` of a nonneg value) or `unknown` (anything else, `sqrt` of an unknown + value among it). A + divisor, `log*` and `rsqrt` need `positive`, `sqrt` needs `nonneg`. A sign + claim holds for non-NaN operands: a NaN propagates identically on both + sides (`clip` passes 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 | +|---|---|---| +| `max_trips` | `None` | refuse above this many worst-case loop trips per sample (`budget.trips`) | +| `max_ops` | `None` | refuse above this many arithmetic and special operations per sample (`budget.ops`) | +| `max_stack_bytes` | `None` | refuse above this many static bytes (tapes, carries, temporaries; `budget.stack`) | +| `fp_domain` | `"record"` | `"refuse"` refuses an `unknown` domain site (`fp.domain`) | +| `index_policy` | `"clamp"` | `"refuse"` refuses any `clamped` index site (`index.unproven`) | +| `require_cppcheck` | `False` | an absent cppcheck raises instead of being recorded as skipped | +| `c_std` | `"c99"` | the dialect the consumer compiles under, `"c99"` or `"c11"` | + +**The refusal shape.** Every refusal is a `HawkError` naming the kernel, the +node, the rule, why, and what to do: + +```text +'': the C target refuses node () under : ; +``` + +`` is the node's index in the walk, or `loop.` 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: +`'': the C target refuses line under audit.: `. Rules are `unsupported.`, `budget.trips`, `budget.ops`, +`budget.stack`, `index.unproven`, `fp.domain` and `audit.`. + +**Refused features.** Checked first, and always: + +| feature | why | fix | +|---|---|---| +| `random` | a random draw lowers to aether's counter-based generator | draw in the caller and pass the values in as a plane | +| `raw_device` | a `raw_device` block splices C++ text | spell the block with `hawk.math` | +| `sample_reduction` | a reduction over samples (`hawk.all_samples(x).sum()`, `.max()`, ...) needs the wrapper's cross-sample fold | commit the own column and reduce in the caller | +| `cross_sample_at` | `hawk.all_samples(x)[i]` reads another sample's value, which a partition call may not hold | pass the values the kernel needs as a `Table` plane | +| `active_set` | an active-set guard reads an index map the C entry does not take | run without the index map (the plain mask guard) | +| `segmented` | a segmented dispatch is a per-unit split | export each branch as its own kernel | +| `compensated` | the compensated sink policy commits a correction plane | use the plain sink policy | +| `fused_lanes` | a fused lane group is composed for a launching runtime | export each lane as its own kernel | +| `two_path` | an automatic kernel branches once per launch on a runtime step word | export `kernel.step` or `hawk.steps(step, K)` with an integer `K` | +| `int_libm` | a libm function on an `Int` operand takes an implicit double round-trip | use `maximum(minimum(i, hi), lo)` (`Int` min, max and abs are allowed) or convert to `Real` explicitly | +| `scattered_tensor_accum` | a scattered accumulate of a rank>=1 value is not lowered (hawk's tracer refuses it first) | declare one scalar accumulate plane per component | +| `zero_trip_loop` | a loop that never runs is dead code (MISRA C:2012 Rule 14.3) | give the loop at least one trip | +| `tensor_select_cond` | 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.`): +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. + +(report)= +## The report file + +`.embedded.json` is indented JSON, always in this key order +(`hawk.embedded.REPORT_KEYS`): + +| key | holds | +|---|---| +| `kernel` | the kernel name | +| `generator` | `"hawk.embedded/1"` | +| `hawk_version` | `hawk.__version__` | +| `walk_digest` | the digest of the traced walk | +| `source_digest` | sha256 of the kernel's source (`Kernel.source_digest`), or `"unavailable"` for a hand-built or derived kernel | +| `scalar_mode` | `"float64"` or `"float32"` | +| `profile` | `repr` of the `EmbeddedProfile` | +| `interface` | one row per slot, as above | +| `budgets` | `max_trips`, `ops`, `fp64_ops`, `static_bytes` (tapes, carries, temporaries, estimate, total), `loops` | +| `indices` | every runtime index site with its `proven` or `clamped` status and reason | +| `fp_domain` | every domain-sensitive site and its verdict (a sign claim holds for non-NaN operands) | +| `fp_equality_sites` | walk positions that compare reals with `==` or `!=` | +| `int_division_sites` | positions of integer divisions | +| `int_ub_sites` | positions of every `Index` `+`, `-`, `*`, negation and true division `/`: infix in the C++ build (signed overflow, a zero divisor and `MIN / -1` undefined), defined through `hawk_i64_*` in the C | +| `literal_specials` | `NAN` / `INFINITY` literals the unit spells | +| `libm` | every libm function the unit calls, sorted | +| `helpers` | the hawk-owned helper functions emitted | +| `core` | the aether core functions inlined | +| `core_version` | the aether core's version, `aether-c/1` | +| `deviations` | the deviations table below, plus one `float-equality` note carrying the `fp_equality_sites` list and one `defined_where_cpp_is_ub` note carrying the `int_ub_sites` list, each when there are any | +| `unsupported_checked` | the refused features that were checked | +| `audit` | `{"hits": []}` (a hit refuses before the report exists) | +| `checks` | 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 | +|---|---|---| +| `misra-c2012-8.7` | 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 | +| `misra-c2012-9.3` | 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 (`audit.init`) refuses any `= {` in the `.c`, so the suppression cannot hide a real one | +| `misra-c2012-2.3` | advisory | the `HAWK_EMBEDDED_TYPES` block is the same text in every hawk header of a build; a unit that reads no integer still declares `hawk_int_t` for the units beside it, and the C11 size assert that uses it is compiled out under the C99 the checker analyses | + +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 samples and cross-sample reads +(`hawk.all_samples`), 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. diff --git a/docs/content/examples.md b/docs/content/examples.md index fa88fda..f707c6e 100644 --- a/docs/content/examples.md +++ b/docs/content/examples.md @@ -18,5 +18,6 @@ examples/03_kernelkind_reuse examples/04_hawk_next_to_warp examples/autodiff_vs_torch examples/two_body_hybrid +examples/05_embedded_c_target vocabulary/when_hawk_cant_spell_it ``` diff --git a/docs/content/examples/05_embedded_c_target.ipynb b/docs/content/examples/05_embedded_c_target.ipynb new file mode 100644 index 0000000..ea42bda --- /dev/null +++ b/docs/content/examples/05_embedded_c_target.ipynb @@ -0,0 +1,1119 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "emb-00", + "metadata": {}, + "source": [ + "# Example: a kernel as standalone MISRA C\n", + "\n", + "One task: take a kernel you already run on the CPU and emit it as a\n", + "standalone C99 source pair for an embedded build, then show that the C\n", + "computes the same bits as hawk's own host build. The output includes no\n", + "hawk, aether or eagle header, so it compiles on its own.\n", + "\n", + "**Time:** ~5 min · **Runs on:** CPU · **You need:** hawk, NumPy and a C\n", + "compiler (`cc`, `clang` or `gcc`); with `cppcheck` installed the MISRA C:2012 check\n", + "runs too; reference: [The embedded C target](../embedded.md)" + ] + }, + { + "cell_type": "markdown", + "id": "colabnote", + "metadata": { + "tags": [ + "colab-setup" + ] + }, + "source": [ + "**Colab or Kaggle:** run this first cell (it installs the packages and downloads the helper files this notebook uses). Elsewhere it only checks the imports." + ] + }, + { + "cell_type": "code", + "id": "colabsetup", + "metadata": { + "tags": [ + "colab-setup", + "hide-input" + ] + }, + "execution_count": null, + "outputs": [], + "source": [ + "# Colab setup, generated by python -m raptor_docs.nb colab_setup (do not edit).\n", + "import os\n", + "\n", + "try:\n", + " import google.colab # noqa: F401\n", + " HOSTED = True # a Colab runtime\n", + "except ImportError:\n", + " HOSTED = \"KAGGLE_KERNEL_RUN_TYPE\" in os.environ # a Kaggle kernel\n", + "\n", + "if HOSTED:\n", + " import importlib.metadata, pathlib, re, shutil, subprocess, sys, urllib.request\n", + "\n", + " gpu = bool(shutil.which(\"nvidia-smi\")) and subprocess.run([\"nvidia-smi\", \"-L\"], capture_output=True).returncode == 0\n", + " extra = \"\"\n", + " if gpu:\n", + " # the CUDA major the runtime already has: a CuPy that is installed, else torch's, else the driver's\n", + " major = None\n", + " for m in (\"13\", \"12\"):\n", + " try:\n", + " importlib.metadata.version(f\"cupy-cuda{m}x\")\n", + " major = m\n", + " break\n", + " except importlib.metadata.PackageNotFoundError:\n", + " pass\n", + " if major is None:\n", + " try:\n", + " import torch\n", + " major = \"13\" if str(torch.version.cuda).startswith(\"13\") else None\n", + " except ImportError:\n", + " pass\n", + " if major is None:\n", + " smi = re.search(r\"CUDA Version: (\\d+)\", subprocess.run([\"nvidia-smi\"], capture_output=True, text=True).stdout)\n", + " major = \"13\" if smi and int(smi.group(1)) >= 13 else \"12\"\n", + " extra = f\"[cuda{major}]\"\n", + " print(f\"GPU found: installing the {extra} extras to match this runtime.\")\n", + " pre = [\"--pre\"] if os.environ.get(\"RAPTOR_PRE\") else [] # optional: test a release candidate\n", + " ref = os.environ.get(\"RAPTOR_REF\", \"main\") # optional: fetch the helper files from this git ref\n", + " if pre or \"RAPTOR_REF\" in os.environ:\n", + " print(f\"Pre-release test: installing with --pre, helper files from {ref}.\")\n", + " # our packages with --upgrade, so a runtime that kept an older release gets the current one\n", + " subprocess.check_call([sys.executable, \"-m\", \"pip\", \"install\", \"-q\", \"--upgrade\", *pre, f\"raptor-hawk{extra}\"])\n", + " if not gpu:\n", + " print(\"Running on the CPU (no GPU in this runtime). For a GPU: \" + (\"Settings → Accelerator → GPU T4 x2.\" if \"KAGGLE_KERNEL_RUN_TYPE\" in os.environ else \"Runtime → Change runtime type → T4 GPU.\"))\n", + "\n", + "try:\n", + " import hawk # noqa: F401\n", + "except ImportError as err:\n", + " raise ImportError(\n", + " \"hawk not found. Install with `pip install raptor-hawk`. For a GPU, add [cuda12] (or [cuda13]) to raptor-hawk.\"\n", + " ) from err" + ] + }, + { + "cell_type": "markdown", + "id": "emb-01", + "metadata": {}, + "source": [ + "## The kernel\n", + "\n", + "Quadratic drag on a three-component state, the same kernel the\n", + "[reference page](../embedded.md) uses. Nothing in it knows about C:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "emb-02", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:16.806679Z", + "iopub.status.busy": "2026-10-17T09:34:16.806556Z", + "iopub.status.idle": "2026-10-17T09:34:16.837487Z", + "shell.execute_reply": "2026-10-17T09:34:16.837085Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import hawk\n", + "from hawk import Mutable, Param, Vector\n", + "from hawk.math import norm\n", + "\n", + "\n", + "@hawk.kernel\n", + "def drag(state: Vector[3], rho: Param, out: Mutable[Vector[3]]):\n", + " out = -rho * state * norm(state)\n", + "\n", + "\n", + "drag" + ] + }, + { + "cell_type": "markdown", + "id": "emb-03", + "metadata": {}, + "source": [ + "## Export it\n", + "\n", + "`hawk.embedded.export` runs the whole pipeline: it checks the kernel's IR\n", + "for anything the C target cannot build, renders the C, audits the text,\n", + "and compiles it under strict `-std=c99` and `-std=c11` warnings-as-errors.\n", + "When `cppcheck` is on the `PATH` it also runs the MISRA addon. It writes\n", + "three files:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "emb-04", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:16.838624Z", + "iopub.status.busy": "2026-10-17T09:34:16.838557Z", + "iopub.status.idle": "2026-10-17T09:34:17.015842Z", + "shell.execute_reply": "2026-10-17T09:34:17.015394Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "['drag.c', 'drag.embedded.json', 'drag.h']" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import pathlib\n", + "import tempfile\n", + "\n", + "import hawk.embedded\n", + "\n", + "work = pathlib.Path(tempfile.mkdtemp())\n", + "art = hawk.embedded.export(drag, work / \"c\")\n", + "sorted(p.name for p in (work / \"c\").iterdir())" + ] + }, + { + "cell_type": "markdown", + "id": "emb-05", + "metadata": {}, + "source": [ + "The header is the whole contract for whoever calls the unit. It records\n", + "where the code came from (hawk version and digests of the walk and of the\n", + "kernel's source), the worst-case budgets, and one row per argument: role,\n", + "direction, element type, shape and whether the caller must initialise it.\n", + "Planes are structure-of-arrays with a pitch of `n_samples`:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "emb-06", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:17.016919Z", + "iopub.status.busy": "2026-10-17T09:34:17.016857Z", + "iopub.status.idle": "2026-10-17T09:34:17.018681Z", + "shell.execute_reply": "2026-10-17T09:34:17.018357Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/* drag.h -- generated by hawk 0.5.0; do not edit.\n", + " * TRACEABILITY:\n", + " * kernel drag\n", + " * hawk version 0.5.0\n", + " * walk digest bcc450195db6caa95fb54687b8b395c13f1bcffbf63d1c0ef69191cda2624a84\n", + " * kernel sha256 9361b9391b4dc2bbfbece7e388f9463910515d535cc0cf6298af0e7fd6749506\n", + " * scalar mode float64\n", + " * target c, MISRA C:2012+AMD, C99 dialect, serial\n", + " * profile EmbeddedProfile(max_trips=None, max_ops=None, max_stack_bytes=None, fp_domain='record', index_policy='clamp', require_cppcheck=False, c_std='c99')\n", + " * aether core aether-c/1\n", + " * BUDGETS (per sample, worst case):\n", + " * loop trips 0\n", + " * ops arith32 0, arith64 14, special32 0, special64 0, loads 3, gathers 0, chain32 0, chain64 3, chainSpecial32 0, chainSpecial64 0, stores 3\n", + " * fp64 ops 14\n", + " * static bytes tapes 0, carries 0, temporaries 72, total 72\n", + " * libm sqrt\n", + " * INTERFACE CONTRACT: planes are SoA; rank-1 extent N stores component c of sample i at\n", + " * base[(c * n_samples) + i]; rank-2 (R,C) at base[(((r*C)+c) * n_samples) + i]; rank-0 at\n", + " * base[i]. The pitch is ALWAYS n_samples, also for a partition [base, base+count).\n", + " * A runtime-length plane carries its length in the int64_t field after its pointer.\n", + " * field role dir dtype shape init-by-caller\n", + " * mut_out mutable out f64 (3,) no\n", + " * vin_state vec_in in f64 (3,) yes\n", + " * uni_rho uniform in f64 () yes (by value)\n", + " * drag_c() validates args and every pointer, 0 <= base <= n_samples and\n", + " * 0 <= count <= n_samples - base, returning HAWK_E_NULL / HAWK_E_RANGE untouched otherwise.\n", + " * Re-entrant, allocation-free. */\n", + "#ifndef HAWK_DRAG_H\n", + "#define HAWK_DRAG_H\n", + "#include \n", + "#include \n", + "#ifndef HAWK_EMBEDDED_TYPES\n", + "#define HAWK_EMBEDDED_TYPES 64\n", + "typedef double hawk_real_t;\n", + "typedef int64_t hawk_int_t;\n", + "typedef enum { HAWK_OK = 0, HAWK_E_NULL = 1, HAWK_E_RANGE = 2 } hawk_status_t;\n", + "#elif HAWK_EMBEDDED_TYPES != 64\n", + "#error \"drag.h: float64 unit; another hawk header in this build defined hawk_real_t at a different width\"\n", + "#endif\n", + "typedef struct {\n", + " hawk_real_t* mut_out; /* mutable out Vector[3] SoA, pitch n_samples */\n", + " const hawk_real_t* vin_state; /* vec_in in Vector[3] SoA, pitch n_samples */\n", + " hawk_real_t uni_rho; /* uniform in Scalar by value */\n", + "} drag_args_t;\n", + "hawk_status_t drag_c(const drag_args_t* args, int64_t base, int64_t count, int64_t n_samples);\n", + "#endif\n", + "\n" + ] + } + ], + "source": [ + "print(art.h_path.read_text())" + ] + }, + { + "cell_type": "markdown", + "id": "emb-07", + "metadata": {}, + "source": [ + "The `.c` holds the computation. Everything it calls is inlined into the\n", + "file: aether's reference C for each vector operation (`aether_c_*`), and\n", + "hawk's own load and store helpers (`hawk_*`). Here is the per-sample loop\n", + "and the entry that validates its arguments before touching any plane:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "emb-08", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:17.019525Z", + "iopub.status.busy": "2026-10-17T09:34:17.019477Z", + "iopub.status.idle": "2026-10-17T09:34:17.021175Z", + "shell.execute_reply": "2026-10-17T09:34:17.020757Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "/* drag.c -- generated by hawk 0.5.0; do not edit. See drag.h. */\n", + "#include \"drag.h\"\n", + "#include \n", + "#include \n", + "#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 201112L)\n", + "_Static_assert(sizeof(hawk_real_t) == 8U, \"drag.c: hawk_real_t is not the float64 width\");\n", + "_Static_assert(sizeof(hawk_int_t) == 8U, \"drag.c: hawk_int_t is not 64-bit\");\n", + "#endif\n", + "\n", + "#ifndef AETHER_C_F64_T\n", + "#define AETHER_C_F64_T\n", + "typedef double aether_c_f64_t;\n", + "#endif\n", + "\n", + "#ifndef AETHER_C_F64_VEC3_T\n", + "#define AETHER_C_F64_VEC3_T\n", + "typedef struct { aether_c_f64_t v[3]; } aether_c_f64_vec3_t;\n", + "#endif\n", + "\n", + "/* aether-c: vec_norm (3,) f64 aether-c/1 */\n", + "static inline aether_c_f64_t aether_c_f64_vec3_norm(aether_c_f64_vec3_t a)\n", + "{\n", + " return sqrt(((a.v[2] * a.v[2]) + ((a.v[1] * a.v[1]) + (a.v[0] * a.v[0]))));\n", + "}\n", + "\n", + "/* aether-c: vec_scale (3,) f64 aether-c/1 */\n", + "static inline aether_c_f64_vec3_t aether_c_f64_vec3_scale(aether_c_f64_vec3_t a, aether_c_f64_t s)\n", + "{\n", + " aether_c_f64_vec3_t out;\n", + " out.v[0] = (s * a.v[0]);\n", + " out.v[1] = (s * a.v[1]);\n", + " out.v[2] = (s * a.v[2]);\n", + " return out;\n", + "}\n", + "\n", + "/* hawk: load (3,) float64 */\n", + "static inline aether_c_f64_vec3_t hawk_vec3_load(const hawk_real_t* plane, int64_t i, int64_t pitch)\n", + "{\n", + " aether_c_f64_vec3_t out;\n", + " out.v[0] = plane[i];\n", + " out.v[1] = plane[pitch + i];\n", + " out.v[2] = plane[(2 * pitch) + i];\n", + " return out;\n", + "}\n", + "\n", + "/* hawk: store (3,) float64 */\n", + "static inline void hawk_vec3_store(hawk_real_t* plane, int64_t i, int64_t pitch, aether_c_f64_vec3_t x)\n", + "{\n", + " plane[i] = x.v[0];\n", + " plane[pitch + i] = x.v[1];\n", + " plane[(2 * pitch) + i] = x.v[2];\n", + "}\n", + "\n", + "static void drag_run(const drag_args_t* args, int64_t base, int64_t count, int64_t n_samples)\n", + "{\n", + " hawk_real_t* const mut_out = args->mut_out;\n", + " const hawk_real_t* const vin_state = args->vin_state;\n", + " const hawk_real_t uni_rho = args->uni_rho;\n", + " const int64_t hawk_end = base + count;\n", + " for (int64_t hawk_i = base; hawk_i < hawk_end; ++hawk_i) {\n", + " const aether_c_f64_vec3_t vin_state_i = hawk_vec3_load(vin_state, hawk_i, n_samples);\n", + " const aether_c_f64_vec3_t t3 = aether_c_f64_vec3_scale(vin_state_i, (-uni_rho));\n", + " const aether_c_f64_vec3_t t5 = aether_c_f64_vec3_scale(t3, aether_c_f64_vec3_norm(vin_state_i));\n", + " hawk_vec3_store(mut_out, hawk_i, n_samples, t5);\n", + " }\n", + "}\n", + "\n", + "hawk_status_t drag_c(const drag_args_t* args, int64_t base, int64_t count, int64_t n_samples)\n", + "{\n", + " hawk_status_t status;\n", + " if (args == NULL) {\n", + " status = HAWK_E_NULL;\n", + " } else if ((args->mut_out == NULL) || (args->vin_state == NULL)) {\n", + " status = HAWK_E_NULL;\n", + " } else if ((base < INT64_C(0)) ||\n", + " (base > n_samples) ||\n", + " (count < INT64_C(0)) ||\n", + " (count > (n_samples - base))) {\n", + " status = HAWK_E_RANGE;\n", + " } else {\n", + " drag_run(args, base, count, n_samples);\n", + " status = HAWK_OK;\n", + " }\n", + " return status;\n", + "}\n", + "\n" + ] + } + ], + "source": [ + "print(art.c_path.read_text())" + ] + }, + { + "cell_type": "markdown", + "id": "emb-09", + "metadata": {}, + "source": [ + "## The report\n", + "\n", + "`drag.embedded.json` carries what the header summarises, in a form a\n", + "safety review or a CI gate can read. The budgets are per sample:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "emb-10", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:17.021915Z", + "iopub.status.busy": "2026-10-17T09:34:17.021866Z", + "iopub.status.idle": "2026-10-17T09:34:17.023479Z", + "shell.execute_reply": "2026-10-17T09:34:17.023184Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "loop trips : 0\n", + "fp64 ops : 14\n", + "stack bytes : 72\n", + "libm : ['sqrt']\n", + "aether core : ['aether_c_f64_vec3_norm', 'aether_c_f64_vec3_scale'] aether-c/1\n", + "hawk helpers: ['hawk_vec3_load', 'hawk_vec3_store']\n" + ] + } + ], + "source": [ + "report = art.report\n", + "budgets = report[\"budgets\"]\n", + "print(\"loop trips :\", budgets[\"max_trips\"])\n", + "print(\"fp64 ops :\", budgets[\"fp64_ops\"])\n", + "print(\"stack bytes :\", budgets[\"static_bytes\"][\"total\"])\n", + "print(\"libm :\", report[\"libm\"])\n", + "print(\"aether core :\", report[\"core\"], report[\"core_version\"])\n", + "print(\"hawk helpers:\", report[\"helpers\"])" + ] + }, + { + "cell_type": "markdown", + "id": "emb-11", + "metadata": {}, + "source": [ + "The interface rows are the machine-readable form of the header's\n", + "contract table:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "emb-12", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:17.024261Z", + "iopub.status.busy": "2026-10-17T09:34:17.024204Z", + "iopub.status.idle": "2026-10-17T09:34:17.025790Z", + "shell.execute_reply": "2026-10-17T09:34:17.025421Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "mut_out mutable out f64 [3] init by caller: False\n", + "vin_state vec_in in f64 [3] init by caller: True\n", + "uni_rho uniform in f64 [] init by caller: True\n" + ] + } + ], + "source": [ + "for row in report[\"interface\"]:\n", + " print(f'{row[\"field\"]:10} {row[\"role\"]:8} {row[\"direction\"]:6} '\n", + " f'{row[\"dtype\"]:8} {str(row[\"shape\"]):6} init by caller: {row[\"init_by_caller\"]}')" + ] + }, + { + "cell_type": "markdown", + "id": "emb-13", + "metadata": {}, + "source": [ + "And the checks block records exactly what ran. A missing `cppcheck`\n", + "shows up as `skipped`, never as a silent pass; pass\n", + "`EmbeddedProfile(require_cppcheck=True)` to make it a refusal instead.\n", + "Every suppressed rule is a documented deviation with its reason in\n", + "`report[\"deviations\"]`:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "emb-14", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:17.026504Z", + "iopub.status.busy": "2026-10-17T09:34:17.026463Z", + "iopub.status.idle": "2026-10-17T09:34:17.028120Z", + "shell.execute_reply": "2026-10-17T09:34:17.027771Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cc -std=c99 rc=0\n", + "cc -std=c11 rc=0\n", + "cppcheck : rc 0 findings []\n", + "deviations : ['misra-c2012-8.7', 'misra-c2012-9.3', 'misra-c2012-2.3']\n" + ] + } + ], + "source": [ + "checks = report[\"checks\"]\n", + "for cc in checks[\"cc\"]:\n", + " print(f'cc -std={cc[\"std\"]:4} rc={cc[\"rc\"]}')\n", + "cppcheck = checks[\"cppcheck\"]\n", + "if \"skipped\" in cppcheck:\n", + " print(\"cppcheck :\", cppcheck[\"skipped\"])\n", + "else:\n", + " print(\"cppcheck : rc\", cppcheck[\"rc\"], \"findings\", cppcheck[\"findings\"])\n", + "print(\"deviations :\", [d[\"rule\"] for d in report[\"deviations\"]])" + ] + }, + { + "cell_type": "markdown", + "id": "emb-15", + "metadata": {}, + "source": [ + "## The same bits as the host build\n", + "\n", + "The C target is serial and bit-identical to hawk's host C++ build under\n", + "the `native` profile, so the host build is the reference. Build it, run it,\n", + "then compile the C into a shared library and call it through `ctypes`.\n", + "Both sides keep floating-point contraction off: the C is compiled with\n", + "`-ffp-contract=off`, as any embedded build of this unit must be. The\n", + "comparison is on raw bytes, so a difference in the last bit, a `-0.0`\n", + "or a NaN payload would all count.\n", + "\n", + "The `ctypes` structure below mirrors `drag_args_t` from the header, field\n", + "for field:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "emb-16", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:17.028789Z", + "iopub.status.busy": "2026-10-17T09:34:17.028741Z", + "iopub.status.idle": "2026-10-17T09:34:18.231790Z", + "shell.execute_reply": "2026-10-17T09:34:18.231237Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "float64: status 0, 30000 values, bit-identical: True\n", + "float32: status 0, 30000 values, bit-identical: True\n" + ] + } + ], + "source": [ + "import ctypes\n", + "\n", + "import hawk.artifact\n", + "import numpy as np\n", + "from hawk.embedded import toolchain\n", + "\n", + "C_REAL = {\"float64\": ctypes.c_double, \"float32\": ctypes.c_float}\n", + "\n", + "\n", + "def c_drag(mode):\n", + " \"\"\"Export drag at ``mode``, compile it, and return a callable entry.\"\"\"\n", + " unit = hawk.embedded.export(drag, work / f\"c_{mode}\", mode=mode)\n", + " lib = ctypes.CDLL(str(toolchain.compile_shared(unit.c_path,\n", + " work / f\"libdrag_{mode}.so\")))\n", + " real = C_REAL[mode]\n", + "\n", + " class DragArgs(ctypes.Structure): # drag_args_t\n", + " _fields_ = [(\"mut_out\", ctypes.POINTER(real)),\n", + " (\"vin_state\", ctypes.POINTER(real)),\n", + " (\"uni_rho\", real)]\n", + "\n", + " lib.drag_c.argtypes = [ctypes.POINTER(DragArgs),\n", + " ctypes.c_int64, ctypes.c_int64, ctypes.c_int64]\n", + " lib.drag_c.restype = ctypes.c_int # hawk_status_t\n", + "\n", + " def call(state, rho, out, base=0, count=None):\n", + " n = state.shape[1]\n", + " ptr = ctypes.POINTER(real)\n", + " args = DragArgs(None if out is None else out.ctypes.data_as(ptr),\n", + " state.ctypes.data_as(ptr), rho)\n", + " return lib.drag_c(ctypes.byref(args), base,\n", + " n - base if count is None else count, n)\n", + "\n", + " return call\n", + "\n", + "\n", + "rng = np.random.default_rng(7)\n", + "n = 10_000\n", + "state64 = rng.normal(size=(3, n)) * 10.0 ** rng.integers(-3, 4, size=n)\n", + "rho = 1.225\n", + "\n", + "for mode, dtype in ((\"float64\", np.float64), (\"float32\", np.float32)):\n", + " state = np.ascontiguousarray(state64.astype(dtype))\n", + " rho_m = float(dtype(rho))\n", + "\n", + " hawk.artifact.build(drag, work / f\"host_{mode}\", targets=(\"host\",),\n", + " mode=mode, host_profile=\"native\")\n", + " out_host = np.zeros((3, n), dtype)\n", + " hawk.run(hawk.load(work / f\"host_{mode}\", \"drag\"),\n", + " state=state, rho=rho_m, out=out_host)\n", + "\n", + " out_c = np.zeros((3, n), dtype)\n", + " status = c_drag(mode)(state, rho_m, out_c)\n", + "\n", + " same = out_c.tobytes() == out_host.tobytes()\n", + " print(f\"{mode}: status {status}, {out_c.size} values, bit-identical: {same}\")" + ] + }, + { + "cell_type": "markdown", + "id": "emb-17", + "metadata": {}, + "source": [ + "The float32 unit is float end to end: `typedef float hawk_real_t`, `sqrtf`\n", + "and no `double` anywhere, so nothing is silently promoted:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "emb-18", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:18.233648Z", + "iopub.status.busy": "2026-10-17T09:34:18.233413Z", + "iopub.status.idle": "2026-10-17T09:34:18.236268Z", + "shell.execute_reply": "2026-10-17T09:34:18.235865Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sqrtf calls: 1 | 'double' tokens: 0\n" + ] + } + ], + "source": [ + "f32 = (work / \"c_float32\" / \"drag.c\").read_text()\n", + "print(\"sqrtf calls:\", f32.count(\"sqrtf(\"), \"| 'double' tokens:\", f32.count(\"double\"))" + ] + }, + { + "cell_type": "markdown", + "id": "emb-19", + "metadata": {}, + "source": [ + "## The entry checks its arguments\n", + "\n", + "`drag_c` takes a partition `[base, base + count)` of the batch and returns\n", + "a status instead of trusting its inputs. A partition writes its own\n", + "samples only, and the pitch stays `n_samples`:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "emb-20", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:18.237629Z", + "iopub.status.busy": "2026-10-17T09:34:18.237551Z", + "iopub.status.idle": "2026-10-17T09:34:18.473171Z", + "shell.execute_reply": "2026-10-17T09:34:18.472739Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "status: 0\n", + "samples written: [2 3 4]\n" + ] + } + ], + "source": [ + "call = c_drag(\"float64\")\n", + "small = np.ascontiguousarray(state64[:, :8])\n", + "part = np.zeros((3, 8))\n", + "print(\"status:\", call(small, rho, part, base=2, count=3))\n", + "print(\"samples written:\", np.flatnonzero(part.any(axis=0)))" + ] + }, + { + "cell_type": "markdown", + "id": "emb-21", + "metadata": {}, + "source": [ + "A range outside the batch, or a null plane, is refused before any plane\n", + "is touched (`HAWK_E_RANGE` is 2, `HAWK_E_NULL` is 1):" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "emb-22", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:18.474481Z", + "iopub.status.busy": "2026-10-17T09:34:18.474406Z", + "iopub.status.idle": "2026-10-17T09:34:18.476386Z", + "shell.execute_reply": "2026-10-17T09:34:18.475994Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "base past the end: 2\n", + "null output plane: 1\n", + "output untouched : True\n" + ] + } + ], + "source": [ + "untouched = np.zeros((3, 8))\n", + "print(\"base past the end:\", call(small, rho, untouched, base=6, count=5))\n", + "print(\"null output plane:\", call(small, rho, None))\n", + "print(\"output untouched :\", not untouched.any())" + ] + }, + { + "cell_type": "markdown", + "id": "emb-23", + "metadata": {}, + "source": [ + "## What the verify pass refuses\n", + "\n", + "Before any C is rendered, a verify pass walks the kernel's IR. By default\n", + "anything it cannot guarantee is refused with an error that names the\n", + "kernel, the node and the rule, and nothing is written. An\n", + "`EmbeddedProfile` adds caps and tightens policies.\n", + "\n", + "**Index safety.** A table read uses a runtime index, which the IR cannot\n", + "prove in range. The C clamps it to the table's length, a no-op on every\n", + "call the host build defines, and the report lists the site:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "emb-24", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:18.477444Z", + "iopub.status.busy": "2026-10-17T09:34:18.477379Z", + "iopub.status.idle": "2026-10-17T09:34:18.645676Z", + "shell.execute_reply": "2026-10-17T09:34:18.645244Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[{'node': 2,\n", + " 'kind': 'table_read',\n", + " 'status': 'clamped',\n", + " 'reason': 'a runtime index into a runtime-length plane',\n", + " 'length': 'lut_table_len'}]" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from hawk import Index, Scalar, Table\n", + "from hawk.embedded import EmbeddedProfile\n", + "from hawk.ir import HawkError\n", + "\n", + "\n", + "@hawk.kernel\n", + "def gather(table: Table[Scalar], where: Index, y: Mutable[Scalar]):\n", + " y = table.at(where)\n", + "\n", + "\n", + "hawk.embedded.export(gather, work / \"gather\").report[\"indices\"]" + ] + }, + { + "cell_type": "markdown", + "id": "emb-25", + "metadata": {}, + "source": [ + "A project that forbids unproven indices says so in the profile, and the\n", + "export refuses instead:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "emb-26", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:18.646836Z", + "iopub.status.busy": "2026-10-17T09:34:18.646776Z", + "iopub.status.idle": "2026-10-17T09:34:18.649031Z", + "shell.execute_reply": "2026-10-17T09:34:18.648692Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "'gather': the C target refuses node 2 (table_read) under index.unproven: the index is not provably in range (a runtime index into a runtime-length plane); index with a static loop index or a literal, or export with index_policy='clamp'\n", + "anything written: False\n" + ] + } + ], + "source": [ + "try:\n", + " hawk.embedded.export(gather, work / \"gather_strict\",\n", + " profile=EmbeddedProfile(index_policy=\"refuse\"))\n", + "except HawkError as err:\n", + " print(err)\n", + "print(\"anything written:\", (work / \"gather_strict\").exists())" + ] + }, + { + "cell_type": "markdown", + "id": "emb-27", + "metadata": {}, + "source": [ + "**Budgets.** A cap on the worst-case loop trips per sample:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "emb-28", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:18.650085Z", + "iopub.status.busy": "2026-10-17T09:34:18.650031Z", + "iopub.status.idle": "2026-10-17T09:34:18.652907Z", + "shell.execute_reply": "2026-10-17T09:34:18.652594Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "'settle': the C target refuses node loop3 (loop) under budget.trips: 5000 worst-case loop trips per sample exceed max_trips=1000 (loop3 runs 5000); shorten the range or raise the cap\n" + ] + } + ], + "source": [ + "@hawk.kernel\n", + "def settle(x: Scalar, y: Mutable[Scalar]):\n", + " s = x\n", + " for _k in range(5000):\n", + " s = s * 0.5 + 1.0\n", + " y = s\n", + "\n", + "\n", + "try:\n", + " hawk.embedded.export(settle, work / \"settle\",\n", + " profile=EmbeddedProfile(max_trips=1000))\n", + "except HawkError as err:\n", + " print(err)" + ] + }, + { + "cell_type": "markdown", + "id": "emb-29", + "metadata": {}, + "source": [ + "**Floating-point domain.** Every `sqrt`, `log`, division and similar site\n", + "gets a sign verdict. `sqrt` of a squared norm or of `1 + x*x` is provably\n", + "safe; `sqrt` of a raw input has no provable sign. The default records the\n", + "verdicts in the report; `fp_domain=\"refuse\"` turns an unknown into a\n", + "refusal:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "emb-30", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:18.653875Z", + "iopub.status.busy": "2026-10-17T09:34:18.653822Z", + "iopub.status.idle": "2026-10-17T09:34:18.814956Z", + "shell.execute_reply": "2026-10-17T09:34:18.814410Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[{'node': 1, 'op': 'sqrt', 'status': 'unknown', 'reason': 'sqrt needs a nonneg operand; its sign is unknown'}]\n", + "'root': the C target refuses node 1 (sqrt) under fp.domain: sqrt needs a nonneg operand; its sign is unknown; bound the operand into the domain (abs, a positive offset, clip) or export with fp_domain='record'\n" + ] + } + ], + "source": [ + "from hawk.math import sqrt\n", + "\n", + "\n", + "@hawk.kernel\n", + "def root(x: Scalar, y: Mutable[Scalar]):\n", + " y = sqrt(x)\n", + "\n", + "\n", + "print(hawk.embedded.export(root, work / \"root\").report[\"fp_domain\"])\n", + "try:\n", + " hawk.embedded.export(root, work / \"root_strict\",\n", + " profile=EmbeddedProfile(fp_domain=\"refuse\"))\n", + "except HawkError as err:\n", + " print(err)" + ] + }, + { + "cell_type": "markdown", + "id": "emb-31", + "metadata": {}, + "source": [ + "**Unsupported features.** Some features have no C spelling in this\n", + "version, random draws among them. They are refused by name, with the\n", + "alternative:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "emb-32", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:18.816017Z", + "iopub.status.busy": "2026-10-17T09:34:18.815935Z", + "iopub.status.idle": "2026-10-17T09:34:18.818062Z", + "shell.execute_reply": "2026-10-17T09:34:18.817718Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "'draw': the C target refuses node 2 (random_uniform) under unsupported.random: a random draw lowers to aether's counter-based generator; no counter-based draw is built for C yet; draw in the caller and pass the values in as a plane\n" + ] + } + ], + "source": [ + "import hawk.math as hm\n", + "\n", + "\n", + "@hawk.kernel\n", + "def draw(seed: Index, counter: Index, y: Mutable[Scalar]):\n", + " y = hm.random.uniform(seed, counter)\n", + "\n", + "\n", + "try:\n", + " hawk.embedded.export(draw, work / \"draw\")\n", + "except HawkError as err:\n", + " print(err)" + ] + }, + { + "cell_type": "markdown", + "id": "emb-33", + "metadata": {}, + "source": [ + "## Through the build door\n", + "\n", + "`c` is also a build target beside `host` and `cuda`, so one call can\n", + "produce the host library and the C unit together. The C never enters the\n", + "eagle manifest:" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "emb-34", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:18.818851Z", + "iopub.status.busy": "2026-10-17T09:34:18.818798Z", + "iopub.status.idle": "2026-10-17T09:34:18.997290Z", + "shell.execute_reply": "2026-10-17T09:34:18.996843Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['.hawk_unit', 'drag.c', 'drag.cpp', 'drag.embedded.json', 'drag.h', 'drag.json', 'drag.so', 'manifest.json']\n", + "exec_targets: ['host']\n" + ] + } + ], + "source": [ + "import json\n", + "\n", + "hawk.build([drag], work / \"both\", targets=(\"host\", \"c\"))\n", + "manifest = json.loads((work / \"both\" / \"manifest.json\").read_text())\n", + "print(sorted(p.name for p in (work / \"both\").iterdir()))\n", + "print(\"exec_targets:\", manifest[\"exec_targets\"])" + ] + }, + { + "cell_type": "markdown", + "id": "emb-35", + "metadata": {}, + "source": [ + "A `c`-only build directory is source for an embedded build, not something\n", + "hawk can run, and `hawk.load` says so:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "emb-36", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-17T09:34:18.998372Z", + "iopub.status.busy": "2026-10-17T09:34:18.998317Z", + "iopub.status.idle": "2026-10-17T09:34:19.152278Z", + "shell.execute_reply": "2026-10-17T09:34:19.151866Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "'drag' in /var/folders/x4/0p52hj795wv4k6kyhcq9ndkh0000gn/T/tmp09vwg526/c_only was built for the 'c' target only: it is a standalone C unit (.c, .h, .embedded.json, see hawk.embedded) for an embedded build, not a host artifact hawk.load can run; build it with targets=('host',) to load it here\n" + ] + } + ], + "source": [ + "hawk.build([drag], work / \"c_only\", targets=(\"c\",))\n", + "try:\n", + " hawk.load(work / \"c_only\", \"drag\")\n", + "except HawkError as err:\n", + " print(err)" + ] + }, + { + "cell_type": "markdown", + "id": "emb-37", + "metadata": {}, + "source": [ + "## What just happened\n", + "\n", + "- `hawk.embedded.export` turned an ordinary kernel into `drag.c` and\n", + " `drag.h` with a plain C ABI, plus a JSON report of budgets, interface,\n", + " index sites, floating-point sites and the checks that ran.\n", + "- The C matched the host build byte for byte in float64 and float32,\n", + " because both evaluate every fold in the same order with contraction off.\n", + "- The entry validated its pointers and partition and returned a status.\n", + "- The verify pass refused, by name and before writing anything, what a\n", + " profile ruled out or the C target cannot build.\n", + "\n", + "## Try this\n", + "\n", + "- Compile `drag.c` with `-ffp-contract=fast` on a machine with fused\n", + " multiply-add and compare again: the bits can change, which is why the\n", + " header asks for contraction off.\n", + "- Add a `Terminated` mask to the kernel and read the header: the output\n", + " plane becomes `inout` and the caller must initialise it, because a\n", + " masked sample keeps its previous value.\n", + "- Set `EmbeddedProfile(max_ops=10)` on `drag` and read the refusal." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv-hawk-dev (3.12.14)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/index.md b/docs/index.md index eae4add..e2151aa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -150,6 +150,7 @@ content/examples content/across_samples content/control_vs_warp content/compiling +content/embedded content/interop ``` diff --git a/hawk/__init__.py b/hawk/__init__.py index 4e46dce..9fd6fd9 100644 --- a/hawk/__init__.py +++ b/hawk/__init__.py @@ -80,9 +80,13 @@ "samples_first": ("_plane_layout", "samples_first"), "samples_last": ("_plane_layout", "samples_last")} +#: Submodules ``import hawk`` resolves lazily: the door returns the module +#: itself (``hawk.embedded.export``), imported on first access. +_SUBMODULE_DOORS = ("embedded",) + __all__ = ["__version__", *_AUTHORING, *_TOP_LEVEL_DOORS] -__version__ = "0.6.4" +__version__ = "0.6.5" # --------------------------------------------------------------------------- # @@ -213,6 +217,12 @@ def __getattr__(name: str) -> Any: value = getattr(importlib.import_module(f".{module_name}", __name__), attr) globals()[name] = value return value + if name in _SUBMODULE_DOORS: + import importlib + + value = importlib.import_module(f".{name}", __name__) + globals()[name] = value + return value if name in _AUTHORING: if name in _FROM_IR: from . import ir @@ -232,4 +242,4 @@ def __getattr__(name: str) -> Any: def __dir__() -> list[str]: - return sorted(__all__) + return sorted((*__all__, *_SUBMODULE_DOORS)) diff --git a/hawk/artifact/__init__.py b/hawk/artifact/__init__.py index 75add20..f9d1015 100644 --- a/hawk/artifact/__init__.py +++ b/hawk/artifact/__init__.py @@ -16,6 +16,7 @@ import ctypes from types import SimpleNamespace +from ..ir import HawkError from .bundle import ( STAMP_NAME, Artifact, @@ -119,7 +120,11 @@ def plugins(bundle: Bundle, *, device_loader=None) -> dict: Without one a plugin carries its host entry only. Everything an entry point points into is held on the plugin's - ``_keepalive``, so it stays valid as long as the caller holds it.""" + ``_keepalive``, so it stays valid as long as the caller holds it. + + A kernel built for the ``c`` target alone (a standalone C unit, + :mod:`hawk.embedded`) has no entry point to plan and is refused, as + :func:`hawk.load` refuses its directory.""" from .. import _core registry = None @@ -128,6 +133,13 @@ def plugins(bundle: Bundle, *, device_loader=None) -> dict: registry = device_loader(bundle.manifest_path) out = {} for art in bundle.artifacts: + if not art.entries.keys() - {"c"}: + raise HawkError( + f"{art.name!r} was built for the 'c' target only: it is a " + "standalone C unit (.c, .h, .embedded.json, " + "see hawk.embedded) for an embedded build, with no host or " + "device entry point to plan; build it with targets=('host',) " + "or ('cuda',) for plugins") view = plan_view(art.sidecar) symbol = view.pop("host_entry", None) entries, keep = {}, [] diff --git a/hawk/artifact/bundle.py b/hawk/artifact/bundle.py index 0da1b15..c07ddb1 100644 --- a/hawk/artifact/bundle.py +++ b/hawk/artifact/bundle.py @@ -78,14 +78,19 @@ #: the FALLBACK manifest ``format``, used as-is for ``host`` and overridden #: for ``cuda`` by :func:`_sniff_device_format` below, since the ``cuda`` #: leg can compile to PTX *or* CUBIN depending on the box's toolchain). -_TARGET = {"cuda": (".ptx", ".cu", "ptx"), "host": (".so", ".cpp", "host")} +#: ``c`` is the standalone MISRA-C unit (:mod:`hawk.embedded`): its +#: "artifact" is the ``.h`` a consumer includes, its source the ``.c``; it is +#: never compiled here and never enters the manifest or the sidecar. +_TARGET = {"cuda": (".ptx", ".cu", "ptx"), "host": (".so", ".cpp", "host"), + "c": (".h", ".c", "c")} #: The published extension for each real device format #: :func:`_sniff_device_format` can return (NVRTC/nvcc here never emit a #: ``fatbin``, the third ``eagle.roles.MANIFEST_FORMATS`` member). _DEVICE_EXT = {"ptx": ".ptx", "cubin": ".cubin"} -#: HAWK's backend id -> the manifest's ``exec_targets`` spelling. +#: HAWK's backend id -> the manifest's ``exec_targets`` spelling. ``c`` has +#: none on purpose: a standalone C unit is not something eagle executes. _EXEC_TARGET = {"cuda": "device", "host": "host"} @@ -101,7 +106,9 @@ def _sniff_device_format(path: Path) -> str: @dataclass(frozen=True) class Entry: - """One compiled target of one kernel.""" + """One compiled target of one kernel. A ``c`` entry's ``artifact`` is the + ``.h``, its ``source`` the ``.c`` and its ``report`` the + ``.embedded.json``; ``report`` is ``None`` for every other target.""" backend: str artifact: Path @@ -110,6 +117,7 @@ class Entry: hit: bool seconds: float closure: tuple = field(default=()) + report: Path | None = None @dataclass(frozen=True) @@ -130,22 +138,28 @@ class Bundle: ``digest`` is the unit's own content identity — the same value :func:`_unit_digest` computes and :data:`STAMP_NAME` records, exposed here because a second unit's derivative can name this one as its - primal's home (:func:`_per_kernel_derivative`'s cross-unit reference).""" + primal's home (:func:`_per_kernel_derivative`'s cross-unit reference). + + ``manifest_path``/``manifest`` are ``None`` for a unit built for the + ``c`` target alone: a standalone C unit has nothing for eagle to load.""" directory: Path - manifest_path: Path - manifest: dict + manifest_path: Path | None + manifest: dict | None artifacts: tuple digest: str | None = None def files(self) -> list: - """EVERY file this unit published — the set row greps.""" - out = [self.manifest_path] + """EVERY file this unit published — the set row greps. Each path + once: a c-only artifact's sidecar IS its ``c`` entry's report.""" + out = [] if self.manifest_path is None else [self.manifest_path] for a in self.artifacts: out.append(a.sidecar_path) for entry in a.entries.values(): out += [entry.artifact, entry.source] - return out + if entry.report is not None: + out.append(entry.report) + return list(dict.fromkeys(out)) # -- The unit's CONTENT, computed before anything is written. -------------- # @@ -188,7 +202,7 @@ class _Draft: name: str sidecar: dict sidecar_name: str - entries: dict # target -> (CompileResult, art name, src name) + entries: dict # target -> (result, art name, src name, fmt) files: tuple # the _File records this kernel contributes @@ -308,8 +322,14 @@ def _active_spec(kind) -> ActiveSpec | None: _binding_name("lookup", ACTIVE_COUNT)) +def _native(targets) -> tuple: + """The targets this module compiles and the manifest describes: every + one but ``c``, the standalone C unit :mod:`hawk.embedded` renders.""" + return tuple(t for t in targets if t != "c") + + def _sources(kernel, *, name, smode, kind, targets, layout_sizes_override, - literal_table: bool = False) -> dict: + literal_table: bool = False, embedded_profile=None) -> dict: """Every declared target's emitted TU. No compiler, no disk, no sidecar — the cheap half of a build, and what the unit key is computed from, so an emitter change is a key change even when the walk is unmoved. @@ -319,19 +339,30 @@ def _sources(kernel, *, name, smode, kind, targets, layout_sizes_override, provably wrap the same string. ``literal_table`` rewrites the device TU's ``double`` literals into one - ``__constant__`` table (:func:`hawk.emit.literal_table`).""" - hook = getattr(kernel, "fused_body", None) + ``__constant__`` table (:func:`hawk.emit.literal_table`). + + A ``"c"`` target is :func:`hawk.embedded.render`'s + :class:`~hawk.embedded.EmbeddedSource` under ``embedded_profile`` + (:class:`~hawk.embedded.EmbeddedProfile` when ``None``), never a + :data:`hawk.emit.BACKENDS` render.""" + if not targets: + raise HawkError( + f"{name!r}: no target to build; pass targets=('cuda',), " + "targets=('host',) or targets=('c',)") + for target in targets: + if target not in _TARGET: + raise HawkError( + f"unknown target {target!r}: the valid targets are 'cuda', 'host' " + "and 'c' ('cuda' builds for the GPU, 'host' for the CPU, 'c' a " + "standalone MISRA C unit for an embedded build); pass " + "targets=('cuda',), targets=('host',) or targets=('c',)") + native = _native(targets) + hook = getattr(kernel, "fused_body", None) if native else None body = None if hook is None else hook(kind) segment = _segment_spec(kernel) active = _active_spec(kind) out = {} - for target in targets: - if target not in _TARGET: - raise HawkError( - f"unknown target {target!r}: the valid targets are " - + " and ".join(repr(t) for t in _TARGET) - + " ('cuda' builds for the GPU, 'host' for the CPU); " - + f"pass targets=({next(iter(_TARGET))!r},) or targets=('host',)") + for target in native: out[target] = render_source( name, kernel.sinks, kernel.walk, BACKENDS[target], mode=smode, kind=kind, body=body, segment=segment, active=active, @@ -340,6 +371,13 @@ def _sources(kernel, *, name, smode, kind, targets, layout_sizes_override, layout_sizes_override=layout_sizes_override)) if literal_table and target == "cuda": out[target] = _literal_table(out[target]) + if "c" in targets: + from .. import embedded + + out["c"] = embedded.render( + kernel, name=name, mode=smode.id, kind=kind, + profile=(embedded_profile if embedded_profile is not None + else embedded.EmbeddedProfile())) return out @@ -380,9 +418,47 @@ def _entry_counts_finished(kernel, kind) -> bool: return entry_counts_finished(kernel.walk, render_lane_split(kernel.sinks, kernel.walk, kind=kind)) +@dataclass(frozen=True) +class _EmbeddedResult: + """A ``c`` target's stand-in for a compile result: the fields + :func:`_artifact_of`, :func:`_served` and :func:`_closure_watch` read. + ``key`` is the sha256 of the ``.c`` and ``.h`` texts (no compile cache + entry exists for it, so its closure is empty), ``seconds`` the toolchain + checks' run time.""" + + key: str + hit: bool + seconds: float + closure: tuple = () + + +def _embedded_part(source, profile) -> tuple: + """A ``c`` target's share of a draft: the audit and the toolchain checks + (any finding refuses, before anything is written), then the report + :func:`hawk.embedded.export` writes, built by the same code. Returns + ``(result, report, files)``.""" + from .. import embedded + + embedded.refuse_audit_hits(source) + checks = embedded.check(source, profile=profile) + report = embedded.finish_report(source, checks) + name = source.name + h = hashlib.sha256() + for text in (source.text, source.header): + h.update(_blob(text)) + h.update(b"\0") + result = _EmbeddedResult(h.hexdigest(), False, float(checks.get("seconds", 0.0))) + files = [] + for fname, text in ((f"{name}.c", source.text), (f"{name}.h", source.header), + (f"{name}.embedded.json", embedded.report_text(report))): + blob = _blob(text) + files.append(_File(fname, _digest_bytes(blob), blob=blob)) + return result, report, files + + def _draft(kernel, sources, *, name, smode, kind, defines, cache_dir, device_arch, targets, derivative, host_profile: str = "", opt_level: str = "", - split: dict | None = None) -> _Draft: + embedded_profile=None, split: dict | None = None) -> _Draft: """Compile every target and assemble the unit CONTENT for one kernel. Everything here is a pure function of the inputs plus the @@ -393,16 +469,23 @@ def _draft(kernel, sources, *, name, smode, kind, defines, cache_dir, device_arc The device arch is resolved ONCE here, not per target, and only when ``"cuda"`` is actually one of ``targets``: the resolver may reach the device probe (:func:`hawk.artifact.arch`), so a host-only build must - never pay for it.""" + never pay for it. + + A ``"c"`` target is not compiled: its source is audited and checked + (:func:`_embedded_part`) and contributes ``.c``, ``.h`` and + ``.embedded.json``. The JSON sidecar describes the NATIVE targets + only and is written only when there is one; a c-only draft carries the + embedded report in the sidecar's place and writes no ``.json``.""" + native = _native(targets) entries, files = {}, [] - resolved_arch = arch(device_arch) if "cuda" in targets else "" + resolved_arch = arch(device_arch) if "cuda" in native else "" options = {target: kind.apply(CompileOptions( backend=target, mode=smode.id, arch=resolved_arch, defines=tuple(defines), cache_dir=cache_dir, host_profile=host_profile or "", opt_level=opt_level or "")) - for target in targets} - results = _compile_targets(sources, name, options) - for target in targets: + for target in native} + results = _compile_targets(sources, name, options) if native else {} + for target in native: art_ext, src_ext, fmt = _TARGET[target] source = sources[target] result = results[target] @@ -424,6 +507,18 @@ def _draft(kernel, sources, *, name, smode, kind, defines, cache_dir, device_arc files.append(_File(src_name, _digest_bytes(blob), blob=blob)) entries[target] = (result, art_name, src_name, fmt) + report = None + if "c" in targets: + from ..embedded import EmbeddedProfile + + result, report, c_files = _embedded_part( + sources["c"], embedded_profile if embedded_profile is not None + else EmbeddedProfile()) + files += c_files + entries["c"] = (result, f"{name}.h", f"{name}.c", "c") + if not native: + return _Draft(name, report, f"{name}.embedded.json", entries, tuple(files)) + segment = getattr(kernel, "segment", None) dispatch_override = None if segment is None else ( DispatchInfo(segment.dispatch_name, segment.K, "segmented", segment.units, @@ -437,8 +532,8 @@ def _draft(kernel, sources, *, name, smode, kind, defines, cache_dir, device_arc # choice so the sidecar and manifest never disagree. fmt=entries["cuda"][3] if "cuda" in entries else entries["host"][3], scalar_type=smode.id, - exec_targets=[_EXEC_TARGET[t] for t in targets], - host_entry=f"{name}_host" if "host" in targets else None, + exec_targets=[_EXEC_TARGET[t] for t in native], + host_entry=f"{name}_host" if "host" in native else None, derivative=derivative, lanes=getattr(kernel, "lanes", None), dispatch_override=dispatch_override, @@ -504,7 +599,9 @@ def _artifact_of(draft: _Draft, directory: Path) -> Artifact: entries = { target: Entry(target, directory / art_name, directory / src_name, result.key, result.hit, result.seconds, - tuple(result.closure)) + tuple(result.closure), + directory / f"{draft.name}.embedded.json" if target == "c" + else None) for target, (result, art_name, src_name, _fmt) in draft.entries.items() } return Artifact(draft.name, directory, directory / draft.sidecar_name, @@ -518,8 +615,7 @@ def _served(bundle: Bundle) -> Bundle: compiler and not a lookup.""" artifacts = tuple( Artifact(a.name, a.directory, a.sidecar_path, a.sidecar, - {t: Entry(e.backend, e.artifact, e.source, e.key, True, 0.0, - e.closure) + {t: replace(e, hit=True, seconds=0.0) for t, e in a.entries.items()}) for a in bundle.artifacts ) @@ -535,14 +631,20 @@ def _rehome_bundle(bundle: Bundle, directory: Path) -> Bundle: :func:`_materialize` is what puts them there first.""" artifacts = tuple( Artifact(a.name, directory, directory / a.sidecar_path.name, a.sidecar, - {t: Entry(e.backend, directory / e.artifact.name, - directory / e.source.name, e.key, e.hit, e.seconds, - e.closure) + {t: replace(e, artifact=directory / e.artifact.name, + source=directory / e.source.name, + report=_rehomed(e.report, directory)) for t, e in a.entries.items()}) for a in bundle.artifacts ) - return Bundle(directory, directory / bundle.manifest_path.name, bundle.manifest, - artifacts, digest=bundle.digest) + return Bundle(directory, _rehomed(bundle.manifest_path, directory), + bundle.manifest, artifacts, digest=bundle.digest) + + +def _rehomed(path: Path | None, directory: Path) -> Path | None: + """``path``'s name under ``directory``; ``None`` (no manifest, no + report) stays ``None``.""" + return None if path is None else directory / path.name def _materialize(bundle: Bundle, files, digest: str, directory: Path) -> Bundle: @@ -620,7 +722,8 @@ def build(kernel, directory, *, mode: str = "float64", targets=("cuda", "host"), kind=None, layout_sizes_override=None, defines=(), cache_dir=None, device_arch: str = "", name: str | None = None, derivative: dict | None = None, host_profile: str = "", - opt_level: str = "", literal_table: bool = False) -> Artifact: + opt_level: str = "", literal_table: bool = False, + embedded_profile=None) -> Artifact: """Emit, compile and publish ONE kernel into ``directory``. ``kernel`` is anything carrying ``.name``/``.sinks``/``.walk`` (a @@ -637,7 +740,8 @@ def build(kernel, directory, *, mode: str = "float64", targets=("cuda", "host"), ``$HAWK_OPT_LEVEL``, else :data:`hawk.compile.toolchain.DEFAULT_OPT_LEVEL` (``"O3"``). See :func:`build_bundle`'s own docstring for the full rule. - ``literal_table`` (default ``False``): see :func:`build_bundle`. + ``literal_table`` (default ``False``) and ``embedded_profile``: see + :func:`build_bundle`. Refuses a ``segmented`` dispatch, which needs K artifacts: call :func:`build_bundle` with ``[kernel]`` instead.""" @@ -659,11 +763,12 @@ def build(kernel, directory, *, mode: str = "float64", targets=("cuda", "host"), directory = Path(directory) sources = _sources(kernel, name=name, smode=smode, kind=kind, targets=targets, layout_sizes_override=layout_sizes_override, - literal_table=literal_table) + literal_table=literal_table, embedded_profile=embedded_profile) draft = _draft(kernel, sources, name=name, smode=smode, kind=kind, defines=defines, cache_dir=cache_dir, device_arch=device_arch, targets=targets, derivative=derivative, - host_profile=host_profile, opt_level=opt_level) + host_profile=host_profile, opt_level=opt_level, + embedded_profile=embedded_profile) directory.mkdir(parents=True, exist_ok=True) for f in draft.files: f.write(directory) @@ -745,13 +850,39 @@ def _member_kind(kernel, kind, explicit: bool): return replace(kind, guard=replace(guard, active_set=False)) +def _manifest_of(drafts) -> dict: + """The ``manifest.json`` document over ``drafts``' native targets: one + execution axis (every member must agree), one plugin entry per kernel + naming its device object when it has one, else its host object.""" + axes = {(tuple(d.sidecar["exec_targets"]), d.sidecar["exec_access"], + d.sidecar.get("exec_op")) for d in drafts} + if len(axes) != 1: + raise HawkError( + "a bundle declares ONE execution axis at the manifest level, but " + f"its members disagree: {sorted(axes)}. Split them into two bundles" + ) + axis_targets, access, op = axes.pop() + entries = [ + _manifest.plugin_entry( + d.name, order, + d.entries["cuda"][1] if "cuda" in d.entries else d.entries["host"][1], + d.sidecar_name, + # The real format (``ptx``/``cubin``, read off the compiled + # bytes), not a static "ptx" guess. + d.entries["cuda"][3] if "cuda" in d.entries else "host") + for order, d in enumerate(drafts) + ] + return _manifest.manifest_doc(entries, exec_targets=axis_targets, + exec_access=access, exec_op=op) + + def build_bundle(kernels, directory, *, mode: str = "float64", targets=("cuda", "host"), kind=None, layout_sizes_override=None, defines=(), cache_dir=None, device_arch: str = "", name: str | None = None, derivative: Mapping[str, str] | None = None, host_profile: str = "", opt_level: str = "", - literal_table: bool = False) -> Bundle: + literal_table: bool = False, embedded_profile=None) -> Bundle: """Build every kernel into ``directory`` and write ONE ``manifest.json`` over them. Every member must agree on the execution axis (a manifest-level, not per-entry, declaration) — a set that doesn't agree is @@ -793,12 +924,25 @@ def build_bundle(kernels, directory, *, mode: str = "float64", instructions on architectures that issue a move pair per literal (sm_90), unchanged on the others. Off, the device code is byte-identical to a build without the option. A ``float32`` build ignores it, and the host - build is not affected.""" + build is not affected. + + ``"c"`` in ``targets`` adds each kernel's standalone MISRA C unit + (:mod:`hawk.embedded`: ``.c``, ``.h``, + ``.embedded.json``, audited and toolchain-checked) under + ``embedded_profile`` (:class:`hawk.embedded.EmbeddedProfile` when + ``None``). It never enters the manifest or a sidecar: those describe + the native targets only, and a unit built for ``("c",)`` alone writes + neither (``Bundle.manifest`` is ``None``).""" _bump_builds() #: every CALL, attempt or success (one locked increment) kernels = list(kernels) smode = scalar_mode(mode) directory = Path(directory) targets = tuple(targets) + native = _native(targets) + if "c" in targets and embedded_profile is None: + from ..embedded import EmbeddedProfile + + embedded_profile = EmbeddedProfile() names = [name or k.name for k in kernels] explicit = kind is not None kind = _resolve_bundle_kind(names, kernels, kind) @@ -821,7 +965,10 @@ def build_bundle(kernels, directory, *, mode: str = "float64", device_arch=device_arch, derivative_blocks=per_kernel_derivative, layout_sizes_override=layout_sizes_override, host_profile=host_profile, opt_level=opt_level, - literal_table=literal_table) + literal_table=literal_table, + embedded_profile=repr(embedded_profile) if "c" in targets else "", + source_digests=([getattr(k, "source_digest", None) for k in kernels] + if "c" in targets else ())) memo = _UNIT_MEMO.get(key) if memo is not None: @@ -856,35 +1003,21 @@ def build_bundle(kernels, directory, *, mode: str = "float64", # written. Each draft gets its own per_kernel_derivative[i] block. drafts = [_draft(k, _sources(k, name=n, smode=smode, kind=kd, targets=targets, layout_sizes_override=layout_sizes_override, - literal_table=literal_table), + literal_table=literal_table, + embedded_profile=embedded_profile), name=n, smode=smode, kind=kd, defines=defines, cache_dir=cache_dir, device_arch=device_arch, targets=targets, derivative=d, host_profile=host_profile, opt_level=opt_level, - split=split.get(n)) + embedded_profile=embedded_profile, split=split.get(n)) for k, n, kd, d in zip(kernels, names, kinds, per_kernel_derivative)] - axes = {(tuple(d.sidecar["exec_targets"]), d.sidecar["exec_access"], - d.sidecar.get("exec_op")) for d in drafts} - if len(axes) != 1: - raise HawkError( - "a bundle declares ONE execution axis at the manifest level, but " - f"its members disagree: {sorted(axes)}. Split them into two bundles" - ) - axis_targets, access, op = axes.pop() - entries = [ - _manifest.plugin_entry( - d.name, order, - d.entries["cuda"][1] if "cuda" in d.entries else d.entries["host"][1], - d.sidecar_name, - # The real format (``ptx``/``cubin``, read off the compiled - # bytes), not a static "ptx" guess. - d.entries["cuda"][3] if "cuda" in d.entries else "host") - for order, d in enumerate(drafts) - ] - doc = _manifest.manifest_doc(entries, exec_targets=axis_targets, - exec_access=access, exec_op=op) - manifest_blob = _blob(json.dumps(doc, indent=2) + "\n") - files = tuple(f for d in drafts for f in d.files) + ( - _File("manifest.json", _digest_bytes(manifest_blob), blob=manifest_blob),) + files = tuple(f for d in drafts for f in d.files) + # The manifest describes the NATIVE targets only; a unit built for the + # standalone C target alone has nothing for eagle to load and writes none. + doc = _manifest_of(drafts) if native else None + if doc is not None: + manifest_blob = _blob(json.dumps(doc, indent=2) + "\n") + files += (_File("manifest.json", _digest_bytes(manifest_blob), + blob=manifest_blob),) digest = _unit_digest(files) # Phase 3 — publish idempotently: a directory already holding this @@ -900,8 +1033,9 @@ def build_bundle(kernels, directory, *, mode: str = "float64", _write_stamp(directory, digest, files) _note("published", f"unit {digest[:12]} published at {directory}") - bundle = Bundle(directory, directory / "manifest.json", doc, - tuple(_artifact_of(d, directory) for d in drafts), digest=digest) + bundle = Bundle(directory, None if doc is None else directory / "manifest.json", + doc, tuple(_artifact_of(d, directory) for d in drafts), + digest=digest) if len(_UNIT_MEMO) >= _UNIT_MEMO_LIMIT: _UNIT_MEMO.clear() _UNIT_MEMO[key] = _Unit( @@ -909,5 +1043,3 @@ def build_bundle(kernels, directory, *, mode: str = "float64", tuple((str(directory / f.name), f.digest) for f in files), _closure_watch(drafts), kind) return bundle - - diff --git a/hawk/artifact/unit_cache.py b/hawk/artifact/unit_cache.py index d562b78..147d4a9 100644 --- a/hawk/artifact/unit_cache.py +++ b/hawk/artifact/unit_cache.py @@ -161,7 +161,8 @@ def _first_moved(pairs) -> str | None: def _unit_key(names, walk_digests, *, targets, smode, kind, defines, cache_dir, device_arch, derivative_blocks, layout_sizes_override, host_profile: str = "", opt_level: str = "", - literal_table: bool = False) -> str: + literal_table: bool = False, embedded_profile: str = "", + source_digests=()) -> str: """The per-process identity of a BUILD REQUEST: every input the unit's content is a function of, hashed term by term. Per kernel: :attr:`hawk.ir.walk.Walk.digest` and its name. Bundle-wide: the @@ -182,14 +183,22 @@ def _unit_key(names, walk_digests, *, targets, smode, kind, defines, cache_dir, profile, it changes the AOT nvcc device compile too (``-O`` / ``-Xptxas -O``), so an ``"O0"`` request must never be served an ``"O3"`` unit regardless of which targets this bundle builds; and - ``literal_table``, which changes the device source. + ``literal_table``, which changes the device source; and + ``embedded_profile``, the ``repr`` of the + :class:`hawk.embedded.EmbeddedProfile` a ``"c"`` target renders under + (its caps and policies reach the C text and its report; empty when + ``"c"`` is not a target); and, only when ``"c"`` is a target, each + kernel's ``source_digest`` (``source_digests``, in kernel order: the C + report and header stamp it, and two kernels sharing a walk can differ + in source text) and ``aether_dsc.c_core.CORE_VERSION`` (the inlined + core functions the C unit carries). STATED EXPOSURE, accepted rather than closed: the key names the render's INPUTS, not its output, so an emitter monkeypatched between two builds of the same kernel is invisible to it and would be served the first build's unit. :func:`reset_unit_memo` is the door out.""" h = hashlib.sha256() - h.update(b"hawk.artifact.unit-key/5\n") + h.update(b"hawk.artifact.unit-key/6\n") for part in (_contracts.AETHER_ABI_VERSION, smode.id, repr(kind), "\x00".join(targets), "\x00".join(str(d) for d in defines), arch(device_arch) if "cuda" in targets else "", str(cache_dir), @@ -200,7 +209,12 @@ def _unit_key(names, walk_digests, *, targets, smode, kind, defines, cache_dir, ("host_profile:" + _host_profile(host_profile or None) if "host" in targets else ""), "opt_level:" + _opt_level(opt_level or None), - "literal_table" if literal_table else ""): + "literal_table" if literal_table else "", + ("embedded_profile:" + embedded_profile + if embedded_profile else ""), + ("source_digests:" + "\x00".join(str(d) for d in source_digests) + if "c" in targets else ""), + ("c_core:" + _c_core_version() if "c" in targets else "")): h.update(part.encode("utf-8")) h.update(b"\0") for name, walk_digest in zip(names, walk_digests): @@ -211,6 +225,14 @@ def _unit_key(names, walk_digests, *, targets, smode, kind, defines, cache_dir, return h.hexdigest() +def _c_core_version() -> str: + """``aether_dsc.c_core.CORE_VERSION``, read at call time: the core + functions a ``"c"`` unit inlines, a term of its unit key.""" + from aether_dsc import c_core + + return c_core.CORE_VERSION + + def _closure_watch(drafts) -> ClosureWatch: """Every target's validity record, de-duplicated across the unit. diff --git a/hawk/embedded/__init__.py b/hawk/embedded/__init__.py new file mode 100644 index 0000000..4b4b77d --- /dev/null +++ b/hawk/embedded/__init__.py @@ -0,0 +1,298 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The standalone C target: hawk kernels rendered as freestanding MISRA-C +instead of aether expression templates. The package holds the C dialect +(``dialect``), the spelling tables (``spelling``), the runtime helpers +(``helpers``), the header and ABI layout (``abi``), the verification harness +(``verify``), the rule audit (``audit``), the toolchain driver (``toolchain``) +and the target profile (``profile``).""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from .profile import EmbeddedProfile + +#: The ``generator`` stamp of every report this package writes. +GENERATOR = "hawk.embedded/1" + +#: The ``.embedded.json`` keys, in order: the spec's list, with +#: ``int_division_sites`` and ``int_ub_sites`` where the verify pass puts +#: them (after ``fp_equality_sites``), and ``core``/``core_version``/ +#: ``deviations`` after ``helpers``. +REPORT_KEYS = ( + "kernel", "generator", "hawk_version", "walk_digest", "source_digest", + "scalar_mode", "profile", "interface", "budgets", "indices", "fp_domain", + "fp_equality_sites", "int_division_sites", "int_ub_sites", + "literal_specials", "libm", "helpers", "core", "core_version", "deviations", + "unsupported_checked", "audit", "checks", +) + + +@dataclass(frozen=True) +class EmbeddedSource: + """One rendered C unit: the ``.c`` text, the ``.h`` text, the bare body + (the renderer's output, indented for the sample loop), the verify report + the render completed, the kernel name and the scalar mode id.""" + + text: str + header: str + body: str + report: dict + name: str + mode: str + + +@dataclass(frozen=True) +class EmbeddedArtifact: + """One exported C unit: the kernel name, the directory it was written to, + the three file paths and the report as written.""" + + name: str + directory: Path + c_path: Path + h_path: Path + report_path: Path + report: dict + + +def render(kernel, *, name=None, mode="float64", kind=None, + profile=None) -> EmbeddedSource: + """Render ``kernel`` as a standalone C ``.c``/``.h`` pair. + + ``kernel`` is anything with ``sinks`` and ``walk`` (and ``name``/``kind`` + unless given): the kind resolves as ``build()`` resolves it, the verify + pass refuses what the C target does not build and classifies every index + site, the one body traversal renders through :class:`CDialect`, and the + report gains what the render learned (helpers, core functions, libm, + floating-point equality sites, special literals, static bytes) before + the profile's caps apply. Deterministic: the same call gives the same + bytes.""" + from ..artifact.bundle import _resolve_kind + from ..emit import render_body + from ..emit.backend import scalar_mode + from . import abi, verify + from .dialect import CDialect + from .profile import EmbeddedProfile + + name = name if name is not None else kernel.name + kind = _resolve_kind(name, getattr(kernel, "kind", None), kind) + smode = scalar_mode(getattr(mode, "id", mode)) + profile = profile if profile is not None else EmbeddedProfile() + sinks, walk = tuple(kernel.sinks), kernel.walk + verify.refuse_unsupported(name, kernel, kind, walk, sinks) + report = {**_traceability(kernel, name, walk, smode, profile), + **verify.ir_checks(name, sinks, walk, kind, profile)} + # verify's rows are the one interface source; a branch-mix plane is K cells + abi.size_mix_rows(report["interface"], walk) + refuse_unclampable(name, report) + where = verify.positions(sinks) + by_pos = {site["node"]: site for site in report["indices"]} + sites = {nid: by_pos[p]["status"] for nid, p in where.items() if p in by_pos} + lengths = {nid: by_pos[p]["length"] for nid, p in where.items() if p in by_pos} + dialect = CDialect(smode, name, sites, lengths=lengths, positions=where) + body = render_body(sinks, walk, indent=" ", kind=kind, dialect=dialect).text + core_text, core_names = abi.core_closure(dialect) + helper_text, helper_names, helper_libm = abi.helper_closure(dialect) + core_libm = {m for n in core_names for m in _core_libm(dialect, n)} + report["helpers"] = sorted(helper_names) + report["core"] = core_names + report["core_version"] = _core_version() + report["libm"] = sorted(set(dialect.libm) | core_libm | set(helper_libm)) + # a switch renders a branch-exclusive node once per case: one site each + report["fp_equality_sites"] = list(dict.fromkeys( + p for p in dialect.fp_eq_sites if p is not None)) + report["literal_specials"] = list(dialect.literal_specials) + static = report["budgets"]["static_bytes"] + static["temporaries"] = sum(dialect.sizeof(t) for _n, t in dialect.declared) + static["total"] = static["tapes"] + static["carries"] + static["temporaries"] + verify.apply_caps(name, report, profile) + header = abi.header_text(name, walk, smode, report, profile) + text = abi.tu_text(name, walk, smode, body, dialect, core_text, helper_text, + rows=report["interface"]) + return EmbeddedSource(text, header, body, report, name, smode.id) + + +def _traceability(kernel, name: str, walk, smode, profile) -> dict: + """The report's leading stamps: what was built, by which hawk, from which + walk and which kernel source (``"unavailable"`` when the kernel holds no + source digest: a hand-built or derived sink set), in which mode, under + which profile. No timestamp: the same call gives the same bytes.""" + from .. import __version__ + + digest = getattr(kernel, "source_digest", None) + return {"kernel": name, "generator": GENERATOR, "hawk_version": __version__, + "walk_digest": walk.digest, + "source_digest": "unavailable" if digest is None else digest, + "scalar_mode": smode.id, "profile": repr(profile)} + + +def check(source: EmbeddedSource, *, profile=None) -> dict: + """Run the C compiler (``-std=c99`` and ``-std=c11``, strict flags) and + cppcheck's MISRA addon over ``source`` in a temp dir: the report's + ``checks`` block (:func:`hawk.embedded.toolchain.run_checks`). Any + diagnostic or finding raises :class:`~hawk.ir.HawkError`.""" + return _checks(source, profile) + + +def _checks(source: EmbeddedSource, profile) -> dict: + from . import toolchain + + return toolchain.run_checks(source.text, source.header, source.name, profile) + + +def export(kernel, directory, *, mode="float64", kind=None, name=None, + profile=None, check=True) -> EmbeddedArtifact: + """Export ``kernel`` as ``.c``, ``.h`` and + ``.embedded.json`` in ``directory``. + + :func:`render` (verify, render, caps), then the text audit + (:func:`hawk.embedded.audit.audit`; any hit refuses with rule + ``audit.`` and nothing is written), then the ``.c`` and ``.h`` are + written, so a failing toolchain check leaves them for inspection, then + :func:`check` when ``check`` is true, then the report, whose keys follow + :data:`REPORT_KEYS`. Deterministic: a re-export writes the same bytes + (the ``checks`` block names the checked files relative to ``directory`` + and drops the run time).""" + profile = profile if profile is not None else EmbeddedProfile() + source = render(kernel, name=name, mode=mode, kind=kind, profile=profile) + name = source.name + refuse_audit_hits(source) + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + c_path = directory / f"{name}.c" + h_path = directory / f"{name}.h" + report_path = directory / f"{name}.embedded.json" + # a report is written only after its own C passed: never leave an older + # clean report beside C that fails its checks + report_path.unlink(missing_ok=True) + c_path.write_bytes(source.text.encode()) + h_path.write_bytes(source.header.encode()) + checks = _checks(source, profile) if check else {"skipped": "not requested"} + report = finish_report(source, checks) + report_path.write_bytes(report_text(report).encode()) + return EmbeddedArtifact(name, directory, c_path, h_path, report_path, report) + + +def refuse_audit_hits(source: EmbeddedSource) -> None: + """Run the text audit (:func:`hawk.embedded.audit.audit`) over ``source`` + and refuse any hit with rule ``audit.``: the step every door + (:func:`export`, ``build_bundle(targets=("c",))``) runs before it writes + or publishes anything.""" + from ..ir import HawkError + from . import audit + + hits = audit.audit(source.text, source.header, source.name) + if hits: + raise HawkError("\n".join( + f"{source.name!r}: the C target refuses line {line} under {rule}: {text}" + for line, rule, text in hits)) + + +def finish_report(source: EmbeddedSource, checks: dict) -> dict: + """The ``.embedded.json`` document for an audited ``source`` whose + toolchain ``checks`` block (:func:`check`, or ``{"skipped": ...}``) is in + hand: the render's report plus the deviations, the clean audit and the + checks made stable (no run time, no temp directory), in + :data:`REPORT_KEYS` order. Every door builds its report here.""" + from . import toolchain + + return _ordered({**source.report, + "deviations": _deviations(toolchain.DEVIATIONS, source.report), + "audit": {"hits": []}, "checks": _stable_checks(checks)}) + + +def report_text(report: dict) -> str: + """The exact text a ``.embedded.json`` is written as: indented + JSON in the report's own key order, no NaN, one trailing newline.""" + return json.dumps(report, indent=2, sort_keys=False, allow_nan=False) + "\n" + + +def _deviations(deviations, report: dict) -> list: + """The toolchain's rule deviations, plus a ``float-equality`` note naming + every walk position that compares reals for exact equality and a + ``defined_where_cpp_is_ub`` note naming every Index ``+ - *``, negation + and true division ``/``, which the C defines where the C++ build (infix + there) is undefined.""" + out = [dict(d) for d in deviations] + sites = report.get("fp_equality_sites") or [] + if sites: + out.append({"rule": "float-equality", "category": "note", "sites": list(sites), + "reason": "the kernel compares reals for exact equality at these " + "walk positions as its author wrote it; the C keeps " + "the comparison (it mirrors the host build bit for " + "bit) and records each site for review"}) + ub = report.get("int_ub_sites") or [] + if ub: + out.append({"rule": "defined_where_cpp_is_ub", "category": "note", + "sites": list(ub), + "reason": "the C defines these Index +, -, *, negations and / " + "through hawk_i64_* (two's-complement wrapping; 0 for " + "a zero divisor, MIN for MIN / -1); the C++ build " + "spells them infix, where signed overflow, a zero " + "divisor and MIN / -1 are undefined, so the two agree " + "wherever the C++ is defined"}) + return out + + +def _stable_checks(checks: dict) -> dict: + """``checks`` with its run time dropped and the temp directory the check + ran in replaced by ``.``: the checked files are byte-identical to the + exported ones, so the argv reads relative to the export directory and a + re-export writes the same report.""" + import os + + runs = checks.get("cc") or [] + tmp = os.path.dirname(runs[0]["argv"][-1]) if runs else None + + def rel(value): + if isinstance(value, str) and tmp: + return value.replace(tmp, ".") + if isinstance(value, list): + return [rel(v) for v in value] + if isinstance(value, dict): + return {k: rel(v) for k, v in value.items()} + return value + + return {k: rel(v) for k, v in checks.items() if k != "seconds"} + + +def _ordered(report: dict) -> dict: + """``report`` in :data:`REPORT_KEYS` order; a key the list does not know + (a later pass's addition) follows, never dropped.""" + known = {k: report[k] for k in REPORT_KEYS if k in report} + return {**known, **{k: v for k, v in report.items() if k not in known}} + + +def refuse_unclampable(name: str, report: dict) -> None: + """Refuse a ``clamped`` index site the C target cannot clamp: the renderer + spells a tape read's subscript itself, so only a PROVEN one may render.""" + from .verify import _refusal + + for site in report["indices"]: + if site["kind"] == "tape_read" and site["status"] == "clamped": + raise _refusal( + name, site["node"], site["kind"], "index.unproven", + f"the tape subscript is not provably in range ({site['reason']})", + "the reader's range must lie inside the taped loop's range; the C " + "target cannot clamp a tape subscript yet") + + +def _core_version() -> str: + from aether_dsc import c_core + + return c_core.CORE_VERSION + + +def _core_libm(dialect, name: str) -> tuple: + """The libm names a core function calls; a helper's core dependency (the + scalar ``sign`` under ``hawk_sign_nan``) calls none.""" + held = dialect.core_functions.get(name) + return () if held is None else held.libm + + +__all__ = ["EmbeddedArtifact", "EmbeddedProfile", "EmbeddedSource", "check", + "export", "finish_report", "refuse_audit_hits", "render", "report_text"] diff --git a/hawk/embedded/abi.py b/hawk/embedded/abi.py new file mode 100644 index 0000000..5b0cfe7 --- /dev/null +++ b/hawk/embedded/abi.py @@ -0,0 +1,495 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The standalone C target's ABI: the ``.h`` header and the +``.c`` translation unit around a rendered body. + +The header carries the traceability, budget and interface-contract blocks, +the ``HAWK_EMBEDDED_TYPES`` width guard (``hawk_real_t``, ``hawk_int_t``, +``hawk_status_t``), the args struct (one typed field per slot in +``arg_spec`` order, an ``int64_t _len`` after every runtime-length +plane) and the status-returning prototype. The TU includes the header, +```` and ````, checks the two type widths under a C11 +guard, inlines the aether core closure and then hawk's helpers in dependency +order, and ends in the entry: it validates ``args``, every pointer and the +partition range (``HAWK_E_NULL`` / ``HAWK_E_RANGE``, one ``return``), then a +static run function binds every slot it reads to a ``const`` local and runs +the sample loop over ``[base, base + count)`` with the SoA pitch +``n_samples``. + +One function per block; :func:`header_text` and :func:`tu_text` join them. +The ``nsamples`` slot is not a field: the entry's own ``n_samples`` argument +is the sample count, and the body reads it under that name. +""" + +from __future__ import annotations + +import re + +from aether_dsc import c_core + +from ..ir.nodes import FINISHED_PLANE +from .helpers import helper +from .spelling import WIDTH + +_REAL = ("f64", "f32") +#: Element dtype -> its C type in a plane or a by-value field. +_ELEM = {"f64": "hawk_real_t", "f32": "hawk_real_t", "i32": "hawk_int_t", + "i64": "hawk_int_t", "int32": "int32_t", "uint32": "uint32_t", + "bool": "uint8_t"} +#: The width word in the header guard per scalar mode. +_BITS = {"float64": 64, "float32": 32} +_C_REAL = {"float64": "double", "float32": "float"} + + +def _guard_macro(name: str) -> str: + return "HAWK_" + re.sub(r"[^A-Za-z0-9]", "_", name).upper() + "_H" + + +# -------------------------------------------------------------------------- +# the args struct + + +def _shape_word(shape) -> str: + if not shape: + return "Scalar" + if len(shape) == 1: + return f"Vector[{shape[0]}]" + return f"Matrix[{shape[0]}, {shape[1]}]" + + +def mix_planes(walk) -> dict[str, int]: + """``{plane name: K}`` for every ``auto`` dispatch group's branch-mix + plane (:func:`hawk.ir.segment.mix_name`): K ``uint32`` counters.""" + from ..ir.segment import mix_name + + return {mix_name(info.name): int(info.K) for info in walk.dispatches + if info.policy == "auto"} + + +def size_mix_rows(rows: list, walk) -> list: + """Give each branch-mix plane's interface row its true shape ``[K]`` + (verify's rows describe every reserved counter as one cell).""" + mixes = mix_planes(walk) + for row in rows: + if row["role"] == "lookup" and row["name"] in mixes: + row["shape"] = [mixes[row["name"]]] + return rows + + +def _layout(row: dict, mixes: dict) -> str: + if row["layout"] == "value": + return "by value" + if row["layout"] == "partials": + then = "zeroes it, then reduces" if row["init_by_caller"] else "reduces" + return f"one partial per sample, the caller {then}" + if row["role"] == "terminated": + return "mask, one byte per sample" + if row["role"] == "lookup" and row["name"] in mixes: + return f"{mixes[row['name']]} cells, the caller zeroes them" + if row["role"] == "lookup" and row["name"] == FINISHED_PLANE: + return "counter, the caller zeroes it" + if row["runtime_length"]: + return f"length in {row['field']}_len" + if row["role"] == "lookup": + return "one cell, read once" + return "SoA, pitch n_samples" + + +def args_struct(rows: list, walk, mode, name: str = "kernel") -> tuple[str, list[dict]]: + """The args struct's text and its fields in ``arg_spec`` order: a by-value + field per ``uniform``, a pointer per plane (``const`` when the interface + direction is ``in``), an ``int64_t _len`` after each runtime-length + plane. ``rows`` are the report's interface rows (the verify pass's, with + each branch-mix plane already sized by :func:`size_mix_rows`): the ONE + interface source. Each field is ``{"field", "ctype", "kind", "row"}`` with + ``kind`` one of ``value``, ``pointer``, ``length``.""" + mixes = mix_planes(walk) + fields: list[dict] = [] + for row in rows: + role, dtype = row["role"], row["dtype"] + if role == "nsamples": + continue + if role == "uniform": + # a Param is Scalar, Int32 or UInt32; an integer one travels as + # the by-value 64-bit Int (hawk/trace/decl.py) + elem = "hawk_real_t" if dtype in _REAL else "hawk_int_t" + fields.append({"field": row["field"], "ctype": elem, "kind": "value", + "row": row}) + continue + elem = "uint8_t" if role == "terminated" else _ELEM[dtype] + const = "const " if row["direction"] == "in" else "" + fields.append({"field": row["field"], "ctype": f"{const}{elem}*", + "kind": "pointer", "row": row}) + if row["runtime_length"]: + fields.append({"field": f"{row['field']}_len", "ctype": "int64_t", + "kind": "length", "row": row}) + type_w = max((len(f["ctype"]) for f in fields), default=0) + 1 + name_w = max((len(f["field"]) for f in fields), default=0) + 3 + role_w = max((len(f["row"]["role"]) for f in fields), default=0) + dir_w = max((len(f["row"]["direction"]) for f in fields), default=0) + 1 + shape_w = max((len(_shape_word(f["row"]["shape"])) for f in fields), default=0) + lines = ["typedef struct {"] + for f in fields: + row = f["row"] + if f["kind"] == "length": + note = f"length of {row['field']} (>= 1)" + else: + note = (f"{row['role']:<{role_w}} {row['direction']:<{dir_w}} " + f"{_shape_word(row['shape']):<{shape_w}} {_layout(row, mixes)}") + decl = f"{f['ctype']:<{type_w}}{f['field'] + ';':<{name_w}}" + lines.append(f" {decl}/* {note} */") + lines.append(f"}} {name}_args_t;") + return "\n".join(lines) + "\n", fields + + +# -------------------------------------------------------------------------- +# the header + + +def _prototype(name: str) -> str: + return (f"hawk_status_t {name}_c(const {name}_args_t* args, int64_t base, " + "int64_t count, int64_t n_samples)") + + +def _traceability(name: str, mode, report: dict, profile) -> list[str]: + from .. import __version__ + + def held(key: str) -> str: + value = report.get(key) + return "unavailable" if value is None else str(value) + + return [ + " * TRACEABILITY:", + f" * kernel {name}", + f" * hawk version {report.get('hawk_version', __version__)}", + f" * walk digest {held('walk_digest')}", + f" * kernel sha256 {held('source_digest')}", + f" * scalar mode {mode.id}", + " * target c, MISRA C:2012+AMD, C99 dialect, serial", + f" * profile {profile!r}", + f" * aether core {report.get('core_version', c_core.CORE_VERSION)}", + ] + + +def _budgets(report: dict) -> list[str]: + budgets = report["budgets"] + ops = ", ".join(f"{k} {v:g}" for k, v in budgets["ops"].items()) + static = budgets["static_bytes"] + + def held(value) -> str: + return "unavailable" if value is None else str(value) + + libm = ", ".join(report.get("libm", ())) or "none" + return [ + " * BUDGETS (per sample, worst case):", + f" * loop trips {budgets['max_trips']}", + f" * ops {ops}", + f" * fp64 ops {budgets['fp64_ops']:g}", + f" * static bytes tapes {static['tapes']}, carries {static['carries']}, " + f"temporaries {held(static['temporaries'])}, total {held(static['total'])}", + f" * libm {libm}", + ] + + +#: The element width a plane's caller allocates, by IR dtype (an index +#: ``i32`` is the 64-bit ``hawk_int_t``; a ``terminated`` mask is one byte). +_C_DTYPE = {"i32": "i64", "i64": "i64", "int32": "i32", "uint32": "u32", "bool": "u8"} + + +def _c_dtype(row: dict, mode) -> str: + if row["role"] == "terminated": + return "u8" + if row["dtype"] in _REAL: + return WIDTH[mode.id] + if row["role"] == "uniform": + return "i64" # an integer Param is the by-value hawk_int_t + return _C_DTYPE[row["dtype"]] + + +_CONTRACT_PREAMBLE = ( + " * 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.", +) + + +#: Writable roles a mask guard leaves untouched at a masked sample (the +#: partials of an ``accum_out`` have their own line). +_OWN_COLUMN = ("mutable", "wide_out") + + +def _contract(name: str, report: dict, mode) -> list[str]: + cols = [(r["field"], r["role"], r["direction"], _c_dtype(r, mode), + str(tuple(r["shape"])), + ("yes (by value)" if r["layout"] == "value" + else "yes" if r["init_by_caller"] else "no")) + for r in report["interface"] if r["role"] != "nsamples"] + head = ("field", "role", "dir", "dtype", "shape", "init-by-caller") + pads = (2, 2, 2, 1, 2) + widths = [max(len(c[k]) for c in [head, *cols]) + pads[k] for k in range(5)] + + def line(c) -> str: + return " * " + "".join(f"{c[k]:<{widths[k]}}" for k in range(5)) + c[5] + + lengths = any(r["runtime_length"] for r in report["interface"]) + out = [ + *_CONTRACT_PREAMBLE, + line(head), + *(line(c) for c in cols), + ] + for r in report["interface"]: + # the row's structural ``guard``: the bound masks that skip a sample + guard = f"(guard: {', '.join(r['guard'])})" + if r["layout"] == "partials": + # under a guard, masked samples keep theirs: the caller zeroes first + tail = (f"masked samples keep theirs {guard}: the caller zeroes it, " + "then reduces" if r["guard"] else "the caller reduces it") + out.append(f" * {r['field']} holds ONE PARTIAL PER SAMPLE (length " + f"n_samples); {tail}.") + elif r["role"] in _OWN_COLUMN and r["guard"]: + out.append(f" * {r['field']}: masked samples keep the prior value " + f"{guard}; the caller initialises it.") + out += [ + f" * {name}_c() validates args and every pointer, 0 <= base <= n_samples and", + " * 0 <= count <= n_samples - base" + + (", every runtime length >= 1," if lengths else ",") + + " returning HAWK_E_NULL / HAWK_E_RANGE untouched otherwise.", + " * Re-entrant, allocation-free. */", + ] + return out + + +def _types_guard(name: str, mode) -> list[str]: + bits = _BITS[mode.id] + real = _C_REAL[mode.id] + return [ + "#ifndef HAWK_EMBEDDED_TYPES", + f"#define HAWK_EMBEDDED_TYPES {bits}", + f"typedef {real:<7} 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;", + f"#elif HAWK_EMBEDDED_TYPES != {bits}", + f'#error "{name}.h: {mode.id} unit; another hawk header in this build defined ' + 'hawk_real_t at a different width"', + "#endif", + ] + + +def header_text(name: str, walk, mode, report: dict, profile) -> str: + """The ``.h`` text (the spec's ``drag.h`` shape).""" + from .. import __version__ + + struct, _fields = args_struct(report["interface"], walk, mode, name) + guard = _guard_macro(name) + includes = ["#include ", "#include "] + lines = [ + f"/* {name}.h -- generated by hawk {__version__}; do not edit.", + *_traceability(name, mode, report, profile), + *_budgets(report), + *_contract(name, report, mode), + f"#ifndef {guard}", + f"#define {guard}", + *includes, + *_types_guard(name, mode), + ] + return "\n".join(lines) + "\n" + struct + _prototype(name) + ";\n#endif\n" + + +# -------------------------------------------------------------------------- +# the translation unit: closures + + +def core_closure(dialect) -> tuple[str, list[str]]: + """The aether core functions the body and the helpers call, with every + struct typedef a declared local needs: the dependency-ordered text + (:func:`aether_dsc.c_core.emit`) and the sorted function names.""" + names = set(dialect.core) | set(dialect.structs) + functions = set(dialect.core) + for name in _helper_order(dialect): + for dep in _helper_fn(dialect, name).deps: + if dep.startswith("aether_c_"): + names.add(dep) + if not dep.endswith("_t"): + functions.add(dep) + text = c_core.emit(sorted(names)) if names else "" + return text, sorted(functions) + + +def _helper_fn(dialect, name: str): + base, shape = dialect.helper_requests[name] + return helper(base, mode=dialect.mode, shape=shape) + + +#: Helper families in emission order: clamps, integers, reals, aggregates. +_FAMILY = {"clamp": 0, "i64": 1, "i32": 1, "u32": 1, "spacing": 2, "sign": 2} +_SHAPED_ORDER = ("load", "store", "splat", "make", "select", "at", "at_raw", + "set", "set_raw") + + +def _helper_order(dialect) -> list[str]: + """Every hawk helper the body references plus their ``hawk_*`` deps, + each once, deps before users, in a fixed family order.""" + for name in list(dialect.helper_requests): + for dep in _helper_fn(dialect, name).deps: + if dep.startswith("hawk_") and dep not in dialect.helper_requests: + dialect.helper_requests[dep] = (dep.removeprefix("hawk_"), None) + + def key(name: str): + base, shape = dialect.helper_requests[name] + if shape is None: + return (_FAMILY.get(base.split("_", 1)[0], 2), (), 0, name) + return (3, (len(shape), shape), _SHAPED_ORDER.index(base), name) + + return sorted(dialect.helper_requests, key=key) + + +def helper_closure(dialect) -> tuple[str, list[str], list[str]]: + """The hawk helpers' text in dependency order, their names, and the libm + names they call.""" + order = _helper_order(dialect) + fns = [_helper_fn(dialect, n) for n in order] + libm = sorted({m for fn in fns for m in fn.libm}) + return "\n".join(fn.text for fn in fns), order, libm + + +# -------------------------------------------------------------------------- +# the translation unit: the entry + + +def _includes(name: str) -> list[str]: + return [f'#include "{name}.h"', "#include ", "#include "] + + +def _size_asserts(name: str, mode) -> list[str]: + real = 8 if mode.id == "float64" else 4 + return [ + "#if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 201112L)", + f'_Static_assert(sizeof(hawk_real_t) == {real}U, "{name}.c: hawk_real_t is not ' + f'the {mode.id} width");', + f'_Static_assert(sizeof(hawk_int_t) == 8U, "{name}.c: hawk_int_t is not ' + '64-bit");', + "#endif", + ] + + +def _validation(name: str, fields: list[dict]) -> list[str]: + """The entry: ``args``, every pointer, every runtime length and the + partition range checked before anything is read; one ``return``.""" + pointers = [f"(args->{f['field']} == NULL)" for f in fields + if f["kind"] == "pointer"] + ranges = ["(base < INT64_C(0))", "(base > n_samples)", "(count < INT64_C(0))", + "(count > (n_samples - base))"] + ranges += [f"(args->{f['field']} < INT64_C(1))" for f in fields + if f["kind"] == "length"] + lines = [f"{_prototype(name)}", "{", " hawk_status_t status;", + " if (args == NULL) {", " status = HAWK_E_NULL;"] + if pointers: + lines += [f" }} else if ({_disjunction(pointers, 15)}) {{", + " status = HAWK_E_NULL;"] + lines += [f" }} else if ({_disjunction(ranges, 15)}) {{", + " status = HAWK_E_RANGE;", + " } else {", + f" {name}_run(args, base, count, n_samples);", + " status = HAWK_OK;", + " }", + " return status;", + "}"] + return lines + + +def _disjunction(terms: list[str], indent: int) -> str: + """``a || b || ...``, one term per line past the first two.""" + if len(terms) <= 2: + return " || ".join(terms) + pad = "\n" + " " * indent + return (" ||" + pad).join(terms) + + +def _uses(body: str, ident: str) -> bool: + return re.search(rf"\b{re.escape(ident)}\b", body) is not None + + +def _bindings(fields: list[dict], body: str, walk) -> list[str]: + """Every slot the body reads, bound once to a ``const`` local (a pointer + is ``T* const``); a parameter cell loaded once; the finish mask's + writable twin.""" + from ..emit.spelling import finish_binding, param_cell_binding + + lines = [] + for f in fields: + field, ct = f["field"], f["ctype"] + row = f["row"] + if f["kind"] == "pointer": + if _uses(body, field): + # the finish epilogue writes the mask through its twin only, + # so the reading binding points to const (cppcheck + # constVariablePointer) + read = ct + if (row["role"] == "terminated" and not ct.startswith("const ") + and _uses(body, finish_binding(row["name"]))): + read = f"const {ct}" + lines.append(f"{read} const {field} = args->{field};") + if row["role"] == "lookup": + cell = param_cell_binding(row["name"]) + if _uses(body, cell): + elem = ct.removeprefix("const ").rstrip("*") + lines.append(f"const {elem} {cell} = args->{field}[0];") + if row["role"] == "terminated": + twin = finish_binding(row["name"]) + if _uses(body, twin): + lines.append(f"{ct.removeprefix('const ')} const {twin} = " + f"args->{field};") + elif _uses(body, field): + prefix = "" if ct.startswith("const ") else "const " + lines.append(f"{prefix}{ct} {field} = args->{field};") + return lines + + +def _run(name: str, fields: list[dict], body: str, walk) -> list[str]: + """The static run function: bindings, then the sample loop around the body.""" + bindings = _bindings(fields, body, walk) + lines = [f"static void {name}_run(const {name}_args_t* args, int64_t base, " + "int64_t count, int64_t n_samples)", "{"] + if not bindings: + lines.append(" (void)args;") + if not any(_uses(text, "n_samples") for text in (body, *bindings)): + lines.append(" (void)n_samples;") + lines += [f" {b}" for b in bindings] + lines += [" const int64_t hawk_end = base + count;", + " for (int64_t hawk_i = base; hawk_i < hawk_end; ++hawk_i) {", + body, + " }", + "}"] + return lines + + +def tu_text(name: str, walk, mode, body: str, dialect, core_closure: str, + helper_closure: str, *, rows: list) -> str: + """The ``.c`` text: includes, the guarded size asserts, the core + closure, the helper closure, the run function and the validating entry. + ``rows`` are the report's interface rows (see :func:`args_struct`).""" + from .. import __version__ + + _struct, fields = args_struct(rows, walk, mode, name) + blocks = [ + "\n".join([f"/* {name}.c -- generated by hawk {__version__}; do not edit. " + f"See {name}.h. */", *_includes(name), *_size_asserts(name, mode)]), + ] + if core_closure: + blocks.append(core_closure.rstrip("\n")) + if helper_closure: + blocks.append(helper_closure.rstrip("\n")) + blocks.append("\n".join(_run(name, fields, body, walk))) + blocks.append("\n".join(_validation(name, fields))) + return "\n\n".join(blocks) + "\n" + + +__all__ = ["args_struct", "core_closure", "header_text", "helper_closure", "tu_text"] + diff --git a/hawk/embedded/audit.py b/hawk/embedded/audit.py new file mode 100644 index 0000000..b91812e --- /dev/null +++ b/hawk/embedded/audit.py @@ -0,0 +1,353 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The emitted-text audit of the standalone C target. + +The verify pass checks the IR; this pass reads the ``.c`` and ``.h`` text the +renderer produced, so a spelling no IR check can see (a helper template, the +core closure, the ABI blocks) still answers to the rules. Each hit is +``(line, rule, text)`` with ``rule`` one of: + +* ``audit.token`` — a banned token in code (comments are prose and are not + read): allocation, ``goto``, stdio, inline assembly, ``union``, + ``setjmp``/``longjmp``, ``volatile``, C++ casts and namespaces, ``eagle``, + ``#pragma``, ``tgmath``, ``extern "C"`` and every launch-geometry token of + :data:`hawk.emit.spelling.GEOMETRY_TOKENS` (imported, never respelled). + The bare word ``aether`` is allowed: the inlined core functions are named + ``aether_c_*``; +* ``audit.include`` — an ``#include`` outside :data:`INCLUDE_WHITELIST` and + the unit's own ``".h"``; +* ``audit.break`` — a ``for`` body with more than one ``break;`` at its own + level (a ``break`` inside a nested ``for``/``while``/``do``/``switch`` + belongs to that statement; the tail form ``if (f) { break; }`` sits one + brace deeper and still counts), or a ``for`` body that is not a compound + statement (MISRA C:2012 Rules 15.4 and 15.6); +* ``audit.switch`` — a ``switch`` without ``default:`` or a clause that does + not end in ``break;`` (Rules 16.3 and 16.4); +* ``audit.unused`` — a ``static`` function of the ``.c`` that nothing else in + the unit references (Rule 2.2); +* ``audit.ident`` — two identifiers the header declares (the entry, the args + struct typedef, the shared types, the status enumerators) that agree in + their first 31 characters (Rule 5.1); +* ``audit.comment`` — a ``//`` anywhere outside a string literal, in either + file (block comments only; Rule 3.1); +* ``audit.init`` — an aggregate (brace) initialiser ``= {`` in the ``.c``. + hawk never emits one, and this rule keeps it so: it is what makes the + unit-wide MISRA C:2012 Rule 9.3 deviation in + :data:`hawk.embedded.toolchain.DEVIATIONS` (a checker false positive on + ``const T t = f(...);``) unable to hide a real partial brace initialiser. + +Line numbers are 1-based in the file the hit's text names. +""" + +from __future__ import annotations + +import re + +from ..emit.spelling import GEOMETRY_TOKENS + +#: Tokens no emitted unit may spell in code. Word tokens match at word +#: boundaries; the ``::`` namespaces, ``#pragma`` and ``extern "C"`` match as +#: literal text; the launch-geometry tokens match as a word PREFIX, as +#: :func:`hawk.emit.spelling.geometry_hits` matches them. +BANNED_TOKENS: tuple[str, ...] = ( + "malloc", "calloc", "realloc", "free", "goto", "printf", "sprintf", + "fprintf", "asm", "__asm__", "union", "setjmp", "longjmp", "volatile", + "static_cast", "reinterpret_cast", "eagle", "aether::", "std::", "#pragma", + "tgmath", 'extern "C"', *GEOMETRY_TOKENS, +) + +#: The system headers a unit may include; the unit's own ``".h"`` is +#: the only other include. +INCLUDE_WHITELIST: tuple[str, ...] = ( + "", "", "", "", "", +) + +#: Rule 5.1: external identifiers distinct in this many leading characters. +IDENT_SIGNIFICANT = 31 + +_WORD = re.compile(r"\w") + + +def _token_pattern(token: str) -> str: + if token in GEOMETRY_TOKENS: + return rf"(?\n]*>|\"[^\"\n]*\")\s*$") +_FOR = re.compile(r"\bfor\s*\(") +_NESTED = re.compile(r"\b(?:for|while|switch)\s*\(|\bdo\b|\bbreak\s*;") +_SWITCH = re.compile(r"\bswitch\s*\(") +_LABEL = re.compile(r"\bcase\b|\bdefault\s*:") +_BREAK_END = re.compile(r"\bbreak\s*;\Z") +_BRACE_INIT = re.compile(r"(?])=\s*\{") +_STATIC_FN = re.compile( + r"^static[ \t]+(?:inline[ \t]+)?(?:const[ \t]+)?[A-Za-z_]\w*[ \t*]+" + r"([A-Za-z_]\w*)[ \t]*\(", re.M) +_TYPEDEF_NAME = re.compile(r"\btypedef\b[^;{}]*?\b([A-Za-z_]\w*)\s*;") +_STRUCT_TYPEDEF = re.compile(r"}\s*([A-Za-z_]\w*)\s*;") +_ENUM_BODY = re.compile(r"\benum\b[^{;]*{([^}]*)}") +_PROTOTYPE = re.compile(r"^[A-Za-z_][\w \t*]*?\b([A-Za-z_]\w*)[ \t]*\([^;{]*\)[ \t]*;", + re.M) + + +# -------------------------------------------------------------------------- +# lexing: blank comments and/or strings, keeping every offset and newline + + +def _blank(span: str) -> str: + return re.sub(r"[^\n]", " ", span) + + +def _views(text: str) -> tuple[str, str, list[int]]: + """``(code, structure, line_comment_offsets)``: ``code`` is ``text`` with + every comment blanked (string literals kept, so ``extern "C"`` and the + include targets stay visible); ``structure`` also blanks string and + character literals; the offsets are every ``//`` outside a literal + (a line comment's start, or one inside a block comment).""" + code, structure = list(text), list(text) + slashes: list[int] = [] + i, n = 0, len(text) + while i < n: + two = text[i:i + 2] + if two == "/*": + end = text.find("*/", i + 2) + end = n if end < 0 else end + 2 + slashes += [m.start() + i for m in re.finditer("//", text[i:end])] + code[i:end] = structure[i:end] = _blank(text[i:end]) + i = end + elif two == "//": + end = text.find("\n", i) + end = n if end < 0 else end + slashes.append(i) + code[i:end] = structure[i:end] = _blank(text[i:end]) + i = end + elif text[i] in "\"'": + quote, j = text[i], i + 1 + while j < n and text[j] != quote and text[j] != "\n": + j += 2 if text[j] == "\\" else 1 + end = min(j + 1, n) + structure[i + 1:end - 1] = _blank(text[i + 1:end - 1]) + i = end + else: + i += 1 + return "".join(code), "".join(structure), slashes + + +def _match(text: str, start: int) -> int: + """The offset of the bracket closing the one at ``start`` (``(`` or + ``{``); ``len(text)`` when it never closes.""" + pair = {"(": ")", "{": "}"}[text[start]] + depth = 0 + for j in range(start, len(text)): + if text[j] == text[start]: + depth += 1 + elif text[j] == pair: + depth -= 1 + if depth == 0: + return j + return len(text) + + +def _skip_ws(text: str, i: int) -> int: + while i < len(text) and text[i].isspace(): + i += 1 + return i + + +# -------------------------------------------------------------------------- +# the rules + + +class _File: + def __init__(self, label: str, text: str) -> None: + self.label = label + self.text = text + self.lines = text.splitlines() + self.code, self.structure, self.slashes = _views(text) + + def line_of(self, offset: int) -> int: + return self.text.count("\n", 0, offset) + 1 + + def hit(self, offset: int, rule: str, why: str) -> tuple[int, str, str]: + line = self.line_of(offset) + source = self.lines[line - 1].strip() if line <= len(self.lines) else "" + return (line, rule, f"{self.label} `{source}` ({why})") + + +def _tokens(f: _File) -> list: + return [f.hit(m.start(), "audit.token", f"banned token {m.group(0)!r}") + for m in _BANNED.finditer(f.code)] + + +def _includes(f: _File, own: str) -> list: + out = [] + for m in _INCLUDE.finditer(f.code): + target = _INCLUDE_TARGET.fullmatch(m.group(1)) + spelled = target.group(1) if target else m.group(1).strip() + if spelled not in INCLUDE_WHITELIST and spelled != own: + out.append(f.hit(m.start(), "audit.include", + f"{spelled} is not on the include whitelist")) + return out + + +def _body(s: str, head: int) -> tuple[int, int] | None: + """``(open, close)`` brace offsets of the compound statement after the + parenthesised header opening at ``head``; ``None`` when the statement + is not a compound statement.""" + after = _skip_ws(s, _match(s, head) + 1) + if after >= len(s) or s[after] != "{": + return None + return after, _match(s, after) + + +def _own_breaks(s: str, start: int, end: int) -> list[int]: + """Every ``break;`` in ``s[start:end]`` not inside a nested loop or + ``switch``.""" + found: list[int] = [] + i = start + while True: + m = _NESTED.search(s, i, end) + if m is None: + return found + word = m.group(0) + if word.startswith("break"): + found.append(m.start()) + i = m.end() + elif word == "do": + brace = _skip_ws(s, m.end()) + i = _match(s, brace) + 1 if brace < end and s[brace] == "{" else m.end() + else: + body = _body(s, m.end() - 1) + i = body[1] + 1 if body else m.end() + + +def _breaks(f: _File) -> list: + s, out = f.structure, [] + for m in _FOR.finditer(s): + body = _body(s, m.end() - 1) + if body is None: + out.append(f.hit(m.start(), "audit.break", + "the loop body is not a compound statement")) + continue + out += [f.hit(b, "audit.break", "a second `break;` at the loop's own level") + for b in _own_breaks(s, body[0] + 1, body[1])[1:]] + return out + + +def _clauses(s: str, start: int, end: int) -> list[tuple[int, int, int]]: + """``(label, content start, content end)`` per clause of the switch body + ``s[start:end]``: labels are read at the body's own brace depth.""" + labels: list[tuple[int, int]] = [] + depth, i = 0, start + while i < end: + c = s[i] + if c in "{(": + depth += 1 + elif c in "})": + depth -= 1 + elif depth == 0 and (i == start or not _WORD.fullmatch(s[i - 1])): + m = _LABEL.match(s, i) + if m is not None: + colon = s.find(":", m.start()) + labels.append((i, colon + 1)) + i = colon + 1 + continue + i += 1 + return [(lab, body, labels[k + 1][0] if k + 1 < len(labels) else end) + for k, (lab, body) in enumerate(labels)] + + +def _unwrap(s: str, a: int, b: int) -> str: + """The clause content, stripped of every block wrapping the whole of it.""" + while True: + a = _skip_ws(s, a) + while b > a and s[b - 1].isspace(): + b -= 1 + if a < b and s[a] == "{" and _match(s, a) == b - 1: + a, b = a + 1, b - 1 + continue + return s[a:b] + + +def _switches(f: _File) -> list: + s, out = f.structure, [] + for m in _SWITCH.finditer(s): + body = _body(s, m.end() - 1) + if body is None: + out.append(f.hit(m.start(), "audit.switch", + "the switch body is not a compound statement")) + continue + clauses = _clauses(s, body[0] + 1, body[1]) + if not any(s.startswith("default", lab) for lab, _a, _b in clauses): + out.append(f.hit(m.start(), "audit.switch", "the switch has no `default:`")) + for lab, a, b in clauses: + if not _BREAK_END.search(_unwrap(s, a, b)): + out.append(f.hit(lab, "audit.switch", + "the clause does not end in `break;`")) + return out + + +def _unused(f: _File) -> list: + out = [] + for m in _STATIC_FN.finditer(f.structure): + name = m.group(1) + uses = len(re.findall(rf"(? list: + s = f.structure + found: dict[str, int] = {} + for pattern in (_TYPEDEF_NAME, _STRUCT_TYPEDEF, _PROTOTYPE): + for m in pattern.finditer(s): + found.setdefault(m.group(1), m.start(1)) + for m in _ENUM_BODY.finditer(s): + for part in m.group(1).split(","): + ident = re.match(r"\s*([A-Za-z_]\w*)", part) + if ident: + found.setdefault(ident.group(1), m.start(1) + m.group(1).find(part)) + out, names = [], sorted(found, key=lambda k: (found[k], k)) + for k, later in enumerate(names): + for earlier in names[:k]: + if later[:IDENT_SIGNIFICANT] == earlier[:IDENT_SIGNIFICANT]: + out.append(f.hit(found[later], "audit.ident", + f"{later} and {earlier} agree in their first " + f"{IDENT_SIGNIFICANT} characters")) + return out + + +def _inits(f: _File) -> list: + return [f.hit(m.start(), "audit.init", + "an aggregate initialiser `= {`; the C target emits none") + for m in _BRACE_INIT.finditer(f.structure)] + + +def _comments(f: _File) -> list: + return [f.hit(i, "audit.comment", + "`//` outside a string literal; block comments only") + for i in f.slashes] + + +def audit(c_text: str, h_text: str, name: str) -> list[tuple[int, str, str]]: + """Every rule hit in the ``.c``/``.h`` pair, ``.c`` first, + each as ``(line, rule, text)``; an empty list is a clean unit.""" + c, h = _File(f"{name}.c", c_text), _File(f"{name}.h", h_text) + own = f'"{name}.h"' + hits = [] + for f in (c, h): + hits += sorted(_tokens(f) + _includes(f, own) + _comments(f) + + _breaks(f) + _switches(f) + + (_unused(f) + _inits(f) if f is c else _idents(f))) + return hits + + +__all__ = ["BANNED_TOKENS", "INCLUDE_WHITELIST", "audit"] diff --git a/hawk/embedded/dialect.py b/hawk/embedded/dialect.py new file mode 100644 index 0000000..f413aed --- /dev/null +++ b/hawk/embedded/dialect.py @@ -0,0 +1,717 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The plain-C dialect: the standalone C target's spelling of every text the +body renderer produces, behind the :class:`hawk.emit.dialect.Dialect` seam. + +Mirrors :mod:`hawk.emit.dialect_aether` member for member. ``r`` is the +renderer (``r._emit``, ``r._ref``, ``r.hoists``, ``r.indent``, ``r.loop_pos``). +Every rank>=1 value is a named C struct value (``aether_c__vecN_t``, +``aether_c__matRxC_t``); every fold, product and component-wise function +is an aether CORE function requested by ``(kind, shape, width)`` +(:mod:`aether_dsc.c_core`); the plane access, clamps, aggregate glue and the +integer helpers are hawk's own (:mod:`hawk.embedded.helpers`). Rank-0 Real +math is libm at the mode's width (:func:`~hawk.embedded.spelling.libm_name`). + +The dialect collects, as it spells, what the translation-unit assembler and +the report need: ``declared`` (every named local), ``helpers`` and +``helper_requests`` (hawk helpers by name, and how to regenerate each), +``core`` (core function names), ``libm``, ``fp_eq_sites``, +``literal_specials`` and ``shapes``. Index sites come from the verify pass's +ONE classification (``sites``: ``id(node) -> "proven" | "clamped"``), so a +proven index is emitted raw and every other one through a clamp. An op with +no C spelling raises :class:`~hawk.ir.HawkError` naming the kind and rank. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence + +from aether_dsc import c_core + +from ..emit.spelling import ( + _CWISE1, + _INFIX, + _MATH1, + _MATH2, + SAMPLE_INDEX_IDENT, + binding_name, + finish_binding, + param_cell_binding, +) +from ..ir import HawkError +from ..ir.nodes import ( + FINISHED_PLANE, + GATHER_ROLES, + RAW_KIND, + At, + Const, + Finish, + Leaf, + Op, + Select, + TapeWrite, + is_param_cell, +) +from ..ir.ops import RANDOM_OPS +from ..types import INT_PLANE_DTYPES, TensorType +from .helpers import helper +from .spelling import ( + INT_HELPER, + WIDTH, + clamp_index, + ctype, + int_literal, + libm_name, + real_literal, +) + +#: Real element dtypes (the scalar mode decides the width of both). +_REAL = ("f64", "f32") +#: Every integer element dtype; ``i32``/``i64`` are the 64-bit ``hawk_int_t``. +_INTS = ("i32", "i64") + INT_PLANE_DTYPES +#: Kinds an integer value always spells through the wrapping helpers. +_INT_KINDS = ("add", "sub", "mul", "neg", "abs", "min", "max", "div", "floordiv", + "mod", "band", "bor", "bxor", "shl", "shr", "invert") +#: Rank-0 kinds that are aether CORE scalar functions, not libm. +_CORE_SCALARS = ("rsqrt", "clip", "remainder") +#: Rank-0 floating-point class tests: C99 macros, no width suffix. +_PREDICATES = ("isnan", "isinf", "isfinite") +#: Rank>=1 binary kinds a rank-0 operand splats into (the core takes two structs). +_SPLAT_BINARY = {"add": "add", "sub": "sub", "min": "min", "max": "max", + "pow": "pow"} +#: Rank>=1 quaternion and geometric kinds -> core kind (no shape: quaternions +#: are ``vec4``, ``(w, x, y, z)``). +_QUAT = {"quat_mul": "quat_mul", "quat_conj": "quat_conj", "quat_recip": "quat_recip", + "quat_rotate": "quat_rotate", "as_pure": "quat_as_pure", + "as_vec3": "quat_as_vec3"} +#: Bytes one element of a dtype occupies in the C build, by scalar mode. +_SIZEOF = {"f64": None, "f32": None, "i32": 8, "i64": 8, "int32": 4, "uint32": 4, + "bool": 1} + +_IDENT = r"[A-Za-z_]\w*" +#: Texts that are not composite expressions (MISRA C:2012 Rule 10.8): an +#: identifier, a plane read, a fixed-width literal, a cast real literal. +_ATOM = re.compile( + rf"{_IDENT}|{_IDENT}\[[^\[\]]*\]|U?INT(?:32|64)_C\(-?\d+\)" + r"|\(\(hawk_real_t\)-?[\w.+\-]+\)") + + +class CDialect: + """The plain-C spelling (see the module docstring).""" + + id = "c" + #: Every rank>=1 value is a C struct value and gets its own statement. + names_every_tensor = True + #: The C unit stores a vector once (``hawk__store``), never per component. + commits_per_component = False + + def __init__(self, mode, name: str, sites: dict, *, lengths: dict | None = None, + positions: dict | None = None) -> None: + self.mode = mode + self.width = WIDTH[mode.id] + self.name = name + #: ``id(node) -> "proven" | "clamped"``: verify's index-site table. + self.sites = dict(sites) + #: ``id(node) -> bound``: the site's length (binding name or int). + self.lengths = dict(lengths or {}) + #: ``id(node) -> position`` (:func:`hawk.embedded.verify.positions`). + self.positions = dict(positions or {}) + self.declared: list[tuple[str, TensorType]] = [] + self.helpers: set[str] = set() + self.helper_requests: dict[str, tuple] = {} + self.core: set[str] = set() + self.core_functions: dict[str, c_core.CFunction] = {} + #: Struct typedef names a declared local needs (always in the closure). + self.structs: set[str] = set() + self.libm: set[str] = set() + self.fp_eq_sites: list = [] + self.literal_specials: list[str] = [] + self.shapes: set[tuple] = set() + #: local name -> its type, for the members that get only text. + self.types: dict[str, TensorType] = {} + #: Bindings of planes read only through at() (``lookup``/``wide_in``). + self._gathered: set[str] = set() + + # -- bookkeeping --------------------------------------------------- + + def _ctype(self, ttype: TensorType) -> str: + if ttype.shape: + self.shapes.add(tuple(ttype.shape)) + self.structs.add(c_core.struct(tuple(ttype.shape), self.width).name) + return ctype(ttype, self.mode) + + def _helper(self, base: str, shape=None) -> str: + shape = None if shape is None else tuple(shape) + fn = helper(base, mode=self.mode, shape=shape) + self.helpers.add(fn.name) + self.helper_requests[fn.name] = (base, shape) + if shape is not None: + self.shapes.add(shape) + return fn.name + + def _core(self, kind: str, shape=None, shape2=None) -> str: + fn = c_core.function(kind, shape=shape, shape2=shape2, width=self.width) + self.core.add(fn.name) + self.core_functions[fn.name] = fn + return fn.name + + def _libm(self, fn: str) -> str: + name = libm_name(fn, self.mode) + self.libm.add(name) + return name + + def _record(self, name: str, ttype: TensorType) -> None: + self.declared.append((name, ttype)) + self.types[name] = ttype + + def sizeof(self, ttype: TensorType) -> int: + """Bytes one value of ``ttype`` occupies in this unit.""" + each = _SIZEOF[ttype.dtype] or (8 if self.mode.id == "float64" else 4) + count = 1 + for extent in ttype.shape: + count *= int(extent) + return each * count + + def _atom(self, r, text: str, ttype: TensorType) -> str: + """``text`` itself when it is not a composite expression, else a + hoisted local (a cast or a member access never wraps an expression).""" + if _ATOM.fullmatch(text): + return text + return self.hoist(r, text, ttype) + + def _named(self, r, text: str, ttype: TensorType) -> str: + """``text`` as an identifier: a struct member is read off a name.""" + if re.fullmatch(_IDENT, text): + return text + return self.hoist(r, text, ttype) + + def _real(self, r, text: str, dtype: str) -> str: + """A Real operand: an integer or bool value cast once, explicitly.""" + if dtype in _REAL: + return text + return f"((hawk_real_t){self._atom(r, text, TensorType((), dtype))})" + + def _coerce(self, r, text: str, source: str, target: str) -> str: + """``text`` (typed ``source``) as a ``target`` value: only an + integer-into-Real store needs a cast; the IR types everything else.""" + if target in _REAL and source not in _REAL: + return self._real(r, text, source) + return text + + def _status(self, node) -> str: + return self.sites.get(id(node), "clamped") + + def _clamped(self, node, index: str, default) -> str: + """``index`` raw when the site is proven, else clamped to its bound.""" + if self._status(node) == "proven": + return index + self._helper("clamp_index") + length = self.lengths.get(id(node)) + length = default if length is None else length + if isinstance(length, int): + length = int_literal(length, "i64") + return clamp_index(index, length) + + # -- declarations and loops ---------------------------------------- + + def declare(self, name: str, ttype: TensorType, text: str) -> str: + """A named local: ``const {ctype} {name} = {text};``.""" + if text in self._gathered: + raise HawkError( + f"renderer defect: a lookup/wide_in leaf reached declare " + f"({name!r} = {text!r}); the renderer reads such a plane only " + "through at() and never names its leaf") + self._record(name, ttype) + return f"const {self._ctype(ttype)} {name} = {text};" + + def carry_type(self, ttype: TensorType) -> str: + """A loop-carried (or ``switch``-assigned) local's C type.""" + return self._ctype(ttype) + + def tape_decl(self, elem: str, name: str, trip: int) -> str: + """A forward loop's per-iteration tape array.""" + return f"{elem} {name}[{trip}];" + + def counter_decl(self, counter: str) -> str: + """A loop's per-sample iteration counter.""" + return f"hawk_int_t {counter} = {int_literal(0, 'i64')};" + + def for_header(self, r, index: str, loop, count: str | None) -> str: + """The lowered loop's header. Static bounds are ``INT64_C`` literals; + a RUNTIME count is first clamped into ``[0, trip]`` by its own + statement, then the loop runs the first (head form) or the last + (``count_tail``) ``cnt`` iterations of the static range, as + :func:`hawk.emit.spelling._for_header` does, over an ``int32_t``.""" + cmp_ = "<" if loop.step > 0 else ">" + if count is None: + lit = lambda v: int_literal(v, "i64") # noqa: E731 + incr = (f"++{index}" if loop.step == 1 else f"--{index}" if loop.step == -1 + else f"{index} += {lit(loop.step)}") + return (f"for (hawk_int_t {index} = {lit(loop.start)}; {index} {cmp_} " + f"{lit(loop.stop)}; {incr}) {{") + lit = lambda v: int_literal(v, "int32") # noqa: E731 + incr = (f"++{index}" if loop.step == 1 else f"--{index}" if loop.step == -1 + else f"{index} += {lit(loop.step)}") + cnt = f"hawk_cnt{r.loop_pos[id(loop)]}" + trips = self._helper("clamp_trips") + r._emit(f"const int32_t {cnt} = {trips}({count}, {lit(loop.trip)});") + if loop.count_tail: + sign = "+" if loop.step > 0 else "-" + # no `* 1`: an operation with no effect is dead code (Rule 2.2) + span = f"({lit(loop.trip)} - {cnt})" + if abs(loop.step) != 1: + span = f"({span} * {lit(abs(loop.step))})" + begin = f"{lit(loop.start)} {sign} {span}" + end = lit(loop.stop) + else: + begin = lit(loop.start) + scaled = cnt if loop.step == 1 else f"({cnt} * {lit(loop.step)})" + end = (cnt if (loop.start, loop.step) == (0, 1) + else f"({lit(loop.start)} + {scaled})") + return f"for (int32_t {index} = {begin}; {index} {cmp_} {end}; {incr}) {{" + + def tail_break(self, flag: str) -> str: + """The tail-form exit, braced.""" + return f"if ({flag}) {{ break; }}" + + def exit_if(self, cond: str) -> str: + """The ``if`` opening a head/middle-form ``break``. A condition that is + already one parenthesised expression is not wrapped again (clang's + ``-Wparentheses-equality`` rejects ``if ((a == b))``).""" + return f"if {_paren(cond)} {{" + + def sample_index_value(self) -> str: + """A ``SampleIndex`` node: the loop's own ``int64_t`` sample index.""" + return SAMPLE_INDEX_IDENT + + def hoist(self, r, text: str, ttype: TensorType | None = None) -> str: + """Bind ``text`` to a fresh typed local ahead of the current line.""" + if ttype is None: + raise HawkError("the C dialect hoists only a typed value " + f"(no type for {text!r})") + name = f"hz{r.hoists}" + r.hoists += 1 + self._record(name, ttype) + r._emit(f"const {self._ctype(ttype)} {name} = {text};") + return name + + # -- dispatch ------------------------------------------------------- + + def selector_decl(self, r, kname: str, kind_expr: str, k: int, *, + in_range: bool = False) -> str: + """The selector clamped into ``[0, K)`` as an ``int32_t``, over a named + ``hawk_int_t`` operand. Always clamped, ``in_range`` or not: the verify + pass records every selector as a clamped site, and the clamp is a no-op + on an in-range value.""" + if not re.fullmatch(_IDENT, kind_expr): + kind_expr = self.hoist(r, kind_expr, TensorType((), "i64")) + x = kind_expr + return (f"const int32_t {kname} = (({x} < {_i64(0)}) ? {_i32(0)} : " + f"(({x} > {_i64(k - 1)}) ? {_i32(k - 1)} : (int32_t){x}));") + + def mix_count(self, mix: str, k: int, kname: str) -> str: + """An ``auto`` dispatch's branch-mix count, one ``uint32_t`` per branch.""" + return f"{mix}[{kname}] = {mix}[{kname}] + 1U;" + + def predicated_rank0(self, kname: str, texts: Sequence[str]) -> str: + """A right-nested ternary chain over every branch.""" + expr = texts[-1] + for j in range(len(texts) - 2, -1, -1): + expr = f"(({kname} == {_i32(j)}) ? {texts[j]} : {expr})" + return expr + + def predicated_tensor(self, r, kname: str, texts: Sequence[str]) -> str: + """Nested ``hawk_{S}_select`` calls, the branches named struct values.""" + ttype = self.types.get(texts[-1]) + if ttype is None or not ttype.shape: + raise HawkError( + f"a rank>=1 predicated dispatch in {self.name!r} reached the C " + f"dialect with an unnamed branch ({texts[-1]!r}); use policy='switch'") + select = self._helper("select", ttype.shape) + expr = texts[-1] + for j in range(len(texts) - 2, -1, -1): + expr = f"{select}(({kname} == {_i32(j)}), {texts[j]}, {expr})" + return expr + + def switch_default_tail(self) -> str: + """Every ``default`` clause ends in a ``break`` (MISRA C:2012 16.3).""" + return "break;" + + # -- sinks and the guard -------------------------------------------- + + def guard(self, bound: Sequence[str]) -> str | None: + """The guard's condition: any bound ``uint8_t`` mask set at this sample.""" + if not bound: + return None + reads = [self._mask(b) for b in bound] + return reads[0] if len(reads) == 1 else "(" + " || ".join(reads) + ")" + + @staticmethod + def _mask(binding: str) -> str: + return f"({binding}[{SAMPLE_INDEX_IDENT}] != (uint8_t)0)" + + def store(self, r, sink, target: str, value: str) -> None: + """The own column: a rank-0 element, or a SoA store with pitch ``n_samples``.""" + value = self._coerce(r, value, sink.value.ttype.dtype, sink.ttype.dtype) + if sink.ttype.shape: + store = self._helper("store", sink.ttype.shape) + r._emit(f"{store}({target}, {SAMPLE_INDEX_IDENT}, n_samples, {value});") + return + r._emit(f"{target}[{SAMPLE_INDEX_IDENT}] = {value};") + + def scatter_store(self, r, sink, target: str, index: str, value: str) -> None: + """A scattered wide write: a store at the clamped computed row.""" + value = self._coerce(r, value, sink.value.ttype.dtype, sink.ttype.dtype) + row = self._clamped(sink, index, f"{target}_len") + r._emit(f"{target}[{row}] = {value};") + + def accum_scattered(self, r, sink, target: str, index: str, value: str) -> None: + """A scattered accumulate: a serial ``+=`` at the clamped computed row.""" + value = self._coerce(r, value, sink.value.ttype.dtype, sink.ttype.dtype) + row = self._clamped(sink, index, f"{target}_len") + r._emit(f"{target}[{row}] += {value};") + + def store_compensated(self, r, sink, target: str, comp: str, value: str) -> None: + """Refused: the compensated sink policy is not lowered (verify refuses + it first; this is the belt and braces).""" + raise HawkError(f"{self.name!r}: the C target does not lower the " + "compensated sink policy; use the plain sink policy") + + def component_commit(self, r, target: str, at: str, op: str, value: str) -> str: + """Never reached: :attr:`commits_per_component` is off, so the renderer + keeps the single vector store.""" + raise HawkError(f"{self.name!r}: the C target stores a vector once, never " + "per component") + + def in_place_accumulate(self, r, target: str, k: str, value: str) -> str: + """Refused: a runtime-indexed accumulate inside a lowered loop updates a + plane row in place, which the C target does not lower.""" + raise HawkError( + f"{self.name!r}: the C target does not lower a gradient accumulated in " + f"place inside a loop ({target} at a runtime index, as a vjp through a " + "runtime-indexed loop writes it); differentiate the unrolled loop, or " + "export the primal") + + def zero_row(self, r, target: str, width: int) -> str: + """Refused with :meth:`in_place_accumulate`, whose prologue this is.""" + return self.in_place_accumulate(r, target, "hk", "0") + + def sample_sum(self, r, sink, column: int) -> None: + """Refused (belt and braces; the verify pass refuses it first): a sum + over samples needs the wrapper's cross-sample reduction.""" + raise HawkError(f"{self.name!r}: the C target does not lower a sum over " + "samples; commit the own column and reduce in the caller") + + # -- expressions ---------------------------------------------------- + + def leaf(self, r, leaf: Leaf, materialised: bool) -> str: + name = binding_name(leaf.role, leaf.name) + t = leaf.ttype + if leaf.role == "nsamples": + return "n_samples" + if leaf.role == "uniform": + if t.dtype in INT_PLANE_DTYPES: + return f"(({self._ctype(t)}){name})" + return name + if leaf.role == "terminated": + return self._mask(name) + if leaf.role in GATHER_ROLES: + # Read only through at(): the renderer still visits the plane leaf, + # whose text no expression uses, and never names it (:meth:`declare` + # keeps a belt-and-braces check). + self._gathered.add(name) + return name + if t.shape: + if t.dtype not in _REAL: + raise HawkError(f"{self.name!r}: a rank-{len(t.shape)} {t.dtype} plane " + f"({leaf.name!r}) has no C load") + load = self._helper("load", t.shape) + return f"{load}({name}, {SAMPLE_INDEX_IDENT}, n_samples)" + return f"{name}[{SAMPLE_INDEX_IDENT}]" + + def const(self, node: Const) -> str: + t = node.ttype + if t.shape: + raise HawkError( + f"a rank-{len(t.shape)} literal typed {t!r} has no C spelling; " + "a broadcast literal is a rank-0 constant under a 'splat'") + if t.dtype == "bool" or t.dtype in _INTS: + return _int_zero_like(t.dtype, int(node.literal)) + text = real_literal(float(node.literal), self.mode) + if "NAN" in text or "INFINITY" in text: + self.literal_specials.append(text[len("((hawk_real_t)"):-1]) + return text + + def at(self, r, node: At) -> str: + if is_param_cell(node): + return param_cell_binding(node.plane.name) + if node.ttype.shape: + raise HawkError(f"{self.name!r}: a rank-{len(node.ttype.shape)} at() read " + "has no C spelling") + plane = binding_name(node.plane.role, node.plane.name) + index = self._clamped(node, r._ref(node.index), f"{plane}_len") + return f"{plane}[{index}]" + + def select(self, r, node: Select, c: str, a: str, b: str) -> str: + """A rank-0 ternary, or ``hawk_{S}_select`` on a rank-0 condition.""" + t = node.ttype + if not t.shape: + a = self._coerce(r, a, node.operands[1].ttype.dtype, t.dtype) + b = self._coerce(r, b, node.operands[2].ttype.dtype, t.dtype) + return f"({c} ? {a} : {b})" + if node.operands[0].ttype.shape: + raise HawkError(f"{self.name!r}: a select on a rank>=1 condition is not " + "lowered; select per component with a rank-0 condition") + return f"{self._helper('select', t.shape)}({c}, {a}, {b})" + + def op(self, r, node: Op, args: Sequence[str], ranks: Sequence[int]) -> str: + kind, rank = node.kind, len(node.ttype.shape) + if kind == RAW_KIND or kind in RANDOM_OPS: + raise HawkError(f"{self.name!r}: op {kind!r} has no C spelling " + "(the C target refuses it)") + if rank == 0 and all(rk == 0 for rk in ranks): + return self._scalar_op(r, kind, args, node) + return self._tensor_op(r, kind, args, ranks, node) + + def _scalar_op(self, r, kind: str, args: Sequence[str], node: Op) -> str: + dtype = node.ttype.dtype + srcs = [o.ttype.dtype for o in node.operands] + if kind == "cast": + return self._cast_op(r, args[0], node) + if dtype in _INTS and kind in _INT_KINDS: + # Int and int-plane arithmetic: the wrapping, numpy-defined helpers. + fn = self._helper(INT_HELPER[(kind, dtype)].removeprefix("hawk_")) + return f"{fn}({', '.join(args)})" + if kind in _PREDICATES: + return f"({kind}({args[0]}) != 0)" + if kind in ("eq", "ne") and any(s in _REAL for s in srcs): + self.fp_eq_sites.append(self.positions.get(id(node))) + reals = ([self._real(r, a, s) for a, s in zip(args, srcs)] + if dtype in _REAL or kind in ("lt", "le", "gt", "ge", "eq", "ne") + and any(s in _REAL for s in srcs) else list(args)) + if kind == "spacing": + return f"{self._helper('spacing')}({reals[0]})" + if kind == "sign": + return f"{self._helper('sign_nan')}({reals[0]})" + if kind in _CORE_SCALARS: + return f"{self._core(kind)}({', '.join(reals)})" + if kind == "abs": + return f"{self._libm('fabs')}({reals[0]})" + if kind in _MATH1: + return f"{self._libm(_MATH1[kind])}({reals[0]})" + if kind in _MATH2: + return f"{self._libm(_MATH2[kind])}({reals[0]}, {reals[1]})" + if kind == "fma": + return f"{self._libm('fma')}({', '.join(reals)})" + if kind in _INFIX and len(args) == 2: + return f"({reals[0]} {_INFIX[kind]} {reals[1]})" + if kind == "neg": + return f"(-{reals[0]})" + if kind == "lnot": + return f"(!{args[0]})" + if kind in ("dot", "norm", "sum", "component"): + return self._tensor_op(r, kind, args, [0] * len(args), node) + raise HawkError(_no_spelling(kind, 0)) + + def _cast_op(self, r, arg: str, node: Op) -> str: + """``cast``: Real -> integer through the saturating ``from_real`` + helper, integer -> Real and integer -> integer as an explicit C cast + of a non-composite operand (Rule 10.8).""" + target, source = node.ttype.dtype, node.operands[0].ttype.dtype + if target in _REAL and source in _REAL: + return arg + if target in _INTS and source in _REAL: + fn = self._helper(INT_HELPER[("from_real", target)].removeprefix("hawk_")) + return f"{fn}({arg})" + to = self._ctype(TensorType((), target)) + if to == self._ctype(TensorType((), source)): + return arg + return f"(({to}){self._atom(r, arg, node.operands[0].ttype)})" + + def _reduction(self, r, kind: str, args: Sequence[str], node: Op) -> str: + """The rank>=1 -> rank-0 forms: a core fold, or a struct member.""" + src = node.operands[0].ttype + if kind == "component": + index = node.literal if isinstance(node.literal, tuple) else (node.literal,) + x = self._named(r, args[0], src) + if len(src.shape) == 1: + return f"{x}.v[{int(index[0])}]" + if len(index) == 2: + return f"{x}.m[{int(index[0])}][{int(index[1])}]" + row = int(index[0]) + cells = ", ".join(f"{x}.m[{row}][{c}]" for c in range(src.shape[1])) + return f"{self._helper('make', (src.shape[1],))}({cells})" + if kind == "sum": + word = "vec" if len(src.shape) == 1 else "mat" + return f"{self._core(f'{word}_sum', src.shape)}({args[0]})" + if len(src.shape) != 1: + raise HawkError(_no_spelling(kind, len(src.shape))) + if kind == "dot": + return f"{self._core('vec_dot', src.shape)}({args[0]}, {args[1]})" + return f"{self._core('vec_norm', src.shape)}({args[0]})" + + def _runtime_component(self, r, node: Op, args: Sequence[str]) -> str: + """``v[k]`` at a runtime index: ``hawk_{S}_at`` (clamped) or ``_at_raw``.""" + src = node.operands[0].ttype + base = "at_raw" if self._status(node) == "proven" else "at" + return f"{self._helper(base, src.shape)}({args[0]}, {args[1]})" + + def _runtime_component_store(self, r, node: Op, args: Sequence[str]) -> str: + """``v[k] = e`` as a value: ``hawk_{S}_set`` (clamped) or ``_set_raw``.""" + t = node.ttype + base = "set_raw" if self._status(node) == "proven" else "set" + e = self._real(r, args[2], node.operands[2].ttype.dtype) + return f"{self._helper(base, t.shape)}({args[0]}, {args[1]}, {e})" + + def _tensor_op(self, r, kind: str, args: Sequence[str], ranks: Sequence[int], + node: Op) -> str: + t = node.ttype + rank = len(t.shape) + if kind == "component_at": + return self._runtime_component(r, node, args) + if kind == "set_component_at": + return self._runtime_component_store(r, node, args) + if kind in ("dot", "norm", "sum", "component"): + return self._reduction(r, kind, args, node) + if rank == 0: + raise HawkError(_no_spelling(kind, max(ranks))) + word = "vec" if rank == 1 else "mat" + srcs = [o.ttype.dtype for o in node.operands] + if kind == "splat": + return self._splat(r, args[0], srcs[0], t.shape) + if kind == "vec": + if not args: + raise HawkError("vec: a vector literal needs at least one component") + parts = ", ".join(self._real(r, a, s) for a, s in zip(args, srcs)) + return f"{self._helper('make', t.shape)}({parts})" + if kind == "mul" and 0 in ranks: + # s * v == v * s: one core scale (its signature is ``(a, s)``; + # each component is ``s * a_k``, multiplication commutes). + s, v = (0, 1) if ranks[0] == 0 else (1, 0) + return (f"{self._core(f'{word}_scale', t.shape)}(" + f"{args[v]}, {self._real(r, args[s], srcs[s])})") + if kind == "div" and ranks[1] == 0: + # the reciprocal once, then a scale (never a cwise_div by a splat). + return (f"{self._core(f'{word}_div_s', t.shape)}(" + f"{args[0]}, {self._real(r, args[1], srcs[1])})") + if kind in _SPLAT_BINARY or kind in ("mul", "div"): + core = {"mul": "mul", "div": "cwise_div"}.get(kind, _SPLAT_BINARY.get(kind)) + a, b = (self._splat(r, x, s, t.shape) if rk == 0 else x + for x, s, rk in zip(args, srcs, ranks)) + return f"{self._core(f'{word}_{core}', t.shape)}({a}, {b})" + if kind in ("neg", "abs") or kind in _CWISE1: + return f"{self._core(f'{word}_{kind}', t.shape)}({args[0]})" + src = node.operands[0].ttype + if kind == "mv": + return f"{self._core('mat_mv', src.shape)}({args[0]}, {args[1]})" + if kind == "mm": + fn = self._core("mat_mm", src.shape, node.operands[1].ttype.shape) + return f"{fn}({args[0]}, {args[1]})" + if kind == "outer": + fn = self._core("vec_outer", src.shape, node.operands[1].ttype.shape) + return f"{fn}({args[0]}, {args[1]})" + if kind == "transpose": + return f"{self._core('mat_transpose', src.shape)}({args[0]})" + if kind == "cross": + return f"{self._core('vec_cross', src.shape)}({args[0]}, {args[1]})" + if kind in _QUAT: + return f"{self._core(_QUAT[kind])}({', '.join(args)})" + raise HawkError(_no_spelling(kind, rank)) + + def _splat(self, r, text: str, dtype: str, shape) -> str: + """A rank-0 operand of a two-struct core function, broadcast.""" + return f"{self._helper('splat', shape)}({self._real(r, text, dtype)})" + + def tape_write(self, r, sink: TapeWrite, target: str) -> None: + """One tape ROW on the own column, under ``if (on)``: component + ``row*w + c`` of the sample's ``(W*w,)`` SoA plane gets the ``c``-th + flattened component of the stored values, in order. The row is + clamped to the plane's row count unless verify proved it.""" + rows = sink.ttype.shape[0] // sink.width + row = self._clamped(sink, r._ref(sink.row), rows) + lines = [] + offset = 0 + for value in sink.values: + text = r._ref(value) + shape = value.ttype.shape + if not shape: + comps = [self._real(r, text, value.ttype.dtype)] + else: + x = self._named(r, text, value.ttype) + comps = [f"{x}.{cell}" for cell in _cells(shape)] + for comp in comps: + cell = (f"((hawk_tape_row * {_i64(sink.width)}) + {_i64(offset)})" + f" * n_samples") + lines.append(f"{target}[({cell}) + {SAMPLE_INDEX_IDENT}] = {comp};") + offset += 1 + on, on_dtype = r._ref(sink.on), sink.on.ttype.dtype + cond = on if on_dtype == "bool" else f"{on} != {_int_zero_like(on_dtype)}" + r._emit(f"if {_paren(cond)} {{") + r.indent += " " + r._emit(f"const int64_t hawk_tape_row = {row};") + for line in lines: + r._emit(line) + r.indent = r.indent[:-4] + r._emit("}") + + def finish(self, r, sink: Finish) -> None: + """The finish epilogue — the last text of the guarded scope: an + unfinished sample whose condition holds sets its mask through the + writable twin and adds one to the reserved counter.""" + mask = binding_name("terminated", sink.name) + cond = r._ref(sink.value) + counter = binding_name("lookup", FINISHED_PLANE) + r._emit(f"if (({mask}[{SAMPLE_INDEX_IDENT}] == (uint8_t)0) && {cond}) {{") + r._emit(f" {finish_binding(sink.name)}[{SAMPLE_INDEX_IDENT}] = (uint8_t)1;") + r._emit(f" {counter}[0] = {counter}[0] + 1U;") + r._emit("}") + + def typed_zero(self, ttype: TensorType) -> str: + """The typed zero a ``switch`` ``default:`` writes when a branch was elided.""" + if ttype.shape: + zero = real_literal(0.0, self.mode) + return f"{self._helper('splat', ttype.shape)}({zero})" + if ttype.dtype in _REAL: + return real_literal(0.0, self.mode) + return _int_zero_like(ttype.dtype) + + +def _paren(cond: str) -> str: + """``cond`` as an ``if`` condition: itself when it is ONE parenthesised + expression (the outer pair matches), else wrapped once.""" + if cond.startswith("(") and cond.endswith(")"): + depth = 0 + for k, ch in enumerate(cond): + depth += {"(": 1, ")": -1}.get(ch, 0) + if depth == 0 and k < len(cond) - 1: + break + else: + return cond + return f"({cond})" + + +def _i64(value: int) -> str: + return int_literal(value, "i64") + + +def _i32(value: int) -> str: + return int_literal(value, "int32") + + +def _int_zero_like(dtype: str, value: int = 0) -> str: + """An integer or bool literal of ``dtype`` (an index ``i32`` is 64-bit).""" + return int_literal(value, "i64" if dtype == "i32" else dtype) + + +def _cells(shape) -> list[str]: + """Row-major struct member accessors (``v[k]`` / ``m[r][c]``).""" + if len(shape) == 1: + return [f"v[{k}]" for k in range(shape[0])] + return [f"m[{i}][{j}]" for i in range(shape[0]) for j in range(shape[1])] + + +def _no_spelling(kind: str, rank: int) -> str: + return f"op {kind!r} has no C spelling at rank {rank}" + diff --git a/hawk/embedded/helpers.py b/hawk/embedded/helpers.py new file mode 100644 index 0000000..c28f84b --- /dev/null +++ b/hawk/embedded/helpers.py @@ -0,0 +1,338 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The C helpers hawk itself owns in the standalone C target: index and trip +clamps, plane load/store, aggregate construction and selection, the integer +helpers with numpy edge semantics, ``spacing`` and ``sign_nan``. Each is a text +template returned as an ``aether_dsc.c_core.CFunction``, so the translation-unit +assembler treats these and the core's arithmetic folds alike. Arithmetic folds +and the scalar ``sign`` come from the core, not from here.""" + +from __future__ import annotations + +from aether_dsc import c_core +from aether_dsc.c_core import CFunction + +from ..ir import HawkError +from .spelling import ( + INT_HELPER, + WIDTH, + int_literal, + libm_name, + real_literal, + shape_word, +) + +#: Helpers that take a shape (aggregate type), by base name. +_SHAPED = frozenset( + "load store splat make select at set at_raw set_raw".split() +) + +#: ``(prefix, op)`` pairs of the integer helpers, in declaration order. +_INT_OPS = tuple(dict.fromkeys( + tuple(name.removeprefix("hawk_").split("_", 1)) for name in INT_HELPER.values() +)) + +#: Every helper name; shape-bearing ones once, by base name. +HELPER_NAMES: tuple[str, ...] = ( + "clamp_index", "clamp_trips", "clamp_count", + "load", "store", "splat", "make", "select", "at", "set", "at_raw", "set_raw", + *(f"{prefix}_{op}" for prefix, op in _INT_OPS), + "spacing", "sign_nan", +) + + +def _text(name: str, shape, mode, signature: str, body: list[str], + note: str | None = None) -> str: + """One helper: the header line, an optional note, then the function.""" + lines = [f"/* hawk: {name} {shape if shape else ()} {mode.id} */"] + if note: + lines.append(f"/* {note} */") + lines.append(f"static inline {signature}") + lines.append("{") + lines += [f" {line}" for line in body] + lines.append("}") + return "\n".join(lines) + "\n" + + +# ---- clamps ---- + +def _clamp(name: str, mode) -> str: + zero32, zero64 = int_literal(0, "int32"), int_literal(0, "i64") + if name == "clamp_index": + one = int_literal(1, "i64") + return _text( + name, None, mode, "int64_t hawk_clamp_index(int64_t k, int64_t n)", + [f"return (k < {zero64}) ? {zero64} : ((k >= n) ? (n - {one}) : k);"], + "precondition: n >= 1", + ) + if name == "clamp_trips": + return _text( + name, None, mode, "int32_t hawk_clamp_trips(hawk_int_t n, int32_t trip)", + [f"return (n < {zero64}) ? {zero32} : ((n > trip) ? trip : (int32_t)n);"], + ) + top = int_literal(2147483647, "int32") + return _text( + name, None, mode, "int32_t hawk_clamp_count(hawk_int_t n)", + [f"return (n <= {zero64}) ? {zero32} : " + f"((n < {int_literal(2147483647, 'i64')}) ? (int32_t)n : {top});"], + "returns 0 for n <= 0; the C++ _CLAMP_COUNT narrowed instead " + "(both run no iterations for INT32_MIN <= n <= 0)", + ) + + +# ---- aggregates ---- + +def _cells(shape: tuple[int, ...]) -> list[tuple[int, str]]: + """Row-major ``(flat index, accessor)`` per component.""" + if len(shape) == 1: + return [(k, f"v[{k}]") for k in range(shape[0])] + rows, cols = shape + return [(r * cols + c, f"m[{r}][{c}]") for r in range(rows) for c in range(cols)] + + +def _plane(flat: int) -> str: + if flat == 0: + return "plane[i]" + if flat == 1: + return "plane[pitch + i]" + return f"plane[({flat} * pitch) + i]" + + +def _unused_pitch(count: int) -> list[str]: + """A one-component plane access never reads ``pitch``.""" + return ["(void)pitch;"] if count == 1 else [] + + +def _clamped(shape: tuple[int, ...], count: int) -> tuple[list[str], str]: + """Prelude lines and the index expression for a clamped slot access.""" + clamp = f"hawk_clamp_index(k, {count})" + if len(shape) == 1: + return [], clamp + return [f"const int64_t j = {clamp};"], "j" + + +def _aggregate(name: str, shape: tuple[int, ...], mode) -> str: + w = WIDTH[mode.id] + t = c_core.struct(shape, w).name + word = shape_word(shape) + fn = f"hawk_{word}_{name}" + cells = _cells(shape) + count = len(cells) + if name == "load": + sig = f"{t} {fn}(const hawk_real_t* plane, int64_t i, int64_t pitch)" + body = _unused_pitch(count) + [f"{t} out;"] + body += [f"out.{acc} = {_plane(flat)};" for flat, acc in cells] + body.append("return out;") + elif name == "store": + sig = f"void {fn}(hawk_real_t* plane, int64_t i, int64_t pitch, {t} x)" + body = _unused_pitch(count) + body += [f"{_plane(flat)} = x.{acc};" for flat, acc in cells] + elif name == "splat": + sig = f"{t} {fn}(hawk_real_t s)" + body = [f"{t} out;"] + body += [f"out.{acc} = s;" for _, acc in cells] + body.append("return out;") + elif name == "make": + args = ", ".join(f"hawk_real_t a{flat}" for flat, _ in cells) + sig = f"{t} {fn}({args})" + body = [f"{t} out;"] + body += [f"out.{acc} = a{flat};" for flat, acc in cells] + body.append("return out;") + elif name == "select": + sig = f"{t} {fn}(bool c, {t} a, {t} b)" + body = [f"{t} out;"] + body += [f"out.{acc} = c ? a.{acc} : b.{acc};" for _, acc in cells] + body.append("return out;") + elif name in ("at", "at_raw"): + sig = f"hawk_real_t {fn}({t} x, hawk_int_t k)" + if name == "at": + body, use = _clamped(shape, count) + else: + body, use = [], "k" + if len(shape) == 1: + body.append(f"return x.v[{use}];") + else: + body.append(f"return x.m[{use} / {shape[1]}][{use} % {shape[1]}];") + else: + sig = f"{t} {fn}({t} x, hawk_int_t k, hawk_real_t e)" + if name == "set": + body, use = _clamped(shape, count) + else: + body, use = [], "k" + body.append(f"{t} out = x;") + if len(shape) == 1: + body.append(f"out.v[{use}] = e;") + else: + body.append(f"out.m[{use} / {shape[1]}][{use} % {shape[1]}] = e;") + body.append("return out;") + return _text(name, shape, mode, sig, body) + + +# ---- integer helpers: one formatter for the three widths ---- + +#: prefix -> (dtype key of the literal macro, value type, unsigned twin, twin +#: literal macro, bits, signed). ``i64`` takes ``hawk_int_t`` for both sides. +_WIDTHS = { + "i64": ("i64", "hawk_int_t", "uint64_t", "UINT64_C", 64, True), + "i32": ("int32", "int32_t", "uint32_t", "UINT32_C", 32, True), + "u32": ("uint32", "uint32_t", "uint32_t", "UINT32_C", 32, False), +} + +_RANGE = { + "i64": (f"(-{int_literal(9223372036854775807, 'i64')} - {int_literal(1, 'i64')})", + int_literal(9223372036854775807, "i64")), + "i32": (f"(-{int_literal(2147483647, 'int32')} - {int_literal(1, 'int32')})", + int_literal(2147483647, "int32")), + "u32": (int_literal(0, "uint32"), int_literal(4294967295, "uint32")), +} + + +#: The saturation bounds a real is compared against, spelled as real +#: literals (exact in both widths; a cast of the composite ``-MAX - 1`` would +#: break MISRA C:2012 10.8). The values returned stay the integer :data:`_RANGE`. +_REAL_RANGE = { + "i64": ("((hawk_real_t)-9223372036854775808.0)", + "((hawk_real_t)9223372036854775807.0)"), + "i32": ("((hawk_real_t)-2147483648.0)", "((hawk_real_t)2147483647.0)"), + "u32": ("((hawk_real_t)0.0)", "((hawk_real_t)4294967295.0)"), +} + + +def _int_helper(prefix: str, op: str, mode) -> str: + dtype, t, u, umacro, bits, signed = _WIDTHS[prefix] + lit = lambda v: int_literal(v, dtype) # noqa: E731 + ulit = lambda v: f"{umacro}({v})" # noqa: E731 + fn = f"hawk_{prefix}_{op}" + lo, hi = _RANGE[prefix] + z, m1, one = lit(0), lit(-1), lit(1) + two = f"{t} a, {t} b" + # wrapping arithmetic runs in the unsigned twin and is named before the + # cast back: a composite expression is never cast (MISRA C:2012 10.8) + negate = f"const {u} n = {ulit(0)} - ({u})a;" + if op in ("add", "sub", "mul"): + sym = {"add": "+", "sub": "-", "mul": "*"}[op] + sig = f"{t} {fn}({two})" + body = [f"const {u} r = ({u})a {sym} ({u})b;", f"return ({t})r;"] + elif op == "neg": + sig, body = f"{t} {fn}({t} a)", [negate, f"return ({t})n;"] + elif op == "abs": + sig = f"{t} {fn}({t} a)" + body = ([negate, f"return (a < {z}) ? ({t})n : a;"] if signed + else ["return a;"]) + elif op == "min": + sig, body = f"{t} {fn}({two})", ["return (b < a) ? b : a;"] + elif op == "max": + sig, body = f"{t} {fn}({two})", ["return (a < b) ? b : a;"] + elif op == "div": + sig = f"{t} {fn}({two})" + body = [f"{t} result;", f"if (b == {z}) {{", f" result = {z};", "}"] + if signed: + body += [f"else if ((a == {lo}) && (b == {m1})) {{", + f" result = {lo};", "}"] + body += ["else {", " result = a / b;", "}", "return result;"] + elif op == "floordiv": + sig = f"{t} {fn}({two})" + body = [f"{t} result;", f"if (b == {z}) {{", f" result = {z};", "}"] + if signed: + body += [f"else if (b == {m1}) {{", f" {negate}", + f" result = ({t})n;", "}", + "else {", + f" const {t} q = ({t})(a / b);", + f" result = (((a % b) != {z}) && ((a < {z}) != (b < {z})))", + f" ? ({t})(q - {one}) : q;", + "}"] + else: + body += ["else {", f" result = ({t})(a / b);", "}"] + body.append("return result;") + elif op == "mod": + sig = f"{t} {fn}({two})" + if signed: + body = [f"{t} result;", + f"if ((b == {z}) || (b == {m1})) {{", f" result = {z};", "}", + "else {", + f" const {t} r = ({t})(a % b);", + f" result = ((r != {z}) && ((r < {z}) != (b < {z})))", + f" ? ({t})(r + b) : r;", + "}", "return result;"] + else: + body = [f"{t} result;", f"if (b == {z}) {{", f" result = {z};", "}", + "else {", f" result = ({t})(a % b);", "}", "return result;"] + elif op in ("and", "or", "xor"): + sym = {"and": "&", "or": "|", "xor": "^"}[op] + sig, body = f"{t} {fn}({two})", [f"return ({t})(a {sym} b);"] + elif op == "invert": + sig, body = f"{t} {fn}({t} a)", [f"return ({t})(~a);"] + elif op == "shl": + sig = f"{t} {fn}({two})" + body = [f"{t} result;", f"if (({u})b < {ulit(bits)}) {{", + f" const {u} r = ({u})a << ({u})b;", + f" result = ({t})r;", "}", + "else {", f" result = {z};", "}", "return result;"] + elif op == "shr": + sig = f"{t} {fn}({two})" + far = f"({t})((a < {z}) ? {m1} : {z})" if signed else lit(0) + # a signed operand shifts in the unsigned twin (MISRA C:2012 10.1): + # ~(~a >> b) is the arithmetic shift of a negative a, bit for bit + shift = ([f" const {u} ua = ({u})a;", + f" const {u} r = (a < {z}) ? ~((~ua) >> ({u})b) : (ua >> ({u})b);", + f" result = ({t})r;"] if signed + else [f" result = ({t})(a >> ({u})b);"]) + body = [f"{t} result;", f"if (({u})b < {ulit(bits)}) {{", *shift, "}", + "else {", f" result = {far};", "}", "return result;"] + elif op == "from_real": + sig = f"{t} {fn}(hawk_real_t x)" + rlo, rhi = _REAL_RANGE[prefix] + body = [f"return isnan(x) ? {z} : ((x <= {rlo}) ? {lo} : " + f"((x >= {rhi}) ? {hi} : ({t})x));"] + else: + raise HawkError(f"no integer helper {prefix}_{op}") + return _text(f"{prefix}_{op}", None, mode, sig, body) + + +# ---- real helpers ---- + +def _spacing(mode) -> str: + zero, one, two = (real_literal(v, mode) for v in (0.0, 1.0, 2.0)) + nxt = libm_name("nextafter", mode) + return _text( + "spacing", None, mode, "hawk_real_t hawk_spacing(hawk_real_t x)", + [f"const hawk_real_t a = (x < {zero}) ? -x : x;", + f"const hawk_real_t up = {nxt}(a, (a * {two}) + {one}) - a;", + f"return (x < {zero}) ? -up : up;"], + ) + + +def _sign_nan(mode) -> str: + sign = c_core.function("sign", width=WIDTH[mode.id]).name + return _text( + "sign_nan", None, mode, "hawk_real_t hawk_sign_nan(hawk_real_t x)", + [f"return isnan(x) ? x : {sign}(x);"], + ) + + +def helper(name: str, *, mode, shape=None) -> CFunction: + """The C helper ``name`` under a scalar mode; shape-bearing helpers need + ``shape`` (rank 1 or 2).""" + w = WIDTH[mode.id] + if name in _SHAPED: + if shape is None or len(shape) not in (1, 2): + raise HawkError(f"helper {name!r} needs a rank-1 or rank-2 shape") + shape = tuple(shape) + deps = (c_core.struct(shape, w).name,) + if name in ("at", "set"): + deps += ("hawk_clamp_index",) + return CFunction(f"hawk_{shape_word(shape)}_{name}", + _aggregate(name, shape, mode), deps, ()) + if name in ("clamp_index", "clamp_trips", "clamp_count"): + return CFunction(f"hawk_{name}", _clamp(name, mode), (), ()) + if name == "spacing": + libm = (libm_name("nextafter", mode),) + return CFunction("hawk_spacing", _spacing(mode), (), libm) + if name == "sign_nan": + deps = (c_core.function("sign", width=w).name,) + return CFunction("hawk_sign_nan", _sign_nan(mode), deps, ()) + prefix, _, op = name.partition("_") + if (prefix, op) in _INT_OPS: + return CFunction(f"hawk_{name}", _int_helper(prefix, op, mode), (), ()) + raise HawkError(f"unknown helper {name!r}; known: {', '.join(HELPER_NAMES)}") diff --git a/hawk/embedded/profile.py b/hawk/embedded/profile.py new file mode 100644 index 0000000..6fe182b --- /dev/null +++ b/hawk/embedded/profile.py @@ -0,0 +1,53 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The C target's profile: the opt-in caps and policies one export runs under. + +Every cap defaults to ``None`` (no cap) and every policy to the permissive +value, so the default profile refuses only what the C target cannot build. +``repr()`` is a term of the unit key, so it stays the dataclass default: +deterministic, field order fixed.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from ..ir import HawkError + +#: The cap fields: ``None`` (no cap) or a non-negative ``int``. +_CAPS = ("max_trips", "max_ops", "max_stack_bytes") + +#: The allowed values of each policy field, in the order a refusal lists them. +_CHOICES = { + "fp_domain": ("record", "refuse"), + "index_policy": ("clamp", "refuse"), + "c_std": ("c99", "c11"), +} + + +@dataclass(frozen=True) +class EmbeddedProfile: + """Caps and policies for one C export (see :mod:`hawk.embedded.verify`).""" + + max_trips: int | None = None # worst-case loop trips per sample + max_ops: int | None = None # arith + special ops per sample (kernel_cost) + max_stack_bytes: int | None = None # tapes + carries + named temporaries + fp_domain: str = "record" # "record" | "refuse" + index_policy: str = "clamp" # "clamp" | "refuse" + require_cppcheck: bool = False + c_std: str = "c99" # the dialect the consumer compiles under + + def __post_init__(self) -> None: + for field in _CAPS: + value = getattr(self, field) + if value is not None and (isinstance(value, bool) + or not isinstance(value, int) or value < 0): + raise HawkError( + f"EmbeddedProfile({field}={value!r}): {field} is None (no cap) " + "or a non-negative int") + for field, allowed in _CHOICES.items(): + value = getattr(self, field) + if value not in allowed: + raise HawkError( + f"EmbeddedProfile({field}={value!r}): {field} is one of " + f"{', '.join(repr(a) for a in allowed)}") diff --git a/hawk/embedded/spelling.py b/hawk/embedded/spelling.py new file mode 100644 index 0000000..565b28e --- /dev/null +++ b/hawk/embedded/spelling.py @@ -0,0 +1,117 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""How the standalone C target spells things: element and aggregate types, libm +names, literals, index and trip clamps, and the integer helper names. Pure +tables and small string functions; nothing here renders a kernel.""" + +from __future__ import annotations + +from ..emit.spelling import _real_literal +from ..ir import HawkError + +#: ScalarMode id to the aether C width word used in vector and matrix type names. +WIDTH = {"float64": "f64", "float32": "f32"} + +#: Element dtype to its scalar C type. +_SCALAR_CTYPE = { + "f64": "hawk_real_t", + "f32": "hawk_real_t", + "i32": "hawk_int_t", + "i64": "hawk_int_t", + "int32": "int32_t", + "uint32": "uint32_t", + "bool": "bool", +} + +#: C99 libm base names the C target may call (float32 appends ``f``). +#: ``isnan``/``isinf``/``isfinite`` are macros, not libm names. +LIBM_NAMES = ( + "sqrt rsqrt exp log sin cos tan tanh asin acos atan floor ceil trunc round " + "rint exp2 expm1 log2 log10 log1p cbrt sinh cosh asinh acosh atanh erf erfc " + "pow fmin fmax atan2 hypot copysign fmod fdim fma fabs nextafter" +).split() + +#: Integer helper kinds, in the order the helpers are declared. +_INT_KINDS = ( + "add sub mul neg abs min max div floordiv mod band bor bxor shl shr invert " + "from_real" +).split() + +#: Kind to the operation word in the helper name (``band`` is ``and``, ...). +_INT_OP = {"band": "and", "bor": "or", "bxor": "xor"} + +#: Dtype to the helper family prefix; i32 and i64 share the 64-bit helpers. +_INT_PREFIX = { + "i32": "hawk_i64", + "i64": "hawk_i64", + "int32": "hawk_i32", + "uint32": "hawk_u32", +} + +#: ``(kind, dtype)`` to the integer helper function name. +INT_HELPER = { + (kind, dtype): f"{prefix}_{_INT_OP.get(kind, kind)}" + for dtype, prefix in _INT_PREFIX.items() + for kind in _INT_KINDS +} + +#: Dtype to the fixed-width literal macro. +_INT_MACRO = { + "i32": "INT64_C", + "i64": "INT64_C", + "int32": "INT32_C", + "uint32": "UINT32_C", +} + + +def shape_word(shape: tuple[int, ...]) -> str: + """Name a shape for type spellings: ``scalar``, ``vec3``, ``mat2x3``.""" + if len(shape) == 0: + return "scalar" + if len(shape) == 1: + return f"vec{shape[0]}" + if len(shape) == 2: + return f"mat{shape[0]}x{shape[1]}" + raise HawkError(f"no C spelling for rank-{len(shape)} shape {shape}") + + +def ctype(ttype, mode) -> str: + """The C type of a ``TensorType`` under a scalar mode.""" + if ttype.shape == (): + try: + return _SCALAR_CTYPE[ttype.dtype] + except KeyError: + raise HawkError(f"no C type for dtype {ttype.dtype!r}") from None + return f"aether_c_{WIDTH[mode.id]}_{shape_word(ttype.shape)}_t" + + +def libm_name(fn: str, mode) -> str: + """The C99 libm function for ``fn`` under a mode (``f`` suffix in float32).""" + if fn not in LIBM_NAMES: + raise HawkError( + f"unknown libm function {fn!r}; known: {', '.join(LIBM_NAMES)}" + ) + return fn + "f" if mode.id == "float32" else fn + + +def real_literal(value: float, mode) -> str: + """A real constant as a cast double literal; the mode does not change it.""" + return f"((hawk_real_t){_real_literal(value)})" + + +def int_literal(value: int, dtype: str) -> str: + """An integer (or bool) constant as a fixed-width literal macro.""" + if dtype == "bool": + return "true" if value else "false" + return f"{_INT_MACRO[dtype]}({int(value)})" + + +def clamp_index(expr: str, length: str) -> str: + """Clamp an index expression into ``[0, length)``.""" + return f"hawk_clamp_index({expr}, {length})" + + +def clamp_trips(expr: str, trip: int) -> str: + """Clamp a loop trip count into ``[0, trip]``.""" + return f"hawk_clamp_trips({expr}, {trip})" diff --git a/hawk/embedded/toolchain.py b/hawk/embedded/toolchain.py new file mode 100644 index 0000000..2ae852c --- /dev/null +++ b/hawk/embedded/toolchain.py @@ -0,0 +1,307 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""C compiler and cppcheck discovery, and the checks run on emitted C. + +The C compiler is found in this order: ``$HAWK_CC`` (an explicit override; +unresolvable raises), the C sibling of ``$HAWK_CXX`` (``g++-13`` -> ``gcc-13``, +``clang++`` -> ``clang``, ``c++`` -> ``cc``) so the C and C++ checks agree on +one toolchain, the first word of ``$CC``, ``cc``, ``clang`` and ``gcc`` on +``PATH``, and last a ``zig cc`` wrapper from the ``ziglang`` wheel. cppcheck +is ``$HAWK_CPPCHECK`` or ``cppcheck`` on ``PATH``. + +Nothing is skipped silently. A missing C compiler raises and names every +candidate tried, and a missing cppcheck is recorded as skipped in the result +(or raises when the profile requires it): a skipped check certifies nothing, +so the caller must be able to see that it did not run. + +Rule deviations are recorded in :data:`DEVIATIONS` and in the task report, +and applied as cppcheck ``--suppress`` flags; they are never written as +suppression comments in the emitted C. +""" + +from __future__ import annotations + +import os +import re +import shlex +import shutil +import subprocess +import tempfile +import time +from pathlib import Path + +from ..compile.cache import default_cache_dir +from ..ir import HawkError + +_STDS = ("c99", "c11") +_TIMEOUT_S = 600 + +DEVIATIONS: tuple[dict, ...] = ( + {"rule": "misra-c2012-8.7", "category": "advisory", + "reason": "cppcheck analyses one translation unit; the emitted entry " + "function and its args struct are the unit's exported API, " + "referenced from the consumer's translation unit, so the " + "single-TU heuristic cannot see the reference"}, + {"rule": "misra-c2012-9.3", "category": "required", + "reason": "a checker false positive, not a violation: cppcheck's misra " + "addon parses every non-name initialiser of a struct with an " + "array member as if it were a brace initialiser, so " + "`const aether_c_f64_vec3_t t = f(x);` (a function call result) " + "is reported as a partially initialised array while " + "`const pair_t p = f(x);` (no array member) is not; the emitted " + "C contains no brace initialiser at all, so Rule 9.3 has " + "nothing to apply to; the emitted-text audit's audit.init rule " + "(hawk.embedded.audit) refuses any `= {` in the .c, so this " + "unit-wide suppression can never hide a real partial brace " + "initialiser"}, + {"rule": "misra-c2012-2.3", "category": "advisory", + "reason": "the HAWK_EMBEDDED_TYPES block (hawk_real_t, hawk_int_t, " + "hawk_status_t) is the same text in every hawk header of a " + "build, so that the first header defines the shared types and " + "the width guard checks every later one; a unit that reads no " + "integer still declares hawk_int_t for the units beside it, and " + "the C11 size assert that references it is compiled out under " + "the C99 the checker analyses"}, +) +_FINDING = re.compile( + r"^(?P.+?):(?P\d+):(?P\d+): (?P\w+): " + r"(?P.*) \[(?P[^\]\s]+)\]\s*$") +_SIBLINGS = (("g++", "gcc"), ("clang++", "clang"), ("c++", "cc")) + + +def _c_sibling(cxx: str) -> str: + """``cxx`` with its basename rewritten to the C driver's (``g++-13`` -> + ``gcc-13``); unchanged when the basename is not a known C++ driver.""" + head, base = os.path.split(cxx) + for cxx_name, c_name in _SIBLINGS: + if base == cxx_name or base.startswith(cxx_name + "-"): + return os.path.join(head, c_name + base[len(cxx_name):]) + return cxx + + +def _run(argv: list[str], cwd: str) -> subprocess.CompletedProcess: + """``argv`` in ``cwd`` with captured text output and a timeout; a timeout + raises :class:`HawkError` naming the command.""" + try: + return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, + timeout=_TIMEOUT_S) + except subprocess.TimeoutExpired: + raise HawkError(f"timed out after {_TIMEOUT_S}s.\n$ " + f"{' '.join(argv)}") from None + + +def c_compiler() -> str: + """The C compiler, in the discovery order the module docstring gives.""" + tried: list[str] = [] + cc = os.environ.get("HAWK_CC") + if cc: + found = shutil.which(cc) + if not found: + raise HawkError(f"$HAWK_CC={cc!r} not found on PATH.") + return found + tried.append("$HAWK_CC (unset)") + cxx = os.environ.get("HAWK_CXX") + if cxx: + sibling = _c_sibling(cxx) + found = shutil.which(sibling) if sibling != cxx else None + if found: + return found + tried.append(f"C sibling of $HAWK_CXX={cxx!r} ({sibling!r}, " + "did not resolve)") + else: + tried.append("C sibling of $HAWK_CXX (unset)") + words = os.environ.get("CC", "").split() + if words: + found = shutil.which(words[0]) + if found: + return found + tried.append(f"$CC ({words[0]!r}, did not resolve)") + else: + tried.append("$CC (unset)") + for name in ("cc", "clang", "gcc"): + found = shutil.which(name) + if found: + return found + tried.append(f"{name} on PATH (absent)") + return _zig_cc_wrapper(tried) + + +def _zig_cc_wrapper(tried: list[str]) -> str: + """A stable ``exec cc "$@"`` script for the ``ziglang`` fallback, + written under the compile cache dir (see + :func:`hawk.compile.toolchain._zig_cxx_wrapper`).""" + try: + import ziglang + except ImportError: + raise HawkError( + "no C compiler found. Tried, in order: " + ", ".join(tried) + + ", the ziglang wheel (not installed). Set $HAWK_CC or $CC, " + "install cc/clang/gcc, or `pip install ziglang`. " + "A skipped check certifies nothing.") from None + zig_bin = Path(ziglang.__file__).resolve().parent / "zig" + if not zig_bin.is_file(): + raise HawkError( + f"the ziglang wheel is installed but its zig binary is missing " + f"at {zig_bin}.") + script = f"#!/bin/sh\nexec {shlex.quote(str(zig_bin))} cc \"$@\"\n" + wrapper = default_cache_dir() / "zig-cc-wrapper.sh" + try: + current = wrapper.read_text() + except OSError: + current = None + if current != script: + wrapper.parent.mkdir(parents=True, exist_ok=True) + wrapper.write_text(script) + wrapper.chmod(0o755) + return str(wrapper) + + +def compiler_identity(path: str) -> str: + """``||``; the second part is empty when + the compiler cannot be asked.""" + first = "" + try: + out = subprocess.run([path, "--version"], capture_output=True, + text=True, timeout=60).stdout + lines = out.splitlines() + first = lines[0].strip() if lines else "" + except (OSError, subprocess.SubprocessError): + pass + return f"{path}||{first}" + + +def c_check_flags(std: str) -> list[str]: + """The strict syntax-only flags for ``std`` (``c99`` or ``c11``).""" + if std not in _STDS: + raise HawkError(f"unsupported C standard {std!r}; expected one of " + f"{', '.join(_STDS)}.") + return [f"-std={std}", "-pedantic", "-Wall", "-Wextra", "-Wconversion", + "-Wsign-conversion", "-Wdouble-promotion", "-Werror", + "-fsyntax-only"] + + +def cppcheck() -> str | None: + """``$HAWK_CPPCHECK`` (must resolve), else ``cppcheck`` on ``PATH``, else + ``None``.""" + override = os.environ.get("HAWK_CPPCHECK") + if override: + found = shutil.which(override) + if not found: + raise HawkError(f"$HAWK_CPPCHECK={override!r} not found on PATH.") + return found + return shutil.which("cppcheck") + + +#: The file name :func:`run_checks` writes :data:`STDINT_LIBRARY` to, beside the unit. +STDINT_LIBRARY_NAME = "hawk_stdint.cfg" + +#: constant macros (INT64_C, ...) as glibc defines them. cppcheck +#: does not read system headers here, and older releases (2.13, Ubuntu 24.04) +#: then report each use as an undeclared function (Rule 17.3). Not a suppression. +STDINT_LIBRARY = """ + + + + + + + + + + + + +""" + + +def cppcheck_argv(exe: str, c_path: str, include_dir: str) -> list[str]: + """The cppcheck command line with the MISRA addon for ``c_path``. The + :data:`STDINT_LIBRARY` file is expected in ``include_dir`` (:func:`run_checks` + writes it there).""" + return [exe, "--addon=misra", "--std=c99", "--language=c", + "--enable=warning,style", "--error-exitcode=2", + "--suppress=missingIncludeSystem", + *[f"--suppress={d['rule']}" for d in DEVIATIONS], + f"--library={include_dir}/{STDINT_LIBRARY_NAME}", + f"-I{include_dir}", c_path] + + +def parse_cppcheck(stderr: str) -> list[dict]: + """One ``{file, line, id, text}`` per default-template finding line; + other lines are ignored.""" + findings = [] + for raw in stderr.splitlines(): + m = _FINDING.match(raw) + if m: + findings.append({"file": m["file"], "line": int(m["line"]), + "id": m["id"], "text": m["text"]}) + return findings + + +def run_checks(c_text: str, h_text: str, name: str, profile=None) -> dict: + """Write ``.c``/``.h`` to a temp dir and check them: the C + compiler under :func:`c_check_flags` for c99 then c11, then cppcheck with + the MISRA addon. Any failure or finding raises :class:`HawkError`. + + ``profile`` is duck-typed: only ``.require_cppcheck`` is read, deciding + whether an absent cppcheck raises or is recorded as skipped. + """ + start = time.perf_counter() + cc = c_compiler() + with tempfile.TemporaryDirectory(prefix="hawk-embedded-") as tmp: + c_path = Path(tmp) / f"{name}.c" + c_path.write_text(c_text) + (Path(tmp) / f"{name}.h").write_text(h_text) + (Path(tmp) / STDINT_LIBRARY_NAME).write_text(STDINT_LIBRARY) + cc_runs = [] + for std in _STDS: + argv = [cc, *c_check_flags(std), f"-I{tmp}", str(c_path)] + done = _run(argv, tmp) + if done.returncode != 0: + raise HawkError( + f"C check of {name!r} failed under -std={std} " + f"(rc={done.returncode}).\n$ {' '.join(argv)}\n" + f"{done.stderr[:4000]}") + cc_runs.append({"std": std, "argv": argv, "rc": done.returncode}) + exe = cppcheck() + if exe is None: + if profile is not None and profile.require_cppcheck: + raise HawkError( + "cppcheck not found but the profile requires it " + "(set $HAWK_CPPCHECK or install cppcheck). " + "A skipped check certifies nothing.") + cpp: dict = {"skipped": "cppcheck not found (set $HAWK_CPPCHECK)"} + else: + argv = cppcheck_argv(exe, str(c_path), tmp) + done = _run(argv, tmp) + findings = parse_cppcheck(done.stderr) + if done.returncode != 0 or findings: + listing = "\n".join( + f"{f['file']}:{f['line']} {f['id']} {f['text']}" + for f in findings) + raise HawkError( + f"cppcheck of {name!r} reported {len(findings)} " + f"finding(s) (rc={done.returncode}).\n" + f"$ {' '.join(argv)}\n{listing}\n{done.stderr[:2000]}") + cpp = {"argv": argv, "rc": done.returncode, "findings": findings, + "suppressed": [d["rule"] for d in DEVIATIONS]} + cxx = os.environ.get("HAWK_CXX") + return {"cc": cc_runs, "cc_identity": compiler_identity(cc), + "cxx_identity": compiler_identity(cxx) if cxx else None, + "cppcheck": cpp, "seconds": time.perf_counter() - start} + + +def compile_shared(c_path, out_path, mode=None) -> Path: + """Build ``c_path`` into the shared library ``out_path`` (c99, -O2, no FP + contraction) for the test harness; ``mode`` is reserved for the float32 + flag set.""" + c_path = Path(c_path) + argv = [c_compiler(), "-std=c99", "-O2", "-ffp-contract=off", "-fPIC", + "-shared", str(c_path), "-o", str(out_path), "-lm"] + done = _run(argv, str(c_path.parent)) + if done.returncode != 0: + raise HawkError( + f"shared build of {c_path.name!r} failed (rc={done.returncode})." + f"\n$ {' '.join(argv)}\n{done.stderr[:4000]}") + return Path(out_path) diff --git a/hawk/embedded/verify.py b/hawk/embedded/verify.py new file mode 100644 index 0000000..05a19d6 --- /dev/null +++ b/hawk/embedded/verify.py @@ -0,0 +1,638 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The C target's verify pass: everything checked on the hawk IR before a +line of C is rendered. + +:func:`refuse_unsupported` refuses, by name, every feature the C target does +not build (:data:`UNSUPPORTED`). :func:`ir_checks` never refuses: it computes +the per-sample budgets, classifies every runtime index as ``proven`` or +``clamped``, reports each floating-point domain site, and describes the +interface one row per slot. :func:`apply_caps` is where a profile's opt-in +caps turn that report into a refusal. Every refusal has one shape:: + + '': the C target refuses node () under : ; + +A node position is its index in ``walk.order``, or ``loop.`` for the +``k``-th node of the body of the loop at ``i`` (nested: ``loop..``). +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from math import prod + +from ..emit.spelling import _MATH1, _MATH2, _MATH3, binding_name +from ..ext import DEFAULT_KIND +from ..ir import HawkError +from ..ir.cost import fp64_ops, kernel_cost +from ..ir.loop_nodes import Loop, LoopIndex, TapeRead +from ..ir.nodes import ( + FINISHED_PLANE, + GATHER_ROLES, + RAW_KIND, + AccumWrite, + At, + Const, + Dispatch, + Op, + ReduceSamples, + SampleSum, + Select, + Sink, + TapeWrite, + WideWrite, + is_param_cell, +) +from ..ir.ops import RANDOM_OPS +from ..ir.walk import canonical_nodes +from ..types import INT_PLANE_DTYPES + +_FLOATS = ("f64", "f32") +_INDEX_INTS = ("i32", "i64") +#: Bytes one element of a dtype occupies in the C build (an index Int is 64-bit). +_SIZEOF = {"f64": 8, "f32": 4, "i32": 8, "i64": 8, "int32": 4, "uint32": 4, "bool": 1} +#: The named fields of ``BranchCost.row_tuple()``, the ``ops`` budget's keys. +_OPS = ("arith32", "arith64", "special32", "special64", "loads", "gathers", + "chain32", "chain64", "chainSpecial32", "chainSpecial64", "stores") +_ARITH = ("arith32", "arith64", "special32", "special64") +#: libm kinds the C target spells with an integer helper on an Int operand. +_INT_LIBM_OK = ("abs", "min", "max") +#: Index kinds where the two builds part: the C++ spells them infix (signed +#: overflow, and a division by zero or ``MIN / -1``, is undefined), the C +#: defines them through ``hawk_i64_*``. An Int32/UInt32 operation and every +#: Index ``//``, ``%`` and shift go through the host's own helper too +#: (``hawk/emit/backend.py`` ``_INT_HELPERS``), so they are no site. +_INT_UB = ("add", "sub", "mul", "neg", "div") +_INPUT_ROLES = ("per_sample", "vec_in", "mat_in", "lookup", "wide_in", "uniform", + "nsamples") +_LENGTH_ROLES = ("lookup", "wide_in", "wide_out", "accum_out") + + +def _refusal(name, pos, kind, rule, why, fix) -> HawkError: + return HawkError(f"{name!r}: the C target refuses node {pos} ({kind}) under " + f"{rule}: {why}; {fix}") + + +def _top(sinks) -> tuple: + """THE traversal's top-level node sequence for ``sinks``: the renderer's + own (:func:`hawk.ir.walk.canonical_nodes`), so a position here is the + position the C text names. Nothing here re-enumerates the walk.""" + return canonical_nodes(tuple(sinks))[0] + + +def _every_node(top) -> Iterator[tuple]: + """``(pos, node, enclosing loops)`` for every node of the top-level + sequence ``top`` (:func:`_top`) and, recursively, every loop body (its + nodes, then its per-iteration sinks). Dispatch branches are operands, so + they are already in ``top``.""" + seen: set = set() + + def visit(pos, node, loops): + if id(node) in seen: + return + seen.add(id(node)) + yield pos, node, loops + if isinstance(node, Loop): + prefix = pos if isinstance(pos, str) else f"loop{pos}" + for k, child in enumerate((*node.body_nodes(), *node.body_sinks)): + yield from visit(f"{prefix}.{k}", child, (*loops, node)) + + for i, node in enumerate(top): + yield from visit(i, node, ()) + + +def positions(sinks) -> dict[int, int | str]: + """``id(node) -> position`` for every node the traversal of ``sinks`` + reaches, loop bodies included: the numbering every report site and + refusal uses (an int at the top level, ``loop.`` inside a body).""" + return {id(node): pos for pos, node, _ in _every_node(_top(sinks))} + + +def _first(nodes, test) -> tuple | None: + return next(((pos, node.kind) for pos, node, _ in nodes if test(node)), None) + + +def _int_libm(node) -> bool: + return (isinstance(node, Op) and node.kind not in _INT_LIBM_OK + and (node.kind in _MATH1 or node.kind in _MATH2 or node.kind in _MATH3) + and any(o.ttype.dtype in _INDEX_INTS for o in node.operands)) + + +def _segmented(kernel, nodes): + found = _first(nodes, lambda n: isinstance(n, Dispatch) and n.policy == "segmented") + if found is None and getattr(kernel, "segment", None) is not None: + return "kernel", "segment" + return found + + +def _sample_reduction(walk, nodes): + found = _first(nodes, lambda n: isinstance(n, (SampleSum, ReduceSamples))) + if found is None and walk.access.cls == "mapreduce": + return "kernel", "mapreduce" + return found + + +def _cross_sample_at(nodes): + return _first(nodes, lambda n: isinstance(n, At) + and n.operands[0].role not in GATHER_ROLES) + + +def _whole(flag: bool, what: str): + return ("kernel", what) if flag else None + + +#: feature -> (predicate(kernel, kind, walk, nodes) -> (pos, node kind) | None, +#: (why, fix)). Checked in this order; the first hit refuses. +UNSUPPORTED: dict[str, tuple[Callable, tuple[str, str]]] = { + "random": ( + lambda k, kind, w, nodes: _first( + nodes, lambda n: isinstance(n, Op) and n.kind in RANDOM_OPS), + ("a random draw lowers to aether's counter-based generator", + "no counter-based draw is built for C yet; draw in the caller and pass " + "the values in as a plane")), + "raw_device": ( + lambda k, kind, w, nodes: _first(nodes, lambda n: n.kind == RAW_KIND), + ("a raw_device block splices C++ text the C target cannot check", + "spell the block with hawk.math")), + "sample_reduction": ( + lambda k, kind, w, nodes: _sample_reduction(w, nodes), + ("a reduction over samples needs the wrapper's cross-sample fold, which a " + "standalone build does not have", + "commit the own column and reduce in the caller")), + "cross_sample_at": ( + lambda k, kind, w, nodes: _cross_sample_at(nodes), + ("an at() read of a per-sample plane reads another sample's value, which a " + "partition call may not hold", + "pass the values the kernel needs as a Table plane")), + "active_set": ( + lambda k, kind, w, nodes: _whole( + bool(getattr(getattr(kind, "guard", None), "active_set", False)), "guard"), + ("an active-set guard reads an index map the C entry does not take", + "run without the index map (the plain mask guard)")), + "segmented": ( + lambda k, kind, w, nodes: _segmented(k, nodes), + ("a segmented dispatch is a per-unit split the C target does not run", + "export each branch as its own kernel")), + "compensated": ( + lambda k, kind, w, nodes: _whole( + getattr(getattr(kind, "sink", None), "id", None) == "compensated", + "sink policy"), + ("the compensated sink policy commits a correction plane the C target " + "does not lower", "use the plain sink policy")), + "fused_lanes": ( + lambda k, kind, w, nodes: _whole( + getattr(k, "fused_body", None) is not None + or getattr(k, "lanes", None) is not None, "fused lanes"), + ("a fused lane group is composed for a launching runtime", + "export each lane as its own kernel")), + "two_path": ( + lambda k, kind, w, nodes: _whole( + getattr(k, "one_step", None) is not None, "automatic kernel"), + ("an automatic kernel branches once per launch on a runtime step word", + "export `kernel.step` (the single step) or `hawk.steps(step, K)` with " + "an integer K")), + "int_libm": ( + lambda k, kind, w, nodes: _first(nodes, _int_libm), + ("a libm function on an Int operand takes an implicit double round-trip " + "a safety build must not rely on", + "use maximum(minimum(i, hi), lo) (Int min/max are allowed) or convert " + "to Real explicitly")), + "scattered_tensor_accum": ( + lambda k, kind, w, nodes: _first( + nodes, lambda n: isinstance(n, AccumWrite) and n.index is not None + and len(n.ttype.shape) >= 1), + ("a scattered accumulate of a rank>=1 value is not lowered", + "declare one scalar accumulate plane per component")), + "zero_trip_loop": ( + lambda k, kind, w, nodes: _first( + nodes, lambda n: isinstance(n, Loop) and n.trip == 0), + ("a loop that never runs is dead code (MISRA C:2012 Rule 14.3)", + "give the loop at least one trip")), + "tensor_select_cond": ( + lambda k, kind, w, nodes: _first( + nodes, lambda n: isinstance(n, Select) + and len(n.operands[0].ttype.shape) >= 1), + ("a select on a rank>=1 condition picks per component, which is not " + "lowered", "select per component with a rank-0 condition")), +} + + +def refuse_unsupported(name: str, kernel, kind, walk, sinks) -> None: + """Refuse the first :data:`UNSUPPORTED` feature ``kernel`` uses, by name.""" + if kind is None: + kind = getattr(kernel, "kind", None) + nodes = list(_every_node(_top(sinks))) + for feature, (predicate, (why, fix)) in UNSUPPORTED.items(): + hit = predicate(kernel, kind, walk, nodes) + if hit is not None: + pos, what = hit + raise _refusal(name, pos, what, f"unsupported.{feature}", why, fix) + + +# -------------------------------------------------------------------------- +# a. bounds and budgets + + +def _trips(loop: Loop) -> int: + inner = sum(_trips(n) for n in loop.body_nodes() if isinstance(n, Loop)) + return loop.trip * (1 + inner) + + +def _max_trips(top) -> tuple[int, list]: + """Worst-case trips per sample: top-level loops summed (dispatch branches + included, since their loops are top-level), nested loops multiplied; a + runtime-count loop contributes its static trip, which the C clamps to.""" + loops = [{"node": f"loop{i}", "trips": _trips(n)} + for i, n in enumerate(top) if isinstance(n, Loop)] + return sum(entry["trips"] for entry in loops), loops + + +def _bytes(ttype) -> int: + return prod(ttype.shape) * _SIZEOF[ttype.dtype] + + +def _static_bytes(nodes) -> dict: + """Tapes (``trip`` x carry, per slot some ``TapeRead`` names) and carries + (every loop's, nested included), mirroring the renderer's tape scan.""" + taped: dict = {} + carries = 0 + for _, node, _ in nodes: + if isinstance(node, TapeRead): + taped[(id(node.loop), node.slot)] = ( + node.loop.trip * _bytes(node.loop.carries[node.slot].ttype)) + if isinstance(node, Loop): + carries += sum(_bytes(c.ttype) for c in node.carries) + tapes = sum(taped.values()) + return {"tapes": tapes, "carries": carries, "temporaries": None, + "estimate": tapes + carries, "total": None} + + +# -------------------------------------------------------------------------- +# b. index sites + + +def _loop_of(index, loops) -> Loop | None: + for loop in reversed(loops): + if index is loop.index: + return loop + for loop in reversed(loops): + if (loop.index.depth, loop.index.name) == (index.depth, index.name): + return loop + return None + + +def _span(loop: Loop) -> tuple[int, int]: + last = loop.start + (loop.trip - 1) * loop.step + return min(loop.start, last), max(loop.start, last) + + +def _in_range(index, extent: int, loops) -> tuple[bool, str]: + if isinstance(index, Const) and isinstance(index.literal, int): + return (0 <= index.literal < extent, + f"literal {index.literal} against extent {extent}") + loop = _loop_of(index, loops) if isinstance(index, LoopIndex) else None + if loop is None or loop.trip == 0: + return False, f"a runtime index against extent {extent}" + lo, hi = _span(loop) + return (0 <= lo and hi < extent, + f"loop index over [{lo}, {hi}] against extent {extent}") + + +def _tape_ordinal(node: TapeRead, loops) -> tuple[bool, str]: + fwd = node.loop + loop = _loop_of(node.index, loops) if isinstance(node.index, LoopIndex) else None + if loop is None or loop.trip == 0: + return False, "the tape ordinal is not a loop index" + if loop.step % fwd.step or (loop.start - fwd.start) % fwd.step: + return False, "the reading loop's range is not the taped loop's" + lo, hi = ((v - fwd.start) // fwd.step for v in _span(loop)) + lo, hi = min(lo, hi), max(lo, hi) + return (0 <= lo and hi < fwd.trip, + f"ordinals [{lo}, {hi}] of a {fwd.trip}-row tape") + + +def _tape_row(sink: TapeWrite) -> tuple[bool, str]: + row = sink.row + if (isinstance(row, Op) and row.kind == "mod" and isinstance(row.operands[1], Const) + and isinstance(row.operands[1].literal, int)): + window = row.operands[1].literal + return (window > 0 and window * sink.width <= sink.ttype.shape[0], + f"row mod {window} of {sink.ttype.shape[0] // sink.width} rows") + return False, "the row is not a floor modulo of a literal window" + + +def _index_site(node, loops) -> dict | None: + if isinstance(node, At): + if is_param_cell(node): + return {"kind": "param_cell", "status": "proven", + "reason": "a parameter cell is read at index 0", "length": None} + plane = node.plane + return {"kind": "table_read", "status": "clamped", + "reason": "a runtime index into a runtime-length plane", + "length": binding_name(plane.role, plane.name) + "_len"} + if isinstance(node, Op) and node.kind in ("component_at", "set_component_at"): + extent = node.operands[0].ttype.shape[0] + ok, why = _in_range(node.operands[1], extent, loops) + return {"kind": node.kind, "status": "proven" if ok else "clamped", + "reason": why, "length": extent} + if isinstance(node, TapeRead): + (ok, why), length = _tape_ordinal(node, loops), node.loop.trip + elif isinstance(node, TapeWrite): + (ok, why), length = _tape_row(node), node.ttype.shape[0] // node.width + elif isinstance(node, (WideWrite, AccumWrite)) and node.index is not None: + return {"kind": "scatter", "status": "clamped", + "reason": "a scattered sink's runtime row", + "length": binding_name(node.role, node.name) + "_len"} + elif isinstance(node, Dispatch): + return {"kind": "dispatch", "status": "clamped", + "reason": f"the selector is clamped to [0, {node.K})", + "length": node.K} + else: + return None + return {"kind": node.kind, "status": "proven" if ok else "clamped", + "reason": why, "length": length} + + +def _index_sites(nodes) -> list: + sites = [] + for pos, node, loops in nodes: + site = _index_site(node, loops) + if site is not None: + sites.append({"node": pos, **site}) + return sites + + +def index_status(report: dict, pos) -> str: + """The ``proven``/``clamped`` status of the index site at ``pos``.""" + for site in report["indices"]: + if site["node"] == pos: + return site["status"] + raise HawkError(f"node {pos} is not an index site of this report") + + +def site_status(report: dict, sinks, node) -> str: + """The ``proven``/``clamped`` status of the index site ``node`` (a node + reached from ``sinks``), looked up through :func:`positions`.""" + pos = positions(sinks).get(id(node)) + if pos is None: + raise HawkError(f"{node!r} is not a node of this walk") + try: + return index_status(report, pos) + except HawkError: + raise HawkError(f"node {pos} ({node.kind}) is not an index site of this " + "report") from None + + +# -------------------------------------------------------------------------- +# c. floating-point domain + +_UNKNOWN, _NONNEG, _POSITIVE = "unknown", "nonneg", "positive" +_RANK = {_UNKNOWN: 0, _NONNEG: 1, _POSITIVE: 2} +_LOWER = {v: k for k, v in _RANK.items()} +#: Kinds whose sign is read off their operands' signs. +_FROM_OPERANDS = ("add", "mul", "div", "max", "min", "clip", "splat", "sqrt", + "select", "primitive") +#: Domain sites: kind -> (the operand index checked, the sign it needs). +_SITES = {"div": (1, _POSITIVE), "sqrt": (0, _NONNEG), "rsqrt": (0, _POSITIVE), + "log": (0, _POSITIVE), "log2": (0, _POSITIVE), "log10": (0, _POSITIVE), + "log1p": (0, _POSITIVE), "pow": (0, _POSITIVE), "acosh": (0, None), + "acos": (0, "unit"), "asin": (0, "unit"), "atanh": (0, "unit")} + + +def _own_sign(node, signs: list) -> str: + """The sign ``node`` is proven to have, for non-NaN operands: a NaN + propagates identically on both sides (``clip`` passes it through), so a + claim says what holds wherever the value is a number.""" + kind = node.kind + ops = node.operands + if isinstance(node, Const): + literal = node.literal + if isinstance(literal, bool) or not isinstance(literal, (int, float)): + return _UNKNOWN + return _POSITIVE if literal > 0 else _NONNEG if literal == 0 else _UNKNOWN + least = _LOWER[min(_RANK[s] for s in signs)] if signs else _UNKNOWN + if kind in ("exp", "exp2"): + return _NONNEG # IEEE underflow: exp(-1000) is +0 + if kind in ("norm", "abs") or (kind == "dot" and ops[0] is ops[1]): + return _NONNEG + if kind == "sqrt": + return signs[0] # sqrt of a negative is NaN: unknown stays unknown + if kind == "mul" and ops[0] is ops[1]: + return _POSITIVE if signs[0] == _POSITIVE else _NONNEG + if kind in ("mul", "min", "splat", "primitive"): + return least if kind != "primitive" else signs[0] + if kind == "add": + if least == _UNKNOWN: + return _UNKNOWN + return _POSITIVE if _POSITIVE in signs else _NONNEG + if kind == "div": + return signs[0] if signs[1] == _POSITIVE else _UNKNOWN + if kind == "max": + return _LOWER[max(_RANK[s] for s in signs)] + if kind == "clip": + return signs[1] + if kind == "select": + return _LOWER[min(_RANK[signs[1]], _RANK[signs[2]])] + return _UNKNOWN + + +def _sign(node, memo: dict) -> str: + """``positive``/``nonneg``/``unknown`` of ``node``, bottom-up over its + operand subgraph, memoised by identity; iterative (deep chains).""" + stack = [(node, False)] + while stack: + current, expanded = stack.pop() + if id(current) in memo: + continue + kids = current.operands if current.kind in _FROM_OPERANDS else () + if not expanded: + stack.append((current, True)) + stack.extend((k, False) for k in kids if id(k) not in memo) + continue + memo[id(current)] = _own_sign(current, [memo[id(k)] for k in kids]) + return memo[id(node)] + + +def _unit_clip(node) -> bool: + if not (isinstance(node, Op) and node.kind == "clip"): + return False + lo, hi = node.operands[1], node.operands[2] + return (isinstance(lo, Const) and isinstance(hi, Const) + and -1 <= lo.literal and hi.literal <= 1) + + +def _fp_site(node, memo: dict) -> dict | None: + if not (isinstance(node, Op) and node.kind in _SITES + and node.ttype.dtype in _FLOATS): + return None + which, need = _SITES[node.kind] + operand = node.operands[which] + if need == "unit": + safe = _unit_clip(operand) + return {"op": node.kind, "status": "safe" if safe else "unknown", + "reason": ("the operand is clipped to [-1, 1]" if safe else + f"{node.kind} needs an operand clipped to literal [-1, 1]")} + if need is None: + return {"op": node.kind, "status": "unknown", + "reason": f"no safe form of the {node.kind} operand is recognised"} + sign = _sign(operand, memo) + if _RANK[sign] >= _RANK[need]: + return {"op": node.kind, "status": "safe", "reason": f"the operand is {sign}"} + return {"op": node.kind, "status": "unknown", + "reason": f"{node.kind} needs a {need} operand; its sign is {sign}"} + + +def _fp_domain(nodes) -> list: + memo: dict = {} + out = [] + for pos, node, _ in nodes: + site = _fp_site(node, memo) + if site is not None: + out.append({"node": pos, **site}) + return out + + +# -------------------------------------------------------------------------- +# d. interface contract + + +def _guard_masks(walk, kind) -> list[str]: + """The fields of the masks that guard every store, exactly as the renderer + computes them (:meth:`hawk.emit.aether._Renderer._guard_slot`): each mask + the kind's guard names counts only if bound in the ``terminated`` role. A + finishing kernel binds its mask there too, so it is guarded the same way.""" + kind = DEFAULT_KIND if kind is None else kind + return [binding_name("terminated", m) for m in kind.guard.names + if ("terminated", m) in walk.slot_of] + + +def _interface(walk, nodes, kind) -> list: + scattered = {(n.role, n.name) for _, n, _ in nodes + if isinstance(n, Sink) and n.index is not None} + counters = {FINISHED_PLANE} | {f"{d.name}_mix" for d in walk.dispatches + if d.policy == "auto"} + single = set(walk.param_planes) | counters + mask = walk.finish[0] if walk.finish else None + # a masked sample never reaches its own-column store and keeps the prior + # value (the host's design), so a guarded own column is caller-initialised + bound = _guard_masks(walk, kind) + guard = f"guard: {', '.join(bound)}" + rows = [] + for role, name in walk.arg_spec: + ttype = walk.slot_types[(role, name)] + layout = "scalar" if ttype.shape == () else "soa" + if role in ("uniform", "nsamples"): + layout = "value" + if role == "lookup" and name in counters: + direction, init, reason = "inout", True, "counter (the caller zeroes it)" + elif role in _INPUT_ROLES: + direction, init, reason = "in", True, "input" + elif role == "mutable" and name in walk.prior_reads: + direction, init, reason = "inout", True, "prior read" + elif role == "out": + direction, init = "inout", True + reason = "one row written per launch, rest kept" + elif role == "terminated": + direction, init = ("inout" if name == mask else "in"), True + reason = "mask (the finish sets it)" if name == mask else "mask" + elif (role, name) in scattered: + direction, init = "inout", True + reason = ("+= into prior (the caller zeroes it)" if role == "accum_out" + else "scattered rows written, rest kept") + elif role == "accum_out" and bound: + direction, init, layout = "inout", True, "partials" + reason = ("one partial per sample, length n_samples; masked samples " + f"keep theirs ({guard}): the caller zeroes it, then reduces") + elif role == "accum_out": + direction, init, layout = "out", False, "partials" + reason = "one partial per sample, length n_samples; the caller reduces it" + elif bound: + direction, init = "inout", True + reason = f"masked samples keep the prior value ({guard})" + else: + direction, init, reason = "out", False, "written every sample" + rows.append({ + "field": binding_name(role, name), "role": role, "name": name, + "direction": direction, "dtype": ttype.dtype, "shape": list(ttype.shape), + "layout": layout, + "runtime_length": role in _LENGTH_ROLES and name not in single, + "init_by_caller": init, "guard": list(bound), "reason": reason}) + return rows + + +# -------------------------------------------------------------------------- +# the report and the caps + + +def ir_checks(name: str, sinks, walk, kind, profile) -> dict: + """The verify report of one kernel (never refuses; see :func:`apply_caps`).""" + top = _top(sinks) + nodes = list(_every_node(top)) + max_trips, loops = _max_trips(top) + cost = kernel_cost(walk) + ints = _INDEX_INTS + INT_PLANE_DTYPES + return { + "interface": _interface(walk, nodes, kind), + "budgets": { + "max_trips": max_trips, + "loops": loops, + "ops": {field: getattr(cost, field) for field in _OPS}, + # kernel_cost's loop arm already counts each body x trip. + "fp64_ops": fp64_ops(top), + "static_bytes": _static_bytes(nodes), + }, + "indices": _index_sites(nodes), + "fp_domain": _fp_domain(nodes), + "fp_equality_sites": [ + pos for pos, n, _ in nodes if isinstance(n, Op) and n.kind in ("eq", "ne") + and any(o.ttype.dtype in _FLOATS for o in n.operands)], + "int_division_sites": [ + pos for pos, n, _ in nodes if isinstance(n, Op) + and n.kind in ("div", "floordiv", "mod") and n.ttype.dtype in ints], + "int_ub_sites": [ + pos for pos, n, _ in nodes if isinstance(n, Op) + and n.kind in _INT_UB and n.ttype.dtype in _INDEX_INTS], + "unsupported_checked": sorted(UNSUPPORTED), + } + + +def apply_caps(name: str, report: dict, profile) -> None: + """Refuse the first cap ``profile`` sets that ``report`` exceeds.""" + budgets = report["budgets"] + if profile.max_trips is not None and budgets["max_trips"] > profile.max_trips: + worst = max(budgets["loops"], key=lambda entry: entry["trips"], + default={"node": "kernel", "trips": budgets["max_trips"]}) + raise _refusal( + name, worst["node"], "loop", "budget.trips", + f"{budgets['max_trips']} worst-case loop trips per sample exceed " + f"max_trips={profile.max_trips} ({worst['node']} runs {worst['trips']})", + "shorten the range or raise the cap") + ops = sum(budgets["ops"][field] for field in _ARITH) + if profile.max_ops is not None and ops > profile.max_ops: + raise _refusal(name, "kernel", "ops", "budget.ops", + f"{ops:g} arith + special ops per sample exceed " + f"max_ops={profile.max_ops}", + "split the kernel or raise the cap") + static = budgets["static_bytes"] + used = static["total"] if static["total"] is not None else static["estimate"] + if profile.max_stack_bytes is not None and used > profile.max_stack_bytes: + raise _refusal(name, "kernel", "stack", "budget.stack", + f"{used} static bytes (tapes {static['tapes']}, carries " + f"{static['carries']}) exceed max_stack_bytes=" + f"{profile.max_stack_bytes}", + "shorten the taped loop or raise the cap") + if profile.index_policy == "refuse": + for site in report["indices"]: + if site["status"] == "clamped": + raise _refusal(name, site["node"], site["kind"], "index.unproven", + f"the index is not provably in range ({site['reason']})", + "index with a static loop index or a literal, or " + "export with index_policy='clamp'") + if profile.fp_domain == "refuse": + for site in report["fp_domain"]: + if site["status"] == "unknown": + raise _refusal(name, site["node"], site["op"], "fp.domain", + site["reason"], + "bound the operand into the domain (abs, a positive " + "offset, clip) or export with fp_domain='record'") diff --git a/hawk/emit/__init__.py b/hawk/emit/__init__.py index d373d32..5bf7d20 100644 --- a/hawk/emit/__init__.py +++ b/hawk/emit/__init__.py @@ -1,12 +1,14 @@ # Copyright 2026 Alessandro Masat # SPDX-License-Identifier: Apache-2.0 -"""HAWK's emitters: ONE aether renderer, two built backends. +"""HAWK's emitters: ONE renderer, one built dialect, two built backends. An INTERNAL package: its ``__all__`` is the small "IR access" surface a downstream package may read; every other name is implementation detail. Importing it is not free, and :mod:`hawk` itself never does. -:mod:`hawk.emit.aether` produces the body STRING; :mod:`hawk.emit.backend` +:mod:`hawk.emit.aether` produces the body STRING through the spelling seam +(:mod:`hawk.emit.dialect`; :mod:`hawk.emit.dialect_aether` is the aether C++ +dialect every built backend wraps); :mod:`hawk.emit.backend` carries the two seams and the shared translation-unit assembly; :mod:`hawk.emit.cuda`/:mod:`hawk.emit.host` are the two built backends and :mod:`hawk.emit.fused` the fused-lane emitter's body path. @@ -18,8 +20,8 @@ from .backend import FAST_ENTRIES_MACRO as FAST_ENTRIES_MACRO from .backend import FLOAT32 as FLOAT32 from .backend import FLOAT64 as FLOAT64 -from .backend import SCALAR_MODES as SCALAR_MODES from .backend import RANGE_SUFFIX as RANGE_SUFFIX +from .backend import SCALAR_MODES as SCALAR_MODES from .backend import Backend as Backend from .backend import ScalarMode as ScalarMode from .backend import SegmentSpec as SegmentSpec @@ -27,6 +29,8 @@ from .backend import literal_table, render_source, scalar_mode from .cuda import CUDA as CUDA from .cuda import CudaBackend as CudaBackend +from .dialect import Dialect as Dialect +from .dialect_aether import AETHER as AETHER from .fused import FusedKernel as FusedKernel from .fused import Lane as Lane from .fused import compose diff --git a/hawk/emit/aether.py b/hawk/emit/aether.py index 877495c..f6fcd65 100644 --- a/hawk/emit/aether.py +++ b/hawk/emit/aether.py @@ -31,7 +31,6 @@ from ..ir.loop_nodes import Loop, LoopCarry, LoopCount, LoopIndex, LoopValue, TapeRead from ..ir.nodes import ( GATHER_ROLES, - FINISHED_PLANE, RAW_KIND, SWITCH_RENDERED, AccumWrite, @@ -54,27 +53,10 @@ ) from ..ir.ops import RANDOM_OPS from ..ir.walk import canonical_nodes -from ..types import INT_PLANE_DTYPES, TensorType +from ..types import TensorType +from .dialect_aether import AETHER from .dispatch import _DispatchMixin from .dispatch import render_dispatch_rank0 as render_dispatch_rank0 -from .spelling import ( - _CWISE, - _CWISE1, - _INFIX, - _MATH1, - _MATH2, - _MATH3, - _METHOD0, - _METHOD1, - _RANDOM_FN, - _for_header, - _ident, - _no_spelling, - _ordinal, - _real_literal, - _through, - param_cell_binding, -) from .spelling import ( GEOMETRY_TOKENS as GEOMETRY_TOKENS, ) @@ -87,6 +69,12 @@ from .spelling import ( SAMPLE_INDEX_IDENT as SAMPLE_INDEX_IDENT, ) +from .spelling import ( + _for_header, + _ident, + _ordinal, + _through, +) from .spelling import ( binding_name as binding_name, ) @@ -105,6 +93,9 @@ from .spelling import ( mirror_of as mirror_of, ) +from .spelling import ( + param_cell_binding as param_cell_binding, +) from .spelling import ( param_name as param_name, ) @@ -131,13 +122,15 @@ class Body: def render_body(sinks: Sequence[Sink], walk: Walk, *, indent: str = " ", - kind=None) -> Body: + kind=None, dialect=None) -> Body: """Render one kernel's body STRING from its sink set and its walk. The traversal is the walk's own; every slot name comes from :attr:`~hawk.ir.walk.Walk.arg_spec`. ``kind`` is the - :class:`hawk.ext.Kind` the commits render under; omitted, the default applies.""" - return _Renderer(tuple(sinks), walk, indent, kind).run() + :class:`hawk.ext.Kind` the commits render under; omitted, the default applies. + ``dialect`` is the :class:`hawk.emit.dialect.Dialect` that spells the text; + omitted, the aether C++ dialect.""" + return _Renderer(tuple(sinks), walk, indent, kind, dialect=dialect).run() @dataclass(frozen=True) @@ -305,13 +298,17 @@ def _declarations(pre: str, names: Sequence[str]) -> tuple: class _Renderer(_DispatchMixin): """One rendering pass. Held as a class only to carry the memo tables.""" - def __init__(self, sinks: tuple, walk: Walk, indent: str, kind=None) -> None: + def __init__(self, sinks: tuple, walk: Walk, indent: str, kind=None, *, + dialect=None) -> None: from ..ext import DEFAULT_KIND self.sinks = sinks self.walk = walk self.indent = indent self.kind = kind if kind is not None else DEFAULT_KIND + #: The spelling seam (:mod:`hawk.emit.dialect`): every target text + #: this traversal emits comes from here. + self.dialect = dialect if dialect is not None else AETHER self.nodes, self.position = canonical_nodes(sinks) self.rendered: dict[int, str] = {} self.lines: list[str] = [] @@ -559,13 +556,13 @@ def run(self) -> Body: self.guarded = True self._plan_commits() for target, at in self._zero: - self._emit(f"{target}({at}, i.global()) = {self._cast('0', 'f64')};") + self._emit(self.dialect.component_commit( + self, target, at, "=", self.dialect.typed_zero(_F64_SCALAR))) for target, width in self._accumulated_planes(): # a plane a lowered loop accumulates at runtime indices once per # iteration (`bar[i].at(k) += e`, constraint 7): zeroed whole, once, # before the walk; the loop's links are the only other writes - self._emit(f"for (Int hk = 0; hk < {width}; ++hk) " - f"{target}[i].at(hk) = {self._cast('0', 'f64')};") + self._emit(self.dialect.zero_row(self, target, width)) for node in self.nodes: if isinstance(node, Sink): continue @@ -619,6 +616,8 @@ def _plan_commits(self) -> None: else keeps today's single store at the end.""" if self.split_loop is not None or self.cut_loops: return + if not self.dialect.commits_per_component: + return read = {(n.role, n.name) for n in self.nodes if isinstance(n, Leaf)} ready = self._ready_times() compensated = self.kind.sink.id == "compensated" @@ -748,9 +747,11 @@ def _flush(self, pos: int) -> None: for n in sorted(need, key=lambda n: self.position[id(n)]): self._emit_outside(n) if op == "at+=": - self._emit(f"{target}[i].at({self._ref(at)}) += {self._ref(part)};") + self._emit(self.dialect.in_place_accumulate( + self, target, self._ref(at), self._ref(part))) else: - self._emit(f"{target}({at}, i.global()) {op} {self._ref(part)};") + self._emit(self.dialect.component_commit( + self, target, at, op, self._ref(part))) def _emit_outside(self, node: Node) -> None: """:meth:`_render_outside`, then the commits that were waiting on it.""" @@ -794,7 +795,7 @@ def _bind(self, node: Node, pos: int, text: str) -> None: """Record ``text`` as ``node``'s rendering, named when :meth:`_materialised`.""" if self._materialised(node, pos): name = self._name(node, pos) - self._emit(f"const auto {name} = {text};") + self._emit(self.dialect.declare(name, node.ttype, text)) self.rendered[pos] = name else: self.rendered[pos] = text @@ -828,15 +829,8 @@ def _emit_group(self, group: tuple) -> None: def _finish(self, sink: Finish) -> None: """The finish epilogue — the last text of the guarded scope, so the step's - own commits land before the sample is marked: an unfinished sample whose - condition holds sets its mask (via :func:`finish_binding`) and adds one to - the reserved counter (``hawk_abi::finish_count``).""" - mask = binding_name("terminated", sink.name) - cond = self._ref(sink.value) - counter = binding_name("lookup", FINISHED_PLANE) - self._emit(f"if (!{mask}[i].eval() && {cond}) {{ " - f"{finish_binding(sink.name)}[i] = true; " - f"hawk_abi::finish_count({counter}); }}") + own commits land before the sample is marked (the dialect spells it).""" + self.dialect.finish(self, sink) def _guard_slot(self) -> str | None: """The guard's CONDITION against the walk's own slots: each mask the @@ -845,11 +839,7 @@ def _guard_slot(self) -> str | None: never fires, nor does a guard none of whose masks the kernel binds.""" bound = [binding_name("terminated", m) for m in self.kind.guard.names if ("terminated", m) in self.walk.slot_of] - if not bound: - return None - if len(bound) == 1: - return f"{bound[0]}[i].eval()" - return "(" + " || ".join(f"{b}[i].eval()" for b in bound) + ")" + return self.dialect.guard(bound) def _emit(self, line: str) -> None: self.lines.append(self.indent + line) @@ -875,12 +865,19 @@ def _materialised(self, node: Node, pos: int) -> bool: trade away.""" if isinstance(node, Leaf) and node.role in ("uniform", "nsamples"): return False + if isinstance(node, Leaf) and node.role in GATHER_ROLES: + # read BY NAME at a computed index (`_at`), never through a + # reference to the leaf: naming it would emit a dead (and possibly + # out-of-bounds) `plane[i].eval()` at the sample index. + return False if is_param_cell(node): return False # a bare identifier, loaded once before the loop if isinstance(node, (SampleIndex, LoopIndex, LoopCarry)): return False # already a bare identifier the enclosing scope bound if isinstance(node, (Loop, Const, Primitive)): return False # a statement / a literal / a boundary: none is named + if self.dialect.names_every_tensor and node.ttype.shape: + return True # a C struct value: always its own statement if pos in self.forced: return True return self.fanout.get(pos, 0) >= 2 or self._leaf_valued(node) @@ -902,14 +899,10 @@ def _ref(self, child: Node) -> str: return held return self.rendered[self.position[id(child)]] - def _hoist(self, text: str) -> str: - """Bind ``text`` to a fresh named local ahead of the current line — the - lifetime-safe home for a value that is an aether LEAF but not a node of - its own (a broadcast ``Constant``, a raw block's operand).""" - name = f"hz{self.hoists}" - self.hoists += 1 - self._emit(f"const auto {name} = {text};") - return name + def _hoist(self, text: str, ttype=None) -> str: + """Bind ``text`` to a fresh named local ahead of the current line (the + dialect's :meth:`~hawk.emit.dialect.Dialect.hoist`).""" + return self.dialect.hoist(self, text, ttype) # -- lowered loops ----------------------------------------------- @@ -930,7 +923,7 @@ def _loop(self, loop: Loop) -> None: feed one carry's new value into the next carry's expression — a different, silently wrong recurrence.""" pos = self.loop_pos[id(loop)] - elems = [carry_type(c.ttype) for c in loop.carries] + elems = [self.dialect.carry_type(c.ttype) for c in loop.carries] names = tuple(f"hawk_v{pos}_{j}_{_ident(c.name)}" for j, c in enumerate(loop.carries)) self.carry_names[pos] = names @@ -938,13 +931,13 @@ def _loop(self, loop: Loop) -> None: self._emit(f"{elem} {name} = {self._ref(init)};") tapes = self.tape_names.get(pos, {}) for slot in sorted(tapes): - self._emit(f"{elems[slot]} {tapes[slot]}[{loop.trip}];") + self._emit(self.dialect.tape_decl(elems[slot], tapes[slot], loop.trip)) counter = self.count_names.get(pos) if counter is not None: - self._emit(f"Int {counter} = 0;") + self._emit(self.dialect.counter_decl(counter)) index = f"hawk_k{pos}_{_ident(loop.index.name)}" count = None if loop.count is None else self._ref(loop.count) - self._emit(_for_header(index, loop, count)) + self._emit(self.dialect.for_header(self, index, loop, count)) outer, self.indent = self.indent, self.indent + " " saved, saved_items = self.local, self.items self.local, self.items = dict(saved), dict(saved_items) @@ -958,7 +951,7 @@ def _loop(self, loop: Loop) -> None: if form in ("head", "middle"): pre = self._exit_part(loop) self._render_scope(loop, only=pre, sinks=False) - self._emit(f"if ({self._ref(loop.exit_cond)}) {{") + self._emit(self.dialect.exit_if(self._ref(loop.exit_cond))) self.indent += " " moved = [(j, v) for j, v in enumerate(loop.exit_values) if v is not loop.carries[j]] @@ -988,7 +981,7 @@ def _loop(self, loop: Loop) -> None: if counter is not None: self._emit(f"++{counter};") if form == "tail": - self._emit(f"if (hawk_x{pos}) break;") + self._emit(self.dialect.tail_break(f"hawk_x{pos}")) self.local, self.items = saved, saved_items self._scope_depth -= 1 self.indent = outer @@ -1257,7 +1250,7 @@ def _render_scope(self, loop: Loop, *, only: frozenset | None = None, if named: name = f"tb{self.temps}" self.temps += 1 - self._emit(f"const auto {name} = {text};") + self._emit(self.dialect.declare(name, node.ttype, text)) self.local[id(node)] = name else: self.local[id(node)] = text @@ -1315,7 +1308,7 @@ def _sink(self, sink: Sink) -> None: return target = binding_name(sink.role, sink.name) if isinstance(sink, TapeWrite): - self._tape_write(sink, target) + self.dialect.tape_write(self, sink, target) return if isinstance(sink, SampleSum): self._sample_sum(sink) @@ -1325,7 +1318,8 @@ def _sink(self, sink: Sink) -> None: # a per-iteration runtime accumulate (constraint 7): each link on # the own column where it stands, nothing for the zero base for k, e in links: - self._emit(f"{target}[i].at({self._ref(k)}) += {self._ref(e)};") + self._emit(self.dialect.in_place_accumulate( + self, target, self._ref(k), self._ref(e))) return value = self._ref(sink.value) if not isinstance(sink, (Assign, WideWrite, AccumWrite)): @@ -1336,54 +1330,25 @@ def _sink(self, sink: Sink) -> None: # compensated kind's own-column target, which accumulates into the # host's prior value instead. if self._is_compensated_own_column(sink): - # `i` is already the body's bound `aether::SampleIndex`, so - # unlike a computed scatter index it needs no `_sample_index` wrap. comp = binding_name("mutable", self.kind.sink.compensation) - self._emit( - f"hawk_abi::store_compensated({target}, {comp}, i, {value});") + self.dialect.store_compensated(self, sink, target, comp, value) return - self._emit(f"{target}[i] = {value};") + self.dialect.store(self, sink, target, value) return - index = self._sample_index(self._ref(sink.index)) + index = self._ref(sink.index) if not isinstance(sink, AccumWrite): # A scattered wide_write targets a computed ROW: a store, exactly as # the reference evaluator reads it. - self._emit(f"{target}[{index}] = {value};") + self.dialect.scatter_store(self, sink, target, index, value) return self._commit_scattered(sink, target, index, value) - def _tape_write(self, sink: TapeWrite, target: str) -> None: - """One tape ROW on the own column, under ``if (on)``: component - ``row*w + c`` of the sample's ``(W*w,)`` plane gets the ``c``-th - flattened component of the stored values, in order. Rendered where the - sink stands — a twin puts it FIRST, so it reads the pre-step state - before any commit overwrites it.""" - row = self._ref(sink.row) - lines = [] - offset = 0 - for value in sink.values: - text = self._ref(value) - shape = value.ttype.shape - comps = ([((), text)] if not shape - else [(idx, f"{text}.eval<{', '.join(map(str, idx))}>(i)") - for idx in _flat_indices(shape)]) - for _idx, comp in comps: - lines.append(f"{target}(static_cast(hawk_tape_row * " - f"{sink.width} + {offset}), i.global()) = {comp};") - offset += 1 - self._emit(f"if ({self._ref(sink.on)} != 0) {{") - self.indent += " " - self._emit(f"const std::int64_t hawk_tape_row = {row};") - for line in lines: - self._emit(line) - self.indent = self.indent[:-4] - self._emit("}") def _sample_sum(self, sink: SampleSum) -> None: """The sample's term into its column of the row the wrapper sums (:data:`SAMPLE_SUM_TERMS`).""" column = sample_sums(self.sinks).index(sink) - self._emit(f"{SAMPLE_SUM_TERMS}[{column}] = {self._ref(sink.value)};") + self.dialect.sample_sum(self, sink, column) def _commit_scattered(self, sink: Sink, target: str, index: str, value: str) -> None: @@ -1398,7 +1363,8 @@ def _commit_scattered(self, sink: Sink, target: str, index: str, The compensated policy applies only to the kind's resolved OUTPUT TARGET (:func:`hawk.ext.compensated_target`): a scattered by-key store beside, but not itself, that target commits ``plain`` — applying the policy to - every scattered sink would misfire on an unrelated diagnostic plane.""" + every scattered sink would misfire on an unrelated diagnostic plane. + The dialect spells each commit; this method decides which.""" if sink.ttype.shape: raise HawkError( f"{sink.name!r}: a scattered accumulate of a rank-" @@ -1422,24 +1388,21 @@ def _commit_scattered(self, sink: Sink, target: str, index: str, "The kernel declares the plane; the seam only decides the " "arithmetic" ) - self._emit(f"hawk_abi::accum_add_compensated({target}, " - f"{binding_name('accum_out', comp)}, {index}, {value});") + self.dialect.store_compensated( + self, sink, target, binding_name("accum_out", comp), value) return - self._emit(f"hawk_abi::accum_add({target}, {index}, {value});") + self.dialect.accum_scattered(self, sink, target, index, value) - @staticmethod - def _sample_index(expr: str) -> str: - return f"aether::SampleIndex::make(static_cast({expr}))" - # -- expressions ------------------------------------ + # -- expressions (spelled by the dialect) ----------------------------- def _expr(self, node: Node, *, materialised: bool) -> str: if isinstance(node, Leaf): - return self._leaf(node, materialised) + return self.dialect.leaf(self, node, materialised) if isinstance(node, Const): - return self._const(node) + return self.dialect.const(node) if isinstance(node, SampleIndex): - return f"static_cast({SAMPLE_INDEX_IDENT})" + return self.dialect.sample_index_value() if isinstance(node, (LoopIndex, LoopCarry)): # Bound by the enclosing loop's header/carried local, seeded into # `self.local` before the body renders. @@ -1456,345 +1419,18 @@ def _expr(self, node: Node, *, materialised: bool) -> str: # forward subgraph it stands in front of. return self._ref(node.forward) if isinstance(node, At): - return self._at(node) + return self.dialect.at(self, node) if isinstance(node, Select): - return self._select(node) + c, a, b = (self._ref(o) for o in node.operands) + return self.dialect.select(self, node, c, a, b) if isinstance(node, Dispatch): return self._dispatch(node) if isinstance(node, Op): - return self._op(node) + args = [self._ref(o) for o in node.operands] + ranks = [len(o.ttype.shape) for o in node.operands] + return self.dialect.op(self, node, args, ranks) raise HawkError(f"no aether rendering for node {node!r}") - def _leaf(self, leaf: Leaf, materialised: bool) -> str: - name = binding_name(leaf.role, leaf.name) - if leaf.role == "nsamples": - return f"static_cast({name})" - if leaf.role == "uniform": - if leaf.ttype.dtype in INT_PLANE_DTYPES: - return f"static_cast<{element_spelling(leaf.ttype.dtype)}>({name})" - return name - if leaf.role == "mutable": - # `.prior` — the only way a `mutable`-role LEAF (as opposed to its - # SINK) reaches here. The plane's local is a WRITABLE view, so `[i]` - # calls the non-const `View::operator[]` and returns a `SampleRef`, - # which offers only `.get()` (no bare `.eval()` — that's - # `ConstSampleRef`'s overload, reached only through a `const` view). - # Rank>=1 stops at the materialised `Item`; rank 0 additionally - # calls its zero-argument `operator()()` to recover the raw scalar - # `aether::math::fmax`/`+`/... require. - if leaf.ttype.shape: - return f"{name}[i].get()" - return f"{name}[i].get()()" - if leaf.ttype.shape and self.position.get(id(leaf)) in self.lazy: - return f"{name}[i]" # proxy, element reads only (LAZY_LEAF_WIDTH) - if leaf.ttype.shape: - # Rank >= 1: always the body's own sample, never the whole plane. - # `view[i].get()` materialises the row into an `Item`, which is what - # makes the value sample-bound — a bare view carries no sample, and - # every aether fold reached through one silently answers at - # `SampleIndex::make(0)`. `_materialised` names every rank>=1 leaf, - # so this branch has no unmaterialised arm to take. - return f"{name}[i].get()" - return f"{name}[i].eval()" - - def _const(self, node: Const) -> str: - t = node.ttype - if t.shape: - raise HawkError( - f"a rank-{len(t.shape)} literal typed {t!r} has no aether spelling; " - "a broadcast literal is a rank-0 constant under a 'splat'" - ) - if t.dtype == "bool": - return "true" if node.literal else "false" - if t.dtype in ("i32", "i64"): - return f"static_cast({int(node.literal)})" - if t.dtype in INT_PLANE_DTYPES: - return f"static_cast<{element_spelling(t.dtype)}>({int(node.literal)}LL)" - return f"static_cast({_real_literal(float(node.literal))})" - - def _at(self, node: At) -> str: - if is_param_cell(node): - return param_cell_binding(node.plane.name) - plane = binding_name(node.plane.role, node.plane.name) - idx = self._sample_index(self._ref(node.index)) - return f"{plane}[{idx}]" + (".get()" if node.ttype.shape else ".eval()") - - def _select(self, node: Select) -> str: - """The ``cond ? a : b``. A rank-0 select is a plain C++ ternary; a - rank>=1 one is ``aether::select``, which reads both branches through - the shared working-type helper — a raw ternary can't name them, since - two aether expressions of different C++ types have no common type.""" - c, a, b = (self._ref(o) for o in node.operands) - if not node.ttype.shape: - return f"({c} ? {a} : {b})" - cond_rank = len(node.operands[0].ttype.shape) - if cond_rank == 0: - # A per-sample mask: aether::select needs an EXPRESSION, and a - # rank-0 C++ bool isn't one, so it broadcasts through a rank-0 - # Constant (Select's `Cond::element_extents::Rank == 0` arm). - c = self._hoist(f"aether::constant>({c})") - return f"aether::select({c}, {a}, {b})" - - def _op(self, node: Op) -> str: - kind, rank = node.kind, len(node.ttype.shape) - args = [self._ref(o) for o in node.operands] - ranks = [len(o.ttype.shape) for o in node.operands] - if kind == RAW_KIND: - return self._raw(node, args) - if rank == 0 and all(r == 0 for r in ranks): - return self._scalar_op(kind, args, node) - return self._tensor_op(kind, args, ranks, node) - - def _raw(self, node: Op, args: Sequence[str]) -> str: - block = node.literal - for k, arg in enumerate(args): - self._emit(f"const auto hawk_raw_arg{k} = {arg};") - return f"({block.text})" - - def _scalar_op(self, kind: str, args: Sequence[str], node: Op) -> str: - if kind in _INT_CALLS or (kind in _INT_WRAPPING and node.ttype.dtype in INT_PLANE_DTYPES): - # integer planes (and `//`, `%`, the bit operators on an index): - # the wrapping, numpy-defined helpers (hawk/emit/backend.py - # INT_HELPERS), since C++'s own `+ - * / % << >>` are undefined - # or differently defined at the edges numpy pins. - name = _INT_CALLS.get(kind) or _INT_WRAPPING[kind] - return f"hawk_abi::{name}({', '.join(args)})" - if kind == "cast": - return self._cast_op(args[0], node) - if kind in RANDOM_OPS: - return self._random_draw(kind, args) - if kind == "spacing": - return f"hawk_abi::spacing({args[0]})" - if kind == "sign": - # aether's sign maps NaN to 0; numpy keeps the NaN. - x = args[0] if args[0].isidentifier() else self._hoist(args[0]) - return f"(aether::math::isnan({x}) ? {x} : aether::math::sign({x}))" - if kind in _MATH1: - return f"aether::math::{_MATH1[kind]}({args[0]})" - if kind in _MATH2: - return f"aether::math::{_MATH2[kind]}({args[0]}, {args[1]})" - if kind in _MATH3: - return f"aether::math::{_MATH3[kind]}({', '.join(args)})" - if kind in _INFIX and len(args) == 2: - return f"({args[0]} {_INFIX[kind]} {args[1]})" - if kind == "neg": - return f"(-{args[0]})" - if kind == "lnot": - return f"(!{args[0]})" - if kind in ("dot", "norm", "sum", "component"): - return self._reduction(kind, args, node) - raise HawkError(_no_spelling(kind, 0)) - - @staticmethod - def _cast_op(arg: str, node: Op) -> str: - """``cast``: float -> integer plane through the saturating helper, - every other pair a plain ``static_cast`` (modular between integers, - exact into ``Real`` up to its precision).""" - target, source = node.ttype.dtype, node.operands[0].ttype.dtype - elem = element_spelling(target) - if target in INT_PLANE_DTYPES and source in ("f64", "f32"): - return f"hawk_abi::int_from_real<{elem}>({arg})" - return f"static_cast<{elem}>({arg})" - - def _random_draw(self, kind: str, args: Sequence[str]) -> str: - """HAWK's ``(seed, counter)`` draw: aether's stateless - ``detail::uniform01``/``detail::standardNormal(seed, global, - counter)`` (:data:`_RANDOM_FN`), with ``global`` the kernel's own - flattened sample index spelled here as the bound :data:`SAMPLE_INDEX_IDENT` - directly. Every lane is its own stream by construction (an author names - only ``seed``/``counter``), which is also what lets a captured graph - re-draw by writing one of those wires rather than re-capturing. Sets - :attr:`uses_random` so :class:`Body` carries the fact - :func:`hawk.emit.backend.prelude` needs to add the header.""" - self.uses_random = True - seed, counter = args - return (f"aether::random::detail::{_RANDOM_FN[kind]}(" - f"static_cast({seed}), " - f"static_cast({SAMPLE_INDEX_IDENT}), " - f"static_cast({counter}))") - - def _reduction(self, kind: str, args: Sequence[str], node: Op) -> str: - """The rank>=1 -> rank-0 forms: they READ a tensor and RETURN a scalar.""" - if kind == "component": - index = node.literal if isinstance(node.literal, tuple) else (node.literal,) - ks = ', '.join(str(int(k)) for k in index) - if self.position.get(id(_through(node.operands[0]))) in self.lazy: - return f"{args[0]}.eval<{ks}>()" - return f"{args[0]}.eval<{ks}>(i)" - if kind == "sum" and len(node.operands[0].ttype.shape) == 2: - return self._matrix_sum(args[0], node.operands[0].ttype) - if kind in _METHOD1: - return f"{args[0]}.{_METHOD1[kind]}({args[1]})" - return f"{args[0]}.{_METHOD0[kind]}()" - - def _runtime_component(self, node: Op, args: Sequence[str]) -> str: - """``v[k]`` at a RUNTIME index, in aether's own spelling. - - aether answers this through ``Item::operator()``, which casts each - index to ``std::size_t`` and folds them row-major over the static - extents — no local array copy is needed. The catch: ``operator()`` is - ``Item``'s own, not the plain ``Expression`` CRTP's, so the operand must - first be MATERIALISED into an ``aether::Item`` local. That - materialisation is named once per value per SCOPE (:attr:`items`), so - two runtime reads of one vector inside a loop body share one Item, and - an Item built inside a loop isn't referenced after the scope closes.""" - source = node.operands[0] - if _reads_in_place(source): - # a read-only input plane: the element at the runtime index straight - # from the view (`SampleRef::at`), no `Item` of the whole row - return f"{binding_name(source.role, source.name)}[i].at({args[1]})" - held = self.items.get(id(source)) - if held is None: - t = source.ttype - extents = ", ".join(str(e) for e in t.shape) - held = f"hawk_it{self.hoists}" - self.hoists += 1 - self._emit(f"const aether::Item<{element_spelling(t.dtype)}, " - f"{extents}> {held} = {args[0]};") - self.items[id(source)] = held - return f"{held}({args[1]})" - - def _runtime_component_store(self, node: Op, args: Sequence[str], - accumulate: bool = False) -> str: - """``v[k] = e`` at a RUNTIME index, as a VALUE. - - The IR node is functional — ``v`` with one component replaced — and C++ - has no such expression, so this is the one place the emitter turns a - value into two statements: a non-const ``aether::Item`` copy of the - incoming vector, then a write through its non-const ``operator()``. - The copy preserves the IR's functional meaning (a later read of ``v`` - sees the old value); it's register-resident, so a compiler that can - see the original elides it. Emitting statements mid-expression is safe - because :meth:`_expr` runs once per node in traversal order, so the - lines land above their use.""" - t = node.ttype - name = f"hawk_sv{self.hoists}" - self.hoists += 1 - self._emit(f"{carry_type(t)} {name} = {args[0]};") - op = "+=" if accumulate else "=" - self._emit(f"{name}({args[1]}) {op} {self._cast(args[2], t.dtype)};") - return name - - def _matrix_sum(self, arg: str, ttype: TensorType) -> str: - """The FULL reduction of a rank-2 expression, in aether's own spelling. - - ``hawk.ir.ops`` admits ``sum`` over a rank-2 operand, and the reverse - rule mints one for the adjoint of a rank-0 operand broadcast against a - matrix. aether's ``Expression::sum()`` asserts rank 1, so this uses - ``Expression::matDot()`` instead — the Frobenius inner product with a - matrix of ones gives the same total, one aether node, no scalarised - per-element chain. The ones matrix is HOISTED like any other broadcast - constant: ``aether::constant`` is a leaf and ``matDot`` binds it by - reference.""" - ext = ", ".join(str(e) for e in ttype.shape) - ones = self._hoist(f"aether::constant>(" - f"{self._cast('1', ttype.dtype)})") - return f"{arg}.matDot({ones})" - - def _tensor_op(self, kind: str, args: Sequence[str], ranks: Sequence[int], - node: Op) -> str: - rank = len(node.ttype.shape) - if kind == "component_at": - return self._runtime_component(node, args) - if kind == "set_component_at": - return self._runtime_component_store(node, args) - if kind == "add_at": - return self._runtime_component_store(node, args, accumulate=True) - if kind in ("dot", "norm", "sum", "component"): - return self._reduction(kind, args, node) - if kind == "splat": - ext = ", ".join(str(e) for e in node.ttype.shape) - return (f"aether::constant>(" - f"{self._cast(args[0], node.ttype.dtype)})") - if kind == "vec": - if not args: - raise HawkError("vec: a vector literal needs at least one component") - elem = element_spelling(node.ttype.dtype) - # aether added the 1-component `Item{v}` ctor beside the - # N-scalar one, so the arity floor is 1, not 2. - ext = ", ".join(str(e) for e in node.ttype.shape) - return f"aether::Item<{elem}, {ext}>{{{', '.join(args)}}}" - if kind in ("add", "sub"): - return self._additive(kind, args, ranks, node) - if kind == "mul": - return self._multiplicative(args, ranks, "*", "cwiseMul", node) - if kind == "div" and ranks[1] == 0: - return self._multiplicative(args, ranks, "/", None, node) - if kind in _CWISE: - # min/max/div/pow: one spelling for both operand shapes — - # `f(expr, bound)` when the right operand is rank-0, `f(expr, expr)` - # otherwise. Only the LEFT operand must be an expression in every - # overload, so a rank-0 left one broadcasts through a Constant first. - left = args[0] if ranks[0] else self._broadcast(args[0], node.ttype) - return f"{_CWISE[kind]}({left}, {args[1]})" - if kind == "neg": - return f"({args[0]} * static_cast(-1))" - if kind == "abs": - return f"aether::cwiseAbs({args[0]})" - if kind in _CWISE1: - return f"aether::{_CWISE1[kind]}({args[0]})" - if kind in ("mv", "mm"): - return f"({args[0]} * {args[1]})" - if kind == "outer": - return f"aether::outer({args[0]}, {args[1]})" - if kind in _METHOD1: - return f"{args[0]}.{_METHOD1[kind]}({args[1]})" - if kind in _METHOD0: - return f"{args[0]}.{_METHOD0[kind]}()" - raise HawkError(_no_spelling(kind, rank)) - - def _additive(self, kind: str, args: Sequence[str], ranks: Sequence[int], - node: Op) -> str: - sym = "+" if kind == "add" else "-" - left, right = args - if ranks[0] == 0: - left = self._broadcast(left, node.ttype) - if ranks[1] == 0: - right = self._broadcast(right, node.ttype) - return f"({left} {sym} {right})" - - def _multiplicative(self, args: Sequence[str], ranks: Sequence[int], sym: str, - cwise: str | None, node: Op) -> str: - a, b = args - if ranks[0] == 0 and sym == "*": - return f"({self._cast(a, node.ttype.dtype)} * {b})" - if ranks[1] == 0: - return f"({a} {sym} {self._cast(b, node.ttype.dtype)})" - if cwise is not None and ranks[0] == ranks[1]: - return f"{a}.{cwise}({b})" - kind = "div" if sym == "/" else "mul" - raise HawkError( - _no_spelling(kind, len(node.ttype.shape)) - + " for two rank>=1 operands: aether carries a component-wise PRODUCT " - "(Expression::cwiseMul) and no component-wise quotient" - ) - - def _broadcast(self, scalar: str, ttype: TensorType) -> str: - """A rank-0 operand added to a tensor becomes an aether broadcast leaf — - HOISTED, since a ``Constant`` is a leaf and would be stored by reference - by the enclosing node.""" - ext = ", ".join(str(e) for e in ttype.shape) - return self._hoist(f"aether::constant>(" - f"{self._cast(scalar, ttype.dtype)})") - - @staticmethod - def _cast(text: str, dtype: str) -> str: - elem = element_spelling(dtype) - if text.startswith(f"static_cast<{elem}>"): - return text - return f"static_cast<{elem}>({text})" - - -#: Integer-only kinds -> their ``hawk_abi`` helper (any integer type). -_INT_CALLS = {"floordiv": "int_floordiv", "mod": "int_mod", "band": "int_and", - "bor": "int_or", "bxor": "int_xor", "shl": "int_shl", "shr": "int_shr", - "invert": "int_invert"} -#: Kinds an integer PLANE value routes through a wrapping helper, where the -#: infix spelling would overflow a signed int (undefined) or promote. -_INT_WRAPPING = {"add": "int_add", "sub": "int_sub", "mul": "int_mul", - "neg": "int_neg", "abs": "int_abs", "min": "int_min", - "max": "int_max"} - def _flat_body(loop: Loop) -> list: """``loop``'s body nodes and, recursively, those of every loop inside it @@ -1812,6 +1448,11 @@ def _flat_body(loop: Loop) -> list: return out +#: A rank-0 float64 type: the zero :meth:`_Renderer.run` writes into planes it +#: zero-fills is the dialect's typed zero of it. +_F64_SCALAR = TensorType((), "f64") + + #: The local row of per-sample terms a ``sample_sum`` body writes; the #: backend's wrapper declares it and sums it. SAMPLE_SUM_TERMS = "hawk_ss" diff --git a/hawk/emit/dialect.py b/hawk/emit/dialect.py new file mode 100644 index 0000000..f75e0db --- /dev/null +++ b/hawk/emit/dialect.py @@ -0,0 +1,166 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The spelling seam: what a DIALECT supplies to the one body renderer. + +:class:`hawk.emit.aether._Renderer` owns the traversal — the walk order, +fan-out naming, loop scopes and carries, the per-case ``switch`` scopes, the +mask guard and the finish epilogue. Every piece of TARGET TEXT it produces +comes from a :class:`Dialect`: the aether C++ spelling +(:mod:`hawk.emit.dialect_aether`, the default, and what both built backends +wrap) or a plain-C spelling (:mod:`hawk.embedded`). The traversal is written +once; a dialect never re-walks the IR. + +Every member takes already-rendered operand TEXT and returns text (or, for +the sink members, emits statements through the renderer's ``_emit``). ``r`` +is the renderer: a dialect reads ``r.walk``, ``r.kind``, ``r.sinks``, +``r.items``, ``r.hoists`` and calls ``r._emit``/``r._ref`` exactly as the +moved aether code did through ``self``. Nothing here imports the renderer, +so a dialect module never cycles through :mod:`hawk.emit.aether`. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any, Protocol + +#: Every spelling member a dialect supplies, in the order the renderer +#: reaches them: declarations and loops, dispatch, expressions, sinks. +DIALECT_MEMBERS: tuple[str, ...] = ( + "declare", "carry_type", "tape_decl", "counter_decl", "for_header", + "tail_break", "sample_index_value", "hoist", + "selector_decl", "mix_count", "predicated_rank0", "predicated_tensor", + "switch_default_tail", "typed_zero", + "leaf", "const", "at", "select", "op", + "guard", "store", "scatter_store", "accum_scattered", "store_compensated", + "sample_sum", "tape_write", "finish", "exit_if", + "component_commit", "in_place_accumulate", "zero_row", +) + + +class Dialect(Protocol): + """One target's spelling of every text the renderer produces. + + The docstring of each member states the aether dialect's text, which is + the renderer's output today; a second dialect answers the same question + in its own language.""" + + #: The dialect's id (``"aether"``, ``"c"``). + id: str + #: Whether EVERY rank>=1 value is named (a C struct value), beyond the + #: renderer's own fan-out rule. ``False`` for aether, whose lazy + #: expressions inline until the fan-out rule names them. + names_every_tensor: bool + #: Whether the renderer may store a vector built from scalars one + #: component at a time (:meth:`component_commit`), so a wide value is never + #: live whole; otherwise it keeps the single store at the end. + commits_per_component: bool + + # -- declarations and loops ---------------------------------------- + def declare(self, name: str, ttype: Any, text: str) -> str: + """The named local: ``const auto {name} = {text};``.""" + + def carry_type(self, ttype: Any) -> str: + """A loop-carried local's declared type (``Real``, ``aether::Item<...>``).""" + + def tape_decl(self, elem: str, name: str, trip: int) -> str: + """A forward loop's tape array: ``{elem} {name}[{trip}];``.""" + + def counter_decl(self, counter: str) -> str: + """A loop's per-sample iteration counter: ``Int {counter} = 0;``.""" + + def for_header(self, r: Any, index: str, loop: Any, count: str | None) -> str: + """The lowered loop's ``for`` header (``spelling._for_header``). Takes + the renderer because a runtime-count header may need a statement of + its own ahead of the ``for`` (the C dialect's clamped count).""" + + def tail_break(self, flag: str) -> str: + """The tail-form exit: ``if ({flag}) break;``.""" + + def exit_if(self, cond: str) -> str: + """The line opening a head/middle-form ``break``: ``if ({cond}) {``.""" + + def sample_index_value(self) -> str: + """A ``SampleIndex`` node's value: ``static_cast(hawk_i)``.""" + + def hoist(self, r: Any, text: str, ttype: Any) -> str: + """Bind ``text`` to a fresh local ahead of the current line; its name.""" + + # -- dispatch ------------------------------------------------------- + def selector_decl(self, r: Any, kname: str, kind_expr: str, k: int, *, + in_range: bool = False) -> str: + """The clamped selector local: ``const std::int32_t {kname} = ...;``. + ``in_range``: the selector is a select between two in-range literals, + so a dialect may skip the clamp (the aether dialect does).""" + + def mix_count(self, mix: str, k: int, kname: str) -> str: + """An ``auto`` dispatch's branch-mix count statement.""" + + def predicated_rank0(self, kname: str, texts: Sequence[str]) -> str: + """The rank-0 ``predicated`` chain (``render_dispatch_rank0``).""" + + def predicated_tensor(self, r: Any, kname: str, texts: Sequence[str]) -> str: + """The rank>=1 ``predicated`` chain (nested ``aether::select``).""" + + def switch_default_tail(self) -> str: + """Text after a ``switch``'s ``default`` clause (empty for aether).""" + + def typed_zero(self, ttype: Any) -> str: + """The typed zero an elided ``switch`` branch assigns.""" + + # -- expressions ---------------------------------------------------- + def leaf(self, r: Any, leaf: Any, materialised: bool) -> str: + """A slot read at the body's own sample (``view[i].eval()``).""" + + def const(self, node: Any) -> str: + """A literal (``static_cast(1.5)``).""" + + def at(self, r: Any, node: Any) -> str: + """A plane read at a computed index.""" + + def select(self, r: Any, node: Any, c: str, a: str, b: str) -> str: + """``cond ? a : b`` at the node's rank.""" + + def op(self, r: Any, node: Any, args: Sequence[str], ranks: Sequence[int]) -> str: + """Every ``Op`` kind: raw blocks, rank-0 math, casts, reductions, tensors.""" + + # -- sinks and the guard -------------------------------------------- + def guard(self, bound: Sequence[str]) -> str | None: + """The body-wide mask condition over ``bound`` mask bindings, or ``None``.""" + + def store(self, r: Any, sink: Any, target: str, value: str) -> None: + """An own-column store.""" + + def scatter_store(self, r: Any, sink: Any, target: str, index: str, + value: str) -> None: + """A scattered store at a computed index.""" + + def accum_scattered(self, r: Any, sink: Any, target: str, index: str, + value: str) -> None: + """A scattered ``plain``/``atomic`` accumulate.""" + + def store_compensated(self, r: Any, sink: Any, target: str, comp: str, + value: str) -> None: + """The ``compensated`` kind's commit (own column or scattered).""" + + def sample_sum(self, r: Any, sink: Any, column: int) -> None: + """A ``SampleSum``'s per-sample term into column ``column`` of the row + the wrapper reduces.""" + + def component_commit(self, r: Any, target: str, at: str, op: str, value: str) -> str: + """Component ``at`` (static, row-major) of the sample's row of plane + ``target`` stored (``op`` ``=``) or accumulated (``+=``).""" + + def in_place_accumulate(self, r: Any, target: str, k: str, value: str) -> str: + """``value`` added to component ``k`` (a runtime index) of the sample's + row of plane ``target``: a lowered loop's per-iteration accumulate.""" + + def zero_row(self, r: Any, target: str, width: int) -> str: + """The whole of the sample's row of plane ``target`` (``width`` + components) zeroed, once, before a loop accumulates into it.""" + + def tape_write(self, r: Any, sink: Any, target: str) -> None: + """A tape row write under ``if (on)``.""" + + def finish(self, r: Any, sink: Any) -> None: + """The finish epilogue: mark the mask, count the sample.""" diff --git a/hawk/emit/dialect_aether.py b/hawk/emit/dialect_aether.py new file mode 100644 index 0000000..5f75da4 --- /dev/null +++ b/hawk/emit/dialect_aether.py @@ -0,0 +1,597 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The aether C++ dialect: today's spelling of every text the body renderer +produces, behind the :class:`hawk.emit.dialect.Dialect` seam. + +Every method here is the text :class:`hawk.emit.aether._Renderer` emitted +before the seam existed, moved verbatim; the renderer keeps the traversal +and hands this dialect already-rendered operand text. ``r`` is the renderer +(``r._emit``, ``r._ref``, ``r.items``, ``r.hoists``, ``r.indent``, +``r.uses_random``). Rank is the dispatch axis: rank-0 spells through +``aether::math``, rank>=1 through ``Expression``'s methods and +``aether/expr`` operators, quaternions through aether's method spellings. An +op with no aether spelling at its rank raises :class:`~hawk.ir.HawkError` +naming the kind, the rank and what aether would need. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from ..ir import HawkError +from ..ir.nodes import ( + FINISHED_PLANE, + RAW_KIND, + At, + Const, + Finish, + Leaf, + Op, + Select, + TapeWrite, + is_param_cell, +) +from ..ir.ops import RANDOM_OPS +from ..types import INT_PLANE_DTYPES, TensorType +from .dispatch import _clamp_expr, render_dispatch_rank0 +from .spelling import ( + _CWISE, + _CWISE1, + _INFIX, + _MATH1, + _MATH2, + _MATH3, + _METHOD0, + _METHOD1, + _RANDOM_FN, + SAMPLE_INDEX_IDENT, + _for_header, + _no_spelling, + _real_literal, + binding_name, + carry_type, + element_spelling, + finish_binding, + param_cell_binding, +) + + +class AetherDialect: + """The aether C++ spelling (see the module docstring).""" + + id = "aether" + #: aether's lazy expressions inline until the renderer's fan-out rule + #: names them; only leaf values (``Item``/``Constant``) are forced. + names_every_tensor = False + commits_per_component = True + + # -- declarations and loops ---------------------------------------- + + def declare(self, name: str, ttype: TensorType, text: str) -> str: + """A named local: ``const auto`` lets aether's expression type through.""" + return f"const auto {name} = {text};" + + def carry_type(self, ttype: TensorType) -> str: + """:func:`hawk.emit.spelling.carry_type`.""" + return carry_type(ttype) + + def tape_decl(self, elem: str, name: str, trip: int) -> str: + """A forward loop's per-iteration tape array.""" + return f"{elem} {name}[{trip}];" + + def counter_decl(self, counter: str) -> str: + """A loop's per-sample iteration counter.""" + return f"Int {counter} = 0;" + + def for_header(self, r, index: str, loop, count: str | None) -> str: + """:func:`hawk.emit.spelling._for_header`.""" + return _for_header(index, loop, count) + + def tail_break(self, flag: str) -> str: + """The tail-form exit, tested after the carries moved.""" + return f"if ({flag}) break;" + + def exit_if(self, cond: str) -> str: + """The ``if`` opening a head/middle-form ``break``, before the body's rest.""" + return f"if ({cond}) {{" + + def sample_index_value(self) -> str: + """A ``SampleIndex`` node: the lane's own integer index as an ``Int``.""" + return f"static_cast({SAMPLE_INDEX_IDENT})" + + def hoist(self, r, text: str, ttype: TensorType | None = None) -> str: + """Bind ``text`` to a fresh named local ahead of the current line — the + lifetime-safe home for a value that is an aether LEAF but not a node of + its own (a broadcast ``Constant``, a raw block's operand).""" + name = f"hz{r.hoists}" + r.hoists += 1 + r._emit(f"const auto {name} = {text};") + return name + + def _hoist(self, r, text: str) -> str: + return self.hoist(r, text, None) + + # -- dispatch ------------------------------------------------------- + + def selector_decl(self, r, kname: str, kind_expr: str, k: int, *, + in_range: bool = False) -> str: + """The selector, materialised once: clamped (:func:`_clamp_expr`), or a + plain cast when it is a select between two in-range literals.""" + spelled = (f"static_cast({kind_expr})" if in_range + else _clamp_expr(kind_expr, k)) + return f"const std::int32_t {kname} = {spelled};" + + def mix_count(self, mix: str, k: int, kname: str) -> str: + """An ``auto`` dispatch's branch-mix count (``hawk_abi::mix_count``).""" + return f"hawk_abi::mix_count({mix}, {k}, {kname});" + + def predicated_rank0(self, kname: str, texts: Sequence[str]) -> str: + """:func:`hawk.emit.dispatch.render_dispatch_rank0`'s ternary chain.""" + return render_dispatch_rank0(kname, texts, "predicated") + + def predicated_tensor(self, r, kname: str, texts: Sequence[str]) -> str: + """Nested ``aether::select`` calls, each condition broadcast into a + hoisted rank-0 ``Constant`` (the shape ``render_dispatch_rank0`` can't + build: it has no scope to hoist into).""" + expr = texts[-1] + for j in range(len(texts) - 2, -1, -1): + cond = self._hoist(r, f"aether::constant>" + f"({kname} == {j})") + expr = f"aether::select({cond}, {texts[j]}, {expr})" + return expr + + def switch_default_tail(self) -> str: + """Nothing follows a C++ ``default`` clause.""" + return "" + + # -- sinks and the guard -------------------------------------------- + + def guard(self, bound: Sequence[str]) -> str | None: + """The guard's CONDITION over the bound mask bindings: each read at + the body's own sample, the commit skipped when any is set.""" + if not bound: + return None + if len(bound) == 1: + return f"{bound[0]}[i].eval()" + return "(" + " || ".join(f"{b}[i].eval()" for b in bound) + ")" + + def store(self, r, sink, target: str, value: str) -> None: + """The own column: one elementwise contribution, one store.""" + r._emit(f"{target}[i] = {value};") + + def scatter_store(self, r, sink, target: str, index: str, value: str) -> None: + """A scattered wide_write targets a computed ROW: a store, exactly as + the reference evaluator reads it.""" + r._emit(f"{target}[{self._sample_index(index)}] = {value};") + + def accum_scattered(self, r, sink, target: str, index: str, value: str) -> None: + """The scattered ``plain``/``atomic`` commit: the target's atomic add.""" + r._emit(f"hawk_abi::accum_add({target}, {self._sample_index(index)}, {value});") + + def store_compensated(self, r, sink, target: str, comp: str, value: str) -> None: + """The ``compensated`` kind's commit: a Neumaier accumulate into the + own column (``i`` is already the bound ``SampleIndex``), or Knuth + two-sum under the target's atomic add when scattered.""" + if sink.index is None: + r._emit(f"hawk_abi::store_compensated({target}, {comp}, i, {value});") + return + index = self._sample_index(r._ref(sink.index)) + r._emit(f"hawk_abi::accum_add_compensated({target}, {comp}, {index}, {value});") + + def component_commit(self, r, target: str, at: str, op: str, value: str) -> str: + return f"{target}({at}, i.global()) {op} {value};" + + def in_place_accumulate(self, r, target: str, k: str, value: str) -> str: + return f"{target}[i].at({k}) += {value};" + + def zero_row(self, r, target: str, width: int) -> str: + return (f"for (Int hk = 0; hk < {width}; ++hk) " + f"{target}[i].at(hk) = {self.typed_zero(TensorType((), 'f64'))};") + + def sample_sum(self, r, sink, column: int) -> None: + """The sample's term into its column of the row the wrapper sums + (:data:`hawk.emit.aether.SAMPLE_SUM_TERMS`).""" + from .aether import SAMPLE_SUM_TERMS + + r._emit(f"{SAMPLE_SUM_TERMS}[{column}] = {r._ref(sink.value)};") + + # -- expressions ---------------------------------------------------- + + def leaf(self, r, leaf: Leaf, materialised: bool) -> str: + name = binding_name(leaf.role, leaf.name) + if leaf.role == "nsamples": + return f"static_cast({name})" + if leaf.role == "uniform": + if leaf.ttype.dtype in INT_PLANE_DTYPES: + return f"static_cast<{element_spelling(leaf.ttype.dtype)}>({name})" + return name + if leaf.role == "mutable": + # `.prior` — the only way a `mutable`-role LEAF (as opposed to its + # SINK) reaches here. The plane's local is a WRITABLE view, so `[i]` + # calls the non-const `View::operator[]` and returns a `SampleRef`, + # which offers only `.get()` (no bare `.eval()` — that's + # `ConstSampleRef`'s overload, reached only through a `const` view). + # Rank>=1 stops at the materialised `Item`; rank 0 additionally + # calls its zero-argument `operator()()` to recover the raw scalar + # `aether::math::fmax`/`+`/... require. + if leaf.ttype.shape: + return f"{name}[i].get()" + return f"{name}[i].get()()" + if leaf.ttype.shape and r.position.get(id(leaf)) in r.lazy: + return f"{name}[i]" # proxy, element reads only (LAZY_LEAF_WIDTH) + if leaf.ttype.shape: + # Rank >= 1: always the body's own sample, never the whole plane. + # `view[i].get()` materialises the row into an `Item`, which is what + # makes the value sample-bound — a bare view carries no sample, and + # every aether fold reached through one silently answers at + # `SampleIndex::make(0)`. `_materialised` names every rank>=1 leaf, + # so this branch has no unmaterialised arm to take. + return f"{name}[i].get()" + return f"{name}[i].eval()" + + def const(self, node: Const) -> str: + t = node.ttype + if t.shape: + raise HawkError( + f"a rank-{len(t.shape)} literal typed {t!r} has no aether spelling; " + "a broadcast literal is a rank-0 constant under a 'splat'" + ) + if t.dtype == "bool": + return "true" if node.literal else "false" + if t.dtype in ("i32", "i64"): + return f"static_cast({int(node.literal)})" + if t.dtype in INT_PLANE_DTYPES: + return f"static_cast<{element_spelling(t.dtype)}>({int(node.literal)}LL)" + return f"static_cast({_real_literal(float(node.literal))})" + + def at(self, r, node: At) -> str: + if is_param_cell(node): + return param_cell_binding(node.plane.name) + plane = binding_name(node.plane.role, node.plane.name) + idx = self._sample_index(r._ref(node.index)) + return f"{plane}[{idx}]" + (".get()" if node.ttype.shape else ".eval()") + + def select(self, r, node: Select, c: str, a: str, b: str) -> str: + """The ``cond ? a : b``. A rank-0 select is a plain C++ ternary; a + rank>=1 one is ``aether::select``, which reads both branches through + the shared working-type helper — a raw ternary can't name them, since + two aether expressions of different C++ types have no common type.""" + if not node.ttype.shape: + return f"({c} ? {a} : {b})" + cond_rank = len(node.operands[0].ttype.shape) + if cond_rank == 0: + # A per-sample mask: aether::select needs an EXPRESSION, and a + # rank-0 C++ bool isn't one, so it broadcasts through a rank-0 + # Constant (Select's `Cond::element_extents::Rank == 0` arm). + c = self._hoist(r, f"aether::constant>({c})") + return f"aether::select({c}, {a}, {b})" + + def op(self, r, node: Op, args: Sequence[str], ranks: Sequence[int]) -> str: + kind, rank = node.kind, len(node.ttype.shape) + if kind == RAW_KIND: + return self._raw(r, node, args) + if rank == 0 and all(rk == 0 for rk in ranks): + return self._scalar_op(r, kind, args, node) + return self._tensor_op(r, kind, args, ranks, node) + + def _raw(self, r, node: Op, args: Sequence[str]) -> str: + block = node.literal + for k, arg in enumerate(args): + r._emit(f"const auto hawk_raw_arg{k} = {arg};") + return f"({block.text})" + + def _scalar_op(self, r, kind: str, args: Sequence[str], node: Op) -> str: + if kind in _INT_CALLS or (kind in _INT_WRAPPING + and node.ttype.dtype in INT_PLANE_DTYPES): + # integer planes (and `//`, `%`, the bit operators on an index): + # the wrapping, numpy-defined helpers (hawk/emit/backend.py + # INT_HELPERS), since C++'s own `+ - * / % << >>` are undefined + # or differently defined at the edges numpy pins. + name = _INT_CALLS.get(kind) or _INT_WRAPPING[kind] + return f"hawk_abi::{name}({', '.join(args)})" + if kind == "cast": + return self._cast_op(args[0], node) + if kind in RANDOM_OPS: + return self._random_draw(r, kind, args) + if kind == "spacing": + return f"hawk_abi::spacing({args[0]})" + if kind == "sign": + # aether's sign maps NaN to 0; numpy keeps the NaN. + x = args[0] if args[0].isidentifier() else self._hoist(r, args[0]) + return f"(aether::math::isnan({x}) ? {x} : aether::math::sign({x}))" + if kind in _MATH1: + return f"aether::math::{_MATH1[kind]}({args[0]})" + if kind in _MATH2: + return f"aether::math::{_MATH2[kind]}({args[0]}, {args[1]})" + if kind in _MATH3: + return f"aether::math::{_MATH3[kind]}({', '.join(args)})" + if kind in _INFIX and len(args) == 2: + return f"({args[0]} {_INFIX[kind]} {args[1]})" + if kind == "neg": + return f"(-{args[0]})" + if kind == "lnot": + return f"(!{args[0]})" + if kind in ("dot", "norm", "sum", "component"): + return self._reduction(r, kind, args, node) + raise HawkError(_no_spelling(kind, 0)) + + @staticmethod + def _cast_op(arg: str, node: Op) -> str: + """``cast``: float -> integer plane through the saturating helper, + every other pair a plain ``static_cast`` (modular between integers, + exact into ``Real`` up to its precision).""" + target, source = node.ttype.dtype, node.operands[0].ttype.dtype + elem = element_spelling(target) + if target in INT_PLANE_DTYPES and source in ("f64", "f32"): + return f"hawk_abi::int_from_real<{elem}>({arg})" + return f"static_cast<{elem}>({arg})" + + def _random_draw(self, r, kind: str, args: Sequence[str]) -> str: + """HAWK's ``(seed, counter)`` draw: aether's stateless + ``detail::uniform01``/``detail::standardNormal(seed, global, + counter)`` (:data:`_RANDOM_FN`), with ``global`` the kernel's own + flattened sample index spelled here as the bound :data:`SAMPLE_INDEX_IDENT` + directly. Every lane is its own stream by construction (an author names + only ``seed``/``counter``), which is also what lets a captured graph + re-draw by writing one of those wires rather than re-capturing. Sets + :attr:`uses_random` so :class:`Body` carries the fact + :func:`hawk.emit.backend.prelude` needs to add the header.""" + r.uses_random = True + seed, counter = args + return (f"aether::random::detail::{_RANDOM_FN[kind]}(" + f"static_cast({seed}), " + f"static_cast({SAMPLE_INDEX_IDENT}), " + f"static_cast({counter}))") + + def _reduction(self, r, kind: str, args: Sequence[str], node: Op) -> str: + """The rank>=1 -> rank-0 forms: they READ a tensor and RETURN a scalar.""" + if kind == "component": + from .aether import _through + + index = node.literal if isinstance(node.literal, tuple) else (node.literal,) + ks = ', '.join(str(int(k)) for k in index) + if r.position.get(id(_through(node.operands[0]))) in r.lazy: + return f"{args[0]}.eval<{ks}>()" + return f"{args[0]}.eval<{ks}>(i)" + if kind == "sum" and len(node.operands[0].ttype.shape) == 2: + return self._matrix_sum(r, args[0], node.operands[0].ttype) + if kind in _METHOD1: + return f"{args[0]}.{_METHOD1[kind]}({args[1]})" + return f"{args[0]}.{_METHOD0[kind]}()" + + def _runtime_component(self, r, node: Op, args: Sequence[str]) -> str: + """``v[k]`` at a RUNTIME index, in aether's own spelling. + + aether answers this through ``Item::operator()``, which casts each + index to ``std::size_t`` and folds them row-major over the static + extents — no local array copy is needed. The catch: ``operator()`` is + ``Item``'s own, not the plain ``Expression`` CRTP's, so the operand must + first be MATERIALISED into an ``aether::Item`` local. That + materialisation is named once per value per SCOPE (:attr:`items`), so + two runtime reads of one vector inside a loop body share one Item, and + an Item built inside a loop isn't referenced after the scope closes.""" + from .aether import _reads_in_place + + source = node.operands[0] + if _reads_in_place(source): + # a read-only input plane: the element at the runtime index straight + # from the view (`SampleRef::at`), no `Item` of the whole row + return f"{binding_name(source.role, source.name)}[i].at({args[1]})" + held = r.items.get(id(source)) + if held is None: + t = source.ttype + extents = ", ".join(str(e) for e in t.shape) + held = f"hawk_it{r.hoists}" + r.hoists += 1 + r._emit(f"const aether::Item<{element_spelling(t.dtype)}, " + f"{extents}> {held} = {args[0]};") + r.items[id(source)] = held + return f"{held}({args[1]})" + + def _runtime_component_store(self, r, node: Op, args: Sequence[str], + accumulate: bool = False) -> str: + """``v[k] = e`` at a RUNTIME index, as a VALUE. + + The IR node is functional — ``v`` with one component replaced — and C++ + has no such expression, so this is the one place the emitter turns a + value into two statements: a non-const ``aether::Item`` copy of the + incoming vector, then a write through its non-const ``operator()``. + The copy preserves the IR's functional meaning (a later read of ``v`` + sees the old value); it's register-resident, so a compiler that can + see the original elides it. Emitting statements mid-expression is safe + because :meth:`_expr` runs once per node in traversal order, so the + lines land above their use.""" + t = node.ttype + name = f"hawk_sv{r.hoists}" + r.hoists += 1 + r._emit(f"{carry_type(t)} {name} = {args[0]};") + op = "+=" if accumulate else "=" + r._emit(f"{name}({args[1]}) {op} {self._cast(args[2], t.dtype)};") + return name + + def _matrix_sum(self, r, arg: str, ttype: TensorType) -> str: + """The FULL reduction of a rank-2 expression, in aether's own spelling. + + ``hawk.ir.ops`` admits ``sum`` over a rank-2 operand, and the reverse + rule mints one for the adjoint of a rank-0 operand broadcast against a + matrix. aether's ``Expression::sum()`` asserts rank 1, so this uses + ``Expression::matDot()`` instead — the Frobenius inner product with a + matrix of ones gives the same total, one aether node, no scalarised + per-element chain. The ones matrix is HOISTED like any other broadcast + constant: ``aether::constant`` is a leaf and ``matDot`` binds it by + reference.""" + ext = ", ".join(str(e) for e in ttype.shape) + ones = self._hoist(r, f"aether::constant>(" + f"{self._cast('1', ttype.dtype)})") + return f"{arg}.matDot({ones})" + + def _tensor_op(self, r, kind: str, args: Sequence[str], ranks: Sequence[int], + node: Op) -> str: + rank = len(node.ttype.shape) + if kind == "component_at": + return self._runtime_component(r, node, args) + if kind == "set_component_at": + return self._runtime_component_store(r, node, args) + if kind == "add_at": + return self._runtime_component_store(r, node, args, accumulate=True) + if kind in ("dot", "norm", "sum", "component"): + return self._reduction(r, kind, args, node) + if kind == "splat": + ext = ", ".join(str(e) for e in node.ttype.shape) + return (f"aether::constant>(" + f"{self._cast(args[0], node.ttype.dtype)})") + if kind == "vec": + if not args: + raise HawkError("vec: a vector literal needs at least one component") + elem = element_spelling(node.ttype.dtype) + # aether added the 1-component `Item{v}` ctor beside the + # N-scalar one, so the arity floor is 1, not 2. + ext = ", ".join(str(e) for e in node.ttype.shape) + return f"aether::Item<{elem}, {ext}>{{{', '.join(args)}}}" + if kind in ("add", "sub"): + return self._additive(r, kind, args, ranks, node) + if kind == "mul": + return self._multiplicative(r, args, ranks, "*", "cwiseMul", node) + if kind == "div" and ranks[1] == 0: + return self._multiplicative(r, args, ranks, "/", None, node) + if kind in _CWISE: + # min/max/div/pow: one spelling for both operand shapes — + # `f(expr, bound)` when the right operand is rank-0, `f(expr, expr)` + # otherwise. Only the LEFT operand must be an expression in every + # overload, so a rank-0 left one broadcasts through a Constant first. + left = args[0] if ranks[0] else self._broadcast(r, args[0], node.ttype) + return f"{_CWISE[kind]}({left}, {args[1]})" + if kind == "neg": + return f"({args[0]} * static_cast(-1))" + if kind == "abs": + return f"aether::cwiseAbs({args[0]})" + if kind in _CWISE1: + return f"aether::{_CWISE1[kind]}({args[0]})" + if kind in ("mv", "mm"): + return f"({args[0]} * {args[1]})" + if kind == "outer": + return f"aether::outer({args[0]}, {args[1]})" + if kind in _METHOD1: + return f"{args[0]}.{_METHOD1[kind]}({args[1]})" + if kind in _METHOD0: + return f"{args[0]}.{_METHOD0[kind]}()" + raise HawkError(_no_spelling(kind, rank)) + + def _additive(self, r, kind: str, args: Sequence[str], ranks: Sequence[int], + node: Op) -> str: + sym = "+" if kind == "add" else "-" + left, right = args + if ranks[0] == 0: + left = self._broadcast(r, left, node.ttype) + if ranks[1] == 0: + right = self._broadcast(r, right, node.ttype) + return f"({left} {sym} {right})" + + def _multiplicative(self, r, args: Sequence[str], ranks: Sequence[int], sym: str, + cwise: str | None, node: Op) -> str: + a, b = args + if ranks[0] == 0 and sym == "*": + return f"({self._cast(a, node.ttype.dtype)} * {b})" + if ranks[1] == 0: + return f"({a} {sym} {self._cast(b, node.ttype.dtype)})" + if cwise is not None and ranks[0] == ranks[1]: + return f"{a}.{cwise}({b})" + kind = "div" if sym == "/" else "mul" + raise HawkError( + _no_spelling(kind, len(node.ttype.shape)) + + " for two rank>=1 operands: aether carries a component-wise PRODUCT " + "(Expression::cwiseMul) and no component-wise quotient" + ) + + def _broadcast(self, r, scalar: str, ttype: TensorType) -> str: + """A rank-0 operand added to a tensor becomes an aether broadcast leaf — + HOISTED, since a ``Constant`` is a leaf and would be stored by reference + by the enclosing node.""" + ext = ", ".join(str(e) for e in ttype.shape) + return self._hoist(r, f"aether::constant>(" + f"{self._cast(scalar, ttype.dtype)})") + + @staticmethod + def _cast(text: str, dtype: str) -> str: + elem = element_spelling(dtype) + if text.startswith(f"static_cast<{elem}>"): + return text + return f"static_cast<{elem}>({text})" + + def tape_write(self, r, sink: TapeWrite, target: str) -> None: + """One tape ROW on the own column, under ``if (on)``: component + ``row*w + c`` of the sample's ``(W*w,)`` plane gets the ``c``-th + flattened component of the stored values, in order. Rendered where the + sink stands — a twin puts it FIRST, so it reads the pre-step state + before any commit overwrites it.""" + row = r._ref(sink.row) + lines = [] + offset = 0 + for value in sink.values: + text = r._ref(value) + shape = value.ttype.shape + comps = ([((), text)] if not shape + else [(idx, f"{text}.eval<{', '.join(map(str, idx))}>(i)") + for idx in _flat_indices(shape)]) + for _idx, comp in comps: + lines.append(f"{target}(static_cast(hawk_tape_row * " + f"{sink.width} + {offset}), i.global()) = {comp};") + offset += 1 + r._emit(f"if ({r._ref(sink.on)} != 0) {{") + r.indent += " " + r._emit(f"const std::int64_t hawk_tape_row = {row};") + for line in lines: + r._emit(line) + r.indent = r.indent[:-4] + r._emit("}") + + @staticmethod + def _sample_index(expr: str) -> str: + return f"aether::SampleIndex::make(static_cast({expr}))" + + def finish(self, r, sink: Finish) -> None: + """The finish epilogue — the last text of the guarded scope, so the step's + own commits land before the sample is marked: an unfinished sample whose + condition holds sets its mask (via :func:`finish_binding`) and adds one to + the reserved counter (``hawk_abi::finish_count``).""" + mask = binding_name("terminated", sink.name) + cond = r._ref(sink.value) + counter = binding_name("lookup", FINISHED_PLANE) + r._emit(f"if (!{mask}[i].eval() && {cond}) {{ " + f"{finish_binding(sink.name)}[i] = true; " + f"hawk_abi::finish_count({counter}); }}") + + def typed_zero(self, ttype: TensorType) -> str: + """The typed zero the ``switch`` ``default:`` writes when at + least one branch was elided — the same spelling :meth:`_broadcast` + builds, inlined rather than hoisted, since it's assigned straight + into the declared local, which copies it out immediately.""" + elem = element_spelling(ttype.dtype) + if not ttype.shape: + return f"static_cast<{elem}>(0)" + ext = ", ".join(str(e) for e in ttype.shape) + return f"aether::constant>(static_cast<{elem}>(0))" + + +#: Integer-only kinds -> their ``hawk_abi`` helper (any integer type). +_INT_CALLS = {"floordiv": "int_floordiv", "mod": "int_mod", "band": "int_and", + "bor": "int_or", "bxor": "int_xor", "shl": "int_shl", "shr": "int_shr", + "invert": "int_invert"} +#: Kinds an integer PLANE value routes through a wrapping helper, where the +#: infix spelling would overflow a signed int (undefined) or promote. +_INT_WRAPPING = {"add": "int_add", "sub": "int_sub", "mul": "int_mul", + "neg": "int_neg", "abs": "int_abs", "min": "int_min", + "max": "int_max"} + + +def _flat_indices(shape: tuple) -> list: + """Every index tuple of ``shape`` in row-major (C) order.""" + out: list = [()] + for extent in shape: + out = [idx + (k,) for idx in out for k in range(int(extent))] + return out + + +#: The built aether dialect: what every backend wraps today. +AETHER = AetherDialect() diff --git a/hawk/emit/dispatch.py b/hawk/emit/dispatch.py index e8a15db..27b492e 100644 --- a/hawk/emit/dispatch.py +++ b/hawk/emit/dispatch.py @@ -21,8 +21,7 @@ Primitive, SampleIndex, ) -from ..types import TensorType -from .spelling import _is_literal_zero, binding_name, carry_type, element_spelling +from .spelling import _is_literal_zero, binding_name def _speculable(node: Node) -> bool: @@ -160,21 +159,15 @@ def _dispatch(self, node: Dispatch) -> str: self._selector(kname, node.selector, node.K) branch_texts = [self._ref(b) for b in node.branches] if not node.ttype.shape: - return render_dispatch_rank0(kname, branch_texts, "predicated") - expr = branch_texts[-1] - for j in range(len(branch_texts) - 2, -1, -1): - cond = self._hoist(f"aether::constant>" - f"({kname} == {j})") - expr = f"aether::select({cond}, {branch_texts[j]}, {expr})" - return expr + return self.dialect.predicated_rank0(kname, branch_texts) + return self.dialect.predicated_tensor(self, kname, branch_texts) def _selector(self, kname: str, selector: Node, k: int) -> None: """Emit ``kname``, the selector clamped to ``[0, k)``; no clamp for a select between two in-range literals.""" ref = self._ref(selector) - spelled = (f"static_cast({ref})" if _in_range(selector, k) - else _clamp_expr(ref, k)) - self._emit(f"const std::int32_t {kname} = {spelled};") + self._emit(self.dialect.selector_decl(self, kname, ref, k, + in_range=_in_range(selector, k))) def _disp_pos(self, node: Dispatch): """The suffix a dispatch's emitted names carry: its canonical position, @@ -220,7 +213,7 @@ def _dispatch_switches(self, nodes: Sequence[Dispatch]) -> list: for node, name in zip(nodes, names): mix = self._mix_plane(node) if mix is not None: - self._emit(f"hawk_abi::mix_count({mix}, {k}, {kname});") + self._emit(self.dialect.mix_count(mix, k, kname)) elided = [frozenset(j for j, b in enumerate(n.branches) if _is_literal_zero(b)) for n in nodes] shown = [i for i, n in enumerate(nodes) if id(n) not in hidden] @@ -235,7 +228,7 @@ def _dispatch_switches(self, nodes: Sequence[Dispatch]) -> list: + [r for _t, a, b in stmts[j] for r in (a, b)] for j in range(k)]) for i in shown: - self._emit(f"{carry_type(nodes[i].ttype)} {names[i]};") + self._emit(f"{self.dialect.carry_type(nodes[i].ttype)} {names[i]};") self._emit(f"switch ({kname}) {{") outer, self.indent = self.indent, self.indent + " " for j in range(k): @@ -244,21 +237,28 @@ def _dispatch_switches(self, nodes: Sequence[Dispatch]) -> list: self._emit(f"case {j}: {{") self._emit_case_body( [(names[i], nodes[i].branches[j]) for i in active[j]], - [names[i] + " = " + self._typed_zero(nodes[i].ttype) + [names[i] + " = " + self.dialect.typed_zero(nodes[i].ttype) for i in shown if j in elided[i]], stmts[j]) self._emit("break;") self._emit("}") + tail = self.dialect.switch_default_tail() if plain: self._emit("default: {") self._emit_case_body( [(names[i], nodes[i].branches[last]) for i in active[last]], - [names[i] + " = " + self._typed_zero(nodes[i].ttype) + [names[i] + " = " + self.dialect.typed_zero(nodes[i].ttype) for i in shown if last in elided[i]], stmts[last]) + if tail: + self.indent += " " + self._emit(tail) + self.indent = self.indent[:-4] self._emit("}") else: for n, i in enumerate(shown): label = "default: " if n == 0 else "" - self._emit(f"{label}{names[i]} = {self._typed_zero(nodes[i].ttype)};") + self._emit(f"{label}{names[i]} = {self.dialect.typed_zero(nodes[i].ttype)};") + if tail: + self._emit(tail) self.indent = outer self._emit("}") return [None if id(n) in hidden else name for n, name in zip(nodes, names)] @@ -333,7 +333,8 @@ def _emit_case_body(self, assigns: list, zeros: list, stmts: Sequence = ()) -> N for name, root in assigns: self._emit(f"{name} = {self._ref(root)};") for target, at, value in stmts: - self._emit(f"{target}[i].at({self._ref(at)}) += {self._ref(value)};") + self._emit(self.dialect.in_place_accumulate( + self, target, self._ref(at), self._ref(value))) for zero in zeros: self._emit(f"{zero};") self.local, self.items = saved_local, saved_items @@ -384,6 +385,8 @@ def _should_name(self, node: Node, fanout: int) -> bool: return False if isinstance(node, (Loop, Const, Primitive)): return False + if self.dialect.names_every_tensor and node.ttype.shape: + return True return fanout >= 2 or self._leaf_valued(node) @staticmethod @@ -423,18 +426,7 @@ def _render_nodes(self, order: Sequence[Node], tally: dict) -> None: if named: name = f"tb{self.temps}" self.temps += 1 - self._emit(f"const auto {name} = {text};") + self._emit(self.dialect.declare(name, node.ttype, text)) self.local[id(node)] = name else: self.local[id(node)] = text - - def _typed_zero(self, ttype: TensorType) -> str: - """The typed zero the ``switch`` ``default:`` writes when at - least one branch was elided — the same spelling :meth:`_broadcast` - builds, inlined rather than hoisted, since it's assigned straight - into the declared local, which copies it out immediately.""" - elem = element_spelling(ttype.dtype) - if not ttype.shape: - return f"static_cast<{elem}>(0)" - ext = ", ".join(str(e) for e in ttype.shape) - return f"aether::constant>(static_cast<{elem}>(0))" diff --git a/hawk/runtime.py b/hawk/runtime.py index 1ef6f7e..c9a8e27 100644 --- a/hawk/runtime.py +++ b/hawk/runtime.py @@ -440,11 +440,23 @@ def _index_ctype(): def load(directory, kernel: str, sidecar: dict | None = None) -> HostKernel: - """Load ``/.so``, reading its sidecar JSON if omitted.""" + """Load ``/.so``, reading its sidecar JSON if omitted. + + Refuses a directory holding only ``kernel``'s standalone C unit (a + ``.embedded.json`` and no ``.json``): that is source for + an embedded build, not a host artifact.""" import json directory = Path(directory) if sidecar is None: + if (not (directory / f"{kernel}.json").exists() + and (directory / f"{kernel}.embedded.json").exists()): + raise HawkError( + f"{kernel!r} in {directory} was built for the 'c' target only: " + "it is a standalone C unit (.c, .h, " + ".embedded.json, see hawk.embedded) for an embedded build, " + "not a host artifact hawk.load can run; build it with " + "targets=('host',) to load it here") sidecar = json.loads((directory / f"{kernel}.json").read_text()) return HostKernel(directory / f"{kernel}.so", sidecar) diff --git a/hawk/trace/kernel.py b/hawk/trace/kernel.py index 277c30d..bb9baee 100644 --- a/hawk/trace/kernel.py +++ b/hawk/trace/kernel.py @@ -17,6 +17,7 @@ from __future__ import annotations import ast +import hashlib import inspect import sys import textwrap @@ -57,12 +58,13 @@ class Kernel: """A traced kernel: its sink set, its canonical walk and its declarations.""" __slots__ = ("name", "sinks", "walk", "planes", "kind", "step", "one_step", - "time", "time_step", "jump") + "time", "time_step", "source_digest", "jump") def __init__(self, name: str, sinks: tuple[Node, ...], planes: dict[str, Any] | None = None, *, kind: Any = None, step: Kernel | None = None, - one_step: Kernel | None = None) -> None: + one_step: Kernel | None = None, + source_digest: str | None = None) -> None: from ..ext import DEFAULT_KIND self.name = name @@ -82,6 +84,10 @@ def __init__(self, name: str, sinks: tuple[Node, ...], #: by the taping twin, never by the plain kernel's emission. self.time: str | None = None self.time_step: str | None = None + #: The sha256 of the traced function's dedented source (see + #: :func:`source_digest`); ``None`` for a hand-built kernel or one + #: whose source cannot be read. Traceability only. + self.source_digest = source_digest #: The width of the derivative through the kernel's switches #: (``@hawk.kernel(jump=...)``): a number, ``"auto"`` or a Param #: name; ``None`` (a switch has derivative zero) otherwise. Read by @@ -212,7 +218,7 @@ def resolve_dispatch(kernel: Kernel, policies: dict[str, str]) -> Kernel: f"'switch', 'segmented', 'predicated', got {policy!r}") chosen[id(node)] = policy resolved = Kernel(step.name, with_policies(step.sinks, chosen), step.planes, - kind=step.kind) + kind=step.kind, source_digest=step.source_digest) resolved.time, resolved.time_step = step.time, step.time_step resolved.jump = step.jump if kernel.step is None or "segmented" in chosen.values(): @@ -258,7 +264,19 @@ def _trace(fn: Callable[..., None], kind: Any) -> Kernel: extra = _compensated_companion(resolved_kind, planes, vocab, sinks) if extra is not None: sinks = sinks + (extra,) - return Kernel(fn.__name__, sinks, planes, kind=resolved_kind) + return Kernel(fn.__name__, sinks, planes, kind=resolved_kind, + source_digest=source_digest(fn)) + + +def source_digest(fn: Callable[..., Any]) -> str | None: + """The sha256 hex digest of ``fn``'s dedented source, as + :func:`inspect.getsource` returns it (decorators included), or ``None`` + when the source cannot be read.""" + try: + text = textwrap.dedent(inspect.getsource(fn)) + except (OSError, TypeError): + return None + return hashlib.sha256(text.encode()).hexdigest() def _retag_by_key(planes: dict[str, Any], args: dict[str, Any]) -> None: @@ -341,7 +359,8 @@ def _returned_kernel(fn: Callable[..., Any], kind: Any) -> Kernel: extra = _compensated_companion(kind, planes, vocab, sinks) if extra is not None: sinks = sinks + (extra,) - return Kernel(fn.__name__, sinks, planes, kind=kind) + return Kernel(fn.__name__, sinks, planes, kind=kind, + source_digest=source_digest(fn)) def _own_declarations(fn: Callable[..., None], scope: dict[str, Any] diff --git a/hawk/trace/steps.py b/hawk/trace/steps.py index 0e51f0b..ef57de6 100644 --- a/hawk/trace/steps.py +++ b/hawk/trace/steps.py @@ -198,7 +198,8 @@ def _derive(kernel: Any, K: int | str, out_name: str, *, step: Any = None) -> An done = make("gt", (LoopValue(loop, len(commits)), Const(0.5, _FLAG))) out.append(Finish(finish.name, done, steps=K)) return Kernel(out_name, tuple(out), kernel.planes, kind=kernel.kind, - step=step, one_step=kernel if auto else None) + step=step, one_step=kernel if auto else None, + source_digest=getattr(kernel, "source_digest", None)) def _clamped_word() -> Node: diff --git a/pyproject.toml b/pyproject.toml index 8015f15..842b266 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,8 +53,9 @@ classifiers = [ # AETHER payload every NVRTC device compile serves headers from # (`hawk.compile.payload.current_payload()`), added the day the code that # uses it landed rather than pre-declared ahead of it. +# aether-dsc 0.2.7 brings `aether_dsc.c_core`, which the `c` target imports. # numpy: hawk never imports it, but the host route and every example pass NumPy arrays -dependencies = ["aether-dsc>=0.2.4,<0.3", "numpy>=1.26"] +dependencies = ["aether-dsc>=0.2.7,<0.3", "numpy>=1.26"] [project.optional-dependencies] # numpy is a TEST dependency only: the finite-difference row evaluates a diff --git a/tests/_deployable.py b/tests/_deployable.py index 1fb42aa..7a8e310 100644 --- a/tests/_deployable.py +++ b/tests/_deployable.py @@ -126,6 +126,16 @@ def diagnostic_two(x: Scalar, terminated: Terminated, rejected: Terminated, y = x + 1.0 +@kernel +def two_fin(x: Scalar, lim: Scalar, terminated: Terminated, rejected: Terminated, + y: Mutable[Scalar]): + """ fixture: two masks AND a finish in one kernel. Under :data:`TWO_MASKS` + the guard skips a sample flagged by either mask; the finish sets only + ``terminated``. Automatic (no ``steps=``): export ``two_fin.step``.""" + y = x * 2.0 + terminated = y > lim + + @kernel def fraction(x: Scalar, y: Mutable[Scalar]): """The subject: a body that READS the sample count, so the ``nsamples`` diff --git a/tests/_embedded.py b/tests/_embedded.py new file mode 100644 index 0000000..a9a43e0 --- /dev/null +++ b/tests/_embedded.py @@ -0,0 +1,200 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The ctypes harness that CALLS an exported C unit the way a caller would. + +:func:`hawk.embedded.export` writes ``.c``/``.h`` and the report +whose ``interface`` rows (one per ``arg_spec`` slot, in order) describe the +args struct field for field. This module builds that struct from the report +(:class:`ArgsStruct`), compiles the unit into a shared library +(:func:`cc_shared`) and runs it over a whole range or a partition +(:func:`run_c`), handing back the output planes in ``arg_spec`` order. + +THE PLANE LAYOUT IS THE ORACLE'S, AND IT IS THE C ABI'S. ``_oracle.planes()`` +(``tests/_oracle.py``'s ``_shape``) allocates a rank>=1 plane as a ``(W, n)`` +C-contiguous array with ``W = prod(shape)`` in row-major component order, so a +``Matrix[R, C]`` plane is ``(R*C, n)`` and component ``(r, c)`` is row +``r*C + c``; a rank-0 plane is ``(n,)``. Component ``c`` of sample ``i`` is +then at flat offset ``c * n + i``: exactly the header's SoA contract, +``base[(c * n_samples) + i]``, pitch ``n_samples``. The same array can be +handed to both sides with no reshuffle, which is what lets a row compare +``tobytes()``. + +It does not import ``_oracle`` (that module exists to build and run host +artifacts, which the interpreter row must not depend on): the allocation +rules are restated from the report's rows instead. + +* a supplied plane is used as given (``np.ascontiguousarray``; a real plane + cast to the mode's width as ``_oracle._host_plane`` casts it, an integer or + mask plane at its own dtype; a ``bool`` mask is VIEWED as ``uint8``, so an + ``inout`` mask the finish sets is written into the caller's array); +* an absent output plane (``out``/``mutable``/``wide_out``/``accum_out``) is + zero-filled at ``(W, n)`` / ``(n,)``, integers as ``int64``; +* an absent counter (``finished_count``, a branch-mix plane) is zero-filled + at ``prod(shape)`` cells (the caller zeroes it, the header says); +* any other absent plane is an error naming it. +""" + +from __future__ import annotations + +import ctypes +import math +import sys +from pathlib import Path + +import numpy as np + +#: The real element per scalar mode: ``(ctypes type, numpy dtype)``. +_REAL = {"float64": (ctypes.c_double, np.float64), + "float32": (ctypes.c_float, np.float32)} +#: A non-real element per IR dtype. An index (``i32``/``i64``) is the 64-bit +#: ``hawk_int_t``; a ``bool`` plane is one byte. +_ELEM = {"i32": (ctypes.c_int64, np.int64), "i64": (ctypes.c_int64, np.int64), + "int32": (ctypes.c_int32, np.int32), "uint32": (ctypes.c_uint32, np.uint32), + "bool": (ctypes.c_uint8, np.uint8)} +_OUTPUT_ROLES = ("out", "mutable", "wide_out", "accum_out") +_SHARED_SUFFIX = ".dylib" if sys.platform == "darwin" else ".so" + + +def corpus(): + """``(name, sinks, walk)`` of every C-target corpus case: + ``tests/_emitted.cases()`` without ``energy`` (a sum over samples, refused).""" + import _emitted + + return [c for c in _emitted.cases() if c[0] != "energy"] + + +def cc_shared(c_path, tmp) -> Path: + """Compile the exported ``c_path`` into a shared library in ``tmp`` + (:func:`hawk.embedded.toolchain.compile_shared`: c99, -O2, no FP + contraction).""" + from hawk.embedded.toolchain import compile_shared + + c_path = Path(c_path) + return compile_shared(c_path, Path(tmp) / f"{c_path.stem}{_SHARED_SUFFIX}") + + +def _mode(report, mode) -> str: + return str(mode if mode is not None else report["scalar_mode"]) + + +def element(row: dict, mode: str): + """``(ctypes type, numpy dtype)`` of one interface row's element.""" + if row["role"] == "terminated": + return _ELEM["bool"] + if row["dtype"] in ("f64", "f32"): + return _REAL[mode] + return _ELEM[row["dtype"]] + + +def _rows(report) -> list: + return [r for r in report["interface"] if r["role"] != "nsamples"] + + +class ArgsStruct(ctypes.Structure): + """Base of every ``_args_t`` mirror; :meth:`from_report` makes one.""" + + @classmethod + def from_report(cls, report: dict, mode=None) -> type[ctypes.Structure]: + """The ``_args_t`` struct of ``report``, fields in interface + order: a pointer per plane (``POINTER(element)``) followed by + ``int64_t _len`` when the plane has a runtime length; a + by-value field per ``uniform`` (the mode's real, or the 64-bit int of + an integer Param). The ``nsamples`` row is the entry's own + ``n_samples`` argument, not a field.""" + mode = _mode(report, mode) + fields = [] + for row in _rows(report): + if row["layout"] == "value": + real = row["dtype"] in ("f64", "f32") + fields.append((row["field"], _REAL[mode][0] if real else ctypes.c_int64)) + continue + fields.append((row["field"], ctypes.POINTER(element(row, mode)[0]))) + if row["runtime_length"]: + fields.append((f"{row['field']}_len", ctypes.c_int64)) + return type(f"{report['kernel']}_args_t", (cls,), {"_fields_": fields}) + + +_LIBS: dict = {} + + +def _entry(so_path, name: str): + so_path = str(Path(so_path).resolve()) + lib = _LIBS.get(so_path) + if lib is None: + lib = _LIBS[so_path] = ctypes.CDLL(so_path) + return getattr(lib, f"{name}_c") + + +def _cells(row: dict, n: int) -> tuple: + if row["layout"] == "partials" or not row["shape"]: + return (n,) + return (math.prod(row["shape"]), n) + + +def _bind(row: dict, n: int, mode: str, planes: dict): + """``(array the caller sees, array the C side reads)`` for one plane row.""" + ctype, dtype = element(row, mode) + name = row["name"] + if name in planes: + arr = np.ascontiguousarray(planes[name]) + if row["role"] == "terminated" or arr.dtype == np.bool_: + assert arr.dtype.itemsize == 1, f"{name!r}: a mask is one byte per sample" + return arr, arr.view(np.uint8) + if arr.dtype != dtype: + assert row["direction"] == "in", ( + f"{name!r} is {row['direction']}: pass it as {np.dtype(dtype)}, not " + f"{arr.dtype} (a cast copy would hide the C side's writes)") + arr = arr.astype(dtype) + return arr, arr + if row["role"] == "lookup" and row["direction"] == "inout": + arr = np.zeros(math.prod(row["shape"]) if row["shape"] else 1, dtype=dtype) + return arr, arr + if row["role"] in _OUTPUT_ROLES: + arr = np.zeros(_cells(row, n), dtype=dtype) + return arr, arr + raise KeyError(f"no plane supplied for {name!r} ({row['role']}, {row['field']})") + + +def run_c(so_path, name: str, report: dict, n: int, /, *, base: int = 0, + count: int | None = None, mode=None, **planes): + """Call ``_c`` over ``[base, base + count)`` of ``n`` samples and + return the planes it may write (direction ``out``/``inout``) in + ``arg_spec`` order: the plane itself for a single one, never a 1-tuple + (``_oracle.returned``'s rule). Asserts ``HAWK_OK``. + + ``planes`` maps each plane's NAME (the interface row's ``name``, as + ``_oracle.run_kernel`` takes it) to an array, and each ``uniform`` to its + value; see the module docstring for what is allocated when absent. A + supplied writable plane is written in place. The four leading parameters + are positional-only, so a plane may be named ``n`` (``tape(spin)``'s + integer counter is).""" + mode = _mode(report, mode) + struct = ArgsStruct.from_report(report, mode) + args = struct() + keep: list = [] # ctypes holds no reference: keep every buffer alive + returned = [] + for row in _rows(report): + if row["layout"] == "value": + setattr(args, row["field"], planes[row["name"]]) + continue + seen, raw = _bind(row, n, mode, planes) + keep.append(raw) + setattr(args, row["field"], + raw.ctypes.data_as(ctypes.POINTER(element(row, mode)[0]))) + if row["runtime_length"]: + setattr(args, f"{row['field']}_len", int(raw.shape[-1])) + if row["direction"] != "in": + returned.append(seen) + entry = _entry(so_path, name) + entry.restype = ctypes.c_int32 + entry.argtypes = [ctypes.POINTER(struct), ctypes.c_int64, ctypes.c_int64, + ctypes.c_int64] + status = entry(ctypes.byref(args), base, n if count is None else count, n) + assert status == 0, ( + f"{name}_c returned status {status} (1 = HAWK_E_NULL, 2 = HAWK_E_RANGE) " + f"for base={base}, count={count}, n_samples={n}") + del keep + if not returned: + raise AssertionError(f"{name}: its interface declares no written plane") + return returned[0] if len(returned) == 1 else tuple(returned) diff --git a/tests/test_build_unknown_target.py b/tests/test_build_unknown_target.py index f41c9b2..7c272a5 100644 --- a/tests/test_build_unknown_target.py +++ b/tests/test_build_unknown_target.py @@ -23,5 +23,5 @@ def test_unknown_target_lists_the_valid_names(tmp_path): build(_identity, tmp_path / "unit", targets=("device",)) text = str(err.value) assert "unknown target 'device'" in text - assert "'cuda' and 'host'" in text + assert "'cuda', 'host' and 'c'" in text assert "GPU" in text and "CPU" in text diff --git a/tests/test_embedded_audit.py b/tests/test_embedded_audit.py new file mode 100644 index 0000000..1ddb5b0 --- /dev/null +++ b/tests/test_embedded_audit.py @@ -0,0 +1,265 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The emitted-text audit of the standalone C target (``hawk.embedded.audit``). + +Rows: + +* **clean** — every rendered unit the C target builds (the corpus in both + modes, a loop with each break form, a switch dispatch, a vjp through a + head break) audits with no hit; +* **planted** — each rule fires on a real unit mutated in one place: a + banned token (``malloc(``, ``#pragma``), a second loop-level ``break``, a + ``switch`` clause that falls through, an unreferenced ``static inline`` + helper, a ``//`` comment, a disallowed include, a ``= {`` brace + initialiser, two header identifiers that agree in their first 31 + characters; +* **export** — :func:`hawk.embedded.export` refuses an audit hit by name + (rule and line) and writes nothing; +* **vocabulary** — the banned list carries every launch-geometry token, + imported rather than respelled. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import _kernels as K +import pytest + +from hawk.diff import vjp +from hawk.embedded import audit, render +from hawk.emit.spelling import GEOMETRY_TOKENS +from hawk.ir import HawkError, canonical + +MODES = ("float64", "float32") +CORPUS = [n for n in K.CORPUS if n != "energy"] + + +def _derived(name, sinks): + return SimpleNamespace(name=name, sinks=tuple(sinks), walk=canonical(sinks), + kind=None) + + +def _rules(hits): + return {rule for _line, rule, _text in hits} + + +def _audit(source, c_text=None, h_text=None): + return audit.audit(source.text if c_text is None else c_text, + source.header if h_text is None else h_text, source.name) + + +# --------------------------------------------------------------------------- # +# clean +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("name", CORPUS) +def test_every_corpus_unit_audits_clean(name, mode): + assert _audit(render(K.CORPUS[name], mode=mode)) == [] + + +def _loop_and_dispatch_units(): + import test_dispatch as DI + import test_finish_own_sample as F + import test_loop_early_exit as E + + yield from (E.head, E.middle, E.tail, E.gated_sum, E.trajectory, E.vec_head) + yield from (DI.disp_r0_k5_switch, DI.disp_r1_k3_switch, DI.disp_int_switch, + DI.disp_r0_k5_pred) + yield from (F.osc_finish, F.branch_finish) + yield _derived("head_vjp", vjp(E.head, wrt=("x",))) + yield _derived("drag_vjp", K.drag_vjp()) + + +@pytest.mark.parametrize("mode", MODES) +def test_loop_dispatch_and_finish_units_audit_clean(mode): + for k in _loop_and_dispatch_units(): + s = render(k, mode=mode) + assert _audit(s) == [], s.name + + +# --------------------------------------------------------------------------- # +# planted +# --------------------------------------------------------------------------- # +def test_a_helper_calling_malloc_is_a_banned_token(monkeypatch): + from hawk.embedded import abi + + real = abi.helper_closure + + def planted(dialect): + text, names, libm = real(dialect) + return text.replace("out.v[0] = plane[i];", + "out.v[0] = plane[i];\n (void)malloc(8U);", 1), names, libm + + monkeypatch.setattr(abi, "helper_closure", planted) + s = render(K.drag) + assert "malloc(" in s.text + hits = _audit(s) + assert [(rule, "malloc" in text) for _l, rule, text in hits] == [("audit.token", True)] + (line, _rule, _text) = hits[0] + assert "malloc" in s.text.splitlines()[line - 1] + + +def test_a_pragma_is_a_banned_token(): + s = render(K.drag) + hits = _audit(s, c_text=s.text.replace('#include ', + '#pragma once\n#include ')) + assert _rules(hits) == {"audit.token"} + assert any("#pragma" in text for _l, _r, text in hits) + + +def test_a_word_that_merely_contains_a_banned_token_is_not_a_hit(): + s = render(K.drag) + planted = s.text.replace("static void drag_run", + "static void drag_run_freely_unioned", 1) + planted = planted.replace("drag_run(args", "drag_run_freely_unioned(args", 1) + assert _audit(s, c_text=planted) == [] + + +def test_a_banned_token_inside_a_comment_is_not_a_hit(): + """The header's own contract says ``allocation-free``: comments are prose.""" + s = render(K.drag) + assert "allocation-free" in s.header + assert _audit(s) == [] + + +def test_a_cxx_namespace_is_a_banned_token(): + s = render(K.drag) + planted = s.text.replace("sqrt(", "std::sqrt(", 1) + assert _rules(_audit(s, c_text=planted)) == {"audit.token"} + + +def test_a_second_break_at_the_loops_level_is_refused(): + import test_loop_early_exit as E + + s = render(E.middle) + assert s.text.count("break;") == 1 + planted = s.text.replace("hawk_v5_1_t = hawk_n5_1;\n", + "hawk_v5_1_t = hawk_n5_1;\n" + " if (tb3 > psc_lim_i) {\n" + " break;\n" + " }\n", 1) + assert planted.count("break;") == 2 + hits = _audit(s, c_text=planted) + assert _rules(hits) == {"audit.break"} + + +def test_a_loop_without_a_break_and_one_with_a_switch_pass(): + """The sample loop has no ``break`` of its own; a switch's clause breaks + belong to the switch.""" + import test_dispatch as DI + + s = render(DI.disp_r0_k5_switch) + assert s.text.count("break;") == 5 and "for (" in s.text + assert _audit(s) == [] + + +def test_a_switch_clause_without_a_break_is_refused(): + import test_dispatch as DI + + s = render(DI.disp_r0_k5_switch) + body = s.text.split("default: {", 1)[1] + planted_default = body.replace(" break;\n", "", 1) + planted = s.text.split("default: {", 1)[0] + "default: {" + planted_default + assert planted.count("break;") == 4 + assert _rules(_audit(s, c_text=planted)) == {"audit.switch"} + + +def test_a_switch_without_a_default_is_refused(): + import test_dispatch as DI + + s = render(DI.disp_r0_k5_switch) + planted = s.text.replace("default: {", "case 4: {", 1) + assert _rules(_audit(s, c_text=planted)) == {"audit.switch"} + + +def test_an_unreferenced_static_inline_helper_is_refused(): + s = render(K.drag) + planted = s.text.replace( + "static void drag_run", + "static inline int64_t hawk_orphan(int64_t a)\n{\n return a;\n}\n\n" + "static void drag_run", 1) + hits = _audit(s, c_text=planted) + assert _rules(hits) == {"audit.unused"} + assert any("hawk_orphan" in text for _l, _r, text in hits) + + +def test_a_line_comment_is_refused_in_either_file(): + s = render(K.drag) + in_c = s.text.replace("static void drag_run", "// the run\nstatic void drag_run", 1) + in_h = s.header.replace("#endif\n", "#endif // guard\n", 1) + assert _rules(_audit(s, c_text=in_c)) == {"audit.comment"} + assert _rules(_audit(s, h_text=in_h)) == {"audit.comment"} + + +def test_a_brace_initialiser_is_refused(): + """``audit.init`` keeps the unit free of ``= {``, which is what lets the + unit-wide Rule 9.3 deviation stand.""" + s = render(K.drag) + planted = s.text.replace(" const int64_t hawk_end = base + count;\n", + " const int64_t hawk_end = base + count;\n" + " hawk_real_t hawk_pad[2] = {0};\n", 1) + hits = _audit(s, c_text=planted) + assert _rules(hits) == {"audit.init"} + (line, _rule, text) = hits[0] + assert "hawk_pad[2] = {0};" in planted.splitlines()[line - 1] and "drag.c" in text + # a comparison or a brace in a comment is not an initialiser + assert _audit(s, c_text=s.text.replace( + "static void drag_run", "/* x = { } */\nstatic void drag_run", 1)) == [] + + +def test_a_disallowed_include_is_refused(): + s = render(K.drag) + planted = s.text.replace("#include ", "#include \n#include ", 1) + hits = _audit(s, c_text=planted) + assert _rules(hits) == {"audit.include"} + assert any("stdio.h" in text for _l, _r, text in hits) + other = s.text.replace('#include "drag.h"', '#include "other.h"', 1) + assert _rules(_audit(s, c_text=other)) == {"audit.include"} + + +def test_header_identifiers_must_differ_in_their_first_31_characters(): + """A 31-character kernel name makes ``_c`` and ``_args_t`` + agree in their first 31 characters (MISRA C:2012 Rule 5.1).""" + long = "k" * 31 + s = render(K.drag, name=long) + hits = _audit(s) + assert _rules(hits) == {"audit.ident"} + assert render(K.drag, name="k" * 20).name and _audit(render(K.drag, name="k" * 20)) == [] + + +# --------------------------------------------------------------------------- # +# the export door refuses a hit +# --------------------------------------------------------------------------- # +def test_export_refuses_an_audit_hit_by_rule_and_line(monkeypatch, tmp_path): + from hawk import embedded + from hawk.embedded import abi + + real = abi.helper_closure + + def planted(dialect): + text, names, libm = real(dialect) + return text + "\nstatic inline int64_t hawk_orphan(int64_t a)\n{\n return a;\n}", names, libm + + monkeypatch.setattr(abi, "helper_closure", planted) + with pytest.raises(HawkError, match=r"'drag': the C target refuses line \d+ under " + r"audit\.unused: .*hawk_orphan"): + embedded.export(K.drag, tmp_path, check=False) + assert list(tmp_path.iterdir()) == [] + + +# --------------------------------------------------------------------------- # +# vocabulary +# --------------------------------------------------------------------------- # +def test_banned_tokens_carry_every_geometry_token(): + assert set(GEOMETRY_TOKENS) <= set(audit.BANNED_TOKENS) + for token in ("malloc", "goto", "union", "volatile", "eagle", "aether::", + "std::", "#pragma", "tgmath", 'extern "C"'): + assert token in audit.BANNED_TOKENS + assert "aether" not in audit.BANNED_TOKENS + + +def test_the_include_whitelist(): + assert set(audit.INCLUDE_WHITELIST) == {"", "", "", + "", ""} diff --git a/tests/test_embedded_bit_identity.py b/tests/test_embedded_bit_identity.py new file mode 100644 index 0000000..4c914f9 --- /dev/null +++ b/tests/test_embedded_bit_identity.py @@ -0,0 +1,757 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The standalone C target computes the SAME BITS as hawk's own host build. + +The C unit (:func:`hawk.embedded.export`) is compiled into a shared library +(:func:`_embedded.cc_shared`) and called through its args struct +(:func:`_embedded.run_c`); the host build runs the same kernel through +``hawk._core``. Both are serial, both are built without floating-point +contraction (``-ffp-contract=off``; the host under the ``native`` profile), and +both evaluate the formula table of the design spec, so the comparison is +``tobytes()`` equality, never a band. + +Three groups: + +* **the deployable fixtures** (session ``built`` fixture, which compiles the + cuda target too, so these rows run on Linux CI): every in-scope + ``_deployable.cases(N)`` case against ``_oracle.run_kernel`` on the built + host artifact; ``energy`` (a sum over samples) is not in scope, and its refusal, + with every other feature the C target refuses, is asserted by name; +* **the loop / dispatch / finish / derivative corpus** (this file's own + host-only bundles, one per access class per mode, built once per session): + head, middle and tail breaks, a per-sample stop running past the cap, the + vjp through a head and a tail break (the reverse loop's RUNTIME trip count, + clamped by ``hawk_clamp_trips``), switch / predicated / auto dispatch with + the branch-mix plane, finishing kernels and ``hawk.steps(k, 4)``, ``vjp`` + of ``drag``, two ``tape()`` twins, a vocabulary sweep, ``Vector / Vector``, + two masks and a finish in one kernel (under ``TWO_MASKS``, its own bundle) + and own-column partials under a mask guard, in float64 and float32, every + writable plane compared (masks and counters too), and every plane under a + mask guard checked to keep the caller's value at a masked sample; plus a + partition run (``base=2, count=3, n=8``) whose untouched samples must stay + zero on both sides; +* **the integer kernels on their edge grids** (the same host-only bundles): + ``test_int_planes``' ``ops_i32``, ``ops_u32``, ``casts`` (Param ``s`` in + ``{-5, 7, 2^31 - 1}``), ``scaled`` and two Index kernels + (``index_arith``: ``+ - *`` and negation; ``index_divshift``: ``// % << >>``), + in float64 and float32, each fed the boundary grid its operators are pinned + at (``INT32_EDGES``/``UINT32_EDGES`` pairs; NaN, +-inf, +-0, 2^31 +- 0.5, + 2^32 +- 0.5, +-2^63 and 1e300 for the casts; a 16x16 Index grid reaching + +-2^31 and +-2^40), never a random draw. Int32/UInt32 arithmetic and the + Index ``// % << >>`` are a wrapping helper on both sides, so they compare + over the whole grid. Index ``+ - *`` and negation identity is "where the C++ + is defined": the C++ spells them infix (signed overflow is undefined) and + the C wraps them through ``hawk_i64_*`` (the report's ``int_ub_sites`` and + ``defined_where_cpp_is_ub`` note). So the split: the host comparison runs + ``index_arith`` only on the pairs whose results stay inside int64, and a + C-only row runs it at the overflow points (the grid plus the int64 + extremes) against NumPy's int64 wrapping arithmetic. ``abs``/``minimum``/ + ``maximum`` on an Index are not in the grid: they do not compile on the + host, so the C's NumPy semantics for them have no host twin; +* **the interpreter row** (no host artifact and no ``hawk.runtime`` on its + path; this module itself imports ``hawk``, which loads ``hawk._core``, so + the row still needs the extension installed): the C library against + ``tests/_eval.evaluate`` per sample, within the ``S x 2 x eps`` band of + ``test_serial_oracle`` (restated, not imported: one test module does not + import another). + +Every failing row names the kernel, the mode, the plane, the first differing +(component, sample) and both bit patterns. +""" + +from __future__ import annotations + +import functools +import pathlib + +import _deployable as D +import _embedded as E +import _kernels as K +import numpy as np +import pytest + +import hawk +from hawk import Index, Mutable, Param, Scalar, Terminated, Vector +from hawk.embedded import export +from hawk.ir import HawkError +from hawk.ir.nodes import AccumWrite, Leaf +from hawk.ir.ops import make +from hawk.trace.kernel import Kernel +from hawk.types import TensorType + +N = 96 +SEED = 20261016 +MODES = ("float64", "float32") +_NP = {"float64": np.float64, "float32": np.float32} +_EPS = {"float64": np.finfo(np.float64).eps, "float32": np.finfo(np.float32).eps} + + +# --------------------------------------------------------------------------- # +# comparison +# --------------------------------------------------------------------------- # +def _bits(value: np.ndarray) -> str: + raw = np.ascontiguousarray(value).reshape(1).view(np.uint8).tobytes() + return "0x" + raw[::-1].hex() + + +def first_difference(got, want): + """``None``, or ``(component, sample, got value, want value)`` of the first + element whose BYTES differ (so ``-0.0`` vs ``0.0`` and differing NaN + payloads count).""" + g, w = np.ascontiguousarray(got), np.ascontiguousarray(want) + if g.tobytes() == w.tobytes(): + return None + size = g.dtype.itemsize + bad = np.flatnonzero((g.reshape(-1).view(np.uint8) != w.reshape(-1).view(np.uint8)) + .reshape(-1, size).any(axis=1))[0] + shape = g.shape if g.ndim == 2 else (1, g.shape[0]) + comp, sample = np.unravel_index(bad, shape) + return int(comp), int(sample), g.reshape(-1)[bad:bad + 1], w.reshape(-1)[bad:bad + 1] + + +def assert_bit_identical(got, want, *, kernel: str, mode: str, plane: str) -> None: + g, w = np.ascontiguousarray(got), np.ascontiguousarray(want) + assert g.dtype == w.dtype and g.shape == w.shape, ( + f"{kernel} [{mode}] plane {plane!r}: shape/dtype disagree, C " + f"{g.shape}/{g.dtype} vs host {w.shape}/{w.dtype}") + diff = first_difference(g, w) + if diff is None: + return + comp, sample, gv, wv = diff + count = int(np.sum((g.reshape(-1).view(np.uint8) != w.reshape(-1).view(np.uint8)) + .reshape(-1, g.dtype.itemsize).any(axis=1))) + raise AssertionError( + f"{kernel} [{mode}] plane {plane!r}: NOT bit-identical to the host build; " + f"first difference at component {comp}, sample {sample}: C {gv[0]!r} " + f"({_bits(gv)}) vs host {wv[0]!r} ({_bits(wv)}); {count} element(s) " + "differ. Both sides are serial and uncontracted, so this is a C spelling " + "that evaluates a different formula than aether's host path") + + +def _tuple(value): + return value if isinstance(value, tuple) else (value,) + + +def _written(report) -> list: + """The interface rows a call may write, in interface order.""" + return [r for r in report["interface"] + if r["role"] != "nsamples" and r["direction"] != "in"] + + +# --------------------------------------------------------------------------- # +# the C side, compiled once per (kernel, mode) per session +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="session") +def c_units(tmp_path_factory): + """``unit(kernel, mode, kind=None) -> (so_path, report)``, cached. The + export runs its own checks (``check=True``: strict cc and cppcheck), so + the unit compared is the unit a caller would ship.""" + root = pathlib.Path(tmp_path_factory.mktemp("embedded_c")) + cache: dict = {} + + def unit(kernel, mode, kind=None): + key = (kernel.name, mode, getattr(kind, "id", kind)) + if key not in cache: + where = root / f"{kernel.name}_{mode}" + art = export(kernel, where, mode=mode, kind=kind) + cache[key] = (E.cc_shared(art.c_path, where), art.report) + return cache[key] + + return unit + + +# --------------------------------------------------------------------------- # +# group 1: the deployable fixtures against the session `built` bundles (CI) +# --------------------------------------------------------------------------- # +#: The fixture cases the C target builds. ``energy`` is a sum over samples (refused, +#: asserted below); ``scatter_c`` is not a ``cases()`` entry, and its +#: compensated build is refused below too. +_OUT_OF_SCOPE = {"energy"} +_CASES = [c for c in D.cases(N) if c[1] not in _OUT_OF_SCOPE] + + +@pytest.mark.parametrize("bundle_name,kernel,kw,_want", _CASES, + ids=[c[0] for c in _CASES]) +def test_built_fixture_c_matches_the_serial_oracle_bit_for_bit( + built, c_units, bundle_name, kernel, kw, _want): + import _oracle as O + from conftest import sidecar_of + + bundle = built[bundle_name] + sidecar = sidecar_of(bundle, kernel) + mode = sidecar["scalar_type"] + want = O.run_kernel(O.load(bundle.directory, kernel, sidecar), N, **kw) + so, report = c_units(getattr(D, kernel), mode) + got = E.run_c(so, kernel, report, N, mode=mode, **kw) + rows = _written(report) + assert len(_tuple(got)) == len(_tuple(want)) == len(rows) + for row, g, w in zip(rows, _tuple(got), _tuple(want)): + assert_bit_identical(g, w, kernel=kernel, mode=mode, plane=row["name"]) + + +# --------------------------------------------------------------------------- # +# refusals: what the C target does not build is refused BY NAME, never run +# --------------------------------------------------------------------------- # +@hawk.kernel +def _auto_step(dt: Param, t_final: Scalar, terminated: Terminated, + t: Mutable[Scalar]): + """A finishing step: ``@hawk.kernel`` without ``steps=`` derives the + automatic kernel, which branches on a runtime step word.""" + tn = t + dt + t = tn + terminated = tn >= t_final + + +def _refused(): + import test_embedded_refusals as R + + return { + "energy": (D.energy, None, "sample_reduction"), + "scatter_c_compensated": (D.scatter_c, D.COMPENSATED, "compensated"), + "random": (R.draw, None, "random"), + "active_set": (R.active, None, "active_set"), + "segmented": (R.segmented, None, "segmented"), + "steps_auto": (_auto_step, None, "two_path"), + } + + +@pytest.mark.parametrize("case", ["energy", "scatter_c_compensated", "random", + "active_set", "segmented", "steps_auto"]) +@pytest.mark.parametrize("mode", MODES) +def test_a_refused_feature_is_named_and_nothing_is_written(tmp_path, case, mode): + kernel, kind, feature = _refused()[case] + with pytest.raises(HawkError) as err: + export(kernel, tmp_path / "out", mode=mode, kind=kind, check=False) + text = str(err.value) + assert f"unsupported.{feature}" in text and "the C target" in text, text + assert repr(kernel.name) in text, text + assert not (tmp_path / "out").exists() + + +# --------------------------------------------------------------------------- # +# group 2: the loop / dispatch / finish / derivative corpus against this +# file's own host-only bundles +# --------------------------------------------------------------------------- # +CAP = 12 # test_loop_early_exit.CAP, restated: no test-module import at collection +#: (kernel name, selector extent) of every dispatch fixture: an index plane +#: named ``k`` stays inside ``[0, K)``. +_SELECTOR = {"disp_r0_k5_pred": 5, "disp_r0_k5_switch": 5, "disp_r1_k3_pred": 3, + "disp_r1_k3_switch": 3, "disp_int_pred": 2, "disp_int_switch": 2, + "mix_auto": 3, "mix_switch": 3} + + +def _gp() -> Kernel: + """Own-column partials under the default mask guard: a masked sample + keeps the partial the caller handed over (the interface row is ``inout``, + init by caller). Built as IR: hawk 0.6 removed ``Accum.add``, the + ``AccumWrite`` it made remains.""" + x = Leaf("vocab_read", "per_sample", "x", TensorType((), "f64")) + return Kernel("gp", (AccumWrite("acc", make("mul", (x, x))),), + {"terminated": Terminated}) + + +gp = _gp() + + +@hawk.kernel +def index_arith(a: Index, b: Index, s: Mutable[Index], p: Mutable[Index], + ng: Mutable[Index]): + """Index ``+ - *`` and negation: infix in the C++ (signed overflow is + undefined), ``hawk_i64_*`` in the C. Compared with the host only where the + C++ is defined; pinned against NumPy at the overflow points.""" + s = a + b - 3 + p = a * b + ng = -a + + +@hawk.kernel +def index_div(a: Index, b: Index, q: Mutable[Index]): + """Index true division: infix in the C++ (a zero divisor and ``MIN / -1`` + are undefined), ``hawk_i64_div`` in the C. Compared with the host only + where the C++ is defined; the two undefined points are pinned C-only.""" + q = a / b + + +@hawk.kernel +def index_divshift(a: Index, b: Index, q: Mutable[Index], r: Mutable[Index], + sh: Mutable[Index]): + """Index ``// %`` and the shifts: a wrapping helper on BOTH sides, so the + host comparison takes the whole grid.""" + q = a // b + r = a % b + sh = (a << b) >> 1 + + +@hawk.kernel +def vdiv(a: Vector[3], b: Vector[3], y: Mutable[Vector[3]]): + """Rank>=1 over rank>=1: the core's ``vec_cwise_div`` against the host's + ``aether::cwiseDiv``, ``a_k / b_k``.""" + y = a / b + + +@functools.cache +def _corpus() -> dict: + """``{name: kernel}``. The fixture modules are test modules with their own + collection-time work, so they are imported here, never at collection.""" + import test_dispatch as T + import test_dispatch_mix_and_guard as G + import test_finish_own_sample as F + import test_int_planes as I + import test_loop_early_exit as LE + import test_steps as S + import test_tape_twin as TT + + from hawk import Kernel + from hawk.diff import vjp + from hawk.diff.tape import tape + + kernels = [ + LE.head, LE.middle, LE.tail, LE.gated_sum, LE.trajectory, LE.vec_head, + Kernel("head_vjp", vjp(LE.head, wrt=("x",))), + Kernel("tail_vjp", vjp(LE.tail, wrt=("x",))), + T.disp_r0_k5_pred, T.disp_r0_k5_switch, T.disp_r1_k3_pred, + T.disp_r1_k3_switch, T.disp_int_pred, T.disp_int_switch, + G.mix_auto, G.mix_switch, + F.osc_finish, F.stop_only, F.branch_finish, + hawk.steps(S.osc, 4), hawk.steps(S.spin3, 4), + K.drag, Kernel("drag_vjp", vjp(K.drag)), K.scale, K.chain6, + tape(TT.osc, 8), tape(TT.spin, 8), + D.vec3_scale, D.mat_apply, D.spin, D.vocab, D.diagnostic_two, + D.scatter, gp, D.two_fin.step, vdiv, + I.ops_i32, I.ops_u32, I.casts, I.scaled, index_arith, index_div, + index_divshift, + ] + out = {k.name: k for k in kernels} + assert len(out) == len(kernels), "two corpus kernels share a name" + assert LE.CAP == CAP + return out + + +#: Every group-2 kernel, by name (pinned against :func:`_corpus` in the +#: fixture, so the two lists cannot drift). +CORPUS_NAMES = ( + "head", "middle", "tail", "gated_sum", "trajectory", "vec_head", "head_vjp", + "tail_vjp", "disp_r0_k5_pred", "disp_r0_k5_switch", "disp_r1_k3_pred", + "disp_r1_k3_switch", "disp_int_pred", "disp_int_switch", "mix_auto", + "mix_switch", "osc_finish", "stop_only", "branch_finish", "osc_x4", + "spin3_x4", "drag", "drag_vjp", "scale", "chain6", "osc_tape", "spin_tape", + "vec3_scale", "mat_apply", "spin", "vocab", "diagnostic_two", "scatter", + "gp", "two_fin", "vdiv", "ops_i32", "ops_u32", "casts", "scaled", "index_arith", + "index_div", "index_divshift", +) +#: The integer kernels: fed their edge grids (:data:`EDGE_CASES`), never the +#: random draw of :func:`_inputs`. +_EDGE_GRID = ("ops_i32", "ops_u32", "casts", "scaled", "index_arith", "index_div", + "index_divshift") +#: Every group-2 kernel the random draw feeds. +RANDOM_NAMES = tuple(n for n in CORPUS_NAMES if n not in _EDGE_GRID) + +#: ``name -> (bundle group, kind)`` of every kernel outside the default +#: ``local`` group: a bundle declares one execution axis (the scattering +#: kernel is its own unit) and renders under ONE kind. +_GROUPS = {"scatter": ("scattering", None), "two_fin": ("two_masks", D.TWO_MASKS)} + + +def _group(name: str) -> tuple: + return _GROUPS.get(name, ("local", None)) + + +@pytest.fixture(scope="session") +def host_units(tmp_path_factory, cache_dir): + """``unit(name, mode) -> HostKernel``: one host-only bundle per mode and + :data:`_GROUPS` group (the sample-local corpus, the scattering kernel, a + kernel under another kind), native profile, built on first use and once + per session.""" + from hawk import runtime + from hawk.artifact import build_bundle + + corpus = _corpus() + assert tuple(corpus) == CORPUS_NAMES + root = pathlib.Path(tmp_path_factory.mktemp("embedded_host")) + bundles: dict = {} + loaded: dict = {} + + def unit(name, mode): + if mode not in bundles: + groups: dict = {} + for n, k in corpus.items(): + groups.setdefault(_group(n), []).append(k) + bundles[mode] = { + group: build_bundle(members, root / f"{group}_{mode}", mode=mode, + kind=kind, targets=("host",), + host_profile="native", cache_dir=cache_dir) + for (group, kind), members in groups.items()} + if (name, mode) not in loaded: + directory = bundles[mode][_group(name)[0]].directory + loaded[(name, mode)] = runtime.load(directory, name) + return loaded[(name, mode)] + + return unit + + +def _inputs(name: str, report: dict, n: int, mode: str, rng) -> dict: + """One value per interface row (``nsamples`` aside), drawn once and copied + to each side: reals in ``[-2, 2]`` (by-value reals rounded to the mode's + width first), index planes in range, masks a mix of 0/1 bytes, counters + and tapes zeroed, a per-sample stop up to ``CAP + 5``.""" + real = _NP[mode] + out = {} + for row in report["interface"]: + role, rname, dtype = row["role"], row["name"], row["dtype"] + width = int(np.prod(row["shape"])) if row["shape"] else 1 + cells = (width, n) if row["shape"] else (n,) + if role == "nsamples": + continue + if role == "uniform" and dtype in ("f64", "f32"): + out[rname] = float(real(rng.uniform(-2.0, 2.0))) + elif role == "uniform": # an integer Param + out[rname] = 1 if rname == "tape_on" else int(rng.integers(-3, 4)) + elif role == "terminated": + out[rname] = rng.random(n) < 0.3 + elif role == "lookup" and row["direction"] == "inout": # counters + out[rname] = np.zeros(width, dtype=np.uint32) + elif role == "out" or rname == "applied_steps": # tape twin + out[rname] = np.zeros(cells, dtype=real if role == "out" else np.int64) + elif role == "accum_out" and row["init_by_caller"] and row["layout"] == "partials": + # guarded partials: a masked sample keeps what the caller passed + out[rname] = rng.uniform(-2.0, 2.0, n).astype(real) + elif role == "accum_out": + out[rname] = np.zeros(n, dtype=real) + elif dtype in ("int32", "uint32"): + raise AssertionError(f"{name}: an integer plane takes its edge grid " + "(EDGE_CASES), not a random draw") + elif dtype in ("i32", "i64"): + if rname == "k" and name in _SELECTOR: + out[rname] = rng.integers(0, _SELECTOR[name], cells).astype(np.int64) + elif rname == "lane": + out[rname] = rng.integers(0, n, cells).astype(np.int64) + else: + out[rname] = rng.integers(-1000, 1000, cells).astype(np.int64) + elif row["direction"] == "out": + out[rname] = np.zeros(cells, dtype=real) + else: + out[rname] = rng.uniform(-2.0, 2.0, cells).astype(real) + if name == "trajectory": + # the per-sample stop: some samples stop early, some run past the cap + out["nstop"] = rng.integers(0, CAP + 6, n).astype(real) + out["k"] = np.zeros(n, dtype=real) + return out + + +def _copies(values: dict) -> dict: + return {k: (v.copy() if isinstance(v, np.ndarray) else v) for k, v in values.items()} + + +def _run_host(kernel, n: int, values: dict, *, base: int = 0, count=None) -> dict: + """The host side: every plane supplied by the caller (so the masks and + counters the oracle does not hand back are read in place).""" + import _oracle as O + + from hawk import runtime + + bound = O.planes(kernel, n, values) + runtime.run(kernel, base=base, count=n if count is None else count, + n_samples=n, **bound) + return bound + + +def _compare_written(name, mode, report, c_side: dict, host_side: dict) -> None: + for row in _written(report): + assert_bit_identical(c_side[row["name"]], host_side[row["name"]], + kernel=name, mode=mode, plane=row["name"]) + + +def _assert_masked_kept(name, mode, report, values: dict, sides: dict) -> int: + """Every per-sample plane a guarded kernel writes (a ``mutable``, own + partials, the finish mask) still holds, at each sample one of the row's + ``guard`` masks flagged on entry, exactly the value the caller handed + over, on each side. Returns how many such planes were checked.""" + fields = {r["field"]: r["name"] for r in report["interface"]} + checked = 0 + for row in _written(report): + per_sample = (row["role"] in ("mutable", "terminated") + or row["layout"] == "partials") + if not row["guard"] or not per_sample: + continue + assert (row["direction"], row["init_by_caller"]) == ("inout", True), row + masked = np.zeros(np.asarray(values[row["name"]]).shape[-1], dtype=bool) + for field in row["guard"]: + masked |= np.asarray(values[fields[field]], dtype=bool) + assert masked.any(), f"{name}: no sample is masked; the row proves nothing" + want = np.ascontiguousarray(values[row["name"]][..., masked]) + for side, planes in sides.items(): + got = np.ascontiguousarray(planes[row["name"]][..., masked]) + assert got.tobytes() == want.tobytes(), ( + f"{name} [{mode}] plane {row['name']!r}: the {side} side changed a " + f"masked sample (guard: {', '.join(row['guard'])})") + checked += 1 + return checked + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("name", RANDOM_NAMES) +def test_c_matches_the_host_bundle_bit_for_bit(host_units, c_units, name, mode): + kernel = _corpus()[name] + so, report = c_units(kernel, mode, _group(name)[1]) + host = host_units(name, mode) + values = _inputs(name, report, N, mode, np.random.default_rng(SEED)) + c_side, host_side = _copies(values), _copies(values) + E.run_c(so, name, report, N, mode=mode, **c_side) + _run_host(host, N, host_side) + _compare_written(name, mode, report, c_side, host_side) + _assert_masked_kept(name, mode, report, values, {"C": c_side, "host": host_side}) + + +# --------------------------------------------------------------------------- # +# the integer kernels on their edge grids +# --------------------------------------------------------------------------- # +#: The 16x16 Index grid: small values, shift counts at and past the width, +#: and magnitudes past 2^31 and 2^40 (so products overflow 64 bits). +_INDEX_EDGES = [0, 1, -1, 2, -2, 7, -7, 31, 63, 64, 1000, -1000, 2**31 - 1, -2**31, + 2**40, -2**40] +#: Reals at every boundary a saturating conversion pins. +_CAST_XS = [float("nan"), float("inf"), float("-inf"), 0.0, -0.0, 0.5, -0.5, -0.999, + 0.999, 1.0, -1.0, 2147483647.0, 2147483647.5, 2147483648.0, 2147483648.5, + -2147483647.5, -2147483648.0, -2147483648.5, -2147483649.0, 4294967295.0, + 4294967295.5, 4294967296.0, 4294967296.5, 1e300, -1e300, 2.0**63, + -(2.0**63), 123456.789, -123456.789] + + +def _pairs(edges, dtype) -> dict: + a, b = np.meshgrid(np.array(edges, dtype=dtype), np.array(edges, dtype=dtype)) + return {"a": np.ascontiguousarray(a.reshape(-1)), + "b": np.ascontiguousarray(b.reshape(-1))} + + +_I64 = (-(2**63), 2**63 - 1) + + +def _wrap64(x: int) -> int: + """``x`` reduced mod 2^64 into the signed int64 range: NumPy's (and the + C's ``hawk_i64_*``) two's-complement result.""" + return (x + 2**63) % 2**64 - 2**63 + + +def _index_arith(a: int, b: int) -> dict: + """``index_arith``'s outputs for one pair, exact (Python ints).""" + return {"s": a + b - 3, "p": a * b, "ng": -a} + + +def _defined(planes: dict) -> dict: + """The pairs of ``planes`` at which every ``index_arith`` result (and + the intermediate ``a + b``) stays inside int64: the inputs whose C++ + behaviour is defined.""" + keep = [k for k, (a, b) in enumerate(zip(planes["a"].tolist(), planes["b"].tolist())) + if all(_I64[0] <= v <= _I64[1] + for v in (a + b, *_index_arith(a, b).values()))] + return {k: np.ascontiguousarray(v[keep]) for k, v in planes.items()} + + +def _defined_div(planes: dict) -> dict: + """The pairs of ``planes`` whose ``a / b`` the C++ defines: no zero + divisor and not ``MIN / -1``.""" + keep = [k for k, (a, b) in enumerate(zip(planes["a"].tolist(), planes["b"].tolist())) + if b != 0 and not (a == _I64[0] and b == -1)] + return {k: np.ascontiguousarray(v[keep]) for k, v in planes.items()} + + +def _cast_planes(mode: str) -> dict: + import test_int_planes as I + + with np.errstate(over="ignore"): # +-1e300 is +-inf in float32 + xs = np.array(_CAST_XS, dtype=np.float64).astype(_NP[mode]) + k = xs.shape[0] + return {"x": xs, "n": np.resize(np.array(I.INT32_EDGES, np.int32), k), + "u": np.resize(np.array(I.UINT32_EDGES, np.uint32), k)} + + +def _edge_values(case: str, mode: str) -> tuple[str, dict]: + """``(kernel name, planes)`` of one edge-grid case: every input on its + grid (a by-value real rounded to the mode's width first).""" + import test_int_planes as I + + if case == "ops_i32": + return case, _pairs(I.INT32_EDGES, np.int32) + if case == "ops_u32": + return case, _pairs(I.UINT32_EDGES, np.uint32) + if case == "index_arith": + # only where the C++ is defined: + - * and negation inside int64 + return case, _defined(_pairs(_INDEX_EDGES, np.int64)) + if case == "index_div": + # only where the C++ is defined: no zero divisor, not MIN / -1 + return case, _defined_div(_pairs(_INDEX_EDGES, np.int64)) + if case == "index_divshift": + return case, _pairs(_INDEX_EDGES, np.int64) + if case.startswith("casts_s"): + return "casts", {**_cast_planes(mode), "s": int(case.removeprefix("casts_s"))} + if case == "scaled": + return case, {**_cast_planes(mode), "a": float(_NP[mode](1.25))} + raise KeyError(case) + + +EDGE_CASES = ("ops_i32", "ops_u32", "index_arith", "index_div", "index_divshift", + "casts_s-5", + "casts_s7", + "casts_s2147483647", "scaled") + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("case", EDGE_CASES) +def test_integer_kernels_match_the_host_bundle_on_their_edge_grids( + host_units, c_units, case, mode): + name, values = _edge_values(case, mode) + so, report = c_units(_corpus()[name], mode) + n = next(v for v in values.values() if isinstance(v, np.ndarray)).shape[-1] + for row in _written(report): + # every written plane supplied at its own dtype on both sides + cells = (int(np.prod(row["shape"])), n) if row["shape"] else (n,) + values.setdefault(row["name"], np.zeros(cells, dtype=E.element(row, mode)[1])) + c_side, host_side = _copies(values), _copies(values) + E.run_c(so, name, report, n, mode=mode, **c_side) + _run_host(host_units(name, mode), n, host_side) + _compare_written(f"{name} ({case})", mode, report, c_side, host_side) + + +#: The overflow points the host row leaves out, plus the int64 extremes so +#: a sum and a negation overflow too. +_OVERFLOW_EDGES = [*_INDEX_EDGES, 2**63 - 1, -(2**63)] + + +@pytest.mark.parametrize("mode", MODES) +def test_index_arithmetic_wraps_like_numpy_at_the_overflow_points(c_units, mode): + """C only: at the inputs where the C++ build's signed overflow is + undefined there is no host result to compare, so the C unit's + ``hawk_i64_*`` results are pinned against NumPy's int64 wrapping + arithmetic (mod 2^64 into the signed range).""" + so, report = c_units(_corpus()["index_arith"], mode) + planes = _pairs(_OVERFLOW_EDGES, np.int64) + n = planes["a"].shape[0] + for row in _written(report): + planes[row["name"]] = np.zeros(n, dtype=np.int64) + E.run_c(so, "index_arith", report, n, mode=mode, **planes) + exact = [_index_arith(a, b) for a, b in zip(planes["a"].tolist(), + planes["b"].tolist())] + for out in ("s", "p", "ng"): + want = np.array([_wrap64(e[out]) for e in exact], dtype=np.int64) + overflowed = sum(not _I64[0] <= e[out] <= _I64[1] for e in exact) + assert overflowed, f"index_arith.{out}: the grid reaches no overflow" + assert_bit_identical(planes[out], want, kernel="index_arith", mode=mode, + plane=out) + + +@pytest.mark.parametrize("mode", MODES) +def test_index_division_is_defined_by_the_c_where_the_cpp_is_not(c_units, mode): + """C only: a zero divisor and ``MIN / -1`` are undefined in the C++ build, + so there is no host result; the C unit's ``hawk_i64_div`` returns 0 and + ``MIN`` there (the defined results the ``defined_where_cpp_is_ub`` note + and the docs state), and plain truncating division everywhere else.""" + so, report = c_units(_corpus()["index_div"], mode) + lo = _I64[0] + a = np.array([5, -5, lo, lo, 7, -7, lo], dtype=np.int64) + b = np.array([0, 0, -1, 1, 2, 2, 2], dtype=np.int64) + want = np.array([0, 0, lo, lo, 3, -3, lo // 2], dtype=np.int64) + planes = {"a": a, "b": b, "q": np.zeros(a.shape[0], dtype=np.int64)} + E.run_c(so, "index_div", report, a.shape[0], mode=mode, **planes) + assert_bit_identical(planes["q"], want, kernel="index_div", mode=mode, plane="q") + + +#: Kernels whose own-column planes sit under a mask guard: each one's group-2 +#: row must check at least one masked plane (pins the interface rule). +_GUARDED = ("diagnostic_two", "gp", "branch_finish", "two_fin") + + +@pytest.mark.parametrize("name", _GUARDED) +def test_a_guarded_plane_keeps_the_callers_value_at_a_masked_sample( + host_units, c_units, name): + mode = "float64" + so, report = c_units(_corpus()[name], mode, _group(name)[1]) + values = _inputs(name, report, N, mode, np.random.default_rng(SEED)) + c_side, host_side = _copies(values), _copies(values) + E.run_c(so, name, report, N, mode=mode, **c_side) + _run_host(host_units(name, mode), N, host_side) + assert _assert_masked_kept(name, mode, report, values, + {"C": c_side, "host": host_side}) >= 1 + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("name", ["drag", "scatter"]) +def test_a_partition_writes_its_window_alone_on_both_sides(host_units, c_units, + name, mode): + n, base, count = 8, 2, 3 + kernel = _corpus()[name] + so, report = c_units(kernel, mode) + host = host_units(name, mode) + values = _inputs(name, report, n, mode, np.random.default_rng(SEED)) + c_side, host_side = _copies(values), _copies(values) + E.run_c(so, name, report, n, base=base, count=count, mode=mode, **c_side) + _run_host(host, n, host_side, base=base, count=count) + _compare_written(name, mode, report, c_side, host_side) + if name == "drag": + for side in (c_side, host_side): + out = side["out"] + assert not out[:, :base].any() and not out[:, base + count:].any(), ( + f"drag [{mode}]: a sample outside [{base}, {base + count}) was " + "written") + assert out[:, base:base + count].all() + else: + hit = set(values["lane"][base:base + count].tolist()) + idle = [j for j in range(n) if j not in hit] + for side in (c_side, host_side): + assert not side["acc"][idle].any(), ( + f"scatter [{mode}]: a lane no sample of [{base}, {base + count}) " + "targets was written") + assert side["acc"][sorted(hit)].any() + + +# --------------------------------------------------------------------------- # +# group 3: the C library against the numpy interpreter (no host artifact) +# --------------------------------------------------------------------------- # +def band(s: int, a, b, mode: str) -> float: + """``S x 2 x eps(dtype)``, relative to the larger operand (and 1): the one + anchor ``test_serial_oracle.band`` states, restated here so this file + does not import another test module.""" + scale = max(1.0, float(np.max(np.abs(a))), float(np.max(np.abs(b)))) + return s * 2.0 * _EPS[mode] * scale + + +def _ops(report) -> int: + """``S``: the per-sample arithmetic, special and chain operations the + verify pass counted (derived from the kernel, never fitted).""" + ops = report["budgets"]["ops"] + return max(1, int(sum(v for k, v in ops.items() + if k.startswith(("arith", "special", "chain"))))) + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("kernel", [K.drag, K.chain6, D.vec3_scale], + ids=["drag", "chain6", "vec3_scale"]) +def test_c_matches_the_numpy_interpreter_within_band(c_units, kernel, mode): + from _eval import evaluate + + so, report = c_units(kernel, mode) + rng = np.random.default_rng(SEED) + values = {} + for row in report["interface"]: + if row["role"] == "uniform": + # chain6 takes sqrt(k): its Param stays positive + values[row["name"]] = float(_NP[mode](rng.uniform(0.25, 2.0))) + elif row["direction"] == "in": + cells = (int(np.prod(row["shape"])), N) if row["shape"] else (N,) + values[row["name"]] = rng.uniform(-2.0, 2.0, cells).astype(_NP[mode]) + got = _tuple(E.run_c(so, kernel.name, report, N, mode=mode, **values)) + rows = _written(report) + s = _ops(report) + for i in range(N): + env = {k: (v[..., i].astype(np.float64) if isinstance(v, np.ndarray) else v) + for k, v in values.items()} + want = evaluate(kernel.sinks, env, lanes=N, lane=i) + for row, plane in zip(rows, got): + g = np.asarray(plane[..., i], dtype=np.float64) + w = np.asarray(want[row["name"]], dtype=np.float64) + limit = band(s, g, w, mode) + worst = float(np.max(np.abs(g - w))) + assert worst <= limit, ( + f"{kernel.name} [{mode}] plane {row['name']!r}, sample {i}: the C " + f"result {g} differs from the interpreter's {w} by {worst}, band " + f"S*2*eps*scale = {limit} (S = {s})") diff --git a/tests/test_embedded_bundle.py b/tests/test_embedded_bundle.py new file mode 100644 index 0000000..63520a3 --- /dev/null +++ b/tests/test_embedded_bundle.py @@ -0,0 +1,226 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The standalone C target through ``hawk.build`` / ``build_bundle``. + +``targets=("c", ...)`` routes through :mod:`hawk.embedded` (render, audit, +toolchain checks, report) and never through the eagle manifest: a ``c`` +entry publishes ``.c``, ``.h`` and ``.embedded.json``, the +manifest and the JSON sidecar describe the native targets only, and a +c-only unit writes neither. + +Rows: + +* **c-only** — exactly the three C files plus the stamp, byte-identical to + :func:`hawk.embedded.export`'s; ``Bundle.manifest`` is ``None``; +* **stamp** — a second c-only build is a memo hit in-process and a stamp + hit across a cold memo; +* **host + c** — the manifest, its plugin entry and the sidecar name the + host target only; ``Entry("c").report`` is set; +* **tables** — ``_EXEC_TARGET`` and ``manifest.EXEC_TARGETS`` never learn + ``c``; +* **load** — ``hawk.load`` on a c-only directory refuses with a + :class:`HawkError` naming the ``c`` target and ``hawk.embedded``; +* **key** — the embedded profile is a term of the unit key; +* **message** — the unknown-target message names ``c``. +""" + +from __future__ import annotations + +import json + +import _kernels as K +import pytest + +import hawk +from hawk.artifact import ( + STAMP_NAME, + build, + build_bundle, + plugins, + reset_unit_memo, + unit_stats, +) +from hawk.artifact import bundle as _bundle +from hawk.artifact import manifest as _manifest +from hawk.artifact.unit_cache import _unit_key +from hawk.embedded import EmbeddedProfile, export +from hawk.emit import scalar_mode +from hawk.ext import DEFAULT_KIND +from hawk.ir import HawkError + +C_FILES = ["drag.c", "drag.embedded.json", "drag.h"] + + +@pytest.fixture +def fresh(): + """A publisher with no memory of any unit, before and after the row.""" + reset_unit_memo() + yield + reset_unit_memo() + + +def _c_only(directory, cache_dir, **kw): + return build_bundle([K.drag], directory, targets=("c",), cache_dir=cache_dir, **kw) + + +def test_c_only_bundle_writes_sources_report_and_no_manifest(tmp_path, cache_dir, + fresh): + d = tmp_path / "u" + bundle = _c_only(d, cache_dir) + assert sorted(p.name for p in d.iterdir()) == sorted([*C_FILES, STAMP_NAME]) + assert not (d / "manifest.json").exists() and not (d / "drag.json").exists() + assert bundle.manifest is None and bundle.manifest_path is None + assert sorted(p.name for p in bundle.files()) == C_FILES + (art,) = bundle.artifacts + assert list(art.entries) == ["c"] + entry = art.entries["c"] + assert (entry.backend, entry.artifact, entry.source, entry.report) == ( + "c", d / "drag.h", d / "drag.c", d / "drag.embedded.json") + assert art.sidecar_path == d / "drag.embedded.json" + assert art.sidecar == json.loads(entry.report.read_text()) + assert art.sidecar["checks"].get("cc"), "the bundle door runs the toolchain checks" + # one code path: the bundle's C unit is byte-identical to export()'s + exported = export(K.drag, tmp_path / "x") + for f in C_FILES: + assert (d / f).read_bytes() == (exported.directory / f).read_bytes(), f + + +def test_second_build_is_a_stamp_hit(tmp_path, cache_dir, fresh): + d = tmp_path / "u" + first = _c_only(d, cache_dir) + again = _c_only(d, cache_dir) + assert unit_stats()["memo_hits"] == 1 + assert again.artifacts[0].entries["c"].hit + reset_unit_memo() + cold = _c_only(d, cache_dir) + assert unit_stats()["stamp_hits"] == 1 and unit_stats()["published"] == 0 + assert cold.digest == first.digest and cold.manifest is None + + +def test_host_and_c_together_keep_the_manifest_host_only(tmp_path, cache_dir, fresh): + d = tmp_path / "u" + bundle = build_bundle([K.drag], d, targets=("host", "c"), host_profile="native", + cache_dir=cache_dir) + assert sorted(p.name for p in d.iterdir()) == sorted( + ["drag.so", "drag.cpp", "drag.json", "manifest.json", *C_FILES, STAMP_NAME]) + assert bundle.manifest_path == d / "manifest.json" + assert bundle.manifest["exec_targets"] == ["host"] + assert json.loads((d / "manifest.json").read_text()) == bundle.manifest + (plugin,) = bundle.manifest["plugins"] + assert (plugin["artifact"], plugin["sidecar"], plugin["format"]) == ( + "drag.so", "drag.json", "host") + (art,) = bundle.artifacts + assert art.sidecar["exec_targets"] == ["host"] + assert art.sidecar_path == d / "drag.json" + assert art.entries["c"].report == d / "drag.embedded.json" + assert art.entries["host"].report is None + assert sorted(p.name for p in bundle.files()) == sorted( + ["manifest.json", "drag.json", "drag.so", "drag.cpp", *C_FILES]) + # the plugin carries the host entry point and nothing for the C unit + plugin_ns = plugins(bundle)["drag"] + assert plugin_ns.host_entry and not hasattr(plugin_ns, "device_function") + + +def test_exec_targets_table_never_learns_c(): + assert "c" not in _bundle._EXEC_TARGET + assert _manifest.EXEC_TARGETS == ("device", "host") + assert _bundle._TARGET["c"] == (".h", ".c", "c") + + +def test_c_only_directory_refuses_cleanly_to_load(tmp_path, cache_dir, fresh): + d = tmp_path / "u" + _c_only(d, cache_dir) + with pytest.raises(HawkError) as err: + hawk.load(d, "drag") + text = str(err.value) + assert "'c'" in text and "hawk.embedded" in text + + +def test_unit_key_differs_by_profile(tmp_path, cache_dir, fresh): + def key(profile: str) -> str: + return _unit_key(["drag"], [K.drag.walk.digest], targets=("c",), + smode=scalar_mode("float64"), kind=DEFAULT_KIND, defines=(), + cache_dir=cache_dir, device_arch="", derivative_blocks=[None], + layout_sizes_override=None, embedded_profile=profile) + + assert key(repr(EmbeddedProfile())) != key(repr(EmbeddedProfile(max_trips=10**6))) + # and through the door: a second profile is a real publish, not a memo hit + _c_only(tmp_path / "u", cache_dir) + other = _c_only(tmp_path / "u", cache_dir, + embedded_profile=EmbeddedProfile(max_trips=10**6)) + assert unit_stats()["memo_hits"] == 0 and unit_stats()["published"] == 2 + assert "max_trips=1000000" in other.artifacts[0].sidecar["profile"] + + +def test_unknown_target_message_names_c(tmp_path): + with pytest.raises(HawkError) as err: + build(K.drag, tmp_path / "u", targets=("device",)) + assert str(err.value) == ( + "unknown target 'device': the valid targets are 'cuda', 'host' and 'c' " + "('cuda' builds for the GPU, 'host' for the CPU, 'c' a standalone MISRA C " + "unit for an embedded build); pass targets=('cuda',), targets=('host',) " + "or targets=('c',)") + + +def test_no_target_is_refused(tmp_path, cache_dir, fresh): + """``targets=()`` must not publish an empty unit now that a draft may + legitimately carry no native target.""" + with pytest.raises(HawkError, match="no target to build"): + build_bundle([K.drag], tmp_path / "u", targets=(), cache_dir=cache_dir) + assert not (tmp_path / "u").exists() + + +def test_plugins_refuses_a_c_only_bundle(tmp_path, cache_dir, fresh): + """A c-only unit holds no eagle entry point: asking for plugins is a + mistake, refused as ``hawk.load`` refuses the directory.""" + bundle = _c_only(tmp_path / "u", cache_dir) + with pytest.raises(HawkError) as err: + plugins(bundle) + text = str(err.value) + assert "'c'" in text and "hawk.embedded" in text + + +def _kernel_from(path, text: str): + """Trace ``k`` out of ``text`` written to ``path``, so its source digest + reads the real file.""" + path.write_text(text) + ns = {"hawk": hawk} + exec(compile(text, str(path), "exec"), ns) + return ns["k"] + + +def test_same_walk_different_source_reports_its_own_digest(tmp_path, cache_dir, fresh): + """Two kernels with one walk and different source text (a comment) are two + C units: each report carries its own kernel's source digest.""" + body = " y = a * 2.0\n" + head = ("import hawk\n@hawk.kernel\n" + "def k(a: hawk.Scalar, y: hawk.Mutable[hawk.Scalar]):\n") + ka = _kernel_from(tmp_path / "a.py", head + body) + kb = _kernel_from(tmp_path / "b.py", head + " # a comment\n" + body) + assert ka.walk.digest == kb.walk.digest + assert ka.source_digest != kb.source_digest + first = build_bundle([ka], tmp_path / "u", targets=("c",), cache_dir=cache_dir) + second = build_bundle([kb], tmp_path / "u", targets=("c",), cache_dir=cache_dir) + assert first.artifacts[0].sidecar["source_digest"] == ka.source_digest + assert second.artifacts[0].sidecar["source_digest"] == kb.source_digest + on_disk = json.loads((tmp_path / "u" / "k.embedded.json").read_text()) + assert on_disk["source_digest"] == kb.source_digest + + +def _key_for(targets, cache_dir, **kw) -> str: + return _unit_key(["drag"], [K.drag.walk.digest], targets=targets, + smode=scalar_mode("float64"), kind=DEFAULT_KIND, defines=(), + cache_dir=cache_dir, device_arch="", derivative_blocks=[None], + layout_sizes_override=None, host_profile="native", **kw) + + +def test_unit_key_names_the_c_core_version_for_c_only(cache_dir, monkeypatch): + from aether_dsc import c_core + + c_before = _key_for(("c",), cache_dir, embedded_profile=repr(EmbeddedProfile())) + host_before = _key_for(("host",), cache_dir) + monkeypatch.setattr(c_core, "CORE_VERSION", c_core.CORE_VERSION + "-other") + assert _key_for(("c",), cache_dir, + embedded_profile=repr(EmbeddedProfile())) != c_before + assert _key_for(("host",), cache_dir) == host_before diff --git a/tests/test_embedded_compiles.py b/tests/test_embedded_compiles.py new file mode 100644 index 0000000..bbe4037 --- /dev/null +++ b/tests/test_embedded_compiles.py @@ -0,0 +1,62 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""Every C unit the corpus renders compiles strictly and passes the MISRA +checker. + +For every corpus kernel (``energy`` is a sum over samples, refused by the C target) +in both scalar modes, :func:`hawk.embedded.check` runs the C compiler under +the strict flag set for ``-std=c99`` and ``-std=c11`` and then cppcheck with +the MISRA C:2012 addon; any diagnostic or finding raises. The rows run under +``EmbeddedProfile(require_cppcheck=True)``, so a machine without cppcheck +FAILS them (a skipped check certifies nothing): install cppcheck or point +``$HAWK_CPPCHECK`` at one. The deviations the checker runs under are +:data:`hawk.embedded.toolchain.DEVIATIONS`, each with its stated reason. +""" + +from __future__ import annotations + +import _kernels as K +import pytest + +from hawk.embedded import EmbeddedProfile, check, render, toolchain + +MODES = ("float64", "float32") +CORPUS = [n for n in K.CORPUS if n != "energy"] +STRICT = EmbeddedProfile(require_cppcheck=True) + + +def test_cppcheck_is_installed(): + assert toolchain.cppcheck() is not None, "install cppcheck or set $HAWK_CPPCHECK" + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("name", CORPUS) +def test_the_unit_compiles_and_passes_misra(name, mode): + checks = check(render(K.CORPUS[name], mode=mode), profile=STRICT) + assert [run["std"] for run in checks["cc"]] == ["c99", "c11"] + assert all(run["rc"] == 0 for run in checks["cc"]) + assert "findings" in checks["cppcheck"], "install cppcheck or set $HAWK_CPPCHECK" + assert checks["cppcheck"]["findings"] == [] + assert checks["cppcheck"]["suppressed"] == [d["rule"] for d in toolchain.DEVIATIONS] + + +@pytest.mark.parametrize("mode", MODES) +@pytest.mark.parametrize("name", ("casts", "scaled")) +def test_a_saturating_real_to_int_cast_passes_misra(name, mode): + """``hawk_*_from_real`` compares against real-literal bounds (no cast of a + composite expression, Rule 10.8).""" + import test_int_planes as I + + source = render(getattr(I, name), mode=mode) + assert "_from_real(" in source.text + checks = check(source, profile=STRICT) + assert "findings" in checks["cppcheck"], "install cppcheck or set $HAWK_CPPCHECK" + assert checks["cppcheck"]["findings"] == [] + + +def test_every_deviation_states_its_rule_category_and_reason(): + for d in toolchain.DEVIATIONS: + assert d["rule"].startswith("misra-c2012-") + assert d["category"] in ("advisory", "required") + assert len(d["reason"]) > 40 diff --git a/tests/test_embedded_export.py b/tests/test_embedded_export.py new file mode 100644 index 0000000..7625b92 --- /dev/null +++ b/tests/test_embedded_export.py @@ -0,0 +1,245 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The standalone C target's export door and its traceability. + +Rows: + +* **files** — :func:`hawk.embedded.export` writes exactly ``.c``, + ``.h`` and ``.embedded.json``; +* **report** — the report's keys come in the spec's order, its traceability + stamps (hawk version, walk digest, kernel source digest) agree with the + header's TRACEABILITY block, and it records the toolchain deviations; +* **digest** — ``Kernel.source_digest`` is the sha256 of the dedented + source of the traced function, carried through ``hawk.steps`` and + ``resolve_dispatch``; a hand-built ``Kernel`` reports ``"unavailable"``; +* **determinism** — a re-export is byte-identical, with and without the + toolchain checks (no timestamps, no temp paths, no durations); +* **checks** — ``check=True`` runs the C compiler and cppcheck and records a + zero-finding cppcheck block. +""" + +from __future__ import annotations + +import hashlib +import inspect +import json +import re +import textwrap + +import _kernels as K +import pytest + +import hawk +from hawk import Index, Int32, Mutable, Param, Scalar, Vector +from hawk.embedded import EmbeddedArtifact, export, render, toolchain +from hawk.math import norm, where +from hawk.trace.kernel import Kernel + +#: The report's keys in the spec's order (``int_division_sites`` and +#: ``int_ub_sites`` sit where the verify pass puts them, after +#: ``fp_equality_sites``). +REPORT_KEYS = [ + "kernel", "generator", "hawk_version", "walk_digest", "source_digest", + "scalar_mode", "profile", "interface", "budgets", "indices", "fp_domain", + "fp_equality_sites", "int_division_sites", "int_ub_sites", "literal_specials", + "libm", + "helpers", "core", "core_version", "deviations", "unsupported_checked", + "audit", "checks", +] + + +def _probe(state: Vector[3], rho: Param, out: Mutable[Vector[3]]): + out = -rho * state * norm(state) + +PROBE = hawk.kernel(_probe) + + +def _sha(fn) -> str: + return hashlib.sha256(textwrap.dedent(inspect.getsource(fn)).encode()).hexdigest() + + +def _drag_source() -> str: + """``K.drag``'s decorated definition, as ``inspect.getsource`` returns it + (a :class:`Kernel` does not hold the traced function).""" + text = inspect.getsource(K) + m = re.search(r"^@kernel\ndef drag\(.*?(?=\n\n)", text, re.S | re.M) + return m.group(0) + "\n" + + +# --------------------------------------------------------------------------- # +# files and report +# --------------------------------------------------------------------------- # +def test_export_writes_exactly_three_files(tmp_path): + art = export(K.drag, tmp_path, check=False) + assert isinstance(art, EmbeddedArtifact) + assert sorted(p.name for p in tmp_path.iterdir()) == [ + "drag.c", "drag.embedded.json", "drag.h"] + assert (art.name, art.directory) == ("drag", tmp_path) + assert (art.c_path, art.h_path, art.report_path) == ( + tmp_path / "drag.c", tmp_path / "drag.h", tmp_path / "drag.embedded.json") + assert json.loads(art.report_path.read_text()) == art.report + + +def test_report_keys_follow_the_spec_order(tmp_path): + art = export(K.drag, tmp_path, check=False) + assert list(json.loads(art.report_path.read_text())) == REPORT_KEYS + r = art.report + assert r["kernel"] == "drag" and r["generator"] == "hawk.embedded/1" + assert r["hawk_version"] == hawk.__version__ + assert r["walk_digest"] == K.drag.walk.digest + assert r["scalar_mode"] == "float64" + assert r["profile"].startswith("EmbeddedProfile(") + assert r["audit"] == {"hits": []} + assert r["checks"] == {"skipped": "not requested"} + assert [d["rule"] for d in r["deviations"]] == [d["rule"] for d in toolchain.DEVIATIONS] + assert not re.search(r"\b(19|20)\d\d-\d\d-\d\d", art.report_path.read_text()) + + +@hawk.kernel +def float_equal(x: Scalar, z: Scalar, y: Mutable[Scalar]): + y = where(x == z, x, z) + + +def test_float_equality_sites_become_a_deviation_note(tmp_path): + art = export(float_equal, tmp_path, check=False) + sites = art.report["fp_equality_sites"] + assert sites + (note,) = [d for d in art.report["deviations"] if d["rule"] == "float-equality"] + assert note == {"rule": "float-equality", "category": "note", "sites": sites, + "reason": note["reason"]} + + +@hawk.kernel +def index_add(a: Index, b: Index, y: Mutable[Index]): + y = a + b + + +@hawk.kernel +def int32_add(a: Int32, b: Int32, y: Mutable[Int32]): + y = a + b + + +def test_int_ub_sites_become_a_defined_where_cpp_is_ub_note(tmp_path): + art = export(index_add, tmp_path / "index_add", check=False) + sites = art.report["int_ub_sites"] + assert sites + (note,) = [d for d in art.report["deviations"] + if d["rule"] == "defined_where_cpp_is_ub"] + assert note == {"rule": "defined_where_cpp_is_ub", "category": "note", + "sites": sites, "reason": note["reason"]} + for needle in ("hawk_i64_*", "undefined", "wherever the C++ is defined"): + assert needle in note["reason"] + # Int32 arithmetic wraps through a helper on the host too: no note + art = export(int32_add, tmp_path / "int32_add", check=False) + assert art.report["int_ub_sites"] == [] + assert not [d for d in art.report["deviations"] + if d["rule"] == "defined_where_cpp_is_ub"] + # nor for a real-only kernel + art = export(K.drag, tmp_path / "drag", check=False) + assert art.report["int_ub_sites"] == [] + assert not [d for d in art.report["deviations"] + if d["rule"] == "defined_where_cpp_is_ub"] + + +# --------------------------------------------------------------------------- # +# the kernel source digest +# --------------------------------------------------------------------------- # +def test_source_digest_is_the_sha256_of_the_dedented_source(): + assert PROBE.source_digest == _sha(_probe) + assert K.drag.source_digest == hashlib.sha256(_drag_source().encode()).hexdigest() + + +def test_the_digest_rides_through_derived_kernels(): + import test_finish_own_sample as F + + from hawk.trace.steps import steps + + four = steps(F.osc_finish, 4) + assert F.osc_finish.source_digest is not None + assert four.source_digest == F.osc_finish.source_digest + + +def test_a_hand_built_kernel_reports_unavailable(tmp_path): + hand = Kernel("x", PROBE.sinks) + assert hand.source_digest is None + art = export(hand, tmp_path, check=False) + assert art.report["source_digest"] == "unavailable" + assert re.search(r"\* kernel sha256 +unavailable\n", art.h_path.read_text()) + + +def test_the_header_traceability_block_shows_the_stamps(tmp_path): + art = export(PROBE, tmp_path, check=False) + header = art.h_path.read_text() + assert re.search(rf"\* hawk version +{re.escape(hawk.__version__)}\n", header) + assert re.search(rf"\* walk digest +{PROBE.walk.digest}\n", header) + assert re.search(rf"\* kernel sha256 +{_sha(_probe)}\n", header) + assert art.report["source_digest"] == _sha(_probe) + # render() alone already shows them + assert render(PROBE).header == header + + +# --------------------------------------------------------------------------- # +# determinism +# --------------------------------------------------------------------------- # +def _bytes(directory): + return {p.name: p.read_bytes() for p in sorted(directory.iterdir())} + + +def test_a_re_export_is_byte_identical(tmp_path): + a, b = tmp_path / "a", tmp_path / "b" + export(K.drag, a, check=False) + export(K.drag, b, check=False) + export(K.drag, b, check=False) + assert _bytes(a) == _bytes(b) + text = (a / "drag.embedded.json").read_text() + assert text.endswith("}\n") and json.loads(text) + + +def test_a_failed_check_leaves_no_stale_report(tmp_path, monkeypatch): + """A re-export whose checks fail leaves its C for inspection and NO + report: never an older clean ``.embedded.json`` beside failing C.""" + import test_int_planes as I + + from hawk.ir import HawkError + + assert toolchain.cppcheck() is not None, "install cppcheck or set $HAWK_CPPCHECK" + export(K.drag, tmp_path, name="u", check=True) + assert (tmp_path / "u.embedded.json").is_file() + + def refuse(*_args, **_kw): + raise HawkError("planted toolchain failure") + + monkeypatch.setattr(toolchain, "run_checks", refuse) + with pytest.raises(HawkError, match="planted toolchain failure"): + export(I.casts, tmp_path, name="u", check=True) + assert not (tmp_path / "u.embedded.json").exists() + assert sorted(p.name for p in tmp_path.iterdir()) == ["u.c", "u.h"] + assert (tmp_path / "u.c").read_text() == render(I.casts, name="u").text + + +def test_check_true_runs_cppcheck_clean_and_stays_byte_identical(tmp_path): + assert toolchain.cppcheck() is not None, "install cppcheck or set $HAWK_CPPCHECK" + a, b = tmp_path / "a", tmp_path / "b" + art = export(K.drag, a, check=True) + export(K.drag, b, check=True) + cpp = art.report["checks"]["cppcheck"] + assert cpp["findings"] == [] and cpp["rc"] == 0 + assert [run["std"] for run in art.report["checks"]["cc"]] == ["c99", "c11"] + assert "seconds" not in art.report["checks"] + assert "hawk-embedded-" not in (a / "drag.embedded.json").read_text() + assert _bytes(a) == _bytes(b) + + +def test_a_bare_import_hawk_resolves_the_embedded_submodule(): + """``import hawk; hawk.embedded.export`` works with no explicit submodule + import (a subprocess: in-process the attribute is set once any test + imported ``hawk.embedded``).""" + import subprocess + import sys + + done = subprocess.run( + [sys.executable, "-c", "import hawk; hawk.embedded.export"], + capture_output=True, text=True) + assert done.returncode == 0, done.stderr + assert "AttributeError" not in done.stderr diff --git a/tests/test_embedded_helpers.py b/tests/test_embedded_helpers.py new file mode 100644 index 0000000..5e6c0b0 --- /dev/null +++ b/tests/test_embedded_helpers.py @@ -0,0 +1,163 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the hawk-owned C helpers of the standalone C target.""" + +import importlib +import shutil +import subprocess + +import pytest + +from hawk.embedded import spelling as S +from hawk.emit import FLOAT32, FLOAT64 +from hawk.ir import HawkError + + +class _Lazy: + """A module imported on first attribute access, not at collection. Jobs that + run only the ``ft`` rows install a released aether-dsc without ``c_core`` + (tests/sanitize/tsan_ft.sh); collecting this file must not import it there. + A row that runs still imports it, and fails, never skips, when it is missing.""" + + def __init__(self, name: str) -> None: + self._name = name + + def __getattr__(self, attr: str): + return getattr(importlib.import_module(self._name), attr) + + +c_core = _Lazy("aether_dsc.c_core") +H = _Lazy("hawk.embedded.helpers") + +SHAPES = ((1,), (3,), (2, 2), (2, 3)) +MODES = (FLOAT64, FLOAT32) + + +def test_load_uses_the_pitch(): + text = H.helper("load", mode=FLOAT64, shape=(3,)).text + assert "plane[(2 * pitch) + i]" in text + assert "out.v[0] = plane[i];" in text + assert "out.v[1] = plane[pitch + i];" in text + + +def test_mat_load_flattens_row_major(): + text = H.helper("load", mode=FLOAT64, shape=(2, 3)).text + assert "out.m[1][2] = plane[(5 * pitch) + i];" in text + + +def test_i64_div_defines_zero_and_min_over_minus_one(): + text = H.helper("i64_div", mode=FLOAT64).text + assert "if (b == INT64_C(0))" in text + assert "(a == (-INT64_C(9223372036854775807) - INT64_C(1))) && (b == INT64_C(-1))" in text + assert text.count("return") == 1 + + +def test_i64_from_real_saturates_and_maps_nan_to_zero(): + text = H.helper("i64_from_real", mode=FLOAT64).text + assert "isnan(x) ? INT64_C(0)" in text + assert "(x <= ((hawk_real_t)-9223372036854775808.0))" in text + assert "(x >= ((hawk_real_t)9223372036854775807.0))" in text + assert "? (-INT64_C(9223372036854775807) - INT64_C(1))" in text + + +def test_select_is_per_component_ternary(): + text = H.helper("select", mode=FLOAT64, shape=(2, 2)).text + assert "out.m[1][0] = c ? a.m[1][0] : b.m[1][0];" in text + assert "bool c" in text + + +def test_at_clamps_and_at_raw_does_not(): + assert "x.v[hawk_clamp_index(k, 3)]" in H.helper("at", mode=FLOAT64, shape=(3,)).text + assert "x.v[k]" in H.helper("at_raw", mode=FLOAT64, shape=(3,)).text + assert "hawk_clamp_index" not in H.helper("set_raw", mode=FLOAT64, shape=(3,)).text + assert "[j / 3][j % 3]" in H.helper("at", mode=FLOAT64, shape=(2, 3)).text + + +def test_sign_nan_depends_on_the_core_sign(): + for mode in MODES: + w = S.WIDTH[mode.id] + h = H.helper("sign_nan", mode=mode) + assert h.deps == (c_core.function("sign", width=w).name,) + assert h.libm == () + assert f"aether_c_{w}_sign(x)" in h.text + + +def test_deps_and_libm(): + assert H.helper("load", mode=FLOAT32, shape=(3,)).deps == ("aether_c_f32_vec3_t",) + assert H.helper("spacing", mode=FLOAT32).libm == ("nextafterf",) + assert H.helper("spacing", mode=FLOAT64).libm == ("nextafter",) + assert H.helper("clamp_index", mode=FLOAT64).deps == () + for name in ("at", "set"): + h = H.helper(name, mode=FLOAT64, shape=(3,)) + assert h.deps == ("aether_c_f64_vec3_t", "hawk_clamp_index") + assert H.helper("at_raw", mode=FLOAT64, shape=(3,)).deps == ("aether_c_f64_vec3_t",) + + +def test_header_line_and_no_line_comments(): + for name in H.HELPER_NAMES: + shape = (3,) if name in H._SHAPED else None + for mode in MODES: + text = H.helper(name, mode=mode, shape=shape).text + assert text.startswith(f"/* hawk: {name} {shape or ()} {mode.id} */\n") + assert "//" not in text + assert "static inline" in text + + +def test_unknown_name_and_missing_shape(): + with pytest.raises(HawkError): + H.helper("nope", mode=FLOAT64) + with pytest.raises(HawkError): + H.helper("load", mode=FLOAT64) + + +def _unit(mode): + w = S.WIDTH[mode.id] + real = "double" if w == "f64" else "float" + funcs = [] + for name in H.HELPER_NAMES: + if name in H._SHAPED: + funcs += [H.helper(name, mode=mode, shape=s) for s in SHAPES] + else: + funcs.append(H.helper(name, mode=mode)) + by_name = {f.name: f for f in funcs} + core = [c_core.real_typedef(w).name] + core += [c_core.struct(s, w).name for s in SHAPES] + core += [c_core.function("sign", width=w).name] + ordered, seen = [], set() + + def visit(f): + if f.name in seen: + return + seen.add(f.name) + for dep in f.deps: + if dep.startswith("hawk_"): + visit(by_name[dep]) + else: + core.append(dep) + ordered.append(f) + + for f in funcs: + visit(f) + parts = [ + "#include \n#include \n#include \n", + f"typedef {real} hawk_real_t;\ntypedef int64_t hawk_int_t;\n", + c_core.emit(core), + ] + parts += [f.text for f in ordered] + return "\n".join(parts) + + +@pytest.mark.parametrize("mode", MODES, ids=lambda m: m.id) +def test_every_helper_compiles_as_strict_c99(tmp_path, mode): + cc = shutil.which("cc") + assert cc is not None, "a C compiler (cc) is required" + src = tmp_path / "helpers.c" + src.write_text(_unit(mode)) + proc = subprocess.run( + [cc, "-std=c99", "-pedantic", "-Wall", "-Wextra", "-Wconversion", + "-Wsign-conversion", "-Wdouble-promotion", "-Wno-unused-function", + "-Werror", "-fsyntax-only", str(src)], + capture_output=True, text=True, + ) + assert proc.returncode == 0, proc.stderr diff --git a/tests/test_embedded_refusals.py b/tests/test_embedded_refusals.py new file mode 100644 index 0000000..4ce754c --- /dev/null +++ b/tests/test_embedded_refusals.py @@ -0,0 +1,184 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The C target refuses every unbuilt feature BY NAME, before anything renders. + +One planted kernel per :data:`hawk.embedded.verify.UNSUPPORTED` key, each +tripping only its own row; the message names the C target, the kernel, the +feature (``unsupported.``) and the alternative an author can take. +""" + +from __future__ import annotations + +import _deployable as D +import _kernels as K +import pytest + +import hawk +import hawk.math as hm +from hawk import Index, Mutable, Param, Scalar, Terminated, Vector, all_samples +from hawk.embedded.verify import UNSUPPORTED, refuse_unsupported +from hawk.emit import compose +from hawk.ext import DEFAULT_KIND, Guard, Kind +from hawk.ir import Assign, HawkError, Leaf, canonical +from hawk.ir.loop_nodes import Loop, LoopCarry, LoopIndex, LoopValue +from hawk.ir.nodes import AccumWrite, Const +from hawk.ir.ops import make +from hawk.trace.kernel import Kernel +from hawk.types import TensorType + +F64 = TensorType((), "f64") + + +@hawk.kernel +def draw(seed: Index, counter: Index, y: Mutable[Scalar]): + y = hm.random.uniform(seed, counter) + + +_COPY = hawk.raw_device("mut_y = psc_x;", access="sample_local", returns=Scalar) + + +@hawk.kernel +def spliced(x: Scalar, y: Mutable[Scalar]): + y = _COPY(x) + + +ACTIVE = Kind("verify_active_rows", guard=Guard(active_set=True)) + + +@hawk.kernel(kind=ACTIVE) +def active(omega: Scalar, dt: Param, terminated: Terminated, x: Mutable[Scalar]): + x = x + dt * omega + + +@hawk.kernel +def segmented(k: Index, b0: Scalar, b1: Scalar, y: Mutable[Scalar]): + y = hm.dispatch(k, [b0, b1], policy="segmented") + + +@hawk.kernel +def lane_a(x: Scalar, p: Mutable[Scalar]): + p = x * 2.0 + + +@hawk.kernel +def lane_b(x: Scalar, q: Mutable[Scalar]): + q = x + 2.0 + + +@hawk.kernel(steps=1) +def advance(dt: Param, t_final: Scalar, terminated: Terminated, x: Mutable[Scalar]): + xn = x + dt + x = xn + terminated = xn >= t_final + + +@hawk.kernel +def int_hypot(a: Index, b: Index, y: Mutable[Index]): + y = hm.hypot(a, b) + + +@hawk.kernel +def int_min_max_abs(a: Index, b: Index, y: Mutable[Index]): + y = hm.minimum(a, b) + hm.maximum(a, b) + abs(a) + + +def _vector_scatter(): + """A scattered accumulate of a ``Vector[3]``, as IR: hawk's tracer refuses + ``all_samples(acc)[lane] += v`` for a vector itself (one plane per + component), so no ``@hawk.kernel`` reaches the node; the C target keeps + its own refusal behind that one.""" + v = Leaf("vocab_read", "vec_in", "v", TensorType((3,), "f64")) + lane = Leaf("vocab_read", "per_sample", "lane", TensorType((), "i64")) + return Kernel("vector_scatter", + (AccumWrite("acc", v, lane, TensorType((3,), "f64")),)) + + +@hawk.kernel +def gather_other(x: Scalar, i: Index, y: Mutable[Scalar]): + y = all_samples(x)[i] + + +def _zero_trip(): + """A ``range(0)`` loop as IR. The frontend collapses ``for _ in range(0)`` + to the loop's initial values before a ``Loop`` is minted + (``hawk/trace/astpass.py``), so no ``@hawk.kernel`` reaches the node; the + IR constructor does, and the C target must still refuse it (MISRA 14.3).""" + x = Leaf("vocab_read", "per_sample", "x", F64) + carry = LoopCarry(0, 0, "s", F64) + loop = Loop(LoopIndex(0, "k"), (carry,), (x,), + (make("mul", (carry, Const(2.0, F64))),), start=0, stop=0, step=1) + return Kernel("zero_trip", (Assign("y", LoopValue(loop, 0), F64),)) + + +@hawk.kernel +def tensor_select(v: Vector[3], y: Mutable[Vector[3]]): + y = hm.where(v > 0.0, v, -v) + + +def _case(kernel, kind=None): + name = kernel.name + kind = kind if kind is not None else getattr(kernel, "kind", DEFAULT_KIND) + return name, kernel, kind, kernel.walk, kernel.sinks + + +#: feature -> (planted case, the FIX text the message must name). +CASES = { + "random": (lambda: _case(draw), "no counter-based draw is built for C yet"), + "raw_device": (lambda: _case(spliced), "spell the block with hawk.math"), + "sample_reduction": (lambda: _case(K.energy), + "commit the own column and reduce in the caller"), + "cross_sample_at": (lambda: _case(gather_other), + "pass the values the kernel needs as a Table plane"), + "active_set": (lambda: _case(active), "run without the index map"), + "segmented": (lambda: _case(segmented), "export each branch as its own kernel"), + "compensated": (lambda: _case(D.scatter_c, D.COMPENSATED), + "use the plain sink policy"), + "fused_lanes": (lambda: _case(compose([lane_a, lane_b], "fused"), DEFAULT_KIND), + "export each lane as its own kernel"), + "two_path": (lambda: _case(hawk.steps(advance, "auto")), + "export `kernel.step` (the single step) or `hawk.steps(step, K)` " + "with an integer K"), + "int_libm": (lambda: _case(int_hypot), + "use maximum(minimum(i, hi), lo) (Int min/max are allowed) or " + "convert to Real explicitly"), + "scattered_tensor_accum": (lambda: _case(_vector_scatter()), + "declare one scalar accumulate plane per component"), + "zero_trip_loop": (lambda: _case(_zero_trip()), "give the loop at least one trip"), + "tensor_select_cond": (lambda: _case(tensor_select), "select per component"), +} + + +def test_every_unsupported_feature_has_a_planted_kernel(): + assert list(CASES) == list(UNSUPPORTED) + + +@pytest.mark.parametrize("feature", list(UNSUPPORTED)) +def test_each_row_carries_a_why_and_a_specific_fix(feature): + why, fix = UNSUPPORTED[feature][1] + assert why and fix and CASES[feature][1] in fix + + +@pytest.mark.parametrize("feature", list(UNSUPPORTED)) +def test_the_c_target_refuses_by_name(feature): + build, alternative = CASES[feature] + name, kernel, kind, walk, sinks = build() + with pytest.raises(HawkError) as err: + refuse_unsupported(name, kernel, kind, walk, sinks) + message = str(err.value) + for needle in ("the C target", f"unsupported.{feature}", f"{name!r}", alternative): + assert needle in message, (needle, message) + + +@pytest.mark.parametrize("kernel", [K.drag, K.gather, D.scatter, int_min_max_abs, + advance, hawk.steps(advance, 4)], + ids=lambda k: k.name) +def test_a_supported_kernel_passes(kernel): + refuse_unsupported(kernel.name, kernel, kernel.kind, kernel.walk, kernel.sinks) + + +def test_a_derivative_twin_passes(): + from hawk.diff import vjp + + sinks = vjp(K.drag) + refuse_unsupported("drag_vjp", sinks, DEFAULT_KIND, canonical(sinks), sinks) diff --git a/tests/test_embedded_render.py b/tests/test_embedded_render.py new file mode 100644 index 0000000..006405f --- /dev/null +++ b/tests/test_embedded_render.py @@ -0,0 +1,287 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The standalone C target's renderer: ``hawk.embedded.render`` turns a kernel +into a ``.c``/``.h`` pair through the one body traversal and the C dialect. + +Rows: + +* **header** — the args struct (one typed field per slot, pointer constness + from the interface direction), the status-returning prototype and the + ``HAWK_EMBEDDED_TYPES`` width guard; +* **body** — every rank>=1 value is a named C struct value, every fold a CORE + function inlined above the entry, the SoA pitch is ``n_samples`` (never the + partition's ``count``), a float32 unit never names ``double``; +* **corpus** — every corpus kernel (``energy`` is a sum over samples, refused) renders + in both modes, deterministically, with no aether C++ left in it; +* **sites** — a scattered accumulate is a clamped ``+=``, a runtime-count loop + clamps its count to the static trip, the guard and the finish epilogue spell + the ``uint8_t`` masks. +""" + +from __future__ import annotations + +import re +from types import SimpleNamespace + +import _deployable as D +import _kernels as K +import pytest + +import hawk +from hawk import Mutable, Scalar +from hawk.embedded import render +from hawk.emit import FLOAT32, FLOAT64 +from hawk.ir import HawkError, canonical + +DRAG_H_STRUCT = ("typedef struct {\n hawk_real_t* mut_out;\n const hawk_real_t* vin_state;\n" + " hawk_real_t uni_rho;\n} drag_args_t;\n") # exact comment columns per abi.py's formatter + +#: The field comments the spec's ``drag.h`` carries after each member; the +#: struct row compares the declarations themselves. +_FIELD_COMMENT = re.compile(r"[ ]*/\*[^\n]*?\*/(?=\n)") + + +def _derived(name, sinks): + """A kernel-shaped object over a derived sink set (``vjp`` returns sinks, + not a :class:`~hawk.trace.kernel.Kernel`): what ``render`` reads of one.""" + return SimpleNamespace(name=name, sinks=tuple(sinks), walk=canonical(sinks), + kind=None) + + +def test_drag_header_struct_and_prototype(): + s = render(K.drag, mode="float64") + header = _FIELD_COMMENT.sub("", s.header) + assert DRAG_H_STRUCT.replace(" ", " ") in header.replace(" ", " ") # whitespace-normalised + assert "hawk_status_t drag_c(const drag_args_t* args, int64_t base, int64_t count, int64_t n_samples);" in s.header + assert "#define HAWK_EMBEDDED_TYPES 64" in s.header + + +def test_drag_body_names_every_tensor_and_uses_the_core(): + s = render(K.drag) + assert "const aether_c_f64_vec3_t vin_state_i = hawk_vec3_load(vin_state, hawk_i, n_samples);" in s.text + assert "aether_c_f64_vec3_norm(" in s.text and "hawk_vec3_store(mut_out, hawk_i, n_samples, " in s.text + assert s.report["core"] == sorted(s.report["core"]) and "aether_c_f64_vec3_norm" in s.report["core"] + for fn in s.report["core"]: + assert s.text.count("/* aether-c: ") >= 1 and fn + "(" in s.text + + +def test_pitch_is_n_samples_not_count(): + loop = render(K.drag).text.split("for (int64_t hawk_i")[1] + assert "hawk_vec3_load(vin_state, hawk_i, n_samples)" in loop and "hawk_i, count)" not in loop + + +def test_float32_text_never_promotes(): + s = render(K.drag, mode=FLOAT32.id) + assert "sqrtf(" in s.text and "double" not in s.text and "aether_c_f32_vec3_t" in s.text + + +def test_every_corpus_kernel_renders_in_both_modes(): + for name, k in K.CORPUS.items(): + if name == "energy": + continue + for mode in (FLOAT64, FLOAT32): + s = render(k, mode=mode.id) + assert ".eval(" not in s.text and "aether::" not in s.text and "std::" not in s.text + assert render(k, mode=mode.id).text == s.text + + +def test_int_arithmetic_routes_through_wrapping_helpers(): + s = render(K.scatter) # `x * x` is Real; the Index lane is used as an index: expect hawk_clamp_index + assert "hawk_clamp_index(" in s.text and "+=" in s.text + import test_int_planes as I + + ints = render(I.ops_i32) + for helper in ("hawk_i32_add(", "hawk_i32_mul(", "hawk_i32_floordiv(", "hawk_i32_mod("): + assert helper in ints.text + assert "hawk_u32_add(" in render(I.ops_u32).text + + +def test_an_integer_param_is_a_64_bit_field(): + import test_int_planes as I + + s = render(I.casts) + assert " hawk_int_t uni_s;" in _FIELD_COMMENT.sub("", s.header) + assert re.search(r"\* uni_s +uniform +in +i64 ", s.header) + assert "" not in s.header + + +def test_two_masks_guard_and_finish(): + import test_finish_own_sample as F + + two = render(D.diagnostic_two, kind=D.TWO_MASKS) + assert ("if (!((trm_terminated[hawk_i] != (uint8_t)0) || " + "(trm_rejected[hawk_i] != (uint8_t)0))) {") in two.text + assert "const uint8_t* const trm_rejected = args->trm_rejected;" in two.text + one = render(D.diagnostic) + assert "if (!(trm_terminated[hawk_i] != (uint8_t)0)) {" in one.text + fin = render(F.stop_only) + assert "if ((trm_terminated[hawk_i] == (uint8_t)0) && " in fin.text + assert "trm_terminated_w[hawk_i] = (uint8_t)1;" in fin.text + assert ("lut_finished_count[0] = lut_finished_count[0] + 1U;" in fin.text) + # the finish mask is written, so its field is non-const and the twin binds it + assert " uint8_t* trm_terminated;" in fin.header + assert "uint8_t* const trm_terminated_w = args->trm_terminated;" in fin.text + assert "uint32_t*" in fin.header and "lut_finished_count" in fin.header + + +def test_two_masks_and_a_finish_in_one_kernel(): + """``two_fin.step`` under ``TWO_MASKS``: the guard reads BOTH mask bytes, + the finish writes only its own mask (through the writable twin), and the + other mask stays a ``const`` input.""" + one = render(D.two_fin.step, kind=D.TWO_MASKS) + guard = ("if (!((trm_terminated[hawk_i] != (uint8_t)0) || " + "(trm_rejected[hawk_i] != (uint8_t)0))) {") + assert one.text.count(guard) == 1 + body = one.text.split(guard, 1)[1] + assert "if ((trm_terminated[hawk_i] == (uint8_t)0) && " in body + assert "trm_terminated_w[hawk_i] = (uint8_t)1;" in body + assert "trm_rejected_w" not in one.text + assert not re.search(r"trm_rejected\[hawk_i\] = ", one.text) + assert "const uint8_t* const trm_rejected = args->trm_rejected;" in one.text + assert "uint8_t* const trm_terminated_w = args->trm_terminated;" in one.text + header = _FIELD_COMMENT.sub("", one.header) + assert " const uint8_t* trm_rejected;" in header + assert " uint8_t* trm_terminated;" in header + rows = {r["field"]: r for r in one.report["interface"]} + assert rows["trm_rejected"]["direction"] == "in" + assert rows["trm_terminated"]["direction"] == "inout" + assert rows["mut_y"]["reason"] == ( + "masked samples keep the prior value (guard: trm_terminated, trm_rejected)") + + +def test_runtime_count_loop_clamps_to_trip(): + """A vjp through a head ``break`` reads the forward loop's iteration count + back in its reverse loop: a RUNTIME count the C clamps to the static trip.""" + import test_loop_early_exit as E + + from hawk.diff import vjp + + k = _derived("head_vjp", vjp(E.head, wrt=("x",))) + for mode in (FLOAT64, FLOAT32): + s = render(k, mode=mode.id) + assert "hawk_clamp_trips(" in s.text + assert re.search(r"const int32_t hawk_cnt\d+ = hawk_clamp_trips\(\w+, " + r"INT32_C\(12\)\);", s.text) + assert "if (hawk_v" in s.body and "if ((" not in s.body and "break;" in s.body + + +def test_a_runtime_count_header_never_multiplies_by_one(): + """MISRA C:2012 Rule 2.2: an operation with no effect is dead code. A + runtime-count header scales by the step's magnitude only when it is not 1 + (cppcheck 2.13 reports ``(12 - cnt) * INT32_C(1)``).""" + import test_loop_early_exit as E + + from hawk.diff import vjp + + for name, fwd in (("head_vjp", E.head), ("tail_vjp", E.tail)): + k = _derived(name, vjp(fwd, wrt=("x",))) + for mode in (FLOAT64, FLOAT32): + text = render(k, mode=mode.id).text + assert "hawk_clamp_trips(" in text + assert not re.search(r"\* (INT32_C|INT64_C)\(1\)\)", text), name + + +def test_a_vjp_kernel_renders(): + """``drag_vjp()`` is a sink set, not a Kernel; :func:`_derived` gives it the + three attributes ``render`` reads (name, sinks, walk; kind defaults).""" + s = render(_derived("drag_vjp", K.drag_vjp())) + assert "hawk_status_t drag_vjp_c(const drag_vjp_args_t* args" in s.header + assert "aether_c_f64_vec3_dot(" in s.text or "aether_c_f64_vec3_sum(" in s.text + assert "aether_c_f64_vec3_scale(" in s.text + assert "mut_bar_rho[hawk_i] = " in s.text + assert "hawk_vec3_store(mut_bar_state, hawk_i, n_samples, " in s.text + + +def test_scattered_accumulate_is_a_clamped_plus_equals(): + s = render(D.scatter) + assert "aout_acc[hawk_clamp_index(psc_lane[hawk_i], aout_acc_len)] += " in s.text + assert " int64_t aout_acc_len;" in _FIELD_COMMENT.sub("", s.header) + (row,) = [r for r in s.report["interface"] if r["field"] == "aout_acc"] + assert row["direction"] == "inout" and row["runtime_length"] is True + + +@hawk.kernel +def eq_break(x: Scalar, lim: Scalar, y: Mutable[Scalar]): + s = x + for _k in range(8): + if s == lim: + break + s = s * 0.5 + 1.0 + y = s + + +def test_a_head_break_on_an_equality_is_parenthesised_once(): + """clang's ``-Wparentheses-equality`` rejects ``if ((a == b))``: the + break's ``if`` wraps a condition only when it is not already one + parenthesised expression.""" + for mode in (FLOAT64, FLOAT32): + s = render(eq_break, mode=mode.id) + assert "if ((" not in s.body + assert re.search(r"if \(hawk_v\w+ == psc_lim\w*\) \{", s.body) + + +def test_the_branch_mix_plane_is_k_cells(): + import test_dispatch_mix_and_guard as G + + s = render(G.mix_auto) + (row,) = [r for r in s.report["interface"] if r["field"] == "lut_dispatch0_mix"] + assert row["shape"] == [3] + assert re.search(r"uint32_t\* +lut_dispatch0_mix; +/\* lookup +inout +Vector\[3\] +" + r"3 cells, the caller zeroes them \*/", s.header) + assert re.search(r"\* lut_dispatch0_mix +lookup +inout +u32 +\(3,\) ", s.header) + assert "counter, the caller zeroes it" not in s.header + + +def test_a_clamped_tape_read_is_refused_by_name(monkeypatch): + """No fixture produces one today: the renderer spells a tape subscript + itself, so a clamped tape read could only render raw.""" + from hawk.embedded import verify + + real = verify.ir_checks + + def planted(*args, **kwargs): + report = real(*args, **kwargs) + report["indices"].append({"node": "loop7.3", "kind": "tape_read", + "status": "clamped", "reason": "planted", + "length": 12}) + return report + + monkeypatch.setattr(verify, "ir_checks", planted) + with pytest.raises(HawkError) as err: + render(K.drag) + message = str(err.value) + for needle in ("'drag'", "node loop7.3", "(tape_read)", "index.unproven", + "the C target cannot clamp a tape subscript yet"): + assert needle in message + + +def test_per_component_commits_stay_in_the_aether_dialect(tmp_path): + """hawk 0.6.4 stores a vector built from scalars one component at a time + (``target(k, i.global()) = part``) so a wide value is never live whole. That + is an aether-view optimisation: the C dialect keeps the single store, and the + unit passes every check (render, audit, strict cc, cppcheck).""" + import test_math_parity as MP + + from hawk.embedded import export + + for mode in (FLOAT64, FLOAT32): + body = render(MP.unary_all, mode=mode.id).body + assert "i.global()" not in body and "hawk_vec" in body + export(MP.unary_all, tmp_path / mode.id, mode=mode.id) + + +def test_a_runtime_index_accumulate_in_a_loop_is_refused_by_name(): + """A vjp through a rolled, runtime-indexed loop accumulates its gradient in + place, ``bar[i].at(k) += g`` per iteration: the C target does not lower it + and says so, rather than emitting aether view syntax.""" + import _spline_forms as SF + import test_rolled_reverse as RR + + from hawk.diff import vjp + + k = _derived("kan_vjp", vjp(SF.kan_rolled("plain", 8), wrt=RR.WRT)) + with pytest.raises(HawkError) as err: + render(k) + text = str(err.value) + assert "'kan_vjp'" in text and "the C target" in text and "in place" in text diff --git a/tests/test_embedded_spelling.py b/tests/test_embedded_spelling.py new file mode 100644 index 0000000..d95f395 --- /dev/null +++ b/tests/test_embedded_spelling.py @@ -0,0 +1,64 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the standalone C target's spelling tables.""" + +import pytest + +from hawk.embedded import spelling as S +from hawk.emit import FLOAT32, FLOAT64 +from hawk.ir import HawkError +from hawk.types import TensorType + + +def test_ctype_by_rank_and_mode(): + assert S.ctype(TensorType((), "f64"), FLOAT64) == "hawk_real_t" + assert S.ctype(TensorType((3,), "f32"), FLOAT32) == "aether_c_f32_vec3_t" + assert S.ctype(TensorType((2, 3), "f64"), FLOAT64) == "aether_c_f64_mat2x3_t" + assert S.ctype(TensorType((), "i32"), FLOAT64) == "hawk_int_t" + assert S.ctype(TensorType((), "uint32"), FLOAT64) == "uint32_t" + assert S.ctype(TensorType((), "bool"), FLOAT64) == "bool" + + +def test_shape_word(): + assert S.shape_word((3,)) == "vec3" + assert S.shape_word((2, 3)) == "mat2x3" + assert S.shape_word(()) == "scalar" + + +def test_libm_names_carry_the_f_suffix_only_in_float32(): + assert S.libm_name("sqrt", FLOAT64) == "sqrt" + assert S.libm_name("sqrt", FLOAT32) == "sqrtf" + assert S.libm_name("fmin", FLOAT32) == "fminf" + assert S.libm_name("nextafter", FLOAT32) == "nextafterf" + + +def test_libm_unknown_name_lists_known_names(): + with pytest.raises(HawkError, match="sqrt"): + S.libm_name("isnan", FLOAT64) + + +def test_real_literals_are_cast_double_literals_in_both_modes(): + assert S.real_literal(1.5, FLOAT64) == "((hawk_real_t)1.5)" + assert S.real_literal(1.5, FLOAT32) == "((hawk_real_t)1.5)" + assert S.real_literal(float("-inf"), FLOAT64) == "((hawk_real_t)-INFINITY)" + assert S.real_literal(float("nan"), FLOAT32) == "((hawk_real_t)NAN)" + assert S.real_literal(2.0, FLOAT64) == "((hawk_real_t)2.0)" + + +def test_int_literals_and_helpers(): + assert S.int_literal(-3, "i32") == "INT64_C(-3)" + assert S.int_literal(7, "uint32") == "UINT32_C(7)" + assert S.int_literal(5, "int32") == "INT32_C(5)" + assert S.int_literal(True, "bool") == "true" + assert S.int_literal(0, "bool") == "false" + assert S.INT_HELPER[("add", "i32")] == "hawk_i64_add" + assert S.INT_HELPER[("add", "i64")] == "hawk_i64_add" + assert S.INT_HELPER[("band", "int32")] == "hawk_i32_and" + assert S.INT_HELPER[("from_real", "uint32")] == "hawk_u32_from_real" + assert len(S.INT_HELPER) == 17 * 4 + + +def test_clamps(): + assert S.clamp_index("t4", "lut_t_len") == "hawk_clamp_index(t4, lut_t_len)" + assert S.clamp_trips("n", 8) == "hawk_clamp_trips(n, 8)" diff --git a/tests/test_embedded_toolchain.py b/tests/test_embedded_toolchain.py new file mode 100644 index 0000000..e793a2a --- /dev/null +++ b/tests/test_embedded_toolchain.py @@ -0,0 +1,207 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The embedded C toolchain: discovery, strict flags, checks, shared build.""" + +from __future__ import annotations + +import ctypes +import shutil +import subprocess +import sys + +import pytest + +from hawk.embedded import toolchain as tc +from hawk.ir import HawkError + +VALID_C = """\ +#include "t.h" + +int t_add(int a, int b) +{ + return a + b; +} +""" +VALID_H = """\ +#ifndef T_H +#define T_H + +int t_add(int a, int b); + +#endif +""" +# cppcheck sees one translation unit, so MISRA 8.7 flags any external function +# (nothing else references it); the clean file defines only main and a static function. +CLEAN_C = """\ +#include "t.h" + +static int t_square(int a) +{ + return a * a; +} + +int main(void) +{ + return t_square(0); +} +""" +CLEAN_H = "#ifndef T_H\n#define T_H\n\n#endif\n" +#: A float promoted to double to meet a double operand: refused by GCC and Clang +#: alike under -Wdouble-promotion. (A float passed to ``sqrt``'s double +#: parameter is refused by Clang only; the float32 text rows of +#: test_embedded_render pin that no bare ``sqrt(`` is ever emitted.) +PROMOTE_C = """\ +#include "t.h" + +float t_twice(float x) +{ + return (float)(x * 2.0); +} +""" +PROMOTE_H = "#ifndef T_H\n#define T_H\n\nfloat t_twice(float x);\n\n#endif\n" + + +def test_hawk_cc_must_resolve(monkeypatch): + monkeypatch.setenv("HAWK_CC", "/no/such/compiler-xyz") + with pytest.raises(HawkError, match="/no/such/compiler-xyz"): + tc.c_compiler() + + +def test_c_sibling_of_hawk_cxx(monkeypatch): + if shutil.which("clang") is None: + pytest.skip("clang not on PATH") + monkeypatch.delenv("HAWK_CC", raising=False) + monkeypatch.setenv("HAWK_CXX", "clang++") + assert tc.c_compiler() == shutil.which("clang") + + +def test_parse_cppcheck_lines(): + text = ( + "t.c:12:5: style: Variable 'x' is assigned a value. [unreadVariable]\n" + "noise line\n" + "/tmp/a b/t.c:7:1: style: Rule 10.4 [misra-c2012-10.4]\n" + ) + assert tc.parse_cppcheck(text) == [ + {"file": "t.c", "line": 12, "id": "unreadVariable", + "text": "Variable 'x' is assigned a value."}, + {"file": "/tmp/a b/t.c", "line": 7, "id": "misra-c2012-10.4", + "text": "Rule 10.4"}, + ] + + +def test_c_check_flags_rejects_other_std(): + assert tc.c_check_flags("c99")[0] == "-std=c99" + with pytest.raises(HawkError): + tc.c_check_flags("c17") + + +def test_run_checks_on_a_hand_written_c_file(monkeypatch): + monkeypatch.delenv("HAWK_CXX", raising=False) + result = tc.run_checks(CLEAN_C, CLEAN_H, "t") + assert [c["std"] for c in result["cc"]] == ["c99", "c11"] + assert all(c["rc"] == 0 for c in result["cc"]) + assert result["cxx_identity"] is None + assert "||" in result["cc_identity"] + cpp = result["cppcheck"] + assert "skipped" in cpp or (cpp["rc"] == 0 and cpp["findings"] == []) + assert result["seconds"] >= 0.0 + + +def test_run_checks_requires_cppcheck_when_profile_says_so(monkeypatch): + class Profile: + require_cppcheck = True + + monkeypatch.setattr(tc, "cppcheck", lambda: None) + with pytest.raises(HawkError, match="cppcheck"): + tc.run_checks(VALID_C, VALID_H, "t", Profile()) + + +def test_run_checks_refuses_a_double_promotion(tmp_path): + with pytest.raises(HawkError) as err: + tc.run_checks(PROMOTE_C, PROMOTE_H, "t") + text = str(err.value) + argv_line, _, stderr = text.partition("\n$ ")[2].partition("\n") + assert "-Wdouble-promotion" in argv_line + assert "double-promotion" in stderr + # The flag is what refuses it: the same file compiles without it. + (tmp_path / "t.c").write_text(PROMOTE_C) + (tmp_path / "t.h").write_text(PROMOTE_H) + flags = [f for f in tc.c_check_flags("c99") if f != "-Wdouble-promotion"] + done = subprocess.run([tc.c_compiler(), *flags, "t.c"], cwd=tmp_path, + capture_output=True, text=True) + assert done.returncode == 0, done.stderr + + +def test_a_timeout_is_a_hawk_error(monkeypatch): + def boom(*a, **k): + raise subprocess.TimeoutExpired(a[0], 600) + + monkeypatch.setattr(tc.subprocess, "run", boom) + with pytest.raises(HawkError, match="timed out after 600s"): + tc._run(["cc", "x.c"], ".") + + +def test_compile_shared_builds_a_loadable_library(tmp_path): + c_path = tmp_path / "t.c" + c_path.write_text(VALID_C) + (tmp_path / "t.h").write_text(VALID_H) + suffix = ".dylib" if sys.platform == "darwin" else ".so" + out = tc.compile_shared(c_path, tmp_path / f"libt{suffix}") + lib = ctypes.CDLL(str(out)) + lib.t_add.argtypes = [ctypes.c_int, ctypes.c_int] + lib.t_add.restype = ctypes.c_int + assert lib.t_add(2, 3) == 5 + + +def test_run_checks_passes_an_exported_function(): + if tc.cppcheck() is None: + pytest.skip("cppcheck not installed") + result = tc.run_checks(VALID_C, VALID_H, "t") + assert result["cppcheck"]["findings"] == [] + assert result["cppcheck"]["suppressed"] == [d["rule"] for d in tc.DEVIATIONS] + + +#: A unit that uses every constant macro the emitted C uses. +STDINT_C = """\ +#include +#include "t.h" + +int64_t t_pick(int64_t a, uint32_t b) +{ + int64_t out; + if ((a < INT64_C(0)) && (b < UINT32_C(32))) { + out = INT64_C(-1); + } else { + out = (int64_t)INT32_C(7); + } + return out; +} +""" +STDINT_H = ("#ifndef T_H\n#define T_H\n#include \n\n" + "int64_t t_pick(int64_t a, uint32_t b);\n\n#endif\n") + + +def test_cppcheck_knows_the_stdint_constant_macros(): + """The run never reads (``missingIncludeSystem``), and an older + cppcheck's standard library does not declare ``INT64_C`` and the rest, so + without hawk's library file the misra addon reports every use as an + implicitly declared function (Rule 17.3; cppcheck 2.13, Ubuntu 24.04's).""" + checks = tc.run_checks(STDINT_C, STDINT_H, "t") + assert checks["cppcheck"].get("findings") == [] + argv = checks["cppcheck"]["argv"] + lib = next(a for a in argv if a.startswith("--library=")) + assert lib.endswith(tc.STDINT_LIBRARY_NAME) + + +def test_the_stdint_library_declares_every_constant_macro(): + for macro in ("INT8_C", "INT16_C", "INT32_C", "INT64_C", "UINT8_C", "UINT16_C", + "UINT32_C", "UINT64_C", "INTMAX_C", "UINTMAX_C"): + assert f'= 20 + assert set(ops) >= {"arith32", "arith64", "special32", "special64", "loads", + "gathers", "stores"} + with pytest.raises(HawkError, match=r"'twenty_ops'.*budget\.ops"): + apply_caps("twenty_ops", report, EmbeddedProfile(max_ops=5)) + apply_caps("twenty_ops", report, EmbeddedProfile(max_ops=50)) + assert report["budgets"]["fp64_ops"] >= 20 + + +@hawk.kernel +def recurrence(x: Scalar, g: Param, y: Mutable[Scalar]): + s = x + for _k in range(64): + s = hm.tanh(g * s) + y = s + + +def test_tape_bytes_feed_the_memory_estimate(): + sinks = vjp(recurrence) + walk = canonical(sinks) + report = ir_checks("recurrence_vjp", sinks, walk, DEFAULT_KIND, EmbeddedProfile()) + static = report["budgets"]["static_bytes"] + (site,) = report["indices"] + assert (site["kind"], site["status"], site["length"]) == ("tape_read", "proven", 64) + taped_slots = 1 # the forward loop's one f64 carry + assert static["tapes"] == 64 * 8 * taped_slots + # forward loop: one f64 carry; reverse loop: two f64 carries + assert static["carries"] == 3 * 8 + assert static["estimate"] == static["tapes"] + static["carries"] + assert static["temporaries"] is None and static["total"] is None + with pytest.raises(HawkError, match=r"budget\.stack"): + apply_caps("recurrence_vjp", report, EmbeddedProfile(max_stack_bytes=256)) + apply_caps("recurrence_vjp", report, EmbeddedProfile(max_stack_bytes=1024)) + + +def test_a_loop_free_kernel_has_no_trips_and_no_static_bytes(): + report = _report(K.drag) + assert report["budgets"]["max_trips"] == 0 + assert report["budgets"]["loops"] == [] + apply_caps("drag", report, EmbeddedProfile(max_trips=0, max_stack_bytes=0)) + assert report["budgets"]["static_bytes"]["estimate"] == 0 + assert report["unsupported_checked"] == sorted(UNSUPPORTED) + + +# --------------------------------------------------------------------------- # +# b. Index sites. +# --------------------------------------------------------------------------- # +def test_table_read_is_clamped_and_refusable(): + report = _report(K.gather) + site = report["indices"][0] + assert site["kind"] == "table_read" + assert site["status"] == "clamped" + assert site["length"] == "lut_table_len" + assert index_status(report, site["node"]) == "clamped" + apply_caps("gather", report, EmbeddedProfile()) # clamp is the default + with pytest.raises(HawkError) as err: + apply_caps("gather", report, EmbeddedProfile(index_policy="refuse")) + message = str(err.value) + assert f"node {site['node']} " in message + assert "index.unproven" in message and "'gather'" in message + + +@hawk.kernel +def component_sum(v: Vector[3], y: Mutable[Scalar]): + s = 0.0 + for k in range(3): + s = s + v[k] + y = s + + +def test_component_at_with_an_in_range_loop_index_is_proven(): + report = _report(component_sum) + sites = [s for s in report["indices"] if s["kind"] == "component_at"] + assert len(sites) == 1 + assert sites[0]["status"] == "proven" + assert sites[0]["length"] == 3 + assert isinstance(sites[0]["node"], str) and sites[0]["node"].startswith("loop") + assert index_status(report, sites[0]["node"]) == "proven" + apply_caps("component_sum", report, EmbeddedProfile(index_policy="refuse")) + + +def test_a_site_is_found_from_its_node_object(): + report = _report(K.gather) + walk, sinks = K.gather.walk, K.gather.sinks + (at,) = [n for n in walk.order if isinstance(n, At)] + (site,) = report["indices"] + assert positions(sinks)[id(at)] == site["node"] + assert site_status(report, sinks, at) == "clamped" + with pytest.raises(HawkError, match="not an index site"): + site_status(report, sinks, walk.order[-1]) + + report = _report(component_sum) + walk, sinks = component_sum.walk, component_sum.sinks + (i, loop), = [(i, n) for i, n in enumerate(walk.order) if isinstance(n, Loop)] + k, node = next((k, n) for k, n in enumerate(loop.body_nodes()) + if n.kind == "component_at") + assert positions(sinks)[id(node)] == f"loop{i}.{k}" + assert site_status(report, sinks, node) == "proven" + every = positions(sinks) + assert len(every) == len(walk.order) + len(loop.body_nodes()) + + +@hawk.kernel +def switched(k: Index, b0: Scalar, b1: Scalar, y: Mutable[Scalar]): + y = hm.dispatch(k, [b0, b1], policy="switch") + + +def test_a_scattered_sink_and_a_dispatch_selector_are_clamped(): + report = _report(D.scatter) + (site,) = report["indices"] + assert (site["kind"], site["status"], site["length"]) == ( + "scatter", "clamped", "aout_acc_len") + report = _report(switched) + (site,) = report["indices"] + assert (site["kind"], site["status"], site["length"]) == ( + "dispatch", "clamped", 2) + + +# --------------------------------------------------------------------------- # +# c. Floating-point domain. +# --------------------------------------------------------------------------- # +@hawk.kernel +def speed_of(v: Vector[3], y: Mutable[Scalar]): + y = hm.sqrt(hm.dot(v, v)) + + +@hawk.kernel +def root_of(x: Scalar, y: Mutable[Scalar]): + y = hm.sqrt(x) + + +@hawk.kernel +def softened(x: Scalar, y: Mutable[Scalar]): + y = hm.log(1.0 + x * x) / (hm.norm(hm.vec(x, x, x)) + 0.5) + + +def test_fp_domain_sqrt_of_dot_is_safe_and_sqrt_of_a_scalar_is_unknown(): + safe = _report(speed_of) + (site,) = safe["fp_domain"] + assert (site["op"], site["status"]) == ("sqrt", "safe") + apply_caps("speed_of", safe, EmbeddedProfile(fp_domain="refuse")) + + unknown = _report(root_of) + (site,) = unknown["fp_domain"] + assert (site["op"], site["status"]) == ("sqrt", "unknown") + apply_caps("root_of", unknown, EmbeddedProfile()) # record is the default + with pytest.raises(HawkError) as err: + apply_caps("root_of", unknown, EmbeddedProfile(fp_domain="refuse")) + message = str(err.value) + assert "fp.domain" in message and "'root_of'" in message + assert f"node {site['node']} " in message + + +def test_positive_forms_make_log_and_div_safe(): + report = _report(softened) + ops = {s["op"]: s["status"] for s in report["fp_domain"]} + assert ops == {"log": "safe", "div": "safe"} + + +@hawk.kernel +def recip_exp(x: Scalar, y: Mutable[Scalar]): + y = 1.0 / hm.exp(x) + + +@hawk.kernel +def log_exp(x: Scalar, y: Mutable[Scalar]): + y = hm.log(hm.exp(x)) + + +@hawk.kernel +def rsqrt_exp(x: Scalar, y: Mutable[Scalar]): + y = hm.rsqrt(hm.exp(x)) + + +@hawk.kernel +def log_sqrt(x: Scalar, y: Mutable[Scalar]): + y = hm.log(hm.sqrt(x)) + + +@hawk.kernel +def root_root(x: Scalar, y: Mutable[Scalar]): + y = hm.sqrt(hm.sqrt(x)) + + +@hawk.kernel +def lorentzian(x: Scalar, y: Mutable[Scalar]): + y = 1.0 / (1.0 + x * x) + + +@pytest.mark.parametrize("kernel, op", [ + (recip_exp, "div"), (log_exp, "log"), (rsqrt_exp, "rsqrt")], + ids=["1/exp", "log(exp)", "rsqrt(exp)"]) +def test_exp_is_nonneg_not_positive(kernel, op): + """``exp`` underflows to +0 for a large negative operand: a divisor, a + ``log`` or an ``rsqrt`` over it is not provably in its domain.""" + report = _report(kernel) + (site,) = report["fp_domain"] + assert (site["op"], site["status"]) == (op, "unknown") + assert site["reason"].endswith("its sign is nonneg") + with pytest.raises(HawkError, match=r"fp\.domain"): + apply_caps(kernel.name, report, EmbeddedProfile(fp_domain="refuse")) + + +def test_sqrt_of_an_unknown_sign_is_unknown(): + """A negative operand gives NaN, which is no sign at all.""" + report = _report(log_sqrt) + sites = {s["op"]: s["status"] for s in report["fp_domain"]} + assert sites == {"sqrt": "unknown", "log": "unknown"} + outer = [s for s in _report(root_root)["fp_domain"] if s["status"] == "safe"] + assert outer == [] + + +def test_the_sound_positive_forms_stay_safe(): + (site,) = _report(lorentzian)["fp_domain"] + assert (site["op"], site["status"]) == ("div", "safe") + apply_caps("lorentzian", _report(lorentzian), EmbeddedProfile(fp_domain="refuse")) + (site,) = _report(speed_of)["fp_domain"] + assert (site["op"], site["status"]) == ("sqrt", "safe") + + +@hawk.kernel +def float_equal(x: Scalar, z: Scalar, y: Mutable[Scalar]): + y = hm.where(x == z, x, z) + + +def test_float_equality_sites_are_listed(): + report = _report(float_equal) + eq = [i for i, n in enumerate(float_equal.walk.order) if n.kind == "eq"] + assert report["fp_equality_sites"] == eq + assert _report(K.drag)["fp_equality_sites"] == [] + + +@hawk.kernel +def int_divide(a: Index, b: Index, y: Mutable[Index]): + y = a // b + a % b + + +def test_int_division_sites_are_listed(): + report = _report(int_divide) + want = [i for i, n in enumerate(int_divide.walk.order) + if n.kind in ("floordiv", "mod")] + assert len(want) == 2 + assert report["int_division_sites"] == want + assert report["fp_domain"] == [] + + +@hawk.kernel +def index_add(a: Index, b: Index, y: Mutable[Index]): + y = a + b + + +@hawk.kernel +def index_mix(a: Index, b: Index, s: Mutable[Index], d: Mutable[Index], + p: Mutable[Index], n: Mutable[Index], sh: Mutable[Index]): + s = a + b + d = a - b + p = a * b + n = -a + sh = (a << b) >> 1 + + +def test_int_ub_sites_are_the_index_add_sub_mul_neg_and_true_div(): + """Only an Index ``+ - *``, negation and true division ``/`` part the two + builds: the C++ spells them infix (signed overflow, a zero divisor and + ``MIN / -1`` undefined), the C defines them through ``hawk_i64_*``. + Int32/UInt32 arithmetic and every Index ``//``, ``%`` and shift go + through the host's own helpers too, so they are no site.""" + report = _report(index_add) + (add,) = [i for i, n in enumerate(index_add.walk.order) if n.kind == "add"] + assert report["int_ub_sites"] == [add] + report = _report(index_mix) + kinds = sorted(index_mix.walk.order[p].kind for p in report["int_ub_sites"]) + assert kinds == ["add", "mul", "neg", "sub"] + # the divisions are int_division_sites, never UB sites + report = _report(int_divide) + assert report["int_ub_sites"] == [ + i for i, n in enumerate(int_divide.walk.order) if n.kind == "add"] + import test_int_planes as I + + assert _report(I.ops_i32)["int_ub_sites"] == [] + assert _report(I.ops_u32)["int_ub_sites"] == [] + # a real `x * x` is no site + assert _report(K.drag)["int_ub_sites"] == [] + + +@hawk.kernel +def index_true_div(a: Index, b: Index, y: Mutable[Index]): + y = a / b + + +def test_an_index_true_division_is_a_ub_site_as_well_as_a_division_site(): + """``a / b`` on two ``Index`` values is infix in the C++ (undefined at + ``b == 0`` and at ``MIN / -1``) and ``hawk_i64_div`` in the C (0 and MIN + there), so it is a ``defined_where_cpp_is_ub`` site; ``//`` and ``%`` go + through the host's own helpers and stay division sites only.""" + report = _report(index_true_div) + (div,) = [i for i, n in enumerate(index_true_div.walk.order) if n.kind == "div"] + assert report["int_division_sites"] == [div] + assert report["int_ub_sites"] == [div] + assert _report(int_divide)["int_ub_sites"] == [ + i for i, n in enumerate(int_divide.walk.order) if n.kind == "add"] + + +# --------------------------------------------------------------------------- # +# d. The interface contract. +# --------------------------------------------------------------------------- # +@hawk.kernel +def accumulate_in_place(x: Scalar, y: Mutable[Scalar]): + y = y + x + + +def _own_partial(name: str, *, guarded: bool = False) -> Kernel: + """An own-column accumulate partial (``AccumWrite`` with no index, role + ``accum_out``: one partial per sample, the caller reduces) built as IR. + hawk 0.6 removed its authoring form (``Accum.add``); the node and its + interface row remain, so the C target's handling of it stays pinned. + ``guarded`` declares the default ``terminated`` mask, as a traced + ``terminated: Terminated`` parameter would.""" + x = Leaf("vocab_read", "per_sample", "x", TensorType((), "f64")) + planes = {"terminated": Terminated} if guarded else None + return Kernel(name, (AccumWrite("acc", make("mul", (x, x))),), planes) + + +own_partials = _own_partial("own_partials") + + +def _rows(kernel): + return {row["field"]: row for row in _report(kernel)["interface"]} + + +def test_interface_directions(): + rows = _rows(D.diagnostic) + assert list(rows) == ["mut_y", "psc_x", "trm_terminated"] + assert rows["trm_terminated"]["direction"] == "in" + assert rows["trm_terminated"]["init_by_caller"] is True + # the store sits inside the default guard: a masked sample keeps the prior + assert rows["mut_y"]["direction"] == "inout" + assert rows["mut_y"]["init_by_caller"] is True + assert rows["mut_y"]["reason"] == ( + "masked samples keep the prior value (guard: trm_terminated)") + assert rows["psc_x"]["layout"] == "scalar" + + rows = _rows(accumulate_in_place) + assert rows["mut_y"]["direction"] == "inout" + assert rows["mut_y"]["init_by_caller"] is True + + rows = _rows(D.scatter) + acc = rows["aout_acc"] + assert acc["direction"] == "inout" + assert acc["init_by_caller"] is True + assert acc["runtime_length"] is True + + rows = _rows(own_partials) + acc = rows["aout_acc"] + assert acc["layout"] == "partials" + assert acc["direction"] == "out" + assert "partial" in acc["reason"] + + +guarded_partials = _own_partial("guarded_partials", guarded=True) + + +@hawk.kernel +def guarded_vector(v: Vector[3], terminated: Terminated, y: Mutable[Vector[3]]): + y = v * 2.0 + + +@hawk.kernel +def guarded_wide(x: Scalar, terminated: Terminated, w: WideOut[Scalar]): + w = x * 2.0 + + +def _guarded_rows(kernel, kind=None): + report = ir_checks(kernel.name, kernel.sinks, kernel.walk, + kind or kernel.kind, EmbeddedProfile()) + return {row["field"]: row for row in report["interface"]} + + +_KEEP = "masked samples keep the prior value (guard: {})" + + +@pytest.mark.parametrize("kernel, field", [ + (D.diagnostic, "mut_y"), (guarded_vector, "mut_y"), (guarded_wide, "wout_w")]) +def test_a_guarded_own_column_store_is_inout_and_init_by_caller(kernel, field): + """A masked sample never reaches the store, so the host keeps the prior + value: the caller must hand over initialised memory.""" + row = _guarded_rows(kernel)[field] + assert (row["direction"], row["init_by_caller"]) == ("inout", True) + assert row["reason"] == _KEEP.format("trm_terminated") + + +def test_guarded_partials_are_zeroed_by_the_caller(): + row = _guarded_rows(guarded_partials)["aout_acc"] + assert (row["direction"], row["init_by_caller"], row["layout"]) == ( + "inout", True, "partials") + assert row["reason"] == ( + "one partial per sample, length n_samples; masked samples keep theirs " + "(guard: trm_terminated): the caller zeroes it, then reduces") + + +def test_the_reason_names_every_bound_mask_and_a_mask_free_kind_is_unchanged(): + rows = _guarded_rows(D.diagnostic_two, D.TWO_MASKS) + assert rows["mut_y"]["reason"] == _KEEP.format("trm_terminated, trm_rejected") + # the default guard reads `terminated` alone, even with `rejected` bound + rows = _guarded_rows(D.diagnostic_two) + assert rows["mut_y"]["reason"] == _KEEP.format("trm_terminated") + rows = _guarded_rows(D.diagnostic, D.MASK_FREE) + assert (rows["mut_y"]["direction"], rows["mut_y"]["init_by_caller"]) == ( + "out", False) + assert rows["mut_y"]["reason"] == "written every sample" + # a guard naming a mask the kernel does not bind never fires + rows = _guarded_rows(own_partials) + assert (rows["aout_acc"]["direction"], rows["aout_acc"]["init_by_caller"]) == ( + "out", False) + + +def test_a_finishing_kernel_falls_under_the_same_rule(): + """The finish mask is bound in the ``terminated`` role, so the own-column + store is guarded by it like any other.""" + import test_finish_own_sample as F + + rows = _guarded_rows(F.branch_finish) + assert rows["trm_terminated"]["direction"] == "inout" + assert (rows["mut_y"]["direction"], rows["mut_y"]["init_by_caller"]) == ( + "inout", True) + assert rows["mut_y"]["reason"] == _KEEP.format("trm_terminated") + + +def test_every_row_carries_the_bound_guard_masks(): + """``guard`` is structural: the bound mask fields (as the renderer's + ``_guard_slot`` computes them) on EVERY row, empty when unguarded.""" + for kernel, kind, want in [ + (D.diagnostic, None, ["trm_terminated"]), + (D.diagnostic_two, D.TWO_MASKS, ["trm_terminated", "trm_rejected"]), + (D.diagnostic_two, None, ["trm_terminated"]), + (D.diagnostic, D.MASK_FREE, []), + (own_partials, None, []), + (K.drag, None, [])]: + rows = _guarded_rows(kernel, kind) + assert all(row["guard"] == want for row in rows.values()), (kernel.name, kind) + + +def test_the_header_selects_the_guard_lines_from_the_guard_field(): + """The header's per-field guard line and the guarded partials line are + chosen by ``row["guard"]``, never by the reason text.""" + from hawk.embedded import abi, render + from hawk.emit import FLOAT64 + + for kernel, field in [(D.diagnostic, "mut_y"), (guarded_partials, "aout_acc")]: + report = render(kernel).report + reworded = {**report, "interface": [ + {**r, "reason": "reworded"} for r in report["interface"]]} + text = "\n".join(abi._contract(kernel.name, reworded, FLOAT64)) + assert "(guard: trm_terminated)" in text and f"* {field}" in text + unguarded = {**report, "interface": [ + {**r, "guard": []} for r in report["interface"]]} + text = "\n".join(abi._contract(kernel.name, unguarded, FLOAT64)) + assert "guard:" not in text + + +def test_the_header_contract_says_a_guarded_plane_is_initialised(): + from hawk.embedded import render + + header = render(guarded_partials).header + assert re.search(r"\* aout_acc +accum_out +inout +f64 +\(\) +yes\n", header) + assert ("aout_acc holds ONE PARTIAL PER SAMPLE (length n_samples); masked " + "samples keep theirs (guard: trm_terminated): the caller zeroes it, " + "then reduces.") in header + assert "the caller reduces it." not in header + header = render(D.diagnostic).header + assert re.search(r"\* mut_y +mutable +inout +f64 +\(\) +yes\n", header) + assert ("mut_y: masked samples keep the prior value (guard: trm_terminated); " + "the caller initialises it.") in header + # keyed on the structural guard: a prior-read plane under the guard is + # skipped at a masked sample too, and says so + import test_finish_own_sample as F + + header = render(F.osc_finish).header + for field in ("mut_t", "mut_v", "mut_x"): + assert (f"{field}: masked samples keep the prior value (guard: " + "trm_terminated); the caller initialises it.") in header + # an unguarded partial plane keeps the plain reduce line + assert "aout_acc holds ONE PARTIAL PER SAMPLE (length n_samples); the caller " \ + "reduces it." in render(own_partials).header + + +def test_interface_rows_carry_dtype_shape_and_layout(): + rows = _rows(K.drag) + assert rows["mut_out"] == { + "field": "mut_out", "role": "mutable", "name": "out", "direction": "out", + "dtype": "f64", "shape": [3], "layout": "soa", "runtime_length": False, + "init_by_caller": False, "guard": [], "reason": rows["mut_out"]["reason"]} + assert rows["uni_rho"]["layout"] == "value" + assert rows["uni_rho"]["direction"] == "in" + rows = _rows(K.gather) + assert rows["lut_table"]["runtime_length"] is True + + +@hawk.kernel(steps=1, time="t", time_step="dt") +def taped_step(dt: Param, t_final: Scalar, terminated: Terminated, + x: Mutable[Scalar], t: Mutable[Scalar]): + x = x - dt * x + t = t + dt + terminated = t >= t_final + + +def test_the_tape_twin_row_is_clamped_with_its_row_count(): + """The twin's row is ``a - (a / W) * W`` over the caller-writable + ``applied_steps`` plane: negative for a negative ``a``, so not provable.""" + from hawk.diff import tape + + twin = tape(taped_step, 8) + (site,) = _report(twin)["indices"] + assert (site["kind"], site["status"], site["length"]) == ("tape_write", "clamped", 8) diff --git a/tests/test_emit_dialect.py b/tests/test_emit_dialect.py new file mode 100644 index 0000000..f61062b --- /dev/null +++ b/tests/test_emit_dialect.py @@ -0,0 +1,122 @@ +# Copyright 2026 Alessandro Masat +# SPDX-License-Identifier: Apache-2.0 + +"""The spelling seam: one renderer, N dialects. + +``hawk.emit.dialect.Dialect`` names every text the body renderer produces; +``hawk.emit.dialect_aether.AETHER`` is today's aether C++ spelling moved +behind it. These rows pin that the protocol is complete and that the +renderer really delegates (a dialect that spells declarations differently +changes the body), while the aether dialect's own text stays what it was. +""" + +from __future__ import annotations + +from hawk.emit.dialect import DIALECT_MEMBERS, Dialect + + +def test_the_dialect_protocol_names_every_spelling_seam(): + expected = {"declare", "carry_type", "tape_decl", "counter_decl", "for_header", + "tail_break", "sample_index_value", "hoist", "selector_decl", "mix_count", + "predicated_rank0", "predicated_tensor", "switch_default_tail", "typed_zero", + "leaf", "const", "at", "select", "op", "guard", "store", "scatter_store", + "accum_scattered", "store_compensated", "sample_sum", "tape_write", + "finish", "exit_if", "component_commit", "in_place_accumulate", "zero_row"} + assert set(DIALECT_MEMBERS) == expected + assert len(DIALECT_MEMBERS) == 31 + assert all(callable(getattr(Dialect, m)) for m in DIALECT_MEMBERS) + assert "id" in Dialect.__annotations__ + assert "names_every_tensor" in Dialect.__annotations__ + assert "commits_per_component" in Dialect.__annotations__ + + +# -- the seam is live ------------------------------------------------------- + +import _kernels as K # noqa: E402 + +from hawk.emit import render_body # noqa: E402 +from hawk.emit.dialect_aether import AETHER, AetherDialect # noqa: E402 + + +class _Probe(AetherDialect): + """The aether dialect with ONE spelling changed: declarations drop ``const``.""" + + id = "probe" + + def declare(self, name, ttype, text): + return f"auto {name} = {text};" + + +def test_render_body_defaults_to_the_aether_dialect(): + k = K.chain6 + assert render_body(k.sinks, k.walk).text == render_body( + k.sinks, k.walk, dialect=AETHER).text + + +def test_a_dialect_that_spells_declarations_differently_changes_the_body(): + k = K.chain6 + plain = render_body(k.sinks, k.walk).text + probed = render_body(k.sinks, k.walk, dialect=_Probe()).text + assert plain != probed + assert "const auto " in plain and "const auto " not in probed + assert plain.replace("const auto ", "auto ") == probed + + +def test_the_aether_dialect_implements_every_member_and_for_header_takes_the_renderer(): + import inspect + + assert all(callable(getattr(AETHER, m)) for m in DIALECT_MEMBERS) + # a runtime-count header is TWO statements for a C dialect (the clamped + # count, then the loop), so the member gets the renderer like `hoist` does + assert list(inspect.signature(AETHER.for_header).parameters)[:2] == ["r", "index"] + + +def test_param_cell_binding_still_re_exports_from_hawk_emit_aether(): + from hawk.emit.aether import param_cell_binding + + assert param_cell_binding("k") == "lut_k_cell" + + +# -- a table read twice is never loaded at the sample index ---------------- + +import hawk # noqa: E402 +from hawk import Index, Mutable, Scalar, Table, Vector # noqa: E402 + + +@hawk.kernel +def _two_reads(table: Table[Scalar], where: Index, y: Mutable[Scalar]): + y = table.at(where) + table.at(where + 1) + + +def test_a_table_read_twice_is_not_loaded_at_the_sample_index(): + """A ``lookup`` plane is read BY NAME at computed indices (``_at``); naming + the leaf for its fan-out would emit a dead ``lut_table[i].eval()`` at the + sample index, an out-of-bounds read on a table shorter than the batch.""" + text = render_body(_two_reads.sinks, _two_reads.walk).text + assert "lut_table_i" not in text + assert text.count("lut_table[aether::SampleIndex::make(") == 2 + + +@hawk.kernel +def _vector_read_once(table: Table[Vector[3]], where: Index, y: Mutable[Vector[3]]): + y = table.at(where) + + +def test_a_rank1_table_read_once_is_not_loaded_at_the_sample_index(): + """A rank>=1 leaf is named for correctness, but a ``lookup`` plane is read + by name at its computed index (``_at``), so even ONE read of a + ``Table[Vector[3]]`` emits no dead ``lut_table_i = lut_table[i].get()``.""" + text = render_body(_vector_read_once.sinks, _vector_read_once.walk).text + assert "lut_table_i" not in text + assert "lut_table[i]" not in text + assert text.count("lut_table[aether::SampleIndex::make(") == 1 + + +def test_a_head_break_test_is_spelled_by_the_dialect(): + """The ``if (cond) {`` that opens a head/middle-form ``break`` is dialect + text: a C dialect must not wrap an already parenthesised ``(a == b)`` in a + second pair (clang's ``-Wparentheses-equality`` under ``-Werror``).""" + import inspect + + assert list(inspect.signature(AETHER.exit_if).parameters) == ["cond"] + assert AETHER.exit_if("(a == b)") == "if ((a == b)) {"