Keep integers visible to LLVM and take the ASCII fast paths of text conversion (#898)

This commit is contained in:
Chris Tate
2026-10-12 16:21:02 -05:00
committed by GitHub
18 changed files with 770 additions and 54 deletions
@@ -269,13 +269,29 @@ function emitBytesGetLocal(
B.br(done);
B.startBlock(done);
const out = B.tmp();
B.line(`${out} = phi double [ ${loaded.name}, %${ok} ], [ ${f64Lit(NaN)}, %${invalid} ]`);
if (loaded.uint32 === undefined) {
B.line(`${out} = phi double [ ${loaded.name}, %${ok} ], [ ${f64Lit(NaN)}, %${invalid} ]`);
return { name: out, type: F64 };
}
// The hint is ToUint32 of the value, and ToUint32(NaN) is 0, so it holds
// for an invalid index too. Integer ranges (which would claim more) are
// never derived for NaN-coded reads.
if (loaded.uint32 === undefined) return { name: out, type: F64 };
const u32 = B.tmp();
//
// An integer element joins as an integer and a validity bit, and the
// number converts after the join. A join of the numbers, `phi [sitofp x,
// NaN]`, hides the integer from LLVM: compares of the number stay floating,
// and once `| 0` makes the NaN arm 0, InstCombine moves the conversion into
// that join (`phi [sitofp x, 0.0]`) and never folds it back. Converting
// the joined integer gives it two users (this conversion and the ToUint32
// consumers), so a compare of `x | 0` stays an integer compare.
const u32 = B.tmp(),
valid = B.tmp(),
number = B.tmp();
B.line(`${u32} = phi i32 [ ${loaded.uint32}, %${ok} ], [ 0, %${invalid} ]`);
B.line(`${valid} = phi i1 [ true, %${ok} ], [ false, %${invalid} ]`);
const signed = elem === "i8" || elem === "i16" || elem === "i32";
B.line(`${number} = ${signed ? "sitofp" : "uitofp"} i32 ${u32} to double`);
B.line(`${out} = select i1 ${valid}, double ${number}, double ${f64Lit(NaN)}`);
return { name: out, type: F64, uint32: u32 };
}
@@ -361,47 +377,46 @@ export function emitToUint32(
B.br(done);
B.startBlock(slowLabel);
const ordered = B.tmp();
const belowInf = B.tmp();
const aboveNegInf = B.tmp();
const finiteRange = B.tmp();
const finite = B.tmp();
B.line(`${ordered} = fcmp ord double ${value}, ${value}`);
B.line(`${belowInf} = fcmp olt double ${value}, ${F64_INF}`);
B.line(`${aboveNegInf} = fcmp ogt double ${value}, ${f64Lit(-Infinity)}`);
B.line(`${finiteRange} = and i1 ${belowInf}, ${aboveNegInf}`);
B.line(`${finite} = and i1 ${ordered}, ${finiteRange}`);
const finiteLabel = B.newLabel("uint32.coerce.finite");
const nonfiniteLabel = B.newLabel("uint32.coerce.nonfinite");
const slowDone = B.newLabel("uint32.coerce.slow.done");
B.condBr(finite, finiteLabel, nonfiniteLabel);
B.startBlock(finiteLabel);
// Only magnitudes above 2^53 reach this block, and every such double is
// already an integer: no truncation is needed before the reduction.
const residue = B.tmp();
// Only NaN, the infinities and magnitudes above 2^53 reach this block.
// Such a finite double is the integer mantissa * 2^shift with shift >= 1,
// so its low 32 bits are the shifted mantissa's (none once shift >= 64),
// negated for a negative value. NaN and the infinities have the maximum
// exponent, a shift of 972, and convert to 0 like ToUint32. Integer
// arithmetic only: the former `frem` became an `fmod` call that LLVM
// could hoist above the range test, onto the fast path.
const bits = B.tmp();
const exponentBits = B.tmp();
const exponent = B.tmp();
const fraction = B.tmp();
const mantissa = B.tmp();
const shift = B.tmp();
const inWord = B.tmp();
const safeShift = B.tmp();
const shifted = B.tmp();
const magnitude = B.tmp();
const low = B.tmp();
const negative = B.tmp();
const wrapped = B.tmp();
const normalized = B.tmp();
const finiteU32 = B.tmp();
B.line(`${residue} = frem double ${value}, ${f64Lit(4294967296)}`);
B.line(`${negative} = fcmp olt double ${residue}, ${f64Lit(0)}`);
B.line(`${wrapped} = fadd double ${residue}, ${f64Lit(4294967296)}`);
B.line(`${normalized} = select i1 ${negative}, double ${wrapped}, double ${residue}`);
B.line(`${finiteU32} = fptoui double ${normalized} to i32`);
B.br(slowDone);
B.startBlock(nonfiniteLabel);
B.br(slowDone);
B.startBlock(slowDone);
const negated = B.tmp();
const slowU32 = B.tmp();
B.line(`${slowU32} = phi i32 [ ${finiteU32}, %${finiteLabel} ], [ 0, %${nonfiniteLabel} ]`);
B.line(`${bits} = bitcast double ${value} to i64`);
B.line(`${exponentBits} = lshr i64 ${bits}, 52`);
B.line(`${exponent} = and i64 ${exponentBits}, 2047`);
B.line(`${fraction} = and i64 ${bits}, 4503599627370495`);
B.line(`${mantissa} = or i64 ${fraction}, 4503599627370496`);
B.line(`${shift} = sub i64 ${exponent}, 1075`);
B.line(`${inWord} = icmp ult i64 ${shift}, 64`);
B.line(`${safeShift} = select i1 ${inWord}, i64 ${shift}, i64 0`);
B.line(`${shifted} = shl i64 ${mantissa}, ${safeShift}`);
B.line(`${magnitude} = select i1 ${inWord}, i64 ${shifted}, i64 0`);
B.line(`${low} = trunc i64 ${magnitude} to i32`);
B.line(`${negative} = icmp slt i64 ${bits}, 0`);
B.line(`${negated} = sub i32 0, ${low}`);
B.line(`${slowU32} = select i1 ${negative}, i32 ${negated}, i32 ${low}`);
B.br(done);
B.startBlock(done);
const out = B.tmp();
B.line(`${out} = phi i32 [ ${fastU32}, %${fastLabel} ], [ ${slowU32}, %${slowDone} ]`);
B.line(`${out} = phi i32 [ ${fastU32}, %${fastLabel} ], [ ${slowU32}, %${slowLabel} ]`);
return out;
}
@@ -11,7 +11,13 @@ import { BYTES_ELEM_NUM, closureIdentityEqual, f64Lit } from "./common.js";
import { emitStringInputs } from "./string-lifetimes.js";
import { emitStringParts, numberPart, stringParts } from "./string-construction.js";
import { emitBorrowedInputs } from "./borrowed-inputs.js";
import { exactInteger, widenInteger, integerNumber } from "./integer-values.js";
import {
exactInteger,
int32Number,
integerNumber,
narrowInteger,
widenInteger,
} from "./integer-values.js";
import {
bitwiseIdentityOperand,
integerArithmeticRange,
@@ -233,9 +239,20 @@ export function emitOperatorExpr(
const li = exactInteger(host, l, e.left),
ri = exactInteger(host, r, e.right);
if (li && ri) {
const range = e.op === "%" ? null : integerArithmeticRange(e.op, li.range, ri.range);
if (range && withinInt32(range) && withinInt32(li.range) && withinInt32(ri.range)) {
// Int32 operands and result: 32-bit arithmetic that cannot
// overflow. The nsw flag survives where a truncated 64-bit
// result would lose it, so LLVM can widen loop counters such
// as `i = (i + 1) | 0` into 64-bit address arithmetic.
const result = B.tmp();
B.line(
`${result} = ${e.op === "+" ? "add" : e.op === "-" ? "sub" : "mul"} nsw i32 ${narrowInteger(host, li)}, ${narrowInteger(host, ri)}`,
);
return int32Number(host, result, range, e.type);
}
const left = widenInteger(host, li),
right = widenInteger(host, ri);
const range = e.op === "%" ? null : integerArithmeticRange(e.op, li.range, ri.range);
if (range) {
const result = B.tmp();
B.line(
@@ -40,6 +40,12 @@ export function exactInteger(
if (value.uint32 !== undefined && range.min >= -2147483648 && range.max <= 2147483647) {
return { name: value.uint32, type: "i32", signed: true, range };
}
if (range.min >= -2147483648 && range.max <= 2147483647) {
// An int32 conversion lets LLVM fold it with an int32 source's sitofp.
const name = host.B.tmp();
host.B.line(`${name} = fptosi double ${value.name} to i32`);
return { name, type: "i32", signed: true, range };
}
const name = host.B.tmp();
host.B.line(`${name} = fptosi double ${value.name} to i64`);
return { name, type: "i64", signed: true, range };
@@ -52,6 +58,26 @@ export function widenInteger(host: LlvmEmitterContext, value: LlInteger): string
return wide;
}
/** The i32 of an integer whose range is within int32. */
export function narrowInteger(host: LlvmEmitterContext, value: LlInteger): string {
if (value.type === "i32") return value.name;
const narrow = host.B.tmp();
host.B.line(`${narrow} = trunc i64 ${value.name} to i32`);
return narrow;
}
/** `integerNumber` for an i32 whose range is within int32. */
export function int32Number(
host: LlvmEmitterContext,
name: string,
range: IntegerRange,
type: IrType,
): LlValue {
const number = host.B.tmp();
host.B.line(`${number} = sitofp i32 ${name} to double`);
return { name: number, type, uint32: name, integer: { name, type: "i32", signed: true, range } };
}
export function integerNumber(
host: LlvmEmitterContext,
name: string,
@@ -60,7 +86,18 @@ export function integerNumber(
): LlValue {
const number = host.B.tmp();
const uint32 = host.B.tmp();
host.B.line(`${number} = sitofp i64 ${name} to double`);
host.B.line(`${uint32} = trunc i64 ${name} to i32`);
// A proven int32 converts from its low half. The value is the same, but
// LLVM folds `fptosi (sitofp i32 x)` back to x, also through the phis of
// loop-carried locals (whose other incoming values are int32 too, such as
// `x | 0`), while a 64-bit source is not known to convert exactly. So a
// counter written `i = (i + 1) | 0` stays an integer instead of taking a
// double round trip on every iteration.
const int32 = range.min >= -2147483648 && range.max <= 2147483647;
host.B.line(
int32
? `${number} = sitofp i32 ${uint32} to double`
: `${number} = sitofp i64 ${name} to double`,
);
return { name: number, type, uint32, integer: { name, type: "i64", signed: true, range } };
}
@@ -6,7 +6,14 @@ import { byteNumberAccess } from "../../ir/byte-numbers.js";
* consumers in loops can reuse it. The number remains authoritative for floating
* arithmetic, signed zero, returns and zero-iteration loops. Only explicit
* local stores maintain these views; captures and implicit binders stay on
* the ordinary path. No range or integer assumption is made about inputs. */
* the ordinary path. No range or integer assumption is made about inputs.
*
* A declared local that only ever holds bitwise results (`c = units[i] | 0`)
* also gets a view when a loop compares it: its stores already have the
* integer, and compares of the view stay integer compares. Without it each
* compare converts the stored number back, and LLVM moves that number's
* conversion into the join of a NaN-coded typed-array read (`phi [sitofp x,
* 0.0]`), where nothing folds it back to the integer. */
export function findIntegerViews(fn: IrFunction): Set<string> {
const wanted = new Set<string>();
if (fn.async || fn.generator) return wanted;
@@ -19,7 +26,19 @@ export function findIntegerViews(fn: IrFunction): Set<string> {
if (loopDepth > 0 && expr.kind === "varRef" && expr.type.kind === "f64")
wanted.add(expr.localId);
};
const declared = new Set<string>();
const compared = new Set<string>();
const nonBitwise = new Set<string>(fn.params.map((p) => p.localId));
const stored = (localId: string, value: IrExpr | null | undefined): void => {
if (!value || !bitwiseValue(value)) nonBitwise.add(localId);
};
const expr = (node: IrExpr): boolean => {
if (node.kind === "assignExpr") stored(node.localId, node.value);
else if (node.kind === "incDec") nonBitwise.add(node.localId);
else if (loopDepth > 0 && node.kind === "bin" && COMPARES.has(node.op)) {
for (const side of [node.left, node.right])
if (side.kind === "varRef" && side.type.kind === "f64") compared.add(side.localId);
}
if (
node.kind === "bin" &&
(node.op === "&" ||
@@ -48,7 +67,11 @@ export function findIntegerViews(fn: IrFunction): Set<string> {
node.kind === "while" ||
node.kind === "doWhile";
if (loop) loopDepth++;
if (node.kind === "varDecl") initialized.add(node.localId);
if (node.kind === "varDecl") {
initialized.add(node.localId);
declared.add(node.localId);
stored(node.localId, node.init);
} else if (node.kind === "assign") stored(node.localId, node.value);
if (node.kind === "forOf") excluded.add(node.localId);
// Canonical induction is maintained separately by the loop emitter.
if (node.kind === "for" && node.init?.kind === "varDecl") excluded.add(node.init.localId);
@@ -67,6 +90,7 @@ export function findIntegerViews(fn: IrFunction): Set<string> {
// A conversion observed only after a floating loop must not add work to
// each iteration. Expression results still carry their existing views.
fn.body.every(stmt);
for (const id of compared) if (declared.has(id) && !nonBitwise.has(id)) wanted.add(id);
const eligible = new Set(
fn.locals
.filter(
@@ -82,3 +106,20 @@ export function findIntegerViews(fn: IrFunction): Set<string> {
for (const id of wanted) if (!eligible.has(id)) wanted.delete(id);
return wanted;
}
const COMPARES = new Set(["===", "!==", "<", "<=", ">", ">="]);
/** Whether a value is always a bitwise result, whose ToUint32 view is free. */
function bitwiseValue(value: IrExpr): boolean {
if (value.kind === "bin")
return (
value.op === "&" ||
value.op === "|" ||
value.op === "^" ||
value.op === "<<" ||
value.op === ">>" ||
value.op === ">>>"
);
if (value.kind === "unary") return value.op === "~";
return value.kind === "libCall" && (value.fn === "math.imul" || value.fn === "math.clz32");
}
@@ -37,8 +37,11 @@ console.log(safe(0.5));
new RegExp(`define internal [^\\n]+ @sc_f_${name}\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`),
)![1]!;
expect(llBody("safe")).toContain(" = add nsw i64 ");
// Only the arbitrary input's slow ToUint32 path still adds doubles.
expect(llBody("safe").match(/ = fadd double /g)).toHaveLength(1);
// The arbitrary input's slow ToUint32 path is integer arithmetic: no
// double additions and no `frem` (an `fmod` call LLVM could hoist).
expect(llBody("safe")).toContain("uint32.coerce.slow");
expect(llBody("safe")).not.toContain(" = fadd double ");
expect(llBody("safe")).not.toContain("frem");
expect(llBody("unknown")).toContain("uint32.coerce.slow");
expect(llBody("unknown")).toContain(" = fadd double ");
expect(llBody("negativeZero")).toContain(" = fadd double ");
@@ -0,0 +1,76 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../src/index.js";
import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
test("int32 counters and typed-array reads keep integers visible to LLVM", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-integer-reads-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(
entry,
`
class Text {
pos: number;
end: number;
units: Uint16Array;
values: Float64Array;
constructor(n: number) {
this.units = new Uint16Array(n);
this.values = new Float64Array(n);
this.pos = 0;
this.end = n | 0;
}
skip(): number {
const units = this.units;
const end = this.end;
let pos = this.pos;
while (pos < end) {
const c = units[pos] | 0;
if (c === 42 || c > 127) break;
pos = (pos + 1) | 0;
}
this.pos = pos;
return pos;
}
sum(at: number): number {
return this.values[at] + this.units[at];
}
}
const text = new Text(8);
console.log(text.skip(), text.sum(3), text.sum(9));
`,
);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const mod = deserializeModule(await readFile(outPath, "utf8"));
const ll = emitLlvmModule(mod);
const body = (name: string): string =>
ll.match(
new RegExp(`define internal [^\\n]+ @sc_bf_${name}\\([^\\n]*\\) #0 \\{([\\s\\S]*?)\\n\\}`),
)![1]!;
const skip = body("_x25_Text_skip");
// The counter's increment is 32-bit arithmetic that cannot overflow and
// converts from i32, so LLVM folds the double round trip of the local.
expect(skip).toMatch(/ = add nsw i32 /);
expect(skip).not.toMatch(/ = sitofp i64 /);
// The read joins its integer and a validity bit; the number converts after the join.
expect(skip).toMatch(
/ = phi i32 \[ %\w+, %bytes\.read\.ok\d+ \], \[ 0, %bytes\.read\.invalid\d+ \]/,
);
expect(skip).toMatch(
/ = phi i1 \[ true, %bytes\.read\.ok\d+ \], \[ false, %bytes\.read\.invalid\d+ \]/,
);
expect(skip).not.toMatch(/ = phi double \[ %\w+, %bytes\.read\.ok/);
// A floating element has no integer view and keeps the joined number.
const sum = body("_x25_Text_sum");
expect(sum).toMatch(/ = phi double \[ %\w+, %bytes\.read\.ok\d+ \], \[ 0x7FF8000000000000/);
expect(sum).toMatch(/ = phi i1 \[ true, %bytes\.read\.ok/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -46,7 +46,7 @@ console.log(mix(-0, new Uint32Array(3)), floating(1.75, false), observedAfter(1.
const mix = body(ll, "mix");
expect(mix).toContain("integer view state");
// The original input is converted once, before entering the loop.
expect(mix.match(/^uint32\.coerce\.slow\.[^\n]*:/gm)).toHaveLength(1);
expect(mix.match(/^uint32\.coerce\.slow\d+:/gm)).toHaveLength(1);
const loopStart = mix.search(/\nloop\.b[._\d]/);
expect(loopStart).toBeGreaterThan(0);
const loop = mix.slice(loopStart);
@@ -35,8 +35,9 @@ console.log(remainder(5, 2.5), signed(-7.5), maybeZero(7.5, 2.5));
const fn = (name: string): string =>
new RegExp(`^define internal [^\\n]*@sc_[bf]+_${name}\\([^]*?^}`, "m").exec(ll)![0];
// Four source remainders keep a floating fallback behind a runtime
// integer check; three ToUint32 coercion fallbacks stay floating.
expect(ll.match(/ = frem double /g)).toHaveLength(7);
// integer check; ToUint32 coercion fallbacks use integer arithmetic.
expect(ll.match(/ = frem double /g)).toHaveLength(4);
expect(ll).toContain("uint32.coerce.slow");
expect(ll.match(/ = srem i64 /g)).toHaveLength(5);
expect(ll).toMatch(/ = select i1 [^\n]+double [^\n]+double /);
// Unproven operands: both round-trip checks, the 2^53 dividend bound,
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4730-touint32-large-magnitudes.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4731-string-mixed-ascii-slices.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4732-buffer-write-utf16le-runs.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4733-typed-array-read-compares.ts"
],
"diags": []
}
+26
View File
@@ -2738,6 +2738,32 @@ double scr_bytes_write_str(ScrBytes *b, const ScrStr *s, const ScrStr *enc,
size_t i = 0, written = 0, width = utf16 ? 2 : 1;
const uint64_t high = UINT64_C(0x8080808080808080);
while (i < s->len && budget - written >= width) {
#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
/* utf16le of an ASCII run: zero-extend each byte to a little-endian
* unit, eight per step with two word stores (the generic loop below
* stores byte by byte, because the source and destination may alias
* as far as the compiler knows). */
if (utf16) {
uint8_t *d = b->data + o + written;
size_t n = 0, room = (budget - written) / 16, left = (s->len - i) / 8;
size_t steps = room < left ? room : left;
for (; n < steps; n++) {
uint64_t w;
memcpy(&w, s->data + i + 8 * n, 8);
if (w & high) break;
uint64_t lo = w & UINT64_C(0xffffffff), hi = w >> 32;
lo = (lo | (lo << 16)) & UINT64_C(0x0000ffff0000ffff);
lo = (lo | (lo << 8)) & UINT64_C(0x00ff00ff00ff00ff);
hi = (hi | (hi << 16)) & UINT64_C(0x0000ffff0000ffff);
hi = (hi | (hi << 8)) & UINT64_C(0x00ff00ff00ff00ff);
memcpy(d + 16 * n, &lo, 8);
memcpy(d + 16 * n + 8, &hi, 8);
}
i += 8 * n;
written += 16 * n;
if (n != 0) continue;
}
#endif
/* ASCII runs encode byte-for-unit: widen (or copy) eight bytes per
* step while the budget holds them all, else one code point below. */
if (s->len - i >= 8 && budget - written >= 8 * width) {
+61 -6
View File
@@ -1081,13 +1081,44 @@ static void scr_sidx_ascii_run(const ScrStr *s, ScrSidx *e) {
e->ascii_end = end;
}
/* How far past its end a forward access may grow the cached ASCII run, and
* how far beyond the access the growth looks ahead. */
#define SCR_SIDX_ASCII_REACH ((size_t)4096)
#define SCR_SIDX_ASCII_AHEAD ((size_t)256)
/* Grow the cached ASCII run forward until it covers byte `want` (plus a
* lookahead) or meets a non-ASCII byte. The run stays one contiguous ASCII
* span starting at the unit/byte pair (ascii_cu, ascii_cb), so a scan that
* reads a mostly ASCII text front to back (a scanner taking substrings at
* increasing offsets) tests each byte once instead of walking from the
* cursor and re-centring the run every 128 bytes. Only accesses up to
* SCR_SIDX_ASCII_REACH past the run try: the walk below bounds the rest. */
static bool scr_sidx_ascii_grow(const ScrStr *s, ScrSidx *e, size_t want) {
size_t end = e->ascii_end;
if (want < end || want >= s->len || want - end > SCR_SIDX_ASCII_REACH) return false;
size_t limit = s->len - want > SCR_SIDX_ASCII_AHEAD ? want + SCR_SIDX_ASCII_AHEAD : s->len;
while (limit - end >= 8) {
uint64_t word;
memcpy(&word, s->data + end, 8);
if (word & UINT64_C(0x8080808080808080)) break;
end += 8;
}
while (end < limit && (unsigned char)s->data[end] < 0x80) end++;
e->ascii_end = end;
return want < end;
}
static size_t scr_u16_to_byte_walk(const ScrStr *s, ScrSidx *e, size_t u16,
bool *mid);
/* Convert a UTF-16 index to a byte offset from the closest sparse anchor or
* hot cursor. If u16 addresses the second (low-surrogate) unit of
* an astral char, *mid is set and the returned offset is the START of that
* 4-byte sequence. u16 at or past the end returns s->len with *mid false.
* Same contract as a from-scratch scan. */
static size_t scr_u16_to_byte_c(const ScrStr *s, ScrSidx *e, size_t u16,
bool *mid) {
* Same contract as a from-scratch scan. The identity and ASCII-run cases
* inline into callers; everything else is out of line. */
static inline size_t scr_u16_to_byte_c(const ScrStr *s, ScrSidx *e, size_t u16,
bool *mid) {
if (s->ascii || e->u16len == s->len) { /* all ASCII: identity mapping */
*mid = false;
return u16 < s->len ? u16 : s->len;
@@ -1098,6 +1129,17 @@ static size_t scr_u16_to_byte_c(const ScrStr *s, ScrSidx *e, size_t u16,
*mid = false;
return e->cb;
}
return scr_u16_to_byte_walk(s, e, u16, mid);
}
static __attribute__((noinline)) size_t scr_u16_to_byte_walk(const ScrStr *s, ScrSidx *e,
size_t u16, bool *mid) {
if (u16 >= e->ascii_cu && scr_sidx_ascii_grow(s, e, e->ascii_cb + (u16 - e->ascii_cu))) {
e->cb = e->ascii_cb + (u16 - e->ascii_cu);
e->cu = u16;
*mid = false;
return e->cb;
}
scr_sidx_extend_to_u16(s, e, u16);
/* Extending a far-end lookup can just have proved identity. Do not
* materialize an index that the identity fast path will never consult. */
@@ -1151,16 +1193,29 @@ static size_t scr_u16_to_byte_c(const ScrStr *s, ScrSidx *e, size_t u16,
return cb;
}
static size_t scr_byte_to_u16_walk(const ScrStr *s, ScrSidx *e, size_t byte_off);
/* Convert a byte offset (must be a char boundary) to a UTF-16 index from
* the closest sparse anchor or hot cursor. */
static size_t scr_byte_to_u16_c(const ScrStr *s, ScrSidx *e,
size_t byte_off) {
* the closest sparse anchor or hot cursor. As above, the identity and
* ASCII-run cases inline into callers. */
static inline size_t scr_byte_to_u16_c(const ScrStr *s, ScrSidx *e,
size_t byte_off) {
if (s->ascii || e->u16len == s->len) return byte_off; /* all ASCII */
if (byte_off >= e->ascii_cb && byte_off < e->ascii_end) {
e->cu = e->ascii_cu + (byte_off - e->ascii_cb);
e->cb = byte_off;
return e->cu;
}
return scr_byte_to_u16_walk(s, e, byte_off);
}
static __attribute__((noinline)) size_t scr_byte_to_u16_walk(const ScrStr *s, ScrSidx *e,
size_t byte_off) {
if (byte_off >= e->ascii_cb && scr_sidx_ascii_grow(s, e, byte_off)) {
e->cu = e->ascii_cu + (byte_off - e->ascii_cb);
e->cb = byte_off;
return e->cu;
}
/* A byte search result may be far beyond the existing prefix. Build the
* same sparse intervals first, then choose the closest boundary anchor. */
if (byte_off > e->indexed_cb) {
@@ -0,0 +1,117 @@
// ToInt32/ToUint32 of doubles beyond 2^53, NaN and the infinities (the slow
// path of every bitwise operator and integer typed-array store), and int32
// loop counters written `i = (i + 1) | 0`, whose values convert back and
// forth between integers and doubles.
const big: number[] = [
2 ** 53 + 2,
-(2 ** 53 + 2),
2 ** 53 + 4,
2 ** 53 * 3 + 6,
2 ** 54 + 4,
-(2 ** 54 + 12),
2 ** 60 + 2 ** 33 + 2 ** 12,
-(2 ** 60 + 2 ** 31 + 2 ** 9),
2 ** 62 + 2 ** 31,
2 ** 63,
-(2 ** 63),
2 ** 63 + 2 ** 11,
2 ** 64 - 2 ** 11,
2 ** 64,
2 ** 64 + 2 ** 12,
-(2 ** 64 + 2 ** 12),
2 ** 84 + 2 ** 33,
2 ** 85 + 2 ** 33,
2 ** 1023,
1.7976931348623157e308,
-1.7976931348623157e308,
1e21,
-1e21,
123456789012345680000,
0 / 0,
1 / 0,
-1 / 0,
];
function show(label: string, value: number): string {
return `${label}=${value}`;
}
for (const x of big) {
const parts = [
show("|0", x | 0),
show(">>>0", x >>> 0),
show("~", ~x),
show("&ffff", x & 0xffff),
show("^1", x ^ 1),
show("<<1", x << 1),
show(">>1", x >> 1),
show(">>>1", x >>> 1),
show("1<<x", 1 << x),
];
console.log(String(x), parts.join(" "));
}
// Integer typed-array stores convert with ToUint32 (modulo the element width).
const i32 = new Int32Array(big.length);
const u32 = new Uint32Array(big.length);
const u16 = new Uint16Array(big.length);
const i8 = new Int8Array(big.length);
for (let i = 0; i < big.length; i++) {
const x = big[i]!;
i32[i] = x;
u32[i] = x;
u16[i] = x;
i8[i] = x;
}
console.log("Int32Array", Array.from(i32).join(","));
console.log("Uint32Array", Array.from(u32).join(","));
console.log("Uint16Array", Array.from(u16).join(","));
console.log("Int8Array", Array.from(i8).join(","));
// Values computed at run time, so no conversion folds at compile time.
let seed = 7;
function next(): number {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed;
}
let mixed = 0;
for (let k = 0; k < 200; k++) {
const scale = 2 ** (53 + (next() % 40));
const value = (next() + 1) * scale * (next() % 2 === 0 ? 1 : -1);
mixed = (mixed + (value | 0) + (value >>> 0) + (value >> 3)) | 0;
}
console.log("mixed", mixed);
// int32 counters: the loop variable converts to a double for every number use.
function sumCounters(start: number, end: number): number {
let sum = 0;
for (let i = start | 0; i < end; i = (i + 1) | 0) sum += i * 0.5;
return sum;
}
console.log("counters", sumCounters(0, 1000), sumCounters(-500, 500), sumCounters(2147483000, 2147483647));
function scanUntil(units: Uint16Array, pos: number, stop: number): number {
let i = pos | 0;
while (i < units.length && (units[i] | 0) !== stop) i = (i + 1) | 0;
return i;
}
const units = new Uint16Array(64);
for (let i = 0; i < units.length; i++) units[i] = 97 + (i % 26);
console.log("scan", scanUntil(units, 0, 122), scanUntil(units, 26, 122), scanUntil(units, 60, 0));
// A counter that wraps at 2^31: its increment is not an int32, so it stays a
// double until `| 0` wraps it.
const wrapped: number[] = [];
let w = 2147483645;
for (let n = 0; n < 6; n++) {
wrapped.push(w);
w = (w + 1) | 0;
}
console.log("wrap", wrapped.join(","));
// An index that goes negative: integer reads past either end stay undefined.
const small = new Uint16Array([10, 20, 30]);
const reads: string[] = [];
for (let i = -2; i < 5; i = (i + 1) | 0) reads.push(String(small[i]));
console.log("reads", reads.join(","));
@@ -0,0 +1,121 @@
// Mostly ASCII strings with rare non-ASCII characters (2-, 3- and 4-byte
// UTF-8, the last a surrogate pair), read the way a scanner reads a source
// text: forward substrings and character codes, searches whose results are
// UTF-16 offsets, backward reads and random jumps. Short and long strings
// (the native index keeps them in different caches) of several shapes.
const words = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa"];
const rare = ["\u00e9", "\u20ac", "\ud83d\ude00", "\u00df\u00e4", "\u4e2d"];
let seed = 12345;
function rand(n: number): number {
seed = (seed * 1103515245 + 12345) % 2147483648;
return (seed >>> 8) % n;
}
function makeText(lines: number, rareEvery: number): string {
const out: string[] = [];
for (let i = 0; i < lines; i++) {
let line = "";
const count = 3 + rand(8);
for (let j = 0; j < count; j++) {
if (j > 0) line += rand(5) === 0 ? ", " : " ";
line += words[rand(words.length)]!;
if (rareEvery > 0 && rand(rareEvery) === 0) line += rare[rand(rare.length)]!;
}
out.push(line);
}
return out.join("\n");
}
function fold(hash: number, s: string): number {
for (let i = 0; i < s.length; i++) hash = (Math.imul(hash, 31) + s.charCodeAt(i)) | 0;
return (Math.imul(hash, 17) + s.length) | 0;
}
function isWordUnit(c: number): boolean {
return (c >= 97 && c <= 122) || c > 127;
}
// Forward: extract every word with substring, as a scanner takes token values.
function forward(text: string): string {
let hash = 0;
let count = 0;
let pos = 0;
while (pos < text.length) {
const c = text.charCodeAt(pos);
if (!isWordUnit(c)) {
pos++;
continue;
}
const start = pos;
while (pos < text.length && isWordUnit(text.charCodeAt(pos))) pos++;
hash = fold(hash, text.substring(start, pos));
count++;
}
return `words=${count} hash=${hash}`;
}
// Searches: line breaks and a word, with offsets fed back into slices.
function searches(text: string): string {
let hash = 0;
let lines = 0;
for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) {
lines++;
// Native strings are UTF-8: keep surrogate pairs whole (a lone surrogate would become U+FFFD).
let from = Math.max(0, i - 6);
const unit = text.charCodeAt(from);
if (unit >= 0xdc00 && unit <= 0xdfff) from--;
hash = fold(hash, text.slice(from, i));
}
let found = 0;
for (let i = text.indexOf("theta"); i >= 0; i = text.indexOf("theta", i + 5)) {
found++;
hash = (hash + i) | 0;
}
const last = text.lastIndexOf("kappa");
return `lines=${lines} theta=${found} lastKappa=${last} hash=${hash}`;
}
// Backward reads and random jumps; a position inside a surrogate pair moves to its start.
function jumps(text: string): string {
let hash = 0;
for (let i = text.length - 1; i >= 0; i -= 7) hash = (Math.imul(hash, 3) + text.charCodeAt(i)) | 0;
for (let k = 0; k < 400; k++) {
let a = rand(text.length);
const lowA = text.charCodeAt(a);
if (lowA >= 0xdc00 && lowA <= 0xdfff) a--;
let b = Math.min(text.length, a + 1 + rand(40));
const lowB = text.charCodeAt(b);
if (b < text.length && lowB >= 0xdc00 && lowB <= 0xdfff) b++;
hash = fold(hash, text.substring(a, b));
}
return `hash=${hash}`;
}
const shapes: [string, number, number][] = [
["short ascii", 20, 0],
["short mixed", 40, 6],
["medium mixed", 900, 40],
["long sparse", 9000, 400],
["long dense", 6000, 3],
["long ascii", 7000, 0],
];
for (const [name, lines, rareEvery] of shapes) {
const text = makeText(lines, rareEvery);
console.log(name, `length=${text.length}`, forward(text), searches(text), jumps(text));
}
// A long ASCII prefix before the first non-ASCII character, read from both ends.
const prefix = "x".repeat(70000) + "\u00e9" + "y".repeat(5000) + "\ud83d\ude00" + "z".repeat(100);
console.log(
prefix.length,
prefix.indexOf("\u00e9"),
prefix.indexOf("y"),
prefix.indexOf("z"),
prefix.substring(69998, 70003),
prefix.substring(75000, 75004),
prefix.charCodeAt(75001),
prefix.charCodeAt(75002),
prefix.lastIndexOf("x"),
);
@@ -0,0 +1,61 @@
// Buffer#write with 'utf16le' (and latin1/ascii) of strings whose ASCII runs
// have every length around the eight-byte steps, mixed with non-ASCII
// characters, at offsets and byte budgets that cut runs, units and surrogate
// pairs; plus the scanner's use: a Uint16Array filled through its buffer.
function hex(bytes: Uint8Array, count: number): string {
let out = "";
for (let i = 0; i < count; i++) out += bytes[i]!.toString(16).padStart(2, "0");
return out;
}
const pieces = ["", "a", "\u00e9", "\u20ac", "\ud83d\ude00", "\u007f", "\u0080"];
const strings: string[] = [];
for (let run = 0; run <= 34; run++) {
const ascii = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGH".slice(0, run);
for (const piece of pieces) strings.push(ascii + piece + ascii.slice(0, run % 9));
}
let lines = 0;
let checksum = 0;
for (const s of strings) {
const size = 2 * s.length + 6;
const out: string[] = [];
for (const [offset, budget] of [
[0, -1],
[1, -1],
[3, 7],
[0, 16],
[2, 17],
[0, 2 * s.length - 1],
]) {
const buf = Buffer.alloc(size, 0xee);
const written =
budget < 0 ? buf.write(s, offset, "utf16le") : buf.write(s, offset, Math.min(budget, size), "utf16le");
out.push(`${written}:${hex(buf, size)}`);
}
const latin = Buffer.alloc(s.length + 2, 0xee);
const writtenLatin = latin.write(s, 1, "latin1");
const ascii = Buffer.alloc(s.length + 2, 0xee);
const writtenAscii = ascii.write(s, 0, "ascii");
const line = `${out.join(" ")} ${writtenLatin} ${hex(latin, latin.length)} ${writtenAscii} ${hex(ascii, ascii.length)}`;
for (let i = 0; i < line.length; i++) checksum = (Math.imul(checksum, 31) + line.charCodeAt(i)) | 0;
if (lines++ % 16 === 0) console.log(JSON.stringify(s), line);
}
console.log("strings", strings.length, "checksum", checksum);
// The scanner's conversion: the code units of a text, through the bytes of a Uint16Array.
function toCodeUnits(text: string): Uint16Array {
const chars = new Uint16Array(text.length + 4);
Buffer.from(chars.buffer).write(text, "utf16le");
return chars;
}
let text = "";
for (let i = 0; i < 3000; i++) text += i % 97 === 0 ? "\u00e9\u20ac" : i % 211 === 0 ? "\ud83d\ude00" : "abcdefgh"[i % 8];
const units = toCodeUnits(text);
let same = units.length === text.length + 4;
for (let i = 0; i < text.length; i++) if (units[i] !== text.charCodeAt(i)) same = false;
for (let i = text.length; i < units.length; i++) if (units[i] !== 0) same = false;
let sum = 0;
for (let i = 0; i < units.length; i++) sum = (sum * 33 + units[i]!) % 1000000007;
console.log("units", text.length, units.length, same, sum);
@@ -0,0 +1,122 @@
// Typed-array reads at unproven indices, past the end (undefined, so NaN,
// so 0 after `| 0`), and compares of bitwise locals in loops: signed and
// unsigned elements, values above 2^31, and counters that leave the array.
function scanUntil(units: Uint16Array, start: number, stop: number): number {
let pos = start | 0;
const end = (units.length + 2) | 0;
while (pos < end) {
const c = units[pos] | 0;
if (c === stop || c === 0) break;
pos = (pos + 1) | 0;
}
return pos;
}
function classify32(values: Int32Array, extra: number): string {
const out: string[] = [];
for (let i = 0; i < values.length + extra; i = (i + 1) | 0) {
const v = values[i] | 0;
const u = values[i] >>> 0;
let kind = "";
if (v < 0) kind += "neg";
else if (v === 0) kind += "zero";
else if (v <= 127) kind += "small";
else kind += "big";
if (u > 2147483647) kind += "/high";
out.push(`${i}:${kind}`);
}
return out.join(" ");
}
function classifyU32(values: Uint32Array, extra: number): string {
const out: string[] = [];
for (let i = 0; i < values.length + extra; i = (i + 1) | 0) {
const v = values[i] | 0;
const u = values[i] >>> 0;
let kind = "";
if (v < 0) kind += "neg";
else if (v === 0) kind += "zero";
else if (v <= 127) kind += "small";
else kind += "big";
if (u > 2147483647) kind += "/high";
out.push(`${i}:${kind}`);
}
return out.join(" ");
}
function classify16(values: Int16Array, extra: number): string {
const out: string[] = [];
for (let i = 0; i < values.length + extra; i = (i + 1) | 0) {
const v = values[i] | 0;
const u = values[i] >>> 0;
let kind = "";
if (v < 0) kind += "neg";
else if (v === 0) kind += "zero";
else if (v <= 127) kind += "small";
else kind += "big";
if (u > 2147483647) kind += "/high";
out.push(`${i}:${kind}`);
}
return out.join(" ");
}
function classify8(values: Int8Array, extra: number): string {
const out: string[] = [];
for (let i = 0; i < values.length + extra; i = (i + 1) | 0) {
const v = values[i] | 0;
const u = values[i] >>> 0;
let kind = "";
if (v < 0) kind += "neg";
else if (v === 0) kind += "zero";
else if (v <= 127) kind += "small";
else kind += "big";
if (u > 2147483647) kind += "/high";
out.push(`${i}:${kind}`);
}
return out.join(" ");
}
function countBetween(bytes: Uint8Array, lo: number, hi: number): number {
let n = 0;
for (let i = 0; i <= bytes.length; i = (i + 1) | 0) {
const b = bytes[i] & 255;
if (b >= lo && b <= hi) n = (n + 1) | 0;
const x = bytes[i] ^ 0;
if (x !== b) n = (n + 1000) | 0;
}
return n;
}
function mixed(units: Uint16Array): number {
let h = 0;
let i = 0;
while (i < units.length + 3) {
const c = units[i] | 0;
const d = ~units[i + 1];
if (c > 64 && d < -65) h = (Math.imul(h, 31) + c) | 0;
else if (c === 0) h = h ^ 0x5bd1e995;
i = (i + 1) | 0;
}
return h;
}
const text = "abc/*x*/def\nghi*jk\u00e9\u4e2d\uffff";
const units = new Uint16Array(text.length);
for (let i = 0; i < text.length; i++) units[i] = text.charCodeAt(i);
console.log(scanUntil(units, 0, 42), scanUntil(units, 3, 10), scanUntil(units, 0, 7), scanUntil(units, -2, 99));
console.log(scanUntil(units, 2.5, 42), scanUntil(units, units.length - 1, 42), scanUntil(units, 1e10, 42));
const i32 = new Int32Array([0, -1, 1, 127, 128, -2147483648, 2147483647, 65536]);
const u32 = new Uint32Array([0, 1, 4294967295, 2147483648, 2147483647, 300]);
const i16 = new Int16Array([-32768, 32767, -1, 0, 5]);
const i8 = new Int8Array([-128, 127, -1, 0]);
console.log(classify32(i32, 2));
console.log(classifyU32(u32, 1));
console.log(classify16(i16, 0));
console.log(classify8(i8, 3));
const bytes = new Uint8Array(64);
for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 37 + 11) & 255;
console.log(countBetween(bytes, 0, 0), countBetween(bytes, 32, 126), countBetween(bytes, 200, 255));
console.log(mixed(units), mixed(new Uint16Array(0)), mixed(new Uint16Array([65, 66, 0, 120])));