Skip to main content

DataView

New in Chisel 3.5

Introduction

DataView is a mechanism for "viewing" Scala objects as a subtype of chisel3.Data. Often, this is useful for viewing one subtype of chisel3.Data, as another. One can think about a DataView as a mapping from a Target type T to a View type V. This is similar to a cast (eg. .asTypeOf) with a few differences:

  1. Views are connectable—connections to the view will occur on the target
  2. Whereas casts are structural (a reinterpretation of the underlying bits), a DataView is a customizable mapping
  3. Views can be partial—not every field in the target must be included in the mapping

A Motivating Example (AXI4)

AXI4 is a common interface in digital design. A typical Verilog peripheral using AXI4 will define a write channel as something like:

module my_module(
// Write Channel
input AXI_AWVALID,
output AXI_AWREADY,
input [3:0] AXI_AWID,
input [19:0] AXI_AWADDR,
input [1:0] AXI_AWLEN,
input [1:0] AXI_AWSIZE,
// ...
);

This would correspond to the following Chisel Bundle:

class VerilogAXIBundle(val addrWidth: Int) extends Bundle {
val AWVALID = Output(Bool())
val AWREADY = Input(Bool())
val AWID = Output(UInt(4.W))
val AWADDR = Output(UInt(addrWidth.W))
val AWLEN = Output(UInt(2.W))
val AWSIZE = Output(UInt(2.W))
// The rest of AW and other AXI channels here
}

// Instantiated as
class my_module extends RawModule {
val AXI = IO(new VerilogAXIBundle(20))
}

Expressing something that matches a standard Verilog interface is important when instantiating Verilog modules in a Chisel design as BlackBoxes. Generally though, Chisel developers prefer to use composition via utilities like Decoupled rather than a flat handling of ready and valid as in the above. A more "Chisel-y" implementation of this interface might look like:

// Note that both the AW and AR channels look similar and could use the same Bundle definition
class AXIAddressChannel(val addrWidth: Int) extends Bundle {
val id = UInt(4.W)
val addr = UInt(addrWidth.W)
val len = UInt(2.W)
val size = UInt(2.W)
// ...
}
import chisel3.util.Decoupled
// We can compose the various AXI channels together
class AXIBundle(val addrWidth: Int) extends Bundle {
val aw = Decoupled(new AXIAddressChannel(addrWidth))
// val ar = new AXIAddressChannel
// ... Other channels here ...
}
// Instantiated as
class MyModule extends RawModule {
val axi = IO(new AXIBundle(20))
}

Of course, this would result in very different looking Verilog:

// Generated by CIRCT firtool-1.95.1
module MyModule(
input axi_aw_ready,
output axi_aw_valid,
output [3:0] axi_aw_bits_id,
output [19:0] axi_aw_bits_addr,
output [1:0] axi_aw_bits_len,
axi_aw_bits_size
);

assign axi_aw_valid = 1'h0;
assign axi_aw_bits_id = 4'h0;
assign axi_aw_bits_addr = 20'h0;
assign axi_aw_bits_len = 2'h0;
assign axi_aw_bits_size = 2'h0;
endmodule

So how can we use our more structured types while maintaining expected Verilog interfaces? Meet DataView:

import chisel3.experimental.dataview._

// We recommend putting DataViews in a companion object of one of the involved types
object AXIBundle {
// Don't be afraid of the use of implicits, we will discuss this pattern in more detail later
implicit val axiView: DataView[VerilogAXIBundle, AXIBundle] = DataView(
// The first argument is a function constructing an object of View type (AXIBundle)
// from an object of the Target type (VerilogAXIBundle)
vab => new AXIBundle(vab.addrWidth),
// The remaining arguments are a mapping of the corresponding fields of the two types
_.AWVALID -> _.aw.valid,
_.AWREADY -> _.aw.ready,
_.AWID -> _.aw.bits.id,
_.AWADDR -> _.aw.bits.addr,
_.AWLEN -> _.aw.bits.len,
_.AWSIZE -> _.aw.bits.size,
// ...
)
}

This DataView is a mapping between our flat, Verilog-style AXI Bundle to our more compositional, Chisel-style AXI Bundle. It allows us to define our ports to match the expected Verilog interface, while manipulating it as if it were the more structured type:

class AXIStub extends RawModule {
val AXI = IO(new VerilogAXIBundle(20))
val view = AXI.viewAs[AXIBundle]

// We can now manipulate `AXI` via `view`
view.aw.bits := 0.U.asTypeOf(new AXIAddressChannel(20)) // zero everything out by default
view.aw.valid := true.B
when (view.aw.ready) {
view.aw.bits.id := 5.U
view.aw.bits.addr := 1234.U
// We can still manipulate AXI as well
AXI.AWLEN := 1.U
}
}

This will generate Verilog that matches the standard naming convention:

// Generated by CIRCT firtool-1.95.1
module AXIStub(
output AXI_AWVALID,
input AXI_AWREADY,
output [3:0] AXI_AWID,
output [19:0] AXI_AWADDR,
output [1:0] AXI_AWLEN,
AXI_AWSIZE
);

assign AXI_AWVALID = 1'h1;
assign AXI_AWID = AXI_AWREADY ? 4'h5 : 4'h0;
assign AXI_AWADDR = AXI_AWREADY ? 20'h4D2 : 20'h0;
assign AXI_AWLEN = {1'h0, AXI_AWREADY};
assign AXI_AWSIZE = 2'h0;
endmodule

Note that if both the Target and the View types are subtypes of Data (as they are in this example), the DataView is invertible. This means that we can easily create a DataView[AXIBundle, VerilogAXIBundle] from our existing DataView[VerilogAXIBundle, AXIBundle], all we need to do is provide a function to construct a VerilogAXIBundle from an instance of an AXIBundle:

// Note that typically you should define these together (eg. inside object AXIBundle)
implicit val axiView2: DataView[AXIBundle, VerilogAXIBundle] = AXIBundle.axiView.invert(ab => new VerilogAXIBundle(ab.addrWidth))

The following example shows this and illustrates another use case of DataView—connecting unrelated types:

class ConnectionExample extends RawModule {
val in = IO(new AXIBundle(20))
val out = IO(Flipped(new VerilogAXIBundle(20)))
out.viewAs[AXIBundle] <> in
}

This results in the corresponding fields being connected in the emitted Verilog:

// Generated by CIRCT firtool-1.95.1
module ConnectionExample(
input in_aw_ready,
output in_aw_valid,
output [3:0] in_aw_bits_id,
output [19:0] in_aw_bits_addr,
output [1:0] in_aw_bits_len,
in_aw_bits_size,
input out_AWVALID,
output out_AWREADY,
input [3:0] out_AWID,
input [19:0] out_AWADDR,
input [1:0] out_AWLEN,
out_AWSIZE
);

assign in_aw_valid = out_AWVALID;
assign in_aw_bits_id = out_AWID;
assign in_aw_bits_addr = out_AWADDR;
assign in_aw_bits_len = out_AWLEN;
assign in_aw_bits_size = out_AWSIZE;
assign out_AWREADY = in_aw_ready;
endmodule

Other Use Cases

While the ability to map between Bundle types as in the AXI4 example is pretty compelling, DataView has many other applications. Importantly, because the Target of the DataView need not be a Data, it provides a way to use non-Data objects with APIs that require Data.

Tuples

Perhaps the most helpful use of DataView for a non-Data type is viewing Scala tuples as Bundles. For example, in Chisel prior to the introduction of DataView, one might try to Mux tuples and see an error like the following:

class TupleExample extends RawModule {
val a, b, c, d = IO(Input(UInt(8.W)))
val cond = IO(Input(Bool()))
val x, y = IO(Output(UInt(8.W)))
(x, y) := Mux(cond, (a, b), (c, d))
}
// error: value := is not a member of (chisel3.UInt, chisel3.UInt)
// Expression does not convert to assignment because receiver is not assignable.
// (x, y) := Mux(cond, (a, b), (c, d))
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// error: inferred type arguments [(chisel3.UInt, chisel3.UInt)] do not conform to macro method apply's type parameter bounds [T <: chisel3.Data]
// (x, y) := Mux(cond, (a, b), (c, d))
// ^^^
// error: type mismatch;
// found : (chisel3.UInt, chisel3.UInt)
// required: T
// (x, y) := Mux(cond, (a, b), (c, d))
// ^^^^^^
// error: type mismatch;
// found : (chisel3.UInt, chisel3.UInt)
// required: T
// (x, y) := Mux(cond, (a, b), (c, d))
// ^^^^^^

The issue, is that Chisel primitives like Mux and := only operate on subtypes of Data and Tuples (as members of the Scala standard library), are not subclasses of Data. DataView provides a mechanism to view a Tuple as if it were a Data:

// We need a type to represent the Tuple
class HWTuple2[A <: Data, B <: Data](val _1: A, val _2: B) extends Bundle

// Provide DataView between Tuple and HWTuple
implicit def view[A <: Data, B <: Data]: DataView[(A, B), HWTuple2[A, B]] =
DataView(tup => new HWTuple2(tup._1.cloneType, tup._2.cloneType),
_._1 -> _._1, _._2 -> _._2)

Now, we can use .viewAs to view Tuples as if they were subtypes of Data:

class TupleVerboseExample extends RawModule {
val a, b, c, d = IO(Input(UInt(8.W)))
val cond = IO(Input(Bool()))
val x, y = IO(Output(UInt(8.W)))
(x, y).viewAs[HWTuple2[UInt, UInt]] := Mux(cond, (a, b).viewAs[HWTuple2[UInt, UInt]], (c, d).viewAs[HWTuple2[UInt, UInt]])
}

This is much more verbose than the original idea of just using the Tuples directly as if they were Data. We can make this better by providing an implicit conversion that views a Tuple as a HWTuple2:

implicit def tuple2hwtuple[A <: Data, B <: Data](tup: (A, B)): HWTuple2[A, B] =
tup.viewAs[HWTuple2[A, B]]

Now, the original code just works!

class TupleExample extends RawModule {
val a, b, c, d = IO(Input(UInt(8.W)))
val cond = IO(Input(Bool()))
val x, y = IO(Output(UInt(8.W)))
(x, y) := Mux(cond, (a, b), (c, d))
}

Note that this example ignored DataProduct which is another required piece (see the documentation about it below).

All of this is available to users via a single import:

import chisel3.experimental.conversions._

Totality and PartialDataView

A DataView is total if all fields of the Target type and all fields of the View type are included in the mapping. Chisel will error if a field is accidentally left out from a DataView. For example:

class BundleA extends Bundle {
val foo = UInt(8.W)
val bar = UInt(8.W)
}
class BundleB extends Bundle {
val fizz = UInt(8.W)
}
// We forgot BundleA.foo in the mapping!
implicit val myView: DataView[BundleA, BundleB] = DataView(_ => new BundleB, _.bar -> _.fizz)
class BadMapping extends Module {
val in = IO(Input(new BundleA))
val out = IO(Output(new BundleB))
out := in.viewAs[BundleB]
}
// We must run Chisel to see the error
getVerilogString(new BadMapping)
// chisel3.experimental.dataview.package$InvalidViewException: Viewing BadMapping.in: IO[BundleA] as BundleB is non-Total!
// Target field '_.foo' is missing.
// DataView used is DataView(defined @[dataview.md:228:59]).
// If the view *should* be non-total, try a 'PartialDataView'.
// at ... ()
// at repl.MdocSession$MdocApp6$$anonfun$55$BadMapping$1$$anonfun$60.apply(dataview.md:232)
// at repl.MdocSession$MdocApp6$$anonfun$55$BadMapping$1$$anonfun$60.apply(dataview.md:232)
// at chisel3.DataImpl.$anonfun$$colon$eq$1(DataImpl.scala:820)
// at scala.runtime.java8.JFunction0$mcV$sp.apply(JFunction0$mcV$sp.scala:18)
// at chisel3.experimental.prefix$.apply(prefix.scala:33)
// at chisel3.DataImpl.$colon$eq(DataImpl.scala:820)
// at chisel3.DataImpl.$colon$eq$(DataImpl.scala:818)
// at chisel3.Data.$colon$eq(Data.scala:31)
// at repl.MdocSession$MdocApp6$$anonfun$55$BadMapping$1.<init>(dataview.md:232)
// at repl.MdocSession$MdocApp6$$anonfun$55$$anonfun$apply$67.apply(dataview.md:234)
// at repl.MdocSession$MdocApp6$$anonfun$55$$anonfun$apply$67.apply(dataview.md:234)
// at chisel3.ObjectModuleImpl.evaluate(ModuleImpl.scala:96)
// at chisel3.ObjectModuleImpl.evaluate$(ModuleImpl.scala:62)
// at chisel3.Module$.evaluate(Module.scala:10)
// at chisel3.ObjectModuleImpl._applyImpl(ModuleImpl.scala:35)
// at chisel3.ObjectModuleImpl._applyImpl$(ModuleImpl.scala:33)
// at chisel3.Module$._applyImpl(Module.scala:10)
// at chisel3.Module$.do_apply(Module.scala:22)
// at chisel3.stage.phases.Elaborate.$anonfun$transform$2(Elaborate.scala:56)
// at chisel3.internal.Builder$.$anonfun$buildImpl$1(Builder.scala:1086)
// at scala.util.DynamicVariable.withValue(DynamicVariable.scala:59)
// at chisel3.internal.Builder$.buildImpl(Builder.scala:1076)
// at chisel3.internal.Builder$.$anonfun$build$1(Builder.scala:1068)
// at logger.Logger$.$anonfun$makeScope$4(Logger.scala:148)
// at scala.util.DynamicVariable.withValue(DynamicVariable.scala:59)
// at logger.Logger$.makeScope(Logger.scala:146)
// at logger.Logger$.makeScope(Logger.scala:133)
// at ... ()
// at ... (Stack trace trimmed to user code only. Rerun with --full-stacktrace to see the full stack trace)

As that error suggests, if we want the view to be non-total, we can use a PartialDataView:

// A PartialDataView does not have to be total for the Target
implicit val myView: DataView[BundleA, BundleB] = PartialDataView[BundleA, BundleB](_ => new BundleB, _.bar -> _.fizz)
// myView: DataView[BundleA, BundleB] = PartialDataView(defined @[dataview.md:243:84])
class PartialDataViewModule extends Module {
val in = IO(Input(new BundleA))
val out = IO(Output(new BundleB))
out := in.viewAs[BundleB]
}
// Generated by CIRCT firtool-1.95.1
module PartialDataViewModule(
input clock,
reset,
input [7:0] in_foo,
in_bar,
output [7:0] out_fizz
);

assign out_fizz = in_bar;
endmodule

While PartialDataViews need not be total for the Target, both PartialDataViews and DataViews must always be total for the View. This has the consequence that PartialDataViews are not invertible in the same way as DataViews.

For example:

implicit val myView2 = myView.invert(_ => new BundleA)
class PartialDataViewModule2 extends Module {
val in = IO(Input(new BundleA))
val out = IO(Output(new BundleB))
// Using the inverted version of the mapping
out.viewAs[BundleA] := in
}
// We must run Chisel to see the error
getVerilogString(new PartialDataViewModule2)
// chisel3.experimental.dataview.package$InvalidViewException: Cannot invert 'PartialDataView(defined @[dataview.md:243:84])' as it is non-total.
// Try providing a DataView[MdocApp6.this.BundleB, MdocApp6.this.BundleA].
// Please see https://www.chisel-lang.org/chisel3/docs/explanations/dataview.
// at chisel3.experimental.dataview.InvertibleDataView$InvertibleDataView.invert(InvertibleDataView.scala:33)
// at repl.MdocSession$MdocApp6$$anonfun$67.apply(dataview.md:263)
// at repl.MdocSession$MdocApp6$$anonfun$67.apply(dataview.md:262)

As noted, the mapping must always be total for the View.

Advanced Details

DataView takes advantage of features of Scala that may be new to many users of Chisel—in particular Type Classes.

Type Classes

Type classes are powerful language feature for writing polymorphic code. They are a common feature in "modern programming languages" like Scala, Swift (see protocols), and Rust (see traits). Type classes may appear similar to inheritance in object-oriented programming but there are some important differences:

  1. You can provide a type class for a type you don't own (eg. one defined in a 3rd party library, the Scala standard library, or Chisel itself)
  2. You can write a single type class for many types that do not have a sub-typing relationship
  3. You can provide multiple different type classes for the same type

For DataView, (1) is crucial because we want to be able to implement DataViews of built-in Scala types like tuples and Seqs. Furthermore, DataView has two type parameters (the Target and the View types) so inheritance does not really make sense—which type would extend DataView?

In Scala 2, type classes are not a built-in language feature, but rather are implemented using implicits. There are great resources out there for interested readers:

Note that Scala 3 has added built-in syntax for type classes that does not apply to Chisel 3 which currently only supports Scala 2.

Implicit Resolution

Given that DataView is implemented using implicits, it is important to understand implicit resolution. Whenever the compiler sees an implicit argument is required, it first looks in current scope before looking in the implicit scope.

  1. Current scope
    • Values defined in the current scope
    • Explicit imports
    • Wildcard imports
  2. Implicit scope
    • Companion object of a type
    • Implicit scope of an argument's type
    • Implicit scope of type parameters

If at either stage, multiple implicits are found, then the static overloading rule is used to resolve it. Put simply, if one implicit applies to a more-specific type than the other, the more-specific one will be selected. If multiple implicits apply within a given stage, then the compiler throws an ambiguous implicit resolution error.

This section draws heavily from [1] and [2]. In particular, see [1] for examples.

Implicit Resolution Example

To help clarify a bit, let us consider how implicit resolution works for DataView. Consider the definition of viewAs:

def viewAs[V <: Data](implicit dataView: DataView[T, V]): V

Armed with the knowledge from the previous section, we know that whenever we call .viewAs, the Scala compiler will first look for a DataView[T, V] in the current scope (defined in, or imported), then it will look in the companion objects of DataView, T, and V. This enables a fairly powerful pattern, namely that default or typical implementations of a DataView should be defined in the companion object for one of the two types. We can think about DataViews defined in this way as "low priority defaults". They can then be overruled by a specific import if a given user ever wants different behavior. For example:

Given the following types:

class Foo extends Bundle {
val a = UInt(8.W)
val b = UInt(8.W)
}
class Bar extends Bundle {
val c = UInt(8.W)
val d = UInt(8.W)
}
object Foo {
implicit val f2b: DataView[Foo, Bar] = DataView(_ => new Bar, _.a -> _.c, _.b -> _.d)
implicit val b2f: DataView[Bar, Foo] = f2b.invert(_ => new Foo)
}

This provides an implementation of DataView in the implicit scope as a "default" mapping between Foo and Bar (and it doesn't even require an import!):

class FooToBar extends Module {
val foo = IO(Input(new Foo))
val bar = IO(Output(new Bar))
bar := foo.viewAs[Bar]
}
// Generated by CIRCT firtool-1.95.1
module FooToBar(
input clock,
reset,
input [7:0] foo_a,
foo_b,
output [7:0] bar_c,
bar_d
);

assign bar_c = foo_a;
assign bar_d = foo_b;
endmodule

However, it's possible that some user of Foo and Bar wants different behavior, perhaps they would prefer more of "swizzling" behavior rather than a direct mapping:

object Swizzle {
implicit val swizzle: DataView[Foo, Bar] = DataView(_ => new Bar, _.a -> _.d, _.b -> _.c)
}
// Current scope always wins over implicit scope
import Swizzle._
class FooToBarSwizzled extends Module {
val foo = IO(Input(new Foo))
val bar = IO(Output(new Bar))
bar := foo.viewAs[Bar]
}
// Generated by CIRCT firtool-1.95.1
module FooToBarSwizzled(
input clock,
reset,
input [7:0] foo_a,
foo_b,
output [7:0] bar_c,
bar_d
);

assign bar_c = foo_b;
assign bar_d = foo_a;
endmodule

DataProduct

DataProduct is a type class used by DataView to validate the correctness of a user-provided mapping. In order for a type to be "viewable" (ie. the Target type of a DataView), it must have an implementation of DataProduct.

For example, say we have some non-Bundle type:

// Loosely based on chisel3.util.Counter
class MyCounter(val width: Int) {
/** Indicates if the Counter is incrementing this cycle */
val active = WireDefault(false.B)
val value = RegInit(0.U(width.W))
def inc(): Unit = {
active := true.B
value := value + 1.U
}
def reset(): Unit = {
value := 0.U
}
}

Say we want to view MyCounter as a Valid[UInt]:

import chisel3.util.Valid
implicit val counterView = DataView[MyCounter, Valid[UInt]](c => Valid(UInt(c.width.W)), _.value -> _.bits, _.active -> _.valid)
// error: myView is already defined as value myView
// implicit val myView: DataView[BundleA, BundleB] = PartialDataView[BundleA, BundleB](_ => new BundleB, _.bar -> _.fizz)
// ^^^^^^
// error: Could not find implicit value for DataView[MdocApp1.this.BundleA, MdocApp1.this.BundleB].
// Please see https://www.chisel-lang.org/chisel3/docs/explanations/dataview
// out := in.viewAs[BundleB]
// ^^^^^^^^^^^^^^^^^^
// error: Could not find implicit value for DataView[MdocApp1.this.BundleA, MdocApp1.this.BundleB].
// Please see https://www.chisel-lang.org/chisel3/docs/explanations/dataview
// out := in.viewAs[BundleB]
// ^^^^^^^^^^^^^^^^^^
// error: Could not find implicit value for DataView[MdocApp1.this.BundleB, MdocApp1.this.BundleA].
// Please see https://www.chisel-lang.org/chisel3/docs/explanations/dataview
// out.viewAs[BundleA] := in
// ^^^^^^^^^^^^^^^^^^^
// error: Could not find implicit value for DataProduct[MdocApp1.this.MyCounter].
// Please see https://www.chisel-lang.org/chisel3/docs/explanations/dataview#dataproduct
// implicit val counterView = DataView[MyCounter, Valid[UInt]](c => Valid(UInt(c.width.W)), _.value -> _.bits, _.active -> _.valid)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// error: Implicit definition must have explicit type [quickfixable]
// Scala 3 migration messages are issued as errors under -Xsource:3. Use -Wconf or @nowarn to demote them to warnings or suppress.
// Applicable -Wconf / @nowarn filters for this fatal warning: msg=<part of the message>, cat=scala3-migration, site=repl.MdocSession.MdocApp1.myView2
// implicit val myView2 = myView.invert(_ => new BundleA)
// ^^^^^^^
// error: Implicit definition must have explicit type [quickfixable]
// Scala 3 migration messages are issued as errors under -Xsource:3. Use -Wconf or @nowarn to demote them to warnings or suppress.
// Applicable -Wconf / @nowarn filters for this fatal warning: msg=<part of the message>, cat=scala3-migration, site=repl.MdocSession.MdocApp1.counterView
// implicit val counterView = DataView[MyCounter, Valid[UInt]](c => Valid(UInt(c.width.W)), _.value -> _.bits, _.active -> _.valid)
// ^^^^^^^^^^^

As you can see, this fails Scala compliation. We need to provide an implementation of DataProduct[MyCounter] which provides Chisel a way to access the objects of type Data within MyCounter:

import chisel3.util.Valid
implicit val counterProduct: DataProduct[MyCounter] = new DataProduct[MyCounter] {
// The String part of the tuple is a String path to the object to help in debugging
def dataIterator(a: MyCounter, path: String): Iterator[(Data, String)] =
List(a.value -> s"$path.value", a.active -> s"$path.active").iterator
}
// Now this works
implicit val counterView: DataView[MyCounter, Valid[UInt]] = DataView(c => Valid(UInt(c.width.W)), _.value -> _.bits, _.active -> _.valid)

Why is this useful? This is how Chisel is able to check for totality as described above. In addition to checking if a user has left a field out of the mapping, it also allows Chisel to check if the user has included a Data in the mapping that isn't actually a part of the target nor the view.