Skip to content

RL logprob parity scripts: MaxText trainer vs vLLM sampler (Qwen3.5 35B/397B) - #5047

Draft
wenxindongwork wants to merge 2 commits into
mainfrom
wxd/rl-logprob-parity-scripts
Draft

RL logprob parity scripts: MaxText trainer vs vLLM sampler (Qwen3.5 35B/397B)#5047
wenxindongwork wants to merge 2 commits into
mainfrom
wxd/rl-logprob-parity-scripts

Conversation

@wenxindongwork

Copy link
Copy Markdown
Collaborator

Description

Experiment scripts that produced the MaxText-trainer vs vLLM-sampler logprob comparison tables (TIS band [0.999, 1.002] statistics), added under tools/rl_logprob_parity/.

Setup measured:

  • Trainer: MaxText Qwen3.5 (nnx), fsdp=8, bf16; fp8 rows via qwix fp8_full dynamic quantization.
  • Samplers: tpu-inference native (MODEL_IMPL_TYPE=vllm, attention DP-4 x TP-2, EP-8, bf16 KV, chunked prefill) and MaxText-in-vLLM (maxtext_vllm_adapter, DP-4 x TP-2, MoE TP-8).
  • Knobs: trainer/sampler dtype (bf16/bf16, bf16/fp8, fp8/fp8), expert routing replay (sampler's top-k expert ids fed into the trainer's RoutedMoE.get_topk, weights recomputed from the trainer's fp32 gate logits), depth (4 / 8 via logit lens, full model), prompt tokens vs sampled tokens (decode path).
  • Data: 8 x 512 real tokens; models Qwen3.5-35B-A3B (bf16 and FP8) and Qwen3.5-397B-A17B-FP8 (8-layer prefix).

Contents: the scripts for each table row (sampler side, trainer side, table builder), the env wrappers / TPU-retry loops, chain_matrix.sh with the exact invocation for every cell, and a README mapping rows to scripts and giving the run order.

These are experiment scripts, not tests: output directory, checkpoint and worktree paths are hard-coded for the host they ran on (documented in the README). Kept byte-identical to what produced the reported numbers.

Tests

Not applicable (no library code changed). Scripts were run on 8 x v7x to produce the tables; build_tables.py renders them from the saved npz outputs.

Checklist

Before submitting this PR, please make sure (put X in square brackets):

  • I have performed a self-review of my code.
  • I have necessary comments in my code, particularly in hard-to-understand areas.
  • I have run end-to-end tests tests and provided workload links above if applicable.
  • I have made or will make corresponding changes to the doc if needed.

…ables)

Experiment scripts that produced the MaxText-trainer vs vLLM-sampler logprob
comparison tables (tpu-inference native and MaxText-in-vLLM samplers; bf16/fp8;
expert routing replay; 4/8/full layers; prompt and sampled tokens), with the env
wrappers and a README mapping each table row to its script.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a suite of experimental scripts and utilities under tools/rl_logprob_parity/ to analyze and compare log-probability parity between the MaxText trainer and the vLLM sampler for Qwen3.5 models. The feedback highlights several critical issues and areas for improvement: a referenced script (run_397b_layer.py) is missing from the repository, potential runtime crashes and NaN propagation could occur under certain edge cases in maxtext_score.py and compare_replay.py, helper class D is duplicated in build_tables.py, and several shell scripts contain fragile, non-portable hardcoded absolute paths and process IDs.

Comment thread tools/rl_logprob_parity/run.sh Outdated
export OUT_DIR=/mnt/disks/persist/pr4925_repro
mkdir -p $OUT_DIR
cd $MAXTEXT_WT
exec python /home/wenxindong_google_com/.claude/jobs/7ea88918/tmp/run_397b_layer.py "$@"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The script run_397b_layer.py is executed here, but it is completely missing from the repository and this pull request. Anyone attempting to run run.sh will encounter a 'file not found' error. Please ensure that run_397b_layer.py is added to the repository or update the script to point to the correct file.

Comment on lines +42 to +49
rows = routed.shape[1]
for b in range(B):
n = min(rows, L); ridx[:, b, :n, :] = routed[b, :n, :, :K].transpose(1, 0, 2)
if os.environ.get("GEN_ONLY_REPLAY") == "1":
ridx[:, :, :S, :] = -1 # own routing on all prefill rows 0..S-1 (row S-1 predicts the first decode token and is a prefill row)
zero_rows = np.all(routed[:, :, :, :K] == 0, axis=(2, 3)) # engine zero-fills rows it did not capture (prefill under attn_dp+chunked prefill)
for b in range(B):
n = min(rows, L); ridx[:, b, :n, :][:, zero_rows[b, :n], :] = -1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the routed experts array is empty or has mismatched shapes (which can happen if any r is None or shapes mismatch in vllm_generate.py), routed is saved as an empty 1D array. In that case, accessing routed.shape[1] will raise an IndexError, and slicing routed[:, :, :, :K] will crash. Wrapping this block in a dimension check prevents a runtime crash.

Suggested change
rows = routed.shape[1]
for b in range(B):
n = min(rows, L); ridx[:, b, :n, :] = routed[b, :n, :, :K].transpose(1, 0, 2)
if os.environ.get("GEN_ONLY_REPLAY") == "1":
ridx[:, :, :S, :] = -1 # own routing on all prefill rows 0..S-1 (row S-1 predicts the first decode token and is a prefill row)
zero_rows = np.all(routed[:, :, :, :K] == 0, axis=(2, 3)) # engine zero-fills rows it did not capture (prefill under attn_dp+chunked prefill)
for b in range(B):
n = min(rows, L); ridx[:, b, :n, :][:, zero_rows[b, :n], :] = -1
if routed.ndim >= 4:
rows = routed.shape[1]
for b in range(B):
n = min(rows, L); ridx[:, b, :n, :] = routed[b, :n, :, :K].transpose(1, 0, 2)
if os.environ.get("GEN_ONLY_REPLAY") == "1":
ridx[:, :, :S, :] = -1 # own routing on all prefill rows 0..S-1 (row S-1 predicts the first decode token and is a prefill row)
zero_rows = np.all(routed[:, :, :, :K] == 0, axis=(2, 3)) # engine zero-fills rows it did not capture (prefill under attn_dp+chunked prefill)
for b in range(B):
n = min(rows, L); ridx[:, b, :n, :][:, zero_rows[b, :n], :] = -1
else:
log("Warning: routed experts array is empty or invalid; skipping replay index population.")

Comment on lines +23 to +24
Pt = mt[f"{mode}_lens{k}_logprobs_2seq"]; Pi = tx[f"lens{k}_logprobs_2seq"]
kl = np.sum(np.exp(Pt) * (Pt - Pi), -1) # KL(train || sampler) per token, first 2 sequences

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If any log-probability in Pt is -inf (which is common for masked tokens or padding), np.exp(Pt) * (Pt - Pi) will produce NaN because 0 * -inf is undefined in NumPy. This will propagate NaN to the mean and max KL divergence values. Using np.where to safely ignore -inf values prevents this issue.

Suggested change
Pt = mt[f"{mode}_lens{k}_logprobs_2seq"]; Pi = tx[f"lens{k}_logprobs_2seq"]
kl = np.sum(np.exp(Pt) * (Pt - Pi), -1) # KL(train || sampler) per token, first 2 sequences
Pt = mt[f"{mode}_lens{k}_logprobs_2seq"]; Pi = tx[f"lens{k}_logprobs_2seq"]
kl = np.sum(np.where(Pt > -1e9, np.exp(Pt) * (Pt - Pi), 0.0), -1) # KL(train || sampler) per token, first 2 sequences

return f"— | — | — | — | layer-contribution error: relL2 {np.linalg.norm(a-b)/np.linalg.norm(a):.1%}, tokens with contribution cos<0.999: {np.mean(c<0.999):.1%} (no logprob at depth 1);"
def load(p):
return np.load(p) if os.path.exists(p) else None
rows = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The helper class D is defined locally multiple times throughout this file. Defining it once at the module level is much cleaner and improves maintainability.

Suggested change
rows = []
class D(dict):
files = property(lambda self: list(self.keys()))
rows = []

Comment on lines +67 to +69
class D(dict):
files = property(lambda self: list(self.keys()))
m = D(m)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Remove the duplicate local definition of class D since it is now defined at the module level.

  m = D(m)

Comment on lines +78 to +80
class D(dict):
files = property(lambda self: list(self.keys()))
lens_rows("35B", "tpu-inference native", "bf16 / fp8", D(m), tx40f, D(m), depths=(40,))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Remove the duplicate local definition of class D since it is now defined at the module level.

  lens_rows("35B", "tpu-inference native", "bf16 / fp8", D(m), tx40f, D(m), depths=(40,))

Comment thread tools/rl_logprob_parity/wait_adapter.sh Outdated
for attempt in $(seq 1 12); do
while [ -n "$(sudo lsof -t /dev/vfio/* 2>/dev/null)" ]; do sleep 10; done
sleep 5; echo "attempt $attempt start $(date)" > /mnt/disks/persist/pr4925_repro/adapter.log
bash /home/wenxindong_google_com/.claude/jobs/7ea88918/tmp/run_adapter.sh >> /mnt/disks/persist/pr4925_repro/adapter.log 2>&1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using hardcoded absolute paths to a specific user's temporary directory (/home/wenxindong_google_com/...) makes the script non-portable and fragile. Using $(dirname "$0") allows the script to dynamically locate the companion script relative to its own location, making it instantly portable.

Suggested change
bash /home/wenxindong_google_com/.claude/jobs/7ea88918/tmp/run_adapter.sh >> /mnt/disks/persist/pr4925_repro/adapter.log 2>&1
bash "$(dirname "$0")/run_adapter.sh" >> /mnt/disks/persist/pr4925_repro/adapter.log 2>&1

Comment thread tools/rl_logprob_parity/chain2.sh Outdated
@@ -0,0 +1,8 @@
#!/bin/bash
while kill -0 3487041 2>/dev/null; do sleep 20; done; sleep 10
L=/mnt/disks/persist/pr4925_repro; T=/home/wenxindong_google_com/.claude/jobs/7ea88918/tmp

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the other scripts, using a hardcoded absolute path for T makes the script non-portable. Defining T dynamically using $(dirname "$0") ensures it works seamlessly on any host.

Suggested change
L=/mnt/disks/persist/pr4925_repro; T=/home/wenxindong_google_com/.claude/jobs/7ea88918/tmp
L=/mnt/disks/persist/pr4925_repro; T="$(dirname "$0")"

Comment thread tools/rl_logprob_parity/wait_real.sh Outdated
@@ -0,0 +1,6 @@
#!/bin/bash
while kill -0 1665431 2>/dev/null; do sleep 15; done

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Waiting on a hardcoded process ID (1665431) is extremely risky for reproducibility. If this PID happens to be active and reused by an unrelated process on the host machine, the script will hang indefinitely. Consider passing the PID as an argument or removing the hardcoded PID wait entirely.

Drop the real-engine native row script, replay diagnostics and the older chain
scripts (not used for the reported numbers). Replace the per-script env and
TPU-retry wrappers with run_maxtext.sh / run_vllm.sh / wait_tpu.sh, and record
the exact invocation for every table cell in run_all.sh. Guard build_tables.py
against the one npz whose producer is not part of this set.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant