From cbcc5aab52f0963dcb1450aeed508b31e668669d Mon Sep 17 00:00:00 2001 From: Yingge He Date: Tue, 15 Sep 2026 02:18:04 -0700 Subject: [PATCH 1/4] feat(llm): carry KServe requested outputs on tensor requests Forward ModelInferRequest.outputs (name and parameters) onto NvCreateTensorRequest so workers can honor classification and output selection. Signed-off-by: Yingge He --- lib/llm/src/grpc/service/tensor.rs | 99 +++++++++++++++++++++++++++++- lib/llm/src/protocols/tensor.rs | 13 ++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/lib/llm/src/grpc/service/tensor.rs b/lib/llm/src/grpc/service/tensor.rs index f1d628fb0042..d488c1ea89c8 100644 --- a/lib/llm/src/grpc/service/tensor.rs +++ b/lib/llm/src/grpc/service/tensor.rs @@ -28,7 +28,7 @@ use crate::http::service::{ use crate::protocols::tensor; use crate::protocols::tensor::{ - NvCreateTensorRequest, NvCreateTensorResponse, Tensor, TensorMetadata, + NvCreateTensorRequest, NvCreateTensorResponse, RequestedOutput, Tensor, TensorMetadata, }; use crate::grpc::service::kserve::inference; @@ -303,10 +303,20 @@ impl TryFrom for NvCreateTensorRequest { }, model: request.model_name.clone(), tensors: Vec::new(), + outputs: Vec::with_capacity(request.outputs.len()), parameters, nvext: None, }; + // Requested outputs select which model outputs come back and how; their + // parameters (e.g. `classification`) are interpreted by the worker. + for output in &request.outputs { + tensor_request.outputs.push(RequestedOutput { + name: output.name.clone(), + parameters: convert_kserve_to_dynamo_params(&output.parameters)?, + }); + } + // iterate through inputs for (idx, input) in request.inputs.into_iter().enumerate() { // Extract per-tensor parameters @@ -853,6 +863,93 @@ impl FromStr for tensor::DataType { #[cfg(test)] mod tests { use super::*; + use inference::model_infer_request::InferRequestedOutputTensor; + use std::collections::HashMap; + + fn param(choice: Option) -> inference::InferParameter { + inference::InferParameter { + parameter_choice: choice, + } + } + + fn classification(k: i64) -> HashMap { + HashMap::from([( + "classification".to_string(), + param(Some(ParameterChoice::Int64Param(k))), + )]) + } + + fn requested_output( + name: &str, + parameters: HashMap, + ) -> InferRequestedOutputTensor { + InferRequestedOutputTensor { + name: name.to_string(), + parameters, + } + } + + fn infer_request(outputs: Vec) -> inference::ModelInferRequest { + inference::ModelInferRequest { + model_name: "classifier".to_string(), + outputs, + ..Default::default() + } + } + + #[test] + fn requested_outputs_keep_their_parameters() { + let converted = NvCreateTensorRequest::try_from(infer_request(vec![ + requested_output("OUTPUT0", classification(3)), + requested_output("OUTPUT1", HashMap::new()), + ])) + .unwrap(); + + assert_eq!( + converted.outputs, + vec![ + RequestedOutput { + name: "OUTPUT0".to_string(), + parameters: tensor::Parameters::from([( + "classification".to_string(), + tensor::ParameterValue::Int64(3), + )]), + }, + RequestedOutput { + name: "OUTPUT1".to_string(), + parameters: tensor::Parameters::new(), + }, + ] + ); + assert_eq!( + serde_json::to_value(&converted.outputs[0]).unwrap(), + serde_json::json!({"name": "OUTPUT0", "parameters": {"classification": {"int64": 3}}}) + ); + } + + #[test] + fn requested_outputs_empty_are_omitted_from_json() { + let converted = NvCreateTensorRequest::try_from(infer_request(vec![])).unwrap(); + assert!(converted.outputs.is_empty()); + assert!( + serde_json::to_value(&converted) + .unwrap() + .get("outputs") + .is_none() + ); + } + + #[test] + fn requested_outputs_valueless_parameter_is_rejected() { + let error = NvCreateTensorRequest::try_from(infer_request(vec![requested_output( + "OUTPUT0", + HashMap::from([("classification".to_string(), param(None))]), + )])) + .unwrap_err(); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("classification")); + } // Golden bytes hardcoded (not derived from to_le_bytes) so a symmetric // break in both encode/decode still fires this test. diff --git a/lib/llm/src/protocols/tensor.rs b/lib/llm/src/protocols/tensor.rs index a3241973a623..38dd61c3fabd 100644 --- a/lib/llm/src/protocols/tensor.rs +++ b/lib/llm/src/protocols/tensor.rs @@ -154,6 +154,15 @@ pub struct Tensor { pub data: FlattenTensor, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct RequestedOutput { + pub name: String, + + #[serde(skip_serializing_if = "HashMap::is_empty", default)] + pub parameters: Parameters, +} + impl validator::Validate for Tensor { fn validate(&self) -> Result<(), validator::ValidationErrors> { use validator::{ValidationError, ValidationErrors}; @@ -207,6 +216,10 @@ pub struct NvCreateTensorRequest { #[validate(nested)] pub tensors: Vec, + /// Outputs the client asked for. An empty list asks for every model output. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub outputs: Vec, + /// Optional request-level parameters #[serde(skip_serializing_if = "HashMap::is_empty", default)] pub parameters: Parameters, From 507fb6c12a6425e1b4eef21a28bb2f27db08a838 Mon Sep 17 00:00:00 2001 From: Yingge He Date: Thu, 10 Sep 2026 08:20:45 -0700 Subject: [PATCH 2/4] feat(triton): honor KServe classification on tensor outputs Interpret requested-output classification as Triton's top-K class strings, and return only the outputs the client asked for. Signed-off-by: Yingge He --- .../src/dynamo/triton/classification.py | 128 ++++++ components/src/dynamo/triton/handlers.py | 53 ++- .../tests/test_triton_classification.py | 425 ++++++++++++++++++ .../triton/tests/test_triton_handlers.py | 15 +- .../triton/tests/test_triton_health_check.py | 1 + .../backends/triton/overview.md | 2 +- 6 files changed, 615 insertions(+), 9 deletions(-) create mode 100644 components/src/dynamo/triton/classification.py create mode 100644 components/src/dynamo/triton/tests/test_triton_classification.py diff --git a/components/src/dynamo/triton/classification.py b/components/src/dynamo/triton/classification.py new file mode 100644 index 000000000000..c46e3fdfd57d --- /dev/null +++ b/components/src/dynamo/triton/classification.py @@ -0,0 +1,128 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""KServe classification (top-K) for Triton model outputs. + +A requested output carrying the KServe ``classification`` parameter asks for the +output's top-K classes as ``":[: