feat: GBNF grammar-constrained sampling - #487
Conversation
fb17000 to
877cb60
Compare
|
I've been testing your PR with Qwen3.5-9B. Without it, tool calling would often fail due to malformed JSON output. Now it works without issues. Great job! |
There was a problem hiding this comment.
Pull request overview
Adds GBNF grammar-constrained decoding to FastFlowLM by porting a llama.cpp-derived grammar engine and integrating it into the sampling pipeline and REST API so outputs can be forced to match a specified grammar (e.g., JSON).
Changes:
- Introduces a self-contained GBNF grammar parser/enforcer module (
FlmGrammar) and hooks it into token sampling (logit masking + state advancement). - Adds per-request grammar lifecycle management in
AutoModel(token-piece cache + set/clear API + auto cleanup). - Extends REST request parsing to accept
grammarand optionalgrammar_root.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/include/grammar/grammar.hpp | Declares grammar data structures, parser API, and FlmGrammar interface. |
| src/common/grammar/grammar.cpp | Implements GBNF parsing + pushdown automaton + logit filtering + state advancement. |
| src/include/modules/sampler.hpp | Adds optional grammar pointer + setter to Sampler. |
| src/common/modules/sampler.cpp | Applies grammar masking before top-k and advances grammar after sampling. |
| src/include/AutoModel/automodel.hpp | Adds set_grammar / clear_grammar APIs and per-request grammar members. |
| src/common/AutoModel/automodel.cpp | Builds token-piece cache, creates/clears grammar, and clears grammar after generation. |
| src/server/rest_handler.cpp | Parses grammar / grammar_root from requests and forwards to the engine. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // Clear grammar after generation so it doesn't leak to the next request. | ||
| clear_grammar(); |
There was a problem hiding this comment.
clear_grammar() is called only at the end of _shared_generate(), but there are early return paths above (e.g., immediate EOS after prefill, MAX_L reached) that bypass this call and can leak grammar state into the next request. Consider an RAII/scope guard to ensure clear_grammar() runs on all exits (including early returns and exceptions).
| // Grammar-constrained sampling: reject tokens that violate the grammar | ||
| // BEFORE top-k, so the full vocabulary is considered for filtering. | ||
| if (this->grammar) { | ||
| this->grammar->apply(this->logits.data(), this->in_features); | ||
| } |
There was a problem hiding this comment.
Grammar masking can legally reject every token in the current state. If apply() makes all logits -inf, softmax_inplace() will compute exp(-inf - -inf) and produce NaNs, breaking sampling. After applying the grammar, detect the "no valid tokens" case and fall back deterministically (e.g., allow EOG if grammar permits, otherwise disable/clear grammar for this step or pick the highest-logit unmasked token) so the sampler never feeds an all -inf set into softmax.
| #include "typedef.hpp" | ||
| #include <cstdint> | ||
| #include <deque> | ||
| #include <memory> |
There was a problem hiding this comment.
is newly included here but isn’t used in this header (only a forward decl + raw pointer are added). Consider removing it to keep includes minimal and avoid unnecessary rebuilds.
| #include <memory> |
| grammar_ = FlmGrammar::create(grammar_str, grammar_root, | ||
| token_pieces_cache_, eos_token_ids); | ||
| if (grammar_ && this->sampler) { | ||
| this->sampler->set_grammar(grammar_.get()); | ||
| } else if (!grammar_) { | ||
| fprintf(stderr, "AutoModel::set_grammar: failed to parse grammar\n"); | ||
| } |
There was a problem hiding this comment.
AutoModel::set_grammar() assigns grammar_ directly from FlmGrammar::create(). If create() fails, the previous grammar_ is destroyed but Sampler still retains the old non-owning pointer (set_grammar() is only called on success), which can lead to a dangling pointer / UAF during sampling. Create into a temporary unique_ptr and only swap + set sampler pointer on success, otherwise explicitly clear_grammar() (or at least sampler->set_grammar(nullptr)).
| grammar_ = FlmGrammar::create(grammar_str, grammar_root, | |
| token_pieces_cache_, eos_token_ids); | |
| if (grammar_ && this->sampler) { | |
| this->sampler->set_grammar(grammar_.get()); | |
| } else if (!grammar_) { | |
| fprintf(stderr, "AutoModel::set_grammar: failed to parse grammar\n"); | |
| } | |
| auto new_grammar = FlmGrammar::create(grammar_str, grammar_root, | |
| token_pieces_cache_, eos_token_ids); | |
| if (!new_grammar) { | |
| fprintf(stderr, "AutoModel::set_grammar: failed to parse grammar\n"); | |
| clear_grammar(); | |
| return; | |
| } | |
| grammar_ = std::move(new_grammar); | |
| if (this->sampler) { | |
| this->sampler->set_grammar(grammar_.get()); | |
| } |
Port llama.cpp's GBNF grammar engine to FastFlowLM, enabling grammar-constrained decoding on AMD NPU hardware. Includes full support for character-level matching and token-level matching (<[token_id]> and !<[token_id]> syntax), enabling thinking- aware grammars that constrain output after a </think> token. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
877cb60 to
6a42ba6
Compare
|
Thanks for the review! Pushed What changed1. RAII guard for struct GrammarGuard {
AutoModel * self;
~GrammarGuard() { self->clear_grammar(); }
} grammar_guard{this};Now the early-return paths (immediate EOS after prefill, 2. All-masked grammar fallback ( bool grammar_fell_back = false;
// ... after grammar->apply()
if (!std::isfinite(max_l)) {
fprintf(stderr, "Sampler: grammar masked all tokens; falling back...");
std::copy(pre_grammar.begin(), pre_grammar.end(), this->logits.begin());
grammar_fell_back = true;
}
// ... later
if (this->grammar && !grammar_fell_back) {
this->grammar->accept(sampled_index);
}Without the 3. Removed unused 4. auto new_grammar = FlmGrammar::create(grammar_str, grammar_root,
token_pieces_cache_, eos_token_ids);
if (!new_grammar) {
fprintf(stderr, "AutoModel::set_grammar: failed to parse grammar\n");
clear_grammar();
return;
}
grammar_ = std::move(new_grammar);
if (this->sampler) this->sampler->set_grammar(grammar_.get());Verified with Runtime validation summary
|
Hi, @Cytrus14, do you mind sharing your grammar for Qwen3.5? Thank you! |
Motivation
FastFlowLM excels at fast inference on AMD NPU hardware, but currently offers no way to constrain the output format of generated text. When using FLM for structured extraction tasks (JSON, function calls, form-filling), the model can emit malformed output — missing keys, wrong types, trailing commentary, markdown wrappers — that downstream parsers must handle defensively or reject entirely.
llama.cpp solved this years ago with GBNF grammars: a formal grammar (EBNF-like) is compiled into a pushdown automaton that masks logits at each decoding step, guaranteeing the output conforms to the grammar. This is the de facto standard for constrained decoding in the GGUF ecosystem.
This PR brings that same capability to FastFlowLM, so AMD NPU users get the same structured-output guarantees that GPU users have enjoyed via llama.cpp.
What this PR does
Ports the GBNF grammar parser and enforcer from llama.cpp (
llama-grammar.h/llama-grammar.cpp, MIT-licensed) into FastFlowLM as a self-contained module, and wires it into the existing sampling pipeline and REST API.New files
src/include/grammar/grammar.hppFlmGrammarclass declarationsrc/common/grammar/grammar.cppModified files
src/include/modules/sampler.hppFlmGrammar*member andset_grammar()method toSamplerclasssrc/common/modules/sampler.cppsrc/include/AutoModel/automodel.hppset_grammar()/clear_grammar()public API and grammar state memberssrc/common/AutoModel/automodel.cppsrc/server/rest_handler.cppgrammarandgrammar_rootfields from API requestsHow it works
Architecture
Grammar engine (ported from llama.cpp)
The grammar engine is a character-level pushdown automaton:
Parse phase: The GBNF string is parsed into a set of production rules. Each rule is a sequence of elements: character matches (
[a-z],"literal",.), character ranges ([^abc]), rule references, and alternates (|).Apply phase (
FlmGrammar::apply): Before top-k filtering, the grammar iterates over all vocabulary tokens. Each token is decoded to UTF-8 codepoints via a pre-built lookup table (token_pieces_cache_). Tokens whose decoded text would violate the current grammar state get their logits set to-inf, preventing them from being sampled.Accept phase (
FlmGrammar::accept): After a token is sampled, the grammar advances its internal stacks by consuming the token's codepoints character by character, producing the set of valid next states.What was ported vs. stripped
Ported from llama.cpp (fully functional):
*+?{m,n}, any-char.)Intentionally excluded (to minimize scope and dependencies):
<token>syntax (requires tokenizer integration at parse time — only<[numeric_id]>is supported)The grammar module has zero dependencies on llama.cpp runtime — it's entirely self-contained within FastFlowLM.
API usage
Request format
The
grammarparameter accepts a GBNF grammar string. The optionalgrammar_rootparameter specifies the start rule (defaults to"root").Works with all three endpoints:
POST /v1/chat/completions(OpenAI-compatible)POST /v1/completions(OpenAI-compatible)POST /api/chat(Ollama-compatible)Example: Force digit-only output
Example: Force JSON object with specific keys
Example: Force JSON array
Design decisions
Grammar applied before top-k, not after. The grammar filter must see the full vocabulary to correctly mask tokens. Applying it after top-k would miss valid tokens that top-k already discarded, potentially leaving zero candidates.
Non-owning pointer in Sampler. The
FlmGrammar*inSampleris non-owning —AutoModelowns the grammar viaunique_ptrand automatically clears it after generation completes. This prevents grammar state from leaking between requests.Token-piece cache built lazily. The first grammar request triggers a one-time O(vocab_size) scan that decodes every token ID to its text representation. This cache is reused across requests and invalidated on model switch.
Zero overhead when unused. When no grammar is active, the only cost is a null-pointer check per sampling step. No allocations, no iterations.
Testing
Tested on AMD Strix Halo hardware (XDNA NPU) with Qwen3.5-2B and Qwen3.5-4B models:
License
The grammar engine is derived from llama.cpp's
llama-grammar.h/llama-grammar.cpp, which is licensed under MIT. The ported code retains the same license terms. All llama.cpp-specific dependencies (ggml, llama context, vocabulary interface) have been removed — the port is fully self-contained.