5

We have a requirement to do some computational heavy lifting to connect with an Oracle database. Up until now, we have performed our numerical calculations in PL/SQL and largely put up with the lack of performance.

Now we are implementing a multi-echelon inventory model (https://www.researchgate.net/publication/222409130_Evaluation_of_time-varying_availability_in_multi-echelon_spare_parts_systems_with_passivation) I am much more concerned about the performance due to the size of the problem.

I have implemented part of the algorithm in three languages: Fortran (90-2008 complied with gfortran), VBA in Excel and PL/SQL, and wrapped a one-million-calls test loop around it. Even using binary_double data type and native compilation using PLSQL_CODE_TYPE=NATIVE (both of which result in an improvement) the test code below still takes 37s to run (Oracle XE 11.2). In comparison, VBA takes 16s and Fortran 1.6s on the same hardware.

While it would probably be too much to ask for performance approaching the Fortran figure (though this would obviously be highly desirable) I am surprised that even humble VBA out performs the PL/SQL.

So my question has two parts:

  1. Is there anything else I can try on the Oracle side to improve the performance further?
  2. If we consider moving away from PL/SQL, what alternatives should we consider for interfacing to the Oracle database? The heavy lifting will be called relatively seldom in a batch mode-like fashion, although a performance improvement to that approaching the speed of Fortran would allow us to consider introducing some of the heavy lifting in the the interactive parts of the application. My preferred language for numerical work remains Fortran for its ease of implementation of multi-dimensional arrays with masking.

Also, though I am not directly after a critique of my source code per se, I would be grateful if anyone can spot any obvious optimizations which I can incorporate.

The function timeebo is the test function, which I am invoking with a simple select timeebo from dual; in SQL Developer.

create or replace FUNCTION gammln(
    x IN binary_double)
  RETURN binary_double
IS
  --Lanczos' approximation to the Log Gamma function
  gammln binary_double;
  ser binary_double;
  tmp binary_double;
BEGIN
  tmp := x + 5.5;
  tmp :=(x + 0.5) * Ln(tmp) - tmp;
  ser := 1.000000000190015;
  ser := ser + 76.18009172947146 /(x + 1.0) ;
  ser := ser - 86.50532032941677 /(x + 2.0) ;
  ser := ser + 24.01409824083091 /(x + 3.0) ;
  ser := ser - 1.231739572450155 /(x + 4.0) ;
  ser := ser + 1.208650973866179E-03 /(x + 5.0) ;
  ser := ser - 5.395239384953E-06 /(x + 6.0) ;
  RETURN tmp + Ln(2.5066282746310005 * ser / x) ;
END;
/
CREATE OR REPLACE FUNCTION PoissonDist(
    k      IN INTEGER,
    lambda IN binary_double)
  RETURN binary_double
IS
BEGIN
  RETURN Exp((k * Ln(lambda)) - lambda - gammln(k + 1)) ;
END;
/
CREATE OR REPLACE FUNCTION EBO(
    stock    IN pls_integer,
    pipeline IN binary_double,
    accuracy IN binary_double DEFAULT 0.0000000001)
  RETURN binary_double
IS
  i pls_integer;
  EBO binary_double;
  term binary_double;
  temp binary_double;
  PoissonVal binary_double;
  peaked BOOLEAN; --Flag the Poisson curve as having peaked
BEGIN
  EBO        := 0.0;
  IF(pipeline = 0.0) THEN
    RETURN EBO;
  END IF;
--Initialise
i            := 1;
peaked       := false;
PoissonVal   := PoissonDist(stock + 1, pipeline) ;         --Get p() value
IF(PoissonVal < accuracy AND floor(pipeline) > stock) THEN --If p() is very
  -- small...
  i          := floor(pipeline)   - stock;         --Revise i to just below peak of Poisson curve
  PoissonVal := PoissonDist(stock + i, pipeline) ; --Get p() value close to
  -- peak
  temp := PoissonVal *(pipeline / CAST(stock + i + 1 AS binary_double)) ; --
  -- Store poisson value just above peak
  LOOP
    term       := CAST(i AS binary_double) * PoissonVal;
    EBO        := EBO                         + term;
    i          := i                           - 1; --Work backwards
    PoissonVal := PoissonVal                  *(CAST(stock + i + 1 AS DOUBLE
    PRECISION)                                / pipeline) ; --Revise Poisson
    -- value for next time
    EXIT
  WHEN(term < accuracy OR i = 0) ;
  END LOOP;
  i          := 1 + floor(pipeline) - stock;
  PoissonVal := temp;
  peaked     := true;
END IF;
LOOP
  term       := CAST(i AS binary_double) * PoissonVal;
  EBO        := EBO                         + term;
  i          := i                           + 1;
  PoissonVal := PoissonVal                  *(pipeline / CAST(stock + i AS
  binary_double)) ; --Revise Poisson value for next time
  IF(CAST(stock + i AS binary_double) > pipeline) THEN
    peaked                              := true;
  END IF;
  EXIT
WHEN(term < accuracy AND peaked) ;
END LOOP;
IF(EBO < accuracy) THEN
  EBO := 0.0;
END IF;
RETURN EBO;
END;
/
CREATE OR REPLACE FUNCTION timeebo
  RETURN binary_double
IS
  i pls_integer;
  EBOVal binary_double;
  acc binary_double;
BEGIN
  acc := 0.0;
  FOR i IN 1..1000000
  LOOP
    EBOVal := EBO(500, CAST(i AS binary_double) / 1000.0) ;
    acc    := acc                                + EBOVal;
  END LOOP;
RETURN acc;
END;
Matti Wens
  • 740
  • 6
  • 24
  • can you please why did you choose plsql for this numerical task ? – nabeel Oct 22 '16 at 16:24
  • Because the other 95+% of the application is written in Oracle APEX, the numerical code needs to access data in the database, and also to insert its results back into the database. – Matti Wens Oct 22 '16 at 17:01
  • 1
    Would Java be a better choice for numerical stuff inside an Oracle database? If the overhead of moving data from Oracle types into Java types (and vice versa) is not dominating the total execution time, Java may well be faster than PL/SQL. – Erwin Kalvelagen Oct 22 '16 at 18:07
  • in your code there is no DB access that i noticed :) – nabeel Oct 22 '16 at 19:08
  • Why not do all the calculations IN ORACLE (in plain SQL)? Take a look at the standard statistics packages, too - they may implement some (or all) of the statistical functions you use, so you don't need to do it by hand. –  Oct 22 '16 at 20:49
  • @nabeel - Indeed, there is no DB access in this code, as I wanted to test pure numerical performance. The entire multi-echelon inventory model is a considerably more complex numerical model. The code presented above is a small subset of it which was easily ported from Fortran to PL/SQL. – Matti Wens Oct 22 '16 at 21:27
  • @mathguy - please take a look at the EBO function. It involves a loop which is calculating smaller and smaller terms of an infinite series until a specified level of accuracy is met. If you can tell me how to do this in pure SQL, I would be very grateful to hear your solution. The full model uses considerably more complex iterative calculations, and with a representative set of data, takes several minutes of execution time in Fortran. Hence the need to investigate other possibilities. – Matti Wens Oct 22 '16 at 21:36
  • @ErwinKalvelagen - thanks. Speend comparisons between C/Fortran and Java seem to be pretty favourable (typically 2-4 times slower) so it may well be a viable solution. – Matti Wens Oct 22 '16 at 21:37
  • I will show a proof a concept in an Answer so I can format it properly. –  Oct 22 '16 at 22:35
  • [`pragma inline`](https://docs.oracle.com/cloud/latest/db112/LNPLS/inline_pragma.htm) could help with the function calls. I'd also assign initial values to variables in the declaration rather than adding an extra step, although it's possible the compiler already spots this. – William Robertson Oct 23 '16 at 10:02
  • Also, apologies for untested wild guess but would those casts be needed if `i` was a `binary_double` (in the places where it isn't a loop index) and you specified the literals directly as binary doubles e.g. `1d`? (See [NUMBER and Floating Point Literals](https://docs.oracle.com/cd/E11882_01/server.112/e41084/sql_elements003.htm#sthref357).) – William Robertson Oct 23 '16 at 10:12
  • Have you looked at using DBMS_Profiler https://docs.oracle.com/database/121/ARPLS/d_profil.htm#ARPLS67466 to see where your execution time is being spent? – David Aldridge Oct 23 '16 at 13:16
  • PL/SQL also has the data types SIMPLE_INTEGER and SIMPLE_DOUBLE, which do not check for null and for overflow. These checks take unexpected amounts of time in some cases; you may want to investigate their use (and appropriateness in your algorithms). –  Oct 23 '16 at 14:05
  • Something else I noticed - you compute the mass function for the Poisson distribution, using an approximation for the Gamma function. I would compute the Poisson mass function directly; you only need approximations for the factorial, not the full Gamma function. For n <= 100 you can calculate 1/n! just once and keep the values in a lookup table (1/100! still has 16 significant digits in Oracle - very likely enough for any computations you make); and for n > 100 you can use Stirling's approximation, which has relative error of the order 1/n^3, or Ramanujan's (two correction terms) - 1/n^5. –  Oct 23 '16 at 14:09
  • @WilliamRobertson - Eliminating the casting has had the largest effect of the suggested refinements. Doing everything in `binary_double` makes the code run around 3.5x faster, a hair over ten seconds. Thence applying `simple_double` and `pragma_inline` reduce this to around 9.8s on average. It will be interesting to see how much faster Fortran runs without casting, although I recall reading somewhere previously that it was a particular performance issue in PL/SQL. – Matti Wens Oct 23 '16 at 16:11
  • @mathguy - I will look again at the Poisson function, although the 'n' (or 'k') and lambda values in our case may be in the thousands, since they correspond to the stock level held at a given site. However, with such high values, approximations to the Poisson distribution will doubtless suffice. I used log-gamma since both Excel/VBA and Fortran 2008 have the function as an intrinsic, and it simplified the code prototyping. Only when porting to PL/SQL did I need to create my own function, but you are right that this solution is probably overly complex for the job in hand. – Matti Wens Oct 23 '16 at 16:14
  • If n (or k) is in the thousands, then the Stirling approximation should suffice. The relative error is of order 1/k^3, so for k = 1000 it is of the order 0.1 ^ 9. –  Oct 23 '16 at 16:27
  • 1
    @WilliamRobertson - Re: integer i used as loop counter prevents it from being simple_integer - that has a simple solution, use a WHILE loop instead of a FOR loop. –  Oct 23 '16 at 16:34
  • @DavidAldridge - I don't think `dbms_profiler` will help with natively compiled code (for obvious reasons - it's no longer interpreted!), so if the OP wants to profile his code, he will have to add his own timers within the code. –  Oct 23 '16 at 16:37

2 Answers2

3

This is not an answer to the OP's question (but it is a "proof of concept" to show how something like what he is doing in PL/SQL can be done in plain SQL). I only use the Answer format because what I will do below doesn't fit in a Comment.

The OP asked to see how the summation of an infinite series up to a desired degree of precision can be done in pure SQL. I will give two examples.

First example:

An infinite series with positive terms: e = sum [ j = 0 to infinity ] ( 1 / factorial(j) ). A trivial upper bound for the error for the sum from 0 to n is the last term added to the series. The recursive query below (which requires Oracle 11.1 or above - actually 11.2 the way I wrote it, with column names in the declaration, but it can be easily changed for 11.1) computes the value of e precise to 38 decimal places (the maximum precision available in Oracle). The inverse-factorial series for e converges very quickly; this only takes 35 steps and runs in less than 0.001 seconds on my old, home-class computer (which is just a big Dell tablet with a keyboard).

Edit: Duh! Only I can post something where e = 3.71828! Even though in the recursive query I add ALL the terms (including 1/0!), I started the sum from 1 instead of 0. (Corrected now, but had this mistake before the correction.)

with 
     rec ( j, s, next_term, err ) as (
       select  0, 0, 1, 2
         from  dual
       union all
       select  j+1, s + next_term, next_term/(j+1), next_term
         from  rec
         where err > power(10, -38) and j < 1000000000
     )
select max(j) as steps, round(max(s), 38) as e 
from   rec
;

STEPS                                         E
-----  ----------------------------------------
   35  2.71828182845904523536028747135266249776

Second example:

OK, so now let's take an alternating series (where the absolute value of the last term is always an upper bound for the error), and let's take a very slow-converging one:

ln( 2 ) = sum [ j = 1 to infinity ] ( (-1)^(j - 1) / j )

The query below computes ln(2), accurate to five decimal places; here we know beforehand we need 100,000 steps, and the computation on my machine took about 1.1 seconds. (Remember though, this is a VERY slowly converging series.)

with
     rec ( j, s, sgn ) as (
       select  0, 0, 1
         from  dual
       union all
       select  j+1, s + sgn / (j+1), -sgn
         from  rec
         where j <= 100000
     )
select 100000 as steps, round(s, 5) as ln_2
from   rec
where  j = 100000
;

 STEPS     LN_2
------  -------
100000  0.69314
  • Very useful, thanks. I wasn't aware that `with` could be self-referential. – Matti Wens Oct 23 '16 at 14:18
  • In response to your edit, I just assumed you were calculating e + 1. – Matti Wens Oct 25 '16 at 15:52
  • 1
    @MattWenham - no, I wrote the code one way, I didn't like it, I simplified it and made it more logical, but I forgot to change the initialization. Which can happen. But 3.71828 was staring me in the face, I looked at it several times and still didn't catch it, and that shouldn't happen. {:-) –  Oct 25 '16 at 15:57
  • FYI: Recursive `with` clauses arrived in Oracle 11gR2. But everybody should be using 12c by now so that's alright :) – APC Oct 25 '16 at 16:37
  • @APC - "everybody should be using 12c by now". Yeah, you say that... our clients only moved to 11gR2 this year, and are now in the process of upgrading to APEX 5. – Matti Wens Oct 25 '16 at 16:44
  • @APC - Recursive WITH clause arrived in 11.1, not in 11.2. –  Oct 25 '16 at 16:46
  • Really? Then it's very odd that the Recursive WITH clause appears in the New Features chapter of [the 11gR2 SQL Reference](http://docs.oracle.com/cd/E11882_01/server.112/e41084/wnsql.htm#SQLRF50929) – APC Oct 25 '16 at 20:48
  • @APC - sorry, my mistake - you are right, it was new in 11.2 –  Oct 25 '16 at 20:58
1

To summarise my findings based on the suggestions in the above comments:

Eliminating the casting has had the largest effect of the suggested refinements. Doing everything in binary_double makes the code run around 3.5x faster, a hair over ten seconds. Thence applying simple_double and pragma_inline reduce this to around 9.8s on average.

The current version of my code, with the above changes applied is as follows. The PoissonDist function could be optimized further, but the original question was more about how to make existing code run faster, rather than the optimization of the code per se.

EDIT: The code has been revised to reflect the correct use of pragma_inline, which has reduced the execution time to around 9.5s.

create or replace FUNCTION gammln(
    x IN simple_double)
  RETURN simple_double
IS
  --Lanczos' approximation to the Log Gamma function
  ser simple_double := 1.000000000190015d;
  tmp simple_double := x + 5.5d;
BEGIN
  tmp :=(x + 0.5d) * Ln(tmp) - tmp;
  ser := ser + 76.18009172947146d /(x + 1.0d) ;
  ser := ser - 86.50532032941677d /(x + 2.0d) ;
  ser := ser + 24.01409824083091d /(x + 3.0d) ;
  ser := ser - 1.231739572450155d /(x + 4.0d) ;
  ser := ser + 1.208650973866179E-03d /(x + 5.0d) ;
  ser := ser - 5.395239384953E-06d /(x + 6.0d) ;
  RETURN tmp + Ln(2.5066282746310005d * ser / x) ;
END;
/
create or replace FUNCTION PoissonDist(
    k      IN simple_double,
    lambda IN simple_double)
  RETURN simple_double
IS
BEGIN
  PRAGMA INLINE (gammln, 'YES');
  RETURN Exp((k * Ln(lambda)) - lambda - gammln(k + 1d)) ;
END;
/
CREATE OR REPLACE FUNCTION EBO(
    stock    IN simple_double,
    pipeline IN simple_double,
    accuracy IN simple_double DEFAULT 0.0000000001)
  RETURN simple_double
IS
  i simple_double          := 1d;
  EBO simple_double        := 0d;
  term simple_double       := 0d;
  temp simple_double       := 0d;
  PRAGMA INLINE(PoissonDist, 'YES') ;
  PoissonVal simple_double := PoissonDist(stock + 1d, pipeline) ;
  peaked BOOLEAN           := false; --Flag the Poisson curve as having peaked
BEGIN
  IF(pipeline = 0.0d) THEN
    RETURN EBO;
  END IF;
IF(PoissonVal < accuracy AND floor(pipeline) > stock) THEN --If p() is very
  -- small...
  i          := floor(pipeline)   - stock;         --Revise i to just below peak of Poisson curve
  PRAGMA INLINE(PoissonDist, 'YES') ;
  PoissonVal := PoissonDist(stock + i, pipeline) ; --Get p() value close to
  -- peak
  temp := PoissonVal *(pipeline /(stock + i + 1d)) ; --
  -- Store poisson value just above peak
  LOOP
    term       := i          * PoissonVal;
    EBO        := EBO        + term;
    i          := i          - 1d;                           --Work backwards
    PoissonVal := PoissonVal *((stock + i + 1) / pipeline) ; --Revise Poisson
    -- value for next time
    EXIT
  WHEN(term < accuracy OR i = 0d) ;
  END LOOP;
  i          := 1d + floor(pipeline) - stock;
  PoissonVal := temp;
  peaked     := true;
END IF;
LOOP
  term       := i          * PoissonVal;
  EBO        := EBO        + term;
  i          := i          + 1d;
  PoissonVal := PoissonVal *(pipeline /(stock + i)) ; --Revise Poisson value
  -- for next time
  IF((stock + i) > pipeline) THEN
    peaked      := true;
  END IF;
  EXIT
WHEN(term < accuracy AND peaked) ;
END LOOP;
IF(EBO < accuracy) THEN
  EBO := 0.0d;
END IF;
RETURN EBO;
END;
/
create or replace FUNCTION timeebo
  RETURN binary_double
IS
  i binary_double;
  EBOVal binary_double;
  acc binary_double;
BEGIN
  acc := 0.0d;
  FOR i IN 1d..1000000d
  LOOP
    PRAGMA INLINE (EBO, 'YES');
    EBOVal := EBO(500d, i / 1000d) ;
    acc    := acc                                + EBOVal;
  END LOOP;
RETURN acc;
END;
Matti Wens
  • 740
  • 6
  • 24
  • Thanks for the feedback. Might be interesting to see the final version. – William Robertson Oct 24 '16 at 14:56
  • Current version of code added above, thanks for the interest. – Matti Wens Oct 25 '16 at 15:44
  • I don't think the `pragma inline` does anything unless it's immediately before the call. (If you enable compiler warnings with `alter session set plsql_warnings = 'ENABLE:ALL', 'DISABLE:5018';` or via your tool preference settings you may see warnings like `PLW-05011: pragma INLINE for procedure 'GAMMLN' does not apply to any calls`, which change when you move it to `PLW-06004: inlining of call of procedure 'GAMMLN' requested`.) Or just set `plsql_optimizer_level = 3` and have it inline everything automatically. – William Robertson Oct 25 '16 at 16:09
  • @WilliamRobertson - Having re-read the documentation, you appear to be right... which is odd, since the alterations above did appear to make an improvement to the execution time. – Matti Wens Oct 25 '16 at 16:42
  • I'm dying to know how much improvement you got when you fixed it. – William Robertson Oct 26 '16 at 22:06
  • @WilliamRobertson - Now clocking in at around 9.5s, I will revise the code above to reflect the latest changes. – Matti Wens Oct 27 '16 at 14:08