The compressed FSM and jump-forward decoding: three generations of structured-output backend¶
The paper's second runtime technique is the compressed finite-state machine: the long deterministic stretches of text in JSON need not be generated token by token and can be "jumped" over whole. Its code history is interesting: the first version copied Outlines' FSM implementation and masked during sampling; three weeks later jump-forward was added; a month after that the copied code was swapped back for the Outlines library; xgrammar arrived in October 2024, llguidance in February 2025, and in March of that year jump-forward was deleted from the scheduler entirely — because the grammar libraries do it better. This chapter follows that line, watching an idea from the paper reshaped by engineering reality.
Self-test: if you can answer these, skip the chapter
- How does regex-constrained decoding work at bottom? How much computation does each step take?
- What does jump-forward jump over? Why does it have to retokenize afterwards?
- Where does the first version compile the FSM? Why on a background thread?
- Why was jump-forward deleted from the scheduler in March 2025? Where does the feature live today?
Answers for the self-test (answer first, then open this)
- Compile the regex into a deterministic finite-state machine, then precompute for each state which tokens are allowed (those whose string the FSM can accept from that state); at each step set the disallowed tokens' logits to negative infinity, and after sampling advance the state with the token chosen. The precomputation is one-off (proportional to the vocabulary size) and each step is just a mask.
- It jumps over the string corresponding to a run of states in the FSM with only one outgoing edge (such as
", "age":in JSON); those characters are determined and the model does not have to decide them. But appending them as new tokens directly would give a tokenization different from the one seen in training (the boundary between"ageand":, for instance), so the preceding text and the jumped string are concatenated, retokenized and continued from there. - In
FSMCache: the compiled result is cached by regex, and on first seeing a regex a thread is started to compile it (init_fsm_in_background) while an arriving request waits for it to finish. Compiling a regex takes seconds to tens of seconds, which on the main thread would stall the whole scheduler. - Jump-forward has to pull a request out of the batch inside the scheduling loop, change its input, retokenize and put it back, which interleaves with overlapped scheduling, speculative decoding, chunked prefill and the rest at a high maintenance cost; grammar libraries like xgrammar implement the same "find the deterministic string" (
find_jump_forward_string) on the C++ side, and SGLang only has to call the interface. TodayBaseGrammarObjectstill keepstry_jump_forwardand its companions, and the backend decides whether to support them.
A six-panel strip first, then the chapter:
The first version: copy Outlines, mask during sampling¶
The first code release's srt/constrained/ has four files, and the heads of fsm.py and regex.py state where they came from:
| python/sglang/srt/constrained/fsm.py @ 22085081bb L1-2 | |
|---|---|
What Outlines does: interegular parses the regex into an FSM and create_fsm_index_tokenizer computes the allowed token set for each state (states_to_token_maps). SGLang copied that code rather than depending on Outlines directly because Outlines' interface was changing fast and its dependencies were heavy at the time, and because SGLang wanted to do its own things on the FSM (jump-forward, below, needs access to the FSM's internal transition table).
Compiling a regex is slow, so there is a cache with a background thread:
When a request arrives (handle_generate_request) carrying a regex, the scheduler calls get_fsm(regex): a regex seen for the first time starts a compiling thread and event.wait() waits for it; afterwards the cache hits and returns directly. Note that get_fsm blocks — the scheduling loop stops here to wait for the compilation, an acceptable simplification in the first version (in the paper's experiments one regex is reused by a batch of requests). The masking during sampling is in Batch.sample:
def sample(self, logits: torch.Tensor):
# Post process logits
logits = logits.contiguous()
logits.div_(self.temperatures)
logits.add_(self.logit_bias)
has_regex = any(req.regex_fsm is not None for req in self.reqs)
if has_regex:
allowed_mask = torch.empty_like(logits[0], dtype=torch.bool)
for i, req in enumerate(self.reqs):
if req.regex_fsm is not None:
allowed_mask.zero_()
allowed_mask[
req.regex_fsm.allowed_token_ids(req.regex_fsm_state)
] = 1
logits[i].masked_fill_(~allowed_mask, float("-inf"))
...
if has_regex:
batch_next_token_ids_cpu = batch_next_token_ids.cpu().numpy()
for i, req in enumerate(self.reqs):
if req.regex_fsm is not None:
req.regex_fsm_state = req.regex_fsm.next_state(
req.regex_fsm_state, batch_next_token_ids_cpu[i]
)
It builds a boolean mask per request, masked_fill_s to negative infinity, and advances the state on the CPU after sampling. Each request with a regex costs one Python loop plus one GPU mask per step — simple, but a visible overhead at a large batch, later replaced by a batched fill_vocab_mask_batched and an apply_vocab_mask on the GPU.
Three weeks later: fast forward, then renamed jump-forward¶
The commit fast regex decode (01ee0fbc05) of 2024-01-25 added the paper's compressed FSM, called fast-forward at the time; jump-forward rename (#144) of 5 February settled the name (LMSYS published a blog post introducing jump-forward the same day). The commits along this line:
REF=${REF:-29f6d408c0}
git log --reverse --date=short --format='%ad %h %s' "$REF" | grep -iE 'jump.?forward|fast regex' | cut -c1-100
2024-01-25 01ee0fbc05 fast regex decode
2024-02-05 26f0bedc8f jump-forward rename (#144)
2024-02-09 4d303c4fa3 Fix token usage with jump forward (#174)
2024-02-15 63ba630bbb Refactor decoding logprob and add completion_tokens_wo_jump_forward (#189)
2024-06-16 ad5f04d6ce Fix the Jump-Forward with Chinese (#551)
2024-07-19 7620cd37dd Fix jump forward when streaming (#665)
2024-08-13 e205527cb1 Fix jump forward final state circular path bug. (#1084)
2024-10-12 9610fcd469 Fix the batch_is_full check for jump-forward decoding (#1654)
2025-03-03 935cda944b Misc clean up; Remove the support of jump forward (#4032)
v0.1.12's (2024-02-11) jump_forward.py is a self-contained JumpForwardMap:
It reads the FSM's transition table (fsm_info.transitions) and finds every state with only one outgoing edge corresponding to exactly one character (state_to_jump_forward), marking states with several edges, or an edge covering several characters, as dirty. A query walks along those single edges from the current state and concatenates the characters, which is the string that can be jumped over. The compiled result is stored on disk with disk_cache (Disk FSM cache (#63), added 2024-01-21, because the same regex is wanted again after a restart).
Before each decode step the scheduler checks which requests can jump:
A request that can jump is pulled out of the batch: its existing KV goes into the radix tree first (as when a request finishes), the slots and the request slot are freed, and then jump_forward_and_retokenize concatenates the preceding text with the jumped string, retokenizes and produces new input_ids — and it goes back into the waiting queue to enter the next round as a new extend request, whose prefix naturally hits on the tree. The design is clever: jump-forward is reduced to "one new request with a prefix cache", with no tensors inside the batch to change. The price is there too: a request goes through "pull out → insert into the tree → retokenize → be admitted again", deeply coupled to the batch's other machinery.
The blog post gives an example of why retokenizing is necessary: appending the jumped string character by character would split "name": at token boundaries different from the ones the model was trained on and the output quality afterwards would drop; after retokenizing, the prefix's tokens may differ slightly from the original (the blog measured about 4% of token boundaries changing), which is why the tree's matching is used rather than assuming the prefix is identical. Fix the Jump-Forward with Chinese (#551) of 2024-06-16 fixed exactly the multi-byte character boundary problem on this path.
Back to a library, then to a new one¶
import outlines (#168) of 2024-02-09 deleted the copied FSM code and depended on Outlines directly (followed by a run of "Pin outlines version", "Support outlines > 0.0.31" and "Fix outlines-0.0.35 incompatibility" — the price of depending on a fast-moving library). In August, JSON constrained support (#1125) added JSON schemas. On 26 October, Support both xgrammar and outlines for constrained decoding (#1752) brought in a second backend, and v0.4's blog post claimed JSON decoding 10 times faster; in November, Fix grammar backend for tensor parallelism (#2020) factored out base_grammar_backend.py and the backends became pluggable; Support llguidance (#3298) of 2025-02-26 added a third; and integrate Structural Tag in xgrammar backend for function calling (#3566) of 2025-02-28 applied constrained decoding to function calling.
The directory at four points in time:
REF=${REF:-29f6d408c0}
for r in 22085081bb v0.1.12 v0.4.0 "$REF"; do
echo "== $r"; git ls-tree --name-only "$r" python/sglang/srt/constrained/ | sed 's|python/sglang/srt/constrained/| |'
done
== 22085081bb
fsm.py
fsm_cache.py
regex.py
tokenizer.py
== v0.1.12
__init__.py
base_cache.py
fsm_cache.py
jump_forward.py
== v0.4.0
__init__.py
base_grammar_backend.py
outlines_backend.py
outlines_jump_forward.py
xgrammar_backend.py
== 29f6d408c0
base_grammar_backend.py
grammar_manager.py
json_schema_validation.py
llguidance_backend.py
outlines_backend.py
outlines_jump_forward.py
reasoner_grammar_backend.py
torch_ops
utils.py
xgrammar_backend.py
Today's interface is BaseGrammarObject, with every backend implementing the same set of methods:
class BaseGrammarObject:
def __init__(self):
self._finished = False
self.grammar_stats = None
self.current_token = None
def maybe_init_reasoning(self, reasoning: bool):
pass
def accept_token(self, token: int) -> None:
"""
Accept a token in the grammar.
"""
raise NotImplementedError()
def rollback(self, k: int):
raise NotImplementedError()
def is_terminated(self):
...
def try_jump_forward(self, tokenizer) -> Optional[Tuple[List[int], str]]:
"""
Try to jump forward in the grammar.
Returns:
A jump forward helper which may be used in `jump_forward_str_state`.
None if the jump forward is not possible.
"""
raise NotImplementedError()
def jump_forward_str_state(self, helper: Tuple[List[int], str]) -> Tuple[str, int]:
"""
Jump forward for the grammar.
Returns:
A tuple of the jump forward string and the next state of the grammar
(which can be used in `jump_and_retokenize` if needed).
"""
raise NotImplementedError()
def jump_and_retokenize(
self, old_output_ids: List[int], new_output_ids: List[int], next_state: int
) -> None:
"""
Jump forward occurs, and update the grammar state if needed.
"""
raise NotImplementedError()
The three methods try_jump_forward, jump_forward_str_state and jump_and_retokenize are the interface jump-forward left behind; the xgrammar backend's implementation simply forwards to its matcher:
Deleting jump-forward from the scheduler¶
Misc clean up; Remove the support of jump forward (#4032) of 2025-03-03 changed 41 files and deleted 426 lines. Its commit message and what it removed:
git show --format='%ad %h %an%n%s%n%b' --date=short --stat=90 935cda944b | grep -v '^$' | grep -iE 'jump|^2025|Misc|constrained|schedule|infer|batch' | head -20
2025-03-03 935cda944b Lianmin Zheng
Misc clean up; Remove the support of jump forward (#4032)
...atch_inference.py => offline_batch_inference_eagle.py} | 2 +-
python/sglang/srt/constrained/base_grammar_backend.py | 1 -
python/sglang/srt/constrained/outlines_backend.py | 7 +-
python/sglang/srt/layers/attention/flashinfer_backend.py | 3 +-
.../sglang/srt/layers/attention/flashinfer_mla_backend.py | 3 +-
python/sglang/srt/managers/schedule_batch.py | 126 +---------
python/sglang/srt/managers/scheduler.py | 59 -----
python/sglang/srt/model_executor/forward_batch_info.py | 2 +-
test/srt/test_ebnf_constrained.py | 8 -
test/srt/test_json_constrained.py | 22 --
test/srt/test_regex_constrained.py | 17 --
The reason for the removal can be inferred from the code of the time: by early 2025 the scheduler already had overlapped scheduling (the CPU's scheduling one step out of phase with the GPU's forward pass), EAGLE speculative decoding (several tokens verified in one step) and chunked prefill, each of which had to meet jump-forward's "pull the request out of the batch and put it back" path; meanwhile xgrammar implemented the same find_jump_forward_string in C++ and did the mask computation on the C++ side as well. So SGLang kept the interface and deleted its own implementation: a classic case of "build it first, then use the library". The paper's technique did not disappear — it became a grammar library's feature, and SGLang went back to its main business of scheduling and caching.
Design trade-offs¶
| Stage | The choice | Gained | Lost |
|---|---|---|---|
| 2024-01 | copy Outlines' FSM code | access to the transition table, enabling jump-forward | bugs have to be fixed along with upstream |
| 2024-01-25 | reduce jump-forward to "a new request" | no tensors inside the batch to change, the prefix cache reused | the request is pulled out, retokenized and re-admitted, a deep coupling |
| 2024-02 | depend on the Outlines library | one copy of the code less to maintain | version compatibility broke repeatedly |
| 2024-10 | several backends (xgrammar) | speed, JSON schemas, EBNF, structural tags | a backend abstraction to maintain |
| 2025-03 | delete its own jump-forward | the scheduler simplified | the feature depends on whether a backend supports it |
What happened afterwards¶
- Around June 2025 constrained decoding met "reasoning models":
reasoner_grammar_backend.pylets a grammar take effect only after</think>. - Applying the mask went from one
masked_fill_per request to a batchedfill_vocab_mask_batchedplus one GPU kernel (constrained/torch_ops/). grammar_manager.pymanages the compilation cache and the fallback on failure in one place (a grammar that fails to compile returns anInvalidGrammarObjectrather than leaving the request stuck).- Function calling (chapter 20) is parsed in
function_call/and in the Rust gateway, and constrained decoding is only responsible for guaranteeing the format during generation.
Exercises¶
1. The race in background compilation. In the first version's FSMCache.get_fsm, what happens when two requests bring the same new regex at the same time? Does it compile twice?
Answer
No: init_fsm_in_background checks regex not in self.cache first, so the first call puts an empty FSMCacheEntry in and starts the thread, and the second sees the key already present and goes straight to event.wait(). But this relies on the scheduling loop being single-threaded (two calls never enter the if simultaneously); the first version's scheduling does run in one thread, so it holds.
2. When jump-forward pays. From check_for_jump_forward's code, what kind of regex or JSON schema gains from jump-forward? Why skip when len(jump_forward_str) <= 1?
Answer
Patterns with long deterministic stretches (JSON with fixed key names, a sentence with a fixed prefix). Every jump costs a pull-out, a retokenization and a fresh extend, and when the jumped string is a single character the gain does not cover the overhead, so it is skipped.
3. Verify the removal's effect yourself. Count the lines containing jump_forward under managers/ and constrained/ at 935cda944b's parent and at the commit itself.
A way to approach it
Compare git grep -c jump_forward 935cda944b^ -- python/sglang/srt/managers python/sglang/srt/constrained with git grep -c jump_forward 935cda944b -- ...; the references on the scheduler side go to essentially zero and only the backend interface is left in constrained/.
How to explain it
"How is structured output done, and how is it made faster?" — At bottom it is an FSM mask (precompute each state's allowed tokens and mask once per step); the speedup is jumping over the deterministic stretches (a compressed FSM, jump-forward), and the hard parts are retokenizing and the coupling with the scheduler. You can add a sentence on SGLang's evolution: it implemented jump-forward itself, later handed it to a grammar library like xgrammar, and the scheduler keeps only the interface — which shows you know which layer the feature belongs in.
Summary¶
- The first version copied Outlines' FSM, compiled in the background through
FSMCache, and masked per request inBatch.sample. - Jump-forward reduces "skip the deterministic string" to one new request with a prefix cache, and must retokenize.
- The backend went from copied code → the Outlines library → pluggable xgrammar and llguidance; jump-forward left the scheduler in 2025-03 and the feature moved into the grammar libraries.
- This is a complete specimen of "an idea from a paper → an implementation of one's own → handed to a dedicated library".
Found a mistake or a better way to say it? Edit this page on GitHubReport an issue