This document is a guide to the usage and design of the Simics
blueprint framework.
The Simics configuration system consists of classes, objects and
attributes on objects. To set up a configuration involving more than
one object, some code must be written. There is typically a need to
also share the configuration setup and let others add more objects to
it in a controlled manner. Therefore, the problem that arises is that
of creating re-usable parameterized sub-systems. These sub-systems
typically correspond to hardware blocks, and hence this is a modelling
problem.
Examples of sub-systems are:
- a processor, which typically consists of
several individual cores that are modeled as Simics objects
- an SOC
- a motherboard
In principle, sub-systems can be created in any way, but the type of
problems that arise when modelling sub-systems are fundamentally
different than when modelling devices, i.e. individual pieces of
hardware. Experience has also shown that it is not natural or
convenient to create sub-systems in the same style as devices,
e.g. using DML or C.
Instead, experience has shown that Python is well-suited for this type
of programming problem, and it is also convenient, since Simics
includes Python as a built-in supported programming language, and most
of the Simics API is available there.
To aid in sub-system modelling, and to standardise it, for easier
sharing and other benefits, Simics includes a Python framework for
sub-system modelling, called blueprints.
The two most basic requirements in sub-system modelling are the ability
to “connect” different sub-systems to create larger systems, and the
ability to parameterize a sub-system in order to facilitate re-use.
To show how the blueprint framework can help, we can look at examples
which create small Simics configurations.
Consider this small baseline configuration. We have a PCIe bus
target with a virtio block device and associated image.
def create_pci_blk_device0(name):
top = simics.pre_conf_object(name, 'namespace')
top.mem = simics.pre_conf_object('memory-space')
top.bus = simics.pre_conf_object('pcie-downstream-port',
upstream_target=top.mem)
top.blk = simics.pre_conf_object('virtio_pcie_blk',
upstream_target=top.bus)
top.blk.image = simics.pre_conf_object('image', size=1024)
top.blk.attr.image = top.blk.image
top.bus.devices = [[1, 0, top.blk]]
simics.SIM_add_configuration([top], None)
simics> @create_pci_blk_device0("bus0")
simics> list-objects -tree namespace = bus0
┐
├ blk ┐
│ ├ image
│ └ upstream_ingress_targets ┐
│ ├ config
│ ├ io
│ ├ memory
│ └ message
├ bus ┐
│ ├ cfg_space
│ ├ io_space
│ ├ mem_space
│ └ msg_space
└ mem
This small configuration can be created easily using the Simics API
directly, or even in the Simics static configuration format. But
normally we need to extend and parameterise our configuration.
Consider an improved example. The target has four physical PCIe slots
0 - 3 corresponding to PCI device ID 1, 2, 5, and 8 on the bus. We
also want to allow different sizes of the block device. Easy, we add
two more parameters to the create-function:
def id_from_slot(slot):
return [1, 2, 5, 8][slot]
def create_pci_blk_device1(name, size, pci_slot):
top = simics.pre_conf_object(name, 'namespace')
top.mem = simics.pre_conf_object('memory-space')
top.bus = simics.pre_conf_object('pcie-downstream-port',
upstream_target=top.mem)
top.blk = simics.pre_conf_object('virtio_pcie_blk',
upstream_target=top.bus)
top.blk.image = simics.pre_conf_object('image', size=size)
top.blk.attr.image = top.blk.image
top.bus.devices = [[id_from_slot(pci_slot), 0, top.blk]]
simics.SIM_add_configuration([top], None)
simics> @create_pci_blk_device1("bus1", 4096, 2)
simics> list-objects -tree namespace = bus1
┐
├ blk ┐
│ ├ image
│ └ upstream_ingress_targets ┐
│ ├ config
│ ├ io
│ ├ memory
│ └ message
├ bus ┐
│ ├ cfg_space
│ ├ io_space
│ ├ mem_space
│ └ msg_space
└ mem
Now consider an even more advanced case.
-
We want to be able to plugin any of the two PCIe cards variants in
the four PCIe slots in any combination.
-
We want to reuse the two PCIe cards for other targets that do not have this
particular mapping between PCIe slot and device ID on the bus. Similarly, we
want to be able to connect PCIe cards developed in other projects.
-
We need to separate the creation of the PCIe bus and the PCIe cards in our
example target system and standardize the connection between them!
-
The blueprint framework helps the target developer with partitioning the
simulated system into reusable parts with standardized connections
between them. It also provides many other features needed when building
target systems, such as propagating configuration data all the way through
the hierarchy, clearly defining the user-visible target parameters, and
guiding the addition of relevant target metadata in a standardized format
to be presented in a target viewer/browser.
# Data about a single PCIe device
class PCIEDevice(NamedTuple):
device_id: int
function_id: int
dev: ConfObject
# State shared between PCIe bus and PCIe device blueprints
class PCIEBusData(State):
bus: ConfObject = None
devices: list[PCIEDevice] = []
# Blueprint creating a PCIe bus
def pcie_bus(builder: Builder, name: Namespace, data: PCIEBusData):
# Register creation of PCIe bus
data.bus = builder.obj(name, "pcie-downstream-port",
upstream_target=name.mem,
devices=data.devices)
# Register creation of associated devices
builder.obj(name.mem, "memory-space")
# Blueprint for PCIe device
def pcie_device(builder: Builder, name: Namespace, data: PCIEBusData,
size=0, device_id=0):
# Register creation of PCIe device
builder.obj(name, "virtio_pcie_blk", upstream_target=data.bus)
builder.set(name.attr, image=name.image)
data.devices.extend([PCIEDevice(device_id, 0, ConfObject(name))])
# Register creation of associated devices
builder.obj(name.image, "image", size=size)
# Whole system state
class Fabric(State):
pcie_bus = PCIEBusData()
# Blueprint for whole system
def system(builder: Builder, name: Namespace):
# Create whole system state
fabric = builder.expose_state(name, Fabric)
# Expose PCIe bus state
builder.expose_state(name, fabric.pcie_bus)
# Expand PCIe bus blueprint in the PCIe object hierarchy
builder.expand(name, "pci", pcie_bus)
# Expand PCIe device blueprint
builder.expand(name, "blk", pcie_device,
size=4096, device_id=id_from_slot(2))
simics> @instantiate("bus2", system)
simics> list-objects -tree namespace = bus2
┐
├ blk ┐
│ ├ image
│ └ upstream_ingress_targets ┐
│ ├ config
│ ├ io
│ ├ memory
│ └ message
└ pci ┐
├ cfg_space
├ io_space
├ mem
├ mem_space
└ msg_space
Notice how the creation of the PCI bus and the devices are separated
into different blueprints. Hence we can replace any of them with a
different variant, as long as it uses the same type of state. Only at
the system level are all pieces put together.
In this section we will describe the blueprint framework concepts, how
they work, and how to use them.
Consider the following small example of a blueprint.
class UARTState(State):
uart: ConfObject = None
console: ConfObject = None
def uart(builder: Builder, name: Namespace):
con = builder.read_state(name, UARTState)
con.uart = builder.obj(name, "NS16550", console=con.console)
def console(builder: Builder, name: Namespace):
con = builder.read_state(name, UARTState)
con.console = builder.obj(name, "textcon", device=con.uart,
recorder=name.recorder)
builder.obj(name.recorder, "recorder")
def board(builder: Builder, name: Namespace):
builder.expose_state(name, UARTState)
builder.obj(name, "blueprint-namespace", info="UART example")
builder.expand(name, "uart", uart)
builder.expand(name, "console", console)
simics> @builder = expand("board", board)
simics> @builder.instantiate()
simics> list-objects -tree -with-class-name substr = board max-depth = 2
┐
└ board (blueprint-namespace) ┐
├ console (textcon)
└ uart (NS16550)
Verify blueprint attribute value
board
The intention of this code is to obtain a Simics configuration with a
text console and a UART object, pointing at each other via
attributes. Exactly how this happens will become clear in the
following sections.
The example illustrates three core concepts of the blueprint
framework: the blueprints themselves, the State and the
expansion.
The blueprints are Python functions. As can be seen in the
example, they have some fixed parameters mandated by the framework
but can also take other parameters if needed. The blueprint functions can:
-
Register with the framework where in the resulting Simics
configuration (pre) objects should be created. As can be seen in the
example, this is done via builder.obj() method.
-
Register that additional blueprints should be included/called by the
framework. This is done via the builder.expand() method.
-
Create and expose or read State structures (used to “connect”
blueprints), done via the builder.expose_state() and builder.read_state()
methods.
-
Set data members in State structures.
-
Register post-instantiate functions which are run after instantiation,
done via builder.at_post_instantiate().
In addition, a blueprint can provide information which can be extracted
from the complete configuration.
-
Information about the built system and which blueprint was used to
create it. In the example this is done by adding an object of class
blueprint-namespace. This is described in a later section.
-
User parameters that are presented to the user in the form of target
parameters. This is described in a later section.
Note that a blueprint describes how a specific part of the system
should be built. A blueprint is not a representation of a part of
the built system, i.e. the way the model is split into different
blueprints have no direct connection to how the Simics configuration
looks like in terms of its object hierarchy. The blueprints themselves
do not leave any trace in the resulting configuration.
Note: A blueprint function is actually a pure function, without
side-effects. At first glance, this is not readily apparent. However,
the builder.expand statement is actually just a convenient way to
compile a set (the “return value”) with objects and blueprints to
be constructed.
Blueprint functions take a namespace parameter, usually called
name. The namespace is the location in the Simics object hierarchy
where the blueprint is expanded, i.e. the sub-tree where the blueprint
is expected to register its objects, or expose or read State. The
blueprint should not access the rest of the hierarchy.
The Namespace class is basically a string with some
extra functionality to make it easy to create derived object names in a
convenient manner, e.g.:
ns = Namespace("qsp.system.mb")
str(ns) == "qsp.system.mb"
str(ns.kappa) == "qsp.system.mb.kappa"
str(ns.kappa[1][2]) == "qsp.system.mb.kappa[1][2]"
str(ns.dev@2) == f"qsp.system.mb.dev2"
The State class is used for defining the shared state used by the
blueprints. As seen in the example, it is read or written by the
blueprints involved and used to set attributes on the created Simics
objects. The blueprint functions themselves have no state. A State
structure should be thought of as a Python dict and is basically a
pure data container.
Essentially, all attribute values on registered objects should come
from State data members, if the value is not computed from other
data members or if it is something local to a single blueprint. In the
example above, the name of the recorder object is not part of any
State since it is not needed by any other blueprint.
Blueprint functions do not have access to internals of other
blueprints. In particular, a blueprint cannot access attributes of
objects registered in another blueprint. All such accesses are done by
having those values coming from a State structure. Only values
defined as a data member of a State can be shared by multiple blueprints.
A state class often includes connectivity data, like in the
example above, but can hold arbitrary data that needs to be passed
between different blueprints.
In the example we define UARTState which carries the
necessary state of an UART connection, namely the objects which are
the end points of the connection: the UART device in the machine and
the text console.
The ConfObject class used in the example is a trivial wrapper of the
Namespace class, providing improved typing (for details, see the
typing section).
As seen in the example, blueprints can both read and write state
fields. At first glance, this seems to violate the principle that
blueprints are pure functions without side effects. However, setting a
State field actually results in a “return” value, where the returned
value is an encoding that the field in a specific instance of the
State sub-class should be set to a specific value. The state in
question is not owned by the blueprint, but is handled by the
framework.
The blueprints can create and pick up instances of the particular
State sub-class that they need. The builder.expose_state() method
creates an instance and makes it available in the sub-tree rooted at
the specified namespace location. The builder.read_state() method
looks for a state instance in the specified namespace location.
This hierarchical way of passing state implies:
-
Blueprints in the middle of an hierarchy do not need to “forward”
state. It is sufficient to just publish state from a top level blueprint
to make it available to leaf blueprints.
-
Less boilerplate code. An SOC built from various small, internal,
blueprints can easily get hold of the state describing the SOC
“backplane”.
-
Open design. It is possible to drop in additional blueprints into an
existing blueprints, or combine multiple blueprints (in a
mixin-manner).
Blueprint expansion is the process of running the specified
blueprint function (and any blueprints expanded recursively) at the
specified namespace location and create a list of pre-objects from
it. It is done via the global expand() function as well as
builder.expand().
Expansion is iterative: since the blueprint framework handles all
state, it can keep track of when the state changes, i.e. when the
blueprints write to State member fields. It will re-run the
blueprint tree, i.e. the function given to expand() and all its
descendants added using builder.expand(), until the state no longer
changes.
In fact, the state used in each iteration is immutable. When
blueprints “change” the state, they write to the state that will be
used in the next iteration of the expansion. I.e. updated state fields
are not immediately visible. Instead, the entire blueprint tree is
re-expanded from scratch, this time using updated field values in the
state structures. This ensures that the expansion result is
consistent:
- state fields are constant during expansion
- blueprints can be expanded in any order
This iterative behaviour of expansion is the key to why blueprints can
be written in a declarative manner: it does not matter exactly in
which order the state members are read or written. In the example
above, one blueprint writes to the state member that is used by the
other blueprint and this will work since in some iteration the state
will be updated and the reading blueprint will obtain correct data.
The expansion can be performed many times. The same result is obtained
every time. Moreover, expansion does not necessitate instantiation of
the pre-object tree. For instance, expanding blueprints is useful in
order to extract data from the configuration.
As mentioned above, the State class is used for defining shared state
that can be read or written by the blueprints involved in the
expansion.
If one wants to define input configuration parameters to the model,
this can be done in a similar way, but sub-classing Config instead
of State. The meaning of configuration parameter is a value that
does not change during blueprint expansion, it is only an input value
used to configure the model, i.e. used in the blueprint function logic
or passed directly to attributes.
Hence a Config sub-class instance is read-only in the blueprints.
To create an instance of a Config sub-class, the blueprints use
builder.create_config(), which is similar to
builder.expose_state(). The equivalent of builder.read_state() is
builder.get_config().
Another situation is when one wants to define 1-1 channels, some part
of the state should only be used by exactly one reader blueprint. For
this case, one can sub-class Binding instead of State. It is
illegal for multiple blueprints to do builder.read_state() on a
particular Binding instance.
In the example above, the blueprints do explicit
builder.read_state() to obtain in necessary instances. The blueprint
framework has syntactic sugar for this. If a blueprint function
parameter is of a sub-type to State, then the framework will do an
automatic builder.read_state() and provide the instance as that
parameter, without the caller blueprint (the one doing
builder.expand()) having to do anything. The uart blueprint in the
example can therefore be written like this:
def uart(builder: Builder, name: Namespace, con: UARTState):
con.uart = builder.obj(name, "NS16550", console=con.console)
In a similar way, if a function argument is a sub-type of Config,
the framework will do an automatic builder.get_config() and provide
the instance.
Simics objects have a queue attribute, which should be set to the
processor or clock object that the object is related to. A common case
is that all objects in each cell are connected to one of the processor
objects in the cell.
When creating Simics objects, each object will inherit the queue
attribute value from its parent object, unless explicitly set. A
modeller writing a blueprint for a sub-system can therefore make sure
the attribute is set for the whole hierarchy by setting the attribute
on the top object.
Moreover, the blueprint framework defines a standard state class named
Queue. Each top blueprint in a sub-system should typically expose
this state on the top level namespace. This facilitates letting
another blueprint easily picking up the same queue object when
integrating several blueprints (by writing wrapper blueprints).
An example illustrating this:
class ClockParams(Config):
freq_mhz = 42
class SystemFabric(State):
clk: ConfObject = None
def my_clock(builder: Builder, name: Namespace,
params: ClockParams, fabric: SystemFabric):
fabric.clk = builder.obj(name, "clock",
freq_mhz=params.freq_mhz)
def board(builder: Builder, name: Namespace, clock_freq=10):
# Expose main state
fabric = builder.expose_state(name, SystemFabric)
# Expose Queue state, useful for external blueprints
queue = builder.expose_state(name, Queue)
# Set queue on whole hierarchy
builder.obj(name, "blueprint-namespace", queue=queue.queue)
# Create config for sub-blueprints
clk_conf = builder.create_config(name.clk, ClockParams)
clk_conf.freq_mhz = clock_freq
queue.queue = fabric.clk
# Expand sub-blueprints
builder.expand(name, "clk", my_clock)
simics> @instantiate("board", board)
simics> board.clk->freq_mhz
<
10.0
simics> board.clk->queue
<
board.clk
As seen above, blueprints are easily made small and modular. They are
regular Python functions and can declare additional function
parameters if necessary. This is useful when blueprints expand other
blueprints within a certain subsystem.
Blueprints can also declare user-facing parameters, which are exposed
outside of the blueprint world, e.g. to Simics targets.
An example of how to define blueprint parameters:
class CPUParams(Config):
freq_mhz = 10
class SystemBackplane(Config):
member = 4711
# Define parameters for all Config members
@blueprint(params_from_config(CPUParams))
def cpu_bp(builder: Builder, name: Namespace, config: CPUParams):
# State classes can be converted to a Python dict
builder.obj(name, "my-cpu-class", **config.asdict())
@blueprint([
Param(name="member", desc="Parameter description", config=SystemBackplane),
ParamGroup(name="cpu", desc="CPU parameters", import_bp="cpu_bp"),
])
def board(builder: Builder, name: Namespace, plane: SystemBackplane):
# Namespace must match the parameter group name
builder.expand(name, "cpu", cpu_bp)
The parameters form a tree. Each parameter definition can either be an
actual parameter (a leaf node) or a parameter group declaration, which
is used to import parameters from another blueprint.
The user-facing name of a parameter definition is of the form
<parent>:<name> where <parent> is the user-facing name of
the parent node and <name> is the name provided in the parameter
definition.
Each parameter is tied to a specific data member in some Config, and
obtains its default value and type from there. If the default value is
None an explicit type annotation must be used. When a blueprint
import parameters from another blueprint, it must also add that
blueprint (using builder.expand) and the namespace must match the
parameter group that is used in the import.
When expanding and/or instantiating a blueprint, values of the
user-facing parameters can be provided as a Python dictionary, where
the user-facing names are the keys. This results in the corresponding
Config data members being overridden. Example:
builder = expand("board", board,
params={"member": 42, "cpu:freq_mhz": 100}})
However, it is rarely needed to do this explicitly since it is handled
by the target parameter framework, as explained in the next
section.
Two cases of dynamic parameters are supported:
-
A blueprint X adds N copies of a blueprint Y in a loop, where
N is a user-facing parameter defined by X. In this case, X
should define a parameter group declaration that imports from Y and
set the count constructor argument of ParamGroup to the name of
the N parameter, which must be of integer type.
-
A blueprint X adds a blueprint Y if a flag is set, and the flag
is a user-facing parameter defined by X. In this case, X should
define a parameter group declaration that imports from Y and set the
enable constructor argument of ParamGroup to the name of the flag
parameter, which must be of boolean type.
The primary use of blueprint parameters is for inclusion in Simics
targets. The target parameter framework has built-in support for
directly importing blueprint parameters, as mentioned in the Target
parameters reference Technology Guide. The result is that the
blueprint parameters become target parameters for the target importing
the blueprint, and the parameters can therefore be inspected using the
target parameter Simics commands.
The target parameter framework also handles expansion and
instantiation of the imported blueprints. Loading a target therefore
has three steps:
-
The target parameter framework parses the YAML scripts that the
target consists of (via script imports) and collects all imported
blueprints. All blueprint parameters and their default values are
inserted in the target parameter tree at their corresponding nodes.
-
All the blueprints are expanded using the same blueprint Builder,
allowing them to expose and read state from each other. In this
expansion, all the target parameter overrides coming from any preset
files or command line arguments are used to set blueprint
parameters. The result of the blueprint expansion is instantiated.
-
The target script code is executed in the usual manner.
This process facilitates integrating sub-systems modelled as
blueprints with targets on top of them. The natural way of integrating
blueprint models is to create a wrapper blueprint that does
builder.expand() but there may be a situation where the sub-system
model inherently consists of a blueprint with a target script on top
that has important logic, so that the integration entry point is the
target script.
In that case, two such sub-systems can be integrated by creating a
wrapper target script that imports the sub-system scripts. By the
above mentioned process when loading the target, the blueprints in the
sub-systems will be expanded together so that they can share state,
and hence “connect”.
The blueprint framework provides some Simics CLI commands for inspection:
-
print-blueprint-state displays the state of the last blueprint
expansion. This is useful for debug purposes.
-
list-blueprint-state displays or returns the state used by the
specified blueprint.
-
list-blueprint-params displays or returns the parameters of the
specified blueprint.
-
list-blueprints lists the known blueprints.
Note that apart from the first command, these work on blueprints that
have been registered with Simics. The registration is done by the same
Python decorator blueprint that is used to define blueprint
parameters (i.e. the parameter list is optional). The decorator has an
optional parameter name for assigning the name used by the CLI
commands, defaulting to the Python function name.
When blueprints register object creation using builder.obj(), they
do not need to register objects at every level of the tree. At tree
nodes which have no explicit object registered, the blueprint
framework will automatically register objects of class namespace.
The exception to this rule is the top level in each blueprint,
i.e. the node of the given name argument. If no object is registered
at this node, an object of class blueprint-namespace is
automatically registered.
This Simics class has an attribute blueprint which records the name
of the blueprint. The attribute is set automatically on all such
objects that are registered at the top level in each blueprint, whether
or not the registration is done explicitly in the blueprint or by the
framework.
The class also has other attributes that are used to store meta-data
about the sub-system modelled by the blueprint, and these are used by
e.g. the Simics command print-target-info. Those attributes are not
set on objects automatically registered by the blueprint framework, so
if specific values are needed, then the blueprint must register the
blueprint-namespace objects explicitly. This is why it is done in
the example above.
The blueprint framework makes full use of the typing system in Python.
State structures are defined using the preferred syntax to declare typed
named tuples in Python. The main difference is that default values
are required for all fields.
With a properly configured editor, the blueprint writer has
- auto-completion for all state fields
- access to field documentation
- full type checking
Standard Python types are used.
When assigned to an object attribute, a Namespace object name is converted
to the corresponding Simics pre-object. The Namespace type is also wrapped
in some other pre-defined types:
ConfObject (simple Namespace wrapping, used to signify that the
Namespace specifies an object)
SignalPort (o|[os] tuple, representing a signal target)
Port (o|[os] representing a generic Simics object interface)
MapEntry (entry suitable the memory-space.map attribute)
What a blueprint does is completely determined by looking at all
values in the state (this is available through the
print-blueprint-state command).
To debug a blueprint, it is sufficient to look at the state that the
blueprint uses, and to look at what the blueprint does itself. It does
not interact with anything else.
The state structures themselves provide a bus-centric view of the
system, describing exactly how the system is connected.
Hotplugging is the act of connecting already instantiated blueprints
or objects. The blueprint framework does not try to unify hotplugging and
the task of creating the initial configuration. There are several
reasons for this:
The requirements for hotplugging/initial configuration are generally
quite different:
-
Pre-objects vs configured Simics objects:
- The initial configuration is built from scratch using pre-objects.
- Hotplugging modifies the state of existing Simics objects.
-
A hotplug connect/disconnect is a simulation event:
- Inserting e.g. a cable should generally result in a signal being raised.
- Creating a system in a connected state should not result in a hotplug
signal.
-
Blueprint size:
- Hotplugging is generally used to connect large blocks with each other.
- Blueprints are usually much smaller (e.g. a single functional unit
in a SOC).
-
Connection kind:
- Hotplug connections often use a natural 1-1 connection style.
- Blueprints within a SOC often have a much more complex structure, with
spider-like connections.
-
Simplicity:
- Keeping the blueprint framework as something that is only used during
the initial construction of the configuration keeps both the hotplug
system and the blueprint framework simple.
Hotplugging is currently handling using the existing framework for connectors.
In general, hotplugging should be reserved for things that can be
modified while the simulation is running, while the blueprint framework
is used to build constituents.
The blueprint framework can also build configurations that contain
either connected or disconnected hotplug connections, with minimal
double work.
Here we again consider the example above, i.e. keep the definition of
the blueprint function board, but look more closely at the
expansion. The expand() function has a parameter logger which can
be set to a standard Python logging.Logger object; the default is to
use the Python root logger object. The expansion uses this object for
logging.
simics> @logging.basicConfig(format="%(message)s", stream=sys.stdout, level=logging.INFO)
simics> @expand("board", board)
Expansion of blueprint "board" at node "board"
Expansion roots:
[(board, 'board', {})]
Expansion presets:
[]
Iteration start: 0
Iteration end: 0
Iteration start: 1
UARTState[board]
uart None
console None
Iteration end: 1
Iteration start: 2
UARTState[board]
uart board.uart
console board.console
Iteration end: 2
At the start of the first iteration, the state is of course
empty. Since the framework does another iteration, the blueprints must
have exposed more state or written something new to the state. At the
start of the second iteration, we see that there is now the expected
UARTState state exposed at the node board but it is empty
Since the framework does a third iteration, the blueprints must again
have written something new to the state during the second
iteration. Indeed, at the start of the third iteration, we see that
the state is now populated with the expected values. It is clear from
the blueprint code these will not change and indeed the expansion is
finished after this iteration.
The blueprint framework is distributed as a Simics module, but it has
a bespoke Python import hook. When importing Python functions
from the blueprint framework, the usual simmod syntax should not be
used. Imports should be done like in this example:
from blueprints import expand, Builder, Namespace
def top(builder: Builder, name: Namespace):
builder.obj(name.clk, "clock", freq_mhz=1)
expand("top", top)
Note that there is also no need to explicitly load the blueprint
Simics module beforehand.
A blueprint is written in Python and included in a Simics module like
other Python code. Delivering a model or sub-system written as a
blueprint is no different than delivering other Simics modules.
If the blueprint is not a model of a whole system but of a sub-system,
then it typically does builder.read_state() or
builder.get_config() somewhere to pick up a State or Config
instance which it reads or writes from.
To integrate such a sub-system into a larger system written as a
blueprint, the sub-system must document which State or Config it
expects to have exposed and on what namespace nodes. As mentioned in
earlier sections, the sub-system should also typically either expose
or read a Queue state (depending on if it creates clocks/processors
or expects them from elsewhere). The integrator can then write wrapper
blueprints that exposes the necessary state.
The integrator can reference blueprint functions in Python files in
other Simics modules using the simmod syntax that Simics provides,
like in this example:
# Refer to blueprint function board in board.py
from simmod.qsp_x86_bp.board import board as qsp_board
def top(builder: Builder, name: Namespace):
builder.expand(name, "qsp", qsp_board)
The Simics module containing a blueprint should make sure to import
the Python files from its module_load.py. This makes sure that any
usage of the Python decorator blueprint mentioned earlier is run, so
that the blueprints are registered with Simics and the blueprint CLI
commands can refer to the blueprint after the Simics module is loaded.
The blueprint framework solves similar problems as the older component
system, which it is meant to replace.
The component system has the connector concept. A component can
expose connection points of certain types and separate connector
objects are implemented for each type. A connector type defines which
connection data can be transferred using the connector.
Moreover, connectors have direction and a component either exposes the
connection point as “up” or “down”. An “up” component con connect to a
“down” component that has a connection point of the same type. When
they connect they can transfer some data.
The connector concept was meant for external connections, typically of
hotplugging type, such as USB or serial connectors, and worked well
for this use case. However it was the only way to transfer data
between components and was therefore used for all kinds of
connections. In many such cases the connector concept was too
opinionated and hence difficult to work with. Connector data was hard to
change and connectors required lots of boilerplate code.
As explained in earlier sections, the blueprint framework introduces
the shared state concept. This is the replacement of the connectors.
It allows blueprints to transfer data, by reading and writing to the
shared state. This is a much more light weight mechanism than
connectors, and therefore easier to use for “internal” connections
within a sub-system modelled as several blueprints.
The up/down nature of connectors came from a general assumption in the
component system that the modelled system consists of sub-systems in a
tree structure. This assumption is generally true in the use cases
that connectors were meant for, but in modern systems it is frequently
not true.
The blueprint framework makes no such assumptions on the model. The
shared state can be read or written by any blueprint that picks it
up. There are still two hierarchies: the Simics object hierarchy which
is related to where state is exposed, and also the hierarchy of
blueprints expanding other blueprints. But this is not directly
related to how blueprints “connect”. Two blueprints anywhere in these
hierarchies can “connect” if they can pick up the same shared
state. As a result, the blueprint framework is less strict in how it
enforces the data flow in the model. It makes it easier to split up
the model into smaller blueprints, which can become reusable parts.
The component system was written in an object oriented style, and
required a fair amount of boilerplate code, not just in connectors but
also to e.g. define component parameters. The blueprint framework is
instead based on a functional style and each blueprint is very terse,
as can be seen from the example above.
The blueprint framework is quite different from the component
system. To make porting a platform from components to blueprints
easier, the blueprint framework includes an adapter that exposes a
blueprint (and child blueprints) as a component. It is only intended to be
used to support step-by-step porting.
The adapter converts exposed state, that is never read during the
blueprint expansion, to down connectors. State that is read but not
exposed anywhere is assumed to be input and is converted to up
connectors.
Blueprint parameters are converted to component configuration attributes.
To make the adapter work, one has to potentially implement methods on
the State sub-classes that define the connector type and data. The
methods are:
# return connector type
def legacy_type(self) -> str:
# similar to get_connect_data() on connectors
def legacy_data(self, is_up, comp, cnt, data) -> list:
# similar to connect() on connectors
def legacy_connect(self, is_up, comp, cnt, attr) -> list:
Here is an example of using the adapter:
@bp_legacy_comp([ParamGroup("board", "", import_bp="system")])
def qsp_config_comp(builder: Builder, name: Namespace):
builder.expand(name, "board", system)
cli.global_cmds.new_qsp_config_comp(name="qsp", **{'board_memory_size_mb': 8192, 'board_disk_bp': "clear_linux"})
As a general rule, a platform that includes a tree of
components is likely most easily ported to blueprints in a bottom-up
manner.
-
Start with the leaf components (without down connections) and
rewrite them into blueprints. Surrounding code has to be changed to
instantiate a blueprint instead of a component. In particular, a
component one level up in the tree has to be modified to not expect
component connections. The blueprint-component adapter may be useful
in this case to make the blueprint still look like a component, if the
component one level up is hard to update.
-
Continue in the same way up the component hierarchy and rewrite
components to blueprints. These blueprints will likely expand the
blueprints already ported.
-
When the whole tree has been converted to blueprints, any use of
the adapter can be removed, and the platform can be simplified since
it is now not dependent on the structure imposed by the component
system.