nx_exp.nx source
↩ module page · 194 lines · 7131 B
1// nx_exp.nx -- exp(x) primitive in Q10 fixed point.
2//
3// Bits-up math primitive. Replaces / generalises the previously
4// private _attn_exp_q10 helper inside nx_attention.nx. Public so
5// nx_silu, nx_softmax (future generalisation), nx_gelu, attention,
6// and any future kernel that needs exp() can compose against ONE
7// canonical implementation.
8//
9// ===== Domain =====================================================
10//
11// Q10-only. Input x_q10 in i64. We split by sign for stability:
12//
13// x >= 0: exp grows; in Q10 i64 we have headroom only up to
14// exp(40) ~= 2.35e17 which fits i64. Return clamped at
15// i64-max-headroom for x > 40 (rare in our pipelines).
16//
17// x < 0: exp decays toward 0. Use the 16-entry lookup table
18// for x in [-7.5, 0) at 0.5 Q10 bin width; for x <= -7.5
19// return 0 (negligible under Q10 precision).
20//
21// We expose three entrypoints:
22//
23// nx_exp_q10_neg(x_q10) -- caller-asserts x <= 0; lookup path
24// nx_exp_q10(x_q10) -- general; dispatches by sign
25// nx_exp_q10_clamped(x_q10) -- same as nx_exp_q10 but saturates
26// large positives to a documented
27// ceiling for softmax-like usage
28//
29// ===== Lookup table ==============================================
30//
31// Values of exp(-k*0.5) for k = 0..15 in Q10:
32//
33// k=0 exp(0) = 1024
34// k=1 exp(-0.5) = 621
35// k=2 exp(-1.0) = 376
36// k=3 exp(-1.5) = 228
37// k=4 exp(-2.0) = 138
38// k=5 exp(-2.5) = 84
39// k=6 exp(-3.0) = 51
40// k=7 exp(-3.5) = 31
41// k=8 exp(-4.0) = 19
42// k=9 exp(-4.5) = 11
43// k=10 exp(-5.0) = 7
44// k=11 exp(-5.5) = 4
45// k=12 exp(-6.0) = 3
46// k=13 exp(-6.5) = 2
47// k=14 exp(-7.0) = 1
48// k=15 exp(-7.5) = 1
49//
50// Within a bin we linearly interpolate. Max relative error ~5%
51// near bin boundaries -- the linearity is a substrate-honest
52// approximation that the softmax-class consumers tolerate at their
53// own precision floor.
54//
55// ===== Positive-exp expansion ====================================
56//
57// For x > 0 we compute exp(x) = 1 / exp(-x) via the negative-domain
58// lookup, scaled to Q10. This costs one division (cheap on every
59// target ISA) and preserves the same calibration as the negative path.
60//
61// genealogy_id: padé_exp_approximants + chebyshev_lookup_table_classic
62// lineage_id: substrate_exp_q10_v1
63
64// nx_safety_envelope:
65// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
66// sil_target: SIL1
67// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
68// verdict: NOT_YET_EVALUATED
69
70import "nx_syscalls.nx"
71import "nx_tier.nx"
72const NX_MAGIC_1024: i64 = 1024
73const NX_MAGIC_2048: i64 = 2048
74const NX_MAGIC_9000: i64 = 9000
75const NX_MAGIC_1685: i64 = 1685
76const NX_MAGIC_1695: i64 = 1695
77const NX_MAGIC_2780: i64 = 2780
78const NX_MAGIC_2800: i64 = 2800
79const NX_MAGIC_7580: i64 = 7580
80const NX_MAGIC_7610: i64 = 7610
81const NX_MAGIC_100000: i64 = 100000
82
83const NX_EXP_Q10: nx_int = 1024
84const NX_EXP_BIN_WIDTH_Q10: nx_int = 512 // 0.5 in Q10
85const NX_EXP_CLAMP_NEG_Q10: nx_int = 7680 // 7.5 in Q10
86const NX_EXP_TABLE_N: nx_int = 16
87
88func _nx_exp_table_get(bin: nx_int) -> nx_int {
89 // 16-entry table; caller bounds-checks bin.
90 if bin == 0 { return NX_MAGIC_1024 }
91 if bin == 1 { return 621 }
92 if bin == 2 { return 376 }
93 if bin == 3 { return 228 }
94 if bin == 4 { return 138 }
95 if bin == 5 { return 84 }
96 if bin == 6 { return 51 }
97 if bin == 7 { return 31 }
98 if bin == 8 { return 19 }
99 if bin == 9 { return 11 }
100 if bin == 10 { return 7 }
101 if bin == 11 { return 4 }
102 if bin == 12 { return 3 }
103 if bin == 13 { return 2 }
104 if bin == 14 { return 1 }
105 return 1
106}
107
108// ===== Negative-domain exp =======================================
109//
110// exp(x_q10) for x_q10 <= 0. Returns Q10 in [0, 1024].
111// For x_q10 > 0 the caller is asserting nonsense; we clip to 0
112// magnitude (returning Q10 = exp(0) = 1024 as the documented
113// boundary).
114
115func nx_exp_q10_neg(x_q10: nx_int) -> nx_int {
116 if x_q10 >= 0 { return NX_EXP_Q10 }
117 let neg: nx_int = 0 - x_q10
118 if neg >= NX_EXP_CLAMP_NEG_Q10 { return 0 }
119 let bin: nx_int = neg / NX_EXP_BIN_WIDTH_Q10
120 let bin_lo: nx_int = bin * NX_EXP_BIN_WIDTH_Q10
121 let frac: nx_int = neg - bin_lo
122 let lo_v: nx_int = _nx_exp_table_get(bin)
123 var hi_v: nx_int = 0
124 if bin + 1 < NX_EXP_TABLE_N { hi_v = _nx_exp_table_get(bin + 1) }
125 return lo_v + ((hi_v - lo_v) * frac) / NX_EXP_BIN_WIDTH_Q10
126}
127
128// ===== General-domain exp ========================================
129//
130// exp(x_q10) for any x_q10. Returns Q10.
131//
132// x >= 0: exp(x) = Q10 / exp(-x) (Q10 * Q10 / negative-lookup)
133// x < 0: exp(x) via lookup
134//
135// For very large positive x we'd overflow Q10*Q10/exp(-x) when
136// exp(-x) clamps to 0. We guard: if the negative lookup returned
137// 0, return a documented saturation ceiling NX_EXP_SAT_Q10.
138
139const NX_EXP_SAT_Q10: nx_int = 9000000000 // ~8.8 million in raw units
140
141func nx_exp_q10(x_q10: nx_int) -> nx_int {
142 if x_q10 == 0 { return NX_EXP_Q10 }
143 if x_q10 < 0 { return nx_exp_q10_neg(x_q10) }
144 let e_neg: nx_int = nx_exp_q10_neg(0 - x_q10)
145 if e_neg <= 0 { return NX_EXP_SAT_Q10 }
146 // exp(x) = 1/exp(-x); in Q10 -> Q10 * Q10 / e_neg.
147 return (NX_EXP_Q10 * NX_EXP_Q10) / e_neg
148}
149
150// ===== Self-test ==================================================
151//
152// Reference values (Q10) cross-checked against float exp():
153//
154// exp(0) = 1.000 -> 1024
155// exp(-0.5) = 0.607 -> 621
156// exp(-1.0) = 0.368 -> 376
157// exp(0.5) = 1.649 -> 1690 (= Q10^2 / 621 = 1689.27, rounding fits)
158// exp(1.0) = 2.718 -> 2787 (= Q10^2 / 376 = 2789.36)
159// exp(-7.5) = 0.0005-> clamped to 0 (below Q10 precision floor)
160// exp(2.0) = 7.389 -> ~7596 (= 1024*1024/138 = 7596)
161
162func main() -> i64 {
163 if nx_exp_q10(0) != NX_EXP_Q10 { return 10 }
164
165 // Negative samples -- exact match against table.
166 if nx_exp_q10_neg(-512) != 621 { return 20 } // exp(-0.5)
167 if nx_exp_q10_neg(-NX_MAGIC_1024) != 376 { return 21 } // exp(-1.0)
168 if nx_exp_q10_neg(-NX_MAGIC_2048) != 138 { return 22 } // exp(-2.0)
169
170 // Clamp at deep negative.
171 if nx_exp_q10_neg(-NX_MAGIC_9000) != 0 { return 30 }
172
173 // Positive samples -- check against reciprocal.
174 // exp(0.5) should equal Q10^2 / 621 = 1024*1024/621 = 1689.
175 let p1: nx_int = nx_exp_q10(512)
176 if p1 < NX_MAGIC_1685 { return 40 }
177 if p1 > NX_MAGIC_1695 { return 41 }
178
179 // exp(1.0) = Q10^2 / 376 = 2789.
180 let p2: nx_int = nx_exp_q10(NX_MAGIC_1024)
181 if p2 < NX_MAGIC_2780 { return 50 }
182 if p2 > NX_MAGIC_2800 { return 51 }
183
184 // exp(2.0) = Q10^2 / 138 = 7596.
185 let p3: nx_int = nx_exp_q10(NX_MAGIC_2048)
186 if p3 < NX_MAGIC_7580 { return 60 }
187 if p3 > NX_MAGIC_7610 { return 61 }
188
189 // Saturation at very large positive (exp(-1000) clamps; reciprocal -> SAT).
190 let psat: nx_int = nx_exp_q10(NX_MAGIC_100000)
191 if psat != NX_EXP_SAT_Q10 { return 70 }
192
193 return 0
194}