Add a solver speed test and report Qwen3.5 4B results on Ollama

#119
by powercoding1 - opened

๐Ÿค– 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.eval checks 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.
  • inspect_eval/test_speedtest.py: 4 offline tests for the prompt check. Each guard was also checked by mutation (dropping the extra_headers exclusion, 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 a roster.md row 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_ctx 16384)
  • 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_ctx 16384), applied afterwards with inspect 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

  1. At max_tokens 2048, 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/chat outside 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.
  2. Ollama serves Qwen3.5 one request at a time.
    • OLLAMA_NUM_PARALLEL is 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 1 removes them all at the same throughput (30.1 tokens/s), so the gain is reliability, not speed.
  3. llama.cpp's llama-server can 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.
  4. --reasoning-effort none is silently dropped by Inspect's ollama provider.
    • clamp_reasoning_effort_to_low_medium_high returns None for none, so nothing is sent and thinking stays on.
    • --extra-body '{"reasoning_effort":"none"}' reaches Ollama and works.
  5. 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 generate time in speedtest.py will 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.
  6. 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-prompts expects it. It's noted here for transcript fidelity.

Recommended configuration (Qwen3.5 4B on Ollama)

  • --max-connections 1.
  • -T max_tokens=8192 if 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:

  1. 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.
  2. 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.
  3. Once #141 merges, retries are bounded by default (max_retries=10, timeout=3600), so the recommended configuration could mention that.
  4. A "slowest samples" list (id, epoch, seconds) would show which items cost the most time.
  5. 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

stationhouse changed pull request status to merged

@gowrishkumar
Thanks for the thorough review, and for running it on your own logs! On your points:

  1. 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.
  2. I'll add a warning when a log has no samples.
  3. 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.

Sign up or log in to comment