Coding-agent workload generation, trace collection and analysis for the Spark-Infer project.
We want to know what a coding agent actually asks an LLM to do, in enough detail to design an inference engine for it.
The chain we build and measure:
Terminal-Bench a benchmark of real terminal/coding tasks
│
▼
Pi Coding Agent reads files, runs tests, edits code, calls the LLM
│ many times per task
▼
SGLang serves the model over an OpenAI-compatible API
│
▼
RadixArk/Qwen3.8-Flash-Next-NVFP4
│
▼
traces every LLM request, every tool call, with timing
│
▼
workload analysis context growth, potential prefix reuse, idle gaps,
concurrency, sub-agent structure
Local inference for coding agents does not look like chatbot inference:
pytest, compiling, installing packages. Those gaps are seconds to minutes
long — windows in which an engine could evict, offload to SSD, or prefetch.Numbers for each of these are exactly what the Spark-Infer engine work needs, and none of them can be guessed. So we measure them, store them in a stable format, and make the workload replayable against any engine.
"synthetic": true. ┌────────────────────────┐
│ Terminal-Bench task │ task definition + verifier
└───────────┬────────────┘
│ Harbor starts a container and hands over the instruction
▼
┌────────────────────────┐
│ Pi coding agent │ decides what to read, run and edit,
│ (inside the container)│ and when to call the LLM again
└───────────┬────────────┘
│ OpenAI-compatible HTTP
▼
┌────────────────────────┐
│ SparkBench proxy │ ◄── EVERY request/response recorded here
│ (on the host) │ timing, tokens, full bodies
└───────────┬────────────┘
│ OpenAI-compatible HTTP
▼
┌────────────────────────┐
│ SGLang │ the inference engine
└───────────┬────────────┘
▼
┌────────────────────────────────────────┐
│ RadixArk/Qwen3.8-Flash-Next-NVFP4 │
└────────────────────────────────────────┘
Terminal-Bench / Harbor — supplies the tasks and decides pass/fail. Harbor builds a container per task, runs an agent inside it, then runs a verifier. We do not modify Harbor; we register our agent with it.
Pi coding agent — the workload generator. Pi decides what files to read, what commands to run, what to edit, and when to call the LLM again. Its behaviour is the thing we are studying, so we never script it.
SGLang — hosts the model and does the actual inference. External service.
The SparkBench recording proxy — the heart of this repository. It sits between the agent and the engine and records every call at the HTTP boundary. This is the only place that can honestly measure:
| measurement | why the proxy |
|---|---|
| time to first token | it sees the first streamed chunk arrive |
| request concurrency | it knows how many calls are in flight at one instant |
| the exact prompt | needed for prefix analysis and faithful replay |
| server-reported token usage | usage comes from the engine, not the agent |
It also makes the benchmark agent-agnostic: swap Pi for another agent and the LLM-level trace keeps the same shape.
This repository's pipeline — connects everything and turns raw output into analysis:
raw (immutable) ──► parse ──► normalized schema ──► analysis
│
└──► replay against any engine
A longer version of this section, including how agents are attributed and how
the three kinds of concurrency differ, is in docs/architecture.md.
spark-infer-bench/
├── README.md this file
├── pyproject.toml package metadata + dependencies
├── .env.example template for your local settings
├── .gitignore keeps secrets, weights and traces out of git
│
├── configs/
│ ├── models/
│ │ └── qwen38_flash_next_nvfp4.yaml the research model + reference launch args
│ ├── backends/
│ │ ├── sglang.yaml primary backend
│ │ ├── vllm.yaml future / unverified
│ │ └── llamacpp.yaml future / cannot serve NVFP4
│ ├── agents/
│ │ └── pi.yaml how Pi is configured and launched
│ ├── benchmarks/
│ │ └── terminalbench.yaml Harbor dataset, flags, task ids
│ └── experiments/
│ └── concurrency.yaml the 1/2/3/5-session sweep
│
├── sparkbench/ the Python package
│ ├── __init__.py version + the fixed research model id
│ ├── cli.py every `sparkbench …` command
│ ├── util.py ids, time, JSONL io, provenance, formatting
│ ├── doctor.py read-only environment inspection
│ │
│ ├── config/
│ │ └── loader.py .env + YAML -> Settings (nothing is hardcoded)
│ │
│ ├── backends/
│ │ ├── base.py Backend interface + HealthResult
│ │ └── openai_compatible.py health check, tiny probe, raw POST for replay
│ │
│ ├── agents/
│ │ └── pi.py generates Pi's project-local config, runs Pi,
│ │ streams its JSON events to disk
│ │
│ ├── tracing/
│ │ ├── schema.py THE normalized trace format + validation
│ │ ├── proxy.py the recording proxy (ASGI app)
│ │ ├── collector.py orchestrates one run, writes raw traces
│ │ ├── parser.py raw (Pi/proxy) -> normalized schema
│ │ ├── prefix.py potential prefix reuse
│ │ ├── tokenizer.py model tokenizer, with an honest fallback
│ │ └── synthetic.py fake traces for testing the pipeline
│ │
│ ├── analysis/
│ │ ├── stats.py percentiles, describe()
│ │ ├── workload.py request/token stats, context growth
│ │ ├── tools.py tool categories, durations, idle gaps
│ │ ├── concurrency.py agent / request / sub-agent concurrency
│ │ └── summary.py assembles everything; report + JSON + CSV
│ │
│ ├── replay/
│ │ └── replay.py re-send a recorded workload to any engine
│ │
│ └── harbor/
│ └── pi_agent.py SparkBenchPiAgent: runs Pi inside a TB container
│
├── scripts/
│ ├── lib/common.sh .env loading, helpers (source, do not run)
│ ├── doctor.sh sparkbench doctor
│ ├── check_backend.sh sparkbench backend check
│ ├── start_sglang_qwen38.sh starts SGLang with the research model
│ ├── start_proxy.sh starts the recording proxy
│ ├── run_pi_smoke.sh experiment A: one Pi session, end to end
│ ├── run_terminalbench_smoke.sh experiment B: one Terminal-Bench task
│ ├── run_concurrency.sh experiment G: 1/2/3/5 session sweep
│ └── patch_harbor_upload_dir.sh optional fix for a known Harbor bug
│
├── examples/
│ ├── simple_repo_task/ a tiny repo with one real bug, for smoke tests
│ │ ├── README.md
│ │ ├── PROMPT.md the instruction handed to the agent
│ │ ├── textstats/analyze.py the library (contains the bug)
│ │ └── tests/test_analyze.py one failing test, six passing
│ └── synthetic_traces/
│ └── generate.py make a fake trace to exercise the pipeline
│
├── traces/
│ ├── raw/ immutable per-run output (git-ignored)
│ └── processed/ normalized traces (git-ignored)
│
├── results/ analysis output: summary.json, CSV, PNG
│
├── tests/ unit tests; no model, no network, no GPU
│ ├── conftest.py synthetic trace fixtures
│ ├── test_config.py .env parsing, settings precedence
│ ├── test_schema.py record derivation + validation
│ ├── test_parser.py tool categorisation, raw -> normalized
│ ├── test_pi_agent.py Pi config generation + command line
│ ├── test_prefix.py common-prefix maths, ratio definition
│ ├── test_analysis.py workload, tools, gaps, concurrency, summary
│ ├── test_proxy.py SSE parsing, agent labelling, headers
│ ├── test_replay.py replay loading and ordering
│ ├── test_harbor_agent.py container config generation
│ └── test_util.py ids, JSONL tolerance, formatting
│
└── docs/
├── architecture.md components, data flow, attribution
├── trace-format.md full schema reference with examples
├── pi-notes.md Pi specifics + what to verify
├── harbor-notes.md Harbor specifics + what to verify
└── metrics.md every metric defined, with caveats
| file | what it is for |
|---|---|
.env | the only file you must edit to point at your machine |
configs/backends/sglang.yaml | SGLang launch defaults (TP size, memory, context) |
configs/agents/pi.yaml | Pi's flags — check approve_args before an unattended run |
configs/benchmarks/terminalbench.yaml | Harbor flags that may differ in your version |
sparkbench/tracing/schema.py | if you need a new field in traces, add it here |
sparkbench/tracing/parser.py | the only file that knows vendor formats; fix upstream changes here |
sparkbench/harbor/pi_agent.py | the only file that touches Harbor's API |
scripts/start_sglang_qwen38.sh | starting the model server |
| dependency | needed for | why |
|---|---|---|
| Python ≥ 3.10 | everything | the sparkbench package |
| Node ≥ 20 + npm | the agent | Pi is a Node program |
| Pi | the agent | the coding agent whose workload we study |
| NVIDIA driver + GPU | serving the model | NVFP4 needs an NVIDIA FP4-capable stack |
| SGLang | serving the model | the only engine documented for this checkpoint |
| Hugging Face access | downloading weights | ~135 GB checkpoint |
| Docker | Terminal-Bench | tasks run in containers |
Harbor (uv tool install harbor) | Terminal-Bench | the task runner |
transformers (optional) | exact token counts | offline tokenization for prefix analysis |
matplotlib (optional) | figures | CSVs are always written regardless |
You do not need a GPU to develop on this repository. Parsing, analysis, prefix maths and the unit tests all run on a laptop against synthetic traces. You need the GPU only to actually serve the model.
# 1. clone
git clone https://github.com/bairixie/spark-infer-bench.git
cd spark-infer-bench
# 2. a virtual environment, so nothing is installed system-wide
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3. the benchmark package, in editable mode so your edits take effect
pip install -e .
# 4. optional extras
pip install -e '.[tokenizer]' # exact token counts (transformers)
pip install -e '.[plots]' # PNG figures (matplotlib)
pip install -e '.[dev]' # pytest
# or everything at once:
pip install -e '.[all]'
# 5. the coding agent (installs globally via npm)
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
pi --version
# 6. the benchmark runner (only needed for Terminal-Bench)
curl -LsSf https://astral.sh/uv/install.sh | sh
uv tool install harbor
harbor --version
SGLang is not installed by this package: it is large, hardware-specific,
and you may already have a build. Install it into the same environment (or a
separate one) following NVIDIA/SGLang instructions for your platform, and
make sure the build supports this model architecture (qwen4_exp).
Verify the install:
sparkbench --version
sparkbench doctor
RadixArk/Qwen3.8-Flash-Next-NVFP4
This is the research model for Spark-Infer. A different model has a
different tokenizer, a different context window and a completely different
KV/state footprint, so its workload is not comparable. If you ever do run
something else, sparkbench prints a warning and the run is stamped with the
model actually used.
| property | value |
|---|---|
| base checkpoint | Qwen/Qwen3.8-Flash-Next |
| quantization | NVFP4 (served via ModelOpt) |
| architecture | hybrid GDN + sparse attention, Mixture-of-Experts |
| context length | ~262,144 tokens |
| checkpoint size | ~135 GB |
| documented engine | SGLang (--quantization modelopt_fp4) |
| vLLM / llama.cpp | unverified — llama.cpp serves GGUF, which NVFP4 is not |
Storage. Allow ~150 GB free for weights plus the Hugging Face cache. Put the cache somewhere with room:
export HF_HOME=/data/hf
huggingface-cli login # or: export HF_TOKEN=hf_...
Memory. On a unified-memory machine (DGX Spark / GB10) the weights,
the KV/state cache and the rest of the system share one pool. That is what
--mem-fraction-static controls, and it is the first thing to lower if the
server dies at startup.
Why the architecture matters to us. This is not a plain dense-attention
model: the gated-delta-net (mamba-like) state is managed separately from the
attention KV cache, which is why SGLang has --mamba-* flags. When you later
reason about "KV cache" for this model, remember there are two kinds of state.
Work through these in order. Each step is independent and verifiable — if something fails, you know exactly which layer is at fault. Nothing here has been run for you.
You will end up using three terminals. Keep them open:
Terminal 1 SGLang (the model server)
Terminal 2 recording proxy (optional for step 9, required for step 12)
Terminal 3 everything else
git clone https://github.com/bairixie/spark-infer-bench.git
cd spark-infer-bench
python3 -m venv .venv
source .venv/bin/activate
Everything below assumes this environment is active.
pip install -e '.[all]'
sparkbench --version
-e (editable) means your edits to sparkbench/ take effect without
reinstalling.
./scripts/doctor.sh
This only inspects. It never installs or changes anything.
You should see a table of components with ok / warn / MISSING, then
either No blockers. or a list of blockers. Roughly:
status component detail matters for
------ ------------------- ----------------------------- ------------------
ok Python 3.11.9 (/…/.venv/bin/python) blocker
ok python: yaml 6.0.1 blocker
ok python: httpx 0.27.0 blocker
ok python: uvicorn 0.30.1 blocker
ok pi (coding agent) <some version> blocker
ok docker Docker version … needed-for: Terminal-Bench
MISSING harbor not found needed-for: Terminal-Bench
ok NVIDIA driver / GPUs 1 GPU(s): … needed-for: serving the model
warn disk free (HF cache) 90.0 GiB at /home/…/.cache/… needed-for: model download
Read it like this:
ok → stop and fix it; nothing will work.warn on optional Python packages is fine; you lose exact token counts or
plots, and the reports say so.Only needed to download the checkpoint.
export HF_HOME=/data/hf # somewhere with ~150 GB free
huggingface-cli login # paste a token with access to the repo
# or, non-interactively:
export HF_TOKEN=hf_xxxxxxxxxxxx
Optionally pre-download so the first server start is not also a 135 GB download:
huggingface-cli download RadixArk/Qwen3.8-Flash-Next-NVFP4 --local-dir /data/models/qwen38-nvfp4
Never put a real token in .env.example or any committed file.
.envcp .env.example .env
$EDITOR .env
A typical single-GPU local setup:
BACKEND=sglang
BASE_URL=http://127.0.0.1:30000/v1
MODEL_ID=RadixArk/Qwen3.8-Flash-Next-NVFP4
TOKENIZER_ID=RadixArk/Qwen3.8-Flash-Next-NVFP4
API_KEY=dummy
PROXY_HOST=0.0.0.0
PROXY_PORT=8900
PROXY_URL=http://127.0.0.1:8900/v1
PROXY_URL_IN_CONTAINER=http://host.docker.internal:8900/v1
# if you pre-downloaded the weights:
# MODEL_PATH=/data/models/qwen38-nvfp4
# HF_HOME=/data/hf
Check what the code actually resolved:
sparkbench config show
Look at the command before running anything:
./scripts/start_sglang_qwen38.sh --dry-run
Then start it for real:
TP_SIZE=1 ./scripts/start_sglang_qwen38.sh
What this does: runs pre-flight checks (is SGLang importable, how many GPUs
are visible, is the port free, does TP_SIZE exceed the GPU count), prints
the exact command, asks for confirmation, and then runs the server in the
foreground on port 30000.
What success looks like: SGLang logs model loading (this takes minutes for a 135 GB FP4 checkpoint — the first run also downloads it), then a line saying the server is ready and listening. Leave it running; Ctrl-C stops it.
Every flag is explained in SGLang flags explained.
Start with TP_SIZE equal to your GPU count.
sparkbench backend check --smoke
Expected shape (your values will differ):
Backend check
backend sglang
base_url http://127.0.0.1:30000/v1
configured model RadixArk/Qwen3.8-Flash-Next-NVFP4
endpoint reachable yes (HTTP 200)
models advertised (1):
RadixArk/Qwen3.8-Flash-Next-NVFP4 <- configured model
model visible yes
tiny generation ok (1.23s)
reply: 'ok'
usage: {'prompt_tokens': …, 'completion_tokens': …}
RESULT: backend looks usable
If the model list is empty or shows a different id, do not edit
MODEL_ID to match — find out why the server is serving something else.
./scripts/run_pi_smoke.sh
This checks the backend, starts the recording proxy in the background, copies
examples/simple_repo_task to a scratch working directory, runs Pi against
it once, then parses and analyses the trace.
The example repository contains a real, small bug: word_frequencies() does
not strip punctuation, so exactly one of seven tests fails. The agent is
asked to read the code, run the tests, fix the source (not the test), and
re-run.
While it runs you should see interleaved output like:
=== run smoke-20260915-101500 ===
task simple_repo_task
agent URL http://127.0.0.1:8900/v1
model RadixArk/Qwen3.8-Flash-Next-NVFP4
starting Pi ...
[pi] agent_start
[proxy] # 1 agent=main in=4211 out=96 ttft=0.42s lat=3.11s status=200
[pi] tool read textstats/analyze.py
[pi] tool done (ok)
[proxy] # 2 agent=main in=5820 out=140 ttft=0.55s lat=4.02s status=200
[pi] tool bash python -m pytest -q
...
Two things to watch for: [proxy] lines (proof that Pi is really talking to
your local model) and the in= numbers growing (context accumulation).
The prompt lives in examples/simple_repo_task/PROMPT.md; override it with
--prompt "..." or --prompt-file <path> if you want.
sparkbench list
ls traces/raw/<RUN_ID>/
traces/raw/<RUN_ID>/
run_meta.json what ran, where, with which versions and git commit
llm_calls.jsonl every LLM call with full bodies and timing
pi_events.jsonl Pi's JSON event stream
pi_stderr.log Pi's stderr
pi_session.jsonl copy of Pi's own session file
proxy.log proxy output
pi-home/agent/ the exact Pi config used
workdir/ the repo copy the agent edited
Quick look:
wc -l traces/raw/<RUN_ID>/llm_calls.jsonl
head -c 400 traces/raw/<RUN_ID>/llm_calls.jsonl
Also worth checking: did the agent actually fix the bug?
cd traces/raw/<RUN_ID>/workdir && python -m pytest -q; cd -
Already done by the script, but to repeat it:
sparkbench parse <RUN_ID>
sparkbench analyze <RUN_ID> --plots
parse turns raw output into the normalized schema at
traces/processed/<RUN_ID>.jsonl. analyze prints a report and writes
results/<RUN_ID>/ (summary.json, context_growth.csv, prefix.csv,
gaps.csv, requests.csv, tool_calls.csv, and PNGs with --plots).
See What success looks like for how to read the report.
First make Harbor itself work, with no model involved at all:
harbor --version
harbor run -d [email protected] -a oracle # the reference solution agent
If the oracle does not pass, the problem is Harbor or Docker, not this repository. Fix that first.
Then find a task id and check container→host networking:
harbor run --help # confirm the flag names for your version
sparkbench net suggest # candidate URLs for PROXY_URL_IN_CONTAINER
Now, Terminal 2, start the proxy and leave it running:
./scripts/start_proxy.sh
and in Terminal 3:
TASK_ID=<a-real-task-id> DRY_RUN=1 ./scripts/run_terminalbench_smoke.sh # look first
TASK_ID=<a-real-task-id> ./scripts/run_terminalbench_smoke.sh
The script verifies the proxy is up, prepares the trace directory, prints the
exact harbor run command, asks for confirmation, runs it, then finalizes,
parses and analyses.
Harbor writes its own results (ctrf.json, reward.txt) telling you whether
the task passed. Harbor's exit code is not the verdict.
sparkbench analyze <RUN_ID> --plots
Same report as step 11, with task success filled in from the verifier.
The whole pipeline can be exercised on synthetic data:
python examples/synthetic_traces/generate.py
sparkbench parse synthetic-001
sparkbench analyze synthetic-001 --plots
pytest
Nothing here touches a model, and every generated run is stamped
"synthetic": true. Use it to learn the commands and to check your install
before the GPU work starts — never as a source of results.
scripts/start_sglang_qwen38.sh builds this command. Defaults come from the
model card (configs/backends/sglang.yaml), except --tp.
| flag | default here | what it means | change it when |
|---|---|---|---|
--model-path | $MODEL_PATH or $MODEL_ID | the checkpoint | you pre-downloaded the weights → set MODEL_PATH |
--tp | 1 (card says 2) | tensor parallelism: how many GPUs the model is split across | must equal the GPUs you actually have. 1 GPU → TP_SIZE=1 |
--quantization | modelopt_fp4 | how to interpret the NVFP4 weights | almost never |
--fp4-gemm-backend | flashinfer_cutlass | which FP4 matrix-multiply kernels | if FlashInfer is missing/incompatible on your platform |
--page-size | 64 | tokens per KV cache page. Bigger pages = less bookkeeping, coarser reuse | tuning prefix-cache behaviour |
--mamba-scheduler-strategy | extra_buffer | how the GDN (mamba-like) state is scheduled — this model is hybrid, so that state is separate from attention KV | on memory pressure |
--mamba-track-interval | 64 | how often that state is tracked | rarely |
--chunked-prefill-size | 4096 | a long prompt is prefilled in chunks of this many tokens; keeps one huge request from blocking everything | lower if long prompts starve other requests; raise for prefill throughput |
--max-running-requests | 36 | how many requests the engine will run concurrently | lower on a small machine; our workload rarely needs 36 |
--context-length | 262144 | maximum context the server accepts | lower it (e.g. 65536) if you hit OOM — KV/state reservations scale with it |
--mem-fraction-static | 0.80 | fraction of memory reserved for weights + static allocations; the rest is the KV pool | lower (0.7, 0.6) if the server dies at startup; raise for a bigger cache |
--allow-auto-truncate | on | truncate over-long prompts instead of erroring | keep on: coding agents will eventually exceed the window |
--host / --port | 127.0.0.1 / 30000 | where it listens | must match BASE_URL in .env |
Anything else can be appended with EXTRA_ARGS="...".
On DGX Spark specifically, expect to revisit: TP_SIZE (GPU count),
MEM_FRACTION_STATIC (unified memory is shared with the system),
CONTEXT_LENGTH (262K reservations are expensive), and
FP4_GEMM_BACKEND (aarch64 kernel availability).
When SGLang and the proxy run on the host while Pi runs inside a
container (which is what Terminal-Bench does), localhost inside the
container is the container, not your machine. So the container cannot reach
http://127.0.0.1:8900/v1.
Two settings matter:
PROXY_HOST=0.0.0.0 (the default).PROXY_URL_IN_CONTAINER.Candidates, most portable first:
| situation | URL |
|---|---|
| Docker Desktop (macOS/Windows) | http://host.docker.internal:8900/v1 |
Linux, container started with --add-host=host.docker.internal:host-gateway | http://host.docker.internal:8900/v1 |
| Linux, default bridge | http://172.17.0.1:8900/v1 (check: ip addr show docker0) |
| Any container that can route to your host | http://<your-LAN-IP>:8900/v1 |
Linux, --network=host | http://127.0.0.1:8900/v1 |
sparkbench net suggest # prints candidates for THIS machine
With the proxy running:
# from the host — should print JSON
curl -sS http://127.0.0.1:8900/__sparkbench/health
# from inside a container — should print the same JSON
docker run --rm --add-host=host.docker.internal:host-gateway curlimages/curl \
-sS -m 5 http://host.docker.internal:8900/__sparkbench/health
Whichever URL works goes into PROXY_URL_IN_CONTAINER. Our Harbor agent runs
the same probe during setup and fails the run immediately if the
container cannot reach the proxy — an empty trace is worse than an error.
Also check your firewall: on Linux, a host firewall can silently drop container→host traffic on port 8900.
Exact numbers depend on your machine, the task and the model's behaviour, so none are given here. Judge by shape.
sparkbench doctorA table plus either No blockers. or a blocker list. Missing Harbor/Docker is
fine until step 12.
sparkbench backend checkendpoint reachable yes, the configured model listed under "models
advertised", model visible yes, and with --smoke a short reply and a
usage dictionary. Anything else means the server is not ready.
sparkbench run pi / run_pi_smoke.sh[proxy] #N … lines appear — Pi is using your local model.in= (prompt tokens) grows over the run — context is accumulating.pytest.Failure mode to watch for: Pi finishes quickly with no [proxy] lines.
That means it never called your model (see Troubleshooting).
sparkbench parseparsed traces/raw/<RUN_ID>
agents 1
llm requests 14
tool calls 22
wrote 38 records -> traces/processed/<RUN_ID>.jsonl
llm requests 0 is a failure, and the command says so explicitly.
sparkbench analyzeA report with these sections, all populated:
token_sources — you want server_usage.unit line:
tokens is a real measurement, chars means no tokenizer was available.test/build should
dominate total time if the task involves running tests.unexplained time is
worth investigating (agent overhead, or a stall).sparkbench replayA per-request progress line, then a summary comparing replay TTFT/latency
against the originals, and n_failed 0.
Three layers, three different purposes:
| layer | path | written by | edit? |
|---|---|---|---|
| raw | traces/raw/<RUN_ID>/ | the proxy, Pi, Harbor | never |
| processed | traces/processed/<RUN_ID>.jsonl | sparkbench parse | regenerate any time |
| derived | results/<RUN_ID>/ | sparkbench analyze | disposable |
Why both raw and processed? Raw is evidence: whatever the tools actually
emitted, including full request bodies. Replay needs it, and so will analyses
you have not thought of yet. Processed is a stable contract: Pi's event
names may change, our schema does not, so figures made today still work on
traces collected next semester. Only parser.py bridges the two.
llm_request — request_id, agent_id, start_ts, first_token_ts,
end_ts, ttft_s, latency_s, input_tokens, output_tokens,
context_length, cached_tokens, token_source, params, finish_reason,
error, prompt_sha256, body_ref.
tool_call — tool_name, category, command, start_ts, end_ts,
duration_s, success.
agent — agent_id, parent_agent_id, agent_type, start_ts, end_ts.
run — task, benchmark, backend, model, machine, versions, git commit, seed,
success.
body_ref (e.g. llm_calls.jsonl#7) points back into the raw recording; the
processed trace deliberately does not duplicate large prompt bodies.
{
"record_id": "req-000003-9f2c",
"agent_label": "main",
"endpoint": "/v1/chat/completions",
"modified_request": ["stream_options.include_usage"],
"request": {"model": "RadixArk/Qwen3.8-Flash-Next-NVFP4", "stream": true,
"n_messages": 7, "body": {"messages": ["..."]}},
"timing": {"t_start": 1789466400.12, "t_first_token": 1789466400.51,
"t_end": 1789466404.88, "ttft_s": 0.39, "latency_s": 4.76},
"response": {"status": 200, "finish_reason": "tool_calls",
"usage": {"prompt_tokens": 18422, "completion_tokens": 142}},
"tokens": {"prompt": 18422, "completion": 142, "source": "server_usage"}
}
{"record_type": "llm_request", "schema_version": 1,
"request_id": "req-000003-9f2c", "run_id": "smoke-20260915-101500",
"agent_id": "main", "agent_attribution": "label", "request_index": 2,
"model": "RadixArk/Qwen3.8-Flash-Next-NVFP4", "stream": true,
"start_ts": 1789466400.12, "first_token_ts": 1789466400.51,
"end_ts": 1789466404.88, "ttft_s": 0.39, "latency_s": 4.76, "decode_s": 4.37,
"input_tokens": 18422, "output_tokens": 142, "context_length": 18422,
"token_source": "server_usage", "finish_reason": "tool_calls",
"body_ref": "llm_calls.jsonl#3"}
modified_request is an honesty field: the proxy adds
stream_options.include_usage to streamed requests so the server reports
token counts, and every record says whether it did. Disable with
sparkbench proxy --no-inject-usage (you then lose token counts on streamed
calls).
Full reference: docs/trace-format.md.
In plain language:
Input tokens — how much context was sent to the model for one request. For a coding agent this is the dominant cost: the whole conversation so far, including every file the agent read and every test output.
Output tokens — how much the model generated. Usually far smaller than the input; a 10:1 or 20:1 input:output ratio is normal.
Context length — how much history a request carries. It grows turn by turn, which is why "long context" is not an edge case here but the default.
Potential prefix reuse — how much of a request's prompt is a character-for-character (token-for-token) prefix of an earlier request. If request B is request A plus one appended tool result, almost all of B's prompt is a prefix of A's, so an engine could in principle skip recomputing it. Reported two ways: sequential (versus the immediately preceding request) and best (versus the best matching earlier request, the upper bound for an unbounded radix cache).
This is potential reuse, an upper bound — not a cache hit rate. Whether the engine still holds those blocks depends on its cache size, eviction policy and what else ran in between. Real hit rates come out below these numbers, never above.
TTFT (time to first token) — how long the user waits before anything appears. Dominated by prefill, so it rises with prompt length and with queue depth.
Request latency — the whole request, first byte to last. Includes queueing, so it rises under concurrency even when the model is equally fast.
Tool duration — how long the agent spends outside inference: running tests, building, installing. This is the workload's duty cycle; for many coding tasks the agent spends more time in tools than waiting on the model.
Idle gap — the time between one request finishing and the next starting for the same agent. From the engine's point of view this agent is idle but still holding cache — the window in which offloading or eviction could pay off. The analysis also reports how much of each gap is explained by tool calls.
Agent concurrency — how many agents were alive at once.
Request concurrency — how many LLM requests were in flight at the server at one instant. This is what the engine actually schedules against, and it is measured, never assumed.
Sub-agent parallelism — how many child agents of one session ran at the
same time. max_parallel == 1 means the agent spawns children serially; > 1
means genuinely parallel branches.
Keep these three separate: a run with session concurrency 5 may still show request concurrency 2, because agents spend much of their time in tools. That gap between "5 agents" and "2 concurrent requests" is itself a finding.
Full definitions and caveats: docs/metrics.md.
All of these assume SGLang is running and sparkbench backend check passes.
./scripts/run_pi_smoke.sh
Purpose: prove the whole stack works end to end.
# Terminal 2
./scripts/start_proxy.sh
# Terminal 3
TASK_ID=<task-id> ./scripts/run_terminalbench_smoke.sh
Purpose: a real benchmark task with a verifier that says pass/fail.
./scripts/start_proxy.sh # Terminal 2, leave running
for t in task-a task-b task-c task-d task-e; do
TASK_ID=$t RUN_ID=tb-$t ./scripts/run_terminalbench_smoke.sh
done
sparkbench list
Purpose: enough runs to see whether workload shape is task-dependent.
Put the real task ids in configs/benchmarks/terminalbench.yaml
(small_task_set) so the set is recorded.
sparkbench analyze <RUN_ID> --plots
column -s, -t results/<RUN_ID>/context_growth.csv | head -20
Gives request index vs input tokens per agent, the growth factor, mean growth
per request, and context_growth.png.
pip install -e '.[tokenizer]' # needed for token-level numbers
sparkbench analyze <RUN_ID> --prefix-scope agent # within one agent
sparkbench analyze <RUN_ID> --prefix-scope run # across all agents
column -s, -t results/<RUN_ID>/prefix.csv | head -20
--prefix-scope run is the interesting one once sub-agents exist: it shows
how much a child could reuse from its parent.
Check the unit line in the report. chars means no tokenizer was loaded
and the numbers are only a relative signal.
sparkbench analyze <RUN_ID> --long-gap 5
column -s, -t results/<RUN_ID>/gaps.csv | head -20
Reports every idle window between LLM requests, which tools ran inside it,
and how much time tools do not explain. Lower --long-gap to catch shorter
windows; raise it to focus on the big ones.
Sub-agents are not required for the first runs, and Pi core ships without them. When you enable them (extension or Pi package):
# configs/agents/pi.yaml
# subagents:
# enabled: true
# extension: <name>
sparkbench analyze <RUN_ID> --prefix-scope run
The report's CONCURRENCY section then shows sub-agent count, max parallelism, lifetimes, and whether they ran in parallel or serially; CROSS-AGENT PROMPT OVERLAP shows parent→child and sibling prompt sharing.
To compare, run the same task twice — once with sub-agents disabled, once
enabled — and diff the two summary.json files.
./scripts/run_concurrency.sh # sweeps 1 2 3 5
# or one level at a time:
sparkbench run concurrency --level 3 --task examples/simple_repo_task --auto-parse
Each level launches N independent Pi sessions sharing one recording proxy, so request concurrency at the server is measured correctly. Compare levels:
python - <<'PY'
import json, glob
for f in sorted(glob.glob("results/conc*.json")):
d = json.load(open(f)); a = d["aggregate"]
print(f"{d['session_concurrency']} sessions: "
f"max_req_conc={a['request_concurrency_max']} "
f"mean={a['request_concurrency_mean']:.2f} "
f"wall={a['wall_clock_s']:.0f}s "
f"ttft_p95={(a['ttft_s']['p95'] or 0):.2f}s")
PY
The interesting question: how far below N does measured request concurrency sit, and how much does TTFT degrade as N rises?
# as fast as possible, one at a time
sparkbench replay <RUN_ID> --timing immediate --concurrency 1
# stress: same requests, 4 at a time
sparkbench replay <RUN_ID> --timing immediate --concurrency 4
# faithful: reproduce the agent's original think-time
sparkbench replay <RUN_ID> --timing original
# same shape, twice as fast
sparkbench replay <RUN_ID> --timing original --speed 2
Replay re-sends the exact recorded request bodies, so it turns a slow,
non-deterministic agent run into a fast, repeatable engine benchmark. Results
land in results/replay/….
--timing immediate inflates apparent prefix-cache benefit, because nothing
gets evicted during a 45-second pytest that no longer happens.
--timing original preserves those idle windows and is the honest comparison.
configs/backends/sglang.yaml to configs/backends/<engine>.yaml
and set base_url / model_id.sparkbench --backend <engine> backend checksparkbench --backend <engine> replay <RUN_ID> --timing originalBecause everything goes through the OpenAI-compatible abstraction, no benchmark code changes. Do not claim the NVFP4 checkpoint works on another engine until you have verified it: vLLM is unverified for this checkpoint, and llama.cpp serves GGUF, which NVFP4 is not.
General rule: find the lowest layer that is broken and fix that one.
The layers, bottom up: GPU/driver → SGLang → /v1/models → proxy → Pi →
Harbor/Docker → analysis. sparkbench doctor and sparkbench backend check
tell you where you are.
The repo may be gated, or your token lacks access.
huggingface-cli whoami
huggingface-cli download RadixArk/Qwen3.8-Flash-Next-NVFP4 --repo-type model --dry-run
Accept any terms on the model page, then re-login. Do not switch to a different model to get around it.
~135 GB of weights plus cache overhead.
df -h $HF_HOME ${HF_HOME:-~/.cache/huggingface}
huggingface-cli scan-cache
Point HF_HOME at a big volume and re-run the download — it resumes. A
truncated download often shows up later as a confusing safetensors error, so
verify the download completed before blaming SGLang.
qwen4_expThe architecture is new; your SGLang build must support it.
python -c "import sglang; print(sglang.__version__, sglang.__file__)"
python -m sglang.launch_server --help | head -40
Collect the full traceback — the useful line usually names an unknown architecture or a missing module. Upgrade SGLang to a build that supports this model family. Do not work around it by changing the model.
Symptoms: unknown quantization method modelopt_fp4, missing FP4 kernels, or
an import error from flashinfer.
python -c "import modelopt; print(modelopt.__version__)"
python -c "import flashinfer; print(flashinfer.__version__)"
nvidia-smi --query-gpu=name,driver_version --format=csv
These packages are tightly coupled to CUDA/driver versions, and aarch64
wheels lag x86. Try a different FP4_GEMM_BACKEND and record which backend
you used when reporting numbers.
nvidia-smi
echo $CUDA_VISIBLE_DEVICES
python -c "import torch; print(torch.cuda.is_available(), torch.cuda.device_count())"
TP_SIZE must not exceed the visible GPU count — the start script checks
this and refuses.
In order of effect:
MEM_FRACTION_STATIC=0.70 CONTEXT_LENGTH=65536 MAX_RUNNING_REQUESTS=8 \
./scripts/start_sglang_qwen38.sh
CONTEXT_LENGTH is usually the biggest lever: reserving 262K tokens of
KV/state is expensive. On unified-memory machines remember the system and the
model share one pool — close other memory-hungry processes. If OOM happens
mid-run rather than at startup, the agent's context has grown; lower
MAX_RUNNING_REQUESTS or CONTEXT_LENGTH.
pip may fail to find wheels for GPU-side packages.
uname -m # aarch64
pip debug --verbose | grep -i tag
Use NVIDIA's ARM builds/containers. sparkbench itself is pure Python and
installs anywhere.
lsof -i :30000 # SGLang
lsof -i :8900 # proxy
Change PORT / PROXY_PORT in .env (and keep BASE_URL / PROXY_URL in
sync).
/v1/models unavailablecurl -v http://127.0.0.1:30000/v1/models
Connection refused → the server is not up (or is still loading; watch
Terminal 1). Hangs → it is still loading the checkpoint. 404 → your
BASE_URL is missing or duplicating the /v1 path.
The server is running but advertises a different id. Look at what it lists and
at the --model-path you launched with. Do not change MODEL_ID in
.env to match unless you genuinely intend to change the research model.
The single most common failure. Check in this order:
# 1. is the proxy up?
curl -s http://127.0.0.1:8900/__sparkbench/health
# 2. does the generated Pi config point at the proxy?
cat traces/raw/<RUN_ID>/pi-home/agent/models.json # baseUrl must be the PROXY
# 3. can the proxy reach the engine?
sparkbench backend check
# 4. what did Pi say?
tail -40 traces/raw/<RUN_ID>/pi_stderr.log
# 5. does Pi see the provider at all?
PI_CODING_AGENT_DIR=$(pwd)/traces/raw/<RUN_ID>/pi-home/agent pi --list-models
Also verify PI_CODING_AGENT_DIR is honoured by your Pi version
(see docs/pi-notes.md); if it is not, Pi read your
personal ~/.pi config instead and may have called a cloud provider.
It is waiting for approval. Check the flag:
pi --help | grep -i approve
and fix run.approve_args in configs/agents/pi.yaml.
Often an unsupported field. The compat block in configs/agents/pi.yaml
already disables the developer role and reasoning_effort. To see the
offending body:
tail -1 traces/raw/<RUN_ID>/llm_calls.jsonl | python -m json.tool | head -40
The proxy records failures too, so the request that failed is in the trace.
See Docker networking and run the two curl checks
there. Confirm PROXY_HOST=0.0.0.0, and check any host firewall.
harbor run --help
harbor agent list
python -c "import sparkbench.harbor.pi_agent as m; print(m.__file__)"
PYTHONPATH=$(pwd) harbor run -d [email protected] \
--agent-import-path sparkbench.harbor.pi_agent:SparkBenchPiAgent -m openai/x -n 1
sparkbench must be importable by the Python that runs Harbor — PYTHONPATH
is the simplest fix. If the base class or environment.exec differs in your
version, only sparkbench/harbor/pi_agent.py needs editing; _exec() already
tries several spellings and tells you what to change.
First prove Harbor works without us:
harbor run -d [email protected] -a oracle
docker ps -a
docker system df # containers/images can fill the disk quickly
If verifiers fail because a tests/ directory got nested, see the known
upload_dir issue and scripts/patch_harbor_upload_dir.sh.
The report says unit: chars or token_source: estimate.
pip install -e '.[tokenizer]'
python -c "from transformers import AutoTokenizer; \
AutoTokenizer.from_pretrained('RadixArk/Qwen3.8-Flash-Next-NVFP4', trust_remote_code=True)"
If TOKENIZER_ID differs from MODEL_ID, sparkbench warns — the numbers
are then not comparable to the served model.
pi_events.jsonl is empty. Check --mode json is being used
(traces/raw/<RUN_ID>/run_meta.json records the exact command) and look at
pi_stderr.log.Send:
sparkbench doctor --json /tmp/doctor.json (attach the file);traces/raw/<RUN_ID>/run_meta.json;pi_stderr.log, proxy.log, or
the SGLang terminal;head -c 2000 traces/raw/<RUN_ID>/llm_calls.jsonl if it exists;sparkbench config show.That is almost always enough to identify the layer at fault. Redact tokens.
Work down the list. Each item is independently checkable, and they are in dependency order — if one fails, the ones below it will too.
[ ] pip install -e '.[all]' succeeds and `sparkbench --version` works
[ ] pytest passes (no model needed)
[ ] synthetic pipeline works:
python examples/synthetic_traces/generate.py
sparkbench parse synthetic-001 && sparkbench analyze synthetic-001
[ ] doctor works ./scripts/doctor.sh -> "No blockers."
[ ] SGLang starts ./scripts/start_sglang_qwen38.sh
[ ] /v1/models responds curl http://127.0.0.1:30000/v1/models
[ ] target model is visible sparkbench backend check --smoke
[ ] proxy starts and is healthy curl http://127.0.0.1:8900/__sparkbench/health
[ ] Pi connects [proxy] lines appear during a Pi run
[ ] Pi can use tools tool lines appear; workdir is modified
[ ] raw trace is created traces/raw/<RUN_ID>/llm_calls.jsonl is non-empty
[ ] processed trace is created sparkbench parse <RUN_ID>
[ ] trace validates sparkbench validate <RUN_ID>
[ ] analyze command works sparkbench analyze <RUN_ID> --plots
[ ] prefix analysis works report shows unit "tokens", not "chars"
[ ] tool analysis works tool categories and durations look sane
[ ] replay works sparkbench replay <RUN_ID> --timing immediate --limit 5
[ ] Docker can reach the proxy the container curl check succeeds
[ ] Harbor works on its own harbor run -d [email protected] -a oracle
[ ] one Terminal-Bench task runs ./scripts/run_terminalbench_smoke.sh
[ ] concurrency experiment works sparkbench run concurrency --level 2 --auto-parse
[ ] subagent experiment works (only after enabling sub-agents in Pi)
pip install -e '.[dev]'
pytest # all tests
pytest -v # verbose
pytest tests/test_prefix.py # one file
pytest -k concurrency # by name
The tests cover configuration parsing and precedence, schema validation and derived fields, trace parsing and tool categorisation, common-prefix maths and the reuse-ratio definition, tool/gap statistics, concurrency sweep-line maths, summary rendering and CSV output, SSE parsing and agent labelling in the proxy, replay loading and ordering, the generated Pi configuration (including the assertion that Pi is pointed at the proxy and never straight at the engine), and the Harbor container-config generation.
They deliberately use synthetic fixtures only: no model, no network, no GPU, no Docker. Anything that needs those is a manual validation step, which is what section M is for. Synthetic data never leaves the tests as a result.
.gitignore already excludes: .env, auth.json, any hf_token*, model
weights (*.safetensors, *.gguf, *.bin), Hugging Face caches,
traces/raw/*, traces/processed/*, results/*, .pi-home/,
node_modules/, and Python build artefacts.
Never commit: Hugging Face tokens, API keys, model weights, Docker layers, or raw benchmark datasets.
Where to put large traces. Raw traces grow with prompt size — a long
Terminal-Bench run can produce tens of MB of llm_calls.jsonl, because full
prompts are stored. Keep them out of git and archive them deliberately:
tar czf spark-infer-traces-$(date +%Y%m%d).tar.gz traces/raw/<RUN_ID>
Then store on a lab volume, shared drive, or object storage, and record the
archive location in your experiment notes. What belongs in git is the
results/<RUN_ID>/summary.json if you want a number in version control — it
is small and carries the provenance (git commit, versions, machine).
Reproducibility. Every run records: run id, timestamps, git commit and dirty flag, model and revision, backend and URL, versions of Pi / Harbor / SGLang / Docker / driver, machine and GPUs, task, seed, session concurrency and the full effective configuration. Credentials are never stored.
These are the things that could not be confirmed from documentation alone and should be checked once on your machine. Each is marked [VERIFY] in the source, and each is isolated to a single file so a fix is small.
| assumption | where | how to check |
|---|---|---|
PI_CODING_AGENT_DIR redirects Pi's whole config/state directory | sparkbench/agents/pi.py (pi_env) | PI_CODING_AGENT_DIR=/tmp/x pi --list-models |
Pi's approval flag is --approve | configs/agents/pi.yaml (run.approve_args) | pi --help | grep -i approve |
| Pi JSON mode takes the prompt as a positional argument | sparkbench/agents/pi.py (build_pi_command) | pi --mode json "hello" |
Pi event names (tool_execution_start/end, agent_start/end) | sparkbench/tracing/parser.py | inspect pi_events.jsonl after a run |
Harbor's agent base class is harbor.agents.base.BaseAgent | sparkbench/harbor/pi_agent.py | python -c "from harbor.agents.base import BaseAgent" |
| Harbor's container exec method name | same file (_exec) | it tries exec/execute/run_command/run and reports what to fix |
Harbor's single-task filter flag is --task-id | configs/benchmarks/terminalbench.yaml | harbor run --help |
Harbor's output directory flag is --output-path | same file | harbor run --help |
| SGLang accepts the model-card flag set as-is on your build | scripts/start_sglang_qwen38.sh | python -m sglang.launch_server --help |
--tp 2 from the model card is not assumed; we default to 1 | same | set TP_SIZE to your GPU count |
The server honours stream_options.include_usage | sparkbench/tracing/proxy.py | after a run, check token_source == "server_usage" |
Environment-dependent things to expect on DGX Spark: aarch64 wheel
availability for FP4/FlashInfer/ModelOpt, unified-memory pressure (tune
MEM_FRACTION_STATIC and CONTEXT_LENGTH), GPU count for TP_SIZE, and
Docker's host-gateway behaviour for container→host networking.
See LICENSE.
Coding-agent workload generation, trace collection and analysis for the Spark-Infer project.
We want to know what a coding agent actually asks an LLM to do, in enough detail to design an inference engine for it.
The chain we build and measure:
Terminal-Bench a benchmark of real terminal/coding tasks
│
▼
Pi Coding Agent reads files, runs tests, edits code, calls the LLM
│ many times per task
▼
SGLang serves the model over an OpenAI-compatible API
│
▼
RadixArk/Qwen3.8-Flash-Next-NVFP4
│
▼
traces every LLM request, every tool call, with timing
│
▼
workload analysis context growth, potential prefix reuse, idle gaps,
concurrency, sub-agent structure
Local inference for coding agents does not look like chatbot inference:
pytest, compiling, installing packages. Those gaps are seconds to minutes
long — windows in which an engine could evict, offload to SSD, or prefetch.Numbers for each of these are exactly what the Spark-Infer engine work needs, and none of them can be guessed. So we measure them, store them in a stable format, and make the workload replayable against any engine.
"synthetic": true. ┌────────────────────────┐
│ Terminal-Bench task │ task definition + verifier
└───────────┬────────────┘
│ Harbor starts a container and hands over the instruction
▼
┌────────────────────────┐
│ Pi coding agent │ decides what to read, run and edit,
│ (inside the container)│ and when to call the LLM again
└───────────┬────────────┘
│ OpenAI-compatible HTTP
▼
┌────────────────────────┐
│ SparkBench proxy │ ◄── EVERY request/response recorded here
│ (on the host) │ timing, tokens, full bodies
└───────────┬────────────┘
│ OpenAI-compatible HTTP
▼
┌────────────────────────┐
│ SGLang │ the inference engine
└───────────┬────────────┘
▼
┌────────────────────────────────────────┐
│ RadixArk/Qwen3.8-Flash-Next-NVFP4 │
└────────────────────────────────────────┘
Terminal-Bench / Harbor — supplies the tasks and decides pass/fail. Harbor builds a container per task, runs an agent inside it, then runs a verifier. We do not modify Harbor; we register our agent with it.
Pi coding agent — the workload generator. Pi decides what files to read, what commands to run, what to edit, and when to call the LLM again. Its behaviour is the thing we are studying, so we never script it.
SGLang — hosts the model and does the actual inference. External service.
The SparkBench recording proxy — the heart of this repository. It sits between the agent and the engine and records every call at the HTTP boundary. This is the only place that can honestly measure:
| measurement | why the proxy |
|---|---|
| time to first token | it sees the first streamed chunk arrive |
| request concurrency | it knows how many calls are in flight at one instant |
| the exact prompt | needed for prefix analysis and faithful replay |
| server-reported token usage | usage comes from the engine, not the agent |
It also makes the benchmark agent-agnostic: swap Pi for another agent and the LLM-level trace keeps the same shape.
This repository's pipeline — connects everything and turns raw output into analysis:
raw (immutable) ──► parse ──► normalized schema ──► analysis
│
└──► replay against any engine
A longer version of this section, including how agents are attributed and how
the three kinds of concurrency differ, is in docs/architecture.md.
spark-infer-bench/
├── README.md this file
├── pyproject.toml package metadata + dependencies
├── .env.example template for your local settings
├── .gitignore keeps secrets, weights and traces out of git
│
├── configs/
│ ├── models/
│ │ └── qwen38_flash_next_nvfp4.yaml the research model + reference launch args
│ ├── backends/
│ │ ├── sglang.yaml primary backend
│ │ ├── vllm.yaml future / unverified
│ │ └── llamacpp.yaml future / cannot serve NVFP4
│ ├── agents/
│ │ └── pi.yaml how Pi is configured and launched
│ ├── benchmarks/
│ │ └── terminalbench.yaml Harbor dataset, flags, task ids
│ └── experiments/
│ └── concurrency.yaml the 1/2/3/5-session sweep
│
├── sparkbench/ the Python package
│ ├── __init__.py version + the fixed research model id
│ ├── cli.py every `sparkbench …` command
│ ├── util.py ids, time, JSONL io, provenance, formatting
│ ├── doctor.py read-only environment inspection
│ │
│ ├── config/
│ │ └── loader.py .env + YAML -> Settings (nothing is hardcoded)
│ │
│ ├── backends/
│ │ ├── base.py Backend interface + HealthResult
│ │ └── openai_compatible.py health check, tiny probe, raw POST for replay
│ │
│ ├── agents/
│ │ └── pi.py generates Pi's project-local config, runs Pi,
│ │ streams its JSON events to disk
│ │
│ ├── tracing/
│ │ ├── schema.py THE normalized trace format + validation
│ │ ├── proxy.py the recording proxy (ASGI app)
│ │ ├── collector.py orchestrates one run, writes raw traces
│ │ ├── parser.py raw (Pi/proxy) -> normalized schema
│ │ ├── prefix.py potential prefix reuse
│ │ ├── tokenizer.py model tokenizer, with an honest fallback
│ │ └── synthetic.py fake traces for testing the pipeline
│ │
│ ├── analysis/
│ │ ├── stats.py percentiles, describe()
│ │ ├── workload.py request/token stats, context growth
│ │ ├── tools.py tool categories, durations, idle gaps
│ │ ├── concurrency.py agent / request / sub-agent concurrency
│ │ └── summary.py assembles everything; report + JSON + CSV
│ │
│ ├── replay/
│ │ └── replay.py re-send a recorded workload to any engine
│ │
│ └── harbor/
│ └── pi_agent.py SparkBenchPiAgent: runs Pi inside a TB container
│
├── scripts/
│ ├── lib/common.sh .env loading, helpers (source, do not run)
│ ├── doctor.sh sparkbench doctor
│ ├── check_backend.sh sparkbench backend check
│ ├── start_sglang_qwen38.sh starts SGLang with the research model
│ ├── start_proxy.sh starts the recording proxy
│ ├── run_pi_smoke.sh experiment A: one Pi session, end to end
│ ├── run_terminalbench_smoke.sh experiment B: one Terminal-Bench task
│ ├── run_concurrency.sh experiment G: 1/2/3/5 session sweep
│ └── patch_harbor_upload_dir.sh optional fix for a known Harbor bug
│
├── examples/
│ ├── simple_repo_task/ a tiny repo with one real bug, for smoke tests
│ │ ├── README.md
│ │ ├── PROMPT.md the instruction handed to the agent
│ │ ├── textstats/analyze.py the library (contains the bug)
│ │ └── tests/test_analyze.py one failing test, six passing
│ └── synthetic_traces/
│ └── generate.py make a fake trace to exercise the pipeline
│
├── traces/
│ ├── raw/ immutable per-run output (git-ignored)
│ └── processed/ normalized traces (git-ignored)
│
├── results/ analysis output: summary.json, CSV, PNG
│
├── tests/ unit tests; no model, no network, no GPU
│ ├── conftest.py synthetic trace fixtures
│ ├── test_config.py .env parsing, settings precedence
│ ├── test_schema.py record derivation + validation
│ ├── test_parser.py tool categorisation, raw -> normalized
│ ├── test_pi_agent.py Pi config generation + command line
│ ├── test_prefix.py common-prefix maths, ratio definition
│ ├── test_analysis.py workload, tools, gaps, concurrency, summary
│ ├── test_proxy.py SSE parsing, agent labelling, headers
│ ├── test_replay.py replay loading and ordering
│ ├── test_harbor_agent.py container config generation
│ └── test_util.py ids, JSONL tolerance, formatting
│
└── docs/
├── architecture.md components, data flow, attribution
├── trace-format.md full schema reference with examples
├── pi-notes.md Pi specifics + what to verify
├── harbor-notes.md Harbor specifics + what to verify
└── metrics.md every metric defined, with caveats
| file | what it is for |
|---|---|
.env | the only file you must edit to point at your machine |
configs/backends/sglang.yaml | SGLang launch defaults (TP size, memory, context) |
configs/agents/pi.yaml | Pi's flags — check approve_args before an unattended run |
configs/benchmarks/terminalbench.yaml | Harbor flags that may differ in your version |
sparkbench/tracing/schema.py | if you need a new field in traces, add it here |
sparkbench/tracing/parser.py | the only file that knows vendor formats; fix upstream changes here |
sparkbench/harbor/pi_agent.py | the only file that touches Harbor's API |
scripts/start_sglang_qwen38.sh | starting the model server |
| dependency | needed for | why |
|---|---|---|
| Python ≥ 3.10 | everything | the sparkbench package |
| Node ≥ 20 + npm | the agent | Pi is a Node program |
| Pi | the agent | the coding agent whose workload we study |
| NVIDIA driver + GPU | serving the model | NVFP4 needs an NVIDIA FP4-capable stack |
| SGLang | serving the model | the only engine documented for this checkpoint |
| Hugging Face access | downloading weights | ~135 GB checkpoint |
| Docker | Terminal-Bench | tasks run in containers |
Harbor (uv tool install harbor) | Terminal-Bench | the task runner |
transformers (optional) | exact token counts | offline tokenization for prefix analysis |
matplotlib (optional) | figures | CSVs are always written regardless |
You do not need a GPU to develop on this repository. Parsing, analysis, prefix maths and the unit tests all run on a laptop against synthetic traces. You need the GPU only to actually serve the model.
# 1. clone
git clone https://github.com/bairixie/spark-infer-bench.git
cd spark-infer-bench
# 2. a virtual environment, so nothing is installed system-wide
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 3. the benchmark package, in editable mode so your edits take effect
pip install -e .
# 4. optional extras
pip install -e '.[tokenizer]' # exact token counts (transformers)
pip install -e '.[plots]' # PNG figures (matplotlib)
pip install -e '.[dev]' # pytest
# or everything at once:
pip install -e '.[all]'
# 5. the coding agent (installs globally via npm)
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
pi --version
# 6. the benchmark runner (only needed for Terminal-Bench)
curl -LsSf https://astral.sh/uv/install.sh | sh
uv tool install harbor
harbor --version
SGLang is not installed by this package: it is large, hardware-specific,
and you may already have a build. Install it into the same environment (or a
separate one) following NVIDIA/SGLang instructions for your platform, and
make sure the build supports this model architecture (qwen4_exp).
Verify the install:
sparkbench --version
sparkbench doctor
RadixArk/Qwen3.8-Flash-Next-NVFP4
This is the research model for Spark-Infer. A different model has a
different tokenizer, a different context window and a completely different
KV/state footprint, so its workload is not comparable. If you ever do run
something else, sparkbench prints a warning and the run is stamped with the
model actually used.
| property | value |
|---|---|
| base checkpoint | Qwen/Qwen3.8-Flash-Next |
| quantization | NVFP4 (served via ModelOpt) |
| architecture | hybrid GDN + sparse attention, Mixture-of-Experts |
| context length | ~262,144 tokens |
| checkpoint size | ~135 GB |
| documented engine | SGLang (--quantization modelopt_fp4) |
| vLLM / llama.cpp | unverified — llama.cpp serves GGUF, which NVFP4 is not |
Storage. Allow ~150 GB free for weights plus the Hugging Face cache. Put the cache somewhere with room:
export HF_HOME=/data/hf
huggingface-cli login # or: export HF_TOKEN=hf_...
Memory. On a unified-memory machine (DGX Spark / GB10) the weights,
the KV/state cache and the rest of the system share one pool. That is what
--mem-fraction-static controls, and it is the first thing to lower if the
server dies at startup.
Why the architecture matters to us. This is not a plain dense-attention
model: the gated-delta-net (mamba-like) state is managed separately from the
attention KV cache, which is why SGLang has --mamba-* flags. When you later
reason about "KV cache" for this model, remember there are two kinds of state.
Work through these in order. Each step is independent and verifiable — if something fails, you know exactly which layer is at fault. Nothing here has been run for you.
You will end up using three terminals. Keep them open:
Terminal 1 SGLang (the model server)
Terminal 2 recording proxy (optional for step 9, required for step 12)
Terminal 3 everything else
git clone https://github.com/bairixie/spark-infer-bench.git
cd spark-infer-bench
python3 -m venv .venv
source .venv/bin/activate
Everything below assumes this environment is active.
pip install -e '.[all]'
sparkbench --version
-e (editable) means your edits to sparkbench/ take effect without
reinstalling.
./scripts/doctor.sh
This only inspects. It never installs or changes anything.
You should see a table of components with ok / warn / MISSING, then
either No blockers. or a list of blockers. Roughly:
status component detail matters for
------ ------------------- ----------------------------- ------------------
ok Python 3.11.9 (/…/.venv/bin/python) blocker
ok python: yaml 6.0.1 blocker
ok python: httpx 0.27.0 blocker
ok python: uvicorn 0.30.1 blocker
ok pi (coding agent) <some version> blocker
ok docker Docker version … needed-for: Terminal-Bench
MISSING harbor not found needed-for: Terminal-Bench
ok NVIDIA driver / GPUs 1 GPU(s): … needed-for: serving the model
warn disk free (HF cache) 90.0 GiB at /home/…/.cache/… needed-for: model download
Read it like this:
ok → stop and fix it; nothing will work.warn on optional Python packages is fine; you lose exact token counts or
plots, and the reports say so.Only needed to download the checkpoint.
export HF_HOME=/data/hf # somewhere with ~150 GB free
huggingface-cli login # paste a token with access to the repo
# or, non-interactively:
export HF_TOKEN=hf_xxxxxxxxxxxx
Optionally pre-download so the first server start is not also a 135 GB download:
huggingface-cli download RadixArk/Qwen3.8-Flash-Next-NVFP4 --local-dir /data/models/qwen38-nvfp4
Never put a real token in .env.example or any committed file.
.envcp .env.example .env
$EDITOR .env
A typical single-GPU local setup:
BACKEND=sglang
BASE_URL=http://127.0.0.1:30000/v1
MODEL_ID=RadixArk/Qwen3.8-Flash-Next-NVFP4
TOKENIZER_ID=RadixArk/Qwen3.8-Flash-Next-NVFP4
API_KEY=dummy
PROXY_HOST=0.0.0.0
PROXY_PORT=8900
PROXY_URL=http://127.0.0.1:8900/v1
PROXY_URL_IN_CONTAINER=http://host.docker.internal:8900/v1
# if you pre-downloaded the weights:
# MODEL_PATH=/data/models/qwen38-nvfp4
# HF_HOME=/data/hf
Check what the code actually resolved:
sparkbench config show
Look at the command before running anything:
./scripts/start_sglang_qwen38.sh --dry-run
Then start it for real:
TP_SIZE=1 ./scripts/start_sglang_qwen38.sh
What this does: runs pre-flight checks (is SGLang importable, how many GPUs
are visible, is the port free, does TP_SIZE exceed the GPU count), prints
the exact command, asks for confirmation, and then runs the server in the
foreground on port 30000.
What success looks like: SGLang logs model loading (this takes minutes for a 135 GB FP4 checkpoint — the first run also downloads it), then a line saying the server is ready and listening. Leave it running; Ctrl-C stops it.
Every flag is explained in SGLang flags explained.
Start with TP_SIZE equal to your GPU count.
sparkbench backend check --smoke
Expected shape (your values will differ):
Backend check
backend sglang
base_url http://127.0.0.1:30000/v1
configured model RadixArk/Qwen3.8-Flash-Next-NVFP4
endpoint reachable yes (HTTP 200)
models advertised (1):
RadixArk/Qwen3.8-Flash-Next-NVFP4 <- configured model
model visible yes
tiny generation ok (1.23s)
reply: 'ok'
usage: {'prompt_tokens': …, 'completion_tokens': …}
RESULT: backend looks usable
If the model list is empty or shows a different id, do not edit
MODEL_ID to match — find out why the server is serving something else.
./scripts/run_pi_smoke.sh
This checks the backend, starts the recording proxy in the background, copies
examples/simple_repo_task to a scratch working directory, runs Pi against
it once, then parses and analyses the trace.
The example repository contains a real, small bug: word_frequencies() does
not strip punctuation, so exactly one of seven tests fails. The agent is
asked to read the code, run the tests, fix the source (not the test), and
re-run.
While it runs you should see interleaved output like:
=== run smoke-20260915-101500 ===
task simple_repo_task
agent URL http://127.0.0.1:8900/v1
model RadixArk/Qwen3.8-Flash-Next-NVFP4
starting Pi ...
[pi] agent_start
[proxy] # 1 agent=main in=4211 out=96 ttft=0.42s lat=3.11s status=200
[pi] tool read textstats/analyze.py
[pi] tool done (ok)
[proxy] # 2 agent=main in=5820 out=140 ttft=0.55s lat=4.02s status=200
[pi] tool bash python -m pytest -q
...
Two things to watch for: [proxy] lines (proof that Pi is really talking to
your local model) and the in= numbers growing (context accumulation).
The prompt lives in examples/simple_repo_task/PROMPT.md; override it with
--prompt "..." or --prompt-file <path> if you want.
sparkbench list
ls traces/raw/<RUN_ID>/
traces/raw/<RUN_ID>/
run_meta.json what ran, where, with which versions and git commit
llm_calls.jsonl every LLM call with full bodies and timing
pi_events.jsonl Pi's JSON event stream
pi_stderr.log Pi's stderr
pi_session.jsonl copy of Pi's own session file
proxy.log proxy output
pi-home/agent/ the exact Pi config used
workdir/ the repo copy the agent edited
Quick look:
wc -l traces/raw/<RUN_ID>/llm_calls.jsonl
head -c 400 traces/raw/<RUN_ID>/llm_calls.jsonl
Also worth checking: did the agent actually fix the bug?
cd traces/raw/<RUN_ID>/workdir && python -m pytest -q; cd -
Already done by the script, but to repeat it:
sparkbench parse <RUN_ID>
sparkbench analyze <RUN_ID> --plots
parse turns raw output into the normalized schema at
traces/processed/<RUN_ID>.jsonl. analyze prints a report and writes
results/<RUN_ID>/ (summary.json, context_growth.csv, prefix.csv,
gaps.csv, requests.csv, tool_calls.csv, and PNGs with --plots).
See What success looks like for how to read the report.
First make Harbor itself work, with no model involved at all:
harbor --version
harbor run -d [email protected] -a oracle # the reference solution agent
If the oracle does not pass, the problem is Harbor or Docker, not this repository. Fix that first.
Then find a task id and check container→host networking:
harbor run --help # confirm the flag names for your version
sparkbench net suggest # candidate URLs for PROXY_URL_IN_CONTAINER
Now, Terminal 2, start the proxy and leave it running:
./scripts/start_proxy.sh
and in Terminal 3:
TASK_ID=<a-real-task-id> DRY_RUN=1 ./scripts/run_terminalbench_smoke.sh # look first
TASK_ID=<a-real-task-id> ./scripts/run_terminalbench_smoke.sh
The script verifies the proxy is up, prepares the trace directory, prints the
exact harbor run command, asks for confirmation, runs it, then finalizes,
parses and analyses.
Harbor writes its own results (ctrf.json, reward.txt) telling you whether
the task passed. Harbor's exit code is not the verdict.
sparkbench analyze <RUN_ID> --plots
Same report as step 11, with task success filled in from the verifier.
The whole pipeline can be exercised on synthetic data:
python examples/synthetic_traces/generate.py
sparkbench parse synthetic-001
sparkbench analyze synthetic-001 --plots
pytest
Nothing here touches a model, and every generated run is stamped
"synthetic": true. Use it to learn the commands and to check your install
before the GPU work starts — never as a source of results.
scripts/start_sglang_qwen38.sh builds this command. Defaults come from the
model card (configs/backends/sglang.yaml), except --tp.
| flag | default here | what it means | change it when |
|---|---|---|---|
--model-path | $MODEL_PATH or $MODEL_ID | the checkpoint | you pre-downloaded the weights → set MODEL_PATH |
--tp | 1 (card says 2) | tensor parallelism: how many GPUs the model is split across | must equal the GPUs you actually have. 1 GPU → TP_SIZE=1 |
--quantization | modelopt_fp4 | how to interpret the NVFP4 weights | almost never |
--fp4-gemm-backend | flashinfer_cutlass | which FP4 matrix-multiply kernels | if FlashInfer is missing/incompatible on your platform |
--page-size | 64 | tokens per KV cache page. Bigger pages = less bookkeeping, coarser reuse | tuning prefix-cache behaviour |
--mamba-scheduler-strategy | extra_buffer | how the GDN (mamba-like) state is scheduled — this model is hybrid, so that state is separate from attention KV | on memory pressure |
--mamba-track-interval | 64 | how often that state is tracked | rarely |
--chunked-prefill-size | 4096 | a long prompt is prefilled in chunks of this many tokens; keeps one huge request from blocking everything | lower if long prompts starve other requests; raise for prefill throughput |
--max-running-requests | 36 | how many requests the engine will run concurrently | lower on a small machine; our workload rarely needs 36 |
--context-length | 262144 | maximum context the server accepts | lower it (e.g. 65536) if you hit OOM — KV/state reservations scale with it |
--mem-fraction-static | 0.80 | fraction of memory reserved for weights + static allocations; the rest is the KV pool | lower (0.7, 0.6) if the server dies at startup; raise for a bigger cache |
--allow-auto-truncate | on | truncate over-long prompts instead of erroring | keep on: coding agents will eventually exceed the window |
--host / --port | 127.0.0.1 / 30000 | where it listens | must match BASE_URL in .env |
Anything else can be appended with EXTRA_ARGS="...".
On DGX Spark specifically, expect to revisit: TP_SIZE (GPU count),
MEM_FRACTION_STATIC (unified memory is shared with the system),
CONTEXT_LENGTH (262K reservations are expensive), and
FP4_GEMM_BACKEND (aarch64 kernel availability).
When SGLang and the proxy run on the host while Pi runs inside a
container (which is what Terminal-Bench does), localhost inside the
container is the container, not your machine. So the container cannot reach
http://127.0.0.1:8900/v1.
Two settings matter:
PROXY_HOST=0.0.0.0 (the default).PROXY_URL_IN_CONTAINER.Candidates, most portable first:
| situation | URL |
|---|---|
| Docker Desktop (macOS/Windows) | http://host.docker.internal:8900/v1 |
Linux, container started with --add-host=host.docker.internal:host-gateway | http://host.docker.internal:8900/v1 |
| Linux, default bridge | http://172.17.0.1:8900/v1 (check: ip addr show docker0) |
| Any container that can route to your host | http://<your-LAN-IP>:8900/v1 |
Linux, --network=host | http://127.0.0.1:8900/v1 |
sparkbench net suggest # prints candidates for THIS machine
With the proxy running:
# from the host — should print JSON
curl -sS http://127.0.0.1:8900/__sparkbench/health
# from inside a container — should print the same JSON
docker run --rm --add-host=host.docker.internal:host-gateway curlimages/curl \
-sS -m 5 http://host.docker.internal:8900/__sparkbench/health
Whichever URL works goes into PROXY_URL_IN_CONTAINER. Our Harbor agent runs
the same probe during setup and fails the run immediately if the
container cannot reach the proxy — an empty trace is worse than an error.
Also check your firewall: on Linux, a host firewall can silently drop container→host traffic on port 8900.
Exact numbers depend on your machine, the task and the model's behaviour, so none are given here. Judge by shape.
sparkbench doctorA table plus either No blockers. or a blocker list. Missing Harbor/Docker is
fine until step 12.
sparkbench backend checkendpoint reachable yes, the configured model listed under "models
advertised", model visible yes, and with --smoke a short reply and a
usage dictionary. Anything else means the server is not ready.
sparkbench run pi / run_pi_smoke.sh[proxy] #N … lines appear — Pi is using your local model.in= (prompt tokens) grows over the run — context is accumulating.pytest.Failure mode to watch for: Pi finishes quickly with no [proxy] lines.
That means it never called your model (see Troubleshooting).
sparkbench parseparsed traces/raw/<RUN_ID>
agents 1
llm requests 14
tool calls 22
wrote 38 records -> traces/processed/<RUN_ID>.jsonl
llm requests 0 is a failure, and the command says so explicitly.
sparkbench analyzeA report with these sections, all populated:
token_sources — you want server_usage.unit line:
tokens is a real measurement, chars means no tokenizer was available.test/build should
dominate total time if the task involves running tests.unexplained time is
worth investigating (agent overhead, or a stall).sparkbench replayA per-request progress line, then a summary comparing replay TTFT/latency
against the originals, and n_failed 0.
Three layers, three different purposes:
| layer | path | written by | edit? |
|---|---|---|---|
| raw | traces/raw/<RUN_ID>/ | the proxy, Pi, Harbor | never |
| processed | traces/processed/<RUN_ID>.jsonl | sparkbench parse | regenerate any time |
| derived | results/<RUN_ID>/ | sparkbench analyze | disposable |
Why both raw and processed? Raw is evidence: whatever the tools actually
emitted, including full request bodies. Replay needs it, and so will analyses
you have not thought of yet. Processed is a stable contract: Pi's event
names may change, our schema does not, so figures made today still work on
traces collected next semester. Only parser.py bridges the two.
llm_request — request_id, agent_id, start_ts, first_token_ts,
end_ts, ttft_s, latency_s, input_tokens, output_tokens,
context_length, cached_tokens, token_source, params, finish_reason,
error, prompt_sha256, body_ref.
tool_call — tool_name, category, command, start_ts, end_ts,
duration_s, success.
agent — agent_id, parent_agent_id, agent_type, start_ts, end_ts.
run — task, benchmark, backend, model, machine, versions, git commit, seed,
success.
body_ref (e.g. llm_calls.jsonl#7) points back into the raw recording; the
processed trace deliberately does not duplicate large prompt bodies.
{
"record_id": "req-000003-9f2c",
"agent_label": "main",
"endpoint": "/v1/chat/completions",
"modified_request": ["stream_options.include_usage"],
"request": {"model": "RadixArk/Qwen3.8-Flash-Next-NVFP4", "stream": true,
"n_messages": 7, "body": {"messages": ["..."]}},
"timing": {"t_start": 1789466400.12, "t_first_token": 1789466400.51,
"t_end": 1789466404.88, "ttft_s": 0.39, "latency_s": 4.76},
"response": {"status": 200, "finish_reason": "tool_calls",
"usage": {"prompt_tokens": 18422, "completion_tokens": 142}},
"tokens": {"prompt": 18422, "completion": 142, "source": "server_usage"}
}
{"record_type": "llm_request", "schema_version": 1,
"request_id": "req-000003-9f2c", "run_id": "smoke-20260915-101500",
"agent_id": "main", "agent_attribution": "label", "request_index": 2,
"model": "RadixArk/Qwen3.8-Flash-Next-NVFP4", "stream": true,
"start_ts": 1789466400.12, "first_token_ts": 1789466400.51,
"end_ts": 1789466404.88, "ttft_s": 0.39, "latency_s": 4.76, "decode_s": 4.37,
"input_tokens": 18422, "output_tokens": 142, "context_length": 18422,
"token_source": "server_usage", "finish_reason": "tool_calls",
"body_ref": "llm_calls.jsonl#3"}
modified_request is an honesty field: the proxy adds
stream_options.include_usage to streamed requests so the server reports
token counts, and every record says whether it did. Disable with
sparkbench proxy --no-inject-usage (you then lose token counts on streamed
calls).
Full reference: docs/trace-format.md.
In plain language:
Input tokens — how much context was sent to the model for one request. For a coding agent this is the dominant cost: the whole conversation so far, including every file the agent read and every test output.
Output tokens — how much the model generated. Usually far smaller than the input; a 10:1 or 20:1 input:output ratio is normal.
Context length — how much history a request carries. It grows turn by turn, which is why "long context" is not an edge case here but the default.
Potential prefix reuse — how much of a request's prompt is a character-for-character (token-for-token) prefix of an earlier request. If request B is request A plus one appended tool result, almost all of B's prompt is a prefix of A's, so an engine could in principle skip recomputing it. Reported two ways: sequential (versus the immediately preceding request) and best (versus the best matching earlier request, the upper bound for an unbounded radix cache).
This is potential reuse, an upper bound — not a cache hit rate. Whether the engine still holds those blocks depends on its cache size, eviction policy and what else ran in between. Real hit rates come out below these numbers, never above.
TTFT (time to first token) — how long the user waits before anything appears. Dominated by prefill, so it rises with prompt length and with queue depth.
Request latency — the whole request, first byte to last. Includes queueing, so it rises under concurrency even when the model is equally fast.
Tool duration — how long the agent spends outside inference: running tests, building, installing. This is the workload's duty cycle; for many coding tasks the agent spends more time in tools than waiting on the model.
Idle gap — the time between one request finishing and the next starting for the same agent. From the engine's point of view this agent is idle but still holding cache — the window in which offloading or eviction could pay off. The analysis also reports how much of each gap is explained by tool calls.
Agent concurrency — how many agents were alive at once.
Request concurrency — how many LLM requests were in flight at the server at one instant. This is what the engine actually schedules against, and it is measured, never assumed.
Sub-agent parallelism — how many child agents of one session ran at the
same time. max_parallel == 1 means the agent spawns children serially; > 1
means genuinely parallel branches.
Keep these three separate: a run with session concurrency 5 may still show request concurrency 2, because agents spend much of their time in tools. That gap between "5 agents" and "2 concurrent requests" is itself a finding.
Full definitions and caveats: docs/metrics.md.
All of these assume SGLang is running and sparkbench backend check passes.
./scripts/run_pi_smoke.sh
Purpose: prove the whole stack works end to end.
# Terminal 2
./scripts/start_proxy.sh
# Terminal 3
TASK_ID=<task-id> ./scripts/run_terminalbench_smoke.sh
Purpose: a real benchmark task with a verifier that says pass/fail.
./scripts/start_proxy.sh # Terminal 2, leave running
for t in task-a task-b task-c task-d task-e; do
TASK_ID=$t RUN_ID=tb-$t ./scripts/run_terminalbench_smoke.sh
done
sparkbench list
Purpose: enough runs to see whether workload shape is task-dependent.
Put the real task ids in configs/benchmarks/terminalbench.yaml
(small_task_set) so the set is recorded.
sparkbench analyze <RUN_ID> --plots
column -s, -t results/<RUN_ID>/context_growth.csv | head -20
Gives request index vs input tokens per agent, the growth factor, mean growth
per request, and context_growth.png.
pip install -e '.[tokenizer]' # needed for token-level numbers
sparkbench analyze <RUN_ID> --prefix-scope agent # within one agent
sparkbench analyze <RUN_ID> --prefix-scope run # across all agents
column -s, -t results/<RUN_ID>/prefix.csv | head -20
--prefix-scope run is the interesting one once sub-agents exist: it shows
how much a child could reuse from its parent.
Check the unit line in the report. chars means no tokenizer was loaded
and the numbers are only a relative signal.
sparkbench analyze <RUN_ID> --long-gap 5
column -s, -t results/<RUN_ID>/gaps.csv | head -20
Reports every idle window between LLM requests, which tools ran inside it,
and how much time tools do not explain. Lower --long-gap to catch shorter
windows; raise it to focus on the big ones.
Sub-agents are not required for the first runs, and Pi core ships without them. When you enable them (extension or Pi package):
# configs/agents/pi.yaml
# subagents:
# enabled: true
# extension: <name>
sparkbench analyze <RUN_ID> --prefix-scope run
The report's CONCURRENCY section then shows sub-agent count, max parallelism, lifetimes, and whether they ran in parallel or serially; CROSS-AGENT PROMPT OVERLAP shows parent→child and sibling prompt sharing.
To compare, run the same task twice — once with sub-agents disabled, once
enabled — and diff the two summary.json files.
./scripts/run_concurrency.sh # sweeps 1 2 3 5
# or one level at a time:
sparkbench run concurrency --level 3 --task examples/simple_repo_task --auto-parse
Each level launches N independent Pi sessions sharing one recording proxy, so request concurrency at the server is measured correctly. Compare levels:
python - <<'PY'
import json, glob
for f in sorted(glob.glob("results/conc*.json")):
d = json.load(open(f)); a = d["aggregate"]
print(f"{d['session_concurrency']} sessions: "
f"max_req_conc={a['request_concurrency_max']} "
f"mean={a['request_concurrency_mean']:.2f} "
f"wall={a['wall_clock_s']:.0f}s "
f"ttft_p95={(a['ttft_s']['p95'] or 0):.2f}s")
PY
The interesting question: how far below N does measured request concurrency sit, and how much does TTFT degrade as N rises?
# as fast as possible, one at a time
sparkbench replay <RUN_ID> --timing immediate --concurrency 1
# stress: same requests, 4 at a time
sparkbench replay <RUN_ID> --timing immediate --concurrency 4
# faithful: reproduce the agent's original think-time
sparkbench replay <RUN_ID> --timing original
# same shape, twice as fast
sparkbench replay <RUN_ID> --timing original --speed 2
Replay re-sends the exact recorded request bodies, so it turns a slow,
non-deterministic agent run into a fast, repeatable engine benchmark. Results
land in results/replay/….
--timing immediate inflates apparent prefix-cache benefit, because nothing
gets evicted during a 45-second pytest that no longer happens.
--timing original preserves those idle windows and is the honest comparison.
configs/backends/sglang.yaml to configs/backends/<engine>.yaml
and set base_url / model_id.sparkbench --backend <engine> backend checksparkbench --backend <engine> replay <RUN_ID> --timing originalBecause everything goes through the OpenAI-compatible abstraction, no benchmark code changes. Do not claim the NVFP4 checkpoint works on another engine until you have verified it: vLLM is unverified for this checkpoint, and llama.cpp serves GGUF, which NVFP4 is not.
General rule: find the lowest layer that is broken and fix that one.
The layers, bottom up: GPU/driver → SGLang → /v1/models → proxy → Pi →
Harbor/Docker → analysis. sparkbench doctor and sparkbench backend check
tell you where you are.
The repo may be gated, or your token lacks access.
huggingface-cli whoami
huggingface-cli download RadixArk/Qwen3.8-Flash-Next-NVFP4 --repo-type model --dry-run
Accept any terms on the model page, then re-login. Do not switch to a different model to get around it.
~135 GB of weights plus cache overhead.
df -h $HF_HOME ${HF_HOME:-~/.cache/huggingface}
huggingface-cli scan-cache
Point HF_HOME at a big volume and re-run the download — it resumes. A
truncated download often shows up later as a confusing safetensors error, so
verify the download completed before blaming SGLang.
qwen4_expThe architecture is new; your SGLang build must support it.
python -c "import sglang; print(sglang.__version__, sglang.__file__)"
python -m sglang.launch_server --help | head -40
Collect the full traceback — the useful line usually names an unknown architecture or a missing module. Upgrade SGLang to a build that supports this model family. Do not work around it by changing the model.
Symptoms: unknown quantization method modelopt_fp4, missing FP4 kernels, or
an import error from flashinfer.
python -c "import modelopt; print(modelopt.__version__)"
python -c "import flashinfer; print(flashinfer.__version__)"
nvidia-smi --query-gpu=name,driver_version --format=csv
These packages are tightly coupled to CUDA/driver versions, and aarch64
wheels lag x86. Try a different FP4_GEMM_BACKEND and record which backend
you used when reporting numbers.
nvidia-smi
echo $CUDA_VISIBLE_DEVICES
python -c "import torch; print(torch.cuda.is_available(), torch.cuda.device_count())"
TP_SIZE must not exceed the visible GPU count — the start script checks
this and refuses.
In order of effect:
MEM_FRACTION_STATIC=0.70 CONTEXT_LENGTH=65536 MAX_RUNNING_REQUESTS=8 \
./scripts/start_sglang_qwen38.sh
CONTEXT_LENGTH is usually the biggest lever: reserving 262K tokens of
KV/state is expensive. On unified-memory machines remember the system and the
model share one pool — close other memory-hungry processes. If OOM happens
mid-run rather than at startup, the agent's context has grown; lower
MAX_RUNNING_REQUESTS or CONTEXT_LENGTH.
pip may fail to find wheels for GPU-side packages.
uname -m # aarch64
pip debug --verbose | grep -i tag
Use NVIDIA's ARM builds/containers. sparkbench itself is pure Python and
installs anywhere.
lsof -i :30000 # SGLang
lsof -i :8900 # proxy
Change PORT / PROXY_PORT in .env (and keep BASE_URL / PROXY_URL in
sync).
/v1/models unavailablecurl -v http://127.0.0.1:30000/v1/models
Connection refused → the server is not up (or is still loading; watch
Terminal 1). Hangs → it is still loading the checkpoint. 404 → your
BASE_URL is missing or duplicating the /v1 path.
The server is running but advertises a different id. Look at what it lists and
at the --model-path you launched with. Do not change MODEL_ID in
.env to match unless you genuinely intend to change the research model.
The single most common failure. Check in this order:
# 1. is the proxy up?
curl -s http://127.0.0.1:8900/__sparkbench/health
# 2. does the generated Pi config point at the proxy?
cat traces/raw/<RUN_ID>/pi-home/agent/models.json # baseUrl must be the PROXY
# 3. can the proxy reach the engine?
sparkbench backend check
# 4. what did Pi say?
tail -40 traces/raw/<RUN_ID>/pi_stderr.log
# 5. does Pi see the provider at all?
PI_CODING_AGENT_DIR=$(pwd)/traces/raw/<RUN_ID>/pi-home/agent pi --list-models
Also verify PI_CODING_AGENT_DIR is honoured by your Pi version
(see docs/pi-notes.md); if it is not, Pi read your
personal ~/.pi config instead and may have called a cloud provider.
It is waiting for approval. Check the flag:
pi --help | grep -i approve
and fix run.approve_args in configs/agents/pi.yaml.
Often an unsupported field. The compat block in configs/agents/pi.yaml
already disables the developer role and reasoning_effort. To see the
offending body:
tail -1 traces/raw/<RUN_ID>/llm_calls.jsonl | python -m json.tool | head -40
The proxy records failures too, so the request that failed is in the trace.
See Docker networking and run the two curl checks
there. Confirm PROXY_HOST=0.0.0.0, and check any host firewall.
harbor run --help
harbor agent list
python -c "import sparkbench.harbor.pi_agent as m; print(m.__file__)"
PYTHONPATH=$(pwd) harbor run -d [email protected] \
--agent-import-path sparkbench.harbor.pi_agent:SparkBenchPiAgent -m openai/x -n 1
sparkbench must be importable by the Python that runs Harbor — PYTHONPATH
is the simplest fix. If the base class or environment.exec differs in your
version, only sparkbench/harbor/pi_agent.py needs editing; _exec() already
tries several spellings and tells you what to change.
First prove Harbor works without us:
harbor run -d [email protected] -a oracle
docker ps -a
docker system df # containers/images can fill the disk quickly
If verifiers fail because a tests/ directory got nested, see the known
upload_dir issue and scripts/patch_harbor_upload_dir.sh.
The report says unit: chars or token_source: estimate.
pip install -e '.[tokenizer]'
python -c "from transformers import AutoTokenizer; \
AutoTokenizer.from_pretrained('RadixArk/Qwen3.8-Flash-Next-NVFP4', trust_remote_code=True)"
If TOKENIZER_ID differs from MODEL_ID, sparkbench warns — the numbers
are then not comparable to the served model.
pi_events.jsonl is empty. Check --mode json is being used
(traces/raw/<RUN_ID>/run_meta.json records the exact command) and look at
pi_stderr.log.Send:
sparkbench doctor --json /tmp/doctor.json (attach the file);traces/raw/<RUN_ID>/run_meta.json;pi_stderr.log, proxy.log, or
the SGLang terminal;head -c 2000 traces/raw/<RUN_ID>/llm_calls.jsonl if it exists;sparkbench config show.That is almost always enough to identify the layer at fault. Redact tokens.
Work down the list. Each item is independently checkable, and they are in dependency order — if one fails, the ones below it will too.
[ ] pip install -e '.[all]' succeeds and `sparkbench --version` works
[ ] pytest passes (no model needed)
[ ] synthetic pipeline works:
python examples/synthetic_traces/generate.py
sparkbench parse synthetic-001 && sparkbench analyze synthetic-001
[ ] doctor works ./scripts/doctor.sh -> "No blockers."
[ ] SGLang starts ./scripts/start_sglang_qwen38.sh
[ ] /v1/models responds curl http://127.0.0.1:30000/v1/models
[ ] target model is visible sparkbench backend check --smoke
[ ] proxy starts and is healthy curl http://127.0.0.1:8900/__sparkbench/health
[ ] Pi connects [proxy] lines appear during a Pi run
[ ] Pi can use tools tool lines appear; workdir is modified
[ ] raw trace is created traces/raw/<RUN_ID>/llm_calls.jsonl is non-empty
[ ] processed trace is created sparkbench parse <RUN_ID>
[ ] trace validates sparkbench validate <RUN_ID>
[ ] analyze command works sparkbench analyze <RUN_ID> --plots
[ ] prefix analysis works report shows unit "tokens", not "chars"
[ ] tool analysis works tool categories and durations look sane
[ ] replay works sparkbench replay <RUN_ID> --timing immediate --limit 5
[ ] Docker can reach the proxy the container curl check succeeds
[ ] Harbor works on its own harbor run -d [email protected] -a oracle
[ ] one Terminal-Bench task runs ./scripts/run_terminalbench_smoke.sh
[ ] concurrency experiment works sparkbench run concurrency --level 2 --auto-parse
[ ] subagent experiment works (only after enabling sub-agents in Pi)
pip install -e '.[dev]'
pytest # all tests
pytest -v # verbose
pytest tests/test_prefix.py # one file
pytest -k concurrency # by name
The tests cover configuration parsing and precedence, schema validation and derived fields, trace parsing and tool categorisation, common-prefix maths and the reuse-ratio definition, tool/gap statistics, concurrency sweep-line maths, summary rendering and CSV output, SSE parsing and agent labelling in the proxy, replay loading and ordering, the generated Pi configuration (including the assertion that Pi is pointed at the proxy and never straight at the engine), and the Harbor container-config generation.
They deliberately use synthetic fixtures only: no model, no network, no GPU, no Docker. Anything that needs those is a manual validation step, which is what section M is for. Synthetic data never leaves the tests as a result.
.gitignore already excludes: .env, auth.json, any hf_token*, model
weights (*.safetensors, *.gguf, *.bin), Hugging Face caches,
traces/raw/*, traces/processed/*, results/*, .pi-home/,
node_modules/, and Python build artefacts.
Never commit: Hugging Face tokens, API keys, model weights, Docker layers, or raw benchmark datasets.
Where to put large traces. Raw traces grow with prompt size — a long
Terminal-Bench run can produce tens of MB of llm_calls.jsonl, because full
prompts are stored. Keep them out of git and archive them deliberately:
tar czf spark-infer-traces-$(date +%Y%m%d).tar.gz traces/raw/<RUN_ID>
Then store on a lab volume, shared drive, or object storage, and record the
archive location in your experiment notes. What belongs in git is the
results/<RUN_ID>/summary.json if you want a number in version control — it
is small and carries the provenance (git commit, versions, machine).
Reproducibility. Every run records: run id, timestamps, git commit and dirty flag, model and revision, backend and URL, versions of Pi / Harbor / SGLang / Docker / driver, machine and GPUs, task, seed, session concurrency and the full effective configuration. Credentials are never stored.
These are the things that could not be confirmed from documentation alone and should be checked once on your machine. Each is marked [VERIFY] in the source, and each is isolated to a single file so a fix is small.
| assumption | where | how to check |
|---|---|---|
PI_CODING_AGENT_DIR redirects Pi's whole config/state directory | sparkbench/agents/pi.py (pi_env) | PI_CODING_AGENT_DIR=/tmp/x pi --list-models |
Pi's approval flag is --approve | configs/agents/pi.yaml (run.approve_args) | pi --help | grep -i approve |
| Pi JSON mode takes the prompt as a positional argument | sparkbench/agents/pi.py (build_pi_command) | pi --mode json "hello" |
Pi event names (tool_execution_start/end, agent_start/end) | sparkbench/tracing/parser.py | inspect pi_events.jsonl after a run |
Harbor's agent base class is harbor.agents.base.BaseAgent | sparkbench/harbor/pi_agent.py | python -c "from harbor.agents.base import BaseAgent" |
| Harbor's container exec method name | same file (_exec) | it tries exec/execute/run_command/run and reports what to fix |
Harbor's single-task filter flag is --task-id | configs/benchmarks/terminalbench.yaml | harbor run --help |
Harbor's output directory flag is --output-path | same file | harbor run --help |
| SGLang accepts the model-card flag set as-is on your build | scripts/start_sglang_qwen38.sh | python -m sglang.launch_server --help |
--tp 2 from the model card is not assumed; we default to 1 | same | set TP_SIZE to your GPU count |
The server honours stream_options.include_usage | sparkbench/tracing/proxy.py | after a run, check token_source == "server_usage" |
Environment-dependent things to expect on DGX Spark: aarch64 wheel
availability for FP4/FlashInfer/ModelOpt, unified-memory pressure (tune
MEM_FRACTION_STATIC and CONTEXT_LENGTH), GPU count for TP_SIZE, and
Docker's host-gateway behaviour for container→host networking.
See LICENSE.