Skip to content

feat(generation): Track generation tokens and refactor with ParsedRecord - #392

Merged
seayang-nv merged 7 commits into
mainfrom
seayang/364-add-used-tokens-to-summary
Apr 22, 2026
Merged

feat(generation): Track generation tokens and refactor with ParsedRecord#392
seayang-nv merged 7 commits into
mainfrom
seayang/364-add-used-tokens-to-summary

Conversation

@seayang-nv

@seayang-nv seayang-nv commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Tracks token usage throughout the generation pipeline so operators can see how efficiently the LLM's output budget is being spent -- how many tokens land in valid records vs. invalid or non-record overhead.

  • Data model: Introduces ParsedRecord (text, parsed dict, error, token count on one object) and reshapes ParsedResponse to hold list[ParsedRecord], replacing the prior parallel lists (valid_records / invalid_records / errors / *_token_counts) that previously threaded through extraction, validation, group-level rejection, batch aggregation, and data-action post-processing. ← new
  • Each record is tokenized at extraction time (using the vLLM engine's tokenizer) and the count rides along on its ParsedRecord through validation, group-level rejection, and time-series cascade invalidation.
  • New metrics are surfaced in batch progress logs, GenerateJobResults, and the final SafeSynthesizerSummary / W&B: completion token totals, valid/invalid/non-record breakdowns, tokens-per-second throughput (with and without startup), valid-tokens-per-second, and tokenization overhead.
  • Training-eval callback path (InferenceEvalCallback) now passes the tokenizer through and forwards per-row completion-token counts so the same metrics apply during training-time evaluation.
  • Fixes the rich table renderer so duration fields (_sec, _seconds) are displayed as plain floats instead of percentages.

Example summary for patient_events.csv

     Batch Generation Summary     
+--------------------------------+
| Metric                | Value  |
|-----------------------+--------|
| Num Prompts           | 100    |
| Num Valid Records     | 357    |
| Num Invalid Records   | 115    |
| Valid Record Fraction | 76.00% |
| Completion Tokens     | 72566  |
| Valid Record Tokens   | 51694  |
| Invalid Record Tokens | 15628  |
| Non Record Tokens     | 5244   |
+--------------------------------+
 
2026-04-15T18:03:19.083 | Nemo Safe Synthesizer |  user    |  info  |  batch.py: Batch.log_summary: 270 


           Error Statistics           
+------------------------------------+
| Metric                    | Value  |
|---------------------------+--------|
| Groupby Generation Failed | 81.00% |
| Invalid Field Value       | 13.00% |
| Invalid Json              | 6.00%  |
+------------------------------------+
 
2026-04-15T18:03:19.085 | Nemo Safe Synthesizer |  user    |  info  |  vllm_backend.py: VllmBackend._log_batch_timing_and_progress: 485 


           Batch Progress            
+-----------------------------------+
| Metric                  | Value   |
|-------------------------+---------|
| Records Per Second      | 8.31    |
| Duration Seconds        | 42.98   |
| Valid Records Generated | 753     |
| Target Records          | 1000    |
| Progress Fraction       | 75.30%  |
| Tokens Per Second       | 1688.30 |
| Valid Tokens Per Second | 1202.70 |
+-----------------------------------+



Pre-Review Checklist

Ensure that the following pass:

  • make format && make check or via prek validation.
  • make test passes locally
  • make test-e2e passes locally
  • make test-ci-container passes locally (recommended)
  • GPU CI status check passes -- comment /sync on this PR to trigger a run (auto-triggers on ready-for-review)

Pre-Merge Checklist

  • New or updated tests for any fix or new behavior
  • Updated documentation for new features and behaviors, including docstrings for API docs.

Other Notes

@seayang-nv seayang-nv changed the title feature(generation): Track generation tokens feat(generation): Track generation tokens Apr 13, 2026
Comment thread generation_token_statistics_f26c553b.plan.md Outdated
Comment thread generation_token_statistics_f26c553b.plan.md Outdated
Comment thread generation_token_statistics_f26c553b.plan.md Outdated
Comment thread generation_token_statistics_f26c553b.plan.md Outdated
Comment thread generation_token_statistics_f26c553b.plan.md Outdated
@seayang-nv
seayang-nv marked this pull request as ready for review April 15, 2026 18:08
@seayang-nv
seayang-nv requested a review from a team as a code owner April 15, 2026 18:08

@nina-xu nina-xu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good!

Love that you have a test plan! I think some end-to-end testing should be added to the testing plan, like can we see what the changes look like in

  • the log (you already have that)
  • wandb
  • the results summary json
  • whatever else is changed

in the three modes

  • non-group by
  • group by
  • time series?

Comment thread src/nemo_safe_synthesizer/config/external_results.py Outdated
Comment thread src/nemo_safe_synthesizer/generation/processors.py Outdated
Comment thread tests/data_processing/test_records.py Outdated

@kendrickb-nvidia kendrickb-nvidia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few small comments, and then majority are around ExtractionResult and these parallel lists of valid, invalid, errors, valid tokens, invalid tokens that we're passing all over. I think the code would be simpler if we switch to lists of ParsedRecord where the ParsedRecord instance contains an is_valid, error messages, # tokens, etc. Instead of the ParsedResponse and ExtractionResult with several parallel lists. But that would also be a bigger refactor (and may be more complicated than I think).

Curious on other thoughts on the patterns @binaryaaron @mckornfield . @seayang-nv let's chat more tomorrow.

Comment thread src/nemo_safe_synthesizer/config/external_results.py Outdated
Comment thread tests/generation/conftest.py Outdated
Comment thread src/nemo_safe_synthesizer/config/external_results.py
Comment thread src/nemo_safe_synthesizer/data_processing/record_utils.py Outdated
Comment thread src/nemo_safe_synthesizer/data_processing/record_utils.py Outdated
Comment thread src/nemo_safe_synthesizer/generation/processors.py Outdated
Comment thread src/nemo_safe_synthesizer/generation/processors.py Outdated
Comment thread src/nemo_safe_synthesizer/generation/processors.py Outdated
Comment thread src/nemo_safe_synthesizer/generation/processors.py Outdated
Comment thread src/nemo_safe_synthesizer/generation/processors.py Outdated

@binaryaaron binaryaaron left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, looks good.

main issues

  • Training path - should we do this? can we add it?
  • src/nemo_safe_synthesizer/training/huggingface_backend.py:507: create_processor(...) is called without tokenizer=, so InferenceEvalCallback can't collect token metrics. self.tokenizer is in scope.
  • src/nemo_safe_synthesizer/training/callbacks.py:156: batch.process(idx, text) doesn't pass completion_tokens=, so this path always records 0 tokens and the total_completion_tokens > 0 gates downstream drop the metrics.

Follow-ups

  1. Batch-tokenize: one tokenizer(list_of_strings, add_special_tokens=False, return_length=True) call per extraction instead of N·K individual tokenizer.encode(str) calls. Gate on measured cost — instrument tokenization_overhead_sec / generation_time_sec in production first. <1% → defer, >5% → prioritize. An offset-mapping approach was rejected; it depends on tokenize(decode(ids)) == ids holding for the vLLM detokenizer.

  2. Parallel-list bookkeeping: 5 aligned lists across 5 files, in-place surgery in _reject_group_records, and the zip(... or [0] * len(...)) workaround in _apply_data_actions_fn. Two refactors collapse it: a ValidationError NamedTuple for the (message, validator) pair, and list[ClassifiedRecord] with a RecordStatus enum so text, count, error, and status live on the same object. The _reject_group_records text/count mismatch flagged inline becomes unrepresentable under the second.

  3. Aggregation: TokenStats with __add__ collapses the 10 sum-properties across Batch and GenerationBatches into one aggregable dataclass.

Comment thread src/nemo_safe_synthesizer/data_processing/record_utils.py Outdated
Comment thread src/nemo_safe_synthesizer/generation/processors.py Outdated
Comment thread src/nemo_safe_synthesizer/generation/processors.py Outdated
Comment thread src/nemo_safe_synthesizer/data_processing/record_utils.py
Comment thread src/nemo_safe_synthesizer/generation/processors.py Outdated
Comment thread src/nemo_safe_synthesizer/config/external_results.py
Comment thread src/nemo_safe_synthesizer/generation/vllm_backend.py
Comment thread src/nemo_safe_synthesizer/generation/timeseries_backend.py Outdated
Comment thread src/nemo_safe_synthesizer/generation/results.py Outdated
Comment thread src/nemo_safe_synthesizer/generation/batch.py Outdated
@binaryaaron

binaryaaron commented Apr 16, 2026

Copy link
Copy Markdown
Collaborator

Consolidating with @kendrickb-nvidia's review so @seayang-nv doesn't have to triangulate. we roughly agree on the ParsedRecord / ClassifiedRecord bit.

Direct overlap across reviews

Issue Comments Suggested resolution
W&B None-filter at external_results.py:190 / :195 nina, kendrick, my #10 Kendrick's: restore wandb.log(metrics) and add a comment that the None-logging is intentional.
Test helpers in conftest.py:19 kendrick, my #15 Either inline into test_processors.py (kendrick) or split out _helpers.py (me). Pick one.
Parallel valid/invalid/errors/tokens lists across ExtractionResult / ParsedResponse both kendrick and i Same refactor: list[ParsedRecord] with is_valid / error / tokens on the record. Kendrick's processors.py:405 flags a smaller intermediate step — drop ExtractionResult and have record_utils.py return ParsedResponse directly. Useful stepping stone toward the bigger move.

Points kendrick raised that i did not and that I endorse

  • external_results.py:146: document the field invariants (num_non_record_tokens = num_completion_tokens - num_valid_record_tokens - num_invalid_record_tokens, tokens_per_completion = num_completion_tokens / num_prompts). Would have made the _reject_group_records text/count mismatch (my chore: sync 5737 from nmp #7) catchable at review time.
  • processors.py:85: property that returns a callable is indeed odd. Either rename to _encode_func or collapse to def _count_tokens(self, s: str) -> int: returning 0 when the tokenizer is absent.
  • record_utils.py:196: _timed_encode is a local helper, not a method — leading underscore is nonstandard here.
  • processors.py:82: log tokenizer-present in the init debug line.

Stale comments

The 5 inline comments on generation_token_statistics_f26c553b.plan.md are anchored to 7ebe107c and the file was deleted in 5d084b79. Current status:

  • "per-record token stats" (3081478331) — resolved, implementation is per-record.
  • "time.monotonic() vs perf_counter()" (3081487882) — resolved, current code uses time.monotonic().
  • "deferred tokenizer assignment" (3081497203) — still open; ties into the training-path gap in my summary (huggingface_backend.py:507, callbacks.py:156). The deferred slot exists but isn't fed on that path.
  • The remaining two (total-tokens source, group-by stats semantics) should be confirmed against the current implementation.

Overall -

  1. decide on training-path omission. it's not a blocker imo; generation stats are more important.
  2. Restore wandb.log(metrics) without the None-filter, with a comment.
  3. Decide whether to do the ParsedRecord refactor in this PR, or file it as a follow-up. doing Kendrick's smaller ExtractionResult → ParsedResponse change would be sufficient for now.

@codecov

codecov Bot commented Apr 17, 2026

Copy link
Copy Markdown

Signed-off-by: Sean Yang <seayang@nvidia.com>
Signed-off-by: Sean Yang <seayang@nvidia.com>
Signed-off-by: Sean Yang <seayang@nvidia.com>
Signed-off-by: seayang <seayang@nvidia.com>
Signed-off-by: Sean Yang <seayang@nvidia.com>
@seayang-nv
seayang-nv force-pushed the seayang/364-add-used-tokens-to-summary branch from 146d1ca to 832f666 Compare April 17, 2026 20:03
@seayang-nv seayang-nv changed the title feat(generation): Track generation tokens feat(generation): Track generation tokens and refactor with ParsedRecord Apr 17, 2026
Comment thread tests/data_processing/test_records.py Outdated
Comment thread src/nemo_safe_synthesizer/data_processing/record_utils.py
Signed-off-by: Sean Yang <seayang@nvidia.com>
Signed-off-by: Sean Yang <seayang@nvidia.com>
Comment thread tests/test_results.py Fixed
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Signed-off-by: seayang-nv <seayang@nvidia.com>

@binaryaaron binaryaaron left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking the ParsedRecord refactor all the way through, Sean. The new metrics surfaced through the summary / W&B / batch progress will be really helpful for tuning runs.

Ran an astnav sweep and left four suggestions inline — all optional for this PR, none of them change behavior. happy to file follow-up issues for any we don't land here.

One pre-existing observation (not inline because it's outside the diff): over in make_nss_results at src/nemo_safe_synthesizer/results.py:156, make_nss_summary(...) gets called before the None / empty-DataFrame guards on lines 157-160 run, so a None input would AttributeError inside make_nss_summary before the ValueError fires. One-line reorder to move the two guards above the call. Totally fine to skip or handle separately since this PR didn't touch it.

Comment thread src/nemo_safe_synthesizer/results.py
Comment thread src/nemo_safe_synthesizer/generation/batch.py
Comment thread src/nemo_safe_synthesizer/generation/processors.py
Comment thread src/nemo_safe_synthesizer/data_processing/record_utils.py
@seayang-nv
seayang-nv merged commit 8025eb1 into main Apr 22, 2026
15 checks passed
@seayang-nv
seayang-nv deleted the seayang/364-add-used-tokens-to-summary branch April 22, 2026 15:48
seayang-nv added a commit that referenced this pull request Apr 28, 2026
<!-- SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION
& AFFILIATES. All rights reserved. -->
<!-- SPDX-License-Identifier: Apache-2.0 -->

<!-- Thank you for contributing to Safe Synthesizer! -->

# Summary

Addresses the four nit suggestions @binaryaaron left on #392 after the
main token-tracking refactor was merged. No behavior change — all four
are cleanups / readability improvements around code introduced in that
PR.
Closes #426.
## Changes
### 1. `results.py` — collapse repetitive token-field wiring
Replaced the 13-line block of `num_valid_records = None`, `if
isinstance(results, GenerateJobResults): num_valid_records =
results.num_valid_records`, ...,
`SafeSynthesizerSummary(num_valid_records=num_valid_records, ...)` with:
- `_GENERATE_RESULT_FIELDS` tuple — names that pass through verbatim
from `GenerateJobResults` to `SafeSynthesizerSummary`.
- `_REPORT_SCORE_FIELDS` dict — `{summary_field: report_score_name}` for
report-derived scores.
`make_nss_summary` now does a single `getattr` loop for the gen fields,
computes `valid_record_token_fraction` inline, projects report scores
(or `None`) via a dict comprehension, and `**`-unpacks both into one
`SafeSynthesizerSummary(...)` call. ~45 lines gone.
### 2. `batch.py` — single-pass token aggregation
Added a private `_record_token_totals() -> (valid, invalid)` that walks
`self._responses` exactly once. The three token properties all share it:
- `total_valid_record_tokens` / `total_invalid_record_tokens`
destructure one side.
- `total_non_record_tokens` gets both and reuses them for the
clamped-to-zero warning context (so the `extra={}` payload no longer
re-triggers the scan).
### 3. `processors.py` — extract `_valid_parsed` helper
The comprehension `[r.parsed for r in group_records if r.is_valid and
r.parsed is not None]` appeared twice inside
`GroupedDataProcessor._process_text_generation` (non-unique-groupby
check and order_by check). Hoisted into a module-level helper with a
docstring explaining the `is not None` narrows the type for static
analysis (valid records always carry `parsed`).
### 4. `record_utils.py` — reframe `ParsedResponse` docstring
Dropped the "backward compatibility with callers that want the old
parallel-list shape" framing. The doc now describes `valid_records` /
`invalid_records` / `errors` as convenience views that project `records`
into the shapes expected by downstream aggregation code (parsed dicts /
original text / `(msg, validator)` tuples).

## Pre-Review Checklist

<!-- These checks should be completed before a PR is reviewed, -->
<!-- but you can submit a draft early to indicate that the issue is
being worked on. -->

Ensure that the following pass:

- [x] `make format && make check` or via prek validation.
- [x] `make test` passes locally
- [x] `make test-e2e` passes locally
- [ ] `make test-ci-container` passes locally (recommended)
- [ ] GPU CI status check passes -- comment `/sync` on this PR to
trigger a run (auto-triggers on ready-for-review)

## Pre-Merge Checklist

<!-- These checks need to be completed before a PR is merged, -->
<!-- but as PRs often change significantly during review, -->
<!-- it's OK for them to be incomplete when review is first requested.
-->

- [x] New or updated tests for any fix or new behavior
- [x] Updated documentation for new features and behaviors, including
docstrings for API docs.

## Other Notes

<!-- Please add the issue number that should be closed when this PR is
merged. -->
- Closes #426

---------

Signed-off-by: Sean Yang <seayang@nvidia.com>
Signed-off-by: seayang <seayang@nvidia.com>
Signed-off-by: seayang-nv <seayang@nvidia.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track generated and used tokens

4 participants