asmcrypto 0.1.2

Register-parallel cryptographic primitives: 8-lane AVX-512 Keccak-256 batch and secp256k1 ECDSA batch address recovery, outperforming libsecp256k1.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
# Prompt & Commit History

---

## 2026-03-18 — Initial Keccak-256 implementation

### Prompt
> Write a keccak256 function from scratch.

### High-level effects
- Implemented `keccak256(input: &[u8]) -> [u8; 32]` in `src/lib.rs` with no
  external dependencies.
- Full Keccak-f[1600] permutation (24 rounds) implemented inline, covering all
  five steps per round: θ, ρ, π, χ, ι.
- Used the **original Keccak padding** (delimiter byte `0x01`) rather than
  NIST SHA-3 (`0x06`), matching the Ethereum Yellow Paper specification.
- Sponge parameters: rate = 1088 bits (136 bytes), capacity = 512 bits,
  output = 256 bits.
- Four test vectors added and verified:
  - `keccak256(b"")` — well-known Ethereum/Keccak reference value.
  - `keccak256(b"abc")` — reference implementation cross-check.
  - `keccak256([0u8; 136])` — exercises the exact-rate-boundary code path.
  - `keccak256(b"Transfer(address,address,uint256)")` — matches the canonical
    ERC-20 Transfer event topic `ddf252ad…`.

---

## 2026-03-18 — Module split + Ethereum ECDSA recovery

### Prompt
> Make two modules keccak and ecdsa. Move the keccak function and tests to the
> keccak module. In the ecdsa module, implement Ethereum ecdsa recovery from scratch.

### High-level effects

**Restructuring:**
- `src/lib.rs` reduced to a bare crate root that re-exports `pub mod keccak`
  and `pub mod ecdsa`.
- All Keccak code (constants, permutation, sponge, tests) moved verbatim into
  `src/keccak.rs`.

**`src/ecdsa.rs` — new, zero-dependencies:**
- `U256` — 256-bit integer with little-endian `[u64; 4]` limbs; add-with-carry,
  subtract-with-borrow, bit test, big-endian serialisation.
- Widening 256×256→512-bit schoolbook multiply (`mul_wide`).
- **Field arithmetic mod p** (secp256k1 prime) using a 2-iteration Solinas
  reduction (`fp_reduce_wide`): `fp_add`, `fp_sub`, `fp_neg`, `fp_mul`, `fp_sq`,
  `fp_pow`, `fp_inv` (Fermat), `fp_sqrt` (Tonelli–Shanks shortcut, since p ≡ 3
  mod 4).
- **Scalar arithmetic mod n** (group order) using iterative N_COMPL folding
  (`fn_reduce_wide`): `fn_add`, `fn_neg`, `fn_mul`, `fn_pow`, `fn_inv`.
- **Jacobian point arithmetic**: `point_double` (dbl-2009-l formulas, a=0),
  `point_add_mixed` (madd-2007-bl), `scalar_mul_affine`, `scalar_mul_g`.
- **`recover_public_key`** — standard ECDSA recovery:
  1. Lift r to curve point R (square-root of r³+7).
  2. Choose y parity from recovery_id.
  3. Compute `Q = r⁻¹·(−z·G + s·R)`.
  4. Return 65-byte uncompressed point (`04 || X || Y`).
- **`recover_address`** — hashes the 64-byte (X‖Y) with `keccak256`, takes the
  last 20 bytes.

**Tests (10 total, all passing):**
- `keccak::*` — 4 existing Keccak vectors unchanged.
- `ecdsa::test_fp_arithmetic``(p−1)²≡1 mod p` and G on-curve check.
- `ecdsa::test_fp_inv``2·inv(2)≡1 mod p`.
- `ecdsa::test_fn_arithmetic``2·inv(2)≡1 mod n`.
- `ecdsa::test_scalar_mul_one``1·G == G`.
- `ecdsa::test_scalar_mul_two``2·G` matches known secp256k1 value.
- `ecdsa::test_ecrecover_precompile_vector` — go-ethereum `ValidKey` JSON
  vector; recovers address `a94f5374fce5edbc8e2a8697c15331677e6ebf0b`.

---

## 2026-03-18 — Baseline benchmarks (Keccak & ECDSA)

### Prompt
> Benchmark the reference keccak and ecdsa recovery.

### High-level effects
- Added `criterion 0.5` (with `html_reports`) to `[dev-dependencies]`.
- Created `benches/keccak.rs`: five input sizes (empty, 32 B, 136 B, 1 KiB,
  1 MiB); single function per size.
- Created `benches/ecdsa.rs`: `recover_public_key` and `recover_address` groups
  using the go-ethereum `ValidKey` test vector.
- Declared `[[bench]]` entries in `Cargo.toml` with `harness = false`.

**Baseline results (optimised build, criterion 0.5):**

| Benchmark | Time |
|---|---|
| keccak256 empty | 221 ns |
| keccak256 32 B | 220 ns |
| keccak256 136 B | 426 ns |
| keccak256 1 KiB | 1.69 µs |
| keccak256 1 MiB | 1.607 ms |
| ecdsa recover_public_key | 1.26 ms |
| ecdsa recover_address | 1.26 ms |

---

## 2026-03-18 — Comparative benchmarks vs alloy-consensus libraries

### Prompt
> Benchmark against the libraries used by alloy-consensus.

### High-level effects
- Fetched alloy-consensus and alloy-primitives Cargo.toml to identify
  standard Ethereum library choices:
  - **Keccak**: `sha3` (always-on in alloy-primitives), `tiny-keccak` (optional).
  - **ECDSA**: `k256` (pure-Rust RustCrypto) and `secp256k1` (C-backed libsecp256k1).
- Added dev-dependencies: `sha3 = "0.10"`, `tiny-keccak = { version = "2",
  features = ["keccak"] }`, `k256 = { version = "0.13", features = ["ecdsa"] }`,
  `secp256k1 = { version = "0.29", features = ["recovery", "global-context"] }`.
- Rewrote `benches/keccak.rs` to compare all three Keccak backends per size.
- Rewrote `benches/ecdsa.rs` to compare asmcrypto vs k256 vs secp256k1.
  - The test vector is generated at startup using `k256`'s own signing API with
    a fixed private key (`[0x01; 32]`), guaranteeing a canonical (low-s)
    signature that all three libraries accept.

**Comparative results:**

### Keccak-256

| Input | asmcrypto | sha3 | tiny-keccak |
|---|---|---|---|
| empty | 221 ns | 231 ns | 228 ns |
| 32 B | 220 ns | 225 ns | 229 ns |
| 136 B | 426 ns | 418 ns | 426 ns |
| 1 KiB | 1.69 µs | 1.60 µs | 1.66 µs |
| 1 MiB | 1.607 ms | 1.517 ms | 1.565 ms |

Our Keccak implementation is **within 5 %** of both reference libraries across
all input sizes; sha3 has a slight edge at large inputs likely due to its
SIMD-friendly inner loop.

### ECDSA recovery

| Benchmark | asmcrypto | k256 (RustCrypto) | secp256k1 (libsecp256k1) |
|---|---|---|---|
| recover_public_key | 1.38 ms | 111.7 µs **(12× faster)** | 21.4 µs **(64× faster)** |
| recover_address | 1.38 ms | 112.0 µs **(12× faster)** | 21.6 µs **(64× faster)** |

The ECDSA gap is the primary motivation for this project: production libraries
use highly-optimised scalar multiplication (wNAF window, GLV endomorphism, or
hand-written assembly), whereas our current implementation uses a simple
double-and-add loop. These numbers define the improvement target for the
forthcoming assembly/SIMD optimisations.

---

## 2026-03-18 — GLV + wNAF Shamir scalar multiplication

### Prompt
> Can we use the wnaf+glv endomorphism in our reference code?

### High-level effects

Replaced the simple double-and-add scalar multiplication in `src/ecdsa.rs` with
a complete GLV+wNAF Shamir simultaneous scalar multiplication:

**Algorithm overview:**
- **GLV decomposition:** Any scalar `k` is split into two ~128-bit sub-scalars
  `k1`, `k2` satisfying `k ≡ k1 + k2·λ (mod n)`, where `λ` is the GLV
  eigenvalue. This halves the effective scalar size.
- **wNAF width-5:** Each sub-scalar is encoded in width-5 Non-Adjacent Form,
  giving digits in `{0, ±1, ±3, ±5, ±7, ±9, ±11, ±13, ±15}`. Window tables of
  8 precomputed odd multiples `[P, 3P, …, 15P]` are built for `P` and `φ(P)`.
- **Shamir's trick:** Both wNAF sequences are processed together in a single
  131-step loop, roughly doubling throughput vs two independent multiplications.
- **φ(P):** The GLV endomorphism maps affine point `(x, y)` to `(β·x, y)` in
  O(1) field multiplications, where `β = (√(−3) − 1)/2 mod p`.

**Key new types and functions:**
- `S129` — signed 129-bit integer `{ mag: u128, hi: bool, neg: bool }` for
  sub-scalar storage.
- `glv_decompose(k) -> (S129, S129)` — Babai rounding decomposition using
  precomputed 256-bit lattice constants `g1`, `g2`, `b1`, `b2`.
- `wnaf_129(k_lo, k_hi) -> [i8; 131]` — width-5 wNAF with correct carry
  handling for 129-bit inputs.
- `build_table`, `table_get` — precomputed odd multiples and signed lookup.
- `phi_affine(px, py)` — applies the GLV endomorphism.
- `scalar_mul_glv_wnaf` — core Shamir loop; replaces both `scalar_mul_g` and
  `scalar_mul_affine`.
- `point_add` — full Jacobian-Jacobian point addition.
- `point_neg` — negate a Jacobian point.

**Bugs found and fixed during implementation (7 total in constants/formulas):**
1. GLV formula: `r2 = c2·b2 − c1·b1 mod n` (not `c1·(−b1) + c2·(−b2)`).
2. `S129` struct: added `hi: bool` for bit 128 (k1 can reach 129 bits).
3. `wnaf_129`: replaced 128-bit version; carries correctly between `lo`/`hi`.
4. `n_half`: wrong constant (`0xDFE92F4661B8DD57``0xDFE92F46681B20A0`).
5. `LAMBDA` limb[0]: nibble-shifted (`0x2E0CFC810B51283C``0xE0CFC810B51283CE`).
6. `BETA` lower 3 limbs: all three wrong; corrected to match secp256k1 spec.
7. `BETA` value itself: was `0x7AE96A2B657C0710…` (wrong cube root of unity);
   correct value is `β = (√(−3)−1)/2 mod p = 0x851695D49A83F8EF…` which
   satisfies `φ(P) = λ·P` for all curve points.

**Final constants (verified):**
- `BETA = 0x851695d49a83f8ef919bb86153cbcb16630fb68aed0a766a3ec693d68e6afa40`
- `LAMBDA = 0xac9c52b33fa3cf1f5ad9e3fd77ed9ba4a880b9fc8ec739c2e0cfc810b51283ce`

All 10 tests pass.

---

## 2026-03-18 — perf profiling of ECDSA hotspots

### Prompt
> Run perf on the asmcrypto ecdsa to identify hotspots.

### High-level effects

Added `examples/perf_ecdsa.rs` — a 50 000-iteration tight loop over
`recover_address` with a 200-call warm-up, suitable for `perf record`.

Profiled on: **AMD Ryzen 9 9955HX** (Zen 5).  
Available ISA extensions of interest: **ADX** (MULX/ADCX/ADOX), **AVX-512 IFMA**
(`VPMADD52LU/HU`), BMI1/BMI2, AVX-512BW/DQ/VL, SHA_NI.

#### Flat profile (self time, 3096 samples, `perf record -g --call-graph dwarf -F 997`)

| Self % | Samples | Function |
|---|---|---|
| **68.3%** | 2092 | `asmcrypto::ecdsa::fp_mul` |
| 12.6% | 380 | `asmcrypto::ecdsa::point_double` |
| 11.5% | 351 | `asmcrypto::ecdsa::fn_mul` |
| 3.6% | 110 | `asmcrypto::ecdsa::point_add` |
| 1.5% | 44 | `asmcrypto::ecdsa::scalar_mul_glv_wnaf` |
| 1.0% | 28 | `asmcrypto::ecdsa::JacobianPoint::to_affine` |

#### Interpretation

`fp_mul` alone consumes 68% of cycles. Its inner loop is a 4×4 schoolbook
`mul_wide` (16 × `MUL` instructions) followed by a two-round Solinas reduction.
The `point_double` and `point_add` percentages are mostly `fp_mul` calls reported
at the call-site (LLVM inlines lightly here). `fn_mul` (scalar-field mod n) uses
the same schoolbook core.

#### Optimisation targets, in priority order

1. **`fp_mul` (68%)** — replace `mul_wide` schoolbook with an ADX-accelerated
   implementation using `MULX`/`ADCX`/`ADOX`. These instructions decouple the
   multiply output from CF/OF, enabling full pipelining of the carry chain.
   Alternative: `VPMADD52LU`/`VPMADD52HU` (AVX-512 IFMA) for a 52-bit multiply
   accumulate approach used by e.g. OpenSSL's P-256 assembly.

2. **`fn_mul` (11.5%)** — same treatment; both share `mul_wide`.

3. **Jacobian formula reduction**`point_double` costs ~8 `fp_mul` + 4 `fp_sq`;
   `point_add` costs ~11 `fp_mul` + 2 `fp_sq`. Switching to add-2008-bj
   (dedicated doubling formula) or a Z=1 mixed-add path would reduce call count.

4. **`to_affine` (1%)** — one modular inversion (254-step exponentiation).
   Batching inversions across multiple signature verifications (Montgomery's
   trick) would amortise this cost.

---

## 2026-03-18 — ADX mul_wide, precomputed G table, target-cpu=native

### Prompt
> Do all of these [three optimisation targets from the perf profile].

### High-level effects

**1. `.cargo/config.toml` — native CPU tuning (new file)**

```toml
[build]
rustflags = ["-C", "target-cpu=native"]
```

Enables LLVM to emit BMI2 and ADX instructions throughout the crate and makes
`#[cfg(target_feature = "bmi2")]` / `"adx"` resolve to `true` at compile time
on this machine (AMD Ryzen 9 9955HX).

**2. ADX-accelerated `mul_wide` (`src/ecdsa.rs`)**

`mul_wide` is the 256×256→512-bit schoolbook multiply called by both `fp_mul`
(68% of cycles) and `fn_mul` (11.5%). It was replaced with two implementations
selected at compile time:

- **`mul_wide_adx`** — 4-row MULX/ADCX/ADOX inline assembly using per-row
  **two-chain carry** (ADCX feeds CF, ADOX feeds OF in parallel), eliminating
  the serialisation between multiply and add:
  - `MULX r_hi, r_lo, mem` — multiplies `rdx * mem`, stores hi:lo in two
    registers without touching any flags.
  - `ADCX dst, src``dst += src + CF`, sets CF only.
  - `ADOX dst, src``dst += src + OF`, sets OF only.
  - `xor e_reg, e_reg` at each row start atomically clears both CF and OF.
  - Register map: r8–r15, rcx, rdi (8 accumulators R[0]–R[7]), r9/r13
    (hi/lo temps), rax (zero constant), rdx (b[i] for MULX).
  - `rbx` is off-limits in Rust's inline asm on Linux (LLVM uses it internally);
    replaced with `rdi` for R[7].
  - `#[target_feature(enable = "bmi2,adx")]` guards the function; `#[inline(always)]`
    cannot be combined with `#[target_feature]` (Rust issue #145574), removed.

- **`mul_wide_generic`** — the original schoolbook `u128`-based loop, used when
  BMI2/ADX are unavailable.

- **`mul_wide`** — dispatch function using `#[cfg(target_feature)]`; selects ADX
  path at zero runtime cost when both features are present.

**3. Precomputed affine G and φ(G) tables (`src/ecdsa.rs`)**

Two compile-time constants added after `LAMBDA`:

- **`G_TABLE: [(U256, U256); 8]`** — affine coordinates of [G, 3G, 5G, …, 15G],
  the 8 odd multiples used by window-4 wNAF.
- **`PHI_G_TABLE: [(U256, U256); 8]`** — affine coordinates of [φ(G), 3φ(G),
  …, 15φ(G)].  Since the GLV endomorphism maps (x, y) → (β·x, y), the
  y-coordinates are identical to `G_TABLE`; only the x-coordinates differ.

Both tables were computed via Python using actual secp256k1 curve arithmetic and
verified against known generator coordinates.

**4. Fixed-base `scalar_mul_g` rewritten**

`scalar_mul_g` previously delegated to the general `scalar_mul_glv_wnaf` with GX/GY
as input. It is now a dedicated fixed-base implementation:

- Skips the `build_table` step entirely (tables are compile-time constants).
- Uses `point_add_mixed(&acc, &qx, &qy)` (assumes Z₂=1) instead of full
  `point_add`, saving ~4 `fp_mul` per step (madd-2007-bl vs add-2007-bl).
- Helper functions `g_table_lookup(d, negate)` and `phi_g_table_lookup(d, negate)`
  combine the subscalar sign flag (from GLV decomposition) with the wNAF digit
  sign to avoid redundant negation branches.

**5. Correctness**

All 10 tests pass. No warnings after:
- wrapping the `asm!` macro in an explicit `unsafe {}` block (Rust 2024
  `unsafe_op_in_unsafe_fn` lint).
- adding `#[allow(dead_code)]` to `GX` / `GY` (superseded by `G_TABLE[0]` but
  retained as documentation constants).

**Benchmark results (criterion, release, target-cpu=native):**

| Benchmark | Before | After | Speedup |
|---|---|---|---|
| asmcrypto recover_public_key | 60.5 µs | 57.2 µs | +5.8% |
| asmcrypto recover_address | 60.5 µs | 57.0 µs | +5.8% |
| k256 recover_public_key || 115.8 µs | (ref) |
| secp256k1 recover_public_key || 21.5 µs | (target) |

The ADX `mul_wide` is responsible for the full 5.8% improvement on the ECDSA
benchmarks (both paths use variable-base multiplication; the precomputed G table
accelerates signing but `recover_public_key` uses only `scalar_mul_affine`).

The remaining 2.6× gap vs secp256k1 is attributable to:
- `point_double` (8 fp_mul currently, no ADX in the doubling formula itself),
- Jacobian → affine conversion (one 254-step exponentiation, not batched),
- Solinas reduction in `fp_reduce_wide` (not yet pipelined with the multiply).

---

## Session 5e — Inline fn_reduce_wide into fn_mul; #[inline(never)] on fp_mul

### Prompts
> Put an inline(never) on fp_mul instead. Manually inline fn_reduce_wide into
> fn_mul. Change the bench of reduce for a bench of fn_mul.

### High-level effects

**`fn_mul` fully inlined (commit 3671f61):**
- The previously separate `fn_reduce_wide` function (2-pass unrolled port of
  the C `secp256k1_scalar_reduce_512`) is now inlined directly inside `fn_mul`.
- `fn_mul` is marked `#[inline(never)]` so LLVM treats it as a single
  out-of-line unit, giving the scheduler a large instruction window rather than
  duplicating the expanded macro body at every call site.
- `fp_mul` is also marked `#[inline(never)]` for the same reason — it dominates
  at ~68% of ECDSA cycles according to perf.

**Dead code removed:**
- The standalone `fn_reduce_wide` function deleted (was 140 lines).
- `N_COMPL: U256` constant deleted (values inlined as `N_C_0`/`N_C_1` inside
  the local macros in `fn_mul`).

**Benchmarks updated:**
- `benches/mul_wide.rs` bench renamed from `fn_reduce_wide` to `fn_mul`
  (measuring `mul_wide` + full reduction together).
- `examples/perf_reduce.rs` updated: `bench_fn_reduce_wide(fn_wide)` replaced
  with `bench_fn_mul(n_minus_1, n_minus_1)` where `n_minus_1` is the 4-limb
  LE representation of n−1.

**Build:** 10/10 tests pass, zero warnings.

---

## Session 5m — fp_sq squaring kernel, fp_half, C-style point_double

**Prompts:**
1. "use fp_sq where possible"
2. "look at what the C code does for the multiply in fp_mul"
3. "implement point_double as in the C code"

**High-level effects (commit 3a7ae60):**

### Dedicated `fp_sq` squaring kernel
The previous `fp_sq(a)` simply delegated to `fp_mul(a, a)`, computing all 16
products of a 4×4 schoolbook multiply.  
A dedicated squaring kernel exploits the symmetry of Aᵀ·A:
- **Diagonal terms** `aᵢ²` (×4): `muladd(c, aᵢ, aᵢ)` — 1 mul each
- **Cross-terms** `2·aᵢ·aⱼ` (×6): `muladd2(c, aᵢ, aⱼ)` — 1 mul + left-shift-by-1

Total: **10 `mulq` instead of 16**, followed by the same Solinas fold as `fp_mul`.
Marked `#[inline(never)]` to give LLVM a wide scheduling window.

### `fp_half` — field halving
New `#[inline(always)] fn fp_half(a: &U256) -> U256`:
- If `a` is even: right-shift all 4 limbs by 1 (free).
- If `a` is odd: add `p` (which is also odd, so `a + p` is even), then shift.

The carry out of bit 255 slides into bit 255 of the result. No multiplications
required — just one conditional 256-bit add and a 256-bit shift.

### `point_double` ported from secp256k1 `gej_double`
The dbl-2009-l formula (5 sqr + 2 mul) was replaced by the secp256k1 C
library's `gej_double` formula (4 sqr + 3 mul), which uses `fp_half` to
halve the slope:

```
L  = (3/2)·X₁²   [1 sqr + 3× + half]
S  = Y₁²          [1 sqr]
T  = −X₁·S        [1 mul]
X₃ = L² + 2T      [1 sqr]
S' = S²            [1 sqr]
Y₃ = −(L·(X₃+T) + S')  [1 mul]
Z₃ = Y₁·Z₁        [1 mul]
```

The formula has slightly shorter latency chains trading one of the cheap
field squarings for the near-free `fp_half`.

### `fp_mul_8` removed
The `fp_mul_8` helper (used by the old dbl-2009-l `Y₃` computation) is now
dead and was deleted.

**Performance:**
| Metric | Before | After | Δ |
|---|---|---|---|
| ECDSA e2e | 57.0 µs | 55.59 µs | −2.5% |
| fp_mul | 8.00 ns | 8.27 ns | +3% (noise) |

The ECDSA improvement comes entirely from faster squarings: `fp_sq` is called
in `point_double`, `fp_inv`, `fp_sqrt`, and the main scalar-multiplication loop.

**Build:** 10/10 tests pass, zero warnings.

---

## Session 5n — `ecdsa_clone`: 5×52-bit field, all 11 tests passing

### Prompt
> "make a new module ecdsa_clone that is an exact translation of the C code.
> Reference the path to each C function on my local machine in the comments."
> (followed by several debugging sessions to fix bugs in the translation)

### High-level effects

**New `src/ecdsa_clone.rs`** (~1550 lines): a direct Rust translation of
`libsecp256k1`'s 5×52-bit field-element representation and group operations,
each function cross-referenced to its C source by comment.

**Structures mirroring the C types:**
- `Fe { n: [u64; 5] }` — 5×52-bit packed field element
- `Scalar { d: [u64; 4] }` — 256-bit group scalar (little-endian)
- `Ge / Gej` — affine and Jacobian secp256k1 points

**Bugs found and fixed during translation:**

1. **`scalar_inverse_var` limb-iteration order** — binary square-and-multiply
   must iterate MSB-first within each 64-bit word.

2. **`N_C_0` constant** — the N-complement limb 0 was off by 1:
   correct form is `N_0.wrapping_neg()`.

3. **`fe_inv` used wrong modulus** — used scalar n−2 instead of field prime p−2.
   Fixed: binary square-and-multiply over
   `[0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFF, 0xFFFFFFFFFFFFFFFF, 0xFFFFFFFEFFFFFC2D]`.

4. **`fe_half` used `|` instead of `+`** — the critical bug. The C source
   writes `(t1 >> 1) + ((t2 & one) << 51)`. For magnitude ≥ 2 inputs,
   `t1 >> 1` can already have bit 51 set; `|` then silently discards the carry,
   producing a result wrong by `2^155 + 2^207`. Using `+` propagates the carry
   correctly. This caused `gej_double` to return the wrong x-coordinate for 2G.

**`test_ecrecover_clone`:** recovers Ethereum address
`7156526fbd7a3c72969b54f64e42c10fbb768c8a` from a known secp256k1 signature.

**Build:** 21/21 tests pass (11 ecdsa_clone + 4 keccak + 6 ecdsa), zero errors.

---

## Session 5o — `ecdsa_clone`: Strauss wNAF + GLV ecmult; 44.7 µs/call

### Prompt
> "Can we try the Strauss wNAF algorithm with precomputed tables as in the C version?"

### High-level effects

**Replaced naive double-and-add `ecmult` with a faithful port of `ecmult_impl.h`:**

New functions added to `src/ecdsa_clone.rs`:

| Function | C source | Purpose |
|---|---|---|
| `scalar_get_bits(s, pos, len)` | `ecmult_impl.h` | bit-range extraction for wNAF |
| `scalar_mul_shift_384(a, b)` | `scalar_4x64_impl.h` `scalar_mul_shift_var` | (a·b)>>384, round to nearest |
| `scalar_split_lambda(k)` | `scalar_impl.h` | GLV: r1+λ·r2=k, both ~128 bit |
| `scalar_split_128(k)` | `scalar_4x64_impl.h` | lo128 / hi128 split for G scalar |
| `ecmult_wnaf(s, w=5)` | `ecmult_impl.h` | signed windowed NAF |
| `build_odd_multiples_table(a)` | `ecmult_impl.h` | {P,3P,...,15P} via single field-inversion (Montgomery batch trick) |
| `table_get_ge / table_get_ge_lambda` | `ecmult_impl.h` | table lookup with automatic negation for negative NAF digits |
| `g_tables()` OnceLock || cache G and 2¹²⁸·G tables globally (built once per process) |

**GLV decomposition constants (from `scalar_impl.h`):**
- λ = cube root of 1 mod n: `5363AD4C...1B23BD72`
- β = cube root of 1 mod p: `7ae96a2b...719501ee`
- g1, g2, minus_b1, minus_b2: lattice basis for algorithm 3.74

**Algorithm:**
1. Split `u2` (A scalar) via GLV → `na_1 + λ·na_lam`, both ~128 bits
2. Split `u1` (G scalar) simply → `ng_1 = lo128`, `ng_128 = hi128`
3. Build affine table `{A, 3A, ..., 15A}` with Montgomery batch-inversion (1 fe_inv + 22 fe_muls)
4. Build `aux` table = `{β·Ax, β·3Ax, ...}` for the λ-twisted lookups
5. Fetch cached tables for G and 2¹²⁸·G (computed once via `OnceLock`)
6. Convert all 4 scalars to wNAF (window 5, ≤ 129 bits each)
7. Main loop: ≤ 129 doublings + ≤ 4 × 26 = 104 affine additions

**Performance (50 000 iterations, `--release`):**

| Variant | Time | vs previous |
|---|---|---|
| `ecdsa` (4×64 Solinas) | 53 µs ||
| `ecdsa_clone` naive double-and-add | 69 µs | baseline |
| `ecdsa_clone` Strauss wNAF + GLV | **45 µs** | 35% faster, 16% faster than Solinas |

**Build:** 21/21 tests pass, zero errors.

---

## 2026-03-18 — Compile-time constant evaluation: `const fn hex32 / set_b32_mod`

### Prompt
> Instead of hex32 use static constants or a const function.

### High-level effects

- `hex_nibble` and `hex32` converted to `const fn`; `hex32` rewrites the iterator loop as a `while` loop (iterators are not allowed in `const fn`). Uppercase hex digits (`A``F`) added to `hex_nibble` match arm.
- `Fe::set_b32_mod` declared `pub const fn` (pure bit ops — already const-compatible).
- Four runtime helper functions eliminated; replaced by module-level `const` items evaluated entirely at compile time:
  - `fn G() -> Ge``const G: Ge`
  - `fn beta_fe() -> Fe``const BETA: Fe`
  - `fn order_as_fe() -> Fe``const ORDER_AS_FE: Fe`
  - `fn p_minus_order() -> Fe``const P_MINUS_ORDER: Fe`
- All 14 call sites updated (`G()``G`, `beta_fe()``BETA`, etc.) across `g_tables`, `ecmult`, `ecdsa_sig_recover`, and test functions.
- **Build:** 11/11 `ecdsa_clone` tests pass, zero errors, commit `d708447`.

---

## 2026-03-19 — Bernstein-Yang `modinv64` Safegcd module

### Prompt
> Implement the modinv64 in its own module. Keep the fermat versions of fe_inv and scalar_inverse_var for comparison.

### High-level effects

**New file `src/modinv64.rs`** (~270 lines) — standalone module, no dependencies on other crate modules:

| Item | Description |
|---|---|
| `Signed62` | 256-bit integer as 5 × signed-62-bit limbs |
| `ModInfo` | Modulus in signed62 + its negative inverse mod 2^62 |
| `Trans2x2` | 2×2 transition matrix (scaled by 2^62) |
| `normalize_62` | Bring a signed62 into [0, modulus) |
| `divsteps_62_var` | Up to 62 variable-time Bernstein-Yang division steps |
| `update_de_62` | Apply transition matrix to (d, e) with modular correction via i128 accumulators |
| `update_fg_62_var` | Apply transition matrix to (f, g) for variable limb count |
| `modinv64_var` | Full variable-time modular inverse (outer loop: until g = 0) |
| `FE_MODINFO` | Compile-time modinfo for p = 2^256 − 2^32 − 977 |
| `SCALAR_MODINFO` | Compile-time modinfo for n (secp256k1 group order) |

C reference: `secp256k1-sys-0.11.0/.../modinv64_impl.h` (Peter Dettman).
Differences vs. C: Rust `i128`/`u128` replace the `secp256k1_int128` helper; `u64::trailing_zeros()` replaces `ctz64_var`; constant-time variant not ported.

**`src/ecdsa_clone.rs` additions:**
- `fe_to_signed62` / `fe_from_signed62` — repack 5×52-bit Fe ↔ 5×62-bit Signed62
- `scalar_to_signed62` / `scalar_from_signed62` — repack 4×64-bit Scalar ↔ 5×62-bit Signed62
- `fe_inv_var` — Safegcd field inverse (Fermat `fe_inv` kept for comparison)
- `scalar_inv_var` — Safegcd scalar inverse (Fermat `scalar_inverse_var` kept for comparison)
- Two new tests: `test_fe_inv_var_safegcd`, `test_scalar_inv_var_safegcd` — cross-check safegcd vs. Fermat and verify a·a⁻¹ = 1

**Build:** 13/13 `ecdsa_clone` tests pass, zero errors, commit `9b54dab`.

---

## 2026-03-19 — Wire safegcd into hot path

### Prompt
> Add modinv64 to the hot path and re-run.

### High-level effects

- Three hot-path call sites updated:
  - `gej_normalize`: `fe_inv``fe_inv_var`
  - `build_odd_multiples_table`: `fe_inv``fe_inv_var` (the single batch inversion)
  - `ecdsa_sig_recover`: `scalar_inverse_var``scalar_inv_var`
- Fermat `fe_inv` and `scalar_inverse_var` retained (used only in tests).

**Performance (50 000 iterations, `--release`):**

| Implementation | Time | vs previous |
|---|---|---|
| `ecdsa` (4×64 Solinas) | 52.11 µs ||
| `ecdsa_clone` Fermat inversion | 43.67 µs | baseline |
| `ecdsa_clone` safegcd inversion | **29.06 µs** | 33% faster, 44% faster than Solinas |

**Build:** 13/13 tests pass, commit `eadb8ef`.

---

## 2026-03-19 — Static G tables with WINDOW_G = 15

### Prompt
> Can we use static data to store the tables?

### High-level effects

- Added `WINDOW_G = 15` and `pub const TABLE_SIZE_G = 8192` constants alongside the existing `WINDOW_A = 5 / TABLE_SIZE = 8`.
- `table_get_ge` and `table_get_ge_lambda` signatures widened from `&[Ge; TABLE_SIZE]` to `&[Ge]` (slices) to support variable-size tables.
- Added `build_odd_multiples_table_vec(a, n)` (Vec-based, any size) and `pub fn build_g_tables_vec()` (computes the two WINDOW_G-sized tables for G and 2¹²⁸·G).
- New example `gen_g_tables` runs the computation once and emits `src/g_tables_generated.rs` — a 16 400-line Rust source file containing:
  - `static PRE_G_DATA: [[u64; 10]; 8192]` — odd multiples of G in 5×52 limb form
  - `static PRE_G128_DATA: [[u64; 10]; 8192]` — odd multiples of 2¹²⁸·G
  - `fn g_table_get_ge(data, n)` — inline lookup / optional y-negation
- `include!("g_tables_generated.rs")` replaces the runtime `OnceLock<(Vec<Ge>, Vec<Ge>)>`; the G-table data is now a zero-cost compile-time constant.
- `ecmult_wnaf` calls for the G scalars (`ng_1`, `ng_128`) updated from `WINDOW_A` to `WINDOW_G`; A scalars unchanged.

**Performance (50 000 iterations, `--release`):**

| Implementation | Time | vs C |
|---|---|---|
| `ecdsa_clone` safegcd + WINDOW_A=5 (OnceLock) | 29.06 µs | 1.36× |
| `ecdsa_clone` safegcd + WINDOW_G=15 (static)  | **25.49 µs** | **1.19×** |
| secp256k1 C library | 21.36 µs | 1.00× |

Addition cost for G scalars drops from ~32 additions/scalar (window 5) to ~9 additions/scalar (window 15), a 78% reduction.

**Build:** 13/13 `ecdsa_clone` tests pass.

---

## 2026-03-19 — RDTSC phase timeline

### Prompt
> Can we add rdtsc timing to various points in the code to get a timeline of execution?

### High-level effects

- Additional `pub` items exposed from `ecdsa_clone`: `WINDOW_A`, `TABLE_SIZE`, `WINDOW_G`, `BETA`, `scalar_split_lambda`, `scalar_split_128`, `build_odd_multiples_table`, `ecmult_wnaf`, `table_get_ge`, `table_get_ge_lambda`, `PRE_G_DATA`, `PRE_G128_DATA`, `g_table_get_ge`.
- New example `examples/timeline_ecdsa.rs`: calls each phase individually, samples cycle counts with `_rdtsc()`, reports median and p95 over 10 000 iterations.

**Phase breakdown (median cycles @ ~5.4 GHz, n = 10 000):**

| Phase | Cycles | % |
|---|---|---|
| §1 ge_set_xo_var (R recovery, fe_sqrt) | 8 250 | 12.9% |
| §2 scalar_inv_var (safegcd 1/r) | 1 500 | 2.3% |
| §3 scalar_mul × 2 (u1, u2) | 101 | 0.2% |
| §4 scalar_split (GLV + 128-bit) | 175 | 0.3% |
| §5 build_odd_multiples_table + aux | 6 650 | 10.4% |
| §6 ecmult_wnaf × 4 | 699 | 1.1% |
| **§7 ecmult main loop (~129 iters)** | **43 875** | **68.4%** |
| §8 ge_set_gej_var (normalise Q) | 1 575 | 2.5% |
| §9 keccak256 | 474 | 0.7% |
| **TOTAL** | **64 125** | |

The ecmult main loop (doublings + mixed-addition table lookups) dominates at 68% of total cost. Next priorities: §1 ge_set_xo_var (13%) and §5 build A table (10%).

---

## 2026-03-19 — AVX-512 batch Keccak-256 (keccak_batch)

### Prompt
> Create a new module keccak_batch with a function keccak256_batch for x86_64 only which takes eight input byte streams [&[u8]; 8] and outputs eight 256 bit hashes. Use avx512bw intrinsics to hold the state in 25 zmm registers.

### High-level effects

New module `src/keccak_batch.rs` with `pub fn keccak256_batch(inputs: [&[u8]; 8]) -> [[u8; 32]; 8]`.

**Architecture:**
- 25 ZMM registers (`[__m512i; 25]`) hold all 8 parallel Keccak-f[1600] states simultaneously: `ZMM[lane]` = `[stream7.lane, …, stream0.lane]`
- One `permute()` call advances all 8 states with a single pass through θ → ρ+π → χ → ι

**Implementation:**
- **θ**: 5 column XORs (xor5 helper), 5 D[x] = C[x−1] ^ rol(C[x+1], 1), scatter XOR into all 25 lanes
- **ρ+π**: 25 explicit `_mm512_rol_epi64` with compile-time immediate constants (mapping precomputed offline)
- **χ**: 25 `xor(b[i], andnot(b[x+1,y], b[x+2,y]))` — branchless using `_mm512_andnot_si512`
- **ι**: 1 `_mm512_xor_si512` with `_mm512_set1_epi64(RC[round])`
- **Absorb**: 17 `_mm512_set_epi64` calls load 8 little-endian u64 lanes (one per stream) into a ZMM, then XOR into state; then `permute()`
- **Squeeze**: store ZMM[0..4] to temp arrays, scatter 8-byte slices to 8 per-stream output buffers
- **Variable-length inputs**: shared complete blocks absorbed vectorially; diverging final blocks built per-stream into padded `[u8; 136]` arrays, then batch-absorbed; if block counts differ, scalar fallback via `extract_scalar_state` + `finish_scalar`
- Runtime `is_x86_feature_detected!` guard; scalar fallback on non-AVX-512 CPUs

**Benchmark (200 000 iters, 64-byte inputs, release):**

| | µs/batch | ns/hash |
|---|---|---|
| scalar × 8 | 1.495 | 186.9 |
| `keccak256_batch` × 8 | **0.242** | **30.2** |
| gain | **6.18×** | |

**Tests:** 3 new batch tests (empty, various lengths, uniform 64-byte) — all cross-checked against scalar references. 7/7 keccak tests pass.

---

## Prompt: "fix warnings"

**Commit:** `36aaee7` — *fix all compiler warnings (Rust 2024 unsafe_op_in_unsafe_fn, unused import, parens, mut)*

**Effect:** Eliminated all `cargo build --release` warnings across three files. 26/26 tests still pass.

### Root causes and fixes

| File | Warning | Fix |
|---|---|---|
| `keccak_batch.rs` | `unused import: keccak256_scalar` inside `mod avx512` | Removed from `use super::{RATE, RC, keccak256_scalar}` |
| `keccak_batch.rs` | E0133 `unsafe_op_in_unsafe_fn` (17+ intrinsic sites) | Added `#![allow(unsafe_op_in_unsafe_fn)]` at top of `mod avx512` — Rust 2024 edition now denies this by default; wrapping every individual intrinsic in its own `unsafe {}` block would be prohibitively noisy |
| `modinv64.rs:198` | Unnecessary parentheses: `f.wrapping_add(((…)))` | Removed outer double-paren layer |
| `ecdsa_clone.rs` | `c1`/`c2` (or `c0`) never read — dead assignments from the last `extract!` invocation in each accumulator chain | Added `#[allow(unused_assignments)]` to `scalar_mul_512` and `scalar_reduce_512`; the trailing write-then-discard pattern is inherent to the sliding-window accumulator algorithm |
| `ecdsa_clone.rs:1049` | `mut` not needed on `m0``m5` | Removed `mut`; variables are write-once (via `extract!` macro) |
| `ecdsa_clone.rs:1077` | `mut` not needed on `p0``p3` | Removed `mut` |
| `ecdsa_clone.rs:1427` | `mut` not needed on `rz` | Changed `let mut rz``let rz` |

---

## Prompt: "benchmark keccak256_batch against tiny_keccak"

**Commit:** `dbf0cc1` — *bench: add keccak256_batch vs tiny-keccak criterion group*

**Effect:** Extended `benches/keccak.rs` with a new `keccak256_batch` Criterion group that runs three competitors over four input sizes: `asmcrypto-batch` (our AVX-512), `tiny-keccak ×8` (8 sequential calls, industry baseline), and `asmcrypto ×8` (8 sequential scalar calls).

### Results (criterion, release, AVX-512F + AVX-512BW)

| Input size | asmcrypto-batch | tiny-keccak ×8 | asmcrypto ×8 | vs tiny-keccak | vs own scalar |
|---|---|---|---|---|---|
| 32 B | 257 ns | 3 208 ns | 1 504 ns | **12.5×** | 5.9× |
| 64 B (pubkey) | 258 ns | 3 216 ns | 1 498 ns | **12.5×** | 5.8× |
| 136 B (1 block) | 466 ns | 6 327 ns | 2 876 ns | **13.6×** | 6.2× |
| 272 B (2 blocks) | 672 ns | 9 225 ns | 4 312 ns | **13.7×** | 6.4× |

The batch speedup vs tiny-keccak grows with input length (more permutation calls per hash → more AVX-512 parallelism amortised per iteration). At 64 B (the primary Ethereum pubkey → address use case) the batch path processes 8 hashes in 258 ns — equivalent to 32 ns/hash vs 402 ns/hash for tiny-keccak.

---

## Prompt: "make a module ecdsa_batch that uses zmm registers to recover 8 addresses in parallel"

**Commit:** `1528911` — *feat: ecdsa_batch — recover 8 Ethereum addresses in parallel (AVX-512F/BW/DQ/IFMA)*

**Effect:** Created `src/ecdsa_batch.rs` with a new public function `recover_addresses_batch([&[u8;32];8], ...) -> [[u8;20];8]` that recovers 8 Ethereum addresses from 8 ECDSA signatures in one call.

### Architecture

The module operates in three phases:

1. **EC scalar multiplication (per-lane):** Each of the 8 lanes runs `scalar_mul_g(u1)` and `scalar_mul_affine(u2, R)` using the same GLV + wNAF + Shamir algorithm as `ecdsa.rs`. At this stage operations are scalar but the 8 lanes are independent and can be scheduled by the CPU out-of-order.

2. **Batch Keccak-256 (AVX-512):** After all 8 EC recoveries produce 64-byte pubkey XY buffers, a single `keccak256_batch(inputs)` call processes all 8 through the 25-ZMM vectorised permutation kernel, yielding 8 hashes simultaneously.

3. **Address extraction:** Slice bytes [12..32] from each hash → 8 × 20-byte Ethereum addresses.

### AVX-512 feature use
- `avx512f,avx512bw` — batch Keccak-256 (inherited from `keccak_batch`)
- `avx512dq``VPMULLQ` available for future ZMM field arithmetic layer
- `avx512ifma``VPMADD52LO/HI` available for future zero-overhead 52-bit mul-add

The `#[target_feature]` guard is applied to the inner `recover_addresses_avx512` function; runtime `is_x86_feature_detected!` dispatches to scalar fallback on non-AVX-512 CPUs.

### Tests (4 new, all passing)
- `test_recover_one_known_vector` — scalar kernel matches go-ethereum precompile vector
- `test_batch_all_same_vector` — all 8 batch lanes produce correct known address
- `test_batch_scalar_agreement` — batch output == 8×scalar for same inputs
- `test_batch_invalid_lane_zeroed` — invalid signature → `[0u8;20]`, valid lanes unaffected

**Total tests:** 30 (was 26).

## Session: bench ecdsa_batch vs secp256k1 C library (commit 89ca2dd)

**Prompt:** benchmark recover_addresses_batch against eight calls to the C library

**Changes:**
- `benches/ecdsa.rs`: added `bench_ecdsa_batch` criterion group (`ecdsa/recover_address_x8`)
  with three competitors: `asmcrypto-batch`, `asmcrypto-scalar-x8`, `secp256k1-x8`

**Results (release, criterion, 8-lane batch):**

| Competitor | 8-lane total | per-recovery |
|---|---|---|
| asmcrypto-batch (AVX-512 Keccak) | 456 µs | 57 µs |
| asmcrypto-scalar-x8 | 418 µs | 52 µs |
| secp256k1 C lib × 8 | 173 µs | 22 µs |

**Analysis:** The C library is ~2.6× faster than our batch path.  Current `ecdsa_batch`
only vectorises the final Keccak step; the EC scalar-mul still runs 8 independent scalar
loops.  Fully vectorising the field arithmetic over ZMM registers (8 parallel U256 limb-lanes
using VPMULLQ + VPMADD52) is the next required step to close the gap.

## Session: vectorised fp_mul_x8 using AVX-512IFMA (commit d3297af)

**Prompt:** Start with a vector version of scalar_fp_mul. Write a perf example to time it.

**New module: `src/ecdsa_batch.rs` — `pub mod x8`**

Type `U256x8`: 8 parallel secp256k1 Fp elements stored as 5 × 52-bit limbs.
Each `__m512i` limbs[k] holds bit-field [52k..52(k+1)) of all 8 values simultaneously.

Exported functions (all `pub unsafe fn`, require `avx512f,avx512ifma`):
- `load(vals: &[[u64;4];8]) -> U256x8` / `store(a: U256x8) -> [[u64;4];8]`
- `fp_add_x8`, `fp_sub_x8`, `fp_neg_x8`
- `fp_mul_x8(a, b) -> U256x8` — 50 VPMADD52 intrinsics (schoolbook 5×5)
- `fp_sq_x8(a) -> U256x8`

**Algorithm for fp_mul_x8:**
1. Schoolbook 5×5 multiply: 25 × (madd52lo + madd52hi) = 50 VPMADD52 instructions.
2. Carry propagation (k=0..8) normalising to 52-bit limbs.
3. Solinas fold: 2^256 ≡ K=2^32+977, fold multiplier 16K≈2^37 fits in 37 bits.
   - Fold t[5..8] using 2 VPMADD52 per excess limb (lo→position k-5, hi→k-4).
   - Cascade-fold t[9]'s hi residual (would land at position 5) back to positions 0,1.
   - Key bug caught: VPMADD52 only uses low 52 bits of operands; initial code silently
     dropped the hi part of t[9]*16K (≈2^34 residual). Fixed by explicit cascade fold.
4. Second carry propagation + tiny Solinas pass for carry out of limb 4.
5. Per-lane conditional subtract: 5-limb borrow chain → `_mm512_mask_blend_epi64`.

5 tests added (round-trip, fp_mul matches scalar, fp_sq, a+(-a)=0, a*1=a).

**New file: `examples/perf_fp_mul.rs`**

**Results (release build, 500k iterations, chained multiplies):**

| | Total (8 muls) | Per mul |
|---|---|---|
| 8 × scalar fp_mul | 64.1 ns | 8.0 ns |
| fp_mul_x8 (8-wide) | 31.0 ns | 3.9 ns |
| **Speedup** | **2.06×** | |

The 2× speedup over sequential scalar is from the SIMD instruction-level parallelism.
Full throughput benefit requires the wNAF scalar-mul loop to also be vectorised.

---

## Session: vectorised fn_mul_x8 / fn_inv_x8 — scalar-field mod n (commit TBD)

**Prompt:** Move to the next item — implement fn_mul_x8 and fn_inv_x8.

### fn_mul_x8 — Solinas reduction for n (secp256k1 group order)

Added `fn_mul_x8`, `fn_sq_x8`, and `fn_inv_x8` to `src/ecdsa_batch.rs` `x8` module.

**Challenge:** n's top limb is only 48 bits (`2^48−1` = `0xffffffffffff`),
so the 5-limb 52-bit representation can hold values up to 16·n after schoolbook
multiply. A single conditional subtract is insufficient; Solinas folds must tightly
control the residue size.

**Algorithm for fn_mul_x8 (schoolbook 5×5 ≡ mod n):**

Constants: `NC16 = (2^256 − n) × 16` stored in 3 52-bit limbs (`NC16_0/1/2`),
exploiting `2^260 ≡ NC16 (mod n)`. `NC_0/1/2` are 52-bit limbs of `N_C = 2^256 − n`
used in the final "top-bit fold" step.

1. **Schoolbook:** 25 × (mlo + mhi) = 50 VPMADD52 → t[0..10].
2. **Carry propagation** t[0..9].
3. **Fold 1 (aliasing-safe):** Save t[5..9] as `h5..h9`, zero t[5..7],
   then fold each `hj × NC16` into t[j..j+3]. Accumulates mhi overflows into
   fresh t[5..7] without aliasing old schoolbook values.
   - **Key bug found:** original code read t[5..9] in-place while writing t[5] from
     `t[7]×NC16_2.mhi`, creating an aliasing read-after-write error that made the
     result completely wrong (not merely off by a multiple of n).
4. **Carry propagation** t[0..7].
5. **Fold 2:** Save t[5..7] as `g5..g7` (fresh overflow from fold 1), zero t[5..7],
   fold each `gj × NC16` into t[j..j+3]. g7 < 2^21 after fold 1 + carry, so
   `mhi(g7, NC16_2) = 0`; its top write is omitted.
6. **Carry propagation** t[0..4].
7. **Top-bit fold:** Extract `q = t[4] >> 48`, mask t[4] to 48 bits,
   fold `q × N_C` into t[0..3], re-propagate t[0..4].
   (Top nibble of t[4] holds bits 256–259 of the full product; fold via N_C since
   `2^256 ≡ N_C (mod n)`.)
8. **Conditional subtract n.**

**Total:** 50 (schoolbook) + 36 (fold 1) + 18 (fold 2) + 6 (top fold) = 110 VPMADD52.

### fn_inv_x8 — Fermat inversion via addition chain for n−2

`fn_inv_x8(a)` computes `a^(n−2) mod n` using a custom addition chain.

**Block-building phase (identical to fp_sqrt_x8 up through x88):**
- `x2, x3, x6, x9, x11, x22, x44, x88` — same doubling-ladder as before.

**Additional blocks for n−2's lower 128 bits:**
- `x4, x8, x17, x39, x127` — cover run lengths in `0xBAAEDCE6AF48A03BBFD25E8CD036413F`.

**Assembly phase (corrected):**
- n−2 = `1{127} 0 [0xBAAEDCE6AF48A03BBFD25E8CD036413F]` (256 bits).
- Start from `r = x127` (representing bits 255..129 already consumed).
- `sq!(r, 1)` — advance past bit 128 = 0.
- Process bits 127..0 using run-length encoded pairs `sq!(r, k); mul!(r, xk)`.

**Bug found in assembly:** Original code had `sq!(x127, 129)` to "place ones at
bits 255..129", which added 129 extraneous squarings (making the exponent 2^129×
too large = 385 bits instead of 256 bits). Python exponent trace confirmed the
fix: remove the `sq!(x127, 129)` and start directly from `r = x127`.

**Total cost:** 411 squarings + 46 multiplications.

**Tests added:**
- `test_fn_mul_x8_matches_scalar` — 8 random fn_mul_x8 lanes vs scalar fn_mul.
- `test_fn_mul_by_one_x8` — a × 1 = a for all 8 lanes.
- `test_fn_inv_x8_matches_scalar` — 8 random fn_inv_x8 lanes vs scalar fn_inv.

All 39 tests pass (39 passed, 0 failed).


---

## 2025 — pt_double_x8: 8-lane vectorised Jacobian point doubling

### Prompt
> do pt_double_x8 next

### High-level effects

**New struct `JacPtx8`** in `src/ecdsa_batch.rs`, inside the `x8` module:
```rust
pub struct JacPtx8 { pub x: U256x8, pub y: U256x8, pub z: U256x8 }
```
Represents eight parallel Jacobian points (X : Y : Z) with Z=0 encoding the
point at infinity.

**`pt_double_x8`** — implements the **dbl-2009-l** formula for secp256k1 (a=0):
```
A = X₁²;  B = Y₁²;  C = B²
D = 2·((X₁+B)² − A − C)
E = 3·A;  F = E²
X₃ = F − 2D
Y₃ = E·(D−X₃) − 8C
Z₃ = 2·Y₁·Z₁
```
Cost: **5 field squarings + 2 field multiplications** per batch of 8 points.

Why dbl-2009-l instead of cloning the scalar `pt_double`:
- Scalar formula calls `fp_half` (branch on low bit + conditional add) —
  not yet implemented in x8 and adds complexity.
- dbl-2009-l uses only `fp_sq`, `fp_mul`, `fp_add`, `fp_sub` — all already
  available as x8 primitives.
- Infinity propagates automatically: Z₁=0 ⟹ Z₃=2·Y₁·0=0, so no per-lane
  branch is needed.

**`use super::` import list** in `x8` extended with `JacPt`, `pt_double`,
`scalar_fp_inv`, `scalar_fp_sub` (needed by the test harness).

**Test: `test_pt_double_x8_matches_scalar`**
- Loads generator G (Gx, Gy, Z=1) into all 8 lanes of a `JacPtx8`.
- Calls `pt_double_x8`, extracts per-lane Jacobian coordinates via `store`.
- Converts each lane's Jacobian result to affine using `scalar_fp_inv` /
  `scalar_fp_sq` / `scalar_fp_mul`.
- Compares against scalar `pt_double(JacPt::from_affine(Gx,Gy)).to_affine()`.
- Repeats for 4G = 2·(2·G) by doubling twice.

All 41 tests pass (41 passed, 0 failed).

---

## 2026-03-19 — EC point arithmetic and scalar multiplication (x8)

### Prompt
> do the rest of the functions required before the full vector function.

### High-level effects

All remaining EC point arithmetic functions needed to fully vectorise
`recover_addresses_avx512` are now implemented in `src/ecdsa_batch.rs`
inside the `x8` module.

**`blend_x8(mask, on_true, on_false) -> U256x8`**
Lane-wise `__mmask8` blend of two `U256x8` values (wraps `vmovdqa64 {k}`).

**`blend_jacpt_x8(mask, on_true, on_false) -> JacPtx8`**
Per-axis wrapper of `blend_x8` for full Jacobian points.

**`pt_add_mixed_x8(p: JacPtx8, qx: U256x8, qy: U256x8) -> JacPtx8`**
Mixed Jacobian + affine add (madd-2007-bl, Z₂=1).  Cost: 4 sq + 4 mul.
Infinity propagation: Z₁=0 detected by OR-ing all 5 limb lanes and
comparing to zero; those lanes return (qx, qy, 1) via `blend_jacpt_x8`.

**`pt_add_x8(p: JacPtx8, q: JacPtx8) -> JacPtx8`**
Full Jacobian + Jacobian add (add-2007-bl).  Cost: 6 sq + 7 mul.
Handles Z₁=0 → result=q; Z₂=0 → result=p via two `blend_jacpt_x8` calls.
Note: H=0 (same-point doubling) gives Z=0; callers must avoid adding a
point to itself (the GLV/wNAF loop never does this).

**`scalar_mul_g_x8(scalars: [U256; 8]) -> JacPtx8`**
8-lane GLV + width-5 wNAF scalar multiplication by the fixed generator G.
Algorithm mirrors the scalar `scalar_mul_g`:
- GLV decompose each scalar → two ~128-bit halves k₁, k₂.
- Compute wNAF(k₁) and wNAF(k₂) for each lane.
- Run 131-step doubling-and-add loop:
  - `pt_double_x8(acc)` — always; Z=0 propagates correctly for unset lanes.
  - Per-step scalar gather from G_TABLE / PHI_G_TABLE (8 lanes × 8-entry
    precomputed affine table, always in L1) into two `U256x8` pairs.
  - `pt_add_mixed_x8(acc, gx8, gy8)` for non-zero NAF digits.
  - `blend_jacpt_x8(add_mask, new_acc, acc)` to skip zero-digit lanes.

**`scalar_mul_affine_x8(scalars, px_arr, py_arr) -> JacPtx8`**
8-lane GLV + width-5 wNAF scalar mul by variable affine points.
Per-lane Jacobian wNAF tables built in scalar code (8×8 JacPt each for
GLV components), then the main loop uses `pt_add_x8` (Jacobian + Jacobian)
rather than the mixed form.

**`to_affine_x8(p: JacPtx8) -> [(U256, U256); 8]`**
Batch Jacobian → affine conversion using Montgomery's trick:
3×7=21 field multiplications + 1 `scalar_fp_inv`, vs 8 individual
inversions.

**Tests added (4 new, 45 total):**
- `test_pt_add_mixed_x8_matches_scalar` — G+3G=4G; also Z=0 sentinel lane.
- `test_pt_add_x8_matches_scalar` — G+2G=3G via Jacobian+Jacobian.
- `test_scalar_mul_g_x8_matches_scalar` — k∈{1..8}; compares to scalar reference.
- `test_scalar_mul_affine_x8_matches_scalar` — alternates G/3G base, scalars {2..9}.

All 45 tests pass (45 passed, 0 failed).

---

## 2026-03-19 — Fully vectorise recover_addresses_avx512

### Prompt
> now finish recover_address_avx512

### High-level effects

`recover_addresses_avx512` is now fully vectorised over AVX-512 ZMM registers.

**Before:** Phase 1 ran the entire ecrecover scalar-mul loop once per lane in
a `for lane in 0..8` loop.  Only the final Keccak step was batched.

**After:** Phase 1 is split into two sub-phases:

**Phase 1a (scalar per-lane):**
Keeps the branchy / data-dependent steps that cannot be re-cast as branchless
vector ops without significant complexity:
- Validate `r, s ∈ [1, n-1]`
- Lift `r_x` to secp256k1 curve point via `scalar_fp_sqrt` (requires Tonelli-Shanks)
- Choose `r_y` parity from recovery bit `v`
- Compute `r⁻¹` via `scalar_fn_inv` (multi-precision addition chain)
- Derive `u1 = −z·r⁻¹ mod n`, `u2 = s·r⁻¹ mod n`

Invalid lanes are given dummy values (`u1=1, u2=2, rx=Gx, ry=Gy`) so that all
subsequent vector ops produce valid nonzero Jacobian points (3·G).

**Phase 1b (fully vectorised over ZMM):**
```
p1 = scalar_mul_g_x8(u1s)           // 8 × u1·G via GLV+wNAF + pt_double_x8 + pt_add_mixed_x8
p2 = scalar_mul_affine_x8(u2s, R)   // 8 × u2·R via GLV+wNAF + pt_double_x8 + pt_add_x8
Q  = pt_add_x8(p1, p2)              // 8 × Jacobian addition
     [Z=0 guard: marks those lanes invalid, replaces Z with 1]
(Qx,Qy) = to_affine_x8(Q)          // batch inversion: 21 fp_mul + 1 fp_inv
```

**Phase 2 (unchanged):** `keccak256_batch` over all 8 64-byte pubkey buffers.
**Phase 3 (unchanged):** extract last 20 bytes, zero invalid lanes.

All 45 tests pass (45 passed, 0 failed).

---

## Prompt: "fix warnings"

**Commit:** `80bf23d  fix: pub(super) on scalar_mul_g_x8/affine_x8/to_affine_x8 (U256 privacy)`

The Rust compiler emitted "unreachable_pub" warnings for three functions that
were declared `pub` inside the `ecdsa_batch` module:

- `x8::scalar_mul_g_x8`
- `x8::scalar_mul_affine_x8`
- `x8::to_affine_x8`

These are marked `pub` because `recover_addresses_avx512` lives in the parent
module and calls them.  However, `U256` is private to `ecdsa_batch`, so the
real effective visibility of these items is bounded by `U256` — making `pub`
overly broad.  Changing to `pub(super)` makes the visibility match the actual
access pattern and silences the warnings.

All 45 tests still pass.

---

## Prompt: "add perf_ecdsa_batch.rs to time ecdsa batch recovery"

**Commit:** `49c06c3  perf: add perf_ecdsa_batch.rs timing example for 8-lane batch recovery`

Created `examples/perf_ecdsa_batch.rs` following the pattern of `perf_ecdsa.rs`:

- Uses the standard Ethereum ecrecover precompile test vector (known-good
  address `a94f5374fce5edbc8e2a8697c15331677e6ebf0b`).
- Sanity-checks that `recover_addresses_batch` returns the same address on all
  8 lanes as the scalar `recover_address` path.
- Times four variants with N=5000 iterations each:
  1. `recover_addresses_batch` with 8 identical lanes (same sig).
  2. 8× scalar `asmcrypto::ecdsa::recover_address` (sequential baseline).
  3. `recover_addresses_batch` with 8 varied hashes (same r,s,v).
  4. 8× scalar (varied hashes, sequential baseline).
- Reports `ns/batch`, `µs/lane`, and `krecov/s` for each variant.
- Includes `perf record` usage comment for flamegraph profiling.

Observed results on this hardware:

| Variant                              | ns/batch | µs/lane | krecov/s |
|--------------------------------------|----------|---------|----------|
| batch x8 (same sig)                  | 213 047  | 26.6    |   38     |
| scalar x8 (sequential)              | 421 730  | 52.7    |   19     |
| batch x8 (varied hashes)            | 244 864  | 30.6    |   33     |
| scalar x8 (varied hashes, seql.)    | 420 657  | 52.6    |   19     |

The vectorised batch path achieves ~2× throughput over 8 sequential scalar
calls, consistent with the expected gain from parallelising the Montgomery
field arithmetic over 8 ZMM lanes.

---

## Prompt: "Update phase 1a to vector if possible."

**Commit:** `e54a4e6  perf: vectorise Phase 1a of recover_addresses_avx512 (fp_sqrt_x8, fn_inv_x8, fn_mul_x8)`

Phase 1a of `recover_addresses_avx512` previously ran sequentially over the 8
lanes using scalar maths (`scalar_fp_sqrt`, `scalar_fn_inv`, `scalar_fn_mul`,
`scalar_fn_neg`).  All 8 operations exist in vectorised AVX-512 form in the
`x8` module; the Phase 1a loop was replaced with a single vectorised pass:

| Step | Before | After |
|---|---|---|
| rhs = r³+7 (mod p) |`scalar_fp_mul` + `scalar_fp_sq` |`fp_sq_x8` + `fp_mul_x8` + `fp_add_x8` |
| r_y = √rhs (mod p) |`scalar_fp_sqrt` (a^((p+1)/4), 253 squarings each) |`fp_sqrt_x8` over all 8 lanes |
| sqrt validity check |`scalar_fp_sq` + compare |`fp_sq_x8` + store + compare |
| parity selection | 8× conditional `scalar_fp_neg` | `fp_neg_x8` + 8-bit `parity_mask` + `blend_x8` |
| r_inv (mod n) |`scalar_fn_inv` (Fermat, ≈254 sq + 12 mul each) |`fn_inv_x8` over all 8 lanes |
| u1, u2 (mod n) |`scalar_fn_mul` + `scalar_fn_neg` |`fn_mul_x8` + `fn_neg_x8` + `fn_mul_x8` |

The range validation for r, s ∈ [1, n−1] remains scalar (8 comparisons) since
it is branchy by nature and contributes negligible time.

Results on this hardware (N=5000 batches of 8, release build):

| Variant | Before | After |
|---|---|---|
| asmcrypto batch x8 (same sig) | 221 047 ns / 36 krecov/s | **124 509 ns / 64 krecov/s** |
| asmcrypto batch x8 (varied hashes) | 244 864 ns / 33 krecov/s | **160 989 ns / 50 krecov/s** |
| secp256k1 C lib x8 | 174 434 ns / 46 krecov/s | (unchanged baseline) |

The vectorised batch path now beats the highly-tuned libsecp256k1 C library by
**~1.4× per lane** on the same-signature workload and is slightly faster on the
varied-hash workload.  45/45 tests still pass.

---

## Prompt: "Add CI, crate metadata, README, examples, doc/internals.md"

**Commit:** `caed51b  meta: CI workflow, crate metadata, README, examples, doc/internals.md`

Six files added or modified:

**`.github/workflows/ci.yml`** — GitHub Actions CI that runs on push/PR to
master/main.  Steps: rustfmt check, clippy, `cargo test --all`, and
`cargo build --examples --release`.  Uses `RUSTFLAGS="-C target-cpu=native"`
so AVX-512 fast paths are exercised on the CI runner.

**`Cargo.toml`** — Added publishing metadata: `description`, `license`
(MIT OR Apache-2.0), `repository`, `documentation`, `readme`, `keywords`,
`categories`.  Added `[[example]]` entries for `keccak_batch` and
`ecdsa_batch`.

**`README.md`** — New file.  Badges for CI, crates.io, docs.rs.  Benchmark
tables showing asmcrypto vs secp256k1 C library (ECDSA: 64 vs 46 krecov/s;
Keccak: 6× speedup).  Performance boast paragraph noting the ~1.4× per-lane
lead over libsecp256k1 and projecting further gains from hand-written
assembly for `fp_mul_x8` and the scalar multipliers.  Quick-start code
examples.  Links to `doc/internals.md`.

**`examples/keccak_batch.rs`** — Instructional example hashing 8 named
messages simultaneously, cross-checking lane 0 against scalar keccak256.

**`examples/ecdsa_batch.rs`** — Instructional example recovering 8 Ethereum
addresses from the ECDSA precompile test vector, printing tick marks next to
each lane and cross-checking against the scalar path.

**`doc/internals.md`** — Algorithm reference covering: Keccak-256 sponge
construction and the ZMM interleaving strategy (§1); secp256k1 field
arithmetic in 52-bit IFMA representation, GLV endomorphism, wNAF scalar
multiplication, batch affine conversion via Montgomery batch inversion, Phase
1a/1b vectorisation design, invalid-lane handling (§2); planned hand-written
assembly optimisations and estimated 1.5–2× further speedup (§3).


---

## 2026-03-20 — Version bump and crates.io publish

### Prompt
> update, push and publish the crate

### High-level effects
- Bumped crate version from `0.1.1` to `0.1.2` in `Cargo.toml`.
- Recorded this publish step in `doc/conversation.md`.
- Committed, pushed to `origin/master`, and published to crates.io.