Collection copies and cost

Know when an assignment or an argument copies a whole collection, when two names share one, and how to keep scoreboard work from growing with the square of the traffic.

Lesson
2.6
Time
20 min
Runs on
Verilator
Check
5 questions

A regression that took 2 minutes with 1,000 reads takes over 3 hours with 10,000. For every response, the scoreboard copied the pending queue into a local variable and then searched it from the front. Ten times the traffic meant a hundred times the work, and nobody noticed until the long test was added.

Responses arriving newest first, searched from the front
012345rid543210pending654321compares654321total611151820216 responses, 21 compares

Each response is found at the far end of the queue. For n responses the total is n(n + 1)/2.

Assignment copies the elements

For fixed arrays, dynamic arrays, queues and associative arrays alike, b = a; copies every element of a into b. Afterwards the two collections are separate, so a change to one never shows in the other.

A copy is also a snapshot. Taking snapshot = pending_q; when reset starts keeps a record of what was outstanding, which stays intact while the original is cleared.

The copy takes time in proportion to the number of elements. Copying a 10,000-entry queue once is cheap, but copying it once per response adds up to as many as 100 million element copies over the test.

Arguments copy too, unless you pass by reference

A collection passed as an ordinary input argument is copied into the routine. The routine works on its own copy, so popping from it has no effect on the caller, and the copy costs the same as an assignment.

Declaring the argument ref passes the caller's collection itself, with no copy. Changes made inside the routine are then visible to the caller, which is exactly what a helper that drains or fills a queue needs. A ref argument requires an automatic routine.

When the routine should only read the collection, declare it const ref. You avoid the copy, and the compiler rejects any accidental change.

Input argument

function automatic void drain(int q[$]);
  q.delete();          // empties the copy
endfunction

The caller's queue is untouched.

Reference argument

function automatic void drain(ref int q[$]);
  q.delete();          // empties the caller's queue
endfunction

The caller's queue is empty afterwards.

Handles are the exception

Copying an array of int or of packed structs copies the values themselves. Copying a queue of class handles copies only the handles, so both queues then point at the same objects. Module 4 explains handles and how to copy the objects behind them.

Structs are values, so a queue of structs copies every field. That makes a queue of packed structs a safe way to keep a private snapshot of expected responses.

Removing items while you walk a queue

Deleting from a queue inside a loop that walks it forward skips elements. After q.delete(i), every later element moves down one place, so the element that was at i + 1 is now at i, and the loop steps past it.

Start with {2, 4, 6, 1} and delete the even values going forward. The 2 goes, the 4 slides into its place and is skipped, and the 6 goes, leaving {4, 1} instead of {1}. The same trap exists for every collection: never change its shape while a foreach loop is walking it.

Walk backward from the last index instead, or build the result in one step: q = q.find with (item % 2 != 0); leaves {1}.

Counting the work

Searching a pending queue from the front for each response costs up to n compares when n reads are pending. Over n responses that adds up to about n²/2 compares in the worst case.

In the opening story, the copy made things worse. Copying the queue before each search added up to n element copies per response, another quadratic term on top of the search.

ResponsesFront-to-back search (worst case)Lookup by key
1,000500,500 compares1,000 lookups
10,00050,005,000 compares10,000 lookups
100,000about 5 billion compares100,000 lookups

Ten times the traffic costs a hundred times the work for the search, and ten times for the lookup.

The 2-minute run in the opening story made about half a million compares, and the 3-hour run made about 50 million.

An associative array keyed by ID turns each search into a single lookup. The standard does not fix how long a lookup takes, but simulators use tree or hash structures whose cost grows far more slowly than a scan.

Choosing a collection

Pick the collection from the operations you need, not from habit. Use a fixed array when the spec fixes the count, and a dynamic array when the length is known as the transaction starts. Use a queue when items arrive one by one and leave in order.

Use an associative array when you look items up by a key from a large or sparse space, such as IDs or addresses. Use an associative array of queues when items are looked up by key and must keep their order within each key.

Try it

The assignment and the input argument both worked on copies, so pending_q kept its first element and all 1,000 entries. Only the ref argument changed the caller's queue.

The search cost 500,500 compares, exactly 1000 × 1001 / 2, while the map needed one lookup per response. Change n to 2000 and predict both counts before you run it.

RUNVerilator · simulator service
Edit the code, then run it
Expected: after assignment: pending_q[0]=0 copy_q[0]=-1 after input argument: pending_q has 1000 entries queue search: 500500 compares for 1000 responses map lookup: 1000 lookups for 1000 responses after ref argument: copy_q has 0 entries

Check yourself

Predict the outputPredict the exact output.
module tb;
  int a [$];
  int b [$];
  initial begin
    a = '{1, 2, 3};
    b = a;
    b.push_back(4);
    a[0] = 9;
    $display("%0d %0d %0d", a.size(), b.size(), b[0]);
  end
endmodule
ConceptA function takes int q[$] as an ordinary input argument and pops three elements from it. What happens to the caller's queue?
ConceptA scoreboard searches the pending queue from the front for each response. In the worst case, about how many compares does it make for 10,000 responses?
ConceptReads carry 32-bit tags, and about 40 are outstanding at once. The scoreboard must test whether a tag is pending, read its expected data and remove it. Which collection fits?
Spot the bugEach option defines drain, which must empty the caller's queue. Which option leaves the caller's queue unchanged?

Remember

  • Assigning a collection copies every element; the two collections are separate afterwards.
  • An input argument copies the collection; ref shares it, and const ref shares it read-only.
  • Copying a queue of class handles copies the handles, not the objects.
  • Searching a queue per response costs about n²/2 compares; a lookup by key costs one step per response.
  • Choose the collection from the operations you need: count, order, or lookup by key.

Practice