The Piecewise-Polynomial Activation Model & Evaluation
All symbols, offsets, and addresses on this page apply to
neuronx_cc2.24.5133.0+58f8de22 (cp310; thepwp_jsons/directory andlibpwp_sim.soare byte-identical across cp310/11/12). The numeric model lives inneuronxcc/starfish/lib/libpwp_sim.so(BuildID sha1c0b9dc4a217f3626d34ef08b5830619064ae7c55, not stripped — DWARF + demangled C++ symbols present, so every address below is certain by symbol, not inference). The profile data lives inneuronxcc/pwp/pwp_jsons/*.json, shipped in the wheel. For.text/.rodata, VA equals file offset. Other wheels differ — treat every address as version-pinned.
Abstract
The Neuron Activation/Scalar engine evaluates an arbitrary scalar transcendental — exp, sigmoid, gelu, 1/x, sqrt, … — not by a closed-form transcendental unit but by a piecewise-polynomial (PWP) lookup table: the input's floating-point exponent picks an octave, the top mantissa bits pick a bucket within that octave, and each bucket stores a degree-3 Taylor segment {x, d0, d1, d2, d3} that is Horner-evaluated about its breakpoint. This page is the mathematical model: the 24-key per-function profile schema, the two-level (octave × mantissa) LUT index derivation, the degree-3 evaluation with its exact float/double mixing, the 4-way saturation clamp, and the symmetry fold that lets odd/even functions store only half the domain. The bar is that a reader can reimplement the activation evaluation f(x) bit-for-bit from this page.
The model has two halves that must not be confused. The profile schema (pwp_jsons/*.json, 40 files, 24 keys each) is the data — generated by an offline tool (KaenaPWP) and shipped in the wheel. The evaluator is PWPSim::Simulator::evaluate_generic (0x99c0 in libpwp_sim.so); it loads each profile into an in-memory AFTable (184 bytes) and runs the per-element math. Critically, the evaluator reads only a subset of the 24 keys: the symmetry flags, the four special-value scalars, the two octave arrays, and the four saturation points. The other nine keys (max_diff, lut_size, exponent_offset, lower_bound, upper_bound, imm_bias, fma_const0, fma_const1, use_multipass) are generator/hardware-LUT metadata that the numeric kernel never touches. Knowing which keys are live and which are inert is the difference between a faithful reimplementation and one that reproduces fields nobody reads.
Three facts shape everything and are easy to get wrong. First, saturation_points is not a range→segment map — it is a 4-way clamp (positive/negative × high/low), and each of the four entries carries its own full degree-3 polynomial, not a bare constant. Second, the degree-3 evaluation is not uniform-precision Horner: the d0 + d1·t term stays in float, the d2·t² and d3·t³ terms accumulate in double, and the cube is computed with libm pow(t, 3.0), not t*t*t — a bit-exact reimplementation must replicate this exact rounding sequence. Third, the symmetry fold is the mechanism that makes sigmoid and reciprocal store only one half-domain: the evaluator folds the input to |v| (or −|v|), evaluates, then reconstructs via an optional sign flip plus a symmetry_point offset.
| Evaluator | PWPSim::Simulator::evaluate_generic(AFTable&, float v, float alpha) @ 0x99c0 (thunk 0x8ae0) |
| Bucket search | find_pwp_nonsat_section(vector<pwp_nonsat_region>&, float) @ 0x9340 (.isra.0) |
| In-memory table | PWPSim::AFTable — 184 bytes (struct §3) |
| Profile data | neuronxcc/pwp/pwp_jsons/*.json — 40 files, exactly 24 keys each |
| Profile loader | initialize_pwptable @ 0xb040 → parse_nonsat 0xae90 / parse_sat 0xab50 / parse_xd 0xa550 |
| Polynomial form | f(sel) = d0 + d1·t + d2·t² + d3·t³, t = sel − x (degree-3 Taylor about x) |
| Outer index | biased exponent: octave = bits(sel) >> 23 (one pwp_nonsat_region per octave) |
| Inner index | sectid = (bits(sel) & 0x7FFFFF) >> (23 − extract_size) (top extract_size mantissa bits) |
| Clamp | 4-way: sat_point_{pos,neg}_{high,low}, each a full {x, d0..d3} poly |
| Source path | /tmp/packages/KaenaPWP/pwp_sim/activation_pwp_simulation.cpp (rodata 0x17058) |
This page is the model. The opcode dispatch that reaches this evaluator — visitInstActivation → PWPSim::Simulator::simulate — is the simulator walk and lives in 7.40 (sim activation/PWP). The engine datapath that runs the same math in silicon is 1.10 (Activation engine datapath). The pre-baked binary blobs the hardware LUT generator emits (bkt.bin / ctrl.bin) are decoded in 10.4 (bkt/ctrl blob). Evidence is tagged CONFIRMED (shipped-JSON value or disasm-proven), STRONG (cross-checked across two sources), INFERRED (a rule verified across all 40 profiles but not independently proven), and SPECULATIVE (flagged).
1. The two-level LUT, conceptually
Before the schema and the disassembly, the geometry. A transcendental like exp(x) has very different curvature at x = 0.001 than at x = 50. A uniform table — one segment per fixed input step — would either waste buckets where the function is nearly linear or under-resolve where it bends. The PWP table instead buckets by floating-point structure, which is exactly where the curvature lives.
The IEEE-754 single-precision bit layout of the (fold-selected) input is split into two indices:
bits(sel): [ s | eeeeeeee | mmmmmmmmmmmmmmmmmmmmmmm ]
31 30 .. 23 22 ............... 0
| | |
| | +-- INNER index: top `extract_size` bits → bucket
| +----------------- OUTER index: biased exponent → octave
+------------------------- sign → which side (pos / neg region)
-
Outer index — the octave. The 8-bit biased exponent (
bits(sel) >> 23) selects onepwp_nonsat_regionfrom a vector indexed by exponent. There is one octave per power-of-two range of|sel|, so buckets get exponentially denser toward zero — matching the relative precision of fp32 itself. A function with sharp curvature near the origin (exp,gelu) simply declares more sections in the small-exponent octaves. -
Inner index — the bucket. Within an octave the function is subdivided uniformly by the top
extract_sizemantissa bits:2^extract_sizebuckets, each covering an equal slice of the octave's mantissa range.extract_size = 0means the whole octave is one segment. -
The segment. Each bucket stores
{x, d0, d1, d2, d3}— a degree-3 Taylor expansion centered at the breakpointx. Evaluation isd0 + d1·t + d2·t² + d3·t³witht = sel − x.
NOTE — Why two levels and not a flat table. A flat
mant-only table would need a fixed number of buckets per the entire fp32 range; a flatexp-only table would have no sub-octave resolution. The two-level scheme spends buckets proportional to where the function actually curves, in fp32-relative terms. This is the same geometry the silicon Activation engine's LUT addressing uses; the simulator mirrors it bit-for-bit so its output matches hardware when a profile is loaded.
The total bucket count across all octaves (positive + negative) is the profile's lut_size — the physical memory footprint of the table. For exp_400p that is 371 positive + 406 negative = 777 segments; verified lut_size == Σ stored exponent_sections for all 40 profiles (CONFIRMED, §4).
2. The 24-key profile schema
Every pwp_jsons/*.json profile has exactly 24 top-level keys — verified by jq 'keys | length' returning 24 on all 40 files, with an identical key set on every file (CONFIRMED). The keys group into seven roles. The "live?" column states whether evaluate_generic / initialize_pwptable actually consume the key; the rest are generator/HW-LUT metadata that the numeric kernel ignores.
2.1 Identity & precision metadata
| Key | Type | Live? | Meaning |
|---|---|---|---|
name | string | yes (key) | Bare activation name ("exp", "gelu", "reciprocal"). Matches the ActivationFunctionType name and the table-map key the evaluator binds. |
tonga_id | int | no | Codename id on the gen-1 (Inferentia/tonga) generation. 0 ⇒ "absent on gen-1". |
sunda_id | int | no | gen-2+ codename id. sunda_id == neuron_id on all 40. |
neuron_id | int | no | Canonical Neuron id; maps to the BIR InstActivation func field. |
max_diff | int | no | Generator's precision budget N — equals the filename suffix and the act_info budget value. Drove bucket density (higher N ⇒ tighter ULP ⇒ more buckets). Not read by the evaluator. |
lut_size | int | no | Total physical bucket count = Σ stored exponent_sections (pos+neg). 0 for the 13 hardwired funcs. Metadata only. |
GOTCHA —
max_diffandlut_sizeare inert in the evaluator. They are the most tempting fields to wire into a reimplementation (they look like sizing parameters), butinitialize_pwptablenever reads them — the loaded table is already the expanded bucket list, so the evaluator iterates the actualexponent_sectionsvector lengths, not a declared count.lut_sizeis a footprint report;max_diffis a generator knob. (CONFIRMED: the binary string pool contains no read site, and theAFTablestruct has no field for either.)
2.2 Symmetry (the half-domain fold)
These four keys (AFTable +0..+4) let an odd or even function store only one side of the domain. Full taxonomy in §6.
| Key | Type | AFTable | Meaning |
|---|---|---|---|
symmetry_en | bool | +0 | Master enable. If false, evaluate on the signed input directly (asymmetric — both pos and neg octaves stored). |
symmetry_invert_sign_opt | bool | +1 | Odd reconstruction: after evaluating the fold, negate the result (f(−x) = −f(x)). |
symmetry_opt_use_neg_region | bool | +2 | Fold onto the negative table: `sel = − |
symmetry_point | float-box | +4 | Reflection offset added after reconstruction. 0 for odd-about-origin; 1.0 for the sigmoid family (f(x) = 1 − f(−x)). |
2.3 Special-value results
The four short-circuit returns (AFTable +8..+20), each a float-box (§2.6):
| Key | AFTable | Returned when | Example (exp / reciprocal) |
|---|---|---|---|
zero_result | +8 | v == 0.0 | 1.0 / ≈FLT_MAX (1/0 saturates) |
nan_result | +12 | (loaded but not dispatched — see GOTCHA) | nan / nan |
pinf_result | +16 | ` | v |
ninf_result | +20 | ` | v |
GOTCHA —
nan_resultis loaded but the evaluator has no NaN branch.evaluate_genericshort-circuits only onv == 0and|v| > FLT_MAX(±inf). A NaN input compares false toFLT_MAXand is!= 0, so it falls through to the bit-decompose → bucket → poly path.nan_resultis parsed into theAFTablebut is never returned by a dedicated NaN branch in the evaluator (it is presumably consumed by the silicon LUT). A reimplementation that adds a NaN guard will diverge from this evaluator. (CONFIRMED: only the zero/±inf trio appears in the body; tagged a known sim-vs-silicon gap.)
2.4 Domain bounds & float-decomposition control (metadata)
| Key | Type | Live? | Meaning |
|---|---|---|---|
lower_bound | float-box | no | Smallest valid input (below which the function saturates). Generator metadata. |
upper_bound | float-box | no | Largest valid input. Generator metadata. |
exponent_offset | int | no | Lowest biased-exponent octave covered = the first exponent field. 127/−127 for the degenerate lut=0 hardwired funcs. Not read by the evaluator. |
2.5 The polynomial payload
This is the live core: two arrays of octaves plus the 4-way clamp.
| Key | Type | Live? | Meaning |
|---|---|---|---|
pos_exponents | array | yes | One entry per biased-exponent octave, positive side → AFTable.pos_region (+24). |
neg_exponents | array | yes | Same for the negative side → AFTable.neg_region (+48). Length 0 if the function is symmetric or domain-restricted (sqrt, reciprocal, tanh store no negative octaves). |
saturation_points | object | yes | Exactly 4 keys: sat_point_{pos,neg}_{high,low} → AFTable +72..+156. The 4-way clamp (§5). |
Each octave entry in pos_exponents/neg_exponents has 6 keys:
| Octave key | Type | Live? | Meaning |
|---|---|---|---|
exponent | int | yes | The unbiased exponent of this octave. Stored at vector index exponent + 127. |
pos | bool | no | Sign-of-region flag (true in pos_exponents). Not loaded into the region struct. |
num_sections | int | yes | Addressing span = 2^extract_size (the bucket count this octave subdivides into). |
extract_size | int | yes | Number of top mantissa bits used to index the inner bucket. |
extract_lsb | int | no | LSB position of the extraction = 23 − extract_size. Shipped in the JSON but not read by the evaluator — the bucket search recomputes 23 − extract_size at runtime (see CORRECTION below). |
exponent_sections | array | yes | The per-bucket degree-3 segments (§2.6). |
CORRECTION —
extract_lsbis a stored key the simulator ignores. D-M05 listedextract_lsbas INFERRED (= 23 − extract_size). In fact it is an explicit JSON key present on every octave entry (the octave object has 6 keys:exponent,exponent_sections,extract_lsb,extract_size,num_sections,pos), and its value equals23 − extract_sizefor all 1422 octaves withnum_sections > 0(0 violations). But the binary string pool containsextract_size,num_sections,exponent_sectionsand notextract_lsb— soparse_nonsatnever reads it. Insteadfind_pwp_nonsat_sectioncomputes the shift live:0x936a: sub ecx, [region+8](whereecx = 23,[region+8] = extract_size), then0x936d: sar eax, cl. Soextract_lsbis redundant generator output, in the same inert class aslut_size. Upgraded CONFIRMED (stored), and the simulator-ignores-it fact is new vs D-M05's "inferred rule". (CONFIRMED — string-pool absence + disasm.)
The exponent_sections[] segment has 5 keys — one breakpoint and four coefficients, laid out in the in-memory exponent_sect (20 bytes) as x@+0, d0@+4, d1@+8, d2@+12, d3@+16:
| Segment key | Type | exponent_sect | Meaning |
|---|---|---|---|
section_id | int | (not stored) | Bucket index within the octave (0..len−1); positional, not loaded. |
x | float-box | +0 | Segment breakpoint / Taylor-expansion center. |
d0 | float-box | +4 | Degree-0 (constant) coefficient. |
d1 | float-box | +8 | Degree-1 (linear) coefficient. |
d2 | float-box | +12 | Degree-2 (quadratic) coefficient. |
d3 | float-box | +16 | Degree-3 (cubic) coefficient. |
2.6 The float-decomposition box
Every float value in the schema — x, d0..d3, zero_result, symmetry_point, the bounds — is stored not as a bare JSON number but as a 6-field decomposition object:
{ "float": "0.166667", // human-readable — LOSSY, decorative
"int": 1042983627, // the raw IEEE-754 bit pattern as uint32 ← THIS is loaded
"hexstring": "3e2aaacb", // = int in hex
"sign": 0, "exponent": 124, "mantissa": 2796235 }
The loader (parse_xd @ 0xa550) reads only the .int member and stores it as the raw 32-bit pattern (LODWORD store of json_object_get_int64(obj["d3"]["int"])). The "float" string is rounded and must never be used by a bit-exact reimplementation — e.g. d3.float = "0.166667" but the true value is 0x3e2aaacb = 0.16666667163….
GOTCHA —
sat_pointandmantissa_pointare true integers, not bit-boxes. Insidesaturation_points, the two threshold fieldssat_pointandmantissa_pointare plain JSON integers compared directly against the input's biased exponent and mantissa (parse_sat@0xab50:json_object_get_int64with no["...","int"]nesting). Only thex/d0..d3of each clamp region are float-boxes. Mixing these up — bit-reinterpretingsat_point— breaks the clamp comparison. (CONFIRMED viaparse_satbody.)
2.7 FMA / multipass control (metadata)
| Key | Type | Live? | Meaning |
|---|---|---|---|
imm_bias | bool | no | Immediate-additive-bias flag (true on copy/memset/is_finite/reciprocal). HW metadata. |
fma_const0 | float-box | no | FMA constant 0. Nonzero only for parametric_relu (= 1.0); 0 on the other 39. |
fma_const1 | float-box | no | FMA constant 1. 0 on all 40. |
use_multipass | bool | no | True only for ln_4p_0mp / ln_4p_1mp (the 2-pass ln; see §7). The evaluator single-passes. |
3. The in-memory table: AFTable and its sub-structs
initialize_pwptable (0xb040) parses one profile JSON into one AFTable (184 bytes), keyed by activation name in the simulator's tables map. The layout is proven by the struct database and corroborated by the field offsets evaluate_generic actually dereferences (CONFIRMED).
// PWPSim::AFTable — 184 bytes. Only these fields exist; the 9 metadata keys
// from the JSON (max_diff, lut_size, exponent_offset, lower/upper_bound,
// imm_bias, fma_const0/1, use_multipass, *_id) have NO field here.
struct AFTable { // off
bool symmetry_en; // +0 read @0x99fb movzx ebx,[rbp+0]
bool symmetry_invert_sign_opt; // +1 read @0x9b9a cmp byte [table+1],0
bool symmetry_opt_use_neg_region;// +2 read @0x9a07 cmp byte [table+2],0
float symmetry_point; // +4 read @0x9ba7 addss xmm0,[table+4]
float zero_result; // +8 read @0x9a98 movss xmm0,[table+8]
float nan_result; // +12 (loaded, not dispatched — §2.3)
float pinf_result; // +16
float ninf_result; // +20
AFNonSatTy pos_region; // +24 ← pos_exponents (24 B vector wrapper)
AFNonSatTy neg_region; // +48 ← neg_exponents
pwp_sat_region sat_point_pos_high; // +72 (28 B)
pwp_sat_region sat_point_pos_low; // +100
pwp_sat_region sat_point_neg_high; // +128
pwp_sat_region sat_point_neg_low; // +156
};
struct AFNonSatTy { // 24 B: std::vector<pwp_nonsat_region>
pwp_nonsat_region *start, *finish, *end_of_storage;
};
struct pwp_nonsat_region { // 40 B — one OCTAVE
int exponent; // +0 = json exponent + 127 (used as the vector INDEX)
int num_sections; // +4 = 2^extract_size
int extract_size; // +8 # top mantissa bits → bucket index
// +12 padding
std::vector<exponent_sect> sections; // +16 (start/finish/eos)
// NOTE: no extract_lsb field — recomputed as 23 - extract_size at search time
};
struct pwp_sat_region { // 28 B — one CLAMP corner
int sat_point; // +0 biased-exponent THRESHOLD (true int)
int mantissa_point; // +4 mantissa threshold for the exponent tie
exponent_sect section; // +8 the clamp region's OWN degree-3 poly
};
struct exponent_sect { // 20 B — one BUCKET (degree-3 Taylor)
float x, d0, d1, d2, d3; // +0 / +4 / +8 / +12 / +16
};
NOTE — the region vector is indexed by biased exponent.
parse_nonsat(0xae90) does notpush_backoctaves in array order; it writesregion[exponent + 127] = {…}into a vector pre-sized to span the octave range. Sopos_region.sectionsis addressable directly bybits(sel) >> 23with no search — the "search" infind_pwp_nonsat_sectionis only the inner mantissa-bucket pick, an O(1) shift. (CONFIRMED viaparse_nonsatbody.)
4. Two-level index derivation — find_pwp_nonsat_section @ 0x9340
Given a region vector (positive or negative) and the fold-selected float sel, the bucket search is pure bit-arithmetic (CONFIRMED — every line below is a named disasm anchor):
// find_pwp_nonsat_section(vector<pwp_nonsat_region>& region, float sel) @0x9340
const exponent_sect* find_pwp_nonsat_section(region_vec, float sel) {
uint32_t bits = reinterpret_bits(sel);
// OUTER index — biased exponent picks the octave.
// 0x9357: sar edx, 0x17 ; bits >> 23 (arithmetic; sign in MSB)
// 0x935f: movzx edx, dl ; (u8) — clamp to the 8-bit biased exponent
// 0x9362: lea rdx,[rdx+rdx*4] ; ×5
// 0x9366: lea rdx,[rdi+rdx*8] ; base + (×5)×8 = base + 40·octave
pwp_nonsat_region* R = region_base + 40 * (uint8_t)(bits >> 23);
// INNER index — top `extract_size` mantissa bits pick the bucket.
// 0x935a: and eax, 0x7FFFFF ; mant
// 0x936a: sub ecx, [R + 8] ; ecx = 23 - extract_size (ecx preset = 23)
// 0x936d: sar eax, cl ; mant >> (23 - extract_size)
uint32_t mant = bits & 0x7FFFFF;
uint32_t sectid = mant >> (23 - R->extract_size);
// BOUNDS — sectid must be < the actual stored section count.
// 0x936f: mov rcx,[R+0x10] finish ; 0x9373: mov rdx,[R+0x18] start... (start/finish)
// 0x937c: sar rdx, 2 ; 0x9380: imul rdx, 0xCCCC...CD → (finish-start)/20
// 0x9384: cmp rax, rdx ; 0x9387: jnb → assert
size_t num_sections = (R->sections.finish - R->sections.start) / 20;
assert(sectid < num_sections &&
"Unhandled section in simulation"); // rodata @0x17098, asrt @0xAF
// 0x9389: lea rax,[rax+rax*4] ; ×5 0x938d: lea rax,[rcx+rax*4] ; start + 20·sectid
return R->sections.start + 20 * sectid; // &exponent_sect[sectid]
}
NOTE — the bounds count comes from the vector byte-size, not
num_sections. The assert divides(finish − start)bysizeof(exponent_sect) = 20. This matters because an octave can store fewer segments than its declarednum_sections: where the function has already saturated, the generator truncates the tail. For exampleexp_400poctave+6declaresnum_sections = 256(extract_size = 8) but stores only 99 segments (the rest overflow to+infand are handled by the high clamp). Sonum_sectionsis the addressing span; the vector length is the physical count. A reimplementation must size the bounds check off the stored length. (CONFIRMED:jqshows octave+6has 256 declared / 99 stored;lut_size = 777 = Σ stored.)
QUIRK —
lut_size == Σ stored sections, exactly, for all 40. Verified by summingpos_exponents[].exponent_sections | length + neg_exponents[]…and comparing tolut_size: zero mismatches across all 40 profiles (the 13 hardwired funcs havelut_size = 0and zero stored sections). Solut_sizeis a faithful post-truncation footprint — it counts the stored buckets, not the declaredΣ num_sectionsspan. (CONFIRMED.)
The octave-density invariant, verified across all 1422 octaves with num_sections > 0 (0 violations):
num_sections == 2^extract_size AND extract_lsb == 23 − extract_size
This is what makes the inner index a single shift: 2^extract_size uniform buckets, addressed by the top extract_size mantissa bits. (CONFIRMED — exhaustive over the shipped data; the second clause is a stored key the evaluator nonetheless recomputes, §2.5.)
5. The 4-way saturation clamp
Before the octave bucket search, the evaluator first checks whether the input lies outside the LUT's covered range, on either the high (large-magnitude) or low (small-magnitude) side, separately for the positive and negative halves. That is the 4-way clamp — sat_point_pos_high, sat_point_pos_low, sat_point_neg_high, sat_point_neg_low. Each is a pwp_sat_region carrying a threshold pair (sat_point, mantissa_point) and its own full degree-3 polynomial.
// Positive side: bits(sel) >= 0 (sign bit clear). expo = bits(sel) >> 23 (signed, ≤255).
if (bits(sel) >= 0) {
if ( expo > T.sat_point_pos_high.sat_point ||
(expo == T.sat_point_pos_high.sat_point &&
mant >= T.sat_point_pos_high.mantissa_point) )
sect = &T.sat_point_pos_high.section; // ABOVE the LUT range (e.g. exp: d0=+inf)
else if ( expo < T.sat_point_pos_low.sat_point ||
(expo == T.sat_point_pos_low.sat_point &&
mant <= T.sat_point_pos_low.mantissa_point) )
sect = &T.sat_point_pos_low.section; // BELOW the LUT range (e.g. exp: ≈1+x)
else
sect = find_pwp_nonsat_section(T.pos_region, sel); // in-range LUT (§4)
} else {
// Negative side: expo read as (u8) — drops the sign bit, keys off |sel| magnitude.
if ( (u8)expo > T.sat_point_neg_high.sat_point || /* … >= mantissa_point */ )
sect = &T.sat_point_neg_high.section;
else if ( (u8)expo < T.sat_point_neg_low.sat_point || /* … <= mantissa_point */ )
sect = &T.sat_point_neg_low.section;
else
sect = find_pwp_nonsat_section(T.neg_region, sel);
}
The threshold comparison is a two-level test: compare the biased exponent first, and on an exponent tie, compare the mantissa — mantissa_point is the mantissa value at which the clamp engages within the boundary octave. The _high branches use >= on the mantissa tie (clamp when at-or-above), the _low branches use <= (clamp when at-or-below).
CORRECTION —
saturation_pointsis a 4-way clamp, not a range→segment map. A natural misreading of the name is thatsaturation_pointsmaps input ranges to LUT segments. It does not. It is exactly four corner-clamps. Each clamp carries a complete degree-3 polynomial (not a bare constant) so the out-of-range behavior can be a slope, not just a flat value. The clamp poly is evaluated by the same Horner path as an in-range bucket — there is no special "return constant" code. (CONFIRMED viaevaluate_genericbody and thesaturation_pointsobject having exactly the 4 keys.)
Worked clamp values (CONFIRMED from shipped JSON):
| Profile | corner | sat_point | mantissa_point | poly | effect |
|---|---|---|---|---|---|
exp | pos_high | 133 | 3240472 | d0=+inf, d1=d2=d3=0 | ` |
exp | pos_low | 108 | 0 | d0=1, d1=1 | tiny +x → eˣ ≈ 1 + x |
exp | neg_high | 133 | 4362891 | d0=0, d1=0 | x ≲ −88 → eˣ → 0 |
reciprocal | pos_high | 169 | — | d0=0 | huge +x → 1/x → 0 |
reciprocal | pos_low | 85 | — | d0=1e+07 | tiny +x → 1/x large |
gelu | pos_high | 129 | 1926024 | d0=0, d1=1 | large +x → gelu(x) → x |
relu | pos | — | — | d0=0, d1=1 | the entire relu (f = x) lives in a clamp poly |
NOTE — the hardwired (lut=0) functions express their whole shape through the clamp polys. The 13 functions with
lut_size = 0(relu,copy,abs,sign,square,identity, the threederivative_*,leaky_relu,parametric_relu,memset_zero,is_finite) carry emptyexponent_sectionsand encodef(x)entirely in the foursaturation_points(often degenerate linear/constant):reluispos {d0=0,d1=1} → x,neg {d0=0,d1=0} → 0;copyis all-four{d0=0,d1=1} → x.parametric_reluis the sole exception with no LUT path — its slopealphais supplied at runtime, not from the table. (CONFIRMED.)
6. Symmetry — the half-domain fold
symmetry_en plus its two flags and symmetry_point let a function store only half its domain and reconstruct the other half. The fold happens before decomposition (so the bucket search runs on the folded value sel); the reconstruction happens after the poly, conditioned on the original input having been negative.
// (fold) before decomposition:
if (T.symmetry_en) {
sel = absv; // evaluate on |v|
if (T.symmetry_opt_use_neg_region)
sel = absv ^ 0x80000000; // …or its negative copy, -|v|
} else {
sel = v; // asymmetric: keep signed input
}
// (reconstruct) after the poly, only if the original input was negative:
if (v != absv && T.symmetry_en) { // v was negative (sel = |v|)
if (T.symmetry_invert_sign_opt) result = -result; // odd: f(-x) = -f(x)
return result + T.symmetry_point; // + reflection offset
}
return result;
The four flag combinations (symmetry_en, invert_sign_opt, opt_use_neg_region, symmetry_point) partition all 40 functions into symmetry families (CONFIRMED across the shipped profiles):
(en, inv, negreg, sympt) | family | functions |
|---|---|---|
(F, F, F, 0) | asymmetric — store both pos+neg, eval signed v | exp, gelu, gelu_apprx_{tanh,sigmoid}, mish, silu, softplus, ln, reciprocal_sqrt, and all lut=0 identity/relu/copy/derivative_{identity,relu,leaky_relu} |
(T, T, F, 0) | odd about origin, f(−x)=−f(x) — store pos, eval ` | v |
(T, F, F, 0) | even / eval-on-` | x |
(T, T, T, 1) | point-symmetric about (0, ½), f(x)=1−f(−x) — store neg (`sel=− | v |
CORRECTION —
sigmoidis point-symmetric, not asymmetric. An earlier reading (D-F18 §3.3) listedsigmoidwithsymmetry_en = false. The shippedsigmoid_40p.jsonis(T, T, T, sympt=1):symmetry_en = true,invert_sign_opt = true,opt_use_neg_region = true,symmetry_point = 1.0, withpos_exponentsempty and 11 negative octaves. It stores the negative region and reflects positive inputs via1 − f(−x). (CONFIRMED from JSON; corrects the family label only — the rest of the evaluator description is accurate.)
Worked odd example — 1/(−4) on reciprocal: symmetry_en ⇒ sel = |−4| = 4; octave/bucket → r ≈ 0.25; invert_sign_opt ⇒ r = −0.25; + symmetry_point (0) → −0.25. The negative reciprocal table is never stored (neg_exponents = []); the odd fold supplies it. (CONFIRMED.)
7. The evaluation, end to end — evaluate_generic @ 0x99c0
Putting the pieces together. The input v arriving here is already pre-scaled by the caller: the per-element loop in PWPSim::Simulator::simulate computes scaled = multiplier·in[i] + addend (see 7.40 for the operand-swap caveat) and passes that as v. The evaluator never re-scales.
// PWPSim::Simulator::evaluate_generic(AFTable& T, float v, float alpha) @0x99c0
float evaluate_generic(AFTable& T, float v, float alpha) {
// (1) SPECIAL VALUES — short-circuit.
if (v == 0.0f) return T.zero_result; // @0x99d0 ucomiss
uint32_t absv = bits(v) & 0x7FFFFFFF; // @0x99e7 andps, mask @0x17C90
if (as_float(absv) > 3.4028235e38f) // @0x99ee ucomiss vs FLT_MAX @0x17E40
return (v <= 0.0f) ? T.ninf_result : T.pinf_result; // ±inf
// NaN: |NaN| compares false to FLT_MAX, != 0 → falls THROUGH (no NaN branch; §2.3 GOTCHA)
// (2) SYMMETRY FOLD (§6).
uint32_t sel;
if (T.symmetry_en) { // @0x99fb read +0
sel = absv;
if (T.symmetry_opt_use_neg_region) // @0x9a07 read +2
sel = absv ^ 0x80000000; // -|v|, negate mask @0x17CA0
} else {
sel = bits(v);
}
// (3) DECOMPOSE sel.
int32_t expo = bits(sel) >> 23; // biased exponent (sign in MSB on pos test)
uint32_t mant = bits(sel) & 0x7FFFFF;
// (4) SECTION SELECT — 4-way clamp THEN in-range bucket (§5).
const exponent_sect* sect = /* pos/neg clamp or find_pwp_nonsat_section(...) */;
// (5) DEGREE-3 EVALUATION — mixed float/double, libm cube (see GOTCHA).
float t = as_float(sel) - sect->x; // @0x9afb subss [rax]
float lo = (t * sect->d1) + sect->d0; // @0x9b2b mulss d1; @0x9b38 addss d0 (FLOAT)
double r = (double)lo + (double)t * (double)t * (double)sect->d2; // d2 @0x9b17 cvtss2sd (DOUBLE)
r = pow((double)t, 3.0) * (double)sect->d3 + r; // d3 @0x9b1c cvtss2sd; @0x9b5b call _pow
float result = (float)r; // @0x9b83 cvtsd2ss
// ⇒ f(sel) = d0 + d1·t + d2·t² + d3·t³
// (6) SYMMETRY RECONSTRUCTION (§6) — only if v was the reflected (negative) copy.
if (bits(v) != absv && T.symmetry_en) {
if (T.symmetry_invert_sign_opt) result = -result; // @0x9ba0 xorps (negate mask)
return result + T.symmetry_point; // @0x9ba7 addss [table+4]
}
return result;
}
GOTCHA — the degree-3 evaluation is NOT uniform-precision Horner. Three rounding details make it bit-exact-sensitive, all disasm-anchored:
d0 + d1·tstays infloat(mulss/addssat0x9b2b/0x9b38) — the linear term is computed and rounded in single precision.d2·t²andd3·t³accumulate indouble—d2andd3are promoted viacvtss2sd(0x9b17/0x9b1c), and the running sumris adoubleuntil the finalcvtsd2ss(0x9b83).- the cube uses libm
pow(t, 3.0), nott*t*t(call _pow@0x9b5b) — these can differ in the last ULP.A reimplementation that does straight
floatHorner, or computest*t*t, will mismatch this evaluator on some inputs. The exact sequence is:r_double = (double)((float)(t·d1 + d0)) + (double)(t·t·d2); r_double = pow((double)t,3)·(double)d3 + r_double; return (float)r_double. (CONFIRMED — instruction-level.)
QUIRK — saturation
+infrides the normal poly path. When the input lands inexp.sat_point_pos_high(d0 = +inf,d1 = d2 = d3 = 0), there is no special "return inf" branch. The clamp's poly is evaluated like any bucket:(t·0 + inf) + … = +inf. The clamp is a section selection, not a separate return code. (CONFIRMED.)
Worked in-range example — exp(t) near zero. exp_400p octave −19 (num_sections=1, extract_size=0) section 0: x = 2.86e-06 (0x363fffff), d0 = 0x3f800018 ≈ 1.0000003, d1 = 0x3f800018 ≈ 1.0000003, d2 = 0x3f000018 ≈ 0.500001, d3 = 0x3e2aaacb ≈ 0.16666667. That is exactly the Maclaurin series eᵗ ≈ 1 + t + t²/2 + t³/6 baked into {d0, d1, d2, d3} — the evaluator's Horner form reproduces it. (CONFIRMED from JSON + the parse_xd cross-check.)
8. Reimplementation contract
To rebuild the PWP activation evaluation f(x) bit-for-bit, a reimplementer needs:
-
The profile loader. Parse the 24-key JSON; for every float field read the
.intmember and store the raw uint32 bits (never the"float"string). Forsaturation_points, readsat_point/mantissa_pointas plain integers. Build the octave vector indexed byexponent + 127; ignore the 9 metadata keys (max_diff,lut_size,exponent_offset,lower/upper_bound,imm_bias,fma_const0/1,use_multipass,*_id) and the per-octaveextract_lsb/pos/section_id. -
The pre-scale.
scaled = multiplier·in + addend(operand order per 7.40). -
Special-value short-circuits.
v==0 → zero_result;|v|>FLT_MAX → (v≤0 ? ninf : pinf). No NaN branch (NaN falls through to the poly). -
The symmetry fold + reconstruct (§6): fold to
sel = ±|v|or signedv; after the poly, conditionally negate and addsymmetry_point. -
The two-level index (§4):
octave = bits(sel)>>23((u8));sectid = (bits(sel)&0x7FFFFF) >> (23 − extract_size); bounds-check against the stored section count. -
The 4-way clamp (§5) before the in-range bucket: pos/neg × high/low, exponent-then-mantissa thresholds, each clamp carrying a full degree-3 poly.
-
The mixed-precision degree-3 evaluation (§7 GOTCHA):
floatlinear term,doublequadratic+cubic accumulation, libmpow(t,3.0)for the cube, finalcvtsd2ss.
The single most important non-obvious fact: only 15 of the 24 keys are live in the evaluator (name, the 3 symmetry flags + symmetry_point, the 4 special-value results, pos_exponents, neg_exponents, saturation_points, and within those the octave's exponent/num_sections/extract_size and the section's x/d0..d3). The rest are LUT-generator/hardware metadata. Wiring the inert keys in is harmless but reproduces machinery the numeric kernel never reads.
9. Gaps & open questions
- G1 — generator metadata semantics.
imm_bias,fma_const0/1, andexponent_offsetare parsed by neitherevaluate_genericnorinitialize_pwptable. Their role is in the offline KaenaPWP LUT generator (not in the wheel). SPECULATIVE:fma_const0/1may be a post-poly affinef' = fma1·f + fma0applied by silicon, exercised only byparametric_relu(fma_const0 = 1.0). Confirm against thebkt.bin/ctrl.binblob decode (10.4). - G2 — NaN.
nan_resultis loaded into theAFTablebut the evaluator has no NaN branch (§2.3). Whether a NaN ever reaches a bucket whose poly reproducesnan_resultis data-dependent; the silicon may dispatch it. A real sim-vs-silicon divergence on NaN inputs is possible. Needs a NaN trace. - G3 — multipass
ln.use_multipass = trueonly forln_4p_0mp/ln_4p_1mp(a 2-passln:ln(x) = ln(mant) + exp·ln2). The simulator's FuncMap loads single-passln_40p.jsoninstead (CONFIRMED: the binary string pool containsln_40p.jsonbut notln_4p_0mp.json), soevaluate_genericdoes one bucket eval — a knownlnsim-vs-silicon divergence. The pass-combination logic is a generator/HW concern. - G4 — negative-side
sat_pointintent. The negative branch readsexpoas(u8)and compares againstsat_point_neg_{high,low}.sat_point; the bodies are explicit (§5), but whether the JSON author intends neg tables to key off the magnitude's exponent is inferred, not proven against a negative-region trace.
Cross-references: opcode dispatch and the simulate pre-scale operand swap — 7.40 (sim activation/PWP); the silicon Activation engine datapath — 1.10 (activation engine); the pre-baked hardware-LUT blobs (bkt.bin/ctrl.bin) — 10.4 (bkt/ctrl blob). Backing reports: D-M05 (profile schema + 4 full transcriptions), D-F03 (simulator per-opcode kernels + numeric model), D-F18 (full PWPSim::Simulator class + LUT loading + evaluate_generic).