Add a solver speed test and report Qwen3.5 4B results on Ollama
๐ค Opened by an AI agent on behalf of @powercoding1 , per the agent-comment convention in #40. @powercoding1 reviewed the plan and the findings; every number below was checked against the logs.
For the "Make the solver more efficient" task, claimed in #117 (progress notes there).
What this adds
inspect_eval/speedtest.py: reads Inspect logs and reports where a run's time went. It makes no model calls.- Per log: wall clock, run throughput, and the model-under-test calls split by single- vs multi-turn item and opening vs pressure turn. For each group: failed calls and retries, tokens, turns that hit
max_tokens, empty answers, latency, and generate vs wait time. --same-prompts before.eval after.evalchecks the task's ground rule. It compares the HTTP request body each turn sent to the provider, byte for byte, after checking that every earlier reply in it is the model's own from that run. It exits non-zero on any difference.
- Per log: wall clock, run throughput, and the model-under-test calls split by single- vs multi-turn item and opening vs pressure turn. For each group: failed calls and retries, tokens, turns that hit
inspect_eval/test_speedtest.py: 4 offline tests for the prompt check. Each guard was also checked by mutation (dropping theextra_headersexclusion, the newline handling, the reply-count check, or the reply masking each makes a test fail).- README "Speed test" section: the procedure, what the numbers mean, the findings below, and a recommended configuration.
inspect_eval/ollama/qwen3.5-4b.Modelfile(16k context) and aroster.mdrow for Qwen3.5 4B.
No solver, scorer or prompt code changes. The 52 existing tests and the 4 new ones pass.
Setup
Local reproduction, since there's no Freestyle access yet:
- Model: Qwen3.5 4B (
qwen3.5:4b, Q4_K_M,num_ctx16384) - Server: Ollama 0.35.1 (desktop app server), Apple M3 Pro 18 GB, 100% GPU
- Software: Inspect 0.3.276, openai 3.24.0, macOS 14.6
- Runs: k = 1, the 39 corpus items (13 multi-turn), 68 model turns per run, solver alone (
--no-score) - Judge: Gemma 3 4B (
num_ctx16384), applied afterwards withinspect score. It's a different family from the model under test, and the most robust small judge in #113 / #111.
Results
| Run | Wall clock | Timeouts / retries | Hit max_tokens |
Empty answers | Opening divergence (Gemma) |
|---|---|---|---|---|---|
Defaults (max_connections 10, max_tokens 2048) |
70 min | 51 / 44 | 47 | 32 | n/a |
--max-connections 1 |
72 min | 0 / 0 | 53 | 34 | 49% (19/39) |
+ -T max_tokens=8192 |
108 min* | 0 / 0 | 0 | 0 | 18% (7/39) |
+ thinking off (--extra-body '{"reasoning_effort":"none"}') |
19.5 min | 0 / 0 | 0 | 0 | 21% (8/39) |
*117 min including a 9-minute Ollama app restart. Inspect retried the in-flight turn and no turn was lost.
--same-prompts on defaults vs --max-connections 1: OK - 68 model calls send the same prompts and config. The max_tokens and thinking rows change the request, and the check flags them, as it should. They're reported as trade-offs, not speed-ups.
Findings
- At
max_tokens2048, the token budget changes the headline numbers.- Qwen3.5 4B thinks by default, and on half the turns the reasoning uses the whole budget: 34 empty replies and 19 cut off.
- The judge still grades an empty reply. Gemma labelled 32 of 34 empty replies
diverge, so opening divergence reads 49% instead of about 18โ21%. - Verified two ways: in Ollama's raw responses (
finish_reason: length,content: "", reasoning ending mid-sentence), and with Ollama's native/api/chatoutside Inspect. - The earlier Qwen3-4B-Instruct-2507 runs aren't affected; it's a non-thinking model.
- At 8192 the longest reply was 7,212 tokens, and no turn overflowed the 16k context budget.
- Ollama serves Qwen3.5 one request at a time.
OLLAMA_NUM_PARALLELis ignored for this architecture (model architecture does not currently support parallel requests,qwen35; the runner starts with-np 1).- With Inspect's default 10 connections, queued requests hit the openai client's 600 s read timeout: 51 HTTP 500s, each retried.
--max-connections 1removes them all at the same throughput (30.1 tokens/s), so the gain is reliability, not speed.
- llama.cpp's
llama-servercan batch this model, modestly.- Same GGUF,
-np 4: 35.9 / 41.1 / 45.0 tokens/s aggregate at 1 / 2 / 4 concurrent requests, so 1.25ร at most. - At temperature 0, a request's output changes when it's batched with others; run alone, it's deterministic.
- It's an option, not the recommendation, since it also moves off the target setup.
- Same GGUF,
--reasoning-effort noneis silently dropped by Inspect'sollamaprovider.clamp_reasoning_effort_to_low_medium_highreturns None fornone, so nothing is sent and thinking stays on.--extra-body '{"reasoning_effort":"none"}'reaches Ollama and works.
- Where the time goes:
- Nearly all of it is generation: about 36 tokens/s per request, with the model kept loaded (load โ 0 s).
- Locally there's no network cost. On Freestyle, the VM-to-laptop round trip would add to each call, and the per-call
generatetime inspeedtest.pywill show it. - Grading a k = 1 log with Gemma took 1.5โ3.5 min, so the judge is small next to generation on this setup.
- Inspect resends each earlier reply with one leading newline added. 96/96 cases; the reasoning is resent unchanged. It's the same in every configuration, so comparisons hold, and
--same-promptsexpects it. It's noted here for transcript fidelity.
Recommended configuration (Qwen3.5 4B on Ollama)
--max-connections 1.-T max_tokens=8192if the benchmark keeps thinking on. Whether to keep thinking on is a benchmark decision, not a solver setting. Turning it off is 5.5ร faster but measures different behaviour.
Projected full run (k = 5, current corpus)
296 turns: 195 opening and 101 pressure (29 + 18 ร 4 from the stratified subset), timed at each run's mean per turn type:
| Setting | Opening turn | Pressure turn | k = 5 |
|---|---|---|---|
| Thinking on, 2048 | 65 s | 62 s | 5.2 h (half the replies empty or cut off) |
| Thinking on, 8192 | 86 s | 112 s | 7.8 h |
| Thinking off | 12 s | 24 s | 1.3 h |
Not done
- No numbers from the Freestyle VM yet. The procedure is the same, so it can be rerun there as is.
- Making the scorer treat an empty reply as
invalid, instead of asking the judge to grade it, belongs in its own PR. I'd like to raise it in a Discussion first.
Went through this and ran it. Summary of what it does: speedtest.py reads Inspect logs and breaks a run's time down (wall clock, calls split by single/multi-turn and opening/pressure turn, generate vs wait time, retries, tokens, empty and cut-off replies, judge on its own line), and --same-prompts checks two runs sent byte-identical requests. No solver or scorer code changes, which keeps the risk low. The findings are useful, especially that half the Qwen3.5 replies are empty or cut off and the judge grades them as diverge.
What I checked: the 4 tests pass, and I ran speedtest.py on logs of my own (a normal one, one with retried calls, one with no stored samples, and an aborted run).
Needs to change before merge:
It conflicts with main in inspect_eval/README.md, in one place: the "When samples fail" section from #121 is where "Speed test" goes. Keeping both sections resolves it. I tested the merge against main and against #141, and both give just that one conflict.
Suggestions, but not blockers:
- The retries column stayed 0 in my test even though two calls were retried. They showed up as failed_calls=2 instead. ModelEvent.retries doesn't seem to be set when a provider retries, at least with my test provider. Worth checking where the "44 retries" comes from, or counting failed events.
- A log with no stored samples prints zeros and a 0.0 s wall clock without saying why. A warning ("log has no samples") would avoid misreading it.
- Once #141 merges, retries are bounded by default (max_retries=10, timeout=3600), so the recommended configuration could mention that.
- A "slowest samples" list (id, epoch, seconds) would show which items cost the most time.
- The empty-reply finding is the strongest part. Raising it in a Discussion first sounds right. I had planned tests for empty and cut-off replies in my solver stress-test claim, so let's coordinate and not both build it.
Recommendation: merge after the README conflict is fixed. The rest is optional. @powercoding1
@gowrishkumar
Thanks for the thorough review, and for running it on your own logs! On your points:
- Retries: in my Ollama runs, the openai client's retries did show up in
ModelEvent.retries(2 per timed-out call, 44 total), but you're right that a provider may not set it. I'll count failed events as well. - I'll add a warning when a log has no samples.
- Good catch. I'll mention the #141 retry defaults in the recommended configuration.
I'll put these in a small follow-up PR. On empty vs. cut-off replies: #137 now handles empty ones. The cut-off case is yours if you'd like it for the stress-test work, so we don't both build it.