The present invention will be described further, by way of example only, with reference to embodiments thereof as illustrated in the accompanying drawings, in which.
a and 4b are Hasse diagrams illustrating dependencies between events;
a and 5b show a Hasse diagram and corresponding data structure;
a and 6b illustrate simpler dependencies in Hasse diagram and data structure;
c shows a disadvantage of the simplified data structure;
a to d schematically shows the splitting into separately executable sections of a computer program according to an embodiment of the present invention;
a to b schematically shows a method of splitting and then merging sections of a computer program;
a shows a simple computer program annotated according to an embodiment of the present invention;
b shows the maximal set of threads for the program of
The trace reordering logic can reorder the trace data in a number of ways. In the embodiments shown, the complex sequence of events that are output from the system being traced comprise synchronisation events which are indicated as capitals in the stream of data 12. These synchronisation events are events where one processor is synchronised with another processor and thus, different threads being processed on different processors are synchronised at this point. The trace reordering logic can utilise this information to reorder the trace to produce an alternative, simpler sequence of events which could legally have been produced by the program. This data is then input to the trace user interface 30 where it may be displayed to the user or compressed to a disc for later replay.
Although, this figure shows a single event stream 12 in practice there may be multiple event streams, for example there may be one for each processor. These may then be merged prior to the trace reordering process or the merging could be performed as part of the reordering process.
Events that are traced can be quite low level events such as individual memory accesses and instruction execution or they can be quite high level events such as the start and stop of remote procedure calls. Data transfer between different processors, inserting/removing an entry from an inter-thread communication channel etc. Events may be generated automatically by hardware or they may be generated in response to operations in the program. For example, a communication library may explicitly generate an event on every send or receive or an operating system may explicitly generate an event on every context switch.
Embodiments of the present invention may also comprise a compiler 40 into which the original program is input to be compiled to run on the multiprocessor system. The compiler parallelises the program to run on the different processors and produces program fragments. It should be noted, that in some embodiments the compiler provides multiple threads from the program which would be suitable to run on a multiprocessor system but may be run on a single processor system. In this embodiment, compiler 40 in addition to producing the parallelised threads or program fragments also produces debug information 42 and dependency information 44. The dependency information indicates additional conditions under which it is legal to reorder events. This information is input to the trace reordering logic 20. By providing more information about the dependencies present in the original program, the trace reordering logic is able to provide further reordering and potentially produce a simpler trace.
The debug information 42 which may also comprise dependency data can be input to the user interface where it can be used when displaying the reordered diagnostic data.
The compiler may also insert trace generating operations into the program to reduce the number of possible re-orderings available. This may allow the trace reordering to run faster or use less memory and it may help to make the reconstructive trace more similar to a trace of the original program.
The reordering logic uses different reordering rules to reorder the trace data depending on the embodiment. In some systems, different processors and different threads can run at different rates from each other and the relative rates may be unknown. Thus, any correct program must contain synchronisation between threads and processors whenever the threads communicate. These are synchronisation events. In such systems, we may assume that events in different threads are not ordered with respect to each other unless the two threads directly or indirectly synchronise with each other. This assumption allows a significant amount of trace reordering to be performed. In order to do this, these events are identified as synchronisation events.
Some of the techniques/rules used for reordering depend on the goals of the reordering. For example, if the goal is to gain a high level understanding of the program then the number of context switches, that is switches between different threads may be reduced. Furthermore, the maximum number of simultaneous live threads at any point in the trace should be reduced. Furthermore, the number of events between a causing event and a consequence of that event should also be reduced to make the trace easier to understand. The amount of reordering should be kept to only reordering events where it simplifies the trace.
Although, in some embodiments the trace may be complete for the whole program, in other embodiments it is only a part of the program that is traced.
There are a number of ways that trace reconstruction can be performed. The following describes a simple online algorithm that can be used for reordering trace.
In common with many online algorithms, the event stream can be split into three regions:
Events within the window are represented by an appropriate data structure and trace reconstruction consists of repeatedly either adding the next unobserved event to the window or removing an event from the window and adding it to the processed events.
The reordering is restricted by dependencies between events: two events cannot be reordered relative to each other if one must come before another. For example, we write ‘x+y’ to indicate that event ‘a’ must occur before ‘b’ in the reordered trace. This relationship is transitive: if ‘x→y’ and ‘y→z’ then it must be true that ‘x→z’. The usual convention of using a Hasse diagram to represent the → relation is used. For example, the relation ‘a→b’, ‘b→c’, ‘a→c’, ‘b→d’, ‘c→d’, ‘a→d’ is represented by
It is assumed that the initial event stream is ordered such that if ‘x→y’ then event ‘x’ comes before event ‘y’.
To add an event ‘x’ to the window, we identify all events ‘e’ already within the window such that ‘e→x’ and add a link from each such event to x. As an transformation, an edge can be omitted or an existing edge deleted if there is an edge from event ‘e’ to some other edge ‘f’ and there is an edge from ‘f’ to ‘x’.
For example, if we add an event ‘a5’ to
An event ‘y’ can only be removed from the window (and added to the end of the processed event list) if there is no other event ‘x’ within the window such that ‘x→y’. There are often multiple events that can be removed from the window. For example, in the above example, events ‘a1’, ‘b1’ and ‘c1’ can be removed from the window. When there are multiple events that can be removed from the window, heuristics are used to choose the best event to remove.
A number of different data structures may be used to represent the events within the window. An obvious representation is to reflect the ‘→’ relation directly using a directed graph structure. For example, the events and dependencies between them of
The rule here is that an event ‘x’ comes before an event ‘y’ in a queue if and only if ‘x→y’. This has the effect of restricting the window to a set of totally ordered events (e.g., all the events generated by a single thread or a single processor). The disadvantage of this simplified representation is that it is not capable of representing some types of dependency. For example, if the event ‘b2’ is added and ‘a2→b2’, then ‘a1’ and a2’ must be removed from the window, see
When an event is removed from the window (and added to the list of processed events), there is often a choice of several events that could be removed. In such cases, some simple rules for choosing which event to remove, these are:
Some examples of events and dependencies for detailed trace are given below. Some processors support generation of detailed traces consisting of memory accesses and instructions. For such processors, appropriate dependencies might be:
Some examples of events and dependencies for coarse-grained trace are given below.
Some systems may generate much coarser-grained traces consisting of when a given task (e.g., computing a result) starts and stops and when threads/processors communicate/synchronize with each other. For such systems, appropriate dependencies might be:
If completion of one task ‘t1’ triggers another task ‘t2’ to start, then ‘t1→t2’.
Communication between threads can be ‘direct’ or ‘buffered’. In direct communication, if a thread ‘A’ sends a value to a thread ‘B’, the next value read by ‘B’ will be the last value written by ‘A’. In such communication, there is a dependency from the event associated with sending a value to the next event associated with receiving a value. In buffered communication, if a thread ‘A’ sends a value to a thread ‘B’, the next value read by ‘B’ will be one of the values previously written sent by ‘A’. For example, when using a FIFO channel between threads, values are received by B in the order they are sent by A. In such communication, there is a dependency from the event associated with sending a value to the event associated with receiving that particular value from the channel.
There is often an overhead associated with generating events so it is desirable to reduce the amount of trace generated. This can be done by using coarser-grained trace but it can also be done by generating less detail in the trace and/or by exploiting the semantics of the operations generating the trace. For example, to model a buffered communication precisely, one must include enough information in an event so that the event caused by reading a particular value can be matched with the event that wrote that particular value. The amount of trace can be reduced by omitting this information in which case, all receive events are considered to be dependent on all preceding send events. Alternatively, if the communication is through a FIFO channel, the amount of trace can be reduced by exploiting the fact that values are received in the same order as they are written so the first send to that channel can be paired with the first receive from the channel, etc.
If trace is generated by executing instructions which emit events into the trace, it can be convenient to emit trace in communication and scheduling libraries. For example, the scheduler can emit an event on every context switch and communication libraries can emit events before sending an event, after sending an event, before receiving an event and after receiving an event.
It may also be convenient to generate trace in parallelising compilers. If a compiler automatically parallelizes a program, the compiler can insert instructions to emit trace events to allow more complete reconstruction of the trace. If the program is split into multiple threads and the threads frequently communicate with each other, it is often sufficient to emit events when the threads communicate which can be done using a communication library that emits events as previously described. If the threads communicate infrequently, it may be desirable for the compiler to emit additional events. For example, if the original program is:
and the compiler splits this program into two threads that do not communicate with each other:
then there may not be enough information to reconstruct a properly synchronized trace showing alternating calls to functions ‘f’ and ‘g’. In this case, the compiler can insert instructions to emit an event after invoking ‘f’ and before invoking ‘g’ resulting in threads:
It should be noted that although the above described techniques are particularly useful for multiprocessor SoCs, they can also be applied to traces of VLIW execution (parallelism between functional units) and to traces of remote execution (parallelism between computers).
In summary embodiments of the invention that are able to modify the parallelizing compiler, may insert enough trace generation hints so that, instead of an arbitrary total order, a total order corresponding to the original sequential program is generated.
If an embodiment is not able to insert all the trace generation hints needed to achieve a total order, our reconstruction algorithm uses heuristics to reduce the number of context switches in the trace.
To achieve complete reconstruction, it helps if the parallelizing compiler inserts hints in the code that make it easier to match up corresponding parts of the program. In the absence of explicit hints, it may be possible to obtain full reconstruction using debug information to match parts of the program.
When there are no explicit hints or debug information, partial reconstruction can be achieved by using points in the program that synchronize with each other to guide the matching process. The resulting trace will not be sequential but will be easier to understand. A useful application is to make it simpler to understand a trace of a program written using an event-based programming style (e.g., a GUI, interrupt handlers, device drivers, etc.)
Partial reconstruction could also be used to simplify parallel programs running on systems that use release consistency. Such programs must use explicit memory barriers at all synchronization points so it will be possible to simplify traces to reduce the degree of parallelism the programmer must consider.
One simple case of this is reconstructing a ‘message passing’ view of bus traffic.
In summary, reordering of trace can make use of multiple sources of information including (in approximately increasing order of specificity):
1) Information about the compiler which is independent of the compilation of the program such as:
a) The style of parallelization used
b) Naming conventions used for variables/functions introduced during parallelization.
c) How programs are instrumented to produce trace (e.g., a trace event might be generated at the end of every loop).
2) Information about how a particular program was compiled such as:
a) Which sections of code were parallelized.
b) Where threads and communication/synchronization between threads was introduced.
c) How sections of code in the original program relate to sections of code in the parallelized program, e.g., line 23 of the original program might correspond to line 45 in the parallelized program.
d) How variables in the original program relate to variables in the parallelized program, e.g. a variable ‘x’ in the original program might have been split into two parts ‘x1’ and ‘x2’ in the parallelized program
e) What instrumentation has been introduced into this program e.g., an event might be generated indicating how many times a particular loop executed.
3) Information about this particular execution. This primarily consists of the trace data but might also include information about which processors executed particular threads (say).
4) User preferences.
Details of further techniques are given below.
a shows a portion of a computer program comprising a loop in which data items are processed, function f operating on the data items, and function g operating on the data items output by function f and then function h operating on these items. These functions being performed n times in a row for values of i from 1 to n.
Thus, the control flow can be seen as following the solid arrows while data flow follows the dotted arrows. In order to try to parallelise this portion of the computer program it is analysed, either automatically or by a programmer and “decouple” indications are inserted into the data flow where it is seen as being desirable to split the portion into sections that are decoupled from each other and can thus, be executed on separate execution mechanisms. In this case, a decouple indication is provided between the data processing operations f and g. This can be seen as being equivalent to inserting a buffer in the data flow, as the two sections are decoupled by providing a data store between then so that the function f can produce its results which can then be accessed at a different time by function g.
c, shows how the program is amended to enable this decoupling by the insertion of “put” and “get” instructions into the data stream. These result in the data being generated by the f function being put into a data store, from which it is retrieved by the get instruction to be processed by function g. This enables the program to be split into two sections as is shown in
As can be seen from
It should be noted that the put and get operations used in
void* put_begin(channel *ch);
void put_end(channel *ch, void* buf);
void* get_begin(channel *ch);
void get_end(channel *ch, void* buf);
Using these operations, sequences of code such as:
int x[100];
generate(x);
put(ch,x);
Can be rewritten to this more efficient sequence:
int px=put_begin(ch);
generate(px);
put_end(ch,px);
And similarly, for get:
int x[100];
get(ch,x);
consume(x);
to this more efficient sequence:
int px=get_begin(ch);
consume(px);
get_end(ch,px);
The use of puts and gets to decouple threads can be further extended to use where communication between threads is cyclic. Cyclic thread dependencies can lead to “Loss of Decoupling”—that is, two threads may not run in parallel because of data dependencies between them and thus, in devices of the prior art decoupling is generally limited to acyclic thread dependencies.
1. A particularly common case of cyclic thread dependencies is code such as
Under conventional decoupling schemes, puts are always inserted after assignment to any data boundary variable. This would require both a put outside the loop and a put at the end of the loop:
Conventional decoupling schemes only generate matched pairs of puts and gets (i.e., there is only one put on each channel and only one get on each channel) so they cannot generate such code.
Embodiments of the present invention use an alternative way of decoupling this code and generate:
This does have matched pairs of puts and gets but breaks the rule of always performing a put after any assignment to a variable.
a and 8b schematically illustrate the program code shown in
Furthermore, parallelizing at a significantly coarser granularity also allows the duplication of more control code between threads which reduces and simplifies inter-thread communication allowing the generation of distributed schedules. That is, we can distribute the control code across multiple processors both by putting each control thread on a different processor and by putting different parts of a single control thread onto different processors.
The transfer of data may be done by, writing the data to a particular buffer such as a FIFO. Alternatively it may simply be done by providing the other section of the program with information as to where the data has been stored.
The way of transferring the data depends on the system the program is executing on. In particular, if the architecture does not have shared memory, it is necessary to insert DMA copies from a buffer in one memory to a buffer in a different memory. This can lead to a lot of changes in the code: declaring both buffers, performing the copy, etc. In embodiments of the invention an analysis is performed to determine which buffers need to be replicated in multiple memory regions and to determine exactly which form of copy should be used. DMA copies are also inserted automatically subject to some heuristics when the benefit from having the programmer make the decision themselves is too small.
Systems with multiple local memories often have tight memory requirements which are exacerbated by allocating a copy of a buffer in multiple memories. The analysis takes account of this and seeks to reduce the memory requirement by overlapping buffers in a single memory when they are never simultaneously live.
It should be noted that although in some programs it may be appropriate to provide a FIFO type data store between the sections, in others it may be that the section requiring the data does not require it in a particular order, or it may not require all of the data. This can be provided for by varying the way the data is passed between the sections.
a shows a simple computer program annotated according to an embodiment of the present invention. An analysis of this program is performed initially and parts of the program are identified by programmer annotation in this embodiment although it could be identified by some other analysis including static analysis, profile driven feedback, etc. The parts identified are as follows:
What can be regarded as the “decoupling scope”. This is a contiguous sequence of code that we wish to split into multiple threads.
The “replicatable objects”: that is variables and operations which it is acceptable to replicate. A simple rule of thumb is that scalar variables (i.e., not arrays) which are not used outside the scope, scalar operations which only depend on and only modify replicatable variables, and control flow operations should be replicated but more sophisticated policies are possible.
Ordering dependencies between different operations: if two function calls both modify a non-replicated variable, the order of those two function calls is preserved in the decoupled code. (Extensions to the basic algorithm allow this requirement to be relaxed in various ways.)
The “data boundaries” between threads: that is, the non-replicatable variables which will become FIFO channels. (The “copies” data annotation described above determines the number of entries in the FIFO.)
This degree of annotation is fine for examples but would be excessive in practice so most real embodiments would rely on tools to add the annotations automatically based on heuristics and/or analyses.
At a high level, the algorithm splits the operations in the scope into a number of threads whose execution will produce the same result as the original program under any scheduling policy that respects the FIFO access ordering of the channels used to communicate between threads.
The particular decoupling algorithm used generates a maximal set of threads such that the following properties hold:
b shows the maximal set of threads for the program of
Another way is to pick an operation, identify all the operations which must be in the same thread as that operation by repeatedly adding operations which would be merged (in step 2 above). Then pick the next operation not yet assigned to a thread and add all operations which must be in the same thread as that operation. Repeat until there are no more non-replicatable operations. It should be noted that this is just one possible way of tackling this problem: basically, we are forming equivalence classes based on a partial order and there are many other known ways to do this.
The above method splits a program into a number of sections which can be executed in parallel. There are many possible mechanisms that can be used to accomplish this task.
Also illustrated in
The following describes language extensions/annotations, compilation tools, analysis tools, debug/profiling tools, runtime libraries and visualization tools to help programmers program complex multiprocessor systems. It is primarily aimed at programming complex SoCs which contain heterogeneous parallelism (CPUs, DEs, DSPs, programmable accelerators, fixed-function accelerators and DMA engines) and irregular memory hierarchies.
The compilation tools can take a program that is—either sequential or contains few threads and map it onto the available hardware, introducing parallelism in the process. When the program is executed, we can exploit the fact that we know mappings between the user's program and what is executing to efficiently present a debug and profile experience close to what the programmer expects while still giving the benefit of using the parallel hardware. We can also exploit the high level view of the overall system to test the system more thoroughly, or to abstract away details that do not matter for some views of the system.
The task of programming a SoC is to map different parts of an application onto different parts of the hardware. In particular, blocks of code must be mapped onto processors, data engines, accelerators, etc. and data must be mapped onto various memories. In a heterogeneous system, we may need to write several versions of each kernel (each optimized for a different processor) and some blocks of code may be implemented by a fixed—function accelerator with the same semantics as the code.
The mapping process is both tedious and error-prone because the mappings must be consistent with each other and with the capabilities of the hardware. We reduce these problems using program analysis which:
Some features of our approach are:
To describe this idea further, we need some syntax for annotations. Here we provide one embodiment of annotations which provide the semantics we want.
In this document, all annotations take the form:
. . . @ {tag1=>value1, . . . tagm=>value}
. . . @ value
The primary annotations are on data and on code. If a tag is repeated, it indicates alternative mappings.
The tags associated with data include:
int x[100] @ {memory=>“bank3”, copies=>2, memory=>“bank4”, copies=>1} indicates that there are 3 alternative mappings of the array x: two in memory bank3 and one in memory bank4.
The tags associated with code include:
{fir(x); fft(x,y);} @ {processor=>“P1”}
Specifies that processor P1 is to execute fft followed by P1. The semantics is similar to that of a synchronous remote procedure call: when control reaches this code, free variables are marshalled and sent to processor P1, processor P1 starts executing the code and the program continues when the code finishes executing.
It is not always desirable to have synchronous RPC behaviour. It is possible to implement asynchronous RPCs using this primitive either by executing mapped code in a separate thread or by splitting each call into two parts: one which signals the start and one which signals completion.
The tags associated with functions are:
Void copy_DMA(void* src, void* tgt, unsigned length) @ {cpu=>“PL081”, implements=>“copy”};
There are a variety of languages for describing hardware architectures including the SPIRIT language and ARM SoCDesigner's internal language. While the languages differ in syntax, they share the property that we can extract information such as the following:
Based on rules detected in an architectural description and/or rules from other sources, we can analyse both sequential and parallel programs to detect errors in the mapping. Some examples:
Having detected errors in a system mapping, there are a variety of responses. An error such as mapping a piece of code to a fixed-function accelerator that does not support that function should probably just be reported as an error that the programmer must fix. Errors such as omitting synchronization can sometimes be fixed by automatically inserting synchronization. Errors such as requiring more variables to a memory bank than will fit can be solved, to some extent, using overlay techniques. Errors such as mapping an overly large variable to a memory can be resolved using software managed paging though this may need hardware support or require that the kernel be compiled with software paging turned on (note: software paging is fairly unusual so we have to implement it before we can turn it on!). Errors such as omitting memory barriers, cache flush/invalidate operations or DMA transfers can always be fixed automatically though it can require heuristics to insert them efficiently and, in some cases, it is more appropriate to request that the programmer fix the problem themselves.
Given a program that has been mapped to the hardware, the precise way that the code is compiled depends on details of the hardware architecture. In particular, it depends on whether two communicating processors have a coherent and sequentially consistent view of a memory through which they are passing data.
Our compiler uses information about the SoC architecture, extracted from the architecture description, to determine how to implement the communication requirements specified within the program. This enables it to generate the glue code necessary for communication to occur efficiently and correctly. This can include generation of memory barriers, cache maintenance operations, DMA transfers and synchronisation on different processing elements.
Other manual and automatic factors may be used to influence the communication mechanism decisions. Errors and warnings within communication mappings can be found using information derived from the architecture description.
Some features of our approach are:
An RPC abstraction can be expressed as functions mapped to particular execution mechanisms:
Code can be translated to target the selected processing elements, providing the associated synchronisation and communication. For example, this could include checking the resource is free, configuring it, starting it and copying the results on completion. The compiler can select appropriate glue mechanisms based on the source and target of the function call. For example, an accelerator is likely to be invoked primarily by glue on a processor using a mechanism specific to the accelerator.
The choice of processor on which the operation runs can be determined statically or can be determined dynamically. For example, if there are two identical DMA engines, one might indicate that the operation can be mapped onto either engine depending on which is available first.
RPC calls may be synchronous or asynchronous. Asynchronous calls naturally introduce parallelism, while synchronous calls are useful as a simpler function call model, and may be used in conjunction with fork-join parallelism. In fact, parallelism is not necessary for efficiency; a synchronous call alone can get the majority of the gain when targeting accelerators. Manually and automatically selecting between asynchronous and synchronous options can benefit debugging, tracing and optimisation.
This mechanism enables a particular function to have a number of different execution targets within a program, but each of those targets can be associated back to the original function; debugging and trace can exploit this information. This enables a user to set a breakpoint on a particular function, and the debug and trace mechanisms be arranged such that it can be caught wherever it executes, or on a restricted subset (e.g. a particular processing element).
Some features of our approach are:
Increasingly, applications are being built using libraries which define datatypes and a set of operations on those types. The datatypes are often bulk datastructures such as arrays of data, multimedia data, signal processing data, network packets, etc. and the operations may be executed with some degree of parallelism on a coprocessor, DSP processor, accelerator, etc. It is therefore possible to view programs as a series of often quite coarse-grained operations applied to quite large data structures instead of the conventional view of a program as a sequence of ‘scalar’ operations (like ‘32 bit add’) applied to ‘scalar’ values like 32-bit integers or the small sets of values found in SIMD within a register (SWAR) processing such as that found in NEON. It is also advantageous to do so because this coarse-grained view can be a good match for accelerators found in modern SoCs.
Most optimizing compilers perform a dataflow analysis prior to optimization. For example, section 10.5 of Aho Sethi and Ullman's ‘Compilers: Principles Techniques and Tools’, published by Addison Wesley, 1986, ISBN: 0-201-10194-7 describes dataflow analysis. The dataflow analysis is restricted to scalar values: those that fit in a single CPU register. Two parts of a dataflow analysis are:
Especially when writing parallel programs or when using I/O devices and when dealing with complex memory hierarchies, it is necessary to allocate multiple identically sized buffers and copy between the different buffers (or use memory remapping hardware to achieve the effect of a copy). We propose that in many cases these multiple buffers can be viewed as alternative versions of a single, logical variable. It is possible to detect this situation in a program with multiple buffers, or the programmer can identify the situation. One way the programmer can identify the situation is to declare a single variable and then use annotations to specify that the variable lives in multiple places or the programmer could declare multiple variables and use annotations to specify that they are the same logical variable. However the different buffers are identified as being one logical variable, the advantages that can be obtained include:
By performing a liveness analysis of the data structures, the compiler can provide improved memory allocation through memory reuse because it can identify opportunities to place two different variables in the same memory location. Indeed, one can use many algorithms normally used for register allocation (where the registers contain scalar values) to perform allocation of data structures. One modification required is that one must handle the varying size of buffers whereas, typically, all scalar registers are the same size.
One thing that can increase memory use is having many variables simultaneously live. It has been known for a long time that you can reduce the number of scalar registers required by a piece of code by reordering the scalar operations so that less variables are simultaneously live.
Using a coarse-grained dataflow analysis, one can identify the lifetime of each coarse-grained data structure and then reorder code to reduce the number of simultaneously live variables. One can even choose to recalculate the value of some data structure because it is cheaper to recalculate it than to remember its value.
When parallelising programs, one can also deliberately choose to restrain the degree of parallelism to reduce the number of simultaneously live values. Various ways to restrain the parallelism exist: forcing two operations into the same thread, using mutexes/semaphores to block one thread if another is using a lot of resource, tweaking priorities or other scheduler parameters.
If a processor/accelerator has a limited amount of available memory, performing a context switch on that processor can be challenging. Context switching memory-allocated variables used by that processor solves the problem.
Compiler books list many other standard transformations that can be performed to scalar code. Some of the mapping and optimisation techniques that can be applied at the coarse-grain we discuss include value splitting, spilling, coalescing, dead variable removal, recomputation, loop hoisting and CSE.
In some cases, one would want to view a complex datastructure at multiple granularities. For example, given a buffer of complex values, one might wish to reason about dataflow affecting all real values in the buffer, dataflow affecting all imaginary values or dataflow involving the whole buffer. (More complex examples exist)
When debugging, it is possible for the data structure to live in a number of different places throughout the program. We can provide a single debug view of all copies, and watch a value wherever it is throughout the lifetime of a program, optionally omitting omit certain accesses such as DMAs.
Using this coarse-grained view, one can achieve zero copy optimization of a sequence of code like this:
Most of this section is about coarse-grained data structure but some benefits from identifying coarse-grained operations come when we are generating trace. Instead of tracing every scalar operation that is used inside a coarse-grained operation, we can instead just trace the start and stop of the operation. This can also be used for cross-triggering the start/stop of recording other information through trace.
If we rely on programmer assertions, documentation, etc. in performing our dataflow analysis, it is possible that an error in the assertions will lead to an error in the analysis or transformations performed. To guard against these we can often use hardware or software check mechanisms. For example, if we believe that a function should be read but not written by a given function, then we can perform a compile-time analysis to verify it ahead of time or we can program an MMU or MPU to watch for writes to that range of addresses or we can insert instrumentation to check for such errors. We can also perform a ‘lint’ check which looks for things which may be wrong even if it cannot prove that they are wrong. Indeed, one kind of warning is that the program is too complex for automatica analysis to prove that it is correct.
Some of the features of our approach are:
Given a program that uses some accelerators, it is possible to make it run faster by executing different parts in parallel with one another. Many methods for parallelizing programs exist but many of them require homogeneous hardware to work and/or require very low cost, low latency communication mechanisms to obtain any benefit. Our compiler uses programmer annotations (many/all of which can be inserted automatically) to split the code that invokes the accelerators (‘the control code’) into a number of parallel “threads” which communicate infrequently. Parallelizing the control code is advantage because it allows tasks on independent accelerators to run concurrently.
Our compiler supports a variety of code generation strategies which allow the parallelized control code to run on a control processor in a real time operating system, in interrupt handlers or in a polling loop (using ‘wait for event’ if available to reduce power) and it also supports distributed scheduling where some control code runs on one or more control processors, some control code runs on programmable accelerators, some simple parts of the code are implemented using conventional task-chaining hardware mechanisms. It is also possible to design special ‘scheduler devices’ which could execute some parts of the control code. The advantage of not running all the control code on the control processor is that it can greatly decrease the load on the control processor.
Some of the features of our approach are:
The basic decoupling algorithm splits a block of code into a number of threads that pass data between each other via FIFO channels. The algorithm requires us to identify (by programmer annotation or by some other analysis including static analysis, profile driven feedback, etc.) the following parts of the program:
If we use annotations on the program to identify these program parts, a simple program might look like this:
At a high level, the algorithm splits the operations in the scope into a number of threads whose execution will produce the same result as the original program under any scheduling policy that respects the FIFO access ordering of the channels used to communicate between threads.
The particular decoupling algorithm we use generates a maximal set of threads such that the following properties hold:
One way to generate this set of threads is as follows:
It is conventional to use dataflow analysis to determine the live ranges of a scalar variable and then replace the variable with multiple copies of the variable: one for each live range. We use the same analysis techniques to determine the live range of arrays and split their live ranges in the same way. This has the benefit of increasing the precision of later analyses which can enable more threads to be generated. On some compilers it also has the undesirable effect of increasing memory usage which can be mitigated by later merging these copies if they end up in the same thread and by being selective about splitting live ranges where the additional decoupling has little overall-effect on performance.
The put and get operations used when decoupling can be used both for scalar and non-scalar values. (i.e., both for individual values (scalars) and arrays of values (non-scalars) but they are inefficient for large scalar values because they require a memory copy. Therefore, for coarse-grained decoupling, it is desirable to use an optimized mechanism to pass data between threads.
In operating systems, it is conventional to use “zero copy” interfaces for bulk data transfer: instead of generating data into one buffer and then copying the data to the final destination, we first determine the final destination and generate the data directly into the final destination. Applying this idea to the channel interface, we can replace the simple ‘put’ operation with two functions: put_begin obtains the address of the next free buffer in the channel and put_end makes this buffer available to readers of the channel:
We can do that using queues with multiple readers, queues with intermediate r/w points, reference counts or by restricting the decoupling (all readers must be in same thread and . . . ) to make lifetime trivial to track. This can be done by generating custom queue structures to match the code or custom queues can be built out of a small set of primitives.
Prior art on decoupling restricts the use of decoupling to cases where the communication between the different threads is acyclic. There are two reasons why prior art has done this:
It is possible to modify the decoupling algorithm to allow the programmer to insert-puts and gets (or put_begin/end, get_begin/end pairs) themselves. The modified decoupling algorithm treats the puts and gets in much the same way that the standard algorithm treats data boundaries. Specifically, it constructs the maximal set of threads such that:
The modified decoupling algorithm will produce:
Writing code using explicit puts can also be performed as a preprocessing step. For example, we could transform:
To the following equivalent code:
A First-In First-Out (FIFO) channel preserves the order of values that pass through it: the first value inserted is the first value extracted, the second value inserted is the second value extracted, etc. Other kinds of channel are possible including:
In parallel programming, it is often necessary for one thread to need exclusive access to some resource while it is using that resource to avoid a class of timing dependent behaviour known as a “race condition” or just a “race”. The regions of exclusive access are known as “critical sections” and are often clearly marked in a program. Exclusive access can be arranged in several ways. For example, one may ‘acquire’ (aka ‘lock) a ‘lock’ (aka ‘mutex’) before starting to access the resource and ‘release’ (aka ‘unlock’) the lock after using the resource. Exclusive access may also be arranged by disabling pre-emption (such as interrupts) while in a critical section (i.e., a section in which exclusive access is required). In some circumstances, one might also use a ‘lock free’ mechanism where multiple users may use a resource but at some point during use (in particular, at the end), they will detect the conflict, clean up and retry. Some examples of wanting exclusive access include having exclusive access to a hardware accelerator, exclusive access to a block of memory or exclusive access to an input/output device. Note that in these cases, it is usually not necessary to preserve the order of accesses to the resource.
The basic decoupling algorithm avoids introducing race conditions by preserving all ordering dependencies on statements that access non-replicated resources. Where locks have been inserted into the program, the basic decoupling algorithm is modified as follows:
Decoupling can be applied to any sequential section of a parallel program. If the section communicates with the parallel program, we must determine any ordering dependencies that apply to operations within the section (a safe default is that the order of such operations should be preserved). What I'm saying here is that one of the nice properties of decoupling is that it interacts well with other forms of paralellization including manual parallelization.
The decoupling algorithm generates sections of code that are suitable for execution on separate processors but can be executed on a variety of different execution engines by modifying the “back end” of the compiler. That is, by applying a further transformation to the code after decoupling to better match the hardware or the context we wish it to run in.
The Most Straightforward Execution Model is to Execute each Separate Section in the Decoupled Program on a Separate Processor or, on a Processor that Supports Multiple Hardware Contexts (i.e., Threads), to Execute each Separate Section on a Separate Thread.
Since most programs have at least one sequential section before the separate sections start (e.g., there may be a sequential section to allocate and initialize channels), execution will typically start on one processor which will later synchronize with the other processors/threads to start parallel sections on them.
In the context of an embedded system and, especially, a System on Chip (SoC), some of the data processing may be performed by separate processors such as general purpose processors, digital signal processors (DSPs), graphics processing units (GPUs), direct memory access (DMA) units, data engines, programmable accelerators or fixed-function accelerators. This data processing can be modelled as a synchronous remote procedure call. For example, a memory copy operation on a DMA engine can be modelled as a function call to perform a memory copy. When such an operation executes, the thread will typically:
Instead of a multiprocessor or multithreaded processor, one can use a thread library, operating system (OS) or real time operating system (RTOS) running on one or more processors to execute the threads introduced by decoupling. This is especially effective when combined with the use of accelerators because running an RTOS does not provide parallelism and hence does not increase performance but using accelerators does provide parallelism and can therefore increase performance.
Instead of executing threads directly using a thread library, OS or RTOS, one can transform threads into an ‘event-based’ form which can execute more efficiently than threads. The methods can be briefly summarized as follows:
Transforming threads as described above to allow event-based execution is a good match for applications that use accelerators that signal task completion via interrupts. On receiving an interrupt signalling task completion the following steps occur:
Transforming threads as described above is also a good match for polling-based execution where the control processor tests for completion of tasks on a set of accelerators by reading a status register associated with each accelerator. This is essentially the same as interrupt-driven execution except that the state of the accelerators is updated by polling and the polling loop executes until all threads complete execution.
Distributed scheduling can be done in various ways. Some part of a program may be simple enough that it can be implemented using a simple state machine which schedules one invocation of an accelerator after completion of another accelerator. Or, a control processor can hand over execution of a section within a thread to another processor. In both cases, this can be viewed as a RPC like mechanism (“{foo( ); bar( )@P0;}@P1”). In the first case, one way to implement it is to first transform the thread to event-based form and then opportunistically spot that a sequence of system states can be mapped onto a simple state machine and/or you may perform transformations to make it map better.
Two claims in this section: 1) using a priority mechanism and 2) using a non-work-conserving scheduler in the context of decoupling
If a system has to meet a set of deadlines and the threads within the system share resources such as processors, it is common to use a priority mechanism to select which thread to run next. These priorities might be static or they may depend on dynamic properties such as the time until the next deadline or how full/empty input and output queues are.
In a multiprocessor system, using a priority mechanism can be problematic because at the instant that one task completes, the set of tasks available to run next is too small to make a meaningful choice and better schedules occur if one waits a small period of time before making a choice. Such schedulers are known as non-work-conserving schedulers.
A long-standing problem of parallelizing compilers is that it is hard to relate the view of execution seen by debug mechanisms to the view of execution the programmer expects from the original sequential program. Our tools can take an execution trace obtained from running a program on parallel hardware and reorder it to obtain a sequential trace that matches the original program. This is especially applicable to but not limited to the coarse-grained nature of our parallelization method.
To achieve complete reconstruction, it helps if the parallelizing compiler inserts hints in the code that make it easier to match up corresponding parts of the program. In the absence of explicit hints, it may be possible to obtain full reconstruction using debug information to match parts of the program.
When there are no explicit hints or debug information, partial reconstruction can be achieved by using points in the program that synchronize with each other to guide the matching process. The resulting trace will not be sequential but will be easier to understand. A useful application is to make it simpler to understand a trace of a program written using an event-based programming style (e.g., a GUI, interrupt handlers, device drivers, etc.)
Partial reconstruction could also be used to simplify parallel programs running on systems that use release consistency. Such programs must use explicit memory barriers at all synchronization points so it will be possible to simplify traces to reduce the degree of parallelism the programmer must consider.
HP has been looking at using trace to enable performance debugging of distributed protocols. Their focus is on data mining and performance not reconstructing a sequential trace. http://portal.acm.org/citation.cfm?id=945445.945454&d1=portal&d1=ACM&type=series&idx=945445&part=Proceedings&WantType=Proceedings&title=ACM %20Symposium %20 on %20Operating %20Systems %20Principles&CFID=111111111&CFTOKEN=2222222
Suppose we can identify sections of the system execution and we have a trace which lets us identify when each section was running and we have a trace of the memory accesses they performed or, from knowing properties of some of the sections, we know what memory accesses they would perform without needing a trace. The sections we can identify might be:
Given a sequence of traces of sections, we can construct a dynamic dataflow graph where each section is a node in a directed graph and there is an edge from a node M to a node N if the section corresponding to M writes to an address x and the section corresponding to N reads from address x and, in the original trace, no write to x happens between M's write to x and N's read from x.
This directed dataflow graph shows how different sections communicate with each other and can be used for a variety of purposes:
The first section talks about what you need for the general case of a program that has been parallelized and you would like to serialize trace from a run of the parallel program based on some understanding of what transformations were done during parallelization (i.e., you know how different bits of the program relate to the original program). The second part talks about how you would specifically do this if the paralellization process included decoupling. The sketch describes the simplest case in which it can work but it is possible to relax these restrictions significantly.
Here is a brief description of what is required to do trace reconstruction for decoupled programs. That is, to be able to take a trace from the decoupled program and reorder it to obtain a legal trace of the original program.
Most relevant should be conditions 1-9 which say what we need from trace. Where the conditions do not hold, there need to be mechanisms to achieve the same effect or a way of relaxing the goals so that they can still be met. For example, if we can only trace activity on the bus and two kernels running on the same DE communicate by one leaving the result in DE-local memory and the other using it from there, then we either add hardware to observe accesses to local memories or we tweak the schedule to add a spurious DMA copy out of the local memory so that it appears on the bus or we pretend we didn't want to see that kind of activity anyway.
Condition 10 onwards relate mainly to what decoupling aims to achieve. But, some conditions are relevant such as conditions 5 and 6 because, in practice, it is useful to be able to relax these conditions slightly. For example (5) says that kernels have exclusive access to buffers but it is obviously ok to have multiple readers of the same buffer and it would also be ok (in most real programs) for two kernels to (atomically) invoke ‘malloc’ and ‘free’ in the middle of the kernels even though the particular heap areas returned will depend on the precise interleaving of those calls and it may even be ok for debugging printfs from each kernel to be ordered.
Consequences of (1)-(2): We can derive which kernel instance is running on any processor at any time.
Condition 2 can be satisfied if we have each kernel only accesses buffers that are either:
To show that decoupling gives us property (10) (i.e., that any trace of the decoupled program can be reordered to give a trace of the original program and to show how to do that reordering), we need to establish a relationship between the parallel state machine and the master state machine (i.e., the original program). This relationship is an “embedding” (i.e., a mapping between states in the parallel and the master machines such that the transitions map to each other in the obvious way). It is probably easiest to prove this by considering what happens when we decouple a single state machine (i.e., a program) into two parallel state machines.
When we decouple, we take a connected set of states in the original and create a new state machine containing copies of those states but:
5. Deadlock should not happen:
Outline proof: Because they share the same control flow, the two threads perform opposing actions (i.e., a put/get pair) on channels in the same sequence as each other. A thread can only block on a get or a put if it has run ahead of the other thread. Therefore, when one thread is blocked, the other is always runnable.
1. Locks are added by the programmer.
To avoid deadlock, we require:
A sufficient (and almost necessary) condition is that a put and a get on the same channel must not be inside corresponding critical sections (in different threads):
That is, extreme care must be taken if DECOUPLE occurs inside a critical section especially when inserting DECOUPLE annotations automatically
2. Puts and gets don't have to occur in pairs in the program.
A useful and safe special case is that all initialization code does N puts, a loop then contains only put-get pairs and then finalization code does at most N gets. It should be possible to prove that this special case is ok.
It might also be possible to prove the following for programs containing arbitrary puts and gets: if the original single-threaded program does not deadlock (i.e., never does a get on an empty channel or a put on a full channel), then neither will the decoupled program.
A long-standing problem of parallelizing compilers is that it is virtually impossible to provide the programmer with a start-stop debugger that lets them debug in terms of their sequential program even though it runs in parallel. In particular, we would like to be able to run the program quickly (on the parallel hardware) for a few minutes and then switch to a sequential view when we want to debug.
It is not necessary (and hard) to seamlessly switch from running parallel code to running sequential code but it is feasible to change the scheduling rules to force the program to run only one task at a time. With compiler help, it is possible to execute in almost the sequence that the original program would have executed. With less compiler help or where the original program was parallel, it is possible to present a simpler schedule than on the original program. This method can be applied to interrupt driven program too.
This same method of tweaking the scheduler while leaving the application unchanged can be used to test programs more thoroughly. Some useful examples:
Errors in concurrent systems often stem from timing-dependent behaviour. It is hard to find and to reproduce errors because they depend on two independently executing sections of code executing at the same time (on a single-processor system, this means that one section is preempted and the other section runs). The problematic sections are often not identified in the code.
Concurrent systems often have a lot of flexibility about when a particular piece of code should run: a task may have a deadline or it may require that it receive 2 seconds of CPU in every 10 second interval but tasks rarely require that they receive a particular pattern of scheduling.
The idea is to use the flexibility that the system provides to explore different sequences from those that a traditional scheduler would provide. In particular, we can use the same scheduler but modify task properties (such as deadlines or priorities) so that the system should still satisfy real time requirements or, more flexibly, use a different scheduler which uses a different schedule.
Most schedulers in common use are ‘work conserving schedulers’: if the resources needed to run a task are available and the task is due to execute, the task is started. In contrast, a non-work-conserving scheduler might choose to leave a resource idle for a short time even though it could be used. Non-work-conserving schedulers are normally used to improve efficiency where there is a possibility that a better choice of task will become available if the scheduler delays for a short time.
A non-work-conserving scheduler for testing concurrent systems because they provide more flexibility over the precise timing of different tasks than does a work-conserving scheduler. In particular, we can exploit flexibility in the following way:
It is often useful to monitor which different schedules have been explored either to report to the programmer exactly what tests have been performed and which ones found problems or to drive a feedback loop where a test harness keeps testing different schedules until sufficient test coverage has been achieved.
When a sequential program is parallelized, it is often the case that one of the possible schedules that the scheduler might choose causes the program to execute in exactly the same order that the original program would have executed. (Where this is not true, such as with a non-preemptive scheduler it is sometimes possible to insert pre-emption points into the code to make it true.)
If the scheduler is able to determine what is currently executing and what would have run next in the original program, the scheduler can choose to execute the thread that would run that piece of code. (Again, it may be necessary to insert instrumentation into the code to help the scheduler figure out the status of each thread so that it can execute them in the correct order.)
Working with the whole program at once and following compilation through many different levels of abstraction allows us to exploit information from one level of compilation in a higher or lower level. Some examples:
Although illustrative embodiments of the invention have been described in detail herein with reference to the accompanying drawings, it is to be understood that the invention is not limited to those precise embodiments, and that various changes and modifications can be effected therein by one skilled in the art without departing from the scope and spirit of the invention as defined by the appended claims.
Number | Date | Country | |
---|---|---|---|
60853756 | Oct 2006 | US |