Skip to content

Commit ce5234a

Browse files
committed
perf(embed): write a grammar's rules positionally
Decoding the compiled grammar was 3.83 ms, the largest thing left in a cold start once the snapshot stopped copying itself. CBOR names every field in the payload and matches those names on the way back in, which a 417-rule grammar pays for in full at every startup. The rules go through postcard now, which writes them positionally. The legacy module keeps CBOR and has to: its command arguments are an untagged enum, so what a value is can only be told by looking at it, and postcard says outright it will never support that. Two sections with a length between them, since they cannot share an encoding. Decoding falls to 2.65 ms and the grammar shrinks from 229 KB to 152. Cold start is now around 7 ms, from about 40 before any of this. What is left is the untagged half and the identifiers. Every string in a decoded artifact goes through a global interner that takes a mutex and hashes, and reading one back allocates. That is the next thing, and it is a change to how names are held rather than to how they are written down.
1 parent 012785e commit ce5234a

3 files changed

Lines changed: 61 additions & 21 deletions

File tree

crates/zynml/examples/decode_profile.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ fn main() {
3636
}
3737
let with_prelude = t.elapsed().as_secs_f64() * 1000.0 / iters as f64;
3838

39+
// The grammar is the other half of what installing a language reads.
40+
let snapshot = zyntax_embed::Snapshot::load(bytes).expect("load");
41+
let grammar_bytes = snapshot.grammar_bytes().to_vec();
42+
let t = Instant::now();
43+
for _ in 0..iters {
44+
let grammar =
45+
zyntax_embed::LanguageGrammar::from_compiled_bytes(&grammar_bytes).expect("grammar");
46+
std::hint::black_box(grammar.name().len());
47+
}
48+
eprintln!(
49+
"grammar decode {:.2} ms ({} KB)",
50+
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
51+
grammar_bytes.len() / 1024
52+
);
53+
3954
eprintln!(
4055
"load only {load:.2} ms\n\
4156
load + prelude {with_prelude:.2} ms\n\

crates/zyntax_embed/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ log = "0.4"
2828
serde = { version = "1.0", features = ["derive"] }
2929
serde_json = "1.0"
3030
ciborium = "0.2"
31+
# Positional binary encoding for the grammar artifact. CBOR names every
32+
# field in the payload and matches those names on the way back in, which
33+
# a 417-rule grammar pays for on every startup.
34+
postcard = { version = "1.0", features = ["alloc"] }
3135
indexmap = "2.0"
3236

3337
# Dynamic grammar parsing at runtime

crates/zyntax_embed/src/grammar.rs

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,10 @@ pub struct LanguageGrammar {
113113
}
114114

115115
const COMPILED_GRAMMAR_MAGIC: &[u8; 4] = b"ZGRM";
116-
const COMPILED_GRAMMAR_SCHEMA: u32 = 1;
116+
const COMPILED_GRAMMAR_SCHEMA: u32 = 2;
117117
const COMPILED_GRAMMAR_HEADER_LEN: usize =
118118
COMPILED_GRAMMAR_MAGIC.len() + std::mem::size_of::<u32>();
119119

120-
#[derive(Serialize, Deserialize)]
121-
struct CompiledGrammarPayload {
122-
module: ZpegModule,
123-
grammar2: Option<GrammarIR>,
124-
}
125-
126120
fn next_grammar_cache_id() -> u64 {
127121
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
128122
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
@@ -248,18 +242,27 @@ impl LanguageGrammar {
248242
/// Serialize the compiled legacy metadata and GrammarIR for embedding in
249243
/// an application binary. Loading the result does not parse `.zyn` source.
250244
pub fn to_compiled_bytes(&self) -> GrammarResult<Vec<u8>> {
251-
let payload = CompiledGrammarPayload {
252-
module: (*self.module).clone(),
253-
grammar2: self.grammar2.as_deref().cloned(),
254-
};
255-
let mut encoded = Vec::new();
256-
ciborium::into_writer(&payload, &mut encoded).map_err(|e| {
245+
// Two sections, because they cannot share an encoding. The
246+
// rules are written positionally, which is most of the payload
247+
// and most of the time spent reading it back. The legacy module
248+
// keeps a self-describing one: its command arguments are an
249+
// untagged enum, so what a value is can only be known by
250+
// looking at it, which a positional format cannot do.
251+
let rules = postcard::to_allocvec(&self.grammar2.as_deref().cloned()).map_err(|e| {
257252
GrammarError::CompileError(format!("Failed to encode compiled grammar: {e}"))
258253
})?;
259-
let mut bytes = Vec::with_capacity(COMPILED_GRAMMAR_HEADER_LEN + encoded.len());
254+
let mut module = Vec::new();
255+
ciborium::into_writer(&*self.module, &mut module).map_err(|e| {
256+
GrammarError::CompileError(format!("Failed to encode compiled grammar: {e}"))
257+
})?;
258+
259+
let mut bytes =
260+
Vec::with_capacity(COMPILED_GRAMMAR_HEADER_LEN + 4 + rules.len() + module.len());
260261
bytes.extend_from_slice(COMPILED_GRAMMAR_MAGIC);
261262
bytes.extend_from_slice(&COMPILED_GRAMMAR_SCHEMA.to_le_bytes());
262-
bytes.extend_from_slice(&encoded);
263+
bytes.extend_from_slice(&(rules.len() as u32).to_le_bytes());
264+
bytes.extend_from_slice(&rules);
265+
bytes.extend_from_slice(&module);
263266
Ok(bytes)
264267
}
265268

@@ -283,13 +286,31 @@ impl LanguageGrammar {
283286
"unsupported compiled grammar schema {found}; expected {COMPILED_GRAMMAR_SCHEMA}"
284287
)));
285288
}
286-
let payload: CompiledGrammarPayload =
287-
ciborium::from_reader(&bytes[COMPILED_GRAMMAR_HEADER_LEN..]).map_err(|e| {
288-
GrammarError::LoadError(format!("Failed to decode compiled grammar: {e}"))
289-
})?;
289+
let body = &bytes[COMPILED_GRAMMAR_HEADER_LEN..];
290+
if body.len() < 4 {
291+
return Err(GrammarError::LoadError(
292+
"compiled grammar is truncated".to_string(),
293+
));
294+
}
295+
let rules_len = u32::from_le_bytes(body[..4].try_into().expect("four bytes")) as usize;
296+
let rules_end = 4 + rules_len;
297+
let (rules, module) = (
298+
body.get(4..rules_end).ok_or_else(|| {
299+
GrammarError::LoadError("compiled grammar is truncated".to_string())
300+
})?,
301+
body.get(rules_end..).ok_or_else(|| {
302+
GrammarError::LoadError("compiled grammar is truncated".to_string())
303+
})?,
304+
);
305+
let grammar2: Option<GrammarIR> = postcard::from_bytes(rules).map_err(|e| {
306+
GrammarError::LoadError(format!("Failed to decode compiled grammar: {e}"))
307+
})?;
308+
let module: ZpegModule = ciborium::from_reader(module).map_err(|e| {
309+
GrammarError::LoadError(format!("Failed to decode compiled grammar: {e}"))
310+
})?;
290311
let grammar = Self {
291-
module: Arc::new(payload.module),
292-
grammar2: payload.grammar2.map(Arc::new),
312+
module: Arc::new(module),
313+
grammar2: grammar2.map(Arc::new),
293314
vm: Arc::new(Mutex::new(None)),
294315
cache_id: next_grammar_cache_id(),
295316
language: None,

0 commit comments

Comments
 (0)