Repository navigation
common, grammar: add recursive depth limit, make it a requirement - #31153
Conversation
ggerganov
left a comment
There was a problem hiding this comment.
The llama-grammar changes are OK
There was a problem hiding this comment.
Slight problem, on the MSVC Debug build, the 128 limit is still too much for some of these.
| Test | Debug | Release |
|---|---|---|
test-peg-parser |
crash (deep JSON) |
pass |
test-jinja |
crash (macros.endless macro recursion) |
crash (same test) |
test-grammar-integration |
pass | pass |
test-json-schema-to-grammar |
pass | pass |
test-server-component |
doesn't compile | not built |
test-peg-parser crashes because the node carries a lot, and test-jinja crashes because the thrown exception balloons the stack.
| Build | Stack | test-peg-parser | test-jinja |
|---|---|---|---|
| MSVC Debug | 1 MB | crash | crash |
| MSVC Debug | 8 MB | pass | pass |
| MSVC Release | 1 MB | pass | crash |
| MSVC Release | 8 MB | pass | pass |
| clang Release | 1 MB | pass | crash |
| clang Release | 8 MB | pass | pass |
I recommend we increase the total stack size for Windows builds from 1MB to 8MB to match glibc. This might not be as straightforward.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index d091a1411..767fdc229 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -80,6 +80,13 @@ option(BUILD_SHARED_LIBS "build shared libraries" ${BUILD_SHARED_LIBS_DEFAULT})
if (WIN32)
add_compile_definitions(_CRT_SECURE_NO_WARNINGS)
+
+ # match the 8 MB default stack on Linux
+ if (MINGW)
+ add_link_options(LINKER:--stack,8388608)
+ else()
+ add_link_options(LINKER:/STACK:8388608)
+ endif()
endif()
if (MSVC)There was a problem hiding this comment.
I don't think the changes here are needed in this file if the AST is already bounded by the parser. Just more work when we're given safe input.
|
I still have some tests to run... I found a side effect: some vocabs have very long tokens, like GLM-4.7-Flash with a single token for 512 spaces. With the new limit in the grammar code, any token of 256 chars or more is now always blocked when a grammar is active, while master allows it. The limit counts the chars of a vocab token here, not request input, so it probably needs to follow the longest token of the vocab instead. |
The depth limit in llama_grammar_reject_candidates_for_stack counted the code points of a vocab token, so any token of 256 chars or more was always rejected under a grammar, such as the 512-space token of GLM-4.7-Flash or the long space tokens of Laguna-S-2.1. The rejection now walks the code points with its own stack of levels instead of recursing: there is no limit and no stack risk, whatever the length of a token from the GGUF, and the masks match master.
|
Pushed a fix for the long token issue: instead of a limit following the vocab, the rejection is now a loop, so there is no limit at all and the masks match master again. LGTM now! |
At a given code point a candidate only differs from its input by the position of its code point pointer, so the levels now carry indices into the input candidates instead of copies. The first level needs no special case and the rejects map back to the input directly. Grammar sampling gets faster than master on GLM-4.7, Laguna, Qwen3.6 and gemma-4 with the json, list and spaces grammars, same masks.
|
Follow-up: the levels now carry indices into the candidates instead of copies, which makes the code simpler too. This PR made grammar sampling 3 to 10% slower than master, it is now 3 to 15% faster than master (GLM-4.7, Laguna, Qwen3.6 and gemma-4 with the json, list and spaces grammars), with the same masks. |
|
I think @ggerganov might need to review the new grammar changes. |
| #include <string> | ||
| #include <vector> | ||
|
|
||
| #define GRAMMAR_RECURSIVE_LIMIT 256 |
There was a problem hiding this comment.
Better move it in the .cpp file.
|
Nice, thanks. OK to merge when CI passes @aldehir ? |
|
@ngxson The MSVC Windows debug builds still crash on Throw exception outside of catchdiff --git a/common/jinja/runtime.cpp b/common/jinja/runtime.cpp
index c2c5385d0..17b8a6f5d 100644
--- a/common/jinja/runtime.cpp
+++ b/common/jinja/runtime.cpp
@@ -3,6 +3,7 @@
#include "value.h"
#include "utils.h"
+#include <exception>
#include <string>
#include <vector>
#include <memory>
@@ -52,32 +53,40 @@ static void ensure_key_type_allowed(const value & val) {
// execute with error handling
value statement::execute(context & ctx) const {
+ std::exception_ptr pending;
+ std::string message;
try {
return execute_impl(ctx);
} catch (const continue_statement::signal & /* ex */) {
- throw;
+ pending = std::current_exception();
} catch (const break_statement::signal & /* ex */) {
- throw;
+ pending = std::current_exception();
} catch (const rethrown_exception & /* ex */) {
- throw;
+ pending = std::current_exception();
} catch (const not_implemented_exception & /* ex */) {
- throw;
+ pending = std::current_exception();
} catch (const std::exception & e) {
const std::string & source = *ctx.src;
if (source.empty()) {
std::ostringstream oss;
oss << "\nError executing " << type() << " at position " << pos << ": " << e.what();
- throw rethrown_exception(oss.str());
+ message = oss.str();
} else {
std::ostringstream oss;
oss << "\n------------\n";
oss << "While executing " << type() << " at " << get_line_col(source, pos) << " in source:\n";
oss << peak_source(source, pos) << "\n";
oss << "Error: " << e.what();
- // throw as another exception to avoid repeated formatting
- throw rethrown_exception(oss.str());
+ message = oss.str();
}
}
+ // MSVC runs a catch block on top of the frames it unwinds, so throwing
+ // from inside one at every level stacks the handlers; throw from here
+ if (pending) {
+ std::rethrow_exception(pending);
+ }
+ // throw as another exception to avoid repeated formatting
+ throw rethrown_exception(message);
}
value identifier::execute_impl(context & ctx) const {I'll leave that to your discretion, but basically throwing inside a I'm lowering the recursion limit in the peg parser, since there is no need for it to be this high. A true fix is to parse using trampolines, but that's around a 1000-line fix. |
|
@aldehir you were right about the cause. I reproduced it on Windows and measured how much stack 120 nested macro calls need:
Same idea as yours, the only difference is that the error formatting moves out of the catch block, so its locals no longer weigh on every level. With it, the 128 macro limit fits in the default stack everywhere (1 MB on Windows, 512 KB for macOS threads), and test-jinja passes on Windows in Release and Debug. Note that CI stays green either way, Windows only runs the main label so test-jinja never runs there. Your 64 limit for the PEG parser makes sense for now: one JSON level costs about 3.6 KB in Release and 9.3 KB in Debug. I'll look into making the PEG parser non-recursive with an explicit stack, like the grammar rejection in this PR, so it no longer depends on the stack size at all. If it works out well, I'll open it as a follow-up. @ngxson your call for the jinja fix: I can push it here, or open it as a follow-up if you prefer to merge first. |
No need to spin your wheels on this, I already have a working implementation from when I was tackling incremental parsing. It's more of a question of whether or not it's worth the complexity for higher recursion limits if model outputs are typically very shallow. |
|
@aldehir @ServeurpersoCom I applied both suggestions |
…ml-org#31153) * common, grammar: add recursive depth limit, make it a requirement * catch more recursive functions * common : tidy up peg parser changes * grammar : reject candidates in a loop, without a depth limit The depth limit in llama_grammar_reject_candidates_for_stack counted the code points of a vocab token, so any token of 256 chars or more was always rejected under a grammar, such as the 512-space token of GLM-4.7-Flash or the long space tokens of Laguna-S-2.1. The rejection now walks the code points with its own stack of levels instead of recursing: there is no limit and no stack risk, whatever the length of a token from the GGUF, and the masks match master. * grammar : track rejected candidates as indices into the input At a given code point a candidate only differs from its input by the position of its code point pointer, so the levels now carry indices into the input candidates instead of copies. The first level needs no special case and the rejects map back to the input directly. Grammar sampling gets faster than master on GLM-4.7, Laguna, Qwen3.6 and gemma-4 with the json, list and spaces grammars, same masks. * nit : shorten the comment of llama_grammar_reject_step * common : add depth_guard for schema walks * grammar : move GRAMMAR_RECURSIVE_LIMIT into the .cpp * common : reduce recursion limit for peg parser * improve --------- Co-authored-by: Alde Rojas <hello@alde.dev> Co-authored-by: Pascal <admin@serveurperso.com>
Overview
As explained in #30242 (comment)
All recursive function in the code base must now come with a hard limit.
code-reviewskill checks for it.Additional information
COMMON_RECURSIVE_LIMITbuild_node), schema regexpatternnesting, PEG parse (re-entries of each rule),glob_match(number of wildcards)JINJA_RECURSIVE_LIMITfrom_json), nested macro andcaller()callsGRAMMAR_RECURSIVE_LIMIT(groups (parse_sequence), rule chains (detect_left_recursion)RECURSIVE_LIMITdecision_*helpers (replace_text,sort_keys,kev_render,d1omni_escape,py_dump)Requirements