Breck Carter
Last modified: July 22, 1996
mail to: bcarter@bcarter.com
The last time I tried to make gravy the result was too watery, so I added some flour. Then it was too thick so in went more water. Now it was watery again, and tasteless too, so more flour and meat stock and mushrooms and spices were added. This cycle was repeated until I had a gallon of gruel and decided to give up cooking altogether.
Some time ago I helped optimize a PowerBuilder application designed to solve a similar problem, "If we put this in the pot, what will be the result?" The pot was a smelter and the things that went into it were pieces of metal alloy of varying but known composition. For example, if 100 kilograms of alloy containing 98.5% aluminum were added to 100 kilograms of 99.5% aluminum, the result would be 200 kilograms that was 99% pure.
No one needs a computer to solve an example that simple, but in real life they were adding many pieces to the pot, each containing up to 26 different elements in varying proportions. These pieces came from different sources, all the way from pure hot metal to recycled scrap, plus the mysteriously named “heels”, “sows”, “pucks” and “logs”. Each piece was weighed and its composition was analyzed and stored in a database so that the final composition of a batch containing all these pieces could be calculated ahead of time.
This calculation was important because each batch had a customer who was expecting a specific target composition. The final result was analyzed too, but if it wasn’t right, and if the pot was now full of metal, some would have to be poured out and other pieces added in a second attempt to get it right. While they would save these outpourings to use as raw material for future batches (unlike the gravy I poured down the drain to make room for more flour), it was regarded as a waste of time and energy.
All these calculations had to be done in more-or-less real time by users standing next to fuming pots of molten metal, and they wanted to know if the pieces they’d chosen would produce success. The trouble was that it was taking several minutes for the PowerBuilder application to spit out the answer. And that’s where I came in, in my role as Forensic Programmer.
Figure 1 shows a simplified view of the piece and element tables. Each row in the piece table is identified by the batch and piece within the batch, and contains the piece’s weight and an field identifying its chemical analysis. The analysis table is keyed by analysis id and element (aluminum, iron, etc.), and each row contains the proportion (purity) of that element as measured by this particular analysis process. In the simple example above, the piece table would contain two rows with the same batch id but different piece ids, weights and analysis ids. The analysis table would contain rows with two different analysis ids (for the two different pieces), and one row for each element found in each piece.
Figure 1: Piece and Element Analysis Tables
For various (and legitimate) reasons, embedded SQL was used instead of DataWindows to retrieve this information. A straightforward row-oriented approach had been used: for each piece, the analysis table was queried for each element that was required in the final composition. If that element existed in this piece, its proportion in the piece was used to adjust the final proportion. Missing elements were treated as “zero purity” and also affected the final composition.
So where was the time going? Two nested cursor fetch loops were used, one using Format 3 dynamic SQL to get the pieces and the other Format 4 to look for elements. I don’t know about you, but outside of the PowerBuilder sample application I’d never even seen Format 4 SQL let alone used it myself. So it took a while before I noticed something even more unusual: the Format 4 cursor was being opened and closed with each fetch, 26 times, inside the inner “get next element for this piece” loop.
In other words, the inner fetch was based on a singleton select rather than a result set. Common sense indicated that a single result set containing 10 elements (a common number) would be more efficient 26 separate selects of which 10 would return a single row and the other 16 fail. A simple timing test proved that while Format 4 SQL might be very powerful, using it to seek individual rows can consume a lot of time.
Listing 1 shows a sample of code that was used to measure the time taken before and after optimization. Listing 2 shows the pseudo-code for the original nested-fetch implementation, and Listing 3 shows a simplified but working version of the actual code. The word “simplified” might seem too strong a term because it’s still pretty complex stuff; nevertheless, the actual script contained even more logic that’s been removed for the purpose of this article.
Listing 1: Measuring CPU Time
long ll_start_msec long ll_elapsed_msec ll_start_msec = CPU() ...long running script code ll_elapsed_msec = CPU() - ll_start_msec MessageBox ( "Time", & string ( ll_elapsed_msec, & "#####0" ) + " msec." )
Listing 2: Nested Fetch Pseudo-Code
outer cursor = select weight, analysis_id from piece where batch_id = ? inner cursor = select purity from analysis where analysis_id = ? and element_id = ? open outer cursor fetch first row from outer cursor do while there is another row accumulate batch weight for each of 26 elements of interest open inner cursor // OUCH!!! fetch single row from inner cursor if fetch worked accumulate element proportion else adjust proportion downwards end if close inner cursor // OUCH!!! next fetch next row from outer cursor loop close outer cursor
Listing 3: Nested Fetch Script
// Function Arguments: // string as_batch_id // The batch of interest. // string as_element_id[] // The 26 elements of interest. // decimal ac_total_weight // Accumulates the batch weight. // decimal ac_total_purity[] // Accumulates element proportions. DynamicStagingArea ldsa_piece DynamicStagingArea ldsa_analysis string ls_analysis_id string ls_piece_select string ls_analysis_select decimal lc_piece_weight decimal lc_total_weight decimal lc_piece_purity decimal lc_total_purity integer li_element_ctr integer li_element_ct // Outer cursor retrieves pieces in a batch. ldsa_piece = create DynamicStagingArea declare piece_cursor & dynamic cursor for ldsa_piece; ls_piece_select & = "select weight, analysis_id " & + "from piece " & + "where batch_id = '" + as_batch_id +"'" prepare ldsa_piece & from :ls_piece_select using SQLCA; // Inner cursor looks for a single element. ldsa_analysis = create DynamicStagingArea declare analysis_cursor & dynamic cursor for ldsa_analysis; ls_analysis_select & = "select purity from analysis " & + "where analysis_id = ? " & + "and element_id = ?" prepare ldsa_analysis & from :ls_analysis_select using SQLCA; describe ldsa_analysis into SQLDA; // Start processing pieces. open dynamic piece_cursor; if ( SQLCA.SQLCode <> 0 ) & and ( SQLCA.SQLCode <> 100 ) then MessageBox ( "Error", "Open 1" ) halt close end if fetch piece_cursor & into :lc_piece_weight, :ls_analysis_id; if ( SQLCA.SQLCode <> 0 ) & and ( SQLCA.SQLCode <> 100 ) then MessageBox ( "Error", "Fetch 1" ) halt close end if do while SQLCA.SQLCode = 0 // Accumulate batch weight. lc_total_weight = ac_total_weight ac_total_weight & = ac_total_weight + lc_piece_weight // Look for elements in analysis. SetDynamicParm ( SQLDA, 1, & ls_analysis_id ) li_element_ct & = UpperBound ( as_element_id ) // This is where the time is going... for li_element_ctr = 1 to li_element_ct SetDynamicParm ( SQLDA, 2, & as_element_id [ li_element_ctr ] ) open dynamic analysis_cursor & using descriptor SQLDA; // OUCH!!! if ( SQLCA.SQLCode <> 0 ) & and ( SQLCA.SQLCode <> 100 ) then MessageBox ( "Error", "Open 2" ) halt close end if fetch analysis_cursor & using descriptor SQLDA; if ( SQLCA.SQLCode <> 0 ) & and ( SQLCA.SQLCode <> 100 ) then MessageBox ( "Error", "Fetch 2" ) halt close end if if SQLCA.SQLCode = 0 then lc_piece_purity & = GetDynamicNumber ( SQLDA, 1 ) else lc_piece_purity = 0.0 end if // Accumulate element proportion. lc_total_purity & = ac_total_purity[ li_element_ctr ] ac_total_purity [ li_element_ctr ] & = ( ( lc_total_purity & * lc_total_weight ) & + ( lc_piece_purity & * lc_piece_weight ) ) & / ac_total_weight close analysis_cursor; // OUCH!!! next fetch piece_cursor & into :lc_piece_weight, :ls_analysis_id; if ( SQLCA.SQLCode <> 0 ) & and ( SQLCA.SQLCode <> 100 ) then MessageBox ( "Error", "Fetch 3" ) halt close end if loop close piece_cursor; destroy ldsa_piece destroy ldsa_analysis return
The original solution of Listings 2 and 3 may be stated as “Look for each element we want, and process the found and not-found cases similarly”. A new approach was worked out to involve a single result set: “Get all the elements we have, process them, then figure out what’s missing and adjust accordingly.” In other words, “Think sets, not rows!”, an important motto for anyone working with relational databases.
A second optimization was now possible: since the inner fetch loop was now using a result set, it could be combined with the outer fetch with a simple join. In other words, by joining the piece and analysis tables, a single Format 3 cursor could be used and the Format 4 SQL eliminated altogether.
As with all but the simplest optimizations, the down side was more complex script code. Extra logic was necessary to figure out which rows belonged to which piece, and to keep track of which elements were missing from each piece so their proportion in the final batch could be adjusted. Listing 4 shows the optimized pseudo-code, and Listing 5 shows the corresponding script.
Listing 4: Single Cursor Pseudo-Code
cursor = select piece_id, weight, element_id, purity from piece, analysis where batch_id = ? and piece.analysis_id = analysis.analysis_id order by batch_id, piece_id open cursor fetch first row from cursor do while there is another row if it is a new piece if it is not the first piece figure out which elements were missing from the previous piece and adjust their proportions end if accumulate batch weight end if figure out which element was found in this piece and accumulate its proportion loop fetch next row from cursor loop if there was at least one piece figure out which elements were missing from the last piece and adjust their proportions end if close cursor
Listing 5: Single Cursor Script
// - see Listing 3 for function arguments DynamicStagingArea ldsa_piece string ls_piece_select decimal lc_piece_weight decimal lc_total_weight decimal lc_piece_purity decimal { 6 } lc_total_purity integer li_elem_ctr integer li_elem_ct string ls_piece_id string ls_element_id string ls_prev_piece_id boolean lb_found[] decimal lc_prev_piece_weight decimal lc_prev_total_weight boolean lb_not_done li_elem_ct = UpperBound ( as_element_id ) // Single result set: piece AND element data. ldsa_piece = create DynamicStagingArea declare piece_cursor & dynamic cursor for ldsa_piece; ls_piece_select & = "select piece_id, weight, " & + "element_id, purity " & + "from piece, analysis " & + "where batch_id = '" + as_batch_id & + "' and piece.analysis_id " & + "= analysis.analysis_id " & + "order by batch_id, piece_id" prepare ldsa_piece & from :ls_piece_select using SQLCA; open dynamic piece_cursor; if ( SQLCA.SQLCode <> 0 ) & and ( SQLCA.SQLCode <> 100 ) then MessageBox ( "Error", "Open" ) halt close end if fetch piece_cursor & into :ls_piece_id, :lc_piece_weight, & :ls_element_id, :lc_piece_purity; if ( SQLCA.SQLCode <> 0 ) & and ( SQLCA.SQLCode <> 100 ) then MessageBox ( "Error", "Fetch 1" ) halt close end if ls_prev_piece_id = "" // no piece yet do while SQLCA.SQLCode = 0 // Handle "break" on new piece. if ls_prev_piece_id <> ls_piece_id then // Anything missing in previous piece? if ls_prev_piece_id <> "" then for li_elem_ctr = 1 to li_elem_ct // Adjust element proportion. if not lb_found[li_elem_ctr] then lc_total_purity & = ac_total_purity & [ li_elem_ctr ] ac_total_purity & [ li_elem_ctr ] & = ( lc_total_purity & * lc_prev_total_weight ) & / ( lc_prev_piece_weight & + lc_prev_total_weight ) end if next end if // Accumulate batch weight. lc_total_weight = ac_total_weight ac_total_weight & = ac_total_weight + lc_piece_weight // Set up for this new piece. for li_elem_ctr = 1 to li_elem_ct lb_found [ li_elem_ctr ] = false next ls_prev_piece_id = ls_piece_id lc_prev_piece_weight= lc_piece_weight lc_prev_total_weight= lc_total_weight end if // Handle element that was found. li_elem_ctr = 1 lb_not_done = true do while lb_not_done // Accumulate element proportion. if ls_element_id = as_element_id & [ li_elem_ctr ] then lc_total_purity = ac_total_purity & [ li_elem_ctr ] ac_total_purity[ li_elem_ctr ] & = ( ( lc_total_purity & * lc_total_weight ) & + ( lc_piece_purity & * lc_piece_weight ) ) & / ac_total_weight lb_found [ li_elem_ctr ] = true lb_not_done = false else li_elem_ctr++ if li_elem_ctr & > li_elem_ct then lb_not_done = false end if end if loop fetch piece_cursor & into :ls_piece_id, :lc_piece_weight, & :ls_element_id, :lc_piece_purity; if ( SQLCA.SQLCode <> 0 ) & and ( SQLCA.SQLCode <> 100 ) then MessageBox ( "Error", "Fetch 2" ) halt close end if loop // Anything missing in last piece? if ls_prev_piece_id <> "" then for li_elem_ctr = 1 to li_elem_ct // Adjust element proportion. if not lb_found [ li_elem_ctr ] then lc_total_purity & = ac_total_purity [ li_elem_ctr ] ac_total_purity [ li_elem_ctr ] & = ( lc_total_purity & * lc_prev_total_weight ) & / ( lc_prev_piece_weight & + lc_prev_total_weight ) end if next end if close piece_cursor; destroy ldsa_piece return
Even with the extra logic, the code of Listing 5 runs twenty times faster than Listing 3. In the real application there was a lot of other stuff going on so the improvement there was “only” about 10 to 1. Nevertheless, that was more than enough to change response time from minutes to seconds, from hopeless to acceptable.