Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Piecewise-Polynomial Activation Model & Evaluation

All symbols, offsets, and addresses on this page apply to neuronx_cc 2.24.5133.0+58f8de22 (cp310; the pwp_jsons/ directory and libpwp_sim.so are byte-identical across cp310/11/12). The numeric model lives in neuronxcc/starfish/lib/libpwp_sim.so (BuildID sha1 c0b9dc4a217f3626d34ef08b5830619064ae7c55, not stripped — DWARF + demangled C++ symbols present, so every address below is certain by symbol, not inference). The profile data lives in neuronxcc/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.

EvaluatorPWPSim::Simulator::evaluate_generic(AFTable&, float v, float alpha) @ 0x99c0 (thunk 0x8ae0)
Bucket searchfind_pwp_nonsat_section(vector<pwp_nonsat_region>&, float) @ 0x9340 (.isra.0)
In-memory tablePWPSim::AFTable — 184 bytes (struct §3)
Profile dataneuronxcc/pwp/pwp_jsons/*.json — 40 files, exactly 24 keys each
Profile loaderinitialize_pwptable @ 0xb040parse_nonsat 0xae90 / parse_sat 0xab50 / parse_xd 0xa550
Polynomial formf(sel) = d0 + d1·t + d2·t² + d3·t³, t = sel − x (degree-3 Taylor about x)
Outer indexbiased exponent: octave = bits(sel) >> 23 (one pwp_nonsat_region per octave)
Inner indexsectid = (bits(sel) & 0x7FFFFF) >> (23 − extract_size) (top extract_size mantissa bits)
Clamp4-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 — visitInstActivationPWPSim::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 one pwp_nonsat_region from 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_size mantissa bits: 2^extract_size buckets, each covering an equal slice of the octave's mantissa range. extract_size = 0 means the whole octave is one segment.

  • The segment. Each bucket stores {x, d0, d1, d2, d3} — a degree-3 Taylor expansion centered at the breakpoint x. Evaluation is d0 + d1·t + d2·t² + d3·t³ with t = 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 flat exp-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

KeyTypeLive?Meaning
namestringyes (key)Bare activation name ("exp", "gelu", "reciprocal"). Matches the ActivationFunctionType name and the table-map key the evaluator binds.
tonga_idintnoCodename id on the gen-1 (Inferentia/tonga) generation. 0 ⇒ "absent on gen-1".
sunda_idintnogen-2+ codename id. sunda_id == neuron_id on all 40.
neuron_idintnoCanonical Neuron id; maps to the BIR InstActivation func field.
max_diffintnoGenerator'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_sizeintnoTotal physical bucket count = Σ stored exponent_sections (pos+neg). 0 for the 13 hardwired funcs. Metadata only.

GOTCHA — max_diff and lut_size are inert in the evaluator. They are the most tempting fields to wire into a reimplementation (they look like sizing parameters), but initialize_pwptable never reads them — the loaded table is already the expanded bucket list, so the evaluator iterates the actual exponent_sections vector lengths, not a declared count. lut_size is a footprint report; max_diff is a generator knob. (CONFIRMED: the binary string pool contains no read site, and the AFTable struct 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.

KeyTypeAFTableMeaning
symmetry_enbool+0Master enable. If false, evaluate on the signed input directly (asymmetric — both pos and neg octaves stored).
symmetry_invert_sign_optbool+1Odd reconstruction: after evaluating the fold, negate the result (f(−x) = −f(x)).
symmetry_opt_use_neg_regionbool+2Fold onto the negative table: `sel = −
symmetry_pointfloat-box+4Reflection 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):

KeyAFTableReturned whenExample (exp / reciprocal)
zero_result+8v == 0.01.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_result is loaded but the evaluator has no NaN branch. evaluate_generic short-circuits only on v == 0 and |v| > FLT_MAX (±inf). A NaN input compares false to FLT_MAX and is != 0, so it falls through to the bit-decompose → bucket → poly path. nan_result is parsed into the AFTable but 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)

KeyTypeLive?Meaning
lower_boundfloat-boxnoSmallest valid input (below which the function saturates). Generator metadata.
upper_boundfloat-boxnoLargest valid input. Generator metadata.
exponent_offsetintnoLowest 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.

KeyTypeLive?Meaning
pos_exponentsarrayyesOne entry per biased-exponent octave, positive side → AFTable.pos_region (+24).
neg_exponentsarrayyesSame 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_pointsobjectyesExactly 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 keyTypeLive?Meaning
exponentintyesThe unbiased exponent of this octave. Stored at vector index exponent + 127.
posboolnoSign-of-region flag (true in pos_exponents). Not loaded into the region struct.
num_sectionsintyesAddressing span = 2^extract_size (the bucket count this octave subdivides into).
extract_sizeintyesNumber of top mantissa bits used to index the inner bucket.
extract_lsbintnoLSB 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_sectionsarrayyesThe per-bucket degree-3 segments (§2.6).

CORRECTION — extract_lsb is a stored key the simulator ignores. D-M05 listed extract_lsb as 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 equals 23 − extract_size for all 1422 octaves with num_sections > 0 (0 violations). But the binary string pool contains extract_size, num_sections, exponent_sections and not extract_lsb — so parse_nonsat never reads it. Instead find_pwp_nonsat_section computes the shift live: 0x936a: sub ecx, [region+8] (where ecx = 23, [region+8] = extract_size), then 0x936d: sar eax, cl. So extract_lsb is redundant generator output, in the same inert class as lut_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 keyTypeexponent_sectMeaning
section_idint(not stored)Bucket index within the octave (0..len−1); positional, not loaded.
xfloat-box+0Segment breakpoint / Taylor-expansion center.
d0float-box+4Degree-0 (constant) coefficient.
d1float-box+8Degree-1 (linear) coefficient.
d2float-box+12Degree-2 (quadratic) coefficient.
d3float-box+16Degree-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_point and mantissa_point are true integers, not bit-boxes. Inside saturation_points, the two threshold fields sat_point and mantissa_point are plain JSON integers compared directly against the input's biased exponent and mantissa (parse_sat @ 0xab50: json_object_get_int64 with no ["...","int"] nesting). Only the x/d0..d3 of each clamp region are float-boxes. Mixing these up — bit-reinterpreting sat_point — breaks the clamp comparison. (CONFIRMED via parse_sat body.)

2.7 FMA / multipass control (metadata)

KeyTypeLive?Meaning
imm_biasboolnoImmediate-additive-bias flag (true on copy/memset/is_finite/reciprocal). HW metadata.
fma_const0float-boxnoFMA constant 0. Nonzero only for parametric_relu (= 1.0); 0 on the other 39.
fma_const1float-boxnoFMA constant 1. 0 on all 40.
use_multipassboolnoTrue 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 not push_back octaves in array order; it writes region[exponent + 127] = {…} into a vector pre-sized to span the octave range. So pos_region.sections is addressable directly by bits(sel) >> 23 with no search — the "search" in find_pwp_nonsat_section is only the inner mantissa-bucket pick, an O(1) shift. (CONFIRMED via parse_nonsat body.)


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) by sizeof(exponent_sect) = 20. This matters because an octave can store fewer segments than its declared num_sections: where the function has already saturated, the generator truncates the tail. For example exp_400p octave +6 declares num_sections = 256 (extract_size = 8) but stores only 99 segments (the rest overflow to +inf and are handled by the high clamp). So num_sections is the addressing span; the vector length is the physical count. A reimplementation must size the bounds check off the stored length. (CONFIRMED: jq shows octave +6 has 256 declared / 99 stored; lut_size = 777 = Σ stored.)

QUIRK — lut_size == Σ stored sections, exactly, for all 40. Verified by summing pos_exponents[].exponent_sections | length + neg_exponents[]… and comparing to lut_size: zero mismatches across all 40 profiles (the 13 hardwired funcs have lut_size = 0 and zero stored sections). So lut_size is a faithful post-truncation footprint — it counts the stored buckets, not the declared Σ num_sections span. (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_points is a 4-way clamp, not a range→segment map. A natural misreading of the name is that saturation_points maps 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 via evaluate_generic body and the saturation_points object having exactly the 4 keys.)

Worked clamp values (CONFIRMED from shipped JSON):

Profilecornersat_pointmantissa_pointpolyeffect
exppos_high1333240472d0=+inf, d1=d2=d3=0`
exppos_low1080d0=1, d1=1tiny +xeˣ ≈ 1 + x
expneg_high1334362891d0=0, d1=0x ≲ −88eˣ → 0
reciprocalpos_high169d0=0huge +x1/x → 0
reciprocalpos_low85d0=1e+07tiny +x1/x large
gelupos_high1291926024d0=0, d1=1large +xgelu(x) → x
reluposd0=0, d1=1the 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 three derivative_*, leaky_relu, parametric_relu, memset_zero, is_finite) carry empty exponent_sections and encode f(x) entirely in the four saturation_points (often degenerate linear/constant): relu is pos {d0=0,d1=1} → x, neg {d0=0,d1=0} → 0; copy is all-four {d0=0,d1=1} → x. parametric_relu is the sole exception with no LUT path — its slope alpha is 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)familyfunctions
(F, F, F, 0)asymmetric — store both pos+neg, eval signed vexp, 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 — sigmoid is point-symmetric, not asymmetric. An earlier reading (D-F18 §3.3) listed sigmoid with symmetry_en = false. The shipped sigmoid_40p.json is (T, T, T, sympt=1): symmetry_en = true, invert_sign_opt = true, opt_use_neg_region = true, symmetry_point = 1.0, with pos_exponents empty and 11 negative octaves. It stores the negative region and reflects positive inputs via 1 − 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_ensel = |−4| = 4; octave/bucket → r ≈ 0.25; invert_sign_optr = −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·t stays in float (mulss/addss at 0x9b2b/0x9b38) — the linear term is computed and rounded in single precision.
  • d2·t² and d3·t³ accumulate in doubled2 and d3 are promoted via cvtss2sd (0x9b17/0x9b1c), and the running sum r is a double until the final cvtsd2ss (0x9b83).
  • the cube uses libm pow(t, 3.0), not t*t*t (call _pow @ 0x9b5b) — these can differ in the last ULP.

A reimplementation that does straight float Horner, or computes t*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 +inf rides the normal poly path. When the input lands in exp.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:

  1. The profile loader. Parse the 24-key JSON; for every float field read the .int member and store the raw uint32 bits (never the "float" string). For saturation_points, read sat_point/mantissa_point as plain integers. Build the octave vector indexed by exponent + 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-octave extract_lsb/pos/section_id.

  2. The pre-scale. scaled = multiplier·in + addend (operand order per 7.40).

  3. Special-value short-circuits. v==0 → zero_result; |v|>FLT_MAX → (v≤0 ? ninf : pinf). No NaN branch (NaN falls through to the poly).

  4. The symmetry fold + reconstruct (§6): fold to sel = ±|v| or signed v; after the poly, conditionally negate and add symmetry_point.

  5. The two-level index (§4): octave = bits(sel)>>23 ((u8)); sectid = (bits(sel)&0x7FFFFF) >> (23 − extract_size); bounds-check against the stored section count.

  6. 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.

  7. The mixed-precision degree-3 evaluation (§7 GOTCHA): float linear term, double quadratic+cubic accumulation, libm pow(t,3.0) for the cube, final cvtsd2ss.

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, and exponent_offset are parsed by neither evaluate_generic nor initialize_pwptable. Their role is in the offline KaenaPWP LUT generator (not in the wheel). SPECULATIVE: fma_const0/1 may be a post-poly affine f' = fma1·f + fma0 applied by silicon, exercised only by parametric_relu (fma_const0 = 1.0). Confirm against the bkt.bin/ctrl.bin blob decode (10.4).
  • G2 — NaN. nan_result is loaded into the AFTable but the evaluator has no NaN branch (§2.3). Whether a NaN ever reaches a bucket whose poly reproduces nan_result is 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 = true only for ln_4p_0mp/ln_4p_1mp (a 2-pass ln: ln(x) = ln(mant) + exp·ln2). The simulator's FuncMap loads single-pass ln_40p.json instead (CONFIRMED: the binary string pool contains ln_40p.json but not ln_4p_0mp.json), so evaluate_generic does one bucket eval — a known ln sim-vs-silicon divergence. The pass-combination logic is a generator/HW concern.
  • G4 — negative-side sat_point intent. The negative branch reads expo as (u8) and compares against sat_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).