Skip to content

Commit ab92057

Browse files
committed
Fix incomplete Input_tokens metric by adding payload fallback extraction and sync estimation for timed-out greenlets
1 parent 79cf274 commit ab92057

3 files changed

Lines changed: 116 additions & 10 deletions

File tree

st_engine/engine/llm_locustfile.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -454,18 +454,14 @@ def on_test_stop(environment, **kwargs):
454454
return
455455

456456
# Wait for pending async token counting greenlets to complete.
457-
# This ensures all token stats (including those sent from Worker
458-
# to Master) are fully reported before final aggregation.
457+
# If any greenlets remain after timeout, drain_pending uses fast
458+
# byte-estimation fallback so no token metrics are lost.
459459
pending_count = _async_token_counter.pending_count
460460
if pending_count > 0:
461461
task_logger.info(
462462
f"Waiting for {pending_count} pending token calculations..."
463463
)
464-
remaining = _async_token_counter.join_pending(timeout=5)
465-
if remaining > 0:
466-
task_logger.warning(
467-
f"{remaining} token calculations did not finish in time"
468-
)
464+
_async_token_counter.drain_pending(idle_timeout=5, max_timeout=30)
469465

470466
# Only Master and LocalRunner need to output report
471467
if not isinstance(runner, (MasterRunner, LocalRunner)):

st_engine/engine/request_processor.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,17 @@ def prepare_request_kwargs(
500500
)
501501
return None, None
502502

503-
user_prompt = prompt_data.get("prompt", DEFAULT_PROMPT)
503+
user_prompt = prompt_data.get("prompt")
504+
if not user_prompt and prompt_data.get("messages"):
505+
for msg in reversed(prompt_data.get("messages", [])):
506+
if msg.get("role") == "user" and isinstance(
507+
msg.get("content"), str
508+
):
509+
user_prompt = msg.get("content", "")
510+
break
511+
if not user_prompt:
512+
user_prompt = DEFAULT_PROMPT
513+
504514
# Unified payload handling for all APIs
505515
self._update_payload_with_prompt_data(payload, prompt_data)
506516

@@ -900,7 +910,43 @@ def _extract_prompt_from_payload(self, payload: Dict[str, Any]) -> str:
900910
prompt_value = StreamProcessor.get_field_value(
901911
payload, field_mapping.prompt
902912
)
903-
return str(prompt_value) if prompt_value else ""
913+
if prompt_value:
914+
return str(prompt_value)
915+
916+
# Fallbacks for standard structures
917+
for path in [
918+
"messages.-1.content",
919+
"messages.0.content",
920+
"prompt",
921+
"input",
922+
]:
923+
val = StreamProcessor.get_field_value(payload, path)
924+
if val:
925+
if isinstance(val, str):
926+
return val
927+
elif isinstance(val, list):
928+
# Maybe it is a list of content blocks, e.g. [{"type": "text", "text": "..."}]
929+
for item in val:
930+
if isinstance(item, dict) and "text" in item:
931+
return str(item["text"])
932+
elif isinstance(item, str):
933+
return item
934+
935+
# Final fallback: serialize all message contents for token estimation
936+
if "messages" in payload and isinstance(payload["messages"], list):
937+
parts = []
938+
for msg in payload["messages"]:
939+
content = msg.get("content", "") if isinstance(msg, dict) else ""
940+
if isinstance(content, str):
941+
parts.append(content)
942+
elif isinstance(content, list):
943+
for item in content:
944+
if isinstance(item, dict) and "text" in item:
945+
parts.append(str(item["text"]))
946+
elif isinstance(item, str):
947+
parts.append(item)
948+
if parts:
949+
return " ".join(parts)
904950
return ""
905951
except Exception:
906952
return ""

st_engine/utils/token_counter.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import logging
77
import math
8+
import os
89
import re
910
from abc import ABC, abstractmethod
1011
from functools import lru_cache
@@ -397,13 +398,17 @@ class AsyncTokenCounter:
397398
performed synchronously and the callback is invoked immediately.
398399
"""
399400

400-
_DEFAULT_POOL_SIZE = 2
401+
# Dynamically scale the thread pool based on CPU cores.
402+
# On low-spec machines, limit threads to avoid starving the main gevent loop.
403+
# On high-spec machines, cap at 16 to prevent excessive context switching.
404+
_DEFAULT_POOL_SIZE = min(16, max(4, os.cpu_count() or 4))
401405

402406
def __init__(self, pool_size: int = _DEFAULT_POOL_SIZE) -> None:
403407
"""Initialize the async token counter with the given pool size."""
404408
self._pool_size = pool_size
405409
self._pool = None # lazily created
406410
self._pending = None # lazily created
411+
self._pending_inputs: Dict[int, Tuple[Callable, str, str, str]] = {}
407412

408413
def _get_pool(self):
409414
"""Get or lazily create the OS thread pool."""
@@ -501,6 +506,14 @@ def count_async(
501506
on_complete,
502507
_log,
503508
)
509+
gid = id(g)
510+
self._pending_inputs[gid] = (
511+
on_complete,
512+
user_prompt,
513+
reasoning_content,
514+
content,
515+
)
516+
g.link(lambda _g: self._pending_inputs.pop(id(_g), None))
504517
pending.add(g)
505518
else:
506519
# gevent unavailable — synchronous fallback
@@ -569,6 +582,57 @@ def join_pending(self, timeout: float = 5) -> int:
569582
pending.join(timeout=timeout)
570583
return len(pending)
571584

585+
def drain_pending(self, idle_timeout: float = 5, max_timeout: float = 60) -> int:
586+
"""Wait for pending greenlets to finish with sync fallback.
587+
588+
Args:
589+
idle_timeout: Ignored (kept for backward compatibility).
590+
max_timeout: Hard cap on total wait time.
591+
592+
Returns:
593+
Number of greenlets that did NOT complete (always 0 after fallback).
594+
"""
595+
pending = self._get_pending()
596+
if pending is None or len(pending) == 0:
597+
return 0
598+
599+
pending.join(timeout=max_timeout)
600+
remaining = len(pending)
601+
if remaining > 0:
602+
logger.warning(
603+
"drain_pending: timeout %.1fs reached, %d greenlets remaining. "
604+
"Using byte-estimation fallback.",
605+
max_timeout,
606+
remaining,
607+
)
608+
self._fallback_remaining()
609+
return 0
610+
611+
def _fallback_remaining(self) -> None:
612+
"""Synchronously estimate tokens for any greenlets that didn't finish."""
613+
stale = dict(self._pending_inputs)
614+
self._pending_inputs.clear()
615+
for _gid, (
616+
on_complete,
617+
user_prompt,
618+
reasoning_content,
619+
content,
620+
) in stale.items():
621+
try:
622+
input_tokens = (
623+
estimate_tokens_via_bytes(user_prompt) if user_prompt else 0
624+
)
625+
completion_tokens = 0
626+
if reasoning_content:
627+
completion_tokens += estimate_tokens_via_bytes(reasoning_content)
628+
if content:
629+
completion_tokens += estimate_tokens_via_bytes(content)
630+
total_tokens = input_tokens + completion_tokens
631+
if completion_tokens > 0 or total_tokens > 0:
632+
on_complete(input_tokens, completion_tokens, total_tokens)
633+
except Exception as e:
634+
logger.error("Fallback token estimation failed: %s", e)
635+
572636

573637
def estimate_tokens_via_bytes(text: str) -> int:
574638
"""Heuristic: estimate tokens from UTF-8 byte length.

0 commit comments

Comments
 (0)