As others have mentioned, this is best done in assembly.  Here is some code to try:

    void delay_sub(unsigned char i)
    {
    // convert 20, 21, 22 etc to count in R7 of 1, 2, 3 (extra cycle added if i is odd)
                        ; cycles
        rrc A           ; 1		       c = 1 if odd
        jnc             ; 2 or 4       extra 2 cycles if branch taken (spoils cache)
        nop             ; 1
        nop             ; 1
        clc             ; 1
    even:
        subb A,#6       ; 1
                                    
        mov R7,A        ; 1	           R7 now = (i / 2) - 6
        //while (i--);
    loop:
        djnz R7, loop   ; 2     loop address should be in cache, so no extra cycles needed
        ret             ; 6
    }
    
    if i even:
    	5+7+R7*2+6 = minimum of 20 22 24 ... R7 = 1, 2, 3
    if i odd:
    	5+8+R7*2+6 = minimum of 21 23 25 ... R7 = 1, 2, 3

It assumes a call is made like LCALL(nn), where nn is a constant or a variable in a byte variable, so that the paramter can be passed using a MOV A, instruction.

The call will take 5 cycles.  The fixed overhead in the routine is either 7 cycles if i is even, or 8 if i is odd.  A count is generated and put into R7, which is then decremented in a tight loop (two cycles per count).  Then there is a fixed cycle count of 6 for the return.

The behavior of the jump instructions are based on the datasheet for the C8051F38x as I understand them (in terms of when the instruction cache is spoiled or not).  This may be different for other versions of the 8051.