Skip to content

Commit fb526a8

Browse files
committed
Fast-paths for ASCII-only identifiers
1 parent c1ad25e commit fb526a8

2 files changed

Lines changed: 256 additions & 0 deletions

File tree

include/prism/defines.h

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,37 @@
264264
#define PRISM_UNLIKELY(x) (x)
265265
#endif
266266

267+
/**
268+
* Count trailing zero bits in a 64-bit value. Used by SWAR identifier scanning
269+
* to find the first non-matching byte in a word.
270+
*
271+
* Precondition: v must be nonzero. The result is undefined when v == 0
272+
* (matching the behavior of __builtin_ctzll and _BitScanForward64).
273+
*/
274+
#if defined(__GNUC__) || defined(__clang__)
275+
#define pm_ctzll(v) ((unsigned) __builtin_ctzll(v))
276+
#elif defined(_MSC_VER)
277+
#include <intrin.h>
278+
static inline unsigned pm_ctzll(uint64_t v) {
279+
unsigned long index;
280+
_BitScanForward64(&index, v);
281+
return (unsigned) index;
282+
}
283+
#else
284+
static inline unsigned
285+
pm_ctzll(uint64_t v) {
286+
unsigned c = 0;
287+
v &= (uint64_t) (-(int64_t) v);
288+
if (v & 0x00000000FFFFFFFFULL) c += 0; else c += 32;
289+
if (v & 0x0000FFFF0000FFFFULL) c += 0; else c += 16;
290+
if (v & 0x00FF00FF00FF00FFULL) c += 0; else c += 8;
291+
if (v & 0x0F0F0F0F0F0F0F0FULL) c += 0; else c += 4;
292+
if (v & 0x3333333333333333ULL) c += 0; else c += 2;
293+
if (v & 0x5555555555555555ULL) c += 0; else c += 1;
294+
return c;
295+
}
296+
#endif
297+
267298
/**
268299
* We use -Wimplicit-fallthrough to guard potentially unintended fall-through between cases of a switch.
269300
* Use PRISM_FALLTHROUGH to explicitly annotate cases where the fallthrough is intentional.

src/prism.c

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,6 +1777,227 @@ char_is_identifier_utf8(const uint8_t *b, ptrdiff_t n) {
17771777
}
17781778
}
17791779

1780+
/**
1781+
* Scan forward through ASCII identifier characters (a-z, A-Z, 0-9, _) using
1782+
* wide operations. Returns the number of leading ASCII identifier bytes.
1783+
* Callers must handle any remaining bytes (short tail or non-ASCII/UTF-8)
1784+
* with a byte-at-a-time loop.
1785+
*
1786+
* Up to four optimized implementations are selected at compile time, with a
1787+
* no-op fallback for unsupported platforms:
1788+
* 1. NEON — processes 16 bytes per iteration on aarch64.
1789+
* 2. SSE2 — processes 16 bytes per iteration on x86-64.
1790+
* 3. WASM SIMD — processes 16 bytes per iteration on WebAssembly.
1791+
* 4. SWAR — little-endian fallback, processes 8 bytes per iteration.
1792+
* 5. No-op — returns 0; the caller's byte-at-a-time loop handles everything.
1793+
*/
1794+
1795+
#if defined(__aarch64__) && defined(__ARM_NEON)
1796+
#include <arm_neon.h>
1797+
1798+
static inline size_t
1799+
scan_identifier_ascii(const uint8_t *start, const uint8_t *end) {
1800+
const uint8_t *cursor = start;
1801+
1802+
// Nibble-based lookup tables for classifying [a-zA-Z0-9_].
1803+
// Each high nibble is assigned a unique bit; the low nibble table
1804+
// contains the OR of bits for all high nibbles that have an
1805+
// identifier character at that low nibble position. A byte is an
1806+
// identifier character iff (low_lut[lo] & high_lut[hi]) != 0.
1807+
const uint8x16_t low_lut = (uint8x16_t) {
1808+
0x15, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F,
1809+
0x1F, 0x1F, 0x1E, 0x0A, 0x0A, 0x0A, 0x0A, 0x0E
1810+
};
1811+
const uint8x16_t high_lut = (uint8x16_t) {
1812+
0x00, 0x00, 0x00, 0x01, 0x02, 0x04, 0x08, 0x10,
1813+
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
1814+
};
1815+
const uint8x16_t mask_0f = vdupq_n_u8(0x0F);
1816+
1817+
while (cursor + 16 <= end) {
1818+
uint8x16_t v = vld1q_u8(cursor);
1819+
1820+
uint8x16_t lo_class = vqtbl1q_u8(low_lut, vandq_u8(v, mask_0f));
1821+
uint8x16_t hi_class = vqtbl1q_u8(high_lut, vshrq_n_u8(v, 4));
1822+
uint8x16_t ident = vandq_u8(lo_class, hi_class);
1823+
1824+
// Fast check: if the per-byte minimum is nonzero, every byte matched.
1825+
if (vminvq_u8(ident) != 0) {
1826+
cursor += 16;
1827+
continue;
1828+
}
1829+
1830+
// Find the first non-identifier byte (zero in ident).
1831+
uint8x16_t is_zero = vceqq_u8(ident, vdupq_n_u8(0));
1832+
uint64_t lo = vgetq_lane_u64(vreinterpretq_u64_u8(is_zero), 0);
1833+
1834+
if (lo != 0) {
1835+
cursor += pm_ctzll(lo) / 8;
1836+
} else {
1837+
uint64_t hi = vgetq_lane_u64(vreinterpretq_u64_u8(is_zero), 1);
1838+
cursor += 8 + pm_ctzll(hi) / 8;
1839+
}
1840+
1841+
return (size_t) (cursor - start);
1842+
}
1843+
1844+
return (size_t) (cursor - start);
1845+
}
1846+
1847+
#elif defined(__x86_64__) && defined(__SSE2__)
1848+
#include <emmintrin.h>
1849+
1850+
static inline size_t
1851+
scan_identifier_ascii(const uint8_t *start, const uint8_t *end) {
1852+
const uint8_t *cursor = start;
1853+
1854+
while (cursor + 16 <= end) {
1855+
__m128i v = _mm_loadu_si128((const __m128i *) cursor);
1856+
__m128i zero = _mm_setzero_si128();
1857+
1858+
// Unsigned range check via saturating subtraction:
1859+
// byte >= lo ⟺ saturate(lo - byte) == 0
1860+
// byte <= hi ⟺ saturate(byte - hi) == 0
1861+
1862+
// Fold case: OR with 0x20 maps A-Z to a-z.
1863+
__m128i lowered = _mm_or_si128(v, _mm_set1_epi8(0x20));
1864+
__m128i letter = _mm_and_si128(
1865+
_mm_cmpeq_epi8(_mm_subs_epu8(_mm_set1_epi8(0x61), lowered), zero),
1866+
_mm_cmpeq_epi8(_mm_subs_epu8(lowered, _mm_set1_epi8(0x7A)), zero));
1867+
1868+
__m128i digit = _mm_and_si128(
1869+
_mm_cmpeq_epi8(_mm_subs_epu8(_mm_set1_epi8(0x30), v), zero),
1870+
_mm_cmpeq_epi8(_mm_subs_epu8(v, _mm_set1_epi8(0x39)), zero));
1871+
1872+
__m128i underscore = _mm_cmpeq_epi8(v, _mm_set1_epi8(0x5F));
1873+
1874+
__m128i ident = _mm_or_si128(_mm_or_si128(letter, digit), underscore);
1875+
int mask = _mm_movemask_epi8(ident);
1876+
1877+
if (mask == 0xFFFF) {
1878+
cursor += 16;
1879+
continue;
1880+
}
1881+
1882+
cursor += pm_ctzll((uint64_t) (~mask & 0xFFFF));
1883+
return (size_t) (cursor - start);
1884+
}
1885+
1886+
return (size_t) (cursor - start);
1887+
}
1888+
1889+
#elif defined(__wasm_simd128__)
1890+
#include <wasm_simd128.h>
1891+
1892+
static inline size_t
1893+
scan_identifier_ascii(const uint8_t *start, const uint8_t *end) {
1894+
const uint8_t *cursor = start;
1895+
1896+
while (cursor + 16 <= end) {
1897+
v128_t v = wasm_v128_load(cursor);
1898+
1899+
// Range checks via subtract-and-unsigned-compare: (v - lo) < count
1900+
// is true iff v is in [lo, lo + count). One subtract + one compare
1901+
// per range instead of two comparisons + AND.
1902+
1903+
// Fold case: OR with 0x20 maps A-Z to a-z.
1904+
v128_t lowered = wasm_v128_or(v, wasm_u8x16_splat(0x20));
1905+
v128_t letter = wasm_u8x16_lt(
1906+
wasm_i8x16_sub(lowered, wasm_u8x16_splat(0x61)),
1907+
wasm_u8x16_splat(0x1A));
1908+
1909+
v128_t digit = wasm_u8x16_lt(
1910+
wasm_i8x16_sub(v, wasm_u8x16_splat(0x30)),
1911+
wasm_u8x16_splat(0x0A));
1912+
1913+
v128_t underscore = wasm_i8x16_eq(v, wasm_u8x16_splat(0x5F));
1914+
1915+
v128_t ident = wasm_v128_or(wasm_v128_or(letter, digit), underscore);
1916+
1917+
// Fast path: if all 16 bytes are identifier chars, advance.
1918+
if (wasm_i8x16_all_true(ident)) {
1919+
cursor += 16;
1920+
continue;
1921+
}
1922+
1923+
// Extract bitmask only on the exit path to find the first non-match.
1924+
uint32_t mask = wasm_i8x16_bitmask(ident);
1925+
cursor += pm_ctzll((uint64_t) (~mask & 0xFFFF));
1926+
return (size_t) (cursor - start);
1927+
}
1928+
1929+
return (size_t) (cursor - start);
1930+
}
1931+
1932+
// The SWAR path uses pm_ctzll to find the first non-matching byte within a
1933+
// word, which only yields the correct byte index on little-endian targets.
1934+
// We gate on a positive little-endian check so that unknown-endianness
1935+
// platforms safely fall through to the no-op fallback.
1936+
#elif defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
1937+
1938+
/**
1939+
* Portable SWAR fallback — processes 8 bytes per iteration.
1940+
*
1941+
* The byte-wise range checks avoid cross-byte borrows by pre-setting the high
1942+
* bit of each byte before subtraction: (byte | 0x80) - lo has a minimum value
1943+
* of 0x80 - 0x7F = 1, so underflow (and thus a borrow into the next byte) is
1944+
* impossible. The result has bit 7 set if and only if byte >= lo. The same
1945+
* reasoning applies to the upper-bound direction.
1946+
*/
1947+
static inline size_t
1948+
scan_identifier_ascii(const uint8_t *start, const uint8_t *end) {
1949+
static const uint64_t ones = 0x0101010101010101ULL;
1950+
static const uint64_t highs = 0x8080808080808080ULL;
1951+
const uint8_t *cursor = start;
1952+
1953+
while (cursor + 8 <= end) {
1954+
uint64_t word;
1955+
memcpy(&word, cursor, 8);
1956+
1957+
// Bail on any non-ASCII byte.
1958+
if (word & highs) break;
1959+
1960+
uint64_t digit = ((word | highs) - ones * 0x30) & ((ones * 0x39 | highs) - word) & highs;
1961+
1962+
// Fold upper- and lowercase together by forcing bit 5 (OR 0x20),
1963+
// then check the lowercase range once. A-Z maps to a-z; the
1964+
// only non-letter byte that could alias into [0x61,0x7A] is one
1965+
// whose original value was in [0x41,0x5A] — which is exactly
1966+
// the uppercase letters we want to match.
1967+
uint64_t lowered = word | (ones * 0x20);
1968+
uint64_t letter = ((lowered | highs) - ones * 0x61) & ((ones * 0x7A | highs) - lowered) & highs;
1969+
1970+
// Standard SWAR "has zero byte" idiom on (word XOR 0x5F) to find
1971+
// bytes equal to underscore. Safe from cross-byte borrows because
1972+
// the ASCII guard above ensures all bytes are < 0x80.
1973+
uint64_t xor_us = word ^ (ones * 0x5F);
1974+
uint64_t underscore = (xor_us - ones) & ~xor_us & highs;
1975+
1976+
uint64_t ident = digit | letter | underscore;
1977+
1978+
if (ident == highs) {
1979+
cursor += 8;
1980+
continue;
1981+
}
1982+
1983+
// Find the first non-identifier byte. On little-endian the first
1984+
// byte sits in the least-significant position.
1985+
uint64_t not_ident = ~ident & highs;
1986+
cursor += pm_ctzll(not_ident) / 8;
1987+
return (size_t) (cursor - start);
1988+
}
1989+
1990+
return (size_t) (cursor - start);
1991+
}
1992+
1993+
#else
1994+
1995+
// No-op fallback for big-endian or other unsupported platforms.
1996+
// The caller's byte-at-a-time loop handles everything.
1997+
#define scan_identifier_ascii(start, end) ((size_t) 0)
1998+
1999+
#endif
2000+
17802001
/**
17812002
* Like the above, this function is also used extremely frequently to lex all of
17822003
* the identifiers in a source file once the first character has been found. So
@@ -8155,6 +8376,10 @@ lex_identifier(pm_parser_t *parser, bool previous_command_start) {
81558376
current_end += width;
81568377
}
81578378
} else {
8379+
// Fast path: scan ASCII identifier bytes using wide operations.
8380+
current_end += scan_identifier_ascii(current_end, end);
8381+
8382+
// Byte-at-a-time fallback for the tail and any UTF-8 sequences.
81588383
while ((width = char_is_identifier_utf8(current_end, end - current_end)) > 0) {
81598384
current_end += width;
81608385
}

0 commit comments

Comments
 (0)