Python performance with Blender operators
Most operators cause implicit scene updates. It means that every object in the scene is checked and updated if necessary. If you add e.g. mesh primitives using bpy.ops.mesh.primitive_cube_add() in a loop, every iteration creates a new cube, starts a scene update and Blender iterates over all objects in the scene and updates objects if it has to.
If you start with 0 objects, there will be 1 in the first iteration and 1 object needs to be checked in the scene update. In the second iteration, there will be 2 objects and 2 be checked. The first object was checked in the first iteration already (thus, 3 object updates in total). In the third iteration, there will be 3 objects and 3 + 2 + 1 = 6 objects checked in total. In iteration 1000, there will be 1000 objects and 500,500 checks have been carried out. Here's the formula where n is the number of objects:
$\displaystyle \sum_{n=1}^n i = 1 + 2 + ... +  n = \frac {n (n + 1)} {2} $
As you see, the runtime isn't linear and could only be if there was only one update for every object after all have been added. You need to use the &quot;low-level&quot; API - RNA methods and attributes - instead of operators to achieve better runtimes. A scene update needs to be called manually like bpy.context.scene.update() with this approach.
Many, but not all operator calls can be somehow replaced by &quot;low-level&quot; code. You can duplicate objects very efficiently like:
import bpy
from mathutils import Vector

ob = bpy.context.object
obs = []
sce = bpy.context.scene

for i in range(-48, 48, 3):
    for j in range(-48, 48, 3):
        copy = ob.copy()
        copy.location += Vector((i, j, 0))
        copy.data = copy.data.copy() # also duplicate mesh, remove for linked duplicate
        obs.append(copy)

for ob in obs:
    sce.objects.link(ob)

sce.update() # don't place this in either of the above loops!

A good comparison between 4 different ways to do the same thing:

bpy.ops.anim.keyframe_insert_menu() - don't ever use this in a script, it is solely to show a menu for the user

bpy.ops.anim.keyframe_insert() - this is supposed to be used via the UI, not in script. Use operator calls only if there is no lower-level API!

Object.keyframe_insert() - RNA method that can be called on an object, better

The low-level way - add F-Curves and keyframe_points manually, fastest but you need to do alot yourself and consider several conditions (like object not having animation_data or an animation_data.action)


Related (also examples included):

Python slowing down over time