# E3 — first run findings + bug exposure

**Status**: E3 first attempt aborted at ~16 min wall by SGLang assertion crash on decode-1. Partial data confirms the load-floor bonus works as designed; the crash is an independent vendored-SGLang bug exposed by E3's new routing pattern.

**Branch**: `h200-cu130`.
**Companion**: `docs/E1_E2_RESULTS_ZH.md`, `docs/E1_E2_FIX_DESIGN_ZH.md`.

---

## 1. What worked: load-floor bonus (K=200)

Within the first ~15 minutes of E3, before the crash:

| | E1 (run1) | E2 (run1) | E3 (run1, partial) |
|---|---:|---:|---:|
| total bindings | 1285 | 1186 admit attempts | 1001 |
| decode-0 bindings | 575 | 600 | 240 (24.0%) |
| decode-1 bindings | 710 | 685 | 536 (53.5%) |
| **decode-2 bindings** | **0** | **0** | **225 (22.5%)** |
| unique sessions on D2 | 0 | 0 | **30** |

**Load-floor bonus successfully broke the overlap-pinning death spiral.** D2 is finally getting traffic on Inferact's shared-boilerplate workload. The graduated formula (`K * deficit / mean`) plus the `not sticky` gate produces the intended behavior: fresh sessions land on under-loaded D's, established sessions keep going to their original D for cache locality.

This validates the Q2.B design from `docs/E1_E2_FIX_DESIGN_ZH.md` empirically — but only as far as the run got. End-to-end metrics (lat / TTFT / failure rate) are not interpretable yet because the worker died.

## 2. The new crash: SGLang streaming-session correction leaves an invariant violated

At `01:51:21` (~5 min into the benchmark), decode-1 hit:

```
[01:51:21] Correcting streaming-session extend_input_len from 6648 to 0
  (rid=6f4318e93dd543a49dbf19248cfc1e6f, session_id=1000195,
   fill_len=6648, prefix_len=43459, kv_committed_len=43459)
[01:51:21] Scheduler hit an exception: AssertionError
  at third_party/sglang/python/sglang/srt/managers/schedule_batch.py:1646
  → assert seq_len - pre_len == req.extend_input_len
```

### Mechanism

With `--enable-streaming-session`, SGLang's session_aware_cache hands the scheduler a request whose `fill_ids` is just the new tokens since the last turn (6648), while `prefix_indices` represents the already-cached prefix on this D (43459 blocks). When the prefix exceeds `fill_ids` (e.g., the new turn's input is short relative to the conversation history that's already in cache), this code path fires at `schedule_batch.py:1572-1585`:

```python
actual_extend_len = max(0, len(req.fill_ids) - len(req.prefix_indices))
if req.extend_input_len != actual_extend_len:
    logger.warning("Correcting streaming-session extend_input_len from %d to %d ...")
    req.set_extend_input_len(actual_extend_len)
```

So `req.extend_input_len` becomes `max(0, 6648 - 43459) = 0`.

Then at line 1588-1590:

```python
seq_lens = [len(r.fill_ids) for r in reqs]       # 6648
prefix_lens = [len(r.prefix_indices) for r in reqs]  # 43459
```

And at line 1646:

```python
assert seq_len - pre_len == req.extend_input_len  # 6648 - 43459 == 0 → FAIL
```

The correction patches `extend_input_len` but the downstream invariant is computed from raw `fill_ids`/`prefix_indices` lengths, which the correction never touched. The arithmetic check is fundamentally incompatible with the corrected state.

### Provenance

The streaming-session correction (`schedule_batch.py:1572-1585`) and the assertion site (line 1646) are both inside the project's SGLang vendor patches — `git log` on this file shows the patch came from commit `b8e6f13 feat(sglang): support decode session cache admission`. So this is a regression in the project's own SGLang fork, not upstream SGLang.

### Why E3 triggers it and E2 didn't

The crash is independent of migration (session 1000195 stayed on decode-1 the entire time). Two factors combined to expose it in E3:

1. **D1 was under more sustained load in E3** — 536 bindings on 17 unique sessions means high re-binding density per session, which means more concurrent turns of the same session at the scheduler, increasing the rate at which streaming-session corrections fire.
2. **Faster overall dispatch** — with D2 actually consuming work, the prefill→decode pipeline moves faster, so streaming-session entries reach the corrected state more often than in E2's saturated cap-out regime.

Both factors are effects of the load-floor fix, not its cause. The crash is a pre-existing landmine in the vendored streaming-session code that E1 and E2 happened to avoid because their pipelines stalled before sessions accumulated enough committed prefix to trigger the correction.

---

## 3. Decision space for the fix

| # | Fix | Layer | Where | Risk |
|---|---|---|---|---|
| **A** | Patch the assertion to match the corrected state | vendored SGLang `schedule_batch.py:1646` | Add: `if req.extend_input_len == 0 and len(req.fill_ids) < len(req.prefix_indices): continue` to skip degenerate reqs before iterating. | Local, scoped, doesn't touch correctness elsewhere. Need to handle the skipped reqs (set `was_skipped` flag, drop from batch). |
| **B** | Fix the correction site to also drop the req from the batch | vendored SGLang `schedule_batch.py:1572-1585` | When `actual_extend_len == 0` and req has nothing to extend, signal upstream to remove the req from this batch (defer or drop). | Slightly more invasive. The upstream call path needs to handle a "filtered" return. |
| **C** | Compute `seq_lens` and `prefix_lens` consistently with the correction | vendored SGLang `schedule_batch.py:1588-1590` | After correction, recompute `seq_lens = [len(r.fill_ids[:pre_len] + extension)]` or align both sides. | Risky; affects all downstream tensor sizing. |
| **D** | Workaround: disable session migration in E3 (the trigger combination) | our `cli` flag `--kvcache-migration-reject-threshold 0` | One-line config change in `sweep_e3_*.sh`. | Doesn't actually fix the crash — session 1000195 didn't migrate. May reduce but not eliminate. Might still hit it on a different session. |
| **E** | Workaround: disable streaming session | server flag, remove `--enable-streaming-session` | Sidesteps the entire correction path. | Loses KVC's direct-to-D fast path (the central perf win we measure). Defeats the experiment. |

### Recommendation

**Fix A** — patch `schedule_batch.py:1646` to skip the malformed req before asserting. It's the minimal-blast-radius change and matches the apparent intent of the correction (graceful handling of the degenerate state).

Concretely:

```python
# Just before the assertion at line ~1646
if req.extend_input_len == 0:
    # The streaming-session correction zeroed extend_input_len because
    # prefix_indices already covers fill_ids. Skip this req from the
    # extend batch — its KV is already committed; nothing to compute.
    skip_indices.append(i)
    continue
```

Then the caller of `prepare_for_extend` needs to handle skipped requests (return them to the decode queue without an extend pass).

**Avoid Fix D/E** — D doesn't address the root cause (the failing session didn't migrate), and E loses the entire reason we're running this experiment.

---

## 4. Decision points for review

| # | Question | Default if no answer |
|---|---|---|
| D1 | Implement Fix A (vendor patch to skip zero-extend-len reqs)? | **Yes** |
| D2 | Re-run E3 with same K=200, same subset, after the fix? | Yes |
| D3 | Add a structural log entry every time the correction fires so we can track its frequency? | Recommended |
| D4 | File this as a separate `feat(sglang)` commit on the branch so the patch and the failure case it fixes are traceable? | Yes |

---

## 5. What this tells us about KVC v2 maturity

The load-floor bonus's first real exposure to the production codepath uncovered an existing patch bug that was masked by E2's failure cascade. This is good news: the failure cascade in E2 was hiding *another* layer of breakage. Without rebalancing, sessions cap-out → cascade → never run long enough to commit deep prefixes → never hit the streaming-session correction → never crash. With rebalancing, sessions DO commit deep prefixes → trigger the correction → crash.

Each fix tends to expose the next-shallowest bug. This is expected for a stack of ~6 interacting subsystems (kv-aware policy, KVC admission, session_aware_cache, streaming session, mooncake transfer, prefill batch prep). The path forward is to keep patching, re-running, and pushing the failure boundary out.
