Skip to content

[debug] Add circt_debug_* intrinsics for Chisel type metadata#5276

Open
fkhaidari wants to merge 1 commit intochipsalliance:mainfrom
fkhaidari:fk-sc/debug-info
Open

[debug] Add circt_debug_* intrinsics for Chisel type metadata#5276
fkhaidari wants to merge 1 commit intochipsalliance:mainfrom
fkhaidari:fk-sc/debug-info

Conversation

@fkhaidari
Copy link
Copy Markdown

@fkhaidari fkhaidari commented Apr 20, 2026

Hi! This PR adds source-level debug info collection for Chisel
Based on @rameloni's Tywaves work (#4015, #4224), but using intrinsics
instead of annotations as @fabianschuiki suggested in that thread.

Emits four intrinsics during elaboration:

  • circt_debug_var -- type + ctor params per signal
  • circt_debug_subfield -- aggregate hierarchy
  • circt_debug_moduleinfo -- module-level metadata (class, ctor params)
  • circt_debug_enumdef -- ChiselEnum definitions

Fully opt-in via --emit-debug-type-info. Without the flag, FIRRTL
output is bit-identical to main.

Companion CIRCT PR (intrinsic lowering + debug dialect extensions)
is in progress at [fkhaidari/circt#1] and will be submitted upstream
later

cc @fabianschuiki @rameloni @jackkoenig @seldridge

Contributor Checklist

  • Did you add Scaladoc to every public function/method?
  • Did you add at least one test demonstrating the PR?
  • Did you delete any extraneous printlns/debugging code?
  • Did you specify the type of improvement?
  • Did you add appropriate documentation in docs/src?
  • Did you request a desired merge strategy?
  • Did you add text to be included in the Release Notes for this change?

Type of Improvement

  • Feature (or new API)

Desired Merge Strategy

  • Rebase: You will rebase the PR onto master and it will be merged with a merge commit.

Release Notes

Add opt-in ChiselStage flag withDebug = true that emits circt_debug_*
intrinsics preserving Chisel-level type and parameter metadata in FIRRTL.

Reviewer Checklist (only modified by reviewer)

  • Did you add the appropriate labels? (Select the most appropriate one based on the "Type of Improvement")
  • Did you mark the proper milestone (Bug fix: 3.6.x, 5.x, or 6.x depending on impact, API modification or big change: 7.0)?
  • Did you review?
  • Did you check whether all relevant Contributor checkboxes have been checked?
  • Did you do one of the following when ready to merge:
    • Squash: You/ the contributor Enable auto-merge (squash) and clean up the commit message.
    • Merge: Ensure that contributor has cleaned up their commit history, then merge with Create a merge commit.

@linux-foundation-easycla
Copy link
Copy Markdown

linux-foundation-easycla Bot commented Apr 20, 2026

CLA Signed

The committers listed above are authorized under a signed CLA.

  • ✅ login: fkhaidari / name: fkhaidari (2aa674c)

@fkhaidari fkhaidari force-pushed the fk-sc/debug-info branch 20 times, most recently from a6036bf to d8ed90a Compare April 27, 2026 01:46
@fkhaidari fkhaidari force-pushed the fk-sc/debug-info branch from d8ed90a to 61414ba Compare May 6, 2026 17:08
@jackkoenig jackkoenig added the Feature New feature, will be included in release notes label May 6, 2026
Copy link
Copy Markdown
Contributor

@jackkoenig jackkoenig left a comment

Choose a reason for hiding this comment

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

Thank you for this contribution.

A few comments, I'm okay moving forward using runtime reflection but I think we should discuss how we might do this in a much more robust way using static reflection (aka macros) in Scala 3.

Comment on lines +7 to +18
// Scala 3 has no runtime mirror to pick a primary out of multiple ctors,
// so emit params only for single-ctor classes.
private[debug] object CtorParamsPlatform extends LazyLogging {

def ctorParams(obj: Any): Seq[(String, String)] = {
val cls = obj.getClass
val ctors = cls.getDeclaredConstructors
if (ctors.length != 1) {
if (ctors.length > 1)
logger.warn(s"ctorParams: ${cls.getName} has ${ctors.length} constructors; omitting `params`")
Seq.empty
} else {
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.

I think for Scala 3 we'll eventually want to do this with static reflection (aka macros) rather than runtime reflection which, as you note, can't get the information needed.

Future work.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed -- the multi-ctor case here are exactly the kind of thing typeclass derivation would solve cleanly

Comment on lines +23 to +38
implicit val rw: json.ReadWriter[ClassParam] = json
.readwriter[ujson.Value]
.bimap(
p =>
ujson.Obj(
"name" -> p.name,
"typeName" -> p.typeName,
"value" -> p.value.getOrElse(ujson.Null)
),
j =>
ClassParam(
j("name").str,
j("typeName").str,
j.obj.get("value").filterNot(_ == ujson.Null)
)
)
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.

Suggested change
implicit val rw: json.ReadWriter[ClassParam] = json
.readwriter[ujson.Value]
.bimap(
p =>
ujson.Obj(
"name" -> p.name,
"typeName" -> p.typeName,
"value" -> p.value.getOrElse(ujson.Null)
),
j =>
ClassParam(
j("name").str,
j("typeName").str,
j.obj.get("value").filterNot(_ == ujson.Null)
)
)
implicit val rw: json.ReadWriter[ClassParam] = json.macroRW

Is this not the same as the macro-derived serializer?

Copy link
Copy Markdown
Author

@fkhaidari fkhaidari May 8, 2026

Choose a reason for hiding this comment

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

No -- macroRW array-wraps Option[T], which is okay in general, but feels less semantically clean here: downstream consumers would have to unwrap a one-element array to get the value. The manual bimap produces a flatter value

// macroRW
{"name":"x","typeName":"String","value":["hi"]}
{"name":"y","typeName":"Long","value":[42]}

// manual bimap (this PR)
{"name":"x","typeName":"String","value":"hi"}
{"name":"y","typeName":"Long","value":42}

case _: chisel3.Bits | _: chisel3.Clock | _: chisel3.Reset => true
case _: chisel3.experimental.Analog => true
case _: chisel3.Vec[_] | _: chisel3.EnumType => true
case _ => target.getClass.getName == "chisel3.util.MixedVec"
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.

This feels a bit sketchy, what is this trying to accomplish? Is the problem that MixedVec has it's argument as private?

If that's the case, I think we have a really tricky usability issue since may arbitrarily make their arguments private or constructors private...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The string-name check is a build-graph constraint: chisel3.util.MixedVec lives in the chisel module, which dependsOn(coreModule) -- and chisel3.debug.* is in core/. Importing it from there cycles the graph (verified, doesn't compile)

I could add a marker trait in core (e.g. private[chisel3] trait OmitDebugCtorParams) that MixedVec extends, so the matcher becomes case _: OmitDebugCtorParams => true. Not sure that's actually less sketchy though -- happy to go either way.

`toString` will slow or OOM the compile. Avoid expensive `toString`s in
classes you expect to feed into debug metadata. Cycles in the object graph
are detected via identity tracking and printed as `toString` rather than
recursed into.
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.

Thank you for describing these limitations. I must confess these make me pretty nervous about the stability of this feature. I'm not opposed to moving forward with the limitations in mind, but I think we should mark this experimental (not using the experimental package, that was a bit of a failed experiment). I'll add some suggestions of how to warn users a bit.

I think looking into how to do this with static reflection (aka macros, especially typeclass derivation in Scala 3) is the more robust way forward.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed on both fronts. I'll apply your experimental warnings/scaladoc suggestions in the next push so users see the caveat right at the entry points. And yes, moving the runtime reflection to typeclass derivation (Scala 3 macros) is the right long-term direction

```

## Debug type-info intrinsics

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.

Suggested change
:::warning
This feature is experimental and subject to change.
See [Notes on reflection](#notes-on-reflection) below for limitations.
:::

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed


override val options: Seq[ShellOption[Unit]] = Seq(
new ShellOption[Unit](
longOption = "with-debug-intrinsics",
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.

Suggested change
longOption = "with-debug-intrinsics",
longOption = "with-experimental-debug-intrinsics",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed

import firrtl.options.{HasShellOptions, ShellOption, Unserializable}

/** Opt-in toggle for [[chisel3.stage.phases.AddDebugIntrinsics]].
* Activate with `--with-debug-intrinsics` or by adding this annotation.
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.

Suggested change
* Activate with `--with-debug-intrinsics` or by adding this annotation.
* Activate with `--with-debug-intrinsics` or by adding this annotation.
*
* @note This API is experimental and subject to change.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed

import firrtl.options.{Dependency, Phase}
import firrtl.{annoSeqToSeq, seqToAnnoSeq, AnnotationSeq}

/** No-op unless [[chisel3.debug.EmitDebugIntrinsicsAnnotation]] is present. */
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.

Suggested change
/** No-op unless [[chisel3.debug.EmitDebugIntrinsicsAnnotation]] is present. */
/** No-op unless [[chisel3.debug.EmitDebugIntrinsicsAnnotation]] is present.
*
* @note This API is experimental and subject to change.
*/

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed

@fkhaidari fkhaidari force-pushed the fk-sc/debug-info branch from 61414ba to 8b4eadc Compare May 8, 2026 11:18
Introduce an opt-in pass that injects CIRCT debug intrinsics carrying
Chisel-level type and constructor-parameter metadata through to FIRRTL,
so downstream tooling (waveform viewers, debuggers) can recover Bundle
field names, Vec shapes, ChiselEnum types and module parameters that
are otherwise lost during conversion.

The DebugIntrinsics emitter walks the elaborated circuit and appends
four kinds of secret intrinsics:

  - circt_debug_moduleinfo  per module (typeName + ctor params)
  - circt_debug_var         per top-level signal, port or memory
  - circt_debug_subfield    per Bundle/Vec element, linked by parent
  - circt_debug_enumdef     per ChiselEnum type (emitted once)

Constructor parameters are extracted via runtime reflection and
serialised to JSON. Booleans round-trip as native JSON bools; all
numeric types (Byte / Short / Int / Long / Float / Double) serialise
uniformly as JSON strings -- JSON has no NaN/Infinity literals and
Double-backed parsers lose precision on Long > 2^53, so a single string
shape avoids both pitfalls and gives downstream sinks one attribute
mapping to write. JDK and Scala stdlib classes are not recursed into
during nested ctor extraction; their constructors are implementation
details and produce no user-meaningful parameters. User toString
output embedded in the params payload is capped at 4 KiB to bound
emission size against pathological implementations.

Emission is disabled by default and activated by adding
EmitDebugIntrinsicsAnnotation to the annotation seq programmatically,
or by passing `--with-debug-intrinsics` on the CLI to any
circt.stage.ChiselStage entry point. Either form schedules the new
AddDebugIntrinsics phase between Elaborate and Convert; the phase is
a no-op without the annotation. After consuming it, the phase strips
EmitDebugIntrinsicsAnnotation from the seq so a second pass over the
same annotations does not double-emit intrinsics.

Includes unit tests for moduleinfo / var / subfield / enumdef emission
across scalar, Bundle, Vec, ChiselEnum, memory and hierarchy cases,
plus a docs section in docs/src/explanations/intrinsics.md.

Based on work of @rameloni

Signed-off-by: fkhaidari <khaidari.fg@gmail.com>
@fkhaidari fkhaidari force-pushed the fk-sc/debug-info branch from 8b4eadc to 2aa674c Compare May 8, 2026 11:43
@fkhaidari
Copy link
Copy Markdown
Author

@jackkoenig, thank you for your review!

@fkhaidari fkhaidari requested a review from jackkoenig May 8, 2026 11:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature New feature, will be included in release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants