Skip to content

Itoa implementations for NEON, SSE2, SSE4.1, AVX2 - #156

Open
TobiSchluter wants to merge 5 commits into
vitaut:mainfrom
TobiSchluter:itoa
Open

TobiSchluter wants to merge 5 commits into
vitaut:mainfrom
TobiSchluter:itoa

Conversation

@TobiSchluter

@TobiSchluter TobiSchluter commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Hi Victor,

I've been watching your progress, and it's been impressive. What I did, isn't so impressive and it took me a while to get around to this, but I'm finally proposing a pull request for the itoa stuff. Headlines first: branch-free (except 128bits) implementations of integer itoa (signed and unsigned <= 32bit, 64bit, and 128bit where available). Predictable runtime down to under 1ns on M5 neon. Winner on almost all benchmarks I could think of.

In order to avoid crashing with your changes, and making the review a bit easier by not interleaving the two, I've split the integer handling into a separate file. Mainly because I reuse to_bcd8 which counts the trailing zero some unnecessary stuff is present, but I think the unrelated stuff is easier to ignore this way.

What is this optimized for? The idea I had was that the tpyical, performance-aware user will convert one number, and then either copy the string somewhere else or continue inserting characters after each converted integer. Notably, they won't actually look at the emitted bytes directly, and only care about the length of the emitted string which the CPU's out-of-order implementation will fill in while the program continues. So the first thing to optimize is the length evaluation. Here I used the algorithm found in fmt with three modifications:

  1. the algorithm used by fmt for 32bit integers leaves a lot bits unused and can easily be extended in range. For purposes of u128 conversion we need the range up to 1e16, and thus this is extended as such
  2. the algorithm for u64 uses two table lookups. In fmt's variant they happen sequentially: the result of the first lookup is used for the second lookup. But at the cost of a larger table one can both lookups simultaneously. Depending on whether we are otpimizing for size or not we use one or the other.
  3. the required bitcounting wants clz but on old intel CPUs this is implemented as btz followed by xor 63. REversing the table order makes the xor unnecessary. Depending on the compile target the optimal sequence is chosen. The tables themselves are evaluated at compile time.

As for the conversions themselves, it is perhaps worth pointing out that a decimal number string always takes more than twice the space than the binary number. An 32bit number takes up to ten bytes, a 64bit number up to 20, a 128bit number up to 39. Obviously, these numbers don't map well to the registers which are 8, 16 or (AVX2) 32 bytes wide. Therefore, the numbers need to be split, and because SIMD doesn't have full-width 64bit multiplies, they actually need to be split several times. Most of the work went into identifying the optimal splitting sequences. Add to that that on Intel there are several generations of SIMD instructions, that perform differently across different machines, and you will understand that it took some time to come up with what I think are the best sequences.

AI summary of the digit-group splits in the zmij integer conversion

Every SIMD tier shares the same 16-digit kernel underneath: the GPR side does
v / 1e8 to get two 8-digit halves into two 64-bit lanes, then the vector side
splits each lane 8 → 4+4 (mul_epu32 by the 1e4 reciprocal), 4 → 2+2
(mulhi_epu16 by the 100 reciprocal), 2 → 1+1 (mulhi by the 10 reciprocal),
and a final shuffle reverses and trims leading zeros. The differences below are
about what is fed into that kernel and what is handled outside it.

u32 (up to 10 digits)

Tier Split Where
scalar and SSE2 8 + 2 GPR: v/100 and v/1e6 in parallel, feeding to_bcd8_split (SWAR 3-step on scalar, SSE2 to_bcd_4x4 on SSE2), right-shifted to trim and stored as 8 bytes; low 2 digits via the digits2 table
SSE4.1 and AVX2 4 + 4 + 2 in one vector GPR: v/100 and v/1e6; the 8-digit part is packed to two 4-digit lanes with one madd, the 2-digit remainder goes in a third lane. One to_ascii_4x4 pass and one pshufb from revalign_shuffle10, one 16-byte store
NEON 16 (full kernel) No narrow kernel. v/1e8 (0..42) packed with the remainder by one madd, full 16-digit body, one store. Signed i32 folds the - into the same store via a widened shuffle window and a sign_bias row

u64 (up to 20 digits)

Tier Split Where
scalar 4 + 8 + 8 v/1e8 and v/1e16 (parallel). Top ≤ 4 digits via divmod100 + two digits2 lookups, mid and low via to_bcd8 SWAR. Assembled right-aligned in a 48-byte buffer, one 20-byte copy at 24 − len
SSE2 16 + 4 v/1e4 in GPR; body = high if v ≥ 1e16 else v, so body < 1e16. Body pre-scaled by 10^(16−len) so the kernel output is already left-aligned, no shuffle. Low 4 digits always written after via two digits2 lookups, counted only if v ≥ 1e16
SSE4.1 generic, AVX2 generic 16 + 4 Same peel as SSE2. Body goes through the pshufb revalign_shuffle body instead of the scale trick; low 4 digits via digits2
SSE4.1 + ZMIJ_USE_U64_SPLIT12 (Zen 5 default) 12 + 8 v/1e16 and v/1e8 as two independent reciprocal multiplies, no chaining. Lanes (q16, q8 mod 1e8) go through the 16-digit kernel with an unclamped shuffle offset of 24 − c, which emits all-padding for v < 1e8. Low 8 digits via split10k + to_ascii_4x4 + a revalign_shuffle window, 8-byte store at out + klen
AVX2 + ZMIJ_USE_AVX2_U64_FP (Zen 5 default) 4 + 16 Same two independent divides. Head q16 (≤ 4 digits) via the float reciprocal kernel to_ascii4_ps on the FP ports, trimmed by the peel4_pack window, 4-byte store. Remainder lanes through itoa_body_lanes at out + hlen, whose 16-byte store overwrites the head's garbage
NEON 16 + 4 Same peel as SSE2, v/1e4 via umulh with a static_data reciprocal, compare against 1e16 − 1 from the same ldp. Low 4 via a /100 reciprocal and two digits2 lookups

u128 (only above UINT64_MAX, else the u64 path)

First peel: a 128-bit reciprocal divmod by 1e16 gives the low 16 digits and a
quotient. If the quotient is below 1e16 (19 to 32 digits total) the split is
top ≤ 16 trimmed + low 16 padded. Otherwise a second narrow divmod (a 64-bit
reciprocal for 5^16 on quotient >> 16) gives top ≤ 7 + mid 16 + low 16.

Tier 19–32 digits 33–39 digits
scalar 8+8 | 8+8, four to_bcd8, one 32-byte copy from a 64-byte buffer 8 | 8+8 | 8+8, five to_bcd8, one 40-byte copy
SSE2 16 trimmed (scaled body) + 16 padded (to_ascii16 via pshufd, no bswap) itoa_head7: one 8-digit BCD group, shifted and stored as 8 bytes, then 16 + 16 padded
SSE4.1 16 trimmed (pshufb body) + 16 padded (bswap shuffle) top through the full 16-digit body, then 16 + 16 padded, three passes
AVX2 16 + 16 in one 256-bit pass, lane 0 gets the trim shuffle, lane 1 the plain reversal, the two lanes stored at out and out + hlen itoa_top8: four base-100 blocks via float reciprocals, one SWAR /10 split, one pshufb trim, 8-byte store; then 32 padded in one 256-bit pass
NEON 16 trimmed + 16 padded (offset 0 into revalign_shuffle is the pure reversal) top through the 16-digit body, then 16 + 16 padded in a noinline itoa_body32_pad

Signed wrappers

i128 first checks whether the value fits in i64 and takes that path. For
everything else the sign is written first and the unsigned path runs, except
i32 on NEON, which has the dedicated single-store body above.

Note on AVX2

AVX2 without the Zen 5 tuning flag does not get its own u64 split. The first
branch in itoa requires either ZMIJ_USE_AVX2_U64_FP or
ZMIJ_USE_U64_SPLIT12, so a plain -mavx2 build falls to the same 16 + 4 peel
as generic SSE4.1, and only the u128 paths differ.

As for the floating point conversions, AVX2 turned out to be quite useless. I managed to find two uses though: for large u128, the low 32bit digits can be handled in one AVX2 chain. Additionally, on architectures with lots of wide multipliers (Zen5) it is possible to use the floating point fma instruction to convert 4 digits in a minimum of operations. This is used for the 4 remaining digits after a 20 digits 64-bit number is split in to 16+4 digits.

As for benchmarks, in my fork of the itoa-benchmark repo you can find a number of low-level benchmarks. The current plots are for the same code as the one in this PR. For the PR, I chose to define a smaller set of benchmarks, and I looked for some more inspiration. Champagne and Lemire used a "Twitter JSON" dataset to benchmark their AVX512 itoa code. Unfortunately, that sample only contains some 2000 numbers -- an amount the branch prediction in a modern CPU easily trivializes, so I added a Markov-chain generator that is trained on that sample but generates much longer samples. This is the twitter benchmark, each number is converted, ", " is inserted after the number and the next number is converted. For this benchmark, I added a variety that always uses u64 conversion and one that picks the conversion based on the size of the number (most are smaller than <2e32, and so it is fairly predictable). The labels are zmij and zmij32 , respectively.

Another benchmark converts i8 and u8 numbers, either as 32bit or as 64bit integers. This is a bit of a worst-case, as it can be handled trivially via a table, but still the 32bit code wins against fmt (the baseline for comparison). This also the only loss against fmt in the benchmarks.

Finally, and maybe not really applicable to the real world, I measured log-uniform distributions. Here we routinely see speed increases which reach more than an order of magnitude over fmt.

Detailed benchmark results

Note that the table show a few variants not included in the PR for the twitter benchmark: "iid" is a flat generator which picks from the set of twitter.json numbers. mk1 is a Markov-chain generator, i.e. the next number is picked on the conditional distribution given by the length of the current number. The mk2 is the default included in the repo, and it makes the number's length depend on the two preceding numbers.

All values are average nanoseconds per conversion. Our columns also contain the relative speed up compared to fmt.

AMD Ryzen 9 9950X (Zen 5), gcc 16

benchmark fmt scalar v1 v2 v3 native
json_twitter_iid_u64 6.90 5.12 (+35%) 3.92 (+76%) 2.71 (+155%) 2.60 (+165%) 2.65 (+160%)
json_twitter_iid_u64 (zmij32) 6.90 3.86 (+79%) 3.61 (+91%) 2.83 (+144%) 2.82 (+145%) 2.72 (+154%)
json_twitter_mk1_u64 5.54 5.09 (+9%) 3.91 (+42%) 2.71 (+104%) 2.59 (+114%) 2.69 (+106%)
json_twitter_mk1_u64 (zmij32) 5.54 3.87 (+43%) 3.51 (+58%) 2.80 (+98%) 2.70 (+105%) 2.69 (+106%)
json_twitter_mk2_u64 3.86 5.09 (-24%) 3.91 (-1%) 2.74 (+41%) 2.57 (+50%) 2.68 (+44%)
json_twitter_mk2_u64 (zmij32) 3.86 3.85 (+0%) 3.61 (+7%) 2.83 (+37%) 2.71 (+43%) 2.72 (+42%)
json_u8_as_u32 3.25 2.55 (+28%) 2.25 (+45%) 1.65 (+97%) 1.52 (+114%) 1.54 (+111%)
json_i8_as_i32 4.52 2.79 (+62%) 2.47 (+83%) 1.95 (+132%) 1.82 (+149%) 1.78 (+155%)
json_u8_as_u64 3.22 5.09 (-37%) 3.90 (-17%) 2.72 (+19%) 2.55 (+27%) 2.63 (+23%)
json_i8_as_i64 4.77 5.38 (-11%) 4.18 (+14%) 2.97 (+61%) 2.93 (+63%) 2.80 (+70%)
log_uniform_u32 8.61 2.17 (+297%) 1.85 (+366%) 1.35 (+536%) 1.33 (+549%) 1.16 (+640%)
log_uniform_u64 11.13 4.96 (+124%) 3.35 (+232%) 2.31 (+382%) 2.16 (+415%) 2.26 (+391%)
log_uniform_i32 8.29 2.53 (+228%) 2.12 (+291%) 1.50 (+453%) 1.44 (+476%) 1.40 (+493%)
log_uniform_i64 10.96 5.16 (+113%) 3.58 (+206%) 2.68 (+309%) 2.41 (+356%) 2.42 (+353%)
log_uniform_u128 101.95 10.67 (+856%) 6.80 (+1400%) 5.95 (+1615%) 5.60 (+1722%) 5.96 (+1612%)
log_uniform_i128 68.24 9.89 (+590%) 7.91 (+763%) 7.27 (+839%) 7.05 (+868%) 8.09 (+743%)

AMD Ryzen 9 9950X (Zen 5), clang 21

benchmark fmt scalar v1 v2 v3 native
json_twitter_iid_u64 7.51 5.20 (+44%) 3.98 (+89%) 2.79 (+170%) 2.59 (+190%) 2.56 (+194%)
json_twitter_iid_u64 (zmij32) 7.51 3.86 (+94%) 3.57 (+110%) 2.94 (+156%) 2.80 (+169%) 2.76 (+172%)
json_twitter_mk1_u64 6.00 5.25 (+14%) 3.99 (+50%) 2.81 (+114%) 2.58 (+133%) 2.55 (+135%)
json_twitter_mk1_u64 (zmij32) 6.00 3.89 (+54%) 3.55 (+69%) 2.95 (+103%) 2.83 (+112%) 2.76 (+117%)
json_twitter_mk2_u64 4.13 5.28 (-22%) 4.00 (+3%) 2.90 (+42%) 2.57 (+61%) 2.55 (+62%)
json_twitter_mk2_u64 (zmij32) 4.13 3.90 (+6%) 3.60 (+15%) 2.95 (+40%) 2.83 (+46%) 2.89 (+43%)
json_u8_as_u32 3.85 2.57 (+50%) 2.21 (+74%) 1.73 (+123%) 1.60 (+141%) 1.61 (+140%)
json_i8_as_i32 2.96 2.83 (+5%) 2.52 (+18%) 1.97 (+50%) 1.86 (+59%) 1.82 (+62%)
json_u8_as_u64 3.60 5.24 (-31%) 3.90 (-8%) 2.82 (+28%) 2.57 (+40%) 2.55 (+41%)
json_i8_as_i64 3.00 5.42 (-45%) 4.30 (-30%) 3.16 (-5%) 2.84 (+5%) 2.86 (+5%)
log_uniform_u32 8.66 2.11 (+310%) 1.76 (+391%) 1.27 (+582%) 1.13 (+667%) 1.16 (+644%)
log_uniform_u64 11.23 4.84 (+132%) 3.34 (+237%) 2.30 (+389%) 2.02 (+455%) 2.01 (+458%)
log_uniform_i32 8.14 2.44 (+234%) 2.00 (+307%) 1.48 (+448%) 1.37 (+494%) 1.35 (+501%)
log_uniform_i64 11.10 5.09 (+118%) 3.53 (+215%) 2.54 (+338%) 2.29 (+385%) 2.29 (+386%)
log_uniform_u128 78.98 11.95 (+561%) 6.16 (+1182%) 5.46 (+1348%) 5.00 (+1479%) 4.92 (+1505%)
log_uniform_i128 47.87 10.28 (+366%) 7.38 (+549%) 7.59 (+530%) 6.65 (+620%) 6.47 (+640%)

Apple M5, Apple clang 21

benchmark fmt scalar neon
json_twitter_iid_u64 5.67 3.23 (+76%) 2.26 (+151%)
json_twitter_iid_u64 (zmij32) 5.67 2.95 (+92%) 2.26 (+151%)
json_twitter_mk1_u64 4.50 3.26 (+38%) 2.26 (+99%)
json_twitter_mk1_u64 (zmij32) 4.50 2.98 (+51%) 2.30 (+95%)
json_twitter_mk2_u64 3.17 3.28 (-3%) 2.26 (+40%)
json_twitter_mk2_u64 (zmij32) 3.17 3.01 (+5%) 2.26 (+40%)
json_u8_as_u32 2.66 1.99 (+34%) 1.94 (+38%)
json_i8_as_i32 2.47 2.21 (+12%) 1.95 (+27%)
json_u8_as_u64 2.59 3.29 (-21%) 2.26 (+14%)
json_i8_as_i64 2.34 3.48 (-33%) 2.49 (-6%)
log_uniform_u32 6.70 1.66 (+303%) 0.97 (+591%)
log_uniform_u64 9.61 3.06 (+214%) 1.51 (+536%)
log_uniform_i32 6.28 1.83 (+244%) 1.16 (+444%)
log_uniform_i64 9.70 3.26 (+198%) 1.62 (+498%)
log_uniform_u128 136.13 9.39 (+1350%) 4.26 (+3098%)
log_uniform_i128 85.27 9.23 (+824%) 5.73 (+1387%)

Apple M1 (192.168.1.98), Apple clang 21

benchmark fmt scalar neon
json_twitter_iid_u64 8.77 4.95 (+77%) 4.26 (+106%)
json_twitter_iid_u64 (zmij32) 8.77 4.89 (+79%) 4.00 (+119%)
json_twitter_mk1_u64 6.95 4.99 (+39%) 4.40 (+58%)
json_twitter_mk1_u64 (zmij32) 6.95 4.95 (+41%) 4.05 (+72%)
json_twitter_mk2_u64 5.04 4.95 (+2%) 4.39 (+15%)
json_twitter_mk2_u64 (zmij32) 5.04 4.91 (+3%) 4.03 (+25%)
json_u8_as_u32 4.45 4.01 (+11%) 3.69 (+21%)
json_i8_as_i32 4.21 4.72 (-11%) 3.70 (+14%)
json_u8_as_u64 4.26 4.94 (-14%) 4.39 (-3%)
json_i8_as_i64 4.43 5.45 (-19%) 4.88 (-9%)
log_uniform_u32 10.86 2.81 (+286%) 1.61 (+573%)
log_uniform_u64 13.88 4.67 (+197%) 2.50 (+454%)
log_uniform_i32 10.20 3.09 (+230%) 1.87 (+444%)
log_uniform_i64 14.12 4.88 (+189%) 2.67 (+429%)
log_uniform_u128 223.66 14.19 (+1477%) 6.67 (+3255%)
log_uniform_i128 138.11 13.65 (+912%) 8.48 (+1528%)

Intel Core i7-1165G7 (Tiger Lake), gcc 16

benchmark fmt scalar v1 v2 v3 native
json_twitter_iid_u64 (zmij32) n/a 8.08 8.41 7.49 6.78 6.90
json_twitter_mk1_u64 (zmij32) n/a 9.94 8.36 7.03 6.63 7.11
json_twitter_mk2_u64 8.87 10.52 (-16%) 9.61 (-8%) 7.76 (+14%) 7.48 (+19%) 7.28 (+22%)
json_twitter_mk2_u64 (zmij32) 8.87 8.09 (+10%) 9.10 (-3%) 7.25 (+22%) 6.86 (+29%) 7.76 (+14%)
json_u8_as_u32 6.26 5.46 (+15%) 6.40 (-2%) 5.07 (+23%) 4.95 (+26%) 5.01 (+25%)
json_i8_as_i32 8.60 6.50 (+32%) 6.50 (+32%) 6.32 (+36%) 5.56 (+55%) 5.94 (+45%)
json_u8_as_u64 6.26 11.34 (-45%) 8.61 (-27%) 8.31 (-25%) 8.18 (-23%) 7.28 (-14%)
json_i8_as_i64 8.42 11.14 (-24%) 9.26 (-9%) 8.55 (-2%) 7.88 (+7%) 7.76 (+8%)
log_uniform_u32 16.94 5.21 (+225%) 5.32 (+219%) 4.85 (+250%) 4.01 (+323%) 3.83 (+343%)
log_uniform_u64 22.47 9.77 (+130%) 8.13 (+176%) 7.42 (+203%) 8.09 (+178%) 6.61 (+240%)
log_uniform_i32 14.73 5.51 (+167%) 5.79 (+154%) 5.12 (+188%) 4.94 (+198%) 5.29 (+179%)
log_uniform_i64 23.01 11.01 (+109%) 9.71 (+137%) 7.68 (+199%) 7.28 (+216%) 7.27 (+217%)
log_uniform_u128 243.47 21.23 (+1047%) 20.12 (+1110%) 18.99 (+1182%) 14.21 (+1613%) 16.06 (+1416%)
log_uniform_i128 136.03 21.83 (+523%) 18.11 (+651%) 18.50 (+635%) 18.00 (+656%) 18.54 (+634%)

Intel Core i7-1165G7 (Tiger Lake), clang 21

benchmark fmt scalar v1 v2 v3 native
json_twitter_iid_u64 (zmij32) n/a 8.80 8.86 7.70 8.18 7.62
json_twitter_mk1_u64 (zmij32) n/a 8.61 8.94 7.75 7.32 7.28
json_twitter_mk2_u64 9.30 11.39 (-18%) 9.62 (-3%) 8.72 (+7%) 8.41 (+11%) 10.82 (-14%)
json_twitter_mk2_u64 (zmij32) 9.30 8.48 (+10%) 8.56 (+9%) 7.42 (+25%) 7.34 (+27%) 7.64 (+22%)
json_u8_as_u32 7.76 5.81 (+33%) 6.11 (+27%) 5.07 (+53%) 4.75 (+63%) 5.08 (+53%)
json_i8_as_i32 6.29 6.79 (-7%) 6.99 (-10%) 6.41 (-2%) 5.78 (+9%) 5.55 (+13%)
json_u8_as_u64 7.64 10.20 (-25%) 9.67 (-21%) 8.15 (-6%) 9.16 (-17%) 7.82 (-2%)
json_i8_as_i64 6.42 12.41 (-48%) 10.16 (-37%) 10.31 (-38%) 9.48 (-32%) 9.61 (-33%)
log_uniform_u32 14.77 5.19 (+184%) 5.34 (+177%) 4.22 (+250%) 3.96 (+273%) 4.13 (+258%)
log_uniform_u64 22.49 10.37 (+117%) 8.43 (+167%) 8.53 (+164%) 8.04 (+180%) 7.19 (+213%)
log_uniform_i32 14.55 5.48 (+165%) 5.64 (+158%) 4.64 (+213%) 4.67 (+212%) 4.63 (+214%)
log_uniform_i64 22.01 9.81 (+124%) 8.86 (+148%) 8.53 (+158%) 7.93 (+178%) 8.84 (+149%)
log_uniform_u128 154.24 22.66 (+581%) 17.94 (+760%) 15.85 (+873%) 15.71 (+882%) 15.91 (+869%)
log_uniform_i128 107.93 21.70 (+397%) 23.12 (+367%) 18.22 (+493%) 16.23 (+565%) 15.67 (+589%)

13th Gen Intel Core i9-13900KF (Raptor Lake), gcc 16

benchmark fmt scalar v1 v2 v3 native
json_twitter_iid_u64 7.14 6.07 (+18%) 3.90 (+83%) 3.69 (+93%) 3.63 (+97%) 3.66 (+95%)
json_twitter_iid_u64 (zmij32) 7.14 3.76 (+90%) 3.68 (+94%) 3.08 (+132%) 2.99 (+139%) 3.00 (+138%)
json_twitter_mk1_u64 5.66 6.04 (-6%) 3.90 (+45%) 3.72 (+52%) 3.66 (+55%) 3.63 (+56%)
json_twitter_mk1_u64 (zmij32) 5.66 3.79 (+49%) 3.70 (+53%) 3.09 (+83%) 2.99 (+89%) 3.00 (+88%)
json_twitter_mk2_u64 3.97 6.08 (-35%) 3.90 (+2%) 3.70 (+7%) 3.65 (+9%) 3.65 (+9%)
json_twitter_mk2_u64 (zmij32) 3.97 3.84 (+3%) 3.71 (+7%) 3.08 (+29%) 2.99 (+32%) 3.02 (+31%)
json_u8_as_u32 3.26 2.56 (+28%) 2.47 (+32%) 1.92 (+70%) 1.85 (+77%) 1.85 (+76%)
json_i8_as_i32 4.55 2.80 (+62%) 2.72 (+68%) 2.14 (+112%) 2.08 (+119%) 2.07 (+120%)
json_u8_as_u64 3.39 6.01 (-44%) 3.88 (-13%) 3.70 (-8%) 3.67 (-8%) 3.63 (-7%)
json_i8_as_i64 5.05 6.40 (-21%) 4.25 (+19%) 3.95 (+28%) 3.90 (+30%) 3.93 (+29%)
log_uniform_u32 12.11 2.56 (+374%) 2.19 (+452%) 1.71 (+606%) 1.64 (+639%) 1.68 (+622%)
log_uniform_u64 16.43 5.58 (+194%) 3.51 (+368%) 3.40 (+382%) 3.31 (+397%) 3.30 (+398%)
log_uniform_i32 14.00 2.61 (+437%) 2.43 (+477%) 1.88 (+646%) 1.84 (+663%) 1.81 (+672%)
log_uniform_i64 19.08 5.85 (+226%) 3.83 (+398%) 3.64 (+424%) 3.54 (+439%) 3.57 (+435%)
log_uniform_u128 107.37 10.36 (+937%) 7.69 (+1297%) 7.11 (+1409%) 6.96 (+1443%) 6.96 (+1442%)
log_uniform_i128 71.70 10.10 (+610%) 8.55 (+739%) 8.42 (+751%) 8.27 (+767%) 8.26 (+768%)

13th Gen Intel Core i9-13900KF (Raptor Lake), clang 21

benchmark fmt scalar v1 v2 v3 native
json_twitter_iid_u64 8.13 6.15 (+32%) 3.98 (+104%) 3.64 (+124%) 3.61 (+125%) 3.62 (+125%)
json_twitter_iid_u64 (zmij32) 8.13 3.82 (+113%) 3.67 (+122%) 3.22 (+152%) 3.07 (+165%) 3.13 (+160%)
json_twitter_mk1_u64 6.56 6.16 (+6%) 3.96 (+66%) 3.68 (+78%) 3.61 (+82%) 3.64 (+80%)
json_twitter_mk1_u64 (zmij32) 6.56 3.79 (+73%) 3.68 (+78%) 3.18 (+106%) 3.09 (+112%) 3.17 (+107%)
json_twitter_mk2_u64 4.51 6.15 (-27%) 3.99 (+13%) 3.64 (+24%) 3.63 (+24%) 3.65 (+24%)
json_twitter_mk2_u64 (zmij32) 4.51 3.82 (+18%) 3.67 (+23%) 3.23 (+40%) 3.08 (+46%) 3.12 (+44%)
json_u8_as_u32 3.80 2.56 (+48%) 2.42 (+57%) 1.93 (+97%) 1.89 (+101%) 1.91 (+99%)
json_i8_as_i32 3.08 2.74 (+13%) 2.66 (+16%) 2.08 (+48%) 2.10 (+47%) 2.11 (+46%)
json_u8_as_u64 3.81 6.11 (-38%) 3.97 (-4%) 3.65 (+4%) 3.62 (+5%) 3.62 (+5%)
json_i8_as_i64 3.15 6.26 (-50%) 4.31 (-27%) 3.87 (-19%) 3.86 (-19%) 3.86 (-19%)
log_uniform_u32 8.83 2.49 (+255%) 2.10 (+320%) 1.66 (+431%) 1.67 (+430%) 1.67 (+428%)
log_uniform_u64 11.15 5.59 (+99%) 3.55 (+214%) 3.32 (+236%) 3.28 (+240%) 3.33 (+235%)
log_uniform_i32 7.83 2.50 (+213%) 2.26 (+247%) 1.82 (+331%) 1.81 (+332%) 1.81 (+333%)
log_uniform_i64 11.43 5.80 (+97%) 3.84 (+198%) 3.50 (+226%) 3.50 (+227%) 3.55 (+222%)
log_uniform_u128 77.85 10.76 (+623%) 7.51 (+937%) 6.84 (+1039%) 6.73 (+1056%) 6.73 (+1057%)
log_uniform_i128 53.63 10.20 (+426%) 7.99 (+571%) 8.23 (+551%) 7.75 (+592%) 7.75 (+592%)

I mentioned Champagne and Lemire and their AVX512 implementation above. I have not yet included AVX512 code for two reasons: 1. it is a wide field, there are so many variations of instructions available and different CPUs implement them with different characteristics, so it becomes hard to settle on one or two variants. Also, I mentioned above that CL use branches, and their data sets are not actually large enough to beat the branch predictor, so I would also have to demonstrate that I didn't tune my benchmarks just to beat them. All this seems to be too much work, especially given that distributions still don't push their defaults past v3 microarchitectures, and as I learned a few days ago, there really are users that are bound to v1 for years to come and who are dear to my heart given that I did my PhD at CERN. This actually triggered the last commit which gained almost 25% on the SSE2 path.

One aside: I didn't use fmt's format_int because it has one defect that killed its usability: the output buffer has no padding. So It is not possible to copy the result out with a copy operation of predetermined length but instead a variable-length memcpy is needed, which introduces a second hard-to-predict branch on top of the loop over digits.

I should mention that the ARM {u,i}32 implementation is a bit over-optimized: M5 decodes 8 instructions at a time, so making the code aligned and < 32 instructions allows a benchmark to process it in 4 cycles. For this reason, and only this reason there is a variant of the zeros array that includes the leading '-' (that allowed shaving off one instruction at the expense of 16bytes more table data, and the re-arranging of the static_data: ARM can encode small offsets efficiently, so it is carefully layed out to make the relevant constants (mostly for the digit_count) available near the beginning of the struct with some dirty offset tricks. Given how short this function is, it may be a better choice to actually make it inlinable. This was also the main driver to place this code in a separate file, because you had also rearranged static_data to use more efficient addressing on ARM. One could put the things integers need inside a nested struct and then get ideal addressing for both floats and integers, but it is micro-optimization churn that obscures what is actually going on, and also a continuous source of merge conflicts, whereas in this way AI can handle the folding back of the code easily.

As for how to merge it, as I said, I moved it to a separate file to be able to easier review it, but I see not fundamental reason to not merge it back into the original file.

Self-contained in zmij-int.cc under namespace details_int so its helpers
cannot collide with the floating-point implementation's, including in
unity builds; the public write overloads and buffer sizes live in the
shared zmij.h. Kernels by ISA tier: NEON (including a fused signed-i32
body), SSE4.1, AVX2 256-bit u128 chunk passes, plain SSE2 and a scalar
SWAR fallback. The Zen 5-tuned u64 splits (the float-reciprocal 4+16
peel and the 12+8 XMM-tail split) sit behind tune-gated macros: both
offload work to vector ports that only helps where those are split from
the scalar ports; on Tiger Lake the 12+8 split measured +22-31% against
the generic 16+4 peel.

A single up-front dynamic-object-size bounds check per conversion
replaces the fortified per-copy checks, which cost a call plus clamp
branches once the kernels are inlined into a frame with a visible
destination buffer.
zmij-itoa-test.cc compiles into every flag-variant test build and
includes zmij-int.cc directly, like zmij-impl-test.cc, so each variant
tests its own configuration. int-check exhaustively verifies all 32-bit
values against fmt; itoa-benchmark compares against fmt across
log-uniform sweeps and realistic digit-count streams.
Scale the value by 10^(16 - len) from a table so the 16-digit kernel's
output is already left-aligned and stores straight from the XMM register,
replacing the GPR extraction and 128-bit right shift. The 33-39-digit u128
top group goes through a new itoa_head7 (one to_bcd8 + 8-byte store) instead
of the 16-digit kernel, whose scaled form would wait on the digit count at
the end of the two-divmod chain.

On a Ryzen 9 9950X this makes the SSE2 tier faster by means of 3-6% (gcc 16)
and 8-13% (clang 21) on the u64/i64 and u128 streams, with the 33-39-digit
band at or below the old times. A fused variant folding the scale into the
/1e8 split via per-row reciprocals measured slower everywhere and was
dropped.
@TobiSchluter

TobiSchluter commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

I forgot to say two things:

  1. this also adds tests, an exhaustive test of int32 as well as a test of all boundaries (powers of two and ten as well as their neighbors, numerical limits)
  2. why is u128 not branch-free? Because the added work for the very long numbers takes similar time to a mispredicted branch, and because it is hard to imagine a scenario where the user's data would touch the whole range of possible u128. Of course, if their numbers keep straddling the 2^64 boundary in unpredictable ways, this will pessimize the performance, but that seems like a low risk especially given that it is still much faster than fmt's code in that range.

@vitaut vitaut left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Impressive work!

Overall looks good but please address inline comments. Also I would drop the exhaustive test. It is useful for float because of hard-to-test boundary conditions but I don't think it's particularly useful for integers.

Comment thread zmij-int.cc
__m128 shifted = _mm_castsi128_ps(_mm_slli_si128(_mm_castps_si128(qf), 4));
// digit_k + '0' + 2^23 = (qf_k + bias) - 10 * qf_{k-1}
__m128i dig =
_mm_castps_si128(_mm_fnmadd_ps(ten, shifted, _mm_add_ps(qf, bias)));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This need to check whether FMA is enabled.

@TobiSchluter TobiSchluter Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block is conditional on ZMIJ_USE_AVX2_U64_FP which in turn checks for ZMIJ_USE_AVX2 && (defined(__znver5__) || defined(__tune_znver5__)).

Other than that, I just learned from StackOverflow that there is a CPU out in the wild that has AVX2 but not FMA, which seems like a mistake. So, I don't think a local check is mandated, but there might be a CPU with FMA and no AVX2 (and lots of hardware multipliers) that could benefit from this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I think I get it now: if the user selects an -march that enables AVX2 but not FMA they might see an error if they also enable ZMIJ_USE_AVX2_U64_FP. Gotta love the randomness of the x86 instruction set growth! Ok, I will make sure that the checks are tight.

More as a note to self, this also applies to itoa_top8 below (which is I think the most creative thing I did in all of this, and which I failed to highlight in the PR).

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the user selects an -march that enables AVX2 but not FMA they might see an error

Yes, that was my concern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to address this concern with some infrastructure in #160

Comment thread zmij-int.cc
Comment thread test/itoa-benchmark.cc
Comment thread test/zmij-itoa-test.cc
Comment thread zmij-int.cc
// standalone with the same settings; identical redefinitions keep unity
// builds that include both files valid.

#ifndef ZMIJ_USE_SIMD

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should put common configuration macros in zmij.h to avoid duplication.

@MasterDuke17

Copy link
Copy Markdown
Contributor

Would you be able to also make a C version of this? MoarVM uses the C floating point code, and I would love to use Żmij for integers as well.

@TobiSchluter

Copy link
Copy Markdown
Contributor Author

Since my Zen5 broke, I moved to an i9 (Raptor Lake). Great to physically experience a modern Intel CPU that doesn't have AVX512. Performance is distributed a bit differently -- sign handling seems to cost more. I added the benchmark table to the first post.

One curious observation that I made because I inadvertently ran the floating-point benchmarks as well is that for ftoa on the i9 the non-SIMD variant is actually fastest.

Intermediate calculation overflowed, leading to overweighting of largest bin.
itoa buffers are one larger than utoa buffers, missing one byte in the buffer overrun check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants