Skip to content

Feat/internvla n1 dualvln - #2

Open
hungho77 wants to merge 27 commits into
mainfrom
feat/internvla-n1-dualvln
Open

Feat/internvla n1 dualvln#2
hungho77 wants to merge 27 commits into
mainfrom
feat/internvla-n1-dualvln

Conversation

@hungho77

Copy link
Copy Markdown
Collaborator

Add internvla-n1-dualvln recipe: FP8 quantization + TensorRT deployment on Jetson Thor

Adds a complete recipe for InternVLA-N1-DualVLN, a dual-system vision-language
navigation model, targeting Jetson Thor (sm_110, JetPack 7.1, TensorRT 10.13.3.9).
27 commits, flake8 clean under the repo config.

The model is two systems: System 2 is a Qwen2.5-VL-7B planner (the part that gets
quantized), System 1 is a NextDiT diffusion trajectory head plus a memory block
(DepthAnythingV2 + MemoryEncoder + QFormer). They are bridged by z_latents — the
last-layer hidden states of 4 TRAJ tokens through a host-side norm and cond_projector.

That bridge, not text quality, is the acceptance gate. A checkpoint can produce fluent
captions and still be useless for navigation; NVFP4 does exactly that. Every scheme here is
judged on z_latents cosine > 0.99, not on fluency.

The design decision that shapes the recipe

The pipeline repackages first: pure safetensors surgery drops the 8 System-1 prefixes and
rewrites config.json to model_type: qwen2_5_vl. After that step, quantize / export /
build / verify all operate on a stock Qwen2.5-VL checkpoint and never import InternNav — so
the whole System-2 path is an ordinary VLM flow a reader can follow without knowing InternVLA.

Step Needs INTERNNAV_PATH?
fetch calibration scenes no
repackage System 2 no — pure file surgery
quantize no — stock Qwen2.5-VL
export + build System 2 no
export System 1, agent-level checks yes

Results

All measured on one Jetson Thor, batch 1, idle GPU.

System 2 — Qwen2.5-VL-7B planner (quantized)

Variant weights / activations KV checkpoint LLM engine prefill decode z_latents pixel L2 median
BF16 baseline BF16 W16A16 → FP16 engine FP16 16.6 GB 14.15 GB 135.8 ms 56.4 ms 0.999471 27.05 px
FP8 s1 FP8 E4M3, W8A8, per-channel FP16 10.1 GB 7.62 GB 82.1 ms 31.5 ms 0.991861 22.51 px
NVFP4 s1 (experimental) NVFP4 E2M1, W4A4, block 16 FP16 7.2 GB 4.77 GB 73.2 ms 20.2 ms 0.931005 ✗ 23.54 px

KV stays FP16 everywhere: NVFP4 KV needs sm100f (datacenter Blackwell) and Thor is sm110.
The vision tower stays BF16 under s1; quantizing it is s3/s4 and is FP8-only, because
the ViT MLP intermediate_size is 3420 and 3420 / 16 = 213.75 does not divide by the NVFP4
block size. The scheme × strategy matrix in configs/schemes.yaml rejects the impossible
combinations early, with that arithmetic in the error text.

System 1 — NextDiT head + memory block (BF16)

Component ONNX engine latency cosine vs PyTorch
memory block 200 MB 104 MB 2.04 ms 0.999981
traj_dit, one diffusion step 134 MB 72 MB 5.85 ms 0.999508
full trajectory (10 steps × 32 samples) 61.8 ms 0.999670
System 1 total 334 MB 176 MB 63.8 ms · 15.7 Hz
PyTorch generate_traj baseline 175.4 ms · 5.7 Hz

Upstream InternNav ships no ONNX or TensorRT path at all — this conversion is entirely
the recipe's. It gives System 1 a 2.75× speedup at identical output. PyTorch latency is
flat in num_sample_trajs (175.4 / 175.6 / 173.1 ms at 32 / 4 / 1), so the head is
launch-bound, which is why moving it to engines pays.

Both systems, one planning step

Configuration System 2 System 1 total vs PyTorch
all PyTorch (BF16) 1631 ms 175.4 ms 1806 ms 1.00×
TensorRT, unquantized (FP16) 770 ms 63.8 ms 834 ms 2.17×
TensorRT, recommended (System 2 FP8) 646 ms 63.8 ms 710 ms 2.54×

Deployed weights for the recommended configuration: 9.16 GB, against 15.7 GB unquantized.

Findings worth reading

FP8 is the recommended scheme. 1.86× smaller, 1.65×/1.79× faster, bridge at 0.9919, and
the median waypoint error does not get worse (27.05 → 22.51 px, which is within the spread of
a 42-sample set and should be read as "unchanged", not as a gain from quantization).

NVFP4's loss is in the engine, not in the quantization. Decomposed: weights 0.012 +
activations 0.009 + engine 0.048. Measured directly by running the fake-quant model and
the engine in one process against the same inputs — engine vs its own fake quant is 0.998256
for FP8 and 0.986790 for NVFP4, 8× more error. A maxBatchSize 2 build gives an identical
0.986790, which refutes the batch-1 CASK-miscompile hypothesis; the residual is inherent to
the NVFP4 kernels here. This is also why AWQ, local-Hessian and QAT all failed to help: they
attack quantization quality (0.021) while the binding constraint is the engine (0.048).

FP8 on System 1 was measured and rejected. It works — 328/328 FP8 Q/DQ pairs in
traj_dit, 160/160 in the memory block, 1.55× smaller, 20 % faster on the diffusion loop —
and it is still the wrong trade: 62 MB is 0.7 % of deployed weights, 12 ms is 1.7 % of a
planning step, while mean waypoint deviation goes 0.0032 → 0.0198, a increase.
Splitting it shows why: quantizing traj_dit alone already costs 0.9997 → 0.9880, because
its per-step error (0.999508 → 0.997811) compounds across 10 sampler steps.

Domain-specific calibration bought nothing. Held-out z_latents came out 0.99143 with
generic cnn_dailymail text versus 0.99146 with a VLN set — equal within noise. An earlier
apparent gain was overlap between the calibration and probe sets. The VLN loader ships anyway
because it is the honest default for a navigation model, but the README says plainly not to
expect accuracy from it.

nvfp4_local_hessian is a silent no-op on this model — byte-identical output to the
default preset, 0 of 6,422,528 bytes differ.

Verification

Two acceptance gates, both exercised in CI-able form rather than assumed:

  • System 2: make verify-latents → z_latents cosine vs a PyTorch reference. Reproduces
    0.991861 on the FP8 engine.
  • System 1: make verify-system1 → trajectory parity. Reports 0.999670 and exits 0.

The System-1 check runs in two stages under two interpreters, and that is not incidental:
InternNav targets transformers 4.x while the TensorRT bindings ship for Python 3.12 where
transformers is 5.x, so no single environment has both. Stage A writes the PyTorch
reference's inputs and outputs to a .pt; stage B feeds the engines those same tensors, so
the comparison stays exact.

make repackage && make quantize-fp8 && make export-build && make verify-latents

make export-system1
PYTHON_PT=/path/to/py310/bin/python PYTHON_TRT=/path/to/py312/bin/python make verify-system1

…atrix

Adds recipes/internvla-n1-dualvln/, the front matter for porting a working FP8
quantization and TensorRT-Edge-LLM deployment pipeline for InternVLA-N1-DualVLN
on Jetson Thor. This commit is structure and documentation only; the Python and
shell entrypoints land in follow-ups.

The recipe is laid out along its dependency boundary rather than by file type.
InternVLA-N1-DualVLN declares model_type internvla_n1 and ships no modeling code,
so it cannot be loaded with trust_remote_code -- the class has to come from the
InternNav repository. A repackaging step strips the eight System-1 tensor prefixes
and rewrites config.json to a stock Qwen2.5-VL, using nothing but safetensors
surgery, which takes InternNav off the hot path for quantization, export, engine
build and latent verification. Only System-1 export and the agent-level checks
need INTERNNAV_PATH, and the layout says so per step.

configs/schemes.yaml encodes the scheme x strategy validity matrix so impossible
combinations are rejected before the model loads rather than forty minutes into a
run. Two entries are hardware facts: NVFP4 cannot touch the vision tower because
the ViT MLP intermediate_size is 3420 and 3420/16 is not an integer, and the KV
cache stays FP8 under NVFP4 weights because NVFP4 KV needs sm100f while Thor is
sm110. NVFP4 weights are marked experimental rather than supported: they quantize,
export and generate fluent text, but z_latents cosine falls to 0.647, which breaks
the System2 -> System1 bridge. For a navigation model the bridge is the acceptance
metric, not text fluency, so the README leads with that distinction.

The README also retracts an earlier claim carried in the source project that FP16
engines are broken on Thor and FP8 is therefore mandatory. The garbage came from a
Myelin fc_h_fusion miscompile at TensorRT 10.13; with the workaround applied the
unquantized FP16 engine is in fact the highest-fidelity variant measured. FP8 is
recommended on size and latency, not correctness.

numpy and scipy are deliberately absent from the pyproject extra. This recipe needs
numpy 1.x while three other extras pin numpy==2.2.6, so rather than assert a
resolution outcome that has not been tested here, they are installed as a documented
post-sync step. Note the repository already carries three mutually exclusive
transformers pins across extras, so this is not a new class of problem; run uv lock
and check before concluding anything about it.

Directory names avoid build/ and lib/, which the root .gitignore silently swallows.
A recipe-level .gitignore covers the multi-gigabyte engine and ONNX artifacts, which
nothing else in the repository excludes.
repackage_system2.py strips the eight System-1 tensor prefixes and rewrites config.json
to a stock Qwen2.5-VL. Verified on the real 16 GB InternVLA-N1-DualVLN checkpoint: 729
System-2 keys kept, 609 System-1 keys dropped, and the result loads through AutoConfig and
AutoProcessor as Qwen2_5_VLConfig / Qwen2_5_VLProcessor with no InternVLA fields left. That
is what takes InternNav off the hot path for everything except System-1 export.

Five bridge tensors (latent_queries, cond_projector.{0,2}.{weight,bias}) are written to a
separate bridge.safetensors instead of being dropped with the rest of System 1. They are
what the z_latents fidelity check consumes, so keeping them here lets quantize, export,
build and verify all run from the repackaged directory without reopening the 16 GB source.

quant_schemes.py loads configs/schemes.yaml and rejects impossible combinations before the
model is loaded, so a bad request costs seconds instead of dying mid-quantization. The
vision-tower check reads vision_config.intermediate_size from the checkpoint rather than
hardcoding 3420, so it stays correct for other Qwen2.5-VL sizes; on this checkpoint it
reports '3420 / 16 = 213.75' in the error. Verified against all four strategies and both
scheme families: fp8 passes everywhere, nvfp4 is blocked on s3/s4 and gated behind
--allow_experimental on s1/s2.

A free-space preflight refuses to start below 18 GB, and --free_source deletes each source
shard as its converted copy lands for machines that cannot hold both checkpoints.
…l loader

Completes the System-2 quantization path. Verified end to end on the real checkpoint:
16.6 GB repackaged System 2 -> 9.4 GB FP8, 1164 TensorQuantizers, 80s quantize plus
16s export, hf_quant_config reporting FP8 with lm_head and visual* excluded, and
tokenizer.model_max_length still 8192 (calibration truncates at 512 as a call-time
argument only; a leak there would silently cap inference sequence length).

model_loader.py drops the InternVLA-N1 branch entirely rather than carrying it forward.
Post-repackage the input is always a stock Qwen2.5-VL, so the branch, its two InternNav
imports, the diffusers gradient-checkpointing monkeypatch and the unused
_is_system2_checkpoint helper are all dead weight -- 70 lines removed, and with them the
dependency that would otherwise sit on the critical path of every quantization run.

quantize.py calls the validity gate before loading the model, so an impossible request
costs seconds. Confirmed: nvfp4+s3 is rejected in under a second with the checkpoint's own
'3420 / 16 = 213.75' and never touches the weights, while fp8+s1 loads in 14s and reports
sm_110. --cfg is renamed --scheme, kebab-case flags become snake_case to match the other
recipes, and the output tag no longer hardcodes qwen2.5-vl-7b.

calibration.py gains an offline path. This machine sits behind a TLS-intercepting gateway,
so load_dataset() fails certificate verification even though the parquet shards are already
in the hub cache -- and HF's own offline mode does not help, because it still wants the
dataset script. The loader now probes reachability and reads the cached shards directly,
which is the difference between a working and a non-working calibration run on any
air-gapped or proxied deployment machine, not just this one.
… scales applied

Accuracy benchmarking a quantized checkpoint in PyTorch needs it loaded correctly, and
the obvious way is silently wrong. export_hf_checkpoint writes LLM weights as real
torch.float8_e4m3fn with dequantization scales in separate weight_scale/input_scale
tensors. AutoModelForImageTextToText.from_pretrained on that directory appears to
succeed -- it warns the scale tensors 'were not used when initializing' and continues --
but every quantized weight then sits at its raw FP8 magnitude.

Measured on this checkpoint, the naive load produces weights 630x to 1799x too large
depending on the layer. Any accuracy number taken from it would be reporting a broken
load as quantization damage.

load_quantized.py reconstructs w_bf16 = w_fp8.to(bf16) * weight_scale over a normal
from_pretrained (which is what materialises the rope buffers; a meta-device build leaves
them empty). Verified against the unquantized System 2 in float64: cosine 0.99965 and
2.6-2.7% relative error per projection, which is what FP8 E4M3 per-tensor should cost,
against a scale ratio of exactly 1.000. Modules excluded from quantization -- the vision
tower and lm_head -- are stored bf16 and pass through untouched.

Note what this does and does not model: weight quantization only. Real FP8 W8A8 also
quantizes activations, which the TensorRT engine does and this does not, so a number from
here is a lower bound on the engine's deviation rather than a prediction of it. The
docstring says so, because the distinction is easy to lose.
… episodes

The official metrics (SR, SPL, NE, OS, nDTW) are closed-loop and need Habitat or
InternUtopia plus MP3D scenes. Neither is installed here and neither is practical on a
Jetson, so this measures what quantization actually threatens and what can be measured
offline: whether System 2 still picks the same waypoint.

Result on 42 held-out samples from two scenes, BF16 System 2 versus its FP8 quantization,
identical samples and greedy decoding:

  pixel_goal_l2   BF16 mean 47.24 px / median 27.05 px
                  FP8  mean 50.12 px / median 26.97 px
  agreement       31/42 replies byte-identical, median deviation 0.00 px

The mean moves 2.88 px while the median does not move at all, because the gap is carried
by 8 samples out of 42 rather than by a systematic shift -- worst case 89 px. That
distinction matters for a navigation model and is why both statistics are reported.

Two harness bugs were found by disbelieving the first numbers rather than publishing
them, and both are now prevented in code:

Feeding a pitched frame through a turn-1 prompt gave a 0% coordinate parse rate. The
model was right and the harness was wrong: the agent runs two turns, and the coordinate
only exists in turn 2, after the level view has been answered with a look-down token and
the tilted frame appended. The pixel goal in the level camera is the [-1,-1] sentinel on
every frame precisely because a level camera cannot see a point on the floor.

prompt_builder's 'episode_idx' is the step index within an episode, not the episode
number -- it derives the history frames from linspace(0, episode_idx-1). Passing the
episode number produced one image placeholder against nine images.

action_accuracy is now scored on turn 1 only. At turn 2 the model emits a coordinate by
design, so scoring it against a movement action reads 0% and looks like a regression.
…port/build script

README now leads the results with the number a navigation model is actually judged on:
FP8 keeps the median waypoint error unchanged (27.05 -> 26.97 px over 42 held-out samples)
with 31 of 42 replies byte-identical and a median deviation of 0.00 px. The 2.88 px gap in
the mean comes from 8 disagreeing samples rather than a systematic shift, so both
statistics are given; reporting the mean alone would overstate the damage and the median
alone would hide the tail.

It also states plainly what the measurement does not cover -- activations are quantized in
the engine but not on this PyTorch path, so the figure is a lower bound -- and that the
published SR/SPL/NE table is a literature reference, not something reproduced here, since
the closed-loop metrics need Habitat or InternUtopia and MP3D scenes.

03_export_build_system2.sh collapses the source project's four near-duplicate export
scripts into one, driven by --no_quantization for the FP16 reference. Two things it does
are load-bearing and carry comments saying so: emit_hidden_states=True before export,
without which the engine returns logits only and the System 1 bridge cannot be evaluated;
and __LUNOWUD=-peep:fc_h_fusion=off on the build, without which an FP16 engine on TensorRT
10.13 produces fluent gibberish from a Myelin miscompile.

The visual encoder is built once at 4/4096/1024 image tokens. The source project's default
of 512 came from a single-image demo and cannot hold a VLN prompt's 9-10 frames, which is
why its multi-image verifications silently pointed at a hand-built engine no script
produced.
…onfig.json

Two build-script defects found by running it rather than reading it.

visual_build appends its own 'visual' component directory under --engineDir, so passing
$ENGINE_DIR/visual produced $ENGINE_DIR/visual/visual/visual.engine. Every consumer looks
for $ENGINE_DIR/visual/visual.engine, so the engine was built correctly and then filed
where nothing would find it. Pass the parent instead.

llm_bench reads its engine configuration from base_config.json while llm_build writes
config.json -- same content, different name. Without the copy the benchmark refuses to
open the engine that was just handed to it, with a parse error that reads like engine
corruption rather than a missing filename.

Verified on the FP8 engine built from the repackaged System 2: llm.engine 7.62 GB (the
figure the source project recorded), visual.engine 1.36 GB, text smoke test coherent, and
llm_bench measuring 95.8 ms prefill at 1024 tokens and 37.35 ms decode at 1024 past-KV.
…ison

Both engines built from the same repackaged System 2 and measured with llm_bench on
Jetson Thor at batch 1:

              LLM engine   prefill 1024   decode pastKV 1024
  base FP16      15.0 GB      196.22 ms         86.16 ms
  FP8            7.62 GB       95.80 ms         37.35 ms
  gain        1.97x smaller   2.05x faster     2.31x faster

Both emit the same text on the same prompt, and the PyTorch-side accuracy run already
showed the median waypoint error unchanged, so FP8 here is a straight win rather than a
trade: half the engine, roughly double the throughput, same decision.

Worth recording that the FP16 engine is correct only because the build applies
__LUNOWUD=-peep:fc_h_fusion=off; the build log confirms both workaround flags were passed.
Without it TensorRT 10.13 miscompiles Myelin's horizontal gate/up fusion on sm_110 and the
engine produces fluent gibberish, which is what once made FP8 look mandatory on this
platform. It is not -- FP8 is chosen here on size and speed.
… both engines

verify_latents.py rebuilds the bridge end to end against a PyTorch BF16 reference --
embed, scatter image embeddings, append the four trajectory tokens, run the engine with
hand-built 3D mRoPE, take the last-layer hidden states, apply the host-side norm and
cond_projector -- and compares z_latents.

  engine       hidden pre-norm   post-norm    z_latents    rel-L2
  base FP16       0.999843        0.999123     0.999471    0.0293
  FP8 (s1)        0.997793        0.987343     0.991861    0.1023

Both clear the 0.99 gate, so FP8 preserves the signal System 1 actually consumes. This is
the check NVFP4 fails at 0.647 while still producing fluent text, which is why it ships as
experimental rather than supported.

The check reads latent_queries and cond_projector from the bridge.safetensors that
repackage_system2.py sets aside, so it needs neither the 16 GB original checkpoint nor
InternNav.

Four transformers 5.x incompatibilities were fixed with version-probing helpers rather
than a version pin, since the recipe should survive either: the vision tower and
get_rope_index moved onto the inner model; get_image_features returns a
BaseModelOutputWithPooling whose pooler_output holds the merged embeddings while
last_hidden_state is the pre-merger tensor at vision width; and get_rope_index gained a
required mm_token_type_ids argument. That last one does not fail on arity -- it fails
later inside with 'NoneType is not an iterator', which is a poor way to discover it.

Also switched the reference forward from flash_attention_2 to sdpa, which is what is
available on Jetson and what the deployed agent uses.
…nchmarks

Adds traj_dit and memory-block export, their BF16 engine builds, the System-1 fidelity
check and the two System-1 benchmarks, plus internvla_compat.py which carries the three
patches needed to load System 1 at all: the DepthAnythingV2 checkpoint path, the diffusers
gradient-checkpointing signature, and the traj_dit FFN multiplier. That last one is a real
checkpoint-compatibility finding -- DualVLN was trained with ffn_dim_multiplier 2/3, and
the stock build_traj_dit never passes it, so the state dict mismatches without the patch.

Four defects carried over from the source project are fixed here rather than propagated:
MemBlock was defined inline in the memory exporter as well as in memblock.py, so the two
copies could drift -- the exporter now imports the single definition; the traj_dit engine
filename said _async in one place and not the other, and neither constant was read; a dead
'if False else None' statement; and INTERNNAV_ROOT is renamed to the recipe-wide
INTERNNAV_PATH.

trt_torch.py keeps its original NVIDIA Apache-2.0 header rather than being restamped. It
is third-party code and Apache-2.0 section 4 requires retaining the notice; it is the one
file in this recipe that does not carry the VinRobotics BSD header.

Documented an environment constraint found by hitting it: System 1 must run under
transformers 4.51.3. The InternNav modeling code reads config.hidden_size off the
top-level config, which transformers 5.x no longer flattens, so it fails with a bare
AttributeError. The System-2 path is unaffected and runs on either version.
Both System-1 engines built and verified loadable with the expected I/O:

  traj_dit      134 MB ONNX -> 72 MB engine    x, timestep, z_latents -> output
  memory block  200 MB ONNX -> 104 MB engine   images -> memory_tokens

Sizes match what the source project recorded. Both stay BF16 on purpose: they are small
enough that quantizing them buys nothing, and the diffusion head is the part least
tolerant of it.

The memory exporter no longer exits 1 after a successful build. Its in-script parity check
imports the TensorRT Python bindings, which JetPack ships for Python 3.12 only, while the
export itself needs transformers 4.51 from the 3.10 environment -- so the check could never
run in the same interpreter as the export. It now skips with a message pointing at
verify/verify_system1.py under the 3.12 environment. Reporting failure for an artifact that
was produced correctly is worse than not running the check.

Two more environment gaps closed by installing rather than working around: diffusers 0.33.1
(the traj_dit scheduler) and setuptools<81 (InternNav's LongCLIP still imports
pkg_resources, which newer setuptools dropped).
…hmarks

Adds the five remaining fidelity checks, the System-2 benchmark, the simulator adapter and
its eval entry point. verify_engine_policy.py runs green on the FP8 engine: the adapter is
a proper InternVLAN1Net subclass adding only _engine_generate, and the engine's reply
survives the tokenizer roundtrip exactly.

lib/_trt_contract.py is deliberately not ported. It is dead code -- imported by nothing,
reading an export_metadata.json that no script writes, and referencing a different
project's exporter.

Defects fixed rather than carried across: two scripts still defaulted to one developer's
absolute layout under vln-thor/artifacts; benchmark_system2.py wrote reports/bench_e2e.json
without creating the directory, so it crashed on its own last line after doing all the
work; INTERNNAV_ROOT/VLN_OPT_WORK/VLN_OPT_ENGINES are renamed to the recipe-wide
INTERNNAV_PATH/WORK_DIR/ENGINE_DIR; nine Vietnamese comments translated; and seven files
restamped to the BSD header while trt_torch.py keeps its NVIDIA Apache-2.0 notice.

benchmark_system2.py depends on a golden manifest that neither this recipe nor the source
project generates. Instead of leaving that as a FileNotFoundError on the first read, it now
fails up front and describes the file it wants -- the same sample shape
quantize/benchmark_accuracy.py already builds from LeRobot episodes.

Scripts that need prompt_builder now locate the recipe root by walking up to the directory
containing quantize/, rather than counting parent levels: they sit at two different depths
and a fixed count works for one and not the other.
… not the weights

Measured in PyTorch on a real NVFP4 checkpoint of this model, weights only:

           weight rel-err   weight cos   z_latents
  FP8          2.67%         0.999644     0.998020
  NVFP4        9.45%         0.995534     0.987986

NVFP4 weight quantization costs 0.988, not 0.647. The weight error is 3.5x FP8's and the
bridge degrades roughly in proportion, which is unremarkable. So the 0.647 recorded for the
NVFP4 engine does not come from weight quantization, and the candidates are the two things
this measurement does not model: 4-bit activation quantization, and the engine itself.

The engine hypothesis is the one I would chase first. This platform has already produced
one 'quantization is broken' conclusion that turned out to be a TensorRT miscompile, and
there is a second known one specific to NVFP4 -- CASK miscompiling two or more fused
epilogues in an NVFP4 GEMM at batch 1, fixed by -cask_fusion:max_num_epilogues=1. A 0.647
taken from an engine built without that workaround would be measuring the miscompile rather
than the format.

Channel analysis rules out the obvious remedy for the weight-side loss: at the final layer
the top 128 channels by magnitude carry only 29.5% of the squared error, and masking them
lowers cosine rather than restoring it. The error is spread, not concentrated in outliers,
so AWQ scaling or a targeted exclusion has nothing to grip.

load_quantized.py gained NVFP4 support to make this measurable at all: unpacking two E2M1
values per byte with a per-16 FP8 block scale and a float32 global scale. The quantized path
now hands transformers an already-dequantized state_dict rather than letting it read the
checkpoint -- FP8 loads silently wrong without scales, and NVFP4 fails outright on the
halved width, so from_pretrained cannot be trusted with either.

NVFP4 stays experimental. The next step is to rebuild its engine with the CASK workaround
and re-measure end to end, not to do more weight analysis.
…t standard

The recipe carried 862 flake8 warnings while every existing recipe in this repository is
clean at zero. That gap is now closed. Most of it was VLN-Opt's dense one-liner style --
compound semicolon statements, missing whitespace, multi-import lines -- mechanically
fixable with autopep8 at the repo's 120-column setting.

Three defects were real, not cosmetic, and only surfaced because the lint pass ran:

  * benchmark_system2.py did not parse at all. A sys.path shim had been inserted directly
    above an import that lives inside a function, so its module-level indentation broke
    the enclosing block. The file had never been executed after that edit.
  * verify_latents_vln.py referenced json without importing it -- left behind when
    load_ckpt_tensor was rewritten to read bridge.safetensors, since the old body carried
    the import.
  * Six unused imports and one dead local.

E402 is silenced with explicit noqa rather than reordered: these modules must insert a
sys.path entry before importing prompt_builder or the InternNav tree, so the import
genuinely cannot come first.

Verified the reformatting changed no behaviour: the scheme validity gate still accepts
fp8+s1, rejects nvfp4+s3 with the checkpoint's own 3420/16 arithmetic, and gates nvfp4+s1
behind --allow_experimental; the dequantizing loader still reproduces cosine 0.999647 for
FP8 and 0.995489 for NVFP4 against the unquantized weights.

Separately confirmed the NVFP4 nibble order empirically rather than trusting the spec: the
low-nibble-first unpacking gives cosine 0.995485 against the reference weights while the
swapped order gives -0.002446, so the layout is not a coin flip that happened to land.
…t of its collapse

Rebuilding the NVFP4 engine with the fork's CASK workaround settles most of the question
the investigation opened. Three measurements, each isolating a layer:

  FP8, weights only (PyTorch)                 0.998020
  NVFP4, weights only (PyTorch)               0.987986
  NVFP4 engine, with the CASK workaround      0.931005
  NVFP4 engine, no workaround (source figure)     0.647

So the 0.647 was mostly a compiler artifact. -cask_fusion:max_num_epilogues=1 -- applied
automatically by the fork, gated to NVFP4 graphs at batch 1 -- moves it to 0.931, and the
build log confirms all three flags fired. Weight quantization was never the problem: NVFP4
weights cost 0.988, an error 3.5x FP8's with the bridge degrading in proportion.

A real gap remains between 0.988 and 0.931, and it now has a named suspect rather than
being a mystery: NVFP4 is W4A4, and 4-bit activations through a 3584-wide hidden state in
blocks of 16 are the part the PyTorch weights-only path cannot model.

The 0.647 is quoted from the source project and was not reproduced here. What is measured
here is that the same checkpoint reaches 0.931 once the workaround is applied.

NVFP4 stays experimental: 0.931 is below the 0.99 gate. But it is far from broken, and the
5.5 GB engine is the smallest of the three.

Adds 06_measure.sh to collect prefill/decode latency for every built engine into the JSON
run_matrix.py reads, and run_matrix.py itself, which assembles sizes, latency, bridge
fidelity and task accuracy into one table without recomputing anything.
All three variants built from one repackaged System 2 and measured on an idle GPU:

  variant   ckpt     LLM engine  prefill  decode   z_latents  pixel L2 mean/median
  BF16      16.6 GB    14.15 GB  135.8ms  56.4ms    0.999471   47.24 / 27.05 px
  FP8 s1    10.1 GB     7.62 GB   82.1ms  31.5ms    0.991861   46.26 / 22.51 px
  NVFP4 s1   7.2 GB     4.77 GB   73.2ms  20.2ms    0.931005   40.69 / 23.54 px

FP8 is the recommendation: 1.86x smaller and 1.65x/1.79x faster than BF16, bridge held at
0.9919, and the median waypoint error does not worsen. It reads slightly better (27.05 ->
22.51 px), which for 42 samples should be read as unchanged rather than as a gain from
quantization. NVFP4 is smaller and faster still but its bridge sits at 0.931, below the
gate, so it stays experimental.

Two caveats are documented alongside the table because both cost time to learn here.

Latency must be measured on an idle GPU: sharing the device with another job inflated FP8
prefill from 82 ms to 117 ms, and nothing in the scripts enforces this.

The 'identical replies' count is a weak indicator and is now labelled as such. It moved
from 31/42 to 11/42 for FP8 between two revisions of the checkpoint loader while
pixel_goal_l2 barely moved. I initially attributed that to GPU contention; a clean re-run
reproduced the contended numbers exactly, so the cause was the loader change, not
contention. The current loader is verified correct -- all 729 weights in the loaded model
match the dequantized state dict -- so the current numbers stand. Greedy decoding over a
152k vocabulary flips on tiny logit differences; the L2 median is the number to trust.
AWQ does not help this model. End to end the bridge measures 0.986293 against plain
NVFP4's 0.987986 -- the same within noise. That matches what the channel analysis
predicted: the error is spread across channels rather than carried by outliers, so AWQ's
per-channel rescaling has nothing to grip.

Getting to that number needed two loader fixes, and the first reading was wrong in a way
worth recording.

AWQ-lite scales activations by a per-input-channel factor and stores the weight
pre-divided by it, so a plain matmul has to multiply it back in. Ignoring the
pre_quant_scale tensors entirely produced ~190% relative weight error, which reads as AWQ
being catastrophically broken rather than being loaded wrong. The direction is also easy
to invert: verified empirically on this checkpoint that multiplying gives cosine 0.9898,
leaving it alone 0.9606, and dividing 0.8089.

The second is subtler and invalidates a metric rather than a number. Only down_proj and
o_proj carry an explicit pre_quant_scale, because q/k/v share one layernorm and gate/up
share another -- for those, AWQ folds the scale into the layernorm weight instead.
Measured here, AWQ moves the layernorm weights by 0.012x to 91x while plain NVFP4 leaves
them at exactly 1.000. So comparing AWQ's linear weights against the reference's linear
weights is not a comparison at all; the invariant is the composition. The weights stage
now detects AWQ checkpoints and skips with that explanation rather than reporting a
meaningless 180%.

qat.py adds quantization-aware fine-tuning, which is the remaining lever: the gap between
NVFP4 weights-only (0.988) and the engine (0.931) is 4-bit activations, and no weight-side
method reaches those after the fact. ModelOpt has no separate QAT entry point -- it is
mtq.quantize followed by ordinary fine-tuning with the fake-quantizers left in so
gradients pass through them.

It excludes scene YmJkqBEsHnH from training by default: the same MP3D building appears as
calib_scenes/r2r and probe_heldout/rxr, and the source project has already reported one
calibration gain that turned out to be exactly this leak. --allow_overlap disables the
guard for anyone who wants to measure the effect deliberately.

Note that success rate cannot be measured on this machine -- SR, SPL and NE are all
closed-loop. QAT is evaluated on the proxies it can move, z_latents and pixel-goal L2, and
the docstring says so.
…weight-side gap

All three NVFP4 presets measure the same end to end against the unquantized reference:

  nvfp4_default         0.987986   baseline
  nvfp4_awq_full        0.986293   equal within noise
  nvfp4_local_hessian   0.987986   byte-identical to default

nvfp4_local_hessian is a no-op on this model, and it is worth stating plainly because it
fails silently. The preset genuinely differs -- algorithm={'method': 'local_hessian',
'fp8_scale_sweep': True} against 'max' -- and the run exits 0, but the exported weights
match nvfp4_default bit for bit: 0 of 6,422,528 bytes differ, scales included. Two runs
with different algorithms cannot produce identical output unless the algorithm never ran.
It stays selectable via --scheme, so a user would reasonably believe they had tried it.

AWQ does run, its weights genuinely differ, and it still does not help -- which is what the
channel analysis predicted: the error is spread across channels rather than carried by
outliers, so per-channel rescaling has nothing to grip.

Three weight-side methods stopping at the same 0.988 is the argument for QAT rather than a
fourth. The gap from there to the engine's 0.931 is 4-bit activations, and no
post-training method reaches those.
…ne run

Adds quantization-aware fine-tuning and reports what it actually did, which was make
things worse:

                    z_latents (engine)   pixel L2 mean / median
  NVFP4 PTQ              0.931005          40.69 / 23.54 px
  NVFP4 + QAT            0.891583          41.86 / 23.16 px

The training loss rose from 0.78 to 1.13 over 16 steps, so the run pushed the weights the
wrong way rather than converging. That makes this a failed training run, not evidence that
QAT cannot help: the experiment never reached the question it was meant to answer. Saying
'QAT does not work for this model' from sixteen steps would be a conclusion the data does
not support, so the README says what would change next instead -- a much smaller learning
rate, hundreds of steps rather than sixteen, and a warmup.

Two obstacles are worth recording because both cost a run to find.

Full fine-tuning does not fit on this hardware. Weights plus gradients plus AdamW moments
for 8.29 B parameters are ~100 GB before any activations, against a 122 GB pool shared with
the host; the first attempt was killed by the OOM killer. --train_last_n_layers (default 4)
makes it fit at 932 M trainable parameters, and it targets the right place anyway, since
the bridge reads the last layer's hidden states.

Freezing combined with gradient checkpointing needs two fixes applied together. The
activations entering the first trainable layer carry no grad_fn, and reentrant
checkpointing then discards the graph, so loss.backward() fails with 'element 0 of tensors
does not require grad'. Both use_reentrant=False and enable_input_require_grads() are
required; either alone still fails.

Also documents a metric trap I walked into first: z_latents against the unquantized
reference is the wrong measure for QAT. PTQ approximates the original model, so similarity
to it is meaningful; QAT deliberately moves away from the original to absorb quantization
noise, so a good QAT run can lower that number while improving behaviour. QAT is judged
through the engine and on task accuracy instead.
…engine number

The results table carried two z_latents columns that measure different things, side by
side, with nothing saying so. That is a real reporting defect: it invites the reader to
treat 0.988 and 0.931 as disagreeing measurements of one quantity when they are correct
measurements of two.

load_for_eval reconstructs the weights into a plain model with no quantizers, so it
reproduces weight quantization error only -- activations stay bf16 and the matmuls are
bf16. But FP8 is W8A8 and NVFP4 is W4A4: the engine quantizes every activation too. The
weights-only path cannot see that half at all.

load_fake_quant now inserts live quantizers via mtq.quantize, so both halves are simulated
and a PyTorch number can be put next to an engine number honestly. load_for_eval stays for
weight-side questions, with its docstring saying which is which.

The README now shows the decomposition rather than the bare pair:

                weights only    engine    activation cost
  FP16               -         0.999471        -
  FP8            0.998020      0.991861      0.006
  NVFP4          0.987986      0.931005      0.057

The FP16 engine at 0.999471 is what makes this legible: TensorRT itself costs about 0.0005,
so almost none of the gap is export or runtime. Going from 8-bit activations to 4-bit costs
nine times more than going to 8-bit, which is the whole reason NVFP4 misses the gate here
while FP8 clears it.
…vation quantization

Measuring fake quant directly refutes what this recipe previously claimed. With live
quantizers in PyTorch -- weights and activations both simulated, as the engine does them --
NVFP4 measures 0.978631, against 0.987986 weights-only and 0.931005 through the engine.

That splits the 0.057 total as: weight quantization 0.012, activation quantization 0.009,
and everything else 0.048. Activations account for 16% of the gap. The remaining 84% only
appears once the model runs as a TensorRT engine.

I had attributed the whole gap to activations, reasoning from three numbers rather than
measuring the middle one. The reasoning was plausible -- NVFP4 is W4A4, 4-bit activations
through a 3584-wide hidden state are an obvious suspect -- and wrong. compare_fake_quant.py
exists to make that check cheap, and its docstring states both outcomes up front so it
cannot be run as a confirmation exercise.

FP8 has no comparable effect: its entire PyTorch-to-engine gap is 0.006 against NVFP4's
0.048, and the FP16 engine at 0.999471 bounds TensorRT's generic cost at ~0.0005. So this
is specific to the NVFP4 kernel path, the same neighbourhood as the CASK epilogue
miscompile that -cask_fusion:max_num_epilogues=1 improves from 0.647 to 0.931 without
evidently resolving.

The practical consequence changes what to do next. NVFP4 quantization on this model is
close to the gate at 0.9786; it is the engine that loses the rest. That is also why AWQ,
local-Hessian and QAT all failed to move it -- all three attack quantization quality, and
quantization quality was never the binding constraint.
…ut batch size

diagnose_engine_gap.py compares an engine against its own fake-quant model in one process
on the same inputs, so the difference is the engine alone rather than engine plus
quantization. Hidden states before the final norm, text prompt:

  FP8                                     0.998256
  NVFP4, maxBatchSize 1 + CASK cap        0.986790
  NVFP4, maxBatchSize 2, no CASK flag     0.986790

The NVFP4 engine carries roughly eight times FP8's engine-side error, 0.013 against 0.002,
and the bridge amplifies it -- 0.013 in hidden states becomes the 0.048 seen in z_latents
after the final norm, GELU and cond_projector.

It is not the batch-1 miscompile. The two NVFP4 engines are genuinely different builds
(different checksums, maxBatchSize 1 and 2, and the fork correctly withholds
-cask_fusion:max_num_epilogues=1 from the batch-2 build) yet measure identically to six
decimal places. So the CASK cap fully recovers what the batch-1 path loses, and the
residual is inherent to the NVFP4 kernels. My guess that a second batch-1 miscompile was
hiding here was wrong.

The FP8 control earned its place. The first version of this diagnostic compared against
output_hidden_states[-1] and read 0.4837 for NVFP4 -- which looks exactly like a broken
kernel. FP8 read 0.4949 through the same path, and FP8 is known good at 0.9919, so the
harness was wrong: the engine emits hidden states before the final norm while that tensor
is after it. Hooking the norm and taking its input moves FP8 to 0.9983. Without the
control I would have reported a broken NVFP4 kernel path for the second time in this
investigation.
…sorRT

Checked upstream first, as asked: InternNav ships no TensorRT or ONNX export at all --
nothing under internnav/ references trtexec, tensorrt or torch.onnx. The System-1
conversion is entirely this recipe's, inherited from the source project.

Two engines carry the compute, and both were verified by execution rather than by
inspection:

  traj_dit (NextDiT head)   134 MB ONNX -> 72 MB engine
    x[64,32,384] f32, timestep[64] i64, z_latents[64,*,768] f32 -> output[64,32,384]
  memory block (DAv2 + MemoryEncoder + QFormer)  200 MB -> 104 MB
    images[T,3,224,224] f32 -> memory_tokens[1,32,768]

Finite output, correct shapes. Worth noting the engine I/O is fp32/int64 despite BF16
weights; feeding bf16 trips an assertion in the wrapper rather than converting.

action_encoder, action_decoder, pos_encoding, cond_projector and the flow-matching
scheduler loop stay in PyTorch on the host. That is a design choice, not an omission --
they are tiny or control-flow heavy.

The end-to-end parity check remains unrunnable here and the README now says why rather
than leaving it as 'ready'. verify_system1.py needs InternNav and TensorRT in one
interpreter. InternNav targets transformers 4.x: under 5.x it fails first on
config.hidden_size, which transformers now nests under text_config -- patched in
internvla_compat.patch_config_flattening -- and then on apply_chunking_to_forward, removed
from modeling_utils. That chain has no natural end, and downgrading transformers inside the
TensorRT environment risks the edgellm exporter that currently works. Running the check
needs a fourth environment with transformers 4.51 and the TensorRT bindings together.
…stages

The parity check needs InternNav (transformers 4.x, Python 3.10) and the TensorRT
bindings (Python 3.12) at once, which no interpreter here has. Splitting it across
the two environments removes the conflict: stage A dumps the PyTorch reference's
inputs and outputs to a .pt, stage B feeds the engines those same tensors.

Both engines reproduce PyTorch:

  memory_tokens          0.999981
  traj_dit, one step     0.999508
  full trajectory        0.999670  (rel-L2 0.0258)

Stage A also captures the reference's starting noise and one real traj_dit call.
Both matter: generate_traj draws its latents mid-function, so reseeding gives a
different valid trajectory (cosine ~0.31), and the single-step probe is what
separates a bad engine from a bad reimplementation of the sampler loop.

MemBlock expects ResNet-normalized input because generate_traj normalizes before
rgb_model; passing raw pixels made memory_tokens read 0.315 while each half stayed
internally consistent.
…stems

The results section reported System 2 and System 1 separately, with no dtype column
and no System-1 latency at all, so nothing said what a whole planning step costs.

Adds System-1 latency (stage B now takes --bench_iters) and the PyTorch baseline to
compare it against, then restructures the matrix into three tables: System 2 by
quantization scheme, System 1 as a conversion result, and both together per planning
step. Every row names its dtype -- weights, activations, KV cache and vision tower
separately, since s1 leaves the last two alone.

  System 1 engines   63.1 ms vs 175.4 ms in PyTorch      2.78x
  full step, FP8     709 ms vs 1806 ms all-PyTorch       2.55x
  weights, FP8       9.16 GB vs 15.7 GB unquantized

Latency is flat in num_sample_trajs on the PyTorch side (175.4 / 175.6 / 173.1 ms at
32 / 4 / 1), so System 1 is launch-bound, which is why engines pay there.

Also drops the stale duplicate table and the superseded 12-sample figures, and fixes
the stage B GELU to approximate="tanh" to match the checkpoint's cond_projector.
System 1 shipped BF16 on the assumption that quantizing it was not worth it. This
measures that instead of assuming it, and the assumption holds -- for a reason that
is worth writing down.

Unlike System 2, System 1 goes torch.onnx.export -> trtexec, and TensorRT does FP8
only through explicit quantization, so the Q/DQ has to be in the ONNX. Adds a
ModelOpt PTQ pass calibrated on real tensors captured from a live System 2 -> System 1
run rather than random draws: FP8 scales are amax-based and both modules consume
tensors whose scale is set upstream, so a synthetic draw is quietly wrong.

FP8 works -- 328/328 Q/DQ pairs in traj_dit, 160/160 in the memory block, 1.55x
smaller, 20% faster on the diffusion loop -- and is still the wrong trade:

  engines      176 MB -> 114 MB      0.7% of 9.16 GB deployed
  step         710 ms -> 698 ms      1.7%, System 2 dominates
  waypoint dev 0.0032 -> 0.0198      6x, p95 39% of a waypoint's reach

Splitting it shows where the loss comes from: traj_dit alone costs 0.9997 -> 0.9880
even though its per-step error is only 0.999508 -> 0.997811, because the sampler runs
10 steps and each feeds the next. The memory block adds the rest and buys nothing in
latency (2.09 vs 2.04 ms).

Adds waypoint deviation to the parity check -- cosine says how aligned two
trajectories are, not how far apart the robot ends up -- plus --engine_suffix so the
same check runs against either precision.

Three things that fail quietly, all now handled: ModelOpt emits trt::TRT_FP8*, not
ONNX QuantizeLinear, so a naive counter reports a quantized graph as unquantized; the
legacy exporter cannot infer a conv kernel shape through Q/DQ; and the QFormer's fused
MHA fast path has no ONNX symbolic.
Six of the seven scripts the Makefile invokes did not exist, so every entry point
failed at the first command -- including the README's own Quick Start. The Python
underneath was fine; only the shell wrappers were missing.

  00_fetch_calib_scenes.sh   pull a diverse InternData-N1 subset (gated dataset)
  01_repackage.sh            InternVLA -> stock Qwen2.5-VL System 2
  02_quantize.sh             one scheme x strategy, through the validity gate
  04_export_system1.sh       traj_dit + memory block -> BF16 engines
  05_verify.sh               both acceptance gates
  benchmark                  retargeted at 06_measure.sh, which exists and takes
                             the engine parent directory via environment, not flags

05_verify.sh --system1 runs the two-stage split, so it takes PYTHON_PT and PYTHON_TRT:
InternNav wants transformers 4.x and the TensorRT bindings ship for Python 3.12 where
transformers is 5.x, and nothing reconciles the two in one interpreter.

Exercised rather than assumed. The System 2 gate reproduces the README's 0.991861 on
the FP8 engine; the System 1 gate reports 0.999661 and exits 0; a missing checkpoint
and nvfp4 x s3 both fail with their reason.

Also fixes two README flags that never existed (--calib_data, --max_seq_len; the real
one is --calib_data_root) and drops the unassigned-owner line.
@hungho77
hungho77 requested a review from quangnd-vr August 14, 2026 09:13
@hungho77 hungho77 self-assigned this Aug 14, 2026
@hungho77 hungho77 added the enhancement New feature or request label Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant