Skip to content

Commit d7b6de0

Browse files
authored
perf: speed up VLQ mappings decode (#393)
Profiling parse of a ~115KB sourcemap (samply, inline-expanded) attributed ~40% of parse time to the VLQ segment loop. Rework its internals, keeping `parse_vlq_segment_into`'s signature and all validation in `decode_mapping` untouched: - Fold the `,` / `;` delimiters into the decode table as negative sentinels: one table load per byte instead of two compares plus a load. Bytes outside the base64 alphabet map to 63, which reproduces the historical lenient behavior exactly (they already decoded as payload 31 with the continuation bit set, i.e. like '/'). - Add a single-byte fast path per value (small deltas dominate real mappings), skipping continuation/shift bookkeeping entirely. - Decode the sign-magnitude bit branchlessly. Error semantics are locked by new unit tests written against the old implementation first (error precedence, exact >5-field counts, invalid-char equivalence), and a differential run over all 142 fixture / tc39 resource files shows byte-identical tokens, re-encoded output, and error variants. Criterion vs saved baseline: parse/real_xlarge -13.6% (126.8us -> 109.7us), parse/real_large -15.3%, parse/real_medium -7.2%, smoke -12.3%. Other groups at parity.
1 parent 1423083 commit d7b6de0

1 file changed

Lines changed: 146 additions & 42 deletions

File tree

src/decode.rs

Lines changed: 146 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,35 @@ fn decode_mapping(mapping: &str, names_len: usize, sources_len: usize) -> Result
231231
#[repr(align(64))]
232232
struct Aligned64([i8; 256]);
233233

234-
#[rustfmt::skip]
235-
static B64: Aligned64 = Aligned64([ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, -1, 0, 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, -1, -1, -1, -1, -1, -1, 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, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 ]);
234+
/// VLQ decode table. Entry semantics:
235+
/// * `-1` — segment (`,`) or line (`;`) delimiter, terminates a value scan.
236+
/// * `0..=63` — base64 payload; bit 5 (value >= 32) is the VLQ continuation flag.
237+
///
238+
/// Bytes outside the base64 alphabet decode as `63`, i.e. exactly like `'/'`
239+
/// (payload 31 with the continuation bit set). This preserves the historical
240+
/// lenient behavior: invalid characters are consumed as continuation bytes and
241+
/// surface as `VlqLeftover`/`VlqOverflow` or garbage values, never a panic.
242+
static B64_DECODE: Aligned64 = build_b64_decode();
243+
244+
const fn build_b64_decode() -> Aligned64 {
245+
let mut table = [63i8; 256];
246+
let mut i = 0;
247+
while i < 26 {
248+
table[(b'A' + i) as usize] = i as i8;
249+
table[(b'a' + i) as usize] = 26 + i as i8;
250+
i += 1;
251+
}
252+
let mut i = 0;
253+
while i < 10 {
254+
table[(b'0' + i) as usize] = 52 + i as i8;
255+
i += 1;
256+
}
257+
table[b'+' as usize] = 62;
258+
table[b'/' as usize] = 63;
259+
table[b',' as usize] = -1;
260+
table[b';' as usize] = -1;
261+
Aligned64(table)
262+
}
236263

237264
/// Pick a `Vec<Token>` capacity for `decode_mapping`.
238265
///
@@ -266,59 +293,75 @@ fn estimate_token_capacity(mapping: &[u8]) -> usize {
266293
///
267294
/// Returns the number of decoded values in the segment. Values are written into
268295
/// `rv` for the first 5 fields (the maximum valid segment size). If the segment
269-
/// contains more than 5 fields, we keep counting in `rv_len` so caller can reject
270-
/// it with `BadSegmentSize`.
296+
/// contains more than 5 fields, we keep parsing and counting so the caller can
297+
/// reject it with `BadSegmentSize` carrying the true field count.
271298
fn parse_vlq_segment_into(mapping: &[u8], cursor: &mut usize, rv: &mut [i64; 5]) -> Result<usize> {
272-
let mut cur = 0i64;
273-
let mut shift = 0u32;
274299
let mut rv_len = 0usize;
275-
276-
while *cursor < mapping.len() {
277-
let c = mapping[*cursor];
278-
if c == b',' || c == b';' {
279-
break;
300+
while let Some(value) = next_vlq(mapping, cursor)? {
301+
if rv_len < rv.len() {
302+
rv[rv_len] = value;
280303
}
304+
rv_len += 1;
305+
}
306+
if rv_len == 0 {
307+
return Err(Error::VlqNoValues);
308+
}
309+
Ok(rv_len)
310+
}
281311

282-
// SAFETY: B64 is a 256-element lookup table, and c is a u8 (0-255)
283-
let enc = unsafe { i64::from(*B64.0.get_unchecked(c as usize)) };
284-
let val = enc & 0b11111;
285-
let cont = enc >> 5;
312+
/// Decode one VLQ value starting at `*cursor`.
313+
///
314+
/// Returns `Ok(None)` without consuming anything when positioned at a
315+
/// delimiter (`,` / `;`) or end of input; otherwise consumes the value's bytes.
316+
#[inline(always)]
317+
fn next_vlq(mapping: &[u8], cursor: &mut usize) -> Result<Option<i64>> {
318+
let Some(&byte) = mapping.get(*cursor) else { return Ok(None) };
319+
let first = i64::from(B64_DECODE.0[byte as usize]);
320+
if first < 0 {
321+
return Ok(None);
322+
}
323+
*cursor += 1;
324+
if first < 32 {
325+
// No continuation bit: a single-byte value, the dominant case in real
326+
// mappings (small line/column deltas).
327+
return Ok(Some(decode_sign(first)));
328+
}
286329

330+
let mut cur = first & 0b11111;
331+
let mut shift = 5u32;
332+
loop {
333+
let Some(&byte) = mapping.get(*cursor) else {
334+
// Input ended while a continuation was pending.
335+
return Err(Error::VlqLeftover);
336+
};
337+
let enc = i64::from(B64_DECODE.0[byte as usize]);
338+
if enc < 0 {
339+
// Delimiter while a continuation was pending.
340+
return Err(Error::VlqLeftover);
341+
}
342+
*cursor += 1;
287343
// VLQ shift grows by 5 bits per continuation byte. Bail out before
288-
// `val << shift` could overflow i64: the largest safe shift for a 5-bit
289-
// value is 62, and `shift` only ever takes multiples of 5, so the first
290-
// offending shift is 65.
344+
// `payload << shift` could overflow i64: the largest safe shift for a
345+
// 5-bit value is 62, and `shift` only ever takes multiples of 5, so
346+
// the first offending shift is 65.
291347
if shift > 62 {
292348
return Err(Error::VlqOverflow);
293349
}
294-
cur = cur.wrapping_add(val << shift);
295-
296-
*cursor += 1;
350+
cur |= (enc & 0b11111) << shift;
297351
shift += 5;
298-
299-
if cont == 0 {
300-
// VLQ stores sign in low bit; remaining bits are the magnitude.
301-
let sign = cur & 1;
302-
cur >>= 1;
303-
if sign != 0 {
304-
cur = -cur;
305-
}
306-
if rv_len < rv.len() {
307-
rv[rv_len] = cur;
308-
}
309-
rv_len += 1;
310-
cur = 0;
311-
shift = 0;
352+
if enc < 32 {
353+
return Ok(Some(decode_sign(cur)));
312354
}
313355
}
356+
}
314357

315-
if cur != 0 || shift != 0 {
316-
Err(Error::VlqLeftover)
317-
} else if rv_len == 0 {
318-
Err(Error::VlqNoValues)
319-
} else {
320-
Ok(rv_len)
321-
}
358+
/// VLQ stores the sign in the low bit; remaining bits are the magnitude.
359+
/// Branchless sign-magnitude decode: `-0` collapses to `0`.
360+
#[inline(always)]
361+
fn decode_sign(cur: i64) -> i64 {
362+
let mag = cur >> 1;
363+
let sign = cur & 1;
364+
(mag ^ -sign) + sign
322365
}
323366

324367
#[cfg(test)]
@@ -473,6 +516,67 @@ mod tests {
473516
assert!(matches!(SourceMap::from_json(bad_mapping), Err(Error::BadSourceReference(_))));
474517
}
475518

519+
#[test]
520+
fn decode_invalid_char_behaves_like_slash() {
521+
// Bytes outside the base64 alphabet decode as payload 31 with the
522+
// continuation bit set — exactly like '/'. A map using '!' must
523+
// therefore produce the same tokens as the same map using '/'.
524+
let make = |mappings: &str| {
525+
format!(r#"{{"version":3,"names":[],"sources":[],"mappings":"{mappings}"}}"#)
526+
};
527+
let bang_json = make("gD,!C");
528+
let slash_json = make("gD,/C");
529+
let bang = SourceMap::from_json_string(&bang_json).unwrap();
530+
let slash = SourceMap::from_json_string(&slash_json).unwrap();
531+
let tokens: Vec<Token> = bang.get_tokens().collect();
532+
assert_eq!(tokens, slash.get_tokens().collect::<Vec<Token>>());
533+
assert_eq!(tokens[0].get_dst_col(), 48);
534+
assert_eq!(tokens[1].get_dst_col(), 1);
535+
}
536+
537+
#[test]
538+
fn decode_dangling_continuation_before_delimiter_is_leftover() {
539+
// 13 continuation bytes stay under the shift-overflow bound, so ending
540+
// the segment there must surface VlqLeftover, not VlqOverflow.
541+
let mappings = format!("{},A", "g".repeat(13));
542+
let input = format!(r#"{{"version":3,"names":[],"sources":[],"mappings":"{mappings}"}}"#);
543+
let err = SourceMap::from_json_string(&input).unwrap_err();
544+
assert!(matches!(err, Error::VlqLeftover));
545+
}
546+
547+
#[test]
548+
fn decode_oversized_segments_report_exact_field_count() {
549+
for (mappings, expected) in [("AAAAAA", 6u32), ("AAAAAAA", 7u32)] {
550+
let input =
551+
format!(r#"{{"version":3,"names":[],"sources":[],"mappings":"{mappings}"}}"#);
552+
let err = SourceMap::from_json_string(&input).unwrap_err();
553+
assert!(matches!(err, Error::BadSegmentSize(n) if n == expected));
554+
}
555+
}
556+
557+
#[test]
558+
fn decode_parse_error_beats_segment_validation() {
559+
// The whole segment is VLQ-parsed before any field validation, so a
560+
// dangling continuation reports VlqLeftover even when the decoded
561+
// fields would also fail the column check ("Dg": delta -1) or the
562+
// segment-size check ("AAAAAg": 6 fields).
563+
for mappings in ["Dg", "AAAAAg"] {
564+
let input =
565+
format!(r#"{{"version":3,"names":[],"sources":[],"mappings":"{mappings}"}}"#);
566+
let err = SourceMap::from_json_string(&input).unwrap_err();
567+
assert!(matches!(err, Error::VlqLeftover), "mappings {mappings:?}: {err:?}");
568+
}
569+
}
570+
571+
#[test]
572+
fn decode_negative_column_beats_size_check() {
573+
// "DA" decodes to two fields with a negative generated column; the
574+
// column check runs before the field-count check.
575+
let input = r#"{"version":3,"names":[],"sources":[],"mappings":"DA"}"#;
576+
let err = SourceMap::from_json_string(input).unwrap_err();
577+
assert!(matches!(err, Error::BadSegmentSize(0)));
578+
}
579+
476580
#[test]
477581
fn parse_vlq_segment_empty_is_no_values() {
478582
// Directly exercise the defensive `VlqNoValues` branch: with the cursor

0 commit comments

Comments
 (0)