Fallback Behaviour, Deep Dive
Every way pyvorin-native 1.0.9 declines to run native code, what each fallback costs, the reason strings the tools emit, and when fallback means restructure.
Published Jul 30, 2026
Fallback in pyvorin-native 1.0.9 is not an error state. It is the designed answer to a question the compiler asks of every function — whether it can produce native code it can stand behind — whenever the answer is no. The consequence is deliberately mild: the function keeps running as ordinary CPython, its results identical to the interpreter's, and the lost prize is speed alone. This page takes the mechanism apart: the four ways fallback happens, the exact strings the tooling emits, what a runtime diversion costs, one honest caveat found in live probing, and how to instrument your services so the fallback rate is a number you watch rather than a surprise you discover.
The taxonomy
Fallback has four distinct triggers, and they differ in when they fire, what they cost and what you should do about them.
| Trigger | When | Cost | Action |
|---|---|---|---|
| Compile-time decline | The frontend or codegen meets a construct it cannot lower | Interpreter speed for that function, every call | Read the reason; rewrite the construct or accept the loss |
| Partial compilation | A function compiles with named constructs left on the interpreter path | Native speed minus the uncompiled regions | Check whether the unsupported part is on the hot path |
| Runtime guard diversion | A compiled function is called with arguments violating its guard assumptions | One diverted call; native path stays in place | Investigate if recurring; the guards are telling you something about your call sites |
| Hard failure | The oracle check fails, or an unsupported import is demanded in strict contexts | None — the function refuses to claim native status | Treat as a correctness alarm first, a performance issue second |
Across all of these, the status vocabulary of the tooling is consistent: COMPILED_FULL, COMPILED_PARTIAL, COMPATIBILITY_EXECUTED and FAILED from support and compile; and at the execution layer, results carry a backend_used field drawn from three values — pyvorin-native-llvm, pyvorin-cpython-fallback and pyvorin-invalid — so a record of which engine actually produced a result always exists.
Compile-time decline, from the inside
The frontend classifies imports into tiers — compile-safe, runtime-callable, compatibility-only and unsupported — and unsupported imports raise an ImportPolicyError at the frontend rather than slipping through. Unsupported constructs inside a function are gentler: the router marks the function and runs it as honest CPython, and the reason is recorded in the diagnostics layer. What that looks like in real output, captured from run --json on a function using a global counter:
"status": "COMPATIBILITY_EXECUTED",
"unsupported_features": [
"unsupported_ast_pattern (bump): unsupported AST pattern;
calls a compat-mode function"
],
"fallback_used": false,
"fallback_reason": null
Three things to read carefully here. The function never went native, and says so in its status. The reason string is specific — not "unsupported" but the construct class and the function name. And fallback_used is false with a null reason: those fields describe the runtime diversion path, which never engaged, because the decline happened earlier, at compile time. Keeping those two notions apart — declined-at-compile versus diverted-at-runtime — is the single most useful habit when reading these reports.
The reasons also chain. compile --explain-fallback on a caller and its callee shows the causal structure directly:
entry:
Fallback reasons:
- Call (entry): Unsupported feature encountered during codegen: Call
Fix the root construct, not the leaf that reported it; a caller inherits the fallback of anything it calls.
Runtime diversion: what a guard failure actually does
A compiled function runs on assumptions: argument arity, dtypes, memory layout, non-null data pointers, non-aliasing between arrays the kernel assumed independent. Guards check those assumptions at the boundary on every call. When a guard fails and a Python reference implementation is available, the call diverts to the original source — a lazily compiled Python fallback of your function, not an approximation — executes there with the same arguments, and returns that result. The semantics are exactly the interpreter's, because the fallback is the interpreter's code.
Two properties of this path matter operationally. First, the diversion is per-call: the native artefact is not discarded, and 1.0.9 does not recompile or re-specialise after a guard failure — there is no tier-up loop waiting on the other side of a diversion. A function whose guards fail once will tend to divert again under the same call pattern, which is why recurring diversion is a signal to look at the call site, not to wait for the compiler to adapt. The full guard and deoptimisation contract is on the guards and deoptimisation page.
Second, diversion has one principled exception: when native code has already mutated parameter state that a CPython rerun would double-apply, the wrapper re-raises instead of rerunning. A partially applied mutation cannot be replayed honestly, so the system stops loudly rather than corrupting state. Fallback preserves answers or fails; it never guesses.
A known caveat: the router path and wrong-arity calls
Live probing during our documentation verification found one divergence worth stating plainly. The native path handles wrong-arity calls loudly: on the installed build, calling a natively compiled function with the wrong number of arguments raises TypeError, exactly as CPython would. On the router path — the PyvorinCompiler route used by embedding code — the same wrong-arity call was observed in probes to return silent garbage rather than raising a guard error, a finding recorded as a product caveat on 1.0.9 pending verification.
The practical consequence is a discipline, not a panic: validate argument shapes at your embedding boundary before the call, and treat any router-path result for edge-case inputs with the scepticism the caveat earns until a fixed build ships. The unsupported code and fallback page documents the honest-path behaviour this caveat is an exception to.
Measuring your fallback rate
Fallback is only manageable once it is a metric. Four verified surfaces give it to you, in increasing order of integration effort:
- The compile gate.
python -m pyvorin compile app.py --fail-on-fallbackexits 2 when any function in the file lands in compatibility mode. Verified on the installed build. This turns fallback from an ambient condition into a build failure for files you have claimed native. - The bench report.
python -m pyvorin bench app.py --function entry --jsonemitsstatus,fallback_used,fallback_countandfallback_reasonper run.fallback_countabove zero during a benchmark means the native path was abandoned mid-measurement — a result to investigate before publication, exactly the kind of number the benchmarks page insists on separating from steady-state timing. - OpenMetrics.
bench --metricsadds Prometheus-style gauges to the output, includingpyvorin_bench_fallback_countandpyvorin_bench_deopt_count, for pipelines that already scrape metrics. - The programmatic surface. Compiled callables expose
fallback_usedandfallback_reasonattributes, readable after calls in production instrumentation. A cheap health check: sample these on your hot functions per request batch and log anytruewith the reason attached.
For services, the aggregate pattern is: gate in CI, sample the programmatic attributes in production, review the trend on the cadence your team already uses for latency. The adoption metrics page treats the fallback-rate trend as a first-class metric for exactly this reason.
When fallback means restructure
Some fallback deserves a shrug. A setup function that runs once per deploy, a cold-path error handler, a logging preamble — interpreter speed there costs nothing measurable, and chasing it wastes the time the compiler was supposed to save. The debugging compiled code page shows how to confirm a reason points at a cold path.
Other fallback is the compiler doing your profiling for you. The most instructive example from our runs: a nearest-neighbour function initialised its accumulator as best = None, and the compile declined with Return(Optional var) and Compare(Optional operand) — an optionally-typed variable has no stable machine representation, so the function executed in compatibility mode and every call ran at interpreter speed. The rewrite is trivial, a numeric sentinel instead of None, and the function compiles full afterwards. The generalisable pattern: fallback reasons naming a type-shape problem — optionals, unsupported receivers, unlowerable calls on the hot path — are restructure signals. The fallback exists to keep you correct in the interim, not to save you the edit. Judgement is knowing which side of that line a given reason falls on: cold path, shrug; hot path, the compiler has found the bottleneck your profile would have found next week.
Where to go next
- Unsupported code and the fallback path — the classification tiers and the honest-path fallback contract in full.
- Guards and deoptimisation — the runtime safety checks behind every diversion, and why failed guards do not recompile in 1.0.9.
- Debugging compiled code — reading status words, reason chains and timing tells together.
- Benchmarks — why fallback measurement belongs inside every speed claim you publish.
Last reviewed 30 July 2026 against pyvorin-native 1.0.9 installed at /root/pvfinal. Status reports, JSON fields, reason strings, exit codes, OpenMetrics names and the native-path arity behaviour were captured from real runs on 13 September 2026; the router-path caveat is recorded from the same live probes and flagged as a product finding pending verification.