Below Article shows good insight in how to write a OVM/UVM driver to model pipelined transactions. good way of modeling pipelined behavior.
Courtesy: Verification Academy
original link: https://verificationacademy.com/cookbook/ovm/driver/pipelined
In a pipelined bus protocol a data transfer is broken down into two 
or more phases which are executed one after the other, often using 
different groups of signals on the bus. This type of protocol allows 
several transfers to be in progress at the same time with each transfer 
occupying one stage of the pipeline. The AMBA AHB bus is an example of a
 pipelined bus, it has two phases - the address phase and the data 
phase. During the address phase, the address and the bus control 
information, such as the opcode, is set up by the host, and then during 
the data phase the data transfer between the target and the host takes 
place. Whilst the data phase for one transfer is taking place on the 
second stage of the pipeline, the address phase for the next cycle can 
be taking place on the first stage of the pipeline. Other protocols such
 as OCP use more phases.
A pipelined protocol has the potential to increase the bandwidth of a
 system since, provided the pipeline is kept full, it increases the 
number of transfers that can take place over a given number of clock 
cycles. Using a pipeline also relaxes the timing requirements for target
 devices since it gives them extra time to decode and respond to a host 
access. 
A pipelined protocol could be modelled with a simple 
bidirectional style, whereby the sequence sends a sequence item to the 
driver and the driver unblocks the sequence when it has completed the 
bus transaction. In reality, most I/O and register style accesses take 
place in this way. The drawback is that it lowers the bandwidth of the 
bus and does not stress test it. In order to implement a pipelined 
sequence-driver combination, there are a number of design considerations
 that need to be taken into account in order to support fully pipelined 
transfers: 
- Driver Implementation - The driver needs to have multiple
 threads running, each thread needs to take a sequence item and take it 
through each of the pipeline stages. 
 
- Keeping the pipeline full - The driver needs to unblock the sequencer to get the next sequence item so that the pipeline can be kept full 
 
- Sequence Implementation - The sequence needs to have 
separate stimulus generation and response threads. The stimulus 
generation thread needs to continually send new bus transactions to the 
driver to keep the pipeline full. 
 
  Recommended Implementation Pattern Using get and put
The most straight-forward way to model a pipelined protocol with a 
sequence and a driver is to use the get() and put() methods from the 
driver-sequencer API.
  Driver Implementation
In order to support pipelining, a driver needs to process multiple 
sequence_items concurrently. In order to achieve this, the drivers run 
method spawns a number of parallel threads each of which takes a 
sequence item and executes it to completion on the bus. The number of 
threads required is equal to the number of stages in the pipeline. Each 
thread uses the get() method to acquire a new sequence item, this 
unblocks the sequencer and the finish_item() method in the sequence so 
that a new sequence item can be sent to the driver to fill the next 
stage of the pipeline. 
In order to ensure that only one thread can call get() at a time,
 and also to ensure that only one thread attempts to drive the first 
phase of the bus cycle, a semaphore is used to lock access. The 
semaphore is grabbed at the start of the loop in the driver thread and 
is released at the end of the first phase, allowing another thread to 
grab the semaphore and take ownership. 
At the end of the last phase in the bus cycle, the driver thread 
sends a response back to the sequence using the put() method. This 
returns the response to the originating sequence for processing. 
In the code example a two stage pipeline is shown to illustrate the principles outlined. 
//
// This class implements a pipelined driver
//
class mbus_pipelined_driver extends ovm_driver #(mbus_seq_item);
 
`ovm_component_utils(mbus_pipelined_driver)
 
virtual mbus_if MBUS;
 
function new(string name = "mbus_pipelined_driver", ovm_component parent = null);
  super.new(name, parent);
endfunction
 
// The two pipeline processes use a semaphore to ensure orderly execution
semaphore pipeline_lock = new(1);
//
// The run_phase(ovm_phase phase);
//
// This spawns two parallel transfer threads, only one of
// which can be active during the cmd phase, so implementing
// the pipeline
//
task run();
 
  @(posedge MBUS.MRESETN);
  @(posedge MBUS.MCLK);
 
  fork
    do_pipelined_transfer;
    do_pipelined_transfer;
  join
 
endtask
 
//
// This task has to be automatic because it is spawned
// in separate threads
//
task automatic do_pipelined_transfer;
  mbus_seq_item req;
 
  forever begin
    pipeline_lock.get();
    seq_item_port.get(req);
    accept_tr(req, $time);
    void'(begin_tr(req, "pipelined_driver"));
    MBUS.MADDR <= req.MADDR;
    MBUS.MREAD <= req.MREAD;
    MBUS.MOPCODE <= req.MOPCODE;
    @(posedge MBUS.MCLK);
    while(!MBUS.MRDY == 1) begin
      @(posedge MBUS.MCLK);
    end
    // End of command phase:
    // - unlock pipeline semaphore
    pipeline_lock.put();
    // Complete the data phase
    if(req.MREAD == 1) begin
      @(posedge MBUS.MCLK);
      while(MBUS.MRDY != 1) begin
        @(posedge MBUS.MCLK);
      end
      req.MRESP = MBUS.MRESP;
      req.MRDATA = MBUS.MRDATA;
    end
    else begin
      MBUS.MWDATA <= req.MWDATA;
      @(posedge MBUS.MCLK);
      while(MBUS.MRDY != 1) begin
        @(posedge MBUS.MCLK);
      end
      req.MRESP = MBUS.MRESP;
    end
    // Return the request as a response
    seq_item_port.put(req);
    end_tr(req);
  end
endtask: do_pipelined_transfer
 
endclass: mbus_pipelined_driver
 
 
 | 
  Sequence Implementation
  Unpipelined Accesses  
Most of the time unpipelined transfers are required, since typical 
bus fabric is emulating what a software program does, which is to access
 single locations. For instance using the value read back from one 
location to determine what to do next in terms of reading or writing 
other locations. 
In order to implement an unpipelined sequence that would work 
with the pipelined driver, the body() method would call start_item(), 
finish_item() and get_response() methods in sequence. The get_response()
 method blocks until the driver sends a response using its put() method 
at the end of the bus cycle. The following code example illustrates 
this: 
//
// This sequence shows how a series of unpipelined accesses to
// the bus would work. The sequence waits for each item to finish
// before starting the next.
//
class mbus_unpipelined_seq extends ovm_sequence #(mbus_seq_item);
 
`ovm_object_utils(mbus_unpipelined_seq)
 
logic[31:0] addr[10]; // To save addresses
logic[31:0] data[10]; // To save data for checking
 
int error_count;
 
function new(string name = "mbus_unpipelined_seq");
  super.new(name);
endfunction
 
task body;
 
  mbus_seq_item req = mbus_seq_item::type_id::create("req");
 
  error_count = 0;
  for(int i=0; i<10; i++) begin
    start_item(req);
    assert(req.randomize() with {MREAD == 0; MOPCODE == SINGLE; MADDR inside {[32'h0010_0000:32'h001F_FFFC]};});
    addr[i] = req.MADDR;
    data[i] = req.MWDATA;
    finish_item(req);
    get_response(req);
  end
 
  foreach (addr[i]) begin
    start_item(req);
    req.MADDR = addr[i];
    req.MREAD = 1;
    finish_item(req);
    get_response(req);
    if(data[i] != req.MRDATA) begin
      error_count++;
      `ovm_error("body", $sformatf("@%0h Expected data:%0h Actual data:%0h", addr[i], data[i], req.MRDATA))
    end
  end
endtask: body
 
endclass: mbus_unpipelined_seq
 
 
 | 
Note:
 This example sequence has checking built-in, this is to demonstrate how
 a read data value can be used. The specifictype of check would normally
 be done using a scoreboard.
  Pipelined Accesses  
Pipelined accesses are primarily used to stress test the bus but they
 require a different approach in the sequence. A pipelined sequence 
needs to have a seperate threads for generating the request sequence 
items and for handling the response sequence items. 
The generation loop will block on each finish_item() call until 
one of the threads in the driver completes a get() call. Once the 
generation loop is unblocked it needs to generate a new item to have 
something for the next driver thread to get(). Note that a new request 
sequence item needs to be generated on each iteration of the loop, if 
only one request item handle is used then the driver will be attempting 
to execute its contents whilst the sequence is changing it. 
In the example sequence, there is no response handling, the 
assumption is that checks on the data validity will be done by a 
scoreboard. However, with the get() and put() driver implementation, 
there is a response FIFO in the sequence which must be managed. In the 
example, the response_handler is enabled using the 
use_response_handler() method, and then the response_handler function is
 called everytime a response is available, keeping the sequences 
response FIFO empty. In this case the response handler keeps cound of 
the number of transactions to ensure that the sequence only exist when 
the last transaction is complete. 
//
// This is a pipelined version of the previous sequence with no blocking
// call to get_response();
// There is no attempt to check the data, this would be carried out
// by a scoreboard
//
class mbus_pipelined_seq extends ovm_sequence #(mbus_seq_item);
 
`ovm_object_utils(mbus_pipelined_seq)
 
logic[31:0] addr[10]; // To save addresses
int count; // To ensure that the sequence does not complete too early
 
function new(string name = "mbus_pipelined_seq");
  super.new(name);
endfunction
 
task body;
 
  mbus_seq_item req = mbus_seq_item::type_id::create("req");
  use_response_handler(1);
  count = 0;
 
  for(int i=0; i<10; i++) begin
    start_item(req);
    assert(req.randomize() with {MREAD == 0; MOPCODE == SINGLE; MADDR inside {[32'h0010_0000:32'h001F_FFFC]};});
    addr[i] = req.MADDR;
    finish_item(req);
  end
 
  foreach (addr[i]) begin
    start_item(req);
    req.MADDR = addr[i];
    req.MREAD = 1;
    finish_item(req);
  end
  // Do not end the sequence until the last req item is complete
  wait(count == 20);
endtask: body
 
// This response_handler function is enabled to keep the sequence response
// FIFO empty
function void response_handler(ovm_sequence_item response);
  count++;
endfunction: response_handler
 
endclass: mbus_pipelined_seq
 
 
 | 
If the sequence needs to handle responses, then the response handler function should be extended.
  Alternative Implementation Pattern Using Events To Signal Completion
  Adding Completion Events to sequence_items  
In this implementation pattern, events are added to the sequence_item
 to provide a means of signalling from the driver to the sequence that 
the driver has completed a specific phase. In the example, a 
ovm_event_pool is used for the events, and two methods are provided to 
trigger and to wait for events in the pool: 
//------------------------------------------------------------------------------
//
// The mbus_seq_item is designed to be used with a pipelined bus driver.
// It contains an event pool which is used to signal back to the
// sequence when the driver has completed different pipeline stages
//
class mbus_seq_item extends ovm_sequence_item;
 
// From the master to the slave
rand logic[31:0] MADDR;
rand logic[31:0] MWDATA;
rand logic MREAD;
rand mbus_opcode_e MOPCODE;
 
// Driven by the slave to the master
mbus_resp_e MRESP;
logic[31:0] MRDATA;
 
// Event pool:
ovm_event_pool events;
 
`ovm_object_utils(mbus_seq_item)
 
function new(string name = "mbus_seq_item");
  super.new(name);
  events = get_event_pool();
endfunction
 
constraint addr_is_32 {MADDR[1:0] == 0;}
 
// Wait for an event - called by sequence
task wait_trigger(string evnt);
  ovm_event e = events.get(evnt);
  e.wait_trigger();
endtask: wait_trigger
 
// Trigger an event - called by driver
task trigger(string evnt);
  ovm_event e = events.get(evnt);
  e.trigger();
endtask: trigger
 
// do_copy(), do_compare() etc
 
 
endclass: mbus_seq_item
 
 
 | 
  Driver Signalling Completion using sequence_item Events  
The driver is almost identical to the get, put implementation except 
that it triggers the phase completed events in the sequence item rather 
than using a put() method signal to the sequence that a phase has 
completed and that there is response information available via the 
sequence_item handle.
//
// This class implements a pipelined driver
//
class mbus_pipelined_driver extends ovm_driver #(mbus_seq_item);
 
`ovm_component_utils(mbus_pipelined_driver)
 
virtual mbus_if MBUS;
 
function new(string name = "mbus_pipelined_driver", ovm_component parent = null);
  super.new(name, parent);
endfunction
 
// the two pipeline processes use a semaphore to ensure orderly execution
semaphore pipeline_lock = new(1);
//
// The run();
//
// This spawns two parallel transfer threads, only one of
// which can be active during the cmd phase, so implementing
// the pipeline
//
task run();
 
  @(posedge MBUS.MRESETN);
  @(posedge MBUS.MCLK);
 
  fork
    do_pipelined_transfer;
    do_pipelined_transfer;
  join
 
endtask
 
//
// This task has to be automatic because it is spawned
// in separate threads
//
task automatic do_pipelined_transfer;
  mbus_seq_item req;
 
  forever begin
    pipeline_lock.get();
    seq_item_port.get(req);
    accept_tr(req, $time);
    void'(begin_tr(req, "pipelined_driver"));
    MBUS.MADDR <= req.MADDR;
    MBUS.MREAD <= req.MREAD;
    MBUS.MOPCODE <= req.MOPCODE;
    @(posedge MBUS.MCLK);
    while(!MBUS.MRDY == 1) begin
      @(posedge MBUS.MCLK);
    end
    // End of command phase:
    // - unlock pipeline semaphore
    // - signal CMD_DONE
    pipeline_lock.put();
    req.trigger("CMD_DONE");
    // Complete the data phase
    if(req.MREAD == 1) begin
      @(posedge MBUS.MCLK);
      while(MBUS.MRDY != 1) begin
        @(posedge MBUS.MCLK);
      end
      req.MRESP = MBUS.MRESP;
      req.MRDATA = MBUS.MRDATA;
    end
    else begin
      MBUS.MWDATA <= req.MWDATA;
      @(posedge MBUS.MCLK);
      while(MBUS.MRDY != 1) begin
        @(posedge MBUS.MCLK);
      end
      req.MRESP = MBUS.MRESP;
    end
    req.trigger("DATA_DONE");
    end_tr(req);
  end
endtask: do_pipelined_transfer
 
endclass: mbus_pipelined_driver
 
 
 | 
  
  Unpipelined Access Sequences  
Unpipelined accesses are made from sequences which block, after 
completing the finish_item() call, by waiting for the data phase 
completed event. This enables code in the sequence body method to react 
to the data read back. An alternative way of implementing this type of 
sequence would be to overload the finish_item method so that it does not
 return until the data phase completed event occurs.
//
// Task: finish_item
//
// Calls super.finish_item but then also waits for the item's data phase
// event. This is notified by the driver when it has completely finished
// processing the item. 
//
task finish_item( ovm_sequence_item item , int set_priority = -1 );
 
  // The "normal" finish_item()
  super.finish_item( item , set_priority );
  // Wait for the data phase to complete
  item.wait_trigger("DATA_DONE");
 
endtask
 
 
 | 
As
 in the previous example of an unpipelined sequence, the code example 
shown has a data integrity check, this is purely for illustrative 
purposes.
class mbus_unpipelined_seq extends ovm_sequence #(mbus_seq_item);
 
`ovm_object_utils(mbus_unpipelined_seq)
 
logic[31:0] addr[10]; // To save addresses
logic[31:0] data[10]; // To save addresses
 
int error_count;
 
function new(string name = "mbus_pipelined_seq");
  super.new(name);
endfunction
 
task body;
 
  mbus_seq_item req = mbus_seq_item::type_id::create("req");
  error_count = 0;
 
  for(int i=0; i<10; i++) begin
    start_item(req);
    assert(req.randomize() with {MREAD == 0; MOPCODE == SINGLE; MADDR inside {[32'h0010_0000:32'h001F_FFFC]};});
    addr[i] = req.MADDR;
    data[i] = req.MWDATA;
    finish_item(req);
    req.wait_trigger("DATA_DONE");
  end
 
  foreach(addr[i]) begin
    start_item(req);
    req.MADDR = addr[i];
    req.MREAD = 1;
    finish_item(req);
    req.wait_trigger("DATA_DONE");
    if(req.MRDATA != data[i]) begin
      error_count++;
      `ovm_error("body", $sformatf("@%0h Expected data:%0h Actual data:%0h", addr[i], data[i], req.MRDATA))
    end
  end
endtask: body
 
endclass: mbus_unpipelined_seq
 
 
 | 
  Pipelined Access  
The pipelined access sequence does not wait for the data phase 
completion event before generating the next sequence item. Unlike the 
get, put driver model, there is no need to manage the response FIFO, so 
in this respect this implementation model is more straight-forward.
class mbus_pipelined_seq extends ovm_sequence #(mbus_seq_item);
 
`ovm_object_utils(mbus_pipelined_seq)
 
logic[31:0] addr[10]; // To save addresses
 
function new(string name = "mbus_pipelined_seq");
  super.new(name);
endfunction
 
task body;
 
  mbus_seq_item req = mbus_seq_item::type_id::create("req");
 
  for(int i=0; i<10; i++) begin
    start_item(req);
    assert(req.randomize() with {MREAD == 0; MOPCODE == SINGLE; MADDR inside {[32'h0010_0000:32'h001F_FFFC]};});
    addr[i] = req.MADDR;
    finish_item(req);
  end
 
  foreach (addr[i]) begin
    start_item(req);
    req.MADDR = addr[i];
    req.MREAD = 1;
    finish_item(req);
  end
endtask: body
 
endclass: mbus_pipelined_seq
 
 
 | 
 
transaction item as given below, it is working