05 мая 2010

Вред преждевременной оптимизации

   Некто из великих сказал "Преждевременная оптимизация - корень всех зол" ("Premature optimization is the root of all evil"). Точно автор этих мудрых слов не известен, но возможно это был Дональд Кнут или Энтони Хоар или Эдсгер Дейкстра. Подробнее о исследованиях на эту тему можно посмотреть тут или тут.
   Недавно я сам убедился в правдивости этой фразы. В одном из очень старых модулей на некоторую сумму денег хитрым образом начислялись проценты. На протяжении многих лет это начисление делалось первого числа каждого месяца (ежемесячно), но у руководства компании родилась идея начислять проценты раз в год (ежегодно). Теоретически, если проценты рассчитывать ежемесячно в течение года, то мы должны получить такую же сумму, как и при одном расчете сразу за весь год (из-за округлений можно допустить копеечную разницу). Практически же, попытка сделать ежегодное начисление на тестовом договоре, дала разницу в несколько тысяч рублей.
   Начал я разбираться. Сначала проверил заложенную в код логику расчета – все правильно. Потом стал смотреть код под отладчиком. Дошел до куска кода, в котором в цикле подсчитывается, сколько дней действовала каждая процентная ставка (для выбора дальнейшей формулы):

var
  iDayCount1, iDayCount2: Word;
  iDayRefCount: Byte;
  ........
  iDayCount1 := 0;
  iDayRefCount := 0;

  // начало цикла
  iDayCount2 := ...;
  If "правильное условие в этом тесте" then
    begin
      ...
      Inc(iDayRefCount, iDayCount2);
    end;
  Inc(iDayCount1, iDayCount2);
  // конец цикла
  ...

Первый проход цикла:
 • Ставка действовала 31 день (iDayCount2 = 31)
 • Начисление процентов сделано за 31 день (iDayRefCount = 31)
 • Общее количество обработанных дней в периоде – 31 (iDayCount1 = 31)
Второй проход цикла:
 • Ставка действовала 327 день (iDayCount2 = 327)
 • Начисление процентов сделано за 102 дня (iDayRefCount = 102)
 • Общее количество обработанных дней в периоде – 358 (iDayCount1 = 358)

   Стоп! При одинаковых значениях переменных результат выражения Inc(iDayRefCount, iDayCount2) – 102, а результат Inc(iDayCount1, iDayCount2) – 358. Чудеса? Нет! Переменная iDayRefCount объявлена, как Byte (unsigned 8-bit, 0..255), а переменная iDayCount1, как Word (unsigned 16-bit, 0..65535). Оказалось, что когда начисление делалось ежемесячно, размера переменной типа Byte хватало для хранения количества дней, за которые сделано начисление процентов (их было от 0 до 31). А ежегодный расчет вызвал искажение данных за счет её переполнения.
   Вот так сэкономленный много лет тому назад один байт памяти, мог привести компанию к большим убыткам.

4 комментария:

  1. Этот код случайно не банк "Русский Стандарт" использовал? Буквально недавно заметил "странную" математику в их расчётах - кидаешь на счёт 1000 руб - на счёту (что логично) 1000, через день бросаешь ещё 1000 и...на счёту 1900. WTF???!
    А по сабжу - пару раз сталкивался с такими чудесами, как в статье, но слава богу денег проги эти не касались. Сейчас в основном сталкиваюсь с проблемами округления малых чисел (меньше 0,0000001) - часто встречаю в программах тип single при делении в результате на выходе получаются искаженные данные

    ОтветитьУдалить
  2. С каких это пор использование типов требующих расширения значеня до полной ширины арифметических регистров процессора считается оптимизацией?
    Это просто ощибка, нифига не оптимизация...

    ОтветитьУдалить
  3. Анонимный15 ноября, 2010 21:40

    Это не ошибка оптимизации, а ошибка программиста.
    Читаем хелп:
    "In general, arithmetic operations on integers return a value of type Integer--which, in its current implementation, is equivalent to the 32-bit Longint. Operations return a value of type Int64 only when performed on one or more Int64 operand. Hence the following code produces incorrect results.

    var
    I: Integer;
    J: Int64;
    ...
    I := High(Integer);
    J := I + 1;

    To get an Int64 return value in this situation, cast I as Int64:

    ...
    J := Int64(I) + 1;

    For more information, see Arithmetic operators.

    Note

    Some standard routines that take integer arguments truncate Int64 values to 32 bits. However, the High, Low, Succ, Pred, Inc, Dec, IntToStr, and IntToHex routines fully support Int64 arguments. Also, the Round, Trunc, StrToInt64, and StrToInt64Def functions return Int64 values. A few routines cannot take Int64 values at all.

    When you increment the last value or decrement the first value of an integer type, the result wraps around the beginning or end of the range. For example, the Shortint type has the range -128..127; hence, after execution of the code

    var I: Shortint;
    ...
    I := High(Shortint);
    I := I + 1;

    the value of I is -128. If compiler range-checking is enabled, however, this code generates a runtime error."

    Так что винить надо только себя.

    ОтветитьУдалить
  4. Себя? А вы уверены, что код мой? ;)

    Для меня причина применения Byte тут очевидна, я сам так в юности делал - экономил на спичках. В данном случае видимо было "соптимизировано" потребление памяти, используя Byte вместо Word. Зачем было использовать больший тип, если в Byte спокойно влезет 31 день (для которого и писали расчет)? Так, что изначально ошибки в алгоритме нет и проработал он много лет без проблем. Но, когда расчет был сделан для года, ошибки выполнения не последовало из-за отключенной проверки диапазона, процедура отработала, только не верно посчитала :)

    P.S. Я только не понял, что вы хотели сказать этим куском хелпа про то, что в большинстве случаев стандартные функции работают с Integer? В приведенном кусочке кода даже намека на применение функций нет. Так что он не подтверждает слова "Так что винить надо только себя". А винить тут не кого - алгоритм правильно работает.

    ОтветитьУдалить