This is an automated email from the ASF dual-hosted git repository.
tqchen pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git
The following commit(s) were added to refs/heads/main by this push:
new 5745c209f4 [REFACTOR][SCRIPT] Keep dependent shape recursion in
docsifier (#19940)
5745c209f4 is described below
commit 5745c209f482179f7edff965b185eb1f6c83e968
Author: Tianqi Chen <[email protected]>
AuthorDate: Sun Jul 5 06:29:56 2026 +0800
[REFACTOR][SCRIPT] Keep dependent shape recursion in docsifier (#19940)
Replace the nested `DocToPythonScript` invocation in Relax
dependent-shape printing with an expression-string Doc rendered during
the active `PythonDocPrinter` traversal.
This keeps recursive IR-to-doc conversion inside the active docsifier,
preserves naming, precedence, escaping, source paths, and printer
configuration, and avoids a nested top-level renderer or a new public
rendering entry point.
Expression-string escaping is streamed directly into the final output so
wrapper and nested source spans retain exact escaped byte offsets.
---
include/tvm/script/printer/doc.h | 34 +++++++
src/relax/script/printer/dependent_type.cc | 5 +-
src/script/printer/doc.cc | 10 ++
src/script/printer/doc_printer/base_doc_printer.cc | 2 +
src/script/printer/doc_printer/base_doc_printer.h | 5 +
.../printer/doc_printer/python_doc_printer.cc | 109 +++++++++++++++++++++
tests/python/relax/test_tvmscript_printer_relax.py | 51 ++++++++++
7 files changed, 212 insertions(+), 4 deletions(-)
diff --git a/include/tvm/script/printer/doc.h b/include/tvm/script/printer/doc.h
index bc90e53657..0442852692 100644
--- a/include/tvm/script/printer/doc.h
+++ b/include/tvm/script/printer/doc.h
@@ -328,6 +328,40 @@ class LiteralDoc : public ExprDoc {
TVM_FFI_DEFINE_OBJECT_REF_METHODS_NOTNULLABLE(LiteralDoc, ExprDoc,
LiteralDocNode);
};
+/*!
+ * \brief Doc that renders an expression as a Python string literal.
+ *
+ * \sa ExprStringDoc
+ */
+class ExprStringDocNode : public ExprDocNode {
+ public:
+ /*! \brief The expression to render as a string. */
+ ExprDoc value{ffi::UnsafeInit()};
+
+ static void RegisterReflection() {
+ namespace refl = tvm::ffi::reflection;
+ refl::ObjectDef<ExprStringDocNode>().def_ro("value",
&ExprStringDocNode::value);
+ }
+ TVM_FFI_DECLARE_OBJECT_INFO_FINAL("script.printer.ExprStringDoc",
ExprStringDocNode, ExprDocNode);
+};
+
+/*!
+ * \brief Reference type of ExprStringDocNode.
+ *
+ * \sa ExprStringDocNode
+ */
+class ExprStringDoc : public ExprDoc {
+ public:
+ /*!
+ * \brief Constructor of ExprStringDoc.
+ * \param value The expression to render as a string.
+ * \param object_path The object path.
+ */
+ explicit ExprStringDoc(ExprDoc value, const ffi::Optional<AccessPath>&
object_path);
+
+ TVM_FFI_DEFINE_OBJECT_REF_METHODS_NOTNULLABLE(ExprStringDoc, ExprDoc,
ExprStringDocNode);
+};
+
/*!
* \brief Doc that represents identifier.
*
diff --git a/src/relax/script/printer/dependent_type.cc
b/src/relax/script/printer/dependent_type.cc
index c3b77223b5..2c6ea7e9c7 100644
--- a/src/relax/script/printer/dependent_type.cc
+++ b/src/relax/script/printer/dependent_type.cc
@@ -54,10 +54,7 @@ ExprDoc PrintShapeVar(const PrimExpr& e, const AccessPath&
e_p, const IRDocsifie
}
// Step 3. Stringify the PrimExpr if func var exists
if (func_var_mode) {
- // This nested render is only converting a shape expression into one token.
- // Give it an independent configuration so it cannot consume or emit the
- // enclosing invocation's access-path diagnostics.
- return LiteralDoc::Str(DocToPythonScript(expr_doc, PrinterConfig()), e_p);
+ return ExprStringDoc(expr_doc, e_p);
}
return expr_doc;
}
diff --git a/src/script/printer/doc.cc b/src/script/printer/doc.cc
index ffdb081a48..97ebabaaa4 100644
--- a/src/script/printer/doc.cc
+++ b/src/script/printer/doc.cc
@@ -33,6 +33,7 @@ TVM_FFI_STATIC_INIT_BLOCK() {
StmtDocNode::RegisterReflection();
StmtBlockDocNode::RegisterReflection();
LiteralDocNode::RegisterReflection();
+ ExprStringDocNode::RegisterReflection();
IdDocNode::RegisterReflection();
AttrAccessDocNode::RegisterReflection();
IndexDocNode::RegisterReflection();
@@ -94,6 +95,15 @@ LiteralDoc::LiteralDoc(ffi::Any value, const
ffi::Optional<AccessPath>& object_p
this->data_ = std::move(n);
}
+ExprStringDoc::ExprStringDoc(ExprDoc value, const ffi::Optional<AccessPath>&
object_path) {
+ ffi::ObjectPtr<ExprStringDocNode> n = ffi::make_object<ExprStringDocNode>();
+ n->value = value;
+ if (object_path.defined()) {
+ n->source_paths.push_back(object_path.value());
+ }
+ this->data_ = std::move(n);
+}
+
IdDoc::IdDoc(ffi::String name) {
ffi::ObjectPtr<IdDocNode> n = ffi::make_object<IdDocNode>();
n->name = name;
diff --git a/src/script/printer/doc_printer/base_doc_printer.cc
b/src/script/printer/doc_printer/base_doc_printer.cc
index 93e0233d38..4b4d799cd5 100644
--- a/src/script/printer/doc_printer/base_doc_printer.cc
+++ b/src/script/printer/doc_printer/base_doc_printer.cc
@@ -301,6 +301,8 @@ void DocPrinter::PrintDoc(const Doc& doc) {
if (auto doc_node = doc.as<LiteralDoc>()) {
PrintTypedDoc(doc_node.value());
+ } else if (auto doc_node = doc.as<ExprStringDoc>()) {
+ PrintTypedDoc(doc_node.value());
} else if (auto doc_node = doc.as<IdDoc>()) {
PrintTypedDoc(doc_node.value());
} else if (auto doc_node = doc.as<AttrAccessDoc>()) {
diff --git a/src/script/printer/doc_printer/base_doc_printer.h
b/src/script/printer/doc_printer/base_doc_printer.h
index 1dee2322f3..c8335f951a 100644
--- a/src/script/printer/doc_printer/base_doc_printer.h
+++ b/src/script/printer/doc_printer/base_doc_printer.h
@@ -105,6 +105,11 @@ class DocPrinter {
*/
virtual void PrintTypedDoc(const LiteralDoc& doc) = 0;
+ /*!
+ * \brief Virtual method to print an ExprStringDoc
+ */
+ virtual void PrintTypedDoc(const ExprStringDoc& doc) = 0;
+
/*!
* \brief Virtual method to print an IdDoc
*/
diff --git a/src/script/printer/doc_printer/python_doc_printer.cc
b/src/script/printer/doc_printer/python_doc_printer.cc
index c8f32eef0c..0a82430e8e 100644
--- a/src/script/printer/doc_printer/python_doc_printer.cc
+++ b/src/script/printer/doc_printer/python_doc_printer.cc
@@ -27,6 +27,7 @@
#include <map>
#include <optional>
#include <sstream>
+#include <streambuf>
#include <string>
#include "../../../support/str_escape.h"
@@ -38,6 +39,100 @@ namespace script {
namespace printer {
namespace {
+/*!
+ * \brief Escape an ExprStringDoc child while preserving final output
positions.
+ *
+ * ExprStringDoc must print its child through the active PythonDocPrinter so
that the child's
+ * precedence, printer configuration, and source-path bookkeeping stay in the
current traversal.
+ * However, escaping after printing into a temporary buffer would make
PrintDoc record positions
+ * in that unescaped buffer, rather than the final Python source. This scoped
adapter instead
+ * replaces the output stream's buffer only while the child is printed. It
escapes each write and
+ * forwards it immediately to the original final buffer; the caller emits the
surrounding quotes
+ * outside the scope so that only the child is transformed.
+ *
+ * The adapter owns neither the output stream nor its original buffer. Both
outlive this stack
+ * object. The constructor saves the original buffer in destination_ and
installs this adapter;
+ * the destructor restores the original buffer before the caller continues
writing to the stream.
+ *
+ * PrintDoc uses tellp() before and after every child to record source spans.
tellp() reaches
+ * seekoff(0, cur, out), which is the only seek this adapter accepts and
delegates directly to the
+ * original buffer. Because that buffer already contains the preceding output
and advances by the
+ * escaped byte count, child spans use absolute positions in the final source,
including any
+ * expansion introduced by StrEscape.
+ *
+ * A raw newline violates the one-line expression-string contract. xsputn
records its presence
+ * while still escaping and forwarding the write, and the caller checks
saw_newline() together
+ * with the stream state before completing the literal. A destination short
write is reported as
+ * an input write failure so that the output stream records the error. On
every exit path,
+ * including exception unwinding, the noexcept destructor restores the
original buffer and then
+ * reapplies the child traversal's stream state, which rdbuf() would otherwise
clear.
+ */
+class ScopedExprStringEscapeBuf : public std::streambuf {
+ public:
+ explicit ScopedExprStringEscapeBuf(std::ostream* output)
+ : output_(output), destination_(output->rdbuf()) {
+ TVM_FFI_ICHECK(output_->good()) << "Cannot escape into a failed output
stream";
+ output_->rdbuf(this);
+ }
+
+ ~ScopedExprStringEscapeBuf() noexcept {
+ // Swapping rdbuf resets rdstate, so retain the child's state across
restoration. setstate may
+ // throw under the stream's exception mask; suppress that throw so an
in-flight exception is
+ // never replaced, while setstate still records the bits before throwing.
+ std::ios_base::iostate state = output_->rdstate();
+ output_->rdbuf(destination_);
+ try {
+ output_->setstate(state);
+ } catch (...) {
+ // Preserve the stream state without replacing an exception already in
flight.
+ }
+ }
+
+ ScopedExprStringEscapeBuf(const ScopedExprStringEscapeBuf&) = delete;
+ ScopedExprStringEscapeBuf& operator=(const ScopedExprStringEscapeBuf&) =
delete;
+
+ bool saw_newline() const { return saw_newline_; }
+
+ protected:
+ std::streamsize xsputn(const char* data, std::streamsize count) final {
+ if (count <= 0) return 0;
+ saw_newline_ = saw_newline_ || std::find(data, data + count, '\n') != data
+ count;
+ // StrEscape is byte-wise, so each write can be transformed independently
and forwarded
+ // without retaining or copying the complete output. Report consumed input
bytes, not the
+ // potentially larger number of escaped bytes written to the destination.
+ std::string escaped = support::StrEscape(data, static_cast<size_t>(count));
+ return destination_->sputn(escaped.data(), escaped.size()) ==
+ static_cast<std::streamsize>(escaped.size())
+ ? count
+ : 0;
+ }
+
+ int_type overflow(int_type ch) final {
+ if (traits_type::eq_int_type(ch, traits_type::eof())) {
+ return traits_type::not_eof(ch);
+ }
+ char value = traits_type::to_char_type(ch);
+ return xsputn(&value, 1) == 1 ? ch : traits_type::eof();
+ }
+
+ int sync() final { return destination_->pubsync(); }
+
+ pos_type seekoff(off_type offset, std::ios_base::seekdir direction,
+ std::ios_base::openmode mode) final {
+ // Support the current-position query used by tellp(), preserving the
destination's absolute
+ // escaped offset. ExprStringDoc rendering never needs to reposition the
output sequence.
+ if (offset == 0 && direction == std::ios_base::cur && (mode &
std::ios_base::out)) {
+ return destination_->pubseekoff(offset, direction, mode);
+ }
+ return pos_type(off_type(-1));
+ }
+
+ private:
+ std::ostream* output_;
+ std::streambuf* destination_;
+ bool saw_newline_{false};
+};
+
ffi::String RenderInvisiblePathInfo(const ffi::String& script,
const ffi::Array<AccessPath>&
requested_paths,
const
ffi::Array<ffi::Optional<AccessPath>>& visible_paths) {
@@ -147,6 +242,7 @@ ExprPrecedence GetExprPrecedence(const ExprDoc& doc) {
// Key is the type index of Doc
static const std::unordered_map<uint32_t, ExprPrecedence>
doc_type_precedence = {
{LiteralDocNode::RuntimeTypeIndex(), ExprPrecedence::kIdentity},
+ {ExprStringDocNode::RuntimeTypeIndex(), ExprPrecedence::kIdentity},
{IdDocNode::RuntimeTypeIndex(), ExprPrecedence::kIdentity},
{AttrAccessDocNode::RuntimeTypeIndex(), ExprPrecedence::kIdentity},
{IndexDocNode::RuntimeTypeIndex(), ExprPrecedence::kIdentity},
@@ -181,6 +277,7 @@ class PythonDocPrinter : public DocPrinter {
using DocPrinter::PrintDoc;
void PrintTypedDoc(const LiteralDoc& doc) final;
+ void PrintTypedDoc(const ExprStringDoc& doc) final;
void PrintTypedDoc(const IdDoc& doc) final;
void PrintTypedDoc(const AttrAccessDoc& doc) final;
void PrintTypedDoc(const IndexDoc& doc) final;
@@ -394,6 +491,18 @@ void PythonDocPrinter::PrintTypedDoc(const LiteralDoc&
doc) {
}
}
+void PythonDocPrinter::PrintTypedDoc(const ExprStringDoc& doc) {
+ this->output_ << '"';
+ {
+ ScopedExprStringEscapeBuf escaping_scope(&this->output_);
+ this->PrintDoc(doc->value);
+ TVM_FFI_ICHECK(this->output_.good()) << "Failed to render an expression
string literal";
+ TVM_FFI_ICHECK(!escaping_scope.saw_newline())
+ << "An expression rendered inside a Python string literal must be one
line";
+ }
+ this->output_ << '"';
+}
+
void PythonDocPrinter::PrintTypedDoc(const IdDoc& doc) { output_ << doc->name;
}
void PythonDocPrinter::PrintTypedDoc(const AttrAccessDoc& doc) {
diff --git a/tests/python/relax/test_tvmscript_printer_relax.py
b/tests/python/relax/test_tvmscript_printer_relax.py
index a1a7caeab4..b98fb33c41 100644
--- a/tests/python/relax/test_tvmscript_printer_relax.py
+++ b/tests/python/relax/test_tvmscript_printer_relax.py
@@ -17,10 +17,12 @@
# pylint: disable=missing-docstring
# ruff: noqa: E501, F841
+from tvm_ffi.access_path import AccessPath
import tvm
import tvm.testing
from tvm import IRModule, relax, tirx
+from tvm.runtime.script_printer import PrinterConfig, _script
from tvm.script import ir as I
from tvm.script import relax as R
from tvm.script import tirx as T
@@ -53,6 +55,55 @@ def func(a: R.Tensor((10, 10))) -> R.Tensor((10, 10)):
)
+def test_function_dependent_shape_escaped_source_spans():
+ n = tirx.Var("n", "int64")
+ cast = tirx.Cast("int64", n)
+ x = relax.Var("x", relax.TensorType([cast], "float32"))
+ ret_ty = relax.TensorType(dtype="float32", ndim=1)
+ func = relax.Function([x], x, ret_ty=ret_ty).with_attr("global_symbol",
"main")
+ cast_path = (
+ AccessPath.root()
+ .attr("params")
+ .array_item(0)
+ .attr("ty")
+ .attr("shape")
+ .attr("values")
+ .array_item(0)
+ )
+
+ def render(path):
+ config = PrinterConfig(
+ verbose_expr=True,
+ num_context_lines=0,
+ path_to_underline=[path],
+ extra_config={"render_invisible_path_info": True},
+ )
+ first = _script(func, config)
+ assert _script(func, config) == first
+ assert first.count("Access path:") == 1
+ lines = first.splitlines()
+ definition_index = next(i for i, line in enumerate(lines) if "def
main" in line)
+ assert "Access path:" not in lines[definition_index]
+ return lines[definition_index], lines[definition_index + 1]
+
+ expression = r'"T.Cast(\"int64\", n)"'
+ definition, underline = render(cast_path)
+ expression_start = definition.index(expression)
+ assert underline[expression_start : expression_start + len(expression)] ==
"^" * len(expression)
+ assert underline.strip() == "^" * len(expression)
+
+ escaped_dtype = r"\"int64\""
+ definition, underline = render(cast_path.attr("dtype"))
+ dtype_start = definition.index(escaped_dtype)
+ assert underline[dtype_start : dtype_start + len(escaped_dtype)] == "^" *
len(escaped_dtype)
+ assert underline.strip() == "^" * len(escaped_dtype)
+
+ definition, underline = render(cast_path.attr("value"))
+ variable_start = definition.index(expression) + expression.rindex("n")
+ assert underline[variable_start] == "^"
+ assert underline.strip() == "^"
+
+
def test_lone_private_function():
@R.function(private=True)
def func(a: R.Tensor((10, 10))) -> R.Tensor((10, 10)): # type: ignore