PN25 v3: close Cliff 1 mech B (club-3090#16) on long-text via setup-time Genesis backport

Background
----------

Cliff 1 mech B is the inductor-compiled FFN intermediate buffer leak that
PN12 (eager-mode SiluAndMul.forward_cuda pool) doesn't reach. vLLM v0.20
with `compilation_config.custom_ops=["none"]` dispatches SiluAndMul through
forward_native, which Inductor inlines and lowers to raw `empty_strided_cuda(
(s, intermediate_size), ...)` — bypassing PN12's FFNIntermediateCache pool.

Result: real IDE-agent prompts (sys-prompt + tool schemas + user request)
crashed long-text/long-vision/bounded-thinking with 138 MiB FFN OOM at the
inductor cache site. VolandBerlioz's Reddit reproducer + our local synthetic
both confirmed.

Sander shipped Genesis PN25 to address this — registers `silu_and_mul` as a
`torch.library.custom_op` so Inductor treats it as opaque (can't inline).
But PN25 hit a worker-fork registration bug under spawn: `_register_op_once()`
called from inside dynamo trace → `@custom_op` decorator → `infer_schema()`
→ dynamo refuses to trace.

Filed Sander/genesis-vllm-patches#16 with the trace + analysis.

What this commit adds
---------------------

A setup-time backport that lets us enable PN25 NOW, before Sander's upstream
fix lands in our pinned version (or in case it doesn't transfer to TP=1).
Two parts in `patch_pn25_genesis_register_fix.py`:

1. **Genesis-side change** to `silu_and_mul_customop.py`: hardened
   `get_op_callable()` to return None if called during dynamo tracing
   (defensive — shouldn't happen if part 2 works).

2. **Wiring change** to `patch_N25_silu_inductor_safe_pool.py`: text-patches
   `vllm/model_executor/layers/activation.py` to import the customop module
   and cache the op as a module-level global at activation.py import time.
   The patched `forward_native` body just reads `_GENESIS_PN25_SILU_AND_MUL_OP`
   — no import + no registration during the dynamo trace.

Worker module-import happens during model construction in vLLM, BEFORE
profile_run enters aot_compile_fullgraph. Registration runs in eager
Python at startup; subsequent forward calls just read the cached global.

Wired into setup.sh after Genesis checkout. Idempotent via marker.

Also in this commit
-------------------

- **long-text.yml backed off 214K + 0.985 → 180K + 0.95.** PN25's pool keeps
  the FFN buffer resident (~140 MiB persistent), which tightens activation
  budget at OTHER peaks (DeltaNet `chunk_fwd_o`). 0.985 left only 26 MiB
  free at 30K probe — OOM. 0.95 frees ~480 MiB for activation comfort.

  Net memory accounting: PN25 is a strict win on KV pool because vLLM's
  profile_run measures lower activation peak (no fresh FFN alloc), so KV
  pool grows. Max concurrency at 180K: 1.07x without PN25 → 1.49x with
  PN25 + 0.95 (or 1.64x at 0.97 if we'd held it).

- **verify-stress.sh probe 3 hardened.** Was using tool_choice="auto" which
  let the model emit a tool_call and exit before the long-reasoning path
  that triggers the bug. Now uses tool_choice="none" + temperature=0 +
  asserts completion_tokens >= 200 to ensure the inductor compile path
  actually exercises during the test.

Validation on long-text 180K + 0.95 + PN25 v3
---------------------------------------------

| Probe                           | Result | Notes                            |
|---------------------------------|--------|----------------------------------|
| 1.1 Long-ctx needle 9.8K        |  PASS | activation budget safe           |
| 1.2 Long-ctx needle 29K         |  PASS | activation budget safe at 30K    |
| 1.3 Long-ctx needle 60K         |  FAIL | Cliff 2 architectural (DeltaNet) |
| 2  25K tool RETURN              |  FAIL | FA varlen workspace (Sander #15) |
| 3  IDE-agent one-shot           |  PASS | **Closed by PN25 v3**          |
| 4  Multi-turn agent             |  PASS | **Closed by PN25 v3**          |
| 5  LCB-coding                   |  FAIL | DS conv state (Sander #17)       |
| 6  Reasoning-heavy 8192         |  PASS | pure reasoning works clean       |

The 4 failures are pre-tracked separately:
- Cliff 2 (#1.3): architectural, no fix at single-card; route to dual or llama.cpp
- 25K tool RETURN (#2): Sander shipped PN31 (`753344b`) — pending our cross-rig
- LCB-coding (#5): Sander shipped PN30 (`a9977d8`) — pending our cross-rig

Sander has since shipped PN25 fix upstream (`d92bcb3` on dev) using a
slightly different approach (hasattr check on global registry). Once our
pin bumps to that commit, this local v3 patch becomes redundant — drop it
and remove the setup.sh hook in a follow-up.

Other 3 TQ3 composes (long-vision/bounded-thinking/dual-turbo) are NOT
PN25-enabled in this commit pending validation. Long-text is the only
compose with PN25 active + verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex CLI (ChatGPT) <noreply@openai.com>
This commit is contained in:
noonghunna
2026-05-01 22:26:43 +00:00
parent 5e745c5c85
commit a62ad78a4e
4 changed files with 346 additions and 16 deletions

View File

@@ -160,6 +160,7 @@ services:
- GENESIS_ENABLE_PN13_CUDA_GRAPH_LAMBDA_ARITY=1 - GENESIS_ENABLE_PN13_CUDA_GRAPH_LAMBDA_ARITY=1
- GENESIS_ENABLE_PN14_TQ_DECODE_OOB_CLAMP=1 - GENESIS_ENABLE_PN14_TQ_DECODE_OOB_CLAMP=1
- GENESIS_ENABLE_PN17_FA2_LSE_CLAMP=1 - GENESIS_ENABLE_PN17_FA2_LSE_CLAMP=1
- GENESIS_ENABLE_PN25_SILU_INDUCTOR_SAFE=1
- GENESIS_ENABLE_PN19_SCOPED_MAX_SPLIT=1 - GENESIS_ENABLE_PN19_SCOPED_MAX_SPLIT=1
- GENESIS_ENABLE_PN22_LOCAL_ARGMAX_TP=1 - GENESIS_ENABLE_PN22_LOCAL_ARGMAX_TP=1
# PN26b sparse-V Triton kernel — first SM86 (Ampere consumer) sparse-V # PN26b sparse-V Triton kernel — first SM86 (Ampere consumer) sparse-V
@@ -229,18 +230,36 @@ services:
- float16 - float16
- --tensor-parallel-size - --tensor-parallel-size
- "1" - "1"
# 214K + 0.985restored from intermediate 185K backoff after v0.20 # 180K + 0.97backed off from 214K + 0.985 on 2026-05-01 PM after
# migration (2026-05-01 PM). Engine ceiling on v0.20 + cudagraph # verify-stress probe 1 (10K-token long-context needle) crashed with
# profiling-OFF is 214K (vs 218K on dev205); -4K is the cumulative # GDN forward OOM (`fla.ops.chunk.chunk_gated_delta_rule_fwd_h`
# overhead of v0.20's PyTorch 2.11 + CUDAGraph profiling default-on. # `k.new_empty(B, NT, H, V, K)` requested 98 MiB, 46 MiB free). At
# 50K-token cliff that fired on dev205 doesn't reproduce on v0.20 + # 0.985 mem-util, activation budget was ~46 MiB at 10K prefill —
# Genesis v7.65 — verified verify-full + 33K + 50K stress all PASS. # tight enough that ANY single prompt over a few thousand tokens was
# Cliff 2 (single-prompt 5060K in GDN) is independent and still # a coin flip. The "Cliff 2 fires only at 50-60K" framing was
# applies — route those to TP=2 dual.yml or llama.cpp. # config-specific to the dev205 + 0.92 default; at tighter mem-util
# the GDN forward buffer outpaces available activation much earlier.
#
# New target (validated 2026-05-01 PM via verify-stress.sh):
# 180K + 0.97 → ~6.4 GB after weights, ~5.9 GB KV pool, ~500 MiB
# activation safety. Probe 1 (10K needle) and probe 6 (math reasoning
# at max_tokens=8192) PASS. Safe single-prompt envelope ~30K; full
# 180K context for steady-state turn accumulation only.
#
# IDE-agent workloads (sys + tool schemas) STILL crash here per
# club-3090#16 — different bug (Cliff 1 mech B inductor leak), not
# fixed by mem-util tuning. Use tools-text.yml for those.
- --max-model-len - --max-model-len
- "214000" - "180000"
# 0.95 — backed off from 0.97 on 2026-05-01 PM after PN25 v3 closed
# Cliff 1 mech B at the FFN intermediate buffer (which now sits in
# PN12+PN25's resident FFNIntermediateCache pool). Pool residence
# adds ~140 MiB persistent VRAM, which tightened activation budget
# at DeltaNet `chunk_fwd_o` (`o = torch.empty_like(v)`, 50 MiB) —
# 0.97 left only 26 MiB free, OOM at 9.8K probe. 0.95 frees ~480 MiB
# activation budget, comfortable for 30K+ prompts.
- --gpu-memory-utilization - --gpu-memory-utilization
- "0.985" - "0.95"
- --max-num-seqs - --max-num-seqs
- "1" - "1"
- --max-num-batched-tokens - --max-num-batched-tokens

View File

@@ -0,0 +1,286 @@
"""Genesis PN25 worker-spawn registration fix (setup-time patch).
Filed upstream as
[Sandermage/genesis-vllm-patches#16](https://github.com/Sandermage/genesis-vllm-patches/issues/16).
This is our local backport while the upstream PR cycle plays out.
Why setup-time, not boot-time
-----------------------------
The genesis tree is mounted read-only into the vLLM container; a boot-time
sidecar can't write to it. The patch needs to land in the genesis checkout
BEFORE the container starts, which means at setup time. setup.sh invokes
this script after `git checkout <pin>` so the fix lands on every fresh
setup.
The bug
-------
`_register_op_once()` checks an in-process `_op_registered` flag. When
vLLM workers spawn (vLLM uses spawn, not fork), they get a fresh Python
interpreter — module-level state resets, including `_op_registered=False`.
The C++ `torch.library` registry also doesn't survive across spawn.
Worker's first call to the patched `SiluAndMul.forward_native` body calls
`get_op_callable()` which calls `_register_op_once()`. The flag is False,
so it tries to re-decorate via `@torch.library.custom_op(...)`. That call
internally invokes `torch.library.infer_schema(fn)` to derive the op
schema from type hints. **Dynamo refuses to trace through `infer_schema`**
(it's marked as skipped). And worker's first `forward_native` call IS
inside a dynamo `aot_compile_fullgraph` trace during profile_run. Engine
dies at:
torch._dynamo.exc.Unsupported: Attempted to call function marked as skipped
Explanation: Dynamo developers have intentionally marked that the
function `infer_schema` ... should not be traced.
The fix
-------
Do not register from the compiled forward path at all. In PyTorch 2.11,
calling a `torch._dynamo.disable` wrapped function from vLLM's
`fullgraph=True` compile path is itself an Unsupported graph break.
Instead, patch Genesis PN25 so `activation.py` registers and caches the
opaque op at module import time in each spawned worker:
_GENESIS_PN25_SILU_AND_MUL_OP = get_op_callable()
Worker module import happens during model construction, before
`profile_run` enters `aot_compile_fullgraph`. The patched
`forward_native` then calls only that cached module global and never runs
`torch.library.custom_op(...)` while Dynamo is tracing.
"""
import os
import sys
CUSTOMOP_TARGET = (
"models/qwen3.6-27b/vllm/patches/genesis/vllm/_genesis/kernels/"
"silu_and_mul_customop.py"
)
WIRING_TARGET = (
"models/qwen3.6-27b/vllm/patches/genesis/vllm/_genesis/wiring/hybrid/"
"patch_N25_silu_inductor_safe_pool.py"
)
MARKER_CUSTOMOP = "# club-3090: PN25 trace-safe get_op_callable v3"
MARKER_WIRING = "club-3090: PN25 import-time op cache v3"
OLD_GET_OP_CALLABLE = '''def get_op_callable():
"""Return the registered op callable, or None if registration failed.
Used by the PN25 wiring patch to populate the replacement body.
Caller is responsible for graceful degradation on None.
"""
if not _register_op_once():
return None
try:
return torch.ops.genesis.silu_and_mul_pooled
except (AttributeError, RuntimeError):
return None
'''
NEW_GET_OP_CALLABLE = f'''def get_op_callable():
"""Return the registered op callable, or None if registration failed.
{MARKER_CUSTOMOP}
Intended path: activation.py imports this module and calls this function
while each spawned worker imports activation.py, before Dynamo tracing.
If a stale forward body calls this from inside fullgraph tracing, avoid
torch.library registration there because Dynamo cannot trace Library
construction or infer_schema.
"""
if not _op_registered:
try:
compiler = getattr(torch, "compiler", None)
if compiler is not None and compiler.is_compiling():
log.debug(
"[PN25] get_op_callable called during torch.compile "
"before registration; returning None"
)
return None
except Exception:
pass
try:
dynamo = getattr(torch, "_dynamo", None)
if dynamo is not None and dynamo.is_compiling():
log.debug(
"[PN25] get_op_callable called during Dynamo tracing "
"before registration; returning None"
)
return None
except Exception:
pass
if not _register_op_once():
return None
try:
return torch.ops.genesis.silu_and_mul_pooled
except (AttributeError, RuntimeError):
return None
'''
OLD_FORWARD_REPLACEMENT = '''PN25_FORWARD_NATIVE_REPLACEMENT = (
" @staticmethod\\n"
" def forward_native(x: torch.Tensor) -> torch.Tensor:\\n"
" \\"\\"\\"PyTorch-native — Genesis PN25 routes through opaque\\n"
" custom op so torch.compile/Inductor cannot inline the FFN\\n"
" intermediate alloc; pool from FFNIntermediateCache instead.\\n"
" Falls back to vanilla math when registration unavailable.\\n"
" \\"\\"\\"\\n"
" try:\\n"
" from vllm._genesis.kernels.silu_and_mul_customop import (\\n"
" get_op_callable as _genesis_pn25_get_op,\\n"
" )\\n"
" _genesis_pn25_op = _genesis_pn25_get_op()\\n"
" if _genesis_pn25_op is not None:\\n"
" return _genesis_pn25_op(x)\\n"
" except Exception: # pragma: no cover — defensive fallback\\n"
" pass\\n"
" d = x.shape[-1] // 2\\n"
" return F.silu(x[..., :d]) * x[..., d:]\\n"
)
'''
NEW_FORWARD_REPLACEMENT = '''PN25_FORWARD_NATIVE_REPLACEMENT = (
" @staticmethod\\n"
" def forward_native(x: torch.Tensor) -> torch.Tensor:\\n"
" \\"\\"\\"PyTorch-native — Genesis PN25 routes through an opaque\\n"
" custom op cached at activation.py import time, before vLLM's\\n"
" aot_compile_fullgraph trace. Do not register custom ops here.\\n"
" \\"\\"\\"\\n"
" _genesis_pn25_op = _GENESIS_PN25_SILU_AND_MUL_OP\\n"
" if _genesis_pn25_op is not None:\\n"
" return _genesis_pn25_op(x)\\n"
" d = x.shape[-1] // 2\\n"
" return F.silu(x[..., :d]) * x[..., d:]\\n"
)
'''
IMPORT_CONSTANTS = f'''PN25_IMPORT_ANCHOR = (
"logger = init_logger(__name__)\\n"
"\\n"
"\\n"
)
PN25_IMPORT_REPLACEMENT = (
"logger = init_logger(__name__)\\n"
"\\n"
"# [Genesis PN25] {MARKER_WIRING}. Register/cache the opaque\\n"
"# silu_and_mul op while activation.py is imported in each spawned\\n"
"# worker, before vLLM enters aot_compile_fullgraph profile_run.\\n"
"try:\\n"
" from vllm._genesis.kernels.silu_and_mul_customop import (\\n"
" get_op_callable as _genesis_pn25_get_op_callable,\\n"
" )\\n"
" _GENESIS_PN25_SILU_AND_MUL_OP = _genesis_pn25_get_op_callable()\\n"
"except Exception:\\n"
" _GENESIS_PN25_SILU_AND_MUL_OP = None\\n"
"\\n"
"\\n"
)
'''
SUBPATCH_ANCHOR = ''' sub_patches=[
TextPatch(
name="pN25_silu_and_mul_forward_native_opaque",
'''
SUBPATCH_REPLACEMENT = ''' sub_patches=[
TextPatch(
name="pN25_silu_and_mul_import_time_register",
anchor=PN25_IMPORT_ANCHOR,
replacement=PN25_IMPORT_REPLACEMENT,
required=True,
),
TextPatch(
name="pN25_silu_and_mul_forward_native_opaque",
'''
def _read(path: str) -> str | None:
if not os.path.isfile(path):
print(f"[pn25_register_fix] target not found: {path}", file=sys.stderr)
return None
with open(path, "r") as f:
return f.read()
def _write(path: str, src: str) -> None:
with open(path, "w") as f:
f.write(src)
def _patch_customop() -> bool:
src = _read(CUSTOMOP_TARGET)
if src is None:
return False
if MARKER_CUSTOMOP in src:
print("[pn25_register_fix v3] customop guard already applied")
return True
if OLD_GET_OP_CALLABLE not in src:
print(
"[pn25_register_fix v3] customop get_op_callable anchor not found",
file=sys.stderr,
)
return False
_write(CUSTOMOP_TARGET, src.replace(OLD_GET_OP_CALLABLE, NEW_GET_OP_CALLABLE, 1))
print("[pn25_register_fix v3] patched get_op_callable trace guard")
return True
def _patch_wiring() -> bool:
src = _read(WIRING_TARGET)
if src is None:
return False
changed = False
if MARKER_WIRING not in src:
if OLD_FORWARD_REPLACEMENT not in src:
print(
"[pn25_register_fix v3] forward replacement anchor not found",
file=sys.stderr,
)
return False
src = src.replace(OLD_FORWARD_REPLACEMENT, NEW_FORWARD_REPLACEMENT, 1)
src = src.replace(
NEW_FORWARD_REPLACEMENT,
NEW_FORWARD_REPLACEMENT + "\n" + IMPORT_CONSTANTS,
1,
)
changed = True
if "pN25_silu_and_mul_import_time_register" not in src:
if SUBPATCH_ANCHOR not in src:
print(
"[pn25_register_fix v3] subpatch insertion anchor not found",
file=sys.stderr,
)
return False
src = src.replace(SUBPATCH_ANCHOR, SUBPATCH_REPLACEMENT, 1)
changed = True
if changed:
_write(WIRING_TARGET, src)
print("[pn25_register_fix v3] patched PN25 activation import-time cache")
else:
print("[pn25_register_fix v3] wiring already applied")
return True
def main() -> int:
ok_customop = _patch_customop()
ok_wiring = _patch_wiring()
if not (ok_customop and ok_wiring):
return 1
print(
"[pn25_register_fix v3] done — PN25 registers in spawned workers "
"before fullgraph tracing; forward_native no longer registers in-graph"
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -129,6 +129,16 @@ if [[ "${SKIP_GENESIS:-0}" != "1" ]]; then
exit 1 exit 1
fi fi
echo "[genesis] Pinned to ${GENESIS_PIN} ($(cd "${GENESIS_DIR}" && git rev-parse --short HEAD))" echo "[genesis] Pinned to ${GENESIS_PIN} ($(cd "${GENESIS_DIR}" && git rev-parse --short HEAD))"
# PN25 worker-spawn registration fix (genesis-vllm-patches#16) — local
# backport while upstream PR cycle plays out. Idempotent: re-running is a
# no-op once the markers are present. Safe to remove after Sandermage ships
# the import-time custom-op registration fix.
if [[ -f "${ROOT_DIR}/models/qwen3.6-27b/vllm/patches/patch_pn25_genesis_register_fix.py" ]]; then
(cd "${ROOT_DIR}" && python3 models/qwen3.6-27b/vllm/patches/patch_pn25_genesis_register_fix.py) || {
echo "[genesis] WARN: PN25 register fix did not apply cleanly. PN25 may not work in workers." >&2
}
fi
else else
echo "[genesis] SKIP_GENESIS=1 — not cloning." echo "[genesis] SKIP_GENESIS=1 — not cloning."
fi fi

View File

@@ -380,10 +380,15 @@ body = {
{"role": "user", "content": user_text}, {"role": "user", "content": user_text},
], ],
"tools": tools, "tools": tools,
"tool_choice": "auto", # tool_choice="none" forces content-only output (no tool_calls),
# which makes the model go through long-reasoning + code emission —
# the path that triggers Cliff 1 mech B inductor leak. With "auto"
# the model can short-circuit by emitting a tool_call and exit
# before hitting the inductor-compiled reasoning forward, hiding
# the bug. We want the bug to surface deterministically.
"tool_choice": "none",
"max_tokens": 2000, "max_tokens": 2000,
"temperature": 0.6, "temperature": 0.0,
"top_p": 0.95,
"stream": False, "stream": False,
} }
with open(os.environ['REQ_FILE'], 'w') as f: with open(os.environ['REQ_FILE'], 'w') as f:
@@ -396,10 +401,20 @@ PYEOF
case "$http_code" in case "$http_code" in
200) 200)
body="$(cat "${resp_file}")" body="$(cat "${resp_file}")"
local finish content_len local finish content_chars completion_tokens
finish="$(echo "$body" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['choices'][0].get('finish_reason') or '?')" 2>/dev/null || echo "?")" finish="$(echo "$body" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['choices'][0].get('finish_reason') or '?')" 2>/dev/null || echo "?")"
content_len="$(echo "$body" | python3 -c "import sys,json; d=json.load(sys.stdin); m=d['choices'][0].get('message') or {}; c=m.get('content') or ''; tc=m.get('tool_calls') or []; print(f'{len(c)},{len(tc)}')" 2>/dev/null || echo "0,0")" content_chars="$(echo "$body" | python3 -c "import sys,json; d=json.load(sys.stdin); m=d['choices'][0].get('message') or {}; print(len(m.get('content') or ''))" 2>/dev/null || echo "0")"
pass "IDE-agent one-shot OK — finish=${finish} content_chars=${content_len%,*} tool_calls=${content_len##*,}" completion_tokens="$(echo "$body" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('usage',{}).get('completion_tokens', 0))" 2>/dev/null || echo "0")"
# Hardened: tool_choice=none forces content-only generation, so the
# model MUST go through the long-reasoning + code-emission path.
# If completion_tokens is suspiciously low, the model didn't actually
# exercise the inductor compile path that Cliff 1 mech B fires from.
if [[ "$completion_tokens" -lt 200 ]]; then
fail "HTTP 200 but only ${completion_tokens} completion tokens (finish=${finish})" \
"Probe expects long-form content generation to exercise the inductor compile path. <200 tokens means the bug surface wasn't actually tested. Check finish_reason — if 'tool_calls' despite tool_choice=none, the engine ignored the constraint."
else
pass "IDE-agent one-shot OK — ${completion_tokens} completion tokens (${content_chars} chars), finish=${finish}"
fi
;; ;;
500) 500)
fail "HTTP 500 — likely Cliff 1 mech B (inductor FFN intermediate OOM)" \ fail "HTTP 500 — likely Cliff 1 mech B (inductor FFN intermediate OOM)" \